From 4716abff9cd3d58f844c9ccbfc59f4a3adb7171a Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Fri, 10 Jul 2026 13:06:15 +0200 Subject: [PATCH 1/5] feat(editor): blur() delegate, autofocus, keydown contract, gutter part tokens - blur() mirrors the focus() delegate into the shadow textarea - firstUpdated() honors the native autofocus attribute - class doc pins the composed-keydown pass-through as public API (only Tab is intercepted, never stopPropagation on other keys) - gutter lines expose breakpoint/current state as part tokens so consumers and e2e suites stop coupling to internal class names --- src/lib/sema-editor.ts | 17 ++++++++++++++--- tests/sema-editor.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/lib/sema-editor.ts b/src/lib/sema-editor.ts index d96e698..635d957 100644 --- a/src/lib/sema-editor.ts +++ b/src/lib/sema-editor.ts @@ -29,8 +29,11 @@ const SUPPORTS_FIELD_SIZING = * - `current-line="5"` — the active/debug line (highlighted in gutter + editor). * - listen for `gutter-click` (`detail: { line }`) to toggle/snap breakpoints, etc. * - * Events: `input` and `change` as `CustomEvent<{ value }>`; native `keydown` bubbles - * (composed) so hosts can bind Shift+Enter etc. + * Events: `input` and `change` as `CustomEvent<{ value }>`. The inner textarea's + * native `keydown` bubbles composed to the host — this is part of the public API, + * so hosts bind Shift+Enter, Escape, Cmd+S, etc. directly on the host element. The + * component itself intercepts **only Tab** (`preventDefault()` + indent/dedent) and + * never calls `stopPropagation()`, on Tab or any other key. */ export class SemaEditor extends SemaElement { static styles = [ @@ -219,6 +222,9 @@ export class SemaEditor extends SemaElement { const t = this._ta; if (t) this._undo = new TextareaUndo(t, { onChange: () => this._onInput() }); if (this.autosize && !SUPPORTS_FIELD_SIZING) this._grow(); + // Lit can't declare a Lit `@property` named `autofocus` (it collides with the + // HTMLElement IDL attribute), so honor it by reading the attribute directly. + if (this.hasAttribute('autofocus')) this.focus(); } updated(changed: Map) { @@ -235,6 +241,11 @@ export class SemaEditor extends SemaElement { this._ta?.focus(); } + /** Blur delegates to the inner textarea. */ + blur() { + this._ta?.blur(); + } + /** Scroll so 1-based `line` is vertically centered (e.g. a debugger's current line). */ scrollToLine(line: number) { const t = this._ta; @@ -362,7 +373,7 @@ export class SemaEditor extends SemaElement { const n = i + 1; return html`
{ expect(spy).toHaveBeenCalled() }) + it('does not stopPropagation on Tab either — the host still observes it', async () => { + const el = await mount() + const spy = vi.fn() + el.addEventListener('keydown', spy) + ta(el).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, composed: true, cancelable: true }), + ) + expect(spy).toHaveBeenCalled() + }) + + it('blur() delegates to the inner textarea', async () => { + const el = await mount() + ta(el).focus() + expect(el.shadowRoot!.activeElement).toBe(ta(el)) + el.blur() + expect(el.shadowRoot!.activeElement).toBeNull() + }) + + it('honors the native autofocus attribute by focusing the inner textarea', async () => { + const el = await mount('autofocus') + expect(el.shadowRoot!.activeElement).toBe(ta(el)) + }) + // ── Gutter / breakpoints / current-line ── it('renders a line-number gutter with one entry per line', async () => { @@ -135,6 +158,10 @@ describe('sema-editor', () => { expect(gl[1].classList.contains('bp')).toBe(true) // line 2 breakpoint expect(gl[0].classList.contains('cur')).toBe(true) // line 1 current expect(gl[2].classList.contains('bp')).toBe(false) + // State is also exposed as part tokens — the public hook for styling and tests. + expect(gl[1].getAttribute('part')).toBe('gutter-line breakpoint') + expect(gl[0].getAttribute('part')).toBe('gutter-line current') + expect(gl[2].getAttribute('part')).toBe('gutter-line') }) it('emits gutter-click with the 1-based line when a gutter line is clicked', async () => { From c49b444a8a551366d2b310602af0872d6fd2f975 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Fri, 10 Jul 2026 13:06:51 +0200 Subject: [PATCH 2/5] feat(forms,button): testid forwarding on input/textarea; run+danger reads destructive at rest - sema-input/sema-textarea forward a testid prop onto the inner control, matching the sema-editor/sema-markdown pattern, so shadow-piercing test queries work - variant=run + danger (a Stop button) shows --error border/text at rest, not only on hover --- src/lib/sema-button.ts | 11 +++++++++++ src/lib/sema-input.ts | 2 ++ src/lib/sema-textarea.ts | 2 ++ tests/sema-button.test.ts | 13 +++++++++++++ tests/sema-form.test.ts | 16 ++++++++++++++++ 5 files changed, 44 insertions(+) diff --git a/src/lib/sema-button.ts b/src/lib/sema-button.ts index 4107735..0deddc4 100644 --- a/src/lib/sema-button.ts +++ b/src/lib/sema-button.ts @@ -143,6 +143,17 @@ export class SemaButton extends SemaElement { outline-offset: 3px; border-radius: 3px; } + /* run + danger: a destructive/stop state, so it must read as danger at rest — + unlike the debug/action danger modifier below, which only tints on hover. */ + :host([variant="run"][danger]) .button { + background: transparent; + border: 1px solid var(--error, #c85555); + color: var(--error, #c85555); + } + :host([variant="run"][danger]) .button:hover:not(:disabled) { + background: var(--error-bg, rgba(200, 85, 85, 0.06)); + opacity: 1; + } /* shortcut badge inside run */ .shortcut { diff --git a/src/lib/sema-input.ts b/src/lib/sema-input.ts index e6ecd76..afcaa72 100644 --- a/src/lib/sema-input.ts +++ b/src/lib/sema-input.ts @@ -38,6 +38,7 @@ export class SemaInput extends SemaElement { @property({ type: Boolean, reflect: true }) readonly = false; /** Maximum number of characters the control accepts (omit for no limit). */ @property({ type: Number }) maxlength?: number; + @property() testid = ''; private _internals = this.attachInternals(); @@ -79,6 +80,7 @@ export class SemaInput extends SemaElement { return html` { expect(el.hasAttribute('danger')).toBe(true) }) + it('run + danger reflects the attribute and looks danger at rest (not just on hover)', async () => { + document.body.innerHTML = + 'StopRun' + const [dangerEl, plainEl] = document.querySelectorAll('sema-button') + await dangerEl.updateComplete + await plainEl.updateComplete + expect(dangerEl.hasAttribute('danger')).toBe(true) + const dangerBtn = dangerEl.shadowRoot!.querySelector('button')! + const plainBtn = plainEl.shadowRoot!.querySelector('button')! + expect(getComputedStyle(dangerBtn).color).not.toBe(getComputedStyle(plainBtn).color) + expect(parseFloat(getComputedStyle(dangerBtn).borderTopWidth)).toBeGreaterThan(0) + }) + it('defaults to primary variant when none specified', async () => { document.body.innerHTML = 'Default' const el = document.querySelector('sema-button')! diff --git a/tests/sema-form.test.ts b/tests/sema-form.test.ts index eea1f48..5c338f2 100644 --- a/tests/sema-form.test.ts +++ b/tests/sema-form.test.ts @@ -85,6 +85,14 @@ describe('SemaInput', () => { expect(input.readOnly).toBe(true) expect(input.getAttribute('maxlength')).toBe('5') }) + + it('forwards the testid onto the inner input', async () => { + document.body.innerHTML = `` + const el = document.querySelector('sema-input') as SemaInput + await el.updateComplete + const input = el.shadowRoot!.querySelector('input')! + expect(input.getAttribute('data-testid')).toBe('pkg-input') + }) }) describe('SemaTextarea', () => { @@ -117,6 +125,14 @@ describe('SemaTextarea', () => { await el.updateComplete expect(ta.getBoundingClientRect().height).toBeGreaterThan(before) }) + + it('forwards the testid onto the inner textarea', async () => { + document.body.innerHTML = `` + const el = document.querySelector('sema-textarea') as SemaTextarea + await el.updateComplete + const ta = el.shadowRoot!.querySelector('textarea')! + expect(ta.getAttribute('data-testid')).toBe('notes') + }) }) describe('SemaSelect (custom dropdown)', () => { From f107c64872e7f29103193f724bee03107022db1d Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Fri, 10 Jul 2026 13:06:51 +0200 Subject: [PATCH 3/5] fix(popover): fixed positioning escapes overflow-clipping ancestors Panel is position:fixed, measured from the trigger on open, flipped vertically at the viewport edge, clamped horizontally, and closed on outside scroll/resize (scrolling the popover's own content is exempt via composedPath, which keeps sema-select's listbox usable). --- src/lib/sema-popover.ts | 101 +++++++++++++++++++++++++++---------- tests/sema-popover.test.ts | 73 +++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 26 deletions(-) diff --git a/src/lib/sema-popover.ts b/src/lib/sema-popover.ts index 2d3a9c9..7fd17a6 100644 --- a/src/lib/sema-popover.ts +++ b/src/lib/sema-popover.ts @@ -23,6 +23,14 @@ export type PopoverPlacement = * trigger gets `aria-haspopup` + `aria-expanded`. Add the **`modal`** attribute for * dialog-like content that should hard-trap Tab instead of closing on Tab/focus-out. * + * **Positioning:** the panel is `position: fixed`, so it always escapes `overflow`- + * clipping ancestors instead of being clipped by them. On open it measures the + * trigger with `getBoundingClientRect()` and sets viewport-relative coordinates, + * flipping vertically (`bottom-*` ↔ `top-*`) when the preferred side would cross the + * viewport edge. Because the fixed coordinates go stale if the page scrolls or + * resizes, the popover closes on either while open (scrolling *inside* the popover's + * own light-DOM content is exempt, so a scrollable slotted panel doesn't self-close). + * * Emits `sema-open` / `sema-close`. A child ``'s `sema-select` auto-closes it. */ export class SemaPopover extends SemaElement { @@ -33,8 +41,14 @@ export class SemaPopover extends SemaElement { display: inline-block; position: relative; } + /* Fixed (not absolute) so the panel escapes overflow-clipping ancestors — a + plain overflow:hidden/auto container does not clip a fixed descendant. + top/left are set inline by _reposition() (measured from the trigger's + getBoundingClientRect() once the panel has laid out); left unset here, + the panel sits at its default fixed origin only for the single + pre-measurement frame right after open flips true. */ .panel { - position: absolute; + position: fixed; z-index: 300; min-width: max-content; background: var(--bg-elevated, #141414); @@ -46,30 +60,6 @@ export class SemaPopover extends SemaElement { .panel[hidden] { display: none; } - :host([placement='bottom-start']) .panel { - top: calc(100% + 4px); - left: 0; - } - :host([placement='bottom-end']) .panel { - top: calc(100% + 4px); - right: 0; - } - :host([placement='top-start']) .panel { - bottom: calc(100% + 4px); - left: 0; - } - :host([placement='top-end']) .panel { - bottom: calc(100% + 4px); - right: 0; - } - :host([placement='left']) .panel { - right: calc(100% + 4px); - top: 0; - } - :host([placement='right']) .panel { - left: calc(100% + 4px); - top: 0; - } `, ]; @@ -95,9 +85,20 @@ export class SemaPopover extends SemaElement { if (!e.composedPath().includes(this)) this.hide(false); }; + // The fixed coordinates computed in _reposition() are only valid until the page's + // scroll offset or viewport size changes, so close rather than track them live. + // A scroll bubbling up from the popover's own light-DOM content (composedPath + // includes `this`) is exempt so a scrollable slotted panel doesn't self-close. + private _onViewportChange = (e: Event) => { + if (e.composedPath().includes(this)) return; + this.hide(false); + }; + disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('pointerdown', this._onDocPointer, true); + window.removeEventListener('scroll', this._onViewportChange, true); + window.removeEventListener('resize', this._onViewportChange); // Don't leave a reconnected popover stuck-open with a stale aria-expanded. if (this.open) { this._triggerEl?.setAttribute('aria-expanded', 'false'); @@ -110,6 +111,48 @@ export class SemaPopover extends SemaElement { return this.querySelector('[slot="trigger"]'); } + /** Measure the trigger and place the (fixed) panel at viewport-relative + * coordinates, flipping vertically when the preferred side doesn't fit but the + * opposite side does. Called once the panel has laid out (post-`updateComplete`), + * since it needs the panel's real dimensions. */ + private _reposition() { + const trigger = this._triggerEl; + const panel = this._panel; + if (!trigger || !panel) return; + const gap = 4; + const tr = trigger.getBoundingClientRect(); + const pr = panel.getBoundingClientRect(); + + const preferAbove = this.placement.startsWith('top'); + const fitsAbove = tr.top - gap - pr.height >= 0; + const fitsBelow = tr.bottom + gap + pr.height <= window.innerHeight; + let renderAbove = preferAbove; + if (preferAbove && !fitsAbove && fitsBelow) renderAbove = false; + if (!preferAbove && !fitsBelow && fitsAbove) renderAbove = true; + + let top: number; + let left: number; + switch (this.placement) { + case 'left': + top = tr.top; + left = tr.left - gap - pr.width; + break; + case 'right': + top = tr.top; + left = tr.right + gap; + break; + default: // bottom-start, bottom-end, top-start, top-end + top = renderAbove ? tr.top - gap - pr.height : tr.bottom + gap; + left = this.placement.endsWith('end') ? tr.right - pr.width : tr.left; + } + + // Clamp X so an edge-adjacent trigger can't push the panel off-viewport. + left = Math.max(gap, Math.min(left, window.innerWidth - pr.width - gap)); + + panel.style.top = `${top}px`; + panel.style.left = `${left}px`; + } + show() { if (this.open) return; this._triggerEl = this._trigger; @@ -123,10 +166,14 @@ export class SemaPopover extends SemaElement { ); } document.addEventListener('pointerdown', this._onDocPointer, true); + window.addEventListener('scroll', this._onViewportChange, true); + window.addEventListener('resize', this._onViewportChange); this.dispatchEvent(new CustomEvent('sema-open', { bubbles: true, composed: true })); - // Move focus into the panel (a menu focuses its first item). + // Position the panel and move focus into it (a menu focuses its first item) — + // both need the post-render panel, so they run after updateComplete. this.updateComplete.then(() => { if (!this.open) return; + this._reposition(); const menu = this.querySelector('sema-menu') as (HTMLElement & { focusFirst?: () => void }) | null; if (menu?.focusFirst) menu.focusFirst(); // getFocusableElements walks shadow roots + slots, so it finds slotted content @@ -140,6 +187,8 @@ export class SemaPopover extends SemaElement { if (!this.open) return; this.open = false; document.removeEventListener('pointerdown', this._onDocPointer, true); + window.removeEventListener('scroll', this._onViewportChange, true); + window.removeEventListener('resize', this._onViewportChange); // Resolve the live trigger (it may have been re-slotted) and fall back to the cached ref. const trigger = this._trigger ?? this._triggerEl; trigger?.setAttribute('aria-expanded', 'false'); diff --git a/tests/sema-popover.test.ts b/tests/sema-popover.test.ts index 40a5010..8f87db3 100644 --- a/tests/sema-popover.test.ts +++ b/tests/sema-popover.test.ts @@ -176,6 +176,79 @@ describe('SemaPopover', () => { expect(trigger.getAttribute('aria-expanded')).toBe('false') }) + it('escapes an overflow:hidden ancestor — the panel is not visually clipped', async () => { + document.body.innerHTML = ` +
+ ${MENU} +
` + const el = pop() + el.show() + await el.updateComplete + const p = panel(el) + expect(getComputedStyle(p).position).toBe('fixed') + const clipperBottom = (document.querySelector('#clipper') as HTMLElement).getBoundingClientRect().bottom + const r = p.getBoundingClientRect() + expect(r.bottom).toBeGreaterThan(clipperBottom) // extends past the 40px-tall ancestor + // A point in the panel's own padding, below the ancestor's clip boundary, still + // hit-tests into the popover — proof the ancestor's overflow:hidden isn't + // painting over it. document.elementFromPoint retargets shadow content to the + // host; pierce one level with the shadow root's own elementFromPoint to confirm + // it's really the panel (not the clipper's background) that's painted there. + expect(document.elementFromPoint(r.left + 2, r.bottom - 2)).toBe(el) + expect(el.shadowRoot!.elementFromPoint(r.left + 2, r.bottom - 2)).toBe(p) + }) + + it('flips to render above the trigger when pinned near the bottom of the viewport', async () => { + document.body.innerHTML = `
${MENU}
` + const el = pop() // default placement is bottom-start + el.show() + await el.updateComplete + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement + const triggerRect = trigger.getBoundingClientRect() + const panelRect = panel(el).getBoundingClientRect() + // rendered above the trigger, not below (which would overflow the viewport) + expect(panelRect.bottom).toBeLessThanOrEqual(triggerRect.top + 1) + }) + + it('closes when the page scrolls (fixed coordinates would otherwise go stale)', async () => { + document.body.innerHTML = MENU + const el = pop() + el.show() + await el.updateComplete + expect(el.open).toBe(true) + window.dispatchEvent(new Event('scroll')) + await el.updateComplete + expect(el.open).toBe(false) + }) + + it('closes when the window resizes', async () => { + document.body.innerHTML = MENU + const el = pop() + el.show() + await el.updateComplete + expect(el.open).toBe(true) + window.dispatchEvent(new Event('resize')) + await el.updateComplete + expect(el.open).toBe(false) + }) + + it('does not close when the scroll originates inside its own slotted content', async () => { + document.body.innerHTML = ` + + +
+
tall content
+
+
` + const el = pop() + el.show() + await el.updateComplete + expect(el.open).toBe(true) + document.querySelector('#scrollme')!.dispatchEvent(new Event('scroll')) + await el.updateComplete + expect(el.open).toBe(true) + }) + it('modal popover does not close on Tab — it wraps focus inside the panel', async () => { document.body.innerHTML = ` From d9c8192a1bb63e82f87163ad83c119eb3e400460 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Fri, 10 Jul 2026 13:06:51 +0200 Subject: [PATCH 4/5] feat(dialog)!: rename events to sema-open/sema-close; give the open host a real box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: sema-dialog-open/sema-dialog-close are now sema-open/sema-close, aligning with sema-popover. The open host is position:fixed inset:0 so it has a real box matching what is painted (its only child, the backdrop, is out of flow — visibility checks used to report a shown dialog as hidden). --- src/lib/events.ts | 2 -- src/lib/sema-dialog.ts | 17 +++++++++++++++-- tests/sema-dialog.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/lib/events.ts b/src/lib/events.ts index 03289b1..3a25c29 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -45,8 +45,6 @@ declare global { 'sema-resize': CustomEvent; 'sema-open': Event; 'sema-close': Event; - 'sema-dialog-open': Event; - 'sema-dialog-close': Event; 'sema-drawer-open': Event; 'sema-drawer-close': Event; 'sema-dismiss': Event; diff --git a/src/lib/sema-dialog.ts b/src/lib/sema-dialog.ts index 0f05f30..3b858aa 100644 --- a/src/lib/sema-dialog.ts +++ b/src/lib/sema-dialog.ts @@ -3,6 +3,12 @@ import { property } from 'lit/decorators.js'; import { SemaElement } from '../internal/sema-element.js'; import { FocusTrapController } from '../internal/controllers/focus-trap.js'; +/** + * `` — a modal dialog with a focus trap, backdrop click, Escape + * close, scroll lock, and header/body/footer slots. + * + * Emits `sema-open` / `sema-close` (aligned with ``). + */ export class SemaDialog extends SemaElement { static styles = [ SemaElement.base, @@ -10,8 +16,15 @@ export class SemaDialog extends SemaElement { :host { display: none; } + /* Give the open host a real box matching what is visually painted: its + only child (.backdrop) is position:fixed and out of flow, so without + this the host computes 0x0 and visibility checks (a11y tools, + Playwright toBeVisible) report the shown dialog as hidden. */ :host([open]) { display: block; + position: fixed; + inset: 0; + z-index: 500; } .backdrop { @@ -130,10 +143,10 @@ export class SemaDialog extends SemaElement { if (changed.has('open')) { if (this.open) { document.addEventListener('keydown', this._onDocKeydown); - this.dispatchEvent(new CustomEvent('sema-dialog-open', { bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent('sema-open', { bubbles: true, composed: true })); } else { document.removeEventListener('keydown', this._onDocKeydown); - this.dispatchEvent(new CustomEvent('sema-dialog-close', { bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent('sema-close', { bubbles: true, composed: true })); } } } diff --git a/tests/sema-dialog.test.ts b/tests/sema-dialog.test.ts index 4c48c95..16b8559 100644 --- a/tests/sema-dialog.test.ts +++ b/tests/sema-dialog.test.ts @@ -25,6 +25,15 @@ describe('SemaDialog', () => { expect(el.shadowRoot!.querySelector('.dialog')).toBeTruthy() }) + it('gives the open host a real box (visibility checks must not report it hidden)', async () => { + document.body.innerHTML = '

Content

' + const el = document.querySelector('#d') as SemaDialog + await el.updateComplete + const rect = el.getBoundingClientRect() + expect(rect.width).toBeGreaterThan(0) + expect(rect.height).toBeGreaterThan(0) + }) + it('has role="dialog" and aria-modal', async () => { document.body.innerHTML = '

Content

' const el = document.querySelector('#d') as SemaDialog @@ -69,6 +78,22 @@ describe('SemaDialog', () => { expect(el.open).toBe(false) }) + it('emits sema-open / sema-close', async () => { + document.body.innerHTML = '

Content

' + const el = document.querySelector('#d') as SemaDialog + await el.updateComplete + let opened = 0 + let closed = 0 + el.addEventListener('sema-open', () => opened++) + el.addEventListener('sema-close', () => closed++) + el.show() + await el.updateComplete + el.close() + await el.updateComplete + expect(opened).toBe(1) + expect(closed).toBe(1) + }) + it('locks body scroll when open and unlocks on close', async () => { document.body.innerHTML = '

Content

' const el = document.querySelector('#d') as SemaDialog From b31a51d78061a48f98042ad77af760d3653edba9 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Fri, 10 Jul 2026 13:06:51 +0200 Subject: [PATCH 5/5] release: 0.2.0 (changelog, roadmap cleanup, manifest, generic fence comment) - CHANGELOG 0.2.0 section (Added / Changed-BREAKING / Fixed) - TODO-COMPONENTS: shipped items moved to Done; stale packaging/CI notes removed; event-name + popover-positioning marked resolved - custom-elements.json regenerated - sema-markdown fence-degradation comment no longer names one consumer --- CHANGELOG.md | 51 ++++++++++++++++++++++++++++++++++ TODO-COMPONENTS.md | 18 ++++++------ custom-elements.json | 60 ++++++++++++++++++++++++++++++++++++---- package.json | 2 +- src/lib/sema-markdown.ts | 2 +- 5 files changed, 116 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd0a58..fe45bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,57 @@ All notable changes to `@sema-lang/ui` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to semantic versioning. +## [0.2.0] + +### Added + +- **`sema-editor`: `blur()` delegate.** Mirrors the existing `focus()` delegate, + blurring the inner shadow textarea. Added so consumers that programmatically + focus the editor (e.g. a notebook cell) can also programmatically blur it — + restoring behavior the pre-migration light-DOM `