mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+66
-13
@@ -2450,9 +2450,75 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
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
|
||||||
class removed by admin-ui.js after identity boot). Deliberately BIG and
|
class removed by admin-ui.js after identity boot). Deliberately BIG and
|
||||||
prominent: it is the entry point to user/permission management. */
|
prominent: it is the entry point to user/permission management. */
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user