From 4bb333e9bc3f5f60480312ab2bb7f3273e03fcfb Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 06:15:19 +0200 Subject: [PATCH] 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) --- docs/tile-grid-plan.md | 9 +++ src/web/public/index.html | 2 +- src/web/public/styles.css | 81 +++++++++++++++++---- test/header-icon-hover.test.ts | 125 +++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 test/header-icon-hover.test.ts 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 @@ - + 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'); + } + }); +});