Improve live mode configure bar and pick UX (#242)

* Fix: tear down annotation overlay when Escape exits live pick mode.

The configure prompt auto-focuses and bypasses the global Escape handler, so its local path must hide the annot overlay; togglePick off now does the same as a safety net.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Improve live mode steer pill typing affordance.

Show a visible caret and placeholder when focused, expand on pointerdown, and drop the muddy border so the graphite surface carries the affordance alone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Improve live mode configure bar layout and pill styling.

Align pills and input on a shared text track, refine muted pill chrome with a quiet action border, and center the row with symmetric inset so spacing reads evenly in the 36px bar.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add x1 to live mode variant count picker.

The configure bar count pill now cycles 1→2→3→4→1 so users can request a single variant.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Polish live mode configure bar, edit badge, and action picker.

Refine selection pill layout and tooltips, shrink edit copy to an icon aligned with the outline, right-align the action picker, and sync demo styles and regression coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix live mode element nav when configure input is focused.

Passthrough empty arrow keys from the configure and steer prompts so handleKeyDown can move between pickable elements without breaking autofocus typing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove accidental live.js inject from Base.astro.

Strip the localhost helper script tag left over from local live mode iteration so the PR ships only intentional UI changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review findings: pick-cursor state sync, anchor recovery, e2e selectors.

Code review of this branch surfaced ten confirmed bugs plus three smaller
ones; this commit fixes all of them.

- Route every interaction-state transition through a new setLiveState()
  helper that re-syncs the pick-mode crosshair, fixing four confirmed
  cursor bugs: never appearing on pick toggle (sync ran before the state
  change), sticking through the configure phase, surviving teardown
  page-wide, and the style mounting inside the adapter's shadow root
  where it can't match the host document (now document.head).
- Anchor recovery: a matching id is decisive again (hashed class names
  and component tags broke recovery), empty-text elements can no longer
  match the fuzzy text passes (".includes('')" hole plus shortest-text
  preference), and the dead 2-class-subset fallback is removed.
- Selection pill: drop the hover-only "armed" guard so keyboard
  activation works; the pill arms on focus as well as hover.
- Configure chrome: remove the configure-bar tooltip on teardown, align
  restorePickerBarChrome padding with initBar (5px), share the
  configure-input stylesheet with the insert row, and sync the
  ui-core.mjs surface inventory with live-browser.js.
- Site demos: delete the stale duplicate .live-demo-ctx-selection rule
  that killed the teal pill on dark pages, and keep the configure-phase
  demo bar on the overlay's dark surface in light mode so the near-white
  prompt text stays readable.
- E2E/contract tests: match the icon-only submit button by aria-label
  ("Generate variants") instead of the removed "Go" text, and update
  source-contract pins for setLiveState and buildConfigureSubmitButton.

Verified: bun run test green, live-mode E2E 23/23 across all fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Wire insert voice button into syncVoiceUi listening state.

Voice on the insert configure row runs through the same 'configure' mode,
but syncVoiceUi only stamped data-listening/aria state on the replace
row's #impeccable-live-configure-voice, so the insert button never pulsed
while listening. Target whichever of the two row buttons is mounted, the
same either-row pattern syncConfigureInputChrome uses.

Addresses Bugbot review comment on PR #242.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reinject from source when the session wrapper lands during anchor recovery.

The anchor-recovery observer stood down as soon as the session's variant
wrapper appeared in the DOM, without running injectVariantsFromSource.
A wrapper can land incomplete (wrap HMR landed, variant insert did not),
which is exactly the case injectVariantsFromSource's existing-wrapper
replace path handles - so recovery ended with the bar stuck and no
variants. Route both the anchor-found and wrapper-landed cases through
injectVariantsFromSource, which owns wrapper replacement, recovery-flag
clearing, and variant display.

Addresses Bugbot review comment on PR #242.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restore inline edit drafts before configure chrome teardown disables editing.

teardownConfigureChrome called disableInlineEdit() ahead of hideBar(),
wiping inlineEditRows and the impeccableOriginalText metadata that
hideBar()'s EDITING-state restoreInlineEditDrafts() needs - so turning
Pick off mid "Edit copy" left edited DOM text in place, neither saved
nor canceled. Let hideBar() own the sequence: it restores drafts first,
then disables inline edit.

Addresses Bugbot review comment on PR #242.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-06-10 22:18:45 -07:00
committed by GitHub
co-authored by Cursor Claude Fable 5
parent 1863a44b23
commit 5b5e487a4f
17 changed files with 4849 additions and 1667 deletions
File diff suppressed because it is too large Load Diff
@@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-selection-pill',
'impeccable-live-input',
'impeccable-live-configure-voice',
'impeccable-live-configure-bar-tooltip',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
File diff suppressed because it is too large Load Diff
@@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-selection-pill',
'impeccable-live-input',
'impeccable-live-configure-voice',
'impeccable-live-configure-bar-tooltip',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
File diff suppressed because it is too large Load Diff
@@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-selection-pill',
'impeccable-live-input',
'impeccable-live-configure-voice',
'impeccable-live-configure-bar-tooltip',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
+40 -13
View File
@@ -197,15 +197,29 @@ import '../styles/testimonials.css';
<div class="live-demo-ctx" data-demo-ctx data-phase="hidden">
<div class="live-demo-ctx-row live-demo-ctx-row--configure">
<button type="button" class="live-demo-ctx-pill" data-demo-ctx-pill>
<span data-demo-cmd-name>adapt</span>
<span class="live-demo-ctx-pill-caret" aria-hidden="true">▾</span>
</button>
<span class="live-demo-ctx-input" data-demo-input>
<span data-demo-input-text></span><span class="live-demo-ctx-caret"></span>
</span>
<button type="button" class="live-demo-ctx-count">×3</button>
<button type="button" class="live-demo-ctx-go" data-demo-go>Go <span aria-hidden="true">→</span></button>
<div class="live-demo-ctx-command">
<span class="live-demo-ctx-input" data-demo-input>
<button type="button" class="live-demo-ctx-selection">h1</button>
<span data-demo-input-text></span><span class="live-demo-ctx-caret"></span>
</span>
</div>
<div class="live-demo-ctx-trailing">
<div class="live-demo-ctx-modifiers">
<button type="button" class="live-demo-ctx-action" data-demo-ctx-pill>
<span data-demo-cmd-name>adapt</span>
<span class="live-demo-ctx-action-caret" aria-hidden="true">▾</span>
</button>
<button type="button" class="live-demo-ctx-count">×3</button>
</div>
<div class="live-demo-ctx-actions">
<button type="button" class="live-demo-ctx-voice" aria-label="Voice input">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" x2="12" y1="19" y2="22"></line></svg>
</button>
<button type="button" class="live-demo-ctx-go" data-demo-go aria-label="Generate variants">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>
</button>
</div>
</div>
</div>
<LiveDemoPalette pick="colorize" />
<div class="live-demo-ctx-row live-demo-ctx-row--generating">
@@ -545,10 +559,23 @@ import '../styles/testimonials.css';
</div>
<div class="why-live-ctx live-demo-ctx" data-phase="configuring">
<div class="live-demo-ctx-row live-demo-ctx-row--configure">
<span class="live-demo-ctx-pill">bolder<span class="live-demo-ctx-pill-caret" aria-hidden="true">&#x25BE;</span></span>
<span class="live-demo-ctx-input"><span class="live-demo-ctx-caret"></span></span>
<span class="live-demo-ctx-count">&times;3</span>
<span class="live-demo-ctx-go">Go <span aria-hidden="true">&rarr;</span></span>
<div class="live-demo-ctx-command">
<span class="live-demo-ctx-input"><span class="live-demo-ctx-selection">h1</span><span class="live-demo-ctx-caret"></span></span>
</div>
<div class="live-demo-ctx-trailing">
<div class="live-demo-ctx-modifiers">
<span class="live-demo-ctx-action">bolder<span class="live-demo-ctx-action-caret" aria-hidden="true">&#x25BE;</span></span>
<span class="live-demo-ctx-count">&times;3</span>
</div>
<div class="live-demo-ctx-actions">
<span class="live-demo-ctx-voice" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" x2="12" y1="19" y2="22"></line></svg>
</span>
<span class="live-demo-ctx-go" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>
</span>
</div>
</div>
</div>
</div>
</div>
+23 -9
View File
@@ -106,15 +106,29 @@ import '../../styles/live-mode-kinpaku.css';
<div class="live-demo-ctx" data-demo-ctx data-phase="hidden">
<div class="live-demo-ctx-row live-demo-ctx-row--configure">
<button type="button" class="live-demo-ctx-pill" data-demo-ctx-pill>
<span data-demo-cmd-name>adapt</span>
<span class="live-demo-ctx-pill-caret" aria-hidden="true">&#x25BE;</span>
</button>
<span class="live-demo-ctx-input" data-demo-input>
<span data-demo-input-text></span><span class="live-demo-ctx-caret"></span>
</span>
<button type="button" class="live-demo-ctx-count">&times;3</button>
<button type="button" class="live-demo-ctx-go" data-demo-go>Go <span aria-hidden="true">&rarr;</span></button>
<div class="live-demo-ctx-command">
<span class="live-demo-ctx-input" data-demo-input>
<button type="button" class="live-demo-ctx-selection">h1</button>
<span data-demo-input-text></span><span class="live-demo-ctx-caret"></span>
</span>
</div>
<div class="live-demo-ctx-trailing">
<div class="live-demo-ctx-modifiers">
<button type="button" class="live-demo-ctx-action" data-demo-ctx-pill>
<span data-demo-cmd-name>adapt</span>
<span class="live-demo-ctx-action-caret" aria-hidden="true">&#x25BE;</span>
</button>
<button type="button" class="live-demo-ctx-count">&times;3</button>
</div>
<div class="live-demo-ctx-actions">
<button type="button" class="live-demo-ctx-voice" aria-label="Voice input">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" x2="12" y1="19" y2="22"></line></svg>
</button>
<button type="button" class="live-demo-ctx-go" data-demo-go aria-label="Generate variants">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>
</button>
</div>
</div>
</div>
<LiveDemoPalette pick="delight" />
<div class="live-demo-ctx-row live-demo-ctx-row--generating">
+154 -29
View File
@@ -1198,18 +1198,17 @@
margin: 0 4px 0 6px;
border-radius: 5px;
background: var(--ks-graphite);
border: 1px solid oklch(92% 0 0 / 0.1);
border: 1px solid transparent;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.15s ease;
transition: width 0.18s ease, border-color 0.15s ease;
}
.home-kinpaku .live-demo-gbar-chat.is-expanded,
.live-mode-kinpaku .live-demo-gbar-chat.is-expanded {
width: min(220px, 42vw);
cursor: text;
border-color: oklch(78% 0.12 82 / 0.55);
}
.home-kinpaku .live-demo-gbar-chat-icon,
@@ -1251,9 +1250,23 @@
outline: none;
opacity: 0;
pointer-events: none;
caret-color: var(--ks-kinpaku);
transition: opacity 0.15s ease;
}
.home-kinpaku .live-demo-gbar-chat[data-input-focused="true"]:not([data-expanded="true"]) .live-demo-gbar-chat-hint,
.live-mode-kinpaku .live-demo-gbar-chat[data-input-focused="true"]:not([data-expanded="true"]) .live-demo-gbar-chat-hint {
display: none;
}
.home-kinpaku .live-demo-gbar-chat[data-input-focused="true"]:not([data-expanded="true"]) .live-demo-gbar-chat-input,
.live-mode-kinpaku .live-demo-gbar-chat[data-input-focused="true"]:not([data-expanded="true"]) .live-demo-gbar-chat-input {
width: auto;
padding: 0 4px;
opacity: 1;
pointer-events: auto;
}
.home-kinpaku .live-demo-gbar-chat.is-expanded .live-demo-gbar-chat-hint,
.live-mode-kinpaku .live-demo-gbar-chat.is-expanded .live-demo-gbar-chat-hint {
display: none;
@@ -1316,47 +1329,159 @@
/* Context bar sub-elements (the inline action panel that appears
next to a picked element during "configure" / "cycling" / "accepted"
states). */
.home-kinpaku .live-demo-ctx-pill,
.live-mode-kinpaku .live-demo-ctx-pill {
.home-kinpaku .live-demo-ctx[data-phase="configuring"],
.live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] {
padding: 0;
background: var(--ks-graphite-2);
border: 1px solid oklch(92% 0 0 / 0.12);
color: var(--ks-champagne);
overflow: hidden;
}
/* Even heights across the configure row: the pill, input, count, and Go button
share one 30px baseline instead of stepping up and down. */
.home-kinpaku .live-demo-ctx-row--configure > *,
.live-mode-kinpaku .live-demo-ctx-row--configure > * {
height: 30px;
box-sizing: border-box;
.home-kinpaku .live-demo-ctx-command,
.live-mode-kinpaku .live-demo-ctx-command {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: 36px;
padding: 0 6px 0 7px;
background: transparent;
border: 0;
overflow: hidden;
}
.home-kinpaku .live-demo-ctx-trailing,
.live-mode-kinpaku .live-demo-ctx-trailing {
display: inline-flex;
align-items: stretch;
flex-shrink: 0;
border-left: 1px solid oklch(92% 0 0 / 0.12);
}
.home-kinpaku .live-demo-ctx-modifiers,
.live-mode-kinpaku .live-demo-ctx-modifiers {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 10px;
flex-shrink: 0;
}
.home-kinpaku .live-demo-ctx-pill-caret,
.live-mode-kinpaku .live-demo-ctx-pill-caret {
.home-kinpaku .live-demo-ctx-selection,
.live-mode-kinpaku .live-demo-ctx-selection {
box-sizing: border-box;
min-width: 28px;
height: auto;
padding: 1px 4px;
border: 1px solid oklch(70% 0.12 188);
border-radius: 7px;
flex-shrink: 0;
background: transparent;
color: oklch(70% 0.12 188);
font-family: var(--ks-mono);
font-size: 12px;
font-weight: 600;
line-height: 18px;
transition: background 0.15s ease, color 0.15s ease;
}
.home-kinpaku .live-demo-ctx-action,
.home-kinpaku .live-demo-ctx-count,
.live-mode-kinpaku .live-demo-ctx-action,
.live-mode-kinpaku .live-demo-ctx-count {
box-sizing: border-box;
height: 18px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--ks-text-muted);
}
.home-kinpaku .live-demo-ctx-input,
.live-mode-kinpaku .live-demo-ctx-input {
background: var(--ks-graphite);
border: 1px solid oklch(92% 0 0 / 0.12);
color: var(--ks-champagne);
}
.home-kinpaku .live-demo-ctx-caret,
.live-mode-kinpaku .live-demo-ctx-caret {
background: var(--ks-kinpaku);
font-size: 12px;
font-weight: 500;
line-height: 18px;
transition: color 0.12s ease;
}
.home-kinpaku .live-demo-ctx-count,
.live-mode-kinpaku .live-demo-ctx-count {
background: var(--ks-graphite-2);
border: 1px solid oklch(92% 0 0 / 0.12);
font-family: var(--ks-mono);
font-weight: 600;
}
.home-kinpaku .live-demo-ctx-row--configure,
.live-mode-kinpaku .live-demo-ctx-row--configure {
width: 100%;
min-height: 36px;
}
.home-kinpaku .live-demo-ctx-actions,
.live-mode-kinpaku .live-demo-ctx-actions {
display: inline-flex;
align-items: stretch;
border-left: 1px solid oklch(92% 0 0 / 0.12);
}
.home-kinpaku .live-demo-ctx-voice,
.live-mode-kinpaku .live-demo-ctx-voice {
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-right: 1px solid oklch(92% 0 0 / 0.12);
border-radius: 0;
background: transparent;
color: var(--ks-text-muted);
}
.home-kinpaku .live-demo-ctx-actions .live-demo-ctx-go,
.live-mode-kinpaku .live-demo-ctx-actions .live-demo-ctx-go {
width: 36px;
height: 36px;
padding: 0;
border-radius: 0;
border-left: 0;
}
.home-kinpaku .live-demo-ctx-action-caret,
.live-mode-kinpaku .live-demo-ctx-action-caret {
font-size: 10px;
color: inherit;
}
.home-kinpaku .live-demo-ctx-selection:hover,
.live-mode-kinpaku .live-demo-ctx-selection:hover {
background: oklch(27% 0 0);
}
.home-kinpaku .live-demo-ctx-action:hover,
.home-kinpaku .live-demo-ctx-count:hover,
.live-mode-kinpaku .live-demo-ctx-action:hover,
.live-mode-kinpaku .live-demo-ctx-count:hover {
color: var(--ks-text);
}
.home-kinpaku .live-demo-ctx-input,
.live-mode-kinpaku .live-demo-ctx-input {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
height: 18px;
padding: 0;
background: transparent;
border: 0;
border-radius: 0;
font-size: 12px;
font-weight: 500;
line-height: 18px;
color: oklch(94% 0.02 82);
}
.home-kinpaku .live-demo-ctx-caret,
.live-mode-kinpaku .live-demo-ctx-caret {
background: oklch(94% 0.02 82);
}
.home-kinpaku .live-demo-ctx-go,
.live-mode-kinpaku .live-demo-ctx-go,
.home-kinpaku .live-demo-ctx-accept,
+48 -9
View File
@@ -1364,15 +1364,54 @@ html.light .live-mode-kinpaku .live-demo-gbar-chat-input {
was an 11%-darker olive-cast block that read as heavy and unrefined on the
white bar. A barely-there warm off-white recess with a crisp hairline reads
as polished product chrome — the border does the separating, not a gray slab. */
html.light .home-kinpaku .live-demo-ctx-pill,
html.light .home-kinpaku .live-demo-ctx-input,
html.light .home-kinpaku .live-demo-ctx-count,
html.light .live-mode-kinpaku .live-demo-ctx-pill,
html.light .live-mode-kinpaku .live-demo-ctx-input,
html.light .live-mode-kinpaku .live-demo-ctx-count {
background: oklch(97.5% 0.004 95);
border-color: oklch(25% 0.02 95 / 0.14);
color: var(--ks-text);
/* The configure-phase bar mirrors the real overlay chrome, which keeps a dark
surface in both themes. The pale light-mode bar from the rule above would
swallow the near-white prompt text below, so the configuring phase opts back
into the overlay's dark surface. */
html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"],
html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] {
background: oklch(15% 0.008 95);
border-color: oklch(92% 0 0 / 0.14);
}
html.light .home-kinpaku .why-live-frame > .why-live-ctx.live-demo-ctx[data-phase="configuring"] {
background: oklch(15% 0.008 95);
border-color: oklch(92% 0 0 / 0.14);
}
html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-input,
html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-input {
background: transparent;
border-color: transparent;
color: oklch(94% 0.02 82);
}
html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-caret,
html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-caret {
background: oklch(94% 0.02 82);
}
html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-selection,
html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-selection {
background: transparent;
border: 1px solid oklch(70% 0.12 188);
color: oklch(70% 0.12 188);
}
html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-action,
html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-count,
html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-action,
html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-count {
background: transparent;
border: 0;
/* Light muted tone from the base sheet: these sit on the dark configure
surface, where the light-theme text-muted token would vanish. */
color: oklch(63% 0.024 82);
}
html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-voice,
html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-voice {
color: oklch(63% 0.024 82);
}
html.light .home-kinpaku .live-demo-ctx-go,
+135 -36
View File
@@ -417,64 +417,163 @@
display: flex;
}
/* Configure row: [delight ▾] [input] [×3] [Go →] */
.live-demo-ctx-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
background: var(--color-ink);
color: var(--color-paper);
border: 0;
border-radius: 6px;
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
.live-demo-ctx[data-phase="configuring"] {
padding: 0;
background: oklch(15% 0.008 95);
overflow: hidden;
}
.live-demo-ctx-pill-caret { font-size: 9px; opacity: 0.7; margin-left: 2px; }
.live-demo-ctx-input {
display: inline-flex;
/* Configure row: [h1 pill | input …] | [adapt ▾ ×3 | voice →] */
.live-demo-ctx-row--configure {
width: 100%;
min-height: 36px;
}
.live-demo-ctx-command {
display: flex;
align-items: center;
flex: 1;
min-width: 180px;
padding: 5px 8px;
height: 36px;
padding: 0 6px 0 7px;
background: transparent;
border: 0;
overflow: hidden;
}
.live-demo-ctx-trailing {
display: inline-flex;
align-items: stretch;
flex-shrink: 0;
border-left: 1px solid oklch(92% 0 0 / 0.12);
}
.live-demo-ctx-modifiers {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 10px;
flex-shrink: 0;
}
.live-demo-ctx-selection {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
box-sizing: border-box;
min-width: 28px;
height: auto;
padding: 1px 4px;
background: transparent;
color: oklch(70% 0.12 188);
border: 1px solid oklch(70% 0.12 188);
border-radius: 7px;
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
line-height: 18px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s ease, color 0.15s ease;
}
.live-demo-ctx-selection:hover {
background: oklch(27% 0 0);
}
.live-demo-ctx-action,
.live-demo-ctx-count {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
box-sizing: border-box;
height: 18px;
padding: 0;
background: transparent;
color: oklch(63% 0.024 82);
border: 0;
border-radius: 0;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-ink);
font-weight: 500;
line-height: 18px;
cursor: pointer;
white-space: nowrap;
transition: color 0.12s ease;
}
.live-demo-ctx-count {
font-family: var(--font-mono);
font-weight: 600;
}
.live-demo-ctx-action:hover,
.live-demo-ctx-count:hover {
color: oklch(84% 0.035 82);
}
.live-demo-ctx-action-caret {
font-size: 10px;
margin-left: 2px;
color: inherit;
}
.live-demo-ctx-input {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
height: 18px;
padding: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
line-height: 18px;
color: oklch(94% 0.02 82);
background: transparent;
border: 0;
}
.live-demo-ctx-actions {
display: inline-flex;
align-items: stretch;
flex-shrink: 0;
border-left: 1px solid oklch(92% 0 0 / 0.12);
}
.live-demo-ctx-voice {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: transparent;
color: oklch(63% 0.024 82);
border: 0;
border-right: 1px solid oklch(22% 0 0);
border-radius: 0;
cursor: pointer;
}
.live-demo-ctx-caret {
display: inline-block;
width: 1px;
height: 13px;
background: var(--color-ink);
background: oklch(94% 0.02 82);
margin-left: 2px;
animation: liveDemoCaret 1s steps(1) infinite;
}
@keyframes liveDemoCaret {
50% { opacity: 0; }
}
.live-demo-ctx-count {
padding: 4px 6px;
background: transparent;
border: 1px solid var(--color-mist);
border-radius: 5px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--color-ash);
cursor: pointer;
}
.live-demo-ctx-go {
padding: 5px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
box-sizing: border-box;
padding: 0;
background: var(--color-accent);
color: var(--color-paper);
border: 0;
border-radius: 6px;
border-left: 1px solid oklch(22% 0 0);
border-radius: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
font-size: 0;
line-height: 0;
cursor: pointer;
white-space: nowrap;
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-selection-pill',
'impeccable-live-input',
'impeccable-live-configure-voice',
'impeccable-live-configure-bar-tooltip',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
+179 -7
View File
@@ -128,7 +128,7 @@ describe('live-browser.js regression guards', () => {
it('exits inline editing directly on outside click', () => {
assert.match(
SOURCE,
/function cancelEditingToPicking\(\) \{[\s\S]{0,600}?state = 'PICKING';/,
/function cancelEditingToPicking\(\) \{[\s\S]{0,600}?setLiveState\('PICKING'\);/,
'outside-click editing cancel should avoid rebuilding configure UI before hiding it',
);
assert.match(
@@ -201,7 +201,7 @@ describe('live-browser.js regression guards', () => {
);
assert.match(
SOURCE,
/function handleServerLost\(\)[\s\S]{0,300}?const recoveryState = currentSessionId \? state : 'IDLE';[\s\S]{0,1200}?state = recoveryState;[\s\S]{0,120}?if \(currentSessionId\) saveSession\(\);/,
/function handleServerLost\(\)[\s\S]{0,300}?const recoveryState = currentSessionId \? state : 'IDLE';[\s\S]{0,1200}?setLiveState\(recoveryState\);[\s\S]{0,120}?if \(currentSessionId\) saveSession\(\);/,
'server-lost cleanup should keep the current session phase in local recovery state instead of rewriting it to GENERATING',
);
});
@@ -305,6 +305,61 @@ describe('live-browser.js regression guards', () => {
/steer-blur-recover/,
'steer blur should recover focus for type-to-steer when not selecting page text',
);
assert.match(
SOURCE,
/function syncPageChatFocusRing\(\)[\s\S]{0,900}?typingReady[\s\S]{0,500}?pageChatInput\.style\.opacity = '1'/,
'collapsed steer focus must reveal the real input and caret instead of a patina truncated label',
);
assert.match(
SOURCE,
/armPageChatForTyping\([\s\S]{0,120}?expand: true/,
'steer pill pointerdown must expand before click so focus never flashes a dead collapsed state',
);
assert.match(
SOURCE,
/function syncPageChatChrome\(\)[\s\S]{0,400}?pageChatEl\.style\.borderColor = 'transparent'/,
'steer pill must stay borderless; surface contrast carries the affordance',
);
assert.match(
SOURCE,
/floating bar surface IS the input; modifier pills sit left of the field/,
'element configure bar must use the bar surface as the input, not a nested field',
);
});
it('empty configure and steer inputs passthrough arrow nav to handleKeyDown', () => {
assert.match(
SOURCE,
/function shouldPassthroughElementNav\(deepActive, e\)[\s\S]{0,500}?PREFIX \+ '-input' && state === 'CONFIGURING'/,
'configure prompt must passthrough empty arrow keys for sibling navigation',
);
assert.match(
SOURCE,
/function shouldPassthroughElementNav\(deepActive, e\)[\s\S]{0,700}?PREFIX \+ '-page-chat-input' && state === 'PICKING'/,
'steer prompt must passthrough empty arrow keys while picking',
);
assert.match(
SOURCE,
/&& !shouldPassthroughElementNav\(deepActive, e\)/,
'global input guard must honor empty-input arrow passthrough',
);
});
it('configure input Escape tears down annotation overlay before returning to picking', () => {
// The configure prompt auto-focuses. While focused, the global keydown
// handler bails on own() inputs, so this local Escape path must hide the
// annotation overlay. Leaving it active strands a crosshair layer that
// flashes comment pins on every click after pick mode is turned off.
assert.match(
SOURCE,
/input\.addEventListener\('keydown', \(e\) => \{[\s\S]{0,400}?if \(e\.key === 'Escape'\) \{[\s\S]{0,200}?exitConfigureToPicking\('configure-input-escape'\)/,
'configure input Escape must call exitConfigureToPicking so annot overlay is removed',
);
assert.match(
SOURCE,
/function togglePick\(\)[\s\S]{0,500}?teardownConfigureChrome\(\);/,
'togglePick off must tear down configure chrome so a stuck annot overlay cannot survive IDLE',
);
});
it('pick mode preference persists in localStorage', () => {
@@ -325,7 +380,7 @@ describe('live-browser.js regression guards', () => {
);
assert.match(
SOURCE,
/if \(state === 'IDLE' && \(pickActive \|\| insertActive\)\) state = 'PICKING';/,
/if \(state === 'IDLE' && \(pickActive \|\| insertActive\)\) setLiveState\('PICKING'\);/,
'SSE connected must arm insert mode when saved preference has insert on',
);
});
@@ -386,9 +441,19 @@ describe('live-browser.js regression guards', () => {
assert.match(SOURCE, /border: '2px dotted ' \+ BP\.accent/, 'placeholder border matches insert line (dotted)');
assert.match(
SOURCE,
/function syncPageInteractionCursor\(\)[\s\S]{0,280}?cursorForInsertAxis/,
/function syncPageInteractionCursor\(\)[\s\S]{0,420}?cursorForInsertAxis/,
'insert picking cursor follows row/column axis',
);
assert.match(
SOURCE,
/function ensurePickCursorStyle\(\)[\s\S]{0,420}?cursor: crosshair !important/,
'pick mode injects a crosshair cursor that wins over page pointer styles',
);
assert.match(
SOURCE,
/document\.documentElement\.classList\.add\(PICK_CURSOR_CLASS\)/,
'pick mode toggles a document-level class for the crosshair cursor',
);
assert.match(SOURCE, /function hitSiblingInsertGap\(/, 'insert mode detects gaps between siblings');
assert.match(SOURCE, /function resolveInsertHover\(/, 'insert hover resolves axis-aware boundaries');
assert.match(SOURCE, /data-impeccable-placeholder-resize/, 'placeholder edge handles on annotation overlay');
@@ -406,12 +471,12 @@ describe('live-browser.js regression guards', () => {
);
assert.match(
SOURCE,
/buildInsertConfigureRow[\s\S]*?const count = el\('button', \{[\s\S]{0,320}?height: '28px'/,
'insert count toggle must match input height',
/buildInsertConfigureRow[\s\S]*?buildConfigureCountControl\(/,
'insert count toggle uses the same inline bar control as configure mode',
);
assert.match(
SOURCE,
/buildInsertConfigureRow[\s\S]*?const create = el\('button', \{[\s\S]{0,320}?height: '28px'/,
/buildInsertConfigureRow[\s\S]*?buildConfigureSubmitButton\(/,
'insert Create button must match input height',
);
assert.match(SOURCE, /function resolveBarAnchor\(\)/, 'bar positions from a connected anchor');
@@ -463,6 +528,113 @@ describe('live-browser.js regression guards', () => {
);
});
it('configure row groups selection and input on the left, trailing controls before submit', () => {
assert.match(
SOURCE,
/function configureModifierPillStyle\(extra = \{\}\)[\s\S]{0,480}?background: 'transparent'[\s\S]{0,120}?color: P\.textDim/,
'configure modifier chips use muted global-bar idle chrome, not active gold',
);
assert.match(
SOURCE,
/const CONFIGURE_ROW_TRACK_H = '18px'/,
'configure row shares one text track height across pills and input',
);
assert.match(
SOURCE,
/function configureInputFieldStyle\(extra = \{\}\)[\s\S]{0,320}?height: CONFIGURE_ROW_TRACK_H/,
'configure input uses the same track height as modifier pills',
);
assert.match(
SOURCE,
/const CONFIGURE_PILL_PAD_Y = '3px'/,
'modifier pills share 3px vertical padding',
);
assert.match(
SOURCE,
/function configureInputShellStyle\(\)[\s\S]{0,200}?alignItems: 'center'[\s\S]{0,120}?padding: '0 6px 0 ' \+ CONFIGURE_BAR_INSET/,
'configure shell vertically centers the row; inset matches centered pill margin',
);
assert.match(
SOURCE,
/inputShell\.appendChild\(buildSelectionPill[\s\S]{0,120}?inputShell\.appendChild\(input\)/,
'selection pill and prompt input share the left side of the bar',
);
assert.match(
SOURCE,
/buildConfigureTrailingCluster\(\[action, count\], voiceBtn, go\)/,
'freeform and variant count sit just left of voice and submit',
);
});
it('configure bar keeps selection pill, inline controls, outline, and instant tooltips', () => {
assert.match(SOURCE, /function buildSelectionPill\(/, 'selected element tag lives in the configure bar');
assert.match(SOURCE, /CONFIGURE_SELECTION_PILL_PAD = '1px 4px'/, 'selection pill uses 1px 4px padding');
assert.match(
SOURCE,
/function configureSelectionPillStyle\(extra = \{\}\)[\s\S]{0,400}?color: P\.patina/,
'selection pill label uses patina text color',
);
assert.doesNotMatch(
SOURCE,
/function configureSelectionPillStyle\(extra = \{\}\)[\s\S]{0,400}?minHeight:/,
'selection pill must not impose a min-height',
);
assert.match(
SOURCE,
/gridArea: '1 \/ 1'/,
'selection pill swaps tag and clear faces without resizing the bar',
);
assert.match(
SOURCE,
/function shouldShowHighlightTagTooltip\(\)[\s\S]{0,160}?state !== 'CONFIGURING'/,
'configure mode keeps the outline but drops the floating tag tooltip',
);
assert.match(SOURCE, /function buildConfigureActionControl\(/, 'action dropdown is an inline bar control, not a pill');
assert.match(SOURCE, /function buildConfigureCountControl\(/, 'variant count is an inline bar control, not a pill');
assert.match(
SOURCE,
/const hiTarget = resolveBarAnchor\(\);[\s\S]{0,200}?showHighlight\(hiTarget\);/,
'scroll tracking keeps the element outline visible while configuring',
);
assert.match(SOURCE, /function showConfigureBarTooltip\(/, 'configure controls use instant custom tooltips');
assert.doesNotMatch(
SOURCE,
/count\.title = controlsLocked \? 'Apply is still running' : 'Variants: click to change'/,
'variant count must not rely on native title tooltips',
);
});
it('variant count pill cycles through x1', () => {
assert.match(
SOURCE,
/function cycleSelectedCount\(\)[\s\S]{0,200}?selectedCount = VARIANT_COUNT_MIN/,
'variant count must wrap back to x1 after the max',
);
assert.doesNotMatch(
SOURCE,
/selectedCount >= 4 \? 2/,
'variant count must not skip x1 by wrapping 4 back to 2',
);
});
it('variant injection resolves the picked anchor before entering recovery', () => {
assert.match(
SOURCE,
/function resolveLiveInjectionAnchor\(originalMarkup\)/,
'variant source fallback must try the live picked element before giving up on anchor resolution',
);
assert.match(
SOURCE,
/pickedAnchor: pickedAnchorSnapshot/,
'picked anchor snapshot must persist in session storage for resume and reinjection',
);
assert.doesNotMatch(
SOURCE,
/showToast\('Variants ready\. Reveal the selected element to resume\.'/,
'recovery chrome already shows this message in the generating bar; a duplicate toast stacks two bars',
);
});
it('editing focus timeout does not read a stale inline edit row', () => {
assert.doesNotMatch(
SOURCE,
+7 -2
View File
@@ -93,9 +93,14 @@ describe('live-browser source contracts', () => {
);
assert.match(
SOURCE,
/function buildConfigureRow\(\)[\s\S]{0,80}?const controlsLocked = pendingApplyInFlight === true;[\s\S]*?go\.disabled = controlsLocked;/,
/function buildConfigureRow\(\)[\s\S]{0,80}?const controlsLocked = pendingApplyInFlight === true;[\s\S]*?const go = buildConfigureSubmitButton\(\{\s*controlsLocked,/,
'Configure controls should render disabled while manual copy edits are applying',
);
assert.match(
SOURCE,
/function buildConfigureSubmitButton\(\{ controlsLocked[\s\S]{0,700}?btn\.disabled = controlsLocked;/,
'the configure submit button must disable itself while manual copy edits are applying',
);
assert.match(
SOURCE,
/function handleMouseMove\(e\) \{[\s\S]{0,80}?if \(pendingApplyInFlight\) return;/,
@@ -293,7 +298,7 @@ describe('live-browser source contracts', () => {
);
assert.match(
SOURCE,
/case 'error':\s*if \(pendingAcceptedSession\?\.id && msg\.id === pendingAcceptedSession\.id\) \{[\s\S]{0,80}?pendingAcceptedSession = null;[\s\S]{0,80}?state = 'CYCLING';[\s\S]{0,80}?updateBarContent\('cycling'\);[\s\S]{0,160}?break;/,
/case 'error':\s*if \(pendingAcceptedSession\?\.id && msg\.id === pendingAcceptedSession\.id\) \{[\s\S]{0,80}?pendingAcceptedSession = null;[\s\S]{0,80}?setLiveState\('CYCLING'\);[\s\S]{0,80}?updateBarContent\('cycling'\);[\s\S]{0,160}?break;/,
'an SSE error for a queued accept should invalidate pending accept state and keep variants retryable',
);
assert.equal(
+33 -21
View File
@@ -5,7 +5,8 @@
* Selector strategy: live-browser.js uses deterministic ids (`impeccable-live-*`)
* for the global bar, per-element bar, action picker, and params panel. Buttons
* inside the per-element bar are matched by visible text or unicode glyph
* (`Go →`, `← / →`, `✓ Accept`, `✕`). All selectors below come from
* (`← / →`, `✓ Accept`, `✕`), or by aria-label for icon-only buttons (the
* configure submit button). All selectors below come from
* skill/scripts/live-browser.js — keep this file in sync if
* the bar's text content changes.
*/
@@ -18,6 +19,9 @@ const GLOBAL_BAR_ID = '#impeccable-live-global-bar';
const PICKER_ID = '#impeccable-live-picker';
const EDIT_BADGE_ID = '#impeccable-live-edit-badge';
const PENDING_DOCK_ID = '#impeccable-live-pending-dock';
// The configure-row submit button is icon-only; its accessible name is the
// only stable handle (see buildConfigureSubmitButton in live-browser.js).
const GO_BUTTON_ARIA_LABEL = 'Generate variants';
const STEER_CHAT_ID = '#impeccable-live-page-chat';
const STEER_INPUT_ID = '#impeccable-live-page-chat-input';
const PICK_TOGGLE = '#impeccable-live-pick-toggle';
@@ -423,20 +427,21 @@ export async function pickElement(page, selector, opts = {}) {
await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 1 });
}
}
// Wait specifically for the Configure-row Go button to be in the bar.
// Wait specifically for the Configure-row submit button to be in the bar.
// pickElement returning before that race-conditions with clickGo on
// fixtures whose framework re-renders right after pick (modal open, tab
// switch). Anchoring the wait on the Go button's text is robust: the bar
// can be visible-but-empty (state=PICKING) before showBar('configure')
// populates the row.
// switch). Anchoring the wait on the submit button's accessible name is
// robust: the bar can be visible-but-empty (state=PICKING) before
// showBar('configure') populates the row, and the button itself is
// icon-only.
await page.waitForFunction(
(barSel) => {
({ barSel, goLabel }) => {
const bar = window.__impeccableLiveQuery(barSel);
if (!bar) return false;
const btns = [...bar.querySelectorAll('button')];
return btns.some((b) => /Go\b/.test(b.textContent || ''));
return btns.some((b) => (b.getAttribute('aria-label') || '') === goLabel);
},
BAR_ID,
{ barSel: BAR_ID, goLabel: GO_BUTTON_ARIA_LABEL },
{ timeout: 5_000 },
);
}
@@ -532,17 +537,17 @@ export async function setCount(page, count) {
export async function clickGo(page) {
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
await clickBarButton(page, /Go\b/);
await clickBarButton(page, { ariaLabel: GO_BUTTON_ARIA_LABEL });
const advanced = await page.waitForFunction(
(barSel) => {
({ barSel, goLabel }) => {
const bar = window.__impeccableLiveQuery(barSel);
if (!bar) return false;
const text = bar.textContent || '';
if (/Generating\b/.test(text)) return true;
if (/\d+\s*\/\s*\d+/.test(text)) return true;
return ![...bar.querySelectorAll('button')].some((button) => /Go\b/.test(button.textContent || ''));
return ![...bar.querySelectorAll('button')].some((button) => (button.getAttribute('aria-label') || '') === goLabel);
},
BAR_ID,
{ barSel: BAR_ID, goLabel: GO_BUTTON_ARIA_LABEL },
{ timeout: 3_000 },
).then(() => true, (err) => {
lastErr = err;
@@ -616,15 +621,21 @@ export async function clickPrev(page) {
await clickBarButton(page, '←');
}
function barButtonMatch(label) {
if (label instanceof RegExp) return { kind: 'regex', source: label.source, flags: label.flags };
if (label && typeof label === 'object' && label.ariaLabel) return { kind: 'aria', value: label.ariaLabel };
return { kind: 'text', value: String(label) };
}
async function clickBarButton(page, label) {
const textMatch = label instanceof RegExp
? { kind: 'regex', source: label.source, flags: label.flags }
: { kind: 'text', value: String(label) };
const textMatch = barButtonMatch(label);
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
await installLiveQueryHelpers(page);
const button = page.locator(`${BAR_ID} button`, { hasText: label });
const button = textMatch.kind === 'aria'
? page.locator(`${BAR_ID} button[aria-label="${textMatch.value}"]`)
: page.locator(`${BAR_ID} button`, { hasText: label });
await button.click({ timeout: 5_000 });
return;
} catch (err) {
@@ -651,9 +662,7 @@ async function clickBarButton(page, label) {
async function dispatchBarButton(page, label) {
try {
await installLiveQueryHelpers(page);
const textMatch = label instanceof RegExp
? { kind: 'regex', source: label.source, flags: label.flags }
: { kind: 'text', value: String(label) };
const textMatch = barButtonMatch(label);
return await withTimeout(
page.evaluate(findAndClickBarButton, { barSel: BAR_ID, textMatch }),
5_000,
@@ -671,6 +680,7 @@ function findAndClickBarButton({ barSel, textMatch }) {
.find((candidate) => {
const text = candidate.textContent || '';
if (textMatch.kind === 'regex') return new RegExp(textMatch.source, textMatch.flags).test(text);
if (textMatch.kind === 'aria') return (candidate.getAttribute('aria-label') || '') === textMatch.value;
return text.includes(textMatch.value);
});
if (!btn) return false;
@@ -779,7 +789,9 @@ export async function clickSaveEdit(page) {
async function clickEditBadgeButton(page, label) {
const proxyRect = await page.evaluate((text) => {
const proxies = [...document.querySelectorAll('[data-impeccable-edit-badge-proxy="true"]')];
const proxy = proxies.find((candidate) => (candidate.title || '').includes(text));
const proxy = proxies.find((candidate) =>
(candidate.title || candidate.getAttribute('aria-label') || '').includes(text)
);
if (!proxy) return null;
const rect = proxy.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return null;
@@ -797,7 +809,7 @@ async function clickEditBadgeButton(page, label) {
const clicked = await page.evaluate(({ badgeSel, text }) => {
const badge = window.__impeccableLiveQuery(badgeSel);
const btn = [...(badge?.querySelectorAll('button') || [])].find((candidate) =>
(candidate.textContent || '').includes(text)
(candidate.textContent || candidate.getAttribute('aria-label') || candidate.title || '').includes(text)
);
if (!btn) return false;
btn.click();
+2 -1
View File
@@ -439,6 +439,7 @@ async function assertSelectedElementChrome(page) {
hasPicker: Boolean(picker),
hasBadge: Boolean(badge),
badgeText: badge?.textContent || '',
badgeLabel: badge?.querySelector('button')?.getAttribute('aria-label') || badge?.title || '',
hasHighlight: Boolean(highlight),
highlightVisible: highlight?.style.display !== 'none',
hasInput: Boolean(input),
@@ -449,7 +450,7 @@ async function assertSelectedElementChrome(page) {
assert.equal(result.hasBar, true);
assert.equal(result.hasPicker, true);
assert.equal(result.hasBadge, true);
assert.match(result.badgeText, /Edit copy/);
assert.match(result.badgeLabel, /Edit copy/);
assert.equal(result.hasHighlight, true);
assert.equal(result.highlightVisible, true);
assert.equal(result.hasInput, true);