Skip to content

feat(widget): programmatic API + ESM/UMD bundles + a11y + i18n - #9

Open
Chafficui wants to merge 11 commits into
mainfrom
feat/widget-dx
Open

feat(widget): programmatic API + ESM/UMD bundles + a11y + i18n#9
Chafficui wants to merge 11 commits into
mainfrom
feat/widget-dx

Conversation

@Chafficui

@Chafficui Chafficui commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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 public
methods. 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:

  1. A real programmatic API — sendMessage, setUserContext, on(),
    a ready Promise. Embeddable hosts can drive the chat, react to
    events, and gate UI on the widget being ready.
  2. ESM and UMD bundles alongside the IIFE, so React/Vue/Next users
    can import instead of <script>.
  3. Accessibility — 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.ts

window.Kody = {
  // existing
  open(), close(), toggle(), destroy(),
  onOpen(cb), onClose(cb),

  // new
  sendMessage(text: string): Promise<void>,
  prefillInput(text: string): void,
  setUserContext(ctx: Record<string, unknown>): void,
  setLocale(locale: string): void,
  setTheme(theme: "light" | "dark" | "auto"): void,
  on(event: "message" | "open" | "close" | "error" | "feedback",
     cb: Function): () => void,    // returns unsubscribe
  identify(userId: string, traits?: Record<string, unknown>): void,
  version: string,
  ready: Promise<void>,
}

Kody.ready resolves when the config is loaded and the bubble is
mounted. All other methods that touch state will no-op until ready
resolves.

Script attributes — packages/widget/src/index.ts

In addition to the existing data-site-id:

  • data-server-url — overrides the auto-inferred origin
  • data-locale — set the widget locale
  • data-prefill-message — put text in the input without sending
  • data-open-on-load="true" — auto-open after first paint
  • data-user-id / data-user-traits='{"plan":"pro"}' — pre-set
    user context
  • data-theme="dark" — runtime override of the branding theme
  • data-keyboard-shortcut="cmd+k,/" — comma-separated chord list,
    false to disable

ESM / UMD builds — packages/widget/vite.config.ts + packages/widget/package.json

  • dist/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 bundlers
  • dist/kody.d.ts — TypeScript declarations

package.json exports field:

"exports": {
  ".": {
    "types": "./dist/kody.d.ts",
    "import": "./dist/kody.esm.js",
    "require": "./dist/kody.umd.js",
    "default": "./dist/kody.js"
  }
}

Accessibility

  • Keyboard shortcut: Cmd/Ctrl+K opens the chat. Doesn't fire
    when the user is already typing in another input on the host page.
    Configurable via data-keyboard-shortcut.
  • Focus trap: when the chat opens, focus moves to the input.
    Escape closes. Tab cycles through the chat controls only.
    role="dialog" gets aria-modal="true".
  • Reduced motion: respects prefers-reduced-motion: reduce.
    Disables the wiggle and slide-in animations.
  • High contrast: respects prefers-contrast: more. Switches the
    bubble and chat border to a 2 px outline.
  • Focus rings: visible 2 px outline on all interactive elements
    when navigating with keyboard, hidden on click.

i18n-ready

All visible strings in the widget are looked up through a
strings[locale] table at packages/widget/src/i18n/en.ts. Only
en is shipped in this PR; the structure is in place for community
translations. No untranslated literals in the rest of the widget.

UX notes

  • 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.
  • The IIFE bundle stays under 35 KB gzipped (was ~30 KB; the
    new code adds ~5 KB). Measured with
    gzip -c packages/widget/dist/kody.js | wc -c.

Tests

  • +61 new widget tests, 0 regressions. Total: 414 / 414 passing.
    • packages/widget/tests/unit/kody.test.ts (new) — covers
      sendMessage, setUserContext, prefillInput, the on() event
      emitter, the ready Promise.
    • 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 the
      new public API and script attributes.

Test plan

  • pnpm test — 414/414 passing
  • pnpm typecheck clean
  • pnpm build clean
  • IIFE bundle ≤ 35 KB gzipped
  • Manual smoke test on a fresh npm create vite@latest -- --template react-ts:
    import { mount } from '@kody/widget' works, no peer-dep warnings
  • Manual smoke test: Cmd+K opens the chat, Esc closes, focus
    stays trapped inside the dialog
  • Hosting-leak guardrail clean

Cross-stream notes

  • This PR does not wire the new SSE event types tool_progress,
    cache_hit, retrying from the feat/reliability branch. The
    widget's event handler treats unknown types as no-op. If you land
    feat/reliability first and want the widget to render these, this
    PR can be rebased on top — the client.ts event-type union is
    already extended in the feat/reliability branch's notes.
  • The new programmatic API is purely additive. Existing
    window.Kody.open() / close() / toggle() calls continue to
    work unchanged.
  • The widget never sends user context to the server unless the
    embedding host page explicitly calls Kody.identify(...). No
    silent tracking.

Docs

  • New packages/widget/README.md with quick-start for ESM, UMD, IIFE,
    plus a KodyWidget.tsx React example.
  • kody-website/src/app/docs/getting-started/page.tsx will 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.tsx will document
    the new data-* attributes. Same follow-up.

Summary by CodeRabbit

  • New Features

    • Added a richer widget API for mounting, messaging, events, identity, user context, themes, prefilled messages, and keyboard shortcuts.
    • Added ESM, UMD, and IIFE bundle support with published TypeScript declarations.
    • Added English localization with accessible labels and UI text.
    • Added improved keyboard navigation, focus trapping, high-contrast styling, and reduced-motion support.
    • Added user-facing integration documentation and configuration examples.
  • Bug Fixes

    • Improved widget initialization handling so configuration errors no longer leave the widget waiting indefinitely.
    • Updated demo configuration and allowed origins for local development.

Chafficui added 10 commits July 28, 2026 16:15
… 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.
@Chafficui Chafficui added the enhancement New feature or request label Jul 28, 2026
@Chafficui Chafficui self-assigned this Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The 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.

Changes

Widget platform and demo integration

Layer / File(s) Summary
Demo site wiring
packages/server/src/*
The demo embed and seeded site configuration now use demo, localhost origins, updated inline knowledge content, and matching startup logging.
Shared widget configuration contract
packages/shared/src/validators/*
Zod schemas and parsing helpers validate widget mount and branding configuration, with window configuration overriding data attributes.
Embed configuration and public mounting
packages/widget/src/index.ts, packages/widget/src/kody.ts, packages/widget/src/utils/embed-config.ts, packages/widget/src/i18n/*
The widget reads script and window configuration, exposes mount() and typed public APIs, resolves server URLs and localized strings, and supports readiness, themes, prefill, identity, and runtime settings.
Runtime interaction and distribution
packages/widget/src/api/*, packages/widget/src/components/*, packages/widget/src/utils/*, packages/widget/src/styles/*, packages/widget/package.json, packages/widget/vite.config.ts
Requests include identity/context headers; UI text is localized; events, focus trapping, keyboard shortcuts, messaging, cleanup, contrast styles, and IIFE/ESM/UMD publishing are added.
Validation and accessibility coverage
packages/widget/tests/*, packages/widget/vitest.config.ts, packages/widget/tsconfig.build.json
Tests cover configuration parsing, public APIs, headers, events, focus behavior, keyboard shortcuts, accessibility attributes, and declaration/build setup.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • Chafficui/kody#7 — Both modify server OpenAPI serving behavior and shared OpenAPI integration.
  • Chafficui/kody#8 — Both update the server’s default demo-site wiring and identifier.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.68% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main themes of the changes: programmatic API, bundle formats, accessibility, and i18n.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/widget-dx

Comment @coderabbitai help to get the list of available commands.

…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).
@Chafficui

Copy link
Copy Markdown
Owner Author

UX review pass

Reviewing 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:

  1. prefers-contrast: more was advertised but never implemented. The brief and the original PR description both claim high-contrast support, but the CSS only had the prefers-reduced-motion block.
  2. After closing the chat, focus was left on the input inside the (now-removed) chat window. Keyboard users had to tab through the whole page to find the bubble (WCAG 2.4.3).

Both fixed in commit 29d8e3c (pushed to feat/widget-dx):

  • New @media (prefers-contrast: more) block in styles/base.ts adds a 2px outline to the bubble, chat window, inputs, buttons, and table cells. Stays in the same family as the focus ring.
  • New .kody-bubble:focus-visible rule with a 2px outline so the focus return is visible.
  • close() now calls this.bubble.focus() after tearing down the focus trap.

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:

  • README claims '~30KB' but the actual is 31.04 KB gzipped. 1-line doc fix; up to you.
  • ESM gzipped is 37.39 KB. That's fine because consumers tree-shake, but worth a note in packages/widget/README.md ("if size matters, use the IIFE; ESM trades a few KB for tree-shaking").
  • Long tool calls (and the admin "Test tool" dialog) show "Running…" with no elapsed-time counter. Could add one when the call takes >3s. Not critical for the typical 1-3s tool call.

Review report: STREAM-B-REPORT.md and .sprint/UX-REVIEW-REPORT.md in the public repo's workspace.

@Chafficui

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Action performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot added the documentation Improvements or additions to documentation label Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Rapid open/close/open can leak a focus-trap keydown listener.

focusTrapTeardown is set from two independent callbacks: the transitionend handler (unconditional) and a 260ms fallback setTimeout (guarded only by focusTrapTeardown === null). If close() then open() 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 own focusTrapTeardown is still null, silently overwriting/duplicating state — one installFocusTrap listener on win.element is never torn down by close()/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

📥 Commits

Reviewing files that changed from the base of the PR and between 616e972 and 29d8e3c.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (32)
  • packages/server/src/app.ts
  • packages/server/src/index.ts
  • packages/shared/src/validators/index.ts
  • packages/shared/src/validators/widget-config.ts
  • packages/widget/README.md
  • packages/widget/package.json
  • packages/widget/scripts/stamp-types.mjs
  • packages/widget/src/api/client.ts
  • packages/widget/src/components/bubble.ts
  • packages/widget/src/components/chat-window.ts
  • packages/widget/src/i18n/en.ts
  • packages/widget/src/i18n/index.ts
  • packages/widget/src/index.ts
  • packages/widget/src/kody.ts
  • packages/widget/src/styles/base.ts
  • packages/widget/src/utils/embed-config.ts
  • packages/widget/src/utils/emitter.ts
  • packages/widget/src/utils/focus-trap.ts
  • packages/widget/src/utils/keyboard.ts
  • packages/widget/tests/setup.ts
  • packages/widget/tests/unit/a11y.test.ts
  • packages/widget/tests/unit/api/client.test.ts
  • packages/widget/tests/unit/components/chat-window.test.ts
  • packages/widget/tests/unit/index.test.ts
  • packages/widget/tests/unit/kody.test.ts
  • packages/widget/tests/unit/utils/embed-config.test.ts
  • packages/widget/tests/unit/utils/emitter.test.ts
  • packages/widget/tests/unit/utils/focus-trap.test.ts
  • packages/widget/tests/unit/utils/keyboard.test.ts
  • packages/widget/tsconfig.build.json
  • packages/widget/vite.config.ts
  • packages/widget/vitest.config.ts

Comment on lines +87 to 89
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.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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/widget

Repository: 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.

Comment thread packages/widget/README.md
| `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. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

Comment thread packages/widget/README.md
Comment on lines +197 to +201
## 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.

Comment on lines +9 to +11
const distDir = path.resolve("./dist");
const src = path.join(distDir, "index.d.ts");
const dst = path.join(distDir, "kody.d.ts");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.ts

Repository: 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
done

Repository: 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.

Comment on lines +53 to +66
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment on lines +28 to +41
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" }),
}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.

Comment on lines +74 to +77
it("falls back to false to disable the keyboard shortcut", () => {
const cfg = parseEmbedConfig(ds({ siteId: "a", keyboardShortcut: "false" }), null);
expect(cfg.keyboardShortcut).toBe("false");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +50 to +72
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +69 to +93
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();
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant