create-muten 0.0.2 → 0.0.3
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/index.js +77 -64
- package/package.json +5 -1
- package/template/.claude/AGENTS.md +59 -0
- package/template/.claude/skills/muten/SKILL.md +249 -0
- package/template/package.json +1 -1
- package/template/theme.muten +1 -0
- package/template/src/styles.css +0 -9
package/index.js
CHANGED
|
@@ -1,86 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// create-muten — scaffold a new Muten app
|
|
2
|
+
// create-muten — scaffold a new Muten app, with modern interactive prompts (@clack/prompts).
|
|
3
3
|
//
|
|
4
|
-
// npm create muten@latest [name] (
|
|
5
|
-
//
|
|
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] [--css|--scss|--tailwind] [--pm npm|pnpm|yarn|bun] [--no-install]
|
|
7
6
|
//
|
|
8
|
-
// Interactive in a TTY (prompts
|
|
9
|
-
// scriptable.
|
|
10
|
-
// unless --no-install, runs `<pm> install` + `<pm> run dev`.
|
|
7
|
+
// Interactive in a TTY (styled prompts: name, styling, package manager); flags / non-TTY make it
|
|
8
|
+
// scriptable. It scaffolds and — unless declined — runs `<pm> install` + `<pm> run dev`.
|
|
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');
|
|
19
18
|
const PKG = JSON.parse(readFileSync(join(SELF, 'package.json'), 'utf8'));
|
|
20
19
|
const PMS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
20
|
+
const STYLES = ['css', 'scss', 'tailwind'];
|
|
21
|
+
|
|
22
|
+
// the starter reset — written by the CLI so the template stays pure .muten (no default styles file).
|
|
23
|
+
const RESET = `/* Your look. Muten ships STRUCTURE + LAYOUT (style() tokens); the LOOK lives here, via class("…"). */
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
body { margin: 0; font: 15px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; color: #111; }
|
|
26
|
+
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
|
27
|
+
h1 { font-size: 32px; font-weight: 700; letter-spacing: -.02em; }
|
|
28
|
+
.stack { display: flex; flex-direction: column; }
|
|
29
|
+
img { max-width: 100%; display: block; }
|
|
30
|
+
a { color: inherit; text-decoration: none; }
|
|
31
|
+
`;
|
|
32
|
+
// Tailwind v4: one @import + the @tailwindcss/vite plugin (no config file). Preflight does the reset;
|
|
33
|
+
// .stack is a Muten layout primitive Tailwind doesn't know about, so it stays.
|
|
34
|
+
const TAILWIND_STYLES = `@import "tailwindcss";
|
|
35
|
+
|
|
36
|
+
/* Muten layout primitive Tailwind doesn't define */
|
|
37
|
+
.stack { display: flex; flex-direction: column; }
|
|
38
|
+
`;
|
|
39
|
+
const TAILWIND_VITE = `import muten from '@muten/core/vite-plugin-muten.js';
|
|
40
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
plugins: [muten(), tailwindcss()],
|
|
39
44
|
};
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
`;
|
|
46
|
+
const TAILWIND_NOTE = `
|
|
47
|
+
## Styling: Tailwind CSS v4 (installed)
|
|
48
|
+
This app has Tailwind. Write the LOOK with \`class("…")\` using Tailwind utilities, e.g.
|
|
49
|
+
\`class("flex gap-4 rounded-lg bg-zinc-900 text-white")\`. \`style()\` still owns Muten's layout/
|
|
50
|
+
typography tokens — don't put Tailwind classes in \`style()\`, and don't put layout in \`class()\`.
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
// Which PM launched us? npm/pnpm/yarn/bun set npm_config_user_agent — the idiomatic default.
|
|
54
|
+
const detectPM = () => { const ua = process.env.npm_config_user_agent || ''; return PMS.find((p) => ua.startsWith(p + '/')) || 'npm'; };
|
|
42
55
|
const validName = (n) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(n);
|
|
43
|
-
const
|
|
56
|
+
const keep = (v) => { if (isCancel(v)) { cancel('Cancelled.'); process.exit(0); } return v; };
|
|
44
57
|
|
|
45
58
|
async function main() {
|
|
46
|
-
// args: one positional name + optional flags (so the CLI is also scriptable / CI-friendly)
|
|
47
59
|
const argv = process.argv.slice(2);
|
|
48
60
|
const has = (f) => argv.includes(f);
|
|
49
61
|
const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
|
|
50
62
|
if (has('-v') || has('--version')) { console.log(PKG.version); return; }
|
|
51
|
-
console.log(
|
|
52
|
-
if (has('-h') || has('--help')) { console.log(' Usage: create-muten [name] [--css|--scss] [--pm npm|pnpm|yarn|bun] [--no-install]\n'); return; }
|
|
63
|
+
if (has('-h') || has('--help')) { console.log('Usage: create-muten [name] [--css|--scss|--tailwind] [--pm npm|pnpm|yarn|bun] [--no-install]'); return; }
|
|
53
64
|
|
|
54
65
|
let name = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--pm')[0];
|
|
55
|
-
let style = has('--scss') ? 'scss' : has('--css') ? 'css' : undefined;
|
|
66
|
+
let style = has('--tailwind') ? 'tailwind' : has('--scss') ? 'scss' : has('--css') ? 'css' : undefined;
|
|
56
67
|
let pm = val('--pm');
|
|
57
68
|
let install = has('--no-install') ? false : undefined;
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
if (pm && !PMS.includes(pm)) die(`Unknown package manager: "${pm}" (${PMS.join(', ')})`);
|
|
61
|
-
|
|
69
|
+
if (name && !validName(name)) { console.error(`Invalid name: "${name}" (letters, digits, . _ -)`); process.exit(1); }
|
|
70
|
+
if (pm && !PMS.includes(pm)) { console.error(`Unknown package manager: "${pm}" (${PMS.join(', ')})`); process.exit(1); }
|
|
62
71
|
const dpm = detectPM();
|
|
63
72
|
|
|
64
|
-
//
|
|
65
|
-
// flags + defaults instead of hanging on a prompt.
|
|
73
|
+
// Styled prompts only with a real TTY (piped/CI input would hang); otherwise use flags + defaults.
|
|
66
74
|
if (process.stdin.isTTY) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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();
|
|
75
|
+
intro(color.bgCyan(color.black(' create-muten ')) + color.dim(' the AI-first frontend framework'));
|
|
76
|
+
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 }));
|
|
77
|
+
if (!style) style = keep(await select({ message: 'Styling', options: [
|
|
78
|
+
{ value: 'css', label: 'Plain CSS', hint: 'zero deps' },
|
|
79
|
+
{ value: 'scss', label: 'SCSS', hint: 'adds sass' },
|
|
80
|
+
{ value: 'tailwind', label: 'Tailwind CSS', hint: 'utility classes via class("…") — fastest to style' },
|
|
81
|
+
] }));
|
|
82
|
+
if (!pm) pm = keep(await select({ message: 'Package manager', initialValue: dpm, options: PMS.map((p) => ({ value: p, label: p })) }));
|
|
83
|
+
if (install === undefined) install = keep(await confirm({ message: 'Install dependencies and start the dev server now?' }));
|
|
84
84
|
}
|
|
85
85
|
name = name || 'muten-app';
|
|
86
86
|
style = style || 'css';
|
|
@@ -88,24 +88,37 @@ async function main() {
|
|
|
88
88
|
if (install === undefined) install = false;
|
|
89
89
|
|
|
90
90
|
const target = resolve(name);
|
|
91
|
-
if (existsSync(target))
|
|
91
|
+
if (existsSync(target)) { (process.stdin.isTTY ? cancel : console.error)(`"${name}" already exists.`); process.exit(1); }
|
|
92
92
|
|
|
93
|
-
// scaffold from ./template
|
|
93
|
+
// scaffold from ./template (pure .muten) + apply the styling choice
|
|
94
94
|
cpSync(TEMPLATE, target, { recursive: true });
|
|
95
|
-
const ignore = join(target, '_gitignore');
|
|
95
|
+
const ignore = join(target, '_gitignore');
|
|
96
96
|
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
97
|
|
|
99
98
|
const pkgPath = join(target, 'package.json');
|
|
100
99
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
101
100
|
pkg.name = name;
|
|
102
|
-
|
|
101
|
+
const addDev = (deps) => { pkg.devDependencies = { ...(pkg.devDependencies || {}), ...deps }; };
|
|
102
|
+
|
|
103
|
+
if (style === 'tailwind') {
|
|
104
|
+
writeFileSync(join(target, 'src', 'styles.css'), TAILWIND_STYLES);
|
|
105
|
+
writeFileSync(join(target, 'vite.config.mjs'), TAILWIND_VITE);
|
|
106
|
+
addDev({ tailwindcss: '^4.0.0', '@tailwindcss/vite': '^4.0.0' });
|
|
107
|
+
const agents = join(target, '.claude', 'AGENTS.md'); // tell the AI Tailwind is available
|
|
108
|
+
if (existsSync(agents)) writeFileSync(agents, readFileSync(agents, 'utf8') + TAILWIND_NOTE);
|
|
109
|
+
} else {
|
|
110
|
+
writeFileSync(join(target, 'src', style === 'scss' ? 'styles.scss' : 'styles.css'), RESET);
|
|
111
|
+
if (style === 'scss') addDev({ sass: '^1.101.0' });
|
|
112
|
+
}
|
|
103
113
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
104
114
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
if (!install) {
|
|
116
|
+
if (process.stdin.isTTY) { note(`cd ${name}\n${pm} install\n${pm} run dev`, 'Next steps'); outro(color.green(`Created ${name} (${style})`)); }
|
|
117
|
+
else console.log(`\n Created ${name} (${style}, ${pm})\n cd ${name} && ${pm} install && ${pm} run dev\n`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
108
120
|
|
|
121
|
+
if (process.stdin.isTTY) outro(color.green(`Created ${name} (${style}) — installing with ${pm}…`));
|
|
109
122
|
// PMs are .cmd shims on Windows → spawn needs shell:true to find them.
|
|
110
123
|
const run = (a) => spawnSync(pm, a, { cwd: target, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
111
124
|
if (run(['install']).status === 0) run(['run', 'dev']);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-muten",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
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
22
|
"files": ["index.js", "template"]
|
|
19
23
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
- **State:** `state { q = "" : text users = query listUsers : list<User> }` — query states expose `.loading/.error/.data`.
|
|
47
|
+
- **Actions:** `action add mutates users <- item { users.push(item) }` — ops: `push/set/reset/remove`; branch with `if/else`.
|
|
48
|
+
- **Tokens:** `gap.md padding.lg cols.3 text.lg row center between` — responsive prefix: `md:cols.2`.
|
|
49
|
+
|
|
50
|
+
## Dependencies & limits
|
|
51
|
+
- **CSS / Tailwind / SCSS: YES** — it's a Vite app; install them and use `class("…")` + your CSS.
|
|
52
|
+
- **React / Vue / Svelte UI libraries: NO** — the UI is `.muten` (vanilla DOM, no JS framework runtime).
|
|
53
|
+
Need a JS widget? Wrap it in a `Custom` host component (`src/components/<Name>.js`).
|
|
54
|
+
- Routing is **hash-based with static paths** (no `:id` params yet). Shell has no local state → use a
|
|
55
|
+
`.store`. No `toggle` op → `set(not x)`. `style()` is layout tokens only; visuals go in `class()`.
|
|
56
|
+
- The full reference (stores, routing, theme, every primitive) is in [`skills/muten/SKILL.md`](skills/muten/SKILL.md).
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
`npm run dev` (dev server + HMR) · `npm run build` (production) · `npm run lint` (validate `.muten`).
|
|
@@ -0,0 +1,249 @@
|
|
|
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). 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 is hash-based** (`#/path`) and **paths are static** — there are **no route params** yet
|
|
42
|
+
(no `/product/:id`). Model per-item views with state + `when`, or a query param read in host JS.
|
|
43
|
+
- **Shell has no local state** — put shell/cross-page state in a `.store` (see the mobile-menu pattern).
|
|
44
|
+
- **No `toggle` op** — flip a bool with `set(not x)`.
|
|
45
|
+
- **Forms**: `Form` (auto-generated from an entity) and `SearchField` (single text input) are the
|
|
46
|
+
built-ins; richer custom inputs need a `Custom` component for now.
|
|
47
|
+
- **Pages are single-root** (one top node per page).
|
|
48
|
+
|
|
49
|
+
## 4. Files
|
|
50
|
+
```
|
|
51
|
+
src/app.muten routes (+ optional shell) — the ROOT; read it first
|
|
52
|
+
src/pages/<route>/<route>.muten one page; the folder name IS the route
|
|
53
|
+
src/parts/<name>.muten reusable component (inlined at build time)
|
|
54
|
+
src/components/<Name>.js host-JS escape hatch, mounted via Custom
|
|
55
|
+
src/<domain>.store app-global state slice (domain = file name)
|
|
56
|
+
theme.muten token scale (space/font/weight/leading/breakpoints)
|
|
57
|
+
src/styles.css reset + look (or styles.scss)
|
|
58
|
+
index.html / vite.config.mjs wired to @muten/core; don't hand-edit the boot
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 5. Declarations
|
|
62
|
+
```
|
|
63
|
+
screen <name> # page identity (first line of a page)
|
|
64
|
+
|
|
65
|
+
entity User { # data shape + validation (implicit `id uuid`)
|
|
66
|
+
name text required # constraints: required | min:N | max:N
|
|
67
|
+
email email required
|
|
68
|
+
role admin | member # `a | b | c` = enum
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
state { # page-LOCAL reactive state
|
|
72
|
+
q = "" : text
|
|
73
|
+
users = query listUsers : list<User> # query → async; exposes @users.loading/.error/.data
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const TAX = 0.21 # compile-time immutable scalar (inlined, never reactive)
|
|
77
|
+
|
|
78
|
+
action add mutates users <- item { # mutation; `mutates` lists what it may change (enforced)
|
|
79
|
+
users.push(item) # ops: push | set | reset | remove
|
|
80
|
+
if item.vip { rating.set(5) } else { rating.set(1) } # if/else = the only branching in actions
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
mock { listUsers: [ { name: "Ana", role: admin } ] } # mock data for a query
|
|
84
|
+
sources { listUsers: { url: "https://api…", at: "results" } } # real data source for a query
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## 6. Primitives
|
|
88
|
+
A bare string is the node's main prop. `{ }` = children. Lay out with `style()`, skin with `class()`.
|
|
89
|
+
|
|
90
|
+
| Primitive | Use | Example |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `Stack` | vertical stack (flex column) | `Stack style(gap.md) { … }` |
|
|
93
|
+
| `Page` | page root `<main>` (one per route) | `Page style(padding.lg) { … }` |
|
|
94
|
+
| `Header`/`Nav`/`Sidebar`/`Footer` | landmarks | `Header style(row, between, center) { … }` |
|
|
95
|
+
| `Text` | paragraph, interpolates | `Text "Hi, {user.name}"` |
|
|
96
|
+
| `Title` | heading; level keyword | `Title "Dashboard" h2` |
|
|
97
|
+
| `Span` | inline text | `Span "{cart.total}"` |
|
|
98
|
+
| `Image` | `<img>`, **alt required** | `Image "{p.image}" alt "{p.title}"` |
|
|
99
|
+
| `Link` | client-side nav | `Link "Catalog" -> /catalog` |
|
|
100
|
+
| `Button` | runs an action | `Button "Save" -> save(draft)` |
|
|
101
|
+
| `SearchField` | text input bound to state | `SearchField bind @q "Search…"` |
|
|
102
|
+
| `Form` | auto-form from an entity draft | `Form bind @draft submit create "Save"` |
|
|
103
|
+
| `DataTable` | reactive table over a list/query | `DataTable @users columns(name, email)` |
|
|
104
|
+
| `RowAction` | a button inside each table row | `RowAction "Delete" -> remove(row.id)` |
|
|
105
|
+
| `slot` | outlet inside `shell` | `slot` |
|
|
106
|
+
| `Custom` | host-JS escape hatch | `Custom Chart inputs(data: @sales) on(pick: select)` |
|
|
107
|
+
|
|
108
|
+
Horizontal layout = a region with `style(row)` (there is no `Row` primitive). Clickable card =
|
|
109
|
+
`Button { … }` or `Link "" -> /x { … }` with children instead of a label.
|
|
110
|
+
|
|
111
|
+
Modifiers (after a primitive): `style(tokens)` · `class("css")` · `bind @state` · `submit action` ·
|
|
112
|
+
`where(clauses)` · `columns(a, b)` · `alt "…"` · `inputs(k: v)` · `on(event: action)`.
|
|
113
|
+
|
|
114
|
+
## 7. Theme — how it works
|
|
115
|
+
`theme.muten` supplies the **scale** (values); the engine owns only the **vocabulary** (token names).
|
|
116
|
+
```
|
|
117
|
+
theme {
|
|
118
|
+
space { xs "4px" sm "8px" md "16px" lg "24px" xl "32px" }
|
|
119
|
+
font { sm "13px" md "15px" lg "20px" xl "28px" }
|
|
120
|
+
weight { medium "500" bold "700" }
|
|
121
|
+
leading { tight "1.2" normal "1.5" }
|
|
122
|
+
breakpoints { sm "640px" md "768px" lg "1024px" }
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
A token like `gap.md` resolves to `gap: 16px` via `space.md`; `text.lg` → `font.lg`; `md:cols.2` uses
|
|
126
|
+
`breakpoints.md`. **No CSS/reset goes in `theme.muten`** — the reset and the look live in `src/styles.css`.
|
|
127
|
+
|
|
128
|
+
### Style tokens (`style(...)`)
|
|
129
|
+
```
|
|
130
|
+
row column wrap grid grow center between
|
|
131
|
+
gap.sm|md|lg padding.md|lg padding.x.md padding.y.md margin.md
|
|
132
|
+
cols.2|3|auto rows.2
|
|
133
|
+
text.sm|md|lg|xl weight.medium|bold leading.normal italic bold
|
|
134
|
+
align.left|center|right justify.center|between items.center|start
|
|
135
|
+
width.full height.full
|
|
136
|
+
```
|
|
137
|
+
Responsive: prefix any token with a breakpoint → `md:cols.2`, `lg:cols.4` (`sm/md/lg/xl`).
|
|
138
|
+
|
|
139
|
+
## 8. State, actions & reactivity
|
|
140
|
+
- `state` cells are signals; reading them in interpolation / `when` / `each` auto-updates that spot.
|
|
141
|
+
- `query` state is async → render with `when @x.loading { … }`, then use `@x.data`.
|
|
142
|
+
- Mutate **only** through `action`s, and only the state in `mutates` (the linter enforces it):
|
|
143
|
+
- `list.push(x)` (append; auto-fills uuid fields) · `s.set(v)` · `s.reset()` · `list.remove(x => x.id == id)`
|
|
144
|
+
- There is no `toggle`: `flag.set(not flag)`.
|
|
145
|
+
- Control flow in the tree: `when <expr> { … }` (mount/unmount), `each <list> as item { … }` (item is a scope var).
|
|
146
|
+
- Expressions: `== != < > <= >=`, `and or not`, `contains` (case-insensitive substring / list membership),
|
|
147
|
+
`+ - * /`, ternary `c ? a : b`, parentheses, refs (`user.name`, `cart.total`, `$item.x`).
|
|
148
|
+
|
|
149
|
+
## 9. Stores — app-global state
|
|
150
|
+
A `.store` file = state shared across pages, **no prop drilling**. The file name is the domain.
|
|
151
|
+
```
|
|
152
|
+
# src/ui.store → referenced everywhere as ui.<member>
|
|
153
|
+
state { menuOpen = false : bool }
|
|
154
|
+
get isOpen = menuOpen # derived/memoized value (read as ui.isOpen)
|
|
155
|
+
action toggleMenu mutates menuOpen <- x { menuOpen.set(not menuOpen) }
|
|
156
|
+
effect { /* runs whenever the store state it reads changes */ }
|
|
157
|
+
```
|
|
158
|
+
Use it from any page/shell by name: `when ui.menuOpen { … }`, `Button "☰" -> ui.toggleMenu`. The Vite
|
|
159
|
+
plugin auto-detects every `.store` file. `get` = memoized; `effect` = reactive side-effect (Angular-style).
|
|
160
|
+
|
|
161
|
+
## 10. Routing — how it works
|
|
162
|
+
`src/app.muten` maps URLs to pages. It's a **hash router** (URLs look like `#/about`); the **first
|
|
163
|
+
route is the default**. The folder under `src/pages/` must match the page name.
|
|
164
|
+
```
|
|
165
|
+
routes {
|
|
166
|
+
/ -> home # src/pages/home/home.muten
|
|
167
|
+
/about -> about # static page → compiles to zero-runtime HTML
|
|
168
|
+
/cart -> cart guard auth.loggedIn else /login # guard: a store boolean; redirect if false
|
|
169
|
+
/login -> login guard not auth.loggedIn else / # guest-only page
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
Guards read a **store boolean**; when it flips (login/logout) the active route re-renders automatically.
|
|
173
|
+
Navigate with `Link "x" -> /path` (client-side, no reload). **No path params** (`:id`) yet.
|
|
174
|
+
|
|
175
|
+
### Shell (persistent chrome)
|
|
176
|
+
Wrap routes in a `shell { … slot … }` for a nav/footer around every page. `slot` is where the active
|
|
177
|
+
page mounts. The shell has **no local state** → use a store for things like a mobile menu:
|
|
178
|
+
```
|
|
179
|
+
shell {
|
|
180
|
+
Header style(row, between, center) class("nav") {
|
|
181
|
+
Link "Home" -> /
|
|
182
|
+
Button "☰" -> ui.toggleMenu class("burger")
|
|
183
|
+
}
|
|
184
|
+
when ui.menuOpen { Stack class("mobile-menu") { Link "About" -> /about } }
|
|
185
|
+
slot
|
|
186
|
+
Footer { Span "© 2026" }
|
|
187
|
+
}
|
|
188
|
+
routes { / -> home }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## 11. Entities, forms & validation
|
|
192
|
+
`entity` defines a shape + constraints. `Form bind @draft submit create` auto-renders one input per
|
|
193
|
+
field and validates on submit (per-field `.field-error`), blocking the action if invalid.
|
|
194
|
+
```
|
|
195
|
+
entity Task { title text required notes text done bool }
|
|
196
|
+
state { draft = {} : Task tasks = [] : list<Task> }
|
|
197
|
+
action create mutates tasks, draft <- t { tasks.push(draft) draft.reset() }
|
|
198
|
+
# in the page: Form bind @draft submit create "Add task"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## 12. Parts — reusable composition
|
|
202
|
+
`part` = a reusable fragment, **inlined at build** (not a runtime component). Pass OBJECTS (`$x.field`)
|
|
203
|
+
and ACTION callbacks (`-> $onPick(...)`).
|
|
204
|
+
```
|
|
205
|
+
# src/parts/feature.muten
|
|
206
|
+
part Feature(item: Feature, onPick: action) {
|
|
207
|
+
Stack style(column, gap.sm) class("card") {
|
|
208
|
+
Title "{$item.title}" h3
|
|
209
|
+
Text "{$item.body}"
|
|
210
|
+
Button "Choose" -> $onPick($item.id)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
# use it: Feature(item: f, onPick: select)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## 13. Custom — the host-JS escape hatch
|
|
217
|
+
For anything Muten can't express (a chart, a 3rd-party widget), write vanilla JS in
|
|
218
|
+
`src/components/<Name>.js` and mount it with `Custom`. It receives `inputs` (values/state) and wires
|
|
219
|
+
DOM events to your actions via `on`. This is the ONLY way to use non-Muten UI code.
|
|
220
|
+
```
|
|
221
|
+
Custom Chart inputs(data: @sales) on(pointSelect: select)
|
|
222
|
+
# → src/components/Chart.js exports a mount(el, { inputs, on }) that builds vanilla DOM.
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## 14. Gotchas
|
|
226
|
+
- It's NOT React: PascalCase primitives + `{ }` children; no JSX/hooks/`className`.
|
|
227
|
+
- No `main.js`/`<script>` — `app.muten` is the entry.
|
|
228
|
+
- `style()` (layout tokens) ≠ `class()` (look). No colors/borders in `style()`.
|
|
229
|
+
- `Image` without `alt` fails validation (`alt ""` for decorative).
|
|
230
|
+
- Actions may only touch their declared `mutates`.
|
|
231
|
+
- Want a library? If it's CSS → `class()`. If it's a JS widget → `Custom`. If it's React/Vue UI → not possible.
|
|
232
|
+
|
|
233
|
+
## 15. Minimal full app
|
|
234
|
+
```
|
|
235
|
+
# src/app.muten
|
|
236
|
+
routes { / -> home }
|
|
237
|
+
|
|
238
|
+
# src/pages/home/home.muten
|
|
239
|
+
screen home
|
|
240
|
+
state { name = "" : text }
|
|
241
|
+
action greet mutates name <- v { name.set(v) }
|
|
242
|
+
|
|
243
|
+
Page style(padding.lg, gap.md) {
|
|
244
|
+
Title "Hello"
|
|
245
|
+
SearchField bind @name "Your name"
|
|
246
|
+
when name { Text "Hi, {name}!" }
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
Validate anytime: `npm run lint`.
|
package/template/package.json
CHANGED
package/template/theme.muten
CHANGED
package/template/src/styles.css
DELETED
|
@@ -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; }
|