mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-31 20:45:58 +00:00
feat(mobile): adopt NLOS instrument interface
This commit is contained in:
154
.github/workflows/consumer-nlos-ci.yml
vendored
154
.github/workflows/consumer-nlos-ci.yml
vendored
@@ -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
|
||||
|
||||
512
docs/adr/ADR-342-cognitum-inspired-mobile-instrument-ui.md
Normal file
512
docs/adr/ADR-342-cognitum-inspired-mobile-instrument-ui.md
Normal 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.
|
||||
15
docs/screenshots/consumer-nlos-mobile-ui/README.md
Normal file
15
docs/screenshots/consumer-nlos-mobile-ui/README.md
Normal 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`.
|
||||
BIN
docs/screenshots/consumer-nlos-mobile-ui/overview-390x844.png
Normal file
BIN
docs/screenshots/consumer-nlos-mobile-ui/overview-390x844.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/screenshots/consumer-nlos-mobile-ui/setup-390x844.png
Normal file
BIN
docs/screenshots/consumer-nlos-mobile-ui/setup-390x844.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/screenshots/consumer-nlos-mobile-ui/synthetic-390x844.png
Normal file
BIN
docs/screenshots/consumer-nlos-mobile-ui/synthetic-390x844.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
3
ui/mobile/.gitignore
vendored
3
ui/mobile/.gitignore
vendored
@@ -6,6 +6,9 @@ node_modules/
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
dist-e2e/
|
||||
test-results/
|
||||
playwright-report/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
|
||||
39
ui/mobile/e2e/nlos_mobile_ui.yaml
Normal file
39
ui/mobile/e2e/nlos_mobile_ui.yaml
Normal 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"
|
||||
109
ui/mobile/e2e/web/nlos.mobile.spec.ts
Normal file
109
ui/mobile/e2e/web/nlos.mobile.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
49
ui/mobile/e2e/web/serve.mjs
Normal file
49
ui/mobile/e2e/web/serve.mjs
Normal 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`);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)/)',
|
||||
],
|
||||
|
||||
64
ui/mobile/package-lock.json
generated
64
ui/mobile/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
38
ui/mobile/playwright.config.ts
Normal file
38
ui/mobile/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
155
ui/mobile/src/components/InstrumentPanel.tsx
Normal file
155
ui/mobile/src/components/InstrumentPanel.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user