feng-x commited on
Commit
4fa3ab9
·
verified ·
1 Parent(s): 877155b

Upload folder using huggingface_hub

Browse files
.gitignore CHANGED
@@ -38,3 +38,10 @@ input/*.heic
38
  .DS_Store
39
  Thumbs.db
40
 
 
 
 
 
 
 
 
 
38
  .DS_Store
39
  Thumbs.db
40
 
41
+ # Claude Code session metadata
42
+ .claude/
43
+
44
+ # Local TLS certs (mkcert) — private key MUST NOT be committed.
45
+ web_demo/certs/
46
+ *.pem
47
+
script/analyze_hand_span.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Hand-span ratio distribution analysis for the web-preview distance gate.
3
+
4
+ For each image in `input/kol_total` ∪ `input/kol_success`:
5
+ 1. Run MediaPipe Hands once on the original frame; compute
6
+ hand_span_ratio = ||landmark[5] - landmark[17]|| / min(W, H).
7
+ 2. Run the full measurement pipeline once (cached) to get the authoritative
8
+ fail_reason. This is the only way to cleanly separate `card_too_small`
9
+ from other failure modes (`hand_not_detected`, `card_not_parallel`, etc.).
10
+ 3. Bucket ratios by fail_reason and print percentile stats so we can pick
11
+ the preview "close enough" threshold (target: P10 of the success bucket
12
+ to be conservative for small-handed users).
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import cv2
22
+ import mediapipe as mp
23
+ import numpy as np
24
+ from mediapipe.tasks import python as mp_python
25
+ from mediapipe.tasks.python import vision as mp_vision
26
+
27
+ # Landmarks for the palm-MCP span (index MCP and pinky MCP).
28
+ LM_INDEX_MCP = 5
29
+ LM_PINKY_MCP = 17
30
+
31
+ IMG_EXTS = {".jpg", ".jpeg", ".png"}
32
+
33
+ # Reuse the same MediaPipe Tasks model the pipeline already downloads.
34
+ MODEL_PATH = (
35
+ Path(__file__).resolve().parent.parent / ".model" / "hand_landmarker.task"
36
+ )
37
+
38
+ _detector = None
39
+
40
+
41
+ def _get_detector():
42
+ global _detector
43
+ if _detector is None:
44
+ if not MODEL_PATH.exists():
45
+ raise FileNotFoundError(
46
+ f"hand_landmarker.task missing at {MODEL_PATH} — run measure_finger.py once to trigger the auto-download."
47
+ )
48
+ opts = mp_vision.HandLandmarkerOptions(
49
+ base_options=mp_python.BaseOptions(model_asset_path=str(MODEL_PATH)),
50
+ num_hands=1,
51
+ min_hand_detection_confidence=0.3,
52
+ min_tracking_confidence=0.3,
53
+ )
54
+ _detector = mp_vision.HandLandmarker.create_from_options(opts)
55
+ return _detector
56
+
57
+
58
+ def hand_span_ratio(image_path: Path):
59
+ """Return (ratio, h, w, detected). Try 4 rotations like the pipeline does;
60
+ the ratio is rotation-invariant so we just take whichever rotation detects
61
+ a hand with highest confidence."""
62
+ img = cv2.imread(str(image_path))
63
+ if img is None:
64
+ return None, None, None, False
65
+ h0, w0 = img.shape[:2]
66
+
67
+ rotations = [
68
+ (img, 0),
69
+ (cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE), 1),
70
+ (cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE), 3),
71
+ (cv2.rotate(img, cv2.ROTATE_180), 2),
72
+ ]
73
+
74
+ detector = _get_detector()
75
+ best_score = -1.0
76
+ best_lm = None
77
+ best_hw = None
78
+
79
+ for rotated, _code in rotations:
80
+ rgb = cv2.cvtColor(rotated, cv2.COLOR_BGR2RGB)
81
+ rgb = np.ascontiguousarray(rgb)
82
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
83
+ res = detector.detect(mp_image)
84
+ if not res.hand_landmarks:
85
+ continue
86
+ score = res.handedness[0][0].score if res.handedness else 0.0
87
+ if score > best_score:
88
+ best_score = score
89
+ best_lm = res.hand_landmarks[0]
90
+ best_hw = rotated.shape[:2]
91
+
92
+ if best_lm is None:
93
+ return None, h0, w0, False
94
+
95
+ rh, rw = best_hw
96
+ p5 = np.array([best_lm[LM_INDEX_MCP].x * rw, best_lm[LM_INDEX_MCP].y * rh])
97
+ p17 = np.array([best_lm[LM_PINKY_MCP].x * rw, best_lm[LM_PINKY_MCP].y * rh])
98
+ span_px = float(np.linalg.norm(p5 - p17))
99
+ short_side = min(rh, rw) # rotation-invariant
100
+ return span_px / short_side, h0, w0, True
101
+
102
+
103
+ def measure_fail_reason(image_path: Path, cache_dir: Path, base: Path) -> dict:
104
+ """Run measure_finger.py once (cached) and return its result dict."""
105
+ out_json = cache_dir / f"{image_path.stem}.json"
106
+ if not out_json.exists():
107
+ cmd = [
108
+ sys.executable, "measure_finger.py",
109
+ "--input", str(image_path),
110
+ "--output", str(out_json),
111
+ "--finger-index", "index",
112
+ "--card-method", "sam",
113
+ "--no-calibration",
114
+ ]
115
+ try:
116
+ subprocess.run(
117
+ cmd, capture_output=True, text=True, timeout=180, cwd=base,
118
+ )
119
+ except subprocess.TimeoutExpired:
120
+ return {"fail_reason": "timeout"}
121
+ if out_json.exists():
122
+ with open(out_json) as f:
123
+ return json.load(f)
124
+ return {"fail_reason": "no_output"}
125
+
126
+
127
+ def percentiles(values, ps=(10, 25, 50, 75, 90)):
128
+ if not values:
129
+ return {p: None for p in ps}
130
+ arr = np.array(values)
131
+ return {p: float(np.percentile(arr, p)) for p in ps}
132
+
133
+
134
+ def main():
135
+ base = Path(__file__).resolve().parent.parent
136
+ os.chdir(base)
137
+
138
+ # Union of kol_total and kol_success, dedup by filename.
139
+ by_name = {}
140
+ for d in ("kol_total", "kol_success"):
141
+ folder = base / "input" / d
142
+ if not folder.is_dir():
143
+ continue
144
+ for p in folder.iterdir():
145
+ if p.suffix.lower() in IMG_EXTS:
146
+ by_name.setdefault(p.name, p)
147
+ images = sorted(by_name.values(), key=lambda p: p.name)
148
+ print(f"Found {len(images)} unique images across kol_total ∪ kol_success")
149
+
150
+ cache_dir = base / "output" / "hand_span_analysis"
151
+ cache_dir.mkdir(parents=True, exist_ok=True)
152
+
153
+ results = []
154
+ for i, img_path in enumerate(images, 1):
155
+ print(f"[{i}/{len(images)}] {img_path.name}", end=" ", flush=True)
156
+
157
+ ratio, h, w, detected = hand_span_ratio(img_path)
158
+ meas = measure_fail_reason(img_path, cache_dir, base)
159
+ fail = meas.get("fail_reason")
160
+
161
+ results.append({
162
+ "image": img_path.name,
163
+ "h": h, "w": w,
164
+ "hand_detected_mediapipe": detected,
165
+ "hand_span_ratio": ratio,
166
+ "fail_reason": fail,
167
+ "scale_px_per_cm": meas.get("scale_px_per_cm"),
168
+ })
169
+ print(f"ratio={ratio if ratio is None else f'{ratio:.3f}'} fail={fail}")
170
+
171
+ out_json = cache_dir / "hand_span_results.json"
172
+ with open(out_json, "w") as f:
173
+ json.dump(results, f, indent=2)
174
+ print(f"\nSaved {out_json}")
175
+
176
+ # Bucket by fail_reason.
177
+ buckets: dict[str, list[float]] = {}
178
+ for r in results:
179
+ if r["hand_span_ratio"] is None:
180
+ key = "_mediapipe_no_hand"
181
+ else:
182
+ key = r["fail_reason"] or "success"
183
+ buckets.setdefault(key, []).append(r["hand_span_ratio"])
184
+
185
+ print("\n=== hand_span_ratio distribution by fail_reason ===")
186
+ print(f"{'bucket':<30} {'n':>4} {'P10':>6} {'P25':>6} {'P50':>6} {'P75':>6} {'P90':>6} mean")
187
+ for key in sorted(buckets, key=lambda k: (-len(buckets[k]), k)):
188
+ vals = buckets[key]
189
+ ps = percentiles(vals)
190
+ mean = float(np.mean(vals))
191
+ line = (
192
+ f"{key:<30} {len(vals):>4} "
193
+ f"{ps[10]:.3f} {ps[25]:.3f} {ps[50]:.3f} {ps[75]:.3f} {ps[90]:.3f} {mean:.3f}"
194
+ )
195
+ print(line)
196
+
197
+ no_hand = sum(1 for r in results if not r["hand_detected_mediapipe"])
198
+ print(f"\nMediaPipe failed to detect a hand on {no_hand}/{len(results)} images.")
199
+
200
+ # Threshold suggestion.
201
+ success = buckets.get("success", [])
202
+ too_small = buckets.get("card_too_small", [])
203
+ if success:
204
+ p10_success = float(np.percentile(success, 10))
205
+ print(f"\nSuggested preview gate: hand_span_ratio >= {p10_success:.3f}")
206
+ print(f" (P10 of success cohort, conservative for small-handed users)")
207
+ if too_small:
208
+ below = sum(1 for v in too_small if v < p10_success)
209
+ print(f" {below}/{len(too_small)} card_too_small samples fall below this gate.")
210
+ above = sum(1 for v in success if v >= p10_success)
211
+ print(f" {above}/{len(success)} success samples pass this gate (= 90% by construction).")
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
web_demo/app.py CHANGED
@@ -22,7 +22,7 @@ from typing import Any, Dict, List, Optional, Tuple
22
 
23
  import cv2
24
  import numpy as np
25
- from flask import Flask, Response, jsonify, render_template, request, send_from_directory
26
  from werkzeug.utils import secure_filename
27
 
28
  ROOT_DIR = Path(__file__).resolve().parents[1]
@@ -55,6 +55,8 @@ DEFAULT_SAMPLE_URL = "/static/examples/default_sample.jpg"
55
  ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
56
  DEMO_EDGE_METHOD = "mask"
57
  DEMO_CARD_METHOD = "sam"
 
 
58
 
59
  app = Flask(__name__)
60
 
@@ -145,25 +147,79 @@ def _save_json(path: Path, data: Dict[str, Any]) -> None:
145
  json.dump(data, f, indent=2, ensure_ascii=False)
146
 
147
 
148
- def _read_form_settings() -> Dict[str, str]:
149
  """Parse the measurement-request form fields shared by /api/measure and
150
  /api/measure-default."""
151
  ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
152
  if ring_model not in VALID_RING_MODELS:
153
  ring_model = DEFAULT_RING_MODEL
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  return {
155
  "finger_index": request.form.get("finger_index", "index"),
156
  "mode": request.form.get("mode", "single"),
157
  "kol_name": request.form.get("kol_name", "").strip(),
158
  "ring_model": ring_model,
 
 
159
  }
160
 
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  @app.route("/")
163
  def index():
 
 
 
 
 
 
 
164
  return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=False)
165
 
166
 
 
 
 
 
 
167
  @app.route("/dev")
168
  @app.route("/debug")
169
  def index_dev():
@@ -209,6 +265,8 @@ def api_measure():
209
  input_image_url=f"/uploads/{upload_name}",
210
  ring_model=settings["ring_model"],
211
  kol_name=settings["kol_name"],
 
 
212
  upload_path=upload_path,
213
  upload_name=upload_name,
214
  base_name=base_name,
@@ -236,6 +294,8 @@ def api_measure_default():
236
  input_image_url=DEFAULT_SAMPLE_URL,
237
  ring_model=settings["ring_model"],
238
  kol_name=settings["kol_name"],
 
 
239
  base_name=base_name,
240
  run_id=run_id,
241
  )
@@ -250,6 +310,8 @@ def _run_measurement(
250
  input_image_url: str,
251
  ring_model: str = DEFAULT_RING_MODEL,
252
  kol_name: str = "",
 
 
253
  upload_path: Optional[Path] = None,
254
  upload_name: str = "",
255
  base_name: str = "",
@@ -279,6 +341,9 @@ def _run_measurement(
279
  if rec:
280
  result["ring_size"] = rec
281
 
 
 
 
282
  result = _numpy_safe(result)
283
 
284
  result_json_name = f"{base_name}_result.json"
@@ -325,6 +390,8 @@ def _run_multi_measurement(
325
  input_image_url: str,
326
  ring_model: str = DEFAULT_RING_MODEL,
327
  kol_name: str = "",
 
 
328
  upload_path: Optional[Path] = None,
329
  upload_name: str = "",
330
  base_name: str = "",
@@ -344,6 +411,9 @@ def _run_multi_measurement(
344
  ring_model=ring_model,
345
  )
346
 
 
 
 
347
  result = _numpy_safe(result)
348
 
349
  # Collect finger widths for AI recommendation
@@ -744,4 +814,11 @@ def api_admin_export_csv():
744
 
745
 
746
  if __name__ == "__main__":
747
- app.run(host="0.0.0.0", port=8000, debug=True)
 
 
 
 
 
 
 
 
22
 
23
  import cv2
24
  import numpy as np
25
+ from flask import Flask, Response, jsonify, redirect, render_template, request, send_from_directory
26
  from werkzeug.utils import secure_filename
27
 
28
  ROOT_DIR = Path(__file__).resolve().parents[1]
 
55
  ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
56
  DEMO_EDGE_METHOD = "mask"
57
  DEMO_CARD_METHOD = "sam"
58
+ VALID_CAPTURE_METHODS = {"upload", "camera"}
59
+ DEFAULT_CAPTURE_METHOD = "upload"
60
 
61
  app = Flask(__name__)
62
 
 
147
  json.dump(data, f, indent=2, ensure_ascii=False)
148
 
149
 
150
+ def _read_form_settings() -> Dict[str, Any]:
151
  """Parse the measurement-request form fields shared by /api/measure and
152
  /api/measure-default."""
153
  ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
154
  if ring_model not in VALID_RING_MODELS:
155
  ring_model = DEFAULT_RING_MODEL
156
+ capture_method = request.form.get("capture_method", DEFAULT_CAPTURE_METHOD)
157
+ if capture_method not in VALID_CAPTURE_METHODS:
158
+ capture_method = DEFAULT_CAPTURE_METHOD
159
+
160
+ # gate_telemetry is a JSON-encoded blob from the v5 capture coach. Schema
161
+ # is intentionally fluid — server stamps it into result_json verbatim so
162
+ # frontend can evolve the payload without server changes. Hard-cap the
163
+ # raw size at 10 KB so a malicious client can't pad result_json (and
164
+ # Supabase rows) with arbitrary bytes; the legitimate payload is on the
165
+ # order of a few hundred bytes.
166
+ gate_telemetry: Optional[Dict[str, Any]] = None
167
+ raw = request.form.get("gate_telemetry")
168
+ if raw:
169
+ if len(raw) > 10_000:
170
+ logger.warning("dropping oversized gate_telemetry payload (%d bytes)", len(raw))
171
+ else:
172
+ try:
173
+ parsed = json.loads(raw)
174
+ if isinstance(parsed, dict):
175
+ gate_telemetry = parsed
176
+ except json.JSONDecodeError:
177
+ logger.warning("dropping malformed gate_telemetry payload")
178
+
179
  return {
180
  "finger_index": request.form.get("finger_index", "index"),
181
  "mode": request.form.get("mode", "single"),
182
  "kol_name": request.form.get("kol_name", "").strip(),
183
  "ring_model": ring_model,
184
+ "capture_method": capture_method,
185
+ "gate_telemetry": gate_telemetry,
186
  }
187
 
188
 
189
+ # Coarse UA sniff — only used to decide which surface (`index.html` vs
190
+ # `mobile.html`) to render at `/`. We deliberately use a simple regex
191
+ # rather than a UA-parsing library: false negatives just mean a phone
192
+ # user sees the desktop page, which they can recover from with the
193
+ # `?desktop=1` override; false positives are harmless because the
194
+ # desktop user just sees the mobile page (and can use `?desktop=1`
195
+ # from there). Not worth a heavy dependency.
196
+ _MOBILE_UA_RE = re.compile(
197
+ r"iphone|ipod|android.+mobile|blackberry|iemobile|opera mini|windows phone",
198
+ re.IGNORECASE,
199
+ )
200
+
201
+
202
+ def _is_mobile_ua(user_agent_str: str) -> bool:
203
+ return bool(user_agent_str and _MOBILE_UA_RE.search(user_agent_str))
204
+
205
+
206
  @app.route("/")
207
  def index():
208
+ # Phone visitors get auto-redirected to the mobile flow unless they
209
+ # opt out with `?desktop=1`. The override lets the dev demo from a
210
+ # phone in desktop mode and lets us share desktop links that hold
211
+ # up regardless of the recipient's device.
212
+ force_desktop = request.args.get("desktop") is not None
213
+ if not force_desktop and _is_mobile_ua(request.headers.get("User-Agent", "")):
214
+ return redirect("/m", code=302)
215
  return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=False)
216
 
217
 
218
+ @app.route("/m")
219
+ def index_mobile():
220
+ return render_template("mobile.html")
221
+
222
+
223
  @app.route("/dev")
224
  @app.route("/debug")
225
  def index_dev():
 
265
  input_image_url=f"/uploads/{upload_name}",
266
  ring_model=settings["ring_model"],
267
  kol_name=settings["kol_name"],
268
+ capture_method=settings["capture_method"],
269
+ gate_telemetry=settings["gate_telemetry"],
270
  upload_path=upload_path,
271
  upload_name=upload_name,
272
  base_name=base_name,
 
294
  input_image_url=DEFAULT_SAMPLE_URL,
295
  ring_model=settings["ring_model"],
296
  kol_name=settings["kol_name"],
297
+ capture_method=settings["capture_method"],
298
+ gate_telemetry=settings["gate_telemetry"],
299
  base_name=base_name,
300
  run_id=run_id,
301
  )
 
310
  input_image_url: str,
311
  ring_model: str = DEFAULT_RING_MODEL,
312
  kol_name: str = "",
313
+ capture_method: str = DEFAULT_CAPTURE_METHOD,
314
+ gate_telemetry: Optional[Dict[str, Any]] = None,
315
  upload_path: Optional[Path] = None,
316
  upload_name: str = "",
317
  base_name: str = "",
 
341
  if rec:
342
  result["ring_size"] = rec
343
 
344
+ result["capture_method"] = capture_method
345
+ if gate_telemetry is not None:
346
+ result["gate_telemetry"] = gate_telemetry
347
  result = _numpy_safe(result)
348
 
349
  result_json_name = f"{base_name}_result.json"
 
390
  input_image_url: str,
391
  ring_model: str = DEFAULT_RING_MODEL,
392
  kol_name: str = "",
393
+ capture_method: str = DEFAULT_CAPTURE_METHOD,
394
+ gate_telemetry: Optional[Dict[str, Any]] = None,
395
  upload_path: Optional[Path] = None,
396
  upload_name: str = "",
397
  base_name: str = "",
 
411
  ring_model=ring_model,
412
  )
413
 
414
+ result["capture_method"] = capture_method
415
+ if gate_telemetry is not None:
416
+ result["gate_telemetry"] = gate_telemetry
417
  result = _numpy_safe(result)
418
 
419
  # Collect finger widths for AI recommendation
 
814
 
815
 
816
  if __name__ == "__main__":
817
+ # Local-dev HTTPS via mkcert: set RING_DEV_TLS_CERT and RING_DEV_TLS_KEY
818
+ # to enable HTTPS on the dev server (required for getUserMedia on a
819
+ # phone over LAN). HF Space deploys leave these unset and serve HTTP
820
+ # behind HF's platform-level TLS terminator.
821
+ cert = os.environ.get("RING_DEV_TLS_CERT")
822
+ key = os.environ.get("RING_DEV_TLS_KEY")
823
+ ssl_context = (cert, key) if cert and key else None
824
+ app.run(host="0.0.0.0", port=8000, debug=True, ssl_context=ssl_context)
web_demo/static/mobile/mobile.css ADDED
@@ -0,0 +1,843 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
10
+ * {
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ [hidden] {
15
+ display: none !important;
16
+ }
17
+
18
+ html,
19
+ body {
20
+ margin: 0;
21
+ padding: 0;
22
+ width: 100%;
23
+ height: 100%;
24
+ background: var(--bg-1);
25
+ color: var(--ink);
26
+ font-family: "Iowan Old Style", "Palatino", "Book Antiqua", "Times New Roman", serif;
27
+ -webkit-font-smoothing: antialiased;
28
+ overflow: hidden;
29
+ }
30
+
31
+ #mobileRoot {
32
+ width: 100%;
33
+ /* Cascade order: each later rule overrides on browsers supporting
34
+ that unit. Old browsers stop at vh; modern iOS Safari reaches
35
+ dvh which tracks URL-bar transitions, so per-step footers stay
36
+ visible. */
37
+ height: 100vh;
38
+ height: 100svh;
39
+ height: 100dvh;
40
+ }
41
+
42
+ /* --- Step base layout -------------------------------------------- */
43
+
44
+ .step {
45
+ display: flex;
46
+ flex-direction: column;
47
+ width: 100%;
48
+ height: 100%;
49
+ padding: max(16px, env(safe-area-inset-top, 0px)) 20px
50
+ max(16px, env(safe-area-inset-bottom, 0px));
51
+ background:
52
+ radial-gradient(circle at 10% 10%, var(--bg-3), transparent 55%),
53
+ radial-gradient(circle at 90% 0%, var(--bg-2), transparent 55%),
54
+ linear-gradient(140deg, var(--bg-1), #fff8f2 60%, #f0e2d8 100%);
55
+ }
56
+
57
+ .step-head {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 12px;
61
+ margin-bottom: 12px;
62
+ min-height: 40px;
63
+ flex: 0 0 auto;
64
+ }
65
+
66
+ .step-back {
67
+ width: 40px;
68
+ height: 40px;
69
+ flex: 0 0 40px;
70
+ border: 1px solid var(--border);
71
+ border-radius: 50%;
72
+ background: white;
73
+ color: var(--ink);
74
+ font-size: 22px;
75
+ cursor: pointer;
76
+ display: inline-flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ }
80
+
81
+ .step-back:active {
82
+ background: var(--sand);
83
+ }
84
+
85
+ .step-body {
86
+ flex: 1 1 auto;
87
+ overflow-y: auto;
88
+ -webkit-overflow-scrolling: touch;
89
+ }
90
+
91
+ .step-foot {
92
+ flex: 0 0 auto;
93
+ padding-top: 14px;
94
+ }
95
+
96
+ /* Secondary action under a primary button — text-link weight so it
97
+ reads as an "escape hatch" rather than an equally-weighted choice.
98
+ Padding is sized to clear the WCAG 2.5.5 / Apple HIG ~44px minimum
99
+ touch target. */
100
+ .step-link {
101
+ display: block;
102
+ width: 100%;
103
+ margin-top: 8px;
104
+ padding: 12px 16px;
105
+ background: transparent;
106
+ border: none;
107
+ border-radius: 8px;
108
+ color: var(--ink-soft);
109
+ font-size: 0.95rem;
110
+ font-weight: 500;
111
+ text-decoration: underline;
112
+ text-underline-offset: 3px;
113
+ cursor: pointer;
114
+ }
115
+
116
+ .step-link:hover {
117
+ color: var(--ink);
118
+ }
119
+
120
+ .step-link:focus-visible {
121
+ color: var(--ink);
122
+ outline: 2px solid var(--accent);
123
+ outline-offset: 2px;
124
+ }
125
+
126
+ .guide-upload-error {
127
+ margin: 8px 4px 0;
128
+ font-size: 0.85rem;
129
+ color: var(--accent);
130
+ text-align: center;
131
+ }
132
+
133
+ /* --- Typography (matches desktop hero/panel) --------------------- */
134
+
135
+ .hero-eyebrow {
136
+ text-transform: uppercase;
137
+ letter-spacing: 0.18em;
138
+ font-size: 0.75rem;
139
+ font-weight: 600;
140
+ color: var(--accent-dark);
141
+ margin: 0 0 24px;
142
+ }
143
+
144
+ .hero-headline {
145
+ font-family: "Futura", "Gill Sans", "Optima", "Trebuchet MS", sans-serif;
146
+ font-size: clamp(2rem, 8vw, 2.4rem);
147
+ letter-spacing: 0.02em;
148
+ line-height: 1.15;
149
+ margin: 0 0 28px;
150
+ color: var(--ink);
151
+ }
152
+
153
+ .hero-sub {
154
+ font-size: 1.05rem;
155
+ line-height: 1.7;
156
+ color: var(--ink-soft);
157
+ margin: 0;
158
+ max-width: 36ch;
159
+ }
160
+
161
+ /* Step 1 leans into a softly centered hero: vertically nudge the copy
162
+ toward the middle so the eye lands on the headline, not the very
163
+ top of the page. */
164
+ .step-intro .step-body {
165
+ display: flex;
166
+ flex-direction: column;
167
+ justify-content: center;
168
+ }
169
+
170
+ /* --- Panel (matches desktop .hero-card / .panel) ----------------- */
171
+
172
+ .panel {
173
+ background: rgba(255, 255, 255, 0.78);
174
+ border: 1px solid var(--border);
175
+ border-radius: 20px;
176
+ padding: 22px;
177
+ box-shadow: 0 18px 40px rgba(43, 31, 31, 0.08);
178
+ -webkit-backdrop-filter: blur(6px);
179
+ backdrop-filter: blur(6px);
180
+ margin-bottom: 16px;
181
+ }
182
+
183
+ .panel:last-child {
184
+ margin-bottom: 0;
185
+ }
186
+
187
+ .panel-title {
188
+ font-family: "Futura", "Gill Sans", "Optima", "Trebuchet MS", sans-serif;
189
+ font-size: 1.4rem;
190
+ margin: 0 0 14px;
191
+ color: var(--ink);
192
+ }
193
+
194
+ /* --- Form controls (matches desktop) ----------------------------- */
195
+
196
+ .controls {
197
+ display: flex;
198
+ flex-direction: column;
199
+ gap: 14px;
200
+ }
201
+
202
+ .controls label {
203
+ display: flex;
204
+ flex-direction: column;
205
+ gap: 6px;
206
+ font-size: 0.9rem;
207
+ color: var(--ink-soft);
208
+ }
209
+
210
+ .controls input[type="text"],
211
+ .controls select {
212
+ /* Lock both controls to the same height. iOS Safari's native
213
+ <select> renders at a slightly shorter intrinsic height than
214
+ <input>, so identical padding alone doesn't match — an explicit
215
+ height (with box-sizing: border-box, set globally) does. */
216
+ height: 48px;
217
+ width: 100%;
218
+ border: 1px solid var(--border);
219
+ border-radius: 12px;
220
+ padding: 0 14px;
221
+ /* iOS auto-zoom guard — must be ≥ 16px. */
222
+ font-size: 1rem;
223
+ line-height: 1.4;
224
+ background: white;
225
+ color: var(--ink);
226
+ }
227
+
228
+ /* Strip the native <select> chrome (Safari's tiny up/down chevrons,
229
+ inset shadows, etc.) so the visual treatment matches the text
230
+ input. We restore a single chevron via a background-image so the
231
+ dropdown affordance is still visible. */
232
+ .controls select {
233
+ -webkit-appearance: none;
234
+ -moz-appearance: none;
235
+ appearance: none;
236
+ padding-right: 40px;
237
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%234b3d3d' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1,2 6,7 11,2'/></svg>");
238
+ background-repeat: no-repeat;
239
+ background-position: right 14px center;
240
+ background-size: 12px 8px;
241
+ }
242
+
243
+ .form-error {
244
+ margin: 12px 0 0;
245
+ font-size: 0.85rem;
246
+ color: var(--accent);
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;
294
+ padding: 14px 18px 14px 32px;
295
+ list-style: disc;
296
+ background: rgba(191, 58, 43, 0.06);
297
+ border-left: 3px solid rgba(191, 58, 43, 0.55);
298
+ border-radius: 8px;
299
+ font-size: 0.9rem;
300
+ color: var(--ink-soft);
301
+ line-height: 1.55;
302
+ }
303
+
304
+ .capture-tips li + li {
305
+ margin-top: 6px;
306
+ }
307
+
308
+ .capture-tips strong {
309
+ color: var(--ink);
310
+ }
311
+
312
+ /* "Like this" sample below the tips on the guide step. Same rounded
313
+ image-frame chrome as the confirm preview / result overlay so the
314
+ visual treatment is consistent across the three places we show a
315
+ photo. */
316
+ .guide-example {
317
+ margin: 16px 0 0;
318
+ padding: 0;
319
+ }
320
+
321
+ .guide-example img {
322
+ width: 100%;
323
+ height: auto;
324
+ display: block;
325
+ border-radius: 14px;
326
+ background: #f6efea;
327
+ box-shadow: 0 6px 18px var(--shadow);
328
+ }
329
+
330
+ .guide-example figcaption {
331
+ margin-bottom: 8px;
332
+ font-size: 0.85rem;
333
+ color: var(--ink-soft);
334
+ text-align: center;
335
+ font-style: italic;
336
+ }
337
+
338
+ /* --- Primary button --------------------------------------------- */
339
+
340
+ .primary {
341
+ width: 100%;
342
+ border: none;
343
+ border-radius: 14px;
344
+ padding: 14px 16px;
345
+ font-size: 1rem;
346
+ font-weight: 600;
347
+ color: white;
348
+ background: linear-gradient(120deg, var(--accent), #e25f4f);
349
+ box-shadow: 0 12px 30px var(--shadow);
350
+ cursor: pointer;
351
+ }
352
+
353
+ .primary:active {
354
+ transform: translateY(1px);
355
+ }
356
+
357
+ .primary:disabled {
358
+ /* Distinct solid color rather than an opacity fade. Opacity makes
359
+ a disabled button look like a "weak" version of the live one,
360
+ which is visually noisy on top of a busy video preview. A solid
361
+ warm beige-gray with no shadow reads unambiguously as inactive
362
+ while staying inside the cream palette. */
363
+ background: #a89e8f;
364
+ box-shadow: none;
365
+ cursor: not-allowed;
366
+ }
367
+
368
+ /* --- Image frame (matches desktop input/result image panels) ---- */
369
+
370
+ .image-frame {
371
+ position: relative;
372
+ border-radius: 16px;
373
+ overflow: hidden;
374
+ background: #f6efea;
375
+ min-height: 180px;
376
+ display: grid;
377
+ place-items: center;
378
+ }
379
+
380
+ .image-frame img {
381
+ width: 100%;
382
+ height: auto;
383
+ display: none;
384
+ }
385
+
386
+ .image-frame.show img {
387
+ display: block;
388
+ }
389
+
390
+ /* --- Confirm step status text ----------------------------------- */
391
+
392
+ /* Lives in the step-foot (not the panel) so it's always visible —
393
+ tall photos can push panel content out of the visible step-body
394
+ area, which would otherwise hide the live "Measuring… Xs" timer. */
395
+ .confirm-status {
396
+ margin: 0 0 10px;
397
+ font-size: 0.95rem;
398
+ color: var(--ink-soft);
399
+ text-align: center;
400
+ /* Keep the timer line single-line so the layout doesn't jump as the
401
+ seconds counter ticks; the message is short by design. */
402
+ white-space: nowrap;
403
+ overflow: hidden;
404
+ text-overflow: ellipsis;
405
+ }
406
+
407
+ .confirm-status.error {
408
+ color: var(--accent);
409
+ font-weight: 600;
410
+ /* Errors can be longer than the live timer; allow wrapping there. */
411
+ white-space: normal;
412
+ }
413
+
414
+ /* --- Step 4 — Capture stage (full-bleed camera) ----------------- */
415
+
416
+ .step-capture {
417
+ --capture-url-bar-inset: 0px;
418
+ --capture-bottom-inset: max(
419
+ 28px,
420
+ calc(var(--capture-url-bar-inset) + env(safe-area-inset-bottom, 0px) + 16px)
421
+ );
422
+ position: relative;
423
+ padding: 0;
424
+ background: #000;
425
+ }
426
+
427
+ .capture-video {
428
+ width: 100%;
429
+ height: 100%;
430
+ object-fit: cover;
431
+ background: #000;
432
+ }
433
+
434
+ .capture-back,
435
+ .capture-flash {
436
+ position: absolute;
437
+ top: max(12px, env(safe-area-inset-top, 12px));
438
+ width: 40px;
439
+ height: 40px;
440
+ padding: 0;
441
+ border: none;
442
+ border-radius: 50%;
443
+ background: rgba(0, 0, 0, 0.45);
444
+ color: #fff;
445
+ font-size: 22px;
446
+ cursor: pointer;
447
+ z-index: 2;
448
+ display: inline-flex;
449
+ align-items: center;
450
+ justify-content: center;
451
+ -webkit-backdrop-filter: blur(8px);
452
+ backdrop-filter: blur(8px);
453
+ transition: background 0.15s ease, color 0.15s ease;
454
+ }
455
+
456
+ .capture-back {
457
+ left: 12px;
458
+ }
459
+
460
+ .capture-flash {
461
+ right: 12px;
462
+ font-size: 18px;
463
+ }
464
+
465
+ .capture-back:hover,
466
+ .capture-back:focus-visible,
467
+ .capture-flash:hover,
468
+ .capture-flash:focus-visible {
469
+ background: rgba(0, 0, 0, 0.65);
470
+ }
471
+
472
+ .capture-flash[aria-pressed="true"] {
473
+ background: #fff8d4;
474
+ color: #8a5b0a;
475
+ }
476
+
477
+ .capture-chips {
478
+ position: absolute;
479
+ bottom: calc(var(--capture-bottom-inset) + 64px);
480
+ left: 16px;
481
+ right: 16px;
482
+ display: flex;
483
+ flex-wrap: wrap;
484
+ gap: 8px;
485
+ justify-content: center;
486
+ z-index: 2;
487
+ }
488
+
489
+ .capture-status {
490
+ position: absolute;
491
+ bottom: calc(var(--capture-bottom-inset) + 110px);
492
+ left: 16px;
493
+ right: 16px;
494
+ margin: 0;
495
+ text-align: center;
496
+ font-size: 0.85rem;
497
+ color: rgba(255, 255, 255, 0.85);
498
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
499
+ z-index: 2;
500
+ }
501
+
502
+ .capture-status.error {
503
+ color: #ffd5d0;
504
+ font-weight: 600;
505
+ }
506
+
507
+ .capture-controls {
508
+ position: absolute;
509
+ bottom: var(--capture-bottom-inset);
510
+ left: 16px;
511
+ right: 16px;
512
+ z-index: 2;
513
+ }
514
+
515
+ .capture-controls .capture-shutter {
516
+ width: 100%;
517
+ }
518
+
519
+ /* --- Status chips (distance / level) ---------------------------- */
520
+
521
+ .status-chip {
522
+ display: inline-flex;
523
+ align-items: center;
524
+ gap: 6px;
525
+ padding: 4px 10px;
526
+ border-radius: 999px;
527
+ font-size: 0.82rem;
528
+ font-weight: 600;
529
+ border: 1px solid transparent;
530
+ background: rgba(255, 255, 255, 0.85);
531
+ color: var(--ink-soft);
532
+ }
533
+
534
+ .status-chip .chip-dot {
535
+ display: inline-block;
536
+ width: 8px;
537
+ height: 8px;
538
+ border-radius: 50%;
539
+ background: currentColor;
540
+ opacity: 0.8;
541
+ }
542
+
543
+ .status-chip.pending {
544
+ color: var(--ink-soft);
545
+ border-color: var(--border);
546
+ }
547
+
548
+ .status-chip.skipped {
549
+ color: var(--ink-soft);
550
+ border-color: var(--border);
551
+ opacity: 0.6;
552
+ }
553
+
554
+ .status-chip.red {
555
+ color: #b1271b;
556
+ background: #fde7e3;
557
+ border-color: rgba(177, 39, 27, 0.4);
558
+ }
559
+
560
+ .status-chip.amber {
561
+ color: #8a5b0a;
562
+ background: #fbeed1;
563
+ border-color: rgba(138, 91, 10, 0.4);
564
+ }
565
+
566
+ .status-chip.green {
567
+ color: #1f6b34;
568
+ background: #d8f1de;
569
+ border-color: rgba(31, 107, 52, 0.4);
570
+ }
571
+
572
+ /* --- Bubble level (capture stage) ------------------------------- */
573
+ /* iPhone-style two-cross level. A static white cross marks the center
574
+ target; a second cross of the same shape drifts toward the high side
575
+ of the device. When the device is in tolerance the moving cross snaps
576
+ to the center and turns green (the two crosses visually merge). When
577
+ out of tolerance it floats out, clamped to a max radius, in red. The
578
+ ±5° tolerance ring is decorative — it gives the user a sense of how
579
+ far "in tolerance" is — and stays neutral white. */
580
+
581
+ .capture-level-bubble {
582
+ position: absolute;
583
+ top: 50%;
584
+ left: 50%;
585
+ width: 128px;
586
+ height: 128px;
587
+ transform: translate(-50%, -50%);
588
+ pointer-events: none;
589
+ z-index: 2;
590
+ --neutral-color: rgba(255, 255, 255, 0.85);
591
+ --indicator-color: rgba(255, 255, 255, 0.85);
592
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55));
593
+ }
594
+
595
+ .capture-level-bubble[data-state="green"] {
596
+ --indicator-color: #6ce39a;
597
+ }
598
+
599
+ .capture-level-bubble[data-state="red"] {
600
+ --indicator-color: #ff8a7e;
601
+ }
602
+
603
+ /* Tolerance ring + center crosshair stay neutral so only the moving
604
+ cross signals state (per design feedback). */
605
+ .capture-level-bubble .bubble-tolerance {
606
+ position: absolute;
607
+ top: 50%;
608
+ left: 50%;
609
+ width: 38px;
610
+ height: 38px;
611
+ border-radius: 50%;
612
+ transform: translate(-50%, -50%);
613
+ border: 1.5px solid var(--neutral-color);
614
+ opacity: 0.85;
615
+ }
616
+
617
+ .capture-level-bubble .bubble-crosshair-h,
618
+ .capture-level-bubble .bubble-crosshair-v {
619
+ position: absolute;
620
+ top: 50%;
621
+ left: 50%;
622
+ background: var(--neutral-color);
623
+ opacity: 0.6;
624
+ }
625
+
626
+ .capture-level-bubble .bubble-crosshair-h {
627
+ width: 14px;
628
+ height: 1.5px;
629
+ transform: translate(-50%, -50%);
630
+ }
631
+
632
+ .capture-level-bubble .bubble-crosshair-v {
633
+ width: 1.5px;
634
+ height: 14px;
635
+ transform: translate(-50%, -50%);
636
+ }
637
+
638
+ /* Moving cross. The wrapper is the element JS translates every tick;
639
+ the two arms inside form the cross relative to the wrapper's origin. */
640
+ .capture-level-bubble .bubble-indicator {
641
+ position: absolute;
642
+ top: 50%;
643
+ left: 50%;
644
+ width: 14px;
645
+ height: 14px;
646
+ transform: translate(-50%, -50%);
647
+ /* Linear transform transition smooths the 100 ms detection tick;
648
+ also gives the snap-to-center on green a brief settle motion. */
649
+ transition: transform 0.08s linear;
650
+ }
651
+
652
+ .capture-level-bubble .bubble-indicator-h,
653
+ .capture-level-bubble .bubble-indicator-v {
654
+ position: absolute;
655
+ top: 50%;
656
+ left: 50%;
657
+ background: var(--indicator-color);
658
+ transition: background 0.18s ease;
659
+ }
660
+
661
+ .capture-level-bubble .bubble-indicator-h {
662
+ width: 18px;
663
+ height: 2px;
664
+ transform: translate(-50%, -50%);
665
+ }
666
+
667
+ .capture-level-bubble .bubble-indicator-v {
668
+ width: 2px;
669
+ height: 18px;
670
+ transform: translate(-50%, -50%);
671
+ }
672
+
673
+ .capture-level-bubble[data-state="pending"] .bubble-indicator {
674
+ opacity: 0;
675
+ }
676
+
677
+ /* --- Step 6 — Result -------------------------------------------- */
678
+
679
+ /* Top-of-result feedback banner. Mirrors the desktop's status text
680
+ semantically (single line of feedback after measurement) but is
681
+ visually a small panel-shaped card so it reads as a result rather
682
+ than form chrome. Color-coded: green = success, red = failure. */
683
+ .result-status {
684
+ margin: 0 0 16px;
685
+ padding: 14px 16px;
686
+ border-radius: 14px;
687
+ background: white;
688
+ border: 1px solid var(--border);
689
+ border-left: 4px solid var(--ink-soft);
690
+ box-shadow: 0 6px 18px var(--shadow);
691
+ }
692
+
693
+ .result-status p {
694
+ margin: 0;
695
+ font-size: 0.95rem;
696
+ font-weight: 600;
697
+ line-height: 1.5;
698
+ }
699
+
700
+ .result-status-success {
701
+ background: #e8f4ec;
702
+ border-color: rgba(31, 107, 52, 0.4);
703
+ border-left-color: #1f6b34;
704
+ }
705
+
706
+ .result-status-success p {
707
+ color: #1f6b34;
708
+ }
709
+
710
+ .result-status-error {
711
+ background: #fde7e3;
712
+ border-color: rgba(177, 39, 27, 0.4);
713
+ border-left-color: var(--accent);
714
+ }
715
+
716
+ .result-status-error p {
717
+ color: #b1271b;
718
+ }
719
+
720
+ /* Three finger cards stacked vertically (instead of the desktop's
721
+ 3-column grid) — narrower mobile width fits a single column more
722
+ comfortably without shrinking the size number. */
723
+ .finger-cards {
724
+ display: flex;
725
+ flex-direction: column;
726
+ gap: 10px;
727
+ margin-bottom: 12px;
728
+ }
729
+
730
+ .finger-card {
731
+ background: var(--sand);
732
+ border-radius: 10px;
733
+ padding: 14px 16px 16px;
734
+ text-align: center;
735
+ box-shadow: 0 1px 4px var(--shadow);
736
+ }
737
+
738
+ .finger-card .finger-name {
739
+ font-weight: 600;
740
+ font-size: 0.95rem;
741
+ margin-bottom: 6px;
742
+ text-transform: uppercase;
743
+ letter-spacing: 0.04em;
744
+ color: var(--ink);
745
+ }
746
+
747
+ .finger-card .finger-size-label {
748
+ font-size: 0.75rem;
749
+ text-transform: uppercase;
750
+ letter-spacing: 0.06em;
751
+ color: var(--ink-soft);
752
+ margin-top: 4px;
753
+ }
754
+
755
+ .finger-card .finger-size {
756
+ font-size: 2rem;
757
+ font-weight: 700;
758
+ color: var(--accent);
759
+ line-height: 1.1;
760
+ margin: 2px 0;
761
+ }
762
+
763
+ .finger-card .finger-range {
764
+ font-size: 0.9rem;
765
+ color: var(--ink-soft);
766
+ }
767
+
768
+ .finger-card .finger-width {
769
+ font-size: 0.85rem;
770
+ color: var(--ink-soft);
771
+ margin-top: 4px;
772
+ }
773
+
774
+ .finger-card-failed {
775
+ opacity: 0.85;
776
+ }
777
+
778
+ .finger-card .finger-failed {
779
+ font-size: 1rem;
780
+ font-weight: 600;
781
+ color: #721c24;
782
+ margin: 8px 0 4px;
783
+ }
784
+
785
+ .finger-card .finger-fail-reason {
786
+ font-size: 0.78rem;
787
+ color: var(--ink-soft);
788
+ word-break: break-word;
789
+ }
790
+
791
+ .finger-count {
792
+ text-align: center;
793
+ font-size: 0.85rem;
794
+ color: var(--ink-soft);
795
+ margin-bottom: 12px;
796
+ }
797
+
798
+ /* --- Size reference table (mirrors desktop) --------------------- */
799
+
800
+ .size-ref-table {
801
+ margin-top: 14px;
802
+ padding-top: 12px;
803
+ border-top: 1px solid var(--border);
804
+ }
805
+
806
+ .size-ref-title {
807
+ font-family: "Futura", "Gill Sans", "Optima", "Trebuchet MS", sans-serif;
808
+ font-size: 1rem;
809
+ margin: 0 0 8px;
810
+ color: var(--ink);
811
+ /* Center the model-label heading so it lines up with the centered
812
+ SIZE / Inner ⌀ (mm) columns below — same alignment story as
813
+ the desktop. */
814
+ /*text-align: center;*/
815
+ }
816
+
817
+ .size-ref-table table {
818
+ width: 100%;
819
+ border-collapse: collapse;
820
+ font-size: 0.9rem;
821
+ }
822
+
823
+ .size-ref-table th,
824
+ .size-ref-table td {
825
+ padding: 8px 10px;
826
+ /* Match the desktop alignment — both headers and values are
827
+ centered, which is more comfortable to scan when the columns
828
+ are short numeric values. */
829
+ text-align: center;
830
+ border-bottom: 1px solid var(--border);
831
+ }
832
+
833
+ .size-ref-table th {
834
+ font-weight: 600;
835
+ color: var(--ink-soft);
836
+ font-size: 0.8rem;
837
+ text-transform: uppercase;
838
+ letter-spacing: 0.06em;
839
+ }
840
+
841
+ .size-ref-table tbody tr:nth-child(even) {
842
+ background: rgba(245, 241, 231, 0.5);
843
+ }
web_demo/static/mobile/mobile.js ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Mobile flow entrypoint. Registers six steps that mirror the
2
+ // desktop page broken into discrete screens:
3
+ //
4
+ // 1. intro — value prop (desktop hero copy)
5
+ // 2. form — name + ring model (desktop hero-card top)
6
+ // 3. guide — photo tips + "like this" sample, opens the camera
7
+ // 4. capture — fullscreen camera coach
8
+ // 5. confirm — preview the captured image, fire measurement
9
+ // 6. result — overlay image + ring-size cards + size reference
10
+ //
11
+ // Cross-step state lives in `session.js` so back-navigation preserves
12
+ // what the user has already entered. Step 3 also exposes an "Upload
13
+ // from photos" escape hatch that skips the capture step and hands the
14
+ // picked file straight to confirm — same downstream path as a camera
15
+ // capture, just with `imageSource = "upload"`.
16
+
17
+ import { registerStep, setOrder, start } from "./steps.js";
18
+ import intro from "./steps/intro.js";
19
+ import form from "./steps/form.js";
20
+ import guide from "./steps/guide.js";
21
+ import capture from "./steps/capture.js";
22
+ import confirm from "./steps/confirm.js";
23
+ import result from "./steps/result.js";
24
+
25
+ registerStep("intro", intro);
26
+ registerStep("form", form);
27
+ registerStep("guide", guide);
28
+ registerStep("capture", capture);
29
+ registerStep("confirm", confirm);
30
+ registerStep("result", result);
31
+
32
+ setOrder(["intro", "form", "guide", "capture", "confirm", "result"]);
33
+
34
+ const root = document.getElementById("mobileRoot");
35
+ if (root) start(root);
web_demo/static/mobile/session.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared state that persists across step transitions.
2
+ //
3
+ // `goTo(name, data)` would force every step to re-thread the full
4
+ // payload to the next; with six steps and a back-nav that needs to
5
+ // remember what was already entered, an implicit session module is
6
+ // simpler. Lifecycle is bounded by the page (cleared on `reset()`,
7
+ // invoked by the result step's "Measure again" button to ensure the
8
+ // next run starts fresh).
9
+
10
+ export const session = {
11
+ kolName: "",
12
+ ringModel: "gen",
13
+ // Either an uploaded File or a Blob captured from the camera step.
14
+ imageBlob: null,
15
+ // Object URL pointing at imageBlob — created by whichever step
16
+ // produced the blob, revoked by reset() to avoid leaks.
17
+ imageUrl: "",
18
+ // Source of the blob, used by confirm.js to label the preview.
19
+ imageSource: "", // "upload" | "camera"
20
+ // Capture-coach telemetry snapshot. Forwarded to /api/measure with
21
+ // the camera path; null for the upload path.
22
+ gateTelemetry: null,
23
+ // /api/measure response payload, populated by confirm.js before
24
+ // navigating to the result step.
25
+ result: null,
26
+ };
27
+
28
+ // Wipe just the photo + result — used by "Measure again" so the user
29
+ // keeps their entered name + ring model on the next capture without
30
+ // re-typing. A full reset isn't a separate function: a page refresh
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 = "";
43
+ session.imageSource = "";
44
+ session.gateTelemetry = null;
45
+ session.result = null;
46
+ }
web_demo/static/mobile/steps.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tiny linear step controller for the mobile flow.
2
+ //
3
+ // Each step registers a `mount(container)` function that paints itself
4
+ // into the supplied DOM container, plus an optional `unmount()` for
5
+ // cleanup (e.g. tearing down a camera stream when leaving the capture
6
+ // step). The controller maintains an index into a fixed step order and
7
+ // exposes `next()` / `back()` / `goTo(name)` for transitions.
8
+ //
9
+ // Deliberately minimal: no transitions, no history-API integration, no
10
+ // deep-linking. Five linear steps with back/forward — anything fancier
11
+ // can be added when the UX calls for it.
12
+
13
+ const stepRegistry = new Map();
14
+ let order = [];
15
+ let currentIndex = -1;
16
+ let currentStep = null;
17
+ let rootContainer = null;
18
+
19
+ export function registerStep(name, step) {
20
+ if (typeof step.mount !== "function") {
21
+ throw new Error(`Step "${name}" must define mount(container)`);
22
+ }
23
+ stepRegistry.set(name, step);
24
+ }
25
+
26
+ export function setOrder(names) {
27
+ for (const name of names) {
28
+ if (!stepRegistry.has(name)) {
29
+ throw new Error(`Step "${name}" is not registered`);
30
+ }
31
+ }
32
+ order = [...names];
33
+ }
34
+
35
+ export function start(container) {
36
+ rootContainer = container;
37
+ if (!order.length) throw new Error("setOrder() must be called before start()");
38
+ goTo(order[0]);
39
+ }
40
+
41
+ // `data` is an optional payload handed to the destination step's
42
+ // mount() — used by the capture step to forward the measurement result
43
+ // to the result step. Steps that don't need data can ignore the third
44
+ // argument; steps that need persistent state should still maintain it
45
+ // themselves (the controller doesn't survive a hard reload).
46
+ export function goTo(name, data) {
47
+ const idx = order.indexOf(name);
48
+ if (idx === -1) throw new Error(`Step "${name}" is not in the active order`);
49
+ if (currentStep && typeof currentStep.unmount === "function") {
50
+ try {
51
+ currentStep.unmount();
52
+ } catch (err) {
53
+ console.error(`Step "${order[currentIndex]}" unmount failed:`, err);
54
+ }
55
+ }
56
+ rootContainer.innerHTML = "";
57
+ currentIndex = idx;
58
+ currentStep = stepRegistry.get(name);
59
+ // Pass a small `nav` API down so steps don't need to import this
60
+ // module — keeps each step file standalone and easy to test.
61
+ const nav = {
62
+ next: (payload) => {
63
+ if (currentIndex < order.length - 1) goTo(order[currentIndex + 1], payload);
64
+ },
65
+ back: (payload) => {
66
+ if (currentIndex > 0) goTo(order[currentIndex - 1], payload);
67
+ },
68
+ goTo,
69
+ isFirst: () => currentIndex === 0,
70
+ isLast: () => currentIndex === order.length - 1,
71
+ progress: () => ({ index: currentIndex, total: order.length }),
72
+ };
73
+ currentStep.mount(rootContainer, nav, data);
74
+ }
web_demo/static/mobile/steps/capture.js ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Step 4 — Live camera + capture coach.
2
+ //
3
+ // Drives the existing `static/preview/*` modules (camera lifecycle,
4
+ // MediaPipe Hands, DeviceOrientation, torch) inside the step
5
+ // lifecycle: mount() opens the camera and starts the gate loop,
6
+ // unmount() tears everything down. On shutter, captures a frame and
7
+ // hands the blob to the confirm step (step 5) — the actual
8
+ // /api/measure POST happens there.
9
+ //
10
+ // Most of the gate-loop logic mirrors the desktop implementation in
11
+ // `web_demo/static/app.js` (anti-jitter counters, telemetry shape,
12
+ // MediaPipe-not-loaded fallback). The desktop and mobile copies will
13
+ // converge into a shared module in a later phase once the mobile flow
14
+ // is validated end-to-end.
15
+
16
+ import { session } from "../session.js";
17
+
18
+ const LEVEL_NO_DATA_TIMEOUT_MS = 2000;
19
+
20
+ let detectionTimer = null;
21
+ let consecutiveDistanceGreen = 0;
22
+ let consecutiveLevelGreen = 0;
23
+ let mediaPipeReady = false;
24
+ let orientationStatus = "unsupported";
25
+ let latestOrientation = null;
26
+ let levelStartedAtMs = null;
27
+ let levelMarkedSkipped = false;
28
+ let gateTelemetry = createTelemetry();
29
+ let videoEl = null;
30
+ let distanceChip = null;
31
+ let levelChip = null;
32
+ let levelBubble = null;
33
+ let levelDot = null;
34
+ let shutterBtn = null;
35
+ let flashBtn = null;
36
+ let statusEl = null;
37
+
38
+ function createTelemetry() {
39
+ return {
40
+ started_at_ms: null,
41
+ distance_first_green_ms: null,
42
+ distance_green_frames: 0,
43
+ distance_red_frames: 0,
44
+ distance_amber_frames: 0,
45
+ distance_no_hand_frames: 0,
46
+ level_first_green_ms: null,
47
+ level_green_frames: 0,
48
+ level_red_frames: 0,
49
+ level_amber_frames: 0,
50
+ level_no_data_frames: 0,
51
+ orientation_status: "unsupported",
52
+ threshold_used: null,
53
+ };
54
+ }
55
+
56
+ function setChip(chipEl, state, label) {
57
+ if (!chipEl) return;
58
+ chipEl.className = `status-chip ${state}`;
59
+ const labelEl = chipEl.querySelector(".chip-label");
60
+ if (labelEl) labelEl.textContent = label;
61
+ }
62
+
63
+ function setStatus(text, isError = false) {
64
+ if (!statusEl) return;
65
+ statusEl.textContent = text || "";
66
+ statusEl.classList.toggle("error", !!isError);
67
+ }
68
+
69
+ function evaluateDistance(T) {
70
+ // Mobile collapses the desktop's 5-band gauge (red / amber /
71
+ // green / amber / red) to a strict 3-state read: too far / OK /
72
+ // too close. The amber soft-zone is gone — clearer single-action
73
+ // feedback at the cost of fewer intermediate hints.
74
+ const minRatio = T.HAND_SPAN_RATIO_MIN ?? 0.28;
75
+ const maxRatio = T.HAND_SPAN_RATIO_MAX ?? 0.55;
76
+
77
+ let result = null;
78
+ try {
79
+ result = window.HandsDetector ? window.HandsDetector.detect(videoEl) : null;
80
+ } catch (err) {
81
+ return { state: "skipped", label: "Detection error", isGreen: false };
82
+ }
83
+ const ratio = result ? result.ratio : null;
84
+ if (ratio === null) {
85
+ return { state: "red", label: "Hand not detected", isGreen: false };
86
+ }
87
+ if (ratio < minRatio) {
88
+ return { state: "red", label: "Move closer", isGreen: false };
89
+ }
90
+ if (ratio > maxRatio) {
91
+ return { state: "red", label: "Pull back", isGreen: false };
92
+ }
93
+ return { state: "green", label: "Distance OK", isGreen: true };
94
+ }
95
+
96
+ function evaluateLevel(T) {
97
+ if (orientationStatus !== "granted") {
98
+ return { included: false, state: "skipped", label: "" };
99
+ }
100
+ if (!latestOrientation) {
101
+ if (levelStartedAtMs !== null && Date.now() - levelStartedAtMs > LEVEL_NO_DATA_TIMEOUT_MS) {
102
+ return { included: false, state: "skipped", label: "Level: no sensor" };
103
+ }
104
+ return { included: true, state: "pending", label: "Level: detecting…", isGreen: false };
105
+ }
106
+ // Same red/green collapse as the distance gate — drop the desktop's
107
+ // amber "Hold steady" middle band. Tighter ±5° green threshold (set
108
+ // in thresholds.js) gets the user closer to a clean top-down shot.
109
+ const beta = Math.abs(latestOrientation.beta);
110
+ const gamma = Math.abs(latestOrientation.gamma);
111
+ const bmax = T.LEVEL_BETA_MAX_DEG ?? 5;
112
+ const gmax = T.LEVEL_GAMMA_MAX_DEG ?? 5;
113
+ if (beta < bmax && gamma < gmax) {
114
+ return { included: true, state: "green", label: "Level OK", isGreen: true };
115
+ }
116
+ return { included: true, state: "red", label: "Camera not level", isGreen: false };
117
+ }
118
+
119
+ // Bubble-level visualization. Dot moves toward the *high* side of the
120
+ // device (matches a real spirit level): tilting the right edge down
121
+ // (γ > 0) leaves the left edge high, so the dot drifts left. Saturates
122
+ // at ±15° so wild tilts don't fly off the rim. Color states are derived
123
+ // from raw degrees independent of the gate's anti-jitter counter — the
124
+ // bubble is purely visual feedback; the gate still owns shutter unlock.
125
+ function updateLevelBubble(lvl, T) {
126
+ if (!levelBubble || !levelDot) return;
127
+ if (!lvl.included) {
128
+ levelBubble.hidden = true;
129
+ return;
130
+ }
131
+ levelBubble.hidden = false;
132
+ // Belt-and-braces guard: orientation.js already filters non-finite
133
+ // readings, but treat anything we can't read as pending so a bad
134
+ // sample never propagates into the CSS transform as NaN.
135
+ if (
136
+ lvl.state === "pending" ||
137
+ !latestOrientation ||
138
+ !Number.isFinite(latestOrientation.beta) ||
139
+ !Number.isFinite(latestOrientation.gamma)
140
+ ) {
141
+ levelBubble.dataset.state = "pending";
142
+ levelDot.style.transform = "translate(-50%, -50%)";
143
+ return;
144
+ }
145
+ // Two-state behavior:
146
+ // green → indicator snaps to the center cross, so the user sees a
147
+ // single merged cross flash green when they're in tolerance.
148
+ // red → indicator floats out toward the high side of the device,
149
+ // clamped to a max radius so wild tilts don't fly off-canvas.
150
+ if (lvl.isGreen) {
151
+ levelDot.style.transform = "translate(-50%, -50%)";
152
+ levelBubble.dataset.state = "green";
153
+ return;
154
+ }
155
+ const SATURATION_DEG = 15;
156
+ const MAX_RADIUS_PX = 52;
157
+ const clamp = (v) => Math.max(-1, Math.min(1, v / SATURATION_DEG));
158
+ const dx = -clamp(latestOrientation.gamma) * MAX_RADIUS_PX;
159
+ const dy = -clamp(latestOrientation.beta) * MAX_RADIUS_PX;
160
+ levelDot.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
161
+ levelBubble.dataset.state = "red";
162
+ }
163
+
164
+ function runDetectionTick() {
165
+ if (!videoEl || videoEl.paused || videoEl.readyState < 2) return;
166
+ const T = window.PreviewThresholds || {};
167
+ const requiredFrames = T.GATE_CONSECUTIVE_FRAMES ?? 3;
168
+
169
+ const dist = evaluateDistance(T);
170
+ setChip(distanceChip, dist.state, dist.label);
171
+ if (dist.isGreen) {
172
+ consecutiveDistanceGreen++;
173
+ gateTelemetry.distance_green_frames++;
174
+ if (
175
+ consecutiveDistanceGreen === requiredFrames &&
176
+ gateTelemetry.distance_first_green_ms === null &&
177
+ gateTelemetry.started_at_ms !== null
178
+ ) {
179
+ gateTelemetry.distance_first_green_ms = Date.now() - gateTelemetry.started_at_ms;
180
+ }
181
+ } else {
182
+ consecutiveDistanceGreen = 0;
183
+ if (dist.state === "red") gateTelemetry.distance_red_frames++;
184
+ else if (dist.state === "amber") gateTelemetry.distance_amber_frames++;
185
+ else gateTelemetry.distance_no_hand_frames++;
186
+ }
187
+
188
+ const lvl = evaluateLevel(T);
189
+ updateLevelBubble(lvl, T);
190
+ if (lvl.included) {
191
+ if (levelChip) {
192
+ levelChip.hidden = false;
193
+ setChip(levelChip, lvl.state, lvl.label);
194
+ }
195
+ if (lvl.isGreen) {
196
+ consecutiveLevelGreen++;
197
+ gateTelemetry.level_green_frames++;
198
+ if (
199
+ consecutiveLevelGreen === requiredFrames &&
200
+ gateTelemetry.level_first_green_ms === null &&
201
+ gateTelemetry.started_at_ms !== null
202
+ ) {
203
+ gateTelemetry.level_first_green_ms = Date.now() - gateTelemetry.started_at_ms;
204
+ }
205
+ } else {
206
+ consecutiveLevelGreen = 0;
207
+ if (lvl.state === "red") gateTelemetry.level_red_frames++;
208
+ else if (lvl.state === "amber") gateTelemetry.level_amber_frames++;
209
+ else gateTelemetry.level_no_data_frames++;
210
+ }
211
+ } else if (lvl.state === "skipped" && lvl.label) {
212
+ if (levelChip && !levelMarkedSkipped) {
213
+ levelChip.hidden = false;
214
+ setChip(levelChip, "skipped", lvl.label);
215
+ levelMarkedSkipped = true;
216
+ }
217
+ } else if (levelChip) {
218
+ levelChip.hidden = true;
219
+ }
220
+
221
+ const distancePass = consecutiveDistanceGreen >= requiredFrames;
222
+ const levelPass = !lvl.included || consecutiveLevelGreen >= requiredFrames;
223
+ if (shutterBtn) shutterBtn.disabled = !(distancePass && levelPass);
224
+ }
225
+
226
+ function abandonGate(distanceLabel) {
227
+ if (window.OrientationDetector) window.OrientationDetector.stop();
228
+ if (levelChip) levelChip.hidden = true;
229
+ if (levelBubble) {
230
+ levelBubble.hidden = true;
231
+ levelBubble.dataset.state = "pending";
232
+ if (levelDot) levelDot.style.transform = "translate(-50%, -50%)";
233
+ }
234
+ setChip(distanceChip, "skipped", distanceLabel);
235
+ if (shutterBtn) shutterBtn.disabled = false;
236
+ }
237
+
238
+ async function startGateLoop() {
239
+ gateTelemetry = createTelemetry();
240
+ gateTelemetry.started_at_ms = Date.now();
241
+ gateTelemetry.threshold_used =
242
+ (window.PreviewThresholds && window.PreviewThresholds.HAND_SPAN_RATIO_MIN) || null;
243
+ gateTelemetry.orientation_status = orientationStatus;
244
+ consecutiveDistanceGreen = 0;
245
+ consecutiveLevelGreen = 0;
246
+ latestOrientation = null;
247
+ levelStartedAtMs = Date.now();
248
+ levelMarkedSkipped = false;
249
+ mediaPipeReady = false;
250
+ if (shutterBtn) shutterBtn.disabled = true;
251
+ setChip(distanceChip, "pending", "Distance: starting…");
252
+
253
+ if (orientationStatus === "granted" && window.OrientationDetector) {
254
+ if (levelChip) {
255
+ levelChip.hidden = false;
256
+ setChip(levelChip, "pending", "Level: starting…");
257
+ }
258
+ if (levelBubble) {
259
+ levelBubble.hidden = false;
260
+ levelBubble.dataset.state = "pending";
261
+ if (levelDot) levelDot.style.transform = "translate(-50%, -50%)";
262
+ }
263
+ window.OrientationDetector.start((data) => {
264
+ latestOrientation = data;
265
+ });
266
+ } else {
267
+ if (levelChip) levelChip.hidden = true;
268
+ if (levelBubble) {
269
+ levelBubble.hidden = true;
270
+ levelBubble.dataset.state = "pending";
271
+ if (levelDot) levelDot.style.transform = "translate(-50%, -50%)";
272
+ }
273
+ }
274
+
275
+ if (!window.HandsDetector) {
276
+ abandonGate("Hands module didn't load");
277
+ return;
278
+ }
279
+ try {
280
+ await window.HandsDetector.init();
281
+ mediaPipeReady = true;
282
+ } catch (err) {
283
+ console.warn("MediaPipe init failed; distance gate disabled", err);
284
+ const detail = err && err.message ? String(err.message).slice(0, 60) : "init failed";
285
+ abandonGate(`Distance check off: ${detail}`);
286
+ return;
287
+ }
288
+ detectionTimer = setInterval(runDetectionTick, 100);
289
+ }
290
+
291
+ function stopGateLoop() {
292
+ if (detectionTimer) {
293
+ clearInterval(detectionTimer);
294
+ detectionTimer = null;
295
+ }
296
+ if (window.OrientationDetector) window.OrientationDetector.stop();
297
+ consecutiveDistanceGreen = 0;
298
+ consecutiveLevelGreen = 0;
299
+ latestOrientation = null;
300
+ }
301
+
302
+ // Same pattern as desktop: stage anchored to top:0/bottom:0 with the
303
+ // URL-bar height pushed into a CSS variable so the bottom controls
304
+ // stay above iOS Safari's URL bar without leaving a gap below.
305
+ function syncCaptureInsets(stageEl) {
306
+ const layoutH = window.innerHeight;
307
+ const vv = window.visualViewport;
308
+ const visualH = vv ? vv.height : layoutH;
309
+ const offsetTop = vv ? vv.offsetTop : 0;
310
+ const urlBarH = Math.max(0, layoutH - visualH - offsetTop);
311
+ stageEl.style.setProperty("--capture-url-bar-inset", `${urlBarH}px`);
312
+ }
313
+
314
+ async function refreshFlash() {
315
+ if (!flashBtn || !window.CapturePreview) return;
316
+ if (!window.CapturePreview.isTorchSupported()) {
317
+ flashBtn.hidden = true;
318
+ return;
319
+ }
320
+ flashBtn.hidden = false;
321
+ flashBtn.setAttribute("aria-pressed", "true");
322
+ flashBtn.setAttribute("aria-label", "Turn flash off");
323
+ try {
324
+ await window.CapturePreview.setTorch(true);
325
+ } catch (err) {
326
+ flashBtn.setAttribute("aria-pressed", "false");
327
+ flashBtn.setAttribute("aria-label", "Turn flash on");
328
+ }
329
+ }
330
+
331
+ async function toggleFlash() {
332
+ if (!flashBtn || !window.CapturePreview) return;
333
+ const wasOn = flashBtn.getAttribute("aria-pressed") === "true";
334
+ const next = !wasOn;
335
+ flashBtn.setAttribute("aria-pressed", next ? "true" : "false");
336
+ flashBtn.setAttribute("aria-label", next ? "Turn flash off" : "Turn flash on");
337
+ try {
338
+ await window.CapturePreview.setTorch(next);
339
+ } catch (err) {
340
+ flashBtn.setAttribute("aria-pressed", wasOn ? "true" : "false");
341
+ flashBtn.setAttribute("aria-label", wasOn ? "Turn flash off" : "Turn flash on");
342
+ setStatus("Could not toggle flash on this device.", true);
343
+ }
344
+ }
345
+
346
+ async function shoot(nav) {
347
+ if (!shutterBtn || !window.CapturePreview) return;
348
+ shutterBtn.disabled = true;
349
+ setStatus("Capturing…");
350
+ let blob;
351
+ try {
352
+ blob = await window.CapturePreview.captureFrame(videoEl);
353
+ } catch (err) {
354
+ setStatus(`Capture failed: ${err.message || err}`, true);
355
+ shutterBtn.disabled = false;
356
+ return;
357
+ }
358
+ // Hand the captured frame off to the confirm step. The /api/measure
359
+ // POST happens there so the user can review the photo (and the
360
+ // measuring timer has its own dedicated screen).
361
+ if (session.imageUrl && session.imageUrl.startsWith("blob:")) {
362
+ URL.revokeObjectURL(session.imageUrl);
363
+ }
364
+ session.imageBlob = blob;
365
+ session.imageUrl = URL.createObjectURL(blob);
366
+ session.imageSource = "camera";
367
+ session.gateTelemetry = {
368
+ ...gateTelemetry,
369
+ captured_at_ms: gateTelemetry.started_at_ms ? Date.now() - gateTelemetry.started_at_ms : null,
370
+ media_pipe_ready: mediaPipeReady,
371
+ capture_width: videoEl.videoWidth || null,
372
+ capture_height: videoEl.videoHeight || null,
373
+ surface: "mobile",
374
+ };
375
+
376
+ stopGateLoop();
377
+ window.CapturePreview.stop();
378
+ nav.next();
379
+ }
380
+
381
+ export default {
382
+ mount(container, nav) {
383
+ container.innerHTML = `
384
+ <section class="step step-capture">
385
+ <video id="captureVideo" class="capture-video" autoplay playsinline muted></video>
386
+
387
+ <button type="button" class="capture-back" aria-label="Back">←</button>
388
+ <button type="button" class="capture-flash" hidden aria-label="Turn flash on" aria-pressed="false">
389
+ <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
390
+ <path d="M13 2 L4 14 L11 14 L9 22 L20 9 L13 9 Z" fill="currentColor" />
391
+ </svg>
392
+ </button>
393
+
394
+ <div class="capture-level-bubble" id="captureLevelBubble" data-state="pending" hidden aria-hidden="true">
395
+ <div class="bubble-tolerance"></div>
396
+ <div class="bubble-crosshair-h"></div>
397
+ <div class="bubble-crosshair-v"></div>
398
+ <div class="bubble-indicator" id="captureLevelDot">
399
+ <div class="bubble-indicator-h"></div>
400
+ <div class="bubble-indicator-v"></div>
401
+ </div>
402
+ </div>
403
+
404
+ <div class="capture-chips">
405
+ <div class="status-chip pending" id="captureDistanceChip">
406
+ <span class="chip-dot" aria-hidden="true"></span>
407
+ <span class="chip-label">Distance: starting…</span>
408
+ </div>
409
+ <div class="status-chip pending" id="captureLevelChip" hidden>
410
+ <span class="chip-dot" aria-hidden="true"></span>
411
+ <span class="chip-label">Level: starting…</span>
412
+ </div>
413
+ </div>
414
+
415
+ <p class="capture-status" id="captureStatus">Requesting camera…</p>
416
+
417
+ <div class="capture-controls">
418
+ <button id="captureShutterBtn" type="button" class="primary capture-shutter" disabled>
419
+ Take Photo
420
+ </button>
421
+ </div>
422
+ </section>
423
+ `;
424
+
425
+ const stage = container.querySelector(".step-capture");
426
+ videoEl = container.querySelector("#captureVideo");
427
+ distanceChip = container.querySelector("#captureDistanceChip");
428
+ levelChip = container.querySelector("#captureLevelChip");
429
+ levelBubble = container.querySelector("#captureLevelBubble");
430
+ levelDot = container.querySelector("#captureLevelDot");
431
+ shutterBtn = container.querySelector("#captureShutterBtn");
432
+ flashBtn = container.querySelector(".capture-flash");
433
+ statusEl = container.querySelector("#captureStatus");
434
+
435
+ container.querySelector(".capture-back").addEventListener("click", () => {
436
+ // Tear-down happens in unmount() when the step controller swaps
437
+ // us out; just trigger the back transition.
438
+ nav.back();
439
+ });
440
+ shutterBtn.addEventListener("click", () => shoot(nav));
441
+ flashBtn.addEventListener("click", toggleFlash);
442
+
443
+ // URL-bar inset sync. Same pattern as v5 capture stage.
444
+ const sync = () => syncCaptureInsets(stage);
445
+ sync();
446
+ requestAnimationFrame(sync);
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
457
+ // camera" button click bubbles into this navigation, so we are
458
+ // still inside the gesture's task).
459
+ if (window.OrientationDetector) {
460
+ try {
461
+ orientationStatus = await window.OrientationDetector.requestPermission();
462
+ } catch (err) {
463
+ orientationStatus = "denied";
464
+ }
465
+ } else {
466
+ orientationStatus = "unsupported";
467
+ }
468
+
469
+ try {
470
+ await window.CapturePreview.start(videoEl);
471
+ } catch (err) {
472
+ setStatus(`Camera unavailable: ${err.message || err}`, true);
473
+ return;
474
+ }
475
+ setStatus("Keep the camera parallel to the table, at the right distance from your hand.");
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;
492
+ levelBubble = null;
493
+ levelDot = null;
494
+ shutterBtn = null;
495
+ flashBtn = null;
496
+ statusEl = null;
497
+ },
498
+ };
web_demo/static/mobile/steps/confirm.js ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Step 5 — Confirm the photo + run measurement.
2
+ //
3
+ // Receives `session.imageBlob` from either the upload step (step 3,
4
+ // file picked) or the capture step (step 4, camera shutter). Shows
5
+ // the photo so the user can decide whether to keep it, then on
6
+ // "Start Measurement" POSTs to /api/measure and shows a live
7
+ // elapsed-time counter — the algorithm can take 20-30 s and silence
8
+ // during that wait is the worst UX. When the response arrives, we
9
+ // store it on `session.result` and advance to the result step.
10
+
11
+ import { session } from "../session.js";
12
+ import { postMeasure } from "../../shared/measure-api.js";
13
+
14
+ let elapsedTimer = null;
15
+
16
+ function clearTimerFn() {
17
+ if (elapsedTimer) {
18
+ clearInterval(elapsedTimer);
19
+ elapsedTimer = null;
20
+ }
21
+ }
22
+
23
+ export default {
24
+ mount(container, nav) {
25
+ if (!session.imageBlob) {
26
+ // Defensive: someone reached this step without an image.
27
+ // Send them back to the photo guide rather than leaving them
28
+ // staring at an empty preview.
29
+ nav.goTo("guide");
30
+ return;
31
+ }
32
+
33
+ const sourceLabel =
34
+ session.imageSource === "camera" ? "Captured Photo" : "Uploaded Photo";
35
+
36
+ container.innerHTML = `
37
+ <section class="step step-confirm">
38
+ <header class="step-head">
39
+ <button type="button" class="step-back" aria-label="Back">←</button>
40
+ </header>
41
+ <div class="step-body">
42
+ <div class="panel">
43
+ <p class="hero-eyebrow">Step 3 of 4</p>
44
+ <h2 class="panel-title">${sourceLabel}</h2>
45
+ <div class="image-frame show">
46
+ <img id="confirmPreview" alt="Photo to measure" />
47
+ </div>
48
+ </div>
49
+ </div>
50
+ <footer class="step-foot">
51
+ <!-- Status sits in the footer (which never scrolls) so the
52
+ live "Measuring…" feedback stays visible even when the
53
+ photo is tall enough to push the rest of the panel
54
+ content off-screen. Empty before the user taps Start
55
+ Measurement — no pre-measurement placeholder. -->
56
+ <p class="confirm-status" id="confirmStatus" hidden></p>
57
+ <button id="confirmStartBtn" type="button" class="primary">
58
+ Start Measurement
59
+ </button>
60
+ </footer>
61
+ </section>
62
+ `;
63
+
64
+ const previewImg = container.querySelector("#confirmPreview");
65
+ const statusEl = container.querySelector("#confirmStatus");
66
+ const startBtn = container.querySelector("#confirmStartBtn");
67
+
68
+ previewImg.src = session.imageUrl;
69
+
70
+ container.querySelector(".step-back").addEventListener("click", () => {
71
+ // Sending the user back to the photo-guide step is the sanest
72
+ // default. The capture step would re-open the camera and
73
+ // discard the photo we just captured, which is hardly ever
74
+ // what the user wants.
75
+ nav.goTo("guide");
76
+ });
77
+
78
+ startBtn.addEventListener("click", async () => {
79
+ startBtn.disabled = true;
80
+ statusEl.hidden = false;
81
+
82
+ // Live elapsed-time counter — copy mirrors the desktop's
83
+ // setStatus(`Measuring… Done in under a minute. (${secs}s)`)
84
+ // so both surfaces show identical feedback during the wait.
85
+ const startedAt = Date.now();
86
+ const renderTimer = () => {
87
+ const secs = Math.floor((Date.now() - startedAt) / 1000);
88
+ statusEl.textContent = `Measuring… Done in under a minute. (${secs}s)`;
89
+ statusEl.classList.remove("error");
90
+ };
91
+ renderTimer();
92
+ elapsedTimer = setInterval(renderTimer, 1000);
93
+
94
+ try {
95
+ const result = await postMeasure({
96
+ blob: session.imageBlob,
97
+ kol_name: session.kolName,
98
+ ring_model: session.ringModel,
99
+ gate_telemetry: session.gateTelemetry,
100
+ capture_method: session.imageSource === "camera" ? "camera" : "upload",
101
+ });
102
+ clearTimerFn();
103
+ session.result = result;
104
+ nav.next();
105
+ } catch (err) {
106
+ clearTimerFn();
107
+ statusEl.textContent = `Measurement failed: ${err.message || err}`;
108
+ statusEl.classList.add("error");
109
+ startBtn.disabled = false;
110
+ }
111
+ });
112
+ },
113
+
114
+ unmount() {
115
+ clearTimerFn();
116
+ },
117
+ };
web_demo/static/mobile/steps/form.js ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Step 2 — Name / ID + Ring Model.
2
+ // Mirrors the desktop hero-card's top "controls" block.
3
+ // Persists answers in session so back-nav preserves them.
4
+
5
+ import { session } from "../session.js";
6
+
7
+ export default {
8
+ mount(container, nav) {
9
+ container.innerHTML = `
10
+ <section class="step step-form">
11
+ <header class="step-head">
12
+ <button type="button" class="step-back" aria-label="Back">←</button>
13
+ </header>
14
+ <div class="step-body">
15
+ <div class="panel">
16
+ <p class="hero-eyebrow">Step 1 of 4</p>
17
+ <h2 class="panel-title">Select Ring Model</h2>
18
+ <div class="controls">
19
+ <label>
20
+ <span>Ring Model</span>
21
+ <select id="formRingModel">
22
+ <option value="gen">Gen1/Gen2</option>
23
+ <option value="air">Air</option>
24
+ </select>
25
+ </label>
26
+ <label>
27
+ <span>Name / ID</span>
28
+ <input
29
+ type="text"
30
+ id="formKolName"
31
+ placeholder="e.g. Your Name"
32
+ autocomplete="name"
33
+ />
34
+ </label>
35
+ </div>
36
+ <p class="form-error" id="formError" hidden></p>
37
+ </div>
38
+ </div>
39
+ <footer class="step-foot">
40
+ <button type="button" class="primary step-next">Continue</button>
41
+ </footer>
42
+ </section>
43
+ `;
44
+
45
+ const nameInput = container.querySelector("#formKolName");
46
+ const modelSelect = container.querySelector("#formRingModel");
47
+ const errorEl = container.querySelector("#formError");
48
+
49
+ // Pre-fill from session — back-nav from upload/camera should not
50
+ // ask the user to retype.
51
+ nameInput.value = session.kolName || "";
52
+ modelSelect.value = session.ringModel || "gen";
53
+
54
+ container.querySelector(".step-back").addEventListener("click", nav.back);
55
+ container.querySelector(".step-next").addEventListener("click", () => {
56
+ const name = nameInput.value.trim();
57
+ if (!name) {
58
+ errorEl.textContent = "Please enter a name or ID before continuing.";
59
+ errorEl.hidden = false;
60
+ nameInput.focus();
61
+ return;
62
+ }
63
+ session.kolName = name;
64
+ session.ringModel = modelSelect.value;
65
+ nav.next();
66
+ });
67
+ },
68
+ };
web_demo/static/mobile/steps/guide.js ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Step 3 — Photo guide.
2
+ //
3
+ // Shows the same five-bullet tips list the desktop displays, plus the
4
+ // pre-bundled sample photo as a concrete "like this" example so the
5
+ // user knows exactly what they're aiming for. Two actions at the
6
+ // bottom: a primary "Open Camera" (preferred path — feeds the live
7
+ // capture coach with level + distance gates) and a secondary ghost
8
+ // link "Upload from photos" for users with a usable shot already in
9
+ // the camera roll. Upload bypasses the capture step entirely and
10
+ // hands the file straight to the confirm step.
11
+
12
+ import { session, resetForRetake } from "../session.js";
13
+
14
+ export default {
15
+ mount(container, nav) {
16
+ container.innerHTML = `
17
+ <section class="step step-guide">
18
+ <header class="step-head">
19
+ <button type="button" class="step-back" aria-label="Back">←</button>
20
+ </header>
21
+ <div class="step-body">
22
+ <div class="panel">
23
+ <p class="hero-eyebrow">Step 2 of 4</p>
24
+ <h2 class="panel-title">Photo Guidance</h2>
25
+
26
+ <ul class="capture-tips">
27
+ <li>Place a card of <strong>standard credit card size</strong> beside your hand.</li>
28
+ <li>Hold phone <strong>directly above hand</strong>, parallel to table.</li>
29
+ <li><strong>Spread your fingers naturally</strong>.</li>
30
+ <li><strong>Use plain white background</strong>, a sheet of paper works great.</li>
31
+ <li><strong>Turn on your phone's flash</strong>, it sharpens finger edges.</li>
32
+ </ul>
33
+
34
+ <figure class="guide-example">
35
+ <figcaption>Like this — hand flat, card beside it.</figcaption>
36
+ <img
37
+ src="/static/examples/default_sample.jpg"
38
+ alt="Example photo: hand flat with a credit card placed beside it"
39
+ />
40
+ </figure>
41
+ </div>
42
+ </div>
43
+ <footer class="step-foot">
44
+ <button type="button" class="primary step-next">Open Camera</button>
45
+ <button type="button" class="step-link guide-upload">Upload from photos</button>
46
+ <p class="guide-upload-error" id="guideUploadError" hidden role="alert"></p>
47
+ <input
48
+ type="file"
49
+ id="guideFileInput"
50
+ class="guide-file-input"
51
+ accept="image/*"
52
+ hidden
53
+ />
54
+ </footer>
55
+ </section>
56
+ `;
57
+
58
+ container.querySelector(".step-back").addEventListener("click", nav.back);
59
+ container.querySelector(".step-next").addEventListener("click", nav.next);
60
+
61
+ const fileInput = container.querySelector("#guideFileInput");
62
+ const errorEl = container.querySelector("#guideUploadError");
63
+ const showUploadError = (msg) => {
64
+ if (!errorEl) return;
65
+ errorEl.textContent = msg;
66
+ errorEl.hidden = false;
67
+ };
68
+ const clearUploadError = () => {
69
+ if (!errorEl) return;
70
+ errorEl.hidden = true;
71
+ errorEl.textContent = "";
72
+ };
73
+ container.querySelector(".guide-upload").addEventListener("click", () => {
74
+ clearUploadError();
75
+ // Reset value so picking the same file twice still fires `change`.
76
+ fileInput.value = "";
77
+ fileInput.click();
78
+ });
79
+ fileInput.addEventListener("change", (ev) => {
80
+ const file = ev.target.files && ev.target.files[0];
81
+ if (!file) return;
82
+ // `accept="image/*"` is a hint, not enforcement — Android's "Files"
83
+ // picker lets users pick anything. Validate at source so the
84
+ // downstream confirm/measure steps don't render a broken image
85
+ // or POST garbage and surface a backend 4xx.
86
+ if (!file.type.startsWith("image/") || file.size === 0) {
87
+ showUploadError("Please pick an image file (JPG or PNG).");
88
+ return;
89
+ }
90
+ // Drop any prior session image (camera capture or earlier upload)
91
+ // so the confirm step sees a clean slate.
92
+ resetForRetake();
93
+ session.imageBlob = file;
94
+ session.imageUrl = URL.createObjectURL(file);
95
+ session.imageSource = "upload";
96
+ // Skip the capture step — uploads bypass the live coach by design.
97
+ nav.goTo("confirm");
98
+ });
99
+ },
100
+ };
web_demo/static/mobile/steps/intro.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Step 1 — Intro / value prop.
2
+ // Mirrors the desktop hero copy: red eyebrow, large display headline,
3
+ // muted sub-line. No panel — this lives directly on the gradient
4
+ // background, same as the desktop column-left treatment.
5
+
6
+ export default {
7
+ mount(container, nav) {
8
+ container.innerHTML = `
9
+ <section class="step step-intro">
10
+ <div class="step-body">
11
+ <p class="hero-eyebrow">Femometer Smart Ring Sizer</p>
12
+ <h1 class="hero-headline">Upload a photo to quickly measure ring size</h1>
13
+ <p class="hero-sub">
14
+ Using a card of standard credit card size for scale reference.
15
+ </p>
16
+ </div>
17
+ <footer class="step-foot">
18
+ <button type="button" class="primary step-next">Get Started</button>
19
+ </footer>
20
+ </section>
21
+ `;
22
+ container.querySelector(".step-next").addEventListener("click", nav.next);
23
+ },
24
+ };
web_demo/static/mobile/steps/result.js ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Step 6 — Result.
2
+ //
3
+ // Mirrors the desktop result section: a "Result Overlay" panel
4
+ // showing the algorithm's annotated image, then a "Ring Size
5
+ // Recommendation" panel with one card per measured finger and the
6
+ // ring-size reference table at the bottom. Reads
7
+ // `session.result` (the /api/measure response) and `session.ringModel`.
8
+
9
+ import { session, resetForRetake } from "../session.js";
10
+ import { formatFailReason } from "../../shared/fail-reasons.js";
11
+
12
+ const FINGER_LABEL = {
13
+ index: "Index (Recommended)",
14
+ middle: "Middle",
15
+ ring: "Ring",
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" };
23
+ const RING_SIZE_TABLES = {
24
+ gen: { 6: 16.9, 7: 17.7, 8: 18.6, 9: 19.4, 10: 20.3, 11: 21.1, 12: 21.9, 13: 22.7 },
25
+ air: { 6: 16.6, 7: 17.4, 8: 18.2, 9: 19.0, 10: 19.9, 11: 20.7, 12: 21.5, 13: 22.3 },
26
+ };
27
+
28
+ function escape(s) {
29
+ return String(s == null ? "" : s)
30
+ .replace(/&/g, "&amp;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;");
33
+ }
34
+
35
+ function fingerCardHtml(fn, pf) {
36
+ const label = FINGER_LABEL[fn] || fn;
37
+ const color = FINGER_COLOR[fn] || "#888";
38
+ if (pf.status !== "ok") {
39
+ return `
40
+ <div class="finger-card finger-card-failed" style="border-top: 3px solid ${color};">
41
+ <div class="finger-name">${escape(label)}</div>
42
+ <div class="finger-failed">Failed</div>
43
+ <div class="finger-fail-reason">${escape(pf.fail_reason || "unknown")}</div>
44
+ </div>
45
+ `;
46
+ }
47
+ const widthMm = pf.diameter_cm ? (pf.diameter_cm * 10).toFixed(1) : "—";
48
+ const range = pf.range ? `${pf.range[0]} – ${pf.range[1]}` : "";
49
+ return `
50
+ <div class="finger-card" style="border-top: 3px solid ${color};">
51
+ <div class="finger-name">${escape(label)}</div>
52
+ <div class="finger-size-label">Size</div>
53
+ <div class="finger-size">${escape(pf.best_match)}</div>
54
+ ${range ? `<div class="finger-range">Range: ${escape(range)}</div>` : ""}
55
+ <div class="finger-width">Width: ${escape(widthMm)} mm</div>
56
+ </div>
57
+ `;
58
+ }
59
+
60
+ function buildSizeRefTable(ringModel) {
61
+ const sizeTable = RING_SIZE_TABLES[ringModel] || RING_SIZE_TABLES.gen;
62
+ const modelLabel = RING_MODEL_LABELS[ringModel] || ringModel;
63
+ const rows = Object.entries(sizeTable)
64
+ .map(([size, mm]) => `<tr><td>${size}</td><td>${mm.toFixed(1)}</td></tr>`)
65
+ .join("");
66
+ return `
67
+ <div class="size-ref-table">
68
+ <h3 class="size-ref-title">Size Reference (${escape(modelLabel)})</h3>
69
+ <table>
70
+ <thead><tr><th>Size</th><th>Inner ⌀ (mm)</th></tr></thead>
71
+ <tbody>${rows}</tbody>
72
+ </table>
73
+ </div>
74
+ `;
75
+ }
76
+
77
+ function renderRecommendation(payload, ringModel) {
78
+ // The status banner above this panel already explains the failure
79
+ // case (using the friendly desktop copy via formatFailReason), so
80
+ // we don't repeat a "Measurement Failed" hero here. Just render
81
+ // whatever finger data made it back, the count, and the size
82
+ // reference — useful even when nothing measured.
83
+ const perFinger = (payload && payload.per_finger) || {};
84
+ let cards = "";
85
+ for (const fn of ["index", "middle", "ring"]) {
86
+ const pf = perFinger[fn];
87
+ if (!pf) continue;
88
+ cards += fingerCardHtml(fn, pf);
89
+ }
90
+ const succeeded = (payload && payload.fingers_succeeded) ?? 0;
91
+ const total = (payload && payload.fingers_measured) ?? 0;
92
+ // Count above the cards (not below) so the user reads
93
+ // "X/Y fingers measured" first and immediately understands the
94
+ // cards are a per-finger breakdown rather than a single result.
95
+ return `
96
+ <div class="finger-count">${escape(`${succeeded}/${total} fingers measured`)}</div>
97
+ ${cards ? `<div class="finger-cards">${cards}</div>` : ""}
98
+ ${buildSizeRefTable(ringModel)}
99
+ `;
100
+ }
101
+
102
+ export default {
103
+ mount(container, nav) {
104
+ const data = session.result;
105
+ if (!data) {
106
+ // Defensive — nothing to show, kick back to start.
107
+ nav.goTo("intro");
108
+ return;
109
+ }
110
+ const payload = data.result || data;
111
+ const overlayUrl = data.result_image_url;
112
+ const ringModel = session.ringModel || "gen";
113
+
114
+ // Same success/error rule the desktop uses (web_demo/app.py
115
+ // sets `data.success = result.fail_reason is None`). Status copy
116
+ // is sourced from shared/fail-reasons.js so the mobile and
117
+ // desktop surfaces stay in sync.
118
+ const succeeded = !!data.success && payload && payload.per_finger;
119
+ const statusBanner = succeeded
120
+ ? `
121
+ <div class="result-status result-status-success">
122
+ <p>Measurement complete.</p>
123
+ </div>
124
+ `
125
+ : `
126
+ <div class="result-status result-status-error">
127
+ <p>${escape(formatFailReason(payload && payload.fail_reason))}</p>
128
+ </div>
129
+ `;
130
+
131
+ container.innerHTML = `
132
+ <section class="step step-result">
133
+ <div class="step-body">
134
+ ${statusBanner}
135
+ ${
136
+ overlayUrl
137
+ ? `
138
+ <div class="panel">
139
+ <h2 class="panel-title">Result Overlay</h2>
140
+ <div class="image-frame show">
141
+ <img src="${escape(overlayUrl)}" alt="Measurement overlay" />
142
+ </div>
143
+ </div>
144
+ `
145
+ : ""
146
+ }
147
+
148
+ <div class="panel">
149
+ <h2 class="panel-title">Ring Size Recommendation</h2>
150
+ ${renderRecommendation(payload, ringModel)}
151
+ </div>
152
+ </div>
153
+ <footer class="step-foot">
154
+ <button type="button" class="primary step-retake">Measure Again</button>
155
+ </footer>
156
+ </section>
157
+ `;
158
+
159
+ // "Measure again" returns to the photo-guide step so the user can
160
+ // pick between Open Camera and Upload from photos — the upload
161
+ // entry was added after this button originally jumped straight to
162
+ // the camera. Form fields (Name / Ring Model) are preserved; only
163
+ // the photo + result + telemetry are wiped. Users who want a full
164
+ // reset (e.g. handing the phone to a different person) can
165
+ // refresh the page.
166
+ container.querySelector(".step-retake").addEventListener("click", () => {
167
+ resetForRetake();
168
+ nav.goTo("guide");
169
+ });
170
+ },
171
+ };
web_demo/static/preview/hands.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // v5 Phase 2 — in-browser MediaPipe Hands for the distance gate.
2
+ //
3
+ // Loaded as `<script type="module">` so we can `import` directly from the
4
+ // jsdelivr CDN. The module attaches the public API to window.HandsDetector
5
+ // so the classic-script app.js can call it.
6
+ //
7
+ // CDN choice: jsdelivr-mirrored npm package (default for Phase 2). Future
8
+ // productization on Shopify may require self-hosting under
9
+ // `web_demo/static/vendor/mediapipe/` to satisfy strict CSP — see doc/v5/PRD.md
10
+ // "Risks and open questions".
11
+
12
+ // Pinned to a real published version (0.10.22 from an earlier draft did not
13
+ // exist on jsdelivr; latest at the time of pinning was 0.10.35). Bump only
14
+ // after re-validating that the entrypoint and `/wasm/` subpath both 200.
15
+ import {
16
+ HandLandmarker,
17
+ FilesetResolver,
18
+ } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/vision_bundle.mjs";
19
+
20
+ // Same hand-landmarker model the Python pipeline uses (see
21
+ // src/finger_segmentation.py:55). Hosting it on Google's CDN keeps the
22
+ // frontend / backend symmetric and avoids us redistributing the weights.
23
+ const MODEL_URL =
24
+ "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
25
+ const WASM_URL =
26
+ "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
27
+
28
+ // MediaPipe landmark indices for the palm-MCP span — matches
29
+ // LM_INDEX_MCP / LM_PINKY_MCP in script/analyze_hand_span.py.
30
+ const LM_INDEX_MCP = 5;
31
+ const LM_PINKY_MCP = 17;
32
+
33
+ let landmarker = null;
34
+ let initPromise = null;
35
+
36
+ async function init() {
37
+ if (landmarker) return landmarker;
38
+ if (initPromise) return initPromise;
39
+ initPromise = (async () => {
40
+ const fileset = await FilesetResolver.forVisionTasks(WASM_URL);
41
+ landmarker = await HandLandmarker.createFromOptions(fileset, {
42
+ baseOptions: {
43
+ modelAssetPath: MODEL_URL,
44
+ delegate: "GPU",
45
+ },
46
+ runningMode: "VIDEO",
47
+ numHands: 1,
48
+ minHandDetectionConfidence: 0.3,
49
+ minTrackingConfidence: 0.3,
50
+ });
51
+ return landmarker;
52
+ })();
53
+ try {
54
+ return await initPromise;
55
+ } catch (err) {
56
+ initPromise = null;
57
+ throw err;
58
+ }
59
+ }
60
+
61
+ // Run a single detection on the current `<video>` frame. Returns
62
+ // `{ratio, span_px}` if a hand is detected, `null` otherwise. The ratio is
63
+ // computed against `min(videoWidth, videoHeight)` so it's invariant to
64
+ // portrait vs landscape framing.
65
+ function detect(videoEl) {
66
+ if (!landmarker) return null;
67
+ if (!videoEl || videoEl.readyState < 2) return null;
68
+ const w = videoEl.videoWidth;
69
+ const h = videoEl.videoHeight;
70
+ if (!w || !h) return null;
71
+
72
+ const result = landmarker.detectForVideo(videoEl, performance.now());
73
+ if (!result || !result.landmarks || result.landmarks.length === 0) {
74
+ return null;
75
+ }
76
+ const lm = result.landmarks[0];
77
+ const p5 = lm[LM_INDEX_MCP];
78
+ const p17 = lm[LM_PINKY_MCP];
79
+ if (!p5 || !p17) return null;
80
+
81
+ // Landmark coords from MediaPipe Tasks are normalized [0,1] over the
82
+ // image dimensions, so multiplying by w/h gets pixel coords.
83
+ const dx = (p5.x - p17.x) * w;
84
+ const dy = (p5.y - p17.y) * h;
85
+ const span_px = Math.sqrt(dx * dx + dy * dy);
86
+ const ratio = span_px / Math.min(w, h);
87
+ return { ratio, span_px };
88
+ }
89
+
90
+ window.HandsDetector = { init, detect };
web_demo/static/preview/orientation.js ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // v5 Phase 3 — DeviceOrientationEvent for the level gate.
2
+ //
3
+ // Encapsulates the iOS 13+ requestPermission() dance, falls back gracefully
4
+ // where the API is missing, and exposes a callback-based stream of
5
+ // {beta, gamma} readings. Loaded as a non-module IIFE attached to window so
6
+ // the classic-script app.js can call it directly (matching preview.js).
7
+ //
8
+ // Permission semantics:
9
+ // "granted" — events should fire; caller can attach the listener
10
+ // "denied" — user explicitly declined; respect that and skip the gate
11
+ // "unsupported" — browser doesn't expose DeviceOrientationEvent at all
12
+ //
13
+ // The desktop case (DeviceOrientationEvent exists but no sensor) is reported
14
+ // as "granted" — events will fire but with null beta/gamma. Caller is
15
+ // responsible for treating "no data after some time" as "skipped" and
16
+ // excluding the level signal from the gate.
17
+
18
+ window.OrientationDetector = (function () {
19
+ let activeHandler = null;
20
+ let receivedAny = false;
21
+
22
+ async function requestPermission() {
23
+ if (typeof DeviceOrientationEvent === "undefined") {
24
+ return "unsupported";
25
+ }
26
+ // Non-iOS browsers do not require an explicit grant — they fire events
27
+ // immediately when an attached listener is present (subject to policy).
28
+ if (typeof DeviceOrientationEvent.requestPermission !== "function") {
29
+ return "granted";
30
+ }
31
+ try {
32
+ const result = await DeviceOrientationEvent.requestPermission();
33
+ return result; // "granted" | "denied"
34
+ } catch (err) {
35
+ // Spec says the rejection is permanent for this origin within the
36
+ // session — same observable behaviour as a denial, so map to that.
37
+ return "denied";
38
+ }
39
+ }
40
+
41
+ function start(callback) {
42
+ stop();
43
+ receivedAny = false;
44
+ activeHandler = function (event) {
45
+ // Desktop browsers fire `deviceorientation` with null beta/gamma
46
+ // (the API exists, the sensor doesn't). Drop those silently — the
47
+ // caller's no-data-after-timeout logic decides whether to skip.
48
+ // Also drop non-finite (NaN/Infinity) readings — rare but observed
49
+ // on misbehaving sensors; would otherwise propagate into the gate
50
+ // and CSS `calc()` as NaN, which silently freezes the indicator.
51
+ if (
52
+ event.beta === null ||
53
+ event.gamma === null ||
54
+ !Number.isFinite(event.beta) ||
55
+ !Number.isFinite(event.gamma)
56
+ ) {
57
+ return;
58
+ }
59
+ receivedAny = true;
60
+ callback({ beta: event.beta, gamma: event.gamma });
61
+ };
62
+ window.addEventListener("deviceorientation", activeHandler);
63
+ }
64
+
65
+ function stop() {
66
+ if (activeHandler) {
67
+ window.removeEventListener("deviceorientation", activeHandler);
68
+ activeHandler = null;
69
+ }
70
+ }
71
+
72
+ function hasReceivedData() {
73
+ return receivedAny;
74
+ }
75
+
76
+ return { requestPermission, start, stop, hasReceivedData };
77
+ })();
web_demo/static/preview/preview.js ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // v5 Phase 1 — camera lifecycle + frame capture.
2
+ // No gates yet; that's Phase 2-4. This module owns the MediaStream and
3
+ // exposes start/stop/captureFrame so app.js can wire UI events to it.
4
+ //
5
+ // Loaded as a non-module IIFE attached to window so it can coexist with
6
+ // the existing app.js (which is also non-module). Convert both to ES
7
+ // modules later if v5 adds a bundler.
8
+
9
+ window.CapturePreview = (function () {
10
+ let activeStream = null;
11
+ let activeVideo = null;
12
+
13
+ function isSupported() {
14
+ // getUserMedia requires a secure context (HTTPS or localhost). On HTTP
15
+ // LAN IPs, Chrome leaves navigator.mediaDevices undefined; Safari
16
+ // exposes the namespace but getUserMedia() rejects. Check secure
17
+ // context up-front so the "Use Camera" button stays hidden in
18
+ // insecure-test setups instead of opening a doomed flow.
19
+ return !!(
20
+ window.isSecureContext &&
21
+ navigator.mediaDevices &&
22
+ typeof navigator.mediaDevices.getUserMedia === "function"
23
+ );
24
+ }
25
+
26
+ async function start(videoEl) {
27
+ if (!window.isSecureContext) {
28
+ throw new Error(
29
+ "Camera requires HTTPS. Open the deployed (Hugging Face) URL, or set up HTTPS locally — http://<LAN-IP> won't work."
30
+ );
31
+ }
32
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
33
+ throw new Error("This browser does not expose getUserMedia.");
34
+ }
35
+ stop();
36
+ // facingMode 'environment' = rear camera. 'ideal' (not 'exact') so
37
+ // desktop laptops without a rear camera still open the front cam
38
+ // rather than rejecting outright.
39
+ //
40
+ // Width/height are also `ideal` hints — the UA picks the closest
41
+ // mode the camera actually supports. We ask for 4K because the
42
+ // kol_success training set is ~3024×4032 native iPhone captures and
43
+ // sub-1080 frames noticeably soften the finger boundary. Modern
44
+ // iPhones and high-end Android phones honor 3840×2160; lesser
45
+ // hardware quietly negotiates down (typically to 1920×1080), which
46
+ // is still our previous baseline. No outright rejection.
47
+ const stream = await navigator.mediaDevices.getUserMedia({
48
+ video: {
49
+ facingMode: { ideal: "environment" },
50
+ width: { ideal: 3840 },
51
+ height: { ideal: 2160 },
52
+ },
53
+ audio: false,
54
+ });
55
+ videoEl.srcObject = stream;
56
+ // iOS Safari needs muted + playsinline (set as HTML attrs) to autoplay.
57
+ await videoEl.play();
58
+ activeStream = stream;
59
+ activeVideo = videoEl;
60
+ return stream;
61
+ }
62
+
63
+ function stop() {
64
+ if (activeStream) {
65
+ activeStream.getTracks().forEach((t) => t.stop());
66
+ activeStream = null;
67
+ }
68
+ if (activeVideo) {
69
+ activeVideo.srcObject = null;
70
+ activeVideo = null;
71
+ }
72
+ }
73
+
74
+ function getActiveVideoTrack() {
75
+ if (!activeStream) return null;
76
+ const tracks = activeStream.getVideoTracks();
77
+ return tracks.length ? tracks[0] : null;
78
+ }
79
+
80
+ // Torch (camera flash) support is Chromium-on-Android only — iOS
81
+ // Safari/WebKit does not expose `torch` in MediaTrackCapabilities, and
82
+ // most front cameras lack an LED even on Android. Callers must hide
83
+ // the flash UI when this returns false.
84
+ function isTorchSupported() {
85
+ const track = getActiveVideoTrack();
86
+ if (!track || typeof track.getCapabilities !== "function") return false;
87
+ let caps;
88
+ try {
89
+ caps = track.getCapabilities();
90
+ } catch (err) {
91
+ return false;
92
+ }
93
+ return !!(caps && caps.torch);
94
+ }
95
+
96
+ async function setTorch(on) {
97
+ const track = getActiveVideoTrack();
98
+ if (!track) throw new Error("No active video track");
99
+ await track.applyConstraints({ advanced: [{ torch: !!on }] });
100
+ }
101
+
102
+ // Draw the current video frame to an offscreen canvas at native sensor
103
+ // resolution and encode as a JPEG Blob suitable for FormData submission.
104
+ // The server's existing /api/measure path accepts this with no changes.
105
+ async function captureFrame(videoEl, mimeType, quality) {
106
+ const w = videoEl.videoWidth;
107
+ const h = videoEl.videoHeight;
108
+ if (!w || !h) {
109
+ throw new Error("Video stream has no dimensions yet");
110
+ }
111
+ const canvas = document.createElement("canvas");
112
+ canvas.width = w;
113
+ canvas.height = h;
114
+ const ctx = canvas.getContext("2d");
115
+ ctx.drawImage(videoEl, 0, 0, w, h);
116
+ return new Promise((resolve, reject) => {
117
+ canvas.toBlob(
118
+ (blob) =>
119
+ blob ? resolve(blob) : reject(new Error("toBlob returned null")),
120
+ mimeType || "image/jpeg",
121
+ typeof quality === "number" ? quality : 0.92,
122
+ );
123
+ });
124
+ }
125
+
126
+ return { isSupported, start, stop, captureFrame, isTorchSupported, setTorch };
127
+ })();
web_demo/static/preview/thresholds.js ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // v5 capture-coach gate thresholds.
2
+ // Source of truth: doc/v5/PRD.md and script/analyze_hand_span.py results
3
+ // (72-image kol corpus, 2026-05-06 fit).
4
+ //
5
+ // Loaded as a non-module IIFE attached to window so both hands.js (module)
6
+ // and app.js (classic) can read the same constants. Convert to ES module
7
+ // when v5 introduces a bundler.
8
+
9
+ window.PreviewThresholds = (function () {
10
+ // Distance: ‖landmark[5] - landmark[17]‖ / min(videoWidth, videoHeight).
11
+ // Originally 0.239 (P10 of the success cohort) — narrowed to 0.3 on
12
+ // 2026-05-07 after user feedback that the previous bound accepted
13
+ // too many "still a bit far" frames as green.
14
+ const HAND_SPAN_RATIO_MIN = 0.3;
15
+
16
+ // Soft amber threshold (desktop only — mobile collapsed to red/green
17
+ // on the same date). Between 80% of the hard threshold and the
18
+ // threshold itself, the desktop UI shows "almost there".
19
+ const HAND_SPAN_RATIO_AMBER = HAND_SPAN_RATIO_MIN * 0.8;
20
+
21
+ // Upper distance bound. MediaPipe still returns landmarks when the hand
22
+ // partially clips the frame, so without an upper threshold the gate
23
+ // reads "Distance OK" even when the camera is too close — at which
24
+ // point there is no room left for the credit card and lens distortion
25
+ // biases the edge measurement. Tightened from 0.55 → 0.5 on
26
+ // 2026-05-07 alongside the lower-bound bump; pending a P90 sweep of
27
+ // the success cohort via analyze_hand_span.py.
28
+ const HAND_SPAN_RATIO_MAX = 0.5;
29
+ const HAND_SPAN_RATIO_MAX_AMBER = HAND_SPAN_RATIO_MAX * 0.88;
30
+
31
+ // Level: DeviceOrientationEvent beta (front-back) and gamma (left-right) in
32
+ // degrees. Tightened from 10° to 5° on 2026-05-07 — the previous
33
+ // bound matched the card_not_parallel hard gate but felt too lax in
34
+ // practice; ±5° gets the user closer to a clean top-down shot.
35
+ const LEVEL_BETA_MAX_DEG = 5;
36
+ const LEVEL_GAMMA_MAX_DEG = 5;
37
+
38
+ // Brightness: mean luminance (0–255) of a 64×64 downsample of the preview
39
+ // frame, computed as 0.299R + 0.587G + 0.114B. Below 60 is visibly
40
+ // underexposed and likely to fail edge detection.
41
+ const BRIGHTNESS_MIN_MEAN_LUM = 60;
42
+
43
+ // Anti-jitter: number of consecutive frames a signal must hold green
44
+ // before the gate counts it as passing. Prevents the shutter from flashing
45
+ // on/off as the user wobbles between threshold and threshold-epsilon.
46
+ const GATE_CONSECUTIVE_FRAMES = 3;
47
+
48
+ return {
49
+ HAND_SPAN_RATIO_MIN,
50
+ HAND_SPAN_RATIO_AMBER,
51
+ HAND_SPAN_RATIO_MAX,
52
+ HAND_SPAN_RATIO_MAX_AMBER,
53
+ LEVEL_BETA_MAX_DEG,
54
+ LEVEL_GAMMA_MAX_DEG,
55
+ BRIGHTNESS_MIN_MEAN_LUM,
56
+ GATE_CONSECUTIVE_FRAMES,
57
+ };
58
+ })();
web_demo/static/shared/fail-reasons.js ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Friendly status messages for /api/measure failures.
2
+ //
3
+ // Currently consumed by the mobile result step. The desktop
4
+ // `static/app.js` keeps an inline duplicate of this map until the
5
+ // Phase 4 desktop refactor migrates it to import from here. Until
6
+ // then, edits to copy must land in BOTH places — that's tracked in
7
+ // doc/v6/Progress.md.
8
+
9
+ export const FAIL_REASON_MESSAGES = {
10
+ card_not_detected:
11
+ "Card not detected. A card of standard credit card dimensions (85.6 × 54 mm) is required as a scale reference to measure your finger width. Place the card beside your hand on a plain white background (e.g. a sheet of paper), and turn on your phone's flash.",
12
+ card_not_parallel:
13
+ "Card scale calibration failed. Keep your phone parallel to the card. Use a card of standard credit card dimensions (85.6 × 54 mm) as the reference.",
14
+ card_near_edge:
15
+ "Card appears cropped. Place the entire card within the photo frame.",
16
+ card_too_small:
17
+ "Card looks too small in the photo. Move your phone closer to the table so the card takes up a larger portion of the frame, then retake.",
18
+ hand_not_detected:
19
+ "Hand not detected. Place your hand flat on a plain white background (e.g. a sheet of paper), and spread your fingers naturally.",
20
+ finger_isolation_failed:
21
+ "Could not isolate the selected finger. Keep one target finger extended and separated.",
22
+ finger_not_fully_visible:
23
+ "Finger is partially out of frame. Move hand to center of photo.",
24
+ finger_mask_too_small:
25
+ "Finger region is too small. Move closer and use a higher-resolution photo.",
26
+ fingers_too_close:
27
+ "Fingers are too close together. Spread your fingers apart naturally.",
28
+ contour_extraction_failed:
29
+ "Finger contour extraction failed. Improve lighting and reduce background clutter.",
30
+ axis_estimation_failed:
31
+ "Finger axis estimation failed. Keep the finger straight and fully visible.",
32
+ zone_localization_failed:
33
+ "Ring zone localization failed. Keep more of the finger base visible.",
34
+ width_measurement_failed:
35
+ "Width measurement failed. Retake with phone parallel to the table and steady focus.",
36
+ sobel_edge_refinement_failed:
37
+ "Edge refinement failed. Turn on flash or use stronger, even lighting.",
38
+ width_unreasonable:
39
+ "Measured width is out of range. Retake with the phone parallel to the table.",
40
+ disagreement_with_contour:
41
+ "Edge methods disagree too much. Retake with cleaner edges and more even lighting.",
42
+ all_fingers_failed:
43
+ "Could not measure any fingers. Ensure hand is flat with fingers spread and well-lit.",
44
+ image_too_blurry:
45
+ "Photo is blurry. Hold your phone steady or use a tripod.",
46
+ image_underexposed:
47
+ "Photo is too dark. Turn on flash or improve lighting.",
48
+ image_overexposed:
49
+ "Photo is too bright. Avoid direct sunlight or strong overhead light.",
50
+ image_low_contrast:
51
+ "Photo has low contrast. Use a different background color.",
52
+ image_resolution_too_low:
53
+ "Photo resolution is too low. Use the rear camera at full resolution.",
54
+ image_quality_low_lighting:
55
+ "Lighting is uneven. Turn on flash and shoot from directly above.",
56
+ };
57
+
58
+ export function formatFailReason(failReason) {
59
+ if (!failReason) {
60
+ return "Measurement failed.";
61
+ }
62
+ if (failReason.startsWith("quality_score_low_")) {
63
+ return "Low edge quality detected. Turn on flash and retake.";
64
+ }
65
+ if (failReason.startsWith("consistency_low_")) {
66
+ return "Edge detection was inconsistent. Keep phone parallel to table and retry.";
67
+ }
68
+ return (
69
+ FAIL_REASON_MESSAGES[failReason] ||
70
+ "Measurement failed. Please retake the photo and try again."
71
+ );
72
+ }
web_demo/static/shared/measure-api.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // POST a captured frame to /api/measure. Returns the parsed response
2
+ // JSON unchanged so callers can render the same shape the desktop UI
3
+ // already understands.
4
+ //
5
+ // Currently consumed only by the mobile capture step. Phase 4 of the
6
+ // v6 plan migrates the desktop submit handler to this helper too;
7
+ // until then `web_demo/static/app.js` keeps its inline fetch call.
8
+
9
+ const DEFAULTS = {
10
+ finger_index: "index",
11
+ mode: "multi",
12
+ edge_method: "mask",
13
+ ring_model: "gen",
14
+ // Server reads "1" as truthy; anything else (including "on", "0",
15
+ // "") is treated as off. Mobile flow does not surface the AI toggle,
16
+ // so default off — opt-in lives on the desktop dev page.
17
+ ai_explain: "0",
18
+ capture_method: "camera",
19
+ };
20
+
21
+ export async function postMeasure({
22
+ blob,
23
+ kol_name,
24
+ gate_telemetry = null,
25
+ ...overrides
26
+ } = {}) {
27
+ if (!blob) throw new Error("postMeasure: blob is required");
28
+ if (!kol_name) throw new Error("postMeasure: kol_name is required");
29
+
30
+ const settings = { ...DEFAULTS, ...overrides };
31
+ const formData = new FormData();
32
+ formData.append("image", blob, "capture.jpg");
33
+ formData.append("kol_name", kol_name);
34
+ formData.append("finger_index", settings.finger_index);
35
+ formData.append("mode", settings.mode);
36
+ formData.append("edge_method", settings.edge_method);
37
+ formData.append("ring_model", settings.ring_model);
38
+ formData.append("ai_explain", settings.ai_explain);
39
+ formData.append("capture_method", settings.capture_method);
40
+ if (gate_telemetry) {
41
+ formData.append("gate_telemetry", JSON.stringify(gate_telemetry));
42
+ }
43
+
44
+ const response = await fetch("/api/measure", {
45
+ method: "POST",
46
+ body: formData,
47
+ });
48
+ if (!response.ok) {
49
+ const text = await response.text().catch(() => "");
50
+ throw new Error(`Measurement request failed (${response.status}): ${text.slice(0, 120)}`);
51
+ }
52
+ return response.json();
53
+ }
web_demo/static/shared/tokens.css ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Design tokens shared by the desktop (`styles.css`) and mobile
2
+ (`mobile/mobile.css`) surfaces. Keep this file colors-and-radii only —
3
+ no layout, no per-component rules — so both surfaces can evolve
4
+ independently while staying visually coherent. */
5
+ :root {
6
+ --bg-1: #f5f1e7;
7
+ --bg-2: #eedad5;
8
+ --bg-3: #e7efe8;
9
+ --ink: #2b1f1f;
10
+ --ink-soft: #4b3d3d;
11
+ --accent: #bf3a2b;
12
+ --accent-dark: #8f2b22;
13
+ --sand: #f9f4ec;
14
+ --shadow: rgba(34, 26, 26, 0.12);
15
+ --border: rgba(45, 33, 33, 0.18);
16
+ }
web_demo/static/styles.css CHANGED
@@ -1,20 +1,20 @@
1
- :root {
2
- --bg-1: #f5f1e7;
3
- --bg-2: #eedad5;
4
- --bg-3: #e7efe8;
5
- --ink: #2b1f1f;
6
- --ink-soft: #4b3d3d;
7
- --accent: #bf3a2b;
8
- --accent-dark: #8f2b22;
9
- --sand: #f9f4ec;
10
- --shadow: rgba(34, 26, 26, 0.12);
11
- --border: rgba(45, 33, 33, 0.18);
12
- }
13
 
14
  * {
15
  box-sizing: border-box;
16
  }
17
 
 
 
 
 
 
 
 
 
18
  body {
19
  margin: 0;
20
  min-height: 100vh;
@@ -140,7 +140,7 @@ body {
140
  background: rgba(191, 58, 43, 0.06);
141
  border-left: 3px solid rgba(191, 58, 43, 0.55);
142
  border-radius: 8px;
143
- font-size: 0.85rem;
144
  color: var(--ink-soft);
145
  line-height: 1.5;
146
  }
@@ -173,7 +173,11 @@ select,
173
  border: 1px solid var(--border);
174
  border-radius: 12px;
175
  padding: 10px 12px;
176
- font-size: 0.95rem;
 
 
 
 
177
  background: white;
178
  color: var(--ink);
179
  width: 100%;
@@ -197,6 +201,13 @@ select,
197
  box-shadow: 0 12px 24px rgba(191, 58, 43, 0.25);
198
  }
199
 
 
 
 
 
 
 
 
200
  .status {
201
  margin-top: 12px;
202
  font-size: 0.9rem;
 
1
+ /* Design tokens (CSS variables) live in shared/tokens.css so the mobile
2
+ surface can pick up the same palette. CSS @import must be the first
3
+ rule in a stylesheet, so it goes above everything else here. */
4
+ @import url("./shared/tokens.css");
 
 
 
 
 
 
 
 
5
 
6
  * {
7
  box-sizing: border-box;
8
  }
9
 
10
+ /* Force the HTML `hidden` attribute to win over class-level display rules.
11
+ The user-agent `[hidden]` rule is `display: none` at attribute specificity
12
+ (0,0,1,0), which loses to any class rule. We use `!important` because that's
13
+ the only way to beat author-level display declarations across the codebase. */
14
+ [hidden] {
15
+ display: none !important;
16
+ }
17
+
18
  body {
19
  margin: 0;
20
  min-height: 100vh;
 
140
  background: rgba(191, 58, 43, 0.06);
141
  border-left: 3px solid rgba(191, 58, 43, 0.55);
142
  border-radius: 8px;
143
+ font-size: 1.0rem;
144
  color: var(--ink-soft);
145
  line-height: 1.5;
146
  }
 
173
  border: 1px solid var(--border);
174
  border-radius: 12px;
175
  padding: 10px 12px;
176
+ /* iOS Safari auto-zooms the page when an input's computed font-size
177
+ is below 16px. The zoom doesn't always restore cleanly when the
178
+ keyboard dismisses, leaving the layout viewport offset. 16px
179
+ (= 1rem at default html font-size) keeps Safari hands-off. */
180
+ font-size: 1rem;
181
  background: white;
182
  color: var(--ink);
183
  width: 100%;
 
201
  box-shadow: 0 12px 24px rgba(191, 58, 43, 0.25);
202
  }
203
 
204
+ .primary:disabled {
205
+ opacity: 0.55;
206
+ cursor: not-allowed;
207
+ transform: none;
208
+ box-shadow: none;
209
+ }
210
+
211
  .status {
212
  margin-top: 12px;
213
  font-size: 0.9rem;
web_demo/templates/mobile.html ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#f5f1e7" />
7
+ <title>Ring Size · Mobile</title>
8
+ <link rel="stylesheet" href="/static/mobile/mobile.css" />
9
+ </head>
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
+
20
+ <!-- Capture-coach modules. Loaded up front so they're ready by the
21
+ time the user reaches the capture step (step 4). They register
22
+ on window.* so the mobile capture step can reach them via
23
+ window.HandsDetector / CapturePreview / OrientationDetector. -->
24
+ <script src="/static/preview/thresholds.js"></script>
25
+ <script src="/static/preview/preview.js"></script>
26
+ <script src="/static/preview/orientation.js"></script>
27
+ <script type="module" src="/static/preview/hands.js"></script>
28
+
29
+ <script type="module" src="/static/mobile/mobile.js"></script>
30
+ </body>
31
+ </html>