diff --git a/package-lock.json b/package-lock.json
index 263fd793d..6430491c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"vue": "^3.5.44"
},
"devDependencies": {
+ "@happy-dom/global-registrator": "^20.14.6",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.5.1",
"happy-dom": "^20.14.6",
@@ -588,6 +589,20 @@
"node": ">=18"
}
},
+ "node_modules/@happy-dom/global-registrator": {
+ "version": "20.14.6",
+ "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.14.6.tgz",
+ "integrity": "sha512-Xd2FwCdm0cAZ+EG0upp69tpxBT9jtvVxaZv1lnbgWsNaZyEuPs0PojYgvCmtQ1RRdRCYCyWTeLjn3Z0aSxDchA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "happy-dom": "^20.14.6"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
diff --git a/package.json b/package.json
index 138867968..44316183c 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
"vue": "^3.5.44"
},
"devDependencies": {
+ "@happy-dom/global-registrator": "^20.14.6",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.5.1",
"happy-dom": "^20.14.6",
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 2522f91c7..6197aa43c 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -1118,5 +1118,9 @@
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
- "src/share/shareLinkPath.test.mjs"
+ "src/share/shareLinkPath.test.mjs",
+ "src/ui/kit/UiRail.test.mjs",
+ "src/testSupport/vueDom.mjs",
+ "src/testSupport/vueLoader.mjs",
+ "src/testSupport/registerVue.mjs"
]
diff --git a/scripts/format.mjs b/scripts/format.mjs
index 25311ba29..e91cf527b 100644
--- a/scripts/format.mjs
+++ b/scripts/format.mjs
@@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) {
const files = [];
for (const name of new Set(candidates)) {
if (
- !/\.[cm]?js$/.test(name) ||
+ !/\.([cm]?js|vue)$/.test(name) ||
/\.test\.[cm]?js$/.test(name) ||
!existsSync(path.join(root, name))
)
diff --git a/src/materialSymbolsSubset.test.mjs b/src/materialSymbolsSubset.test.mjs
index ec9ffe2f0..e6352502a 100644
--- a/src/materialSymbolsSubset.test.mjs
+++ b/src/materialSymbolsSubset.test.mjs
@@ -14,6 +14,8 @@ const SPAN_TEXT =
/** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */
const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
+/** A rail or menu item's glyph: `{ icon: 'layers' }`, rendered by `UiIcon`. */
+const ICON_PROPERTY = /\bicon:\s*['"]([a-z0-9_]+)['"]/g;
/**
* Files that SHIP markup. Tests assert on markup, they do not render it.
@@ -29,7 +31,7 @@ function sourceFiles(directory = SRC_ROOT) {
if (entry.isDirectory()) files.push(...sourceFiles(absolute));
else if (
entry.isFile() &&
- /\.(js|mjs|html)$/.test(entry.name) &&
+ /\.(js|mjs|html|vue)$/.test(entry.name) &&
!entry.name.endsWith('.test.mjs')
) {
files.push(absolute);
@@ -62,6 +64,7 @@ function referencedGlyphs() {
if (!found.has(glyph)) found.set(glyph, relative);
};
for (const match of source.matchAll(SPAN_TEXT)) add(match[1]);
+ for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]);
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
const statement = match[1].replaceAll('?.', '.');
const assigned = statement.includes('?')
diff --git a/src/testSupport/vueDom.mjs b/src/testSupport/vueDom.mjs
new file mode 100644
index 000000000..3fd138513
--- /dev/null
+++ b/src/testSupport/vueDom.mjs
@@ -0,0 +1,10 @@
+import { GlobalRegistrator } from '@happy-dom/global-registrator';
+
+/**
+ * Give a component test a browser document; returns the teardown.
+ * Call it from `before`/`after`, since other suites assert there is no DOM.
+ */
+export function installVueDom() {
+ GlobalRegistrator.register({ url: 'http://localhost/' });
+ return () => GlobalRegistrator.unregister();
+}
diff --git a/src/ui/kit/UiIcon.vue b/src/ui/kit/UiIcon.vue
new file mode 100644
index 000000000..4f34f29e4
--- /dev/null
+++ b/src/ui/kit/UiIcon.vue
@@ -0,0 +1,16 @@
+
+
+
+ {{ name }}
+
diff --git a/src/ui/kit/UiRail.test.mjs b/src/ui/kit/UiRail.test.mjs
new file mode 100644
index 000000000..6c443286a
--- /dev/null
+++ b/src/ui/kit/UiRail.test.mjs
@@ -0,0 +1,73 @@
+import { after, before, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { installVueDom } from '../../testSupport/vueDom.mjs';
+
+let mount;
+let UiRail;
+let uninstall;
+before(async () => {
+ uninstall = installVueDom();
+ ({ mount } = await import('@vue/test-utils'));
+ UiRail = (await import('./UiRail.vue')).default;
+});
+after(() => uninstall());
+
+const items = [
+ { id: 'layers', label: 'DATA LAYERS', icon: 'layers' },
+ { id: 'scenes', label: 'AUTHORING', icon: 'movie', separatorBefore: true },
+ { id: 'cctv', label: 'CCTV', icon: 'videocam', disabled: true, badge: 3 },
+];
+
+function mountRail(props = {}) {
+ return mount(UiRail, {
+ props: { items, side: 'left', label: 'Left panels', ...props },
+ });
+}
+
+test('the rail is a named nav with one pressed button per item', () => {
+ const wrapper = mountRail({ active: 'scenes' });
+ const nav = wrapper.get('nav');
+ assert.equal(nav.attributes('aria-label'), 'Left panels');
+ assert.ok(nav.classes().includes('ui-rail--left'));
+ const buttons = wrapper.findAll('button');
+ assert.deepEqual(
+ buttons.map((button) => button.attributes('data-rail-item')),
+ ['layers', 'scenes', 'cctv'],
+ );
+ assert.deepEqual(
+ buttons.map((button) => button.attributes('aria-pressed')),
+ ['false', 'true', 'false'],
+ );
+ assert.equal(buttons[0].attributes('aria-label'), 'DATA LAYERS');
+ assert.equal(buttons[0].get('.material-symbols-outlined').text(), 'layers');
+ assert.ok(buttons[1].classes().includes('ui-rail__item--on'));
+ assert.equal(wrapper.findAll('.ui-rail__sep').length, 1);
+ assert.equal(buttons[2].attributes('disabled'), '');
+ assert.equal(buttons[2].get('.ui-rail__badge').text(), '3');
+});
+
+test('selecting an item emits its id', async () => {
+ const wrapper = mountRail();
+ await wrapper.get('[data-rail-item="scenes"]').trigger('click');
+ assert.deepEqual(wrapper.emitted('select'), [['scenes']]);
+});
+
+test('tips open toward the screen, and not for the open panel', async () => {
+ const wrapper = mountRail({
+ side: 'right',
+ active: 'layers',
+ panelOpen: true,
+ });
+ const [open, other] = wrapper.findAll('.ui-tooltip');
+ await open.trigger('focusin');
+ assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
+ await other.trigger('focusin');
+ // Over an open panel the other tips wait for the pointer to rest.
+ assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
+ await new Promise((resolve) => setTimeout(resolve, 450));
+ const tip = wrapper.get('[role="tooltip"]');
+ assert.equal(tip.text(), 'AUTHORING');
+ assert.ok(tip.classes().includes('ui-tooltip__tip--left'));
+ await other.trigger('focusout');
+ assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
+});
diff --git a/src/ui/kit/UiRail.vue b/src/ui/kit/UiRail.vue
new file mode 100644
index 000000000..af740aae3
--- /dev/null
+++ b/src/ui/kit/UiRail.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
diff --git a/src/ui/kit/UiTooltip.vue b/src/ui/kit/UiTooltip.vue
new file mode 100644
index 000000000..4c63f2316
--- /dev/null
+++ b/src/ui/kit/UiTooltip.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+ {{ text }}
+
+
diff --git a/src/ui/kit/rail.js b/src/ui/kit/rail.js
new file mode 100644
index 000000000..498628770
--- /dev/null
+++ b/src/ui/kit/rail.js
@@ -0,0 +1,12 @@
+/**
+ * @typedef {object} UiRailItem
+ * @property {string} id Item identity, emitted on selection.
+ * @property {string} label Translated name, shown as the tooltip and accessible name.
+ * @property {string} icon Material Symbols glyph.
+ * @property {boolean} [disabled] Shown but not selectable.
+ * @property {boolean} [separatorBefore] Draw a divider above this item.
+ * @property {string|number} [badge] Small count or marker on the icon.
+ */
+
+/** How long the pointer rests on an item before its tip shows over an open panel. */
+export const RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS = 400;
diff --git a/src/ui/styles/kit.css b/src/ui/styles/kit.css
new file mode 100644
index 000000000..ef65c1215
--- /dev/null
+++ b/src/ui/styles/kit.css
@@ -0,0 +1,100 @@
+/* Generic components in src/ui/kit. Names follow Halfpixel's ui-vue kit; the
+ surfaces use this application's glass, accent and type tokens. */
+
+.ui-icon {
+ display: inline-block;
+ line-height: 1;
+ font-variation-settings:
+ 'FILL' 0,
+ 'wght' 400,
+ 'GRAD' 0,
+ 'opsz' 20;
+ user-select: none;
+}
+
+.ui-tooltip {
+ position: relative;
+ display: inline-flex;
+}
+.ui-tooltip__tip {
+ position: absolute;
+ top: 50%;
+ z-index: 1;
+ padding: 4px 8px;
+ border: 1px solid var(--glass-border);
+ border-radius: 6px;
+ background: var(--menu-bg);
+ color: var(--dock-label-color);
+ font: var(--dock-label-font-weight) var(--dock-label-font-size) / 1.4
+ var(--font-sans);
+ letter-spacing: var(--dock-label-letter-spacing);
+ white-space: nowrap;
+ pointer-events: none;
+ transform: translateY(-50%);
+}
+.ui-tooltip__tip--right {
+ left: calc(100% + 10px);
+}
+.ui-tooltip__tip--left {
+ right: calc(100% + 10px);
+}
+
+.ui-rail {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 4px;
+ border: 1px solid var(--glass-border);
+ border-radius: calc(var(--btn-radius) + 4px);
+ background: var(--glass-bg);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ pointer-events: auto;
+}
+.ui-rail__sep {
+ height: 1px;
+ margin: 3px 6px;
+ background: var(--glass-border);
+}
+.ui-rail__item {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ border: 1px solid transparent;
+ border-radius: var(--btn-radius);
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition:
+ color var(--transition-fast),
+ background var(--transition-fast),
+ border-color var(--transition-fast);
+}
+.ui-rail__item:hover:not(:disabled) {
+ color: var(--text-primary);
+ background: rgba(255, 255, 255, 0.05);
+}
+.ui-rail__item--on,
+.ui-rail__item--on:hover:not(:disabled) {
+ border-color: var(--accent-dim);
+ background: var(--accent-dim);
+ color: var(--accent);
+}
+.ui-rail__item:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+.ui-rail__badge {
+ position: absolute;
+ top: 1px;
+ right: 1px;
+ min-width: 14px;
+ padding: 0 3px;
+ border-radius: 7px;
+ background: var(--accent);
+ color: var(--bg-dark);
+ font: 600 9px/14px var(--font-mono);
+ text-align: center;
+}
diff --git a/style.css b/style.css
index 1b55c459f..f887debb1 100644
--- a/style.css
+++ b/style.css
@@ -32,6 +32,7 @@
@import './src/ui/styles/weather.css';
@import './src/ui/styles/panel-surfaces.css';
@import './src/ui/styles/segmented-control.css';
+@import './src/ui/styles/kit.css';
@import './src/ui/styles/cjk.css';
@import './src/ui/styles/mobile-shell.css';
@import './src/ui/styles/cyber.css';