diff --git a/changelog.d/1501-rail-pills.md b/changelog.d/1501-rail-pills.md
new file mode 100644
index 000000000..61d23a38b
--- /dev/null
+++ b/changelog.d/1501-rail-pills.md
@@ -0,0 +1,3 @@
+- The side panel rails show each panel as a labelled pill while no panel on
+ that side is open, and fold to icons once one opens. `UiRail` takes
+ `collapsible` for this (samehkhamis, #1501).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index e2f776810..c1c1ae770 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1582,6 +1582,10 @@ Imagery (while shown), Street Level and Context on the right. Selecting an item
opens its panel through `setPanelCollapsed(id, false, {explicit: true})`;
selecting the open panel's item closes it. The item of the open panel is
pressed (`aria-pressed`), and its tooltip is suppressed while the panel is open.
+While no panel on its side is open, the rail shows each item as a labelled pill
+(icon and title, `UiRail`'s `collapsible`); opening a panel folds the labels away
+to the icon rail. The rail is placed by its screen-side edge, so the pills grow
+toward the middle of the screen, over the closed side's empty corridor.
One panel is open per desktop side, in every HUD layout. Opening a side panel
by any route (rail, header, keyboard, voice) collapses its peers; restored or
diff --git a/src/ui/PanelRailView.vue b/src/ui/PanelRailView.vue
index 8f43dabb2..8ac9fb256 100644
--- a/src/ui/PanelRailView.vue
+++ b/src/ui/PanelRailView.vue
@@ -42,6 +42,7 @@ const label = computed(() =>
:side="side"
:label="label"
:panel-open="active !== null"
+ collapsible
@select="onSelect"
/>
diff --git a/src/ui/kit/UiRail.test.mjs b/src/ui/kit/UiRail.test.mjs
index 6c443286a..57a0ff09d 100644
--- a/src/ui/kit/UiRail.test.mjs
+++ b/src/ui/kit/UiRail.test.mjs
@@ -71,3 +71,75 @@ test('tips open toward the screen, and not for the open panel', async () => {
await other.trigger('focusout');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
});
+
+test('a collapsible rail shows labelled pills until a panel opens', async () => {
+ const wrapper = mountRail({ collapsible: true });
+ const nav = wrapper.get('nav');
+ assert.ok(nav.classes().includes('ui-rail--collapsible'));
+ assert.ok(nav.classes().includes('ui-rail--expanded'));
+ assert.deepEqual(
+ wrapper.findAll('.ui-rail__label').map((label) => label.text()),
+ ['DATA LAYERS', 'AUTHORING', 'CCTV'],
+ );
+ // The label is on screen, so its tip would only repeat it.
+ await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
+ assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
+ // The button keeps its own accessible name; the visible label is hidden
+ // from assistive technology so the name is not read twice.
+ const button = wrapper.get('[data-rail-item="layers"]');
+ assert.equal(button.attributes('aria-label'), 'DATA LAYERS');
+ assert.equal(button.get('.ui-rail__label').attributes('aria-hidden'), 'true');
+
+ await wrapper.setProps({ active: 'layers', panelOpen: true });
+ assert.ok(nav.classes().includes('ui-rail--collapsible'));
+ assert.equal(nav.classes().includes('ui-rail--expanded'), false);
+ // Collapsed, the other items' tips come back.
+ await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
+ await new Promise((resolve) => setTimeout(resolve, 450));
+ assert.equal(wrapper.get('[role="tooltip"]').text(), 'AUTHORING');
+});
+
+test('a label cut short by the pill keeps its tip', async (t) => {
+ const observers = [];
+ const original = globalThis.ResizeObserver;
+ globalThis.ResizeObserver = class {
+ constructor(callback) {
+ this.callback = callback;
+ this.targets = [];
+ observers.push(this);
+ }
+ observe(target) {
+ this.targets.push(target);
+ }
+ unobserve() {}
+ disconnect() {}
+ };
+ t.after(() => (globalThis.ResizeObserver = original));
+ const wrapper = mountRail({ collapsible: true });
+ const [observer] = observers;
+ const [layers, scenes] = observer.targets;
+ Object.defineProperties(layers, {
+ scrollWidth: { value: 260 },
+ clientWidth: { value: 220 },
+ });
+ Object.defineProperties(scenes, {
+ scrollWidth: { value: 90 },
+ clientWidth: { value: 90 },
+ });
+ observer.callback([{ target: layers }, { target: scenes }]);
+ await wrapper.vm.$nextTick();
+ await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
+ assert.equal(wrapper.get('[role="tooltip"]').text(), 'DATA LAYERS');
+ await wrapper.findAll('.ui-tooltip')[0].trigger('focusout');
+ await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
+ assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
+});
+
+test('a plain rail has no labels', () => {
+ const wrapper = mountRail();
+ assert.equal(wrapper.find('.ui-rail__label').exists(), false);
+ assert.equal(
+ wrapper.get('nav').classes().includes('ui-rail--expanded'),
+ false,
+ );
+});
diff --git a/src/ui/kit/UiRail.vue b/src/ui/kit/UiRail.vue
index af740aae3..4e2bf6836 100644
--- a/src/ui/kit/UiRail.vue
+++ b/src/ui/kit/UiRail.vue
@@ -1,9 +1,10 @@
-