@muten/shadcn 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/globals.css CHANGED
@@ -37,6 +37,14 @@
37
37
  /* shadcn's base: every border/outline defaults to the token (so a bare `border` is subtle, not currentColor). */
38
38
  @layer base {
39
39
  * { border-color: var(--color-border); outline-color: color-mix(in srgb, var(--color-ring) 50%, transparent); }
40
+
41
+ /* Fully themed scrollbars (track + thumb), so the OS default never clashes with the theme. */
42
+ * { scrollbar-width: thin; scrollbar-color: var(--color-border) transparent; }
43
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
44
+ ::-webkit-scrollbar-track { background: color-mix(in srgb, var(--color-muted) 60%, transparent); border-radius: 9999px; }
45
+ ::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 9999px; border: 2px solid transparent; background-clip: padding-box; }
46
+ ::-webkit-scrollbar-thumb:hover { background: var(--color-muted-foreground); background-clip: padding-box; }
47
+ ::-webkit-scrollbar-corner { background: transparent; }
40
48
  }
41
49
 
42
50
  @layer components {
@@ -46,11 +54,13 @@
46
54
  .btn-destructive { @apply bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90; }
47
55
  .btn-outline { @apply border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground; }
48
56
  .btn-secondary { @apply bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80; }
49
- .btn-ghost { @apply hover:bg-accent hover:text-accent-foreground; }
57
+ .btn-ghost { @apply text-foreground hover:bg-accent hover:text-accent-foreground; }
50
58
  .btn-link { @apply text-primary underline-offset-4 hover:underline; }
51
59
  .btn-sm { @apply h-8 rounded-md px-3 text-xs; }
52
60
  .btn-lg { @apply h-10 rounded-md px-8; }
53
- .btn-icon { @apply h-9 w-9 px-0; }
61
+ .btn-icon { @apply h-9 w-9 shrink-0 px-0; }
62
+ /* shadcn sizes any icon inside a button (its [&_svg]:size-4); so `Button { Icon } class("btn btn-ghost btn-icon")` just works */
63
+ .btn svg, .btn .mu-icon, .btn iconify-icon { width: 1rem; height: 1rem; flex-shrink: 0; }
54
64
 
55
65
  /* ---- Form controls ---- */
56
66
  .input { @apply flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none transition-[color,box-shadow] duration-200 placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50; }
@@ -72,6 +82,11 @@
72
82
  .badge-secondary { @apply border-transparent bg-secondary text-secondary-foreground; }
73
83
  .badge-destructive { @apply border-transparent bg-destructive text-destructive-foreground; }
74
84
  .badge-outline { @apply text-foreground; }
85
+ /* Notification count pill (rounded-full, for an unseen counter) + a bare status dot. Default primary;
86
+ add .badge-count-destructive for a red alert count. */
87
+ .badge-count { @apply inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold leading-none text-primary-foreground; }
88
+ .badge-count-destructive { @apply bg-destructive text-white; }
89
+ .badge-dot { @apply inline-block size-2 shrink-0 rounded-full bg-primary; }
75
90
 
76
91
  /* ---- Alert ---- */
77
92
  .alert { @apply relative w-full rounded-lg border px-4 py-3 text-sm flex flex-row gap-3 items-start; }
@@ -354,6 +369,133 @@
354
369
  .context-menu-open { @apply opacity-100 visible; }
355
370
  .context-item { @apply flex rounded-sm px-2 py-1.5 text-sm text-left outline-none transition-colors duration-200 hover:bg-accent hover:text-accent-foreground; }
356
371
 
372
+ /* ---- NumberField / Stepper (Custom; host builds [-] input[number] [+]) ---- */
373
+ .number-field { @apply inline-flex flex-row h-9 items-center rounded-md border border-input bg-transparent shadow-sm overflow-hidden outline-none transition-[color,box-shadow] duration-200 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50; }
374
+ .number-field-btn { @apply flex size-8 shrink-0 items-center justify-center text-base text-muted-foreground outline-none transition-colors duration-200 hover:bg-accent hover:text-foreground; }
375
+ .number-field-input { @apply h-full w-14 min-w-0 border-0 bg-transparent text-center text-sm outline-none; -moz-appearance: textfield; }
376
+ .number-field-input::-webkit-inner-spin-button, .number-field-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
377
+
378
+ /* ---- Segmented Control (single-select toolbar; like Tabs but for a toolbar) ---- */
379
+ .segmented { @apply inline-flex flex-row items-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground; }
380
+ .segmented-item { @apply inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap outline-none transition-all duration-200 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50; }
381
+ .segmented-item-active { @apply bg-background text-foreground shadow-sm; }
382
+
383
+ /* ---- File Input / Dropzone (Custom; click or drag-drop) ---- */
384
+ .dropzone { @apply flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-input p-8 text-center text-sm text-muted-foreground cursor-pointer transition-colors duration-200 hover:border-ring hover:bg-accent/30; }
385
+ .dropzone-over { @apply border-ring bg-accent/50; }
386
+ .dropzone-label { @apply pointer-events-none; }
387
+
388
+ /* ---- Color Picker (Custom; swatch + popover with a saturation/value square + hue slider + hex) ---- */
389
+ .color-picker { @apply relative inline-flex; }
390
+ .color-swatch { @apply inline-flex h-9 items-center gap-2 rounded-md border border-input bg-transparent px-2 text-sm shadow-sm outline-none transition-colors duration-200 hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50; }
391
+ .color-swatch-dot { @apply size-5 rounded-sm border border-border shrink-0; }
392
+ .color-popover { @apply absolute top-full left-0 z-50 mt-1 w-56 rounded-md border bg-popover p-3 text-popover-foreground shadow-md flex flex-col gap-3 origin-top opacity-0 invisible scale-95; transition: opacity 150ms ease, transform 150ms ease, visibility 150ms ease; }
393
+ .color-popover-open { @apply opacity-100 visible scale-100; }
394
+ .color-sv { position: relative; width: 100%; height: 8rem; border-radius: calc(var(--radius) - 2px); cursor: crosshair; }
395
+ .color-sv-cursor, .color-hue-cursor { position: absolute; border: 2px solid #fff; border-radius: 9999px; transform: translate(-50%, -50%); box-shadow: 0 0 0 1px rgba(0,0,0,.4); pointer-events: none; }
396
+ .color-sv-cursor { width: 12px; height: 12px; }
397
+ .color-hue { position: relative; height: 0.75rem; border-radius: 9999px; cursor: pointer; background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); }
398
+ .color-hue-cursor { top: 50%; width: 14px; height: 14px; }
399
+ .color-hex { @apply h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm uppercase outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50; }
400
+
401
+ /* ---- Vector Field (paired NumberFields with axis labels) ---- */
402
+ .vector-field { @apply inline-flex flex-row items-center gap-2; }
403
+ .vector-axis { @apply inline-flex flex-row items-center gap-1.5; }
404
+ .vector-axis-label { @apply text-xs font-medium text-muted-foreground w-3 text-center; }
405
+
406
+ /* ---- TreeView (Custom; nested folder/file tree, expandable + selectable) ---- */
407
+ .tree-view { @apply w-full overflow-auto; }
408
+ .tree { @apply flex flex-col text-sm select-none; }
409
+ .tree-row { @apply flex flex-row items-center gap-1.5 rounded-md py-1 pr-2 text-left outline-none transition-colors duration-150 hover:bg-accent; }
410
+ .tree-row-selected { @apply bg-accent text-accent-foreground; }
411
+ .tree-chevron { @apply flex size-4 shrink-0 items-center justify-center text-[10px] text-muted-foreground; }
412
+ .tree-chevron-hidden { @apply opacity-0; }
413
+ .tree-icon { @apply flex size-4 shrink-0 items-center justify-center text-muted-foreground; }
414
+ .tree-icon svg { width: 1rem; height: 1rem; }
415
+ .tree-label { @apply truncate; }
416
+
417
+ /* ---- Toolbar (grouped buttons with separators) ---- */
418
+ .toolbar { @apply inline-flex flex-row items-center gap-1 rounded-md border bg-background p-1 shadow-sm; }
419
+ .toolbar-group { @apply inline-flex flex-row items-center gap-0.5; }
420
+ .toolbar-separator { @apply mx-1 h-5 w-px shrink-0 bg-border; }
421
+
422
+ /* ---- Canvas Viewport (Custom <canvas> with pan/zoom + a muten toolbar slot on top) ---- */
423
+ .canvas-viewport { @apply relative w-full h-80 overflow-hidden rounded-lg border bg-muted; }
424
+ .canvas-host { @apply absolute inset-0; }
425
+ .canvas-host canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; }
426
+ .canvas-host canvas:active { cursor: grabbing; }
427
+ .canvas-overlay { @apply absolute inset-0 p-3 flex flex-col items-start gap-2 pointer-events-none; }
428
+ .canvas-overlay > * { pointer-events: auto; }
429
+
430
+ /* ---- Status Bar ---- */
431
+ .status-bar { @apply flex flex-row items-center gap-4 h-7 px-3 rounded-md border bg-background text-xs text-muted-foreground; }
432
+ .status-item { @apply inline-flex flex-row items-center gap-1; }
433
+ .status-spacer { @apply flex-1; }
434
+
435
+ /* ---- Zoom Control ( - 100% + ) ---- */
436
+ .zoom-control { @apply inline-flex flex-row items-center rounded-md border bg-background shadow-sm overflow-hidden; }
437
+ .zoom-btn { @apply flex size-7 items-center justify-center text-base text-muted-foreground outline-none transition-colors duration-200 hover:bg-accent hover:text-foreground; }
438
+ .zoom-value { @apply min-w-12 px-1 text-center text-sm tabular-nums; }
439
+
440
+ /* ---- Split Button (main action + dropdown caret) ---- */
441
+ .split-button { @apply relative inline-flex flex-row items-stretch; }
442
+ .split-button-main { @apply rounded-r-none; }
443
+ .split-button-caret { @apply rounded-l-none border-l border-l-white/15 px-2; }
444
+
445
+ /* ---- Progress (indeterminate) ---- */
446
+ .progress-indeterminate { @apply relative h-2 w-full overflow-hidden rounded-full bg-primary/20; }
447
+ .progress-indeterminate::after { content: ''; position: absolute; top: 0; bottom: 0; left: -40%; width: 40%; border-radius: 9999px; background: var(--color-primary); animation: progress-indeterminate 1.2s ease-in-out infinite; }
448
+
449
+ /* ---- Tabs (vertical variant - use with TabsTrigger / TabsContent) ---- */
450
+ .tabs-list-vertical { @apply inline-flex flex-col items-stretch gap-1 rounded-lg bg-muted p-1 text-muted-foreground; }
451
+
452
+ /* ---- Tag Input (Custom; chips + a typing field, Enter adds, Backspace/x removes) ---- */
453
+ .tag-input { @apply flex flex-row flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent p-1.5 text-sm shadow-sm min-h-9 outline-none transition-[color,box-shadow] duration-200 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50; }
454
+ .tag-chip { @apply inline-flex flex-row items-center gap-1 rounded-md bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground; }
455
+ .tag-chip-x { @apply cursor-pointer opacity-60 transition-opacity hover:opacity-100; }
456
+ .tag-input-field { @apply flex-1 min-w-20 border-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground; }
457
+
458
+ /* ---- Swatch / Swatch Palette (single-select color grid; the swatch bg is set via style(bg)) ---- */
459
+ .swatch-palette { @apply inline-flex flex-row flex-wrap items-center gap-1.5; }
460
+ .swatch { @apply size-7 rounded-md border border-border shrink-0 outline-none transition-transform duration-150 hover:scale-110 focus-visible:ring-[3px] focus-visible:ring-ring/50; background: var(--bg); }
461
+ .swatch-active { @apply ring-2 ring-ring ring-offset-2 ring-offset-background; }
462
+
463
+ /* ---- Range Slider (Custom; two thumbs, emits lo/hi) ---- */
464
+ .range-slider { @apply relative h-2 w-full rounded-full bg-primary/20 select-none; touch-action: none; }
465
+ .range-slider-fill { @apply absolute top-0 h-full rounded-full bg-primary; }
466
+ .range-slider-thumb { @apply absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow cursor-grab; }
467
+
468
+ /* ---- Sidebar (collapsible nav panel) ---- */
469
+ .sidebar { @apply flex flex-col w-60 shrink-0 border-r bg-card text-card-foreground overflow-hidden; transition: width 200ms ease; }
470
+ .sidebar-collapsed { @apply w-14; }
471
+ .sidebar-header { @apply flex flex-row items-center gap-2 h-12 shrink-0 px-3 border-b font-semibold; }
472
+ .sidebar-group { @apply flex flex-col p-2 gap-0.5; }
473
+ .sidebar-group-label { @apply px-2 py-1 text-xs font-medium text-muted-foreground; }
474
+ .sidebar-item { @apply flex flex-row items-center gap-2 rounded-md px-2 py-1.5 text-sm whitespace-nowrap outline-none transition-colors duration-200 hover:bg-accent; }
475
+ .sidebar-item-active { @apply bg-accent text-accent-foreground font-medium; }
476
+
477
+ /* ---- Sortable List (Custom; drag rows to reorder, e.g. layers) ---- */
478
+ .sortable-list { @apply flex flex-col gap-1; }
479
+ .sortable-row { @apply flex flex-row items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm cursor-grab transition-colors duration-150 hover:bg-accent; }
480
+ .sortable-dragging { @apply opacity-40; }
481
+ .sortable-handle { @apply flex size-4 shrink-0 items-center justify-center text-muted-foreground; }
482
+ .sortable-handle svg { width: 1rem; height: 1rem; }
483
+ .sortable-label { @apply truncate; }
484
+
485
+ /* ---- Inspector / Property Grid (label : value rows, collapsible sections) ---- */
486
+ .inspector { @apply flex flex-col rounded-lg border bg-card text-card-foreground text-sm divide-y; }
487
+ .inspector-section { @apply flex flex-col; }
488
+ .inspector-section-header { @apply flex flex-row items-center justify-between gap-2 px-3 py-2 font-medium outline-none transition-colors duration-200 hover:bg-accent; }
489
+ .inspector-chevron { @apply size-4 shrink-0 text-muted-foreground transition-transform duration-200; }
490
+ .inspector-chevron-open { @apply rotate-180; }
491
+ .inspector-body { @apply flex flex-col gap-2 px-3 pb-3 pt-1; }
492
+ .inspector-row { @apply flex flex-row items-center justify-between gap-3; }
493
+ .inspector-row-label { @apply text-muted-foreground shrink-0; }
494
+
495
+ /* ---- Command Palette (a Command in a top-anchored modal, opened with a hotkey) ---- */
496
+ .command-palette { align-items: flex-start; }
497
+ .command-palette-content { @apply relative z-10 mx-auto w-full max-w-lg; margin-top: 14vh; }
498
+
357
499
  /* ---- Typography (prose scale) ---- */
358
500
  .prose-h1 { @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; }
359
501
  .prose-h2 { @apply scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0; }
@@ -377,3 +519,5 @@
377
519
  .input-group { @apply flex flex-row h-9 w-full items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm shadow-sm outline-none transition-[color,box-shadow] duration-200 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50; }
378
520
  .input-group-control { @apply h-full flex-1 border-0 bg-transparent p-0 text-sm shadow-none outline-none placeholder:text-muted-foreground; }
379
521
  }
522
+
523
+ @keyframes progress-indeterminate { 0% { left: -40%; } 100% { left: 100%; } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "shadcn-style component registry for muten - own-the-source parts you add with `muten add`.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,12 +11,24 @@
11
11
  "access": "public"
12
12
  },
13
13
  "type": "module",
14
- "files": ["registry", "registry.json", "globals.css", "README.md"],
14
+ "files": [
15
+ "registry",
16
+ "registry.json",
17
+ "globals.css",
18
+ "README.md"
19
+ ],
15
20
  "exports": {
16
21
  "./globals.css": "./globals.css",
17
22
  "./registry.json": "./registry.json",
18
23
  "./registry/*": "./registry/*"
19
24
  },
20
- "keywords": ["muten", "muten-plugin", "muten-registry", "components", "ui", "shadcn"],
25
+ "keywords": [
26
+ "muten",
27
+ "muten-plugin",
28
+ "muten-registry",
29
+ "components",
30
+ "ui",
31
+ "shadcn"
32
+ ],
21
33
  "license": "MIT"
22
34
  }
@@ -78,8 +78,9 @@ export function mount(el, inputs, handlers) {
78
78
 
79
79
  function render() {
80
80
  el.innerHTML = '';
81
- const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'calendar-nav-prev'; prev.textContent = '‹';
82
- const next = document.createElement('button'); next.type = 'button'; next.className = 'calendar-nav-next'; next.textContent = '›';
81
+ const SVG = (p) => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + p + '</svg>';
82
+ const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'calendar-nav-prev'; prev.innerHTML = SVG('<path d="m15 18-6-6 6-6"/>'); // lucide chevron-left
83
+ const next = document.createElement('button'); next.type = 'button'; next.className = 'calendar-nav-next'; next.innerHTML = SVG('<path d="m9 18 6-6-6-6"/>'); // lucide chevron-right
83
84
  prev.addEventListener('click', () => { vm--; if (vm < 0) { vm = 11; vy--; } render(); });
84
85
  next.addEventListener('click', () => { vm++; if (vm > 11) { vm = 0; vy++; } render(); });
85
86
  el.appendChild(prev); el.appendChild(next);
@@ -0,0 +1,44 @@
1
+ // CanvasHost (muten Custom). A <canvas> with pan (drag) + zoom (wheel). handlers: { view(zoomPercent) }.
2
+ // Draws a checkerboard + a sample sprite as a placeholder - replace draw() with your scene render.
3
+ export function mount(el, inputs, handlers) {
4
+ const canvas = document.createElement('canvas');
5
+ el.appendChild(canvas);
6
+ const ctx = canvas.getContext('2d');
7
+ let scale = 1, ox = 0, oy = 0;
8
+ const dpr = () => window.devicePixelRatio || 1;
9
+
10
+ const draw = () => {
11
+ const w = canvas.width / dpr(), h = canvas.height / dpr();
12
+ ctx.clearRect(0, 0, w, h);
13
+ ctx.save();
14
+ ctx.translate(ox, oy); ctx.scale(scale, scale);
15
+ const cell = 16;
16
+ const sx = Math.floor((-ox / scale) / cell) - 1, sy = Math.floor((-oy / scale) / cell) - 1;
17
+ const cols = Math.ceil(w / cell / scale) + 3, rows = Math.ceil(h / cell / scale) + 3;
18
+ for (let i = sx; i < sx + cols; i++) for (let j = sy; j < sy + rows; j++) {
19
+ ctx.fillStyle = (i + j) % 2 ? 'rgba(255,255,255,.05)' : 'rgba(255,255,255,.02)';
20
+ ctx.fillRect(i * cell, j * cell, cell, cell);
21
+ }
22
+ ctx.fillStyle = '#3b82f6'; ctx.fillRect(-32, -32, 64, 64);
23
+ ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1 / scale; ctx.strokeRect(-32, -32, 64, 64);
24
+ ctx.restore();
25
+ };
26
+ const resize = () => { const r = el.getBoundingClientRect(); canvas.width = r.width * dpr(); canvas.height = r.height * dpr(); ctx.setTransform(dpr(), 0, 0, dpr(), 0, 0); draw(); };
27
+
28
+ let drag = false, lx = 0, ly = 0;
29
+ canvas.addEventListener('pointerdown', (e) => { drag = true; lx = e.clientX; ly = e.clientY; canvas.setPointerCapture(e.pointerId); });
30
+ canvas.addEventListener('pointermove', (e) => { if (!drag) return; ox += e.clientX - lx; oy += e.clientY - ly; lx = e.clientX; ly = e.clientY; draw(); });
31
+ canvas.addEventListener('pointerup', () => drag = false);
32
+ canvas.addEventListener('wheel', (e) => {
33
+ e.preventDefault();
34
+ const f = e.deltaY < 0 ? 1.1 : 1 / 1.1;
35
+ const r = el.getBoundingClientRect(), cx = e.clientX - r.left, cy = e.clientY - r.top;
36
+ const ns = Math.min(8, Math.max(0.2, scale * f));
37
+ const k = ns / scale; ox = cx - (cx - ox) * k; oy = cy - (cy - oy) * k; scale = ns;
38
+ draw(); if (handlers.view) handlers.view(Math.round(scale * 100));
39
+ }, { passive: false });
40
+
41
+ requestAnimationFrame(() => { const r = el.getBoundingClientRect(); ox = r.width / 2; oy = r.height / 2; resize(); });
42
+ window.addEventListener('resize', resize);
43
+ return () => {};
44
+ }
@@ -0,0 +1,11 @@
1
+ # CanvasViewport - a <canvas> host with pan (drag) + zoom (wheel), and a muten toolbar slot floating on top.
2
+ # The PAGE can react to zoom via onView. The canvas draws a placeholder scene - edit src/components/CanvasHost.js.
3
+ # action onView(z: number) mutates zoom { zoom.set(z) }
4
+ # CanvasViewport(onView: onView) { Toolbar { … } } # the slot renders over the canvas (pointer-events re-enabled)
5
+ # shadcn-for-muten · Custom (the canvas) wrapped in a muten part (the overlay). host JS is src/components/CanvasHost.js.
6
+ part CanvasViewport(onView: action) {
7
+ Stack class("canvas-viewport") {
8
+ Custom CanvasHost on(view: $onView) class("canvas-host")
9
+ Stack class("canvas-overlay") { slot }
10
+ }
11
+ }
@@ -4,8 +4,9 @@ export function mount(el, inputs, handlers) {
4
4
  const viewport = document.createElement('div'); viewport.className = 'carousel-viewport';
5
5
  const track = document.createElement('div'); track.className = 'carousel-track';
6
6
  viewport.appendChild(track); el.appendChild(viewport);
7
- const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'carousel-prev'; prev.textContent = '‹';
8
- const next = document.createElement('button'); next.type = 'button'; next.className = 'carousel-next'; next.textContent = '›';
7
+ const SVG = (p) => '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + p + '</svg>';
8
+ const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'carousel-prev'; prev.innerHTML = SVG('<path d="m15 18-6-6 6-6"/>'); // lucide chevron-left
9
+ const next = document.createElement('button'); next.type = 'button'; next.className = 'carousel-next'; next.innerHTML = SVG('<path d="m9 18 6-6-6-6"/>'); // lucide chevron-right
9
10
  el.appendChild(prev); el.appendChild(next);
10
11
 
11
12
  let idx = 0;
@@ -0,0 +1,48 @@
1
+ // ColorPicker host (muten Custom). inputs: { value } (hex). handlers: { input(hex) }.
2
+ // A swatch button opens a popover with a saturation/value square + a hue slider + a hex field.
3
+ export function mount(el, inputs, handlers) {
4
+ const swatch = document.createElement('button'); swatch.type = 'button'; swatch.className = 'color-swatch';
5
+ const dot = document.createElement('span'); dot.className = 'color-swatch-dot';
6
+ const txt = document.createElement('span'); swatch.appendChild(dot); swatch.appendChild(txt);
7
+ const pop = document.createElement('div'); pop.className = 'color-popover';
8
+ const sv = document.createElement('div'); sv.className = 'color-sv';
9
+ const svc = document.createElement('div'); svc.className = 'color-sv-cursor'; sv.appendChild(svc);
10
+ const hue = document.createElement('div'); hue.className = 'color-hue';
11
+ const huec = document.createElement('div'); huec.className = 'color-hue-cursor'; hue.appendChild(huec);
12
+ const hex = document.createElement('input'); hex.className = 'color-hex'; hex.maxLength = 7;
13
+ pop.appendChild(sv); pop.appendChild(hue); pop.appendChild(hex);
14
+ el.appendChild(swatch); el.appendChild(pop);
15
+
16
+ let h = 0, s = 100, v = 100;
17
+ const hsv2rgb = (h, s, v) => { s /= 100; v /= 100; const c = v * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = v - c; let r, g, b; if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0]; else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c]; else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; };
18
+ const toHex = (r, g, b) => '#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('');
19
+ const fromHex = (str) => { const m = /^#?([0-9a-f]{6})$/i.exec(String(str || '')); if (!m) return null; const n = parseInt(m[1], 16); const r = (n >> 16) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; let hh = 0; if (d) { if (mx === r) hh = ((g - b) / d) % 6; else if (mx === g) hh = (b - r) / d + 2; else hh = (r - g) / d + 4; hh *= 60; if (hh < 0) hh += 360; } return [hh, mx ? d / mx * 100 : 0, mx * 100]; };
20
+ const curHex = () => toHex.apply(null, hsv2rgb(h, s, v));
21
+
22
+ const paint = () => {
23
+ const [r, g, b] = hsv2rgb(h, 100, 100);
24
+ sv.style.background = 'linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, rgb(' + r + ',' + g + ',' + b + '))';
25
+ svc.style.left = s + '%'; svc.style.top = (100 - v) + '%';
26
+ huec.style.left = (h / 360 * 100) + '%';
27
+ const hx = curHex(); dot.style.background = hx; txt.textContent = hx; hex.value = hx;
28
+ };
29
+ const emit = () => { paint(); if (handlers.input) handlers.input(curHex()); };
30
+
31
+ const init = fromHex(inputs.value || '#3b82f6'); if (init) [h, s, v] = init;
32
+ paint();
33
+
34
+ let open = false;
35
+ const show = () => { pop.classList.add('color-popover-open'); open = true; };
36
+ const hide = () => { pop.classList.remove('color-popover-open'); open = false; };
37
+ swatch.addEventListener('click', (e) => { e.stopPropagation(); open ? hide() : show(); });
38
+ pop.addEventListener('click', (e) => e.stopPropagation());
39
+ document.addEventListener('click', () => { if (open) hide(); });
40
+
41
+ const svPick = (e) => { const r = sv.getBoundingClientRect(); if (!r.width) return; s = Math.min(100, Math.max(0, (e.clientX - r.left) / r.width * 100)); v = Math.min(100, Math.max(0, (1 - (e.clientY - r.top) / r.height) * 100)); emit(); };
42
+ let svDrag = false; sv.addEventListener('pointerdown', (e) => { svDrag = true; sv.setPointerCapture(e.pointerId); svPick(e); }); sv.addEventListener('pointermove', (e) => { if (svDrag) svPick(e); }); sv.addEventListener('pointerup', () => svDrag = false);
43
+ const huePick = (e) => { const r = hue.getBoundingClientRect(); if (!r.width) return; h = Math.min(360, Math.max(0, (e.clientX - r.left) / r.width * 360)); emit(); };
44
+ let hueDrag = false; hue.addEventListener('pointerdown', (e) => { hueDrag = true; hue.setPointerCapture(e.pointerId); huePick(e); }); hue.addEventListener('pointermove', (e) => { if (hueDrag) huePick(e); }); hue.addEventListener('pointerup', () => hueDrag = false);
45
+ hex.addEventListener('change', () => { const r = fromHex(hex.value); if (r) { [h, s, v] = r; emit(); } });
46
+
47
+ return (n) => { const r = fromHex(n.value); if (r) { [h, s, v] = r; paint(); } };
48
+ }
@@ -0,0 +1,7 @@
1
+ # Color Picker - a swatch that opens a saturation/value + hue picker. The PAGE owns the hex value (text):
2
+ # state { color = "#3b82f6" : text } action setColor(v: text) mutates color { color.set(v) }
3
+ # ColorPicker(value: color, onInput: setColor)
4
+ # shadcn-for-muten · Custom · host JS is src/components/ColorPicker.js (copied by `muten add color-picker`).
5
+ part ColorPicker(value: text, onInput: action) {
6
+ Custom ColorPicker inputs(value: $value) on(input: $onInput) class("color-picker")
7
+ }
@@ -0,0 +1,14 @@
1
+ # Command Palette - a top-anchored modal holding a Command (search + items). The PAGE owns the open bool;
2
+ # pair it with Hotkey to open on Cmd/Ctrl+K:
3
+ # state { open = false : bool } action openP mutates open { open.set(true) } action closeP mutates open { open.set(false) }
4
+ # Hotkey(keys: "k", onPress: openP)
5
+ # CommandPalette(open: open, onClose: closeP) {
6
+ # Command { SearchField bind(q) "Type a command…" class("command-input") CommandList { CommandItem(onSelect: closeP) { … } } }
7
+ # }
8
+ # shadcn-for-muten · part · no deps · reuses the .dialog* + .command* classes; backdrop click closes.
9
+ part CommandPalette(open: bool, onClose: action) {
10
+ Stack class("dialog-overlay command-palette", "dialog-overlay-open" when $open) {
11
+ Button -> $onClose class("dialog-backdrop")
12
+ Stack class("command-palette-content") { slot }
13
+ }
14
+ }
@@ -0,0 +1,23 @@
1
+ // FileInput / Dropzone host (muten Custom). inputs: { label, accept, multiple }. handlers: { files(names) }.
2
+ // Click or drag-drop. Emits the comma-joined file NAMES (muten actions take strings); to read the bytes,
3
+ // edit this file to FileReader the files and emit the result (data URL / text) instead.
4
+ export function mount(el, inputs, handlers) {
5
+ const placeholder = inputs.label == null ? 'Drop files here, or click to browse' : String(inputs.label);
6
+ const input = document.createElement('input'); input.type = 'file'; input.hidden = true;
7
+ if (inputs.accept) input.accept = String(inputs.accept);
8
+ if (inputs.multiple) input.multiple = true;
9
+ const label = document.createElement('div'); label.className = 'dropzone-label'; label.textContent = placeholder;
10
+ el.appendChild(input); el.appendChild(label);
11
+
12
+ const emit = (fileList) => {
13
+ const names = Array.from(fileList || []).map((f) => f.name);
14
+ label.textContent = names.length ? names.join(', ') : placeholder;
15
+ if (handlers.files) handlers.files(names.join(', '));
16
+ };
17
+
18
+ el.addEventListener('click', () => input.click());
19
+ input.addEventListener('change', () => emit(input.files));
20
+ el.addEventListener('dragover', (e) => { e.preventDefault(); el.classList.add('dropzone-over'); });
21
+ el.addEventListener('dragleave', () => el.classList.remove('dropzone-over'));
22
+ el.addEventListener('drop', (e) => { e.preventDefault(); el.classList.remove('dropzone-over'); emit(e.dataTransfer && e.dataTransfer.files); });
23
+ }
@@ -0,0 +1,8 @@
1
+ # File Input / Dropzone - click or drag-drop files. The PAGE gets the file names (text):
2
+ # state { picked = "" : text } action onFiles(v: text) mutates picked { picked.set(v) }
3
+ # FileInput(label: "Drop a sprite sheet here", accept: "image/*", onFiles: onFiles)
4
+ # Emits the file NAMES; to read bytes, edit src/components/FileInput.js (FileReader -> emit a data URL).
5
+ # shadcn-for-muten · Custom · host JS is src/components/FileInput.js (copied by `muten add file-input`).
6
+ part FileInput(label: text, accept: text, onFiles: action) {
7
+ Custom FileInput inputs(label: $label, accept: $accept) on(files: $onFiles) class("dropzone")
8
+ }
@@ -0,0 +1,18 @@
1
+ // FolderPicker host (muten Custom). inputs: { label }. handlers: { pick(folderName) }.
2
+ // Uses the File System Access API (showDirectoryPicker) to connect a local folder; emits its name. The directory
3
+ // handle stays here in JS - edit this file to read/write files in the chosen folder (the studio's "connect folder").
4
+ export function mount(el, inputs, handlers) {
5
+ const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-outline gap-2';
6
+ const idle = inputs.label == null ? 'Connect folder' : String(inputs.label);
7
+ btn.textContent = idle;
8
+ el.appendChild(btn);
9
+
10
+ btn.addEventListener('click', async () => {
11
+ if (!window.showDirectoryPicker) { btn.textContent = 'Not supported in this browser'; return; }
12
+ try {
13
+ const handle = await window.showDirectoryPicker();
14
+ btn.textContent = handle.name;
15
+ if (handlers.pick) handlers.pick(handle.name);
16
+ } catch (e) { /* user cancelled */ }
17
+ });
18
+ }
@@ -0,0 +1,8 @@
1
+ # Folder Picker - connect a local folder via the File System Access API (showDirectoryPicker). The PAGE gets the
2
+ # folder name; the directory handle stays in the host JS for reading/writing:
3
+ # state { folder = "" : text } action onPick(name: text) mutates folder { folder.set(name) }
4
+ # FolderPicker(label: "Connect folder", onPick: onPick)
5
+ # shadcn-for-muten · Custom · host JS is src/components/FolderPicker.js (copied by `muten add folder-picker`).
6
+ part FolderPicker(label: text, onPick: action) {
7
+ Custom FolderPicker inputs(label: $label) on(pick: $onPick) class("inline-flex")
8
+ }
@@ -0,0 +1,10 @@
1
+ // Hotkey host (muten Custom, invisible). inputs: { keys } (e.g. "k" -> Cmd/Ctrl+K). handlers: { press }.
2
+ // A global keyboard shortcut: useful to open a CommandPalette. Renders nothing.
3
+ export function mount(el, inputs, handlers) {
4
+ const key = String(inputs.keys == null ? 'k' : inputs.keys).toLowerCase();
5
+ const onKey = (e) => {
6
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key) { e.preventDefault(); if (handlers.press) handlers.press(); }
7
+ };
8
+ document.addEventListener('keydown', onKey);
9
+ return () => {};
10
+ }
@@ -0,0 +1,6 @@
1
+ # Hotkey - a global keyboard shortcut (Cmd/Ctrl + a key), invisible. The PAGE provides the action to run:
2
+ # Hotkey(keys: "k", onPress: openPalette) # Cmd/Ctrl + K
3
+ # shadcn-for-muten · Custom · host JS is src/components/Hotkey.js (copied by `muten add hotkey`).
4
+ part Hotkey(keys: text, onPress: action) {
5
+ Custom Hotkey inputs(keys: $keys) on(press: $onPress) class("hidden")
6
+ }
@@ -0,0 +1,19 @@
1
+ # Inspector / Property Grid - label:value rows in collapsible sections (the right panel of an editor). The PAGE
2
+ # owns a bool per section + the value states; the row's slot is the value control (a NumberField, Switch, …):
3
+ # InspectorSection(label: "Transform", open: t, onToggle: tt) {
4
+ # InspectorRow(label: "X") { NumberField(value: x, …) } InspectorRow(label: "Y") { NumberField(value: y, …) }
5
+ # }
6
+ # shadcn-for-muten · part · no deps · styles via the semantic .inspector* classes in globals.css.
7
+ part Inspector() { Stack class("inspector") { slot } }
8
+ part InspectorSection(label: text, open: bool, onToggle: action) {
9
+ Stack class("inspector-section") {
10
+ Button -> $onToggle aria(expanded: $open) class("inspector-section-header") {
11
+ Span "{$label}"
12
+ Icon "lucide:chevron-down" class("inspector-chevron", "inspector-chevron-open" when $open)
13
+ }
14
+ when $open { Stack class("inspector-body") { slot } }
15
+ }
16
+ }
17
+ part InspectorRow(label: text) {
18
+ Stack class("inspector-row") { Span "{$label}" class("inspector-row-label") slot }
19
+ }
@@ -0,0 +1,30 @@
1
+ // NumberField host (muten Custom). inputs: { value, min, max, step }. handlers: { input(value) }.
2
+ // [-] <input type=number> [+] - clamps to [min,max], steps on buttons / arrows / wheel, emits on change.
3
+ const ico = (p) => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + p + '</svg>';
4
+ export function mount(el, inputs, handlers) {
5
+ const num = (v, d) => { const n = Number(v); return Number.isFinite(n) ? n : d; };
6
+ let min = num(inputs.min, -Infinity), max = num(inputs.max, Infinity), step = num(inputs.step, 1) || 1;
7
+
8
+ const dec = document.createElement('button'); dec.type = 'button'; dec.className = 'number-field-btn'; dec.innerHTML = ico('<path d="M5 12h14"/>'); // lucide minus
9
+ const input = document.createElement('input'); input.type = 'number'; input.className = 'number-field-input';
10
+ const inc = document.createElement('button'); inc.type = 'button'; inc.className = 'number-field-btn'; inc.innerHTML = ico('<path d="M5 12h14"/><path d="M12 5v14"/>'); // lucide plus
11
+ el.appendChild(dec); el.appendChild(input); el.appendChild(inc);
12
+ if (Number.isFinite(min)) input.min = String(min);
13
+ if (Number.isFinite(max)) input.max = String(max);
14
+ input.step = String(step);
15
+
16
+ const clamp = (v) => Math.min(max, Math.max(min, v));
17
+ let value = clamp(num(inputs.value, 0));
18
+ input.value = String(value);
19
+
20
+ const emit = (v) => { value = clamp(v); input.value = String(value); if (handlers.input) handlers.input(value); };
21
+ dec.addEventListener('click', () => emit(value - step));
22
+ inc.addEventListener('click', () => emit(value + step));
23
+ input.addEventListener('change', () => emit(num(input.value, value)));
24
+ input.addEventListener('wheel', (e) => { if (document.activeElement === input) { e.preventDefault(); emit(value + (e.deltaY < 0 ? step : -step)); } }, { passive: false });
25
+
26
+ return (n) => {
27
+ min = num(n.min, min); max = num(n.max, max); step = num(n.step, step) || step;
28
+ value = clamp(num(n.value, value)); input.value = String(value);
29
+ };
30
+ }
@@ -0,0 +1,7 @@
1
+ # NumberField - a numeric input with -/+ steppers. The PAGE owns the value (number) + the action:
2
+ # state { size = 16 : number } action setSize(v: number) mutates size { size.set(v) }
3
+ # NumberField(value: size, min: 1, max: 512, step: 1, onInput: setSize)
4
+ # shadcn-for-muten · Custom · host JS is src/components/NumberField.js (copied by `muten add number-field`).
5
+ part NumberField(value: number, min: number, max: number, step: number, onInput: action) {
6
+ Custom NumberField inputs(value: $value, min: $min, max: $max, step: $step) on(input: $onInput) class("number-field")
7
+ }
@@ -0,0 +1,25 @@
1
+ // RangeSlider host (muten Custom). inputs: { lo, hi, min, max, step }. handlers: { lo(v), hi(v) }.
2
+ // Two thumbs; drag the nearer one. The page owns two numbers (the low + high bounds).
3
+ export function mount(el, inputs, handlers) {
4
+ const num = (v, d) => { const n = Number(v); return Number.isFinite(n) ? n : d; };
5
+ const min = num(inputs.min, 0), max = num(inputs.max, 100), step = num(inputs.step, 1) || 1;
6
+ let lo = num(inputs.lo, min), hi = num(inputs.hi, max);
7
+
8
+ const fill = document.createElement('div'); fill.className = 'range-slider-fill';
9
+ const tlo = document.createElement('div'); tlo.className = 'range-slider-thumb';
10
+ const thi = document.createElement('div'); thi.className = 'range-slider-thumb';
11
+ el.appendChild(fill); el.appendChild(tlo); el.appendChild(thi);
12
+
13
+ const pct = (v) => (max === min ? 0 : (v - min) / (max - min) * 100);
14
+ const paint = () => { fill.style.left = pct(lo) + '%'; fill.style.width = (pct(hi) - pct(lo)) + '%'; tlo.style.left = pct(lo) + '%'; thi.style.left = pct(hi) + '%'; };
15
+ paint();
16
+
17
+ const valAt = (clientX) => { const r = el.getBoundingClientRect(); if (!r.width) return lo; const ratio = Math.min(1, Math.max(0, (clientX - r.left) / r.width)); return Math.round((min + ratio * (max - min)) / step) * step; };
18
+ let dragging = null;
19
+ const move = (e) => { const v = valAt(e.clientX); if (dragging === 'lo') { lo = Math.min(v, hi); if (handlers.lo) handlers.lo(lo); } else { hi = Math.max(v, lo); if (handlers.hi) handlers.hi(hi); } paint(); };
20
+ el.addEventListener('pointerdown', (e) => { const v = valAt(e.clientX); dragging = Math.abs(v - lo) <= Math.abs(v - hi) ? 'lo' : 'hi'; el.setPointerCapture(e.pointerId); move(e); });
21
+ el.addEventListener('pointermove', (e) => { if (dragging) move(e); });
22
+ el.addEventListener('pointerup', () => dragging = null);
23
+
24
+ return (n) => { lo = num(n.lo, lo); hi = num(n.hi, hi); paint(); };
25
+ }
@@ -0,0 +1,7 @@
1
+ # Range Slider - two thumbs (a low + high bound). The PAGE owns two numbers + two actions:
2
+ # state { lo = 20 : number hi = 80 : number } action setLo(v: number) mutates lo { lo.set(v) } action setHi(v: number) mutates hi { hi.set(v) }
3
+ # RangeSlider(lo: lo, hi: hi, min: 0, max: 100, step: 1, onLo: setLo, onHi: setHi)
4
+ # shadcn-for-muten · Custom · host JS is src/components/RangeSlider.js (copied by `muten add range-slider`).
5
+ part RangeSlider(lo: number, hi: number, min: number, max: number, step: number, onLo: action, onHi: action) {
6
+ Custom RangeSlider inputs(lo: $lo, hi: $hi, min: $min, max: $max, step: $step) on(lo: $onLo, hi: $onHi) class("range-slider")
7
+ }
@@ -0,0 +1,8 @@
1
+ # Segmented Control - a single-select toolbar (tool picker, view switch). The PAGE owns a text state:
2
+ # state { tool = "select" : text } action setTool(v: text) mutates tool { tool.set(v) }
3
+ # SegmentedControl { SegmentedItem(value: "select", current: tool, label: "Select", onSelect: setTool) … }
4
+ # shadcn-for-muten · part · no deps · styles via the semantic .segmented* classes in globals.css.
5
+ part SegmentedControl() { Stack class("segmented") { slot } }
6
+ part SegmentedItem(value: text, current: text, label: text, onSelect: action) {
7
+ Button "{$label}" -> $onSelect($value) aria(pressed: $value == $current) class("segmented-item", "segmented-item-active" when $value == $current)
8
+ }
@@ -0,0 +1,18 @@
1
+ # Sidebar - a collapsible nav panel. The PAGE owns a collapsed bool + the active item:
2
+ # state { collapsed = false : bool nav = "sprites" : text } action setNav(v: text) mutates nav { nav.set(v) }
3
+ # Sidebar(collapsed: collapsed) {
4
+ # SidebarHeader { Span "Studio" }
5
+ # SidebarGroup { SidebarGroupLabel(label: "Assets")
6
+ # SidebarItem(label: "Sprites", value: "sprites", current: nav, onSelect: setNav) { Icon "lucide:image" class("size-4 shrink-0") } … }
7
+ # }
8
+ # shadcn-for-muten · part · no deps · styles via the semantic .sidebar* classes in globals.css.
9
+ part Sidebar(collapsed: bool) { Stack class("sidebar", "sidebar-collapsed" when $collapsed) { slot } }
10
+ part SidebarHeader() { Stack class("sidebar-header") { slot } }
11
+ part SidebarGroup() { Stack class("sidebar-group") { slot } }
12
+ part SidebarGroupLabel(label: text) { Span "{$label}" class("sidebar-group-label") }
13
+ part SidebarItem(label: text, value: text, current: text, onSelect: action) {
14
+ Button -> $onSelect($value) aria(pressed: $value == $current) class("sidebar-item", "sidebar-item-active" when $value == $current) {
15
+ slot
16
+ Span "{$label}" class("truncate")
17
+ }
18
+ }
@@ -0,0 +1,27 @@
1
+ // SortableList host (muten Custom). inputs: { items (comma-joined) }. handlers: { reorder(commaJoined) }.
2
+ // Drag a row by its grip to reorder; emits the new order. The page owns a comma-joined text (e.g. layer order).
3
+ export function mount(el, inputs, handlers) {
4
+ const GRIP = '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.4"/><circle cx="9" cy="12" r="1.4"/><circle cx="9" cy="18" r="1.4"/><circle cx="15" cy="6" r="1.4"/><circle cx="15" cy="12" r="1.4"/><circle cx="15" cy="18" r="1.4"/></svg>';
5
+ const parse = (v) => String(v == null ? '' : v).split(',').map((s) => s.trim()).filter(Boolean);
6
+ let items = parse(inputs.items);
7
+ let dragIdx = -1;
8
+
9
+ const emit = () => { if (handlers.reorder) handlers.reorder(items.join(',')); };
10
+ const render = () => {
11
+ el.innerHTML = '';
12
+ items.forEach((it, i) => {
13
+ const row = document.createElement('div'); row.className = 'sortable-row'; row.draggable = true;
14
+ const handle = document.createElement('span'); handle.className = 'sortable-handle'; handle.innerHTML = GRIP;
15
+ const label = document.createElement('span'); label.className = 'sortable-label'; label.textContent = it;
16
+ row.appendChild(handle); row.appendChild(label);
17
+ row.addEventListener('dragstart', () => { dragIdx = i; row.classList.add('sortable-dragging'); });
18
+ row.addEventListener('dragend', () => row.classList.remove('sortable-dragging'));
19
+ row.addEventListener('dragover', (e) => e.preventDefault());
20
+ row.addEventListener('drop', (e) => { e.preventDefault(); if (dragIdx >= 0 && dragIdx !== i) { const [m] = items.splice(dragIdx, 1); items.splice(i, 0, m); dragIdx = -1; render(); emit(); } });
21
+ el.appendChild(row);
22
+ });
23
+ };
24
+
25
+ render();
26
+ return (n) => { items = parse(n.items); render(); };
27
+ }
@@ -0,0 +1,7 @@
1
+ # Sortable List - drag rows by the grip to reorder (layers, steps). The PAGE owns a comma-joined order:
2
+ # state { layers = "Background,Player,UI" : text } action reorder(v: text) mutates layers { layers.set(v) }
3
+ # SortableList(items: layers, onReorder: reorder)
4
+ # shadcn-for-muten · Custom · host JS is src/components/SortableList.js (copied by `muten add sortable-list`).
5
+ part SortableList(items: text, onReorder: action) {
6
+ Custom SortableList inputs(items: $items) on(reorder: $onReorder) class("sortable-list")
7
+ }
@@ -0,0 +1,12 @@
1
+ # Split Button - a main action + a caret that opens a dropdown. The PAGE owns the main action + the open bool:
2
+ # state { open = false : bool } action t mutates open { open.toggle() }
3
+ # SplitButton(label: "Export", open: open, onMain: doExport, onToggle: t) { DropdownItem(label: "Export as PNG", onSelect: …) … }
4
+ # shadcn-for-muten · part · no deps · reuses the .btn + .dropdown* classes.
5
+ part SplitButton(label: text, open: bool, onMain: action, onToggle: action) {
6
+ Stack class("split-button") {
7
+ Button "{$label}" -> $onMain class("btn btn-default split-button-main")
8
+ Button -> $onToggle aria(expanded: $open) class("btn btn-default split-button-caret") { Icon "lucide:chevron-down" class("size-4") }
9
+ when $open { Button -> $onToggle class("dropdown-backdrop") }
10
+ Stack class("dropdown-content", "dropdown-content-open" when $open) { slot }
11
+ }
12
+ }
@@ -0,0 +1,6 @@
1
+ # Status Bar - a thin bottom bar of status items (zoom %, coords, dimensions). Use StatusSpacer to push items apart.
2
+ # StatusBar { StatusItem(label: "Ready") StatusSpacer StatusItem(label: "32 x 32") StatusItem(label: "100%") }
3
+ # shadcn-for-muten · part · no deps · styles via the semantic .status-* classes in globals.css.
4
+ part StatusBar() { Stack class("status-bar") { slot } }
5
+ part StatusItem(label: text) { Span "{$label}" class("status-item") }
6
+ part StatusSpacer() { Stack class("status-spacer") }
@@ -0,0 +1,10 @@
1
+ # Swatch / Swatch Palette - a grid of selectable color swatches (recent colors, a pixel palette). The PAGE owns
2
+ # the selected hex + the list of colors:
3
+ # state { colors = ["#ef4444", "#3b82f6", "#22c55e"] : list<text> active = "#3b82f6" : text }
4
+ # action pick(c: text) mutates active { active.set(c) }
5
+ # SwatchPalette { each colors as c { Swatch(color: c, current: active, onSelect: pick) } }
6
+ # shadcn-for-muten · part · no deps · the swatch background is set via style(bg).
7
+ part SwatchPalette() { Stack class("swatch-palette") { slot } }
8
+ part Swatch(color: text, current: text, onSelect: action) {
9
+ Button -> $onSelect($color) aria(pressed: $color == $current) class("swatch", "swatch-active" when $color == $current) style(bg: "{$color}")
10
+ }
@@ -0,0 +1,28 @@
1
+ // TagInput host (muten Custom). inputs: { value (comma-joined), placeholder }. handlers: { change(commaJoined) }.
2
+ // Type + Enter adds a chip; the x or Backspace (on an empty field) removes one. The page owns a comma-joined text.
3
+ export function mount(el, inputs, handlers) {
4
+ const parse = (v) => String(v == null ? '' : v).split(',').map((s) => s.trim()).filter(Boolean);
5
+ let tags = parse(inputs.value);
6
+ const input = document.createElement('input'); input.className = 'tag-input-field';
7
+ input.placeholder = inputs.placeholder == null ? 'Add tag…' : String(inputs.placeholder);
8
+
9
+ const emit = () => { if (handlers.change) handlers.change(tags.join(',')); render(); };
10
+ const render = () => {
11
+ el.innerHTML = '';
12
+ for (const t of tags) {
13
+ const chip = document.createElement('span'); chip.className = 'tag-chip'; chip.textContent = t;
14
+ const x = document.createElement('span'); x.className = 'tag-chip-x'; x.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'; // lucide x
15
+ x.addEventListener('click', (e) => { e.stopPropagation(); tags = tags.filter((z) => z !== t); emit(); });
16
+ chip.appendChild(x); el.appendChild(chip);
17
+ }
18
+ el.appendChild(input);
19
+ };
20
+ input.addEventListener('keydown', (e) => {
21
+ if (e.key === 'Enter' && input.value.trim()) { e.preventDefault(); const v = input.value.trim(); if (!tags.includes(v)) tags.push(v); input.value = ''; emit(); }
22
+ else if (e.key === 'Backspace' && !input.value && tags.length) { tags.pop(); emit(); }
23
+ });
24
+ el.addEventListener('click', () => input.focus());
25
+
26
+ render();
27
+ return (n) => { tags = parse(n.value); render(); };
28
+ }
@@ -0,0 +1,7 @@
1
+ # Tag Input - chips you add by typing + Enter, remove with x / Backspace. The PAGE owns a comma-joined text:
2
+ # state { tags = "sprite,enemy" : text } action setTags(v: text) mutates tags { tags.set(v) }
3
+ # TagInput(value: tags, placeholder: "Add tag…", onChange: setTags)
4
+ # shadcn-for-muten · Custom · host JS is src/components/TagInput.js (copied by `muten add tag-input`).
5
+ part TagInput(value: text, placeholder: text, onChange: action) {
6
+ Custom TagInput inputs(value: $value, placeholder: $placeholder) on(change: $onChange) class("tag-input")
7
+ }
@@ -0,0 +1,6 @@
1
+ # Toolbar - a grouped button bar with separators. Put icon buttons inside (class("btn btn-ghost btn-icon")).
2
+ # Toolbar { ToolbarGroup { Button { Icon "lucide:mouse-pointer-2" } class("btn btn-ghost btn-icon") … } ToolbarSeparator ToolbarGroup { … } }
3
+ # shadcn-for-muten · part · no deps · styles via the semantic .toolbar* classes in globals.css.
4
+ part Toolbar() { Stack class("toolbar") { slot } }
5
+ part ToolbarGroup() { Stack class("toolbar-group") { slot } }
6
+ part ToolbarSeparator() { Stack class("toolbar-separator") }
@@ -0,0 +1,49 @@
1
+ // TreeView host (muten Custom). inputs: { data } (nested nodes: { name, children?: [...] }). handlers: { select(path) }.
2
+ // Folders expand/collapse; clicking selects and emits its "/path". Falls back to a sample tree when no data is
3
+ // passed - edit SAMPLE or feed your own `data` (e.g. your project's file tree) for the real thing.
4
+ export function mount(el, inputs, handlers) {
5
+ const FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>';
6
+ const FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>';
7
+ const SAMPLE = [
8
+ { name: 'src', children: [
9
+ { name: 'app.muten' },
10
+ { name: 'pages', children: [{ name: 'home.muten' }, { name: 'editor.muten' }] },
11
+ { name: 'components', children: [{ name: 'Canvas.js' }] },
12
+ { name: 'styles.css' },
13
+ ] },
14
+ { name: 'theme.muten' },
15
+ { name: 'package.json' },
16
+ ];
17
+
18
+ let data = Array.isArray(inputs.data) && inputs.data.length ? inputs.data : SAMPLE;
19
+ let selected = '';
20
+ const expanded = new Set();
21
+ for (const n of data) if (Array.isArray(n.children)) expanded.add('/' + n.name); // first level open
22
+
23
+ const build = (nodes, container, depth, parent) => {
24
+ for (const node of nodes) {
25
+ const path = parent + '/' + node.name;
26
+ const isFolder = Array.isArray(node.children);
27
+ const row = document.createElement('button'); row.type = 'button'; row.className = 'tree-row';
28
+ row.style.paddingLeft = (depth * 16 + 6) + 'px';
29
+ if (path === selected) row.classList.add('tree-row-selected');
30
+ const chev = document.createElement('span'); chev.className = 'tree-chevron' + (isFolder ? '' : ' tree-chevron-hidden');
31
+ // lucide chevron-down (open) / chevron-right (closed)
32
+ chev.innerHTML = isFolder ? '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="' + (expanded.has(path) ? 'm6 9 6 6 6-6' : 'm9 18 6-6-6-6') + '"/></svg>' : '';
33
+ const ic = document.createElement('span'); ic.className = 'tree-icon'; ic.innerHTML = isFolder ? FOLDER : FILE;
34
+ const label = document.createElement('span'); label.className = 'tree-label'; label.textContent = node.name;
35
+ row.appendChild(chev); row.appendChild(ic); row.appendChild(label);
36
+ row.addEventListener('click', () => {
37
+ if (isFolder) { expanded.has(path) ? expanded.delete(path) : expanded.add(path); }
38
+ selected = path; if (handlers.select) handlers.select(path);
39
+ render();
40
+ });
41
+ container.appendChild(row);
42
+ if (isFolder && expanded.has(path)) build(node.children, container, depth + 1, path);
43
+ }
44
+ };
45
+ const render = () => { el.innerHTML = ''; const root = document.createElement('div'); root.className = 'tree'; build(data, root, 0, ''); el.appendChild(root); };
46
+
47
+ render();
48
+ return (n) => { if (Array.isArray(n.data) && n.data.length) data = n.data; render(); };
49
+ }
@@ -0,0 +1,8 @@
1
+ # TreeView - a nested folder/file tree (expandable, selectable, icons + indent). The PAGE owns the selected path:
2
+ # state { tree = [] : list<text> picked = "" : text } action onPick(p: text) mutates picked { picked.set(p) }
3
+ # TreeView(data: tree, onSelect: onPick)
4
+ # Pass your own nested data ({ name, children?: [...] }); with an empty list it renders a sample file tree you
5
+ # can edit in src/components/TreeView.js. shadcn-for-muten · Custom · multilevel (Collapsible/Accordion are single-level).
6
+ part TreeView(data: list, onSelect: action) {
7
+ Custom TreeView inputs(data: $data) on(select: $onSelect) class("tree-view")
8
+ }
@@ -0,0 +1,10 @@
1
+ # Vector Field - paired NumberFields (X / Y) for position or size. The PAGE owns two number states + two actions:
2
+ # state { px = 0 : number py = 0 : number } action setX(v: number) mutates px { px.set(v) } action setY(v: number) mutates py { py.set(v) }
3
+ # VectorField(x: px, y: py, min: -9999, max: 9999, step: 1, onX: setX, onY: setY)
4
+ # shadcn-for-muten · part · depends on number-field · styles via the semantic .vector* classes.
5
+ part VectorField(x: number, y: number, min: number, max: number, step: number, onX: action, onY: action) {
6
+ Stack class("vector-field") {
7
+ Stack class("vector-axis") { Span "X" class("vector-axis-label") NumberField(value: $x, min: $min, max: $max, step: $step, onInput: $onX) }
8
+ Stack class("vector-axis") { Span "Y" class("vector-axis-label") NumberField(value: $y, min: $min, max: $max, step: $step, onInput: $onY) }
9
+ }
10
+ }
@@ -0,0 +1,11 @@
1
+ # Zoom Control - a - 100% + widget. The PAGE owns the zoom number + the -/+ actions:
2
+ # state { zoom = 100 : number } action zOut mutates zoom { zoom.set(zoom - 10) } action zIn mutates zoom { zoom.set(zoom + 10) }
3
+ # ZoomControl(value: zoom, onOut: zOut, onIn: zIn)
4
+ # shadcn-for-muten · part · no deps · styles via the semantic .zoom-* classes in globals.css.
5
+ part ZoomControl(value: number, onOut: action, onIn: action) {
6
+ Stack class("zoom-control") {
7
+ Button -> $onOut class("zoom-btn") { Icon "lucide:minus" class("size-4") }
8
+ Span "{$value}%" class("zoom-value")
9
+ Button -> $onIn class("zoom-btn") { Icon "lucide:plus" class("size-4") }
10
+ }
11
+ }
package/registry.json CHANGED
@@ -47,7 +47,27 @@
47
47
  { "name": "carousel", "part": "Carousel", "file": "registry/carousel.muten", "component": "Carousel", "description": "A slide carousel over a list (Custom). Page owns the slides list.", "deps": [] },
48
48
  { "name": "chart", "part": "Chart", "file": "registry/chart.muten", "component": "Chart", "description": "An SVG bar chart over a list of { label, value } (Custom).", "deps": [] },
49
49
  { "name": "resizable", "part": "Resizable", "file": "registry/resizable.muten", "component": "Resizable", "description": "Two panels with a draggable splitter (Custom).", "deps": [] },
50
- { "name": "context-menu", "part": "ContextMenu", "file": "registry/context-menu.muten", "component": "ContextMenu", "description": "Right-click an area to open a menu of items (Custom).", "deps": [] }
50
+ { "name": "context-menu", "part": "ContextMenu", "file": "registry/context-menu.muten", "component": "ContextMenu", "description": "Right-click an area to open a menu of items (Custom).", "deps": [] },
51
+ { "name": "number-field", "part": "NumberField", "file": "registry/number-field.muten", "component": "NumberField", "description": "A numeric input with -/+ steppers, min/max/step (Custom).", "deps": [] },
52
+ { "name": "segmented-control", "part": "SegmentedControl", "file": "registry/segmented-control.muten", "description": "A single-select toolbar (SegmentedItem); page owns a text state.", "deps": [] },
53
+ { "name": "file-input", "part": "FileInput", "file": "registry/file-input.muten", "component": "FileInput", "description": "A click/drag-drop file dropzone (Custom); emits file names.", "deps": [] },
54
+ { "name": "color-picker", "part": "ColorPicker", "file": "registry/color-picker.muten", "component": "ColorPicker", "description": "A swatch + saturation/value + hue popover (Custom); page owns the hex.", "deps": [] },
55
+ { "name": "vector-field", "part": "VectorField", "file": "registry/vector-field.muten", "description": "Paired NumberFields (X/Y) for position or size.", "deps": ["number-field"] },
56
+ { "name": "tree-view", "part": "TreeView", "file": "registry/tree-view.muten", "component": "TreeView", "description": "A nested folder/file tree (Custom): expandable, selectable, icons + indent.", "deps": [] },
57
+ { "name": "toolbar", "part": "Toolbar", "file": "registry/toolbar.muten", "description": "A grouped button bar with ToolbarGroup / ToolbarSeparator.", "deps": [] },
58
+ { "name": "canvas-viewport", "part": "CanvasViewport", "file": "registry/canvas-viewport.muten", "component": "CanvasHost", "description": "A <canvas> with pan/zoom (Custom) + a muten toolbar slot floating on top.", "deps": [] },
59
+ { "name": "status-bar", "part": "StatusBar", "file": "registry/status-bar.muten", "description": "A thin bottom bar with StatusItem / StatusSpacer.", "deps": [] },
60
+ { "name": "zoom-control", "part": "ZoomControl", "file": "registry/zoom-control.muten", "description": "A - 100% + zoom widget; page owns the zoom number.", "deps": [] },
61
+ { "name": "split-button", "part": "SplitButton", "file": "registry/split-button.muten", "description": "A main action + a caret dropdown; page owns the open bool.", "deps": [] },
62
+ { "name": "tag-input", "part": "TagInput", "file": "registry/tag-input.muten", "component": "TagInput", "description": "Chips you add by typing + Enter (Custom); page owns a comma-joined text.", "deps": [] },
63
+ { "name": "swatch", "part": "SwatchPalette", "file": "registry/swatch.muten", "description": "A grid of selectable color swatches (Swatch); page owns the selected hex + colors.", "deps": [] },
64
+ { "name": "range-slider", "part": "RangeSlider", "file": "registry/range-slider.muten", "component": "RangeSlider", "description": "A two-thumb range slider (Custom); page owns the low + high numbers.", "deps": [] },
65
+ { "name": "sidebar", "part": "Sidebar", "file": "registry/sidebar.muten", "description": "A collapsible nav panel: SidebarHeader / SidebarGroup / SidebarItem.", "deps": [] },
66
+ { "name": "sortable-list", "part": "SortableList", "file": "registry/sortable-list.muten", "component": "SortableList", "description": "Drag rows to reorder (Custom); page owns a comma-joined order.", "deps": [] },
67
+ { "name": "inspector", "part": "Inspector", "file": "registry/inspector.muten", "description": "Property grid: InspectorSection (collapsible) / InspectorRow (label : value slot).", "deps": [] },
68
+ { "name": "folder-picker", "part": "FolderPicker", "file": "registry/folder-picker.muten", "component": "FolderPicker", "description": "Connect a local folder via showDirectoryPicker (Custom); emits the name.", "deps": [] },
69
+ { "name": "command-palette", "part": "CommandPalette", "file": "registry/command-palette.muten", "description": "A top-anchored modal holding a Command; page owns the open bool.", "deps": [] },
70
+ { "name": "hotkey", "part": "Hotkey", "file": "registry/hotkey.muten", "component": "Hotkey", "description": "A global keyboard shortcut (Cmd/Ctrl+key), invisible (Custom).", "deps": [] }
51
71
  ],
52
72
  "classComponents": {
53
73
  "note": "Size/spacing-driven primitives ship as classes in globals.css (no `muten add` needed once you @import it). Style the muten primitive with these:",