--- url: /guide/getting-started.md --- # Getting Started Set up the Cocoar Design System in your Vue 3 project in a few steps. ## 1. Install ```bash pnpm add @cocoar/vue-ui ``` ## 2. Import Fonts & Styles Import fonts and styles in your app's entry point. Fonts are self-hosted via `@fontsource` — no external CDN needed. ```ts // main.ts import '@cocoar/vue-ui/fonts'; // Poppins + Inter (self-hosted) import '@cocoar/vue-ui/styles'; // Design tokens + component styles ``` ::: info Bring your own fonts? The font import is optional. If you prefer a CDN or custom fonts, skip `@cocoar/vue-ui/fonts` and load them yourself. Components fall back to system fonts gracefully. ::: ## 3. Use Components Import components directly — no global registration required. Tree-shaking is automatic. ```vue ``` ## 4. Dark Mode Toggle dark mode by adding the `.dark-mode` class to the root element. All design tokens and components adapt automatically. ```ts document.documentElement.classList.toggle('dark-mode', isDark); ``` ## 5. Overlay System For components that render overlays (Dialog, Toast, Popover, Tooltip), register the plugin once: ```ts // main.ts import { createApp } from 'vue'; import { CoarOverlayPlugin } from '@cocoar/vue-ui'; createApp(App) .use(CoarOverlayPlugin) .mount('#app'); ``` And add the overlay host to your root layout: ```vue ``` ## Date/Time Components The date and time pickers use the [Temporal API](https://tc39.es/proposal-temporal/docs/) via `@js-temporal/polyfill`, which is included as a dependency of `@cocoar/vue-ui`. No extra install needed. When native Temporal support reaches all browsers, the polyfill can be dropped in a future major release. ## Additional Packages Optional packages for extended functionality: ```bash pnpm add @cocoar/vue-localization # i18n & timezone pnpm add @cocoar/vue-data-grid # AG Grid wrapper pnpm add @cocoar/vue-markdown # Markdown viewer ``` --- --- url: /guide/error-handling.md --- # Error Handling Cocoar UI components are designed to fail gracefully. Internal errors are either caught and recovered silently, or surfaced to the user through controlled feedback mechanisms. This guide explains the patterns used throughout the library and how to handle errors in your own application code. ## Library Philosophy: Fail Gracefully Components never throw unhandled exceptions into your application. Instead they follow one of two patterns: * **Silent fallback** — return a safe default (`null`, `'UTC'`, `false`) and continue * **User feedback** — surface the error visibly via a Toast or state change ## Overlay Promises `CoarDialog` and `CoarPopconfirm` return Promises. Always handle the rejection case: ```ts import { useDialog } from '@cocoar/vue-ui'; const dialog = useDialog(); // ✅ Always add .catch() dialog.confirm({ title: 'Delete item', message: 'This cannot be undone.', }) .then((confirmed) => { if (confirmed) deleteItem(); }) .catch(() => { // Dialog was closed unexpectedly (e.g. overlay destroyed before user responded) }); ``` With async/await: ```ts try { const confirmed = await dialog.confirm({ title: 'Delete item', message: '...' }); if (confirmed) await deleteItem(); } catch { // Handle unexpected close } ``` ::: tip Popconfirm `CoarPopconfirm` emits `@confirmed` and `@cancelled` events — no Promise handling needed there. Use it for simple inline confirmations, and reserve `useDialog()` for programmatic flows where error handling is more important. ::: ## Toast for Error Feedback Use `useToast().error()` to surface errors to the user. Error toasts are persistent by default (duration `0`) — they stay until the user dismisses them, which is appropriate for errors that require attention. ```ts import { useToast } from '@cocoar/vue-ui'; const toast = useToast(); async function saveData() { try { await api.save(payload); toast.success('Saved successfully'); } catch (err) { toast.error('Save failed', { message: err instanceof Error ? err.message : 'Please try again.', }); } } ``` ```ts // With a retry action toast.error('Connection lost', { message: 'Could not reach the server.', action: { label: 'Retry', callback: () => saveData(), }, }); ``` ## Date and Time Parsing Date parsing functions return `null` on failure instead of throwing. Always null-check the result before using it: ```ts import { coarParsePlainDate } from '@cocoar/vue-ui'; const date = coarParsePlainDate(userInput); if (date === null) { // Input was invalid — show validation error toast.error('Invalid date format'); return; } // date is a Temporal.PlainDate — safe to use processDate(date); ``` The date picker components handle this internally — invalid input simply doesn't update the model value. Your `v-model` will remain `null` until the user enters a valid date. ## Timezone Fallbacks Timezone utilities default to `'UTC'` when the browser API fails or the timezone identifier is unrecognised. This keeps date/time components functional even in restricted environments: ```ts import { useTimezone } from '@cocoar/vue-localization'; const { timezone } = useTimezone(); // Always a valid IANA identifier — 'UTC' as last resort ``` ## Async Operations in Overlays When loading data inside a Dialog or Popover, manage loading and error states yourself: ```vue ``` ## Pattern Summary | Situation | Recommended pattern | |-----------|---------------------| | Dialog/Popconfirm result | `.then().catch()` or `try/await/catch` | | API call in component | `try/catch` + `toast.error()` | | Date input validation | Null-check return value of parse functions | | Non-recoverable error | `toast.error()` with persistent duration (default) | | Recoverable error | `toast.error()` with `action: { label: 'Retry', callback }` | | Silent failures OK | Rely on library defaults (`null`, `'UTC'`, `false`) | --- --- url: /guide/theming.md --- # Theming Cocoar uses an **oklch-based** color system. You set a few base colors, and the entire palette — including all shades for light and dark mode — is auto-calculated. ## Quick Start Override the CSS custom properties on `:root` to match your brand: ```css :root { --coar-accent: #1183CD; /* Your brand color → primary buttons, links, focus rings */ } ``` That's it. All accent shades (50–900), in both light and dark mode, recalculate from this single value. ## Customizable Base Colors | Variable | Default | Purpose | |----------|---------|---------| | `--coar-accent` | `#1183CD` | Brand/accent color — primary buttons, active states, links | | `--coar-success` | `#1e8f48` | Success states — confirmations, positive feedback | | `--coar-error` | `#d63b3b` | Error states — validation errors, destructive actions | | `--coar-warning` | `#cc821f` | Warning states — caution, attention needed | | `--coar-info` | `#5e6b84` | Info states — neutral informational context | ### Example: Red Brand ```css :root { --coar-accent: #C41E3A; /* Red brand */ --coar-error: #8B0000; /* Darker red so errors are distinguishable */ } ``` ### Example: Purple Brand ```css :root { --coar-accent: #7C3AED; } ``` ## How It Works Each base color generates a 10-step shade scale using **oklch relative color syntax**: ```css /* You set this: */ --coar-accent: #1183CD; /* The library calculates these: */ --coar-color-accent-50: oklch(from var(--coar-accent) 0.97 0.012 h); /* lightest */ --coar-color-accent-100: oklch(from var(--coar-accent) 0.92 0.035 h); --coar-color-accent-200: oklch(from var(--coar-accent) 0.84 0.075 h); /* ... */ --coar-color-accent-500: var(--coar-accent); /* = your exact color */ /* ... */ --coar-color-accent-900: oklch(from var(--coar-accent) 0.31 0.095 h); /* darkest */ ``` The `h` (hue) is extracted from your color. Lightness and chroma follow a designed curve that keeps shades vibrant instead of washed out. `accent-500` is always your exact brand color. This works because **oklch is perceptually uniform** — unlike HSL, a lightness of 0.5 in oklch looks equally "medium" for blue, red, and yellow. ## Fine-Tuning Individual Shades If an auto-calculated shade doesn't look right for your specific color, override it: ```css :root { --coar-accent: #FF6600; /* Auto-calculated 50 too warm? Override just that one: */ --coar-color-accent-50: #FFF5EB; } ``` ## Dark Mode Dark mode shades are calculated from the same base variables — no need to set anything extra. The library uses a separate lightness/chroma curve designed for dark backgrounds: * Low numbers (50–200): dark with a subtle color tint * Mid range (300–500): vibrant and saturated * High numbers (600–900): lighter for text on dark backgrounds The primary button color (`accent-500`) stays identical in both modes. ## Browser Support The oklch color system requires: * Chrome 119+ * Firefox 128+ * Safari 18+ This covers all modern browsers. For older browsers, consider providing hex fallbacks for your specific brand color. --- --- url: /guide/changelog.md --- # Changelog All notable changes to the Cocoar Design System (Vue) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions are calculated automatically by [GitVersion](https://gitversion.net/). *** ## 2.5.2 Patch release that fixes an outside-click dismissal bug in the `@cocoar/vue-ui` overlay-service: an anchored panel (date picker, select, popover, menu) opened from inside a modal/dialog would not close when the user clicked elsewhere in the dialog body — it only closed when its own trigger was clicked again. Outside a modal the same panels dismissed correctly, so the bug only surfaced for overlays stacked above another containing overlay. Root cause: `onDocumentPointerDown` treated "click landed inside overlay X" as "collapse X's tree-children and stop", which silently ignored unrelated dismissable overlays stacked above X. A picker opened via the overlay-service is teleported to `` and stacked above the dialog but is not a tree-child of it, so it was skipped. Reported against the date pickers, but the same code path affects every anchored overlay (all three date pickers, `CoarSelect` / `CoarMultiSelect` / `CoarTagSelect`, `CoarPopover`, menus) when used inside a modal. ### Fixed * **`@cocoar/vue-ui` — overlay-service outside-click dismissal inside modals.** When a click lands inside an overlay, `onDocumentPointerDown` now also closes any dismissable overlay stacked *above* it whose own subtree doesn't contain the click — instead of only collapsing the clicked overlay's tree-children. Fixes anchored panels (date pickers, selects, popovers, menus) staying open after an outside click when opened from inside a `useDialog()` / modal overlay; previously they only closed when the trigger was re-clicked. Sub-menu collapse and modal backdrop behaviour are unchanged. *** ## 2.5.1 Patch release that fixes a pdf.js styling leak in `@cocoar/vue-document-viewer`. `pdfjs-dist` appends an internal working canvas (`canvas.hiddenCanvasElement`) directly to `` and relies on its own `pdf_viewer.css` to keep it invisible. The viewer didn't ship that stylesheet, so the canvas surfaced as a 300×150 black rectangle and inflated `document.body.scrollHeight` by 150 px the moment any PDF source mounted in a non-modal layout. Modal contexts hid the symptom (overlay above, `overflow: hidden` chrome) but full-routed views surfaced it immediately. Reported from Finoxl's BookingView; same pattern would hit any consumer embedding the viewer in a full-page layout. Fix inlines the minimum subset of pdf.js's body-level rules into the viewer's own stylesheet — `import '@cocoar/vue-document-viewer/styles'` stays the complete styling import. ### Fixed * **`@cocoar/vue-document-viewer` — body-level `.hiddenCanvasElement` + `#hiddenCopyElement`** now hidden via the package's own CSS. Adds an unscoped global rule (`position: absolute; top: 0; left: 0; width: 0; height: 0; visibility: hidden; overflow: hidden`) to `vue-document-viewer.css`, mirroring the minimum subset of `pdfjs-dist/web/pdf_viewer.css` needed for the body-mounted helpers. No API change, no consumer action needed. The block sits next to the existing pdfjs textLayer subset already mirrored in `CoarDocumentViewer.vue` for the same reason. *** ## 2.5.0 Polish release that lands integration-feedback from the first wave of consumers using `@cocoar/vue-file-explorer` v2.4.0 (Atlas — backend-backed Knowledge editor) and Finoxl (Booking modal — `CoarFormField`), and reworks `CoarFormField` from a label-plus-message-row wrapper into a per-field status indicator with a popover-driven severity model that handles hints, warnings, errors, live-validation rules, and X-of-Y aggregate constraints with a single mechanism. Most of the file-explorer changes fix a contract gap where the documented `loadTree()` method was never actually called by the composable — only the in-memory store's undocumented `_assets` ref was. Now both paths work: composables that supply a reactive `_assets` get the zero-overhead live-mirror as before; composables that only implement the typed `AssetStore` get an internal projection seeded by `loadTree()` on mount and patched after every CRUD op. `CoarTabGroup` gains a `fill` opt-in so editor / viewer / file-explorer tabs no longer need `:deep()` workarounds to propagate height. `CoarTree` warns in dev when rendered empty without an `#empty` slot. `CoarFormField` is a meaningful visual change for every consumer: the message-below-input row is gone, replaced by an icon in the label row with a popover that groups everything by severity section (hint → checklist → errors → warnings); form-labels bumped from 12 px to 14 px (caption-size to medium-input-label-size — the caption token kept its 12 px for tags / badges / dropdowns). No breaking API changes — `error: string` stays valid (now sugar for a one-item array), `hint` keeps the same prop shape (only its render location moved). ### Added * **`@cocoar/vue-file-explorer` — `loading: Ref` + `refresh(folderId?)`** on `UseFileExplorerReturn`. `loading` is the initial-`loadTree()` signal (stays `false` for `_assets`-backed stores); `refresh()` re-runs `loadTree()` or per-folder `loadChildren()` for SignalR / multi-tab / retention-sweep scenarios. * **`@cocoar/vue-ui` — `CoarTabGroup.fill: boolean`** (default `false`). When true, the active tab panel + content wrapper flip from `display: block` to `flex: 1; min-height: 0; display: flex; flex-direction: column`, propagating the root's height down. The root itself stays untouched (consumer's parent layout decides whether the tab-group stretches). Removes the `:deep(.coar-tab-panel)` workaround consumers had to write for Monaco / PDF / file-explorer tabs. * **`@cocoar/vue-ui` — `CoarFormField.warning`** (`string | readonly string[]`): non-blocking warnings, drives an orange `triangle-alert` icon when no error is also set. Input stays valid (`aria-invalid="false"`); SR announcements are non-urgent (no `role="alert"`). Errors continue to win severity for icon + invalid state. * **`@cocoar/vue-ui` — `CoarFormField.rules`** + **`CoarFormFieldRule`** type: live-validation rules with two-axis display modes. Each rule has `label`, `fulfilled: boolean`, and optional `whenPass: 'success' | 'hide'` (default `'success'`) + `whenFail: 'pending' | 'warning' | 'error' | 'hide'` (default `'pending'`). The defaults give you the password-checklist UX (✓ green when fulfilled, ○ grey when not). `whenFail: 'error'` promotes the rule to the popover's Errors section and drives `aria-invalid="true"` on the child input — that's the validity signal. `whenPass: 'hide'` + `whenFail: 'error'` gives the live-validation pattern (disappears when ok, red error when not — e.g. "Max 20 chars"). `whenFail: 'warning'` is the live-advisory pattern. Rules are reactive via Vue's template eval — write `text.length <= 20` inline; no `() => boolean` needed. Named types `CoarFormFieldRule`, `CoarFormFieldRulePassMode`, `CoarFormFieldRuleFailMode` exported for IntelliSense in `computed(...)` consumer code. Aggregate "X of Y must be satisfied" patterns compose by adding a 5th rule with `whenFail: 'error'` that checks the count — the 4 individual rules stay as progress, the aggregate is the validity gate. * **`@cocoar/vue-ui` — `CoarFormField` per-section icons in popover**: hint section gets a grey `info` icon, error section a red `circle-alert`, warning section an orange `triangle-alert`. Section icon only renders on the first line of each section; subsequent items in a section flow left-aligned under the first message's text. Per-rule icons (✓ green `check`, ○ grey `circle`) in the rules checklist section. * **`@cocoar/vue-ui` — `CoarFormFieldStatusPanel.vue`** internal sub-component carrying the popover content. Lifted out of `CoarFormField` so the section layout is testable without spinning up the overlay service. * **`@cocoar/vue-ui` — `circle` icon** added to core-icons (used by the rules-checklist for unfulfilled `○` state). `check-circle-2` was already present; it's now also used as the trigger icon when the rule severity is `success`. ### Changed * **`@cocoar/vue-file-explorer` — `useFileExplorer` now calls `store.loadTree()` on mount** and patches its internal projection after each successful CRUD op (`createFolder`, `createFile`, `uploadFile`, `delete`, `rename`, `move`). Stores that surface a reactive `_assets` ref keep their previous zero-overhead path (the in-memory reference impl is unchanged). Before this release, the composable read `store._assets` directly and never invoked `loadTree()` — any store that implemented the typed `AssetStore` contract without the undocumented `_assets` escape hatch rendered an empty tree silently. * **`@cocoar/vue-ui` — `CoarFormField` error rendering** rebuilt as a per-field status indicator. Replaces the message-below-input row with a single severity-driven icon in the label row that opens a popover listing every applicable message grouped into sections: hint → rules checklist → errors → warnings. The icon is conditionally rendered so its appearance shifts the label-text right by `icon-width + gap` — that small horizontal nudge is the attention signal. Form's vertical geometry stays stable (no row appears below the input, no Submit button moves). Hover the icon for a peek, click to pin (load-bearing for a planned form-wide error-summary panel where each item will scroll to its field + open its popover). * **`@cocoar/vue-ui` — `CoarFormField` trigger-icon severity model** is "highest severity visible in the popover": ≥1 error item → red; else ≥1 warning item → orange; else ≥1 success item (a fulfilled `whenPass: 'success'` rule, e.g. a green ✓) → green; else ≥1 pending item or hint → grey info; else no icon. Success **wins over** pending — once the user has fulfilled any rule, the icon flips green for positive reinforcement (the popover still shows unfulfilled rules as ○ for "could do more" detail). Validity-gate rules with `whenFail: 'error'` keep ownership of the red/error path; pending is reserved for genuinely optional progress. * **`@cocoar/vue-ui` — `CoarFormField.error` widened** to `string | readonly string[]` — pass multiple validation errors as an array, single-string form still works as sugar for a one-item array. * **`@cocoar/vue-ui` — `CoarFormField.hint` moved fully into the popover** — the always-visible help row below the input is gone. Hint sits at the top of the popover (grey, info icon). * **`@cocoar/vue-ui` — `CoarFormField` label font-size** bumped from `--coar-body-caption-size` (12 px) to `--coar-component-m-label-font-size` (14 px). The caption token kept its 12 px and is still used by tags, badges, dropdowns, and other decoration text — only form labels moved to the medium-input-label-size tier, which is what they were always meant to be. Every `CoarFormField` consumer sees a small visual bump on every label. ### Fixed * **`@cocoar/vue-file-explorer` — `resolveFileMeta`** now defaults `language` to `'plaintext'` when the resolved editor is `'script'` but no language was supplied (e.g. `Dockerfile`, `Makefile`, `LICENSE`, or any `asset.editor === 'script'` without `asset.language`). Saves every consumer the `?? 'plaintext'` dance when binding to ``. * **`@cocoar/vue-ui` — `CoarTree` DEV-only warn when rendered empty without an `#empty` slot**, with a 500-ms grace so async loaders (a store's `loadTree()`) don't trigger false positives. Stripped from production builds via `import.meta.env.DEV`. Catches silent blank-pane regressions. * **`@cocoar/vue-ui` — `CoarOtpInput.transform` + `accept` props** now have explicit `undefined` defaults to satisfy `vue/require-default-prop` (pre-existing lint warning, not a runtime change). ### Docs * **`@cocoar/vue-file-explorer` — `AssetStore`** docs spell out that `loadTree()` is called on mount, document the patch-after-CRUD model, and add a "Reacting to out-of-band updates" section covering both patterns: `api.refresh()` on signal (default) and surfacing `_assets` directly (Pinia / live-query escape hatch). * **`@cocoar/vue-file-explorer` — `useFileExplorer`** `loading` + `refresh` added to the return-surface and imperative-ops tables. * **`@cocoar/vue-ui` — `CoarTabGroup`** new "Fill-Height Tabs" section with `TabsFill` demo + opt-in rationale tip. * **`@cocoar/vue-ui` — `CoarFormField`** docs rewritten for the new status indicator + rules system: tip box at the top, new "Status Indicator", "Live Rules", "On-Submit Validation" sections with live demos. Rules section covers the four common patterns table (progress / live-validation / live-advisory / required-with-progress-tick), trigger-icon severity priority list, and the X-of-Y aggregate-rule pattern with a dedicated demo. Refreshed accessibility section explaining the per-message SR-only spans + space-separated `aria-describedby` aggregation. *** ## 2.4.0 Introduces a new package — `@cocoar/vue-file-explorer` — a VSCode-style file/asset explorer for Vue 3 built as a single composable over a pluggable `AssetStore` backend (no wrapper component for v1 — consumers compose the shell themselves; the worked example is the 1280-LoC playground POC, the docs ship five live demos). Composable-only is a deliberate v1 scope decision: the file-explorer's shell varies wildly per consumer (tab styling, editor dispatch, simulator panels, context-menu shape), but the bits that are hard to get right — placeholder-then-fill open with optimistic rollback, conflict pipeline, blob-URL lease tracking, beforeunload-while-dirty, drag-to-reorder tabs, 3-stage file-meta fallback — all live in `useFileExplorer`. Ships alongside a new generic tree primitive in `@cocoar/vue-ui`, `CoarTree`, with full drag-and-drop (reorder + OS file drop), keyboard navigation, declarative per-target context menus via the builder API, inline rename, and virtualisation. The two were designed together — file-explorer composes `CoarTree`'s drop event into `store.move()` and the inline rename UI lives entirely on the tree side — but `CoarTree` stands on its own for any navigable hierarchy. Also lands a `size` prop on `CoarBreadcrumb`, an `onlyOnOverflow` gate on the `v-tooltip` directive, and broader language support on `CoarScriptEditor` (~40 Monaco grammars + plaintext fallback for extension-less files). Purely additive — no breaking changes. ### Added * **`@cocoar/vue-file-explorer` — new package** (Preview tier): `useFileExplorer({store, ...})` is the entire public surface besides the `AssetStore` types and the `createInMemoryAssetStore` reference impl. The composable owns the data plane (`store._assets` projection with sort-mode + parentId filter, CRUD ops with optimistic rollback, error funnel through a single `onError(op, err, ctx)` callback), the tree state (`selectedId`, `expanded`), the tab state machine (preview vs pinned with VSCode auto-pin-on-edit semantics, dirty tracking via `content !== savedContent`, save-flow with `savingNodes`-blocked close, drag-to-reorder via `reorderTab`), the async state (`loadingNodes: Set` for content fetch + lazy load, `savingNodes: Set` for in-flight mutations), the blob-URL leases (revoked on delete + `onScopeDispose`), and the `beforeunload` warning while any tab is dirty. Returns refs to bind into `` (`rootNodes`, `getId`, `getChildren`, `getLabel`, `isExpandable`, `expanded`, `selectedId`) + imperative ops for everything else (`openFile`, `saveTab`, `closeTab` / `closeOthers` / `closeToRight` / `closeAll`, `pinTab` / `unpinTab` / `reorderTab`, `addFolder`, `addFiles` for OS drops, `deleteNode`, `moveNode`, `rename`, `revealInTree`, `pathOf`, `fileMeta`). The placeholder-then-fill `openFile` flow pushes the tab + activates immediately so the editor-area overlay shows on the right pane while `store.loadContent` is awaited; on rejection the placeholder rolls back so the user isn't stranded on an empty editor for a file that never loaded. Editors only mount when `!loadingNodes.has(activeTab.id)` — otherwise heavy viewers (CoarDocumentViewer, Monaco, Milkdown) render their own error UI with empty content before the overlay can take over. * **`@cocoar/vue-file-explorer` — `AssetStore` contract**: flat interface with `Asset` shape (`{ id, name, kind, parentId, hasChildren?, editor?, language?, payload? }`) — hierarchy is filesystem-style flat with `parentId` links, NOT nested `children[]`, so move/delete/rename operate on single objects and the composable projects to tree via filter. Read methods (`loadTree`, optional `loadChildren` for lazy mode, `loadContent`), write methods (`createFolder`, `createFile`, separate `uploadFile` so backends can pick multipart/signed-URL transports, `save`, `rename`, `delete`, `move` with optional `position`). `createAssetStore(config)` is the recommended factory — thin passthrough today, future home for cross-cutting wrappers (request dedup, retry, telemetry). Lazy mode opt-in via the presence of `loadChildren` on the store (capability probe is `'loadChildren' in store`) — the composable defaults `initialExpandedIds = []` in lazy mode for canonical click-to-expand UX, watches `expanded` with `immediate: true` so seeded ids preload, sets `loadingNodes` per folder during fetch. Eager stores leave `loadChildren` undefined and the watcher is dead code. * **`@cocoar/vue-file-explorer` — `createInMemoryAssetStore` reference implementation**: browser-only backend backed by `ref[]>` + an `id → content` Map. Reactive knobs (`latencyMs` + `failureRate` are `MaybeRefOrGetter` read per-op via `toValue`; `onConflict` is `MaybeRefOrGetter>`; `sortMode` lives on the composable for the same reactivity). Conflict policy resolution runs BEFORE simulated latency so `'prompt'` UIs aren't blocked behind it. Lazy mode (`lazy: true`) switches `_assets` to a `ComputedRef` filtered by an internal `_publishedIds` Set; only published subtrees are visible to the composable, the complete dataset still lives in the store's internal bookkeeping for move / delete / cycle-guard. The store exposes `_assets` and `_contents` escape hatches for tests + devtools (underscore-prefixed to mark them as non-portable — a real-backend store won't have them). * **`@cocoar/vue-file-explorer` — conflict policy**: `ConflictPolicy = 'rename' | 'overwrite' | 'prompt' | 'error' | ((info) => ConflictResolution | Promise<…>)`. Default `'rename'` mirrors Finder / VSCode auto-suffixing (`foo.txt` → `foo (2).txt` → `foo (3).txt`, capped at 999 iterations with UUID-tagged fallback). `'overwrite'` recursively deletes the existing entry before proceeding. `'prompt'` opens `window.prompt` with the auto-suggested name as default; cancel → throws conflict error. Function form receives a `ConflictInfo` with `existing` asset, `incoming` (name + kind), `parentId`, and `suggestedRename`. Applies to `createFolder` / `createFile` / `uploadFile` ONLY — `move` and `rename` deliberately bypass the policy because they're explicit user intent (silently changing the requested name would be surprising). Resolution runs BEFORE `settle()` so prompt UIs aren't latency-blocked. * **`@cocoar/vue-file-explorer` — sort modes**: `SortMode = 'manual' | 'folders-first' | 'alphabetical' | AssetComparator`, default `'folders-first'` (VSCode pattern — folders alphabetical, then files alphabetical). `'alphabetical'` = Finder-style mixed. `'manual'` preserves the store's array order so drag-reorder between siblings sticks. Reactive via `MaybeRefOrGetter` so a toolbar can swap modes live. `api.reorderable: Ref` is `true` only in `'manual'` mode; in other modes the composable silently drops the `position` arg on `move` (the comparator decides where the moved node lands). Lives on `FileExplorerConfig`, NOT on the store, because filesystem backends can't persist per-entry order (filesystems have no such concept) — that's why VSCode's explorer doesn't support drag-reorder between siblings, only move-into-folder. * **`@cocoar/vue-file-explorer` — 3-stage `FileMeta` fallback**: `asset.editor` (explicit on the asset) → `config.getFileMeta(asset)` (consumer override returning `null` to fall through) → `defaultFileMetaFromName(asset.name)` (extension heuristic recognising markdown + PDF + common image formats + the ~40 Monaco script-editor languages). `resolveFileMeta(asset, {getFileMeta})` is exported for use outside the composable. Unrecognised extensions return `null` and the caller skips with a `console.warn` (same as the POC's behaviour). * **`@cocoar/vue-file-explorer` — error funnel**: single `onError(op, err, ctx)` callback in `UseFileExplorerOptions`. By the time it fires the composable has already rolled back the optimistic mutation — failed `loadContent` removes the placeholder tab, failed `uploadFile` skips the follow-up `save`, failed `move` reverts the parent change. `AssetOp` is one of `'loadTree' | 'loadChildren' | 'loadContent' | 'createFolder' | 'createFile' | 'uploadFile' | 'save' | 'rename' | 'delete' | 'move'`; `AssetOpContext` carries `id`, `parentId`, `name`, and `file` (for `uploadFile`) so consumers can format toast / dialog / inline messages with file context. * **`@cocoar/vue-document-viewer` — `v-model:sidebar-open` + `v-model:annotations-panel-open`**: the left rail (thumbnails / outline) and right rail (info + annotations list) now expose their open/closed state as `defineModel` props with `default: false`. Without `v-model` the behavior is unchanged (state lives internally, no breaking change). With `v-model`, the state is held by the consumer's parent component — useful inside a file-explorer shell where the user toggles a rail open, switches to a different editor (Markdown / Monaco), and switches back: with `v-model` the panel state survives the remount; without it, the rail comes back closed because the viewer instance is fresh. New emits: `update:sidebarOpen`, `update:annotationsPanelOpen`. No new internal state — the existing `ref(false)` declarations are replaced by `defineModel` which falls back to the same uncontrolled local-ref behavior when the prop isn't bound. Surfaces the "persistent viewer config across file swaps" pattern documented under `/components/file-explorer/use-file-explorer#persistent-viewer-config-across-file-swaps`. * **`@cocoar/vue-ui` — `CoarTree` component**: generic, keyboard-navigable, drag-drop-aware tree primitive. Identity, children, and label are extracted via prop functions — render any node shape without forcing a common base type. Two APIs that compose but should not be mixed on the same instance: **props-mode** (`nodes`, `getId`, `getChildren`, `getLabel`, `isExpandable`, manual `` wiring) for simple cases; **builder-mode** (`useTree()` returns `{ builder, api }`, the builder configures data / behavior / handlers / `folderMenu` / `leafMenu` / `viewportMenu` in a fluent chain, the tree renders its `` itself, the imperative `api` exposes `focusNode` / `startRename`). Features: drag-and-drop reorder with `before` / `after` / `inside` semantics (rejects self-onto-descendant drops, draws a 2-pixel indicator line for siblings + dashed outline for inside, auto-expands collapsed folders after hover dwell); OS file drop via `accepts-files` with `@files-drop` emitting raw `FileList` + target folder (or `null` for background drop); hover-revealed `⋮` button per row that opens the same context menu as right-click (keyboard + left-click parity with right-click); keyboard nav (arrows, Home/End, Enter/Space, type-to-jump); flat-rendering virtualisation under the hood — handles thousands of nodes without DOM bloat; tooltip on truncated labels via the new `vTooltip.onlyOnOverflow` gate; drop zone fills its container (`min-height: 100%`) so OS drops onto the empty area of sparse trees register. Desktop-first by explicit design — right-click context menus and hover-revealed row affordances are part of the intended UX (exception to the library's tablet-first principle, documented inline). Full docs at `/components/tree/` with six live demos (basic, drag-reorder, file-drop, context-menu, builder API, virtualisation). * **`@cocoar/vue-ui` — `CoarTree.renamable` API**: opt-in inline rename. New `` drops into the consumer's default slot and swaps `` ↔ `` via inject — the consumer never wires `renamingId` / buffer / blur handlers manually. The tree owns F2-on-focused-row, Enter / Escape / blur with a 200-ms grace timer for menu-overlay focus-restore (so renaming from a context-menu item doesn't immediately blur the input when the menu closes). New events: `@rename({ node, newName })`, `@rename-cancel(node)`. New exposed: `api.startRename(id)`. New types: `CoarTreeRenameContext`, `CoarTreeRenameEvent`, `CoarTreeNodeSlotProps.isRenaming`. File-explorer composable consumes this directly — the consumer just listens to `@rename` and calls `fe.rename(node.id, newName)`; ~80 LoC of inline rename machinery dropped from the POC. * **`@cocoar/vue-ui` — `CoarBreadcrumb.size` prop** (`'m' | 's'`): `'s'` produces the slim secondary-chrome variant used by the file-explorer's path strip and any "file path under a tab bar" layout. The token cascades to children so links + separators all scale together. Default `'m'` unchanged. * **`@cocoar/vue-ui` — `vTooltip.onlyOnOverflow` gate** (`boolean | selector | (el) => boolean`): when set, the tooltip only shows if the target element is overflowing — or, with a selector / function, if a specified descendant is. Eliminates the "tooltip-on-everything" anti-pattern for truncated lists. Used by `CoarTree`'s row labels. * **`@cocoar/vue-ui` — `ellipsis-vertical` icon**: new core icon for `⋮` overflow menus. Used by `CoarTree`'s hover-revealed row menu and available for general consumer use via ``. * **`@cocoar/vue-script-editor` — broader language support**: `LANGUAGE_EXTENSIONS` extended to ~40 Monaco grammars — TypeScript, JavaScript, JSON, YAML, CSS / SCSS / LESS, HTML, XML, SQL, Shell (bash / zsh / fish), Dockerfile, INI / TOML, C#, C / C++ / Objective-C, Java, Python, Go, Rust, Ruby, PHP, Swift, Kotlin, Scala, Lua, Perl, Dart, F#, VB, R, PowerShell, Solidity, Protobuf, GraphQL, Razor / cshtml, Pug, Handlebars, Twig — plus plaintext fallback for `.txt` / `.log` / `.env` / `.csv` / `.tsv` / extension-less files. `LANGUAGE_EXTENSIONS` is now `Partial>` with an `extensionFor` fallback to the language name itself when no explicit mapping exists. Drives the file-explorer's editor dispatch — `defaultFileMetaFromName` recognises every extension in the table and returns the matching `{ editor: 'script', language: '…' }`. ### Fixed * **`@cocoar/vue-ui` — `CoarTree` right-click no longer disturbs selection**: opening a context menu on an unselected row leaves the existing selection in place (matches Finder / VSCode behaviour). The previous behaviour swapped the selection on the right-click target, which made multi-action menus (e.g. "Delete selected items") behave inconsistently when the right-clicked row wasn't part of the selection. * **`@cocoar/vue-ui` — `CoarTree` drop area fills its container**: `min-height: 100%` on the drop surface so OS file drops onto the empty area of a sparsely-populated tree register correctly. Previously drops below the last row sometimes missed the tree entirely. ### Internal * **Docs site sidebar**: new `File Explorer (Preview)` group with four entries (Overview, useFileExplorer, AssetStore contract, In-memory store). The Overview embeds five live demos (FullDispatch with real editor dispatch across `CoarScriptEditor` / `CoarMarkdownEditor` / `CoarDocumentViewer`; BasicUsage minimal shell; LazyMode; ConflictPolicies; SortModes). Demos use the dynamic-import + `` pattern (heavy impl in `_internal/*.vue`, thin SSR-safe shell in `demos/*.vue`) — same shape as the document-viewer demos. Two scoped CSS workarounds for `.vp-doc` cascade interactions: VitePress applies `margin-top: 8px` to consecutive `
  • ` elements in prose lists, which inflated the breadcrumb `
      ` height (`.bc :deep(.coar-breadcrumb-list li) { margin: 0 }`); and `CoarScriptEditor`'s root carries its own 1 px border, which doubled with the demo's outer border in the script-editor branch only (`.editor :deep(.coar-script-editor) { border: 0 }`). * **VitePress alias for `@cocoar/vue-file-explorer`**: dev-mode resolves to `packages/file-explorer/src/index.ts`; added to `ssr.noExternal` so SSR rendering picks up the workspace package without requiring a prebuild. * **Playground POC**: `apps/playground/src/views/FileExplorerPocView.vue` — the 1280-LoC worked example. Every feature of the package exercised in one file: tab bar with drag-to-reorder + context menus + middle-click close, simulator panel with localStorage-persisted knobs for latency / failure / sort / conflict / lazy, OS file drop, reveal-in-tree, `Ctrl+P` quick-open with substring match over `pathOf(id)`, editor dispatch across Monaco / Milkdown / `CoarDocumentViewer`. The POC consumes the package via its workspace dep — there's no `apps/playground/src/file-explorer/` folder any more. * **POC simulator state persists across reloads**: `Latency`, `Failure`, `Sort`, `Conflict`, and `Lazy` all sync to `localStorage`. `Lazy` is a construction-time switch (changing it would require recreating the store and losing state), so the POC reloads the page when it changes; the others are reactive and retune live. Lets a developer dial in `lazy=true, latency=1000` and reload to see the lazy initial-load spinners with the same configuration they had before. *** ## 2.3.0 Introduces a new package — `@cocoar/vue-document-viewer` — for rendering PDFs, single images, and multi-page image galleries through one source-agnostic Vue 3 component. The internal seam (`PageProvider`) keeps the public surface identical across formats: same toolbar, same panels, same annotation layer. Source kind is picked via a small factory (`pdfSource()`, `imageSource()`, `imageGallerySource()`); the toolbar reads `capabilities` flags off the source to disable (never hide) tools that don't apply, so switching between a PDF and an image never causes button layout shift. `pdfjs-dist` is an optional peer dep — only required when rendering PDFs, image-only consumers skip it entirely. The release also lands horizontal-orientation support on `CoarSidebarDivider` + a `splitTrigger` mode on `CoarSidebarGroup`, both needed by the new document-viewer toolbar (which is built on the same `CoarSidebar` primitives as the markdown-editor toolbar). Purely additive — no breaking changes. ### Added * **`@cocoar/vue-document-viewer` — new package** (Preview tier): `` is the entire public surface. One required prop; everything else (toolbar position, sidebars, annotations panel, info section, search, print, position memory, custom toolbar layout, annotation modes, labels for i18n) is optional. Switching `source` keeps the chrome mounted — only the inner page renderer rebinds. The render pipeline is source-agnostic via a `PageProvider` interface (`render(canvas, opts)` / `cancel()` / optional `getTextLayer()`); PDF pages wrap a `PDFPageProxy`, image pages wrap an `HTMLImageElement`, future kinds can plug in by providing the same shape. `usePageRenderer` no longer imports `pdfjs-dist` at all. The internal `useDocumentLoader` dispatcher watches `source.kind` and routes to one of three always-mounted adapters with filtered source slices (composables can't be conditionally instantiated within one render tree, but mounting all three and letting each watch its own filtered source keeps dispatch reactive without component-level remounting). 58 unit tests across 5 files cover the dispatcher, the `PageProvider` per-canvas render tracking (incl. the black-thumbnail-with-DevTools-open regression), the toolbar's `computeEffectiveTools` separator/capability filtering, and the pure-function helpers (`parsePdfDate`, `inferImageFormat`). Full VitePress documentation at `/components/document-viewer/` (overview, component reference, toolbar customization, annotations lifecycle). * **`@cocoar/vue-document-viewer` — three source factories**: `pdfSource({ url, headers?, withCredentials? })` from the `/pdf` subpath (pdfjs-dist is an optional peer dep, only PDF consumers pay the bundle cost); `imageSource({ url })` accepts anything `` accepts (JPG, PNG, SVG, WebP, AVIF, GIF, `blob:`, `data:`); `imageGallerySource({ urls })` for multi-page image documents with possibly-mixed orientations. Each factory returns a frozen `DocumentSource` with the appropriate `capabilities` flags pre-populated. Build inside a `computed` so the viewer rebinds only on real source changes. * **`@cocoar/vue-document-viewer` — capability-driven toolbar**: every `DocumentSource` advertises `{ multiPage, textLayer, search, outline, print }` capability flags; the toolbar reads them to mark unsupported tools as `disabled` with a `notAvailableForSource` tooltip suffix, rather than removing them from the layout. This is the **stable-position rule** — switching from a 14-page PDF to a single-page SVG never makes buttons jump around, just dims the ones that don't apply. Layer 3 of the toolbar filtering pipeline (after section toggles and separator normalization). * **`@cocoar/vue-document-viewer` — order-driven `tools` prop**: typed `CoarDocumentViewerTool[]`, drives BOTH visible set AND order. A new `'separator'` pseudo-tool renders a `CoarSidebarDivider` at its position. Leading + trailing separators auto-trim, consecutive separators collapse to one, so section-toggle filtering never leaves orphan dividers. Default is `COAR_DOCUMENT_VIEWER_ALL_TOOLS` — an 8-group canonical layout with separators at group boundaries (panels / nav / zoom / view / rotation / pointer-modes / drawing / actions). The trim+collapse logic lives in pure-function form at `internal/effective-tools.ts` (`computeEffectiveTools`), extracted from the toolbar SFC for testability without component mount. Layer 1 and 2 of the toolbar filtering pipeline. * **`@cocoar/vue-document-viewer` — built-in annotations**: four types — `marker` (multiply-blend highlighter), `comment` (pin + popover), `ink` (freehand), `freetext` (text box). Coordinates are page-relative and normalized to `[0..1]` so annotations render correctly across zoom + rotation, and stay portable across rendering contexts (a normalized stroke renders identically at 50 % zoom and 300 % zoom, on a different screen, in a different document with matching page geometry). Controlled-component pattern: viewer emits `annotation:create` / `annotation:update` / `annotation:delete`, consumer owns the `annotations` array. Pointer modifier conventions for drawing: Shift → 15°-snapped line, Ctrl/Cmd → free-angle line, Alt → append to previous stroke. Mode is `v-model:annotation-mode`-bindable (`'view' | 'select' | 'eraser' | 'marker' | 'comment' | 'ink' | 'freetext'`). Custom color palette via `:annotation-colors`; default is 7 colors (5 pastels + 2 brights). Eraser is destructive — clicking a stroke on a marker/ink annotation deletes that stroke, deleting the last stroke fires `annotation:delete` for the whole annotation. Annotations panel on the right rail with filter chips per type, sort (by page / chronological), substring search over comments and freetext bodies. * **`@cocoar/vue-document-viewer` — info panel**: a collapsible **Info** section at the top of the annotations panel surfaces source metadata — format string (`"PDF · v1.7"`, `"Image · PNG"`, `"Image gallery · SVG"`), total page count, current-page dimensions (live, updates as the user flips pages), and PDF metadata (title / author / subject / keywords / creator / producer / created / modified / PDF version) parsed from the pdfjs `info` bag with `D:YYYYMMDDHHmmSS±HH'mm'` dates converted to `YYYY-MM-DD HH:mm:ss`. Empty fields are skipped (no "Author:" row for PDFs without an author). File size in bytes is surfaced for PDFs (from pdfjs's `contentLength`); image / gallery sources omit it. Disable with `:show-info-section="false"` for a minimal annotations-only panel. * **`@cocoar/vue-document-viewer` — position memory**: `storageKey: string` opts into automatic localStorage persistence of `{ page, pageOffset, zoom, rotation }`; alternatively `v-model:position` for consumer-owned persistence (server, IndexedDB, existing state manager). Both compatible — when both are present, the bound `position` wins on mount and afterwards both stay in sync. * **`@cocoar/vue-ui` — `CoarSidebarDivider` orientation-aware**: detects the parent sidebar's `side` via `SIDEBAR_SIDE_KEY` and renders a 1 px vertical line in horizontal sidebars (top / bottom) instead of the previous border-bottom which only worked in vertical rails. Symmetric along/across margin policy across both orientations + collapsed state. Required by the new document-viewer toolbar (which uses `CoarSidebar` horizontally) and by future horizontal-toolbar consumers. * **`@cocoar/vue-ui` — `CoarSidebarGroup.splitTrigger` prop**: when combined with `mode='flyout'`, the trigger click emits a new `triggerClick` event instead of toggling the flyout. The flyout still opens via hover (`openOnHover`) or programmatic `v-model:open`. Lets a tool toggle act as the primary action with a separate hover-revealed config panel — the canonical use case is the document-viewer's marker tool with a hover-only width/color picker, mirrored from the markdown-editor's heading dropdown. * **`@cocoar/vue-ui` — `CoarSidebarGroup.active` prop**: selected-state styling matching `CoarSidebarItem` (side-keyed indicator border, accent color + background). Used by the document-viewer toolbar to highlight the currently-active drawing tool's flyout group. * **`@cocoar/vue-ui` — seven new core icons** regenerated from `/assets/icons` via `pnpm build:icons`: `highlighter`, `type`, `hand`, `rotate-cw`, `rotate-ccw`, `move-horizontal`, `mouse-pointer-2`. All used by the document-viewer toolbar; available for general consumer use via ``. ### Internal * **Docs site widening**: `--vp-layout-max-width` bumped from 1440 to 2200 px; `.VPDoc.has-aside .content-container` max-width override removed (was 688 px, now fills the layout). Kitchen Sink coupled to the same `--vp-layout-max-width` so it scales together. On wide monitors the doc content area grows from ~688 to ~1530 px instead of leaving ~30 % side gutter; small screens (under 1440 px viewport) are unchanged. The right "On this page" outline stays in place. `pdfjs-dist` is aliased to a no-op SSR stub in the docs vite config — `DOMMatrix` at module-evaluation time would otherwise break VitePress's SSR pass, and the docs demos don't render real PDFs (the live PDF demo lives in the playground), so the stub is never actually called. If a docs demo ever needs to render a real PDF, remove the alias and lazy-import the adapter inside `onMounted`. * **Playground demo route**: `/pdf-viewer` (apps/playground) — exercises all three sources, custom toolbar layouts, annotation modes, position memory, and the per-canvas render tracking fix (open DevTools while a PDF is loaded — thumbnails should NOT go black). *** ## 2.2.1 Closes an inconsistency in the v2.2.0 router-link rollout: `CoarMenuItem` shipped without an `active` prop or `RouterLink.isActive` wiring, while `CoarSidebarItem` had both. Consumers who wanted to mark a menu item as "currently selected" (view-mode toggles, settings sub-menus with a ✓ marker, sort-direction indicators) had no built-in way to do so. This release mirrors the sidebar's pattern onto the menu so both components have parity. ### Added * **`@cocoar/vue-ui` — `CoarMenuItem.active` prop**: typed `boolean | undefined`. Marks the item as the current selection — applies the `coar-menu-item--active` class and `aria-current="page"` attribute. When `to` is set and `active` is omitted, the state follows ``'s `isActive` slot prop automatically, so consumer-computed `route.path === '/x'` checks aren't needed. Explicit `active` wins over the router-derived value, for non-route selections (e.g. "current view mode is List" — not a route, just app state). The menu still auto-closes when an active item is clicked: the active styling is meaningful while the menu is open (user sees "✓ List view"), then the menu closes and reopens later with the new selection active. Mirrors the `CoarSidebarItem.active` implementation exactly — same `props.active ?? routerIsActive` resolution, same precedence rules, same CSS-token shape. New CSS tokens `--coar-menu-item-active-color` (default `var(--coar-text-accent-primary)`) and `--coar-menu-item-active-bg` (default `var(--coar-background-accent-tertiary)`) match the sidebar's accent-treatment palette so visually the two components stay in sync. 7 new tests across the three render branches (router-installed auto-active, explicit overrides, active-still-closes-menu, no-router explicit-only, `
      ` non-route selection state). Total `CoarMenuItem.test.ts` suite: 28 (was 21). *** ## 2.2.0 This release closes a long-standing usability gap reported from the `cocoarappbase` (Multi-Tenant ASP.NET + Vue + Marten) template: the three component families that consumers most often wire to Vue Router (sidebar navigation, dropdown menu items, and call-to-action buttons) all rendered as `
      ` or `