From bc3a3389aadb9e5e3c69b87333bd8ca7f4591dda Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 11:18:06 -0700 Subject: [PATCH 01/10] Add shared development element callouts --- docs/developer/development.md | 34 ++- scripts/app-layout.ts | 1 + scripts/markover.ts | 66 +++++- src/contracts.ts | 9 + src/development-element.ts | 367 +++++++++++++++++++++++++++++++ src/ipc-contract.ts | 16 ++ src/local-service.ts | 48 +++- src/main.ts | 62 +++++- src/preload.ts | 37 ++++ src/renderer.ts | 9 + src/startup-contract.ts | 1 + src/styles.css | 26 +++ test/development-element.test.ts | 150 +++++++++++++ test/ipc-security.test.ts | 38 ++++ test/local-service.test.ts | 80 +++++++ test/markover-cli.test.ts | 74 ++++++- 16 files changed, 1008 insertions(+), 10 deletions(-) create mode 100644 src/development-element.ts create mode 100644 test/development-element.test.ts diff --git a/docs/developer/development.md b/docs/developer/development.md index b45f6da6..39a6ba11 100644 --- a/docs/developer/development.md +++ b/docs/developer/development.md @@ -158,16 +158,40 @@ failed renderer build leaves the displayed renderer and last published assets untouched, and the next valid edit retries normally. An edit used by Electron's main process or local backend prints the message -`restart required` and leaves the running window untouched. Stop and restart the loop -when that change should enter the application; the loop never turns a runtime -edit into an automatic app restart. Watcher implementation updates hand the -running app to the replacement watcher without quitting it. Generated output, -dependency directories, Git metadata, and instance state do not trigger +`restart required` and leaves the running window untouched. Stop and restart +the loop when that change should enter the application; the loop never turns a +runtime edit into an automatic app restart. Watcher implementation updates hand +the running app to the replacement watcher without quitting it. Generated +output, dependency directories, Git metadata, and instance state do not trigger rebuilds. Keep only one loop per instance and use `npm start` for deterministic one-shot work. End the loop with Ctrl-C; it asks the addressed instance to quit through the managed durability path and waits for that process before returning. +### Shared development element callouts + +While the live development loop is running, Option-click any rendered element. +Markover pins a bright bounding box to that element and copies one opaque +`mko-ui-v1:` reference to the clipboard. Paste that reference into the agent +thread; no screenshot, DevTools inspection, or hand-drawn circle is needed. + +An agent highlights the same element in the addressed running instance with: + +```sh +npm --silent run markover -- --instance dev element highlight '' +``` + +Clear the pinned box with: + +```sh +npm --silent run markover -- --instance dev element clear +``` + +References use a validated unique-ID anchor and deterministic element path. +Stale or ambiguous references fail instead of selecting a different element. +The picker and authenticated highlight route exist only in a running live +development watcher; release and non-watch instances do not expose them. + ## Development review links Install or inspect the forwarding-only handler for the current worktree's open diff --git a/scripts/app-layout.ts b/scripts/app-layout.ts index a99149f6..0c70e336 100644 --- a/scripts/app-layout.ts +++ b/scripts/app-layout.ts @@ -14,6 +14,7 @@ export const runtimeModuleNames = [ 'codex-thread-titles', 'development-control', 'development-config', + 'development-element', 'durability-shutdown', 'ipc-contract', 'ipc-security', diff --git a/scripts/markover.ts b/scripts/markover.ts index 1ee515be..a49c32ab 100644 --- a/scripts/markover.ts +++ b/scripts/markover.ts @@ -41,6 +41,7 @@ import { import { serviceEndpointPath } from '../src/service-endpoint' import { guidance } from '../src/agent-guidance' import { normalizeSettings } from '../src/settings' +import { isDevelopmentElementCalloutResult } from '../src/development-element' import { parseMarkdown } from '../src/tree' import { cleanupDevelopmentInstance, @@ -110,6 +111,8 @@ export type ParsedCommand = ParsedInstanceTarget & ( | { command: 'canonical'; action: 'doctor' } | { command: 'canonical'; action: 'refresh'; install: boolean } | { command: 'cleanup'; expectedIdentity: `pr-${number}` } + | { command: 'element'; action: 'clear' } + | { command: 'element'; action: 'highlight'; reference: string } | { command: 'edit'; reviewId: string } | { command: 'resolve' @@ -309,6 +312,11 @@ export function helpPayload() { usage: '--instance dev cleanup ', purpose: 'Move one stopped worktree-local instance to macOS Trash after its development URL handler has been removed.' }, + { + name: 'element', + usage: '--instance dev element highlight | --instance dev element clear', + purpose: 'Highlight or clear one exact element callout in the addressed running live development window.' + }, { name: 'help', aliases: ['info', '--help', '-h'], @@ -398,7 +406,8 @@ export function parseCommandArguments(args: string[]): ParsedCommand { command !== 'pending' && command !== 'resolve' && command !== 'unresolve' && - command !== 'cleanup' + command !== 'cleanup' && + command !== 'element' ) { if (command === 'check') { throw commandError( @@ -414,7 +423,34 @@ export function parseCommandArguments(args: string[]): ParsedCommand { } throw commandError( `Unknown command: ${command}`, - 'markover ...' + 'markover ...' + ) + } + + if (command === 'element') { + if (instance !== 'development') { + throw commandError( + 'element callouts are available only for the current live development worktree.', + 'markover --instance dev element ...' + ) + } + if (rest.length === 1 && rest[0] === 'clear') { + return targeted({ command, action: 'clear' as const }) + } + if ( + rest.length === 2 && + rest[0] === 'highlight' && + rest[1]?.startsWith('mko-ui-v1:') + ) { + return targeted({ + command, + action: 'highlight' as const, + reference: rest[1] + }) + } + throw commandError( + 'element requires highlight with one copied reference, or clear.', + 'markover --instance dev element highlight | markover --instance dev element clear' ) } @@ -1152,6 +1188,7 @@ export async function ensureService({ export interface ExecuteCommandOptions { endpointPath?: string + requestLocal?: typeof requestJson ensure?: () => Promise resolveTarget?: ( selector: InstanceSelector, @@ -1622,6 +1659,31 @@ export async function executeCommand( const cleanup = options.cleanup || cleanupDevelopmentInstance return cleanup(instance, parsed.expectedIdentity) } + if (parsed.command === 'element') { + const endpointPath = options.endpointPath || ( + await resolveTarget('development') + ).service.endpointPath + const result = await (options.requestLocal || requestJson)( + endpointPath, + 'POST', + '/development/element-callout', + parsed.action === 'clear' + ? { action: 'clear' } + : { action: 'highlight', reference: parsed.reference } + ) + if (!isDevelopmentElementCalloutResult(result)) { + throw new LocalServiceError( + 'INVALID_RESPONSE', + 'Markover returned an invalid development element callout response.', + 200 + ) + } + return { + ...(result.bounds ? { bounds: result.bounds } : {}), + ...(result.reference ? { reference: result.reference } : {}), + status: result.status + } + } const profile = parsed.instance === undefined ? await (options.loadRemoteProfile || loadRemoteProfile)() diff --git a/src/contracts.ts b/src/contracts.ts index 1d86943c..fc87d527 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -7,6 +7,10 @@ import type { StartupPhaseEvent, StartupReady } from './startup-contract' +import type { + DevelopmentElementCalloutCommand, + DevelopmentElementCalloutResult +} from './development-element' export interface DiffStats { additions: number @@ -754,6 +758,11 @@ declare global { createLocalReview: (tree: ReviewTree) => Promise onOpenMarkdownRequested: (callback: () => void) => void onReviewBatchModeRequested: (callback: () => void) => void + onDevelopmentElementCallout: ( + callback: ( + command: DevelopmentElementCalloutCommand + ) => DevelopmentElementCalloutResult | Promise + ) => void checksum: (source: string) => Promise copyText: (text: string) => void readClipboardImage: () => Promise diff --git a/src/development-element.ts b/src/development-element.ts new file mode 100644 index 00000000..356530f8 --- /dev/null +++ b/src/development-element.ts @@ -0,0 +1,367 @@ +const REFERENCE_PREFIX = 'mko-ui-v1:' +const MAXIMUM_REFERENCE_LENGTH = 4096 +const MAXIMUM_ANCHOR_LENGTH = 512 +const MAXIMUM_PATH_LENGTH = 128 +const ELEMENT_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/ + +interface DevelopmentElementPathSegment { + index: number + name: string +} + +interface DevelopmentElementReferencePayload { + anchorId: string | null + path: DevelopmentElementPathSegment[] + version: 1 +} + +export interface DevelopmentElementBounds { + height: number + width: number + x: number + y: number +} + +export interface DevelopmentElementCalloutCommand { + action: 'clear' | 'highlight' + reference?: string | undefined + requestId: string +} + +export interface DevelopmentElementCalloutRequest { + action: 'clear' | 'highlight' + reference?: string | undefined +} + +export interface DevelopmentElementCalloutResult { + bounds?: DevelopmentElementBounds | undefined + reference?: string | undefined + requestId: string + status: 'ambiguous' | 'cleared' | 'highlighted' | 'stale' +} + +export interface DevelopmentElementCallouts { + clear: () => void + handle: ( + command: DevelopmentElementCalloutCommand + ) => DevelopmentElementCalloutResult +} + +interface DevelopmentElementCalloutOptions { + copyText: (reference: string) => void + notify: (message: string) => void +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function encodeBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') +} + +function decodeBase64Url(value: string): string | null { + if (!/^[A-Za-z0-9_-]+$/.test(value)) return null + const padding = (4 - (value.length % 4)) % 4 + try { + const binary = atob( + value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat(padding) + ) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + return null + } +} + +function isPayload(value: unknown): value is DevelopmentElementReferencePayload { + if (!isRecord(value) || value.version !== 1) return false + if ( + value.anchorId !== null && + ( + typeof value.anchorId !== 'string' || + !value.anchorId || + value.anchorId.length > MAXIMUM_ANCHOR_LENGTH + ) + ) return false + if (!Array.isArray(value.path) || value.path.length > MAXIMUM_PATH_LENGTH) { + return false + } + return value.path.every((segment) => ( + isRecord(segment) && + typeof segment.name === 'string' && + ELEMENT_NAME_PATTERN.test(segment.name) && + Number.isInteger(segment.index) && + (segment.index as number) >= 0 + )) +} + +function decodeReference( + reference: string +): DevelopmentElementReferencePayload | null { + if ( + !reference.startsWith(REFERENCE_PREFIX) || + reference.length > MAXIMUM_REFERENCE_LENGTH + ) return null + const encoded = reference.slice(REFERENCE_PREFIX.length) + const decoded = decodeBase64Url(encoded) + if (decoded === null) return null + let value: unknown + try { + value = JSON.parse(decoded) + } catch { + return null + } + if (!isPayload(value)) return null + return `${REFERENCE_PREFIX}${encodeBase64Url(JSON.stringify(value))}` === reference + ? value + : null +} + +export function isDevelopmentElementReference(value: unknown): value is string { + return typeof value === 'string' && decodeReference(value) !== null +} + +export function isDevelopmentElementCalloutCommand( + value: unknown +): value is DevelopmentElementCalloutCommand { + if (!isRecord(value) || !/^element-callout-[1-9]\d*$/.test(String(value.requestId))) { + return false + } + if (value.action === 'clear') { + return Object.keys(value).length === 2 && value.reference === undefined + } + return value.action === 'highlight' && + Object.keys(value).length === 3 && + isDevelopmentElementReference(value.reference) +} + +export function isDevelopmentElementCalloutRequest( + value: unknown +): value is DevelopmentElementCalloutRequest { + if (!isRecord(value)) return false + if (value.action === 'clear') { + return Object.keys(value).length === 1 && value.reference === undefined + } + return value.action === 'highlight' && + Object.keys(value).length === 2 && + isDevelopmentElementReference(value.reference) +} + +export function isDevelopmentElementCalloutResult( + value: unknown +): value is DevelopmentElementCalloutResult { + if ( + !isRecord(value) || + !/^element-callout-[1-9]\d*$/.test(String(value.requestId)) || + !['ambiguous', 'cleared', 'highlighted', 'stale'].includes(String(value.status)) + ) return false + const allowed = new Set(['bounds', 'reference', 'requestId', 'status']) + if (Object.keys(value).some((key) => !allowed.has(key))) return false + if (value.reference !== undefined && !isDevelopmentElementReference(value.reference)) { + return false + } + if (value.status === 'cleared') { + return value.reference === undefined && value.bounds === undefined + } + if (value.reference === undefined) return false + if (value.status !== 'highlighted') return value.bounds === undefined + const bounds = value.bounds + if (!isRecord(bounds)) return false + return Object.keys(bounds).sort().join(',') === 'height,width,x,y' && + ['height', 'width', 'x', 'y'].every((key) => ( + typeof bounds[key] === 'number' && + Number.isFinite(bounds[key]) + )) +} + +function uniqueIdAnchor( + element: Element, + document: Document +): Element | null { + let current: Element | null = element + while (current) { + if ( + current.id && + [...document.querySelectorAll('[id]')].filter( + (candidate) => candidate.id === current?.id + ).length === 1 + ) return current + current = current.parentElement + } + return null +} + +function segmentFor(element: Element): DevelopmentElementPathSegment { + const parent = element.parentElement + if (!parent) throw new Error('Development element is outside the document tree.') + const siblings = [...parent.children].filter( + (candidate) => candidate.localName === element.localName + ) + const index = siblings.indexOf(element) + if (index < 0 || !ELEMENT_NAME_PATTERN.test(element.localName)) { + throw new Error('Development element path is invalid.') + } + return { name: element.localName, index } +} + +export function developmentElementReference( + element: Element, + document: Document +): string { + if (!document.documentElement.contains(element) && element !== document.documentElement) { + throw new Error('Development element is outside the document tree.') + } + const anchor = uniqueIdAnchor(element, document) + const path: DevelopmentElementPathSegment[] = [] + let current = element + while (current !== (anchor || document.documentElement)) { + path.unshift(segmentFor(current)) + const parent = current.parentElement + if (!parent) throw new Error('Development element path is incomplete.') + current = parent + } + const payload: DevelopmentElementReferencePayload = { + anchorId: anchor?.id || null, + path, + version: 1 + } + return `${REFERENCE_PREFIX}${encodeBase64Url(JSON.stringify(payload))}` +} + +export function resolveDevelopmentElementReference( + reference: string, + document: Document +): { element: Element; status: 'found' } | { status: 'ambiguous' | 'stale' } { + const payload = decodeReference(reference) + if (!payload) return { status: 'stale' } + let current: Element + if (payload.anchorId !== null) { + const anchors = [...document.querySelectorAll('[id]')].filter( + (candidate) => candidate.id === payload.anchorId + ) + if (anchors.length > 1) return { status: 'ambiguous' } + if (anchors.length === 0) return { status: 'stale' } + current = anchors[0] as Element + } else { + current = document.documentElement + } + for (const segment of payload.path) { + const candidates = [...current.children].filter( + (candidate) => candidate.localName === segment.name + ) + const next = candidates[segment.index] + if (!next) return { status: 'stale' } + current = next + } + return { element: current, status: 'found' } +} + +function roundedBounds(element: Element): DevelopmentElementBounds { + const bounds = element.getBoundingClientRect() + return { + height: Math.round(bounds.height), + width: Math.round(bounds.width), + x: Math.round(bounds.x), + y: Math.round(bounds.y) + } +} + +function calloutLabel(element: Element): string { + return element.id ? `${element.localName}#${element.id}` : element.localName +} + +export function installDevelopmentElementCallouts( + document: Document, + { copyText, notify }: DevelopmentElementCalloutOptions +): DevelopmentElementCallouts { + const overlay = document.createElement('div') + overlay.className = 'development-element-callout' + overlay.dataset.markoverDevelopmentCallout = 'true' + overlay.hidden = true + const label = document.createElement('span') + label.className = 'development-element-callout-label' + overlay.append(label) + document.body.append(overlay) + let pinned: Element | null = null + let pinnedReference: string | null = null + + const clear = (): void => { + pinned = null + pinnedReference = null + overlay.hidden = true + } + const position = (): DevelopmentElementBounds | null => { + if (!pinned || !document.documentElement.contains(pinned)) { + clear() + return null + } + const bounds = roundedBounds(pinned) + overlay.style.left = `${String(bounds.x)}px` + overlay.style.top = `${String(bounds.y)}px` + overlay.style.width = `${String(bounds.width)}px` + overlay.style.height = `${String(bounds.height)}px` + label.textContent = calloutLabel(pinned) + overlay.hidden = false + return bounds + } + const pin = (element: Element, reference: string): DevelopmentElementBounds => { + pinned = element + pinnedReference = reference + return position() as DevelopmentElementBounds + } + + document.addEventListener('click', (event) => { + if ( + event.button !== 0 || + !event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) return + const target = event.target as Node | null + if (!target || target.nodeType !== 1) return + const element = target as Element + if (overlay.contains(element)) return + event.preventDefault() + event.stopImmediatePropagation() + const reference = developmentElementReference(element, document) + pin(element, reference) + copyText(reference) + notify('Element reference copied') + }, true) + document.defaultView?.addEventListener('resize', () => { position() }) + document.addEventListener('scroll', () => { position() }, true) + + return { + clear, + handle(command) { + if (command.action === 'clear') { + clear() + return { requestId: command.requestId, status: 'cleared' } + } + const reference = command.reference as string + const resolved = resolveDevelopmentElementReference(reference, document) + if (resolved.status !== 'found') { + clear() + return { + reference, + requestId: command.requestId, + status: resolved.status + } + } + return { + bounds: pin(resolved.element, reference), + reference: pinnedReference as string, + requestId: command.requestId, + status: 'highlighted' + } + } + } +} diff --git a/src/ipc-contract.ts b/src/ipc-contract.ts index be49f0c3..d4029f87 100644 --- a/src/ipc-contract.ts +++ b/src/ipc-contract.ts @@ -9,6 +9,12 @@ import type { StartupReady, StartupWarning } from './startup-contract' +import { + isDevelopmentElementCalloutCommand, + isDevelopmentElementCalloutResult, + type DevelopmentElementCalloutCommand, + type DevelopmentElementCalloutResult +} from './development-element' import { isReviewArtifact, isReviewTree } from './review-format' import { isWorkspaceState } from './workspace-state' @@ -145,6 +151,7 @@ export interface RendererSendArguments { 'clipboard:write': [string] 'review:activate': [string] 'review:autosave': [string, ReviewTree] + 'development:element-callout-response': [DevelopmentElementCalloutResult] } export interface MainEventArguments { @@ -162,6 +169,7 @@ export interface MainEventArguments { 'review:activation-request': [ReviewActivationRequest] 'review:resolution-confirmation-request': [ReviewResolutionConfirmationRequest] 'review:trashed': [ReviewTrashedEvent] + 'development:element-callout': [DevelopmentElementCalloutCommand] } export type RendererInvokeChannel = keyof RendererInvokeArguments @@ -358,12 +366,14 @@ function isStartupInfo(value: unknown): value is StartupInfo { return hasExactKeys(value, [ 'development', 'diagnosticPath', + 'elementCallouts', 'holdPhase', 'failPhase', 'smoke' ]) && typeof value.development === 'boolean' && typeof value.diagnosticPath === 'string' && + typeof value.elementCallouts === 'boolean' && (value.holdPhase === null || isStartupPhaseValue(value.holdPhase)) && (value.failPhase === null || isStartupPhaseValue(value.failPhase)) && typeof value.smoke === 'boolean' @@ -907,6 +917,9 @@ export function assertRendererSendArguments( isReviewSessionTree(args[1], args[0]) && args[1].review.status === 'editing' break + case 'development:element-callout-response': + valid = singleArgument(args, isDevelopmentElementCalloutResult) + break } if (!valid) throw new IpcContractError(channel, 'renderer-to-main send') } @@ -1020,6 +1033,9 @@ export function assertMainEventArguments( valid = singleArgument(args, isResolutionConfirmationRequest) break case 'review:trashed': valid = singleArgument(args, isReviewTrashedEvent); break + case 'development:element-callout': + valid = singleArgument(args, isDevelopmentElementCalloutCommand) + break } if (!valid) throw new IpcContractError(channel, 'main-to-renderer event') } diff --git a/src/local-service.ts b/src/local-service.ts index 4589b124..041205bb 100644 --- a/src/local-service.ts +++ b/src/local-service.ts @@ -26,6 +26,11 @@ import { isPullRequestStatus, parseGitHubPullRequestUrl } from './pull-request' +import { + isDevelopmentElementCalloutRequest, + type DevelopmentElementCalloutRequest, + type DevelopmentElementCalloutResult +} from './development-element' export const MAXIMUM_BODY_BYTES = 16 * 1024 * 1024 export const INTERNAL_REMOTE_CREATE_PATH = '/internal/remote/reviews' @@ -129,6 +134,9 @@ export interface LocalServiceOptions { action: LocalServiceChangeAction ) => void | Promise) | undefined onDevelopmentReload?: (() => Promise) | undefined + onDevelopmentElementCallout?: (( + request: DevelopmentElementCalloutRequest + ) => Promise) | undefined onQuit?: (() => void) | undefined onUnauthorized?: ((event: UnauthorizedRequest) => void) | undefined interpretationPolicy?: (() => string) | undefined @@ -252,13 +260,18 @@ function errorStatus(error: unknown): number { const code = errorProperty(error, 'code') if (code === 'ACTIVATION_NOT_READY') return 503 if (code === 'ACTIVATION_TIMEOUT') return 504 - if (code === 'NOT_FOUND' || code === 'RECEIPT_NOT_FOUND') return 404 + if ( + code === 'DEVELOPMENT_ELEMENT_REFERENCE_STALE' || + code === 'NOT_FOUND' || + code === 'RECEIPT_NOT_FOUND' + ) return 404 if ( code === 'UNSUPPORTED_REVIEW_FORMAT' || code === 'UNSUPPORTED_REVIEW_VERSION' ) return 409 if ( code === 'INVALID_ID' || + code === 'INVALID_DEVELOPMENT_ELEMENT_CALLOUT' || code === 'INVALID_CREATION_RECEIPT' || code === 'INVALID_JSON' || code === 'INVALID_PULL_REQUEST' || @@ -275,6 +288,7 @@ function errorStatus(error: unknown): number { } if ( code === 'CLAIM_CONFLICT' || + code === 'DEVELOPMENT_ELEMENT_REFERENCE_AMBIGUOUS' || code === 'CREATION_RECEIPT_SCAN_INCOMPLETE' || code === 'DUPLICATE_CREATION_RECEIPT' || code === 'FEEDBACK_REQUIRED' || @@ -287,6 +301,8 @@ function errorStatus(error: unknown): number { code === 'SUBMISSION_CONFLICT' ) return 409 if (code === 'SHUTTING_DOWN') return 503 + if (code === 'DEVELOPMENT_ELEMENT_CALLOUT_NOT_READY') return 503 + if (code === 'DEVELOPMENT_ELEMENT_CALLOUT_TIMEOUT') return 504 if (code === 'REQUEST_UNCERTAIN') return 503 if (code === 'BODY_TOO_LARGE') return 413 return 500 @@ -313,6 +329,7 @@ export async function startLocalService({ 'Review activation is unavailable.' )), onChange = () => {}, + onDevelopmentElementCallout, onDevelopmentReload, onQuit = () => {}, onUnauthorized = () => {}, @@ -433,6 +450,35 @@ export async function startLocalService({ const url = new URL(request.url || '', 'http://127.0.0.1') + if ( + request.method === 'POST' && + url.pathname === '/development/element-callout' && + onDevelopmentElementCallout + ) { + const body = await readJson(request) + if (!isDevelopmentElementCalloutRequest(body)) { + throw serviceError( + 'INVALID_DEVELOPMENT_ELEMENT_CALLOUT', + 'Development element callout input is invalid.' + ) + } + const result = await onDevelopmentElementCallout(body) + if (result.status === 'stale') { + throw serviceError( + 'DEVELOPMENT_ELEMENT_REFERENCE_STALE', + 'The development element reference is stale.' + ) + } + if (result.status === 'ambiguous') { + throw serviceError( + 'DEVELOPMENT_ELEMENT_REFERENCE_AMBIGUOUS', + 'The development element reference is ambiguous.' + ) + } + sendJson(response, 200, result) + return + } + if ( request.method === 'POST' && url.pathname === '/development/reload' && diff --git a/src/main.ts b/src/main.ts index 0df9239c..0b7e7e4e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,10 @@ import { isDevelopmentControlQuit } from './development-control' import { loadDevelopmentConfig } from './development-config' +import type { + DevelopmentElementCalloutRequest, + DevelopmentElementCalloutResult +} from './development-element' import { persistReviewSnapshots, runDurabilityShutdown @@ -172,6 +176,12 @@ interface PendingResolutionConfirmation { timeout: ReturnType } +interface PendingElementCallout { + reject: (reason?: unknown) => void + resolve: (result: DevelopmentElementCalloutResult) => void + timeout: ReturnType +} + interface PendingReviewUrl { focusState: MarkoverWindowFocusState parsed: ReviewUrl @@ -271,6 +281,7 @@ let snapshotSequence = 0 let statusSequence = 0 let activationSequence = 0 let resolutionSequence = 0 +let elementCalloutSequence = 0 let brandAssetsPromise: Promise | null = null let startupDiagnostic: StartupDiagnostic | null = null let startupBuildIdentity: BuildIdentity | null = null @@ -296,6 +307,7 @@ const pendingResolutionConfirmations = new Map< string, PendingResolutionConfirmation >() +const pendingElementCallouts = new Map() const pendingManagedReviewNotifications = new Map() const reviewProjectContexts = new Map>() const projectFavicons = new Map>() @@ -1561,6 +1573,40 @@ async function requestRendererActivation( }) } +async function requestDevelopmentElementCallout( + request: DevelopmentElementCalloutRequest +): Promise { + if ( + !developmentWatchMode || + !mainWindow || + mainWindow.isDestroyed() || + !startupReady + ) { + throw Object.assign( + new Error('Development element callouts require a running live development window.'), + { code: 'DEVELOPMENT_ELEMENT_CALLOUT_NOT_READY' } + ) + } + const window = mainWindow + await waitForRendererReady(window) + elementCalloutSequence += 1 + const requestId = `element-callout-${String(elementCalloutSequence)}` + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingElementCallouts.delete(requestId) + reject(Object.assign( + new Error('Timed out waiting for the development element callout.'), + { code: 'DEVELOPMENT_ELEMENT_CALLOUT_TIMEOUT' } + )) + }, 5000) + pendingElementCallouts.set(requestId, { reject, resolve, timeout }) + sendMainEvent(window.webContents, 'development:element-callout', { + ...request, + requestId + }) + }) +} + function reviewResolutionSummary( artifact: ReviewArtifact ): ReviewResolutionSummary { @@ -1899,7 +1945,10 @@ async function startAndPublishService(): Promise { ), onActivate: activateManagedReview, ...(developmentWatchMode - ? { onDevelopmentReload: reloadDevelopmentRenderer } + ? { + onDevelopmentElementCallout: requestDevelopmentElementCallout, + onDevelopmentReload: reloadDevelopmentRenderer + } : {}), onQuit() { app.quit() @@ -2189,6 +2238,7 @@ if (!hasSingleInstanceLock) { startupInfo = { development: developmentRuntime, diagnosticPath: startupDiagnosticPath, + elementCallouts: developmentWatchMode, smoke: smokeMode, ...controls } @@ -2620,6 +2670,16 @@ if (!hasSingleInstanceLock) { ) => { requireManagedAutosave().queue(reviewId, tree) }) + privilegedIpc.on('development:element-callout-response', ( + _event: IpcMainEvent, + result: DevelopmentElementCalloutResult + ) => { + const pending = pendingElementCallouts.get(result.requestId) + if (!pending) return + clearTimeout(pending.timeout) + pendingElementCallouts.delete(result.requestId) + pending.resolve(result) + }) createWindow() diff --git a/src/preload.ts b/src/preload.ts index 141d5617..1eeed5be 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -16,6 +16,10 @@ import { type RendererSendArguments, type RendererSendChannel } from './ipc-contract' +import type { + DevelopmentElementCalloutCommand, + DevelopmentElementCalloutResult +} from './development-element' function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) @@ -135,6 +139,34 @@ async function respondToReviewResolutionConfirmation( } satisfies ReviewResolutionConfirmationResponse) } +async function respondToDevelopmentElementCallout( + callback: ( + command: DevelopmentElementCalloutCommand + ) => DevelopmentElementCalloutResult | Promise, + command: DevelopmentElementCalloutCommand +): Promise { + try { + send( + 'development:element-callout-response', + await callback(command) + ) + } catch { + send( + 'development:element-callout-response', + command.action === 'highlight' + ? { + reference: command.reference, + requestId: command.requestId, + status: 'stale' + } + : { + requestId: command.requestId, + status: 'cleared' + } + ) + } +} + const bridge = { getStartupInfo: () => invoke('startup:info'), reportStartupPhase: (event) => invoke('startup:phase', event), @@ -163,6 +195,11 @@ const bridge = { callback() }) }, + onDevelopmentElementCallout: (callback) => { + listen('development:element-callout', (command) => { + void respondToDevelopmentElementCallout(callback, command) + }) + }, checksum: (source) => invoke('document:checksum', source), copyText: (text) => { send('clipboard:write', text) diff --git a/src/renderer.ts b/src/renderer.ts index 1389452b..d86279c5 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -12,6 +12,7 @@ import * as MarkoverAgentGuidance from './agent-guidance' import * as MarkoverAnnotationBlock from './annotation-block' import * as MarkoverAnnotations from './annotations' import { autosaveFailureMessage } from './durability-status' +import { installDevelopmentElementCallouts } from './development-element' import { appendIncomingReview, incomingReviewAction, @@ -546,6 +547,7 @@ const BRIDGE_METHODS = [ 'getWorkspaceState', 'getWindowFocusState', 'onOpenMarkdownRequested', + 'onDevelopmentElementCallout', 'onReviewOpened', 'onReviewUpdated', 'onReviewTrashed', @@ -5098,6 +5100,13 @@ async function rendererSmokeResult(): Promise<{ async function initialize(): Promise { const startupInfo = await bridge.getStartupInfo() startupUi.development(startupInfo.development) + if (startupInfo.elementCallouts) { + const callouts = installDevelopmentElementCallouts(document, { + copyText: bridge.copyText, + notify: showToast + }) + bridge.onDevelopmentElementCallout((command) => callouts.handle(command)) + } bridge.onWindowFocusChanged((focusState) => { windowFocusStateVersion += 1 windowFocusState = focusState diff --git a/src/startup-contract.ts b/src/startup-contract.ts index db8b5028..adf59d28 100644 --- a/src/startup-contract.ts +++ b/src/startup-contract.ts @@ -30,6 +30,7 @@ export interface BuildIdentity { export interface StartupInfo { development: boolean diagnosticPath: string + elementCallouts: boolean holdPhase: StartupPhase | null failPhase: StartupPhase | null smoke: boolean diff --git a/src/styles.css b/src/styles.css index 592afdad..e82f76c8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -78,6 +78,32 @@ --pane-label-highlight: var(--markover-secondary); } +.development-element-callout { + position: fixed; + z-index: 2147483647; + box-sizing: border-box; + border: 2px solid #ff2ea6; + border-radius: 4px; + background: rgb(255 46 166 / 10%); + box-shadow: 0 0 0 1px rgb(255 255 255 / 80%); + pointer-events: none; +} + +.development-element-callout-label { + position: absolute; + bottom: 100%; + left: -2px; + max-width: min(420px, 80vw); + padding: 3px 6px; + overflow: hidden; + border-radius: 4px 4px 0 0; + background: #ff2ea6; + color: white; + font: 600 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + :root[data-palette="olive"] { --markover-primary: #4e5828; --markover-secondary: #b5d52a; diff --git a/test/development-element.test.ts b/test/development-element.test.ts new file mode 100644 index 00000000..ca84c88f --- /dev/null +++ b/test/development-element.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' + +import { JSDOM } from 'jsdom' + +import { + developmentElementReference, + installDevelopmentElementCallouts, + isDevelopmentElementCalloutCommand, + isDevelopmentElementReference, + resolveDevelopmentElementReference +} from '../src/development-element' + +const root = path.resolve(__dirname, '../..') + +test('one Option-click pins an element and copies its stable reference', () => { + const dom = new JSDOM('
') + const { document, MouseEvent } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: 62, + height: 42, + left: 10, + right: 130, + toJSON: () => ({}), + top: 20, + width: 120, + x: 10, + y: 20 + }) + const copied: string[] = [] + const notices: string[] = [] + const callouts = installDevelopmentElementCallouts(document, { + copyText: (reference) => { copied.push(reference) }, + notify: (message) => { notices.push(message) } + }) + let ordinaryClicks = 0 + button.addEventListener('click', () => { ordinaryClicks += 1 }) + + button.dispatchEvent(new MouseEvent('click', { + altKey: true, + bubbles: true, + button: 0, + cancelable: true + })) + + assert.equal(ordinaryClicks, 0) + assert.equal(copied.length, 1) + assert.equal(isDevelopmentElementReference(copied[0]), true) + assert.deepEqual(notices, ['Element reference copied']) + const overlay = document.querySelector('.development-element-callout') + assert.ok(overlay) + assert.equal(overlay.hidden, false) + assert.equal(overlay.style.left, '10px') + assert.equal(overlay.style.width, '120px') + + const result = callouts.handle({ + action: 'highlight', + reference: copied[0], + requestId: 'element-callout-1' + }) + assert.deepEqual(result, { + bounds: { height: 42, width: 120, x: 10, y: 20 }, + reference: copied[0], + requestId: 'element-callout-1', + status: 'highlighted' + }) + assert.deepEqual(callouts.handle({ + action: 'clear', + requestId: 'element-callout-2' + }), { + requestId: 'element-callout-2', + status: 'cleared' + }) + assert.equal(overlay.hidden, true) +}) + +test('references fail stale or ambiguous without selecting another element', () => { + const dom = new JSDOM('
') + const { document } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + const reference = developmentElementReference(button, document) + assert.equal(resolveDevelopmentElementReference(reference, document).status, 'found') + + document.querySelector('section')?.remove() + assert.deepEqual(resolveDevelopmentElementReference(reference, document), { + status: 'stale' + }) + + const anchored = developmentElementReference( + document.querySelector('#workspace') as HTMLElement, + document + ) + const duplicate = document.createElement('main') + duplicate.id = 'workspace' + document.body.append(duplicate) + assert.deepEqual(resolveDevelopmentElementReference(anchored, document), { + status: 'ambiguous' + }) +}) + +test('element commands accept only canonical references and exact actions', () => { + const dom = new JSDOM('') + const reference = developmentElementReference( + dom.window.document.querySelector('button') as HTMLButtonElement, + dom.window.document + ) + assert.equal(isDevelopmentElementCalloutCommand({ + action: 'highlight', + reference, + requestId: 'element-callout-1' + }), true) + assert.equal(isDevelopmentElementCalloutCommand({ + action: 'clear', + requestId: 'element-callout-2' + }), true) + assert.equal(isDevelopmentElementCalloutCommand({ + action: 'highlight', + reference: `${reference}x`, + requestId: 'element-callout-3' + }), false) + assert.equal(isDevelopmentElementCalloutCommand({ + action: 'clear', + reference, + requestId: 'element-callout-4' + }), false) +}) + +test('picker and service route are live-watch-only and documented', () => { + const main = fs.readFileSync(path.join(root, 'src/main.ts'), 'utf8') + const renderer = fs.readFileSync(path.join(root, 'src/renderer.ts'), 'utf8') + const docs = fs.readFileSync( + path.join(root, 'docs/developer/development.md'), + 'utf8' + ) + assert.match(main, /elementCallouts: developmentWatchMode/) + assert.match( + main, + /\.\.\.\(developmentWatchMode[\s\S]*onDevelopmentElementCallout: requestDevelopmentElementCallout/ + ) + assert.match( + renderer, + /if \(startupInfo\.elementCallouts\)[\s\S]*installDevelopmentElementCallouts/ + ) + assert.match(docs, /Option-click any rendered element/) + assert.match(docs, /--instance dev element highlight/) + assert.match(docs, /--instance dev element clear/) +}) diff --git a/test/ipc-security.test.ts b/test/ipc-security.test.ts index 54bf5348..b8b2f160 100644 --- a/test/ipc-security.test.ts +++ b/test/ipc-security.test.ts @@ -568,6 +568,43 @@ test('review resolution IPC carries exact selections and preserved feedback summ }) }) +test('development element callout IPC accepts only exact references and outcomes', () => { + const reference = `mko-ui-v1:${Buffer.from(JSON.stringify({ + anchorId: 'save', + path: [], + version: 1 + })).toString('base64url')}` + assert.doesNotThrow(() => { + assertMainEventArguments('development:element-callout', [{ + action: 'highlight', + reference, + requestId: 'element-callout-1' + }]) + }) + assert.throws(() => { + assertMainEventArguments('development:element-callout', [{ + action: 'highlight', + reference: `${reference}x`, + requestId: 'element-callout-1' + }]) + }) + assert.doesNotThrow(() => { + assertRendererSendArguments('development:element-callout-response', [{ + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }]) + }) + assert.throws(() => { + assertRendererSendArguments('development:element-callout-response', [{ + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }]) + }) +}) + test('application IPC uses only the centralized registration and bridge paths', () => { const main = fs.readFileSync(path.join(root, 'src/main.ts'), 'utf8') const preload = fs.readFileSync(path.join(root, 'src/preload.ts'), 'utf8') @@ -586,6 +623,7 @@ test('application IPC uses only the centralized registration and bridge paths', 'brand:assets', 'clipboard:read-image', 'clipboard:write', + 'development:element-callout-response', 'document:checksum', 'document:open', 'review:activate', diff --git a/test/local-service.test.ts b/test/local-service.test.ts index 1d899c4d..1f097374 100644 --- a/test/local-service.test.ts +++ b/test/local-service.test.ts @@ -124,6 +124,7 @@ async function serviceFixture( await options.onChange?.(artifact, action) }, onActivate: options.onActivate, + onDevelopmentElementCallout: options.onDevelopmentElementCallout, onDevelopmentReload: options.onDevelopmentReload, onQuit: options.onQuit, onUnauthorized: options.onUnauthorized, @@ -889,6 +890,85 @@ test('development reload allows the complete renderer durability barrier', async await reload }) +test('development element callouts are authenticated, exact, and opt-in', async (t) => { + const reference = `mko-ui-v1:${Buffer.from(JSON.stringify({ + anchorId: 'save', + path: [], + version: 1 + })).toString('base64url')}` + const requests: unknown[] = [] + const enabled = await serviceFixture(t, { + onDevelopmentElementCallout(request) { + requests.push(request) + return Promise.resolve({ + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }) + } + }) + assert.deepEqual(await requestJson( + enabled.endpointPath, + 'POST', + '/development/element-callout', + { action: 'highlight', reference } + ), { + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }) + assert.deepEqual(requests, [{ action: 'highlight', reference }]) + await assert.rejects( + requestJson( + enabled.endpointPath, + 'POST', + '/development/element-callout', + { action: 'highlight', reference: `${reference}x` } + ), + (error: unknown) => hasServiceError( + error, + 'INVALID_DEVELOPMENT_ELEMENT_CALLOUT', + 400 + ) + ) + + const stale = await serviceFixture(t, { + onDevelopmentElementCallout(request) { + return Promise.resolve({ + reference: request.reference, + requestId: 'element-callout-1', + status: 'stale' + }) + } + }) + await assert.rejects( + requestJson( + stale.endpointPath, + 'POST', + '/development/element-callout', + { action: 'highlight', reference } + ), + (error: unknown) => hasServiceError( + error, + 'DEVELOPMENT_ELEMENT_REFERENCE_STALE', + 404 + ) + ) + + const disabled = await serviceFixture(t) + await assert.rejects( + requestJson( + disabled.endpointPath, + 'POST', + '/development/element-callout', + { action: 'clear' } + ), + (error: unknown) => hasServiceError(error, 'NOT_FOUND', 404) + ) +}) + test('authenticated quit acknowledges and invokes the app callback', async (t) => { let quits = 0 const { endpointPath } = await serviceFixture(t, { diff --git a/test/markover-cli.test.ts b/test/markover-cli.test.ts index e8e59586..55f958b5 100644 --- a/test/markover-cli.test.ts +++ b/test/markover-cli.test.ts @@ -222,6 +222,11 @@ test('parses lifecycle commands and PR observations', () => { }) test('development targeting is worktree-local and cleanup requires an exact identity', () => { + const reference = `mko-ui-v1:${Buffer.from(JSON.stringify({ + anchorId: 'save', + path: [], + version: 1 + })).toString('base64url')}` assert.deepEqual( parseCommandArguments(['--instance', 'dev', 'get', 'mko_aaa11111']), { @@ -239,6 +244,33 @@ test('development targeting is worktree-local and cleanup requires an exact iden instance: 'development' } ) + assert.deepEqual( + parseCommandArguments([ + '--instance', + 'dev', + 'element', + 'highlight', + reference + ]), + { + action: 'highlight', + command: 'element', + instance: 'development', + reference + } + ) + assert.deepEqual( + parseCommandArguments(['--instance', 'dev', 'element', 'clear']), + { + action: 'clear', + command: 'element', + instance: 'development' + } + ) + assert.throws( + () => parseCommandArguments(['element', 'clear']), + /only for the current live development worktree/ + ) assert.throws( () => parseCommandArguments(['cleanup', 'pr-61']), /only for the current development worktree/ @@ -253,6 +285,46 @@ test('development targeting is worktree-local and cleanup requires an exact iden ) }) +test('development element commands target only the addressed running service', async () => { + const reference = `mko-ui-v1:${Buffer.from(JSON.stringify({ + anchorId: 'save', + path: [], + version: 1 + })).toString('base64url')}` + const requests: unknown[] = [] + const result = await executeCommand({ + action: 'highlight', + command: 'element', + instance: 'development', + reference + }, { + endpointPath: '/development/service.json', + requestLocal(endpointPath, method, requestPath, body) { + requests.push({ body, endpointPath, method, requestPath }) + return Promise.resolve({ + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }) + }, + ensure() { + throw new Error('element callouts must not cold-start Markover') + } + }) + assert.deepEqual(result, { + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + status: 'highlighted' + }) + assert.deepEqual(requests, [{ + body: { action: 'highlight', reference }, + endpointPath: '/development/service.json', + method: 'POST', + requestPath: '/development/element-callout' + }]) +}) + test('canonical maintenance is explicit and instance-independent', () => { assert.deepEqual( parseCommandArguments(['canonical', 'doctor']), @@ -933,7 +1005,7 @@ test('CLI help is strict JSON and misuse gives an exact recovery path', () => { assert.match(misuse.stderr, /Unknown command: wat/) assert.match( misuse.stderr, - /Usage: markover / + /Usage: markover / ) assert.match( misuse.stderr, From 1403f9144679b1d1e73ca39da313479d92996e7d Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 12:10:32 -0700 Subject: [PATCH 02/10] Keep element callouts exact --- src/development-element.ts | 72 ++++++++++++++++++++++++++++++-- src/renderer.ts | 8 +++- test/development-element.test.ts | 60 ++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/development-element.ts b/src/development-element.ts index 356530f8..b2508703 100644 --- a/src/development-element.ts +++ b/src/development-element.ts @@ -5,6 +5,8 @@ const MAXIMUM_PATH_LENGTH = 128 const ELEMENT_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/ interface DevelopmentElementPathSegment { + count: number + fingerprint: string index: number name: string } @@ -45,6 +47,7 @@ export interface DevelopmentElementCallouts { handle: ( command: DevelopmentElementCalloutCommand ) => DevelopmentElementCalloutResult + reposition: () => DevelopmentElementBounds | null } interface DevelopmentElementCalloutOptions { @@ -95,10 +98,16 @@ function isPayload(value: unknown): value is DevelopmentElementReferencePayload } return value.path.every((segment) => ( isRecord(segment) && + Object.keys(segment).sort().join(',') === 'count,fingerprint,index,name' && typeof segment.name === 'string' && ELEMENT_NAME_PATTERN.test(segment.name) && + typeof segment.fingerprint === 'string' && + /^[a-f0-9]{32}$/.test(segment.fingerprint) && + Number.isInteger(segment.count) && + (segment.count as number) > 0 && Number.isInteger(segment.index) && - (segment.index as number) >= 0 + (segment.index as number) >= 0 && + (segment.index as number) < (segment.count as number) )) } @@ -208,7 +217,58 @@ function segmentFor(element: Element): DevelopmentElementPathSegment { if (index < 0 || !ELEMENT_NAME_PATTERN.test(element.localName)) { throw new Error('Development element path is invalid.') } - return { name: element.localName, index } + return { + count: siblings.length, + fingerprint: elementFingerprint(element), + name: element.localName, + index + } +} + +const FNV_PRIME_64 = 0x100000001b3n +const FNV_OFFSET_64 = 0xcbf29ce484222325n +const FNV_SECOND_OFFSET_64 = 0x6c62272e07bb0142n + +function fingerprintPart(bytes: Uint8Array, seed: bigint): string { + let hash = seed + for (const byte of bytes) { + hash ^= BigInt(byte) + hash = BigInt.asUintN(64, hash * FNV_PRIME_64) + } + return hash.toString(16).padStart(16, '0') +} + +function elementFingerprint(element: Element): string { + const shallow = element.cloneNode(false) as Element + shallow.removeAttribute('style') + const stableClasses = [...element.classList] + .filter((name) => !name.startsWith('is-') && !name.startsWith('has-')) + .sort() + if (stableClasses.length) shallow.setAttribute('class', stableClasses.join(' ')) + else shallow.removeAttribute('class') + for (const attribute of [ + 'aria-activedescendant', + 'aria-busy', + 'aria-checked', + 'aria-current', + 'aria-disabled', + 'aria-expanded', + 'aria-hidden', + 'aria-pressed', + 'aria-selected', + 'tabindex' + ]) shallow.removeAttribute(attribute) + const directText = [...element.childNodes] + .filter((node) => node.nodeType === 3) + .map((node) => node.textContent || '') + .join(' ') + .replace(/\s+/g, ' ') + .trim() + const bytes = new TextEncoder().encode(`${shallow.outerHTML}\0${directText}`) + return `${fingerprintPart(bytes, FNV_OFFSET_64)}${fingerprintPart( + bytes, + FNV_SECOND_OFFSET_64 + )}` } export function developmentElementReference( @@ -256,8 +316,11 @@ export function resolveDevelopmentElementReference( const candidates = [...current.children].filter( (candidate) => candidate.localName === segment.name ) + if (candidates.length !== segment.count) return { status: 'stale' } const next = candidates[segment.index] - if (!next) return { status: 'stale' } + if (!next || elementFingerprint(next) !== segment.fingerprint) { + return { status: 'stale' } + } current = next } return { element: current, status: 'found' } @@ -362,6 +425,7 @@ export function installDevelopmentElementCallouts( requestId: command.requestId, status: 'highlighted' } - } + }, + reposition: position } } diff --git a/src/renderer.ts b/src/renderer.ts index d86279c5..e99e4794 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -12,7 +12,10 @@ import * as MarkoverAgentGuidance from './agent-guidance' import * as MarkoverAnnotationBlock from './annotation-block' import * as MarkoverAnnotations from './annotations' import { autosaveFailureMessage } from './durability-status' -import { installDevelopmentElementCallouts } from './development-element' +import { + installDevelopmentElementCallouts, + type DevelopmentElementCallouts +} from './development-element' import { appendIncomingReview, incomingReviewAction, @@ -284,6 +287,7 @@ let sourceDiffCleanup: (() => void) | null = null let sourceDiffModule: Promise | null = null let sourceDiffRenderer: DiffRenderer | null = null let paneResizeLayoutFrame: number | null = null +let developmentElementCallouts: DevelopmentElementCallouts | null = null let statusAnnouncementFrame: number | null = null let imagePreviewReturnFocus: HTMLElement | null = null let resolutionDialogCompletion: ((confirmed: boolean) => void) | null = null @@ -3662,6 +3666,7 @@ function schedulePaneLayoutResizeUpdate(): void { paneResizeLayoutFrame = requestAnimationFrame(() => { paneResizeLayoutFrame = null updatePinnedSelection() + developmentElementCallouts?.reposition() MarkoverAnnotationBlock.updateTruncation(elements.annotationList) }) } @@ -5105,6 +5110,7 @@ async function initialize(): Promise { copyText: bridge.copyText, notify: showToast }) + developmentElementCallouts = callouts bridge.onDevelopmentElementCallout((command) => callouts.handle(command)) } bridge.onWindowFocusChanged((focusState) => { diff --git a/test/development-element.test.ts b/test/development-element.test.ts index ca84c88f..8acfad56 100644 --- a/test/development-element.test.ts +++ b/test/development-element.test.ts @@ -101,6 +101,62 @@ test('references fail stale or ambiguous without selecting another element', () }) }) +test('references reject changed same-tag sibling ordinals', () => { + const dom = new JSDOM( + '
' + ) + const { document } = dom.window + const buttons = document.querySelectorAll('button') + const reference = developmentElementReference(buttons[1] as Element, document) + const inserted = document.createElement('button') + inserted.textContent = 'Inserted' + buttons[1]?.before(inserted) + + assert.deepEqual(resolveDevelopmentElementReference(reference, document), { + status: 'stale' + }) +}) + +test('pinned callouts can reposition after application layout changes', () => { + const dom = new JSDOM('
') + const { document } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + let x = 10 + button.getBoundingClientRect = () => ({ + bottom: 60, + height: 40, + left: x, + right: x + 120, + toJSON: () => ({}), + top: 20, + width: 120, + x, + y: 20 + }) + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + const reference = developmentElementReference(button, document) + callouts.handle({ + action: 'highlight', + reference, + requestId: 'element-callout-1' + }) + x = 75 + + assert.deepEqual(callouts.reposition(), { + height: 40, + width: 120, + x: 75, + y: 20 + }) + assert.equal( + document.querySelector('.development-element-callout')?.style.left, + '75px' + ) +}) + test('element commands accept only canonical references and exact actions', () => { const dom = new JSDOM('') const reference = developmentElementReference( @@ -144,6 +200,10 @@ test('picker and service route are live-watch-only and documented', () => { renderer, /if \(startupInfo\.elementCallouts\)[\s\S]*installDevelopmentElementCallouts/ ) + assert.match( + renderer, + /schedulePaneLayoutResizeUpdate[\s\S]*developmentElementCallouts\?\.reposition\(\)/ + ) assert.match(docs, /Option-click any rendered element/) assert.match(docs, /--instance dev element highlight/) assert.match(docs, /--instance dev element clear/) From 2e56fc0702fa607a2aa75c27b4bf1356eb5755f6 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 12:20:22 -0700 Subject: [PATCH 03/10] Complete callout recovery paths --- docs/developer/development.md | 7 ++++- scripts/markover.ts | 12 ++------ src/development-element.ts | 53 ++++++++++++++++---------------- src/renderer.ts | 1 + test/development-element.test.ts | 20 ++++++++++++ test/markover-cli.test.ts | 42 ++++++++++++++++++++++--- 6 files changed, 94 insertions(+), 41 deletions(-) diff --git a/docs/developer/development.md b/docs/developer/development.md index 39a6ba11..84fec1a2 100644 --- a/docs/developer/development.md +++ b/docs/developer/development.md @@ -187,7 +187,12 @@ Clear the pinned box with: npm --silent run markover -- --instance dev element clear ``` -References use a validated unique-ID anchor and deterministic element path. +Omit `--instance dev`, or pass `--instance canonical`, when `npm run dev` +addresses the canonical checkout instead of a pull-request worktree. Element +commands address only an already-running watcher and never cold-start Markover. + +References use a validated unique-ID anchor and deterministic same-tag child +indices, sibling counts, and structural fingerprints. Stale or ambiguous references fail instead of selecting a different element. The picker and authenticated highlight route exist only in a running live development watcher; release and non-watch instances do not expose them. diff --git a/scripts/markover.ts b/scripts/markover.ts index a49c32ab..eabafb9d 100644 --- a/scripts/markover.ts +++ b/scripts/markover.ts @@ -314,7 +314,7 @@ export function helpPayload() { }, { name: 'element', - usage: '--instance dev element highlight | --instance dev element clear', + usage: '[--instance ] element highlight | [--instance ] element clear', purpose: 'Highlight or clear one exact element callout in the addressed running live development window.' }, { @@ -428,12 +428,6 @@ export function parseCommandArguments(args: string[]): ParsedCommand { } if (command === 'element') { - if (instance !== 'development') { - throw commandError( - 'element callouts are available only for the current live development worktree.', - 'markover --instance dev element ...' - ) - } if (rest.length === 1 && rest[0] === 'clear') { return targeted({ command, action: 'clear' as const }) } @@ -450,7 +444,7 @@ export function parseCommandArguments(args: string[]): ParsedCommand { } throw commandError( 'element requires highlight with one copied reference, or clear.', - 'markover --instance dev element highlight | markover --instance dev element clear' + 'markover [--instance ] element highlight | markover [--instance ] element clear' ) } @@ -1661,7 +1655,7 @@ export async function executeCommand( } if (parsed.command === 'element') { const endpointPath = options.endpointPath || ( - await resolveTarget('development') + await resolveTarget(selector) ).service.endpointPath const result = await (options.requestLocal || requestJson)( endpointPath, diff --git a/src/development-element.ts b/src/development-element.ts index b2508703..0aac1292 100644 --- a/src/development-element.ts +++ b/src/development-element.ts @@ -239,32 +239,33 @@ function fingerprintPart(bytes: Uint8Array, seed: bigint): string { } function elementFingerprint(element: Element): string { - const shallow = element.cloneNode(false) as Element - shallow.removeAttribute('style') - const stableClasses = [...element.classList] - .filter((name) => !name.startsWith('is-') && !name.startsWith('has-')) - .sort() - if (stableClasses.length) shallow.setAttribute('class', stableClasses.join(' ')) - else shallow.removeAttribute('class') - for (const attribute of [ - 'aria-activedescendant', - 'aria-busy', - 'aria-checked', - 'aria-current', - 'aria-disabled', - 'aria-expanded', - 'aria-hidden', - 'aria-pressed', - 'aria-selected', - 'tabindex' - ]) shallow.removeAttribute(attribute) - const directText = [...element.childNodes] - .filter((node) => node.nodeType === 3) - .map((node) => node.textContent || '') - .join(' ') - .replace(/\s+/g, ' ') - .trim() - const bytes = new TextEncoder().encode(`${shallow.outerHTML}\0${directText}`) + const snapshot = element.cloneNode(true) as Element + const candidates = [snapshot, ...snapshot.querySelectorAll('*')] + for (const candidate of candidates) { + if (candidate.hasAttribute('data-markover-development-callout')) { + candidate.remove() + continue + } + candidate.removeAttribute('style') + const stableClasses = [...candidate.classList] + .filter((name) => !name.startsWith('is-') && !name.startsWith('has-')) + .sort() + if (stableClasses.length) candidate.setAttribute('class', stableClasses.join(' ')) + else candidate.removeAttribute('class') + for (const attribute of [ + 'aria-activedescendant', + 'aria-busy', + 'aria-checked', + 'aria-current', + 'aria-disabled', + 'aria-expanded', + 'aria-hidden', + 'aria-pressed', + 'aria-selected', + 'tabindex' + ]) candidate.removeAttribute(attribute) + } + const bytes = new TextEncoder().encode(snapshot.outerHTML) return `${fingerprintPart(bytes, FNV_OFFSET_64)}${fingerprintPart( bytes, FNV_SECOND_OFFSET_64 diff --git a/src/renderer.ts b/src/renderer.ts index e99e4794..244e931f 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -3592,6 +3592,7 @@ function renderDocumentsList(): void { if (incompatibleReviews.length) { elements.documentsListTree.append(renderIncompatibleReviews()) } + developmentElementCallouts?.reposition() } function applyLeftPaneWidth(): void { diff --git a/test/development-element.test.ts b/test/development-element.test.ts index 8acfad56..4db3c32d 100644 --- a/test/development-element.test.ts +++ b/test/development-element.test.ts @@ -115,6 +115,22 @@ test('references reject changed same-tag sibling ordinals', () => { assert.deepEqual(resolveDevelopmentElementReference(reference, document), { status: 'stale' }) + + const reorderedDom = new JSDOM( + '
Alpha
Beta
' + ) + const reorderedDocument = reorderedDom.window.document + const details = reorderedDocument.querySelectorAll('details') + const descendant = details[0]?.querySelector('span') as Element + const reorderedReference = developmentElementReference( + descendant, + reorderedDocument + ) + details[1]?.after(details[0] as Element) + assert.deepEqual( + resolveDevelopmentElementReference(reorderedReference, reorderedDocument), + { status: 'stale' } + ) }) test('pinned callouts can reposition after application layout changes', () => { @@ -204,6 +220,10 @@ test('picker and service route are live-watch-only and documented', () => { renderer, /schedulePaneLayoutResizeUpdate[\s\S]*developmentElementCallouts\?\.reposition\(\)/ ) + assert.match( + renderer, + /function renderDocumentsList\(\)[\s\S]*replaceChildren\(\)[\s\S]*developmentElementCallouts\?\.reposition\(\)/ + ) assert.match(docs, /Option-click any rendered element/) assert.match(docs, /--instance dev element highlight/) assert.match(docs, /--instance dev element clear/) diff --git a/test/markover-cli.test.ts b/test/markover-cli.test.ts index 55f958b5..25545fef 100644 --- a/test/markover-cli.test.ts +++ b/test/markover-cli.test.ts @@ -267,9 +267,13 @@ test('development targeting is worktree-local and cleanup requires an exact iden instance: 'development' } ) - assert.throws( - () => parseCommandArguments(['element', 'clear']), - /only for the current live development worktree/ + assert.deepEqual( + parseCommandArguments(['element', 'clear']), + { action: 'clear', command: 'element' } + ) + assert.deepEqual( + parseCommandArguments(['--instance', 'canonical', 'element', 'clear']), + { action: 'clear', command: 'element', instance: 'canonical' } ) assert.throws( () => parseCommandArguments(['cleanup', 'pr-61']), @@ -285,20 +289,26 @@ test('development targeting is worktree-local and cleanup requires an exact iden ) }) -test('development element commands target only the addressed running service', async () => { +test('element commands target only the addressed running watcher service', async () => { const reference = `mko-ui-v1:${Buffer.from(JSON.stringify({ anchorId: 'save', path: [], version: 1 })).toString('base64url')}` const requests: unknown[] = [] + const selectors: string[] = [] const result = await executeCommand({ action: 'highlight', command: 'element', instance: 'development', reference }, { - endpointPath: '/development/service.json', + resolveTarget(selector) { + selectors.push(selector) + return Promise.resolve({ + service: { endpointPath: `/${selector}/service.json` } + } as unknown as ResolvedInstance) + }, requestLocal(endpointPath, method, requestPath, body) { requests.push({ body, endpointPath, method, requestPath }) return Promise.resolve({ @@ -323,6 +333,28 @@ test('development element commands target only the addressed running service', a method: 'POST', requestPath: '/development/element-callout' }]) + assert.deepEqual(selectors, ['development']) + + await executeCommand({ + action: 'clear', + command: 'element', + instance: 'canonical' + }, { + resolveTarget(selector) { + selectors.push(selector) + return Promise.resolve({ + service: { endpointPath: `/${selector}/service.json` } + } as unknown as ResolvedInstance) + }, + requestLocal(endpointPath) { + assert.equal(endpointPath, '/canonical/service.json') + return Promise.resolve({ + requestId: 'element-callout-2', + status: 'cleared' + }) + } + }) + assert.deepEqual(selectors, ['development', 'canonical']) }) test('canonical maintenance is explicit and instance-independent', () => { From f37dbdab53f723d20924efde0e55317a2eca9100 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 12:29:21 -0700 Subject: [PATCH 04/10] Keep tree callouts aligned --- src/renderer.ts | 1 + test/development-element.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/renderer.ts b/src/renderer.ts index 244e931f..a9e87dc9 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1276,6 +1276,7 @@ function renderTree(): void { if (unsupported) { elements.parseStatus.append(` · ${unsupported} omitted`) } + developmentElementCallouts?.reposition() requestAnimationFrame(updatePinnedSelection) } diff --git a/test/development-element.test.ts b/test/development-element.test.ts index 4db3c32d..c8fca534 100644 --- a/test/development-element.test.ts +++ b/test/development-element.test.ts @@ -224,6 +224,10 @@ test('picker and service route are live-watch-only and documented', () => { renderer, /function renderDocumentsList\(\)[\s\S]*replaceChildren\(\)[\s\S]*developmentElementCallouts\?\.reposition\(\)/ ) + assert.match( + renderer, + /function renderTree\(\)[\s\S]*elements\.tree\.replaceChildren\(\)[\s\S]*developmentElementCallouts\?\.reposition\(\)/ + ) assert.match(docs, /Option-click any rendered element/) assert.match(docs, /--instance dev element highlight/) assert.match(docs, /--instance dev element clear/) From 7850d4f1e1a755c646d8d38caa63fd103dc86575 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 12:37:23 -0700 Subject: [PATCH 05/10] Clear detached element callouts --- src/development-element.ts | 13 +++++++++++++ src/renderer.ts | 2 -- test/development-element.test.ts | 32 ++++++++++++++++++++++++-------- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/development-element.ts b/src/development-element.ts index 0aac1292..bdc15ef3 100644 --- a/src/development-element.ts +++ b/src/development-element.ts @@ -380,6 +380,19 @@ export function installDevelopmentElementCallouts( pinnedReference = reference return position() as DevelopmentElementBounds } + const MutationObserver = document.defaultView?.MutationObserver + const observer = MutationObserver + ? new MutationObserver((records) => { + if ( + pinned && + records.some((record) => !overlay.contains(record.target)) + ) position() + }) + : null + observer?.observe(document.documentElement, { + childList: true, + subtree: true + }) document.addEventListener('click', (event) => { if ( diff --git a/src/renderer.ts b/src/renderer.ts index a9e87dc9..e99e4794 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1276,7 +1276,6 @@ function renderTree(): void { if (unsupported) { elements.parseStatus.append(` · ${unsupported} omitted`) } - developmentElementCallouts?.reposition() requestAnimationFrame(updatePinnedSelection) } @@ -3593,7 +3592,6 @@ function renderDocumentsList(): void { if (incompatibleReviews.length) { elements.documentsListTree.append(renderIncompatibleReviews()) } - developmentElementCallouts?.reposition() } function applyLeftPaneWidth(): void { diff --git a/test/development-element.test.ts b/test/development-element.test.ts index c8fca534..e030ea81 100644 --- a/test/development-element.test.ts +++ b/test/development-element.test.ts @@ -173,6 +173,30 @@ test('pinned callouts can reposition after application layout changes', () => { ) }) +test('pinned callouts clear after rendered subtrees replace their target', async () => { + const dom = new JSDOM('
') + const { document } = dom.window + const section = document.querySelector('section') as HTMLElement + const button = document.querySelector('button') as HTMLButtonElement + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + callouts.handle({ + action: 'highlight', + reference: developmentElementReference(button, document), + requestId: 'element-callout-1' + }) + + section.replaceChildren(document.createElement('button')) + await new Promise((resolve) => { dom.window.setTimeout(resolve, 0) }) + + assert.equal( + document.querySelector('.development-element-callout')?.hidden, + true + ) +}) + test('element commands accept only canonical references and exact actions', () => { const dom = new JSDOM('') const reference = developmentElementReference( @@ -220,14 +244,6 @@ test('picker and service route are live-watch-only and documented', () => { renderer, /schedulePaneLayoutResizeUpdate[\s\S]*developmentElementCallouts\?\.reposition\(\)/ ) - assert.match( - renderer, - /function renderDocumentsList\(\)[\s\S]*replaceChildren\(\)[\s\S]*developmentElementCallouts\?\.reposition\(\)/ - ) - assert.match( - renderer, - /function renderTree\(\)[\s\S]*elements\.tree\.replaceChildren\(\)[\s\S]*developmentElementCallouts\?\.reposition\(\)/ - ) assert.match(docs, /Option-click any rendered element/) assert.match(docs, /--instance dev element highlight/) assert.match(docs, /--instance dev element clear/) From e2d11cc676a4c6edc0bcd329054cacab3d7ff02a Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 12:43:47 -0700 Subject: [PATCH 06/10] Clear hidden element callouts --- src/development-element.ts | 23 +++++++++++++--- test/development-element.test.ts | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/development-element.ts b/src/development-element.ts index bdc15ef3..131c2d88 100644 --- a/src/development-element.ts +++ b/src/development-element.ts @@ -367,6 +367,10 @@ export function installDevelopmentElementCallouts( return null } const bounds = roundedBounds(pinned) + if (bounds.width <= 0 || bounds.height <= 0) { + clear() + return null + } overlay.style.left = `${String(bounds.x)}px` overlay.style.top = `${String(bounds.y)}px` overlay.style.width = `${String(bounds.width)}px` @@ -375,10 +379,13 @@ export function installDevelopmentElementCallouts( overlay.hidden = false return bounds } - const pin = (element: Element, reference: string): DevelopmentElementBounds => { + const pin = ( + element: Element, + reference: string + ): DevelopmentElementBounds | null => { pinned = element pinnedReference = reference - return position() as DevelopmentElementBounds + return position() } const MutationObserver = document.defaultView?.MutationObserver const observer = MutationObserver @@ -390,6 +397,8 @@ export function installDevelopmentElementCallouts( }) : null observer?.observe(document.documentElement, { + attributeFilter: ['aria-hidden', 'class', 'hidden', 'open', 'style'], + attributes: true, childList: true, subtree: true }) @@ -433,8 +442,16 @@ export function installDevelopmentElementCallouts( status: resolved.status } } + const bounds = pin(resolved.element, reference) + if (!bounds) { + return { + reference, + requestId: command.requestId, + status: 'stale' + } + } return { - bounds: pin(resolved.element, reference), + bounds, reference: pinnedReference as string, requestId: command.requestId, status: 'highlighted' diff --git a/test/development-element.test.ts b/test/development-element.test.ts index e030ea81..48d0034d 100644 --- a/test/development-element.test.ts +++ b/test/development-element.test.ts @@ -178,6 +178,17 @@ test('pinned callouts clear after rendered subtrees replace their target', async const { document } = dom.window const section = document.querySelector('section') as HTMLElement const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: 40, + height: 30, + left: 10, + right: 110, + toJSON: () => ({}), + top: 10, + width: 100, + x: 10, + y: 10 + }) const callouts = installDevelopmentElementCallouts(document, { copyText() {}, notify() {} @@ -197,6 +208,41 @@ test('pinned callouts clear after rendered subtrees replace their target', async ) }) +test('pinned callouts clear when visibility changes hide their target', async () => { + const dom = new JSDOM('
') + const { document } = dom.window + const section = document.querySelector('section') as HTMLElement + const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: section.hidden ? 0 : 40, + height: section.hidden ? 0 : 30, + left: section.hidden ? 0 : 10, + right: section.hidden ? 0 : 110, + toJSON: () => ({}), + top: section.hidden ? 0 : 10, + width: section.hidden ? 0 : 100, + x: section.hidden ? 0 : 10, + y: section.hidden ? 0 : 10 + }) + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + callouts.handle({ + action: 'highlight', + reference: developmentElementReference(button, document), + requestId: 'element-callout-1' + }) + + section.hidden = true + await new Promise((resolve) => { dom.window.setTimeout(resolve, 0) }) + + assert.equal( + document.querySelector('.development-element-callout')?.hidden, + true + ) +}) + test('element commands accept only canonical references and exact actions', () => { const dom = new JSDOM('') const reference = developmentElementReference( From 4a4400e5eda6cda1037a7932f6b2094cbca86365 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 12:51:12 -0700 Subject: [PATCH 07/10] Bound development element references --- docs/developer/development.md | 10 +++--- src/development-element.ts | 26 +++++++++++---- test/development-element.test.ts | 56 +++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/docs/developer/development.md b/docs/developer/development.md index 84fec1a2..f9521f67 100644 --- a/docs/developer/development.md +++ b/docs/developer/development.md @@ -170,10 +170,12 @@ returning. ### Shared development element callouts -While the live development loop is running, Option-click any rendered element. -Markover pins a bright bounding box to that element and copies one opaque -`mko-ui-v1:` reference to the clipboard. Paste that reference into the agent -thread; no screenshot, DevTools inspection, or hand-drawn circle is needed. +While the live development loop is running, Option-click a rendered element +whose path from its nearest unique ID is at most 128 elements. Markover pins a +bright bounding box to that element and copies one opaque `mko-ui-v1:` +reference to the clipboard. Deeper targets report that they cannot be +referenced and copy nothing. Paste a copied reference into the agent thread; no +screenshot, DevTools inspection, or hand-drawn circle is needed. An agent highlights the same element in the addressed running instance with: diff --git a/src/development-element.ts b/src/development-element.ts index 131c2d88..a6ac3ffa 100644 --- a/src/development-element.ts +++ b/src/development-element.ts @@ -1,5 +1,5 @@ const REFERENCE_PREFIX = 'mko-ui-v1:' -const MAXIMUM_REFERENCE_LENGTH = 4096 +const MAXIMUM_REFERENCE_LENGTH = 32 * 1024 const MAXIMUM_ANCHOR_LENGTH = 512 const MAXIMUM_PATH_LENGTH = 128 const ELEMENT_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/ @@ -283,6 +283,9 @@ export function developmentElementReference( const path: DevelopmentElementPathSegment[] = [] let current = element while (current !== (anchor || document.documentElement)) { + if (path.length >= MAXIMUM_PATH_LENGTH) { + throw new Error('Development element path exceeds the supported depth.') + } path.unshift(segmentFor(current)) const parent = current.parentElement if (!parent) throw new Error('Development element path is incomplete.') @@ -293,7 +296,11 @@ export function developmentElementReference( path, version: 1 } - return `${REFERENCE_PREFIX}${encodeBase64Url(JSON.stringify(payload))}` + const reference = `${REFERENCE_PREFIX}${encodeBase64Url(JSON.stringify(payload))}` + if (!isDevelopmentElementReference(reference)) { + throw new Error('Development element reference exceeds the supported limits.') + } + return reference } export function resolveDevelopmentElementReference( @@ -417,10 +424,17 @@ export function installDevelopmentElementCallouts( if (overlay.contains(element)) return event.preventDefault() event.stopImmediatePropagation() - const reference = developmentElementReference(element, document) - pin(element, reference) - copyText(reference) - notify('Element reference copied') + try { + const reference = developmentElementReference(element, document) + if (!pin(element, reference)) { + throw new Error('Development element has no visible bounds.') + } + copyText(reference) + notify('Element reference copied') + } catch { + clear() + notify('Element cannot be referenced') + } }, true) document.defaultView?.addEventListener('resize', () => { position() }) document.addEventListener('scroll', () => { position() }, true) diff --git a/test/development-element.test.ts b/test/development-element.test.ts index 48d0034d..2a466d44 100644 --- a/test/development-element.test.ts +++ b/test/development-element.test.ts @@ -77,6 +77,60 @@ test('one Option-click pins an element and copies its stable reference', () => { assert.equal(overlay.hidden, true) }) +test('the picker copies only references inside its finite depth contract', () => { + const dom = new JSDOM('
') + const { document, MouseEvent } = dom.window + let parent = document.querySelector('main') as HTMLElement + for (let index = 0; index < 127; index += 1) { + const child = document.createElement('section') + parent.append(child) + parent = child + } + const supportedButton = document.createElement('button') + parent.append(supportedButton) + assert.equal( + isDevelopmentElementReference( + developmentElementReference(supportedButton, document) + ), + true + ) + supportedButton.remove() + for (let index = 0; index < 2; index += 1) { + const child = document.createElement('section') + parent.append(child) + parent = child + } + const button = document.createElement('button') + parent.append(button) + button.getBoundingClientRect = () => ({ + bottom: 40, + height: 30, + left: 10, + right: 110, + toJSON: () => ({}), + top: 10, + width: 100, + x: 10, + y: 10 + }) + const copied: string[] = [] + const notices: string[] = [] + installDevelopmentElementCallouts(document, { + copyText: (reference) => { copied.push(reference) }, + notify: (message) => { notices.push(message) } + }) + + button.dispatchEvent(new MouseEvent('click', { + altKey: true, + bubbles: true, + button: 0, + cancelable: true + })) + + assert.deepEqual(copied, []) + assert.deepEqual(notices, ['Element cannot be referenced']) +}) + test('references fail stale or ambiguous without selecting another element', () => { const dom = new JSDOM('
') const { document } = dom.window @@ -290,7 +344,7 @@ test('picker and service route are live-watch-only and documented', () => { renderer, /schedulePaneLayoutResizeUpdate[\s\S]*developmentElementCallouts\?\.reposition\(\)/ ) - assert.match(docs, /Option-click any rendered element/) + assert.match(docs, /Option-click a rendered element[\s\S]*at most 128 elements/) assert.match(docs, /--instance dev element highlight/) assert.match(docs, /--instance dev element clear/) }) From 1868ea5c73afacd1b5ef10b9df91a5c2ce2fb9e8 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 12:59:30 -0700 Subject: [PATCH 08/10] Pick disabled development controls --- src/development-element.ts | 22 +++++++++++------- test/development-element.test.ts | 40 +++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/development-element.ts b/src/development-element.ts index a6ac3ffa..614a6dd1 100644 --- a/src/development-element.ts +++ b/src/development-element.ts @@ -410,14 +410,15 @@ export function installDevelopmentElementCallouts( subtree: true }) - document.addEventListener('click', (event) => { - if ( - event.button !== 0 || - !event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey - ) return + const isPickerGesture = (event: MouseEvent): boolean => ( + event.button === 0 && + event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) + document.addEventListener('pointerdown', (event) => { + if (!isPickerGesture(event)) return const target = event.target as Node | null if (!target || target.nodeType !== 1) return const element = target as Element @@ -436,6 +437,11 @@ export function installDevelopmentElementCallouts( notify('Element cannot be referenced') } }, true) + document.addEventListener('click', (event) => { + if (!isPickerGesture(event)) return + event.preventDefault() + event.stopImmediatePropagation() + }, true) document.defaultView?.addEventListener('resize', () => { position() }) document.addEventListener('scroll', () => { position() }, true) diff --git a/test/development-element.test.ts b/test/development-element.test.ts index 2a466d44..e59eaeb3 100644 --- a/test/development-element.test.ts +++ b/test/development-element.test.ts @@ -39,6 +39,12 @@ test('one Option-click pins an element and copies its stable reference', () => { let ordinaryClicks = 0 button.addEventListener('click', () => { ordinaryClicks += 1 }) + button.dispatchEvent(new MouseEvent('pointerdown', { + altKey: true, + bubbles: true, + button: 0, + cancelable: true + })) button.dispatchEvent(new MouseEvent('click', { altKey: true, bubbles: true, @@ -77,6 +83,38 @@ test('one Option-click pins an element and copies its stable reference', () => { assert.equal(overlay.hidden, true) }) +test('pointer capture picks rendered disabled controls', () => { + const dom = new JSDOM('') + const { document, MouseEvent } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: 40, + height: 30, + left: 10, + right: 110, + toJSON: () => ({}), + top: 10, + width: 100, + x: 10, + y: 10 + }) + const copied: string[] = [] + installDevelopmentElementCallouts(document, { + copyText: (reference) => { copied.push(reference) }, + notify() {} + }) + + button.dispatchEvent(new MouseEvent('pointerdown', { + altKey: true, + bubbles: true, + button: 0, + cancelable: true + })) + + assert.equal(copied.length, 1) + assert.equal(isDevelopmentElementReference(copied[0]), true) +}) + test('the picker copies only references inside its finite depth contract', () => { const dom = new JSDOM('
') const { document, MouseEvent } = dom.window @@ -120,7 +158,7 @@ test('the picker copies only references inside its finite depth contract', () => notify: (message) => { notices.push(message) } }) - button.dispatchEvent(new MouseEvent('click', { + button.dispatchEvent(new MouseEvent('pointerdown', { altKey: true, bubbles: true, button: 0, From 8b08d39d8df88f4ffc73842eac46a356d6be028e Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 13:05:28 -0700 Subject: [PATCH 09/10] Track callouts across settings changes --- src/development-element.ts | 1 - test/development-element.test.ts | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/development-element.ts b/src/development-element.ts index 614a6dd1..9daf5bbf 100644 --- a/src/development-element.ts +++ b/src/development-element.ts @@ -404,7 +404,6 @@ export function installDevelopmentElementCallouts( }) : null observer?.observe(document.documentElement, { - attributeFilter: ['aria-hidden', 'class', 'hidden', 'open', 'style'], attributes: true, childList: true, subtree: true diff --git a/test/development-element.test.ts b/test/development-element.test.ts index e59eaeb3..401a8289 100644 --- a/test/development-element.test.ts +++ b/test/development-element.test.ts @@ -265,6 +265,42 @@ test('pinned callouts can reposition after application layout changes', () => { ) }) +test('pinned callouts follow layout-affecting renderer attributes', async () => { + const dom = new JSDOM('
') + const { document } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + let x = 10 + button.getBoundingClientRect = () => ({ + bottom: 60, + height: 40, + left: x, + right: x + 120, + toJSON: () => ({}), + top: 20, + width: 120, + x, + y: 20 + }) + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + callouts.handle({ + action: 'highlight', + reference: developmentElementReference(button, document), + requestId: 'element-callout-1' + }) + + x = 75 + document.documentElement.dataset.treeDensity = 'compact' + await new Promise((resolve) => { dom.window.setTimeout(resolve, 0) }) + + assert.equal( + document.querySelector('.development-element-callout')?.style.left, + '75px' + ) +}) + test('pinned callouts clear after rendered subtrees replace their target', async () => { const dom = new JSDOM('
') const { document } = dom.window From 580d8e1ee83fa308cfe4567eca16ae46b1cce68a Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 13:15:38 -0700 Subject: [PATCH 10/10] Clear CSS-hidden element callouts --- src/development-element.ts | 24 ++++++++++++++++++++- test/development-element.test.ts | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/development-element.ts b/src/development-element.ts index 9daf5bbf..c0655658 100644 --- a/src/development-element.ts +++ b/src/development-element.ts @@ -344,6 +344,24 @@ function roundedBounds(element: Element): DevelopmentElementBounds { } } +function hasRenderedVisibility(element: Element, document: Document): boolean { + const view = document.defaultView + if (!view) return true + let current: Element | null = element + while (current) { + const style = view.getComputedStyle(current) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.visibility === 'collapse' || + style.opacity === '0' || + style.getPropertyValue('content-visibility') === 'hidden' + ) return false + current = current.parentElement + } + return true +} + function calloutLabel(element: Element): string { return element.id ? `${element.localName}#${element.id}` : element.localName } @@ -369,7 +387,11 @@ export function installDevelopmentElementCallouts( overlay.hidden = true } const position = (): DevelopmentElementBounds | null => { - if (!pinned || !document.documentElement.contains(pinned)) { + if ( + !pinned || + !document.documentElement.contains(pinned) || + !hasRenderedVisibility(pinned, document) + ) { clear() return null } diff --git a/test/development-element.test.ts b/test/development-element.test.ts index 401a8289..9cd02dd7 100644 --- a/test/development-element.test.ts +++ b/test/development-element.test.ts @@ -371,6 +371,43 @@ test('pinned callouts clear when visibility changes hide their target', async () ) }) +test('pinned callouts clear when CSS visibility hides their target', async () => { + const dom = new JSDOM( + '
' + ) + const { document } = dom.window + const main = document.querySelector('main') as HTMLElement + const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: 40, + height: 30, + left: 10, + right: 110, + toJSON: () => ({}), + top: 10, + width: 100, + x: 10, + y: 10 + }) + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + callouts.handle({ + action: 'highlight', + reference: developmentElementReference(button, document), + requestId: 'element-callout-1' + }) + + main.classList.add('is-collapsed') + await new Promise((resolve) => { dom.window.setTimeout(resolve, 0) }) + + assert.equal( + document.querySelector('.development-element-callout')?.hidden, + true + ) +}) + test('element commands accept only canonical references and exact actions', () => { const dom = new JSDOM('') const reference = developmentElementReference(