>;
+ notify: (input: { type: string; payload: P; priority?: NotificationPriority }) => string; // local -> SAME funnel
+ toast: ToastSugar; // toast.success/error/dismiss -> built-in '__toast' type
+ markRead: (id: string) => void; markAllRead: () => void;
+ dismissToast: (id: string) => void; archive: (id: string) => void;
+ runAction: (id: string, actionId: string) => Promise;
+ openModel: (id: string) => Ref;
+}
+export function useNotifications(): UseNotificationsReturn; // full surface
+export function useNotificationCenter(): UseNotificationCenterReturn; // inbox/groups/filter/markAllRead
+export function useUnreadCount(): Readonly>; // cheap badge — does NOT pull the whole store
+```
+
+End-to-end (a Nuxt plugin / app entry):
+
+```ts
+// plugins/notifications.client.ts
+import { createNotificationRegistry, appProvideNotificationRegistry, registerNotificationTypes,
+ useSharedRealtime } from '@robonen/vue';
+import { createWebSocketTransport } from '@robonen/realtime';
+import { authPlugin, jsonCodec, heartbeatPlugin, resumePlugin, loggerPlugin } from '@robonen/realtime/plugins';
+
+export default defineNuxtPlugin((nuxtApp) => {
+ const registry = createNotificationRegistry();
+ registerNotificationTypes([friendRequest, systemAlert, message]); // bootstrap, once
+
+ // CORRECTED: use appProvide at the plugin layer (component-level `provide` only works inside setup)
+ appProvideNotificationRegistry(nuxtApp.vueApp)(registry);
+
+ useSharedRealtime({
+ transport: createWebSocketTransport(),
+ url: () => `wss://api.example.com/notifications?u=${useUserId().value}`,
+ plugins: [authPlugin(getToken), jsonCodec, heartbeatPlugin(), resumePlugin(), loggerPlugin(console.log)],
+ backoff: { base: 500, cap: 30_000, jitter: true },
+ });
+});
+```
+
+```vue
+
+
+
+
+
+
+
+
+
+```
+
+```ts
+// anywhere: local notification + imperative toast (SAME ingest funnel)
+const { notify, toast, runAction } = useNotifications();
+notify({ type: 'message', payload: { threadId: 't1', from: 'Ana', text: 'hi' } });
+toast.success('Saved', { description: 'Your changes are live.' });
+toast.error('Upload failed', { actions: [{ id: 'retry', label: 'Retry', altText: 'Retry upload' }] });
+await runAction(friendReqId, 'accept'); // optimistic -> WS action -> ack commit / timeout rollback
+```
+
+---
+
+## 9. SSR, testing, tree-shaking
+
+**SSR.** `core/realtime` imports nothing from Vue and touches `WebSocket`/`BroadcastChannel`/timers only
+after `connect()`. `useRealtime` returns `ssrNoopRealtime()` when `!isClient`. `useStorage` defers with
+`initOnMounted`. `useSharedRealtime` (`createSharedComposable`) returns a fresh raw instance on the
+server. The unread badge renders only after `onMounted`. `` lives under ``.
+
+**Testing** (house style: `describe(fnName)`, `effectScope()` disposal, `MockTransport`, fake registry):
+
+```ts
+// store.test.ts
+describe('createNotificationStore', () => {
+ it('surfacing interrupt evicts lowest toast back to inbox, never deletes', () => {
+ const scope = effectScope();
+ scope.run(() => {
+ const store = createNotificationStore({ registry: fakeRegistry, maxToasts: 1 });
+ store.commit(ingest(frame('a', { priority: Priority.low })), 'ingest');
+ store.commit(ingest(frame('b', { priority: Priority.high })), 'ingest'); // interrupts
+ expect(store.toasting.value.map(n => n.id)).toEqual(['b']);
+ expect(store.entities.value.get('a')?.lifecycle).toBe('queued'); // demoted, still present
+ });
+ scope.stop();
+ });
+
+ it('hydration-commit never toasts (cross-tab no-re-toast)', () => {
+ const store = createNotificationStore({ registry: fakeRegistry });
+ store.commit(ingest(frame('x', { priority: Priority.critical })), 'hydration');
+ expect(store.toasting.value).toHaveLength(0); // inbox only
+ expect(store.unreadCount.value).toBe(1);
+ });
+
+ it('two-key dedup folds identical content into meta.count', () => { /* same contentHash, distinct id -> count=2 */ });
+ it('disposes timers/subscriptions on scope stop', () => { /* spy + effectScope().stop() */ });
+});
+
+// machine.test.ts — drive via await machine.send(event)
+describe('buildLifecycleMachine', () => {
+ it('NET_OFFLINE pauses retries without scheduling RETRY', async () => {
+ const m = buildLifecycleMachine(depsOffline);
+ await m.send('CONNECT'); await m.send('OPENED'); await m.send('NET_OFFLINE');
+ expect(depsOffline.scheduleRetry).not.toHaveBeenCalled();
+ });
+ it('cursor advances ONLY on contiguous delivery', () => {
+ const rb = createReorderBuffer({});
+ expect(rb.accept(frame('a', { seq: 1 })).map(f => f.seq)).toEqual([1]); expect(rb.cursor).toBe('1');
+ expect(rb.accept(frame('c', { seq: 3 }))).toEqual([]); expect(rb.cursor).toBe('1'); // gap held
+ expect(rb.accept(frame('b', { seq: 2 })).map(f => f.seq)).toEqual([2, 3]); expect(rb.cursor).toBe('3'); // drains
+ });
+ it('drops duplicates by id and seq<=lastSeq', () => { /* idempotent at-least-once */ });
+});
+```
+
+Plus explicit tests for **seq-gap-then-reconnect** (assert no loss/dup), **storage-event toast
+suppression**, **bounded-LRU dedup eviction**, and **leader handoff on abrupt tab close**.
+
+**Tree-shaking.** Per-category barrels (`composables/notifications/index.ts` → `composables/index.ts` →
+`src/index.ts`). Registry descriptors are **side-effect-free**; only the `defineNotificationType`s a
+consumer imports + `register()`s ship. Realtime plugins are separate modules under
+`@robonen/realtime/plugins`. `useUnreadCount` imports only the badge selector.
+
+---
+
+## 10. Build order / milestones
+
+| # | Milestone | Deliverables | Gate |
+|---|---|---|---|
+| **M1** | Transport core skeleton | `core/realtime`: `Transport` iface, `createWebSocketTransport`, `envelope.ts`, `MockTransport`. | Connect/send/recv against a mock; JSON round-trip. |
+| **M2** | Lifecycle + reliability | `machine.ts`, `backoff.ts` (jitter, gated), `heartbeat`/`resume` plugins, `reorder.ts` (contiguous cursor + gap-timeout + LruSet), `outbound.ts` (`AsyncPool`+`Deque`+ack by clientToken). | machine/reorder/backoff tests green; seq-gap-reconnect asserts no loss. |
+| **M3** | Plugin pipeline + 2nd transport | `plugin.ts` 1:1 with `core/fetch`; `auth`/`codec`/`logger`/`compression`; ship `createSseTransport`. | SSE swaps in with zero upper-layer change. |
+| **M4** | Registry + ingest funnel | `registry.ts`, `defineNotificationType`, `ingest.ts` (pure, catches `validate` throws → `onReject`), `contentHash`. | One funnel for WS + local; malformed payload never crashes the pump. |
+| **M5** | Domain store | `store.ts`, `surfacing.ts` (victim-min PQ + guarded enqueue + `ensureCapacity`), `selectors.ts` (explicit sort), pure-reducer lifecycle, two-key dedup. | Interrupt/eviction/dedup/disposal tests green. |
+| **M6** | Persistence + cross-tab | `persistence.ts` (`useStorage` map, merge lattice, `toastedIds`), hydration-commit separation, optional `useTabLeader`+`broadcastedRef`. | Storage-event suppression + leader-handoff tests green. |
+| **M7** | Presentation | `` (drives existing toast), `` primitive, shared `surface`-prop renderers; `useNotifications`/`useNotificationCenter`/`useUnreadCount`; `notify()`/`toast.*`. | Optimistic ack/rollback + a11y verified; the 3 demo types work end-to-end. |
+
+**Hardest risks & mitigations**
+
+1. **Resume gap / cursor correctness.** Cursor advances *only* on contiguous delivery; gap-timeout
+ deliver-with-gap + a server reconciliation request for permanently-lost ranges. Test: inject gaps +
+ reconnect, assert no loss/dup.
+2. **Zombie connections (TCP half-open).** Watchdog cleared by *any* inbound frame, armed whenever
+ `connected`; conservative configurable `ping 20s`/`watchdog 10s`.
+3. **`PriorityQueue` is a min-heap.** The surfacing PQ comparator makes the *eviction victim* the
+ minimum (lowest priority, then oldest); viewport order is a **separate** display sort. `surface()`
+ `peek`/`isFull`-guards then `dequeue`-then-`enqueue`; never `toArray()` for display.
+4. **Cross-tab double-toast.** `commit()` separates ingest (may toast) from hydration (never); per-tab
+ non-persisted `toastedIds`; `useTabLeader` (Web Locks) for the optional single socket; **never**
+ `useEventBus` for cross-tab; **never** a hand-rolled lease.
+5. **Singleton arg-dropping.** Inbox/registry via `useAppSharedState` (options bound once); socket via
+ `createSharedComposable` (disposable, raw-on-SSR).
+6. **Optimistic double-apply.** Idempotent server commands keyed by `clientToken` (the one canonical
+ correlation key); ack-after-timeout ignored via token compare; do **not** rollback on mid-action
+ socket drop; eviction skips `pendingAction`/`surfaced`.
+7. **Persistence schema.** Persist only committed lifecycle + read/dismiss receipts + cursor (exclude
+ runtime fields); Map serializer; validate-on-hydrate, drop+log unknown-type entities. Whole-Map LWW
+ is the multi-tab durability limit — opt into leader single-writer for strictness.
+8. **Durable-Deque overflow.** `pushBack` throws at `maxSize`, so `ensureCapacity()` runs *before* every
+ insert and force-evicts the oldest non-active entity if all candidates are protected.
+9. **SSR hydration mismatch.** Badge/connection UI render only after mount; `` under
+ ``; `isClient` guards throughout.
+
+---
+
+## Appendix — review corrections folded into this document
+
+This design was adversarially verified against the real repo source. Corrections already applied above:
+
+1. **PriorityQueue orientation** — it's a min-heap; the surfacing comparator now makes the eviction
+ victim the minimum, and the viewport uses a separate display sort (§5.2, §5.3).
+2. **Registry provisioning** — the Nuxt plugin uses `appProvide(nuxtApp.vueApp)` instead of
+ component-level `provide` (§6, §8).
+3. **Bounded LRU dedup** — `seen` is a real bounded `LruSet`, not an unbounded `Set` (§4.5).
+4. **Backoff** — `backoffDelay` is bespoke and does not reuse stdlib `retry()` (§4.3).
+5. **Ack correlation** — one canonical key (`clientToken`) end-to-end; `AckFrame.ref` echoes it
+ (§4.1, §4.6, §5.5).
+6. **Network boundary** — the Vue `useRealtime` wrapper watches `useNetwork().isOnline` and dispatches
+ `NET_OFFLINE`/`NET_ONLINE`; `core/realtime` never imports Vue (§4.7).
+7. **Durable overflow** — explicit `ensureCapacity()` before insert (the `Deque` throws when full)
+ (§5.2).
+8. **Server contract** — the monotonic-seq / replay / reconcile / token-echo requirements are stated
+ explicitly (§4.1).
diff --git a/docs/content.config.ts b/docs/content.config.ts
new file mode 100644
index 0000000..4e560ae
--- /dev/null
+++ b/docs/content.config.ts
@@ -0,0 +1,26 @@
+import { defineCollection, defineContentConfig } from '@nuxt/content';
+
+const repositories = [
+ '../configs/tsconfig',
+ '../core/stdlib',
+ '../core/platform',
+ '../infra/renovate',
+ '../web/vue',
+];
+
+export default defineContentConfig({
+ collections: repositories.reduce((acc, repo) => {
+ const name = repo.split('/').pop();
+
+ acc[name] = defineCollection({
+ source: {
+ include: `**/*.md`,
+ exclude: ['**/node_modules/**', '**/dist/**'],
+ cwd: repo,
+ },
+ type: 'page',
+ });
+
+ return acc;
+ }, {}),
+});
diff --git a/docs/eslint.config.ts b/docs/eslint.config.ts
index f394f09..9cc100a 100644
--- a/docs/eslint.config.ts
+++ b/docs/eslint.config.ts
@@ -1,4 +1,4 @@
-import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint';
+import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, {
name: 'docs/build-scripts',
@@ -7,4 +7,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
/* Build-time tooling (doc extractor) logs progress to the console. */
'no-console': 'off',
},
-});
+}, tests);
diff --git a/docs/modules/extractor/extract.ts b/docs/modules/extractor/extract.ts
index 2145a68..19baa49 100644
--- a/docs/modules/extractor/extract.ts
+++ b/docs/modules/extractor/extract.ts
@@ -88,7 +88,7 @@ const PACKAGES: PackageConfig[] = [
{ path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' },
// ── vue ──
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' },
- { path: 'vue/editor', slug: 'editor', kind: 'api', group: 'vue' },
+ { path: 'vue/writekit', slug: 'writekit', kind: 'api', group: 'vue' },
{ path: 'vue/primitives', slug: 'primitives', kind: 'components', group: 'vue' },
// ── configs ──
{ path: 'configs/eslint', slug: 'eslint', kind: 'guide', group: 'configs', guideSources: ['README.md', 'rules/*.md'] },
@@ -98,6 +98,27 @@ const PACKAGES: PackageConfig[] = [
{ path: 'infra/renovate', slug: 'renovate', kind: 'guide', group: 'infra', guideSources: ['README.md'] },
];
+/**
+ * Display label for each category FOLDER under `src/`. Components now live at
+ * `src///`, so the folder is the source of truth for a
+ * component's category. Unlisted folders fall back to `toPascalCase(folder)`.
+ * The display order of categories lives in `useDocs` (`COMPONENT_CATEGORY_ORDER`).
+ */
+const CATEGORY_LABELS: Record = {
+ forms: 'Forms',
+ selection: 'Selection',
+ color: 'Color',
+ overlays: 'Overlays',
+ menus: 'Menus',
+ disclosure: 'Disclosure',
+ navigation: 'Navigation',
+ display: 'Display',
+ feedback: 'Feedback',
+ canvas: 'Canvas & editors',
+ utilities: 'Utilities',
+ internal: 'Internal',
+};
+
// ── Helpers ────────────────────────────────────────────────────────────────
function toKebabCase(str: string): string {
@@ -716,14 +737,14 @@ function inferCategoryFromItem(item: ItemMeta): string {
}
/** Resolve a package's export subpaths to source entry files. */
-function resolveEntryPoints(pkgDir: string, exportsField: Record): Array<{ subpath: string; filePath: string }> {
+function resolveEntryPoints(pkgDir: string, exportsField: Record): Array<{ subpath: string; filePath: string }> {
const entryPoints: Array<{ subpath: string; filePath: string }> = [];
for (const [subpath, value] of Object.entries(exportsField)) {
if (typeof value !== 'object' || value === null) continue;
- let entry: any = (value as Record).import ?? (value as Record).types;
- if (typeof entry === 'object' && entry !== null) entry = entry.types || entry.default;
+ let entry: unknown = (value as Record).import ?? (value as Record).types;
+ if (typeof entry === 'object' && entry !== null) entry = (entry as Record).types || (entry as Record).default;
if (!entry || typeof entry !== 'string') continue;
// Wildcard exports (e.g. "./*") can't be resolved to a single file here.
if (entry.includes('*')) continue;
@@ -942,75 +963,100 @@ function roleFromName(componentName: string, base: string): string {
return role || 'Root';
}
+/**
+ * Build a single component group from its directory, or `null` when the dir is
+ * not a component group (no `.vue`). `category` is the display label; `entryPoint`
+ * is the package subpath (e.g. `./forms/checkbox`).
+ */
+function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
+ // A component group is any dir that ships at least one .vue file.
+ const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
+ if (vueFiles.length === 0) return null;
+
+ const base = toPascalCase(slug);
+
+ // Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
+ // excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
+ // that aren't part of the public API. Fall back to all .vue (minus demo) only
+ // when the barrel exposes no parseable `export { default as X }`.
+ const order = readPartOrder(resolve(dir, 'index.ts'));
+ const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
+ const candidates = publicFiles.length > 0
+ ? publicFiles
+ : vueFiles.filter(f => f !== 'demo.vue');
+ // Drop internal implementation/variant parts users never compose directly
+ // (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
+ const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
+ const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
+
+ const parts: ComponentPartMeta[] = [];
+ let groupDescription = '';
+
+ for (const file of orderedFiles) {
+ const sfc = readFileSync(resolve(dir, file), 'utf-8');
+ const plain = extractScriptBlock(sfc, false);
+ const setup = extractScriptBlock(sfc, true);
+ const { props, description } = extractPartProps(plain);
+ const name = file.replace(/\.vue$/, '');
+ const role = roleFromName(name, base);
+ if (role === 'Root' && description && !groupDescription) groupDescription = description;
+
+ // Merge in `defineModel` v-model props/emits (invisible to the interface/
+ // defineEmits parsers), de-duping against any explicitly-declared ones.
+ const models = extractModels(setup);
+ const emits = extractEmits(setup);
+ for (const mp of models.props)
+ if (!props.some(p => p.name === mp.name)) props.push(mp);
+ for (const me of models.emits)
+ if (!emits.some(e => e.name === me.name)) emits.push(me);
+
+ parts.push({ name, role, description, props, emits });
+ }
+
+ return {
+ name: base,
+ slug,
+ category,
+ description: groupDescription,
+ entryPoint,
+ parts,
+ hasDemo: existsSync(resolve(dir, 'demo.vue')),
+ demoSource: '', // loaded lazily client-side via #docs/demo-sources
+ sourcePath: relative(ROOT, dir),
+ };
+}
+
function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return [];
const components: ComponentMeta[] = [];
- for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- const dir = resolve(srcDir, entry.name);
+ // Components live one level deep, in category folders: src///.
+ // The category folder IS the source of truth for the component's category.
+ for (const catEntry of readdirSync(srcDir, { withFileTypes: true })) {
+ if (!catEntry.isDirectory()) continue;
+ const catDir = resolve(srcDir, catEntry.name);
+ const label = CATEGORY_LABELS[catEntry.name];
- // A component group is any dir that ships at least one .vue file.
- const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
- if (vueFiles.length === 0) continue;
-
- const slug = entry.name;
- const base = toPascalCase(slug);
-
- // Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
- // excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
- // that aren't part of the public API. Fall back to all .vue (minus demo) only
- // when the barrel exposes no parseable `export { default as X }`.
- const order = readPartOrder(resolve(dir, 'index.ts'));
- const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
- const candidates = publicFiles.length > 0
- ? publicFiles
- : vueFiles.filter(f => f !== 'demo.vue');
- // Drop internal implementation/variant parts users never compose directly
- // (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
- const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
- const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
-
- const parts: ComponentPartMeta[] = [];
- let groupDescription = '';
-
- for (const file of orderedFiles) {
- const sfc = readFileSync(resolve(dir, file), 'utf-8');
- const plain = extractScriptBlock(sfc, false);
- const setup = extractScriptBlock(sfc, true);
- const { props, description } = extractPartProps(plain);
- const name = file.replace(/\.vue$/, '');
- const role = roleFromName(name, base);
- if (role === 'Root' && description && !groupDescription) groupDescription = description;
-
- // Merge in `defineModel` v-model props/emits (invisible to the interface/
- // defineEmits parsers), de-duping against any explicitly-declared ones.
- const models = extractModels(setup);
- const emits = extractEmits(setup);
- for (const mp of models.props)
- if (!props.some(p => p.name === mp.name)) props.push(mp);
- for (const me of models.emits)
- if (!emits.some(e => e.name === me.name)) emits.push(me);
-
- parts.push({ name, role, description, props, emits });
+ if (label) {
+ // A known category folder — each child dir is a component group.
+ for (const compEntry of readdirSync(catDir, { withFileTypes: true })) {
+ if (!compEntry.isDirectory()) continue;
+ const c = buildComponentAt(
+ resolve(catDir, compEntry.name),
+ compEntry.name,
+ label,
+ `./${catEntry.name}/${compEntry.name}`,
+ );
+ if (c) components.push(c);
+ }
+ }
+ else {
+ // Backward-compat: a flat component dir directly under src.
+ const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
+ if (c) components.push(c);
}
-
- const entryPoint = `./${slug}`;
- const demoPath = resolve(dir, 'demo.vue');
- const hasDemo = existsSync(demoPath);
-
- components.push({
- name: base,
- slug,
- description: groupDescription,
- entryPoint,
- parts,
- hasDemo,
- demoSource: '', // loaded lazily client-side via #docs/demo-sources
- sourcePath: relative(ROOT, dir),
- });
}
return components.sort((a, b) => a.name.localeCompare(b.name));
diff --git a/docs/modules/extractor/index.ts b/docs/modules/extractor/index.ts
index 36b8387..f165e9b 100644
--- a/docs/modules/extractor/index.ts
+++ b/docs/modules/extractor/index.ts
@@ -44,7 +44,7 @@ export default defineNuxtModule({
'@robonen/fetch': 'core/fetch/src',
'@robonen/encoding': 'core/encoding/src',
'@robonen/crdt': 'core/crdt/src',
- '@robonen/editor': 'vue/editor/src',
+ '@robonen/writekit': 'vue/writekit/src',
'@robonen/primitives': 'vue/primitives/src',
'@robonen/vue': vueSrc,
};
@@ -58,7 +58,13 @@ export default defineNuxtModule({
// Primitive `as="template"` / Slot path), silently blanking every demo
// that hits it. `import.meta.env.DEV` resolves correctly in dev & prod.
config.define ??= {};
- (config.define as Record).__DEV__ ??= 'import.meta.env.DEV';
+ // Inline a STATIC boolean, not `import.meta.env.DEV`: a define value is
+ // inserted verbatim and is NOT re-scanned for Vite's `import.meta.env`
+ // replacement, so in a prod build it shipped a literal `import.meta.env.DEV`
+ // into chunks where `import.meta.env` is undefined at runtime →
+ // "Cannot read properties of undefined (reading 'DEV')". A literal
+ // true/false has no runtime dependency and tree-shakes the dev branches.
+ (config.define as Record).__DEV__ ??= JSON.stringify(nuxt.options.dev);
const existing = config.resolve?.alias;
const sourceAliases = [
diff --git a/docs/modules/extractor/types.ts b/docs/modules/extractor/types.ts
index 8ee1ae9..16491fd 100644
--- a/docs/modules/extractor/types.ts
+++ b/docs/modules/extractor/types.ts
@@ -115,6 +115,8 @@ export interface ComponentMeta {
name: string;
/** URL-friendly slug, e.g. "accordion" */
slug: string;
+ /** Functional category for grouping in the docs, e.g. "Forms", "Overlays". */
+ category: string;
/** Short description (from README heading or first JSDoc) */
description: string;
/** Subpath export, e.g. "./accordion" */
diff --git a/docs/modules/mcp/docs-index.test.ts b/docs/modules/mcp/docs-index.test.ts
index bd5df48..7ad92cd 100644
--- a/docs/modules/mcp/docs-index.test.ts
+++ b/docs/modules/mcp/docs-index.test.ts
@@ -159,15 +159,15 @@ describe('getPackage / resolveEntry', () => {
describe('slug uniqueness & collisions', () => {
// A function and a co-located type/interface whose names differ only in case
// both slugify to the same value — the real extractor produces these in
- // @robonen/editor and @robonen/vue.
+ // @robonen/writekit and @robonen/vue.
const colliding: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z',
packages: [
{
- name: '@robonen/editor',
+ name: '@robonen/writekit',
version: '1.0.0',
- description: 'Editor',
- slug: 'editor',
+ description: 'Writekit',
+ slug: 'writekit',
kind: 'api',
group: 'vue',
entryPoints: ['.'],
@@ -197,12 +197,12 @@ describe('slug uniqueness & collisions', () => {
it('reaches both colliding symbols — function and interface — independently', () => {
const leaves = buildLeaves(colliding);
// Exact case-sensitive name disambiguates the function from the interface.
- const fn = resolveEntry(leaves, 'editor', 'position');
- const iface = resolveEntry(leaves, 'editor', 'Position');
+ const fn = resolveEntry(leaves, 'writekit', 'position');
+ const iface = resolveEntry(leaves, 'writekit', 'Position');
expect(fn?.kind === 'api' && fn.item.kind).toBe('function');
expect(iface?.kind === 'api' && iface.item.kind).toBe('interface');
// The disambiguated slug also resolves the interface directly.
- const bySlug = resolveEntry(leaves, 'editor', 'position-interface');
+ const bySlug = resolveEntry(leaves, 'writekit', 'position-interface');
expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface');
});
diff --git a/docs/nuxt.config.ts b/docs/nuxt.config.ts
index 7879013..b016e77 100644
--- a/docs/nuxt.config.ts
+++ b/docs/nuxt.config.ts
@@ -20,6 +20,8 @@ export default defineNuxtConfig({
vite: {
plugins: [
+ // `as any`: @tailwindcss/vite and Nuxt resolve different `vite` versions, so
+ // their `Plugin` types are structurally identical but nominally incompatible.
tailwindcss() as any,
],
},
diff --git a/docs/public/favicon.ico b/docs/public/favicon.ico
new file mode 100644
index 0000000..18993ad
Binary files /dev/null and b/docs/public/favicon.ico differ
diff --git a/docs/public/robots.txt b/docs/public/robots.txt
new file mode 100644
index 0000000..0ad279c
--- /dev/null
+++ b/docs/public/robots.txt
@@ -0,0 +1,2 @@
+User-Agent: *
+Disallow:
diff --git a/web/vue/src/composables/broadcastedRef/index.ts b/web/vue/src/composables/broadcastedRef/index.ts
new file mode 100644
index 0000000..76c035d
--- /dev/null
+++ b/web/vue/src/composables/broadcastedRef/index.ts
@@ -0,0 +1,27 @@
+import { customRef, onScopeDispose } from 'vue';
+
+export function broadcastedRef(key: string, initialValue: T) {
+ const channel = new BroadcastChannel(key);
+
+ onScopeDispose(channel.close);
+
+ return customRef((track, trigger) => {
+ channel.onmessage = (event) => {
+ track();
+ return event.data;
+ };
+
+ channel.postMessage(initialValue);
+
+ return {
+ get() {
+ return initialValue;
+ },
+ set(newValue: T) {
+ initialValue = newValue;
+ channel.postMessage(newValue);
+ trigger();
+ },
+ };
+ });
+}
\ No newline at end of file