style(header): hover 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 cancelled it one by one (the font-size
buttons, notifications, the sidebar toggle).

The rule is gone, and with it those three overrides. Hover motion now moves
the icon only:
- the settings gear's icon turns 45 degrees (one tooth, so it lands on the
  same shape);
- the Tiles button's four squares spread apart, each toward its corner;
- the folder cross-fades to an open folder (a second drawing in its SVG,
  `.icon-folder-closed` / `.icon-folder-open`);
- every other icon just takes the hover colour.

Pointer devices only (`@media (hover: hover)`, so a tap cannot leave an icon
stuck mid-motion), and the transitions are off under reduced motion.

Owner request: the Tiles and folder buttons "weirdly turn" on hover.
Checked live on a dark and a light skin (rest, mid, end frames). Pinned by
test/header-icon-hover.test.ts, mutation-checked five ways (the button
rotation back, the open drawing missing, the motion not hover-gated, a
square spreading toward the wrong corner, reduced motion keeping its
transition). Gate: 507 files, 9798 tests passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 06:15:19 +02:00
parent fcec77131c
commit 4bb333e9bc
4 changed files with 202 additions and 15 deletions
+9
View File
@@ -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 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, 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. "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: - **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 "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 own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
+1 -1
View File
@@ -189,7 +189,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span> <span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
</button> </button>
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button> <button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button> <button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button> <button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" title="Tiles: show several sessions side by side (right-click for how many)" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button> <button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" title="Tiles: show several sessions side by side (right-click for how many)" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
+67 -14
View File
@@ -2450,7 +2450,73 @@ html[data-tab-orientation='vertical'].tab-rail-compact
.btn-icon-header:hover { .btn-icon-header:hover {
background: rgba(255, 255, 255, 0.07); background: rgba(255, 255, 255, 0.07);
color: var(--text); 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 /* 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; font-weight: 600;
} }
.btn-icon-header.btn-sm:hover {
transform: none;
}
.header-font-controls { .header-font-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -9195,10 +9257,6 @@ kbd {
margin-right: 2px; margin-right: 2px;
} }
.btn-icon-header.btn-notifications:hover {
transform: none;
}
.notification-badge { .notification-badge {
position: absolute; position: absolute;
top: 2px; top: 2px;
@@ -19172,11 +19230,6 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .ta
display: none !important; 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 { html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle svg {
transform: scaleX(-1); transform: scaleX(-1);
} }
+125
View File
@@ -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<string, string>;
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<string, string> = {};
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('</button>', 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(/<rect /g)?.length).toBe(4);
const expected = ['(-1.5px, -1.5px)', '(1.5px, -1.5px)', '(-1.5px, 1.5px)', '(1.5px, 1.5px)'];
expected.forEach((offset, i) => {
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');
}
});
});