Skip to content

fix(remote): apply the audit fixes to remote, graphs and the plate - #5

Merged
JheisonMB merged 9 commits into
mainfrom
fix/audit-remote-and-plate
Nov 4, 2026
Merged

JheisonMB merged 9 commits into
mainfrom
fix/audit-remote-and-plate

Conversation

@JheisonMB

Copy link
Copy Markdown
Contributor

Nine fixes from the audit: PTY sizing from the device panel, following the host's resized frame, the Canopy teal header banners, the phone toolbar, fail arcs inside the plate's viewBox, the phone plate viewport, the guest invite code and sign-in return, and the floating latest button. Merging does not deploy.

… size

"This device controls the size" re-sent the local xterm's grid, which is
always the host's own size (the terminal is opened at the snapshot size
and scaled to fit), so turning it on changed nothing.

The toggle now makes this device the size owner: it measures the cell off
the open terminal (screen / grid, shown at the face's base scale times the
zoom), fits whole cells into its panel (cols 20-500, rows 5-200), resizes
the local terminal in place to that grid so it renders at scale 1, and
sends resize{id,cols,rows} through the existing path. While on it
re-asserts, debounced 150 ms, on panel resize, orientation change,
fullscreen and zoom; the remembered toggle re-asserts the computed size on
every mount; turning it off stops asserting and fits the host's size again.
The owner's fit box takes a fixed 40dvh so the rows never feed back into
the height they are measured from.
…keover

The host now announces a session's PTY size change as
resized{id,cols,rows} (another device asserted its size, or the local TUI
resized it). The client parses it with the host's size bounds and routes
it for the attached session only; older hosts never send it and anything
malformed is dropped like an unknown frame.

A follower resizes its xterm in place and refits, so the picture matches
the host without waiting for a snapshot (the guest view follows too). A
size owner that sees a size it never asserted lets go instead of fighting
back: the toggle turns off and is remembered off, and the size row's note
says "Size taken over by another device.". A slow echo of one of its own
older sizes is not a takeover.

The dev fake host tracks each session's size, answers resize with
resized, snapshots at the held size and can resize from elsewhere, and a
new e2e spec drives the toggle and both resized paths at 390x844.
… them centred

6a7bc4b moved the graphs and remote banners onto the landing's wizard
greens; the intent was the other way round (the landing moves to the
teal). Only the colour goes back: BANNER_GRADIENT is the TUI's five
legible Canopy teal stops again, bannerColor is again an exact port of
harness-canopy src/shared/banner.rs gradient_rgb (round(index /
max(1, count) * 4), clamped, #52fff6 fallback), and the app word is
#52fff6. The stacked, centred header from 6a7bc4b stays as it is.

The look tests, the graphs page snapshots and the e2e ink checks
(graphs visual, remote serve) expect the teal again.
At 390 px the Keys and latest labels ran past their 44 px buttons (the
latest word wrapped under its arrow, stretching the whole row to 69 px),
and at 360 px the latest button ran past the viewport: eight 44 px
targets do not fit a phone's row once fullscreen and both attach buttons
are up.

Keys gains a keyboard icon and, like Builder, both Keys and latest go
icon-only under 640 px, named by their aria-label and a title tooltip;
on desktop the words stay beside the icons. Toolbar buttons keep the
44 px floor without shrinking or stacking their content, and the row
wraps instead of clipping, so the latest button drops to a second row.

The control-size checkbox (13x13 px) is now a switch in the latched-key
look, role="switch", with the global focus ring and Space to toggle. The
size row is laid out like a settings row: the label spans the switch's
words and the explainer beneath them, so the whole 44 px row is the
target and the row is no taller than before at 360x640.

A touch-targets spec checks the row at 360 and 390 px with latest up:
no clipped label, no overflow, every target at least 44 px.
The horizontal layout measured each arc's rise against mainTop in design
coordinates, bounded the viewBox with it there, then shifted every point
into the padded viewBox but handed edgePath the unshifted rise. Every arc
that loops above the flow (the fail and back edges into Designer,
Implementer and Reviewer on canopy-builder) therefore topped out `dy`
units above the bounds: outside the viewBox, clipped flat by the svg
edge, and the token's fail legs rode off the plate with them.

The rise now shifts with the drawing, and the bounds read the curve the
arc actually draws (cubicExtent, from its start through the rise to the
shortened arrowhead end) instead of its control points, so the plate
loses the quarter-height of empty band the controls left above the apex.
The vertical orientation was already consistent and is unchanged.

A bounds test parses every edge and walk path of every fixture and
bundled pattern, in both orientations, and holds it inside the viewBox;
it fails on the old layout. The snapshots and the handler-free digest
move with the shifted drawing, and the remote's bundle gets a 512 B
allowance for the bounds code (+237 B gzip measured).
Below the datasheet breakpoint the live viewport stood a fixed 60svh,
min 320 px, so a wide horizontal plate fitted to a phone's width filled
barely a fifth of it: canopy-builder at 390 px drew 110 px of plate
between ~195 px black bands above and below.

The client now publishes the drawing's proportions as --plate-ratio, and
the one-column live viewport takes them through aspect-ratio, between
200 px and 60svh: the band shrinks to what the controls need, and a tall
plate still stops at 60svh. Beside the datasheet (1024 px and up) the
viewport stretches to the sheet as before, and without scripting it keeps
its 60svh scroll fallback.

The plate spec now checks each edge path's own box, not the svg's,
against the viewport at the initial fit and after Fit (an arc cut off by
the viewBox never showed in the svg box), asserts the new phone height,
and that the page never scrolls sideways at 390 px.
Since the relay gated the guest door with invite codes (5f264ee), it
answers 403 bad_invite to any /ws/device?guest_of=<uid> without a live
`invite=<code>`, but the guest page never sent one: guestRelayUrl built
`?guest_of=<uid>` only and no web code knew about invites, so a guest
could never connect.

The guest page now takes the code from the link's fragment,
`/g/<owner_uid>/<host_id>#invite=<code>` (the form `canopy remote invite`
prints), so it never reaches the relay's logs or a Referer. The fragment
is read before anything else runs and stripped with
history.replaceState; the code is kept in this tab's sessionStorage only,
per link, so a reload or a reconnect in the same tab still has it and it
survives the GitHub sign-in round trip. guestRelayUrl(ownerUid, code)
appends `&invite=<encoded code>`.

The relay hashes exactly what it receives and the host publishes the
SHA-256 of the canonical CNR-XXXX-XXXX-XXXX-XXXX form, so the page sends
only that form: canonicalInviteCode mirrors the host's
normalize_code_input (dashes and spaces ignored, ASCII case folded, I/L
read as 1 and O as 0, the CNR prefix optional).

A link without a code, or with a malformed one, shows a small invite
form in the sign-in card: one labelled field tied to its hint and its
error (aria-describedby, aria-invalid, role="alert"), checked against
the code format on submit. The relay refuses a bad door with an HTTP
status the browser never shows the page, so a guest socket that closes
before it ever opened, while online, is read as the invite being refused:
the code leaves the tab and the form comes back prefilled, saying the
code may be mistyped, expired, revoked or used, or, once the host had
let this tab in, that the single-use code is spent. Offline, the code is
kept and the relay is reported unreachable instead.

The fake host gains `?screen=guest&invite=ask|refuse` to drive the form,
and the guest chunk an allowance of 1.5 KB gzip (measured +1 390 B).
A guest who opened /g/<owner_uid>/<host_id> signed out clicked "Sign in
with GitHub", and the OAuth callback redirected to `/`: the guest landed
on the owner's pairing form with the link gone, and only an already
signed-in guest could ever reach a host.

The guest sign-in button now remembers the link's pathname (never the
invite code, which keeps its own tab-only slot) in sessionStorage, and
main.ts, before routing, resumes it in place with history.replaceState
when the tab comes back to `/` within 15 minutes, so the guest chunk
mounts with the code it saved before leaving. Only a well-formed guest
path is ever resumed, once; a stale or malformed entry is dropped. The
main bundle gains a 512 B allowance (measured +226 B gzip), since this
step has to run before the guest chunk loads.
At 360 and 390 px the latest button, the toolbar's eighth 44 px target,
only fit by wrapping onto a row of its own whenever the view was scrolled
up: the toolbar grew from 44 to 90 px and the terminal lost a row.

latest now floats over the terminal card's bottom-right corner at every
width, inside a new .term-stage around #term-pane, so the toolbar holds
seven targets on one row. It stays a 44x44 round target in the key look,
icon-only everywhere (its aria-label and title name it), slightly
see-through until pointed at or focused, and inset by the pane's padding.
It shows only when the cursor line is off screen: on the shell's buffer
while the view no longer follows the bottom, as before, and in a
fullscreen app, where follow is always off and the button would sit over
the app's own input line, only while the pane is scrolled away from the
frame's top, which a tap restores.

The touch-targets spec now checks at 360 and 390 px that the toolbar is
one row, that latest sits inside the pane's bottom-right corner rather
than in the toolbar, and the 44 px floor; the alternate-screen spec
expects the button hidden while the whole frame fits.
@JheisonMB
JheisonMB merged commit 6ba6525 into main Nov 4, 2026
6 checks passed
@JheisonMB
JheisonMB deleted the fix/audit-remote-and-plate branch November 4, 2026 07:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant