@vc-shell/vc-app-skill 2.0.0-alpha.25 → 2.0.0-alpha.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ # [2.0.0-alpha.27](https://github.com/VirtoCommerce/vc-shell/compare/v2.0.0-alpha.26...v2.0.0-alpha.27) (2026-03-25)
2
+
3
+ **Note:** Version bump only for package @vc-shell/vc-app-skill
4
+
5
+ # [2.0.0-alpha.26](https://github.com/VirtoCommerce/vc-shell/compare/v2.0.0-alpha.25...v2.0.0-alpha.26) (2026-03-25)
6
+
7
+ **Note:** Version bump only for package @vc-shell/vc-app-skill
8
+
1
9
  # [2.0.0-alpha.25](https://github.com/VirtoCommerce/vc-shell/compare/v2.0.0-alpha.24...v2.0.0-alpha.25) (2026-03-25)
2
10
 
3
11
  **Note:** Version bump only for package @vc-shell/vc-app-skill
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ // ─── Configuration ───────────────────────────────────────────────────────────
8
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
9
+ const FRAMEWORK_DIR = path.join(ROOT, 'framework');
10
+
11
+ const STORYBOOK_BASE = 'https://vc-shell-storybook.govirto.com';
12
+
13
+ // Resolve vc-docs path from --docs-path arg or VC_DOCS_PATH env or default
14
+ const docsPathArg = process.argv.find((a) => a.startsWith('--docs-path='));
15
+ const VC_DOCS_PATH =
16
+ (docsPathArg && docsPathArg.split('=')[1]) ||
17
+ process.env.VC_DOCS_PATH ||
18
+ path.resolve(ROOT, '..', 'vc-docs');
19
+
20
+ const MKDOCS_YML = path.join(VC_DOCS_PATH, 'platform', 'developer-guide', 'mkdocs.yml');
21
+ const DOCS_OUT = path.join(VC_DOCS_PATH, 'platform', 'developer-guide', 'docs', 'custom-apps-development', 'vc-shell');
22
+
23
+ if (!fs.existsSync(VC_DOCS_PATH)) {
24
+ console.error(`vc-docs repo not found at: ${VC_DOCS_PATH}`);
25
+ console.error('Use --docs-path=/path/to/vc-docs or set VC_DOCS_PATH env var');
26
+ process.exit(1);
27
+ }
28
+
29
+ // ─── Path mapping ────────────────────────────────────────────────────────────
30
+ // framework relative path → vc-docs relative path (under DOCS_OUT) + nav category
31
+ function mapPath(relPath) {
32
+ const parts = relPath.replace(/\.docs\.md$/, '').split(path.sep);
33
+
34
+ // core/composables/{name}/index → Essentials/composables/{name}.md
35
+ // core/composables/{name}.docs.md (flat) → Essentials/composables/{name}.md
36
+ if (parts[0] === 'core' && parts[1] === 'composables') {
37
+ const name = parts.length >= 3 ? parts[2] : parts[1];
38
+ return { outPath: `Essentials/composables/${name}.md`, navCategory: 'Composables' };
39
+ }
40
+
41
+ // core/plugins/{name}/* → Essentials/plugins/{name}.md
42
+ if (parts[0] === 'core' && parts[1] === 'plugins') {
43
+ const name = parts[2];
44
+ return { outPath: `Essentials/plugins/${name}.md`, navCategory: 'Plugins' };
45
+ }
46
+
47
+ // core/api/* → Essentials/API-Integration/{name}.md
48
+ if (parts[0] === 'core' && parts[1] === 'api') {
49
+ const name = parts[parts.length - 1];
50
+ return { outPath: `Essentials/API-Integration/${name}.md`, navCategory: null };
51
+ }
52
+
53
+ // core/blade-navigation/* → Essentials/composables/{name}.md
54
+ if (parts[0] === 'core' && parts[1] === 'blade-navigation') {
55
+ const name = parts[parts.length - 1];
56
+ return { outPath: `Essentials/composables/${name}.md`, navCategory: 'Composables' };
57
+ }
58
+
59
+ // core/notifications/* → Essentials/shared/components/notifications.md
60
+ if (parts[0] === 'core' && parts[1] === 'notifications') {
61
+ return { outPath: 'Essentials/shared/components/notifications.md', navCategory: null };
62
+ }
63
+
64
+ // ui/components/{tier}/{name}/* → Essentials/ui-components/{name}.md
65
+ // Skip internal composable docs (table-composables) — only sync the main component doc
66
+ if (parts[0] === 'ui' && parts[1] === 'components') {
67
+ const tier = parts[2]; // atoms, molecules, organisms
68
+ const compDir = parts[3]; // vc-badge, vc-select, etc.
69
+ const fileName = parts[parts.length - 1];
70
+ if (compDir) {
71
+ // Skip sub-directory docs (e.g. composables/table-composables.docs.md) — main doc covers these
72
+ if (parts.length > 5) return null;
73
+ // vc-table dir contains VcDataTable — use the actual component name
74
+ const outName = compDir === 'vc-table' ? 'vc-data-table' : compDir;
75
+ return { outPath: `Essentials/ui-components/${outName}.md`, navCategory: 'UI Components', tier };
76
+ }
77
+ }
78
+
79
+ // ui/composables/* → skip (internal table composables)
80
+ if (parts[0] === 'ui' && parts[1] === 'composables') {
81
+ return null;
82
+ }
83
+
84
+ // shell/components/{name}/* → Essentials/shared/components/{name}.md
85
+ if (parts[0] === 'shell' && parts[1] === 'components') {
86
+ const name = parts[2];
87
+ return { outPath: `Essentials/shared/components/${name}.md`, navCategory: null };
88
+ }
89
+
90
+ // shell/dashboard/{name}/* → Essentials/shared/components/{name}.md
91
+ if (parts[0] === 'shell' && parts[1] === 'dashboard') {
92
+ const name = parts[2];
93
+ return { outPath: `Essentials/shared/components/${name}.md`, navCategory: null };
94
+ }
95
+
96
+ // Anything else we don't map (core/types, core/utilities, etc.)
97
+ return null;
98
+ }
99
+
100
+ // ─── Storybook helpers ───────────────────────────────────────────────────────
101
+ function findStorybookTitle(compDir) {
102
+ const storiesGlob = fs.readdirSync(compDir).find((f) => f.endsWith('.stories.ts'));
103
+ if (!storiesGlob) return null;
104
+
105
+ const content = fs.readFileSync(path.join(compDir, storiesGlob), 'utf-8');
106
+ const match = content.match(/title:\s*["']([^"']+)["']/);
107
+ return match ? match[1] : null;
108
+ }
109
+
110
+ function storybookTitleToId(title) {
111
+ // "Data Display/VcBadge" → "data-display-vcbadge"
112
+ return title
113
+ .toLowerCase()
114
+ .replace(/\s+/g, '-')
115
+ .replace(/\//g, '-');
116
+ }
117
+
118
+ function buildStorybookSection(title, componentName) {
119
+ const id = storybookTitleToId(title);
120
+ const displayName = componentName.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
121
+ return [
122
+ '',
123
+ '## Storybook',
124
+ '',
125
+ `[${displayName} Storybook](${STORYBOOK_BASE}/?path=/docs/${id}--docs)`,
126
+ '',
127
+ '<iframe',
128
+ ` src="${STORYBOOK_BASE}/iframe.html?id=${id}--docs&viewMode=story&shortcuts=false&singleStory=true"`,
129
+ ' width="1000"',
130
+ ' height="500"',
131
+ '></iframe>',
132
+ '',
133
+ ].join('\n');
134
+ }
135
+
136
+ // ─── Content transformation ──────────────────────────────────────────────────
137
+ function transformContent(content, mapping, frameworkRelPath) {
138
+ let result = content;
139
+
140
+ // If UI component, inject Storybook iframe after first heading
141
+ if (mapping.navCategory === 'UI Components' && mapping.tier) {
142
+ const compName = path.basename(mapping.outPath, '.md');
143
+ const compDir = path.join(FRAMEWORK_DIR, path.dirname(frameworkRelPath));
144
+ const storybookTitle = findStorybookTitle(compDir);
145
+
146
+ if (storybookTitle) {
147
+ const storySection = buildStorybookSection(storybookTitle, compName);
148
+ // Insert after first # heading line
149
+ result = result.replace(/^(# .+\n)/, `$1${storySection}\n`);
150
+ }
151
+ }
152
+
153
+ return result;
154
+ }
155
+
156
+ // ─── Find all docs ───────────────────────────────────────────────────────────
157
+ function findDocsFiles(dir, base) {
158
+ const results = [];
159
+ if (!fs.existsSync(dir)) return results;
160
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
161
+ const fullPath = path.join(dir, entry.name);
162
+ if (entry.isDirectory()) {
163
+ results.push(...findDocsFiles(fullPath, base));
164
+ } else if (entry.name.endsWith('.docs.md')) {
165
+ results.push(path.relative(base, fullPath));
166
+ }
167
+ }
168
+ return results;
169
+ }
170
+
171
+ // ─── Nav update helpers ──────────────────────────────────────────────────────
172
+ function extractTitle(content) {
173
+ const match = content.match(/^# (.+)$/m);
174
+ return match ? match[1] : null;
175
+ }
176
+
177
+ function updateNavSection(mkdocsContent, category, entries) {
178
+ // entries: [{title, path}]
179
+ // We update the specific nav category section in mkdocs.yml
180
+ // This is a targeted update — find the category line and replace its children
181
+
182
+ const lines = mkdocsContent.split('\n');
183
+ const result = [];
184
+ let inCategory = false;
185
+ let categoryIndent = 0;
186
+ let replaced = false;
187
+
188
+ for (let i = 0; i < lines.length; i++) {
189
+ const line = lines[i];
190
+ const trimmed = line.trimStart();
191
+
192
+ // Find the category header (e.g., "- Composables:" or "- UI Components:")
193
+ if (!replaced && trimmed === `- ${category}:`) {
194
+ categoryIndent = line.length - trimmed.length;
195
+ inCategory = true;
196
+ result.push(line);
197
+
198
+ // Add all entries with proper indentation
199
+ const childIndent = ' '.repeat(categoryIndent + 6); // 6 more for child items
200
+ const sortedEntries = entries.sort((a, b) => a.title.localeCompare(b.title));
201
+ for (const entry of sortedEntries) {
202
+ result.push(`${childIndent}- ${entry.title}: ${entry.navPath}`);
203
+ }
204
+ replaced = true;
205
+ continue;
206
+ }
207
+
208
+ // Skip old children of this category
209
+ if (inCategory) {
210
+ const lineIndent = line.length - line.trimStart().length;
211
+ if (line.trim() === '' || lineIndent > categoryIndent) {
212
+ // Still inside category children, skip
213
+ if (line.trim().startsWith('-') && lineIndent > categoryIndent) {
214
+ continue;
215
+ }
216
+ }
217
+ if (line.trim() !== '' && (line.length - line.trimStart().length) <= categoryIndent) {
218
+ inCategory = false;
219
+ // This line belongs to next section, keep it
220
+ result.push(line);
221
+ continue;
222
+ }
223
+ if (line.trim() === '') {
224
+ continue;
225
+ }
226
+ continue;
227
+ }
228
+
229
+ result.push(line);
230
+ }
231
+
232
+ return result.join('\n');
233
+ }
234
+
235
+ // ─── Main ────────────────────────────────────────────────────────────────────
236
+ const frameworkDocs = findDocsFiles(FRAMEWORK_DIR, FRAMEWORK_DIR);
237
+
238
+ let synced = 0;
239
+ let skipped = 0;
240
+ let unmapped = 0;
241
+
242
+ // Track nav entries per category
243
+ const navEntries = {
244
+ Composables: [],
245
+ Plugins: [],
246
+ 'UI Components': [],
247
+ };
248
+
249
+ // Pre-populate nav entries from existing mkdocs.yml to preserve manual entries
250
+ // (we only update categories we manage)
251
+
252
+ const NAV_PREFIX = 'custom-apps-development/vc-shell/';
253
+
254
+ for (const relPath of frameworkDocs) {
255
+ const mapping = mapPath(relPath);
256
+ if (!mapping) {
257
+ unmapped++;
258
+ continue;
259
+ }
260
+
261
+ const src = path.join(FRAMEWORK_DIR, relPath);
262
+ const dest = path.join(DOCS_OUT, mapping.outPath);
263
+ const srcContent = fs.readFileSync(src, 'utf-8');
264
+
265
+ // Transform content
266
+ const transformed = transformContent(srcContent, mapping, relPath);
267
+
268
+ // Check if dest is identical
269
+ if (fs.existsSync(dest)) {
270
+ const destContent = fs.readFileSync(dest, 'utf-8');
271
+ if (destContent === transformed) {
272
+ skipped++;
273
+ // Still collect nav entry
274
+ if (mapping.navCategory && navEntries[mapping.navCategory]) {
275
+ const title = extractTitle(srcContent) || path.basename(mapping.outPath, '.md');
276
+ navEntries[mapping.navCategory].push({
277
+ title,
278
+ navPath: NAV_PREFIX + mapping.outPath,
279
+ });
280
+ }
281
+ continue;
282
+ }
283
+ }
284
+
285
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
286
+ fs.writeFileSync(dest, transformed);
287
+ console.log(` synced: ${relPath} → ${mapping.outPath}`);
288
+ synced++;
289
+
290
+ // Collect nav entry
291
+ if (mapping.navCategory && navEntries[mapping.navCategory]) {
292
+ const title = extractTitle(srcContent) || path.basename(mapping.outPath, '.md');
293
+ navEntries[mapping.navCategory].push({
294
+ title,
295
+ navPath: NAV_PREFIX + mapping.outPath,
296
+ });
297
+ }
298
+ }
299
+
300
+ // Update mkdocs.yml nav sections
301
+ if (fs.existsSync(MKDOCS_YML)) {
302
+ let mkdocsContent = fs.readFileSync(MKDOCS_YML, 'utf-8');
303
+ let navUpdated = false;
304
+
305
+ for (const [category, entries] of Object.entries(navEntries)) {
306
+ if (entries.length === 0) continue;
307
+
308
+ const before = mkdocsContent;
309
+ mkdocsContent = updateNavSection(mkdocsContent, category, entries);
310
+ if (mkdocsContent !== before) navUpdated = true;
311
+ }
312
+
313
+ if (navUpdated) {
314
+ fs.writeFileSync(MKDOCS_YML, mkdocsContent);
315
+ console.log(`\n mkdocs.yml nav updated`);
316
+ }
317
+ }
318
+
319
+ // ─── Mark orphaned docs ──────────────────────────────────────────────────────
320
+ // Docs in vc-docs that no longer have a source in framework get a deprecation banner
321
+ const DEPRECATION_BANNER = [
322
+ '!!! warning "Deprecated"',
323
+ ' This API has been removed from the current version of the framework.',
324
+ ' This page is kept for reference only. Do not use in new code.',
325
+ '',
326
+ ].join('\n');
327
+
328
+ const managedDirs = [
329
+ path.join(DOCS_OUT, 'Essentials', 'composables'),
330
+ path.join(DOCS_OUT, 'Essentials', 'plugins'),
331
+ path.join(DOCS_OUT, 'Essentials', 'ui-components'),
332
+ ];
333
+
334
+ // Build set of all output paths we just synced
335
+ const syncedOutPaths = new Set();
336
+ for (const relPath of frameworkDocs) {
337
+ const mapping = mapPath(relPath);
338
+ if (mapping) syncedOutPaths.add(path.join(DOCS_OUT, mapping.outPath));
339
+ }
340
+
341
+ let deprecated = 0;
342
+ for (const dir of managedDirs) {
343
+ if (!fs.existsSync(dir)) continue;
344
+ for (const file of fs.readdirSync(dir)) {
345
+ if (!file.endsWith('.md')) continue;
346
+ const filePath = path.join(dir, file);
347
+ if (syncedOutPaths.has(filePath)) continue;
348
+
349
+ // File exists in vc-docs but not in framework — mark as deprecated
350
+ const content = fs.readFileSync(filePath, 'utf-8');
351
+ if (content.includes('This API has been removed')) continue; // already marked
352
+
353
+ fs.writeFileSync(filePath, DEPRECATION_BANNER + '\n' + content);
354
+ const relFile = path.relative(DOCS_OUT, filePath);
355
+ console.log(` deprecated: ${relFile}`);
356
+ deprecated++;
357
+ }
358
+ }
359
+
360
+ console.log(`\nSync to vc-docs complete: ${synced} synced, ${skipped} unchanged, ${unmapped} unmapped, ${deprecated} deprecated`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vc-shell/vc-app-skill",
3
- "version": "2.0.0-alpha.25",
3
+ "version": "2.0.0-alpha.27",
4
4
  "description": "AI coding skill for scaffolding and generating VirtoCommerce Shell applications. Works with Claude Code, OpenCode, Gemini, Codex, Cursor.",
5
5
  "bin": "./bin/install.cjs",
6
6
  "files": [
@@ -20,7 +20,8 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "sync-docs": "node bin/sync-docs.cjs",
23
- "knowledge-stats": "node bin/knowledge-stats.cjs"
23
+ "knowledge-stats": "node bin/knowledge-stats.cjs",
24
+ "sync-to-docs": "node bin/sync-to-docs.cjs"
24
25
  },
25
26
  "publishConfig": {
26
27
  "access": "public",
package/runtime/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.0-alpha.25
1
+ 2.0.0-alpha.27
@@ -1 +1 @@
1
- Synced from framework at commit 99a2f022b on 2026-03-25T11:57:14.169Z
1
+ Synced from framework at commit 8dddac30c on 2026-03-25T15:16:59.790Z
@@ -286,5 +286,5 @@ currentPage.value = Math.min(currentPage.value, totalPages.value);
286
286
 
287
287
  ## Related Components
288
288
 
289
- - [VcDataTable](../../organisms/vc-table/) -- Data table that commonly pairs with pagination for large datasets
289
+ - [VcDataTable](../../organisms/vc-table) -- Data table that commonly pairs with pagination for large datasets
290
290
  - [VcSelect](../vc-select/) -- Can be used alongside pagination for a "rows per page" selector
@@ -44,18 +44,20 @@ const user = reactive({ name: "John", role: "Admin" });
44
44
  | `name` | `string` | -- | Current user display name. |
45
45
  | `role` | `string` | -- | Current user role label. |
46
46
  | `disableMenu` | `boolean` | `false` | Hide navigation menu items. |
47
- | `disableAppSwitcher` | `boolean` | `false` | Hide application switcher UI. |
47
+ | `disableAppHub` | `boolean` | `false` | Hide the Applications section inside the App Hub. |
48
+ | `showSearch` | `boolean` | `false` | Show a search input in the sidebar that filters menu items by title. |
49
+ | `searchPlaceholder` | `string` | `"Search keyword"` | Placeholder text for the sidebar search input. Falls back to i18n key `SHELL.SIDEBAR.SEARCH_PLACEHOLDER`. |
48
50
 
49
51
  ## Slots
50
52
 
51
53
  | Slot | Props | Description |
52
54
  |------|-------|-------------|
53
55
  | `layout` | `{ isMobile, sidebar, appsList, switchApp, openRoot, handleMenuItemClick }` | Override the entire layout (sidebar + navigation). |
54
- | `menu` | `{ expanded, onItemClick }` | Custom navigation menu. |
56
+ | `menu` | `{ expanded, onItemClick, searchQuery }` | Custom navigation menu. `searchQuery` contains the current search input value (empty string when search is inactive). |
55
57
  | `sidebar-header` | `{ logo, expanded, isMobile }` | Custom sidebar header. |
56
58
  | `sidebar-footer` | `{ avatar, name, role }` | Custom sidebar footer (user info). |
57
59
  | `workspace` | `{ isAuthenticated }` | Override the blade navigation workspace. |
58
- | `app-switcher` | `{ appsList, switchApp }` | Custom application switcher. |
60
+ | `app-hub` | `{ appsList, switchApp }` | Custom content for the Applications section of the App Hub. |
59
61
 
60
62
  ## Architecture
61
63
 
@@ -72,13 +74,54 @@ VcApp orchestrates several internal systems:
72
74
 
73
75
  On desktop viewports, VcApp renders a collapsible sidebar on the left with navigation menu items, user info in the footer, and the blade workspace on the right. On mobile viewports, the sidebar is replaced by a top bar with a hamburger menu that opens a slide-over navigation panel.
74
76
 
77
+ ### Sidebar Menu Search
78
+
79
+ When `showSearch` is `true`, a search input appears at the top of the sidebar (desktop) or mobile navigation panel. It filters menu items in real time (300ms debounce) by matching the search query against translated item titles:
80
+
81
+ - **Standalone items** — shown if their title contains the query.
82
+ - **Group items** — if the group title matches, all accessible children are shown. Otherwise, only children whose titles match are displayed.
83
+ - The search query is automatically cleared when a menu item is clicked or when the sidebar collapses.
84
+
85
+ On desktop, the search input is visible only when the sidebar is expanded (pinned). On mobile, it appears inside the slide-out navigation panel.
86
+
87
+ If you use the `menu` slot to provide a custom menu, the `searchQuery` prop is passed to your slot so you can implement your own filtering logic.
88
+
89
+ ```vue
90
+ <template>
91
+ <VcApp :is-ready="true" logo="/logo.svg" title="Admin" show-search search-placeholder="Find a module...">
92
+ <!-- Default menu with built-in filtering — no extra code needed -->
93
+ </VcApp>
94
+ </template>
95
+ ```
96
+
97
+ #### Custom Menu with Search
98
+
99
+ ```vue
100
+ <template>
101
+ <VcApp :is-ready="true" logo="/logo.svg" title="Admin" show-search>
102
+ <template #menu="{ expanded, onItemClick, searchQuery }">
103
+ <MyCustomMenu
104
+ :expanded="expanded"
105
+ :filter="searchQuery"
106
+ @select="onItemClick"
107
+ />
108
+ </template>
109
+ </VcApp>
110
+ </template>
111
+ ```
112
+
75
113
  ### Dynamic Module Registration
76
114
 
77
115
  Modules registered via `useDynamicModules()` are loaded at runtime. Each module can contribute menu items, blades, toolbar actions, settings pages, and dashboard widgets. VcApp handles module loading errors gracefully by displaying notification toasts.
78
116
 
79
- ### Application Switcher
117
+ ### App Hub
118
+
119
+ The App Hub is a popover panel (desktop) or a swipeable tab (mobile) that combines two sections:
120
+
121
+ - **Applications** — tile grid of registered apps (e.g., Vendor Portal, Marketplace Admin). Clicking an app switches context without a full page reload. This section can be hidden with `disableAppHub` or customized via the `app-hub` slot. The list is searchable via a built-in search input inside the hub.
122
+ - **Widgets** — registered app bar widgets (notifications, background tasks, etc.). Clicking a widget expands its content inline (desktop) or navigates to its panel (mobile). Widgets are registered via `useAppBarWidget()` and can display badges for unread counts.
80
123
 
81
- When multiple applications are registered (e.g., Vendor Portal, Marketplace Admin), VcApp shows an app switcher in the sidebar header. Users can toggle between apps without a full page reload.
124
+ On desktop, the App Hub opens from the sidebar header menu button (`AppHubPopover`). On mobile, it appears as a second tab ("Hub") in the slide-out navigation panel — users can swipe between Menu and Hub tabs.
82
125
 
83
126
  ## Recipe: Minimal App Setup
84
127
 
@@ -24,8 +24,8 @@ Columns are defined as `<VcColumn>` child components -- no configuration objects
24
24
  |----------|-----------|
25
25
  | Tabular data with sorting, selection, pagination | **VcDataTable** |
26
26
  | Simple short list without table features | `v-for` with custom markup |
27
- | Image/card grid layout | [VcGallery](../vc-gallery/) |
28
- | Key-value detail display | [VcField](../../molecules/vc-field/) or [VcCard](../../atoms/vc-card/) |
27
+ | Image/card grid layout | [VcGallery](../vc-gallery) |
28
+ | Key-value detail display | [VcField](../../molecules/vc-field) or [VcCard](../../atoms/vc-card) |
29
29
 
30
30
  Use VcDataTable whenever you need structured rows and columns with any combination of sorting, filtering, inline editing, or column management. **Do not use** VcDataTable for simple lists of 5-10 items that need no table features -- a plain `v-for` loop is lighter. For thumbnail/card grids, prefer VcGallery.
31
31