Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { type BrowserContext, expect, type Locator, type Page, test as base } fr
// nav-<id>, pane with data-pane-kind, chat, chat-item with data-kind,
// composer, question-form, queued-reply, artifacts-chip/-popover,
// attachment-chip, panel with data-panel, browser-pane with data-mode,
// chat-background, settings-nav-<id>, settings-<id>, theme-option with
// chat-background, header-picture with data-picture, header-picture-settings, settings-nav-<id>, settings-<id>, theme-option with
// data-theme-id, plugin-row and market-plugin with data-plugin,
// market-error, zoom-hud, leadline with data-leadline and data-state, and
// its leadline-row, -detail, -notice, -box, database with data-state, and
Expand Down
129 changes: 129 additions & 0 deletions app/e2e/header-picture.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { Locator } from "@playwright/test";

import { type App, expect, mockOnly, test } from "./fixtures";

// The header picture (Settings › Appearance › Header picture): the painting
// across the top of a new chat, an empty worktree and Home. The harbour by
// default; a plate, a picture of your own, or none.

// An 8×4 PNG, sky over sand, as a picture of your own.
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAgAAAAECAIAAAA8r+mnAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAGElEQVQImWOI6rmEFTHglDixqgIrwikBAAqpOiFhDrg6AAAAAElFTkSuQmCC", "base64");

// ?scene=first-prompt: the mock's agents at rest haven't been asked anything.
const OPEN = { params: { view: "conversation", scene: "first-prompt" } };

const band = (scope: Locator | App["page"]) => scope.locator("[data-testid=header-picture]:visible");

async function settings(app: App) {
const appearance = await app.openSettings("appearance");
const section = appearance.getByTestId("header-picture-settings");
await section.scrollIntoViewIfNeeded();
return section;
}

// A new chat: an agent that hasn't been asked anything yet.
async function newChat(app: App) {
await app.openWorktree("devl/order-export");
const pane = app.page.locator("[data-testid=pane]:visible").first();
await expect(pane.getByPlaceholder(/What should/).or(pane.getByRole("textbox")).first()).toBeVisible();
return pane;
}

test("a painting chosen in Settings tops a new chat, an empty worktree and Home, and stays after a reload", async ({ app }) => {
mockOnly("the first prompt is a mock scene");
await app.open({ ...OPEN, theme: "berth-light" });
const section = await settings(app);
const meadow = section.getByRole("group", { name: "Paintings" }).getByRole("button", { name: "Meadow" });
// The harbour until chosen otherwise.
await expect(section.getByRole("button", { name: "Harbour, by the time of day" })).toHaveAttribute("aria-pressed", "true");
await meadow.click();
await expect(meadow).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ headerPicture: { source: "builtin", builtin: "meadow" } });

// A new chat.
const chat = await newChat(app);
await expect(band(chat)).toHaveAttribute("data-picture", "meadow");
await expect(band(chat).locator("[data-drawn]")).toBeAttached();

// Home.
await app.page.getByTestId("nav-home").click();
await expect(band(app.page)).toHaveAttribute("data-picture", "meadow");

// An empty worktree: its launcher, once its tabs are closed.
await app.openWorktree("devl/qa-deck");
const tabs = app.page.locator("[data-tab-strip] [data-tab]:visible");
await expect(tabs.first()).toBeVisible();
while ((await tabs.count()) > 0) {
const n = await tabs.count();
await tabs.first().click({ button: "middle" });
await expect(tabs).toHaveCount(n - 1);
}
await expect(app.page.getByRole("button", { name: "New shell" })).toBeVisible();
await expect(band(app.page)).toHaveAttribute("data-picture", "meadow");

// A reload keeps it.
await app.page.reload();
await expect(app.page.getByTestId("nav-home")).toBeVisible();
await app.page.getByTestId("nav-home").click();
await expect(band(app.page)).toHaveAttribute("data-picture", "meadow");
});

test("a dark theme shows a painting's night", async ({ app }) => {
mockOnly("the first prompt is a mock scene");
const fetched: string[] = [];
app.page.on("request", (r) => /\/snowy-fjord/.test(r.url()) && fetched.push(r.url()));
await app.open({ ...OPEN, theme: "berth-dark", prefs: { headerPicture: { source: "builtin", builtin: "snowy-fjord" } } });
const chat = await newChat(app);
await expect(band(chat)).toHaveAttribute("data-picture", "snowy-fjord");
await expect(band(chat).locator("[data-drawn]")).toBeAttached();
// The night painting, and not the day's.
expect(fetched.some((u) => /snowy-fjord-night-[^/]*\.webp/.test(u))).toBe(true);
expect(fetched.some((u) => /snowy-fjord-(?!night)[^/]*\.webp/.test(u))).toBe(false);
});

test("a picture of your own tops a new chat", async ({ app }) => {
mockOnly("the first prompt is a mock scene");
await app.open(OPEN);
const section = await settings(app);
await section.getByLabel("Header picture file").setInputFiles({ name: "boat.png", mimeType: "image/png", buffer: PNG });
await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ headerPicture: { source: "image", image: { name: "boat.png" } } });
// It's a tile now, chosen, with where it sits.
await expect(section.getByRole("group", { name: "Custom" }).getByRole("button", { name: "boat.png", exact: true })).toHaveAttribute("aria-pressed", "true");
await section.getByRole("button", { name: "Top", exact: true }).click();
await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ headerPicture: { position: "top" } });
// The chat background is its own choice, still none.
await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ chatBackground: { source: "none" } });

const chat = await newChat(app);
await expect(band(chat)).toHaveAttribute("data-picture", "image");
// Read back from IndexedDB, as a blob: URL.
await expect(band(chat).locator("[data-drawn]")).toBeAttached({ timeout: 20_000 });
});

test("None leaves the top of a new chat and Home plain", async ({ app }) => {
mockOnly("the first prompt is a mock scene");
await app.open(OPEN);
const section = await settings(app);
await section.getByRole("button", { name: "None", exact: true }).click();
await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ headerPicture: { source: "none" } });

const chat = await newChat(app);
await expect(chat.getByRole("textbox").first()).toBeVisible();
await expect(band(chat)).toHaveCount(0);
await app.page.getByTestId("nav-home").click();
await expect(app.page.getByRole("heading", { name: "What should your agents work on?" })).toBeVisible();
await expect(band(app.page)).toHaveCount(0);

await app.page.reload();
await expect(app.page.getByRole("heading", { name: "What should your agents work on?" })).toBeVisible();
await expect(band(app.page)).toHaveCount(0);
});

test("the plates are chat backgrounds too", async ({ app }) => {
await app.open({ params: { view: "conversation" } });
const appearance = await app.openSettings("appearance");
const scene = appearance.getByRole("group", { name: "Scenes" }).getByRole("button", { name: "Lavender fields" });
await scene.click();
await expect(scene).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ chatBackground: { source: "builtin", builtin: "lavender-fields" }, headerPicture: { source: "builtin", builtin: "harbour" } });
});
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"perf:guard": "node perf/guard.mjs",
"perf:soak": "node perf/soak.mjs",
"perf:terminals": "node perf/terminals.mjs",
"test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/orchestrate-core.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/markdown-html.test.ts src/lib/file-refs.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts src/lib/browser-tabs.test.ts src/lib/callouts.test.ts src/lib/repo-refs.test.ts src/lib/chat-table.test.ts src/lib/test-results.test.ts src/lib/box-defaults-core.test.ts src/lib/code-blocks.test.ts src/lib/art/mermaid.test.ts"
"test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/orchestrate-core.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/header-picture.test.ts src/lib/file-match.test.ts src/lib/markdown-html.test.ts src/lib/file-refs.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts src/lib/browser-tabs.test.ts src/lib/callouts.test.ts src/lib/repo-refs.test.ts src/lib/chat-table.test.ts src/lib/test-results.test.ts src/lib/box-defaults-core.test.ts"
},
"dependencies": {
"@base-ui/react": "^1.8.0",
Expand Down
4 changes: 4 additions & 0 deletions app/src/components/art/chat-backgrounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import fog from "@/components/art/backgrounds/fog.webp";
import night from "@/components/art/backgrounds/night.webp";
import openSea from "@/components/art/backgrounds/open-sea.webp";
import { HARBOUR, type HarbourLight } from "@/components/art/harbour-art";
import { plateSrc } from "@/components/art/plates";
import { PLATES } from "@/lib/header-picture";

// The chat backgrounds Shipyard ships (lib/chat-background.ts). Patterns and
// gradients are drawn in code, in the theme's colours
Expand Down Expand Up @@ -34,6 +36,8 @@ export const BUILTINS: Builtin[] = [
{ id: "night", name: "Moorings at night", kind: "scene", src: () => night },
{ id: "open-sea", name: "Open sea", kind: "scene", src: () => openSea },
{ id: "fog", name: "Fog", kind: "scene", src: () => fog },
// The header picture's plates (lib/header-picture.ts), by day or night.
...PLATES.map((p): Builtin => ({ id: p.id, name: p.name, kind: "scene", src: (l) => plateSrc(p.id, l) })),
];

export const builtin = (id: string) => BUILTINS.find((b) => b.id === id) ?? BUILTINS[0];
21 changes: 15 additions & 6 deletions app/src/components/art/dither-band.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { cn } from "@/lib/utils";
// dither of its lightness, strongest along edges and in gradients and faint
// in flat areas, so it reads as the painting first and the dots second. The
// colour is left smooth (dithering each channel apart is what makes static).
// It can be muted toward the page's background, which calms a busy night.
// It can be muted toward the page's background, which calms a busy night,
// and dimmed, which brings a daylight picture into a dark theme.
// Toward its foot the picture dissolves dot by dot (a threshold ramp, not a
// fade) into whatever is behind it, so set the band on the page's background
// and it melts into it in light and dark alike.
Expand Down Expand Up @@ -35,10 +36,13 @@ export interface DitherBandProps {
fade?: number;
// How far to mute the picture toward the page's background, 0 to 1.
mute?: number;
// How far to darken it first, 0 to 1: its light falls but its contrast
// and hues stay, where muting alone would only grey it.
dim?: number;
className?: string;
}

export function DitherBand({ src, position = 0.5, cell = 2, levels = 6, fade = 0.4, mute = 0, className }: DitherBandProps) {
export function DitherBand({ src, position = 0.5, cell = 2, levels = 6, fade = 0.4, mute = 0, dim = 0, className }: DitherBandProps) {
const wrap = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const [drawn, setDrawn] = useState(false);
Expand Down Expand Up @@ -94,7 +98,7 @@ export function DitherBand({ src, position = 0.5, cell = 2, levels = 6, fade = 0
soft.width = soft.height = 0;
}
const frame = ctx.getImageData(0, 0, w, h);
dither(frame, levels, fade, mute, backgroundOf(el));
dither(frame, levels, fade, mute, dim, backgroundOf(el));
ctx.putImageData(frame, 0, 0);
setDrawn(true);
};
Expand All @@ -119,10 +123,10 @@ export function DitherBand({ src, position = 0.5, cell = 2, levels = 6, fade = 0
cv.width = 0;
cv.height = 0;
};
}, [src, position, cell, levels, fade, mute]);
}, [src, position, cell, levels, fade, mute, dim]);

return (
<div ref={wrap} aria-hidden className={cn("pointer-events-none overflow-hidden", className)}>
<div ref={wrap} aria-hidden data-drawn={drawn || undefined} className={cn("pointer-events-none overflow-hidden", className)}>
<canvas ref={canvas} className={cn("block [image-rendering:pixelated] transition-opacity duration-500", drawn ? "opacity-100" : "opacity-0")} />
</div>
);
Expand All @@ -144,12 +148,17 @@ function backgroundOf(el: HTMLElement): [number, number, number] {
// gradients, about a third in flat areas. Then it clears the dots below a
// ramp toward the foot, with the matrix shifted so the dissolve's pattern
// does not line up with the grain's.
function dither(frame: ImageData, levels: number, fade: number, mute: number, bg: [number, number, number]) {
function dither(frame: ImageData, levels: number, fade: number, mute: number, dim: number, bg: [number, number, number]) {
const { data, width: w, height: h } = frame;
const L = levels - 1;
const from = h * (1 - fade);
const lum = new Float32Array(w * h);
for (let i = 0, p = 0; p < w * h; i += 4, p++) {
if (dim) {
data[i] *= 1 - dim;
data[i + 1] *= 1 - dim;
data[i + 2] *= 1 - dim;
}
if (mute) {
data[i] += (bg[0] - data[i]) * mute;
data[i + 1] += (bg[1] - data[i + 1]) * mute;
Expand Down
6 changes: 3 additions & 3 deletions app/src/components/art/harbour-art.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ export type HarbourLight = "dawn" | "day" | "dusk" | "night";

export const HARBOUR: Record<HarbourLight, string> = { dawn, day, dusk, night };

// How far each light is muted toward the page in a DitherBand: the night
// most, so the stars and the moon's path stay calm.
export const HARBOUR_MUTE: Record<HarbourLight, number> = { dawn: 0.06, day: 0.04, dusk: 0.08, night: 0.3 };
// How far each light is muted toward the page lives with the header
// picture's choice (lib/header-picture.ts), which tests can read.
export { HARBOUR_MUTE } from "@/lib/header-picture";

function byClock(d = new Date()): HarbourLight {
const h = d.getHours();
Expand Down
Loading
Loading