@muten/shadcn 0.0.2 → 0.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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",
@@ -0,0 +1,14 @@
1
+ // Code block host (muten Custom). inputs: { code, lang }. Highlights with the muten grammar via the shared
2
+ // highlight.js (lazy-loaded, so the token rules live in one place). See code.muten.
3
+ export function mount(el, inputs) {
4
+ const code = String(inputs.code == null ? "" : inputs.code);
5
+ const lang = inputs.lang == null ? "muten" : String(inputs.lang);
6
+ el.className = "rounded-lg border bg-muted/40 px-4 py-3 font-mono text-foreground overflow-x-auto";
7
+ const pre = document.createElement("pre");
8
+ pre.className = "m-0 text-sm leading-relaxed";
9
+ pre.textContent = code; // plain until the highlighter loads (progressive)
10
+ el.appendChild(pre);
11
+ import("@muten/shadcn/registry/highlight.js")
12
+ .then((m) => { pre.innerHTML = m.highlight(code, lang); })
13
+ .catch(() => { /* keep the plain text */ });
14
+ }
@@ -0,0 +1,7 @@
1
+ # Code - a syntax-highlighted code block. `lang` defaults to "muten" (the muten grammar, matching the VS Code
2
+ # extension); pass another lang to render plain. NOTE: muten strings can't hold `{` or `"`, so feed muten source
3
+ # from a .md file (a Markdown component) or a .js snippet, not an inline muten string.
4
+ # shadcn-for-muten · Custom · host JS is src/components/Code.js (copied by `muten add code`); shares highlight.js.
5
+ part Code(code: text, lang: text) {
6
+ Custom Code inputs(code: $code, lang: $lang) class("code-block")
7
+ }
@@ -0,0 +1,36 @@
1
+ // CountUp - a muten Custom that animates a formatted stat from 0 to its target the first time it scrolls into
2
+ // view. inputs: { to } - the final display string (e.g. "2.4M", "94%", "120K", "58", "99.99%", "40ms", "12B").
3
+ // It splits an optional prefix + number (decimals/commas ok) + suffix, animates the number, then restores the
4
+ // EXACT target string so formatting like "2.4M" is preserved. Pure vanilla JS - this is muten's 20% escape for
5
+ // something CSS can't do (animating a formatted number). Respects prefers-reduced-motion.
6
+ export function mount(el, inputs) {
7
+ const target = String(inputs && inputs.to != null ? inputs.to : '0');
8
+ const m = target.match(/^([^\d]*)([\d.,]+)(.*)$/);
9
+ if (!m) { el.textContent = target; return; }
10
+ const prefix = m[1], numStr = m[2], suffix = m[3];
11
+ const end = parseFloat(numStr.replace(/,/g, ''));
12
+ const decimals = (numStr.split('.')[1] || '').length;
13
+ const grouped = numStr.indexOf(',') !== -1;
14
+ const fmt = (v) => prefix + (decimals ? v.toFixed(decimals) : (grouped ? Math.round(v).toLocaleString() : String(Math.round(v)))) + suffix;
15
+
16
+ const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
17
+ if (reduce || !('IntersectionObserver' in window) || !isFinite(end)) { el.textContent = target; return; }
18
+
19
+ el.textContent = fmt(0);
20
+ let started = false;
21
+ const run = () => {
22
+ if (started) return; started = true;
23
+ const dur = 1400, t0 = performance.now();
24
+ const tick = (now) => {
25
+ const p = Math.min(1, (now - t0) / dur);
26
+ const eased = 1 - Math.pow(1 - p, 3); // ease-out cubic
27
+ if (p < 1) { el.textContent = fmt(end * eased); requestAnimationFrame(tick); }
28
+ else el.textContent = target; // land on the exact target (keeps "2.4M"/"99.99%" formatting)
29
+ };
30
+ requestAnimationFrame(tick);
31
+ };
32
+ const io = new IntersectionObserver((entries) => {
33
+ for (const e of entries) if (e.isIntersecting) { run(); io.disconnect(); }
34
+ }, { threshold: 0.5 });
35
+ io.observe(el);
36
+ }
@@ -0,0 +1,10 @@
1
+ # CountUp - animate a formatted stat from 0 to its target when it scrolls into view (Custom, keeps the exact
2
+ # formatting: 2.4M / 94% / 40ms / 99.99% / 120K). For a STYLED number use the Custom form directly (it takes the
3
+ # class - a part with params can't), which is what stat bands and hero metrics want:
4
+ # Stack class("items-center gap-1") {
5
+ # Custom CountUp inputs(to: "2.4M") class("text-4xl font-bold text-primary")
6
+ # Span "trees planted" class("text-sm text-muted-foreground")
7
+ # }
8
+ # The `CountUp(to: ...)` part below is a thin convenience for the unstyled/inherit case (wrap it in a styled Stack).
9
+ # shadcn-for-muten · Custom · no deps · host JS: src/components/CountUp.js.
10
+ part CountUp(to: text) { Custom CountUp inputs(to: $to) }
@@ -0,0 +1,14 @@
1
+ # FeatureGrid - a responsive grid of feature cards. FeatureCard takes title + desc and a slot for the icon:
2
+ # FeatureGrid {
3
+ # FeatureCard(title: "Locatable", desc: "Every screen has a name and a place.") { Icon "lucide:scan-text" class("size-5") }
4
+ # …
5
+ # }
6
+ # shadcn-for-muten · block · no deps · styles via the semantic .feature-* classes in globals.css.
7
+ part FeatureGrid() { Stack class("feature-grid") { slot } }
8
+ part FeatureCard(title: text, desc: text) {
9
+ Stack class("feature-card") {
10
+ Stack class("feature-card-icon") { slot }
11
+ Title "{$title}" h3 class("feature-card-title")
12
+ Text "{$desc}" class("feature-card-desc")
13
+ }
14
+ }
@@ -0,0 +1,129 @@
1
+ // GradientBlinds - ported from React Bits (reactbits.dev) to a muten Custom. A WebGL fragment shader (via `ogl`)
2
+ // draws diagonal blinds over an animated gradient with a spotlight that FOLLOWS THE MOUSE + grain. Faithful and
3
+ // GPU-fast. A Custom (graphics = the vanilla-JS escape); ogl loads via dynamic import() (a Custom is inlined into
4
+ // an IIFE, so a top-level import is illegal). Inputs mirror the React props: colors, angle, noise, blindCount,
5
+ // blindMinWidth, spotlightRadius/Softness/Opacity, mouseDampening, distort, shineDirection, mirror. Needs `ogl`.
6
+ const MAX_COLORS = 8;
7
+ const hexToRGB = (hex) => {
8
+ const c = String(hex).replace("#", "").padEnd(6, "0");
9
+ return [parseInt(c.slice(0, 2), 16) / 255, parseInt(c.slice(2, 4), 16) / 255, parseInt(c.slice(4, 6), 16) / 255];
10
+ };
11
+ const prepStops = (stops) => {
12
+ const base = (stops && stops.length ? stops : ["#FF9FFC", "#5227FF"]).slice(0, MAX_COLORS);
13
+ if (base.length === 1) base.push(base[0]);
14
+ while (base.length < MAX_COLORS) base.push(base[base.length - 1]);
15
+ return { arr: base.map(hexToRGB), count: Math.max(2, Math.min(MAX_COLORS, stops && stops.length ? stops.length : 2)) };
16
+ };
17
+
18
+ export function mount(el, inputs) {
19
+ const num = (v, d) => { const n = Number(v); return Number.isFinite(n) ? n : d; };
20
+ const I = inputs || {};
21
+ const colors = (I.colors ? String(I.colors) : "#FF9FFC,#5227FF").split(",").map((s) => s.trim()).filter(Boolean);
22
+ const angle = num(I.angle, -25), noise = num(I.noise, 0.22);
23
+ const blindCount = num(I.blindCount, 16), blindMinWidth = num(I.blindMinWidth, 55);
24
+ const spotRadius = num(I.spotlightRadius, 0.6), spotSoft = num(I.spotlightSoftness, 1), spotOpacity = num(I.spotlightOpacity, 0.75);
25
+ const damp = num(I.mouseDampening, 0.15), distort = num(I.distort, 0);
26
+ const shineFlip = I.shineDirection === "right" ? 1 : 0, mirror = I.mirror ? 1 : 0;
27
+
28
+ import("ogl").then(({ Renderer, Program, Mesh, Triangle }) => {
29
+ const renderer = new Renderer({ dpr: Math.min(2, window.devicePixelRatio || 1), alpha: true, antialias: true });
30
+ const gl = renderer.gl;
31
+ const canvas = gl.canvas;
32
+ canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;-webkit-mask-image:linear-gradient(to bottom,#000 42%,transparent 94%);mask-image:linear-gradient(to bottom,#000 42%,transparent 94%)";
33
+ el.appendChild(canvas);
34
+
35
+ const vertex = "attribute vec2 position;attribute vec2 uv;varying vec2 vUv;void main(){vUv=uv;gl_Position=vec4(position,0.0,1.0);}";
36
+ const fragment = `#ifdef GL_ES
37
+ precision mediump float;
38
+ #endif
39
+ uniform vec3 iResolution; uniform vec2 iMouse; uniform float iTime;
40
+ uniform float uAngle, uNoise, uBlindCount, uSpotlightRadius, uSpotlightSoftness, uSpotlightOpacity, uMirror, uDistort, uShineFlip;
41
+ uniform vec3 uColor0,uColor1,uColor2,uColor3,uColor4,uColor5,uColor6,uColor7; uniform int uColorCount;
42
+ varying vec2 vUv;
43
+ float rand(vec2 co){ return fract(sin(dot(co, vec2(12.9898,78.233))) * 43758.5453); }
44
+ vec2 rotate2D(vec2 p, float a){ float c=cos(a), s=sin(a); return mat2(c,-s,s,c)*p; }
45
+ vec3 getGradientColor(float t){
46
+ float tt=clamp(t,0.0,1.0); int count=uColorCount; if(count<2) count=2;
47
+ float scaled=tt*float(count-1); float seg=floor(scaled); float f=fract(scaled);
48
+ if(seg<1.0) return mix(uColor0,uColor1,f);
49
+ if(seg<2.0 && count>2) return mix(uColor1,uColor2,f);
50
+ if(seg<3.0 && count>3) return mix(uColor2,uColor3,f);
51
+ if(seg<4.0 && count>4) return mix(uColor3,uColor4,f);
52
+ if(seg<5.0 && count>5) return mix(uColor4,uColor5,f);
53
+ if(seg<6.0 && count>6) return mix(uColor5,uColor6,f);
54
+ if(seg<7.0 && count>7) return mix(uColor6,uColor7,f);
55
+ if(count>7) return uColor7; if(count>6) return uColor6; if(count>5) return uColor5;
56
+ if(count>4) return uColor4; if(count>3) return uColor3; if(count>2) return uColor2; return uColor1;
57
+ }
58
+ void mainImage(out vec4 fragColor, in vec2 fragCoord){
59
+ vec2 uv0 = fragCoord.xy / iResolution.xy;
60
+ float aspect = iResolution.x / iResolution.y;
61
+ vec2 p = uv0*2.0-1.0; p.x*=aspect; vec2 pr = rotate2D(p, uAngle); pr.x/=aspect; vec2 uv = pr*0.5+0.5;
62
+ vec2 uvMod = uv;
63
+ if(uDistort>0.0){ float a=uvMod.y*6.0, b=uvMod.x*6.0, w=0.01*uDistort; uvMod.x+=sin(a)*w; uvMod.y+=cos(b)*w; }
64
+ float t = uvMod.x; if(uMirror>0.5){ t = 1.0 - abs(1.0 - 2.0*fract(t)); }
65
+ vec3 base = getGradientColor(t);
66
+ vec2 offset = vec2(iMouse.x/iResolution.x, iMouse.y/iResolution.y);
67
+ float d = length(uv0-offset); float r = max(uSpotlightRadius, 1e-4); float dn = d/r;
68
+ float spot = (1.0 - 2.0*pow(dn, uSpotlightSoftness)) * uSpotlightOpacity;
69
+ vec3 cir = vec3(spot);
70
+ float stripe = fract(uvMod.x * max(uBlindCount,1.0)); if(uShineFlip>0.5) stripe = 1.0-stripe;
71
+ vec3 ran = vec3(stripe);
72
+ vec3 col = cir + base - ran;
73
+ col += (rand(gl_FragCoord.xy + iTime) - 0.5) * uNoise;
74
+ fragColor = vec4(col, 1.0);
75
+ }
76
+ void main(){ vec4 color; mainImage(color, vUv*iResolution.xy); gl_FragColor = color; }`;
77
+
78
+ const { arr, count } = prepStops(colors);
79
+ const uniforms = {
80
+ iResolution: { value: [gl.drawingBufferWidth, gl.drawingBufferHeight, 1] },
81
+ iMouse: { value: [0, 0] }, iTime: { value: 0 },
82
+ uAngle: { value: (angle * Math.PI) / 180 }, uNoise: { value: noise },
83
+ uBlindCount: { value: Math.max(1, blindCount) },
84
+ uSpotlightRadius: { value: spotRadius }, uSpotlightSoftness: { value: spotSoft }, uSpotlightOpacity: { value: spotOpacity },
85
+ uMirror: { value: mirror }, uDistort: { value: distort }, uShineFlip: { value: shineFlip },
86
+ uColor0: { value: arr[0] }, uColor1: { value: arr[1] }, uColor2: { value: arr[2] }, uColor3: { value: arr[3] },
87
+ uColor4: { value: arr[4] }, uColor5: { value: arr[5] }, uColor6: { value: arr[6] }, uColor7: { value: arr[7] },
88
+ uColorCount: { value: count },
89
+ };
90
+ const program = new Program(gl, { vertex, fragment, uniforms });
91
+ const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });
92
+
93
+ let first = true;
94
+ const resize = () => {
95
+ const rect = el.getBoundingClientRect();
96
+ renderer.setSize(rect.width || 1, rect.height || 1);
97
+ uniforms.iResolution.value = [gl.drawingBufferWidth, gl.drawingBufferHeight, 1];
98
+ const maxByMin = blindMinWidth > 0 ? Math.max(1, Math.floor((rect.width || 1) / blindMinWidth)) : blindCount;
99
+ uniforms.uBlindCount.value = Math.max(1, blindCount ? Math.min(blindCount, maxByMin) : maxByMin);
100
+ if (first) { first = false; const cx = gl.drawingBufferWidth / 2, cy = gl.drawingBufferHeight / 2; uniforms.iMouse.value = [cx, cy]; tx = cx; ty = cy; }
101
+ };
102
+ let tx = 0, ty = 0, last = 0;
103
+ resize();
104
+ try { new ResizeObserver(resize).observe(el); } catch (_) {}
105
+
106
+ // listen on window so the spotlight tracks the cursor even through the hero content overlaid on top.
107
+ window.addEventListener("pointermove", (e) => {
108
+ const rect = canvas.getBoundingClientRect();
109
+ const scale = renderer.dpr || 1;
110
+ tx = (e.clientX - rect.left) * scale;
111
+ ty = (rect.height - (e.clientY - rect.top)) * scale;
112
+ if (damp <= 0) uniforms.iMouse.value = [tx, ty];
113
+ });
114
+
115
+ const loop = (t) => {
116
+ requestAnimationFrame(loop);
117
+ uniforms.iTime.value = t * 0.001;
118
+ if (damp > 0) {
119
+ if (!last) last = t;
120
+ const dt = (t - last) / 1000; last = t;
121
+ let factor = 1 - Math.exp(-dt / Math.max(1e-4, damp)); if (factor > 1) factor = 1;
122
+ const cur = uniforms.iMouse.value;
123
+ cur[0] += (tx - cur[0]) * factor; cur[1] += (ty - cur[1]) * factor;
124
+ }
125
+ try { renderer.render({ scene: mesh }); } catch (e) { /* context lost */ }
126
+ };
127
+ requestAnimationFrame(loop);
128
+ }).catch(() => { /* no ogl - the hero just has a flat background */ });
129
+ }
@@ -0,0 +1,13 @@
1
+ # GradientBlinds - an animated WebGL gradient with diagonal blinds and a spotlight that follows the cursor
2
+ # (ported from React Bits). Use it as a hero background: make it the first child of a
3
+ # `relative overflow-hidden` box and it fills the box, then fades out at the bottom to blend into the page.
4
+ # Stack class("relative overflow-hidden") {
5
+ # GradientBlinds(colors: "#FF9FFC,#5227FF")
6
+ # Section class("relative z-10 ...") { ...hero content... }
7
+ # }
8
+ # `colors` = a comma-separated list of hex stops. Angle, blind count, spotlight and grain are tuned defaults in
9
+ # the ejected host JS - edit src/components/GradientBlinds.js to change them.
10
+ # shadcn-for-muten · Custom · needs `ogl` (npm i ogl). Host JS: src/components/GradientBlinds.js.
11
+ part GradientBlinds(colors: text) {
12
+ Custom GradientBlinds inputs(colors: $colors) class("absolute inset-0 z-0 pointer-events-none opacity-75")
13
+ }
@@ -0,0 +1,47 @@
1
+ // A muten-aware syntax highlighter for the browser - the same token classes the muten VS Code grammar uses
2
+ // (keywords, primitives, types, modifiers, methods, components, strings, params, refs, numbers, operators,
3
+ // comments). `highlight(code, lang)` returns HTML with .tk-* spans; the colors live in globals.css so they
4
+ // follow the theme. Non-muten languages are returned escaped (no highlight). Shared by the Code Custom + docs.
5
+ const KEYWORDS = "screen entity state store const theme get effect action mutates mock sources api meta routes shell guard else part param query every live persist post put delete body into if when each as where by with and or not contains use from".split(" ");
6
+ const PRIMITIVES = "Stack Header Nav Sidebar Footer Page Section Article List Details Text Title Span Image Icon Video SearchField DataTable RowAction Button Form Link slot When Each Custom".split(" ");
7
+ const MODIFIERS = "bind submit where columns class alt inputs on aria style".split(" ");
8
+ const METHODS = "push remove patch reset toggle set create update delete refetch".split(" ");
9
+ const TYPES = "text email string number bool uuid list".split(" ");
10
+
11
+ const grp = (a) => "(?:" + a.join("|") + ")";
12
+ // Ordered like the grammar: first match wins, specific before generic. Each: [regex source, token class].
13
+ const RULES = [
14
+ ["#[^\\n]*", "comment"],
15
+ ['"(?:[^"\\\\]|\\\\.)*"', "string"],
16
+ ["\\$[A-Za-z_]\\w*", "variable"],
17
+ ["@[A-Za-z_]\\w*", "variable"],
18
+ ["\\b" + grp(KEYWORDS) + "\\b", "keyword"],
19
+ ["\\b(?:true|false)\\b", "constant"],
20
+ ["\\b" + grp(TYPES) + "\\b", "type"],
21
+ ["\\b" + grp(PRIMITIVES) + "\\b", "primitive"],
22
+ ["\\b" + grp(MODIFIERS) + "\\b", "modifier"],
23
+ ["\\." + grp(METHODS) + "\\b", "method"],
24
+ ["\\b[A-Z][A-Za-z0-9_]*\\b", "component"],
25
+ ["-?\\b\\d+(?:\\.\\d+)?\\b", "number"],
26
+ ["->|<-|==|!=|<=|>=|[-+*/<>=|?]", "operator"],
27
+ ];
28
+ const MASTER = new RegExp(RULES.map((r) => "(" + r[0] + ")").join("|"), "g");
29
+ const CLASSES = RULES.map((r) => r[1]);
30
+
31
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
32
+
33
+ export function highlight(code, lang) {
34
+ const src = String(code == null ? "" : code);
35
+ if (lang && lang !== "muten" && lang !== "store") return esc(src);
36
+ let out = "", last = 0, m;
37
+ MASTER.lastIndex = 0;
38
+ while ((m = MASTER.exec(src))) {
39
+ if (m.index > last) out += esc(src.slice(last, m.index));
40
+ let cls = "variable";
41
+ for (let i = 1; i < m.length; i++) { if (m[i] !== undefined) { cls = CLASSES[i - 1]; break; } }
42
+ out += '<span class="tk-' + cls + '">' + esc(m[0]) + "</span>";
43
+ last = m.index + m[0].length;
44
+ }
45
+ if (last < src.length) out += esc(src.slice(last));
46
+ return out;
47
+ }
@@ -0,0 +1,11 @@
1
+ # LogoCloud - the "trusted by" strip: a wrapping row of logos, muted + grayscale until hover. GENERIC - a
2
+ # LogoCloudItem holds ANY logo type: an Image, an Icon, or a plain text wordmark (Span). All normalize to one
3
+ # height. Give it a heading above via a plain Text.
4
+ # LogoCloud {
5
+ # LogoCloudItem { Image "~/logos/acme.svg" alt("Acme") } # image logo
6
+ # LogoCloudItem { Icon "simple-icons:vercel" } # icon logo
7
+ # LogoCloudItem { Span "Globex" class("text-lg font-bold tracking-tight") } # text wordmark
8
+ # }
9
+ # shadcn-for-muten · block · no deps · styles via .logo-cloud / .logo-cloud-item in globals.css.
10
+ part LogoCloud() { Stack class("logo-cloud") { slot } }
11
+ part LogoCloudItem() { Stack class("logo-cloud-item") { slot } }
@@ -0,0 +1,5 @@
1
+ # Pill - a hero eyebrow chip (bigger than a Badge, rounded-full). Slot-based so you drop an icon + text:
2
+ # Pill { Icon "lucide:sparkles" class("size-4") Span "For freelancers, studios and agencies" }
3
+ # Tint it per brand with extra classes: Pill class("bg-blue-50 border-blue-100 text-blue-700").
4
+ # shadcn-for-muten · part · no deps · styles via the semantic .pill class in globals.css.
5
+ part Pill() { Stack class("pill") { slot } }
@@ -0,0 +1,25 @@
1
+ # Pricing - a row of tiers. PricingTier / PricingTierFeatured take name/price/period/description; the slot holds
2
+ # the feature list (PricingFeature items) + the CTA. Use PricingTierFeatured to highlight the recommended plan.
3
+ # Pricing {
4
+ # PricingTierFeatured(name: "Pro", price: "$20", period: "/mo", description: "for pro sites") {
5
+ # Stack class("pricing-features") { PricingFeature { Span "Image optimization" } PricingFeature { Span "Bot protection" } }
6
+ # Button "See Pro plan" -> choose class("btn btn-default w-full mt-auto")
7
+ # }
8
+ # }
9
+ # shadcn-for-muten · block · no deps · styles via the semantic .pricing-* classes in globals.css.
10
+ part Pricing() { Stack class("pricing-grid") { slot } }
11
+ part PricingTier(name: text, price: text, period: text, description: text) {
12
+ Stack class("pricing-tier") {
13
+ Stack class("gap-1") { Span "{$name}" class("pricing-name") Span "{$description}" class("pricing-desc") }
14
+ Stack class("flex flex-row items-baseline gap-1.5") { Span "{$price}" class("pricing-price") Span "{$period}" class("pricing-period") }
15
+ slot
16
+ }
17
+ }
18
+ part PricingTierFeatured(name: text, price: text, period: text, description: text) {
19
+ Stack class("pricing-tier pricing-tier-featured") {
20
+ Stack class("gap-1") { Span "{$name}" class("pricing-name") Span "{$description}" class("pricing-desc") }
21
+ Stack class("flex flex-row items-baseline gap-1.5") { Span "{$price}" class("pricing-price") Span "{$period}" class("pricing-period") }
22
+ slot
23
+ }
24
+ }
25
+ part PricingFeature() { Stack class("pricing-feature") { Icon "lucide:check" class("size-4 text-primary shrink-0") slot } }
@@ -6,3 +6,9 @@ part SegmentedControl() { Stack class("segmented") { slot } }
6
6
  part SegmentedItem(value: text, current: text, label: text, onSelect: action) {
7
7
  Button "{$label}" -> $onSelect($value) aria(pressed: $value == $current) class("segmented-item", "segmented-item-active" when $value == $current)
8
8
  }
9
+ # Full-width tab bar with icon + label (Forms · Scheduling · PDF …). IconTab is slot-based so you drop an
10
+ # Icon + Span inside: SegmentedTabs { IconTab(value: "forms", current: tab, onSelect: setTab) { Icon "lucide:file-text" Span "Forms" } … }
11
+ part SegmentedTabs() { Stack class("segmented segmented-fill") { slot } }
12
+ part IconTab(value: text, current: text, onSelect: action) {
13
+ Button -> $onSelect($value) aria(pressed: $value == $current) class("segmented-item", "segmented-item-active" when $value == $current) { slot }
14
+ }
@@ -1,18 +1,19 @@
1
- # Sidebar - a collapsible nav panel. The PAGE owns a collapsed bool + the active item:
1
+ # AppSidebar - a collapsible nav panel (named AppSidebar, NOT Sidebar, so it never shadows muten's core `Sidebar`
2
+ # <aside> primitive). The PAGE owns a collapsed bool + the active item:
2
3
  # state { collapsed = false : bool nav = "sprites" : text } action setNav(v: text) mutates nav { nav.set(v) }
3
- # Sidebar(collapsed: collapsed) {
4
+ # AppSidebar(collapsed: collapsed) {
4
5
  # SidebarHeader { Span "Studio" }
5
6
  # SidebarGroup { SidebarGroupLabel(label: "Assets")
6
7
  # SidebarItem(label: "Sprites", value: "sprites", current: nav, onSelect: setNav) { Icon "lucide:image" class("size-4 shrink-0") } … }
7
8
  # }
8
9
  # 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 AppSidebar(collapsed: bool) { Stack class("sidebar", "sidebar-collapsed" when $collapsed) { slot } }
10
11
  part SidebarHeader() { Stack class("sidebar-header") { slot } }
11
12
  part SidebarGroup() { Stack class("sidebar-group") { slot } }
12
13
  part SidebarGroupLabel(label: text) { Span "{$label}" class("sidebar-group-label") }
13
14
  part SidebarItem(label: text, value: text, current: text, onSelect: action) {
14
15
  Button -> $onSelect($value) aria(pressed: $value == $current) class("sidebar-item", "sidebar-item-active" when $value == $current) {
15
16
  slot
16
- Span "{$label}" class("truncate")
17
+ Span "{$label}" class("sidebar-item-label truncate")
17
18
  }
18
19
  }
@@ -0,0 +1,17 @@
1
+ # Stepper - a horizontal (default) or vertical progress indicator. Wrap the steps in Stepper (or
2
+ # StepperVertical) and render each with the Step part; state is "done" | "active" | "upcoming".
3
+ # state { steps = query steps : list<StepItem> }
4
+ # Stepper { each steps as s { Step(num: s.num, label: s.label, state: s.state) } }
5
+ # A completed step shows a check (the number hides); the current step is ringed. Own it with `muten add stepper`.
6
+ # shadcn-for-muten · part · no deps · styles via .stepper / .stepper-step* in globals.css.
7
+ part Stepper() { Stack class("stepper") { slot } }
8
+ part StepperVertical() { Stack class("stepper stepper-vertical") { slot } }
9
+ part Step(num: text, label: text, state: text) {
10
+ Stack class("stepper-step stepper-step-{$state}") {
11
+ Stack class("stepper-indicator") {
12
+ when $state == "done" { Icon "lucide:check" class("size-4") }
13
+ Span "{$num}" class("stepper-num")
14
+ }
15
+ Span "{$label}" class("stepper-label")
16
+ }
17
+ }
@@ -0,0 +1,16 @@
1
+ # Timeline - a vertical list of events, each a dot on a connector line + content (title, time, description).
2
+ # state { events = query events : list<Event> }
3
+ # Timeline { each events as e { TimelineItem(title: e.title, time: e.time, desc: e.desc) } }
4
+ # Own it with `muten add timeline`.
5
+ # shadcn-for-muten · part · no deps · styles via .timeline / .timeline-* in globals.css.
6
+ part Timeline() { Stack class("timeline") { slot } }
7
+ part TimelineItem(title: text, time: text, desc: text) {
8
+ Stack class("timeline-item") {
9
+ Stack class("timeline-dot")
10
+ Stack class("timeline-content") {
11
+ Span "{$time}" class("timeline-time")
12
+ Span "{$title}" class("timeline-title")
13
+ Span "{$desc}" class("timeline-desc")
14
+ }
15
+ }
16
+ }
@@ -1,6 +1,12 @@
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.
1
+ // TreeView host (muten Custom). TWO modes, chosen by whether you pass key names:
2
+ // nested (default): data = [{ name, children? }] - folders expand, a leaf emits its "/path".
3
+ // flat (pass keys): data = a FLAT list of records; `groupKey` buckets them into folders, `labelKey` is the
4
+ // visible text, `valueKey` is the payload - a leaf emits node[valueKey] (NOT a path). This is
5
+ // how you feed a `query` of rows straight in (e.g. tilesets) without nesting them in muten.
6
+ // `selected` (optional, controlled): highlights the matching leaf (its value in flat mode, its path in nested) and
7
+ // auto-expands the folder holding it - for the row your app auto-opens on load.
8
+ // Folders ONLY expand; only leaves emit `select`. inputs: { data, labelKey, valueKey, groupKey, selected }.
9
+ // handlers: { select(value) }. A `query` passed as data arrives as { loading, data:[…] } - unwrapped here.
4
10
  export function mount(el, inputs, handlers) {
5
11
  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
12
  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>';
@@ -15,18 +21,55 @@ export function mount(el, inputs, handlers) {
15
21
  { name: 'package.json' },
16
22
  ];
17
23
 
18
- let data = Array.isArray(inputs.data) && inputs.data.length ? inputs.data : SAMPLE;
19
- let selected = '';
24
+ // A query arrives as { loading, data:[…] }; a plain list as the array; else nothing.
25
+ const rows = (d) => Array.isArray(d) ? d : (d && Array.isArray(d.data) ? d.data : null);
26
+
27
+ // Normalize either mode into the internal nested shape: folder = { name, children:[…] }, leaf = { name, value }.
28
+ const toTree = (ins) => {
29
+ const lk = ins.labelKey, vk = ins.valueKey, gk = ins.groupKey;
30
+ const list = rows(ins.data);
31
+ if ((lk || vk || gk) && list) {
32
+ const groups = new Map(); const loose = [];
33
+ for (const row of list) {
34
+ const label = lk ? row[lk] : (row && row.name != null ? row.name : String(row));
35
+ const value = vk ? row[vk] : label;
36
+ const leaf = { name: String(label), value };
37
+ const g = gk ? row[gk] : '';
38
+ if (g == null || g === '') { loose.push(leaf); continue; }
39
+ if (!groups.has(g)) groups.set(g, { name: String(g), children: [] });
40
+ groups.get(g).children.push(leaf);
41
+ }
42
+ return [...groups.values(), ...loose];
43
+ }
44
+ return list && list.length ? list : SAMPLE;
45
+ };
46
+
47
+ const idOf = (node, path) => Array.isArray(node.children) ? path : (node.value != null ? String(node.value) : path);
48
+
49
+ let data = toTree(inputs);
50
+ let selected = inputs.selected != null ? String(inputs.selected) : '';
20
51
  const expanded = new Set();
21
- for (const n of data) if (Array.isArray(n.children)) expanded.add('/' + n.name); // first level open
52
+
53
+ // expand the ancestor folders of the leaf whose id === selected; true if found in this subtree
54
+ const expandTo = (nodes, parent) => {
55
+ for (const node of nodes) {
56
+ const path = parent + '/' + node.name;
57
+ if (Array.isArray(node.children)) { if (expandTo(node.children, path)) { expanded.add(path); return true; } }
58
+ else if (idOf(node, path) === selected) return true;
59
+ }
60
+ return false;
61
+ };
62
+ const seed = () => { for (const n of data) if (Array.isArray(n.children)) expanded.add('/' + n.name); if (selected) expandTo(data, ''); };
63
+ seed();
22
64
 
23
65
  const build = (nodes, container, depth, parent) => {
24
66
  for (const node of nodes) {
25
67
  const path = parent + '/' + node.name;
26
68
  const isFolder = Array.isArray(node.children);
69
+ const id = idOf(node, path);
27
70
  const row = document.createElement('button'); row.type = 'button'; row.className = 'tree-row';
28
71
  row.style.paddingLeft = (depth * 16 + 6) + 'px';
29
- if (path === selected) row.classList.add('tree-row-selected');
72
+ if (!isFolder && id === selected) row.classList.add('tree-row-selected');
30
73
  const chev = document.createElement('span'); chev.className = 'tree-chevron' + (isFolder ? '' : ' tree-chevron-hidden');
31
74
  // lucide chevron-down (open) / chevron-right (closed)
32
75
  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>' : '';
@@ -34,8 +77,8 @@ export function mount(el, inputs, handlers) {
34
77
  const label = document.createElement('span'); label.className = 'tree-label'; label.textContent = node.name;
35
78
  row.appendChild(chev); row.appendChild(ic); row.appendChild(label);
36
79
  row.addEventListener('click', () => {
37
- if (isFolder) { expanded.has(path) ? expanded.delete(path) : expanded.add(path); }
38
- selected = path; if (handlers.select) handlers.select(path);
80
+ if (isFolder) { expanded.has(path) ? expanded.delete(path) : expanded.add(path); } // folders: expand only
81
+ else { selected = id; if (handlers.select) handlers.select(node.value != null ? node.value : path); } // leaves emit
39
82
  render();
40
83
  });
41
84
  container.appendChild(row);
@@ -45,5 +88,9 @@ export function mount(el, inputs, handlers) {
45
88
  const render = () => { el.innerHTML = ''; const root = document.createElement('div'); root.className = 'tree'; build(data, root, 0, ''); el.appendChild(root); };
46
89
 
47
90
  render();
48
- return (n) => { if (Array.isArray(n.data) && n.data.length) data = n.data; render(); };
91
+ return (n) => {
92
+ data = toTree(n);
93
+ if (n.selected != null) { selected = String(n.selected); if (selected) expandTo(data, ''); } // re-open to the controlled leaf
94
+ render();
95
+ };
49
96
  }
@@ -1,8 +1,14 @@
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")
1
+ # Tree View - a file/folder tree (Custom). TWO modes:
2
+ # nested (default): pass a nested `data` of { name, children }; leaves emit their "/path". Leave the keys "".
3
+ # TreeView(data: fileTree, labelKey: "", valueKey: "", groupKey: "", selected: "", onSelect: pick)
4
+ # flat (pass keys): feed a FLAT list (e.g. a `query` of rows) and let the tree group + key it for you -
5
+ # groupKey buckets rows into folders, labelKey is the text, valueKey is the payload a leaf emits:
6
+ # state { tiles = query tilesets : list<Tile> tileUrl = "" : text }
7
+ # action pick(u: text) mutates tileUrl { tileUrl.set(u) }
8
+ # TreeView(data: tiles, labelKey: "tname", valueKey: "url", groupKey: "folder", selected: tileUrl, onSelect: pick)
9
+ # `selected` highlights + auto-opens that leaf. Folders only expand; only leaves emit select. (muten parts need
10
+ # every arg, so pass "" for the keys you don't use - an empty key = nested mode.)
11
+ # shadcn-for-muten · Custom · host JS is src/components/TreeView.js (copied by `muten add tree-view`).
12
+ part TreeView(data: list, labelKey: text, valueKey: text, groupKey: text, selected: text, onSelect: action) {
13
+ Custom TreeView inputs(data: $data, labelKey: $labelKey, valueKey: $valueKey, groupKey: $groupKey, selected: $selected) on(select: $onSelect) class("tree-view")
8
14
  }