diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index d0f85c994..4424b73c1 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -86,10 +86,11 @@ They contain no application state.
(done: `RecentImageryReadout` and its day cards, the same way),
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
store that `StreetLevelControls` publishes to; the controls keep their
- listeners, EXPAND, focus and resizing), CCTV (in progress: `CctvPanel`
- renders the whole panel from the `gev-cctv` store, including the inline
- pose editor, the frame preview, source badge and typed summary;
- `CctvControls` still fills the picker's options), Layers, Display, Context and Radio, the command dock,
+ listeners, EXPAND, focus and resizing), CCTV (done: `CctvPanel` renders
+ the whole panel from the `gev-cctv` store, including the camera picker,
+ the inline pose editor, the frame preview, source badge and typed
+ summary; `CctvControls` keeps its listeners, the frame requests and the
+ live video surface), Layers, Display, Context and Radio, the command dock,
the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead.
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index e8bfd3bad..d9041aebd 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -48,6 +48,7 @@
"src/ui/views/panels/StreetLevelPanel.vue",
"src/ui/views/panels/CctvPanel.vue",
"src/ui/cctv/CctvCalValue.vue",
+ "src/ui/cctv/CctvPicker.vue",
"src/ui/stores/cctv.js",
"src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue"
diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs
index e7bc7f72b..f76acdb72 100644
--- a/src/data/cctv.test.mjs
+++ b/src/data/cctv.test.mjs
@@ -25,9 +25,6 @@ function componentFunctionSource(fn) {
.replace(/\(\s+/g, '(');
}
import assert from 'node:assert/strict';
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
import * as Cesium from 'cesium';
import cctvLayer, {
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
@@ -83,16 +80,6 @@ import {
LIVE_CAMERA_COLOR,
} from '../layers/cctv/policy.js';
-const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
- path.join(
- path.dirname(fileURLToPath(import.meta.url)),
- '..',
- 'ui',
- 'cctvPresentation.js',
- ),
- 'utf8',
-);
-
const ASPECT = 16 / 9;
const toRad = (deg) => (deg * Math.PI) / 180;
const GESTURE_TYPES = {
@@ -1302,16 +1289,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
assert.equal(cctvCycleIndex(-1, -1, records.length), records.length - 1);
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
-
- const renderer = CCTV_PRESENTATION_SOURCE.match(
- /_renderCctvState\(state\) \{[\s\S]*?\n\}\n/,
- );
- assert.ok(renderer, '_renderCctvState is missing');
- assert.match(
- renderer[0],
- // Nothing active clears the pick; only the outside-view note may show.
- /else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
- );
} finally {
cctvLayer.setParams({ autoHop: false });
_setCctvOverlayHostForTest();
diff --git a/src/ui/cctv/CctvPicker.vue b/src/ui/cctv/CctvPicker.vue
new file mode 100644
index 000000000..719209cb2
--- /dev/null
+++ b/src/ui/cctv/CctvPicker.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/src/ui/cctvBindings.js b/src/ui/cctvBindings.js
index 717770989..d9492d870 100644
--- a/src/ui/cctvBindings.js
+++ b/src/ui/cctvBindings.js
@@ -61,7 +61,7 @@ export function _initCctvPanel() {
// The picker defers rebuilds while focused; catch up once it lets go.
this.listen(this._cctvSelect, 'blur', () => {
- this._cctvSelectSource = null;
+ this._picker = null;
this._renderCctvState(this._cctvState);
});
diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs
index 66771a103..f3e0223f6 100644
--- a/src/ui/cctvPanel.test.mjs
+++ b/src/ui/cctvPanel.test.mjs
@@ -149,21 +149,6 @@ test('the buttons stay the elements the controls listen on', async (t) => {
assert.equal(byId('cctv-nearest-btn').disabled, true);
});
-test('re-renders leave the picker options the controls fill alone', async (t) => {
- const { controls, byId } = panel(t);
- const select = byId('cctv-camera-select');
- const option = document.createElement('option');
- option.value = 'a';
- option.textContent = 'Austin · Congress Ave';
- select.append(option);
- for (const enabled of [true, false, true]) {
- controls._renderCctvState({ enabled, count: 1, cameraIndex: [] });
- await nextTick();
- }
- assert.equal(select.options.length, 1);
- assert.equal(select.options[0].value, 'a');
-});
-
test('FOCUS waits for an active camera while the other buttons work', () => {
const loaded = { enabled: true, count: 2, cameraIndex: [] };
const view = presentCctvPanel(loaded, true);
diff --git a/src/ui/cctvPicker.test.mjs b/src/ui/cctvPicker.test.mjs
index c96fdeacb..7e36c7291 100644
--- a/src/ui/cctvPicker.test.mjs
+++ b/src/ui/cctvPicker.test.mjs
@@ -1,123 +1,116 @@
// src/ui/cctvPicker.test.mjs — the camera picker lists the cameras in view and
// says how many more the view leaves out.
+import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
+import { createPinia } from 'pinia';
+import { createApp, nextTick } from 'vue';
import { CctvControls } from './cctvControls.js';
-
-/** The slice of a