@keyfunc/keybricks-ds 2.4.6 → 2.4.8

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.
Files changed (40) hide show
  1. package/DEPLOY.md +2 -2
  2. package/DESIGN.md +4 -4
  3. package/INTEGRATION.md +24 -2
  4. package/_adherence.oxlintrc.json +4 -4
  5. package/assets/icons/icon-index.js +1 -1
  6. package/assets/icons/icon-index.json +1 -1
  7. package/components/_cards/segmented-control.card.html +1 -1
  8. package/components/_cards/table.card.html +139 -6
  9. package/components/_cards/toggles.card.html +27 -27
  10. package/components/_shared/src-data.js +1 -1
  11. package/components/_shared/src-display.js +1 -1
  12. package/components/_shared/src-forms.js +1 -1
  13. package/components/_shared/src-icons.js +1 -1
  14. package/components/_shared/src-navigation.js +1 -1
  15. package/components/_shared/src-tabs.js +1 -1
  16. package/components/data/SmartTable.d.ts +9 -1
  17. package/components/data/SmartTable.jsx +24 -4
  18. package/components/data/SmartTable.vue +18 -5
  19. package/components/data/Spreadsheet.d.ts +5 -1
  20. package/components/data/Spreadsheet.jsx +23 -3
  21. package/components/data/Spreadsheet.vue +18 -5
  22. package/components/data/Table.d.ts +25 -4
  23. package/components/data/Table.jsx +74 -20
  24. package/components/data/Table.vue +56 -14
  25. package/components/display/Tag.d.ts +1 -1
  26. package/components/display/Tag.jsx +1 -1
  27. package/components/display/Tag.vue +1 -1
  28. package/components/forms/Switch.d.ts +2 -2
  29. package/components/forms/Switch.jsx +10 -8
  30. package/components/forms/Switch.vue +5 -4
  31. package/components/forms/forms.prompt.md +1 -1
  32. package/components/icons/Icon.jsx +3 -2
  33. package/components/icons/icon-paths.js +3 -2
  34. package/components/navigation/Breadcrumb.jsx +9 -7
  35. package/components/navigation/Breadcrumb.vue +16 -14
  36. package/components/tabs/CountBadge.vue +1 -1
  37. package/components/tabs/Tabs.d.ts +1 -1
  38. package/components/tabs/Tabs.jsx +1 -1
  39. package/package.json +1 -1
  40. package/tokens/layout.css +5 -5
@@ -1 +1 @@
1
- window.__KB_SRC__=Object.assign(window.__KB_SRC__||{},{"data/CodeBlock.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\n\n/** Monospace code/JSON block with a language label and copy-to-clipboard button (SQL Preview, export JSON). */\nexport function CodeBlock({ code = \"\", language, height, style }) {\n const [copied, setCopied] = React.useState(false);\n const copy = () => { try { navigator.clipboard && navigator.clipboard.writeText(code); } catch (e) {} setCopied(true); setTimeout(() => setCopied(false), 1400); };\n return (\n <div style={{ background: \"var(--bg-inset)\", border: \"1px solid var(--border-subtle)\", borderRadius: \"var(--radius-md)\", ...style }}>\n <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\", padding: \"7px 10px\", borderBottom: \"1px solid var(--border-subtle)\" }}>\n <span style={{ fontFamily: \"var(--font-mono)\", fontSize: \"var(--text-xs)\", color: \"var(--text-muted)\" }}>{language || \"code\"}</span>\n <button type=\"button\" onClick={copy} data-ink=\"\" style={{ display: \"flex\", alignItems: \"center\", gap: 4, border: \"none\", background: \"transparent\", cursor: \"pointer\", color: copied ? \"var(--success-strong)\" : \"var(--ink, var(--text-secondary))\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", padding: 0 }}>\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />{copied ? \"Copied\" : \"Copy\"}\n </button>\n </div>\n <pre style={{ margin: 0, padding: \"10px 12px\", overflow: \"auto\", height, fontFamily: \"var(--font-mono)\", fontSize: \"var(--text-xs)\", lineHeight: \"var(--leading-xs)\", color: \"var(--text-body)\" }}>{code}</pre>\n </div>\n );\n}\n","data/CodeBlock.vue":"<script setup>\nimport { ref } from 'vue'\nimport Icon from '../icons/Icon.vue'\n// Monospace code/JSON block with a language label and copy-to-clipboard button (SQL Preview, export JSON).\nconst props = defineProps({ code: { type: String, default: '' }, language: String, height: [Number, String], style: Object })\nconst copied = ref(false)\nfunction copy() {\n try { navigator.clipboard && navigator.clipboard.writeText(props.code) } catch (e) {}\n copied.value = true; setTimeout(() => copied.value = false, 1400)\n}\n</script>\n<template>\n <div :style=\"{ background: 'var(--bg-inset)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', ...style }\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;padding:7px 10px;border-bottom:1px solid var(--border-subtle)\">\n <span style=\"font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-muted)\">{{ language || 'code' }}</span>\n <button type=\"button\" @click=\"copy\" data-ink=\"\" :style=\"{ display: 'flex', alignItems: 'center', gap: '4px', border: 'none', background: 'transparent', cursor: 'pointer', color: copied ? 'var(--success-strong)' : 'var(--ink, var(--text-secondary))', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', padding: 0 }\">\n <Icon :name=\"copied ? 'check' : 'copy'\" :size=\"12\" />{{ copied ? 'Copied' : 'Copy' }}\n </button>\n </div>\n <pre :style=\"{ margin: 0, padding: '10px 12px', overflow: 'auto', height, fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', lineHeight: 'var(--leading-xs)', color: 'var(--text-body)' }\">{{ code }}</pre>\n </div>\n</template>\n","data/DiffValue.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\n\nconst toneColor = { neutral: \"var(--text-heading)\", warning: \"var(--warning-text)\", success: \"var(--success-text)\", danger: \"var(--danger-text)\" };\n\n/** Before → after value change (\"USD → EUR\"). tone colors the new value; from renders muted + struck through when both sides are given. */\nexport function DiffValue({ from, to, tone = \"warning\", mono = true, style }) {\n return (\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6, fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", ...style }}>\n {from != null ? <span style={{ color: \"var(--text-muted)\", textDecoration: \"line-through\" }}>{from}</span> : null}\n <Icon name=\"arrow-right\" size={12} style={{ color: \"var(--text-muted)\" }} />\n <span style={{ color: toneColor[tone], fontWeight: \"var(--weight-medium)\" }}>{to}</span>\n </span>\n );\n}\n","data/DiffValue.vue":"<script setup>\nimport Icon from '../icons/Icon.vue'\nconst toneColor = { neutral: 'var(--text-heading)', warning: 'var(--warning-text)', success: 'var(--success-text)', danger: 'var(--danger-text)' }\n// Before → after value change (\"USD → EUR\"). tone colors the new value; from renders muted + struck through when both sides are given.\ndefineProps({ from: [String, Number], to: [String, Number], tone: { type: String, default: 'warning' }, mono: { type: Boolean, default: true }, style: Object })\n</script>\n<template>\n <span :style=\"{ display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', ...style }\">\n <span v-if=\"from != null\" style=\"color:var(--text-muted);text-decoration:line-through\">{{ from }}</span>\n <Icon name=\"arrow-right\" :size=\"12\" style=\"color:var(--text-muted)\" />\n <span :style=\"{ color: toneColor[tone], fontWeight: 'var(--weight-medium)' }\">{{ to }}</span>\n </span>\n</template>\n","data/FilterBar.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { Button } from \"../buttons/Button.jsx\";\n\n/** SAP Fiori-style list-report filter bar: variant title row with expand/collapse, a wrapping row of labeled filter fields (pass Field-wrapped controls as children), and Adapt Filters (n) + Go actions.\n * A size region: `size` (default inherit → md) sets the title bar height, the fields' tier and the gap between fields (--group-gap) so every control in it matches. */\nexport function FilterBar({ variant = \"Standard\", modified, adaptCount, defaultExpanded = true, size, onGo, onAdapt, children, style }) {\n const [open, setOpen] = React.useState(defaultExpanded);\n return (\n <div data-size={size} style={{ background: \"var(--bg-surface)\", border: \"1px solid var(--border-card)\", borderRadius: \"var(--radius-lg)\", boxSizing: \"border-box\", ...style }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: \"var(--control-gap)\", minHeight: \"var(--bar-card-h)\", padding: \"0 var(--edge-pad)\", boxSizing: \"border-box\" }}>\n <button type=\"button\" aria-expanded={open} onClick={() => setOpen(!open)} style={{ display: \"flex\", alignItems: \"center\", gap: 6, border: \"none\", background: \"transparent\", cursor: \"pointer\", padding: 0, color: \"var(--text-heading)\", fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\" }}>\n {variant}{modified && <span style={{ color: \"var(--text-muted)\", fontWeight: \"var(--weight-regular)\" }}>*</span>}\n <span style={{ color: \"var(--text-muted)\", display: \"inline-flex\", transform: open ? \"rotate(180deg)\" : \"none\", transition: \"transform var(--dur-fast) var(--ease-out)\" }}><Icon name=\"chevron-down\" size={16} style={{ width: \"var(--control-icon)\", height: \"var(--control-icon)\" }} /></span>\n </button>\n <span style={{ flex: 1 }}></span>\n <Button variant=\"text\" onClick={onAdapt}>Adapt Filters{adaptCount ? ` (${adaptCount})` : \"\"}</Button>\n <Button onClick={onGo}>Go</Button>\n </div>\n {open && <div style={{ display: \"flex\", flexWrap: \"wrap\", alignItems: \"flex-end\", gap: \"var(--control-gap) var(--group-gap)\", padding: \"12px var(--edge-pad) var(--edge-pad)\", borderTop: \"1px solid var(--border-subtle)\" }}>{children}</div>}\n </div>\n );\n}\n","data/FilterBar.vue":"<script setup>\nimport { ref } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport Button from '../buttons/Button.vue'\n// SAP Fiori-style list-report filter bar: variant title + expand/collapse, wrapping labeled filter fields (slot), Adapt Filters (n) + Go.\n// A size region: `size` (default inherit → md) sets the title bar height, the fields' tier and the gap between fields (--group-gap) so every control in it matches.\nconst props = defineProps({ variant: { type: String, default: 'Standard' }, modified: Boolean, adaptCount: Number, defaultExpanded: { type: Boolean, default: true }, size: String, style: Object })\nconst emit = defineEmits(['go', 'adapt'])\nconst open = ref(props.defaultExpanded)\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ background: 'var(--bg-surface)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)', boxSizing: 'border-box', ...style }\">\n <div style=\"display:flex;align-items:center;gap:var(--control-gap);min-height:var(--bar-card-h);padding:0 var(--edge-pad);box-sizing:border-box\">\n <button type=\"button\" :aria-expanded=\"open\" @click=\"open = !open\" style=\"display:flex;align-items:center;gap:6px;border:none;background:transparent;cursor:pointer;padding:0;color:var(--text-heading);font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--control-text);line-height:var(--control-leading);letter-spacing:var(--control-tracking)\">\n {{ variant }}<span v-if=\"modified\" style=\"color:var(--text-muted);font-weight:var(--weight-regular)\">*</span>\n <span :style=\"{ color: 'var(--text-muted)', display: 'inline-flex', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform var(--dur-fast) var(--ease-out)' }\"><Icon name=\"chevron-down\" :size=\"16\" :style=\"{ width: 'var(--control-icon)', height: 'var(--control-icon)' }\" /></span>\n </button>\n <span style=\"flex:1\" />\n <Button variant=\"text\" @click=\"emit('adapt')\">Adapt Filters{{ adaptCount ? ` (${adaptCount})` : '' }}</Button>\n <Button @click=\"emit('go')\">Go</Button>\n </div>\n <div v-if=\"open\" style=\"display:flex;flex-wrap:wrap;align-items:flex-end;gap:var(--control-gap) var(--group-gap);padding:12px var(--edge-pad) var(--edge-pad);border-top:1px solid var(--border-subtle)\"><slot /></div>\n </div>\n</template>\n","data/KeyValue.jsx":"import React from \"react\";\n\n/** Label/value row (config summaries, confirmation steps). Values always use the UI font with tabular figures; `mono` is kept for 2.x callers and changes nothing. */\nexport function KeyValue({ label, value, mono, style }) {\n return (\n <div style={{ display: \"flex\", justifyContent: \"space-between\", gap: 12, padding: \"6px 0\", ...style }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-base)\", lineHeight: \"var(--leading-base)\", letterSpacing: \"var(--tracking-base)\", color: \"var(--text-secondary)\" }}>{label}</span>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-base)\", lineHeight: \"var(--leading-base)\", letterSpacing: \"var(--tracking-base)\", fontVariantNumeric: \"tabular-nums\", color: \"var(--text-heading)\", textAlign: \"right\" }}>{value}</span>\n </div>\n );\n}\n\n/** Stack of KeyValue rows with hairline separators. rows: [{label,value,mono}]. */\nexport function KeyValueList({ rows = [], style }) {\n return (\n <div style={{ display: \"flex\", flexDirection: \"column\", ...style }}>\n {rows.map((r, i) => (\n <React.Fragment key={r.label}>\n <KeyValue {...r} />\n {i < rows.length - 1 ? <div style={{ height: 1, background: \"var(--border-subtle)\" }} /> : null}\n </React.Fragment>\n ))}\n </div>\n );\n}\n","data/KeyValue.vue":"<script setup>\n// Label/value row (config summaries, confirmation steps). Values always use the UI font with tabular figures; `mono` is kept for 2.x callers and changes nothing.\ndefineProps({ label: String, value: [String, Number], mono: Boolean, style: Object })\n</script>\n<template>\n <div :style=\"{ display: 'flex', justifyContent: 'space-between', gap: '12px', padding: '6px 0', ...style }\">\n <span style=\"font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-secondary)\">{{ label }}</span>\n <span :style=\"{ fontFamily: 'var(--font-sans)', fontSize: 'var(--text-base)', lineHeight: 'var(--leading-base)', letterSpacing: 'var(--tracking-base)', fontVariantNumeric: 'tabular-nums', color: 'var(--text-heading)', textAlign: 'right' }\">{{ value }}</span>\n </div>\n</template>\n","data/ListRow.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { IconTile } from \"../display/Badge.jsx\";\n\n/** Expandable workflow list row: chevron + bordered 32px icon tile + title/meta + right count. */\nexport function ListRow({ icon = \"file-text\", title, meta, trailing, expanded, onClick, children, style }) {\n const [hover, setHover] = React.useState(false);\n return (\n <div style={{ borderBottom: \"1px solid var(--border-card)\", ...style }}>\n <div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}\n style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", padding: \"12px 16px\", cursor: \"pointer\", background: hover ? \"var(--bg-inset)\" : \"transparent\", transition: \"var(--t-hover)\" }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <span style={{ color: \"var(--text-muted)\", transform: expanded ? \"rotate(90deg)\" : \"none\", transition: \"transform .15s ease\", display: \"inline-flex\" }}><Icon name=\"chevron-right\" size={16} /></span>\n <IconTile icon={icon} kind=\"row\" />\n <div style={{ display: \"flex\", flexDirection: \"column\" }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--text-base)\", lineHeight: \"var(--leading-base)\", letterSpacing: \"var(--tracking-base)\", color: \"var(--text-heading)\" }}>{title}</span>\n {meta ? <span style={{ fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-regular)\", fontSize: \"var(--text-xs)\", lineHeight: \"var(--leading-xs)\", color: \"var(--text-secondary)\" }}>{meta}</span> : null}\n </div>\n </div>\n {trailing ? <span style={{ fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--text-base)\", lineHeight: \"var(--leading-base)\", letterSpacing: \"var(--tracking-base)\", fontVariantNumeric: \"tabular-nums\", color: \"var(--text-secondary)\" }}>{trailing}</span> : null}\n </div>\n {expanded && children ? <div style={{ padding: \"0 16px 12px 72px\" }}>{children}</div> : null}\n </div>\n );\n}\n","data/ListRow.vue":"<script setup>\nimport { ref } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport IconTile from '../display/IconTile.vue'\n// Expandable workflow list row: chevron + bordered 32px icon tile + title/meta + right count.\ndefineProps({ icon: { type: String, default: 'file-text' }, title: String, meta: String, trailing: [String, Number], expanded: Boolean, style: Object })\ndefineEmits(['click'])\nconst hover = ref(false)\n</script>\n<template>\n <div :style=\"{ borderBottom: '1px solid var(--border-card)', ...style }\">\n <div @click=\"$emit('click')\" @mouseenter=\"hover = true\" @mouseleave=\"hover = false\" :style=\"{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', cursor: 'pointer', background: hover ? 'var(--bg-inset)' : 'transparent', transition: 'var(--t-hover)' }\">\n <div style=\"display:flex;align-items:center;gap:12px\">\n <span :style=\"{ color: 'var(--text-muted)', transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s ease', display: 'inline-flex' }\"><Icon name=\"chevron-right\" :size=\"16\" /></span>\n <IconTile :icon=\"icon\" kind=\"row\" />\n <div style=\"display:flex;flex-direction:column\">\n <span style=\"font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-heading)\">{{ title }}</span>\n <span v-if=\"meta\" style=\"font-family:var(--font-sans);font-weight:var(--weight-regular);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary)\">{{ meta }}</span>\n </div>\n </div>\n <span v-if=\"trailing\" style=\"font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);font-variant-numeric:tabular-nums;color:var(--text-secondary)\">{{ trailing }}</span>\n </div>\n <div v-if=\"expanded\" style=\"padding:0 16px 12px 72px\"><slot /></div>\n </div>\n</template>\n","data/Pagination.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\n\nconst tierIcon = { width: \"var(--control-icon)\", height: \"var(--control-icon)\" };\n\n/** Table pagination — range label + prev/next + numbered pages, all at the region's tier (page items are control-height squares with no border — the current page takes the neutral selected fill (--bg-active, weight 600) — it is a state, not an action, so it never uses the action blue; the rest show a wash on hover). `format(from, to, total, page, pageCount)` localises the label. */\nexport function Pagination({ page = 1, pageCount = 1, total, pageSize = 20, size, format, onChange, style }) {\n const btn = (content, disabled, onClick, active, label) => (\n <button type=\"button\" disabled={disabled} onClick={onClick} aria-label={label} aria-current={active ? \"page\" : undefined} data-wash={active || disabled ? undefined : \"\"}\n style={{ minWidth: \"var(--control-h)\", height: \"var(--control-h)\", padding: \"0 6px\", boxSizing: \"border-box\", borderRadius: \"var(--radius-md)\", border: \"none\", background: active ? \"var(--bg-active)\" : \"var(--wash, transparent)\", color: active ? \"var(--text-heading)\" : \"var(--text-body)\", fontWeight: active ? \"var(--weight-semibold)\" : \"var(--weight-regular)\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", letterSpacing: \"var(--control-tracking)\", fontVariantNumeric: \"tabular-nums\", cursor: disabled ? \"default\" : \"pointer\", opacity: disabled ? .45 : 1, display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", transition: \"var(--t-hover)\" }}>\n {content}\n </button>\n );\n const pages = [];\n const first = Math.max(1, Math.min(page - 1, pageCount - 2));\n for (let i = first; i < first + 3 && i <= pageCount; i++) pages.push(i);\n const from = total != null ? (page - 1) * pageSize + 1 : null, to = total != null ? Math.min(page * pageSize, total) : null;\n const text = format ? format(from, to, total, page, pageCount) : (total != null ? `${from}–${to} of ${total}` : `Page ${page} of ${pageCount}`);\n return (\n <div data-size={size} style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", gap: \"var(--group-gap)\", ...style }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", fontVariantNumeric: \"tabular-nums\", color: \"var(--text-secondary)\" }}>\n {text}\n </span>\n <div style={{ display: \"flex\", gap: 4 }}>\n {btn(<Icon name=\"chevron-left\" size={16} style={tierIcon} />, page <= 1, () => onChange && onChange(page - 1), false, \"Previous page\")}\n {pages.map((p) => <React.Fragment key={p}>{btn(p, false, () => onChange && onChange(p), p === page)}</React.Fragment>)}\n {btn(<Icon name=\"chevron-right\" size={16} style={tierIcon} />, page >= pageCount, () => onChange && onChange(page + 1), false, \"Next page\")}\n </div>\n </div>\n );\n}\n","data/Pagination.vue":"<script setup>\nimport { computed } from 'vue'\nimport Icon from '../icons/Icon.vue'\n// Table pagination — range label + prev/next + numbered pages, all at the region's tier (page items are control-height squares). `format(from, to, total, page, pageCount)` localises the label.\nconst tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }\nconst props = defineProps({ page: { type: Number, default: 1 }, pageCount: { type: Number, default: 1 }, total: Number, pageSize: { type: Number, default: 20 }, size: String, format: Function, style: Object })\nconst emit = defineEmits(['change'])\nconst pages = computed(() => {\n const from = Math.max(1, Math.min(props.page - 1, props.pageCount - 2)), out = []\n for (let i = from; i < from + 3 && i <= props.pageCount; i++) out.push(i)\n return out\n})\nconst label = computed(() => {\n const from = props.total != null ? (props.page - 1) * props.pageSize + 1 : null, to = props.total != null ? Math.min(props.page * props.pageSize, props.total) : null\n if (props.format) return props.format(from, to, props.total, props.page, props.pageCount)\n return props.total != null ? `${from}–${to} of ${props.total}` : `Page ${props.page} of ${props.pageCount}`\n})\nfunction btnStyle(active, disabled) { return { minWidth: 'var(--control-h)', height: 'var(--control-h)', padding: '0 6px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', background: active ? 'var(--bg-active)' : 'var(--wash, transparent)', color: active ? 'var(--text-heading)' : 'var(--text-body)', fontWeight: active ? 'var(--weight-semibold)' : 'var(--weight-regular)', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', letterSpacing: 'var(--control-tracking)', fontVariantNumeric: 'tabular-nums', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? .45 : 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'var(--t-hover)' } }\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--group-gap)', ...style }\">\n <span style=\"font-family:var(--font-sans);font-size:var(--control-text);line-height:var(--control-leading);letter-spacing:var(--control-tracking);font-variant-numeric:tabular-nums;color:var(--text-secondary)\">{{ label }}</span>\n <div style=\"display:flex;gap:4px\">\n <button type=\"button\" aria-label=\"Previous page\" :disabled=\"page <= 1\" :data-wash=\"page <= 1 ? undefined : ''\" @click=\"emit('change', page - 1)\" :style=\"btnStyle(false, page <= 1)\"><Icon name=\"chevron-left\" :size=\"16\" :style=\"tierIcon\" /></button>\n <button v-for=\"p in pages\" :key=\"p\" type=\"button\" :aria-current=\"p === page ? 'page' : undefined\" :data-wash=\"p === page ? undefined : ''\" @click=\"emit('change', p)\" :style=\"btnStyle(p === page, false)\">{{ p }}</button>\n <button type=\"button\" aria-label=\"Next page\" :disabled=\"page >= pageCount\" :data-wash=\"page >= pageCount ? undefined : ''\" @click=\"emit('change', page + 1)\" :style=\"btnStyle(false, page >= pageCount)\"><Icon name=\"chevron-right\" :size=\"16\" :style=\"tierIcon\" /></button>\n </div>\n </div>\n</template>\n","data/SmartTable.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { IconButton } from \"../buttons/IconButton.jsx\";\nimport { Checkbox } from \"../forms/Checkbox.jsx\";\nimport { Avatar } from \"../display/Avatar.jsx\";\nimport { SearchInput } from \"../forms/SearchInput.jsx\";\nimport { Menu } from \"../feedback/Menu.jsx\";\n\nconst typeIcon = { text: \"type\", longText: \"align-left\", number: \"hash\", select: \"chevron-down\", multiSelect: \"tag\", date: \"calendar\", person: \"user\", checkbox: \"check-square\", url: \"link\", progress: \"bar-chart-2\" };\nconst Pill = ({ hue = \"gray\", children }) => <span style={{ display: \"inline-flex\", alignItems: \"center\", height: 20, padding: \"0 8px\", borderRadius: \"var(--radius-full)\", background: `var(--hue-${hue}-bg)`, color: `var(--hue-${hue}-fg)`, fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--text-xs)\", lineHeight: 1, whiteSpace: \"nowrap\" }}>{children}</span>;\n\n/** Notion/Airtable-style database grid (\"smart table\"): field-type icons in headers, colored select pills, person/date/checkbox/url/progress cells, row hover + expand, inline text/number editing, + New row, add-field column, per-column footer aggregates, and a view toolbar (Hide fields · Filter · Group · Sort · search). */\n /* A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size=\"md\" for a roomier grid.\n density: \"default\" rows are --bar-h (36 / 40 / 48) with --cell-px; \"compact\" rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's \"Row height\" menu switches it; rowHeight (a raw height) overrides density.\n Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline. */\nconst DENSITY = { default: { h: \"var(--bar-h)\", px: \"var(--cell-px)\" }, compact: { h: \"var(--control-h)\", px: \"8px\" } };\nexport function SmartTable({ fields = [], rows = [], title = \"Grid view\", toolbar = true, height, rowHeight, density: densityProp, defaultDensity = \"default\", onDensityChange, size = \"sm\", onChange, style }) {\n const [data, setData] = React.useState(rows);\n const [densityState, setDensityState] = React.useState(defaultDensity);\n const density = DENSITY[densityProp || densityState] ? (densityProp || densityState) : \"default\";\n const setDensity = (d) => { if (!densityProp) setDensityState(d); onDensityChange && onDensityChange(d); };\n const rowH = rowHeight != null ? rowHeight : DENSITY[density].h;\n const [hover, setHover] = React.useState(-1);\n const [editing, setEditing] = React.useState(null);\n const [draft, setDraft] = React.useState(\"\");\n const setCell = (r, key, v) => { const next = data.map((row, i) => i === r ? { ...row, [key]: v } : row); setData(next); onChange && onChange(next); };\n const widths = [\"40px\", ...fields.map(f => (f.width || 140) + \"px\"), \"40px\"];\n const grid = { display: \"grid\", gridTemplateColumns: widths.join(\" \"), width: \"fit-content\", minWidth: \"100%\" };\n const cellBase = { display: \"flex\", alignItems: \"center\", gap: 5, padding: \"0 \" + DENSITY[density].px, borderRight: \"1px solid var(--border-subtle)\", borderBottom: \"1px solid var(--border-subtle)\", height: rowH, boxSizing: \"border-box\", overflow: \"hidden\", whiteSpace: \"nowrap\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: \"var(--text-primary)\" };\n const tierIcon = { width: \"var(--control-icon)\", height: \"var(--control-icon)\" };\n // Frozen edges — zIndex 1 (row-number column) < 2 (header row) < 3 (corner, both axes); the editing input sits inside a body cell, under them.\n const stickyTop = { position: \"sticky\", top: 0, zIndex: 2, background: \"var(--bg-inset)\" };\n const stickyLeft = { position: \"sticky\", left: 0, zIndex: 1, background: \"var(--bg-inset)\" };\n const stickyCorner = { ...stickyTop, left: 0, zIndex: 3 };\n const agg = (f) => {\n if (!f.aggregate) return \"\";\n const vals = data.map(r => r[f.key]);\n if (f.aggregate === \"count\") return \"COUNT \" + vals.filter(v => v === true || (v !== \"\" && v != null && v !== false)).length;\n const nums = vals.map(Number).filter(n => !isNaN(n));\n if (f.aggregate === \"sum\") return \"SUM \" + nums.reduce((a, b) => a + b, 0);\n if (f.aggregate === \"avg\") return \"AVG \" + (nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length * 10) / 10 : 0);\n return \"\";\n };\n const renderCell = (f, row, r) => {\n const v = row[f.key];\n const isEdit = editing && editing.r === r && editing.key === f.key;\n if (isEdit) return <input autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={() => { setCell(r, f.key, f.type === \"number\" && draft !== \"\" && !isNaN(Number(draft)) ? Number(draft) : draft); setEditing(null); }} onKeyDown={(e) => { if (e.key === \"Enter\") e.target.blur(); if (e.key === \"Escape\") setEditing(null); }} style={{ position: \"absolute\", inset: 0, border: \"2px solid var(--action-primary)\", borderRadius: 2, outline: \"none\", padding: \"0 6px\", font: \"inherit\", background: \"var(--bg-surface)\", color: \"var(--text-primary)\", boxSizing: \"border-box\" }} />;\n switch (f.type) {\n case \"select\": { const hue = (f.options || {})[v] || \"gray\"; return v ? <Pill hue={hue}>{v}</Pill> : null; }\n case \"multiSelect\": return <span style={{ display: \"flex\", gap: 4, overflow: \"hidden\" }}>{(v || []).map(t => <Pill key={t} hue={(f.options || {})[t] || \"gray\"}>{t}</Pill>)}</span>;\n case \"person\": return v ? <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6, minWidth: 0 }}><Avatar name={v} size={20} /><span style={{ overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{v}</span></span> : null;\n case \"checkbox\": return <Checkbox checked={!!v} onChange={(nv) => setCell(r, f.key, nv)} />;\n case \"url\": return v ? <a href={\"https://\" + v} onClick={(e) => e.preventDefault()} style={{ color: \"var(--link)\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{v}</a> : null;\n case \"progress\": return <span style={{ display: \"flex\", alignItems: \"center\", gap: 6, width: \"100%\" }}><span style={{ flex: 1, height: 4, borderRadius: \"var(--radius-full)\", background: \"var(--track)\", overflow: \"hidden\" }}><span style={{ display: \"block\", width: (v || 0) + \"%\", height: \"100%\", background: \"var(--chart-blue)\" }}></span></span><span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\" }}>{v || 0}%</span></span>;\n case \"number\": return <span style={{ marginLeft: \"auto\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", letterSpacing: 0, fontVariantNumeric: \"tabular-nums\" }}>{v}</span>;\n default: return <span style={{ overflow: \"hidden\", textOverflow: \"ellipsis\", fontWeight: f.primary ? \"var(--weight-medium)\" : \"var(--weight-regular)\", color: f.primary ? \"var(--text-heading)\" : \"var(--text-primary)\" }}>{v}</span>;\n }\n };\n const tbBtn = (icon, label) => <button key={label} type=\"button\" style={{ display: \"inline-flex\", alignItems: \"center\", gap: 5, height: \"var(--control-h)\", padding: \"0 var(--control-px)\", border: \"none\", borderRadius: \"var(--radius-md)\", background: \"transparent\", cursor: \"pointer\", color: \"var(--text-body)\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", letterSpacing: \"var(--control-tracking)\" }} onMouseEnter={(e) => e.currentTarget.style.background = \"var(--bg-hover)\"} onMouseLeave={(e) => e.currentTarget.style.background = \"transparent\"}><Icon name={icon} size={14} style={tierIcon} />{label}</button>;\n return (\n <div data-size={size} style={{ border: \"1px solid var(--border-control)\", borderRadius: \"var(--radius-lg)\", background: \"var(--bg-surface)\", overflow: \"hidden\", boxSizing: \"border-box\", ...style }}>\n {toolbar && <div style={{ display: \"flex\", alignItems: \"center\", gap: 4, minHeight: \"var(--bar-card-h)\", padding: \"0 var(--edge-pad)\", boxSizing: \"border-box\", borderBottom: \"1px solid var(--border-subtle)\" }}>\n <button type=\"button\" data-wash=\"strong\" style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6, height: \"var(--control-h)\", padding: \"0 var(--control-px)\", border: \"none\", borderRadius: \"var(--radius-md)\", background: \"var(--wash, var(--bg-active))\", transition: \"var(--t-hover)\", cursor: \"pointer\", color: \"var(--text-heading)\", fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--control-text)\", letterSpacing: \"var(--control-tracking)\" }}><Icon name=\"layout-grid\" size={14} style={tierIcon} />{title}<Icon name=\"chevron-down\" size={14} style={tierIcon} /></button>\n <span style={{ width: 1, height: 16, background: \"var(--border-control)\", margin: \"0 4px\" }}></span>\n {tbBtn(\"eye-off\", \"Hide fields\")}{tbBtn(\"funnel\", \"Filter\")}{tbBtn(\"layers\", \"Group\")}{tbBtn(\"arrow-down\", \"Sort\")}\n <Menu trigger={tbBtn(\"menu\", \"Row height\")} items={[{ label: \"Compact\", selected: density === \"compact\" }, { label: \"Default\", selected: density === \"default\" }]} onSelect={(l) => setDensity(l === \"Compact\" ? \"compact\" : \"default\")} />\n <span style={{ flex: 1 }}></span>\n <SearchInput bare placeholder=\"Search...\" style={{ width: 140, gap: 6 }} />\n </div>}\n <div style={{ overflow: \"auto\", maxHeight: height }}>\n <div style={grid}>\n <span style={{ ...cellBase, ...stickyCorner, justifyContent: \"center\" }}></span>\n {fields.map(f => <span key={f.key} style={{ ...cellBase, ...stickyTop, color: \"var(--text-secondary)\", fontWeight: \"var(--weight-semibold)\" }}><Icon name={typeIcon[f.type] || \"type\"} size={14} style={{ ...tierIcon, color: \"var(--text-muted)\" }} />{f.label}</span>)}\n <span style={{ ...cellBase, ...stickyTop, justifyContent: \"center\", padding: 0 }}><IconButton icon=\"plus\" muted label=\"Add field\" /></span>\n {data.map((row, r) => [\n <span key={\"n\" + r} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} style={{ ...cellBase, ...stickyLeft, justifyContent: \"center\", color: \"var(--text-muted)\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", letterSpacing: 0, cursor: \"pointer\" }}>{hover === r ? <Icon name=\"maximize-2\" size={14} style={{ ...tierIcon, color: \"var(--action-primary)\" }} /> : r + 1}</span>,\n ...fields.map(f => <span key={r + f.key} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} onDoubleClick={() => { if (f.type === \"text\" || f.type === \"number\" || f.type === \"date\" || f.type === \"url\") { setEditing({ r, key: f.key }); setDraft(String(row[f.key] == null ? \"\" : row[f.key])); } }} style={{ ...cellBase, position: \"relative\", background: hover === r ? \"var(--bg-inset)\" : \"transparent\" }}>{renderCell(f, row, r)}</span>),\n <span key={\"a\" + r} style={{ ...cellBase, background: hover === r ? \"var(--bg-inset)\" : \"transparent\" }}></span>,\n ])}\n <button type=\"button\" onClick={() => { const empty = {}; fields.forEach(f => empty[f.key] = f.type === \"multiSelect\" ? [] : f.type === \"checkbox\" ? false : \"\"); const next = [...data, empty]; setData(next); onChange && onChange(next); }} style={{ gridColumn: \"1 / -1\", display: \"flex\", alignItems: \"center\", gap: 6, height: rowH, padding: \"0 var(--edge-pad)\", border: \"none\", borderBottom: \"1px solid var(--border-subtle)\", background: \"transparent\", cursor: \"pointer\", color: \"var(--text-secondary)\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", letterSpacing: \"var(--control-tracking)\", textAlign: \"left\" }} onMouseEnter={(e) => e.currentTarget.style.background = \"var(--bg-inset)\"} onMouseLeave={(e) => e.currentTarget.style.background = \"transparent\"}><Icon name=\"plus\" size={13} />New</button>\n <span style={{ ...cellBase, ...stickyLeft, borderBottom: \"none\" }}></span>\n {fields.map(f => <span key={\"g\" + f.key} style={{ ...cellBase, borderBottom: \"none\", background: \"var(--bg-inset)\", justifyContent: \"flex-end\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", letterSpacing: 0, color: \"var(--text-secondary)\" }}>{agg(f)}</span>)}\n <span style={{ ...cellBase, borderBottom: \"none\", background: \"var(--bg-inset)\" }}></span>\n </div>\n </div>\n </div>\n );\n}\n","data/SmartTable.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport IconButton from '../buttons/IconButton.vue'\nimport Checkbox from '../forms/Checkbox.vue'\nimport Avatar from '../display/Avatar.vue'\nimport SearchInput from '../forms/SearchInput.vue'\nimport Menu from '../feedback/Menu.vue'\n// Notion/Airtable-style database grid: field-type header icons, hue pills, person/date/checkbox/url/progress cells, row hover + expand, inline editing, + New row, footer aggregates, view toolbar.\nconst typeIcon = { text: 'type', longText: 'align-left', number: 'hash', select: 'chevron-down', multiSelect: 'tag', date: 'calendar', person: 'user', checkbox: 'check-square', url: 'link', progress: 'bar-chart-2' }\nconst props = defineProps({ fields: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, title: { type: String, default: 'Grid view' }, toolbar: { type: Boolean, default: true }, height: [Number, String], rowHeight: [Number, String], density: String, defaultDensity: { type: String, default: 'default' }, size: { type: String, default: 'sm' }, style: Object })\n// A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size=\"md\" for a roomier grid.\n// density: 'default' rows are --bar-h (36 / 40 / 48) with --cell-px; 'compact' rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's \"Row height\" menu switches it (emits density-change; v-model:density to control); rowHeight (a raw height) overrides density.\n// Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline.\nconst DENSITY = { default: { h: 'var(--bar-h)', px: 'var(--cell-px)' }, compact: { h: 'var(--control-h)', px: '8px' } }\nconst tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }\n// Frozen edges — zIndex 1 (row-number column) < 2 (header row) < 3 (corner, both axes); the editing input sits inside a body cell, under them.\nconst stickyTop = { position: 'sticky', top: 0, zIndex: 2, background: 'var(--bg-inset)' }\nconst stickyLeft = { position: 'sticky', left: 0, zIndex: 1, background: 'var(--bg-inset)' }\nconst stickyCorner = { ...stickyTop, left: 0, zIndex: 3 }\nconst emit = defineEmits(['change', 'update:density', 'densityChange'])\nconst densityState = ref(props.defaultDensity)\nconst density = computed(() => DENSITY[props.density || densityState.value] ? (props.density || densityState.value) : 'default')\nfunction setDensity(d) { if (!props.density) densityState.value = d; emit('update:density', d); emit('densityChange', d) }\nconst rowH = computed(() => props.rowHeight != null ? (typeof props.rowHeight === 'number' ? props.rowHeight + 'px' : props.rowHeight) : DENSITY[density.value].h)\nconst cellPx = computed(() => DENSITY[density.value].px)\nconst data = ref(props.rows.map(r => ({ ...r })))\nconst hover = ref(-1), editing = ref(null), draft = ref('')\nconst gridCols = computed(() => ['40px', ...props.fields.map(f => (f.width || 140) + 'px'), '40px'].join(' '))\nconst cellBase = computed(() => ({ display: 'flex', alignItems: 'center', gap: '5px', padding: '0 ' + cellPx.value, borderRight: '1px solid var(--border-subtle)', borderBottom: '1px solid var(--border-subtle)', boxSizing: 'border-box', overflow: 'hidden', whiteSpace: 'nowrap', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: 'var(--text-primary)' }))\nfunction setCell(r, key, v) { data.value[r][key] = v; emit('change', data.value) }\nfunction commitEdit(r, f) { setCell(r, f.key, f.type === 'number' && draft.value !== '' && !isNaN(Number(draft.value)) ? Number(draft.value) : draft.value); editing.value = null }\nfunction startEdit(r, f, row) { if (['text', 'number', 'date', 'url'].includes(f.type || 'text')) { editing.value = { r, key: f.key }; draft.value = String(row[f.key] == null ? '' : row[f.key]) } }\nfunction addRow() { const empty = {}; props.fields.forEach(f => empty[f.key] = f.type === 'multiSelect' ? [] : f.type === 'checkbox' ? false : ''); data.value.push(empty); emit('change', data.value) }\nfunction agg(f) {\n if (!f.aggregate) return ''\n const vals = data.value.map(r => r[f.key])\n if (f.aggregate === 'count') return 'COUNT ' + vals.filter(v => v === true || (v !== '' && v != null && v !== false)).length\n const nums = vals.map(Number).filter(n => !isNaN(n))\n if (f.aggregate === 'sum') return 'SUM ' + nums.reduce((a, b) => a + b, 0)\n return 'AVG ' + (nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length * 10) / 10 : 0)\n}\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ border: '1px solid var(--border-control)', borderRadius: 'var(--radius-lg)', background: 'var(--bg-surface)', overflow: 'hidden', boxSizing: 'border-box', ...style }\">\n <div v-if=\"toolbar\" style=\"display:flex;align-items:center;gap:4px;min-height:var(--bar-card-h);padding:0 var(--edge-pad);box-sizing:border-box;border-bottom:1px solid var(--border-subtle)\">\n <button type=\"button\" data-wash=\"strong\" style=\"display:inline-flex;align-items:center;gap:6px;height:var(--control-h);padding:0 var(--control-px);border:none;border-radius:var(--radius-md);background:var(--wash, var(--bg-active));transition:var(--t-hover);cursor:pointer;color:var(--text-heading);font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--control-text);letter-spacing:var(--control-tracking)\"><Icon name=\"layout-grid\" :size=\"14\" :style=\"tierIcon\" />{{ title }}<Icon name=\"chevron-down\" :size=\"14\" :style=\"tierIcon\" /></button>\n <span style=\"width:1px;height:16px;background:var(--border-control);margin:0 4px\" />\n <button v-for=\"b in [['eye-off','Hide fields'],['funnel','Filter'],['layers','Group'],['arrow-down','Sort']]\" :key=\"b[1]\" type=\"button\" class=\"kb-st-btn\"><Icon :name=\"b[0]\" :size=\"14\" :style=\"tierIcon\" />{{ b[1] }}</button>\n <Menu :items=\"[{ label: 'Compact', selected: density === 'compact' }, { label: 'Default', selected: density === 'default' }]\" @select=\"l => setDensity(l === 'Compact' ? 'compact' : 'default')\"><template #trigger><button type=\"button\" class=\"kb-st-btn\"><Icon name=\"menu\" :size=\"14\" :style=\"tierIcon\" />Row height</button></template></Menu>\n <span style=\"flex:1\" />\n <SearchInput bare placeholder=\"Search...\" style=\"width:140px;gap:6px\" />\n </div>\n <div :style=\"{ overflow: 'auto', maxHeight: typeof height === 'number' ? height + 'px' : height }\">\n <div :style=\"{ display: 'grid', gridTemplateColumns: gridCols, width: 'fit-content', minWidth: '100%' }\">\n <span :style=\"{ ...cellBase, height: rowH, ...stickyCorner }\" />\n <span v-for=\"f in fields\" :key=\"f.key\" :style=\"{ ...cellBase, height: rowH, ...stickyTop, color: 'var(--text-secondary)', fontWeight: 'var(--weight-semibold)' }\"><Icon :name=\"typeIcon[f.type] || 'type'\" :size=\"14\" :style=\"{ ...tierIcon, color: 'var(--text-muted)' }\" />{{ f.label }}</span>\n <span :style=\"{ ...cellBase, height: rowH, ...stickyTop, justifyContent: 'center', padding: 0 }\"><IconButton icon=\"plus\" muted label=\"Add field\" /></span>\n <template v-for=\"(row, r) in data\" :key=\"r\">\n <span @mouseenter=\"hover = r\" @mouseleave=\"hover = -1\" :style=\"{ ...cellBase, height: rowH, ...stickyLeft, justifyContent: 'center', color: 'var(--text-muted)', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, cursor: 'pointer' }\">\n <Icon v-if=\"hover === r\" name=\"maximize-2\" :size=\"14\" :style=\"{ ...tierIcon, color: 'var(--action-primary)' }\" /><template v-else>{{ r + 1 }}</template>\n </span>\n <span v-for=\"f in fields\" :key=\"r + f.key\" @mouseenter=\"hover = r\" @mouseleave=\"hover = -1\" @dblclick=\"startEdit(r, f, row)\" :style=\"{ ...cellBase, height: rowH, position: 'relative', background: hover === r ? 'var(--bg-inset)' : 'transparent' }\">\n <input v-if=\"editing && editing.r === r && editing.key === f.key\" autofocus :value=\"draft\" @input=\"draft = $event.target.value\" @blur=\"commitEdit(r, f)\" @keydown.enter=\"$event.target.blur()\" @keydown.esc=\"editing = null\" style=\"position:absolute;inset:0;border:2px solid var(--action-primary);border-radius:2px;outline:none;padding:0 6px;font:inherit;background:var(--bg-surface);color:var(--text-primary);box-sizing:border-box\" />\n <template v-else-if=\"f.type === 'select'\"><span v-if=\"row[f.key]\" class=\"kb-pill\" :style=\"{ background: `var(--hue-${(f.options || {})[row[f.key]] || 'gray'}-bg)`, color: `var(--hue-${(f.options || {})[row[f.key]] || 'gray'}-fg)` }\">{{ row[f.key] }}</span></template>\n <template v-else-if=\"f.type === 'multiSelect'\"><span style=\"display:flex;gap:4px;overflow:hidden\"><span v-for=\"t in row[f.key] || []\" :key=\"t\" class=\"kb-pill\" :style=\"{ background: `var(--hue-${(f.options || {})[t] || 'gray'}-bg)`, color: `var(--hue-${(f.options || {})[t] || 'gray'}-fg)` }\">{{ t }}</span></span></template>\n <template v-else-if=\"f.type === 'person'\"><span v-if=\"row[f.key]\" style=\"display:inline-flex;align-items:center;gap:6px;min-width:0\"><Avatar :name=\"row[f.key]\" :size=\"20\" /><span style=\"overflow:hidden;text-overflow:ellipsis\">{{ row[f.key] }}</span></span></template>\n <template v-else-if=\"f.type === 'checkbox'\"><Checkbox :checked=\"!!row[f.key]\" @change=\"v => setCell(r, f.key, v)\" /></template>\n <template v-else-if=\"f.type === 'url'\"><a v-if=\"row[f.key]\" href=\"#\" @click.prevent style=\"color:var(--link);overflow:hidden;text-overflow:ellipsis\">{{ row[f.key] }}</a></template>\n <template v-else-if=\"f.type === 'progress'\"><span style=\"display:flex;align-items:center;gap:6px;width:100%\"><span style=\"flex:1;height:4px;border-radius:var(--radius-full);background:var(--track);overflow:hidden\"><span :style=\"{ display: 'block', width: (row[f.key] || 0) + '%', height: '100%', background: 'var(--chart-blue)' }\" /></span><span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-secondary)\">{{ row[f.key] || 0 }}%</span></span></template>\n <template v-else-if=\"f.type === 'number'\"><span style=\"margin-left:auto;font-family:var(--font-sans);font-size:var(--control-text);letter-spacing:0;font-variant-numeric:tabular-nums\">{{ row[f.key] }}</span></template>\n <template v-else><span :style=\"{ overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: f.primary ? 'var(--weight-medium)' : 'var(--weight-regular)', color: f.primary ? 'var(--text-heading)' : 'var(--text-primary)' }\">{{ row[f.key] }}</span></template>\n </span>\n <span :style=\"{ ...cellBase, height: rowH, background: hover === r ? 'var(--bg-inset)' : 'transparent' }\" />\n </template>\n <button type=\"button\" @click=\"addRow\" class=\"kb-st-new\" :style=\"{ gridColumn: '1 / -1', height: rowH }\"><Icon name=\"plus\" :size=\"14\" :style=\"tierIcon\" />New</button>\n <span :style=\"{ ...cellBase, height: rowH, ...stickyLeft, borderBottom: 'none' }\" />\n <span v-for=\"f in fields\" :key=\"'g' + f.key\" :style=\"{ ...cellBase, height: rowH, borderBottom: 'none', background: 'var(--bg-inset)', justifyContent: 'flex-end', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, color: 'var(--text-secondary)' }\">{{ agg(f) }}</span>\n <span :style=\"{ ...cellBase, height: rowH, borderBottom: 'none', background: 'var(--bg-inset)' }\" />\n </div>\n </div>\n </div>\n</template>\n<style scoped>\n.kb-pill{display:inline-flex;align-items:center;height:20px;padding:0 8px;border-radius:var(--radius-full);font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-xs);line-height:1;white-space:nowrap}\n.kb-st-btn{display:inline-flex;align-items:center;gap:5px;height:var(--control-h);padding:0 var(--control-px);border:none;border-radius:var(--radius-md);background:transparent;cursor:pointer;color:var(--text-body);font-family:var(--font-sans);font-size:var(--control-text);letter-spacing:var(--control-tracking)}\n.kb-st-btn:hover{background:var(--bg-hover)}\n.kb-st-new{display:flex;align-items:center;gap:6px;padding:0 var(--edge-pad);border:none;border-bottom:1px solid var(--border-subtle);background:transparent;cursor:pointer;color:var(--text-secondary);font-family:var(--font-sans);font-size:var(--control-text);letter-spacing:var(--control-tracking);text-align:left}\n.kb-st-new:hover{background:var(--bg-inset)}\n</style>\n","data/Spreadsheet.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { IconButton } from \"../buttons/IconButton.jsx\";\nimport { Select } from \"../forms/Select.jsx\";\n\nexport const colName = (i) => { let s = \"\"; i++; while (i > 0) { s = String.fromCharCode(64 + ((i - 1) % 26 + 1)) + s; i = Math.floor((i - 1) / 26); } return s; };\nconst norm = (cell) => cell == null ? { v: \"\" } : (typeof cell === \"object\" ? { ...cell } : { v: cell });\nconst refRC = (ref) => { const m = /^([A-Z]+)(\\d+)$/.exec(String(ref).trim().toUpperCase()); if (!m) return null; let c = 0; for (const ch of m[1]) c = c * 26 + (ch.charCodeAt(0) - 64); return { r: +m[2] - 1, c: c - 1 }; };\n\nfunction evalCell(cells, cell, depth = 0) {\n if (!cell || depth > 12) return \"\";\n const raw = cell.f != null ? cell.f : cell.v;\n if (typeof raw === \"string\" && raw.startsWith(\"=\")) return evalFormula(cells, raw, depth);\n return raw;\n}\nfunction numAt(cells, r, c, depth) { const v = evalCell(cells, cells[r] && cells[r][c], depth + 1); const n = typeof v === \"number\" ? v : parseFloat(String(v).replace(/[^0-9.eE+-]/g, \"\")); return isNaN(n) ? null : n; }\nfunction evalFormula(cells, f, depth = 0) {\n try {\n let expr = f.slice(1).toUpperCase().replace(/\\s+/g, \"\");\n expr = expr.replace(/(SUM|AVERAGE|AVG|COUNT|MIN|MAX)\\(([A-Z]+\\d+):([A-Z]+\\d+)\\)/g, (m, fn, a, b) => {\n const A = refRC(a), B = refRC(b), vals = [];\n for (let r = Math.min(A.r, B.r); r <= Math.max(A.r, B.r); r++) for (let c = Math.min(A.c, B.c); c <= Math.max(A.c, B.c); c++) { const n = numAt(cells, r, c, depth); if (n != null) vals.push(n); }\n if (fn === \"COUNT\") return String(vals.length);\n if (!vals.length) return \"0\";\n if (fn === \"SUM\") return String(vals.reduce((x, y) => x + y, 0));\n if (fn === \"MIN\") return String(Math.min(...vals));\n if (fn === \"MAX\") return String(Math.max(...vals));\n return String(vals.reduce((x, y) => x + y, 0) / vals.length);\n });\n expr = expr.replace(/[A-Z]+\\d+/g, (ref) => { const p = refRC(ref); const n = p ? numAt(cells, p.r, p.c, depth) : null; return String(n == null ? 0 : n); });\n if (!/^[-+*/().\\d\\seE]*$/.test(expr)) return \"#ERROR\";\n const out = Function('\"use strict\";return (' + expr + \")\")();\n return typeof out === \"number\" && isFinite(out) ? out : \"#ERROR\";\n } catch (e) { return \"#ERROR\"; }\n}\nfunction fmt(val, t, currency) {\n if (val === \"\" || val == null) return \"\";\n if (typeof val !== \"number\") return String(val);\n const r2 = Math.round(val * 100) / 100;\n if (t === \"percent\") return (Math.round(val * 1000) / 10) + \"%\";\n if (t === \"currency\") return currency + r2.toLocaleString();\n return r2.toLocaleString();\n}\n\n/** Excel-style spreadsheet: toolbar, formula bar (name box + fx), lettered/numbered frozen headers, click + shift-click range selection, double-click editing, =SUM()/AVERAGE()-style formulas, sheet tabs and a Sum/Avg/Count status bar. */\n/* A size region that defaults to sm: a spreadsheet is the densest surface in the system — 24px toolbar controls, 12px text, 28px cells. */\nexport function Spreadsheet({ data = [], rows, cols, colWidths = [], defaultColWidth = 92, currency = \"$\", defaultActive = \"A1\", sheets = [\"Sheet1\"], activeSheet, toolbar = true, formulaBar = true, statusBar = true, height, size = \"sm\", onChange, style }) {\n const nR = Math.max(rows || 0, data.length, 8), nC = Math.max(cols || 0, ...data.map(r => r.length), 6);\n const [cells, setCells] = React.useState(() => Array.from({ length: nR }, (_, r) => Array.from({ length: nC }, (_, c) => norm(data[r] && data[r][c]))));\n const a0 = refRC(defaultActive) || { r: 0, c: 0 };\n const [active, setActive] = React.useState(a0);\n const [anchor, setAnchor] = React.useState(a0);\n const [editing, setEditing] = React.useState(null);\n const [draft, setDraft] = React.useState(\"\");\n const [sheet, setSheet] = React.useState(activeSheet || sheets[0]);\n const cur = cells[active.r][active.c];\n const rawOf = (cell) => cell.f != null ? cell.f : (cell.v == null ? \"\" : String(cell.v));\n const commit = (r, c, text) => { const next = cells.map(row => row.map(cell => ({ ...cell }))); const cell = next[r][c]; delete cell.f; if (String(text).startsWith(\"=\")) { cell.f = text; cell.v = \"\"; } else { const n = text !== \"\" && !isNaN(Number(text)) ? Number(text) : text; cell.v = n; } setCells(next); onChange && onChange(next); };\n const lo = { r: Math.min(active.r, anchor.r), c: Math.min(active.c, anchor.c) }, hi = { r: Math.max(active.r, anchor.r), c: Math.max(active.c, anchor.c) };\n const multi = lo.r !== hi.r || lo.c !== hi.c;\n const inRange = (r, c) => r >= lo.r && r <= hi.r && c >= lo.c && c <= hi.c;\n const stats = React.useMemo(() => { if (!multi) return null; const vals = []; for (let r = lo.r; r <= hi.r; r++) for (let c = lo.c; c <= hi.c; c++) { const v = evalCell(cells, cells[r][c]); const n = typeof v === \"number\" ? v : NaN; if (!isNaN(n)) vals.push(n); } if (!vals.length) return null; const sum = vals.reduce((x, y) => x + y, 0); return { sum: Math.round(sum * 100) / 100, avg: Math.round(sum / vals.length * 100) / 100, count: vals.length }; }, [cells, active, anchor]);\n const pick = (r, c, e) => { setActive({ r, c }); if (!(e && e.shiftKey)) setAnchor({ r, c }); setEditing(null); };\n const setFlag = (k) => { const next = cells.map(row => row.map(cell => ({ ...cell }))); for (let r = lo.r; r <= hi.r; r++) for (let c = lo.c; c <= hi.c; c++) next[r][c][k] = !cur[k]; setCells(next); };\n const setType = (label) => { const t = { Automatic: undefined, Number: \"number\", Currency: \"currency\", Percent: \"percent\" }[label]; const next = cells.map(row => row.map(cell => ({ ...cell }))); for (let r = lo.r; r <= hi.r; r++) for (let c = lo.c; c <= hi.c; c++) { if (t) next[r][c].t = t; else delete next[r][c].t; } setCells(next); };\n const widths = [46]; for (let c = 0; c < nC; c++) widths.push(colWidths[c] || defaultColWidth);\n const hdr = { position: \"sticky\", top: 0, zIndex: 3, background: \"var(--bg-inset)\", borderRight: \"1px solid var(--border-control)\", borderBottom: \"1px solid var(--border-control)\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--text-xs)\", letterSpacing: \"var(--tracking-xs)\", color: \"var(--text-secondary)\", height: 26, boxSizing: \"border-box\", userSelect: \"none\" };\n const tbBtn = (label, k, st) => <button key={label} type=\"button\" data-wash={cur[k] ? undefined : \"\"} onClick={() => setFlag(k)} style={{ width: \"var(--control-h)\", height: \"var(--control-h)\", border: \"none\", borderRadius: \"var(--radius-md)\", cursor: \"pointer\", background: cur[k] ? \"var(--bg-active)\" : \"var(--wash, transparent)\", color: \"var(--text-body)\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", ...st }}>{label}</button>;\n return (\n <div data-size={size} style={{ border: \"1px solid var(--border-control)\", borderRadius: \"var(--radius-lg)\", overflow: \"hidden\", background: \"var(--bg-surface)\", boxSizing: \"border-box\", ...style }}>\n {toolbar && <div style={{ height: \"var(--bar-card-h)\", display: \"flex\", alignItems: \"center\", gap: 2, padding: \"0 var(--edge-pad)\", boxSizing: \"border-box\", borderBottom: \"1px solid var(--border-subtle)\" }}>\n <IconButton icon=\"undo-2\" muted label=\"Undo\" /><IconButton icon=\"redo-2\" muted label=\"Redo\" />\n <span style={{ width: 1, height: 16, background: \"var(--border-control)\", margin: \"0 6px\" }}></span>\n {tbBtn(\"B\", \"b\", { fontWeight: 700 })}{tbBtn(\"I\", \"i\", { fontStyle: \"italic\" })}{tbBtn(\"U\", \"u\", { textDecoration: \"underline\" })}\n <span style={{ width: 1, height: 16, background: \"var(--border-control)\", margin: \"0 6px\" }}></span>\n <Select options={[\"Automatic\", \"Number\", \"Currency\", \"Percent\"]} value={{ number: \"Number\", currency: \"Currency\", percent: \"Percent\" }[cur.t] || \"Automatic\"} onChange={setType} style={{ width: 110 }} />\n </div>}\n {formulaBar && <div style={{ height: \"var(--bar-h)\", display: \"flex\", alignItems: \"center\", borderBottom: \"1px solid var(--border-control)\" }}>\n <span style={{ width: 72, alignSelf: \"stretch\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", borderRight: \"1px solid var(--border-control)\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-heading)\" }}>{multi ? colName(lo.c) + (lo.r + 1) + \":\" + colName(hi.c) + (hi.r + 1) : colName(active.c) + (active.r + 1)}</span>\n <span style={{ padding: \"0 10px\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", fontStyle: \"italic\", color: \"var(--text-muted)\", borderRight: \"1px solid var(--border-subtle)\", alignSelf: \"stretch\", display: \"flex\", alignItems: \"center\" }}>fx</span>\n <input value={editing === \"fx\" ? draft : rawOf(cur)} onFocus={() => { setEditing(\"fx\"); setDraft(rawOf(cur)); }} onChange={(e) => setDraft(e.target.value)} onBlur={() => { if (editing === \"fx\") { commit(active.r, active.c, draft); setEditing(null); } }} onKeyDown={(e) => { if (e.key === \"Enter\") e.target.blur(); if (e.key === \"Escape\") { setEditing(null); e.target.blur(); } }} style={{ flex: 1, border: \"none\", outline: \"none\", background: \"transparent\", padding: \"0 10px\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-primary)\" }} />\n </div>}\n <div style={{ overflow: \"auto\", maxHeight: height }}>\n <div style={{ display: \"grid\", gridTemplateColumns: widths.map(w => w + \"px\").join(\" \"), width: \"fit-content\" }}>\n <span style={{ ...hdr, zIndex: 5, left: 0 }}><span style={{ width: 0, height: 0, borderStyle: \"solid\", borderWidth: \"0 0 10px 10px\", borderColor: \"transparent transparent var(--border-control) transparent\", alignSelf: \"flex-end\", marginLeft: \"auto\", marginRight: 3, marginBottom: 3 }}></span></span>\n {Array.from({ length: nC }, (_, c) => <span key={c} style={{ ...hdr, ...(c >= lo.c && c <= hi.c ? { background: \"var(--soft-bg)\", color: \"var(--action-primary)\" } : null) }}>{colName(c)}</span>)}\n {cells.map((row, r) => [\n <span key={\"h\" + r} style={{ ...hdr, position: \"sticky\", left: 0, top: \"auto\", zIndex: 2, height: 28, ...(r >= lo.r && r <= hi.r ? { background: \"var(--soft-bg)\", color: \"var(--action-primary)\" } : null) }}>{r + 1}</span>,\n ...row.map((cell, c) => {\n const v = evalCell(cells, cell);\n const isNum = typeof v === \"number\";\n const isActive = active.r === r && active.c === c;\n const isEdit = editing && editing !== \"fx\" && editing.r === r && editing.c === c;\n return <span key={r + \",\" + c} onMouseDown={(e) => pick(r, c, e)} onDoubleClick={() => { setEditing({ r, c }); setDraft(rawOf(cell)); }}\n style={{ position: \"relative\", height: 28, boxSizing: \"border-box\", borderRight: \"1px solid var(--border-subtle)\", borderBottom: \"1px solid var(--border-subtle)\", display: \"flex\", alignItems: \"center\", justifyContent: cell.align || (isNum ? \"flex-end\" : \"flex-start\"), padding: \"0 8px\", cursor: \"cell\", background: isActive ? \"var(--bg-surface)\" : inRange(r, c) ? \"var(--soft-bg)\" : \"var(--bg-surface)\", boxShadow: isActive ? \"inset 0 0 0 2px var(--action-primary)\" : \"none\", zIndex: isActive ? 1 : 0, fontFamily: \"var(--font-sans)\", fontVariantNumeric: isNum ? \"tabular-nums\" : \"normal\", fontSize: \"var(--control-text)\", fontVariantNumeric: \"tabular-nums\", color: v === \"#ERROR\" ? \"var(--danger)\" : \"var(--text-primary)\", fontWeight: cell.b ? 600 : \"var(--weight-regular)\", fontStyle: cell.i ? \"italic\" : \"normal\", textDecoration: cell.u ? \"underline\" : \"none\", whiteSpace: \"nowrap\", overflow: \"hidden\", userSelect: \"none\" }}>\n {isEdit ? <input autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={() => { commit(r, c, draft); setEditing(null); }} onKeyDown={(e) => { if (e.key === \"Enter\") e.target.blur(); if (e.key === \"Escape\") setEditing(null); }} style={{ position: \"absolute\", inset: 0, border: \"none\", outline: \"none\", padding: \"0 8px\", fontFamily: \"inherit\", fontSize: \"inherit\", background: \"var(--bg-surface)\", color: \"var(--text-primary)\" }} /> : fmt(v, cell.t, currency)}\n {isActive && !isEdit && <span style={{ position: \"absolute\", right: -3, bottom: -3, width: 6, height: 6, background: \"var(--action-primary)\", border: \"1px solid var(--bg-surface)\", cursor: \"crosshair\", zIndex: 2 }}></span>}\n </span>;\n })])}\n </div>\n </div>\n {statusBar && <div style={{ height: \"var(--bar-h)\", display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", borderTop: \"1px solid var(--border-control)\", background: \"var(--bg-inset)\", paddingRight: 12 }}>\n <div style={{ display: \"flex\", alignSelf: \"stretch\", alignItems: \"stretch\" }}>\n {sheets.map(s => <button key={s} type=\"button\" data-ink={sheet === s ? undefined : \"\"} onClick={() => setSheet(s)} style={{ border: \"none\", borderRight: \"1px solid var(--border-subtle)\", padding: \"0 16px\", cursor: \"pointer\", background: sheet === s ? \"var(--bg-surface)\" : \"transparent\", color: sheet === s ? \"var(--text-heading)\" : \"var(--ink, var(--text-secondary))\", fontFamily: \"var(--font-sans)\", fontWeight: sheet === s ? \"var(--weight-medium)\" : \"var(--weight-regular)\", fontSize: \"var(--control-text)\", boxShadow: sheet === s ? \"inset 0 2px 0 var(--action-primary)\" : \"none\" }}>{s}</button>)}\n <span style={{ display: \"flex\", alignItems: \"center\", padding: \"0 6px\" }}><IconButton icon=\"plus\" muted label=\"Add sheet\" /></span>\n </div>\n <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\" }}>{stats ? \"Sum \" + fmt(stats.sum, cur.t, currency) + \" Avg \" + fmt(stats.avg, cur.t, currency) + \" Count \" + stats.count : \"100%\"}</span>\n </div>}\n </div>\n );\n}\n","data/Spreadsheet.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport IconButton from '../buttons/IconButton.vue'\nimport KSelect from '../forms/Select.vue'\n// Excel-style spreadsheet: toolbar, formula bar (name box + fx), frozen lettered/numbered headers, click + shift-click range selection, double-click editing, =SUM()/AVERAGE() formulas, sheet tabs, Sum/Avg/Count status bar.\nconst props = defineProps({ data: { type: Array, default: () => [] }, rows: Number, cols: Number, colWidths: { type: Array, default: () => [] }, defaultColWidth: { type: Number, default: 92 }, currency: { type: String, default: '$' }, defaultActive: { type: String, default: 'A1' }, sheets: { type: Array, default: () => ['Sheet1'] }, toolbar: { type: Boolean, default: true }, formulaBar: { type: Boolean, default: true }, statusBar: { type: Boolean, default: true }, height: [Number, String], size: { type: String, default: 'sm' }, style: Object })\n// A size region that defaults to sm: a spreadsheet is the densest surface in the system — 24px toolbar controls, 12px text, 28px cells.\nconst emit = defineEmits(['change'])\n\nconst colName = (i) => { let s = ''; i++; while (i > 0) { s = String.fromCharCode(64 + ((i - 1) % 26 + 1)) + s; i = Math.floor((i - 1) / 26) } return s }\nconst refRC = (ref) => { const m = /^([A-Z]+)(\\d+)$/.exec(String(ref).trim().toUpperCase()); if (!m) return null; let c = 0; for (const ch of m[1]) c = c * 26 + (ch.charCodeAt(0) - 64); return { r: +m[2] - 1, c: c - 1 } }\nconst norm = (cell) => cell == null ? { v: '' } : (typeof cell === 'object' ? { ...cell } : { v: cell })\nfunction evalCell(cells, cell, depth = 0) { if (!cell || depth > 12) return ''; const raw = cell.f != null ? cell.f : cell.v; if (typeof raw === 'string' && raw.startsWith('=')) return evalFormula(cells, raw, depth); return raw }\nfunction numAt(cells, r, c, depth) { const v = evalCell(cells, cells[r] && cells[r][c], depth + 1); const n = typeof v === 'number' ? v : parseFloat(String(v).replace(/[^0-9.eE+-]/g, '')); return isNaN(n) ? null : n }\nfunction evalFormula(cells, f, depth = 0) {\n try {\n let expr = f.slice(1).toUpperCase().replace(/\\s+/g, '')\n expr = expr.replace(/(SUM|AVERAGE|AVG|COUNT|MIN|MAX)\\(([A-Z]+\\d+):([A-Z]+\\d+)\\)/g, (m, fn, a, b) => {\n const A = refRC(a), B = refRC(b), vals = []\n for (let r = Math.min(A.r, B.r); r <= Math.max(A.r, B.r); r++) for (let c = Math.min(A.c, B.c); c <= Math.max(A.c, B.c); c++) { const n = numAt(cells, r, c, depth); if (n != null) vals.push(n) }\n if (fn === 'COUNT') return String(vals.length)\n if (!vals.length) return '0'\n if (fn === 'SUM') return String(vals.reduce((x, y) => x + y, 0))\n if (fn === 'MIN') return String(Math.min(...vals))\n if (fn === 'MAX') return String(Math.max(...vals))\n return String(vals.reduce((x, y) => x + y, 0) / vals.length)\n })\n expr = expr.replace(/[A-Z]+\\d+/g, (ref) => { const p = refRC(ref); const n = p ? numAt(cells, p.r, p.c, depth) : null; return String(n == null ? 0 : n) })\n if (!/^[-+*/().\\d\\seE]*$/.test(expr)) return '#ERROR'\n const out = Function('\"use strict\";return (' + expr + ')')()\n return typeof out === 'number' && isFinite(out) ? out : '#ERROR'\n } catch (e) { return '#ERROR' }\n}\nfunction fmt(val, t, currency) { if (val === '' || val == null) return ''; if (typeof val !== 'number') return String(val); const r2 = Math.round(val * 100) / 100; if (t === 'percent') return (Math.round(val * 1000) / 10) + '%'; if (t === 'currency') return currency + r2.toLocaleString(); return r2.toLocaleString() }\n\nconst nR = Math.max(props.rows || 0, props.data.length, 8)\nconst nC = Math.max(props.cols || 0, ...props.data.map(r => r.length), 6)\nconst cells = ref(Array.from({ length: nR }, (_, r) => Array.from({ length: nC }, (_, c) => norm(props.data[r] && props.data[r][c]))))\nconst a0 = refRC(props.defaultActive) || { r: 0, c: 0 }\nconst active = ref(a0), anchor = ref(a0), editing = ref(null), draft = ref(''), sheet = ref(props.sheets[0])\nconst cur = computed(() => cells.value[active.value.r][active.value.c])\nconst rawOf = (cell) => cell.f != null ? cell.f : (cell.v == null ? '' : String(cell.v))\nconst lo = computed(() => ({ r: Math.min(active.value.r, anchor.value.r), c: Math.min(active.value.c, anchor.value.c) }))\nconst hi = computed(() => ({ r: Math.max(active.value.r, anchor.value.r), c: Math.max(active.value.c, anchor.value.c) }))\nconst multi = computed(() => lo.value.r !== hi.value.r || lo.value.c !== hi.value.c)\nconst inRange = (r, c) => r >= lo.value.r && r <= hi.value.r && c >= lo.value.c && c <= hi.value.c\nconst stats = computed(() => { if (!multi.value) return null; const vals = []; for (let r = lo.value.r; r <= hi.value.r; r++) for (let c = lo.value.c; c <= hi.value.c; c++) { const v = evalCell(cells.value, cells.value[r][c]); if (typeof v === 'number') vals.push(v) } if (!vals.length) return null; const sum = vals.reduce((x, y) => x + y, 0); return { sum: Math.round(sum * 100) / 100, avg: Math.round(sum / vals.length * 100) / 100, count: vals.length } })\nfunction pick(r, c, e) { active.value = { r, c }; if (!(e && e.shiftKey)) anchor.value = { r, c }; editing.value = null }\nfunction commit(r, c, text) { const cell = cells.value[r][c]; delete cell.f; if (String(text).startsWith('=')) { cell.f = text; cell.v = '' } else cell.v = text !== '' && !isNaN(Number(text)) ? Number(text) : text; emit('change', cells.value) }\nfunction setFlag(k) { const on = !cur.value[k]; for (let r = lo.value.r; r <= hi.value.r; r++) for (let c = lo.value.c; c <= hi.value.c; c++) cells.value[r][c][k] = on }\nfunction setType(label) { const t = { Automatic: undefined, Number: 'number', Currency: 'currency', Percent: 'percent' }[label]; for (let r = lo.value.r; r <= hi.value.r; r++) for (let c = lo.value.c; c <= hi.value.c; c++) { if (t) cells.value[r][c].t = t; else delete cells.value[r][c].t } }\nconst gridCols = computed(() => ['46px', ...Array.from({ length: nC }, (_, c) => (props.colWidths[c] || props.defaultColWidth) + 'px')].join(' '))\nconst hdr = { position: 'sticky', top: 0, zIndex: 3, background: 'var(--bg-inset)', borderRight: '1px solid var(--border-control)', borderBottom: '1px solid var(--border-control)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--text-xs)', letterSpacing: 'var(--tracking-xs)', color: 'var(--text-secondary)', height: '26px', boxSizing: 'border-box', userSelect: 'none' }\nconst hl = { background: 'var(--soft-bg)', color: 'var(--action-primary)' }\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ border: '1px solid var(--border-control)', borderRadius: 'var(--radius-lg)', overflow: 'hidden', background: 'var(--bg-surface)', boxSizing: 'border-box', ...style }\">\n <div v-if=\"toolbar\" style=\"height:var(--bar-card-h);display:flex;align-items:center;gap:2px;padding:0 var(--edge-pad);box-sizing:border-box;border-bottom:1px solid var(--border-subtle)\">\n <IconButton icon=\"undo-2\" muted label=\"Undo\" /><IconButton icon=\"redo-2\" muted label=\"Redo\" />\n <span style=\"width:1px;height:16px;background:var(--border-control);margin:0 6px\" />\n <button type=\"button\" :data-wash=\"cur.b ? undefined : ''\" @click=\"setFlag('b')\" :style=\"{ width: 'var(--control-h)', height: 'var(--control-h)', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', background: cur.b ? 'var(--bg-active)' : 'var(--wash, transparent)', color: 'var(--text-body)', fontWeight: 700 }\">B</button>\n <button type=\"button\" :data-wash=\"cur.i ? undefined : ''\" @click=\"setFlag('i')\" :style=\"{ width: 'var(--control-h)', height: 'var(--control-h)', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', background: cur.i ? 'var(--bg-active)' : 'var(--wash, transparent)', color: 'var(--text-body)', fontStyle: 'italic' }\">I</button>\n <button type=\"button\" :data-wash=\"cur.u ? undefined : ''\" @click=\"setFlag('u')\" :style=\"{ width: 'var(--control-h)', height: 'var(--control-h)', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', background: cur.u ? 'var(--bg-active)' : 'var(--wash, transparent)', color: 'var(--text-body)', textDecoration: 'underline' }\">U</button>\n <span style=\"width:1px;height:16px;background:var(--border-control);margin:0 6px\" />\n <KSelect :options=\"['Automatic','Number','Currency','Percent']\" :model-value=\"{ number: 'Number', currency: 'Currency', percent: 'Percent' }[cur.t] || 'Automatic'\" @update:modelValue=\"setType\" style=\"width:110px\" />\n </div>\n <div v-if=\"formulaBar\" style=\"height:var(--bar-h);display:flex;align-items:center;border-bottom:1px solid var(--border-control)\">\n <span style=\"width:72px;align-self:stretch;display:flex;align-items:center;justify-content:center;border-right:1px solid var(--border-control);font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ multi ? colName(lo.c) + (lo.r + 1) + ':' + colName(hi.c) + (hi.r + 1) : colName(active.c) + (active.r + 1) }}</span>\n <span style=\"padding:0 10px;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);font-style:italic;color:var(--text-muted);border-right:1px solid var(--border-subtle);align-self:stretch;display:flex;align-items:center\">fx</span>\n <input :value=\"editing === 'fx' ? draft : rawOf(cur)\" @focus=\"editing = 'fx'; draft = rawOf(cur)\" @input=\"draft = $event.target.value\" @blur=\"editing === 'fx' && (commit(active.r, active.c, draft), editing = null)\" @keydown.enter=\"$event.target.blur()\" @keydown.esc=\"editing = null; $event.target.blur()\" style=\"flex:1;border:none;outline:none;background:transparent;padding:0 10px;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-primary)\" />\n </div>\n <div :style=\"{ overflow: 'auto', maxHeight: typeof height === 'number' ? height + 'px' : height }\">\n <div :style=\"{ display: 'grid', gridTemplateColumns: gridCols, width: 'fit-content' }\">\n <span :style=\"{ ...hdr, zIndex: 5, left: 0 }\" />\n <span v-for=\"c in nC\" :key=\"'c' + c\" :style=\"{ ...hdr, ...((c - 1) >= lo.c && (c - 1) <= hi.c ? hl : null) }\">{{ colName(c - 1) }}</span>\n <template v-for=\"(row, r) in cells\" :key=\"r\">\n <span :style=\"{ ...hdr, position: 'sticky', left: 0, top: 'auto', zIndex: 2, height: '28px', ...(r >= lo.r && r <= hi.r ? hl : null) }\">{{ r + 1 }}</span>\n <span v-for=\"(cell, c) in row\" :key=\"r + ',' + c\" @mousedown=\"pick(r, c, $event)\" @dblclick=\"editing = { r, c }; draft = rawOf(cell)\"\n :style=\"{ position: 'relative', height: '28px', boxSizing: 'border-box', borderRight: '1px solid var(--border-subtle)', borderBottom: '1px solid var(--border-subtle)', display: 'flex', alignItems: 'center', justifyContent: cell.align || (typeof evalCell(cells, cell) === 'number' ? 'flex-end' : 'flex-start'), padding: '0 8px', cursor: 'cell', background: inRange(r, c) && !(active.r === r && active.c === c) ? 'var(--soft-bg)' : 'var(--bg-surface)', boxShadow: active.r === r && active.c === c ? 'inset 0 0 0 2px var(--action-primary)' : 'none', fontFamily: 'var(--font-sans)', fontVariantNumeric: typeof evalCell(cells, cell) === 'number' ? 'tabular-nums' : 'normal', fontSize: 'var(--control-text)', fontVariantNumeric: 'tabular-nums', color: evalCell(cells, cell) === '#ERROR' ? 'var(--danger)' : 'var(--text-primary)', fontWeight: cell.b ? 600 : 'var(--weight-regular)', fontStyle: cell.i ? 'italic' : 'normal', textDecoration: cell.u ? 'underline' : 'none', whiteSpace: 'nowrap', overflow: 'hidden', userSelect: 'none' }\">\n <input v-if=\"editing && editing !== 'fx' && editing.r === r && editing.c === c\" autofocus :value=\"draft\" @input=\"draft = $event.target.value\" @blur=\"commit(r, c, draft); editing = null\" @keydown.enter=\"$event.target.blur()\" @keydown.esc=\"editing = null\" style=\"position:absolute;inset:0;border:none;outline:none;padding:0 8px;font:inherit;background:var(--bg-surface);color:var(--text-primary)\" />\n <template v-else>{{ fmt(evalCell(cells, cell), cell.t, currency) }}</template>\n <span v-if=\"active.r === r && active.c === c && !editing\" style=\"position:absolute;right:-3px;bottom:-3px;width:6px;height:6px;background:var(--action-primary);border:1px solid var(--bg-surface);cursor:crosshair;z-index:2\" />\n </span>\n </template>\n </div>\n </div>\n <div v-if=\"statusBar\" style=\"height:var(--bar-h);display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--border-control);background:var(--bg-inset);padding-right:12px\">\n <div style=\"display:flex;align-self:stretch;align-items:stretch\">\n <button v-for=\"s in sheets\" :key=\"s\" type=\"button\" :data-ink=\"sheet === s ? undefined : ''\" @click=\"sheet = s\" :style=\"{ border: 'none', borderRight: '1px solid var(--border-subtle)', padding: '0 16px', cursor: 'pointer', background: sheet === s ? 'var(--bg-surface)' : 'transparent', color: sheet === s ? 'var(--text-heading)' : 'var(--ink, var(--text-secondary))', fontFamily: 'var(--font-sans)', fontWeight: sheet === s ? 'var(--weight-medium)' : 'var(--weight-regular)', fontSize: 'var(--control-text)', boxShadow: sheet === s ? 'inset 0 2px 0 var(--action-primary)' : 'none' }\">{{ s }}</button>\n <span style=\"display:flex;align-items:center;padding:0 6px\"><IconButton icon=\"plus\" muted label=\"Add sheet\" /></span>\n </div>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-secondary)\">{{ stats ? 'Sum ' + fmt(stats.sum, cur.t, currency) + ' Avg ' + fmt(stats.avg, cur.t, currency) + ' Count ' + stats.count : '100%' }}</span>\n </div>\n </div>\n</template>\n","data/KeyValueList.vue":"<script setup>\nimport KeyValue from './KeyValue.vue'\ndefineProps({ rows: { type: Array, default: () => [] }, style: Object })\n</script>\n<template>\n <div :style=\"{ display: 'flex', flexDirection: 'column', ...style }\">\n <template v-for=\"(r, i) in rows\" :key=\"r.label\">\n <KeyValue v-bind=\"r\" />\n <div v-if=\"i < rows.length - 1\" style=\"height:1px;background:var(--border-subtle)\" />\n </template>\n </div>\n</template>\n","data/Table.jsx":"import React from \"react\";\nimport { Checkbox } from \"../forms/Checkbox.jsx\";\nimport { IconTile } from \"../display/Badge.jsx\";\nimport { IconButton } from \"../buttons/IconButton.jsx\";\nimport { Icon } from \"../icons/Icon.jsx\";\n\n/* Selection checkbox: sits in the first column's leading padding, not in a column of its own, so the gap to the\n first column's text equals the edge padding (16px; 12px in sm) whatever the table width or column widths (a separate column took a share\n of any spare width). */\nconst CHECK_PAD = \"calc(var(--edge-pad) * 2 + var(--check-size))\";\n/* Config-code chip (`code: true` columns): Menlo at --text-code on the inset fill — the one place the code font appears in a table.\n Only text and numbers get the chip; an element in the cell (a Skeleton while loading, a link) renders as is. */\nconst codeChip = { fontFamily: \"var(--font-mono)\", fontSize: \"var(--text-code)\", lineHeight: \"var(--leading-code)\", letterSpacing: 0, background: \"var(--bg-inset)\", border: \"1px solid var(--border-subtle)\", borderRadius: 4, padding: \"1px 6px\", color: \"var(--text-primary)\", whiteSpace: \"nowrap\" };\nconst checkSlot = { position: \"absolute\", left: \"var(--edge-pad)\", top: 0, bottom: 0, display: \"flex\", alignItems: \"center\" };\n\n/* Two-line title cell (`twoLine` column): icon tile · title over description · trailing ⋯. Every measure is a --title-cell-* tier token:\n md 32px tile (r8) · 12px to the text · 14px title / 12px description · 2px between · 10px row padding → 58px row. */\nconst T_TILE = { width: \"var(--title-cell-tile)\", height: \"var(--title-cell-tile)\", borderRadius: \"var(--title-cell-tile-r)\" };\nconst T_ICON = { width: \"var(--title-cell-icon)\", height: \"var(--title-cell-icon)\" };\nconst T_ONE = { overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\", minWidth: 0 };\nconst T_TITLE = { ...T_ONE, fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-regular)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: \"var(--text-heading)\" };\nconst T_DESC = { ...T_ONE, fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", lineHeight: \"var(--leading-xs)\", letterSpacing: \"var(--tracking-xs)\", color: \"var(--text-secondary)\" };\nconst STAR_FILLED = \"M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z\";\n// field name (string) or accessor (row, index) => value\nconst pick = (a, r, ri) => typeof a === \"function\" ? a(r, ri) : a != null ? r[a] : undefined;\n/* Sorting: one column at a time. Click cycles ascending → descending → none; another column starts at ascending.\n The tooltip names the next step. Rows are reordered here unless manualSort (server-side: rows arrive ordered). */\n// the label is capped at the declared width minus the button (28) and its gap (4), so a long label truncates and the cell never\n// shrinks below label + button — the button is never pushed out\nconst SORT_BTN = 32;\nconst isEmpty = (v) => v == null || v === \"\" || v === \"–\";\n/* Every word the table draws itself. Pass `labels` to translate; missing keys fall back to these. The sort tooltip names the NEXT step:\n unsorted → sortAsc, ascending → sortDesc, descending → sortClear. */\nconst DEFAULT_LABELS = { sortAsc: \"Sort ascending\", sortDesc: \"Sort descending\", sortClear: \"Clear sorting\", more: \"More actions\", expand: \"Expand\", collapse: \"Collapse\" };\nconst sortLabel = (L, sorted) => sorted === \"asc\" ? L.sortDesc : sorted === \"desc\" ? L.sortClear : L.sortAsc;\n/* Tree (`tree` on the table, `children` on a row): the twoLine column indents 24px per level and reserves a 20px arrow slot + 4px,\n so a child's arrow sits exactly under its parent's tile. */\nconst TREE_INDENT = 24, TREE_TOGGLE = 20;\n/* Builds the visible rows. index = depth-first position over EVERY row (expanded or not) — for a flat table that is the position in\n `rows`. It is assigned before sorting, so selection, onRowMore and onRowOpen indices never move when the user sorts or expands.\n Sorting reorders siblings only; children stay under their parent. */\nfunction buildView(rows, { tree, rowKey, expanded, col, dir, manualSort }) {\n const idx = new Map(); let n = 0;\n const number = (list) => list.forEach((r) => { idx.set(r, n++); if (tree && Array.isArray(r.children)) number(r.children); });\n number(rows);\n const cmp = col && !manualSort ? (a, b) => {\n const va = a[col.key], vb = b[col.key], ea = isEmpty(va), eb = isEmpty(vb);\n if (ea || eb) return ea && eb ? idx.get(a) - idx.get(b) : ea ? 1 : -1; // empty values last in both directions\n return (col.compare ? col.compare(va, vb, a, b) : compareValues(va, vb)) * dir || idx.get(a) - idx.get(b);\n } : null;\n const out = [];\n const walk = (list, level) => (cmp ? list.slice().sort(cmp) : list).forEach((r) => {\n const ri = idx.get(r), key = r[rowKey] != null ? r[rowKey] : String(ri);\n const kids = !!tree && ((Array.isArray(r.children) && r.children.length > 0) || !!r.hasChildren);\n const open = kids && expanded.has(key);\n out.push({ r, ri, key, level, kids, open });\n if (open && Array.isArray(r.children)) walk(r.children, level + 1);\n });\n walk(rows, 0);\n return { list: out, total: n };\n}\nconst nextSort = (cur, key) => !cur || cur.key !== key ? { key, direction: \"asc\" } : cur.direction === \"asc\" ? { key, direction: \"desc\" } : null;\nconst num = (v) => typeof v === \"number\" ? v : typeof v === \"string\" && /^\\s*-?[\\d,]+(\\.\\d+)?\\s*$/.test(v) ? parseFloat(v.replace(/,/g, \"\")) : NaN;\nfunction compareValues(a, b) {\n const ea = a == null || a === \"\" || a === \"–\", eb = b == null || b === \"\" || b === \"–\";\n if (ea || eb) return ea && eb ? 0 : ea ? 1 : -1;\n const na = num(a), nb = num(b);\n if (!isNaN(na) && !isNaN(nb)) return na - nb;\n return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: \"base\" });\n}\nfunction TitleMark({ m }) {\n if (m === \"favorite\") return <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinejoin=\"round\" role=\"img\" aria-label=\"Favorite\" style={{ display: \"block\", flexShrink: 0, color: \"var(--warning)\" }}><path d={STAR_FILLED} /></svg>;\n return <span role=\"img\" aria-label={m.label} style={{ display: \"inline-flex\", flexShrink: 0, color: m.color || \"var(--text-muted)\" }}><Icon name={m.icon} size={14} /></span>;\n}\nfunction TreeToggle({ open, label, onClick }) {\n const [hover, setHover] = React.useState(false);\n return (\n <button type=\"button\" aria-expanded={open} aria-label={label} title={label} onClick={(e) => { e.stopPropagation(); onClick(); }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}\n style={{ width: TREE_TOGGLE, height: TREE_TOGGLE, padding: 0, border: \"none\", borderRadius: \"var(--radius-sm)\", display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", cursor: \"pointer\", background: hover ? \"var(--overlay-hover)\" : \"transparent\", color: hover ? \"var(--text-body)\" : \"var(--text-muted)\", transition: \"var(--t-hover)\" }}>\n <Icon name=\"chevron-right\" size={16} style={{ width: \"var(--control-icon)\", height: \"var(--control-icon)\", transform: open ? \"rotate(90deg)\" : \"none\", transition: \"transform .15s ease\" }} />\n </button>\n );\n}\nfunction TitleCell({ c, item, onRowMore, onRowOpen, labels, tree, onToggleRow }) {\n const { r, ri } = item;\n const o = c.twoLine === true ? {} : c.twoLine;\n const icon = pick(o.icon ?? \"icon\", r, ri), hue = pick(o.hue ?? \"hue\", r, ri), desc = pick(o.description ?? \"description\", r, ri), mark = pick(o.mark ?? \"mark\", r, ri);\n // onRowOpen: the tile and the title open the row (hand cursor); the title is also reachable by keyboard (Enter / Space)\n const open = onRowOpen ? (e) => onRowOpen(r, ri, e) : null;\n return (\n <div style={{ display: \"flex\", alignItems: \"center\", minWidth: c.minWidth ?? 180, ...(tree ? { paddingLeft: item.level * TREE_INDENT } : null) }}>\n {tree ? <span style={{ width: TREE_TOGGLE, flexShrink: 0, marginRight: TREE_INDENT - TREE_TOGGLE, display: \"inline-flex\" }}>{item.kids ? <TreeToggle open={item.open} label={item.open ? labels.collapse : labels.expand} onClick={() => onToggleRow(item)} /> : null}</span> : null}\n <div style={{ display: \"flex\", alignItems: \"center\", gap: \"var(--title-cell-tile-gap)\", flex: 1, minWidth: 0 }}>\n {icon ? <span onClick={open || undefined} style={{ display: \"inline-flex\", flexShrink: 0, cursor: open ? \"pointer\" : undefined }}><IconTile icon={icon} hue={hue || \"neutral\"} kind=\"row\" style={T_TILE} iconStyle={T_ICON} /></span> : null}\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: \"var(--title-cell-gap)\", flex: 1, minWidth: 0 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 4, minWidth: 0 }}>\n <span style={{ ...T_TITLE, ...(open ? { cursor: \"pointer\" } : null) }} {...(open ? { role: \"button\", tabIndex: 0, onClick: open, onKeyDown: (e) => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); open(e); } } } : null)}>{c.render ? c.render(r[c.key], r, ri) : r[c.key]}</span>\n {mark ? <TitleMark m={mark} /> : null}\n </div>\n {desc ? <span style={T_DESC}>{desc}</span> : null}\n </div>\n {o.more !== false ? <IconButton icon=\"ellipsis\" size=\"sm\" muted label={labels.more} title={labels.more} onClick={(e) => { if (e && e.stopPropagation) e.stopPropagation(); if (onRowMore) onRowMore(r, ri, e); }} /> : null}\n </div>\n </div>\n );\n}\n\n/**\n * Keybricks table. columns: [{key,label,width,minWidth,mono,code,twoLine,sortable,compare,align,render}].\n * sortable — hover the header for a muted sm sort button at the cell's right end (in line with a two-line row's ⋯; arrow-up); click: ascending → descending → none.\n * While sorted the button stays and the label turns --text-heading. One column at a time. sort / onSortChange control it\n * (defaultSort to seed it uncontrolled); compare(a, b, rowA, rowB) overrides the default comparison; manualSort keeps rows as given.\n * twoLine — the name column of a document list: icon tile, title over a description, optional mark (\"favorite\" = filled star), trailing ⋯\n * (onRowMore(row, index, event)); onRowOpen(row, index, event) makes the tile and the title open the row (hand cursor, Enter / Space on the title).\n * tree — rows nest through `children` (or `hasChildren` for rows you load on demand); the twoLine column indents 24px per level with an\n * expand arrow; expandedKeys / defaultExpandedKeys / onExpandChange(row, expanded, keys); rowKey names the key field (default \"id\").\n * labels — { sortAsc, sortDesc, sortClear, more, expand, collapse } for every word the table draws (English by default).\n * Give it a width; it shrinks to minWidth (default 180) before the other columns give way, and title and description truncate to whatever it gets. Row grows to 58px in md (50 sm · 68 lg).\n * The table is a size region: `size` (\"sm\" | \"md\" | \"lg\", default inherit → md) sets its rows, header, cell padding and every\n * control inside the cells from the tier tokens — rows are the tier's bar height (sm 36 · md 40 · lg 48), cells read the tier's\n * control text, header cells are the same size in weight 600 / text-secondary, sentence case, no letter-spacing.\n * `dense` is kept as an alias of size=\"sm\" (the data-preview drawer).\n * Every rule in the table is one weight (--border-subtle); the header is set apart by its --bg-table-header tint, never by a heavier line.\n * bordered — the table draws its own rounded card frame (border-card, radius-lg) and drops the edge rules that would double it.\n * framed — the table sits flush inside a container that already has a border (Card pad={false}); drops only the header's top rule.\n * resizable — a short divider between header cells (hover darkens it, cursor col-resize); drag to set that column's width.\n * Widths live in local state unless you pass columnWidths/onColumnResize to control them. Min width = column.minWidth ?? 48.\n */\nexport function Table({ columns = [], rows = [], size, dense, selectable, selected = [], onToggle, headerBorder = true, bordered, framed, resizable, columnWidths, onColumnResize, onRowMore, onRowOpen, sort, defaultSort = null, onSortChange, manualSort, tree, rowKey = \"id\", expandedKeys, defaultExpandedKeys, onExpandChange, labels, empty, style }) {\n const L = { ...DEFAULT_LABELS, ...labels };\n const [localExp, setLocalExp] = React.useState(defaultExpandedKeys || []);\n const expKeys = expandedKeys !== undefined ? expandedKeys : localExp;\n const [localSort, setLocalSort] = React.useState(defaultSort);\n const sortState = sort !== undefined ? sort : localSort;\n const [hotTh, setHotTh] = React.useState(null); // header under the pointer or holding focus\n const cycleSort = (key) => { const n = nextSort(sortState, key); if (sort === undefined) setLocalSort(n); if (onSortChange) onSortChange(n); };\n const { list: view, total } = React.useMemo(() => buildView(rows, {\n tree, rowKey, expanded: new Set(expKeys), manualSort,\n col: sortState && columns.find((x) => x.key === sortState.key), dir: sortState && sortState.direction === \"desc\" ? -1 : 1,\n }), [rows, columns, sortState, manualSort, tree, rowKey, expKeys]);\n const toggleRow = (item) => {\n const next = item.open ? expKeys.filter((k) => k !== item.key) : [...expKeys, item.key];\n if (expandedKeys === undefined) setLocalExp(next);\n if (onExpandChange) onExpandChange(item.r, !item.open, next);\n };\n const tier = size || (dense ? \"sm\" : undefined);\n const flushTop = bordered || framed;\n const colCount = columns.length || 1;\n const [localW, setLocalW] = React.useState({});\n const [drag, setDrag] = React.useState(null); // key being dragged\n const [hot, setHot] = React.useState(null); // key whose handle is hovered\n const thRefs = React.useRef({});\n const widths = columnWidths || localW;\n const widthOf = (c) => widths[c.key] ?? c.width;\n\n const startDrag = (e, c) => {\n e.preventDefault();\n const th = thRefs.current[c.key];\n const startX = e.clientX, startW = th ? th.getBoundingClientRect().width : (parseFloat(widthOf(c)) || 120);\n const min = c.minWidth ?? 48;\n setDrag(c.key);\n const move = (ev) => {\n const w = Math.max(min, Math.round(startW + ev.clientX - startX));\n if (onColumnResize) onColumnResize(c.key, w); else setLocalW((s) => ({ ...s, [c.key]: w }));\n };\n const up = () => { setDrag(null); window.removeEventListener(\"mousemove\", move); window.removeEventListener(\"mouseup\", up); document.body.style.cursor = \"\"; document.body.style.userSelect = \"\"; };\n document.body.style.cursor = \"col-resize\"; document.body.style.userSelect = \"none\";\n window.addEventListener(\"mousemove\", move); window.addEventListener(\"mouseup\", up);\n };\n\n // `mono` columns (codes, IDs, amounts) use the UI font with tabular figures; the monospace font is for code only\n const cellFont = (c) => c.mono\n ? { fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", fontVariantNumeric: \"tabular-nums\", color: \"var(--text-primary)\" }\n : { fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: \"var(--text-primary)\" };\n // first and last cells keep the card's edge padding; every other cell the tier's cell padding\n const cellPad = (i) => ({ ...(i === 0 && selectable ? { position: \"relative\" } : null), paddingTop: 0, paddingBottom: 0, paddingLeft: i === 0 ? (selectable ? CHECK_PAD : \"var(--edge-pad)\") : \"var(--cell-px)\", paddingRight: i === columns.length - 1 ? \"var(--edge-pad)\" : \"var(--cell-px)\" });\n\n const table = (\n <table data-size={tier} style={{ width: \"100%\", borderCollapse: \"collapse\", tableLayout: resizable ? \"fixed\" : \"auto\", ...(bordered ? null : style) }}>\n <thead>\n <tr style={{ background: \"var(--bg-table-header)\", borderTop: headerBorder && !flushTop ? \"1px solid var(--border-subtle)\" : \"none\", borderBottom: headerBorder ? \"1px solid var(--border-subtle)\" : \"none\", height: \"var(--bar-h)\" }}>\n {columns.map((c, i) => {\n const last = i === columns.length - 1;\n const active = drag === c.key, lit = active || hot === c.key;\n const sorted = sortState && sortState.key === c.key ? sortState.direction : null;\n const showSort = c.sortable && (sorted || hotTh === c.key);\n return (\n <th key={c.key} ref={(el) => { thRefs.current[c.key] = el; }} aria-sort={c.sortable ? (sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : \"none\") : undefined}\n onMouseEnter={c.sortable ? () => setHotTh(c.key) : undefined} onMouseLeave={c.sortable ? () => setHotTh(null) : undefined} onFocus={c.sortable ? () => setHotTh(c.key) : undefined} onBlur={c.sortable ? () => setHotTh(null) : undefined}\n style={{ position: \"relative\", textAlign: c.align || \"left\", ...cellPad(i), width: widthOf(c), fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-semibold)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: sorted ? \"var(--text-heading)\" : \"var(--text-secondary)\", whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>\n {selectable && i === 0 ? <span style={checkSlot}><Checkbox checked={total > 0 && selected.length === total} indeterminate={selected.length > 0 && selected.length < total} onChange={(v) => onToggle && onToggle(\"all\", v)} /></span> : null}\n {c.sortable ? (\n <span style={{ display: \"flex\", alignItems: \"center\", gap: 4 }}>\n <span style={{ overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\", minWidth: 0, ...(c.align === \"right\" ? { marginLeft: \"auto\" } : c.align === \"center\" ? { margin: \"0 auto\" } : null), ...(typeof widthOf(c) === \"number\" ? { maxWidth: widthOf(c) - SORT_BTN } : null) }}>{c.label}</span>\n <IconButton icon={sorted === \"desc\" ? \"arrow-down\" : \"arrow-up\"} size=\"sm\" muted label={sortLabel(L, sorted)} title={sortLabel(L, sorted)} onClick={() => cycleSort(c.key)} style={{ opacity: showSort ? 1 : 0, ...(c.align === \"right\" ? null : { marginLeft: \"auto\" }) }} />\n </span>\n ) : c.label}\n {resizable && !last ? (\n <span aria-hidden=\"true\" onMouseDown={(e) => startDrag(e, c)} onMouseEnter={() => setHot(c.key)} onMouseLeave={() => setHot(null)}\n style={{ position: \"absolute\", top: 0, right: -5, width: 11, height: \"100%\", cursor: \"col-resize\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 1 }}>\n <span style={{ width: active ? 2 : 1, height: \"50%\", background: lit ? \"var(--action-primary)\" : \"var(--border-split)\", borderRadius: 1, transition: \"var(--t-hover)\" }}></span>\n </span>\n ) : null}\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody>\n {view.map((item, pos) => { const { r, ri } = item; return (\n <tr key={item.key} data-row=\"\" aria-level={tree ? item.level + 1 : undefined} aria-expanded={item.kids ? item.open : undefined} style={{ height: \"var(--bar-h)\", background: \"var(--row-wash, transparent)\", transition: \"var(--t-hover)\", borderBottom: bordered && pos === view.length - 1 ? \"none\" : \"1px solid var(--border-subtle)\" }}>\n {columns.map((c, i) => (\n <td key={c.key} style={{ ...cellPad(i), ...(c.twoLine ? { paddingTop: \"var(--title-cell-py)\", paddingBottom: \"var(--title-cell-py)\", maxWidth: 0 } : null), textAlign: c.align || \"left\", whiteSpace: \"nowrap\", ...(resizable ? { overflow: \"hidden\", textOverflow: \"ellipsis\" } : null), ...cellFont(c) }}>{selectable && i === 0 ? <span style={checkSlot}><Checkbox checked={selected.includes(ri)} onChange={(v) => onToggle && onToggle(ri, v)} /></span> : null}{c.twoLine ? <TitleCell c={c} item={item} onRowMore={onRowMore} onRowOpen={onRowOpen} labels={L} tree={tree && c === columns.find((x) => x.twoLine)} onToggleRow={toggleRow} /> : (c.code && (typeof r[c.key] === \"string\" || typeof r[c.key] === \"number\") && r[c.key] !== \"\") ? <code style={codeChip}>{c.render ? c.render(r[c.key], r, ri) : r[c.key]}</code> : c.render ? c.render(r[c.key], r, ri) : r[c.key]}</td>\n ))}\n </tr>\n ); })}\n {rows.length === 0 && empty ? <tr><td colSpan={colCount} style={{ padding: 0 }}>{empty}</td></tr> : null}\n </tbody>\n </table>\n );\n if (!bordered) return table;\n return <div data-size={tier} style={{ background: \"var(--bg-surface)\", border: \"1px solid var(--border-card)\", borderRadius: \"var(--radius-lg)\", overflow: \"hidden\", ...style }}>{table}</div>;\n}\n","data/Table.vue":"<script setup>\nimport { px } from '../px.js'\nimport { ref, computed, reactive, getCurrentInstance } from 'vue'\nimport Checkbox from '../forms/Checkbox.vue'\nimport IconTile from '../display/IconTile.vue'\nimport IconButton from '../buttons/IconButton.vue'\nimport Icon from '../icons/Icon.vue'\n// Keybricks table. columns: [{key,label,width,minWidth,mono,code,twoLine,align,render}].\n// twoLine — the name column of a document list: icon tile, title over a description, optional mark (\"favorite\" = filled star), trailing ⋯\n// (@row-more(row, index, event)); @row-open(row, index, event) makes the tile and the title open the row (hand cursor, Enter / Space on the title).\n// tree — rows nest through `children` (or `hasChildren` for rows you load on demand); the twoLine column indents 24px per level with an\n// expand arrow; v-model:expandedKeys / defaultExpandedKeys / @expand-change(row, expanded, keys); rowKey names the key field (default \"id\").\n// labels — { sortAsc, sortDesc, sortClear, more, expand, collapse } for every word the table draws (English by default).\n// Give it a width; it shrinks to minWidth (default 180) before the other columns give way, and title and description truncate to whatever it gets. Row grows to 58px in md (50 sm · 68 lg).\n// sortable — hover the header for a muted sm sort button at the cell's right end (in line with a two-line row's ⋯; arrow-up); click: ascending → descending → none. While sorted the\n// button stays and the label turns --text-heading. One column at a time. v-model:sort / @sort-change (defaultSort seeds it\n// uncontrolled); compare(a, b, rowA, rowB) overrides the default comparison; manualSort keeps rows as given (server-side).\n// The table is a size region: `size` (\"sm\" | \"md\" | \"lg\", default inherit → md) sets its rows, header, cell padding and every control\n// inside the cells from the tier tokens — rows are the tier's bar height (sm 36 · md 40 · lg 48), cells read the tier's control text,\n// header cells are the same size in weight 600 / text-secondary, sentence case, no letter-spacing. `dense` is an alias of size=\"sm\".\n// Every rule in the table is one weight (--border-subtle); the header is set apart by its --bg-table-header tint, never by a heavier line.\n// bordered — the table draws its own rounded card frame (border-card, radius-lg) and drops the edge rules that would double it.\n// framed — the table sits flush inside a container that already has a border (Card :pad=\"false\"); drops only the header's top rule.\n// resizable — a short divider between header cells (hover darkens it, cursor col-resize); drag to set that column's width.\n// Widths live in local state unless you pass columnWidths + listen to @column-resize(key, px). Min width = column.minWidth ?? 48.\n// #empty slot renders inside the table when rows is empty.\nconst props = defineProps({ columns: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, size: String, dense: Boolean, selectable: Boolean, selected: { type: Array, default: () => [] }, headerBorder: { type: Boolean, default: true }, bordered: Boolean, framed: Boolean, resizable: Boolean, columnWidths: Object, sort: { type: Object, default: undefined }, defaultSort: { type: Object, default: null }, manualSort: Boolean, tree: Boolean, rowKey: { type: String, default: 'id' }, expandedKeys: { type: Array, default: undefined }, defaultExpandedKeys: { type: Array, default: () => [] }, labels: Object, style: Object })\nconst emit = defineEmits(['toggle', 'columnResize', 'rowMore', 'rowOpen', 'sortChange', 'update:sort', 'expandChange', 'update:expandedKeys'])\n// Every word the table draws itself; `labels` translates them. The sort tooltip names the NEXT step: unsorted → sortAsc, ascending → sortDesc, descending → sortClear.\nconst DEFAULT_LABELS = { sortAsc: 'Sort ascending', sortDesc: 'Sort descending', sortClear: 'Clear sorting', more: 'More actions', expand: 'Expand', collapse: 'Collapse' }\nconst L = computed(() => ({ ...DEFAULT_LABELS, ...(props.labels || {}) }))\nconst sortLabel = (sorted) => sorted === 'asc' ? L.value.sortDesc : sorted === 'desc' ? L.value.sortClear : L.value.sortAsc\n// the tile and title open the row only when a @row-open listener is attached\nconst inst = getCurrentInstance()\nconst canOpen = computed(() => !!(inst && inst.vnode.props && (inst.vnode.props.onRowOpen || inst.vnode.props['onRow-open'])))\nfunction openRow(r, ri, e) { emit('rowOpen', r, ri, e) }\nfunction openKey(r, ri, e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRow(r, ri, e) } }\n// Tree: 24px per level; a 20px arrow slot + 4px, so a child's arrow sits under its parent's tile.\nconst TREE_INDENT = 24, TREE_TOGGLE = 20\nconst localExp = ref([...props.defaultExpandedKeys])\nconst expKeys = computed(() => props.expandedKeys !== undefined ? props.expandedKeys : localExp.value)\nconst hotToggle = ref(null)\nfunction toggleRow(item) {\n const next = item.open ? expKeys.value.filter(k => k !== item.key) : [...expKeys.value, item.key]\n if (props.expandedKeys === undefined) localExp.value = next\n emit('update:expandedKeys', next); emit('expandChange', item.r, !item.open, next)\n}\nconst treeCol = computed(() => props.tree ? props.columns.find(x => x.twoLine) : null)\nconst localSort = ref(props.defaultSort)\nconst sortState = computed(() => props.sort !== undefined ? props.sort : localSort.value)\nconst hotTh = ref(null)\nconst nextSort = (cur, key) => !cur || cur.key !== key ? { key, direction: 'asc' } : cur.direction === 'asc' ? { key, direction: 'desc' } : null\nfunction cycleSort(key) { const n = nextSort(sortState.value, key); if (props.sort === undefined) localSort.value = n; emit('update:sort', n); emit('sortChange', n) }\nconst num = (v) => typeof v === 'number' ? v : typeof v === 'string' && /^\\s*-?[\\d,]+(\\.\\d+)?\\s*$/.test(v) ? parseFloat(v.replace(/,/g, '')) : NaN\nfunction compareValues(a, b) {\n const ea = a == null || a === '' || a === '–', eb = b == null || b === '' || b === '–'\n if (ea || eb) return ea && eb ? 0 : ea ? 1 : -1\n const na = num(a), nb = num(b)\n if (!isNaN(na) && !isNaN(nb)) return na - nb\n return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })\n}\n// Visible rows. index = depth-first position over EVERY row (expanded or not) — for a flat table the position in `rows` — assigned\n// before sorting, so selection and event indices never move. Sorting reorders siblings only; children stay under their parent.\nconst built = computed(() => {\n const idx = new Map(); let n = 0\n const number = (list) => list.forEach(r => { idx.set(r, n++); if (props.tree && Array.isArray(r.children)) number(r.children) })\n number(props.rows)\n const s = sortState.value, col = s && props.columns.find(x => x.key === s.key), dir = s && s.direction === 'desc' ? -1 : 1\n const isEmpty = (x) => x == null || x === '' || x === '–'\n const cmp = col && !props.manualSort ? (a, b) => { const va = a[col.key], vb = b[col.key], ea = isEmpty(va), eb = isEmpty(vb); if (ea || eb) return ea && eb ? idx.get(a) - idx.get(b) : ea ? 1 : -1; return (col.compare ? col.compare(va, vb, a, b) : compareValues(va, vb)) * dir || idx.get(a) - idx.get(b) } : null\n const exp = new Set(expKeys.value), out = []\n const walk = (list, level) => (cmp ? list.slice().sort(cmp) : list).forEach(r => {\n const ri = idx.get(r), key = r[props.rowKey] != null ? r[props.rowKey] : String(ri)\n const kids = props.tree && ((Array.isArray(r.children) && r.children.length > 0) || !!r.hasChildren)\n const open = kids && exp.has(key)\n out.push({ r, ri, key, level, kids, open })\n if (open && Array.isArray(r.children)) walk(r.children, level + 1)\n })\n walk(props.rows, 0)\n return { list: out, total: n }\n})\nconst view = computed(() => built.value.list)\nconst total = computed(() => built.value.total)\nconst sortedDir = (c) => sortState.value && sortState.value.key === c.key ? sortState.value.direction : null\n// Two-line title cell — every measure is a --title-cell-* tier token (md: 32px tile r8 · 12px to text · 14/12px · 2px gap · 10px padding → 58px).\nconst T_TILE = { width: 'var(--title-cell-tile)', height: 'var(--title-cell-tile)', borderRadius: 'var(--title-cell-tile-r)' }\nconst T_ICON = { width: 'var(--title-cell-icon)', height: 'var(--title-cell-icon)' }\nconst T_ONE = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }\nconst T_TITLE = { ...T_ONE, fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-regular)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: 'var(--text-heading)' }\nconst T_DESC = { ...T_ONE, fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', lineHeight: 'var(--leading-xs)', letterSpacing: 'var(--tracking-xs)', color: 'var(--text-secondary)' }\nconst STAR_FILLED = 'M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z'\nconst pick = (a, r, ri) => typeof a === 'function' ? a(r, ri) : a != null ? r[a] : undefined\nfunction tl(c, r, ri) {\n const o = c.twoLine === true ? {} : c.twoLine\n return { icon: pick(o.icon ?? 'icon', r, ri), hue: pick(o.hue ?? 'hue', r, ri), desc: pick(o.description ?? 'description', r, ri), mark: pick(o.mark ?? 'mark', r, ri), more: o.more !== false }\n}\nfunction more(r, ri, e) { if (e && e.stopPropagation) e.stopPropagation(); emit('rowMore', r, ri, e) }\nconst tier = computed(() => props.size || (props.dense ? 'sm' : undefined))\nconst flushTop = computed(() => props.bordered || props.framed)\nconst colCount = computed(() => props.columns.length || 1)\n// Selection checkbox sits in the first column's leading padding (no column of its own): the gap to the text equals the edge padding.\nconst CHECK_PAD = 'calc(var(--edge-pad) * 2 + var(--check-size))'\n// Config-code chip (`code: true` columns): Menlo at --text-code on the inset fill.\nconst codeChip = { fontFamily: 'var(--font-mono)', fontSize: 'var(--text-code)', lineHeight: 'var(--leading-code)', letterSpacing: 0, background: 'var(--bg-inset)', border: '1px solid var(--border-subtle)', borderRadius: '4px', padding: '1px 6px', color: 'var(--text-primary)', whiteSpace: 'nowrap' }\nconst checkSlot = { position: 'absolute', left: 'var(--edge-pad)', top: 0, bottom: 0, display: 'flex', alignItems: 'center' }\nconst frame = computed(() => props.bordered ? { background: 'var(--bg-surface)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)', overflow: 'hidden', ...props.style } : { display: 'contents' })\nconst localW = reactive({})\nconst drag = ref(null), hot = ref(null)\nconst thEls = {}\nconst widthOf = (c) => px((props.columnWidths || localW)[c.key] ?? c.width)\nfunction startDrag(e, c) {\n e.preventDefault()\n const th = thEls[c.key]\n const startX = e.clientX, startW = th ? th.getBoundingClientRect().width : (parseFloat(widthOf(c)) || 120), min = c.minWidth ?? 48\n drag.value = c.key\n const move = (ev) => { const w = Math.max(min, Math.round(startW + ev.clientX - startX)); if (props.columnWidths) emit('columnResize', c.key, w); else localW[c.key] = w }\n const up = () => { drag.value = null; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); document.body.style.cursor = ''; document.body.style.userSelect = '' }\n document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'\n window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)\n}\nfunction cellFont(c) { return c.mono ? { fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', fontVariantNumeric: 'tabular-nums', color: 'var(--text-primary)' } : { fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: 'var(--text-primary)' } }\n// first and last cells keep the card's edge padding; every other cell the tier's cell padding\nfunction cellPad(i) { return { ...(i === 0 && props.selectable ? { position: 'relative' } : null), paddingTop: 0, paddingBottom: 0, paddingLeft: i === 0 ? (props.selectable ? CHECK_PAD : 'var(--edge-pad)') : 'var(--cell-px)', paddingRight: i === props.columns.length - 1 ? 'var(--edge-pad)' : 'var(--cell-px)' } }\n</script>\n<template>\n <div :style=\"frame\" :data-size=\"bordered ? tier : undefined\">\n <table :data-size=\"tier\" :style=\"{ width: '100%', borderCollapse: 'collapse', tableLayout: resizable ? 'fixed' : 'auto', ...(bordered ? null : style) }\">\n <thead>\n <tr :style=\"{ background: 'var(--bg-table-header)', borderTop: (headerBorder && !flushTop) ? '1px solid var(--border-subtle)' : 'none', borderBottom: headerBorder ? '1px solid var(--border-subtle)' : 'none', height: 'var(--bar-h)' }\">\n <th v-for=\"(c, i) in columns\" :key=\"c.key\" :ref=\"el => { thEls[c.key] = el }\" :aria-sort=\"c.sortable ? (sortedDir(c) === 'asc' ? 'ascending' : sortedDir(c) === 'desc' ? 'descending' : 'none') : undefined\"\n @mouseenter=\"c.sortable && (hotTh = c.key)\" @mouseleave=\"c.sortable && (hotTh = null)\" @focusin=\"c.sortable && (hotTh = c.key)\" @focusout=\"c.sortable && (hotTh = null)\"\n :style=\"{ position: 'relative', textAlign: c.align || 'left', ...cellPad(i), width: widthOf(c), fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-semibold)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: sortedDir(c) ? 'var(--text-heading)' : 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }\">\n <span v-if=\"selectable && i === 0\" :style=\"checkSlot\"><Checkbox :checked=\"total > 0 && selected.length === total\" :indeterminate=\"selected.length > 0 && selected.length < total\" @change=\"v => emit('toggle', 'all', v)\" /></span>\n <span v-if=\"c.sortable\" :style=\"{ display: 'flex', alignItems: 'center', gap: '4px' }\"><span :style=\"{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, ...(c.align === 'right' ? { marginLeft: 'auto' } : c.align === 'center' ? { margin: '0 auto' } : null), ...(typeof ((columnWidths || localW)[c.key] ?? c.width) === 'number' ? { maxWidth: (((columnWidths || localW)[c.key] ?? c.width) - 32) + 'px' } : null) }\">{{ c.label }}</span><IconButton :icon=\"sortedDir(c) === 'desc' ? 'arrow-down' : 'arrow-up'\" size=\"sm\" muted :label=\"sortLabel(sortedDir(c))\" :title=\"sortLabel(sortedDir(c))\" :style=\"{ opacity: (sortedDir(c) || hotTh === c.key) ? 1 : 0, ...(c.align === 'right' ? null : { marginLeft: 'auto' }) }\" @click=\"cycleSort(c.key)\" /></span>\n <template v-else>{{ c.label }}</template>\n <span v-if=\"resizable && i < columns.length - 1\" aria-hidden=\"true\" @mousedown=\"e => startDrag(e, c)\" @mouseenter=\"hot = c.key\" @mouseleave=\"hot = null\"\n style=\"position:absolute;top:0;right:-5px;width:11px;height:100%;cursor:col-resize;display:flex;align-items:center;justify-content:center;z-index:1\">\n <span :style=\"{ width: drag === c.key ? '2px' : '1px', height: '50%', background: (drag === c.key || hot === c.key) ? 'var(--action-primary)' : 'var(--border-split)', borderRadius: '1px', transition: 'var(--t-hover)' }\"></span>\n </span>\n </th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"(item, pos) in view\" :key=\"item.key\" data-row=\"\" :aria-level=\"tree ? item.level + 1 : undefined\" :aria-expanded=\"item.kids ? item.open : undefined\" :style=\"{ height: 'var(--bar-h)', background: 'var(--row-wash, transparent)', transition: 'var(--t-hover)', borderBottom: bordered && pos === view.length - 1 ? 'none' : '1px solid var(--border-subtle)' }\">\n <template v-for=\"{ r, ri } in [item]\" :key=\"'r'\">\n <td v-for=\"(c, i) in columns\" :key=\"c.key\" :style=\"{ ...cellPad(i), ...(c.twoLine ? { paddingTop: 'var(--title-cell-py)', paddingBottom: 'var(--title-cell-py)', maxWidth: 0 } : null), textAlign: c.align || 'left', whiteSpace: 'nowrap', ...(resizable ? { overflow: 'hidden', textOverflow: 'ellipsis' } : null), ...cellFont(c) }\"><span v-if=\"selectable && i === 0\" :style=\"checkSlot\"><Checkbox :checked=\"selected.includes(ri)\" @change=\"v => emit('toggle', ri, v)\" /></span><div v-if=\"c.twoLine\" :style=\"{ display: 'flex', alignItems: 'center', minWidth: px(c.minWidth ?? 180), ...(treeCol === c ? { paddingLeft: (item.level * TREE_INDENT) + 'px' } : null) }\"><span v-if=\"treeCol === c\" :style=\"{ width: TREE_TOGGLE + 'px', flexShrink: 0, marginRight: (TREE_INDENT - TREE_TOGGLE) + 'px', display: 'inline-flex' }\"><button v-if=\"item.kids\" type=\"button\" :aria-expanded=\"item.open\" :aria-label=\"item.open ? L.collapse : L.expand\" :title=\"item.open ? L.collapse : L.expand\" @click.stop=\"toggleRow(item)\" @mouseenter=\"hotToggle = item.key\" @mouseleave=\"hotToggle = null\" :style=\"{ width: TREE_TOGGLE + 'px', height: TREE_TOGGLE + 'px', padding: 0, border: 'none', borderRadius: 'var(--radius-sm)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: hotToggle === item.key ? 'var(--overlay-hover)' : 'transparent', color: hotToggle === item.key ? 'var(--text-body)' : 'var(--text-muted)', transition: 'var(--t-hover)' }\"><Icon name=\"chevron-right\" :size=\"16\" :style=\"{ width: 'var(--control-icon)', height: 'var(--control-icon)', transform: item.open ? 'rotate(90deg)' : 'none', transition: 'transform .15s ease' }\" /></button></span><div style=\"display:flex;align-items:center;gap:var(--title-cell-tile-gap);flex:1;min-width:0\"><template v-for=\"t in [tl(c, r, ri)]\" :key=\"0\"><span v-if=\"t.icon\" :style=\"{ display: 'inline-flex', flexShrink: 0, cursor: canOpen ? 'pointer' : undefined }\" @click=\"e => canOpen && openRow(r, ri, e)\"><IconTile :icon=\"t.icon\" :hue=\"t.hue || 'neutral'\" kind=\"row\" :style=\"T_TILE\" :icon-style=\"T_ICON\" /></span><div style=\"display:flex;flex-direction:column;gap:var(--title-cell-gap);flex:1;min-width:0\"><div style=\"display:flex;align-items:center;gap:4px;min-width:0\"><span v-if=\"canOpen\" role=\"button\" tabindex=\"0\" :style=\"{ ...T_TITLE, cursor: 'pointer' }\" @click=\"e => openRow(r, ri, e)\" @keydown=\"e => openKey(r, ri, e)\">{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</span><span v-else :style=\"T_TITLE\">{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</span><svg v-if=\"t.mark === 'favorite'\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linejoin=\"round\" role=\"img\" aria-label=\"Favorite\" style=\"display:block;flex-shrink:0;color:var(--warning)\"><path :d=\"STAR_FILLED\" /></svg><span v-else-if=\"t.mark\" role=\"img\" :aria-label=\"t.mark.label\" :style=\"{ display: 'inline-flex', flexShrink: 0, color: t.mark.color || 'var(--text-muted)' }\"><Icon :name=\"t.mark.icon\" :size=\"14\" /></span></div><span v-if=\"t.desc\" :style=\"T_DESC\">{{ t.desc }}</span></div><IconButton v-if=\"t.more\" icon=\"ellipsis\" size=\"sm\" muted :label=\"L.more\" :title=\"L.more\" @click=\"e => more(r, ri, e)\" /></template></div></div><code v-else-if=\"c.code && (typeof r[c.key] === 'string' || typeof r[c.key] === 'number') && r[c.key] !== ''\" :style=\"codeChip\">{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</code><template v-else>{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</template></td>\n </template>\n </tr>\n <tr v-if=\"rows.length === 0 && $slots.empty\"><td :colspan=\"colCount\" style=\"padding:0\"><slot name=\"empty\" /></td></tr>\n </tbody>\n </table>\n </div>\n</template>\n"});
1
+ window.__KB_SRC__=Object.assign(window.__KB_SRC__||{},{"data/CodeBlock.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\n\n/** Monospace code/JSON block with a language label and copy-to-clipboard button (SQL Preview, export JSON). */\nexport function CodeBlock({ code = \"\", language, height, style }) {\n const [copied, setCopied] = React.useState(false);\n const copy = () => { try { navigator.clipboard && navigator.clipboard.writeText(code); } catch (e) {} setCopied(true); setTimeout(() => setCopied(false), 1400); };\n return (\n <div style={{ background: \"var(--bg-inset)\", border: \"1px solid var(--border-subtle)\", borderRadius: \"var(--radius-md)\", ...style }}>\n <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\", padding: \"7px 10px\", borderBottom: \"1px solid var(--border-subtle)\" }}>\n <span style={{ fontFamily: \"var(--font-mono)\", fontSize: \"var(--text-xs)\", color: \"var(--text-muted)\" }}>{language || \"code\"}</span>\n <button type=\"button\" onClick={copy} data-ink=\"\" style={{ display: \"flex\", alignItems: \"center\", gap: 4, border: \"none\", background: \"transparent\", cursor: \"pointer\", color: copied ? \"var(--success-strong)\" : \"var(--ink, var(--text-secondary))\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", padding: 0 }}>\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />{copied ? \"Copied\" : \"Copy\"}\n </button>\n </div>\n <pre style={{ margin: 0, padding: \"10px 12px\", overflow: \"auto\", height, fontFamily: \"var(--font-mono)\", fontSize: \"var(--text-xs)\", lineHeight: \"var(--leading-xs)\", color: \"var(--text-body)\" }}>{code}</pre>\n </div>\n );\n}\n","data/CodeBlock.vue":"<script setup>\nimport { ref } from 'vue'\nimport Icon from '../icons/Icon.vue'\n// Monospace code/JSON block with a language label and copy-to-clipboard button (SQL Preview, export JSON).\nconst props = defineProps({ code: { type: String, default: '' }, language: String, height: [Number, String], style: Object })\nconst copied = ref(false)\nfunction copy() {\n try { navigator.clipboard && navigator.clipboard.writeText(props.code) } catch (e) {}\n copied.value = true; setTimeout(() => copied.value = false, 1400)\n}\n</script>\n<template>\n <div :style=\"{ background: 'var(--bg-inset)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', ...style }\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;padding:7px 10px;border-bottom:1px solid var(--border-subtle)\">\n <span style=\"font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-muted)\">{{ language || 'code' }}</span>\n <button type=\"button\" @click=\"copy\" data-ink=\"\" :style=\"{ display: 'flex', alignItems: 'center', gap: '4px', border: 'none', background: 'transparent', cursor: 'pointer', color: copied ? 'var(--success-strong)' : 'var(--ink, var(--text-secondary))', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', padding: 0 }\">\n <Icon :name=\"copied ? 'check' : 'copy'\" :size=\"12\" />{{ copied ? 'Copied' : 'Copy' }}\n </button>\n </div>\n <pre :style=\"{ margin: 0, padding: '10px 12px', overflow: 'auto', height, fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', lineHeight: 'var(--leading-xs)', color: 'var(--text-body)' }\">{{ code }}</pre>\n </div>\n</template>\n","data/DiffValue.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\n\nconst toneColor = { neutral: \"var(--text-heading)\", warning: \"var(--warning-text)\", success: \"var(--success-text)\", danger: \"var(--danger-text)\" };\n\n/** Before → after value change (\"USD → EUR\"). tone colors the new value; from renders muted + struck through when both sides are given. */\nexport function DiffValue({ from, to, tone = \"warning\", mono = true, style }) {\n return (\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6, fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", ...style }}>\n {from != null ? <span style={{ color: \"var(--text-muted)\", textDecoration: \"line-through\" }}>{from}</span> : null}\n <Icon name=\"arrow-right\" size={12} style={{ color: \"var(--text-muted)\" }} />\n <span style={{ color: toneColor[tone], fontWeight: \"var(--weight-medium)\" }}>{to}</span>\n </span>\n );\n}\n","data/DiffValue.vue":"<script setup>\nimport Icon from '../icons/Icon.vue'\nconst toneColor = { neutral: 'var(--text-heading)', warning: 'var(--warning-text)', success: 'var(--success-text)', danger: 'var(--danger-text)' }\n// Before → after value change (\"USD → EUR\"). tone colors the new value; from renders muted + struck through when both sides are given.\ndefineProps({ from: [String, Number], to: [String, Number], tone: { type: String, default: 'warning' }, mono: { type: Boolean, default: true }, style: Object })\n</script>\n<template>\n <span :style=\"{ display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', ...style }\">\n <span v-if=\"from != null\" style=\"color:var(--text-muted);text-decoration:line-through\">{{ from }}</span>\n <Icon name=\"arrow-right\" :size=\"12\" style=\"color:var(--text-muted)\" />\n <span :style=\"{ color: toneColor[tone], fontWeight: 'var(--weight-medium)' }\">{{ to }}</span>\n </span>\n</template>\n","data/FilterBar.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { Button } from \"../buttons/Button.jsx\";\n\n/** SAP Fiori-style list-report filter bar: variant title row with expand/collapse, a wrapping row of labeled filter fields (pass Field-wrapped controls as children), and Adapt Filters (n) + Go actions.\n * A size region: `size` (default inherit → md) sets the title bar height, the fields' tier and the gap between fields (--group-gap) so every control in it matches. */\nexport function FilterBar({ variant = \"Standard\", modified, adaptCount, defaultExpanded = true, size, onGo, onAdapt, children, style }) {\n const [open, setOpen] = React.useState(defaultExpanded);\n return (\n <div data-size={size} style={{ background: \"var(--bg-surface)\", border: \"1px solid var(--border-card)\", borderRadius: \"var(--radius-lg)\", boxSizing: \"border-box\", ...style }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: \"var(--control-gap)\", minHeight: \"var(--bar-card-h)\", padding: \"0 var(--edge-pad)\", boxSizing: \"border-box\" }}>\n <button type=\"button\" aria-expanded={open} onClick={() => setOpen(!open)} style={{ display: \"flex\", alignItems: \"center\", gap: 6, border: \"none\", background: \"transparent\", cursor: \"pointer\", padding: 0, color: \"var(--text-heading)\", fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\" }}>\n {variant}{modified && <span style={{ color: \"var(--text-muted)\", fontWeight: \"var(--weight-regular)\" }}>*</span>}\n <span style={{ color: \"var(--text-muted)\", display: \"inline-flex\", transform: open ? \"rotate(180deg)\" : \"none\", transition: \"transform var(--dur-fast) var(--ease-out)\" }}><Icon name=\"chevron-down\" size={16} style={{ width: \"var(--control-icon)\", height: \"var(--control-icon)\" }} /></span>\n </button>\n <span style={{ flex: 1 }}></span>\n <Button variant=\"text\" onClick={onAdapt}>Adapt Filters{adaptCount ? ` (${adaptCount})` : \"\"}</Button>\n <Button onClick={onGo}>Go</Button>\n </div>\n {open && <div style={{ display: \"flex\", flexWrap: \"wrap\", alignItems: \"flex-end\", gap: \"var(--control-gap) var(--group-gap)\", padding: \"12px var(--edge-pad) var(--edge-pad)\", borderTop: \"1px solid var(--border-subtle)\" }}>{children}</div>}\n </div>\n );\n}\n","data/FilterBar.vue":"<script setup>\nimport { ref } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport Button from '../buttons/Button.vue'\n// SAP Fiori-style list-report filter bar: variant title + expand/collapse, wrapping labeled filter fields (slot), Adapt Filters (n) + Go.\n// A size region: `size` (default inherit → md) sets the title bar height, the fields' tier and the gap between fields (--group-gap) so every control in it matches.\nconst props = defineProps({ variant: { type: String, default: 'Standard' }, modified: Boolean, adaptCount: Number, defaultExpanded: { type: Boolean, default: true }, size: String, style: Object })\nconst emit = defineEmits(['go', 'adapt'])\nconst open = ref(props.defaultExpanded)\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ background: 'var(--bg-surface)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)', boxSizing: 'border-box', ...style }\">\n <div style=\"display:flex;align-items:center;gap:var(--control-gap);min-height:var(--bar-card-h);padding:0 var(--edge-pad);box-sizing:border-box\">\n <button type=\"button\" :aria-expanded=\"open\" @click=\"open = !open\" style=\"display:flex;align-items:center;gap:6px;border:none;background:transparent;cursor:pointer;padding:0;color:var(--text-heading);font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--control-text);line-height:var(--control-leading);letter-spacing:var(--control-tracking)\">\n {{ variant }}<span v-if=\"modified\" style=\"color:var(--text-muted);font-weight:var(--weight-regular)\">*</span>\n <span :style=\"{ color: 'var(--text-muted)', display: 'inline-flex', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform var(--dur-fast) var(--ease-out)' }\"><Icon name=\"chevron-down\" :size=\"16\" :style=\"{ width: 'var(--control-icon)', height: 'var(--control-icon)' }\" /></span>\n </button>\n <span style=\"flex:1\" />\n <Button variant=\"text\" @click=\"emit('adapt')\">Adapt Filters{{ adaptCount ? ` (${adaptCount})` : '' }}</Button>\n <Button @click=\"emit('go')\">Go</Button>\n </div>\n <div v-if=\"open\" style=\"display:flex;flex-wrap:wrap;align-items:flex-end;gap:var(--control-gap) var(--group-gap);padding:12px var(--edge-pad) var(--edge-pad);border-top:1px solid var(--border-subtle)\"><slot /></div>\n </div>\n</template>\n","data/KeyValue.jsx":"import React from \"react\";\n\n/** Label/value row (config summaries, confirmation steps). Values always use the UI font with tabular figures; `mono` is kept for 2.x callers and changes nothing. */\nexport function KeyValue({ label, value, mono, style }) {\n return (\n <div style={{ display: \"flex\", justifyContent: \"space-between\", gap: 12, padding: \"6px 0\", ...style }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-base)\", lineHeight: \"var(--leading-base)\", letterSpacing: \"var(--tracking-base)\", color: \"var(--text-secondary)\" }}>{label}</span>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-base)\", lineHeight: \"var(--leading-base)\", letterSpacing: \"var(--tracking-base)\", fontVariantNumeric: \"tabular-nums\", color: \"var(--text-heading)\", textAlign: \"right\" }}>{value}</span>\n </div>\n );\n}\n\n/** Stack of KeyValue rows with hairline separators. rows: [{label,value,mono}]. */\nexport function KeyValueList({ rows = [], style }) {\n return (\n <div style={{ display: \"flex\", flexDirection: \"column\", ...style }}>\n {rows.map((r, i) => (\n <React.Fragment key={r.label}>\n <KeyValue {...r} />\n {i < rows.length - 1 ? <div style={{ height: 1, background: \"var(--border-subtle)\" }} /> : null}\n </React.Fragment>\n ))}\n </div>\n );\n}\n","data/KeyValue.vue":"<script setup>\n// Label/value row (config summaries, confirmation steps). Values always use the UI font with tabular figures; `mono` is kept for 2.x callers and changes nothing.\ndefineProps({ label: String, value: [String, Number], mono: Boolean, style: Object })\n</script>\n<template>\n <div :style=\"{ display: 'flex', justifyContent: 'space-between', gap: '12px', padding: '6px 0', ...style }\">\n <span style=\"font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-secondary)\">{{ label }}</span>\n <span :style=\"{ fontFamily: 'var(--font-sans)', fontSize: 'var(--text-base)', lineHeight: 'var(--leading-base)', letterSpacing: 'var(--tracking-base)', fontVariantNumeric: 'tabular-nums', color: 'var(--text-heading)', textAlign: 'right' }\">{{ value }}</span>\n </div>\n</template>\n","data/ListRow.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { IconTile } from \"../display/Badge.jsx\";\n\n/** Expandable workflow list row: chevron + bordered 32px icon tile + title/meta + right count. */\nexport function ListRow({ icon = \"file-text\", title, meta, trailing, expanded, onClick, children, style }) {\n const [hover, setHover] = React.useState(false);\n return (\n <div style={{ borderBottom: \"1px solid var(--border-card)\", ...style }}>\n <div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}\n style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", padding: \"12px 16px\", cursor: \"pointer\", background: hover ? \"var(--bg-inset)\" : \"transparent\", transition: \"var(--t-hover)\" }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <span style={{ color: \"var(--text-muted)\", transform: expanded ? \"rotate(90deg)\" : \"none\", transition: \"transform .15s ease\", display: \"inline-flex\" }}><Icon name=\"chevron-right\" size={16} /></span>\n <IconTile icon={icon} kind=\"row\" />\n <div style={{ display: \"flex\", flexDirection: \"column\" }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--text-base)\", lineHeight: \"var(--leading-base)\", letterSpacing: \"var(--tracking-base)\", color: \"var(--text-heading)\" }}>{title}</span>\n {meta ? <span style={{ fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-regular)\", fontSize: \"var(--text-xs)\", lineHeight: \"var(--leading-xs)\", color: \"var(--text-secondary)\" }}>{meta}</span> : null}\n </div>\n </div>\n {trailing ? <span style={{ fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--text-base)\", lineHeight: \"var(--leading-base)\", letterSpacing: \"var(--tracking-base)\", fontVariantNumeric: \"tabular-nums\", color: \"var(--text-secondary)\" }}>{trailing}</span> : null}\n </div>\n {expanded && children ? <div style={{ padding: \"0 16px 12px 72px\" }}>{children}</div> : null}\n </div>\n );\n}\n","data/ListRow.vue":"<script setup>\nimport { ref } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport IconTile from '../display/IconTile.vue'\n// Expandable workflow list row: chevron + bordered 32px icon tile + title/meta + right count.\ndefineProps({ icon: { type: String, default: 'file-text' }, title: String, meta: String, trailing: [String, Number], expanded: Boolean, style: Object })\ndefineEmits(['click'])\nconst hover = ref(false)\n</script>\n<template>\n <div :style=\"{ borderBottom: '1px solid var(--border-card)', ...style }\">\n <div @click=\"$emit('click')\" @mouseenter=\"hover = true\" @mouseleave=\"hover = false\" :style=\"{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', cursor: 'pointer', background: hover ? 'var(--bg-inset)' : 'transparent', transition: 'var(--t-hover)' }\">\n <div style=\"display:flex;align-items:center;gap:12px\">\n <span :style=\"{ color: 'var(--text-muted)', transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s ease', display: 'inline-flex' }\"><Icon name=\"chevron-right\" :size=\"16\" /></span>\n <IconTile :icon=\"icon\" kind=\"row\" />\n <div style=\"display:flex;flex-direction:column\">\n <span style=\"font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-heading)\">{{ title }}</span>\n <span v-if=\"meta\" style=\"font-family:var(--font-sans);font-weight:var(--weight-regular);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary)\">{{ meta }}</span>\n </div>\n </div>\n <span v-if=\"trailing\" style=\"font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);font-variant-numeric:tabular-nums;color:var(--text-secondary)\">{{ trailing }}</span>\n </div>\n <div v-if=\"expanded\" style=\"padding:0 16px 12px 72px\"><slot /></div>\n </div>\n</template>\n","data/Pagination.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\n\nconst tierIcon = { width: \"var(--control-icon)\", height: \"var(--control-icon)\" };\n\n/** Table pagination — range label + prev/next + numbered pages, all at the region's tier (page items are control-height squares with no border — the current page takes the neutral selected fill (--bg-active, weight 600) — it is a state, not an action, so it never uses the action blue; the rest show a wash on hover). `format(from, to, total, page, pageCount)` localises the label. */\nexport function Pagination({ page = 1, pageCount = 1, total, pageSize = 20, size, format, onChange, style }) {\n const btn = (content, disabled, onClick, active, label) => (\n <button type=\"button\" disabled={disabled} onClick={onClick} aria-label={label} aria-current={active ? \"page\" : undefined} data-wash={active || disabled ? undefined : \"\"}\n style={{ minWidth: \"var(--control-h)\", height: \"var(--control-h)\", padding: \"0 6px\", boxSizing: \"border-box\", borderRadius: \"var(--radius-md)\", border: \"none\", background: active ? \"var(--bg-active)\" : \"var(--wash, transparent)\", color: active ? \"var(--text-heading)\" : \"var(--text-body)\", fontWeight: active ? \"var(--weight-semibold)\" : \"var(--weight-regular)\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", letterSpacing: \"var(--control-tracking)\", fontVariantNumeric: \"tabular-nums\", cursor: disabled ? \"default\" : \"pointer\", opacity: disabled ? .45 : 1, display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", transition: \"var(--t-hover)\" }}>\n {content}\n </button>\n );\n const pages = [];\n const first = Math.max(1, Math.min(page - 1, pageCount - 2));\n for (let i = first; i < first + 3 && i <= pageCount; i++) pages.push(i);\n const from = total != null ? (page - 1) * pageSize + 1 : null, to = total != null ? Math.min(page * pageSize, total) : null;\n const text = format ? format(from, to, total, page, pageCount) : (total != null ? `${from}–${to} of ${total}` : `Page ${page} of ${pageCount}`);\n return (\n <div data-size={size} style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", gap: \"var(--group-gap)\", ...style }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", fontVariantNumeric: \"tabular-nums\", color: \"var(--text-secondary)\" }}>\n {text}\n </span>\n <div style={{ display: \"flex\", gap: 4 }}>\n {btn(<Icon name=\"chevron-left\" size={16} style={tierIcon} />, page <= 1, () => onChange && onChange(page - 1), false, \"Previous page\")}\n {pages.map((p) => <React.Fragment key={p}>{btn(p, false, () => onChange && onChange(p), p === page)}</React.Fragment>)}\n {btn(<Icon name=\"chevron-right\" size={16} style={tierIcon} />, page >= pageCount, () => onChange && onChange(page + 1), false, \"Next page\")}\n </div>\n </div>\n );\n}\n","data/Pagination.vue":"<script setup>\nimport { computed } from 'vue'\nimport Icon from '../icons/Icon.vue'\n// Table pagination — range label + prev/next + numbered pages, all at the region's tier (page items are control-height squares). `format(from, to, total, page, pageCount)` localises the label.\nconst tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }\nconst props = defineProps({ page: { type: Number, default: 1 }, pageCount: { type: Number, default: 1 }, total: Number, pageSize: { type: Number, default: 20 }, size: String, format: Function, style: Object })\nconst emit = defineEmits(['change'])\nconst pages = computed(() => {\n const from = Math.max(1, Math.min(props.page - 1, props.pageCount - 2)), out = []\n for (let i = from; i < from + 3 && i <= props.pageCount; i++) out.push(i)\n return out\n})\nconst label = computed(() => {\n const from = props.total != null ? (props.page - 1) * props.pageSize + 1 : null, to = props.total != null ? Math.min(props.page * props.pageSize, props.total) : null\n if (props.format) return props.format(from, to, props.total, props.page, props.pageCount)\n return props.total != null ? `${from}–${to} of ${props.total}` : `Page ${props.page} of ${props.pageCount}`\n})\nfunction btnStyle(active, disabled) { return { minWidth: 'var(--control-h)', height: 'var(--control-h)', padding: '0 6px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', background: active ? 'var(--bg-active)' : 'var(--wash, transparent)', color: active ? 'var(--text-heading)' : 'var(--text-body)', fontWeight: active ? 'var(--weight-semibold)' : 'var(--weight-regular)', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', letterSpacing: 'var(--control-tracking)', fontVariantNumeric: 'tabular-nums', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? .45 : 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'var(--t-hover)' } }\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--group-gap)', ...style }\">\n <span style=\"font-family:var(--font-sans);font-size:var(--control-text);line-height:var(--control-leading);letter-spacing:var(--control-tracking);font-variant-numeric:tabular-nums;color:var(--text-secondary)\">{{ label }}</span>\n <div style=\"display:flex;gap:4px\">\n <button type=\"button\" aria-label=\"Previous page\" :disabled=\"page <= 1\" :data-wash=\"page <= 1 ? undefined : ''\" @click=\"emit('change', page - 1)\" :style=\"btnStyle(false, page <= 1)\"><Icon name=\"chevron-left\" :size=\"16\" :style=\"tierIcon\" /></button>\n <button v-for=\"p in pages\" :key=\"p\" type=\"button\" :aria-current=\"p === page ? 'page' : undefined\" :data-wash=\"p === page ? undefined : ''\" @click=\"emit('change', p)\" :style=\"btnStyle(p === page, false)\">{{ p }}</button>\n <button type=\"button\" aria-label=\"Next page\" :disabled=\"page >= pageCount\" :data-wash=\"page >= pageCount ? undefined : ''\" @click=\"emit('change', page + 1)\" :style=\"btnStyle(false, page >= pageCount)\"><Icon name=\"chevron-right\" :size=\"16\" :style=\"tierIcon\" /></button>\n </div>\n </div>\n</template>\n","data/SmartTable.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { IconButton } from \"../buttons/IconButton.jsx\";\nimport { Checkbox } from \"../forms/Checkbox.jsx\";\nimport { Avatar } from \"../display/Avatar.jsx\";\nimport { SearchInput } from \"../forms/SearchInput.jsx\";\nimport { Menu } from \"../feedback/Menu.jsx\";\n\nconst typeIcon = { text: \"type\", longText: \"align-left\", number: \"hash\", select: \"chevron-down\", multiSelect: \"tag\", date: \"calendar\", person: \"user\", checkbox: \"check-square\", url: \"link\", progress: \"bar-chart-2\" };\nconst Pill = ({ hue = \"gray\", children }) => <span style={{ display: \"inline-flex\", alignItems: \"center\", height: 20, padding: \"0 8px\", borderRadius: \"var(--radius-full)\", background: `var(--hue-${hue}-bg)`, color: `var(--hue-${hue}-fg)`, fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--text-xs)\", lineHeight: 1, whiteSpace: \"nowrap\" }}>{children}</span>;\n\n/** Notion/Airtable-style database grid (\"smart table\"): field-type icons in headers, colored select pills, person/date/checkbox/url/progress cells, row hover + expand, inline text/number editing, + New row, add-field column, per-column footer aggregates, and a view toolbar (Hide fields · Filter · Group · Sort · search). */\n /* A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size=\"md\" for a roomier grid.\n density: \"default\" rows are --bar-h (36 / 40 / 48) with --cell-px; \"compact\" rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's \"Row height\" menu switches it; rowHeight (a raw height) overrides density.\n Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline.\n resizable (default true): drag a field header's right edge to set its width (min field.minWidth ?? 60); columnWidths / onColumnResize(key, px) control it. */\n/* Column resize handle — a 7px hit strip inside the header cell's right edge (cursor col-resize). Hover or drag turns the cell's own right\n grid line into a 2px --action-primary line (EDGE_LIT: the border plus a 1px inset shadow), so the line sits ON the column boundary. */\nfunction ResizeHandle({ onMouseDown, onEnter, onLeave }) {\n return <span aria-hidden=\"true\" onMouseDown={onMouseDown} onMouseEnter={onEnter} onMouseLeave={onLeave} style={{ position: \"absolute\", top: 0, right: 0, width: 7, height: \"100%\", cursor: \"col-resize\", zIndex: 1 }}></span>;\n}\nconst EDGE_LIT = { borderRight: \"1px solid var(--action-primary)\", boxShadow: \"inset -1px 0 0 var(--action-primary)\" }; // overrides the borderRight shorthand (a longhand would leave currentColor behind on leave)\nconst DENSITY = { default: { h: \"var(--bar-h)\", px: \"var(--cell-px)\" }, compact: { h: \"var(--control-h)\", px: \"8px\" } };\nexport function SmartTable({ fields = [], rows = [], title = \"Grid view\", toolbar = true, height, rowHeight, density: densityProp, defaultDensity = \"default\", onDensityChange, resizable = true, columnWidths, onColumnResize, size = \"sm\", onChange, style }) {\n const [data, setData] = React.useState(rows);\n const [densityState, setDensityState] = React.useState(defaultDensity);\n const density = DENSITY[densityProp || densityState] ? (densityProp || densityState) : \"default\";\n const setDensity = (d) => { if (!densityProp) setDensityState(d); onDensityChange && onDensityChange(d); };\n const rowH = rowHeight != null ? rowHeight : DENSITY[density].h;\n const [hover, setHover] = React.useState(-1);\n const [editing, setEditing] = React.useState(null);\n const [draft, setDraft] = React.useState(\"\");\n const setCell = (r, key, v) => { const next = data.map((row, i) => i === r ? { ...row, [key]: v } : row); setData(next); onChange && onChange(next); };\n // Column widths: local state seeded from field.width (140) unless columnWidths controls them; onColumnResize fires on every drag frame.\n const [localW, setLocalW] = React.useState({});\n const [rz, setRz] = React.useState({ hot: null, drag: null });\n const widthOf = (f) => (columnWidths || localW)[f.key] ?? f.width ?? 140;\n const startResize = (e, f) => {\n e.preventDefault(); e.stopPropagation();\n const x0 = e.clientX, w0 = widthOf(f), min = f.minWidth ?? 60;\n setRz((s) => ({ ...s, drag: f.key }));\n const move = (ev) => { const w = Math.max(min, Math.round(w0 + ev.clientX - x0)); if (!columnWidths) setLocalW((s) => ({ ...s, [f.key]: w })); if (onColumnResize) onColumnResize(f.key, w); };\n document.body.style.cursor = \"col-resize\"; document.body.style.userSelect = \"none\";\n const up = () => { setRz((s) => ({ ...s, drag: null })); window.removeEventListener(\"mousemove\", move); window.removeEventListener(\"mouseup\", up); document.body.style.cursor = \"\"; document.body.style.userSelect = \"\"; };\n window.addEventListener(\"mousemove\", move); window.addEventListener(\"mouseup\", up);\n };\n const widths = [\"40px\", ...fields.map(f => widthOf(f) + \"px\"), \"40px\"];\n const grid = { display: \"grid\", gridTemplateColumns: widths.join(\" \"), width: \"fit-content\", minWidth: \"100%\" };\n const cellBase = { display: \"flex\", alignItems: \"center\", gap: 5, padding: \"0 \" + DENSITY[density].px, borderRight: \"1px solid var(--border-subtle)\", borderBottom: \"1px solid var(--border-subtle)\", height: rowH, boxSizing: \"border-box\", overflow: \"hidden\", whiteSpace: \"nowrap\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: \"var(--text-primary)\" };\n const tierIcon = { width: \"var(--control-icon)\", height: \"var(--control-icon)\" };\n // Frozen edges — zIndex 1 (row-number column) < 2 (header row) < 3 (corner, both axes); the editing input sits inside a body cell, under them.\n const stickyTop = { position: \"sticky\", top: 0, zIndex: 2, background: \"var(--bg-inset)\" };\n const stickyLeft = { position: \"sticky\", left: 0, zIndex: 1, background: \"var(--bg-inset)\" };\n const stickyCorner = { ...stickyTop, left: 0, zIndex: 3 };\n const agg = (f) => {\n if (!f.aggregate) return \"\";\n const vals = data.map(r => r[f.key]);\n if (f.aggregate === \"count\") return \"COUNT \" + vals.filter(v => v === true || (v !== \"\" && v != null && v !== false)).length;\n const nums = vals.map(Number).filter(n => !isNaN(n));\n if (f.aggregate === \"sum\") return \"SUM \" + nums.reduce((a, b) => a + b, 0);\n if (f.aggregate === \"avg\") return \"AVG \" + (nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length * 10) / 10 : 0);\n return \"\";\n };\n const renderCell = (f, row, r) => {\n const v = row[f.key];\n const isEdit = editing && editing.r === r && editing.key === f.key;\n if (isEdit) return <input autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={() => { setCell(r, f.key, f.type === \"number\" && draft !== \"\" && !isNaN(Number(draft)) ? Number(draft) : draft); setEditing(null); }} onKeyDown={(e) => { if (e.key === \"Enter\") e.target.blur(); if (e.key === \"Escape\") setEditing(null); }} style={{ position: \"absolute\", inset: 0, border: \"2px solid var(--action-primary)\", borderRadius: 2, outline: \"none\", padding: \"0 6px\", font: \"inherit\", background: \"var(--bg-surface)\", color: \"var(--text-primary)\", boxSizing: \"border-box\" }} />;\n switch (f.type) {\n case \"select\": { const hue = (f.options || {})[v] || \"gray\"; return v ? <Pill hue={hue}>{v}</Pill> : null; }\n case \"multiSelect\": return <span style={{ display: \"flex\", gap: 4, overflow: \"hidden\" }}>{(v || []).map(t => <Pill key={t} hue={(f.options || {})[t] || \"gray\"}>{t}</Pill>)}</span>;\n case \"person\": return v ? <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6, minWidth: 0 }}><Avatar name={v} size={20} /><span style={{ overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{v}</span></span> : null;\n case \"checkbox\": return <Checkbox checked={!!v} onChange={(nv) => setCell(r, f.key, nv)} />;\n case \"url\": return v ? <a href={\"https://\" + v} onClick={(e) => e.preventDefault()} style={{ color: \"var(--link)\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{v}</a> : null;\n case \"progress\": return <span style={{ display: \"flex\", alignItems: \"center\", gap: 6, width: \"100%\" }}><span style={{ flex: 1, height: 4, borderRadius: \"var(--radius-full)\", background: \"var(--track)\", overflow: \"hidden\" }}><span style={{ display: \"block\", width: (v || 0) + \"%\", height: \"100%\", background: \"var(--chart-blue)\" }}></span></span><span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\" }}>{v || 0}%</span></span>;\n case \"number\": return <span style={{ marginLeft: \"auto\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", letterSpacing: 0, fontVariantNumeric: \"tabular-nums\" }}>{v}</span>;\n default: return <span style={{ overflow: \"hidden\", textOverflow: \"ellipsis\", fontWeight: f.primary ? \"var(--weight-medium)\" : \"var(--weight-regular)\", color: f.primary ? \"var(--text-heading)\" : \"var(--text-primary)\" }}>{v}</span>;\n }\n };\n const tbBtn = (icon, label) => <button key={label} type=\"button\" style={{ display: \"inline-flex\", alignItems: \"center\", gap: 5, height: \"var(--control-h)\", padding: \"0 var(--control-px)\", border: \"none\", borderRadius: \"var(--radius-md)\", background: \"transparent\", cursor: \"pointer\", color: \"var(--text-body)\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", letterSpacing: \"var(--control-tracking)\" }} onMouseEnter={(e) => e.currentTarget.style.background = \"var(--bg-hover)\"} onMouseLeave={(e) => e.currentTarget.style.background = \"transparent\"}><Icon name={icon} size={14} style={tierIcon} />{label}</button>;\n return (\n <div data-size={size} style={{ border: \"1px solid var(--border-control)\", borderRadius: \"var(--radius-lg)\", background: \"var(--bg-surface)\", overflow: \"hidden\", boxSizing: \"border-box\", ...style }}>\n {toolbar && <div style={{ display: \"flex\", alignItems: \"center\", gap: 4, minHeight: \"var(--bar-card-h)\", padding: \"0 var(--edge-pad)\", boxSizing: \"border-box\", borderBottom: \"1px solid var(--border-subtle)\" }}>\n <button type=\"button\" data-wash=\"strong\" style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6, height: \"var(--control-h)\", padding: \"0 var(--control-px)\", border: \"none\", borderRadius: \"var(--radius-md)\", background: \"var(--wash, var(--bg-active))\", transition: \"var(--t-hover)\", cursor: \"pointer\", color: \"var(--text-heading)\", fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--control-text)\", letterSpacing: \"var(--control-tracking)\" }}><Icon name=\"layout-grid\" size={14} style={tierIcon} />{title}<Icon name=\"chevron-down\" size={14} style={tierIcon} /></button>\n <span style={{ width: 1, height: 16, background: \"var(--border-control)\", margin: \"0 4px\" }}></span>\n {tbBtn(\"eye-off\", \"Hide fields\")}{tbBtn(\"funnel\", \"Filter\")}{tbBtn(\"layers\", \"Group\")}{tbBtn(\"arrow-down\", \"Sort\")}\n <Menu trigger={tbBtn(\"menu\", \"Row height\")} items={[{ label: \"Compact\", selected: density === \"compact\" }, { label: \"Default\", selected: density === \"default\" }]} onSelect={(l) => setDensity(l === \"Compact\" ? \"compact\" : \"default\")} />\n <span style={{ flex: 1 }}></span>\n <SearchInput bare placeholder=\"Search...\" style={{ width: 140, gap: 6 }} />\n </div>}\n <div style={{ overflow: \"auto\", maxHeight: height }}>\n <div style={grid}>\n <span style={{ ...cellBase, ...stickyCorner, justifyContent: \"center\" }}></span>\n {fields.map(f => <span key={f.key} style={{ ...cellBase, ...stickyTop, color: \"var(--text-secondary)\", fontWeight: \"var(--weight-semibold)\", ...(resizable && (rz.hot === f.key || rz.drag === f.key) ? EDGE_LIT : null) }}><Icon name={typeIcon[f.type] || \"type\"} size={14} style={{ ...tierIcon, color: \"var(--text-muted)\" }} />{f.label}{resizable && <ResizeHandle onMouseDown={(e) => startResize(e, f)} onEnter={() => setRz((s) => ({ ...s, hot: f.key }))} onLeave={() => setRz((s) => ({ ...s, hot: null }))} />}</span>)}\n <span style={{ ...cellBase, ...stickyTop, justifyContent: \"center\", padding: 0 }}><IconButton icon=\"plus\" muted label=\"Add field\" /></span>\n {data.map((row, r) => [\n <span key={\"n\" + r} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} style={{ ...cellBase, ...stickyLeft, justifyContent: \"center\", color: \"var(--text-muted)\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", letterSpacing: 0, cursor: \"pointer\" }}>{hover === r ? <Icon name=\"maximize-2\" size={14} style={{ ...tierIcon, color: \"var(--action-primary)\" }} /> : r + 1}</span>,\n ...fields.map(f => <span key={r + f.key} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} onDoubleClick={() => { if (f.type === \"text\" || f.type === \"number\" || f.type === \"date\" || f.type === \"url\") { setEditing({ r, key: f.key }); setDraft(String(row[f.key] == null ? \"\" : row[f.key])); } }} style={{ ...cellBase, position: \"relative\", background: hover === r ? \"var(--bg-inset)\" : \"transparent\" }}>{renderCell(f, row, r)}</span>),\n <span key={\"a\" + r} style={{ ...cellBase, background: hover === r ? \"var(--bg-inset)\" : \"transparent\" }}></span>,\n ])}\n <button type=\"button\" onClick={() => { const empty = {}; fields.forEach(f => empty[f.key] = f.type === \"multiSelect\" ? [] : f.type === \"checkbox\" ? false : \"\"); const next = [...data, empty]; setData(next); onChange && onChange(next); }} style={{ gridColumn: \"1 / -1\", display: \"flex\", alignItems: \"center\", gap: 6, height: rowH, padding: \"0 var(--edge-pad)\", border: \"none\", borderBottom: \"1px solid var(--border-subtle)\", background: \"transparent\", cursor: \"pointer\", color: \"var(--text-secondary)\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", letterSpacing: \"var(--control-tracking)\", textAlign: \"left\" }} onMouseEnter={(e) => e.currentTarget.style.background = \"var(--bg-inset)\"} onMouseLeave={(e) => e.currentTarget.style.background = \"transparent\"}><Icon name=\"plus\" size={13} />New</button>\n <span style={{ ...cellBase, ...stickyLeft, borderBottom: \"none\" }}></span>\n {fields.map(f => <span key={\"g\" + f.key} style={{ ...cellBase, borderBottom: \"none\", background: \"var(--bg-inset)\", justifyContent: \"flex-end\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", letterSpacing: 0, color: \"var(--text-secondary)\" }}>{agg(f)}</span>)}\n <span style={{ ...cellBase, borderBottom: \"none\", background: \"var(--bg-inset)\" }}></span>\n </div>\n </div>\n </div>\n );\n}\n","data/SmartTable.vue":"<script setup>\nimport { ref, computed, reactive } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport IconButton from '../buttons/IconButton.vue'\nimport Checkbox from '../forms/Checkbox.vue'\nimport Avatar from '../display/Avatar.vue'\nimport SearchInput from '../forms/SearchInput.vue'\nimport Menu from '../feedback/Menu.vue'\n// Notion/Airtable-style database grid: field-type header icons, hue pills, person/date/checkbox/url/progress cells, row hover + expand, inline editing, + New row, footer aggregates, view toolbar.\nconst typeIcon = { text: 'type', longText: 'align-left', number: 'hash', select: 'chevron-down', multiSelect: 'tag', date: 'calendar', person: 'user', checkbox: 'check-square', url: 'link', progress: 'bar-chart-2' }\nconst props = defineProps({ fields: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, title: { type: String, default: 'Grid view' }, toolbar: { type: Boolean, default: true }, height: [Number, String], rowHeight: [Number, String], density: String, defaultDensity: { type: String, default: 'default' }, resizable: { type: Boolean, default: true }, columnWidths: Object, size: { type: String, default: 'sm' }, style: Object })\n// A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size=\"md\" for a roomier grid.\n// density: 'default' rows are --bar-h (36 / 40 / 48) with --cell-px; 'compact' rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's \"Row height\" menu switches it (emits density-change; v-model:density to control); rowHeight (a raw height) overrides density.\n// Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline.\nconst DENSITY = { default: { h: 'var(--bar-h)', px: 'var(--cell-px)' }, compact: { h: 'var(--control-h)', px: '8px' } }\nconst tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }\n// Frozen edges — zIndex 1 (row-number column) < 2 (header row) < 3 (corner, both axes); the editing input sits inside a body cell, under them.\nconst stickyTop = { position: 'sticky', top: 0, zIndex: 2, background: 'var(--bg-inset)' }\nconst stickyLeft = { position: 'sticky', left: 0, zIndex: 1, background: 'var(--bg-inset)' }\nconst stickyCorner = { ...stickyTop, left: 0, zIndex: 3 }\nconst emit = defineEmits(['change', 'update:density', 'densityChange', 'columnResize'])\nconst densityState = ref(props.defaultDensity)\nconst density = computed(() => DENSITY[props.density || densityState.value] ? (props.density || densityState.value) : 'default')\nfunction setDensity(d) { if (!props.density) densityState.value = d; emit('update:density', d); emit('densityChange', d) }\nconst rowH = computed(() => props.rowHeight != null ? (typeof props.rowHeight === 'number' ? props.rowHeight + 'px' : props.rowHeight) : DENSITY[density.value].h)\nconst cellPx = computed(() => DENSITY[density.value].px)\nconst data = ref(props.rows.map(r => ({ ...r })))\nconst hover = ref(-1), editing = ref(null), draft = ref('')\n// resizable (default true): drag a field header's right edge to set its width (min field.minWidth ?? 60). Local unless columnWidths controls it; @column-resize(key, px) on every drag frame.\nconst localW = reactive({}), rz = reactive({ hot: null, drag: null })\nconst edgeLit = { borderRight: '1px solid var(--action-primary)', boxShadow: 'inset -1px 0 0 var(--action-primary)' } // hover / drag: the header's own right grid line turns 2px --action-primary, on the column boundary\nconst widthOf = (f) => (props.columnWidths || localW)[f.key] ?? f.width ?? 140\nfunction startResize(e, f) {\n e.preventDefault(); e.stopPropagation()\n const x0 = e.clientX, w0 = widthOf(f), min = f.minWidth ?? 60\n rz.drag = f.key\n const move = (ev) => { const w = Math.max(min, Math.round(w0 + ev.clientX - x0)); if (!props.columnWidths) localW[f.key] = w; emit('columnResize', f.key, w) }\n const up = () => { rz.drag = null; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); document.body.style.cursor = ''; document.body.style.userSelect = '' }\n document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'\n window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)\n}\nconst gridCols = computed(() => ['40px', ...props.fields.map(f => widthOf(f) + 'px'), '40px'].join(' '))\nconst cellBase = computed(() => ({ display: 'flex', alignItems: 'center', gap: '5px', padding: '0 ' + cellPx.value, borderRight: '1px solid var(--border-subtle)', borderBottom: '1px solid var(--border-subtle)', boxSizing: 'border-box', overflow: 'hidden', whiteSpace: 'nowrap', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: 'var(--text-primary)' }))\nfunction setCell(r, key, v) { data.value[r][key] = v; emit('change', data.value) }\nfunction commitEdit(r, f) { setCell(r, f.key, f.type === 'number' && draft.value !== '' && !isNaN(Number(draft.value)) ? Number(draft.value) : draft.value); editing.value = null }\nfunction startEdit(r, f, row) { if (['text', 'number', 'date', 'url'].includes(f.type || 'text')) { editing.value = { r, key: f.key }; draft.value = String(row[f.key] == null ? '' : row[f.key]) } }\nfunction addRow() { const empty = {}; props.fields.forEach(f => empty[f.key] = f.type === 'multiSelect' ? [] : f.type === 'checkbox' ? false : ''); data.value.push(empty); emit('change', data.value) }\nfunction agg(f) {\n if (!f.aggregate) return ''\n const vals = data.value.map(r => r[f.key])\n if (f.aggregate === 'count') return 'COUNT ' + vals.filter(v => v === true || (v !== '' && v != null && v !== false)).length\n const nums = vals.map(Number).filter(n => !isNaN(n))\n if (f.aggregate === 'sum') return 'SUM ' + nums.reduce((a, b) => a + b, 0)\n return 'AVG ' + (nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length * 10) / 10 : 0)\n}\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ border: '1px solid var(--border-control)', borderRadius: 'var(--radius-lg)', background: 'var(--bg-surface)', overflow: 'hidden', boxSizing: 'border-box', ...style }\">\n <div v-if=\"toolbar\" style=\"display:flex;align-items:center;gap:4px;min-height:var(--bar-card-h);padding:0 var(--edge-pad);box-sizing:border-box;border-bottom:1px solid var(--border-subtle)\">\n <button type=\"button\" data-wash=\"strong\" style=\"display:inline-flex;align-items:center;gap:6px;height:var(--control-h);padding:0 var(--control-px);border:none;border-radius:var(--radius-md);background:var(--wash, var(--bg-active));transition:var(--t-hover);cursor:pointer;color:var(--text-heading);font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--control-text);letter-spacing:var(--control-tracking)\"><Icon name=\"layout-grid\" :size=\"14\" :style=\"tierIcon\" />{{ title }}<Icon name=\"chevron-down\" :size=\"14\" :style=\"tierIcon\" /></button>\n <span style=\"width:1px;height:16px;background:var(--border-control);margin:0 4px\" />\n <button v-for=\"b in [['eye-off','Hide fields'],['funnel','Filter'],['layers','Group'],['arrow-down','Sort']]\" :key=\"b[1]\" type=\"button\" class=\"kb-st-btn\"><Icon :name=\"b[0]\" :size=\"14\" :style=\"tierIcon\" />{{ b[1] }}</button>\n <Menu :items=\"[{ label: 'Compact', selected: density === 'compact' }, { label: 'Default', selected: density === 'default' }]\" @select=\"l => setDensity(l === 'Compact' ? 'compact' : 'default')\"><template #trigger><button type=\"button\" class=\"kb-st-btn\"><Icon name=\"menu\" :size=\"14\" :style=\"tierIcon\" />Row height</button></template></Menu>\n <span style=\"flex:1\" />\n <SearchInput bare placeholder=\"Search...\" style=\"width:140px;gap:6px\" />\n </div>\n <div :style=\"{ overflow: 'auto', maxHeight: typeof height === 'number' ? height + 'px' : height }\">\n <div :style=\"{ display: 'grid', gridTemplateColumns: gridCols, width: 'fit-content', minWidth: '100%' }\">\n <span :style=\"{ ...cellBase, height: rowH, ...stickyCorner }\" />\n <span v-for=\"f in fields\" :key=\"f.key\" :style=\"{ ...cellBase, height: rowH, ...stickyTop, color: 'var(--text-secondary)', fontWeight: 'var(--weight-semibold)', ...((resizable && (rz.hot === f.key || rz.drag === f.key)) ? edgeLit : null) }\"><Icon :name=\"typeIcon[f.type] || 'type'\" :size=\"14\" :style=\"{ ...tierIcon, color: 'var(--text-muted)' }\" />{{ f.label }}<span v-if=\"resizable\" aria-hidden=\"true\" @mousedown=\"e => startResize(e, f)\" @mouseenter=\"rz.hot = f.key\" @mouseleave=\"rz.hot = null\" style=\"position:absolute;top:0;right:0;width:7px;height:100%;cursor:col-resize;z-index:1\" /></span>\n <span :style=\"{ ...cellBase, height: rowH, ...stickyTop, justifyContent: 'center', padding: 0 }\"><IconButton icon=\"plus\" muted label=\"Add field\" /></span>\n <template v-for=\"(row, r) in data\" :key=\"r\">\n <span @mouseenter=\"hover = r\" @mouseleave=\"hover = -1\" :style=\"{ ...cellBase, height: rowH, ...stickyLeft, justifyContent: 'center', color: 'var(--text-muted)', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, cursor: 'pointer' }\">\n <Icon v-if=\"hover === r\" name=\"maximize-2\" :size=\"14\" :style=\"{ ...tierIcon, color: 'var(--action-primary)' }\" /><template v-else>{{ r + 1 }}</template>\n </span>\n <span v-for=\"f in fields\" :key=\"r + f.key\" @mouseenter=\"hover = r\" @mouseleave=\"hover = -1\" @dblclick=\"startEdit(r, f, row)\" :style=\"{ ...cellBase, height: rowH, position: 'relative', background: hover === r ? 'var(--bg-inset)' : 'transparent' }\">\n <input v-if=\"editing && editing.r === r && editing.key === f.key\" autofocus :value=\"draft\" @input=\"draft = $event.target.value\" @blur=\"commitEdit(r, f)\" @keydown.enter=\"$event.target.blur()\" @keydown.esc=\"editing = null\" style=\"position:absolute;inset:0;border:2px solid var(--action-primary);border-radius:2px;outline:none;padding:0 6px;font:inherit;background:var(--bg-surface);color:var(--text-primary);box-sizing:border-box\" />\n <template v-else-if=\"f.type === 'select'\"><span v-if=\"row[f.key]\" class=\"kb-pill\" :style=\"{ background: `var(--hue-${(f.options || {})[row[f.key]] || 'gray'}-bg)`, color: `var(--hue-${(f.options || {})[row[f.key]] || 'gray'}-fg)` }\">{{ row[f.key] }}</span></template>\n <template v-else-if=\"f.type === 'multiSelect'\"><span style=\"display:flex;gap:4px;overflow:hidden\"><span v-for=\"t in row[f.key] || []\" :key=\"t\" class=\"kb-pill\" :style=\"{ background: `var(--hue-${(f.options || {})[t] || 'gray'}-bg)`, color: `var(--hue-${(f.options || {})[t] || 'gray'}-fg)` }\">{{ t }}</span></span></template>\n <template v-else-if=\"f.type === 'person'\"><span v-if=\"row[f.key]\" style=\"display:inline-flex;align-items:center;gap:6px;min-width:0\"><Avatar :name=\"row[f.key]\" :size=\"20\" /><span style=\"overflow:hidden;text-overflow:ellipsis\">{{ row[f.key] }}</span></span></template>\n <template v-else-if=\"f.type === 'checkbox'\"><Checkbox :checked=\"!!row[f.key]\" @change=\"v => setCell(r, f.key, v)\" /></template>\n <template v-else-if=\"f.type === 'url'\"><a v-if=\"row[f.key]\" href=\"#\" @click.prevent style=\"color:var(--link);overflow:hidden;text-overflow:ellipsis\">{{ row[f.key] }}</a></template>\n <template v-else-if=\"f.type === 'progress'\"><span style=\"display:flex;align-items:center;gap:6px;width:100%\"><span style=\"flex:1;height:4px;border-radius:var(--radius-full);background:var(--track);overflow:hidden\"><span :style=\"{ display: 'block', width: (row[f.key] || 0) + '%', height: '100%', background: 'var(--chart-blue)' }\" /></span><span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-secondary)\">{{ row[f.key] || 0 }}%</span></span></template>\n <template v-else-if=\"f.type === 'number'\"><span style=\"margin-left:auto;font-family:var(--font-sans);font-size:var(--control-text);letter-spacing:0;font-variant-numeric:tabular-nums\">{{ row[f.key] }}</span></template>\n <template v-else><span :style=\"{ overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: f.primary ? 'var(--weight-medium)' : 'var(--weight-regular)', color: f.primary ? 'var(--text-heading)' : 'var(--text-primary)' }\">{{ row[f.key] }}</span></template>\n </span>\n <span :style=\"{ ...cellBase, height: rowH, background: hover === r ? 'var(--bg-inset)' : 'transparent' }\" />\n </template>\n <button type=\"button\" @click=\"addRow\" class=\"kb-st-new\" :style=\"{ gridColumn: '1 / -1', height: rowH }\"><Icon name=\"plus\" :size=\"14\" :style=\"tierIcon\" />New</button>\n <span :style=\"{ ...cellBase, height: rowH, ...stickyLeft, borderBottom: 'none' }\" />\n <span v-for=\"f in fields\" :key=\"'g' + f.key\" :style=\"{ ...cellBase, height: rowH, borderBottom: 'none', background: 'var(--bg-inset)', justifyContent: 'flex-end', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, color: 'var(--text-secondary)' }\">{{ agg(f) }}</span>\n <span :style=\"{ ...cellBase, height: rowH, borderBottom: 'none', background: 'var(--bg-inset)' }\" />\n </div>\n </div>\n </div>\n</template>\n<style scoped>\n.kb-pill{display:inline-flex;align-items:center;height:20px;padding:0 8px;border-radius:var(--radius-full);font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-xs);line-height:1;white-space:nowrap}\n.kb-st-btn{display:inline-flex;align-items:center;gap:5px;height:var(--control-h);padding:0 var(--control-px);border:none;border-radius:var(--radius-md);background:transparent;cursor:pointer;color:var(--text-body);font-family:var(--font-sans);font-size:var(--control-text);letter-spacing:var(--control-tracking)}\n.kb-st-btn:hover{background:var(--bg-hover)}\n.kb-st-new{display:flex;align-items:center;gap:6px;padding:0 var(--edge-pad);border:none;border-bottom:1px solid var(--border-subtle);background:transparent;cursor:pointer;color:var(--text-secondary);font-family:var(--font-sans);font-size:var(--control-text);letter-spacing:var(--control-tracking);text-align:left}\n.kb-st-new:hover{background:var(--bg-inset)}\n</style>\n","data/Spreadsheet.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { IconButton } from \"../buttons/IconButton.jsx\";\nimport { Select } from \"../forms/Select.jsx\";\n\nexport const colName = (i) => { let s = \"\"; i++; while (i > 0) { s = String.fromCharCode(64 + ((i - 1) % 26 + 1)) + s; i = Math.floor((i - 1) / 26); } return s; };\nconst norm = (cell) => cell == null ? { v: \"\" } : (typeof cell === \"object\" ? { ...cell } : { v: cell });\nconst refRC = (ref) => { const m = /^([A-Z]+)(\\d+)$/.exec(String(ref).trim().toUpperCase()); if (!m) return null; let c = 0; for (const ch of m[1]) c = c * 26 + (ch.charCodeAt(0) - 64); return { r: +m[2] - 1, c: c - 1 }; };\n\n/* Column resize handle — a 7px hit strip inside the header cell's right edge (cursor col-resize). Hover or drag turns the cell's own right\n grid line into a 2px --action-primary line (EDGE_LIT: the border plus a 1px inset shadow), so the line sits ON the column boundary. */\nfunction ResizeHandle({ onMouseDown, onEnter, onLeave }) {\n return <span aria-hidden=\"true\" onMouseDown={onMouseDown} onMouseEnter={onEnter} onMouseLeave={onLeave} style={{ position: \"absolute\", top: 0, right: 0, width: 7, height: \"100%\", cursor: \"col-resize\", zIndex: 1 }}></span>;\n}\nconst EDGE_LIT = { borderRight: \"1px solid var(--action-primary)\", boxShadow: \"inset -1px 0 0 var(--action-primary)\" }; // overrides the borderRight shorthand (a longhand would leave currentColor behind on leave)\nconst MIN_COL = 32;\n\nfunction evalCell(cells, cell, depth = 0) {\n if (!cell || depth > 12) return \"\";\n const raw = cell.f != null ? cell.f : cell.v;\n if (typeof raw === \"string\" && raw.startsWith(\"=\")) return evalFormula(cells, raw, depth);\n return raw;\n}\nfunction numAt(cells, r, c, depth) { const v = evalCell(cells, cells[r] && cells[r][c], depth + 1); const n = typeof v === \"number\" ? v : parseFloat(String(v).replace(/[^0-9.eE+-]/g, \"\")); return isNaN(n) ? null : n; }\nfunction evalFormula(cells, f, depth = 0) {\n try {\n let expr = f.slice(1).toUpperCase().replace(/\\s+/g, \"\");\n expr = expr.replace(/(SUM|AVERAGE|AVG|COUNT|MIN|MAX)\\(([A-Z]+\\d+):([A-Z]+\\d+)\\)/g, (m, fn, a, b) => {\n const A = refRC(a), B = refRC(b), vals = [];\n for (let r = Math.min(A.r, B.r); r <= Math.max(A.r, B.r); r++) for (let c = Math.min(A.c, B.c); c <= Math.max(A.c, B.c); c++) { const n = numAt(cells, r, c, depth); if (n != null) vals.push(n); }\n if (fn === \"COUNT\") return String(vals.length);\n if (!vals.length) return \"0\";\n if (fn === \"SUM\") return String(vals.reduce((x, y) => x + y, 0));\n if (fn === \"MIN\") return String(Math.min(...vals));\n if (fn === \"MAX\") return String(Math.max(...vals));\n return String(vals.reduce((x, y) => x + y, 0) / vals.length);\n });\n expr = expr.replace(/[A-Z]+\\d+/g, (ref) => { const p = refRC(ref); const n = p ? numAt(cells, p.r, p.c, depth) : null; return String(n == null ? 0 : n); });\n if (!/^[-+*/().\\d\\seE]*$/.test(expr)) return \"#ERROR\";\n const out = Function('\"use strict\";return (' + expr + \")\")();\n return typeof out === \"number\" && isFinite(out) ? out : \"#ERROR\";\n } catch (e) { return \"#ERROR\"; }\n}\nfunction fmt(val, t, currency) {\n if (val === \"\" || val == null) return \"\";\n if (typeof val !== \"number\") return String(val);\n const r2 = Math.round(val * 100) / 100;\n if (t === \"percent\") return (Math.round(val * 1000) / 10) + \"%\";\n if (t === \"currency\") return currency + r2.toLocaleString();\n return r2.toLocaleString();\n}\n\n/** Excel-style spreadsheet: toolbar, formula bar (name box + fx), lettered/numbered frozen headers, click + shift-click range selection, double-click editing, =SUM()/AVERAGE()-style formulas, sheet tabs and a Sum/Avg/Count status bar. */\n/* A size region that defaults to sm: a spreadsheet is the densest surface in the system — 24px toolbar controls, 12px text, 28px cells. */\nexport function Spreadsheet({ data = [], rows, cols, colWidths = [], defaultColWidth = 92, currency = \"$\", defaultActive = \"A1\", sheets = [\"Sheet1\"], activeSheet, toolbar = true, formulaBar = true, statusBar = true, height, size = \"sm\", resizable = true, onColumnResize, onChange, style }) {\n const nR = Math.max(rows || 0, data.length, 8), nC = Math.max(cols || 0, ...data.map(r => r.length), 6);\n const [cells, setCells] = React.useState(() => Array.from({ length: nR }, (_, r) => Array.from({ length: nC }, (_, c) => norm(data[r] && data[r][c]))));\n const a0 = refRC(defaultActive) || { r: 0, c: 0 };\n const [active, setActive] = React.useState(a0);\n const [anchor, setAnchor] = React.useState(a0);\n const [editing, setEditing] = React.useState(null);\n const [draft, setDraft] = React.useState(\"\");\n const [sheet, setSheet] = React.useState(activeSheet || sheets[0]);\n const cur = cells[active.r][active.c];\n const rawOf = (cell) => cell.f != null ? cell.f : (cell.v == null ? \"\" : String(cell.v));\n const commit = (r, c, text) => { const next = cells.map(row => row.map(cell => ({ ...cell }))); const cell = next[r][c]; delete cell.f; if (String(text).startsWith(\"=\")) { cell.f = text; cell.v = \"\"; } else { const n = text !== \"\" && !isNaN(Number(text)) ? Number(text) : text; cell.v = n; } setCells(next); onChange && onChange(next); };\n const lo = { r: Math.min(active.r, anchor.r), c: Math.min(active.c, anchor.c) }, hi = { r: Math.max(active.r, anchor.r), c: Math.max(active.c, anchor.c) };\n const multi = lo.r !== hi.r || lo.c !== hi.c;\n const inRange = (r, c) => r >= lo.r && r <= hi.r && c >= lo.c && c <= hi.c;\n const stats = React.useMemo(() => { if (!multi) return null; const vals = []; for (let r = lo.r; r <= hi.r; r++) for (let c = lo.c; c <= hi.c; c++) { const v = evalCell(cells, cells[r][c]); const n = typeof v === \"number\" ? v : NaN; if (!isNaN(n)) vals.push(n); } if (!vals.length) return null; const sum = vals.reduce((x, y) => x + y, 0); return { sum: Math.round(sum * 100) / 100, avg: Math.round(sum / vals.length * 100) / 100, count: vals.length }; }, [cells, active, anchor]);\n const pick = (r, c, e) => { setActive({ r, c }); if (!(e && e.shiftKey)) setAnchor({ r, c }); setEditing(null); };\n const setFlag = (k) => { const next = cells.map(row => row.map(cell => ({ ...cell }))); for (let r = lo.r; r <= hi.r; r++) for (let c = lo.c; c <= hi.c; c++) next[r][c][k] = !cur[k]; setCells(next); };\n const setType = (label) => { const t = { Automatic: undefined, Number: \"number\", Currency: \"currency\", Percent: \"percent\" }[label]; const next = cells.map(row => row.map(cell => ({ ...cell }))); for (let r = lo.r; r <= hi.r; r++) for (let c = lo.c; c <= hi.c; c++) { if (t) next[r][c].t = t; else delete next[r][c].t; } setCells(next); };\n // Column widths start from colWidths (defaultColWidth 92); resizable (default true) lets the user drag a column letter's right edge (min 32). onColumnResize(col, px) on every drag frame.\n const [cw, setCw] = React.useState(() => Array.from({ length: nC }, (_, c) => colWidths[c] || defaultColWidth));\n const [rz, setRz] = React.useState({ hot: null, drag: null });\n const startResize = (e, c) => {\n e.preventDefault(); e.stopPropagation();\n const x0 = e.clientX, w0 = cw[c];\n setRz((s) => ({ ...s, drag: c }));\n const move = (ev) => { const w = Math.max(MIN_COL, Math.round(w0 + ev.clientX - x0)); setCw((s) => { const n = s.slice(); n[c] = w; return n; }); if (onColumnResize) onColumnResize(c, w); };\n document.body.style.cursor = \"col-resize\"; document.body.style.userSelect = \"none\";\n const up = () => { setRz((s) => ({ ...s, drag: null })); window.removeEventListener(\"mousemove\", move); window.removeEventListener(\"mouseup\", up); document.body.style.cursor = \"\"; document.body.style.userSelect = \"\"; };\n window.addEventListener(\"mousemove\", move); window.addEventListener(\"mouseup\", up);\n };\n const widths = [46, ...cw];\n const hdr = { position: \"sticky\", top: 0, zIndex: 3, background: \"var(--bg-inset)\", borderRight: \"1px solid var(--border-control)\", borderBottom: \"1px solid var(--border-control)\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--text-xs)\", letterSpacing: \"var(--tracking-xs)\", color: \"var(--text-secondary)\", height: 26, boxSizing: \"border-box\", userSelect: \"none\" };\n const tbBtn = (label, k, st) => <button key={label} type=\"button\" data-wash={cur[k] ? undefined : \"\"} onClick={() => setFlag(k)} style={{ width: \"var(--control-h)\", height: \"var(--control-h)\", border: \"none\", borderRadius: \"var(--radius-md)\", cursor: \"pointer\", background: cur[k] ? \"var(--bg-active)\" : \"var(--wash, transparent)\", color: \"var(--text-body)\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", ...st }}>{label}</button>;\n return (\n <div data-size={size} style={{ border: \"1px solid var(--border-control)\", borderRadius: \"var(--radius-lg)\", overflow: \"hidden\", background: \"var(--bg-surface)\", boxSizing: \"border-box\", ...style }}>\n {toolbar && <div style={{ height: \"var(--bar-card-h)\", display: \"flex\", alignItems: \"center\", gap: 2, padding: \"0 var(--edge-pad)\", boxSizing: \"border-box\", borderBottom: \"1px solid var(--border-subtle)\" }}>\n <IconButton icon=\"undo-2\" muted label=\"Undo\" /><IconButton icon=\"redo-2\" muted label=\"Redo\" />\n <span style={{ width: 1, height: 16, background: \"var(--border-control)\", margin: \"0 6px\" }}></span>\n {tbBtn(\"B\", \"b\", { fontWeight: 700 })}{tbBtn(\"I\", \"i\", { fontStyle: \"italic\" })}{tbBtn(\"U\", \"u\", { textDecoration: \"underline\" })}\n <span style={{ width: 1, height: 16, background: \"var(--border-control)\", margin: \"0 6px\" }}></span>\n <Select options={[\"Automatic\", \"Number\", \"Currency\", \"Percent\"]} value={{ number: \"Number\", currency: \"Currency\", percent: \"Percent\" }[cur.t] || \"Automatic\"} onChange={setType} style={{ width: 110 }} />\n </div>}\n {formulaBar && <div style={{ height: \"var(--bar-h)\", display: \"flex\", alignItems: \"center\", borderBottom: \"1px solid var(--border-control)\" }}>\n <span style={{ width: 72, alignSelf: \"stretch\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", borderRight: \"1px solid var(--border-control)\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-heading)\" }}>{multi ? colName(lo.c) + (lo.r + 1) + \":\" + colName(hi.c) + (hi.r + 1) : colName(active.c) + (active.r + 1)}</span>\n <span style={{ padding: \"0 10px\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", fontStyle: \"italic\", color: \"var(--text-muted)\", borderRight: \"1px solid var(--border-subtle)\", alignSelf: \"stretch\", display: \"flex\", alignItems: \"center\" }}>fx</span>\n <input value={editing === \"fx\" ? draft : rawOf(cur)} onFocus={() => { setEditing(\"fx\"); setDraft(rawOf(cur)); }} onChange={(e) => setDraft(e.target.value)} onBlur={() => { if (editing === \"fx\") { commit(active.r, active.c, draft); setEditing(null); } }} onKeyDown={(e) => { if (e.key === \"Enter\") e.target.blur(); if (e.key === \"Escape\") { setEditing(null); e.target.blur(); } }} style={{ flex: 1, border: \"none\", outline: \"none\", background: \"transparent\", padding: \"0 10px\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-primary)\" }} />\n </div>}\n <div style={{ overflow: \"auto\", maxHeight: height }}>\n <div style={{ display: \"grid\", gridTemplateColumns: widths.map(w => w + \"px\").join(\" \"), width: \"fit-content\" }}>\n <span style={{ ...hdr, zIndex: 5, left: 0 }}><span style={{ width: 0, height: 0, borderStyle: \"solid\", borderWidth: \"0 0 10px 10px\", borderColor: \"transparent transparent var(--border-control) transparent\", alignSelf: \"flex-end\", marginLeft: \"auto\", marginRight: 3, marginBottom: 3 }}></span></span>\n {Array.from({ length: nC }, (_, c) => <span key={c} style={{ ...hdr, ...(c >= lo.c && c <= hi.c ? { background: \"var(--soft-bg)\", color: \"var(--action-primary)\" } : null), ...(resizable && (rz.hot === c || rz.drag === c) ? EDGE_LIT : null) }}>{colName(c)}{resizable && <ResizeHandle onMouseDown={(e) => startResize(e, c)} onEnter={() => setRz((s) => ({ ...s, hot: c }))} onLeave={() => setRz((s) => ({ ...s, hot: null }))} />}</span>)}\n {cells.map((row, r) => [\n <span key={\"h\" + r} style={{ ...hdr, position: \"sticky\", left: 0, top: \"auto\", zIndex: 2, height: 28, ...(r >= lo.r && r <= hi.r ? { background: \"var(--soft-bg)\", color: \"var(--action-primary)\" } : null) }}>{r + 1}</span>,\n ...row.map((cell, c) => {\n const v = evalCell(cells, cell);\n const isNum = typeof v === \"number\";\n const isActive = active.r === r && active.c === c;\n const isEdit = editing && editing !== \"fx\" && editing.r === r && editing.c === c;\n return <span key={r + \",\" + c} onMouseDown={(e) => pick(r, c, e)} onDoubleClick={() => { setEditing({ r, c }); setDraft(rawOf(cell)); }}\n style={{ position: \"relative\", height: 28, boxSizing: \"border-box\", borderRight: \"1px solid var(--border-subtle)\", borderBottom: \"1px solid var(--border-subtle)\", display: \"flex\", alignItems: \"center\", justifyContent: cell.align || (isNum ? \"flex-end\" : \"flex-start\"), padding: \"0 8px\", cursor: \"cell\", background: isActive ? \"var(--bg-surface)\" : inRange(r, c) ? \"var(--soft-bg)\" : \"var(--bg-surface)\", boxShadow: isActive ? \"inset 0 0 0 2px var(--action-primary)\" : \"none\", zIndex: isActive ? 1 : 0, fontFamily: \"var(--font-sans)\", fontVariantNumeric: isNum ? \"tabular-nums\" : \"normal\", fontSize: \"var(--control-text)\", fontVariantNumeric: \"tabular-nums\", color: v === \"#ERROR\" ? \"var(--danger)\" : \"var(--text-primary)\", fontWeight: cell.b ? 600 : \"var(--weight-regular)\", fontStyle: cell.i ? \"italic\" : \"normal\", textDecoration: cell.u ? \"underline\" : \"none\", whiteSpace: \"nowrap\", overflow: \"hidden\", userSelect: \"none\" }}>\n {isEdit ? <input autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={() => { commit(r, c, draft); setEditing(null); }} onKeyDown={(e) => { if (e.key === \"Enter\") e.target.blur(); if (e.key === \"Escape\") setEditing(null); }} style={{ position: \"absolute\", inset: 0, border: \"none\", outline: \"none\", padding: \"0 8px\", fontFamily: \"inherit\", fontSize: \"inherit\", background: \"var(--bg-surface)\", color: \"var(--text-primary)\" }} /> : fmt(v, cell.t, currency)}\n {isActive && !isEdit && <span style={{ position: \"absolute\", right: -3, bottom: -3, width: 6, height: 6, background: \"var(--action-primary)\", border: \"1px solid var(--bg-surface)\", cursor: \"crosshair\", zIndex: 2 }}></span>}\n </span>;\n })])}\n </div>\n </div>\n {statusBar && <div style={{ height: \"var(--bar-h)\", display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", borderTop: \"1px solid var(--border-control)\", background: \"var(--bg-inset)\", paddingRight: 12 }}>\n <div style={{ display: \"flex\", alignSelf: \"stretch\", alignItems: \"stretch\" }}>\n {sheets.map(s => <button key={s} type=\"button\" data-ink={sheet === s ? undefined : \"\"} onClick={() => setSheet(s)} style={{ border: \"none\", borderRight: \"1px solid var(--border-subtle)\", padding: \"0 16px\", cursor: \"pointer\", background: sheet === s ? \"var(--bg-surface)\" : \"transparent\", color: sheet === s ? \"var(--text-heading)\" : \"var(--ink, var(--text-secondary))\", fontFamily: \"var(--font-sans)\", fontWeight: sheet === s ? \"var(--weight-medium)\" : \"var(--weight-regular)\", fontSize: \"var(--control-text)\", boxShadow: sheet === s ? \"inset 0 2px 0 var(--action-primary)\" : \"none\" }}>{s}</button>)}\n <span style={{ display: \"flex\", alignItems: \"center\", padding: \"0 6px\" }}><IconButton icon=\"plus\" muted label=\"Add sheet\" /></span>\n </div>\n <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\" }}>{stats ? \"Sum \" + fmt(stats.sum, cur.t, currency) + \" Avg \" + fmt(stats.avg, cur.t, currency) + \" Count \" + stats.count : \"100%\"}</span>\n </div>}\n </div>\n );\n}\n","data/Spreadsheet.vue":"<script setup>\nimport { ref, computed, reactive } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport IconButton from '../buttons/IconButton.vue'\nimport KSelect from '../forms/Select.vue'\n// Excel-style spreadsheet: toolbar, formula bar (name box + fx), frozen lettered/numbered headers, click + shift-click range selection, double-click editing, =SUM()/AVERAGE() formulas, sheet tabs, Sum/Avg/Count status bar.\nconst props = defineProps({ data: { type: Array, default: () => [] }, rows: Number, cols: Number, colWidths: { type: Array, default: () => [] }, defaultColWidth: { type: Number, default: 92 }, currency: { type: String, default: '$' }, defaultActive: { type: String, default: 'A1' }, sheets: { type: Array, default: () => ['Sheet1'] }, toolbar: { type: Boolean, default: true }, formulaBar: { type: Boolean, default: true }, statusBar: { type: Boolean, default: true }, height: [Number, String], size: { type: String, default: 'sm' }, resizable: { type: Boolean, default: true }, style: Object })\n// A size region that defaults to sm: a spreadsheet is the densest surface in the system — 24px toolbar controls, 12px text, 28px cells.\nconst emit = defineEmits(['change', 'columnResize'])\n\nconst colName = (i) => { let s = ''; i++; while (i > 0) { s = String.fromCharCode(64 + ((i - 1) % 26 + 1)) + s; i = Math.floor((i - 1) / 26) } return s }\nconst refRC = (ref) => { const m = /^([A-Z]+)(\\d+)$/.exec(String(ref).trim().toUpperCase()); if (!m) return null; let c = 0; for (const ch of m[1]) c = c * 26 + (ch.charCodeAt(0) - 64); return { r: +m[2] - 1, c: c - 1 } }\nconst norm = (cell) => cell == null ? { v: '' } : (typeof cell === 'object' ? { ...cell } : { v: cell })\nfunction evalCell(cells, cell, depth = 0) { if (!cell || depth > 12) return ''; const raw = cell.f != null ? cell.f : cell.v; if (typeof raw === 'string' && raw.startsWith('=')) return evalFormula(cells, raw, depth); return raw }\nfunction numAt(cells, r, c, depth) { const v = evalCell(cells, cells[r] && cells[r][c], depth + 1); const n = typeof v === 'number' ? v : parseFloat(String(v).replace(/[^0-9.eE+-]/g, '')); return isNaN(n) ? null : n }\nfunction evalFormula(cells, f, depth = 0) {\n try {\n let expr = f.slice(1).toUpperCase().replace(/\\s+/g, '')\n expr = expr.replace(/(SUM|AVERAGE|AVG|COUNT|MIN|MAX)\\(([A-Z]+\\d+):([A-Z]+\\d+)\\)/g, (m, fn, a, b) => {\n const A = refRC(a), B = refRC(b), vals = []\n for (let r = Math.min(A.r, B.r); r <= Math.max(A.r, B.r); r++) for (let c = Math.min(A.c, B.c); c <= Math.max(A.c, B.c); c++) { const n = numAt(cells, r, c, depth); if (n != null) vals.push(n) }\n if (fn === 'COUNT') return String(vals.length)\n if (!vals.length) return '0'\n if (fn === 'SUM') return String(vals.reduce((x, y) => x + y, 0))\n if (fn === 'MIN') return String(Math.min(...vals))\n if (fn === 'MAX') return String(Math.max(...vals))\n return String(vals.reduce((x, y) => x + y, 0) / vals.length)\n })\n expr = expr.replace(/[A-Z]+\\d+/g, (ref) => { const p = refRC(ref); const n = p ? numAt(cells, p.r, p.c, depth) : null; return String(n == null ? 0 : n) })\n if (!/^[-+*/().\\d\\seE]*$/.test(expr)) return '#ERROR'\n const out = Function('\"use strict\";return (' + expr + ')')()\n return typeof out === 'number' && isFinite(out) ? out : '#ERROR'\n } catch (e) { return '#ERROR' }\n}\nfunction fmt(val, t, currency) { if (val === '' || val == null) return ''; if (typeof val !== 'number') return String(val); const r2 = Math.round(val * 100) / 100; if (t === 'percent') return (Math.round(val * 1000) / 10) + '%'; if (t === 'currency') return currency + r2.toLocaleString(); return r2.toLocaleString() }\n\nconst nR = Math.max(props.rows || 0, props.data.length, 8)\nconst nC = Math.max(props.cols || 0, ...props.data.map(r => r.length), 6)\nconst cells = ref(Array.from({ length: nR }, (_, r) => Array.from({ length: nC }, (_, c) => norm(props.data[r] && props.data[r][c]))))\nconst a0 = refRC(props.defaultActive) || { r: 0, c: 0 }\nconst active = ref(a0), anchor = ref(a0), editing = ref(null), draft = ref(''), sheet = ref(props.sheets[0])\nconst cur = computed(() => cells.value[active.value.r][active.value.c])\nconst rawOf = (cell) => cell.f != null ? cell.f : (cell.v == null ? '' : String(cell.v))\nconst lo = computed(() => ({ r: Math.min(active.value.r, anchor.value.r), c: Math.min(active.value.c, anchor.value.c) }))\nconst hi = computed(() => ({ r: Math.max(active.value.r, anchor.value.r), c: Math.max(active.value.c, anchor.value.c) }))\nconst multi = computed(() => lo.value.r !== hi.value.r || lo.value.c !== hi.value.c)\nconst inRange = (r, c) => r >= lo.value.r && r <= hi.value.r && c >= lo.value.c && c <= hi.value.c\nconst stats = computed(() => { if (!multi.value) return null; const vals = []; for (let r = lo.value.r; r <= hi.value.r; r++) for (let c = lo.value.c; c <= hi.value.c; c++) { const v = evalCell(cells.value, cells.value[r][c]); if (typeof v === 'number') vals.push(v) } if (!vals.length) return null; const sum = vals.reduce((x, y) => x + y, 0); return { sum: Math.round(sum * 100) / 100, avg: Math.round(sum / vals.length * 100) / 100, count: vals.length } })\nfunction pick(r, c, e) { active.value = { r, c }; if (!(e && e.shiftKey)) anchor.value = { r, c }; editing.value = null }\nfunction commit(r, c, text) { const cell = cells.value[r][c]; delete cell.f; if (String(text).startsWith('=')) { cell.f = text; cell.v = '' } else cell.v = text !== '' && !isNaN(Number(text)) ? Number(text) : text; emit('change', cells.value) }\nfunction setFlag(k) { const on = !cur.value[k]; for (let r = lo.value.r; r <= hi.value.r; r++) for (let c = lo.value.c; c <= hi.value.c; c++) cells.value[r][c][k] = on }\nfunction setType(label) { const t = { Automatic: undefined, Number: 'number', Currency: 'currency', Percent: 'percent' }[label]; for (let r = lo.value.r; r <= hi.value.r; r++) for (let c = lo.value.c; c <= hi.value.c; c++) { if (t) cells.value[r][c].t = t; else delete cells.value[r][c].t } }\n// Column widths start from colWidths (defaultColWidth 92); resizable (default true) lets the user drag a column letter's right edge (min 32). @column-resize(col, px) on every drag frame.\nconst cw = ref(Array.from({ length: nC }, (_, c) => props.colWidths[c] || props.defaultColWidth))\nconst rz = reactive({ hot: null, drag: null })\nconst edgeLit = { borderRight: '1px solid var(--action-primary)', boxShadow: 'inset -1px 0 0 var(--action-primary)' } // hover / drag: the header's own right grid line turns 2px --action-primary, on the column boundary\nfunction startResize(e, c) {\n e.preventDefault(); e.stopPropagation()\n const x0 = e.clientX, w0 = cw.value[c]\n rz.drag = c\n const move = (ev) => { const w = Math.max(32, Math.round(w0 + ev.clientX - x0)); cw.value[c] = w; emit('columnResize', c, w) }\n const up = () => { rz.drag = null; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); document.body.style.cursor = ''; document.body.style.userSelect = '' }\n document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'\n window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)\n}\nconst gridCols = computed(() => ['46px', ...cw.value.map(w => w + 'px')].join(' '))\nconst hdr = { position: 'sticky', top: 0, zIndex: 3, background: 'var(--bg-inset)', borderRight: '1px solid var(--border-control)', borderBottom: '1px solid var(--border-control)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--text-xs)', letterSpacing: 'var(--tracking-xs)', color: 'var(--text-secondary)', height: '26px', boxSizing: 'border-box', userSelect: 'none' }\nconst hl = { background: 'var(--soft-bg)', color: 'var(--action-primary)' }\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ border: '1px solid var(--border-control)', borderRadius: 'var(--radius-lg)', overflow: 'hidden', background: 'var(--bg-surface)', boxSizing: 'border-box', ...style }\">\n <div v-if=\"toolbar\" style=\"height:var(--bar-card-h);display:flex;align-items:center;gap:2px;padding:0 var(--edge-pad);box-sizing:border-box;border-bottom:1px solid var(--border-subtle)\">\n <IconButton icon=\"undo-2\" muted label=\"Undo\" /><IconButton icon=\"redo-2\" muted label=\"Redo\" />\n <span style=\"width:1px;height:16px;background:var(--border-control);margin:0 6px\" />\n <button type=\"button\" :data-wash=\"cur.b ? undefined : ''\" @click=\"setFlag('b')\" :style=\"{ width: 'var(--control-h)', height: 'var(--control-h)', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', background: cur.b ? 'var(--bg-active)' : 'var(--wash, transparent)', color: 'var(--text-body)', fontWeight: 700 }\">B</button>\n <button type=\"button\" :data-wash=\"cur.i ? undefined : ''\" @click=\"setFlag('i')\" :style=\"{ width: 'var(--control-h)', height: 'var(--control-h)', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', background: cur.i ? 'var(--bg-active)' : 'var(--wash, transparent)', color: 'var(--text-body)', fontStyle: 'italic' }\">I</button>\n <button type=\"button\" :data-wash=\"cur.u ? undefined : ''\" @click=\"setFlag('u')\" :style=\"{ width: 'var(--control-h)', height: 'var(--control-h)', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', background: cur.u ? 'var(--bg-active)' : 'var(--wash, transparent)', color: 'var(--text-body)', textDecoration: 'underline' }\">U</button>\n <span style=\"width:1px;height:16px;background:var(--border-control);margin:0 6px\" />\n <KSelect :options=\"['Automatic','Number','Currency','Percent']\" :model-value=\"{ number: 'Number', currency: 'Currency', percent: 'Percent' }[cur.t] || 'Automatic'\" @update:modelValue=\"setType\" style=\"width:110px\" />\n </div>\n <div v-if=\"formulaBar\" style=\"height:var(--bar-h);display:flex;align-items:center;border-bottom:1px solid var(--border-control)\">\n <span style=\"width:72px;align-self:stretch;display:flex;align-items:center;justify-content:center;border-right:1px solid var(--border-control);font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ multi ? colName(lo.c) + (lo.r + 1) + ':' + colName(hi.c) + (hi.r + 1) : colName(active.c) + (active.r + 1) }}</span>\n <span style=\"padding:0 10px;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);font-style:italic;color:var(--text-muted);border-right:1px solid var(--border-subtle);align-self:stretch;display:flex;align-items:center\">fx</span>\n <input :value=\"editing === 'fx' ? draft : rawOf(cur)\" @focus=\"editing = 'fx'; draft = rawOf(cur)\" @input=\"draft = $event.target.value\" @blur=\"editing === 'fx' && (commit(active.r, active.c, draft), editing = null)\" @keydown.enter=\"$event.target.blur()\" @keydown.esc=\"editing = null; $event.target.blur()\" style=\"flex:1;border:none;outline:none;background:transparent;padding:0 10px;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-primary)\" />\n </div>\n <div :style=\"{ overflow: 'auto', maxHeight: typeof height === 'number' ? height + 'px' : height }\">\n <div :style=\"{ display: 'grid', gridTemplateColumns: gridCols, width: 'fit-content' }\">\n <span :style=\"{ ...hdr, zIndex: 5, left: 0 }\" />\n <span v-for=\"c in nC\" :key=\"'c' + c\" :style=\"{ ...hdr, ...((c - 1) >= lo.c && (c - 1) <= hi.c ? hl : null), ...((resizable && (rz.hot === c - 1 || rz.drag === c - 1)) ? edgeLit : null) }\">{{ colName(c - 1) }}<span v-if=\"resizable\" aria-hidden=\"true\" @mousedown=\"e => startResize(e, c - 1)\" @mouseenter=\"rz.hot = c - 1\" @mouseleave=\"rz.hot = null\" style=\"position:absolute;top:0;right:0;width:7px;height:100%;cursor:col-resize;z-index:1\" /></span>\n <template v-for=\"(row, r) in cells\" :key=\"r\">\n <span :style=\"{ ...hdr, position: 'sticky', left: 0, top: 'auto', zIndex: 2, height: '28px', ...(r >= lo.r && r <= hi.r ? hl : null) }\">{{ r + 1 }}</span>\n <span v-for=\"(cell, c) in row\" :key=\"r + ',' + c\" @mousedown=\"pick(r, c, $event)\" @dblclick=\"editing = { r, c }; draft = rawOf(cell)\"\n :style=\"{ position: 'relative', height: '28px', boxSizing: 'border-box', borderRight: '1px solid var(--border-subtle)', borderBottom: '1px solid var(--border-subtle)', display: 'flex', alignItems: 'center', justifyContent: cell.align || (typeof evalCell(cells, cell) === 'number' ? 'flex-end' : 'flex-start'), padding: '0 8px', cursor: 'cell', background: inRange(r, c) && !(active.r === r && active.c === c) ? 'var(--soft-bg)' : 'var(--bg-surface)', boxShadow: active.r === r && active.c === c ? 'inset 0 0 0 2px var(--action-primary)' : 'none', fontFamily: 'var(--font-sans)', fontVariantNumeric: typeof evalCell(cells, cell) === 'number' ? 'tabular-nums' : 'normal', fontSize: 'var(--control-text)', fontVariantNumeric: 'tabular-nums', color: evalCell(cells, cell) === '#ERROR' ? 'var(--danger)' : 'var(--text-primary)', fontWeight: cell.b ? 600 : 'var(--weight-regular)', fontStyle: cell.i ? 'italic' : 'normal', textDecoration: cell.u ? 'underline' : 'none', whiteSpace: 'nowrap', overflow: 'hidden', userSelect: 'none' }\">\n <input v-if=\"editing && editing !== 'fx' && editing.r === r && editing.c === c\" autofocus :value=\"draft\" @input=\"draft = $event.target.value\" @blur=\"commit(r, c, draft); editing = null\" @keydown.enter=\"$event.target.blur()\" @keydown.esc=\"editing = null\" style=\"position:absolute;inset:0;border:none;outline:none;padding:0 8px;font:inherit;background:var(--bg-surface);color:var(--text-primary)\" />\n <template v-else>{{ fmt(evalCell(cells, cell), cell.t, currency) }}</template>\n <span v-if=\"active.r === r && active.c === c && !editing\" style=\"position:absolute;right:-3px;bottom:-3px;width:6px;height:6px;background:var(--action-primary);border:1px solid var(--bg-surface);cursor:crosshair;z-index:2\" />\n </span>\n </template>\n </div>\n </div>\n <div v-if=\"statusBar\" style=\"height:var(--bar-h);display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--border-control);background:var(--bg-inset);padding-right:12px\">\n <div style=\"display:flex;align-self:stretch;align-items:stretch\">\n <button v-for=\"s in sheets\" :key=\"s\" type=\"button\" :data-ink=\"sheet === s ? undefined : ''\" @click=\"sheet = s\" :style=\"{ border: 'none', borderRight: '1px solid var(--border-subtle)', padding: '0 16px', cursor: 'pointer', background: sheet === s ? 'var(--bg-surface)' : 'transparent', color: sheet === s ? 'var(--text-heading)' : 'var(--ink, var(--text-secondary))', fontFamily: 'var(--font-sans)', fontWeight: sheet === s ? 'var(--weight-medium)' : 'var(--weight-regular)', fontSize: 'var(--control-text)', boxShadow: sheet === s ? 'inset 0 2px 0 var(--action-primary)' : 'none' }\">{{ s }}</button>\n <span style=\"display:flex;align-items:center;padding:0 6px\"><IconButton icon=\"plus\" muted label=\"Add sheet\" /></span>\n </div>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-secondary)\">{{ stats ? 'Sum ' + fmt(stats.sum, cur.t, currency) + ' Avg ' + fmt(stats.avg, cur.t, currency) + ' Count ' + stats.count : '100%' }}</span>\n </div>\n </div>\n</template>\n","data/KeyValueList.vue":"<script setup>\nimport KeyValue from './KeyValue.vue'\ndefineProps({ rows: { type: Array, default: () => [] }, style: Object })\n</script>\n<template>\n <div :style=\"{ display: 'flex', flexDirection: 'column', ...style }\">\n <template v-for=\"(r, i) in rows\" :key=\"r.label\">\n <KeyValue v-bind=\"r\" />\n <div v-if=\"i < rows.length - 1\" style=\"height:1px;background:var(--border-subtle)\" />\n </template>\n </div>\n</template>\n","data/Table.jsx":"import React from \"react\";\nimport { Checkbox } from \"../forms/Checkbox.jsx\";\nimport { IconTile } from \"../display/Badge.jsx\";\nimport { IconButton } from \"../buttons/IconButton.jsx\";\nimport { Icon } from \"../icons/Icon.jsx\";\n\n/* Selection checkbox: sits in the first column's leading padding, not in a column of its own, so the gap to the\n first column's text equals the edge padding (16px; 12px in sm) whatever the table width or column widths (a separate column took a share\n of any spare width). */\nconst CHECK_PAD = \"calc(var(--edge-pad) * 2 + var(--check-size))\";\n/* Config-code chip (`code: true` columns): Menlo at --text-code on the hover fill with a control border (stays visible on a hovered row) — the one place the code font appears in a table.\n Only text and numbers get the chip; an element in the cell (a Skeleton while loading, a link) renders as is. */\nconst codeChip = { fontFamily: \"var(--font-mono)\", fontSize: \"var(--text-code)\", lineHeight: \"var(--leading-code)\", letterSpacing: 0, background: \"var(--bg-hover)\", border: \"1px solid var(--border-control)\", borderRadius: 4, padding: \"1px 6px\", color: \"var(--text-primary)\", whiteSpace: \"nowrap\" };\nconst checkSlot = { position: \"absolute\", left: \"var(--edge-pad)\", top: 0, bottom: 0, display: \"flex\", alignItems: \"center\" };\n\n/* Two-line title cell (`twoLine` column): icon tile · title over description · trailing ⋯. Every measure is a --title-cell-* tier token:\n md 32px tile (r8) · 12px to the text · 14px title / 12px description · 2px between · 10px row padding → 58px row. */\nconst T_TILE = { width: \"var(--title-cell-tile)\", height: \"var(--title-cell-tile)\", borderRadius: \"var(--title-cell-tile-r)\" };\nconst T_ICON = { width: \"var(--title-cell-icon)\", height: \"var(--title-cell-icon)\" };\nconst T_ONE = { overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\", minWidth: 0 };\nconst T_TITLE = { ...T_ONE, fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-regular)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: \"var(--text-heading)\" };\nconst T_DESC = { ...T_ONE, fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", lineHeight: \"var(--leading-xs)\", letterSpacing: \"var(--tracking-xs)\", color: \"var(--text-secondary)\" };\nconst STAR_FILLED = \"M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z\";\n// field name (string) or accessor (row, index) => value\nconst pick = (a, r, ri) => typeof a === \"function\" ? a(r, ri) : a != null ? r[a] : undefined;\n/* Sorting: one column at a time. Click cycles ascending → descending → none; another column starts at ascending.\n The tooltip names the next step. Rows are reordered here unless manualSort (server-side: rows arrive ordered). */\n// the label is capped at the declared width minus the button (28) and its gap (4), so a long label truncates and the cell never\n// shrinks below label + button — the button is never pushed out\nconst SORT_BTN = 32;\nconst isEmpty = (v) => v == null || v === \"\" || v === \"–\";\n/* Every word the table draws itself. Pass `labels` to translate; missing keys fall back to these. The sort tooltip names the NEXT step:\n unsorted → sortAsc, ascending → sortDesc, descending → sortClear. */\nconst DEFAULT_LABELS = { sortAsc: \"Sort ascending\", sortDesc: \"Sort descending\", sortClear: \"Clear sorting\", more: \"More actions\", expand: \"Expand\", collapse: \"Collapse\", pinned: \"Selected\", others: \"Others\", unavailable: \"Unavailable\", clear: \"Clear\" };\n/* pinSelected=\"labels\": a 28px group row above each group, on the header tint, 12px semibold --text-secondary. The header rule, the group rows' rules and\n the rule closing the pinned group turn --border-control (#e5e5e5) — --border-subtle is the header tint itself, so it would not show. */\nconst GROUP_CELL = { height: 28, padding: \"0 var(--edge-pad)\", background: \"var(--bg-table-header)\", borderBottom: \"1px solid var(--border-control)\", fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", lineHeight: \"var(--leading-xs)\", letterSpacing: \"var(--tracking-xs)\", fontWeight: \"var(--weight-semibold)\", color: \"var(--text-secondary)\", whiteSpace: \"nowrap\" };\nconst CLEAR_BTN = { marginLeft: \"auto\", border: \"none\", background: \"none\", padding: 0, cursor: \"pointer\", fontFamily: \"inherit\", fontSize: \"inherit\", lineHeight: \"inherit\", fontWeight: \"var(--weight-regular)\", color: \"var(--link)\" };\nconst sortLabel = (L, sorted) => sorted === \"asc\" ? L.sortDesc : sorted === \"desc\" ? L.sortClear : L.sortAsc;\n/* Tree (`tree` on the table, `children` on a row): the twoLine column indents 24px per level and reserves a 20px arrow slot + 4px,\n so a child's arrow sits exactly under its parent's tile. */\nconst TREE_INDENT = 24, TREE_TOGGLE = 20;\n/* Builds the visible rows. index = depth-first position over EVERY row (expanded or not) — for a flat table that is the position in\n `rows`. It is assigned before sorting, so selection, onRowMore and onRowOpen indices never move when the user sorts or expands.\n Sorting reorders siblings only; children stay under their parent. */\nfunction buildView(rows, { tree, rowKey, expanded, col, dir, manualSort, pin, unavail }) {\n const idx = new Map(); let n = 0;\n const number = (list) => list.forEach((r) => { idx.set(r, n++); if (tree && Array.isArray(r.children)) number(r.children); });\n number(rows);\n const cmp = col && !manualSort ? (a, b) => {\n const va = a[col.key], vb = b[col.key], ea = isEmpty(va), eb = isEmpty(vb);\n if (ea || eb) return ea && eb ? idx.get(a) - idx.get(b) : ea ? 1 : -1; // empty values last in both directions\n return (col.compare ? col.compare(va, vb, a, b) : compareValues(va, vb)) * dir || idx.get(a) - idx.get(b);\n } : null;\n /* pinSelected: checked rows move to the top of their sibling list (sorted among themselves), the rest follow in their own order.\n unavail (pinSelected=\"labels\" + isRowDisabled): locked unchecked rows go last — Selected, Others, Unavailable, each sorted on its own. */\n const order = (list) => {\n const l = cmp ? list.slice().sort(cmp) : list;\n if (!pin) return l;\n const rest = l.filter((r) => !pin.has(idx.get(r)));\n return [...l.filter((r) => pin.has(idx.get(r))), ...(unavail ? [...rest.filter((r) => !unavail.has(idx.get(r))), ...rest.filter((r) => unavail.has(idx.get(r)))] : rest)];\n };\n const out = [];\n const walk = (list, level) => order(list).forEach((r) => {\n const ri = idx.get(r), key = r[rowKey] != null ? r[rowKey] : String(ri);\n const kids = !!tree && ((Array.isArray(r.children) && r.children.length > 0) || !!r.hasChildren);\n const open = kids && expanded.has(key);\n out.push({ r, ri, key, level, kids, open });\n if (open && Array.isArray(r.children)) walk(r.children, level + 1);\n });\n walk(rows, 0);\n return { list: out, total: n, flat: [...idx.keys()] };\n}\nconst nextSort = (cur, key) => !cur || cur.key !== key ? { key, direction: \"asc\" } : cur.direction === \"asc\" ? { key, direction: \"desc\" } : null;\nconst num = (v) => typeof v === \"number\" ? v : typeof v === \"string\" && /^\\s*-?[\\d,]+(\\.\\d+)?\\s*$/.test(v) ? parseFloat(v.replace(/,/g, \"\")) : NaN;\nfunction compareValues(a, b) {\n const ea = a == null || a === \"\" || a === \"–\", eb = b == null || b === \"\" || b === \"–\";\n if (ea || eb) return ea && eb ? 0 : ea ? 1 : -1;\n const na = num(a), nb = num(b);\n if (!isNaN(na) && !isNaN(nb)) return na - nb;\n return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: \"base\" });\n}\nfunction TitleMark({ m }) {\n if (m === \"favorite\") return <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinejoin=\"round\" role=\"img\" aria-label=\"Favorite\" style={{ display: \"block\", flexShrink: 0, color: \"var(--warning)\" }}><path d={STAR_FILLED} /></svg>;\n return <span role=\"img\" aria-label={m.label} style={{ display: \"inline-flex\", flexShrink: 0, color: m.color || \"var(--text-muted)\" }}><Icon name={m.icon} size={14} /></span>;\n}\nfunction TreeToggle({ open, label, onClick }) {\n const [hover, setHover] = React.useState(false);\n return (\n <button type=\"button\" aria-expanded={open} aria-label={label} title={label} onClick={(e) => { e.stopPropagation(); onClick(); }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}\n style={{ width: TREE_TOGGLE, height: TREE_TOGGLE, padding: 0, border: \"none\", borderRadius: \"var(--radius-sm)\", display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", cursor: \"pointer\", background: hover ? \"var(--overlay-hover)\" : \"transparent\", color: hover ? \"var(--text-body)\" : \"var(--text-muted)\", transition: \"var(--t-hover)\" }}>\n <Icon name=\"chevron-right\" size={16} style={{ width: \"var(--control-icon)\", height: \"var(--control-icon)\", transform: open ? \"rotate(90deg)\" : \"none\", transition: \"transform .15s ease\" }} />\n </button>\n );\n}\nfunction TitleCell({ c, item, onRowMore, onRowOpen, labels, tree, onToggleRow }) {\n const { r, ri } = item;\n const o = c.twoLine === true ? {} : c.twoLine;\n const icon = pick(o.icon ?? \"icon\", r, ri), hue = pick(o.hue ?? \"hue\", r, ri), desc = pick(o.description ?? \"description\", r, ri), mark = pick(o.mark ?? \"mark\", r, ri);\n // onRowOpen: the tile and the title open the row (hand cursor); the title is also reachable by keyboard (Enter / Space)\n const open = onRowOpen ? (e) => onRowOpen(r, ri, e) : null;\n return (\n <div style={{ display: \"flex\", alignItems: \"center\", minWidth: c.minWidth ?? 180, ...(tree ? { paddingLeft: item.level * TREE_INDENT } : null) }}>\n {tree ? <span style={{ width: TREE_TOGGLE, flexShrink: 0, marginRight: TREE_INDENT - TREE_TOGGLE, display: \"inline-flex\" }}>{item.kids ? <TreeToggle open={item.open} label={item.open ? labels.collapse : labels.expand} onClick={() => onToggleRow(item)} /> : null}</span> : null}\n <div style={{ display: \"flex\", alignItems: \"center\", gap: \"var(--title-cell-tile-gap)\", flex: 1, minWidth: 0 }}>\n {icon ? <span onClick={open || undefined} style={{ display: \"inline-flex\", flexShrink: 0, cursor: open ? \"pointer\" : undefined }}><IconTile icon={icon} hue={hue || \"neutral\"} kind=\"row\" style={T_TILE} iconStyle={T_ICON} /></span> : null}\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: \"var(--title-cell-gap)\", flex: 1, minWidth: 0 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 4, minWidth: 0 }}>\n <span style={{ ...T_TITLE, ...(open ? { cursor: \"pointer\" } : null) }} {...(open ? { role: \"button\", tabIndex: 0, onClick: open, onKeyDown: (e) => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); open(e); } } } : null)}>{c.render ? c.render(r[c.key], r, ri) : r[c.key]}</span>\n {mark ? <TitleMark m={mark} /> : null}\n </div>\n {desc ? <span style={T_DESC}>{desc}</span> : null}\n </div>\n {o.more !== false ? <IconButton icon=\"ellipsis\" size=\"sm\" muted label={labels.more} title={labels.more} onClick={(e) => { if (e && e.stopPropagation) e.stopPropagation(); if (onRowMore) onRowMore(r, ri, e); }} /> : null}\n </div>\n </div>\n );\n}\n\n/**\n * Keybricks table. columns: [{key,label,width,minWidth,mono,code,twoLine,sortable,compare,align,render}].\n * sortable — hover the header for a muted sm sort button at the cell's right end (in line with a two-line row's ⋯; arrow-up); click: ascending → descending → none.\n * While sorted the button stays and the label turns --text-heading. One column at a time. sort / onSortChange control it\n * (defaultSort to seed it uncontrolled); compare(a, b, rowA, rowB) overrides the default comparison; manualSort keeps rows as given.\n * twoLine — the name column of a document list: icon tile, title over a description, optional mark (\"favorite\" = filled star), trailing ⋯\n * (onRowMore(row, index, event)); onRowOpen(row, index, event) makes the tile and the title open the row (hand cursor, Enter / Space on the title).\n * tree — rows nest through `children` (or `hasChildren` for rows you load on demand); the twoLine column indents 24px per level with an\n * expand arrow; expandedKeys / defaultExpandedKeys / onExpandChange(row, expanded, keys); rowKey names the key field (default \"id\").\n * labels — { sortAsc, sortDesc, sortClear, more, expand, collapse } for every word the table draws (English by default).\n * Give it a width; it shrinks to minWidth (default 180) before the other columns give way, and title and description truncate to whatever it gets. Row grows to 58px in md (50 sm · 68 lg).\n * The table is a size region: `size` (\"sm\" | \"md\" | \"lg\", default inherit → md) sets its rows, header, cell padding and every\n * control inside the cells from the tier tokens — rows are the tier's bar height (sm 36 · md 40 · lg 48), cells read the tier's\n * control text, header cells are the same size in weight 600 / text-secondary, sentence case, no letter-spacing.\n * `dense` is kept as an alias of size=\"sm\" (the data-preview drawer).\n * Every rule in the table is one weight (--border-subtle); the header is set apart by its --bg-table-header tint, never by a heavier line.\n * bordered — the table draws its own rounded card frame (border-card, radius-lg) and drops the edge rules that would double it.\n * framed — the table sits flush inside a container that already has a border (Card pad={false}); drops only the header's top rule.\n * pinSelected — with selectable: checked rows move to the top (of their level in a tree), in the current sort order; unchecking returns a row to its place.\n * Pinned rows sit on --bg-inset; while any are pinned, the header rule and the rule closing the group are --border-control. true | \"divider\": just that. \"labels\": a 28px group row above each group\n * (\"Selected · n\" with Clear → onToggle(\"all\", false), \"Others · n\") on the header tint. labels.pinned / others / clear translate them.\n * With isRowDisabled, locked unchecked rows form a third group after Others — \"Unavailable · n\" (labels.unavailable). Group order is fixed under sorting\n * (Selected · Others · Unavailable; the sort applies inside each); a row changes group as its lock changes. Empty groups show no group row.\n * isRowDisabled(row, index) — with selectable: the app's lock for a row, asked on every render. A locked row's checkbox is disabled (50%, not-allowed, no hover, no onToggle)\n * and the row takes no hover wash; its cells are left to the app. The lock never changes selected — a locked, checked row stays checked.\n * Select-all checks every unlocked row, Clear unchecks them; locked rows keep their state — onToggle(\"all\", v, next) passes the result.\n * With pinSelected, a locked unchecked row stays with the others. disabledReason(row, index) → the checkbox tooltip.\n * resizable — a short divider between header cells (hover darkens it, cursor col-resize); drag to set that column's width.\n * Widths live in local state unless you pass columnWidths (then update it from onColumnResize). onColumnResize(key, px) fires on every drag either way. Min width = column.minWidth ?? 48.\n */\nexport function Table({ columns = [], rows = [], size, dense, selectable, selected = [], onToggle, pinSelected, isRowDisabled, disabledReason, headerBorder = true, bordered, framed, resizable, columnWidths, onColumnResize, onRowMore, onRowOpen, sort, defaultSort = null, onSortChange, manualSort, tree, rowKey = \"id\", expandedKeys, defaultExpandedKeys, onExpandChange, labels, empty, style }) {\n const L = { ...DEFAULT_LABELS, ...labels };\n const [localExp, setLocalExp] = React.useState(defaultExpandedKeys || []);\n const expKeys = expandedKeys !== undefined ? expandedKeys : localExp;\n const [localSort, setLocalSort] = React.useState(defaultSort);\n const sortState = sort !== undefined ? sort : localSort;\n const [hotTh, setHotTh] = React.useState(null); // header under the pointer or holding focus\n const cycleSort = (key) => { const n = nextSort(sortState, key); if (sort === undefined) setLocalSort(n); if (onSortChange) onSortChange(n); };\n /* Locked unchecked rows, asked of isRowDisabled on every render (never cached) — only \"labels\" groups them; the key re-runs the view when the set changes. */\n const allRows = React.useMemo(() => { const a = []; const f = (l) => l.forEach((r) => { a.push(r); if (tree && Array.isArray(r.children)) f(r.children); }); f(rows); return a; }, [rows, tree]);\n const unavail = selectable && pinSelected === \"labels\" && isRowDisabled ? new Set(allRows.map((r, i) => (!selected.includes(i) && isRowDisabled(r, i) ? i : -1)).filter((i) => i >= 0)) : null;\n const unKey = unavail ? [...unavail].join(\",\") : \"\";\n const { list: view, total, flat } = React.useMemo(() => buildView(rows, {\n tree, rowKey, expanded: new Set(expKeys), manualSort, pin: selectable && pinSelected ? new Set(selected) : null, unavail: unavail && unavail.size ? unavail : null,\n col: sortState && columns.find((x) => x.key === sortState.key), dir: sortState && sortState.direction === \"desc\" ? -1 : 1,\n }), [rows, columns, sortState, manualSort, tree, rowKey, expKeys, selectable, pinSelected, selected, unKey]);\n /* pinSelected: true | \"divider\" → pinned rows on --bg-inset + a 1px --border-control rule under the group; \"labels\" → the same fill + a group row above each group.\n While rows are pinned the header rule is --border-control too (--border-subtle is the header tint itself, so it would not show). */\n const pinInfo = (() => {\n if (!selectable || !pinSelected) return null;\n const sel = new Set(selected), top = view.filter((x) => x.level === 0 && sel.has(x.ri)).length;\n const style = pinSelected === \"labels\" ? \"labels\" : \"divider\", un = unavail ? unavail.size : 0;\n if (style === \"divider\") return { style, sel, top, strong: top > 0, n: selected.length, others: total - selected.length, rest: top ? view.findIndex((x) => x.level === 0 && !sel.has(x.ri)) : -1, unStart: -1, un: 0 };\n /* labels: up to three groups — Selected · n, Others · n, Unavailable · n. A group row shows only when its group has top-level rows; the bands show while any group does. */\n const unStart = un ? view.findIndex((x) => x.level === 0 && unavail.has(x.ri)) : -1, grouped = top > 0 || unStart >= 0;\n const rest = grouped ? view.findIndex((x) => x.level === 0 && !sel.has(x.ri) && !(unavail && unavail.has(x.ri))) : -1;\n return { style, sel, top, grouped, strong: grouped, n: selected.length, others: total - selected.length - un, rest, unStart, un };\n })();\n /* isRowDisabled(row, index) — the app's lock, asked again on every render (it may depend on selected). It blocks checking only: a locked row keeps\n whatever selected says. Select-all counts and changes only unlocked rows; onToggle(\"all\", v, next) hands the app the resulting selection. */\n const isDis = (r, ri) => !!(selectable && isRowDisabled && isRowDisabled(r, ri));\n const selSet = new Set(selected);\n const enabledIdx = selectable ? flat.map((r, i) => (isDis(r, i) ? -1 : i)).filter((i) => i >= 0) : [];\n const allOn = enabledIdx.length > 0 && enabledIdx.every((i) => selSet.has(i));\n const toggleAll = (v) => { if (!onToggle) return; const next = v ? [...new Set([...selected, ...enabledIdx])] : selected.filter((i) => flat[i] && isDis(flat[i], i)); onToggle(\"all\", v, next); };\n const groupRow = (text, clear) => <tr><td colSpan={columns.length || 1} style={GROUP_CELL}><span style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>{text}{clear ? <button type=\"button\" data-link=\"\" onClick={() => toggleAll(false)} style={CLEAR_BTN}>{L.clear}</button> : null}</span></td></tr>;\n const groupAt = (pos) => !pinInfo || pinInfo.style !== \"labels\" || !pinInfo.grouped ? null : pos === 0 && pinInfo.top ? groupRow(`${L.pinned} · ${pinInfo.n}`, true) : pos === pinInfo.rest ? groupRow(`${L.others} · ${pinInfo.others}`) : pos === pinInfo.unStart ? groupRow(`${L.unavailable} · ${pinInfo.un}`) : null;\n /* The row above a group row (or closing the pinned band) takes --border-control: --border-subtle is the header tint the group row sits on. */\n const ruleBelow = (pos) => !!pinInfo && ((pinInfo.rest > 0 && pos === pinInfo.rest - 1) || (pinInfo.unStart > 0 && pos === pinInfo.unStart - 1));\n const toggleRow = (item) => {\n const next = item.open ? expKeys.filter((k) => k !== item.key) : [...expKeys, item.key];\n if (expandedKeys === undefined) setLocalExp(next);\n if (onExpandChange) onExpandChange(item.r, !item.open, next);\n };\n const tier = size || (dense ? \"sm\" : undefined);\n const flushTop = bordered || framed;\n const colCount = columns.length || 1;\n const [localW, setLocalW] = React.useState({});\n const [drag, setDrag] = React.useState(null); // key being dragged\n const [hot, setHot] = React.useState(null); // key whose handle is hovered\n const thRefs = React.useRef({});\n const widths = columnWidths || localW;\n /* Resizable: the last column (no divider) takes no width and absorbs the slack. Otherwise, when every column has a width, a fixed-layout table\n scales them all to fill, and the dragged column would not follow the cursor. */\n const widthOf = (c) => (resizable && c === columns[columns.length - 1] ? undefined : widths[c.key] ?? c.width);\n\n const startDrag = (e, c) => {\n e.preventDefault();\n const th = thRefs.current[c.key];\n /* A column width is the content box (the cell padding sits outside it), so start from the rendered width minus the padding — else the column jumps by its padding on the first move. */\n const cs = th && getComputedStyle(th);\n const startX = e.clientX, startW = th ? th.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) : (parseFloat(widthOf(c)) || 120);\n const min = c.minWidth ?? 48;\n setDrag(c.key);\n const move = (ev) => {\n const w = Math.max(min, Math.round(startW + ev.clientX - startX));\n /* Uncontrolled unless columnWidths is passed; onColumnResize always hears the new width (listening alone must not freeze the column). */\n if (!columnWidths) setLocalW((s) => ({ ...s, [c.key]: w }));\n if (onColumnResize) onColumnResize(c.key, w);\n };\n const up = () => { setDrag(null); window.removeEventListener(\"mousemove\", move); window.removeEventListener(\"mouseup\", up); document.body.style.cursor = \"\"; document.body.style.userSelect = \"\"; };\n document.body.style.cursor = \"col-resize\"; document.body.style.userSelect = \"none\";\n window.addEventListener(\"mousemove\", move); window.addEventListener(\"mouseup\", up);\n };\n\n // `mono` columns (codes, IDs, amounts) use the UI font with tabular figures; the monospace font is for code only\n const cellFont = (c) => c.mono\n ? { fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", fontVariantNumeric: \"tabular-nums\", color: \"var(--text-primary)\" }\n : { fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: \"var(--text-primary)\" };\n // first and last cells keep the card's edge padding; every other cell the tier's cell padding\n const cellPad = (i) => ({ ...(i === 0 && selectable ? { position: \"relative\" } : null), paddingTop: 0, paddingBottom: 0, paddingLeft: i === 0 ? (selectable ? CHECK_PAD : \"var(--edge-pad)\") : \"var(--cell-px)\", paddingRight: i === columns.length - 1 ? \"var(--edge-pad)\" : \"var(--cell-px)\" });\n\n const table = (\n <table data-size={tier} style={{ width: \"100%\", borderCollapse: \"collapse\", tableLayout: resizable ? \"fixed\" : \"auto\", ...(bordered ? null : style) }}>\n <thead>\n <tr style={{ background: \"var(--bg-table-header)\", borderTop: headerBorder && !flushTop ? \"1px solid var(--border-subtle)\" : \"none\", borderBottom: headerBorder ? (pinInfo && pinInfo.strong ? \"1px solid var(--border-control)\" : \"1px solid var(--border-subtle)\") : \"none\", height: \"var(--bar-h)\" }}>\n {columns.map((c, i) => {\n const last = i === columns.length - 1;\n const active = drag === c.key, lit = active || hot === c.key;\n const sorted = sortState && sortState.key === c.key ? sortState.direction : null;\n const showSort = c.sortable && (sorted || hotTh === c.key);\n return (\n <th key={c.key} ref={(el) => { thRefs.current[c.key] = el; }} aria-sort={c.sortable ? (sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : \"none\") : undefined}\n onMouseEnter={c.sortable ? () => setHotTh(c.key) : undefined} onMouseLeave={c.sortable ? () => setHotTh(null) : undefined} onFocus={c.sortable ? () => setHotTh(c.key) : undefined} onBlur={c.sortable ? () => setHotTh(null) : undefined}\n style={{ position: \"relative\", textAlign: c.align || \"left\", ...cellPad(i), width: widthOf(c), fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-semibold)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: sorted ? \"var(--text-heading)\" : \"var(--text-secondary)\", whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>\n {selectable && i === 0 ? <span style={checkSlot}><Checkbox checked={allOn} indeterminate={!allOn && selected.length > 0} disabled={total > 0 && !enabledIdx.length} onChange={toggleAll} /></span> : null}\n {c.sortable ? (\n <span style={{ display: \"flex\", alignItems: \"center\", gap: 4 }}>\n <span style={{ overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\", minWidth: 0, ...(c.align === \"right\" ? { marginLeft: \"auto\" } : c.align === \"center\" ? { margin: \"0 auto\" } : null), ...(typeof widthOf(c) === \"number\" ? { maxWidth: widthOf(c) - SORT_BTN } : null) }}>{c.label}</span>\n <IconButton icon={sorted === \"desc\" ? \"arrow-down\" : \"arrow-up\"} size=\"sm\" muted label={sortLabel(L, sorted)} title={sortLabel(L, sorted)} onClick={() => cycleSort(c.key)} style={{ opacity: showSort ? 1 : 0, ...(c.align === \"right\" ? null : { marginLeft: \"auto\" }) }} />\n </span>\n ) : c.label}\n {resizable && !last ? (\n <span aria-hidden=\"true\" onMouseDown={(e) => startDrag(e, c)} onMouseEnter={() => setHot(c.key)} onMouseLeave={() => setHot(null)}\n style={{ position: \"absolute\", top: 0, right: -5, width: 11, height: \"100%\", cursor: \"col-resize\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 1 }}>\n <span style={{ width: active ? 2 : 1, height: \"50%\", background: lit ? \"var(--action-primary)\" : \"var(--border-split)\", borderRadius: 1, transition: \"var(--t-hover)\" }}></span>\n </span>\n ) : null}\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody>\n {view.map((item, pos) => { const { r, ri } = item; const dis = isDis(r, ri); return (<React.Fragment key={item.key}>{groupAt(pos)}\n <tr data-row=\"\" aria-level={tree ? item.level + 1 : undefined} aria-expanded={item.kids ? item.open : undefined} style={{ ...(dis ? { \"--row-wash\": \"initial\" } : null), height: \"var(--bar-h)\", background: pinInfo && pinInfo.sel.has(ri) ? \"var(--row-wash, var(--bg-inset))\" : \"var(--row-wash, transparent)\", transition: \"var(--t-hover)\", borderBottom: ruleBelow(pos) ? \"1px solid var(--border-control)\" : bordered && pos === view.length - 1 ? \"none\" : \"1px solid var(--border-subtle)\" }}>\n {columns.map((c, i) => (\n <td key={c.key} style={{ ...cellPad(i), ...(c.twoLine ? { paddingTop: \"var(--title-cell-py)\", paddingBottom: \"var(--title-cell-py)\", maxWidth: 0 } : null), textAlign: c.align || \"left\", whiteSpace: \"nowrap\", ...(resizable ? { overflow: \"hidden\", textOverflow: \"ellipsis\" } : null), ...cellFont(c) }}>{selectable && i === 0 ? <span style={checkSlot} title={dis && disabledReason ? disabledReason(r, ri) : undefined}><Checkbox checked={selected.includes(ri)} disabled={dis} onChange={(v) => onToggle && onToggle(ri, v)} /></span> : null}{c.twoLine ? <TitleCell c={c} item={item} onRowMore={onRowMore} onRowOpen={onRowOpen} labels={L} tree={tree && c === columns.find((x) => x.twoLine)} onToggleRow={toggleRow} /> : (c.code && (typeof r[c.key] === \"string\" || typeof r[c.key] === \"number\") && r[c.key] !== \"\") ? <code style={codeChip}>{c.render ? c.render(r[c.key], r, ri) : r[c.key]}</code> : c.render ? c.render(r[c.key], r, ri) : r[c.key]}</td>\n ))}\n </tr></React.Fragment>\n ); })}\n {rows.length === 0 && empty ? <tr><td colSpan={colCount} style={{ padding: 0 }}>{empty}</td></tr> : null}\n </tbody>\n </table>\n );\n if (!bordered) return table;\n return <div data-size={tier} style={{ background: \"var(--bg-surface)\", border: \"1px solid var(--border-card)\", borderRadius: \"var(--radius-lg)\", overflow: \"hidden\", ...style }}>{table}</div>;\n}\n","data/Table.vue":"<script setup>\nimport { px } from '../px.js'\nimport { ref, computed, reactive, getCurrentInstance } from 'vue'\nimport Checkbox from '../forms/Checkbox.vue'\nimport IconTile from '../display/IconTile.vue'\nimport IconButton from '../buttons/IconButton.vue'\nimport Icon from '../icons/Icon.vue'\n// Keybricks table. columns: [{key,label,width,minWidth,mono,code,twoLine,align,render}].\n// twoLine — the name column of a document list: icon tile, title over a description, optional mark (\"favorite\" = filled star), trailing ⋯\n// (@row-more(row, index, event)); @row-open(row, index, event) makes the tile and the title open the row (hand cursor, Enter / Space on the title).\n// tree — rows nest through `children` (or `hasChildren` for rows you load on demand); the twoLine column indents 24px per level with an\n// expand arrow; v-model:expandedKeys / defaultExpandedKeys / @expand-change(row, expanded, keys); rowKey names the key field (default \"id\").\n// labels — { sortAsc, sortDesc, sortClear, more, expand, collapse } for every word the table draws (English by default).\n// Give it a width; it shrinks to minWidth (default 180) before the other columns give way, and title and description truncate to whatever it gets. Row grows to 58px in md (50 sm · 68 lg).\n// sortable — hover the header for a muted sm sort button at the cell's right end (in line with a two-line row's ⋯; arrow-up); click: ascending → descending → none. While sorted the\n// button stays and the label turns --text-heading. One column at a time. v-model:sort / @sort-change (defaultSort seeds it\n// uncontrolled); compare(a, b, rowA, rowB) overrides the default comparison; manualSort keeps rows as given (server-side).\n// The table is a size region: `size` (\"sm\" | \"md\" | \"lg\", default inherit → md) sets its rows, header, cell padding and every control\n// inside the cells from the tier tokens — rows are the tier's bar height (sm 36 · md 40 · lg 48), cells read the tier's control text,\n// header cells are the same size in weight 600 / text-secondary, sentence case, no letter-spacing. `dense` is an alias of size=\"sm\".\n// Every rule in the table is one weight (--border-subtle); the header is set apart by its --bg-table-header tint, never by a heavier line.\n// bordered — the table draws its own rounded card frame (border-card, radius-lg) and drops the edge rules that would double it.\n// framed — the table sits flush inside a container that already has a border (Card :pad=\"false\"); drops only the header's top rule.\n// resizable — a short divider between header cells (hover darkens it, cursor col-resize); drag to set that column's width.\n// Widths live in local state unless you pass columnWidths (then update it from @column-resize). @column-resize(key, px) fires on every drag either way. Min width = column.minWidth ?? 48.\n// pinSelected — with selectable: checked rows move to the top (of their level in a tree), in the current sort order; unchecking returns a row to its place.\n// Pinned rows sit on --bg-inset; while any are pinned, the header rule and the rule closing the group are --border-control. true | 'divider': just that. 'labels': a 28px group row above each group\n// ('Selected · n' with Clear → @toggle('all', false), 'Others · n') on the header tint. labels.pinned / others / clear translate them.\n// With isRowDisabled, locked unchecked rows form a third group after Others — 'Unavailable · n' (labels.unavailable). Order is fixed under\n// sorting (Selected · Others · Unavailable; the sort applies inside each); a row changes group as its lock changes; empty groups show no group row.\n// isRowDisabled(row, index) — with selectable: the app's lock for a row, called on every render. Locked: checkbox disabled (50%, not-allowed, no hover, no toggle), no row hover wash,\n// cells left to the app. Never changes selected. Select-all / Clear skip locked rows — @toggle('all', v, next) passes the result. disabledReason(row, index) → checkbox tooltip.\n// #empty slot renders inside the table when rows is empty.\nconst props = defineProps({ columns: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, size: String, dense: Boolean, selectable: Boolean, pinSelected: [Boolean, String], isRowDisabled: Function, disabledReason: Function, selected: { type: Array, default: () => [] }, headerBorder: { type: Boolean, default: true }, bordered: Boolean, framed: Boolean, resizable: Boolean, columnWidths: Object, sort: { type: Object, default: undefined }, defaultSort: { type: Object, default: null }, manualSort: Boolean, tree: Boolean, rowKey: { type: String, default: 'id' }, expandedKeys: { type: Array, default: undefined }, defaultExpandedKeys: { type: Array, default: () => [] }, labels: Object, style: Object })\nconst emit = defineEmits(['toggle', 'columnResize', 'rowMore', 'rowOpen', 'sortChange', 'update:sort', 'expandChange', 'update:expandedKeys'])\n// Every word the table draws itself; `labels` translates them. The sort tooltip names the NEXT step: unsorted → sortAsc, ascending → sortDesc, descending → sortClear.\nconst DEFAULT_LABELS = { sortAsc: 'Sort ascending', sortDesc: 'Sort descending', sortClear: 'Clear sorting', more: 'More actions', expand: 'Expand', collapse: 'Collapse', pinned: 'Selected', others: 'Others', unavailable: 'Unavailable', clear: 'Clear' }\nconst L = computed(() => ({ ...DEFAULT_LABELS, ...(props.labels || {}) }))\nconst sortLabel = (sorted) => sorted === 'asc' ? L.value.sortDesc : sorted === 'desc' ? L.value.sortClear : L.value.sortAsc\n// the tile and title open the row only when a @row-open listener is attached\nconst inst = getCurrentInstance()\nconst canOpen = computed(() => !!(inst && inst.vnode.props && (inst.vnode.props.onRowOpen || inst.vnode.props['onRow-open'])))\nfunction openRow(r, ri, e) { emit('rowOpen', r, ri, e) }\nfunction openKey(r, ri, e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRow(r, ri, e) } }\n// Tree: 24px per level; a 20px arrow slot + 4px, so a child's arrow sits under its parent's tile.\nconst TREE_INDENT = 24, TREE_TOGGLE = 20\nconst localExp = ref([...props.defaultExpandedKeys])\nconst expKeys = computed(() => props.expandedKeys !== undefined ? props.expandedKeys : localExp.value)\nconst hotToggle = ref(null)\nfunction toggleRow(item) {\n const next = item.open ? expKeys.value.filter(k => k !== item.key) : [...expKeys.value, item.key]\n if (props.expandedKeys === undefined) localExp.value = next\n emit('update:expandedKeys', next); emit('expandChange', item.r, !item.open, next)\n}\nconst treeCol = computed(() => props.tree ? props.columns.find(x => x.twoLine) : null)\nconst localSort = ref(props.defaultSort)\nconst sortState = computed(() => props.sort !== undefined ? props.sort : localSort.value)\nconst hotTh = ref(null)\nconst nextSort = (cur, key) => !cur || cur.key !== key ? { key, direction: 'asc' } : cur.direction === 'asc' ? { key, direction: 'desc' } : null\nfunction cycleSort(key) { const n = nextSort(sortState.value, key); if (props.sort === undefined) localSort.value = n; emit('update:sort', n); emit('sortChange', n) }\nconst num = (v) => typeof v === 'number' ? v : typeof v === 'string' && /^\\s*-?[\\d,]+(\\.\\d+)?\\s*$/.test(v) ? parseFloat(v.replace(/,/g, '')) : NaN\nfunction compareValues(a, b) {\n const ea = a == null || a === '' || a === '–', eb = b == null || b === '' || b === '–'\n if (ea || eb) return ea && eb ? 0 : ea ? 1 : -1\n const na = num(a), nb = num(b)\n if (!isNaN(na) && !isNaN(nb)) return na - nb\n return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })\n}\n// Visible rows. index = depth-first position over EVERY row (expanded or not) — for a flat table the position in `rows` — assigned\n// before sorting, so selection and event indices never move. Sorting reorders siblings only; children stay under their parent.\nconst built = computed(() => {\n const idx = new Map(); let n = 0\n const number = (list) => list.forEach(r => { idx.set(r, n++); if (props.tree && Array.isArray(r.children)) number(r.children) })\n number(props.rows)\n const s = sortState.value, col = s && props.columns.find(x => x.key === s.key), dir = s && s.direction === 'desc' ? -1 : 1\n const isEmpty = (x) => x == null || x === '' || x === '–'\n const cmp = col && !props.manualSort ? (a, b) => { const va = a[col.key], vb = b[col.key], ea = isEmpty(va), eb = isEmpty(vb); if (ea || eb) return ea && eb ? idx.get(a) - idx.get(b) : ea ? 1 : -1; return (col.compare ? col.compare(va, vb, a, b) : compareValues(va, vb)) * dir || idx.get(a) - idx.get(b) } : null\n // pinSelected: checked rows move to the top of their sibling list (sorted among themselves), the rest follow in their own order\n const pin = props.selectable && props.pinSelected ? new Set(props.selected) : null\n // pinSelected='labels' + isRowDisabled: locked unchecked rows go last — Selected, Others, Unavailable, each sorted on its own. The predicate runs inside\n // this computed, so Vue re-runs it whenever a prop or any reactive state the predicate reads changes.\n const un = pin && props.pinSelected === 'labels' && props.isRowDisabled ? new Set([...idx.keys()].map((r, i) => !pin.has(i) && props.isRowDisabled(r, i) ? i : -1).filter(i => i >= 0)) : null\n const unavail = un && un.size ? un : null\n const order = (list) => { const l = cmp ? list.slice().sort(cmp) : list; if (!pin) return l; const rest = l.filter(r => !pin.has(idx.get(r))); return [...l.filter(r => pin.has(idx.get(r))), ...(unavail ? [...rest.filter(r => !unavail.has(idx.get(r))), ...rest.filter(r => unavail.has(idx.get(r)))] : rest)] }\n const exp = new Set(expKeys.value), out = []\n const walk = (list, level) => order(list).forEach(r => {\n const ri = idx.get(r), key = r[props.rowKey] != null ? r[props.rowKey] : String(ri)\n const kids = props.tree && ((Array.isArray(r.children) && r.children.length > 0) || !!r.hasChildren)\n const open = kids && exp.has(key)\n out.push({ r, ri, key, level, kids, open })\n if (open && Array.isArray(r.children)) walk(r.children, level + 1)\n })\n walk(props.rows, 0)\n return { list: out, total: n, flat: [...idx.keys()], unavail }\n})\nconst view = computed(() => built.value.list)\nconst total = computed(() => built.value.total)\n// isRowDisabled(row, index) — the app's lock, called on every render (it may depend on selected), never cached. It blocks checking only: a locked row keeps\n// whatever selected says. Select-all counts and changes only unlocked rows; @toggle('all', v, next) hands the app the resulting selection.\nconst isDis = (r, ri) => !!(props.selectable && props.isRowDisabled && props.isRowDisabled(r, ri))\nconst enabledIdx = () => props.selectable ? built.value.flat.map((r, i) => isDis(r, i) ? -1 : i).filter(i => i >= 0) : []\nfunction head() { const en = enabledIdx(), sel = new Set(props.selected), all = en.length > 0 && en.every(i => sel.has(i)); return { all, ind: !all && props.selected.length > 0, none: total.value > 0 && !en.length } }\nfunction toggleAll(v) { const flat = built.value.flat; const next = v ? [...new Set([...props.selected, ...enabledIdx()])] : props.selected.filter(i => flat[i] && isDis(flat[i], i)); emit('toggle', 'all', v, next) }\n// pinSelected: true | 'divider' → pinned rows on --bg-inset + a 1px --border-control rule under the group; 'labels' → the same fill + a group row above each group.\n// While rows are pinned the header rule is --border-control too (--border-subtle is the header tint itself, so it would not show).\nconst pinInfo = computed(() => {\n if (!props.selectable || !props.pinSelected) return null\n const sel = new Set(props.selected), v = view.value, top = v.filter(x => x.level === 0 && sel.has(x.ri)).length\n const style = props.pinSelected === 'labels' ? 'labels' : 'divider', ua = built.value.unavail, un = ua ? ua.size : 0\n if (style === 'divider') return { style, sel, top, strong: top > 0, n: props.selected.length, others: total.value - props.selected.length, rest: top ? v.findIndex(x => x.level === 0 && !sel.has(x.ri)) : -1, unStart: -1, un: 0 }\n // labels: up to three groups — Selected · n, Others · n, Unavailable · n; a group row shows only when its group has top-level rows\n const unStart = un ? v.findIndex(x => x.level === 0 && ua.has(x.ri)) : -1, grouped = top > 0 || unStart >= 0\n const rest = grouped ? v.findIndex(x => x.level === 0 && !sel.has(x.ri) && !(ua && ua.has(x.ri))) : -1\n return { style, sel, top, grouped, strong: grouped, n: props.selected.length, others: total.value - props.selected.length - un, rest, unStart, un }\n})\nconst GROUP_CELL = { height: '28px', padding: '0 var(--edge-pad)', background: 'var(--bg-table-header)', borderBottom: '1px solid var(--border-control)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', lineHeight: 'var(--leading-xs)', letterSpacing: 'var(--tracking-xs)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }\nconst CLEAR_BTN = { marginLeft: 'auto', border: 'none', background: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'inherit', lineHeight: 'inherit', fontWeight: 'var(--weight-regular)', color: 'var(--link)' }\nconst sortedDir = (c) => sortState.value && sortState.value.key === c.key ? sortState.value.direction : null\n// Two-line title cell — every measure is a --title-cell-* tier token (md: 32px tile r8 · 12px to text · 14/12px · 2px gap · 10px padding → 58px).\nconst T_TILE = { width: 'var(--title-cell-tile)', height: 'var(--title-cell-tile)', borderRadius: 'var(--title-cell-tile-r)' }\nconst T_ICON = { width: 'var(--title-cell-icon)', height: 'var(--title-cell-icon)' }\nconst T_ONE = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }\nconst T_TITLE = { ...T_ONE, fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-regular)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: 'var(--text-heading)' }\nconst T_DESC = { ...T_ONE, fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', lineHeight: 'var(--leading-xs)', letterSpacing: 'var(--tracking-xs)', color: 'var(--text-secondary)' }\nconst STAR_FILLED = 'M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z'\nconst pick = (a, r, ri) => typeof a === 'function' ? a(r, ri) : a != null ? r[a] : undefined\nfunction tl(c, r, ri) {\n const o = c.twoLine === true ? {} : c.twoLine\n return { icon: pick(o.icon ?? 'icon', r, ri), hue: pick(o.hue ?? 'hue', r, ri), desc: pick(o.description ?? 'description', r, ri), mark: pick(o.mark ?? 'mark', r, ri), more: o.more !== false }\n}\nfunction more(r, ri, e) { if (e && e.stopPropagation) e.stopPropagation(); emit('rowMore', r, ri, e) }\nconst tier = computed(() => props.size || (props.dense ? 'sm' : undefined))\nconst flushTop = computed(() => props.bordered || props.framed)\nconst colCount = computed(() => props.columns.length || 1)\n// Selection checkbox sits in the first column's leading padding (no column of its own): the gap to the text equals the edge padding.\nconst CHECK_PAD = 'calc(var(--edge-pad) * 2 + var(--check-size))'\n// Config-code chip (`code: true` columns): Menlo at --text-code on the hover fill with a control border (stays visible on a hovered row).\nconst codeChip = { fontFamily: 'var(--font-mono)', fontSize: 'var(--text-code)', lineHeight: 'var(--leading-code)', letterSpacing: 0, background: 'var(--bg-hover)', border: '1px solid var(--border-control)', borderRadius: '4px', padding: '1px 6px', color: 'var(--text-primary)', whiteSpace: 'nowrap' }\nconst checkSlot = { position: 'absolute', left: 'var(--edge-pad)', top: 0, bottom: 0, display: 'flex', alignItems: 'center' }\nconst frame = computed(() => props.bordered ? { background: 'var(--bg-surface)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)', overflow: 'hidden', ...props.style } : { display: 'contents' })\nconst localW = reactive({})\nconst drag = ref(null), hot = ref(null)\nconst thEls = {}\n// Resizable: the last column (no divider) takes no width and absorbs the slack — otherwise a fixed-layout table scales every width to fill and the dragged column drifts from the cursor.\nconst widthOf = (c) => props.resizable && c === props.columns[props.columns.length - 1] ? undefined : px((props.columnWidths || localW)[c.key] ?? c.width)\nfunction startDrag(e, c) {\n e.preventDefault()\n const th = thEls[c.key]\n // A column width is the content box (cell padding sits outside it): start from the rendered width minus the padding, or the column jumps on the first move.\n const cs = th && getComputedStyle(th)\n const startX = e.clientX, startW = th ? th.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) : (parseFloat(widthOf(c)) || 120), min = c.minWidth ?? 48\n drag.value = c.key\n const move = (ev) => { const w = Math.max(min, Math.round(startW + ev.clientX - startX)); if (!props.columnWidths) localW[c.key] = w; emit('columnResize', c.key, w) }\n const up = () => { drag.value = null; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); document.body.style.cursor = ''; document.body.style.userSelect = '' }\n document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'\n window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)\n}\nfunction cellFont(c) { return c.mono ? { fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', fontVariantNumeric: 'tabular-nums', color: 'var(--text-primary)' } : { fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: 'var(--text-primary)' } }\n// first and last cells keep the card's edge padding; every other cell the tier's cell padding\nfunction cellPad(i) { return { ...(i === 0 && props.selectable ? { position: 'relative' } : null), paddingTop: 0, paddingBottom: 0, paddingLeft: i === 0 ? (props.selectable ? CHECK_PAD : 'var(--edge-pad)') : 'var(--cell-px)', paddingRight: i === props.columns.length - 1 ? 'var(--edge-pad)' : 'var(--cell-px)' } }\n</script>\n<template>\n <div :style=\"frame\" :data-size=\"bordered ? tier : undefined\">\n <table :data-size=\"tier\" :style=\"{ width: '100%', borderCollapse: 'collapse', tableLayout: resizable ? 'fixed' : 'auto', ...(bordered ? null : style) }\">\n <thead>\n <tr :style=\"{ background: 'var(--bg-table-header)', borderTop: (headerBorder && !flushTop) ? '1px solid var(--border-subtle)' : 'none', borderBottom: headerBorder ? (pinInfo && pinInfo.strong ? '1px solid var(--border-control)' : '1px solid var(--border-subtle)') : 'none', height: 'var(--bar-h)' }\">\n <th v-for=\"(c, i) in columns\" :key=\"c.key\" :ref=\"el => { thEls[c.key] = el }\" :aria-sort=\"c.sortable ? (sortedDir(c) === 'asc' ? 'ascending' : sortedDir(c) === 'desc' ? 'descending' : 'none') : undefined\"\n @mouseenter=\"c.sortable && (hotTh = c.key)\" @mouseleave=\"c.sortable && (hotTh = null)\" @focusin=\"c.sortable && (hotTh = c.key)\" @focusout=\"c.sortable && (hotTh = null)\"\n :style=\"{ position: 'relative', textAlign: c.align || 'left', ...cellPad(i), width: widthOf(c), fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-semibold)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: sortedDir(c) ? 'var(--text-heading)' : 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }\">\n <span v-if=\"selectable && i === 0\" :style=\"checkSlot\"><Checkbox v-for=\"h in [head()]\" :key=\"0\" :checked=\"h.all\" :indeterminate=\"h.ind\" :disabled=\"h.none\" @change=\"toggleAll\" /></span>\n <span v-if=\"c.sortable\" :style=\"{ display: 'flex', alignItems: 'center', gap: '4px' }\"><span :style=\"{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, ...(c.align === 'right' ? { marginLeft: 'auto' } : c.align === 'center' ? { margin: '0 auto' } : null), ...(typeof ((columnWidths || localW)[c.key] ?? c.width) === 'number' ? { maxWidth: (((columnWidths || localW)[c.key] ?? c.width) - 32) + 'px' } : null) }\">{{ c.label }}</span><IconButton :icon=\"sortedDir(c) === 'desc' ? 'arrow-down' : 'arrow-up'\" size=\"sm\" muted :label=\"sortLabel(sortedDir(c))\" :title=\"sortLabel(sortedDir(c))\" :style=\"{ opacity: (sortedDir(c) || hotTh === c.key) ? 1 : 0, ...(c.align === 'right' ? null : { marginLeft: 'auto' }) }\" @click=\"cycleSort(c.key)\" /></span>\n <template v-else>{{ c.label }}</template>\n <span v-if=\"resizable && i < columns.length - 1\" aria-hidden=\"true\" @mousedown=\"e => startDrag(e, c)\" @mouseenter=\"hot = c.key\" @mouseleave=\"hot = null\"\n style=\"position:absolute;top:0;right:-5px;width:11px;height:100%;cursor:col-resize;display:flex;align-items:center;justify-content:center;z-index:1\">\n <span :style=\"{ width: drag === c.key ? '2px' : '1px', height: '50%', background: (drag === c.key || hot === c.key) ? 'var(--action-primary)' : 'var(--border-split)', borderRadius: '1px', transition: 'var(--t-hover)' }\"></span>\n </span>\n </th>\n </tr>\n </thead>\n <tbody>\n <template v-for=\"(item, pos) in view\" :key=\"item.key\">\n <tr v-if=\"pinInfo && pinInfo.style === 'labels' && pinInfo.grouped && pinInfo.top && pos === 0\"><td :colspan=\"columns.length || 1\" :style=\"GROUP_CELL\"><span style=\"display:flex;align-items:center;gap:8px\">{{ L.pinned }} · {{ pinInfo.n }}<button type=\"button\" data-link=\"\" @click=\"toggleAll(false)\" :style=\"CLEAR_BTN\">{{ L.clear }}</button></span></td></tr>\n <tr v-else-if=\"pinInfo && pinInfo.style === 'labels' && pinInfo.grouped && pos === pinInfo.rest\"><td :colspan=\"columns.length || 1\" :style=\"GROUP_CELL\">{{ L.others }} · {{ pinInfo.others }}</td></tr>\n <tr v-else-if=\"pinInfo && pinInfo.style === 'labels' && pinInfo.grouped && pos === pinInfo.unStart\"><td :colspan=\"columns.length || 1\" :style=\"GROUP_CELL\">{{ L.unavailable }} · {{ pinInfo.un }}</td></tr>\n <tr data-row=\"\" :aria-level=\"tree ? item.level + 1 : undefined\" :aria-expanded=\"item.kids ? item.open : undefined\" :style=\"{ ...(isDis(item.r, item.ri) ? { '--row-wash': 'initial' } : null), height: 'var(--bar-h)', background: pinInfo && pinInfo.sel.has(item.ri) ? 'var(--row-wash, var(--bg-inset))' : 'var(--row-wash, transparent)', transition: 'var(--t-hover)', borderBottom: pinInfo && ((pinInfo.rest > 0 && pos === pinInfo.rest - 1) || (pinInfo.unStart > 0 && pos === pinInfo.unStart - 1)) ? '1px solid var(--border-control)' : bordered && pos === view.length - 1 ? 'none' : '1px solid var(--border-subtle)' }\">\n <template v-for=\"{ r, ri } in [item]\" :key=\"'r'\">\n <td v-for=\"(c, i) in columns\" :key=\"c.key\" :style=\"{ ...cellPad(i), ...(c.twoLine ? { paddingTop: 'var(--title-cell-py)', paddingBottom: 'var(--title-cell-py)', maxWidth: 0 } : null), textAlign: c.align || 'left', whiteSpace: 'nowrap', ...(resizable ? { overflow: 'hidden', textOverflow: 'ellipsis' } : null), ...cellFont(c) }\"><span v-if=\"selectable && i === 0\" :style=\"checkSlot\" :title=\"isDis(r, ri) && disabledReason ? disabledReason(r, ri) : undefined\"><Checkbox :checked=\"selected.includes(ri)\" :disabled=\"isDis(r, ri)\" @change=\"v => emit('toggle', ri, v)\" /></span><div v-if=\"c.twoLine\" :style=\"{ display: 'flex', alignItems: 'center', minWidth: px(c.minWidth ?? 180), ...(treeCol === c ? { paddingLeft: (item.level * TREE_INDENT) + 'px' } : null) }\"><span v-if=\"treeCol === c\" :style=\"{ width: TREE_TOGGLE + 'px', flexShrink: 0, marginRight: (TREE_INDENT - TREE_TOGGLE) + 'px', display: 'inline-flex' }\"><button v-if=\"item.kids\" type=\"button\" :aria-expanded=\"item.open\" :aria-label=\"item.open ? L.collapse : L.expand\" :title=\"item.open ? L.collapse : L.expand\" @click.stop=\"toggleRow(item)\" @mouseenter=\"hotToggle = item.key\" @mouseleave=\"hotToggle = null\" :style=\"{ width: TREE_TOGGLE + 'px', height: TREE_TOGGLE + 'px', padding: 0, border: 'none', borderRadius: 'var(--radius-sm)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: hotToggle === item.key ? 'var(--overlay-hover)' : 'transparent', color: hotToggle === item.key ? 'var(--text-body)' : 'var(--text-muted)', transition: 'var(--t-hover)' }\"><Icon name=\"chevron-right\" :size=\"16\" :style=\"{ width: 'var(--control-icon)', height: 'var(--control-icon)', transform: item.open ? 'rotate(90deg)' : 'none', transition: 'transform .15s ease' }\" /></button></span><div style=\"display:flex;align-items:center;gap:var(--title-cell-tile-gap);flex:1;min-width:0\"><template v-for=\"t in [tl(c, r, ri)]\" :key=\"0\"><span v-if=\"t.icon\" :style=\"{ display: 'inline-flex', flexShrink: 0, cursor: canOpen ? 'pointer' : undefined }\" @click=\"e => canOpen && openRow(r, ri, e)\"><IconTile :icon=\"t.icon\" :hue=\"t.hue || 'neutral'\" kind=\"row\" :style=\"T_TILE\" :icon-style=\"T_ICON\" /></span><div style=\"display:flex;flex-direction:column;gap:var(--title-cell-gap);flex:1;min-width:0\"><div style=\"display:flex;align-items:center;gap:4px;min-width:0\"><span v-if=\"canOpen\" role=\"button\" tabindex=\"0\" :style=\"{ ...T_TITLE, cursor: 'pointer' }\" @click=\"e => openRow(r, ri, e)\" @keydown=\"e => openKey(r, ri, e)\">{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</span><span v-else :style=\"T_TITLE\">{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</span><svg v-if=\"t.mark === 'favorite'\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linejoin=\"round\" role=\"img\" aria-label=\"Favorite\" style=\"display:block;flex-shrink:0;color:var(--warning)\"><path :d=\"STAR_FILLED\" /></svg><span v-else-if=\"t.mark\" role=\"img\" :aria-label=\"t.mark.label\" :style=\"{ display: 'inline-flex', flexShrink: 0, color: t.mark.color || 'var(--text-muted)' }\"><Icon :name=\"t.mark.icon\" :size=\"14\" /></span></div><span v-if=\"t.desc\" :style=\"T_DESC\">{{ t.desc }}</span></div><IconButton v-if=\"t.more\" icon=\"ellipsis\" size=\"sm\" muted :label=\"L.more\" :title=\"L.more\" @click=\"e => more(r, ri, e)\" /></template></div></div><code v-else-if=\"c.code && (typeof r[c.key] === 'string' || typeof r[c.key] === 'number') && r[c.key] !== ''\" :style=\"codeChip\">{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</code><template v-else>{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</template></td>\n </template>\n </tr>\n </template>\n <tr v-if=\"rows.length === 0 && $slots.empty\"><td :colspan=\"colCount\" style=\"padding:0\"><slot name=\"empty\" /></td></tr>\n </tbody>\n </table>\n </div>\n</template>\n"});