diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md
index b319edc9..9e4a2b2d 100644
--- a/docs/tile-grid-plan.md
+++ b/docs/tile-grid-plan.md
@@ -68,6 +68,15 @@ or settled a question the spec left open. The invariants as built are in
every joining tile mounted and laid out before any connects (one fit, one PTY resize
each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab,
"Open group as tiles", Run) still add up to the cap of 6.
+- **Header icons move their icon on hover, never the button** (owner request: the Tiles
+ and folder buttons "weirdly turn" on hover; make a nicer hover). A global
+ `.btn-icon-header:hover { transform: rotate(45deg) }` (meant for the settings gear)
+ turned every header icon button, swinging its hover background into a diamond. Now
+ only the gear's ICON turns (45 degrees, one tooth), the Tiles button's four squares
+ spread apart, and the folder cross-fades to an open folder (`.icon-folder-closed` /
+ `.icon-folder-open` in its SVG); every other icon just takes the hover colour. Pointer
+ devices only (`@media (hover: hover)`), transitions off under reduced motion. Pinned by
+ `test/header-icon-hover.test.ts`.
- **The grid opens and closes with a short animation, on by default** (owner request:
"when clicking on the tile button first make this animation nicer"). It is the grid's
own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
diff --git a/src/web/public/index.html b/src/web/public/index.html
index d868c437..910f9316 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -189,7 +189,7 @@
0
-
+
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 1afe5454..ae470011 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -2450,7 +2450,73 @@ html[data-tab-orientation='vertical'].tab-rail-compact
.btn-icon-header:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--text);
- transform: rotate(45deg);
+}
+
+/* Hover motion moves the icon, never the button: turning the whole button used
+ to swing its hover background into a diamond on every header icon. Now only the
+ gear turns, the folder opens and the Tiles squares spread apart; every other
+ icon just takes the hover colour. Pointer devices only (a tap must not leave an
+ icon stuck mid-motion), and no motion at all under reduced motion. */
+.btn-icon-header.btn-settings svg,
+.btn-icon-header.btn-tile-grid svg rect,
+.btn-icon-header.btn-file-viewer svg .icon-folder-closed,
+.btn-icon-header.btn-file-viewer svg .icon-folder-open {
+ transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 160ms ease;
+}
+
+.btn-icon-header.btn-tile-grid svg rect {
+ transform-box: fill-box;
+ transform-origin: center;
+}
+
+.btn-icon-header.btn-file-viewer svg .icon-folder-open {
+ opacity: 0;
+ transform: translateY(1px);
+}
+
+@media (hover: hover) {
+ .btn-icon-header.btn-settings:hover svg {
+ transform: rotate(45deg);
+ }
+
+ .btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(1) {
+ transform: translate(-1.5px, -1.5px);
+ }
+
+ .btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(2) {
+ transform: translate(1.5px, -1.5px);
+ }
+
+ .btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(3) {
+ transform: translate(-1.5px, 1.5px);
+ }
+
+ .btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(4) {
+ transform: translate(1.5px, 1.5px);
+ }
+
+ .btn-icon-header.btn-file-viewer:hover svg .icon-folder-closed {
+ opacity: 0;
+ }
+
+ .btn-icon-header.btn-file-viewer:hover svg .icon-folder-open {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .btn-icon-header.btn-settings svg,
+ .btn-icon-header.btn-tile-grid svg rect,
+ .btn-icon-header.btn-file-viewer svg .icon-folder-closed,
+ .btn-icon-header.btn-file-viewer svg .icon-folder-open {
+ transition: none;
+ }
+
+ .btn-icon-header.btn-settings:hover svg,
+ .btn-icon-header.btn-tile-grid:hover svg rect {
+ transform: none;
+ }
}
/* Admin Panel header button: only shown to admins in multi-user mode (marker
@@ -2569,10 +2635,6 @@ html[data-tab-orientation='vertical'].tab-rail-compact
font-weight: 600;
}
-.btn-icon-header.btn-sm:hover {
- transform: none;
-}
-
.header-font-controls {
display: flex;
align-items: center;
@@ -9195,10 +9257,6 @@ kbd {
margin-right: 2px;
}
-.btn-icon-header.btn-notifications:hover {
- transform: none;
-}
-
.notification-badge {
position: absolute;
top: 2px;
@@ -19172,11 +19230,6 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .ta
display: none !important;
}
-/* .btn-icon-header:hover rotates 45deg globally — a panel glyph must not spin. */
-.btn-sidebar-toggle:hover {
- transform: none;
-}
-
html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle svg {
transform: scaleX(-1);
}
diff --git a/test/header-icon-hover.test.ts b/test/header-icon-hover.test.ts
new file mode 100644
index 00000000..74088b80
--- /dev/null
+++ b/test/header-icon-hover.test.ts
@@ -0,0 +1,125 @@
+/**
+ * @fileoverview Header icon buttons: hover motion moves the icon, never the button.
+ *
+ * A global `.btn-icon-header:hover { transform: rotate(45deg) }` (meant for the
+ * settings gear) turned EVERY header icon button on hover, so the folder, Tiles,
+ * Split and the rest swung their rounded hover background into a diamond. Three
+ * buttons had already been patched one by one with `transform: none`. The owner
+ * asked for a nicer hover on the folder and Tiles buttons; this file pins the
+ * shape of the fix:
+ *
+ * 1. No rule on a header icon button transforms the BUTTON on hover.
+ * 2. Only the gear's icon turns; the Tiles squares spread apart; the folder
+ * cross-fades from closed to open (two drawings in its SVG).
+ * 3. Those motions live inside `@media (hover: hover)`, so a tap on a touch
+ * screen cannot leave an icon stuck mid-motion, and reduced motion turns the
+ * transitions off.
+ */
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import postcss, { type AtRule, type Rule } from 'postcss';
+
+const ROOT = resolve(__dirname, '..');
+const css = readFileSync(resolve(ROOT, 'src/web/public/styles.css'), 'utf8');
+const html = readFileSync(resolve(ROOT, 'src/web/public/index.html'), 'utf8');
+const root = postcss.parse(css);
+
+interface FoundRule {
+ selector: string;
+ decls: Record;
+ media: string[];
+}
+
+function rules(): FoundRule[] {
+ const out: FoundRule[] = [];
+ root.walkRules((rule: Rule) => {
+ const media: string[] = [];
+ for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) {
+ if (p.type === 'atrule' && (p as AtRule).name === 'media') media.push((p as AtRule).params);
+ }
+ const decls: Record = {};
+ rule.walkDecls((d) => {
+ decls[d.prop] = d.value;
+ });
+ for (const selector of rule.selectors) out.push({ selector, decls, media });
+ });
+ return out;
+}
+
+const all = rules();
+const iconRules = all.filter((r) => r.selector.includes('btn-icon-header'));
+
+function buttonTag(cls: string): string {
+ const i = html.indexOf(`class="btn-icon-header ${cls}`);
+ expect(i, `${cls} button in index.html`).toBeGreaterThan(-1);
+ return html.slice(i, html.indexOf('', i));
+}
+
+describe('header icon hover', () => {
+ it('never transforms the button itself on hover', () => {
+ const offenders = iconRules.filter(
+ (r) => /:hover\s*$/.test(r.selector) && r.decls.transform && r.decls.transform !== 'none'
+ );
+ expect(offenders.map((r) => `${r.selector} { transform: ${r.decls.transform} }`)).toEqual([]);
+ });
+
+ it('turns only the gear icon, and only on pointer devices', () => {
+ const rotations = iconRules.filter((r) => /rotate\(/.test(r.decls.transform || ''));
+ expect(rotations.map((r) => r.selector)).toEqual(['.btn-icon-header.btn-settings:hover svg']);
+ expect(rotations[0].media).toContain('(hover: hover)');
+ });
+
+ it('spreads the four Tiles squares apart, each toward its own corner', () => {
+ const svg = buttonTag('btn-tile-grid');
+ expect(svg.match(/ {
+ const rule = iconRules.find(
+ (r) => r.selector === `.btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(${i + 1})`
+ );
+ expect(rule?.decls.transform, `rect ${i + 1}`).toBe(`translate${offset}`);
+ expect(rule?.media).toContain('(hover: hover)');
+ });
+ const box = iconRules.find(
+ (r) => r.selector === '.btn-icon-header.btn-tile-grid svg rect' && r.decls['transform-box']
+ );
+ expect(box?.decls['transform-origin']).toBe('center');
+ });
+
+ it('opens the folder: the open drawing is hidden at rest and replaces the closed one on hover', () => {
+ const svg = buttonTag('btn-file-viewer');
+ expect(svg).toContain('class="icon-folder-closed"');
+ expect(svg).toContain('class="icon-folder-open"');
+ const rest = iconRules.find(
+ (r) => r.selector === '.btn-icon-header.btn-file-viewer svg .icon-folder-open' && r.decls.opacity
+ );
+ expect(rest?.decls.opacity).toBe('0');
+ expect(rest?.media).toEqual([]);
+ const hoverClosed = iconRules.find(
+ (r) => r.selector === '.btn-icon-header.btn-file-viewer:hover svg .icon-folder-closed'
+ );
+ const hoverOpen = iconRules.find(
+ (r) => r.selector === '.btn-icon-header.btn-file-viewer:hover svg .icon-folder-open'
+ );
+ expect(hoverClosed?.decls.opacity).toBe('0');
+ expect(hoverOpen?.decls.opacity).toBe('1');
+ expect(hoverClosed?.media).toContain('(hover: hover)');
+ expect(hoverOpen?.media).toContain('(hover: hover)');
+ });
+
+ it('turns the motion off under reduced motion', () => {
+ const reduced = iconRules.filter((r) => r.media.includes('(prefers-reduced-motion: reduce)'));
+ for (const sel of [
+ '.btn-icon-header.btn-settings svg',
+ '.btn-icon-header.btn-tile-grid svg rect',
+ '.btn-icon-header.btn-file-viewer svg .icon-folder-closed',
+ '.btn-icon-header.btn-file-viewer svg .icon-folder-open',
+ ]) {
+ expect(reduced.find((r) => r.selector === sel)?.decls.transition, sel).toBe('none');
+ }
+ for (const sel of ['.btn-icon-header.btn-settings:hover svg', '.btn-icon-header.btn-tile-grid:hover svg rect']) {
+ expect(reduced.find((r) => r.selector === sel)?.decls.transform, sel).toBe('none');
+ }
+ });
+});