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: 2 additions & 0 deletions .config/tend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ merge: maintainer
secrets:
allowed:
- CHROMATIC_PROJECT_TOKEN
- ARGOS_TOKEN

workflows:
ci-fix:
watched_workflows:
- CI
- Chromatic
- Argos
86 changes: 86 additions & 0 deletions .github/workflows/argos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Argos

# Runs beside Chromatic while Argos is evaluated as its replacement: same
# stories, and the same triggers and draft and fork gates as `chromatic.yml`,
# whose comments give the reasons. Argos renders in this job
# (Playwright, via the Storybook Vitest addon) rather than in its own cloud, so
# a play-function exception fails the run here instead of passing silently.
on:
push:
branches:
- main
paths:
- 'lib/**'
- 'docs/stories/**'
- 'pnpm-lock.yaml'
- 'dor/**'
- 'remote-lib-common/**'
- 'dor-lib-common/**'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'lib/**'
- 'docs/stories/**'
workflow_dispatch:

permissions:
contents: read

concurrency:
# Keyed by SHA on push so main never cancels: those builds are the baselines.
group: argos-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
argos:
name: Argos Visual Tests
if: >-
github.event_name != 'pull_request'
|| (github.event.pull_request.draft == false
&& github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
Comment thread
dormouse-bot marked this conversation as resolved.
# Unlike Chromatic's, this job does the rendering, and `--with-deps` runs an
# unbounded apt-get; without a cap a stalled mirror holds the runner 6h.
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json

- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0

- name: Install dependencies
# Frozen, so the lockfile pins what runs in a job holding ARGOS_TOKEN.
run: pnpm install --frozen-lockfile
working-directory: lib

- name: Install browsers
run: pnpm exec playwright install --with-deps chromium webkit
working-directory: lib

- name: Capture and upload screenshots
# One Argos build per browser: `storybook-chromium`, `storybook-webkit`.
run: pnpm test:argos
working-directory: lib
env:
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}

# Publish even when a story fails capture, as Chromatic does, so one throwing
# `play` on main does not freeze the standing Storybook link.
- name: Build Storybook
Comment thread
dormouse-bot marked this conversation as resolved.
if: ${{ !cancelled() }}
run: pnpm build-storybook
working-directory: lib

- name: Deploy Storybook
if: ${{ !cancelled() }}
# main's deploy is the production one, the standing link to the Storybook.
run: pnpm exec argos deploy ./storybook-static ${{ github.ref == 'refs/heads/main' && '--prod' || '' }}
working-directory: lib
env:
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
2 changes: 1 addition & 1 deletion .github/workflows/tend-ci-fix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
name: tend-ci-fix
on:
workflow_run:
workflows: ["CI", "Chromatic"]
workflows: ["CI", "Chromatic", "Argos"]
types: [completed]
branches: ["main"]

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,13 @@ hosted/.wrangler/
hosted/.pgstencil/
hosted/dist-worker/

# Storybook / Chromatic
# Storybook / Chromatic / Argos
storybook-static/
build-storybook.log
chromatic.log
chromatic-build-*.xml
chromatic-diagnostics.json
lib/screenshots/

# symlinked skill files
.agents/skills/
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pnpm build # build lib, vscode extension, Pocket, and website
The harness opens its browser pane and prints its URL and browser command
(`docs/specs/standalone.md` → "Standalone browser-dev harness").

**Open every PR as a draft.** Chromatic bills per snapshot and skips drafts, so
**Open every PR as a draft.** Chromatic and Argos bill per snapshot and skip drafts, so
marking a PR ready for review is what spends them.

## Worktrees
Expand Down
4 changes: 2 additions & 2 deletions TESTING_AND_MODIFICATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ needed. If a PR is squash-merged by mistake, PR N+1 will show PR N's changes
again until `main` is merged into its branch (that merge resolves cleanly, since
both sides carry identical content). The repo's default is a merge commit; keep it.

Keep every PR a draft until it is actually up for review (Chromatic bills on
ready-for-review).
Keep every PR a draft until it is actually up for review (Chromatic and Argos
bill on ready-for-review).

## 2. Setup in a worktree

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/alert.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Guarding only completion leaves a stale `start` free to replace the active utter

**Why `SPEAKING` may pulse unbounded and `SPOKEN` may not.** An utterance is seconds long and stops on its own, so the pulse it carries is self-bounding. `SPOKEN` persists until the ring is attended, so animating it would be exactly the per-Session animation with no end that bounding the burst exists to remove.

**Why `cfg.alert.ringingPaused` suppresses the pulse.** It is the Chromatic freeze that pins the alarm; even a bounded animation could otherwise snapshot at an arbitrary phase during its first 2.6 seconds.
**Why `cfg.alert.ringingPaused` suppresses the pulse.** It is the visual-snapshot freeze that pins the alarm; even a bounded animation could otherwise snapshot at an arbitrary phase during its first 2.6 seconds.

**Why the unlabelled treatment pulses once per episode.** An infinite per-Session animation is expensive, and the whole-Pane treatment covers far more surface than the retired bell icon did. With four focused panes wearing an infinite animation, three minutes cost 6.89 MB of embedder memory, 1,127 style recalculations, and 3.99 seconds of renderer CPU; pausing only those animations in the same loaded document reduced that to 0.13 MB, two recalculations, and 0.025 seconds. After bounding the burst, two consecutive three-minute windows each had zero live animations, one recalculation, under 0.40 MB of non-cumulative embedder drift, and at most 0.024 seconds of renderer CPU (measured in Chrome 150, 2026-09). A handful of cycles preserves the entry cue without leaving an animation running for the lifetime of an unattended alert. The episode — not a track latch — is the key because the episode is the summons the sinks already work from: a second track latching inside one enriches an alarm the user was already shown, and re-flashing the whole Pane for it would read as a new alarm. Running the burst off `episode.startedAt` rather than from mount makes the CSS clock a property of the episode, so minimize → reattach or a Workspace switch lands past an expired burst instead of replaying it. A Session BEL-ing in a loop still cannot restart the burst, because a track that is already latched does not re-latch.

Expand Down
10 changes: 5 additions & 5 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ A fixed-positioned element on top of the Lath host, covering the active element'
- **Exactly one pane or door is active at a time**, drawn by one SVG renderer (`SelectionRing`, `variant: 'ants' | 'solid'`).
- **Passthrough:** `variant='solid'` — a 1px solid SVG stroke, centerline `strokeWidth/2` inside the div edge for panes and doors alike, no glow (rationale).
- **Command:** `variant='ants'` — marching-ants border (`cfg.marchingAnts`: 10px segment, 60% dash, 0.4s cycle, 2px stroke). **March for as long as command mode lasts** (test: `marches for as long as command mode lasts, across selection changes` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`; rationale). **Never restart or retime the march for travel** — only the dash resizes, refitted to the moving perimeter so segments stay even — and draw the smear separately ([Ring travel](#ring-travel)). **While unfocused, pause it and apply `saturate(0.3)` to the ring.**
- **Never pause the ants in a focused window except during Workspace title editing**, resuming when editing ends without changing mode, **under reduced motion**, which holds a still dashed ring, **or under `cfg.marchingAnts.paused`** (Chromatic sets it in `lib/.storybook/preview.ts`). Pinned by `pauses while a workspace is renamed, then resumes marching` and `holds the ants still under reduced motion` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`.
- **Never pause the ants in a focused window except during Workspace title editing**, resuming when editing ends without changing mode, **under reduced motion**, which holds a still dashed ring, **or under `cfg.marchingAnts.paused`** (visual snapshots set it in `lib/.storybook/preview.ts`). Pinned by `pauses while a workspace is renamed, then resumes marching` and `holds the ants still under reduced motion` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`.
- Border radius follows DESIGN.md's Concentric-Corners Rule: the pane ring's radius is the pane radius plus the inflate (`PANE_SELECTION_RING_RADIUS_PX`), with the marching-ants path inset so its stroke centerline sits on the same gutter midline; doors sit at zero offset and keep `0.5rem 0.5rem 0 0`.
- Color is the resolved `--color-focus-ring`, **re-read whenever `document.body`'s class/style changes**, because the dynamic palette publishes it there (`useFocusRingColor`).
- `z-index: SELECTION_RING_Z_INDEX` (50), `pointer-events: none`. Under `WorkspaceWindow` it renders into `document.body`, outside the Workspace's transform and stacking context.
Expand All @@ -346,8 +346,8 @@ Per-frame writes are **imperative**: `SelectionRing` gives the overlay refs to i
- **Identity change → tween.** A measurement whose identity (`${selectedType}:${selectedId}`) differs from the one on screen glides from the current interpolated position to the new target, **clock restarted**, so arrow-key spam stays responsive.
- **Same identity → snap 1:1.** A same-identity re-measure with no tween in flight (sash drag, window resize, a settled leaf's store commit) writes the new rect directly, tracking the geometry exactly instead of easing behind it.
- **In-flight retarget.** A same-identity re-measure *during* a tween retargets the destination **without resetting the clock**, so the ring converges on a moving target (select-a-neighbor-during-kill) and still lands on the original completion instant.
- **Snap gate.** `motionIsInstant()` — `!cfg.layout.animate` (Chromatic) or `prefersReducedMotion()` — settles the ring instantly; it is the same predicate the Lath animator's duration uses, so ring and leaves agree. **A ring appearing with nothing on screen also snaps**: there is no `from` to glide from.
- **The unfocus-saturate fade is the one CSS transition** (`filter ${FOCUS_MOTION_MS}ms`, set inline by `SelectionRing.tsx`); neither the snap gate nor reduced motion touches it. Under Chromatic it snapshots already finished (pinned in `lib/.storybook/preview.ts`).
- **Snap gate.** `motionIsInstant()` — `!cfg.layout.animate` (visual snapshots) or `prefersReducedMotion()` — settles the ring instantly; it is the same predicate the Lath animator's duration uses, so ring and leaves agree. **A ring appearing with nothing on screen also snaps**: there is no `from` to glide from.
- **The unfocus-saturate fade is the one CSS transition** (`filter ${FOCUS_MOTION_MS}ms`, set inline by `SelectionRing.tsx`); neither the snap gate nor reduced motion touches it. Visual snapshots capture it already finished (pinned in `lib/.storybook/preview.ts`).
- Pane↔door selection morphs the corner radii (12px all-round ⇄ `8,8,0,0`) and stroke inset through the same tween, so the shape lerps instead of popping.
- **Must continue from the last painted frame across Workspace activation**, not the incoming Wall's stale frame. Hidden Walls neither animate nor publish ring geometry. Tabs use Door geometry; `+` uses its button rectangle and 4px corners. Pinned by `carries the last visible ring across Walls instead of their stale pane positions` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`.

Expand Down Expand Up @@ -421,7 +421,7 @@ The name `<span>` is replaced by an `InlineEditInput` (shared with the browser U

The field is **controlled by its own draft state**, seeded at mount and untouched by later prop changes, and **the `select()` ref callback has a stable identity** so it runs exactly once (rationale). **Mounting is the reset** — the editor exists only during a rename, so each one starts from the current label. Clipboard chords inside the field are the wall's job on hosts whose webview has no native Edit menu (`docs/specs/mouse-and-clipboard.md` §8.9).

Submitted values are rejected when empty or when they fail the `setTerminalUserTitle` validation that also guards title seeding (`docs/specs/terminal-state.md` → Supported OSC Inputs). `<unnamed>` is the default panel placeholder but is otherwise allowed as a user pin. **On rejection the input still closes** — it is not a blocking dialog — and a warning popover anchored under it names the offending value, dismissing on the next pointerdown, scroll, resize, `Escape`, or after `cfg.overlays.warningAutoDismissMs` (3s; 0 under Chromatic, pinned in `lib/.storybook/preview.ts`).
Submitted values are rejected when empty or when they fail the `setTerminalUserTitle` validation that also guards title seeding (`docs/specs/terminal-state.md` → Supported OSC Inputs). `<unnamed>` is the default panel placeholder but is otherwise allowed as a user pin. **On rejection the input still closes** — it is not a blocking dialog — and a warning popover anchored under it names the offending value, dismissing on the next pointerdown, scroll, resize, `Escape`, or after `cfg.overlays.warningAutoDismissMs` (3s; 0 under visual snapshots, pinned in `lib/.storybook/preview.ts`).

Source of truth: `lib/src/components/wall/IllegalRenameWarning.tsx`, `lib/src/components/wall/use-dismiss-overlay.ts`.

Expand Down Expand Up @@ -450,7 +450,7 @@ On cold restore, a terminal pane with a host-captured recovery invocation runs i

### Renderer

**Must use `@xterm/addon-webgl` for mounted terminals when available**, falling back to xterm's DOM renderer on unsupported WebGL, activation failure, or context-budget eviction. `cfg.terminal.webglRenderer` disables WebGL and is off under Chromatic. ImageAddon owns its separate canvas layers (`docs/specs/terminal-escapes.md` → "Inline graphics"). (rationale)
**Must use `@xterm/addon-webgl` for mounted terminals when available**, falling back to xterm's DOM renderer on unsupported WebGL, activation failure, or context-budget eviction. `cfg.terminal.webglRenderer` disables WebGL and is off under visual snapshots. ImageAddon owns its separate canvas layers (`docs/specs/terminal-escapes.md` → "Inline graphics"). (rationale)

- **Must acquire GPU resources at mount, never at Session creation**, and keep a successfully activated renderer when context capture or explicit loss is unavailable. (rationale)
- **Must dispose the addon on unmount/minimize, Workspace deactivation, helper parking, and Session disposal**, then explicitly lose its context when captured and supported. Report addon or extension failures without aborting teardown. Minimize preserves the xterm, grid, buffers, PTY, and other addons.
Expand Down
Loading
Loading