diff --git a/.github/workflows/consumer-nlos-ci.yml b/.github/workflows/consumer-nlos-ci.yml index 43d36e80..63b5506e 100644 --- a/.github/workflows/consumer-nlos-ci.yml +++ b/.github/workflows/consumer-nlos-ci.yml @@ -11,8 +11,10 @@ on: - 'docs/adr/ADR-32[8-9]*' - 'docs/adr/ADR-33[0-1]*' - 'docs/adr/ADR-341-consumer-nlos-beta-tester-delivery-and-diagnostics.md' + - 'docs/adr/ADR-342-cognitum-inspired-mobile-instrument-ui.md' - 'docs/research/consumer-nlos-acceptance-protocol.md' - 'docs/research/consumer-nlos-beta-test-protocol.md' + - 'docs/screenshots/consumer-nlos-mobile-ui/**' - 'docs/schemas/ruview-nlos-*.schema.json' - 'docs/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json' - 'docs/security/consumer-nlos-threat-model.md' @@ -27,6 +29,8 @@ on: - 'ui/mobile/**' - 'harness/ruview/**' - 'docs/**consumer-nlos*' + - 'docs/adr/ADR-342-cognitum-inspired-mobile-instrument-ui.md' + - 'docs/screenshots/consumer-nlos-mobile-ui/**' - 'docs/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json' - '.github/workflows/consumer-nlos-ci.yml' - 'v2/Cargo.toml' @@ -118,6 +122,20 @@ jobs: - run: npm test -- --runInBand - run: npx tsc --noEmit - run: npm run lint + - name: Install pinned Playwright Chromium + run: ./node_modules/.bin/playwright install --with-deps chromium + - name: Run production browser E2E and reproduce mobile screenshots + run: npm run e2e:web + - name: Upload browser E2E review evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: consumer-nlos-mobile-ui-browser-${{ github.sha }} + path: | + docs/screenshots/consumer-nlos-mobile-ui + ui/mobile/test-results/playwright + if-no-files-found: warn + retention-days: 14 - name: Export browser bundle run: npx expo export --platform web - name: Export Expo iOS bundle @@ -136,6 +154,142 @@ jobs: console.log(JSON.stringify(report.metadata?.vulnerabilities ?? {}, null, 2)); JS + mobile-ui-contract: + name: Mobile UI ADR, E2E, and screenshot contract + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - name: Validate UI-only architecture and deterministic review evidence + shell: bash + run: | + set -euo pipefail + python - <<'PY' + import re + import struct + from pathlib import Path + + adr_path = Path( + "docs/adr/ADR-342-cognitum-inspired-mobile-instrument-ui.md" + ) + flow_path = Path("ui/mobile/e2e/nlos_mobile_ui.yaml") + screenshot_dir = Path("docs/screenshots/consumer-nlos-mobile-ui") + + adr = adr_path.read_text(encoding="utf-8") + required_sections = ( + "## Context", + "## Specification", + "## Pseudocode and state transitions", + "## Architecture", + "## Decision", + "## Alternatives with quantified tradeoffs", + "## Accessibility", + "## Security and privacy", + "## Performance budgets", + "## Refinement and failure handling", + "## Requirement to evidence mapping", + "## Rollout and rollback", + "## Acceptance test", + ) + for section in required_sections: + assert section in adr, f"missing ADR section: {section}" + + required_adr_contract = ( + "UI-only", + "feat/consumer-nlos-ruview", + "Cognitum Explain", + "No Cognitum name, logo, wordmark", + "SYNTHETIC", + "LIVE VERIFIED", + "LIVE UNVERIFIED", + "STALE", + "DISCONNECTED", + "390 by 844", + "44 by 44 points", + "under 1.5 seconds", + "under 100 milliseconds", + "zero runtime dependency", + "physical iPhone validation pending", + ) + for phrase in required_adr_contract: + assert phrase in adr, f"missing mobile UI contract: {phrase}" + + flow = flow_path.read_text(encoding="utf-8") + required_flow_contract = ( + "appId:", + "---", + "launchApp:", + "tapOn:", + "assertVisible:", + "nlos-evidence-state", + "nlos-synthetic-watermark", + "nlos-beta-setup", + ) + for token in required_flow_contract: + assert token in flow, f"missing Maestro UI contract token: {token}" + + screen_sources = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(Path("ui/mobile/src/screens/NLOSScreen").glob("*.tsx")) + ) + for test_id in ( + "nlos-evidence-state", + "nlos-synthetic-watermark", + "nlos-beta-setup", + ): + assert test_id in screen_sources, f"missing screen contract: {test_id}" + for state in ( + "SYNTHETIC", + "LIVE VERIFIED", + "LIVE UNVERIFIED", + "STALE", + "DISCONNECTED", + ): + assert state in screen_sources, f"missing evidence state: {state}" + + state_type = re.search( + r"export type NlosEvidenceState\s*=\s*(.*?);", + screen_sources, + re.DOTALL, + ) + assert state_type, "missing NlosEvidenceState type contract" + actual_states = set(re.findall(r"'([^']+)'", state_type.group(1))) + expected_states = { + "SYNTHETIC", + "LIVE VERIFIED", + "LIVE UNVERIFIED", + "STALE", + "DISCONNECTED", + } + assert actual_states == expected_states, ( + f"evidence state taxonomy drift: {sorted(actual_states)}" + ) + + def png_dimensions(path): + data = path.read_bytes() + assert data.startswith(b"\x89PNG\r\n\x1a\n"), f"not a PNG: {path}" + assert data[12:16] == b"IHDR", f"missing PNG IHDR: {path}" + return struct.unpack(">II", data[16:24]) + + expected_screenshots = ( + "overview-390x844.png", + "synthetic-390x844.png", + "setup-390x844.png", + ) + for name in expected_screenshots: + path = screenshot_dir / name + assert path.is_file(), f"missing screenshot baseline: {path}" + assert png_dimensions(path) == (390, 844), ( + f"wrong screenshot dimensions for {path}: {png_dimensions(path)}" + ) + + print( + "Validated ADR 342, Maestro UI contract, five evidence states, " + "and three 390x844 PNG baselines" + ) + PY + native-ios: name: Native Swift, simulator, and unsigned archive dry gate runs-on: macos-26 diff --git a/docs/adr/ADR-342-cognitum-inspired-mobile-instrument-ui.md b/docs/adr/ADR-342-cognitum-inspired-mobile-instrument-ui.md new file mode 100644 index 00000000..51636d8d --- /dev/null +++ b/docs/adr/ADR-342-cognitum-inspired-mobile-instrument-ui.md @@ -0,0 +1,512 @@ +# ADR-342: Cognitum-inspired mobile instrument UI for RuView NLOS + +| Field | Decision | +|---|---| +| **Status** | Proposed; software implementation and deterministic review evidence are part of a stacked UI-only pull request, while physical-device validation remains operator-gated | +| **Date** | 2026-08-23 | +| **Owners** | RuView Labs mobile, iOS, design, accessibility, privacy, security, and research maintainers | +| **Scope** | Presentation, responsive layout, evidence-state projection, accessibility semantics, mobile end-to-end review flow, and screenshot baselines for the Expo and native SwiftUI NLOS clients | +| **Depends on** | ADR-295, ADR-318, ADR-319, ADR-330, ADR-340, ADR-341 | +| **Stacked pull request base** | `feat/consumer-nlos-ruview`, the head branch for pull request 1687; retarget to `main` after the dependency merges | +| **Implementation boundary** | `ui/mobile` presentation and tests, `ui/ios-nlos/App` presentation, the mobile UI screenshot baselines, and the UI validation portion of `.github/workflows/consumer-nlos-ci.yml` | + +## Context + +RuView NLOS already exposes strict source provenance, frame freshness, setup, +diagnostic, and transport behavior in its Expo and native SwiftUI clients. The +mobile presentation does not yet have one coherent instrument language. Its +most important state can consequently compete with setup copy, controls, and +technical detail on a narrow phone viewport. + +The requested direction is similar to the visual language of Cognitum Explain +Studio: a near-black technical canvas, fine grid, restrained cyan and green +accents, compact monospaced labels, thin luminous outlines, and an orbital or +radar motif. This decision adopts that visual language as design provenance, +not as a product or code dependency. No Cognitum name, logo, wordmark, copy, +source code, private asset, authentication behavior, or investor-deck branding +is reused. RuView keeps its own name, logo, evidence vocabulary, information +architecture, and privacy boundary. + +The authenticated Studio route was not available to the automated cloud review +session. The initial contract is therefore derived from the product owner's +direction and the visual characteristics observable on the available hosted +Cognitum Explain reference surface. Pixel equivalence with the authenticated +Studio route is neither claimed nor required. A maintainer with authorized +Studio access can later provide an approved reference screenshot and request a +bounded visual refinement without changing the epistemic or privacy rules in +this decision. + +This is a UI-only decision. It must not change sensing, reconstruction, +filtering, transport, credential storage, permissions, diagnostic contents, +data retention, or claims. Existing source-validation and fail-closed behavior +remain authoritative. The UI projects that state; it does not invent or +upgrade it. + +## Specification + +### Outcome and actors + +The outcome is a unified mobile instrument surface that lets a tester identify +the active evidence state, understand the privacy boundary, inspect current +hidden-target hypotheses when allowed, and reach the next safe action without +reading the entire screen. + +Actors are beta testers, researchers, accessibility users, support maintainers, +design reviewers, privacy and security reviewers, and the release operator. + +### Inputs + +1. Existing validated Expo NLOS store values: frame source, freshness, stream + status, accepted tracks, credential availability, and rejection reason. +2. Existing native `AppModel` connection, capability, frame, track, and visible + depth validation state. +3. Current device safe-area insets, viewport dimensions, Dynamic Type or browser + text scaling, reduced-motion preference, and high-contrast settings. +4. Local user actions such as selecting a view, opening setup guidance, + starting synthetic replay, connecting an authenticated live source, or + forgetting a credential. + +### Outputs + +1. A narrow-screen overview with a single prominent evidence state. +2. A visual instrument region that shows only tracks already allowed by the + existing validation and freshness boundary. +3. Setup, provenance, privacy, and interpretation guidance in a stable reading + order. +4. Reviewable screenshot baselines for overview, synthetic, and setup states. +5. A production-browser Playwright end-to-end suite and a mobile Maestro flow + that verify navigation, state visibility, synthetic watermarking, setup + affordances, and the primary local action. + +### Honest evidence-state taxonomy + +The presentation exposes exactly five top-level states. These are display +projections over existing domain state and do not create a second sensing state +machine. + +| Display state | Minimum condition | Required presentation | Forbidden presentation | +|---|---|---|---| +| `SYNTHETIC` | A fresh accepted frame is explicitly sourced from synthetic replay | Persistent amber label and watermark in the instrument region | Green live treatment, verified language, or removal of the watermark | +| `LIVE VERIFIED` | A fresh accepted live frame has authenticated transport and satisfies the existing provenance and evidence gate | Green and cyan live label plus source and freshness details | Inferring verification from connection status alone | +| `LIVE UNVERIFIED` | A live transport is active or attempting to provide data, but no fresh frame satisfies the full verification gate | Amber caution label, withheld target visualization, and corrective guidance | Calling the source verified or silently substituting replay | +| `STALE` | A previously displayable frame has exceeded the existing freshness threshold | High-priority stale label, age context when available, and suppression of hidden-target geometry | Continuing to show cached targets as current | +| `DISCONNECTED` | No eligible fresh source is active and no stale frame needs a stronger warning | Neutral disconnected label and explicit connection or replay actions | Implied live availability or retained target geometry | + +Unknown, contradictory, or partially initialized input resolves to `LIVE +UNVERIFIED` only when an authenticated live attempt is active. It resolves to +`DISCONNECTED` otherwise. No unknown state may resolve to `LIVE VERIFIED`. + +### Layout and interaction requirements + +1. The primary reference viewport is 390 by 844 CSS pixels or iOS points. It + must have no horizontal document or root-scroll overflow at 100 percent text + scale. +2. The layout must remain functional at 320 points wide and with text enlarged + to 200 percent. Secondary rows may wrap vertically; essential state, privacy, + and action text may not be clipped. +3. Interactive targets have a minimum hit area of 44 by 44 points. Adjacent + targets maintain at least 8 points of separation unless their combined + control supplies equivalent accessible grouping. +4. Evidence state is encoded by text and icon or shape as well as color. Color + is never the sole differentiator. +5. The semantic reading order is identity and evidence state, instrument, + metrics, primary actions, setup, provenance, privacy, then technical detail. +6. The existing explainer and tester-feedback destinations remain visible from + setup without collecting credentials or diagnostics. +7. Motion is decorative and bounded. Reduced-motion mode removes continuous + orbit, sweep, pulse, and parallax effects without removing information. + +### Performance budgets + +1. Initial usable render is under 1.5 seconds in a production browser build on + a representative modern iPhone under the recorded test conditions. Usable + means the evidence state and primary action are visible and respond to input. +2. Local UI state interactions have p95 input-to-committed-visual latency under + 100 milliseconds over at least 30 repetitions on the same device. +3. Continuous decorative animation must not be required for state recognition. + It pauses when the app or page is inactive and honors reduced motion. +4. The redesign adds no network request, remote font, remote image, analytics + client, sensor subscription, or background timer. +5. Screenshot rendering is deterministic: fixed viewport, fixed fixtures, + reduced motion, local assets, and no dependency on a live endpoint. + +The timing budgets are targets until a physical iPhone report records device, +OS, build commit, build mode, browser, network conditioning, repetition count, +median, and p95. CI and desktop browser results are software evidence and must +not be relabeled as physical-device measurements. + +### Explicit exclusions + +This decision does not authorize or alter optical NLOS sensing, ARKit capture, +CSI ingestion, WebSocket protocol behavior, endpoint validation, token handling, +Keychain behavior, browser credential persistence, permission prompts, +diagnostic schema, upload behavior, raw-data retention, background sensing, +identity inference, health or safety use, or camera-equivalence claims. + +## Pseudocode and state transitions + +### Evidence-state projection + +```text +projectEvidenceState(domain): + if domain.freshness == stale and domain.previouslyDisplayableFrameExists: + return STALE + + if domain.freshness == fresh and domain.acceptedFrame.source == synthetic: + return SYNTHETIC + + if domain.liveAttemptActive: + if domain.freshness == fresh + and domain.acceptedFrame.source == live + and domain.transportAuthenticated + and domain.provenanceGatePassed + and domain.evidenceGatePassed: + return LIVE_VERIFIED + return LIVE_UNVERIFIED + + return DISCONNECTED +``` + +The projection consumes authoritative state without mutating it. A source +cannot become verified because a user tapped a control, because a socket is +open, or because a previous frame was verified. + +### Presentation control flow + +```text +onRender: + state = projectEvidenceState(authoritativeDomainState) + announce state when it changes, but do not repeatedly announce frame updates + render state text, redundant icon or shape, and provenance summary + + if state == SYNTHETIC: + render only accepted fresh replay tracks + render persistent SYNTHETIC watermark above the instrument + else if state == LIVE_VERIFIED: + render only accepted fresh live tracks + else: + render no hidden-target geometry + render safe recovery guidance for the current state + + preserve setup, privacy, interpretation, and feedback paths + disable decorative motion when reduced motion is requested +``` + +### Responsive layout flow + +```text +layout(viewport, textScale, safeArea): + availableWidth = viewport.width - safeArea.left - safeArea.right + apply bounded horizontal inset + place identity and evidence status before the instrument + stack metrics and actions when their measured width does not fit + allow labels to wrap; never reduce essential text below token minimum + assert rootScrollWidth <= viewport.width + assert every interactive hit rectangle >= 44 by 44 points +``` + +### Screenshot flow + +```text +captureBaseline(name, fixture): + build production web bundle + serve local immutable bundle + set viewport to 390 by 844 + enable reduced motion and deterministic fixture mode + navigate to NLOS screen + apply fixture and wait for stable UI contract marker + assert evidence-state marker and state-specific safety cues + assert root has no horizontal overflow + save PNG with expected dimensions +``` + +### Success and failure walks + +Success case: synthetic replay provides a fresh accepted frame. The projection +returns `SYNTHETIC`, the instrument shows only accepted tracks, a visible amber +label and watermark remain present, and no live wording appears. The user can +switch local views without changing provenance. All invariants hold. + +Failure case: an authenticated live frame becomes stale while targets were +visible. The next projection returns `STALE`, removes target geometry, displays +the stale warning, and offers recovery guidance. A reconnect cannot restore +`LIVE VERIFIED` until a new accepted frame passes the existing gates. All +invariants hold. + +Contradiction case: transport reports connected while the frame provenance is +missing. The projection returns `LIVE UNVERIFIED`, does not render target +geometry, and never infers verification from connection state. All invariants +hold. + +## Architecture + +### Component boundaries + +| Component | Responsibility | May change in this decision | Must not change | +|---|---|---|---| +| Expo NLOS screen and presentation components | Responsive hierarchy, tokens, evidence-state label, accessible controls, instrument composition | Yes | Store semantics, service requests, credential lifecycle | +| Native SwiftUI shell and presentation helpers | Equivalent hierarchy, tokens, state labels, Dynamic Type, reduced motion, hit targets | Yes | `AppModel`, Apple capability probe, stream guard, Keychain, diagnostics | +| Existing Expo store and hooks | Authoritative source, freshness, frame, rejection, and connection inputs | No | Any sensing or transport behavior | +| Existing native model and core packages | Authoritative connection, frame, track, diagnostic, and capability inputs | No | Any sensing, validation, persistence, or permission behavior | +| Playwright production-browser suite | Black-box responsive navigation, overflow assertions, local state actions, and screenshot reproduction | Yes | Live endpoint use, credentials, or physical-device claims | +| Maestro mobile review flow | Black-box native navigation and visible safety-contract assertions | Yes | Production state or credentials | +| Screenshot harness and baselines | Deterministic visual review evidence | Yes | Live endpoint access or physical-hardware claims | +| Consumer NLOS CI | Build, test, static UI-contract and baseline validation | UI validation only | Signing, upload, secrets, or release authority | + +### Design token contract + +The platforms implement equivalent tokens using their native systems rather +than a new shared runtime package: + +1. near-black navy canvas and elevated graphite surfaces; +2. subtle grid lines with low contrast and no information meaning; +3. cyan primary accent and green verified accent; +4. amber synthetic and unverified accent, and red stale or blocked accent; +5. thin low-opacity outlines, restrained glow, and high-contrast text; +6. editorial sans-serif hierarchy with monospaced instrument labels; and +7. orbital or radar geometry that remains decorative and can be disabled. + +Exact color values may differ enough to satisfy platform contrast and material +behavior. Semantic state, contrast, hierarchy, and spacing are the contract; +pixel identity between React Native Web and SwiftUI is not. + +### Data lifecycle and trust boundaries + +```text +existing validated domain state + -> pure evidence-state projection + -> local presentation tree + -> pixels and accessibility semantics +``` + +The redesign adds no storage and no export. Sensitive values remain in their +existing owners. Credential controls may display length-independent readiness +or masked state only; tokens never enter labels, logs, screenshots, test +fixtures, accessibility values, analytics, or error copy. Screenshot fixtures +contain synthetic, non-personal data and cannot connect to a live endpoint. + +External links remain explicit user actions handled by the operating system or +browser. Opening an explainer or feedback page cannot attach a diagnostic, +credential, scene detail, endpoint, or referrer payload created by the app. + +### Deployment and stacking + +The UI pull request is stacked on `feat/consumer-nlos-ruview` because the NLOS +clients do not yet exist on `main`. Its diff against that base contains only UI +presentation, UI tests, screenshots, this ADR, and bounded UI CI validation. +After pull request 1687 merges, maintainers retarget the UI pull request to +`main`, verify that the diff remains scoped, rerun required checks, and merge it +independently. No sensing commit is cherry-picked into the UI branch. + +## Decision + +Adopt a RuView-owned mobile instrument system inspired by the available +Cognitum Explain visual language on both Expo and native SwiftUI. Preserve one +explicit evidence-state surface, a fail-closed instrument, prominent privacy +and interpretation boundaries, and platform-native accessibility behavior. + +Use mirrored semantic tokens and state projection tests instead of sharing a +runtime UI package across TypeScript and Swift. Use deterministic Expo web +screenshots as cross-platform review references, not as proof of native pixel +output. Require an Xcode 26 build gate and a named physical-device review before +accepting native behavior or performance claims. + +## Alternatives with quantified tradeoffs + +Alternatives are scored from 1, poor, to 5, strong. Weighted score is out of +5.0. Product clarity and epistemic safety each receive 25 percent; delivery +cost, native accessibility, and long-term maintainability each receive 15, 20, +and 15 percent respectively. + +| Alternative | Clarity 25% | Epistemic safety 25% | Delivery cost 15% | Native accessibility 20% | Maintainability 15% | Weighted score | Decision | +|---|---:|---:|---:|---:|---:|---:|---| +| RuView-owned mirrored instrument system | 5 | 5 | 4 | 5 | 4 | 4.70 | Selected | +| Restyle Expo only and leave native SwiftUI unchanged | 3 | 4 | 5 | 3 | 2 | 3.40 | Rejected because two beta surfaces would disagree on the primary state and review burden would increase | +| Embed one web UI in the native app | 4 | 4 | 3 | 2 | 4 | 3.40 | Rejected because WebView startup, focus, Dynamic Type, offline behavior, and native sensor workflow integration add operational risk | +| Create a cross-platform generated token and component package now | 5 | 5 | 1 | 4 | 3 | 3.90 | Deferred because generation infrastructure is disproportionate to one screen and would expand the UI-only diff materially | +| Keep the current generic card layout | 2 | 4 | 5 | 4 | 4 | 3.65 | Rejected because the evidence state remains visually subordinate and does not meet the requested design outcome | + +The selected approach is expected to add roughly two small platform-specific +presentation implementations and one shared behavioral contract, while adding +zero runtime dependency. The cost is duplicated token maintenance. The control +is a screenshot review plus identical state names and accessibility assertions +on both platforms. + +## Accessibility + +1. Every interactive element has a descriptive label, role or trait, and a hit + target of at least 44 by 44 points. +2. Evidence state is exposed as text and an accessible value. It is not encoded + by color, glow, motion, or spatial position alone. +3. State-change announcements are debounced to meaningful transitions. Frame + rate and coordinate updates do not continuously interrupt screen readers. +4. Expo supports browser text scaling and native font scaling. SwiftUI uses + Dynamic Type and avoids fixed-height text containers for essential content. +5. At 200 percent text scale, controls may stack and cards may grow vertically; + evidence state, privacy copy, warnings, and primary actions remain complete. +6. Reduced motion removes continuous decorative animation. Increased contrast + retains distinct borders and semantic text. +7. The visual grid, glow, radar sweep, and orbital decorations are hidden from + the accessibility tree. +8. The synthetic watermark has a programmatic label in addition to its visual + rendering. + +## Security and privacy + +1. The redesign adds no permission, entitlement, endpoint, network request, + storage key, cookie, analytics event, remote asset, background mode, or data + retention path. +2. Existing credential rules remain unchanged: the Expo client holds an + ephemeral credential in memory, while the approved native client uses its + existing Keychain boundary. UI code cannot log, render, snapshot, export, or + persist the credential. +3. Screenshot and E2E fixtures are synthetic and contain no CSI, image, depth, + point-cloud, location, person, device identifier, token, endpoint, or private + diagnostic data. +4. Stale, malformed, unauthenticated, replayed, unknown, or contradictory input + fails closed through the existing validation layer and the state projection. +5. The synthetic watermark remains visible in every synthetic visualization + and captured synthetic baseline. No style token can disable it. +6. The privacy and interpretation boundary is visible without opening a menu. + Raw RF, audio, camera, depth, and transient retention remain off or absent as + defined by prior decisions. +7. External links open only after a user action and do not append app state, + credentials, diagnostics, or scene context. +8. CI uses read-only repository permission and receives no signing, App Store + Connect, endpoint, or test-user secret for the UI contract. + +## Performance budgets + +The normative performance thresholds are defined in Specification. Validation +uses three evidence classes: + +| Evidence | What it can establish | What it cannot establish | +|---|---|---| +| Deterministic CI production build | Bundle validity, type and unit tests, baseline dimensions, state-contract presence | iPhone render timing, thermal behavior, touch latency | +| Desktop browser capture at 390 by 844 | Responsive composition, overflow, fixture states, screenshot review | Mobile Safari GPU behavior or physical touch response | +| Named physical iPhone run | Mobile Safari usable-render time, local interaction p95, Dynamic Type, VoiceOver, reduced motion, safe areas | General performance across every supported device | + +No measured value is documented without its reproducer and evidence label. A +desktop or CI measurement is `MEASURED_SOFTWARE` and not a physical-device +claim. A threshold without a completed physical run remains `TARGET`. + +## Refinement and failure handling + +### Increment plan + +1. Introduce platform-local semantic colors, typography, spacing, and + instrument primitives without changing data owners. +2. Project and render the five evidence states with unit coverage for success, + stale, disconnected, contradiction, and synthetic watermark cases. +3. Recompose the Expo NLOS screen at the reference viewport and add the + production-browser Playwright suite plus Maestro mobile flow. +4. Recompose the native SwiftUI surface using equivalent semantics, Dynamic + Type, and reduced-motion behavior. +5. Capture deterministic Expo overview, synthetic, and setup baselines. +6. Run type, lint, unit, bundle, Swift package, Xcode 26, contract, accessibility, + security, and diff-scope review gates. +7. Complete the named physical iPhone gate before calling native behavior or + mobile performance validated. + +### Failure handling + +1. If state inputs conflict, use `LIVE UNVERIFIED` during an active live attempt + or `DISCONNECTED` otherwise, hide targets, and retain the rejection reason + supplied by the authoritative layer. +2. If a frame becomes stale, remove its geometry on the same committed state + transition. Reconnection alone does not restore it. +3. If decorative rendering fails, retain plain text state, controls, privacy, + and provenance. Decoration cannot block operation. +4. If the viewport overflows, stack secondary content and remove nonessential + decoration before reducing essential text or hit areas. +5. If the performance target fails, profile render count, SVG complexity, + shadow or blur cost, and animation scheduling. Reduce decorative work before + changing semantic content. +6. If a screenshot differs, the reviewer must classify it as an intended design + change, platform rendering variance, fixture drift, or regression. Baselines + are never updated solely to make CI pass. +7. If the native Xcode or physical-device gate fails, the web UI may remain + reviewable, but the pull request is not described as fully validated on iOS. +8. If authenticated Studio reference access later reveals a material mismatch, + refine nonsemantic tokens in a follow-up. Evidence and privacy behavior stay + unchanged unless a new accepted ADR explicitly changes them. + +## Requirement to evidence mapping + +| Requirement | Automated evidence | Human or physical evidence | Pass condition | +|---|---|---|---| +| UI-only scope | Pull-request diff allowlist and service or model tests | Reviewer checks no sensing, transport, permissions, persistence, or retention diff | Zero out-of-scope behavior changes | +| Cognitum-inspired, RuView-owned language | Three screenshot baselines and token review | Product reviewer compares hierarchy and general visual language | Requested characteristics present; zero Cognitum branding or private assets | +| Five honest states | Unit tests plus `nlos-evidence-state` Playwright and Maestro selectors | Reviewer verifies wording and fail-closed hierarchy | All five exact labels are representable; unknown never becomes verified | +| Synthetic safety | Unit and E2E assertions for `nlos-synthetic-watermark` | Screenshot review of synthetic baseline | Label and watermark both visible | +| 390 by 844 layout | PNG dimension contract and browser overflow assertion | Screenshot review | Exact dimensions and no horizontal overflow | +| 44 point targets | Style and interaction assertions where supported | iPhone accessibility inspector and manual target review | Every interactive hit rectangle is at least 44 by 44 points | +| Accessibility | Semantic tests, lint, and reduced-motion fixture | VoiceOver, Dynamic Type at 200 percent, contrast, and reduced-motion review | State and primary actions remain understandable and operable | +| Initial usable render under 1.5 seconds | Production build and instrumentation contract | Named modern iPhone run with recorded conditions | Measured usable render is below 1.5 seconds | +| Local interaction p95 under 100 milliseconds | Deterministic action loop instrumentation | Named iPhone run over at least 30 repetitions | Recorded p95 is below 100 milliseconds | +| Screenshot baselines | CI validates PNG format, names, dimensions, and synthetic marker contract | Reviewer approves overview, synthetic, and setup compositions | All three approved and traceable to the commit | +| No security or privacy expansion | Unit suite, dependency audit, secret scan, workflow permission review | Privacy and security diff review | No new collection, permission, storage, network, secret, or retention path | +| Native equivalence | Swift tests and Xcode 26 unsigned simulator build | Named physical iPhone inspection | Equivalent hierarchy and state semantics; no pixel-equivalence claim | + +## Rollout and rollback + +### Rollout + +1. Review the stacked diff against `feat/consumer-nlos-ruview` and confirm the + scope is limited to presentation, UI tests, screenshots, this ADR, and UI CI. +2. Require the deterministic Expo checks, screenshots, Maestro contract, Swift + tests, and Xcode 26 unsigned build to pass. +3. After pull request 1687 merges, retarget to `main`, recheck the diff, and + rerun required checks. +4. Install the resulting beta on a named supported iPhone and complete the + physical accessibility, layout, state, and performance protocol. +5. Admit beta testers only after release governance in ADR-341 remains green. + +### Rollback + +Rollback is presentation-only. Revert the mobile UI commit while preserving +the NLOS state, service, validation, credential, and diagnostic implementations +from the base feature. If only decoration is defective, disable the affected +grid, glow, orbit, or animation and retain the plain evidence state, actions, +privacy boundary, and setup path. No rollback may remove the synthetic +watermark, stale target suppression, or visible evidence label. + +## Acceptance test + +The decision is accepted only when all software gates pass and the physical +gate is either complete or explicitly recorded as pending without a claim of +full iPhone validation. + +### Deterministic software gate + +1. Build the Expo production web and iOS bundles, run type checking, lint, unit + tests, dependency audit, the production-browser Playwright suite, and the + NLOS mobile UI Maestro contract validation. +2. Render the NLOS route from a production web bundle at 390 by 844 with reduced + motion and deterministic fixtures. Assert `scrollWidth <= clientWidth`. +3. Capture and review: + `docs/screenshots/consumer-nlos-mobile-ui/overview-390x844.png`, + `docs/screenshots/consumer-nlos-mobile-ui/synthetic-390x844.png`, and + `docs/screenshots/consumer-nlos-mobile-ui/setup-390x844.png`. +4. Assert each PNG is exactly 390 by 844. Assert the synthetic baseline and E2E + flow preserve the `SYNTHETIC` label and watermark. +5. Exercise `SYNTHETIC`, `LIVE VERIFIED`, `LIVE UNVERIFIED`, `STALE`, and + `DISCONNECTED` projections. Assert stale, unverified, and disconnected states + render no hidden-target geometry. +6. Run Swift package tests and the Xcode 26 unsigned simulator build. Confirm + zero changes to core packages, sensing, transport, credentials, permissions, + diagnostic schema, or retention. + +### Physical iPhone gate + +On a named representative modern iPhone, record model, OS, commit, build mode, +browser or native client, and test conditions. Verify no overflow in portrait, +44 point hit targets, VoiceOver reading order, Dynamic Type at 200 percent, +reduced motion, all five states, synthetic watermarking, stale target removal, +initial usable render under 1.5 seconds, and local interaction p95 under 100 +milliseconds over at least 30 repetitions. + +Simulator, unsigned archive, desktop browser, or screenshot success does not +substitute for this physical gate. Until the report is attached to the pull +request or linked issue, the correct status is software-validated, with +physical iPhone validation pending. diff --git a/docs/screenshots/consumer-nlos-mobile-ui/README.md b/docs/screenshots/consumer-nlos-mobile-ui/README.md new file mode 100644 index 00000000..3aff93b2 --- /dev/null +++ b/docs/screenshots/consumer-nlos-mobile-ui/README.md @@ -0,0 +1,15 @@ +# Consumer NLOS mobile UI review captures + +These images are deterministic review captures from the production Expo web +export at a 390 by 844 viewport. The Playwright flow navigates the real mobile +application, exercises the synthetic replay control, verifies provenance and +watermark requirements, and writes the PNG files in this directory. + +The captures contain only the disconnected state, governed setup copy, and the +built in synthetic fixture. They use no live endpoint, credential, sensor data, +person data, or remote asset. They are not native iPhone screenshots, physical +LiDAR evidence, performance evidence, or proof of optical NLOS capability. + +Regenerate them from `ui/mobile` with `npm run e2e:web`. The executable design +and evidence contract is documented in +`docs/adr/ADR-342-cognitum-inspired-mobile-instrument-ui.md`. diff --git a/docs/screenshots/consumer-nlos-mobile-ui/overview-390x844.png b/docs/screenshots/consumer-nlos-mobile-ui/overview-390x844.png new file mode 100644 index 00000000..7483a005 Binary files /dev/null and b/docs/screenshots/consumer-nlos-mobile-ui/overview-390x844.png differ diff --git a/docs/screenshots/consumer-nlos-mobile-ui/setup-390x844.png b/docs/screenshots/consumer-nlos-mobile-ui/setup-390x844.png new file mode 100644 index 00000000..32aa9dba Binary files /dev/null and b/docs/screenshots/consumer-nlos-mobile-ui/setup-390x844.png differ diff --git a/docs/screenshots/consumer-nlos-mobile-ui/synthetic-390x844.png b/docs/screenshots/consumer-nlos-mobile-ui/synthetic-390x844.png new file mode 100644 index 00000000..277462ac Binary files /dev/null and b/docs/screenshots/consumer-nlos-mobile-ui/synthetic-390x844.png differ diff --git a/ui/ios-nlos/App/ContentView.swift b/ui/ios-nlos/App/ContentView.swift index d25a76a5..684cf1d8 100644 --- a/ui/ios-nlos/App/ContentView.swift +++ b/ui/ios-nlos/App/ContentView.swift @@ -6,30 +6,38 @@ import SwiftUI struct ContentView: View { @ObservedObject var model: AppModel @Environment(\.scenePhase) private var scenePhase + @ScaledMetric(relativeTo: .largeTitle) private var heroFontSize: CGFloat = 42 @State private var exportConsent = false @State private var diagnosticURL: URL? @State private var exportError: String? var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 18) { - onboardingCard - visibleDepthCard - Text("NLOS MONITOR") - .font(.caption.bold().monospaced()) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - connectionCard - statusCard - visualizationCard - capabilityCard - boundaryCard + ZStack(alignment: .top) { + RuViewTheme.background.ignoresSafeArea() + InstrumentGrid() + + ScrollView { + LazyVStack(spacing: 16) { + instrumentHeader + statusCard + visualizationCard + onboardingCard + visibleDepthCard + connectionCard + capabilityCard + boundaryCard + evidenceLegend + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 36) } - .padding() + .scrollContentBackground(.hidden) } - .navigationTitle("RuView NLOS") - .background(Color(uiColor: .systemGroupedBackground)) + .toolbar(.hidden, for: .navigationBar) + .tint(RuViewTheme.cyan) + .preferredColorScheme(.dark) .onChange(of: scenePhase) { phase in if phase != .active { model.suspendForPrivacy() @@ -38,24 +46,223 @@ struct ContentView: View { } } - private var onboardingCard: some View { - card(title: "Beta tester setup") { - Label("Validate your iPhone's public ARKit LiDAR capabilities before opening the NLOS monitor.", systemImage: "iphone.gen3.radiowaves.left.and.right") - .font(.subheadline) - Text("This test measures only visible surfaces. Every result is labeled direct_depth and is never presented as around the corner evidence.") - .font(.caption.bold()) - .foregroundStyle(.orange) - Link(destination: URL(string: "https://ruview-nlos.ruv.chatgpt.site")!) { - Label("Open the visual explainer", systemImage: "safari") + private var instrumentHeader: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 7) { + Text("RUVIEW / MOBILE FIELD LAB") + .font(.caption.bold().monospaced()) + .tracking(1.6) + .foregroundStyle(RuViewTheme.muted) + HStack(spacing: 7) { + Circle() + .fill(evidenceState.color) + .frame(width: 8, height: 8) + .shadow(color: evidenceState.color.opacity(0.8), radius: 5) + Text(evidenceState.label) + .font(.caption.bold().monospaced()) + .foregroundStyle(evidenceState.color) + .lineLimit(2) + } + } + + Spacer(minLength: 4) + OrbitalSignatureView(accent: evidenceState.color) + .frame(width: 78, height: 78) + .accessibilityHidden(true) } - Link(destination: URL(string: "https://github.com/ruvnet/RuView/issues/1690")!) { - Label("Read the test guide and provide feedback", systemImage: "bubble.left.and.exclamationmark.bubble.right") + + VStack(alignment: .leading, spacing: 7) { + Text("NLOS FIELD") + .foregroundStyle(.white) + Text("MONITOR") + .foregroundStyle( + LinearGradient( + colors: [RuViewTheme.cyan, RuViewTheme.green], + startPoint: .leading, + endPoint: .trailing + ) + ) + } + .font(.system(size: heroFontSize, weight: .black, design: .rounded)) + .tracking(-1.4) + .minimumScaleFactor(0.72) + .accessibilityElement(children: .combine) + + Text("Review authenticated evidence, sensor provenance, and privacy controls in one fail closed instrument.") + .font(.subheadline) + .foregroundStyle(RuViewTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 10) { + headerMetric( + eyebrow: "STREAM", + value: connectionLabel, + color: connectionColor + ) + headerMetric( + eyebrow: "EVIDENCE", + value: evidenceLabel, + color: evidenceState.color + ) + } + + HStack(spacing: 8) { + Image(systemName: "lock.shield.fill") + .foregroundStyle(RuViewTheme.green) + Text("PRIVACY GUARD ACTIVE") + .font(.caption.bold().monospaced()) + .tracking(0.7) + Spacer() + Text("FAIL CLOSED") + .font(.caption2.bold().monospaced()) + .foregroundStyle(RuViewTheme.muted) + } + .padding(.horizontal, 12) + .frame(minHeight: 44) + .background(RuViewTheme.panelStrong, in: Capsule()) + .overlay { + Capsule().stroke(RuViewTheme.green.opacity(0.3), lineWidth: 1) + } + .accessibilityElement(children: .combine) + } + .padding(.top, 4) + } + + private var statusCard: some View { + instrumentCard(eyebrow: "01 / EVIDENCE", title: "Current signal state", accent: evidenceState.color) { + HStack(alignment: .top, spacing: 12) { + ZStack { + Circle() + .fill(evidenceState.color.opacity(0.14)) + .frame(width: 48, height: 48) + Image(systemName: evidenceState.icon) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(evidenceState.color) + } + + VStack(alignment: .leading, spacing: 4) { + Text(evidenceState.label) + .font(.headline.bold().monospaced()) + .foregroundStyle(evidenceState.color) + Text(evidenceState.detail) + .font(.caption) + .foregroundStyle(RuViewTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + + Text(model.statusMessage) + .font(.subheadline) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RuViewTheme.surface, in: RoundedRectangle(cornerRadius: 12)) + + if let frame = model.frame { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + frameBadges(frame) + } + VStack(alignment: .leading, spacing: 8) { + frameBadges(frame) + } + } + .accessibilityElement(children: .combine) + + VStack(spacing: 0) { + provenanceRow("SENSOR", frame.provenance.sensorModel) + provenanceRow("TRANSIENT", frame.provenance.transientKind.rawValue) + provenanceRow("HISTOGRAM", frame.provenance.histogramPreserved ? "preserved" : "not preserved") + provenanceRow("ALGORITHM", frame.algorithmVersion, isLast: true) + } + .background(RuViewTheme.surface, in: RoundedRectangle(cornerRadius: 12)) } } } + private var visualizationCard: some View { + instrumentCard(eyebrow: "02 / SPATIAL", title: "Hidden target hypotheses", accent: RuViewTheme.cyan) { + Text("Only validated, fresh tracks are shown. Uncertainty rings represent the reported position covariance.") + .font(.caption) + .foregroundStyle(RuViewTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + ZStack { + TrackCanvas(tracks: displayableTracks) + .frame(height: 310) + .privacySensitive() + + if let watermark = model.frame?.watermark { + Text(watermark) + .font(.system(size: 35, weight: .black, design: .rounded)) + .tracking(2) + .foregroundStyle(RuViewTheme.orange.opacity(0.48)) + .rotationEffect(.degrees(-18)) + .accessibilityLabel("\(watermark.capitalized) evidence watermark") + } + + if displayableTracks.isEmpty { + VStack(spacing: 8) { + Image(systemName: "scope") + .font(.title2) + Text("NO DISPLAYABLE TRACKS") + .font(.caption.bold().monospaced()) + } + .foregroundStyle(RuViewTheme.muted) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(RuViewTheme.background.opacity(0.88), in: Capsule()) + .overlay { + Capsule().stroke(RuViewTheme.border, lineWidth: 1) + } + } + } + + ForEach(displayableTracks) { track in + trackRow(track) + } + } + .privacySensitive() + } + + private var onboardingCard: some View { + instrumentCard(eyebrow: "03 / BETA", title: "Tester flight plan", accent: RuViewTheme.green) { + Label { + Text("Validate your iPhone's public ARKit LiDAR capabilities before opening the NLOS monitor.") + } icon: { + Image(systemName: "iphone.gen3.radiowaves.left.and.right") + .foregroundStyle(RuViewTheme.green) + } + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + + boundaryNotice( + "This test measures visible surfaces only. Every result is labeled DIRECT_DEPTH and is never presented as around the corner evidence.", + color: RuViewTheme.orange + ) + + Link(destination: URL(string: "https://ruview-nlos.ruv.chatgpt.site")!) { + Label("Open the visual explainer", systemImage: "safari") + .frame(maxWidth: .infinity) + } + .buttonStyle(InstrumentPrimaryButtonStyle(accent: RuViewTheme.green)) + .accessibilityHint("Opens the RuView NLOS explainer in your browser") + + Link(destination: URL(string: "https://github.com/ruvnet/RuView/issues/1690")!) { + Label("Test guide and feedback", systemImage: "bubble.left.and.exclamationmark.bubble.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(InstrumentSecondaryButtonStyle(accent: RuViewTheme.green)) + .accessibilityHint("Opens issue 1690 on GitHub") + } + } + private var visibleDepthCard: some View { - card(title: "Visible depth validation") { + instrumentCard(eyebrow: "04 / DEVICE", title: "Visible depth validation", accent: RuViewTheme.orange) { VisibleDepthValidationView( session: model.visibleDepthSession, exportConsent: $exportConsent, @@ -69,228 +276,442 @@ struct ContentView: View { } private var connectionCard: some View { - card(title: "Authenticated stream") { - TextField("wss://host.example/api/v1/nlos/ws", text: $model.endpointText) + instrumentCard(eyebrow: "05 / TRANSPORT", title: "Authenticated stream", accent: RuViewTheme.cyan) { + VStack(alignment: .leading, spacing: 6) { + Text("SECURE ENDPOINT") + .font(.caption2.bold().monospaced()) + .tracking(0.8) + .foregroundStyle(RuViewTheme.muted) + TextField("wss://host.example/api/v1/nlos/ws", text: $model.endpointText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .textContentType(.URL) + .instrumentInput() + .accessibilityLabel("Secure WebSocket endpoint") + } + + VStack(alignment: .leading, spacing: 6) { + Text("PAIRING TOKEN") + .font(.caption2.bold().monospaced()) + .tracking(0.8) + .foregroundStyle(RuViewTheme.muted) + SecureField( + model.storedTokenAvailable ? "Token stored in Keychain" : "Pairing token", + text: $model.pairingToken + ) .textInputAutocapitalization(.never) .autocorrectionDisabled() - .keyboardType(.URL) - .textContentType(.URL) - .padding(12) - .background(Color(uiColor: .tertiarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .textContentType(.password) + .instrumentInput() + .accessibilityLabel("Pairing token") + } - SecureField( - model.storedTokenAvailable ? "Pairing token stored in Keychain" : "Pairing token", - text: $model.pairingToken - ) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .textContentType(.password) - .padding(12) - .background(Color(uiColor: .tertiarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - - HStack { - Button(model.isConnected ? "Reconnect" : "Connect") { + VStack(spacing: 10) { + Button(model.isConnected ? "Reconnect secure stream" : "Connect secure stream") { model.connect() } - .buttonStyle(.borderedProminent) + .buttonStyle(InstrumentPrimaryButtonStyle(accent: RuViewTheme.cyan)) if model.isConnected { Button("Disconnect", role: .cancel) { model.disconnect() } - .buttonStyle(.bordered) + .buttonStyle(InstrumentSecondaryButtonStyle(accent: RuViewTheme.cyan)) } - Spacer() - if model.storedTokenAvailable { - Button("Forget token", role: .destructive) { + Button("Forget stored token", role: .destructive) { model.forgetPairingToken() } - .font(.caption) + .frame(minHeight: 44) + .font(.caption.bold().monospaced()) + .foregroundStyle(RuViewTheme.red) } } Label( - "Bearer token stays in this device's Keychain and is sent only over wss.", + "The bearer token stays in this device's Keychain and is sent only over wss.", systemImage: "lock.shield" ) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(RuViewTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) } } - private var statusCard: some View { - card(title: "Evidence status") { - HStack(alignment: .top) { - Circle() - .fill(statusColor) - .frame(width: 10, height: 10) - .padding(.top, 4) - Text(model.statusMessage) - .font(.subheadline) - Spacer() - } - - if let frame = model.frame { - HStack(spacing: 8) { - badge(frame.source.rawValue.uppercased(), color: sourceColor(frame.source)) - badge(frame.evidenceLevel.rawValue.uppercased(), color: .indigo) - badge("SEQ \(frame.sequence)", color: .gray) - } - .accessibilityElement(children: .combine) - - VStack(alignment: .leading, spacing: 4) { - Text("Sensor: \(frame.provenance.sensorModel)") - Text("Transient: \(frame.provenance.transientKind.rawValue)") - Text("Histogram preserved: \(frame.provenance.histogramPreserved ? "yes" : "no")") - Text("Algorithm: \(frame.algorithmVersion)") - } - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } - } - } - - private var visualizationCard: some View { - card(title: "Validated hidden target hypotheses") { - ZStack { - TrackCanvas(tracks: model.tracks) - .frame(height: 300) - .privacySensitive() - - if let watermark = model.frame?.watermark { - Text(watermark) - .font(.system(size: 38, weight: .black, design: .rounded)) - .foregroundStyle(.orange.opacity(0.42)) - .rotationEffect(.degrees(-18)) - .accessibilityLabel("Synthetic evidence watermark") - } - - if model.tracks.isEmpty { - Text("NO DISPLAYABLE TRACKS") - .font(.caption.bold().monospaced()) - .foregroundStyle(.secondary) - .padding(10) - .background(.ultraThinMaterial, in: Capsule()) - } - } - - ForEach(model.tracks) { track in - HStack { - VStack(alignment: .leading) { - Text(track.trackId) - .font(.subheadline.monospaced()) - .lineLimit(1) - Text(String( - format: "x %.2f y %.2f z %.2f m", - track.positionM.x, - track.positionM.y, - track.positionM.z - )) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - Spacer() - Text("\(Int(track.confidence * 100))%") - .font(.headline.monospacedDigit()) - badge(track.state.rawValue.uppercased(), color: track.state == .degraded ? .orange : .cyan) - } - .accessibilityElement(children: .combine) - } - } - .privacySensitive() - } - private var capabilityCard: some View { - card(title: "Apple capability probe") { - capabilityRow("ARKit scene depth", model.capabilities.sceneDepth) - capabilityRow("ARKit smoothed depth", model.capabilities.smoothedSceneDepth) - capabilityRow("ARKit scene mesh", model.capabilities.sceneMesh) - capabilityRow("ARKit world pose", model.capabilities.worldPose) - capabilityRow("Raw photon histograms", model.capabilities.rawPhotonHistograms) + instrumentCard(eyebrow: "06 / CAPABILITY", title: "Apple capability probe", accent: RuViewTheme.indigo) { + VStack(spacing: 0) { + capabilityRow("ARKit scene depth", model.capabilities.sceneDepth) + capabilityRow("ARKit smoothed depth", model.capabilities.smoothedSceneDepth) + capabilityRow("ARKit scene mesh", model.capabilities.sceneMesh) + capabilityRow("ARKit world pose", model.capabilities.worldPose) + capabilityRow("Raw photon histograms", model.capabilities.rawPhotonHistograms, isLast: true) + } + .background(RuViewTheme.surface, in: RoundedRectangle(cornerRadius: 12)) Text(model.capabilities.rawPhotonHistogramReason) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(RuViewTheme.textSecondary) .fixedSize(horizontal: false, vertical: true) } } private var boundaryCard: some View { - card(title: "Interpretation boundary") { - Label( + instrumentCard(eyebrow: "07 / BOUNDARY", title: "Interpretation boundary", accent: RuViewTheme.orange) { + boundaryNotice( "This client visualizes validated NLOS output produced by an external transient histogram pipeline.", - systemImage: "waveform.path.ecg.rectangle" + color: RuViewTheme.cyan, + icon: "waveform.path.ecg.rectangle" ) - Label( - "ARKit depth, mesh, and pose are useful context, but this app never labels them as optical NLOS evidence.", - systemImage: "exclamationmark.shield" + boundaryNotice( + "ARKit depth, mesh, and pose are context only. The app never labels them as optical NLOS evidence.", + color: RuViewTheme.orange, + icon: "exclamationmark.shield" ) Text("Unknown, stale, malformed, replayed, oversized, or unauthenticated input is hidden by default.") .font(.caption.bold()) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) } - .font(.subheadline) } - private var statusColor: Color { - switch model.connectionState { - case .connected: return .green - case .connecting: return .yellow - case .blocked: return .red - case .disconnected: return .secondary + private var evidenceLegend: some View { + VStack(alignment: .leading, spacing: 10) { + Text("EVIDENCE LEGEND") + .font(.caption2.bold().monospaced()) + .tracking(1.2) + .foregroundStyle(RuViewTheme.muted) + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + legendItem("VERIFIED", RuViewTheme.green) + legendItem("UNVERIFIED", RuViewTheme.yellow) + legendItem("SYNTHETIC", RuViewTheme.orange) + } + VStack(alignment: .leading, spacing: 8) { + legendItem("VERIFIED", RuViewTheme.green) + legendItem("UNVERIFIED", RuViewTheme.yellow) + legendItem("SYNTHETIC", RuViewTheme.orange) + } + } + Text("A label describes evidence provenance, not certainty about the physical world.") + .font(.caption2) + .foregroundStyle(RuViewTheme.muted) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + .accessibilityElement(children: .combine) + } + + private var evidenceState: EvidencePresentation { + switch model.connectionState { + case .disconnected: + return EvidencePresentation( + label: "DISCONNECTED", + detail: "No authenticated transport is active and no track evidence is displayed.", + color: RuViewTheme.muted, + icon: "antenna.radiowaves.left.and.right.slash" + ) + case .connecting: + return EvidencePresentation( + label: "LIVE UNVERIFIED", + detail: "The secure transport is opening. No evidence is accepted yet.", + color: RuViewTheme.yellow, + icon: "ellipsis" + ) + case .blocked: + let isStale = model.statusMessage.localizedCaseInsensitiveContains("stale") + || model.statusMessage.localizedCaseInsensitiveContains("expired") + return EvidencePresentation( + label: isStale ? "STALE" : (model.isConnected ? "LIVE UNVERIFIED" : "DISCONNECTED"), + detail: "Input failed validation and has been hidden. Reconnect only after resolving the reported cause.", + color: RuViewTheme.red, + icon: "exclamationmark.octagon.fill" + ) + case .connected: + guard let frame = model.frame else { + return EvidencePresentation( + label: "LIVE UNVERIFIED", + detail: "Transport is authenticated, but no validated evidence frame is available.", + color: RuViewTheme.yellow, + icon: "shield.lefthalf.filled" + ) + } + switch frame.source { + case .synthetic: + return EvidencePresentation( + label: "SYNTHETIC", + detail: "Generated evidence is accepted only at L0 and remains visibly watermarked.", + color: RuViewTheme.orange, + icon: "testtube.2" + ) + case .replay: + return EvidencePresentation( + label: "DISCONNECTED", + detail: "Replay provenance is retained below, but recorded tracks are withheld from the live evidence surface.", + color: RuViewTheme.muted, + icon: "arrow.counterclockwise.circle.fill" + ) + case .live: + if frame.evidenceLevel >= .l2Calibrated { + return EvidencePresentation( + label: "LIVE VERIFIED", + detail: "A fresh calibrated live frame passed the fail closed validator.", + color: RuViewTheme.green, + icon: "checkmark.shield.fill" + ) + } + return EvidencePresentation( + label: "LIVE UNVERIFIED", + detail: "A fresh measured live frame passed validation but is not calibrated.", + color: RuViewTheme.yellow, + icon: "shield.lefthalf.filled" + ) + } + } + } + + private var displayableTracks: [NLOSTrack] { + guard model.connectionState == .connected, let frame = model.frame else { + return [] + } + switch frame.source { + case .synthetic: + return model.tracks + case .live where frame.evidenceLevel >= .l2Calibrated: + return model.tracks + case .live, .replay: + return [] + } + } + + private var connectionLabel: String { + switch model.connectionState { + case .disconnected: return "OFFLINE" + case .connecting: return "OPENING" + case .connected: return "SECURE" + case .blocked: return "BLOCKED" + } + } + + private var connectionColor: Color { + switch model.connectionState { + case .disconnected: return RuViewTheme.muted + case .connecting: return RuViewTheme.yellow + case .connected: return RuViewTheme.green + case .blocked: return RuViewTheme.red + } + } + + private var evidenceLabel: String { + guard let frame = model.frame else { return "NONE" } + return frame.evidenceLevel.rawValue.uppercased() + } + + @ViewBuilder + private func frameBadges(_ frame: TrackDisplayFrame) -> some View { + badge(frame.source.rawValue.uppercased(), color: sourceColor(frame.source)) + badge(frame.evidenceLevel.rawValue.uppercased(), color: evidenceState.color) + badge("SEQ \(frame.sequence)", color: RuViewTheme.muted) } private func sourceColor(_ source: NLOSSource) -> Color { switch source { - case .live: return .green - case .replay: return .blue - case .synthetic: return .orange + case .live: return RuViewTheme.cyan + case .replay: return RuViewTheme.indigo + case .synthetic: return RuViewTheme.orange } } + private func headerMetric(eyebrow: String, value: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(eyebrow) + .font(.caption2.bold().monospaced()) + .tracking(0.8) + .foregroundStyle(RuViewTheme.muted) + Text(value) + .font(.caption.bold().monospaced()) + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .padding(12) + .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + .background(RuViewTheme.panel, in: RoundedRectangle(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke(color.opacity(0.3), lineWidth: 1) + } + .accessibilityElement(children: .combine) + } + + private func trackRow(_ track: NLOSTrack) -> some View { + HStack(spacing: 12) { + Circle() + .fill(track.state == .degraded ? RuViewTheme.orange : RuViewTheme.cyan) + .frame(width: 8, height: 8) + .shadow( + color: (track.state == .degraded ? RuViewTheme.orange : RuViewTheme.cyan).opacity(0.8), + radius: 4 + ) + VStack(alignment: .leading, spacing: 4) { + Text(track.trackId) + .font(.subheadline.bold().monospaced()) + .lineLimit(1) + Text(String( + format: "x %.2f y %.2f z %.2f m", + track.positionM.x, + track.positionM.y, + track.positionM.z + )) + .font(.caption.monospacedDigit()) + .foregroundStyle(RuViewTheme.textSecondary) + } + Spacer(minLength: 4) + VStack(alignment: .trailing, spacing: 4) { + Text("\(Int(track.confidence * 100))%") + .font(.headline.monospacedDigit()) + badge( + track.state.rawValue.uppercased(), + color: track.state == .degraded ? RuViewTheme.orange : RuViewTheme.cyan + ) + } + } + .padding(12) + .background(RuViewTheme.surface, in: RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "Track \(track.trackId), \(track.state.rawValue), confidence \(Int(track.confidence * 100)) percent, position x \(track.positionM.x) y \(track.positionM.y) z \(track.positionM.z) meters" + ) + } + private func capabilityRow( _ title: String, - _ availability: AppleCapabilityAvailability + _ availability: AppleCapabilityAvailability, + isLast: Bool = false ) -> some View { - HStack { - Text(title) - Spacer() - Label( - availability.rawValue.capitalized, - systemImage: availability == .available ? "checkmark.circle.fill" : "xmark.circle.fill" - ) - .foregroundStyle(availability == .available ? .green : .secondary) + VStack(spacing: 0) { + HStack(spacing: 12) { + Text(title) + .font(.subheadline) + Spacer() + Label( + availability.rawValue.capitalized, + systemImage: availability == .available ? "checkmark.circle.fill" : "xmark.circle.fill" + ) + .font(.caption.bold().monospaced()) + .foregroundStyle(availability == .available ? RuViewTheme.green : RuViewTheme.muted) + } + .padding(.horizontal, 12) + .frame(minHeight: 48) + if !isLast { + Rectangle() + .fill(RuViewTheme.border) + .frame(height: 1) + .padding(.leading, 12) + } + } + .accessibilityElement(children: .combine) + } + + private func provenanceRow(_ label: String, _ value: String, isLast: Bool = false) -> some View { + VStack(spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(label) + .font(.caption2.bold().monospaced()) + .tracking(0.6) + .foregroundStyle(RuViewTheme.muted) + Spacer() + Text(value) + .font(.caption.monospaced()) + .foregroundStyle(.white) + .multilineTextAlignment(.trailing) + } + .padding(.horizontal, 12) + .frame(minHeight: 44) + if !isLast { + Rectangle() + .fill(RuViewTheme.border) + .frame(height: 1) + .padding(.leading, 12) + } + } + .accessibilityElement(children: .combine) + } + + private func boundaryNotice(_ text: String, color: Color, icon: String = "exclamationmark.shield") -> some View { + Label { + Text(text) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: icon) + .foregroundStyle(color) + } + .font(.caption) + .foregroundStyle(RuViewTheme.textSecondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(color.opacity(0.07), in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(color.opacity(0.24), lineWidth: 1) + } + } + + private func legendItem(_ label: String, _ color: Color) -> some View { + HStack(spacing: 5) { + Circle().fill(color).frame(width: 6, height: 6) + Text(label) + .font(.caption2.bold().monospaced()) + .foregroundStyle(RuViewTheme.textSecondary) } - .font(.subheadline) } private func badge(_ text: String, color: Color) -> some View { Text(text) .font(.caption2.bold().monospaced()) .lineLimit(1) + .minimumScaleFactor(0.72) .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(color.opacity(0.14), in: Capsule()) + .frame(minHeight: 28) + .background(color.opacity(0.12), in: Capsule()) + .overlay { + Capsule().stroke(color.opacity(0.25), lineWidth: 1) + } .foregroundStyle(color) } - private func card( + private func instrumentCard( + eyebrow: String, title: String, + accent: Color, @ViewBuilder content: () -> Content ) -> some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + Rectangle() + .fill(accent) + .frame(width: 22, height: 2) + .shadow(color: accent.opacity(0.8), radius: 4) + Text(eyebrow) + .font(.caption2.bold().monospaced()) + .tracking(1.1) + .foregroundStyle(accent) + } Text(title) - .font(.headline) + .font(.title3.bold()) + .foregroundStyle(.white) content() } - .padding() + .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 18)) + .background(RuViewTheme.panel, in: RoundedRectangle(cornerRadius: 20)) + .overlay { + RoundedRectangle(cornerRadius: 20) + .stroke(RuViewTheme.border, lineWidth: 1) + } + .shadow(color: accent.opacity(0.06), radius: 18, y: 8) } } @@ -305,33 +726,34 @@ private struct VisibleDepthValidationView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - HStack { - Text("DIRECT_DEPTH") - .font(.caption.bold().monospaced()) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.orange.opacity(0.15), in: Capsule()) - .foregroundStyle(.orange) + HStack(spacing: 8) { + statusBadge("DIRECT_DEPTH", color: RuViewTheme.orange) Spacer() Text(phaseLabel) .font(.caption.bold().monospacedDigit()) + .foregroundStyle(phaseColor) } Text(session.statusMessage) .font(.subheadline) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) if isRunning { ProgressView(value: phaseProgress) + .tint(RuViewTheme.orange) + .accessibilityLabel("Validation phase progress") + .accessibilityValue("\(Int(phaseProgress * 100)) percent") LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { metric("FPS", String(format: "%.1f", session.metrics.fps)) - metric("Depth coverage", "\(Int(session.metrics.depthCoverage * 100))%") - metric("Tracking", session.metrics.trackingState) - metric("Movement", String(format: "%.3f m/s", session.metrics.movementMetersPerSecond)) - metric("Thermal", session.metrics.thermalState) - metric("Remaining", "\(session.metrics.phaseSecondsRemaining)s") + metric("DEPTH COVERAGE", "\(Int(session.metrics.depthCoverage * 100))%") + metric("TRACKING", session.metrics.trackingState) + metric("MOVEMENT", String(format: "%.3f m/s", session.metrics.movementMetersPerSecond)) + metric("THERMAL", session.metrics.thermalState) + metric("REMAINING", "\(session.metrics.phaseSecondsRemaining)s") } Button("Cancel validation", role: .cancel, action: cancel) - .buttonStyle(.bordered) + .buttonStyle(InstrumentSecondaryButtonStyle(accent: RuViewTheme.orange)) } else { Button { exportConsent = false @@ -339,22 +761,30 @@ private struct VisibleDepthValidationView: View { exportError = nil start() } label: { - Label(session.diagnostic == nil ? "Start 45 second validation" : "Run again", systemImage: "sensor.tag.radiowaves.forward") + Label( + session.diagnostic == nil ? "Start 45 second validation" : "Run validation again", + systemImage: "sensor.tag.radiowaves.forward" + ) + .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .buttonStyle(InstrumentPrimaryButtonStyle(accent: RuViewTheme.orange)) } if session.diagnostic != nil { - Divider() + Rectangle() + .fill(RuViewTheme.border) + .frame(height: 1) diagnosticPreview Toggle(isOn: $exportConsent) { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { Text("I choose to export aggregate diagnostics") + .font(.subheadline) Text("The JSON contains no images, raw depth, endpoint, token, or raw samples.") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(RuViewTheme.textSecondary) } } + .tint(RuViewTheme.green) .onChange(of: exportConsent) { consent in if !consent { diagnosticURL = nil } } @@ -362,8 +792,9 @@ private struct VisibleDepthValidationView: View { if let diagnosticURL { ShareLink(item: diagnosticURL) { Label("Share diagnostic JSON", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .buttonStyle(InstrumentPrimaryButtonStyle(accent: RuViewTheme.green)) } else { Button("Prepare local JSON") { do { @@ -373,14 +804,22 @@ private struct VisibleDepthValidationView: View { exportError = error.localizedDescription } } - .buttonStyle(.bordered) + .buttonStyle(InstrumentSecondaryButtonStyle(accent: RuViewTheme.green)) .disabled(!exportConsent) + .opacity(exportConsent ? 1 : 0.45) } if let exportError { - Text(exportError).font(.caption).foregroundStyle(.red) + Text(exportError) + .font(.caption) + .foregroundStyle(RuViewTheme.red) + .accessibilityLabel("Export error: \(exportError)") } - Link("Open issue 1690 to submit feedback", destination: URL(string: "https://github.com/ruvnet/RuView/issues/1690")!) - .font(.caption) + Link( + "Open issue 1690 to submit feedback", + destination: URL(string: "https://github.com/ruvnet/RuView/issues/1690")! + ) + .font(.caption.bold()) + .frame(minHeight: 44) } } } @@ -401,27 +840,46 @@ private struct VisibleDepthValidationView: View { } } + private var phaseColor: Color { + switch session.state { + case .completed: return RuViewTheme.green + case .failed: return RuViewTheme.red + case .cancelled: return RuViewTheme.muted + default: return RuViewTheme.orange + } + } + private var phaseProgress: Double { let total = session.state == .calibration ? 15.0 : 30.0 return max(0, min(1, (total - Double(session.metrics.phaseSecondsRemaining)) / total)) } private func metric(_ title: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.caption).foregroundStyle(.secondary) - Text(value).font(.subheadline.bold().monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption2.bold().monospaced()) + .foregroundStyle(RuViewTheme.muted) + Text(value) + .font(.subheadline.bold().monospacedDigit()) + .lineLimit(1) + .minimumScaleFactor(0.65) } .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(uiColor: .tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) + .frame(maxWidth: .infinity, minHeight: 66, alignment: .leading) + .background(RuViewTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10).stroke(RuViewTheme.border, lineWidth: 1) + } + .accessibilityElement(children: .combine) } @ViewBuilder private var diagnosticPreview: some View { if let diagnostic = session.diagnostic { - VStack(alignment: .leading, spacing: 4) { - Text("Diagnostic preview") - .font(.subheadline.bold()) + VStack(alignment: .leading, spacing: 6) { + Text("DIAGNOSTIC PREVIEW") + .font(.caption.bold().monospaced()) + .foregroundStyle(RuViewTheme.green) Text("Evidence: \(diagnostic.evidenceLabel.rawValue)") Text("Physical NLOS: \(diagnostic.physicalNLOSStatus)") Text("Permission: \(diagnostic.cameraPermission)") @@ -432,10 +890,173 @@ private struct VisibleDepthValidationView: View { Text("Raw sensor export: false") } .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .padding(10) + .foregroundStyle(RuViewTheme.textSecondary) + .padding(12) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(uiColor: .tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) + .background(RuViewTheme.surface, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12).stroke(RuViewTheme.green.opacity(0.22), lineWidth: 1) + } + .accessibilityElement(children: .combine) + } + } + + private func statusBadge(_ text: String, color: Color) -> some View { + Text(text) + .font(.caption2.bold().monospaced()) + .padding(.horizontal, 9) + .frame(minHeight: 28) + .background(color.opacity(0.12), in: Capsule()) + .overlay { + Capsule().stroke(color.opacity(0.3), lineWidth: 1) + } + .foregroundStyle(color) + } +} + +private struct EvidencePresentation { + let label: String + let detail: String + let color: Color + let icon: String +} + +private enum RuViewTheme { + static let background = Color(red: 0.020, green: 0.035, blue: 0.051) + static let panel = Color(red: 0.036, green: 0.061, blue: 0.078) + static let panelStrong = Color(red: 0.049, green: 0.082, blue: 0.102) + static let surface = Color(red: 0.050, green: 0.082, blue: 0.102) + static let cyan = Color(red: 0.129, green: 0.831, blue: 0.906) + static let green = Color(red: 0.361, green: 1.000, blue: 0.561) + static let orange = Color(red: 1.000, green: 0.612, blue: 0.231) + static let yellow = Color(red: 1.000, green: 0.827, blue: 0.318) + static let red = Color(red: 1.000, green: 0.353, blue: 0.384) + static let indigo = Color(red: 0.490, green: 0.584, blue: 1.000) + static let muted = Color(red: 0.494, green: 0.596, blue: 0.651) + static let textSecondary = Color(red: 0.665, green: 0.733, blue: 0.769) + static let border = cyan.opacity(0.18) +} + +private struct InstrumentGrid: View { + var body: some View { + Canvas { context, size in + var grid = Path() + let spacing: CGFloat = 28 + var x: CGFloat = 0 + while x <= size.width { + grid.move(to: CGPoint(x: x, y: 0)) + grid.addLine(to: CGPoint(x: x, y: size.height)) + x += spacing + } + var y: CGFloat = 0 + while y <= size.height { + grid.move(to: CGPoint(x: 0, y: y)) + grid.addLine(to: CGPoint(x: size.width, y: y)) + y += spacing + } + context.stroke(grid, with: .color(RuViewTheme.cyan.opacity(0.045)), lineWidth: 0.5) + + var topLine = Path() + topLine.move(to: CGPoint(x: 0, y: 1)) + topLine.addLine(to: CGPoint(x: size.width, y: 1)) + context.stroke(topLine, with: .color(RuViewTheme.cyan.opacity(0.8)), lineWidth: 1) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + .ignoresSafeArea() + } +} + +private struct OrbitalSignatureView: View { + let accent: Color + + var body: some View { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let rings: [CGFloat] = [0.31, 0.55, 0.82] + for (index, scale) in rings.enumerated() { + let diameter = min(size.width, size.height) * scale + let rect = CGRect( + x: center.x - diameter / 2, + y: center.y - diameter / 2, + width: diameter, + height: diameter + ) + context.stroke( + Path(ellipseIn: rect), + with: .color(accent.opacity(index == rings.count - 1 ? 0.2 : 0.36)), + lineWidth: 1 + ) + } + + var crosshair = Path() + crosshair.move(to: CGPoint(x: center.x, y: 5)) + crosshair.addLine(to: CGPoint(x: center.x, y: size.height - 5)) + crosshair.move(to: CGPoint(x: 5, y: center.y)) + crosshair.addLine(to: CGPoint(x: size.width - 5, y: center.y)) + context.stroke(crosshair, with: .color(accent.opacity(0.2)), lineWidth: 0.5) + + context.fill( + Path(ellipseIn: CGRect(x: center.x - 3, y: center.y - 3, width: 6, height: 6)), + with: .color(accent) + ) + context.fill( + Path(ellipseIn: CGRect(x: center.x + 21, y: center.y - 20, width: 5, height: 5)), + with: .color(RuViewTheme.green) + ) } } } + +private struct InstrumentInputModifier: ViewModifier { + func body(content: Content) -> some View { + content + .font(.body.monospaced()) + .padding(.horizontal, 12) + .frame(minHeight: 50) + .background(RuViewTheme.surface, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(RuViewTheme.border, lineWidth: 1) + } + } +} + +private extension View { + func instrumentInput() -> some View { + modifier(InstrumentInputModifier()) + } +} + +private struct InstrumentPrimaryButtonStyle: ButtonStyle { + let accent: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.subheadline.bold()) + .foregroundStyle(RuViewTheme.background) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity, minHeight: 48) + .background(accent.opacity(configuration.isPressed ? 0.72 : 1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: accent.opacity(configuration.isPressed ? 0.08 : 0.22), radius: 10, y: 4) + } +} + +private struct InstrumentSecondaryButtonStyle: ButtonStyle { + let accent: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.subheadline.bold()) + .foregroundStyle(accent) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity, minHeight: 48) + .background(accent.opacity(configuration.isPressed ? 0.12 : 0.06)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(accent.opacity(0.42), lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/ui/ios-nlos/App/TrackCanvas.swift b/ui/ios-nlos/App/TrackCanvas.swift index aa0cd6e8..d7f7560e 100644 --- a/ui/ios-nlos/App/TrackCanvas.swift +++ b/ui/ios-nlos/App/TrackCanvas.swift @@ -7,75 +7,148 @@ struct TrackCanvas: View { var body: some View { Canvas { context, size in + var background = Path() + background.addRect(CGRect(origin: .zero, size: size)) + context.fill( + background, + with: .color(Color(red: 0.014, green: 0.029, blue: 0.043)) + ) drawGrid(context: &context, size: size) + drawRadar(context: &context, size: size) + let radiusMeters = max( 5, min(100, tracks.flatMap { [abs($0.positionM.x), abs($0.positionM.z)] }.max() ?? 5) ) for track in tracks { - let point = CGPoint( - x: size.width / 2 + CGFloat(track.positionM.x / radiusMeters) * size.width * 0.45, - y: size.height / 2 - CGFloat(track.positionM.z / radiusMeters) * size.height * 0.45 - ) - let uncertainty = min( - 34, - max(8, CGFloat(sqrt(max(track.covarianceDiagonalM2.x, track.covarianceDiagonalM2.z))) * 14) - ) - let color: Color = track.state == .degraded ? .orange : .cyan - let uncertaintyRect = CGRect( - x: point.x - uncertainty, - y: point.y - uncertainty, - width: uncertainty * 2, - height: uncertainty * 2 - ) - context.stroke( - Path(ellipseIn: uncertaintyRect), - with: .color(color.opacity(0.45)), - lineWidth: 1 - ) - context.fill( - Path(ellipseIn: CGRect(x: point.x - 5, y: point.y - 5, width: 10, height: 10)), - with: .color(color) - ) - context.draw( - Text(String(track.trackId.prefix(12))) - .font(.caption2.monospaced()) - .foregroundColor(.primary), - at: CGPoint(x: point.x, y: point.y + uncertainty + 10) + draw( + track: track, + context: &context, + size: size, + radiusMeters: radiusMeters ) } } - .background(Color(uiColor: .secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay { RoundedRectangle(cornerRadius: 16) - .stroke(Color.secondary.opacity(0.25), lineWidth: 1) + .stroke(Color.cyan.opacity(0.24), lineWidth: 1) } + .shadow(color: Color.cyan.opacity(0.08), radius: 16) .accessibilityHidden(true) } + private func draw( + track: NLOSTrack, + context: inout GraphicsContext, + size: CGSize, + radiusMeters: Double + ) { + let point = CGPoint( + x: size.width / 2 + CGFloat(track.positionM.x / radiusMeters) * size.width * 0.43, + y: size.height / 2 - CGFloat(track.positionM.z / radiusMeters) * size.height * 0.43 + ) + let uncertainty = min( + 36, + max(9, CGFloat(sqrt(max(track.covarianceDiagonalM2.x, track.covarianceDiagonalM2.z))) * 14) + ) + let color: Color = track.state == .degraded + ? Color(red: 1.000, green: 0.612, blue: 0.231) + : Color(red: 0.129, green: 0.831, blue: 0.906) + let uncertaintyRect = CGRect( + x: point.x - uncertainty, + y: point.y - uncertainty, + width: uncertainty * 2, + height: uncertainty * 2 + ) + + context.stroke( + Path(ellipseIn: uncertaintyRect), + with: .color(color.opacity(0.5)), + style: StrokeStyle(lineWidth: 1, dash: [4, 3]) + ) + context.fill( + Path(ellipseIn: CGRect(x: point.x - 11, y: point.y - 11, width: 22, height: 22)), + with: .color(color.opacity(0.12)) + ) + context.fill( + Path(ellipseIn: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8)), + with: .color(color) + ) + + let labelPoint = CGPoint( + x: min(max(point.x, 42), size.width - 42), + y: min(point.y + uncertainty + 14, size.height - 12) + ) + context.draw( + Text(String(track.trackId.prefix(12)).uppercased()) + .font(.caption2.bold().monospaced()) + .foregroundColor(.white), + at: labelPoint + ) + } + private func drawGrid(context: inout GraphicsContext, size: CGSize) { - var path = Path() - for fraction in stride( - from: CGFloat(0.1), - through: CGFloat(0.9), - by: CGFloat(0.1) - ) { - let x = size.width * fraction - let y = size.height * fraction - path.move(to: CGPoint(x: x, y: 0)) - path.addLine(to: CGPoint(x: x, y: size.height)) - path.move(to: CGPoint(x: 0, y: y)) - path.addLine(to: CGPoint(x: size.width, y: y)) + var minorGrid = Path() + let spacing: CGFloat = 24 + var x: CGFloat = 0 + while x <= size.width { + minorGrid.move(to: CGPoint(x: x, y: 0)) + minorGrid.addLine(to: CGPoint(x: x, y: size.height)) + x += spacing } - context.stroke(path, with: .color(.secondary.opacity(0.12)), lineWidth: 0.5) + var y: CGFloat = 0 + while y <= size.height { + minorGrid.move(to: CGPoint(x: 0, y: y)) + minorGrid.addLine(to: CGPoint(x: size.width, y: y)) + y += spacing + } + context.stroke(minorGrid, with: .color(Color.cyan.opacity(0.065)), lineWidth: 0.5) var axes = Path() axes.move(to: CGPoint(x: size.width / 2, y: 0)) axes.addLine(to: CGPoint(x: size.width / 2, y: size.height)) axes.move(to: CGPoint(x: 0, y: size.height / 2)) axes.addLine(to: CGPoint(x: size.width, y: size.height / 2)) - context.stroke(axes, with: .color(.secondary.opacity(0.5)), lineWidth: 1) + context.stroke(axes, with: .color(Color.cyan.opacity(0.34)), lineWidth: 1) + } + + private func drawRadar(context: inout GraphicsContext, size: CGSize) { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let maximumDiameter = min(size.width, size.height) * 0.86 + for fraction in [0.25, 0.5, 0.75, 1.0] as [CGFloat] { + let diameter = maximumDiameter * fraction + context.stroke( + Path(ellipseIn: CGRect( + x: center.x - diameter / 2, + y: center.y - diameter / 2, + width: diameter, + height: diameter + )), + with: .color(Color.cyan.opacity(fraction == 1 ? 0.2 : 0.12)), + lineWidth: 0.8 + ) + } + + var sweep = Path() + sweep.move(to: center) + sweep.addLine(to: CGPoint( + x: center.x + maximumDiameter * 0.31, + y: center.y - maximumDiameter * 0.31 + )) + context.stroke(sweep, with: .color(Color.green.opacity(0.32)), lineWidth: 1) + + context.fill( + Path(ellipseIn: CGRect(x: center.x - 3, y: center.y - 3, width: 6, height: 6)), + with: .color(Color.green.opacity(0.8)) + ) + + context.draw( + Text("RELAY ORIGIN") + .font(.caption2.bold().monospaced()) + .foregroundColor(Color.white.opacity(0.5)), + at: CGPoint(x: center.x, y: center.y + 17) + ) } } diff --git a/ui/mobile/.gitignore b/ui/mobile/.gitignore index d914c328..521c0b36 100644 --- a/ui/mobile/.gitignore +++ b/ui/mobile/.gitignore @@ -6,6 +6,9 @@ node_modules/ # Expo .expo/ dist/ +dist-e2e/ +test-results/ +playwright-report/ web-build/ expo-env.d.ts diff --git a/ui/mobile/e2e/nlos_mobile_ui.yaml b/ui/mobile/e2e/nlos_mobile_ui.yaml new file mode 100644 index 00000000..b858be63 --- /dev/null +++ b/ui/mobile/e2e/nlos_mobile_ui.yaml @@ -0,0 +1,39 @@ +appId: com.ruvnet.wifidensepose +name: RuView NLOS instrument UI evidence flow +--- +- launchApp: + clearState: true +- tapOn: "NLOS" +- assertVisible: + id: "nlos-evidence-state" +- scrollUntilVisible: + element: + id: "nlos-start-synthetic" + direction: DOWN + timeout: 10000 +- tapOn: + id: "nlos-start-synthetic" +- extendedWaitUntil: + visible: + id: "nlos-synthetic-watermark" + timeout: 5000 +- assertVisible: + id: "nlos-synthetic-watermark" +- assertVisible: + id: "nlos-evidence-state" +- scrollUntilVisible: + element: + id: "nlos-beta-setup" + direction: DOWN + timeout: 10000 +- assertVisible: + id: "nlos-beta-setup" +- scrollUntilVisible: + element: + id: "nlos-feedback-link" + direction: DOWN + timeout: 10000 +- assertVisible: + id: "nlos-feedback-link" +- assertVisible: + id: "nlos-explainer-link" diff --git a/ui/mobile/e2e/web/nlos.mobile.spec.ts b/ui/mobile/e2e/web/nlos.mobile.spec.ts new file mode 100644 index 00000000..32c90b97 --- /dev/null +++ b/ui/mobile/e2e/web/nlos.mobile.spec.ts @@ -0,0 +1,109 @@ +import { expect, test, type Page } from '@playwright/test'; +import { mkdir, readFile, stat } from 'node:fs/promises'; +import { extname, resolve, sep } from 'node:path'; + +const screenshotDirectory = resolve( + process.cwd(), + '../../docs/screenshots/consumer-nlos-mobile-ui', +); +const staticBundleDirectory = process.env.RUVIEW_E2E_STATIC_DIR + ? resolve(process.cwd(), process.env.RUVIEW_E2E_STATIC_DIR) + : null; + +const contentTypes: Record = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', +}; + +test.beforeEach(async ({ page }) => { + if (!staticBundleDirectory) return; + await page.route('http://ruview.test/**', async (route) => { + const pathname = decodeURIComponent(new URL(route.request().url()).pathname); + let localPath = resolve(staticBundleDirectory, `.${pathname}`); + if (!localPath.startsWith(`${staticBundleDirectory}${sep}`) && localPath !== staticBundleDirectory) { + await route.fulfill({ status: 404, body: 'Not found' }); + return; + } + try { + if ((await stat(localPath)).isDirectory()) localPath = resolve(localPath, 'index.html'); + } catch { + localPath = resolve(staticBundleDirectory, 'index.html'); + } + await route.fulfill({ + status: 200, + contentType: contentTypes[extname(localPath)] ?? 'application/octet-stream', + body: await readFile(localPath), + }); + }); +}); + +const openNlos = async (page: Page) => { + await page.goto('/'); + await page.getByText('NLOS', { exact: true }).last().click(); + await expect(page.getByText('RuView NLOS', { exact: true })).toBeVisible(); + await expect(page.getByTestId('nlos-evidence-state')).toBeVisible(); +}; + +const capture = async (page: Page, name: string) => { + await mkdir(screenshotDirectory, { recursive: true }); + await page.screenshot({ + path: resolve(screenshotDirectory, name), + animations: 'disabled', + caret: 'hide', + }); +}; + +test.describe('RuView NLOS mobile instrument UI', () => { + test('captures the disconnected overview without horizontal overflow', async ({ page }) => { + await openNlos(page); + await page.evaluate(() => window.scrollTo(0, 0)); + + const dimensions = await page.evaluate(() => ({ + viewport: window.innerWidth, + content: document.documentElement.scrollWidth, + })); + const nestedDimensions = await page.getByTestId('nlos-scroll-view').evaluate((element) => ({ + viewport: element.clientWidth, + content: element.scrollWidth, + })); + expect(dimensions.viewport).toBe(390); + expect(dimensions.content).toBeLessThanOrEqual(390); + expect(nestedDimensions.content).toBeLessThanOrEqual(nestedDimensions.viewport + 1); + await expect(page.getByTestId('nlos-evidence-state')).toHaveText('DISCONNECTED'); + + await capture(page, 'overview-390x844.png'); + }); + + test('captures governed setup with fixed explainer and feedback controls', async ({ page }) => { + await openNlos(page); + const setup = page.getByTestId('nlos-beta-setup'); + await setup.scrollIntoViewIfNeeded(); + await expect(setup).toBeVisible(); + await expect(page.getByRole('link', { name: 'OPEN EXPLAINER' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'TEST STEPS AND FEEDBACK' })).toBeVisible(); + + await capture(page, 'setup-390x844.png'); + }); + + test('starts synthetic replay and preserves its visible watermark', async ({ page }) => { + await openNlos(page); + const replay = page.getByRole('button', { name: 'USE SYNTHETIC REPLAY' }); + await replay.scrollIntoViewIfNeeded(); + await replay.click(); + + await expect(page.getByTestId('nlos-synthetic-watermark')).toBeVisible(); + await expect(page.getByTestId('nlos-provenance-badge')).toContainText('SYNTHETIC'); + await expect(page.getByTestId('nlos-evidence-state')).toContainText('SYNTHETIC'); + await page.getByTestId('nlos-provenance-panel').evaluate((element) => { + element.scrollIntoView({ behavior: 'auto', block: 'start' }); + }); + + await capture(page, 'synthetic-390x844.png'); + }); +}); diff --git a/ui/mobile/e2e/web/serve.mjs b/ui/mobile/e2e/web/serve.mjs new file mode 100644 index 00000000..0702e6c4 --- /dev/null +++ b/ui/mobile/e2e/web/serve.mjs @@ -0,0 +1,49 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize, resolve } from 'node:path'; + +const host = '127.0.0.1'; +const port = 4173; +const root = resolve(process.cwd(), 'dist-e2e'); + +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +}; + +const resolveRequestPath = (rawUrl) => { + const pathname = decodeURIComponent(new URL(rawUrl ?? '/', `http://${host}`).pathname); + const normalized = normalize(pathname).replace(/^(\.\.[/\\])+/, ''); + const requested = resolve(root, `.${normalized}`); + if (!requested.startsWith(`${root}/`) && requested !== root) return null; + if (existsSync(requested) && statSync(requested).isFile()) return requested; + const nestedIndex = join(requested, 'index.html'); + if (existsSync(nestedIndex) && statSync(nestedIndex).isFile()) return nestedIndex; + return join(root, 'index.html'); +}; + +const server = createServer((request, response) => { + const path = resolveRequestPath(request.url); + if (!path || !existsSync(path)) { + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + response.end('Not found'); + return; + } + + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': contentTypes[extname(path)] ?? 'application/octet-stream', + 'x-content-type-options': 'nosniff', + }); + createReadStream(path).pipe(response); +}); + +server.listen(port, host, () => { + process.stdout.write(`RuView mobile E2E server listening on http://${host}:${port}\n`); +}); diff --git a/ui/mobile/eslint.config.js b/ui/mobile/eslint.config.js index db7937a5..74f2f113 100644 --- a/ui/mobile/eslint.config.js +++ b/ui/mobile/eslint.config.js @@ -9,7 +9,16 @@ const typescriptFiles = ['**/*.{ts,tsx}']; module.exports = [ { - ignores: ['node_modules/**', 'dist/**', '.expo/**', 'coverage/**', 'src/assets/webview/**'], + ignores: [ + 'node_modules/**', + 'dist/**', + 'dist-e2e/**', + '.expo/**', + 'coverage/**', + 'playwright-report/**', + 'test-results/**', + 'src/assets/webview/**', + ], }, { files: sourceFiles, diff --git a/ui/mobile/jest.config.js b/ui/mobile/jest.config.js index 3a39d3d7..a6ea800a 100644 --- a/ui/mobile/jest.config.js +++ b/ui/mobile/jest.config.js @@ -7,7 +7,12 @@ module.exports = { ...(expoPreset.setupFiles || []), ], setupFilesAfterEnv: ['/jest.setup.ts'], - testPathIgnorePatterns: ['/node_modules/', '/__mocks__/', '/src/__tests__/test-utils.tsx'], + testPathIgnorePatterns: [ + '/node_modules/', + '/__mocks__/', + '/e2e/web/', + '/src/__tests__/test-utils.tsx', + ], transformIgnorePatterns: [ 'node_modules/(?!(expo|expo-.+|react-native|@react-native|react-native-webview|react-native-reanimated|react-native-svg|react-native-safe-area-context|react-native-screens|@react-navigation|@expo|@unimodules|expo-modules-core|react-native-worklets)/)', ], diff --git a/ui/mobile/package-lock.json b/ui/mobile/package-lock.json index b7e8a532..672dabda 100644 --- a/ui/mobile/package-lock.json +++ b/ui/mobile/package-lock.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@playwright/test": "1.62.1", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "^13.3.3", "@types/jest": "29.5.14", @@ -2864,6 +2865,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@react-native-async-storage/async-storage": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", @@ -9952,6 +9969,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/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/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", diff --git a/ui/mobile/package.json b/ui/mobile/package.json index 19427906..4893c991 100644 --- a/ui/mobile/package.json +++ b/ui/mobile/package.json @@ -9,7 +9,10 @@ "web": "expo start --web", "test": "jest", "typecheck": "tsc --noEmit", - "lint": "eslint ." + "lint": "eslint .", + "build:web:e2e": "expo export --platform web --output-dir dist-e2e", + "serve:web:e2e": "node e2e/web/serve.mjs", + "e2e:web": "playwright test --config playwright.config.ts" }, "dependencies": { "@expo/vector-icons": "^15.0.2", @@ -37,6 +40,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@playwright/test": "1.62.1", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "^13.3.3", "@types/jest": "29.5.14", diff --git a/ui/mobile/playwright.config.ts b/ui/mobile/playwright.config.ts new file mode 100644 index 00000000..1a67131a --- /dev/null +++ b/ui/mobile/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from '@playwright/test'; + +const staticBundleMode = Boolean(process.env.RUVIEW_E2E_STATIC_DIR); + +export default defineConfig({ + testDir: './e2e/web', + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [['github'], ['list']] : 'list', + timeout: 30_000, + expect: { timeout: 8_000 }, + outputDir: 'test-results/playwright', + use: { + baseURL: staticBundleMode ? 'http://ruview.test' : 'http://127.0.0.1:4173', + viewport: { width: 390, height: 844 }, + colorScheme: 'dark', + locale: 'en-CA', + contextOptions: { reducedMotion: 'reduce' }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + ? { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + args: ['--no-sandbox', '--disable-dev-shm-usage'], + } + : undefined, + }, + webServer: staticBundleMode + ? undefined + : { + command: 'npm run build:web:e2e && npm run serve:web:e2e', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx b/ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx index 47944ac1..27001a1a 100644 --- a/ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx +++ b/ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Linking } from 'react-native'; +import { Linking, StyleSheet } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { createSyntheticNlosFrame } from '@/services/nlos.service'; import { createLiveNlosFrameFixture } from '@/testUtils/nlosFixtures'; @@ -9,6 +9,14 @@ import { NLOS_EXPLAINER_URL, NLOS_FEEDBACK_URL, } from '@/screens/NLOSScreen/BetaSetupCard'; +import { resolveNlosEvidenceState } from '@/screens/NLOSScreen/ProvenancePanel'; + +const mockSafeAreaInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + +jest.mock('react-native-safe-area-context', () => ({ + ...jest.requireActual('react-native-safe-area-context'), + useSafeAreaInsets: () => mockSafeAreaInsets, +})); const syntheticFrame = createSyntheticNlosFrame(0, 1_700_000_000_000); const mockNlosResult: Record = { @@ -44,6 +52,7 @@ jest.mock('react-native-svg', () => { describe('NLOSScreen', () => { beforeEach(() => { + Object.assign(mockSafeAreaInsets, { top: 0, right: 0, bottom: 0, left: 0 }); Object.assign(mockNlosResult, { frame: syntheticFrame, freshness: 'fresh', @@ -54,6 +63,8 @@ describe('NLOSScreen', () => { }); mockNlosResult.configureCredential.mockClear(); mockNlosResult.forgetCredential.mockClear(); + mockNlosResult.startReplay.mockClear(); + mockNlosResult.connectLive.mockClear(); }); it('renders the RuView NLOS screen and iPhone API boundary', () => { @@ -64,6 +75,20 @@ describe('NLOSScreen', () => { expect(screen.getByText(/web client cannot capture ARKit LiDAR or raw timing data/)).toBeTruthy(); }); + it('keeps the instrument content inside supplied iPhone safe area insets', () => { + Object.assign(mockSafeAreaInsets, { top: 47, right: 3, bottom: 34, left: 3 }); + const { NLOSScreen } = require('@/screens/NLOSScreen'); + render(); + + const contentStyle = StyleSheet.flatten( + screen.getByTestId('nlos-scroll-view').props.contentContainerStyle, + ); + expect(contentStyle.paddingTop).toBe(71); + expect(contentStyle.paddingRight).toBe(19); + expect(contentStyle.paddingBottom).toBe(106); + expect(contentStyle.paddingLeft).toBe(19); + }); + it('provides platform-specific beta setup without browser API assumptions', () => { const ios = getBetaPlatformGuidance('ios'); const web = getBetaPlatformGuidance('web'); @@ -107,6 +132,16 @@ describe('NLOSScreen', () => { render(); expect(screen.getByTestId('nlos-synthetic-watermark')).toBeTruthy(); expect(screen.getByTestId('nlos-provenance-badge').props.children).toBe('SYNTHETIC'); + expect(screen.getByTestId('nlos-evidence-state').props.children).toBe('SYNTHETIC'); + }); + + it('starts deterministic replay from the primary synthetic control', () => { + const { NLOSScreen } = require('@/screens/NLOSScreen'); + render(); + + fireEvent.press(screen.getByTestId('nlos-start-synthetic')); + + expect(mockNlosResult.startReplay).toHaveBeenCalledTimes(1); }); it('does not enable live without an ephemeral credential', () => { @@ -139,13 +174,17 @@ describe('NLOSScreen', () => { const { NLOSScreen } = require('@/screens/NLOSScreen'); render(); expect(screen.getByTestId('nlos-provenance-badge').props.children).toBe('UNKNOWN'); + expect(screen.getByTestId('nlos-evidence-state').props.children).toBe('DISCONNECTED'); + expect(screen.getByTestId('nlos-track-count').props.children).toBe(0); + expect(screen.queryByText('target-1')).toBeNull(); expect(screen.queryByTestId('nlos-synthetic-watermark')).toBeNull(); expect(screen.getByText(/Unknown evidence is never promoted to live/)).toBeTruthy(); }); it('keeps stale measured frames visibly stale', () => { + const live = createLiveNlosFrameFixture(); Object.assign(mockNlosResult, { - frame: createLiveNlosFrameFixture(), + frame: live, freshness: 'stale', streamStatus: 'error', liveCredentialAvailable: true, @@ -153,9 +192,11 @@ describe('NLOSScreen', () => { const { NLOSScreen } = require('@/screens/NLOSScreen'); render(); expect(screen.getByTestId('nlos-stale-overlay')).toBeTruthy(); + expect(screen.getByTestId('nlos-evidence-state').props.children).toBe('STALE'); expect(screen.getByTestId('nlos-freshness-badge').props.children).toBe('STALE'); expect(screen.getByTestId('nlos-track-count').props.children).toBe(0); expect(screen.getByTestId('nlos-mean-confidence').props.children).toBe('N/A'); + expect(screen.queryByText(live.tracks[0].trackId)).toBeNull(); expect(screen.queryByTestId('nlos-synthetic-watermark')).toBeNull(); }); @@ -175,4 +216,100 @@ describe('NLOSScreen', () => { expect(screen.getByTestId('nlos-mean-confidence').props.children).toBe('N/A'); expect(screen.queryByText(live.tracks[0].trackId)).toBeNull(); }); + + it('distinguishes verified and unverified live evidence without changing source labels', () => { + const calibrated = createLiveNlosFrameFixture(); + const measured = createLiveNlosFrameFixture({ evidenceLevel: 'l1_measured' }); + + expect(resolveNlosEvidenceState(calibrated, 'fresh', 'live')).toBe('LIVE VERIFIED'); + expect(resolveNlosEvidenceState(measured, 'fresh', 'live')).toBe('LIVE UNVERIFIED'); + expect(resolveNlosEvidenceState(calibrated, 'stale', 'error')).toBe('STALE'); + expect(resolveNlosEvidenceState(null, 'unknown', 'idle')).toBe('DISCONNECTED'); + expect(resolveNlosEvidenceState(null, 'unknown', 'connecting')).toBe('LIVE UNVERIFIED'); + }); + + it('withholds geometry for unverified live and replay provenance', () => { + const live = createLiveNlosFrameFixture({ evidenceLevel: 'l1_measured' }); + Object.assign(mockNlosResult, { + frame: live, + freshness: 'fresh', + streamStatus: 'live', + }); + const { NLOSScreen } = require('@/screens/NLOSScreen'); + const view = render(); + + expect(screen.getByTestId('nlos-evidence-state').props.children).toBe('LIVE UNVERIFIED'); + expect(screen.getByTestId('nlos-track-count').props.children).toBe(0); + expect(screen.queryByText(live.tracks[0].trackId)).toBeNull(); + + Object.assign(mockNlosResult, { + frame: { ...live, source: 'replay' }, + freshness: 'fresh', + streamStatus: 'live', + }); + view.rerender(); + + expect(screen.getByTestId('nlos-evidence-state').props.children).toBe('DISCONNECTED'); + expect(screen.getByTestId('nlos-provenance-badge').props.children).toBe('REPLAY'); + expect(screen.getByTestId('nlos-track-count').props.children).toBe(0); + expect(screen.queryByText(live.tracks[0].trackId)).toBeNull(); + }); + + it('projects a no-frame live attempt as unverified while withholding geometry', () => { + Object.assign(mockNlosResult, { + frame: null, + freshness: 'unknown', + streamStatus: 'connecting', + }); + const { NLOSScreen } = require('@/screens/NLOSScreen'); + render(); + + expect(screen.getByTestId('nlos-evidence-state').props.children).toBe('LIVE UNVERIFIED'); + expect(screen.getByTestId('nlos-track-count').props.children).toBe(0); + expect(screen.queryByText('target-1')).toBeNull(); + }); + + it('shows geometry only after a fresh live frame passes the verification gate', () => { + const live = createLiveNlosFrameFixture(); + Object.assign(mockNlosResult, { + frame: live, + freshness: 'fresh', + streamStatus: 'live', + }); + const { NLOSScreen } = require('@/screens/NLOSScreen'); + render(); + + expect(screen.getByTestId('nlos-evidence-state').props.children).toBe('LIVE VERIFIED'); + expect(screen.getByTestId('nlos-track-count').props.children).toBe(1); + expect(screen.getByText(live.tracks[0].trackId)).toBeTruthy(); + }); + + it('does not override validator accepted USB sensor provenance in the UI', () => { + const base = createLiveNlosFrameFixture(); + const usbFrame = createLiveNlosFrameFixture({ + provenance: { ...base.provenance, transport: 'usb_serial' }, + }); + + expect(resolveNlosEvidenceState(usbFrame, 'fresh', 'live')).toBe('LIVE VERIFIED'); + }); + + it('keeps privacy, setup, explainer, and feedback controls visible in the screen tree', () => { + const { NLOSScreen } = require('@/screens/NLOSScreen'); + render(); + + expect(screen.getByTestId('nlos-privacy-legend')).toBeTruthy(); + expect(screen.getByTestId('nlos-beta-setup')).toBeTruthy(); + expect(screen.getByTestId('nlos-explainer-link')).toBeTruthy(); + expect(screen.getByTestId('nlos-feedback-link')).toBeTruthy(); + expect(screen.getByText(/Viewer retention: raw RF off, audio off/)).toBeTruthy(); + }); + + it('switches between plan and perspective instrument views', () => { + const { NLOSScreen } = require('@/screens/NLOSScreen'); + render(); + + expect(screen.getByTestId('nlos-view-plan').props.accessibilityState.selected).toBe(true); + fireEvent.press(screen.getByTestId('nlos-view-perspective')); + expect(screen.getByTestId('nlos-view-perspective').props.accessibilityState.selected).toBe(true); + }); }); diff --git a/ui/mobile/src/components/InstrumentPanel.tsx b/ui/mobile/src/components/InstrumentPanel.tsx new file mode 100644 index 00000000..05d1e019 --- /dev/null +++ b/ui/mobile/src/components/InstrumentPanel.tsx @@ -0,0 +1,155 @@ +import type { ReactNode } from 'react'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; +import { ThemedText } from './ThemedText'; + +export const instrumentColors = { + background: '#05090D', + panel: '#091218', + panelRaised: '#0C171E', + cyan: '#24D3E5', + green: '#58F28B', + cyanDim: 'rgba(36, 211, 229, 0.38)', + greenDim: 'rgba(88, 242, 139, 0.36)', + border: 'rgba(65, 204, 219, 0.22)', + borderStrong: 'rgba(65, 204, 219, 0.42)', + grid: 'rgba(92, 151, 162, 0.065)', + text: '#F3F8FA', + textSecondary: '#91A4AE', + warning: '#FFB65C', + danger: '#FF6478', + dimOverlay: 'rgba(5, 9, 13, 0.78)', +} as const; + +interface InstrumentPanelProps { + children?: ReactNode; + eyebrow?: string; + accessory?: ReactNode; + style?: StyleProp; + testID?: string; + accessibilityLabel?: string; +} + +export const InstrumentGrid = () => ( + + + {Array.from({ length: 8 }, (_, index) => ( + + ))} + + + {Array.from({ length: 18 }, (_, index) => ( + + ))} + + +); + +export const InstrumentPanel = ({ + children, + eyebrow, + accessory, + style, + testID, + accessibilityLabel, +}: InstrumentPanelProps) => ( + + + + + + + + {(eyebrow || accessory) && ( + + {eyebrow ? ( + {eyebrow} + ) : } + {accessory} + + )} + {children} + +); + +const styles = StyleSheet.create({ + gridColumns: { + ...StyleSheet.absoluteFillObject, + flexDirection: 'row', + }, + gridColumn: { + flex: 1, + borderRightColor: instrumentColors.grid, + borderRightWidth: 1, + }, + gridRows: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'space-between', + }, + gridRow: { + height: 1, + backgroundColor: instrumentColors.grid, + }, + panel: { + position: 'relative', + overflow: 'hidden', + backgroundColor: instrumentColors.panel, + borderColor: instrumentColors.border, + borderWidth: 1, + borderRadius: 18, + padding: 16, + gap: 12, + shadowColor: instrumentColors.cyan, + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.07, + shadowRadius: 24, + elevation: 2, + }, + accentRail: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 2, + flexDirection: 'row', + }, + accentRailCyan: { flex: 2, backgroundColor: instrumentColors.cyan }, + accentRailGreen: { flex: 1, backgroundColor: instrumentColors.green }, + corner: { + position: 'absolute', + width: 11, + height: 11, + borderColor: instrumentColors.cyanDim, + }, + cornerTopLeft: { + top: 7, + left: 7, + borderTopWidth: 1, + borderLeftWidth: 1, + }, + cornerBottomRight: { + right: 7, + bottom: 7, + borderRightWidth: 1, + borderBottomWidth: 1, + }, + headingRow: { + minHeight: 22, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: 8, + }, + eyebrow: { + color: instrumentColors.cyan, + fontSize: 11, + lineHeight: 16, + letterSpacing: 1.4, + textTransform: 'uppercase', + }, +}); diff --git a/ui/mobile/src/screens/NLOSScreen/BetaSetupCard.tsx b/ui/mobile/src/screens/NLOSScreen/BetaSetupCard.tsx index c00dc15c..e20f55d2 100644 --- a/ui/mobile/src/screens/NLOSScreen/BetaSetupCard.tsx +++ b/ui/mobile/src/screens/NLOSScreen/BetaSetupCard.tsx @@ -1,6 +1,6 @@ import { Linking, Platform, Pressable, StyleSheet, View } from 'react-native'; +import { InstrumentPanel, instrumentColors } from '@/components/InstrumentPanel'; import { ThemedText } from '@/components/ThemedText'; -import { colors } from '@/theme/colors'; import { spacing } from '@/theme/spacing'; export const NLOS_EXPLAINER_URL = 'https://ruview-nlos.ruv.chatgpt.site'; @@ -65,8 +65,19 @@ const openTrustedUrl = async (url: string): Promise => { } }; -const LinkButton = ({ label, url, primary = false }: { label: string; url: string; primary?: boolean }) => ( +const LinkButton = ({ + label, + url, + primary = false, + testID, +}: { + label: string; + url: string; + primary?: boolean; + testID?: string; +}) => ( { const guidance = getBetaPlatformGuidance(currentBetaPlatform()); return ( - + - BETA SETUP + BETA SETUP {guidance.label} - Start a governed test in about five minutes + Start a governed test in about five minutes {guidance.steps.map((step, index) => ( @@ -102,41 +118,53 @@ export const BetaSetupCard = () => { CAPABILITY BOUNDARY - + The web client cannot capture ARKit LiDAR or raw timing data. It only displays synthetic replay or validated tracks produced by a RuView server. - - Compatibility: any supported device can view tracks. A LiDAR equipped iPhone Pro or iPad Pro is needed only for separately assigned hardware capability checks. - - - Evidence labels: L0 synthetic, L1 measured, L2 calibrated, or L3 corroborated, plus fresh, stale, or unknown. Depth only input is never physical NLOS evidence. - + + + DEVICE + + Compatibility: any supported device can view tracks. A LiDAR equipped iPhone Pro or iPad Pro is needed only for separately assigned hardware capability checks. + + + + EVIDENCE + + Evidence labels: L0 synthetic, L1 measured, L2 calibrated, or L3 corroborated, plus fresh, stale, or unknown. Depth only input is never physical NLOS evidence. + + + {guidance.showTestFlightButton && ( )} - - + + - + No credentials are saved by setup. Live pairing credentials remain in memory only and can be forgotten at any time. - + ); }; const styles = StyleSheet.create({ card: { - backgroundColor: colors.surface, - borderColor: colors.accentDim, - borderWidth: 1, - borderRadius: 12, - padding: spacing.lg, - gap: spacing.md, + backgroundColor: instrumentColors.panelRaised, }, headingRow: { flexDirection: 'row', @@ -145,48 +173,84 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', gap: spacing.sm, }, + sectionLabel: { color: instrumentColors.text }, platformBadge: { - color: colors.accent, - borderColor: colors.accentDim, + color: instrumentColors.cyan, + borderColor: instrumentColors.cyanDim, borderWidth: 1, borderRadius: 999, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, }, - title: { lineHeight: 23 }, + title: { + maxWidth: 300, + color: instrumentColors.text, + fontSize: 25, + lineHeight: 30, + letterSpacing: -0.45, + }, steps: { gap: spacing.sm }, - stepRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm }, + stepRow: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.md, + borderBottomColor: instrumentColors.grid, + borderBottomWidth: 1, + paddingBottom: spacing.sm, + }, stepNumber: { - color: colors.bg, - backgroundColor: colors.accent, + color: instrumentColors.background, + backgroundColor: instrumentColors.cyan, borderRadius: 999, - width: 24, - height: 24, - lineHeight: 24, + width: 28, + height: 28, + lineHeight: 28, textAlign: 'center', }, - stepText: { flex: 1, lineHeight: 21 }, + stepText: { flex: 1, lineHeight: 21, color: instrumentColors.text }, boundary: { - backgroundColor: 'rgba(255, 165, 2, 0.08)', - borderLeftColor: colors.warn, + backgroundColor: 'rgba(255, 182, 92, 0.07)', + borderColor: 'rgba(255, 182, 92, 0.24)', + borderWidth: 1, + borderLeftColor: instrumentColors.warning, borderLeftWidth: 3, + borderRadius: 10, padding: spacing.md, gap: spacing.xs, }, - boundaryLabel: { color: colors.warn }, + boundaryLabel: { color: instrumentColors.warning }, + boundaryCopy: { color: instrumentColors.text }, + compatibilityGrid: { gap: spacing.sm }, + compatibilityCell: { + backgroundColor: 'rgba(5, 9, 13, 0.38)', + borderColor: instrumentColors.border, + borderWidth: 1, + borderRadius: 10, + padding: spacing.md, + gap: spacing.xs, + }, + cellLabel: { + color: instrumentColors.green, + fontSize: 10, + letterSpacing: 1.2, + }, + cellCopy: { color: instrumentColors.textSecondary, lineHeight: 18 }, links: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, linkButton: { - minHeight: 44, + minHeight: 48, flexGrow: 1, + flexBasis: 150, alignItems: 'center', justifyContent: 'center', - borderColor: colors.accent, + borderColor: instrumentColors.cyanDim, borderWidth: 1, - borderRadius: 8, + borderRadius: 10, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, }, - linkButtonPrimary: { backgroundColor: colors.accent }, - linkButtonText: { color: colors.accent, textAlign: 'center' }, - linkButtonPrimaryText: { color: colors.bg, textAlign: 'center' }, + linkButtonPrimary: { backgroundColor: instrumentColors.cyan }, + linkButtonText: { color: instrumentColors.cyan, textAlign: 'center' }, + linkButtonPrimaryText: { color: instrumentColors.background, textAlign: 'center' }, + retentionNote: { color: instrumentColors.textSecondary, lineHeight: 18 }, }); diff --git a/ui/mobile/src/screens/NLOSScreen/HiddenTargetVisualization.tsx b/ui/mobile/src/screens/NLOSScreen/HiddenTargetVisualization.tsx index 7db3ae15..33ef64a2 100644 --- a/ui/mobile/src/screens/NLOSScreen/HiddenTargetVisualization.tsx +++ b/ui/mobile/src/screens/NLOSScreen/HiddenTargetVisualization.tsx @@ -1,7 +1,7 @@ import React, { memo, useMemo } from 'react'; import { View } from 'react-native'; import Svg, { Circle, Ellipse, Line, Polygon, Rect, Text as SvgText } from 'react-native-svg'; -import { colors } from '@/theme/colors'; +import { instrumentColors } from '@/components/InstrumentPanel'; import type { NlosFreshness, NlosTrack } from '@/types/nlos'; export type NlosViewMode = 'plan' | 'perspective'; @@ -29,9 +29,9 @@ const CANVAS_HEIGHT = 260; const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); const resolveTrackColor = (track: NlosTrack, freshness: NlosFreshness): string => { - if (freshness !== 'fresh' || track.state === 'unknown') return colors.muted; - if (track.state === 'degraded') return colors.warn; - return colors.accent; + if (freshness !== 'fresh' || track.state === 'unknown') return instrumentColors.textSecondary; + if (track.state === 'degraded') return instrumentColors.warning; + return instrumentColors.green; }; const projectPlan = (track: NlosTrack): ProjectedTrack => { @@ -65,27 +65,45 @@ const projectPerspective = (track: NlosTrack): ProjectedTrack => { const PlanScene = () => ( <> - - - - HIDDEN REGION - RELAY SURFACE - - - SENSOR + + {[60, 108, 156, 204, 252, 300].map((x) => ( + + ))} + {[54, 94, 134, 174, 214].map((y) => ( + + ))} + + + HIDDEN REGION + RELAY SURFACE + + + + + + + SENSOR ); const PerspectiveScene = () => ( <> - - - - - - - BEYOND RELAY PLANE - + + + + + {[1, 2, 3].map((step) => ( + + + + + ))} + + + + BEYOND RELAY PLANE + + ); @@ -103,6 +121,7 @@ export const HiddenTargetVisualization = memo(({ return (