mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
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:
@@ -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 {
|
||||
|
||||
+33
-16
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>;
|
||||
widths: ReturnType<typeof mobileRuleWidths>;
|
||||
}> = [];
|
||||
postcss.parse(mobileCss).walkRules((rule) => {
|
||||
if (!rule.selector.includes('tab-cluster')) return;
|
||||
const decls: Record<string, string> = {};
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user