mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
* feat(app): tighten the touch composer toolbar onto the text column
On touch, the toolbar's icon-only glyphs were 20pt muted outlines at a 1.5px
stroke in loosely spaced slots, and the row sat on a flat -6 margin, so the
attach + and the trailing control floated inside the text column. Touch now
places 44pt slots edge to edge, draws icon-only glyphs at 24pt with a 2px
foreground stroke, keeps label-paired glyphs at 20pt, and runs the context
ring at 26pt with a 3px stroke. The row overhangs into the composer padding
by each end control's ink inset (composerToolbarOverhang), so the + ink and
the trailing glyph or send/stop disc sit on the text column's edges.
Pointer sizing is unchanged.
The folded model control now drops its thinking label before the model
label truncates, and the model label truncates on a word boundary
("Ten second…") instead of leaving a space before the ellipsis. Both are
decided from measured widths. The web reports layout widths rounded to whole
pixels, so a tie counts as not fitting.
* fix(app): measure replacement model labels instead of hiding them
The folded model label and TailEllipsisText kept their measuring views mounted
when the label text changed, while discarding the cached widths. A retained
measuring node whose size does not change emits no new layout event on web or
native, so after switching to a model whose label (or a shortened prefix of it)
has the same width, the widths stayed unknown and the label stayed transparent.
Keying each measurer by what it measures remounts it, so every width is
measured again.
Also move the label width sweep into a named helper so the test body states
the expected label order.
* feat(app): space the touch composer toolbar by what it paints
- Lay each control out at its painted width, 20pt apart, with a disc-sized circle that is both its frame and its fully rounded pressed highlight. Pickers are 36pt pills.
- Match the header icons: 20pt glyphs at 1.5px, an 18pt context ring at 2px.
- Always end the row in a disc (voice mode, send or stop) so it does not shift, nested into the composer's corner, with a concentric 26pt corner radius on touch.
- Stop the picker row's clip from flattening the picker highlight's left edge.
- Budget the density ladder on laid-out widths.
* fix(app): disable the stand-in send disc and keep form model fields in their box
- An empty new chat's stand-in Send is disabled until there is content, as in fullscreen.
- A custom model selector trigger, such as the schedule form's field, drops the toolbar slot's margins.
245 lines
9.4 KiB
TypeScript
245 lines
9.4 KiB
TypeScript
import { expect, type Page } from "@playwright/test";
|
|
|
|
/**
|
|
* The composer toolbar collapses its controls by measured width. A collapsed
|
|
* frame is observable from the DOM: the mode trigger drops its label and
|
|
* shrinks to the icon-only box, so a visible trigger with no text is a
|
|
* collapsed paint.
|
|
*/
|
|
interface ToolbarFrame {
|
|
atMs: number;
|
|
labels: string[];
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__composerToolbarFrames?: ToolbarFrame[];
|
|
__composerToolbarStop?: () => void;
|
|
}
|
|
}
|
|
|
|
/** Records every painted frame's visible mode-trigger labels until stopped. */
|
|
export async function recordComposerToolbarFrames(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const frames: ToolbarFrame[] = [];
|
|
window.__composerToolbarFrames = frames;
|
|
const start = performance.now();
|
|
let running = true;
|
|
const tick = () => {
|
|
if (!running) return;
|
|
const labels = Array.from(document.querySelectorAll('[data-testid="mode-control"]'))
|
|
.filter((node) => node.getClientRects().length > 0)
|
|
.map((node) => (node.textContent ?? "").trim());
|
|
frames.push({ atMs: Math.round(performance.now() - start), labels });
|
|
requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
window.__composerToolbarStop = () => {
|
|
running = false;
|
|
};
|
|
});
|
|
}
|
|
|
|
async function readComposerToolbarFrames(page: Page): Promise<ToolbarFrame[]> {
|
|
return page.evaluate(() => {
|
|
window.__composerToolbarStop?.();
|
|
return window.__composerToolbarFrames ?? [];
|
|
});
|
|
}
|
|
|
|
export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise<void> {
|
|
const frames = await readComposerToolbarFrames(page);
|
|
expect(frames.length).toBeGreaterThan(0);
|
|
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
|
|
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
|
|
}
|
|
|
|
/** What the folded model control's labels show, read from the DOM. */
|
|
export interface ModelTriggerLabelState {
|
|
thinkingShown: boolean;
|
|
modelShown: boolean;
|
|
modelText: string;
|
|
modelTruncated: boolean;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__modelTriggerFrames?: ModelTriggerLabelState[];
|
|
}
|
|
}
|
|
|
|
function readModelTriggerLabelStateInPage(): ModelTriggerLabelState {
|
|
const visible = (node: Element) => node.getClientRects().length > 0;
|
|
const model = Array.from(
|
|
document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'),
|
|
).find(visible);
|
|
const thinking = Array.from(
|
|
document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'),
|
|
).find(visible);
|
|
const shown = (node: Element | null): boolean => {
|
|
for (let current = node; current; current = current.parentElement) {
|
|
if (Number(getComputedStyle(current).opacity) === 0) return false;
|
|
}
|
|
return true;
|
|
};
|
|
const modelShown = model !== undefined && shown(model);
|
|
const modelText = model?.textContent ?? "";
|
|
return {
|
|
modelShown,
|
|
modelText,
|
|
thinkingShown: modelShown && thinking !== undefined,
|
|
modelTruncated:
|
|
model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth),
|
|
};
|
|
}
|
|
|
|
/** Records the folded model control's labels on every painted frame from page load. */
|
|
export async function recordModelTriggerFramesFromLoad(page: Page): Promise<void> {
|
|
await page.addInitScript(`(() => {
|
|
const read = ${readModelTriggerLabelStateInPage.toString()};
|
|
const frames = [];
|
|
window.__modelTriggerFrames = frames;
|
|
const tick = () => {
|
|
frames.push(read());
|
|
requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
})()`);
|
|
}
|
|
|
|
/** The model control's labels once two reads 100ms apart agree. */
|
|
export async function readSettledModelTriggerLabels(page: Page): Promise<ModelTriggerLabelState> {
|
|
let previous = await page.evaluate(readModelTriggerLabelStateInPage);
|
|
for (;;) {
|
|
await page.waitForTimeout(100);
|
|
const next = await page.evaluate(readModelTriggerLabelStateInPage);
|
|
if (JSON.stringify(next) === JSON.stringify(previous)) return next;
|
|
previous = next;
|
|
}
|
|
}
|
|
|
|
/** The labels at the widths where the folded model control changes what it shows. */
|
|
export interface ModelTriggerLabelSweep {
|
|
/** The labels at the widest width. */
|
|
widest: ModelTriggerLabelState & { width: number };
|
|
/** The first width at which the thinking label is hidden. */
|
|
thinkingHidden: ModelTriggerLabelState & { width: number };
|
|
/** The first width at which the model label is truncated. */
|
|
modelTruncated: ModelTriggerLabelState & { width: number };
|
|
}
|
|
|
|
/**
|
|
* Narrows the viewport step by step and records where the thinking label disappears and where the
|
|
* model label first truncates.
|
|
*/
|
|
export async function sweepModelTriggerLabels(
|
|
page: Page,
|
|
widths: { from: number; to: number; step: number; height: number },
|
|
): Promise<ModelTriggerLabelSweep> {
|
|
const steps: (ModelTriggerLabelState & { width: number })[] = [];
|
|
for (let width = widths.from; width >= widths.to; width -= widths.step) {
|
|
await page.setViewportSize({ width, height: widths.height });
|
|
const labels = await readSettledModelTriggerLabels(page);
|
|
steps.push({ ...labels, width });
|
|
if (labels.modelTruncated || !labels.modelShown) break;
|
|
}
|
|
const thinkingHidden = steps.find((step) => !step.thinkingShown);
|
|
const modelTruncated = steps.find((step) => step.modelTruncated);
|
|
if (!thinkingHidden || !modelTruncated) {
|
|
throw new Error(`the sweep never hid the thinking label and truncated the model label`);
|
|
}
|
|
return { widest: steps[0], thinkingHidden, modelTruncated };
|
|
}
|
|
|
|
/** No frame recorded so far painted a truncated model label beside the thinking label. */
|
|
export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise<void> {
|
|
const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []);
|
|
expect(frames.some((frame) => frame.modelShown)).toBe(true);
|
|
const wrong = frames
|
|
.map((frame, index) => ({ ...frame, index }))
|
|
.filter((frame) => frame.thinkingShown && frame.modelTruncated);
|
|
expect(wrong, `of ${frames.length} frames`).toEqual([]);
|
|
}
|
|
|
|
/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */
|
|
export interface ComposerToolbarRails {
|
|
/** Where the text column starts. */
|
|
textLeft: number;
|
|
/** Where the toolbar's leftmost ink starts. */
|
|
inkLeft: number;
|
|
/** How far the trailing disc sits from the composer's right edge. */
|
|
discRightInset: number;
|
|
/** How far the trailing disc sits from the composer's bottom edge. */
|
|
discBottomInset: number;
|
|
}
|
|
|
|
/**
|
|
* Reads where the toolbar's ink starts against the text column and where the trailing disc sits
|
|
* in the composer's corner. A control's ink is its filled disc when it has one, otherwise its
|
|
* glyph's stroked geometry.
|
|
*/
|
|
export async function readComposerToolbarRails(page: Page): Promise<ComposerToolbarRails> {
|
|
const root = page.locator('[data-testid="message-input-root"]:visible').first();
|
|
return root.evaluate((element) => {
|
|
const textarea = element.querySelector("textarea");
|
|
if (!textarea) throw new Error("composer has no text input");
|
|
const composerRect = element.getBoundingClientRect();
|
|
const textLeft =
|
|
textarea.getBoundingClientRect().left +
|
|
Number.parseFloat(getComputedStyle(textarea).paddingLeft);
|
|
|
|
const isFilled = (node: Element) => {
|
|
const color = getComputedStyle(node).backgroundColor;
|
|
return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
|
|
};
|
|
const glyphInkLeft = (svg: SVGSVGElement) => {
|
|
let left = Number.POSITIVE_INFINITY;
|
|
for (const shape of Array.from(
|
|
svg.querySelectorAll<SVGGraphicsElement>("path, circle, rect, line, polyline"),
|
|
)) {
|
|
const matrix = shape.getScreenCTM();
|
|
if (!matrix) continue;
|
|
const box = shape.getBBox();
|
|
const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0");
|
|
left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e);
|
|
}
|
|
return left;
|
|
};
|
|
|
|
const band = composerRect.bottom - 64;
|
|
let inkLeft = Number.POSITIVE_INFINITY;
|
|
let trailingDisc: DOMRect | null = null;
|
|
for (const control of Array.from(element.querySelectorAll('[role="button"]'))) {
|
|
const rect = control.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.top < band) continue;
|
|
const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled);
|
|
const svg = control.querySelector("svg");
|
|
if (disc) {
|
|
const discRect = disc.getBoundingClientRect();
|
|
inkLeft = Math.min(inkLeft, discRect.left);
|
|
if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect;
|
|
} else if (svg) {
|
|
inkLeft = Math.min(inkLeft, glyphInkLeft(svg));
|
|
}
|
|
}
|
|
if (!trailingDisc) throw new Error("composer toolbar has no trailing disc");
|
|
return {
|
|
textLeft,
|
|
inkLeft,
|
|
discRightInset: composerRect.right - trailingDisc.right,
|
|
discBottomInset: composerRect.bottom - trailingDisc.bottom,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the
|
|
* corner, as far from the right edge as from the bottom edge. Within half a pixel.
|
|
*/
|
|
export async function expectComposerToolbarOnTextRails(page: Page): Promise<void> {
|
|
const rails = await readComposerToolbarRails(page);
|
|
// A precision of 0 digits passes within half a pixel.
|
|
expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0);
|
|
expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0);
|
|
}
|