diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index d2a34848..e9cce7ff 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -334,6 +334,27 @@ html.mobile-init .file-browser-panel { } } +/* Tab Layout "By case" at tablet widths, getDeviceType()'s 'tablet' (600 to + 767px). The strip is the tablet block's one scrolling row and + updateTabOverflowMode() never wraps it here, so the boxes, which may shrink + below 768px, squeezed and wrapped their tabs inside themselves instead of + overflowing: the strip never scrolled, and every tab past a box's first + line was clipped under the fixed header. The boxes dissolve into the chip + row as on phones (the 599px block), which keeps the tablet's own 40px chip + geometry; a 44px box would hang below the 48px header. Ends at 767px, not + at the tablet block's 768: from 768 getDeviceType() says 'desktop', and the + desktop rule in styles.css (flex-shrink: 0, wrapping box by box) owns the + strip. */ +@media (min-width: 600px) and (max-width: 767px) { + :where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster { + display: contents; + } + + :where(.header) .tab-cluster-label { + display: none; + } +} + /* Edge fade for the phone's header tab strip (used in the block below). Registered so the keyframes can interpolate them as lengths; @property is only valid at the top level, hence out here. */ @@ -1718,7 +1739,10 @@ html.mobile-init .file-browser-panel { } /* Only reached with the phone overview switched off. The primary spans the - column; the chips keep wrapping, a little taller for a thumb. */ + column; the chips keep wrapping at the finger size styles.css gives touch + screens (40px, its pointer: coarse rule). This rule loads later at equal + specificity, so a lower value here made a phone's chips shorter than a + tablet's; restating 40px also covers a narrow window with a mouse. */ .welcome-primary { width: 100%; justify-content: center; @@ -1727,8 +1751,8 @@ html.mobile-init .file-browser-panel { } .welcome-chip { - height: 36px; - border-radius: 18px; + height: 40px; + border-radius: 20px; } .history-show-more { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 91f345b4..85cb0e1b 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -4519,7 +4519,8 @@ html[data-skin="og"] .welcome-primary:hover { color: var(--green); } -/* Touch screens wide enough for this view (tablets) get finger-sized chips. */ +/* Touch screens get finger-sized chips. Phones too: mobile.css's 599px block + loads later and wins at equal specificity, so it restates the 40px. */ @media (pointer: coarse) { .welcome-chip { height: 40px; @@ -19741,29 +19742,50 @@ body.tile-grid-resizing--row * { color: var(--accent, #4a9eff); } -/* A permission prompt or question, visible across the room. Only the border - colour and a box-shadow animate: anything that moves layout would resize - the tile's PTY every frame. */ +/* A permission prompt or question, visible across the room: a red border and + a glow that pulses for as long as the prompt waits, which can be hours. + Anything that moves layout would resize the tile's PTY every frame, and a + box-shadow animated on the tile itself repainted the whole tile (its DOM + rendered terminal rows included, the whole stage when zoomed) every frame. + So the glow is a STATIC shadow on an overlay and only its opacity + animates, which the compositor runs without repainting the tile. The + shadow is inset because .tile is overflow: hidden and would clip an outer + one on its own pseudo-element. Above .tile-attach (z-index 2), and never + in the way of a click. */ .tile.tile--needs { border-color: var(--red, #ef4444); +} + +.tile.tile--needs::after { + content: ''; + position: absolute; + inset: 0; + z-index: 3; + border-radius: inherit; + pointer-events: none; + box-shadow: inset 0 0 10px 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent); + opacity: 0; animation: tile-needs-pulse 1.6s ease-in-out infinite; } @keyframes tile-needs-pulse { 0%, 100% { - box-shadow: 0 0 0 0 color-mix(in srgb, var(--red, #ef4444) 0%, transparent); + opacity: 0; } 50% { - box-shadow: 0 0 10px 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent); + opacity: 1; } } @media (prefers-reduced-motion: reduce) { .tile.tile--needs { - animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent); } + + .tile.tile--needs::after { + display: none; + } } /* The tab of a tiled session (the focused tile's tab is .active as usual). */ @@ -20030,13 +20052,6 @@ body.tile-grid-resizing--row * { animation-delay: calc(var(--tile-enter-index, 0) * 24ms); } -/* A tile that needs you enters AND pulses (one animation property). */ -.tile.tile--entering.tile--needs { - animation: - tile-enter 180ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--tile-enter-index, 0) * 24ms) both, - tile-needs-pulse 1.6s ease-in-out infinite; -} - @keyframes tile-enter { from { opacity: 0; @@ -20121,7 +20136,7 @@ body.tile-grid-resizing--row * { @media (prefers-reduced-motion: reduce) { .tile-count-menu, .tile.tile--entering, - .tile.tile--entering.tile--needs, + .tile.tile--needs::after, .tile.tile--loading .tile-body::after, .tile-grid-ghosts .tile.tile--leaving, .tile-grid-ghosts.tile-grid-ghosts--release .tile.tile--leaving, @@ -20981,7 +20996,9 @@ html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .ta the routes ran through tabs, labels and box borders. Only a case wider than the whole strip still wraps inside its box (max-width: 100%), and the strip wraps around it too (_tabClustersWrapInside). The narrower screens keep - their scrolling strip. */ + their scrolling strip: mobile.css dissolves the boxes into its chip row on + phones (599px block) and on tablets (600 to 767px block), since a box + allowed to shrink there wrapped its tabs out of the one-row strip. */ @media (min-width: 768px) { .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster { flex-shrink: 0; diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 12cc0ef4..64537600 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; +import postcss, { type AtRule } from 'postcss'; import { describe, expect, it } from 'vitest'; function loadRunModeHarness() { @@ -638,6 +639,29 @@ describe('Codex quick start settings', () => { expect(html.indexOf('id="welcomeQr"')).toBeGreaterThan(tunnel); }); + it('never gives a phone shorter launcher chips than a tablet', () => { + // styles.css sizes chips for touch screens under (pointer: coarse); + // mobile.css's phone block loads later at equal specificity and wins on + // every phone, so a lower height there shrank the phone's chips below the + // tablet's (36px against 40px). + const chipHeights = (file: string, media: string) => { + const heights: number[] = []; + const css = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8'); + postcss.parse(css).walkRules('.welcome-chip', (rule) => { + if (rule.parent?.type !== 'atrule' || (rule.parent as AtRule).params !== media) return; + rule.walkDecls('height', (d) => { + heights.push(parseFloat(d.value)); + }); + }); + return heights; + }; + const touch = chipHeights('styles.css', '(pointer: coarse)'); + const phone = chipHeights('mobile.css', '(max-width: 599px)'); + expect(touch).toEqual([40]); + expect(phone.length).toBeGreaterThan(0); + for (const h of phone) expect(h).toBeGreaterThanOrEqual(Math.max(...touch)); + }); + it('falls back to the first ENABLED agent when the chosen run mode is disabled, never a hardcoded claude', () => { const catalog = CATALOG.map((cli) => cli.id === 'claude' || cli.id === 'codex' ? { ...cli, enabled: false } : cli diff --git a/test/tab-clusters.test.ts b/test/tab-clusters.test.ts index 21028e1f..8ff69d57 100644 --- a/test/tab-clusters.test.ts +++ b/test/tab-clusters.test.ts @@ -24,6 +24,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; +import postcss, { type AtRule, type Rule } from 'postcss'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const PUBLIC = join(process.cwd(), 'src/web/public'); @@ -350,4 +351,79 @@ describe('tab layouts by case and ledger (static)', () => { ); expect(mobileCss).toMatch(/:where\(\.header\) \.tab-cluster-label \{\s*display: none;/); }); + + // The width range a mobile.css rule applies in: the file's own link media + // (max-width: 1023px) narrowed by every @media around the rule. `other` marks + // a rule that also needs a non-width feature, which therefore cannot be + // counted on to apply. + const mobileRuleWidths = (rule: Rule) => { + let min = 0; + let max = 1023; + let other = false; + for (let node = rule.parent; node && node.type !== 'root'; node = node.parent) { + if (node.type !== 'atrule') continue; + const at = node as AtRule; + if (at.name !== 'media') { + other = true; + continue; + } + for (const part of at.params.split(/\s+and\s+/)) { + const m = part.trim().match(/^\((min|max)-width:\s*(\d+)px\)$/); + if (!m) other = true; + else if (m[1] === 'min') min = Math.max(min, Number(m[2])); + else max = Math.min(max, Number(m[2])); + } + } + return { min, max, other }; + }; + const mobileClusterRules = () => { + const rules: Array<{ + selector: string; + decls: Record; + widths: ReturnType; + }> = []; + postcss.parse(mobileCss).walkRules((rule) => { + if (!rule.selector.includes('tab-cluster')) return; + const decls: Record = {}; + rule.walkDecls((d) => { + decls[d.prop] = d.value; + }); + rules.push({ selector: rule.selector, decls, widths: mobileRuleWidths(rule) }); + }); + return rules; + }; + + it('dissolves the boxes on tablet widths too, where the strip is one scrolling row (600 to 767px)', () => { + // getDeviceType() says 'tablet' from 600 to 767px, and updateTabOverflowMode() + // never wraps the strip there. A box allowed to shrink in that one-row strip + // squeezed and wrapped its tabs inside itself, and every tab past a box's + // first line was clipped under the 48px fixed header, unreachable. + const rules = mobileClusterRules(); + const box = '.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster'; + for (const width of [360, 599, 600, 700, 744, 767]) { + const applies = (r: (typeof rules)[number]) => !r.widths.other && r.widths.min <= width && width <= r.widths.max; + expect( + rules.some((r) => applies(r) && r.selector === `:where(.header) ${box}` && r.decls.display === 'contents'), + `box dissolved at ${width}px` + ).toBe(true); + expect( + rules.some( + (r) => applies(r) && r.selector === ':where(.header) .tab-cluster-label' && r.decls.display === 'none' + ), + `label hidden at ${width}px` + ).toBe(true); + } + }); + + it('leaves the desktop strip (768px and up) to styles.css: no mobile.css cluster rule reaches it', () => { + // mobile.css is linked up to 1023px, and from 768 getDeviceType() says + // 'desktop': the boxes keep their width there and the strip wraps box by + // box. The tablet block of mobile.css runs to 768 inclusive, so a cluster + // rule put inside it would dissolve the boxes at exactly 768. + const rules = mobileClusterRules(); + expect(rules.length).toBeGreaterThan(0); + for (const r of rules) { + expect(r.widths.max, `${r.selector} stops below 768px`).toBeLessThan(768); + } + }); }); diff --git a/test/tile-grid-motion.test.ts b/test/tile-grid-motion.test.ts index d2f1985a..d2a93ed9 100644 --- a/test/tile-grid-motion.test.ts +++ b/test/tile-grid-motion.test.ts @@ -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 }> = []; + postcss.parse(css).walkRules((rule: Rule) => { + if (!rule.selector.includes('tile--needs')) return; + const decls: Record = {}; + 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); + }); });