feat(widget): bring your own launcher β data-launcher="none" + window.krispy + krispy:* events - #53
Open
lonormaly wants to merge 1 commit into
Open
feat(widget): bring your own launcher β data-launcher="none" + window.krispy + krispy:* events#53lonormaly wants to merge 1 commit into
lonormaly wants to merge 1 commit into
Conversation
β¦.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.
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
The
themeconfig 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.jsrenders<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.jsalways 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 anasyncscript.2. There is no programmatic open.
open()is a closure inside the IIFE β no window global, no custom event, nopostMessagelistener. So a custom button has to reach into the open shadow root and synthesise a click on the hidden built-in one: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.jscreates a bare<div>, setsstyle.cssText, and appends it. The only thing identifying it in the document is the z-index in that inline style: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:kunreaddot,knudgepulse,kglow, thelauncherDelayMsentrance β keeps running untouched, so suppression needs no branches scattered through the file;.khiddencan't reveal it again. (Verified β see below.)window.krispyβ commandskrispy:open/krispy:close/krispy:unreadondocumentβ statekrispy:unreadcarriesdetail: { 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 hostSo 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()andunread()have to return a value and a dispatchedCustomEventcannot. 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, onekrispy_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:
window.krispycall at that moment can't be β hence theif (window.krispy)guard in the example below;timing.autoOpenMs, the panel's ownΓ. A custom launcher cannot track that state on its own;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-launcherabsent βcfg.launcher === ""β thedisplay:noneline never runs and the launcher renders exactly as before.window.krispyand 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.Try it by hand
Then a ten-line custom launcher on any page:
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 toucheslocalStorage,WebSocket,IntersectionObserver,AudioContextandvisualViewportis its own PR. So this was driven in a real headless Chrome instead, on two pages β one default embed, one withdata-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/chatstubbed to reply 400ms late, visitor closes the panel before it lands, sonotifyInboundruns 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
Docs (Β§7)
apps/docs/content/docs/guides/embed-and-theme.mdxβdata-launcherrow + a bring your own launcher section (the example, thewindow.krispytable, the events table, the host element)packages/widget/README.md+ rootREADME.mdβ attribute tables + the same in shortpackages/widget/index.htmlβ the demo page names the new attributeCHANGELOG.mdβ[Unreleased] β Addedreference/markers.mdxis about[!HANDOFF]/[!FORM:<id>]and doesn't list embed attributes, so it needed no change.Follow-ups, deliberately not in this PR
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.data-launcher="none"andtheme.popupTextgets 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.π€ Generated with Claude Code