diff --git a/docs/superpowers/plans/2026-08-04-sdk-auto-popup.md b/docs/superpowers/plans/2026-08-04-sdk-auto-popup.md new file mode 100644 index 0000000..469fab1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-sdk-auto-popup.md @@ -0,0 +1,531 @@ +# SDK Auto-Popup (Proactive Teaser) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a dismissible proactive teaser card ("auto-popup") above the closed floating FAB, configurable via `popup` / `popupTitle` / `popupDelay` init options and `data-popup*` script-tag attributes. + +**Architecture:** The teaser renders inside the existing `` custom element (shadow DOM), as a sibling of `.bridle__fab` in the `.bridle--floating` container. Behavior follows the existing `greeting` conventions: string-coercible props, timers cancelled on unmount, localStorage guarded by try/catch. Dismissal is permanent per agent via `bridle:popup-dismissed:`. + +**Tech Stack:** Vue 3 custom element (`defineCustomElement`), TypeScript, Vite build, `marked` + `DOMPurify` for markdown. No unit-test harness exists in `sdk/` — verification is `vue-tsc` typecheck, Vite build, and a manual pass via the example page. + +**Spec:** `docs/superpowers/specs/2026-08-04-sdk-auto-popup-design.md` (approved). + +## Global Constraints + +- Work in repo `/Users/maksymtmk/my-knowledge/bridle`, branch `feat/sdk-auto-popup` (already created). +- Floating mode only: in `mode: 'inline'` the options are ignored. +- `popup` absent/empty ⇒ feature fully off (this is the off-switch requirement). +- `popupDelay` default is exactly `3000` ms; `0` means "show immediately". +- localStorage key is exactly `bridle:popup-dismissed:`; all storage access wrapped in try/catch (privacy-mode convention of the existing `bridle:anon:` key). +- Markdown in `popup` must go through the existing `renderMarkdown()` (marked + DOMPurify) — no new sanitization path. +- Styling uses existing `--bridle-*` custom properties only; no hardcoded colors. +- Version bump: `sdk/package.json` `0.13.3` → `0.14.0`. Do NOT push the `sdk-v0.14.0` tag — that happens after merge, on explicit user confirmation. +- Verification commands run from `sdk/`: `npm run typecheck` and `npm run build`. + +--- + +### Task 1: Public API plumbing — types + `init()` + `autoMount()` + +**Files:** +- Modify: `sdk/src/types.ts` (insert after `greetingDelay?: number`, line ~179) +- Modify: `sdk/src/index.ts` (attribute mapping ~line 117, `autoMount()` ~line 228) + +**Interfaces:** +- Consumes: existing `IBridleInitOptions`, `init()`, `autoMount()`. +- Produces: `IBridleInitOptions.popup?: string`, `popupTitle?: string`, `popupDelay?: number`; element attributes `popup`, `popup-title`, `popup-delay`; data-attrs `data-popup`, `data-popup-title`, `data-popup-delay`. Task 2's component props must match these attribute names. + +- [ ] **Step 1: Install SDK deps (fresh clone has no node_modules)** + +Run: `cd /Users/maksymtmk/my-knowledge/bridle/sdk && npm install` +Expected: installs from `package-lock.json` without errors. + +- [ ] **Step 2: Add the three options to `IBridleInitOptions`** + +In `sdk/src/types.ts`, directly after the `greetingDelay?: number` member (line ~179), insert: + +```ts + /** + * Text of the proactive teaser card shown above the closed floating FAB + * (auto-popup), inviting the visitor to chat. Markdown is supported. + * Absent/empty ⇒ the teaser is disabled. Floating mode only. Dismissing + * it (✕) or opening the chat is remembered per agent in localStorage + * (`bridle:popup-dismissed:`) and the teaser never re-appears. + */ + popup?: string + /** Bold headline above the `popup` text, e.g. "👋 Hi, I'm Assistant!". */ + popupTitle?: string + /** + * Milliseconds after mount before the teaser appears. Default: 3000. + * Set to 0 to show immediately. + */ + popupDelay?: number +``` + +- [ ] **Step 3: Map options to element attributes in `init()`** + +In `sdk/src/index.ts`, directly after the `greeting-delay` block (lines 114–117): + +```ts + if (opts.greeting) el.setAttribute('greeting', opts.greeting) + if (opts.greetingDelay !== undefined) { + el.setAttribute('greeting-delay', String(opts.greetingDelay)) + } +``` + +add: + +```ts + if (opts.popup) el.setAttribute('popup', opts.popup) + if (opts.popupTitle) el.setAttribute('popup-title', opts.popupTitle) + if (opts.popupDelay !== undefined) { + el.setAttribute('popup-delay', String(opts.popupDelay)) + } +``` + +- [ ] **Step 4: Parse the data-attrs in `autoMount()`** + +In the same file, inside the `init({...})` call of `autoMount()`, directly after `greetingDelay: ds.greetingDelay ? Number(ds.greetingDelay) : undefined,` (line ~228), add: + +```ts + popup: ds.popup, + popupTitle: ds.popupTitle, + popupDelay: ds.popupDelay ? Number(ds.popupDelay) : undefined, +``` + +(dataset camelCases automatically: `data-popup-title` → `ds.popupTitle`.) + +- [ ] **Step 5: Typecheck** + +Run: `cd /Users/maksymtmk/my-knowledge/bridle/sdk && npm run typecheck` +Expected: exit 0, no errors. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/maksymtmk/my-knowledge/bridle +git add sdk/src/types.ts sdk/src/index.ts +git commit -m "feat(sdk): popup/popupTitle/popupDelay init options + data-attrs" +``` + +--- + +### Task 2: Teaser behavior, markup and styles in the component + +**Files:** +- Modify: `sdk/src/BridleChat.ce.vue` + - props block (~line 112, after `greetingDelay`) + - state refs (~line 172, after `greetingTimer`) + - functions (after `cancelGreetingTimer`, ~line 730) + - `onDocKeydown` (~line 849) + - `onMounted` / `onBeforeUnmount` (~lines 911–931) + - template (~line 948, before the FAB button) + - styles (~line 1507, after `.bridle__fab-icon`) + +**Interfaces:** +- Consumes: attributes `popup`, `popup-title`, `popup-delay` from Task 1; existing `isOpen`, `toggle()`, `renderMarkdown()`, `coerceBool` conventions. +- Produces: shadow-DOM classes `.bridle__popup`, `.bridle__popup-card`, `.bridle__popup-title`, `.bridle__popup-body`, `.bridle__popup-close` (documented restyle surface for `customCss`); localStorage key `bridle:popup-dismissed:`. + +- [ ] **Step 1: Add props** + +In the `defineProps` block, directly after the `greetingDelay?: number | string` member (line ~112), insert: + +```ts + /** + * Text of the proactive teaser card shown above the closed floating + * FAB (auto-popup). Markdown is supported. Empty/absent ⇒ disabled. + * Floating mode only. Dismissal (✕ or opening the chat) is remembered + * per agent in localStorage and the teaser never re-appears. + */ + popup?: string + /** Bold headline above the `popup` text. */ + popupTitle?: string + /** + * Milliseconds after mount before the teaser appears. Default: 3000. + * Attributes arrive as strings, hence `number | string`. + */ + popupDelay?: number | string +``` + +No new entries in `withDefaults` — all three are optional with in-code fallbacks. + +- [ ] **Step 2: Add state** + +After the `let greetingTimer: ReturnType | null = null` line (~172), insert: + +```ts +// Pre-open teaser (auto-popup). Timer is one-shot; every exit path +// (open, dismiss, unmount) cancels it so the card can't resurface. +const popupVisible = ref(false) +let popupTimer: ReturnType | null = null +``` + +- [ ] **Step 3: Add behavior functions** + +Directly after `cancelGreetingTimer()` (line ~730), insert: + +```ts +const POPUP_DISMISSED_PREFIX = 'bridle:popup-dismissed:' + +function isPopupDismissed(): boolean { + if (typeof window === 'undefined') return true + try { + return ( + window.localStorage.getItem(POPUP_DISMISSED_PREFIX + props.agentId) === '1' + ) + } catch { + // Storage disabled (privacy mode) — treat as not dismissed so the + // teaser still shows; we just can't remember the dismissal. + return false + } +} + +function markPopupDismissed(): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(POPUP_DISMISSED_PREFIX + props.agentId, '1') + } catch { + // Best-effort — same convention as the `bridle:anon:` key. + } +} + +function cancelPopupTimer(): void { + if (popupTimer) { + clearTimeout(popupTimer) + popupTimer = null + } +} + +// Schedule the teaser once from onMounted. Floating mode only, closed +// panel only, and never after a remembered dismissal. +function maybeShowPopup(): void { + if (props.mode !== 'floating') return + if (isOpen.value) return + const text = props.popup?.trim() + if (!text) return + if (isPopupDismissed()) return + + const raw = + typeof props.popupDelay === 'string' ? Number(props.popupDelay) : props.popupDelay + const delay = Number.isFinite(raw) && raw !== undefined ? Math.max(0, raw as number) : 3000 + + popupTimer = setTimeout(() => { + popupTimer = null + if (isOpen.value) return + popupVisible.value = true + }, delay) +} + +function dismissPopup(): void { + cancelPopupTimer() + popupVisible.value = false + markPopupDismissed() +} + +function onPopupClick(): void { + dismissPopup() + if (!isOpen.value) toggle() +} +``` + +- [ ] **Step 4: Dismiss when the panel opens by any path** + +Near the other watchers (e.g. directly after the `watch(() => [props.theme, props.colorMode], ...)` block, line ~909), add: + +```ts +// Opening the chat by ANY path (FAB, defaultOpen, programmatic open()) +// counts as engagement: hide the teaser and remember the dismissal. +watch(isOpen, (open) => { + if (!open) return + if (!props.popup?.trim()) return + dismissPopup() +}) +``` + +- [ ] **Step 5: Escape hides the teaser** + +Replace the existing `onDocKeydown` (lines ~849–853): + +```ts +function onDocKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape' && menuOpen.value) { + menuOpen.value = false + } +} +``` + +with: + +```ts +function onDocKeydown(e: KeyboardEvent): void { + if (e.key !== 'Escape') return + if (menuOpen.value) menuOpen.value = false + if (popupVisible.value) dismissPopup() +} +``` + +- [ ] **Step 6: Wire lifecycle** + +In `onMounted` (line ~911), after the `if (!props.apiUrl || !props.agentId) return` guard and before `await connect()`, add one line: + +```ts + maybeShowPopup() +``` + +In `onBeforeUnmount` (line ~922), after `cancelGreetingTimer()`, add one line: + +```ts + cancelPopupTimer() +``` + +- [ ] **Step 7: Template** + +In the template, inside the root `
` (line 948), directly BEFORE the ` +
+
{{ popupTitle }}
+
+
+
+``` + +- [ ] **Step 8: Styles** + +After the `.bridle__fab-icon` rule (line ~1507), insert: + +```css +/* ---- Pre-open teaser (auto-popup) ---- */ +.bridle__popup { + position: absolute; + bottom: 68px; + right: 0; + width: max-content; + max-width: min(300px, calc(100vw - 40px)); + background: var(--bridle-bg-elv); + color: var(--bridle-fg); + border: 1px solid var(--bridle-border); + border-radius: var(--bridle-radius); + box-shadow: var(--bridle-shadow); + padding: 12px 14px; + animation: bridle-popup-in 0.25s ease-out both; +} +@media (prefers-reduced-motion: reduce) { + .bridle__popup { animation: none; } +} +@keyframes bridle-popup-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.bridle__popup-card { cursor: pointer; } +.bridle__popup-card:focus-visible { + outline: 2px solid var(--bridle-focus-ring); + outline-offset: 2px; + border-radius: 6px; +} +.bridle__popup-title { + font-weight: 600; + font-size: 14px; + padding-right: 20px; +} +.bridle__popup-body { + font-size: 13px; + color: var(--bridle-muted); +} +.bridle__popup-title + .bridle__popup-body { margin-top: 4px; } +.bridle__popup-body :first-child { margin-top: 0; } +.bridle__popup-body :last-child { margin-bottom: 0; } +.bridle__popup-close { + position: absolute; + top: 6px; + right: 8px; + background: transparent; + border: 0; + font-size: 18px; + line-height: 1; + cursor: pointer; + color: var(--bridle-muted); + padding: 2px 4px; + border-radius: 4px; +} +.bridle__popup-close:hover { background: var(--bridle-bubble-bg); } +``` + +- [ ] **Step 9: Typecheck + build** + +Run: `cd /Users/maksymtmk/my-knowledge/bridle/sdk && npm run typecheck && npm run build` +Expected: both exit 0. Build emits `dist/bridle.js`, `dist/bridle.mjs`, and regenerated `dist/*.d.ts` including the three new options. + +- [ ] **Step 10: Commit** + +```bash +cd /Users/maksymtmk/my-knowledge/bridle +git add sdk/src/BridleChat.ce.vue +git commit -m "feat(sdk): proactive auto-popup teaser above the floating FAB" +``` + +--- + +### Task 3: Example page + README docs + +**Files:** +- Modify: `example/index.html` (feature list ~line 31, basic sample code block ~lines 50–64; if the page contains a LIVE ` +``` + +Checks: +1. Teaser appears ~1s after load, above the FAB. +2. Click on the card body → panel opens, teaser gone. +3. Reload → teaser does NOT re-appear (localStorage flag set). +4. `localStorage.removeItem('bridle:popup-dismissed:smoke-test')`, reload, dismiss via ✕ → teaser gone; reload → still gone. +5. With `data-mode="inline"` and a `data-mount` target the teaser never shows. + +If no browser is available in the environment, state that explicitly in the report instead of claiming the checks passed. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/maksymtmk/my-knowledge/bridle +git add sdk/package.json +git commit -m "chore(sdk): bump version to 0.14.0" +``` diff --git a/docs/superpowers/specs/2026-08-04-sdk-auto-popup-design.md b/docs/superpowers/specs/2026-08-04-sdk-auto-popup-design.md new file mode 100644 index 0000000..2cb5ed9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-sdk-auto-popup-design.md @@ -0,0 +1,107 @@ +# SDK Auto-Popup (Proactive Teaser) — Design + +**Date:** 2026-08-04 +**Status:** Approved +**Scope:** `sdk/` only. Integrations (Skyhunter etc.) are follow-up tasks. + +## Problem + +The floating widget (`mode: 'floating'`) renders a FAB in the corner, but stays +silent until the visitor clicks it. Competitors (reference: Autodesk Assistant) +show a proactive greeting card above the closed FAB — title, message, close +button — which measurably increases engagement. The SDK has `greeting` / +`greetingDelay`, but those render *inside* the opened panel; there is no +pre-open teaser. Integrators must be able to disable the teaser entirely. + +## Decision + +Add the teaser natively to the SDK custom element (approach chosen over +(a) reusing `greeting` for both roles — conflates in-chat copy with teaser +copy — and (b) leaving it to each integrator site — duplicated work, +inconsistent UX). + +## API + +Three new `IBridleInitOptions` fields, mirrored as attributes on +`` and as `data-*` attributes for script-tag embeds: + +| init option | element attribute | data-attr | type | default | +|---|---|---|---|---| +| `popup` | `popup` | `data-popup` | `string` | — (absent ⇒ feature off) | +| `popupTitle` | `popup-title` | `data-popup-title` | `string` | — | +| `popupDelay` | `popup-delay` | `data-popup-delay` | `number` (ms) | `3000` | + +- `popup` is the enable switch: no text ⇒ no teaser. This is the "flexible + off-switch" requirement. +- `popup` body renders markdown through the same `marked` + `DOMPurify` + pipeline as chat messages. +- Floating mode only. In `mode: 'inline'` the options are ignored. +- `popupDelay` accepts `number | string` on the prop (attributes arrive as + strings), same coercion pattern as `greetingDelay`. + +## Behavior + +Show the teaser when ALL hold, `popupDelay` ms after mount: + +1. `mode === 'floating'` and the panel is closed (`!isOpen`); +2. `popup` text is non-empty; +3. localStorage flag `bridle:popup-dismissed:` is absent. + +Transitions: + +- Click on the card body → open the panel + set the flag + hide teaser. +- Click on the ✕ button → hide teaser + set the flag. +- Panel opened by any other path (FAB click, `defaultOpen`, programmatic + `open()`) → set the flag and never show; if the timer is pending, cancel it. +- `Escape` while the teaser is visible → same as ✕. + +Persistence: dismissal is permanent per agent (localStorage). Storage +unavailable (privacy mode) ⇒ swallow the error and show the teaser anyway — +same try/catch convention as the existing `bridle:anon:` key. The +flag is scoped per `agentId` so two widgets on one origin don't interfere. + +## Markup & styling + +New block inside the `.bridle--floating` root, sibling of `.bridle__fab`: + +```html +
+ +
👋 Hi, I'm Assistant!
+
+
+``` + +- Absolutely positioned above the FAB, right-aligned with it; max-width + ~300px; card look (radius, shadow, border) built from existing `--bridle-*` + custom properties so themes, `themeVars`, and `customCss` keep working with + zero changes. +- Enter animation: fade + short upward slide (CSS only, `prefers-reduced-motion` + respected). +- Clickable body gets `cursor: pointer` and hover affordance; the ✕ button + stops propagation so it doesn't open the panel. + +## Error handling + +- Markdown sanitization identical to message rendering (no new sink). +- All storage reads/writes wrapped in try/catch. +- Timer cleared on unmount (`onBeforeUnmount`), mirroring `greetingTimer`. + +## Testing & verification + +The SDK has no unit-test harness; verification is: + +1. `npm run typecheck` (vue-tsc) and `npm run build` in `sdk/`; +2. manual pass via `example/index.html` — extend the example with + `data-popup*` attributes; +3. manual checks: shows after delay; body click opens chat; ✕ dismisses; + reload after dismiss ⇒ stays hidden; `defaultOpen` ⇒ never shows; + inline mode ⇒ never shows. + +## Docs & release + +- Document the options in `sdk/README.md` (init options table + script-tag + attrs) and in the example. +- Version: minor bump to `0.14.0` in `sdk/package.json`. +- Release: push tag `sdk-v0.14.0` after merge (CI verifies tag ↔ version and + publishes to npm). Tag is pushed only after explicit user confirmation. diff --git a/example/index.html b/example/index.html index 734371b..29d9f83 100644 --- a/example/index.html +++ b/example/index.html @@ -26,6 +26,7 @@

Bridle integration examples

Latest: + data-popup (v0.14.0) · interactive forms — radio / checkbox / select (v0.12.0) · empty-state suggestions (v0.11.0) · data-greeting (v0.10.0) · @@ -63,6 +64,11 @@

Basic — one <script> @@ -320,6 +326,11 @@

Interactive forms — radio, checkbox, s sdk.dataset.greeting = 'Hi! Drop a screenshot or ask me anything.'; sdk.dataset.greetingDelay = '2500'; + // v0.14.0 — proactive teaser above the closed bubble + sdk.dataset.popup = + 'Have a question? I can compare plans or book a demo.'; + sdk.dataset.popupTitle = '👋 Hi, I\'m the Bridle assistant!'; + sdk.dataset.popupDelay = '2000'; sdk.onerror = () => setError('Failed to load /sdk/latest.js'); sdk.onload = () => { diff --git a/sdk/README.md b/sdk/README.md index d194c44..ef10f72 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -28,6 +28,9 @@ The script auto-mounts a floating chat bubble in the bottom-right corner. If the | `data-placeholder` | `Type a message...` | Input placeholder | | `data-custom-css` | optional | Inline CSS injected into the shadow root | | `data-stylesheet` | optional | Stylesheet URL(s) loaded into the shadow root (comma-separate for multiple) | +| `data-popup` | off | Proactive teaser card above the closed FAB. Setting a text enables it; markdown supported | +| `data-popup-title` | optional | Bold headline of the teaser | +| `data-popup-delay` | `3000` | Milliseconds after load before the teaser appears; `0` = immediately | ## Programmatic init @@ -53,6 +56,27 @@ chat.close() chat.destroy() ``` +## Auto-popup (proactive teaser) + +In floating mode the widget can show a small dismissible card above the +closed bubble inviting the visitor to chat: + +```js +init({ + agentId: 'agent-…', + popup: 'Have a question? I can compare plans or book a demo.', + popupTitle: "👋 Hi, I'm the assistant!", + popupDelay: 2000, // ms, default 3000 +}) +``` + +Omit `popup` to disable the teaser entirely. Clicking the card opens the +chat; the ✕ button (or Escape) dismisses it. Either way the choice is +remembered per agent in `localStorage` (`bridle:popup-dismissed:`) +and the teaser never re-appears for that visitor. Restyle it via +`customCss` targeting `.bridle__popup`, `.bridle__popup-card`, +`.bridle__popup-title`, `.bridle__popup-body`, `.bridle__popup-close`. + ## Headless client (no UI) ```js diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 4f79b6d..fdac441 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cleanslice/bridle", - "version": "0.12.2", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cleanslice/bridle", - "version": "0.12.2", + "version": "0.14.0", "license": "MIT", "dependencies": { "dompurify": "^3.2.0", diff --git a/sdk/package.json b/sdk/package.json index 939ecfd..f4d7458 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@cleanslice/bridle", - "version": "0.13.3", + "version": "0.14.0", "description": "Embeddable web chat for Bridle — drop-in