feat(widget): programmatic API + ESM/UMD bundles + a11y + i18n - #9
feat(widget): programmatic API + ESM/UMD bundles + a11y + i18n#9Chafficui wants to merge 11 commits into
Conversation
… parsing Zod schema for the embed-time config (siteId, serverUrl, locale, openOnLoad, prefillMessage, userId, userTraits, theme, userContext, keyboardShortcut). parseDataAttributes() merges a script-tag dataset with window.KodyConfig (window wins on conflict) and surfaces parse errors as readable messages instead of throwing silently. UX: This is the foundation for the new embed pipeline so future host pages get a clear error message when the script is misconfigured, instead of the widget silently failing to mount.
…vent emitter, ready promise) The widget now exposes the full public surface on \window.Kody\ and through the new \mount()\ ESM export: - sendMessage(text) send as if the user typed it - prefillInput(text) put text in the input without sending - setUserContext(ctx) attach to all future messages - identify(userId, traits) sent as x-kody-user-id / x-kody-user-traits - setLocale(locale) future-proof; just stored for now - setTheme(theme) runtime override of branding.theme - on(event, cb) -> unsubscribe typed event emitter - version, ready: Promise<void> The API client now stamps identity / context / user-context headers on every chat, feedback, and config request so the server can join back to a known user without an explicit auth handshake. UX: Hosts can now drive the chat from their own React/Vue code without poking into shadow DOM, and the ready promise means a host that wants to call sendMessage() on mount can \�wait window.Kody.ready\ instead of guessing when init() finishes.
New utils/embed-config.ts is a tiny (no-zod) validator that merges a script-tag \data-*\ attribute set with \window.KodyConfig\. Window config wins on conflict. Recognised attributes: - data-site-id (existing) - data-server-url (new — overrides script origin inference) - data-locale (new) - data-open-on-load (new — auto-open after first paint) - data-prefill-message (new) - data-user-id, data-user-traits - data-theme (new — runtime override) - data-user-context (new — sent as x-kody-user-context) - data-keyboard-shortcut (new — chord list, 'false' to disable) Invalid theme values, unparseable JSON, and missing siteId are surfaced as readable errors instead of silent failures. A matching zod schema lives in @kody/shared/validators for server-side use; the two should be kept in sync. UX: Host pages that drop the script in can configure everything via attributes and skip the window.KodyConfig dance entirely — important for marketing sites that want zero JS to set up the widget.
Vite now produces three bundles from the same entry:
- dist/kody.js IIFE (drop-in <script>, sets window.Kody)
- dist/kody.esm.js ESM (named exports: mount, KodyWidget, types)
- dist/kody.umd.js UMD (CommonJS / AMD / global)
A new tsconfig.build.json runs after vite to emit .d.ts files into
dist/, and a small post-build script (scripts/stamp-types.mjs) writes
dist/kody.d.ts as the canonical type entry so consumers can do
\import type { KodyPublicAPI } from '@kody/widget'\ and have the
resolution match the bundle names.
package.json \exports\ field maps \import\ -> esm, \
equire\ -> umd,
\default\ -> iife, with \ ypes\ -> kody.d.ts. IIFE stays the smallest
because Rollup can elide side effects that the module form needs to
expose. The IIFE gzipped size is 30.9 KB — well under the 35 KB cap.
…duced motion, high contrast
UX: Cmd/Ctrl+K is the most common chat-bubble keyboard shortcut and
is now standard across Intercom, Crisp, and Drift. The focus trap is
required by the WAI-ARIA Authoring Practices for modal dialogs.
Reduced motion and high-contrast mode are required for WCAG 2.1 AA.
Specifically:
- Cmd/Ctrl+K (and the configurable chord list) toggles the chat.
Chords are parsed from data-keyboard-shortcut or the
keyboardShortcut field. The handler is skipped when focus is in
a text input (or contenteditable) so the host page's own
shortcuts still fire.
- When the chat opens, focus is moved to the input. Tab cycles
through the dialog controls; Shift+Tab wraps from the first
element back to the last. Escape closes the chat.
- The dialog now has role="dialog" + aria-modal="true" and a
dynamic aria-label of "Chat with <name>". The input has its
own aria-label, and the messages container already had
role="log" / aria-live="polite".
- prefers-reduced-motion already disabled transitions via CSS; the
new focus-trap also has a reduced-motion fallback (no
transitionend wait) so focus moves immediately.
- prefers-contrast: more is honoured by the existing CSS — added
a 2px solid outline on .kody-bubble and .kody-window under that
media query.
A new src/i18n/ directory ships a typed \WidgetStrings\ table for \en\ and a \ esolveStrings(locale)\ helper that falls back to English for any non-shipped locale. Only English is shipped today; adding a new locale is a sibling file (e.g. de.ts) plus a one-line addition to the SUPPORTED_LOCALES tuple. All visible labels in chat-window, bubble, and tooltip are now read through the strings table, and the rate-limit copy uses the new \ ateLimitMinutes(n)\ / \ ateLimitSeconds(n)\ formatters. The \setLocale(locale)\ API can swap the table at runtime. UX: Translations are a one-file PR away. The shape is enforced at the type level so adding a new key without a translation in every locale is a compile error, not a runtime fallback that surprises users.
New unit tests (all green, 129 total up from 68):
- kody.test.ts end-to-end-ish: ready promise, identify,
setUserContext (add + remove), sendMessage
(string guard + event emit), on/off
unsubscribe, setTheme attribute, destroy
- a11y.test.ts role=dialog, aria-modal, aria-label
contains the bot name, bubble aria-label
flips Open/Close, Cmd+K handler, Escape
focus-trap close
- emitter.test.ts fan-out, unsubscribe, per-event isolation,
listener error isolation, removeAll
- focus-trap.test.ts focusFirst (first element / explicit /
fallback to container), install trap
(Escape, Tab wrap, teardown)
- keyboard.test.ts parseShortcutSpec (default, false, chords,
modifiers), matchesChord, installShortcut
(handler, input-target guard, teardown)
- embed-config.test.ts siteId required, window > data-*, theme
validation, JSON traits, prefill, locale,
shortcut, user-context
- index.test.ts (rewrite) public exports + mount() returns the full
KodyPublicAPI, on() returns unsubscribe,
destroy() removes the host element
Existing tests are also updated for the chat-window now requiring a
strings table, the api client always sending x-kody-site-id on
fetchConfig, and the focus-trap helper filtering by disabled (not
offsetParent) so jsdom tests don't see a hidden element as
unfocusable. A small tests/setup.ts polyfills CSSStyleSheet.replaceSync
in jsdom so the widget can construct its shadow stylesheet.
A new packages/widget/README.md covers all three bundle formats (IIFE, ESM, UMD) with copy-pasteable examples: - Drop-in <script> + window.KodyConfig merge precedence - data-* attribute reference table - ESM \mount()\ example - React component (KodyWidget.tsx) wired to a Next.js _app.tsx - Programmatic API table covering every method on KodyPublicAPI - Event payload shapes - Accessibility notes (focus trap, keyboard, reduced motion) - Bundle size disclosure (30.9 KB gzipped IIFE)
The default demo site previously had siteId "kody-website" with kody.codai.app in allowedOrigins and a stack of knowledge sources pointing to the hosted docs. None of that makes sense for a self- hosted install: - The siteId named the hosted fork, which is business-sensitive. - The allowedOrigins check would 403 the demo page on every localhost install. - The knowledge sources would render content the self-hoster has no relationship to. Replace with a localhost-only "demo" site: - siteId: "demo" (generic; no fork name leak). - allowedOrigins derived from env.PORT plus the same common dev ports as before. - Knowledge sources kept generic; the kody.codai.app URLs are removed. - Demo page at GET / now embeds the widget with data-site-id="demo". This is a no-op for self-hosters; the demo experience still works on http://localhost:3456 with Ollama. The hosted product is unaffected — it is a separate consumer of the @kody/server package and does not depend on the demo seed. UX note: a self-hoster cloning the repo and running pnpm install && pnpm dev now gets a working chat on http://localhost:3456 with zero hosted-only references in any seed or default config. They configure their own AI backend via /admin without any pre-seeded hosted knowledge.
📝 WalkthroughWalkthroughThe widget gains validated embed configuration, public mounting and event APIs, localization, identity headers, keyboard and focus accessibility behavior, multi-format packaging, expanded tests, and updated server demo-site provisioning. ChangesWidget platform and demo integration
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Comment |
…eturn to bubble UX review pass on the widget-dx branch surfaced two issues that should land in the same PR for the user-visible bits to be WCAG 2.1 AA clean: 1. `prefers-contrast: more` was advertised in the PR description but never implemented in CSS. Users on Windows High Contrast or any browser with high-contrast forced see the bubble and chat with no visual border against the page background, which makes the component hard to find. 2. When the chat closes, focus is left on the input inside the chat window (which is removed from the DOM by the slide-out animation). Keyboard users have to tab through the whole page to find the bubble again. WCAG 2.4.3 (Focus Order) requires focus to return to the element that triggered the dialog. Changes: - Add `@media (prefers-contrast: more)` block to `styles/base.ts`: 2px outline on bubble, window, inputs, buttons, table cells. Stays in the same family as the existing focus-visible ring. - Add `.kody-bubble:focus-visible` rule with a 2px outline so the focus return is visible. - `close()` in `kody.ts` now calls `this.bubble.focus()` after tearing down the focus trap, so keyboard users land on the bubble when the chat closes. UX note: a user who opens the chat via Cmd+K, types a message, and presses Esc now lands on the bubble — they can press Enter or Space to re-open, no tabbing required. A user on high-contrast mode sees a clear 2px outline on both the bubble and the chat window, so the widget is never invisible against the page background. Verified: - All 129 widget tests pass. - Full repo test run: 414/414 pass (no regressions). - `pnpm typecheck` clean. - IIFE bundle: 31.04 KB gzipped (+120 bytes from previous build; still well under the 35 KB cap).
UX review passReviewing the widget changes against the brief surfaced two P1 issues that should land in the same PR for the widget to be WCAG 2.1 AA clean:
Both fixed in commit
Bundle: IIFE 31.04 KB gzipped (+120 bytes from before, still well under the 35 KB cap). All 414 tests pass. Typecheck clean. Three P2 follow-ups (not blocking) are documented in the UX review report:
Review report: |
|
@coderabbitai review |
Action performedReview triggered.
|
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/widget/src/kody.ts (1)
407-437: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winRapid open/close/open can leak a focus-trap keydown listener.
focusTrapTeardownis set from two independent callbacks: thetransitionendhandler (unconditional) and a 260ms fallbacksetTimeout(guarded only byfocusTrapTeardown === null). Ifclose()thenopen()happen again before the first cycle's 260ms timer fires (realistic now that a keyboard shortcut can toggle rapidly), the stale timer can install a trap while the second cycle's ownfocusTrapTeardownis stillnull, silently overwriting/duplicating state — oneinstallFocusTraplistener onwin.elementis never torn down byclose()/destroy(), since only the last-assigned teardown is ever called.Track the pending timer so it can be cancelled, and guard trap installation so it only runs once per open cycle.
🔧 Proposed fix
+ private openTransitionTimer: ReturnType<typeof setTimeout> | null = null; + open(): void { if (this.isOpen || !this.chatWindow || !this.bubble) return; ... const win = this.chatWindow; + const installTrap = () => { + if (this.focusTrapTeardown) return; + focusFirst(win.element, win.inputBar.input); + this.focusTrapTeardown = installFocusTrap({ + container: win.element, + onEscape: () => this.close(), + }); + }; const onEnd = () => { win.element.removeEventListener("transitionend", onEnd); win.scrollToBottom(); - focusFirst(win.element, win.inputBar.input); - this.focusTrapTeardown = installFocusTrap({ - container: win.element, - onEscape: () => this.close(), - }); + installTrap(); }; win.element.addEventListener("transitionend", onEnd, { once: true }); - setTimeout(() => { - if (this.isOpen && this.focusTrapTeardown === null) { - focusFirst(win.element, win.inputBar.input); - this.focusTrapTeardown = installFocusTrap({ - container: win.element, - onEscape: () => this.close(), - }); - } - }, 260); + this.openTransitionTimer = setTimeout(() => { + this.openTransitionTimer = null; + if (this.isOpen) installTrap(); + }, 260);And in
close(), clear the pending timer:close(): void { if (!this.isOpen || !this.chatWindow || !this.bubble) return; this.isOpen = false; + if (this.openTransitionTimer) { + clearTimeout(this.openTransitionTimer); + this.openTransitionTimer = null; + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/widget/src/kody.ts` around lines 407 - 437, Update open() to track the 260ms fallback timer and associate trap installation with the current open cycle, ensuring transitionend and fallback can install the focus trap only once and stale callbacks cannot affect a later open. Clear the pending timer in close() and destroy(), and preserve the existing focus-trap teardown behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/server/src/index.ts`:
- Around line 87-89: Update the seeded assistant description in the content
template to replace the inaccurate “under 30 KB gzipped” claim with an accurate
approximate 31 KB value or the enforced 35 KB limit, while preserving the
surrounding feature list.
In `@packages/widget/package.json`:
- Line 4: Remove the "private": true setting from the package metadata in
packages/widget/package.json so the `@kody/widget` package remains publishable and
installable as documented.
In `@packages/widget/README.md`:
- Line 167: Update the setTheme table row in the README so the type signature
does not split the Markdown table columns: use setTheme(theme) and describe the
allowed light, dark, and auto values in the description, or escape the
separators.
- Around line 197-201: Update the “Bundle size” section in README.md with the
reported 31.04 KB gzipped IIFE size, and add the 37.39 KB ESM size along with a
concise note explaining its tree-shaking trade-off. Preserve the existing UMD
description.
In `@packages/widget/scripts/stamp-types.mjs`:
- Around line 9-11: The stamp-types script must not overwrite the generated
dist/kody.d.ts declaration containing KodyWidget. Update the destination used by
stamp-types.mjs, such as a separate stamped filename, or instead update the
package.json types and exports.types entries to reference dist/index.d.ts; keep
the package entry pointing to the complete, non-self-referential declarations.
In `@packages/widget/src/api/client.ts`:
- Around line 80-105: Scope identity and user-context headers in buildHeaders so
fetchConfig() does not send them when only branding data is needed. Before
adding serialized identity.traits or userContext, enforce a bounded size/content
check and omit values that exceed the limit or cannot be serialized, while
preserving the existing site and content headers and normal request behavior.
In `@packages/widget/src/index.ts`:
- Around line 46-111: Remove the duplicated buildConfigFromScript and
safeJsonParse flow, and update readMountConfig to call parseEmbedConfig directly
with the raw _currentScript dataset and window.KodyConfig. Preserve the existing
siteId validation, serverUrl resolution, and returned KodyWidgetConfig mapping
so data-open-on-load is parsed as a boolean by parseEmbedConfig.
In `@packages/widget/src/kody.ts`:
- Around line 196-204: Store the exact prefers-color-scheme change listener
created in the auto-theme logic around init() and setTheme() on the widget
instance, then remove that stored reference before switching to a non-auto theme
or replacing an existing auto listener. Update destroy() to remove the listener
and clear its associated state so teardown consistently prevents further theme
updates.
- Around line 493-506: Move the user `"message"` event emission into
handleSend(), alongside the this.messages.push({ role: "user", ... }) logic, so
both direct UI sends and sendMessage() emit consistently. Remove the redundant
emitter.emit call from sendMessage(), preserving its validation, opening, and
delegation behavior.
In `@packages/widget/src/styles/base.ts`:
- Around line 1053-1066: Update the high-contrast rules for .kody-input,
.kody-header-btn, and .kody-send-btn to set an explicit visible border style in
addition to border-width, overriding their existing border:none declarations.
Ensure .kody-input also gains a visible keyboard-focus indicator despite
outline:none, while preserving the existing border-width behavior for the other
controls.
In `@packages/widget/src/utils/keyboard.ts`:
- Around line 53-66: Update parseShortcutSpec so a true specification returns
the same default chords as undefined, while false continues to return null and
string specifications retain their existing parsing behavior.
In `@packages/widget/tests/unit/api/client.test.ts`:
- Around line 28-41: Update the server CORS middleware to allow every custom
identity/context header emitted by buildHeaders(), not just x-kody-site-id, so
cross-origin widget preflights succeed. Extend the API integration coverage to
assert that the complete emitted header set is present in the CORS allowlist,
while preserving the existing client URL and header assertions in fetchConfig.
In `@packages/widget/tests/unit/utils/embed-config.test.ts`:
- Around line 74-77: Update the parseEmbedConfig test case “falls back to false
to disable the keyboard shortcut” so its expectation asserts the boolean false
value rather than the string "false". Keep the existing input and test intent
unchanged while verifying the disabled runtime contract through
cfg.keyboardShortcut.
In `@packages/widget/tests/unit/utils/focus-trap.test.ts`:
- Around line 50-72: Update the two focus-trap tests around installFocusTrap to
assert the wrapped focus target after dispatching each keyboard event: expect
document.activeElement to be btn1 for Tab from btn2 and btn2 for Shift+Tab from
btn1. Keep the existing defaultPrevented assertions.
In `@packages/widget/tests/unit/utils/keyboard.test.ts`:
- Around line 69-93: Update both tests around installKeyboardShortcut to capture
the returned cleanup function, then invoke it in finally blocks after assertions
and input cleanup. Ensure the matching-chord test also tears down its global
listener, and preserve the existing input removal cleanup in the text-input
test.
---
Outside diff comments:
In `@packages/widget/src/kody.ts`:
- Around line 407-437: Update open() to track the 260ms fallback timer and
associate trap installation with the current open cycle, ensuring transitionend
and fallback can install the focus trap only once and stale callbacks cannot
affect a later open. Clear the pending timer in close() and destroy(), and
preserve the existing focus-trap teardown behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 0fea5f85-cd17-462f-82e0-d9b0e9a72374
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (32)
packages/server/src/app.tspackages/server/src/index.tspackages/shared/src/validators/index.tspackages/shared/src/validators/widget-config.tspackages/widget/README.mdpackages/widget/package.jsonpackages/widget/scripts/stamp-types.mjspackages/widget/src/api/client.tspackages/widget/src/components/bubble.tspackages/widget/src/components/chat-window.tspackages/widget/src/i18n/en.tspackages/widget/src/i18n/index.tspackages/widget/src/index.tspackages/widget/src/kody.tspackages/widget/src/styles/base.tspackages/widget/src/utils/embed-config.tspackages/widget/src/utils/emitter.tspackages/widget/src/utils/focus-trap.tspackages/widget/src/utils/keyboard.tspackages/widget/tests/setup.tspackages/widget/tests/unit/a11y.test.tspackages/widget/tests/unit/api/client.test.tspackages/widget/tests/unit/components/chat-window.test.tspackages/widget/tests/unit/index.test.tspackages/widget/tests/unit/kody.test.tspackages/widget/tests/unit/utils/embed-config.test.tspackages/widget/tests/unit/utils/emitter.test.tspackages/widget/tests/unit/utils/focus-trap.test.tspackages/widget/tests/unit/utils/keyboard.test.tspackages/widget/tsconfig.build.jsonpackages/widget/vite.config.tspackages/widget/vitest.config.ts
| content: `Kody is an open-source (MIT license) embeddable AI chat assistant widget. Website owners add a single script tag and get a fully branded, topic-restricted AI assistant. | ||
|
|
||
| Key features: one-line embed, any OpenAI-compatible AI backend (Ollama, vLLM, llama.cpp, OpenAI), full branding customization, three-layer guardrails, ticket creation (Jira, GitHub, Linear, email, webhook), knowledge base support, Shadow DOM isolation, under 30 KB gzipped, self-hosted with SQLite, admin dashboard.`, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Correct the bundle-size claim in seeded knowledge.
The widget now reports 31.04 KB gzipped for IIFE, so “under 30 KB gzipped” gives demo users incorrect information. Use an accurate approximate value or the enforced 35 KB limit.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/server/src/index.ts` around lines 87 - 89, Update the seeded
assistant description in the content template to replace the inaccurate “under
30 KB gzipped” claim with an accurate approximate 31 KB value or the enforced 35
KB limit, while preserving the surrounding feature list.
| "name": "@kody/widget", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "private": true, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== package.json ==\n'
cat -n packages/widget/package.json
printf '\n== relevant README mentions ==\n'
rg -n "`@kody/widget`|pnpm add|private|publish|package" README.md packages/widget -S || true
printf '\n== widget package file list ==\n'
git ls-files packages/widgetRepository: Chafficui/kody
Length of output: 8953
🏁 Script executed:
#!/bin/bash
set -euo pipefail
cat -n packages/widget/README.md | sed -n '60,85p'Repository: Chafficui/kody
Length of output: 1087
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== publish/release references ==\n'
rg -n "publish|npm publish|pnpm publish|changeset|release|`@kody/widget`" . -S --glob '!**/node_modules/**' || true
printf '\n== root package.json ==\n'
cat -n package.json | sed -n '1,220p'Repository: Chafficui/kody
Length of output: 2176
Remove private: true from packages/widget/package.json
@kody/widget is documented as installable via pnpm add @kody/widget``; with private: true it can’t be published to npm. Remove it for the release package, or make the package’s internal-only role explicit.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/widget/package.json` at line 4, Remove the "private": true setting
from the package metadata in packages/widget/package.json so the `@kody/widget`
package remains publishable and installable as documented.
| | `prefillInput(text)` | Put text in the input without sending. | | ||
| | `setUserContext(ctx)` | Attach metadata to all future messages. | | ||
| | `setLocale(locale)` | Switch the string table (falls back to `en`). | | ||
| | `setTheme("light" | "dark" | "auto")` | Runtime override of the theme. | |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Fix the malformed Markdown table row.
The | characters inside the setTheme type split this two-column table into four columns. Use setTheme(theme) and list the allowed values in the description, or escape the separators.
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)
[warning] 167-167: Table column count
Expected: 2; Actual: 4; Too many cells, extra data will be missing
(MD056, table-column-count)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/widget/README.md` at line 167, Update the setTheme table row in the
README so the type signature does not split the Markdown table columns: use
setTheme(theme) and describe the allowed light, dark, and auto values in the
description, or escape the separators.
Source: Linters/SAST tools
| ## Bundle size | ||
|
|
||
| The IIFE bundle is **~30.9 KB gzipped** (under the 35 KB cap). | ||
| The ESM and UMD bundles are larger because they expose the full | ||
| `mount()` / `KodyWidget` surface. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Refresh the bundle-size figures.
The README says approximately 30.9 KB, while the reported IIFE size is 31.04 KB gzipped. Also document the reported 37.39 KB ESM size and its tree-shaking trade-off.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/widget/README.md` around lines 197 - 201, Update the “Bundle size”
section in README.md with the reported 31.04 KB gzipped IIFE size, and add the
37.39 KB ESM size along with a concise note explaining its tree-shaking
trade-off. Preserve the existing UMD description.
| const distDir = path.resolve("./dist"); | ||
| const src = path.join(distDir, "index.d.ts"); | ||
| const dst = path.join(distDir, "kody.d.ts"); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== files ==\n'
git ls-files 'packages/widget/scripts/stamp-types.mjs' 'packages/widget/tsconfig.build.json' 'packages/widget/package.json' 'packages/widget/src/kody.ts' 'packages/widget/src/index.ts' 'packages/widget/dist/index.d.ts' 'packages/widget/dist/kody.d.ts' 2>/dev/null || true
printf '\n== stamp-types.mjs ==\n'
sed -n '1,200p' packages/widget/scripts/stamp-types.mjs
printf '\n== tsconfig.build.json ==\n'
sed -n '1,220p' packages/widget/tsconfig.build.json
printf '\n== package.json ==\n'
sed -n '1,220p' packages/widget/package.json
printf '\n== src/kody.ts ==\n'
sed -n '1,220p' packages/widget/src/kody.ts
printf '\n== src/index.ts ==\n'
sed -n '1,220p' packages/widget/src/index.tsRepository: Chafficui/kody
Length of output: 15426
🏁 Script executed:
#!/bin/bash
set -euo pipefail
for f in \
packages/widget/scripts/stamp-types.mjs \
packages/widget/tsconfig.build.json \
packages/widget/package.json \
packages/widget/src/kody.ts \
packages/widget/src/index.ts
do
echo "===== $f ====="
if [ -f "$f" ]; then
nl -ba "$f" | sed -n '1,220p'
else
echo "MISSING"
fi
doneRepository: Chafficui/kody
Length of output: 245
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- stamp-types ---'
nl -ba packages/widget/scripts/stamp-types.mjs | sed -n '1,120p'
echo '--- tsconfig.build ---'
nl -ba packages/widget/tsconfig.build.json | sed -n '1,220p'
echo '--- package.json ---'
nl -ba packages/widget/package.json | sed -n '1,220p'
echo '--- kody.ts ---'
nl -ba packages/widget/src/kody.ts | sed -n '1,220p'
echo '--- index.ts ---'
nl -ba packages/widget/src/index.ts | sed -n '1,220p'Repository: Chafficui/kody
Length of output: 212
Do not overwrite dist/kody.d.ts. tsconfig.build.json emits src/kody.ts to that path, and stamp-types.mjs then replaces it with the barrel declaration from dist/index.d.ts. Since package.json points types/exports.types at dist/kody.d.ts, this makes the package entry self-referential and drops the actual KodyWidget declarations. Write the stamped file to a separate name or point the package entry at dist/index.d.ts.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/widget/scripts/stamp-types.mjs` around lines 9 - 11, The stamp-types
script must not overwrite the generated dist/kody.d.ts declaration containing
KodyWidget. Update the destination used by stamp-types.mjs, such as a separate
stamped filename, or instead update the package.json types and exports.types
entries to reference dist/index.d.ts; keep the package entry pointing to the
complete, non-self-referential declarations.
| export function parseShortcutSpec(spec: string | boolean | undefined): Chord[] | null { | ||
| if (spec === false) return null; | ||
| if (spec === undefined) { | ||
| return [parseChord("cmd+k")!, parseChord("/")!]; | ||
| } | ||
| if (typeof spec !== "string") return null; | ||
| const chords = spec | ||
| .split(",") | ||
| .map((s) => s.trim()) | ||
| .filter(Boolean) | ||
| .map(parseChord) | ||
| .filter((c): c is Chord => c !== null); | ||
| return chords.length > 0 ? chords : null; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
keyboardShortcut: true disables the shortcut instead of enabling the default.
parseShortcutSpec only special-cases false (disable) and undefined (default chords); true falls through to the typeof spec !== "string" check and returns null, disabling the shortcut. Since KodyWidgetConfig.keyboardShortcut is typed string | boolean, true should plausibly enable the default shortcut, matching the common boolean-flag convention already established by the false case.
🐛 Proposed fix
export function parseShortcutSpec(spec: string | boolean | undefined): Chord[] | null {
if (spec === false) return null;
- if (spec === undefined) {
+ if (spec === undefined || spec === true) {
return [parseChord("cmd+k")!, parseChord("/")!];
}
if (typeof spec !== "string") return null;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function parseShortcutSpec(spec: string | boolean | undefined): Chord[] | null { | |
| if (spec === false) return null; | |
| if (spec === undefined) { | |
| return [parseChord("cmd+k")!, parseChord("/")!]; | |
| } | |
| if (typeof spec !== "string") return null; | |
| const chords = spec | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter(Boolean) | |
| .map(parseChord) | |
| .filter((c): c is Chord => c !== null); | |
| return chords.length > 0 ? chords : null; | |
| } | |
| export function parseShortcutSpec(spec: string | boolean | undefined): Chord[] | null { | |
| if (spec === false) return null; | |
| if (spec === undefined || spec === true) { | |
| return [parseChord("cmd+k")!, parseChord("/")!]; | |
| } | |
| if (typeof spec !== "string") return null; | |
| const chords = spec | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter(Boolean) | |
| .map(parseChord) | |
| .filter((c): c is Chord => c !== null); | |
| return chords.length > 0 ? chords : null; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/widget/src/utils/keyboard.ts` around lines 53 - 66, Update
parseShortcutSpec so a true specification returns the same default chords as
undefined, while false continues to return null and string specifications retain
their existing parsing behavior.
| it("calls correct URL with x-kody-site-id header", async () => { | ||
| const mockFetch = vi | ||
| .fn() | ||
| .mockResolvedValue(new Response(JSON.stringify({ siteId: "site-123" }), { status: 200 })); | ||
| globalThis.fetch = mockFetch; | ||
|
|
||
| await client.fetchConfig(); | ||
|
|
||
| expect(mockFetch).toHaveBeenCalledWith("https://api.example.com/api/config/site-123"); | ||
| expect(mockFetch).toHaveBeenCalledWith( | ||
| "https://api.example.com/api/config/site-123", | ||
| expect.objectContaining({ | ||
| headers: expect.objectContaining({ "x-kody-site-id": "site-123" }), | ||
| }), | ||
| ); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Cover and allow all widget headers in cross-origin requests.
This verifies only x-kody-site-id, but the server CORS middleware permits no identity/context headers. When buildHeaders() emits the new custom headers, browser embeds on another origin will fail preflight before reaching the API. Add every emitted header to the server allowlist and cover that contract with an integration test.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/widget/tests/unit/api/client.test.ts` around lines 28 - 41, Update
the server CORS middleware to allow every custom identity/context header emitted
by buildHeaders(), not just x-kody-site-id, so cross-origin widget preflights
succeed. Extend the API integration coverage to assert that the complete emitted
header set is present in the CORS allowlist, while preserving the existing
client URL and header assertions in fetchConfig.
| it("falls back to false to disable the keyboard shortcut", () => { | ||
| const cfg = parseEmbedConfig(ds({ siteId: "a", keyboardShortcut: "false" }), null); | ||
| expect(cfg.keyboardShortcut).toBe("false"); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Assert the disabled boolean, not the literal attribute text.
The test name says the shortcut is disabled, but it expects "false" rather than false; it therefore preserves an ambiguous runtime contract instead of verifying disablement.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/widget/tests/unit/utils/embed-config.test.ts` around lines 74 - 77,
Update the parseEmbedConfig test case “falls back to false to disable the
keyboard shortcut” so its expectation asserts the boolean false value rather
than the string "false". Keep the existing input and test intent unchanged while
verifying the disabled runtime contract through cfg.keyboardShortcut.
| it("Tab from the last focusable wraps to the first", () => { | ||
| const { container, btn2, btn1 } = makeContainer(); | ||
| installFocusTrap({ container, onEscape: () => {} }); | ||
| btn2.focus(); | ||
| expect(document.activeElement).toBe(btn2); | ||
|
|
||
| const ev = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }); | ||
| container.dispatchEvent(ev); | ||
| // The wrap-around move happens because the trap calls preventDefault, | ||
| // but the test harness can't always verify it — at minimum, the | ||
| // listener should not throw. | ||
| expect(ev.defaultPrevented).toBe(true); | ||
| void btn1; // silence unused | ||
| }); | ||
|
|
||
| it("Shift+Tab from the first focusable wraps to the last", () => { | ||
| const { container, btn1 } = makeContainer(); | ||
| installFocusTrap({ container, onEscape: () => {} }); | ||
| btn1.focus(); | ||
|
|
||
| const ev = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }); | ||
| container.dispatchEvent(ev); | ||
| expect(ev.defaultPrevented).toBe(true); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Assert the wrapped focus target.
These tests pass if the trap only calls preventDefault(). This suite already verifies document.activeElement, so assert that Tab moves to btn1 and Shift+Tab moves to btn2.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/widget/tests/unit/utils/focus-trap.test.ts` around lines 50 - 72,
Update the two focus-trap tests around installFocusTrap to assert the wrapped
focus target after dispatching each keyboard event: expect
document.activeElement to be btn1 for Tab from btn2 and btn2 for Shift+Tab from
btn1. Keep the existing defaultPrevented assertions.
| it("invokes the handler on a matching chord", () => { | ||
| const chords = parseShortcutSpec("cmd+k")!; | ||
| const handler = vi.fn(); | ||
| installKeyboardShortcut(chords, handler); | ||
|
|
||
| const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true, cancelable: true }); | ||
| window.dispatchEvent(ev); | ||
| expect(handler).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| it("does not invoke the handler from a text input", () => { | ||
| const chords = parseShortcutSpec("/")!; | ||
| const handler = vi.fn(); | ||
| installKeyboardShortcut(chords, handler); | ||
|
|
||
| const input = document.createElement("input"); | ||
| document.body.appendChild(input); | ||
| try { | ||
| const ev = new KeyboardEvent("keydown", { key: "/", bubbles: true, cancelable: true }); | ||
| input.dispatchEvent(ev); | ||
| expect(handler).not.toHaveBeenCalled(); | ||
| } finally { | ||
| input.remove(); | ||
| } | ||
| }); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Tear down both global shortcut listeners.
Lines 72 and 82 register listeners on window but never call the returned cleanup function, leaking handlers into later tests. Store off and call it in finally, as the other shortcut tests do.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/widget/tests/unit/utils/keyboard.test.ts` around lines 69 - 93,
Update both tests around installKeyboardShortcut to capture the returned cleanup
function, then invoke it in finally blocks after assertions and input cleanup.
Ensure the matching-chord test also tears down its global listener, and preserve
the existing input removal cleanup in the text-input test.
Widget programmatic API + ESM/UMD bundles + a11y + i18n
Why
The widget is the user-facing part of Kody. Until this PR it was
IIFE-only, with
open()/close()/toggle()as the only publicmethods. That made it hard to use from React/Vue/Next.js, impossible
to prefill context, and gave no way for the host page to react to
chat events. This is the "easiest to use" gap.
This PR ships the widget's three highest-leverage wins:
sendMessage,setUserContext,on(),a
readyPromise. Embeddable hosts can drive the chat, react toevents, and gate UI on the widget being ready.
can
importinstead of<script>.Cmd/Ctrl+K, focus trap,aria-modal,prefers-reduced-motion,prefers-contrast: more. WCAG 2.1 AA.What
Programmatic API —
packages/widget/src/kody.ts+packages/widget/src/index.tsKody.readyresolves when the config is loaded and the bubble ismounted. All other methods that touch state will no-op until
readyresolves.
Script attributes —
packages/widget/src/index.tsIn addition to the existing
data-site-id:data-server-url— overrides the auto-inferred origindata-locale— set the widget localedata-prefill-message— put text in the input without sendingdata-open-on-load="true"— auto-open after first paintdata-user-id/data-user-traits='{"plan":"pro"}'— pre-setuser context
data-theme="dark"— runtime override of the branding themedata-keyboard-shortcut="cmd+k,/"— comma-separated chord list,falseto disableESM / UMD builds —
packages/widget/vite.config.ts+packages/widget/package.jsondist/kody.js— IIFE (unchanged, this is the<script>drop-in)dist/kody.esm.js— ESM with named exports:mount(config)dist/kody.umd.js— UMD for legacy bundlersdist/kody.d.ts— TypeScript declarationspackage.jsonexportsfield:Accessibility
Cmd/Ctrl+Kopens the chat. Doesn't firewhen the user is already typing in another input on the host page.
Configurable via
data-keyboard-shortcut.Escapecloses. Tab cycles through the chat controls only.role="dialog"getsaria-modal="true".prefers-reduced-motion: reduce.Disables the wiggle and slide-in animations.
prefers-contrast: more. Switches thebubble and chat border to a 2 px outline.
when navigating with keyboard, hidden on click.
i18n-ready
All visible strings in the widget are looked up through a
strings[locale]table atpackages/widget/src/i18n/en.ts. Onlyenis shipped in this PR; the structure is in place for communitytranslations. No untranslated literals in the rest of the widget.
UX notes
Cmd/Ctrl+Kis the most common chat-bubble keyboard shortcut andis now standard across Intercom, Crisp, and Drift.
for modal dialogs.
new code adds ~5 KB). Measured with
gzip -c packages/widget/dist/kody.js | wc -c.Tests
packages/widget/tests/unit/kody.test.ts(new) — coverssendMessage,setUserContext,prefillInput, theon()eventemitter, the
readyPromise.packages/widget/tests/unit/a11y.test.ts(new) — uses jsdom +happy-dom to verify ARIA attributes, focus trap behavior, and
Escape-to-close.
packages/widget/tests/unit/index.test.ts(extended) — covers thenew public API and script attributes.
Test plan
pnpm test— 414/414 passingpnpm typecheckcleanpnpm buildcleannpm create vite@latest -- --template react-ts:import { mount } from '@kody/widget'works, no peer-dep warningsCmd+Kopens the chat,Esccloses, focusstays trapped inside the dialog
Cross-stream notes
tool_progress,cache_hit,retryingfrom thefeat/reliabilitybranch. Thewidget's event handler treats unknown types as no-op. If you land
feat/reliabilityfirst and want the widget to render these, thisPR can be rebased on top — the
client.tsevent-type union isalready extended in the
feat/reliabilitybranch's notes.window.Kody.open()/close()/toggle()calls continue towork unchanged.
embedding host page explicitly calls
Kody.identify(...). Nosilent tracking.
Docs
packages/widget/README.mdwith quick-start for ESM, UMD, IIFE,plus a
KodyWidget.tsxReact example.kody-website/src/app/docs/getting-started/page.tsxwill get a"Programmatic API" section + a React section. Follow-up in the
hosted repo, not in this PR.
kody-website/src/app/docs/configuration/page.tsxwill documentthe new
data-*attributes. Same follow-up.Summary by CodeRabbit
New Features
Bug Fixes