# ArtScript — Spec for AI A web language that compiles to JavaScript. Files are `.art`. Expressions are JavaScript; only the structure is new. ## Declarations (top level) ``` model User { id: ID name: String bio: String? tags: String[] } component UserCard(user: User, onDelete: Fn, big: Bool = false) { ...members and view } page Users "/users" { ...members and view } ``` - Types: `String Number Bool ID Email Date Fn Any File`, `model` names, `T[]` list, `T?` optional (may be null). - Field rules, enforced by the server: `name: String min=2 max=50` (length; for a Number, its value; for a list, its size), `code: String match="^[A-Z]{3}$"`, `email: Email unique`. - Defaults: `stock: Number = 0` (a literal); `create` may omit the field. - Files: `photo: File? max=2000000 accept="image/*"` (max in bytes). Pass the File from `file picked` straight to `create`/`update`: it's uploaded and stored as `{ url, name, type, size }` (`image post.photo.url`). - Changing a stored model needs no migration code; a new required field needs a default (or `?`); a renamed one: `title: String was="name"`. - `page Product "/products/:id"`: a component with a route; inside it `params.id` (String) and `query.tab` (from `?tab=`). `page NotFound "*"` catches unknown paths. Without a route: `/lowercase-name`. - `meta title="..." description="..." image="/og.png"` in a page sets its title, description and Open Graph tags. - `layout Main { ... slot ... }` wraps pages and stays mounted while they change (the only top-level layout applies to every page; `page X "/x" layout Main` picks one; `layout Docs layout Main` goes inside Main). Links to the current page get `aria-current="page"`. `link "x" to="/path"` and `navigate("/path")` change pages without reloading. `notify("Saved", "success")` shows a short message (`info`, `success`, `danger`). `page Admin "/admin" requires admin` (or `requires login`) only shows to them (others go to `/login` or `/`). `setTheme("dark" | "light" | "auto")`, `theme()`. ## Imports: `use` ``` use "date-fns" { format, addDays as plus } // npm package (install it with npm first) use "canvas-confetti" as confetti // default export use "./lib/money.ts" { toUSD } // your own JS/TS module: the way out for anything not built in ``` - Imported names work in every component and server fn, typed `Any`. ## Members (inside component/page, before the view) ``` state count = 0 // reactive; type inferred state users: User[] = [] // explicit type computed total = count * 2 // derived; assigning it overrides it until count changes fn add(x) { // function; body = JS statements if x == "" { return } users.push({ id: crypto.randomUUID(), name: x, tags: [] }) } ``` - `state`, `computed` and `fn` written outside any component are shared by all of them (`state cart: Item[] = []`: one cart for every page). - Assigning to a `state` updates the UI: `count++`, `name = "x"`, `users.push(u)`, `user.name = "x"`, also through a fn parameter (`fn sell(p) { p.stock-- }`). - A component that assigns its prop (`items = items.filter(...)`) changes the parent's state: pass a state (`List items=items`). - No hooks, setters or manual dependencies. - Statements: expression, `let x = ...`, `if cond { } else { }`, `for x in xs { }`, `while cond { }`, `return`, `try { } catch (e) { } finally { }`. - For DOM libraries (charts, maps), timers and subscriptions: ``` ref box // the element marked `canvas ref=box` (set before mount runs) ref chart // a ref also holds any value that isn't state mount { // once, when the view is in the page chart = new Chart(box, { data: points }) cleanup { chart.destroy() } // on unmount } effect { // re-runs when the states it reads change document.title = `${count} items` } ``` ## Backend: `api` and `data` ``` api users: User // REST at /api/users: validated against the model, data stored ``` - The model needs an `ID` field (if `create` omits it, the server assigns it). - Relations: `author: User` (or `tags: Tag[]`) in a stored model stores the id; writes take the row or its id, reads return the row (`post.author.name`; deeper with `list({ include: ["post.author"] })`), `where: { author: id }` filters. Deleting a referenced row fails unless the field is `cascade` (`post: Post cascade`). - Typed client in any component: `api.users.list(query?)`, `count(query?)`, `get(id)`, `create(obj)`, `update(id, changes)`, `remove(id)`. - Query: `list({ where: { active: true }, search: "pan", sort: "-price", limit: 20, offset: 40 })` (`-` = descending; `search` matches text fields); `count({ where, search })`. Inside `data` they re-run when the states they use change (`offset: page * 20`). - `data users = api.users.list()` loads on mount and **reloads by itself** after any write, login or logout. A list starts as `[]`, a count as `0`, `get` as `null` (`T?`). `users.loading` (until the first response), `users.error` (message or `null`), `users.reload()`. `... live` also reloads when someone else writes. - `await` and `try { } catch (e) { }` work as in JS; `e.message` explains a validation error (`e.details.field` names the field; `e.status` is 409 when a `unique` value is taken). - Access: `api notes: Note login` needs a session; `private` also scopes rows per user (the model needs `owner: ID`, filled in); `readonly` after it (`api orders: Order private readonly`): clients only read, server fns write; `admin`: anyone reads, admins write (accounts need `role: String`; the first account is "admin", later ones "user"). - `auth users` (the model needs `email: Email` and `password: String`): `auth.signup(obj)`, `auth.login(email, password)`, `auth.logout()`, `auth.logoutAll()`, `data me = auth.me()` (`T?`). `auth users with google, github`: `auth.loginWith("google")`. - `auth.requestReset(email)` emails a link to `/reset-password?token=...`, a page that calls `auth.resetPassword(query.token, password)`. With `verified: Bool` in the model, sign-up emails `/verify-email?token=...` (`auth.verifyEmail(query.token)`). - `server fn name(a, b) { ... }` runs on the server; call it as `server.name(a, b)` (also in `data`). Inside: `db.` (no `await`, not scoped per user), `me` (logged-in user or `null`), `fail("message", status?)` and `await email(to, subject, text)`. - `server job cleanup every "1h" { ... }` (`s m h d`): on the server, with `db`, `fail`, `email`. ## View One line per element: `tag content prop=value flag -> action { children }` ``` column gap=4 align=center { title "Users" text `Total: ${total}` muted input draft placeholder="Name" -> add(draft) button "Add" primary -> add(draft) if users.length == 0 { text "Empty" } else { for u, i in users { UserCard user=u onDelete=(id => users = users.filter(x => x.id != id)) } } } ``` | Element | Content | `->` fires on | Props | Flags | |---|---|---|---|---| | `text` | text | — | | bold muted small large danger | | `title` | text | — | | muted small large | | `button` | text | click | disabled | primary danger small | | `input` | **state to bind** (two-way) | Enter | placeholder type disabled label | required | | `textarea` | state to bind | — | placeholder rows disabled label | required | | `select` | state to bind | change | **options** placeholder disabled label | | | `radio` `tabs` | state to bind | change | **options** label | | | `checkbox` | Bool state | change | label disabled | | | `file` | state (`File`, or list with `multiple`) | change | accept label disabled | multiple | | `modal` | Bool state (open) | — | gap pad align justify | | | `image` | src | — | alt width height | | | `video` `audio` | src | — | video: width height poster | controls autoplay loop muted | | `link` | text | — | to href target | muted | | `badge` | text | — | | primary success danger | | `icon` | Lucide name (`"check" "trash" "edit" "search" "user" "home"`...) | — | size label | muted primary success danger | | `spinner` `divider` | — | — | | | | `canvas` | — | — | width height | | | `row` `column` `card` | — | — | gap pad align justify | row: wrap | | `grid` | — | — | gap pad align justify cols | | | `form` | — | submit | gap pad align justify | | | `list` > `item` | item: text | item: click | | item: muted | | `table` > `tr` > `th` `td` | th/td: text | tr: click | | td: muted | - All take `class style id role aria-* data-*` (`button "Menu" aria-expanded=open`); texts and containers take `tag=` for the HTML element (`title "Plans" tag=h1`, `column tag=nav`; default: `title` is an h2, `text` a span); a field without `label=` is named by its `placeholder`. `style { .box { ... } }` in a component: CSS only for its elements. `.css` files in the project are bundled; theme: `:root { --a-primary: #e11d48; --a-radius: 4px; --a-font: Inter }` (also `--a-bg --a-fg --a-surface --a-border --a-muted --a-danger --a-success`). - Conditional flag: `text t.title muted=t.done`. - `gap=4` and `pad=4`: 1 unit = 4px. `align=start|center|end|stretch`. `justify=start|center|end|between|around`. `cols=3`. - `type=text|number|email|password|checkbox|date`. With `type=checkbox`, `input` binds a Bool. - `options=["S", "M"]` or objects (`value`/`id`, `label`/`name`); the state gets the option's value. `label="Email"` adds a visible label. `modal open { ... }` shows while `open` is true (Esc or the backdrop set it to false). - `item`, `th`, `td`, `link` take text and/or `{ children }` (`link to="/p/1" { card { ... } }`). - Prop values: literal, name, `a.b`, call, or `( expression )` in parentheses. - Component: `Name prop=value`. Children `Card { ... }` go where it puts `slot`; `slot header` is filled by `Card { header { ... } }`. `onPick: Fn(User)` types a callback (`onPick=(u => ...)` gets `u: User`). - Other events: `on:=statement` with `event`: `card on:mouseenter=(hover = true)`, `input q on:keydown=(event.key == "Escape" ? q = "" : null)`. - `for p in products key p.id { }`: rows matched by key keep their DOM and focus. - Responsive: `grid cols=1 md:cols=3 lg:gap=6` (`sm md lg xl` = 640/768/1024/1280px; `cols gap pad`). - Multi-statement action: `-> { a(); b = 1 }`. ## Tests `test "adds a task" {` then one step per line, `}`; `art test` runs them in a simulated browser. Steps: `open "/path"`, `see "x"`, `notSee "x"`, `click "Label" [n]`, `link "Label"`, `fill "Placeholder" "value"`, `press "Placeholder" "Enter"`, `select 0 "Option"`, `check 0`. ## Expressions JavaScript: literals, `/regex/`, `` `template ${x}` ``, `a.b`, `a?.b`, `a[i]`, `f(x)`, `x => x * 2`, `{ a, ...b, [key]: 1 }`, `[...xs]`, `? :`, `??`, `&&`, `||`. `==` and `!=` compile to `===` and `!==`. A line starting with `?`, `:`, `.`, `&&`, `||` or `??` continues the previous one. JS globals work (`Math JSON Date crypto fetch localStorage`...). ## Rules the compiler checks - `list.find(...)` and `api.x.get(id)` are `T?`: use `?.field`, `?? value` or `if x { }`. - Inside `if x { }`, `if x != null`, `x && ...`, `x ? ... : ...` or after `if !x { return }`, `x` is no longer null. - Objects passed as a `model` need every non-optional field and no extra fields. - Unknown names, elements, props and flags → error with a suggestion. ## Changing existing code Answer with a ```` ```patch ```` block instead of rewriting files: `replace Todos/column/title` with the new code indented below it; also `insert before|after `, `append `, `remove `, `set Todos/column gap=6`, `add` (paths: `Component/tag/tag[n]`, `Component.member`, `Model.field`). The full format is in SPEC-EDIT.md. # ArtScript — spec for changing existing code The code you are given shows the syntax; this covers what it doesn't. Expressions are JavaScript (`==` compiles to `===`). ## Answer with a patch ```patch replace Todos/column/title title "My tasks" insert after Todos/column/row text "Type and press Enter" muted append Todos state filter = "all" fn clearDone() { todos = todos.filter(t => !t.done) } replace Todos.add fn add() { todos.push({ id: crypto.randomUUID(), title: draft, done: false }) } set Todos/column gap=6 -align set TodoItem compact: Bool = false remove Todos/column/if/else/text ``` - Operations: `replace`, `insert before|after`, `append` (children of a node; members or view of a component; fields of a model), `remove`, `set` (props on the same line, `-name` removes), `add` (new declarations, indented). - View paths: `Component/tag/tag[n]` (`n` = 0-based among siblings with the same tag; `if`, `else`, `for` are segments). Members: `Component.name`. Fields: `Model.field`. - `set Component name: Type` adds a prop to a component without rewriting it; then pass it where it's used. - Bodies are indented under their operation (no `{ }` around them). Applied in order, all or nothing. ## Members `state x = 0` · `state xs: Item[] = []` · `computed total = a * 2` · `fn name(a, b) { ... }` · `data items = api.items.list()` - Assigning updates the screen: `x++`, `xs.push(i)`, `item.done = true` (also on a prop or a fn parameter). - A `computed` can be assigned; it keeps that value until what it reads changes. - A component that assigns its own prop (`items = items.filter(...)`) changes the parent's state: pass a state (`List items=items`). Or pass a callback: `onRemove=(id => items = items.filter(i => i.id != id))`. - `xs.find(...)` and `api.x.get(id)` may be null: `?.`, `??` or `if x { }`. ## View `tag content prop=value flag -> action { children }`, one element per line. - Elements: `text title button input textarea select radio tabs checkbox file modal image link badge icon spinner divider row column card grid form list item table tr th td`, and components (`Name prop=value`). `icon "trash"` (Lucide names); `notify("Saved", "success")` shows a message. - `button "x" -> action` (click), `input state -> action` (Enter; binds the state), `form { } -> action` (submit), `select x options=[...]`. - Flags: `text`/`title`: `bold muted small large danger primary success`; `button`: `primary danger small`. Conditional flag: `muted=t.done`. - Layout props: `gap=4 pad=4` (×4px), `align=start|center|end`, `justify=start|center|end|between`, `cols=3`. - Values in text use a template literal: ``text `Total: ${total}` `` (not `"Total: {total}"`). - Prop values: literal, name, `a.b`, call, or `(any expression)`. Multi-statement action: `-> { a(); b = 1 }`. - Control flow: `if cond { } else { }`, `for item, i in list { }`. - Unknown names, elements, props and flags are compile errors with a suggested fix. # Introduction ArtScript is a web language and framework for building full-stack apps with AI. You describe the data, the pages and the behavior in `.art` files; the compiler turns them into a small JavaScript app and a Node server with a database. It exists for one reason: **an AI model should be able to build and change a web app for fewer dollars than with React, Svelte or Vue**, counting everything it costs (the docs it reads, the code it writes, the mistakes it makes and the retries that follow). ```art page Counter "/" { state count = 0 computed double = count * 2 row gap=2 { button "-" -> count-- text count bold button "+" primary -> count++ } text `Double is ${double}` muted } ``` That is a complete app: no imports, no hooks, no build configuration. ## Who it is for - **People who build with an AI agent** (Claude Code, Cursor, Windsurf or your own) and pay for its tokens. - **Agents themselves**: the whole language fits in a ~3K-token spec, every error comes with its fix, and changes are small patches instead of rewritten files. - **Small teams** that want a full-stack app (accounts, a database, an api, uploads, live data) without assembling a stack. ## What makes it cheaper for a model **Less code for the same app.** One line per element, a backend that is one line per resource, and no boilerplate. The same app takes about a third of the tokens it takes in React with TypeScript, and output tokens are the most expensive ones. **A language that fits in the prompt.** The [spec](docs/SPEC.md) describes all of it in about 3K tokens, and an agent that only changes existing code needs the [edit spec](docs/SPEC-EDIT.md) (about 800). With prompt caching it is paid once per session. **Mistakes caught before they run.** The compiler checks types, null safety, the api calls against the models, element names and props. Each error has a stable code, what was expected and the fix, as JSON with `art check --ai`. A wrong field name costs one cheap retry instead of a broken page. **Edits instead of rewrites.** `art context` gives the agent only the part of the project it needs, and `art patch` applies a small, typechecked change to it. The [benchmarks](#cost-eval-results) measure this with Claude Opus, Sonnet and Haiku against React, Svelte, Vue and SolidJS. ## What you get - **UI**: forms, lists, tables, tabs, modals, media, icons, layouts with responsive props, dark mode, scoped styles. - **Reactivity**: `state`, `computed` and `effect` on signals, with direct DOM updates (no virtual DOM). Apps ship about 5 KB of JavaScript, runtime included. - **Pages**: routes with params, layouts, client-side navigation, meta tags, guards and prerendered HTML. - **Backend**: models, REST apis with validation, SQLite storage, queries, relations, file uploads, live updates, server functions and scheduled jobs. - **Accounts**: sign-up and login with sessions, roles, per-user data, Google and GitHub sign-in, password reset and email verification. - **Tools**: a dev server with live reload, tests in a simulated browser, a formatter, a language server, an MCP server and a production build that is one Node file plus a Dockerfile. - **The JavaScript ecosystem**: `use` imports any npm package or your own TypeScript. ## How it relates to the frameworks you know ArtScript is closest to Svelte and SolidJS: a compiler and fine-grained signals, no virtual DOM. It differs in scope and audience: it also covers the backend (models, apis, auth, storage), and its syntax is designed to be written by models with the fewest tokens and the fewest ways to go wrong. Expressions are plain JavaScript, so there is little new to learn inside a line. ## Status ArtScript is at **version 0.1**. The language and tools are tested (unit, end-to-end and the cost eval run on every change) but the project is young: expect changes before 1.0, and read the [security](SECURITY.md) notes before putting real data in it. Next: the [quick start](/learn/quick-start). # Quick start You need **Node 24** or newer. ## Create an app ```sh npm create artscript@latest my-app cd my-app npm install npm run dev ``` Open http://localhost:3000. Edit `src/app.art` and save: the page reloads by itself. To start from a working app instead of a blank page, pick a template: ```sh npm create artscript@latest my-app -- --template todo ``` | Template | What it shows | |---|---| | `todo` | Lists, input binding, filters, components | | `blog` | Pages with routes, a layout, params, meta tags | | `users` | A full-stack CRUD: model, api and data | | `notes` | Accounts, private data and a server function | | `catalog` | Admin role, search, sorting and pagination | | `crm` | Relations, uploads, live data and a modal | ## What's in the project ```text my-app/ src/app.art your app: models, apis, pages, components and tests public/ static files, copied as they are (images, robots.txt...) ARTSCRIPT.md the language spec, for your AI agent ARTSCRIPT-EDIT.md the short spec for changing existing code AGENTS.md instructions that agents read by themselves (also CLAUDE.md) package.json npm run dev | build | check ``` You can split the app into as many `.art` files as you like, in any folders under `src/`: every declaration is visible from every file. `.css` files are bundled too. ## Your first change Replace `src/app.art` with: ```art page Home "/" { state name = "" state names: String[] = [] column gap=4 pad=8 { title "Guest list" row gap=2 { input name placeholder="Name" -> { names.push(name); name = "" } button "Add" primary -> { names.push(name); name = "" } } for n in names { text n } text `${names.length} guests` muted } } ``` `input name` binds the input to the `name` state both ways. `->` is the action: on a button it runs on click, on an input when Enter is pressed. Assigning or pushing to a `state` updates the screen; there's nothing else to call. ## Check, test, build ```sh npx art check # types and errors, with the fix for each one npx art test # the test "..." { } blocks, in a simulated browser npm run build # production build in dist/ ``` `art build` writes `dist/index.html` and a single minified `app.js`. When the app declares an `api`, it also writes `dist/server.js` (one file, no `node_modules`) and a `Dockerfile`: see [Deploying](/learn/deploy). ## Bring your agent Open the project in Claude Code, Cursor or any agent: `AGENTS.md` tells it to read `ARTSCRIPT.md` and to check its work with `art check --ai`. To give it the tools directly: ```sh claude mcp add artscript -- npx art mcp ``` More in [Working with AI agents](/ai/agents). Next, build a full-stack app in the [tutorial](/learn/tutorial). # Tutorial: a full-stack app We'll build **a reading list**: people sign up, add the books they want to read, mark them as read and filter them. It has a database, a REST api, accounts, private data, two pages and tests, in one file of about 100 lines. Create a project (see the [quick start](/learn/quick-start)) and replace `src/app.art` step by step. Run `npm run dev` and keep the page open: it reloads on every save. ## 1. The data A `model` describes a shape. An `api` stores it and serves it over REST: ```art model Book { id: ID title: String min=1 max=120 author: String? read: Bool = false } api books: Book ``` That line gives you `/api/books` with list, get, create, update and remove, validated against the model on the server (the title must have 1 to 120 characters) and stored in SQLite. `author` is optional (`String?`), and `read` defaults to `false`, so `create` may leave it out. ## 2. A page that lists them ```art page Books "/" { data books = api.books.list({ sort: "title" }) state title = "" fn add() { await api.books.create({ title, author: null }) title = "" } column gap=4 pad=6 { title "Reading list" row gap=2 { input title placeholder="Book title" -> add() button "Add" primary -> add() } for b in books key b.id { row gap=2 { checkbox b.read -> api.books.update(b.id, { read: b.read }) text b.title muted=b.read button "Remove" small danger -> api.books.remove(b.id) } } } } ``` - `data books = api.books.list(...)` loads when the page mounts and **reloads by itself after every write**: after `create`, `update` or `remove` the list is fresh without any code. - `api.books.create({ ... })` is typed: a missing field or a wrong type is a compile error. - `checkbox b.read` binds the checkbox to the row's field; its `->` runs on change. - `text b.title muted=b.read` turns a flag on and off with a condition. - `key b.id` keeps each row's DOM when the list changes. ## 3. Loading and errors `data` has `loading` and `error`: ```art if books.loading { spinner } else { if books.length == 0 { text "Nothing yet. What do you want to read?" muted } } ``` A failed write throws; catch it to show the server's validation message: ```art state problem = "" fn add() { try { await api.books.create({ title, author: null }) title = "" problem = "" } catch (e) { problem = e.message } } ``` ## 4. Filtering Queries take `where`, `search`, `sort`, `limit` and `offset`. Inside `data` they re-run when a state they use changes: ```art state show = "All" state q = "" data books = api.books.list({ search: q, where: show == "To read" ? { read: false } : {}, sort: "title" }) ``` ```art row gap=2 { tabs show options=["All", "To read"] input q placeholder="Search" } ``` ## 5. Accounts and private lists Add a users model with `email` and `password`, declare `auth`, and make the books `private`: each user only sees and changes their own rows. A private model needs an `owner: ID` field, which the server fills in. ```art model User { id: ID email: Email unique password: String min=8 } api users: User auth users model Book { id: ID owner: ID title: String min=1 max=120 author: String? read: Bool = false } api books: Book private ``` Passwords are hashed with scrypt and never returned. Sessions are `HttpOnly` cookies. ## 6. Sign-in page and a guard ```art page Login "/login" { state email = "" state password = "" state problem = "" fn submit() { try { await auth.login(email, password) navigate("/") } catch (e) { problem = e.message } } fn join() { try { await auth.signup({ email, password }) navigate("/") } catch (e) { problem = e.message } } form gap=3 pad=6 -> submit() { title "Sign in" input email label="Email" type=email input password label="Password" type=password if problem != "" { text problem danger } row gap=2 { button "Sign in" primary button "Create account" -> join() } } } ``` Protect the list with `requires login`: visitors without a session go to `/login`. ```art page Books "/" requires login { data books = api.books.list({ sort: "title" }) // the rest of the page, as before } ``` `auth.me()` is `User?`: the compiler makes you handle `null` (`me?.email`, or `if me { }`). ## 7. A layout A layout wraps every page and stays mounted while they change: ```art layout Main { data me = auth.me() column gap=0 { row justify=between pad=4 { link "Reading list" to="/" if me { row gap=2 { text me.email muted small button "Sign out" small -> auth.logout() } } } slot } } ``` ## 8. Tests Tests describe what a person does and sees. `art test` runs them in a simulated browser with a fresh database: ```art test "adds a book after signing up" { open "/login" fill "Email" "ana@example.com" fill "Password" "12345678" click "Create account" see "Sign out" fill "Book title" "Dune" click "Add" see "Dune" } ``` ```sh npx art test ``` ## 9. Ship it ```sh npm run build cd dist && node server.js ``` `dist/server.js` is one file with the app, the api and the database (SQLite in `dist/data/`). There's a `Dockerfile` next to it; [Deploying](/learn/deploy) covers Fly.io, Railway, Render and plain servers. ## What you used `model`, `api` with `private`, `auth`, `data` with queries, `state`, `fn` with `await` and `try`, `page` with a route and `requires login`, `layout`, `form`, bound inputs, `for ... key`, conditional flags and `test`. That's most of the language; the rest is in the guides and the [spec](docs/SPEC.md). # Components and state A `component` (or a `page`, which is a component with a route) has **members** first and its **view** after them. Members hold the data and the logic; the view is one line per element. ```art component Greeting(name: String, excited: Bool = false) { computed message = excited ? `Hello, ${name}!` : `Hello, ${name}` text message bold } ``` ## State `state` declares reactive data. Its type is inferred from the value, or written explicitly: ```art state count = 0 state draft = "" state users: User[] = [] state selected: User? = null ``` Assigning to a state updates the screen, and so does changing it in place: ```art count++ draft = "" users.push({ id: crypto.randomUUID(), name: "Ana" }) users[0].name = "Bea" users = users.filter(u => u.id != id) ``` There are no setters, hooks or dependency arrays. The compiler knows which states a line reads and updates exactly those DOM nodes. ## Computed values `computed` derives a value from states, and recomputes when they change: ```art computed total = cart.reduce((sum, item) => sum + item.price * item.qty, 0) computed empty = cart.length == 0 ``` Assigning a computed overrides it until one of its dependencies changes, which is handy for editable defaults. ## Functions `fn` bodies are JavaScript statements: expressions, `let`, `if`/`else`, `return`, `try`/`catch` and `await`. Conditions don't need parentheses. ```art fn add(title) { if title.trim() == "" { return } todos.push({ id: crypto.randomUUID(), title, done: false }) draft = "" } ``` Parameters may have defaults (`fn load(page = 0)`). Changing a state through a parameter works too: `fn sell(p) { p.stock-- }`. ## The view Each line is `element content prop=value flag -> action { children }`: ```art column gap=4 align=center { title "Tasks" input draft placeholder="New task" -> add(draft) button "Add" primary disabled=(draft == "") -> add(draft) text `${pending} pending` muted } ``` - **Content** is the first value: the text of a `text` or `button`, the state an `input` binds to, the source of an `image`. - **Props** are `name=value`. A value is a literal, a name, `a.b`, a call, or any expression in parentheses. - **Flags** are bare words (`primary`, `muted`, `bold`). `muted=t.done` turns one on with a condition. - **`->`** is the element's action: click for buttons, Enter for inputs, change for selects, submit for forms. Several statements go in braces: `-> { save(); open = false }`. The full list of elements, with their props and flags, is in [UI elements](/reference/elements). ## Conditions and lists ```art if users.loading { spinner } else if users.length == 0 { text "No users yet" muted } else { for u, i in users key u.id { UserRow user=u position=i } } ``` `key` matches rows by identity, so rows that stay keep their DOM, their focus and their scroll. ## Props Props are typed, and may have defaults. A callback is `Fn`, or `Fn(User)` to type its argument: ```art component UserRow(user: User, position: Number = 0, onPick: Fn(User)) { row gap=2 on:click=(onPick(user)) { text `${position + 1}.` muted text user.name } } ``` Use it like an element: `UserRow user=u onPick=(u => selected = u)`. A component that assigns one of its props (`items = items.filter(...)`) changes the parent's state: pass it a state, and it's bound two ways. ## Children and slots A component places its children where it writes `slot`. Named slots take named blocks: ```art component Panel(heading: String) { card gap=3 pad=5 { row justify=between { title heading small slot actions } slot } } page Settings "/settings" { Panel heading="Profile" { actions { button "Save" primary -> save() } text "Your public information." muted } } ``` ## Events `->` covers the main action. Any other DOM event is `on:=statement`, with `event` in scope: ```art card on:mouseenter=(hover = true) on:mouseleave=(hover = false) { text "Hover me" } input q on:keydown=(event.key == "Escape" ? q = "" : null) ``` ## The DOM, timers and libraries `ref` names an element; `mount` runs once the view is in the page; `effect` re-runs when the states it reads change; `cleanup` runs on unmount. ```art use "chart.js/auto" as Chart component Sales(points: Number[]) { ref canvas mount { let chart = new Chart(canvas, { type: "line", data: { labels: points.map((_, i) => i), datasets: [{ data: points }] } }) cleanup { chart.destroy() } } effect { document.title = `${points.length} points` } column ref=canvas } ``` See [JavaScript libraries](/learn/libraries) for `use`. ## Null safety Values that may be missing have a `?` type: `list.find(...)`, `api.x.get(id)`, `auth.me()`. The compiler asks you to handle the missing case with `?.`, `??`, or a condition that narrows it: ```art computed user = users.find(u => u.id == selectedId) if user { text user.name } text user?.email ?? "Nobody selected" muted ``` # Pages and routing A `page` is a component with a URL. Pages change without reloading (History API), and `art build --prerender` turns each static one into a real HTML file. ```art page Home "/" { title "Welcome" link "See the products" to="/products" } page Products "/products" { title "Products" } ``` Without a route, a page is served at its lowercase name: `page About` is `/about`. ## Params and the query string `:name` in the route is a param, read as `params.name` (a `String`). `query` holds the query string: ```art page Product "/products/:id" { data product = api.products.get(params.id) state tab = query.tab ?? "details" if product { title product.name tabs tab options=["details", "reviews"] } } ``` `/products/42?tab=reviews` gives `params.id == "42"` and `query.tab == "reviews"`. ## Not found `*` catches every path that no other page matches: ```art page NotFound "*" { title "Page not found" link "Go home" to="/" } ``` ## Navigation `link "Text" to="/path"` is a real `` (it works with the middle button and "open in new tab") that navigates without reloading. From code, call `navigate`: ```art fn saved(id) { notify("Saved", "success") navigate(`/products/${id}`) } ``` `href="https://..."` links to other sites. `notify(message, kind)` shows a short message (`info`, `success`, `danger`). ## Layouts A `layout` wraps pages and **stays mounted** while they change: its state, its scroll and its `data` survive navigation. It puts the page where it writes `slot`. ```art layout Main { column gap=0 { row gap=4 pad=4 { link "Home" to="/" link "Products" to="/products" } slot } } ``` When there's only one top-level layout, every page uses it. With several, a page picks one: `page Admin "/admin" layout Dashboard`. ### Layouts inside layouts A layout can render inside another one. Here every page has the site header, and the docs pages also have a sidebar: ```art layout Site { column gap=0 { row gap=4 pad=4 { link "Home" to="/" link "Docs" to="/docs" } slot } } layout Docs layout Site { row gap=6 align=start { column gap=1 class="sidebar" { link "Introduction" to="/docs" link "Install" to="/docs/install" } slot } } page Intro "/docs" layout Docs { title "Introduction" } page Install "/docs/install" layout Docs { title "Install" } ``` Going from one docs page to another keeps both layouts mounted (the sidebar keeps its scroll and state); going home removes `Docs` and keeps `Site`. Links to the current page get `aria-current="page"`, so the active item of a menu is one CSS rule: ```css .sidebar a[aria-current="page"] { font-weight: 600; } ``` ## Titles and meta tags `meta` sets the page's title, description and Open Graph image: ```art page Pricing "/pricing" { meta title="Pricing · Acme" description="Plans for teams of every size." image="/og/pricing.png" title "Pricing" } ``` ## Guards `requires login` shows a page only to signed-in users; `requires admin` only to admins. Others are sent to `/login` (when the app has that page) or to `/`. ```art page Account "/account" requires login { data me = auth.me() text me?.email ?? "" } page Admin "/admin" requires admin { title "Admin" } ``` Guards keep a page from showing. The data is protected by the api itself (`login`, `private`, `admin`): see [Accounts and access](/learn/auth). ## Server rendering An app with an `api` is served by `dist/server.js`, which renders every page on the server for each request: the HTML arrives with the page's `data` already loaded, its title and meta tags, and as the visitor (their session decides what `auth.me()` and `private` apis return). Search engines and AI crawlers read real content on every page, including `/products/:id`, and people see it before the JavaScript loads. The responses the page was rendered with go in the HTML, so the browser doesn't ask for them again. A `requires login` page answers with a redirect to `/login` right from the server. Nothing changes in your code; `ART_SSR=off` turns it off. ## Prerendering and SEO ```sh art build --prerender --site https://example.com ``` Every page without params becomes an HTML file with its content, title and meta tags, readable by search engines and AI crawlers without JavaScript. `--site` also writes `sitemap.xml` and `robots.txt`. Pages with params (`/products/:id`) are served an empty shell (`_app.html`) that the app fills in. To serve the app under a subpath (a GitHub Pages project site, or a proxy prefix), add `--base /docs`: links, `navigate` and asset URLs get the prefix, and your code keeps writing `/products`. This website is built with `--base /ArtScript`. # Data and backend The backend lives in the same `.art` files as the UI. `art dev` runs it next to the app, and `art build` bundles it into one Node file. ## Models A `model` is a typed shape. Field types: `String Number Bool ID Email Date File Any`, other models, lists (`T[]`) and optional values (`T?`). ```art model Product { id: ID name: String min=2 max=80 sku: String match="^[A-Z]{3}-[0-9]{4}$" unique price: Number min=0 stock: Number = 0 tags: String[] photo: File? max=2000000 accept="image/*" } ``` - **Rules** run on the server for every write: `min` and `max` (length for text, value for numbers, size for lists), `match` (a regular expression), `unique`. - **Defaults** (`= 0`) let `create` leave the field out. - **Changing a model needs no migrations.** A new required field needs a default (or `?`); a renamed one keeps its data with `was="oldName"`. Every schema change makes a backup of the database first. ## Apis ```art api products: Product ``` One line serves `/api/products` with list, get, create, update and remove, validates every write against the model and stores the rows in SQLite (built into Node). The model needs an `ID` field; if `create` leaves it out, the server assigns one. From any component, the typed client: ```art api.products.list(query) api.products.count(query) api.products.get(id) api.products.create({ name, sku, price, tags: [] }) api.products.update(id, { price: 10 }) api.products.remove(id) ``` `create` is checked against the model at compile time: a missing field, an extra one or a wrong type is an error before anything runs. ## Loading data `data` loads when the component mounts and **reloads by itself after any write**, login or logout: ```art data products = api.products.list({ sort: "-price", limit: 20 }) data total = api.products.count() data product = api.products.get(params.id) ``` A list starts as `[]`, a count as `0` and `get` as `null`. Each has `.loading` (until the first answer), `.error` (a message or `null`) and `.reload()`. Add `live` to also reload when someone else changes the data: ```art data messages = api.messages.list({ sort: "-sent" }) live ``` ## Queries ```art state search = "" state page = 0 data products = api.products.list({ where: { active: true }, search, sort: "-price", limit: 20, offset: page * 20 }) data total = api.products.count({ where: { active: true }, search }) ``` - `where` matches fields exactly. - `search` matches the text fields. - `sort` is a field, `-field` for descending. - `limit` and `offset` page through the results. Inside `data`, the query re-runs when a state it uses changes: typing in a search box or going to the next page just works. ## Relations A field typed as another stored model keeps that row's id, and reads back as the row: ```art model Author { id: ID name: String } model Post { id: ID title: String author: Author tags: Tag[] } model Tag { id: ID label: String } model Comment { id: ID post: Post cascade text: String } api authors: Author api posts: Post api tags: Tag api comments: Comment ``` - Writes take the row or its id: `create({ title, author: me, tags: [] })`. - Reads return the row: `post.author.name`. Deeper levels are opt-in: `api.comments.list({ include: ["post.author"] })`. - `where: { author: id }` filters by the related row. - Deleting a row that others point to fails with a clear message, unless the field is `cascade`, which deletes them too. ## Files A `File` field takes the `File` from a `file` input. It's uploaded, checked against `max` and `accept`, and stored as `{ url, name, type, size }`: ```art page NewProduct "/products/new" { state name = "" state photo: File? = null form gap=3 -> api.products.create({ name, sku: "ABC-0001", price: 1, tags: [], photo }) { input name label="Name" file photo accept="image/*" label="Photo" button "Save" primary } } ``` Uploads go to the data directory, or to S3 and compatible stores (Cloudflare R2, MinIO) with the `ART_S3_*` variables. ## Server functions Logic that must run on the server goes in a `server fn`. Call it as `server.name(...)`, also inside `data`: ```art server fn restock(id, amount) { if !me { fail("Sign in first", 401) } let p = db.products.get(id) if !p { fail("No such product", 404) } db.products.update(id, { stock: p.stock + amount }) return db.products.get(id) } ``` Inside: `db.` (synchronous, not scoped to the user), `me` (the signed-in user or `null`), `fail(message, status)` and `await email(to, subject, text)`. ## Jobs ```art server job lowStock every "1d" { let empty = db.products.list().filter(p => p.stock == 0) if empty.length > 0 { await email("owner@example.com", "Out of stock", empty.map(p => p.name).join(", ")) } } ``` Intervals are `s`, `m`, `h` or `d`. Jobs have `db`, `fail` and `email`. ## Where the data lives In development, in `.art/data/` inside your project (ignored by git). In production, in `dist/data/` (or `ART_DATA_DIR`): `art.db` is the SQLite database and `files/` the uploads. See [Deploying](/learn/deploy) for backups and volumes. # Accounts and access Accounts are one declaration: a model with `email` and `password`, an api for it, and `auth`. ```art model User { id: ID email: Email unique password: String min=8 name: String? } api users: User auth users ``` Passwords are hashed with scrypt and never returned by the api. Sessions are random tokens in `HttpOnly; SameSite=Lax` cookies that last 30 days. Each user can only change or delete their own account. ## Signing up and in The `auth` client works from any component: ```art await auth.signup({ email, password, name: null }) await auth.login(email, password) await auth.logout() await auth.logoutAll() ``` `auth.me()` is the signed-in user, or `null`; as `data`, it updates after login and logout: ```art layout Main { data me = auth.me() row justify=between pad=4 { link "Home" to="/" if me { button "Sign out" small -> auth.logout() } else { link "Sign in" to="/login" } } slot } ``` Failed logins throw an error whose `message` you can show. After 10 failed attempts for an email from one address in 15 minutes, logins are refused for a while. ## Protecting data The api decides who can read and write. Add one word to the `api` line: | Access | Who reads | Who writes | |---|---|---| | (nothing) | anyone | anyone | | `login` | signed-in users | signed-in users | | `private` | each user, their own rows | each user, their own rows | | `admin` | anyone | admins | ```art model Note { id: ID owner: ID text: String } api notes: Note private ``` A `private` model needs `owner: ID`: the server fills it in on create, and every query only sees the user's own rows. ## Roles Give the accounts model a `role: String`. The first account becomes `"admin"` and every later one `"user"`; nobody can give themselves a role. ```art model User { id: ID email: Email unique password: String min=8 role: String } ``` Then `api products: Product admin` lets anyone read and only admins write, and `page Admin "/admin" requires admin` shows the page only to them. In a `server fn`, check `me?.role == "admin"`. ## Pages for signed-in users ```art page Dashboard "/dashboard" requires login { title "Dashboard" } ``` Visitors without a session are sent to `/login` if the app has that page, or to `/`. See [Pages and routing](/learn/routing). ## Google and GitHub ```art auth users with google, github ``` ```art button "Continue with Google" -> auth.loginWith("google") ``` Set `ART_GOOGLE_ID` and `ART_GOOGLE_SECRET` (or `ART_GITHUB_ID` and `ART_GITHUB_SECRET`). Only emails the provider has verified are accepted; a first sign-in creates the account. ## Password reset `auth.requestReset(email)` emails a single-use link to `/reset-password?token=...`, valid for an hour. That page calls `auth.resetPassword`: ```art page ResetPassword "/reset-password" { state password = "" state done = false if done { text "Password changed. You can sign in now." success } else { input password type=password placeholder="New password" button "Save" primary -> { await auth.resetPassword(query.token ?? "", password); done = true } } } ``` A reset ends every other session of the account. ## Email verification Add `verified: Bool` to the accounts model. Sign-up then emails a link to `/verify-email?token=...`; the page calls `auth.verifyEmail(query.token)`. New accounts always start unverified. ## Sending email In development, emails are printed to the console and saved to `outbox.jsonl`. In production, set `ART_RESEND_KEY` and `ART_EMAIL_FROM` to send through Resend, or `ART_EMAIL_WEBHOOK` to POST each email as JSON to your own service. Server functions send their own with `await email(to, subject, text)`. More defaults (rate limits, CSRF, headers) are listed in [Security](SECURITY.md). # Styling and themes ArtScript apps look finished without any CSS: elements have a clean default theme with dark mode. When you want your own look, there are four levels, from least to most code. ## 1. Layout props `row`, `column`, `grid`, `card`, `form` and `modal` take layout props. Spacing is in units of 4px: ```art column gap=4 pad=6 align=center { row gap=2 justify=between wrap { text "Left" text "Right" } grid cols=3 gap=4 { card pad=4 { text "One" } } } ``` - `gap` and `pad`: spacing (`gap=4` is 16px). - `align`: `start center end stretch`. `justify`: `start center end between around`. - `grid cols=3`: equal columns. `row wrap`: wrap onto new lines. ## 2. Responsive props Prefix `cols`, `gap` or `pad` with a breakpoint (`sm` 640px, `md` 768px, `lg` 1024px, `xl` 1280px). It applies from that width up: ```art grid cols=1 md:cols=2 lg:cols=4 gap=3 lg:gap=6 { card pad=4 { text "Responsive" } } ``` ## 3. The theme Any `.css` file in the project is bundled into `app.css`, after the defaults. Set the theme variables in `:root`: ```css :root { --a-primary: #e11d48; --a-radius: 4px; --a-font: "Inter", system-ui, sans-serif; --a-bg: #ffffff; --a-fg: #111111; --a-surface: #ffffff; --a-border: #e5e5e5; --a-muted: #737373; --a-danger: #dc2626; --a-success: #16a34a; } ``` Every element takes `class`, `style` and `id`, so regular CSS works too: `card class="hero"`, `text t.name style="letter-spacing: 1px"`. Both may be expressions: `class=(active ? "tab on" : "tab")`. ## 4. Scoped styles A `style { }` block inside a component applies only to that component's elements: ```art component PriceTag(price: Number) { style { .tag { font-weight: 700; color: var(--a-primary); } .tag:hover { text-decoration: underline; } } text `$${price}` class="tag" } ``` The compiler rewrites the selectors so they can't leak into other components. ## Dark mode Apps follow the system's light or dark setting. To let people choose: ```art button (theme() == "dark" ? "Light mode" : "Dark mode") -> setTheme(theme() == "dark" ? "light" : "dark") ``` `setTheme("dark" | "light" | "auto")` changes it and remembers it; `theme()` reads it and updates the view when it changes. For your own CSS, match both cases the same way the defaults do: ```css @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --brand: #a78bfa; } } :root[data-theme="dark"] { --brand: #a78bfa; } ``` ## Icons `icon` draws a [Lucide](https://lucide.dev) icon. Only the icons you use are included in the app: ```art row gap=2 { icon "search" muted icon "trash" size=16 danger label="Delete" } ``` The name must be written as text (the compiler bundles it); use `if` to switch between icons. ## Ready-made components `art add` copies official components into your project as source you own and can change: ```sh npx art add DataTable Pagination ConfirmButton SearchBox Stat EmptyState ``` # Testing Tests in ArtScript describe what a person does and sees, in the same `.art` files as the app. `art test` runs them in a simulated browser, with the real api and a fresh database for each test. ```art page Todos "/" { state todos: String[] = [] state draft = "" input draft placeholder="New task" -> { todos.push(draft); draft = "" } for t in todos { text t } text `${todos.length} tasks` muted } test "adds a task with Enter" { open "/" fill "New task" "Buy bread" press "New task" "Enter" see "Buy bread" see "1 tasks" } ``` ```sh npx art test ``` ```text ✓ "adds a task with Enter" 1 passed, 0 failed ``` ## Steps One step per line: | Step | What it does | |---|---| | `open "/path"` | Loads the app at that path (the first step; `open` alone is `/`) | | `see "text"` | Fails unless the text is on screen | | `notSee "text"` | Fails if the text is on screen | | `click "Label"` | Clicks the button with that text; `click "Delete" 2` clicks the third one | | `link "Label"` | Follows the link with that text | | `fill "Field" "value"` | Types into the input whose placeholder or label is `Field` | | `press "Field" "Enter"` | Presses a key in that input | | `select 0 "Option"` | Picks an option in the first select | | `check 0` | Toggles the first checkbox | Steps wait for the page to settle (pending api calls, re-renders) before the next one, so there are no sleeps or retries to write. ## Full-stack tests Because each test gets a fresh database and the real server, a test can sign up, create data and check that it's there after a reload: ```art test "keeps notes after reloading" { open "/signup" fill "Email" "ana@example.com" fill "Password" "12345678" click "Create account" fill "New note" "Remember the milk" click "Save" open "/" see "Remember the milk" } ``` ## Why tests matter more with AI A test is the cheapest way to tell a model what "done" means, and to know that a later change didn't break it. A failing step says what it expected and what the screen shows (or which buttons and inputs exist), which is usually enough for the agent to fix it in one step. Ask your agent to write tests for the main flows and to run `art test` after each change. # JavaScript libraries ArtScript covers the UI, the data and the server. For anything else (dates, charts, maps, payments, markdown, your own algorithms) there's the whole JavaScript ecosystem, through `use`. ## `use` ```art use "date-fns" { format, addDays } use "canvas-confetti" as confetti use "./lib/money.ts" { toUSD } ``` - `{ a, b }` imports named exports (`{ a as b }` renames one); `as name` imports the default export. - Packages come from your project's `node_modules`: install them with `npm install` first. - Local paths are relative to the `.art` file. TypeScript works as is. - Imported names are available in every component and server function of the project, typed `Any`. The compiler checks that the module exists and that it exports every name you import, so a typo is an error at compile time with the list of real exports. `art build` bundles everything into one minified file; only what you use is included. ## Your own TypeScript The way out for anything the language doesn't have is a plain module: ```ts // src/lib/money.ts export const toUSD = (cents: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); ``` ```art use "./lib/money.ts" { toUSD } component Price(cents: Number) { text toUSD(cents) bold } ``` This website does the same: its pages are ArtScript, and two small TypeScript modules render the repository's Markdown and run the compiler in the playground. ## DOM libraries: charts, maps, editors Libraries that draw into an element get it through `ref`, start in `mount` and stop in `cleanup`: ```art use "chart.js/auto" as Chart component SalesChart(values: Number[]) { ref box mount { let chart = new Chart(box, { type: "bar", data: { labels: values.map((_, i) => `Week ${i + 1}`), datasets: [{ label: "Sales", data: values }] } }) cleanup { chart.destroy() } } column ref=box } ``` `effect` re-runs when the states it reads change, to push new data into the library. You can also set properties of a ref directly: `box.innerHTML = html`, `box.scrollTop = 0`. ## On the server A `server fn` can use packages too, including Node-only ones (`stripe`, `pg`, `sharp`). `art build` bundles them into `dist/server.js`, which still runs without `node_modules`. ```art use "stripe" as Stripe server fn checkout(priceId) { let stripe = new Stripe(process.env.STRIPE_KEY) let session = await stripe.checkout.sessions.create({ mode: "payment", line_items: [{ price: priceId, quantity: 1 }], success_url: "https://example.com/thanks" }) return session.url } ``` # Recipes Short, complete answers to things most apps need. Each one is a whole program: paste it into `src/app.art` and run `npm run dev`. All of them are compiled by the test suite, so they stay correct. ## A form with validation The server checks every write against the model's rules; the form shows its message. ```art model Signup { id: ID name: String min=2 max=40 email: Email unique age: Number min=18 } api signups: Signup page Join "/" { state name = "" state email = "" state age = 18 state problem = "" state done = false fn submit() { try { await api.signups.create({ name, email, age }) done = true problem = "" } catch (e) { problem = e.message } } if done { text "Thanks, you're in." success } else { form gap=3 -> submit() { input name label="Name" required input email label="Email" type=email required input age label="Age" type=number if problem != "" { text problem danger } button "Join" primary } } } ``` `input age type=number` binds a `Number`. The error message comes from the server (`name must have at least 2 characters`, `email is already used`), so the rules live in one place. ## Search, sort and pagination `data` re-runs its query when a state it uses changes. ```art model Product { id: ID name: String price: Number } api products: Product page Products "/" { state q = "" state sort = "name" state page = 0 data items = api.products.list({ search: q, sort, limit: 10, offset: page * 10 }) data total = api.products.count({ search: q }) computed pages = Math.max(1, Math.ceil(total / 10)) column gap=3 { row gap=2 { input q placeholder="Search" on:input=(page = 0) select sort options=[{ value: "name", label: "Name" }, { value: "-price", label: "Most expensive" }, { value: "price", label: "Cheapest" }] } table { tr { th "Name" th "Price" } for p in items key p.id { tr { td p.name td `$${p.price}` } } } row gap=2 align=center { button "Previous" small disabled=(page == 0) -> page-- text `Page ${page + 1} of ${pages}` muted button "Next" small disabled=(page + 1 >= pages) -> page++ } } } ``` ## Confirm before deleting ```art model Note { id: ID text: String } api notes: Note page Notes "/" { data notes = api.notes.list() state asking = false state target: Note? = null fn ask(n) { target = n asking = true } fn confirm() { if target { await api.notes.remove(target.id) } asking = false notify("Deleted", "success") } column gap=2 { for n in notes key n.id { row gap=2 { text n.text button "Delete" small danger -> ask(n) } } } modal asking gap=3 { title "Delete this note?" small text target?.text ?? "" muted row gap=2 justify=end { button "Cancel" -> asking = false button "Delete" danger -> confirm() } } } ``` `modal asking` opens while `asking` is true; Esc and the backdrop close it. For a ready-made version, `npx art add ConfirmButton`. ## Upload an image with a preview ```art model Profile { id: ID name: String photo: File? max=2000000 accept="image/*" } api profiles: Profile page Profiles "/" { data profiles = api.profiles.list() state name = "" state photo: File? = null computed preview = photo ? URL.createObjectURL(photo) : "" fn save() { await api.profiles.create({ name, photo }) name = "" photo = null } column gap=3 { form gap=2 -> save() { input name label="Name" file photo accept="image/*" label="Photo" if preview != "" { image preview width=120 alt="Preview" } button "Save" primary } grid cols=2 md:cols=4 gap=3 { for p in profiles key p.id { card gap=2 { if p.photo { image p.photo.url alt=p.name } text p.name bold } } } } } ``` ## Remember a setting in the browser ```art page Settings "/" { state compact = localStorage.getItem("compact") == "yes" effect { localStorage.setItem("compact", compact ? "yes" : "no") } checkbox compact label="Compact view" text compact ? "Compact" : "Comfortable" muted } ``` `effect` re-runs whenever `compact` changes. For the theme, `setTheme` already remembers the choice. ## Tabs that live in the URL ```art page Account "/account" { state tab = query.tab ?? "Profile" effect { history.replaceState(null, "", `?tab=${tab}`) } tabs tab options=["Profile", "Billing", "Security"] if tab == "Profile" { text "Your profile" } else if tab == "Billing" { text "Your plan and invoices" } else { text "Password and sessions" } } ``` Reloading or sharing the link keeps the tab. ## Live data: a tiny chat ```art model User { id: ID email: Email unique password: String min=8 name: String } api users: User auth users model Message { id: ID author: User text: String min=1 max=500 sent: Number } api messages: Message login page Chat "/" requires login { data me = auth.me() data messages = api.messages.list({ sort: "sent", limit: 100 }) live state draft = "" fn send() { if me { await api.messages.create({ author: me, text: draft, sent: Date.now() }) draft = "" } } column gap=2 { for m in messages key m.id { row gap=2 { text m.author.name bold text m.text } } input draft placeholder="Message" -> send() } } ``` `live` reloads the list when anyone writes to it, over a single server-sent events connection. ## Call an external API with a secret Secrets stay on the server: read them in a `server fn` from the environment. ```art server fn weather(city) { let key = process.env.WEATHER_KEY ?? "" if key == "" { fail("WEATHER_KEY is not set", 500) } let res = await fetch(`https://api.example.com/weather?q=${encodeURIComponent(city)}&key=${key}`) if !res.ok { fail("The weather service didn't answer", 502) } let body = await res.json() return { city, temp: body.temp } } page Weather "/" { state city = "Buenos Aires" data report = server.weather(city) input city placeholder="City" if report { text `${report.temp}° in ${report.city}` } else { spinner } } ``` ## A chart Charting libraries draw into an element: `ref` hands it over, `mount` starts the chart and `cleanup` stops it. ```art use "chart.js/auto" as Chart page Sales "/" { state values = [12, 19, 7, 15, 22] ref canvas mount { let chart = new Chart(canvas, { type: "bar", data: { labels: values.map((_, i) => `Week ${i + 1}`), datasets: [{ label: "Sales", data: values }] } }) cleanup { chart.destroy() } } column ref=canvas style="max-width: 640px" } ``` Install it first: `npm install chart.js`. ## Format dates and money ```art use "./lib/format.ts" { day, money } page Invoice "/" { state issued = Date.now() state total = 1234.5 text `Issued ${day(issued)}` muted text money(total) bold large } ``` ```ts // src/lib/format.ts export const day = (ms: number) => new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(ms); export const money = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n); ``` ## An admin area ```art model User { id: ID email: Email unique password: String min=8 role: String } api users: User auth users model Post { id: ID title: String published: Bool = false } api posts: Post admin page Blog "/" { data posts = api.posts.list({ where: { published: true } }) for p in posts key p.id { text p.title } } page Admin "/admin" requires admin { data posts = api.posts.list() state title = "" input title placeholder="New post" -> api.posts.create({ title }) for p in posts key p.id { checkbox p.published label=p.title -> api.posts.update(p.id, { published: p.published }) } } ``` The first account is the admin. `admin` on the api protects the data; `requires admin` keeps the page from showing to anyone else. ## A nightly cleanup ```art model Draft { id: ID text: String updated: Number } api drafts: Draft server job cleanup every "1d" { let monthAgo = Date.now() - 30 * 24 * 3600 * 1000 db.drafts.list().filter(d => d.updated < monthAgo).forEach(d => db.drafts.remove(d.id)) } ``` Jobs don't overlap: if a run takes longer than the interval, the next one waits. # Working with AI agents ArtScript is built to be written by models. This guide is about getting the most out of that: what to give the agent, which tools to connect, and the habits that keep each change cheap. ## What the agent should read | Task | Give it | Size | |---|---|---| | Build an app or a new feature | The [spec](docs/SPEC.md) (`ARTSCRIPT.md` in every project) | ~3K tokens | | Change existing code | The [edit spec](docs/SPEC-EDIT.md) (`ARTSCRIPT-EDIT.md`) | ~800 tokens | | Understand a project | `art context` (the project map) | grows with the project | | Change one component | `art context Name` (its source and every patch path) | a few hundred tokens | The spec is the same in every request, so with prompt caching it is billed at the cache rate after the first one. The measured runs counted it in full anyway. Agents that crawl the web can start from [llms.txt](/llms.txt) or read everything at once in [llms-full.txt](/llms-full.txt). Every page of this site has a Markdown version and a "Copy page for your AI" button. ## Instructions file `art init` writes `AGENTS.md` and `CLAUDE.md` into each project. Agents that read instruction files (Claude Code, Cursor, Codex, Windsurf...) pick it up by themselves. It tells them to: - read `ARTSCRIPT.md` before writing code, or `ARTSCRIPT-EDIT.md` to only change it; - check every change with `npx art check --ai`; - read `npx art context` instead of whole files; - prefer a small `art patch` to rewriting files; - write `test` blocks and run `npx art test`. Cursor also gets `.cursor/rules/artscript.mdc`, a rule attached to every `.art` file. If your agent uses another file (`.github/copilot-instructions.md`, `.windsurfrules`), copy the same text there. ## MCP `art mcp` is an MCP server (stdio) with four tools: - `art_spec`: the spec, full or the short edit version. - `art_check`: typecheck; errors as JSON with `expected`, `actual` and `fixes`. - `art_context`: the project map, or the source and patch paths of the named parts. - `art_patch`: apply a patch; atomic and typechecked, nothing is written if it fails. Claude Code: ```sh claude mcp add artscript -- npx art mcp ``` Cursor (`.cursor/mcp.json`), Windsurf and other clients: ```json { "mcpServers": { "artscript": { "command": "npx", "args": ["art", "mcp"] } } } ``` Use `--dir path` when the project isn't the working directory. ## Errors that carry their fix ```sh $ npx art check --ai {"code":"E1011","type":"UNKNOWN_FIELD","loc":"src/app.art:13:10","at":"Users","expr":"u.emial","expected":"id|name|email","fixes":["email"]} ``` Each error is one JSON line: a stable `code` and `type`, where it is, the expression, what was expected, what was found and the suggested fixes. The model doesn't need to guess, and the retry is usually a one-line patch. The catalog is in [Errors](/reference/errors). The compiler is also tolerant of what models write out of habit when it's unambiguous (TypeScript-style function types, props without commas, a one-line `if`), and canonical formatting (`art fmt`) cleans it up afterwards. ## Patches instead of rewrites To change existing code, the agent sends only the change: ```patch replace Todos/column/title title "My tasks" insert after Todos/column/row text "Type and press Enter" muted set Todos/column gap=6 ``` Paths come from `art context`: `Component/tag/tag[n]` for the view, `Component.member` for members, `Model.field` for fields. The whole patch is checked and applied atomically, so a broken edit never lands half-way. The format is in the [edit spec](docs/SPEC-EDIT.md). ## A workflow that stays cheap 1. **Start from the spec**, and a template if one is close (`art init app --template users`). 2. **One feature per request.** Small requests mean small answers and small retries. 3. **Check after every change** (`art check --ai`) and feed the errors back as they are. 4. **Write tests for the flows that matter**, and run them after each change. 5. **For later changes**, give the edit spec and `art context` of the parts involved, and ask for a patch. ## Prompts that work For a new app: ```text Build a [description] in ArtScript. The spec is in ARTSCRIPT.md. Put it in src/app.art. Run `npx art check --ai` and fix every error. Add test blocks for the main flows and make `npx art test` pass. ``` For a change: ```text In the ArtScript app, [change]. Read ARTSCRIPT-EDIT.md and `npx art context [Component]`, answer with an art patch, apply it with `npx art patch`, then run `npx art check --ai` and `npx art test`. ``` ## What it costs The [benchmarks](/benchmarks) compare ArtScript with React, Svelte, Vue and SolidJS on the same tasks, with Claude Opus, Sonnet and Haiku, counting the spec, retries and thinking tokens. Over 50 tasks, ArtScript cost 50% less per working result than React with TypeScript with Sonnet, 45% less with Opus and 40% less with Haiku. # CLI Every command is `art `. In a project created with `art init`, run them with `npx art` or through the npm scripts (`npm run dev`, `npm run build`, `npm run check`). A `[path]` defaults to `./src` if it exists, otherwise the current directory. ## Projects ### `art init [--template t]` Creates a project: `src/app.art`, `public/`, the spec for your agent (`ARTSCRIPT.md`, `ARTSCRIPT-EDIT.md`), `AGENTS.md`, `CLAUDE.md` and a Cursor rule (`.cursor/rules/artscript.mdc`). Templates: `todo`, `blog`, `users`, `notes`, `catalog`, `crm`. ### `art dev [path] [--port 3000]` The development server: compiles on every save and reloads the page, serves the api with a local database in `.art/data/`, and prints emails instead of sending them. Dev tools: press Alt+A in the page (or click the "art" badge at the bottom right) for a panel with every mounted component and its props, states, computed and data, live. Click a state's value to change it; what each update writes to the page flashes. From the console, or for an agent driving the browser, `__art.snapshot()` returns the same as plain objects. None of this is in the production build. ### `art build [path] [--out dist] [--sourcemap] [--base /sub] [--prerender [--site url]]` The production build: `index.html`, one minified `app.js` (the runtime and every `use` module bundled in) and `app.css`. A dynamic `import()` inside a `use` module becomes its own file in `chunks/`, downloaded only when it runs. With apis, also `server.js` (a single file with the server and its packages, no `node_modules`) and a `Dockerfile`. - `--prerender`: an HTML file per page without params, with its content and meta tags. - `--site https://example.com`: with `--prerender`, also `sitemap.xml` and `robots.txt`. - `--base /sub`: the app is served under that path. - `--sourcemap`: `app.js.map`, pointing back to the `.art` files. - `--demo`: a full-stack app as static files. The api, accounts and server fns run in the visitor's browser with the server's rules, and their data stays in that browser: a demo that costs nothing to host (any static host, no server). See [Deploying](/learn/deploy). ## Code ### `art check [path] [--ai]` Typechecks the project. Each error has a code, a location, what was expected and found, and the suggested fixes. `--ai` prints one JSON line per error. ### `art fmt [path] [--write]` The canonical format. Without `--write` it prints the result; with it, it rewrites the files. ### `art patch [file|-] [--dir path] [--dry-run] [--ai]` Applies a patch (from a file, or stdin without one): `replace`, `insert before|after`, `append`, `remove`, `set` and `add` operations on paths of the code. Atomic and typechecked: if anything fails, nothing is written. `--dry-run` checks without writing. ### `art context [Name...] [--dir path] [--budget N]` Compact context for a model. Without names, the project map (models, apis, components and pages with their props). With names, their source and every addressable patch path. `--budget` caps the size in tokens. ### `art add ` Copies official components into the project as source: `DataTable`, `Pagination`, `ConfirmButton`, `SearchBox`, `Stat`, `EmptyState`. ### `art test [path]` Runs every `test "..." { }` block in a simulated browser, with the real api and a fresh database for each test. ## Tools ### `art mcp [--dir path]` An MCP server over stdio with `art_spec`, `art_check`, `art_context` and `art_patch`. See [Working with AI agents](/ai/agents). ### `art lsp` A language server over stdio: live errors with the compiler's fixes, formatting and completion, for any editor with LSP. The VS Code extension in `editors/vscode` uses it. For highlighting in Zed, Neovim and Helix there is a tree-sitter grammar in `editors/tree-sitter-artscript`. ### `art ast ` The syntax tree of a file as JSON. ### `art bench` Token and byte measurements against React and Svelte (inside the ArtScript repository). # Deploying an ArtScript app `art build` writes everything to `dist/`: - `index.html`, `app.js` (and `app.css`, prerendered pages, `app.js.map` when asked): the app. - With apis: `server.js`, one self-contained file that serves the app and its api (no `node_modules`), and a `Dockerfile`. The guides below follow from that layout. Docker and plain Node are exercised by the project's tests; the platform-specific steps haven't been run on each platform yet: check their docs if something differs. ## Apps without apis: any static host Upload `dist/`. Routes are client-side, so unknown paths must serve the app: - **Netlify**: a `public/_redirects` file with `/* /index.html 200` (files in `public/` are copied to `dist/`). - **Vercel**: `vercel.json` with `{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }`. - **Cloudflare Pages**, **GitHub Pages**: copy `index.html` to `404.html` (or use `--prerender` for the routes without params). With `--prerender`, routes without params are real HTML files; serve `_app.html` (an empty shell) for the rest when the host allows choosing the fallback. ## A full-stack app as a static demo: `art build --demo` ``` art build --demo ``` writes only static files: the app's api, accounts and server fns are bundled in and run **in the visitor's browser**, with the same rules as the server (validation, access, relations, `private`, `readonly`), and the data is kept in that browser's localStorage. Upload `dist/` to any static host (it includes Netlify's `_redirects` and a `404.html`), with no server and no database to pay for. It's for demos, prototypes and examples: every visitor has their own data (a "Demo · Reset" button erases it), nothing is shared between people, nothing is secret (the server fns' code is in the bundle) and emails aren't sent. The same source builds the real thing with `art build`. On Netlify, from a repository: build command `npx art build --demo`, publish directory `dist`, `NODE_VERSION=24`. ## Apps with apis: Node 24 ``` art build cd dist && PORT=3000 node server.js ``` Data lives in `dist/data/` (SQLite `art.db`, uploads in `files/`), or in `ART_DATA_DIR`. Keep that directory on persistent storage and back it up (copy `art.db` while the server is stopped, or `sqlite3 art.db ".backup backup.db"` while it runs). Every schema change makes an automatic `art-backup-*.db` before migrating. Run it under a supervisor (systemd, pm2) so it restarts: ``` [Service] WorkingDirectory=/srv/app/dist Environment=PORT=3000 ART_DATA_DIR=/srv/app/data NODE_ENV=production ExecStart=/usr/bin/node server.js Restart=always ``` ## Docker ``` art build docker build -t app dist docker run -p 3000:3000 -v app-data:/data app ``` The image has a health check (`/api/_health`) and keeps data in the `/data` volume. ## Fly.io From `dist/`, after `fly launch --no-deploy` (it detects the Dockerfile): ``` fly volumes create data --size 1 ``` and in `fly.toml`: ``` [mounts] source = "data" destination = "/data" [http_service] internal_port = 3000 ``` then `fly deploy`. Use a single machine: SQLite, rate limits and live streams are per process. ## Railway and Render Deploy `dist/` with its Dockerfile, add a persistent volume mounted at `/data`, and set `PORT` if the platform requires a specific one. One instance (see above). ## Behind a proxy (HTTPS) Terminate HTTPS at Caddy, nginx or the platform and forward to `PORT`. Pass `X-Forwarded-Proto: https` and `X-Forwarded-For` so the server sends HSTS, builds correct links in emails and OAuth, and rate-limits by the real address. ``` app.example.com { reverse_proxy localhost:3000 } ``` ## Environment variables | Variable | What it does | |---|---| | `PORT` | Port to listen on (3000). | | `ART_DATA_DIR` | Data directory (database, uploads, outbox). | | `ART_LOG=json` | One JSON log line per request. | | `ART_SSR=off` | Don't render pages on the server (they're rendered per request by default, with their data). | | `ART_CSP` | Replaces the Content-Security-Policy, or `off`. | | `ART_MAX_JSON`, `ART_MAX_UPLOAD` | Body limits in bytes (1 MB, 10 MB). | | `ART_RATE_LIMIT` | Api requests per address per minute (600; 0 = off). | | `ART_RESEND_KEY`, `ART_EMAIL_FROM` | Send emails through Resend. | | `ART_EMAIL_WEBHOOK` | Send emails as a JSON POST to this URL. | | `ART_S3_BUCKET`, `ART_S3_KEY`, `ART_S3_SECRET`, `ART_S3_REGION`, `ART_S3_ENDPOINT` | Keep uploads in S3 or a compatible store (Cloudflare R2, MinIO, ...) instead of the data directory. | | `ART_GOOGLE_ID`, `ART_GOOGLE_SECRET`, `ART_GITHUB_ID`, `ART_GITHUB_SECRET` | Sign-in with Google / GitHub. | ## Monitoring - `GET /api/_health` → `{"ok":true}`. - `GET /api/_metrics`: Prometheus text (requests by method and status, total time, open live streams). # Security ## Reporting a vulnerability Please report vulnerabilities privately through GitHub's **Report a vulnerability** button (Security tab of this repository). Don't open a public issue. You'll get an answer within 7 days; fixes are released as soon as they're ready and credited if you want. ## What ArtScript apps get by default - **Passwords**: scrypt hashes, never returned by the api; minimum 8 characters. - **Sessions**: random tokens in `HttpOnly; SameSite=Lax` cookies, 30 days, expired on the server too; `auth.logoutAll()`; a new password ends the user's other sessions. - **Rate limit**: 600 api requests per address per minute (`ART_RATE_LIMIT`), then `429`. - **Login**: 10 failed attempts per email and address in 15 minutes, then `429`. - **Password reset and email verification**: single-use links valid for one hour, stored as SHA-256 hashes; a reset request answers the same whether the account exists or not (5 per email per hour), and a reset ends every session of the account. New accounts are always unverified. - **Sign-in with Google/GitHub**: the flow carries a random `state` checked against an `HttpOnly` cookie; only provider-verified emails are accepted. - **CSRF**: writes must be JSON and uploads must carry `x-file-name`, which a cross-site page can't send without a CORS preflight (never answered). - **Access control**: `login`, `private` (rows scoped to their owner) and `admin` apis; relations can only point to rows the writer can see. - **Validation**: every write is checked against the model and its field rules on the server. - **Limits**: JSON bodies up to 1 MB (`ART_MAX_JSON`), uploads up to 10 MB (`ART_MAX_UPLOAD`). - **Uploads**: name, type and size come from the server's record; anything but images, video, audio, PDF and plain text is served as a download, always with `nosniff` and a sandbox CSP. - **XSS**: text is set with `textContent`; `javascript:`, `vbscript:` and `data:text/html` URLs from data become `#`. - **Headers** (`art build` server): CSP (`script-src 'self'`, `ART_CSP` to change it or `off`), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy`, and HSTS behind an HTTPS proxy (`X-Forwarded-Proto: https`). - **Static files**: the data directory, the server code and dotfiles are never served. ## Known limits - Rate limits and live streams are per process; behind several processes, use a shared limiter at the proxy. - No CORS: the api is meant to be served from the app's own origin.