feat(workspace): frameless console that blends into its card
The tab bar and terminal lose their bordered box: the bar keeps one divider with an accent underline on the active tab, and the terminal is painted in the card's colour (--surface-3) instead of the page's. That made a long-standing overflow visible: xterm re-measures its cell only on a font option change or a resize, so a webfont landing after open left the fit on the fallback font's cell and the rows/cols spilled out of the pane. Re-set the family and refit on document.fonts loadingdone.
This commit is contained in:
@@ -99,20 +99,22 @@ export function ConsoleTabs({
|
|||||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-10 shrink-0 items-stretch rounded-t-lg border border-b-0 border-border bg-surface-2">
|
<div className="flex h-10 shrink-0 items-stretch border-b border-border">
|
||||||
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
|
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
|
||||||
{tabs.map((r) => {
|
{tabs.map((r) => {
|
||||||
const active = r.id === activeRunId;
|
const active = r.id === activeRunId;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className={`group relative flex min-w-0 max-w-[15rem] items-center border-r border-border ${
|
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
|
||||||
active
|
active
|
||||||
? "bg-surface-0 text-fg-primary"
|
? "text-fg-primary"
|
||||||
: "text-fg-muted hover:bg-surface-3 hover:text-fg-secondary"
|
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{active && <span className="absolute inset-x-0 top-0 h-0.5 bg-accent" />}
|
{active && (
|
||||||
|
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
|
|||||||
@@ -268,18 +268,31 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
|||||||
const reassert = () => sendSize();
|
const reassert = () => sendSize();
|
||||||
term.textarea?.addEventListener("focus", reassert);
|
term.textarea?.addEventListener("focus", reassert);
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const refit = () => {
|
||||||
// Never fit a collapsed console — see sendSize.
|
// Never fit a collapsed console — see sendSize.
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||||
fit.fit(); // a real change fires onResize, which reports it
|
fit.fit(); // a real change fires onResize, which reports it
|
||||||
});
|
};
|
||||||
|
const resizeObserver = new ResizeObserver(refit);
|
||||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||||
|
// A webfont landing after open changes the cell, not the container, so the
|
||||||
|
// observer never fires — and xterm re-measures its cell only on a font
|
||||||
|
// option change or a resize, so the fit kept the fallback font's cell and
|
||||||
|
// the rows/cols overflowed the pane. Re-setting the family (a trailing
|
||||||
|
// space keeps it a change) makes xterm measure again before the fit.
|
||||||
|
const onFontLoaded = () => {
|
||||||
|
const family = term.options.fontFamily ?? "";
|
||||||
|
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
||||||
|
refit();
|
||||||
|
};
|
||||||
|
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||||
term.textarea?.removeEventListener("focus", reassert);
|
term.textarea?.removeEventListener("focus", reassert);
|
||||||
resizeDisposable.dispose();
|
resizeDisposable.dispose();
|
||||||
dataDisposable.dispose();
|
dataDisposable.dispose();
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ const vars: Record<string, string> = {
|
|||||||
describe("buildTerminalTheme", () => {
|
describe("buildTerminalTheme", () => {
|
||||||
it("maps theme tokens onto the xterm palette", () => {
|
it("maps theme tokens onto the xterm palette", () => {
|
||||||
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
||||||
expect(t.background).toBe("#111113");
|
// The card's colour, so the terminal blends into the card it sits on.
|
||||||
|
expect(t.background).toBe("#272a2d");
|
||||||
|
expect(t.cursorAccent).toBe("#272a2d");
|
||||||
expect(t.foreground).toBe("#edeef0");
|
expect(t.foreground).toBe("#edeef0");
|
||||||
expect(t.cursor).toBe("#edeef0");
|
expect(t.cursor).toBe("#edeef0");
|
||||||
expect(t.selectionBackground).toBe("#43484e");
|
expect(t.selectionBackground).toBe("#43484e");
|
||||||
@@ -58,7 +60,7 @@ describe("buildTerminalTheme", () => {
|
|||||||
describe("terminalThemeFor", () => {
|
describe("terminalThemeFor", () => {
|
||||||
it("resolves variables on a dark probe scoped to the theme id, then removes it", () => {
|
it("resolves variables on a dark probe scoped to the theme id, then removes it", () => {
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.textContent = `.dark { --surface-0: 1 2 3; } .dark[data-theme="darcula"] { --surface-0: 30 31 32; }`;
|
style.textContent = `.dark { --surface-3: 1 2 3; } .dark[data-theme="darcula"] { --surface-3: 30 31 32; }`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
try {
|
try {
|
||||||
expect(terminalThemeFor("default").background).toBe("#010203");
|
expect(terminalThemeFor("default").background).toBe("#010203");
|
||||||
|
|||||||
@@ -54,10 +54,11 @@ export function buildTerminalTheme(read: VarReader): ITheme {
|
|||||||
const magenta = "#c678dd";
|
const magenta = "#c678dd";
|
||||||
const cyan = "#56b6c2";
|
const cyan = "#56b6c2";
|
||||||
return {
|
return {
|
||||||
background: v("--surface-0"),
|
// The `.card` colour: the console sits on a card, with no frame between.
|
||||||
|
background: v("--surface-3"),
|
||||||
foreground: v("--fg-primary"),
|
foreground: v("--fg-primary"),
|
||||||
cursor: v("--fg-primary"),
|
cursor: v("--fg-primary"),
|
||||||
cursorAccent: v("--surface-0"),
|
cursorAccent: v("--surface-3"),
|
||||||
selectionBackground: v("--border-light"),
|
selectionBackground: v("--border-light"),
|
||||||
black: v("--border-light"),
|
black: v("--border-light"),
|
||||||
red,
|
red,
|
||||||
|
|||||||
@@ -180,11 +180,7 @@ function ConsoleArea({
|
|||||||
actions={<LaneToolsMenu lane={lane} />}
|
actions={<LaneToolsMenu lane={lane} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="flex min-h-0 flex-1 flex-col pt-2">
|
||||||
className={`flex min-h-0 flex-1 flex-col border border-border bg-surface-0 p-2 ${
|
|
||||||
lane ? "rounded-b-lg" : "rounded-lg"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{slots.map((slot) => (
|
{slots.map((slot) => (
|
||||||
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
|
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
|
||||||
<LaneConsolePane
|
<LaneConsolePane
|
||||||
|
|||||||
@@ -5413,7 +5413,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
class="flex min-h-0 flex-1 flex-col"
|
class="flex min-h-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col border border-border bg-surface-0 p-2 rounded-lg"
|
class="flex min-h-0 flex-1 flex-col pt-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="contents"
|
class="contents"
|
||||||
|
|||||||
Reference in New Issue
Block a user