fix(css): tablet By-case strip, compositor-only needs pulse, phone chip height

Tab Layout "By case" at tablet widths (600 to 767px, getDeviceType's
'tablet'): the case boxes could shrink in the tablet's one-row scrolling
strip, so they squeezed and wrapped their tabs inside themselves and every
tab past a box's first line was clipped under the fixed 48px header. The
boxes now dissolve into the chip row as they already do on phones, which
keeps the tablet's own 40px chip geometry. The rule sits in its own
600 to 767px block, not the 768px tablet block, so the desktop path at
768px and up (boxes keep their width, the strip wraps box by box) is
untouched.

Needs-you tile pulse: the glow animated box-shadow on the tile itself, so
the whole tile (DOM-rendered terminal rows included, the whole stage when
zoomed) was repainted every frame for as long as a prompt waited. The tile
keeps its red border; the glow is now a static inset shadow on a
.tile--needs::after overlay (inset because .tile is overflow: hidden and
clips an outer one, z-index 3 above .tile-attach, pointer-events none)
and only its opacity animates. The entering-and-needs animation shorthand
is gone, since it would now blink the whole tile's opacity, and reduced
motion keeps the static ring and hides the overlay.

Welcome chips: the phone block set 36px, below the 40px that styles.css
gives touch screens, and it wins on every phone, so phones got shorter
chips than tablets. It now restates 40px.

Tests: the tablet widths and the 768px boundary in tab-clusters, the
opacity-only pulse overlay in tile-grid-motion, and the phone chip height
in run-mode-ui; each fails against the previous CSS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 09:01:31 +02:00
parent 37ddcbe2f0
commit 41643f2b38
5 changed files with 222 additions and 21 deletions
+62 -2
View File
@@ -24,6 +24,7 @@
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import postcss, { type AtRule, type Rule } from 'postcss';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
@@ -279,7 +280,15 @@ describe('the CSS', () => {
};
it('every new keyframe animates opacity and transform only', () => {
for (const name of ['tile-enter', 'tile-ghost-dim', 'tile-leave', 'tile-leave-now', 'tile-loading-breathe', 'tile-count-menu-in']) {
for (const name of [
'tile-enter',
'tile-ghost-dim',
'tile-leave',
'tile-leave-now',
'tile-loading-breathe',
'tile-count-menu-in',
'tile-needs-pulse',
]) {
const body = keyframes(name);
expect(body, name).not.toBe('');
const props = [...body.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]);
@@ -300,11 +309,62 @@ describe('the CSS', () => {
it('nothing moves under prefers-reduced-motion, and a web tab hides the copy', () => {
const block = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce) {\n .tile-count-menu,'));
const reduced = block.slice(0, block.indexOf('\n}\n'));
for (const sel of ['.tile-count-menu', '.tile.tile--entering', '.tile.tile--loading .tile-body::after', '.tile-grid-ghosts .tile.tile--leaving']) {
for (const sel of [
'.tile-count-menu',
'.tile.tile--entering',
'.tile.tile--needs::after',
'.tile.tile--loading .tile-body::after',
'.tile-grid-ghosts .tile.tile--leaving',
]) {
expect(reduced).toContain(sel);
}
expect(reduced).toMatch(/animation: none;/);
expect(reduced).toMatch(/\.tile-body \.xterm \{\s*transition: none;/);
expect(css).toMatch(/\.main\.webview-active \.tile-grid-ghosts \{\s*display: none;/);
});
it('the needs-you pulse fades a static glow overlay in and out, never repainting the tile itself', () => {
// A needs tile pulses for as long as its prompt waits, hours at a time. A
// box-shadow animated on the tile repainted the whole tile (its DOM-rendered
// terminal rows with it, the whole stage when zoomed) every frame.
const rules: Array<{ selector: string; media: string; decls: Record<string, string> }> = [];
postcss.parse(css).walkRules((rule: Rule) => {
if (!rule.selector.includes('tile--needs')) return;
const decls: Record<string, string> = {};
rule.walkDecls((d) => {
decls[d.prop] = d.value;
});
const media = rule.parent?.type === 'atrule' ? (rule.parent as AtRule).params : '';
for (const selector of rule.selectors) rules.push({ selector, media, decls });
});
const find = (selector: string, media = '') => rules.filter((r) => r.selector === selector && r.media === media);
// The tile keeps its red border and animates nothing.
const [tile] = find('.tile.tile--needs');
expect(tile.decls['border-color']).toContain('var(--red');
expect(tile.decls.animation).toBeUndefined();
expect(tile.decls['box-shadow']).toBeUndefined();
// The glow is an overlay: a STATIC inset shadow (.tile is overflow: hidden
// and clips an outer one), above the Attach overlay, out of the pointer's way.
const [glow] = find('.tile.tile--needs::after');
expect(glow.decls.content).toBe("''");
expect(glow.decls.position).toBe('absolute');
expect(glow.decls.inset).toBe('0');
expect(glow.decls['pointer-events']).toBe('none');
expect(Number(glow.decls['z-index'])).toBeGreaterThan(2);
expect(glow.decls['box-shadow']).toMatch(/^inset /);
expect(glow.decls.animation).toMatch(/^tile-needs-pulse /);
// Only that overlay runs the pulse: no rule restates it on the tile (the old
// entering-and-needs shorthand would now blink the whole tile's opacity).
const pulsing = rules.filter((r) => /tile-needs-pulse/.test(r.decls.animation ?? ''));
expect(pulsing.map((r) => r.selector)).toEqual(['.tile.tile--needs::after']);
expect(rules.some((r) => r.selector.includes('tile--entering') && r.selector.includes('tile--needs'))).toBe(false);
// Reduced motion: no pulse, and a static ring on the tile instead.
const reduce = '(prefers-reduced-motion: reduce)';
expect(find('.tile.tile--needs::after', reduce).some((r) => r.decls.display === 'none')).toBe(true);
expect(find('.tile.tile--needs', reduce).some((r) => /^0 0 0 2px /.test(r.decls['box-shadow'] ?? ''))).toBe(true);
});
});