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
4 changes: 2 additions & 2 deletions apps/web/src/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -698,8 +698,8 @@ export function Chat(
id={cueId}
>
{sendError
? <WarningIcon className="icon-danger" size={14} />
: <InfoIcon size={14} />}
? <WarningIcon className="icon-danger icon-first-line" size={14} />
: <InfoIcon className="icon-first-line" size={14} />}
<span>
{sendError ?? (researchBlock
? researchCopy[researchBlock]
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/chat/work-progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ function ToolCall(
let row = (
<>
{hasDetails && (
<ChevronIcon aria-hidden="true" className={open ? "rotate-90" : ""} size={14} />
<ChevronIcon
aria-hidden="true"
className={open ? "icon-first-line rotate-90" : "icon-first-line"}
size={14}
/>
)}
<span className="min-w-0 flex-1 break-words font-mono text-text-secondary">
{toolCopy(tool.name)}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/icon-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,10 @@ test("directional controls reuse one chevron and one panel icon", () => {
expect(existsSync(join(root, "assets/icons/tool-chevron-right.svg"))).toBe(false);
expect(existsSync(join(root, "assets/figma/navigation/sidebar-right-3-hide.svg"))).toBe(false);
});

test("icons never shrink beside wrapping text", () => {
let css = readFileSync(join(root, "theme.css"), "utf8");
expect(css).toMatch(
/@layer base \{\s*:is\(\[data-nucleo-icon\], \[data-filled-icon\]\) \{\s*flex-shrink: 0;/,
);
});
17 changes: 17 additions & 0 deletions apps/web/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* `.ts` is included because shared class lists can live outside components.
*/
@source "../../../packages/editor/src/**/*.{ts,tsx}";
@source "../../../packages/icons/src/**/*.{ts,tsx}";
@source "../../../packages/question/src/**/*.{ts,tsx}";

@utility ring-hairline {
Expand All @@ -22,6 +23,22 @@
color: var(--color-icon);
}

/*
* Every icon keeps its declared size: as a flex item it would otherwise shrink
* when the text beside it wraps. In the base layer so a utility can still opt out.
*/
@layer base {
:is([data-nucleo-icon], [data-filled-icon]) {
flex-shrink: 0;
}
}

/* Beside text that may wrap: centre on the first line instead of the whole block. */
@utility icon-first-line {
align-self: flex-start;
translate: 0 calc(0.5lh - 50%);
}

@utility icon-danger {
--color-icon: var(--color-danger-icon);
}
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

116 changes: 116 additions & 0 deletions e2e/icon-size.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Icons keep their declared size beside text that wraps.
*
* Each icon is a flex item; without `flex-shrink: 0` a long label squeezes it
* narrower than its height. Layout is browser behaviour, so this measures every
* visible icon on the surfaces where labels wrap, at phone width.
*/

import { content, expect, test } from "./room";
import { CHOSEN, SETTLED_ANSWER, SOURCE, STATE } from "./source/icon-size.fixture";

import type { Locator, Page } from "@playwright/test";

/** Icons whose rendered box differs from the size they were declared at. */
function squeezed(scope: Locator) {
return scope.evaluateAll(roots =>
roots.flatMap(root =>
[...root.querySelectorAll("svg[width]")].flatMap(icon => {
let box = icon.getBoundingClientRect();
if (box.width === 0 && box.height === 0) return [];
let size = Number(icon.getAttribute("width"));
return Math.abs(box.width - size) > 0.5 || Math.abs(box.height - size) > 0.5
? [
`${box.width.toFixed(1)}×${box.height.toFixed(1)}, declared ${size}: ${
root.textContent?.slice(0, 60)
}`,
]
: [];
})
)
);
}

/** How far an icon's centre sits from the centre of its row's first text line. */
function firstLineOffset(row: Locator) {
return row.evaluate(element => {
let icon = element.querySelector("svg")!.getBoundingClientRect();
let top = element.getBoundingClientRect().top;
let line = Number.parseFloat(getComputedStyle(element).lineHeight);
return Math.abs(icon.top + icon.height / 2 - (top + line / 2));
});
}

async function phone(page: Page) {
await page.setViewportSize({ width: 390, height: 844 });
}

test("icons keep their size and sit on the first line beside wrapped decision text", async ({ join, seed }) => {
await seed(SOURCE, STATE);
let page = await join("ana");
await phone(page);

let settled = content(page).locator("[data-card-settled]");
await expect(settled).toContainText(SETTLED_ANSWER);
expect((await settled.boundingBox())!.height).toBeGreaterThan(30);
expect(await squeezed(settled)).toEqual([]);
expect(await firstLineOffset(settled)).toBeLessThan(1.5);

await page.getByRole("button", { name: `Decision: ${CHOSEN}` }).click();
let dialog = page.getByRole("dialog", { name: "Decision" });
await expect(dialog).toContainText("Phased over a whole quarter");
let answers = dialog.locator(".plan-decision-answer");
await expect(answers).toHaveCount(3);
expect(await squeezed(dialog)).toEqual([]);
for (let answer of await answers.all()) {
expect(await firstLineOffset(answer)).toBeLessThan(1.5);
}
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);

await page.getByRole("button", { name: /^Decisions/ }).click();
let decisions = page.locator('[data-document-view="decisions"]');
await decisions.getByRole("button", { name: /resolved/ }).click();
await expect(decisions).toContainText("How should we roll this out?");
await expect.poll(() => squeezed(decisions)).toEqual([]);
});

test("header, menu, Chat and sidebar icons keep their size when every row wraps", async ({ join, seed }) => {
await seed(SOURCE, STATE);
let page = await join("ana");
await phone(page);

// Squeeze every row the way a long title or label would.
let narrow = (scope: Locator) =>
scope.evaluateAll(roots => {
for (let root of roots) {
for (let icon of root.querySelectorAll("svg[width]")) {
let row = icon.parentElement;
if (row && getComputedStyle(row).display.includes("flex")) row.style.maxWidth = "2.5rem";
}
}
});

let header = page.getByRole("banner");
await narrow(header);
await expect.poll(() => squeezed(header)).toEqual([]);

await page.getByRole("button", { name: /^Actions for / }).click();
let menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await narrow(menu);
await expect.poll(() => squeezed(menu)).toEqual([]);
await page.keyboard.press("Escape");

await page.getByRole("button", { name: "Chat", exact: true }).click();
let chat = page.getByRole("complementary", { name: "Chat" });
await expect(chat.getByRole("combobox", { name: "Message" })).toBeVisible();
await narrow(chat);
await expect.poll(() => squeezed(chat)).toEqual([]);

await page.getByRole("button", { name: "Show sidebar" }).click();
let sidebar = page.getByRole("navigation", { name: /project|sidebar|documents/i });
await expect(sidebar.first()).toBeVisible();
await narrow(sidebar);
await expect.poll(() => squeezed(sidebar)).toEqual([]);
});
103 changes: 103 additions & 0 deletions e2e/source/icon-size.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/** A document whose decisions carry option labels long enough to wrap beside their icons. */

import { createHash } from "node:crypto";

export const PROSE = "The rollout goes team by team, starting with the documentation team.";
export const RESOLVED = "01K0N4TR8K7JGM4R1J7PW4R8YJ";
export const SETTLED = "01K0N4TR8K7JGM4R1J7PW4R8YK";
const RESOLVED_QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2";
const SETTLED_QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B3";
const AT = "2026-09-23T15:13:00.000Z";

export const CHOSEN =
"Team by team, starting with the documentation team and widening after a review";
const LABELS = [
"All at once",
CHOSEN,
"Phased over a whole quarter with an opt-in beta for the teams who ask first",
];
export const SETTLED_ANSWER =
"Keep room state on disk as canonical MDX beside the transcript and its sidecar";

function digest(text: string): string {
return `sha256:${createHash("sha256").update(`${text}\n`).digest("hex")}`;
}

function optionId(question: string, index: number): string {
return `${question.slice(0, 22)}${question.slice(-2)}0${index}`;
}

function definition(question: string, prompt: string, labels: string[]) {
return {
questions: [{
id: question,
header: "Rollout",
question: prompt,
multiple: false,
options: labels.map((label, index) => ({
id: optionId(question, index),
label,
description: "",
})),
}],
};
}

const RESOLVED_CARD = `<Questionnaire id="${RESOLVED}" by="ana" at="${AT}">
<Question id="${RESOLVED_QUESTION}" header="Rollout" prompt="How should we roll this out?" multiple="false">
${
LABELS.map((label, index) =>
`<Option id="${optionId(RESOLVED_QUESTION, index)}" label="${label}" />`
).join("\n")
}
<Answer value="${CHOSEN}" />
</Question>
</Questionnaire>`;

const SETTLED_CARD = `<Questionnaire id="${SETTLED}" by="ana" status="decided">
<Question id="${SETTLED_QUESTION}" header="Storage" prompt="Where should room state live?" multiple="false">
<Option id="${optionId(SETTLED_QUESTION, 0)}" label="${SETTLED_ANSWER}" />
<Answer value="${SETTLED_ANSWER}" choices="${optionId(SETTLED_QUESTION, 0)}" />
</Question>
</Questionnaire>`;

export const SOURCE = `${PROSE}\n\n${RESOLVED_CARD}\n\n${SETTLED_CARD}\n\n${
"Padding paragraph.\n\n".repeat(20)
}`;

export const STATE = {
revision: 1,
questions: [
{
id: RESOLVED,
status: "answered",
definition: definition(RESOLVED_QUESTION, "How should we roll this out?", LABELS),
answers: { [RESOLVED_QUESTION]: CHOSEN },
resolver: "ana",
at: Date.parse(AT) / 1_000,
anchors: {
widget: RESOLVED,
questions: {
[RESOLVED_QUESTION]: {
anchors: [{ epoch: "stale", position: "", digest: digest(PROSE) }],
pending: false,
},
},
},
},
{
id: SETTLED,
definition: definition(SETTLED_QUESTION, "Where should room state live?", [SETTLED_ANSWER]),
status: "answered",
answers: { [SETTLED_QUESTION]: SETTLED_ANSWER },
choices: [optionId(SETTLED_QUESTION, 0)],
resolver: "ana",
owner: "ana",
decidedAt: 1_790_000_000,
history: [],
optionOrigins: {},
editors: [],
origin: "conversation",
},
],
};
6 changes: 3 additions & 3 deletions packages/editor/src/resolved-layer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,15 @@ function Popover(
</span>
</div>
<p className="plan-decision-answer">
<CheckIcon aria-hidden="true" />
<CheckIcon aria-hidden="true" className="icon-first-line" />
<span>{decision.answer}</span>
</p>
{others.length > 0 && (
<div className="plan-decision-fold" inert={!pinned}>
<div>
{others.map(label => (
<p className="plan-decision-answer" data-rejected="" key={label}>
<CloseIcon aria-hidden="true" />
<CloseIcon aria-hidden="true" className="icon-first-line" />
<span>{label}</span>
</p>
))}
Expand All @@ -209,7 +209,7 @@ function Popover(
)}
{decision.at && when(decision.at) && (
<p>
<ClockIcon aria-hidden="true" />
<ClockIcon aria-hidden="true" className="icon-first-line" />
<span>{when(decision.at)}</span>
</p>
)}
Expand Down
4 changes: 0 additions & 4 deletions packages/editor/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3367,10 +3367,6 @@
flex: none;
}

.plan-decision-meta > p > svg {
margin-block: calc((1lh - 0.875rem) / 2);
}

.plan-decision-meta > p > :is(img, [role="img"]) {
margin-block: calc((1lh - 1.125rem) / 2);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/src/widgets/questionnaire.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ function SettledLine(
data-card-settled=""
data-plan-sidecar-questionnaire={value.id}
>
<DecisionIcon aria-hidden="true" size={14} />
<DecisionIcon aria-hidden="true" className="icon-first-line" size={14} />
<span>
Decided: {chosen.length ? chosen.join(", ") : "Saved decision"}
{owner ? ` · @${owner}` : ""}
Expand Down
5 changes: 4 additions & 1 deletion packages/icons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
},
"devDependencies": {
"@types/bun": "catalog:bun",
"@types/react": "catalog:react"
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"react": "catalog:react",
"react-dom": "catalog:react"
}
}
9 changes: 8 additions & 1 deletion packages/icons/src/brand.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
export function GitHubIcon({ size = 16 }: { size?: number }) {
return (
<svg aria-hidden="true" fill="currentColor" height={size} viewBox="0 0 16 16" width={size}>
<svg
aria-hidden="true"
data-filled-icon=""
fill="currentColor"
height={size}
viewBox="0 0 16 16"
width={size}
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
);
Expand Down
1 change: 1 addition & 0 deletions packages/icons/src/filled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export function ChopinIcon({ size = 14, ...props }: IconProps) {
return (
<svg
aria-hidden={labelled ? undefined : true}
data-filled-icon=""
height={size}
viewBox="0 0 18 18"
width={size}
Expand Down
12 changes: 12 additions & 0 deletions packages/icons/src/icon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";

import * as icons from "./index";

// The web stylesheet holds these markers at their declared size (`flex-shrink: 0`).
test("every icon carries a marker that keeps it from shrinking beside wrapping text", () => {
for (let [name, Icon] of Object.entries(icons)) {
let html = renderToStaticMarkup(<Icon />);
expect(html, name).toMatch(/^<svg[^>]* data-(nucleo|filled)-icon=""/);
}
});
Loading
Loading