Skip to content

feat(landing): copy control for the self-host command (TASK-154) - #1868

Merged
lilyshen0722 merged 3 commits into
mainfrom
feat/task-154-install-copy-button
Sep 25, 2026
Merged

lilyshen0722 merged 3 commits into
mainfrom
feat/task-154-install-copy-button

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

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…) with cd commonly && ./install.sh entirely 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__install onto 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 with min-height: 46px so a future pill or line-height change cannot grow the row.

Behaviour. The click writes SELF_HOST_COMMAND itself — 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 ::after gives a 44px thumb target without growing the visible pill.

i18n. landing.hero.copy / copied / copyInstallAria in both locales, using the BYO row's words (复制 → 已复制, no exclamation mark).

Verification

  • Six mutations, each applied in isolation, each red on exactly one assertion, every restore verified against the commit (0 lines differing): the clipboard write carrying the $; the button moved inside the scroll region; the box owning the overflow again; min-height: 46px removed; the live region never announcing; the label never returning to Copy. Base was green before and after the campaign (5/5).
  • Full frontend suite: 110 suites / 931 tests passing. tsc --noEmit clean; eslint 0 errors (the react/jsx-filename-extension warnings fire on untouched .tsx files too — control run).
  • Production build, not just the suite: vite build 11.18s in a scratch worktree, and the emitted V2LandingPage-*.css carries all four new rules including the 760px block and the ::after extension. jsdom parses no CSS, so the build is the check that the sheet is valid.

Limits, stated rather than implied

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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, #fff fill, a 1px #d0d5dd border and #475467 text.
  • Hover goes to #e9edfb with #1d3fd1 text.
  • The ::after is 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() equals SELF_HOST_COMMAND exactly (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=status aria-live=polite region announces, then clears. The aria-labels are "Copy install command" and "复制安装命令".
  • When writeText rejects with NotAllowedError, the label stays Copy and the full command is selected, so a failed copy is never silent.
  • The ::after exists at 760 and not at 761. Keyboard focus shows :focus-visible with the 3 px halo rgba(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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 scrollWidth equals 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_COMMAND exactly, for both constants. A sentinel is written first, so a stale clipboard can't pass.
  • Denied write (writeText rejects with NotAllowedError): 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-visible shows the 3 px halo rgba(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.

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 819fedd Sep 25, 2026
16 checks passed
lilyshen0722 added a commit to thantiklermcirony/commonly that referenced this pull request Oct 9, 2026
…, 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.
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