Skip to content

feat(widget): bring your own launcher β€” data-launcher="none" + window.krispy + krispy:* events - #53

Open
lonormaly wants to merge 1 commit into
masterfrom
feat/launcher-api
Open

feat(widget): bring your own launcher β€” data-launcher="none" + window.krispy + krispy:* events#53
lonormaly wants to merge 1 commit into
masterfrom
feat/launcher-api

Conversation

@lonormaly

Copy link
Copy Markdown
Collaborator

The gap

The theme config restyles the built-in launcher β€” launcherColor, avatar, glowColor, sparkle. That is enough for a color and a logo. It is not enough for a brand with its own mark: an animated silhouette, a hover gesture, a non-circular shape, a word instead of an icon. widget.js renders <button class="btn"><img class="bic"></button> inside a shadow root, and an <img> can't carry any of that.

So "restyle ours" isn't an answer for anyone who cares about their mark, and there is no supported way to bring your own. This comes out of a real self-host integration (FLAM), which ended up doing all three of the following.

The three workarounds it forces

1. The launcher can't be suppressed. widget.js always renders its own .btn. An embedder who wants their own has to hide Krispy's after the fact, and hope the timing works out against an async script.

2. There is no programmatic open. open() is a closure inside the IIFE β€” no window global, no custom event, no postMessage listener. So a custom button has to reach into the open shadow root and synthesise a click on the hidden built-in one:

export function openKrispyChat(): boolean {
  const btn = findHost()?.shadowRoot?.querySelector<HTMLElement>(".btn");
  if (!btn) return false;
  btn.click();
  return true;
}

That breaks on any internal rename, and it couples an embedder to our private class names.

3. The host element has no stable handle. widget.js creates a bare <div>, sets style.cssText, and appends it. The only thing identifying it in the document is the z-index in that inline style:

/** The widget's own host div, found by the one thing that identifies it. */
const findHost = (): HTMLElement | null =>
  document.querySelector<HTMLElement>('div[style*="2147483000"]');

A string match against a style attribute, in production, because we gave them nothing better.

What this adds

Three things, all opt-in, all inert until used.

data-launcher="none"

Consistent with the existing data-api / data-tenant / data-site / data-title / data-accent. Suppresses the built-in launcher button.

The button stays in the DOM and is hidden with an inline display:none. That is deliberate:

  • every path that touches it β€” kunread dot, knudge pulse, kglow, the launcherDelayMs entrance β€” keeps running untouched, so suppression needs no branches scattered through the file;
  • an inline style beats the stylesheet, so the entrance path removing .khidden can't reveal it again. (Verified β€” see below.)

window.krispy β€” commands

window.krispy.open();     // no-op if already open
window.krispy.close();
window.krispy.toggle();   // what the built-in launcher does
window.krispy.isOpen();   // boolean
window.krispy.unread();   // boolean
window.krispy.el;         // the host element

krispy:open / krispy:close / krispy:unread on document β€” state

krispy:unread carries detail: { unread: boolean }, so a custom launcher can show its own dot when an operator replies, and clear it when the panel opens.

class="krispy-widget" on the host

So nobody has to match on a z-index string again. The UI all lives in the shadow root, so the class exposes nothing but the anchor itself.

Why a global and an event, rather than one or the other

The obvious framing is to pick one β€” a global is easier, an event composes better with multiple widgets. I ended up splitting by direction, because the two directions genuinely want different things.

Commands go on a global, because a caller needs an answer back. isOpen() and unread() have to return a value and a dispatched CustomEvent cannot. The "events compose better with multiple widgets" argument doesn't apply here: the widget is a singleton per page by construction β€” document.currentScript, one host div, one krispy_session_<tenant> key. Two script tags already collide on session storage and stack in the same corner, so designing the new API around a configuration that doesn't work today would buy nothing real.

State comes back as events, because:

  • a listener can be registered before the async script has run, which is exactly when a custom launcher is being wired up. A window.krispy call at that moment can't be β€” hence the if (window.krispy) guard in the example below;
  • the panel opens from paths the embedder never called: a teaser popup click, timing.autoOpenMs, the panel's own Γ—. A custom launcher cannot track that state on its own;
  • polling krispy.unread() on an interval to drive a dot would contradict this repo's own "push, don't poll" rule.

Compatibility

Default-unchanged, by construction:

  • data-launcher absent β†’ cfg.launcher === "" β†’ the display:none line never runs and the launcher renders exactly as before.
  • window.krispy and the three events are added unconditionally, and are inert until something calls or listens.
  • class="krispy-widget" is additive. div[style*="2147483000"] still matches the same element, so existing integrations built on the old hack keep working β€” verified, not assumed.
  • No edge route changed, so no OpenAPI/Bruno change.

Try it by hand

cd services/edge && bunx wrangler dev   # optional; the widget boots without it
bunx serve packages/widget

Then a ten-line custom launcher on any page:

<button id="support" type="button">support</button>

<script>
  var b = document.getElementById("support");
  b.addEventListener("click", function () {
    if (window.krispy) window.krispy.toggle(); // guard: widget.js is async
  });
  document.addEventListener("krispy:unread", function (e) {
    b.dataset.unread = String(e.detail.unread);
  });
</script>

<script src="./widget.js" data-api="http://localhost:8787" data-tenant="self"
        data-launcher="none" async></script>

How this was verified

There is no browser test harness in this repo (no jsdom/happy-dom β€” grep -c "happy-dom\|jsdom" bun.lock β†’ 0), and building one for a widget that touches localStorage, WebSocket, IntersectionObserver, AudioContext and visualViewport is its own PR. So this was driven in a real headless Chrome instead, on two pages β€” one default embed, one with data-launcher="none" plus the launcher above.

Default embed (nothing new set):

{ "hostClass": "krispy-widget",
  "sameElement": true,                      // div[style*="2147483000"] finds the same node
  "api": ["open","close","toggle","isOpen","unread","el"],
  "before":     { "display": "", "computed": "flex", "panelOpen": false },
  "afterOpen":  { "panelOpen": true,  "isOpen": true },   // built-in .btn click
  "afterClose": { "panelOpen": false, "isOpen": false },  // window.krispy.close()
  "events": [["open",null],["unread",{"unread":false}],["close",null]] }

data-launcher="none", driven only through the custom button:

{ "suppressed": { "inline": "none", "computed": "none", "width": 0 },
  "opened":     { "panelOpen": true,  "label": "close" },
  "closed":     { "panelOpen": false, "label": "support" } }

Unread, through the real notifyInbound() path β€” /api/chat stubbed to reply 400ms late, visitor closes the panel before it lands, so notifyInbound runs with the panel closed exactly as it does with a live operator:

{ "afterReply": { "builtInDot": true,  "apiUnread": true,
                  "customAttr": "true",  "customOutline": "rgb(255, 0, 255)" },
  "afterOpen":  { "builtInDot": false, "apiUnread": false, "customAttr": "false" },
  "stillHidden": "none",   // .khidden added then removed β€” suppression survives the entrance path
  "bubbles": 3 }

Gates

$ bun run typecheck
@krispy/edge typecheck: Exited with code 0
@krispyai/cli typecheck: Exited with code 0

$ bun run lint
exit=0   # 0 errors, 167 warnings, all pre-existing
# widget.js: 5 warnings on origin/master, 5 on this branch β€” this change adds none

$ bun run format:check
Checking formatting...
All matched files use the correct format.
Finished in 1087ms on 117 files using 10 threads.

$ bun run test
@krispy/edge test:  193 pass
@krispy/edge test:  0 fail
@krispy/edge test:  563 expect() calls
@krispy/edge test: Ran 193 tests across 7 files. [525.00ms]
@krispyai/cli test:  14 pass
@krispyai/cli test:  0 fail
@krispyai/cli test: Ran 14 tests across 1 file. [1257.00ms]

Docs (Β§7)

  • apps/docs/content/docs/guides/embed-and-theme.mdx β€” data-launcher row + a bring your own launcher section (the example, the window.krispy table, the events table, the host element)
  • packages/widget/README.md + root README.md β€” attribute tables + the same in short
  • packages/widget/index.html β€” the demo page names the new attribute
  • CHANGELOG.md β€” [Unreleased] β†’ Added

reference/markers.mdx is about [!HANDOFF]/[!FORM:<id>] and doesn't list embed attributes, so it needed no change.

Follow-ups, deliberately not in this PR

  • Richer unread state. krispy.unread() is a boolean, mirroring what the built-in dot already shows. An unread count would mean tracking inbound messages the widget currently doesn't count.
  • A suppressed launcher doesn't suppress the teaser popup. A tenant with both data-launcher="none" and theme.popupText gets a teaser card floating where the launcher would have been. That combination is two deliberate opt-ins, so it's left alone rather than guessed at β€” say the word if it should anchor to the custom launcher instead.
  • A widget test harness. Worth having, but it's a devDependency and a pile of browser-API stubs, and it shouldn't ride in on a 20-line feature.

πŸ€– Generated with Claude Code

….krispy

The theme restyles Krispy's launcher; it can never replace it. An <img> in a
shadow root can't carry a brand's own silhouette, hover gesture, or a word
instead of an icon. Embedders who care about their mark were left with three
workarounds, all of them against internals:

  - hide the built-in .btn after the fact, because it always renders;
  - synthesise a .click() on it through the open shadow root, because open()
    is a closure inside the IIFE with no global, event, or postMessage door;
  - find the host with div[style*="2147483000"], because a bare <div> with an
    inline style is all there is to select.

Three additions, each opt-in and each inert by default:

  - data-launcher="none" hides the built-in button. It stays in the DOM, so
    the unread dot, nudge, glow and delayed-entrance paths are untouched; an
    inline display beats the stylesheet, so removing .khidden can't reveal it.
  - window.krispy = { open, close, toggle, isOpen, unread, el }. Commands go
    on a global because a caller needs an answer back and a dispatched event
    can't return one. The widget is a singleton per page by construction.
  - krispy:open / krispy:close / krispy:unread on document. State is pushed,
    not polled: a listener can be registered before this async script runs,
    and the panel opens from paths the embedder never called (teaser popup,
    autoOpenMs, the panel's own Γ—).

The host element now carries class="krispy-widget".

Default embeds are unchanged: set none of it and the launcher renders exactly
as before, byte for byte.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant