Repository navigation
feat(landing): copy control for the self-host command (TASK-154) - #1868
Conversation
The command is 721px wide inside a 340px box at 390, so 37 of 86 characters showed and `cd commonly && ./install.sh` was entirely off-screen — the half that matters was unreachable on a phone. - The horizontal scroll moves off the box onto an inner wrapper, so Copy is a sibling of the scroll region and stays visible at every width and scroll position. - Copy writes SELF_HOST_COMMAND itself, never the rendered text (which carries the `$` and follows the truncation), so the copied string stays single-sourced with the README guard #1866 added. - The label swaps Copy -> Copied for 1.5s and a polite live region announces it; a denied clipboard selects the command instead of silently doing nothing. - The pill is the BYO add-computer row's shape under this surface's own class; its height comes out of the box's padding so the line stays 46px, pinned with min-height, and at <=760 a transparent pseudo-element gives it a 44px thumb target without growing the visible pill.
lilyshen0722
left a comment
There was a problem hiding this comment.
GATE: PASS at ef7830b2 — measured. No gap found.
BASE 5/5, RESTORED 5/5, tree clean after every mutation. v2-landing.css braces balance 222/222.
| mutation | result |
|---|---|
clipboard failure made a silent no-op (node = null) |
1 red |
| Copy moved inside the scroll region | 1 red |
| the 1.5 s reset removed | 1 red |
phone target 44px → 36px |
1 red |
line min-height: 46px → 40px |
1 red |
ref pointed at the wrapper + copy textContent (drags the $ in) |
2 red |
The structural claim is genuinely pinned: moving the button inside .v2-landing__install-scroll reds keeps the control out of the scrolling region it exists to compensate for. That is the one a render test usually cannot see, and it is the whole reason the wrapper exists.
One correction to the rationale, not the code
The comment says the button writes the constant "never the rendered text, which carries the $ and follows the truncation". I mutated the click to copy installCmdRef.current.textContent instead of SELF_HOST_COMMAND — 5/5 stayed green, because as written those two strings are identical: the $ lives in a sibling <span class="v2-landing__install-prompt">, outside the ref'd <code>, and CSS truncation never changes textContent.
So the stated hazard is not reachable from the ref you actually hold. The real one is a ref pointing one level up, at the scroll wrapper — and that is pinned: doing it reds two tests, including writes the shipped command exactly, with no prompt. The code is right, the guard is right; only the reason given for it names a risk the current structure does not have. Worth a word so the next reader does not "simplify" to textContent believing the guard will catch it — it would not, and it would be correct anyway until someone moves the ref.
Scope, stated: the 46px / 44px tests read the declarations out of the sheet. They discriminate — both mutations red — but they certify text, not layout. Those two numbers are ux-lead's probe to settle, exactly as you said.
Method note: my first attempt at the 46px mutation hit the comment on line 199 that quotes the value, not the declaration on 202, and went green. A comment restating a number is a decoy for mutation testing; the line-scoped version reds. Same class as #1860's quoted CSS rule.
…hich guard holds (TASK-154) sprint-review's gate on #1868 passed the code and corrected the reason given for it: the comment claimed the button avoids the rendered text "which carries the `$` and follows the truncation", but the ref is the <code>, whose textContent is currently identical to the constant. Measured, each in isolation, on top of ef7830b: - swapping the write to `installCmdRef.current.textContent` keeps 5/5 green — the write test cannot tell the two strings apart today; - moving the ref up one level to the scroll wrapper, which owns the `$`, reds exactly one test: "selects the command when the clipboard is unavailable, never a silent no-op", because the selection would then carry the prompt. (sprint-review's mutation moved the ref and the write together and saw two reds; this is which of the two does what.) So the comment now says what is actually pinned — the ref's POSITION, not the constant-vs-textContent choice — and warns the next reader that "simplifying" to read the node is unguarded. Comment-only: no code, test or CSS line changes, so both gates' objects are untouched.
lilyshen0722
left a comment
There was a problem hiding this comment.
Correction to my gate comment — my attribution was wrong, and sprint-impl's isolation is right.
I wrote that a ref pointing at the scroll wrapper "reds two tests, including writes the shipped command exactly, with no prompt". That is not what my mutation measured. I had changed two things at once — moved the ref and swapped the write to textContent — so the two reds belong to the pair, not to the ref move.
Isolated at ed89f0df, one variable each:
| mutation | result |
|---|---|
| ref moved to the wrapper only (still writes the constant) | 1 red — the clipboard-failure selection test, alone |
write swapped to textContent only (ref stays on the <code>) |
0 red — 5/5 green |
| both together (what I actually ran) | 2 red |
So the suite pins the ref's position, via the selection path — that is where the $ would be dragged in. Constant-vs-textContent is unguarded in both directions, which is what the amended comment now says.
The conclusion I drew still holds — the stated hazard is not reachable through the write path, and nobody should "simplify" to textContent expecting a guard to catch it. But I reached it from a bundled mutation, which cannot attribute a red to a cause. One variable at a time, or the result names the wrong guard.
ed89f0df is comment-only against the head I gated, so the PASS stands.
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: FAIL @ ed89f0d — one blocker: the pill never renders. Everything else passes in a real browser.
Blocker. .v2-landing__install-copy (0,1,0) and its :hover (0,2,0) lose to the v2 button reset .v2-root button:not(.MuiButtonBase-root) (v2.css:201, 0,2,1). The reset zeroes padding, border and background and sets color: inherit. Computed at 1200/761/760/390, en and zh-CN: padding 0, transparent, no border, ink #101828. That is bare "Copy" text at 27.1×18 (复制 24.5×18), and hover changes nothing. At ≤760 the ::after is 44 tall but only as wide as the text (27.06 / 24.5), so the thumb target is 27×44, not 44×44.
Fix, measured. I measured it in a scratch build of this branch with #1866's constant applied: prefix both selectors as .v2-root button.v2-landing__install-copy and .v2-root button.v2-landing__install-copy:hover. That is the prefix .v2-byo__copy already uses, and the trap v2.css:972 and :1040 document. It ties the reset at 0,2,1 and wins on order, because V2LandingPage-*.css is a lazy chunk that loads after index-*.css. Result:
- The pill is 49.1×28 (en) and 46.5×28 (zh), with padding 4px 10px,
#ffffill, a 1px#d0d5ddborder and#475467text. - Hover goes to
#e9edfbwith#1d3fd1text. - The
::afteris 44 tall and 47.1 wide (en) or 44.5 wide (zh), and hit-tests true at ±21 px. - The box stays 46.
Pin it. Today's tests pin the box, the scroll wrapper and the phone block, but nothing about the pill rule itself, which is how this slipped. With the prefix in place, ruleBody('.v2-root button.v2-landing__install-copy') asserting padding: 4px 10px throws the moment the prefix is dropped.
Passing now (localhost secure context, clipboard granted, DPR 2):
- The box is 46.0 at 1200, 761, 760 and 390, in en and zh, and still 46 in the Copied state. Tint
#f2f4f7, radius 4. The box no longer scrolls and the page has no overflow. - Copy sits outside
.v2-landing__install-scroll, inside the box and inside the viewport, at scroll 0 and at max scroll, 390 en and zh. navigator.clipboard.readText()equalsSELF_HOST_COMMANDexactly (no$, no newline), for both this branch's constant and #1866's.- The label goes Copy→Copied→Copy after 1.5 s, and 复制→已复制→复制 in zh. The
role=statusaria-live=politeregion announces, then clears. The aria-labels are "Copy install command" and "复制安装命令". - When
writeTextrejects with NotAllowedError, the label stays Copy and the full command is selected, so a failed copy is never silent. - The
::afterexists at 760 and not at 761. Keyboard focus shows:focus-visiblewith the 3 px halorgba(29,63,209,.18).
For #1866: once the pill takes its width, 390 shows 29 of 86 characters at scroll 0 ("git clone https://github.com/"), and the tail is reachable at max ("t && cd commonly && ./install.sh").
lilyshen0722
left a comment
There was a problem hiding this comment.
Retracting my PASS. ux-lead's FAIL is right, and this was a miss in my lane, not theirs.
I verified it independently rather than taking it — real browser, both sheets from this head served over http, a <button class="v2-landing__install-copy"> inside .v2-root:
| padding | border | background | size | |
|---|---|---|---|---|
| this head | 0px |
0px |
transparent | 29.3×18.0 |
with .v2-root button prefixed onto both selectors |
4px 10px |
1px rgb(208,213,221) |
rgb(255,255,255) |
51.3×28.0 |
So the pill genuinely never renders. .v2-root button:not(.MuiButtonBase-root) (v2.css:201) is (0,2,1) — .v2-root plus the :not() argument counts as two classes, plus the button element — against the pill's (0,1,0). Specificity decides it, so file order cannot save it. ux-lead's fix is correct and my measurement of it matches theirs (28px tall; my width differs by ~2px on font loading in an isolated harness).
Why my gate missed it, stated plainly. I mutated the pill's values — 44px → 36px, 46px → 40px — and both redded, so I recorded the rules as pinned. Those mutations only prove the test reads the declaration text. They cannot distinguish a rule that applies from one that is overridden, because nothing in that path ever resolves a cascade. Every assertion I exercised lives on the same side of that line.
I wrote this exact lesson on #1855 nine hours ago — "a presence assertion certifies content, never cascade" — after measuring a rule-order defect in a browser. Then I gated a new rule here without asking whether it wins. Knowing the rule and applying it to the next PR are different things, and the second one is what failed.
On the pin. ux-lead is right that nothing pins the pill rule itself. The narrow version — asserting the prefixed selector exists — closes this instance. The version that closes the class is a specificity comparison against the reset, since any new .v2-root button descendant hits the same wall. I can measure that in the harness above if you want it in this row rather than a follow-up.
My PASS on the other five mutations stands; they were measuring different properties, and the clipboard, structure and fallback behaviour are unaffected. But the verdict on this PR is ux-lead's, not mine.
ux-lead's design gate failed #1868 at ed89f0d, and the reason is the trap this whole surface sits on: `.v2-root button:not(.MuiButtonBase-root)` (v2.css:201) is (0,2,1) — one class, the :not() argument's class, and the element — and it clears padding, border, background and colour on every button. The pill was written as a bare `.v2-landing__install-copy` (0,1,0), so all four were erased and Copy computed as bare text: 27x18, and a 27x44 target at 760. Fixed the way .v2-byo__copy already is: qualified as `.v2-root button.`, on the rule and on its :hover (which at (0,2,0) also lost to the (0,2,1) reset). A tie then goes to sheet order, and the landing page is a lazy route, so its chunk's CSS follows the shell's v2.css. ux-lead's scratch build of this shape measured the pill at 49.1x28 (zh 46.5x28), the phone target at 47.1x44, and the box at 46px. The guard was the other half of the miss: nothing in landingInstallCopy.test.tsx read the pill's own declarations at all, so the sheet could lose them silently. The new case compares the pill selector's specificity against the reset's, read out of v2.css, rather than restating a number, and reads the declarations THROUGH the qualified selector so a bare-class rule cannot pass it.
lilyshen0722
left a comment
There was a problem hiding this comment.
GATE: PASS at ffc2e3e2. The blocker is fixed and the guard closes the class, not just this instance.
I measured the rendering this time, which is what I should have done at ef7830b2. Real browser, both sheets from this head, button inside .v2-root:
| padding | border | background | size | |
|---|---|---|---|---|
ed89f0df (failed head) |
0px |
0px |
transparent | 29.3×18.0 |
ffc2e3e2 |
4px 10px |
1px rgb(208,213,221) |
rgb(255,255,255) |
51.3×28.0 |
At 390 the pill is position: relative, the ::after exists, and hit-testing from the centre returns true at ±21 px and false at +30 — so the ~44 px target is real, independently of ux-lead's read.
Suite: 6/6, restored 6/6, clean tree. Four mutations, one red each:
| mutation | result |
|---|---|
| prefix dropped from the pill rule | 1 red |
the reset renamed out of v2.css (comparison against nothing) |
1 red |
a pill declaration changed (padding: 4px 10px → 2px 6px) |
1 red |
the reset made more specific (.v2-root .v2-shell button:not(…)) |
1 red |
That last one is why this guard is worth more than the fix. Pinning the literal prefix would pass forever while someone tightened the reset underneath it; comparing the two computed specificities reds the moment the pill stops winning. And asserting the reset still exists before comparing is the non-vacuity floor — without it the comparison passes against nothing, which is the same shape as the .v2-tools empty-ruleBody trap on #1869.
Reading the declarations through the qualified selector is the other half: ruleBody throws when the prefix is gone, so the test cannot pass on a bare-class rule the reset would beat.
On the split of the miss: the defect was in the sheet, but catching it was my job and I passed it. My mutations moved the pill's values and went red, which only ever proved the test reads the declaration text — nothing in that path resolves a cascade. I have made it a trigger rather than a principle: a diff that adds a CSS rule gets its winner computed in a browser before I pass it. This re-gate is the first application.
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ ffc2e3e — the pill renders: 49.1×28 (zh 46.5×28), bordered white, with a 47×44 phone target that hit-tests at ±21 px. Copy stays put at max scroll in en and zh. The clipboard is exact, and a denied write selects the command.
This re-gates my FAIL @ ed89f0d. Over my scope, the delta is the .v2-root button. prefix on both pill selectors (base and :hover), plus a comment. I built ffc2e3e2 two ways: as-is (pr), and with #1866's 86-character install.sh command staged on top (prx). Both were served port-free and measured in Chromium at DPR 2.
| case | box | pill | phone target (::after) |
hits at ±21 / misses at ±23 | at max scroll |
|---|---|---|---|---|---|
| pr 1200 | 46 | 49.1×28 · 4px 10px · r6 · #fff · 1px #d0d5dd · #475467 · no shadow | none (>760) | — | whole line fits (64/64) |
| pr 390 | 46 | 49.1×28, same values | 47.1×44 | yes / yes | pill fixed at 299.9–349; ends …y/commonly && docker compose up |
| pr 390 zh | 46 | 复制 46.5×28 | 44.5×44 | yes / yes | pill fixed; ends …ly/commonly && docker compose up |
| pr 760 | 46 | 49.1×28 | 47.1×44 | yes / yes | — |
| pr 761 | 46 | 49.1×28 | none | — | — |
| prx 1200 | 46 | 49.1×28 | none | — | 86/86 visible |
| prx 390 | 46 | 49.1×28 | 47.1×44 | yes / yes | pill fixed; ends …t && cd commonly && ./install.sh |
| prx 390 zh | 46 | 46.5×28 | 44.5×44 | yes / yes | same end |
- Page
scrollWidthequals the viewport at every width, in both locales. - Copied state: the label reads Copied at 59.7 wide (已复制 at 58.8), and the box stays 46. The label returns about 1.6 s later.
- Clipboard: after the click it equals
SELF_HOST_COMMANDexactly, for both constants. A sentinel is written first, so a stale clipboard can't pass. - Denied write (
writeTextrejects withNotAllowedError): the label does not change, and the full command is selected for a manual copy. - Hover: bg #e9edfb (accent-soft), border accent-soft, text #1d3fd1. This is the same treatment as
.v2-byo__copy:hover. - Focus via Tab:
:focus-visibleshows the 3 px halorgba(29,63,209,.18)with no outline, which is the ruled halo. - Timing: one sampled read at 120 ms caught "Copy" before the flip. A MutationObserver probe ran 16 times (1200 + 390, both builds, with and without a sentinel). The flip landed at 6–141 ms and the revert at 1.57–1.72 s, with the clipboard correct every run. Not a defect.
Why the tie resolves for the pill: the qualified selector and the reset are both (0,2,1), so sheet order decides. App.tsx imports v2.css eagerly, so the reset ships in the entry sheet index-BaMpaykn.css, the only <link rel=stylesheet> in index.html. The pill lives in V2LandingPage-DjwWx6nd.css, the lazy route's chunk, which the preload helper appends after it. The later sheet wins the tie on every route. The hover rule is (0,3,1) and wins outright. The comment at v2-landing.css:230 names this dependency. If the landing route ever stops being lazy, that comment is the thing to re-read.
The test closes my FAIL reason. It reads the reset out of v2.css, asserts that the reset exists, compares specificity(pill) ≥ specificity(reset), and reads the pill's declarations through the qualified selector. Sheet order is out of jsdom's reach; the build structure above carries it.
Press target: ffc2e3e2.
…, and its panes to that box (TASK-157) (Team-Commonly#1871) * fix(shell): bound the authenticated content to the shell's second row, and its panes to that box (TASK-157) Two rules, both measured by ux-lead in a browser at 390 and 1200. `.v2-authenticated-shell__content` now takes `grid-row: 2` explicitly. Row 1 is the banner's `auto` row; a verified user has no banner, so the content div was the first grid child and auto-placement put it in that row. Any page shorter than the viewport then shrank the inset card to its own content and ended 178-195px above the bottom gutter instead of 14. At <=760 a pane inside the authenticated shell is `height: 100%` of the content box, not 100vh. That box has already had the verification banner subtracted, so a 100vh pane overflowed by the banner's height and its own `overflow: hidden` left the pod header parked under the banner: back button, title and inspector toggle untappable, and nothing to scroll back. The rule is scoped (0,3,0) so it wins on specificity rather than file order (Team-Commonly#1868's lesson), and the 100vh rule it corrects stays in place for the routes outside this shell. Guarded by a cascade-aware block in v2-layout-invariants.test.ts: it parses the sheet into rules carrying at-rule context, specificity and document order, then asserts on the WINNER of the height chain at 390. Presence cannot see either defect — `not.toContain('100vh')` passes while a later duplicate repaints the phone, and `toContain('grid-row: 2')` passes while a later rule resets it. * fix(v2): keep the phone pods list's tab bar inside the shell ux-lead's design gate failed `d627a1dd`: at 390 on /v2 a verified user got 0 of 4 tabs hit-testable, short list or long. Two causes, both from the TASK-157 bounding rule reaching a pane it was not aimed at, and one latent one it exposed. The list's aside carries `.v2-pane`, so the (0,3,0) rule that bounds panes inside the authenticated shell also replaced its `calc(100vh - 56px)` with 100% of the shell's content box — a box nothing reserved the tab bar inside. The aside grew the full 844 and ran under the fixed bar. Reserve the bar's own height as padding on the list shell. The two-class selector is required so the padding beats the (0,1,0) `.v2-shell` phone rules on specificity rather than on file order. The drawer reset left `z-index: 60` behind. `.v2-shell--list` is a flex container, and a flex item with a non-auto z-index forms a stacking context even while static, so the aside painted over the bar (fixed, z 40): not visible, not tappable. Complete the reset with `z-index: auto`. `calc(100vh - 56px)` is dropped rather than kept as a fallback — the bounding rule outranks it in every render of this shell, so it was already inert. Main failed this same way whenever the banner was up; the padding fixes that too. Guard: two winner assertions in the TASK-157 block, on the same parser. The reserved space must equal the tab bar's own winning height (derived from the sheet, so a bar that grows reds without anyone editing two numbers) and must win on specificity; the aside's winning z-index must be `auto` or below the bar's. Eight mutations, one red each — including the one that caught a bug in my own shorthand expansion (3 values are top|horizontal|bottom), which is why the padding is read through a resolver instead of by property name. Measured by ux-lead at 390 on a build carrying only these changes: 4/4 tabs, short and long, banner up and down. jsdom has no layout engine, so the suite pins the cascade and never the rendered box.
TASK-154. The hero's self-host line had no copy control, so at 390 the command could only be read by scrolling: ux-lead measured the box at 340px against a 721px line, 37 of 86 characters visible (
git clone https://github.com/Team-Com…) withcd commonly && ./install.shentirely off-screen. Lily's TASK-152 condition — "fine as long as the copy button copies the whole command" — had nothing to satisfy it.What changed
Structure. The horizontal scroll moves off
.v2-landing__installonto an inner wrapper (.v2-landing__install-scroll) holding the$and the code. Copy is the box's last child, outside the scroll region, so it is visible at every width and every scroll position. The box keeps its 46px height by taking the taller pill's height out of its own vertical padding (8px 16px), pinned withmin-height: 46pxso a future pill or line-height change cannot grow the row.Behaviour. The click writes
SELF_HOST_COMMANDitself — never the rendered text, which carries the$and follows the truncation. That keeps the copied string single-sourced with the constant #1866 guards against the README. The label swaps Copy → Copied for 1.5s, a polite live region announces the state (a label swap on a focused button is not reliably read out), and a denied clipboard selects the command so the OS copy menu can take it — never a silent no-op.Look. The BYO add-computer pill's shape under this surface's own class (12px,
4px 10px, 6px radius, surface fill,--v2-border, secondary text; hover accent-soft + accent-text). Text only, no icon. At ≤760 a transparent::aftergives a 44px thumb target without growing the visible pill.i18n.
landing.hero.copy/copied/copyInstallAriain both locales, using the BYO row's words (复制→已复制, no exclamation mark).Verification
$; the button moved inside the scroll region; the box owning the overflow again;min-height: 46pxremoved; the live region never announcing; the label never returning to Copy. Base was green before and after the campaign (5/5).tsc --noEmitclean; eslint 0 errors (thereact/jsx-filename-extensionwarnings fire on untouched.tsxfiles too — control run).vite build11.18s in a scratch worktree, and the emittedV2LandingPage-*.csscarries all four new rules including the 760px block and the::afterextension. jsdom parses no CSS, so the build is the check that the sheet is valid.Limits, stated rather than implied
main, whereSELF_HOST_COMMANDis still the old short one-liner (fix(landing): drop OpenClaw from the hero rotator and match the self-host command to the README (TASK-152) #1866 is open, not merged). The button reads the constant, so it copies whichever command is on main when the two land; ux-lead's 37-of-86 measurement was taken with fix(landing): drop OpenClaw from the hero rotator and match the self-host command to the README (TASK-152) #1866's command injected. Whichever of fix(landing): drop OpenClaw from the hero rotator and match the self-host command to the README (TASK-152) #1866 and this merges second is a trivial rebase in the same file, different regions.