From 9b17fded8d68e4f04d6e5deb2a2ab124b2544faa Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Tue, 4 Aug 2026 16:18:20 +0200 Subject: [PATCH 1/9] docs(sdk): auto-popup (proactive teaser) design spec Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-04-sdk-auto-popup-design.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-sdk-auto-popup-design.md 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. From bb2018324af410aad5c72d7a5ce95d0ccf567b33 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Tue, 4 Aug 2026 16:18:20 +0200 Subject: [PATCH 2/9] docs(sdk): auto-popup implementation plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-04-sdk-auto-popup.md | 531 ++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-sdk-auto-popup.md 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" +``` From d2f2f93a66823cc4fddbfe156d031997afc76634 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Tue, 4 Aug 2026 16:18:20 +0200 Subject: [PATCH 3/9] feat(sdk): popup/popupTitle/popupDelay init options + data-attrs --- sdk/src/index.ts | 8 ++++++++ sdk/src/types.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/sdk/src/index.ts b/sdk/src/index.ts index c519d65..96cc41c 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -115,6 +115,11 @@ function init(opts: IBridleInitOptions): IBridleInstance { if (opts.greetingDelay !== undefined) { el.setAttribute('greeting-delay', String(opts.greetingDelay)) } + 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)) + } if (opts.emptyAvatar) el.setAttribute('empty-avatar', opts.emptyAvatar) if (opts.emptyTitle) el.setAttribute('empty-title', opts.emptyTitle) if (opts.emptySubtitle) el.setAttribute('empty-subtitle', opts.emptySubtitle) @@ -226,6 +231,9 @@ function autoMount(): void { fabIcon: ds.fabIcon, greeting: ds.greeting, greetingDelay: ds.greetingDelay ? Number(ds.greetingDelay) : undefined, + popup: ds.popup, + popupTitle: ds.popupTitle, + popupDelay: ds.popupDelay ? Number(ds.popupDelay) : undefined, emptyAvatar: ds.emptyAvatar, emptyTitle: ds.emptyTitle, emptySubtitle: ds.emptySubtitle, diff --git a/sdk/src/types.ts b/sdk/src/types.ts index c54b180..fcb3b94 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -177,6 +177,21 @@ export interface IBridleInitOptions { * Default: 3000. Set to 0 to skip the delay. */ greetingDelay?: number + /** + * 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 /** * URL of an avatar image shown on the empty-state screen, above the * `emptyTitle`. Any URL the browser can render in `` works. From bebfd1ce57febfa70df28752b4ae4736d385bd05 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Tue, 4 Aug 2026 16:18:20 +0200 Subject: [PATCH 4/9] fix(sdk): resolve pre-existing vue-tsc errors blocking the typecheck gate --- sdk/src/BridleChat.ce.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sdk/src/BridleChat.ce.vue b/sdk/src/BridleChat.ce.vue index a47e5dc..19cf2f9 100644 --- a/sdk/src/BridleChat.ce.vue +++ b/sdk/src/BridleChat.ce.vue @@ -772,7 +772,8 @@ function maybeShowGreeting(): void { function toggle(): void { isOpen.value = !isOpen.value - emit(isOpen.value ? 'open' : 'close') + if (isOpen.value) emit('open') + else emit('close') } function toggleMenu(): void { @@ -1159,7 +1160,7 @@ defineExpose({