chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
# @copilotkit/web-components
Framework-agnostic, shadow-DOM [Lit](https://lit.dev) custom elements for CopilotKit.
This package currently ships **`<copilotkit-threads-drawer>`** — a public, self-contained,
controlled threads drawer. It is a pure **VIEW**: domain data flows in as
properties and user intent flows out as DOM `CustomEvent`s. It imports no React,
Angular, or `@copilotkit/core` code and renders correctly in any host page,
surviving hostile host CSS (`all: unset`, Tailwind preflight `!important`).
## Install
```bash
npm install @copilotkit/web-components lit
```
`lit` is a peer/runtime dependency so the host dedupes a single Lit runtime.
## Usage
```ts
import { defineCopilotKitThreadsDrawer } from "@copilotkit/web-components/threads-drawer";
defineCopilotKitThreadsDrawer(); // registers <copilotkit-threads-drawer> (idempotent)
```
```html
<copilotkit-threads-drawer active-thread-id="t-123"></copilotkit-threads-drawer>
<script>
const drawer = document.querySelector("copilotkit-threads-drawer");
drawer.threads = [
{
id: "t-123",
name: "My thread",
archived: false,
createdAt: "...",
updatedAt: "...",
},
];
drawer.addEventListener("thread-selected", (e) => open(e.detail.threadId));
drawer.addEventListener("new-thread", () => createThread());
</script>
```
### Inbound properties (DOMAIN state — owned by the consumer)
| property | type | notes |
| ---------------- | ---------------- | ----------------------------------------------- |
| `threads` | `DrawerThread[]` | the element re-orders + filters authoritatively |
| `loading` | `boolean` | initial-fetch loading |
| `error` | `string \| null` | initial-fetch error → actionable Retry |
| `activeThreadId` | `string \| null` | drives selection highlight |
| `licensed` | `boolean` | `false` → upsell replaces the list |
| `fetchingMore` | `boolean` | in-flight pagination |
| `fetchMoreError` | `string \| null` | inline "couldn't load more — retry" |
| `open` | `boolean` | externally controllable (mobile coordination) |
| `collapsed` | `boolean` | desktop collapse-to-rail |
### Outbound events (INTENT — bubbling + composed `CustomEvent`s)
`thread-selected`, `archive`, `unarchive`, `delete` (after in-element confirm),
`new-thread`, `filter-change`, `open-change`, `retry` (`{ scope }`), `upsell`.
The element owns VIEW state: open/collapsed, the Active/All filter, the
confirm-delete dialog, and per-row entry/reveal animations.
## Theming (hybrid)
- **CSS variables** pierce the shadow boundary — set `--cpk-drawer-*`
(`-bg`, `-fg`, `-surface`, `-accent`, `-primary`, `-danger`, `-border`,
`-ring`, `-radius`, `-width`, `-font-family`, …) from the host.
- **`::part()`** hooks on structural nodes (`root`, `header`, `list`, `row`,
`row-name`, `confirm-dialog`, `backdrop`, …).
- **Named slots**: `header`, `footer`, `empty`, `upsell`, `memories`, plus a
**per-row slot** `row:{id}` that projects wrapper-provided row content while
the element keeps the selection/archived/animation chrome around it.
The built-in default skin's token values are **derived at build time** from
react-core's canonical theme (`packages/react-core/src/v2/styles/globals.css`)
by `scripts/generate-tokens.ts`, which writes the checked-in
`src/threads-drawer/generated-tokens.ts`. Run `pnpm run gen:tokens` to regenerate;
`generated-tokens.test.ts` fails if the checked-in values drift from react-core.
## Mobile + a11y
At/below `768px` the drawer is an off-canvas modal overlay with backdrop,
`Escape` close, scroll-lock, and a focus trap (mobile only) that operates over
the composed/flattened tree so slotted rows are included. On desktop it is an
in-flow region with collapse-to-rail — **not** a modal (no focus trap, no
scroll-lock).
+85
View File
@@ -0,0 +1,85 @@
{
"name": "@copilotkit/web-components",
"version": "1.62.3",
"private": false,
"description": "Framework-agnostic shadow-DOM web components for CopilotKit - includes the <copilotkit-threads-drawer> threads drawer element",
"keywords": [
"ai",
"copilotkit",
"custom-elements",
"drawer",
"lit",
"shadow-dom",
"threads",
"web-components"
],
"homepage": "https://github.com/CopilotKit/CopilotKit",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/CopilotKit/CopilotKit.git",
"directory": "packages/web-components"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.cts",
"unpkg": "./dist/index.umd.js",
"jsdelivr": "./dist/index.umd.js",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./threads-drawer": {
"import": {
"types": "./dist/threads-drawer/index.d.mts",
"default": "./dist/threads-drawer/index.mjs"
},
"require": {
"types": "./dist/threads-drawer/index.d.cts",
"default": "./dist/threads-drawer/index.cjs"
}
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "pnpm run gen:tokens && tsdown",
"gen:tokens": "tsx scripts/generate-tokens.ts",
"compat-check": "es-check es2022 --module 'dist/**/!(*.umd).{mjs,cjs,js}' && es-check es2018 'dist/**/*.umd.js'",
"check-types": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"publint": "publint .",
"attw": "attw --pack . --profile node16"
},
"peerDependencies": {
"lit": "^3.3.2"
},
"devDependencies": {
"@copilotkit/typescript-config": "workspace:*",
"@types/node": "^22.15.3",
"jsdom": "^26.1.0",
"lit": "^3.3.2",
"tsdown": "^0.20.3",
"tsx": "^4.19.2",
"typescript": "5.9.2",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=18"
}
}
@@ -0,0 +1,121 @@
/**
* Build-time token sync (anti-drift).
*
* `packages/react-core/src/v2/styles/globals.css` is the canonical source of
* truth for CopilotKit's visual design tokens. This script reads the light-mode
* `[data-copilotkit] { ... }` token block from that file and DERIVES a small,
* curated subset of bundled default values that the drawer's shadow-DOM CSS
* falls back to (e.g. `var(--cpk-drawer-bg, <built default>)`).
*
* Running this at build time means the drawer's bundled defaults cannot drift
* away from react-core's theme: a token change in react-core re-derives here.
* The output is a checked-in generated file (`src/threads-drawer/generated-tokens.ts`)
* so the package still builds and tests run without invoking the script, and so
* drift is visible in code review as a diff to that file.
*
* If the canonical file or a required token is missing, this fails loudly
* rather than silently emitting stale or empty defaults.
*/
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
extractLightThemeBlock,
parseCustomProperties,
} from "../src/threads-drawer/token-extraction";
const dirname = path.dirname(fileURLToPath(import.meta.url));
const packageDir = path.resolve(dirname, "..");
const repoRoot = path.resolve(packageDir, "../../");
const GLOBALS_CSS = path.join(
repoRoot,
"packages/react-core/src/v2/styles/globals.css",
);
const OUTPUT = path.join(packageDir, "src/threads-drawer/generated-tokens.ts");
/**
* The react-core token names (without leading `--`) that the drawer derives its
* bundled defaults from, mapped to the drawer-local token suffix they back.
* Keep this list minimal and structural — the drawer is a small surface.
*/
const TOKEN_MAP: ReadonlyArray<
readonly [reactCoreVar: string, drawerToken: string]
> = [
["background", "bg"],
["foreground", "fg"],
["card", "surface"],
["card-foreground", "surface-fg"],
["muted", "muted"],
["muted-foreground", "muted-fg"],
["accent", "accent"],
["accent-foreground", "accent-fg"],
["primary", "primary"],
["primary-foreground", "primary-fg"],
["destructive", "danger"],
["border", "border"],
["ring", "ring"],
["radius", "radius"],
];
function formatObjectKey(key: string): string {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
}
function main(): void {
const css = readFileSync(GLOBALS_CSS, "utf8");
const block = extractLightThemeBlock(css, GLOBALS_CSS);
const props = parseCustomProperties(block);
const entries: string[] = [];
for (const [reactCoreVar, drawerToken] of TOKEN_MAP) {
const value = props.get(reactCoreVar);
if (value === undefined) {
throw new Error(
`[generate-tokens] Required token "--${reactCoreVar}" not found in canonical theme; ` +
`react-core globals.css changed shape — update TOKEN_MAP in generate-tokens.ts.`,
);
}
entries.push(
` ${formatObjectKey(drawerToken)}: ${JSON.stringify(value)},`,
);
}
const file = `/**
* GENERATED FILE — DO NOT EDIT BY HAND.
*
* Bundled default values for the drawer's design tokens, derived at build time
* from the canonical react-core theme (\`packages/react-core/src/v2/styles/globals.css\`)
* by \`scripts/generate-tokens.ts\`. Run \`pnpm run gen:tokens\` to regenerate.
*
* The drawer's shadow-DOM CSS references these as fallbacks, e.g.
* \`var(--cpk-drawer-bg, <built default>)\`, so consumers can override every
* token while the built-in skin stays in sync with react-core.
*/
export const GENERATED_DRAWER_TOKEN_DEFAULTS = {
${entries.join("\n")}
} as const satisfies Record<string, string>;
export type GeneratedDrawerTokenKey =
keyof typeof GENERATED_DRAWER_TOKEN_DEFAULTS;
`;
writeFileSync(OUTPUT, file, "utf8");
// eslint-disable-next-line no-console
console.log(
`[generate-tokens] Wrote ${TOKEN_MAP.length} derived token defaults to ${path.relative(repoRoot, OUTPUT)}`,
);
}
// Only run the file-writing entrypoint when executed as a script (via tsx),
// NOT when imported by a test that exercises the pure helpers.
const executedDirectly =
typeof process !== "undefined" &&
Array.isArray(process.argv) &&
process.argv[1] !== undefined &&
fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (executedDirectly) {
main();
}
+9
View File
@@ -0,0 +1,9 @@
/**
* `@copilotkit/web-components` — framework-agnostic shadow-DOM custom elements
* for CopilotKit.
*
* Currently exposes the `<copilotkit-threads-drawer>` threads drawer. Importing this
* root re-exports the drawer's public API; `@copilotkit/web-components/threads-drawer`
* is the focused subpath for tree-shaking when only the drawer is needed.
*/
export * from "./threads-drawer/index";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
/**
* Guards the build-time token-sync (anti-drift) contract.
*
* The drawer's bundled default token values are DERIVED from react-core's
* canonical theme by `scripts/generate-tokens.ts` and checked in as
* `generated-tokens.ts`. These tests assert (a) the generated file exposes the
* tokens the shadow-DOM CSS depends on, and (b) the checked-in values still
* match what the canonical theme currently declares — i.e. the generated file
* has not drifted from `react-core/.../globals.css`.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { GENERATED_DRAWER_TOKEN_DEFAULTS } from "../generated-tokens";
import type { GeneratedDrawerTokenKey } from "../generated-tokens";
import {
extractLightThemeBlock,
parseCustomProperties,
} from "../token-extraction";
const dirname = path.dirname(fileURLToPath(import.meta.url));
// __tests__ -> drawer -> src -> web-components -> packages -> repo root
const repoRoot = path.resolve(dirname, "../../../../../");
const GLOBALS_CSS = path.join(
repoRoot,
"packages/react-core/src/v2/styles/globals.css",
);
/** Maps drawer token keys back to the react-core custom property they derive from. */
const DERIVATION: Record<GeneratedDrawerTokenKey, string> = {
bg: "background",
fg: "foreground",
surface: "card",
"surface-fg": "card-foreground",
muted: "muted",
"muted-fg": "muted-foreground",
accent: "accent",
"accent-fg": "accent-foreground",
primary: "primary",
"primary-fg": "primary-foreground",
danger: "destructive",
border: "border",
ring: "ring",
radius: "radius",
};
function readCanonicalLightTokens(): Map<string, string> {
const css = readFileSync(GLOBALS_CSS, "utf8");
// Route through the SAME canonical parser the build script uses, rather than a
// local naive first-`}` scan: a reimplementation here could pass while the real
// extractor truncates (or vice-versa), defeating the drift guarantee.
return parseCustomProperties(extractLightThemeBlock(css, GLOBALS_CSS));
}
test("generated defaults expose every token the drawer CSS references", () => {
const expectedKeys: GeneratedDrawerTokenKey[] = [
"bg",
"fg",
"surface",
"surface-fg",
"muted",
"muted-fg",
"accent",
"accent-fg",
"primary",
"primary-fg",
"danger",
"border",
"ring",
"radius",
];
for (const key of expectedKeys) {
expect(GENERATED_DRAWER_TOKEN_DEFAULTS[key]).toBeTypeOf("string");
expect(GENERATED_DRAWER_TOKEN_DEFAULTS[key].length).toBeGreaterThan(0);
}
});
test("checked-in generated defaults have not drifted from react-core's canonical theme", () => {
const canonical = readCanonicalLightTokens();
for (const [drawerKey, reactCoreVar] of Object.entries(DERIVATION)) {
const canonicalValue = canonical.get(reactCoreVar);
expect(
canonicalValue,
`react-core theme is missing --${reactCoreVar}; regenerate tokens`,
).toBeDefined();
expect(
GENERATED_DRAWER_TOKEN_DEFAULTS[drawerKey as GeneratedDrawerTokenKey],
`drawer token "${drawerKey}" drifted from react-core --${reactCoreVar}; run "pnpm run gen:tokens"`,
).toBe(canonicalValue);
}
});
// --- Fix 8: brace-balanced block extraction -------------------------------
test("extractLightThemeBlock captures the full block past a nested brace", () => {
const css = [
"[data-copilotkit] {",
" --background: #fff;",
" @media (min-width: 0) { --inner: 1; }",
" --foreground: #000;",
"}",
".other { --x: 1; }",
].join("\n");
const block = extractLightThemeBlock(css);
// a naive first-`}` scan would stop at the @media close and drop --foreground
expect(block).toContain("--background: #fff;");
expect(block).toContain("--foreground: #000;");
// and must NOT bleed into the sibling rule that follows the matching close
expect(block).not.toContain("--x: 1;");
});
test("extractLightThemeBlock throws on unbalanced braces rather than truncating", () => {
const css = "[data-copilotkit] {\n --background: #fff;\n { --orphan: 1;\n";
expect(() => extractLightThemeBlock(css)).toThrow(/Unbalanced braces/);
});
@@ -0,0 +1,121 @@
/**
* De-risk spike (required by the design).
*
* Proves that the id-keyed per-row `<slot name="row:{id}">` projection survives
* a live reorder of `threads` WITHOUT remounting/recreating the slotted row
* content. Two independent identity guarantees are asserted across a reorder:
*
* 1. The wrapper-provided light-DOM node (projected into the slot) is never
* touched by the element — it is the same DOM node before and after.
* 2. The shadow-side `<slot name="row:{id}">` element for a given thread id is
* reused (same node identity), because `repeat()` is keyed by thread id —
* so projection is preserved rather than torn down and re-established.
*/
import { afterEach, expect, test, vi } from "vitest";
import {
COPILOTKIT_THREADS_DRAWER_TAG,
defineCopilotKitThreadsDrawer,
} from "../index";
import type { DrawerThread, CopilotKitThreadsDrawer } from "../index";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
async function flush(element: CopilotKitThreadsDrawer) {
await element.updateComplete;
await tick();
await element.updateComplete;
}
function thread(id: string, updatedAt: string): DrawerThread {
return {
id,
name: `Thread ${id}`,
archived: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt,
};
}
afterEach(() => {
document.body.replaceChildren();
});
test("id-keyed per-row slot reconciliation survives a live reorder without remounting slotted content", async () => {
defineCopilotKitThreadsDrawer();
window.matchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
}) as unknown as typeof window.matchMedia;
const element = document.createElement(
COPILOTKIT_THREADS_DRAWER_TAG,
) as CopilotKitThreadsDrawer;
// Newest-first ordering by updatedAt → initial order is [a, b].
element.threads = [
thread("a", "2026-06-20T00:00:00.000Z"),
thread("b", "2026-06-10T00:00:00.000Z"),
];
// Wrapper projects per-row light-DOM content for each thread id.
const rowA = document.createElement("span");
rowA.slot = "row:a";
rowA.id = "wrapper-row-a";
const rowB = document.createElement("span");
rowB.slot = "row:b";
rowB.id = "wrapper-row-b";
element.append(rowA, rowB);
document.body.appendChild(element);
await flush(element);
const shadow = element.shadowRoot as ShadowRoot;
const slotForBefore = (id: string) =>
shadow.querySelector(`slot[name="row:${id}"]`) as HTMLSlotElement;
const slotABefore = slotForBefore("a");
const slotBBefore = slotForBefore("b");
const orderBefore = Array.from(shadow.querySelectorAll("li.row")).map((el) =>
el.getAttribute("data-thread-id"),
);
expect(orderBefore).toEqual(["a", "b"]);
expect(slotABefore.assignedElements()[0]).toBe(rowA);
expect(slotBBefore.assignedElements()[0]).toBe(rowB);
// Live reorder: bump b to be the newest → order should become [b, a].
element.threads = [
thread("a", "2026-06-20T00:00:00.000Z"),
thread("b", "2026-06-25T00:00:00.000Z"),
];
await flush(element);
const slotAAfter = shadow.querySelector(
'slot[name="row:a"]',
) as HTMLSlotElement;
const slotBAfter = shadow.querySelector(
'slot[name="row:b"]',
) as HTMLSlotElement;
const orderAfter = Array.from(shadow.querySelectorAll("li.row")).map((el) =>
el.getAttribute("data-thread-id"),
);
// The visual order changed...
expect(orderAfter).toEqual(["b", "a"]);
// ...but the keyed slot nodes were REUSED (node identity preserved), not
// recreated — keyed reconciliation moved them rather than tearing down.
expect(slotAAfter).toBe(slotABefore);
expect(slotBAfter).toBe(slotBBefore);
// ...and the projected wrapper light-DOM nodes are the exact same instances,
// still assigned to their id-matched slots (no remount of slotted content).
expect(slotAAfter.assignedElements()[0]).toBe(rowA);
expect(slotBAfter.assignedElements()[0]).toBe(rowB);
expect(document.getElementById("wrapper-row-a")).toBe(rowA);
expect(document.getElementById("wrapper-row-b")).toBe(rowB);
element.remove();
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
import {
COPILOTKIT_THREADS_DRAWER_TAG,
CopilotKitThreadsDrawer,
} from "./copilotkit-threads-drawer";
/**
* Registers the `<copilotkit-threads-drawer>` custom element. Idempotent — safe to call
* multiple times (e.g. from several framework wrappers in the same page); a
* second registration of the same tag is a no-op.
*/
export function defineCopilotKitThreadsDrawer(): void {
if (
typeof customElements !== "undefined" &&
!customElements.get(COPILOTKIT_THREADS_DRAWER_TAG)
) {
customElements.define(
COPILOTKIT_THREADS_DRAWER_TAG,
CopilotKitThreadsDrawer,
);
}
}
declare global {
interface HTMLElementTagNameMap {
"copilotkit-threads-drawer": CopilotKitThreadsDrawer;
}
}
@@ -0,0 +1,30 @@
/**
* GENERATED FILE — DO NOT EDIT BY HAND.
*
* Bundled default values for the drawer's design tokens, derived at build time
* from the canonical react-core theme (`packages/react-core/src/v2/styles/globals.css`)
* by `scripts/generate-tokens.ts`. Run `pnpm run gen:tokens` to regenerate.
*
* The drawer's shadow-DOM CSS references these as fallbacks, e.g.
* `var(--cpk-drawer-bg, <built default>)`, so consumers can override every
* token while the built-in skin stays in sync with react-core.
*/
export const GENERATED_DRAWER_TOKEN_DEFAULTS = {
bg: "oklch(1 0 0)",
fg: "oklch(0.145 0 0)",
surface: "oklch(1 0 0)",
"surface-fg": "oklch(0.145 0 0)",
muted: "oklch(0.97 0 0)",
"muted-fg": "oklch(0.556 0 0)",
accent: "oklch(0.97 0 0)",
"accent-fg": "oklch(0.205 0 0)",
primary: "oklch(0.205 0 0)",
"primary-fg": "oklch(0.985 0 0)",
danger: "oklch(0.577 0.245 27.325)",
border: "oklch(0.922 0 0)",
ring: "oklch(0.708 0 0)",
radius: "0.625rem",
} as const satisfies Record<string, string>;
export type GeneratedDrawerTokenKey =
keyof typeof GENERATED_DRAWER_TOKEN_DEFAULTS;
@@ -0,0 +1,24 @@
export {
COPILOTKIT_THREADS_DRAWER_TAG,
CopilotKitThreadsDrawer,
} from "./copilotkit-threads-drawer";
export { defineCopilotKitThreadsDrawer } from "./define";
export { drawerStyles } from "./styles";
export { GENERATED_DRAWER_TOKEN_DEFAULTS } from "./generated-tokens";
export type { GeneratedDrawerTokenKey } from "./generated-tokens";
export type {
ArchiveDetail,
CollapseChangeDetail,
CopilotKitThreadsDrawerEventMap,
DeleteDetail,
DrawerFilter,
DrawerThread,
FilterChangeDetail,
LoadMoreDetail,
NewThreadDetail,
OpenChangeDetail,
RetryDetail,
ThreadSelectedDetail,
UnarchiveDetail,
LicensedDetail,
} from "./types";
@@ -0,0 +1,705 @@
import { css, unsafeCSS } from "lit";
import { GENERATED_DRAWER_TOKEN_DEFAULTS } from "./generated-tokens";
/**
* Wraps a generated token default value so it can be safely interpolated into a
* lit `css` template. Values come from the checked-in generated tokens file
* (derived from react-core), never from user input, so `unsafeCSS` is safe
* here.
*/
const tok = (value: string) => unsafeCSS(value);
const T = GENERATED_DRAWER_TOKEN_DEFAULTS;
/**
* Self-contained shadow-DOM styles for `<copilotkit-threads-drawer>`.
*
* Authoring rules baked in here:
* - Every visual value is a `var(--cpk-drawer-<token>, <built default>)`. The
* built defaults come from {@link GENERATED_DRAWER_TOKEN_DEFAULTS}, derived
* from react-core's canonical theme at build time (anti-drift). Consumers
* override by setting `--cpk-drawer-*` from the light DOM — CSS custom
* properties pierce the shadow boundary by design.
* - Inherited properties (font, color, line-height) are RE-PINNED on `:host`
* so a hostile host stylesheet (`all: unset`, `* { ... !important }`,
* Tailwind preflight) cannot leak into or strip styling from the element.
* - Structural nodes expose `::part(...)` hooks for fine-grained theming.
*
* Token fallbacks are interpolated as literals (not runtime-read) so the CSS is
* static and ships compiled.
*/
export const drawerStyles = css`
:host {
/* Re-pin inheritable properties to close inheritance leaks from hostile
host CSS. These are the only properties that cross the shadow boundary
via inheritance, so we hard-set them at the root. */
all: initial;
display: block;
height: 100%;
box-sizing: border-box;
font-family: var(
--cpk-drawer-font-family,
ui-sans-serif,
system-ui,
sans-serif
);
font-size: var(--cpk-drawer-font-size, 14px);
line-height: var(--cpk-drawer-line-height, 1.4);
color: var(--cpk-drawer-fg, var(--foreground, ${tok(T.fg)}));
/* Three-level token resolution, highest priority first:
1. explicit per-token override (--cpk-drawer-*),
2. the host app's theme variable (--background/--card/… — the standard
react-core/shadcn names), so the drawer follows the host's light/dark
theme by inheritance (custom properties are NOT reset by all:initial),
3. the built-in light default derived from react-core at build time, so a
host with no theme still renders correctly (self-contained). */
--_bg: var(--cpk-drawer-bg, var(--background, ${tok(T.bg)}));
--_surface: var(--cpk-drawer-surface, var(--card, ${tok(T.surface)}));
--_surface-fg: var(
--cpk-drawer-surface-fg,
var(--card-foreground, ${tok(T["surface-fg"])})
);
--_muted: var(--cpk-drawer-muted, var(--muted, ${tok(T.muted)}));
--_muted-fg: var(
--cpk-drawer-muted-fg,
var(--muted-foreground, ${tok(T["muted-fg"])})
);
--_accent: var(--cpk-drawer-accent, var(--accent, ${tok(T.accent)}));
--_accent-fg: var(
--cpk-drawer-accent-fg,
var(--accent-foreground, ${tok(T["accent-fg"])})
);
--_primary: var(--cpk-drawer-primary, var(--primary, ${tok(T.primary)}));
--_primary-fg: var(
--cpk-drawer-primary-fg,
var(--primary-foreground, ${tok(T["primary-fg"])})
);
--_danger: var(--cpk-drawer-danger, var(--destructive, ${tok(T.danger)}));
--_border: var(--cpk-drawer-border, var(--border, ${tok(T.border)}));
--_ring: var(--cpk-drawer-ring, var(--ring, ${tok(T.ring)}));
--_indicator: var(--cpk-drawer-indicator, #5b94e4);
/* Cap the corner radius at 4px (per designer) — themeable but never larger. */
--_radius: min(
var(--cpk-drawer-radius, var(--radius, ${tok(T.radius)})),
4px
);
--_width: var(--cpk-drawer-width, 320px);
}
:host([hidden]) {
display: none;
}
* {
box-sizing: border-box;
}
.root {
display: flex;
flex-direction: column;
height: 100%;
width: var(--_width);
background: var(--_bg);
border-right: 1px solid var(--_border);
overflow: hidden;
transition: width 0.2s ease;
/* The confirm-delete dialog no longer needs a positioning context here: it
is a native <dialog> opened with showModal(), which renders in the
browser top layer independent of any stacking context (ENT-1051). No
other direct child of .root is absolutely positioned (the filter and
row-action popovers anchor to their own positioned ancestors), so .root
stays static. The mobile path establishes its own context via
position:fixed. */
}
/* Mobile: off-canvas overlay (modal pattern). */
.root.mobile {
position: fixed;
inset: 0 auto 0 0;
z-index: 1000;
width: min(var(--_width), 85vw);
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 0 0 0 100vmax transparent;
}
.root.mobile.open {
transform: translateX(0);
}
/* Desktop collapsed: the panel is replaced by the floating cluster, so remove
it from the layout entirely. The host reclaims the reserved column via the
--cpk-drawer-reserved-width:0px override the element sets on collapse. */
.root.collapsed {
display: none;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.4);
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
}
/* Floating launcher cluster (Figma "closed" mockup): a rounded surface card
holding the sidebar toggle + a "New Conversation" (+) icon button. Rendered
by the element itself in the mobile-closed AND desktop-collapsed states so
there is always a way to reopen/expand — and to start a new conversation —
with no host wiring. */
.launcher-cluster {
position: fixed;
z-index: 998;
/* Position is themeable so a host can line the cluster up with its own
header controls (e.g. vertically centering it on a toggle group). */
top: var(--cpk-drawer-launcher-top, 24px);
left: var(--cpk-drawer-launcher-left, 24px);
display: inline-flex;
align-items: center;
gap: 2px;
padding: 6px;
border-radius: var(--_radius);
border: 1px solid var(--_border);
background: var(--_surface);
color: var(--_surface-fg);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.12);
}
.launcher {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 6px;
border: 0;
border-radius: calc(var(--_radius) - 3px);
background: transparent;
color: var(--_surface-fg);
cursor: pointer;
font: inherit;
}
.launcher:hover,
.launcher:focus-visible {
background: var(--_muted);
}
.launcher .icon {
width: 18px;
height: 18px;
display: block;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
}
/* The header is hidden until a consumer projects slot="header" content
(the redesign has no built-in header controls). Mirrors the memories/footer
gating so an empty padded header bar never renders above the list. */
.header[hidden] {
display: none;
}
/* Optional consumer projection surface. When a consumer projects into
slot="header" it fills the header row. */
.header slot[name="header"] {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 6px;
border: 0;
border-radius: var(--_radius);
background: transparent;
color: var(--_muted-fg);
cursor: pointer;
font: inherit;
}
/* "Filter applied" indicator dot at the funnel's bottom-right (Figma archived
view). Shown only when a non-default filter is active. */
.filter-dot {
position: absolute;
right: 3px;
bottom: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--_indicator);
pointer-events: none;
}
.icon-btn:hover,
.icon-btn:focus-visible {
background: var(--_muted);
color: inherit;
}
.icon-btn[aria-pressed="true"] {
background: var(--_muted);
color: inherit;
}
.icon {
width: 20px;
height: 20px;
display: block;
}
.new-conversation {
display: flex;
align-items: center;
gap: 12px;
/* No top margin by default: the header bar (the collapse toggle on desktop,
the close button on mobile-open) sits above this row and supplies the top
spacing — the header-hidden case re-adds it below. Horizontal geometry
matches the list rows: the hover background insets 12px (like a row's
highlight) and, with 10px inner padding, the icon's left edge lands at
22px — the same x as the thread names below (list padding 12 + row
padding 10). */
margin: 0 12px;
padding: 8px 10px;
border: 0;
border-radius: var(--_radius);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
}
/* Only when the header bar is fully hidden (collapsible=false AND no projected
header content) is this row the first element — give it the top spacing the
header would otherwise supply. */
.header[hidden] + .new-conversation {
margin-top: 12px;
}
.new-conversation:hover,
.new-conversation:focus-visible {
background: var(--_muted);
}
.new-conversation .icon {
width: 16px;
height: 16px;
}
.section-heading {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
/* Match the list rows' content edge: "Recent Conversations" lands at 22px
(margin 12 + padding 10), aligned with the thread names and the New
Conversation icon; the funnel sits 22px from the right, over the kebabs. */
padding: 8px 10px;
margin: 0 12px;
}
.section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--_muted-fg);
text-transform: none;
}
.icon-btn.small {
width: 24px;
height: 24px;
padding: 4px;
}
.icon-btn.small .icon {
width: 16px;
height: 16px;
}
.filter-popover {
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 15;
display: flex;
flex-direction: column;
min-width: 120px;
padding: 4px;
background: var(--_surface);
color: var(--_surface-fg);
border: 1px solid var(--_border);
border-radius: var(--_radius);
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
}
.filter-opt {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
padding: 6px 8px;
border-radius: 4px;
}
.filter-opt:hover,
.filter-opt:focus-visible {
background: var(--_muted);
}
.filter-opt[aria-pressed="true"] {
font-weight: 600;
}
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 12px;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--_radius);
cursor: pointer;
background: transparent;
color: inherit;
border: 1px solid transparent;
opacity: 0;
transform: translateY(4px);
animation: cpk-drawer-row-in 0.18s ease forwards;
/* Positioned so the hovered row can be lifted above later rows (below). */
position: relative;
}
/* Lift the interacted row above later rows so its kebab popover (which paints
inside the row's own transform stacking context) is not clipped by / drawn
under the rows below it. */
.row:hover,
.row:focus-within,
.row.menu-open {
z-index: 3;
}
/* While a kebab menu is open, the popover only covers part of the rows it
overlaps, so the rest of the list would still respond to hover — revealing
other rows' kebabs and (under a host \`::part(row):hover\` theme) painting a
hover background "through"/around the open menu. Freeze pointer events on
every OTHER row so the menu reads as a single focused surface. The owner
row (\`.menu-open\`) and its popover stay interactive, and a pointerdown that
lands on a frozen row still reaches the document handler that closes the
menu, so click-away dismissal is preserved. */
.list.menu-open .row:not(.menu-open) {
pointer-events: none;
}
@keyframes cpk-drawer-row-in {
to {
opacity: 1;
transform: translateY(0);
}
}
.row.active {
background: var(--_accent);
color: var(--_accent-fg);
/* No border on the selected row — the background change alone marks it
(per designer). The base row keeps its 1px transparent border so the
layout stays stable. */
}
.row.archived .row-name {
color: var(--_muted-fg);
font-style: italic;
}
.row-name {
flex: 1;
min-width: 0;
}
.row-name-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-name.placeholder {
color: var(--_muted-fg);
font-style: italic;
}
.row-name.revealed {
animation: cpk-drawer-name-reveal 0.3s ease;
}
@keyframes cpk-drawer-name-reveal {
from {
opacity: 0.4;
}
to {
opacity: 1;
}
}
.row-action {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
border: 0;
background: transparent;
color: var(--_muted-fg);
cursor: pointer;
font: inherit;
padding: 5px;
border-radius: 4px;
}
.row-action:hover,
.row-action:focus-visible {
background: var(--_muted);
color: inherit;
}
.row-action-icon {
width: 15px;
height: 15px;
display: block;
}
/* Per-row kebab trigger: hidden at rest, revealed when the row is hovered,
focused (keyboard), active (selected), or its menu is open. */
.row-menu {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--_muted-fg);
cursor: pointer;
font: inherit;
opacity: 0;
}
.row:hover .row-menu,
.row:focus-within .row-menu,
.row.active .row-menu,
.row-menu[aria-expanded="true"] {
opacity: 1;
}
.row-menu:hover,
.row-menu:focus-visible {
background: var(--_muted);
color: inherit;
}
.row-menu .icon {
width: 16px;
height: 16px;
}
.row-menu-popover {
position: absolute;
right: 8px;
top: calc(100% - 4px);
z-index: 16;
display: flex;
flex-direction: column;
min-width: 140px;
padding: 4px;
background: var(--_surface);
color: var(--_surface-fg);
border: 1px solid var(--_border);
border-radius: var(--_radius);
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
}
/* Rows in the lower portion of the list open their kebab menu UPWARD so it is
not clipped by the list's overflow scroll box (.list is overflow-y:auto,
which also clips the x-axis). Anchoring to the button's top edge via
\`bottom\` keeps the popover inside the list's visible area for bottom rows. */
.row.menu-up .row-menu-popover {
top: auto;
bottom: calc(100% - 4px);
}
.row-menu-item {
display: flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
padding: 6px 8px;
border-radius: 4px;
}
.row-menu-item:hover,
.row-menu-item:focus-visible {
background: var(--_muted);
}
.row-menu-item.danger:hover,
.row-menu-item.danger:focus-visible {
color: var(--_danger);
}
.row-menu-item .row-action-icon,
.row-menu-item .icon {
width: 15px;
height: 15px;
}
button.primary {
background: var(--_primary);
color: var(--_primary-fg);
border: 0;
border-radius: var(--_radius);
padding: 8px 12px;
cursor: pointer;
font: inherit;
}
.state {
padding: 24px 16px;
text-align: center;
color: var(--_muted-fg);
}
.state.error {
color: var(--_danger);
}
.fetch-more-error {
padding: 8px 12px;
text-align: center;
color: var(--_danger);
font-size: 0.9em;
}
.load-more {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
color: var(--_muted-fg);
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
.load-more:hover,
.load-more:focus-visible {
color: var(--_surface-fg);
background: var(--_muted);
}
.licensed {
padding: 24px 16px;
text-align: center;
display: flex;
flex-direction: column;
gap: 12px;
}
.memories {
border-top: 1px solid var(--_border);
padding: 8px 12px;
}
.memories[hidden] {
display: none;
}
/* Confirm-delete dialog — a native <dialog> opened with showModal(). It lives
in the browser TOP LAYER, so it can never be painted under other UI (the
old CSS-positioned overlay was trapped in the drawer host's stacking
context and appeared under the chat's welcome view — ENT-1051). The UA
centers it in the viewport via margin:auto; we reset the UA chrome and
apply the drawer's surface-card look. */
.dialog {
/* Top-layer (showModal) so it is never clipped, but centered over the DRAWER
PANEL rather than the viewport: JS sets --confirm-cx/cy from the visible
.root rect (falls back to viewport-center when unmeasured, e.g. jsdom).
Width is capped to the drawer band so it reads as "inside the drawer". */
margin: 0;
position: fixed;
left: var(--confirm-cx, 50%);
top: var(--confirm-cy, 50%);
transform: translate(-50%, -50%);
border: 0;
padding: 16px;
width: max-content;
max-width: min(80vw, calc(var(--_width) - 24px));
background: var(--_surface);
color: var(--_surface-fg);
border-radius: var(--_radius);
}
/* Only lay out the card contents when open. A closed <dialog> is display:none
via the UA stylesheet, and author display rules must not override that (or
an empty box would leak), so the flex layout is scoped to [open]. */
.dialog[open] {
display: flex;
flex-direction: column;
gap: 12px;
}
.dialog::backdrop {
background: rgba(0, 0, 0, 0.3);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.footer {
border-top: 1px solid var(--_border);
padding: 12px;
}
.footer[hidden] {
display: none;
}
`;
@@ -0,0 +1,82 @@
/**
* Pure CSS token-extraction helpers shared by the build-time token-sync script
* (`scripts/generate-tokens.ts`) and the drift-guard test
* (`__tests__/generated-tokens.test.ts`).
*
* These live in `src/` rather than in `scripts/` for two reasons: (1) the test
* cannot import from `scripts/` without pulling a file outside the package's
* tsc `rootDir` into the typecheck program, and (2) keeping a single canonical
* parser means the build output and the drift guard can never diverge on how
* the `[data-copilotkit]` block is located and parsed. The script imports these
* from here; nothing in the shipped element does, so the bundler tree-shakes
* them out of the published artifact.
*/
/**
* Extracts the light-mode `[data-copilotkit] { ... }` declaration block from the
* canonical globals.css using a brace-balanced scan.
*
* Taking the first `}` after the opening brace would silently truncate the block
* if it ever contains a nested brace (a `@media`/nested rule, or a brace inside a
* value), dropping later tokens. Counting depth finds the matching close brace —
* the true end of the block — instead.
*
* @param css - Full globals.css source text.
* @param sourceLabel - Human-readable source name surfaced in error messages.
* @returns The block body between (but excluding) the outer braces.
* @throws If the block cannot be located or its braces are unbalanced.
*/
export function extractLightThemeBlock(
css: string,
sourceLabel = "the canonical theme",
): string {
const start = css.indexOf("[data-copilotkit] {");
if (start === -1) {
throw new Error(
`[generate-tokens] Could not find "[data-copilotkit] {" in ${sourceLabel}`,
);
}
const open = css.indexOf("{", start);
if (open === -1) {
throw new Error(
`[generate-tokens] Malformed "[data-copilotkit]" block in ${sourceLabel}`,
);
}
let depth = 0;
let close = -1;
for (let i = open; i < css.length; i++) {
const char = css[i];
if (char === "{") {
depth++;
} else if (char === "}") {
depth--;
if (depth === 0) {
close = i;
break;
}
}
}
if (close === -1) {
throw new Error(
`[generate-tokens] Unbalanced braces in "[data-copilotkit]" block in ${sourceLabel}`,
);
}
return css.slice(open + 1, close);
}
/**
* Parses `--name: value;` custom-property declarations from a CSS block into a
* name→value map (names returned without the leading `--`).
*
* @param block - A CSS declaration block body.
* @returns Map of custom-property name to trimmed value.
*/
export function parseCustomProperties(block: string): Map<string, string> {
const out = new Map<string, string>();
const re = /--([\w-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = re.exec(block)) !== null) {
out.set(match[1]!.trim(), match[2]!.trim());
}
return out;
}
@@ -0,0 +1,112 @@
/**
* Public type surface for the `<copilotkit-threads-drawer>` custom element.
*
* The element is a pure VIEW: domain data flows IN as properties and user
* intent flows OUT as DOM `CustomEvent`s. These types intentionally do NOT
* import from `@copilotkit/core` or any framework — the element is
* framework-agnostic and self-contained. The thread shape mirrors the fields
* the drawer needs to render and is structurally compatible with core's thread
* record (`id`, `name`, `archived`, timestamps).
*/
/**
* Minimal thread record the drawer renders. A `null` name signals an
* as-yet-unnamed thread and is rendered as a "New thread" placeholder until the
* async name arrives.
*/
export interface DrawerThread {
/** Stable thread identifier; drives row keying and slot reconciliation. */
readonly id: string;
/** Display name, or `null` when not yet named. */
readonly name: string | null;
/** Whether the thread is archived (affects styling + available actions). */
readonly archived: boolean;
/** ISO timestamp the thread was created. */
readonly createdAt: string;
/** ISO timestamp the thread was last updated. */
readonly updatedAt: string;
/** ISO timestamp of the most recent run, if any. */
readonly lastRunAt?: string;
}
/** The Active/All filter the element owns authoritatively. */
export type DrawerFilter = "active" | "all";
/** `detail` for the `thread-selected` event. */
export interface ThreadSelectedDetail {
readonly threadId: string;
}
/** `detail` for the `archive` event. */
export interface ArchiveDetail {
readonly threadId: string;
}
/** `detail` for the `unarchive` event. */
export interface UnarchiveDetail {
readonly threadId: string;
}
/** `detail` for the `delete` event (fired only after in-element confirm). */
export interface DeleteDetail {
readonly threadId: string;
}
/** `detail` for the `filter-change` event. */
export interface FilterChangeDetail {
readonly filter: DrawerFilter;
}
/** `detail` for the `open-change` event. */
export interface OpenChangeDetail {
readonly open: boolean;
}
/** `detail` for the `collapse-change` event (user-driven collapse toggle). */
export interface CollapseChangeDetail {
readonly collapsed: boolean;
}
/**
* `detail` for the `retry` event, emitted from the actionable error state so a
* wrapper can back it with a core refetch. `scope` distinguishes an initial
* fetch retry from a fetch-more retry.
*/
export interface RetryDetail {
readonly scope: "initial" | "fetch-more";
}
/** `detail` for the `new-thread` event. */
export type NewThreadDetail = Record<string, never>;
/**
* `detail` for the `licensed` event (Upgrade CTA click). Carries the
* `licenseUrl` the element will open in a new tab. The event is cancelable:
* a host that calls `preventDefault()` suppresses the default navigation and
* takes over (e.g. to route in-app or fire its own telemetry).
*/
export interface LicensedDetail {
readonly licenseUrl: string | null;
}
/** `detail` for the `load-more` event (advance pagination; no payload). */
export type LoadMoreDetail = Record<string, never>;
/**
* Strongly-typed event map for `<copilotkit-threads-drawer>`. Consumers can use this to
* type `addEventListener` callbacks. All events bubble and are composed so they
* cross the shadow boundary.
*/
export interface CopilotKitThreadsDrawerEventMap {
"thread-selected": CustomEvent<ThreadSelectedDetail>;
archive: CustomEvent<ArchiveDetail>;
unarchive: CustomEvent<UnarchiveDetail>;
delete: CustomEvent<DeleteDetail>;
"new-thread": CustomEvent<NewThreadDetail>;
"filter-change": CustomEvent<FilterChangeDetail>;
"open-change": CustomEvent<OpenChangeDetail>;
"collapse-change": CustomEvent<CollapseChangeDetail>;
retry: CustomEvent<RetryDetail>;
licensed: CustomEvent<LicensedDetail>;
"load-more": CustomEvent<LoadMoreDetail>;
}
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"incremental": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"preserveWatchOutput": true,
"sourceMap": true,
"target": "es2022",
"module": "esnext",
"lib": ["es2023", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"useDefineForClassFields": false,
"experimentalDecorators": false,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts"]
}
+56
View File
@@ -0,0 +1,56 @@
import { defineConfig } from "tsdown";
/**
* Build configuration for `@copilotkit/web-components`.
*
* Mirrors the multi-format layout used by `packages/a2ui-renderer`:
* an unbundled ESM/CJS build (with `.d.ts`) for the root and the
* `./drawer` subpath, plus a single-file UMD bundle for `<script>`
* consumers.
*
* `lit` handling differs by format on purpose:
* - ESM/CJS externalize `lit` (it is a peer dependency) so an npm host that
* already depends on Lit dedupes a single copy of the runtime.
* - The UMD bundle INLINES `lit` so the advertised `<script>`/CDN path is
* self-contained. Externalizing it there mapped Lit submodules to UMD
* globals (`LitDirectivesRepeat`, …) that Lit's CDN build never exposes,
* so the very first `repeat()`/`classMap()` call threw at runtime.
*/
export default defineConfig([
{
entry: ["src/index.ts", "src/threads-drawer/index.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
target: "es2022",
outDir: "dist",
unbundle: true,
external: (id) => {
const externalPkgs = ["lit"];
return externalPkgs.some((pkg) => id === pkg || id.startsWith(pkg + "/"));
},
exports: false,
},
{
entry: ["src/index.ts"],
format: ["umd"],
globalName: "CopilotKitWebComponents",
sourcemap: true,
target: "es2018",
outDir: "dist",
// Force `lit` (a peer dependency, which tsdown would otherwise externalize)
// to be INLINED into the UMD bundle so the single `<script>`/CDN artifact is
// self-contained. Externalizing it emitted a bare `require('lit')` that has
// no meaning in a browser global script. Inlining `lit` necessarily pulls in
// its runtime deps (`@lit/reactive-element`, `lit-html`, `lit-element`,
// `@lit-labs/ssr-dom-shim`); that transitive bundling is intentional here, so
// disable tsdown's "unintended bundling" guard (which is fatal under CI).
noExternal: [/^lit(\/|$)/, /^@lit\//, "lit-html", "lit-element"],
inlineOnly: false,
codeSplitting: false,
outputOptions(options) {
options.entryFileNames = "[name].umd.js";
return options;
},
},
]);
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
test: {
environment: "jsdom",
include: ["**/__tests__/**/*.{test,spec}.ts"],
passWithNoTests: true,
globals: true,
server: {
deps: {
inline: ["lit", "@lit/reactive-element", "lit-element", "lit-html"],
},
},
},
resolve: {
alias: {
"@": path.join(dirname, "src"),
},
},
});