create-muten 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/README.md CHANGED
@@ -60,10 +60,29 @@ In an interactive terminal it prompts for a few things (defaults in parentheses)
60
60
  | Prompt | Options | Default |
61
61
  |---|---|---|
62
62
  | **Project name** | any valid folder name | `muten-app` |
63
+ | **Template** | `basic` / `routing` / `full` | `basic` |
63
64
  | **Stylesheet** | `css` / `scss` | `css` |
65
+ | **Add Tailwind CSS?** | `Y` / `n` (CSS only) | `n` |
66
+ | **Add DaisyUI?** | `Y` / `n` (needs Tailwind) | `n` |
64
67
  | **Package manager** | `npm` / `pnpm` / `yarn` / `bun` | the one that launched it |
65
68
  | **Install deps and start dev now?** | `Y` / `n` | `Y` |
66
69
 
70
+ Tailwind is an optional add-on **on top of** CSS (the look layer; you still style via `class("…")`) — it
71
+ wires `@tailwindcss/vite` + an `@import "tailwindcss"` and notes the setup in the app's `.claude/` guide.
72
+
73
+ ## Templates
74
+
75
+ | Template | What you get |
76
+ |---|---|
77
+ | **basic** | one page — the minimal starter |
78
+ | **routing** | a persistent shell (navbar + footer) + multiple real-path routes + a static `about` page |
79
+ | **full** | routing + a `.store` + an `api` block + a source-backed products page + Tailwind — a real data app |
80
+
81
+ When **Tailwind or DaisyUI** is selected, `theme.muten` is centralized to **match Tailwind's scale** (so
82
+ `style()` tokens and Tailwind utilities share one scale, e.g. `style(gap.md)` == `gap-4`); plain CSS/SCSS
83
+ keeps the default scale. **DaisyUI** adds component classes (`btn`, `card`, `modal`) usable in `class("…")` —
84
+ the closest fit to shadcn that works in Muten (pure classes, no React; behavior is Muten state + `on()`).
85
+
67
86
  If you accept the last prompt it runs `<pm> install` followed by `<pm> run dev` — your app is live in a
68
87
  single step. Choosing SCSS also adds `sass` and switches the stylesheet to `.scss` automatically.
69
88
 
@@ -80,7 +99,10 @@ create-muten my-app --css --no-install # just scaffold, decide later
80
99
  | Flag | Effect |
81
100
  |---|---|
82
101
  | `<name>` | the project folder (positional argument) |
102
+ | `--template <basic\|routing\|full>` | which starter (default: `basic`; `full` implies Tailwind) |
83
103
  | `--css` / `--scss` | pick the stylesheet (default: `css`) |
104
+ | `--tailwind` | add Tailwind CSS v4 on top of CSS (forces `--css`) |
105
+ | `--daisyui` | add DaisyUI component classes (implies `--tailwind`) |
84
106
  | `--pm <npm\|pnpm\|yarn\|bun>` | package manager to use (default: detected) |
85
107
  | `--no-install` | scaffold only — don't install or start the dev server |
86
108
  | `--help` | print usage and exit |
package/index.js CHANGED
@@ -1,111 +1,167 @@
1
1
  #!/usr/bin/env node
2
- // create-muten — scaffold a new Muten app. Zero runtime deps (Node built-ins only).
2
+ // create-muten — scaffold a new Muten app, with modern interactive prompts (@clack/prompts).
3
3
  //
4
- // npm create muten@latest [name] (when published to npm)
5
- // npx github:karttofer/create-muten [name] (from GitHub, not yet public)
6
- // create-muten [name] [--css|--scss] [--pm npm|pnpm|yarn|bun] [--no-install]
4
+ // npm create muten@latest [name] (or: npx create-muten)
5
+ // create-muten [name] [--template basic|routing|full] [--css|--scss] [--tailwind] [--daisyui] [--pm npm|pnpm|yarn|bun] [--no-install]
7
6
  //
8
- // Interactive in a TTY (prompts for name, stylesheet, package manager); flags / non-TTY make it
9
- // scriptable. The package manager defaults to the one that invoked us. Then it scaffolds and,
10
- // unless --no-install, runs `<pm> install` + `<pm> run dev`.
7
+ // Stylesheet (CSS or SCSS) is the base; Tailwind is an optional add-on ON TOP of CSS (it's a styling
8
+ // library, not a stylesheet replacement). Interactive in a TTY; flags / non-TTY make it scriptable.
11
9
  import { cpSync, existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
12
10
  import { join, dirname, resolve } from 'node:path';
13
11
  import { fileURLToPath } from 'node:url';
14
- import { createInterface } from 'node:readline/promises';
15
12
  import { spawnSync } from 'node:child_process';
13
+ import { intro, outro, text, select, confirm, isCancel, cancel, note } from '@clack/prompts';
14
+ import color from 'picocolors';
16
15
 
17
16
  const SELF = dirname(fileURLToPath(import.meta.url));
18
17
  const TEMPLATE = join(SELF, 'template');
18
+ const OVERLAYS = join(SELF, 'overlays'); // additive layers per template variant (routing, full)
19
+ const TEMPLATES = ['basic', 'routing', 'full'];
19
20
  const PKG = JSON.parse(readFileSync(join(SELF, 'package.json'), 'utf8'));
20
21
  const PMS = ['npm', 'pnpm', 'yarn', 'bun'];
21
22
 
22
- const BANNER = [
23
- '',
24
- ' _',
25
- ' _ __ ___ _ _| |_ ___ _ __',
26
- " | '_ ` _ \\| | | | __/ _ \\ '_ \\",
27
- ' | | | | | | |_| | || __/ | | |',
28
- ' |_| |_| |_|\\__,_|\\__\\___|_| |_|',
29
- '',
30
- ' AI-first frontend framework',
31
- '',
32
- ].join('\n');
33
-
34
- // Which PM launched us? npm/pnpm/yarn/bun all set npm_config_user_agent (e.g. "pnpm/8.6 ...").
35
- // Detecting it is what create-vue/vite do — the idiomatic default, no guessing.
36
- const detectPM = () => {
37
- const ua = process.env.npm_config_user_agent || '';
38
- return PMS.find((p) => ua.startsWith(p + '/')) || 'npm';
23
+ // the starter reset — written by the CLI so the template stays pure .muten (no default styles file).
24
+ const RESET = `/* Your look. Muten ships STRUCTURE + LAYOUT (style() tokens); the LOOK lives here, via class("…"). */
25
+ * { box-sizing: border-box; }
26
+ body { margin: 0; font: 15px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; color: #111; }
27
+ h1, h2, h3, h4, h5, h6, p { margin: 0; }
28
+ h1 { font-size: 32px; font-weight: 700; letter-spacing: -.02em; }
29
+ .stack { display: flex; flex-direction: column; }
30
+ img { max-width: 100%; display: block; }
31
+ a { color: inherit; text-decoration: none; }
32
+ `;
33
+ // CSS + Tailwind v4: one @import + the @tailwindcss/vite plugin. Preflight does the reset, so this stays
34
+ // minimal — only `.stack` (a Muten layout primitive Tailwind doesn't know). You style via class("…").
35
+ const tailwindStyles = (daisyui) => `@import "tailwindcss";${daisyui ? '\n@plugin "daisyui";' : ''}
36
+
37
+ /* Muten layout primitive Tailwind doesn't define */
38
+ .stack { display: flex; flex-direction: column; }
39
+ `;
40
+ const TAILWIND_VITE = `import muten from '@muten/core/vite-plugin-muten.js';
41
+ import tailwindcss from '@tailwindcss/vite';
42
+
43
+ export default {
44
+ plugins: [muten(), tailwindcss()],
39
45
  };
40
-
41
- // Safe folder name: starts alphanumeric, then [A-Za-z0-9._-]. Blocks path traversal / odd input.
46
+ `;
47
+ // When Tailwind/DaisyUI is chosen, the Muten token scale is centralized to MATCH Tailwind's defaults, so
48
+ // style() tokens and Tailwind utilities share one scale (e.g. style(gap.md) == gap-4 == 1rem). Plain
49
+ // css/scss keeps the default theme.muten. (DaisyUI builds on Tailwind's scale; its colors come via @plugin.)
50
+ const TAILWIND_THEME = `# Token SCALE aligned with Tailwind's defaults — style() tokens + Tailwind utilities share one scale.
51
+ theme {
52
+ space { xs "0.25rem" sm "0.5rem" md "1rem" lg "1.5rem" xl "2rem" }
53
+ font { sm "0.875rem" md "1rem" lg "1.125rem" xl "1.25rem" }
54
+ weight { medium "500" semibold "600" bold "700" }
55
+ leading { tight "1.25" normal "1.5" loose "2" }
56
+ breakpoints { sm "640px" md "768px" lg "1024px" xl "1280px" }
57
+ }
58
+ `;
59
+ const TAILWIND_NOTE = `
60
+ ## Styling: Tailwind CSS v4 (installed)
61
+ This app has Tailwind ON TOP of CSS. Write the LOOK with \`class("…")\` using Tailwind utilities, e.g.
62
+ \`class("flex gap-4 rounded-lg bg-zinc-900 text-white")\`. \`style()\` still owns Muten's layout/
63
+ typography tokens — don't put Tailwind classes in \`style()\`, and don't put layout in \`class()\`.
64
+ You can still add your own rules in \`src/styles.css\` below the \`@import "tailwindcss";\`.
65
+ `;
66
+ // DaisyUI = component CLASSES on top of Tailwind (no React). The "shadcn for Muten": pre-styled components
67
+ // you drop into class("…"); behavior (open/close) you build with Muten state + class(when) + on(…).
68
+ const DAISY_NOTE = `
69
+ ## DaisyUI (installed)
70
+ DaisyUI adds **component classes** on top of Tailwind — use them in \`class("…")\`: \`class("btn btn-primary")\`,
71
+ \`class("card bg-base-100 shadow-xl")\`, \`class("badge")\`, \`class("alert")\`. Pure classes, no React.
72
+ \`@plugin "daisyui";\` is already in \`src/styles.css\`. Interactive behavior (toggle a modal/dropdown) you build
73
+ with Muten: \`state\` + \`class(active when isOpen)\` + \`on(click: …)\`.
74
+ `;
75
+
76
+ // Which PM launched us? npm/pnpm/yarn/bun set npm_config_user_agent — the idiomatic default.
77
+ const detectPM = () => { const ua = process.env.npm_config_user_agent || ''; return PMS.find((p) => ua.startsWith(p + '/')) || 'npm'; };
42
78
  const validName = (n) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(n);
43
- const die = (msg) => { console.error(msg); process.exit(1); };
79
+ const keep = (v) => { if (isCancel(v)) { cancel('Cancelled.'); process.exit(0); } return v; };
44
80
 
45
81
  async function main() {
46
- // args: one positional name + optional flags (so the CLI is also scriptable / CI-friendly)
47
82
  const argv = process.argv.slice(2);
48
83
  const has = (f) => argv.includes(f);
49
84
  const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
50
85
  if (has('-v') || has('--version')) { console.log(PKG.version); return; }
51
- console.log(BANNER);
52
- if (has('-h') || has('--help')) { console.log(' Usage: create-muten [name] [--css|--scss] [--pm npm|pnpm|yarn|bun] [--no-install]\n'); return; }
86
+ if (has('-h') || has('--help')) { console.log('Usage: create-muten [name] [--template basic|routing|full] [--css|--scss] [--tailwind] [--daisyui] [--pm npm|pnpm|yarn|bun] [--no-install]'); return; }
53
87
 
54
- let name = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--pm')[0];
55
- let style = has('--scss') ? 'scss' : has('--css') ? 'css' : undefined;
88
+ let name = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--pm' && argv[i - 1] !== '--template')[0];
89
+ let template = val('--template') || (has('--full') ? 'full' : has('--routing') ? 'routing' : has('--basic') ? 'basic' : undefined);
90
+ let style = has('--scss') ? 'scss' : has('--css') ? 'css' : undefined; // the base stylesheet
91
+ let tailwind = has('--tailwind') ? true : undefined; // optional add-on (CSS only)
92
+ let daisyui = has('--daisyui') ? true : undefined; // component classes on Tailwind
56
93
  let pm = val('--pm');
57
94
  let install = has('--no-install') ? false : undefined;
58
-
59
- if (name && !validName(name)) die(`Invalid name: "${name}" (letters, digits, . _ -)`);
60
- if (pm && !PMS.includes(pm)) die(`Unknown package manager: "${pm}" (${PMS.join(', ')})`);
61
-
95
+ if (name && !validName(name)) { console.error(`Invalid name: "${name}" (letters, digits, . _ -)`); process.exit(1); }
96
+ if (template && !TEMPLATES.includes(template)) { console.error(`Unknown template: "${template}" (${TEMPLATES.join(', ')})`); process.exit(1); }
97
+ if (pm && !PMS.includes(pm)) { console.error(`Unknown package manager: "${pm}" (${PMS.join(', ')})`); process.exit(1); }
62
98
  const dpm = detectPM();
63
99
 
64
- // Prompt only with a real TTY piped/CI input drops lines through readline, so there we use
65
- // flags + defaults instead of hanging on a prompt.
100
+ // Styled prompts only with a real TTY (piped/CI input would hang); otherwise use flags + defaults.
66
101
  if (process.stdin.isTTY) {
67
- const rl = createInterface({ input: process.stdin, output: process.stdout });
68
- const ask = async (q, def) => (await rl.question(q)).trim() || def;
69
- const pick = async (q, opts, def) => {
70
- for (;;) {
71
- const v = (await ask(q, def)).toLowerCase();
72
- if (opts.includes(v)) return v;
73
- console.log(` Choose: ${opts.join(', ')}.`);
74
- }
75
- };
76
- while (!name) {
77
- const a = await ask('Project name: (muten-app) ', 'muten-app');
78
- if (validName(a)) name = a; else console.log(' Letters, digits, . _ - (start alphanumeric).');
79
- }
80
- if (!style) style = await pick('Stylesheet? [css/scss] (css) ', ['css', 'scss'], 'css');
81
- if (!pm) pm = await pick(`Package manager? [${PMS.join('/')}] (${dpm}) `, PMS, dpm);
82
- if (install === undefined) install = (await ask('Install deps and start the dev server now? [Y/n] ', 'y')).toLowerCase() !== 'n';
83
- rl.close();
102
+ intro(color.bgCyan(color.black(' create-muten ')) + color.dim(' the AI-first frontend framework'));
103
+ if (!name) name = keep(await text({ message: 'Project name', placeholder: 'muten-app', defaultValue: 'muten-app', validate: (v) => (v && !validName(v)) ? 'Use letters, digits, . _ - (start alphanumeric).' : undefined }));
104
+ if (!template) template = keep(await select({ message: 'Template', options: [
105
+ { value: 'basic', label: 'Basic', hint: 'one page' },
106
+ { value: 'routing', label: 'Routing', hint: 'shell + multiple pages' },
107
+ { value: 'full', label: 'Routing + store + API + Tailwind', hint: 'a real data app' },
108
+ ] }));
109
+ if (template === 'full') tailwind = true; // the full template implies Tailwind
110
+ // Tailwind is an add-on on top of CSS (Tailwind v4 is CSS-native; Sass isn't recommended).
111
+ if (!style && !tailwind) style = keep(await select({ message: 'Stylesheet', options: [
112
+ { value: 'css', label: 'CSS', hint: 'plain, zero deps' },
113
+ { value: 'scss', label: 'SCSS', hint: 'adds sass' },
114
+ ] }));
115
+ if (tailwind === undefined) tailwind = style === 'css' ? keep(await confirm({ message: 'Add Tailwind CSS? (utility classes via class("…"))', initialValue: false })) : false;
116
+ if (tailwind && daisyui === undefined) daisyui = keep(await confirm({ message: 'Add DaisyUI? (component classes: btn, card, modal…)', initialValue: template === 'full' }));
84
117
  }
85
118
  name = name || 'muten-app';
119
+ template = template || 'basic';
120
+ if (template === 'full') tailwind = true; // full implies Tailwind
121
+ if (daisyui) tailwind = true; // DaisyUI is a Tailwind plugin
86
122
  style = style || 'css';
123
+ if (tailwind === undefined) tailwind = false;
124
+ if (daisyui === undefined) daisyui = false;
125
+ if (tailwind) style = 'css'; // Tailwind implies a CSS base (not SCSS)
87
126
  pm = pm || dpm;
88
127
  if (install === undefined) install = false;
89
128
 
90
129
  const target = resolve(name);
91
- if (existsSync(target)) die(`"${name}" already exists.`);
130
+ if (existsSync(target)) { (process.stdin.isTTY ? cancel : console.error)(`"${name}" already exists.`); process.exit(1); }
92
131
 
93
- // scaffold from ./template
132
+ // scaffold from ./template (the basic base) + layer the chosen variant's overlay + the stylesheet/add-ons
94
133
  cpSync(TEMPLATE, target, { recursive: true });
95
- const ignore = join(target, '_gitignore'); // npm strips a real .gitignore on publish
134
+ if (template !== 'basic') cpSync(join(OVERLAYS, template), target, { recursive: true }); // routing / full overlay
135
+ const ignore = join(target, '_gitignore');
96
136
  if (existsSync(ignore)) renameSync(ignore, join(target, '.gitignore'));
97
- if (style === 'scss') renameSync(join(target, 'src', 'styles.css'), join(target, 'src', 'styles.scss')); // plugin auto-detects
98
137
 
99
138
  const pkgPath = join(target, 'package.json');
100
139
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
101
140
  pkg.name = name;
102
- if (style === 'scss') pkg.devDependencies = { ...(pkg.devDependencies || {}), sass: '^1.101.0' };
141
+ const addDev = (deps) => { pkg.devDependencies = { ...(pkg.devDependencies || {}), ...deps }; };
142
+
143
+ if (tailwind) {
144
+ writeFileSync(join(target, 'src', 'styles.css'), tailwindStyles(daisyui));
145
+ writeFileSync(join(target, 'vite.config.mjs'), TAILWIND_VITE);
146
+ writeFileSync(join(target, 'theme.muten'), TAILWIND_THEME); // scale centralized to Tailwind's
147
+ addDev({ tailwindcss: '^4.0.0', '@tailwindcss/vite': '^4.0.0' });
148
+ if (daisyui) addDev({ daisyui: '^5.0.0' });
149
+ const agents = join(target, '.claude', 'AGENTS.md'); // tell the AI what styling is available
150
+ if (existsSync(agents)) writeFileSync(agents, readFileSync(agents, 'utf8') + TAILWIND_NOTE + (daisyui ? DAISY_NOTE : ''));
151
+ } else {
152
+ writeFileSync(join(target, 'src', style === 'scss' ? 'styles.scss' : 'styles.css'), RESET);
153
+ }
154
+ if (style === 'scss') addDev({ sass: '^1.101.0' });
103
155
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
104
156
 
105
- console.log(`\n Created ${name} (${style}, ${pm})\n`);
106
-
107
- if (!install) { console.log(` cd ${name}\n ${pm} install\n ${pm} run dev\n`); return; }
157
+ const desc = `${template}, ${style}${tailwind ? ' + Tailwind' : ''}${daisyui ? ' + DaisyUI' : ''}`;
158
+ if (!install) {
159
+ if (process.stdin.isTTY) { note(`cd ${name}\n${pm} install\n${pm} run dev`, 'Next steps'); outro(color.green(`Created ${name} (${desc})`)); }
160
+ else console.log(`\n Created ${name} (${desc}, ${pm})\n cd ${name} && ${pm} install && ${pm} run dev\n`);
161
+ return;
162
+ }
108
163
 
164
+ if (process.stdin.isTTY) outro(color.green(`Created ${name} (${desc}) — installing with ${pm}…`));
109
165
  // PMs are .cmd shims on Windows → spawn needs shell:true to find them.
110
166
  const run = (a) => spawnSync(pm, a, { cwd: target, stdio: 'inherit', shell: process.platform === 'win32' });
111
167
  if (run(['install']).status === 0) run(['run', 'dev']);
@@ -0,0 +1,21 @@
1
+ # The app ROOT. `api` is the backend config (one place: base URL + default headers) — every `sources`
2
+ # inherits it. The shell's navbar reads the cart store (global) and shows a live count.
3
+ api {
4
+ base: "https://fakestoreapi.com"
5
+ }
6
+
7
+ shell {
8
+ Header style(row, between, center) class("p-4 shadow bg-white") {
9
+ Link "Shop" -> / class("text-xl font-bold")
10
+ Nav "Main" style(row, gap.md, center) {
11
+ Link "Products" -> /products
12
+ Span "🛒 {cart.count}" class("font-medium")
13
+ }
14
+ }
15
+ slot
16
+ }
17
+
18
+ routes {
19
+ / -> home
20
+ /products -> products
21
+ }
@@ -0,0 +1,9 @@
1
+ # An app-global store (any `*.store` under src/). State is shared across pages + the shell, no imports.
2
+ # `get` is a derived value; `action`s are the only way to mutate.
3
+ store { items = [] : list<text> }
4
+
5
+ get count = items.length
6
+
7
+ action add mutates items <- id {
8
+ items.push(id)
9
+ }
@@ -0,0 +1,26 @@
1
+ # A real data page: `query` state backed by a `sources` URL (relative → joined to the app's `api.base`).
2
+ # `muten build` fetches it at build and bakes the rows into the HTML (SSG); the runtime then takes over.
3
+ # Clicking "Add" calls the cart store action — the navbar count updates reactively.
4
+ screen products
5
+
6
+ meta { title "Products" }
7
+
8
+ entity Product { title text price text }
9
+
10
+ state { products = query products : list<Product> }
11
+
12
+ sources { products: "/products" }
13
+
14
+ Page style(padding.lg, gap.md) {
15
+ Title "Products" class("text-2xl font-bold")
16
+ when products.loading { Text "Loading…" class("opacity-60") }
17
+ each products as p {
18
+ Stack style(gap.sm) class("p-4 rounded-lg shadow bg-white") {
19
+ Text "{p.title}" class("font-semibold")
20
+ Stack style(row, between, center) {
21
+ Span "$ {p.price}" class("text-lg")
22
+ Button "Add to cart" -> cart.add(p.id) class("px-3 py-1 rounded bg-black text-white")
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,19 @@
1
+ # The app ROOT. A persistent `shell { … slot … }` wraps every page (navbar here); `slot` is where the
2
+ # active page mounts. `routes` maps a real-path URL to a page (the folder under src/pages/).
3
+ shell {
4
+ Header style(row, between, center) class("nav") {
5
+ Link "Home" -> /
6
+ Nav "Main" {
7
+ Link "About" -> /about
8
+ }
9
+ }
10
+ slot
11
+ Footer style(padding.md) {
12
+ Text "Built with Muten"
13
+ }
14
+ }
15
+
16
+ routes {
17
+ / -> home
18
+ /about -> about
19
+ }
@@ -0,0 +1,15 @@
1
+ # A second page. `meta { … }` sets the <head> title/description (og:* auto-derived) — applied on
2
+ # navigation and baked into the static HTML at build. This page has no reactivity, so `muten build`
3
+ # pre-renders it to zero-JS HTML (crawlable).
4
+ screen about
5
+
6
+ meta {
7
+ title "About"
8
+ description "About this Muten app."
9
+ }
10
+
11
+ Page style(padding.lg, gap.md) {
12
+ Title "About"
13
+ Text "Static pages compile to zero-JS HTML at build — crawlable and instant."
14
+ Link "← Home" -> /
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-muten",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Scaffold a new Muten app.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,6 +14,10 @@
14
14
  "bin": {
15
15
  "create-muten": "index.js"
16
16
  },
17
+ "dependencies": {
18
+ "@clack/prompts": "^0.7.0",
19
+ "picocolors": "^1.0.1"
20
+ },
17
21
  "keywords": ["muten", "create-muten", "scaffold", "starter", "cli", "frontend"],
18
- "files": ["index.js", "template"]
22
+ "files": ["index.js", "template", "overlays"]
19
23
  }
@@ -0,0 +1,64 @@
1
+ # Working in a Muten app — guide for AI agents
2
+
3
+ This project uses **Muten**, an AI-first frontend framework. The UI is written in **`.muten` files**
4
+ (a small declarative DSL) — **not** React, JSX, Vue, Svelte or hand-written HTML/JS. No model is
5
+ trained on Muten yet, so **follow this guide instead of guessing**: never import React/Vue, never write
6
+ `.jsx`/`.vue`, never add a JS bootstrap.
7
+
8
+ > Full language reference (every primitive, prop, token, pattern): the **`muten` skill** at
9
+ > [`skills/muten/SKILL.md`](skills/muten/SKILL.md).
10
+
11
+ ## Golden rules
12
+ - UI → `.muten` files. App-global state → `.store` files. Both compile via the `@muten/core` Vite plugin.
13
+ - **No `main.js`.** `src/app.muten` IS the entry (loaded by `index.html`). Never add a JS entry/bootstrap.
14
+ - Primitives are **PascalCase** (`Stack`, `Text`, `Button`); control flow is lowercase (`when`, `each`).
15
+ - **`style(...)`** = layout/typography tokens (Muten builds STRUCTURE). **`class("...")`** = your look
16
+ (your CSS / Tailwind). Muten ships no skin — appearance is yours.
17
+ - State references use `@name`; interpolate in any string with `{expr}`: `Text "Hi, {user.name}"`.
18
+
19
+ ## File map
20
+ ```
21
+ src/
22
+ app.muten ROOT — routes { /url -> page } (+ optional shell { … slot … })
23
+ pages/<route>/<route>.muten a page; the folder name IS the route
24
+ parts/<name>.muten reusable component (composition, inlined at build)
25
+ components/<Name>.js escape hatch (host JS) used via the `Custom` primitive
26
+ theme.muten design tokens: space, font, weight, breakpoints
27
+ src/styles.css your look (.scss if you picked SCSS)
28
+ ```
29
+
30
+ ## A page looks like this
31
+ ```
32
+ screen home
33
+
34
+ Page style(padding.lg, gap.md) {
35
+ Title "Hello"
36
+ Text "Body copy with reactive state: {user.name}"
37
+ Button "Save" -> save
38
+ }
39
+ ```
40
+
41
+ ## Cheat-sheet
42
+ - **Layout:** `Stack` (vertical), `Page` (`<main>`), `Header`/`Nav`/`Sidebar`/`Footer` (landmarks). Horizontal = `style(row)`.
43
+ - **Content:** `Text`, `Title "x" h2`, `Span`, `Image "{src}" alt "…"` (alt required), `Link "x" -> /route`, `Button "x" -> action(arg)`.
44
+ - **Data:** `DataTable @list columns(a, b)`, `Form bind @draft submit create`, `SearchField bind @q`.
45
+ - **Control:** `when <expr> { … }`, `each <list> as item { … }`.
46
+ - **Interactivity:** reactive class `class(active when isOpen)`; events on any element `on(keydown: act, mouseenter: act)`; a `/404` route catches unmatched paths.
47
+ - **State:** `state { q = "" : text users = query listUsers : list<User> }` — query states expose `.loading/.error/.data`.
48
+ - **Backend:** `sources { x: { url, method?, headers?, body?, at? } }` feeds a `query`. Shared base+auth go in `api { base, headers }` (app.muten, named clients via `{ api: "shop" }`) — relative source urls join to `base`. GET sources pre-render at build (SSG).
49
+ - **Writes:** a source-backed list gets `create`/`update`/`delete` in an action (`orders.create(draft)` → POST/PUT/DELETE the resource, optimistic + updates the list). The action is async with reactive `name.pending`/`name.error` for UX. Local-only mutations stay `push`/`set`/`reset`/`remove`.
50
+ - **Refetch:** re-run a query with N params (search / paginate / filter): `products.refetch(q: term, page: n)` in an action → builds `?q=&page=` and reloads the list.
51
+ - **Escape hatch:** non-RESTful API? `post`/`put`/`delete` a `"client:/path"` (interpolated) with optional `body` in an action: `post "shop:/orders" body item`. Uses the client's base+headers; `mutates` is optional for pure commands.
52
+ - **Actions:** `action add mutates users <- item { users.push(item) }` — ops: `push/set/reset/remove`; branch with `if/else`.
53
+ - **Tokens:** `gap.md padding.lg cols.3 text.lg row center between` — responsive prefix: `md:cols.2`.
54
+
55
+ ## Dependencies & limits
56
+ - **CSS / Tailwind / SCSS: YES** — it's a Vite app; install them and use `class("…")` + your CSS.
57
+ - **React / Vue / Svelte UI libraries: NO** — the UI is `.muten` (vanilla DOM, no JS framework runtime).
58
+ Need a JS widget? Wrap it in a `Custom` host component (`src/components/<Name>.js`).
59
+ - Routing uses **real paths** (`/path`, History API; deploy serves `index.html` for any path); route params work (`/product/:id` → `param id`). SEO: `meta { title "…" description "…" }` per page → `<head>` tags (og auto-derived). Shell has no local state → use a
60
+ `.store`. No `toggle` op → `set(not x)`. `style()` is layout tokens only; visuals go in `class()`.
61
+ - The full reference (stores, routing, theme, every primitive) is in [`skills/muten/SKILL.md`](skills/muten/SKILL.md).
62
+
63
+ ## Commands
64
+ `npm run dev` (dev server + HMR) · `npm run build` (production) · `npm run lint` (validate `.muten`).
@@ -0,0 +1,352 @@
1
+ ---
2
+ name: muten
3
+ description: Read and write Muten — the AI-first frontend DSL this app is built in (.muten and .store files), NOT React/Vue/HTML. Use whenever creating or editing any .muten or .store file, app.muten routes, theme.muten, parts, components, stores, or deciding what can be installed. No model is trained on Muten, so consult this before writing any Muten code or adding a dependency. Assume the human will NOT read the code — you do everything.
4
+ ---
5
+
6
+ # Muten — complete language reference
7
+
8
+ Muten compiles `.muten` files to vanilla JS + fine-grained signals (no virtual DOM). The `@muten/core`
9
+ Vite plugin does the compiling. You write a small declarative DSL — **never** React/JSX/Vue/Svelte/HTML.
10
+ A page with no reactivity compiles to plain zero-runtime HTML; a reactive one ships ~1KB of signals.
11
+
12
+ ## Mental model & golden rules
13
+ - **UI** → `.muten` files (pages, parts, the app root, the theme). **App-global state** → `.store` files.
14
+ - **`src/app.muten` is the entry.** `index.html` loads it; the plugin boots it. **Never create `main.js`** or a `<script>` bootstrap.
15
+ - Primitives are **PascalCase** (`Stack`, `Text`); keywords/control flow are **lowercase** (`when`, `each`, `state`).
16
+ - `style(...)` = layout/typography **tokens** (Muten builds STRUCTURE). `class("...")` = **look** (your CSS / Tailwind); toggle reactively with `class(active when isOpen)`. Muten ships no skin.
17
+ - `@name` = a state reference. `{expr}` = interpolation inside any string: `Text "Hi, {user.name}"`.
18
+ - Each page has **one root node**. Reactivity is automatic: reading a state in interpolation / `when` / `each` re-renders just that spot.
19
+
20
+ ## 1. What you CAN install / use
21
+ This is a normal **Vite** project, so the whole Vite/npm ecosystem for **styling, build, and data** works:
22
+ - **Tailwind CSS — YES.** Install it (`tailwindcss`, `postcss`, `autoprefixer`), add the config + the
23
+ `@tailwind` directives to `src/styles.css`, and use utilities via `class("flex gap-4 rounded")`.
24
+ `class()` emits raw class names, so any CSS framework (Tailwind, UnoCSS, Bootstrap CSS, your own CSS) works.
25
+ - **Sass/SCSS** — supported out of the box if you scaffolded with SCSS (or add `sass`); use `src/styles.scss`.
26
+ - **Any Vite plugin / PostCSS plugin** — add it to `vite.config.mjs` alongside `muten()`.
27
+ - **Data / utility npm packages** — usable inside `.store` logic and inside `Custom` host components
28
+ (date libs, fetch wrappers, zod, etc.).
29
+ - **Host UI via the `Custom` primitive** — write vanilla JS in `src/components/<Name>.js` (charts,
30
+ maps, a third-party widget) and mount it with `Custom`. See §Custom.
31
+
32
+ ## 2. What you CANNOT do
33
+ - **No React / Vue / Svelte component libraries as UI.** There is no React/Vue runtime — the UI is
34
+ `.muten` compiled to vanilla DOM. You cannot drop in MUI, Chakra, Ant, shadcn, a React table, etc.
35
+ If you truly need a JS widget, wrap it yourself in a `Custom` component (vanilla JS).
36
+ - **No JSX / `.jsx` / `.vue` / hand-written DOM in pages.** No `className`, no hooks, no lifecycle.
37
+ - **No arbitrary inline CSS via `style()`** — `style()` only takes the layout/typography tokens below.
38
+ Visual styling (colors, borders, shadows) goes through `class("…")` + your CSS.
39
+
40
+ ## 3. Limitations (current)
41
+ - **Routing uses real paths** (`/path`, History API). Route params ARE supported: `/product/:id` → declare `param id`
42
+ in the page (see §10). `muten build` pre-renders pages to real HTML (SSG) so content is crawlable —
43
+ static pages ship zero JS; reactive pages get their content (lists, `each`, interpolation from mock data)
44
+ baked into the HTML, then the runtime boots for interactivity. No special syntax needed.
45
+ - **Shell has no local state** — put shell/cross-page state in a `.store` (see the mobile-menu pattern).
46
+ - **No `toggle` op** — flip a bool with `set(not x)`.
47
+ - **Forms**: `Form` (auto-generated from an entity) and `SearchField` (single text input) are the
48
+ built-ins; richer custom inputs need a `Custom` component for now.
49
+ - **Pages are single-root** (one top node per page).
50
+
51
+ ## 4. Files
52
+ ```
53
+ src/app.muten routes (+ optional shell) — the ROOT; read it first
54
+ src/pages/<route>/<route>.muten one page; the folder name IS the route
55
+ src/parts/<name>.muten reusable component (inlined at build time)
56
+ src/components/<Name>.js host-JS escape hatch, mounted via Custom
57
+ src/<domain>.store app-global state slice (domain = file name)
58
+ theme.muten token scale (space/font/weight/leading/breakpoints)
59
+ src/styles.css reset + look (or styles.scss)
60
+ index.html / vite.config.mjs wired to @muten/core; don't hand-edit the boot
61
+ ```
62
+
63
+ ## 5. Declarations
64
+ ```
65
+ screen <name> # page identity (first line of a page)
66
+
67
+ entity User { # data shape + validation (implicit `id uuid`)
68
+ name text required # constraints: required | min:N | max:N
69
+ email email required
70
+ role admin | member # `a | b | c` = enum
71
+ }
72
+
73
+ state { # page-LOCAL reactive state
74
+ q = "" : text
75
+ users = query listUsers : list<User> # query → async; exposes @users.loading/.error/.data
76
+ }
77
+
78
+ const TAX = 0.21 # compile-time immutable scalar (inlined, never reactive)
79
+
80
+ action add mutates users <- item { # mutation; `mutates` lists what it may change (enforced)
81
+ users.push(item) # ops: push | set | reset | remove
82
+ if item.vip { rating.set(5) } else { rating.set(1) } # if/else = the only branching in actions
83
+ }
84
+
85
+ mock { listUsers: [ { name: "Ana", role: admin } ] } # mock data for a query
86
+ sources { listUsers: { url: "https://api…", at: "results" } } # real data source for a query
87
+ ```
88
+
89
+ A `sources` entry is a complete HTTP request — a bare URL, or `{ url, method?, headers?, body?, at? }`:
90
+ ```
91
+ sources {
92
+ products: "https://api.shop.com/products" # GET, response is the array
93
+ orders: { url: "https://api…/orders", headers: { Authorization: "Bearer KEY" }, at: "data" }
94
+ search: { url: "https://api…/graphql", method: "POST", body: { query: "…" }, at: "data" }
95
+ }
96
+ ```
97
+ - `at` reads the array out of `json[at]` — dotted for nested envelopes (`"data.posts"`). Else the response IS the array. `body` is JSON-encoded (sets `content-type`).
98
+ - At build (`muten build`), **GET** sources are fetched and baked into the HTML (SSG); non-GET run only client-side (no build side-effects).
99
+ - **Headers ship to the client** like any browser fetch — use public keys or a per-user token, never a server secret.
100
+
101
+ **Don't repeat the backend — `api { }` in `src/app.muten`** sets the base URL + default headers for ALL sources:
102
+ ```
103
+ # src/app.muten
104
+ api { base: "https://api.shop.com/v1" headers: { Authorization: "Bearer KEY" } }
105
+ ```
106
+ ```
107
+ # any page — only what differs
108
+ sources {
109
+ products: { url: "/products", at: "data" } # → https://api.shop.com/v1/products, with the Authorization header
110
+ orders: { url: "/orders", at: "data" }
111
+ }
112
+ ```
113
+ A **relative** source url is joined to `base`; an **absolute** one (`https://…`) ignores it (other host). Source headers override the api defaults. Define the backend once.
114
+
115
+ **Multiple backends** — name the clients, pick one per source with `{ api: "name" }`:
116
+ ```
117
+ # src/app.muten
118
+ api {
119
+ shop: { base: "https://api.shop.com/v1", headers: { Authorization: "Bearer KEY" } }
120
+ cms: { base: "https://cms.io/api" }
121
+ }
122
+ ```
123
+ ```
124
+ sources {
125
+ products: { api: "shop", url: "/products", at: "data" }
126
+ posts: { api: "cms", url: "/posts", at: "data.posts" }
127
+ }
128
+ ```
129
+ No `api` field → the client named `default`. The flat `api { base, headers }` form is just a single default client.
130
+
131
+ **Writing to the backend (POST/PUT/DELETE)** — a source-backed list gets `create`/`update`/`delete` in an action, fired by an event; each hits the resource endpoint (reusing the source's `api` base + headers) and updates the list reactively:
132
+ ```
133
+ state { orders = query orders : list<Order> }
134
+ sources { orders: { api: "shop", url: "/orders", at: "data" } }
135
+
136
+ action buy mutates orders <- item { orders.create(item) } # POST /orders → append the result
137
+ action edit mutates orders <- item { orders.update(item) } # PUT /orders/{id} → replace by id
138
+ action drop mutates orders <- item { orders.delete(item) } # DELETE /orders/{id} → remove by id
139
+
140
+ Button "Buy" -> buy(product)
141
+ ```
142
+ The write action is **async** and exposes reactive **`buy.pending`** (true while in flight) and **`buy.error`** — use them for UX:
143
+ ```
144
+ when buy.pending { Text "Saving…" }
145
+ when buy.error { Text "Could not save: {buy.error}" }
146
+ ```
147
+ `create`/`update`/`delete` are **optimistic** — the list changes instantly, reconciles with the server response, and **reverts** if the request fails (with `.error` set). REST convention: create = POST to the collection, update/delete target `/{item.id}`. Local-only mutations stay `push`/`set`/`reset`/`remove`; `create/update/delete` talk to the server.
148
+
149
+ **Re-running a query — `refetch` (search / pagination / filters)** — call it in an action with **N named params**; they become the query string (`?q=…&page=…`, url-encoded) and the list reloads. Works for any web-app, not just lists:
150
+ ```
151
+ state { q = "" : text page = 1 : number products = query products : list<Product> }
152
+ sources { products: { url: "/products", at: "data" } }
153
+
154
+ action search mutates products <- term { products.refetch(q: term, page: 1) }
155
+ action next mutates products { page.set(page + 1) products.refetch(q: q, page: page) }
156
+
157
+ SearchField bind q
158
+ Button "Search" -> search(q)
159
+ Button "Next" -> next
160
+ ```
161
+ Pass as many params as you need (`q`, `page`, `sort`, `category`, …). The query's `.loading`/`.error` reflect the refetch.
162
+
163
+ **Escape hatch — explicit request** (when the API isn't RESTful): `post`/`put`/`delete` a `"client:/path"` (interpolated) with an optional `body`, in an action:
164
+ ```
165
+ action buy <- item { post "shop:/orders" body item } # any method, any path
166
+ action cancel <- o { delete "shop:/orders/{o.id}/cancel" } # custom path, interpolated
167
+ action ping { post "shop:/health" } # no body, no `mutates` needed
168
+ ```
169
+ It uses the named client's base + headers; the action is async with `.pending`/`.error`. Prefer `create`/`update`/`delete` when the API is RESTful (those also update the list); reach for `post`/`put`/`delete` only when the convention doesn't fit.
170
+
171
+ ## 6. Primitives
172
+ A bare string is the node's main prop. `{ }` = children. Lay out with `style()`, skin with `class()`.
173
+
174
+ | Primitive | Use | Example |
175
+ |---|---|---|
176
+ | `Stack` | vertical stack (flex column) | `Stack style(gap.md) { … }` |
177
+ | `Page` | page root `<main>` (one per route) | `Page style(padding.lg) { … }` |
178
+ | `Header`/`Nav`/`Sidebar`/`Footer` | landmarks | `Header style(row, between, center) { … }` |
179
+ | `Text` | paragraph, interpolates | `Text "Hi, {user.name}"` |
180
+ | `Title` | heading; level keyword | `Title "Dashboard" h2` |
181
+ | `Span` | inline text | `Span "{cart.total}"` |
182
+ | `Image` | `<img>`, **alt required** | `Image "{p.image}" alt "{p.title}"` |
183
+ | `Link` | client-side nav | `Link "Catalog" -> /catalog` |
184
+ | `Button` | runs an action | `Button "Save" -> save(draft)` |
185
+ | `SearchField` | text input bound to state | `SearchField bind @q "Search…"` |
186
+ | `Form` | auto-form from an entity draft | `Form bind @draft submit create "Save"` |
187
+ | `DataTable` | reactive table over a list/query | `DataTable @users columns(name, email)` |
188
+ | `RowAction` | a button inside each table row | `RowAction "Delete" -> remove(row.id)` |
189
+ | `slot` | outlet inside `shell` | `slot` |
190
+ | `Custom` | host-JS escape hatch | `Custom Chart inputs(data: @sales) on(pick: select)` |
191
+
192
+ Horizontal layout = a region with `style(row)` (there is no `Row` primitive). Clickable card =
193
+ `Button { … }` or `Link "" -> /x { … }` with children instead of a label.
194
+
195
+ Modifiers (after a primitive): `style(tokens)` · `class("css")` · `bind @state` · `submit action` ·
196
+ `where(clauses)` · `columns(a, b)` · `alt "…"` · `inputs(k: v)` · `on(event: action)`.
197
+ `class()` also toggles reactively (`class(active when isOpen)`); `on(event: action)` works on **any** element
198
+ (keydown, mouseenter, change, blur, …) and calls the action — use `Button -> action(arg)` when you need an arg.
199
+
200
+ ## 7. Theme — how it works
201
+ `theme.muten` supplies the **scale** (values); the engine owns only the **vocabulary** (token names).
202
+ ```
203
+ theme {
204
+ space { xs "4px" sm "8px" md "16px" lg "24px" xl "32px" }
205
+ font { sm "13px" md "15px" lg "20px" xl "28px" }
206
+ weight { medium "500" bold "700" }
207
+ leading { tight "1.2" normal "1.5" }
208
+ breakpoints { sm "640px" md "768px" lg "1024px" }
209
+ }
210
+ ```
211
+ A token like `gap.md` resolves to `gap: 16px` via `space.md`; `text.lg` → `font.lg`; `md:cols.2` uses
212
+ `breakpoints.md`. **No CSS/reset goes in `theme.muten`** — the reset and the look live in `src/styles.css`.
213
+
214
+ ### Style tokens (`style(...)`)
215
+ ```
216
+ row column wrap grid grow center between
217
+ gap.sm|md|lg padding.md|lg padding.x.md padding.y.md margin.md
218
+ cols.2|3|auto rows.2
219
+ text.sm|md|lg|xl weight.medium|bold leading.normal italic bold
220
+ align.left|center|right justify.center|between items.center|start
221
+ width.full height.full
222
+ ```
223
+ Responsive: prefix any token with a breakpoint → `md:cols.2`, `lg:cols.4` (`sm/md/lg/xl`).
224
+
225
+ ## 8. State, actions & reactivity
226
+ - `state` cells are signals; reading them in interpolation / `when` / `each` auto-updates that spot.
227
+ - `query` state is async → render with `when @x.loading { … }`, then use `@x.data`.
228
+ - Mutate **only** through `action`s, and only the state in `mutates` (the linter enforces it):
229
+ - `list.push(x)` (append; auto-fills uuid fields) · `s.set(v)` · `s.reset()` · `list.remove(x => x.id == id)`
230
+ - There is no `toggle`: `flag.set(not flag)`.
231
+ - Control flow in the tree: `when <expr> { … }` (mount/unmount), `each <list> as item { … }` (item is a scope var).
232
+ - Expressions: `== != < > <= >=`, `and or not`, `contains` (case-insensitive substring / list membership),
233
+ `+ - * /`, ternary `c ? a : b`, parentheses, refs (`user.name`, `cart.total`, `$item.x`).
234
+
235
+ ## 9. Stores — app-global state
236
+ A `.store` file = state shared across pages, **no prop drilling**. The file name is the domain.
237
+ ```
238
+ # src/ui.store → referenced everywhere as ui.<member>
239
+ state { menuOpen = false : bool }
240
+ get isOpen = menuOpen # derived/memoized value (read as ui.isOpen)
241
+ action toggleMenu mutates menuOpen <- x { menuOpen.set(not menuOpen) }
242
+ effect { /* runs whenever the store state it reads changes */ }
243
+ ```
244
+ Use it from any page/shell by name: `when ui.menuOpen { … }`, `Button "☰" -> ui.toggleMenu`. The Vite
245
+ plugin auto-detects every `.store` file. `get` = memoized; `effect` = reactive side-effect (Angular-style).
246
+
247
+ ## 10. Routing — how it works
248
+ `src/app.muten` maps URLs to pages. It uses **real paths** (`/about`, History API — client-side nav, no
249
+ reload); the **first route is the default**. The folder under `src/pages/` must match the page name.
250
+ *(Deploy: the host must serve `index.html` for any path — standard SPA fallback.)*
251
+ ```
252
+ routes {
253
+ / -> home # src/pages/home/home.muten
254
+ /about -> about # static page → compiles to zero-runtime HTML
255
+ /cart -> cart guard auth.loggedIn else /login # guard: a store boolean; redirect if false
256
+ /login -> login guard not auth.loggedIn else / # guest-only page
257
+ }
258
+ ```
259
+ Guards read a **store boolean**; when it flips (login/logout) the active route re-renders automatically.
260
+ A route named `/404` catches any unmatched path (otherwise the first route is shown).
261
+ Navigate with `Link "x" -> /path` (client-side, no reload).
262
+
263
+ **Route params:** a `:seg` in the route captures a URL value. The page declares it with `param <name>`,
264
+ then uses it as a read-only string in interpolation / `when` / expressions (it can't be mutated):
265
+ ```
266
+ # app.muten
267
+ routes { /product/:id -> product }
268
+ # src/pages/product/product.muten
269
+ screen product
270
+ param id
271
+ Page { Title "Product {id}" }
272
+ ```
273
+ Navigating `/product/1` → `/product/2` re-mounts the page with the new `id` (re-fetch the new item).
274
+
275
+ **`<head>` meta (SEO):** a page declares `meta { title "…" description "…" }` → `<title>` + `<meta>` tags
276
+ (`og:title`/`og:description` auto-derived). Applied on navigation and baked into the SSG HTML at build.
277
+
278
+ ### Shell (persistent chrome)
279
+ Wrap routes in a `shell { … slot … }` for a nav/footer around every page. `slot` is where the active
280
+ page mounts. The shell has **no local state** → use a store for things like a mobile menu:
281
+ ```
282
+ shell {
283
+ Header style(row, between, center) class("nav") {
284
+ Link "Home" -> /
285
+ Button "☰" -> ui.toggleMenu class("burger")
286
+ }
287
+ when ui.menuOpen { Stack class("mobile-menu") { Link "About" -> /about } }
288
+ slot
289
+ Footer { Span "© 2026" }
290
+ }
291
+ routes { / -> home }
292
+ ```
293
+
294
+ ## 11. Entities, forms & validation
295
+ `entity` defines a shape + constraints. `Form bind @draft submit create` auto-renders one input per
296
+ field and validates on submit (per-field `.field-error`), blocking the action if invalid.
297
+ ```
298
+ entity Task { title text required notes text done bool }
299
+ state { draft = {} : Task tasks = [] : list<Task> }
300
+ action create mutates tasks, draft <- t { tasks.push(draft) draft.reset() }
301
+ # in the page: Form bind @draft submit create "Add task"
302
+ ```
303
+
304
+ ## 12. Parts — reusable composition
305
+ `part` = a reusable fragment, **inlined at build** (not a runtime component). Pass OBJECTS (`$x.field`)
306
+ and ACTION callbacks (`-> $onPick(...)`).
307
+ ```
308
+ # src/parts/feature.muten
309
+ part Feature(item: Feature, onPick: action) {
310
+ Stack style(column, gap.sm) class("card") {
311
+ Title "{$item.title}" h3
312
+ Text "{$item.body}"
313
+ Button "Choose" -> $onPick($item.id)
314
+ }
315
+ }
316
+ # use it: Feature(item: f, onPick: select)
317
+ ```
318
+
319
+ ## 13. Custom — the host-JS escape hatch
320
+ For anything Muten can't express (a chart, a 3rd-party widget), write vanilla JS in
321
+ `src/components/<Name>.js` and mount it with `Custom`. It receives `inputs` (values/state) and wires
322
+ DOM events to your actions via `on`. This is the ONLY way to use non-Muten UI code.
323
+ ```
324
+ Custom Chart inputs(data: @sales) on(pointSelect: select)
325
+ # → src/components/Chart.js exports a mount(el, { inputs, on }) that builds vanilla DOM.
326
+ ```
327
+
328
+ ## 14. Gotchas
329
+ - It's NOT React: PascalCase primitives + `{ }` children; no JSX/hooks/`className`.
330
+ - No `main.js`/`<script>` — `app.muten` is the entry.
331
+ - `style()` (layout tokens) ≠ `class()` (look). No colors/borders in `style()`.
332
+ - `Image` without `alt` fails validation (`alt ""` for decorative).
333
+ - Actions may only touch their declared `mutates`.
334
+ - Want a library? If it's CSS → `class()`. If it's a JS widget → `Custom`. If it's React/Vue UI → not possible.
335
+
336
+ ## 15. Minimal full app
337
+ ```
338
+ # src/app.muten
339
+ routes { / -> home }
340
+
341
+ # src/pages/home/home.muten
342
+ screen home
343
+ state { name = "" : text }
344
+ action greet mutates name <- v { name.set(v) }
345
+
346
+ Page style(padding.lg, gap.md) {
347
+ Title "Hello"
348
+ SearchField bind @name "Your name"
349
+ when name { Text "Hi, {name}!" }
350
+ }
351
+ ```
352
+ Validate anytime: `npm run lint`.
@@ -8,7 +8,7 @@
8
8
  "lint": "muten lint"
9
9
  },
10
10
  "dependencies": {
11
- "@muten/core": "^0.0.2",
11
+ "@muten/core": "^0.0.3",
12
12
  "vite": "^8.0.16"
13
13
  }
14
14
  }
@@ -4,5 +4,6 @@ theme {
4
4
  space { xs "4px" sm "8px" md "16px" lg "24px" xl "32px" }
5
5
  font { sm "13px" md "15px" lg "20px" xl "28px" }
6
6
  weight { medium "500" bold "700" }
7
+ leading { tight "1.2" normal "1.5" loose "1.8" }
7
8
  breakpoints { sm "640px" md "768px" lg "1024px" }
8
9
  }
@@ -1,9 +0,0 @@
1
- /* Your look. Muten ships STRUCTURE (semantic tags) + LAYOUT (style tokens); the LOOK lives here,
2
- referenced from .muten via class("…"). This base reset is all a fresh app needs to start. */
3
- * { box-sizing: border-box; }
4
- body { margin: 0; font: 15px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; color: #111; }
5
- h1, h2, h3, h4, h5, h6, p { margin: 0; }
6
- h1 { font-size: 32px; font-weight: 700; letter-spacing: -.02em; }
7
- .stack { display: flex; flex-direction: column; } /* the class Stack and column layouts emit */
8
- img { max-width: 100%; display: block; }
9
- a { color: inherit; text-decoration: none; }