From db3662cdcf6a2c92499659bf475d550597925dcd Mon Sep 17 00:00:00 2001 From: Vamil Gandhi <13998000+vamgan@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:07:40 -0400 Subject: [PATCH] feat(core): add region selection affordances --- packages/core/README.md | 25 +- packages/core/src/__tests__/capture.test.ts | 168 +++++++++++ packages/core/src/capture.ts | 302 +++++++++++++++++++- packages/core/src/index.ts | 3 + site/docs/api/core.md | 32 ++- site/docs/guide/context.md | 19 +- site/docs/guide/how-it-works.md | 3 +- site/docs/guide/index.md | 5 +- site/docs/guide/react.md | 19 +- site/www/index.html | 229 +++++++++++++-- 10 files changed, 768 insertions(+), 37 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 9839f4e..cc2ad27 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -45,7 +45,8 @@ ctx.destroy(); ## Region, Circle, and Lasso Capture Use `createAskableRegionCapture()` when the user should draw a page region, -circle an area, or lasso a freehand shape and send it as structured context. +square off a fixed-ratio area, circle an area, or lasso a freehand shape and +send it as structured context. ```ts import { @@ -61,10 +62,21 @@ const capture = createAskableRegionCapture(ctx, { shape: 'lasso', intent: 'explain this selected area', includeViewport: true, + selectionAffordance: { + label: 'Selected context', + className: 'my-selection-marker', + prompt: { + placeholder: 'Ask about this area...', + onSubmit(question, packet) { + sendToAgent({ question, context: packet }); + }, + }, + }, theme: { ...ASKABLE_REGION_CAPTURE_THEME, lassoStrokeWidth: 4, lassoGlowRadius: 12, + selectionAffordanceStroke: '#7c3aed', }, onCapture(packet) { sendToAgent(packet); @@ -75,12 +87,17 @@ capture.start(); ``` The packet uses `capture.mode` of `region`, `circle`, or `lasso`, marks consent -as explicit, and includes the selected geometry in `target.bounds`. Lasso -captures also include `target.metadata.points` for the freehand path. +as explicit, and includes the selected geometry in `target.bounds`. Square +captures use `capture.mode: 'region'` with `target.metadata.shape: 'square'`. +Lasso captures also include `target.metadata.points` for the freehand path. The built-in lasso overlay uses `ASKABLE_REGION_CAPTURE_THEME` by default; pass `theme` to override any overlay, selection, or lasso style for your app. +Set `selectionAffordance` to keep the selected shape visible after capture and, +optionally, render a small prompt anchored to the selected area. The affordance +accepts class names, inline style hooks, and a custom `render()` escape hatch. Set `once: false` to keep the overlay mounted for repeated captures. The handle -reports active until `cancel()` or `destroy()` runs. +reports active until `cancel()` or `destroy()` runs, and `clearSelection()` +removes only the persisted selected-state UI. ## Text Selection Capture diff --git a/packages/core/src/__tests__/capture.test.ts b/packages/core/src/__tests__/capture.test.ts index 34f4cca..2b96784 100644 --- a/packages/core/src/__tests__/capture.test.ts +++ b/packages/core/src/__tests__/capture.test.ts @@ -15,6 +15,7 @@ function pointerEvent(type: string, x: number, y: number): PointerEvent { afterEach(() => { document.getElementById('askable-region-capture')?.remove(); + document.getElementById('askable-region-selection-affordance')?.remove(); }); describe('createAskableRegionCapture', () => { @@ -105,6 +106,66 @@ describe('createAskableRegionCapture', () => { ctx.destroy(); }); + it('captures a square as constrained region geometry', () => { + const ctx = createAskableContext(); + const onCapture = vi.fn(); + const capture = createAskableRegionCapture(ctx, { + shape: 'square', + onCapture, + }); + + capture.start(); + + const overlay = document.getElementById('askable-region-capture')!; + overlay.dispatchEvent(pointerEvent('pointerdown', 50, 60)); + overlay.dispatchEvent(pointerEvent('pointermove', 90, 140)); + overlay.dispatchEvent(pointerEvent('pointerup', 90, 140)); + + const [packet, selection] = onCapture.mock.calls[0]; + expect(selection).toMatchObject({ + shape: 'square', + bounds: { x: 50, y: 60, width: 80, height: 80 }, + }); + expect(packet.capture).toMatchObject({ + mode: 'region', + gesture: 'drag', + }); + expect(packet.target).toMatchObject({ + bounds: { x: 50, y: 60, width: 80, height: 80 }, + metadata: { + shape: 'square', + }, + }); + + capture.destroy(); + ctx.destroy(); + }); + + it('captures a square dragged up and left', () => { + const ctx = createAskableContext(); + const onCapture = vi.fn(); + const capture = createAskableRegionCapture(ctx, { + shape: 'square', + onCapture, + }); + + capture.start(); + + const overlay = document.getElementById('askable-region-capture')!; + overlay.dispatchEvent(pointerEvent('pointerdown', 90, 140)); + overlay.dispatchEvent(pointerEvent('pointermove', 50, 60)); + overlay.dispatchEvent(pointerEvent('pointerup', 50, 60)); + + const [, selection] = onCapture.mock.calls[0]; + expect(selection).toMatchObject({ + shape: 'square', + bounds: { x: 10, y: 60, width: 80, height: 80 }, + }); + + capture.destroy(); + ctx.destroy(); + }); + it('captures a lasso with point metadata', () => { const ctx = createAskableContext(); const onCapture = vi.fn(); @@ -317,4 +378,111 @@ describe('createAskableRegionCapture', () => { ctx.destroy(); }); + + it('can persist a selected region affordance after capture', () => { + const ctx = createAskableContext(); + const capture = createAskableRegionCapture(ctx, { + selectionAffordance: { + label: 'Selected area', + className: 'custom-affordance', + style: { opacity: '0.92' }, + }, + }); + + capture.start(); + + const overlay = document.getElementById('askable-region-capture')!; + overlay.dispatchEvent(pointerEvent('pointerdown', 10, 20)); + overlay.dispatchEvent(pointerEvent('pointermove', 80, 90)); + overlay.dispatchEvent(pointerEvent('pointerup', 80, 90)); + + const affordance = document.getElementById('askable-region-selection-affordance')!; + expect(document.getElementById('askable-region-capture')).toBeNull(); + expect(affordance).toBeInstanceOf(HTMLElement); + expect(affordance.getAttribute('data-askable-region-selection-affordance')).toBe('region'); + expect(affordance.className).toBe('custom-affordance'); + expect(affordance.style.left).toBe('10px'); + expect(affordance.style.top).toBe('20px'); + expect(affordance.style.width).toBe('70px'); + expect(affordance.style.height).toBe('70px'); + expect(affordance.style.opacity).toBe('0.92'); + expect(affordance.textContent).toContain('Selected area'); + + capture.clearSelection(); + expect(document.getElementById('askable-region-selection-affordance')).toBeNull(); + + capture.destroy(); + ctx.destroy(); + }); + + it('renders an anchored prompt and calls onSubmit with the captured packet', () => { + const ctx = createAskableContext(); + const onSubmit = vi.fn(); + const capture = createAskableRegionCapture(ctx, { + source: { app: 'dashboard' }, + selectionAffordance: { + prompt: { + placeholder: 'Ask here', + submitLabel: 'Send question', + onSubmit, + }, + }, + }); + + capture.start(); + + const overlay = document.getElementById('askable-region-capture')!; + overlay.dispatchEvent(pointerEvent('pointerdown', 10, 20)); + overlay.dispatchEvent(pointerEvent('pointermove', 80, 90)); + overlay.dispatchEvent(pointerEvent('pointerup', 80, 90)); + + const affordance = document.getElementById('askable-region-selection-affordance')!; + const input = affordance.querySelector('input')!; + const button = affordance.querySelector('button')!; + input.value = 'What changed here?'; + button.click(); + + expect(input.placeholder).toBe('Ask here'); + expect(button.getAttribute('aria-label')).toBe('Send question'); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toBe('What changed here?'); + expect(onSubmit.mock.calls[0][1]).toMatchObject({ + source: { app: 'dashboard' }, + capture: { mode: 'region' }, + }); + expect(onSubmit.mock.calls[0][2]).toMatchObject({ + bounds: { x: 10, y: 20, width: 70, height: 70 }, + }); + expect(input.value).toBe(''); + + capture.destroy(); + ctx.destroy(); + }); + + it('can persist lasso affordance geometry', () => { + const ctx = createAskableContext(); + const capture = createAskableRegionCapture(ctx, { + shape: 'lasso', + selectionAffordance: true, + }); + + capture.start(); + + const overlay = document.getElementById('askable-region-capture')!; + overlay.dispatchEvent(pointerEvent('pointerdown', 10, 20)); + overlay.dispatchEvent(pointerEvent('pointermove', 30, 45)); + overlay.dispatchEvent(pointerEvent('pointermove', 70, 35)); + overlay.dispatchEvent(pointerEvent('pointerup', 80, 75)); + + const affordance = document.getElementById('askable-region-selection-affordance')!; + const path = affordance.querySelector('path')!; + expect(affordance.getAttribute('data-askable-region-selection-affordance')).toBe('lasso'); + expect(affordance.style.left).toBe('10px'); + expect(affordance.style.top).toBe('20px'); + expect(path.getAttribute('d')).toBe('M0 0 L20 25 L60 15 L70 55'); + + capture.destroy(); + expect(document.getElementById('askable-region-selection-affordance')).toBeNull(); + ctx.destroy(); + }); }); diff --git a/packages/core/src/capture.ts b/packages/core/src/capture.ts index ef493ee..1a9ff75 100644 --- a/packages/core/src/capture.ts +++ b/packages/core/src/capture.ts @@ -9,7 +9,7 @@ import type { AskableContextPacketOptions, } from './types.js'; -export type AskableRegionCaptureShape = 'region' | 'circle' | 'lasso'; +export type AskableRegionCaptureShape = 'region' | 'square' | 'circle' | 'lasso'; export interface AskableRegionCaptureSelection { shape: AskableRegionCaptureShape; @@ -27,6 +27,55 @@ export interface AskableRegionCaptureGradientStop { color: string; } +export type AskableRegionCaptureStyle = Partial; + +export interface AskableRegionCapturePromptOptions { + /** Placeholder shown in the anchored prompt input. */ + placeholder?: string; + /** Accessible label/title for the submit button. */ + submitLabel?: string; + /** Class added to the prompt container. */ + className?: string; + /** Inline styles applied to the prompt container. */ + style?: AskableRegionCaptureStyle; + /** Class added to the prompt input. */ + inputClassName?: string; + /** Inline styles applied to the prompt input. */ + inputStyle?: AskableRegionCaptureStyle; + /** Class added to the prompt submit button. */ + buttonClassName?: string; + /** Inline styles applied to the prompt submit button. */ + buttonStyle?: AskableRegionCaptureStyle; + /** Called when the user submits a non-empty prompt from the selected area. */ + onSubmit?: ( + question: string, + packet: WebContextPacket, + selection: AskableRegionCaptureSelection, + ) => void; +} + +export interface AskableRegionCaptureSelectionAffordanceOptions { + /** Keep the selected shape visible after capture. Defaults to true when enabled. */ + persist?: boolean; + /** Render a compact prompt input anchored to the selected shape. Defaults to false. */ + prompt?: boolean | AskableRegionCapturePromptOptions; + /** Optional label shown beside the selected area. */ + label?: string; + /** Class added to the selected-area affordance root. */ + className?: string; + /** Inline styles applied to the selected-area affordance root. */ + style?: AskableRegionCaptureStyle; + /** Class added to the selected-area label. */ + labelClassName?: string; + /** Inline styles applied to the selected-area label. */ + labelStyle?: AskableRegionCaptureStyle; + /** Replace the built-in selected-area affordance with consumer-rendered DOM. */ + render?: ( + packet: WebContextPacket, + selection: AskableRegionCaptureSelection, + ) => HTMLElement | null | undefined | void; +} + export interface AskableRegionCaptureTheme { /** Full-page overlay color while a capture tool is active. */ overlayBackground: string; @@ -44,6 +93,20 @@ export interface AskableRegionCaptureTheme { lassoGlowColor: string; /** Lasso glow radius in CSS pixels. */ lassoGlowRadius: number; + /** Border color for persisted selected-region affordances. */ + selectionAffordanceStroke: string; + /** Fill color for persisted selected-region affordances. */ + selectionAffordanceFill: string; + /** Shadow for persisted selected-region affordances. */ + selectionAffordanceShadow: string; + /** Background color for the anchored prompt input. */ + promptBackground: string; + /** Border color for the anchored prompt input. */ + promptBorder: string; + /** Text color for the anchored prompt input. */ + promptText: string; + /** Accent color for the anchored prompt submit button. */ + promptAccent: string; } export interface AskableRegionCaptureOptions extends Omit { @@ -55,6 +118,8 @@ export interface AskableRegionCaptureOptions extends Omit; + /** Opt-in selected-state UI shown after capture, optionally with an anchored prompt. */ + selectionAffordance?: boolean | AskableRegionCaptureSelectionAffordanceOptions; /** Called after a region/circle/lasso is accepted and serialized to a Context packet. */ onCapture?: (packet: WebContextPacket, selection: AskableRegionCaptureSelection) => void; /** Called when an active capture is cancelled. */ @@ -64,6 +129,7 @@ export interface AskableRegionCaptureOptions extends Omit undefined, cancel: () => undefined, + clearSelection: () => undefined, destroy: () => undefined, isActive: () => false, }; @@ -139,6 +216,11 @@ export function createAskableRegionCapture( const minSize = options.minSize ?? 6; const once = options.once ?? true; const theme = resolveRegionCaptureTheme(options.theme); + const selectionAffordance = resolveSelectionAffordance(options.selectionAffordance); + + const removeAffordance = () => { + document.getElementById(AFFORDANCE_ID)?.remove(); + }; const removeOverlay = () => { overlay?.removeEventListener('pointerdown', onPointerDown); @@ -159,6 +241,7 @@ export function createAskableRegionCapture( const cancel = () => { const wasActive = Boolean(overlay); removeOverlay(); + removeAffordance(); if (wasActive) options.onCancel?.(); }; @@ -336,6 +419,7 @@ export function createAskableRegionCapture( if (lassoSvg) lassoSvg.style.display = 'none'; } + renderSelectionAffordance(packet, selection); options.onCapture?.(packet, selection); } @@ -352,9 +436,178 @@ export function createAskableRegionCapture( ensureOverlay(); }, cancel, - destroy: removeOverlay, + clearSelection: removeAffordance, + destroy() { + removeOverlay(); + removeAffordance(); + }, isActive: () => Boolean(overlay), }; + + function renderSelectionAffordance(packet: WebContextPacket, selection: AskableRegionCaptureSelection) { + if (!selectionAffordance || selectionAffordance.persist === false) return; + removeAffordance(); + + const custom = selectionAffordance.render?.(packet, selection); + if (custom instanceof HTMLElement) { + custom.id = custom.id || AFFORDANCE_ID; + custom.setAttribute(AFFORDANCE_ATTR, selection.shape); + document.body.appendChild(custom); + return; + } + + const root = document.createElement('div'); + root.id = AFFORDANCE_ID; + root.setAttribute(AFFORDANCE_ATTR, selection.shape); + if (selectionAffordance.className) root.className = selectionAffordance.className; + root.style.cssText = [ + 'position:fixed', + `left:${selection.bounds.x}px`, + `top:${selection.bounds.y}px`, + `width:${Math.max(1, selection.bounds.width)}px`, + `height:${Math.max(1, selection.bounds.height)}px`, + 'z-index:2147483646', + 'pointer-events:none', + 'box-sizing:border-box', + ].join(';'); + assignStyles(root, selectionAffordance.style); + + root.appendChild(createSelectionMarker(selection)); + if (selectionAffordance.label !== '') { + root.appendChild(createSelectionLabel(selectionAffordance.label ?? `${selection.shape} context`)); + } + + const prompt = resolvePromptOptions(selectionAffordance.prompt); + if (prompt) root.appendChild(createPrompt(prompt, packet, selection)); + + document.body.appendChild(root); + } + + function createSelectionMarker(selection: AskableRegionCaptureSelection): HTMLElement | SVGSVGElement { + if (selection.shape === 'lasso' && selection.points?.length) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', `0 0 ${Math.max(1, selection.bounds.width)} ${Math.max(1, selection.bounds.height)}`); + svg.style.cssText = [ + 'position:absolute', + 'inset:0', + 'overflow:visible', + 'pointer-events:none', + ].join(';'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pointsToPath(selection.points, selection.bounds)); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', theme.selectionAffordanceStroke); + path.setAttribute('stroke-width', String(theme.lassoStrokeWidth)); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.style.filter = `drop-shadow(0 0 ${theme.lassoGlowRadius}px ${theme.lassoGlowColor})`; + svg.appendChild(path); + return svg; + } + + const marker = document.createElement('div'); + marker.style.cssText = [ + 'position:absolute', + 'inset:0', + 'box-sizing:border-box', + `border:2px solid ${theme.selectionAffordanceStroke}`, + `background:${theme.selectionAffordanceFill}`, + `box-shadow:${theme.selectionAffordanceShadow}`, + 'pointer-events:none', + ].join(';'); + if (selection.shape === 'circle') marker.style.borderRadius = '9999px'; + return marker; + } + + function createSelectionLabel(label: string): HTMLSpanElement { + const el = document.createElement('span'); + el.textContent = label; + if (selectionAffordance?.labelClassName) el.className = selectionAffordance.labelClassName; + el.style.cssText = [ + 'position:absolute', + 'left:0', + 'bottom:calc(100% + 6px)', + 'padding:4px 8px', + 'border-radius:999px', + `background:${theme.promptBackground}`, + `border:1px solid ${theme.promptBorder}`, + `color:${theme.promptText}`, + 'font:600 12px/1.2 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + 'box-shadow:0 8px 20px rgba(15,23,42,0.12)', + 'white-space:nowrap', + 'pointer-events:none', + ].join(';'); + assignStyles(el, selectionAffordance?.labelStyle); + return el; + } + + function createPrompt( + prompt: AskableRegionCapturePromptOptions, + packet: WebContextPacket, + selection: AskableRegionCaptureSelection, + ): HTMLFormElement { + const form = document.createElement('form'); + if (prompt.className) form.className = prompt.className; + const placeAbove = selection.bounds.y + selection.bounds.height + 56 > window.innerHeight; + form.style.cssText = [ + 'position:absolute', + 'left:0', + placeAbove ? 'bottom:calc(100% + 10px)' : 'top:calc(100% + 10px)', + 'display:flex', + 'align-items:center', + 'gap:6px', + 'min-width:220px', + 'max-width:min(320px, calc(100vw - 24px))', + 'padding:6px', + 'border-radius:999px', + `background:${theme.promptBackground}`, + `border:1px solid ${theme.promptBorder}`, + 'box-shadow:0 14px 34px rgba(15,23,42,0.14)', + 'pointer-events:auto', + ].join(';'); + assignStyles(form, prompt.style); + + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = prompt.placeholder ?? 'Ask about this selection...'; + if (prompt.inputClassName) input.className = prompt.inputClassName; + input.style.cssText = [ + 'min-width:0', + 'flex:1', + 'border:0', + 'outline:0', + 'background:transparent', + `color:${theme.promptText}`, + 'font:500 13px/1.2 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + ].join(';'); + assignStyles(input, prompt.inputStyle); + + const button = document.createElement('button'); + button.type = 'submit'; + button.textContent = 'Ask'; + button.setAttribute('aria-label', prompt.submitLabel ?? 'Ask about selected context'); + if (prompt.buttonClassName) button.className = prompt.buttonClassName; + button.style.cssText = [ + 'border:0', + 'border-radius:999px', + 'padding:6px 10px', + `background:${theme.promptAccent}`, + 'color:#fff', + 'font:700 12px/1.2 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + 'cursor:pointer', + ].join(';'); + assignStyles(button, prompt.buttonStyle); + + form.append(input, button); + form.addEventListener('submit', (event) => { + event.preventDefault(); + const question = input.value.trim(); + if (!question) return; + prompt.onSubmit?.(question, packet, selection); + input.value = ''; + }); + return form; + } } function resolveRegionCaptureTheme(theme?: Partial): AskableRegionCaptureTheme { @@ -365,6 +618,37 @@ function resolveRegionCaptureTheme(theme?: Partial): }; } +function resolveSelectionAffordance( + affordance?: boolean | AskableRegionCaptureSelectionAffordanceOptions, +): AskableRegionCaptureSelectionAffordanceOptions | null { + if (!affordance) return null; + if (affordance === true) return { persist: true }; + return { persist: affordance.persist ?? true, ...affordance }; +} + +function resolvePromptOptions( + prompt?: boolean | AskableRegionCapturePromptOptions, +): AskableRegionCapturePromptOptions | null { + if (!prompt) return null; + if (prompt === true) return {}; + return prompt; +} + +function assignStyles(element: HTMLElement | SVGElement, styles?: AskableRegionCaptureStyle): void { + if (!styles) return; + Object.assign(element.style, styles); +} + +function pointsToPath(points: Point[], bounds: WebContextRect): string { + return points + .map((point, index) => { + const x = Math.round(point.x - bounds.x); + const y = Math.round(point.y - bounds.y); + return `${index === 0 ? 'M' : 'L'}${x} ${y}`; + }) + .join(' '); +} + function pointFromEvent(event: PointerEvent): Point { return { x: event.clientX, y: event.clientY }; } @@ -384,6 +668,16 @@ function boundsForShape(shape: AskableRegionCaptureShape, start: Point, end: Poi }; } + if (shape === 'square') { + const size = Math.max(Math.abs(end.x - start.x), Math.abs(end.y - start.y)); + return { + x: end.x < start.x ? start.x - size : start.x, + y: end.y < start.y ? start.y - size : start.y, + width: size, + height: size, + }; + } + return { x: Math.min(start.x, end.x), y: Math.min(start.y, end.y), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cf1c676..bb89f28 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,8 +35,11 @@ export type { AskableRegionCaptureGradientStop, AskableRegionCaptureOptions, AskableRegionCapturePoint, + AskableRegionCapturePromptOptions, AskableRegionCaptureSelection, + AskableRegionCaptureSelectionAffordanceOptions, AskableRegionCaptureShape, + AskableRegionCaptureStyle, AskableRegionCaptureTheme, } from './capture.js'; export type { diff --git a/site/docs/api/core.md b/site/docs/api/core.md index 9711dc2..be31dd0 100644 --- a/site/docs/api/core.md +++ b/site/docs/api/core.md @@ -713,8 +713,9 @@ ctx.destroy(); ## `createAskableRegionCapture(ctx, options?)` -Mounts a temporary browser overlay that lets the user drag a rectangle, circle, -or lasso, then emits a structured Context packet with explicit consent metadata. +Mounts a temporary browser overlay that lets the user drag a rectangle, square, +circle, or lasso, then emits a structured Context packet with explicit consent +metadata. ```ts import { @@ -730,6 +731,15 @@ const capture = createAskableRegionCapture(ctx, { shape: 'lasso', intent: 'explain this selected area', includeViewport: true, + selectionAffordance: { + label: 'Selected context', + prompt: { + placeholder: 'Ask about this area...', + onSubmit(question, packet) { + sendToAgent({ question, context: packet }); + }, + }, + }, theme: { ...ASKABLE_REGION_CAPTURE_THEME, lassoStrokeWidth: 4, @@ -752,24 +762,36 @@ capture.start(); | Option | Type | Default | Description | |---|---|---|---| -| `shape` | `'region' \| 'circle' \| 'lasso'` | `'region'` | Shape produced by the drag gesture | +| `shape` | `'region' \| 'square' \| 'circle' \| 'lasso'` | `'region'` | Shape produced by the drag gesture | | `minSize` | `number` | `6` | Minimum accepted width/height in CSS pixels | | `once` | `boolean` | `true` | Remove the overlay after the first accepted capture | | `theme` | `Partial` | `ASKABLE_REGION_CAPTURE_THEME` | Overlay colors, selection fill/stroke, and lasso gradient/glow styling | +| `selectionAffordance` | `boolean \| AskableRegionCaptureSelectionAffordanceOptions` | `false` | Keep selected geometry visible after capture, optionally with an anchored prompt | | `onCapture` | `(packet, selection) => void` | — | Called with the Context packet and selection geometry | | `onCancel` | `() => void` | — | Called when the capture is cancelled | | _...most `AskableContextPacketOptions`_ | | | Passed through to `toContextPacket()` | The default lasso theme is exported as `ASKABLE_REGION_CAPTURE_THEME`. Use `theme` when your app needs brand-specific capture styling without replacing the -library overlay. +library overlay. The same theme controls persisted selected-state defaults such +as `selectionAffordanceStroke`, `selectionAffordanceFill`, and prompt colors. + +`selectionAffordance` is opt-in. Pass `true` to keep the selected shape visible +after capture, or pass an object with `className`, `style`, `label`, `prompt`, +and `render()` hooks. `prompt.onSubmit(question, packet, selection)` is useful +when the selected area should immediately become the anchor for a follow-up chat +question. + +Square captures are constrained to equal width and height. They serialize with +`capture.mode: 'region'` and `target.metadata.shape: 'square'` so existing +region consumers keep working. Set `once: false` for persistent tools in production dashboards, canvases, and editors. The overlay stays mounted after each accepted capture, and `isActive()` remains `true` until `cancel()` or `destroy()` is called. **Returns:** `AskableRegionCaptureHandle` — object with `start()`, `cancel()`, -`destroy()`, and `isActive()` methods. +`clearSelection()`, `destroy()`, and `isActive()` methods. --- diff --git a/site/docs/guide/context.md b/site/docs/guide/context.md index 04850a8..89288c1 100644 --- a/site/docs/guide/context.md +++ b/site/docs/guide/context.md @@ -141,7 +141,7 @@ ctx.toContextPacket({ }); ``` -## Region, circle, and lasso capture +## Region, square, circle, and lasso capture For "send this part of the page" interactions, `@askable-ui/core` can mount a temporary drag overlay and emit a packet with selected geometry: @@ -156,8 +156,18 @@ const capture = createAskableRegionCapture(ctx, { shape: 'lasso', intent: 'explain this selected chart segment', includeViewport: true, + selectionAffordance: { + label: 'Selected context', + prompt: { + placeholder: 'Ask about this area...', + onSubmit(question, packet) { + sendToAgent({ question, context: packet }); + }, + }, + }, theme: { lassoStrokeWidth: 4, + selectionAffordanceStroke: '#7c3aed', }, onCapture: (packet) => { sendToAgent(packet); @@ -169,10 +179,13 @@ capture.start(); The resulting packet uses `capture.mode` of `region`, `circle`, or `lasso`, sets `privacy.consent` to `explicit`, and places the selected bounds on -`target.bounds`. Lasso packets also include the freehand path in +`target.bounds`. Square captures serialize as `region` mode with +`target.metadata.shape: 'square'`. Lasso packets also include the freehand path in `target.metadata.points`. The default lasso overlay uses `ASKABLE_REGION_CAPTURE_THEME`; pass `theme` to adjust overlay colors, -selection fill/stroke, or lasso line styling. +selection fill/stroke, lasso line styling, or selected-state defaults. Set +`selectionAffordance` when the selected area should stay visible after capture +or expose a small question input anchored to the selected geometry. Framework apps can use wrapper APIs instead: diff --git a/site/docs/guide/how-it-works.md b/site/docs/guide/how-it-works.md index c75c991..fe6a1a7 100644 --- a/site/docs/guide/how-it-works.md +++ b/site/docs/guide/how-it-works.md @@ -65,11 +65,12 @@ Some user intent is not tied to one DOM element. Askable includes explicit captu | `ctx.select(element)` | Ask AI buttons and known widgets | `element-focus` | | `ctx.push(meta, text)` | App events, command palettes, generated summaries | `semantic` | | `createAskableRegionCapture(ctx)` | Dragging a rectangular page area | `region` | +| `createAskableRegionCapture(ctx, { shape: 'square' })` | Selecting a fixed-ratio visual area | `region` | | `createAskableRegionCapture(ctx, { shape: 'circle' })` | Circling one object or anomaly | `circle` | | `createAskableRegionCapture(ctx, { shape: 'lasso' })` | Freehand irregular selections | `lasso` | | `createAskableTextSelectionCapture(ctx)` | Browser-highlighted copy | `text-selection` | -Region, circle, lasso, and text capture mark `privacy.consent` as `explicit` because the user intentionally selected the context. +Region, square, circle, lasso, and text capture mark `privacy.consent` as `explicit` because the user intentionally selected the context. ## Singleton vs. scoped contexts diff --git a/site/docs/guide/index.md b/site/docs/guide/index.md index b13df54..40170fd 100644 --- a/site/docs/guide/index.md +++ b/site/docs/guide/index.md @@ -54,9 +54,12 @@ Start simple with annotated elements: Then add explicit user-marking tools where the UI needs more precision: ```ts -const region = createAskableRegionCapture(ctx, { shape: 'lasso' }); +const region = createAskableRegionCapture(ctx, { shape: 'square' }); region.start(); +const lasso = createAskableRegionCapture(ctx, { shape: 'lasso' }); +lasso.start(); + const text = createAskableTextSelectionCapture(ctx); text.captureNow(); ``` diff --git a/site/docs/guide/react.md b/site/docs/guide/react.md index c9361b5..6c18d83 100644 --- a/site/docs/guide/react.md +++ b/site/docs/guide/react.md @@ -165,6 +165,15 @@ function RegionTools() { const capture = useAskableRegionCapture({ ctx, includeViewport: true, + selectionAffordance: { + label: 'Selected context', + prompt: { + placeholder: 'Ask about this area...', + onSubmit(question, packet) { + sendToAgent({ question, context: packet }); + }, + }, + }, theme: { lassoStrokeWidth: 4, lassoGlowRadius: 12, @@ -179,6 +188,9 @@ function RegionTools() { + @@ -191,11 +203,14 @@ function RegionTools() { } ``` -Region packets use `capture.mode: 'region'`; circle packets use +Region and square packets use `capture.mode: 'region'`; square packets also set +`target.metadata.shape: 'square'`. Circle packets use `capture.mode: 'circle'` and include center/radius metadata. Lasso packets use `capture.mode: 'lasso'` and include freehand path points. The default lasso overlay uses the core `ASKABLE_REGION_CAPTURE_THEME`; pass -`theme` when you need brand-specific overlay colors or line styling. +`theme` when you need brand-specific overlay colors, line styling, or +selected-state defaults. Use `selectionAffordance` to keep the selected area +visible after capture and optionally attach a small prompt input to it. ## Text selection capture diff --git a/site/www/index.html b/site/www/index.html index 04d1a2e..0be08d6 100644 --- a/site/www/index.html +++ b/site/www/index.html @@ -429,6 +429,59 @@ stroke-linejoin: round; filter: blur(1px); } + .selected-region { + position: absolute; z-index: 12; pointer-events: none; + border: 2px solid rgba(124,58,237,0.78); + background: rgba(124,58,237,0.08); + box-shadow: 0 12px 34px rgba(91,33,182,0.16), 0 0 0 4px rgba(124,58,237,0.05); + animation: selectedRegionIn .18s ease-out; + } + .selected-region.circle { border-radius: 999px; } + .selected-region.lasso { inset: 0; border: none; background: transparent; box-shadow: none; } + .selected-region svg { position: absolute; inset: 0; overflow: visible; } + .selected-region path { + fill: none; + stroke: rgba(124,58,237,0.9); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 0 7px rgba(124,58,237,0.24)); + } + .selected-region-label { + position: absolute; left: 0; bottom: calc(100% + 7px); + display: inline-flex; align-items: center; gap: .35rem; + padding: .28rem .52rem; border-radius: var(--radius-pill); + border: 1px solid rgba(124,58,237,0.18); background: rgba(255,255,255,0.94); + color: #5b21b6; font-size: .66rem; font-weight: 850; + letter-spacing: .05em; text-transform: uppercase; + box-shadow: 0 10px 24px rgba(15,23,42,0.1); white-space: nowrap; + } + .selected-region-label::before { + content: ''; width: .42rem; height: .42rem; border-radius: 999px; + background: #7c3aed; box-shadow: 0 0 0 4px rgba(124,58,237,0.1); + } + .selection-inline-prompt { + position: absolute; left: 0; top: calc(100% + 9px); z-index: 13; + display: flex; align-items: center; gap: .42rem; + width: min(310px, calc(100vw - 3rem)); padding: .38rem; + border: 1px solid rgba(124,58,237,0.18); border-radius: var(--radius-pill); + background: rgba(255,255,255,0.96); box-shadow: 0 16px 42px rgba(15,23,42,0.15); + pointer-events: auto; + } + .selection-inline-prompt.above { top: auto; bottom: calc(100% + 9px); } + .selection-inline-prompt input { + min-width: 0; flex: 1; border: none; outline: none; background: transparent; + color: var(--ink); font: 500 .78rem/1.2 var(--font); padding: .26rem .32rem; + } + .selection-inline-prompt button { + flex: 0 0 auto; border: none; border-radius: var(--radius-pill); + background: var(--ink); color: #fff; padding: .36rem .62rem; + font-size: .7rem; font-weight: 800; cursor: pointer; + } + @keyframes selectedRegionIn { + from { opacity: 0; transform: scale(.985); } + to { opacity: 1; transform: scale(1); } + } .ai-cursor-canvas { position: absolute; inset: 0; z-index: 10; width: 100%; height: 100%; @@ -547,11 +600,20 @@ border: 1px solid rgba(124,58,237,0.18); background: linear-gradient(180deg, rgba(124,58,237,0.08), rgba(255,255,255,0.88)); } + .selected-region-card { + margin: .72rem .78rem 0; padding: .72rem .78rem; border-radius: 14px; + border: 1px solid rgba(79,70,229,0.16); + background: linear-gradient(180deg, rgba(79,70,229,0.075), rgba(255,255,255,0.9)); + } .selected-text-card-head { display: flex; align-items: center; justify-content: space-between; gap: .6rem; margin-bottom: .44rem; } .selected-text-card-title { display: flex; align-items: center; gap: .42rem; color: #5b21b6; font-size: .7rem; font-weight: 850; letter-spacing: .05em; text-transform: uppercase; } .selected-text-card-title::before { content: '“'; display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 999px; background: #6d28d9; color: #fff; font-size: .9rem; line-height: 1; } .selected-text-card-state { color: var(--ink-3); font-size: .64rem; font-weight: 700; } .selected-text-quote { margin: 0; color: var(--ink); font-size: .78rem; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } + .selected-region-card .selected-text-card-title { color: #3730a3; } + .selected-region-card .selected-text-card-title::before { content: ''; background: #4f46e5; box-shadow: inset 0 0 0 5px rgba(255,255,255,0.25); } + .selected-region-list { margin: 0; color: var(--ink-2); font-size: .76rem; line-height: 1.48; display: grid; gap: .22rem; } + .selected-region-list span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .dashboard ::selection { background: rgba(124,58,237,0.24); color: var(--ink); } .text-capture-mark { position: absolute; pointer-events: none; z-index: 6; border-radius: 7px; @@ -736,6 +798,7 @@

Your model knows what the user means.

Click Ask AI button Region + Square Circle Lasso Selected text @@ -831,7 +894,7 @@

Not every question starts with a click.

Context becomes an intentional user action.

-

Use DOM focus for the common path, then add explicit tools for visual ambiguity: draw over a chart, circle an outlier, lasso an irregular area, or send the text the user highlighted.

+

Use DOM focus for the common path, then add explicit tools for visual ambiguity: draw over a chart, square off a fixed area, circle an outlier, lasso an irregular area, or send the text the user highlighted.

region.start({ shape: 'lasso' });
 text.captureNow();
@@ -848,6 +911,11 @@ 

Element focus

Region capture

Drag a rectangular page area when the answer depends on a visible section.

+
+
+

Square capture

+

Constrain the selected area when the target should keep an equal width and height.

+

Circle capture

@@ -921,6 +989,10 @@

Select the UI. Watch the prompt tighten.

Regionboxed area + '; + form.addEventListener('pointerdown', function(e) { e.stopPropagation(); }); + form.addEventListener('click', function(e) { e.stopPropagation(); }); + form.addEventListener('submit', function(e) { + e.preventDefault(); + var input = form.querySelector('input'); + var value = input ? input.value.trim() : ''; + if (!value) return; + chatInput.value = value; + send(); + }); + return form; + } + function clearSelectedRegionPreview() { + if (selectedRegionEl) { selectedRegionEl.remove(); selectedRegionEl = null; } + if (selectedRegionCard) selectedRegionCard.style.display = 'none'; + if (selectedRegionPreview) selectedRegionPreview.innerHTML = ''; + } + function showSelectedRegionPanel(shape, items) { + if (!selectedRegionCard || !selectedRegionPreview) return; + if (selectedRegionTitle) selectedRegionTitle.textContent = shapeTitle(shape); + selectedRegionPreview.innerHTML = ''; + var labels = items && items.length ? items.map(function(item) { return item.label; }) : ['No annotated widgets inside the selected area']; + labels.slice(0, 4).forEach(function(label) { + var row = document.createElement('span'); + row.textContent = label; + selectedRegionPreview.appendChild(row); + }); + selectedRegionCard.style.display = 'block'; + } + function showSelectedRegionPreview(shape, bounds, radius, points, items) { + if (!selectionLayer) return; + clearSelectedRegionPreview(); + clearSelectedTextPreview(); + var layerRect = selectionLayer.getBoundingClientRect(); + selectedRegionEl = document.createElement('div'); + selectedRegionEl.className = 'selected-region ' + shape; + if (shape === 'lasso') { + var path = (points || []).map(function(p, index) { return (index ? 'L' : 'M') + Math.round(p.x) + ' ' + Math.round(p.y); }).join(' '); + selectedRegionEl.innerHTML = ''; + } else if (shape === 'circle') { + selectedRegionEl.style.left = Math.round(dragStart.x - radius) + 'px'; + selectedRegionEl.style.top = Math.round(dragStart.y - radius) + 'px'; + selectedRegionEl.style.width = radius * 2 + 'px'; + selectedRegionEl.style.height = radius * 2 + 'px'; + } else { + selectedRegionEl.style.left = bounds.x + 'px'; + selectedRegionEl.style.top = bounds.y + 'px'; + selectedRegionEl.style.width = Math.max(8, bounds.width) + 'px'; + selectedRegionEl.style.height = Math.max(8, bounds.height) + 'px'; + } + selectedRegionEl.appendChild(createRegionLabel(shape, bounds)); + selectedRegionEl.appendChild(createInlinePrompt(shape, bounds, layerRect.height)); + selectionLayer.appendChild(selectedRegionEl); + showSelectedRegionPanel(shape, items); + if (contextBarLabel) contextBarLabel.textContent = shapeTitle(shape) + ':'; + } function drawSelection(point) { if (!selectionLayer || !dragStart) return; if (!drawingEl) { @@ -1595,6 +1780,7 @@

Start with one attribute.

drawingEl.style.width = radius * 2 + 'px'; drawingEl.style.height = radius * 2 + 'px'; } else { + if (activeTool === 'square') b = squareBoundsFromPoints(dragStart, point); drawingEl.style.left = b.x + 'px'; drawingEl.style.top = b.y + 'px'; drawingEl.style.width = Math.max(8, b.width) + 'px'; @@ -1612,7 +1798,7 @@

Start with one attribute.

maxY: Math.max(acc.maxY, p.y) }; }, { x: dragStart.x, y: dragStart.y, maxX: dragStart.x, maxY: dragStart.y }) - : boundsFromPoints(dragStart, point); + : activeTool === 'square' ? squareBoundsFromPoints(dragStart, point) : boundsFromPoints(dragStart, point); if (bounds.maxX != null) { bounds = { x: Math.round(bounds.x), y: Math.round(bounds.y), width: Math.round(bounds.maxX - bounds.x), height: Math.round(bounds.maxY - bounds.y) }; } @@ -1646,18 +1832,13 @@

Start with one attribute.

consent: 'explicit' }; askable.push(meta, selectionSummary(activeTool, selectedItems), dashboard); + showSelectedRegionPreview(activeTool, bounds, radius, lassoPoints.slice(), selectedItems); setToolHint(activeTool + ' context sent to the assistant.'); - if (activeTool === 'lasso') { - toolClearTimer = setTimeout(function() { - if (drawingEl) { drawingEl.remove(); drawingEl = null; } - dragStart = null; - lassoPoints = []; - resetAiTrail(); - setToolHint('Lasso context sent. Draw another lasso, or cancel to leave lasso mode.'); - }, 450); - } else { - toolClearTimer = setTimeout(clearToolState, 450); - } + if (drawingEl) { drawingEl.remove(); drawingEl = null; } + dragStart = null; + lassoPoints = []; + resetAiTrail(); + setToolHint(shapeTitle(activeTool) + ' sent. Ask in the selected area, draw again, or cancel.'); } document.querySelectorAll('[data-tool]').forEach(function(btn) { btn.addEventListener('click', function(e) { @@ -1677,6 +1858,7 @@

Start with one attribute.

e.preventDefault(); if (toolClearTimer) { clearTimeout(toolClearTimer); toolClearTimer = null; } if (drawingEl) { drawingEl.remove(); drawingEl = null; } + clearSelectedRegionPreview(); if (activeTool === 'lasso') { resizeAiTrail(); resetAiTrail(); @@ -1788,6 +1970,7 @@

Start with one attribute.

if (w === 'arpu') return 'How can we improve ARPU?'; if (w === 'nps') return 'What should we do with this NPS score?'; if (meta.capture === 'region') return 'What should I know about this selected area?'; + if (meta.capture === 'square') return 'What should I know about this squared area?'; if (meta.capture === 'circle') return 'What should I do about this circled area?'; if (meta.capture === 'lasso') return 'What stands out in this lassoed area?'; if (meta.capture === 'text-selection') return 'Use this selected text to answer my question.'; @@ -1804,6 +1987,7 @@

Start with one attribute.

if (meta.capture === 'text-selection') return ['Summarize this', 'Extract action items', 'Use this in my answer']; if (meta.capture === 'lasso') return ['What stands out?', 'Summarize this area', 'What should I do next?']; if (meta.capture === 'region') return ['Summarize this area', 'Find risks here', 'What changed?']; + if (meta.capture === 'square') return ['Summarize this square', 'Compare this area', 'What changed?']; if (meta.capture === 'circle') return ['Explain this point', 'Is this an issue?', 'What should I do?']; if (meta.widget === 'account_row') { if (meta.status === 'at_risk') return ['Recommended action', 'Why is this risky?', 'Draft next step']; @@ -1910,8 +2094,16 @@

Start with one attribute.

// Show context bar — let user see what's loaded before sending contextBar.style.display = 'flex'; contextChip.textContent = prompt; - if (meta && meta.capture === 'text-selection') showSelectedTextPreview(focus.text); - else clearSelectedTextPreview(); + if (meta && meta.capture === 'text-selection') { + clearSelectedRegionPreview(); + showSelectedTextPreview(focus.text); + } else if (meta && (meta.capture === 'region' || meta.capture === 'square' || meta.capture === 'circle' || meta.capture === 'lasso')) { + clearSelectedTextPreview(); + showSelectedRegionPanel(meta.capture, meta.selectedItems || []); + } else { + clearSelectedTextPreview(); + clearSelectedRegionPreview(); + } // Highlight active element if (activeEl) activeEl.classList.remove('active'); activeEl = focus.element; @@ -1925,6 +2117,7 @@

Start with one attribute.

document.getElementById('context-dismiss').addEventListener('click', function() { contextBar.style.display = 'none'; contextChip.textContent = ''; clearSelectedTextPreview(); + clearSelectedRegionPreview(); if (activeEl) activeEl.classList.remove('active'); activeEl = null; lastKey = null; askable.clear(); hideQuestionSuggestions(); @@ -1953,6 +2146,8 @@

Start with one attribute.

// Context-aware canned responses if (meta.capture === 'region') { reply = 'This region selection is now explicit context. A real app would attach bounds plus any nearby semantic data, chart data, or screenshot reference.'; + } else if (meta.capture === 'square') { + reply = 'This square selection keeps the visual target constrained while still carrying explicit bounds, nearby semantic data, and the selected UI state.'; } else if (meta.capture === 'circle') { reply = 'The circled area is treated as intentional context. This is useful for chart anomalies, visual defects, map locations, or one object in a dense UI.'; } else if (meta.capture === 'lasso') {