diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 0cfa04d8..1965c4b2 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1693,8 +1693,12 @@ Object.assign(CodemanApp.prototype, { disc.className = 'welcome-primary-logo'; disc.appendChild(logo(primary)); main.appendChild(disc); - // One raw string, kept whole: i18n.js matches the exact text node. - main.append(runLabel(primary)); + // One raw string, kept whole: i18n.js matches the exact text node. The span + // only lets a long custom label ellipsize (styles.css .welcome-label). + const mainLabel = document.createElement('span'); + mainLabel.className = 'welcome-label'; + mainLabel.textContent = runLabel(primary); + main.appendChild(mainLabel); main.onclick = launch(primary); container.appendChild(main); @@ -1715,7 +1719,10 @@ Object.assign(CodemanApp.prototype, { chip.title = runLabel(cli); chip.setAttribute('aria-label', runLabel(cli)); chip.appendChild(logo(cli)); - chip.append(cli.kind === 'shell' ? 'Shell' : cli.label); + const chipLabel = document.createElement('span'); + chipLabel.className = 'welcome-label'; + chipLabel.textContent = cli.kind === 'shell' ? 'Shell' : cli.label; + chip.appendChild(chipLabel); chip.onclick = launch(cli); chips.appendChild(chip); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 37acdc35..7ca9f790 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -4389,6 +4389,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { align-items: center; gap: 0.65rem; height: 46px; + max-width: 100%; padding: 0 1.4rem 0 0.45rem; box-sizing: border-box; border: 1px solid var(--accent); @@ -4416,7 +4417,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { /* OG has no designed --accent-ink (its Daylight extras are placeholders), and a dark ink on its brighter blue reads poorly: it gets the deeper blue and light - text of its own toolbar Run button instead. */ + text of its own toolbar Run button instead. These are that button's colours + (`.btn-toolbar.btn-run.mode-claude` and its :hover, below): keep the two in step. */ html[data-skin="og"] .welcome-primary { background: linear-gradient(135deg, #1e4fb8 0%, #2563eb 100%); border-color: rgba(96, 165, 250, 0.5); @@ -4462,11 +4464,22 @@ html[data-skin="og"] .welcome-primary:hover { max-width: 100%; } +/* A long custom label (clis.json) ellipsizes inside its button (both carry + max-width: 100%) instead of pushing a horizontal scrollbar into + .welcome-content. The span holds the label as ONE text node, so i18n.js still + matches it whole. */ +.welcome-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + .welcome-chip { display: inline-flex; align-items: center; gap: 0.45rem; height: 30px; + max-width: 100%; padding: 0 0.85rem 0 0.7rem; box-sizing: border-box; border: 1px solid var(--control-border); @@ -4497,17 +4510,17 @@ html[data-skin="og"] .welcome-primary:hover { quiet text link under it. settings-ui.js owns its contents (it rewrites the icon, label and spinner on every state change) and its inline display, so everything here is styled from the outside: the 20px svg it writes is sized - down, and the states are text colour only. Ahead of #welcomeQr on purpose, + down, and the states are text colour (plus a pill while a tunnel runs). Ahead of #welcomeQr on purpose, so the QR opens right under the control that toggled it. */ .welcome-tunnel-link { align-items: center; gap: 0.4rem; margin-top: 1.4rem; padding: 0.3rem 0.65rem; - border: none; - border-radius: 8px; + border: 1px solid transparent; + border-radius: 999px; background: none; - color: var(--text-muted); + color: var(--text-dim); font: inherit; font-size: 0.75rem; font-weight: 500; @@ -4525,6 +4538,10 @@ html[data-skin="og"] .welcome-primary:hover { width: 11px; height: 11px; border-width: 1.5px; + /* The shared spinner's track is translucent WHITE, which vanishes on the + light skins; draw it from the link's own text colour instead. */ + border-color: color-mix(in srgb, currentColor 25%, transparent); + border-top-color: currentColor; } .welcome-tunnel-link:hover { @@ -4538,10 +4555,22 @@ html[data-skin="og"] .welcome-primary:hover { color: var(--text); } +/* A running tunnel is a state, not a recoloured link: it gets a quiet green pill. */ .welcome-tunnel-link.active { + border-color: color-mix(in srgb, var(--green) 45%, transparent); + background: color-mix(in srgb, var(--green) 9%, transparent); color: var(--green); } +/* Touch screens wide enough for this view (tablets) get finger-sized chips. */ +@media (pointer: coarse) { + .welcome-chip { + height: 40px; + border-radius: 20px; + padding: 0 1rem 0 0.85rem; + } +} + .welcome-tunnel-link.connecting, .welcome-tunnel-link:disabled { cursor: wait; diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 7e384747..0d3c8394 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -479,7 +479,12 @@ describe('Codex quick start settings', () => { ); } const offeredModes = (container: any) => launchers(container).map((btn: any) => btn.dataset.mode); - const textOf = (btn: any) => btn.children.filter((c: unknown) => typeof c === 'string').join(''); + // The visible label sits in a .welcome-label span (it ellipsizes a long custom + // label) as one text node; a bare string child would be one too. + const textOf = (btn: any) => + btn.children + .map((c: any) => (typeof c === 'string' ? c : c?.className === 'welcome-label' ? c.textContent : '')) + .join(''); it('renders only enabled and available registry entries on the welcome screen', () => { const { app, welcomeCliActions, tunnelBtn } = loadUi({ ...ALL_OFF, claude: true, 'custom-agent': true }); @@ -530,6 +535,9 @@ describe('Codex quick start settings', () => { expect(textOf(primary)).toBe('Run Claude Code'); expect(chips.map(textOf)).toEqual(['Shell']); expect(chips.map((chip: any) => chip.title)).toEqual(['Run Shell']); + // Each label is its own span, so a long one ellipsizes instead of overflowing. + expect(primary.children.filter((c: any) => c?.className === 'welcome-label')).toHaveLength(1); + expect(chips[0].children.filter((c: any) => c?.className === 'welcome-label')).toHaveLength(1); }); it('promotes the first AGENT in catalog order to the primary, chosen by kind and order, never by id', () => { @@ -601,6 +609,13 @@ describe('Codex quick start settings', () => { expect(rule('.welcome-primary')).toContain('var(--accent-grad-a)'); expect(rule('.welcome-chip')).toContain('var(--control-bg)'); expect(rule('.welcome-chip')).toContain('var(--control-border)'); + // Light skins: the tunnel spinner's track follows the link's text colour (the + // shared track is translucent white), and a running tunnel reads as a pill. + expect(rule('.welcome-tunnel-link .tunnel-spinner')).toContain('currentColor'); + expect(rule('.welcome-tunnel-link.active')).toContain('background:'); + expect(rule('.welcome-label')).toContain('text-overflow: ellipsis'); + expect(rule('.welcome-primary')).toContain('max-width: 100%'); + expect(rule('.welcome-chip')).toContain('max-width: 100%'); // Hover motion stays off for anyone who asked for less of it. expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.welcome-primary,/); // The tunnel keeps its id and handler, is no longer a launcher, and sits