@polderlabs/openkan 0.4.0

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 (114) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/agents/openkan.md +254 -0
  5. package/bin/install-agent.mjs +63 -0
  6. package/bin/ok.mjs +17 -0
  7. package/bin/openkan.mjs +10 -0
  8. package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
  9. package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
  10. package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
  11. package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
  12. package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
  13. package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
  14. package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
  15. package/dist/agents/openkan.md +254 -0
  16. package/dist/bin/install-agent.mjs +76 -0
  17. package/dist/bin/ok-install.js +58 -0
  18. package/dist/bin/ok.js +138 -0
  19. package/dist/bin/openkan.js +804 -0
  20. package/dist/commands/organize.md +15 -0
  21. package/dist/kanban/agent-profile.js +8 -0
  22. package/dist/kanban/archive.js +49 -0
  23. package/dist/kanban/bizar.js +242 -0
  24. package/dist/kanban/board.js +367 -0
  25. package/dist/kanban/bulk.js +139 -0
  26. package/dist/kanban/changelog.js +186 -0
  27. package/dist/kanban/chat.js +1280 -0
  28. package/dist/kanban/claude-state.js +974 -0
  29. package/dist/kanban/comments.js +80 -0
  30. package/dist/kanban/docs.js +144 -0
  31. package/dist/kanban/fs.js +163 -0
  32. package/dist/kanban/git.js +196 -0
  33. package/dist/kanban/images.js +140 -0
  34. package/dist/kanban/import.js +295 -0
  35. package/dist/kanban/inputs.js +94 -0
  36. package/dist/kanban/insights.js +140 -0
  37. package/dist/kanban/io.js +75 -0
  38. package/dist/kanban/mdx-render.js +348 -0
  39. package/dist/kanban/mdx.js +231 -0
  40. package/dist/kanban/projects.js +545 -0
  41. package/dist/kanban/search.js +121 -0
  42. package/dist/kanban/server.js +3296 -0
  43. package/dist/kanban/tags.js +124 -0
  44. package/dist/kanban/template.js +145 -0
  45. package/dist/kanban/tsx-sandbox.js +187 -0
  46. package/dist/kanban/watcher.js +270 -0
  47. package/dist/ok/commands/goal.js +65 -0
  48. package/dist/ok/commands/index.js +87 -0
  49. package/dist/ok/commands/init.js +15 -0
  50. package/dist/ok/commands/plan.js +155 -0
  51. package/dist/ok/commands/prd.js +202 -0
  52. package/dist/ok/commands/progress.js +31 -0
  53. package/dist/ok/commands/task.js +377 -0
  54. package/dist/ok/ids.js +98 -0
  55. package/dist/ok/lock.js +156 -0
  56. package/dist/ok/migrate.js +197 -0
  57. package/dist/ok/schemas.js +402 -0
  58. package/dist/ok/storage.js +222 -0
  59. package/dist/skills/openkan/SKILL.md +111 -0
  60. package/dist/skills/openkan/agents/openai.yaml +4 -0
  61. package/dist/skills/openkan/examples/simple-task.mdx +34 -0
  62. package/dist/skills/openkan/examples/with-ask.mdx +32 -0
  63. package/dist/skills/openkan/examples/with-choice.mdx +51 -0
  64. package/dist/skills/openkan/examples/with-preview.mdx +54 -0
  65. package/dist/skills/openkan/references/api.md +169 -0
  66. package/dist/skills/openkan/templates/task.mdx +46 -0
  67. package/dist/web/api.js +257 -0
  68. package/dist/web/app.js +4251 -0
  69. package/dist/web/bizar.js +39 -0
  70. package/dist/web/brand/agent-activity-sprite.svg +1 -0
  71. package/dist/web/brand/banner-docs.svg +24 -0
  72. package/dist/web/brand/banner.svg +32 -0
  73. package/dist/web/brand/empty-sessions.svg +17 -0
  74. package/dist/web/brand/empty-tasks.svg +17 -0
  75. package/dist/web/brand/favicon.svg +9 -0
  76. package/dist/web/brand/infinity-loader-animated.svg +220 -0
  77. package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
  78. package/dist/web/brand/logo-wordmark.svg +10 -0
  79. package/dist/web/brand/logo.svg +9 -0
  80. package/dist/web/brand/pixel-infinity-track.svg +1 -0
  81. package/dist/web/brand/social-card.svg +26 -0
  82. package/dist/web/changelog-view.js +456 -0
  83. package/dist/web/charts.js +269 -0
  84. package/dist/web/chat-sidebar.js +2397 -0
  85. package/dist/web/chat-status-motion.js +154 -0
  86. package/dist/web/claude-pane.js +820 -0
  87. package/dist/web/command-palette.js +381 -0
  88. package/dist/web/contributors-view.js +317 -0
  89. package/dist/web/cross-tab.js +102 -0
  90. package/dist/web/docs-view.js +168 -0
  91. package/dist/web/experience.css +165 -0
  92. package/dist/web/goals-view.js +45 -0
  93. package/dist/web/home-view.js +113 -0
  94. package/dist/web/images.js +311 -0
  95. package/dist/web/index.html +485 -0
  96. package/dist/web/insights.js +217 -0
  97. package/dist/web/keyboard.js +446 -0
  98. package/dist/web/mdx-viewer.js +600 -0
  99. package/dist/web/path-picker.js +787 -0
  100. package/dist/web/preview-frame.html +187 -0
  101. package/dist/web/settings.js +582 -0
  102. package/dist/web/style.css +8545 -0
  103. package/dist/web/task-view.js +1759 -0
  104. package/dist/web/vendor/gsap.min.js +11 -0
  105. package/dist/web/workspace.css +1513 -0
  106. package/package.json +71 -0
  107. package/skills/openkan/SKILL.md +111 -0
  108. package/skills/openkan/agents/openai.yaml +4 -0
  109. package/skills/openkan/examples/simple-task.mdx +34 -0
  110. package/skills/openkan/examples/with-ask.mdx +32 -0
  111. package/skills/openkan/examples/with-choice.mdx +51 -0
  112. package/skills/openkan/examples/with-preview.mdx +54 -0
  113. package/skills/openkan/references/api.md +169 -0
  114. package/skills/openkan/templates/task.mdx +46 -0
@@ -0,0 +1,124 @@
1
+ // OpenKan — tagging, categorization, priority, and effort inference.
2
+ // ─── Tag keyword map (in priority order — first match wins) ──────────────────
3
+ const TAG_KEYWORDS = [
4
+ { keywords: ["fix", "bug", "broken", "regression", "crash", "outage"], tag: "bug" },
5
+ { keywords: ["feature", "add support", "implement"], tag: "feature" },
6
+ { keywords: ["refactor", "cleanup", "clean up"], tag: "refactor" },
7
+ { keywords: ["doc", "docs", "documentation", "readme"], tag: "docs" },
8
+ { keywords: ["test", "spec", "e2e", "coverage"], tag: "test" },
9
+ { keywords: ["perf", "performance", "slow", "optimi"], tag: "perf" },
10
+ { keywords: ["security", "vuln", "cve", "xss"], tag: "security" },
11
+ { keywords: ["a11y", "accessibility"], tag: "a11y" },
12
+ { keywords: ["ux", "design"], tag: "ux" },
13
+ { keywords: ["i18n", "l10n", "locale"], tag: "i18n" },
14
+ { keywords: ["migration", "migrate"], tag: "migration" },
15
+ { keywords: ["deprecat"], tag: "deprecation" },
16
+ ];
17
+ // ─── Category rules (first match wins) ───────────────────────────────────────
18
+ const CATEGORY_PATTERNS = [
19
+ { pattern: /\.tsx|\.jsx|\.css|\.scss|component|page\/|ui\/|button|modal|style(?!s)|css[\/-]/i, category: "frontend" },
20
+ { pattern: /\.py|\.go|\.ts\b(?!x)|\/api|endpoint|route|handler|server|db|query|sql\b/i, category: "backend" },
21
+ { pattern: /k8s|kubernetes|docker|terraform|tf\b|helm|deploy|ci|cd|pipeline/i, category: "infra" },
22
+ { pattern: /readme|changelog|\bdocs\/|documentation|comment|jsdoc/i, category: "docs" },
23
+ { pattern: /playwright|vitest|jest|mocha|cypress|e2e|unit test/i, category: "test" },
24
+ { pattern: /figma|sketch|wireframe|mockup|style guide/i, category: "design" },
25
+ { pattern: /sql\b|postgres|mysql|migration|schema|index|query\b/i, category: "data" },
26
+ { pattern: /xss|csp|cve|auth|oauth|jwt|vuln|rbac/i, category: "security" },
27
+ ];
28
+ // ─── Priority rules (first match wins) ───────────────────────────────────────
29
+ const PRIORITY_PATTERNS = [
30
+ { pattern: /P0|urgent|asap|critical|blocker|outage/i, priority: "urgent" },
31
+ { pattern: /P1|high priority|high\b(?! priority)|important/i, priority: "high" },
32
+ { pattern: /P2|low priority/i, priority: "low" },
33
+ { pattern: /P3|backlog/i, priority: "normal" },
34
+ ];
35
+ // ─── Effort rules (first match wins; null if no signal) ─────────────────────
36
+ const EFFORT_PATTERNS = [
37
+ { pattern: /\b(?:xs|trivial|1[\s_-]?line|one[\s_-]?liner|typo)\b/i, effort: "xs" },
38
+ { pattern: /\b(?:small|quick|minutes?)\b/i, effort: "s" },
39
+ { pattern: /\b(?:medium|afternoon|half[\s_-]?day)\b/i, effort: "m" },
40
+ // xl pattern first (tries multi-week before week in the l rule below)
41
+ { pattern: /\b(?:xl|epic|multi[\s_-]?week|quarter)\b/i, effort: "xl" },
42
+ // "week" must NOT be preceded by a letter/digit/hyphen (prevents matching inside "multi-week")
43
+ { pattern: /\b(?:large|big|(?<![a-zA-Z0-9-])week)\b/i, effort: "l" },
44
+ ];
45
+ // ─── Explicit tag extraction ─────────────────────────────────────────────────
46
+ const HASHTAG_PATTERN = /#([a-zA-Z][a-zA-Z0-9_-]*)/g;
47
+ /** Extract #word tokens from a string. Returns lowercased tag names without the #. */
48
+ function extractExplicitTags(text) {
49
+ const tags = [];
50
+ let match;
51
+ const re = new RegExp(HASHTAG_PATTERN.source, "g");
52
+ while ((match = re.exec(text)) !== null) {
53
+ tags.push(match[1].toLowerCase());
54
+ }
55
+ return tags;
56
+ }
57
+ // ─── Core inference ───────────────────────────────────────────────────────────
58
+ function matchFirst(text, rules) {
59
+ for (const rule of rules) {
60
+ if (rule.pattern.test(text))
61
+ return rule.value;
62
+ }
63
+ return null;
64
+ }
65
+ function deriveTags(title, description) {
66
+ const combined = `${title} ${description}`.toLowerCase();
67
+ const found = [];
68
+ for (const { keywords, tag } of TAG_KEYWORDS) {
69
+ if (keywords.some(kw => combined.includes(kw))) {
70
+ found.push(tag);
71
+ }
72
+ }
73
+ return found;
74
+ }
75
+ function deriveCategory(title, description) {
76
+ const combined = `${title} ${description}`;
77
+ const matched = matchFirst(combined, CATEGORY_PATTERNS.map(r => ({ pattern: r.pattern, value: r.category })));
78
+ return matched ?? "task";
79
+ }
80
+ function derivePriority(title, description) {
81
+ const combined = `${title} ${description}`;
82
+ const matched = matchFirst(combined, PRIORITY_PATTERNS.map(r => ({ pattern: r.pattern, value: r.priority })));
83
+ return matched ?? "normal";
84
+ }
85
+ function deriveEffort(title, description) {
86
+ const combined = `${title} ${description}`;
87
+ const matched = matchFirst(combined, EFFORT_PATTERNS.map(r => ({ pattern: r.pattern, value: r.effort })));
88
+ return matched ?? null;
89
+ }
90
+ // ─── Public API ───────────────────────────────────────────────────────────────
91
+ /**
92
+ * Infer tags, category, priority, and effort from a task's title and description.
93
+ * Tags are deduplicated and sorted: keyword-derived tags first (in table order),
94
+ * then explicit #tag tokens (alphabetical). The category is always included as a tag.
95
+ */
96
+ export function extractMetadata(input) {
97
+ const title = input.title ?? "";
98
+ const description = input.description ?? "";
99
+ const explicitTags = extractExplicitTags(`${title} ${description}`);
100
+ const keywordTags = deriveTags(title, description);
101
+ // Merge: keyword tags first (preserving table order), then explicit tags alphabetical
102
+ const seen = new Set();
103
+ const tags = [];
104
+ for (const tag of keywordTags) {
105
+ if (!seen.has(tag)) {
106
+ seen.add(tag);
107
+ tags.push(tag);
108
+ }
109
+ }
110
+ for (const tag of [...explicitTags].sort()) {
111
+ if (!seen.has(tag)) {
112
+ seen.add(tag);
113
+ tags.push(tag);
114
+ }
115
+ }
116
+ const category = deriveCategory(title, description);
117
+ // Always include category as a tag
118
+ if (!seen.has(category)) {
119
+ tags.push(category);
120
+ }
121
+ const priority = derivePriority(title, description);
122
+ const effort = deriveEffort(title, description);
123
+ return { tags, category, priority, effort, explicitTags };
124
+ }
@@ -0,0 +1,145 @@
1
+ // OpenKan — canonical task MDX template + helpers.
2
+ // ─── Template ────────────────────────────────────────────────────────────────
3
+ /**
4
+ * The canonical task MDX template.
5
+ * Agents fill this in when creating a new task via kanban_add without a description.
6
+ *
7
+ * The template uses placeholder comments so agents can detect
8
+ * unfilled sections via extractTemplate().
9
+ */
10
+ export const TASK_MDX_TEMPLATE = `---
11
+ title: {/* Task title — replace with a short, descriptive name */}
12
+ column: todo
13
+ order: 0
14
+ state: idle
15
+ status: idle
16
+ agent: {/* Bizar agent ID, e.g. "mike" */}
17
+ model: {/* optional provider/model override */}
18
+ createdAt: {/* ISO timestamp */}
19
+ updatedAt: {/* ISO timestamp */}
20
+ tags: []
21
+ category: task
22
+ priority: normal
23
+ effort: null
24
+ archived: false
25
+ ---
26
+
27
+ # {/\* Task title \*/}
28
+
29
+ {/* Add the task title above, then fill in the sections below. */}
30
+
31
+ ## Goal
32
+
33
+ {/* What does "done" look like? Describe the end state in 1-3 sentences.
34
+ Example: "Users can reset their password via a secure email link." */}
35
+
36
+ ## Context
37
+
38
+ {/* Add background context, links to relevant issues/files, and why this task exists.
39
+ Example: "Related to #42 — the current reset flow has no rate limiting." */}
40
+
41
+ ## Acceptance criteria
42
+
43
+ {/* Replace this checklist with concrete, testable conditions. */}
44
+ - [ ] {/* Criterion 1 */}
45
+ - [ ] {/* Criterion 2 */}
46
+ - [ ] {/* Criterion 3 */}
47
+
48
+ ## Files to touch
49
+
50
+ {/* List specific files/functions/UI components to change. */}
51
+ {/* Example: src/auth/reset-password.ts, web/components/PasswordReset.tsx */}
52
+
53
+ ## Safety
54
+
55
+ {/* Any non-functional requirements: performance, security, backwards compat, rollback plan. */}
56
+ {/* Example: "Database migration must be reversible. Rate-limit to 5 attempts per IP." */}
57
+
58
+ ## Agent progress
59
+
60
+ {/* Agents append timestamped one-liners here as they work.
61
+ Format: "- [YYYY-MM-DD HH:MM:SS] <tool or action description>" */}
62
+
63
+ ## MDX components
64
+
65
+ {/* Available interactive components — embed in the task MDX when needed: */}
66
+
67
+ {/* <Ask question="Your question here" /> */}
68
+ {/* <Choice question="Pick one:" options={[{id:"a",label:"Option A"},{id:"b",label:"Option B"}]} /> */}
69
+ {/* <Input label="Enter value" placeholder="e.g. foo" /> */}
70
+ {/* <Confirm question="Proceed?" /> */}
71
+ {/* <Preview tsx="<button>Click me</button>" /> */}
72
+ `;
73
+ /**
74
+ * Render the task template with field values filled in.
75
+ * Fields not provided retain their placeholder comments.
76
+ */
77
+ export function renderTaskTemplate(fields) {
78
+ const { title, goal = "{/* Describe the goal of this task */}", context = "{/* Add context: why this task exists, links, background */}", acceptance = ["{/* Acceptance criterion */}"], safety = [], tags = [], } = fields;
79
+ const acceptanceLines = acceptance
80
+ .map(c => (c.startsWith("- [ ]") ? c : `- [ ] ${c}`))
81
+ .join("\n");
82
+ const safetyLines = safety.length > 0
83
+ ? safety.map(s => (s.startsWith("-") ? s : `- ${s}`)).join("\n")
84
+ : "{/* Safety considerations (optional) */}";
85
+ return `---
86
+ title: ${title}
87
+ column: todo
88
+ order: 0
89
+ state: idle
90
+ status: idle
91
+ agent:
92
+ model:
93
+ createdAt: ${new Date().toISOString()}
94
+ updatedAt: ${new Date().toISOString()}
95
+ tags: [${tags.map(t => `"${t}"`).join(", ")}]
96
+ category: task
97
+ priority: normal
98
+ effort: null
99
+ archived: false
100
+ ---
101
+
102
+ # ${title}
103
+
104
+ ## Goal
105
+
106
+ ${goal}
107
+
108
+ ## Context
109
+
110
+ ${context}
111
+
112
+ ## Acceptance criteria
113
+
114
+ ${acceptanceLines}
115
+
116
+ ## Files to touch
117
+
118
+ {/* List files to change */}
119
+
120
+ ## Safety
121
+
122
+ ${safetyLines}
123
+
124
+ ## Agent progress
125
+
126
+ {/* Agents append progress notes here */}
127
+ `;
128
+ }
129
+ // ─── Placeholder detection ──────────────────────────────────────────────────
130
+ /**
131
+ * Returns true if the MDX still contains unfilled placeholder comments.
132
+ * Used by kanban_add to detect "empty template" state.
133
+ */
134
+ export function extractTemplate(mdx) {
135
+ return /\{\/\*.*?\*\/\}/s.test(mdx);
136
+ }
137
+ // ─── Parse hints (for frontend help panel) ─────────────────────────────────
138
+ /** Hints shown in the frontend template help panel. */
139
+ export const TEMPLATE_PARSE_HINTS = [
140
+ "<Ask> — Ask the user a single structured question; blocks until answered",
141
+ "<Choice> — Present radio options; user picks one",
142
+ "<Input> — Free-form text input from the user",
143
+ "<Confirm> — Yes/no confirmation from the user",
144
+ "<Preview> — Inline interactive TSX preview (live in the browser)",
145
+ ];
@@ -0,0 +1,187 @@
1
+ // OpenKan — TSX compile + sandbox iframe HTML builder.
2
+ // ─── Hook detection (compile-time safety) ────────────────────────────────────
3
+ const HOOK_PATTERN = /\buse[A-Z][a-zA-Z0-9_]*/;
4
+ const BUILTIN_FUNCTIONS = new Set([
5
+ "Button", "Card", "Row", "Column", "Text", "Heading", "Image", "ColorSwatch", "Code",
6
+ "h", "render", "respond",
7
+ ]);
8
+ function checkHooks(source) {
9
+ // Find all useXXX identifiers that aren't in BUILTIN_FUNCTIONS
10
+ const matches = source.matchAll(/\buse[A-Z][a-zA-Z0-9_]*/g);
11
+ for (const m of matches) {
12
+ const name = m[0];
13
+ if (!BUILTIN_FUNCTIONS.has(name)) {
14
+ return `Hooks (${name}) are not supported in sandbox TSX. Use plain functions instead.`;
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+ // ─── Compile TSX ─────────────────────────────────────────────────────────────
20
+ const MAX_BYTES_DEFAULT = 32768;
21
+ export async function compileTsx(source, opts) {
22
+ const maxBytes = opts?.maxBytes ?? MAX_BYTES_DEFAULT;
23
+ if (Buffer.byteLength(source, "utf-8") > maxBytes) {
24
+ return { error: `TSX source exceeds ${maxBytes} bytes limit.` };
25
+ }
26
+ const hookError = checkHooks(source);
27
+ if (hookError)
28
+ return { error: hookError };
29
+ try {
30
+ // Lazy import sucrase
31
+ const { default: sucrase } = await import("sucrase");
32
+ const result = sucrase.transform(source, {
33
+ transforms: ["typescript", "jsx"],
34
+ production: false,
35
+ // Map JSX to the runtime's h() function so we don't need React.
36
+ jsxPragma: "h",
37
+ jsxFragmentPragma: "Fragment",
38
+ });
39
+ let js = result.code;
40
+ // Defensive: if any React.createElement leaked through, replace it.
41
+ js = js.replace(/React\.createElement\b/g, "h")
42
+ .replace(/React\.Fragment\b/g, "Fragment");
43
+ if (Buffer.byteLength(js, "utf-8") > maxBytes * 2) {
44
+ return { error: `Compiled JS exceeds ${maxBytes * 2} bytes after transpilation.` };
45
+ }
46
+ return { js };
47
+ }
48
+ catch (e) {
49
+ return { error: `Compile error: ${e?.message ?? String(e)}` };
50
+ }
51
+ }
52
+ // ─── BUILTIN_LIBRARY ─────────────────────────────────────────────────────────
53
+ const BUILTIN_LIBRARY = `
54
+ const Button = (props) => h('button', { onClick: props.onClick, class: 'ok-btn', type: 'button' }, props.label ?? props.children);
55
+ const Card = (props) => h('div', { class: 'ok-card' }, props.children);
56
+ const Row = (props) => h('div', { class: 'ok-row', style: 'display:flex;gap:8px;align-items:center' }, props.children);
57
+ const Column = (props) => h('div', { class: 'ok-col', style: 'display:flex;flex-direction:column;gap:8px' }, props.children);
58
+ const Text = (props) => h('span', { class: 'ok-text' }, props.children ?? props.text);
59
+ const Heading = (props) => h('h3', { class: 'ok-heading' }, props.children ?? props.text);
60
+ const Image = (props) => h('img', { class: 'ok-img', src: props.src, alt: props.alt ?? '' });
61
+ const ColorSwatch = (props) => h('div', { class: 'ok-swatch', style: 'width:32px;height:32px;border-radius:4px;background:' + (props.color || '#ccc') });
62
+ const Code = (props) => h('pre', { class: 'ok-code' }, h('code', {}, props.children ?? props.text));
63
+ `;
64
+ // ─── Runtime (tiny React-like, no hooks) ─────────────────────────────────────
65
+ const RUNTIME = `
66
+ // Minimal h() and render() runtime — no hooks, no reconciliation.
67
+ // h(type, props, ...children) => vnode
68
+ // render(vnode, root) => void
69
+ function Fragment(props) { return props.children; }
70
+ function h(type, props, ...children) {
71
+ return { type, props: props || {}, children: children.flat().filter(c => c != null && c !== false && c !== '') };
72
+ }
73
+ function render(vnode, root) {
74
+ if (typeof vnode === 'string' || typeof vnode === 'number') {
75
+ root.textContent = String(vnode);
76
+ return;
77
+ }
78
+ if (!vnode || !vnode.type) { root.textContent = ''; return; }
79
+ const el = document.createElement(vnode.type);
80
+ const { props, children } = vnode;
81
+ for (const [k, v] of Object.entries(props || {})) {
82
+ if (k === 'style' && typeof v === 'string') {
83
+ v.split(';').forEach(s => { const [pk, pv] = s.split(':'); if (pk && pv) el.style[pk.trim()] = pv.trim(); });
84
+ } else if (k.startsWith('on') && typeof v === 'function') {
85
+ el.addEventListener(k.slice(2).toLowerCase(), v);
86
+ } else if (k !== 'class') {
87
+ el.setAttribute(k, String(v));
88
+ }
89
+ }
90
+ if (props && props.class) el.className = props.class;
91
+ const childRoot = document.createDocumentFragment();
92
+ for (const child of children || []) {
93
+ render(child, childRoot);
94
+ el.appendChild(childRoot);
95
+ }
96
+ root.appendChild(el);
97
+ }
98
+ function respond(value) {
99
+ parent.postMessage({ type: 'openkan:respond', version: 1, value }, '*');
100
+ }
101
+ `;
102
+ // ─── Sandbox HTML builder ─────────────────────────────────────────────────────
103
+ function esc(s) {
104
+ return s
105
+ .replace(/<\/script>/gi, "<\\/script>")
106
+ .replace(/<\/iframe>/gi, "<\\/iframe>")
107
+ .replace(/`/g, "\\`")
108
+ .replace(/\\/g, "\\\\");
109
+ }
110
+ export function buildSandboxHtml(js, propsJson) {
111
+ const escapedJs = esc(js);
112
+ const escapedProps = esc(propsJson);
113
+ // Wrap user code: the user's component is assigned to window
114
+ const wrappedJs = `\
115
+ const App = (function() {
116
+ ${escapedJs}
117
+ })();
118
+ window.render = function(props) {
119
+ const root = document.getElementById('root');
120
+ root.innerHTML = '';
121
+ const v = App(typeof props === 'string' ? JSON.parse(props) : props);
122
+ render(v, root);
123
+ };
124
+ window.addEventListener('message', function(e) {
125
+ if (e.data && e.data.type === 'openkan:props' && e.data.version === 1) {
126
+ try {
127
+ window.render(e.data.props);
128
+ } catch(err) {
129
+ document.body.textContent = 'Render error: ' + err.message;
130
+ }
131
+ }
132
+ });
133
+ `;
134
+ return `<!doctype html>
135
+ <html>
136
+ <head>
137
+ <meta charset="utf-8">
138
+ <title>preview</title>
139
+ <style>
140
+ *{box-sizing:border-box;margin:0;padding:0}
141
+ body{font-family:system-ui,sans-serif;padding:16px;background:#fafafa}
142
+ .ok-btn{background:#2563eb;color:#fff;border:none;padding:8px 16px;border-radius:4px;cursor:pointer;font-size:14px}
143
+ .ok-btn:hover{background:#1d4ed8}
144
+ .ok-card{border:1px solid #e5e7eb;border-radius:8px;padding:16px;background:#fff}
145
+ .ok-row{display:flex;gap:8px;align-items:center}
146
+ .ok-col{display:flex;flex-direction:column;gap:8px}
147
+ .ok-text{font-size:14px;color:#374151}
148
+ .ok-heading{font-size:18px;font-weight:600;color:#111}
149
+ .ok-img{max-width:100%;border-radius:4px}
150
+ .ok-swatch{border-radius:4px}
151
+ .ok-code{background:#1f2937;color:#f9fafb;padding:12px;border-radius:6px;overflow-x:auto;font-size:13px}
152
+ </style>
153
+ <script>${BUILTIN_LIBRARY}</script>
154
+ <script>${RUNTIME}</script>
155
+ <script>
156
+ window.ok = { Button, Card, Row, Column, Text, Heading, Image, ColorSwatch, Code, respond, h, render };
157
+ try {
158
+ ${wrappedJs}
159
+ } catch(e) {
160
+ document.body.textContent = 'Preview error: ' + e.message;
161
+ }
162
+ </script>
163
+ </head>
164
+ <body>
165
+ <div id="root"></div>
166
+ <script>
167
+ // Initial props from server
168
+ try { window.render && window.render(${escapedProps || '{}'}); } catch(e) {}
169
+ </script>
170
+ </body>
171
+ </html>`;
172
+ }
173
+ // ─── Full preview pipeline ─────────────────────────────────────────────────────
174
+ export async function buildPreview(source, props) {
175
+ const compileResult = await compileTsx(source);
176
+ if (compileResult.error)
177
+ return { error: compileResult.error };
178
+ const js = compileResult.js;
179
+ const propsJson = JSON.stringify(props ?? {});
180
+ try {
181
+ const sandboxHtml = buildSandboxHtml(js, propsJson);
182
+ return { js, sandboxHtml };
183
+ }
184
+ catch (e) {
185
+ return { error: `Sandbox error: ${e?.message ?? String(e)}` };
186
+ }
187
+ }