Jelajahi Sumber

feat: add Adjutant P0 mobile prototype

wangkangyjy 2 hari lalu
induk
melakukan
023f068b0b
62 mengubah file dengan 8950 tambahan dan 0 penghapusan
  1. 4 0
      prototype/adjutant-mobile/.gitignore
  2. 2 0
      prototype/adjutant-mobile/.npmrc
  3. 5 0
      prototype/adjutant-mobile/.openai/hosting.json
  4. 78 0
      prototype/adjutant-mobile/AGENTS.md
  5. TEMPAT SAMPAH
      prototype/adjutant-mobile/browser-full-final.png
  6. TEMPAT SAMPAH
      prototype/adjutant-mobile/browser-full.png
  7. TEMPAT SAMPAH
      prototype/adjutant-mobile/design-comparison-final.png
  8. TEMPAT SAMPAH
      prototype/adjutant-mobile/design-comparison-p0-complete.png
  9. TEMPAT SAMPAH
      prototype/adjutant-mobile/design-comparison.png
  10. 57 0
      prototype/adjutant-mobile/design-qa.md
  11. TEMPAT SAMPAH
      prototype/adjutant-mobile/final-visual-target.png
  12. TEMPAT SAMPAH
      prototype/adjutant-mobile/implementation-home-final.png
  13. TEMPAT SAMPAH
      prototype/adjutant-mobile/implementation-home-p0-complete.png
  14. TEMPAT SAMPAH
      prototype/adjutant-mobile/implementation-home-p0-screen.png
  15. TEMPAT SAMPAH
      prototype/adjutant-mobile/implementation-home.png
  16. 12 0
      prototype/adjutant-mobile/index.html
  17. 30 0
      prototype/adjutant-mobile/mobile-runtime.lock.json
  18. 2281 0
      prototype/adjutant-mobile/package-lock.json
  19. 34 0
      prototype/adjutant-mobile/package.json
  20. 18 0
      prototype/adjutant-mobile/playwright.config.ts
  21. TEMPAT SAMPAH
      prototype/adjutant-mobile/public/assets/android/Keyboard.png
  22. TEMPAT SAMPAH
      prototype/adjutant-mobile/public/assets/android/Pixel10.png
  23. 6 0
      prototype/adjutant-mobile/public/assets/android/navigation-bar.svg
  24. TEMPAT SAMPAH
      prototype/adjutant-mobile/public/assets/iphone/Bezel.png
  25. TEMPAT SAMPAH
      prototype/adjutant-mobile/public/assets/iphone/Keyboard.png
  26. 7 0
      prototype/adjutant-mobile/public/assets/status/ios-status-icons.svg
  27. 5 0
      prototype/adjutant-mobile/public/assets/status/status-icons.svg
  28. 24 0
      prototype/adjutant-mobile/public/brand/emoon-cross.svg
  29. TEMPAT SAMPAH
      prototype/adjutant-mobile/public/brand/registration-illustration.png
  30. 33 0
      prototype/adjutant-mobile/scripts/check-mobile-runtime.mjs
  31. 21 0
      prototype/adjutant-mobile/scripts/prepare-sites-build.mjs
  32. 48 0
      prototype/adjutant-mobile/scripts/update-mobile-runtime-lock.mjs
  33. TEMPAT SAMPAH
      prototype/adjutant-mobile/source-normalized.png
  34. 10 0
      prototype/adjutant-mobile/src/App.tsx
  35. 999 0
      prototype/adjutant-mobile/src/Prototype.tsx
  36. 12 0
      prototype/adjutant-mobile/src/main.tsx
  37. 136 0
      prototype/adjutant-mobile/src/mobile/BottomSheet.tsx
  38. 31 0
      prototype/adjutant-mobile/src/mobile/COMPONENTS.md
  39. 257 0
      prototype/adjutant-mobile/src/mobile/Carousel.tsx
  40. 115 0
      prototype/adjutant-mobile/src/mobile/Device.tsx
  41. 234 0
      prototype/adjutant-mobile/src/mobile/FlowStack.tsx
  42. 241 0
      prototype/adjutant-mobile/src/mobile/Keyboard.tsx
  43. 69 0
      prototype/adjutant-mobile/src/mobile/MobileCursor.tsx
  44. 50 0
      prototype/adjutant-mobile/src/mobile/MobileRuntime.tsx
  45. 466 0
      prototype/adjutant-mobile/src/mobile/MobileScroll.tsx
  46. 144 0
      prototype/adjutant-mobile/src/mobile/PhoneFrame.tsx
  47. 6 0
      prototype/adjutant-mobile/src/mobile/assets.ts
  48. 109 0
      prototype/adjutant-mobile/src/mobile/components.tsx
  49. 68 0
      prototype/adjutant-mobile/src/mobile/geometry.ts
  50. 18 0
      prototype/adjutant-mobile/src/mobile/index.ts
  51. 1974 0
      prototype/adjutant-mobile/src/prototype.css
  52. 740 0
      prototype/adjutant-mobile/src/styles.css
  53. 1 0
      prototype/adjutant-mobile/src/vite-env.d.ts
  54. 242 0
      prototype/adjutant-mobile/tests/mobile-runtime.spec.ts
  55. 82 0
      prototype/adjutant-mobile/tests/runtime-fixture.css
  56. 12 0
      prototype/adjutant-mobile/tests/runtime-fixture.html
  57. 152 0
      prototype/adjutant-mobile/tests/runtime-fixture.tsx
  58. 68 0
      prototype/adjutant-mobile/tests/sites-worker.test.mjs
  59. 21 0
      prototype/adjutant-mobile/tsconfig.json
  60. 13 0
      prototype/adjutant-mobile/vite.config.ts
  61. TEMPAT SAMPAH
      prototype/adjutant-mobile/voice-slot-filling.png
  62. 15 0
      prototype/adjutant-mobile/worker/index.js

+ 4 - 0
prototype/adjutant-mobile/.gitignore

@@ -0,0 +1,4 @@
+node_modules/
+dist/
+test-results/
+playwright-report/

+ 2 - 0
prototype/adjutant-mobile/.npmrc

@@ -0,0 +1,2 @@
+fund=false
+audit=false

+ 5 - 0
prototype/adjutant-mobile/.openai/hosting.json

@@ -0,0 +1,5 @@
+{
+  "project_id": "appgprj_6a660be547b08191bd34a12450f9bda9",
+  "d1": null,
+  "r2": null
+}

+ 78 - 0
prototype/adjutant-mobile/AGENTS.md

@@ -0,0 +1,78 @@
+# Mobile Prototype Agent Guide
+
+## Prototype Instructions
+
+### Product decisions for this prototype
+
+- The external product name is “医梦患者智能服务门户”; the internal codename is Adjutant.
+- P0 is a single-hospital, fixed-patient demo with three services: smart registration, report interpretation, and TCM tongue assessment.
+- Voice is the primary patient input. The agent must extract slots, visibly ask for missing or conflicting information, and only expose the next business action after completeness and safety gates pass.
+- Keep the stable portal shell, patient status/todo module, visual service cards, and health-record result center. Voice does not replace visual confirmation, structured selections, risk notices, or result cards.
+
+In ChatGPT Work Mode, run `sites-preview start "$PWD"`, open `http://terminal.local:4173/` in the cloud browser, and verify the rendered app and its primary interactions. Keep that preview open and tell the user to inspect it in the cloud browser; do not present the local URL as a user-facing chat link. In Codex Desktop, run the local server yourself, open the preview in the in-app browser, and provide the clickable local URL. Do not deploy to Sites unless the user explicitly asks to share, publish, or deploy. Do not give the user server-start instructions when you can run it.
+
+Before planning or implementing any mobile-app change, read this `AGENTS.md` in full. It is the source of truth for the template's runtime and component guidance.
+
+Before making substantial visual changes, use the Product Design plugin's `get-context` skill when the visual source is unclear or no longer matches the current goal. When the user gives durable prototype-specific design feedback, preferences, or decisions, record them in `AGENTS.md`.
+
+When implementing from a selected generated mock, treat that image as the source of truth for layout, component anatomy, density, spacing, color, typography, visible content, and hierarchy.
+
+## Editing Boundary
+
+- Build app-specific UI in `src/Prototype.tsx` and `src/prototype.css`.
+- Treat `src/App.tsx`, `src/main.tsx`, `src/styles.css`, `src/mobile/`, `public/assets/iphone/`, `public/assets/android/`, `public/assets/status/`, `vite.config.ts`, `worker/index.js`, and `scripts/prepare-sites-build.mjs` as protected runtime files. Do not edit, replace, remove, or recreate them unless the user explicitly asks to change the mobile runtime itself. For an explicit runtime change, update the affected lock hashes only after verifying the new runtime behavior.
+- Run `npm run check:runtime` before preview or handoff. If it fails, restore the protected runtime instead of weakening or bypassing the check.
+- `npm run build` preserves the mobile runtime and prepares the static Cloudflare Worker output required by Sites. Before a Sites handoff, confirm `dist/client/index.html`, `dist/server/index.js`, `dist/.openai/hosting.json`, and source `.openai/hosting.json` exist, then run `npm run test:sites`. Do not replace this project with a Vinext starter.
+
+## Runtime Contract
+
+- Preserve the mobile device runtime unless the user's task explicitly asks otherwise. Do not replace it with a standalone page. Visual fidelity applies to app-owned content inside the device screen, not to template-owned device chrome.
+- Keep `App` composed around `PhoneFrame` -> `KeyboardProvider`, with `StatusBar`, app content, `HomeIndicator`, and `KeyboardDock` mounted inside the phone frame. `StatusBar` and the iOS home indicator are overlaid device chrome. When the Android keyboard is closed, the app viewport reserves the protected navigation-bar region instead of painting behind it. When the Android keyboard is open, preserve the current full-screen keyboard layout: its asset includes the IME navigation strip and the separate black navigation bar is hidden. iOS screens continue to paint behind the home-indicator area and own their safe-area content padding.
+- Preserve the `iPhone` / `Pixel 10` device picker and both calibrated device presets. The Pixel screen is `427 x 952`; its `32 x 32` camera circle and `public/assets/android/navigation-bar.svg` bottom navigation bar are protected device chrome, not app content.
+- Preserve the device picker's intentionally lightweight Codex styling in the top-right corner: its trigger wrapper is borderless and transparent, its trigger sizes to content, and its right-aligned menu uses the compact 3px inset plus the specified hairline and elevation shadow layers. Keep the prototype root and default app screen white.
+- Preserve `StatusBar` as live device chrome, including its platform-specific typography, source status-icon assets, and spacing. Pixel 10 uses Roboto, Android indicators, and 32px top, left, and right padding. iPhone uses its iOS indicators, system typography, and calibrated spacing. Do not hardcode screenshot times like `9:41` into the status bar, replace its real-time clock, or move status bar content into app markup unless the user explicitly asks for a fixed/mock device time.
+- `PhoneFrame` owns the calibrated device frame, screen portal, device picker, camera cutout, and custom cursor. Keep device assets in `public/assets/iphone/` and `public/assets/android/`; if an asset fails to load, repair the asset path or restore the asset instead of removing the frame, keyboard, or image render.
+- Use `MobileScroll` directly for simple single-screen prototypes. Use `FlowStack` for conventional multi-screen flows whose routes can own their fixed header and footer; when using it, define each route as a `FlowScreen`: `{ id, header?, headerHeight?, footer?, footerHeight?, render }`, and use `flow.push(screen)`, `flow.pop()`, and `flow.replace(screen)` from `FlowStack` render callbacks or `useFlow()` instead of introducing another router.
+- Use `Carousel` for a carousel, horizontal rail, swipeable cards, image or media strip, horizontally scrollable cards, chip rail, or other horizontal collection.
+- For a layered app shell—such as a persistent composer, independently presented sheet, pushed/peek sidebar, or app-wide transition—compose directly in `Prototype.tsx` rather than forcing it through `FlowStack`. Keep app-owned fixed chrome as sibling layers outside `MobileScroll`.
+- When using `FlowScreen`, put route-owned fixed headers or footers in `FlowScreen.header` or `FlowScreen.footer`. Set `headerHeight` to the visible app-toolbar height; `FlowStack` adds the device's top safe-area/status-bar inset automatically. Do not include `StatusBar` or its height in the header. Set `footerHeight` to the full app-footer height. `FlowScreen.footer` is an overlay, not reserved layout space; screens using it must add their own bottom content padding such as `padding-bottom: calc(var(--flow-footer-height) + var(--mobile-safe-area-height) + 24px)` so final content can scroll above the footer while still painting behind it.
+- Render only scrollable content inside `MobileScroll`; it is for content that should move with scroll and rubber-band overscroll. Keep app-owned headers, nav bars, tabs, composers, and overlays outside it. This keeps scroll physics, safe areas, keyboard insets, scrollbars, and drag click suppression active without letting content paint under fixed chrome.
+- Buttons, links, cards, and images inside `MobileScroll` should still allow drag scrolling when the pointer moves beyond tap slop. Use `data-scroll-drag="ignore"` only for rare controls that must own the drag gesture themselves.
+- Do not add `var(--keyboard-height)` to ordinary screen/content padding inside `MobileScroll`; the scroll viewport already shrinks above the simulated keyboard. For custom fixed composers, search bars, or toast chrome, use `useKeyboardInsets().bottomInset`. It is relative to the app viewport: Android returns `0` while the closed-keyboard viewport already reserves navigation, then returns the keyboard height while open; iOS continues to clear the home indicator while closed and ride directly above the keyboard while open. Do not pin custom bottom chrome to `bottom: 0` or only `keyboardHeight`.
+- Use `KeyboardInput`, `KeyboardTextarea`, or `MobileTextField` for every text-entry control. A raw `input` or `textarea` disconnects focus, keyboard animation, safe-area insets, and attached surfaces.
+- Use `BottomSheet` for phone-scoped sheets. Its props are `open`, `onOpenChange`, `title`, optional `description`, optional `snap`, and `children`; it renders through the phone screen portal and dismisses the keyboard before opening.
+
+## Horizontal Carousels
+
+- Use `Carousel` for horizontally draggable cards, images, media, chips, or other horizontal collections. Do not recreate these with `overflow-x`, custom pointer handlers, or a generic div.
+- `Carousel` can be nested directly inside `MobileScroll`. It owns horizontal gestures and automatically yields vertical gestures to the parent.
+- Never put `data-scroll-drag="ignore"` on or around a `Carousel`; doing so prevents vertical parent scrolling when a gesture begins inside it.
+- Do not add CSS scroll snapping to `Carousel`; its runtime owns momentum and release motion.
+- Use `data-scroll-drag="ignore"` only when a control must prevent parent scrolling in every drag direction.
+
+See `src/mobile/COMPONENTS.md` for the full component and gesture contract.
+
+## Keyboard Rule
+
+The simulated keyboard is a separate top-layer component. Before presenting anything that behaves like iOS navigation or modal UI, dismiss it first.
+
+Call `keyboard.hide()` before:
+
+- pushing, popping, or replacing FlowStack routes
+- opening bottom sheets, action sheets, dialogs, menus, or navigation sheets
+- starting transitions where the destination should not inherit text-input focus
+
+`FlowStack` already hides the keyboard for `push`, `pop`, and `replace`. `BottomSheet` already hides it before opening. If you add new modal/sheet/navigation primitives, follow the same rule.
+
+When a composer, search surface, or other keyboard-attached component closes, call `keyboard.hide()` in the same event before changing that component's open state. Position attached surfaces from `useKeyboardInsets()` rather than a separate timer or visibility flag so both dismiss together.
+
+When any text-entry control loses focus, dismiss the simulated keyboard. If the control is custom or does not use the runtime's keyboard-aware fields, handle its blur event and call `keyboard.hide()` explicitly. Keep the keyboard open only when focus is moving directly to another text-entry control that should share the same keyboard session.
+
+## Interaction Rules
+
+- Do not trigger buttons or inputs after a pointer has become a drag. Preserve the drag suppression behavior in `MobileScroll`.
+- Do not allow native browser image/file dragging inside the phone frame. Preserve the phone-level `dragstart` suppression and non-draggable image styles so scroll drags that begin on images still scroll the prototype.
+- Use `KeyboardInput`, `KeyboardTextarea`, or `MobileTextField` for text entry so the simulated keyboard and safe-area insets stay connected.
+- Fixed phone chrome should not animate with pushed screens. Screen content can animate; the status bar, camera cutout, and preview chrome should stay put.
+- Keep the keyboard below the home indicator/safe area layer in z-index, and above ordinary app UI while visible.
+- Keep the home indicator as the topmost safe-area layer in the z-index above everything else in the prototype.

TEMPAT SAMPAH
prototype/adjutant-mobile/browser-full-final.png


TEMPAT SAMPAH
prototype/adjutant-mobile/browser-full.png


TEMPAT SAMPAH
prototype/adjutant-mobile/design-comparison-final.png


TEMPAT SAMPAH
prototype/adjutant-mobile/design-comparison-p0-complete.png


TEMPAT SAMPAH
prototype/adjutant-mobile/design-comparison.png


+ 57 - 0
prototype/adjutant-mobile/design-qa.md

@@ -0,0 +1,57 @@
+# Product Design QA — Adjutant P0 complete flow
+
+## Comparison target
+
+- Source visual truth: `final-visual-target.png`
+- Browser-rendered implementation: `implementation-home-p0-complete.png`
+- Normalized phone-screen crop: `implementation-home-p0-screen.png`
+- Side-by-side evidence: `design-comparison-p0-complete.png`
+- Source pixels: 852 × 1846
+- Browser screenshot pixels: 679 × 818
+- Normalized comparison: 393 × 852 per side, deviceScaleFactor 1
+- State: single-hospital P0 portal home, fixed Mock patient
+
+The source is a full scroll-length mobile board without device chrome. The implementation uses the protected iPhone runtime and a visible above-the-fold viewport. The comparison therefore evaluates app-owned hierarchy, spacing, colors, cards, copy and entry prominence; the device bezel and scroll crop are expected runtime differences.
+
+## Full-view comparison
+
+No actionable P0, P1 or P2 mismatch remains.
+
+- Fonts and typography: heading hierarchy, weights, muted supporting copy and compact card labels match the selected direction.
+- Spacing and layout rhythm: hospital header, assistant entry, status/todo strip, registration hero, two secondary services and bottom navigation preserve the source order and density.
+- Colors and visual tokens: medical purple, cyan accent, pale blue surface and white-card system are consistent with the source.
+- Image quality and asset fidelity: the supplied registration illustration and brand mark render sharply; Radix icons are used for standard interface actions.
+- Copy and content: the only intentional product change is “语音挂号”, which reflects the frozen voice-first interaction model more accurately than “开始分诊”.
+
+Focused region comparison was not required because the implementation reuses the already approved home visual system and the new work is primarily additional route/state behavior. The report-confirmation and tongue-processing screens use the same established card, type and spacing tokens.
+
+## Interaction verification
+
+Verified in the Codex in-app browser at `http://127.0.0.1:4173/`:
+
+1. Home “语音挂号” → voice transcript → missing doctor follow-up → complete slot summary → query schedule.
+2. Registration emergency demo → ordinary registration stops → emergency guidance and 120 action appear.
+3. New-report todo → report voice context → select hospital report → owner/source/final-status confirmation.
+4. Tongue service → voice context → consent → symptom question → qualified demo image → tongue MCP processing state.
+5. Services, health records and root navigation remain accessible.
+
+Browser console: no errors; only Vite connection and React development information messages.
+
+## Comparison history
+
+### Iteration 1
+
+- Finding: the report todo previously opened a completed result directly.
+- Fix: it now enters the report agent, confirms source and ownership, then proceeds to processing.
+- Finding: the tongue flow skipped the visible MCP-processing stage.
+- Fix: added image-quality completion, existing tongue MCP call and evidence-fusion progress.
+- Finding: the emergency branch was not directly reachable from the voice-first registration entry.
+- Fix: added an explicit P0 emergency-door demo and urgent text routing.
+- Post-fix evidence: all three paths and the emergency branch were exercised in the in-app browser.
+
+## Follow-up polish
+
+- P3: add a real speech-to-text adapter when the P0 demo moves beyond scripted voice transcripts.
+- P3: connect task state to a shared mock store so newly completed tasks change the record list during the same session.
+
+final result: passed

TEMPAT SAMPAH
prototype/adjutant-mobile/final-visual-target.png


TEMPAT SAMPAH
prototype/adjutant-mobile/implementation-home-final.png


TEMPAT SAMPAH
prototype/adjutant-mobile/implementation-home-p0-complete.png


TEMPAT SAMPAH
prototype/adjutant-mobile/implementation-home-p0-screen.png


TEMPAT SAMPAH
prototype/adjutant-mobile/implementation-home.png


+ 12 - 0
prototype/adjutant-mobile/index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Mobile Prototype Boilerplate</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 30 - 0
prototype/adjutant-mobile/mobile-runtime.lock.json

@@ -0,0 +1,30 @@
+{
+  "scripts/check-mobile-runtime.mjs": "ee85738cc2ef889c0b457d8ccac61e017b8c5d16f393e766ec9fb7d11c9aff7f",
+  "scripts/prepare-sites-build.mjs": "b6a6adaa4fab3234676116dd1c9cb6611275ab9d92dd26f5bf402393e3744bf6",
+  "scripts/update-mobile-runtime-lock.mjs": "b4e1aa41133f19ba8bb32685b3ec05798b1ceabaf72c328419d545aa60fe1f0b",
+  "vite.config.ts": "da36eba05b845c3b0d69d823a515f6f7d9612992126ed2fe32c2809815ec73ba",
+  "src/App.tsx": "7054b4e14304ab33153d3a2e860aa43d2a4f69e8a69630b4a37a2cbcd4ad2f16",
+  "src/main.tsx": "01b7049715148ab645637905ccbbc91fc3f87659198a309541c2d1f03bb537d0",
+  "src/styles.css": "844c67b27cf5e38ba395153145d058c929c6479199d245cd046f3646badd719d",
+  "src/mobile/BottomSheet.tsx": "f9c376dc78b8b5feb1c8752aa23414993ab66013d11e81b2ebb8342c570032ac",
+  "src/mobile/Carousel.tsx": "242f4a7488e18c35e9b43651c9d5877288655a6f9f1734e03e56108f17526bf7",
+  "src/mobile/Device.tsx": "867de9c65220f2ac7202f895d2b10a64c747f211a4b22cd8f81a1a1d66e52fc2",
+  "src/mobile/FlowStack.tsx": "1e85744c5f1c1138dd5e8b7b27fbb6c05139d5aac93d94c45084a018eddd4ebc",
+  "src/mobile/Keyboard.tsx": "862587264375499846385ce817ea1b813db8f118140cdb6da163f989a6946cac",
+  "src/mobile/MobileCursor.tsx": "be2353205e05d823bc23ecae88179d712781ee9ede8fa317057c194c7c23ee7d",
+  "src/mobile/MobileRuntime.tsx": "1ffe80a9582460005d66f10fc73deaff08b81431785a506887cc0d8320be747f",
+  "src/mobile/MobileScroll.tsx": "ea3e3691f587ea13753162b9e64c1981263e5b9beddebea21a6a1551dce76430",
+  "src/mobile/PhoneFrame.tsx": "73015590b468acbb4b58e3f6fe5eef99c5f68f67722caffd8f00d271ba00a91d",
+  "src/mobile/assets.ts": "d40cb8e5390fd2b54618266e7694e40349006dfe480f65d60559316ca8c9c234",
+  "src/mobile/components.tsx": "20b02f6c6de4cfd08aa6a49bf9bc261c0ea000864b37e1001afdcd892afe4bc8",
+  "src/mobile/geometry.ts": "f1382e5d6cc9adfd7ac142304f2f9a5a2330db1d74b3f0848adbaf311676bf16",
+  "src/mobile/index.ts": "ee69d9341571db5dee84dd2d7452a62eef09085a84a85f198a3edc48047e0412",
+  "public/assets/iphone/Bezel.png": "ee2514d74d2f75f3541405fa2f20811f159879a66586778cfd8c9c84c44959df",
+  "public/assets/iphone/Keyboard.png": "292e2d83d69adaeb663d6a840ce8db8d0a5257710b472c20713e03f56b08ea88",
+  "public/assets/android/Pixel10.png": "a87c1250ba20756f5d020b5351a7ae71d663dcfc64aca3a1cd56452e8a05233e",
+  "public/assets/android/Keyboard.png": "6085e50be17ca2705b4351283187be2c5b01b58262c70b90d74b3376efb4adfe",
+  "public/assets/android/navigation-bar.svg": "077a782a3b6c2915fc518dfb4bba825c8c5861b2572f9522a7024902b9cb2850",
+  "public/assets/status/status-icons.svg": "1a8bd4207cf34532ae514bdcdca693acc9a2b8ebc9d3182dc163c3e9ddcc4823",
+  "public/assets/status/ios-status-icons.svg": "40e218b274d0973d519b50f42884e6e05ef0f2b6a0e46e102fda54218784466d",
+  "worker/index.js": "2dd0615a445143933d88d4271f54f5d63ee951421fcd08c5a7617bb09c564389"
+}

+ 2281 - 0
prototype/adjutant-mobile/package-lock.json

@@ -0,0 +1,2281 @@
+{
+  "name": "product-design-mobile-app",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "product-design-mobile-app",
+      "version": "0.1.0",
+      "dependencies": {
+        "@fontsource/roboto": "5.2.10",
+        "@radix-ui/react-dialog": "1.1.19",
+        "@radix-ui/react-dropdown-menu": "2.1.20",
+        "@radix-ui/react-icons": "1.3.2",
+        "@use-gesture/react": "10.3.1",
+        "motion": "12.42.2",
+        "react": "19.2.7",
+        "react-dom": "19.2.7"
+      },
+      "devDependencies": {
+        "@playwright/test": "1.61.1",
+        "@types/react": "19.2.17",
+        "@types/react-dom": "19.2.3",
+        "@vitejs/plugin-react": "6.0.3",
+        "typescript": "7.0.2",
+        "vite": "8.1.3"
+      }
+    },
+    "node_modules/@emnapi/core": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+      "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.2.2",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+      "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/wasi-threads": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.5",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+      "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+      "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.7.5",
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/react-dom": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
+      "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/dom": "^1.7.6"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+      "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+      "license": "MIT"
+    },
+    "node_modules/@fontsource/roboto": {
+      "version": "5.2.10",
+      "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz",
+      "integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+      "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@tybys/wasm-util": "^0.10.3"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "peerDependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.139.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+      "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@playwright/test": {
+      "version": "1.61.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+      "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.61.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@radix-ui/primitive": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz",
+      "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==",
+      "license": "MIT"
+    },
+    "node_modules/@radix-ui/react-arrow": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz",
+      "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.7"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-collection": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz",
+      "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-context": "1.2.0",
+        "@radix-ui/react-primitive": "2.1.7",
+        "@radix-ui/react-slot": "1.3.0"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-compose-refs": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
+      "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-context": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz",
+      "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog": {
+      "version": "1.1.19",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz",
+      "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.5",
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-context": "1.2.0",
+        "@radix-ui/react-dismissable-layer": "1.1.15",
+        "@radix-ui/react-focus-guards": "1.1.4",
+        "@radix-ui/react-focus-scope": "1.1.12",
+        "@radix-ui/react-id": "1.1.2",
+        "@radix-ui/react-portal": "1.1.13",
+        "@radix-ui/react-presence": "1.1.7",
+        "@radix-ui/react-primitive": "2.1.7",
+        "@radix-ui/react-slot": "1.3.0",
+        "@radix-ui/react-use-controllable-state": "1.2.3",
+        "aria-hidden": "^1.2.4",
+        "react-remove-scroll": "^2.7.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-direction": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz",
+      "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dismissable-layer": {
+      "version": "1.1.15",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz",
+      "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.5",
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-primitive": "2.1.7",
+        "@radix-ui/react-use-callback-ref": "1.1.2",
+        "@radix-ui/react-use-effect-event": "0.0.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dropdown-menu": {
+      "version": "2.1.20",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.20.tgz",
+      "integrity": "sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.5",
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-context": "1.2.0",
+        "@radix-ui/react-id": "1.1.2",
+        "@radix-ui/react-menu": "2.1.20",
+        "@radix-ui/react-primitive": "2.1.7",
+        "@radix-ui/react-use-controllable-state": "1.2.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-focus-guards": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
+      "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-focus-scope": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz",
+      "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-primitive": "2.1.7",
+        "@radix-ui/react-use-callback-ref": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-icons": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
+      "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@radix-ui/react-id": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
+      "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-menu": {
+      "version": "2.1.20",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.20.tgz",
+      "integrity": "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.5",
+        "@radix-ui/react-collection": "1.1.12",
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-context": "1.2.0",
+        "@radix-ui/react-direction": "1.1.2",
+        "@radix-ui/react-dismissable-layer": "1.1.15",
+        "@radix-ui/react-focus-guards": "1.1.4",
+        "@radix-ui/react-focus-scope": "1.1.12",
+        "@radix-ui/react-id": "1.1.2",
+        "@radix-ui/react-popper": "1.3.3",
+        "@radix-ui/react-portal": "1.1.13",
+        "@radix-ui/react-presence": "1.1.7",
+        "@radix-ui/react-primitive": "2.1.7",
+        "@radix-ui/react-roving-focus": "1.1.15",
+        "@radix-ui/react-slot": "1.3.0",
+        "@radix-ui/react-use-callback-ref": "1.1.2",
+        "aria-hidden": "^1.2.4",
+        "react-remove-scroll": "^2.7.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-popper": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.3.tgz",
+      "integrity": "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/react-dom": "^2.0.0",
+        "@radix-ui/react-arrow": "1.1.11",
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-context": "1.2.0",
+        "@radix-ui/react-primitive": "2.1.7",
+        "@radix-ui/react-use-callback-ref": "1.1.2",
+        "@radix-ui/react-use-layout-effect": "1.1.2",
+        "@radix-ui/react-use-rect": "1.1.2",
+        "@radix-ui/react-use-size": "1.1.2",
+        "@radix-ui/rect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-portal": {
+      "version": "1.1.13",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz",
+      "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.7",
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-presence": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz",
+      "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-primitive": {
+      "version": "2.1.7",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz",
+      "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-slot": "1.3.0"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-roving-focus": {
+      "version": "1.1.15",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.15.tgz",
+      "integrity": "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.5",
+        "@radix-ui/react-collection": "1.1.12",
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-context": "1.2.0",
+        "@radix-ui/react-direction": "1.1.2",
+        "@radix-ui/react-id": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.7",
+        "@radix-ui/react-use-callback-ref": "1.1.2",
+        "@radix-ui/react-use-controllable-state": "1.2.3",
+        "@radix-ui/react-use-is-hydrated": "0.1.1",
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-slot": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
+      "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-callback-ref": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
+      "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-controllable-state": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
+      "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-effect-event": "0.0.3",
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-effect-event": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
+      "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-is-hydrated": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz",
+      "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-layout-effect": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
+      "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-rect": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz",
+      "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/rect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-size": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
+      "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/rect": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz",
+      "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
+      "license": "MIT"
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+      "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+      "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+      "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+      "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+      "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+      "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+      "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+      "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+      "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+      "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+      "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+      "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+      "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "1.11.1",
+        "@emnapi/runtime": "1.11.1",
+        "@napi-rs/wasm-runtime": "^1.1.6"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+      "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+      "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tybys/wasm-util": {
+      "version": "0.10.3",
+      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+      "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.17",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+      "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+      "devOptional": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/@typescript/typescript-aix-ppc64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+      "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-darwin-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+      "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-darwin-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+      "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-freebsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-freebsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+      "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-arm": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+      "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+      "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-loong64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+      "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-mips64el": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+      "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-ppc64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+      "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-riscv64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+      "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-s390x": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+      "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+      "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-netbsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-netbsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+      "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-openbsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-openbsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+      "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-sunos-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+      "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-win32-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+      "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-win32-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+      "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@use-gesture/core": {
+      "version": "10.3.1",
+      "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
+      "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
+      "license": "MIT"
+    },
+    "node_modules/@use-gesture/react": {
+      "version": "10.3.1",
+      "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
+      "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
+      "license": "MIT",
+      "dependencies": {
+        "@use-gesture/core": "10.3.1"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+      "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+        "babel-plugin-react-compiler": "^1.0.0",
+        "vite": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@rolldown/plugin-babel": {
+          "optional": true
+        },
+        "babel-plugin-react-compiler": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/aria-hidden": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+      "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/detect-node-es": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+      "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+      "license": "MIT"
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/framer-motion": {
+      "version": "12.42.2",
+      "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
+      "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
+      "license": "MIT",
+      "dependencies": {
+        "motion-dom": "^12.42.2",
+        "motion-utils": "^12.39.0",
+        "tslib": "^2.4.0"
+      },
+      "peerDependencies": {
+        "@emotion/is-prop-valid": "*",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/is-prop-valid": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/get-nonce": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+      "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.32.0",
+        "lightningcss-darwin-arm64": "1.32.0",
+        "lightningcss-darwin-x64": "1.32.0",
+        "lightningcss-freebsd-x64": "1.32.0",
+        "lightningcss-linux-arm-gnueabihf": "1.32.0",
+        "lightningcss-linux-arm64-gnu": "1.32.0",
+        "lightningcss-linux-arm64-musl": "1.32.0",
+        "lightningcss-linux-x64-gnu": "1.32.0",
+        "lightningcss-linux-x64-musl": "1.32.0",
+        "lightningcss-win32-arm64-msvc": "1.32.0",
+        "lightningcss-win32-x64-msvc": "1.32.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+      "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+      "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+      "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+      "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+      "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+      "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+      "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+      "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+      "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+      "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/motion": {
+      "version": "12.42.2",
+      "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz",
+      "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==",
+      "license": "MIT",
+      "dependencies": {
+        "framer-motion": "^12.42.2",
+        "tslib": "^2.4.0"
+      },
+      "peerDependencies": {
+        "@emotion/is-prop-valid": "*",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/is-prop-valid": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/motion-dom": {
+      "version": "12.42.2",
+      "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
+      "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
+      "license": "MIT",
+      "dependencies": {
+        "motion-utils": "^12.39.0"
+      }
+    },
+    "node_modules/motion-utils": {
+      "version": "12.39.0",
+      "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+      "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.15",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+      "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.61.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+      "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.61.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.61.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+      "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.16",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+      "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+      "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+      "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.7"
+      }
+    },
+    "node_modules/react-remove-scroll": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+      "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+      "license": "MIT",
+      "dependencies": {
+        "react-remove-scroll-bar": "^2.3.7",
+        "react-style-singleton": "^2.2.3",
+        "tslib": "^2.1.0",
+        "use-callback-ref": "^1.3.3",
+        "use-sidecar": "^1.1.3"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-remove-scroll-bar": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+      "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+      "license": "MIT",
+      "dependencies": {
+        "react-style-singleton": "^2.2.2",
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-style-singleton": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+      "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+      "license": "MIT",
+      "dependencies": {
+        "get-nonce": "^1.0.0",
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+      "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.139.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.1.5",
+        "@rolldown/binding-darwin-arm64": "1.1.5",
+        "@rolldown/binding-darwin-x64": "1.1.5",
+        "@rolldown/binding-freebsd-x64": "1.1.5",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+        "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+        "@rolldown/binding-linux-arm64-musl": "1.1.5",
+        "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+        "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+        "@rolldown/binding-linux-x64-gnu": "1.1.5",
+        "@rolldown/binding-linux-x64-musl": "1.1.5",
+        "@rolldown/binding-openharmony-arm64": "1.1.5",
+        "@rolldown/binding-wasm32-wasi": "1.1.5",
+        "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+        "@rolldown/binding-win32-x64-msvc": "1.1.5"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+      "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc"
+      },
+      "engines": {
+        "node": ">=16.20.0"
+      },
+      "optionalDependencies": {
+        "@typescript/typescript-aix-ppc64": "7.0.2",
+        "@typescript/typescript-darwin-arm64": "7.0.2",
+        "@typescript/typescript-darwin-x64": "7.0.2",
+        "@typescript/typescript-freebsd-arm64": "7.0.2",
+        "@typescript/typescript-freebsd-x64": "7.0.2",
+        "@typescript/typescript-linux-arm": "7.0.2",
+        "@typescript/typescript-linux-arm64": "7.0.2",
+        "@typescript/typescript-linux-loong64": "7.0.2",
+        "@typescript/typescript-linux-mips64el": "7.0.2",
+        "@typescript/typescript-linux-ppc64": "7.0.2",
+        "@typescript/typescript-linux-riscv64": "7.0.2",
+        "@typescript/typescript-linux-s390x": "7.0.2",
+        "@typescript/typescript-linux-x64": "7.0.2",
+        "@typescript/typescript-netbsd-arm64": "7.0.2",
+        "@typescript/typescript-netbsd-x64": "7.0.2",
+        "@typescript/typescript-openbsd-arm64": "7.0.2",
+        "@typescript/typescript-openbsd-x64": "7.0.2",
+        "@typescript/typescript-sunos-x64": "7.0.2",
+        "@typescript/typescript-win32-arm64": "7.0.2",
+        "@typescript/typescript-win32-x64": "7.0.2"
+      }
+    },
+    "node_modules/use-callback-ref": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+      "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/use-sidecar": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+      "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+      "license": "MIT",
+      "dependencies": {
+        "detect-node-es": "^1.1.0",
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite": {
+      "version": "8.1.3",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
+      "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.32.0",
+        "picomatch": "^4.0.4",
+        "postcss": "^8.5.16",
+        "rolldown": "~1.1.3",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.3.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite/node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    }
+  }
+}

+ 34 - 0
prototype/adjutant-mobile/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "adjutant-mobile",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "check:runtime": "node scripts/check-mobile-runtime.mjs",
+    "update:runtime-lock": "node scripts/update-mobile-runtime-lock.mjs",
+    "test:runtime": "playwright test",
+    "test:sites": "node --test tests/sites-worker.test.mjs",
+    "predev": "npm run check:runtime",
+    "dev": "vite",
+    "prebuild": "npm run check:runtime",
+    "build": "tsc && vite build && node scripts/prepare-sites-build.mjs"
+  },
+  "dependencies": {
+    "@fontsource/roboto": "5.2.10",
+    "@radix-ui/react-dialog": "1.1.19",
+    "@radix-ui/react-dropdown-menu": "2.1.20",
+    "@radix-ui/react-icons": "1.3.2",
+    "@use-gesture/react": "10.3.1",
+    "motion": "12.42.2",
+    "react": "19.2.7",
+    "react-dom": "19.2.7"
+  },
+  "devDependencies": {
+    "@playwright/test": "1.61.1",
+    "@types/react": "19.2.17",
+    "@types/react-dom": "19.2.3",
+    "@vitejs/plugin-react": "6.0.3",
+    "typescript": "7.0.2",
+    "vite": "8.1.3"
+  }
+}

+ 18 - 0
prototype/adjutant-mobile/playwright.config.ts

@@ -0,0 +1,18 @@
+import { defineConfig } from "@playwright/test";
+
+const testPort = Number(process.env.MOBILE_RUNTIME_TEST_PORT ?? 4174);
+
+export default defineConfig({
+  testDir: "./tests",
+  testMatch: "**/*.spec.ts",
+  timeout: 20_000,
+  use: {
+    baseURL: `http://127.0.0.1:${testPort}`,
+    viewport: { width: 1100, height: 1100 },
+  },
+  webServer: {
+    command: `npm run dev -- --port ${testPort}`,
+    url: `http://127.0.0.1:${testPort}/tests/runtime-fixture.html`,
+    reuseExistingServer: process.env.MOBILE_RUNTIME_TEST_PORT == null,
+  },
+});

TEMPAT SAMPAH
prototype/adjutant-mobile/public/assets/android/Keyboard.png


TEMPAT SAMPAH
prototype/adjutant-mobile/public/assets/android/Pixel10.png


+ 6 - 0
prototype/adjutant-mobile/public/assets/android/navigation-bar.svg

@@ -0,0 +1,6 @@
+<svg width="428" height="48" viewBox="0 0 428 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="427" height="48" transform="translate(1)" fill="#0F0F0F"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M119.492 16.0134C120.159 15.6203 121 16.1009 121 16.8748V31.1254C121 31.8993 120.159 32.3799 119.492 31.9867L107.41 24.8615C106.753 24.4746 106.753 23.5256 107.41 23.1387L119.492 16.0134Z" fill="white"/>
+<circle cx="223" cy="24" r="8" fill="white"/>
+<rect x="325" y="17" width="14" height="14" rx="2" fill="white"/>
+</svg>

TEMPAT SAMPAH
prototype/adjutant-mobile/public/assets/iphone/Bezel.png


TEMPAT SAMPAH
prototype/adjutant-mobile/public/assets/iphone/Keyboard.png


+ 7 - 0
prototype/adjutant-mobile/public/assets/status/ios-status-icons.svg

@@ -0,0 +1,7 @@
+<svg width="79" height="13" viewBox="0 0 79 13" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <rect opacity="0.35" x="51.4333" y="0.5" width="24" height="12" rx="3" stroke="black"/>
+  <path opacity="0.4" d="M76.9333 4.66667V8.66667C77.7381 8.32789 78.2614 7.5398 78.2614 6.66667C78.2614 5.79353 77.7381 5.00544 76.9333 4.66667Z" fill="black"/>
+  <rect x="52.9333" y="2" width="21" height="9" rx="1.33333" fill="black"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M35.1006 3.06735C37.5671 3.06745 39.9394 3.96498 41.727 5.57443C41.8616 5.69869 42.0767 5.69712 42.2093 5.57092L43.4961 4.3411C43.5632 4.27709 43.6006 4.19038 43.6001 4.10017C43.5995 4.00996 43.561 3.92367 43.4931 3.86041C38.8012 -0.397806 31.3992 -0.397806 26.7073 3.86041C26.6393 3.92363 26.6007 4.00988 26.6001 4.1001C26.5995 4.19031 26.6368 4.27704 26.7039 4.3411L27.9911 5.57092C28.1236 5.69731 28.3389 5.69888 28.4734 5.57443C30.2613 3.96488 32.6338 3.06735 35.1006 3.06735ZM35.0988 7.17524C36.4449 7.17516 37.743 7.67327 38.7408 8.57279C38.8757 8.70045 39.0883 8.69768 39.2199 8.56655L40.4965 7.28238C40.5637 7.21502 40.601 7.12364 40.6001 7.02869C40.5991 6.93373 40.5599 6.84313 40.4914 6.77715C37.4528 3.96328 32.7473 3.96328 29.7088 6.77715C29.6402 6.84313 29.601 6.93378 29.6001 7.02876C29.5992 7.12375 29.6366 7.21512 29.704 7.28238L30.9803 8.56655C31.1119 8.69768 31.3244 8.70045 31.4594 8.57279C32.4565 7.67386 33.7536 7.1758 35.0988 7.17524ZM37.6 9.8944C37.6019 9.99694 37.5652 10.0958 37.4984 10.1676L35.3398 12.557C35.2765 12.6272 35.1902 12.6667 35.1002 12.6667C35.0102 12.6667 34.9239 12.6272 34.8606 12.557L32.7016 10.1676C32.6349 10.0958 32.5982 9.99686 32.6002 9.89431C32.6022 9.79177 32.6426 9.69466 32.712 9.62592C34.0906 8.34702 36.1097 8.34702 37.4884 9.62592C37.5577 9.69472 37.5981 9.79185 37.6 9.8944Z" fill="black"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M19.2 1.79175C19.2 1.17043 18.7224 0.666748 18.1333 0.666748H17.0667C16.4776 0.666748 16 1.17043 16 1.79175V11.5417C16 12.1631 16.4776 12.6667 17.0667 12.6667H18.1333C18.7224 12.6667 19.2 12.1631 19.2 11.5417V1.79175ZM11.7667 3.06675H12.8333C13.4224 3.06675 13.9 3.58252 13.9 4.21875V11.5147C13.9 12.151 13.4224 12.6667 12.8333 12.6667H11.7667C11.1776 12.6667 10.7 12.151 10.7 11.5147V4.21875C10.7 3.58252 11.1776 3.06675 11.7667 3.06675ZM7.43333 5.66675H6.36667C5.77756 5.66675 5.3 6.18908 5.3 6.83341V11.5001C5.3 12.1444 5.77756 12.6667 6.36667 12.6667H7.43333C8.02244 12.6667 8.5 12.1444 8.5 11.5001V6.83341C8.5 6.18908 8.02244 5.66675 7.43333 5.66675ZM2.13333 8.06675H1.06667C0.477563 8.06675 0 8.58162 0 9.21675V11.5167C0 12.1519 0.477563 12.6667 1.06667 12.6667H2.13333C2.72244 12.6667 3.2 12.1519 3.2 11.5167V9.21675C3.2 8.58162 2.72244 8.06675 2.13333 8.06675Z" fill="black"/>
+</svg>

+ 5 - 0
prototype/adjutant-mobile/public/assets/status/status-icons.svg

@@ -0,0 +1,5 @@
+<svg width="50" height="14" viewBox="0 0 50 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path d="M36.3722 5.48652C36.7266 5.10328 36.7295 4.50844 36.3477 4.15255C35.3799 3.25059 34.284 2.51985 33.1002 1.98957C31.6417 1.33625 30.0786 1 28.5 1C26.9214 1 25.3583 1.33626 23.8998 1.98957C22.716 2.51985 21.6201 3.25059 20.6523 4.15255C20.2705 4.50844 20.2734 5.10329 20.6278 5.48653L28.5 14L36.3722 5.48652Z" fill="#1C1B14"/>
+  <path d="M14 13V2.41421C14 1.52331 12.9229 1.07714 12.2929 1.7071L1.70711 12.2929C1.07714 12.9229 1.52331 14 2.41421 14H13C13.5523 14 14 13.5523 14 13Z" fill="#1C1B14"/>
+  <path d="M49 2C49.5523 2 50 2.44772 50 3V13C50 13.5523 49.5523 14 49 14H43C42.4477 14 42 13.5523 42 13V3C42 2.44772 42.4477 2 43 2H44C44 1.44772 44.4477 1 45 1H47C47.5523 1 48 1.44772 48 2H49Z" fill="#1C1B14"/>
+</svg>

+ 24 - 0
prototype/adjutant-mobile/public/brand/emoon-cross.svg

@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 280" fill="none">
+  <circle cx="140" cy="140" r="138" fill="url(#bg)" stroke="#2b1f99" stroke-width="2"/>
+  <defs>
+    <radialGradient id="bg" cx="40%" cy="30%">
+      <stop offset="0%" stop-color="rgba(58,212,216,0.2)"/>
+      <stop offset="42%" stop-color="#fff"/>
+      <stop offset="100%" stop-color="#eef8ff"/>
+    </radialGradient>
+  </defs>
+  <!-- face -->
+  <circle cx="140" cy="115" r="40" fill="#ffe4c4"/>
+  <!-- eyes -->
+  <circle cx="128" cy="108" r="5" fill="#2b1f99"/>
+  <circle cx="152" cy="108" r="5" fill="#2b1f99"/>
+  <!-- smile -->
+  <path d="M128 125 Q140 138 152 125" stroke="#2b1f99" stroke-width="2.5" fill="none" stroke-linecap="round"/>
+  <!-- body -->
+  <rect x="95" y="155" width="90" height="75" rx="8" fill="#fff" stroke="#2b1f99" stroke-width="1.5"/>
+  <!-- collar -->
+  <path d="M125 155 L140 172 L155 155" stroke="#3ad4d8" stroke-width="2" fill="none"/>
+  <!-- stethoscope -->
+  <path d="M180 170 Q200 165 200 145 Q200 128 188 128" stroke="#3ad4d8" stroke-width="3" fill="none" stroke-linecap="round"/>
+  <circle cx="188" cy="128" r="8" stroke="#3ad4d8" stroke-width="2.5"/>
+</svg>

TEMPAT SAMPAH
prototype/adjutant-mobile/public/brand/registration-illustration.png


+ 33 - 0
prototype/adjutant-mobile/scripts/check-mobile-runtime.mjs

@@ -0,0 +1,33 @@
+#!/usr/bin/env node
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const lockPath = path.join(root, "mobile-runtime.lock.json");
+const lockedFiles = JSON.parse(readFileSync(lockPath, "utf8"));
+const failures = [];
+
+for (const [relativePath, expectedHash] of Object.entries(lockedFiles)) {
+  const filePath = path.join(root, relativePath);
+
+  if (!existsSync(filePath)) {
+    failures.push(`${relativePath} is missing`);
+    continue;
+  }
+
+  const actualHash = createHash("sha256").update(readFileSync(filePath)).digest("hex");
+  if (actualHash !== expectedHash) {
+    failures.push(`${relativePath} was modified`);
+  }
+}
+
+if (failures.length > 0) {
+  console.error("Mobile runtime integrity check failed:\n");
+  for (const failure of failures) console.error(`- ${failure}`);
+  console.error("\nRestore the protected runtime. Put app UI in src/Prototype.tsx and src/prototype.css.");
+  process.exit(1);
+}
+
+console.log(`Mobile runtime integrity check passed (${Object.keys(lockedFiles).length} protected files).`);

+ 21 - 0
prototype/adjutant-mobile/scripts/prepare-sites-build.mjs

@@ -0,0 +1,21 @@
+#!/usr/bin/env node
+import { copyFileSync, existsSync, mkdirSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const dist = path.join(root, "dist");
+const index = path.join(dist, "client", "index.html");
+const worker = path.join(root, "worker", "index.js");
+const hosting = path.join(root, ".openai", "hosting.json");
+
+for (const file of [index, worker, hosting]) {
+  if (!existsSync(file)) throw new Error("Missing Sites build input: " + file);
+}
+
+mkdirSync(path.join(dist, "server"), { recursive: true });
+mkdirSync(path.join(dist, ".openai"), { recursive: true });
+copyFileSync(worker, path.join(dist, "server", "index.js"));
+copyFileSync(hosting, path.join(dist, ".openai", "hosting.json"));
+
+console.log("Prepared Sites build: dist/server/index.js and dist/.openai/hosting.json");

+ 48 - 0
prototype/adjutant-mobile/scripts/update-mobile-runtime-lock.mjs

@@ -0,0 +1,48 @@
+#!/usr/bin/env node
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const lockPath = path.join(root, "mobile-runtime.lock.json");
+const protectedFiles = [
+  "scripts/check-mobile-runtime.mjs",
+  "scripts/prepare-sites-build.mjs",
+  "scripts/update-mobile-runtime-lock.mjs",
+  "vite.config.ts",
+  "src/App.tsx",
+  "src/main.tsx",
+  "src/styles.css",
+  "src/mobile/BottomSheet.tsx",
+  "src/mobile/Carousel.tsx",
+  "src/mobile/Device.tsx",
+  "src/mobile/FlowStack.tsx",
+  "src/mobile/Keyboard.tsx",
+  "src/mobile/MobileCursor.tsx",
+  "src/mobile/MobileRuntime.tsx",
+  "src/mobile/MobileScroll.tsx",
+  "src/mobile/PhoneFrame.tsx",
+  "src/mobile/assets.ts",
+  "src/mobile/components.tsx",
+  "src/mobile/geometry.ts",
+  "src/mobile/index.ts",
+  "public/assets/iphone/Bezel.png",
+  "public/assets/iphone/Keyboard.png",
+  "public/assets/android/Pixel10.png",
+  "public/assets/android/Keyboard.png",
+  "public/assets/android/navigation-bar.svg",
+  "public/assets/status/status-icons.svg",
+  "public/assets/status/ios-status-icons.svg",
+  "worker/index.js",
+];
+
+const hashes = {};
+for (const relativePath of protectedFiles) {
+  const filePath = path.join(root, relativePath);
+  if (!existsSync(filePath)) throw new Error(`Protected runtime file is missing: ${relativePath}`);
+  hashes[relativePath] = createHash("sha256").update(readFileSync(filePath)).digest("hex");
+}
+
+writeFileSync(lockPath, `${JSON.stringify(hashes, null, 2)}\n`);
+console.log(`Updated mobile-runtime.lock.json (${protectedFiles.length} protected files).`);

TEMPAT SAMPAH
prototype/adjutant-mobile/source-normalized.png


+ 10 - 0
prototype/adjutant-mobile/src/App.tsx

@@ -0,0 +1,10 @@
+import { MobileRuntime } from "./mobile";
+import Prototype from "./Prototype";
+
+export default function App() {
+  return (
+    <MobileRuntime>
+      <Prototype />
+    </MobileRuntime>
+  );
+}

+ 999 - 0
prototype/adjutant-mobile/src/Prototype.tsx

@@ -0,0 +1,999 @@
+import { useState } from "react";
+import {
+  ArrowLeftIcon,
+  CalendarIcon,
+  CameraIcon,
+  ChatBubbleIcon,
+  CheckCircledIcon,
+  ChevronRightIcon,
+  ClockIcon,
+  CrossCircledIcon,
+  DashboardIcon,
+  ExclamationTriangleIcon,
+  FileTextIcon,
+  HeartIcon,
+  HomeIcon,
+  InfoCircledIcon,
+  PersonIcon,
+  PlusIcon,
+  ReaderIcon,
+  ReloadIcon,
+  SpeakerLoudIcon,
+  UploadIcon,
+} from "@radix-ui/react-icons";
+import {
+  FlowStack,
+  KeyboardTextarea,
+  MobileScroll,
+  type FlowControls,
+  type FlowScreen,
+} from "./mobile";
+
+type RootTab = "home" | "services" | "records" | "me";
+type AssistantMode = "general" | "registration" | "report" | "tongue";
+
+function PageHeader({ flow, title, step }: { flow: FlowControls; title: string; step?: string }) {
+  return (
+    <div className="task-header">
+      <button className="icon-button" type="button" onClick={() => flow.pop()} aria-label="返回">
+        <ArrowLeftIcon />
+      </button>
+      <strong>{title}</strong>
+      <span>{step ?? ""}</span>
+    </div>
+  );
+}
+
+function FixedAction({
+  label,
+  onClick,
+  secondary,
+}: {
+  label: string;
+  onClick: () => void;
+  secondary?: string;
+}) {
+  return (
+    <div className="fixed-action">
+      {secondary ? <span>{secondary}</span> : null}
+      <button className="primary-button" type="button" onClick={onClick}>
+        {label}
+        <ChevronRightIcon />
+      </button>
+    </div>
+  );
+}
+
+function BottomNav({ active, flow }: { active: RootTab; flow: FlowControls }) {
+  const items: Array<{ id: RootTab; label: string; icon: typeof HomeIcon }> = [
+    { id: "home", label: "首页", icon: HomeIcon },
+    { id: "services", label: "服务", icon: DashboardIcon },
+    { id: "records", label: "健康记录", icon: ReaderIcon },
+    { id: "me", label: "我的", icon: PersonIcon },
+  ];
+
+  return (
+    <nav className="bottom-nav" aria-label="主导航">
+      {items.map((item) => {
+        const Icon = item.icon;
+        return (
+          <button
+            type="button"
+            key={item.id}
+            className={active === item.id ? "active" : ""}
+            onClick={() => flow.replace(makeRootScreen(item.id))}
+          >
+            <Icon />
+            <span>{item.label}</span>
+          </button>
+        );
+      })}
+    </nav>
+  );
+}
+
+function HomeContent({ flow }: { flow: FlowControls }) {
+  return (
+    <MobileScroll className="portal-scroll">
+      <main className="portal-page home-page" aria-label="医梦患者智能服务门户首页">
+        <header className="brand-header">
+          <div className="brand-lockup">
+            <span className="brand-mark" aria-hidden="true">
+              <PlusIcon />
+            </span>
+            <div>
+              <h1>空海医院</h1>
+              <p>医梦患者智能服务门户</p>
+            </div>
+          </div>
+          <button className="patient-chip" type="button" onClick={() => flow.push(patientScreen)}>
+            <PersonIcon />
+            王*明
+          </button>
+        </header>
+
+        <section className="welcome-block">
+          <h2>今天需要什么帮助?</h2>
+          <p>可以问症状、查报告、找医生、了解健康知识</p>
+        </section>
+
+        <button className="assistant-entry" type="button" onClick={() => flow.push(makeAssistantScreen("general"))}>
+          <span>按住说话,告诉我想办理什么</span>
+          <span className="assistant-entry-icons">
+            <SpeakerLoudIcon />
+            <UploadIcon />
+          </span>
+        </button>
+
+        <button className="todo-strip" type="button" onClick={() => flow.push(makeAssistantScreen("report"))}>
+          <span className="todo-label">
+            <FileTextIcon />
+            今日待办
+          </span>
+          <span className="todo-copy">
+            <strong>1 份新报告待解读</strong>
+            <small>来自检验科 · 07-25 16:30</small>
+          </span>
+          <span className="todo-action">查看</span>
+        </button>
+
+        <section className="service-section">
+          <article className="registration-hero">
+            <div className="registration-copy">
+              <img src="/brand/registration-illustration.png" alt="" />
+              <div>
+                <div className="title-line">
+                  <h3>智能分诊挂号</h3>
+                  <span>推荐</span>
+                </div>
+                <p>描述症状,智能推荐就诊科室和合适医生</p>
+              </div>
+            </div>
+            <button className="primary-button" type="button" onClick={() => flow.push(makeAssistantScreen("registration"))}>
+              语音挂号
+              <ChevronRightIcon />
+            </button>
+          </article>
+
+          <div className="service-grid">
+            <button className="service-card" type="button" onClick={() => flow.push(makeAssistantScreen("report"))}>
+              <span className="service-icon report">
+                <FileTextIcon />
+              </span>
+              <strong>报告智能解读</strong>
+              <p>上传检验检查报告,查看重点指标</p>
+              <ChevronRightIcon className="card-arrow" />
+            </button>
+            <button className="service-card" type="button" onClick={() => flow.push(makeAssistantScreen("tongue"))}>
+              <span className="service-icon tongue">
+                <HeartIcon />
+              </span>
+              <strong>中医舌诊</strong>
+              <p>问答结合舌象,了解体质与调理建议</p>
+              <ChevronRightIcon className="card-arrow" />
+            </button>
+          </div>
+        </section>
+
+        <section className="recent-section">
+          <div className="section-heading">
+            <h3>最近记录</h3>
+            <button type="button" onClick={() => flow.replace(makeRootScreen("records"))}>
+              全部记录 <ChevronRightIcon />
+            </button>
+          </div>
+          <button className="record-row" type="button" onClick={() => flow.push(registrationResultScreen)}>
+            <span className="record-icon">
+              <CalendarIcon />
+            </span>
+            <span>
+              <strong>智能分诊记录</strong>
+              <small>2026-07-26 10:15</small>
+            </span>
+            <em>已推荐科室</em>
+            <ChevronRightIcon />
+          </button>
+        </section>
+
+        <div className="demo-note">
+          <InfoCircledIcon />
+          演示环境 · 数据为 Mock
+        </div>
+      </main>
+    </MobileScroll>
+  );
+}
+
+function ServicesContent({ flow }: { flow: FlowControls }) {
+  const services = [
+    {
+      title: "智能分诊挂号",
+      desc: "症状分析、科室推荐、模拟预约",
+      icon: CalendarIcon,
+      screen: makeAssistantScreen("registration"),
+    },
+    {
+      title: "报告智能解读",
+      desc: "检验检查报告、异常指标、就医建议",
+      icon: FileTextIcon,
+      screen: makeAssistantScreen("report"),
+    },
+    {
+      title: "中医舌诊",
+      desc: "体质问答、舌象采集、综合建议",
+      icon: HeartIcon,
+      screen: makeAssistantScreen("tongue"),
+    },
+  ];
+  return (
+    <MobileScroll className="portal-scroll">
+      <main className="portal-page root-list-page">
+        <header>
+          <h1>服务</h1>
+          <p>空海医院已开通的智能服务</p>
+        </header>
+        <section className="grouped-list">
+          {services.map((service) => {
+            const Icon = service.icon;
+            return (
+              <button key={service.title} type="button" onClick={() => flow.push(service.screen)}>
+                <span className="list-icon">
+                  <Icon />
+                </span>
+                <span>
+                  <strong>{service.title}</strong>
+                  <small>{service.desc}</small>
+                </span>
+                <ChevronRightIcon />
+              </button>
+            );
+          })}
+        </section>
+        <section className="plain-info">
+          <InfoCircledIcon />
+          <div>
+            <strong>服务范围</strong>
+            <p>当前为单医院 P0 演示。所有挂号、报告和支付数据均为 Mock。</p>
+          </div>
+        </section>
+      </main>
+    </MobileScroll>
+  );
+}
+
+function RecordsContent({ flow }: { flow: FlowControls }) {
+  return (
+    <MobileScroll className="portal-scroll">
+      <main className="portal-page root-list-page">
+        <header>
+          <h1>健康记录</h1>
+          <p>查看结果或继续未完成任务</p>
+        </header>
+        <div className="filter-row">
+          <button className="selected" type="button">全部</button>
+          <button type="button">挂号</button>
+          <button type="button">报告</button>
+          <button type="button">中医</button>
+        </div>
+        <section className="record-list">
+          <button type="button" onClick={() => flow.push(reportResultScreen)}>
+            <span className="record-icon report"><FileTextIcon /></span>
+            <span><strong>血常规智能解读</strong><small>今天 14:32 · 已完成</small><p>发现 3 项异常指标</p></span>
+            <em>查看</em>
+          </button>
+          <button type="button" onClick={() => flow.push(tongueCaptureScreen)}>
+            <span className="record-icon tongue"><HeartIcon /></span>
+            <span><strong>中医舌诊评估</strong><small>今天 13:10 · 待上传</small><p>问答已完成,待拍摄舌象</p></span>
+            <em>继续</em>
+          </button>
+          <button type="button" onClick={() => flow.push(registrationResultScreen)}>
+            <span className="record-icon"><CalendarIcon /></span>
+            <span><strong>神经内科预约</strong><small>明天 09:30 · 已完成</small><p>李明 主任医师</p></span>
+            <em>查看</em>
+          </button>
+        </section>
+      </main>
+    </MobileScroll>
+  );
+}
+
+function MeContent({ flow }: { flow: FlowControls }) {
+  return (
+    <MobileScroll className="portal-scroll">
+      <main className="portal-page root-list-page">
+        <header>
+          <h1>我的</h1>
+          <p>P0 演示患者与服务说明</p>
+        </header>
+        <button className="patient-profile-card" type="button" onClick={() => flow.push(patientScreen)}>
+          <span className="avatar"><PersonIcon /></span>
+          <span><strong>王*明</strong><small>本人 · Mock 患者</small></span>
+          <ChevronRightIcon />
+        </button>
+        <section className="grouped-list compact">
+          <button type="button"><span className="list-icon"><InfoCircledIcon /></span><span><strong>隐私与授权</strong><small>查看演示数据说明</small></span><ChevronRightIcon /></button>
+          <button type="button"><span className="list-icon"><ChatBubbleIcon /></span><span><strong>人工帮助</strong><small>联系医院服务人员</small></span><ChevronRightIcon /></button>
+        </section>
+      </main>
+    </MobileScroll>
+  );
+}
+
+function RootContent({ tab, flow }: { tab: RootTab; flow: FlowControls }) {
+  if (tab === "services") return <ServicesContent flow={flow} />;
+  if (tab === "records") return <RecordsContent flow={flow} />;
+  if (tab === "me") return <MeContent flow={flow} />;
+  return <HomeContent flow={flow} />;
+}
+
+function makeRootScreen(tab: RootTab): FlowScreen {
+  return {
+    id: `root-${tab}`,
+    footerHeight: 66,
+    footer: (flow) => <BottomNav active={tab} flow={flow} />,
+    render: (flow) => <RootContent tab={tab} flow={flow} />,
+  };
+}
+
+const patientScreen: FlowScreen = {
+  id: "patient",
+  title: "当前患者",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="当前患者" />,
+  render: () => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page">
+        <section className="patient-detail">
+          <span className="avatar large"><PersonIcon /></span>
+          <h2>王*明</h2>
+          <p>男 · 38 岁</p>
+          <small>演示就诊卡:0001 2345 6789</small>
+        </section>
+        <section className="notice-card neutral">
+          <InfoCircledIcon />
+          <div><strong>P0 固定服务对象</strong><p>本阶段不支持家庭成员切换。P1 将增加本人、儿童和老人代办。</p></div>
+        </section>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: AssistantMode }) {
+  const [text, setText] = useState("");
+  const [stage, setStage] = useState<"ready" | "followup" | "complete">("ready");
+  const [doctor, setDoctor] = useState("");
+  const modeCopy = {
+    general: {
+      title: "请说出您的需求",
+      description: "可以直接说症状、科室、时间和想办理的事情,我会继续追问缺少的信息。",
+      sample: "我头疼,帮我挂神经内科明天早上九点的专家号",
+    },
+    registration: {
+      title: "请说出您的挂号需求",
+      description: "尽量说清症状、科室、日期、时间或号别;没有说全也没关系。",
+      sample: "我头疼,帮我挂神经内科明天早上九点的专家号",
+    },
+    report: {
+      title: "请告诉我想解读哪份报告",
+      description: "可以说院内报告名称,也可以稍后上传报告图片或 PDF。",
+      sample: "帮我解读刚出的血常规报告",
+    },
+    tongue: {
+      title: "请告诉我想了解什么",
+      description: "我会先通过语音了解近期身体感受,再引导完成舌象采集。",
+      sample: "我最近容易疲倦,想做一次中医舌诊",
+    },
+  }[mode];
+
+  const start = () => {
+    if (text.includes("报告")) flow.push(reportUploadScreen);
+    else if (text.includes("舌") || text.includes("体质")) flow.push(tongueIntroScreen);
+    else if (text.includes("胸痛") || text.includes("呼吸困难")) flow.push(emergencyScreen);
+    else {
+      setText(text || modeCopy.sample);
+      setStage("followup");
+    }
+  };
+
+  const simulateVoice = () => {
+    setText(modeCopy.sample);
+    if (mode === "report") setStage("complete");
+    else if (mode === "tongue") setStage("complete");
+    else setStage("followup");
+  };
+
+  const chooseDoctor = (name: string) => {
+    setDoctor(name);
+    setStage("complete");
+  };
+
+  const nextTask = () => {
+    if (mode === "report") flow.push(reportUploadScreen);
+    else if (mode === "tongue") flow.push(tongueIntroScreen);
+    else flow.push(scheduleScreen);
+  };
+
+  return (
+    <MobileScroll className="task-scroll">
+      <main className="task-page assistant-page">
+        <section className="assistant-welcome">
+          <span className={`assistant-orb ${stage !== "ready" ? "is-active" : ""}`}><SpeakerLoudIcon /></span>
+          <h2>{modeCopy.title}</h2>
+          <p>{modeCopy.description}</p>
+        </section>
+
+        {stage === "ready" ? (
+          <>
+            <button className="voice-primary" type="button" onClick={simulateVoice}>
+              <SpeakerLoudIcon />
+              <strong>按住说话</strong>
+              <span>松开发送 · 点击可体验演示语音</span>
+            </button>
+            <p className="voice-example">例如:“{modeCopy.sample}”</p>
+            {mode === "registration" ? (
+              <button className="risk-demo-link" type="button" onClick={() => flow.push(emergencyScreen)}>
+                <ExclamationTriangleIcon />
+                演示急诊门禁:胸痛并呼吸困难
+              </button>
+            ) : null}
+            <label className="assistant-composer compact">
+              <KeyboardTextarea
+                value={text}
+                onChange={(event) => setText(event.target.value)}
+                placeholder="也可以在这里输入文字"
+                aria-label="描述您的需求"
+              />
+              <span>文字输入</span>
+              <button type="button" onClick={start}>发送</button>
+            </label>
+            {mode === "general" ? (
+              <section>
+                <h3 className="minor-heading">也可以直接选择</h3>
+                <div className="quick-actions">
+                  <button type="button" onClick={() => flow.replace(makeAssistantScreen("registration"))}>语音挂号</button>
+                  <button type="button" onClick={() => flow.replace(makeAssistantScreen("report"))}>报告解读</button>
+                  <button type="button" onClick={() => flow.replace(makeAssistantScreen("tongue"))}>中医舌诊</button>
+                </div>
+              </section>
+            ) : null}
+          </>
+        ) : (
+          <section className="slot-dialogue" aria-live="polite">
+            <article className="voice-transcript">
+              <span>您说</span>
+              <p>{text}</p>
+            </article>
+
+            {mode === "report" ? (
+              <article className="assistant-reply">
+                <strong>已理解您的需求</strong>
+                <p>请上传报告,或从本院已审核报告中选择一份。</p>
+                <button className="primary-button inline-action" type="button" onClick={nextTask}>选择报告 <ChevronRightIcon /></button>
+              </article>
+            ) : mode === "tongue" ? (
+              <article className="assistant-reply">
+                <strong>可以为您开始中医舌诊</strong>
+                <p>接下来会通过语音和选项补充体质症候,再拍摄舌象。</p>
+                <button className="primary-button inline-action" type="button" onClick={nextTask}>开始评估 <ChevronRightIcon /></button>
+              </article>
+            ) : (
+              <>
+                <article className="assistant-reply">
+                  <strong>{stage === "followup" ? "还需要确认一项信息" : "挂号信息已收集完整"}</strong>
+                  <p>{stage === "followup" ? "已记录神经内科、明天上午 9 点和专家号。请问您想挂哪位医生?" : "我会按以下条件查询实时号源,查询前不会执行挂号。"}</p>
+                </article>
+
+                {stage === "followup" ? (
+                  <>
+                    <button className="voice-followup" type="button" onClick={() => chooseDoctor("李明 主任医师")}>
+                      <SpeakerLoudIcon />
+                      <span><strong>语音回答</strong><small>例如:“李明主任”</small></span>
+                    </button>
+                    <div className="doctor-quick-replies">
+                      <button type="button" onClick={() => chooseDoctor("李明 主任医师")}>李明 主任医师</button>
+                      <button type="button" onClick={() => chooseDoctor("王佳 副主任医师")}>王佳 副主任医师</button>
+                      <button type="button" onClick={() => chooseDoctor("不限医生")}>不限医生</button>
+                    </div>
+                  </>
+                ) : (
+                  <>
+                    <dl className="slot-summary">
+                      <div><dt>就诊人</dt><dd>王*明 <em>已获取</em></dd></div>
+                      <div><dt>科室</dt><dd>神经内科 <em>已获取</em></dd></div>
+                      <div><dt>日期</dt><dd>明天 07-27 <em>已获取</em></dd></div>
+                      <div><dt>时间</dt><dd>上午 09:00 <em>已获取</em></dd></div>
+                      <div><dt>号别</dt><dd>专家号 <em>已获取</em></dd></div>
+                      <div><dt>医生</dt><dd>{doctor} <em>已获取</em></dd></div>
+                    </dl>
+                    <section className="risk-check-line"><CheckCircledIcon /> 未识别到急诊红旗,可继续普通挂号</section>
+                    <button className="primary-button inline-action" type="button" onClick={nextTask}>信息已齐全,查询号源 <ChevronRightIcon /></button>
+                  </>
+                )}
+              </>
+            )}
+          </section>
+        )}
+      </main>
+    </MobileScroll>
+  );
+}
+
+function makeAssistantScreen(mode: AssistantMode): FlowScreen {
+  const title = mode === "registration" ? "语音挂号" : mode === "report" ? "报告解读" : mode === "tongue" ? "中医舌诊" : "智能助手";
+  return {
+    id: `assistant-${mode}`,
+    title,
+    headerHeight: 52,
+    header: (flow) => <PageHeader flow={flow} title={title} />,
+    render: (flow) => <AssistantExperience flow={flow} mode={mode} />,
+  };
+}
+
+const registrationStartScreen: FlowScreen = {
+  id: "registration-start",
+  title: "智能分诊挂号",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="智能分诊挂号" step="1/5" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page">
+        <section className="task-intro">
+          <span className="task-icon"><CalendarIcon /></span>
+          <h2>您想如何开始?</h2>
+          <p>根据实际情况选择,过程中出现紧急风险时会优先给出就医提示。</p>
+        </section>
+        <div className="choice-stack">
+          <button type="button" onClick={() => flow.push(registrationTriageScreen)}>
+            <span><ChatBubbleIcon /></span>
+            <div><strong>描述症状,帮我找科室</strong><p>适合不知道应该挂什么科</p></div>
+            <ChevronRightIcon />
+          </button>
+          <button type="button" onClick={() => flow.push(departmentScreen)}>
+            <span><MagnifierGraphic /></span>
+            <div><strong>我知道科室,直接找号</strong><p>按科室、医生和时间查询</p></div>
+            <ChevronRightIcon />
+          </button>
+        </div>
+        <div className="demo-note"><InfoCircledIcon /> 号源、挂号和支付均为 Mock</div>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+function MagnifierGraphic() {
+  return <ReaderIcon />;
+}
+
+function RegistrationTriage({ flow }: { flow: FlowControls }) {
+  const [selected, setSelected] = useState("头疼三天,还有点恶心");
+  const urgent = selected.includes("胸痛");
+  const options = ["头疼三天,还有点恶心", "腹痛两天", "胸痛并呼吸困难"];
+  return (
+    <MobileScroll className="task-scroll">
+      <main className="task-page">
+        <section className="question-block">
+          <span className="step-kicker">症状与风险筛查</span>
+          <h2>请描述最主要的不舒服</h2>
+          <p>包括什么时候开始、严重程度和伴随症状。</p>
+        </section>
+        <KeyboardTextarea
+          className="clinical-textarea"
+          value={selected}
+          onChange={(event) => setSelected(event.target.value)}
+          aria-label="症状描述"
+        />
+        <div className="sample-label">演示场景</div>
+        <div className="scenario-list">
+          {options.map((option) => (
+            <button
+              type="button"
+              key={option}
+              className={selected === option ? "selected" : ""}
+              onClick={() => setSelected(option)}
+            >
+              {option}
+              {selected === option ? <CheckCircledIcon /> : null}
+            </button>
+          ))}
+        </div>
+        <section className="notice-card">
+          <InfoCircledIcon />
+          <div><strong>安全优先</strong><p>若识别到胸痛、呼吸困难等风险,普通挂号流程会立即中止。</p></div>
+        </section>
+        <button
+          className="primary-button inline-action"
+          type="button"
+          onClick={() => flow.push(urgent ? emergencyScreen : departmentScreen)}
+        >
+          继续风险筛查 <ChevronRightIcon />
+        </button>
+      </main>
+    </MobileScroll>
+  );
+}
+
+const registrationTriageScreen: FlowScreen = {
+  id: "registration-triage",
+  title: "症状筛查",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="智能分诊挂号" step="2/5" />,
+  render: (flow) => <RegistrationTriage flow={flow} />,
+};
+
+const emergencyScreen: FlowScreen = {
+  id: "emergency",
+  title: "重要就医提示",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="重要就医提示" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll danger-surface">
+      <main className="task-page emergency-page">
+        <span className="emergency-icon"><ExclamationTriangleIcon /></span>
+        <h2>当前不建议继续普通挂号</h2>
+        <p>您描述了胸痛和明显呼吸困难,可能需要尽快接受现场评估。</p>
+        <section className="emergency-actions">
+          <button type="button">查看急诊位置</button>
+          <a href="tel:120">拨打 120</a>
+        </section>
+        <button className="text-button" type="button" onClick={() => flow.pop()}>信息有误,重新填写</button>
+        <small>风险识别仅用于就医分流,不构成诊断。</small>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const departmentScreen: FlowScreen = {
+  id: "department",
+  title: "推荐科室",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="智能分诊挂号" step="3/5" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page">
+        <section className="question-block">
+          <span className="step-kicker">根据“头疼三天、恶心”</span>
+          <h2>为您推荐以下科室</h2>
+          <p>可查看原因后选择,无法判断时可转人工预检。</p>
+        </section>
+        <div className="department-list">
+          <button className="recommended" type="button" onClick={() => flow.push(scheduleScreen)}>
+            <span>首选</span>
+            <div><strong>神经内科</strong><p>头痛伴恶心,建议优先排查神经系统相关问题。</p></div>
+            <ChevronRightIcon />
+          </button>
+          <button type="button" onClick={() => flow.push(scheduleScreen)}>
+            <span>备选</span>
+            <div><strong>普通内科</strong><p>适合作为综合初筛科室。</p></div>
+            <ChevronRightIcon />
+          </button>
+        </div>
+        <section className="safety-copy">
+          <ExclamationTriangleIcon />
+          出现意识异常、突发剧烈头痛等情况请及时前往急诊。
+        </section>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+function ScheduleExperience({ flow }: { flow: FlowControls }) {
+  const [slot, setSlot] = useState("09:30");
+  return (
+    <MobileScroll className="task-scroll has-action-footer">
+      <main className="task-page">
+        <div className="date-row">
+          <button className="selected" type="button">明天<br /><small>07-27</small></button>
+          <button type="button">周二<br /><small>07-28</small></button>
+          <button type="button">周三<br /><small>07-29</small></button>
+        </div>
+        <article className="doctor-card">
+          <div className="doctor-avatar">李</div>
+          <div><h3>李明 <small>主任医师</small></h3><p>头痛、眩晕、脑血管病</p></div>
+          <em>余 5 号</em>
+          <div className="slot-row">
+            {["09:30", "09:45", "10:15"].map((time) => (
+              <button key={time} type="button" className={slot === time ? "selected" : ""} onClick={() => setSlot(time)}>
+                {time}
+              </button>
+            ))}
+          </div>
+        </article>
+        <article className="doctor-card">
+          <div className="doctor-avatar alt">王</div>
+          <div><h3>王佳 <small>副主任医师</small></h3><p>慢性头痛、神经系统常见病</p></div>
+          <em>余 3 号</em>
+          <div className="slot-row"><button type="button">10:30</button><button type="button">10:45</button></div>
+        </article>
+      </main>
+    </MobileScroll>
+  );
+}
+
+const scheduleScreen: FlowScreen = {
+  id: "schedule",
+  title: "医生与号源",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="选择医生与时间" step="4/5" />,
+  footerHeight: 82,
+  footer: (flow) => <FixedAction secondary="已选 李明 · 明天 09:30" label="下一步" onClick={() => flow.push(registrationConfirmScreen)} />,
+  render: (flow) => <ScheduleExperience flow={flow} />,
+};
+
+const registrationConfirmScreen: FlowScreen = {
+  id: "registration-confirm",
+  title: "确认挂号",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="确认挂号" step="5/5" />,
+  footerHeight: 82,
+  footer: (flow) => <FixedAction label="确认并模拟支付" onClick={() => flow.push(registrationResultScreen)} />,
+  render: () => (
+    <MobileScroll className="task-scroll has-action-footer">
+      <main className="task-page">
+        <section className="summary-card">
+          <span className="summary-icon"><CalendarIcon /></span>
+          <h2>请确认挂号信息</h2>
+          <dl>
+            <div><dt>就诊人</dt><dd>王*明</dd></div>
+            <div><dt>科室</dt><dd>神经内科</dd></div>
+            <div><dt>医生</dt><dd>李明 主任医师</dd></div>
+            <div><dt>时间</dt><dd>07-27 09:30</dd></div>
+            <div><dt>挂号费</dt><dd>¥25.00(Mock)</dd></div>
+          </dl>
+        </section>
+        <section className="notice-card neutral"><InfoCircledIcon /><div><strong>演示说明</strong><p>点击确认将模拟锁号、挂号和支付,不会产生真实交易。</p></div></section>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const registrationResultScreen: FlowScreen = {
+  id: "registration-result",
+  title: "挂号结果",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="挂号结果" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page result-page">
+        <span className="result-icon success"><CheckCircledIcon /></span>
+        <h2>挂号成功</h2>
+        <p>明天 09:30 · 神经内科</p>
+        <section className="result-ticket">
+          <strong>李明 主任医师</strong>
+          <span>门诊三楼 302 诊室</span>
+          <span>模拟预约号:A023</span>
+        </section>
+        <section className="notice-card neutral"><ClockIcon /><div><strong>请提前 15 分钟到院</strong><p>到院后请按现场指引完成签到候诊。</p></div></section>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(makeRootScreen("records"))}>查看挂号记录</button>
+        <button className="text-button" type="button" onClick={() => flow.replace(makeRootScreen("home"))}>返回首页</button>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const reportUploadScreen: FlowScreen = {
+  id: "report-upload",
+  title: "报告智能解读",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="报告智能解读" step="1/4" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page">
+        <section className="question-block"><span className="step-kicker">当前患者:王*明(Mock)</span><h2>上传检验或检查报告</h2><p>支持清晰图片和 PDF。上传即确认报告属于当前患者。</p></section>
+        <button className="upload-zone" type="button" onClick={() => flow.push(reportConfirmScreen)}>
+          <UploadIcon />
+          <strong>拍照或选择文件</strong>
+          <span>确保报告内容完整、文字清晰</span>
+        </button>
+        <div className="sample-label">使用演示报告</div>
+        <div className="mock-report-list">
+          <button type="button" onClick={() => flow.push(reportConfirmScreen)}><FileTextIcon /><span><strong>血常规</strong><small>本院已审核 · 重点演示血小板指标</small></span><ChevronRightIcon /></button>
+          <button type="button" onClick={() => flow.push(reportConfirmScreen)}><FileTextIcon /><span><strong>甲状腺超声</strong><small>本院已审核 · 检查报告文字解读</small></span><ChevronRightIcon /></button>
+        </div>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const reportConfirmScreen: FlowScreen = {
+  id: "report-confirm",
+  title: "确认报告",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="报告智能解读" step="2/4" />,
+  footerHeight: 82,
+  footer: (flow) => <FixedAction label="确认并开始解读" onClick={() => flow.push(reportProcessingScreen)} />,
+  render: () => (
+    <MobileScroll className="task-scroll has-action-footer">
+      <main className="task-page">
+        <section className="question-block">
+          <span className="step-kicker">解读前确认</span>
+          <h2>请确认报告信息</h2>
+          <p>报告归属和版本确认后,才会进入风险筛查与知识解读。</p>
+        </section>
+        <section className="report-source-card">
+          <span className="summary-icon"><FileTextIcon /></span>
+          <div>
+            <strong>血常规(五分类)</strong>
+            <small>检验科 · 2026-07-25 16:30</small>
+          </div>
+          <em>已审核</em>
+        </section>
+        <dl className="report-confirm-list">
+          <div><dt>报告归属</dt><dd>王*明(本人)</dd></div>
+          <div><dt>报告来源</dt><dd>空海医院院内报告</dd></div>
+          <div><dt>报告状态</dt><dd>最终报告 · 可解读</dd></div>
+          <div><dt>隐私授权</dt><dd>仅用于本次 Mock 演示</dd></div>
+        </dl>
+        <section className="notice-card neutral">
+          <InfoCircledIcon />
+          <div><strong>服务边界</strong><p>智能解读用于帮助理解报告,不代替医生诊断和治疗建议。</p></div>
+        </section>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const reportProcessingScreen: FlowScreen = {
+  id: "report-processing",
+  title: "正在解读",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="报告智能解读" step="3/4" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page processing-page">
+        <span className="processing-icon"><ReaderIcon /></span>
+        <h2>正在识别报告内容</h2>
+        <p>已完成文件检查,正在提取指标并进行演示风险筛查。</p>
+        <div className="processing-steps">
+          <span className="done"><CheckCircledIcon /> 文件质量检查</span>
+          <span className="done"><CheckCircledIcon /> 报告类型识别</span>
+          <span><ReloadIcon /> 指标标准化与知识检索</span>
+        </div>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(reportResultScreen)}>查看解读结果</button>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const reportResultScreen: FlowScreen = {
+  id: "report-result",
+  title: "血常规智能解读",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="血常规智能解读" step="4/4" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page report-result">
+        <section className="report-overview"><span><FileTextIcon /></span><div><h2>发现 3 项异常指标</h2><p>主要集中在血小板相关指标</p></div></section>
+        <section className="risk-card safe"><CheckCircledIcon /><div><strong>当前未触发演示危急值规则</strong><p>仍建议结合症状和医生意见综合判断。</p></div></section>
+        <section className="metric-section">
+          <h3>异常指标</h3>
+          <div className="metric-row"><span><strong>血小板 PLT</strong><small>参考 125–350</small></span><em>78 ↓</em></div>
+          <div className="metric-row"><span><strong>平均血小板体积 MPV</strong><small>参考 7.4–11.0</small></span><em className="high">12.8 ↑</em></div>
+          <div className="metric-row"><span><strong>血小板分布宽度 PDW</strong><small>参考 9.0–17.0</small></span><em className="high">18.1 ↑</em></div>
+        </section>
+        <section className="interpretation-card">
+          <h3>联合解读</h3>
+          <p>血小板计数降低,同时平均血小板体积和分布宽度升高。需要结合近期感染、用药、出血表现及既往结果进一步判断。</p>
+        </section>
+        <section className="action-card">
+          <h3>下一步建议</h3>
+          <p>如出现皮肤出血点、鼻出血或牙龈出血,建议尽快联系医生;可咨询血液科或原就诊科室。</p>
+        </section>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(makeRootScreen("records"))}>保存并查看健康记录</button>
+        <button className="text-button" type="button" onClick={() => flow.replace(makeRootScreen("home"))}>返回首页</button>
+        <small className="rule-note">当前阈值为演示配置,正式上线以医院确认规则为准。</small>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const tongueIntroScreen: FlowScreen = {
+  id: "tongue-intro",
+  title: "中医舌诊",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="中医舌诊" step="1/4" />,
+  footerHeight: 82,
+  footer: (flow) => <FixedAction label="同意并开始" onClick={() => flow.push(tongueQuestionScreen)} />,
+  render: () => (
+    <MobileScroll className="task-scroll has-action-footer">
+      <main className="task-page">
+        <section className="task-intro tongue-intro"><span className="task-icon cyan"><HeartIcon /></span><h2>约 3 分钟完成</h2><p>通过体质症候问答和舌象分析,生成辅助评估与健康建议。</p></section>
+        <ol className="step-list"><li><span>1</span><div><strong>体质与症候问答</strong><p>了解近期身体感受</p></div></li><li><span>2</span><div><strong>舌象拍摄</strong><p>按提示完成图片采集</p></div></li><li><span>3</span><div><strong>综合结果</strong><p>融合问答与舌象分析结果</p></div></li></ol>
+        <section className="notice-card neutral"><InfoCircledIcon /><div><strong>服务边界</strong><p>本服务不替代中医师面诊、脉诊和处方。</p></div></section>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+function TongueQuestion({ flow }: { flow: FlowControls }) {
+  const [answer, setAnswer] = useState("容易怕冷、手脚凉");
+  const options = ["容易怕冷、手脚凉", "容易口干、怕热", "没有明显偏向", "不确定"];
+  return (
+    <MobileScroll className="task-scroll">
+      <main className="task-page">
+        <section className="question-block"><span className="step-kicker">寒热与出汗 · 2/6</span><h2>最近一段时间,您更常出现哪种情况?</h2><p>请选择最接近当前状态的一项。</p></section>
+        <div className="radio-list">
+          {options.map((option) => <button type="button" className={answer === option ? "selected" : ""} onClick={() => setAnswer(option)} key={option}><span>{answer === option ? <CheckCircledIcon /> : null}</span>{option}</button>)}
+        </div>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.push(tongueCaptureScreen)}>保存问答并继续 <ChevronRightIcon /></button>
+        <button className="text-button" type="button" onClick={() => flow.replace(makeRootScreen("home"))}>稍后继续</button>
+      </main>
+    </MobileScroll>
+  );
+}
+
+const tongueQuestionScreen: FlowScreen = {
+  id: "tongue-question",
+  title: "体质与症候",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="体质与症候" step="2/4" />,
+  render: (flow) => <TongueQuestion flow={flow} />,
+};
+
+function TongueCapture({ flow }: { flow: FlowControls }) {
+  const [failed, setFailed] = useState(false);
+  return (
+    <MobileScroll className="task-scroll">
+      <main className="task-page capture-page">
+        <section className="question-block"><span className="step-kicker">问答进度已保存</span><h2>拍摄舌象</h2><p>请在自然光下拍摄,确保舌体完整入镜且画面清晰。</p></section>
+        <div className={`camera-guide ${failed ? "failed" : ""}`}><CameraIcon /><span>{failed ? "图片模糊,请重新拍摄" : "将舌体完整置于取景框内"}</span></div>
+        <ul className="capture-tips"><li>使用自然光或稳定白光</li><li>舌体自然伸出并完整入镜</li><li>避免刚进食有色食物后拍摄</li></ul>
+        <button className="primary-button inline-action" type="button" onClick={() => failed ? flow.push(tongueProcessingScreen) : setFailed(true)}>{failed ? "重新拍摄并分析" : "打开相机"} <CameraIcon /></button>
+        <button className="secondary-button" type="button" onClick={() => flow.push(tongueProcessingScreen)}>使用合格演示图片</button>
+      </main>
+    </MobileScroll>
+  );
+}
+
+const tongueCaptureScreen: FlowScreen = {
+  id: "tongue-capture",
+  title: "舌象拍摄",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="舌象拍摄" step="3/4" />,
+  render: (flow) => <TongueCapture flow={flow} />,
+};
+
+const tongueProcessingScreen: FlowScreen = {
+  id: "tongue-processing",
+  title: "舌象分析",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="中医舌诊" step="分析中" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page processing-page">
+        <span className="processing-icon cyan"><HeartIcon /></span>
+        <h2>正在调用舌诊 MCP</h2>
+        <p>合格舌象已上传,正在获取舌质、舌苔和特征结果,并与问答信息融合。</p>
+        <div className="processing-steps">
+          <span className="done"><CheckCircledIcon /> 图片质量检查</span>
+          <span className="done"><CheckCircledIcon /> 舌诊 MCP 返回特征</span>
+          <span><ReloadIcon /> 问答与舌象证据融合</span>
+        </div>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(tongueResultScreen)}>查看综合结果</button>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const tongueResultScreen: FlowScreen = {
+  id: "tongue-result",
+  title: "中医舌诊评估",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="中医舌诊评估" step="已完成" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page tongue-result result-page">
+        <span className="result-icon cyan"><HeartIcon /></span><h2>本次评估已完成</h2><p>问答信息与舌象分析已完成融合</p>
+        <section className="pattern-card"><span>体质倾向</span><strong>气虚质</strong><small>证候倾向:脾气虚,伴痰湿倾向</small></section>
+        <section className="feature-grid"><div><span>舌质</span><strong>淡红、偏胖</strong></div><div><span>舌苔</span><strong>白、薄</strong></div><div><span>特征</span><strong>可见齿痕</strong></div><div><span>可信度</span><strong>中等</strong></div></section>
+        <section className="interpretation-card"><h3>综合依据</h3><p>容易疲倦、饭后腹胀,结合舌体偏胖和齿痕,提示气虚倾向。</p></section>
+        <section className="action-card"><h3>健康建议</h3><p>规律作息,饮食宜温软清淡,运动以舒缓、循序渐进为宜。</p></section>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(makeRootScreen("records"))}>保存评估记录</button>
+        <small className="rule-note">建议由中医师结合完整问诊与脉诊进一步确认。</small>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+export default function Prototype() {
+  return <FlowStack initial={makeRootScreen("home")} />;
+}

+ 12 - 0
prototype/adjutant-mobile/src/main.tsx

@@ -0,0 +1,12 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import "@fontsource/roboto/latin-500.css";
+import App from "./App";
+import "./styles.css";
+import "./prototype.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+  <React.StrictMode>
+    <App />
+  </React.StrictMode>,
+);

+ 136 - 0
prototype/adjutant-mobile/src/mobile/BottomSheet.tsx

@@ -0,0 +1,136 @@
+import { type PropsWithChildren, useEffect, useState } from "react";
+import * as Dialog from "@radix-ui/react-dialog";
+import { useDrag } from "@use-gesture/react";
+import { AnimatePresence, motion } from "motion/react";
+import { useKeyboard, useKeyboardInsets } from "./Keyboard";
+import { useScreenPortal } from "./PhoneFrame";
+import { useMobileDevice } from "./Device";
+
+type BottomSheetProps = PropsWithChildren<{
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  title: string;
+  description?: string;
+  snap?: number;
+}>;
+
+export function BottomSheet({
+  open,
+  onOpenChange,
+  title,
+  description,
+  snap = 0.72,
+  children,
+}: BottomSheetProps) {
+  const { device } = useMobileDevice();
+  const { screenRef } = useScreenPortal();
+  const keyboard = useKeyboard();
+  const { keyboardHeight } = useKeyboardInsets();
+  const [dragY, setDragY] = useState(0);
+
+  useEffect(() => {
+    if (open) keyboard.hide();
+  }, [open]);
+
+  const handleOpenChange = (nextOpen: boolean) => {
+    if (nextOpen) {
+      keyboard.hide();
+    }
+
+    onOpenChange(nextOpen);
+  };
+
+  const bindDrag = useDrag(
+    (state) => {
+      const [, movementY] = state.movement;
+      const [, velocityY] = state.velocity;
+      const [, directionY] = state.direction;
+      const nextY = Math.max(0, movementY);
+
+      if (!state.last) {
+        setDragY(nextY);
+        return;
+      }
+
+      const shouldClose = nextY > 96 || (velocityY > 0.55 && directionY > 0);
+      setDragY(0);
+
+      if (shouldClose) {
+        onOpenChange(false);
+      }
+    },
+    {
+      axis: "y",
+      filterTaps: true,
+    },
+  );
+
+  const sheetHeight = Math.round(device.geometry.screen.height * snap);
+  const effectiveHeight = Math.max(260, sheetHeight - Math.min(keyboardHeight, 180));
+  const sheetBottom =
+    device.platform === "android"
+      ? Math.max(device.geometry.safeArea.bottom, keyboardHeight)
+      : keyboardHeight;
+  const portalContainer = screenRef.current ?? undefined;
+
+  return (
+    <Dialog.Root open={open} onOpenChange={handleOpenChange}>
+      {/* Keep the portal mounted after `open` flips so AnimatePresence can run
+          the sheet and overlay exit animations before Radix removes them. */}
+      <Dialog.Portal container={portalContainer} forceMount>
+        <AnimatePresence>
+          {open ? (
+            <>
+              <Dialog.Overlay asChild forceMount>
+                <motion.div
+                  className="sheet-overlay"
+                  data-testid="sheet-overlay"
+                  initial={{ opacity: 0 }}
+                  animate={{ opacity: 1 }}
+                  exit={{ opacity: 0 }}
+                  transition={{ duration: 0.16 }}
+                />
+              </Dialog.Overlay>
+              <Dialog.Content asChild forceMount>
+                <motion.div
+                  className="bottom-sheet"
+                  data-testid="bottom-sheet"
+                  style={{
+                    bottom: sheetBottom,
+                    maxHeight: effectiveHeight,
+                  }}
+                  initial={{ y: effectiveHeight + 36 }}
+                  animate={{ y: dragY }}
+                  exit={{
+                    y: effectiveHeight + 36,
+                    transition: {
+                      type: "spring",
+                      stiffness: 250,
+                      damping: 30,
+                      mass: 1.05,
+                    },
+                  }}
+                  transition={{
+                    type: "spring",
+                    stiffness: 500,
+                    damping: 43,
+                    mass: 0.9,
+                  }}
+                >
+                  <div className="sheet-handle-zone" data-testid="sheet-handle" {...bindDrag()}>
+                    <div className="sheet-handle" />
+                  </div>
+                  <div className="sheet-header">
+                    <Dialog.Title className="sheet-title">{title}</Dialog.Title>
+                    {description ? <Dialog.Description className="sheet-description">{description}</Dialog.Description> : null}
+                  </div>
+                  <div className="sheet-content">{children}</div>
+                </motion.div>
+              </Dialog.Content>
+            </>
+          ) : null}
+        </AnimatePresence>
+      </Dialog.Portal>
+    </Dialog.Root>
+  );
+}

+ 31 - 0
prototype/adjutant-mobile/src/mobile/COMPONENTS.md

@@ -0,0 +1,31 @@
+# Mobile runtime components
+
+## Carousel
+
+`Carousel` is the standard component for horizontal collections: cards, images, media, swipeable items, and chip or filter rails. Place it directly inside `MobileScroll`; consumers should not add gesture wrappers or pointer handlers.
+
+```tsx
+<MobileScroll>
+  <section>
+    <Carousel
+      ariaLabel="Event details"
+      className="event-carousel"
+      contentClassName="event-carousel-track"
+    >
+      {cards}
+    </Carousel>
+  </section>
+</MobileScroll>
+```
+
+The runtime resolves nested gestures by axis. Horizontal intent stays with `Carousel`; vertical intent is handed to the parent `MobileScroll`. Slight vertical drift after a horizontal gesture is claimed does not move, rubber-band, or add momentum to the parent. Taps remain clickable, while a completed drag suppresses the item click.
+
+Do not use `data-scroll-drag="ignore"` for carousels or ordinary rails. It is a hard opt-out that prevents parent scrolling in every direction. Do not layer CSS scroll snapping over the runtime's JavaScript momentum. If snapping is added later, it should be a component option so one system owns release motion.
+
+## Keyboard-linked surfaces
+
+Use `KeyboardInput`, `KeyboardTextarea`, or `MobileTextField` for all text entry. Position a composer, search surface, or other keyboard-linked UI from `useKeyboardInsets().bottomInset`. The inset is relative to the app viewport: Android's closed-keyboard viewport already ends above its navigation bar, while iOS still needs its overlaid home-indicator inset; both platforms return the keyboard height while the keyboard is open. Never pin those surfaces to only `keyboardHeight`. When that surface closes, call `keyboard.hide()` in the same event before updating its own open state.
+
+## BottomSheet
+
+`BottomSheet` dismisses the keyboard before opening and animates both in and out by default. Keep its `open` state controlled through `onOpenChange`; no consumer exit-animation wrapper is needed.

+ 257 - 0
prototype/adjutant-mobile/src/mobile/Carousel.tsx

@@ -0,0 +1,257 @@
+import {
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+  type CSSProperties,
+  type MouseEvent as ReactMouseEvent,
+  type PointerEvent as ReactPointerEvent,
+  type PropsWithChildren,
+} from "react";
+
+export type CarouselProps = PropsWithChildren<{
+  className?: string;
+  contentClassName?: string;
+  ariaLabel?: string;
+  showScrollbar?: boolean;
+  draggingEnabled?: boolean;
+}>;
+
+const physics = {
+  friction: 2.1,
+  velocityScale: 890,
+  velocityTolerance: 18,
+  bounceTension: 200,
+  bounceFriction: 40,
+  overdragScale: 0.5,
+  maxOverdrag: 96,
+  sampleWindow: 100,
+  dragThreshold: 8,
+} as const;
+
+type Sample = { value: number; time: number };
+type DragSession = {
+  pointerId: number;
+  startPrimary: number;
+  startCross: number;
+  startOffset: number;
+  captured: boolean;
+  dragged: boolean;
+};
+
+export function Carousel({
+  className,
+  contentClassName,
+  ariaLabel,
+  showScrollbar = false,
+  draggingEnabled = true,
+  children,
+}: CarouselProps) {
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const sessionRef = useRef<DragSession | null>(null);
+  const samplesRef = useRef<Sample[]>([]);
+  const frameRef = useRef<number | null>(null);
+  const overdragRef = useRef(0);
+  const suppressClickRef = useRef(false);
+  const [dragging, setDragging] = useState(false);
+  const [overdrag, setOverdrag] = useState(0);
+  const [thumb, setThumb] = useState({ visible: false, offset: 0, size: 0 });
+
+  const offset = useCallback((node: HTMLDivElement) => node.scrollLeft, []);
+  const setOffset = useCallback((node: HTMLDivElement, value: number) => {
+    node.scrollLeft = value;
+  }, []);
+  const clientSize = useCallback((node: HTMLDivElement) => node.clientWidth, []);
+  const scrollSize = useCallback((node: HTMLDivElement) => node.scrollWidth, []);
+  const maxOffset = useCallback((node: HTMLDivElement) => Math.max(0, scrollSize(node) - clientSize(node)), [clientSize, scrollSize]);
+
+  const stopMotion = useCallback(() => {
+    if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current);
+    frameRef.current = null;
+  }, []);
+  const setRubberBand = useCallback((value: number) => {
+    const next = Math.max(-physics.maxOverdrag, Math.min(physics.maxOverdrag, value));
+    overdragRef.current = next;
+    setOverdrag(next);
+  }, []);
+
+  const updateThumb = useCallback((visible = true) => {
+    if (!showScrollbar || !scrollRef.current) return;
+    const node = scrollRef.current;
+    const viewport = clientSize(node);
+    const content = scrollSize(node);
+    const enabled = content > viewport + 2;
+    const size = enabled ? Math.max(36, (viewport / content) * viewport) : 0;
+    const track = Math.max(0, viewport - size - 8);
+    const progress = offset(node) / Math.max(1, content - viewport);
+    setThumb({ visible: visible && enabled, size, offset: enabled ? 4 + progress * track : 0 });
+  }, [clientSize, offset, scrollSize, showScrollbar]);
+
+  const springBack = useCallback((initialVelocity = 0) => {
+    stopMotion();
+    let position = overdragRef.current;
+    let velocity = Math.max(-1400, Math.min(1400, initialVelocity));
+    let previous: number | null = null;
+    const tick = (time: number) => {
+      const seconds = Math.min(0.034, ((previous === null ? 16 : time - previous) || 16) / 1000);
+      previous = time;
+      velocity += (-physics.bounceTension * position - physics.bounceFriction * velocity) * seconds;
+      position += velocity * seconds;
+      if (Math.abs(position) < 0.5 && Math.abs(velocity) < 0.5) {
+        setRubberBand(0);
+        frameRef.current = null;
+        return;
+      }
+      setRubberBand(position);
+      frameRef.current = window.requestAnimationFrame(tick);
+    };
+    frameRef.current = window.requestAnimationFrame(tick);
+  }, [setRubberBand, stopMotion]);
+
+  const momentum = useCallback((node: HTMLDivElement, initialVelocity: number) => {
+    let velocity = initialVelocity;
+    let previous: number | null = null;
+    const tick = (time: number) => {
+      const seconds = (previous === null ? 16 : Math.min(34, time - previous)) / 1000;
+      previous = time;
+      velocity *= Math.exp(-physics.friction * seconds);
+      if (Math.abs(velocity) < physics.velocityTolerance) {
+        frameRef.current = null;
+        updateThumb(true);
+        return;
+      }
+      const next = offset(node) + velocity * seconds;
+      const maximum = maxOffset(node);
+      if (next < 0 || next > maximum) {
+        setOffset(node, Math.max(0, Math.min(maximum, next)));
+        // Match MobileScroll's edge convention: positive displacement at the
+        // leading edge, negative displacement at the trailing edge.
+        setRubberBand((next < 0 ? -next : maximum - next) * physics.overdragScale);
+        springBack(velocity * physics.overdragScale);
+        return;
+      }
+      setOffset(node, next);
+      updateThumb(true);
+      frameRef.current = window.requestAnimationFrame(tick);
+    };
+    if (Math.abs(velocity) >= physics.velocityTolerance) frameRef.current = window.requestAnimationFrame(tick);
+  }, [maxOffset, offset, setOffset, setRubberBand, springBack, updateThumb]);
+
+  useEffect(() => {
+    const node = scrollRef.current;
+    if (!node) return;
+    const onScroll = () => updateThumb(true);
+    const observer = new ResizeObserver(() => updateThumb(false));
+    node.addEventListener("scroll", onScroll, { passive: true });
+    observer.observe(node);
+    if (node.firstElementChild) observer.observe(node.firstElementChild);
+    updateThumb(false);
+    return () => {
+      node.removeEventListener("scroll", onScroll);
+      observer.disconnect();
+      stopMotion();
+    };
+  }, [stopMotion, updateThumb]);
+
+  const primary = (event: ReactPointerEvent<HTMLDivElement>) => event.clientX;
+  const cross = (event: ReactPointerEvent<HTMLDivElement>) => event.clientY;
+  const record = (value: number) => {
+    const time = performance.now();
+    samplesRef.current = [...samplesRef.current, { value, time }].filter((sample) => time - sample.time <= physics.sampleWindow);
+  };
+
+  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
+    const node = scrollRef.current;
+    if (!draggingEnabled || !node || maxOffset(node) <= 2 || (event.pointerType === "mouse" && event.button !== 0)) return;
+    stopMotion();
+    samplesRef.current = [];
+    record(primary(event));
+    setRubberBand(0);
+    sessionRef.current = { pointerId: event.pointerId, startPrimary: primary(event), startCross: cross(event), startOffset: offset(node), captured: false, dragged: false };
+  };
+
+  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
+    const node = scrollRef.current;
+    const session = sessionRef.current;
+    if (!node || !session || session.pointerId !== event.pointerId) return;
+    const delta = primary(event) - session.startPrimary;
+    const crossDelta = cross(event) - session.startCross;
+    if (!session.dragged) {
+      // Keep the gesture pending until it clears tap slop. Pointer-down and
+      // these early moves must bubble so a parent MobileScroll can still win.
+      if (Math.max(Math.abs(delta), Math.abs(crossDelta)) < physics.dragThreshold) return;
+      if (Math.abs(crossDelta) > Math.abs(delta)) {
+        // The cross axis won. Abandon this session without capturing or
+        // canceling the event so the parent can handle this move and release.
+        sessionRef.current = null;
+        return;
+      }
+      // The scroller owns the gesture from this move onward. Capture keeps
+      // delivery stable outside its bounds; stopping propagation prevents the
+      // parent from accumulating vertical drift or release momentum.
+      event.currentTarget.setPointerCapture(event.pointerId);
+      session.captured = true;
+    }
+    event.preventDefault();
+    event.stopPropagation();
+    session.dragged = true;
+    setDragging(true);
+    suppressClickRef.current = true;
+    record(primary(event));
+    const desired = session.startOffset - delta;
+    const maximum = maxOffset(node);
+    setOffset(node, Math.max(0, Math.min(maximum, desired)));
+    setRubberBand(desired < 0 ? -desired * physics.overdragScale : desired > maximum ? -(desired - maximum) * physics.overdragScale : 0);
+    updateThumb(true);
+  };
+
+  const finish = (event: ReactPointerEvent<HTMLDivElement>) => {
+    const node = scrollRef.current;
+    const session = sessionRef.current;
+    if (!node || !session || session.pointerId !== event.pointerId) return;
+    if (session.captured) event.currentTarget.releasePointerCapture(event.pointerId);
+    const samples = samplesRef.current;
+    const first = samples[0];
+    const last = samples[samples.length - 1];
+    const velocity = first && last ? -((last.value - first.value) / Math.max(1, last.time - first.time)) * physics.velocityScale : 0;
+    sessionRef.current = null;
+    setDragging(false);
+    if (session.dragged) event.preventDefault();
+    if (Math.abs(overdragRef.current) > 0.1) springBack(velocity * physics.overdragScale);
+    else momentum(node, velocity);
+  };
+
+  const onClickCapture = (event: ReactMouseEvent<HTMLDivElement>) => {
+    if (!suppressClickRef.current) return;
+    suppressClickRef.current = false;
+    event.preventDefault();
+    event.stopPropagation();
+  };
+
+  const style = { "--mobile-carousel-overdrag": `${overdrag}px` } as CSSProperties;
+  const thumbStyle = { width: thumb.size, transform: `translateX(${thumb.offset}px)` };
+
+  return (
+    <div
+      ref={scrollRef}
+      className={`mobile-carousel ${className ?? ""}`}
+      data-dragging={dragging}
+      data-overscroll={overdrag.toFixed(2)}
+      aria-label={ariaLabel}
+      role={ariaLabel ? "region" : undefined}
+      style={style}
+      onPointerDown={onPointerDown}
+      onPointerMove={onPointerMove}
+      onPointerUp={finish}
+      onPointerCancel={finish}
+      onClickCapture={onClickCapture}
+    >
+      <div className={`mobile-carousel-content ${contentClassName ?? ""}`}>{children}</div>
+      {showScrollbar ? (
+        <div className="mobile-carousel-scrollbar" data-visible={thumb.visible} aria-hidden="true">
+          <div className="mobile-carousel-scrollbar-thumb" style={thumbStyle} />
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 115 - 0
prototype/adjutant-mobile/src/mobile/Device.tsx

@@ -0,0 +1,115 @@
+import { createContext, type PropsWithChildren, useContext, useMemo, useState } from "react";
+import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
+import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons";
+import { mobileAssets } from "./assets";
+import { iphoneGeometry, pixelGeometry, type MobileDeviceGeometry } from "./geometry";
+
+export type MobileDeviceId = "iphone" | "pixel-10";
+
+type MobileDevicePreset = {
+  id: MobileDeviceId;
+  label: string;
+  platform: "ios" | "android";
+  bezel: string;
+  bezelLayer: "above-screen" | "below-screen";
+  geometry: MobileDeviceGeometry;
+  camera?: {
+    size: number;
+    top: number;
+  };
+};
+
+export const mobileDevices: Record<MobileDeviceId, MobileDevicePreset> = {
+  iphone: {
+    id: "iphone",
+    label: "iPhone",
+    platform: "ios",
+    bezel: mobileAssets.iphoneBezel,
+    bezelLayer: "above-screen",
+    geometry: iphoneGeometry,
+  },
+  "pixel-10": {
+    id: "pixel-10",
+    label: "Pixel 10",
+    platform: "android",
+    bezel: mobileAssets.pixel10Bezel,
+    bezelLayer: "below-screen",
+    geometry: pixelGeometry,
+    camera: {
+      size: 32,
+      top: 23,
+    },
+  },
+};
+
+type MobileDeviceContextValue = {
+  device: MobileDevicePreset;
+  deviceId: MobileDeviceId;
+  setDeviceId: (deviceId: MobileDeviceId) => void;
+};
+
+const MobileDeviceContext = createContext<MobileDeviceContextValue | null>(null);
+
+export function MobileDeviceProvider({ children }: PropsWithChildren) {
+  const [deviceId, setDeviceId] = useState<MobileDeviceId>("iphone");
+  const value = useMemo(
+    () => ({ device: mobileDevices[deviceId], deviceId, setDeviceId }),
+    [deviceId],
+  );
+
+  return <MobileDeviceContext.Provider value={value}>{children}</MobileDeviceContext.Provider>;
+}
+
+export function useMobileDevice() {
+  const context = useContext(MobileDeviceContext);
+
+  if (!context) {
+    throw new Error("useMobileDevice must be used inside MobileDeviceProvider");
+  }
+
+  return context;
+}
+
+export function DevicePicker() {
+  const { device, deviceId, setDeviceId } = useMobileDevice();
+
+  return (
+    <DropdownMenu.Root>
+      <div className="device-menu-bar" data-testid="device-menu-bar">
+        <DropdownMenu.Trigger asChild>
+          <button
+            className="device-picker-trigger"
+            data-testid="device-picker"
+            aria-label={`Preview device: ${device.label}`}
+            type="button"
+          >
+            <span>{device.label}</span>
+            <ChevronDownIcon aria-hidden="true" />
+          </button>
+        </DropdownMenu.Trigger>
+      </div>
+      <DropdownMenu.Portal>
+        <DropdownMenu.Content className="device-picker-menu" align="end" sideOffset={8} collisionPadding={12}>
+          <DropdownMenu.RadioGroup
+            value={deviceId}
+            onValueChange={(value) => setDeviceId(value as MobileDeviceId)}
+          >
+            {Object.values(mobileDevices).map((option) => (
+              <DropdownMenu.RadioItem
+                key={option.id}
+                className="device-picker-item"
+                value={option.id}
+                data-testid={`device-option-${option.id}`}
+              >
+                <span>{option.label}</span>
+                <DropdownMenu.ItemIndicator className="device-picker-check">
+                  <CheckIcon aria-hidden="true" />
+                </DropdownMenu.ItemIndicator>
+              </DropdownMenu.RadioItem>
+            ))}
+          </DropdownMenu.RadioGroup>
+        </DropdownMenu.Content>
+      </DropdownMenu.Portal>
+    </DropdownMenu.Root>
+  );
+}

+ 234 - 0
prototype/adjutant-mobile/src/mobile/FlowStack.tsx

@@ -0,0 +1,234 @@
+import {
+  createContext,
+  type CSSProperties,
+  type PropsWithChildren,
+  type ReactNode,
+  useCallback,
+  useContext,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { AnimatePresence, motion } from "motion/react";
+import { useDrag } from "@use-gesture/react";
+import { useMobileDevice } from "./Device";
+import { useKeyboard, useKeyboardDismissDrag, useKeyboardInsets } from "./Keyboard";
+
+export type FlowScreen = {
+  id: string;
+  title?: string;
+  header?: (flow: FlowControls) => ReactNode;
+  headerHeight?: number;
+  footer?: (flow: FlowControls) => ReactNode;
+  footerHeight?: number;
+  render: (flow: FlowControls) => ReactNode;
+};
+
+type FlowEntry = FlowScreen & {
+  key: string;
+};
+
+export type FlowControls = {
+  current: FlowEntry;
+  previous: FlowEntry | null;
+  stack: FlowEntry[];
+  canGoBack: boolean;
+  push: (screen: FlowScreen) => void;
+  pop: () => void;
+  replace: (screen: FlowScreen) => void;
+};
+
+const FlowContext = createContext<FlowControls | null>(null);
+
+export function useFlow() {
+  const context = useContext(FlowContext);
+
+  if (!context) {
+    throw new Error("useFlow must be used inside FlowStack");
+  }
+
+  return context;
+}
+
+function FlowProvider({ value, children }: PropsWithChildren<{ value: FlowControls }>) {
+  return <FlowContext.Provider value={value}>{children}</FlowContext.Provider>;
+}
+
+export function FlowStack({ initial }: { initial: FlowScreen }) {
+  const { device } = useMobileDevice();
+  const keyboard = useKeyboard();
+  const { bottomInset, keyboardDragging } = useKeyboardInsets();
+  const dismissKeyboardDrag = useKeyboardDismissDrag();
+  const sequence = useRef(1);
+  const gestureStartedAtEdge = useRef(false);
+  const initialEntry = useRef<FlowEntry>({ ...initial, key: `${initial.id}-0` });
+  const [stack, setStack] = useState<FlowEntry[]>(() => [initialEntry.current]);
+  const [direction, setDirection] = useState(1);
+  const [swipeX, setSwipeX] = useState(0);
+
+  const toEntry = useCallback((screen: FlowScreen): FlowEntry => {
+    const next = sequence.current;
+    sequence.current += 1;
+    return { ...screen, key: `${screen.id}-${next}` };
+  }, []);
+
+  const pop = useCallback(() => {
+    keyboard.hide();
+    setDirection(-1);
+    setStack((currentStack) => {
+      if (currentStack.length <= 1) return currentStack;
+      return currentStack.slice(0, -1);
+    });
+  }, [keyboard]);
+
+  const controls = useMemo<FlowControls>(() => {
+    const current = stack[stack.length - 1];
+    const previous = stack.length > 1 ? stack[stack.length - 2] : null;
+
+    return {
+      current,
+      previous,
+      stack,
+      canGoBack: stack.length > 1,
+      push: (screen) => {
+        keyboard.hide();
+        setDirection(1);
+        setSwipeX(0);
+        setStack((currentStack) => [...currentStack, toEntry(screen)]);
+      },
+      pop,
+      replace: (screen) => {
+        keyboard.hide();
+        setDirection(1);
+        setSwipeX(0);
+        setStack((currentStack) => {
+          const next = currentStack.slice(0, -1);
+          return [...next, toEntry(screen)];
+        });
+      },
+    };
+  }, [keyboard, pop, stack, toEntry]);
+
+  const bindEdgeSwipe = useDrag(
+    (state) => {
+      if (!controls.canGoBack) return;
+
+      if (state.first) {
+        const target = state.event.currentTarget as HTMLElement;
+        const bounds = target.getBoundingClientRect();
+        gestureStartedAtEdge.current = state.initial[0] - bounds.left < 28;
+      }
+
+      if (!gestureStartedAtEdge.current) return;
+
+      const [movementX] = state.movement;
+      const [velocityX] = state.velocity;
+      const [directionX] = state.direction;
+      const nextX = Math.max(0, Math.min(movementX, device.geometry.screen.width));
+
+      if (!state.last) {
+        setSwipeX(nextX);
+        return;
+      }
+
+      const shouldPop = nextX > 92 || (velocityX > 0.45 && directionX > 0);
+      setSwipeX(0);
+      gestureStartedAtEdge.current = false;
+
+      if (shouldPop) {
+        controls.pop();
+      }
+    },
+    {
+      axis: "x",
+      filterTaps: true,
+      pointer: { touch: true },
+    },
+  );
+
+  const screenWidth = device.geometry.screen.width;
+  const topIndex = stack.length - 1;
+  const parkedX = -screenWidth * 0.28;
+  const header = controls.current.header?.(controls);
+  const headerHeight = controls.current.headerHeight ?? 0;
+  const headerSafeArea = header ? device.geometry.safeArea.top : 0;
+  const totalHeaderHeight = header ? headerSafeArea + headerHeight : 0;
+  const footer = controls.current.footer?.(controls);
+  const footerHeight = controls.current.footerHeight ?? 0;
+
+  const screenVariants = {
+    enter: (animationDirection: number) => ({
+      x: animationDirection > 0 ? screenWidth : parkedX,
+      scale: animationDirection > 0 ? 1 : 0.985,
+    }),
+    exit: (animationDirection: number) => ({
+      x: animationDirection < 0 ? screenWidth : parkedX,
+      scale: animationDirection < 0 ? 1 : 0.985,
+    }),
+  };
+
+  return (
+    <FlowProvider value={controls}>
+      <div
+        className="flow-stack"
+        data-testid="flow-stack"
+        data-keyboard-dragging={keyboardDragging ? "true" : "false"}
+        style={
+          {
+            "--flow-header-height": `${totalHeaderHeight}px`,
+            "--flow-header-content-height": `${headerHeight}px`,
+            "--flow-header-safe-area": `${headerSafeArea}px`,
+            "--flow-footer-height": `${footer ? footerHeight : 0}px`,
+            "--keyboard-height": `${bottomInset}px`,
+          } as CSSProperties
+        }
+        {...bindEdgeSwipe()}
+      >
+        {header ? (
+          <header className="flow-fixed-header" data-testid="flow-fixed-header">
+            {header}
+          </header>
+        ) : null}
+        <div className="flow-scenes">
+          <AnimatePresence initial={false} custom={direction}>
+            {stack.map((entry, index) => {
+              const isTop = index === topIndex;
+              const isVisible = index >= topIndex - 1;
+
+              return (
+                <motion.div
+                  key={entry.key}
+                  className="flow-screen"
+                  data-flow-current={isTop ? "true" : "false"}
+                  data-testid={isTop ? "flow-current" : undefined}
+                  custom={direction}
+                  variants={screenVariants}
+                  initial={isTop ? "enter" : false}
+                  animate={{
+                    x: isTop ? swipeX : parkedX,
+                    scale: isTop ? 1 : 0.985,
+                  }}
+                  exit="exit"
+                  transition={{ type: "spring", stiffness: 360, damping: 38, mass: 0.9 }}
+                  style={{
+                    opacity: isVisible ? 1 : 0,
+                    pointerEvents: isTop ? "auto" : "none",
+                    visibility: isVisible ? "visible" : "hidden",
+                    zIndex: isTop ? 2 : 1,
+                  }}
+                >
+                  {entry.render(controls)}
+                </motion.div>
+              );
+            })}
+          </AnimatePresence>
+        </div>
+        {footer ? (
+          <footer className="flow-fixed-footer" data-testid="flow-fixed-footer" {...dismissKeyboardDrag}>
+            {footer}
+          </footer>
+        ) : null}
+      </div>
+    </FlowProvider>
+  );
+}

+ 241 - 0
prototype/adjutant-mobile/src/mobile/Keyboard.tsx

@@ -0,0 +1,241 @@
+import {
+  createContext,
+  type InputHTMLAttributes,
+  type PointerEvent as ReactPointerEvent,
+  type PropsWithChildren,
+  type Ref,
+  type TextareaHTMLAttributes,
+  useContext,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { motion } from "motion/react";
+import { mobileAssets } from "./assets";
+import { useMobileDevice } from "./Device";
+
+type KeyboardContextValue = {
+  visible: boolean;
+  height: number;
+  fullHeight: number;
+  progress: number;
+  dragOffset: number;
+  isDragging: boolean;
+  focusedElement: HTMLElement | null;
+  setDragOffset: (offset: number) => void;
+  setDragging: (dragging: boolean) => void;
+  show: (element?: HTMLElement | null) => void;
+  hide: () => void;
+};
+
+type KeyboardInputProps = InputHTMLAttributes<HTMLInputElement> & {
+  ref?: Ref<HTMLInputElement>;
+};
+
+const KeyboardContext = createContext<KeyboardContextValue | null>(null);
+
+export function KeyboardProvider({ children }: PropsWithChildren) {
+  const { device } = useMobileDevice();
+  const [visible, setVisible] = useState(false);
+  const [dragOffset, setRawDragOffset] = useState(0);
+  const [isDragging, setDragging] = useState(false);
+  const [focusedElement, setFocusedElement] = useState<HTMLElement | null>(null);
+  const fullHeight = device.geometry.keyboard.height;
+  const setDragOffset = (offset: number) => {
+    setRawDragOffset(Math.max(0, Math.min(fullHeight, offset)));
+  };
+
+  const value = useMemo<KeyboardContextValue>(
+    () => ({
+      visible,
+      height: visible ? Math.max(0, fullHeight - dragOffset) : 0,
+      fullHeight,
+      dragOffset,
+      isDragging,
+      progress: visible ? 1 : 0,
+      focusedElement,
+      setDragOffset,
+      setDragging,
+      show: (element) => {
+        setRawDragOffset(0);
+        setDragging(false);
+        setFocusedElement(element ?? null);
+        setVisible(true);
+      },
+      hide: () => {
+        focusedElement?.blur();
+        setDragging(false);
+        setFocusedElement(null);
+        setVisible(false);
+      },
+    }),
+    [dragOffset, focusedElement, fullHeight, isDragging, visible],
+  );
+
+  return <KeyboardContext.Provider value={value}>{children}</KeyboardContext.Provider>;
+}
+
+export function useKeyboard() {
+  const context = useContext(KeyboardContext);
+
+  if (!context) {
+    throw new Error("useKeyboard must be used inside KeyboardProvider");
+  }
+
+  return context;
+}
+
+export function useKeyboardInsets() {
+  const keyboard = useKeyboard();
+  const { device } = useMobileDevice();
+  const reservesAndroidNavigation = device.platform === "android" && !keyboard.visible;
+
+  return {
+    keyboardHeight: keyboard.height,
+    keyboardFullHeight: keyboard.fullHeight,
+    keyboardDragging: keyboard.isDragging,
+    bottomInset: reservesAndroidNavigation
+      ? 0
+      : device.platform === "android"
+        ? keyboard.height
+        : Math.max(device.geometry.safeArea.bottom, keyboard.height),
+    availableHeight:
+      device.geometry.screen.height -
+      keyboard.height -
+      (reservesAndroidNavigation ? device.geometry.safeArea.bottom : 0),
+    isKeyboardVisible: keyboard.visible,
+  };
+}
+
+export function useKeyboardDismissDrag() {
+  const keyboard = useKeyboard();
+  const dragRef = useRef({
+    pointerId: null as number | null,
+    startY: 0,
+    lastY: 0,
+    lastTime: 0,
+    velocityY: 0,
+  });
+
+  const endDismissDrag = (event: ReactPointerEvent<HTMLElement>) => {
+    const drag = dragRef.current;
+    if (drag.pointerId !== event.pointerId) return;
+
+    try {
+      event.currentTarget.releasePointerCapture(event.pointerId);
+    } catch {
+      // Capture may already be gone after pointer cancel.
+    }
+
+    const nextY = Math.max(0, event.clientY - drag.startY);
+    const shouldDismiss = nextY > 76 || drag.velocityY > 0.45;
+    drag.pointerId = null;
+
+    if (shouldDismiss) {
+      keyboard.setDragOffset(nextY);
+      keyboard.hide();
+      return;
+    }
+
+    keyboard.setDragging(false);
+    keyboard.setDragOffset(0);
+  };
+
+  return {
+    onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
+      if (!keyboard.visible) return;
+      if (event.pointerType === "mouse" && event.button !== 0) return;
+      if (
+        event.target instanceof Element &&
+        event.target.closest('button, input, textarea, select, a, [role="button"], [contenteditable="true"]')
+      ) {
+        return;
+      }
+
+      keyboard.setDragging(true);
+      dragRef.current = {
+        pointerId: event.pointerId,
+        startY: event.clientY,
+        lastY: event.clientY,
+        lastTime: performance.now(),
+        velocityY: 0,
+      };
+      event.currentTarget.setPointerCapture(event.pointerId);
+    },
+    onPointerMove: (event: ReactPointerEvent<HTMLElement>) => {
+      const drag = dragRef.current;
+      if (drag.pointerId !== event.pointerId) return;
+
+      const now = performance.now();
+      const elapsed = Math.max(1, now - drag.lastTime);
+      drag.velocityY = (event.clientY - drag.lastY) / elapsed;
+      drag.lastY = event.clientY;
+      drag.lastTime = now;
+      keyboard.setDragOffset(Math.max(0, event.clientY - drag.startY));
+    },
+    onPointerUp: endDismissDrag,
+    onPointerCancel: endDismissDrag,
+  };
+}
+
+export function KeyboardInput(props: KeyboardInputProps) {
+  const keyboard = useKeyboard();
+  const { ref, ...inputProps } = props;
+
+  return (
+    <input
+      {...inputProps}
+      ref={ref}
+      onFocus={(event) => {
+        keyboard.show(event.currentTarget);
+        inputProps.onFocus?.(event);
+      }}
+    />
+  );
+}
+
+export function KeyboardTextarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
+  const keyboard = useKeyboard();
+
+  return (
+    <textarea
+      {...props}
+      onFocus={(event) => {
+        keyboard.show(event.currentTarget);
+        props.onFocus?.(event);
+      }}
+    />
+  );
+}
+
+export function KeyboardDock() {
+  const keyboard = useKeyboard();
+  const { device } = useMobileDevice();
+  const dismissDrag = useKeyboardDismissDrag();
+  const keyboardTransition = keyboard.isDragging
+    ? { duration: 0 }
+    : { duration: 0.26, ease: [0.2, 0.8, 0.2, 1] as [number, number, number, number] };
+
+  return (
+    <motion.div
+      className="keyboard-dock"
+      data-platform={device.platform}
+      data-testid="keyboard-dock"
+      data-visible={keyboard.visible ? "true" : "false"}
+      initial={{ y: keyboard.fullHeight }}
+      animate={{ y: keyboard.visible ? keyboard.dragOffset : keyboard.fullHeight }}
+      aria-hidden={keyboard.visible ? undefined : "true"}
+      style={{ height: keyboard.fullHeight }}
+      transition={keyboardTransition}
+      {...dismissDrag}
+    >
+      <img
+        className="keyboard-asset"
+        src={device.platform === "android" ? mobileAssets.androidKeyboard : mobileAssets.iphoneKeyboard}
+        alt=""
+        aria-hidden="true"
+        draggable={false}
+      />
+    </motion.div>
+  );
+}

+ 69 - 0
prototype/adjutant-mobile/src/mobile/MobileCursor.tsx

@@ -0,0 +1,69 @@
+import { useState, type PointerEvent as ReactPointerEvent } from "react";
+
+type CursorState = {
+  visible: boolean;
+  active: boolean;
+  x: number;
+  y: number;
+};
+
+export function useMobileCursor() {
+  const debug =
+    typeof window !== "undefined" && new URLSearchParams(window.location.search).has("cursorDebug");
+  const [cursor, setCursor] = useState<CursorState>({
+    visible: false,
+    active: false,
+    x: 0,
+    y: 0,
+  });
+
+  const updatePosition = (event: ReactPointerEvent<HTMLElement>) => {
+    const bounds = event.currentTarget.getBoundingClientRect();
+    const localX = bounds.width === 0 ? 0 : ((event.clientX - bounds.left) / bounds.width) * event.currentTarget.offsetWidth;
+    const localY =
+      bounds.height === 0 ? 0 : ((event.clientY - bounds.top) / bounds.height) * event.currentTarget.offsetHeight;
+
+    setCursor((current) => ({
+      ...current,
+      visible: true,
+      x: localX,
+      y: localY,
+    }));
+  };
+
+  return {
+    cursorHandlers: {
+      onPointerEnter: updatePosition,
+      onPointerMove: updatePosition,
+      onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
+        updatePosition(event);
+        setCursor((current) => ({ ...current, active: true }));
+      },
+      onPointerUp: (event: ReactPointerEvent<HTMLElement>) => {
+        updatePosition(event);
+        setCursor((current) => ({ ...current, active: false }));
+      },
+      onPointerCancel: () => {
+        setCursor((current) => ({ ...current, active: false, visible: false }));
+      },
+      onPointerLeave: () => {
+        setCursor((current) => ({ ...current, active: false, visible: false }));
+      },
+    },
+    cursorDebug: debug,
+    cursorElement: (
+      <div
+        className="mobile-cursor"
+        data-active={cursor.active ? "true" : "false"}
+        data-debug={debug ? "true" : "false"}
+        data-visible={cursor.visible ? "true" : "false"}
+        data-testid="mobile-cursor"
+        style={{
+          transform: `translate3d(${cursor.x}px, ${cursor.y}px, 0) translate(-50%, -50%)`,
+        }}
+      >
+        {debug ? <span className="mobile-cursor-hotspot" aria-hidden="true" /> : null}
+      </div>
+    ),
+  };
+}

+ 50 - 0
prototype/adjutant-mobile/src/mobile/MobileRuntime.tsx

@@ -0,0 +1,50 @@
+import { useEffect, type PropsWithChildren } from "react";
+import { MobileDeviceProvider, useMobileDevice } from "./Device";
+import { KeyboardDock, KeyboardProvider, useKeyboard } from "./Keyboard";
+import { PhoneFrame } from "./PhoneFrame";
+import { HomeIndicator, StatusBar } from "./components";
+
+export function MobileRuntime({ children }: PropsWithChildren) {
+  return (
+    <MobileDeviceProvider>
+      <PhoneFrame>
+        <KeyboardProvider>
+          <KeyboardPreview />
+          <StatusBar />
+          <MobileAppViewport>{children}</MobileAppViewport>
+          <HomeIndicator />
+          <KeyboardDock />
+        </KeyboardProvider>
+      </PhoneFrame>
+    </MobileDeviceProvider>
+  );
+}
+
+function MobileAppViewport({ children }: PropsWithChildren) {
+  const { device } = useMobileDevice();
+  const keyboard = useKeyboard();
+
+  return (
+    <div
+      className="mobile-app-viewport"
+      data-keyboard-visible={keyboard.visible ? "true" : "false"}
+      data-platform={device.platform}
+      data-testid="mobile-app-viewport"
+    >
+      {children}
+    </div>
+  );
+}
+
+function KeyboardPreview() {
+  const keyboard = useKeyboard();
+
+  useEffect(() => {
+    const params = new URLSearchParams(window.location.search);
+    if (params.get("keyboard") === "1") {
+      keyboard.show();
+    }
+  }, [keyboard]);
+
+  return null;
+}

+ 466 - 0
prototype/adjutant-mobile/src/mobile/MobileScroll.tsx

@@ -0,0 +1,466 @@
+import {
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+  type CSSProperties,
+  type PropsWithChildren,
+} from "react";
+import { useKeyboardInsets } from "./Keyboard";
+
+type MobileScrollProps = PropsWithChildren<{
+  className?: string;
+}>;
+
+const scrollPhysics = {
+  momentumFriction: 2.1,
+  momentumVelocityScale: 890,
+  momentumTolerance: 18,
+  bounceTension: 200,
+  bounceFriction: 40,
+  bounceTolerance: 0.5,
+  overdragScale: 0.5,
+  maxOverdrag: 96,
+  velocitySampleWindow: 100,
+  tapSlop: 8,
+} as const;
+
+function shouldIgnoreScrollDrag(target: EventTarget | null) {
+  return (
+    target instanceof Element &&
+    Boolean(target.closest('[data-scroll-drag="ignore"]'))
+  );
+}
+
+type DragSample = {
+  y: number;
+  time: number;
+};
+
+type DragSession = {
+  active: boolean;
+  captured: boolean;
+  pointerId: number | null;
+  startY: number;
+  startScrollTop: number;
+  hasDragged: boolean;
+};
+
+export function MobileScroll({ className, children }: MobileScrollProps) {
+  const { isKeyboardVisible, keyboardHeight, keyboardDragging } = useKeyboardInsets();
+  const scrollRef = useRef<HTMLDivElement | null>(null);
+  const hideTimerRef = useRef<number | null>(null);
+  const inertiaFrameRef = useRef<number | null>(null);
+  const lastInertiaTimeRef = useRef<number | null>(null);
+  const overscrollRef = useRef(0);
+  const dragSamplesRef = useRef<DragSample[]>([]);
+  const isDraggingRef = useRef(false);
+  const suppressNextClickRef = useRef(false);
+  const suppressClickTimerRef = useRef<number | null>(null);
+  const dragSessionRef = useRef<DragSession>({
+    active: false,
+    captured: false,
+    pointerId: null,
+    startY: 0,
+    startScrollTop: 0,
+    hasDragged: false,
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const [overscrollY, setOverscrollY] = useState(0);
+  const [thumb, setThumb] = useState({
+    visible: false,
+    top: 0,
+    height: 0,
+    enabled: false,
+  });
+
+  const stopInertia = useCallback(() => {
+    if (inertiaFrameRef.current !== null) {
+      window.cancelAnimationFrame(inertiaFrameRef.current);
+      inertiaFrameRef.current = null;
+    }
+
+    lastInertiaTimeRef.current = null;
+  }, []);
+
+  const suppressUpcomingClick = useCallback(() => {
+    suppressNextClickRef.current = true;
+
+    if (suppressClickTimerRef.current !== null) {
+      window.clearTimeout(suppressClickTimerRef.current);
+    }
+
+    suppressClickTimerRef.current = window.setTimeout(() => {
+      suppressNextClickRef.current = false;
+      suppressClickTimerRef.current = null;
+    }, 180);
+  }, []);
+
+  const setRubberBand = useCallback((value: number) => {
+    const clamped = Math.max(-scrollPhysics.maxOverdrag, Math.min(scrollPhysics.maxOverdrag, value));
+    overscrollRef.current = clamped;
+    setOverscrollY(clamped);
+  }, []);
+
+  const maxScrollTop = useCallback((scroll: HTMLDivElement) => {
+    return Math.max(0, scroll.scrollHeight - scroll.clientHeight);
+  }, []);
+
+  const rubberBand = useCallback((distance: number) => {
+    return distance * scrollPhysics.overdragScale;
+  }, []);
+
+  const pushDragSample = useCallback((y: number) => {
+    const time = performance.now();
+    const samples = [...dragSamplesRef.current, { y, time }].filter(
+      (sample) => time - sample.time <= scrollPhysics.velocitySampleWindow,
+    );
+
+    dragSamplesRef.current = samples;
+  }, []);
+
+  const releaseVelocity = useCallback(() => {
+    const samples = dragSamplesRef.current;
+    if (samples.length < 2) return 0;
+
+    const first = samples[0];
+    const last = samples[samples.length - 1];
+    const elapsed = Math.max(1, last.time - first.time);
+    const pointerVelocity = (last.y - first.y) / elapsed;
+
+    return -pointerVelocity * scrollPhysics.momentumVelocityScale;
+  }, []);
+
+  const springBack = useCallback((initialVelocity = 0) => {
+    stopInertia();
+
+    let position = overscrollRef.current;
+    let velocity = Math.max(-1400, Math.min(1400, initialVelocity));
+    let lastTime: number | null = null;
+
+    const step = (time: number) => {
+      const deltaTime = Math.min(0.034, ((lastTime === null ? time : time - lastTime) || 16) / 1000);
+      lastTime = time;
+      const acceleration = -scrollPhysics.bounceTension * position - scrollPhysics.bounceFriction * velocity;
+      velocity += acceleration * deltaTime;
+      position += velocity * deltaTime;
+
+      if (Math.abs(position) < scrollPhysics.bounceTolerance && Math.abs(velocity) < scrollPhysics.bounceTolerance) {
+        setRubberBand(0);
+        inertiaFrameRef.current = null;
+        return;
+      }
+
+      setRubberBand(position);
+      inertiaFrameRef.current = window.requestAnimationFrame(step);
+    };
+
+    inertiaFrameRef.current = window.requestAnimationFrame(step);
+  }, [setRubberBand, stopInertia]);
+
+  const updateThumb = useCallback((visible = true) => {
+    const scroll = scrollRef.current;
+    if (!scroll) return;
+
+    const { clientHeight, scrollHeight, scrollTop } = scroll;
+    const enabled = scrollHeight > clientHeight + 2;
+    const height = enabled ? Math.max(36, (clientHeight / scrollHeight) * clientHeight) : 0;
+    const maxThumbTop = Math.max(0, clientHeight - height - 8);
+    const maxScrollTop = Math.max(1, scrollHeight - clientHeight);
+    const top = enabled ? 4 + (scrollTop / maxScrollTop) * maxThumbTop : 0;
+
+    setThumb({ visible: visible && enabled, top, height, enabled });
+
+    if (hideTimerRef.current !== null) {
+      window.clearTimeout(hideTimerRef.current);
+    }
+
+    if (visible && enabled && !isDraggingRef.current) {
+      hideTimerRef.current = window.setTimeout(() => {
+        setThumb((current) => ({ ...current, visible: false }));
+      }, 650);
+    }
+  }, []);
+
+  const applyDragPosition = useCallback((startScrollTop: number, movementY: number, scroll: HTMLDivElement) => {
+    const desiredScrollTop = startScrollTop - movementY;
+    const maxTop = maxScrollTop(scroll);
+
+    if (desiredScrollTop < 0) {
+      scroll.scrollTop = 0;
+      setRubberBand(rubberBand(-desiredScrollTop));
+    } else if (desiredScrollTop > maxTop) {
+      scroll.scrollTop = maxTop;
+      setRubberBand(-rubberBand(desiredScrollTop - maxTop));
+    } else {
+      scroll.scrollTop = desiredScrollTop;
+      setRubberBand(0);
+    }
+
+    updateThumb(true);
+  }, [maxScrollTop, rubberBand, setRubberBand, updateThumb]);
+
+  useEffect(() => {
+    updateThumb(false);
+
+    const scroll = scrollRef.current;
+    if (!scroll) return;
+
+    const handleScroll = () => updateThumb(true);
+    const resizeObserver = new ResizeObserver(() => updateThumb(false));
+
+    scroll.addEventListener("scroll", handleScroll, { passive: true });
+    resizeObserver.observe(scroll);
+
+    if (scroll.firstElementChild) {
+      resizeObserver.observe(scroll.firstElementChild);
+    }
+
+    return () => {
+      scroll.removeEventListener("scroll", handleScroll);
+      resizeObserver.disconnect();
+
+      if (hideTimerRef.current !== null) {
+        window.clearTimeout(hideTimerRef.current);
+      }
+
+      if (suppressClickTimerRef.current !== null) {
+        window.clearTimeout(suppressClickTimerRef.current);
+      }
+
+      stopInertia();
+    };
+  }, [stopInertia, updateThumb]);
+
+  useEffect(() => {
+    updateThumb(false);
+  }, [keyboardHeight, updateThumb]);
+
+  const startMomentum = useCallback((scroll: HTMLDivElement, initialVelocity: number) => {
+    let velocity = initialVelocity;
+
+    const step = (time: number) => {
+      const deltaTime =
+        lastInertiaTimeRef.current === null ? 16 : Math.min(34, time - lastInertiaTimeRef.current);
+      const deltaSeconds = deltaTime / 1000;
+      lastInertiaTimeRef.current = time;
+      velocity *= Math.exp(-scrollPhysics.momentumFriction * deltaSeconds);
+
+      if (Math.abs(velocity) < scrollPhysics.momentumTolerance) {
+        updateThumb(true);
+        lastInertiaTimeRef.current = null;
+        inertiaFrameRef.current = null;
+        return;
+      }
+
+      const maxTop = maxScrollTop(scroll);
+      const attempted = scroll.scrollTop + velocity * deltaSeconds;
+      const before = scroll.scrollTop;
+
+      if (attempted < 0) {
+        scroll.scrollTop = 0;
+        setRubberBand(rubberBand(-attempted));
+        springBack(velocity * scrollPhysics.overdragScale);
+        return;
+      }
+
+      if (attempted > maxTop) {
+        scroll.scrollTop = maxTop;
+        setRubberBand(-rubberBand(attempted - maxTop));
+        springBack(velocity * scrollPhysics.overdragScale);
+        return;
+      }
+
+      scroll.scrollTop = attempted;
+      updateThumb(true);
+
+      if (scroll.scrollTop === before) {
+        lastInertiaTimeRef.current = null;
+        inertiaFrameRef.current = null;
+        return;
+      }
+
+      inertiaFrameRef.current = window.requestAnimationFrame(step);
+    };
+
+    if (Math.abs(velocity) > scrollPhysics.momentumTolerance) {
+      lastInertiaTimeRef.current = null;
+      inertiaFrameRef.current = window.requestAnimationFrame(step);
+    } else {
+      updateThumb(true);
+    }
+  }, [maxScrollTop, rubberBand, setRubberBand, springBack, updateThumb]);
+
+  const endDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
+    const scroll = scrollRef.current;
+    const session = dragSessionRef.current;
+
+    if (!scroll || !session.active || session.pointerId !== event.pointerId) return;
+
+    // A nested horizontal scroller can let pointer-down bubble, then claim
+    // pointer-move. Its pointer-up may still reach this parent. Without a
+    // completed parent drag there is no valid release velocity or click to
+    // suppress, so discard the pending session without starting motion.
+    if (!session.hasDragged) {
+      dragSamplesRef.current = [];
+      dragSessionRef.current = {
+        active: false,
+        captured: false,
+        pointerId: null,
+        startY: 0,
+        startScrollTop: 0,
+        hasDragged: false,
+      };
+      setIsDragging(false);
+      isDraggingRef.current = false;
+      return;
+    }
+
+    if (session.captured) {
+      try {
+        event.currentTarget.releasePointerCapture(event.pointerId);
+      } catch {
+        // Pointer capture can already be gone after a browser-level cancel.
+      }
+    }
+
+    if (session.hasDragged) {
+      event.preventDefault();
+      suppressUpcomingClick();
+    }
+
+    pushDragSample(event.clientY);
+    const velocity = releaseVelocity();
+    dragSessionRef.current = {
+      active: false,
+      captured: false,
+      pointerId: null,
+      startY: 0,
+      startScrollTop: 0,
+      hasDragged: false,
+    };
+    setIsDragging(false);
+    isDraggingRef.current = false;
+
+    if (Math.abs(overscrollRef.current) > 0.1) {
+      springBack(velocity * scrollPhysics.overdragScale);
+      return;
+    }
+
+    startMomentum(scroll, velocity);
+  }, [pushDragSample, releaseVelocity, springBack, startMomentum, suppressUpcomingClick]);
+
+  const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
+    const scroll = scrollRef.current;
+    if (!scroll || scroll.scrollHeight <= scroll.clientHeight) return;
+    if (event.pointerType === "mouse" && event.button !== 0) return;
+
+    stopInertia();
+    if (shouldIgnoreScrollDrag(event.target)) {
+      setRubberBand(0);
+      return;
+    }
+
+    isDraggingRef.current = false;
+    dragSamplesRef.current = [];
+    pushDragSample(event.clientY);
+    setRubberBand(0);
+    setThumb((current) => ({ ...current, visible: current.enabled }));
+
+    dragSessionRef.current = {
+      active: true,
+      captured: false,
+      pointerId: event.pointerId,
+      startY: event.clientY,
+      startScrollTop: scroll.scrollTop,
+      hasDragged: false,
+    };
+  }, [pushDragSample, setRubberBand, stopInertia]);
+
+  const handlePointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
+    const scroll = scrollRef.current;
+    const session = dragSessionRef.current;
+
+    if (!scroll || !session.active || session.pointerId !== event.pointerId) return;
+
+    const movementY = event.clientY - session.startY;
+    if (!session.hasDragged && Math.abs(movementY) < scrollPhysics.tapSlop) return;
+
+    if (!session.captured) {
+      try {
+        event.currentTarget.setPointerCapture(event.pointerId);
+        session.captured = true;
+      } catch {
+        // Pointer capture can fail if the browser has already canceled the pointer.
+      }
+    }
+
+    event.preventDefault();
+    session.hasDragged = true;
+    suppressUpcomingClick();
+    isDraggingRef.current = true;
+    setIsDragging(true);
+    pushDragSample(event.clientY);
+    applyDragPosition(session.startScrollTop, movementY, scroll);
+  }, [applyDragPosition, pushDragSample, suppressUpcomingClick]);
+
+  const suppressClickAfterDrag = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
+    if (!suppressNextClickRef.current) return;
+
+    suppressNextClickRef.current = false;
+    if (suppressClickTimerRef.current !== null) {
+      window.clearTimeout(suppressClickTimerRef.current);
+      suppressClickTimerRef.current = null;
+    }
+    event.preventDefault();
+    event.stopPropagation();
+  }, []);
+
+  const style = {
+    "--keyboard-height": `${keyboardHeight}px`,
+  } as CSSProperties;
+
+  return (
+    <section
+      className={`mobile-page ${className ?? ""}`}
+      data-keyboard-dragging={keyboardDragging ? "true" : "false"}
+      data-keyboard-visible={isKeyboardVisible ? "true" : "false"}
+      style={style}
+    >
+      <div
+        ref={scrollRef}
+        className="mobile-scroll"
+        data-testid="mobile-scroll"
+        data-dragging={isDragging ? "true" : "false"}
+        data-overscroll={overscrollY.toFixed(2)}
+        onPointerDown={handlePointerDown}
+        onPointerMove={handlePointerMove}
+        onPointerUp={endDrag}
+        onPointerCancel={endDrag}
+        onClickCapture={suppressClickAfterDrag}
+      >
+        <div
+          className="mobile-scroll-content"
+          data-testid="mobile-scroll-content"
+          style={{ transform: `translateY(${overscrollY}px)` }}
+        >
+          {children}
+        </div>
+      </div>
+      <div
+        className="mobile-scrollbar"
+        data-testid="mobile-scrollbar"
+        data-visible={thumb.visible ? "true" : "false"}
+        aria-hidden="true"
+      >
+        <div
+          className="mobile-scrollbar-thumb"
+          style={{
+            height: thumb.height,
+            transform: `translateY(${thumb.top}px)`,
+          }}
+        />
+      </div>
+    </section>
+  );
+}

+ 144 - 0
prototype/adjutant-mobile/src/mobile/PhoneFrame.tsx

@@ -0,0 +1,144 @@
+import {
+  createContext,
+  type CSSProperties,
+  type DragEvent,
+  type PropsWithChildren,
+  type RefObject,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { DevicePicker, useMobileDevice } from "./Device";
+import { useMobileCursor } from "./MobileCursor";
+
+type ScreenPortalContextValue = {
+  screenRef: RefObject<HTMLDivElement | null>;
+};
+
+const ScreenPortalContext = createContext<ScreenPortalContextValue | null>(null);
+
+function suppressNativeDrag(event: DragEvent<HTMLElement>) {
+  if (event.target instanceof Element && event.target.closest('[data-native-drag="true"]')) {
+    return;
+  }
+
+  event.preventDefault();
+}
+
+export function useScreenPortal() {
+  const context = useContext(ScreenPortalContext);
+
+  if (!context) {
+    throw new Error("useScreenPortal must be used inside PhoneFrame");
+  }
+
+  return context;
+}
+
+function getDeviceScale(deviceWidth: number, deviceHeight: number) {
+  if (typeof window === "undefined") return 1;
+
+  const horizontal = (window.innerWidth - 48) / deviceWidth;
+  const vertical = (window.innerHeight - 48) / deviceHeight;
+
+  return Math.max(0.42, Math.min(horizontal, vertical, 1));
+}
+
+function useDeviceScale(deviceWidth: number, deviceHeight: number) {
+  const [scale, setScale] = useState(() => getDeviceScale(deviceWidth, deviceHeight));
+
+  useEffect(() => {
+    const update = () => setScale(getDeviceScale(deviceWidth, deviceHeight));
+
+    update();
+    window.addEventListener("resize", update);
+
+    return () => window.removeEventListener("resize", update);
+  }, [deviceHeight, deviceWidth]);
+
+  return scale;
+}
+
+export function PhoneFrame({ children }: PropsWithChildren) {
+  const { device } = useMobileDevice();
+  const { geometry } = device;
+  const scale = useDeviceScale(geometry.device.width, geometry.device.height);
+  const screenRef = useRef<HTMLDivElement | null>(null);
+  const contextValue = useMemo(() => ({ screenRef }), []);
+  const mobileCursor = useMobileCursor();
+
+  return (
+    <ScreenPortalContext.Provider value={contextValue}>
+      <div className="phone-stage">
+        <DevicePicker />
+        <div
+          className="phone-scale-box"
+          style={{
+            width: geometry.device.width * scale,
+            height: geometry.device.height * scale,
+          }}
+        >
+          <div
+            className="phone-device"
+            data-device={device.id}
+            data-platform={device.platform}
+            data-testid="phone-frame"
+            onDragStartCapture={suppressNativeDrag}
+            style={{
+              width: geometry.device.width,
+              height: geometry.device.height,
+              transform: `scale(${scale})`,
+            }}
+          >
+            <img
+              className="phone-bezel"
+              src={device.bezel}
+              alt=""
+              aria-hidden="true"
+              draggable={false}
+              style={{ zIndex: device.bezelLayer === "above-screen" ? 2 : 1 }}
+            />
+            <div
+              ref={screenRef}
+              className="device-screen"
+              data-cursor-debug={mobileCursor.cursorDebug ? "true" : "false"}
+              data-device={device.id}
+              data-phone-screen
+              data-testid="device-screen"
+              {...mobileCursor.cursorHandlers}
+              style={
+                {
+                  "--device-safe-area-bottom": `${geometry.safeArea.bottom}px`,
+                  left: geometry.screen.x,
+                  top: geometry.screen.y,
+                  width: geometry.screen.width,
+                  height: geometry.screen.height,
+                  borderRadius: geometry.screen.radius,
+                  zIndex: device.bezelLayer === "above-screen" ? 1 : 2,
+                } as CSSProperties
+              }
+            >
+              {children}
+              {device.camera ? (
+                <span
+                  className="device-camera"
+                  data-testid="device-camera"
+                  aria-hidden="true"
+                  style={{
+                    width: device.camera.size,
+                    height: device.camera.size,
+                    top: device.camera.top,
+                    left: `calc(50% - ${device.camera.size / 2}px)`,
+                  }}
+                />
+              ) : null}
+              {mobileCursor.cursorElement}
+            </div>
+          </div>
+        </div>
+      </div>
+    </ScreenPortalContext.Provider>
+  );
+}

+ 6 - 0
prototype/adjutant-mobile/src/mobile/assets.ts

@@ -0,0 +1,6 @@
+export const mobileAssets = {
+  iphoneBezel: "/assets/iphone/Bezel.png",
+  iphoneKeyboard: "/assets/iphone/Keyboard.png",
+  androidKeyboard: "/assets/android/Keyboard.png",
+  pixel10Bezel: "/assets/android/Pixel10.png",
+} as const;

+ 109 - 0
prototype/adjutant-mobile/src/mobile/components.tsx

@@ -0,0 +1,109 @@
+import { useEffect, useState } from "react";
+import { KeyboardInput, useKeyboard } from "./Keyboard";
+import { useMobileDevice } from "./Device";
+
+export function StatusBar() {
+  const [now, setNow] = useState(() => new Date());
+  const { device } = useMobileDevice();
+
+  useEffect(() => {
+    const syncToMinute = window.setTimeout(() => {
+      setNow(new Date());
+    }, (60 - now.getSeconds()) * 1000 - now.getMilliseconds());
+    const interval = window.setInterval(() => setNow(new Date()), 60_000);
+
+    return () => {
+      window.clearTimeout(syncToMinute);
+      window.clearInterval(interval);
+    };
+  }, [now]);
+
+  return (
+    <div className="status-bar" aria-label="Device status bar">
+      <span className="status-time" data-testid="status-time">
+        {formatStatusTime(now)}
+      </span>
+      <div className="status-indicators" aria-hidden="true">
+        <StatusIndicators platform={device.platform} />
+      </div>
+    </div>
+  );
+}
+
+export function HomeIndicator() {
+  const { device } = useMobileDevice();
+  const keyboard = useKeyboard();
+
+  if (device.platform === "android") {
+    if (keyboard.visible) return null;
+
+    return (
+      <img
+        className="android-navigation-bar"
+        data-testid="android-navigation-bar"
+        src="/assets/android/navigation-bar.svg"
+        alt=""
+        aria-hidden="true"
+        draggable={false}
+      />
+    );
+  }
+
+  return (
+    <svg
+      className="home-indicator-svg"
+      data-testid="home-indicator"
+      width="393"
+      height="34"
+      viewBox="0 0 393 34"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <rect x="129.5" y="21" width="134" height="5" rx="2.5" fill="black" />
+    </svg>
+  );
+}
+
+export function MobileTextField({
+  id,
+  label,
+  placeholder,
+  testId,
+}: {
+  id: string;
+  label: string;
+  placeholder?: string;
+  testId?: string;
+}) {
+  return (
+    <label className="mobile-field" htmlFor={id}>
+      <span className="field-label">{label}</span>
+      <KeyboardInput id={id} data-testid={testId} placeholder={placeholder} />
+    </label>
+  );
+}
+
+function formatStatusTime(date: Date) {
+  const hours = date.getHours();
+  const minutes = String(date.getMinutes()).padStart(2, "0");
+
+  return `${hours % 12 || 12}:${minutes}`;
+}
+
+function StatusIndicators({ platform }: { platform: "ios" | "android" }) {
+  return (
+    <img
+      className="status-indicator-svg"
+      data-testid="status-indicators"
+      data-platform={platform}
+      src={
+        platform === "android"
+          ? "/assets/status/status-icons.svg"
+          : "/assets/status/ios-status-icons.svg"
+      }
+      alt=""
+      aria-hidden="true"
+      draggable={false}
+    />
+  );
+}

+ 68 - 0
prototype/adjutant-mobile/src/mobile/geometry.ts

@@ -0,0 +1,68 @@
+export type MobileDeviceGeometry = {
+  device: {
+    width: number;
+    height: number;
+  };
+  screen: {
+    x: number;
+    y: number;
+    width: number;
+    height: number;
+    radius: number;
+  };
+  safeArea: {
+    top: number;
+    bottom: number;
+  };
+  keyboard: {
+    height: number;
+  };
+};
+
+export const iphoneGeometry = {
+  // CSS-space coordinates for the 3x iPhone assets. Keep source PNG dimensions
+  // divisible by 3 so the rendered phone frame lands on whole CSS pixels.
+  device: {
+    width: 511,
+    height: 968,
+  },
+  screen: {
+    x: 59,
+    y: 58,
+    width: 393,
+    height: 852,
+    radius: 42,
+  },
+  safeArea: {
+    top: 54,
+    bottom: 34,
+  },
+  keyboard: {
+    height: 338,
+  },
+} as const satisfies MobileDeviceGeometry;
+
+export const pixelGeometry = {
+  // Pixel10.png is a 2x asset. Its 854 x 1904 screen opening renders at
+  // exactly 427 x 952 CSS pixels inside the 566 x 1022 asset canvas.
+  device: {
+    width: 566,
+    height: 1022,
+  },
+  screen: {
+    x: 70,
+    y: 35,
+    width: 427,
+    height: 952,
+    radius: 58,
+  },
+  safeArea: {
+    top: 64,
+    bottom: 48,
+  },
+  keyboard: {
+    height: 316,
+  },
+} as const satisfies MobileDeviceGeometry;
+
+export type IPhoneGeometry = typeof iphoneGeometry;

+ 18 - 0
prototype/adjutant-mobile/src/mobile/index.ts

@@ -0,0 +1,18 @@
+export { BottomSheet } from "./BottomSheet";
+export { Carousel, type CarouselProps } from "./Carousel";
+export { DevicePicker, mobileDevices, useMobileDevice, type MobileDeviceId } from "./Device";
+export { FlowStack, useFlow, type FlowControls, type FlowScreen } from "./FlowStack";
+export {
+  KeyboardDock,
+  KeyboardInput,
+  KeyboardProvider,
+  KeyboardTextarea,
+  useKeyboard,
+  useKeyboardInsets,
+} from "./Keyboard";
+export { MobileScroll } from "./MobileScroll";
+export { MobileRuntime } from "./MobileRuntime";
+export { PhoneFrame, useScreenPortal } from "./PhoneFrame";
+export { HomeIndicator, MobileTextField, StatusBar } from "./components";
+export { mobileAssets } from "./assets";
+export { iphoneGeometry, pixelGeometry, type IPhoneGeometry, type MobileDeviceGeometry } from "./geometry";

+ 1974 - 0
prototype/adjutant-mobile/src/prototype.css

@@ -0,0 +1,1974 @@
+:root {
+  --brand: #2b1f99;
+  --brand-deep: #21147d;
+  --brand-soft: #f1f0ff;
+  --cyan: #2fc9d2;
+  --cyan-soft: #e9fbfc;
+  --bg: #f7fbff;
+  --surface: #ffffff;
+  --text: #16133a;
+  --muted: #69708a;
+  --border: #dfe7f7;
+  --danger: #d94a4a;
+  --danger-soft: #fff2f2;
+  --warning: #f0a020;
+  --success: #15996b;
+}
+
+button,
+a,
+textarea {
+  font: inherit;
+}
+
+button {
+  color: inherit;
+}
+
+.flow-stack,
+.flow-scenes,
+.flow-screen {
+  background: var(--bg);
+}
+
+.portal-scroll,
+.task-scroll {
+  background: var(--bg);
+}
+
+.portal-page {
+  min-height: 100%;
+  padding: 62px 18px calc(96px + var(--mobile-safe-area-height));
+  color: var(--text);
+}
+
+.brand-header,
+.brand-lockup,
+.patient-chip,
+.assistant-entry,
+.todo-strip,
+.registration-copy,
+.title-line,
+.section-heading,
+.record-row,
+.demo-note,
+.task-header,
+.notice-card,
+.choice-stack button,
+.department-list button,
+.doctor-card,
+.result-ticket,
+.report-overview,
+.risk-card,
+.metric-row,
+.capture-page,
+.patient-profile-card,
+.grouped-list button,
+.record-list button {
+  display: flex;
+  align-items: center;
+}
+
+.brand-header {
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.brand-lockup {
+  gap: 10px;
+  min-width: 0;
+}
+
+.brand-mark {
+  display: grid;
+  width: 50px;
+  height: 50px;
+  flex: 0 0 50px;
+  place-items: center;
+  border-radius: 15px;
+  background: var(--brand);
+  color: white;
+  box-shadow: 0 8px 20px rgba(43, 31, 153, 0.13);
+}
+
+.brand-mark svg {
+  width: 29px;
+  height: 29px;
+  stroke-width: 3;
+}
+
+.brand-lockup img {
+  width: 50px;
+  height: 50px;
+  border-radius: 15px;
+  box-shadow: 0 8px 20px rgba(43, 31, 153, 0.13);
+}
+
+.brand-lockup h1,
+.brand-lockup p,
+.welcome-block h2,
+.welcome-block p,
+.service-section h3,
+.service-section p,
+.section-heading h3,
+.record-row strong,
+.record-row small,
+.root-list-page header h1,
+.root-list-page header p,
+.task-page h2,
+.task-page p,
+.task-page h3 {
+  margin: 0;
+}
+
+.brand-lockup h1 {
+  font-size: 20px;
+  line-height: 1.15;
+}
+
+.brand-lockup p {
+  margin-top: 4px;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.patient-chip {
+  min-height: 44px;
+  padding: 0 13px;
+  gap: 7px;
+  border: 0;
+  border-radius: 999px;
+  background: var(--brand-soft);
+  color: var(--brand);
+  font-weight: 700;
+}
+
+.patient-chip svg {
+  width: 18px;
+  height: 18px;
+}
+
+.welcome-block {
+  margin-top: 31px;
+}
+
+.welcome-block h2 {
+  font-size: 28px;
+  letter-spacing: -0.035em;
+}
+
+.welcome-block p {
+  margin-top: 8px;
+  color: var(--muted);
+  font-size: 14px;
+}
+
+.assistant-entry {
+  width: 100%;
+  min-height: 70px;
+  margin-top: 20px;
+  padding: 0 17px;
+  justify-content: space-between;
+  gap: 12px;
+  border: 1px solid #cfd8f2;
+  border-radius: 22px;
+  background: var(--surface);
+  color: #7a8098;
+  text-align: left;
+  box-shadow: 0 10px 30px rgba(38, 41, 90, 0.035);
+}
+
+.assistant-entry > span:first-child {
+  max-width: 230px;
+  font-size: 15px;
+  line-height: 1.4;
+}
+
+.assistant-entry-icons {
+  display: flex;
+  gap: 13px;
+}
+
+.assistant-entry-icons svg {
+  width: 22px;
+  height: 22px;
+  color: var(--brand);
+}
+
+.assistant-entry-icons svg:last-child {
+  color: var(--cyan);
+}
+
+.todo-strip {
+  width: 100%;
+  min-height: 72px;
+  margin-top: 14px;
+  padding: 10px 12px;
+  gap: 11px;
+  border: 1px solid var(--border);
+  border-radius: 16px;
+  background: var(--surface);
+  text-align: left;
+  box-shadow: 0 8px 24px rgba(38, 41, 90, 0.035);
+}
+
+.todo-label {
+  display: flex;
+  align-items: center;
+  align-self: stretch;
+  gap: 5px;
+  padding-right: 11px;
+  border-right: 1px solid var(--border);
+  color: var(--brand);
+  font-size: 13px;
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.todo-copy {
+  min-width: 0;
+  flex: 1;
+}
+
+.todo-copy strong,
+.todo-copy small {
+  display: block;
+}
+
+.todo-copy strong {
+  font-size: 14px;
+}
+
+.todo-copy small {
+  margin-top: 4px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.todo-action {
+  padding: 10px 14px;
+  border-radius: 999px;
+  background: var(--brand);
+  color: white;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.service-section {
+  margin-top: 16px;
+}
+
+.registration-hero {
+  padding: 16px;
+  border: 1px solid #cfd8f2;
+  border-radius: 20px;
+  background: #f7f8ff;
+}
+
+.registration-copy {
+  align-items: flex-start;
+  gap: 12px;
+}
+
+.registration-copy img {
+  width: 92px;
+  height: 92px;
+  flex: 0 0 92px;
+  border-radius: 50%;
+  object-fit: cover;
+}
+
+.title-line {
+  gap: 7px;
+  flex-wrap: wrap;
+}
+
+.title-line h3 {
+  font-size: 20px;
+}
+
+.title-line span {
+  padding: 4px 7px;
+  border-radius: 999px;
+  background: var(--cyan-soft);
+  color: #13aeb8;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.registration-copy p {
+  margin-top: 8px;
+  color: var(--muted);
+  font-size: 13px;
+  line-height: 1.55;
+}
+
+.primary-button {
+  display: flex;
+  min-height: 50px;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  border: 0;
+  border-radius: 16px;
+  background: var(--brand);
+  color: white;
+  font-weight: 750;
+  box-shadow: 0 9px 22px rgba(43, 31, 153, 0.18);
+}
+
+.registration-hero .primary-button {
+  width: 100%;
+  margin-top: 13px;
+}
+
+.service-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 10px;
+  margin-top: 10px;
+}
+
+.service-card {
+  position: relative;
+  min-height: 164px;
+  padding: 14px;
+  border: 1px solid var(--border);
+  border-radius: 17px;
+  background: var(--surface);
+  text-align: left;
+}
+
+.service-icon {
+  display: grid;
+  width: 42px;
+  height: 42px;
+  place-items: center;
+  border-radius: 13px;
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.service-icon.tongue {
+  background: #fff2f1;
+  color: #d45b61;
+}
+
+.service-icon svg {
+  width: 22px;
+  height: 22px;
+}
+
+.service-card strong {
+  display: block;
+  margin-top: 15px;
+  font-size: 15px;
+}
+
+.service-card p {
+  margin-top: 6px;
+  padding-right: 12px;
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.55;
+}
+
+.card-arrow {
+  position: absolute;
+  right: 12px;
+  bottom: 14px;
+  color: var(--brand);
+}
+
+.recent-section {
+  margin-top: 21px;
+}
+
+.section-heading {
+  justify-content: space-between;
+}
+
+.section-heading h3 {
+  font-size: 17px;
+}
+
+.section-heading button {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  border: 0;
+  background: transparent;
+  color: var(--brand);
+  font-size: 13px;
+}
+
+.record-row {
+  width: 100%;
+  margin-top: 11px;
+  padding: 12px;
+  gap: 10px;
+  border: 1px solid var(--border);
+  border-radius: 15px;
+  background: var(--surface);
+  text-align: left;
+}
+
+.record-icon {
+  display: grid;
+  width: 40px;
+  height: 40px;
+  flex: 0 0 40px;
+  place-items: center;
+  border-radius: 50%;
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.record-row > span:nth-child(2) {
+  min-width: 0;
+  flex: 1;
+}
+
+.record-row strong,
+.record-row small {
+  display: block;
+}
+
+.record-row small {
+  margin-top: 3px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.record-row em {
+  padding: 5px 8px;
+  border-radius: 999px;
+  background: #eafaf3;
+  color: var(--success);
+  font-size: 10px;
+  font-style: normal;
+  white-space: nowrap;
+}
+
+.record-row > svg {
+  color: #9097ad;
+}
+
+.demo-note {
+  justify-content: center;
+  gap: 6px;
+  margin: 14px 0 8px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.bottom-nav {
+  display: grid;
+  height: 66px;
+  grid-template-columns: repeat(4, 1fr);
+  padding: 5px 10px 7px;
+  border-top: 1px solid var(--border);
+  background: rgba(255, 255, 255, 0.96);
+  backdrop-filter: blur(18px);
+}
+
+.bottom-nav button {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+  border: 0;
+  background: transparent;
+  color: #596077;
+  font-size: 10px;
+}
+
+.bottom-nav button.active {
+  color: var(--brand);
+  font-weight: 750;
+}
+
+.bottom-nav svg {
+  width: 21px;
+  height: 21px;
+}
+
+.task-header {
+  height: 52px;
+  justify-content: space-between;
+  padding: 0 12px;
+  border-bottom: 1px solid var(--border);
+  background: rgba(255, 255, 255, 0.96);
+  color: var(--text);
+}
+
+.task-header strong {
+  font-size: 16px;
+}
+
+.task-header > span {
+  width: 42px;
+  color: var(--muted);
+  font-size: 11px;
+  text-align: right;
+}
+
+.icon-button {
+  display: grid;
+  width: 42px;
+  height: 42px;
+  place-items: center;
+  border: 0;
+  background: transparent;
+}
+
+.task-page {
+  min-height: 100%;
+  padding: 22px 18px calc(38px + var(--mobile-safe-area-height));
+  color: var(--text);
+}
+
+.task-page.has-action-footer,
+.has-action-footer .task-page {
+  padding-bottom: calc(110px + var(--mobile-safe-area-height));
+}
+
+.task-intro,
+.question-block {
+  text-align: center;
+}
+
+.task-icon,
+.assistant-orb,
+.emergency-icon,
+.result-icon,
+.processing-icon {
+  display: grid;
+  width: 68px;
+  height: 68px;
+  margin: 0 auto;
+  place-items: center;
+  border-radius: 22px;
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.task-icon.cyan,
+.result-icon.cyan {
+  background: var(--cyan-soft);
+  color: #12aeb7;
+}
+
+.task-icon svg,
+.assistant-orb svg,
+.emergency-icon svg,
+.result-icon svg,
+.processing-icon svg {
+  width: 31px;
+  height: 31px;
+}
+
+.assistant-orb.is-active {
+  background: var(--cyan-soft);
+  color: #109da6;
+}
+
+.task-intro h2,
+.question-block h2 {
+  margin-top: 16px;
+  font-size: 23px;
+  line-height: 1.25;
+}
+
+.task-intro p,
+.question-block p {
+  margin-top: 9px;
+  color: var(--muted);
+  font-size: 14px;
+  line-height: 1.6;
+}
+
+.choice-stack,
+.department-list {
+  display: grid;
+  gap: 12px;
+  margin-top: 26px;
+}
+
+.choice-stack button,
+.department-list button {
+  width: 100%;
+  min-height: 88px;
+  gap: 12px;
+  padding: 15px;
+  border: 1px solid var(--border);
+  border-radius: 17px;
+  background: var(--surface);
+  text-align: left;
+}
+
+.choice-stack button > span:first-child {
+  display: grid;
+  width: 44px;
+  height: 44px;
+  flex: 0 0 44px;
+  place-items: center;
+  border-radius: 14px;
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.choice-stack button > div,
+.department-list button > div {
+  min-width: 0;
+  flex: 1;
+}
+
+.choice-stack strong,
+.department-list strong {
+  font-size: 15px;
+}
+
+.choice-stack p,
+.department-list p {
+  margin-top: 5px;
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.fixed-action {
+  min-height: 82px;
+  padding: 10px 14px calc(10px + var(--mobile-safe-area-height));
+  border-top: 1px solid var(--border);
+  background: rgba(255, 255, 255, 0.98);
+}
+
+.fixed-action > span {
+  display: block;
+  margin-bottom: 7px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.fixed-action .primary-button {
+  width: 100%;
+}
+
+.assistant-welcome {
+  text-align: center;
+}
+
+.assistant-welcome h2 {
+  margin-top: 14px;
+  font-size: 23px;
+}
+
+.assistant-welcome p {
+  margin-top: 8px;
+  color: var(--muted);
+  font-size: 14px;
+  line-height: 1.6;
+}
+
+.assistant-composer {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  margin-top: 22px;
+  overflow: hidden;
+  border: 1px solid #cfd8f2;
+  border-radius: 18px;
+  background: var(--surface);
+}
+
+.assistant-composer textarea {
+  min-height: 120px;
+  grid-column: 1 / -1;
+  padding: 16px;
+  resize: none;
+  border: 0;
+  outline: 0;
+  background: transparent;
+  color: var(--text);
+}
+
+.assistant-composer > span {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  padding: 0 14px 13px;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.assistant-composer button {
+  min-width: 72px;
+  margin: 0 12px 10px 0;
+  border: 0;
+  border-radius: 12px;
+  background: var(--brand);
+  color: white;
+}
+
+.assistant-composer.compact textarea {
+  min-height: 78px;
+}
+
+.voice-primary {
+  display: grid;
+  width: 190px;
+  height: 190px;
+  margin: 26px auto 0;
+  padding: 28px;
+  place-items: center;
+  align-content: center;
+  gap: 8px;
+  border: 10px solid var(--brand-soft);
+  border-radius: 50%;
+  background: var(--brand);
+  color: white;
+  box-shadow: 0 15px 32px rgba(43, 31, 153, 0.2);
+}
+
+.voice-primary svg {
+  width: 40px;
+  height: 40px;
+}
+
+.voice-primary strong {
+  font-size: 20px;
+}
+
+.voice-primary span {
+  max-width: 120px;
+  font-size: 11px;
+  line-height: 1.45;
+  opacity: 0.76;
+}
+
+.voice-example {
+  margin: 16px auto 0 !important;
+  max-width: 310px;
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.55;
+  text-align: center;
+}
+
+.risk-demo-link {
+  display: flex;
+  width: 100%;
+  min-height: 42px;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
+  margin-top: 10px;
+  border: 1px solid #f1c9c9;
+  border-radius: 13px;
+  background: var(--danger-soft);
+  color: #a23b3b;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.slot-dialogue {
+  display: grid;
+  gap: 12px;
+  margin-top: 22px;
+}
+
+.voice-transcript,
+.assistant-reply {
+  padding: 15px 16px;
+  border-radius: 18px;
+}
+
+.voice-transcript {
+  margin-left: 34px;
+  background: var(--brand);
+  color: white;
+}
+
+.voice-transcript span {
+  font-size: 11px;
+  opacity: 0.72;
+}
+
+.voice-transcript p,
+.assistant-reply p {
+  margin: 5px 0 0;
+  font-size: 14px;
+  line-height: 1.55;
+}
+
+.assistant-reply {
+  margin-right: 22px;
+  border: 1px solid var(--border);
+  background: var(--surface);
+}
+
+.assistant-reply strong {
+  font-size: 15px;
+}
+
+.assistant-reply p {
+  color: var(--muted);
+}
+
+.voice-followup {
+  display: flex;
+  min-height: 70px;
+  padding: 12px 15px;
+  align-items: center;
+  gap: 12px;
+  border: 1px solid #cfd8f2;
+  border-radius: 17px;
+  background: var(--brand-soft);
+  color: var(--brand);
+  text-align: left;
+}
+
+.voice-followup > svg {
+  width: 27px;
+  height: 27px;
+}
+
+.voice-followup span,
+.voice-followup strong,
+.voice-followup small {
+  display: block;
+}
+
+.voice-followup small {
+  margin-top: 3px;
+  color: var(--muted);
+}
+
+.doctor-quick-replies {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.doctor-quick-replies button {
+  min-height: 40px;
+  padding: 0 12px;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  background: var(--surface);
+  font-size: 12px;
+}
+
+.slot-summary {
+  margin: 0;
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: 17px;
+  background: var(--surface);
+}
+
+.slot-summary > div {
+  display: flex;
+  min-height: 47px;
+  padding: 0 14px;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid var(--border);
+}
+
+.slot-summary > div:last-child {
+  border-bottom: 0;
+}
+
+.slot-summary dt {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.slot-summary dd {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.slot-summary em {
+  margin-left: 5px;
+  color: var(--success);
+  font-size: 10px;
+  font-style: normal;
+}
+
+.risk-check-line {
+  display: flex;
+  padding: 12px 14px;
+  align-items: center;
+  gap: 8px;
+  border-radius: 14px;
+  background: #edf9f5;
+  color: #158268;
+  font-size: 12px;
+}
+
+.risk-check-line svg {
+  flex: 0 0 auto;
+}
+
+.minor-heading {
+  margin: 24px 0 10px !important;
+  font-size: 16px;
+}
+
+.quick-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.quick-actions button,
+.filter-row button {
+  min-height: 42px;
+  padding: 0 13px;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  background: var(--surface);
+  color: var(--text);
+  font-size: 12px;
+}
+
+.step-kicker {
+  display: inline-block;
+  margin-bottom: 9px;
+  color: var(--brand);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.clinical-textarea {
+  width: 100%;
+  min-height: 116px;
+  margin-top: 21px;
+  padding: 15px;
+  resize: none;
+  border: 1px solid #cfd8f2;
+  border-radius: 16px;
+  outline: none;
+  background: var(--surface);
+  color: var(--text);
+  line-height: 1.6;
+}
+
+.sample-label {
+  margin: 20px 0 9px;
+  color: var(--muted);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.scenario-list,
+.radio-list {
+  display: grid;
+  gap: 8px;
+}
+
+.scenario-list button,
+.radio-list button {
+  display: flex;
+  min-height: 48px;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 14px;
+  border: 1px solid var(--border);
+  border-radius: 13px;
+  background: var(--surface);
+  text-align: left;
+}
+
+.scenario-list button.selected,
+.radio-list button.selected {
+  border-color: var(--brand);
+  background: var(--brand-soft);
+  color: var(--brand);
+  font-weight: 700;
+}
+
+.notice-card {
+  gap: 11px;
+  margin-top: 17px;
+  padding: 13px;
+  align-items: flex-start;
+  border: 1px solid #f2d38b;
+  border-radius: 14px;
+  background: #fff9ec;
+  color: #70551b;
+}
+
+.notice-card.neutral {
+  border-color: var(--border);
+  background: #f4f8ff;
+  color: var(--text);
+}
+
+.notice-card svg,
+.safety-copy svg {
+  width: 18px;
+  height: 18px;
+  flex: 0 0 18px;
+}
+
+.notice-card strong {
+  font-size: 13px;
+}
+
+.notice-card p {
+  margin-top: 3px;
+  color: inherit;
+  font-size: 12px;
+  line-height: 1.55;
+}
+
+.inline-action {
+  width: 100%;
+  margin-top: 20px;
+}
+
+.danger-surface {
+  background: var(--danger-soft);
+}
+
+.emergency-page {
+  padding-top: 42px;
+  text-align: center;
+}
+
+.emergency-icon {
+  background: #ffe0e0;
+  color: var(--danger);
+}
+
+.emergency-page h2 {
+  margin-top: 19px;
+  color: #a32929;
+  font-size: 23px;
+}
+
+.emergency-page > p {
+  margin-top: 12px;
+  color: #6c3b3b;
+  line-height: 1.7;
+}
+
+.emergency-actions {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 10px;
+  margin-top: 27px;
+}
+
+.emergency-actions button,
+.emergency-actions a {
+  display: flex;
+  min-height: 50px;
+  align-items: center;
+  justify-content: center;
+  border: 0;
+  border-radius: 14px;
+  background: var(--danger);
+  color: white;
+  font-weight: 700;
+  text-decoration: none;
+}
+
+.emergency-actions button {
+  background: white;
+  color: var(--danger);
+  border: 1px solid #efb1b1;
+}
+
+.text-button,
+.secondary-button {
+  min-height: 44px;
+  border: 0;
+  background: transparent;
+  color: var(--brand);
+  font-weight: 700;
+}
+
+.emergency-page .text-button {
+  margin-top: 18px;
+  color: #a32929;
+}
+
+.emergency-page > small {
+  display: block;
+  margin-top: 18px;
+  color: #956767;
+}
+
+.department-list button > span {
+  align-self: flex-start;
+  padding: 4px 7px;
+  border-radius: 999px;
+  background: #eef1f7;
+  color: var(--muted);
+  font-size: 10px;
+}
+
+.department-list button.recommended {
+  border-color: #bdb7f1;
+  background: #f8f7ff;
+}
+
+.department-list button.recommended > span {
+  background: var(--cyan-soft);
+  color: #12aeb7;
+}
+
+.safety-copy {
+  gap: 7px;
+  margin-top: 17px;
+  color: #866727;
+  font-size: 11px;
+  line-height: 1.5;
+}
+
+.date-row {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 8px;
+}
+
+.date-row button {
+  min-height: 56px;
+  border: 1px solid var(--border);
+  border-radius: 13px;
+  background: var(--surface);
+  font-weight: 700;
+}
+
+.date-row button small {
+  color: var(--muted);
+  font-weight: 500;
+}
+
+.date-row button.selected {
+  border-color: var(--brand);
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.doctor-card {
+  position: relative;
+  margin-top: 13px;
+  padding: 14px;
+  align-items: flex-start;
+  gap: 11px;
+  flex-wrap: wrap;
+  border: 1px solid var(--border);
+  border-radius: 17px;
+  background: var(--surface);
+}
+
+.doctor-avatar {
+  display: grid;
+  width: 46px;
+  height: 46px;
+  place-items: center;
+  border-radius: 50%;
+  background: var(--brand-soft);
+  color: var(--brand);
+  font-weight: 800;
+}
+
+.doctor-avatar.alt {
+  background: var(--cyan-soft);
+  color: #119ba4;
+}
+
+.doctor-card > div:nth-child(2) {
+  min-width: 0;
+  flex: 1;
+}
+
+.doctor-card h3 {
+  font-size: 15px;
+}
+
+.doctor-card h3 small {
+  color: var(--muted);
+  font-size: 11px;
+  font-weight: 500;
+}
+
+.doctor-card p {
+  margin-top: 5px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.doctor-card em {
+  color: var(--success);
+  font-size: 10px;
+  font-style: normal;
+}
+
+.slot-row {
+  display: flex;
+  width: 100%;
+  gap: 7px;
+  padding-top: 10px;
+  border-top: 1px solid var(--border);
+}
+
+.slot-row button {
+  min-width: 64px;
+  min-height: 38px;
+  border: 1px solid var(--border);
+  border-radius: 10px;
+  background: white;
+  color: var(--text);
+}
+
+.slot-row button.selected {
+  border-color: var(--brand);
+  background: var(--brand);
+  color: white;
+}
+
+.summary-card,
+.result-ticket,
+.metric-section,
+.interpretation-card,
+.action-card,
+.pattern-card,
+.feature-grid,
+.report-source-card,
+.report-confirm-list {
+  border: 1px solid var(--border);
+  border-radius: 17px;
+  background: var(--surface);
+}
+
+.summary-card {
+  padding: 19px;
+  text-align: center;
+}
+
+.summary-icon {
+  display: grid;
+  width: 56px;
+  height: 56px;
+  margin: 0 auto;
+  place-items: center;
+  border-radius: 18px;
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.summary-card h2 {
+  margin-top: 12px;
+  font-size: 20px;
+}
+
+.summary-card dl {
+  margin: 20px 0 0;
+  border-top: 1px solid var(--border);
+}
+
+.summary-card dl div {
+  display: flex;
+  justify-content: space-between;
+  padding: 12px 0;
+  border-bottom: 1px solid var(--border);
+}
+
+.summary-card dt {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.summary-card dd {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.result-page {
+  padding-top: 34px;
+  text-align: center;
+}
+
+.result-icon.success {
+  background: #e8faf2;
+  color: var(--success);
+}
+
+.result-page > h2 {
+  margin-top: 15px;
+  font-size: 24px;
+}
+
+.result-page > p {
+  margin-top: 7px;
+  color: var(--muted);
+}
+
+.result-ticket {
+  display: grid;
+  gap: 7px;
+  margin-top: 21px;
+  padding: 17px;
+  text-align: left;
+}
+
+.result-ticket span {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.upload-zone {
+  display: flex;
+  width: 100%;
+  min-height: 154px;
+  margin-top: 21px;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 9px;
+  border: 1.5px dashed #aeb8da;
+  border-radius: 18px;
+  background: rgba(255,255,255,.7);
+  color: var(--brand);
+}
+
+.upload-zone svg {
+  width: 30px;
+  height: 30px;
+}
+
+.upload-zone span {
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.report-source-card {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-top: 20px;
+  padding: 14px;
+}
+
+.report-source-card > div {
+  min-width: 0;
+  flex: 1;
+}
+
+.report-source-card strong,
+.report-source-card small {
+  display: block;
+}
+
+.report-source-card small {
+  margin-top: 5px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.report-source-card em {
+  padding: 5px 8px;
+  border-radius: 999px;
+  background: #eafaf3;
+  color: var(--success);
+  font-size: 10px;
+  font-style: normal;
+  font-weight: 700;
+}
+
+.report-confirm-list {
+  margin: 12px 0 0;
+  padding: 4px 15px;
+}
+
+.report-confirm-list div {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 12px 0;
+  border-bottom: 1px solid var(--border);
+}
+
+.report-confirm-list div:last-child {
+  border-bottom: 0;
+}
+
+.report-confirm-list dt {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.report-confirm-list dd {
+  margin: 0;
+  text-align: right;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.mock-report-list {
+  display: grid;
+  gap: 8px;
+}
+
+.mock-report-list button {
+  display: flex;
+  min-height: 65px;
+  align-items: center;
+  gap: 11px;
+  padding: 11px 13px;
+  border: 1px solid var(--border);
+  border-radius: 14px;
+  background: var(--surface);
+  text-align: left;
+}
+
+.mock-report-list button > svg:first-child {
+  color: var(--brand);
+}
+
+.mock-report-list button span {
+  flex: 1;
+}
+
+.mock-report-list strong,
+.mock-report-list small {
+  display: block;
+}
+
+.mock-report-list small {
+  margin-top: 4px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.processing-page {
+  padding-top: 56px;
+  text-align: center;
+}
+
+.processing-icon {
+  animation: gentle-pulse 1.4s ease-in-out infinite;
+}
+
+.processing-icon.cyan {
+  background: var(--cyan-soft);
+  color: #11a5ae;
+}
+
+@keyframes gentle-pulse {
+  50% { transform: scale(1.05); opacity: .8; }
+}
+
+.processing-page h2 {
+  margin-top: 18px;
+}
+
+.processing-page > p {
+  margin-top: 9px;
+  color: var(--muted);
+  line-height: 1.65;
+}
+
+.processing-steps {
+  display: grid;
+  gap: 11px;
+  margin-top: 25px;
+  padding: 15px;
+  border: 1px solid var(--border);
+  border-radius: 15px;
+  background: var(--surface);
+  text-align: left;
+}
+
+.processing-steps span {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: var(--muted);
+  font-size: 13px;
+}
+
+.processing-steps span.done {
+  color: var(--success);
+}
+
+.report-overview {
+  gap: 12px;
+  padding: 15px;
+  border-radius: 17px;
+  background: var(--brand);
+  color: white;
+  text-align: left;
+}
+
+.report-overview > span {
+  display: grid;
+  width: 48px;
+  height: 48px;
+  place-items: center;
+  border-radius: 15px;
+  background: rgba(255,255,255,.16);
+}
+
+.report-overview h2 {
+  font-size: 18px;
+}
+
+.report-overview p {
+  margin-top: 4px;
+  color: #d8d5ff;
+  font-size: 12px;
+}
+
+.risk-card {
+  gap: 10px;
+  margin-top: 12px;
+  padding: 13px;
+  align-items: flex-start;
+  border-radius: 14px;
+  text-align: left;
+}
+
+.risk-card.safe {
+  border: 1px solid #bce9d8;
+  background: #effbf6;
+  color: #176d50;
+}
+
+.risk-card p {
+  margin-top: 4px;
+  color: inherit;
+  font-size: 11px;
+}
+
+.metric-section,
+.interpretation-card,
+.action-card {
+  margin-top: 12px;
+  padding: 15px;
+  text-align: left;
+}
+
+.metric-section h3,
+.interpretation-card h3,
+.action-card h3 {
+  margin-bottom: 10px;
+  font-size: 15px;
+}
+
+.metric-row {
+  justify-content: space-between;
+  padding: 10px 0;
+  border-top: 1px solid var(--border);
+}
+
+.metric-row strong,
+.metric-row small {
+  display: block;
+}
+
+.metric-row strong {
+  font-size: 12px;
+}
+
+.metric-row small {
+  margin-top: 3px;
+  color: var(--muted);
+  font-size: 10px;
+}
+
+.metric-row em {
+  color: var(--danger);
+  font-size: 16px;
+  font-style: normal;
+  font-weight: 800;
+}
+
+.metric-row em.high {
+  color: #b36b00;
+}
+
+.interpretation-card p,
+.action-card p {
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.7;
+}
+
+.action-card {
+  border-color: #c5e5ef;
+  background: #f1fbfe;
+}
+
+.rule-note {
+  display: block;
+  margin: 17px 0 0;
+  color: var(--muted);
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.step-list {
+  display: grid;
+  gap: 12px;
+  margin: 24px 0 0;
+  padding: 0;
+  list-style: none;
+}
+
+.step-list li {
+  display: flex;
+  gap: 12px;
+  padding: 12px;
+  border-bottom: 1px solid var(--border);
+  text-align: left;
+}
+
+.step-list li > span {
+  display: grid;
+  width: 32px;
+  height: 32px;
+  flex: 0 0 32px;
+  place-items: center;
+  border-radius: 50%;
+  background: var(--brand-soft);
+  color: var(--brand);
+  font-weight: 800;
+}
+
+.step-list strong {
+  font-size: 14px;
+}
+
+.step-list p {
+  margin-top: 3px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.radio-list {
+  margin-top: 22px;
+}
+
+.radio-list button {
+  justify-content: flex-start;
+  gap: 10px;
+}
+
+.radio-list button > span {
+  display: grid;
+  width: 22px;
+  height: 22px;
+  place-items: center;
+  border: 1px solid var(--border);
+  border-radius: 50%;
+}
+
+.radio-list button.selected > span {
+  border-color: var(--brand);
+}
+
+.camera-guide {
+  display: grid;
+  min-height: 222px;
+  margin-top: 19px;
+  place-items: center;
+  align-content: center;
+  gap: 13px;
+  border: 2px dashed #b9c2df;
+  border-radius: 50% 50% 42% 42%;
+  background: #f5f8ff;
+  color: var(--muted);
+}
+
+.camera-guide svg {
+  width: 48px;
+  height: 48px;
+  color: var(--brand);
+}
+
+.camera-guide.failed {
+  border-color: #e4a2a2;
+  background: var(--danger-soft);
+  color: var(--danger);
+}
+
+.camera-guide.failed svg {
+  color: var(--danger);
+}
+
+.capture-tips {
+  margin: 18px 0 0;
+  padding-left: 20px;
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.9;
+}
+
+.secondary-button {
+  width: 100%;
+  margin-top: 4px;
+}
+
+.pattern-card {
+  display: grid;
+  gap: 6px;
+  margin-top: 20px;
+  padding: 17px;
+  text-align: left;
+}
+
+.pattern-card span,
+.pattern-card small {
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.pattern-card strong {
+  color: var(--brand);
+  font-size: 24px;
+}
+
+.feature-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 1px;
+  margin-top: 12px;
+  overflow: hidden;
+  background: var(--border);
+}
+
+.feature-grid div {
+  padding: 13px;
+  background: white;
+  text-align: left;
+}
+
+.feature-grid span,
+.feature-grid strong {
+  display: block;
+}
+
+.feature-grid span {
+  color: var(--muted);
+  font-size: 10px;
+}
+
+.feature-grid strong {
+  margin-top: 5px;
+  font-size: 13px;
+}
+
+.root-list-page header {
+  margin-bottom: 23px;
+}
+
+.root-list-page header h1 {
+  font-size: 28px;
+}
+
+.root-list-page header p {
+  margin-top: 7px;
+  color: var(--muted);
+  font-size: 13px;
+}
+
+.grouped-list,
+.record-list {
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: 18px;
+  background: var(--surface);
+}
+
+.grouped-list button,
+.record-list button {
+  width: 100%;
+  min-height: 78px;
+  gap: 12px;
+  padding: 13px;
+  border: 0;
+  border-bottom: 1px solid var(--border);
+  background: transparent;
+  text-align: left;
+}
+
+.grouped-list button:last-child,
+.record-list button:last-child {
+  border-bottom: 0;
+}
+
+.list-icon {
+  display: grid;
+  width: 43px;
+  height: 43px;
+  flex: 0 0 43px;
+  place-items: center;
+  border-radius: 14px;
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.grouped-list button > span:nth-child(2),
+.record-list button > span:nth-child(2) {
+  min-width: 0;
+  flex: 1;
+}
+
+.grouped-list strong,
+.grouped-list small,
+.record-list strong,
+.record-list small,
+.record-list p {
+  display: block;
+}
+
+.grouped-list small,
+.record-list small {
+  margin-top: 4px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.plain-info {
+  display: flex;
+  gap: 11px;
+  margin-top: 18px;
+  padding: 15px;
+  color: var(--muted);
+}
+
+.plain-info p {
+  margin-top: 5px;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.filter-row {
+  display: flex;
+  gap: 7px;
+  margin-bottom: 13px;
+}
+
+.filter-row button.selected {
+  border-color: var(--brand);
+  background: var(--brand);
+  color: white;
+}
+
+.record-list {
+  background: transparent;
+  border: 0;
+}
+
+.record-list button {
+  margin-bottom: 10px;
+  border: 1px solid var(--border);
+  border-radius: 16px;
+  background: var(--surface);
+}
+
+.record-list p {
+  margin: 5px 0 0;
+  color: var(--text);
+  font-size: 12px;
+}
+
+.record-list em {
+  color: var(--brand);
+  font-size: 12px;
+  font-style: normal;
+  font-weight: 700;
+}
+
+.record-icon.report {
+  color: #276fe2;
+}
+
+.record-icon.tongue {
+  background: var(--cyan-soft);
+  color: #11a5ae;
+}
+
+.patient-profile-card {
+  width: 100%;
+  gap: 13px;
+  padding: 17px;
+  border: 1px solid var(--border);
+  border-radius: 18px;
+  background: var(--surface);
+  text-align: left;
+}
+
+.avatar {
+  display: grid;
+  width: 50px;
+  height: 50px;
+  place-items: center;
+  border-radius: 50%;
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.avatar.large {
+  width: 82px;
+  height: 82px;
+}
+
+.patient-profile-card > span:nth-child(2) {
+  flex: 1;
+}
+
+.patient-profile-card strong,
+.patient-profile-card small {
+  display: block;
+}
+
+.patient-profile-card small {
+  margin-top: 5px;
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.grouped-list.compact {
+  margin-top: 15px;
+}
+
+.patient-detail {
+  text-align: center;
+}
+
+.patient-detail h2 {
+  margin-top: 13px;
+}
+
+.patient-detail p,
+.patient-detail small {
+  display: block;
+  margin-top: 5px;
+  color: var(--muted);
+}
+
+@media (max-width: 390px) {
+  .portal-page {
+    padding-inline: 15px;
+  }
+  .welcome-block h2 {
+    font-size: 26px;
+  }
+  .registration-copy img {
+    width: 82px;
+    height: 82px;
+    flex-basis: 82px;
+  }
+}

+ 740 - 0
prototype/adjutant-mobile/src/styles.css

@@ -0,0 +1,740 @@
+:root {
+  color: #151515;
+  background: #ffffff;
+  font-family:
+    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+  width: 100%;
+  height: 100%;
+  margin: 0;
+}
+
+.mobile-carousel {
+  position: relative;
+  display: block;
+  width: 100%;
+  min-width: 0;
+  max-width: 100%;
+  overflow: auto;
+  overscroll-behavior: contain;
+  scrollbar-width: none;
+  user-select: none;
+}
+
+.mobile-carousel::-webkit-scrollbar { display: none; }
+.mobile-carousel { overflow-x: auto; overflow-y: hidden; touch-action: pan-y; }
+.mobile-carousel[data-dragging="true"] { cursor: none; }
+.mobile-carousel-content { display: flex; width: max-content; min-width: 100%; transform: translateX(var(--mobile-carousel-overdrag)); will-change: transform; }
+.mobile-carousel-scrollbar { position: absolute; z-index: 4; inset: 0; opacity: 0; pointer-events: none; transition: opacity 150ms ease; }
+.mobile-carousel-scrollbar[data-visible="true"] { opacity: 1; }
+.mobile-carousel-scrollbar-thumb { position: absolute; right: 4px; bottom: 4px; height: 3px; border-radius: 999px; background: rgba(0, 0, 0, 0.35); }
+
+body {
+  min-width: 320px;
+  overflow: hidden;
+}
+
+button,
+input,
+textarea {
+  font: inherit;
+}
+
+button {
+  cursor: pointer;
+}
+
+.phone-stage {
+  position: relative;
+  display: grid;
+  width: 100%;
+  height: 100%;
+  place-items: center;
+  padding: 24px;
+}
+
+.device-menu-bar {
+  position: absolute;
+  z-index: 100;
+  top: 18px;
+  right: 18px;
+  padding: 3px;
+  border-radius: 12px;
+}
+
+.device-picker-trigger {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+  height: 34px;
+  padding: 0 10px 0 12px;
+  border: 0;
+  border-radius: 9px;
+  background: transparent;
+  color: #151515;
+  font: 600 14px/1 ui-sans-serif, system-ui, sans-serif;
+}
+
+.device-picker-trigger:hover,
+.device-picker-trigger[data-state="open"] {
+  background: rgba(0, 0, 0, 0.055);
+}
+
+.device-picker-trigger:focus-visible {
+  outline: 2px solid rgba(0, 0, 0, 0.72);
+  outline-offset: 2px;
+}
+
+.device-picker-menu {
+  z-index: 200;
+  min-width: 168px;
+  padding: 3px;
+  border-radius: 12px;
+  background: rgba(255, 255, 255, 0.98);
+  box-shadow:
+    0 0 1px rgba(0, 0, 0, 0.06),
+    0 0 0 1px rgba(0, 0, 0, 0.08),
+    0 4px 4px rgba(0, 0, 0, 0.05);
+  transform-origin: var(--radix-dropdown-menu-content-transform-origin);
+  animation: device-menu-in 120ms ease-out;
+}
+
+.device-picker-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 34px;
+  padding: 0 9px 0 10px;
+  border-radius: 8px;
+  color: #181818;
+  font: 500 14px/1 ui-sans-serif, system-ui, sans-serif;
+  outline: none;
+  user-select: none;
+}
+
+.device-picker-item[data-highlighted] {
+  background: #f0f0f0;
+}
+
+.device-picker-item[data-state="checked"] {
+  font-weight: 600;
+}
+
+.device-picker-check {
+  display: grid;
+  place-items: center;
+  color: #181818;
+}
+
+@keyframes device-menu-in {
+  from {
+    opacity: 0;
+    transform: scale(0.98) translateY(-2px);
+  }
+
+  to {
+    opacity: 1;
+    transform: scale(1) translateY(0);
+  }
+}
+
+.phone-scale-box {
+  position: relative;
+}
+
+.phone-device {
+  position: absolute;
+  left: 0;
+  top: 0;
+  transform-origin: top left;
+  -webkit-user-select: none;
+  user-select: none;
+}
+
+.phone-bezel {
+  position: absolute;
+  inset: 0;
+  display: block;
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+  z-index: 2;
+  -webkit-user-drag: none;
+  pointer-events: none;
+}
+
+.device-screen {
+  position: absolute;
+  overflow: hidden;
+  border-radius: 42px;
+  background: #ffffff;
+  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
+  cursor: none;
+  z-index: 1;
+  -webkit-user-select: none;
+  user-select: none;
+}
+
+.device-camera {
+  position: absolute;
+  z-index: 75;
+  display: block;
+  border-radius: 50%;
+  background: #050505;
+  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+  pointer-events: none;
+}
+
+.device-screen,
+.device-screen * {
+  cursor: none !important;
+}
+
+.device-screen img,
+.keyboard-asset {
+  -webkit-user-drag: none;
+  user-select: none;
+}
+
+.device-screen input,
+.device-screen textarea,
+.device-screen select,
+.device-screen option,
+.device-screen [contenteditable="true"] {
+  -webkit-user-select: auto;
+  user-select: auto;
+}
+
+.device-screen:active,
+.device-screen:active * {
+  cursor: none !important;
+}
+
+.device-screen input,
+.device-screen textarea,
+.device-screen button,
+.device-screen a,
+.device-screen [role="button"] {
+  cursor: none !important;
+}
+
+.device-screen[data-cursor-debug="true"],
+.device-screen[data-cursor-debug="true"] * {
+  cursor: default !important;
+}
+
+.device-screen *:focus,
+.device-screen *:focus-visible {
+  outline: 0 !important;
+  box-shadow: none !important;
+}
+
+.flow-stack,
+.flow-scenes,
+.flow-screen {
+  position: absolute;
+  inset: 0;
+}
+
+.mobile-app-viewport {
+  position: absolute;
+  inset: 0;
+}
+
+.mobile-app-viewport[data-platform="android"][data-keyboard-visible="false"] {
+  bottom: var(--device-safe-area-bottom, 48px);
+}
+
+.flow-stack {
+  --flow-header-height: 0px;
+  --flow-header-content-height: 0px;
+  --flow-header-safe-area: 0px;
+  --flow-footer-height: 0px;
+  --keyboard-height: 0px;
+  overflow: hidden;
+  background: transparent;
+  touch-action: pan-y;
+}
+
+.flow-fixed-header {
+  position: absolute;
+  z-index: 12;
+  top: 0;
+  right: 0;
+  left: 0;
+  height: var(--flow-header-height);
+  padding-top: var(--flow-header-safe-area);
+  box-sizing: border-box;
+  background: transparent;
+  border-bottom: 0;
+  backdrop-filter: none;
+}
+
+.flow-fixed-footer {
+  position: absolute;
+  z-index: 12;
+  right: 0;
+  bottom: var(--keyboard-height);
+  left: 0;
+  height: var(--flow-footer-height);
+  background: transparent;
+  transition: bottom 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.flow-scenes {
+  top: var(--flow-header-height);
+  bottom: 0;
+  overflow: hidden;
+}
+
+.flow-screen {
+  overflow: hidden;
+  background: transparent;
+  will-change: transform;
+}
+
+.mobile-page {
+  position: absolute;
+  inset: 0;
+  --mobile-safe-area-height: var(--device-safe-area-bottom, 34px);
+  --keyboard-height: 0px;
+}
+
+.mobile-page[data-keyboard-visible="true"] {
+  --mobile-safe-area-height: 0px;
+}
+
+.mobile-app-viewport[data-platform="android"] .mobile-page {
+  --mobile-safe-area-height: 0px;
+}
+
+.mobile-scroll {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: var(--keyboard-height);
+  left: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+  -webkit-overflow-scrolling: touch;
+  overscroll-behavior: contain;
+  scrollbar-width: none;
+  touch-action: none;
+  user-select: none;
+  transition: bottom 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.mobile-scroll::-webkit-scrollbar {
+  display: none;
+}
+
+.mobile-scroll[data-dragging="true"] {
+  cursor: none;
+}
+
+.mobile-cursor {
+  position: absolute;
+  z-index: 80;
+  top: 0;
+  left: 0;
+  width: 48px;
+  height: 48px;
+  border: 0.5px solid rgba(0, 0, 0, 0.12);
+  border-radius: 999px;
+  background:
+    radial-gradient(circle at 50% 44%, rgba(255, 255, 255, 0.7) 0 34%, rgba(255, 255, 255, 0.54) 58%, rgba(238, 238, 238, 0.48) 100%);
+  box-shadow:
+    inset 0 1px 2px rgba(255, 255, 255, 0.6),
+    inset 0 -5px 10px rgba(0, 0, 0, 0.05),
+    inset 0 0 0 1px rgba(0, 0, 0, 0.025);
+  opacity: 0;
+  pointer-events: none;
+  transition:
+    opacity 90ms ease,
+    background 80ms ease,
+    box-shadow 80ms ease;
+  will-change: transform;
+}
+
+.mobile-cursor::before {
+  position: absolute;
+  top: 3px;
+  left: 50%;
+  width: 32px;
+  height: 17px;
+  transform: translateX(-50%);
+  border-radius: 999px;
+  background: linear-gradient(rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0));
+  content: "";
+  pointer-events: none;
+}
+
+.mobile-cursor::after {
+  position: absolute;
+  inset: 0;
+  border-radius: inherit;
+  box-shadow:
+    inset 1px 0 3px rgba(255, 255, 255, 0.28),
+    inset -1px 0 3px rgba(0, 0, 0, 0.02);
+  content: "";
+  pointer-events: none;
+}
+
+.mobile-cursor-hotspot {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  width: 9px;
+  height: 9px;
+  transform: translate(-50%, -50%);
+  border: 1px solid rgba(0, 122, 255, 0.95);
+  border-radius: 999px;
+  background: rgba(255, 255, 255, 0.82);
+  box-shadow:
+    0 0 0 1px rgba(255, 255, 255, 0.75),
+    0 0 0 3px rgba(0, 122, 255, 0.16);
+  pointer-events: none;
+}
+
+.mobile-cursor-hotspot::before,
+.mobile-cursor-hotspot::after {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  background: rgba(0, 122, 255, 0.95);
+  content: "";
+  transform: translate(-50%, -50%);
+}
+
+.mobile-cursor-hotspot::before {
+  width: 17px;
+  height: 1px;
+}
+
+.mobile-cursor-hotspot::after {
+  width: 1px;
+  height: 17px;
+}
+
+.mobile-cursor[data-visible="true"] {
+  opacity: 1;
+}
+
+.mobile-cursor[data-active="true"] {
+  background:
+    radial-gradient(circle at 50% 55%, rgba(232, 232, 232, 0.58) 0 30%, rgba(248, 248, 248, 0.5) 62%, rgba(255, 255, 255, 0.68) 100%);
+  box-shadow:
+    inset 0 3px 7px rgba(0, 0, 0, 0.08),
+    inset 0 -4px 8px rgba(255, 255, 255, 0.34),
+    inset 0 0 0 1px rgba(0, 0, 0, 0.03);
+}
+
+.mobile-cursor[data-active="true"]::before {
+  background: linear-gradient(rgba(0, 0, 0, 0.055), rgba(0, 0, 0, 0));
+}
+
+.mobile-cursor[data-active="true"]::after {
+  box-shadow:
+    inset 0 5px 9px rgba(0, 0, 0, 0.045),
+    inset 0 -3px 7px rgba(255, 255, 255, 0.28);
+}
+
+.mobile-scroll-content {
+  display: grid;
+  gap: 0;
+  grid-template-columns: minmax(0, 1fr);
+  min-width: 0;
+  padding: 0;
+  will-change: transform;
+}
+
+.app-screen {
+  background: #ffffff;
+}
+
+.mobile-scrollbar {
+  position: absolute;
+  z-index: 9;
+  top: 4px;
+  right: 5px;
+  bottom: calc(var(--mobile-safe-area-height) + var(--keyboard-height) + 4px);
+  width: 4px;
+  opacity: 0;
+  pointer-events: none;
+  transition:
+    bottom 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
+    opacity 180ms ease;
+}
+
+.mobile-page[data-keyboard-dragging="true"] .mobile-scroll,
+.mobile-page[data-keyboard-dragging="true"] .mobile-scrollbar,
+.flow-stack[data-keyboard-dragging="true"] .flow-fixed-footer {
+  transition: none;
+}
+
+.mobile-scrollbar[data-visible="true"] {
+  opacity: 1;
+}
+
+.mobile-scrollbar-thumb {
+  width: 4px;
+  min-height: 36px;
+  border-radius: 999px;
+  background: rgba(0, 0, 0, 0.28);
+  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
+  transition: background 120ms ease;
+}
+
+.status-bar {
+  position: absolute;
+  z-index: 72;
+  top: 0;
+  right: 0;
+  left: 0;
+  display: grid;
+  grid-template-columns: 1fr auto;
+  align-items: center;
+  height: 54px;
+  padding: 10px 34px 0 36px;
+  background: transparent;
+  color: #050505;
+  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
+  pointer-events: none;
+}
+
+.device-screen[data-device="pixel-10"] .status-bar {
+  align-items: start;
+  height: auto;
+  padding: 32px 32px 0;
+  font-family: "Roboto", Arial, sans-serif;
+}
+
+.status-time {
+  justify-self: start;
+  min-width: 58px;
+  text-align: center;
+  font-size: 15px;
+  font-weight: 600;
+  letter-spacing: 0;
+  line-height: 1;
+}
+
+.status-indicators {
+  display: flex;
+  align-items: center;
+  justify-self: end;
+}
+
+.status-indicator-svg {
+  display: block;
+}
+
+.status-indicator-svg[data-platform="ios"] {
+  width: 79px;
+  height: 13px;
+}
+
+.status-indicator-svg[data-platform="android"] {
+  width: 50px;
+  height: 14px;
+}
+
+.device-screen[data-device="pixel-10"] .status-time {
+  font-weight: 500;
+  transform: translateY(2px);
+}
+
+.home-indicator-svg {
+  position: absolute;
+  z-index: 70;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  display: block;
+  width: 100%;
+  height: var(--device-safe-area-bottom, 34px);
+  pointer-events: none;
+}
+
+.android-navigation-bar {
+  position: absolute;
+  z-index: 70;
+  bottom: -1px;
+  left: -1px;
+  display: block;
+  width: 428px;
+  height: var(--device-safe-area-bottom, 48px);
+  pointer-events: none;
+  -webkit-user-drag: none;
+}
+
+h1,
+h2,
+p {
+  margin: 0;
+}
+
+h1 {
+  font-size: 26px;
+  line-height: 1.08;
+  letter-spacing: 0;
+}
+
+h2 {
+  font-size: 15px;
+}
+
+p {
+  color: #555555;
+  font-size: 14px;
+  line-height: 1.45;
+}
+
+.field-label {
+  color: #555555;
+  font-size: 13px;
+  font-weight: 800;
+}
+
+.mobile-field {
+  display: grid;
+  gap: 8px;
+}
+
+.mobile-field input {
+  width: 100%;
+  min-height: 44px;
+  border: 1px solid rgba(0, 0, 0, 0.1);
+  border-radius: 8px;
+  background: #fff;
+  color: #151515;
+  padding: 0 12px;
+  outline: 0;
+}
+
+.mobile-field input:focus {
+  border-color: rgba(0, 0, 0, 0.16);
+  box-shadow: none;
+}
+
+.device-screen .mobile-field input:focus,
+.device-screen .mobile-field input:focus-visible {
+  border-color: rgba(0, 0, 0, 0.12);
+  outline: 0 !important;
+  box-shadow: none !important;
+}
+
+.sheet-overlay {
+  position: absolute;
+  z-index: 20;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.34);
+}
+
+.bottom-sheet {
+  position: absolute;
+  z-index: 21;
+  right: 0;
+  left: 0;
+  display: flex;
+  flex-direction: column;
+  min-height: 260px;
+  overflow: hidden;
+  border-radius: 22px 22px 0 0;
+  background: #fff;
+  box-shadow: 0 -16px 48px rgba(0, 0, 0, 0.2);
+  touch-action: none;
+}
+
+.sheet-handle-zone {
+  display: grid;
+  height: 30px;
+  place-items: center;
+  touch-action: none;
+}
+
+.sheet-handle {
+  width: 42px;
+  height: 5px;
+  border-radius: 999px;
+  background: #c9c9c4;
+}
+
+.sheet-header {
+  display: grid;
+  gap: 4px;
+  padding: 0 18px 14px;
+}
+
+.sheet-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 900;
+}
+
+.sheet-description {
+  margin: 0;
+  color: #737373;
+  font-size: 13px;
+  line-height: 1.35;
+}
+
+.sheet-content {
+  overflow: auto;
+  padding: 0 18px 24px;
+}
+
+.keyboard-dock {
+  position: absolute;
+  z-index: 30;
+  right: 0;
+  bottom: -1px;
+  left: 0;
+  height: 339px;
+  padding: 0;
+  overflow: hidden;
+  border-radius: 32px 32px 0 0;
+  background: #d8d9de;
+  box-shadow: none;
+  pointer-events: none;
+  will-change: transform;
+}
+
+.keyboard-dock[data-platform="android"] {
+  border-radius: 0;
+  background: #f3f2f6;
+  box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.08);
+}
+
+.keyboard-dock[data-visible="true"] {
+  pointer-events: auto;
+}
+
+.keyboard-asset {
+  position: relative;
+  z-index: 1;
+  display: block;
+  width: 100%;
+  height: 100%;
+  border-radius: inherit;
+  object-fit: cover;
+  object-position: center bottom;
+  -webkit-user-drag: none;
+  pointer-events: none;
+}
+
+.keyboard-dock[data-platform="android"] .keyboard-asset {
+  border-radius: 0;
+  object-fit: fill;
+}

+ 1 - 0
prototype/adjutant-mobile/src/vite-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 242 - 0
prototype/adjutant-mobile/tests/mobile-runtime.spec.ts

@@ -0,0 +1,242 @@
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+async function drag(page: Page, locator: Locator, deltaX: number, deltaY: number, steps = 8) {
+  const box = await locator.boundingBox();
+  if (!box) throw new Error("Drag target has no bounding box");
+  const startX = box.x + box.width / 2;
+  const startY = box.y + box.height / 2;
+
+  await page.mouse.move(startX, startY);
+  await page.mouse.down();
+  for (let step = 1; step <= steps; step += 1) {
+    await page.mouse.move(
+      startX + (deltaX * step) / steps,
+      startY + (deltaY * step) / steps,
+    );
+    await page.waitForTimeout(8);
+  }
+  await page.mouse.up();
+}
+
+test.beforeEach(async ({ page }) => {
+  await page.goto("/tests/runtime-fixture.html");
+});
+
+test("horizontal intent stays in Carousel and cannot create parent momentum", async ({ page }) => {
+  const carousel = page.locator(".fixture-carousel");
+  const card = page.locator(".carousel-card").nth(1);
+  const parent = page.getByTestId("mobile-scroll");
+
+  await expect(carousel).not.toHaveAttribute("data-scroll-drag", "ignore");
+  await drag(page, card, -130, 14, 5);
+
+  const afterRelease = await carousel.evaluate((element) => element.scrollLeft);
+  expect(afterRelease).toBeGreaterThan(40);
+  expect(await parent.evaluate((element) => element.scrollTop)).toBe(0);
+
+  await page.waitForTimeout(250);
+  expect(await parent.evaluate((element) => element.scrollTop)).toBe(0);
+  expect(await page.getByTestId("tap-count").textContent()).toBe("0");
+});
+
+test("vertical intent over a carousel is handed to MobileScroll in both directions", async ({ page }) => {
+  const card = page.locator(".carousel-card").nth(1);
+  const carousel = page.locator(".fixture-carousel");
+  const parent = page.getByTestId("mobile-scroll");
+
+  await drag(page, card, 4, -150);
+  expect(await parent.evaluate((element) => element.scrollTop)).toBeGreaterThan(60);
+  expect(await carousel.evaluate((element) => element.scrollLeft)).toBe(0);
+
+  await parent.evaluate((element) => {
+    element.scrollTop = 80;
+  });
+  await drag(page, card, -3, 110);
+  expect(await parent.evaluate((element) => element.scrollTop)).toBeLessThan(80);
+});
+
+test("tap activates a card but a completed drag does not", async ({ page }) => {
+  const firstCard = page.locator(".carousel-card").first();
+  await firstCard.click();
+  await expect(page.getByTestId("tap-count")).toHaveText("1");
+
+  await drag(page, firstCard, -100, 6);
+  await expect(page.getByTestId("tap-count")).toHaveText("1");
+});
+
+test("Carousel preserves momentum and edge rubber-banding", async ({ page }) => {
+  const carousel = page.locator(".fixture-carousel");
+  const card = page.locator(".carousel-card").nth(1);
+
+  await drag(page, card, -100, 5, 3);
+  const releasedOffset = await carousel.evaluate((element) => element.scrollLeft);
+  await page.waitForTimeout(120);
+  expect(await carousel.evaluate((element) => element.scrollLeft)).toBeGreaterThan(releasedOffset);
+
+  await carousel.evaluate((element) => {
+    element.scrollLeft = 0;
+  });
+  const box = await card.boundingBox();
+  if (!box) throw new Error("Card has no bounding box");
+  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+  await page.mouse.down();
+  await page.mouse.move(box.x + box.width / 2 + 90, box.y + box.height / 2, { steps: 4 });
+  expect(Number(await carousel.getAttribute("data-overscroll"))).toBeGreaterThan(0);
+  await page.mouse.up();
+  await page.waitForTimeout(900);
+  expect(Math.abs(Number(await carousel.getAttribute("data-overscroll")))).toBeLessThan(1);
+});
+
+test("BottomSheet remains mounted while its default exit animation plays", async ({ page }) => {
+  await page.locator(".sheet-trigger").click();
+  await expect(page.getByTestId("bottom-sheet")).toBeVisible();
+
+  await page.getByTestId("sheet-overlay").click({ position: { x: 8, y: 8 } });
+  await expect(page.getByTestId("bottom-sheet")).toHaveCount(1);
+  await page.waitForTimeout(500);
+  await expect(page.getByTestId("bottom-sheet")).toHaveCount(0);
+});
+
+test("keyboard and its attached footer dismiss on the same transition", async ({ page }) => {
+  await page.goto("/tests/runtime-fixture.html?fixture=keyboard");
+  const input = page.getByLabel("Message");
+  const footer = page.getByTestId("flow-fixed-footer");
+  const keyboard = page.getByTestId("keyboard-dock");
+
+  await input.click();
+  await expect(keyboard).toHaveAttribute("data-visible", "true");
+  await drag(page, footer, 0, 120, 5);
+  await expect(keyboard).toHaveAttribute("data-visible", "false");
+
+  await page.waitForTimeout(100);
+  const progress = await page.evaluate(() => {
+    const footerElement = document.querySelector<HTMLElement>('[data-testid="flow-fixed-footer"]')!;
+    const keyboardElement = document.querySelector<HTMLElement>('[data-testid="keyboard-dock"]')!;
+    const fullHeight = Number.parseFloat(keyboardElement.style.height);
+    const footerRemaining = Number.parseFloat(getComputedStyle(footerElement).bottom);
+    const matrix = new DOMMatrixReadOnly(getComputedStyle(keyboardElement).transform);
+    return {
+      footer: footerRemaining / fullHeight,
+      keyboard: 1 - matrix.m42 / fullHeight,
+    };
+  });
+  expect(Math.abs(progress.footer - progress.keyboard)).toBeLessThan(0.18);
+
+  await page.waitForTimeout(300);
+  expect(await footer.evaluate((element) => getComputedStyle(element).bottom)).toBe("34px");
+});
+
+test("switching to Pixel keeps the composer above Android navigation", async ({ page }) => {
+  await page.goto("/tests/runtime-fixture.html?fixture=keyboard");
+  const input = page.getByLabel("Message");
+  await input.evaluate((element: HTMLInputElement) => {
+    element.value = "Draft message";
+  });
+
+  await page.getByTestId("device-picker").click();
+  await page.getByTestId("device-option-pixel-10").click();
+
+  const frame = page.getByTestId("phone-frame");
+  const screen = page.getByTestId("device-screen");
+  const statusIndicators = page.getByTestId("status-indicators");
+  const navigation = page.getByTestId("android-navigation-bar");
+  const footer = page.getByTestId("flow-fixed-footer");
+
+  await expect(frame).toHaveAttribute("data-device", "pixel-10");
+  await expect(screen).toHaveAttribute("data-device", "pixel-10");
+  await expect(page.locator(".phone-bezel")).toHaveAttribute(
+    "src",
+    "/assets/android/Pixel10.png",
+  );
+  await expect(statusIndicators).toHaveAttribute("data-platform", "android");
+  await expect(statusIndicators).toHaveAttribute(
+    "src",
+    "/assets/status/status-icons.svg",
+  );
+  await expect(navigation).toBeVisible();
+  await expect(page.getByTestId("home-indicator")).toHaveCount(0);
+  await expect(input).toHaveValue("Draft message");
+  await page.waitForTimeout(300);
+
+  const layout = await page.evaluate(() => {
+    const footerElement = document.querySelector<HTMLElement>(
+      '[data-testid="flow-fixed-footer"]',
+    )!;
+    const navigationElement = document.querySelector<HTMLElement>(
+      '[data-testid="android-navigation-bar"]',
+    )!;
+    const appViewportElement = document.querySelector<HTMLElement>(
+      '[data-testid="mobile-app-viewport"]',
+    )!;
+    return {
+      footerBottom: footerElement.getBoundingClientRect().bottom,
+      appViewportBottom: appViewportElement.getBoundingClientRect().bottom,
+      navigationTop: navigationElement.getBoundingClientRect().top,
+      navigationHeight: Number.parseFloat(getComputedStyle(navigationElement).height),
+      safeAreaBottom: Number.parseFloat(
+        getComputedStyle(document.querySelector<HTMLElement>('[data-testid="device-screen"]')!).getPropertyValue(
+          "--device-safe-area-bottom",
+        ),
+      ),
+    };
+  });
+
+  expect(layout.safeAreaBottom).toBe(layout.navigationHeight);
+  expect(Math.abs(layout.appViewportBottom - layout.navigationTop)).toBeLessThanOrEqual(1);
+  expect(Math.abs(layout.footerBottom - layout.navigationTop)).toBeLessThanOrEqual(1);
+
+  await input.click();
+  await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "true");
+  await expect(navigation).toHaveCount(0);
+  await page.waitForTimeout(300);
+
+  const keyboardLayout = await page.evaluate(() => {
+    const screen = document.querySelector<HTMLElement>('[data-testid="device-screen"]')!;
+    const viewport = document.querySelector<HTMLElement>('[data-testid="mobile-app-viewport"]')!;
+    const scroll = document.querySelector<HTMLElement>('[data-testid="mobile-scroll"]')!;
+    const footerElement = document.querySelector<HTMLElement>('[data-testid="flow-fixed-footer"]')!;
+    const keyboard = document.querySelector<HTMLElement>('[data-testid="keyboard-dock"]')!;
+
+    return {
+      screenBottom: screen.getBoundingClientRect().bottom,
+      viewportBottom: viewport.getBoundingClientRect().bottom,
+      scrollBottom: scroll.getBoundingClientRect().bottom,
+      footerBottom: footerElement.getBoundingClientRect().bottom,
+      keyboardTop: keyboard.getBoundingClientRect().top,
+      keyboardBottom: keyboard.getBoundingClientRect().bottom,
+    };
+  });
+
+  expect(keyboardLayout.viewportBottom).toBeCloseTo(keyboardLayout.screenBottom, 0);
+  expect(Math.abs(keyboardLayout.keyboardBottom - keyboardLayout.screenBottom)).toBeLessThanOrEqual(1);
+  expect(Math.abs(keyboardLayout.scrollBottom - keyboardLayout.keyboardTop)).toBeLessThanOrEqual(1);
+  expect(Math.abs(keyboardLayout.footerBottom - keyboardLayout.keyboardTop)).toBeLessThanOrEqual(1);
+});
+
+test("FlowStack pushes and pops screens while dismissing the keyboard", async ({ page }) => {
+  await page.goto("/tests/runtime-fixture.html?fixture=flow");
+  await page.getByLabel("Flow message").click();
+  await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "true");
+
+  await page.getByRole("button", { name: "Push level 2" }).click();
+  await expect(page.getByRole("heading", { name: "Screen stacking works" })).toBeVisible();
+  await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "false");
+  const safeHeaderPlacement = await page.evaluate(() => {
+    const screen = document.querySelector<HTMLElement>('[data-testid="device-screen"]')!;
+    const toolbar = document.querySelector<HTMLElement>(".flow-fixture-header")!;
+    return toolbar.getBoundingClientRect().top - screen.getBoundingClientRect().top;
+  });
+  expect(safeHeaderPlacement).toBeGreaterThanOrEqual(54);
+
+  await page.getByRole("button", { name: "Push level 3" }).click();
+  await expect(page.getByRole("heading", { name: "Nested view level 3" })).toBeVisible();
+  await page.getByRole("button", { name: "Push level 4" }).click();
+  await expect(page.getByRole("heading", { name: "Nested view level 4" })).toBeVisible();
+
+  await page.getByRole("button", { name: "Done" }).click();
+  await expect(page.getByRole("heading", { name: "Nested view level 3" })).toBeVisible();
+  await page.getByRole("button", { name: "‹ Back" }).click();
+  await expect(page.getByRole("heading", { name: "Screen stacking works" })).toBeVisible();
+  await page.getByRole("button", { name: "Done" }).click();
+  await expect(page.getByRole("heading", { name: "Flow root" })).toBeVisible();
+});

+ 82 - 0
prototype/adjutant-mobile/tests/runtime-fixture.css

@@ -0,0 +1,82 @@
+.fixture-screen {
+  background: #f6f6f6;
+}
+
+.fixture-content {
+  min-height: 1200px;
+  padding: 76px 24px 120px;
+}
+
+.fixture-content h1 {
+  margin: 0 0 20px;
+  font-size: 24px;
+}
+
+.sheet-trigger {
+  margin-bottom: 24px;
+}
+
+.fixture-carousel {
+  width: 100%;
+}
+
+.fixture-carousel-track {
+  gap: 12px;
+}
+
+.carousel-card {
+  width: 180px;
+  height: 132px;
+  flex: 0 0 auto;
+  border: 0;
+  border-radius: 18px;
+  background: #d9e7ff;
+}
+
+[data-testid="tap-count"] {
+  display: block;
+  margin-top: 12px;
+}
+
+.fixture-tall-content {
+  min-height: 900px;
+  padding-top: 160px;
+}
+
+.keyboard-footer {
+  height: 84px;
+  padding: 14px 20px;
+  background: white;
+  border-top: 1px solid #ddd;
+}
+
+.keyboard-footer input {
+  width: 100%;
+  height: 48px;
+  padding: 0 16px;
+  border: 1px solid #bbb;
+  border-radius: 24px;
+}
+
+.flow-fixture-header {
+  display: grid;
+  grid-template-columns: 1fr auto 1fr;
+  align-items: center;
+  height: var(--flow-header-content-height);
+  padding: 0 18px;
+  background: white;
+  border-bottom: 1px solid #ddd;
+}
+
+.flow-fixture-header button {
+  justify-self: start;
+}
+
+.flow-fixture-footer {
+  display: flex;
+  justify-content: flex-end;
+  height: 64px;
+  padding: 10px 18px;
+  background: white;
+  border-top: 1px solid #ddd;
+}

+ 12 - 0
prototype/adjutant-mobile/tests/runtime-fixture.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Mobile runtime interaction fixture</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/tests/runtime-fixture.tsx"></script>
+  </body>
+</html>

+ 152 - 0
prototype/adjutant-mobile/tests/runtime-fixture.tsx

@@ -0,0 +1,152 @@
+import { StrictMode, useState } from "react";
+import { createRoot } from "react-dom/client";
+import {
+  BottomSheet,
+  Carousel,
+  FlowStack,
+  KeyboardInput,
+  MobileRuntime,
+  MobileScroll,
+  type FlowScreen,
+} from "../src/mobile";
+import "../src/styles.css";
+import "./runtime-fixture.css";
+
+function CarouselFixture() {
+  const [tapCount, setTapCount] = useState(0);
+  const [sheetOpen, setSheetOpen] = useState(false);
+
+  return (
+    <MobileRuntime>
+      <MobileScroll className="fixture-screen">
+        <main className="fixture-content">
+          <h1>Runtime fixture</h1>
+          <button className="sheet-trigger" type="button" onClick={() => setSheetOpen(true)}>
+            Open sheet
+          </button>
+          <Carousel
+            ariaLabel="Featured cards"
+            className="fixture-carousel"
+            contentClassName="fixture-carousel-track"
+          >
+            {Array.from({ length: 7 }, (_, index) => (
+              <button
+                className="carousel-card"
+                type="button"
+                onClick={() => setTapCount((count) => count + 1)}
+                key={index}
+              >
+                Card {index + 1}
+              </button>
+            ))}
+          </Carousel>
+          <output data-testid="tap-count">{tapCount}</output>
+          <div className="fixture-tall-content">Scrollable parent content</div>
+        </main>
+      </MobileScroll>
+      <BottomSheet
+        open={sheetOpen}
+        onOpenChange={setSheetOpen}
+        title="Animated sheet"
+        description="Exit motion regression fixture."
+      >
+        <p>Sheet content</p>
+      </BottomSheet>
+    </MobileRuntime>
+  );
+}
+
+function KeyboardFixture() {
+  const screen: FlowScreen = {
+    id: "keyboard",
+    footerHeight: 84,
+    footer: () => (
+      <div className="keyboard-footer">
+        <KeyboardInput aria-label="Message" placeholder="Message" />
+      </div>
+    ),
+    render: () => (
+      <MobileScroll className="fixture-screen">
+        <main className="fixture-content fixture-tall-content">Keyboard fixture</main>
+      </MobileScroll>
+    ),
+  };
+
+  return (
+    <MobileRuntime>
+      <FlowStack initial={screen} />
+    </MobileRuntime>
+  );
+}
+
+function stackedScreen(level: number): FlowScreen {
+  return {
+    id: `flow-level-${level}`,
+    headerHeight: 56,
+    header: (flow) => (
+      <div className="flow-fixture-header">
+        <button type="button" onClick={flow.pop}>‹ Back</button>
+        <strong>Level {level}</strong>
+        <span />
+      </div>
+    ),
+    footerHeight: 64,
+    footer: (flow) => (
+      <div className="flow-fixture-footer">
+        <button type="button" onClick={flow.pop}>Done</button>
+      </div>
+    ),
+    render: (flow) => (
+      <MobileScroll className="fixture-screen">
+        <main className="fixture-content">
+          <h1>{level === 2 ? "Screen stacking works" : `Nested view level ${level}`}</h1>
+          {level < 4 ? (
+            <button type="button" onClick={() => flow.push(stackedScreen(level + 1))}>
+              Push level {level + 1}
+            </button>
+          ) : null}
+        </main>
+      </MobileScroll>
+    ),
+  };
+}
+
+function FlowFixture() {
+  const screen: FlowScreen = {
+    id: "flow-root",
+    footerHeight: 84,
+    footer: () => (
+      <div className="keyboard-footer">
+        <KeyboardInput aria-label="Flow message" placeholder="Message" />
+      </div>
+    ),
+    render: (flow) => (
+      <MobileScroll className="fixture-screen">
+        <main className="fixture-content">
+          <h1>Flow root</h1>
+          <button type="button" onClick={() => flow.push(stackedScreen(2))}>
+            Push level 2
+          </button>
+        </main>
+      </MobileScroll>
+    ),
+  };
+
+  return (
+    <MobileRuntime>
+      <FlowStack initial={screen} />
+    </MobileRuntime>
+  );
+}
+
+const fixture = new URLSearchParams(window.location.search).get("fixture");
+const fixtureElement =
+  fixture === "keyboard"
+    ? <KeyboardFixture />
+    : fixture === "flow"
+      ? <FlowFixture />
+      : <CarouselFixture />;
+
+createRoot(document.getElementById("root")!).render(
+  <StrictMode>{fixtureElement}</StrictMode>,
+);

+ 68 - 0
prototype/adjutant-mobile/tests/sites-worker.test.mjs

@@ -0,0 +1,68 @@
+import assert from "node:assert/strict";
+import { access } from "node:fs/promises";
+import test from "node:test";
+import worker from "../worker/index.js";
+
+test("serves existing static assets without a fallback", async () => {
+  const calls = [];
+  const response = await worker.fetch(new Request("https://example.test/assets/app.js"), {
+    ASSETS: {
+      fetch: async (request) => {
+        calls.push(new URL(request.url).pathname);
+        return new Response("asset", { status: 200 });
+      },
+    },
+  });
+
+  assert.equal(response.status, 200);
+  assert.deepEqual(calls, ["/assets/app.js"]);
+});
+
+test("falls back to index.html for an unknown app route", async () => {
+  const calls = [];
+  const response = await worker.fetch(
+    new Request("https://example.test/flow/step-two?source=share", {
+      headers: { accept: "text/html" },
+    }),
+    {
+      ASSETS: {
+        fetch: async (request) => {
+          const url = new URL(request.url);
+          calls.push(url.pathname + url.search);
+          return new Response(url.pathname === "/index.html" ? "app" : "missing", {
+            status: url.pathname === "/index.html" ? 200 : 404,
+          });
+        },
+      },
+    },
+  );
+
+  assert.equal(response.status, 200);
+  assert.deepEqual(calls, ["/flow/step-two?source=share", "/index.html"]);
+});
+
+test("does not turn missing API or write requests into the app shell", async () => {
+  for (const request of [
+    new Request("https://example.test/api/missing", { headers: { accept: "application/json" } }),
+    new Request("https://example.test/flow", { method: "POST", headers: { accept: "text/html" } }),
+  ]) {
+    let calls = 0;
+    const response = await worker.fetch(request, {
+      ASSETS: {
+        fetch: async () => {
+          calls += 1;
+          return new Response("missing", { status: 404 });
+        },
+      },
+    });
+
+    assert.equal(response.status, 404);
+    assert.equal(calls, 1);
+  }
+});
+
+test("emits the files required by Sites packaging", async () => {
+  await access(new URL("../dist/client/index.html", import.meta.url));
+  await access(new URL("../dist/server/index.js", import.meta.url));
+  await access(new URL("../dist/.openai/hosting.json", import.meta.url));
+});

+ 21 - 0
prototype/adjutant-mobile/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "allowJs": false,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "strict": true,
+    "forceConsistentCasingInFileNames": true,
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx"
+  },
+  "include": ["src"],
+  "references": []
+}

+ 13 - 0
prototype/adjutant-mobile/vite.config.ts

@@ -0,0 +1,13 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+  build: {
+    outDir: "dist/client",
+  },
+  server: {
+    host: "0.0.0.0",
+    allowedHosts: ["terminal.local"],
+  },
+  plugins: [react()],
+});

TEMPAT SAMPAH
prototype/adjutant-mobile/voice-slot-filling.png


+ 15 - 0
prototype/adjutant-mobile/worker/index.js

@@ -0,0 +1,15 @@
+export default {
+  async fetch(request, env) {
+    const response = await env.ASSETS.fetch(request);
+    const acceptsHtml = request.headers.get("accept")?.includes("text/html");
+
+    if (response.status !== 404 || !acceptsHtml || !["GET", "HEAD"].includes(request.method)) {
+      return response;
+    }
+
+    const indexUrl = new URL(request.url);
+    indexUrl.pathname = "/index.html";
+    indexUrl.search = "";
+    return env.ASSETS.fetch(new Request(indexUrl, request));
+  },
+};