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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,27 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
terminal output until then.
([#460](https://github.com/devswha/herdr-web-ui/pull/460))

- The header is one line at every width: the pane's title, then PC › workspace › folder beside
it. The folder shows as its last name, and only when the title, the PC or the workspace does
not already say it; where the line has no room for the whole of it, it is left out rather than
cut. The full path is in the header's tooltip and at the top of the new **⋯** menu.
([#461](https://github.com/devswha/herdr-web-ui/pull/461))
- **New tab**, **Browse files** and **Alerts** are the items of one **⋯** menu in the header, in
place of three buttons; on a phone it also opens the command palette, whose own button left
the phone header. Keyboard shortcuts are unchanged. The Alerts item says this device's state
in words (**On in the app**, **On in this tab**, **On, pushed to this device**, **Off on this
device**), and the **⋯** button carries a dot while alerts are off on this device. This
reverses the old bell, whose outline lit while alerts were on.
([#461](https://github.com/devswha/herdr-web-ui/pull/461))
- The connection chip shows only while the app is reconnecting or disconnected; **live** is no
longer written out. In a window narrower than 900px the chip is its pulsing dot.
([#461](https://github.com/devswha/herdr-web-ui/pull/461))
- From 769px wide the sidebar has its own top row, with its toggle and the search, and the chat
has no bar above it: the conversation scrolls under a short fade. The header is 46px there in
the comfortable density too. Phones keep their bar. The update notice and a PC's "update the
bridge" line are drawn over the pane, not across the window.
([#461](https://github.com/devswha/herdr-web-ui/pull/461))

### Fixed
- An update asked for in the first moments after the app starts, or right after another update,
waits until the start is over. It used to stop the app in the middle of its start-up check,
Expand Down
60 changes: 44 additions & 16 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ all four palettes) and dark Catppuccin, whose elevated surface is darker than it
ink accent and cursor `#242424`.
- **Catppuccin** follows [catppuccin.com/palette](https://catppuccin.com/palette/), its style guide
and its Zed port: Mocha in dark, Latte in light. In both the content (chat canvas `--bg`,
terminal) is Base, the chrome (`--bg-panel`: sidebar, header, tabs) and elevated surfaces Mantle,
terminal) is Base, the chrome (`--bg-panel`: sidebar, the header over it and on a phone, tabs) and elevated surfaces Mantle,
dimmed text Subtext1, cursor Rosewater, selection Overlay2 at 25% over Base. Dark uses palette
colors (only the primary hover `#d3b3f8`, Mauve lightened, and the tints are derived): Base
`#1e1e2e`, Mantle `#181825`, input Crust, hover Surface0, text Text `#cdd6f4` / Subtext1 `#bac2de`,
Expand Down Expand Up @@ -192,7 +192,8 @@ Comfortable values are `:root`; the final column is the complete compact overrid

| Token | Comfortable | Compact | Usage |
|-------|-------------|---------|-------|
| `--header-h` | `52px` | `46px` | App header |
| `--header-h` | `52px` | `46px` | App header below `769px` |
| `--header-h-wide` | `46px` | — | App header from `769px`, in either density (`--header-h` takes this value there) |
| `--sidebar-w` | `320px` | `300px` | Sidebar/drawer |
| `--control-h` | `34px` | `32px` | Buttons and fields |
| `--touch-target` | `40px` | — | Coarse-pointer minimum |
Expand Down Expand Up @@ -242,17 +243,32 @@ One set for both themes: the card is island black wherever it shows.
`.terminal-host`. While a phone's soft keyboard is up (`data-keyboard`), `--app-height` follows
`visualViewport` so the keyboard does not cover input; otherwise the shell is `100dvh`, because an
iPhone home screen app reports a visual viewport shorter than the screen without a keyboard.
- Header anatomy, left to right: mobile drawer toggle / desktop sidebar toggle; flexible context
title plus PC/workspace/cwd subtitle; segmented Chat/Terminal switch; the connection chip (herdr
version in its tooltip) and meta actions for palette, notifications, settings and lock. Theme
lives in Settings and the palette; the herdr version also sits in the sidebar footer.
- The header is one line at every width. Anatomy, left to right: mobile drawer toggle / desktop
sidebar toggle and the command palette (`.header-side`); the flexible context, agent mark and
title then the PC › workspace › folder crumb; segmented Chat/Terminal switch; the connection
chip while the bridge is not live (herdr version in its tooltip), the offline pill, sign out and
the More menu (`⋯`). Theme lives in Settings and the palette; the herdr version also sits in
the sidebar footer.
- From `769px` the header is two zones. Over the sidebar, `.header-side` is the sidebar's own top
row (toggle at its start, palette at its end) on `--bg-panel`, as wide as `--sidebar-w`, and the
sidebar's seam runs to the top of the window. Over the pane, the header takes the pane's
surface: under the chat lens `--bg` with no bottom rule, the transcript scrolling under a
`--space-5` fade from `--bg`; under the terminal lens, with no pane, or with a pane herdr could
not restore, `--term-bg` and the rule. A tab strip takes the same surface as the header over
it from `769px` (`--strip-bg`: `--bg` under the chat lens, `--term-bg` otherwise, `--bg-panel`
below `769px`) and keeps its hairline, under the chat the one line over the transcript (no
fade under it). The update notice and a PC's action banner are drawn in the pane column, over
the tab strip, never across the window: the sidebar and its top row stay one piece. With the
sidebar collapsed the toggle and the palette sit in the one bar. Below `769px` the header is the `--bg-panel` bar with
its rule: the installed app's `theme-color` matches it.
- The sidebar is fixed-width on desktop and a `<=768px` drawer. The desktop collapse removes its
column; the drawer uses a scrim and keeps safe-area insets. On touch, a mostly horizontal swipe in
from the left `24px` edge opens the drawer and a swipe to the left closes it (`56px` of travel).
- The terminal stack contains a positioned terminal surface, then composer or key bar. The xterm
mount stays alive under the chat lens; changing views never creates a second connection.
- At `<=480px`, labels shed in priority order: version, brand name, context subtitle, connection
text and desktop-only control labels. Icons and selected context remain.
- At `<=480px` the brand name, the offline pill and desktop-only control labels go, and the
palette's button gives its room to the title: the palette is the More menu's first item there.
Icons and selected context remain.

## 5. Components

Expand Down Expand Up @@ -389,7 +405,7 @@ One set for both themes: the card is island black wherever it shows.
- Submit calls `POST /api/workspace/create`; the server performs `workspace.create` and, when an
agent was chosen, `agent.start` in its root pane. Pending and partial agent-start failure are
explicit before the created pane opens.
- As **New tab** (from a row's `⋯` menu, the header's New tab, the strip's `+` or the palette),
- As **New tab** (from a row's `⋯` menu, the header's More menu, the strip's `+` or the palette),
the same dialog is titled `New tab · <workspace>`, shows the workspace's folder as a fact in a
dashed box (`.new-session-folder`: a worktree's checkout, else the folder of the pane in front)
instead of asking for one, and its name is the tab's (optional; the placeholder is the number
Expand Down Expand Up @@ -441,14 +457,26 @@ One set for both themes: the card is island black wherever it shows.
leaves the worktree there: the dialog says why, locks its fields, and its button reads **Open**.

### Header context and connection
- A selected pane shows agent mark + title over workspace + cwd. With no selection, the brand fills
the context slot.
- Beside the view switch, on a desktop only, a ghost **New tab** button (`.new-tab-button`, plus
sign and the words) opens the New tab dialog for the selected pane's workspace; a phone has the
action in the row's `⋯` menu and the strip's `+`.
- A selected pane shows agent mark + title (`--fs-md`), then on the same line the crumb
PC › workspace › folder (`--fs-xs`, `--text-dim`). The folder shows as its last name, and only
when the title, the PC or the workspace does not already say it (`lib/headerCrumb.ts`). The
title may shorten with an ellipsis; the crumb never does: when it does not fit whole beside the
whole title it is not drawn, and the title has the row. The full path is in the context's
tooltip and at the top of the More menu. With no selection, the brand fills the context slot.
- The More menu (`.header-more-button`, the `RowMenu` popover, a bottom sheet at `<=640px`) opens
with the location on two lines, "PC › workspace" then the full path in mono, and holds **New
tab** (the selected pane's workspace), **Browse files** and **Alerts**, each under the condition
its own button had; at `<=480px` **Command palette** is its first item. The Alerts item says
this device's state in words after its label ("On in the app", "On in this tab", "On, pushed to
this device", "Off on this device") and switches it: it is a `menuitemcheckbox` with
`aria-checked` in the popover and a button with `aria-pressed` in the sheet. While alerts are off on this device the
More button carries a `--dot-size` `--accent` dot and its name says "alerts are off": the dot
marks the state that needs a look, never "on".
- The segmented Chat/Terminal view switch lives in the header. There is no floating view-toggle pill.
- Connection is one quiet chip: a dot plus the written live/reconnecting/disconnected state;
reconnecting pulses the dot. On phones the chip keeps only its dot.
- Connection is one quiet chip, drawn only while the bridge is not live: a pulsing dot plus the
written reconnecting/disconnected state. Below `900px` it keeps only its dot; the word stays as
its accessible text and in its tooltip. While live the chip takes no room and stays in the
document as a `role="status"` a screen reader can read (`.conn-live`; never `display: none`).

### Chat turn (`.chat-turn`)
- The chat lens is a centered `--content-w` transcript over the still-attached terminal surface.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ Only devices in your tailnet can open that address, and only yours get in withou

1. Open the address.
2. Install the app: in Safari, choose **Share → Add to Home Screen**; in Chrome, choose **Install app**.
3. Tap the bell to turn on alerts for that device. iPhone needs iOS 16.4+ and the home-screen app.
3. Open the **⋯** menu at the top right and tap **Alerts** to turn on alerts for that device. iPhone needs iOS 16.4+ and the home-screen app.

To check alerts later, choose **Settings → Alerts → Send test**. The result tells you
whether the test was sent or failed; a missing subscription offers **Turn alerts on again**.
Expand Down
5 changes: 3 additions & 2 deletions scripts/chat-greeting-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,9 @@ try {
console.log("PASS in a short window the completion menu scrolls over the lifted composer and is not clipped");

// a stack too short for the composer and the greeting: the greeting stays out, with the
// chat's own empty line in its place, and comes back when there is room
await page.setViewportSize({ width: 1440, height: 200 });
// chat's own empty line in its place, and comes back when there is room. 140px: with the
// one-line 46px header (#461) a 200px window still held the card and the greeting
await page.setViewportSize({ width: 1440, height: 140 });
await page.locator(".composer-greeting.is-out").waitFor({ state: "attached" });
assert.equal(await page.locator(".composer-greeting").evaluate((node) => getComputedStyle(node).visibility), "hidden");
assert.equal(await page.locator(".composer-greeting").getAttribute("aria-hidden"), "true");
Expand Down
14 changes: 14 additions & 0 deletions scripts/directory-groups-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,20 @@ try {
await changeState(page, [{ selector: paneSelector(other.paneId), attribute: ["aria-current", "true"] }, { selector: '.tab-strip [role="tab"]', count: 1 }],
() => page.locator(paneSelector(other.paneId)).click(), "selecting the split workspace shows its strip");
assert.equal(await page.locator('.tab-strip [role="tab"]').textContent(), "Tab 1", "a tab herdr named by its number reads as Tab 1");
// at this width the header over the pane is the pane's surface, and the strip under it is
// the same one, not a band of the panel's colour between header and pane
const surfaces = await page.evaluate(() => {
const header = document.querySelector(".app-header")!;
const column = document.querySelector(".pane-column")!;
return {
headerChat: header.classList.contains("is-chat"), columnChat: column.classList.contains("is-chat"),
header: getComputedStyle(header).backgroundColor, strip: getComputedStyle(document.querySelector(".tab-strip")!).backgroundColor,
chat: document.querySelector(".chat-view") ? getComputedStyle(document.querySelector(".chat-view")!).backgroundColor : null,
};
});
assert.equal(surfaces.columnChat, surfaces.headerChat, "the header and the pane column agree on the lens");
assert.equal(surfaces.strip, surfaces.header, "the strip is the header's surface");
if (surfaces.headerChat) assert.equal(surfaces.chat, surfaces.header, "which under the chat lens is the transcript's");
await page.locator(".tab-strip-panes").click();
const picker = page.getByRole("menu", { name: "Panes in Tab 1", exact: true });
await picker.waitFor();
Expand Down
9 changes: 9 additions & 0 deletions scripts/droplet-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ try {
const card = document.querySelector(".droplet-card")!;
return getComputedStyle(card).opacity === "1";
});
// From 769px the header is 46px, so a rotation changes its height: the card follows on
// the header's next resize callback. Give it a bounded wait; the assertion below still
// decides, and reports the numbers.
await page.waitForFunction(() => {
const top = document.querySelector(".droplet-card")!.getBoundingClientRect().top;
const header = document.querySelector(".app-header")!.getBoundingClientRect().bottom;
const safe = parseFloat(getComputedStyle(document.querySelector(".droplet-probe")!).paddingTop) || 0;
return Math.abs(top - (Math.max(safe, header) + 12)) <= 2;
}, undefined, { timeout: 5_000 }).catch(() => undefined);
const geometry = await page.evaluate(() => {
const card = document.querySelector<HTMLElement>(".droplet-card")!;
const rect = card.getBoundingClientRect();
Expand Down
47 changes: 47 additions & 0 deletions scripts/header-more.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* The header's More menu, for the browser scripts: New tab, Browse files and Alerts are its
* items, and at phone width the command palette is its first. It is a popover on a desktop and
* a bottom sheet at 640px and below (RowMenu), so an item is found in either.
*/
import type { Locator, Page } from "playwright-core";

const ITEMS = '.row-menu [role="menuitem"], .row-menu [role="menuitemcheckbox"], .row-sheet .row-sheet-item';

/** Opens the menu when it is not open and returns the item that starts with `label`. */
export async function moreItem(page: Page, label: string): Promise<Locator> {
const item = page.locator(ITEMS).filter({ hasText: new RegExp(`^${label}`) });
if (!(await item.isVisible())) await page.locator(".header-more-button").click();
await item.waitFor();
return item;
}

/** Runs an item; the menu closes with it. */
export async function runMoreItem(page: Page, label: string): Promise<void> {
const item = await moreItem(page, label);
await item.click();
await item.waitFor({ state: "hidden" });
}

/**
* What the Alerts item says of this device, in its words ("On in the app", "Off on this
* device"), read from the open menu, which is closed again. The item is a switch: a checkbox
* item in the popover, a pressed button in the sheet, and its state has to agree with its words.
*/
export async function alertsState(page: Page): Promise<string> {
const item = await moreItem(page, "Alerts");
const state = (await item.locator(".menu-item-hint, .row-sheet-hint").textContent()) ?? "";
const role = await item.getAttribute("role");
const checked = await item.getAttribute(role === "menuitemcheckbox" ? "aria-checked" : "aria-pressed");
await page.keyboard.press("Escape");
await item.waitFor({ state: "hidden" });
if (checked !== String(!state.startsWith("Off"))) throw new Error(`the Alerts item says "${state}" but its ${role === "menuitemcheckbox" ? "aria-checked" : "aria-pressed"} is ${checked}`);
return state;
}

/** Whether the menu's button carries the dot, and says so in its name: alerts are off on this device. */
export async function alertsOffMarked(page: Page): Promise<boolean> {
const dot = (await page.locator(".header-more .header-more-dot").count()) === 1;
const named = (await page.locator(".header-more-button").getAttribute("aria-label")) === "More · alerts are off";
if (dot !== named) throw new Error(`the More button's dot (${dot}) and its name (${named}) disagree`);
return dot;
}
3 changes: 2 additions & 1 deletion scripts/mobile-tabs-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ async function box(page: Page, selector: string): Promise<Box> {

/** The header keeps the pane's title and the strip sits right under it, whatever the lens. */
async function assertShell(page: Page, label: string, titleRoom = 60): Promise<void> {
assert.equal(await page.locator(".new-tab-button").isVisible(), false, `${label}: the header's New tab is a desktop button`);
assert.equal(await page.locator(".app-header").getByRole("button", { name: "New tab" }).count(), 0, `${label}: the header has no New tab button of its own`);
assert.equal(await page.locator(".app-header .header-more-button").isVisible(), true, `${label}: New tab is an item of the header's More menu`);
const title = await box(page, ".context-title-text");
assert.ok(title.width >= titleRoom, `${label}: the pane's title keeps its room in the header (${title.width}px)`);
const header = await box(page, ".app-header");
Expand Down
7 changes: 6 additions & 1 deletion scripts/mobile-viewport-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Browser } from "playwright-core";
import { moreItem } from "./header-more.ts";

type ViewportQA = Window & { viewportQA: { resize: (height: number) => void; pointer: (touch: boolean) => void } };

Expand Down Expand Up @@ -137,7 +138,11 @@ export async function checkMobileViewport(browser: Browser, origin: string, pane

// A sheet with a text field ends where the keyboard begins: an iPhone home screen app keeps
// the layout viewport at full height, so a sheet on its bottom edge would sit under the keyboard.
await page.getByRole("button", { name: "Command palette", exact: true }).click();
// at phone width the palette's header button is gone: it is the More menu's first item
assert.equal(await page.locator(".app-header .palette-button").isVisible(), false, "a phone's header has no palette button");
const palette = await moreItem(page, "Command palette");
assert.equal(await page.locator(".row-sheet-item").first().textContent(), "Command palette", "the palette is the More menu's first item");
await palette.click();
const search = page.locator(".palette-search input");
await search.focus();
await height(500);
Expand Down
Loading
Loading