feat(mobile): adopt NLOS instrument interface

This commit is contained in:
rUv
2026-08-23 19:40:56 -04:00
parent 4a8c8ea734
commit 97cb530a86
23 changed files with 2966 additions and 441 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -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)
)
}
}

View File

@@ -6,6 +6,9 @@ node_modules/
# Expo
.expo/
dist/
dist-e2e/
test-results/
playwright-report/
web-build/
expo-env.d.ts

View File

@@ -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"

View File

@@ -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<string, string> = {
'.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');
});
});

View File

@@ -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`);
});

View File

@@ -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,

View File

@@ -7,7 +7,12 @@ module.exports = {
...(expoPreset.setupFiles || []),
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/__mocks__/', '<rootDir>/src/__tests__/test-utils.tsx'],
testPathIgnorePatterns: [
'/node_modules/',
'/__mocks__/',
'<rootDir>/e2e/web/',
'<rootDir>/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)/)',
],

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,
},
});

View File

@@ -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<string, any> = {
@@ -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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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(<ThemeProvider><NLOSScreen /></ThemeProvider>);
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);
});
});

View File

@@ -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<ViewStyle>;
testID?: string;
accessibilityLabel?: string;
}
export const InstrumentGrid = () => (
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={StyleSheet.absoluteFill}
>
<View style={styles.gridColumns}>
{Array.from({ length: 8 }, (_, index) => (
<View key={`column-${index}`} style={styles.gridColumn} />
))}
</View>
<View style={styles.gridRows}>
{Array.from({ length: 18 }, (_, index) => (
<View key={`row-${index}`} style={styles.gridRow} />
))}
</View>
</View>
);
export const InstrumentPanel = ({
children,
eyebrow,
accessory,
style,
testID,
accessibilityLabel,
}: InstrumentPanelProps) => (
<View testID={testID} accessibilityLabel={accessibilityLabel} style={[styles.panel, style]}>
<View pointerEvents="none" style={styles.accentRail}>
<View style={styles.accentRailCyan} />
<View style={styles.accentRailGreen} />
</View>
<View pointerEvents="none" style={[styles.corner, styles.cornerTopLeft]} />
<View pointerEvents="none" style={[styles.corner, styles.cornerBottomRight]} />
{(eyebrow || accessory) && (
<View style={styles.headingRow}>
{eyebrow ? (
<ThemedText preset="mono" style={styles.eyebrow}>{eyebrow}</ThemedText>
) : <View />}
{accessory}
</View>
)}
{children}
</View>
);
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',
},
});

View File

@@ -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<void> => {
}
};
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;
}) => (
<Pressable
testID={testID}
accessibilityRole="link"
accessibilityLabel={label}
accessibilityHint="Opens in your browser"
@@ -83,13 +94,18 @@ export const BetaSetupCard = () => {
const guidance = getBetaPlatformGuidance(currentBetaPlatform());
return (
<View testID="nlos-beta-setup" style={styles.card} accessibilityLabel="RuView NLOS beta setup">
<InstrumentPanel
testID="nlos-beta-setup"
eyebrow="Governed beta protocol"
style={styles.card}
accessibilityLabel="RuView NLOS beta setup"
>
<View style={styles.headingRow}>
<ThemedText preset="labelLg">BETA SETUP</ThemedText>
<ThemedText preset="labelLg" style={styles.sectionLabel}>BETA SETUP</ThemedText>
<ThemedText preset="labelMd" style={styles.platformBadge}>{guidance.label}</ThemedText>
</View>
<ThemedText preset="bodyLg" style={styles.title}>Start a governed test in about five minutes</ThemedText>
<ThemedText preset="displayMd" style={styles.title}>Start a governed test in about five minutes</ThemedText>
<View style={styles.steps}>
{guidance.steps.map((step, index) => (
@@ -102,41 +118,53 @@ export const BetaSetupCard = () => {
<View style={styles.boundary}>
<ThemedText preset="labelMd" style={styles.boundaryLabel}>CAPABILITY BOUNDARY</ThemedText>
<ThemedText preset="bodyMd">
<ThemedText preset="bodyMd" style={styles.boundaryCopy}>
The web client cannot capture ARKit LiDAR or raw timing data. It only displays synthetic replay or validated tracks produced by a RuView server.
</ThemedText>
</View>
<ThemedText preset="bodyMd" color="textSecondary">
Compatibility: any supported device can view tracks. A LiDAR equipped iPhone Pro or iPad Pro is needed only for separately assigned hardware capability checks.
</ThemedText>
<ThemedText preset="bodyMd" color="textSecondary">
Evidence labels: L0 synthetic, L1 measured, L2 calibrated, or L3 corroborated, plus fresh, stale, or unknown. Depth only input is never physical NLOS evidence.
</ThemedText>
<View style={styles.compatibilityGrid}>
<View style={styles.compatibilityCell}>
<ThemedText preset="mono" style={styles.cellLabel}>DEVICE</ThemedText>
<ThemedText preset="bodySm" style={styles.cellCopy}>
Compatibility: any supported device can view tracks. A LiDAR equipped iPhone Pro or iPad Pro is needed only for separately assigned hardware capability checks.
</ThemedText>
</View>
<View style={styles.compatibilityCell}>
<ThemedText preset="mono" style={styles.cellLabel}>EVIDENCE</ThemedText>
<ThemedText preset="bodySm" style={styles.cellCopy}>
Evidence labels: L0 synthetic, L1 measured, L2 calibrated, or L3 corroborated, plus fresh, stale, or unknown. Depth only input is never physical NLOS evidence.
</ThemedText>
</View>
</View>
<View style={styles.links}>
{guidance.showTestFlightButton && (
<LinkButton label="INSTALL TESTFLIGHT" url={TESTFLIGHT_APP_URL} primary />
)}
<LinkButton label="OPEN EXPLAINER" url={NLOS_EXPLAINER_URL} primary={!guidance.showTestFlightButton} />
<LinkButton label="TEST STEPS AND FEEDBACK" url={NLOS_FEEDBACK_URL} />
<LinkButton
testID="nlos-explainer-link"
label="OPEN EXPLAINER"
url={NLOS_EXPLAINER_URL}
primary={!guidance.showTestFlightButton}
/>
<LinkButton
testID="nlos-feedback-link"
label="TEST STEPS AND FEEDBACK"
url={NLOS_FEEDBACK_URL}
/>
</View>
<ThemedText preset="bodySm" color="textSecondary">
<ThemedText preset="bodySm" style={styles.retentionNote}>
No credentials are saved by setup. Live pairing credentials remain in memory only and can be forgotten at any time.
</ThemedText>
</View>
</InstrumentPanel>
);
};
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 },
});

View File

@@ -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 = () => (
<>
<Rect x={18} y={18} width={324} height={214} rx={10} fill={colors.surface} stroke={colors.border} />
<Rect x={19} y={19} width={322} height={74} rx={9} fill="rgba(255, 165, 2, 0.07)" />
<Line x1={24} y1={94} x2={336} y2={94} stroke={colors.warn} strokeWidth={4} />
<SvgText x={28} y={84} fill={colors.warn} fontSize={10}>HIDDEN REGION</SvgText>
<SvgText x={28} y={112} fill={colors.textSecondary} fontSize={10}>RELAY SURFACE</SvgText>
<Circle cx={180} cy={218} r={5} fill={colors.accent} />
<Line x1={180} y1={213} x2={180} y2={98} stroke={colors.accentDim} strokeDasharray="5 5" />
<SvgText x={190} y={222} fill={colors.textSecondary} fontSize={9}>SENSOR</SvgText>
<Rect x={12} y={12} width={336} height={236} rx={14} fill={instrumentColors.panelRaised} stroke={instrumentColors.border} />
{[60, 108, 156, 204, 252, 300].map((x) => (
<Line key={`plan-column-${x}`} x1={x} y1={18} x2={x} y2={242} stroke={instrumentColors.grid} />
))}
{[54, 94, 134, 174, 214].map((y) => (
<Line key={`plan-row-${y}`} x1={18} y1={y} x2={342} y2={y} stroke={instrumentColors.grid} />
))}
<Rect x={13} y={13} width={334} height={80} rx={13} fill="rgba(255, 182, 92, 0.055)" />
<Line x1={20} y1={94} x2={340} y2={94} stroke={instrumentColors.warning} strokeWidth={2} />
<SvgText x={24} y={79} fill={instrumentColors.warning} fontSize={9} letterSpacing={1.2}>HIDDEN REGION</SvgText>
<SvgText x={24} y={110} fill={instrumentColors.textSecondary} fontSize={9} letterSpacing={1}>RELAY SURFACE</SvgText>
<Circle cx={180} cy={220} r={35} fill="none" stroke={instrumentColors.border} strokeDasharray="2 5" />
<Circle cx={180} cy={220} r={72} fill="none" stroke={instrumentColors.border} strokeDasharray="2 6" />
<Circle cx={180} cy={220} r={4} fill={instrumentColors.cyan} />
<Circle cx={180} cy={220} r={9} fill="none" stroke={instrumentColors.cyanDim} />
<Line x1={180} y1={211} x2={180} y2={98} stroke={instrumentColors.cyanDim} strokeDasharray="5 5" />
<Line x1={180} y1={220} x2={252} y2={148} stroke={instrumentColors.greenDim} strokeWidth={1.5} />
<SvgText x={193} y={232} fill={instrumentColors.textSecondary} fontSize={8} letterSpacing={1}>SENSOR</SvgText>
</>
);
const PerspectiveScene = () => (
<>
<Polygon points="180,38 316,86 180,136 44,86" fill={colors.surface} stroke={colors.border} />
<Polygon points="44,86 180,136 180,220 44,166" fill="rgba(26, 34, 51, 0.7)" stroke={colors.border} />
<Polygon points="180,136 316,86 316,166 180,220" fill="rgba(17, 24, 39, 0.8)" stroke={colors.border} />
<Polygon points="84,72 180,106 276,72 180,38" fill="rgba(255, 165, 2, 0.08)" />
<Line x1={84} y1={72} x2={180} y2={106} stroke={colors.warn} strokeWidth={4} />
<Line x1={180} y1={106} x2={276} y2={72} stroke={colors.warn} strokeWidth={4} />
<SvgText x={119} y={62} fill={colors.warn} fontSize={10}>BEYOND RELAY PLANE</SvgText>
<Circle cx={180} cy={205} r={5} fill={colors.accent} />
<Rect x={12} y={12} width={336} height={236} rx={14} fill={instrumentColors.panelRaised} stroke={instrumentColors.border} />
<Polygon points="180,34 318,84 180,137 42,84" fill={instrumentColors.panel} stroke={instrumentColors.borderStrong} />
<Polygon points="42,84 180,137 180,224 42,169" fill="rgba(14, 27, 35, 0.92)" stroke={instrumentColors.border} />
<Polygon points="180,137 318,84 318,169 180,224" fill="rgba(7, 17, 23, 0.92)" stroke={instrumentColors.border} />
{[1, 2, 3].map((step) => (
<React.Fragment key={`perspective-grid-${step}`}>
<Line x1={42 + step * 34.5} y1={84 + step * 13.25} x2={42 + step * 34.5} y2={169 + step * 13.75} stroke={instrumentColors.grid} />
<Line x1={318 - step * 34.5} y1={84 + step * 13.25} x2={318 - step * 34.5} y2={169 + step * 13.75} stroke={instrumentColors.grid} />
</React.Fragment>
))}
<Polygon points="84,69 180,104 276,69 180,34" fill="rgba(255, 182, 92, 0.065)" />
<Line x1={84} y1={69} x2={180} y2={104} stroke={instrumentColors.warning} strokeWidth={2} />
<Line x1={180} y1={104} x2={276} y2={69} stroke={instrumentColors.warning} strokeWidth={2} />
<SvgText x={110} y={59} fill={instrumentColors.warning} fontSize={9} letterSpacing={1}>BEYOND RELAY PLANE</SvgText>
<Circle cx={180} cy={207} r={4} fill={instrumentColors.cyan} />
<Circle cx={180} cy={207} r={13} fill="none" stroke={instrumentColors.cyanDim} strokeDasharray="2 3" />
</>
);
@@ -103,6 +121,7 @@ export const HiddenTargetVisualization = memo(({
return (
<View
testID="nlos-target-visualization"
accessibilityRole="image"
accessibilityLabel={`${mode === 'plan' ? 'Plan' : 'Perspective'} view of ${tracks.length} hidden target hypotheses`}
style={{ alignSelf: 'center', width: displayWidth, aspectRatio: CANVAS_WIDTH / CANVAS_HEIGHT }}
@@ -123,8 +142,9 @@ export const HiddenTargetVisualization = memo(({
strokeDasharray="4 3"
/>
<Line x1={x} y1={y} x2={x + velocityX} y2={y + velocityY} stroke={color} strokeWidth={2} />
<Circle cx={x} cy={y} r={6 + track.confidence * 4} fill={color} stroke="#FFFFFF" strokeWidth={1.5} />
<SvgText x={x + 12} y={y - 10} fill={colors.textPrimary} fontSize={10}>
<Circle cx={x} cy={y} r={6 + track.confidence * 4} fill={color} stroke={instrumentColors.text} strokeWidth={1.5} />
<Circle cx={x} cy={y} r={12 + track.confidence * 5} fill="none" stroke={`${color}55`} />
<SvgText x={x + 12} y={y - 10} fill={instrumentColors.text} fontSize={10}>
{track.trackId}
</SvgText>
</React.Fragment>

View File

@@ -1,4 +1,5 @@
import { 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';
@@ -10,6 +11,45 @@ interface ProvenancePanelProps {
streamStatus: NlosStreamStatus;
}
export type NlosEvidenceState =
| 'SYNTHETIC'
| 'LIVE VERIFIED'
| 'LIVE UNVERIFIED'
| 'STALE'
| 'DISCONNECTED';
const LIVE_ATTEMPT_STATUSES: ReadonlySet<NlosStreamStatus> = new Set([
'authenticating',
'connecting',
'live',
]);
const hasVerifiedLiveEvidence = (frame: NlosTrackFrame): boolean => (
frame.source === 'live'
&& frame.evidenceLevel === 'l2_calibrated'
);
export const resolveNlosEvidenceState = (
frame: NlosTrackFrame | null,
freshness: NlosFreshness,
streamStatus: NlosStreamStatus,
): NlosEvidenceState => {
if (freshness === 'stale') return 'STALE';
if (freshness === 'fresh' && frame?.source === 'synthetic') return 'SYNTHETIC';
if (freshness === 'fresh' && frame?.source === 'replay') return 'DISCONNECTED';
if (LIVE_ATTEMPT_STATUSES.has(streamStatus) || frame?.source === 'live') {
return freshness === 'fresh'
&& streamStatus === 'live'
&& frame !== null
&& hasVerifiedLiveEvidence(frame)
? 'LIVE VERIFIED'
: 'LIVE UNVERIFIED';
}
return 'DISCONNECTED';
};
const sourceLabel = (frame: NlosTrackFrame | null): string => {
if (!frame) return 'UNKNOWN';
if (frame.source === 'synthetic') return 'SYNTHETIC';
@@ -21,7 +61,16 @@ const sourceColor = (frame: NlosTrackFrame | null): string => {
if (!frame) return colors.muted;
if (frame.source === 'synthetic') return colors.warn;
if (frame.source === 'replay') return colors.textSecondary;
return colors.success;
return instrumentColors.cyan;
};
const evidenceStateColor = (state: NlosEvidenceState): string => {
if (state === 'LIVE VERIFIED') return instrumentColors.green;
if (state === 'SYNTHETIC' || state === 'LIVE UNVERIFIED') {
return instrumentColors.warning;
}
if (state === 'STALE') return instrumentColors.danger;
return instrumentColors.textSecondary;
};
const humanize = (value: string) => value.replace(/_/g, ' ').toUpperCase();
@@ -36,19 +85,45 @@ const ProvenanceRow = ({ label, value }: { label: string; value: string }) => (
export const ProvenancePanel = ({ frame, freshness, streamStatus }: ProvenancePanelProps) => {
const label = sourceLabel(frame);
const accent = sourceColor(frame);
const evidenceState = resolveNlosEvidenceState(frame, freshness, streamStatus);
const stateAccent = evidenceStateColor(evidenceState);
return (
<View style={styles.card}>
<InstrumentPanel testID="nlos-provenance-panel" eyebrow="Evidence state" style={styles.card}>
<View style={styles.stateRow}>
<View style={styles.stateIdentity}>
<View style={[styles.stateDot, { backgroundColor: stateAccent }]} />
<ThemedText
testID="nlos-evidence-state"
preset="labelLg"
accessibilityLabel={`NLOS evidence state ${evidenceState}`}
style={[styles.evidenceState, { color: stateAccent }]}
>
{evidenceState}
</ThemedText>
</View>
<ThemedText preset="mono" style={styles.streamStatus}>
{humanize(streamStatus)}
</ThemedText>
</View>
<View style={styles.badgeRow}>
<ThemedText testID="nlos-provenance-badge" preset="labelMd" style={[styles.badge, { borderColor: accent, color: accent }]}>
{label}
</ThemedText>
<ThemedText testID="nlos-freshness-badge" preset="labelMd" style={{ color: freshness === 'fresh' ? colors.success : freshness === 'stale' ? colors.danger : colors.muted }}>
<ThemedText
testID="nlos-freshness-badge"
preset="labelMd"
style={[
styles.badge,
{
borderColor: freshness === 'fresh' ? instrumentColors.greenDim : freshness === 'stale' ? instrumentColors.danger : instrumentColors.border,
color: freshness === 'fresh' ? instrumentColors.green : freshness === 'stale' ? instrumentColors.danger : instrumentColors.textSecondary,
},
]}
>
{freshness.toUpperCase()}
</ThemedText>
<ThemedText preset="bodySm" color="textSecondary">
{humanize(streamStatus)}
</ThemedText>
</View>
{frame ? (
@@ -64,18 +139,42 @@ export const ProvenancePanel = ({ frame, freshness, streamStatus }: ProvenancePa
No validated frame is available. Unknown evidence is never promoted to live.
</ThemedText>
)}
</View>
</InstrumentPanel>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 12,
padding: spacing.md,
gap: spacing.md,
backgroundColor: instrumentColors.panelRaised,
},
stateRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: spacing.sm,
},
stateIdentity: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
stateDot: {
width: 8,
height: 8,
borderRadius: 4,
shadowColor: instrumentColors.cyan,
shadowOpacity: 0.8,
shadowRadius: 5,
},
evidenceState: {
fontSize: 15,
letterSpacing: 1.1,
},
streamStatus: {
color: instrumentColors.textSecondary,
fontSize: 10,
letterSpacing: 1,
},
badgeRow: {
flexDirection: 'row',
@@ -92,7 +191,14 @@ const styles = StyleSheet.create({
grid: {
gap: spacing.xs,
},
provenanceRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
provenanceRow: {
minHeight: 28,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
borderBottomColor: instrumentColors.grid,
borderBottomWidth: 1,
},
provenanceLabel: { width: 82 },
provenanceValue: { flex: 1 },
});

View File

@@ -1,27 +1,49 @@
import { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, TextInput, useWindowDimensions, View } from 'react-native';
import {
Pressable,
ScrollView,
StyleSheet,
TextInput,
useWindowDimensions,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
InstrumentGrid,
InstrumentPanel,
instrumentColors,
} from '@/components/InstrumentPanel';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { useNlosStream } from '@/hooks/useNlosStream';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { HiddenTargetVisualization, type NlosViewMode } from './HiddenTargetVisualization';
import { BetaSetupCard } from './BetaSetupCard';
import { ProvenancePanel } from './ProvenancePanel';
import { HiddenTargetVisualization, type NlosViewMode } from './HiddenTargetVisualization';
import { ProvenancePanel, resolveNlosEvidenceState } from './ProvenancePanel';
const ViewModePicker = ({ value, onChange }: { value: NlosViewMode; onChange: (value: NlosViewMode) => void }) => (
<View style={styles.picker}>
const ViewModePicker = ({
value,
onChange,
}: {
value: NlosViewMode;
onChange: (value: NlosViewMode) => void;
}) => (
<View accessibilityRole="tablist" style={styles.picker}>
{(['plan', 'perspective'] as const).map((option) => {
const selected = option === value;
return (
<Pressable
key={option}
testID={`nlos-view-${option}`}
accessibilityRole="button"
accessibilityState={{ selected }}
onPress={() => onChange(option)}
style={[styles.pickerButton, selected && styles.pickerButtonSelected]}
>
<ThemedText preset="labelMd" style={{ color: selected ? colors.accent : colors.textSecondary }}>
<ThemedText
preset="mono"
style={{ color: selected ? instrumentColors.cyan : instrumentColors.textSecondary }}
>
{option === 'plan' ? '2D PLAN' : '3D VIEW'}
</ThemedText>
</Pressable>
@@ -30,6 +52,15 @@ const ViewModePicker = ({ value, onChange }: { value: NlosViewMode; onChange: (v
</View>
);
const ScopeChip = ({ label, accent = false }: { label: string; accent?: boolean }) => (
<View style={[styles.scopeChip, accent && styles.scopeChipAccent]}>
<View style={[styles.scopeDot, accent && styles.scopeDotAccent]} />
<ThemedText preset="mono" style={[styles.scopeLabel, accent && styles.scopeLabelAccent]}>
{label}
</ThemedText>
</View>
);
export const NLOSScreen = () => {
const {
frame,
@@ -47,14 +78,26 @@ export const NLOSScreen = () => {
const [credentialDraft, setCredentialDraft] = useState('');
const [credentialError, setCredentialError] = useState(false);
const { width } = useWindowDimensions();
const visualizationWidth = useMemo(() => width - spacing.md * 2, [width]);
const safeAreaInsets = useSafeAreaInsets();
const visualizationWidth = useMemo(
() => Math.max(
260,
Math.min(width - safeAreaInsets.left - safeAreaInsets.right - spacing.xxxl - 4, 520),
),
[safeAreaInsets.left, safeAreaInsets.right, width],
);
const evidenceState = resolveNlosEvidenceState(frame, freshness, streamStatus);
const isSynthetic = frame?.source === 'synthetic';
const geometryDisplayable = evidenceState === 'SYNTHETIC' || evidenceState === 'LIVE VERIFIED';
const visibleTracks = useMemo(
() => freshness === 'fresh'
() => geometryDisplayable
? frame?.tracks.filter((track) => track.state !== 'unknown') ?? []
: [],
[frame, freshness],
[frame, geometryDisplayable],
);
const meanConfidence = visibleTracks.length
? `${Math.round(visibleTracks.reduce((sum, track) => sum + track.confidence, 0) / visibleTracks.length * 100)}%`
: 'N/A';
const credentialLengthValid = credentialDraft.length >= 32 && credentialDraft.length <= 512;
const handleConfigureCredential = () => {
@@ -65,77 +108,139 @@ export const NLOSScreen = () => {
return (
<ThemedView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.header}>
<View style={{ flex: 1 }}>
<ThemedText preset="displayMd">RuView NLOS</ThemedText>
<ThemedText preset="bodySm" color="textSecondary">
Hidden target hypotheses from a RuView reconstruction server
</ThemedText>
<InstrumentGrid />
<ScrollView
testID="nlos-scroll-view"
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.content,
{
paddingTop: spacing.xxl + safeAreaInsets.top,
paddingRight: spacing.lg + safeAreaInsets.right,
paddingBottom: 72 + safeAreaInsets.bottom,
paddingLeft: spacing.lg + safeAreaInsets.left,
},
]}
>
<View style={styles.brandBar}>
<View style={styles.brandIdentity}>
<View style={styles.brandMark}>
<View style={styles.brandMarkCore} />
</View>
<View>
<ThemedText preset="labelLg" style={styles.brandName}>RuView NLOS</ThemedText>
<ThemedText preset="mono" style={styles.brandCaption}>MOBILE INSTRUMENT / 01</ThemedText>
</View>
</View>
<ThemedText preset="labelMd" style={{ color: colors.accent }}>LABS</ThemedText>
<ThemedText preset="mono" style={styles.labsBadge}>LABS</ThemedText>
</View>
<BetaSetupCard />
<View style={styles.notice}>
<ThemedText preset="bodySm" style={{ color: colors.warn }}>
This client does not access raw iPhone LiDAR timing data. Safari and Expo display authenticated RuView track frames or visibly watermarked synthetic replay only.
<InstrumentPanel eyebrow="Consumer NLOS / field viewer" style={styles.hero}>
<ThemedText preset="displayLg" style={styles.heroTitle}>Track hidden space</ThemedText>
<ThemedText preset="displayLg" style={styles.heroAccent}>hypotheses.</ThemedText>
<ThemedText preset="bodyLg" style={styles.heroCopy}>
Inspect validated reconstruction frames with explicit source, freshness, and confidence. No camera equivalence is implied.
</ThemedText>
<View style={styles.scopeRow}>
<ScopeChip label="VIEWER ONLY" />
<ScopeChip label="FAIL CLOSED" accent />
</View>
</InstrumentPanel>
<View testID="nlos-capability-boundary" style={styles.notice}>
<View style={styles.noticeIcon}>
<ThemedText preset="mono" style={styles.noticeIconText}>!</ThemedText>
</View>
<View style={styles.noticeCopy}>
<ThemedText preset="mono" style={styles.noticeLabel}>SENSOR BOUNDARY</ThemedText>
<ThemedText preset="bodySm" style={styles.noticeText}>
This client does not access raw iPhone LiDAR timing data. Safari and Expo display authenticated RuView track frames or visibly watermarked synthetic replay only.
</ThemedText>
</View>
</View>
<ProvenancePanel frame={frame} freshness={freshness} streamStatus={streamStatus} />
<View style={styles.visualizationCard}>
<InstrumentPanel
eyebrow="Spatial return"
accessory={<ThemedText preset="mono" style={styles.panelIndex}>FRAME / 01</ThemedText>}
style={styles.visualizationCard}
>
<ViewModePicker value={viewMode} onChange={setViewMode} />
<HiddenTargetVisualization
tracks={visibleTracks}
freshness={freshness}
mode={viewMode}
width={visualizationWidth}
/>
{isSynthetic && (
<View testID="nlos-synthetic-watermark" pointerEvents="none" style={styles.watermark}>
<ThemedText preset="displayMd" style={styles.watermarkText}>SYNTHETIC</ThemedText>
</View>
)}
{freshness === 'stale' && (
<View testID="nlos-stale-overlay" pointerEvents="none" style={styles.staleOverlay}>
<ThemedText preset="labelLg" style={{ color: colors.danger }}>STALE FRAME</ThemedText>
</View>
)}
</View>
<View style={styles.visualizationStage}>
<HiddenTargetVisualization
tracks={visibleTracks}
freshness={freshness}
mode={viewMode}
width={visualizationWidth}
/>
{isSynthetic && (
<View testID="nlos-synthetic-watermark" pointerEvents="none" style={styles.watermark}>
<ThemedText preset="displayMd" style={styles.watermarkText}>SYNTHETIC</ThemedText>
</View>
)}
{freshness === 'stale' && (
<View testID="nlos-stale-overlay" pointerEvents="none" style={styles.staleOverlay}>
<ThemedText preset="labelLg" style={styles.staleText}>STALE FRAME</ThemedText>
<ThemedText preset="bodySm" style={styles.staleCaption}>Targets hidden until fresh evidence arrives</ThemedText>
</View>
)}
</View>
<View style={styles.summaryRow}>
<View style={styles.metric}>
<ThemedText testID="nlos-track-count" preset="displayMd">{visibleTracks.length}</ThemedText>
<ThemedText preset="bodySm" color="textSecondary">TRACKS</ThemedText>
<View style={styles.summaryRow}>
<View style={styles.metric}>
<ThemedText testID="nlos-track-count" preset="displayMd" style={styles.metricValue}>
{visibleTracks.length}
</ThemedText>
<ThemedText preset="mono" style={styles.metricLabel}>GATED TRACKS</ThemedText>
</View>
<View style={styles.metricDivider} />
<View style={styles.metric}>
<ThemedText testID="nlos-mean-confidence" preset="displayMd" style={styles.metricValue}>
{meanConfidence}
</ThemedText>
<ThemedText preset="mono" style={styles.metricLabel}>MEAN CONF.</ThemedText>
</View>
</View>
<View style={styles.metric}>
<ThemedText testID="nlos-mean-confidence" preset="displayMd">
{visibleTracks.length ? `${Math.round(visibleTracks.reduce((sum, track) => sum + track.confidence, 0) / visibleTracks.length * 100)}%` : 'N/A'}
</ThemedText>
<ThemedText preset="bodySm" color="textSecondary">MEAN CONFIDENCE</ThemedText>
</View>
</View>
</InstrumentPanel>
<View style={styles.actions}>
<Pressable accessibilityRole="button" onPress={startReplay} style={styles.secondaryButton}>
<ThemedText preset="labelMd">USE SYNTHETIC REPLAY</ThemedText>
<Pressable
testID="nlos-start-synthetic"
accessibilityRole="button"
accessibilityLabel="USE SYNTHETIC REPLAY"
onPress={startReplay}
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
>
<View style={styles.buttonLabelRow}>
<View style={styles.syntheticButtonDot} />
<ThemedText preset="labelMd" style={styles.secondaryButtonText}>USE SYNTHETIC REPLAY</ThemedText>
</View>
<ThemedText preset="bodySm" style={styles.buttonCaption}>Deterministic and watermarked</ThemedText>
</Pressable>
<Pressable
testID="nlos-connect-live"
accessibilityRole="button"
accessibilityLabel="CONNECT AUTHENTICATED LIVE"
disabled={!liveCredentialAvailable}
onPress={connectLive}
style={[styles.liveButton, !liveCredentialAvailable && styles.disabledButton]}
style={({ pressed }) => [
styles.liveButton,
!liveCredentialAvailable && styles.disabledButton,
pressed && liveCredentialAvailable && styles.buttonPressed,
]}
>
<ThemedText preset="labelMd" style={{ color: colors.bg }}>CONNECT AUTHENTICATED LIVE</ThemedText>
<ThemedText preset="labelMd" style={styles.liveButtonText}>CONNECT AUTHENTICATED LIVE</ThemedText>
<ThemedText preset="bodySm" style={styles.liveButtonCaption}>Ephemeral credential required</ThemedText>
</Pressable>
</View>
{!liveCredentialAvailable ? (
<View style={styles.credentialCard}>
<ThemedText preset="labelMd">EPHEMERAL LIVE CREDENTIAL</ThemedText>
<InstrumentPanel eyebrow="Secure live pairing" style={styles.credentialCard}>
<ThemedText preset="bodyMd" style={styles.credentialIntro}>
Enter a coordinator supplied credential to unlock the authenticated stream for this session.
</ThemedText>
<TextInput
testID="nlos-credential-input"
accessibilityLabel="Ephemeral NLOS Bearer credential"
@@ -151,34 +256,60 @@ export const NLOSScreen = () => {
textContentType="oneTimeCode"
maxLength={512}
placeholder="32 to 512 character pairing credential"
placeholderTextColor={colors.textSecondary}
placeholderTextColor={instrumentColors.textSecondary}
style={[styles.credentialInput, credentialError && styles.credentialInputError]}
/>
<Pressable
testID="nlos-unlock-live"
accessibilityRole="button"
accessibilityLabel="UNLOCK AUTHENTICATED LIVE"
disabled={!credentialLengthValid}
onPress={handleConfigureCredential}
style={[styles.credentialButton, !credentialLengthValid && styles.disabledButton]}
style={({ pressed }) => [
styles.credentialButton,
!credentialLengthValid && styles.disabledButton,
pressed && credentialLengthValid && styles.buttonPressed,
]}
>
<ThemedText preset="labelMd">UNLOCK AUTHENTICATED LIVE</ThemedText>
<ThemedText preset="labelMd" style={styles.credentialButtonText}>UNLOCK AUTHENTICATED LIVE</ThemedText>
</Pressable>
<ThemedText preset="bodySm" color="textSecondary">
<ThemedText preset="bodySm" style={styles.credentialNote}>
A native host or signed in web session may supply this credential automatically. It is held in memory only, sent solely in the ticket request Authorization header, and never stored by this client.
</ThemedText>
</View>
</InstrumentPanel>
) : (
<View style={styles.credentialReadyRow}>
<ThemedText preset="bodySm" style={{ color: colors.success }}>EPHEMERAL CREDENTIAL READY</ThemedText>
<Pressable accessibilityRole="button" onPress={forgetCredential}>
<ThemedText preset="labelMd" color="textSecondary">FORGET</ThemedText>
<View style={styles.readyIdentity}>
<View style={styles.readyDot} />
<ThemedText preset="bodySm" style={styles.readyText}>EPHEMERAL CREDENTIAL READY</ThemedText>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Forget ephemeral credential"
onPress={forgetCredential}
style={styles.forgetButton}
>
<ThemedText preset="labelMd" style={styles.forgetText}>FORGET</ThemedText>
</Pressable>
</View>
)}
{lastRejectedReason && (
<ThemedText testID="nlos-rejection" preset="bodySm" style={{ color: colors.danger }}>
Rejected {rejectedFrameCount} frame{rejectedFrameCount === 1 ? '' : 's'}; latest reason: {lastRejectedReason}
</ThemedText>
<View style={styles.rejectionCard}>
<ThemedText testID="nlos-rejection" preset="bodySm" style={styles.rejectionText}>
Rejected {rejectedFrameCount} frame{rejectedFrameCount === 1 ? '' : 's'}; latest reason: {lastRejectedReason}
</ThemedText>
</View>
)}
<BetaSetupCard />
<View testID="nlos-privacy-legend" style={styles.privacyLegend}>
<ThemedText preset="mono" style={styles.privacyLabel}>PRIVACY DEFAULTS</ThemedText>
<ThemedText preset="bodySm" style={styles.privacyCopy}>
Viewer retention: raw RF off, audio off, pairing credential memory only. Connected servers require their own consent and retention controls.
</ThemedText>
</View>
</ScrollView>
</ThemedView>
);
@@ -187,40 +318,257 @@ export const NLOSScreen = () => {
export default NLOSScreen;
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
content: { padding: spacing.md, paddingBottom: spacing.xxxl, gap: spacing.md },
header: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
notice: {
backgroundColor: 'rgba(255, 165, 2, 0.08)',
borderColor: 'rgba(255, 165, 2, 0.4)',
borderWidth: 1,
borderRadius: 10,
padding: spacing.md,
container: { flex: 1, backgroundColor: instrumentColors.background },
content: {
width: '100%',
maxWidth: 620,
alignSelf: 'center',
gap: spacing.md,
},
visualizationCard: {
position: 'relative',
overflow: 'hidden',
backgroundColor: colors.surface,
borderColor: colors.border,
brandBar: {
minHeight: 48,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
},
brandIdentity: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.md },
brandMark: {
width: 38,
height: 38,
borderRadius: 19,
borderColor: instrumentColors.cyanDim,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
brandMarkCore: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: instrumentColors.green,
shadowColor: instrumentColors.green,
shadowOpacity: 0.75,
shadowRadius: 8,
},
brandName: { color: instrumentColors.text, fontSize: 14, letterSpacing: 0.8 },
brandCaption: { color: instrumentColors.textSecondary, fontSize: 9, letterSpacing: 1.1 },
labsBadge: {
color: instrumentColors.cyan,
borderColor: instrumentColors.cyanDim,
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 10,
letterSpacing: 1.3,
},
hero: { paddingTop: spacing.xl, paddingBottom: spacing.xl },
heroTitle: {
color: instrumentColors.text,
fontSize: 32,
lineHeight: 36,
letterSpacing: -0.8,
},
heroAccent: {
color: instrumentColors.cyan,
fontSize: 32,
lineHeight: 36,
letterSpacing: -0.8,
textShadowColor: 'rgba(36, 211, 229, 0.3)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 14,
},
heroCopy: {
maxWidth: 470,
color: instrumentColors.textSecondary,
lineHeight: 23,
},
scopeRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.xs },
scopeChip: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
borderColor: instrumentColors.border,
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
scopeChipAccent: { borderColor: instrumentColors.greenDim },
scopeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: instrumentColors.textSecondary },
scopeDotAccent: { backgroundColor: instrumentColors.green },
scopeLabel: { color: instrumentColors.textSecondary, fontSize: 9, letterSpacing: 1 },
scopeLabelAccent: { color: instrumentColors.green },
notice: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
backgroundColor: 'rgba(255, 182, 92, 0.055)',
borderColor: 'rgba(255, 182, 92, 0.2)',
borderWidth: 1,
borderRadius: 12,
paddingTop: spacing.sm,
padding: spacing.md,
},
picker: { flexDirection: 'row', paddingHorizontal: spacing.sm, gap: spacing.sm },
pickerButton: { flex: 1, alignItems: 'center', paddingVertical: spacing.sm, borderBottomWidth: 2, borderBottomColor: colors.border },
pickerButtonSelected: { borderBottomColor: colors.accent },
watermark: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center', transform: [{ rotate: '-18deg' }] },
watermarkText: { color: 'rgba(255, 165, 2, 0.18)', letterSpacing: 5 },
staleOverlay: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(10, 14, 26, 0.7)', alignItems: 'center', justifyContent: 'center' },
summaryRow: { flexDirection: 'row', gap: spacing.md },
metric: { flex: 1, backgroundColor: colors.surface, borderRadius: 10, padding: spacing.md },
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
secondaryButton: { flexGrow: 1, alignItems: 'center', borderColor: colors.border, borderWidth: 1, borderRadius: 8, padding: spacing.md },
liveButton: { flexGrow: 1, alignItems: 'center', backgroundColor: colors.accent, borderRadius: 8, padding: spacing.md },
disabledButton: { opacity: 0.35 },
credentialCard: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1, borderRadius: 10, padding: spacing.md, gap: spacing.sm },
credentialInput: { borderColor: colors.border, borderWidth: 1, borderRadius: 8, padding: spacing.md, color: colors.textPrimary, backgroundColor: colors.bg },
credentialInputError: { borderColor: colors.danger },
credentialButton: { alignItems: 'center', borderColor: colors.accent, borderWidth: 1, borderRadius: 8, padding: spacing.md },
credentialReadyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderRadius: 10, padding: spacing.md },
noticeIcon: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: 'rgba(255, 182, 92, 0.12)',
borderColor: 'rgba(255, 182, 92, 0.34)',
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
noticeIconText: { color: instrumentColors.warning, fontSize: 13 },
noticeCopy: { flex: 1, gap: spacing.xs },
noticeLabel: { color: instrumentColors.warning, fontSize: 9, letterSpacing: 1.25 },
noticeText: { color: instrumentColors.textSecondary, lineHeight: 18 },
panelIndex: { color: instrumentColors.textSecondary, fontSize: 9, letterSpacing: 1 },
visualizationCard: { paddingHorizontal: 0, paddingBottom: 0 },
picker: {
flexDirection: 'row',
marginHorizontal: spacing.md,
padding: 3,
gap: 3,
backgroundColor: 'rgba(5, 9, 13, 0.7)',
borderColor: instrumentColors.border,
borderWidth: 1,
borderRadius: 12,
},
pickerButton: {
minHeight: 44,
flex: 1,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 9,
},
pickerButtonSelected: {
backgroundColor: 'rgba(36, 211, 229, 0.1)',
borderColor: instrumentColors.cyanDim,
borderWidth: 1,
},
visualizationStage: { position: 'relative', overflow: 'hidden' },
watermark: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
transform: [{ rotate: '-18deg' }],
},
watermarkText: {
color: 'rgba(255, 182, 92, 0.19)',
fontSize: 26,
letterSpacing: 6,
},
staleOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: instrumentColors.dimOverlay,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
},
staleText: { color: instrumentColors.danger },
staleCaption: { color: instrumentColors.textSecondary },
summaryRow: {
minHeight: 86,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(5, 9, 13, 0.58)',
borderTopColor: instrumentColors.border,
borderTopWidth: 1,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
metric: { flex: 1, gap: spacing.xs },
metricValue: { color: instrumentColors.text, fontSize: 26 },
metricLabel: { color: instrumentColors.textSecondary, fontSize: 9, letterSpacing: 1.1 },
metricDivider: { width: 1, height: 42, backgroundColor: instrumentColors.border, marginHorizontal: spacing.md },
actions: { gap: spacing.sm },
secondaryButton: {
minHeight: 62,
justifyContent: 'center',
borderColor: 'rgba(255, 182, 92, 0.36)',
borderWidth: 1,
borderRadius: 12,
backgroundColor: 'rgba(255, 182, 92, 0.065)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
gap: spacing.xs,
},
buttonLabelRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
syntheticButtonDot: { width: 7, height: 7, borderRadius: 4, backgroundColor: instrumentColors.warning },
secondaryButtonText: { color: instrumentColors.warning },
buttonCaption: { color: instrumentColors.textSecondary },
liveButton: {
minHeight: 62,
justifyContent: 'center',
borderRadius: 12,
backgroundColor: instrumentColors.cyan,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
gap: spacing.xs,
},
liveButtonText: { color: instrumentColors.background },
liveButtonCaption: { color: 'rgba(5, 9, 13, 0.68)' },
disabledButton: { opacity: 0.36 },
buttonPressed: { opacity: 0.72, transform: [{ scale: 0.992 }] },
credentialCard: { backgroundColor: instrumentColors.panelRaised },
credentialIntro: { color: instrumentColors.textSecondary, lineHeight: 20 },
credentialInput: {
minHeight: 50,
borderColor: instrumentColors.borderStrong,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
color: instrumentColors.text,
backgroundColor: instrumentColors.background,
fontFamily: 'Courier New',
fontSize: 14,
},
credentialInputError: { borderColor: instrumentColors.danger },
credentialButton: {
minHeight: 48,
alignItems: 'center',
justifyContent: 'center',
borderColor: instrumentColors.cyanDim,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: spacing.md,
},
credentialButtonText: { color: instrumentColors.cyan, textAlign: 'center' },
credentialNote: { color: instrumentColors.textSecondary, lineHeight: 18 },
credentialReadyRow: {
minHeight: 60,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: instrumentColors.panelRaised,
borderColor: instrumentColors.greenDim,
borderWidth: 1,
borderRadius: 12,
paddingLeft: spacing.lg,
gap: spacing.md,
},
readyIdentity: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
readyDot: { width: 7, height: 7, borderRadius: 4, backgroundColor: instrumentColors.green },
readyText: { color: instrumentColors.green },
forgetButton: { minWidth: 72, minHeight: 44, alignItems: 'center', justifyContent: 'center' },
forgetText: { color: instrumentColors.textSecondary },
rejectionCard: {
backgroundColor: 'rgba(255, 100, 120, 0.07)',
borderColor: 'rgba(255, 100, 120, 0.25)',
borderWidth: 1,
borderRadius: 12,
padding: spacing.md,
},
rejectionText: { color: instrumentColors.danger, lineHeight: 18 },
privacyLegend: {
borderTopColor: instrumentColors.border,
borderTopWidth: 1,
paddingTop: spacing.lg,
gap: spacing.xs,
},
privacyLabel: { color: instrumentColors.green, fontSize: 10, letterSpacing: 1.2 },
privacyCopy: { color: instrumentColors.textSecondary, lineHeight: 18 },
});