diff --git a/docs/src/ground-suite.md b/docs/src/ground-suite.md
index dc5662a..c082cf4 100644
--- a/docs/src/ground-suite.md
+++ b/docs/src/ground-suite.md
@@ -1,72 +1,67 @@
# Ground Suite
-`ground-suite/` is Kaptan's desktop Flutter application. Its primary
-workspace is the Tactical Command surface: a spatial local scene, local units,
-and session-local drawings and markers. The decoded diagnostic event stream is
-always reachable from the tactical header.
+`ground-suite/` is Kaptan's web-based mission-control workspace. It is a
+map-first React and TypeScript application for working from mission intent to
+tasks, groups, roles, vehicles, and explicit engineering detail.
+
+> **Simulation boundary:** the first release always displays deterministic
+> simulated data. It has no live Kaptan or Cerebrum connection and cannot
+> command a physical vehicle.
## Application model
-The core model keeps these concepts independent:
+The core model keeps these categories independent:
-- **Vehicle definition** — immutable reviewed profile, catalog identity, and
- bundled presentation media.
-- **Local fleet unit** — an operator-created instance of a vehicle definition.
- Local-unit IDs and in-memory planning placements belong to the workspace,
- not the vehicle definition. Current placement is local presentation data and
- is not yet persisted.
-- **Received vehicle data** — transport input and diagnostic events. It cannot
- create, position, identify, or authorize a local fleet unit.
+- **Mission truth** — source-owned missions, tasks, groups, vehicles,
+ operators, authority, alerts, and command receipts.
+- **Derived projections** — fleet health, task coverage, selection summaries,
+ and deterministic recommendations.
+- **Local UI state and preferences** — panels, viewport, layers, density,
+ reduced motion, and configurable hotkeys.
+- **Audit journal** — ordered source and command envelopes.
This separation gives the fleet interface a stable path from local planning to
future live operation without coupling visual selection to incoming telemetry.
## Tactical workspace
-The default route is Tactical Command. Its map-edge `UNITS`, `CURSOR`, and
-`VIEW` groups are local interactions, not MAVLink controls. `Selection cursor`
-is the default mode for map and rectangle selection. `Pencil cursor` creates
-session-local freehand strokes; its top toolbar controls hue, saturation,
-brightness, and thickness. `Marker cursor` has an independent full-color
-style and creates a named local marker only after a map click and explicit
-save. The shared disclosure keeps local scene data,
-vehicle link state, and command authority separate. Local drawings and markers
-are never reported vehicle positions.
+The central map uses an owned offline demonstration raster and local SVG/DOM
+overlays. Four semantic levels progressively disclose group, formation,
+vehicle telemetry, and engineering diagnostics. Click, Shift selection,
+rectangle selection, structured smart filters, configurable group keys, and
+accessible tables all operate on the same explicit selection model. Selection
+never grants command authority.
-`Add local unit` opens the horizontal local hangar. Choosing a definition only
-prepares a local unit draft; the explicit setup confirmation creates and
-selects a locally scattered scene unit, without asking the operator to click
-the map. Local setup currently permits only the workspace label. Vehicle
-configuration remains unavailable until verified binding, compatible schema,
-command authority, and safe activation are available. This presentation
-placement is temporary until reviewed MAVLink position data can drive a
-distinct reported-position layer. The right inspector and bottom selection
-console are available outside the map for keyboard and assistive-technology
-access. These interactions create only session-local workspace data.
+Consequential actions go through an exact-target preview covering authority,
+energy, link, staleness, constraints, connectivity, next state, and failure
+effect. The deterministic source then emits an acknowledgement or rejection
+into the same journal used for live mission projection.
## Architecture
```text
-VehicleDataPort -> typed observation updates -> observability state -> event stream
-Vehicle catalog -> local-unit workspace state -> tactical scene and unit inspector
+DataSource -> validation -> ordering/deduplication -> DataPlane journal
+ -> mission projection/selectors -> useSyncExternalStore -> React
+
+UI intent -> preview/authority/safety -> confirmation -> DataSource receipt
+ -> DataPlane journal -> mission projection
```
-The transport path and local-fleet path intentionally meet only through future
-explicit bindings. A future MAVLink adapter combines pinned MAVLink Common and
-Kaptan dialect definitions at the decoding boundary.
+The fake and future Kaptan sources implement the same transport-neutral
+contract. React never imports the fake adapter. See `ground-suite/docs/` for
+the complete boundary and integration unknowns.
## Follow-up work
-- TODO(gcs-fleet-storage): versioned persistence for local units and catalog
- identities.
-- TODO(gcs-source-binding): explicit source-to-unit binding.
+- TODO(gcs-source-binding): authenticated explicit source-to-vehicle binding.
- TODO(gcs-map-pose): coordinate datum, timestamped pose, uncertainty, and
staleness handling for physical map placement.
- TODO(gcs-map-renderer): owned Linux GPU map host, pinned MapLibre assets,
and reviewed vector/DEM data source.
- TODO(gcs-platform-match): compare reported build/schema metadata to the
local definition.
-- TODO(gcs-command-surface): add command interaction only with its reviewed
- authority, target-binding, and safety contracts.
+- TODO(gcs-command-surface): add a command transport only after its authority,
+ authentication, replay protection, target binding, and acknowledgement
+ contracts are reviewed.
- TODO(gcs-catalog): generate installed definitions and category metadata from
reviewed firmware build manifests.
diff --git a/flake.nix b/flake.nix
index 1b07c2a..cac015d 100644
--- a/flake.nix
+++ b/flake.nix
@@ -10,8 +10,64 @@
"aarch64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
+ perSystem = system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+ groundSuite = pkgs.buildNpmPackage {
+ pname = "kaptan-ground-suite";
+ version = "0.1.0";
+ src = ./ground-suite;
+ npmDepsHash = "sha256-l217EpOz2oEBQK6bzJV0DSEdBkK7HIlB4a7JMF+FadA=";
+ npmBuildScript = "check";
+ PLAYWRIGHT_CHROMIUM_EXECUTABLE = "${pkgs.chromium}/bin/chromium";
+ installPhase = ''
+ runHook preInstall
+ mkdir -p $out/libexec/ground-suite
+ cp -R dist node_modules src public scripts tests package.json package-lock.json tsconfig.json tsconfig.app.json vite.config.ts vitest.config.ts playwright.config.ts $out/libexec/ground-suite/
+ runHook postInstall
+ '';
+ };
+ groundSuiteCommand = pkgs.writeShellApplication {
+ name = "ground-suite";
+ runtimeInputs = [ pkgs.nodejs pkgs.coreutils pkgs.chromium ];
+ text = ''
+ suite_root=${groundSuite}/libexec/ground-suite
+ if [ "''${1:-}" = test ]; then
+ shift
+ test_root=$(mktemp -d -t kaptan-ground-suite-test.XXXXXX)
+ trap 'rm -rf "$test_root"' EXIT
+ cp -R "$suite_root" "$test_root/suite"
+ chmod -R u+w "$test_root/suite"
+ cd "$test_root/suite"
+ export PLAYWRIGHT_CHROMIUM_EXECUTABLE=${pkgs.chromium}/bin/chromium
+ npm test -- "$@"
+ exit $?
+ fi
+ exec node "$suite_root/scripts/server.mjs" "$@"
+ '';
+ };
+ in { inherit pkgs groundSuite groundSuiteCommand; };
in
{
+ packages = forAllSystems (system:
+ let values = perSystem system; in {
+ ground-suite = values.groundSuite;
+ default = values.groundSuite;
+ });
+
+ apps = forAllSystems (system:
+ let values = perSystem system; in {
+ ground-suite = {
+ type = "app";
+ program = "${values.groundSuiteCommand}/bin/ground-suite";
+ };
+ });
+
+ checks = forAllSystems (system:
+ let values = perSystem system; in {
+ ground-suite = values.groundSuite;
+ });
+
devShells = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
@@ -30,6 +86,14 @@
python
];
};
+ ground-suite = pkgs.mkShell {
+ packages = [ pkgs.nodejs ];
+ shellHook = ''
+ echo "Kaptan Ground Suite development shell (Node $(node --version), npm $(npm --version))"
+ echo "Run: cd ground-suite && npm ci && npm run dev"
+ echo "SIMULATED DATA ONLY"
+ '';
+ };
});
};
}
diff --git a/ground-suite-design-files/2026-08-15-183128.jpeg b/ground-suite-design-files/2026-08-15-183128.jpeg
new file mode 100644
index 0000000..83c919f
Binary files /dev/null and b/ground-suite-design-files/2026-08-15-183128.jpeg differ
diff --git a/ground-suite-design-files/2026-08-15-183128_002.jpeg b/ground-suite-design-files/2026-08-15-183128_002.jpeg
new file mode 100644
index 0000000..4ecddda
Binary files /dev/null and b/ground-suite-design-files/2026-08-15-183128_002.jpeg differ
diff --git a/ground-suite-design-files/2026-08-15-183128_003.jpeg b/ground-suite-design-files/2026-08-15-183128_003.jpeg
new file mode 100644
index 0000000..4f224ff
Binary files /dev/null and b/ground-suite-design-files/2026-08-15-183128_003.jpeg differ
diff --git a/ground-suite-design-files/2026-08-15-183128_004.jpeg b/ground-suite-design-files/2026-08-15-183128_004.jpeg
new file mode 100644
index 0000000..e3d469f
Binary files /dev/null and b/ground-suite-design-files/2026-08-15-183128_004.jpeg differ
diff --git a/ground-suite-design-files/2026-08-15-183128_005.jpeg b/ground-suite-design-files/2026-08-15-183128_005.jpeg
new file mode 100644
index 0000000..7165010
Binary files /dev/null and b/ground-suite-design-files/2026-08-15-183128_005.jpeg differ
diff --git a/ground-suite-design-files/2026-08-15-183128_006.jpeg b/ground-suite-design-files/2026-08-15-183128_006.jpeg
new file mode 100644
index 0000000..f50ba54
Binary files /dev/null and b/ground-suite-design-files/2026-08-15-183128_006.jpeg differ
diff --git a/ground-suite-design-files/2026-08-15-183128_007.jpeg b/ground-suite-design-files/2026-08-15-183128_007.jpeg
new file mode 100644
index 0000000..8bbc569
Binary files /dev/null and b/ground-suite-design-files/2026-08-15-183128_007.jpeg differ
diff --git a/ground-suite-design-files/app.js b/ground-suite-design-files/app.js
new file mode 100644
index 0000000..ac36552
--- /dev/null
+++ b/ground-suite-design-files/app.js
@@ -0,0 +1,360 @@
+/* Atlas Mission Control POC — all telemetry and operators are fictional. */
+const groups = [
+ { id: 'alpha', name: 'ALPHA', count: 12, task: 'Area Search', operator: 'Alex', progress: 82, health: 'nominal', color: 'cyan', icon: 'A', current: 'Search area north', next: 'Return', lease: 'Alex' },
+ { id: 'bravo', name: 'BRAVO', count: 10, task: 'Route Recon', operator: 'Sara', progress: 71, health: 'nominal', color: 'blue', icon: 'B', current: 'Coastal route', next: 'Survey pass', lease: 'Sara' },
+ { id: 'charlie', name: 'CHARLIE', count: 8, task: 'Relay', operator: 'David', progress: 64, health: 'nominal', color: 'violet', icon: 'C', current: 'Relay station', next: 'Hold', lease: 'David' },
+ { id: 'delta', name: 'DELTA', count: 12, task: 'Reserve', operator: 'Maria', progress: 35, health: 'attention', color: 'amber', icon: 'D', current: 'Ready reserve', next: 'Awaiting task', lease: 'Maria' },
+ { id: 'reserve', name: 'RESERVE', count: 8, task: 'Available', operator: 'Ken', progress: 100, health: 'nominal', color: 'violet', icon: 'R', current: 'Base service', next: 'Available', lease: 'Ken' }
+];
+
+const operators = [
+ { id: 'alex', name: 'Alex', role: 'Commanding Alpha', color: 'cyan', activity: 'selected Alpha' },
+ { id: 'sara', name: 'Sara', role: 'Commanding Bravo', color: 'green', activity: 'updated Bravo search area' },
+ { id: 'david', name: 'David', role: 'Planning', color: 'amber', activity: 'drafted Task-144' },
+ { id: 'maria', name: 'Maria', role: 'Monitoring', color: 'violet', activity: 'added monitoring note' },
+ { id: 'ken', name: 'Ken', role: 'Engineering', color: 'yellow', activity: 'updated comms plan' }
+];
+
+const alphaVehicles = [
+ ['UAV-01', 18, 32, 92], ['UAV-02', 25, 39, 86], ['UAV-03', 33, 28, 43],
+ ['UAV-04', 39, 44, 80], ['UAV-05', 45, 35, 74], ['UAV-06', 53, 45, 88],
+ ['UAV-07', 60, 30, 74], ['UAV-08', 66, 42, 83], ['UAV-09', 72, 35, 79],
+ ['UAV-10', 76, 51, 90], ['UAV-11', 55, 57, 82], ['UAV-12', 34, 57, 76]
+].map((item, index) => ({ id: item[0], x: item[1], y: item[2], energy: item[3], heading: 20 + index * 16, status: item[0] === 'UAV-03' ? 'critical' : 'nominal' }));
+
+const availableVehicles = [
+ ['ALP-01', 18, 50, 92], ['ALP-02', 31, 47, 71], ['ALP-03', 17, 35, 82], ['ALP-04', 72, 42, 65],
+ ['ALP-05', 71, 55, 80], ['ALP-06', 64, 69, 74], ['ALP-07', 35, 18, 78], ['ALP-08', 38, 72, 61]
+].map(item => ({ id: item[0], x: item[1], y: item[2], energy: item[3], heading: 30, status: 'nominal' }));
+
+const requestedView = new URLSearchParams(window.location.search).get('view');
+const validViews = ['overview', 'zoom', 'selection', 'collaboration', 'recovery', 'queue', 'system'];
+
+let state = {
+ view: validViews.includes(requestedView) ? requestedView : 'overview',
+ selectedGroup: 'alpha',
+ selectedVehicle: 'UAV-07',
+ selectedCandidate: 'UAV-19',
+ modal: null,
+ toast: '',
+ layersOpen: false,
+ reassigned: false,
+ queueDraft: false,
+ queue: [
+ { title: 'Transit', detail: 'to Northern Sector', duration: '8 min', status: 'completed' },
+ { title: 'Search Area A', detail: 'coverage pattern', duration: '18 min', status: 'current' },
+ { title: 'Loiter', detail: '6 min', duration: '6 min', status: 'queued' },
+ { title: 'Return', detail: 'staging point', duration: '12 min', status: 'queued' },
+ { title: 'Recharge', detail: 'base service', duration: '35 min', status: 'queued' },
+ { title: 'Search Area B', detail: 'coverage pattern', duration: '20 min', status: 'queued' }
+ ]
+};
+
+const icons = {
+ menu: ' ',
+ map: ' ',
+ layers: ' ',
+ plane: ' ',
+ people: ' ',
+ task: ' ',
+ alert: ' ',
+ shield: ' ',
+ chevron: ' ',
+ plus: ' ',
+ close: ' ',
+ target: ' ',
+ transfer: ' ',
+ hold: ' ',
+ move: ' ',
+ clock: ' ',
+ check: ' ',
+ search: ' ',
+ info: ' ',
+ bolt: ' ',
+ route: ' ',
+ settings: ' '
+};
+
+function icon(name, extra = '') {
+ return '';
+}
+
+function groupById(id) {
+ return groups.find(group => group.id === id) || groups[0];
+}
+
+function statusTag(status, label) {
+ const text = label || ({ nominal: 'Nominal', attention: 'Attention', critical: 'Critical', planned: 'Planned' }[status] || status);
+ return ' ' + text + ' ';
+}
+
+function button(label, action, options = {}) {
+ const cls = 'button ' + (options.kind || 'secondary') + (options.iconOnly ? ' icon-button' : '') + (options.className ? ' ' + options.className : '');
+ const attrs = (options.disabled ? ' disabled' : '') + (options.label ? ' aria-label="' + options.label + '"' : '') + (options.value ? ' data-value="' + options.value + '"' : '');
+ return '' + (options.icon ? icon(options.icon) : '') + (options.iconOnly ? '' : '' + label + ' ') + ' ';
+}
+
+function navItem(id, label, iconName) {
+ return '' + icon(iconName) + '' + label + ' ';
+}
+
+function topBar() {
+ return '';
+}
+
+function mapBase() {
+ return '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ 'North Point Cliffside Seabridge West Cove ' +
+ '
2 km
';
+}
+
+function mapControls() {
+ return '' + button('', 'toggle-layers', { icon: 'layers', iconOnly: true, label: 'Toggle map layers' }) + button('', 'map-zoom-in', { icon: 'plus', iconOnly: true, label: 'Zoom map in' }) + button('−', 'map-zoom-out', { iconOnly: true, label: 'Zoom map out' }) + '
' +
+ (state.layersOpen ? 'Map layers Search areas Planned routes Constraints Map labels
' : '');
+}
+
+function taskArea(label = 'ALPHA\nSEARCH AREA', classes = '') {
+ return '
' + label.replace('\n', ' ') + ' ';
+}
+
+function groupMarker(group, x, y, active = false) {
+ return '' +
+ '' + group.icon + ' ' + group.name + ' ' + group.count + ' UAV · ' + group.task + ' · ' + group.progress + '% ';
+}
+
+function vehicleMarker(vehicle, selected = false, label = false) {
+ const critical = vehicle.status === 'critical';
+ return '' +
+ icon('plane') + (label ? '' + vehicle.id + ' ' : '') + ' ';
+}
+
+function routeLine(className = '') {
+ return ' ';
+}
+
+function overviewMap() {
+ const g = groupById(state.selectedGroup);
+ return '' + mapBase() + mapControls() + routeLine() + taskArea(g.name + '\nAREA ' + (g.task === 'Area Search' ? 'SEARCH' : g.task.toUpperCase()), 'alpha-area') +
+ '
' +
+ alphaVehicles.map(vehicle => vehicleMarker(vehicle, false, vehicle.id === 'UAV-03')).join('') +
+ groupMarker(groups[1], 65, 27) + groupMarker(groups[2], 69, 50) + groupMarker(groups[3], 73, 68) + groupMarker(groups[4], 57, 82) +
+ '
' +
+ '
Exceptions (3) ' + icon('alert') + ' UAV-27 · Navigation degraded
' + icon('info') + ' Alpha task behind schedule
Review recovery ' +
+ '
';
+}
+
+function groupRail(compact = false) {
+ return 'Groups ' + button('', 'new-group', { icon: 'plus', iconOnly: true, label: 'Create a new group' }) + '
' +
+ '' + groups.map(group => {
+ const selected = state.selectedGroup === group.id;
+ return '' +
+ '' + group.icon + ' ' + group.name + ' ' + group.count + ' UAV ' + group.task + ' ' + group.operator + ' ' +
+ '' + statusTag(group.health, '') + '' + group.progress + '% ' + Array(9).fill(' ').join('') + ' ';
+ }).join('') + '
' + button('New group', 'new-group', { icon: 'plus', className: 'full-width' }) + ' ';
+}
+
+function attentionRail() {
+ return 'Attention ' + button('', 'view-alerts', { icon: 'alert', iconOnly: true, label: 'View all alerts' }) + '
' +
+ '' + icon('alert') + 'UAV-27 Navigation degraded Since 01:32:11 ALPHA ' +
+ '' + icon('clock') + 'Alpha task behind schedule ETA impact +00:08:45 ALPHA ' +
+ '' + icon('alert') + 'Bravo approaching constraint R-210B coastal limit BRAVO ' +
+ '' + icon('check') + '
47 vehicles nominal No action required
' +
+ ' ';
+}
+
+function contextBar() {
+ const group = groupById(state.selectedGroup);
+ return '' + group.icon + '
' + group.name + ' ' + group.count + ' vehicles
Task ' + group.task + '
Progress ' + group.progress + '% complete
Operator ' + group.operator + '
Status ' + statusTag(group.health) + '
Current ' + group.current + '
Next ' + group.next + ' ' +
+ '' + button('Task', 'open-preview', { icon: 'task' }) + button('Move', 'move-group', { icon: 'move' }) + button('Hold', 'hold-group', { icon: 'hold' }) + button('Group', 'group-vehicles', { icon: 'people' }) + button('Split', 'split-group', { icon: 'route' }) + button('Transfer', 'open-transfer', { icon: 'transfer' }) + button('Details', 'open-vehicle-detail', { icon: 'info' }) + '
';
+}
+
+function overviewView() {
+ return '' + groupRail() + '' + attentionRail() + contextBar() + ' ';
+}
+
+function zoomSmallMap(kind) {
+ if (kind === 'strategic') {
+ return '' + mapBase() + taskArea('TASK 143', 'small-task violet') + groupMarker(groups[0], 35, 33, true) + groupMarker(groups[1], 20, 57) + groupMarker(groups[2], 38, 75) + groupMarker(groups[3], 68, 61) + '
' + groups.map(g => '' + g.name + ' ').join('') + '
';
+ }
+ if (kind === 'operational') {
+ return '' + mapBase() + taskArea('AREA SEARCH\n82%', 'wide alpha-area') + routeLine() + alphaVehicles.map(v => vehicleMarker(v)).join('') + '
Area search 82% Alex
';
+ }
+ return '' + mapBase() + taskArea('', 'wide alpha-area muted') + routeLine() + alphaVehicles.map(v => vehicleMarker(v, v.id === 'UAV-07')).join('') + '
UAV-07 ' + button('', 'close-inspector', { icon: 'close', iconOnly: true, label: 'Close vehicle inspector' }) + '
ALPHA GROUP
Position 34.72245° N 18.28512° W
Heading 128°
Energy 74%
Navigation Nominal
Communication Good
Payload Imaging Current task Search Area North
Next action Sweep 4
';
+}
+
+function zoomFooter(kind) {
+ const content = kind === 'strategic' ? 'Groups and task intent High-level view of mission groups and task areas.
' : kind === 'operational' ? 'Group composition and coverage Coordinated group view with coverage progress.
' : 'Vehicle details on request Progressive disclosure for a selected vehicle.
';
+ return '';
+}
+
+function zoomView() {
+ return 'Semantic zoom — shared multi-UAV mission planner Seamless transition from strategic intent to operational control to individual vehicle detail.
' + statusTag('blue', 'Active') + statusTag('cyan', 'Selected') + statusTag('nominal', 'Nominal') + statusTag('attention', 'Attention') + statusTag('planned', 'Planned') + '
' +
+ 'A Strategic ' + button('', 'zoom-strategic', { icon: 'target', iconOnly: true, label: 'View strategic detail' }) + ' ' + zoomSmallMap('strategic') + zoomFooter('strategic') + ' ' +
+ 'B Operational — Alpha ' + button('', 'zoom-operational', { icon: 'target', iconOnly: true, label: 'View operational detail' }) + ' ' + zoomSmallMap('operational') + zoomFooter('operational') + ' ' +
+ 'C Vehicle — UAV-07 ' + button('', 'zoom-vehicle', { icon: 'target', iconOnly: true, label: 'View vehicle detail' }) + ' ' + zoomSmallMap('vehicle') + zoomFooter('vehicle') + ' ' +
+ ' ';
+}
+
+function filterChip(label, value) {
+ return '' + label + ' ' + value + ' ' + icon('close') + '
';
+}
+
+function selectionMap() {
+ return '' + mapBase() + '
' + taskArea('TASK-143\nAREA SEARCH', 'violet') + '
' + routeLine('selection-routes') + availableVehicles.map(v => vehicleMarker(v, true, true)).join('') + '
TASK-143 Area search
Priority High
Assigned Alpha / 4
Area Northern sector
Progress 63%
Required 4 UAV
Health Green ' + icon('clock') + ' Estimated completion 18 min ' + mapControls() + '
';
+}
+
+function smartSelectionView() {
+ return 'Smart selection ' + button('', 'show-filter-help', { icon: 'info', iconOnly: true, label: 'Selection help' }) + 'Natural language query ' + icon('search') + '
Available ISR aircraft within 20 km of Task Bravo with more than 60% energy.
Structured filters Clear all
' + filterChip('Type', 'ISR') + filterChip('Availability', 'Available') + filterChip('Distance', '< 20 km') + filterChip('Energy', '> 60%') + filterChip('Task', 'None') + '
Results 8 vehicles selected Filter distribution
Type (ISR) 32
Availability 12
Distance 9
Energy 8 ' + button('Assign task', 'open-preview', { icon: 'task', kind: 'primary', className: 'full-width' }) + 'Last updated: 14:32:10 UTC ' + assignPanel() + ' ';
+}
+
+function validationRow(label, value, stateName = 'ok') {
+ return '' + icon(stateName === 'warning' ? 'alert' : stateName === 'info' ? 'info' : 'check') + '' + label + ' ' + value + ' ' + (stateName === 'ok' ? '' + icon('check') + ' ' : '') + '
';
+}
+
+function assignPanel() {
+ return 'Assign task 6 vehicles selected
' + button('', 'close-assign', { icon: 'close', iconOnly: true, label: 'Close assignment panel' }) + '
Task TASK-143 / AREA SEARCH
Area Northern sector Validation summary ' + validationRow('Estimated completion', '18 min') + validationRow('Energy reserve', 'All above minimum') + validationRow('Route conflicts', 'None detected') + validationRow('Constraint conflicts', 'None detected') + validationRow('Vehicle compatibility', '6 / 6 compatible') + validationRow('Communication link', 'All links good') + validationRow('Assignment considerations', '1 aircraft may require reassignment.', 'warning') + '
' + icon('info') + '
Review the preview to confirm routing, timing, and resource allocation before assigning.
' + button('Cancel', 'cancel-modal') + button('Preview', 'open-preview', { kind: 'secondary' }) + button('Confirm', 'confirm-assignment', { icon: 'check', kind: 'primary' }) + ' ';
+}
+
+function collabMap() {
+ return '' + mapBase() + '
' + taskArea('ALPHA\n3 UAV · 1 USV', 'alpha-area') + alphaVehicles.slice(0, 3).map(v => vehicleMarker(v)).join('') + '
' +
+ groupMarker(groups[1], 32, 23) + groupMarker(groups[2], 73, 23) + groupMarker(groups[3], 73, 53) + groupMarker(groups[4], 60, 75) +
+ operators.map((op, i) => '
◢ ' + op.name + ' ').join('') +
+ '
Command authority Alex lease expires 02:34
Observers ' + statusTag('nominal', 'Sara') + statusTag('amber', 'David') + '
' +
+ '
';
+}
+
+function collaborationView() {
+ return '' + groupRail(true) + '' + operators.map(op => '' + op.name + ' ' + op.role + ' ⌄ ').join('') + '
Collaboration stream View all ' + operators.slice().reverse().map((op, i) => '
' + op.activity + ' ' + (i * 2 + 2) + 'm ago
').join('') + '
Mission events 14:30:41Z Alex selected Alpha 14:28:15Z David drafted Task-144 14:26:32Z Sara updated Bravo search area 14:24:10Z Maria added monitoring note
Shared awareness · authority changes are explicit and auditable.
';
+}
+
+function recoveryMap() {
+ return '' + mapBase() + taskArea('ALPHA\nSEARCH AREA', 'alpha-area') + routeLine() + alphaVehicles.slice(0, 4).map(v => vehicleMarker(v, false, true)).join('') + '
! UAV-03 Unavailable ' + icon('people') + 'Alpha Search Search and locate pattern
' + (state.reassigned ? '4 / 4' : '3 / 4') + 'assigned ';
+}
+
+function candidateCard(id, energy, distance, compatibility, selected) {
+ return '' + icon('people') + '' + id + ' ' + statusTag('nominal', 'Ready') + ' ' + (selected ? '' + icon('check') + ' ' : '') + '
Energy ' + energy + '% ' + statusTag('nominal', '') + '
Distance to task ' + distance + ' km
Task compatibility ' + compatibility + '
Communication Strong ' + (selected ? '' : '') + ' ';
+}
+
+function recoveryView() {
+ const active = state.selectedCandidate;
+ return '' + icon('shield') + ' Keep TASK-143 on plan Current state
Task Alpha Search
Required 4 vehicles
Current ' + (state.reassigned ? '4 / 4' : '3 / 4') + 'assigned
Completion impact ' + (state.reassigned ? 'None' : '+11 minutes') + ' Recommended replacements ' + icon('info') + ' Select a vehicle to review. This is a system recommendation for operator approval.
' + candidateCard('UAV-19', 78, 6.2, 'High', active === 'UAV-19') + candidateCard('UAV-27', 64, 9.8, 'High', active === 'UAV-27') + candidateCard('UAV-31', 82, 12.4, 'Medium', active === 'UAV-31') + '
' + icon('info') + ' Recommended because it can join before coverage gap exceeds threshold.
Local preview of reassignment ' + icon('plane') + ' ' + active + ' ALPHA SEARCH AREA
' + button('Cancel', 'cancel-modal') + button('Compare candidates', 'compare-candidates') + button(state.reassigned ? 'Recommendation accepted' : 'Accept recommendation', 'accept-replacement', { icon: 'shield', kind: 'primary', disabled: state.reassigned }) + ' ' + icon('alert') + ' 14:33:02 system detected UAV-03 unavailable ' + icon('shield') + ' 14:33:04 system evaluated replacement options ' + icon('info') + ' 14:33:17 operator reviewing recommendation ';
+}
+
+function queueMap() {
+ return '' + mapBase() + routeLine() + '
⌾Base Service
' + taskArea('Search Area A', 'queue-search') + taskArea('Search Area B', 'queue-search future') + '
A ALPHA 12
A Alpha ' + icon('people') + '12 vehicles
' + icon('search') + 'Search
' + icon('people') + 'Operator Alex
' + statusTag('nominal') + ' ';
+}
+
+function queueStep(step, index) {
+ return '⠿ ' + (index + 1) + ' ' + step.title + ' ' + step.detail + '
' + (step.status === 'current' ? '
82% complete ' : '') + '
Alex ' + step.duration + ' ' + statusTag(step.status === 'completed' ? 'nominal' : step.status === 'current' ? 'blue' : 'planned', step.status) + '' + button('', 'queue-up', { icon: 'chevron', iconOnly: true, label: 'Move step up', value: index }) + button('', 'queue-down', { icon: 'chevron', iconOnly: true, label: 'Move step down', value: index }) + '
';
+}
+
+function queueView() {
+ const current = state.queue[1];
+ return 'Alpha · Intent queue' + statusTag('planned', 'Draft changes') + button('', 'queue-menu', { icon: 'settings', iconOnly: true, label: 'Queue options' }) + '
' + state.queue.map(queueStep).join('') + '
⠿ Drop step here to reorder
Step 2 of 6 ' + button('', 'close-inspector', { icon: 'close', iconOnly: true, label: 'Close step inspector' }) + 'Goal Search Area A with coverage pattern to identify objects of interest.
Owner ' + icon('people') + ' Alex
Resources ' + icon('people') + ' 12 vehicles
Est. duration ' + icon('clock') + ' 18 min
Expected finish ⚑ 14:53 (local)
Linked task ⛓ TASK-143
Local plan status ' + statusTag('nominal', 'Plan valid') + '' + button('Add step', 'add-step', { icon: 'plus' }) + button('Reorder', 'reorder-queue', { icon: 'transfer' }) + button('Simulate timeline', 'simulate-queue', { icon: 'clock' }) + button('Review changes', 'review-queue', { kind: 'primary' }) + ' Activity timeline 14:32 Alex reordered Return after Search Area A 14:33 System recomputed completion estimate ';
+}
+
+function systemView() {
+ const color = (name, code, cls) => '' + name + ' ' + code + '
';
+ return '1. Color and status Surface (Graphite / Navy)
' + color('Surface 0', '#0B0F14', 's0') + color('Surface 1', '#111724', 's1') + color('Surface 2', '#171D2B', 's2') + color('Surface 3', '#1D2536', 's3') + color('Border', '#2A3447', 'border') + '
Semantic status (meaning over decoration)
' + color('Blue', 'Active / Primary', 'blue') + color('Cyan', 'Selected', 'cyan') + color('Green', 'Nominal', 'green') + color('Amber', 'Attention', 'amber') + color('Red', 'Critical', 'red') + color('Violet', 'Planned', 'violet') + '
Color communicates state and priority, not category. Do not rely on color alone—use icon, text, and shape. Maintain WCAG AA contrast for all text. 2. Typography H1 Mission / Group Heading H2 Task Heading Body / UI Text Readable action and mission information
Metadata / Secondary UAV-3F7A2C 08:43:21Z ALT 3250 ft 3. Components Buttons (44px height)
' + button('Create group', 'new-group', { icon: 'plus', kind: 'primary' }) + button('Assign task', 'open-preview', { icon: 'task' }) + button('Remove', 'remove', { icon: 'close', kind: 'danger' }) + 'Status chips
' + statusTag('nominal') + statusTag('attention') + statusTag('critical') + statusTag('cyan', 'Selected') + statusTag('planned') + '
Authority
' + icon('shield') + 'AuthorityOperator
▣ Read-only observer
ISR Sweep North ' + statusTag('nominal') + 'Type ISR
Area Sector 12
Assets 14 UAV
27 Group Alpha ' + statusTag('cyan', 'Selected') + 'Members 27 UAVs
Lead UAV-3F7A2C
' + icon('alert') + '' + statusTag('critical') + 'Link degraded on UAV-7B1C 4. Interaction states ' + alphaVehicles.slice(0, 9).map(v => vehicleMarker(v, v.id !== 'UAV-03')).join('') + '
' + icon('check') + '27 vehicles selected ISR 14 Relay 7 Transport 6 ' + button('Create group', 'new-group', { icon: 'plus', kind: 'primary' }) + button('Assign task', 'open-preview') + button('Add to group', 'group-vehicles') + '
Selection state cards 27 Group Alpha ' + statusTag('cyan', 'Selected') + 'Members · 27 UAVs
◈ ISR Sweep North ' + statusTag('nominal') + 'Start · 09:00Z · Assets 14 UAVs
✈ UAV-3F7A2C ' + statusTag('cyan', 'Selected') + 'Type · ISR · Alt 3,250 ft
□ No selection Use selection tools to choose vehicles or groups.
5. Responsive composition Map-first layouts adapt by collapsing / expanding secondary panels.
Laptop
27-inch
Ultrawide
4K wall
6. Density and accessibility 44px minimum tap / click target
Assign task Tab
Aa high-contrast UI text
' + icon('info') + ' Semantic label and tooltip
';
+}
+
+function modal() {
+ if (!state.modal) return '';
+ let content = '';
+ if (state.modal === 'preview') {
+ content = '6 selected vehicles will be assigned to Area Search — Northern Sector .
Current assignment
' + icon('route') + ' Proposed task
' + validationRow('Estimated completion', '18 min') + validationRow('Energy reserve', 'All above minimum') + validationRow('Route conflicts', 'None detected') + validationRow('Geofence conflicts', 'None detected') + validationRow('Vehicle compatibility', '6 / 6') + validationRow('Potential issue', '1 aircraft may require reassignment', 'warning') + '
' + button('Cancel', 'cancel-modal') + button('Confirm assignment', 'confirm-assignment', { icon: 'check', kind: 'primary' }) + ' ';
+ } else if (state.modal === 'transfer') {
+ content = 'A Alpha · command authority Held by Alex · lease expires 02:34
Reason ' + icon('info') + ' This action will transfer command authority from Alex to Sara . The change is logged for all operators.
' + button('Deny', 'deny-transfer') + button('Transfer control', 'confirm-transfer', { icon: 'transfer', kind: 'danger' }) + ' ';
+ } else if (state.modal === 'help') {
+ content = 'Smart selection ' + button('', 'cancel-modal', { icon: 'close', iconOnly: true, label: 'Close help' }) + 'Use a natural-language request or structured filters to select a resource-ready group of aircraft. The POC derives the result from fictional vehicles and always validates the task before confirmation.
' + button('Got it', 'cancel-modal', { kind: 'primary' }) + ' ';
+ }
+ return '';
+}
+
+function toast() {
+ return state.toast ? '' + icon('check') + '' + state.toast + ' ' + icon('close') + '
' : '';
+}
+
+function app() {
+ const views = { overview: overviewView, zoom: zoomView, selection: smartSelectionView, collaboration: collaborationView, recovery: recoveryView, queue: queueView, system: systemView };
+ document.getElementById('app').innerHTML = topBar() + views[state.view]() + modal() + toast();
+}
+
+function setToast(message) {
+ state.toast = message;
+ window.clearTimeout(window.__atlasToast);
+ window.__atlasToast = window.setTimeout(() => { state.toast = ''; app(); }, 3600);
+}
+
+function clickAction(action, value) {
+ if (action === 'set-view') { state.view = value; state.modal = null; }
+ if (action === 'select-group') { state.selectedGroup = value; state.view = state.view === 'collaboration' ? 'collaboration' : 'overview'; setToast(groupById(value).name + ' selected — contextual controls updated.'); }
+ if (action === 'select-vehicle') { state.selectedVehicle = value; state.view = 'zoom'; setToast(value + ' selected — vehicle detail is shown on request.'); }
+ if (action === 'toggle-layers') state.layersOpen = !state.layersOpen;
+ if (action === 'open-preview') state.modal = 'preview';
+ if (action === 'open-transfer') state.modal = 'transfer';
+ if (action === 'cancel-modal' || action === 'close-assign' || action === 'close-inspector') state.modal = null;
+ if (action === 'show-filter-help') state.modal = 'help';
+ if (action === 'confirm-assignment') { state.modal = null; setToast('TASK-143 assigned after route, energy, and constraint validation.'); }
+ if (action === 'confirm-transfer') { state.modal = null; groups[0].lease = 'Sara'; groups[0].operator = 'Sara'; setToast('Command lease transferred to Sara. The audit event is visible to all operators.'); }
+ if (action === 'deny-transfer') { state.modal = null; setToast('Command request denied. Alex retains the Alpha command lease.'); }
+ if (action === 'move-group') setToast('Move intent opened. A route preview would be required before confirmation.');
+ if (action === 'hold-group') setToast('Hold intent queued for ' + groupById(state.selectedGroup).name + '.');
+ if (action === 'group-vehicles') setToast('Create / add to group workflow opened for the current selection.');
+ if (action === 'split-group') setToast('Split group is available after selecting a destination task.');
+ if (action === 'open-vehicle-detail') { state.view = 'zoom'; state.selectedVehicle = 'UAV-07'; }
+ if (action === 'select-candidate') state.selectedCandidate = value;
+ if (action === 'accept-replacement') { state.reassigned = true; setToast(state.selectedCandidate + ' assigned to Alpha Search. Task coverage returned to 4 / 4.'); }
+ if (action === 'show-failure') setToast('UAV-03 is unavailable. A replacement recommendation needs operator approval.');
+ if (action === 'queue-up' || action === 'queue-down') {
+ const index = Number(value); const direction = action === 'queue-up' ? -1 : 1; const next = index + direction;
+ if (next >= 0 && next < state.queue.length) { const copy = state.queue.slice(); [copy[index], copy[next]] = [copy[next], copy[index]]; state.queue = copy; state.queueDraft = true; setToast('Intent queue reordered locally. Review changes to commit.'); }
+ }
+ if (action === 'review-queue') { state.queueDraft = false; setToast('Queue review recorded. The plan remains valid.'); }
+ if (action === 'reorder-queue') { state.queueDraft = true; setToast('Reorder mode active — use the arrows on each intent step.'); }
+ if (action === 'simulate-queue') setToast('Simulation complete: no constraints conflict with the proposed intent queue.');
+ if (action === 'add-step') { state.queue.push({ title: 'New intent', detail: 'Configure task', duration: '—', status: 'queued' }); state.queueDraft = true; setToast('New draft intent added to the queue.'); }
+ if (action === 'new-group') setToast('New group creation starts with a multi-vehicle selection.');
+ if (action === 'remove-filter') setToast('Filter removed. The fictional result set has been recalculated.');
+ if (action === 'clear-filters') setToast('Structured filters cleared. Use a query to build a new selection.');
+ if (action === 'map-zoom-in') setToast('Map zoomed in — semantic detail increases as you select groups and vehicles.');
+ if (action === 'map-zoom-out') setToast('Map zoomed out — groups aggregate to protect situational awareness.');
+ if (action === 'view-alerts' || action === 'view-fleet' || action === 'view-history') setToast('This POC surfaces exceptions first; the nominal fleet stays intentionally quiet.');
+ if (action === 'compare-candidates') setToast('Candidate comparison keeps energy, task compatibility, and link quality visible.');
+ if (action === 'clear-toast') state.toast = '';
+ app();
+}
+
+document.addEventListener('click', event => {
+ const el = event.target.closest('[data-action]');
+ if (el) {
+ event.preventDefault();
+ clickAction(el.dataset.action, el.dataset.value);
+ return;
+ }
+ if (event.target.closest('[data-stop-propagation]')) event.stopPropagation();
+});
+
+document.addEventListener('keydown', event => {
+ if (event.key === 'Escape' && state.modal) { state.modal = null; app(); }
+});
+
+app();
diff --git a/ground-suite/assets/test.png b/ground-suite-design-files/assets/test.png
similarity index 100%
rename from ground-suite/assets/test.png
rename to ground-suite-design-files/assets/test.png
diff --git a/ground-suite/assets/vehicle_catalog/LICENSE.md b/ground-suite-design-files/assets/vehicle_catalog/LICENSE.md
similarity index 100%
rename from ground-suite/assets/vehicle_catalog/LICENSE.md
rename to ground-suite-design-files/assets/vehicle_catalog/LICENSE.md
diff --git a/ground-suite/assets/vehicle_catalog/photos/fixed_wing.png b/ground-suite-design-files/assets/vehicle_catalog/photos/fixed_wing.png
similarity index 100%
rename from ground-suite/assets/vehicle_catalog/photos/fixed_wing.png
rename to ground-suite-design-files/assets/vehicle_catalog/photos/fixed_wing.png
diff --git a/ground-suite/assets/vehicle_catalog/photos/helicopter.png b/ground-suite-design-files/assets/vehicle_catalog/photos/helicopter.png
similarity index 100%
rename from ground-suite/assets/vehicle_catalog/photos/helicopter.png
rename to ground-suite-design-files/assets/vehicle_catalog/photos/helicopter.png
diff --git a/ground-suite/assets/vehicle_catalog/photos/quadcopter.png b/ground-suite-design-files/assets/vehicle_catalog/photos/quadcopter.png
similarity index 100%
rename from ground-suite/assets/vehicle_catalog/photos/quadcopter.png
rename to ground-suite-design-files/assets/vehicle_catalog/photos/quadcopter.png
diff --git a/ground-suite/assets/vehicle_catalog/photos/rover.png b/ground-suite-design-files/assets/vehicle_catalog/photos/rover.png
similarity index 100%
rename from ground-suite/assets/vehicle_catalog/photos/rover.png
rename to ground-suite-design-files/assets/vehicle_catalog/photos/rover.png
diff --git a/ground-suite/assets/vehicle_catalog/photos/surface_vessel.png b/ground-suite-design-files/assets/vehicle_catalog/photos/surface_vessel.png
similarity index 100%
rename from ground-suite/assets/vehicle_catalog/photos/surface_vessel.png
rename to ground-suite-design-files/assets/vehicle_catalog/photos/surface_vessel.png
diff --git a/ground-suite/assets/vehicle_catalog/photos/tiltrotor_vtol.png b/ground-suite-design-files/assets/vehicle_catalog/photos/tiltrotor_vtol.png
similarity index 100%
rename from ground-suite/assets/vehicle_catalog/photos/tiltrotor_vtol.png
rename to ground-suite-design-files/assets/vehicle_catalog/photos/tiltrotor_vtol.png
diff --git a/ground-suite-design-files/context1 b/ground-suite-design-files/context1
new file mode 100644
index 0000000..950808f
--- /dev/null
+++ b/ground-suite-design-files/context1
@@ -0,0 +1,1513 @@
+I think there is a **very productive design analogy between RTS games and multi-UAV GCS**, but I would not copy an RTS literally.
+
+The key insight is:
+
+> **An RTS doesn't make the player control 50 units individually. It gives the player mechanisms for turning many low-level entities into a few high-level, cognitively manageable objects.**
+
+That is exactly the problem you're describing with 5 operators / 50 UAVs.
+
+Modern multi-vehicle systems are already moving toward this direction: for example, SolGCS describes multiple operators seeing/commanding the same vehicles with conflict resolution, while ARA's MPMS emphasizes multi-asset orchestration from one screen. ([sol.one][1])
+
+I would build your GCS around that philosophy.
+
+---
+
+# 1. Don't design a "drone control application"
+
+Design a **mission operating system**.
+
+This distinction is extremely important.
+
+A conventional GCS thinks:
+
+> Vehicle → telemetry → vehicle controls → mission
+
+Your next-generation GCS could think:
+
+> **Mission → Tasks → Groups → Vehicles → Autonomy**
+
+The operator shouldn't normally think:
+
+> "I need to command UAV #37."
+
+They should think:
+
+> "I need this group of 8 aircraft to search this area."
+
+And the system figures out which vehicles should execute it.
+
+That is much closer to an RTS.
+
+---
+
+# 2. The RTS concept I would steal first: hierarchical selection
+
+Look at how games such as Blizzard Entertainment's StarCraft II handle units.
+
+You can select:
+
+* one unit
+* multiple units
+* a control group
+* all units of a type
+* units satisfying some condition
+
+StarCraft II also supports control groups, adding/removing units from groups, queued commands and cycling through subgroups. ([Blizzard News][2])
+
+Your GCS could have the same concept, but **much more powerful**.
+
+### Example
+
+You have:
+
+**50 UAVs**
+
+Instead of presenting 50 independent objects:
+
+```text
+UAV-001
+UAV-002
+UAV-003
+...
+UAV-050
+```
+
+the operator sees:
+
+```text
+ALPHA
+ 12 × ISR UAV
+ Status: GREEN
+ Task: Area Search
+ Coverage: 64%
+
+BRAVO
+ 10 × ISR UAV
+ Status: GREEN
+ Task: Route Recon
+
+CHARLIE
+ 8 × Relay UAV
+ Status: GREEN
+ Task: Communications
+
+DELTA
+ 20 × Mixed UAV
+ Status: AMBER
+ Task: Reserve
+```
+
+Then:
+
+**Click ALPHA → control the group.**
+
+Expand ALPHA → individual aircraft.
+
+This is essentially **zooming through levels of abstraction**.
+
+---
+
+# 3. Think of the UI as a "semantic zoom"
+
+This might be one of the most important concepts for your design team.
+
+At maximum zoom-out:
+
+```text
+ ┌──────────────────────┐
+ │ MISSION │
+ └──────────┬───────────┘
+ │
+ ┌──────────────┼──────────────┐
+ ▼ ▼ ▼
+ ALPHA BRAVO CHARLIE
+ 12 10 8
+```
+
+Zoom in:
+
+```text
+ ALPHA
+ ┌───────────┼───────────┐
+ ▼ ▼ ▼
+ A-01 A-02 A-03
+```
+
+Zoom further:
+
+```text
+ A-01
+ ┌──────┼──────┐
+ │ │ │
+ Flight Nav Payload
+```
+
+The same object changes representation depending on zoom.
+
+At fleet level:
+
+**ALPHA — 12 aircraft — healthy**
+
+At group level:
+
+**12 aircraft / formation / task / coverage**
+
+At vehicle level:
+
+**battery / GPS / flight mode / link / telemetry**
+
+At engineering level:
+
+**parameters / sensors / actuator status**
+
+This is much better than permanently putting 50 vehicles on screen.
+
+---
+
+# 4. Your most important RTS feature: "control groups"
+
+Supreme Commander is particularly interesting for you.
+
+It lets players assign units to numbered groups, recall groups, add units to groups, select units by type, etc. ([supcom.fandom.com][3])
+
+But you can go much further.
+
+Instead of:
+
+> Ctrl+1 = UAVs 1–8
+
+allow:
+
+> **Group = semantic query**
+
+For example:
+
+### Group: "Northern Search"
+
+```text
+Members:
+ aircraft where:
+ type = ISR
+ region = NORTH
+ readiness > 80%
+```
+
+Now when UAV-07 fails:
+
+```text
+Northern Search
+12 → 11 vehicles
+```
+
+and another suitable aircraft can automatically join.
+
+That's a huge conceptual improvement over traditional RTS control groups.
+
+The group becomes a **mission entity**, not merely a collection of vehicle IDs.
+
+---
+
+# 5. Separate "selection" from "ownership"
+
+This is where real-world GCS needs to diverge from games.
+
+In an RTS:
+
+> I select unit → I own the command.
+
+In a real multi-operator system, you need:
+
+> I select unit → I am requesting authority to command it.
+
+That's essential for your 5-operator scenario.
+
+Imagine:
+
+```text
+ UAV-17
+ │
+ ┌─────────┴─────────┐
+ │ │
+ Operator A Operator B
+ viewing viewing
+```
+
+Both can see it.
+
+But only one has **command authority**.
+
+You could introduce something analogous to an RTS "selection state":
+
+### Grey
+
+**Observed**
+
+### Blue
+
+**Selected**
+
+### Purple
+
+**Requested**
+
+### Green
+
+**Command authority**
+
+### Amber
+
+**Command conflict**
+
+### Red
+
+**Command unavailable**
+
+That makes multi-operator collaboration visually obvious.
+
+---
+
+# 6. Use "command leases"
+
+I think this could become a defining feature of your system.
+
+Instead of permanently assigning vehicles to operators:
+
+```text
+Operator 1 owns UAV 1–10
+Operator 2 owns UAV 11–20
+```
+
+use temporary **command leases**.
+
+Example:
+
+> Operator A selects Group ALPHA.
+
+System:
+
+```text
+ALPHA
+
+Command lease:
+Operator A
+
+Expires:
+02:34
+
+Observers:
+Operator B
+Operator C
+```
+
+Operator B can request the lease:
+
+```text
+Operator B
+REQUEST CONTROL
+
+Reason:
+Emergency intervention
+```
+
+Operator A sees:
+
+> **Operator B requests command authority for ALPHA**
+
+Then:
+
+**Accept / Reject / Transfer**
+
+You could also allow predefined authority policies:
+
+```text
+Mission Commander
+ ↓
+Group Commander
+ ↓
+Vehicle Operator
+ ↓
+Vehicle
+```
+
+This gives you the multiplayer RTS feeling without creating dangerous command ambiguity.
+
+---
+
+# 7. Make the map the primary workspace
+
+I'd strongly resist designing the application as:
+
+```text
+Top: toolbar
+Left: vehicle tree
+Center: map
+Right: telemetry
+Bottom: controls
+```
+
+That's basically a traditional GCS.
+
+Instead:
+
+## The map should be the operating canvas.
+
+Everything should exist spatially.
+
+Something like:
+
+```text
+┌──────────────────────────────────────────────────────────┐
+│ MISSION HEALTH 48/50 ALERTS 2 USERS 5 │
+├──────────────────────────────────────────────────────────┤
+│ │
+│ ╭──────── SEARCH AREA ────────╮ │
+│ │ │ │
+│ ● ● ● ● ● │ │
+│ ALPHA │ │
+│ ● ● ● ● │ │
+│ ▲ │
+│ BRAVO → → → → → │ │
+│ │
+│ │
+├──────────────────────────────────────────────────────────┤
+│ ALPHA 12 UAV │ SEARCH │ 82% │ GREEN │ OP: ALEX │
+└──────────────────────────────────────────────────────────┘
+```
+
+The bottom panel becomes a **contextual command bar**.
+
+Select nothing → mission controls.
+
+Select group → group controls.
+
+Select vehicle → vehicle controls.
+
+Select task → task controls.
+
+This is very close to modern RTS design.
+
+---
+
+# 8. Steal "drag-select"
+
+This sounds trivial, but it is enormously powerful.
+
+RTS:
+
+> Drag rectangle → select 15 units.
+
+Your GCS:
+
+> Drag rectangle → select all suitable vehicles in geographical area.
+
+Then:
+
+```text
+27 vehicles selected
+
+TYPE
+☑ ISR 14
+☑ Relay 7
+☑ Transport 6
+
+STATUS
+☑ Ready 25
+☐ Warning 2
+```
+
+Then you can turn the selection into:
+
+**Create Group**
+
+or
+
+**Assign Task**
+
+or
+
+**Add to Existing Group**
+
+This is much faster than clicking individual vehicles.
+
+---
+
+# 9. But add "smart selection"
+
+This is where you can surpass games.
+
+Imagine a command palette:
+
+> **Select all available ISR aircraft within 20 km of Area A with >60% battery**
+
+Result:
+
+```text
+8 vehicles selected
+```
+
+Or:
+
+> **Select aircraft currently assigned to Northern Search**
+
+Or:
+
+> **Select vehicles with degraded navigation**
+
+Or:
+
+> **Select vehicles that can reach Task Bravo within 12 minutes**
+
+This is basically **SQL for operators**, but expressed naturally.
+
+You could provide both:
+
+### Natural language
+
+> Select available search aircraft near Bravo.
+
+and:
+
+### Structured filter
+
+```text
+Type: ISR
+Availability: Available
+Distance: <20 km
+Battery: >60%
+Task: None
+```
+
+This could be one of the biggest UX differentiators.
+
+---
+
+# 10. Steal "queued orders" from RTS games
+
+This is another huge one.
+
+StarCraft II and Company of Heroes both use queued commands. ([Blizzard News][2])
+
+Instead of:
+
+> Go here.
+
+You can construct:
+
+```text
+Task A
+ ↓
+Transit
+ ↓
+Search Area A
+ ↓
+Return
+ ↓
+Recharge
+ ↓
+Task B
+```
+
+For your GCS, make this **first-class**.
+
+Visually:
+
+```text
+ALPHA
+
+[Transit]
+ ↓
+[Search Area A]
+ ↓
+[Loiter]
+ ↓
+[Return]
+ ↓
+[Recharge]
+```
+
+The operator manipulates the **mission graph**, rather than individual flight-controller commands.
+
+---
+
+# 11. Introduce "task objects"
+
+This is probably the architectural concept I'd put at the center of the product.
+
+A vehicle should not be the primary object.
+
+A **Task** should be.
+
+For example:
+
+```text
+TASK-143
+
+Type:
+Area Search
+
+Priority:
+High
+
+Area:
+████████████
+
+Required:
+4 aircraft
+
+Assigned:
+ALPHA / 4
+
+Progress:
+63%
+
+Health:
+GREEN
+```
+
+Then the fleet is a resource pool.
+
+```text
+Task
+ ↓
+Requirements
+ ↓
+Assignment
+ ↓
+Vehicle allocation
+ ↓
+Execution
+```
+
+This allows your software to dynamically substitute vehicles.
+
+If UAV-03 becomes unavailable:
+
+```text
+UAV-03
+ ↓
+UNAVAILABLE
+
+Task engine:
+ ↓
+find replacement
+
+UAV-19
+ ↓
+ASSIGNED
+```
+
+The operator doesn't need to manually rebuild everything.
+
+---
+
+# 12. Think "Squads", not "fleets"
+
+Games have discovered something important:
+
+Humans are bad at managing 50 independent objects.
+
+Humans are much better at managing:
+
+**5–7 meaningful groups.**
+
+So your 50 UAVs should probably become something like:
+
+```text
+┌──────────────────────────────┐
+│ ALPHA 12 ● GREEN │
+│ BRAVO 8 ● GREEN │
+│ CHARLIE 10 ● AMBER │
+│ DELTA 12 ● GREEN │
+│ RESERVE 8 ● GREEN │
+└──────────────────────────────┘
+```
+
+Five operators can then divide responsibility at the **task/group level**, not aircraft level.
+
+---
+
+# 13. Borrow from Total War
+
+The Total War series is particularly interesting because it handles large numbers of units while preserving a relatively understandable tactical picture.
+
+One excellent concept is the **unit card / army card**.
+
+You could have:
+
+```text
+┌──────────────────────────────────────────────┐
+│ ALPHA │
+│ │
+│ [●][●][●][●][●][●][●][●][●][●][●][●] │
+│ │
+│ 12 UAVs SEARCH 64% COMPLETE │
+└──────────────────────────────────────────────┘
+```
+
+Each little icon represents an aircraft.
+
+Click one:
+
+```text
+UAV-17
+```
+
+Click the group:
+
+```text
+ALPHA
+```
+
+Click the mission:
+
+```text
+SEARCH NORTH
+```
+
+Three different levels, one UI.
+
+That is **excellent cognitive compression**.
+
+---
+
+# 14. Borrow from Homeworld for 3D spatial relationships
+
+Homeworld is worth studying for a different reason.
+
+The units exist in genuinely 3D space, so its interface has to communicate:
+
+* relative position
+* formation
+* heading
+* altitude
+* spatial relationships
+
+Your UAV system has exactly this problem.
+
+Don't show:
+
+```text
+UAV 17
+Lat: ...
+Lon: ...
+Alt: 147m
+Heading: 283°
+```
+
+as the primary representation.
+
+Show:
+
+```text
+ ↗ ● UAV-17
+ /
+ / 147m
+ /
+ ● UAV-18
+```
+
+and allow the numeric values to appear when needed.
+
+**Spatial cognition first. Numbers second.**
+
+---
+
+# 15. Steal Supreme Commander's "strategic zoom"
+
+This is probably my **#1 game to study** for your project.
+
+Supreme Commander was designed around very large numbers of units and gave players extensive selection/grouping mechanisms. ([supcom.fandom.com][3])
+
+Your equivalent could be:
+
+### Level 1 — Strategic
+
+```text
+Entire operating area
+
+50 UAV
+5 groups
+7 tasks
+3 alerts
+```
+
+### Level 2 — Operational
+
+```text
+Northern sector
+
+ALPHA
+BRAVO
+TASK 17
+TASK 19
+```
+
+### Level 3 — Tactical
+
+```text
+ALPHA
+
+UAV-01
+UAV-02
+UAV-03
+...
+```
+
+### Level 4 — Vehicle
+
+```text
+UAV-03
+
+flight status
+navigation
+battery
+link
+payload
+etc.
+```
+
+### Level 5 — Engineering
+
+```text
+flight controller
+parameters
+sensors
+actuators
+logs
+configuration
+```
+
+**The same application should support all five levels without feeling like five different applications.**
+
+---
+
+# 16. Introduce an "RTS command vocabulary"
+
+You could deliberately standardize a small number of high-level verbs.
+
+For example:
+
+### Navigate
+
+```text
+MOVE
+ROUTE
+FOLLOW
+RETURN
+HOLD
+```
+
+### Observe
+
+```text
+SEARCH
+SCAN
+TRACK
+MONITOR
+```
+
+### Formation
+
+```text
+JOIN
+SPLIT
+FORM
+REPOSITION
+```
+
+### Mission
+
+```text
+ASSIGN
+REASSIGN
+PAUSE
+RESUME
+CANCEL
+```
+
+### Fleet
+
+```text
+GROUP
+UNGROUP
+TRANSFER
+REQUEST
+RELEASE
+```
+
+The operator learns a **small vocabulary** instead of learning hundreds of vehicle-specific controls.
+
+---
+
+# 17. Use "intent previews"
+
+This is where real-world GCS should improve upon games.
+
+In an RTS:
+
+> Click → command happens.
+
+For real vehicles:
+
+> Click → system previews what will happen.
+
+For example:
+
+```text
+┌───────────────────────────────────────┐
+│ ASSIGN TASK │
+│ │
+│ 6 vehicles selected │
+│ │
+│ Estimated completion: 18 min │
+│ Required battery reserve: OK │
+│ Route conflicts: NONE │
+│ Geofence conflicts: NONE │
+│ Vehicle compatibility: 6/6 │
+│ │
+│ [CANCEL] [CONFIRM] │
+└───────────────────────────────────────┘
+```
+
+That is the **safety equivalent of an RTS command preview**.
+
+---
+
+# 18. Make the system explain *why* it rejects a command
+
+Don't do:
+
+> ❌ Command rejected.
+
+Do:
+
+> **Cannot assign UAV-23**
+
+```text
+Reason:
+Insufficient energy reserve
+
+Current:
+43%
+
+Estimated requirement:
+51%
+
+Alternatives:
+[UAV-19]
+[UAV-27]
+[UAV-31]
+```
+
+This is a massive UX improvement.
+
+The GCS becomes a **co-pilot rather than a form validator**.
+
+---
+
+# 19. Multi-operator collaboration should feel like multiplayer
+
+This is perhaps your biggest opportunity.
+
+Imagine five operators logged into the same mission:
+
+```text
+MISSION: ALPHA
+
+👤 Alex
+ Commanding: ALPHA
+
+👤 Sara
+ Commanding: BRAVO
+
+👤 David
+ Planning
+
+👤 Maria
+ Monitoring
+
+👤 Ken
+ Engineering
+```
+
+Everyone sees the same world.
+
+If Alex selects ALPHA:
+
+Everyone can see:
+
+```text
+ALPHA
+COMMAND: ALEX
+```
+
+If Sara is planning:
+
+```text
+BRAVO
+PLAN EDIT: SARA
+```
+
+If David changes a task:
+
+```text
+TASK 23
+Edited by David
+12 sec ago
+```
+
+You essentially want **Google Docs + an RTS + a GCS**.
+
+That is a very compelling product direction.
+
+---
+
+# 20. Treat mission plans like collaborative documents
+
+This is a major architectural implication.
+
+Don't make the mission:
+
+```text
+mission.json
+```
+
+that one computer owns.
+
+Instead think:
+
+```text
+Mission
+│
+├── Tasks
+├── Groups
+├── Assignments
+├── Constraints
+├── Operators
+├── Permissions
+├── State
+├── History
+└── Events
+```
+
+Then every GCS workstation is a **client of shared mission state**.
+
+Operator A can plan.
+
+Operator B can monitor.
+
+Operator C can modify a task.
+
+Operator D can operate a particular group.
+
+Operator E can handle vehicle configuration.
+
+Everyone sees the same authoritative state.
+
+---
+
+# 21. You need an event model, not just shared telemetry
+
+I'd strongly recommend thinking in terms of:
+
+```text
+EVENT
+
+Operator A:
+"Assign Group ALPHA to Task 17"
+
+ ↓
+
+Mission service
+
+ ↓
+
+Validate
+
+ ↓
+
+Reserve resources
+
+ ↓
+
+Publish event
+
+ ↓
+
+All clients update
+```
+
+rather than:
+
+```text
+GCS A → UAV
+GCS B → UAV
+GCS C → UAV
+```
+
+This becomes much easier to reason about when five operators are working simultaneously.
+
+The multi-user/multi-vehicle research literature has explored networked multiple ground stations and centralized/decentralized/hybrid mission planning, which is conceptually aligned with this architecture. ([chasemurray.com][4])
+
+---
+
+# 22. Build an "attention management" system
+
+This may be even more important than the map.
+
+The operator cannot watch 50 UAVs.
+
+So the system needs to determine:
+
+> **What deserves the operator's attention right now?**
+
+For example:
+
+```text
+ ATTENTION
+
+🔴 UAV-27 navigation degraded
+🟠 ALPHA task behind schedule
+🟠 BRAVO approaching constraint
+🟢 47 vehicles nominal
+```
+
+Not:
+
+```text
+UAV-01 telemetry...
+UAV-02 telemetry...
+UAV-03 telemetry...
+...
+```
+
+You want **exception-based operations**.
+
+Normal things disappear into the background.
+
+Abnormal things rise to the surface.
+
+---
+
+# 23. Make the fleet visually "alive"
+
+Games do this extremely well.
+
+Don't make aircraft icons static.
+
+Their visualization should communicate:
+
+* direction
+* velocity
+* task
+* health
+* connectivity
+* group
+* attention state
+* trajectory
+* future intent
+
+For example:
+
+```text
+ ╭────── planned route
+ │
+ │ ●
+ │ ↗
+ │ ↗
+ ●───→
+```
+
+And groups could have subtle visual identities.
+
+Not overly colorful arcade graphics — **information-rich tactical graphics**.
+
+---
+
+# 24. But don't copy the "game HUD"
+
+This is an important warning.
+
+A common mistake would be:
+
+> "Let's make a military-looking game UI."
+
+That usually means:
+
+* dark background
+* neon green
+* tons of icons
+* radar
+* glowing circles
+* tiny text
+* dozens of panels
+
+That can look cool and be terrible to operate.
+
+Your goal should instead be:
+
+> **game-grade interaction model + aviation-grade information hierarchy**
+
+Not:
+
+> game aesthetics.
+
+---
+
+# 25. One particularly interesting idea: "command stack"
+
+Imagine the operator selects 12 UAVs and gets:
+
+```text
+ALPHA / 12
+
+CURRENT
+────────────────────
+SEARCH AREA A
+
+NEXT
+────────────────────
+RETURN
+
+QUEUED
+────────────────────
+RECHARGE
+SEARCH AREA B
+```
+
+The operator can manipulate this like a task queue.
+
+Drag:
+
+```text
+SEARCH A
+RETURN
+SEARCH B
+```
+
+into:
+
+```text
+SEARCH A
+SEARCH B
+RETURN
+```
+
+This is much easier to understand than editing waypoint lists.
+
+---
+
+# 26. Add "formation as a first-class object"
+
+Games such as Supreme Commander expose formation movement and queued commands for groups. ([supcom.fandom.com][3])
+
+For your system, don't make formation a hidden parameter.
+
+Make it visible:
+
+```text
+ALPHA
+
+ ● ●
+ ● ● ●
+ ● ●
+
+FORMATION
+[ GRID ] [ LINE ] [ COLUMN ] [ CUSTOM ]
+```
+
+Then the operator changes the **group relationship**, rather than individually manipulating vehicles.
+
+For real-world systems, obviously keep vehicle-specific safety/operational constraints authoritative; the formation UI should express intent, not bypass those constraints.
+
+---
+
+# 27. Create "roles" inside groups
+
+Suppose you have:
+
+```text
+ALPHA — 12 UAVs
+```
+
+Don't necessarily make all 12 equivalent.
+
+The group could contain:
+
+```text
+LEAD 1
+SCOUT 4
+RELAY 2
+SEARCH 4
+RESERVE 1
+```
+
+The UI represents **roles**, not just vehicles.
+
+If one aircraft fails:
+
+```text
+RELAY
+2 → 1
+
+Replacement available:
+UAV-38
+```
+
+This is much closer to how a human thinks about a coordinated system.
+
+---
+
+# 28. Think of autonomy as the game's "AI"
+
+The most important design question is:
+
+> **What should the operator have to do manually?**
+
+The answer should be: as little as possible.
+
+The GCS should handle:
+
+* resource allocation
+* vehicle selection
+* route generation
+* task reassignment
+* constraint checking
+* health monitoring
+* conflict detection
+* synchronization
+* status summarization
+
+The operator handles:
+
+* intent
+* priorities
+* approvals
+* exceptions
+* high-level decisions
+
+That's the real leap from "50 drones controlled by 5 people" to something that can actually scale.
+
+A current commercial example, SolGCS, explicitly describes fleet-scale monitoring/control, multi-pilot collaboration and automated path computation for groups of UAVs. ([sol.one][1])
+
+---
+
+# 29. My proposed information architecture
+
+I'd seriously prototype something along these lines:
+
+```text
+ MISSION
+ │
+ ┌─────────────┼─────────────┐
+ │ │ │
+ TASKS FLEET USERS
+ │ │ │
+ ┌────┴────┐ ┌───┴────┐ ┌───┴────┐
+ │ │ │ │ │ │
+ Task A Task B Group A B Operator 1 2
+ │
+ ┌─────┼─────┐
+ │ │ │
+ UAV1 UAV2 UAV3
+```
+
+And the main UI:
+
+```text
+┌────────────────────────────────────────────────────────────┐
+│ MISSION FLEET 48/50 ALERTS 3 USERS 5 │
+├────────────────────────────────────────────────────────────┤
+│ │
+│ MAP │
+│ │
+│ [ALPHA] ● ● ● │
+│ ● ● │
+│ │
+│ [BRAVO] ● ● ● ● │
+│ │
+│ [TASK 17] │
+│ │
+├────────────────────────────────────────────────────────────┤
+│ SELECTED: ALPHA │
+│ 12 VEHICLES │ SEARCH │ 82% │ OP: ALEX │
+│ │
+│ [TASK] [MOVE] [HOLD] [GROUP] [TRANSFER] [DETAILS] │
+└────────────────────────────────────────────────────────────┘
+```
+
+The UI stays extremely simple until the user asks for complexity.
+
+---
+
+# 30. The three-layer UI I would prototype first
+
+### Layer 1 — Strategic
+
+**"What's happening?"**
+
+```text
+Fleet health
+Mission progress
+Tasks
+Alerts
+Operators
+Groups
+```
+
+### Layer 2 — Operational
+
+**"What needs to happen?"**
+
+```text
+Select group
+Assign task
+Modify task
+Reassign resources
+Resolve conflicts
+```
+
+### Layer 3 — Vehicle
+
+**"What is this machine doing?"**
+
+```text
+Telemetry
+Flight status
+Navigation
+Payload
+Health
+Logs
+Configuration
+```
+
+The mistake I'd avoid is exposing Layer 3 all the time.
+
+---
+
+# 31. Games I'd put on your team's research list
+
+I'd actually have the UX team **play these, not merely look at screenshots**.
+
+| Game | What to study |
+| ------------------------------------------- | ------------------------------------------------------------------------------ |
+| **Supreme Commander / Forged Alliance** | ⭐ Strategic zoom, huge fleet management, control groups, multi-unit commands |
+| **StarCraft II** | Selection, hotkeys, control groups, command queues, extremely fast interaction |
+| **Company of Heroes 2** | Squad abstraction, contextual commands, tactical map, unit cards |
+| **Homeworld Remastered** | 3D spatial management, formations, fleet organization |
+| **Total War: Warhammer III** | Massive unit-card organization, army hierarchy, tactical/strategic layers |
+| **Command & Conquer: Generals / Zero Hour** | Fast RTS interaction and simple command vocabulary |
+| **Ashes of the Singularity** | Very large numbers of units and hierarchical control |
+| **Beyond All Reason** | Massive-scale unit control, groups, strategic zoom |
+| **Planetary Annihilation** | Extremely large-scale strategic management and multi-planet abstraction |
+| **Anno 1800** | Complex systems presented through understandable hierarchical UI |
+| **Oxygen Not Included** | Exception/attention management and system visualization |
+| **Frostpunk** | Information prioritization and communicating system state |
+| **FTL** | Managing multiple simultaneous problems with limited attention |
+| **XCOM 2** | Clear tactical information hierarchy and action confirmation |
+| **Door Kickers 2** | Planning → execution separation and visual task sequencing |
+
+If you only have time for **five**, I'd choose:
+
+### 1. Supreme Commander
+
+### 2. StarCraft II
+
+### 3. Company of Heroes
+
+### 4. Homeworld
+
+### 5. Total War
+
+Those five cover most of the interaction patterns you're interested in.
+
+---
+
+# 32. And there's a very interesting "bonus game": Factorio
+
+I would absolutely have someone on the team study Factorio.
+
+Not because of its RTS combat.
+
+Because of its **automation philosophy**.
+
+The fundamental question in Factorio is:
+
+> "Why am I manually doing this repeatedly when I can define a system that does it for me?"
+
+That's exactly the question your GCS should ask.
+
+Instead of:
+
+> "Operator, tell UAV 23 to do X."
+
+you want:
+
+> "Operator, define what outcome you want."
+
+Then the system handles the repetitive orchestration.
+
+---
+
+# 33. Another surprisingly useful reference: Dwarf Fortress
+
+Again, not for aesthetics.
+
+For **information density and hierarchical abstraction**.
+
+A complex world can contain thousands of entities, but the user doesn't need to interact with all of them simultaneously.
+
+That's precisely the problem with 50+ vehicles.
+
+---
+
+# 34. The ultimate design principle
+
+I'd put this on the wall of your UX team's room:
+
+> ### **Don't make the operator control 50 vehicles.**
+>
+> ### **Make the operator control 5 intentions.**
+>
+> ### **Make the system turn those intentions into 50 vehicle behaviors.**
+
+That's the difference between a conventional GCS and what you're describing.
+
+The current generation is already demonstrating pieces of this direction: fleet-oriented GCS products emphasize common mission views, multi-operator collaboration, vehicle-agnostic orchestration and centralized/decentralized autonomy. ([ARA][5])
+
+Your opportunity is to combine that with the **interaction design maturity of RTS games**.
+
+---
+
+## If I were designing the product from scratch
+
+I'd make these **10 concepts foundational**:
+
+1. **Mission** — the shared workspace.
+2. **Task** — what needs to happen.
+3. **Group** — a semantic collection of vehicles.
+4. **Vehicle** — the physical asset.
+5. **Role** — what a vehicle contributes to a group.
+6. **Operator** — human identity and authority.
+7. **Command lease** — temporary authority over an object.
+8. **Intent** — high-level desired behavior.
+9. **Constraint** — what the system must never violate.
+10. **Event/history** — who changed what and why.
+
+Then build the UI around:
+
+**Select → Inspect → Assign → Preview → Confirm → Observe → Exception → Reassign**
+
+rather than:
+
+**Click UAV → open panel → change parameter → click save → repeat 50 times.**
+
+That, in my opinion, is the real lesson to take from RTS games.
+
+And there's a potentially excellent product vision hidden in your question: **a "multiplayer RTS" interaction model sitting on top of a rigorous shared mission/state/authority architecture**, where the game-like part makes complexity comprehensible but the underlying system remains deterministic, auditable, constraint-aware, and safety-first.
+
+[1]: https://www.sol.one/gcs?utm_source=chatgpt.com "Sol Ground Control Station"
+[2]: https://news.blizzard.com/en-us/article/6640645/game-guide-simplified-controls?utm_source=chatgpt.com "Game Guide: Simplified Controls — StarCraft II — Blizzard News"
+[3]: https://supcom.fandom.com/wiki/Controls?utm_source=chatgpt.com "Controls | Supreme Commander Wiki | Fandom"
+[4]: https://www.chasemurray.com/research/multi-user-multi-vehicle-mission-control-m3c/?utm_source=chatgpt.com "Multi-user Multi-vehicle Mission Control (M3C) | Chase Murray | UAVs and Operations Research"
+[5]: https://www.ara.com/mpms/?utm_source=chatgpt.com "MPMS - ARA"
\ No newline at end of file
diff --git a/ground-suite-design-files/context2 b/ground-suite-design-files/context2
new file mode 100644
index 0000000..a4482e1
--- /dev/null
+++ b/ground-suite-design-files/context2
@@ -0,0 +1,1293 @@
+# Role
+
+Act as a world-class product designer, interaction designer, information visualization specialist, and UI/UX art director specializing in complex real-time systems.
+
+You are designing a **next-generation multi-UAV ground control suite (GCS)** for professional operators.
+
+The product controls and coordinates potentially **50+ UAVs with approximately 5 human operators**, while allowing multiple GCS workstations to connect simultaneously to the same shared mission.
+
+The goal is NOT to make a "military-looking dashboard."
+
+The goal is to create a **professional, extremely usable, high-performance mission-control interface inspired by the interaction paradigms of modern RTS games**, while maintaining the clarity, seriousness, safety, auditability, and information hierarchy expected from real aviation software.
+
+Create high-fidelity visual design concepts/mockups for this system.
+
+---
+
+# CORE PRODUCT IDEA
+
+The central design philosophy is:
+
+> **Do not make the operator control 50 vehicles. Make the operator control 5 intentions, and let the system translate those intentions into coordinated vehicle behavior.**
+
+The system should abstract complexity.
+
+Instead of forcing operators to individually control:
+
+* UAV-001
+* UAV-002
+* UAV-003
+* ...
+* UAV-050
+
+the system should organize them into:
+
+* Missions
+* Tasks
+* Groups
+* Roles
+* Vehicles
+* Operators
+* Constraints
+* Commands
+* Events
+
+The user should primarily interact with **groups and tasks**, while individual UAV control is available when needed.
+
+Think:
+
+**Mission → Tasks → Groups → Vehicles**
+
+rather than:
+
+**Vehicle → Vehicle → Vehicle → Vehicle**
+
+---
+
+# IMPORTANT DESIGN INSPIRATIONS
+
+Take interaction inspiration from:
+
+### Supreme Commander
+
+Study:
+
+* strategic zoom
+* massive-scale unit management
+* selection of many units
+* control groups
+* strategic vs tactical views
+* command queues
+* visual aggregation
+
+### StarCraft II
+
+Study:
+
+* drag selection
+* control groups
+* contextual commands
+* command queues
+* extremely fast interaction
+* selection feedback
+* unit grouping
+
+### Company of Heroes
+
+Study:
+
+* squad abstraction
+* unit cards
+* contextual actions
+* tactical map
+* clear unit state
+
+### Homeworld
+
+Study:
+
+* 3D spatial awareness
+* formations
+* fleet relationships
+* spatial visualization
+
+### Total War
+
+Study:
+
+* army/group abstraction
+* unit cards
+* strategic/tactical hierarchy
+* large numbers of units represented compactly
+
+### Factorio
+
+Study:
+
+* automation
+* system-level thinking
+* minimizing repetitive manual actions
+* letting the user specify desired outcomes rather than repeatedly operating individual machines
+
+Do NOT copy their visual aesthetics.
+
+Copy their **interaction principles** and improve them for a professional real-world system.
+
+---
+
+# DESIGN PHILOSOPHY
+
+The interface should feel like:
+
+**Google Docs collaboration + modern RTS + aviation mission planning + professional command-and-control software**
+
+It should NOT feel like:
+
+* a video game HUD
+* a traditional military dashboard overloaded with tiny widgets
+* a cockpit instrument panel
+* a sci-fi spaceship interface
+* a generic admin dashboard
+* a cryptocurrency trading terminal
+
+Avoid excessive neon green, glowing borders, unnecessary radar graphics, decorative HUD elements, fake holograms, excessive gradients, or cyberpunk aesthetics.
+
+The interface must feel:
+
+* modern
+* professional
+* calm
+* precise
+* information-dense
+* spatial
+* responsive
+* trustworthy
+* highly usable
+* operationally serious
+
+---
+
+# PRIMARY USER SCENARIO
+
+Create the visual concept around this scenario:
+
+There are:
+
+**5 operators**
+
+controlling:
+
+**50 UAVs**
+
+inside one shared mission.
+
+Example distribution:
+
+ALPHA
+
+* 12 ISR UAVs
+* Area Search task
+* Operator: Alex
+* Status: GREEN
+
+BRAVO
+
+* 10 ISR UAVs
+* Route Recon task
+* Operator: Sara
+* Status: GREEN
+
+CHARLIE
+
+* 8 Relay UAVs
+* Communications task
+* Operator: David
+* Status: GREEN
+
+DELTA
+
+* 12 mixed UAVs
+* Reserve / support
+* Operator: Maria
+* Status: AMBER
+
+RESERVE
+
+* 8 UAVs
+* Available
+* Operator: Ken
+* Status: GREEN
+
+The operator should NOT need to constantly look at all 50 aircraft individually.
+
+---
+
+# CENTRAL UI CONCEPT: SEMANTIC ZOOM
+
+This is one of the most important design requirements.
+
+The interface should support multiple levels of abstraction.
+
+## LEVEL 1 — STRATEGIC
+
+At maximum zoom-out, show:
+
+* overall operating area
+* mission boundaries
+* 5 groups
+* task areas
+* major routes
+* alerts
+* fleet health
+* operator activity
+
+A group should appear as an aggregated entity.
+
+Example:
+
+ALPHA
+12 UAV
+SEARCH
+82%
+GREEN
+
+Do not show 12 individual icons unless the user zooms in or explicitly expands the group.
+
+---
+
+## LEVEL 2 — OPERATIONAL
+
+When zooming into ALPHA:
+
+show:
+
+* individual UAV positions
+* group formation
+* current task
+* planned route
+* group health
+* task progress
+* coverage
+* vehicle availability
+
+The user can see how the group is composed.
+
+---
+
+## LEVEL 3 — TACTICAL
+
+When selecting a particular UAV:
+
+show:
+
+* position
+* altitude
+* heading
+* velocity
+* battery/energy state
+* navigation status
+* communication state
+* current task
+* next action
+* alerts
+* payload state
+
+Do not permanently display all of these values.
+
+Use contextual progressive disclosure.
+
+---
+
+## LEVEL 4 — ENGINEERING
+
+Only when explicitly requested:
+
+show:
+
+* flight controller status
+* sensors
+* actuators
+* logs
+* configuration
+* detailed telemetry
+* parameters
+
+Engineering information should not overwhelm the operational view.
+
+---
+
+# MAIN SCREEN
+
+Create a high-fidelity desktop application screen at approximately:
+
+**3840 × 2160 visual composition**
+
+with a 16:9 aspect ratio.
+
+The primary screen should be a **large interactive geospatial map**.
+
+Use a realistic but fictional operating environment.
+
+Do not use real military locations.
+
+Use fictional terrain:
+
+* coast
+* hills
+* urban area
+* open land
+* restricted zones
+* task areas
+* roads
+* rivers
+
+The map should occupy approximately **70–80% of the screen**.
+
+---
+
+# TOP BAR
+
+Create a clean top command/status bar.
+
+Include:
+
+MISSION: ALPHA-17
+
+Fleet:
+48 / 50 ACTIVE
+
+Tasks:
+7 ACTIVE
+
+Alerts:
+3
+
+Operators:
+5 ONLINE
+
+Network:
+HEALTHY
+
+Mission time:
+01:42:17
+
+At the far right show the currently logged-in operator:
+
+ALEX
+MISSION OPERATOR
+
+Do not make the top bar visually dominant.
+
+---
+
+# LEFT SIDEBAR — MISSION STRUCTURE
+
+Create a collapsible left sidebar.
+
+Show:
+
+MISSION
+
+TASKS
+
+FLEET
+
+GROUPS
+
+OPERATORS
+
+ALERTS
+
+HISTORY
+
+Example:
+
+GROUPS
+
+ALPHA
+12 UAV
+SEARCH
+● GREEN
+
+BRAVO
+10 UAV
+RECON
+● GREEN
+
+CHARLIE
+8 UAV
+RELAY
+● GREEN
+
+DELTA
+12 UAV
+RESERVE
+● AMBER
+
+RESERVE
+8 UAV
+AVAILABLE
+● GREEN
+
+Use compact cards.
+
+Each group should have:
+
+* group name
+* number of vehicles
+* task
+* health
+* operator
+* tiny status indicator
+
+The sidebar should not feel like a conventional file tree.
+
+It should feel like an RTS "army/group control panel."
+
+---
+
+# MAP VISUALIZATION
+
+The map is the primary workspace.
+
+Show:
+
+* 5 UAV groups
+* aggregated group markers
+* individual UAVs for the currently selected group
+* flight paths
+* future planned routes
+* task areas
+* geofences
+* restricted areas
+* communication coverage
+* points of interest
+* alerts
+
+Use subtle colors.
+
+Suggested palette:
+
+Background/map:
+
+* dark charcoal
+* desaturated navy
+* muted terrain colors
+
+Primary friendly/active:
+
+* blue
+
+Selected:
+
+* bright cyan/blue
+
+Healthy:
+
+* green
+
+Warning:
+
+* amber
+
+Critical:
+
+* red
+
+Planning/edit state:
+
+* violet/purple
+
+Avoid making everything colorful.
+
+Color should encode meaning.
+
+---
+
+# GROUP VISUALIZATION
+
+When ALPHA is selected:
+
+Show 12 UAVs.
+
+Represent them as small directional aircraft icons.
+
+Each aircraft should have:
+
+* heading indicator
+* subtle velocity vector
+* tiny status indicator
+
+Show their planned paths using thin translucent lines.
+
+Show the group boundary or formation using a subtle translucent shape.
+
+Example visual concept:
+
+ALPHA
+
+12 aircraft
+
+AREA SEARCH
+
+82% COMPLETE
+
+The group should visually read as ONE coordinated entity rather than 12 unrelated objects.
+
+---
+
+# GROUP CARD
+
+Create a contextual bottom panel when ALPHA is selected.
+
+Example:
+
+ALPHA
+
+12 VEHICLES
+
+AREA SEARCH
+
+82% COMPLETE
+
+OPERATOR
+ALEX
+
+STATUS
+GREEN
+
+CURRENT
+SEARCH AREA NORTH
+
+NEXT
+RETURN
+
+Then show contextual actions:
+
+TASK
+MOVE
+HOLD
+GROUP
+SPLIT
+TRANSFER
+DETAILS
+
+Use large, easily clickable controls.
+
+Do not create dozens of tiny buttons.
+
+---
+
+# SMART SELECTION
+
+Demonstrate an RTS-style drag selection.
+
+Show a translucent selection rectangle over an area of the map.
+
+Inside it are several UAVs.
+
+At the bottom:
+
+27 VEHICLES SELECTED
+
+FILTER:
+
+ISR 14
+
+RELAY 7
+
+TRANSPORT 6
+
+Then show:
+
+[ CREATE GROUP ]
+
+[ ASSIGN TASK ]
+
+[ ADD TO GROUP ]
+
+The UI should make it obvious that the operator can manipulate many vehicles simultaneously.
+
+---
+
+# SMART FILTER / QUERY INTERFACE
+
+Create a visual example of intelligent fleet selection.
+
+Example command:
+
+"Select available ISR aircraft within 20 km of Task Bravo with more than 60% energy."
+
+Represent the query as structured chips:
+
+TYPE
+ISR
+
+AVAILABILITY
+AVAILABLE
+
+DISTANCE
+< 20 KM
+
+ENERGY
+
+> 60%
+
+TASK
+NONE
+
+Result:
+
+8 VEHICLES SELECTED
+
+The design should feel like a powerful professional filtering system, not a developer query interface.
+
+---
+
+# TASK OBJECT
+
+Create a visual card representing a mission task.
+
+Example:
+
+TASK-143
+
+AREA SEARCH
+
+Priority:
+HIGH
+
+Area:
+Northern Sector
+
+Required:
+4 UAV
+
+Assigned:
+ALPHA / 4
+
+Progress:
+63%
+
+Health:
+GREEN
+
+Estimated completion:
+18 MIN
+
+Make this task visually connect to its geographical area on the map.
+
+The operator should be able to select the task and immediately understand:
+
+* what needs to happen
+* where
+* with what resources
+* who owns it
+* how far it has progressed
+* whether there is a problem
+
+---
+
+# COMMAND QUEUE
+
+Create a visual command/task sequence for ALPHA.
+
+Show:
+
+TRANSIT
+↓
+SEARCH AREA A
+↓
+LOITER
+↓
+RETURN
+↓
+RECHARGE
+↓
+SEARCH AREA B
+
+Represent this as a clean horizontal or vertical workflow.
+
+Allow drag-and-drop reordering.
+
+The design should communicate that operators are specifying **intent**, not manually flying every UAV.
+
+---
+
+# COMMAND PREVIEW
+
+Create a modal or side panel that appears before executing a high-level action.
+
+Example:
+
+ASSIGN TASK
+
+6 VEHICLES SELECTED
+
+Estimated completion:
+18 min
+
+Energy reserve:
+OK
+
+Route conflicts:
+NONE
+
+Geofence conflicts:
+NONE
+
+Vehicle compatibility:
+6 / 6
+
+Communication:
+GOOD
+
+Potential issue:
+1 aircraft may require reassignment
+
+Buttons:
+
+CANCEL
+
+PREVIEW
+
+CONFIRM
+
+The design should emphasize that the system validates the intended command before execution.
+
+---
+
+# MULTI-OPERATOR COLLABORATION
+
+Create a second visual state showing five operators connected to the same mission.
+
+Represent them subtly:
+
+ALEX
+Commanding ALPHA
+
+SARA
+Commanding BRAVO
+
+DAVID
+Planning
+
+MARIA
+Monitoring
+
+KEN
+Engineering
+
+Show that multiple users can view the same map simultaneously.
+
+When Alex selects ALPHA, display:
+
+COMMAND AUTHORITY
+ALEX
+
+Other operators can see ALPHA but cannot accidentally issue conflicting commands.
+
+---
+
+# COMMAND LEASE
+
+Create a visual interaction for temporary command authority.
+
+Example:
+
+ALPHA
+
+COMMAND LEASE
+ALEX
+
+Expires:
+02:34
+
+Observers:
+SARA
+DAVID
+
+Button:
+
+REQUEST CONTROL
+
+When another operator requests control:
+
+OPERATOR B REQUESTS COMMAND
+
+Reason:
+Emergency intervention
+
+Buttons:
+
+TRANSFER CONTROL
+
+DENY
+
+This should visually communicate shared awareness and explicit authority.
+
+---
+
+# ATTENTION MANAGEMENT
+
+Create a prominent but restrained "ATTENTION" area.
+
+Example:
+
+ATTENTION
+
+🔴 UAV-27
+Navigation degraded
+
+🟠 ALPHA
+Task behind schedule
+
+🟠 BRAVO
+Approaching constraint
+
+Then show:
+
+47 VEHICLES NOMINAL
+
+The idea is that the system should suppress normal information and surface exceptions.
+
+Do not create a wall of alerts.
+
+---
+
+# FAILURE / REASSIGNMENT CONCEPT
+
+Create a visual state where one aircraft becomes unavailable.
+
+Example:
+
+UAV-03
+UNAVAILABLE
+
+The system automatically identifies potential replacements.
+
+Show:
+
+TASK:
+ALPHA SEARCH
+
+REQUIRED:
+4 VEHICLES
+
+CURRENT:
+3 / 4
+
+RECOMMENDED REPLACEMENTS:
+
+UAV-19
+READY
+
+UAV-27
+READY
+
+UAV-31
+READY
+
+The design should communicate that the mission system can reason about resources rather than forcing the operator to rebuild the mission manually.
+
+---
+
+# STRATEGIC ZOOM CONCEPT
+
+Create THREE visual panels side by side demonstrating the same map at different abstraction levels.
+
+## PANEL A — STRATEGIC
+
+Show:
+
+5 groups
+7 tasks
+3 alerts
+
+Only aggregated group markers.
+
+## PANEL B — OPERATIONAL
+
+Zoom into ALPHA.
+
+Show:
+
+12 individual UAVs
+formation
+task area
+route
+coverage
+
+## PANEL C — VEHICLE
+
+Zoom into UAV-07.
+
+Show:
+
+position
+heading
+energy
+navigation
+communication
+payload
+task
+
+Make it visually obvious that the UI becomes more detailed as the user zooms in.
+
+---
+
+# INFORMATION HIERARCHY
+
+The visual hierarchy should follow:
+
+1. What is happening?
+2. What needs my attention?
+3. What is the mission doing?
+4. What is each group doing?
+5. What does this particular vehicle need?
+6. Engineering details only when requested.
+
+Do NOT invert this hierarchy.
+
+---
+
+# VISUAL STYLE
+
+Use a modern professional dark interface.
+
+Preferred aesthetic:
+
+* dark graphite
+* deep navy
+* muted blue-gray
+* restrained cyan
+* subtle green
+* amber
+* red only for critical states
+* occasional violet for planning/editing state
+
+Use:
+
+* soft shadows
+* very subtle borders
+* restrained transparency
+* clean typography
+* generous spacing
+* clear grouping
+* strong alignment
+* high contrast
+
+Avoid:
+
+* excessive glow
+* cyberpunk
+* sci-fi holograms
+* decorative radar screens
+* fake military insignia
+* unnecessary gradients
+* excessive rounded cards
+* tiny unreadable text
+* excessive information density
+* ornamental UI
+
+The result should look like a product that could actually be used for hours by professional operators.
+
+---
+
+# TYPOGRAPHY
+
+Use a modern highly legible sans-serif.
+
+Possible visual direction:
+
+Inter / SF Pro / IBM Plex Sans / similar.
+
+Use typography hierarchy:
+
+Large:
+Mission / major group
+
+Medium:
+Task / group name
+
+Small:
+Telemetry / metadata
+
+Monospaced type may be used sparingly for:
+
+* IDs
+* timestamps
+* numeric telemetry
+* coordinates
+
+Do not make the whole interface monospace.
+
+---
+
+# ICONOGRAPHY
+
+Use simple geometric icons.
+
+UAV icons should be directional.
+
+Groups should have an aggregated visual identity.
+
+Tasks should have distinct but restrained icons.
+
+Alerts should be unmistakable.
+
+Avoid decorative military iconography.
+
+---
+
+# INTERACTION STATES TO VISUALIZE
+
+Produce visual examples of all of the following:
+
+1. Nothing selected
+2. One group selected
+3. Multiple groups selected
+4. Individual UAV selected
+5. Drag-selecting multiple UAVs
+6. Task selected
+7. Command preview
+8. Command queue
+9. Operator requesting command authority
+10. Operator transferring command authority
+11. UAV failure
+12. Automatic replacement recommendation
+13. Strategic zoom
+14. Operational zoom
+15. Vehicle-level zoom
+16. Alert escalation
+17. Collaborative mission planning
+
+---
+
+# CRITICAL UX PRINCIPLE
+
+The system must always answer these questions visually:
+
+### WHAT?
+
+What is happening?
+
+### WHERE?
+
+Where is it happening?
+
+### WHO?
+
+Who/what is responsible?
+
+### WHY?
+
+Why is the system doing this?
+
+### NEXT?
+
+What will happen next?
+
+### WHAT IF?
+
+What happens if something fails?
+
+### WHO CAN CHANGE IT?
+
+Who currently has command authority?
+
+These should be understandable within seconds.
+
+---
+
+# REAL-TIME FEEL
+
+The interface should look alive.
+
+Show subtle indicators of real-time state:
+
+* moving aircraft
+* updating progress
+* changing task status
+* operator presence
+* live connection status
+* changing fleet counts
+* recent events
+
+But do not make the interface visually chaotic.
+
+The system should feel like a living operational environment.
+
+---
+
+# EVENT / HISTORY PANEL
+
+Create a chronological event stream.
+
+Example:
+
+14:32:08
+ALEX
+Assigned ALPHA to TASK-143
+
+14:32:14
+SYSTEM
+Route validated
+
+14:32:21
+SARA
+Updated BRAVO search area
+
+14:33:02
+SYSTEM
+UAV-03 unavailable
+
+14:33:04
+SYSTEM
+Recommended UAV-19 as replacement
+
+14:33:17
+ALEX
+Accepted replacement
+
+Make the history auditable and easy to understand.
+
+---
+
+# DESIGN THE UI FOR MULTIPLE MONITOR SIZES
+
+The application should support:
+
+* laptop
+* 27-inch monitor
+* ultrawide monitor
+* 4K command-center display
+
+Do not depend on tiny controls.
+
+The interface should gracefully adapt.
+
+On smaller displays, secondary panels collapse.
+
+On larger displays, additional information can appear without making the main map smaller.
+
+---
+
+# CREATE A DESIGN SYSTEM
+
+After creating the main screen, show a small design-system panel containing:
+
+* colors
+* typography
+* buttons
+* status indicators
+* group cards
+* task cards
+* UAV icons
+* alert states
+* operator presence indicators
+* command authority indicators
+
+This should look like a real product design system.
+
+---
+
+# IMPORTANT: SHOW REAL UI, NOT A CONCEPT ART SCREEN
+
+The output should look like a **real, implementable professional software interface**.
+
+Do not generate a cinematic image of a command center.
+
+Do not generate people sitting in front of screens.
+
+Do not generate a futuristic holographic battlefield.
+
+Generate the actual application UI.
+
+The viewer should be able to imagine a software engineer implementing it.
+
+---
+
+# VISUAL OUTPUT
+
+Create a polished high-fidelity visual presentation containing:
+
+### HERO SCREEN
+
+Full 16:9 multi-UAV mission control interface.
+
+### SCREEN 2
+
+Strategic zoom → operational zoom → vehicle zoom.
+
+### SCREEN 3
+
+Multi-operator collaborative mission view.
+
+### SCREEN 4
+
+Task creation + resource allocation + command preview.
+
+### SCREEN 5
+
+Failure → automatic reassignment → operator confirmation.
+
+### SCREEN 6
+
+Command queue / mission workflow.
+
+### SCREEN 7
+
+Design system.
+
+Use consistent visual language across every screen.
+
+---
+
+# FINAL DESIGN TEST
+
+Before finalizing the visuals, mentally test the design against this scenario:
+
+Five operators are simultaneously managing fifty UAVs.
+
+Operator A manages a 12-UAV search group.
+
+Operator B manages a 10-UAV reconnaissance group.
+
+Operator C manages communications.
+
+Operator D is planning future tasks.
+
+Operator E is monitoring system health.
+
+One UAV fails.
+
+A new task arrives.
+
+Two groups need to be reorganized.
+
+An operator needs to temporarily transfer control of a group.
+
+The system needs to continue operating without everyone fighting over the same controls.
+
+The UI should make this scenario feel manageable rather than overwhelming.
+
+The final visual design should communicate:
+
+> **One shared mission.**
+>
+> **Many operators.**
+>
+> **Many vehicles.**
+>
+> **One coherent operational picture.**
+
+Prioritize cognitive ergonomics, hierarchy, semantic zoom, group abstraction, collaborative authority, exception management, and high-level intent over decorative aesthetics.
+
+### One thing I'd add to the Gemini workflow
+
+Don't stop at the first generated dashboard. Ask Gemini to produce **separate iterations around specific interaction problems**.
+
+For example, after the master prompt, give it:
+
+> "Now forget the visual styling and focus exclusively on the interaction design for managing 50 UAVs with 5 operators. Produce 5 radically different UI concepts for selection, grouping, command authority, task assignment, and strategic zoom. Do not optimize for visual beauty yet. Optimize for reducing operator cognitive load."
+
+Then another:
+
+> "Take concept #3 and redesign it as if Supreme Commander, Google Docs collaborative editing, and a professional aviation mission planner had been combined. Show exactly how five operators can simultaneously manipulate different groups without command conflicts."
+
+And finally:
+
+> "Now create a polished production-quality visual design based on this interaction model. Remove anything that looks like a video game HUD. Keep the RTS interaction model but make the visual language appropriate for professional aviation software."
+
+That **three-stage process—interaction → information architecture → visual design** will likely give you much better results than asking Gemini to jump directly to a pretty GCS screenshot.
\ No newline at end of file
diff --git a/ground-suite-design-files/index.html b/ground-suite-design-files/index.html
new file mode 100644
index 0000000..94a40a0
--- /dev/null
+++ b/ground-suite-design-files/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ Atlas — Mission Control POC
+
+
+
+
+
+
+
diff --git a/ground-suite-design-files/styles.css b/ground-suite-design-files/styles.css
new file mode 100644
index 0000000..4507600
--- /dev/null
+++ b/ground-suite-design-files/styles.css
@@ -0,0 +1,459 @@
+:root {
+ --surface-0: #0b0f14;
+ --surface-1: #111724;
+ --surface-2: #171d2b;
+ --surface-3: #1d2536;
+ --surface-4: #263247;
+ --border: #2a3447;
+ --border-soft: rgba(105, 133, 161, .22);
+ --text: #edf3f7;
+ --muted: #9aa8b7;
+ --dim: #6e7d90;
+ --blue: #227bed;
+ --cyan: #2ab9d6;
+ --green: #59c66f;
+ --amber: #f0b544;
+ --red: #ed5357;
+ --violet: #9a73d8;
+ --radius: 7px;
+ --shadow: 0 14px 36px rgba(0, 0, 0, .3);
+ --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+
+* { box-sizing: border-box; }
+html, body, #app { min-height: 100%; margin: 0; }
+body {
+ background: var(--surface-0);
+ color: var(--text);
+ font-family: var(--sans);
+ font-size: 14px;
+ overflow-x: hidden;
+}
+button, input, textarea { font: inherit; }
+button { color: inherit; }
+button:focus-visible, input:focus-visible, textarea:focus-visible {
+ outline: 2px solid var(--cyan);
+ outline-offset: 2px;
+}
+h1, h2, h3, p { margin: 0; }
+h1 { font-size: 20px; line-height: 1.2; }
+h2 { font-size: 16px; line-height: 1.25; }
+h3 { font-size: 14px; }
+small { color: var(--muted); font-size: 11px; line-height: 1.3; }
+a { color: var(--cyan); }
+dl, dt, dd { margin: 0; }
+.icon { width: 18px; height: 18px; flex: 0 0 auto; }
+.green { color: var(--green) !important; }
+.amber { color: var(--amber) !important; }
+.blue { color: var(--blue) !important; }
+.cyan { color: var(--cyan) !important; }
+.violet { color: var(--violet) !important; }
+.red { color: var(--red) !important; }
+
+.topbar {
+ height: 68px;
+ display: flex;
+ align-items: stretch;
+ gap: 18px;
+ padding: 0 16px;
+ background: rgba(9, 14, 21, .97);
+ border-bottom: 1px solid var(--border);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+.brand, .view-nav, .top-metrics, .operator-avatar {
+ display: flex;
+ align-items: center;
+}
+.brand { gap: 10px; min-width: max-content; font-weight: 700; letter-spacing: .08em; }
+.brand-mark {
+ width: 28px; height: 28px; display: grid; place-items: center;
+ color: var(--cyan); border: 1px solid var(--blue); border-radius: 5px;
+ font-size: 13px; box-shadow: inset 0 0 12px rgba(35, 123, 237, .18);
+}
+.brand-divider { width: 1px; height: 28px; background: var(--border); margin-left: 4px; }
+.mission-name { color: var(--muted); font-size: 12px; letter-spacing: .03em; }
+.view-nav { gap: 2px; margin: 0 auto; overflow-x: auto; scrollbar-width: none; }
+.view-nav::-webkit-scrollbar { display: none; }
+.nav-item {
+ height: 68px; display: inline-flex; gap: 7px; align-items: center; padding: 0 9px;
+ background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); cursor: pointer;
+ white-space: nowrap; font-size: 12px;
+}
+.nav-item:hover { color: var(--text); background: rgba(255,255,255,.025); }
+.nav-item.is-active { color: var(--cyan); border-bottom-color: var(--blue); background: rgba(35,123,237,.06); }
+.nav-item .icon { width: 16px; height: 16px; }
+.top-metrics { gap: 16px; }
+.top-metrics > div:not(.operator-avatar) { display: grid; gap: 2px; padding-right: 16px; border-right: 1px solid var(--border-soft); white-space: nowrap; }
+.top-metrics small { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
+.top-metrics strong { font-size: 12px; font-weight: 600; }
+.operator-avatar { gap: 8px; min-width: 112px; }
+.operator-avatar > span { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--cyan); font-size: 10px; font-weight: 700; }
+.operator-avatar div { display: grid; gap: 2px; }
+.operator-avatar strong { text-transform: uppercase; font-size: 11px; }
+.operator-avatar small { font-size: 9px; }
+
+.button {
+ min-height: 40px; border: 1px solid var(--border); border-radius: 5px; padding: 0 13px;
+ background: rgba(23, 31, 45, .96); display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+ cursor: pointer; font-weight: 600; font-size: 12px; transition: border-color .15s, background .15s, transform .15s;
+}
+.button:hover:not(:disabled) { border-color: #54708f; background: var(--surface-3); }
+.button:active:not(:disabled) { transform: translateY(1px); }
+.button.primary { background: var(--blue); border-color: #3b8af1; color: #fff; }
+.button.primary:hover:not(:disabled) { background: #3288f4; }
+.button.danger { background: #a92f37; border-color: #df555c; color: #fff; }
+.button.danger:hover:not(:disabled) { background: #c43e46; }
+.button:disabled { opacity: .48; cursor: not-allowed; }
+.icon-button { width: 40px; min-width: 40px; padding: 0; }
+.full-width { width: 100%; text-transform: uppercase; letter-spacing: .04em; }
+.status { display: inline-flex; align-items: center; gap: 5px; text-transform: uppercase; color: var(--muted); font-size: 10px; font-weight: 600; white-space: nowrap; }
+.status-dot, .presence-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dim); display: inline-block; }
+.status.nominal, .status.nominal .status-dot { color: var(--green); }
+.status.nominal .status-dot, .presence-dot.green { background: var(--green); }
+.status.attention, .status.attention .status-dot { color: var(--amber); }
+.status.attention .status-dot, .presence-dot.amber, .presence-dot.yellow { background: var(--amber); }
+.status.critical, .status.critical .status-dot { color: var(--red); }
+.status.critical .status-dot { background: var(--red); }
+.status.cyan, .status.cyan .status-dot { color: var(--cyan); }
+.status.cyan .status-dot, .presence-dot.cyan { background: var(--cyan); }
+.status.blue, .status.blue .status-dot { color: var(--blue); }
+.status.blue .status-dot, .presence-dot.blue { background: var(--blue); }
+.status.planned, .status.planned .status-dot { color: var(--violet); }
+.status.planned .status-dot, .presence-dot.violet { background: var(--violet); }
+
+.workspace { min-height: calc(100vh - 68px); display: grid; background: var(--surface-0); }
+.overview-workspace { grid-template: minmax(580px, calc(100vh - 220px)) auto / 250px minmax(400px, 1fr) 250px; }
+.group-rail, .attention-rail { background: var(--surface-1); border-right: 1px solid var(--border); padding: 12px 10px; min-width: 0; }
+.attention-rail { border-right: 0; border-left: 1px solid var(--border); }
+.rail-heading { height: 34px; padding: 0 7px 9px; display: flex; align-items: center; justify-content: space-between; color: #c1ccda; text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }
+.rail-heading .button { min-height: 28px; width: 28px; min-width: 28px; border: 0; background: none; }
+.rail-heading .icon { width: 16px; height: 16px; }
+.group-list { display: grid; gap: 8px; margin-bottom: 12px; }
+.group-card {
+ width: 100%; display: grid; grid-template-columns: 38px 1fr auto; gap: 8px; text-align: left; min-height: 92px; padding: 10px;
+ background: rgba(23, 31, 43, .72); border: 1px solid rgba(82,104,129,.26); border-radius: 6px; cursor: pointer;
+}
+.group-card:hover { border-color: #4d6380; background: var(--surface-2); }
+.group-card.selected { border-color: #2688db; box-shadow: inset 2px 0 0 var(--blue), 0 0 20px rgba(32, 120, 225, .08); }
+.group-badge {
+ width: 34px; height: 34px; border: 1px solid #52667d; display: grid; place-items: center; border-radius: 50%;
+ font-weight: 700; color: #c9d4de; flex: 0 0 auto;
+}
+.group-badge.cyan { border-color: var(--cyan); color: var(--cyan); }
+.group-badge.blue { border-color: var(--blue); color: #73b0ff; }
+.group-badge.green { border-color: var(--green); color: var(--green); }
+.group-badge.amber { border-color: var(--amber); color: var(--amber); }
+.group-badge.violet { border-color: var(--violet); color: var(--violet); }
+.group-card-main { min-width: 0; display: grid; align-content: start; gap: 2px; }
+.group-card-main strong { font-size: 13px; }
+.group-card-main small { font-size: 10px; }
+.operator-line { margin-top: auto; color: var(--muted); font-size: 10px; }
+.group-card-side { min-width: 34px; display: grid; justify-items: end; align-content: space-between; }
+.group-card-side .status { font-size: 0; }
+.group-card-side .status-dot { width: 8px; height: 8px; }
+.group-card-side > b { color: var(--cyan); font-size: 12px; }
+.mini-grid { display: grid; grid-template-columns: repeat(3, 3px); gap: 2px; padding-top: 2px; }
+.mini-grid em { width: 3px; height: 3px; border-radius: 50%; background: var(--blue); }
+.group-card:nth-child(4) .mini-grid em { background: var(--amber); }
+.map-stage { min-width: 0; min-height: 0; position: relative; }
+.map-canvas, .zoom-map, .queue-map {
+ min-height: 100%; overflow: hidden; position: relative; isolation: isolate;
+ background: #07111c;
+}
+.map-canvas::before, .zoom-map::before, .queue-map::before {
+ content: ""; position: absolute; inset: 0; z-index: -3; background:
+ radial-gradient(ellipse at 24% 35%, rgba(43, 94, 87, .48), transparent 38%),
+ radial-gradient(ellipse at 45% 72%, rgba(71, 91, 66, .26), transparent 32%),
+ linear-gradient(135deg, #122531 0%, #0a151e 45%, #06121e 76%);
+}
+.map-art { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -2; opacity: .9; }
+.map-art .land { fill: rgba(45, 70, 65, .8); stroke: #4c6c6d; stroke-width: 2; }
+.map-art .coast { fill: none; stroke: #a6c3bb; stroke-opacity: .4; stroke-width: 2; }
+.map-art .river { fill: none; stroke: #5d91a4; stroke-opacity: .6; stroke-width: 3; }
+.map-art .road { fill: none; stroke: #acb5a1; stroke-opacity: .28; stroke-width: 2; stroke-dasharray: 3 2; }
+.map-art .road.thin { stroke-width: 1; stroke-dasharray: 5 5; }
+.map-art .hill { fill: none; stroke: #778f75; stroke-opacity: .32; stroke-width: 3; }
+.map-art .town { fill: #c0c9bb; fill-opacity: .22; stroke: #dfe4d6; stroke-opacity: .4; }
+.map-label { position: absolute; color: #c4d1d4; text-shadow: 0 1px 3px #000; font-size: 11px; }
+.scale { position: absolute; left: 16px; bottom: 16px; color: #d3dce3; font-family: var(--mono); font-size: 10px; display: grid; gap: 3px; }
+.scale i { width: 84px; height: 7px; border-bottom: 2px solid #e4eced; border-left: 2px solid #e4eced; border-right: 2px solid #e4eced; }
+.map-controls { position: absolute; right: 14px; top: 14px; z-index: 10; display: grid; gap: 5px; }
+.map-controls .button { background: rgba(12, 21, 31, .88); }
+.layers-popover { position: absolute; right: 60px; top: 14px; z-index: 11; padding: 12px; background: rgba(12,21,31,.98); border: 1px solid var(--border); border-radius: 6px; box-shadow: var(--shadow); display: grid; gap: 8px; min-width: 150px; }
+.layers-popover strong { font-size: 12px; }
+.layers-popover label { color: var(--muted); font-size: 11px; display: flex; gap: 7px; align-items: center; }
+.task-area { position: absolute; inset: 19% 28% 19% 19%; z-index: 1; pointer-events: none; }
+.task-shape { position: absolute; inset: 0; background: rgba(28, 122, 183, .16); clip-path: polygon(17% 0, 82% 7%, 100% 52%, 67% 100%, 8% 83%, 0 37%); border: 1px solid var(--cyan); }
+.task-shape::after { content: ""; position: absolute; inset: 0; background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(68,181,215,.14) 7px 8px); }
+.task-area::after { content: ""; position: absolute; inset: 0; clip-path: polygon(17% 0,82% 7%,100% 52%,67% 100%,8% 83%,0 37%); border: 1.4px solid var(--cyan); }
+.task-area span { position: absolute; left: 50%; top: 39%; transform: translate(-50%, -50%); color: #61d4ea; text-align: center; font-size: 13px; line-height: 1.45; font-weight: 600; white-space: nowrap; }
+.task-area.violet .task-shape { background: rgba(119, 75, 166, .22); }
+.task-area.violet::after { border-color: var(--violet); }
+.task-area.violet span { color: #be9bf3; }
+.task-area.muted { opacity: .55; }
+.route-line { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 2; pointer-events: none; }
+.route-line path { stroke: #51a0ec; stroke-width: 3; stroke-opacity: .86; stroke-dasharray: 8 7; }
+.route-line .planned-path { stroke: var(--violet); stroke-opacity: .68; stroke-width: 2.5; }
+.vehicle-marker {
+ position: absolute; z-index: 4; transform: translate(-50%, -50%); background: none; border: 0; color: #bde6ff; padding: 7px; cursor: pointer;
+}
+.vehicle-marker .icon { transform: rotate(var(--heading)); width: 21px; height: 21px; filter: drop-shadow(0 1px 4px rgba(0,0,0,.8)); stroke-width: 2.2; }
+.vehicle-marker::after { content: ""; position: absolute; left: 50%; bottom: 2px; height: 17px; border-left: 1px solid rgba(91, 194, 222, .55); transform: rotate(calc(var(--heading) + 180deg)); transform-origin: bottom; }
+.vehicle-marker:hover .icon, .vehicle-marker.selected .icon { color: white; stroke: var(--cyan); filter: drop-shadow(0 0 6px rgba(42,185,214,.7)); }
+.vehicle-marker.critical { color: var(--red); }
+.vehicle-marker.critical::after { border-color: var(--red); }
+.vehicle-marker > span { position: absolute; left: 20px; top: 4px; color: #fff; text-shadow: 0 1px 3px #000; font-size: 11px; white-space: nowrap; }
+.coverage-ring { position: absolute; width: 145px; height: 145px; right: 23%; top: 25%; border: 1px dashed rgba(100, 179, 223, .55); border-radius: 50%; z-index: 1; }
+.poi { position: absolute; z-index: 3; width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid currentColor; background: rgba(5,13,23,.75); font-weight: 600; }
+.poi-blue { color: #6ab0ff; border-color: #398dde; }
+.poi-violet { color: #c399ff; border-color: var(--violet); border-radius: 50%; }
+.geofence { position: absolute; z-index: 0; width: 130px; height: 100px; right: 8%; top: 7%; transform: rotate(-10deg); background: repeating-linear-gradient(45deg, rgba(237,83,87,.1) 0 5px, transparent 5px 10px); border: 1px solid rgba(237,83,87,.56); }
+.map-mini-exceptions { position: absolute; z-index: 8; right: 18px; bottom: 18px; padding: 12px; width: 215px; background: rgba(11,20,29,.94); border: 1px solid var(--border); box-shadow: var(--shadow); font-size: 11px; }
+.map-mini-exceptions strong { text-transform: uppercase; color: #b9c5d3; font-size: 10px; letter-spacing: .05em; }
+.map-mini-exceptions p { display: flex; gap: 6px; margin: 7px 0; color: #c8d0d6; }
+.map-mini-exceptions .icon { width: 14px; height: 14px; color: var(--amber); }
+.map-mini-exceptions a { font-size: 10px; text-transform: uppercase; }
+
+.attention-card { width: 100%; padding: 12px 9px; display: grid; grid-template-columns: 23px 1fr; gap: 9px; text-align: left; background: rgba(29, 31, 25, .45); color: var(--text); border: 0; border-bottom: 1px solid var(--border-soft); cursor: pointer; }
+.attention-card:hover { background: var(--surface-2); }
+.attention-card .icon { color: var(--amber); margin-top: 2px; }
+.attention-card.warning .icon { color: #f1b64b; }
+.attention-card span { display: grid; gap: 3px; }
+.attention-card strong { font-size: 12px; font-weight: 600; }
+.attention-card em { grid-column: 2; color: var(--blue); font-size: 9px; font-style: normal; text-transform: uppercase; }
+.fleet-nominal { margin: 16px 3px; padding: 15px; display: flex; gap: 12px; align-items: center; border: 1px solid var(--border); background: rgba(25,35,42,.8); }
+.fleet-nominal .icon { color: var(--green); width: 21px; height: 21px; }
+.fleet-nominal div { display: grid; grid-template-columns: auto 1fr; align-items: end; column-gap: 7px; }
+.fleet-nominal b { grid-row: span 2; color: var(--green); font-size: 30px; line-height: .85; }
+.fleet-nominal span { font-size: 10px; text-transform: uppercase; }
+.fleet-nominal small { grid-column: span 2; margin-top: 7px; }
+
+.context-bar { grid-column: 2 / 4; border-top: 1px solid var(--border); background: var(--surface-1); }
+.context-summary { min-height: 70px; display: flex; align-items: center; gap: 10px; padding: 10px 18px; border-bottom: 1px solid var(--border-soft); }
+.context-summary h2 { text-transform: uppercase; }
+.context-summary > div:nth-child(2) { display: grid; gap: 2px; min-width: 90px; }
+.context-summary > div:nth-child(2) small { color: var(--blue); text-transform: uppercase; }
+.context-summary dl { display: grid; grid-template-columns: repeat(6, minmax(82px, 1fr)); gap: 16px; flex: 1; }
+.context-summary dl div { display: grid; gap: 4px; min-width: 0; }
+.context-summary dt { color: var(--dim); font-size: 9px; text-transform: uppercase; }
+.context-summary dd { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.progress { display: inline-flex; height: 4px; width: 68px; margin-left: 5px; vertical-align: middle; background: #283440; border-radius: 4px; overflow: hidden; }
+.progress b { height: 100%; background: var(--blue); }
+.context-actions { padding: 8px 18px; display: grid; grid-template-columns: repeat(7, 1fr); gap: 7px; }
+.context-actions .button { text-transform: uppercase; font-size: 11px; }
+
+.view-intro { padding: 24px 26px 18px; display: flex; justify-content: space-between; align-items: end; border-bottom: 1px solid var(--border); }
+.view-intro h1 { font-size: 24px; text-transform: uppercase; letter-spacing: -.03em; }
+.view-intro h1 span { font-size: 16px; color: var(--muted); font-weight: 500; }
+.view-intro p { color: var(--muted); margin-top: 5px; }
+.legend { display: flex; flex-wrap: wrap; gap: 14px; padding-bottom: 3px; }
+.zoom-view { min-height: calc(100vh - 68px); background: var(--surface-0); }
+.zoom-grid { display: grid; grid-template-columns: repeat(3, minmax(280px, 1fr)); gap: 12px; padding: 18px; }
+.zoom-panel { min-width: 0; min-height: 670px; display: grid; grid-template-rows: 56px 1fr 92px; border: 1px solid var(--border); background: var(--surface-1); border-radius: 5px; overflow: hidden; }
+.zoom-panel > header { display: flex; align-items: center; gap: 11px; padding: 0 14px; border-bottom: 1px solid var(--border); text-transform: uppercase; }
+.zoom-panel > header h2 { font-size: 15px; }
+.zoom-panel > header .button { margin-left: auto; min-height: 30px; width: 30px; min-width: 30px; }
+.letter { width: 26px; height: 26px; display: grid; place-items: center; color: #fff; background: #245ea3; font-weight: 700; border-radius: 2px; }
+.zoom-map { min-height: 0; }
+.zoom-map .map-label { font-size: 9px; }
+.zoom-map .task-area { inset: 20% 16% 20% 17%; }
+.zoom-map .task-area.small-task { inset: 18% 12% 27% 48%; }
+.zoom-map .task-area.wide { inset: 16% 8% 17% 7%; }
+.zoom-map .task-area span { font-size: 11px; }
+.zoom-map .vehicle-marker { transform: translate(-50%, -50%) scale(.72); }
+.zoom-map .group-marker { transform: scale(.75); transform-origin: left top; }
+.zoom-map .group-marker .marker-card { font-size: 12px; }
+.zoom-map .route-line { opacity: .7; }
+.zoom-fleet { position: absolute; left: 12px; right: 12px; bottom: 12px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; background: rgba(10, 18, 27, .83); border: 1px solid var(--border); padding: 8px; }
+.zoom-fleet span { display: grid; gap: 5px; color: var(--muted); font-size: 8px; }
+.zoom-fleet b { color: #cad7e0; font-size: 9px; }
+.zoom-fleet i { display: block; height: 4px; background: #2b3543; border-radius: 3px; }
+.zoom-fleet i::after { content: ""; display: block; width: 70%; height: 100%; background: var(--blue); border-radius: 3px; }
+.map-kpi { position: absolute; left: 14px; top: 14px; padding: 12px; min-width: 132px; background: rgba(10,20,29,.9); border: 1px solid var(--border); border-left: 2px solid var(--green); display: grid; gap: 3px; }
+.map-kpi b { color: var(--green); font-size: 22px; }
+.map-kpi .ring { position: absolute; right: 14px; top: 18px; width: 31px; height: 31px; border: 4px solid rgba(89,198,111,.25); border-top-color: var(--green); border-radius: 50%; }
+.map-kpi em { font-size: 10px; font-style: normal; color: var(--muted); }
+.vehicle-inspector { position: absolute; right: 12px; top: 18px; z-index: 11; width: 184px; padding: 13px; background: rgba(13,23,34,.96); border: 1px solid var(--border); box-shadow: var(--shadow); }
+.vehicle-inspector > div { display: flex; align-items: center; justify-content: space-between; }
+.vehicle-inspector > div .button { min-height: 22px; width: 22px; min-width: 22px; }
+.vehicle-inspector > div .icon { width: 14px; }
+.vehicle-inspector > small { color: var(--green); display: block; padding: 4px 0 9px; border-bottom: 1px solid var(--border); }
+.vehicle-inspector dl { display: grid; gap: 8px; padding: 10px 0; }
+.vehicle-inspector dl div { display: flex; justify-content: space-between; gap: 6px; }
+.vehicle-inspector dt { color: var(--muted); font-size: 9px; }
+.vehicle-inspector dd { text-align: right; color: #e0e8ee; font-size: 10px; }
+.vehicle-inspector hr { border: 0; border-top: 1px solid var(--border); }
+.vehicle-inspector p { display: flex; justify-content: space-between; gap: 4px; margin-top: 8px; font-size: 10px; }
+.vehicle-inspector p b { color: var(--muted); font-weight: 400; }
+.zoom-footer { padding: 20px 12px; text-align: center; background: #101925; border-top: 1px solid var(--border); }
+.zoom-footer h3 { font-size: 15px; }
+.zoom-footer p { color: var(--muted); margin-top: 8px; font-size: 11px; }
+
+.selection-workspace { min-height: calc(100vh - 68px); display: grid; grid-template-columns: 315px minmax(420px,1fr) 365px; }
+.smart-panel, .assign-panel, .presence-panel { padding: 18px; background: var(--surface-1); border-right: 1px solid var(--border); min-width: 0; }
+.smart-panel header, .assign-panel > header, .presence-panel > header, .queue-panel > header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 18px; border-bottom: 1px solid var(--border-soft); }
+.smart-panel header .button, .assign-panel header .button { min-height: 30px; min-width: 30px; width: 30px; }
+.form-label { color: #c7d1dd; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display: block; margin: 20px 0 9px; }
+.natural-query { padding: 13px; display: flex; gap: 9px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 5px; }
+.natural-query .icon { color: var(--cyan); width: 18px; }
+.natural-query p { color: #e3eaf0; font-size: 12px; line-height: 1.55; }
+.filter-heading { display: flex; justify-content: space-between; align-items: end; }
+.filter-heading .form-label { margin-bottom: 9px; }
+.filter-heading button, .subheading button { padding: 0; border: 0; background: none; color: var(--blue); cursor: pointer; font-size: 11px; }
+.filter-stack { display: grid; gap: 6px; }
+.filter-chip { min-height: 33px; display: grid; grid-template-columns: 1fr auto 24px; align-items: center; border: 1px solid var(--border); border-radius: 4px; background: rgba(25,34,47,.75); font-size: 11px; }
+.filter-chip > span { color: #cbd6df; text-transform: uppercase; font-size: 10px; padding-left: 10px; }
+.filter-chip > b { padding: 3px 8px; color: #72b0fd; background: rgba(35,123,237,.16); border-radius: 3px; font-weight: 500; }
+.filter-chip > button { width: 24px; height: 28px; display: grid; place-items: center; border: 0; border-left: 1px solid var(--border); background: transparent; color: var(--muted); cursor: pointer; }
+.filter-chip .icon { width: 13px; }
+.selection-result { padding: 22px 0 14px; }
+.selection-result > small, .selection-result > p { display: block; text-transform: uppercase; font-size: 10px; margin-bottom: 5px; }
+.selection-result > strong { display: block; color: var(--blue); font-size: 38px; line-height: 1; margin-bottom: 18px; }
+.selection-result > strong span { font-size: 14px; text-transform: uppercase; }
+.selection-result dl { display: grid; gap: 8px; }
+.selection-result dl div { display: grid; grid-template-columns: 1fr 94px; align-items: center; gap: 8px; }
+.selection-result dt { color: var(--muted); font-size: 9px; text-transform: uppercase; }
+.selection-result dd { color: #acbac7; font-family: var(--mono); font-size: 9px; }
+.selection-result dd i { width: 58px; height: 5px; display: inline-block; background: linear-gradient(to right, var(--blue) 42%, #263647 42%); vertical-align: middle; margin-right: 4px; }
+.updated { display: block; margin: 14px 0 0; color: var(--dim); }
+.selection-map-stage { min-width: 0; }
+.selection-map .selection-task { position: absolute; inset: 7% 16% 37% 32%; z-index: 2; }
+.selection-map .task-area { inset: 0; }
+.selection-map .task-area span { top: 33%; padding: 11px; background: rgba(37,23,60,.85); border: 1px solid var(--violet); border-radius: 7px; }
+.task-pin { position: absolute; left: 53%; top: 52%; width: 10px; height: 10px; background: var(--violet); border-radius: 50%; box-shadow: 0 0 0 4px rgba(154,115,216,.25); }
+.selection-map .vehicle-marker { color: #a8ecf8; }
+.selection-map .vehicle-marker > span { padding: 4px 6px; left: 22px; top: -5px; background: rgba(12,21,31,.86); border: 1px solid #3b596d; border-radius: 10px; font-size: 9px; }
+.floating-task-card { position: absolute; z-index: 9; left: 25%; bottom: 8%; width: 330px; padding: 16px; background: rgba(13,22,33,.96); border: 1px solid #3c4d63; box-shadow: var(--shadow); border-radius: 7px; }
+.floating-task-card h3 { text-transform: uppercase; color: #b994f3; font-size: 17px; }
+.floating-task-card h3 span { display: block; font-size: 14px; }
+.floating-task-card dl { display: grid; grid-template-columns: 1fr 1fr; gap: 0; margin-top: 16px; border: 1px solid var(--border); }
+.floating-task-card dl div { padding: 10px; display: grid; gap: 5px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
+.floating-task-card dt { color: var(--muted); font-size: 9px; }
+.floating-task-card dd { font-size: 11px; }
+.floating-task-card footer { display: flex; gap: 7px; align-items: center; padding-top: 12px; color: var(--muted); font-size: 10px; }
+.floating-task-card footer .icon { width: 15px; }
+.floating-task-card footer b { margin-left: auto; color: var(--text); font-size: 12px; }
+.assign-panel { border-left: 1px solid var(--border); border-right: 0; padding: 18px 20px; }
+.assign-panel header h1 { text-transform: uppercase; font-size: 17px; }
+.assign-panel header p { color: var(--blue); text-transform: uppercase; font-size: 11px; margin-top: 5px; }
+.task-meta { display: grid; gap: 12px; padding: 20px 0; }
+.task-meta div { display: grid; grid-template-columns: 55px 1fr; gap: 10px; }
+.task-meta dt, .assign-panel h2 { color: var(--muted); text-transform: uppercase; font-size: 10px; }
+.task-meta dd { font-size: 12px; }
+.assign-panel h2 { margin: 7px 0 12px; }
+.validation-list { border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
+.validation-row { min-height: 47px; display: grid; grid-template-columns: 21px 1fr minmax(86px,auto) 23px; gap: 8px; align-items: center; padding: 7px 11px; border-bottom: 1px solid var(--border-soft); }
+.validation-row:last-child { border-bottom: 0; }
+.validation-row > .icon { color: var(--muted); width: 17px; }
+.validation-row > span { font-size: 12px; }
+.validation-row > b { color: #d2dce5; font-size: 11px; font-weight: 500; text-align: right; }
+.round-check { width: 19px; height: 19px; display: grid; place-items: center; border-radius: 50%; background: var(--green); color: #0e2615; }
+.round-check .icon { width: 13px; height: 13px; stroke-width: 3; }
+.info-callout { display: flex; gap: 8px; margin-top: 13px; padding: 10px; border: 1px solid rgba(35,123,237,.46); background: rgba(35,123,237,.06); color: #a9c2df; border-radius: 5px; }
+.info-callout .icon { color: var(--blue); width: 17px; }
+.info-callout p { font-size: 11px; line-height: 1.4; }
+.assign-panel > footer { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; padding-top: 18px; }
+
+.collaboration-workspace { min-height: calc(100vh - 68px); display: grid; grid-template: 1fr auto / 270px minmax(430px, 1fr) 315px; }
+.group-rail.compact .group-card { min-height: 84px; }
+.group-rail.compact .group-card-main small:nth-of-type(2), .group-rail.compact .group-card-side { display: none; }
+.collab-map-stage { min-width: 0; position: relative; }
+.collaboration-map .task-area { inset: 35% 43% 22% 21%; }
+.collab-alpha-boundary { position: absolute; inset: 0; }
+.collab-alpha-boundary .vehicle-marker { transform: translate(-50%,-50%) scale(.8); }
+.group-marker { position: absolute; z-index: 7; display: flex; align-items: center; gap: 7px; border: 0; background: transparent; color: var(--text); padding: 0; cursor: pointer; text-align: left; }
+.marker-dot { width: 38px; height: 38px; display: grid; place-items: center; border: 2px solid currentColor; border-radius: 50%; background: rgba(7,15,24,.72); }
+.marker-dot b { font-size: 14px; }
+.marker-card { padding: 6px 8px; display: grid; gap: 2px; background: rgba(9,16,25,.86); border: 1px solid rgba(80,104,130,.55); border-radius: 4px; box-shadow: 0 3px 10px rgba(0,0,0,.2); }
+.marker-card strong { font-size: 12px; }
+.marker-card small { color: #c5d0da; font-size: 9px; white-space: nowrap; }
+.group-marker.cyan { color: var(--cyan); }.group-marker.blue { color: #4d93ff; }.group-marker.violet { color: #b18be8; }.group-marker.amber { color: var(--amber); }
+.group-marker.is-selected .marker-dot { box-shadow: 0 0 0 5px rgba(42,185,214,.15); }
+.operator-cursor { position: absolute; z-index: 12; display: grid; justify-items: center; gap: 2px; color: var(--cyan); font-size: 10px; }
+.operator-cursor i { font-size: 27px; line-height: .75; font-style: normal; text-shadow: 0 1px 4px #000; }
+.operator-cursor b { padding: 3px 5px; background: rgba(10,20,29,.9); border: 1px solid currentColor; border-radius: 3px; text-transform: uppercase; font-size: 8px; }
+.operator-cursor.green { color: var(--green); }.operator-cursor.amber { color: #ef8e4c; }.operator-cursor.violet { color: var(--violet); }.operator-cursor.yellow { color: #eed16a; }
+.lease-card { position: absolute; z-index: 12; left: 4%; top: 43%; width: 170px; padding: 14px; background: rgba(13,23,34,.95); border: 1px solid #336a8f; box-shadow: var(--shadow); }
+.lease-card small { color: #72c8e4; text-transform: uppercase; font-size: 9px; }
+.lease-card h2 { margin: 7px 0 2px; font-size: 19px; }
+.lease-card p { color: var(--muted); font-size: 11px; }
+.lease-card p b { color: var(--cyan); }.lease-card hr { margin: 12px 0; border: 0; border-top: 1px solid var(--border); }.lease-card .status { margin: 8px 9px 0 0; }
+.presence-panel { border-left: 1px solid var(--border); border-right: 0; padding: 18px 13px; }
+.presence-panel h1 { color: var(--muted); text-transform: uppercase; letter-spacing: .05em; font-size: 12px; }
+.presence-list { border: 1px solid var(--border); margin-top: 12px; }
+.presence-row { width: 100%; min-height: 62px; display: grid; grid-template-columns: 10px 1fr 20px; align-items: center; gap: 9px; text-align: left; padding: 8px 11px; background: rgba(23,31,43,.64); border: 0; border-bottom: 1px solid var(--border-soft); cursor: pointer; }
+.presence-row:hover { background: var(--surface-3); }
+.presence-row span { display: grid; gap: 3px; }.presence-row strong { text-transform: uppercase; font-size: 13px; }.presence-row b { color: var(--muted); font-size: 18px; }
+.subheading { margin-top: 24px; padding: 0 0 9px !important; border: 0 !important; }.subheading h2 { color: var(--muted); text-transform: uppercase; letter-spacing: .05em; font-size: 11px; }
+.collab-stream { border: 1px solid var(--border); }
+.collab-stream p { min-height: 46px; display: grid; grid-template-columns: 8px 1fr auto; align-items: center; gap: 7px; padding: 8px; border-bottom: 1px solid var(--border-soft); color: #c6d2dc; font-size: 10px; }
+.collab-stream small { color: var(--dim); font-size: 9px; }
+.mission-events { grid-column: 1 / -1; display: grid; grid-template-columns: 140px 1fr; gap: 10px; padding: 12px 18px; border-top: 1px solid var(--border); background: #101822; }
+.mission-events > strong { color: var(--muted); text-transform: uppercase; font-size: 10px; letter-spacing: .05em; }
+.mission-events > div { display: flex; gap: 20px; overflow: auto; }
+.mission-events span { min-width: max-content; color: #b7c4cf; font-size: 10px; }.mission-events .presence-dot { margin-right: 3px; }.mission-events p { grid-column: 1 / -1; color: var(--muted); text-align: center; margin-top: 6px; font-size: 11px; }
+
+.recovery-workspace { min-height: calc(100vh - 68px); display: grid; grid-template: minmax(500px, 1fr) auto / 290px minmax(430px, 1fr) minmax(500px, 1.08fr); }
+.recovery-left { padding: 15px 10px; background: var(--surface-1); border-right: 1px solid var(--border); }
+.recovery-left > header { padding: 10px 6px 14px; border-bottom: 1px solid var(--border); }.recovery-left header h1, .recovery-right > header h1 { font-size: 15px; text-transform: uppercase; }
+.recovery-alert { margin-top: 14px; padding: 20px 14px; display: flex; gap: 13px; border: 1px solid; border-radius: 6px; }
+.recovery-alert.critical { border-color: rgba(237,83,87,.55); background: rgba(104,24,31,.4); }.recovery-alert.critical .icon { color: var(--red); }
+.recovery-alert.amber { border-color: rgba(240,181,68,.52); background: rgba(97,74,19,.34); }.recovery-alert.amber .icon { color: var(--amber); }
+.recovery-alert > .icon { width: 26px; height: 26px; margin-top: 4px; }.recovery-alert div { display: grid; gap: 6px; }.recovery-alert h2 { font-size: 14px; }.recovery-alert p { font-size: 12px; }.recovery-alert b { margin-top: 7px; width: max-content; padding: 5px 9px; font-size: 10px; font-weight: 500; border-radius: 3px; }.recovery-alert.critical b { background: rgba(237,83,87,.14); color: #fb9fa2; }.recovery-alert.amber b { background: rgba(240,181,68,.16); color: #f1c66d; }
+.fleet-summary { margin: auto 3px 0; margin-top: 90px; padding: 20px; border: 1px solid var(--border); background: rgba(25,35,42,.8); text-align: center; }.fleet-summary h2 { color: #cdd7df; text-transform: uppercase; font-size: 12px; text-align: left; }.fleet-summary > strong { display: block; margin: 26px 0 2px; color: var(--green); font-size: 49px; line-height: .85; }.fleet-summary > p { font-size: 13px; }.fleet-summary > small { display: block; margin: 20px 0; }.fleet-summary .button { width: 100%; }
+.recovery-map-stage { min-width: 0; }.recovery-map .task-area { inset: 20% 22% 28% 10%; }.recovery-map .vehicle-marker { transform: translate(-50%,-50%) scale(1.08); color: #79e394; }.recovery-map .route-line path { stroke: rgba(100,190,174,.65); }.failed-marker { position: absolute; z-index: 8; transform: translate(-50%,-50%); display: grid; grid-template-columns: 28px 1fr; column-gap: 7px; align-items: center; padding: 6px; color: #f77175; text-align: left; background: rgba(29,15,21,.9); border: 1px solid rgba(237,83,87,.62); border-radius: 20px; cursor: pointer; }.failed-marker span { grid-row: span 2; width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-weight: 700; }.failed-marker b { font-size: 11px; }.failed-marker small { color: #f47479; font-size: 9px; }
+.recovery-task-map-card { position: absolute; z-index: 8; left: 15%; bottom: 11%; width: min(480px, 62%); display: flex; align-items: center; gap: 15px; padding: 19px; background: rgba(14,25,36,.96); border: 1px solid #405166; border-radius: 8px; box-shadow: var(--shadow); }.recovery-task-map-card > .icon { width: 36px; height: 36px; color: var(--blue); }.recovery-task-map-card div { display: grid; gap: 5px; }.recovery-task-map-card h2 { font-size: 18px; }.recovery-task-map-card p { color: var(--muted); font-size: 12px; }.recovery-task-map-card > b { margin-left: auto; display: grid; text-align: right; font-size: 24px; }.recovery-task-map-card > b small { font-size: 10px; }
+.recovery-right { background: var(--surface-1); border-left: 1px solid var(--border); padding: 18px; min-width: 0; }.recovery-right > header { padding: 2px 0 17px; }.recovery-right > header h1 { display: flex; align-items: center; gap: 9px; color: #d9e4ec; }.recovery-right > header .icon { color: var(--blue); width: 29px; height: 29px; }
+.current-state, .recommendations, .local-preview { border: 1px solid var(--border); background: rgba(24,34,46,.64); padding: 14px; margin-bottom: 10px; border-radius: 5px; }.current-state h2, .recommendations h2, .local-preview h2 { color: #c5d1dc; text-transform: uppercase; font-size: 11px; font-weight: 600; }.current-state dl { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 14px; }.current-state dl div { min-width: 0; display: grid; gap: 5px; padding-right: 7px; border-right: 1px solid var(--border-soft); }.current-state dt { color: var(--muted); font-size: 10px; }.current-state dd { font-size: 15px; }.current-state dd small { display: block; }
+.recommendations > p { display: flex; gap: 6px; align-items: center; margin: 11px 0; color: var(--muted); font-size: 10px; }.recommendations > p .icon { width: 15px; }.candidate-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }.candidate-card { min-width: 0; padding: 10px; background: rgba(14,23,34,.84); color: #e2eaf0; border: 1px solid #354355; border-radius: 5px; text-align: left; cursor: pointer; }.candidate-card:hover { border-color: #6887a8; }.candidate-card.selected { border-color: var(--cyan); box-shadow: inset 0 0 0 1px var(--cyan), 0 0 15px rgba(42,185,214,.09); }.candidate-card header { display: flex; gap: 8px; align-items: center; }.candidate-card header > .icon { color: #c3d0da; width: 24px; height: 24px; }.candidate-card header span { display: grid; gap: 3px; }.candidate-card header strong { font-size: 12px; }.candidate-card .status { font-size: 9px; }.candidate-card header .round-check { margin-left: auto; }.candidate-card dl { display: grid; gap: 8px; margin-top: 13px; }.candidate-card dl div { display: flex; justify-content: space-between; gap: 5px; padding-bottom: 5px; border-bottom: 1px solid rgba(73,91,111,.32); }.candidate-card dt { color: var(--muted); font-size: 9px; }.candidate-card dd { font-size: 10px; }.candidate-card dd .status { font-size: 0; }.candidate-card dd .status-dot { display: inline-block; margin-left: 3px; }.candidate-card footer { margin: 10px -10px -10px; padding: 6px; border-top: 1px solid var(--cyan); color: var(--cyan); text-align: center; text-transform: uppercase; font-size: 9px; }
+.recommendation-note { margin-bottom: 0 !important; }.local-preview div { position: relative; height: 70px; margin-top: 8px; display: flex; align-items: center; gap: 12px; color: var(--green); }.local-preview div .icon { width: 25px; height: 25px; }.local-preview div > span { display: flex; align-items: center; gap: 5px; color: #dbe4ea; font-size: 11px; }.local-preview div i { flex: 1; height: 0; border-top: 2px dashed var(--cyan); }.local-preview div b { padding: 15px; color: #5bcff0; font-size: 11px; border: 1px dashed var(--cyan); }.recovery-right > footer { display: grid; grid-template-columns: 1fr 1.3fr 1.7fr; gap: 8px; }.recovery-events { grid-column: 1 / -1; min-height: 72px; display: flex; align-items: center; gap: 30px; padding: 12px 24px; background: #101923; border-top: 1px solid var(--border); overflow: auto; }.recovery-events span { min-width: max-content; display: flex; align-items: center; gap: 8px; color: #b8c4ce; font-size: 11px; }.recovery-events .icon { color: var(--amber); }.recovery-events span:first-child .icon { color: var(--red); }
+
+.queue-workspace { min-height: calc(100vh - 68px); display: grid; grid-template-rows: 55px 1fr 82px; }.queue-topline { display: flex; align-items: center; gap: 0; padding: 0 18px; border-bottom: 1px solid var(--border); background: #0e151f; }.queue-topline > div { padding: 0 34px; display: grid; gap: 3px; border-right: 1px solid var(--border-soft); }.queue-topline > div:last-child { margin-left: auto; }.queue-topline span { color: var(--muted); text-transform: uppercase; font-size: 9px; }.queue-topline b { font-size: 12px; text-transform: uppercase; }.queue-body { min-height: 0; display: grid; grid-template-columns: minmax(360px, 1.06fr) minmax(540px, 1fr); gap: 12px; padding: 12px; background: #0b1119; }.queue-map-stage { min-height: 0; }.queue-map { border: 1px solid var(--border-soft); border-radius: 6px; }.queue-map .task-area { inset: 25% 15% 42% 42%; }.queue-map .task-area.queue-search { inset: 29% 17% 45% 38%; }.queue-map .task-area.queue-search.future { inset: 58% 11% 18% 57%; }.queue-map .task-area.queue-search.future .task-shape { background: rgba(119,75,166,.18); }.queue-map .task-area.queue-search.future::after { border-color: var(--violet); }.queue-map .task-area.queue-search.future span { color: #c499f1; }.base-service { position: absolute; left: 28%; bottom: 21%; display: grid; justify-items: center; color: var(--green); font-size: 27px; }.base-service span { color: #d8e3e5; font-size: 11px; text-align: center; }.queue-group-label { position: absolute; left: 52%; top: 40%; display: grid; justify-items: center; color: #e3edf2; }.queue-group-label .group-badge { margin-bottom: 6px; }.queue-group-label strong { font-size: 13px; }.queue-group-label small { color: #d5dee5; }.queue-group-card { position: absolute; left: 18px; bottom: 18px; width: 185px; padding: 16px; border: 1px solid #40526a; background: rgba(12,23,34,.94); border-radius: 6px; }.queue-group-card .group-badge { float: left; margin-right: 12px; }.queue-group-card h2 { padding-top: 6px; text-transform: uppercase; font-size: 17px; }.queue-group-card p { clear: both; display: flex; gap: 7px; align-items: center; padding-top: 13px; color: #d6e0e5; font-size: 11px; }.queue-group-card p .icon { width: 15px; color: #b9c9d5; }.queue-group-card .status { margin-top: 13px; }
+.queue-panel { min-width: 0; border: 1px solid var(--border); background: var(--surface-1); border-radius: 6px; display: grid; grid-template-rows: 54px 1fr 65px; overflow: hidden; }.queue-panel > header { padding: 0 14px; }.queue-panel > header h1 { text-transform: uppercase; font-size: 17px; }.queue-panel > header h1 span { color: var(--blue); }.queue-panel > header > div { display: flex; align-items: center; gap: 8px; }.queue-panel > header .button { min-height: 30px; width: 30px; min-width: 30px; }.queue-content { min-height: 0; display: grid; grid-template-columns: minmax(300px, 1fr) 270px; }.step-list { padding: 10px; overflow: auto; border-right: 1px solid var(--border); }.queue-step { min-height: 74px; display: grid; grid-template-columns: 18px 31px 1fr 77px 50px; align-items: center; gap: 9px; padding: 8px; margin-bottom: 7px; border: 1px solid #303e50; border-radius: 5px; background: rgba(30,41,55,.74); }.queue-step.current { border-color: var(--blue); box-shadow: inset 0 0 0 1px rgba(42,185,214,.75); }.queue-step.completed { opacity: .8; }.drag-handle { border: 0; background: none; color: var(--muted); cursor: grab; font-size: 18px; padding: 0; }.step-number { width: 28px; height: 28px; display: grid; place-items: center; background: #1d2b3c; border: 1px solid #405064; border-radius: 4px; font-weight: 600; }.queue-step > div:nth-of-type(1) { display: grid; gap: 3px; }.queue-step h2 { font-size: 13px; }.queue-step p { color: var(--muted); font-size: 10px; }.queue-step .progress { margin: 2px 0 0; width: 85px; }.queue-step > div small { color: var(--blue); text-transform: uppercase; font-size: 9px; }.queue-step > aside { display: grid; gap: 2px; justify-items: end; }.queue-step > aside b { font-size: 10px; }.queue-step > aside small { font-size: 9px; }.queue-step > aside .status { font-size: 8px; }.step-arrows { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; }.step-arrows .button { min-height: 26px; min-width: 22px; width: 22px; padding: 0; }.step-arrows .button:first-child .icon { transform: rotate(180deg); }.step-arrows .icon { width: 13px; }.drop-zone { min-height: 48px; display: grid; place-items: center; color: var(--violet); border: 1px dashed rgba(154,115,216,.65); background: rgba(99,63,140,.09); font-size: 11px; }.step-inspector { padding: 15px; background: #101824; overflow: auto; }.step-inspector > header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--border); }.step-inspector > header b, .step-inspector h2 { color: #a9bad0; text-transform: uppercase; font-size: 10px; }.step-inspector > header .button { min-height: 24px; min-width: 24px; width: 24px; }.step-inspector h2 { margin-top: 18px; }.step-inspector p { display: flex; gap: 7px; align-items: center; margin-top: 6px; color: #e0e7eb; font-size: 11px; line-height: 1.5; }.step-inspector p .icon { width: 15px; color: var(--muted); }.queue-actions { display: grid; grid-template-columns: 1fr 1fr 1.3fr 1.3fr; gap: 7px; padding: 10px; border-top: 1px solid var(--border); }.queue-actions .button { text-transform: uppercase; }.queue-events { display: flex; align-items: center; gap: 32px; padding: 12px 24px; border-top: 1px solid var(--border); background: #121b26; color: #b3c0ca; font-size: 11px; }.queue-events strong { color: #c6d0d9; text-transform: uppercase; font-size: 10px; }.queue-events b { color: var(--blue); }
+
+.system-view { max-width: 1600px; margin: 0 auto; padding: 24px; }.system-title { margin-bottom: 12px; }.system-title h1 { font-size: 28px; text-transform: uppercase; }.system-title p { margin-top: 4px; color: var(--muted); }.system-title p span { color: var(--blue); }.system-grid { display: grid; grid-template-columns: 1.1fr .9fr 1.55fr; grid-template-rows: auto auto; border: 1px solid var(--border); }.system-card { min-width: 0; padding: 16px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--surface-1); }.system-card h2 { margin-bottom: 15px; color: #e2e9ee; text-transform: uppercase; font-size: 12px; }.system-card > p { color: #c4d0d8; font-size: 11px; }.colors { grid-row: span 2; }.swatches { display: grid; grid-template-columns: repeat(5, 1fr); gap: 9px; margin: 11px 0 22px; }.swatches.semantic { grid-template-columns: repeat(6, 1fr); }.swatch { display: grid; gap: 5px; color: var(--muted); font-size: 9px; }.swatch i { height: 43px; border: 1px solid #364358; border-radius: 3px; background: var(--surface-0); }.swatch i.s0 { background: var(--surface-0); }.swatch i.s1 { background: var(--surface-1); }.swatch i.s2 { background: var(--surface-2); }.swatch i.s3 { background: var(--surface-3); }.swatch i.border { background: var(--border); }.swatch i.blue { background: var(--blue); }.swatch i.cyan { background: var(--cyan); }.swatch i.green { background: var(--green); }.swatch i.amber { background: var(--amber); }.swatch i.red { background: var(--red); }.swatch i.violet { background: var(--violet); }.swatch b { font-size: 10px; color: #d3dde5; }.colors ul { padding-left: 18px; color: var(--muted); font-size: 10px; line-height: 1.65; }.type { min-height: 275px; }.type h1 { display: flex; gap: 10px; align-items: baseline; font-size: 25px; margin: 17px 0; }.type h1 b { font-size: 20px; }.type h2 { display: flex; gap: 10px; font-size: 19px; }.type h2 b { font-size: 16px; }.type > p { display: flex; gap: 11px; margin: 25px 0; }.type > small { display: block; }.type pre { color: #d4e0e6; font-family: var(--mono); font-size: 10px; }.components { min-height: 275px; }.component-columns { display: grid; grid-template-columns: 1.1fr 1fr .9fr; gap: 15px; }.component-columns section { display: grid; align-content: start; gap: 7px; }.component-columns p { color: var(--muted); font-size: 10px; }.component-columns .button { min-height: 34px; justify-content: flex-start; font-size: 10px; }.chip-wrap { display: flex; flex-wrap: wrap; gap: 8px; }.authority-demo { padding: 8px; display: flex; gap: 8px; align-items: center; border: 1px solid var(--blue); color: var(--cyan); border-radius: 4px; font-size: 10px; text-transform: uppercase; }.authority-demo.muted { border-color: var(--border); color: var(--muted); }.mini-components { display: grid; grid-template-columns: 1fr 1fr 1.3fr; gap: 10px; margin-top: 18px; }.mini-components > article { min-height: 90px; padding: 10px; border: 1px solid var(--border); border-radius: 4px; font-size: 10px; }.mini-components > article > b { display: inline-block; margin-right: 6px; }.mini-components p { margin-top: 6px; color: var(--muted); }.mini-alert { display: flex; gap: 9px; align-items: center; background: rgba(91,24,31,.45); border-color: rgba(237,83,87,.47) !important; }.mini-alert .icon { color: var(--red); }.mini-alert span { display: grid; gap: 6px; }.interactions { grid-column: span 2; }.multi-select-demo { border: 1px solid var(--border); }.select-map { position: relative; height: 166px; overflow: hidden; background: #0b1723; }.select-map .vehicle-marker { transform: translate(-50%,-50%) scale(.7); }.select-map > i { position: absolute; left: 21%; top: 15%; right: 21%; bottom: 15%; border: 1px solid var(--cyan); background: rgba(42,185,214,.08); }.multi-summary { display: grid; grid-template-columns: 31px 1fr repeat(3, .5fr); gap: 8px; align-items: center; padding: 13px; }.multi-summary > .icon { color: #092438; background: var(--cyan); border-radius: 3px; padding: 4px; width: 29px; height: 29px; }.multi-summary strong { text-transform: uppercase; font-size: 15px; }.multi-summary > span { display: grid; gap: 2px; color: var(--muted); text-transform: uppercase; font-size: 9px; }.multi-summary > span b { color: var(--blue); font-size: 16px; }.multi-summary .button { min-height: 37px; grid-row: 2; }.state-cards { grid-row: span 1; }.selection-state-card { min-height: 58px; display: grid; grid-template-columns: 35px 1fr auto; gap: 7px; align-items: center; padding: 8px; margin-bottom: 7px; border: 1px solid var(--border); border-radius: 4px; }.selection-state-card.selected { border-color: var(--cyan); }.selection-state-card .group-badge { width: 27px; height: 27px; font-size: 10px; }.selection-state-card b { font-size: 11px; }.selection-state-card small { grid-column: 2 / 4; }.selection-state-card.muted { opacity: .65; }.responsive { grid-column: 2 / -1; }.responsive-samples { display: grid; grid-template-columns: repeat(4,1fr); gap: 9px; margin: 12px 0 20px; }.responsive-samples div { display: grid; gap: 5px; color: var(--muted); font-size: 9px; }.responsive-samples i { display: block; height: 51px; border: 1px solid #46566b; background: linear-gradient(90deg, #1d2b34 0 18%, #09202d 18% 77%, #1d2536 77%); }.responsive-samples i.laptop { width: 76px; }.responsive-samples i.monitor { width: 94px; }.responsive-samples i.ultrawide { width: 120px; }.responsive-samples i.wall { width: 144px; }.accessibility { display: grid; grid-template-columns: repeat(4,1fr); gap: 9px; }.accessibility p { padding: 8px; border: 1px solid var(--border); color: var(--muted); font-size: 9px; }.accessibility p b { color: #fff; font-size: 15px; }.focus-example { color: #fff !important; box-shadow: inset 0 0 0 1px var(--cyan); }.focus-example kbd { float: right; padding: 2px 4px; border: 1px solid var(--border); color: var(--muted); }
+
+.multi-summary { grid-template-columns: 31px 1fr repeat(3, 1fr); }
+.multi-summary .button:nth-of-type(1) { grid-column: 1 / 3; }
+.multi-summary .button:nth-of-type(2) { grid-column: 3 / 5; }
+.multi-summary .button:nth-of-type(3) { grid-column: 5 / 6; }
+
+.modal-backdrop { position: fixed; z-index: 1000; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(2,6,10,.68); backdrop-filter: blur(3px); }.modal-dialog { width: min(560px, 100%); padding: 22px; background: #141d29; border: 1px solid #405169; box-shadow: 0 28px 70px rgba(0,0,0,.6); border-radius: 8px; }.modal-dialog > header { display: flex; align-items: start; justify-content: space-between; padding-bottom: 16px; border-bottom: 1px solid var(--border); }.modal-dialog > header small { color: var(--cyan); text-transform: uppercase; }.modal-dialog > header h1 { margin-top: 3px; font-size: 21px; }.modal-dialog > header .button { min-height: 30px; min-width: 30px; width: 30px; }.preview-lede { margin: 18px 0; color: #c4d1da; }.preview-route { min-height: 86px; display: grid; grid-template-columns: 1fr 38px 1fr; align-items: center; padding: 13px; background: #0d1721; border: 1px solid var(--border); }.preview-route div { display: grid; gap: 6px; justify-items: center; color: var(--muted); font-size: 11px; }.preview-route div i { width: 20px; height: 20px; border-radius: 50%; background: var(--blue); }.preview-route div.target i { background: var(--violet); }.preview-route > span { color: var(--cyan); text-align: center; }.preview-validation { margin-top: 16px; border: 1px solid var(--border); }.modal-dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }.transfer-authority { display: flex; gap: 11px; align-items: center; padding: 17px 0; }.transfer-authority p { margin-top: 4px; color: var(--muted); font-size: 12px; }.transfer-dialog label { display: grid; gap: 8px; color: var(--muted); font-size: 11px; text-transform: uppercase; }.transfer-dialog textarea { min-height: 65px; padding: 10px; resize: none; color: #dbe4e8; background: #0b131c; border: 1px solid var(--border); border-radius: 4px; }.notice { display: flex; gap: 7px; margin-top: 15px; color: #aebed0; font-size: 12px; line-height: 1.45; }.notice .icon { color: var(--blue); }.help-dialog p { margin-top: 18px; color: #c4d0d8; line-height: 1.65; }
+.toast { position: fixed; z-index: 1200; right: 20px; bottom: 20px; max-width: 430px; padding: 12px 11px 12px 14px; display: flex; align-items: center; gap: 10px; color: #dce8e9; background: #14232c; border: 1px solid #3b8291; box-shadow: var(--shadow); border-radius: 6px; font-size: 12px; }.toast > .icon { color: var(--green); }.toast button { margin-left: 5px; padding: 2px; display: grid; border: 0; background: transparent; color: var(--muted); cursor: pointer; }.toast button .icon { width: 15px; }
+
+@media (min-width: 1900px) {
+ .overview-workspace { grid-template-columns: 280px minmax(900px, 1fr) 280px; }
+ .group-card { min-height: 102px; }
+ .attention-card { padding: 16px 12px; }
+}
+@media (max-width: 1320px) {
+ .top-metrics > div:nth-child(1), .top-metrics > div:nth-child(2), .top-metrics > div:nth-child(4) { display: none; }
+ .overview-workspace { grid-template-columns: 215px minmax(400px, 1fr) 215px; }
+ .group-card { grid-template-columns: 32px 1fr; min-height: 82px; }.group-card-side { display: none; }
+ .context-summary dl { grid-template-columns: repeat(4, minmax(72px,1fr)); }.context-summary dl .wide-only { display: none; }
+ .selection-workspace { grid-template-columns: 270px minmax(360px,1fr) 320px; }.smart-panel, .assign-panel { padding: 14px; }
+ .recovery-workspace { grid-template-columns: 240px minmax(380px, 1fr) minmax(410px,.95fr); }.candidate-card { padding: 8px; }.candidate-grid { gap: 6px; }
+ .collaboration-workspace { grid-template-columns: 225px minmax(400px,1fr) 275px; }
+}
+@media (max-width: 1050px) {
+ .topbar { gap: 7px; }.view-nav { margin: 0; }.nav-item { padding: 0 7px; }.nav-item span { display: none; }.top-metrics > div:not(.operator-avatar) { display: none; }.operator-avatar { min-width: auto; }.operator-avatar div { display: none; }
+ .overview-workspace { grid-template: minmax(520px,1fr) auto / 205px minmax(390px,1fr); }.attention-rail { display: none; }.context-bar { grid-column: 2; }.context-summary dl { grid-template-columns: repeat(3, minmax(80px,1fr)); }.context-actions { grid-template-columns: repeat(4,1fr); }
+ .selection-workspace { grid-template-columns: 250px minmax(350px,1fr); }.assign-panel { display: none; }
+ .collaboration-workspace { grid-template-columns: 210px minmax(420px,1fr); }.presence-panel { display: none; }.mission-events { grid-column: 1 / -1; }
+ .recovery-workspace { grid-template: minmax(470px,1fr) auto / 210px minmax(420px,1fr); }.recovery-right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--border); }.fleet-summary { margin-top: 40px; }.recovery-events { display: none; }
+ .queue-body { grid-template-columns: 1fr; }.queue-map-stage { min-height: 330px; }.queue-workspace { grid-template-rows: 55px auto 80px; }.queue-panel { min-height: 480px; }
+ .system-grid { grid-template-columns: 1fr 1fr; }.colors { grid-row: auto; }.components, .responsive { grid-column: span 2; }.interactions { grid-column: span 1; }
+}
+@media (max-width: 720px) {
+ .topbar { height: 56px; padding: 0 8px; }.brand > span:not(.brand-mark), .brand-divider, .view-nav { display: none; }.top-metrics { margin-left: auto; }.operator-avatar > span { width: 28px; height: 28px; }
+ .workspace, .overview-workspace { min-height: calc(100vh - 56px); }.overview-workspace { grid-template: minmax(420px,1fr) auto / 1fr; }.group-rail { display: none; }.context-bar { grid-column: 1; }.context-summary { padding: 8px 10px; }.context-summary dl { grid-template-columns: repeat(2,1fr); gap: 8px; }.context-summary dl div:nth-child(n+3) { display: none; }.context-actions { padding: 7px 10px; grid-template-columns: repeat(3,1fr); }.context-actions .button { min-height: 42px; padding: 0 7px; font-size: 10px; }
+ .view-intro { padding: 17px 14px; }.view-intro h1 { font-size: 18px; }.view-intro h1 span { display: none; }.view-intro p { font-size: 11px; }.legend { display: none; }.zoom-grid { grid-template-columns: 1fr; padding: 10px; }.zoom-panel { min-height: 470px; grid-template-rows: 48px 1fr 74px; }
+ .selection-workspace { display: block; }.smart-panel { border-right: 0; }.selection-map-stage { height: 470px; }.floating-task-card { left: 8%; bottom: 8%; width: 84%; }.selection-map .map-controls { display: none; }
+ .collaboration-workspace { display: block; }.collaboration-workspace .group-rail { display: block; }.collab-map-stage { height: 510px; }.mission-events { display: block; }.mission-events > div { margin-top: 10px; }.mission-events p { margin-top: 9px; text-align: left; }
+ .recovery-workspace { display: block; }.recovery-left { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.recovery-left > header { grid-column: 1 / -1; }.recovery-alert { margin-top: 0; padding: 12px; }.fleet-summary { display: none; }.recovery-map-stage { height: 430px; }.recovery-right { padding: 12px; }.current-state dl { grid-template-columns: 1fr 1fr; }.candidate-grid { grid-template-columns: 1fr; }.candidate-card dl { grid-template-columns: 1fr 1fr; }
+ .queue-topline { gap: 8px; overflow: auto; padding: 0 10px; }.queue-topline > div { min-width: max-content; padding: 0 10px; }.queue-topline > div:nth-child(n+4) { display: none; }.queue-body { padding: 7px; }.queue-map-stage { min-height: 290px; }.queue-content { grid-template-columns: 1fr; }.step-inspector { display: none; }.queue-panel { grid-template-rows: 54px 1fr 112px; }.queue-actions { grid-template-columns: 1fr 1fr; }.queue-events { display: grid; gap: 6px; padding: 10px; }
+ .system-view { padding: 13px; }.system-grid { display: block; }.system-card { border-right: 0; }.components, .responsive { min-height: auto; }.component-columns, .mini-components { grid-template-columns: 1fr; }.multi-summary { grid-template-columns: 31px 1fr 1fr 1fr; }.multi-summary .button { grid-column: span 2; }.responsive-samples { overflow: auto; grid-template-columns: repeat(4,92px); }.accessibility { grid-template-columns: 1fr 1fr; }
+}
diff --git a/ground-suite/.gitignore b/ground-suite/.gitignore
index 3820a95..f2c2be9 100644
--- a/ground-suite/.gitignore
+++ b/ground-suite/.gitignore
@@ -1,45 +1,7 @@
-# Miscellaneous
-*.class
-*.log
-*.pyc
-*.swp
-.DS_Store
-.atom/
-.build/
-.buildlog/
-.history
-.svn/
-.swiftpm/
-migrate_working_dir/
-
-# IntelliJ related
-*.iml
-*.ipr
-*.iws
-.idea/
-
-# The .vscode folder contains launch configuration and tasks you configure in
-# VS Code which you may wish to be included in version control, so this line
-# is commented out by default.
-#.vscode/
-
-# Flutter/Dart/Pub related
-**/doc/api/
-**/ios/Flutter/.last_build_id
-.dart_tool/
-.flutter-plugins-dependencies
-.pub-cache/
-.pub/
-/build/
-/coverage/
-
-# Symbolication related
-app.*.symbols
-
-# Obfuscation related
-app.*.map.json
-
-# Android Studio will place build artifacts here
-/android/app/debug
-/android/app/profile
-/android/app/release
+node_modules/
+dist/
+coverage/
+.vite/
+playwright-report/
+test-results/
+*.tsbuildinfo
diff --git a/ground-suite/.metadata b/ground-suite/.metadata
deleted file mode 100644
index 2215ee3..0000000
--- a/ground-suite/.metadata
+++ /dev/null
@@ -1,30 +0,0 @@
-# This file tracks properties of this Flutter project.
-# Used by Flutter tool to assess capabilities and perform upgrades etc.
-#
-# This file should be version controlled and should not be manually edited.
-
-version:
- revision: "nixpkgs000000000000000000000000000000000"
- channel: "stable"
-
-project_type: app
-
-# Tracks metadata for the flutter migrate command
-migration:
- platforms:
- - platform: root
- create_revision: nixpkgs000000000000000000000000000000000
- base_revision: nixpkgs000000000000000000000000000000000
- - platform: linux
- create_revision: nixpkgs000000000000000000000000000000000
- base_revision: nixpkgs000000000000000000000000000000000
-
- # User provided section
-
- # List of Local paths (relative to this file) that should be
- # ignored by the migrate tool.
- #
- # Files that are not part of the templates will be ignored by default.
- unmanaged_files:
- - 'lib/main.dart'
- - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/ground-suite/README.md b/ground-suite/README.md
index e7c8545..59778b5 100644
--- a/ground-suite/README.md
+++ b/ground-suite/README.md
@@ -1,24 +1,78 @@
-# Ground Suite
+# Kaptan Ground Suite
-Ground Suite is Kaptan's desktop ground-control application.
+Kaptan Ground Suite is a map-first web workspace for coordinating missions,
+tasks, groups, roles, and approximately 50 robotic vehicles. This first release
+is deliberately backed by a deterministic simulation. It does **not** connect
+to Kaptan firmware and does not send commands to real vehicles.
-## Development
+## Run and verify
From the repository root:
```sh
-nix develop -c flutter analyze ground-suite
-nix develop -c dart format --set-exit-if-changed ground-suite
-nix develop -c sh -c 'cd ground-suite && flutter test'
-nix develop -c sh -c 'cd ground-suite && flutter run -d linux'
+nix run .#ground-suite
+nix run .#ground-suite -- test
+nix flake check
+nix develop .#ground-suite
```
-## Layout
+The server binds to `127.0.0.1:4173` by default, prints its URL, and prints a
+simulated-data warning. Override only when intentionally exposing the local
+demo:
-- `lib/app/` — application shell and appearance.
-- `lib/features/fleet/` — fleet canvas and local-unit workspace.
-- `lib/features/vehicle_workspace/` — installed vehicle definitions and Add-unit flow.
-- `lib/features/diagnostics/` — diagnostic event stream.
-- `lib/core/` — transport and MAVLink decoding boundaries.
+```sh
+KAPTAN_GROUND_SUITE_HOST=127.0.0.1 KAPTAN_GROUND_SUITE_PORT=4173 nix run .#ground-suite
+```
-The durable design and integration notes live in [the documentation](../docs/src/ground-suite.md).
+Inside the development shell:
+
+```sh
+cd ground-suite
+npm ci
+npm run dev
+npm test
+npm run test:e2e
+npm run check
+```
+
+The application has no remote runtime dependency: the demonstration map,
+catalog imagery, fonts, simulation, and overlays are local. Catalog imagery is
+covered by `public/assets/vehicle_catalog/LICENSE.md` (CC0 1.0).
+
+## Operating model
+
+The map is one continuously mounted operations scene. Fleet, queue, events,
+deployment, commissioning, recovery, layers, and settings are drawers, trays,
+or overlays; they never replace or remount the map. Strategic, operational,
+tactical, and engineering detail is driven by the same camera and selection.
+
+Selection, operator-local control groups, authoritative mission groups, and
+authority leases are separate. Every consequential intent remains a local
+spatial draft until its immutable targets, authority, energy, link, freshness,
+constraints, and source status have been reviewed. The fake adapter is the only
+mutation-capable source and is labelled as simulation throughout the scene.
+
+The typed interaction controller and its mode contract live in
+`src/interaction/controller.ts`. It owns selection, lasso/marquee, pan, command
+intent, formation, annotation, task/safety geometry, measurement, mission
+points, editing, erasing, and draft undo/redo. The benchmark and composition
+decisions are recorded in `docs/DESIGN_RESET.md`.
+
+Primary code boundaries:
+
+- `src/core/data-plane/`: transport-neutral source, envelope validation,
+ ordering/deduplication, journal, reducers, preview, and command submission.
+- `src/adapters/fake/`: scenario-owned deterministic clock and 50-vehicle
+ source; no UI component imports it.
+- `src/domain/` and `src/projections/`: authoritative types and derived
+ selectors.
+- `src/core/persistence/`: versioned local operator preferences only.
+- `src/adapters/kaptan/` and `src/adapters/cerebrum/`: explicit future ports;
+ neither claims a live integration.
+- `src/app/` and `src/design-system/`: persistent spatial scene, coordinate map,
+ docked workbenches, charts, SVG iconography, and the supplied graphite/navy
+ visual language.
+- `tests/e2e/visual-compositions.spec.ts`: eight deterministic high-fidelity
+ states rendered at 1366×768, 1920×1080, and 3440×1440.
+
+See `docs/` for architecture, safety, source behavior, and issue traceability.
diff --git a/ground-suite/analysis_options.yaml b/ground-suite/analysis_options.yaml
deleted file mode 100644
index 0d29021..0000000
--- a/ground-suite/analysis_options.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-# This file configures the analyzer, which statically analyzes Dart code to
-# check for errors, warnings, and lints.
-#
-# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
-# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
-# invoked from the command line by running `flutter analyze`.
-
-# The following line activates a set of recommended lints for Flutter apps,
-# packages, and plugins designed to encourage good coding practices.
-include: package:flutter_lints/flutter.yaml
-
-linter:
- # The lint rules applied to this project can be customized in the
- # section below to disable rules from the `package:flutter_lints/flutter.yaml`
- # included above or to enable additional rules. A list of all available lints
- # and their documentation is published at https://dart.dev/lints.
- #
- # Instead of disabling a lint rule for the entire project in the
- # section below, it can also be suppressed for a single line of code
- # or a specific dart file by using the `// ignore: name_of_lint` and
- # `// ignore_for_file: name_of_lint` syntax on the line or in the file
- # producing the lint.
- rules:
- # avoid_print: false # Uncomment to disable the `avoid_print` rule
- # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
-
-# Additional information about this file can be found at
-# https://dart.dev/guides/language/analysis-options
diff --git a/ground-suite/docs/ARCHITECTURE.md b/ground-suite/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..884afb1
--- /dev/null
+++ b/ground-suite/docs/ARCHITECTURE.md
@@ -0,0 +1,46 @@
+# Architecture
+
+## Boundary
+
+```text
+FakeRealtimeSource now / KaptanDataSource later
+ → EventEnvelope validation
+ → stream ordering and event-ID deduplication
+ → bounded immutable DataPlane journal
+ → mission reducer
+ → projections and selectors
+ → useSyncExternalStore
+ → React presentation
+
+React intent
+ → DataPlane.preview
+ → authority + safety checks
+ → operator confirmation
+ → DataPlane.submit
+ → DataSource.send
+ → receipt event
+ → journal and mission projection
+```
+
+`main.tsx` is the composition root and the only presentation-side file that
+chooses the simulation runtime. UI features receive a `DataPlane`; they cannot
+import the fake source, transport, or virtual clock.
+
+## State ownership
+
+| Category | Owner | Persistence |
+| --- | --- | --- |
+| Mission truth | DataPlane projection | Source/replay service in a future release |
+| Derived fleet/task/selection views | Pure selectors | Never |
+| Local UI state | React feature state | Never unless promoted to preference |
+| Operator preferences | `PreferenceStore` schema v2 | Local storage |
+| Audit/event journal | DataPlane, bounded to 1,000 raw envelopes | Replaceable journal port |
+
+Telemetry is updated in the data plane and subscriber notification is batched
+to a projection cadence. React does not own a second authoritative mission
+copy. The 50 markers use CSS transforms and the fake source uses scenario-level
+ticks instead of component timers.
+
+The map is a local raster demonstration asset with DOM/SVG overlays. Spatial
+values are simulated percentages and must not be interpreted as surveyed
+coordinates, navigation truth, or a safe route.
diff --git a/ground-suite/docs/DATA_PLANE.md b/ground-suite/docs/DATA_PLANE.md
new file mode 100644
index 0000000..0bb0964
--- /dev/null
+++ b/ground-suite/docs/DATA_PLANE.md
@@ -0,0 +1,25 @@
+# Data plane
+
+`DataSource` exposes descriptor/capabilities, connect, disconnect, command
+submission, and optional replay controls. It contains no WebSocket, serial,
+MAVLink, or Cerebrum types.
+
+Every `EventEnvelope` carries schema version, stable event and stream IDs,
+monotonic sequence, type, entity reference, source/receive timestamps,
+optional correlation/causation IDs, payload, and quality/staleness metadata.
+Unsupported schemas, invalid identity, sequence, timestamp, type, or quality
+metadata are rejected into observable source diagnostics. Duplicate event IDs
+and sequences at or below a stream high-water mark never mutate mission truth.
+The fake journal is bounded to 1,000 envelopes and activity projection to 500
+rows.
+
+A snapshot replaces the projection. Telemetry updates known vehicles and
+never creates identities. Source-health events represent pause, loss,
+degradation, and recovery. An eventual live adapter must add unknown-reference
+quarantine, signed source identity, restart epochs, and durable replay storage.
+
+Commands use explicit lifecycle names. This release exercises Draft →
+Validating → Validated → Dispatched → Acknowledged through preview and receipt
+events, and represents queued/executing/completed intents in the action queue.
+Rejected previews or source receipts include a human reason and remediation;
+they do not mutate mission truth.
diff --git a/ground-suite/docs/DESIGN_RESET.md b/ground-suite/docs/DESIGN_RESET.md
new file mode 100644
index 0000000..6d14904
--- /dev/null
+++ b/ground-suite/docs/DESIGN_RESET.md
@@ -0,0 +1,37 @@
+# Kaptan Ground Suite — spatial design reset
+
+This board is the implementation benchmark for the persistent operations scene. The supplied Kaptan images are treated as visual-language evidence, not as an information architecture.
+
+## Evidence board
+
+| Reference | Borrow | Avoid |
+| --- | --- | --- |
+| Company of Heroes 2 | Safe left-click selection, additive selection, numbered control groups, contextual command verbs, readable queued intent, tactical overview | Orders that fire from an ambiguous right-click; game-only pause semantics; selection implying command rights |
+| Total War battles | Formation footprints, drag-to-orient placement, compact unit cards, group framing, large-force distributions | Screen-filling unit-card chrome; hidden morale/state meanings; instant consequential placement |
+| QGroundControl setup | Capability coverage, explicit calibration state, parameter metadata, preflight blockers, geofence concepts | A separate page per subsystem, long forced setup wizard, scalar-only telemetry, forms that hide geography |
+| Supplied semantic-zoom image | Strategic → operational → tactical information density; selected/observed/planned separation | Three separate map instances or page transitions to represent zoom |
+| Supplied mission overview | Graphite/navy palette, compact status strip, authority/presence language, direct map labels | Top navigation, oversized attention columns, cards competing with the map |
+| Supplied task/recovery images | Spatial route preview, exact recommendation comparison, audit timeline | Full-screen recovery page; acceptance before a final revalidation |
+| Supplied design-system image | Cyan selection, violet planning, amber attention, red only for danger, non-color marker shapes | Color-only state, generic rectangular button walls, tiny low-contrast text |
+
+## Interaction state boundary
+
+`Select` is the only safe resting state. Map pointer input is routed through one typed interaction controller. A mode owns its cursor, valid targets, pointer/keyboard semantics, draft geometry, completion, cancellation, validation, and output. Space/middle pointer temporarily owns pan. Escape cancels segment → draft → mode. Draft undo/redo never alters observed mission truth.
+
+Selection, local control groups, authoritative mission groups, and authority leases are independent state domains. A command draft captures an immutable target set and is revalidated before the simulated adapter can acknowledge it.
+
+## Persistent compositions
+
+All viewports use the same mounted scene and the same regions; only secondary density changes.
+
+| Viewport | Map target | Composition |
+| --- | ---: | --- |
+| 1366×768 | ≥ 52% of useful body area | 42 px status strip; 48 px tool rail; 218 px roster; map center; 286 px inspector; 122 px unit deck; 32 px collapsed timeline |
+| 1920×1080 | ≥ 60% | 48 px rail; 238 px roster; 320 px inspector; expanded spatial labels and telemetry charts |
+| 3440×1440 | ≥ 68% | fixed secondary rails; map absorbs width; inspector becomes a two-column engineering surface; unit cards remain bounded |
+
+Tools never replace the map. Fleet occupies the roster overlay; deployment and contextual abilities occupy the bottom deck; queue/events/telemetry expand upward; configuration and recovery dock from the right; settings is a compact drawer.
+
+## Required high-fidelity states
+
+The live implementation itself is the high-fidelity composition source. Deterministic state presets and screenshot tests cover: default tactical control, large multi-selection, route/formation drafting, task-area drawing, deployment/discovery, calibration, parameter diff, and degraded recovery. Screenshots are captured at 1366×768, 1920×1080, and 3440×1440; no preset is allowed to unmount `[data-testid="operational-map"]`.
diff --git a/ground-suite/docs/FAKE_SCENARIOS.md b/ground-suite/docs/FAKE_SCENARIOS.md
new file mode 100644
index 0000000..114aa88
--- /dev/null
+++ b/ground-suite/docs/FAKE_SCENARIOS.md
@@ -0,0 +1,20 @@
+# Deterministic fake scenarios
+
+The default seed (`17`) creates Coastal Survey / ALPHA-17, five operators,
+five groups, fifty vehicles, six archetypes, tasks, queues, authority leases,
+and a common initial event history. Positions, energy, link quality, and
+headings are reproducible.
+
+The virtual clock supports pause, resume, restart, 0.5×–4× UI speed, and
+manual stepping in tests. A 100 ms source cadence models 10 Hz vehicle input;
+the data plane batches subscriber notification. Deterministic tick windows
+exercise packet staleness, ALPHA navigation degradation and coverage loss,
+source degradation, and recovery. BRAVO starts near constraint R-210B.
+
+Recovery ranks compatible reserve candidates from energy, link, distance, and
+capability. The recommendation remains a preview until the operator confirms;
+the alert is marked resolved only by the acknowledged simulated command event.
+Restart reconstitutes the exact seed and clears the data-plane ordering state.
+
+Tests construct the source with automatic timers disabled and call `step`, so
+no assertion depends on wall-clock scheduling.
diff --git a/ground-suite/docs/ISSUE_TRACEABILITY.md b/ground-suite/docs/ISSUE_TRACEABILITY.md
new file mode 100644
index 0000000..a293a46
--- /dev/null
+++ b/ground-suite/docs/ISSUE_TRACEABILITY.md
@@ -0,0 +1,16 @@
+# Ground-station issue traceability
+
+Firmware-dependent work is explicitly deferred. “Implemented” below means the
+Ground Suite simulation side, not a live-aircraft integration.
+
+| Issue | Implemented scope | Deferred / firmware dependency | Modules and tests | Acceptance criteria |
+| --- | --- | --- | --- | --- |
+| #24 `ground-station: add persistent app state` | Schema-v2 layers, panels, density, motion, viewport, hotkeys, last workspace, safe draft references; migration/reset/corruption recovery | Shared mission persistence and telemetry storage | `core/persistence/preferences.ts`, `preferences.test.ts`, Settings | Reload-safe preferences; invalid data resets; no mission truth persisted |
+| #23 `ground-station: support "jump to" on events` | Entity/position targets change selection, zoom, map highlight, inspector/context; alert and timeline paths | Distributed event provenance | `domain/model.ts`, `App.tsx`, `App.test.tsx` | Jump selects and identifies group/task/vehicle/constraint context |
+| #22 `ground-station: toggleable information layers` | Ten searchable/operational layers, all/none, legend, persistence, critical mandatory layers | Certified geodata and live coverage | `preferences.ts`, `LayerPanel`, component tests | Keyboard toggles persist; mandatory safety context remains visible |
+| #21 `ground-station: tactical selection (via configurable hotkeys)` | Click, Shift add/remove, marquee, group keys, double-frame, F/Esc/L/Q/Space, conflicts, accessible list alternatives | Policy-synchronized hotkeys and robust lasso | `App.tsx`, `preferences.test.ts`, `App.test.tsx` | Inputs never capture shortcuts; exact selection shown |
+| #20 `ground-station (+firmware): action queue` | Ordered intentions, dependency/owner/resource/duration/timeline, accessible reorder, preview, receipt audit | Firmware queue protocol, cancellation/reconciliation | `domain/model.ts`, Queue workspace, `DataPlane.test.ts` | Reorder remains draft until validated, confirmed, acknowledged |
+| #19 `ground-station (+firmware): cerebrum integration` | Port, discovery/outcome/result/error types, deterministic fake and contract tests | Real API, transport, concurrency, certification | `adapters/cerebrum/*` | Unsupported outcomes reject explicitly; no live claim |
+| #18 `ground-station: multi-operator support` | Five presences/cursors/roles/activity, observers, leases, explicit transfer preview and audit, read-only language | Shared server, arbitration, durable lease expiry | `scenario.ts`, mission inspector, preview tests | Selection and authority remain distinct; transfer is explicit |
+| #17 `ground-station: animated vehicle archetypes on vehicle catalog` | Six imagery-backed cards, capabilities/roles/simulated range, counts/status, restrained orientation | Reviewed build-manifest catalog generation | Catalog workspace/assets; reduced-motion CSS | Motion respects preference; placeholders say simulated |
+| #16 `ground-station: fleet management (multi vehicle management)` | Overview, membership/archetype/status/freshness/task/role/formation/filtering/reserve/bulk target preview/binding | Real source binding and server-side group transactions | Fleet workspace, selectors tests, preview tests | Every bulk operation exposes exact target set and validation |
diff --git a/ground-suite/docs/KAPTAN_INTEGRATION.md b/ground-suite/docs/KAPTAN_INTEGRATION.md
new file mode 100644
index 0000000..94ab258
--- /dev/null
+++ b/ground-suite/docs/KAPTAN_INTEGRATION.md
@@ -0,0 +1,29 @@
+# Future Kaptan and Cerebrum integration
+
+`KaptanDataSource` is intentionally a rejecting skeleton. Current firmware is
+telemetry-only and the repository safety boundary rejects inbound authority.
+No reviewed command API, authenticated source binding, or capability exchange
+exists, so this release claims none.
+
+A future adapter must define and review:
+
+- authenticated source and vehicle identity, key provisioning/rotation, and
+ persistent anti-replay state;
+- MAVLink 2 signing policy and exact Kaptan dialect/schema revision;
+- snapshot/restart epochs, sequence wrap, time synchronization, stale data,
+ reconnect and partial-fleet behavior;
+- capability negotiation for vehicle/task/formation/action-queue behavior;
+- idempotent command IDs, exact target binding, authority leases,
+ acknowledgement/rejection, cancellation, expiry, and reconciliation;
+- safe handling of unsupported or downgraded firmware.
+
+`AutonomyCoordinatorPort` separately models capability discovery and exchange
+of desired outcomes, constraints, success criteria, proposals, explanations,
+and typed errors. `FakeCerebrumAdapter` is contract-tested. The real Cerebrum
+transport, concurrency semantics, conflict resolution, plan provenance,
+versioning, timeout policy, and safety certification remain unresolved. A
+wire protocol must not be inferred from the TypeScript interface.
+
+The next integration step is a reviewed, read-only Kaptan telemetry adapter
+that validates signed source-to-vehicle bindings and event freshness without
+adding command authority.
diff --git a/ground-suite/docs/SAFETY_MODEL.md b/ground-suite/docs/SAFETY_MODEL.md
new file mode 100644
index 0000000..22b3232
--- /dev/null
+++ b/ground-suite/docs/SAFETY_MODEL.md
@@ -0,0 +1,21 @@
+# Safety model
+
+Selection is local attention; authority is a mission-truth lease. Selected,
+observed, requested, authority-held, conflict, and unavailable states use text
+and symbols as well as color. No selection shortcut changes a lease.
+
+Consequential commands always show what and where, the exact affected vehicle
+set, current holder, reason for allowance/rejection, next state, and failure
+effect. Validation covers simulated energy reserve, link quality, telemetry
+freshness, route/constraint warning, capability where applicable, authority,
+and source connectivity. A failed preview cannot be confirmed. Source
+rejections carry a remediation path and are journaled.
+
+UI movement is presentation only. The event stream is telemetry truth. The
+persistent `SIMULATED DATA` badge and server warning distinguish every
+interaction from real aircraft control. Local storage contains no telemetry,
+lease, credential, receipt, or audit truth. Critical-state mandatory layers
+cannot be hidden.
+
+This is an operator workflow prototype and simulation test surface, not a
+flight-safety approval or physical command system.
diff --git a/ground-suite/index.html b/ground-suite/index.html
new file mode 100644
index 0000000..2549775
--- /dev/null
+++ b/ground-suite/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ Kaptan Ground Suite — Simulated Mission
+
+
+
diff --git a/ground-suite/lib/app/app.dart b/ground-suite/lib/app/app.dart
deleted file mode 100644
index 08f1468..0000000
--- a/ground-suite/lib/app/app.dart
+++ /dev/null
@@ -1,20 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
-import 'app_shell.dart';
-import 'theme.dart';
-
-class GroundSuiteApp extends StatelessWidget {
- const GroundSuiteApp({super.key});
-
- @override
- Widget build(BuildContext context) => ProviderScope(
- child: MaterialApp(
- title: 'Ground Suite',
- debugShowCheckedModeBanner: false,
- theme: groundSuiteDarkTheme(),
- themeMode: ThemeMode.dark,
- home: const AppShell(),
- ),
- );
-}
diff --git a/ground-suite/lib/app/app_shell.dart b/ground-suite/lib/app/app_shell.dart
deleted file mode 100644
index b733543..0000000
--- a/ground-suite/lib/app/app_shell.dart
+++ /dev/null
@@ -1,71 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
-import '../features/command/presentation/tactical_command_page.dart';
-import '../features/diagnostics/presentation/diagnostics_page.dart';
-import '../features/observability/presentation/observability_controller.dart';
-
-enum _ApplicationSurface { tactical, eventStream }
-
-/// Owns the two first-class Ground Suite surfaces. Tactical planning never
-/// hides decoded diagnostics or critical accessibility announcements.
-class AppShell extends ConsumerStatefulWidget {
- const AppShell({super.key});
-
- @override
- ConsumerState createState() => _AppShellState();
-}
-
-class _AppShellState extends ConsumerState {
- _ApplicationSurface _surface = _ApplicationSurface.tactical;
-
- @override
- Widget build(BuildContext context) {
- final observation = ref.watch(observabilityControllerProvider);
- return Scaffold(
- body: Stack(
- children: [
- switch (_surface) {
- _ApplicationSurface.tactical => TacticalCommandPage(
- sourceLabel: observation.sourceLabel,
- sourceStatus: observation.status.label,
- onOpenEventStream: () =>
- setState(() => _surface = _ApplicationSurface.eventStream),
- ),
- _ApplicationSurface.eventStream => _EventStreamWorkspace(
- onReturnToTactical: () =>
- setState(() => _surface = _ApplicationSurface.tactical),
- ),
- },
- Semantics(
- liveRegion: true,
- label: observation.accessibilityAnnouncement ?? '',
- child: const SizedBox.shrink(),
- ),
- ],
- ),
- );
- }
-}
-
-class _EventStreamWorkspace extends StatelessWidget {
- const _EventStreamWorkspace({required this.onReturnToTactical});
-
- final VoidCallback onReturnToTactical;
-
- @override
- Widget build(BuildContext context) => Stack(
- children: [
- const Positioned.fill(child: DiagnosticsPage()),
- Positioned(
- top: 16,
- right: 20,
- child: FilledButton.icon(
- onPressed: onReturnToTactical,
- icon: const Icon(Icons.map_outlined),
- label: const Text('Tactical workspace'),
- ),
- ),
- ],
- );
-}
diff --git a/ground-suite/lib/app/theme.dart b/ground-suite/lib/app/theme.dart
deleted file mode 100644
index 096df14..0000000
--- a/ground-suite/lib/app/theme.dart
+++ /dev/null
@@ -1,70 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:google_fonts/google_fonts.dart';
-
-/// Ground Suite's single visual language: a dark tactical operations deck.
-const panelBackground = Color(0xD912141A);
-const panelBorder = Color(0x1AFFFFFF);
-const textPrimary = Color(0xFFF8FAFC);
-const textSecondary = Color(0xFF94A3B8);
-const accentOrange = Color(0xFFF97316);
-const accentMint = Color(0xFF34D399);
-const statusGreen = Color(0xFF10B981);
-const statusRed = Color(0xFFEF4444);
-
-const tacticalBlack = Color(0xFF000000);
-const tacticalTransparent = Color(0x00000000);
-
-ThemeData groundSuiteDarkTheme() {
- final base = ThemeData.dark(useMaterial3: true);
- final interTextTheme = GoogleFonts.interTextTheme(
- base.textTheme,
- ).apply(bodyColor: textPrimary, displayColor: textPrimary);
-
- return base.copyWith(
- scaffoldBackgroundColor: tacticalBlack,
- cardColor: panelBackground,
- colorScheme: const ColorScheme.dark(
- primary: accentMint,
- onPrimary: tacticalBlack,
- secondary: accentOrange,
- onSecondary: tacticalBlack,
- surface: panelBackground,
- onSurface: textPrimary,
- error: statusRed,
- onError: textPrimary,
- outline: panelBorder,
- ),
- textTheme: interTextTheme.copyWith(
- displayLarge: interTextTheme.displayLarge?.copyWith(color: textPrimary),
- displayMedium: interTextTheme.displayMedium?.copyWith(color: textPrimary),
- headlineLarge: interTextTheme.headlineLarge?.copyWith(color: textPrimary),
- headlineMedium: interTextTheme.headlineMedium?.copyWith(
- color: textPrimary,
- ),
- headlineSmall: interTextTheme.headlineSmall?.copyWith(color: textPrimary),
- titleLarge: interTextTheme.titleLarge?.copyWith(color: textPrimary),
- titleMedium: interTextTheme.titleMedium?.copyWith(color: textPrimary),
- titleSmall: interTextTheme.titleSmall?.copyWith(color: textPrimary),
- bodyLarge: interTextTheme.bodyLarge?.copyWith(color: textPrimary),
- bodyMedium: interTextTheme.bodyMedium?.copyWith(color: textPrimary),
- bodySmall: interTextTheme.bodySmall?.copyWith(color: textSecondary),
- labelLarge: interTextTheme.labelLarge?.copyWith(color: textPrimary),
- labelMedium: interTextTheme.labelMedium?.copyWith(color: textSecondary),
- labelSmall: interTextTheme.labelSmall?.copyWith(color: textSecondary),
- ),
- cardTheme: const CardThemeData(color: panelBackground, elevation: 0),
- dividerTheme: const DividerThemeData(color: panelBorder),
- progressIndicatorTheme: ProgressIndicatorThemeData(
- color: accentMint,
- linearTrackColor: textSecondary.withValues(alpha: .2),
- ),
- chipTheme: ChipThemeData(
- backgroundColor: tacticalTransparent,
- selectedColor: accentMint.withValues(alpha: .1),
- side: const BorderSide(color: panelBorder),
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
- labelStyle: GoogleFonts.inter(color: textSecondary),
- secondaryLabelStyle: GoogleFonts.inter(color: textPrimary),
- ),
- );
-}
diff --git a/ground-suite/lib/core/mavlink/mavlink_byte_source.dart b/ground-suite/lib/core/mavlink/mavlink_byte_source.dart
deleted file mode 100644
index 3a2d39d..0000000
--- a/ground-suite/lib/core/mavlink/mavlink_byte_source.dart
+++ /dev/null
@@ -1,12 +0,0 @@
-import 'dart:typed_data';
-
-/// Receive-only bytes from one MAVLink transport endpoint.
-///
-/// A source has one reader and owns its close lifecycle. A future adapter will
-/// bind serial, UDP, or a recording source here; no source in the first slice
-/// may write bytes to a vehicle.
-abstract interface class MavlinkByteSource {
- Stream get chunks;
-
- Future close();
-}
diff --git a/ground-suite/lib/core/mavlink/mavlink_event_decoder.dart b/ground-suite/lib/core/mavlink/mavlink_event_decoder.dart
deleted file mode 100644
index 8312764..0000000
--- a/ground-suite/lib/core/mavlink/mavlink_event_decoder.dart
+++ /dev/null
@@ -1,13 +0,0 @@
-import '../observations/vehicle_data_update.dart';
-import 'mavlink_byte_source.dart';
-
-/// Converts a receive-only MAVLink byte stream into semantically safe events.
-///
-/// TODO(gcs-live-mavlink): generate this adapter from pinned MAVLink Common
-/// definitions plus Kaptan's `kaptan.xml` root. It must reject MAVLink 1 and
-/// CRC-invalid MAVLink 2 frames, validate every fixed diagnostic length before
-/// UTF-8 decoding, retain unknown enum values and flags, and emit parser or
-/// semantic drop updates without ending the connection.
-abstract interface class MavlinkEventDecoder {
- Stream decode(MavlinkByteSource source);
-}
diff --git a/ground-suite/lib/core/observations/observed_event.dart b/ground-suite/lib/core/observations/observed_event.dart
deleted file mode 100644
index 89621a0..0000000
--- a/ground-suite/lib/core/observations/observed_event.dart
+++ /dev/null
@@ -1,136 +0,0 @@
-/// A MAVLink severity value retained exactly as it appeared on the wire.
-///
-/// Known values follow MAVLink's numeric severity semantics. Unknown values
-/// remain displayable so a newer vehicle cannot silently lose diagnostic
-/// meaning before Ground Suite has been updated.
-final class MavSeverity {
- const MavSeverity(this.wireValue);
-
- static const emergency = MavSeverity(0);
- static const alert = MavSeverity(1);
- static const critical = MavSeverity(2);
- static const error = MavSeverity(3);
- static const warning = MavSeverity(4);
- static const notice = MavSeverity(5);
- static const info = MavSeverity(6);
- static const debug = MavSeverity(7);
-
- final int wireValue;
-
- String get label => switch (wireValue) {
- 0 => 'Emergency',
- 1 => 'Alert',
- 2 => 'Critical',
- 3 => 'Error',
- 4 => 'Warning',
- 5 => 'Notice',
- 6 => 'Info',
- 7 => 'Debug',
- _ => 'Unknown ($wireValue)',
- };
-}
-
-/// A KAPTAN_SHUTDOWN reason retained exactly as it appeared on the wire.
-final class KaptanShutdownReason {
- const KaptanShutdownReason(this.wireValue);
-
- static const operator = KaptanShutdownReason(1);
- static const safety = KaptanShutdownReason(2);
-
- final int wireValue;
-
- String get label => switch (wireValue) {
- 1 => 'Operator',
- 2 => 'Safety',
- _ => 'Unknown ($wireValue)',
- };
-}
-
-enum TelemetryKind {
- rawImu,
- distanceSensor,
- opticalFlow;
-
- String get label => switch (this) {
- TelemetryKind.rawImu => 'Raw IMU',
- TelemetryKind.distanceSensor => 'Distance sensor',
- TelemetryKind.opticalFlow => 'Optical flow',
- };
-}
-
-sealed class ObservedEvent {
- const ObservedEvent({required this.receivedAt});
-
- final DateTime receivedAt;
-}
-
-final class HeartbeatEvent extends ObservedEvent {
- const HeartbeatEvent({
- required super.receivedAt,
- required this.systemId,
- required this.componentId,
- required this.armed,
- required this.vehicleState,
- });
-
- final int systemId;
- final int componentId;
- final bool armed;
- final String vehicleState;
-}
-
-final class TelemetryEvent extends ObservedEvent {
- const TelemetryEvent({required super.receivedAt, required this.kind});
-
- final TelemetryKind kind;
-}
-
-final class LogEvent extends ObservedEvent {
- const LogEvent({
- required super.receivedAt,
- required this.level,
- required this.message,
- this.flags = 0,
- });
-
- static const traceFlag = 1;
- static const truncatedFlag = 2;
- static const coalescedFlag = 4;
- static const knownFlags = traceFlag | truncatedFlag | coalescedFlag;
-
- final MavSeverity level;
- final String message;
- final int flags;
-
- bool get isTrace => flags & traceFlag != 0;
- bool get isTruncated => flags & truncatedFlag != 0;
- bool get isCoalesced => flags & coalescedFlag != 0;
- int get unknownFlags => flags & ~knownFlags;
-}
-
-final class ErrorEvent extends ObservedEvent {
- const ErrorEvent({
- required super.receivedAt,
- required this.path,
- this.additionalInformation = '',
- this.flags = 0,
- });
-
- static const truncatedFlag = 2;
- static const coalescedFlag = 4;
- static const knownFlags = truncatedFlag | coalescedFlag;
-
- final String path;
- final String additionalInformation;
- final int flags;
-
- bool get isTruncated => flags & truncatedFlag != 0;
- bool get isCoalesced => flags & coalescedFlag != 0;
- int get unknownFlags => flags & ~knownFlags;
-}
-
-final class ShutdownEvent extends ObservedEvent {
- const ShutdownEvent({required super.receivedAt, required this.reason});
-
- final KaptanShutdownReason reason;
-}
diff --git a/ground-suite/lib/core/observations/vehicle_data_port.dart b/ground-suite/lib/core/observations/vehicle_data_port.dart
deleted file mode 100644
index 31bcec6..0000000
--- a/ground-suite/lib/core/observations/vehicle_data_port.dart
+++ /dev/null
@@ -1,16 +0,0 @@
-import 'vehicle_data_update.dart';
-
-/// A receive-only source of already-classified vehicle observations.
-///
-/// The replay implementation is the first concrete port. A live port may be
-/// added only after the custom MAVLink decoder has its length/CRC/enum safety
-/// contract and endpoint lifecycle tests.
-abstract interface class VehicleDataPort {
- String get label;
-
- bool get isReplay;
-
- Stream observe();
-
- Future close();
-}
diff --git a/ground-suite/lib/core/observations/vehicle_data_update.dart b/ground-suite/lib/core/observations/vehicle_data_update.dart
deleted file mode 100644
index 3a83e12..0000000
--- a/ground-suite/lib/core/observations/vehicle_data_update.dart
+++ /dev/null
@@ -1,28 +0,0 @@
-import 'observed_event.dart';
-
-/// One receive-side outcome from a vehicle-data transport.
-///
-/// Expected malformed or unsupported MAVLink frames are reported as drops and
-/// must not terminate the transport stream. Transport failures remain stream
-/// errors because the connection lifecycle must handle them separately.
-sealed class VehicleDataUpdate {
- const VehicleDataUpdate();
-}
-
-/// A classified observation that is safe to present.
-final class ObservationReceived extends VehicleDataUpdate {
- const ObservationReceived(this.event);
-
- final ObservedEvent event;
-}
-
-enum ObservationDropKind { parser, semantic }
-
-/// One or more rejected frames, retained only as bounded accounting.
-final class ObservationDropped extends VehicleDataUpdate {
- const ObservationDropped({required this.kind, this.count = 1})
- : assert(count > 0);
-
- final ObservationDropKind kind;
- final int count;
-}
diff --git a/ground-suite/lib/core/transport/replay_vehicle_data_port.dart b/ground-suite/lib/core/transport/replay_vehicle_data_port.dart
deleted file mode 100644
index 480e82f..0000000
--- a/ground-suite/lib/core/transport/replay_vehicle_data_port.dart
+++ /dev/null
@@ -1,65 +0,0 @@
-import '../observations/observed_event.dart';
-import '../observations/vehicle_data_port.dart';
-import '../observations/vehicle_data_update.dart';
-
-/// Deterministic observations for the first Ground Suite workflow.
-///
-/// This is intentionally a typed replay rather than a pretend connected
-/// vehicle. Raw frame replay moves behind the MAVLink decoder once bindings
-/// are generated from the MAVLink Common and Kaptan dialect definitions.
-final class ReplayVehicleDataPort implements VehicleDataPort {
- const ReplayVehicleDataPort();
-
- @override
- String get label => 'Replay fixture';
-
- @override
- bool get isReplay => true;
-
- @override
- Stream observe() => Stream.fromIterable(
- _events.map(ObservationReceived.new),
- );
-
- @override
- Future close() async {}
-
- static final List _events = [
- HeartbeatEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 12),
- systemId: 1,
- componentId: 1,
- armed: false,
- vehicleState: 'Standby',
- ),
- TelemetryEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 1),
- kind: TelemetryKind.rawImu,
- ),
- TelemetryEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 2),
- kind: TelemetryKind.distanceSensor,
- ),
- LogEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 3),
- level: MavSeverity.warning,
- message: 'Range aid stale',
- flags: LogEvent.coalescedFlag,
- ),
- ErrorEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 4),
- path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
- additionalInformation: 'The replay contains a bounded error detail.',
- flags: ErrorEvent.truncatedFlag,
- ),
- LogEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 5),
- level: MavSeverity.info,
- message: 'Replay fixture complete',
- ),
- ShutdownEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 6),
- reason: KaptanShutdownReason.operator,
- ),
- ];
-}
diff --git a/ground-suite/lib/features/command/presentation/tactical_command_page.dart b/ground-suite/lib/features/command/presentation/tactical_command_page.dart
deleted file mode 100644
index ce5c9f8..0000000
--- a/ground-suite/lib/features/command/presentation/tactical_command_page.dart
+++ /dev/null
@@ -1,4694 +0,0 @@
-import 'dart:math' as math;
-import 'dart:ui';
-
-import 'package:flutter/gestures.dart' show kPrimaryButton, kSecondaryButton;
-import 'package:flutter/material.dart';
-import 'package:flutter/scheduler.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:google_fonts/google_fonts.dart';
-
-import '../../../app/theme.dart';
-import '../../fleet/domain/fleet_unit.dart';
-import '../../vehicle_workspace/domain/vehicle_catalog.dart';
-import '../../vehicle_workspace/domain/vehicle_definition.dart';
-import '../../vehicle_workspace/presentation/vehicle_workspace_controller.dart';
-import 'tactical_controller.dart';
-
-const _fpvPlaceholder =
- 'https://images.unsplash.com/photo-1470770841072-f978cf4d019e'
- '?auto=format&fit=crop&w=1600&q=85';
-
-const _fleet = <_DroneData>[
- _DroneData('DEMO-1021', '41.4 km/h', '547 m', 'On patrol', statusGreen, .78),
- _DroneData('DEMO-1023', '39.7 km/h', '511 m', 'On route', statusGreen, .64),
- _DroneData(
- 'DEMO-1022',
- '43.5 km/h',
- '611 m',
- 'Monitoring',
- accentOrange,
- .92,
- ),
- _DroneData('DEMO-1024', '—', '—', 'Offline', statusRed, .18),
-];
-
-const _unitMapAnchors = {
- 'DEMO-1021': Alignment(.14, .18),
- 'DEMO-1023': Alignment(-.08, .46),
- 'DEMO-1022': Alignment(.46, -.22),
- 'DEMO-1024': Alignment(-.42, -.32),
-};
-
-String _modeLabel(TacticalMode mode) => switch (mode) {
- TacticalPencilMode() => 'PENCIL',
- TacticalMarkerMode() => 'MARKER',
- _ => 'SELECTION',
-};
-
-/// The high-density tactical surface for Ground Suite.
-///
-/// The current scene values are local data only; they are deliberately kept
-/// separate from the vehicle data port until a reviewed live integration lands.
-class TacticalCommandPage extends ConsumerStatefulWidget {
- const TacticalCommandPage({
- required this.sourceLabel,
- required this.sourceStatus,
- required this.onOpenEventStream,
- super.key,
- });
-
- final String sourceLabel;
- final String sourceStatus;
- final VoidCallback onOpenEventStream;
-
- @override
- ConsumerState createState() =>
- _TacticalCommandPageState();
-}
-
-class _TacticalCommandPageState extends ConsumerState {
- final FocusNode _fallbackFocus = FocusNode(debugLabel: 'tactical-workspace');
- final FocusNode _unitsTriggerFocus = FocusNode(debugLabel: 'units-trigger');
- FocusNode? _toolReturnFocus;
-
- @override
- void dispose() {
- _fallbackFocus.dispose();
- _unitsTriggerFocus.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- final mode = ref.watch(
- tacticalControllerProvider.select((state) => state.mode),
- );
- return Focus(
- focusNode: _fallbackFocus,
- autofocus: true,
- onKeyEvent: (node, event) {
- if (event is KeyDownEvent &&
- event.logicalKey == LogicalKeyboardKey.escape) {
- final tactical = ref.read(tacticalControllerProvider);
- if (mode is! TacticalBrowseMode) {
- _handleAction(_TacticalAction.cancel);
- return KeyEventResult.handled;
- }
- if (tactical.panel == TacticalPanel.unitSetup) {
- if (tactical.unitDraft != null) {
- ref
- .read(tacticalControllerProvider.notifier)
- .returnToUnitHangar();
- } else {
- _closeUnitHangar();
- }
- return KeyEventResult.handled;
- }
- if (tactical.panel == TacticalPanel.unitHangar) {
- _closeUnitHangar();
- return KeyEventResult.handled;
- }
- }
- return KeyEventResult.ignored;
- },
- child: LayoutBuilder(
- builder: (context, constraints) {
- final textScale = MediaQuery.textScalerOf(context).scale(14) / 14;
- // The desktop deck relies on fixed visual regions. A short desktop window
- // receives the scrollable compact arrangement instead of overlapping the
- // map controls and the camera panel.
- final compact =
- constraints.maxWidth < 1120 ||
- constraints.maxHeight < 820 ||
- textScale >= 1.25;
- final editing = mode is! TacticalBrowseMode;
- final content = Stack(
- fit: StackFit.expand,
- children: [
- const _SatelliteMapBackground(),
- const Positioned.fill(child: _MapAtmosphere()),
- Positioned.fill(
- child: _TacticalMapLayer(
- onUnitSelected: _selectUnit,
- onSelectionRect: _selectUnitsInRect,
- ),
- ),
- _TacticalDeckLayer(
- compact: compact,
- width: constraints.maxWidth,
- height: constraints.maxHeight,
- onUnitSelected: _selectUnit,
- onAction: _handleAction,
- sourceLabel: widget.sourceLabel,
- sourceStatus: widget.sourceStatus,
- onOpenEventStream: widget.onOpenEventStream,
- unitsTriggerFocus: _unitsTriggerFocus,
- onCloseHangar: _closeUnitHangar,
- ),
- ],
- );
- return Stack(
- fit: StackFit.expand,
- children: [
- ExcludeSemantics(
- excluding: editing,
- child: ExcludeFocus(
- excluding: editing,
- child: IgnorePointer(ignoring: editing, child: content),
- ),
- ),
- if (editing)
- Positioned.fill(
- child: _TacticalEditLayer(
- onAction: _handleAction,
- onStrokeComplete: _completeSceneStroke,
- onNudgeCursor: _nudgeSceneCursor,
- onCommitCursor: _commitSceneCursor,
- onFinishKeyboardPencil: _finishKeyboardPencil,
- onConfigurePencil: _configurePencil,
- onConfigureMarker: _configureMarker,
- onCommitMarkerName: _commitMarkerName,
- ),
- ),
- ],
- );
- },
- ),
- );
- }
-
- void _closeUnitHangar() {
- ref.read(tacticalControllerProvider.notifier).closeUnitHangar();
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) {
- _unitsTriggerFocus.requestFocus();
- }
- });
- }
-
- void _selectUnit(String unitId) {
- // A draft owns an explicit Close/Back boundary. Map selection must never
- // silently dispose a typed local name or selected category.
- if (ref.read(tacticalControllerProvider).unitDraft != null) {
- return;
- }
- if (unitId.isEmpty) {
- ref.read(tacticalControllerProvider.notifier).clearSelection();
- return;
- }
- final shiftPressed = HardwareKeyboard.instance.logicalKeysPressed.any(
- (key) =>
- key == LogicalKeyboardKey.shiftLeft ||
- key == LogicalKeyboardKey.shiftRight,
- );
- ref
- .read(tacticalControllerProvider.notifier)
- .selectUnit(unitId, toggle: shiftPressed);
- }
-
- void _selectUnitsInRect(LocalScenePoint start, LocalScenePoint end) {
- // Like a direct marker click, a drag-selection cannot quietly close an
- // in-progress local setup. Close/Back is the draft's only exit boundary.
- if (ref.read(tacticalControllerProvider).unitDraft != null) {
- return;
- }
- final left = math.min(start.x, end.x);
- final right = math.max(start.x, end.x);
- final top = math.min(start.y, end.y);
- final bottom = math.max(start.y, end.y);
- bool contains(LocalScenePoint point) =>
- point.x >= left &&
- point.x <= right &&
- point.y >= top &&
- point.y <= bottom;
- final showDemoUnits = ref
- .read(tacticalControllerProvider)
- .overlays
- .contains(TacticalMapOverlay.drones);
- final demoIds = (showDemoUnits ? _fleet : const <_DroneData>[])
- .where(
- (unit) => contains(
- LocalScenePoint(
- x: (_unitMapAnchors[unit.id]!.x + 1) / 2,
- y: (_unitMapAnchors[unit.id]!.y + 1) / 2,
- ),
- ),
- )
- .map((unit) => unit.id);
- final localIds = ref
- .read(vehicleWorkspaceProvider)
- .units
- .where(
- (unit) => contains(
- LocalScenePoint(x: unit.localPlacement.x, y: unit.localPlacement.y),
- ),
- )
- .map((unit) => unit.localUnitId);
- ref.read(tacticalControllerProvider.notifier).selectUnits({
- ...demoIds,
- ...localIds,
- });
- }
-
- Future _handleAction(_TacticalAction action) async {
- final controller = ref.read(tacticalControllerProvider.notifier);
- switch (action) {
- case _TacticalAction.addUnit:
- controller.showUnitHangar();
- case _TacticalAction.openFleet:
- controller.showFleetRoster();
- case _TacticalAction.closeFleet:
- controller.closePanel();
- case _TacticalAction.openUnitSetup:
- final selectedIds = ref
- .read(tacticalControllerProvider)
- .selectedUnitIds;
- final selectedId = selectedIds.length == 1 ? selectedIds.single : null;
- if (selectedId != null &&
- ref
- .read(vehicleWorkspaceProvider)
- .units
- .any((unit) => unit.localUnitId == selectedId)) {
- controller.showUnitSetupForSelectedLocalUnit(selectedId);
- }
- case _TacticalAction.closeUnitHangar:
- _closeUnitHangar();
- case _TacticalAction.clearSelection:
- controller.clearSelection();
- case _TacticalAction.resetView:
- controller.resetLocalView();
- case _TacticalAction.selectTool:
- ref.read(tacticalPointerPreviewProvider.notifier).clear();
- controller.cancelMode();
- _restoreToolFocus();
- case _TacticalAction.pencilTool:
- _beginTool(controller.beginPencil);
- case _TacticalAction.markerTool:
- _beginTool(controller.beginMarker);
- case _TacticalAction.clearSceneObjects:
- if (await _confirmClearLocalOverlays()) {
- controller.clearSceneObjects();
- }
- case _TacticalAction.removeSelectedSceneObject:
- controller.removeSelectedSceneObject();
- case _TacticalAction.cancel:
- _cancelActiveTool();
- }
- }
-
- void _beginTool(VoidCallback begin) {
- _toolReturnFocus = FocusManager.instance.primaryFocus;
- begin();
- }
-
- void _restoreToolFocus() {
- final returnFocus = _toolReturnFocus;
- _toolReturnFocus = null;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) {
- return;
- }
- if (returnFocus?.context != null &&
- (returnFocus?.canRequestFocus ?? false)) {
- returnFocus!.requestFocus();
- } else {
- _fallbackFocus.requestFocus();
- }
- });
- }
-
- Future _confirmClearLocalOverlays() async =>
- await showDialog(
- context: context,
- builder: (dialogContext) => AlertDialog(
- title: const Text('Clear local overlays?'),
- content: const Text(
- 'This removes all session-only local pencil drawings and markers. It does not affect any vehicle.',
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(dialogContext, false),
- child: const Text('Keep local scene'),
- ),
- FilledButton(
- onPressed: () => Navigator.pop(dialogContext, true),
- child: const Text('Clear drawings and markers'),
- ),
- ],
- ),
- ) ??
- false;
-
- void _completeSceneStroke(List points) {
- if (ref
- .read(tacticalControllerProvider.notifier)
- .commitPencilStroke(points)) {
- ref.read(tacticalPointerPreviewProvider.notifier).clear();
- }
- }
-
- void _nudgeSceneCursor(double dx, double dy) =>
- ref.read(tacticalPointerPreviewProvider.notifier).nudge(dx, dy);
-
- void _commitSceneCursor() {
- final point =
- ref.read(tacticalPointerPreviewProvider).point ??
- const LocalScenePoint(x: .5, y: .5);
- final mode = ref.read(tacticalControllerProvider).mode;
- final controller = ref.read(tacticalControllerProvider.notifier);
- if (mode is TacticalPencilMode) {
- controller.addKeyboardPencilPoint(point);
- return;
- }
- if (mode is TacticalMarkerMode) {
- controller.chooseMarkerPoint(point);
- return;
- }
- return;
- }
-
- void _finishKeyboardPencil() {
- if (ref.read(tacticalControllerProvider.notifier).finishKeyboardPencil()) {
- ref.read(tacticalPointerPreviewProvider.notifier).clear();
- }
- }
-
- void _configurePencil({
- double? hue,
- double? saturation,
- double? brightness,
- double? thickness,
- }) => ref
- .read(tacticalControllerProvider.notifier)
- .configurePencil(
- hue: hue,
- saturation: saturation,
- brightness: brightness,
- thickness: thickness,
- );
-
- void _configureMarker({
- double? hue,
- double? saturation,
- double? brightness,
- }) => ref
- .read(tacticalControllerProvider.notifier)
- .configureMarker(
- hue: hue,
- saturation: saturation,
- brightness: brightness,
- );
-
- void _commitMarkerName(String value) =>
- ref.read(tacticalControllerProvider.notifier).commitMarkerName(value);
-
- void _cancelActiveTool() {
- ref.read(tacticalPointerPreviewProvider.notifier).clear();
- if (ref.read(tacticalControllerProvider.notifier).cancelActiveTool()) {
- _restoreToolFocus();
- }
- }
-}
-
-/// Owns only map-derived state. The base map and tactical chrome are outside
-/// this consumer, so selection/annotation changes do not rebuild either.
-class _TacticalMapLayer extends ConsumerWidget {
- const _TacticalMapLayer({
- required this.onUnitSelected,
- required this.onSelectionRect,
- });
-
- final ValueChanged onUnitSelected;
- final void Function(LocalScenePoint start, LocalScenePoint end)
- onSelectionRect;
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- // Keep local-name typing in the hangar from rebuilding this renderer. The
- // selected record consists only of map-visible state and retains identity
- // when a panel draft changes.
- final tactical = ref.watch(
- tacticalControllerProvider.select(
- (state) => (
- selectedUnitIds: state.selectedUnitIds,
- mode: state.mode,
- drawings: state.drawings,
- markers: state.markers,
- overlays: state.overlays,
- ),
- ),
- );
- final localUnits = ref.watch(
- vehicleWorkspaceProvider.select((workspace) => workspace.units),
- );
- return _DemoMapOverlays(
- layers: _layerNames(tactical.overlays),
- drawings: tactical.drawings,
- markers: tactical.markers,
- draftPencil: tactical.mode is TacticalPencilMode
- ? tactical.mode as TacticalPencilMode
- : null,
- draftMarker: tactical.mode is TacticalMarkerMode
- ? tactical.mode as TacticalMarkerMode
- : null,
- localUnits: localUnits,
- selectedUnitIds: tactical.selectedUnitIds,
- onUnitSelected: onUnitSelected,
- onSelectionRect: onSelectionRect,
- onMarkerSelected: ref
- .read(tacticalControllerProvider.notifier)
- .selectMarker,
- );
- }
-}
-
-/// Owns the dynamic deck data separately from the map renderer. Individual
-/// scene visuals are isolated by RepaintBoundary and their own consumers.
-class _TacticalDeckLayer extends ConsumerWidget {
- const _TacticalDeckLayer({
- required this.compact,
- required this.width,
- required this.height,
- required this.onUnitSelected,
- required this.onAction,
- required this.sourceLabel,
- required this.sourceStatus,
- required this.onOpenEventStream,
- required this.unitsTriggerFocus,
- required this.onCloseHangar,
- });
-
- final bool compact;
- final double width;
- final double height;
- final ValueChanged onUnitSelected;
- final ValueChanged<_TacticalAction> onAction;
- final String sourceLabel;
- final String sourceStatus;
- final VoidCallback onOpenEventStream;
- final FocusNode unitsTriggerFocus;
- final VoidCallback onCloseHangar;
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final tactical = ref.watch(tacticalSceneRenderStateProvider);
- final workspaceUnits = ref.watch(
- vehicleWorkspaceProvider.select((workspace) => workspace.units),
- );
- final selectedId = tactical.selectedUnitIds.length == 1
- ? tactical.selectedUnitIds.single
- : null;
- _DroneData? selectedUnit;
- if (selectedId != null) {
- for (final unit in _fleet) {
- if (unit.id == selectedId) {
- selectedUnit = unit;
- break;
- }
- }
- }
- final layers = _layerNames(tactical.overlays);
- if (compact) {
- return _CompactCommandDeck(
- layers: layers,
- onLayerChanged: (label, visible) => ref
- .read(tacticalControllerProvider.notifier)
- .toggleOverlay(_overlayForLabel(label), visible),
- selectedUnit: selectedUnit,
- selectedUnitIds: tactical.selectedUnitIds,
- onUnitSelected: onUnitSelected,
- tactical: tactical,
- workspaceUnits: workspaceUnits,
- onAction: onAction,
- sourceLabel: sourceLabel,
- sourceStatus: sourceStatus,
- onOpenEventStream: onOpenEventStream,
- unitsTriggerFocus: unitsTriggerFocus,
- onCloseHangar: onCloseHangar,
- );
- }
- return _DesktopCommandDeck(
- width: width,
- height: height,
- layers: layers,
- onLayerChanged: (label, visible) => ref
- .read(tacticalControllerProvider.notifier)
- .toggleOverlay(_overlayForLabel(label), visible),
- selectedUnit: selectedUnit,
- selectedUnitIds: tactical.selectedUnitIds,
- onUnitSelected: onUnitSelected,
- tactical: tactical,
- workspaceUnits: workspaceUnits,
- onAction: onAction,
- sourceLabel: sourceLabel,
- sourceStatus: sourceStatus,
- onOpenEventStream: onOpenEventStream,
- unitsTriggerFocus: unitsTriggerFocus,
- onCloseHangar: onCloseHangar,
- );
- }
-}
-
-/// The deck's rendering contract excludes the uncommitted hangar draft. This
-/// lets a name-field update rebuild only [_UnitHangarDock], not FPV, chrome,
-/// map controls, or the selection console. Fields are compared by identity:
-/// the tactical reducer deliberately preserves unchanged immutable records.
-class TacticalSceneRenderState {
- const TacticalSceneRenderState({
- required this.selectedUnitIds,
- required this.mode,
- required this.panel,
- required this.drawings,
- required this.markers,
- required this.selectedDrawingId,
- required this.selectedMarkerId,
- required this.overlays,
- });
-
- factory TacticalSceneRenderState.fromUiState(TacticalUiState state) =>
- TacticalSceneRenderState(
- selectedUnitIds: state.selectedUnitIds,
- mode: state.mode,
- panel: state.panel,
- drawings: state.drawings,
- markers: state.markers,
- selectedDrawingId: state.selectedDrawingId,
- selectedMarkerId: state.selectedMarkerId,
- overlays: state.overlays,
- );
-
- final Set selectedUnitIds;
- final TacticalMode mode;
- final TacticalPanel? panel;
- final List drawings;
- final List markers;
- final String? selectedDrawingId;
- final String? selectedMarkerId;
- final Set overlays;
-
- @override
- bool operator ==(Object other) =>
- other is TacticalSceneRenderState &&
- identical(selectedUnitIds, other.selectedUnitIds) &&
- identical(mode, other.mode) &&
- panel == other.panel &&
- identical(drawings, other.drawings) &&
- identical(markers, other.markers) &&
- selectedDrawingId == other.selectedDrawingId &&
- selectedMarkerId == other.selectedMarkerId &&
- identical(overlays, other.overlays);
-
- @override
- int get hashCode => Object.hash(
- identityHashCode(selectedUnitIds),
- identityHashCode(mode),
- panel,
- identityHashCode(drawings),
- identityHashCode(markers),
- selectedDrawingId,
- selectedMarkerId,
- identityHashCode(overlays),
- );
-}
-
-/// Projection consumed by static tactical chrome. It intentionally omits the
-/// uncommitted [UnitCreationDraft], enabling local setup edits without a
-/// map/deck rebuild.
-final tacticalSceneRenderStateProvider = Provider(
- (ref) => TacticalSceneRenderState.fromUiState(
- ref.watch(tacticalControllerProvider),
- ),
-);
-
-class _TacticalEditLayer extends ConsumerWidget {
- const _TacticalEditLayer({
- required this.onAction,
- required this.onStrokeComplete,
- required this.onNudgeCursor,
- required this.onCommitCursor,
- required this.onFinishKeyboardPencil,
- required this.onConfigurePencil,
- required this.onConfigureMarker,
- required this.onCommitMarkerName,
- });
-
- final ValueChanged<_TacticalAction> onAction;
- final ValueChanged> onStrokeComplete;
- final void Function(double dx, double dy) onNudgeCursor;
- final VoidCallback onCommitCursor;
- final VoidCallback onFinishKeyboardPencil;
- final void Function({
- double? hue,
- double? saturation,
- double? brightness,
- double? thickness,
- })
- onConfigurePencil;
- final void Function({double? hue, double? saturation, double? brightness})
- onConfigureMarker;
- final ValueChanged onCommitMarkerName;
-
- @override
- Widget build(BuildContext context, WidgetRef ref) => _TacticalEditOverlay(
- tactical: ref.watch(tacticalControllerProvider),
- onAction: onAction,
- onStrokeComplete: onStrokeComplete,
- cursor: ref.watch(tacticalPointerPreviewProvider).point,
- onNudgeCursor: onNudgeCursor,
- onCommitCursor: onCommitCursor,
- onFinishKeyboardPencil: onFinishKeyboardPencil,
- onConfigurePencil: onConfigurePencil,
- onConfigureMarker: onConfigureMarker,
- onCommitMarkerName: onCommitMarkerName,
- );
-}
-
-enum _TacticalAction {
- addUnit,
- openFleet,
- closeFleet,
- openUnitSetup,
- closeUnitHangar,
- clearSelection,
- resetView,
- selectTool,
- pencilTool,
- markerTool,
- clearSceneObjects,
- removeSelectedSceneObject,
- cancel,
-}
-
-class _NudgeSceneCursorIntent extends Intent {
- const _NudgeSceneCursorIntent(this.dx, this.dy);
-
- final double dx;
- final double dy;
-}
-
-class _CommitSceneCursorIntent extends Intent {
- const _CommitSceneCursorIntent();
-}
-
-Set _layerNames(Set overlays) => {
- if (overlays.contains(TacticalMapOverlay.drones)) 'Drones',
-};
-
-TacticalMapOverlay _overlayForLabel(String label) => switch (label) {
- 'Drones' => TacticalMapOverlay.drones,
- _ => throw ArgumentError.value(label, 'label', 'Unknown tactical overlay'),
-};
-
-class _SatelliteMapBackground extends StatelessWidget {
- const _SatelliteMapBackground();
-
- @override
- Widget build(BuildContext context) => ExcludeSemantics(
- child: Image.asset(
- 'assets/test.png',
- fit: BoxFit.cover,
- cacheWidth: 1920,
- filterQuality: FilterQuality.medium,
- errorBuilder: (context, error, stackTrace) => const ColoredBox(
- color: tacticalBlack,
- child: Center(
- child: _UnavailableVisual(
- icon: Icons.public_off_outlined,
- label: 'DEMO MAP VISUAL UNAVAILABLE',
- ),
- ),
- ),
- ),
- );
-}
-
-class _MapAtmosphere extends StatelessWidget {
- const _MapAtmosphere();
-
- @override
- Widget build(BuildContext context) => IgnorePointer(
- child: DecoratedBox(
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.topCenter,
- end: Alignment.bottomCenter,
- colors: [
- tacticalBlack.withValues(alpha: .36),
- tacticalBlack.withValues(alpha: .72),
- ],
- ),
- ),
- child: const CustomPaint(painter: _MapGridPainter()),
- ),
- );
-}
-
-class _DemoDisclosureBanner extends StatelessWidget {
- const _DemoDisclosureBanner();
-
- @override
- Widget build(BuildContext context) => Semantics(
- container: true,
- label: 'Local scene data. No vehicle link. Command authority unavailable.',
- child: _TacticalGlass(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- borderRadius: BorderRadius.circular(12),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.science_outlined, color: accentOrange, size: 16),
- const SizedBox(width: 8),
- Flexible(
- child: _HudLabel(
- 'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE',
- color: accentOrange,
- ),
- ),
- ],
- ),
- ),
- );
-}
-
-class _UnavailableVisual extends StatelessWidget {
- const _UnavailableVisual({required this.icon, required this.label});
-
- final IconData icon;
- final String label;
-
- @override
- Widget build(BuildContext context) => Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(icon, color: textSecondary),
- const SizedBox(height: 8),
- _HudLabel(label, color: textSecondary),
- ],
- );
-}
-
-class _DemoMapOverlays extends ConsumerWidget {
- const _DemoMapOverlays({
- required this.layers,
- required this.drawings,
- required this.markers,
- required this.draftPencil,
- required this.draftMarker,
- required this.localUnits,
- required this.selectedUnitIds,
- required this.onUnitSelected,
- required this.onSelectionRect,
- required this.onMarkerSelected,
- });
-
- final Set layers;
- final List drawings;
- final List markers;
- final TacticalPencilMode? draftPencil;
- final TacticalMarkerMode? draftMarker;
- final List localUnits;
- final Set selectedUnitIds;
- final ValueChanged onUnitSelected;
- final void Function(LocalScenePoint start, LocalScenePoint end)
- onSelectionRect;
- final ValueChanged onMarkerSelected;
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final pointerPreview = ref.watch(tacticalPointerPreviewProvider);
- return Stack(
- children: [
- Positioned.fill(
- child: IgnorePointer(
- child: ExcludeSemantics(
- child: RepaintBoundary(
- child: CustomPaint(
- key: const Key('tactical-map-overlay-painter'),
- painter: _DemoMapOverlayPainter(
- drawings: drawings,
- markers: markers,
- draftPencil: draftPencil,
- draftMarker: draftMarker,
- pointerPreview: pointerPreview.point,
- previewStroke: pointerPreview.stroke,
- ),
- ),
- ),
- ),
- ),
- ),
- Positioned.fill(child: _MapSelectionInput(onComplete: onSelectionRect)),
- if (layers.contains('Drones'))
- for (final unit in _fleet)
- Align(
- alignment: _unitMapAnchors[unit.id]!,
- child: _DemoUnitMapMarker(
- unit: unit,
- selected: selectedUnitIds.contains(unit.id),
- onTap: () => onUnitSelected(unit.id),
- ),
- ),
- for (final unit in localUnits)
- Align(
- alignment: Alignment(
- unit.localPlacement.x * 2 - 1,
- unit.localPlacement.y * 2 - 1,
- ),
- child: _LocalUnitMapMarker(
- unit: unit,
- selected: selectedUnitIds.contains(unit.localUnitId),
- onTap: () => onUnitSelected(unit.localUnitId),
- ),
- ),
- for (final marker in markers)
- Align(
- alignment: Alignment(
- marker.point.x * 2 - 1,
- marker.point.y * 2 - 1,
- ),
- child: _LocalSceneMarkerPin(
- marker: marker,
- onTap: () => onMarkerSelected(marker.id),
- ),
- ),
- ],
- );
- }
-}
-
-class _LocalSceneMarkerPin extends StatelessWidget {
- const _LocalSceneMarkerPin({required this.marker, required this.onTap});
-
- final LocalSceneMarker marker;
- final VoidCallback onTap;
-
- @override
- Widget build(BuildContext context) => Semantics(
- button: true,
- label: 'Local marker ${marker.name}, session-only, not a vehicle location',
- child: TextButton.icon(
- key: Key('map-marker-${marker.id}'),
- onPressed: onTap,
- style: TextButton.styleFrom(
- foregroundColor: _markerColor(
- marker.hue,
- marker.saturation,
- marker.brightness,
- ),
- backgroundColor: panelBackground.withValues(alpha: .9),
- side: BorderSide(
- color: _markerColor(marker.hue, marker.saturation, marker.brightness),
- ),
- shape: const StadiumBorder(),
- ),
- icon: const Icon(Icons.location_on_outlined, size: 14),
- label: Text(
- marker.name,
- style: GoogleFonts.inter(fontSize: 8, fontWeight: FontWeight.w800),
- ),
- ),
- );
-}
-
-/// Pointer-only RTS box selection. Keyboard users have equivalent selection
-/// through the roster and focusable map markers. Motion is coalesced to one
-/// paint per frame and never enters the tactical state reducer until release.
-class _MapSelectionInput extends StatefulWidget {
- const _MapSelectionInput({required this.onComplete});
-
- final void Function(LocalScenePoint start, LocalScenePoint end) onComplete;
-
- @override
- State<_MapSelectionInput> createState() => _MapSelectionInputState();
-}
-
-class _MapSelectionInputState extends State<_MapSelectionInput> {
- LocalScenePoint? _start;
- LocalScenePoint? _current;
- LocalScenePoint? _pending;
- bool _frameScheduled = false;
-
- @override
- Widget build(BuildContext context) => Listener(
- behavior: HitTestBehavior.translucent,
- onPointerDown: (event) {
- if (event.buttons != kPrimaryButton) {
- return;
- }
- final point = _pointFor(event.position);
- if (point == null) {
- return;
- }
- setState(() {
- _start = point;
- _current = point;
- });
- },
- onPointerMove: (event) {
- if (_start == null) {
- return;
- }
- _pending = _pointFor(event.position);
- if (_frameScheduled || _pending == null) {
- return;
- }
- _frameScheduled = true;
- SchedulerBinding.instance.scheduleFrameCallback((_) {
- _frameScheduled = false;
- final point = _pending;
- _pending = null;
- if (!mounted || point == null || _start == null) {
- return;
- }
- setState(() => _current = point);
- });
- },
- onPointerUp: (event) {
- final start = _start;
- final end = _pointFor(event.position);
- setState(() {
- _start = null;
- _current = null;
- });
- if (start == null || end == null) {
- return;
- }
- widget.onComplete(start, end);
- },
- onPointerCancel: (_) => setState(() {
- _start = null;
- _current = null;
- _pending = null;
- }),
- child: ExcludeSemantics(
- child: RepaintBoundary(
- child: CustomPaint(
- painter: _SelectionBoxPainter(start: _start, current: _current),
- child: const SizedBox.expand(),
- ),
- ),
- ),
- );
-
- LocalScenePoint? _pointFor(Offset globalPosition) {
- final box = context.findRenderObject() as RenderBox?;
- if (box == null || box.size.isEmpty) {
- return null;
- }
- final local = box.globalToLocal(globalPosition);
- return LocalScenePoint(
- x: (local.dx / box.size.width).clamp(0.0, 1.0),
- y: (local.dy / box.size.height).clamp(0.0, 1.0),
- );
- }
-}
-
-class _SelectionBoxPainter extends CustomPainter {
- const _SelectionBoxPainter({required this.start, required this.current});
-
- final LocalScenePoint? start;
- final LocalScenePoint? current;
-
- @override
- void paint(Canvas canvas, Size size) {
- final origin = start;
- final end = current;
- if (origin == null || end == null) {
- return;
- }
- final rect = Rect.fromPoints(
- Offset(origin.x * size.width, origin.y * size.height),
- Offset(end.x * size.width, end.y * size.height),
- );
- canvas.drawRect(rect, Paint()..color = accentMint.withValues(alpha: .1));
- canvas.drawRect(
- rect,
- Paint()
- ..color = accentMint
- ..style = PaintingStyle.stroke
- ..strokeWidth = 1.5,
- );
- }
-
- @override
- bool shouldRepaint(covariant _SelectionBoxPainter oldDelegate) =>
- oldDelegate.start != start || oldDelegate.current != current;
-}
-
-/// A semantic hit target over the decorative local-scene marker. The marker
-/// selects only mock scene data; it never binds or commands a vehicle.
-class _DemoUnitMapMarker extends StatelessWidget {
- const _DemoUnitMapMarker({
- required this.unit,
- required this.selected,
- required this.onTap,
- });
-
- final _DroneData unit;
- final bool selected;
- final VoidCallback onTap;
-
- @override
- Widget build(BuildContext context) => Semantics(
- button: true,
- selected: selected,
- label: '${unit.id}, simulated local scene unit, not a vehicle position',
- child: TextButton.icon(
- key: Key('map-unit-${unit.id}'),
- onPressed: onTap,
- style: TextButton.styleFrom(
- foregroundColor: selected ? tacticalBlack : accentMint,
- backgroundColor: selected
- ? accentMint
- : panelBackground.withValues(alpha: .88),
- side: BorderSide(color: selected ? accentMint : panelBorder),
- shape: const StadiumBorder(),
- ),
- icon: const Icon(Icons.flight_outlined, size: 14),
- label: Text(
- unit.id,
- style: GoogleFonts.robotoMono(fontSize: 8, fontWeight: FontWeight.w700),
- ),
- ),
- );
-}
-
-class _LocalUnitMapMarker extends StatelessWidget {
- const _LocalUnitMapMarker({
- required this.unit,
- required this.selected,
- required this.onTap,
- });
-
- final FleetUnit unit;
- final bool selected;
- final VoidCallback onTap;
-
- @override
- Widget build(BuildContext context) => Semantics(
- button: true,
- selected: selected,
- label:
- '${unit.displayName}, local workspace unit, local layout only, no vehicle position reported',
- child: TextButton.icon(
- onPressed: onTap,
- style: TextButton.styleFrom(
- foregroundColor: selected ? tacticalBlack : accentMint,
- backgroundColor: selected
- ? accentMint
- : panelBackground.withValues(alpha: .9),
- side: BorderSide(color: selected ? accentMint : panelBorder),
- shape: const StadiumBorder(),
- ),
- icon: const Icon(Icons.flight_outlined, size: 15),
- label: Text(
- unit.displayName,
- style: GoogleFonts.robotoMono(fontSize: 8, fontWeight: FontWeight.w700),
- ),
- ),
- );
-}
-
-class _TacticalInspector extends StatelessWidget {
- const _TacticalInspector({
- required this.selectedUnitId,
- required this.selectedDemo,
- required this.workspaceUnits,
- });
-
- final String selectedUnitId;
- final _DroneData? selectedDemo;
- final List workspaceUnits;
-
- @override
- Widget build(BuildContext context) {
- final local = workspaceUnits.cast().firstWhere(
- (unit) => unit?.localUnitId == selectedUnitId,
- orElse: () => null,
- );
- final isLocal = local != null;
- final name = local?.displayName ?? selectedDemo?.id ?? selectedUnitId;
- return Semantics(
- container: true,
- label: '$name tactical inspector',
- child: _TacticalGlass(
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- const _HudLabel('SELECTION INSPECTOR'),
- const SizedBox(height: 6),
- _MonoText(
- name,
- color: textPrimary,
- size: 12,
- weight: FontWeight.w700,
- ),
- const SizedBox(height: 8),
- _InspectorFact(
- label: 'LOCAL CONTEXT',
- value: isLocal
- ? 'UNBOUND WORKSPACE UNIT'
- : 'SIMULATED LOCAL DATA',
- ),
- const _InspectorFact(
- label: 'REPORTED PLATFORM',
- value: 'NOT REPORTED',
- ),
- const _InspectorFact(
- label: 'COMPATIBILITY',
- value: 'NOT EVALUATED',
- ),
- const _InspectorFact(
- label: 'WRITE AUTHORITY',
- value: 'UNAVAILABLE',
- ),
- ],
- ),
- ),
- );
- }
-}
-
-class _InspectorFact extends StatelessWidget {
- const _InspectorFact({required this.label, required this.value});
-
- final String label;
- final String value;
-
- @override
- Widget build(BuildContext context) => Padding(
- padding: const EdgeInsets.only(top: 6),
- child: Row(
- children: [
- Expanded(child: _HudLabel(label)),
- Flexible(child: _HudLabel(value, color: textPrimary)),
- ],
- ),
- );
-}
-
-class _DrawingInspector extends StatelessWidget {
- const _DrawingInspector({required this.drawing, required this.onDelete});
-
- final LocalSceneDrawing drawing;
- final VoidCallback onDelete;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- const _HudLabel('SELECTION INSPECTOR'),
- const SizedBox(height: 6),
- _MonoText(
- 'LOCAL PENCIL DRAWING',
- color: _pencilColor(
- drawing.hue,
- drawing.saturation,
- drawing.brightness,
- ),
- size: 12,
- weight: FontWeight.w700,
- ),
- const SizedBox(height: 8),
- const _HudLabel(
- 'LOCAL SCENE ANNOTATION · NOT A VEHICLE LOCATION',
- color: accentOrange,
- ),
- const SizedBox(height: 4),
- Text(
- '${drawing.points.length} stroke point(s) · ${drawing.thickness.toStringAsFixed(0)} px · session only',
- ),
- const SizedBox(height: 10),
- _UnitActionButton(
- label: 'REMOVE LOCAL OVERLAY',
- icon: Icons.delete_outline,
- foreground: textPrimary,
- background: tacticalTransparent,
- onPressed: onDelete,
- ),
- ],
- ),
- );
-}
-
-class _MarkerInspector extends StatelessWidget {
- const _MarkerInspector({required this.marker, required this.onDelete});
-
- final LocalSceneMarker marker;
- final VoidCallback onDelete;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- const _HudLabel('SELECTION INSPECTOR'),
- const SizedBox(height: 6),
- _MonoText(
- marker.name,
- color: _markerColor(marker.hue, marker.saturation, marker.brightness),
- size: 12,
- weight: FontWeight.w700,
- ),
- const SizedBox(height: 8),
- const _HudLabel(
- 'LOCAL MARKER · NOT A VEHICLE LOCATION',
- color: accentOrange,
- ),
- const SizedBox(height: 4),
- const Text('Session only · not sent to a vehicle'),
- const SizedBox(height: 10),
- _UnitActionButton(
- label: 'REMOVE LOCAL MARKER',
- icon: Icons.delete_outline,
- foreground: textPrimary,
- background: tacticalTransparent,
- onPressed: onDelete,
- ),
- ],
- ),
- );
-}
-
-class _FleetWorkspaceRoster extends StatelessWidget {
- const _FleetWorkspaceRoster({
- required this.workspaceUnits,
- required this.tactical,
- required this.onSelected,
- required this.onClose,
- });
-
- final List workspaceUnits;
- final TacticalSceneRenderState tactical;
- final ValueChanged onSelected;
- final VoidCallback onClose;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- const Expanded(child: _HudLabel('LOCAL FLEET WORKSPACE')),
- IconButton(
- tooltip: 'Close local fleet workspace',
- onPressed: onClose,
- icon: const Icon(Icons.close, size: 18),
- ),
- ],
- ),
- const SizedBox(height: 4),
- const Text('Local units only. No vehicle link or authority.'),
- const SizedBox(height: 10),
- Expanded(
- child: ListView.separated(
- itemCount: workspaceUnits.length,
- separatorBuilder: (context, index) => const SizedBox(height: 6),
- itemBuilder: (context, index) {
- final unit = workspaceUnits[index];
- final selected = tactical.selectedUnitIds.contains(
- unit.localUnitId,
- );
- return Semantics(
- button: true,
- selected: selected,
- label: '${unit.displayName}, local workspace unit',
- child: OutlinedButton(
- onPressed: () => onSelected(unit.localUnitId),
- style: OutlinedButton.styleFrom(
- alignment: Alignment.centerLeft,
- foregroundColor: selected ? accentMint : textPrimary,
- side: BorderSide(
- color: selected ? accentMint : panelBorder,
- ),
- ),
- child: Text(unit.displayName),
- ),
- );
- },
- ),
- ),
- ],
- ),
- );
-}
-
-class _DesktopCommandDeck extends StatelessWidget {
- const _DesktopCommandDeck({
- required this.width,
- required this.height,
- required this.layers,
- required this.onLayerChanged,
- required this.selectedUnit,
- required this.selectedUnitIds,
- required this.onUnitSelected,
- required this.tactical,
- required this.workspaceUnits,
- required this.onAction,
- required this.sourceLabel,
- required this.sourceStatus,
- required this.onOpenEventStream,
- required this.unitsTriggerFocus,
- required this.onCloseHangar,
- });
-
- final double width;
- final double height;
- final Set layers;
- final void Function(String layer, bool enabled) onLayerChanged;
- final _DroneData? selectedUnit;
- final Set selectedUnitIds;
- final ValueChanged onUnitSelected;
- final TacticalSceneRenderState tactical;
- final List workspaceUnits;
- final ValueChanged<_TacticalAction> onAction;
- final String sourceLabel;
- final String sourceStatus;
- final VoidCallback onOpenEventStream;
- final FocusNode unitsTriggerFocus;
- final VoidCallback onCloseHangar;
-
- @override
- Widget build(BuildContext context) {
- final fpvWidth = math.min(560.0, width * .38);
- final fpvHeight = math.min(474.0, height * .52);
- const unitTrayHeight = 164.0;
- final hangarOpen =
- tactical.panel == TacticalPanel.unitHangar ||
- tactical.panel == TacticalPanel.unitSetup;
- // Setup adds an editable local-name form and the capability-gated
- // configuration explanation, so it grows without turning the horizontal
- // category hangar itself into a tall modal.
- final hangarHeight = tactical.panel == TacticalPanel.unitSetup
- ? 350.0
- : 252.0;
- final mapControlBottom = hangarOpen
- ? hangarHeight + 32
- : unitTrayHeight + 32;
-
- return Stack(
- children: [
- Positioned(
- left: 20,
- top: 16,
- right: 20,
- child: _TopNavigation(
- sourceLabel: sourceLabel,
- sourceStatus: sourceStatus,
- onOpenEventStream: onOpenEventStream,
- ),
- ),
- const Positioned(
- left: 20,
- top: 90,
- right: 20,
- child: _DemoDisclosureBanner(),
- ),
- if (selectedUnit != null)
- Positioned(
- right: 24,
- top: 136,
- width: fpvWidth,
- height: fpvHeight,
- child: _FpvPanel(selectedUnit: selectedUnit!),
- ),
- if (selectedUnitIds.length == 1)
- Positioned(
- right: 24,
- top: selectedUnit == null ? 136 : 136 + fpvHeight + 12,
- width: fpvWidth,
- child: _TacticalInspector(
- selectedUnitId: selectedUnitIds.single,
- selectedDemo: selectedUnit,
- workspaceUnits: workspaceUnits,
- ),
- ),
- if (selectedUnitIds.isEmpty && tactical.selectedDrawingId != null)
- Positioned(
- right: 24,
- top: 136,
- width: fpvWidth,
- child: _DrawingInspector(
- drawing: tactical.drawings.firstWhere(
- (drawing) => drawing.id == tactical.selectedDrawingId,
- ),
- onDelete: () =>
- onAction(_TacticalAction.removeSelectedSceneObject),
- ),
- ),
- if (selectedUnitIds.isEmpty && tactical.selectedMarkerId != null)
- Positioned(
- right: 24,
- top: 136,
- width: fpvWidth,
- child: _MarkerInspector(
- marker: tactical.markers.firstWhere(
- (marker) => marker.id == tactical.selectedMarkerId,
- ),
- onDelete: () =>
- onAction(_TacticalAction.removeSelectedSceneObject),
- ),
- ),
- if (tactical.panel == TacticalPanel.fleetRoster)
- Positioned(
- left: 24,
- top: 136,
- width: math.min(380, width * .3),
- bottom: mapControlBottom,
- child: _FleetWorkspaceRoster(
- workspaceUnits: workspaceUnits,
- tactical: tactical,
- onSelected: onUnitSelected,
- onClose: () => onAction(_TacticalAction.closeFleet),
- ),
- ),
- Positioned(
- left: 24,
- bottom: mapControlBottom,
- child: _MapEdgeCommandControls(
- tactical: tactical,
- layers: layers,
- onLayerChanged: onLayerChanged,
- onAction: onAction,
- unitsTriggerFocus: unitsTriggerFocus,
- ),
- ),
- if (hangarOpen)
- Positioned(
- left: 24,
- right: 24,
- bottom: 24,
- height: hangarHeight,
- child: _UnitHangarDock(onClose: onCloseHangar),
- )
- else
- Positioned(
- right: 24,
- bottom: 24,
- width: math.min(680, width * .48),
- height: unitTrayHeight,
- child: _UnitCommandTray(
- selectedUnit: selectedUnit,
- selectedUnitIds: selectedUnitIds,
- onUnitSelected: onUnitSelected,
- onAddUnit: () => onAction(_TacticalAction.addUnit),
- onOpenSetup: () => onAction(_TacticalAction.openUnitSetup),
- workspaceUnits: workspaceUnits,
- ),
- ),
- ],
- );
- }
-}
-
-class _CompactCommandDeck extends StatelessWidget {
- const _CompactCommandDeck({
- required this.layers,
- required this.onLayerChanged,
- required this.selectedUnit,
- required this.selectedUnitIds,
- required this.onUnitSelected,
- required this.tactical,
- required this.workspaceUnits,
- required this.onAction,
- required this.sourceLabel,
- required this.sourceStatus,
- required this.onOpenEventStream,
- required this.unitsTriggerFocus,
- required this.onCloseHangar,
- });
-
- final Set layers;
- final void Function(String layer, bool enabled) onLayerChanged;
- final _DroneData? selectedUnit;
- final Set selectedUnitIds;
- final ValueChanged onUnitSelected;
- final TacticalSceneRenderState tactical;
- final List workspaceUnits;
- final ValueChanged<_TacticalAction> onAction;
- final String sourceLabel;
- final String sourceStatus;
- final VoidCallback onOpenEventStream;
- final FocusNode unitsTriggerFocus;
- final VoidCallback onCloseHangar;
-
- @override
- Widget build(BuildContext context) => SafeArea(
- child: ListView(
- padding: const EdgeInsets.all(16),
- children: [
- _TopNavigation(
- compact: true,
- sourceLabel: sourceLabel,
- sourceStatus: sourceStatus,
- onOpenEventStream: onOpenEventStream,
- ),
- const SizedBox(height: 16),
- const _DemoDisclosureBanner(),
- const SizedBox(height: 16),
- if (selectedUnit != null)
- SizedBox(height: 350, child: _FpvPanel(selectedUnit: selectedUnit!)),
- const SizedBox(height: 16),
- _CompactTacticalActions(
- tactical: tactical,
- layers: layers,
- onLayerChanged: onLayerChanged,
- onAction: onAction,
- unitsTriggerFocus: unitsTriggerFocus,
- ),
- const SizedBox(height: 16),
- _UnitCommandTray(
- selectedUnit: selectedUnit,
- selectedUnitIds: selectedUnitIds,
- onUnitSelected: onUnitSelected,
- onAddUnit: () => onAction(_TacticalAction.addUnit),
- onOpenSetup: () => onAction(_TacticalAction.openUnitSetup),
- workspaceUnits: workspaceUnits,
- ),
- if (tactical.panel == TacticalPanel.unitHangar ||
- tactical.panel == TacticalPanel.unitSetup) ...[
- const SizedBox(height: 16),
- SizedBox(
- height: 338,
- child: _UnitHangarDock(compact: true, onClose: onCloseHangar),
- ),
- ],
- ],
- ),
- );
-}
-
-/// RTS action taxonomy: Units changes only the local workspace; Cursor
-/// selects or creates session-local scene data; View changes rendering.
-/// None of these actions sends a vehicle command or consumes live telemetry.
-class _MapEdgeCommandControls extends StatelessWidget {
- const _MapEdgeCommandControls({
- required this.tactical,
- required this.layers,
- required this.onLayerChanged,
- required this.onAction,
- required this.unitsTriggerFocus,
- });
-
- final TacticalSceneRenderState tactical;
- final Set layers;
- final void Function(String layer, bool enabled) onLayerChanged;
- final ValueChanged<_TacticalAction> onAction;
- final FocusNode unitsTriggerFocus;
-
- @override
- Widget build(BuildContext context) => Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _TacticalMenuAnchor(
- triggerKey: const Key('units-menu'),
- focusNode: unitsTriggerFocus,
- label: 'Units',
- icon: Icons.groups_2_outlined,
- items: [
- _MenuAction(
- label: 'Add local unit',
- detail: 'Creates local workspace context only',
- icon: Icons.add_circle_outline,
- onSelected: () => onAction(_TacticalAction.addUnit),
- ),
- _MenuAction(
- label: 'Fleet workspace',
- detail: 'Local unit roster is in the selection console',
- icon: Icons.view_list_outlined,
- onSelected: () => onAction(_TacticalAction.openFleet),
- ),
- const _MenuAction(
- label: 'Live fleet',
- detail: 'Requires verified source-to-unit binding',
- icon: Icons.radar_outlined,
- enabled: false,
- ),
- ],
- ),
- const SizedBox(height: 8),
- _TacticalMenuAnchor(
- triggerKey: const Key('cursor-menu'),
- label: 'Mode: ${_modeLabel(tactical.mode)}',
- icon: switch (tactical.mode) {
- TacticalPencilMode() => Icons.draw_outlined,
- TacticalMarkerMode() => Icons.location_on_outlined,
- _ => Icons.ads_click_outlined,
- },
- items: [
- _MenuAction(
- label: 'Selection cursor',
- detail: 'Default · click or draw a rectangle to select units',
- icon: Icons.ads_click_outlined,
- onSelected: () => onAction(_TacticalAction.selectTool),
- ),
- _MenuAction(
- label: 'Pencil cursor',
- detail:
- 'Draw session-local marks with color and thickness controls',
- icon: Icons.draw_outlined,
- onSelected: () => onAction(_TacticalAction.pencilTool),
- ),
- _MenuAction(
- label: 'Marker cursor',
- detail: 'Click a local point, then give the marker a name',
- icon: Icons.location_on_outlined,
- onSelected: () => onAction(_TacticalAction.markerTool),
- ),
- ],
- ),
- const SizedBox(height: 8),
- _TacticalMenuAnchor(
- triggerKey: const Key('view-menu'),
- label: 'View',
- icon: Icons.layers_outlined,
- items: [
- for (final layer in ['Drones'])
- _MenuAction(
- label: '${layers.contains(layer) ? 'Hide' : 'Show'} $layer',
- detail: 'Changes the local tactical scene',
- icon: layers.contains(layer)
- ? Icons.visibility_outlined
- : Icons.visibility_off_outlined,
- onSelected: () => onLayerChanged(layer, !layers.contains(layer)),
- ),
- _MenuAction(
- label: 'Reset local view',
- detail: 'Returns the local scene to its default view',
- icon: Icons.center_focus_strong_outlined,
- onSelected: () => onAction(_TacticalAction.resetView),
- ),
- _MenuAction(
- label: 'Cancel selection',
- detail: 'Clears the current local selection',
- icon: Icons.close_outlined,
- onSelected: () => onAction(_TacticalAction.clearSelection),
- ),
- _MenuAction(
- label: 'Clear local scene',
- detail: tactical.drawings.isEmpty && tactical.markers.isEmpty
- ? 'No local drawings or markers'
- : 'Removes session-local pencil drawings and markers',
- icon: Icons.layers_clear_outlined,
- enabled:
- tactical.drawings.isNotEmpty || tactical.markers.isNotEmpty,
- onSelected: () => onAction(_TacticalAction.clearSceneObjects),
- ),
- ],
- ),
- ],
- );
-}
-
-class _TacticalMenuAnchor extends StatelessWidget {
- const _TacticalMenuAnchor({
- required this.triggerKey,
- required this.label,
- required this.icon,
- required this.items,
- this.focusNode,
- });
-
- final Key triggerKey;
- final String label;
- final IconData icon;
- final List<_MenuAction> items;
- final FocusNode? focusNode;
-
- @override
- Widget build(BuildContext context) => MenuAnchor(
- alignmentOffset: const Offset(0, -4),
- menuChildren: [
- for (final item in items)
- MenuItemButton(
- onPressed: item.enabled ? item.onSelected : null,
- leadingIcon: Icon(item.icon, size: 18),
- child: SizedBox(
- width: 245,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(item.label),
- const SizedBox(height: 3),
- Text(
- item.detail,
- style: Theme.of(
- context,
- ).textTheme.labelSmall?.copyWith(color: textSecondary),
- ),
- ],
- ),
- ),
- ),
- ],
- builder: (context, controller, child) => _MapEdgeTrigger(
- key: triggerKey,
- label: label,
- icon: icon,
- focusNode: focusNode,
- onPressed: () =>
- controller.isOpen ? controller.close() : controller.open(),
- ),
- );
-}
-
-class _MenuAction {
- const _MenuAction({
- required this.label,
- required this.detail,
- required this.icon,
- this.enabled = true,
- this.onSelected,
- });
-
- final String label;
- final String detail;
- final IconData icon;
- final bool enabled;
- final VoidCallback? onSelected;
-}
-
-class _MapEdgeTrigger extends StatelessWidget {
- const _MapEdgeTrigger({
- super.key,
- required this.label,
- required this.icon,
- required this.onPressed,
- this.focusNode,
- });
-
- final String label;
- final IconData icon;
- final VoidCallback onPressed;
- final FocusNode? focusNode;
-
- @override
- Widget build(BuildContext context) => Semantics(
- button: true,
- label: label,
- child: Tooltip(
- message: label,
- child: SizedBox(
- width: 48,
- height: 48,
- child: _TacticalGlass(
- borderRadius: BorderRadius.circular(12),
- child: TextButton(
- focusNode: focusNode,
- onPressed: onPressed,
- style: TextButton.styleFrom(
- foregroundColor: accentMint,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(11),
- ),
- ),
- child: Icon(icon, size: 21),
- ),
- ),
- ),
- ),
- );
-}
-
-class _CompactTacticalActions extends StatelessWidget {
- const _CompactTacticalActions({
- required this.tactical,
- required this.layers,
- required this.onLayerChanged,
- required this.onAction,
- required this.unitsTriggerFocus,
- });
-
- final TacticalSceneRenderState tactical;
- final Set layers;
- final void Function(String layer, bool enabled) onLayerChanged;
- final ValueChanged<_TacticalAction> onAction;
- final FocusNode unitsTriggerFocus;
-
- @override
- Widget build(BuildContext context) => Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- _CompactActionButton(
- label: 'Units',
- icon: Icons.groups_2_outlined,
- focusNode: unitsTriggerFocus,
- onPressed: () => _showActionSheet(
- context,
- title: 'Units',
- actions: [
- _MenuAction(
- label: 'Add local unit',
- detail: 'Creates local workspace context only',
- icon: Icons.add_circle_outline,
- onSelected: () => onAction(_TacticalAction.addUnit),
- ),
- _MenuAction(
- label: 'Fleet workspace',
- detail: 'Open the local unit roster',
- icon: Icons.view_list_outlined,
- onSelected: () => onAction(_TacticalAction.openFleet),
- ),
- const _MenuAction(
- label: 'Live fleet',
- detail: 'Requires verified source-to-unit binding',
- icon: Icons.radar_outlined,
- enabled: false,
- ),
- ],
- ),
- ),
- _CompactActionButton(
- label: 'Mode: ${_modeLabel(tactical.mode)}',
- icon: switch (tactical.mode) {
- TacticalPencilMode() => Icons.draw_outlined,
- TacticalMarkerMode() => Icons.location_on_outlined,
- _ => Icons.ads_click_outlined,
- },
- onPressed: () => _showActionSheet(
- context,
- title: 'Cursor',
- actions: [
- _MenuAction(
- label: 'Selection cursor',
- detail: 'Default · click or draw a rectangle to select units',
- icon: Icons.ads_click_outlined,
- onSelected: () => onAction(_TacticalAction.selectTool),
- ),
- _MenuAction(
- label: 'Pencil cursor',
- detail:
- 'Draw session-local marks with color and thickness controls',
- icon: Icons.draw_outlined,
- onSelected: () => onAction(_TacticalAction.pencilTool),
- ),
- _MenuAction(
- label: 'Marker cursor',
- detail: 'Click a local point, then give the marker a name',
- icon: Icons.location_on_outlined,
- onSelected: () => onAction(_TacticalAction.markerTool),
- ),
- ],
- ),
- ),
- _CompactActionButton(
- label: 'View',
- icon: Icons.layers_outlined,
- onPressed: () =>
- _showViewSheet(context, tactical, layers, onLayerChanged, onAction),
- ),
- ],
- );
-}
-
-class _CompactActionButton extends StatelessWidget {
- const _CompactActionButton({
- required this.label,
- required this.icon,
- required this.onPressed,
- this.focusNode,
- });
-
- final String label;
- final IconData icon;
- final VoidCallback onPressed;
- final FocusNode? focusNode;
-
- @override
- Widget build(BuildContext context) => Semantics(
- button: true,
- label: label,
- child: Tooltip(
- message: label,
- child: SizedBox(
- width: 48,
- height: 48,
- child: _TacticalGlass(
- borderRadius: BorderRadius.circular(12),
- child: TextButton(
- focusNode: focusNode,
- onPressed: onPressed,
- style: TextButton.styleFrom(
- foregroundColor: accentMint,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(11),
- ),
- ),
- child: Icon(icon, size: 21),
- ),
- ),
- ),
- ),
- );
-}
-
-Future _showActionSheet(
- BuildContext context, {
- required String title,
- required List<_MenuAction> actions,
-}) => showModalBottomSheet(
- context: context,
- backgroundColor: tacticalBlack,
- isScrollControlled: true,
- builder: (context) => SafeArea(
- child: SizedBox(
- height: MediaQuery.sizeOf(context).height * .82,
- child: Padding(
- padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Expanded(
- child: Text(
- title,
- style: Theme.of(context).textTheme.titleLarge,
- ),
- ),
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Close'),
- ),
- ],
- ),
- const SizedBox(height: 12),
- Expanded(
- child: ListView(
- key: const Key('tactical-action-sheet-scroll'),
- children: [
- for (final action in actions)
- ListTile(
- enabled: action.enabled,
- leading: Icon(action.icon),
- title: Text(action.label),
- subtitle: Text(action.detail),
- onTap: action.enabled
- ? () {
- Navigator.pop(context);
- action.onSelected?.call();
- }
- : null,
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
- ),
-);
-
-Future _showViewSheet(
- BuildContext context,
- TacticalSceneRenderState tactical,
- Set layers,
- void Function(String layer, bool enabled) onLayerChanged,
- ValueChanged<_TacticalAction> onAction,
-) => _showActionSheet(
- context,
- title: 'View',
- actions: [
- for (final label in ['Drones'])
- _MenuAction(
- label: '${layers.contains(label) ? 'Hide' : 'Show'} $label',
- detail: 'Changes the local tactical scene',
- icon: layers.contains(label)
- ? Icons.visibility_outlined
- : Icons.visibility_off_outlined,
- onSelected: () => onLayerChanged(label, !layers.contains(label)),
- ),
- _MenuAction(
- label: 'Reset local view',
- detail: 'Returns the local scene to its default view',
- icon: Icons.center_focus_strong_outlined,
- onSelected: () => onAction(_TacticalAction.resetView),
- ),
- _MenuAction(
- label: 'Cancel selection',
- detail: 'Clears the current local selection',
- icon: Icons.close_outlined,
- onSelected: () => onAction(_TacticalAction.clearSelection),
- ),
- _MenuAction(
- label: 'Clear local scene',
- detail: tactical.drawings.isEmpty && tactical.markers.isEmpty
- ? 'No local drawings or markers'
- : 'Removes session-local pencil drawings and markers',
- icon: Icons.layers_clear_outlined,
- enabled: tactical.drawings.isNotEmpty || tactical.markers.isNotEmpty,
- onSelected: () => onAction(_TacticalAction.clearSceneObjects),
- ),
- ],
-);
-
-class _TacticalEditOverlay extends StatelessWidget {
- const _TacticalEditOverlay({
- required this.tactical,
- required this.onAction,
- required this.onStrokeComplete,
- required this.cursor,
- required this.onNudgeCursor,
- required this.onCommitCursor,
- required this.onFinishKeyboardPencil,
- required this.onConfigurePencil,
- required this.onConfigureMarker,
- required this.onCommitMarkerName,
- });
-
- final TacticalUiState tactical;
- final ValueChanged<_TacticalAction> onAction;
- final ValueChanged> onStrokeComplete;
- final LocalScenePoint? cursor;
- final void Function(double dx, double dy) onNudgeCursor;
- final VoidCallback onCommitCursor;
- final VoidCallback onFinishKeyboardPencil;
- final void Function({
- double? hue,
- double? saturation,
- double? brightness,
- double? thickness,
- })
- onConfigurePencil;
- final void Function({double? hue, double? saturation, double? brightness})
- onConfigureMarker;
- final ValueChanged onCommitMarkerName;
-
- @override
- Widget build(BuildContext context) {
- final mode = tactical.mode;
- if (mode is TacticalPencilMode) {
- return _PencilToolOverlay(
- mode: mode,
- cursor: cursor,
- onStrokeComplete: onStrokeComplete,
- onCancel: () => onAction(_TacticalAction.cancel),
- onNudgeCursor: onNudgeCursor,
- onCommitCursor: onCommitCursor,
- onFinishKeyboardPencil: onFinishKeyboardPencil,
- onConfigure: onConfigurePencil,
- );
- }
- if (mode is TacticalMarkerMode) {
- return _MarkerToolOverlay(
- mode: mode,
- cursor: cursor,
- onCancel: () => onAction(_TacticalAction.cancel),
- onNudgeCursor: onNudgeCursor,
- onCommitCursor: onCommitCursor,
- onConfigure: onConfigureMarker,
- onSaveName: onCommitMarkerName,
- onSelectTool: () => onAction(_TacticalAction.selectTool),
- );
- }
- return const SizedBox.shrink();
- }
-}
-
-class _PencilToolOverlay extends StatelessWidget {
- const _PencilToolOverlay({
- required this.mode,
- required this.cursor,
- required this.onStrokeComplete,
- required this.onCancel,
- required this.onNudgeCursor,
- required this.onCommitCursor,
- required this.onFinishKeyboardPencil,
- required this.onConfigure,
- });
-
- final TacticalPencilMode mode;
- final LocalScenePoint? cursor;
- final ValueChanged> onStrokeComplete;
- final VoidCallback onCancel;
- final void Function(double dx, double dy) onNudgeCursor;
- final VoidCallback onCommitCursor;
- final VoidCallback onFinishKeyboardPencil;
- final void Function({
- double? hue,
- double? saturation,
- double? brightness,
- double? thickness,
- })
- onConfigure;
-
- @override
- Widget build(BuildContext context) => Stack(
- children: [
- Positioned.fill(
- child: _TacticalPointerInput(
- mode: mode,
- onPoint: (_) {},
- onStrokeComplete: onStrokeComplete,
- onCancel: onCancel,
- ),
- ),
- Positioned(
- left: 24,
- right: 24,
- top: 116,
- child: FocusTraversalGroup(
- child: FocusScope(
- autofocus: true,
- child: Shortcuts(
- shortcuts: const {
- SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
- SingleActivator(LogicalKeyboardKey.arrowLeft):
- _NudgeSceneCursorIntent(-.04, 0),
- SingleActivator(LogicalKeyboardKey.arrowRight):
- _NudgeSceneCursorIntent(.04, 0),
- SingleActivator(LogicalKeyboardKey.arrowUp):
- _NudgeSceneCursorIntent(0, -.04),
- SingleActivator(LogicalKeyboardKey.arrowDown):
- _NudgeSceneCursorIntent(0, .04),
- SingleActivator(LogicalKeyboardKey.enter):
- _CommitSceneCursorIntent(),
- },
- child: Actions(
- actions: {
- DismissIntent: CallbackAction(
- onInvoke: (_) {
- onCancel();
- return null;
- },
- ),
- _NudgeSceneCursorIntent:
- CallbackAction<_NudgeSceneCursorIntent>(
- onInvoke: (intent) {
- onNudgeCursor(intent.dx, intent.dy);
- return null;
- },
- ),
- _CommitSceneCursorIntent:
- CallbackAction<_CommitSceneCursorIntent>(
- onInvoke: (_) {
- onCommitCursor();
- return null;
- },
- ),
- },
- child: Semantics(
- container: true,
- label: 'Pencil cursor. Session-only local drawing tool.',
- child: _PencilToolbar(
- mode: mode,
- cursor: cursor ?? const LocalScenePoint(x: .5, y: .5),
- onConfigure: onConfigure,
- onAddPoint: onCommitCursor,
- onFinish: onFinishKeyboardPencil,
- onCancel: onCancel,
- ),
- ),
- ),
- ),
- ),
- ),
- ),
- ],
- );
-}
-
-class _PencilToolbar extends StatelessWidget {
- const _PencilToolbar({
- required this.mode,
- required this.cursor,
- required this.onConfigure,
- required this.onAddPoint,
- required this.onFinish,
- required this.onCancel,
- });
-
- final TacticalPencilMode mode;
- final LocalScenePoint cursor;
- final void Function({
- double? hue,
- double? saturation,
- double? brightness,
- double? thickness,
- })
- onConfigure;
- final VoidCallback onAddPoint;
- final VoidCallback onFinish;
- final VoidCallback onCancel;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
- child: Wrap(
- spacing: 14,
- runSpacing: 8,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- const _HudLabel('PENCIL CURSOR', color: accentOrange),
- _ColorSwatches(
- toolLabel: 'Pencil',
- keyPrefix: 'pencil',
- hue: mode.hue,
- onSelected: (hue) => onConfigure(hue: hue),
- ),
- SizedBox(
- width: 160,
- child: Semantics(
- label: 'Pencil color hue',
- child: Slider(
- key: const Key('pencil-hue'),
- value: mode.hue,
- min: 0,
- max: 360,
- activeColor: _pencilColor(
- mode.hue,
- mode.saturation,
- mode.brightness,
- ),
- onChanged: (hue) => onConfigure(hue: hue),
- ),
- ),
- ),
- SizedBox(
- width: 130,
- child: Semantics(
- label: 'Pencil thickness',
- child: Slider(
- key: const Key('pencil-thickness'),
- value: mode.thickness,
- min: 1,
- max: 12,
- divisions: 11,
- activeColor: _pencilColor(
- mode.hue,
- mode.saturation,
- mode.brightness,
- ),
- onChanged: (thickness) => onConfigure(thickness: thickness),
- ),
- ),
- ),
- _HudLabel('${mode.thickness.toStringAsFixed(0)} PX'),
- _StyleSlider(
- key: const Key('pencil-saturation'),
- label: 'Pencil saturation',
- value: mode.saturation,
- color: _pencilColor(mode.hue, 1, mode.brightness),
- onChanged: (value) => onConfigure(saturation: value),
- ),
- _StyleSlider(
- key: const Key('pencil-brightness'),
- label: 'Pencil brightness',
- value: mode.brightness,
- color: _pencilColor(mode.hue, mode.saturation, 1),
- onChanged: (value) => onConfigure(brightness: value),
- ),
- _KeyboardMapCursor(
- cursor: cursor,
- isDrawing: true,
- canFinishDrawing: mode.keyboardPoints.length >= 2,
- pointCount: mode.keyboardPoints.length,
- onCommit: onAddPoint,
- onFinish: onFinish,
- ),
- _UnitActionButton(
- label: 'SELECTION CURSOR',
- icon: Icons.ads_click_outlined,
- foreground: textPrimary,
- background: tacticalTransparent,
- onPressed: onCancel,
- ),
- ],
- ),
- );
-}
-
-class _ColorSwatches extends StatelessWidget {
- const _ColorSwatches({
- required this.toolLabel,
- required this.keyPrefix,
- required this.hue,
- required this.onSelected,
- this.colorForHue = _pencilColor,
- });
-
- final String toolLabel;
- final String keyPrefix;
- final double hue;
- final ValueChanged onSelected;
- final Color Function(double hue) colorForHue;
-
- @override
- Widget build(BuildContext context) => Wrap(
- spacing: 6,
- children: [
- for (final swatchHue in [0.0, 26.0, 58.0, 164.0, 208.0, 282.0, 332.0])
- Semantics(
- button: true,
- selected: (hue - swatchHue).abs() < 2,
- label: '$toolLabel color ${swatchHue.round()} degrees',
- child: InkWell(
- key: Key('$keyPrefix-swatch-${swatchHue.round()}'),
- onTap: () => onSelected(swatchHue),
- borderRadius: BorderRadius.circular(12),
- child: Container(
- width: 24,
- height: 24,
- decoration: BoxDecoration(
- color: colorForHue(swatchHue),
- shape: BoxShape.circle,
- border: Border.all(
- color: (hue - swatchHue).abs() < 2
- ? textPrimary
- : panelBorder,
- width: 2,
- ),
- ),
- ),
- ),
- ),
- ],
- );
-}
-
-Color _pencilColor(double hue, [double saturation = .78, double value = .94]) =>
- HSVColor.fromAHSV(1, hue, saturation, value).toColor();
-
-class _MarkerToolOverlay extends StatelessWidget {
- const _MarkerToolOverlay({
- required this.mode,
- required this.cursor,
- required this.onCancel,
- required this.onNudgeCursor,
- required this.onCommitCursor,
- required this.onConfigure,
- required this.onSaveName,
- required this.onSelectTool,
- });
-
- final TacticalMarkerMode mode;
- final LocalScenePoint? cursor;
- final VoidCallback onCancel;
- final void Function(double dx, double dy) onNudgeCursor;
- final VoidCallback onCommitCursor;
- final void Function({double? hue, double? saturation, double? brightness})
- onConfigure;
- final ValueChanged onSaveName;
- final VoidCallback onSelectTool;
-
- @override
- Widget build(BuildContext context) => Stack(
- children: [
- Positioned.fill(
- child: _TacticalPointerInput(
- mode: mode,
- onPoint: (point) => ProviderScope.containerOf(
- context,
- ).read(tacticalControllerProvider.notifier).chooseMarkerPoint(point),
- onStrokeComplete: (_) {},
- onCancel: onCancel,
- ),
- ),
- Positioned(
- left: 24,
- right: 24,
- top: 116,
- child: FocusTraversalGroup(
- child: FocusScope(
- autofocus: true,
- child: Shortcuts(
- shortcuts: const {
- SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
- SingleActivator(LogicalKeyboardKey.arrowLeft):
- _NudgeSceneCursorIntent(-.04, 0),
- SingleActivator(LogicalKeyboardKey.arrowRight):
- _NudgeSceneCursorIntent(.04, 0),
- SingleActivator(LogicalKeyboardKey.arrowUp):
- _NudgeSceneCursorIntent(0, -.04),
- SingleActivator(LogicalKeyboardKey.arrowDown):
- _NudgeSceneCursorIntent(0, .04),
- SingleActivator(LogicalKeyboardKey.enter):
- _CommitSceneCursorIntent(),
- },
- child: Actions(
- actions: {
- DismissIntent: CallbackAction(
- onInvoke: (_) {
- onCancel();
- return null;
- },
- ),
- _NudgeSceneCursorIntent:
- CallbackAction<_NudgeSceneCursorIntent>(
- onInvoke: (intent) {
- onNudgeCursor(intent.dx, intent.dy);
- return null;
- },
- ),
- _CommitSceneCursorIntent:
- CallbackAction<_CommitSceneCursorIntent>(
- onInvoke: (_) {
- onCommitCursor();
- return null;
- },
- ),
- },
- child: Semantics(
- container: true,
- label: 'Marker cursor. Session-only local marker tool.',
- child: _MarkerToolbar(
- mode: mode,
- cursor: cursor ?? const LocalScenePoint(x: .5, y: .5),
- onConfigure: onConfigure,
- onPlaceAtCursor: onCommitCursor,
- onSelectTool: onSelectTool,
- ),
- ),
- ),
- ),
- ),
- ),
- ),
- if (mode.pendingPoint != null)
- Positioned(
- left: 24,
- top: 220,
- width: math.min(360, MediaQuery.sizeOf(context).width - 48),
- child: _MarkerNamePrompt(onSave: onSaveName, onCancel: onCancel),
- ),
- ],
- );
-}
-
-class _MarkerToolbar extends StatelessWidget {
- const _MarkerToolbar({
- required this.mode,
- required this.cursor,
- required this.onConfigure,
- required this.onPlaceAtCursor,
- required this.onSelectTool,
- });
-
- final TacticalMarkerMode mode;
- final LocalScenePoint cursor;
- final void Function({double? hue, double? saturation, double? brightness})
- onConfigure;
- final VoidCallback onPlaceAtCursor;
- final VoidCallback onSelectTool;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
- child: Wrap(
- spacing: 14,
- runSpacing: 8,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- const _HudLabel('MARKER CURSOR', color: accentOrange),
- _ColorSwatches(
- toolLabel: 'Marker',
- keyPrefix: 'marker',
- hue: mode.hue,
- colorForHue: _markerColor,
- onSelected: (hue) => onConfigure(hue: hue),
- ),
- SizedBox(
- width: 160,
- child: Semantics(
- label: 'Marker color hue',
- child: Slider(
- key: const Key('marker-hue'),
- value: mode.hue,
- min: 0,
- max: 360,
- activeColor: _markerColor(
- mode.hue,
- mode.saturation,
- mode.brightness,
- ),
- onChanged: (hue) => onConfigure(hue: hue),
- ),
- ),
- ),
- _StyleSlider(
- key: const Key('marker-saturation'),
- label: 'Marker saturation',
- value: mode.saturation,
- color: _markerColor(mode.hue, 1, mode.brightness),
- onChanged: (value) => onConfigure(saturation: value),
- ),
- _StyleSlider(
- key: const Key('marker-brightness'),
- label: 'Marker brightness',
- value: mode.brightness,
- color: _markerColor(mode.hue, mode.saturation, 1),
- onChanged: (value) => onConfigure(brightness: value),
- ),
- _KeyboardMapCursor(
- cursor: cursor,
- isDrawing: false,
- canFinishDrawing: false,
- pointCount: 0,
- onCommit: onPlaceAtCursor,
- onFinish: () {},
- ),
- _UnitActionButton(
- label: 'SELECTION CURSOR',
- icon: Icons.ads_click_outlined,
- foreground: textPrimary,
- background: tacticalTransparent,
- onPressed: onSelectTool,
- ),
- ],
- ),
- );
-}
-
-class _MarkerNamePrompt extends StatefulWidget {
- const _MarkerNamePrompt({required this.onSave, required this.onCancel});
-
- final ValueChanged onSave;
- final VoidCallback onCancel;
-
- @override
- State<_MarkerNamePrompt> createState() => _MarkerNamePromptState();
-}
-
-class _MarkerNamePromptState extends State<_MarkerNamePrompt> {
- final _controller = TextEditingController();
- final _focusNode = FocusNode();
- String? _error;
-
- @override
- void initState() {
- super.initState();
- WidgetsBinding.instance.addPostFrameCallback(
- (_) => _focusNode.requestFocus(),
- );
- }
-
- @override
- void dispose() {
- _controller.dispose();
- _focusNode.dispose();
- super.dispose();
- }
-
- void _submit() {
- final name = _controller.text;
- if (name.trim().isEmpty) {
- setState(() => _error = 'Enter a marker name.');
- return;
- }
- if (name.runes.length > 48) {
- setState(() => _error = 'Marker names are limited to 48 characters.');
- return;
- }
- widget.onSave(name);
- }
-
- @override
- Widget build(BuildContext context) => Semantics(
- container: true,
- scopesRoute: true,
- namesRoute: true,
- explicitChildNodes: true,
- label: 'Name local marker',
- child: _TacticalGlass(
- padding: const EdgeInsets.all(14),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const _HudLabel('NAME LOCAL MARKER', color: accentOrange),
- const SizedBox(height: 6),
- const Text('Session only · not sent to a vehicle'),
- const SizedBox(height: 10),
- TextField(
- key: const Key('marker-name-field'),
- focusNode: _focusNode,
- controller: _controller,
- maxLength: 48,
- textInputAction: TextInputAction.done,
- onSubmitted: (_) => _submit(),
- decoration: InputDecoration(
- labelText: 'Marker name',
- errorText: _error,
- ),
- ),
- const SizedBox(height: 8),
- Wrap(
- spacing: 8,
- children: [
- _UnitActionButton(
- label: 'SAVE MARKER',
- icon: Icons.check,
- foreground: tacticalBlack,
- background: accentMint,
- onPressed: _submit,
- ),
- _UnitActionButton(
- label: 'CANCEL',
- icon: Icons.close,
- foreground: textPrimary,
- background: tacticalTransparent,
- onPressed: widget.onCancel,
- ),
- ],
- ),
- ],
- ),
- ),
- );
-}
-
-Color _markerColor(double hue, [double saturation = .68, double value = .98]) =>
- HSVColor.fromAHSV(1, hue, saturation, value).toColor();
-
-class _StyleSlider extends StatelessWidget {
- const _StyleSlider({
- super.key,
- required this.label,
- required this.value,
- required this.color,
- required this.onChanged,
- });
-
- final String label;
- final double value;
- final Color color;
- final ValueChanged onChanged;
-
- @override
- Widget build(BuildContext context) => SizedBox(
- width: 130,
- child: Semantics(
- label: label,
- child: Slider(
- value: value,
- min: 0,
- max: 1,
- activeColor: color,
- onChanged: onChanged,
- ),
- ),
- );
-}
-
-class _KeyboardMapCursor extends StatelessWidget {
- const _KeyboardMapCursor({
- required this.cursor,
- required this.isDrawing,
- required this.canFinishDrawing,
- required this.pointCount,
- required this.onCommit,
- required this.onFinish,
- });
-
- final LocalScenePoint cursor;
- final bool isDrawing;
- final bool canFinishDrawing;
- final int pointCount;
- final VoidCallback onCommit;
- final VoidCallback onFinish;
-
- @override
- Widget build(BuildContext context) {
- final location = _qualitativeSceneLocation(cursor);
- return Semantics(
- liveRegion: true,
- label: 'Map cursor: $location. Local scene only, not a vehicle location.',
- child: Wrap(
- spacing: 8,
- runSpacing: 8,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- _HudLabel('MAP CURSOR · $location', color: textSecondary),
- if (isDrawing) _HudLabel('LOCAL POINTS: $pointCount'),
- OutlinedButton.icon(
- onPressed: onCommit,
- icon: const Icon(Icons.add_location_alt_outlined, size: 16),
- label: Text(isDrawing ? 'ADD LOCAL POINT' : 'PLACE AT CURSOR'),
- ),
- if (isDrawing)
- FilledButton.icon(
- onPressed: canFinishDrawing ? onFinish : null,
- icon: const Icon(Icons.check, size: 16),
- label: const Text('FINISH DRAWING'),
- ),
- ],
- ),
- );
- }
-}
-
-String _qualitativeSceneLocation(LocalScenePoint point) {
- final horizontal = point.x < .34
- ? 'left'
- : point.x > .66
- ? 'right'
- : 'center';
- final vertical = point.y < .34
- ? 'upper'
- : point.y > .66
- ? 'lower'
- : 'middle';
- return '$vertical $horizontal';
-}
-
-/// The production-style unit hangar: a horizontal, local-only RTS dock.
-/// Selecting a category prepares a draft; it cannot create or configure a
-/// vehicle. Confirmation creates only a local workspace unit.
-class _UnitHangarDock extends ConsumerWidget {
- const _UnitHangarDock({this.compact = false, required this.onClose});
-
- final bool compact;
- final VoidCallback onClose;
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final tactical = ref.watch(tacticalControllerProvider);
- final workspace = ref.watch(vehicleWorkspaceProvider);
- final controller = ref.read(tacticalControllerProvider.notifier);
- final panel = tactical.panel;
- final draft = tactical.unitDraft;
- FleetUnit? selectedLocal;
- if (tactical.selectedUnitIds.length == 1) {
- for (final unit in workspace.units) {
- if (unit.localUnitId == tactical.selectedUnitIds.single) {
- selectedLocal = unit;
- break;
- }
- }
- }
-
- return RepaintBoundary(
- child: _TacticalGlass(
- padding: EdgeInsets.all(compact ? 14 : 16),
- borderRadius: BorderRadius.circular(14),
- child: switch (panel) {
- TacticalPanel.unitHangar => _HangarSelection(
- draft: draft,
- onSelect: (definition) => controller.chooseUnitDefinition(
- definition,
- displayName: _nextLocalUnitName(
- definition,
- workspace.units.length,
- ),
- ),
- onPrepare: controller.beginUnitSetup,
- onClose: onClose,
- ),
- TacticalPanel.unitSetup when draft != null => _DraftUnitSetup(
- draft: draft,
- onNameChanged: controller.updateUnitDraftName,
- onBack: controller.returnToUnitHangar,
- onConfirm: () {
- final localUnitId = 'local-unit-${workspace.units.length + 1}';
- final created = ref
- .read(vehicleWorkspaceProvider.notifier)
- .addUnitAutomatically(
- definition: draft.definition,
- displayName: draft.displayName,
- );
- if (created) {
- controller.completeUnitPlacement(localUnitId);
- }
- },
- ),
- TacticalPanel.unitSetup when selectedLocal != null =>
- _ExistingUnitSetup(
- unit: selectedLocal,
- onClose: onClose,
- onSaveName: (name) => ref
- .read(vehicleWorkspaceProvider.notifier)
- .renameUnit(selectedLocal!.localUnitId, name),
- ),
- _ => const SizedBox.shrink(),
- },
- ),
- );
- }
-}
-
-String _nextLocalUnitName(VehicleDefinition definition, int unitCount) =>
- '${definition.name.toUpperCase()}-${(unitCount + 1).toString().padLeft(2, '0')}';
-
-class _HangarSelection extends StatefulWidget {
- const _HangarSelection({
- required this.draft,
- required this.onSelect,
- required this.onPrepare,
- required this.onClose,
- });
-
- final UnitCreationDraft? draft;
- final ValueChanged onSelect;
- final VoidCallback onPrepare;
- final VoidCallback onClose;
-
- @override
- State<_HangarSelection> createState() => _HangarSelectionState();
-}
-
-class _HangarSelectionState extends State<_HangarSelection> {
- late final List _cardFocusNodes;
- final ScrollController _scrollController = ScrollController();
-
- @override
- void initState() {
- super.initState();
- _cardFocusNodes = List.generate(
- supportedVehicleDefinitions.length,
- (index) => FocusNode(debugLabel: 'hangar-card-$index'),
- );
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) {
- _cardFocusNodes.first.requestFocus();
- }
- });
- }
-
- @override
- void dispose() {
- _scrollController.dispose();
- for (final node in _cardFocusNodes) {
- node.dispose();
- }
- super.dispose();
- }
-
- void _choose(int index) =>
- widget.onSelect(supportedVehicleDefinitions[index]);
-
- void _moveFocus(int target) {
- final index = target.clamp(0, supportedVehicleDefinitions.length - 1);
- _choose(index);
- _cardFocusNodes[index].requestFocus();
- final context = _cardFocusNodes[index].context;
- if (context != null) {
- Scrollable.ensureVisible(
- context,
- alignment: .5,
- duration: const Duration(milliseconds: 120),
- );
- }
- }
-
- int get _selectedIndex {
- final id = widget.draft?.definition.id;
- return supportedVehicleDefinitions.indexWhere(
- (definition) => definition.id == id,
- );
- }
-
- @override
- Widget build(BuildContext context) => FocusTraversalGroup(
- child: Semantics(
- container: true,
- label: 'Unit hangar. Local scene vehicle categories.',
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- const Icon(
- Icons.inventory_2_outlined,
- color: accentMint,
- size: 18,
- ),
- const SizedBox(width: 8),
- const _HudLabel('UNIT HANGAR', color: textPrimary),
- const SizedBox(width: 8),
- const _Token(label: 'LOCAL SCENE', tone: textSecondary),
- const Spacer(),
- IconButton(
- tooltip: 'Close unit hangar',
- onPressed: widget.onClose,
- icon: const Icon(Icons.close_outlined),
- ),
- ],
- ),
- const SizedBox(height: 8),
- Expanded(
- child: Shortcuts(
- shortcuts: const {
- SingleActivator(LogicalKeyboardKey.arrowLeft):
- _HangarMoveIntent(-1),
- SingleActivator(LogicalKeyboardKey.arrowRight):
- _HangarMoveIntent(1),
- SingleActivator(LogicalKeyboardKey.home): _HangarHomeIntent(),
- SingleActivator(LogicalKeyboardKey.end): _HangarEndIntent(),
- },
- child: Actions(
- actions: {
- _HangarMoveIntent: CallbackAction<_HangarMoveIntent>(
- onInvoke: (intent) {
- _moveFocus(
- (_selectedIndex < 0 ? 0 : _selectedIndex) +
- intent.delta,
- );
- return null;
- },
- ),
- _HangarHomeIntent: CallbackAction<_HangarHomeIntent>(
- onInvoke: (_) {
- _moveFocus(0);
- return null;
- },
- ),
- _HangarEndIntent: CallbackAction<_HangarEndIntent>(
- onInvoke: (_) {
- _moveFocus(supportedVehicleDefinitions.length - 1);
- return null;
- },
- ),
- },
- child: Scrollbar(
- controller: _scrollController,
- child: ListView.separated(
- key: const Key('unit-hangar-strip'),
- controller: _scrollController,
- scrollDirection: Axis.horizontal,
- padding: const EdgeInsets.only(bottom: 2),
- itemCount: supportedVehicleDefinitions.length,
- separatorBuilder: (context, index) =>
- const SizedBox(width: 10),
- itemBuilder: (context, index) {
- final definition = supportedVehicleDefinitions[index];
- return _HangarVehicleCard(
- key: Key('hangar-card-${definition.id}'),
- focusNode: _cardFocusNodes[index],
- autofocus: index == 0,
- definition: definition,
- selected: widget.draft?.definition.id == definition.id,
- onSelected: () => _choose(index),
- );
- },
- ),
- ),
- ),
- ),
- ),
- const SizedBox(height: 10),
- Row(
- children: [
- Expanded(
- child: _HudLabel(
- widget.draft == null
- ? 'SELECT A LOCAL UNIT TYPE'
- : '${widget.draft!.definition.name.toUpperCase()} SELECTED',
- ),
- ),
- _UnitActionButton(
- label: 'PREPARE UNIT',
- icon: Icons.tune_outlined,
- foreground: tacticalBlack,
- background: accentMint,
- onPressed: widget.draft == null ? null : widget.onPrepare,
- ),
- ],
- ),
- ],
- ),
- ),
- );
-}
-
-/// Image-first card for the low, horizontal RTS hangar. Unlike the former
-/// workspace-selection card, it has exactly one job: choose a draft type.
-/// Creating a unit remains an explicit setup confirmation.
-class _HangarVehicleCard extends StatefulWidget {
- const _HangarVehicleCard({
- super.key,
- required this.definition,
- required this.selected,
- required this.onSelected,
- required this.focusNode,
- required this.autofocus,
- });
-
- final VehicleDefinition definition;
- final bool selected;
- final VoidCallback onSelected;
- final FocusNode focusNode;
- final bool autofocus;
-
- @override
- State<_HangarVehicleCard> createState() => _HangarVehicleCardState();
-}
-
-class _HangarVehicleCardState extends State<_HangarVehicleCard> {
- bool _focused = false;
-
- @override
- Widget build(BuildContext context) {
- final active = widget.selected || _focused;
- return Semantics(
- button: true,
- checked: widget.selected,
- inMutuallyExclusiveGroup: true,
- label:
- '${widget.definition.name}, ${widget.definition.vehicleClass}, '
- 'local unit category',
- onTap: widget.onSelected,
- child: SizedBox(
- width: 186,
- child: Material(
- color: Colors.transparent,
- child: InkWell(
- focusNode: widget.focusNode,
- autofocus: widget.autofocus,
- onFocusChange: (value) => setState(() => _focused = value),
- onTap: widget.onSelected,
- borderRadius: BorderRadius.circular(9),
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 110),
- curve: Curves.easeOut,
- padding: const EdgeInsets.all(5),
- decoration: BoxDecoration(
- color: active
- ? accentMint.withValues(alpha: .12)
- : tacticalBlack.withValues(alpha: .48),
- border: Border.all(
- color: active ? accentMint : panelBorder,
- width: active ? 1.5 : 1,
- ),
- borderRadius: BorderRadius.circular(9),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Expanded(
- child: ClipRRect(
- borderRadius: BorderRadius.circular(5),
- child: Image.asset(
- widget.definition.media.imageAssetPath,
- width: double.infinity,
- fit: BoxFit.cover,
- filterQuality: FilterQuality.high,
- excludeFromSemantics: true,
- ),
- ),
- ),
- const SizedBox(height: 6),
- _HudLabel(widget.definition.vehicleClass),
- const SizedBox(height: 2),
- Row(
- children: [
- Icon(
- widget.selected
- ? Icons.radio_button_checked
- : Icons.radio_button_off,
- color: widget.selected ? accentMint : textSecondary,
- size: 14,
- ),
- const SizedBox(width: 5),
- Expanded(
- child: Text(
- widget.definition.name,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: GoogleFonts.inter(
- color: textPrimary,
- fontSize: 12,
- fontWeight: FontWeight.w700,
- ),
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- ),
- ),
- );
- }
-}
-
-class _HangarMoveIntent extends Intent {
- const _HangarMoveIntent(this.delta);
-
- final int delta;
-}
-
-class _HangarHomeIntent extends Intent {
- const _HangarHomeIntent();
-}
-
-class _HangarEndIntent extends Intent {
- const _HangarEndIntent();
-}
-
-class _DraftUnitSetup extends StatefulWidget {
- const _DraftUnitSetup({
- required this.draft,
- required this.onNameChanged,
- required this.onBack,
- required this.onConfirm,
- });
-
- final UnitCreationDraft draft;
- final ValueChanged onNameChanged;
- final VoidCallback onBack;
- final VoidCallback onConfirm;
-
- @override
- State<_DraftUnitSetup> createState() => _DraftUnitSetupState();
-}
-
-class _DraftUnitSetupState extends State<_DraftUnitSetup> {
- late final TextEditingController _nameController;
- String? _error;
-
- @override
- void initState() {
- super.initState();
- _nameController = TextEditingController(text: widget.draft.displayName);
- }
-
- @override
- void didUpdateWidget(covariant _DraftUnitSetup oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (oldWidget.draft.definition.id != widget.draft.definition.id) {
- _nameController.text = widget.draft.displayName;
- }
- }
-
- @override
- void dispose() {
- _nameController.dispose();
- super.dispose();
- }
-
- void _confirm() {
- if (!VehicleWorkspaceController.isValidLocalDisplayName(
- _nameController.text,
- )) {
- setState(
- () => _error = 'Enter a local unit name of up to 32 characters.',
- );
- return;
- }
- setState(() => _error = null);
- widget.onConfirm();
- }
-
- @override
- Widget build(BuildContext context) => _UnitSetupLayout(
- label: 'UNIT SETUP',
- definition: widget.draft.definition,
- nameController: _nameController,
- editable: true,
- error: _error,
- onNameChanged: (value) {
- if (_error != null &&
- VehicleWorkspaceController.isValidLocalDisplayName(value)) {
- setState(() => _error = null);
- }
- widget.onNameChanged(value);
- },
- primaryLabel: 'ADD TO LOCAL SCENE',
- onPrimary: _confirm,
- secondaryLabel: 'BACK TO HANGAR',
- onSecondary: widget.onBack,
- );
-}
-
-class _ExistingUnitSetup extends StatefulWidget {
- const _ExistingUnitSetup({
- required this.unit,
- required this.onClose,
- required this.onSaveName,
- });
-
- final FleetUnit unit;
- final VoidCallback onClose;
- final bool Function(String name) onSaveName;
-
- @override
- State<_ExistingUnitSetup> createState() => _ExistingUnitSetupState();
-}
-
-class _ExistingUnitSetupState extends State<_ExistingUnitSetup> {
- late final TextEditingController _nameController;
- String? _error;
-
- @override
- void initState() {
- super.initState();
- _nameController = TextEditingController(text: widget.unit.displayName);
- }
-
- @override
- void dispose() {
- _nameController.dispose();
- super.dispose();
- }
-
- void _save() {
- if (!widget.onSaveName(_nameController.text)) {
- setState(
- () => _error = 'Enter a local unit name of up to 32 characters.',
- );
- return;
- }
- setState(() => _error = null);
- }
-
- @override
- Widget build(BuildContext context) => _UnitSetupLayout(
- label: 'LOCAL UNIT SETUP',
- definition: widget.unit.definition,
- nameController: _nameController,
- editable: true,
- error: _error,
- onNameChanged: (_) {},
- primaryLabel: 'SAVE LOCAL SETUP',
- onPrimary: _save,
- secondaryLabel: 'CLOSE',
- onSecondary: widget.onClose,
- );
-}
-
-class _UnitSetupLayout extends StatelessWidget {
- const _UnitSetupLayout({
- required this.label,
- required this.definition,
- required this.nameController,
- required this.editable,
- required this.onNameChanged,
- required this.primaryLabel,
- required this.onPrimary,
- required this.secondaryLabel,
- required this.onSecondary,
- this.error,
- });
-
- final String label;
- final VehicleDefinition definition;
- final TextEditingController nameController;
- final bool editable;
- final ValueChanged onNameChanged;
- final String primaryLabel;
- final VoidCallback onPrimary;
- final String secondaryLabel;
- final VoidCallback onSecondary;
- final String? error;
-
- @override
- Widget build(BuildContext context) => LayoutBuilder(
- builder: (context, constraints) {
- final textScale = MediaQuery.textScalerOf(context).scale(14) / 14;
- // A tall stacked setup remains scrollable inside the compact dock at
- // large text sizes, rather than compressing the identity/form columns.
- final stacked = constraints.maxWidth < 760 || textScale >= 1.25;
- final identity = _UnitSetupIdentity(definition: definition);
- final fields = _UnitSetupFields(
- definition: definition,
- nameController: nameController,
- editable: editable,
- error: error,
- onNameChanged: onNameChanged,
- primaryLabel: primaryLabel,
- onPrimary: onPrimary,
- secondaryLabel: secondaryLabel,
- onSecondary: onSecondary,
- );
- return Semantics(
- container: true,
- label:
- '$label. Local workspace only. No vehicle link or command authority.',
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _HudLabel(label, color: textPrimary),
- const SizedBox(height: 10),
- Expanded(
- child: stacked
- ? ListView(
- children: [identity, const SizedBox(height: 12), fields],
- )
- : Row(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- SizedBox(width: 260, child: identity),
- const SizedBox(width: 18),
- Expanded(child: fields),
- ],
- ),
- ),
- ],
- ),
- );
- },
- );
-}
-
-class _UnitSetupIdentity extends StatelessWidget {
- const _UnitSetupIdentity({required this.definition});
-
- final VehicleDefinition definition;
-
- @override
- Widget build(BuildContext context) => Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- SizedBox(
- height: 104,
- width: double.infinity,
- child: ClipRRect(
- borderRadius: BorderRadius.circular(10),
- child: Image.asset(
- definition.media.imageAssetPath,
- fit: BoxFit.cover,
- excludeFromSemantics: true,
- ),
- ),
- ),
- const SizedBox(height: 8),
- _HudLabel(definition.vehicleClass),
- Text(definition.name, style: Theme.of(context).textTheme.titleMedium),
- const _HudLabel('LOCAL SCENE UNIT'),
- ],
- );
-}
-
-class _UnitSetupFields extends StatelessWidget {
- const _UnitSetupFields({
- required this.definition,
- required this.nameController,
- required this.editable,
- required this.onNameChanged,
- required this.primaryLabel,
- required this.onPrimary,
- required this.secondaryLabel,
- required this.onSecondary,
- this.error,
- });
-
- final VehicleDefinition definition;
- final TextEditingController nameController;
- final bool editable;
- final ValueChanged onNameChanged;
- final String primaryLabel;
- final VoidCallback onPrimary;
- final String secondaryLabel;
- final VoidCallback onSecondary;
- final String? error;
-
- @override
- Widget build(BuildContext context) => Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- TextField(
- key: const Key('local-unit-name-field'),
- controller: nameController,
- enabled: editable,
- maxLength: 32,
- onChanged: onNameChanged,
- decoration: InputDecoration(
- labelText: 'Unit name',
- errorText: error,
- border: const OutlineInputBorder(),
- ),
- ),
- const SizedBox(height: 8),
- _SetupFact(label: 'TYPE', value: definition.name),
- const SizedBox(height: 6),
- const _SetupFact(label: 'PLACEMENT', value: 'AUTOMATIC LOCAL POSITION'),
- const SizedBox(height: 10),
- const _ConfigurationUnavailable(),
- const SizedBox(height: 12),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- _UnitActionButton(
- label: primaryLabel,
- icon: Icons.check_outlined,
- foreground: tacticalBlack,
- background: accentMint,
- onPressed: onPrimary,
- ),
- _UnitActionButton(
- label: secondaryLabel,
- icon: Icons.arrow_back_outlined,
- foreground: textPrimary,
- background: tacticalTransparent,
- onPressed: onSecondary,
- ),
- ],
- ),
- ],
- );
-}
-
-class _SetupFact extends StatelessWidget {
- const _SetupFact({required this.label, required this.value});
-
- final String label;
- final String value;
-
- @override
- Widget build(BuildContext context) => Row(
- children: [
- SizedBox(width: 94, child: _HudLabel(label)),
- Expanded(child: _MonoText(value, color: textPrimary, size: 10)),
- ],
- );
-}
-
-class _ConfigurationUnavailable extends StatelessWidget {
- const _ConfigurationUnavailable();
-
- @override
- Widget build(BuildContext context) => Container(
- padding: const EdgeInsets.all(10),
- decoration: BoxDecoration(
- color: textSecondary.withValues(alpha: .08),
- border: Border.all(color: panelBorder),
- borderRadius: BorderRadius.circular(8),
- ),
- child: const Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _HudLabel('VEHICLE CONFIGURATION UNAVAILABLE'),
- SizedBox(height: 3),
- Text(
- 'Unavailable until verified binding, compatible schema, command authority, and safe activation are available.',
- ),
- ],
- ),
- );
-}
-
-/// Coalesces pointer movement to the next frame and publishes it only to the
-/// map-preview provider. The command reducer never receives hover traffic.
-class _TacticalPointerInput extends ConsumerStatefulWidget {
- const _TacticalPointerInput({
- required this.mode,
- required this.onPoint,
- required this.onStrokeComplete,
- required this.onCancel,
- });
-
- final TacticalMode mode;
- final ValueChanged onPoint;
- final ValueChanged> onStrokeComplete;
- final VoidCallback onCancel;
-
- @override
- ConsumerState<_TacticalPointerInput> createState() =>
- _TacticalPointerInputState();
-}
-
-class _TacticalPointerInputState extends ConsumerState<_TacticalPointerInput> {
- // About one logical pixel on a typical desktop map. We coalesce updates to
- // one frame, then retain a bounded high-detail preview so slow curves do not
- // turn into coarse straight segments.
- static const _maximumStrokeSamples = 512;
- static const _minimumSampleDistanceSquared = .000001;
-
- LocalScenePoint? _pending;
- final List _stroke = [];
- int? _activePrimaryPointer;
- bool _frameScheduled = false;
- late final TacticalPointerPreviewController _previewController;
-
- @override
- void initState() {
- super.initState();
- _previewController = ref.read(tacticalPointerPreviewProvider.notifier);
- }
-
- @override
- Widget build(BuildContext context) => Listener(
- behavior: HitTestBehavior.translucent,
- onPointerMove: _queuePreview,
- onPointerHover: _queueHoverPreview,
- onPointerDown: (event) {
- if (event.buttons == kSecondaryButton) {
- _activePrimaryPointer = null;
- _clearPreview();
- widget.onCancel();
- return;
- }
- if (event.buttons != kPrimaryButton) {
- return;
- }
- final point = _pointFor(event.position);
- if (point != null) {
- _activePrimaryPointer = event.pointer;
- _stroke
- ..clear()
- ..add(point);
- _previewController.show(point, stroke: _stroke);
- }
- },
- onPointerUp: (event) {
- if (event.pointer != _activePrimaryPointer) {
- return;
- }
- _activePrimaryPointer = null;
- _flushPending();
- final point = _pointFor(event.position);
- if (point == null) {
- _clearPreview();
- return;
- }
- _appendDistinct(point);
- final stroke = List.of(_stroke);
- final mode = widget.mode;
- if (mode is TacticalPencilMode) {
- widget.onStrokeComplete(stroke);
- } else if (mode is TacticalMarkerMode) {
- widget.onPoint(point);
- }
- _clearPreview();
- },
- onPointerCancel: (event) {
- if (event.pointer == _activePrimaryPointer) {
- _activePrimaryPointer = null;
- _clearPreview();
- }
- },
- child: const ExcludeSemantics(child: SizedBox.expand()),
- );
-
- @override
- void dispose() {
- // A mode transition clears the provider before this input unmounts. Do not
- // mutate a Riverpod provider from disposal; it can run while the widget
- // tree is being rebuilt. Clearing the pending sample prevents a queued
- // frame callback from restoring the preview after unmount.
- _pending = null;
- super.dispose();
- }
-
- void _clearPreview() {
- _pending = null;
- _stroke.clear();
- _previewController.clear();
- }
-
- void _queuePreview(PointerMoveEvent event) {
- if (event.pointer != _activePrimaryPointer) {
- return;
- }
- _schedulePreview(_pointFor(event.position));
- }
-
- void _queueHoverPreview(PointerHoverEvent event) {
- if (_activePrimaryPointer != null || widget.mode is! TacticalMarkerMode) {
- return;
- }
- _schedulePreview(_pointFor(event.position));
- }
-
- void _schedulePreview(LocalScenePoint? point) {
- _pending = point;
- if (_frameScheduled || _pending == null) {
- return;
- }
- _frameScheduled = true;
- SchedulerBinding.instance.scheduleFrameCallback((_) {
- _frameScheduled = false;
- final point = _pending;
- _pending = null;
- if (!mounted || point == null) {
- return;
- }
- if (_activePrimaryPointer != null &&
- widget.mode is TacticalPencilMode &&
- _stroke.isNotEmpty) {
- _appendDistinct(point);
- }
- _previewController.show(point, stroke: _stroke);
- });
- }
-
- void _flushPending() {
- final point = _pending;
- _pending = null;
- if (point != null && widget.mode is TacticalPencilMode) {
- _appendDistinct(point);
- }
- }
-
- void _appendDistinct(LocalScenePoint point) {
- if (_stroke.isEmpty) {
- _stroke.add(point);
- return;
- }
- final previous = _stroke.last;
- final dx = previous.x - point.x;
- final dy = previous.y - point.y;
- if (dx * dx + dy * dy < _minimumSampleDistanceSquared) {
- return;
- }
- if (_stroke.length >= _maximumStrokeSamples) {
- _stroke[_stroke.length - 1] = point;
- } else {
- _stroke.add(point);
- }
- }
-
- LocalScenePoint? _pointFor(Offset globalPosition) {
- final box = context.findRenderObject() as RenderBox?;
- if (box == null || box.size.isEmpty) {
- return null;
- }
- final local = box.globalToLocal(globalPosition);
- return LocalScenePoint(
- x: (local.dx / box.size.width).clamp(0.0, 1.0),
- y: (local.dy / box.size.height).clamp(0.0, 1.0),
- );
- }
-}
-
-class _TopNavigation extends StatelessWidget {
- const _TopNavigation({
- required this.sourceLabel,
- required this.sourceStatus,
- required this.onOpenEventStream,
- this.compact = false,
- });
-
- final bool compact;
- final String sourceLabel;
- final String sourceStatus;
- final VoidCallback onOpenEventStream;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: EdgeInsets.symmetric(horizontal: compact ? 14 : 20, vertical: 12),
- child: SingleChildScrollView(
- scrollDirection: Axis.horizontal,
- child: Row(
- children: [
- const _BrandMark(),
- const SizedBox(width: 24),
- const _HeaderFact(label: 'MODE', value: 'TACTICAL COMMAND'),
- const SizedBox(width: 24),
- const _HeaderFact(label: 'DATA', value: 'LOCAL SCENE'),
- const SizedBox(width: 24),
- _HeaderFact(label: 'SOURCE', value: '$sourceLabel · $sourceStatus'),
- const SizedBox(width: 16),
- TextButton.icon(
- onPressed: onOpenEventStream,
- icon: const Icon(Icons.subject_outlined, size: 16),
- label: const Text('EVENT STREAM'),
- ),
- const SizedBox(width: 16),
- const _PhaseToggle(),
- const SizedBox(width: 28),
- const _HeaderTimer(
- icon: Icons.timer_outlined,
- label: 'SCENE TIME',
- value: '00:55:08',
- ),
- const SizedBox(width: 20),
- const _HeaderTimer(
- icon: Icons.flight_outlined,
- label: 'RENDER TIME',
- value: '00:36:03',
- ),
- ],
- ),
- ),
- );
-}
-
-class _BrandMark extends StatelessWidget {
- const _BrandMark();
-
- @override
- Widget build(BuildContext context) => Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Container(
- width: 34,
- height: 34,
- decoration: BoxDecoration(
- color: accentMint.withValues(alpha: .12),
- border: Border.all(color: accentMint.withValues(alpha: .6)),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Icon(Icons.flight_takeoff, color: accentMint, size: 19),
- ),
- const SizedBox(width: 10),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'KAPTAN',
- style: GoogleFonts.inter(
- color: textPrimary,
- fontSize: 14,
- fontWeight: FontWeight.w800,
- letterSpacing: 1.4,
- ),
- ),
- const _HudLabel('GROUND SUITE'),
- ],
- ),
- ],
- );
-}
-
-class _HeaderFact extends StatelessWidget {
- const _HeaderFact({required this.label, required this.value});
-
- final String label;
- final String value;
-
- @override
- Widget build(BuildContext context) => Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _HudLabel(label),
- const SizedBox(height: 4),
- Text(
- value,
- style: GoogleFonts.inter(
- color: textPrimary,
- fontSize: 12,
- fontWeight: FontWeight.w700,
- ),
- ),
- ],
- );
-}
-
-class _PhaseToggle extends StatelessWidget {
- const _PhaseToggle();
-
- @override
- Widget build(BuildContext context) {
- const tone = accentOrange;
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- decoration: BoxDecoration(
- color: tone.withValues(alpha: .16),
- border: Border.all(color: tone.withValues(alpha: .65)),
- borderRadius: BorderRadius.circular(7),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.auto_mode, color: tone, size: 16),
- const SizedBox(width: 7),
- _HudLabel('PHASE · LOCAL', color: tone),
- ],
- ),
- );
- }
-}
-
-class _HeaderTimer extends StatelessWidget {
- const _HeaderTimer({
- required this.icon,
- required this.label,
- required this.value,
- });
-
- final IconData icon;
- final String label;
- final String value;
-
- @override
- Widget build(BuildContext context) => Row(
- children: [
- Icon(icon, color: textSecondary, size: 16),
- const SizedBox(width: 7),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _HudLabel(label),
- const SizedBox(height: 3),
- _MonoText(value, color: textPrimary, size: 12),
- ],
- ),
- ],
- );
-}
-
-class _FpvPanel extends StatelessWidget {
- const _FpvPanel({required this.selectedUnit});
-
- final _DroneData selectedUnit;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: const EdgeInsets.all(10),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Wrap(
- spacing: 7,
- runSpacing: 6,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- const _HudLabel('SIMULATED CAMERA CONCEPT'),
- _Token(label: selectedUnit.id, tone: accentMint),
- _Token(label: 'NOT VIDEO', tone: accentOrange),
- ],
- ),
- const SizedBox(height: 9),
- Expanded(
- child: Semantics(
- label: 'Simulated camera concept. Not vehicle video.',
- child: ClipRRect(
- borderRadius: BorderRadius.circular(6),
- child: ExcludeSemantics(
- child: Stack(
- fit: StackFit.expand,
- children: [
- Image.network(
- _fpvPlaceholder,
- fit: BoxFit.cover,
- cacheWidth: 1280,
- filterQuality: FilterQuality.medium,
- loadingBuilder: (context, child, progress) =>
- progress == null
- ? child
- : const ColoredBox(color: panelBackground),
- errorBuilder: (context, error, stackTrace) =>
- const ColoredBox(
- color: panelBackground,
- child: Center(
- child: _UnavailableVisual(
- icon: Icons.videocam_off_outlined,
- label: 'DEMO CAMERA VISUAL UNAVAILABLE',
- ),
- ),
- ),
- ),
- ColoredBox(color: tacticalBlack.withValues(alpha: .22)),
- CustomPaint(painter: _FpvHudPainter()),
- const Positioned(
- left: 16,
- top: 18,
- child: _FpvScale(
- label: 'SPD',
- value: '34.2',
- unit: 'km/h',
- ),
- ),
- const Positioned(
- right: 16,
- top: 18,
- child: _FpvScale(
- label: 'ALT',
- value: '252',
- unit: 'm',
- alignEnd: true,
- ),
- ),
- ],
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- );
-}
-
-class _FpvScale extends StatelessWidget {
- const _FpvScale({
- required this.label,
- required this.value,
- required this.unit,
- this.alignEnd = false,
- });
-
- final String label;
- final String value;
- final String unit;
- final bool alignEnd;
-
- @override
- Widget build(BuildContext context) {
- const tone = accentMint;
- return Column(
- crossAxisAlignment: alignEnd
- ? CrossAxisAlignment.end
- : CrossAxisAlignment.start,
- children: [
- _HudLabel(label, color: tone),
- const SizedBox(height: 6),
- _MonoText(value, color: tone, size: 22, weight: FontWeight.w500),
- _HudLabel(unit, color: tone),
- ],
- );
- }
-}
-
-class _UnitCommandTray extends StatelessWidget {
- const _UnitCommandTray({
- required this.selectedUnit,
- required this.selectedUnitIds,
- required this.onUnitSelected,
- required this.onAddUnit,
- required this.onOpenSetup,
- required this.workspaceUnits,
- });
-
- final _DroneData? selectedUnit;
- final Set selectedUnitIds;
- final ValueChanged onUnitSelected;
- final VoidCallback onAddUnit;
- final VoidCallback onOpenSetup;
- final List workspaceUnits;
-
- @override
- Widget build(BuildContext context) {
- if (selectedUnitIds.isEmpty) {
- return _FleetSummaryConsole(
- localUnitCount: workspaceUnits.length,
- onAddUnit: onAddUnit,
- );
- }
- if (selectedUnitIds.length > 1) {
- return _MultiUnitConsole(
- selectedCount: selectedUnitIds.length,
- onClear: () => onUnitSelected(''),
- );
- }
- if (selectedUnit == null) {
- final local = workspaceUnits.where(
- (unit) => selectedUnitIds.contains(unit.localUnitId),
- );
- return _LocalWorkspaceUnitConsole(
- unit: local.isEmpty ? null : local.single,
- onAddUnit: onAddUnit,
- onOpenSetup: onOpenSetup,
- );
- }
- final textScale = MediaQuery.textScalerOf(context).scale(14) / 14;
- final selectorHeight = textScale >= 1.25 ? 112.0 : 54.0;
- return _TacticalGlass(
- padding: const EdgeInsets.all(10),
- borderRadius: BorderRadius.circular(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- const Icon(
- Icons.view_in_ar_outlined,
- color: accentMint,
- size: 16,
- ),
- const SizedBox(width: 7),
- const Expanded(child: _HudLabel('LOCAL SCENE SELECTION')),
- const _Token(label: 'SIMULATED', tone: textSecondary),
- ],
- ),
- const SizedBox(height: 8),
- SizedBox(
- height: selectorHeight,
- child: ListView.separated(
- scrollDirection: Axis.horizontal,
- itemCount: _fleet.length,
- separatorBuilder: (context, index) => const SizedBox(width: 7),
- itemBuilder: (context, index) => _UnitSelectorCard(
- data: _fleet[index],
- selected: _fleet[index].id == selectedUnit!.id,
- deployed: false,
- onTap: () => onUnitSelected(_fleet[index].id),
- ),
- ),
- ),
- const SizedBox(height: 8),
- _LocalScenePreviewBar(unit: selectedUnit!),
- ],
- ),
- );
- }
-}
-
-class _FleetSummaryConsole extends StatelessWidget {
- const _FleetSummaryConsole({
- required this.localUnitCount,
- required this.onAddUnit,
- });
-
- final int localUnitCount;
- final VoidCallback onAddUnit;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: const EdgeInsets.all(14),
- child: Row(
- children: [
- const Icon(Icons.groups_2_outlined, color: accentMint),
- const SizedBox(width: 10),
- Expanded(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const _HudLabel('LOCAL FLEET SUMMARY'),
- const SizedBox(height: 4),
- _MonoText(
- '$localUnitCount LOCAL UNIT${localUnitCount == 1 ? '' : 'S'}',
- color: textPrimary,
- size: 12,
- weight: FontWeight.w700,
- ),
- ],
- ),
- ),
- _UnitActionButton(
- key: const Key('add-local-unit-console'),
- label: 'ADD UNIT',
- icon: Icons.add,
- foreground: tacticalBlack,
- background: accentMint,
- onPressed: onAddUnit,
- ),
- ],
- ),
- );
-}
-
-class _MultiUnitConsole extends StatelessWidget {
- const _MultiUnitConsole({required this.selectedCount, required this.onClear});
-
- final int selectedCount;
- final VoidCallback onClear;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: const EdgeInsets.all(14),
- child: Row(
- children: [
- const Icon(Icons.select_all_outlined, color: accentMint),
- const SizedBox(width: 10),
- Expanded(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const _HudLabel('LOCAL GROUP SELECTION'),
- const SizedBox(height: 4),
- _MonoText(
- '$selectedCount UNITS SELECTED',
- color: textPrimary,
- size: 12,
- weight: FontWeight.w700,
- ),
- ],
- ),
- ),
- _UnitActionButton(
- label: 'CLEAR',
- icon: Icons.close,
- foreground: textPrimary,
- background: tacticalTransparent,
- onPressed: onClear,
- ),
- ],
- ),
- );
-}
-
-class _LocalWorkspaceUnitConsole extends StatelessWidget {
- const _LocalWorkspaceUnitConsole({
- required this.unit,
- required this.onAddUnit,
- required this.onOpenSetup,
- });
-
- final FleetUnit? unit;
- final VoidCallback onAddUnit;
- final VoidCallback onOpenSetup;
-
- @override
- Widget build(BuildContext context) => _TacticalGlass(
- padding: const EdgeInsets.all(14),
- child: Row(
- children: [
- const Icon(Icons.flight_outlined, color: accentMint),
- const SizedBox(width: 10),
- Expanded(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const _HudLabel('LOCAL WORKSPACE UNIT'),
- const SizedBox(height: 4),
- _MonoText(
- unit?.displayName ?? 'UNBOUND UNIT',
- color: textPrimary,
- size: 12,
- weight: FontWeight.w700,
- ),
- const SizedBox(height: 3),
- _HudLabel(
- '${unit?.definition.name.toUpperCase() ?? 'LOCAL UNIT'} · LOCAL SETUP',
- ),
- const SizedBox(height: 3),
- const _HudLabel('NO SOURCE · NO POSITION · NO AUTHORITY'),
- ],
- ),
- ),
- Wrap(
- spacing: 8,
- children: [
- _UnitActionButton(
- label: 'SET UP',
- icon: Icons.tune_outlined,
- foreground: tacticalBlack,
- background: accentMint,
- onPressed: onOpenSetup,
- ),
- _UnitActionButton(
- label: 'ADD',
- icon: Icons.add,
- foreground: textPrimary,
- background: tacticalTransparent,
- onPressed: onAddUnit,
- ),
- ],
- ),
- ],
- ),
- );
-}
-
-class _UnitSelectorCard extends StatelessWidget {
- const _UnitSelectorCard({
- required this.data,
- required this.selected,
- required this.deployed,
- required this.onTap,
- });
-
- final _DroneData data;
- final bool selected;
- final bool deployed;
- final VoidCallback onTap;
-
- @override
- Widget build(BuildContext context) => Semantics(
- button: true,
- selected: selected,
- label: '${data.id}, simulated local scene unit',
- child: InkWell(
- key: Key('unit-card-${data.id}'),
- onTap: onTap,
- borderRadius: BorderRadius.circular(6),
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 120),
- width: 112,
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
- decoration: BoxDecoration(
- color: selected ? accentMint.withValues(alpha: .1) : panelBackground,
- borderRadius: BorderRadius.circular(6),
- border: Border.all(color: selected ? accentMint : panelBorder),
- ),
- child: Row(
- children: [
- Icon(
- Icons.flight,
- color: selected ? accentMint : data.statusColor,
- size: 18,
- ),
- const SizedBox(width: 6),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- _MonoText(data.id, color: textPrimary, size: 8),
- const SizedBox(height: 2),
- Text(
- 'LOCAL PREVIEW',
- maxLines: 1,
- softWrap: false,
- overflow: TextOverflow.fade,
- style: GoogleFonts.inter(
- color: textSecondary,
- fontSize: 7,
- fontWeight: FontWeight.w700,
- letterSpacing: .45,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
- );
-}
-
-class _LocalScenePreviewBar extends StatelessWidget {
- const _LocalScenePreviewBar({required this.unit});
-
- final _DroneData unit;
-
- @override
- Widget build(BuildContext context) => Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _MonoText(unit.id, color: textPrimary, size: 10),
- const SizedBox(height: 3),
- const _HudLabel('LOCAL VISUAL PREVIEW · NO VEHICLE ACTION'),
- ],
- ),
- ),
- const Icon(Icons.lock_outline, color: textSecondary, size: 17),
- ],
- );
-}
-
-class _UnitActionButton extends StatelessWidget {
- const _UnitActionButton({
- super.key,
- required this.label,
- required this.icon,
- required this.foreground,
- required this.background,
- required this.onPressed,
- });
-
- final String label;
- final IconData icon;
- final Color foreground;
- final Color background;
- final VoidCallback? onPressed;
-
- @override
- Widget build(BuildContext context) => SizedBox(
- height: 30,
- child: TextButton.icon(
- onPressed: onPressed,
- style: TextButton.styleFrom(
- foregroundColor: foreground,
- backgroundColor: background,
- padding: const EdgeInsets.symmetric(horizontal: 8),
- side: BorderSide(color: foreground.withValues(alpha: .7)),
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
- ),
- icon: Icon(icon, size: 14),
- label: Text(
- label,
- style: GoogleFonts.inter(
- color: foreground,
- fontSize: 8,
- fontWeight: FontWeight.w800,
- letterSpacing: .45,
- ),
- ),
- ),
- );
-}
-
-class _TacticalGlass extends StatelessWidget {
- const _TacticalGlass({
- required this.child,
- this.padding = EdgeInsets.zero,
- this.borderRadius = const BorderRadius.all(Radius.circular(12)),
- });
-
- final Widget child;
- final EdgeInsets padding;
- final BorderRadius borderRadius;
-
- @override
- Widget build(BuildContext context) {
- final highContrast = MediaQuery.maybeOf(context)?.highContrast ?? false;
- final surface = Container(
- padding: padding,
- decoration: BoxDecoration(
- color: highContrast ? const Color(0xFF12141A) : panelBackground,
- borderRadius: borderRadius,
- border: Border.all(
- color: highContrast ? textPrimary : panelBorder,
- width: highContrast ? 2 : 1,
- ),
- ),
- child: child,
- );
- if (highContrast) {
- return RepaintBoundary(child: surface);
- }
- return RepaintBoundary(
- child: ClipRRect(
- borderRadius: borderRadius,
- child: BackdropFilter(
- filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
- child: surface,
- ),
- ),
- );
- }
-}
-
-class _Token extends StatelessWidget {
- const _Token({required this.label, required this.tone});
-
- final String label;
- final Color tone;
-
- @override
- Widget build(BuildContext context) => Container(
- padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
- decoration: BoxDecoration(
- color: tone.withValues(alpha: .12),
- border: Border.all(color: tone.withValues(alpha: .55)),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [_HudLabel(label, color: tone)],
- ),
- );
-}
-
-class _HudLabel extends StatelessWidget {
- const _HudLabel(this.value, {this.color});
-
- final String value;
- final Color? color;
-
- @override
- Widget build(BuildContext context) => Text(
- value,
- style: GoogleFonts.inter(
- color: color ?? textSecondary,
- fontSize: 9,
- fontWeight: FontWeight.w700,
- letterSpacing: .85,
- ),
- );
-}
-
-class _MonoText extends StatelessWidget {
- const _MonoText(
- this.value, {
- this.color,
- this.size = 10,
- this.weight = FontWeight.w400,
- });
-
- final String value;
- final Color? color;
- final double size;
- final FontWeight weight;
-
- @override
- Widget build(BuildContext context) => Text(
- value,
- style: GoogleFonts.robotoMono(
- color: color ?? textSecondary,
- fontSize: size,
- fontWeight: weight,
- fontFeatures: const [FontFeature.tabularFigures()],
- ),
- );
-}
-
-class _DroneData {
- const _DroneData(
- this.id,
- this.speed,
- this.altitude,
- this.status,
- this.statusColor,
- this.battery,
- );
-
- final String id;
- final String speed;
- final String altitude;
- final String status;
- final Color statusColor;
- final double battery;
-}
-
-class _MapGridPainter extends CustomPainter {
- const _MapGridPainter();
-
- @override
- void paint(Canvas canvas, Size size) {
- final paint = Paint()
- ..color = accentMint.withValues(alpha: .07)
- ..strokeWidth = 1;
- const step = 82.0;
- for (var x = 0.0; x < size.width; x += step) {
- canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
- }
- for (var y = 0.0; y < size.height; y += step) {
- canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
- }
- }
-
- @override
- bool shouldRepaint(covariant _MapGridPainter oldDelegate) => false;
-}
-
-class _DemoMapOverlayPainter extends CustomPainter {
- const _DemoMapOverlayPainter({
- required this.drawings,
- required this.markers,
- required this.draftPencil,
- required this.draftMarker,
- required this.pointerPreview,
- required this.previewStroke,
- });
-
- final List drawings;
- final List markers;
- final TacticalPencilMode? draftPencil;
- final TacticalMarkerMode? draftMarker;
- final LocalScenePoint? pointerPreview;
- final List previewStroke;
-
- @override
- void paint(Canvas canvas, Size size) {
- for (final drawing in drawings) {
- _drawDrawing(
- canvas,
- size,
- drawing.points,
- color: _pencilColor(
- drawing.hue,
- drawing.saturation,
- drawing.brightness,
- ),
- thickness: drawing.thickness,
- );
- }
- final draft = draftPencil;
- if (draft != null && draft.keyboardPoints.isNotEmpty) {
- _drawDrawing(
- canvas,
- size,
- draft.keyboardPoints,
- color: _pencilColor(draft.hue, draft.saturation, draft.brightness),
- thickness: draft.thickness,
- draft: true,
- );
- }
- if (draft != null && previewStroke.isNotEmpty) {
- _drawDrawing(
- canvas,
- size,
- previewStroke,
- color: _pencilColor(draft.hue, draft.saturation, draft.brightness),
- thickness: draft.thickness,
- draft: true,
- );
- }
- final preview = pointerPreview;
- if (preview != null && draft != null) {
- final offset = Offset(preview.x * size.width, preview.y * size.height);
- canvas.drawCircle(
- offset,
- 8,
- Paint()
- ..color = _pencilColor(
- draft.hue,
- draft.saturation,
- draft.brightness,
- ).withValues(alpha: .9)
- ..style = PaintingStyle.stroke
- ..strokeWidth = draft.thickness,
- );
- }
- final marker = draftMarker;
- if (marker != null && preview != null && marker.pendingPoint == null) {
- _drawMarkerPreview(
- canvas,
- size,
- preview,
- color: _markerColor(marker.hue, marker.saturation, marker.brightness),
- );
- }
- if (marker?.pendingPoint case final point?) {
- _drawMarkerPreview(
- canvas,
- size,
- point,
- color: _markerColor(marker!.hue, marker.saturation, marker.brightness),
- pending: true,
- );
- }
- }
-
- void _drawMarkerPreview(
- Canvas canvas,
- Size size,
- LocalScenePoint point, {
- required Color color,
- bool pending = false,
- }) {
- final offset = Offset(point.x * size.width, point.y * size.height);
- final paint = Paint()
- ..color = color.withValues(alpha: pending ? .95 : .72)
- ..style = PaintingStyle.fill;
- final pin = Path()
- ..moveTo(offset.dx, offset.dy + 15)
- ..cubicTo(
- offset.dx - 11,
- offset.dy + 4,
- offset.dx - 9,
- offset.dy - 13,
- offset.dx,
- offset.dy - 13,
- )
- ..cubicTo(
- offset.dx + 9,
- offset.dy - 13,
- offset.dx + 11,
- offset.dy + 4,
- offset.dx,
- offset.dy + 15,
- )
- ..close();
- canvas.drawPath(pin, paint);
- canvas.drawCircle(
- Offset(offset.dx, offset.dy - 3),
- 3,
- Paint()..color = tacticalBlack,
- );
- }
-
- void _drawDrawing(
- Canvas canvas,
- Size size,
- List points, {
- required Color color,
- required double thickness,
- bool draft = false,
- }) {
- final paint = Paint()
- ..color = color.withValues(alpha: draft ? .75 : .9)
- ..style = PaintingStyle.stroke
- ..strokeCap = StrokeCap.round
- ..strokeJoin = StrokeJoin.round
- ..strokeWidth = draft ? math.max(1, thickness * .75) : thickness;
- final offsets = points
- .map((point) => Offset(point.x * size.width, point.y * size.height))
- .toList(growable: false);
- if (offsets.isEmpty) {
- return;
- }
- final path = Path()..moveTo(offsets.first.dx, offsets.first.dy);
- for (final offset in offsets.skip(1)) {
- path.lineTo(offset.dx, offset.dy);
- }
- canvas.drawPath(path, paint);
- }
-
- @override
- bool shouldRepaint(covariant _DemoMapOverlayPainter oldDelegate) =>
- !identical(drawings, oldDelegate.drawings) ||
- !identical(markers, oldDelegate.markers) ||
- draftPencil != oldDelegate.draftPencil ||
- draftMarker != oldDelegate.draftMarker ||
- pointerPreview != oldDelegate.pointerPreview ||
- !identical(previewStroke, oldDelegate.previewStroke);
-}
-
-class _FpvHudPainter extends CustomPainter {
- @override
- void paint(Canvas canvas, Size size) {
- final paint = Paint()
- ..color = accentMint
- ..style = PaintingStyle.stroke
- ..strokeWidth = 1.5
- ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
- final center = Offset(size.width / 2, size.height / 2);
- canvas.drawCircle(center, 15, paint);
- canvas.drawLine(
- Offset(center.dx - 54, center.dy),
- Offset(center.dx - 18, center.dy),
- paint,
- );
- canvas.drawLine(
- Offset(center.dx + 18, center.dy),
- Offset(center.dx + 54, center.dy),
- paint,
- );
- canvas.drawLine(
- Offset(center.dx, center.dy - 9),
- Offset(center.dx, center.dy + 9),
- paint,
- );
-
- final textPainter = TextPainter(textDirection: TextDirection.ltr);
- for (var index = -3; index <= 3; index++) {
- if (index == 0) {
- continue;
- }
- final y = center.dy - index * 24;
- final width = 18 + index.abs() * 7.0;
- canvas.drawLine(
- Offset(center.dx - width, y),
- Offset(center.dx + width, y),
- paint,
- );
- textPainter.text = TextSpan(
- text: '${index * 10}',
- style: GoogleFonts.robotoMono(color: accentMint, fontSize: 8),
- );
- textPainter.layout();
- textPainter.paint(canvas, Offset(center.dx + width + 5, y - 5));
- }
-
- final scalePaint = paint..color = accentMint.withValues(alpha: .62);
- final left = 70.0;
- final right = size.width - 70;
- for (var index = 0; index < 7; index++) {
- final y = 62 + index * ((size.height - 124) / 6);
- canvas.drawLine(Offset(left - 8, y), Offset(left + 8, y), scalePaint);
- canvas.drawLine(Offset(right - 8, y), Offset(right + 8, y), scalePaint);
- }
- }
-
- @override
- bool shouldRepaint(covariant _FpvHudPainter oldDelegate) => false;
-}
diff --git a/ground-suite/lib/features/command/presentation/tactical_controller.dart b/ground-suite/lib/features/command/presentation/tactical_controller.dart
deleted file mode 100644
index ce8cd43..0000000
--- a/ground-suite/lib/features/command/presentation/tactical_controller.dart
+++ /dev/null
@@ -1,616 +0,0 @@
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
-import '../../fleet/domain/fleet_unit.dart';
-import '../../vehicle_workspace/domain/vehicle_definition.dart';
-
-/// A non-modal tactical side panel. Tool state remains in [TacticalMode], so a
-/// panel can never silently become a map action.
-enum TacticalPanel { fleetRoster, unitHangar, unitSetup }
-
-/// Exactly one local map interaction can be active at a time.
-sealed class TacticalMode {
- const TacticalMode();
-}
-
-class TacticalBrowseMode extends TacticalMode {
- const TacticalBrowseMode();
-}
-
-/// The operator's uncommitted local-unit setup. This is intentionally a panel
-/// draft rather than a map tool: choosing a category never creates, connects,
-/// or configures a vehicle.
-class UnitCreationDraft {
- const UnitCreationDraft({
- required this.definition,
- required this.displayName,
- });
-
- final VehicleDefinition definition;
- final String displayName;
-
- UnitCreationDraft copyWith({String? displayName}) => UnitCreationDraft(
- definition: definition,
- displayName: displayName ?? this.displayName,
- );
-}
-
-/// Session-local freehand drawing tool. It never emits a vehicle command.
-class TacticalPencilMode extends TacticalMode {
- TacticalPencilMode({
- this.hue = 164,
- this.saturation = .78,
- this.brightness = .94,
- this.thickness = 3,
- List keyboardPoints = const [],
- }) : keyboardPoints = List.unmodifiable(keyboardPoints);
-
- final double hue;
- final double saturation;
- final double brightness;
- final double thickness;
- final List keyboardPoints;
-}
-
-/// A local, freehand mark. It deliberately has no map pin or label: Pencil is
-/// continuous ink, not a point-placement tool.
-class LocalSceneDrawing {
- LocalSceneDrawing({
- required this.id,
- required this.hue,
- required this.saturation,
- required this.brightness,
- required this.thickness,
- required List points,
- }) : points = List.unmodifiable(points);
-
- final String id;
- final double hue;
- final double saturation;
- final double brightness;
- final double thickness;
- final List points;
-}
-
-/// A local point annotation. It is kept distinct from [LocalSceneDrawing] so
-/// an accidental Pencil click can never become a named marker.
-class TacticalMarkerMode extends TacticalMode {
- const TacticalMarkerMode({
- this.hue = 26,
- this.saturation = .68,
- this.brightness = .98,
- this.pendingPoint,
- });
-
- final double hue;
- final double saturation;
- final double brightness;
- final LocalScenePoint? pendingPoint;
-}
-
-class LocalSceneMarker {
- const LocalSceneMarker({
- required this.id,
- required this.name,
- required this.point,
- required this.hue,
- required this.saturation,
- required this.brightness,
- });
-
- final String id;
- final String name;
- final LocalScenePoint point;
- final double hue;
- final double saturation;
- final double brightness;
-}
-
-class TacticalUiState {
- const TacticalUiState({
- required this.selectedUnitIds,
- required this.mode,
- required this.panel,
- required this.unitDraft,
- required this.drawings,
- required this.markers,
- required this.selectedDrawingId,
- required this.selectedMarkerId,
- required this.overlays,
- });
-
- factory TacticalUiState.initial() => const TacticalUiState(
- selectedUnitIds: {'DEMO-1021'},
- mode: TacticalBrowseMode(),
- panel: null,
- unitDraft: null,
- drawings: [],
- markers: [],
- selectedDrawingId: null,
- selectedMarkerId: null,
- overlays: {TacticalMapOverlay.drones},
- );
-
- final Set selectedUnitIds;
- final TacticalMode mode;
- final TacticalPanel? panel;
- final UnitCreationDraft? unitDraft;
- final List drawings;
- final List markers;
- final String? selectedDrawingId;
- final String? selectedMarkerId;
- final Set overlays;
-
- TacticalUiState copyWith({
- Set? selectedUnitIds,
- TacticalMode? mode,
- TacticalPanel? panel,
- bool clearPanel = false,
- UnitCreationDraft? unitDraft,
- bool clearUnitDraft = false,
- List? drawings,
- List? markers,
- String? selectedDrawingId,
- String? selectedMarkerId,
- bool clearSelectedDrawing = false,
- bool clearSelectedMarker = false,
- Set? overlays,
- }) => TacticalUiState(
- selectedUnitIds: selectedUnitIds == null
- ? this.selectedUnitIds
- : Set.unmodifiable(selectedUnitIds),
- mode: mode ?? this.mode,
- panel: clearPanel ? null : panel ?? this.panel,
- unitDraft: clearUnitDraft ? null : unitDraft ?? this.unitDraft,
- drawings: drawings == null ? this.drawings : List.unmodifiable(drawings),
- markers: markers == null ? this.markers : List.unmodifiable(markers),
- selectedDrawingId: clearSelectedDrawing
- ? null
- : selectedDrawingId ?? this.selectedDrawingId,
- selectedMarkerId: clearSelectedMarker
- ? null
- : selectedMarkerId ?? this.selectedMarkerId,
- overlays: overlays == null ? this.overlays : Set.unmodifiable(overlays),
- );
-}
-
-/// Only data-backed scene layers belong here. Synthetic sector, route, and
-/// range-ring artwork is intentionally not part of the tactical workspace.
-enum TacticalMapOverlay { drones }
-
-final tacticalControllerProvider =
- NotifierProvider(
- TacticalController.new,
- );
-
-/// Deterministic reducer for session-local tactical interaction state.
-///
-/// Agreed action taxonomy:
-/// - unit selection and placement affect only a local workspace;
-/// - Pencil affects only [LocalSceneDrawing] values;
-/// - Marker affects only [LocalSceneMarker] values;
-/// - overlay actions affect only local rendering.
-///
-/// This controller has no transport dependency and no method can emit MAVLink
-/// or create a live vehicle identity.
-class TacticalController extends Notifier {
- int _nextDrawing = 1;
- int _nextMarker = 1;
-
- @override
- TacticalUiState build() => TacticalUiState.initial();
-
- void selectUnit(String unitId, {bool toggle = false}) {
- final selected = toggle ? {...state.selectedUnitIds} : {};
- if (toggle && selected.contains(unitId)) {
- selected.remove(unitId);
- } else {
- selected.add(unitId);
- }
- state = state.copyWith(
- selectedUnitIds: selected,
- mode: const TacticalBrowseMode(),
- clearSelectedDrawing: true,
- clearSelectedMarker: true,
- clearPanel: true,
- );
- }
-
- /// Replaces the local visual selection after a drag-box gesture. This is a
- /// scene-only selection and cannot establish a source or vehicle binding.
- void selectUnits(Iterable unitIds) => state = state.copyWith(
- selectedUnitIds: Set.unmodifiable(unitIds.toSet()),
- mode: const TacticalBrowseMode(),
- clearSelectedDrawing: true,
- clearSelectedMarker: true,
- clearPanel: true,
- );
-
- void clearSelection() => state = state.copyWith(
- selectedUnitIds: const {},
- clearSelectedDrawing: true,
- clearSelectedMarker: true,
- );
-
- void showFleetRoster() =>
- state = state.copyWith(panel: TacticalPanel.fleetRoster);
-
- void closePanel() => state = state.copyWith(clearPanel: true);
-
- /// Opens the horizontal RTS hangar. The map remains in Browse mode behind
- /// it; the hangar never becomes a pointer-placement tool.
- void showUnitHangar() => state = state.copyWith(
- mode: const TacticalBrowseMode(),
- panel: TacticalPanel.unitHangar,
- clearUnitDraft: true,
- );
-
- void chooseUnitDefinition(
- VehicleDefinition definition, {
- required String displayName,
- }) => state = state.copyWith(
- panel: TacticalPanel.unitHangar,
- unitDraft: UnitCreationDraft(
- definition: definition,
- displayName: displayName,
- ),
- );
-
- void updateUnitDraftName(String displayName) {
- final draft = state.unitDraft;
- if (draft == null) {
- return;
- }
- state = state.copyWith(unitDraft: draft.copyWith(displayName: displayName));
- }
-
- void beginUnitSetup() {
- if (state.unitDraft == null) {
- return;
- }
- state = state.copyWith(panel: TacticalPanel.unitSetup);
- }
-
- /// Returns to the catalog without discarding the selected category or its
- /// local name draft. Closing the hangar is the explicit discard boundary.
- void returnToUnitHangar() =>
- state = state.copyWith(panel: TacticalPanel.unitHangar);
-
- /// Opens setup for the one already-selected local workspace unit. The ID is
- /// supplied by the presentation layer after it verifies the unit exists in
- /// its workspace; this reducer only accepts a matching tactical selection.
- void showUnitSetupForSelectedLocalUnit(String localUnitId) {
- if (state.unitDraft != null ||
- state.selectedUnitIds.length != 1 ||
- !state.selectedUnitIds.contains(localUnitId)) {
- return;
- }
- state = state.copyWith(
- mode: const TacticalBrowseMode(),
- panel: TacticalPanel.unitSetup,
- );
- }
-
- void closeUnitHangar() =>
- state = state.copyWith(clearPanel: true, clearUnitDraft: true);
-
- void completeUnitPlacement(String unitId) => state = state.copyWith(
- selectedUnitIds: {unitId},
- mode: const TacticalBrowseMode(),
- clearPanel: true,
- clearUnitDraft: true,
- );
-
- void beginPencil() => state = state.copyWith(
- mode: TacticalPencilMode(),
- selectedUnitIds: const {},
- clearSelectedDrawing: true,
- clearSelectedMarker: true,
- clearPanel: true,
- clearUnitDraft: true,
- );
-
- void configurePencil({
- double? hue,
- double? saturation,
- double? brightness,
- double? thickness,
- }) {
- final mode = state.mode;
- if (mode is! TacticalPencilMode) {
- return;
- }
- state = state.copyWith(
- mode: TacticalPencilMode(
- hue: hue ?? mode.hue,
- saturation: saturation ?? mode.saturation,
- brightness: brightness ?? mode.brightness,
- thickness: thickness ?? mode.thickness,
- keyboardPoints: mode.keyboardPoints,
- ),
- );
- }
-
- /// Accessible alternative to a direct pointer trace. Keyboard users add
- /// points at a movable scene cursor; it never exposes a grid or location.
- void addKeyboardPencilPoint(LocalScenePoint point) {
- final mode = state.mode;
- if (mode is! TacticalPencilMode) {
- return;
- }
- state = state.copyWith(
- mode: TacticalPencilMode(
- hue: mode.hue,
- saturation: mode.saturation,
- brightness: mode.brightness,
- thickness: mode.thickness,
- keyboardPoints: [...mode.keyboardPoints, point],
- ),
- );
- }
-
- bool get canFinishKeyboardPencil {
- final mode = state.mode;
- return mode is TacticalPencilMode && mode.keyboardPoints.length >= 2;
- }
-
- bool finishKeyboardPencil() {
- final mode = state.mode;
- if (mode is! TacticalPencilMode || !canFinishKeyboardPencil) {
- return false;
- }
- _commitDrawing(
- hue: mode.hue,
- saturation: mode.saturation,
- brightness: mode.brightness,
- thickness: mode.thickness,
- points: mode.keyboardPoints,
- );
- return true;
- }
-
- /// Commits a direct press-drag-release map gesture. Preview samples stay in
- /// the input widget; only the simplified stroke becomes durable local scene
- /// geometry here.
- bool commitPencilStroke(List points) {
- final mode = state.mode;
- if (mode is! TacticalPencilMode) {
- return false;
- }
- if (points.length < 2) {
- return false;
- }
- _commitDrawing(
- hue: mode.hue,
- saturation: mode.saturation,
- brightness: mode.brightness,
- thickness: mode.thickness,
- points: _decimateStroke(points),
- );
- return true;
- }
-
- void beginMarker() => state = state.copyWith(
- mode: const TacticalMarkerMode(),
- selectedUnitIds: const {},
- clearSelectedDrawing: true,
- clearSelectedMarker: true,
- clearPanel: true,
- clearUnitDraft: true,
- );
-
- void configureMarker({double? hue, double? saturation, double? brightness}) {
- final mode = state.mode;
- if (mode is! TacticalMarkerMode) {
- return;
- }
- state = state.copyWith(
- mode: TacticalMarkerMode(
- hue: hue ?? mode.hue,
- saturation: saturation ?? mode.saturation,
- brightness: brightness ?? mode.brightness,
- pendingPoint: mode.pendingPoint,
- ),
- );
- }
-
- void chooseMarkerPoint(LocalScenePoint point) {
- final mode = state.mode;
- if (mode is! TacticalMarkerMode || mode.pendingPoint != null) {
- return;
- }
- state = state.copyWith(
- mode: TacticalMarkerMode(
- hue: mode.hue,
- saturation: mode.saturation,
- brightness: mode.brightness,
- pendingPoint: point,
- ),
- );
- }
-
- /// Returns false when the label is not acceptable. Labels are local plain
- /// text only, deliberately bounded before they can reach a map renderer.
- bool commitMarkerName(String value) {
- final mode = state.mode;
- final name = value.trim();
- if (mode is! TacticalMarkerMode ||
- mode.pendingPoint == null ||
- name.isEmpty ||
- name.runes.length > 48) {
- return false;
- }
- final marker = LocalSceneMarker(
- id: 'local-marker-${_nextMarker++}',
- name: name,
- point: mode.pendingPoint!,
- hue: mode.hue,
- saturation: mode.saturation,
- brightness: mode.brightness,
- );
- state = state.copyWith(
- markers: [...state.markers, marker],
- selectedMarkerId: marker.id,
- clearSelectedDrawing: true,
- mode: TacticalMarkerMode(
- hue: mode.hue,
- saturation: mode.saturation,
- brightness: mode.brightness,
- ),
- );
- return true;
- }
-
- /// First cancellation closes an unfinished marker name prompt; the next
- /// returns to Select. This gives Escape/right click a predictable ladder.
- bool cancelActiveTool() {
- final mode = state.mode;
- if (mode is TacticalMarkerMode && mode.pendingPoint != null) {
- state = state.copyWith(
- mode: TacticalMarkerMode(
- hue: mode.hue,
- saturation: mode.saturation,
- brightness: mode.brightness,
- ),
- );
- return false;
- }
- cancelMode();
- return true;
- }
-
- void selectDrawing(String drawingId) => state = state.copyWith(
- selectedUnitIds: const {},
- selectedDrawingId: drawingId,
- clearSelectedMarker: true,
- mode: const TacticalBrowseMode(),
- );
-
- void selectMarker(String markerId) => state = state.copyWith(
- selectedUnitIds: const {},
- selectedMarkerId: markerId,
- clearSelectedDrawing: true,
- mode: const TacticalBrowseMode(),
- );
-
- void removeSelectedSceneObject() {
- final drawing = state.selectedDrawingId;
- final marker = state.selectedMarkerId;
- if (drawing != null) {
- state = state.copyWith(
- drawings: state.drawings.where((value) => value.id != drawing).toList(),
- clearSelectedDrawing: true,
- );
- return;
- }
- if (marker == null) {
- return;
- }
- state = state.copyWith(
- markers: state.markers.where((value) => value.id != marker).toList(),
- clearSelectedMarker: true,
- );
- }
-
- void clearSceneObjects() => state = state.copyWith(
- drawings: const [],
- markers: const [],
- clearSelectedDrawing: true,
- clearSelectedMarker: true,
- );
-
- void cancelMode() => state = state.copyWith(mode: const TacticalBrowseMode());
-
- void toggleOverlay(TacticalMapOverlay overlay, bool visible) {
- final overlays = {...state.overlays};
- if (visible) {
- overlays.add(overlay);
- } else {
- overlays.remove(overlay);
- }
- state = state.copyWith(overlays: overlays);
- }
-
- void resetLocalView() => state = state.copyWith(
- overlays: {TacticalMapOverlay.drones},
- mode: const TacticalBrowseMode(),
- );
-
- void _commitDrawing({
- required double hue,
- required double saturation,
- required double brightness,
- required double thickness,
- required List points,
- }) {
- final drawing = LocalSceneDrawing(
- id: 'local-drawing-${_nextDrawing++}',
- hue: hue,
- saturation: saturation,
- brightness: brightness,
- thickness: thickness,
- points: List.unmodifiable(points),
- );
- state = state.copyWith(
- drawings: [...state.drawings, drawing],
- selectedDrawingId: drawing.id,
- clearSelectedMarker: true,
- mode: TacticalPencilMode(
- hue: hue,
- saturation: saturation,
- brightness: brightness,
- thickness: thickness,
- ),
- );
- }
-
- // Preserve enough detail for a visibly hand-drawn line while keeping local
- // session state bounded. Input preview has its own higher short-lived cap.
- static const _maximumStrokePoints = 256;
-
- List _decimateStroke(List points) {
- if (points.length <= _maximumStrokePoints) {
- return List.unmodifiable(points);
- }
- return List.unmodifiable([
- for (var index = 0; index < _maximumStrokePoints; index++)
- points[((points.length - 1) * index / (_maximumStrokePoints - 1))
- .round()],
- ]);
- }
-}
-
-/// Pointer preview is isolated from command/selection state. A map widget may
-/// update it once per frame while a placement tool is active.
-class TacticalPointerPreview {
- TacticalPointerPreview({this.point, List stroke = const []})
- : stroke = List.unmodifiable(stroke);
-
- final LocalScenePoint? point;
- final List stroke;
-}
-
-final tacticalPointerPreviewProvider =
- NotifierProvider(
- TacticalPointerPreviewController.new,
- );
-
-class TacticalPointerPreviewController
- extends Notifier {
- @override
- TacticalPointerPreview build() => TacticalPointerPreview();
-
- void show(LocalScenePoint point, {List stroke = const []}) =>
- state = TacticalPointerPreview(point: point, stroke: stroke);
-
- void nudge(double dx, double dy) {
- final current = state.point ?? const LocalScenePoint(x: .5, y: .5);
- show(
- LocalScenePoint(
- x: (current.x + dx).clamp(0.0, 1.0),
- y: (current.y + dy).clamp(0.0, 1.0),
- ),
- );
- }
-
- void clear() => state = TacticalPointerPreview();
-}
diff --git a/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart b/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart
deleted file mode 100644
index b7a1df9..0000000
--- a/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart
+++ /dev/null
@@ -1,356 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
-import '../../../core/observations/observed_event.dart';
-import '../../../shared/widgets/severity_badge.dart';
-import '../../../shared/widgets/surfaces.dart';
-import '../../observability/presentation/observability_controller.dart';
-
-enum _DiagnosticFilter { all, logs, errors, shutdowns }
-
-class DiagnosticsPage extends ConsumerStatefulWidget {
- const DiagnosticsPage({super.key});
-
- @override
- ConsumerState createState() => _DiagnosticsPageState();
-}
-
-class _DiagnosticsPageState extends ConsumerState {
- _DiagnosticFilter _filter = _DiagnosticFilter.all;
- String _query = '';
-
- @override
- Widget build(BuildContext context) {
- final state = ref.watch(observabilityControllerProvider);
- final diagnostics = state.events
- .where(_isDiagnostic)
- .toList(growable: false);
- final events = diagnostics.where(_matches).toList(growable: false);
-
- return CustomScrollView(
- slivers: [
- SliverPadding(
- padding: const EdgeInsets.fromLTRB(28, 28, 28, 16),
- sliver: SliverToBoxAdapter(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const ConsoleLabel('Event stream'),
- const SizedBox(height: 8),
- Text(
- 'Event timeline',
- style: Theme.of(context).textTheme.headlineMedium,
- ),
- const SizedBox(height: 6),
- Text(
- 'Read-only decoded observations. Diagnostic delivery can be bounded and lossy.',
- style: Theme.of(context).textTheme.bodyMedium,
- ),
- const SizedBox(height: 8),
- Text(
- 'Retained diagnostics ${state.events.length} · local records not retained ${state.localTimelineEvictionCount}',
- style: Theme.of(
- context,
- ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
- ),
- const SizedBox(height: 20),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- for (final filter in _DiagnosticFilter.values)
- ChoiceChip(
- label: Text(_filterLabel(filter, diagnostics)),
- selected: _filter == filter,
- onSelected: (_) => setState(() => _filter = filter),
- ),
- ],
- ),
- const SizedBox(height: 12),
- TextField(
- decoration: const InputDecoration(
- border: OutlineInputBorder(),
- labelText: 'Search message, error path, or detail',
- prefixIcon: Icon(Icons.search),
- ),
- onChanged: (value) {
- setState(() => _query = value.trim().toLowerCase());
- },
- ),
- ],
- ),
- ),
- ),
- if (events.isEmpty)
- const SliverPadding(
- padding: EdgeInsets.fromLTRB(28, 0, 28, 28),
- sliver: SliverToBoxAdapter(
- child: InstrumentPanel(
- child: Padding(
- padding: EdgeInsets.all(20),
- child: Text('No events match this view.'),
- ),
- ),
- ),
- )
- else
- SliverPadding(
- padding: const EdgeInsets.fromLTRB(28, 0, 28, 28),
- sliver: SliverList.builder(
- itemCount: events.length,
- itemBuilder: (context, index) =>
- _DiagnosticRow(event: events[index]),
- ),
- ),
- ],
- );
- }
-
- bool _matches(ObservedEvent event) {
- final typeMatches = switch (_filter) {
- _DiagnosticFilter.all => true,
- _DiagnosticFilter.logs => event is LogEvent,
- _DiagnosticFilter.errors => event is ErrorEvent,
- _DiagnosticFilter.shutdowns => event is ShutdownEvent,
- };
- return typeMatches &&
- (_query.isEmpty ||
- _searchableText(event).toLowerCase().contains(_query));
- }
-}
-
-class _DiagnosticRow extends StatelessWidget {
- const _DiagnosticRow({required this.event});
-
- final ObservedEvent event;
-
- @override
- Widget build(BuildContext context) {
- final content = _contentFor(
- event,
- Theme.of(context).brightness == Brightness.dark,
- );
-
- return Semantics(
- container: true,
- label: content.semanticLabel,
- child: Padding(
- padding: const EdgeInsets.only(bottom: 8),
- child: InstrumentPanel(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Container(width: 3, height: 56, color: content.tone),
- const SizedBox(width: 12),
- SizedBox(
- width: 92,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- ConsoleLabel(content.kind, color: content.tone),
- const SizedBox(height: 6),
- Text(
- _timelineTimestamp(event.receivedAt),
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- fontFamily: 'monospace',
- ),
- ),
- ],
- ),
- ),
- const SizedBox(width: 8),
- content.icon,
- const SizedBox(width: 10),
- Expanded(
- child: SelectionArea(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- content.title,
- style: Theme.of(context).textTheme.titleMedium,
- ),
- const SizedBox(height: 4),
- Text(
- content.detail,
- style: content.monoDetail
- ? Theme.of(context).textTheme.bodyMedium
- ?.copyWith(fontFamily: 'monospace')
- : null,
- ),
- if (content.additionalDetail case final detail?) ...[
- const SizedBox(height: 8),
- Text(
- detail,
- style: Theme.of(context).textTheme.bodySmall,
- ),
- ],
- ],
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- ),
- );
- }
-}
-
-class _DiagnosticContent {
- const _DiagnosticContent({
- required this.kind,
- required this.tone,
- required this.icon,
- required this.title,
- required this.detail,
- required this.semanticLabel,
- this.additionalDetail,
- this.monoDetail = false,
- });
-
- final String kind;
- final Color tone;
- final Widget icon;
- final String title;
- final String detail;
- final String? additionalDetail;
- final String semanticLabel;
- final bool monoDetail;
-}
-
-_DiagnosticContent _contentFor(
- ObservedEvent event,
- bool dark,
-) => switch (event) {
- LogEvent(:final level, :final message, :final flags) => _DiagnosticContent(
- kind: 'Log',
- tone: _toneForSeverity(level, dark),
- icon: SeverityBadge(level: level),
- title: level.label,
- detail: message,
- additionalDetail: _formatLogFlags(flags),
- semanticLabel: _semanticLogLabel(level, message, flags),
- ),
- ErrorEvent(:final path, :final additionalInformation, :final flags) =>
- _DiagnosticContent(
- kind: 'Error',
- tone: dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
- icon: Icon(
- Icons.error_outline,
- color: dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
- ),
- title: 'Runtime error observed',
- detail: path,
- additionalDetail: _formatErrorDetail(additionalInformation, flags),
- semanticLabel: _semanticErrorLabel(path, additionalInformation, flags),
- monoDetail: true,
- ),
- ShutdownEvent(:final reason) => _DiagnosticContent(
- kind: 'Shutdown',
- tone: dark ? const Color(0xfff7b955) : const Color(0xffb54708),
- icon: Icon(
- Icons.power_settings_new_outlined,
- color: dark ? const Color(0xfff7b955) : const Color(0xffb54708),
- ),
- title: 'Orderly shutdown report',
- detail: 'Reason: ${reason.label}',
- additionalDetail:
- 'Completed orderly, output-inhibited report; not a command, panic, hard-fault, or reset.',
- semanticLabel: 'Orderly shutdown report: ${reason.label}',
- ),
- _ => throw StateError('only diagnostic events reach the timeline'),
-};
-
-bool _isDiagnostic(ObservedEvent event) =>
- event is LogEvent || event is ErrorEvent || event is ShutdownEvent;
-
-String _filterLabel(_DiagnosticFilter filter, List events) {
- final count = switch (filter) {
- _DiagnosticFilter.all => events.length,
- _DiagnosticFilter.logs => events.whereType().length,
- _DiagnosticFilter.errors => events.whereType().length,
- _DiagnosticFilter.shutdowns => events.whereType().length,
- };
- final label = switch (filter) {
- _DiagnosticFilter.all => 'All events',
- _DiagnosticFilter.logs => 'Logs',
- _DiagnosticFilter.errors => 'Errors',
- _DiagnosticFilter.shutdowns => 'Shutdown reports',
- };
- return '$label $count';
-}
-
-String _searchableText(ObservedEvent event) => switch (event) {
- LogEvent(:final level, :final message) => '${level.label} $message',
- ErrorEvent(:final path, :final additionalInformation) =>
- '$path $additionalInformation',
- ShutdownEvent(:final reason) => reason.label,
- _ => '',
-};
-
-Color _toneForSeverity(MavSeverity level, bool dark) =>
- switch (level.wireValue) {
- 0 ||
- 1 ||
- 2 ||
- 3 => dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
- 4 => dark ? const Color(0xfff7b955) : const Color(0xffb54708),
- 5 || 6 => dark ? const Color(0xff70b8ff) : const Color(0xff175cd3),
- 7 => dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
- _ => dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
- };
-
-String? _formatLogFlags(int flags) {
- final labels = [
- if (flags & LogEvent.traceFlag != 0) 'Trace',
- if (flags & LogEvent.truncatedFlag != 0) 'Truncated',
- if (flags & LogEvent.coalescedFlag != 0) 'Coalesced',
- if (flags & ~LogEvent.knownFlags != 0)
- 'Unknown bits 0x${(flags & ~LogEvent.knownFlags).toRadixString(16)}',
- ];
- return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}';
-}
-
-String? _formatErrorDetail(String additionalInformation, int flags) {
- final flagDetail = _formatErrorFlags(flags);
- final details = [
- if (additionalInformation.isNotEmpty) additionalInformation,
- ?flagDetail,
- ];
- return details.isEmpty ? null : details.join('\n');
-}
-
-String? _formatErrorFlags(int flags) {
- final labels = [
- if (flags & ErrorEvent.truncatedFlag != 0) 'Truncated',
- if (flags & ErrorEvent.coalescedFlag != 0) 'Coalesced',
- if (flags & ~ErrorEvent.knownFlags != 0)
- 'Unknown bits 0x${(flags & ~ErrorEvent.knownFlags).toRadixString(16)}',
- ];
- return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}';
-}
-
-String _semanticLogLabel(MavSeverity level, String message, int flags) {
- final flagDetail = _formatLogFlags(flags);
- return flagDetail == null
- ? '${level.label} log: $message'
- : '${level.label} log: $message. $flagDetail';
-}
-
-String _semanticErrorLabel(
- String path,
- String additionalInformation,
- int flags,
-) {
- final detail = _formatErrorDetail(additionalInformation, flags);
- return detail == null ? 'Error: $path' : 'Error: $path. $detail';
-}
-
-String _timelineTimestamp(DateTime value) =>
- '${value.hour.toString().padLeft(2, '0')}:'
- '${value.minute.toString().padLeft(2, '0')}:'
- '${value.second.toString().padLeft(2, '0')}Z';
diff --git a/ground-suite/lib/features/fleet/domain/fleet_unit.dart b/ground-suite/lib/features/fleet/domain/fleet_unit.dart
deleted file mode 100644
index f0d75e5..0000000
--- a/ground-suite/lib/features/fleet/domain/fleet_unit.dart
+++ /dev/null
@@ -1,46 +0,0 @@
-import '../../vehicle_workspace/domain/vehicle_definition.dart';
-
-/// An operator-created fleet unit in the local planning workspace.
-///
-/// It deliberately contains no received identity, platform, authority, or
-/// physical pose. Those must arrive through separate reviewed bindings later.
-class FleetUnit {
- const FleetUnit({
- required this.localUnitId,
- required this.displayName,
- required this.definition,
- required this.localPlacement,
- });
-
- final String localUnitId;
- final String displayName;
- final VehicleDefinition definition;
- final LocalScenePoint localPlacement;
-
- FleetUnit copyWith({String? displayName}) => FleetUnit(
- localUnitId: localUnitId,
- displayName: displayName ?? this.displayName,
- definition: definition,
- localPlacement: localPlacement,
- );
-}
-
-/// A normalized placement in the operator's local tactical scene.
-///
-/// This is deliberately not a geographic position, vehicle pose, or received
-/// location. A future map binding will keep reported coordinates separate.
-class LocalScenePoint {
- const LocalScenePoint({required this.x, required this.y})
- : assert(x >= 0 && x <= 1),
- assert(y >= 0 && y <= 1);
-
- final double x;
- final double y;
-
- @override
- bool operator ==(Object other) =>
- other is LocalScenePoint && other.x == x && other.y == y;
-
- @override
- int get hashCode => Object.hash(x, y);
-}
diff --git a/ground-suite/lib/features/observability/presentation/observability_controller.dart b/ground-suite/lib/features/observability/presentation/observability_controller.dart
deleted file mode 100644
index 50133c2..0000000
--- a/ground-suite/lib/features/observability/presentation/observability_controller.dart
+++ /dev/null
@@ -1,261 +0,0 @@
-import 'dart:async';
-
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
-import '../../../core/observations/observed_event.dart';
-import '../../../core/observations/vehicle_data_port.dart';
-import '../../../core/observations/vehicle_data_update.dart';
-import '../../../core/transport/replay_vehicle_data_port.dart';
-
-final vehicleDataPortProvider = Provider((ref) {
- final port = const ReplayVehicleDataPort();
- ref.onDispose(() => unawaited(port.close()));
- return port;
-});
-
-final observabilityControllerProvider =
- NotifierProvider(
- ObservabilityController.new,
- );
-
-class ObservabilityController extends Notifier {
- static const _criticalAnnouncementWindow = Duration(seconds: 2);
- static const _timelineCapacity = 500;
-
- StreamSubscription? _subscription;
- DateTime? _lastCriticalAnnouncementAt;
-
- @override
- ObservabilityState build() {
- final source = ref.read(vehicleDataPortProvider);
- ref.onDispose(_cancelSubscription);
- Future.microtask(() => _observe(source));
- return ObservabilityState.initial(source);
- }
-
- void _observe(VehicleDataPort source) {
- _subscription = source.observe().listen(
- _handleUpdate,
- onError: (Object _, StackTrace _) {
- state = state.copyWith(
- status: ObservationStatus.disconnected,
- accessibilityAnnouncement: 'Observation connection closed.',
- replaceAccessibilityAnnouncement: true,
- );
- },
- onDone: () {
- state = state.copyWith(
- status: source.isReplay
- ? ObservationStatus.replayComplete
- : ObservationStatus.disconnected,
- accessibilityAnnouncement: source.isReplay
- ? 'Replay complete.'
- : 'Observation connection closed.',
- replaceAccessibilityAnnouncement: true,
- );
- },
- );
- }
-
- void _handleUpdate(VehicleDataUpdate update) {
- switch (update) {
- case ObservationReceived(:final event):
- _record(event);
- case ObservationDropped(:final kind, :final count):
- state = state.copyWith(
- status: ObservationStatus.receiving,
- parserDropCount:
- state.parserDropCount +
- (kind == ObservationDropKind.parser ? count : 0),
- semanticDropCount:
- state.semanticDropCount +
- (kind == ObservationDropKind.semantic ? count : 0),
- );
- }
- }
-
- void _record(ObservedEvent event) {
- final announcement = _announcementFor(event);
- final retention = _isTimelineEvent(event)
- ? _retainTimelineEvent(state.events, event)
- : _TimelineRetention(state.events);
- final heartbeat = event is HeartbeatEvent ? event : state.lastHeartbeat;
- final observedStreams = switch (event) {
- TelemetryEvent(:final kind) => {...state.observedTelemetryKinds, kind},
- _ => state.observedTelemetryKinds,
- };
- state = state.copyWith(
- status: ObservationStatus.receiving,
- lastFrameAt: event.receivedAt,
- events: retention.events,
- lastHeartbeat: heartbeat,
- observedTelemetryKinds: Set.unmodifiable(observedStreams),
- localTimelineEvictionCount:
- state.localTimelineEvictionCount + retention.evictionCount,
- accessibilityAnnouncement: announcement.message,
- replaceAccessibilityAnnouncement: true,
- coalescedAlertCount:
- state.coalescedAlertCount + (announcement.coalesced ? 1 : 0),
- );
- }
-
- _AnnouncementDecision _announcementFor(ObservedEvent event) {
- final candidate = switch (event) {
- ErrorEvent(:final path) => 'Error received: $path',
- ShutdownEvent(:final reason) =>
- 'Orderly shutdown observed: ${reason.label}',
- _ => null,
- };
- if (candidate == null) {
- return const _AnnouncementDecision();
- }
-
- final now = DateTime.now();
- final previous = _lastCriticalAnnouncementAt;
- if (previous != null &&
- now.difference(previous) < _criticalAnnouncementWindow) {
- return const _AnnouncementDecision(coalesced: true);
- }
- _lastCriticalAnnouncementAt = now;
- return _AnnouncementDecision(message: candidate);
- }
-
- void _cancelSubscription() {
- _subscription?.cancel();
- }
-
- _TimelineRetention _retainTimelineEvent(
- List current,
- ObservedEvent incoming,
- ) {
- if (current.length < _timelineCapacity) {
- return _TimelineRetention([...current, incoming]);
- }
-
- final retained = List.of(current);
- final optionalIndex = retained.indexWhere((event) => !_isCritical(event));
- if (optionalIndex >= 0) {
- retained.removeAt(optionalIndex);
- retained.add(incoming);
- return _TimelineRetention(retained, evictionCount: 1);
- }
-
- if (_isCritical(incoming)) {
- retained.removeAt(0);
- retained.add(incoming);
- return _TimelineRetention(retained, evictionCount: 1);
- }
-
- return _TimelineRetention(retained, evictionCount: 1);
- }
-}
-
-bool _isCritical(ObservedEvent event) =>
- event is ErrorEvent || event is ShutdownEvent;
-
-bool _isTimelineEvent(ObservedEvent event) =>
- event is LogEvent || _isCritical(event);
-
-class _TimelineRetention {
- _TimelineRetention(Iterable events, {this.evictionCount = 0})
- : events = List.unmodifiable(events);
-
- final List events;
- final int evictionCount;
-}
-
-enum ObservationStatus {
- starting('Starting'),
- receiving('Observing'),
- replayComplete('Replay complete'),
- disconnected('Disconnected');
-
- const ObservationStatus(this.label);
-
- final String label;
-}
-
-class ObservabilityState {
- const ObservabilityState({
- required this.sourceLabel,
- required this.isReplay,
- required this.status,
- required this.events,
- required this.lastHeartbeat,
- required this.observedTelemetryKinds,
- required this.parserDropCount,
- required this.semanticDropCount,
- required this.coalescedAlertCount,
- required this.localTimelineEvictionCount,
- this.lastFrameAt,
- this.accessibilityAnnouncement,
- });
-
- factory ObservabilityState.initial(VehicleDataPort source) {
- return ObservabilityState(
- sourceLabel: source.label,
- isReplay: source.isReplay,
- status: ObservationStatus.starting,
- events: const [],
- lastHeartbeat: null,
- observedTelemetryKinds: const {},
- parserDropCount: 0,
- semanticDropCount: 0,
- coalescedAlertCount: 0,
- localTimelineEvictionCount: 0,
- );
- }
-
- final String sourceLabel;
- final bool isReplay;
- final ObservationStatus status;
- final List events;
- final HeartbeatEvent? lastHeartbeat;
- final Set observedTelemetryKinds;
- final int parserDropCount;
- final int semanticDropCount;
- final int coalescedAlertCount;
- final int localTimelineEvictionCount;
- final DateTime? lastFrameAt;
- final String? accessibilityAnnouncement;
-
- ObservabilityState copyWith({
- ObservationStatus? status,
- List? events,
- HeartbeatEvent? lastHeartbeat,
- Set? observedTelemetryKinds,
- int? parserDropCount,
- int? semanticDropCount,
- int? coalescedAlertCount,
- int? localTimelineEvictionCount,
- DateTime? lastFrameAt,
- String? accessibilityAnnouncement,
- bool replaceAccessibilityAnnouncement = false,
- }) {
- return ObservabilityState(
- sourceLabel: sourceLabel,
- isReplay: isReplay,
- status: status ?? this.status,
- events: events ?? this.events,
- lastHeartbeat: lastHeartbeat ?? this.lastHeartbeat,
- observedTelemetryKinds:
- observedTelemetryKinds ?? this.observedTelemetryKinds,
- parserDropCount: parserDropCount ?? this.parserDropCount,
- semanticDropCount: semanticDropCount ?? this.semanticDropCount,
- coalescedAlertCount: coalescedAlertCount ?? this.coalescedAlertCount,
- localTimelineEvictionCount:
- localTimelineEvictionCount ?? this.localTimelineEvictionCount,
- lastFrameAt: lastFrameAt ?? this.lastFrameAt,
- accessibilityAnnouncement: replaceAccessibilityAnnouncement
- ? accessibilityAnnouncement
- : this.accessibilityAnnouncement,
- );
- }
-}
-
-class _AnnouncementDecision {
- const _AnnouncementDecision({this.message, this.coalesced = false});
-
- final String? message;
- final bool coalesced;
-}
diff --git a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_catalog.dart b/ground-suite/lib/features/vehicle_workspace/domain/vehicle_catalog.dart
deleted file mode 100644
index cff1411..0000000
--- a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_catalog.dart
+++ /dev/null
@@ -1,66 +0,0 @@
-import 'vehicle_definition.dart';
-
-/// Revision of the local reviewed definition catalog.
-///
-/// TODO(gcs-vehicle-catalog): generate this catalog from reviewed firmware
-/// build manifests, then persist both this revision and a definition ID in a
-/// versioned local vehicle workspace record.
-const vehicleDefinitionCatalogRevision = 1;
-
-/// Local vehicle types available in the tactical scene catalog.
-const supportedVehicleDefinitions = [
- VehicleDefinition(
- id: 'kaptan.vehicle.quadcopter.local',
- name: 'Quadcopter',
- vehicleClass: 'Multirotor',
- supportedProfile: 'Local scene unit',
- media: VehicleCatalogMedia(
- imageAssetPath: 'assets/vehicle_catalog/photos/quadcopter.png',
- ),
- ),
- VehicleDefinition(
- id: 'kaptan.vehicle.fixed-wing.local',
- name: 'Fixed-wing',
- vehicleClass: 'Fixed-wing',
- supportedProfile: 'Local scene unit',
- media: VehicleCatalogMedia(
- imageAssetPath: 'assets/vehicle_catalog/photos/fixed_wing.png',
- ),
- ),
- VehicleDefinition(
- id: 'kaptan.vehicle.tiltrotor-vtol.local',
- name: 'Tiltrotor VTOL',
- vehicleClass: 'VTOL',
- supportedProfile: 'Local scene unit',
- media: VehicleCatalogMedia(
- imageAssetPath: 'assets/vehicle_catalog/photos/tiltrotor_vtol.png',
- ),
- ),
- VehicleDefinition(
- id: 'kaptan.vehicle.helicopter.local',
- name: 'Helicopter',
- vehicleClass: 'Rotorcraft',
- supportedProfile: 'Local scene unit',
- media: VehicleCatalogMedia(
- imageAssetPath: 'assets/vehicle_catalog/photos/helicopter.png',
- ),
- ),
- VehicleDefinition(
- id: 'kaptan.vehicle.rover.local',
- name: 'Ground rover',
- vehicleClass: 'Ground vehicle',
- supportedProfile: 'Local scene unit',
- media: VehicleCatalogMedia(
- imageAssetPath: 'assets/vehicle_catalog/photos/rover.png',
- ),
- ),
- VehicleDefinition(
- id: 'kaptan.vehicle.surface-vessel.local',
- name: 'Surface vessel',
- vehicleClass: 'Marine vehicle',
- supportedProfile: 'Local scene unit',
- media: VehicleCatalogMedia(
- imageAssetPath: 'assets/vehicle_catalog/photos/surface_vessel.png',
- ),
- ),
-];
diff --git a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_definition.dart b/ground-suite/lib/features/vehicle_workspace/domain/vehicle_definition.dart
deleted file mode 100644
index 19a8428..0000000
--- a/ground-suite/lib/features/vehicle_workspace/domain/vehicle_definition.dart
+++ /dev/null
@@ -1,27 +0,0 @@
-/// Bundled profile image for a local vehicle definition.
-class VehicleCatalogMedia {
- const VehicleCatalogMedia({required this.imageAssetPath});
-
- final String imageAssetPath;
-}
-
-/// A reviewed vehicle definition that can own a Ground Suite workspace.
-///
-/// It is deliberately separate from platform identity, compatibility, and
-/// write authority reported by a future live vehicle source.
-class VehicleDefinition {
- const VehicleDefinition({
- required this.id,
- required this.name,
- required this.vehicleClass,
- required this.supportedProfile,
- required this.media,
- });
-
- /// Stable local identity for persistence and future build matching.
- final String id;
- final String name;
- final String vehicleClass;
- final String supportedProfile;
- final VehicleCatalogMedia media;
-}
diff --git a/ground-suite/lib/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart b/ground-suite/lib/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart
deleted file mode 100644
index c44b15c..0000000
--- a/ground-suite/lib/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart
+++ /dev/null
@@ -1,127 +0,0 @@
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
-import '../../fleet/domain/fleet_unit.dart';
-import '../domain/vehicle_definition.dart';
-
-final vehicleWorkspaceProvider =
- NotifierProvider(
- VehicleWorkspaceController.new,
- );
-
-/// Local fleet workspace state.
-///
-/// TODO(gcs-fleet-storage): persist local-unit IDs and reviewed definition IDs
-/// in a versioned workspace store.
-class VehicleWorkspaceState {
- const VehicleWorkspaceState({required this.units});
-
- const VehicleWorkspaceState.unselected() : units = const [];
-
- final List units;
-
- bool get hasUnits => units.isNotEmpty;
-
- VehicleWorkspaceState copyWith({List? units}) =>
- VehicleWorkspaceState(units: units ?? this.units);
-}
-
-class VehicleWorkspaceController extends Notifier {
- static const maximumLocalUnits = 64;
- static const _minimumSeparationSquared = .0016;
-
- @override
- VehicleWorkspaceState build() => const VehicleWorkspaceState.unselected();
-
- /// Validation shared by the hangar draft and committed workspace updates.
- /// It applies only to the operator-local display label, never a vehicle ID.
- static bool isValidLocalDisplayName(String value) =>
- _normalizeDisplayName(value) != null;
-
- bool canPlace(LocalScenePoint localPlacement) =>
- state.units.length < maximumLocalUnits &&
- state.units.every((unit) {
- final dx = unit.localPlacement.x - localPlacement.x;
- final dy = unit.localPlacement.y - localPlacement.y;
- return dx * dx + dy * dy >= _minimumSeparationSquared;
- });
-
- bool addUnit({
- required VehicleDefinition definition,
- required LocalScenePoint localPlacement,
- String? displayName,
- }) {
- if (!canPlace(localPlacement)) {
- return false;
- }
- final sequence = state.units.length + 1;
- final unit = FleetUnit(
- localUnitId: 'local-unit-$sequence',
- displayName:
- _normalizeDisplayName(displayName) ??
- '${definition.name.toUpperCase()}-${sequence.toString().padLeft(2, '0')}',
- definition: definition,
- localPlacement: localPlacement,
- );
- state = VehicleWorkspaceState(units: [...state.units, unit]);
- return true;
- }
-
- /// Creates a local unit at a deterministic, scattered scene position.
- ///
- /// Until a reviewed MAVLink position binding exists, this is presentation
- /// placement only: it is neither a GPS position nor received vehicle data.
- bool addUnitAutomatically({
- required VehicleDefinition definition,
- String? displayName,
- }) {
- for (var attempt = 0; attempt < maximumLocalUnits; attempt++) {
- final sequence = state.units.length + attempt + 1;
- final placement = LocalScenePoint(
- x: .14 + _fraction(sequence * .61803398875) * .72,
- y: .16 + _fraction(sequence * .41421356237) * .66,
- );
- if (addUnit(
- definition: definition,
- localPlacement: placement,
- displayName: displayName,
- )) {
- return true;
- }
- }
- return false;
- }
-
- static double _fraction(double value) => value - value.floorToDouble();
-
- bool renameUnit(String localUnitId, String displayName) {
- final normalized = _normalizeDisplayName(displayName);
- if (normalized == null) {
- return false;
- }
- var changed = false;
- final units = state.units
- .map((unit) {
- if (unit.localUnitId != localUnitId) {
- return unit;
- }
- changed = true;
- return unit.copyWith(displayName: normalized);
- })
- .toList(growable: false);
- if (!changed) {
- return false;
- }
- state = state.copyWith(units: units);
- return true;
- }
-
- static String? _normalizeDisplayName(String? value) {
- final normalized = value?.trim();
- if (normalized == null ||
- normalized.isEmpty ||
- normalized.runes.length > 32) {
- return null;
- }
- return normalized;
- }
-}
diff --git a/ground-suite/lib/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart b/ground-suite/lib/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart
deleted file mode 100644
index ddf634f..0000000
--- a/ground-suite/lib/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart
+++ /dev/null
@@ -1,139 +0,0 @@
-import 'package:flutter/material.dart';
-
-import '../../../../shared/widgets/surfaces.dart';
-import '../../domain/vehicle_definition.dart';
-
-/// A compact, keyboard-selectable workspace definition tile.
-///
-/// Only a reviewed [VehicleDefinition] reaches this widget. The radio control
-/// changes local pending workspace state; it does not connect, configure, or
-/// identify a vehicle.
-class VehicleCatalogCard extends StatefulWidget {
- const VehicleCatalogCard({
- super.key,
- required this.definition,
- required this.selected,
- required this.onSelected,
- });
-
- final VehicleDefinition definition;
- final bool selected;
- final VoidCallback onSelected;
-
- @override
- State createState() => _VehicleCatalogCardState();
-}
-
-class _VehicleCatalogCardState extends State {
- bool _showFocus = false;
-
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
- final accent = widget.selected || _showFocus ? scheme.primary : null;
-
- return Semantics(
- button: true,
- checked: widget.selected,
- inMutuallyExclusiveGroup: true,
- label:
- '${widget.definition.name}, ${widget.definition.vehicleClass}, '
- '${widget.definition.supportedProfile}, local unit type, '
- 'local vehicle image, not telemetry',
- onTap: widget.onSelected,
- child: DecoratedBox(
- decoration: BoxDecoration(
- border: _showFocus
- ? Border.all(color: scheme.primary, width: 2)
- : null,
- borderRadius: BorderRadius.circular(14),
- ),
- child: Material(
- color: Colors.transparent,
- child: InkWell(
- excludeFromSemantics: true,
- onFocusChange: (value) => setState(() => _showFocus = value),
- onTap: widget.onSelected,
- borderRadius: BorderRadius.circular(12),
- child: InstrumentPanel(
- accent: accent,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _VehicleImage(media: widget.definition.media),
- Padding(
- padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- ConsoleLabel('Local unit type'),
- const SizedBox(height: 6),
- Text(
- widget.definition.name,
- style: Theme.of(context).textTheme.titleMedium,
- ),
- const SizedBox(height: 2),
- Text(
- '${widget.definition.vehicleClass} · ${widget.definition.supportedProfile}',
- style: Theme.of(context).textTheme.bodySmall,
- ),
- const SizedBox(height: 10),
- Row(
- children: [
- Icon(
- widget.selected
- ? Icons.radio_button_checked
- : Icons.radio_button_off,
- size: 18,
- color: widget.selected
- ? scheme.primary
- : scheme.onSurfaceVariant,
- ),
- const SizedBox(width: 7),
- Expanded(
- child: Text(
- widget.selected
- ? 'Selected for workspace'
- : 'Select for workspace',
- style: Theme.of(context).textTheme.labelLarge,
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
- ),
- ),
- );
- }
-}
-
-class _VehicleImage extends StatelessWidget {
- const _VehicleImage({required this.media});
-
- final VehicleCatalogMedia media;
-
- @override
- Widget build(BuildContext context) => ClipRRect(
- borderRadius: const BorderRadius.vertical(top: Radius.circular(11)),
- child: AspectRatio(
- aspectRatio: 16 / 10,
- child: Image.asset(
- media.imageAssetPath,
- key: Key('vehicle-image-${media.imageAssetPath}'),
- fit: BoxFit.cover,
- filterQuality: FilterQuality.high,
- excludeFromSemantics: true,
- errorBuilder: (context, error, stackTrace) => ColoredBox(
- color: Theme.of(context).colorScheme.surfaceContainerHighest,
- child: const Center(child: Icon(Icons.image_not_supported_outlined)),
- ),
- ),
- ),
- );
-}
diff --git a/ground-suite/lib/main.dart b/ground-suite/lib/main.dart
deleted file mode 100644
index bd40337..0000000
--- a/ground-suite/lib/main.dart
+++ /dev/null
@@ -1,7 +0,0 @@
-import 'package:flutter/widgets.dart';
-
-import 'app/app.dart';
-
-void main() {
- runApp(const GroundSuiteApp());
-}
diff --git a/ground-suite/lib/shared/widgets/severity_badge.dart b/ground-suite/lib/shared/widgets/severity_badge.dart
deleted file mode 100644
index b3559b7..0000000
--- a/ground-suite/lib/shared/widgets/severity_badge.dart
+++ /dev/null
@@ -1,41 +0,0 @@
-import 'package:flutter/material.dart';
-
-import '../../core/observations/observed_event.dart';
-
-class SeverityBadge extends StatelessWidget {
- const SeverityBadge({required this.level, super.key});
-
- final MavSeverity level;
-
- @override
- Widget build(BuildContext context) {
- final dark = Theme.of(context).brightness == Brightness.dark;
- final (icon, color) = switch (level.wireValue) {
- 0 || 1 || 2 || 3 => (
- Icons.error_outline,
- dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
- ),
- 4 => (
- Icons.warning_amber_outlined,
- dark ? const Color(0xfff7b955) : const Color(0xffb54708),
- ),
- 5 || 6 => (
- Icons.info_outline,
- dark ? const Color(0xff70b8ff) : const Color(0xff175cd3),
- ),
- 7 => (
- Icons.bug_report_outlined,
- dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
- ),
- _ => (
- Icons.help_outline,
- dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
- ),
- };
-
- return Semantics(
- label: 'Severity: ${level.label}',
- child: Icon(icon, color: color),
- );
- }
-}
diff --git a/ground-suite/lib/shared/widgets/surfaces.dart b/ground-suite/lib/shared/widgets/surfaces.dart
deleted file mode 100644
index fb603ef..0000000
--- a/ground-suite/lib/shared/widgets/surfaces.dart
+++ /dev/null
@@ -1,216 +0,0 @@
-import 'dart:ui';
-
-import 'package:flutter/material.dart';
-
-import '../../app/theme.dart';
-
-/// A blurred surface for stable application chrome only.
-///
-/// Keep this out of scrollable content. Diagnostic rows and metric panels use
-/// [InstrumentPanel], which has the same visual language without creating a
-/// compositing layer per item.
-class FrostedChrome extends StatelessWidget {
- const FrostedChrome({
- required this.child,
- super.key,
- this.borderRadius = BorderRadius.zero,
- });
-
- final Widget child;
- final BorderRadius borderRadius;
-
- @override
- Widget build(BuildContext context) {
- final highContrast = MediaQuery.maybeOf(context)?.highContrast ?? false;
- final surface = _SurfaceFrame(
- borderRadius: borderRadius,
- chrome: true,
- opaque: highContrast,
- child: child,
- );
- if (highContrast) {
- return surface;
- }
-
- return ClipRRect(
- borderRadius: borderRadius,
- child: BackdropFilter(
- filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
- child: surface,
- ),
- );
- }
-}
-
-/// An opaque, high-legibility panel for operational data and event rows.
-class InstrumentPanel extends StatelessWidget {
- const InstrumentPanel({
- required this.child,
- super.key,
- this.borderRadius = const BorderRadius.all(Radius.circular(12)),
- this.accent,
- });
-
- final Widget child;
- final BorderRadius borderRadius;
- final Color? accent;
-
- @override
- Widget build(BuildContext context) => _SurfaceFrame(
- borderRadius: borderRadius,
- accent: accent,
- opaque: true,
- child: child,
- );
-}
-
-class _SurfaceFrame extends StatelessWidget {
- const _SurfaceFrame({
- required this.child,
- required this.borderRadius,
- this.chrome = false,
- this.opaque = false,
- this.accent,
- });
-
- final Widget child;
- final BorderRadius borderRadius;
- final bool chrome;
- final bool opaque;
- final Color? accent;
-
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
- final dark = scheme.brightness == Brightness.dark;
- final opacity = opaque ? 0xf6 : (dark ? 0xbf : 0xd9);
- final top = dark
- ? Color.fromARGB(
- opacity,
- chrome ? 12 : 16,
- chrome ? 17 : 23,
- chrome ? 14 : 18,
- )
- : Color.fromARGB(opacity, 255, 255, 255);
- final bottom = dark
- ? Color.fromARGB(opacity, 8, 11, 9)
- : Color.fromARGB(opacity, 244, 248, 244);
- final border =
- accent?.withValues(alpha: 0.74) ??
- scheme.outline.withValues(alpha: dark ? 0.92 : 0.82);
-
- return DecoratedBox(
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.topLeft,
- end: Alignment.bottomRight,
- colors: [top, bottom],
- ),
- border: Border.all(color: border),
- borderRadius: borderRadius,
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: dark ? 0.18 : 0.06),
- blurRadius: 18,
- offset: const Offset(0, 8),
- ),
- ],
- ),
- child: child,
- );
- }
-}
-
-/// The static canvas behind Ground Suite's chrome and instrument surfaces.
-class AppCanvas extends StatelessWidget {
- const AppCanvas({required this.child, super.key});
-
- final Widget child;
-
- @override
- Widget build(BuildContext context) {
- final dark = Theme.of(context).brightness == Brightness.dark;
- final base = dark ? const Color(0xff080b09) : const Color(0xfff3f6f3);
- final primaryHaze = (dark ? accentMint : const Color(0xff087a3b))
- .withValues(alpha: dark ? 0.075 : 0.05);
-
- return ColoredBox(
- color: base,
- child: Stack(
- children: [
- Positioned(
- top: -250,
- right: -200,
- child: IgnorePointer(child: _Haze(color: primaryHaze)),
- ),
- Positioned(
- bottom: -330,
- left: -260,
- child: IgnorePointer(
- child: _Haze(color: primaryHaze.withValues(alpha: 0.55)),
- ),
- ),
- Positioned.fill(child: child),
- ],
- ),
- );
- }
-}
-
-class _Haze extends StatelessWidget {
- const _Haze({required this.color});
-
- final Color color;
-
- @override
- Widget build(BuildContext context) => Container(
- width: 600,
- height: 600,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- gradient: RadialGradient(colors: [color, Colors.transparent]),
- ),
- );
-}
-
-class ConsoleLabel extends StatelessWidget {
- const ConsoleLabel(this.text, {super.key, this.color});
-
- final String text;
- final Color? color;
-
- @override
- Widget build(BuildContext context) => Text(
- text.toUpperCase(),
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: color ?? Theme.of(context).colorScheme.onSurfaceVariant,
- fontFamily: 'monospace',
- fontWeight: FontWeight.w700,
- letterSpacing: 1.25,
- ),
- );
-}
-
-class StatusBadge extends StatelessWidget {
- const StatusBadge({required this.label, super.key, this.color});
-
- final String label;
- final Color? color;
-
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
- final tone = color ?? scheme.onSurfaceVariant;
- return DecoratedBox(
- decoration: BoxDecoration(
- color: tone.withValues(alpha: 0.11),
- border: Border.all(color: tone.withValues(alpha: 0.45)),
- borderRadius: BorderRadius.circular(7),
- ),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
- child: ConsoleLabel(label, color: tone),
- ),
- );
- }
-}
diff --git a/ground-suite/linux/.gitignore b/ground-suite/linux/.gitignore
deleted file mode 100644
index d3896c9..0000000
--- a/ground-suite/linux/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-flutter/ephemeral
diff --git a/ground-suite/linux/CMakeLists.txt b/ground-suite/linux/CMakeLists.txt
deleted file mode 100644
index e06702e..0000000
--- a/ground-suite/linux/CMakeLists.txt
+++ /dev/null
@@ -1,128 +0,0 @@
-# Project-level configuration.
-cmake_minimum_required(VERSION 3.13)
-project(runner LANGUAGES CXX)
-
-# The name of the executable created for the application. Change this to change
-# the on-disk name of your application.
-set(BINARY_NAME "ground_suite")
-# The unique GTK application identifier for this application. See:
-# https://wiki.gnome.org/HowDoI/ChooseApplicationID
-set(APPLICATION_ID "dev.kaptan.ground_suite")
-
-# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
-# versions of CMake.
-cmake_policy(SET CMP0063 NEW)
-
-# Load bundled libraries from the lib/ directory relative to the binary.
-set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
-
-# Root filesystem for cross-building.
-if(FLUTTER_TARGET_PLATFORM_SYSROOT)
- set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
- set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
- set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
- set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
- set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
- set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
-endif()
-
-# Define build configuration options.
-if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
- set(CMAKE_BUILD_TYPE "Debug" CACHE
- STRING "Flutter build mode" FORCE)
- set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
- "Debug" "Profile" "Release")
-endif()
-
-# Compilation settings that should be applied to most targets.
-#
-# Be cautious about adding new options here, as plugins use this function by
-# default. In most cases, you should add new options to specific targets instead
-# of modifying this function.
-function(APPLY_STANDARD_SETTINGS TARGET)
- target_compile_features(${TARGET} PUBLIC cxx_std_14)
- target_compile_options(${TARGET} PRIVATE -Wall -Werror)
- target_compile_options(${TARGET} PRIVATE "$<$>:-O3>")
- target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>")
-endfunction()
-
-# Flutter library and tool build rules.
-set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
-add_subdirectory(${FLUTTER_MANAGED_DIR})
-
-# System-level dependencies.
-find_package(PkgConfig REQUIRED)
-pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
-
-# Application build; see runner/CMakeLists.txt.
-add_subdirectory("runner")
-
-# Run the Flutter tool portions of the build. This must not be removed.
-add_dependencies(${BINARY_NAME} flutter_assemble)
-
-# Only the install-generated bundle's copy of the executable will launch
-# correctly, since the resources must in the right relative locations. To avoid
-# people trying to run the unbundled copy, put it in a subdirectory instead of
-# the default top-level location.
-set_target_properties(${BINARY_NAME}
- PROPERTIES
- RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
-)
-
-
-# Generated plugin build rules, which manage building the plugins and adding
-# them to the application.
-include(flutter/generated_plugins.cmake)
-
-
-# === Installation ===
-# By default, "installing" just makes a relocatable bundle in the build
-# directory.
-set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
-if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
- set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
-endif()
-
-# Start with a clean build bundle directory every time.
-install(CODE "
- file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
- " COMPONENT Runtime)
-
-set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
-set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
-
-install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
- COMPONENT Runtime)
-
-install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
- COMPONENT Runtime)
-
-install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
- COMPONENT Runtime)
-
-foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
- install(FILES "${bundled_library}"
- DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
- COMPONENT Runtime)
-endforeach(bundled_library)
-
-# Copy the native assets provided by the build.dart from all packages.
-set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
-install(DIRECTORY "${NATIVE_ASSETS_DIR}"
- DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
- COMPONENT Runtime)
-
-# Fully re-copy the assets directory on each build to avoid having stale files
-# from a previous install.
-set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
-install(CODE "
- file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
- " COMPONENT Runtime)
-install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
- DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
-
-# Install the AOT library on non-Debug builds only.
-if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
- install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
- COMPONENT Runtime)
-endif()
diff --git a/ground-suite/linux/flutter/CMakeLists.txt b/ground-suite/linux/flutter/CMakeLists.txt
deleted file mode 100644
index d5bd016..0000000
--- a/ground-suite/linux/flutter/CMakeLists.txt
+++ /dev/null
@@ -1,88 +0,0 @@
-# This file controls Flutter-level build steps. It should not be edited.
-cmake_minimum_required(VERSION 3.10)
-
-set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
-
-# Configuration provided via flutter tool.
-include(${EPHEMERAL_DIR}/generated_config.cmake)
-
-# TODO: Move the rest of this into files in ephemeral. See
-# https://github.com/flutter/flutter/issues/57146.
-
-# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
-# which isn't available in 3.10.
-function(list_prepend LIST_NAME PREFIX)
- set(NEW_LIST "")
- foreach(element ${${LIST_NAME}})
- list(APPEND NEW_LIST "${PREFIX}${element}")
- endforeach(element)
- set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
-endfunction()
-
-# === Flutter Library ===
-# System-level dependencies.
-find_package(PkgConfig REQUIRED)
-pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
-pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
-pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
-
-set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
-
-# Published to parent scope for install step.
-set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
-set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
-set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
-set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
-
-list(APPEND FLUTTER_LIBRARY_HEADERS
- "fl_basic_message_channel.h"
- "fl_binary_codec.h"
- "fl_binary_messenger.h"
- "fl_dart_project.h"
- "fl_engine.h"
- "fl_json_message_codec.h"
- "fl_json_method_codec.h"
- "fl_message_codec.h"
- "fl_method_call.h"
- "fl_method_channel.h"
- "fl_method_codec.h"
- "fl_method_response.h"
- "fl_plugin_registrar.h"
- "fl_plugin_registry.h"
- "fl_standard_message_codec.h"
- "fl_standard_method_codec.h"
- "fl_string_codec.h"
- "fl_value.h"
- "fl_view.h"
- "flutter_linux.h"
-)
-list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
-add_library(flutter INTERFACE)
-target_include_directories(flutter INTERFACE
- "${EPHEMERAL_DIR}"
-)
-target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
-target_link_libraries(flutter INTERFACE
- PkgConfig::GTK
- PkgConfig::GLIB
- PkgConfig::GIO
-)
-add_dependencies(flutter flutter_assemble)
-
-# === Flutter tool backend ===
-# _phony_ is a non-existent file to force this command to run every time,
-# since currently there's no way to get a full input/output list from the
-# flutter tool.
-add_custom_command(
- OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
- ${CMAKE_CURRENT_BINARY_DIR}/_phony_
- COMMAND ${CMAKE_COMMAND} -E env
- ${FLUTTER_TOOL_ENVIRONMENT}
- "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
- ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
- VERBATIM
-)
-add_custom_target(flutter_assemble DEPENDS
- "${FLUTTER_LIBRARY}"
- ${FLUTTER_LIBRARY_HEADERS}
-)
diff --git a/ground-suite/linux/flutter/generated_plugin_registrant.cc b/ground-suite/linux/flutter/generated_plugin_registrant.cc
deleted file mode 100644
index e71a16d..0000000
--- a/ground-suite/linux/flutter/generated_plugin_registrant.cc
+++ /dev/null
@@ -1,11 +0,0 @@
-//
-// Generated file. Do not edit.
-//
-
-// clang-format off
-
-#include "generated_plugin_registrant.h"
-
-
-void fl_register_plugins(FlPluginRegistry* registry) {
-}
diff --git a/ground-suite/linux/flutter/generated_plugin_registrant.h b/ground-suite/linux/flutter/generated_plugin_registrant.h
deleted file mode 100644
index e0f0a47..0000000
--- a/ground-suite/linux/flutter/generated_plugin_registrant.h
+++ /dev/null
@@ -1,15 +0,0 @@
-//
-// Generated file. Do not edit.
-//
-
-// clang-format off
-
-#ifndef GENERATED_PLUGIN_REGISTRANT_
-#define GENERATED_PLUGIN_REGISTRANT_
-
-#include
-
-// Registers Flutter plugins.
-void fl_register_plugins(FlPluginRegistry* registry);
-
-#endif // GENERATED_PLUGIN_REGISTRANT_
diff --git a/ground-suite/linux/flutter/generated_plugins.cmake b/ground-suite/linux/flutter/generated_plugins.cmake
deleted file mode 100644
index be1ee3e..0000000
--- a/ground-suite/linux/flutter/generated_plugins.cmake
+++ /dev/null
@@ -1,24 +0,0 @@
-#
-# Generated file, do not edit.
-#
-
-list(APPEND FLUTTER_PLUGIN_LIST
-)
-
-list(APPEND FLUTTER_FFI_PLUGIN_LIST
- jni
-)
-
-set(PLUGIN_BUNDLED_LIBRARIES)
-
-foreach(plugin ${FLUTTER_PLUGIN_LIST})
- add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
- target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
- list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
- list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
-endforeach(plugin)
-
-foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
- add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
- list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
-endforeach(ffi_plugin)
diff --git a/ground-suite/linux/runner/CMakeLists.txt b/ground-suite/linux/runner/CMakeLists.txt
deleted file mode 100644
index e97dabc..0000000
--- a/ground-suite/linux/runner/CMakeLists.txt
+++ /dev/null
@@ -1,26 +0,0 @@
-cmake_minimum_required(VERSION 3.13)
-project(runner LANGUAGES CXX)
-
-# Define the application target. To change its name, change BINARY_NAME in the
-# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
-# work.
-#
-# Any new source files that you add to the application should be added here.
-add_executable(${BINARY_NAME}
- "main.cc"
- "my_application.cc"
- "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
-)
-
-# Apply the standard set of build settings. This can be removed for applications
-# that need different build settings.
-apply_standard_settings(${BINARY_NAME})
-
-# Add preprocessor definitions for the application ID.
-add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
-
-# Add dependency libraries. Add any application-specific dependencies here.
-target_link_libraries(${BINARY_NAME} PRIVATE flutter)
-target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
-
-target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
diff --git a/ground-suite/linux/runner/main.cc b/ground-suite/linux/runner/main.cc
deleted file mode 100644
index e7c5c54..0000000
--- a/ground-suite/linux/runner/main.cc
+++ /dev/null
@@ -1,6 +0,0 @@
-#include "my_application.h"
-
-int main(int argc, char** argv) {
- g_autoptr(MyApplication) app = my_application_new();
- return g_application_run(G_APPLICATION(app), argc, argv);
-}
diff --git a/ground-suite/linux/runner/my_application.cc b/ground-suite/linux/runner/my_application.cc
deleted file mode 100644
index 2c93a65..0000000
--- a/ground-suite/linux/runner/my_application.cc
+++ /dev/null
@@ -1,148 +0,0 @@
-#include "my_application.h"
-
-#include
-#ifdef GDK_WINDOWING_X11
-#include
-#endif
-
-#include "flutter/generated_plugin_registrant.h"
-
-struct _MyApplication {
- GtkApplication parent_instance;
- char** dart_entrypoint_arguments;
-};
-
-G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
-
-// Called when first Flutter frame received.
-static void first_frame_cb(MyApplication* self, FlView* view) {
- gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
-}
-
-// Implements GApplication::activate.
-static void my_application_activate(GApplication* application) {
- MyApplication* self = MY_APPLICATION(application);
- GtkWindow* window =
- GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
-
- // Use a header bar when running in GNOME as this is the common style used
- // by applications and is the setup most users will be using (e.g. Ubuntu
- // desktop).
- // If running on X and not using GNOME then just use a traditional title bar
- // in case the window manager does more exotic layout, e.g. tiling.
- // If running on Wayland assume the header bar will work (may need changing
- // if future cases occur).
- gboolean use_header_bar = TRUE;
-#ifdef GDK_WINDOWING_X11
- GdkScreen* screen = gtk_window_get_screen(window);
- if (GDK_IS_X11_SCREEN(screen)) {
- const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
- if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
- use_header_bar = FALSE;
- }
- }
-#endif
- if (use_header_bar) {
- GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
- gtk_widget_show(GTK_WIDGET(header_bar));
- gtk_header_bar_set_title(header_bar, "Ground Suite");
- gtk_header_bar_set_show_close_button(header_bar, TRUE);
- gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
- } else {
- gtk_window_set_title(window, "Ground Suite");
- }
-
- gtk_window_set_default_size(window, 1280, 720);
-
- g_autoptr(FlDartProject) project = fl_dart_project_new();
- fl_dart_project_set_dart_entrypoint_arguments(
- project, self->dart_entrypoint_arguments);
-
- FlView* view = fl_view_new(project);
- GdkRGBA background_color;
- // Background defaults to black, override it here if necessary, e.g. #00000000
- // for transparent.
- gdk_rgba_parse(&background_color, "#000000");
- fl_view_set_background_color(view, &background_color);
- gtk_widget_show(GTK_WIDGET(view));
- gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
-
- // Show the window when Flutter renders.
- // Requires the view to be realized so we can start rendering.
- g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
- self);
- gtk_widget_realize(GTK_WIDGET(view));
-
- fl_register_plugins(FL_PLUGIN_REGISTRY(view));
-
- gtk_widget_grab_focus(GTK_WIDGET(view));
-}
-
-// Implements GApplication::local_command_line.
-static gboolean my_application_local_command_line(GApplication* application,
- gchar*** arguments,
- int* exit_status) {
- MyApplication* self = MY_APPLICATION(application);
- // Strip out the first argument as it is the binary name.
- self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
-
- g_autoptr(GError) error = nullptr;
- if (!g_application_register(application, nullptr, &error)) {
- g_warning("Failed to register: %s", error->message);
- *exit_status = 1;
- return TRUE;
- }
-
- g_application_activate(application);
- *exit_status = 0;
-
- return TRUE;
-}
-
-// Implements GApplication::startup.
-static void my_application_startup(GApplication* application) {
- // MyApplication* self = MY_APPLICATION(object);
-
- // Perform any actions required at application startup.
-
- G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
-}
-
-// Implements GApplication::shutdown.
-static void my_application_shutdown(GApplication* application) {
- // MyApplication* self = MY_APPLICATION(object);
-
- // Perform any actions required at application shutdown.
-
- G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
-}
-
-// Implements GObject::dispose.
-static void my_application_dispose(GObject* object) {
- MyApplication* self = MY_APPLICATION(object);
- g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
- G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
-}
-
-static void my_application_class_init(MyApplicationClass* klass) {
- G_APPLICATION_CLASS(klass)->activate = my_application_activate;
- G_APPLICATION_CLASS(klass)->local_command_line =
- my_application_local_command_line;
- G_APPLICATION_CLASS(klass)->startup = my_application_startup;
- G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
- G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
-}
-
-static void my_application_init(MyApplication* self) {}
-
-MyApplication* my_application_new() {
- // Set the program name to the application ID, which helps various systems
- // like GTK and desktop environments map this running application to its
- // corresponding .desktop file. This ensures better integration by allowing
- // the application to be recognized beyond its binary name.
- g_set_prgname(APPLICATION_ID);
-
- return MY_APPLICATION(g_object_new(my_application_get_type(),
- "application-id", APPLICATION_ID, "flags",
- G_APPLICATION_NON_UNIQUE, nullptr));
-}
diff --git a/ground-suite/linux/runner/my_application.h b/ground-suite/linux/runner/my_application.h
deleted file mode 100644
index db16367..0000000
--- a/ground-suite/linux/runner/my_application.h
+++ /dev/null
@@ -1,21 +0,0 @@
-#ifndef FLUTTER_MY_APPLICATION_H_
-#define FLUTTER_MY_APPLICATION_H_
-
-#include
-
-G_DECLARE_FINAL_TYPE(MyApplication,
- my_application,
- MY,
- APPLICATION,
- GtkApplication)
-
-/**
- * my_application_new:
- *
- * Creates a new Flutter-based application.
- *
- * Returns: a new #MyApplication.
- */
-MyApplication* my_application_new();
-
-#endif // FLUTTER_MY_APPLICATION_H_
diff --git a/ground-suite/package-lock.json b/ground-suite/package-lock.json
new file mode 100644
index 0000000..ddb7450
--- /dev/null
+++ b/ground-suite/package-lock.json
@@ -0,0 +1,2524 @@
+{
+ "name": "@kaptan/ground-suite",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@kaptan/ground-suite",
+ "version": "0.1.0",
+ "dependencies": {
+ "@vitejs/plugin-react": "6.0.5",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "vite": "8.2.1"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.62.1",
+ "@testing-library/jest-dom": "7.0.1",
+ "@testing-library/react": "16.3.2",
+ "@testing-library/user-event": "14.6.4",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.4",
+ "jsdom": "30.0.1",
+ "typescript": "7.0.2",
+ "vitest": "4.1.10"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "6.0.7",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
+ "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.3.0",
+ "@csstools/css-color-parser": "^4.1.10",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
+ "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+ "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz",
+ "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz",
+ "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.144.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
+ "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "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/@rolldown/binding-android-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
+ "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
+ "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
+ "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
+ "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
+ "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
+ "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
+ "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
+ "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
+ "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
+ "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
+ "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
+ "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
+ "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.4",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.4.tgz",
+ "integrity": "sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
+ "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/jsdom": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
+ "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^6.0.5",
+ "@asamuzakjp/dom-selector": "^8.3.0",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
+ "@exodus/bytes": "^1.15.1",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.9.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "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/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
+ "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.144.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.4",
+ "@rolldown/binding-darwin-arm64": "1.2.4",
+ "@rolldown/binding-darwin-x64": "1.2.4",
+ "@rolldown/binding-freebsd-x64": "1.2.4",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.4",
+ "@rolldown/binding-linux-arm64-musl": "1.2.4",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.4",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.4",
+ "@rolldown/binding-linux-x64-gnu": "1.2.4",
+ "@rolldown/binding-linux-x64-musl": "1.2.4",
+ "@rolldown/binding-openharmony-arm64": "1.2.4",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.4",
+ "@rolldown/binding-win32-x64-msvc": "1.2.4"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.10",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
+ "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.10"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.10",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
+ "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
+ "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.25",
+ "rolldown": "~1.2.1",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
+ "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/ground-suite/package.json b/ground-suite/package.json
new file mode 100644
index 0000000..2250989
--- /dev/null
+++ b/ground-suite/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@kaptan/ground-suite",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "test": "npm run test:unit && npm run test:e2e",
+ "test:unit": "vitest run",
+ "test:e2e": "playwright test",
+ "test:watch": "vitest",
+ "check": "tsc -b && vitest run && vite build"
+ },
+ "dependencies": {
+ "@vitejs/plugin-react": "6.0.5",
+ "vite": "8.2.1",
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.62.1",
+ "@testing-library/jest-dom": "7.0.1",
+ "@testing-library/react": "16.3.2",
+ "@testing-library/user-event": "14.6.4",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.4",
+ "jsdom": "30.0.1",
+ "typescript": "7.0.2",
+ "vitest": "4.1.10"
+ }
+}
diff --git a/ground-suite/playwright.config.ts b/ground-suite/playwright.config.ts
new file mode 100644
index 0000000..e5c2665
--- /dev/null
+++ b/ground-suite/playwright.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from '@playwright/test';
+const environment = globalThis as typeof globalThis & { process?: { env: Record } };
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ reporter: 'line',
+ use: {
+ baseURL: 'http://127.0.0.1:4174',
+ viewport: { width: 1366, height: 768 },
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ launchOptions: {
+ executablePath: environment.process?.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
+ args: ['--no-sandbox', '--disable-gpu'],
+ },
+ },
+ webServer: {
+ command: 'npm run build && KAPTAN_GROUND_SUITE_PORT=4174 node scripts/server.mjs',
+ url: 'http://127.0.0.1:4174',
+ reuseExistingServer: false,
+ timeout: 30_000,
+ },
+});
diff --git a/ground-suite/public/assets/coastal-map.png b/ground-suite/public/assets/coastal-map.png
new file mode 100644
index 0000000..6e2ea1a
Binary files /dev/null and b/ground-suite/public/assets/coastal-map.png differ
diff --git a/ground-suite/public/assets/vehicle_catalog/LICENSE.md b/ground-suite/public/assets/vehicle_catalog/LICENSE.md
new file mode 100644
index 0000000..fa45907
--- /dev/null
+++ b/ground-suite/public/assets/vehicle_catalog/LICENSE.md
@@ -0,0 +1,4 @@
+# Asset notice
+
+Assets in this directory are project-supplied. Replacements require reviewed
+ownership, provenance, and licensing.
diff --git a/ground-suite/public/assets/vehicle_catalog/photos/fixed_wing.png b/ground-suite/public/assets/vehicle_catalog/photos/fixed_wing.png
new file mode 100644
index 0000000..dbe30c8
Binary files /dev/null and b/ground-suite/public/assets/vehicle_catalog/photos/fixed_wing.png differ
diff --git a/ground-suite/public/assets/vehicle_catalog/photos/helicopter.png b/ground-suite/public/assets/vehicle_catalog/photos/helicopter.png
new file mode 100644
index 0000000..9fae497
Binary files /dev/null and b/ground-suite/public/assets/vehicle_catalog/photos/helicopter.png differ
diff --git a/ground-suite/public/assets/vehicle_catalog/photos/quadcopter.png b/ground-suite/public/assets/vehicle_catalog/photos/quadcopter.png
new file mode 100644
index 0000000..d35e860
Binary files /dev/null and b/ground-suite/public/assets/vehicle_catalog/photos/quadcopter.png differ
diff --git a/ground-suite/public/assets/vehicle_catalog/photos/rover.png b/ground-suite/public/assets/vehicle_catalog/photos/rover.png
new file mode 100644
index 0000000..4aacacf
Binary files /dev/null and b/ground-suite/public/assets/vehicle_catalog/photos/rover.png differ
diff --git a/ground-suite/public/assets/vehicle_catalog/photos/surface_vessel.png b/ground-suite/public/assets/vehicle_catalog/photos/surface_vessel.png
new file mode 100644
index 0000000..d7407c1
Binary files /dev/null and b/ground-suite/public/assets/vehicle_catalog/photos/surface_vessel.png differ
diff --git a/ground-suite/public/assets/vehicle_catalog/photos/tiltrotor_vtol.png b/ground-suite/public/assets/vehicle_catalog/photos/tiltrotor_vtol.png
new file mode 100644
index 0000000..61d2280
Binary files /dev/null and b/ground-suite/public/assets/vehicle_catalog/photos/tiltrotor_vtol.png differ
diff --git a/ground-suite/pubspec.lock b/ground-suite/pubspec.lock
deleted file mode 100644
index 1f52fa8..0000000
--- a/ground-suite/pubspec.lock
+++ /dev/null
@@ -1,677 +0,0 @@
-# Generated by pub
-# See https://dart.dev/tools/pub/glossary#lockfile
-packages:
- _fe_analyzer_shared:
- dependency: transitive
- description:
- name: _fe_analyzer_shared
- sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
- url: "https://pub.dev"
- source: hosted
- version: "99.0.0"
- analyzer:
- dependency: transitive
- description:
- name: analyzer
- sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
- url: "https://pub.dev"
- source: hosted
- version: "12.1.0"
- args:
- dependency: transitive
- description:
- name: args
- sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
- url: "https://pub.dev"
- source: hosted
- version: "2.7.0"
- async:
- dependency: transitive
- description:
- name: async
- sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
- url: "https://pub.dev"
- source: hosted
- version: "2.13.1"
- boolean_selector:
- dependency: transitive
- description:
- name: boolean_selector
- sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.2"
- characters:
- dependency: transitive
- description:
- name: characters
- sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
- url: "https://pub.dev"
- source: hosted
- version: "1.4.1"
- cli_config:
- dependency: transitive
- description:
- name: cli_config
- sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
- url: "https://pub.dev"
- source: hosted
- version: "0.2.0"
- clock:
- dependency: transitive
- description:
- name: clock
- sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
- url: "https://pub.dev"
- source: hosted
- version: "1.1.2"
- code_assets:
- dependency: transitive
- description:
- name: code_assets
- sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
- url: "https://pub.dev"
- source: hosted
- version: "1.2.1"
- collection:
- dependency: transitive
- description:
- name: collection
- sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
- url: "https://pub.dev"
- source: hosted
- version: "1.19.1"
- convert:
- dependency: transitive
- description:
- name: convert
- sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
- url: "https://pub.dev"
- source: hosted
- version: "3.1.2"
- coverage:
- dependency: transitive
- description:
- name: coverage
- sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
- url: "https://pub.dev"
- source: hosted
- version: "1.15.1"
- crypto:
- dependency: transitive
- description:
- name: crypto
- sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
- url: "https://pub.dev"
- source: hosted
- version: "3.0.7"
- fake_async:
- dependency: transitive
- description:
- name: fake_async
- sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
- url: "https://pub.dev"
- source: hosted
- version: "1.3.3"
- ffi:
- dependency: transitive
- description:
- name: ffi
- sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
- file:
- dependency: transitive
- description:
- name: file
- sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
- url: "https://pub.dev"
- source: hosted
- version: "7.0.1"
- fixnum:
- dependency: transitive
- description:
- name: fixnum
- sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
- url: "https://pub.dev"
- source: hosted
- version: "1.1.1"
- flutter:
- dependency: "direct main"
- description: flutter
- source: sdk
- version: "0.0.0"
- flutter_lints:
- dependency: "direct dev"
- description:
- name: flutter_lints
- sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
- url: "https://pub.dev"
- source: hosted
- version: "6.0.0"
- flutter_riverpod:
- dependency: "direct main"
- description:
- name: flutter_riverpod
- sha256: "56e81e662e6e54e59b231da57e90ac0d06ac67d8e3f2273c129a37ae6282b40c"
- url: "https://pub.dev"
- source: hosted
- version: "3.4.2"
- flutter_test:
- dependency: "direct dev"
- description: flutter
- source: sdk
- version: "0.0.0"
- frontend_server_client:
- dependency: transitive
- description:
- name: frontend_server_client
- sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
- url: "https://pub.dev"
- source: hosted
- version: "4.0.0"
- glob:
- dependency: transitive
- description:
- name: glob
- sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
- url: "https://pub.dev"
- source: hosted
- version: "2.1.3"
- google_fonts:
- dependency: "direct main"
- description:
- name: google_fonts
- sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
- url: "https://pub.dev"
- source: hosted
- version: "6.3.3"
- hooks:
- dependency: transitive
- description:
- name: hooks
- sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
- url: "https://pub.dev"
- source: hosted
- version: "2.0.2"
- http:
- dependency: transitive
- description:
- name: http
- sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
- url: "https://pub.dev"
- source: hosted
- version: "1.6.0"
- http_multi_server:
- dependency: transitive
- description:
- name: http_multi_server
- sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
- url: "https://pub.dev"
- source: hosted
- version: "3.2.2"
- http_parser:
- dependency: transitive
- description:
- name: http_parser
- sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
- url: "https://pub.dev"
- source: hosted
- version: "4.1.2"
- io:
- dependency: transitive
- description:
- name: io
- sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
- url: "https://pub.dev"
- source: hosted
- version: "1.0.5"
- jni:
- dependency: transitive
- description:
- name: jni
- sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
- url: "https://pub.dev"
- source: hosted
- version: "1.0.3"
- jni_flutter:
- dependency: transitive
- description:
- name: jni_flutter
- sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.2"
- jni_util:
- dependency: transitive
- description:
- name: jni_util
- sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.0"
- leak_tracker:
- dependency: transitive
- description:
- name: leak_tracker
- sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
- url: "https://pub.dev"
- source: hosted
- version: "11.0.2"
- leak_tracker_flutter_testing:
- dependency: transitive
- description:
- name: leak_tracker_flutter_testing
- sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
- url: "https://pub.dev"
- source: hosted
- version: "3.0.10"
- leak_tracker_testing:
- dependency: transitive
- description:
- name: leak_tracker_testing
- sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
- url: "https://pub.dev"
- source: hosted
- version: "3.0.2"
- lints:
- dependency: transitive
- description:
- name: lints
- sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
- url: "https://pub.dev"
- source: hosted
- version: "6.1.0"
- listen:
- dependency: transitive
- description:
- name: listen
- sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.1"
- logging:
- dependency: transitive
- description:
- name: logging
- sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
- url: "https://pub.dev"
- source: hosted
- version: "1.3.0"
- matcher:
- dependency: transitive
- description:
- name: matcher
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
- url: "https://pub.dev"
- source: hosted
- version: "0.12.19"
- material_color_utilities:
- dependency: transitive
- description:
- name: material_color_utilities
- sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
- url: "https://pub.dev"
- source: hosted
- version: "0.13.0"
- meta:
- dependency: transitive
- description:
- name: meta
- sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
- url: "https://pub.dev"
- source: hosted
- version: "1.18.0"
- mime:
- dependency: transitive
- description:
- name: mime
- sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
- url: "https://pub.dev"
- source: hosted
- version: "2.0.0"
- node_preamble:
- dependency: transitive
- description:
- name: node_preamble
- sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
- url: "https://pub.dev"
- source: hosted
- version: "2.0.2"
- objective_c:
- dependency: transitive
- description:
- name: objective_c
- sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
- url: "https://pub.dev"
- source: hosted
- version: "9.5.0"
- package_config:
- dependency: transitive
- description:
- name: package_config
- sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
- path:
- dependency: transitive
- description:
- name: path
- sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
- url: "https://pub.dev"
- source: hosted
- version: "1.9.1"
- path_provider:
- dependency: transitive
- description:
- name: path_provider
- sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
- url: "https://pub.dev"
- source: hosted
- version: "2.1.6"
- path_provider_android:
- dependency: transitive
- description:
- name: path_provider_android
- sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
- url: "https://pub.dev"
- source: hosted
- version: "2.3.1"
- path_provider_foundation:
- dependency: transitive
- description:
- name: path_provider_foundation
- sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
- url: "https://pub.dev"
- source: hosted
- version: "2.6.0"
- path_provider_linux:
- dependency: transitive
- description:
- name: path_provider_linux
- sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
- url: "https://pub.dev"
- source: hosted
- version: "2.2.2"
- path_provider_platform_interface:
- dependency: transitive
- description:
- name: path_provider_platform_interface
- sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.3"
- path_provider_windows:
- dependency: transitive
- description:
- name: path_provider_windows
- sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
- url: "https://pub.dev"
- source: hosted
- version: "2.3.0"
- platform:
- dependency: transitive
- description:
- name: platform
- sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
- url: "https://pub.dev"
- source: hosted
- version: "3.1.6"
- plugin_platform_interface:
- dependency: transitive
- description:
- name: plugin_platform_interface
- sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.8"
- pool:
- dependency: transitive
- description:
- name: pool
- sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
- url: "https://pub.dev"
- source: hosted
- version: "1.5.2"
- pub_semver:
- dependency: transitive
- description:
- name: pub_semver
- sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
- record_use:
- dependency: transitive
- description:
- name: record_use
- sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
- url: "https://pub.dev"
- source: hosted
- version: "0.6.0"
- riverpod:
- dependency: transitive
- description:
- name: riverpod
- sha256: "84351b3472a447f7b89f961f95c73287009d58c59d8cc1267425d995f78e50f1"
- url: "https://pub.dev"
- source: hosted
- version: "3.4.2"
- shelf:
- dependency: transitive
- description:
- name: shelf
- sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
- url: "https://pub.dev"
- source: hosted
- version: "1.4.2"
- shelf_packages_handler:
- dependency: transitive
- description:
- name: shelf_packages_handler
- sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
- url: "https://pub.dev"
- source: hosted
- version: "3.0.2"
- shelf_static:
- dependency: transitive
- description:
- name: shelf_static
- sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
- url: "https://pub.dev"
- source: hosted
- version: "1.1.3"
- shelf_web_socket:
- dependency: transitive
- description:
- name: shelf_web_socket
- sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
- url: "https://pub.dev"
- source: hosted
- version: "3.0.0"
- sky_engine:
- dependency: transitive
- description: flutter
- source: sdk
- version: "0.0.0"
- source_map_stack_trace:
- dependency: transitive
- description:
- name: source_map_stack_trace
- sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
- url: "https://pub.dev"
- source: hosted
- version: "2.1.2"
- source_maps:
- dependency: transitive
- description:
- name: source_maps
- sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
- url: "https://pub.dev"
- source: hosted
- version: "0.10.13"
- source_span:
- dependency: transitive
- description:
- name: source_span
- sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
- url: "https://pub.dev"
- source: hosted
- version: "1.10.2"
- stack_trace:
- dependency: transitive
- description:
- name: stack_trace
- sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
- url: "https://pub.dev"
- source: hosted
- version: "1.12.1"
- state_notifier:
- dependency: transitive
- description:
- name: state_notifier
- sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
- url: "https://pub.dev"
- source: hosted
- version: "1.0.0"
- stream_channel:
- dependency: transitive
- description:
- name: stream_channel
- sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.4"
- string_scanner:
- dependency: transitive
- description:
- name: string_scanner
- sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
- url: "https://pub.dev"
- source: hosted
- version: "1.4.1"
- term_glyph:
- dependency: transitive
- description:
- name: term_glyph
- sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
- url: "https://pub.dev"
- source: hosted
- version: "1.2.2"
- test:
- dependency: transitive
- description:
- name: test
- sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
- url: "https://pub.dev"
- source: hosted
- version: "1.31.0"
- test_api:
- dependency: transitive
- description:
- name: test_api
- sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
- url: "https://pub.dev"
- source: hosted
- version: "0.7.11"
- test_core:
- dependency: transitive
- description:
- name: test_core
- sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
- url: "https://pub.dev"
- source: hosted
- version: "0.6.17"
- typed_data:
- dependency: transitive
- description:
- name: typed_data
- sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
- url: "https://pub.dev"
- source: hosted
- version: "1.4.0"
- uuid:
- dependency: transitive
- description:
- name: uuid
- sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
- url: "https://pub.dev"
- source: hosted
- version: "4.6.0"
- vector_math:
- dependency: transitive
- description:
- name: vector_math
- sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
- vm_service:
- dependency: transitive
- description:
- name: vm_service
- sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
- url: "https://pub.dev"
- source: hosted
- version: "15.2.0"
- watcher:
- dependency: transitive
- description:
- name: watcher
- sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
- url: "https://pub.dev"
- source: hosted
- version: "1.2.1"
- web:
- dependency: transitive
- description:
- name: web
- sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
- url: "https://pub.dev"
- source: hosted
- version: "1.1.1"
- web_socket:
- dependency: transitive
- description:
- name: web_socket
- sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.1"
- web_socket_channel:
- dependency: transitive
- description:
- name: web_socket_channel
- sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
- url: "https://pub.dev"
- source: hosted
- version: "3.0.3"
- webkit_inspection_protocol:
- dependency: transitive
- description:
- name: webkit_inspection_protocol
- sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
- url: "https://pub.dev"
- source: hosted
- version: "1.2.1"
- xdg_directories:
- dependency: transitive
- description:
- name: xdg_directories
- sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
- url: "https://pub.dev"
- source: hosted
- version: "1.1.0"
- yaml:
- dependency: transitive
- description:
- name: yaml
- sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
- url: "https://pub.dev"
- source: hosted
- version: "3.1.3"
-sdks:
- dart: ">=3.12.2 <4.0.0"
- flutter: ">=3.38.4"
diff --git a/ground-suite/pubspec.yaml b/ground-suite/pubspec.yaml
deleted file mode 100644
index fc5fea5..0000000
--- a/ground-suite/pubspec.yaml
+++ /dev/null
@@ -1,88 +0,0 @@
-name: ground_suite
-description: "Kaptan's desktop ground-control workspace."
-# The following line prevents the package from being accidentally published to
-# pub.dev using `flutter pub publish`. This is preferred for private packages.
-publish_to: 'none' # Remove this line if you wish to publish to pub.dev
-
-# The following defines the version and build number for your application.
-# A version number is three numbers separated by dots, like 1.2.43
-# followed by an optional build number separated by a +.
-# Both the version and the builder number may be overridden in flutter
-# build by specifying --build-name and --build-number, respectively.
-# In Android, build-name is used as versionName while build-number used as versionCode.
-# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
-# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
-# Read more about iOS versioning at
-# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
-# In Windows, build-name is used as the major, minor, and patch parts
-# of the product and file versions while build-number is used as the build suffix.
-version: 0.1.0+1
-
-environment:
- sdk: ^3.12.2
-
-# Dependencies specify other packages that your package needs in order to work.
-# To automatically upgrade your package dependencies to the latest versions
-# consider running `flutter pub upgrade --major-versions`. Alternatively,
-# dependencies can be manually updated by changing the version numbers below to
-# the latest version available on pub.dev. To see which dependencies have newer
-# versions available, run `flutter pub outdated`.
-dependencies:
- flutter:
- sdk: flutter
-
- flutter_riverpod: ^3.4.2
- google_fonts: ^6.3.2
-
-dev_dependencies:
- flutter_test:
- sdk: flutter
-
- # The "flutter_lints" package below contains a set of recommended lints to
- # encourage good coding practices. The lint set provided by the package is
- # activated in the `analysis_options.yaml` file located at the root of your
- # package. See that file for information about deactivating specific lint
- # rules and activating additional ones.
- flutter_lints: ^6.0.0
-
-# For information on the generic Dart part of this file, see the
-# following page: https://dart.dev/tools/pub/pubspec
-
-# The following section is specific to Flutter packages.
-flutter:
-
- # The following line ensures that the Material Icons font is
- # included with your application, so that you can use the icons in
- # the material Icons class.
- uses-material-design: true
-
- assets:
- - assets/test.png
- - assets/vehicle_catalog/
- - assets/vehicle_catalog/photos/
-
- # An image asset can refer to one or more resolution-specific "variants", see
- # https://flutter.dev/to/resolution-aware-images
-
- # For details regarding adding assets from package dependencies, see
- # https://flutter.dev/to/asset-from-package
-
- # To add custom fonts to your application, add a fonts section here,
- # in this "flutter" section. Each entry in this list should have a
- # "family" key with the font family name, and a "fonts" key with a
- # list giving the asset and other descriptors for the font. For
- # example:
- # fonts:
- # - family: Schyler
- # fonts:
- # - asset: fonts/Schyler-Regular.ttf
- # - asset: fonts/Schyler-Italic.ttf
- # style: italic
- # - family: Trajan Pro
- # fonts:
- # - asset: fonts/TrajanPro.ttf
- # - asset: fonts/TrajanPro_Bold.ttf
- # weight: 700
- #
- # For details regarding fonts from package dependencies,
- # see https://flutter.dev/to/font-from-package
diff --git a/ground-suite/scripts/server.mjs b/ground-suite/scripts/server.mjs
new file mode 100644
index 0000000..2ff7cf1
--- /dev/null
+++ b/ground-suite/scripts/server.mjs
@@ -0,0 +1,20 @@
+import { createReadStream, existsSync, statSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { extname, join, normalize } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(fileURLToPath(new URL('..', import.meta.url)), 'dist');
+const host = process.env.KAPTAN_GROUND_SUITE_HOST ?? '127.0.0.1';
+const port = Number(process.env.KAPTAN_GROUND_SUITE_PORT ?? '4173');
+const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml' };
+
+createServer((request, response) => {
+ const requested = normalize(decodeURIComponent((request.url ?? '/').split('?')[0])).replace(/^(\.\.[/\\])+/, '');
+ let path = join(root, requested === '/' ? 'index.html' : requested);
+ if (!path.startsWith(root) || !existsSync(path) || statSync(path).isDirectory()) path = join(root, 'index.html');
+ response.writeHead(200, { 'Content-Type': mime[extname(path)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' });
+ createReadStream(path).pipe(response);
+}).listen(port, host, () => {
+ console.log(`Kaptan Ground Suite: http://${host}:${port}`);
+ console.log('SIMULATED DATA ONLY — no real vehicle connection or command authority.');
+});
diff --git a/ground-suite/src/adapters/cerebrum/CerebrumPort.ts b/ground-suite/src/adapters/cerebrum/CerebrumPort.ts
new file mode 100644
index 0000000..165211b
--- /dev/null
+++ b/ground-suite/src/adapters/cerebrum/CerebrumPort.ts
@@ -0,0 +1,4 @@
+export interface AutonomyCapabilities { formationPlanning: boolean; replacementEvaluation: boolean; supportedIntentKinds: string[]; protocolRevision?: string }
+export interface DesiredOutcome { id: string; kind: string; groupId: string; constraints: string[]; successCriteria: string[] }
+export interface AutonomyResult { outcomeId: string; accepted: boolean; explanation: string; proposedIntentIds: string[]; errors: Array<{ code: string; message: string; retryable: boolean }> }
+export interface AutonomyCoordinatorPort { discover(): Promise; evaluate(outcome: DesiredOutcome): Promise; close(): Promise }
diff --git a/ground-suite/src/adapters/cerebrum/FakeCerebrumAdapter.test.ts b/ground-suite/src/adapters/cerebrum/FakeCerebrumAdapter.test.ts
new file mode 100644
index 0000000..20cfdbb
--- /dev/null
+++ b/ground-suite/src/adapters/cerebrum/FakeCerebrumAdapter.test.ts
@@ -0,0 +1,6 @@
+import { describe, expect, it } from 'vitest';
+import { FakeCerebrumAdapter } from './FakeCerebrumAdapter';
+describe('AutonomyCoordinatorPort contract', () => {
+ it('discovers explicit simulated capabilities', async () => { const adapter = new FakeCerebrumAdapter(); expect((await adapter.discover()).replacementEvaluation).toBe(true); });
+ it('rejects outcomes outside discovered capability', async () => { const result = await new FakeCerebrumAdapter().evaluate({ id: 'x', kind: 'invent-protocol', groupId: 'group:alpha', constraints: [], successCriteria: [] }); expect(result.accepted).toBe(false); expect(result.errors[0].code).toBe('UNSUPPORTED_OUTCOME'); });
+});
diff --git a/ground-suite/src/adapters/cerebrum/FakeCerebrumAdapter.ts b/ground-suite/src/adapters/cerebrum/FakeCerebrumAdapter.ts
new file mode 100644
index 0000000..33939e4
--- /dev/null
+++ b/ground-suite/src/adapters/cerebrum/FakeCerebrumAdapter.ts
@@ -0,0 +1,6 @@
+import type { AutonomyCapabilities, AutonomyCoordinatorPort, AutonomyResult, DesiredOutcome } from './CerebrumPort';
+export class FakeCerebrumAdapter implements AutonomyCoordinatorPort {
+ async discover(): Promise { return { formationPlanning: true, replacementEvaluation: true, supportedIntentKinds: ['maintain-coverage', 'form', 'route', 'hold'], protocolRevision: 'simulation-contract-v1' }; }
+ async evaluate(outcome: DesiredOutcome): Promise { const accepted = ['maintain-coverage', 'form', 'route', 'hold'].includes(outcome.kind); return { outcomeId: outcome.id, accepted, explanation: accepted ? 'Deterministic simulation produced an auditable candidate plan' : 'Outcome kind is outside discovered simulation capabilities', proposedIntentIds: accepted ? [`proposed:${outcome.id}`] : [], errors: accepted ? [] : [{ code: 'UNSUPPORTED_OUTCOME', message: `Unsupported outcome: ${outcome.kind}`, retryable: false }] }; }
+ async close(): Promise {}
+}
diff --git a/ground-suite/src/adapters/fake/FakeRealtimeSource.ts b/ground-suite/src/adapters/fake/FakeRealtimeSource.ts
new file mode 100644
index 0000000..0c3a06a
--- /dev/null
+++ b/ground-suite/src/adapters/fake/FakeRealtimeSource.ts
@@ -0,0 +1,66 @@
+import { VirtualClock } from '../../core/clock/VirtualClock';
+import type { CommandEnvelope, CommandReceipt } from '../../core/data-plane/CommandEnvelope';
+import type { DataPlaneSink, DataSource } from '../../core/data-plane/DataSource';
+import type { EventEnvelope } from '../../core/data-plane/EventEnvelope';
+import type { ActivityEvent, MissionState } from '../../domain/model';
+import { createScenario } from './scenario';
+
+export class FakeRealtimeSource implements DataSource {
+ readonly descriptor = { id: 'source:fake-alpha17', name: 'ALPHA-17 deterministic simulation', kind: 'simulation' as const, simulated: true, capabilities: ['snapshot', 'telemetry', 'commands', 'replay', 'authority', 'action-queue'] };
+ private sink?: DataPlaneSink;
+ private sequence = 0;
+ private timer?: ReturnType;
+ private state: MissionState;
+ private readonly clock = new VirtualClock();
+ constructor(private readonly seed = 17, private readonly automatic = true) { this.state = createScenario(seed); }
+ private emit(type: EventEnvelope['type'], entity: EventEnvelope['entity'], payload: unknown, quality: EventEnvelope['quality'] = { stale: false, confidence: 1 }): void {
+ const now = this.clock.now();
+ this.sink?.accept({ schemaVersion: 1, eventId: `event:${++this.sequence}`, streamId: this.descriptor.id, sequence: this.sequence, type, entity, sourceTimestamp: now, receivedTimestamp: now, quality, payload });
+ }
+ async connect(sink: DataPlaneSink): Promise {
+ this.sink = sink;
+ this.state.source = { ...this.state.source, state: 'connected', message: 'Simulation connected', lastEventAt: this.clock.now() };
+ this.emit('snapshot', { kind: 'mission', id: this.state.mission.id }, structuredClone(this.state));
+ if (this.automatic) this.timer = setInterval(() => this.step(100), 100);
+ }
+ async disconnect(): Promise { if (this.timer) clearInterval(this.timer); this.timer = undefined; this.sink = undefined; }
+ private step = (milliseconds = 100): void => {
+ const before = this.clock.state.time;
+ const now = this.clock.advance(milliseconds);
+ if (now === before) return;
+ const tick = this.state.simulation.tick + 1;
+ this.state.simulation = { paused: this.clock.state.paused, speed: this.clock.state.speed, tick };
+ const updates = Object.values(this.state.vehicles).map((vehicle, index) => {
+ const phase = tick / 18 + index * 0.31;
+ const packetLost = (tick + index * 7) % 97 === 0;
+ return { id: vehicle.id, position: { ...vehicle.position, x: Math.max(3, Math.min(96, vehicle.position.x + Math.cos(phase) * 0.035)), y: Math.max(4, Math.min(94, vehicle.position.y + Math.sin(phase) * 0.028)) }, heading: (vehicle.heading + 0.7) % 360, speed: vehicle.speed + Math.sin(phase) * 0.4, altitude: Math.max(0, vehicle.altitude + Math.sin(phase * 0.6) * 0.15), energy: Math.max(8, vehicle.energy - 0.0008 * this.clock.state.speed), link: packetLost ? vehicle.link : Math.max(20, Math.min(100, vehicle.link + Math.sin(phase) * 0.04)), freshnessMs: packetLost ? vehicle.freshnessMs + milliseconds : 0 };
+ });
+ this.emit('telemetry', { kind: 'mission', id: this.state.mission.id }, { updates, elapsedSeconds: this.state.mission.elapsedSeconds + milliseconds / 1000, simulation: this.state.simulation });
+ if (tick === 35) this.triggerDegradation();
+ if (tick === 65) this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'degraded', message: 'Deterministic packet-loss window; telemetry may be stale' }, { stale: true, confidence: 0.72, droppedSinceLast: 3 });
+ if (tick === 78) this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'connected', message: 'Simulation recovered; event ordering verified' });
+ };
+ private triggerDegradation(): void {
+ const vehicle = Object.values(this.state.vehicles).find(item => item.groupId === 'group:alpha' && item.name === 'UAV-03');
+ if (!vehicle) return;
+ const alert = { id: 'alert:alpha-coverage', severity: 'critical' as const, title: 'ALPHA coverage below requirement', detail: `${vehicle.name} navigation unavailable · 11 minute impact`, entityId: vehicle.id, acknowledged: false, resolved: false, target: { kind: 'vehicle' as const, entityId: vehicle.id, zoom: 3 as const, inspector: 'vehicle' as const } };
+ this.emit('alert', { kind: 'alert', id: alert.id }, alert);
+ }
+ readonly replay = {
+ pause: (): void => { this.clock.pause(); this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'connected', message: 'Simulation paused', simulation: { paused: true, speed: this.clock.state.speed } }); },
+ resume: (): void => { this.clock.resume(); this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'connected', message: 'Simulation running', simulation: { paused: false, speed: this.clock.state.speed } }); },
+ restart: (): void => { this.clock.restart(); this.state = createScenario(this.seed); this.sequence = 0; this.emit('snapshot', { kind: 'mission', id: this.state.mission.id }, structuredClone(this.state)); },
+ setSpeed: (speed: number): void => { this.clock.setSpeed(speed); this.state.simulation.speed = this.clock.state.speed; this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'connected', message: `Simulation at ${this.clock.state.speed}×`, simulation: this.state.simulation }); },
+ step: (milliseconds = 100): void => this.step(milliseconds),
+ };
+ async send(command: CommandEnvelope): Promise {
+ const now = this.clock.now();
+ const connected = this.state.source.state !== 'disconnected';
+ const actorHasAuthority = Object.values(this.state.leases).some(lease => lease.holderId === command.actorId && command.targetIds.some(id => id === lease.entityId || this.state.vehicles[id]?.groupId === lease.entityId));
+ const accepted = connected && (command.kind === 'request-authority' || command.kind === 'transfer-authority' || command.kind === 'acknowledge-alert' || actorHasAuthority);
+ const receipt: CommandReceipt = accepted ? { commandId: command.id, accepted: true, state: 'acknowledged', acknowledgedAt: now } : { commandId: command.id, accepted: false, state: 'rejected', reason: connected ? 'Current operator does not hold authority for every target' : 'Simulation source is disconnected', remediation: connected ? 'Request or transfer the command lease, then preview again' : 'Reconnect the source and refresh telemetry', acknowledgedAt: now };
+ const activity: ActivityEvent = { id: `activity:${this.sequence + 1}`, timestamp: now, actor: 'Alex', category: 'command', severity: accepted ? 'info' : 'attention', message: accepted ? `${command.kind} acknowledged by simulated source` : `${command.kind} rejected: ${receipt.reason}`, entityId: command.targetIds[0], correlationId: command.id, acknowledged: accepted, target: { kind: this.state.groups[command.targetIds[0]] ? 'group' : 'vehicle', entityId: command.targetIds[0], zoom: this.state.groups[command.targetIds[0]] ? 2 : 3, inspector: 'event' } };
+ this.emit('command-state', { kind: 'mission', id: this.state.mission.id }, { command, receipt, activity });
+ return receipt;
+ }
+}
diff --git a/ground-suite/src/adapters/fake/scenario.ts b/ground-suite/src/adapters/fake/scenario.ts
new file mode 100644
index 0000000..236830c
--- /dev/null
+++ b/ground-suite/src/adapters/fake/scenario.ts
@@ -0,0 +1,73 @@
+import type { ActivityEvent, Archetype, Group, MissionState, Operator, Task, Vehicle } from '../../domain/model';
+import { seededRandom } from './seededRandom';
+
+const groupSpecs = [
+ ['group:alpha', 'ALPHA', 12, 'task:alpha', 'operator:alex', 82, 'nominal', 'Area Search', 'Return', 'wedge'],
+ ['group:bravo', 'BRAVO', 10, 'task:bravo', 'operator:sara', 71, 'nominal', 'Route Recon', 'Survey pass', 'column'],
+ ['group:charlie', 'CHARLIE', 8, 'task:charlie', 'operator:david', 64, 'nominal', 'Communications Relay', 'Hold station', 'orbit'],
+ ['group:delta', 'DELTA', 12, 'task:delta', 'operator:maria', 35, 'attention', 'Reserve / Support', 'Awaiting allocation', 'staging'],
+ ['group:reserve', 'RESERVE', 8, undefined, 'operator:ken', 100, 'nominal', 'Available', 'Available', 'staging'],
+] as const;
+const archetypes: Archetype[] = ['quadcopter', 'fixed-wing', 'tiltrotor-vtol', 'helicopter', 'rover', 'surface-vessel'];
+
+export function createScenario(seed = 17): MissionState {
+ const random = seededRandom(seed);
+ const tasks: Record = {
+ 'task:alpha': { id: 'task:alpha', name: 'TASK-143 · Alpha Search', kind: 'Area Search', progress: 82, required: 12, assignedGroupId: 'group:alpha', state: 'active', etaMinutes: 18 },
+ 'task:bravo': { id: 'task:bravo', name: 'TASK-144 · Coastal Route', kind: 'Route Recon', progress: 71, required: 10, assignedGroupId: 'group:bravo', state: 'active', etaMinutes: 24 },
+ 'task:charlie': { id: 'task:charlie', name: 'TASK-145 · Relay Mesh', kind: 'Communications Relay', progress: 64, required: 8, assignedGroupId: 'group:charlie', state: 'active', etaMinutes: 31 },
+ 'task:delta': { id: 'task:delta', name: 'TASK-146 · Support Readiness', kind: 'Reserve / Support', progress: 35, required: 8, assignedGroupId: 'group:delta', state: 'active', etaMinutes: 45 },
+ };
+ const groups: Record = Object.fromEntries(groupSpecs.map(([id, name, count, taskId, operatorId, progress, health, currentIntent, nextIntent, formation]) => [id, {
+ id, name, count, taskId, operatorId, progress, health, currentIntent, nextIntent, formation, attentionCount: name === 'DELTA' ? 1 : 0,
+ }]));
+ const operatorsArray: Operator[] = [
+ { id: 'operator:alex', name: 'Alex', role: 'Mission operator · ALPHA', presence: 'online', color: '#45c7ef', cursor: { x: 36, y: 64, lat: 41.08, lon: 29.06 }, selectedEntityId: 'group:alpha' },
+ { id: 'operator:sara', name: 'Sara', role: 'Group operator · BRAVO', presence: 'online', color: '#70d56c', cursor: { x: 47, y: 25, lat: 41.13, lon: 29.08 }, selectedEntityId: 'group:bravo' },
+ { id: 'operator:david', name: 'David', role: 'Mission planner', presence: 'online', color: '#ed9a55', cursor: { x: 72, y: 18, lat: 41.15, lon: 29.13 } },
+ { id: 'operator:maria', name: 'Maria', role: 'Safety observer', presence: 'online', color: '#a77ae4', cursor: { x: 46, y: 76, lat: 41.04, lon: 29.08 } },
+ { id: 'operator:ken', name: 'Ken', role: 'Engineering observer', presence: 'online', color: '#ead365', cursor: { x: 58, y: 80, lat: 41.03, lon: 29.10 } },
+ ];
+ const operators = Object.fromEntries(operatorsArray.map(operator => [operator.id, operator]));
+ const vehicles: Record = {};
+ let ordinal = 1;
+ for (const [groupId, groupName, count, taskId] of groupSpecs) {
+ for (let index = 0; index < count; index += 1) {
+ const id = `vehicle:${String(ordinal).padStart(3, '0')}`;
+ const alpha = groupName === 'ALPHA';
+ const archetype = groupName === 'CHARLIE' ? (index % 2 ? 'tiltrotor-vtol' : 'quadcopter') : archetypes[(ordinal - 1) % archetypes.length];
+ const centerX = 25 + groupSpecs.findIndex(spec => spec[0] === groupId) * 12;
+ const centerY = 27 + groupSpecs.findIndex(spec => spec[0] === groupId) * 11;
+ vehicles[id] = {
+ id, name: alpha ? `UAV-${String(index + 1).padStart(2, '0')}` : `${groupName.slice(0, 3)}-${String(index + 1).padStart(2, '0')}`,
+ groupId, archetype, capabilities: archetype === 'rover' || archetype === 'surface-vessel' ? ['support', 'mapping'] : ['ISR', archetype === 'tiltrotor-vtol' ? 'relay' : 'imaging'],
+ payload: archetype === 'surface-vessel' ? 'Sonar (simulated)' : 'EO/IR (simulated)',
+ position: { x: centerX + (index % 4) * 3 + random() * 2, y: centerY + Math.floor(index / 4) * 4 + random() * 2, lat: 41.02 + random() * 0.14, lon: 28.98 + random() * 0.19 },
+ heading: Math.round(random() * 359), speed: Math.round(8 + random() * 22), altitude: archetype === 'rover' || archetype === 'surface-vessel' ? 0 : Math.round(90 + random() * 230),
+ energy: Math.round(61 + random() * 34), link: Math.round(68 + random() * 31), health: groupName === 'DELTA' && index < 2 ? 'attention' : 'nominal', freshnessMs: 0, available: groupName === 'RESERVE', binding: 'simulated-bound', taskId,
+ };
+ ordinal += 1;
+ }
+ }
+ const events: ActivityEvent[] = [
+ { id: 'activity:1', timestamp: Date.UTC(2026, 7, 17, 11, 30, 2), actor: 'System', category: 'source', severity: 'info', message: 'Deterministic scenario ALPHA-17 connected', entityId: 'source:fake', acknowledged: true, target: { kind: 'source', entityId: 'source:fake', zoom: 1, inspector: 'event' } },
+ { id: 'activity:2', timestamp: Date.UTC(2026, 7, 17, 11, 31, 8), actor: 'Sara', category: 'operator', severity: 'info', message: 'Updated BRAVO coastal route', entityId: 'group:bravo', acknowledged: true, target: { kind: 'group', entityId: 'group:bravo', zoom: 2, inspector: 'summary' } },
+ { id: 'activity:3', timestamp: Date.UTC(2026, 7, 17, 11, 32, 11), actor: 'System', category: 'alert', severity: 'attention', message: 'BRAVO approaching constraint R-210B', entityId: 'group:bravo', acknowledged: false, target: { kind: 'constraint', entityId: 'constraint:r210b', position: { x: 73, y: 47, lat: 41.08, lon: 29.15 }, zoom: 2, inspector: 'event' } },
+ ];
+ return {
+ mission: { id: 'mission:alpha17', name: 'Coastal Survey', code: 'ALPHA-17', revision: 184, elapsedSeconds: 6137 }, tasks, groups, vehicles, operators,
+ leases: Object.fromEntries(groupSpecs.map(([id, , , , operatorId]) => [`lease:${id}`, { id: `lease:${id}`, entityId: id, holderId: operatorId, state: 'held', expiresAt: Date.UTC(2026, 7, 17, 11, 35, 0), observers: operatorsArray.filter(operator => operator.id !== operatorId).slice(0, 2).map(operator => operator.id) }])),
+ queue: [
+ { id: 'intent:1', title: 'Transit', detail: 'to Northern Sector', ownerId: 'operator:alex', groupId: 'group:alpha', taskId: 'task:alpha', durationMinutes: 8, state: 'completed', dependencies: [], resources: ['group:alpha'], validation: 'valid' },
+ { id: 'intent:2', title: 'Search Area A', detail: 'coverage pattern', ownerId: 'operator:alex', groupId: 'group:alpha', taskId: 'task:alpha', durationMinutes: 18, state: 'executing', dependencies: ['intent:1'], resources: ['group:alpha'], validation: 'valid' },
+ { id: 'intent:3', title: 'Loiter', detail: '6 minute observation', ownerId: 'operator:alex', groupId: 'group:alpha', durationMinutes: 6, state: 'queued', dependencies: ['intent:2'], resources: ['group:alpha'], validation: 'valid' },
+ { id: 'intent:4', title: 'Return', detail: 'staging point', ownerId: 'operator:alex', groupId: 'group:alpha', durationMinutes: 12, state: 'queued', dependencies: ['intent:3'], resources: ['group:alpha'], validation: 'valid' },
+ { id: 'intent:5', title: 'Recharge', detail: 'base service', ownerId: 'operator:alex', groupId: 'group:alpha', durationMinutes: 35, state: 'queued', dependencies: ['intent:4'], resources: ['group:alpha'], validation: 'valid' },
+ ],
+ alerts: [
+ { id: 'alert:bravo', severity: 'attention', title: 'BRAVO approaching constraint', detail: 'R-210B coastal limit · ETA 04:12', entityId: 'group:bravo', acknowledged: false, resolved: false, target: { kind: 'constraint', entityId: 'constraint:r210b', position: { x: 73, y: 47, lat: 41.08, lon: 29.15 }, zoom: 2, inspector: 'event' } },
+ { id: 'alert:alpha-eta', severity: 'attention', title: 'ALPHA task behind schedule', detail: 'ETA impact +00:08:45', entityId: 'task:alpha', acknowledged: false, resolved: false, target: { kind: 'task', entityId: 'task:alpha', position: { x: 39, y: 38, lat: 41.09, lon: 29.05 }, zoom: 2, inspector: 'task' } },
+ { id: 'alert:delta-readiness', severity: 'attention', title: 'DELTA readiness below plan', detail: '2 support vehicles require review', entityId: 'group:delta', acknowledged: false, resolved: false, target: { kind: 'group', entityId: 'group:delta', zoom: 2, inspector: 'summary' } },
+ ], events, source: { state: 'connecting', message: 'Connecting deterministic simulation', invalidEvents: 0, duplicates: 0, outOfOrder: 0 }, simulated: true, simulation: { paused: false, speed: 1, tick: 0 },
+ };
+}
diff --git a/ground-suite/src/adapters/fake/seededRandom.ts b/ground-suite/src/adapters/fake/seededRandom.ts
new file mode 100644
index 0000000..4fc273c
--- /dev/null
+++ b/ground-suite/src/adapters/fake/seededRandom.ts
@@ -0,0 +1,7 @@
+export function seededRandom(seed = 17): () => number {
+ let value = seed >>> 0;
+ return () => {
+ value = (value * 1664525 + 1013904223) >>> 0;
+ return value / 0x100000000;
+ };
+}
diff --git a/ground-suite/src/adapters/kaptan/KaptanDataSource.ts b/ground-suite/src/adapters/kaptan/KaptanDataSource.ts
new file mode 100644
index 0000000..9a7ba7c
--- /dev/null
+++ b/ground-suite/src/adapters/kaptan/KaptanDataSource.ts
@@ -0,0 +1,8 @@
+import type { CommandEnvelope, CommandReceipt } from '../../core/data-plane/CommandEnvelope';
+import type { DataPlaneSink, DataSource } from '../../core/data-plane/DataSource';
+export class KaptanDataSource implements DataSource {
+ readonly descriptor = { id: 'source:kaptan-unconfigured', name: 'Kaptan adapter boundary (not connected)', kind: 'kaptan' as const, simulated: false, capabilities: [] };
+ async connect(_sink: DataPlaneSink): Promise { throw new Error('No reviewed Kaptan live-data API is configured. Use the simulation source.'); }
+ async disconnect(): Promise {}
+ async send(command: CommandEnvelope): Promise { return { commandId: command.id, accepted: false, state: 'rejected', reason: 'Kaptan command transport is intentionally unavailable', remediation: 'Define and review authentication, binding, signing, replay protection, capability negotiation, and command acknowledgement contracts', acknowledgedAt: Date.now() }; }
+}
diff --git a/ground-suite/src/app/App.test.tsx b/ground-suite/src/app/App.test.tsx
new file mode 100644
index 0000000..209c083
--- /dev/null
+++ b/ground-suite/src/app/App.test.tsx
@@ -0,0 +1,54 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+import { DataPlane } from '../core/data-plane/DataPlane';
+import { FakeRealtimeSource } from '../adapters/fake/FakeRealtimeSource';
+import { initialInteraction, interactionReducer } from '../interaction/controller';
+import { App } from './App';
+
+const renderApp = () => { const plane = new DataPlane(new FakeRealtimeSource(17, false), callback => callback()); return { plane, ...render( ) }; };
+
+describe('persistent spatial operations scene', () => {
+ it('keeps one map mounted while tools open and close', async () => {
+ renderApp(); const map = await screen.findByTestId('operational-map');
+ await userEvent.click(screen.getByRole('button', { name: 'Settings' }));
+ expect(screen.getByTestId('operational-map')).toBe(map);
+ expect(screen.getByText('SIMULATION · NO LIVE MUTATIONS')).toBeVisible();
+ await userEvent.click(screen.getByRole('button', { name: 'Close settings' }));
+ expect(screen.getByTestId('operational-map')).toBe(map);
+ });
+
+ it('separates selection from authority and exposes group distributions', async () => {
+ renderApp(); expect(await screen.findByText('12 VEHICLES')).toBeVisible();
+ expect(screen.getByText('AUTHORITY HELD')).toBeVisible();
+ await userEvent.click(screen.getAllByRole('button', { name: /BRAVO/ })[0]);
+ expect(await screen.findByText('10 VEHICLES')).toBeVisible();
+ expect(screen.getByText('SELECTION ≠ AUTHORITY')).toBeVisible();
+ expect(screen.getByText('ENERGY DISTRIBUTION')).toBeVisible();
+ });
+
+ it('does not intercept tactical hotkeys in controls', async () => {
+ renderApp(); await userEvent.click(screen.getByRole('button', { name: 'Settings' }));
+ const density = screen.getByLabelText('Density'); await userEvent.click(density);
+ fireEvent.keyDown(density, { key: '1', ctrlKey: true });
+ await waitFor(() => expect(density).toHaveFocus());
+ });
+});
+
+describe('typed interaction controller', () => {
+ it('builds a route review without mutating observed truth', () => {
+ let state = interactionReducer(initialInteraction, { type: 'mode', mode: 'route' });
+ state = interactionReducer(state, { type: 'point', point: { x: 20, y: 20 }, targetIds: ['vehicle:001'] });
+ state = interactionReducer(state, { type: 'point', point: { x: 35, y: 27 }, targetIds: ['vehicle:001'] });
+ state = interactionReducer(state, { type: 'complete' });
+ expect(state.draft?.phase).toBe('review'); expect(state.draft?.targetIds).toEqual(['vehicle:001']); expect(state.artifacts).toHaveLength(0);
+ });
+
+ it('supports artifact completion, undo, and redo', () => {
+ let state = interactionReducer(initialInteraction, { type: 'mode', mode: 'polygon' });
+ for (const point of [{ x: 1, y: 1 }, { x: 5, y: 1 }, { x: 5, y: 5 }]) state = interactionReducer(state, { type: 'point', point, targetIds: [] });
+ state = interactionReducer(state, { type: 'complete' }); expect(state.artifacts).toHaveLength(1);
+ state = interactionReducer(state, { type: 'undo' }); expect(state.artifacts).toHaveLength(0);
+ state = interactionReducer(state, { type: 'redo' }); expect(state.artifacts).toHaveLength(1);
+ });
+});
diff --git a/ground-suite/src/app/App.tsx b/ground-suite/src/app/App.tsx
new file mode 100644
index 0000000..7b7c347
--- /dev/null
+++ b/ground-suite/src/app/App.tsx
@@ -0,0 +1,175 @@
+import { useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';
+import type { DataPlane } from '../core/data-plane/DataPlane';
+import type { CommandEnvelope } from '../core/data-plane/CommandEnvelope';
+import { LAYERS, PreferenceStore, type LayerId, type Preferences } from '../core/persistence/preferences';
+import type { Archetype, Vehicle } from '../domain/model';
+import { fleetSummary } from '../projections/selectors';
+import { MODES, draftDistanceKm, initialInteraction, interactionReducer, type DraftGeometry, type DraftPoint, type InteractionMode } from '../interaction/controller';
+import { Icon } from './Icon';
+import { OperationalMap, type Camera } from './OperationalMap';
+
+type Tool = 'queue' | 'events' | 'deploy' | 'configuration' | 'recovery' | 'settings' | 'layers' | 'telemetry' | null;
+type DeploymentMode = 'simulate' | 'discover' | 'import';
+const actorId = 'operator:alex';
+const layerLabels: Record = { searchAreas: 'Search areas', currentRoutes: 'Observed routes', plannedRoutes: 'Planned intent', constraints: 'Constraints', noFlyZones: 'Keep-out geometry', communicationCoverage: 'Communication coverage', pointsOfInterest: 'Points of interest', mapLabels: 'Map labels', operatorPresence: 'Operator presence', eventLocations: 'Event locations' };
+const toolButtons: Array<{ tool: Exclude; icon: Parameters[0]['name']; key?: string }> = [
+ { tool: 'deploy', icon: 'deploy', key: 'V' }, { tool: 'queue', icon: 'queue', key: 'Q' }, { tool: 'events', icon: 'events' }, { tool: 'configuration', icon: 'configure' }, { tool: 'layers', icon: 'layers', key: 'L' }, { tool: 'settings', icon: 'settings' },
+];
+const primaryModes: Array<{ mode: InteractionMode; icon: Parameters[0]['name']; key?: string }> = [
+ { mode: 'select', icon: 'pointer', key: 'S' }, { mode: 'pan', icon: 'hand' }, { mode: 'inspect', icon: 'inspect' }, { mode: 'route', icon: 'route', key: 'R' }, { mode: 'formation', icon: 'formation', key: 'F' }, { mode: 'polygon', icon: 'tools' }, { mode: 'measure-distance', icon: 'measure' },
+];
+const formatElapsed = (seconds: number) => [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, Math.floor(seconds) % 60].map(value => String(value).padStart(2, '0')).join(':');
+const exact = (ids: string[]) => [...new Set(ids)].sort();
+const commandId = () => `command:${Date.now()}:${crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)}`;
+const makeCommand = (kind: CommandEnvelope['kind'], targetIds: string[], payload: Record): CommandEnvelope => ({ id: commandId(), kind, actorId, targetIds: exact(targetIds), payload, createdAt: Date.now() });
+
+export function App({ plane }: { plane: DataPlane }) {
+ const state = useSyncExternalStore(plane.subscribe, plane.getSnapshot, plane.getSnapshot);
+ const preferenceStore = useMemo(() => new PreferenceStore(), []);
+ const [preferences, setPreferences] = useState(() => preferenceStore.load());
+ const [selection, setSelection] = useState([]);
+ const [focus, setFocus] = useState(0);
+ const [activeControlGroup, setActiveControlGroup] = useState(1);
+ const [controlGroups, setControlGroups] = useState>({});
+ const [tool, setTool] = useState(null);
+ const [camera, setCamera] = useState(() => ({ x: preferences.viewport.x, y: preferences.viewport.y, zoom: preferences.viewport.scale }));
+ const [interaction, dispatchInteraction] = useReducer(interactionReducer, initialInteraction);
+ const [contextPoint, setContextPoint] = useState(null);
+ const [toast, setToast] = useState('');
+ const [deploymentMode, setDeploymentMode] = useState('simulate');
+ const [deployCount, setDeployCount] = useState(50);
+ const [deployArchetype, setDeployArchetype] = useState('quadcopter');
+ const [staged, setStaged] = useState(0);
+ const [configSection, setConfigSection] = useState<'readiness' | 'sensors' | 'radio' | 'parameters'>('readiness');
+ const [timelineOpen, setTimelineOpen] = useState(false);
+ const [modePaletteOpen, setModePaletteOpen] = useState(false);
+ const [now, setNow] = useState(Date.now());
+ const lastGroupTap = useRef<{ number: number; at: number }>({ number: -1, at: 0 });
+
+ useEffect(() => { void plane.connect(); return () => { void plane.disconnect(); }; }, [plane]);
+ useEffect(() => { if (!selection.length && Object.keys(state.vehicles).length) { const alpha = Object.values(state.vehicles).filter(vehicle => vehicle.groupId === 'group:alpha').map(vehicle => vehicle.id); setSelection(alpha); setControlGroups({ 1: alpha }); } }, [selection.length, state.vehicles]);
+ useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []);
+ useEffect(() => { preferenceStore.save({ ...preferences, viewport: { x: camera.x, y: camera.y, scale: camera.zoom }, lastWorkspace: tool ?? 'mission' }); }, [camera, preferenceStore, preferences, tool]);
+ useEffect(() => { if (!toast) return; const timer = setTimeout(() => setToast(''), 3800); return () => clearTimeout(timer); }, [toast]);
+ useEffect(() => {
+ const keyboard = (event: KeyboardEvent) => {
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || (event.target as HTMLElement | null)?.isContentEditable) return;
+ const key = event.key.toLowerCase();
+ if ((event.ctrlKey || event.metaKey) && key === 'z') { event.preventDefault(); dispatchInteraction({ type: 'undo' }); return; }
+ if ((event.ctrlKey || event.metaKey) && key === 'y') { event.preventDefault(); dispatchInteraction({ type: 'redo' }); return; }
+ if (/^[0-9]$/.test(key)) { event.preventDefault(); const group = Number(key); if (event.ctrlKey || event.metaKey) { setControlGroups(value => ({ ...value, [group]: exact(selection) })); setActiveControlGroup(group); setToast(`Control group ${group} assigned · ${selection.length} local selections`); return; } const ids = controlGroups[group] ?? []; setSelection(current => event.shiftKey ? exact([...current, ...ids]) : ids); setActiveControlGroup(group); const at = Date.now(); if (lastGroupTap.current.number === group && at - lastGroupTap.current.at < 430) setCamera(frameVehicles(ids, state.vehicles)); lastGroupTap.current = { number: group, at }; return; }
+ if (key === 'escape') { if (contextPoint) setContextPoint(null); else if (tool) setTool(null); else dispatchInteraction({ type: 'cancel' }); return; }
+ if (key === 'enter') { dispatchInteraction({ type: 'complete' }); return; }
+ if (key === 'tab' && selection.length > 1) { event.preventDefault(); setFocus(value => (value + (event.shiftKey ? -1 : 1) + selection.length) % selection.length); return; }
+ if (key === 'v') { setTool('deploy'); return; } if (key === 'q') { setTool('queue'); setTimelineOpen(true); return; } if (key === 'l') { setTool('layers'); return; }
+ if (key === 's') dispatchInteraction({ type: 'mode', mode: 'select' }); if (key === 'r') dispatchInteraction({ type: 'mode', mode: 'route' }); if (key === 'm') dispatchInteraction({ type: 'mode', mode: 'marker' });
+ };
+ window.addEventListener('keydown', keyboard); return () => window.removeEventListener('keydown', keyboard);
+ }, [contextPoint, controlGroups, selection, state.vehicles, tool]);
+
+ const selectedVehicles = selection.map(id => state.vehicles[id]).filter((value): value is Vehicle => Boolean(value));
+ const focusedVehicle = selectedVehicles[focus % Math.max(selectedVehicles.length, 1)];
+ const summary = fleetSummary(state);
+ const authorities = new Set(selectedVehicles.map(vehicle => state.leases[`lease:${vehicle.groupId}`]?.holderId));
+ const hasAuthority = authorities.size === 1 && authorities.has(actorId);
+ function selectVehicles(ids: string[], additive = false) { setSelection(current => additive ? exact([...current.filter(id => !ids.includes(id)), ...ids.filter(id => !current.includes(id))]) : exact(ids)); setFocus(0); }
+ function chooseMode(mode: InteractionMode) { dispatchInteraction({ type: 'mode', mode }); setContextPoint(null); setModePaletteOpen(false); }
+ function openTool(next: Tool) { setTool(current => current === next ? null : next); if (next === 'events' || next === 'queue') setTimelineOpen(true); }
+ async function dispatchDraft() {
+ if (!interaction.draft) return;
+ const cmd = makeCommand('move', interaction.draft.targetIds, { geometry: interaction.draft.points, correlationId: commandId(), formation: interaction.draft.mode === 'formation' ? 'wedge' : undefined });
+ const preview = plane.preview(cmd);
+ if (!preview.allowed) { setToast(`Dispatch blocked · ${preview.checks.filter(item => item.status === 'fail').map(item => item.label).join(', ')}`); return; }
+ const receipt = await plane.submit(cmd); setToast(receipt.accepted ? `Accepted by simulated adapter · ${cmd.targetIds.length} exact targets` : `${receipt.reason} · no mission truth changed`); dispatchInteraction({ type: 'discard-draft' });
+ }
+ function updatePreferences(next: (value: Preferences) => Preferences) { setPreferences(value => next(value)); }
+
+ return
+
Skip to operational scene
+
+ K {state.mission.code} {state.mission.name} · rev {state.mission.revision}
+
+
+ 1 ? 'MIXED' : 'OBSERVER'}`} tone={hasAuthority ? 'nominal' : 'attention'}/>
+
+ openTool('recovery')}>ATTENTION {state.alerts.filter(alert => !alert.resolved).length} ACTIVE
+ MISSION / LOCAL {formatElapsed(state.mission.elapsedSeconds)} {new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(now)}
+ SIMULATION · NO LIVE MUTATIONS
+
+
+
+
+ {primaryModes.map(item => { const label = item.mode === 'polygon' ? 'Map creation tools' : MODES[item.mode].label; return item.mode === 'polygon' ? setModePaletteOpen(value => !value) : chooseMode(item.mode)}>{item.key && {item.key} } ; })}
+ {toolButtons.map(item => openTool(item.tool)}>{item.key && {item.key} } )}
+
+
+
+
+
+
setContextPoint(point)}/>
+ {modePaletteOpen && setModePaletteOpen(false)}/>}
+ {contextPoint && CONTEXT · LOCAL DRAFT setContextPoint(null)}> { chooseMode('move-intent'); dispatchInteraction({ type: 'point', point: contextPoint, targetIds: selection }); setContextPoint(null); }}>Move intent Review before dispatch { chooseMode('marker'); dispatchInteraction({ type: 'point', point: contextPoint, targetIds: selection }); setContextPoint(null); }}>Place marker Team annotation { chooseMode('measure-distance'); setContextPoint(null); }}>Measure from here No command effect
}
+ {tool && ['layers', 'settings'].includes(tool) && setTool(null)} preferences={preferences} updatePreferences={updatePreferences}/>}
+ {tool && ['configuration', 'recovery'].includes(tool) && setTool(null)} vehicle={focusedVehicle} state={state} section={configSection} setSection={setConfigSection}/>}
+
+
+ setFocus(Math.max(0, selection.indexOf(id)))} onConfigure={() => setTool('configuration')}/>
+
+
+ {tool === 'deploy' ? { setStaged(value => value + deployCount); setToast(`${deployCount} provisional ${deployArchetype} vehicles staged · not mission-ready`); }}/>: <>
+ {selectedVehicles.length ? selectedVehicles.slice(0, 12).map((vehicle, index) =>
setFocus(index)}>{vehicle.name} {vehicle.archetype.replace('-', ' ')} {state.queue.filter(intent => intent.groupId === vehicle.groupId && ['queued', 'validated'].includes(intent.state)).length} ) :
No local selection Click or marquee units on the map
}
+ {selectedVehicles.length} SELECTED {selectedVehicles.length > 1 ? `${Math.round(Math.min(...selectedVehicles.map(v => v.energy)))}–${Math.round(Math.max(...selectedVehicles.map(v => v.energy)))}% ENERGY` : focusedVehicle?.name ?? 'NO UNIT'} {hasAuthority ? 'AUTHORITY HELD' : 'SELECTION ≠ AUTHORITY'} {[
+ ['move-intent', 'route', 'MOVE'], ['route', 'route', 'ROUTE'], ['formation', 'formation', 'FORMATION'], ['marker', 'marker', 'MARK'], ['task-area', 'draw', 'TASK AREA'], ['measure-distance', 'measure', 'MEASURE'],
+ ].map(([mode, icon, label]) => chooseMode(mode as InteractionMode)}>[0]['name']}/>{label} )}
+ >}
+
+
+
+ { setTool('telemetry'); setTimelineOpen(true); }}>TELEMETRY { setTool('queue'); setTimelineOpen(true); }}>QUEUE {state.queue.length} { setTool('events'); setTimelineOpen(true); }}>EVENTS {state.events.length} LIVE TELEMETRY CONTINUES setTimelineOpen(value => !value)}>
+ {timelineOpen && { const vehicle = state.vehicles[entityId ?? '']; if (vehicle) { selectVehicles([vehicle.id]); setCamera(frameVehicles([vehicle.id], state.vehicles)); } }}/>}
+
+
+ {interaction.draft?.phase === 'review' && dispatchInteraction({ type: 'discard-draft' })} onStage={() => setToast('Intent staged locally · no vehicle commanded')} onDispatch={() => void dispatchDraft()}/>}
+ {toast && {toast} setToast('')}>
}
+
+
;
+}
+
+function title(value: string) { return value.charAt(0).toUpperCase() + value.slice(1); }
+function ModePalette({ active, choose, close }: { active: InteractionMode; choose: (mode: InteractionMode) => void; close: () => void }) { const groups: Array<[string, InteractionMode[]]> = [['SELECTION', ['select-additive','marquee','lasso']], ['ANNOTATION', ['marker','ping','note','polyline','arrow','freehand','polygon','circle']], ['TASK / SAFETY', ['task-area','corridor','geofence-include','geofence-exclude']], ['MEASUREMENT', ['measure-distance','measure-bearing','measure-area']], ['MISSION POINTS', ['home','rally','landing','recovery','staging']], ['REVISION', ['edit','erase']]]; return ; }
+function Status({ label, value, tone, icon }: { label: string; value: string; tone: string; icon?: Parameters[0]['name'] }) { return {icon && }{label} {value}
; }
+function frameVehicles(ids: string[], vehicles: Record): Camera { const selected = ids.map(id => vehicles[id]).filter(Boolean); if (!selected.length) return { x: 0, y: 0, zoom: .92 }; const x = selected.reduce((sum, v) => sum + v.position.x, 0) / selected.length; const y = selected.reduce((sum, v) => sum + v.position.y, 0) / selected.length; return { x: 50 - x, y: 50 - y, zoom: selected.length === 1 ? 3.2 : selected.length < 8 ? 2.1 : 1.5 }; }
+function Meters({ vehicle }: { vehicle: Vehicle }) { return {Math.round(vehicle.energy)}% · {vehicle.freshnessMs < 1000 ? 'LIVE' : `${(vehicle.freshnessMs / 1000).toFixed(1)}s`} ; }
+
+function SelectionInspector({ vehicles, focused, state, hasAuthority, onFocus, onConfigure }: { vehicles: Vehicle[]; focused?: Vehicle; state: ReturnType; hasAuthority: boolean; onFocus: (id: string) => void; onConfigure: () => void }) {
+ const energy = vehicles.map(vehicle => Math.round(vehicle.energy)).sort((a, b) => a - b); const median = energy.length ? energy[Math.floor(energy.length / 2)] : 0;
+ if (!vehicles.length) return ;
+ return {vehicles.length === 1 ? 'TACTICAL / ENGINEERING' : 'MULTI-SELECTION'} {vehicles.length === 1 ? focused?.name : `${vehicles.length} VEHICLES`} {hasAuthority ? 'LEASE HELD' : 'MIXED / OBSERVE'}
+ {vehicles.length === 1 && focused ? <>{focused.archetype.toUpperCase()} Observed {focused.binding} truth {focused.health}
POSITION {focused.position.lat.toFixed(5)}°N {focused.position.lon.toFixed(5)}°E
HDG / ALT / SPD {Math.round(focused.heading)}° · {Math.round(focused.altitude)} m {Math.round(focused.speed)} m/s
Open commissioning & configuration > : <>ENERGY MIN {energy[0]}%
MEDIAN {median}%
MAX {energy.at(-1)}%
v.energy)} label="ENERGY DISTRIBUTION"/> v.link)} label="LINK DISTRIBUTION"/>OBSERVED AVAILABILITY {vehicles.filter(v => v.health === 'nominal').length}/{vehicles.length} NOMINAL v.health === 'nominal').length }}/> v.health !== 'nominal').length }}/>
WORST EXCEPTIONS {vehicles.filter(v => v.health !== 'nominal' || v.energy < 70).length} {vehicles.filter(v => v.health !== 'nominal' || v.energy < 70).slice(0, 4).map(vehicle => onFocus(vehicle.id)}>{vehicle.name} {vehicle.health !== 'nominal' ? vehicle.health : `Reserve ${Math.round(vehicle.energy)}%`} LOCATE )} >}
+ ;
+}
+
+function TelemetryChart({ label, unit, value, values, color, threshold }: { label: string; unit: string; value: number; values: number[]; color: string; threshold: number }) { const pts = values.map((v, i) => `${i / (values.length - 1) * 100},${36 - v * .32}`).join(' '); return −10 min threshold {threshold}{unit} now ; }
+function Metric({ label, value, tone }: { label: string; value: string; tone: string }) { return {label} {value}
; }
+function Histogram({ values, label }: { values: number[]; label: string }) { const bins = [50, 60, 70, 80, 90].map(low => values.filter(value => value >= low && value < low + 10).length); const max = Math.max(...bins, 1); return {bins.map((value, index) => {value} )}
; }
+
+function DeploymentDeck({ mode, setMode, archetype, setArchetype, count, setCount, staged, onStage }: { mode: DeploymentMode; setMode: (mode: DeploymentMode) => void; archetype: Archetype; setArchetype: (type: Archetype) => void; count: number; setCount: (count: number) => void; staged: number; onStage: () => void }) { const archetypes: Archetype[] = ['quadcopter', 'fixed-wing', 'rover', 'surface-vessel']; return VEHICLE DEPLOYMENT · PROVISIONAL {staged ? `${staged} STAGED` : 'PLACE A COHORT'} {(['simulate', 'discover', 'import'] as const).map(value => setMode(value)} key={value}>{value.toUpperCase()} )} {mode === 'simulate' ? <>
{archetypes.map(value => setArchetype(value)}>{value.replace('-', ' ')} {value === 'rover' ? 'wheeled · mapping' : value === 'surface-vessel' ? 'marine · sonar' : 'air · EO/IR'} )}
COUNT setCount(Number(event.target.value))}>1 12 25 50 FORMATION Grid Wedge Relay chain Coverage sweep SPACING STAGE {count} GHOSTS
> : mode === 'discover' ?
{[['USB / SERIAL', 'KPT-F722-9A31', 'trusted'], ['RADIO', 'KPT-H743-113E', 'system ID conflict'], ['ETHERNET', 'PX4-A91C-17', 'unclaimed']].map(item => {item[1]} {item[0]} · v0.8.4 {item[2]} )}
:
Versioned provisioning manifest Preview identities, template versions, anchors, and conflicts before staging. CHOOSE BUNDLE
}
; }
+
+function CompactDrawer({ tool, close, preferences, updatePreferences }: { tool: 'layers' | 'settings'; close: () => void; preferences: Preferences; updatePreferences: (fn: (value: Preferences) => Preferences) => void }) { return ; }
+
+function RightWorkbench({ tool, close, vehicle, state, section, setSection }: { tool: 'configuration' | 'recovery'; close: () => void; vehicle?: Vehicle; state: ReturnType; section: 'readiness' | 'sensors' | 'radio' | 'parameters'; setSection: (section: 'readiness' | 'sensors' | 'radio' | 'parameters') => void }) { if (tool === 'recovery') return ;
+ return DOCKED COMMISSIONING {vehicle?.name ?? 'STAGING TRAY'} BOUND CONFIGURED CALIBRATE VALIDATE
{(['readiness', 'sensors', 'radio', 'parameters'] as const).map(value => setSection(value)}>{value.toUpperCase()} )}
+ {section === 'readiness' && 72% READY
{[['Identity', 'complete'], ['Firmware', 'complete'], ['Sensors', 'blocker'], ['Radio', 'warning'], ['Power', 'complete'], ['Failsafes', 'blocker']].map(item => {item[0]} {item[1]} )}
2 readiness blockers Compass calibration failed · return behavior has no rally point
}
+ {section === 'sensors' && X Y Z
{['Accelerometer · passed','Gyroscope · passed','Compass · failed','Level horizon · required','GNSS · 3D fix'].map(value => {value} )}
PRESS AND HOLD TO CALIBRATE }
+ {section === 'radio' &&
{Array.from({ length: 8 }, (_, index) =>
CH {index + 1} {1100 + index * 83} μs )}
}
+ {section === 'parameters' && {[['BAT_CAPACITY','5000','5200','mAh'],['RTL_ALT','30','45','m'],['COM_DISARM_LAND','2','2','s'],['GF_MAX_HOR_DIST','1000','850','m']].map((item, index) =>
{item[0]} {index === 1 ? 'Safety critical · reboot required' : 'Fleet template'} {item[1]} {item[2]} {item[3]}
)}
BASELINE → CURRENT → DESIRED REVIEW 2 CHANGES }
+ ; }
+
+function LowerDrawer({ tool, state, vehicles, onJump }: { tool: Tool; state: ReturnType; vehicles: Vehicle[]; onJump: (id?: string) => void }) { if (tool === 'queue') return {state.queue.map((intent, index) =>
{index + 1} {intent.title} {intent.detail} · {intent.durationMinutes} min {intent.state} {intent.validation} )}
; if (tool === 'events') return {state.events.slice(0, 8).map(event =>
{new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} {event.actor} {event.message} {event.category} onJump(event.entityId)}>LOCATE )}
; return a + v.energy, 0) / vehicles.length) : 0} color="#55c77a" values={[92,90,89,87,84,83,81,79,77,76]} threshold={55}/> a + v.link, 0) / vehicles.length) : 0} color="#45c8ef" values={[98,94,91,82,90,86,70,88,91,89]} threshold={50}/>COMMAND LIFECYCLE VALIDATED SENT 8 ACK 4 EXEC
Crosshair 14:32:11 · 1 source gap · telemetry buffer bounded to 15 min
; }
+
+function ReviewStrip({ draft, state, hasAuthority, onCancel, onStage, onDispatch }: { draft: DraftGeometry; state: ReturnType; hasAuthority: boolean; onCancel: () => void; onStage: () => void; onDispatch: () => void }) { const distance = draftDistanceKm(draft.points); const targets = draft.targetIds.map(id => state.vehicles[id]).filter(Boolean); const minEnergy = targets.length ? Math.min(...targets.map(v => v.energy)) : 0; const projectedReserve = Math.round(Math.max(0, minEnergy - Math.ceil(distance * 1.7))); const conflict = draft.points.some(point => point.x > 68 && point.x < 84 && point.y > 20 && point.y < 40); return LOCAL {draft.mode.toUpperCase()} DRAFT · NOT SENT {draft.points.length} WAYPOINTS · {targets.length} EXACT TARGETS 60 ? 'nominal' : 'attention'}/>
CANCEL STAGE DISPATCH SIMULATION ; }
diff --git a/ground-suite/src/app/Icon.tsx b/ground-suite/src/app/Icon.tsx
new file mode 100644
index 0000000..a6fbc37
--- /dev/null
+++ b/ground-suite/src/app/Icon.tsx
@@ -0,0 +1,34 @@
+import type { SVGProps } from 'react';
+
+const paths: Record = {
+ pointer: <> >,
+ hand: <> >,
+ inspect: <> >,
+ route: <> >,
+ formation: <> >,
+ marker: <> >,
+ draw: <> >,
+ measure: <> >,
+ layers: <> >,
+ deploy: <> >,
+ fleet: <> >,
+ queue: <> >,
+ events: <> >,
+ settings: <> >,
+ configure: <> >,
+ recovery: <> >,
+ tools: <> >,
+ alert: <> >,
+ signal: <> >,
+ battery: <> >,
+ aircraft: <> >,
+ close: <> >,
+ chevron: <> >,
+ undo: <> >,
+ redo: <> >,
+ check: <> >,
+};
+
+export function Icon({ name, ...props }: { name: keyof typeof paths } & SVGProps) {
+ return {paths[name]} ;
+}
diff --git a/ground-suite/src/app/OperationalMap.tsx b/ground-suite/src/app/OperationalMap.tsx
new file mode 100644
index 0000000..3340e7e
--- /dev/null
+++ b/ground-suite/src/app/OperationalMap.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useRef, useState } from 'react';
+import type { Dispatch, PointerEvent as ReactPointerEvent, SetStateAction, WheelEvent as ReactWheelEvent } from 'react';
+import type { MissionState } from '../domain/model';
+import { MODES, type DraftGeometry, type DraftPoint, type InteractionAction, type InteractionState } from '../interaction/controller';
+import { Icon } from './Icon';
+
+export type Camera = { x: number; y: number; zoom: number };
+type Props = {
+ state: MissionState;
+ selection: string[];
+ interaction: InteractionState;
+ dispatch: Dispatch;
+ camera: Camera;
+ setCamera: Dispatch>;
+ selectVehicles: (ids: string[], additive?: boolean) => void;
+ onContext: (point: DraftPoint) => void;
+};
+
+const points = (draft: DraftGeometry) => draft.points.map(point => `${point.x},${point.y}`).join(' ');
+const path = (draft: DraftGeometry) => draft.points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`).join(' ');
+const isPolygon = (draft: DraftGeometry) => ['polygon', 'task-area', 'measure-area', 'geofence-include', 'geofence-exclude'].includes(draft.mode);
+const groupColors: Record = { 'group:alpha': '#45c8ef', 'group:bravo': '#6ed779', 'group:charlie': '#8ea9ff', 'group:delta': '#e8b94b', 'group:reserve': '#a879e2' };
+const insidePolygon = (point: DraftPoint, polygon: DraftPoint[]) => { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const a = polygon[i]; const b = polygon[j]; if ((a.y > point.y) !== (b.y > point.y) && point.x < (b.x - a.x) * (point.y - a.y) / ((b.y - a.y) || .0001) + a.x) inside = !inside; } return inside; };
+
+export function OperationalMap({ state, selection, interaction, dispatch, camera, setCamera, selectVehicles, onContext }: Props) {
+ const pointer = useRef<{ id: number; x: number; y: number; mapX: number; mapY: number; kind: 'pan' | 'marquee' | 'draw' } | null>(null);
+ const spaceHeld = useRef(false);
+ const [marquee, setMarquee] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
+ const [cursorPoint, setCursorPoint] = useState(null);
+ useEffect(() => {
+ const down = (event: KeyboardEvent) => { if (event.code === 'Space' && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)) spaceHeld.current = true; };
+ const up = (event: KeyboardEvent) => { if (event.code === 'Space') spaceHeld.current = false; };
+ window.addEventListener('keydown', down); window.addEventListener('keyup', up); return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); };
+ }, []);
+ const toMap = (event: ReactPointerEvent): DraftPoint => {
+ const rect = event.currentTarget.getBoundingClientRect();
+ return { x: Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width - .5) * 100 / camera.zoom + 50 - camera.x)), y: Math.max(0, Math.min(100, ((event.clientY - rect.top) / rect.height - .5) * 100 / camera.zoom + 50 - camera.y)) };
+ };
+ const toScreen = (event: ReactPointerEvent) => {
+ const rect = event.currentTarget.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top };
+ };
+ function down(event: ReactPointerEvent) {
+ if ((event.target as HTMLElement).closest('button,[data-map-entity]')) return;
+ const map = toMap(event); const screen = toScreen(event); const temporaryPan = event.button === 1 || event.buttons === 4 || spaceHeld.current;
+ const kind = interaction.mode === 'pan' || temporaryPan ? 'pan' : ['select', 'select-additive', 'marquee'].includes(interaction.mode) ? 'marquee' : 'draw';
+ pointer.current = { id: event.pointerId, x: screen.x, y: screen.y, mapX: map.x, mapY: map.y, kind };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ if (kind === 'marquee') setMarquee({ x1: screen.x, y1: screen.y, x2: screen.x, y2: screen.y });
+ if (kind === 'draw') dispatch({ type: 'point', point: map, targetIds: [...selection] });
+ }
+ function move(event: ReactPointerEvent) {
+ const map = toMap(event); setCursorPoint(map); if (!pointer.current) return;
+ const screen = toScreen(event);
+ if (pointer.current.kind === 'pan') {
+ const dx = (screen.x - pointer.current.x) / event.currentTarget.clientWidth * 100 / camera.zoom;
+ const dy = (screen.y - pointer.current.y) / event.currentTarget.clientHeight * 100 / camera.zoom;
+ setCamera(value => ({ ...value, x: value.x + dx, y: value.y + dy })); pointer.current.x = screen.x; pointer.current.y = screen.y;
+ } else if (pointer.current.kind === 'marquee') setMarquee(value => value ? { ...value, x2: screen.x, y2: screen.y } : null);
+ else if (['freehand', 'lasso'].includes(interaction.mode)) dispatch({ type: 'freehand', point: map });
+ }
+ function up(event: ReactPointerEvent) {
+ if (!pointer.current) return;
+ if (pointer.current.kind === 'marquee' && marquee) {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const x1 = ((Math.min(marquee.x1, marquee.x2) / rect.width - .5) * 100 / camera.zoom + 50 - camera.x);
+ const x2 = ((Math.max(marquee.x1, marquee.x2) / rect.width - .5) * 100 / camera.zoom + 50 - camera.x);
+ const y1 = ((Math.min(marquee.y1, marquee.y2) / rect.height - .5) * 100 / camera.zoom + 50 - camera.y);
+ const y2 = ((Math.max(marquee.y1, marquee.y2) / rect.height - .5) * 100 / camera.zoom + 50 - camera.y);
+ const moved = Math.abs(marquee.x2 - marquee.x1) + Math.abs(marquee.y2 - marquee.y1) > 8;
+ const additive = event.shiftKey || interaction.mode === 'select-additive';
+ if (moved) selectVehicles(Object.values(state.vehicles).filter(vehicle => vehicle.position.x >= x1 && vehicle.position.x <= x2 && vehicle.position.y >= y1 && vehicle.position.y <= y2).map(vehicle => vehicle.id), additive);
+ else if (!additive) selectVehicles([]);
+ }
+ if (pointer.current.kind === 'draw' && ['circle', 'formation', 'move-intent', 'measure-distance', 'measure-bearing'].includes(interaction.mode)) dispatch({ type: 'point', point: toMap(event), targetIds: [...selection] });
+ if (pointer.current.kind === 'draw' && interaction.mode === 'lasso') { const polygon = [...(interaction.draft?.points ?? []), toMap(event)]; selectVehicles(Object.values(state.vehicles).filter(vehicle => insidePolygon(vehicle.position, polygon)).map(vehicle => vehicle.id), event.shiftKey); dispatch({ type: 'discard-draft' }); }
+ else if (pointer.current.kind === 'draw' && interaction.mode === 'freehand') dispatch({ type: 'complete' });
+ pointer.current = null; setMarquee(null);
+ }
+ function wheel(event: ReactWheelEvent) { event.preventDefault(); setCamera(value => ({ ...value, zoom: Math.max(.72, Math.min(4.2, value.zoom * Math.exp(-event.deltaY * .0012))) })); }
+ const semantic = camera.zoom < 1.05 ? 'STRATEGIC' : camera.zoom < 1.75 ? 'OPERATIONAL' : camera.zoom < 2.8 ? 'TACTICAL' : 'ENGINEERING';
+ const allDrafts = [...interaction.artifacts, ...(interaction.draft ? [interaction.draft] : [])];
+ return { pointer.current = null; setMarquee(null); }} onWheel={wheel} onContextMenu={event => { event.preventDefault(); onContext(toMap(event as unknown as ReactPointerEvent)); }}>
+
+
+
+
+
+
+
+
+
+ {allDrafts.map(draft =>
+ {isPolygon(draft) ? : draft.mode === 'circle' && draft.points.length > 1 ? : }
+ {draft.points.map((point, index) => {index + 1} )}
+ )}
+ {interaction.draft?.mode === 'formation' && interaction.draft.points.length > 1 && Array.from({ length: Math.min(12, selection.length) }, (_, index) => { const anchor = interaction.draft!.points[0]; const end = interaction.draft!.points[1]; const angle = Math.atan2(end.y - anchor.y, end.x - anchor.x); const row = Math.floor(index / 4); const col = index % 4 - 1.5; const x = anchor.x + Math.cos(angle) * row * 3 - Math.sin(angle) * col * 2.5; const y = anchor.y + Math.sin(angle) * row * 3 + Math.cos(angle) * col * 2.5; return {index + 1} ; })}
+
+
ALPHA SEARCH 82% coverage
+
R-210B keep out
+ {semantic === 'STRATEGIC' ? Object.values(state.groups).map(group => { const vehicles = Object.values(state.vehicles).filter(vehicle => vehicle.groupId === group.id); const x = vehicles.reduce((sum, item) => sum + item.position.x, 0) / vehicles.length; const y = vehicles.reduce((sum, item) => sum + item.position.y, 0) / vehicles.length; return
selection.includes(v.id)) ? 'selected' : ''}`} style={{ left: `${x}%`, top: `${y}%`, '--group': groupColors[group.id] } as React.CSSProperties} onPointerDown={event => event.stopPropagation()} onClick={event => selectVehicles(vehicles.map(v => v.id), event.shiftKey)}>{group.name[0]} {group.name} {group.count} units · {group.progress}% ; }) : Object.values(state.vehicles).map(vehicle =>
1500 ? 'stale' : ''}`} style={{ left: `${vehicle.position.x}%`, top: `${vehicle.position.y}%`, transform: `translate(-50%,-50%) rotate(${vehicle.heading}deg)`, '--group': groupColors[vehicle.groupId] } as React.CSSProperties} onPointerDown={event => event.stopPropagation()} onClick={event => selectVehicles([vehicle.id], event.shiftKey)}>{vehicle.name} )}
+
+ {marquee &&
}
+ {semantic} {MODES[interaction.mode].label} {interaction.draft ? interaction.draft.message : MODES[interaction.mode].pointer}
+ {Math.round(5 / camera.zoom)} km
+ setCamera(value => ({ ...value, zoom: Math.min(4.2, value.zoom * 1.25) }))}>+ setCamera(value => ({ ...value, zoom: Math.max(.72, value.zoom / 1.25) }))}>−
+ setCamera({ x: 0, y: 0, zoom: .92 })}>
+
+ TACTICAL OVERVIEW
+
+ {cursorPoint && interaction.mode !== 'select' && {(41.02 + cursorPoint.y * .0014).toFixed(5)}°N {(28.98 + cursorPoint.x * .0019).toFixed(5)}°E }
+ ;
+}
diff --git a/ground-suite/src/app/runtime.ts b/ground-suite/src/app/runtime.ts
new file mode 100644
index 0000000..5dfb232
--- /dev/null
+++ b/ground-suite/src/app/runtime.ts
@@ -0,0 +1,3 @@
+import { FakeRealtimeSource } from '../adapters/fake/FakeRealtimeSource';
+import { DataPlane } from '../core/data-plane/DataPlane';
+export function createSimulationRuntime(options: { automatic?: boolean; seed?: number; schedule?: (callback: () => void) => unknown } = {}): DataPlane { return new DataPlane(new FakeRealtimeSource(options.seed ?? 17, options.automatic ?? true), options.schedule); }
diff --git a/ground-suite/src/core/clock/VirtualClock.ts b/ground-suite/src/core/clock/VirtualClock.ts
new file mode 100644
index 0000000..9b937c7
--- /dev/null
+++ b/ground-suite/src/core/clock/VirtualClock.ts
@@ -0,0 +1,13 @@
+export class VirtualClock {
+ private time: number;
+ private paused = false;
+ private speed = 1;
+ constructor(start = Date.UTC(2026, 7, 17, 11, 30, 0)) { this.time = start; }
+ now(): number { return this.time; }
+ advance(milliseconds: number): number { if (!this.paused) this.time += milliseconds * this.speed; return this.time; }
+ pause(): void { this.paused = true; }
+ resume(): void { this.paused = false; }
+ setSpeed(speed: number): void { this.speed = Math.max(0.25, Math.min(8, speed)); }
+ restart(start = Date.UTC(2026, 7, 17, 11, 30, 0)): void { this.time = start; this.paused = false; this.speed = 1; }
+ get state(): { time: number; paused: boolean; speed: number } { return { time: this.time, paused: this.paused, speed: this.speed }; }
+}
diff --git a/ground-suite/src/core/data-plane/CommandEnvelope.ts b/ground-suite/src/core/data-plane/CommandEnvelope.ts
new file mode 100644
index 0000000..9bbcc23
--- /dev/null
+++ b/ground-suite/src/core/data-plane/CommandEnvelope.ts
@@ -0,0 +1,4 @@
+export type CommandKind = 'assign-task' | 'move' | 'hold' | 'resume' | 'transfer-authority' | 'request-authority' | 'reorder-queue' | 'accept-replacement' | 'acknowledge-alert' | 'create-group' | 'split-group' | 'merge-group';
+export interface CommandEnvelope { id: string; kind: CommandKind; actorId: string; targetIds: string[]; payload: Record; createdAt: number }
+export interface CommandReceipt { commandId: string; accepted: boolean; state: 'acknowledged' | 'rejected'; reason?: string; remediation?: string; acknowledgedAt: number }
+export interface CommandPreview { command: CommandEnvelope; allowed: boolean; affectedVehicles: string[]; authorityHolder?: string; summary: string; location: string; next: string; failureEffect: string; checks: Array<{ label: string; status: 'pass' | 'warning' | 'fail'; detail: string }> }
diff --git a/ground-suite/src/core/data-plane/DataPlane.test.ts b/ground-suite/src/core/data-plane/DataPlane.test.ts
new file mode 100644
index 0000000..0757775
--- /dev/null
+++ b/ground-suite/src/core/data-plane/DataPlane.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+import { FakeRealtimeSource } from '../../adapters/fake/FakeRealtimeSource';
+import { createScenario } from '../../adapters/fake/scenario';
+import { DataPlane } from './DataPlane';
+import type { EventEnvelope } from './EventEnvelope';
+
+const immediate = (callback: () => void) => callback();
+describe('DataPlane', () => {
+ it('accepts a deterministic 50 vehicle snapshot only through the source contract', async () => {
+ const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate);
+ await plane.connect();
+ expect(Object.keys(plane.getSnapshot().vehicles)).toHaveLength(50);
+ expect(plane.getSnapshot().mission.code).toBe('ALPHA-17');
+ expect(plane.getSnapshot().source.state).toBe('connected');
+ });
+ it('deduplicates IDs and drops out-of-order sequence numbers', () => {
+ const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate);
+ const event = { schemaVersion: 1, eventId: 'x', streamId: 'stream', sequence: 2, type: 'source-status', entity: { kind: 'source', id: 'source' }, sourceTimestamp: 1, receivedTimestamp: 1, quality: { stale: false, confidence: 1 }, payload: { state: 'connected', message: 'ok' } } satisfies EventEnvelope;
+ plane.accept(event); plane.accept(event);
+ plane.accept({ ...event, eventId: 'y', sequence: 1 });
+ expect(plane.getSnapshot().source.duplicates).toBe(1);
+ expect(plane.getSnapshot().source.outOfOrder).toBe(1);
+ });
+ it('surfaces invalid events without crashing', () => {
+ const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate);
+ plane.accept({ nope: true });
+ expect(plane.getSnapshot().source.state).toBe('degraded');
+ expect(plane.getSnapshot().source.invalidEvents).toBe(1);
+ });
+ it('batches subscriber notification using the configured projection cadence', () => {
+ const callbacks: Array<() => void> = []; const plane = new DataPlane(new FakeRealtimeSource(17, false), callback => callbacks.push(callback)); let calls = 0; plane.subscribe(() => calls++);
+ plane.accept({ schemaVersion: 1, eventId: 'a', streamId: 's', sequence: 1, type: 'source-status', entity: { kind: 'source', id: 's' }, sourceTimestamp: 1, receivedTimestamp: 1, quality: { stale: false, confidence: 1 }, payload: { state: 'connected' } });
+ plane.accept({ schemaVersion: 1, eventId: 'b', streamId: 's', sequence: 2, type: 'source-status', entity: { kind: 'source', id: 's' }, sourceTimestamp: 2, receivedTimestamp: 2, quality: { stale: false, confidence: 1 }, payload: { state: 'connected' } });
+ expect(callbacks).toHaveLength(1); callbacks[0](); expect(calls).toBe(1);
+ });
+ it('does not infer authority from selection in command previews', async () => {
+ const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate); await plane.connect();
+ const preview = plane.preview({ id: 'cmd', kind: 'move', actorId: 'operator:sara', targetIds: ['group:alpha'], payload: {}, createdAt: 1 });
+ expect(preview.allowed).toBe(false);
+ expect(preview.checks.find(check => check.label === 'Authority')?.status).toBe('fail');
+ });
+ it('supports pause, deterministic stepping, restart and speed control', async () => {
+ const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate); await plane.connect(); const initial = plane.getSnapshot().simulation.tick;
+ plane.step(100); expect(plane.getSnapshot().simulation.tick).toBe(initial + 1);
+ plane.pause(); const paused = plane.getSnapshot().simulation.tick; plane.step(100); expect(plane.getSnapshot().simulation.tick).toBe(paused);
+ plane.resume(); plane.setSpeed(2); plane.step(100); expect(plane.getSnapshot().simulation.speed).toBe(2);
+ plane.restart(); expect(plane.getSnapshot().simulation.tick).toBe(0);
+ });
+ it('starts from the required scenario shape', () => {
+ const scenario = createScenario(17);
+ expect(Object.values(scenario.groups).map(group => group.count)).toEqual([12, 10, 8, 12, 8]);
+ expect(Object.keys(scenario.operators)).toHaveLength(5);
+ expect(new Set(Object.values(scenario.vehicles).map(vehicle => vehicle.archetype)).size).toBe(6);
+ });
+});
diff --git a/ground-suite/src/core/data-plane/DataPlane.ts b/ground-suite/src/core/data-plane/DataPlane.ts
new file mode 100644
index 0000000..c2a9e59
--- /dev/null
+++ b/ground-suite/src/core/data-plane/DataPlane.ts
@@ -0,0 +1,105 @@
+import type { ActivityEvent, Alert, MissionState, Vehicle } from '../../domain/model';
+import { emptyMissionState } from '../../domain/model';
+import type { CommandEnvelope, CommandPreview, CommandReceipt } from './CommandEnvelope';
+import type { DataSource } from './DataSource';
+import type { EventEnvelope } from './EventEnvelope';
+import { validateEvent } from './eventValidation';
+
+type Listener = () => void;
+export class DataPlane {
+ private state = emptyMissionState();
+ private listeners = new Set();
+ private seen = new Set();
+ private sequences = new Map();
+ private pendingNotify = false;
+ private readonly journal: EventEnvelope[] = [];
+ constructor(private readonly source: DataSource, private readonly schedule: (callback: () => void) => unknown = callback => globalThis.setTimeout(callback, 16)) {}
+ getSnapshot = (): MissionState => this.state;
+ subscribe = (listener: Listener): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
+ getJournal(): readonly EventEnvelope[] { return this.journal; }
+ get descriptor() { return this.source.descriptor; }
+ async connect(): Promise {
+ this.state = { ...this.state, source: { ...this.state.source, state: 'connecting', message: `Connecting ${this.source.descriptor.name}` } };
+ this.notify();
+ try { await this.source.connect({ accept: event => this.accept(event), reportError: error => this.reportError(error) }); }
+ catch (error) { this.reportError(error instanceof Error ? error : new Error(String(error))); }
+ }
+ async disconnect(): Promise { await this.source.disconnect(); this.state = { ...this.state, source: { ...this.state.source, state: 'disconnected', message: 'Source disconnected safely' } }; this.notify(); }
+ accept(value: unknown): void {
+ const result = validateEvent(value);
+ if (!result.valid) { this.state = { ...this.state, source: { ...this.state.source, state: 'degraded', message: result.reason, invalidEvents: this.state.source.invalidEvents + 1 } }; this.notify(); return; }
+ const event = result.event;
+ if (this.seen.has(event.eventId)) { this.state = { ...this.state, source: { ...this.state.source, duplicates: this.state.source.duplicates + 1 } }; this.notify(); return; }
+ this.seen.add(event.eventId);
+ if (this.seen.size > 5000) this.seen = new Set([...this.seen].slice(-2500));
+ const previous = this.sequences.get(event.streamId) ?? -1;
+ if (event.sequence <= previous) { this.state = { ...this.state, source: { ...this.state.source, outOfOrder: this.state.source.outOfOrder + 1 } }; this.notify(); return; }
+ this.sequences.set(event.streamId, event.sequence);
+ this.journal.push(event); if (this.journal.length > 1000) this.journal.shift();
+ this.reduce(event);
+ this.notify();
+ }
+ private reduce(event: EventEnvelope): void {
+ if (event.type === 'snapshot') { this.state = structuredClone(event.payload as MissionState); return; }
+ if (event.type === 'telemetry') {
+ const payload = event.payload as { updates: Array & Pick>; elapsedSeconds: number; simulation: MissionState['simulation'] };
+ const vehicles = { ...this.state.vehicles };
+ for (const update of payload.updates) if (vehicles[update.id]) vehicles[update.id] = { ...vehicles[update.id], ...update };
+ this.state = { ...this.state, vehicles, mission: { ...this.state.mission, elapsedSeconds: payload.elapsedSeconds }, simulation: payload.simulation, source: { ...this.state.source, state: event.quality.stale ? 'degraded' : this.state.source.state, lastEventAt: event.receivedTimestamp } };
+ return;
+ }
+ if (event.type === 'alert') {
+ const alert = event.payload as Alert;
+ if (this.state.alerts.some(item => item.id === alert.id)) return;
+ const activity: ActivityEvent = { id: `activity:${alert.id}`, timestamp: event.sourceTimestamp, actor: 'System', category: 'alert', severity: alert.severity, message: alert.title, entityId: alert.entityId, acknowledged: false, target: alert.target };
+ this.state = { ...this.state, alerts: [alert, ...this.state.alerts], events: [activity, ...this.state.events].slice(0, 500) };
+ return;
+ }
+ if (event.type === 'source-status') {
+ const payload = event.payload as Partial & { simulation?: Partial };
+ this.state = { ...this.state, source: { ...this.state.source, ...payload, lastEventAt: event.receivedTimestamp }, simulation: { ...this.state.simulation, ...(payload.simulation ?? {}) } };
+ return;
+ }
+ if (event.type === 'command-state') {
+ const payload = event.payload as { command: CommandEnvelope; receipt: CommandReceipt; activity: MissionState['events'][number] };
+ let next = { ...this.state, events: [payload.activity, ...this.state.events].slice(0, 500) };
+ if (payload.receipt.accepted && payload.command.kind === 'transfer-authority') {
+ const groupId = payload.command.targetIds[0]; const leaseId = `lease:${groupId}`; const nextHolder = String(payload.command.payload.toOperatorId);
+ next = { ...next, leases: { ...next.leases, [leaseId]: { ...next.leases[leaseId], holderId: nextHolder, state: 'held', requestedBy: undefined } }, groups: { ...next.groups, [groupId]: { ...next.groups[groupId], operatorId: nextHolder } } };
+ }
+ if (payload.receipt.accepted && payload.command.kind === 'reorder-queue') {
+ const order = payload.command.payload.order as string[]; next = { ...next, queue: order.map(id => next.queue.find(item => item.id === id)).filter((item): item is MissionState['queue'][number] => Boolean(item)) };
+ }
+ if (payload.receipt.accepted && payload.command.kind === 'accept-replacement') {
+ const replacementId = String(payload.command.payload.replacementId); const failedId = String(payload.command.payload.failedId);
+ const vehicles: Record = { ...next.vehicles, [replacementId]: { ...next.vehicles[replacementId], groupId: 'group:alpha', available: false, taskId: 'task:alpha' }, [failedId]: { ...next.vehicles[failedId], health: 'unavailable', available: false } };
+ next = { ...next, vehicles, alerts: next.alerts.map(alert => alert.id === 'alert:alpha-coverage' ? { ...alert, resolved: true } : alert), groups: { ...next.groups, 'group:alpha': { ...next.groups['group:alpha'], health: 'nominal', attentionCount: 0 } } };
+ }
+ if (payload.receipt.accepted && payload.command.kind === 'acknowledge-alert') next = { ...next, alerts: next.alerts.map(alert => payload.command.targetIds.includes(alert.id) ? { ...alert, acknowledged: true } : alert) };
+ this.state = next;
+ }
+ }
+ private reportError(error: Error): void { this.state = { ...this.state, source: { ...this.state.source, state: 'degraded', message: error.message, invalidEvents: this.state.source.invalidEvents + 1 } }; this.notify(); }
+ private notify(): void { if (this.pendingNotify) return; this.pendingNotify = true; this.schedule(() => { this.pendingNotify = false; for (const listener of this.listeners) listener(); }); }
+ preview(command: CommandEnvelope): CommandPreview {
+ const affected = command.targetIds.flatMap(id => this.state.groups[id] ? Object.values(this.state.vehicles).filter(vehicle => vehicle.groupId === id).map(vehicle => vehicle.id) : this.state.vehicles[id] ? [id] : []);
+ const leases = new Set(affected.map(id => this.state.vehicles[id]?.groupId).filter(Boolean).map(groupId => this.state.leases[`lease:${groupId}`]?.holderId));
+ const authority = leases.size === 1 ? [...leases][0] : undefined;
+ const checks: CommandPreview['checks'] = [
+ { label: 'Energy reserve', status: affected.every(id => (this.state.vehicles[id]?.energy ?? 0) >= 55) ? 'pass' : 'fail', detail: affected.every(id => (this.state.vehicles[id]?.energy ?? 0) >= 55) ? 'All targets retain ≥55% simulated reserve' : 'One or more targets are below simulated reserve' },
+ { label: 'Communication link', status: affected.every(id => (this.state.vehicles[id]?.link ?? 0) >= 50) ? 'pass' : 'fail', detail: 'Minimum required simulated link quality is 50%' },
+ { label: 'Telemetry freshness', status: affected.every(id => (this.state.vehicles[id]?.freshnessMs ?? 0) < 1500) ? 'pass' : 'warning', detail: 'No target may exceed 1.5 s freshness for dispatch' },
+ { label: 'Constraints / route', status: command.targetIds.includes('group:bravo') && command.kind === 'move' ? 'warning' : 'pass', detail: command.targetIds.includes('group:bravo') ? 'R-210B is within the projected route envelope' : 'No simulated conflict detected' },
+ { label: 'Authority', status: authority === command.actorId || ['request-authority', 'transfer-authority', 'acknowledge-alert'].includes(command.kind) ? 'pass' : 'fail', detail: authority === command.actorId ? 'Alex holds the applicable lease' : `Lease holder: ${this.state.operators[authority ?? '']?.name ?? 'none'}` },
+ { label: 'Source connectivity', status: this.state.source.state === 'connected' ? 'pass' : 'fail', detail: this.state.source.message },
+ ];
+ return { command, allowed: checks.every(check => check.status !== 'fail'), affectedVehicles: affected, authorityHolder: authority, summary: `${command.kind.replaceAll('-', ' ')} for ${affected.length} exact target${affected.length === 1 ? '' : 's'}`, location: command.kind === 'move' ? 'Operator-defined map position' : 'Current mission workspace', next: 'Dispatch to the active simulated source, then await acknowledgement', failureEffect: 'Mission truth remains unchanged and a rejection is written to the event journal', checks };
+ }
+ async submit(command: CommandEnvelope): Promise { const preview = this.preview(command); if (!preview.allowed) return { commandId: command.id, accepted: false, state: 'rejected', reason: preview.checks.filter(check => check.status === 'fail').map(check => check.label).join(', '), remediation: 'Resolve failed preview checks and retry', acknowledgedAt: Date.now() }; return this.source.send(command); }
+ pause(): void { this.source.replay?.pause(); }
+ resume(): void { this.source.replay?.resume(); }
+ restart(): void { this.seen.clear(); this.sequences.clear(); this.journal.length = 0; this.source.replay?.restart(); }
+ setSpeed(speed: number): void { this.source.replay?.setSpeed(speed); }
+ step(milliseconds = 100): void { this.source.replay?.step(milliseconds); }
+ advance(steps = 1, milliseconds = 100): void { for (let index = 0; index < steps; index += 1) this.source.replay?.step(milliseconds); }
+}
diff --git a/ground-suite/src/core/data-plane/DataSource.ts b/ground-suite/src/core/data-plane/DataSource.ts
new file mode 100644
index 0000000..9b68479
--- /dev/null
+++ b/ground-suite/src/core/data-plane/DataSource.ts
@@ -0,0 +1,13 @@
+import type { CommandEnvelope, CommandReceipt } from './CommandEnvelope';
+import type { EventEnvelope } from './EventEnvelope';
+
+export interface DataSourceDescriptor { id: string; name: string; kind: 'simulation' | 'kaptan'; simulated: boolean; capabilities: string[] }
+export interface DataPlaneSink { accept(event: EventEnvelope): void; reportError(error: Error): void }
+export interface ReplayControls { pause(): void; resume(): void; restart(): void; setSpeed(speed: number): void; step(milliseconds?: number): void }
+export interface DataSource {
+ readonly descriptor: DataSourceDescriptor;
+ connect(sink: DataPlaneSink): Promise;
+ disconnect(): Promise;
+ send(command: CommandEnvelope): Promise;
+ replay?: ReplayControls;
+}
diff --git a/ground-suite/src/core/data-plane/EventEnvelope.ts b/ground-suite/src/core/data-plane/EventEnvelope.ts
new file mode 100644
index 0000000..6bc3e88
--- /dev/null
+++ b/ground-suite/src/core/data-plane/EventEnvelope.ts
@@ -0,0 +1,18 @@
+import type { EntityKind, MissionState } from '../../domain/model';
+
+export interface EventEnvelope {
+ schemaVersion: 1;
+ eventId: string;
+ streamId: string;
+ sequence: number;
+ type: 'snapshot' | 'telemetry' | 'task-progress' | 'alert' | 'activity' | 'authority' | 'command-state' | 'source-status';
+ entity: { kind: EntityKind; id: string };
+ sourceTimestamp: number;
+ receivedTimestamp: number;
+ correlationId?: string;
+ causationId?: string;
+ quality: { stale: boolean; confidence: number; droppedSinceLast?: number };
+ payload: T;
+}
+
+export type SnapshotEvent = EventEnvelope;
diff --git a/ground-suite/src/core/data-plane/eventValidation.test.ts b/ground-suite/src/core/data-plane/eventValidation.test.ts
new file mode 100644
index 0000000..d9e210e
--- /dev/null
+++ b/ground-suite/src/core/data-plane/eventValidation.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+import { validateEvent } from './eventValidation';
+describe('event validation', () => {
+ it.each([
+ [{}, 'Unsupported event schema'],
+ [{ schemaVersion: 1 }, 'Missing envelope identity'],
+ [{ schemaVersion: 1, eventId: 'a', streamId: 's', entity: {}, sequence: -1 }, 'Invalid monotonic sequence'],
+ ])('rejects malformed envelopes', (value, reason) => expect(validateEvent(value)).toEqual({ valid: false, reason }));
+});
diff --git a/ground-suite/src/core/data-plane/eventValidation.ts b/ground-suite/src/core/data-plane/eventValidation.ts
new file mode 100644
index 0000000..93a74dc
--- /dev/null
+++ b/ground-suite/src/core/data-plane/eventValidation.ts
@@ -0,0 +1,14 @@
+import type { EventEnvelope } from './EventEnvelope';
+
+const eventTypes = new Set(['snapshot', 'telemetry', 'task-progress', 'alert', 'activity', 'authority', 'command-state', 'source-status']);
+export function validateEvent(value: unknown): { valid: true; event: EventEnvelope } | { valid: false; reason: string } {
+ if (!value || typeof value !== 'object') return { valid: false, reason: 'Event is not an object' };
+ const event = value as Partial;
+ if (event.schemaVersion !== 1) return { valid: false, reason: 'Unsupported event schema' };
+ if (!event.eventId || !event.streamId || !event.entity) return { valid: false, reason: 'Missing envelope identity' };
+ if (!Number.isSafeInteger(event.sequence) || (event.sequence ?? -1) < 0) return { valid: false, reason: 'Invalid monotonic sequence' };
+ if (!event.type || !eventTypes.has(event.type)) return { valid: false, reason: 'Unknown event type' };
+ if (!Number.isFinite(event.sourceTimestamp) || !Number.isFinite(event.receivedTimestamp)) return { valid: false, reason: 'Invalid timestamps' };
+ if (!event.quality || typeof event.quality.stale !== 'boolean') return { valid: false, reason: 'Missing quality metadata' };
+ return { valid: true, event: event as EventEnvelope };
+}
diff --git a/ground-suite/src/core/persistence/preferences.test.ts b/ground-suite/src/core/persistence/preferences.test.ts
new file mode 100644
index 0000000..c1c07eb
--- /dev/null
+++ b/ground-suite/src/core/persistence/preferences.test.ts
@@ -0,0 +1,7 @@
+import { describe, expect, it } from 'vitest';
+import { PreferenceStore, defaultPreferences, hotkeyConflicts, migratePreferences } from './preferences';
+describe('operator preference persistence', () => {
+ it('migrates schema 1 and fills new layer defaults', () => { const next = migratePreferences({ schemaVersion: 1, layers: { searchAreas: false }, hotkeys: { alpha: 'a' } }); expect(next.schemaVersion).toBe(2); expect(next.layers.searchAreas).toBe(false); expect(next.layers.constraints).toBe(true); expect(next.hotkeys.alpha).toBe('a'); });
+ it('recovers from corrupt storage', () => { const storage = { getItem: () => '{broken', setItem: () => {}, removeItemCalls: 0, removeItem() { this.removeItemCalls++; } }; const value = new PreferenceStore(storage).load(); expect(value).toEqual(defaultPreferences()); expect(storage.removeItemCalls).toBe(1); });
+ it('detects configurable key conflicts', () => expect(hotkeyConflicts({ alpha: '1', bravo: '1', clear: 'escape' })).toEqual(['alpha', 'bravo']));
+});
diff --git a/ground-suite/src/core/persistence/preferences.ts b/ground-suite/src/core/persistence/preferences.ts
new file mode 100644
index 0000000..850271d
--- /dev/null
+++ b/ground-suite/src/core/persistence/preferences.ts
@@ -0,0 +1,23 @@
+export const LAYERS = ['searchAreas', 'currentRoutes', 'plannedRoutes', 'constraints', 'noFlyZones', 'communicationCoverage', 'pointsOfInterest', 'mapLabels', 'operatorPresence', 'eventLocations'] as const;
+export type LayerId = typeof LAYERS[number];
+export interface Preferences {
+ schemaVersion: 2; layers: Record; railCollapsed: boolean; inspectorCollapsed: boolean; density: 'comfortable' | 'compact'; reducedMotion: boolean;
+ viewport: { x: number; y: number; scale: number }; hotkeys: Record; lastWorkspace: string; draftIntentIds: string[];
+}
+const defaultLayers = Object.fromEntries(LAYERS.map(layer => [layer, true])) as Record;
+export const defaultPreferences = (): Preferences => ({ schemaVersion: 2, layers: { ...defaultLayers }, railCollapsed: false, inspectorCollapsed: false, density: 'comfortable', reducedMotion: globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false, viewport: { x: 0, y: 0, scale: 1 }, hotkeys: { alpha: '1', bravo: '2', charlie: '3', delta: '4', reserve: '5', frame: 'f', layers: 'l', queue: 'q', attention: ' ', clear: 'escape' }, lastWorkspace: 'mission', draftIntentIds: [] });
+export function migratePreferences(value: unknown): Preferences {
+ const defaults = defaultPreferences();
+ if (!value || typeof value !== 'object') return defaults;
+ const input = value as Omit, 'schemaVersion'> & { schemaVersion?: number };
+ if (input.schemaVersion !== 1 && input.schemaVersion !== 2) return defaults;
+ return { ...defaults, ...input, schemaVersion: 2, layers: { ...defaults.layers, ...(input.layers ?? {}) }, viewport: { ...defaults.viewport, ...(input.viewport ?? {}) }, hotkeys: { ...defaults.hotkeys, ...(input.hotkeys ?? {}) }, draftIntentIds: Array.isArray(input.draftIntentIds) ? input.draftIntentIds.filter(id => typeof id === 'string') : [] };
+}
+export class PreferenceStore {
+ static readonly key = 'kaptan.ground-suite.preferences';
+ constructor(private readonly storage: Pick = localStorage) {}
+ load(): Preferences { try { const raw = this.storage.getItem(PreferenceStore.key); return raw ? migratePreferences(JSON.parse(raw)) : defaultPreferences(); } catch { this.storage.removeItem(PreferenceStore.key); return defaultPreferences(); } }
+ save(preferences: Preferences): void { this.storage.setItem(PreferenceStore.key, JSON.stringify(migratePreferences(preferences))); }
+ reset(): Preferences { this.storage.removeItem(PreferenceStore.key); return defaultPreferences(); }
+}
+export function hotkeyConflicts(hotkeys: Record): string[] { const entries = Object.entries(hotkeys).map(([action, key]) => [action, key.trim().toLowerCase()] as const).filter(([, key]) => key); const counts = new Map(); for (const [, key] of entries) counts.set(key, (counts.get(key) ?? 0) + 1); return entries.filter(([, key]) => (counts.get(key) ?? 0) > 1).map(([action]) => action); }
diff --git a/ground-suite/src/design-system/app.css b/ground-suite/src/design-system/app.css
new file mode 100644
index 0000000..d7c393c
--- /dev/null
+++ b/ground-suite/src/design-system/app.css
@@ -0,0 +1,41 @@
+*{box-sizing:border-box}html,body,#root{width:100%;height:100%;margin:0;overflow:hidden}body{font:12px/1.35 var(--font);color:var(--text);background:var(--surface-0)}button,input,select{font:inherit}button{min-height:32px;border:1px solid var(--border);color:var(--text);background:#12202e;cursor:pointer}button:hover{border-color:#4a6582;background:#182a3c}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--cyan);outline-offset:1px}button:disabled{opacity:.38;cursor:not-allowed}svg{width:20px;height:20px}small{color:var(--muted);font-size:9px;letter-spacing:.04em}b,strong{font-weight:650}.skip-link{position:fixed;z-index:9000;top:-80px;left:10px;padding:10px;background:#fff;color:#000}.skip-link:focus{top:10px}kbd{padding:1px 4px;border:1px solid #41546a;border-radius:2px;color:#8fa7bd;background:#0a121b;font:8px var(--mono)}.primary{border-color:#328fde;background:#1875c7;color:white}.primary:hover{background:#2586da}.nominal{color:var(--green)!important}.attention{color:var(--amber)!important}.critical{color:var(--red)!important}.authority-ok{color:var(--green)}.authority-warn{color:var(--amber)}
+
+.ops-shell{width:100vw;height:100vh;display:grid;grid-template-rows:42px minmax(0,1fr);background:var(--surface-0)}.status-strip{position:relative;z-index:100;display:grid;grid-template-columns:minmax(210px,1.2fr) repeat(4,minmax(105px,.62fr)) minmax(105px,.6fr) minmax(175px,.85fr);align-items:stretch;border-bottom:1px solid #314158;background:#07111b;box-shadow:0 3px 18px #0008}.mission-id,.strip-status,.clock,.alert-status{min-width:0;display:flex;align-items:center;gap:8px;padding:0 12px;border:0;border-right:1px solid var(--border-soft);background:transparent;text-align:left}.mission-id>i{width:25px;height:25px;display:grid;place-items:center;border:1px solid var(--cyan);transform:rotate(45deg);color:var(--cyan);font-style:normal;font-weight:800}.mission-id>i::first-letter{transform:rotate(-45deg)}.mission-id span,.strip-status span,.alert-status span{min-width:0;display:grid}.mission-id b,.strip-status b,.alert-status b,.clock b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:10px var(--mono)}.mission-id b{color:var(--cyan);font:700 12px var(--font)}.mission-id small{white-space:nowrap}.strip-status svg,.alert-status svg{width:17px;color:currentColor}.strip-status.nominal b::before,.strip-status.attention b::before{content:"";display:inline-block;width:6px;height:6px;margin-right:6px;border-radius:50%;background:currentColor}.strip-status.simulation{color:var(--violet)}.alert-status{color:var(--amber)}.alert-status:hover{background:#2b220d}.clock{display:grid;place-content:center}.clock i{display:inline-block;width:1px;height:9px;margin:0 8px;background:var(--border)}.sim-watermark{position:absolute;right:8px;bottom:-16px;padding:2px 7px;border:1px solid #745ba0;border-top:0;background:#211834;color:#caa8f5;font:8px var(--mono);letter-spacing:.08em}
+
+.spatial-scene{position:relative;min-height:0;display:grid;grid-template-columns:48px 218px minmax(420px,1fr) 286px;grid-template-rows:minmax(0,1fr) 122px 32px;grid-template-areas:"rail roster map inspector" "rail deck deck inspector" "rail lower lower inspector";overflow:hidden}.tool-rail{z-index:50;grid-area:rail;display:flex;flex-direction:column;justify-content:space-between;border-right:1px solid var(--border);background:#09131e}.tool-rail>div{display:grid}.tool-rail button{position:relative;width:47px;height:43px;display:grid;place-items:center;border:0;border-bottom:1px solid var(--border-soft);background:transparent;color:#91a6ba}.tool-rail button:hover{background:#152333;color:#dbe7f0}.tool-rail button.active{box-shadow:inset 3px 0 var(--cyan);background:#102b3d;color:var(--cyan)}.tool-rail kbd{position:absolute;right:2px;bottom:2px;padding:0 2px;border:0;background:transparent;color:#71879b}.app-tools button:nth-child(6){color:var(--amber)}
+
+.roster-drawer{z-index:30;grid-area:roster;min-height:0;overflow:auto;border-right:1px solid var(--border);background:rgba(10,20,30,.98)}.roster-drawer>header{height:45px;display:flex;align-items:center;justify-content:space-between;padding:0 10px;border-bottom:1px solid var(--border)}.roster-drawer>header span{display:grid}.roster-drawer>header button{width:28px;min-height:28px;border:0;background:transparent}.roster-drawer>header svg{width:15px}.control-groups{padding:6px}.control-groups>button{position:relative;width:100%;height:39px;display:grid;grid-template-columns:25px 1fr;align-items:center;gap:7px;margin-bottom:3px;padding:4px 7px;overflow:hidden;border-color:transparent;background:#0d1824;text-align:left}.control-groups>button.active{border-color:#357da7;background:#112536}.control-groups>button>span{display:grid}.control-groups>button>i{position:absolute;left:0;bottom:0;width:var(--p);height:2px;background:var(--cyan)}.roster-section{padding:6px;border-top:1px solid var(--border)}.roster-section>header,.smart-filters>b{height:24px;display:flex;justify-content:space-between;align-items:center;color:#8da0b3;font-size:9px;letter-spacing:.08em}.roster-group{width:100%;height:47px;display:grid;grid-template-columns:32px 1fr 34px;align-items:center;gap:7px;margin-bottom:4px;padding:4px 7px;border-color:#1f3043;background:#0e1925;text-align:left}.roster-group.selected{border-color:#3c92c2;box-shadow:inset 2px 0 var(--cyan);background:#102333}.roster-group>span{min-width:0;display:grid}.roster-group>span:last-child{text-align:right}.roster-group strong{color:var(--cyan)}.group-glyph{width:29px;height:29px;display:grid!important;place-items:center;border:1px solid currentColor;clip-path:polygon(50% 0,94% 25%,94% 75%,50% 100%,6% 75%,6% 25%);color:var(--cyan);background:#102636;font-weight:800}.group-glyph.attention{color:var(--amber)}.smart-filters{padding:7px;border-top:1px solid var(--border)}.smart-filters>button{width:100%;height:32px;display:flex;align-items:center;justify-content:space-between;margin-bottom:3px;padding:0 8px;background:#0d1925}.smart-filters>button i{min-width:22px;height:18px;display:grid;place-items:center;border-radius:9px;background:#203348;font-style:normal;color:var(--cyan)}.smart-filters>button:last-child{justify-content:flex-start;gap:8px;color:var(--cyan)}.smart-filters svg{width:15px}
+
+.map-stage{position:relative;z-index:10;grid-area:map;min-width:0;min-height:0;overflow:hidden}.operational-map{position:absolute;inset:0;overflow:hidden;touch-action:none;user-select:none;background:#08131c}.map-grid{position:absolute;inset:0;z-index:2;pointer-events:none;background-image:linear-gradient(#3e61720c 1px,transparent 1px),linear-gradient(90deg,#3e61720c 1px,transparent 1px);background-size:42px 42px}.map-world{position:absolute;inset:0;transform-origin:center;will-change:transform}.map-raster{position:absolute;inset:-2%;background-image:linear-gradient(rgba(4,13,21,.17),rgba(4,13,21,.43)),url('/assets/coastal-map.png');background-position:center;background-size:cover;filter:saturate(.62) contrast(1.08) brightness(.72)}.map-geometry{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}.map-label{position:absolute;display:grid;padding:5px 7px;border:1px solid #307ba4;background:#091724dc;color:var(--cyan);font-size:8px;letter-spacing:.06em}.map-label small{color:#9dc6db}.constraint-label{border-color:#a23c45;color:#f08186;background:#260f15dc}.group-map-marker,.vehicle-map-marker{position:absolute;z-index:8;min-height:0;border:0;background:transparent}.group-map-marker{display:flex;align-items:center;gap:6px;padding:0;transform:translate(-50%,-50%);text-align:left}.group-map-marker>i{width:30px;height:30px;display:grid;place-items:center;border:2px solid var(--group);border-radius:50%;box-shadow:0 0 0 4px #07131ccf;color:var(--group);background:#101b26;font-style:normal;font-weight:800}.group-map-marker>span{display:grid;padding:4px 7px;border:1px solid #334a60;border-left:2px solid var(--group);background:#07131de8}.group-map-marker.selected>i{box-shadow:0 0 0 5px color-mix(in srgb,var(--group),transparent 70%),0 0 0 8px #07131ccc}.vehicle-map-marker{width:17px;height:17px;display:grid;place-items:center;padding:0;color:#d8e4ed;filter:drop-shadow(0 1px 2px #000)}.vehicle-map-marker svg{width:16px;height:16px;fill:#0c1a25;stroke:currentColor;stroke-width:2.2}.vehicle-map-marker span{position:absolute;top:17px;left:50%;display:none;padding:1px 3px;transform:translateX(-50%) rotate(calc(-1 * var(--heading,0deg)));background:#08131cdd;white-space:nowrap;color:#fff;font:6px var(--mono)}.vehicle-map-marker:hover span,.vehicle-map-marker.selected span{display:block}.vehicle-map-marker.selected{z-index:15;color:var(--cyan)}.vehicle-map-marker.selected::before{content:"";position:absolute;inset:-5px;border:1px solid var(--cyan);border-radius:50%;animation:pulse 2s infinite}.vehicle-map-marker.attention{color:var(--amber)}.vehicle-map-marker.critical,.vehicle-map-marker.unavailable{color:var(--red)}.vehicle-map-marker.stale{opacity:.55}.draft-shape path,.draft-shape polygon,.draft-shape circle{fill:#a879e21c;stroke:var(--violet);stroke-width:.45;stroke-dasharray:1.2 .7;vector-effect:non-scaling-stroke}.draft-shape.review path,.draft-shape.review polygon,.draft-shape.review circle{stroke:#6ed5f4;fill:#45c8ef17}.draft-shape.invalid path,.draft-shape.invalid polygon{stroke:var(--red)}.draft-handle{fill:#111d29;stroke:#90dff6;stroke-width:.3}.draft-shape text,.formation-slot text{fill:#fff;font-size:1.1px;text-anchor:middle}.formation-slot circle{fill:#342b4b;stroke:var(--violet);stroke-width:.25;stroke-dasharray:.6 .3}.selection-marquee{position:absolute;z-index:40;border:1px solid var(--cyan);background:#45c8ef14;pointer-events:none}.map-mode-hud{position:absolute;z-index:20;top:10px;left:10px;max-width:310px;display:grid;grid-template-columns:auto 1fr;gap:0 8px;padding:6px 9px;border-left:2px solid var(--cyan);background:#07131deb;box-shadow:0 5px 20px #0008}.map-mode-hud>span{grid-row:span 2;color:var(--cyan);font:8px var(--mono);writing-mode:vertical-rl}.map-mode-hud>b{font-size:11px;text-transform:uppercase}.map-mode-hud small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.map-scale{position:absolute;z-index:20;left:12px;bottom:10px;display:grid;color:#c4d0d9;font:8px var(--mono)}.map-scale i{width:72px;height:6px;border:1px solid #bccbd7;border-top:0}.map-zoom-controls{position:absolute;z-index:20;right:10px;top:10px;display:grid;gap:3px}.map-zoom-controls button{width:32px;min-height:30px;padding:0;background:#08141fe8;font-size:18px}.minimap{position:absolute;z-index:20;right:10px;bottom:10px;width:142px;height:86px;overflow:hidden;padding:0;border-color:#4b6075;background:linear-gradient(145deg,#142e3c,#0a1a25);text-align:left}.minimap::before{content:"";position:absolute;left:-12px;top:-16px;width:85px;height:120px;border-radius:48%;background:#25392f;transform:rotate(18deg);opacity:.8}.mini-coast{position:absolute;inset:0;background:repeating-linear-gradient(45deg,transparent 0 8px,#b8d0dc0b 8px 9px)}.mini-task{position:absolute;left:35%;top:25%;width:38%;height:36%;border:1px solid var(--cyan);background:#45c8ef18}.mini-alert{position:absolute;right:11%;top:20%;width:9px;height:9px;border:2px solid var(--red);transform:rotate(45deg)}.mini-viewport{position:absolute;border:1px solid white;background:#fff0}.minimap small{position:absolute;left:5px;bottom:3px;color:#fff;font:6px var(--mono)}.coordinate-readout{position:absolute;z-index:20;right:162px;bottom:10px;padding:3px 6px;background:#07131dd9;color:#aab8c5;font:7px var(--mono)}
+
+.selection-inspector{z-index:25;grid-area:inspector;min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--border);background:#0b1621}.selection-inspector>header{height:47px;display:flex;align-items:center;justify-content:space-between;padding:0 11px;border-bottom:1px solid var(--border)}.selection-inspector>header>span{display:grid}.selection-inspector>header>span:last-child{display:block;padding:3px 5px;border:1px solid currentColor;font:8px var(--mono)}.inspector-empty{display:grid;place-items:center;gap:15px;height:70%;padding:30px;text-align:center;color:var(--muted)}.inspector-empty svg{width:38px;height:38px}.truth-banner{display:grid;grid-template-columns:36px 1fr auto;align-items:center;gap:8px;padding:10px;border-bottom:1px solid var(--border)}.truth-banner svg{width:31px;height:31px;color:var(--cyan)}.truth-banner span{display:grid}.truth-banner strong{padding:3px 5px;border:1px solid var(--green);color:var(--green);font-size:8px;text-transform:uppercase}.position-readout{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--border)}.position-readout>div{display:grid;gap:2px;padding:9px;border-right:1px solid var(--border);font:9px var(--mono)}.telemetry-chart{padding:8px 10px;border-bottom:1px solid var(--border)}.telemetry-chart>header,.histogram>header,.availability>header,.exceptions>header{display:flex;align-items:center;justify-content:space-between}.telemetry-chart>header b{color:#d7e4ee;font:10px var(--mono)}.telemetry-chart svg{width:100%;height:55px;margin-top:4px;overflow:visible;background:linear-gradient(transparent 49%,#30405255 50%,transparent 51%)}.telemetry-chart polyline{fill:none;stroke-width:1.3;vector-effect:non-scaling-stroke}.telemetry-chart .threshold{stroke:var(--amber);stroke-width:.5;stroke-dasharray:2 2;vector-effect:non-scaling-stroke}.telemetry-chart .gap{stroke:#ef5b6177;stroke-width:2;stroke-dasharray:1 1}.telemetry-chart>footer,.histogram>footer{display:flex;justify-content:space-between;color:#71869a;font:7px var(--mono)}.subsystem-grid,.distribution-summary{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--border)}.subsystem-grid>div,.distribution-summary>div{display:grid;gap:2px;padding:8px;border-right:1px solid var(--border);border-bottom:1px solid var(--border-soft)}.subsystem-grid b,.distribution-summary b{color:var(--text);font:9px var(--mono)}.configure-button{width:calc(100% - 20px);margin:10px;display:flex;align-items:center;justify-content:center;gap:7px;color:var(--cyan)}.configure-button svg{width:15px}.distribution-summary{grid-template-columns:repeat(3,1fr)}.distribution-summary>div{text-align:center}.distribution-summary b{font-size:15px;color:var(--green)}.histogram{padding:10px;border-bottom:1px solid var(--border)}.histogram>div{height:51px;display:flex;align-items:flex-end;gap:5px;padding-top:14px}.histogram>div i{position:relative;flex:1;min-height:2px;background:linear-gradient(#45c8ef,#2472a8)}.histogram em{position:absolute;top:-13px;left:50%;transform:translateX(-50%);color:#bcd0dc;font:7px var(--mono)}.availability{padding:10px;border-bottom:1px solid var(--border)}.availability>div{height:6px;display:flex;margin-top:7px}.availability i.nominal{background:var(--green)}.availability i.attention{background:var(--amber)}.exceptions{padding:10px}.exceptions>button{width:100%;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:6px;padding:6px;border:0;border-top:1px solid var(--border);background:transparent;text-align:left}.exceptions svg{width:15px;color:var(--amber)}.exceptions span{display:grid}.exceptions em{color:var(--cyan);font:7px var(--mono)}
+
+.selection-deck{z-index:35;grid-area:deck;min-width:0;display:grid;grid-template-columns:minmax(0,1.35fr) minmax(430px,1fr);border-top:1px solid #34475d;background:#0d1823;box-shadow:0 -8px 24px #0005}.unit-card-strip{min-width:0;display:flex;gap:4px;padding:7px;overflow-x:auto;border-right:1px solid var(--border)}.unit-card{position:relative;min-width:112px;height:106px;display:grid;grid-template-columns:25px 1fr;grid-template-rows:31px 1fr;gap:3px;padding:8px;background:#111f2c;text-align:left}.unit-card.focused{border-color:var(--cyan);box-shadow:inset 0 -2px var(--cyan)}.unit-card.attention{border-color:#7a5f24}.unit-card>svg{width:23px;color:#b9c9d4}.unit-card.focused>svg{color:var(--cyan)}.unit-card>span{min-width:0;display:grid}.unit-card>span b,.unit-card>span small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.unit-card>em{position:absolute;right:4px;top:4px;width:16px;height:16px;display:grid;place-items:center;border-radius:8px;background:#32274c;color:#cfaff5;font:7px var(--mono)}.card-meters{grid-column:1/-1;display:grid;align-content:end;gap:4px}.card-meters>i{height:4px;background:linear-gradient(90deg,var(--green) var(--v),#273547 var(--v))}.card-meters>i:nth-child(2){background:linear-gradient(90deg,var(--cyan) var(--v),#273547 var(--v))}.no-selection{display:flex;align-items:center;gap:10px;padding:15px;color:var(--muted)}.ability-bar{min-width:0;display:grid;grid-template-rows:38px 1fr}.ability-bar>header{display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-bottom:1px solid var(--border)}.ability-bar>header>span:first-child{display:flex;gap:8px;align-items:baseline}.ability-bar>header>span:last-child{padding:2px 5px;border:1px solid currentColor;font:7px var(--mono)}.ability-bar nav{display:grid;grid-template-columns:repeat(6,1fr);gap:4px;padding:5px}.ability-bar button{display:grid;place-items:center;align-content:center;gap:3px;padding:2px;background:#142331;font-size:8px}.ability-bar button.active{border-color:var(--cyan);color:var(--cyan);background:#102b3b}.ability-bar svg{width:18px;height:18px}.lower-drawer{z-index:55;grid-area:lower;min-width:0;background:#0b1621;border-top:1px solid var(--border)}.lower-drawer>header{height:32px;display:flex;align-items:center;justify-content:space-between}.lower-drawer>header nav{height:100%;display:flex}.lower-drawer>header nav button{min-height:31px;border:0;border-right:1px solid var(--border);background:transparent;font:8px var(--mono)}.lower-drawer>header nav button.active{color:var(--cyan);box-shadow:inset 0 -2px var(--cyan)}.lower-drawer>header nav i{padding:1px 4px;border-radius:7px;background:#203347;font-style:normal}.lower-drawer>header>span{color:#6f8397;font:7px var(--mono)}.lower-drawer>header>button{width:34px;min-height:30px;border:0;background:transparent}.lower-drawer>header svg{width:14px}.lower-drawer.open{position:absolute;z-index:80;left:48px;right:286px;bottom:0;height:230px;box-shadow:0 -18px 45px #000a}.lower-drawer.open>header>button svg{transform:rotate(180deg)}.queue-drawer,.event-drawer{display:flex;gap:6px;padding:8px;overflow-x:auto}.queue-drawer article{min-width:195px;height:172px;display:grid;grid-template-columns:26px 1fr;align-content:start;gap:9px;padding:10px;border:1px solid var(--border);background:#111f2c}.queue-drawer article.executing{border-color:var(--blue)}.queue-drawer article>i{width:25px;height:25px;display:grid;place-items:center;border:1px solid #496078;font-style:normal}.queue-drawer article>span{display:grid}.queue-drawer article>em,.queue-drawer article>strong{grid-column:1/-1;padding:5px;border-top:1px solid var(--border);font-style:normal;text-transform:uppercase}.queue-drawer article>em{color:var(--violet)}.queue-drawer article>strong{color:var(--green)}.event-drawer{display:grid;gap:0}.event-drawer article{height:41px;display:grid;grid-template-columns:70px 1fr 75px 60px;align-items:center;gap:8px;padding:4px 9px;border-bottom:1px solid var(--border);border-left:2px solid var(--blue)}.event-drawer article.attention{border-left-color:var(--amber)}.event-drawer article.critical{border-left-color:var(--red)}.event-drawer time{font:8px var(--mono);color:#8ea2b6}.event-drawer span{display:grid}.event-drawer em{color:var(--muted);font:8px var(--mono)}.event-drawer button{min-height:25px;font-size:8px}.telemetry-drawer{height:198px;display:grid;grid-template-columns:1fr 1fr .9fr}.telemetry-drawer>.telemetry-chart{border-right:1px solid var(--border)}.mode-timeline{padding:15px}.mode-timeline>div,.lifecycle,.recovery-chain>div{display:flex;align-items:center;margin-top:18px}.mode-timeline span,.lifecycle span,.recovery-chain span{padding:4px;border:1px solid #405269;color:#8396a8;font:7px var(--mono)}.mode-timeline span.done,.lifecycle span.done{border-color:var(--green);color:var(--green)}.mode-timeline span.current,.lifecycle span.current{border-color:var(--cyan);color:var(--cyan)}.mode-timeline i,.lifecycle i,.recovery-chain i{flex:1;height:1px;background:#405269}.mode-timeline p{margin-top:18px;color:var(--muted);font:8px var(--mono)}
+
+.context-palette,.compact-drawer,.right-workbench{position:absolute;z-index:70;border:1px solid #40556c;background:#0d1925f5;box-shadow:0 18px 55px #000b}.context-palette{right:12px;bottom:12px;width:210px;padding:6px}.context-palette header,.compact-drawer>header,.right-workbench>header,.deployment-deck>header{display:flex;align-items:center;justify-content:space-between;padding:7px;border-bottom:1px solid var(--border)}.context-palette header button,.compact-drawer>header button,.right-workbench>header button{width:27px;min-height:27px;border:0;background:transparent}.context-palette header svg,.compact-drawer>header svg,.right-workbench>header svg{width:14px}.context-palette>button{width:100%;display:grid;grid-template-columns:24px 1fr;align-items:center;gap:6px;padding:6px;border:0;border-bottom:1px solid var(--border);background:transparent;text-align:left}.context-palette>button span{display:grid}.compact-drawer{top:10px;left:10px;width:300px;max-height:calc(100% - 20px);overflow:auto}.compact-drawer>header span,.right-workbench>header span,.deployment-deck>header span{display:grid}.layer-list,.settings-list{padding:7px}.layer-list label{height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;border-bottom:1px solid var(--border-soft)}.layer-list label small{font-size:7px}.settings-list>label{min-height:42px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border)}.settings-list select,.parameter-view input{min-height:30px;padding:4px;border:1px solid var(--border);background:#07121b;color:var(--text)}.keymap{display:grid;gap:8px;padding:13px 0}.keymap span{display:flex;justify-content:space-between}.right-workbench{top:0;right:0;width:min(520px,78%);height:100%;overflow:auto;border-top:0;border-bottom:0}.right-workbench>header{height:48px;padding:0 12px}.recovery-workbench>article{display:grid;grid-template-columns:27px 1fr auto;align-items:center;gap:8px;margin:8px;padding:10px;border:1px solid #6d5525;background:#2a210d}.recovery-workbench>article svg{color:var(--amber)}.recovery-workbench>article span{display:grid}.recovery-workbench>article em{font:7px var(--mono);color:var(--amber)}.recovery-chain{margin:14px;padding:14px;border:1px solid var(--border)}.recovery-chain>div{margin:12px 0}.recovery-chain p{color:var(--muted)}.commissioning-workbench>nav{display:grid;grid-template-columns:repeat(4,1fr);border-bottom:1px solid var(--border)}.commissioning-workbench>nav button{border:0;border-right:1px solid var(--border);background:transparent;font-size:8px}.commissioning-workbench>nav button.active{color:var(--cyan);box-shadow:inset 0 -2px var(--cyan)}.lifecycle{padding:10px;margin:0}.readiness-view{display:grid;grid-template-columns:150px 1fr;gap:10px;padding:14px}.readiness-ring{position:relative;width:140px;height:140px}.readiness-ring svg{width:100%;height:100%;transform:rotate(-90deg)}.readiness-ring circle{fill:none;stroke:#26384a;stroke-width:7}.readiness-ring .progress-ring{stroke:var(--cyan);stroke-dasharray:190 264;stroke-linecap:round}.readiness-ring>span{position:absolute;inset:0;display:grid;place-content:center;text-align:center}.readiness-ring b{font:25px var(--mono)}.dependency-graph{display:grid;grid-template-columns:1fr 1fr;gap:5px}.dependency-graph button{display:flex;align-items:center;gap:7px;text-align:left}.dependency-graph button>i{width:9px;height:9px;border-radius:50%;background:var(--green)}.dependency-graph button>span{display:grid}.dependency-graph button.blocker>i{background:var(--red);clip-path:polygon(50% 0,100% 100%,0 100%);border-radius:0}.dependency-graph button.warning>i{background:var(--amber)}.blocker-box{grid-column:1/-1;display:flex;gap:8px;padding:10px;border:1px solid #8a3139;background:#2a1116;color:var(--red)}.blocker-box span{display:grid}.orientation-model{position:relative;height:145px;display:grid;place-items:center;margin:14px;border:1px solid var(--border);background:radial-gradient(circle,#1a3040,#0c1721 65%)}.orientation-model svg{width:110px;height:110px;color:var(--cyan);transform:rotate(30deg)}.orientation-model .axis{position:absolute;font:9px var(--mono)}.axis.x{right:15%;top:50%;color:var(--red)}.axis.y{left:48%;top:8%;color:var(--green)}.axis.z{left:28%;bottom:15%;color:var(--blue)}.sensor-list{display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:10px}.sensor-list span{padding:6px;border:1px solid currentColor;background:#112018}.sensor-list span.attention{background:#2b210d}.hold-control{width:calc(100% - 20px);height:42px;margin:10px;border-color:var(--amber);color:var(--amber)}.radio-view{padding:14px}.stick-pair{display:flex;justify-content:center;gap:18px;margin-bottom:15px}.stick-pair>i{position:relative;width:90px;height:90px;border:1px solid #4e6277;border-radius:50%;background:repeating-radial-gradient(circle,transparent 0 15px,#34475b55 16px)}.stick-pair b{position:absolute;left:44%;top:34%;width:10px;height:10px;border-radius:50%;background:var(--cyan);box-shadow:0 0 0 6px #45c8ef25}.radio-view label{height:28px;display:grid;grid-template-columns:38px 1fr 60px;align-items:center;gap:6px}.radio-view label>i{height:5px;background:#26384a}.radio-view label>i b{display:block;height:100%;background:var(--cyan)}.radio-view em{text-align:right;color:#9aadbe;font:8px var(--mono)}.parameter-view{padding:10px}.parameter-view>header{display:flex;gap:6px;margin-bottom:8px}.parameter-view input{flex:1}.parameter-view>div{height:50px;display:grid;grid-template-columns:1fr 55px 55px 35px;align-items:center;gap:5px;padding:5px;border-bottom:1px solid var(--border)}.parameter-view>div.safety{box-shadow:inset 2px 0 var(--amber);background:#281f0d}.parameter-view>div>span{display:grid}.parameter-view>div>em,.parameter-view>div>strong{padding:4px;background:#09131d;text-align:right;font:9px var(--mono)}.parameter-view>div>strong{color:var(--cyan)}.parameter-view>footer{display:flex;align-items:center;justify-content:space-between;padding-top:10px}
+
+.deployment-active{display:block}.deployment-deck{height:100%;display:grid;grid-template-columns:minmax(250px,.75fr) minmax(390px,1.5fr) minmax(320px,1fr);grid-template-areas:"head tiles controls"}.deployment-deck>header{grid-area:head;display:grid;align-content:center;padding:8px 12px;border-right:1px solid var(--border)}.deployment-deck>header nav{display:flex;margin-top:7px}.deployment-deck>header nav button{min-height:25px;padding:0 8px;border:0;border-bottom:1px solid var(--border);background:transparent;font-size:8px}.deployment-deck>header nav button.active{color:var(--cyan);border-color:var(--cyan)}.archetype-tiles{grid-area:tiles;display:flex;gap:4px;padding:6px;overflow-x:auto}.archetype-tiles>button{min-width:125px;display:flex;align-items:center;gap:6px;padding:7px;text-align:left}.archetype-tiles>button.active{border-color:var(--cyan);background:#102b3b}.archetype-tiles svg{width:29px;color:var(--cyan)}.archetype-tiles span{display:grid}.placement-controls{grid-area:controls;display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:7px;border-left:1px solid var(--border)}.placement-controls label{display:grid;grid-template-columns:1fr 80px;align-items:center;font-size:8px}.placement-controls select{height:27px;border:1px solid var(--border);background:#09131d;color:var(--text)}.placement-controls input{width:80px}.placement-controls button{grid-column:1/-1}.discover-row{grid-column:2/-1;display:flex;gap:5px;padding:7px}.discover-row>button{flex:1;display:grid;grid-template-columns:27px 1fr;align-items:center;gap:6px;text-align:left}.discover-row span{display:grid}.discover-row em{grid-column:1/-1;font-size:8px}.import-drop{grid-column:2/-1;display:flex;align-items:center;justify-content:center;gap:12px}.import-drop>span{display:grid}.import-drop>svg{width:32px;color:var(--cyan)}
+
+.review-strip{position:absolute;z-index:110;left:48px;right:286px;bottom:32px;min-height:104px;display:grid;grid-template-columns:220px 1fr 245px;align-items:stretch;border:1px solid #5291a9;border-left:4px solid var(--cyan);background:#0d1b27f8;box-shadow:0 -15px 50px #000b}.review-strip>header{display:flex;align-items:center;gap:9px;padding:12px;border-right:1px solid var(--border)}.review-strip>header svg{width:28px;color:var(--cyan)}.review-strip>header span{display:grid}.review-metrics{display:grid;grid-template-columns:repeat(5,1fr)}.review-metrics>div{display:grid;place-content:center;gap:4px;padding:8px;border-right:1px solid var(--border)}.review-metrics b{color:var(--text);font:9px var(--mono)}.review-strip>footer{display:flex;align-items:center;justify-content:flex-end;gap:5px;padding:10px}.review-strip>footer button{padding:0 10px;font-size:9px}.toast{position:fixed;z-index:1500;right:18px;bottom:18px;max-width:500px;display:flex;align-items:center;gap:8px;padding:11px;border:1px solid #3f8497;background:#122631;box-shadow:0 18px 50px #000c}.toast>svg{color:var(--green)}.toast>button{width:28px;min-height:27px;margin-left:auto;border:0;background:transparent}.toast>button svg{width:14px}
+
+.density-compact .unit-card{height:94px}.density-compact .control-groups>button{height:35px}.reduced-motion *,.reduced-motion *::before,.reduced-motion *::after{animation:none!important;transition:none!important}@keyframes pulse{50%{box-shadow:0 0 0 4px #45c8ef3d}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}
+@media(max-width:1450px){.status-strip{grid-template-columns:minmax(195px,1.1fr) repeat(4,minmax(96px,.55fr)) minmax(100px,.55fr) minmax(155px,.78fr)}.mission-id,.strip-status,.clock,.alert-status{padding-inline:8px}.spatial-scene{grid-template-columns:46px 202px minmax(410px,1fr) 270px}.lower-drawer.open,.review-strip{left:46px;right:270px}.tool-rail button{width:45px}.roster-drawer{font-size:11px}.selection-deck{grid-template-columns:minmax(0,1fr) minmax(400px,.9fr)}.unit-card{min-width:105px}.ability-bar button{font-size:7px}.telemetry-chart svg{height:48px}}
+@media(min-width:2000px){.spatial-scene{grid-template-columns:50px 238px minmax(700px,1fr) 330px;grid-template-rows:minmax(0,1fr) 132px 34px}.lower-drawer.open,.review-strip{left:50px;right:330px}.selection-inspector{font-size:13px}.map-mode-hud{transform:scale(1.08);transform-origin:top left}.unit-card{min-width:132px}}
+@media(max-width:1100px){.status-strip{grid-template-columns:minmax(190px,1.2fr) repeat(3,minmax(90px,.7fr)) minmax(105px,.7fr)}.strip-status:nth-of-type(4),.clock{display:none}.spatial-scene{grid-template-columns:46px 182px minmax(420px,1fr);grid-template-areas:"rail roster map" "rail deck deck" "rail lower lower"}.selection-inspector{display:none}.lower-drawer.open,.review-strip{right:0}.selection-deck{grid-template-columns:minmax(0,1fr) minmax(360px,.9fr)}}
+
+.right-workbench{width:min(440px,65%);background:#0d1925}.mode-palette{position:absolute;z-index:72;left:10px;top:10px;width:370px;max-height:calc(100% - 20px);overflow:auto;border:1px solid #40556c;background:#0d1925;box-shadow:0 18px 55px #000b}.mode-palette>header{height:44px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border-bottom:1px solid var(--border)}.mode-palette>header span{display:grid}.mode-palette>header button{width:28px;min-height:27px;border:0;background:transparent}.mode-palette>header svg{width:14px}.mode-palette>section{padding:7px;border-bottom:1px solid var(--border)}.mode-palette>section>div{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:5px}.mode-palette>section button{min-height:34px;display:flex;align-items:center;gap:5px;padding:5px;text-align:left;font-size:9px}.mode-palette>section button.active{border-color:var(--cyan);color:var(--cyan);background:#102b3b}.mode-palette>section svg{width:14px;height:14px}
+
+/* Reference-aligned operations composition: one roster, one tool rail, readable hierarchy. */
+body{font-size:13px;letter-spacing:.005em}small{font-size:10px;line-height:1.25}button{min-height:38px}.ops-shell{grid-template-rows:48px minmax(0,1fr)}.status-strip{display:flex;align-items:stretch;background:#07111b;border-bottom-color:#30465d}.mission-id{flex:0 0 230px;padding:0 15px}.mission-id>i{width:28px;height:28px}.mission-id b{font-size:13px}.strip-status{flex:0 0 120px;padding:0 11px}.strip-status b,.alert-status b,.clock b{font-size:10px}.strip-status small,.alert-status small,.clock small{font-size:9px}.alert-status{flex:0 0 135px;margin-left:auto;padding:0 12px;border-left:1px solid var(--border-soft)}.clock{flex:0 0 185px;padding:0 14px}.sim-watermark{right:10px;bottom:-18px;padding:3px 9px;font-size:8px}
+.spatial-scene{grid-template-columns:52px 230px minmax(430px,1fr) 300px;grid-template-rows:minmax(0,1fr) 134px 36px}.tool-rail{background:#09131e}.tool-rail button{width:51px;height:48px;color:#9cafbf}.tool-rail button.active{box-shadow:inset 3px 0 var(--cyan),inset 0 0 24px #2d8ff018;background:#102739}.tool-rail button svg{width:21px;height:21px}.tool-rail button::after{content:attr(data-tooltip);position:absolute;z-index:100;left:58px;top:50%;padding:5px 8px;transform:translateY(-50%) translateX(-4px);border:1px solid #3d536a;background:#09131ef5;color:var(--text);font-size:10px;white-space:nowrap;opacity:0;pointer-events:none;transition:.12s ease}.tool-rail button:hover::after,.tool-rail button:focus-visible::after{opacity:1;transform:translateY(-50%) translateX(0)}.tool-rail kbd{font-size:8px}.app-tools{border-top:1px solid #2d4257}
+.roster-drawer{background:#0a1520}.roster-drawer>header{height:58px;padding:0 13px;background:#0c1925}.roster-drawer>header>span:first-child{display:grid;gap:2px}.roster-drawer>header>span:first-child b{font-size:13px;letter-spacing:.04em}.local-group-chip{display:grid;padding:5px 7px;border:1px solid #3c607b;background:#102536;text-align:center}.local-group-chip small{font-size:8px;color:var(--cyan)}.local-group-chip b{font:9px var(--mono)}.force-roster{padding:8px}.force-row{position:relative;width:100%;height:66px;display:grid;grid-template-columns:26px 1fr 48px;grid-template-rows:1fr 4px;align-items:center;gap:0 8px;margin-bottom:6px;padding:7px 8px 4px;border-color:#263b50;background:#0e1b28;text-align:left;overflow:hidden}.force-row:hover{background:#132435}.force-row.selected{border-color:#368db9;box-shadow:inset 3px 0 var(--cyan);background:#11283a}.force-row.attention{border-color:#705a2b}.force-row>kbd{width:24px;padding:3px 0;text-align:center;color:#c8d5df}.force-identity{display:flex;align-items:center;gap:8px;min-width:0}.force-identity>i{width:28px;height:28px;display:grid;place-items:center;clip-path:polygon(50% 0,94% 25%,94% 75%,50% 100%,6% 75%,6% 25%);background:#15344a;color:var(--cyan);font-style:normal;font-weight:750}.force-row.attention .force-identity>i{background:#3a2e13;color:var(--amber)}.force-identity>span,.force-state{min-width:0;display:grid}.force-identity b{font-size:12px;letter-spacing:.025em}.force-identity small,.force-state small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.force-state{text-align:right}.force-state>b{color:var(--cyan);font:700 14px var(--mono)}.force-progress{grid-column:1/-1;height:3px;background:#223448}.force-progress>i{display:block;height:100%;background:var(--cyan)}.force-row.attention .force-progress>i{background:var(--amber)}.smart-filters{padding:10px;border-top:1px solid var(--border)}.smart-filters>header{display:flex;align-items:center;justify-content:space-between;height:28px}.smart-filters>header b{font-size:10px;letter-spacing:.08em}.smart-filters>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.smart-filters>div>button{height:46px;display:grid;grid-template-columns:19px 1fr auto;align-items:center;gap:5px;padding:6px;border-color:#2a4054;background:#0d1a26;text-align:left}.smart-filters>div>button svg{width:16px;color:#aebdca}.smart-filters>div>button>i{width:20px;height:20px;display:grid;place-items:center;border-radius:10px;background:#20384c;color:var(--cyan);font-style:normal;font-size:9px}.smart-filters .deploy-entry{height:52px;display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:8px;margin-top:8px;padding:7px 9px;border-color:#337da3;background:#102536;color:var(--cyan)}.deploy-entry svg{width:20px}.deploy-entry>span{display:grid;text-align:left}.deploy-entry>span b{color:var(--text)}.roster-drawer>footer{display:flex;justify-content:space-between;padding:10px 9px;border-top:1px solid var(--border);color:var(--muted);font-size:9px}.roster-drawer>footer span{display:grid;gap:3px;text-align:center}
+.map-mode-hud{top:12px;left:12px;min-width:270px;padding:8px 10px;border-left-width:3px;background:#0a1825f2}.map-mode-hud>b{font-size:12px}.map-mode-hud small{font-size:9px}.group-map-marker>i{width:34px;height:34px}.group-map-marker>span{padding:6px 9px}.group-map-marker>span b{font-size:11px}.group-map-marker>span small{font-size:9px}.map-label{padding:6px 8px;font-size:9px}.map-scale,.coordinate-readout{font-size:8px}.minimap{width:154px;height:94px}.map-zoom-controls button{width:36px;min-height:34px}
+.selection-inspector{background:#0a1621}.selection-inspector>header{height:56px;padding:0 13px}.selection-inspector>header b{font-size:13px}.selection-inspector>header>span:last-child{padding:4px 6px;font-size:9px}.distribution-summary>div{padding:11px 8px}.distribution-summary b{font-size:18px}.histogram{padding:12px}.histogram>div{height:62px}.histogram em{font-size:8px}.availability,.exceptions{padding:12px}.exceptions>button{min-height:48px;padding:7px 5px}.exceptions em{font-size:8px}.truth-banner{padding:12px}.position-readout>div{padding:11px;font-size:10px}.telemetry-chart{padding:10px 12px}.telemetry-chart>header b{font-size:11px}.telemetry-chart>footer,.histogram>footer{font-size:8px}.subsystem-grid>div{padding:10px}.subsystem-grid b{font-size:10px}.configure-button{min-height:42px}
+.selection-deck{grid-template-columns:minmax(0,1.25fr) minmax(460px,1fr);background:#0c1823}.unit-card-strip{gap:6px;padding:8px}.unit-card{min-width:124px;height:116px;padding:9px;background:#101e2b}.unit-card>svg{width:25px}.unit-card>span b{font-size:11px}.unit-card>em{width:18px;height:18px;border-radius:9px;font-size:8px}.card-meters small{font-size:9px}.ability-bar{grid-template-rows:42px 1fr}.ability-bar>header{padding:0 11px}.ability-bar>header>span:last-child{padding:3px 6px;font-size:8px}.ability-bar nav{gap:6px;padding:7px}.ability-bar button{gap:5px;font-size:9px;background:#132434}.ability-bar svg{width:20px;height:20px}.lower-drawer>header{height:36px}.lower-drawer>header nav button{min-height:35px;padding:0 12px;font-size:9px}.lower-drawer>header>span{font-size:8px}.lower-drawer.open{left:52px;right:300px;height:250px}.review-strip{left:52px;right:300px;bottom:36px}
+.mode-palette{width:410px}.mode-palette>header{height:50px;padding:8px 11px}.mode-palette>section{padding:9px}.mode-palette>section>div{gap:6px}.mode-palette>section button{min-height:40px;padding:7px;font-size:10px}.compact-drawer{width:330px}.right-workbench{width:min(470px,62%)}
+@media(max-width:1450px){.status-strip{display:flex}.spatial-scene{grid-template-columns:50px 220px minmax(410px,1fr) 286px;grid-template-rows:minmax(0,1fr) 130px 36px}.tool-rail button{width:49px;height:46px}.mission-id{flex-basis:205px}.strip-status{flex-basis:108px}.alert-status{flex-basis:122px}.clock{flex-basis:170px}.selection-deck{grid-template-columns:minmax(0,1fr) minmax(430px,.95fr)}.unit-card{min-width:116px;height:111px}.lower-drawer.open,.review-strip{left:50px;right:286px}.review-strip{bottom:36px}.force-row{height:63px}.roster-drawer>footer{font-size:8px}}
+@media(min-width:2000px){.spatial-scene{grid-template-columns:54px 250px minmax(700px,1fr) 340px;grid-template-rows:minmax(0,1fr) 144px 38px}.tool-rail button{width:53px}.lower-drawer.open,.review-strip{left:54px;right:340px}.selection-inspector{font-size:13px}.unit-card{min-width:138px;height:124px}.status-strip{max-width:none}}
+
+.force-row.health-nominal,.force-row.health-attention,.force-row.health-critical,.force-row.health-unavailable{color:var(--text)!important}.force-row.health-attention{border-color:#705a2b}.force-row.health-attention .force-identity>i{background:#3a2e13;color:var(--amber)}.force-row.health-attention .force-progress>i{background:var(--amber)}.force-row.selected .force-identity b{color:var(--cyan)}.app-tools button{color:#9cafbf!important}.app-tools button.active{color:var(--cyan)!important}
+.draft-shape path,.draft-shape polygon,.draft-shape circle{stroke-width:.62}.draft-shape.review path,.draft-shape.review polygon,.draft-shape.review circle{stroke-width:.82;filter:drop-shadow(0 0 1px #43c4eb)}.draft-handle{r:1}.draft-shape text{font-size:1.35px;font-weight:700}.review-strip{background:#0b1925fc}.review-strip>header small{color:var(--cyan)}.review-strip>header b{font-size:11px}.review-metrics>div{padding-inline:10px}.review-metrics b{font-size:10px}
+@media(max-width:1450px){.roster-drawer>header{height:52px}.force-roster{padding-block:6px}.force-row{height:55px;margin-bottom:4px;padding-top:5px}.force-identity>i{width:25px;height:25px}.force-identity small,.force-state small{font-size:9px}.smart-filters{padding:7px 9px}.smart-filters>header{height:24px}.smart-filters>div>button{height:40px;grid-template-columns:15px 1fr 18px;padding:4px}.smart-filters>div>button svg{width:14px}.smart-filters .deploy-entry{height:46px;margin-top:6px}.roster-drawer>footer{padding:7px 8px}}
diff --git a/ground-suite/src/design-system/tokens.css b/ground-suite/src/design-system/tokens.css
new file mode 100644
index 0000000..11e29bb
--- /dev/null
+++ b/ground-suite/src/design-system/tokens.css
@@ -0,0 +1,8 @@
+:root {
+ color-scheme: dark;
+ --surface-0: #071019; --surface-1: #0b1621; --surface-2: #101d2a; --surface-3: #172636;
+ --border: #2b4056; --border-soft: #1d3042; --text: #e8eff5; --muted: #95a8b9;
+ --blue: #2d8ff0; --cyan: #43c4eb; --green: #5dcc7d; --amber: #e8b84b; --red: #ef5a62; --violet: #a77adf;
+ --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+}
diff --git a/ground-suite/src/domain/model.ts b/ground-suite/src/domain/model.ts
new file mode 100644
index 0000000..79b0a5f
--- /dev/null
+++ b/ground-suite/src/domain/model.ts
@@ -0,0 +1,51 @@
+export type OpaqueId = string & { readonly __type: T };
+export type EntityKind = 'mission' | 'task' | 'group' | 'vehicle' | 'operator' | 'constraint' | 'alert' | 'position' | 'source';
+export type Health = 'nominal' | 'attention' | 'critical' | 'unavailable';
+export type CommandState = 'draft' | 'validating' | 'validated' | 'queued' | 'dispatched' | 'acknowledged' | 'executing' | 'completed' | 'rejected' | 'cancelled' | 'failed' | 'expired';
+export type Archetype = 'quadcopter' | 'fixed-wing' | 'tiltrotor-vtol' | 'helicopter' | 'rover' | 'surface-vessel';
+
+export interface Position { x: number; y: number; lat: number; lon: number }
+export interface NavigationTarget { kind: EntityKind; entityId?: string; position?: Position; zoom: 1 | 2 | 3 | 4; inspector: 'summary' | 'task' | 'vehicle' | 'engineering' | 'event' | 'authority' }
+export interface Mission { id: string; name: string; code: string; revision: number; elapsedSeconds: number }
+export interface Task { id: string; name: string; kind: string; progress: number; required: number; assignedGroupId?: string; state: 'planned' | 'active' | 'paused' | 'completed' | 'cancelled'; etaMinutes: number }
+export interface Group { id: string; name: string; count: number; taskId?: string; operatorId: string; progress: number; health: Health; currentIntent: string; nextIntent: string; formation: string; attentionCount: number }
+export interface Vehicle {
+ id: string; name: string; groupId: string; archetype: Archetype; capabilities: string[]; payload: string;
+ position: Position; heading: number; speed: number; altitude: number; energy: number; link: number;
+ health: Health; freshnessMs: number; available: boolean; binding: 'simulated-bound' | 'unbound'; taskId?: string;
+}
+export interface Operator { id: string; name: string; role: string; presence: 'online' | 'idle' | 'offline'; color: string; cursor?: Position; selectedEntityId?: string }
+export interface AuthorityLease { id: string; entityId: string; holderId: string; state: 'held' | 'requested' | 'conflict' | 'unavailable'; requestedBy?: string; expiresAt: number; observers: string[] }
+export interface Intent { id: string; title: string; detail: string; ownerId: string; groupId: string; taskId?: string; durationMinutes: number; state: CommandState; dependencies: string[]; resources: string[]; validation: 'valid' | 'warning' | 'invalid' }
+export interface Alert { id: string; severity: 'info' | 'attention' | 'critical'; title: string; detail: string; entityId: string; acknowledged: boolean; resolved: boolean; target: NavigationTarget }
+export interface ActivityEvent { id: string; timestamp: number; actor: string; category: 'system' | 'command' | 'operator' | 'task' | 'group' | 'vehicle' | 'alert' | 'source'; severity: 'info' | 'attention' | 'critical'; message: string; entityId?: string; correlationId?: string; acknowledged: boolean; target?: NavigationTarget }
+export interface SourceHealth { state: 'disconnected' | 'connecting' | 'connected' | 'degraded'; message: string; invalidEvents: number; duplicates: number; outOfOrder: number; lastEventAt?: number }
+
+export type LifecycleState = 'detected' | 'identity-verified' | 'bound' | 'configured' | 'calibrated' | 'validated' | 'staged' | 'mission-ready';
+export interface VehicleCandidate { id: string; transport: 'usb' | 'serial' | 'radio' | 'ethernet' | 'wifi' | 'adapter'; hardwareUid: string; board: string; firmware: string; vehicleClass: Archetype; systemId: number; link: number; authentication: 'trusted' | 'untrusted' | 'unknown'; claim: 'claimed' | 'unclaimed' | 'conflict'; lastSeenAt: number }
+export interface VehicleIdentity { id: string; logicalVehicleId: string; hardwareUid: string; callsign: string; systemId: number; verifiedAt?: number; provenance: string }
+export interface VehicleBinding { id: string; logicalVehicleId: string; candidateId: string; state: 'draft' | 'bound' | 'quarantined' | 'replaced' | 'unbound'; revision: number }
+export interface CapabilitySet { vehicleId: string; schemaVersion: string; capabilities: string[]; reportedAt: number }
+export interface ConfigurationSchema { id: string; version: string; vehicleClass: Archetype; fields: Array<{ key: string; label: string; unit?: string; min?: number; max?: number; safetyCritical: boolean; scope: 'fleet' | 'device' | 'calibration' | 'identity' | 'secret' | 'read-only' }> }
+export interface ConfigurationSnapshot { id: string; vehicleId: string; schemaId: string; createdAt: number; values: Record; provenance: 'baseline' | 'readback' | 'checkpoint' }
+export interface ConfigurationDraft { id: string; vehicleId: string; baselineId: string; desired: Record; authorId: string; revision: number; state: 'editing' | 'validated' | 'applying' | 'applied' | 'rejected' }
+export interface ChangeSet { id: string; exactTargetIds: readonly string[]; draftIds: string[]; correlationId: string; createdAt: number; state: CommandState }
+export interface ValidationReport { id: string; changeSetId: string; createdAt: number; results: Array<{ code: string; severity: 'pass' | 'warning' | 'blocker'; message: string; vehicleId?: string }> }
+export interface CalibrationRun { id: string; vehicleId: string; kind: string; startedAt: number; completedAt?: number; state: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled'; samples: number }
+export interface CommissioningSession { id: string; vehicleId: string; leaseId: string; lifecycle: LifecycleState; blockers: string[]; warnings: string[]; completedSections: string[] }
+export interface CommissioningLease { id: string; vehicleId: string; holderId: string; expiresAt: number; state: 'held' | 'expired' | 'conflict' }
+export interface BatchJob { id: string; templateVersion: string; exactTargetIds: readonly string[]; canaryIds: readonly string[]; waveSize: number; state: 'draft' | 'validating' | 'canary' | 'rolling' | 'paused' | 'complete' | 'partial' | 'failed'; results: Record }
+export interface MapArtifact { id: string; kind: 'point' | 'ping' | 'note' | 'polyline' | 'arrow' | 'polygon' | 'circle' | 'task-area' | 'corridor' | 'measurement' | 'geofence' | 'home' | 'rally' | 'recovery' | 'landing' | 'staging'; semanticClass: 'annotation' | 'task' | 'safety' | 'command-preview' | 'mission-truth'; points: Position[]; label?: string; scope: 'private' | 'team' | 'mission'; authorId: string; createdAt: number; updatedAt: number; revision: number; expiresAt?: number }
+export interface RecoveryCheckpoint { id: string; vehicleId: string; snapshotId: string; createdAt: number; reason: string; verified: boolean }
+export interface MissionState {
+ mission: Mission; tasks: Record; groups: Record; vehicles: Record;
+ operators: Record; leases: Record; queue: Intent[]; alerts: Alert[];
+ events: ActivityEvent[]; source: SourceHealth; simulated: true; simulation: { paused: boolean; speed: number; tick: number };
+}
+
+export const emptyMissionState = (): MissionState => ({
+ mission: { id: 'mission:none', name: 'No mission', code: '—', revision: 0, elapsedSeconds: 0 },
+ tasks: {}, groups: {}, vehicles: {}, operators: {}, leases: {}, queue: [], alerts: [], events: [],
+ source: { state: 'disconnected', message: 'Source disconnected', invalidEvents: 0, duplicates: 0, outOfOrder: 0 },
+ simulated: true, simulation: { paused: false, speed: 1, tick: 0 },
+});
diff --git a/ground-suite/src/interaction/controller.ts b/ground-suite/src/interaction/controller.ts
new file mode 100644
index 0000000..2e5f7ce
--- /dev/null
+++ b/ground-suite/src/interaction/controller.ts
@@ -0,0 +1,116 @@
+export type InteractionMode =
+ | 'select' | 'select-additive' | 'marquee' | 'lasso' | 'pan' | 'inspect' | 'move-intent' | 'route' | 'formation'
+ | 'marker' | 'ping' | 'note' | 'polyline' | 'arrow' | 'freehand'
+ | 'polygon' | 'circle' | 'task-area' | 'corridor'
+ | 'measure-distance' | 'measure-bearing' | 'measure-area'
+ | 'geofence-include' | 'geofence-exclude'
+ | 'home' | 'rally' | 'landing' | 'recovery' | 'staging'
+ | 'edit' | 'erase';
+
+export type DraftPoint = { x: number; y: number };
+export type DraftGeometry = {
+ id: string;
+ mode: InteractionMode;
+ points: DraftPoint[];
+ targetIds: string[];
+ phase: 'drawing' | 'complete' | 'review';
+ valid: boolean;
+ message: string;
+};
+
+export type ModeDefinition = {
+ label: string;
+ cursor: string;
+ targets: string;
+ pointer: string;
+ keyboard: string;
+ geometry: 'none' | 'point' | 'line' | 'polyline' | 'polygon' | 'circle' | 'freehand' | 'formation';
+ completion: string;
+ cancellation: string;
+ validation: string;
+ output: string;
+};
+
+const mode = (label: string, cursor: string, geometry: ModeDefinition['geometry'], output: string, overrides: Partial = {}): ModeDefinition => ({
+ label, cursor, geometry, output,
+ targets: 'Map ground and selectable mission entities',
+ pointer: geometry === 'none' ? 'Click or drag the active target' : 'Click to add geometry; drag handles to adjust',
+ keyboard: 'Enter completes; Escape cancels; undo and redo affect local drafts',
+ completion: geometry === 'point' ? 'One valid map point' : geometry === 'none' ? 'Pointer release' : 'Enter or double-click after minimum vertices',
+ cancellation: 'Escape removes the current segment, then the draft, then returns to Select',
+ validation: 'Geographic extent, exact target set, freshness, authority, and safety constraints',
+ ...overrides,
+});
+
+export const MODES: Record = {
+ select: mode('Select', 'default', 'none', 'local-selection', { pointer: 'Click selects; Shift toggles; empty drag creates a marquee', validation: 'Visible, selectable mission entities only' }),
+ 'select-additive': mode('Additive select', 'default', 'none', 'local-selection', { pointer: 'Click toggles membership without clearing the local selection', validation: 'Visible, selectable mission entities only' }),
+ marquee: mode('Marquee select', 'crosshair', 'none', 'local-selection', { pointer: 'Drag a screen-space rectangle; entities are hit-tested in geographic world space', validation: 'Visible, selectable mission entities only' }),
+ lasso: mode('Lasso select', 'crosshair', 'freehand', 'local-selection', { pointer: 'Draw a closed geographic loop around entities', completion: 'Release closes the loop and applies local selection', validation: 'Visible vehicle positions inside the loop' }),
+ pan: mode('Pan', 'grab', 'none', 'camera-change', { pointer: 'Drag map; middle-drag and Space-drag temporarily activate', validation: 'Camera bounds' }),
+ inspect: mode('Inspect', 'help', 'point', 'inspector-focus'),
+ 'move-intent': mode('Move intent', 'crosshair', 'line', 'command-draft'),
+ route: mode('Route', 'crosshair', 'polyline', 'command-draft', { pointer: 'Click adds a waypoint; Shift-click continues; drag final heading handle' }),
+ formation: mode('Formation', 'crosshair', 'formation', 'formation-draft', { pointer: 'Drag anchor, orientation, spacing, width, and depth handles' }),
+ marker: mode('Marker', 'crosshair', 'point', 'map-artifact'), ping: mode('Attention ping', 'crosshair', 'point', 'expiring-map-artifact'), note: mode('Text note', 'text', 'point', 'map-artifact'),
+ polyline: mode('Polyline', 'crosshair', 'polyline', 'map-artifact'), arrow: mode('Phase line', 'crosshair', 'polyline', 'map-artifact'), freehand: mode('Freehand', 'crosshair', 'freehand', 'map-artifact', { pointer: 'Press and draw a geographic stroke; release completes' }),
+ polygon: mode('Polygon', 'crosshair', 'polygon', 'map-artifact'), circle: mode('Circle', 'crosshair', 'circle', 'map-artifact', { pointer: 'Drag from center to radius' }), 'task-area': mode('Task area', 'crosshair', 'polygon', 'task-geometry'), corridor: mode('Corridor', 'crosshair', 'polyline', 'task-geometry'),
+ 'measure-distance': mode('Distance', 'crosshair', 'line', 'measurement'), 'measure-bearing': mode('Bearing', 'crosshair', 'line', 'measurement'), 'measure-area': mode('Area', 'crosshair', 'polygon', 'measurement'),
+ 'geofence-include': mode('Keep in', 'crosshair', 'polygon', 'safety-proposal'), 'geofence-exclude': mode('Keep out', 'not-allowed', 'polygon', 'safety-proposal'),
+ home: mode('Home', 'crosshair', 'point', 'location-proposal'), rally: mode('Rally', 'crosshair', 'point', 'location-proposal'), landing: mode('Landing', 'crosshair', 'point', 'location-proposal'), recovery: mode('Recovery', 'crosshair', 'point', 'location-proposal'), staging: mode('Staging', 'crosshair', 'point', 'location-proposal'),
+ edit: mode('Edit vertices', 'move', 'none', 'artifact-revision', { pointer: 'Drag vertices; click midpoint to insert; drag body to move', validation: 'Artifact-specific geometry and scope' }),
+ erase: mode('Erase', 'not-allowed', 'none', 'artifact-tombstone', { pointer: 'Click a local draft or editable artifact', validation: 'Edit permission and artifact revision' }),
+};
+
+export type InteractionState = {
+ mode: InteractionMode;
+ draft: DraftGeometry | null;
+ artifacts: DraftGeometry[];
+ past: DraftGeometry[][];
+ future: DraftGeometry[][];
+};
+
+export type InteractionAction =
+ | { type: 'mode'; mode: InteractionMode }
+ | { type: 'point'; point: DraftPoint; targetIds: string[] }
+ | { type: 'freehand'; point: DraftPoint }
+ | { type: 'complete' }
+ | { type: 'cancel' }
+ | { type: 'discard-draft' }
+ | { type: 'undo' }
+ | { type: 'redo' }
+ | { type: 'erase'; id: string };
+
+export const initialInteraction: InteractionState = { mode: 'select', draft: null, artifacts: [], past: [], future: [] };
+
+const minimumPoints = (mode: InteractionMode) => MODES[mode].geometry === 'polygon' ? 3 : ['polyline', 'line', 'circle', 'formation'].includes(MODES[mode].geometry) ? 2 : 1;
+const snapshot = (state: InteractionState) => [...state.artifacts];
+
+export function interactionReducer(state: InteractionState, action: InteractionAction): InteractionState {
+ if (action.type === 'mode') return { ...state, mode: action.mode, draft: null };
+ if (action.type === 'point') {
+ if (MODES[state.mode].geometry === 'none') return state;
+ const draft = state.draft?.mode === state.mode ? state.draft : { id: `draft:${Date.now()}`, mode: state.mode, points: [], targetIds: [...action.targetIds], phase: 'drawing' as const, valid: true, message: 'Local draft · not dispatched' };
+ const next = { ...draft, points: [...draft.points, action.point] };
+ if (MODES[state.mode].geometry === 'point') return interactionReducer({ ...state, draft: next }, { type: 'complete' });
+ return { ...state, draft: next };
+ }
+ if (action.type === 'freehand' && ['freehand', 'lasso'].includes(state.mode) && state.draft) return { ...state, draft: { ...state.draft, points: [...state.draft.points, action.point] } };
+ if (action.type === 'complete') {
+ if (!state.draft || state.draft.points.length < minimumPoints(state.draft.mode)) return state;
+ const complete = { ...state.draft, phase: (['move-intent', 'route', 'formation'].includes(state.draft.mode) ? 'review' : 'complete') as DraftGeometry['phase'] };
+ return { ...state, draft: complete.phase === 'review' ? complete : null, artifacts: complete.phase === 'complete' ? [...state.artifacts, complete] : state.artifacts, past: [...state.past, snapshot(state)], future: [], mode: complete.phase === 'review' ? state.mode : 'select' };
+ }
+ if (action.type === 'cancel') {
+ if (state.draft?.points.length && state.draft.points.length > 1) return { ...state, draft: { ...state.draft, points: state.draft.points.slice(0, -1) } };
+ if (state.draft) return { ...state, draft: null };
+ return { ...state, mode: 'select' };
+ }
+ if (action.type === 'discard-draft') return { ...state, draft: null, mode: 'select' };
+ if (action.type === 'undo' && state.past.length) return { ...state, artifacts: state.past.at(-1)!, past: state.past.slice(0, -1), future: [snapshot(state), ...state.future], draft: null };
+ if (action.type === 'redo' && state.future.length) return { ...state, artifacts: state.future[0], past: [...state.past, snapshot(state)], future: state.future.slice(1), draft: null };
+ if (action.type === 'erase') return { ...state, past: [...state.past, snapshot(state)], artifacts: state.artifacts.filter(item => item.id !== action.id), future: [] };
+ return state;
+}
+
+export const draftDistanceKm = (points: DraftPoint[]) => points.slice(1).reduce((total, point, index) => total + Math.hypot(point.x - points[index].x, point.y - points[index].y) * 0.18, 0);
diff --git a/ground-suite/src/main.tsx b/ground-suite/src/main.tsx
new file mode 100644
index 0000000..b97a8c1
--- /dev/null
+++ b/ground-suite/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './app/App';
+import { createSimulationRuntime } from './app/runtime';
+import './design-system/tokens.css';
+import './design-system/app.css';
+
+const root = document.getElementById('root');
+if (!root) throw new Error('Ground Suite root element is missing');
+createRoot(root).render( );
diff --git a/ground-suite/src/projections/selectors.test.ts b/ground-suite/src/projections/selectors.test.ts
new file mode 100644
index 0000000..532faba
--- /dev/null
+++ b/ground-suite/src/projections/selectors.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+import { createScenario } from '../adapters/fake/scenario';
+import { parseSmartQuery, replacementCandidates, smartSelect } from './selectors';
+describe('smart selectors', () => {
+ const state = createScenario();
+ it('translates the documented grammar into visible filters', () => expect(parseSmartQuery('Available ISR aircraft within 20 km with more than 60% energy')).toEqual({ capability: 'ISR', available: true, maxDistanceKm: 20, minEnergy: 60, task: 'none' }));
+ it('reports filter distribution and rejection reasons', () => { const result = smartSelect(state, parseSmartQuery('Available ISR aircraft within 20 km with more than 60% energy')); expect(result.matches.length).toBeGreaterThan(0); expect(result.distribution[0]).toEqual({ label: 'Fleet', count: 50 }); expect(result.rejected.length + result.matches.length).toBe(50); });
+ it('ranks deterministic replacement candidates', () => { const one = replacementCandidates(state); const two = replacementCandidates(createScenario()); expect(one.map(item => item.vehicle.id)).toEqual(two.map(item => item.vehicle.id)); expect(one).toHaveLength(3); });
+});
diff --git a/ground-suite/src/projections/selectors.ts b/ground-suite/src/projections/selectors.ts
new file mode 100644
index 0000000..e6d6f89
--- /dev/null
+++ b/ground-suite/src/projections/selectors.ts
@@ -0,0 +1,23 @@
+import type { MissionState, Vehicle } from '../domain/model';
+
+export const selectVehiclesForGroup = (state: MissionState, groupId: string): Vehicle[] => Object.values(state.vehicles).filter(vehicle => vehicle.groupId === groupId);
+export const fleetSummary = (state: MissionState) => ({ total: Object.keys(state.vehicles).length, healthy: Object.values(state.vehicles).filter(vehicle => vehicle.health === 'nominal').length, attention: Object.values(state.vehicles).filter(vehicle => vehicle.health !== 'nominal').length, activeTasks: Object.values(state.tasks).filter(task => task.state === 'active').length });
+export interface SmartFilters { capability?: string; available?: boolean; maxDistanceKm?: number; minEnergy?: number; task?: 'none' | string }
+export interface SmartResult { matches: Vehicle[]; rejected: Array<{ vehicle: Vehicle; reasons: string[] }>; distribution: Array<{ label: string; count: number }> }
+export function parseSmartQuery(query: string): SmartFilters {
+ const normalized = query.toLowerCase();
+ const distance = normalized.match(/within\s+(\d+)\s*km/); const energy = normalized.match(/(?:more than|above|>)\s*(\d+)\s*%/);
+ return { capability: normalized.includes('isr') ? 'ISR' : undefined, available: normalized.includes('available') ? true : undefined, maxDistanceKm: distance ? Number(distance[1]) : undefined, minEnergy: energy ? Number(energy[1]) : undefined, task: normalized.includes('available') || normalized.includes('task: none') ? 'none' : undefined };
+}
+export function smartSelect(state: MissionState, filters: SmartFilters): SmartResult {
+ const all = Object.values(state.vehicles); const steps: SmartResult['distribution'] = [{ label: 'Fleet', count: all.length }]; let current = all;
+ const apply = (label: string, predicate: (vehicle: Vehicle) => boolean) => { current = current.filter(predicate); steps.push({ label, count: current.length }); };
+ if (filters.capability) apply(`Capability: ${filters.capability}`, vehicle => vehicle.capabilities.includes(filters.capability!));
+ if (filters.available) apply('Available', vehicle => vehicle.available);
+ if (filters.maxDistanceKm !== undefined) apply(`Distance < ${filters.maxDistanceKm} km`, vehicle => Math.hypot(vehicle.position.x - 45, vehicle.position.y - 35) / 4 < filters.maxDistanceKm!);
+ if (filters.minEnergy !== undefined) apply(`Energy > ${filters.minEnergy}%`, vehicle => vehicle.energy > filters.minEnergy!);
+ if (filters.task === 'none') apply('No assigned task', vehicle => !vehicle.taskId);
+ const matches = current; const matchIds = new Set(matches.map(vehicle => vehicle.id));
+ return { matches, distribution: steps, rejected: all.filter(vehicle => !matchIds.has(vehicle.id)).map(vehicle => ({ vehicle, reasons: [filters.available && !vehicle.available ? 'already assigned' : '', filters.minEnergy && vehicle.energy <= filters.minEnergy ? 'energy threshold' : '', filters.capability && !vehicle.capabilities.includes(filters.capability) ? 'capability mismatch' : ''].filter(Boolean) })) };
+}
+export const replacementCandidates = (state: MissionState) => Object.values(state.vehicles).filter(vehicle => vehicle.available && vehicle.capabilities.includes('ISR')).map(vehicle => ({ vehicle, distanceKm: Math.hypot(vehicle.position.x - 35, vehicle.position.y - 35) / 4, score: vehicle.energy * 0.45 + vehicle.link * 0.35 - Math.hypot(vehicle.position.x - 35, vehicle.position.y - 35) * 0.2, reasons: [`${Math.round(vehicle.energy)}% energy`, `${Math.round(vehicle.link)}% link`, 'ISR compatible'] })).sort((a, b) => b.score - a.score).slice(0, 3);
diff --git a/ground-suite/src/test/setup.ts b/ground-suite/src/test/setup.ts
new file mode 100644
index 0000000..6c1577b
--- /dev/null
+++ b/ground-suite/src/test/setup.ts
@@ -0,0 +1,8 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup } from '@testing-library/react';
+import { afterEach } from 'vitest';
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
diff --git a/ground-suite/src/vite-env.d.ts b/ground-suite/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/ground-suite/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/ground-suite/test/core/transport/replay_vehicle_data_port_test.dart b/ground-suite/test/core/transport/replay_vehicle_data_port_test.dart
deleted file mode 100644
index 4cf6318..0000000
--- a/ground-suite/test/core/transport/replay_vehicle_data_port_test.dart
+++ /dev/null
@@ -1,30 +0,0 @@
-import 'package:flutter_test/flutter_test.dart';
-import 'package:ground_suite/core/observations/observed_event.dart';
-import 'package:ground_suite/core/observations/vehicle_data_update.dart';
-import 'package:ground_suite/core/transport/replay_vehicle_data_port.dart';
-
-void main() {
- test('replay covers identity, telemetry, log, error, and shutdown', () async {
- const port = ReplayVehicleDataPort();
-
- final events = [
- for (final update in await port.observe().toList())
- if (update case ObservationReceived(:final event)) event,
- ];
-
- expect(events.whereType(), hasLength(1));
- expect(events.whereType(), hasLength(2));
- final logs = events.whereType().toList();
- final errors = events.whereType().toList();
- expect(logs, hasLength(2));
- expect(errors, hasLength(1));
- expect(events.whereType(), hasLength(1));
- expect(logs.first.isCoalesced, isTrue);
- expect(errors.single.isTruncated, isTrue);
- });
-
- test('unknown wire enum values remain visible', () {
- expect(const MavSeverity(99).label, 'Unknown (99)');
- expect(const KaptanShutdownReason(99).label, 'Unknown (99)');
- });
-}
diff --git a/ground-suite/test/features/command/tactical_controller_test.dart b/ground-suite/test/features/command/tactical_controller_test.dart
deleted file mode 100644
index fe33b6e..0000000
--- a/ground-suite/test/features/command/tactical_controller_test.dart
+++ /dev/null
@@ -1,309 +0,0 @@
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
-import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
-import 'package:ground_suite/features/fleet/domain/fleet_unit.dart';
-import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
-
-void main() {
- ProviderContainer container() {
- final value = ProviderContainer();
- addTearDown(value.dispose);
- return value;
- }
-
- test('selection and cursor modes have one deterministic owner', () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
-
- controller.selectUnit('DEMO-1022');
- expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
- 'DEMO-1022',
- });
-
- controller.selectUnit('DEMO-1023', toggle: true);
- expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
- 'DEMO-1022',
- 'DEMO-1023',
- });
-
- controller.beginPencil();
- expect(
- scope.read(tacticalControllerProvider).mode,
- isA(),
- );
- expect(scope.read(tacticalControllerProvider).selectedUnitIds, isEmpty);
-
- controller.showUnitHangar();
- expect(
- scope.read(tacticalControllerProvider).panel,
- TacticalPanel.unitHangar,
- );
- expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
-
- final definition = supportedVehicleDefinitions.first;
- controller.chooseUnitDefinition(definition, displayName: 'QUADCOPTER-01');
- expect(
- scope.read(tacticalControllerProvider).unitDraft?.definition,
- definition,
- );
- controller.beginUnitSetup();
- expect(
- scope.read(tacticalControllerProvider).panel,
- TacticalPanel.unitSetup,
- );
- controller.completeUnitPlacement('local-unit-1');
- expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
- 'local-unit-1',
- });
- expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
- controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
- expect(
- scope.read(tacticalControllerProvider).panel,
- TacticalPanel.unitSetup,
- );
- });
-
- test(
- 'hangar drafts preserve the selected type and do not mutate map state',
- () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
- final before = scope.read(tacticalControllerProvider);
-
- controller.showUnitHangar();
- controller.chooseUnitDefinition(
- supportedVehicleDefinitions[1],
- displayName: 'Fixed wing scout',
- );
- controller.beginUnitSetup();
- controller.updateUnitDraftName('Fixed wing north');
-
- final editing = scope.read(tacticalControllerProvider);
- expect(
- identical(editing.selectedUnitIds, before.selectedUnitIds),
- isTrue,
- );
- expect(identical(editing.drawings, before.drawings), isTrue);
- expect(identical(editing.markers, before.markers), isTrue);
- expect(editing.panel, TacticalPanel.unitSetup);
-
- controller.returnToUnitHangar();
- final returned = scope.read(tacticalControllerProvider);
- expect(returned.panel, TacticalPanel.unitHangar);
- expect(returned.unitDraft?.definition, supportedVehicleDefinitions[1]);
- expect(returned.unitDraft?.displayName, 'Fixed wing north');
-
- controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
- expect(
- scope.read(tacticalControllerProvider).panel,
- TacticalPanel.unitHangar,
- reason:
- 'A catalog draft cannot produce an unrelated blank setup panel.',
- );
- },
- );
-
- test(
- 'editing a hangar draft does not notify the tactical render projection',
- () async {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
- scope.read(tacticalSceneRenderStateProvider);
- var renderNotifications = 0;
- final subscription = scope.listen(
- tacticalSceneRenderStateProvider,
- (previous, next) => renderNotifications += 1,
- fireImmediately: true,
- );
- addTearDown(subscription.close);
- renderNotifications = 0;
-
- controller.showUnitHangar();
- await scope.pump();
- controller.chooseUnitDefinition(
- supportedVehicleDefinitions.first,
- displayName: 'Quad local',
- );
- await scope.pump();
- controller.updateUnitDraftName('Quad north');
- await scope.pump();
- expect(
- renderNotifications,
- 1,
- reason: 'Opening the hangar changes its panel visibility.',
- );
-
- controller.updateUnitDraftName('Quad north one');
- await scope.pump();
- expect(
- renderNotifications,
- 1,
- reason: 'Typing only updates the isolated local setup draft.',
- );
- },
- );
-
- test(
- 'direct pencil strokes are session state and require complete geometry',
- () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
-
- controller.beginPencil();
- expect(
- controller.commitPencilStroke(const [LocalScenePoint(x: .1, y: .2)]),
- isFalse,
- );
- expect(
- controller.commitPencilStroke(const [
- LocalScenePoint(x: .1, y: .2),
- LocalScenePoint(x: .3, y: .4),
- ]),
- isTrue,
- );
-
- final state = scope.read(tacticalControllerProvider);
- expect(state.mode, isA());
- expect(state.drawings, hasLength(1));
- expect(state.drawings.single.thickness, 3);
-
- controller.clearSceneObjects();
- expect(scope.read(tacticalControllerProvider).drawings, isEmpty);
- },
- );
-
- test('view toggles remain separate from selection and pencil tools', () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
-
- controller.beginPencil();
- controller.toggleOverlay(TacticalMapOverlay.drones, false);
-
- final state = scope.read(tacticalControllerProvider);
- expect(state.mode, isA());
- expect(state.overlays, isNot(contains(TacticalMapOverlay.drones)));
- });
-
- test('fleet panel is separate from a local map mode', () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
-
- controller.showFleetRoster();
- expect(
- scope.read(tacticalControllerProvider).panel,
- TacticalPanel.fleetRoster,
- );
- expect(
- scope.read(tacticalControllerProvider).mode,
- isA(),
- );
-
- controller.beginPencil();
- expect(
- scope.read(tacticalControllerProvider).mode,
- isA(),
- );
- controller.closePanel();
- expect(scope.read(tacticalControllerProvider).panel, isNull);
- });
-
- test('drag selection is local only', () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
- controller.selectUnits(['DEMO-1021', 'DEMO-1023']);
- final state = scope.read(tacticalControllerProvider);
- expect(state.selectedUnitIds, {'DEMO-1021', 'DEMO-1023'});
- expect(state.mode, isA());
- });
-
- test('direct strokes are bounded before local scene commit', () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
- controller.beginPencil();
-
- final stroke = [
- for (var index = 0; index < 256; index++)
- LocalScenePoint(x: index / 255, y: index.isEven ? .4 : .6),
- ];
- expect(controller.commitPencilStroke(stroke), isTrue);
- expect(
- scope.read(tacticalControllerProvider).drawings.single.points.length,
- lessThanOrEqualTo(256),
- );
- });
-
- test(
- 'pencil style is retained for the current cursor and committed stroke',
- () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
- controller.beginPencil();
- controller.configurePencil(
- hue: 282,
- saturation: .22,
- brightness: .38,
- thickness: 8,
- );
- expect(
- controller.commitPencilStroke(const [
- LocalScenePoint(x: .2, y: .2),
- LocalScenePoint(x: .4, y: .4),
- ]),
- isTrue,
- );
-
- final state = scope.read(tacticalControllerProvider);
- expect(state.mode, isA());
- final mode = state.mode as TacticalPencilMode;
- expect(mode.hue, 282);
- expect(mode.saturation, .22);
- expect(mode.brightness, .38);
- expect(mode.thickness, 8);
- expect(state.drawings.single.hue, 282);
- expect(state.drawings.single.saturation, .22);
- expect(state.drawings.single.brightness, .38);
- expect(state.drawings.single.thickness, 8);
- },
- );
-
- test('marker names and colors are independent from pencil strokes', () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
-
- controller.beginPencil();
- controller.configurePencil(hue: 282, thickness: 8);
- controller.beginMarker();
- controller.configureMarker(hue: 26, saturation: .41, brightness: .56);
- controller.chooseMarkerPoint(const LocalScenePoint(x: .4, y: .6));
-
- expect(controller.commitMarkerName('Landing review'), isTrue);
- final state = scope.read(tacticalControllerProvider);
- expect(state.markers, hasLength(1));
- expect(state.markers.single.name, 'Landing review');
- expect(state.markers.single.hue, 26);
- expect(state.markers.single.saturation, .41);
- expect(state.markers.single.brightness, .56);
- expect((state.mode as TacticalMarkerMode).hue, 26);
- expect(state.drawings, isEmpty);
- });
-
- test(
- 'blank marker labels and pending marker cancellation create no marker',
- () {
- final scope = container();
- final controller = scope.read(tacticalControllerProvider.notifier);
- controller.beginMarker();
- controller.chooseMarkerPoint(const LocalScenePoint(x: .2, y: .2));
-
- expect(controller.commitMarkerName(' '), isFalse);
- expect(controller.cancelActiveTool(), isFalse);
- expect(scope.read(tacticalControllerProvider).markers, isEmpty);
- expect(controller.cancelActiveTool(), isTrue);
- expect(
- scope.read(tacticalControllerProvider).mode,
- isA(),
- );
- },
- );
-}
diff --git a/ground-suite/test/features/observability/observability_controller_test.dart b/ground-suite/test/features/observability/observability_controller_test.dart
deleted file mode 100644
index 964211f..0000000
--- a/ground-suite/test/features/observability/observability_controller_test.dart
+++ /dev/null
@@ -1,174 +0,0 @@
-import 'dart:async';
-
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:ground_suite/core/observations/observed_event.dart';
-import 'package:ground_suite/core/observations/vehicle_data_port.dart';
-import 'package:ground_suite/core/observations/vehicle_data_update.dart';
-import 'package:ground_suite/features/observability/presentation/observability_controller.dart';
-
-void main() {
- test('coalesces rapid critical accessibility announcements', () async {
- final port = _ControlledDataPort();
- final container = ProviderContainer(
- overrides: [vehicleDataPortProvider.overrideWithValue(port)],
- );
- addTearDown(container.dispose);
-
- container.read(observabilityControllerProvider);
- await Future.delayed(Duration.zero);
-
- port.events.add(
- ObservationReceived(
- ErrorEvent(
- receivedAt: DateTime.utc(2026, 8, 10),
- path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
- ),
- ),
- );
- await Future.delayed(Duration.zero);
-
- expect(
- container.read(observabilityControllerProvider).accessibilityAnnouncement,
- 'Error received: kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
- );
-
- port.events.add(
- ObservationReceived(
- ShutdownEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 0, 0, 1),
- reason: KaptanShutdownReason.safety,
- ),
- ),
- );
- await Future.delayed(Duration.zero);
-
- final state = container.read(observabilityControllerProvider);
- expect(state.accessibilityAnnouncement, isNull);
- expect(state.coalescedAlertCount, 1);
- });
-
- test(
- 'retains expected parser and semantic drops without ending observation',
- () async {
- final port = _ControlledDataPort();
- final container = ProviderContainer(
- overrides: [vehicleDataPortProvider.overrideWithValue(port)],
- );
- addTearDown(container.dispose);
-
- container.read(observabilityControllerProvider);
- await Future.delayed(Duration.zero);
-
- port.events.add(
- const ObservationDropped(kind: ObservationDropKind.parser, count: 2),
- );
- port.events.add(
- const ObservationDropped(kind: ObservationDropKind.semantic),
- );
- await Future.delayed(Duration.zero);
-
- final state = container.read(observabilityControllerProvider);
- expect(state.status, ObservationStatus.receiving);
- expect(state.parserDropCount, 2);
- expect(state.semanticDropCount, 1);
- },
- );
-
- test('treats a transport stream failure as a disconnected link', () async {
- final port = _ControlledDataPort();
- final container = ProviderContainer(
- overrides: [vehicleDataPortProvider.overrideWithValue(port)],
- );
- addTearDown(container.dispose);
-
- container.read(observabilityControllerProvider);
- await Future.delayed(Duration.zero);
-
- port.events.addError(StateError('serial endpoint closed'));
- await Future.delayed(Duration.zero);
-
- final state = container.read(observabilityControllerProvider);
- expect(state.status, ObservationStatus.disconnected);
- expect(state.parserDropCount, 0);
- expect(state.semanticDropCount, 0);
- });
-
- test(
- 'bounds diagnostics while telemetry stays outside the timeline',
- () async {
- final port = _ControlledDataPort();
- final container = ProviderContainer(
- overrides: [vehicleDataPortProvider.overrideWithValue(port)],
- );
- addTearDown(container.dispose);
-
- container.read(observabilityControllerProvider);
- await Future.delayed(Duration.zero);
-
- port.events.add(
- ObservationReceived(
- ErrorEvent(
- receivedAt: DateTime.utc(2026, 8, 10),
- path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
- ),
- ),
- );
- port.events.add(
- ObservationReceived(
- HeartbeatEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 0, 0, 1),
- systemId: 7,
- componentId: 42,
- armed: false,
- vehicleState: 'Standby',
- ),
- ),
- );
- for (var index = 0; index < 501; index++) {
- port.events.add(
- ObservationReceived(
- TelemetryEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 0, 0, index % 60),
- kind: TelemetryKind.rawImu,
- ),
- ),
- );
- }
- for (var index = 0; index < 500; index++) {
- port.events.add(
- ObservationReceived(
- LogEvent(
- receivedAt: DateTime.utc(2026, 8, 10, 0, 1, index % 60),
- level: MavSeverity.info,
- message: 'Diagnostic $index',
- ),
- ),
- );
- }
-
- final state = container.read(observabilityControllerProvider);
- expect(state.events, hasLength(500));
- expect(state.localTimelineEvictionCount, 1);
- expect(state.events.whereType(), hasLength(1));
- expect(state.lastHeartbeat?.systemId, 7);
- expect(state.observedTelemetryKinds, contains(TelemetryKind.rawImu));
- },
- );
-}
-
-class _ControlledDataPort implements VehicleDataPort {
- final events = StreamController(sync: true);
-
- @override
- bool get isReplay => false;
-
- @override
- String get label => 'Test transport';
-
- @override
- Future close() => events.close();
-
- @override
- Stream observe() => events.stream;
-}
diff --git a/ground-suite/test/features/vehicle_workspace/vehicle_catalog_test.dart b/ground-suite/test/features/vehicle_workspace/vehicle_catalog_test.dart
deleted file mode 100644
index 1c1e803..0000000
--- a/ground-suite/test/features/vehicle_workspace/vehicle_catalog_test.dart
+++ /dev/null
@@ -1,77 +0,0 @@
-import 'dart:ui' show CheckedState;
-
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
-import 'package:ground_suite/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart';
-
-void main() {
- test('every local vehicle type has a clear bundled profile image', () {
- expect(supportedVehicleDefinitions, hasLength(6));
- expect(
- supportedVehicleDefinitions.map((definition) => definition.name),
- containsAll([
- 'Quadcopter',
- 'Fixed-wing',
- 'Tiltrotor VTOL',
- 'Helicopter',
- 'Ground rover',
- 'Surface vessel',
- ]),
- );
- for (final definition in supportedVehicleDefinitions) {
- expect(definition.id, isNotEmpty);
- expect(definition.name, isNotEmpty);
- expect(definition.media.imageAssetPath, isNotEmpty);
- }
- });
-
- testWidgets('catalog card is a single keyboard-activatable radio control', (
- tester,
- ) async {
- var selections = 0;
- final definition = supportedVehicleDefinitions.first;
-
- await tester.pumpWidget(
- MaterialApp(
- home: Scaffold(
- body: SingleChildScrollView(
- child: Center(
- child: SizedBox(
- width: 760,
- child: VehicleCatalogCard(
- definition: definition,
- selected: false,
- onSelected: () => selections += 1,
- ),
- ),
- ),
- ),
- ),
- ),
- );
-
- final card = find.byType(VehicleCatalogCard);
- final semantics = tester.getSemantics(card);
- expect(semantics.flagsCollection.isChecked, CheckedState.isFalse);
- expect(semantics.flagsCollection.isInMutuallyExclusiveGroup, isTrue);
- expect(semantics.label, contains('local vehicle image, not telemetry'));
-
- await tester.sendKeyEvent(LogicalKeyboardKey.tab);
- await tester.pump();
- await tester.sendKeyEvent(LogicalKeyboardKey.space);
- await tester.pump();
- await tester.sendKeyEvent(LogicalKeyboardKey.enter);
- await tester.pump();
-
- expect(selections, 2);
- });
-
- testWidgets('definition images are bundled locally', (tester) async {
- for (final definition in supportedVehicleDefinitions) {
- final image = await rootBundle.load(definition.media.imageAssetPath);
- expect(image.lengthInBytes, greaterThan(0));
- }
- });
-}
diff --git a/ground-suite/test/features/vehicle_workspace/vehicle_workspace_controller_test.dart b/ground-suite/test/features/vehicle_workspace/vehicle_workspace_controller_test.dart
deleted file mode 100644
index f709187..0000000
--- a/ground-suite/test/features/vehicle_workspace/vehicle_workspace_controller_test.dart
+++ /dev/null
@@ -1,136 +0,0 @@
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:ground_suite/features/fleet/domain/fleet_unit.dart';
-import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
-import 'package:ground_suite/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart';
-
-void main() {
- test('a reviewed definition creates a selected local fleet unit', () {
- final container = ProviderContainer();
- addTearDown(container.dispose);
-
- final controller = container.read(vehicleWorkspaceProvider.notifier);
- expect(container.read(vehicleWorkspaceProvider).hasUnits, isFalse);
-
- final definition = supportedVehicleDefinitions.first;
- expect(
- controller.addUnit(
- definition: definition,
- localPlacement: const LocalScenePoint(x: .35, y: .45),
- displayName: 'North survey',
- ),
- isTrue,
- );
- expect(container.read(vehicleWorkspaceProvider).hasUnits, isTrue);
- expect(
- container.read(vehicleWorkspaceProvider).units.single.definition,
- definition,
- );
- expect(
- container.read(vehicleWorkspaceProvider).units.single.localUnitId,
- 'local-unit-1',
- );
- expect(
- container.read(vehicleWorkspaceProvider).units.single.displayName,
- 'North survey',
- );
-
- expect(
- controller.addUnit(
- definition: definition,
- localPlacement: const LocalScenePoint(x: .72, y: .45),
- ),
- isTrue,
- );
- expect(container.read(vehicleWorkspaceProvider).units, hasLength(2));
- expect(
- container.read(vehicleWorkspaceProvider).units.last.localUnitId,
- 'local-unit-2',
- );
- });
-
- test(
- 'rejects overlapping local placements and keeps a bounded workspace',
- () {
- final container = ProviderContainer();
- addTearDown(container.dispose);
-
- final controller = container.read(vehicleWorkspaceProvider.notifier);
- final definition = supportedVehicleDefinitions.first;
- const first = LocalScenePoint(x: .2, y: .2);
- expect(
- controller.addUnit(definition: definition, localPlacement: first),
- isTrue,
- );
- expect(
- controller.addUnit(definition: definition, localPlacement: first),
- isFalse,
- );
-
- expect(
- controller.addUnit(
- definition: definition,
- localPlacement: const LocalScenePoint(x: .23, y: .2),
- ),
- isFalse,
- );
- expect(
- controller.addUnit(
- definition: definition,
- localPlacement: const LocalScenePoint(x: .65, y: .7),
- ),
- isTrue,
- );
- },
- );
-
- test(
- 'catalog additions receive a scattered local presentation placement',
- () {
- final container = ProviderContainer();
- addTearDown(container.dispose);
-
- final controller = container.read(vehicleWorkspaceProvider.notifier);
- final definition = supportedVehicleDefinitions.first;
- expect(controller.addUnitAutomatically(definition: definition), isTrue);
- expect(controller.addUnitAutomatically(definition: definition), isTrue);
-
- final units = container.read(vehicleWorkspaceProvider).units;
- expect(units, hasLength(2));
- expect(units.first.localPlacement, isNot(units.last.localPlacement));
- for (final unit in units) {
- expect(unit.localPlacement.x, inInclusiveRange(0.0, 1.0));
- expect(unit.localPlacement.y, inInclusiveRange(0.0, 1.0));
- }
- },
- );
-
- test(
- 'local unit names are bounded workspace data, not vehicle configuration',
- () {
- final container = ProviderContainer();
- addTearDown(container.dispose);
-
- final controller = container.read(vehicleWorkspaceProvider.notifier);
- expect(
- controller.addUnitAutomatically(
- definition: supportedVehicleDefinitions.first,
- ),
- isTrue,
- );
- expect(
- controller.renameUnit('local-unit-1', ' Ridge observer '),
- isTrue,
- );
- expect(
- container.read(vehicleWorkspaceProvider).units.single.displayName,
- 'Ridge observer',
- );
- expect(controller.renameUnit('local-unit-1', ' '), isFalse);
- expect(
- container.read(vehicleWorkspaceProvider).units.single.displayName,
- 'Ridge observer',
- );
- },
- );
-}
diff --git a/ground-suite/test/widget_test.dart b/ground-suite/test/widget_test.dart
deleted file mode 100644
index de4b165..0000000
--- a/ground-suite/test/widget_test.dart
+++ /dev/null
@@ -1,683 +0,0 @@
-import 'package:flutter/gestures.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:ground_suite/app/app.dart';
-import 'package:ground_suite/app/app_shell.dart';
-import 'package:ground_suite/app/theme.dart';
-import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
-import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
-import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
-import 'package:ground_suite/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
-void main() {
- testWidgets('desktop command dashboard opens the grouped tactical controls', (
- tester,
- ) async {
- await tester.binding.setSurfaceSize(const Size(1600, 960));
- addTearDown(() => tester.binding.setSurfaceSize(null));
-
- await tester.pumpWidget(const GroundSuiteApp());
- await tester.pumpAndSettle();
-
- final app = tester.widget(find.byType(MaterialApp));
- expect(app.themeMode, ThemeMode.dark);
- expect(find.text('TACTICAL COMMAND'), findsOneWidget);
- expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
- expect(find.text('DEMO-1021'), findsAtLeastNWidgets(1));
- expect(find.byKey(const Key('units-menu')), findsOneWidget);
- expect(find.byKey(const Key('cursor-menu')), findsOneWidget);
- expect(find.byKey(const Key('view-menu')), findsOneWidget);
- expect(find.text('UNITS'), findsNothing);
- expect(find.text('MODE: SELECTION'), findsNothing);
- expect(find.text('VIEW'), findsNothing);
- expect(
- tester.getSize(find.byKey(const Key('units-menu'))),
- const Size(48, 48),
- );
- expect(
- tester.getSize(find.byKey(const Key('cursor-menu'))),
- const Size(48, 48),
- );
- expect(
- tester.getSize(find.byKey(const Key('view-menu'))),
- const Size(48, 48),
- );
-
- await tester.tap(find.byKey(const Key('units-menu')));
- await tester.pump();
- expect(find.text('Add local unit'), findsOneWidget);
- expect(
- find.text('Requires verified source-to-unit binding'),
- findsOneWidget,
- );
-
- await tester.tap(find.text('Add local unit'));
- await tester.pumpAndSettle();
- expect(find.text('UNIT HANGAR'), findsOneWidget);
- expect(find.text('LOCAL SCENE'), findsAtLeastNWidgets(1));
- expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
- expect(
- find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
- findsOneWidget,
- );
- expect(
- find.byKey(const Key('hangar-card-kaptan.vehicle.fixed-wing.local')),
- findsOneWidget,
- );
- expect(
- find.byKey(const Key('hangar-card-kaptan.vehicle.tiltrotor-vtol.local')),
- findsOneWidget,
- );
- expect(
- find.byKey(const Key('hangar-card-kaptan.vehicle.helicopter.local')),
- findsOneWidget,
- );
- expect(
- find.byKey(const Key('hangar-card-kaptan.vehicle.rover.local')),
- findsOneWidget,
- );
- expect(
- find.byKey(const Key('hangar-card-kaptan.vehicle.surface-vessel.local')),
- findsOneWidget,
- );
-
- await tester.tap(
- find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
- );
- await tester.pumpAndSettle();
- expect(find.text('PREPARE UNIT'), findsOneWidget);
- expect(find.text('QUADCOPTER-01'), findsNothing);
- expect(
- ProviderScope.containerOf(
- tester.element(find.byType(TacticalCommandPage)),
- ).read(tacticalControllerProvider).unitDraft,
- isNotNull,
- );
-
- final rectangle = await tester.startGesture(const Offset(620, 500));
- await rectangle.moveTo(const Offset(760, 560));
- await rectangle.up();
- await tester.pumpAndSettle();
- final draftState = ProviderScope.containerOf(
- tester.element(find.byType(TacticalCommandPage)),
- ).read(tacticalControllerProvider);
- expect(draftState.panel, TacticalPanel.unitHangar);
- expect(draftState.unitDraft?.definition.name, 'Quadcopter');
-
- await tester.tap(find.text('PREPARE UNIT'));
- await tester.pumpAndSettle();
- expect(find.text('UNIT SETUP'), findsOneWidget);
- expect(find.text('VEHICLE CONFIGURATION UNAVAILABLE'), findsOneWidget);
- expect(
- find.text(
- 'Unavailable until verified binding, compatible schema, command authority, and safe activation are available.',
- ),
- findsOneWidget,
- );
-
- await tester.tap(find.text('ADD TO LOCAL SCENE'));
- await tester.pumpAndSettle();
- expect(
- ProviderScope.containerOf(
- tester.element(find.byType(TacticalCommandPage)),
- ).read(tacticalControllerProvider).mode,
- isA