diff --git a/CHANGELOG.md b/CHANGELOG.md
index f414857b3..b9a35f0fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,7 +68,9 @@
kit's `UiButton`, which gains `pressedClass`; so are the rail card
chips, map source chips and location pills (which now report
`aria-pressed`), and the voice card's chips
- are the kit's new `UiChip`. The selects are the kit's new `UiSelect`.
+ are the kit's new `UiChip`. The selects are the kit's new `UiSelect`, the
+ status badges its new `UiBadge` and the one-line empty states its new
+ `UiEmpty`.
`syncScopeControls`
(`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are
deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue
diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index 9e43e4030..fddfdaa0d 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -72,6 +72,14 @@ options that carry their own attributes come through the slot, a caller's
Without `hold`, a select takes its model back after its own change renders,
so a pick its owner refuses goes back; an owner that answers later (the SDR
gain) takes `hold`. Every select in the views is a `UiSelect`.
+`UiBadge` takes ui-vue's `tone` and carries Core's `gev-status-badge`; its
+density variants and colours stay caller classes, and it renders the CCTV
+source and calibration badges and the rail card badges. `UiEmpty` takes
+ui-vue's `title` and `hint`, but Core's empty states are one line, so the title
+is the text itself rather than a bold heading; the Scene shot list, mission
+roster and imagery strip use it. The locale picker's and the mission tables'
+empty rows stay a paragraph and table cells, and no Core block matches
+`UiSection`'s header and well, so none uses it yet.
`UiSlider` keeps the native range input where ui-vue draws a
`role="slider"` track, so Core's keyboard, form behaviour and styling stay;
it takes ui-vue's `v-model`, `min`, `max`, `step` and `disabled`, emits a
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 9ae37e6b8..4fe6adf6f 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -1136,6 +1136,9 @@
"src/ui/kit/UiChip.test.mjs",
"src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs",
+ "src/ui/kit/UiBadge.vue",
+ "src/ui/kit/UiBadge.test.mjs",
+ "src/ui/kit/UiEmpty.vue",
"src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs",
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 8c56e857b..00e7cbf03 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -136,7 +136,9 @@
"src/sdr/gain.js",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiChip.vue",
- "src/ui/kit/UiSelect.js"
+ "src/ui/kit/UiSelect.js",
+ "src/ui/kit/UiEmpty.vue",
+ "src/ui/kit/UiBadge.vue"
],
"external": ["vue", "pinia"]
},
@@ -670,7 +672,8 @@
"src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue",
- "src/ui/kit/UiButton.vue"
+ "src/ui/kit/UiButton.vue",
+ "src/ui/kit/UiBadge.vue"
],
"external": ["vue"]
},
@@ -1070,7 +1073,8 @@
"src/ui/rail/panelState.js",
"src/ui/rail/fieldValue.js",
"src/ui/kit/UiSlider.js",
- "src/ui/kit/UiButton.vue"
+ "src/ui/kit/UiButton.vue",
+ "src/ui/kit/UiBadge.vue"
],
"external": ["cesium", "vue", "pinia"]
},
@@ -3081,7 +3085,9 @@
"src/ui/rail/panelState.js",
"src/ui/rail/fieldValue.js",
"src/ui/kit/UiSlider.js",
- "src/ui/kit/UiButton.vue"
+ "src/ui/kit/UiButton.vue",
+ "src/ui/kit/UiEmpty.vue",
+ "src/ui/kit/UiBadge.vue"
],
"external": [
"hls.js",
diff --git a/src/ui/kit/UiBadge.test.mjs b/src/ui/kit/UiBadge.test.mjs
new file mode 100644
index 000000000..ac1e4372d
--- /dev/null
+++ b/src/ui/kit/UiBadge.test.mjs
@@ -0,0 +1,49 @@
+import { after, before, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { installVueDom } from '../../testSupport/vueDom.mjs';
+
+let mount;
+let UiBadge;
+let UiEmpty;
+let uninstall;
+before(async () => {
+ uninstall = installVueDom();
+ ({ mount } = await import('@vue/test-utils'));
+ UiBadge = (await import('./UiBadge.vue')).default;
+ UiEmpty = (await import('./UiEmpty.vue')).default;
+});
+after(() => uninstall());
+
+test('a badge carries the kit classes, Core’s and the caller’s', async () => {
+ const wrapper = mount(UiBadge, {
+ attrs: { id: 'cal', class: 'gev-status-badge--box', role: 'status' },
+ slots: { default: 'CAL · --' },
+ });
+ const badge = wrapper.get('span');
+ assert.deepEqual(badge.classes(), [
+ 'ui-badge',
+ 'gev-status-badge',
+ 'ui-badge--neutral',
+ 'gev-status-badge--box',
+ ]);
+ assert.equal(badge.attributes('role'), 'status');
+ assert.equal(badge.element.textContent, 'CAL · --');
+ await wrapper.setProps({ tone: 'warn' });
+ assert.ok(badge.classes().includes('ui-badge--warn'));
+ wrapper.unmount();
+});
+
+test('an empty state is its title, then its hint', async () => {
+ const wrapper = mount(UiEmpty, {
+ props: { title: 'No shots yet.' },
+ attrs: { class: 'scene-shot-empty', hidden: true },
+ });
+ const empty = wrapper.get('div');
+ assert.deepEqual(empty.classes(), ['ui-empty', 'scene-shot-empty']);
+ assert.equal(empty.element.textContent, 'No shots yet.');
+ assert.equal(empty.attributes('title'), undefined);
+ assert.equal(empty.element.hidden, true);
+ await wrapper.setProps({ hint: 'Capture one.' });
+ assert.equal(empty.get('.ui-empty__hint').text(), 'Capture one.');
+ wrapper.unmount();
+});
diff --git a/src/ui/kit/UiBadge.vue b/src/ui/kit/UiBadge.vue
new file mode 100644
index 000000000..086de3761
--- /dev/null
+++ b/src/ui/kit/UiBadge.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/src/ui/kit/UiEmpty.vue b/src/ui/kit/UiEmpty.vue
new file mode 100644
index 000000000..0bd88dbb5
--- /dev/null
+++ b/src/ui/kit/UiEmpty.vue
@@ -0,0 +1,16 @@
+
+
+
+