# Getting started - **Status:** current on 2026-11-06, for version 0. - **Related documents:** [language spec](https://surface.solenovis.com/docs/language.md), [component reference](https://surface.solenovis.com/docs/components.md), [standard library](https://surface.solenovis.com/docs/standard-library.md), [limits of version 0](https://surface.solenovis.com/docs/limits.md) This guide makes a small shopping list app. The app has three files: a module file with a record type, a component for one row, and the root component. At the end, the same app runs in a browser and in a desktop window. --- ## 1. Make the project 1. Install the `surf` command. It is the only tool that you install, and a web build needs nothing else: ```bash curl -fsSL https://git.solenovis.com/henning.liemant/surface/raw/branch/main/scripts/install.sh | sh ``` The script puts `surf` into `~/.surf/bin`. Add this folder to the `PATH`. [The `surf` command](https://surface.solenovis.com/docs/commands.md) has the details. 2. Make a project with the name `groceries`: ```bash surf new groceries ``` 3. Start the development server: ```bash surf dev groceries ``` 4. Open `http://localhost:8000` in a browser. The page shows a counter. The command also prints the address of the [inspector](https://surface.solenovis.com/docs/inspector.md), the dev tools of the running app. The server builds the app again after each change to a file in `groceries/src`, and the page loads again. Keep the server open for the next steps. --- ## 2. Read the first component `surf new` wrote `groceries/src/App.surf`. Each project has this file. It is the root component. ```surf state count = 0; derived doubled = count * 2; --- Count: {count} Doubled: {doubled} ``` A component file has two parts. A line `---` divides them. - The part above the line has the declarations. `state count = 0` is a value that can change. `derived doubled` gets its value from other values. It changes when `count` changes. - The part below the line has the markup: the elements of the UI. `{count}` shows a value in text. `on:press={count += 1}` runs a statement each time the button gets a press. - `style` has utilities: `p-6` is a padding of 24 logical px, and `gap-3` is a space of 12 logical px between the children. A logical px is the same size on each screen. The web output writes it in `rem`, so the font size of the browser scales the app. When `count` changes, the runtime updates only the two `Text` elements that use it. There is no virtual DOM. --- ## 3. Add a record type Make the file `groceries/src/model.surfs`: ```surf pub type Item = { id: Int, name: String, done: Bool = false } pub fn left(items: List) -> String { const n = items.filter(i => !i.done).count(); return n == 1 ? "1 item left" : "{n} items left"; } ``` A file without `---` and markup is a module file. It has types and functions. - `pub` makes a declaration available to other files. Other declarations are private to the file. - `type Item` is a record type. `done` has a default value, so a new item can leave it out. - `items.filter(i => !i.done)` makes a new list with the items that are not done. The [standard library](https://surface.solenovis.com/docs/standard-library.md) lists all methods. Values in Surf are values, not references. A function that gets `items` gets its own copy, and a change to the copy does not change the original. --- ## 4. Make a component for one row Make the file `groceries/src/ItemRow.surf`: ```surf import { Item } from "./model.surfs"; bindable prop item: Item; event remove(id: Int); --- {item.name} ``` - `import` names a file relative to this file: `./` is the same folder and `../` the folder above. The files can be in subfolders of `src`. - `prop item: Item` is a value that the parent gives. `bindable` lets the component write the value back to the parent. - `bind:checked={item.done}` connects the check box to the field `done`. A click on the check box writes the field, and the write goes to the parent through the bindable prop. - `event remove(id: Int)` declares an event. `emit remove(item.id)` sends it to the parent. --- ## 5. Use the row in the root component Replace the contents of `groceries/src/App.surf` with this: ```surf import { Item, left } from "./model.surfs"; import ItemRow from "./ItemRow.surf"; state items: List = []; state draft = ""; state nextId = 1; fn add() { const name = draft.trim(); if name.isEmpty() { return; } items.push({ id: nextId, name: name }); nextId += 1; draft = ""; } fn remove(id: Int) { items = items.filter(i => i.id != id); } --- Groceries #for item in items key item.id { } empty { The list is empty. } {left(items)} ``` - `state items: List = []` needs the type, because an empty list does not show the type of its elements. - `bind:value={draft}` connects the text field to `draft`. `on:submit={add}` calls `add` when the user presses Enter. - `#for item in items key item.id { ... }` makes one `ItemRow` for each item. The key keeps the identity of a row when the list changes. The `empty` block shows when the list has no items. - `bind:item={item}` gives each row its item, and the writes of the row change that item in `items`. - `on:remove={remove}` calls `remove` with the `id` of the event. - `$text-secondary` is a theme token. It has a value for each variant of the theme: light, dark, and two variants with high contrast. A token always has `$`. The [layout spec](https://surface.solenovis.com/docs/layout.md), section 2.1, lists the tokens. Save the file. The page shows the list. Type a name, press Enter, and click the check box of an item. The text "1 item left" changes. A long `style` text can go over more than one line. `surf fmt` breaks it at a space and puts each new line under the first utility (ADR-0160): ```surf ``` The line breaks change nothing: a utility ends at a space or at the end of a line. --- ## 6. Find the errors before you run the app The compiler checks all files before it builds the app. Change `item.name` in `ItemRow.surf` to `item.title`, and run this command: ```bash surf check groceries ``` The command shows the file, the line, the column and the message of each error. The development server shows the same errors in the page. Change the name back. `surf lint` finds code that compiles but is probably wrong. Examples are a state that nothing reads, and a `#for` over records with no key: ```bash surf lint groceries ``` The editor extensions in `editors/vscode` and `editors/zed` show the errors and the warnings while you type. They use `surf lsp`. --- ## 7. Test the app `surf test` runs tests with no build, no browser and no window. 1. Give three elements of `groceries/src/App.surf` a `testid`: ```surf {left(items)} ``` 2. Make the file `groceries/tests/groceries.surfs`: ```surf import { Test } from "surf:test"; import { Item, left } from "~/model.surfs"; pub fn testLeft() { Test.equal(left([{ id: 1, name: "Milk" }]), "1 item left"); } pub fn testAdd() { Test.mount("App.surf"); Test.fill("draft", "Milk"); Test.press("add"); Test.equal(Test.text("left"), "1 item left"); } ``` 3. Run the tests: ```bash surf test groceries ``` The command shows `2 passed, 0 failed`. - A test is a `pub fn` with a name that starts with `test`. It has no parameters, and it is not `async`. - A test file is a module with a lowercase name. `~/model.surfs` is a path from `src`. - `Test.mount("App.surf")` runs the app with no window. `Test.press` and `Test.fill` send real input events to the element with that `testid`. - [Build a full app](https://surface.solenovis.com/docs/full-app.md), section 8, lists all functions of `Test`. --- ## 8. Build the app 1. Stop the development server. 2. Build the web app: ```bash surf build groceries ``` 3. Open `groceries/build/web/index.html` in a browser. The page needs no server. You can copy the folder `groceries/build/web` to any static web host. 4. Build the desktop app: ```bash surf build groceries --target desktop ``` The command writes the binary into the folder of the system of this machine, for example `groceries/build/desktop/linux-x86_64/groceries` on Linux, and `groceries/build/desktop/macos-aarch64/groceries.app` on a Mac. Each machine can also build Windows: `--os windows --arch x86_64` writes `groceries/build/desktop/windows-x86_64/groceries.exe` ([the `surf` command](https://surface.solenovis.com/docs/commands.md), section 3). The first native build downloads a private Rust toolchain and the runtime pack into `~/.surf`, and it compiles the runtime, so it takes some minutes. Later builds take some seconds. A native build needs a linker: on macOS, run `xcode-select --install`, and on Linux, install the package `gcc`. `surf doctor` shows what the machine has. 5. Start the binary in `groceries/build/desktop/`. On macOS, on Windows, and on Linux with a Wayland compositor, the app opens in a window. The window has the same UI as the web page. `surf dev groceries --target desktop` runs the app in a window and loads it again after each change, as the web server does. A file `src/head.html` goes into the head of the page, before the stylesheet, in `surf build` and in `surf dev`. Use it for a favicon, or for a script that must run before the first paint. A `` in this file replaces the name of the project. --- ## 9. Use a package A package is a folder of `.surf` and `.surfs` files from a git address or from a local path. 1. Add the package: ```bash surf add charts https://example.com/charts.git --rev v1.2.0 ``` 2. Import it: ```surf import { Bar } from "pkg:charts"; ``` `surf add` writes `surf.toml` and `surf.lock`, and it copies the package to `src/pkg/charts`. Keep these two files in git. `surf install` makes the same copy on another machine. An install runs no code of a package. `pkg:charts` reads `lib.surfs` of the package, and `pkg:charts/x.surfs` reads another file. --- ## 10. Next steps - The [component reference](https://surface.solenovis.com/docs/components.md) lists the built-in components, their attributes and their events. - The [standard library](https://surface.solenovis.com/docs/standard-library.md) lists the methods of `String`, `List`, `Map` and the other built-in types. - The [Surface Layout Spec](https://surface.solenovis.com/docs/layout.md), section 12, lists each utility of `style`. - The [language spec](https://surface.solenovis.com/docs/language.md) has all rules: slots, effects, async functions, and extern modules for code in JavaScript or Rust. - The [limits of version 0](https://surface.solenovis.com/docs/limits.md) lists the features that the compiler does not have yet. - `examples/todomvc` is a larger app with a router and storage in extern modules. - [Build a full app](https://surface.solenovis.com/docs/full-app.md) adds pages from files, a server, forms, sign-in, tests and a deployment. `examples/notes` has its code, and `examples/tickets` is a larger app with an API. <!-- https://surface.solenovis.com/docs/full-app.md --> # Build a full app - **Status:** current on 2026-11-06, for version 0. - **Related documents:** [getting started](https://surface.solenovis.com/docs/getting-started.md), [SurfWave reference](https://surface.solenovis.com/docs/routes.md), [limits of version 0](https://surface.solenovis.com/docs/limits.md), [security](https://surface.solenovis.com/docs/security.md) Read [getting started](https://surface.solenovis.com/docs/getting-started.md) first. This guide adds what a real web app needs: pages from files, data from a server, forms, sign-in, an API, tests and a deployment. `examples/notes` holds all the code of this guide, and the CI builds it. --- ## 1. Decide where your data lives Make this decision first, because version 0 limits it. - A project has no package manager. An extern Rust file can use the Rust standard library and the Surf runtime, but no other crate. So a Surf server has no client for a database. - **For a real database, run an API beside the Surf server.** Set `API_ORIGIN` to its address. A `load` function calls it with `Http.get("/api/x")`, and the server forwards the cookies and the `authorization` header. Only a relative URL gets these headers: an absolute URL goes out without them. A request from the browser to `/api/` goes to the same address, so the API must check each request. `examples/tickets` works this way. - **For a small app, a file is enough.** This guide uses a file, so that the guide has one process. --- ## 2. Make the routes A project with a `src/routes` folder has no `src/App.surf`. Delete that file. Each folder is a part of a URL: ``` src/ model.surfs the types store.server.surfs the notes (server only) store.server.native.rs the file of the notes hooks.server.surfs the check for each request routes/ Layout.surf the frame of each page Page.surf / Error.surf the page for a failed load load.server.surfs the data of Layout.surf and Page.surf notes/[id]/Page.surf /notes/1 notes/[id]/load.server.surfs new/Page.surf /new new/actions.surfs the form of /new login/Page.surf /login login/actions.surfs the form of /login api/notes/server.surfs GET /api/notes ``` `surf routes examples/notes` lists the routes. `surf dev` serves them. A project with server code also needs its server, so `surf dev` builds the server binary in the background and sends it the data requests, the forms and the endpoints. The first build is a native build, and it takes some minutes. A page shows its data when the output of `surf dev` says `the server code runs`. A layout shows its page with `@slot default`: ```surf prop user: String; slot default; --- <Column style="p-6 gap-4 max-w-160"> <Row style="gap-3 items-center"> <Link href="/">Notes</Link> <Link href="/new">New note</Link> </Row> @slot default </Column> ``` --- ## 3. Load data on the server A `load.server.surfs` file runs on the server only. `layout` gives the data of `Layout.surf`, and `load` gives the data of `Page.surf`: ```surf import { Home, Session } from "~/model.surfs"; import { allNotes } from "~/store.server.surfs"; import { Wave } from "surf:wave"; pub async fn layout() -> Result<Session, String> { return .ok({ user: Wave.cookie("user") }); } pub async fn load() -> Result<Home, String> { return .ok({ notes: allNotes() }); } ``` - `~/` is a path from `src`. - The result types must be in a file that the browser can read, such as `model.surfs`. A type in a server file is a compile error, because the browser reads the data too. - A page declares a field of the data as a prop: `prop notes: List<Note>`. - `.err(notFound())` shows `Error.surf` with status 404. `.err(failure(403, "No"))` gives another status, and `.err(redirect("/login"))` goes to another page. - The browser reads the value of each `load` function, also on the server. Return only what the page shows. - `Wave.env` in a file that the browser also reads takes only a name in quotes that starts with `PUBLIC_`. Read a secret in a `*.server.surfs` file. - Check a route parameter before you put it into a URL. `/` and `?` in a value change the address. Use a matcher: `[id=int]` or `[id=uuid]` as a folder name. --- ## 4. Store data in an extern module An extern module has a declaration, and an implementation for each target. The name of `store.server.surfs` keeps it out of the browser: ```surf pub extern module Disk { fn read() -> String; fn write(text: String); } ``` The implementation is `store.server.native.rs`: ```rust pub fn read() -> String { std::fs::read_to_string("notes.json").unwrap_or_default() } pub fn write(text: &String) { let _ = std::fs::write("notes.json", text); } ``` A `String` parameter is `&String` in Rust. `surf extern stubs <folder>` writes a stub with the correct types. `Json.encode` and `Json.decode` turn records into text. Write a file with one writer: the server handles requests on more than one thread. A relative path such as `notes.json` is in the working folder of the server. The server must be able to write into that folder. The unit of `--adapter systemd` makes `/var/lib/<name>` the working folder, and the other folders of the system are read-only for the app. --- ## 5. Take a form A form is a `Column` or another container with the role `form`. Its `action` names a function of `actions.surfs` after `?/`. An `Input` needs a `name`. The form works with no script. With the script, the page stays and shows the result: ```surf <Column role="form" method="post" action="/new?/save" style="gap-2"> <Input name="title" alt="Title" value="" /> <Button>Save</Button> <Text>{problem(form)}</Text> </Column> ``` The action reads `request.form`: ```surf import { addNote } from "~/store.server.surfs"; import { Failure, Request, failure, redirect } from "surf:wave"; pub async fn save(request: Request) -> Result<String, Failure> { const title = (request.form["title"] ?? "").trim(); if title.isEmpty() { return .err(failure(422, "A note needs a title")); } const note = addNote(title, request.cookies["user"] ?? ""); return .err(redirect("/notes/{note.id}")); } ``` The result reaches the page in the prop `form`, as JSON text. `Json.decode` gives the `Failure` back. A `redirect` is the usual end of an action that worked. A form from another site gets status 403. The server checks the `origin` of each form. An action must still check that the user can do the action. --- ## 6. Sign in `Wave.setCookie(name, value, maxAge)` works in a `load` of the server, an action, an endpoint, a hook and a remote function. Each cookie has `HttpOnly`, `SameSite=Lax` and `Path=/`. Each cookie also has `Secure`, except on `localhost`, `127.0.0.1` and `[::1]`. ```surf import { Failure, Request, Wave, redirect } from "surf:wave"; pub async fn signIn(request: Request) -> Result<String, Failure> { Wave.setCookie("user", request.form["name"] ?? "guest", 86400); return .err(redirect("/")); } ``` `examples/notes` trusts the name, so that the guide stays short. A real app checks a password in its API, and it stores a random session id in the cookie, not a name. The hook must then ask the API about that id. A hook protects pages. `before` runs for each request, so the check also holds for a request with no script and for the data of the page: ```surf import { Request, Response, redirectResponse } from "surf:wave"; pub async fn before(request: Request) -> Response? { if request.path == "/new" && !request.cookies.contains("user") { return redirectResponse("/login"); } return none; } ``` - `request.path` has one form. The server sends `//new`, `/%6eew` and `/new/` to `/new` before the hook runs, so `==` is safe. - For a folder, compare the folder and the text under it: `request.path == "/admin" || request.path.startsWith("/admin/")`. Do not use `startsWith("/admin")` alone, because `/administration` matches it too. The server refuses `%2F` and `%5C` in a path, so `/admin%2Fusers` cannot pass this guard and reach a route with `[...rest]`. - A remote function has the path `/__remote/<file>/<name>`, and an endpoint has its own path. Guard these paths too. - A `redirect` to a value of a user can leave your site. Accept only a path: [security](https://surface.solenovis.com/docs/security.md), section 4, has a function for this. A `layout` function can also guard a folder. `.err(redirect("/login"))` in that function stops each page in the folder, and the data of each page. [Security](https://surface.solenovis.com/docs/security.md) lists what the server does, and what an app must do itself. --- ## 7. Answer with JSON A `server.surfs` file has a function for each method: ```surf import { allNotes } from "~/store.server.surfs"; import { Request, Response, jsonResponse } from "surf:wave"; pub async fn get(request: Request) -> Response { return jsonResponse(Json.encode(allNotes())); } ``` For code that only your own pages call, a remote function needs less: see the [SurfWave reference](https://surface.solenovis.com/docs/routes.md), section 6. --- ## 8. Test the app Use these four steps. 1. `surf check <folder>` checks the types of the whole project. Run it first. 2. `surf lint <folder>` finds code that compiles but is probably wrong. A finding gives a non-zero exit code, so a CI job can fail on it. A comment `lint-ignore <rule>` on the line of a finding silences it. The rules are `name-case`, `for-key`, `unused`, `no-effect`, `no-name` (ADR-0070), `contrast` (ADR-0103), `duplicate-id` (ADR-0106), and `media-format`, `no-captions` and `autoplay-stop` (ADR-0155 to ADR-0158). 3. `surf test <folder>` runs each `pub fn test...` of the `.surf` files in `tests/` (ADR-0069). `--filter <word>` runs only the tests with that word in the name. This is `examples/notes/tests/pages.surfs`: ```surf import { Test } from "surf:test"; pub fn testTheFormOfANewNoteStartsWithNoProblem() { Test.mount("routes/new/Page.surf"); Test.equal(Test.text("form"), ""); Test.expect(Test.buttons("Save") == 1, "the form has one button Save"); } ``` A test runs no server. It reads the files that the browser reads, so it cannot import a server file. `Test.mount` mounts one component, for example one page of a route. A prop with no default value gets its value from `props`, a JSON object: `Test.mount("Row.surf", props: "\{\"label\": \"Milk\"\}")`. The JSON of a value is the JSON of `Json.encode`, so `Json.encode` of a record of the props also gives it. Test the server code with `curl`, as the end of this section shows. The functions of `surf:test`: | Function | What it does | |---|---| | `Test.expect(condition, message)` | Fails the test if the condition is false. | | `Test.equal(found, expected)` | Fails the test if the two strings differ. | | `Test.fail(message)` | Fails the test. | | `Test.mount(file, props: json)` | Runs the component of the file with no window. `props` is optional. | | `Test.press(id)` | Sends a press to the center of the element with that `testid`. | | `Test.pressText(text)` | Sends a press to the button with that text or that `alt`. One button must have it. | | `Test.hover(id)` | Moves the pointer to the center of the element with that `testid`. The pointer stays there. | | `Test.fill(id, text)` | Writes the text into the field with that `testid`. | | `Test.key(key)` | Sends a key to the element with the focus, for example `"Tab"`, `"Enter"` or `"a"`. | | `Test.text(id)`, `Test.value(id)` | Reads the text of an element, or the value of a field. | | `Test.count(id)` | Counts the elements with that `testid`. | | `Test.buttons(text)` | Counts the buttons with that text or that `alt`. | | `Test.focused()` | Reads the `alt` or the text of the element with the keyboard focus. With no focus, it gives `""`. | | `Test.color(id)` | Reads the text color of the element with that `testid`, as `#rrggbb`. | | `Test.role(text)`, `Test.description(text)` | Reads the role, or the description, that a screen reader gets for the button with that text. | | `Test.access()` | Reads the accessibility tree: one line for each node, with its role and its name. | | `Test.wait(ms)` | Moves the clock of the test by `ms` milliseconds. No real time passes. Timers, tasks and animations see the new time. | | `Test.settle()` | Waits in real time for tasks that wait for another thread, for example a request. It stops when the text does not change for 25 frames, or after 2 seconds. | | `Test.resize(width, height)` | Gives the window another size in logical px, for example for a narrow layout. The default is 800 × 600. | Use `Test.wait` for a time in the app, for example a toast that closes after 10 seconds. Use `Test.settle` for a request: `Test.wait` gives the request no real time. A test runs the native layout, not the CSS of the web. An extern module needs a host function or a `fallback` block in a test. 4. Test the build in a browser with Playwright. On the web, a `testid` is the attribute `data-testid`: ```javascript test("a new note shows", async ({ page }) => { await page.context().addCookies([{ name: "user", value: "ada", url: "http://localhost:3000" }]); await page.goto("http://localhost:3000/new"); await page.locator("[data-testid=title]").fill("Milk"); await page.locator("[data-testid=save]").click(); await expect(page.locator("[data-testid=title]")).toHaveText("Milk"); }); ``` Test a server with `curl`: `tests/server.sh` of this repository is a complete example. --- ## 9. Deploy `surf build --target server` writes `build/server/<name>-server`: one binary and no other file. A server is a native build, so the first one downloads a private Rust toolchain and the runtime pack into `~/.surf` ([the `surf` command](https://surface.solenovis.com/docs/commands.md)). Copy the binary to the host. It reads these environment variables: | Variable | Meaning | |---|---| | `HOST` | The address. The default is `127.0.0.1`, so a proxy on the same machine can reach the server and other machines cannot. A container needs `0.0.0.0`, and the `Dockerfile` of `--adapter docker` sets it. | | `PORT` | The port. | | `ORIGIN` | The address of the site in the browser, for example `https://example.com`. | | `API_ORIGIN` | The address of your API. | | Your own names | `Wave.env("NAME")` reads them in a file of the server. | The binary has no TLS, so a public site needs a proxy in front of it. Set `ORIGIN` for that site: a proxy that changes `Host` makes the server refuse each form. [Security](https://surface.solenovis.com/docs/security.md) has the steps, and the settings of each limit. A binary runs on the system that built it, so build on Linux for a Linux host. The `Dockerfile` of `--adapter docker` copies that binary into an image: ```bash surf build examples/notes --adapter docker ``` ```bash surf build examples/notes --adapter systemd ``` An app with no server code can use `--target static`, or the adapters `netlify`, `vercel`, `cloudflare` and `github-pages`. The [SurfWave reference](https://surface.solenovis.com/docs/routes.md), section 5, has the list. A serverless host cannot run a Surf server, because the server is a binary with a port. --- ## 10. What else an app needs | Need | Where | |---|---| | Colors, fonts and dark mode | `src/style.toml`: [layout spec](https://surface.solenovis.com/docs/layout.md), section 2.1 | | Images and other files | `src/assets/`: [component reference](https://surface.solenovis.com/docs/components.md), section 4.5 | | Icons | [Component reference](https://surface.solenovis.com/docs/components.md), section 4.12 | | Long lists | `List`: [component reference](https://surface.solenovis.com/docs/components.md), section 4.3 | | Dialogs and menus | `Overlay` and `Select`: [component reference](https://surface.solenovis.com/docs/components.md) | | Screen readers | `alt`, `role` and the other attributes: [component reference](https://surface.solenovis.com/docs/components.md), section 1 | | Animation | `Transition` and `transition-` utilities: [layout spec](https://surface.solenovis.com/docs/layout.md), section 12 | | Animations that follow the scroll, and the split of a text | [Layout spec](https://surface.solenovis.com/docs/layout.md), section 5.10 | | Animations in an order | `sequence`: [language spec](https://surface.solenovis.com/docs/language.md), section 7.5a | | Page transitions | `Wave.animateRoutes(true)`: [SurfWave reference](https://surface.solenovis.com/docs/routes.md), section 7 | | Gradients, shadows, blurs and blend modes | [Layout spec](https://surface.solenovis.com/docs/layout.md), section 5.8 | | Video and sound | `Video` and `Audio`: [component reference](https://surface.solenovis.com/docs/components.md), sections 4.5b to 4.5d | | Numbers, dates and lists in the language of the user | `Locale`: [standard library](https://surface.solenovis.com/docs/standard-library.md), section 6a | | Methods that several types share | Traits, bounds and `dyn`: [language spec](https://surface.solenovis.com/docs/language.md), sections 7.8 and 7.9 | | Code in Rust or JavaScript | Extern modules: [language spec](https://surface.solenovis.com/docs/language.md), section 13 | | A desktop app | `surf build --target desktop`. An extern event reaches `surf dev` too. `[desktop] systems` of `surf.toml` lists the systems, also Windows, and `surf package` makes the installers: [the `surf` command](https://surface.solenovis.com/docs/commands.md), section 3 | | What the server does for safety, and what your app must do | [Security](https://surface.solenovis.com/docs/security.md) | | What does not exist yet | [Limits of version 0](https://surface.solenovis.com/docs/limits.md) | <!-- https://surface.solenovis.com/docs/language.md --> # Surface language specification, v0 - **Status:** draft - **Edition:** none yet. Version 0 has no stability guarantee. - **Related documents:** [PLAN.md](https://git.solenovis.com/henning.liemant/surface/src/branch/main/PLAN.md), [ADRs](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/adr), [GLOSSARY.md](https://git.solenovis.com/henning.liemant/surface/src/branch/main/GLOSSARY.md), [component reference](https://surface.solenovis.com/docs/components.md) - **Changes:** 2026-11-06: the texts of the runtime errors are sentences in Simplified Technical English (15.2, P6.29). 2026-11-06: the formatter writes 4 spaces for each indent level, and the key `indent` of `[fmt]` in `surf.toml` can choose another indent (2.1, ADR-0162). 2026-11-06: the compiler has sequences on all targets: `sequence`, `cue` and the utility `cue-<name>` (3.2, 7.5a, 16, ADR-0152). `with` takes the name of an earlier cue, as in the grammar. 2026-11-06: the compiler has `Video` and `Track` (9.6, P6.25). 2026-11-06: the states `active:` and `focus-visible:` of ADR-0161 in 12. 2026-11-06: the compiler has `Locale` with the ICU of the system, and `sortWith` (4.4, 4.5, 7.3, 10.2, 10.4, 14, P6.6). It reads the key `locales` of `surf.toml`, and it gives its errors. The three sorts have one merge sort on each target, and section 4.5 gives its exact form. 2026-11-05: the compiler has `dyn` types and the error of `==` on a `dyn` value (5.1, 7.9, P6.3). It has generic components (3.2, 7.9, 9.1, P6.4), and `Comparable` includes `Equatable` (7.8). 2026-11-05: `Video`, `Audio`, `Source` and `Track` in the list of built-in components (9.6, ADR-0156, ADR-0157). 2026-11-05: sequences, the timeline form of version 1, with the contextual keywords `sequence` and `cue` (3.2, 7.1, 7.5a, 16, ADR-0152). The spec marks them "Not in version 0". 2026-11-05: the compiler has bounds with `+` and `impl` types (7.3, 7.9, P6.2). It also has impls and methods with type parameters, and `T.zero()` and `T.Item` in a generic function (7.8, 7.9). `==` on a type parameter needs `T: Equatable` (7.3). A generic function is a value where the expected type gives its types, as in Rust (7.3). 2026-11-05: the compiler has the rules of file names (2.2), the order of UTF-16 code units on the web (4.4), only the names of utilities in `ease` (7.5), and the import of a trait (7.2, 7.8). 2026-11-05: the answers of the project owner for version 1 (18). File names in camelCase and PascalCase (2.2, ADR-0140). Each keyword is valid as a field name (3.2, ADR-0134). The web compares strings by UTF-16 code units (4.4, ADR-0113). `ease` of `motion` takes only the name of a utility (7.5, ADR-0104). A file calls a method of a trait only when it imports the trait (7.2, 7.8, ADR-0134). The spec marks the new rules "Not in version 0". 2026-11-05: the compiler has traits and impl blocks (7.8, P6.1). The six words of traits are keywords (3.2). `sort`, map keys, `==`, `{x}` and the operators use the impls (4.5, 5.1, 5.4, 6.2). 2026-11-04: the details of `Locale` (ADR-0132). A module constant cannot read `Locale` (7.3, 14). A call of a function prop reads `Locale` when a function value of its type reads it (10.2). `Locale.set` is a write (10.4, 14). `locales = []` is an error, the lookup of RFC 4647 finds the locale, and a request after `Locale.set` has the header `x-wave-locale` (14). The three sorts use one stable merge sort on each target (4.5). 2026-11-04: `Locale` is valid in each function (7.3, 10.2, 14, ADR-0132). A project with `Locale` needs the key `locales`. A request of a server has its own locale, and `Locale.set` is not saved (14, ADR-0132). `sortWith` (4.4, 4.5, 6.4, ADR-0132). `==` on a `dyn` value is a compile error, and `==` on a type parameter needs `Equatable` (5.1, 7.3, 7.8, 7.9, ADR-0135). A module constant gets its value at its first read (7.3, ADR-0131). 2026-11-04: the decisions for version 1 (18). Traits and impl blocks (7.8, ADR-0134). Type parameters with bounds, generic components and trait objects (7.9, ADR-0135). The changes for both are also in 3.2, 4.1, 4.5, 5.1, 5.4, 6.2, 7.1, 7.3, 9.1, 10.2 and 16. No function types, traits or type parameters in extern modules (13.1, ADR-0133). The module `Locale` (7.2, 14, ADR-0132). The second section 13.3 (Calls) is now 13.4. 2026-11-03: a runtime error in a mount and in `on error` shows in a development build (15.2, ADR-0122). 2026-11-03: a module constant has one value (7.3, ADR-0118). 2026-11-03: a name cannot contain `__` (3.1, ADR-0115). 2026-11-03: `Span`, a `Link` in a `Text`, and one text block for each `Text` (9.3, 9.6, ADR-0111). 2026-11-03: `Window` (14, ADR-0106), the module `surf:ui` (7.2, ADR-0102), `Focus.to` and `Focus.newId` (9.7, ADR-0102), the high contrast modes of `ThemeMode` (ADR-0103), and `motion` (7.5, ADR-0104). 2026-11-02: the syntax review: `;` and match commas (3.5, 6.3, ADR-0094), `.surfs` for modules (2.2, ADR-0093), `$` only for tokens (3.1, ADR-0095), conditions in style text (9.2, 12, ADR-0096), number marks and enum attributes (3.3, 4.7, ADR-0099). 2026-11-01: generic records and enums, and tuples (7.3, ADR-0087). 2026-11-01: `Theme.setScale` (ADR-0085). 2026-11-01: `Set<T>` and `{...value}` (ADR-0083), and `Int64` (ADR-0084). 2026-11-01: generic functions (7.3, ADR-0080). 2026-11-01: the functions of dates (ADR-0079). 2026-11-01: `File.pick` (ADR-0078). 2026-11-01: `Http.event` (ADR-0077). 2026-11-01: names in other scripts (3.1, ADR-0076). 2026-11-01: `Storage` (ADR-0074), and `on key` (7.5, ADR-0075). 2026-11-01: `#try` (9.5, 15.2, ADR-0073). 2026-11-01: packages and `pkg:` imports (7.2, ADR-0071), `surf lint` (3.1, 9.5, ADR-0070), and the module `surf:test` (7.2, ADR-0069). 2026-10-31: `Json.embed`, `Theme.designWidth`, and events and calls for each app in a process (13, ADR-0056). 2026-10-31: the variant form `style:{value}={{ ... }}`, and style text can have line ends (2, 9.2, ADR-0054). 2026-10-30: a function value can be each kind of value, and it only reads (6.4, 10.3, ADR-0039). 2026-10-29: `style:` directives and computed values in `style` on an instance of a user component (9.2, Phase 3, P3.11). 2026-10-29: `on error(message)` in the root component (15.2, Phase 3, P3.11). 2026-10-29: imports from subfolders, and a unique name for each component (7.2, Phase 3, P3.11). 2026-10-29: the pointer events `pointerdown`, `pointermove`, `pointerup` and `wheel` with the built-in record `Pointer` (component reference 3.1, Phase 3, P3.2). 2026-10-29: the standard library for apps (Phase 3, P3.1): math, `format`, `replace`, `indexOf`, `join`, `any`, `all`, `sum`, `Float.pi`, `Time.now()` and `Random`. The [standard library](https://surface.solenovis.com/docs/standard-library.md) lists the rules. 2026-10-29: `surf extern stubs` writes the stubs of the implementation files (13.2). 2026-10-29: an initial value cannot call a function of the component (7.5). Before, the compiler accepted the call, and the generated code failed. 2026-10-29: `List` is virtualized, has the list role, and takes `rowHeight` (9.6, ADR-0027). 2026-10-29: the `Timer` module is removed. The built-in `interval(ms)` ends at the next multiple of `ms` on the clock, so a loop with it does not drift (7.5, 8, ADR-0026). 2026-10-29: `Int.parse` and `Float.parse` read a number from a string (4.2). 2026-10-29: the bounds of `slice`, `get` outside a list, and `split("")` are the same on all targets (4.4, 4.5). Before, the web and the native targets gave different results. 2026-10-29: the features that the compiler of version 0 does not have yet are marked "Not in version 0" (3.1, 6.4, 7.2, 7.5, 9.2, 9.5, 9.6, 13.2, 15.2). [Limits of version 0](https://surface.solenovis.com/docs/limits.md) lists them on one page. `panic` ends a path like `return` (7.3). The lambda of a list method can read a `let` variable (6.4). Text can go into each slot that is inside a component that accepts text, not only the `default` slot (9.3). 2026-10-29: the rules of `chars`, `remove`, `sort` and `sortBy`. `enumerate` is removed: version 0 has no tuples, and a `for` loop gives the index (4.4, 4.5). 2026-10-28: a component file has no `<script>` tag. A line `---` separates its declarations from its markup (2.3). Comments are `//`, `/* */` and `///` everywhere (2.4). An `on:` attribute takes statements (6.4, 9.2). Blocks are `#if`, `#for`, `#match`, `#await` and `#fill` with a body in braces, and tags are `@const` and `@slot` (9.5). ADR-0021 gives the reasons. 2026-10-28: utilities replace the `<style>` section, the style attributes and the class names (ADR-0020). `testid` is new. `Box`, the `variant` and `tone` of `Text`, and `#platform` blocks are removed (2.3, 9.2, 9.5, 9.6, 12, 14, 16). 2026-10-25: clarifications from Spike A ([SPIKE-A.md](https://git.solenovis.com/henning.liemant/surface/src/branch/main/spike/SPIKE-A.md), sections 4 and 7.4). They define these rules: - the rounding of `Float.round()` (4.2) - `for` loops over a changing list (6.1) - lambdas with an assignment body (6.4). An `on:` attribute takes statements now (ADR-0021). - return paths (7.3) - list blocks over computed lists (9.5) 2026-10-26: P1.7 sets the web implementation file of Phase 1 to `.web.js` and gives the JavaScript values at the boundary (13.2, 13.4). 2026-10-26: P1.6 adds the rules of section 8 for the position of `await`, `spawn`, `sleep` and the start of a task. `#await` takes a `Task<T>?` (9.5). 2026-10-26: P1.9 names the theme tokens (12.2). The web stylesheet is in the layout spec, section 10. 2026-10-26: P1.8 adds the [component reference](https://surface.solenovis.com/docs/components.md) (9.2, 9.6). 2026-10-25: P1.5 adds the map methods and the equality of maps (4.5). 2026-10-25: P1.4 adds three rules. The first rule tells where `cleanup` is valid and what it can use (7.5). The others are about events from a component that leaves the tree (7.6), and writes that are not reads (10.2). 2026-10-25: the slot example of 7.7 declares `row` with a `bindable` parameter, and the parent binds `items`. Its fill binds `todo.done`, and a fill parameter without `bindable` is a constant. P1.3 found the conflict. This document specifies the `.surf` language: the file structure, Surf Script, the markup, the reactivity model, the styles and the extern modules. The Surface Layout Spec is a separate document. It has the utilities. Section 12 gives only a summary of them. --- ## 1. Conventions - **must** and **must not** state a rule. A violation of a rule is a compile error, unless the rule says "runtime error". - **can** states a permission. - A **runtime error** has the same cause and the same result on all targets (section 15). - Grammar fragments use this notation: `?` is optional, `*` is zero or more, `+` is one or more, `|` separates alternatives, and `"x"` is literal text. The design rule behind every decision in this spec: **one source, one meaning, on every target.** If a feature cannot have the same meaning in JavaScript and in native code, the feature is not in the language. --- ## 2. Source files ### 2.1 Encoding Source files are UTF-8. Line ends can be LF or CRLF. The formatter writes LF. The formatter writes 4 spaces for each indent level. A project can set another indent in `surf.toml` (ADR-0162): ```toml [fmt] indent = 2 ``` - The value is a number of spaces from 1 to 8. - A file uses the `surf.toml` of the nearest folder above it. Thus a package in `src/pkg` keeps its own indent. A file with no `surf.toml` above it gets 4 spaces. - `surf fmt`, `surf fmt --check` and the format action of the language server use the indent of the project. The indent of the project wins over the settings of the editor. - The indent does not change the meaning of a file. The formatter does not use tabs. ### 2.2 File kinds | Kind | File name | Content | |---|---|---| | Component file | Has the extension `.surf`. A name in PascalCase, for example `Counter.surf` or `TodoRow.surf` | Declarations in Surf Script, then markup | | Module file | Has the extension `.surfs`. A name in camelCase, for example `model.surfs` or `todoRow.surfs` | Declarations in Surf Script only | The extension gives the kind of a file (ADR-0093). The name of a component is its file name without the extension. A component file must contain markup. A module file must not contain markup. **File names** (ADR-0140): - A component file starts with an uppercase letter, and a module file starts with a lowercase letter. A capital letter marks each new word. A name has no `_` and no `-`. - A wrong name is a compile error, and the message gives the correct name. For example, the message for `todo_row.surfs` gives `todoRow.surfs`, and the message for `todo-row.surf` gives `TodoRow.surf`. - Two files with paths that differ only in case are a compile error, for example `todoRow.surfs` and `todorow.surfs`. macOS and Windows read the two paths as one file. - The rule applies to the part before the first dot. The parts after a dot keep their rule, for example `.server` in `hooks.server.surfs` and `.remote` in `todos.remote.surfs` (wave spec 1). The `@` of `Page@.surf` stays valid. - Two files cannot have one module name on a native target, for example `TodoRow.surf` and `todoRow.surfs` (ADR-0115). - The names of folders and of assets have no such rule. ### 2.3 Sections of a component file A component file has two sections, in this order: 1. The **declarations** of the component, in Surf Script. They are also called the script of the component. 2. The **markup**. It is required. A line that has only `---` separates them. A component without declarations has no `---`: its file is markup only. ```surf prop label: String = "Clicks"; state count = 0; --- <Button on:press={count += 1}>{label}: {count}</Button> ``` A component file is a module file with markup at its end. It has no `<script>` tag and no `<style>` section. The styles are utilities in the markup (section 12). A `<script>` tag is a compile error with a help text (ADR-0021). ### 2.4 Comments The comments are the same in the declarations and in the markup: | Comment | Meaning | |---|---| | `// text` | A comment to the end of the line | | `/* text */` | A comment over one or more lines. Block comments do not nest. | | `/// text` | Documentation of the declaration on the next line. At the top of a component file, documentation of the component. The language server shows it. | In the markup, a comment starts at the start of a line or after a tag: `<Text>http://example.com</Text>` is text. `<!-- -->` is a compile error with a help text. A comment between the attributes of a tag is a compile error: write it on the line above the tag. --- ## 3. Lexical structure ### 3.1 Identifiers An identifier starts with a letter of any script or `_`. Its other characters are letters, digits, combining marks and `_` (ADR-0076). `$` marks a theme token, and a name cannot start with `$` (ADR-0095). A name cannot contain `__` (two `_` in a row). The compiler uses these names, so a name of a program never meets a name of the compiler (ADR-0115). This rule is also true for a field, so a JSON key such as `__typename` cannot be a field. `Json.decode` ignores a key that the record does not have. - Component names, type names and enum names must start with an uppercase letter. - Variables, functions, fields, props and enum cases start with a lowercase letter. `surf lint` warns about other names (ADR-0070). ### 3.2 Keywords ``` as async await bindable break case const continue derived dyn effect else emit enum event extern false fn for if impl import in let match module mut none on prop pub return self Self slot spawn state trait true type var while ``` `var` is reserved. It is not a declaration: use `let` for a variable (ADR-0013). `case` and `as` are reserved for later versions. `dyn`, `impl`, `mut`, `self`, `Self` and `trait` are the keywords of traits (section 7.8, ADR-0134) and of trait objects (section 7.9, ADR-0135), as in Rust. Each keyword is valid as a field name, also the six keywords of traits (ADR-0134). So a record reads the JSON keys `type`, `for` and `self`. A field name is the name in a record type, in a record literal and after `.`. A local variable, a function and a type cannot have a keyword name. ```surf pub type Entry = { type: String, for: String, self: Bool } pub fn mine(e: Entry) -> Bool = e.self && e.type == "user"; ``` These words are **contextual keywords**. They have a special meaning only in one position. In all other positions they are valid identifiers: - `component` at the start of a declaration in a component script (section 7.9) - `motion` at the start of a declaration in a component script - `sequence` at the start of a declaration in a component script (section 7.5a) - `cue` at the start of a line in the body of a `sequence` (section 7.5a) - `mount`, `destroy` and `error` after `on` - `cleanup` in an effect - `fallback` in an extern module - `key` in the head of a `#for` block - `else`, `empty`, `then` and `catch` after the body of a block in the markup ### 3.3 Literals | Type | Examples | Rules | |---|---|---| | `Int` | `42`, `1_000_000`, `0xFF`, `0b1010` | `_` can separate digits. | | `Float` | `1.5`, `0.25`, `2e10`, `1.5e-3`, `1f64` | A Float literal must have digits before the point. `.5` is an error. The mark `f64` makes a number a `Float`. | | `Int64` | `1i64`, `9007199254740993i64` | The mark `i64` makes a number an `Int64` (ADR-0099). A hex or a binary literal has no mark. | | `String` | `"hello"`, `"{count} items"` | See section 3.4. | | `Bool` | `true`, `false` | | | Optional | `none` | The empty value of an optional type. | ### 3.4 Strings - A string literal is in double quotes. - `{expression}` inside a string inserts the value of the expression (section 5.4 gives the conversion rules). - Escapes: `\n`, `\t`, `\r`, `\"`, `\\`, `\{`, `\}`, `\u{1F600}`. - A string literal cannot contain a raw line end. Use `\n`. Style text is the exception: in `style`, a line end is white space between two items (section 12). ### 3.5 Statement ends A `;` ends each statement and each declaration on one line (ADR-0094). A line end has no meaning in script, so a statement can use more than one line. - A form that ends with `}` has no `;`: `fn` with a block, `if`, `for`, `while`, `match`, `effect`, `cleanup`, `on ...`, `type`, `enum` and `extern module`. - A declaration or a statement that ends with `}` for another reason needs its `;`, for example `const a = { x: 1 };` and `const b = match c { ... };`. - The last statement of an `on:` value has no need for `;`: `on:press={n += 1}`, `on:press={save(); close()}`. - `@const` has no `;`. The line end ends a tag. --- ## 4. Types ### 4.1 Overview | Type | Meaning | |---|---| | `Int` | Integer in the range −(2^53 − 1) to 2^53 − 1 (section 4.2, ADR-0010) | | `Float` | IEEE 754 binary64 number | | `Bool` | `true` or `false` | | `String` | Immutable sequence of Unicode scalar values | | `Bytes` | Immutable sequence of numbers from 0 to 255 (ADR-0037) | | `Color` | A color that the compiler, `Color.parse`, `Color.rgb` or `Color.mix` checked. Text in quotes is a `Color` where the type says so (ADR-0050, ADR-0160) | | `Scalar` | Built-in enum with the cases `text(String)`, `number(Float)` and `bool(Bool)`, for a JSON value whose kind only the data gives (section 4.7, ADR-0045) | | `Void` | The return type of a function that returns no value | | `List<T>` | Ordered sequence of values of type `T` | | `Map<K, V>` | Map from keys of type `K` to values of type `V` | | `T?` | Optional value: a `T` or `none` | | `Result<T, E>` | Built-in enum with the cases `ok(T)` and `err(E)` | | `Task<T>` | The result of an `async fn` | | `Ordering` | Built-in enum with the cases `less`, `equal` and `greater`. The result of `compare` of the trait `Comparable` (section 7.8, ADR-0134) | | `(A, B) -> R` | Function type | | Record types | Declared with `type` (section 4.6) | | Enum types | Declared with `enum` (section 4.7) | | `dyn Trait` | A trait object: a value of each type with an impl of the trait (section 7.9, ADR-0135) | | `impl Trait` | The short form of a type parameter with a bound (section 7.9, ADR-0135) | There is no `any` type, no `null` other than `none`, and no implicit conversion between types. ### 4.2 Int - The range of `Int` is the same on all targets: −(2^53 − 1) to 2^53 − 1. JavaScript can represent every value in this range exactly. - A result outside the range is a runtime error ("the result is outside the integer range"). The compiler removes the check where it can prove that the result is in the range. - `a / b` with two `Int` values gives an `Int`. The result is truncated toward zero. - `a % b` gives the remainder. The sign of the remainder is the sign of `a`. - Division or remainder by zero is a runtime error. - Conversion: `i.toFloat()`, `f.toInt()` (truncation), `f.round()`, `f.floor()`, `f.ceil()`. Each conversion from `Float` gives a runtime error for NaN and for a result outside the `Int` range. - `Int.parse(s)` gives `Int?`: the value of `s`, or `none`. `s` must be an optional `+` or `-` and the digits `0` to `9`, with no other characters. A value outside the `Int` range gives `none`. - `Float.parse(s)` gives `Float?`. `s` must be an optional sign, digits with an optional `.` and fraction, and an optional exponent (`e` or `E`, an optional sign, digits). It needs at least one digit before the exponent. White space, `inf`, `NaN` and hexadecimal numbers give `none`, and so does a value that is too large for a `Float`. Use `s.trim()` first for the text of an `Input`. - `f.round()` rounds a value that is exactly halfway between two integers away from zero: `2.5.round()` is `3`, and `(-2.5).round()` is `-3`. JavaScript `Math.round` rounds `-2.5` to `-2`, so web builds use their own function for `round`. ### 4.3 Float - `Float` follows IEEE 754 binary64 on all targets. Division by zero gives infinity or NaN, as IEEE 754 specifies. - Conversion to `String` uses the shortest text that converts back to the same value. The format is the format of ECMAScript `Number.prototype.toString`. Native builds implement the same algorithm, so `"{x}"` gives the same text on all targets. - An `Int` and a `Float` cannot be mixed in one operation. `1 + 1.5` is a compile error. Write `1.toFloat() + 1.5` or `1.0 + 1.5`. ### 4.4 String - A `String` is a sequence of Unicode scalar values. Native builds store UTF-8. Web builds store JavaScript strings. - There is no index operator, because the index of a character is different in UTF-8 and UTF-16. - Operations count and slice by Unicode scalar values on all targets: - `s.count()`, `s.isEmpty()`, `s.slice(from, to)`, `s.chars()`. `chars()` gives a `List<String>` with one string for each scalar value. - `s.contains(t)`, `s.startsWith(t)`, `s.endsWith(t)`, `s.split(t)`, `s.trim()` - `s.upper()` and `s.lower()` use the Unicode default case mapping, independent of the locale. - `s.slice(from, to)` keeps each bound between 0 and `s.count()`, and `to` is at least `from`. So `"abc".slice(-1, 9)` is `"abc"`, and `"abc".slice(2, 1)` is `""`. - `s.split("")` gives one string for each scalar value, as `chars()` does. - `a + b` joins two strings. - `<`, `<=`, `>`, `>=` compare by scalar values on the native targets and in the interpreter. The web compares by UTF-16 code units, as JavaScript does, because it is faster (ADR-0113). The same rule applies to `compare` of `String` (section 7.8), `sort` and `sortBy`. The two orders differ only in one case. At the first place where the two strings differ, one character is above U+FFFF and the other is from U+E000 to U+FFFF. Most emoji are above U+FFFF. `docs/native-differences.md` lists this difference. - `==` and `!=` compare the scalar values on all targets. - The order of a language comes from `Locale.compare` (section 14), for example in `sortWith` (section 4.5). - On the web, the runtime replaces each lone surrogate that comes from a browser API with U+FFFD. ### 4.5 List and Map - A list literal is `[1, 2, 3]`. An empty list needs a known type: `state todos: List<Todo> = []`. - `xs[i]` reads element `i`. An index outside the list is a runtime error. `xs.get(i)` gives `T?`: `none` for an index outside the list, also for a negative index. - `xs.slice(from, to)` keeps its bounds in the list, as `String.slice` does (section 4.4). - Methods that change the list need a place (section 5.2): `push`, `insert`, `remove`, `removeAt`, `clear`, `sort`, `sortBy`, `sortWith`. Each is a statement of its own. - `xs.remove(v)` removes the first element that is equal to `v`. Without such an element, the list does not change. - `xs.sort()` orders a list by `<`. `xs.sortBy(x => key)` orders any list by a key, for example `todos.sortBy(t => t.title)`. The type of the elements of `sort` and the type of the key must have the trait `Comparable` (section 7.8, ADR-0134). - `xs.sortWith((a, b) => order)` orders any list with a compare function of the type `(T, T) -> Ordering` (ADR-0132). The result `.less` puts `a` before `b`. An order that no key gives needs this method, for example the order of a language: `names.sortWith((a, b) => Locale.compare(a, b))` or `users.sortWith((a, b) => Locale.compare(a.name, b.name))`. The argument is a lambda with two parameters, or another function value of this type. - The three sorts are stable: equal elements keep their order. For `sortWith`, two elements are equal when the compare function gives `.equal`. - Each runtime uses the same stable merge sort for the three sorts, also the web (ADR-0132). So each target gives the same order, also when the order is not consistent. An example is a compare function or an impl of `Comparable` that gives `.less` for `(a, b)` and also for `(b, a)`. - The merge sort has this exact form, so each target calls the compare function with the same pairs, in the same order: 1. `sortBy` gets the key of each element one time, in the order of the list, before the sort starts. 2. The sort starts with runs of one element. A pass merges each two adjacent runs into one run, from the start of the list to its end. A run at the end with no partner stays as it is. The next pass has runs of double the length. The sort stops when one run holds the whole list. 3. A merge compares two elements: `a`, the next element of the left run, and `b`, the next element of the right run. It takes `b` only when `b` must come before `a`. Else it takes `a`. When one run has no more elements, the merge takes the rest of the other run. 4. `b` must come before `a` in these cases. For `sortWith`, the compare function gives `.greater` for `(a, b)`. For `sort` and `sortBy` of a type with an impl of `Comparable`, `compare` gives `.greater` for `(a, b)`, or for their keys. For the other types, `b < a`, or the key of `b` `<` the key of `a`. So `NaN` never comes before another value in a merge. - Methods that return a new value: `map`, `filter`, `find`, `count`, `isEmpty`, `contains`, `first`, `last`, `get`, `slice`. A loop gets the index of each element with `for item, index in xs` (section 6.1). - `remove`, `contains` and `indexOf` compare with `==`. A list of `dyn` values cannot use them, because a `dyn` value has no `==` (section 5.1, ADR-0135). - A map literal is `Map { "a": 1, "b": 2 }`. The key type must have the trait `Hashable` (section 7.8, ADR-0134): `Int`, `String`, `Bool`, or a record or an enum whose fields all have `Hashable`. A `dyn` type cannot be a key (section 7.9). - `m[k]` reads a value of type `V?`. `m[k] = v` adds or replaces a value. A new key goes to the end. A replaced value keeps the position of its key. - Map methods: `count`, `isEmpty`, `contains(k)`, `keys()` and `values()` return values. `remove(k)` and `clear()` change the map and need a place. - Two maps are equal when they have the same keys with equal values. The order of the keys does not count. - Iteration over a map follows the order in which the keys were first added. ### 4.6 Records ```surf type Todo = { id: Int, title: String, done: Bool = false } ``` - A record type is nominal. Two record types with the same fields are different types. - A field can have a default value. - Two fields with one name are a compile error. - A record cannot hold a value of its own type, also through an optional value, a tuple or another record or enum: `type Node = { next: Node? }` is a compile error. A `List` or a `Map` can hold it: `type Node = { children: List<Node> }`. A native build gives each field a fixed size. - Literal: `Todo { id: 1, title: "Buy milk" }`. When the expected type is known, the type name can be left out: `{ id: 1, title: "Buy milk" }`. - Copy with changes: `{ ...todo, done: true }`. ### 4.7 Enums ```surf enum Tone { info, warning, error } enum Shape { circle(radius: Float), rect(width: Float, height: Float) } ``` - Cases start with a lowercase letter. Two cases with one name, and two fields of a case with one name, are compile errors. - The field of a case cannot hold its own enum outside a `List` or a `Map`, as for a record (4.6). - A case is written `Shape.circle(radius: 2.0)`, or `.circle(radius: 2.0)` when the expected type is known. - A prop with an enum type takes a case: `tone={.warning}`. The prop gives the enum, so the parent needs no import of it. Text is a compile error (ADR-0099). - `Scalar` is a built-in enum (ADR-0045). `Json.decode` takes its case from the value, and `value.text()` gives its text. ### 4.8 Optional values - `T?` is a `T` or `none`. - An optional of an optional is a compile error. The web target has one `none`, so it cannot keep the two levels apart. `xs.get(i)`, `xs.first()`, `xs.last()`, `xs.find(f)` and `m[k]` of a list or a map of optional values are compile errors: check the index or the key first, or use a list without `none`. - `a?.b` gives `none` if `a` is `none`. `a?.b(c)` calls a method only when `a` has a value (ADR-0052). - `a ?? b` gives `b` if `a` is `none`. With an optional `b`, the result is an optional: `m[k] ?? none`. - `if let x = opt { ... }` runs the block only when `opt` has a value. ### 4.9 Result and errors - `Result<T, E>` is a built-in enum with the cases `ok(T)` and `err(E)`. - A `Result` that nothing reads is a compile error. `const _ = save()` drops it (ADR-0052). - In a function that returns `Result<_, E>`, the postfix operator `?` returns the `err` value at once, or gives the `ok` value. - There are no exceptions. A bug that cannot continue uses `panic("message")`, which is a runtime error. ### 4.10 Type inference - The compiler infers the types of `const`, `let`, `state` and `derived` declarations. - These must have explicit types: `prop` declarations, function parameters, the return types of `pub` functions, record fields and extern declarations. --- ## 5. Values ### 5.1 Value semantics Every value in Surf Script is a value, not a reference (ADR-0009). - An assignment, a function argument and a prop give the receiver its own copy. - A change to a copy does not change the original. - `==` compares two values field by field and element by element. Function values cannot be compared. `f == g` is a compile error. - An impl of the trait `Equatable` replaces this comparison for its type (section 7.8, ADR-0134). - A `dyn` value cannot be compared. `a == b` on two `dyn Shape` values is a compile error, because `Equatable` cannot be `dyn` (section 7.9, ADR-0135). - There is no reference identity in the language. Implementations do not copy at every assignment. Native builds use reference counting with copy-on-write. Web builds insert copies only where a change through one name could be visible through another name. Because there are no references, values cannot form cycles, and reference counting never leaks memory. ### 5.2 Places A **place** is an expression that can receive a value: - a `let` variable - a `state` or a `bindable prop` (section 7) - a field of a place: `todo.title` - an element of a list place: `todos[i]` - an entry of a map place: `scores["ana"]` - the variable of a `#for` block over a place (section 9.5) Assignments (`=`, `+=`, `-=`, `*=`, `/=`, `%=`) and the changing methods of lists and maps need a place on the left side. ### 5.3 Equality of primitive writes A write of an `Int`, `Float`, `Bool` or `String` value that is equal to the current value does not count as a change (section 10.4). A write of a list, map, record or enum value always counts as a change. ### 5.4 Conversion to text `{expression}` in a string or in markup text accepts each type with the trait `Text` (section 7.8, ADR-0134). `String`, `Int`, `Float` and `Bool` have it. For another type, `{x}` gives the result of `x.text()`. Other types are a compile error. --- ## 6. Statements and expressions ### 6.1 Statements | Statement | Example | |---|---| | Constant | `const total = a + b` | | Variable | `let i = 0` | | Assignment | `count += 1` | | Condition | `if x > 0 { ... } else if x < 0 { ... } else { ... }` | | Optional test | `if let user = maybeUser { ... }` | | List loop | `for todo in todos { ... }` | | Loop with index | `for todo, i in todos { ... }` | | Map loop | `for key, value in scores { ... }` | | Range loop | `for i in 0..10 { ... }` (excludes 10), `for i in 0..=10 { ... }` (includes 10) | | Condition loop | `while queue.count() > 0 { ... }` | | Loop control | `break`, `continue` | | Return | `return value` | | Event | `emit submit(draft)` (section 7.6) | | Background task | `spawn load()` (section 7.5) | An assignment is a statement, not an expression. `a = b = 0` is a compile error. A `for` loop over a list iterates over the list as it was when the loop started (section 5.1): - The loop variable is a copy of the element. A write to the list in the body does not change the loop variable. - A write to the list in the body does not change the number of iterations or the elements that the loop sees. Examples of such writes are `todos.push(x)` and `todos[0].done = true`. The next statement after the loop sees all the writes. - A `for` loop over a map follows the same rule. ```surf for todo in todos { todos.push({ ...todo, id: nextId() }); // runs once for each element of the original list } ``` ### 6.2 Operators From the highest to the lowest precedence: | Level | Operators | Notes | |---|---|---| | 1 | `.` `?.` `()` `[]` postfix `?` | Member, call, index, error propagation | | 2 | prefix `-` `!` | | | 3 | `*` `/` `%` | | | 4 | `+` `-` | `+` also joins strings | | 5 | `..` `..=` | Ranges of `Int` | | 6 | `<` `<=` `>` `>=` | | | 7 | `==` `!=` | Value equality (section 5.1) | | 8 | `&&` | | | 9 | `\|\|` | | | 10 | `??` | | | 11 | `cond ? a : b` | | | 12 | `=>` | Lambda | Binary operators at the same level group from left to right. `??`, `? :` and `=>` group from right to left. A type with an impl of an operator trait can use the operator of the trait (section 7.8, ADR-0134). For example, a record `Vector` can use `a + b`. The operators of levels 6 and 7 come from `Comparable` and `Equatable`. ### 6.3 Match ```surf const area = match shape { .circle(r) => 3.14159 * r * r, .rect(w, h) => w * h, }; ``` - `match` is an expression. - Each arm ends with `,`. The last arm can leave it out (ADR-0094). - Patterns: literals, enum cases with bindings, `none`, and `_` for any value. - A `match` must cover every case. A missing case is a compile error. ### 6.4 Lambdas - `x => x * 2` - `(a, b) => a + b` - `(g) => { ...statements... }` A lambda captures `const` values and the reactive sources of its component (section 10.1). A lambda cannot capture a `let` variable. This rule prevents shared mutable state between a function and its creator. The lambda of `filter`, `find`, `map`, `sortBy` and `sortWith` is an exception: it runs before the call returns, so it can read a `let` variable. The body of a lambda cannot be an assignment: `x => total += x` is a compile error. Use a block: `x => { total += x; }`. An `on:` attribute is different: its value is statements (section 9.2). A function value is a value (ADR-0039). It can be a local constant, a parameter, a result, a field of a record, an item of a list or a map. It can also be a `state`, a `derived` or a prop. A module function is a value by its name. Each expression of a function type can be called: `f(1)`, `action.run(1)`, `fs[0](1)`, `adder(1)(2)`. - A function value only reads. It cannot write to a `state`, call a function of a component or use `await`. Its block can write its own `let` variables. A child tells its parent with an event (section 9.2). - A lambda with a block needs a known type: `const f: (Int) -> Int = x => { return x + 1; }`. Each path of a block with a result must return. - A function value is not a key of a `Map`. JSON and extern modules refuse it. An `async fn` is not a function value. ### 6.5 Calls and named arguments ```surf fn circle(x: Float, y: Float, r: Float, fill: Color = "#007aff", stroke: Color? = none) { // ... } fn dot(x: Float, y: Float) { circle(x, y, 3.0, fill: "#ff9500"); } ``` - Parameters without a default value are positional. - Parameters with a default value must be named in the call. --- ## 7. Declarations ### 7.1 Where each declaration is valid | Declaration | Module file | Component script | Inside a function | |---|---|---|---| | `import` | yes | yes | no | | `const` | yes (module constant) | yes (instance constant, which can read the props. `pub const` is a module constant) | yes (local constant) | | `type`, `enum` | yes | yes | no | | `fn`, `async fn` | yes | yes | no | | `trait`, `impl` (section 7.8) | yes | no | no | | `component` (section 7.9) | no | yes | no | | `extern module` | yes | no | no | | `prop`, `bindable prop` | no | yes | no | | `state`, `derived`, `motion`, `effect` | no | yes | no | | `sequence` (section 7.5a) | no | yes | no | | `event`, `slot` | no | yes | no | | `on mount`, `on destroy` | no | yes | no | | `let` | no | no | yes | A component script has no `let`. Mutable component data must be `state`, so every change is visible to the reactivity model. ### 7.2 Imports and visibility ```surf import Counter from "./Counter.surf"; import { Todo, nextId } from "./model.surfs"; import { Haptics } from "pkg:haptics"; import close from "./icons/close.svg"; ``` - A path is relative to the importing file and must end with `.surf`, `.surfs` or `.svg`. A `pkg:` path names a package. A `~/` path starts at the folder `src`, for example `~/model.surfs`. `surf:wave`, `surf:test` and `surf:ui` are built-in modules. - An import of an `.svg` file gives a constant of the built-in type `Icon` with the name of the import (ADR-0030). The compiler converts the file: a missing file and a part of the file that an icon cannot have are compile errors. - The files of a project can be in subfolders of its folder. A path starts with `./` (the folder of the importing file) or `../` (the folder above it), for example `./parts/Row.surf` or `../model.surfs`. A path that leaves the folder of the project is a compile error. - A component has the name of its file, without the folders. Two components with the same name in one project are a compile error, also in different folders. - A value of a record type of another file needs an import of the type. This is also true for the value of a prop of a child component. - `pkg:name` imports `lib.surfs` of a package, and `pkg:name/x.surfs` imports another file of it. `surf.toml` names the packages, and `surf install` copies each one to `src/pkg/<name>` (ADR-0071). A `pkg:` path to a package that is not there is a compile error. - The module `surf:test` has the functions of `surf test` (ADR-0069). `surf test` adds this module, so a build has no `surf:test`. - The module `surf:ui` has the standard components (component reference, section 5, ADR-0102). A named import takes each component and each type: `import { Dialog, Tabs, UiItem } from "surf:ui";`. Only the components of the import go into a build. - A component file exports only its component. The other declarations in a component script are private. - A module file exports only the declarations that start with `pub`. - Built-in components (section 9.6), `Platform`, `Focus`, `Result`, `Task`, `Pointer`, `Icon`, `ThemeMode` and the theme tokens need no import. - The built-in traits and `Ordering` (section 7.8), and `Locale` and its enums (section 14), need no import. - A call of a method of a trait of another file needs an import of the trait (section 7.8, ADR-0134). A method of a plain impl needs no import. - An import cycle is a compile error in version 0. ### 7.3 Constants and functions ```surf const MAX_ITEMS = 100; pub fn plural(n: Int, word: String) -> String { return n == 1 ? "1 {word}" : "{n} {word}s"; } fn double(x: Int) -> Int = x * 2; ``` - A `const` value is an expression of literals, other constants and calls of functions. - A module constant has one value. Each read gives the same value, also when the value comes from a call such as `Random.float()` (ADR-0118). - A constant with a value that is not a literal gets its value at its first read, on all targets (ADR-0131). A server has one value for the process. Not in version 0: the web computes the value when the page loads, and a server has one value for each thread (see [native differences](https://surface.solenovis.com/docs/targets.md)). - `fn name(...) -> T = expression` is the short form of a function with one expression. - In a function with a return type other than `Void`, every path through the body must end with `return`. A path that reaches the end of the body without `return` is a compile error. For this check: - An `if` with an `else` returns when both branches return. An `if` without an `else` does not return. - A loop does not return, because its body can run zero times. - A call of `panic(message)` as a statement returns, because it stops the batch (section 15.2). - A `Void` function can end without `return`. - A function in a module file cannot read the reactive sources of a component. It gets the data of a component through its parameters. It can read `Locale` (section 14, ADR-0132). - A module constant cannot read `Locale`, also through a call (ADR-0132). A module constant has one value, so it cannot follow `Locale.set`. This is a compile error. The message says to use a function in place of the constant, for example `fn title() -> String`. An instance constant of a component that reads `Locale` is also a compile error, and its message says to use a `derived` value. **Generic functions** ```surf pub fn first<T>(items: List<T>) -> T? { return items.get(0); } ``` - A function in a module file can have type parameters. A component file cannot have such a function (ADR-0080). A generic component has type parameters of its own (section 7.9). - A call has no list of types. The values of the call give the types, so each type parameter must appear in a parameter. - The body can copy a value of a type parameter and put it in a list. Other operations on it are compile errors. - A type parameter can have a bound: `<T: Comparable>`. The body can then use the methods and the operators of the traits of the bound (section 7.9, ADR-0135). - `==` and `!=` on a value of a type parameter need the bound `T: Equatable`. `contains`, `indexOf` and `remove` on a list of such values need it too (section 4.5). A function type and a `dyn` type have no `Equatable`, so a call cannot give them to such a type parameter (section 7.9, ADR-0135). - A generic function is a value where the expected type gives each type parameter, as a function of Rust: `const pick: (List<Int>) -> Int = largest;`. The type of a parameter of a call can also give them: `apply([1, 3], largest)`. Without such a type, a generic function as a value is a compile error. An `async fn` is not a value. **Generic records and enums, and tuples** ```surf pub type Box<T> = { value: T } pub enum Either<L, R> { left(value: L), right(value: R) } fn sample() { const boxed: Box<Int> = { value: 7 }; const pair = (1, "one"); const (n, word) = pair; } ``` - A record and an enum can have type parameters (ADR-0087). A type parameter of a record or an enum can have a bound too (section 7.9). Each use of the type must give a type with the traits of the bound. - `(Int, String)` is a tuple type with two to four values. `(a, b)` makes a tuple, and `t.0` reads a value. - `const (a, b) = value` reads the values of a tuple. ### 7.4 Props ```surf prop label: String = "Clicks"; prop items: List<Todo>; bindable prop value: String = ""; ``` - A prop without a default value is required. A missing required prop is a compile error in the parent. - A component cannot assign to a normal prop. - A component can assign to a `bindable prop`. If the parent used `bind:value={place}`, the assignment also changes the parent place. Without `bind:`, the assignment changes only the local value until the parent sends a new value. ### 7.5 State, derived values and effects ```surf state count = 0; state todos: List<Todo> = []; derived remaining = todos.filter(t => !t.done).count(); effect { const r = room; Chat.join(r); cleanup { Chat.leave(r); } } on change(filter) { page = 0; } on mount { focusFirstField(); } on destroy { saveDraft(); } ``` - A `state` initializer runs once, when the component instance is created. It can read props, but it does not follow later changes of the props. - The initial values of `state`, of an instance `const` and of a prop cannot call a function of the component. The state of the component does not exist yet at that time. A module function can get the props as arguments: `state items = makeItems(count)`. A `derived` value can call a function of the component. - An initial value can read a `derived` value that comes before it. It then reads the expression of that value, so the rule above applies to that expression too (ADR-0104). - A `derived` value is an expression. It must not write a reactive source. A write is a compile error. - A `derived` value is calculated when something reads it, and it stays cached until one of its dependencies changes. - An `effect` body runs after the first render and again after every batch that changes one of its dependencies (section 10.5). - `cleanup { ... }` inside an effect runs before the next run of the effect and when the component is destroyed. It is valid only at the top level of the effect body, at most once. It can use the locals that the effect declares before it, with their values at the time when the effect reached `cleanup`. - An effect body is synchronous. It can start an async function with `spawn f()`. The runtime cancels the task when the effect runs again or when the component is destroyed. A task that `on change` starts goes on after the next change, as a task of an event handler does. - `on mount` runs once, after the first frame that shows the component. `on destroy` runs once, when the component leaves the tree. **Values that move in time** ```surf state open = false; motion x(ms: 400, ease: "ease-spring") = open ? 240.0 : 0.0; --- <Column style="translate-x-{x}px" /> ``` `motion` declares a value that moves to its target in time (ADR-0104). The expression after `=` is the target, as the expression of a `derived` value is. An app sets a target with a write to a `state` or with a new prop. - The value is a `Float`, and each consumer reads it as it reads a `state`: a computed style, a `derived` value, an effect or a text. The target must be a `Float`. - `ms` is the time in milliseconds. `ease` is the name of an easing utility of the layout spec, section 12.4: `"ease"`, `"ease-linear"`, `"ease-out"`, `"ease-out-cubic"`, `"ease-spring"` or `"ease-spring-0.4"` (ADR-0104). A reader can then copy the name from a `style`. Both are optional and both are fixed in the source. The defaults are 150 and `"ease"`. - A name without `ease-`, for example `"spring"` or `"linear"`, is a compile error. The message gives the correct name, for example `"ease-spring"`. The prop `ease` of `Transition` has the same rule (component reference 4.15). - The first value is the first target, with no movement. The target follows the rule of an initial value of a `state`: it cannot call a function of the component. - When the target changes, the value starts at its shown value. With a spring, it keeps its speed. With another easing, it starts with a speed of 0 and takes the whole time. - At the end of the time, the value is exactly the target. During the time, the value has at most 4 digits after the point, so that all targets show the same numbers. - A `motion` value cannot take a write. Write the state that its target reads. - The value changes once for each frame while it moves. Each change is a batch (section 10.5). A value that does not move uses no frames. - With the setting of reduced motion on, the value goes to a new target at once (layout spec 5.10). - The runtime stops a moving value when its component leaves the tree, as it cancels a task. ### 7.5a Sequences ```surf state ready = false; sequence intro(play: ready) { cue logo(ms: 400) = "fade"; cue title(gap: -200, stagger: 30) = "rise"; cue more(gap: -100) = "fade"; } on mount { ready = true; } --- <Column style="gap-4 items-center"> <Image src="assets/logo.svg" alt="Surface" style="cue-logo" /> <Text style="text-48 split-letters cue-title">One universal best option</Text> <Button style="cue-more">Read more</Button> </Column> ``` A `sequence` orders the animations of several elements on one timeline (ADR-0152). It is the timeline form of version 1. Each `cue` is one animation of the sequence. An element plays a cue with the utility `cue-<name>` (layout spec 5.10). - `sequence` and `cue` are contextual keywords. `sequence` starts a declaration in a component script, and `cue` starts a line in the body of a sequence. Sections 3.2, 7.1 and 16 have these words. - A sequence is valid only in a component script. Its name is unique among the declarations of the component. - `cue <name>(<arguments>) = "<keyframes>";` declares a cue. The text after `=` is a name of `keyframes` of `style.toml`, or of a built-in animation (layout spec 2.1). An unknown name is a compile error. - The arguments of a cue have fixed values in the source. Each argument is optional: | Argument | Meaning | Default | |---|---|---| | `ms` | The time of the cue for one element, in ms | The `ms` of the keyframes | | `ease` | The name of an easing utility, as `ease` of `motion` (section 7.5) | The `ease` of the keyframes | | `gap` | The time from the end of the cue before, in ms. A negative value starts the cue earlier, so the two cues overlap | `0` | | `with` | The name of an earlier cue of the sequence, for example `with: logo`. The cue then starts at the start of that cue, plus `gap` | none | | `stagger` | The extra time for each element of the cue, in ms | `0` | | `from` | The first element of the stagger: `"start"`, `"end"` or `"center"` | `"start"` | **The elements of a cue.** - The elements of a cue are the elements of the component with its utility `cue-<name>`, in tree order. The `style` of an instance gives the utility to the root elements of the instance (section 12). - The parts of a split text are elements of the cue, in the order of the text (layout spec 5.10). - The names of the cues are unique in a component, also across two sequences. A `cue-<name>` that names no cue of the component is a compile error. **The places.** - The first cue starts at its `gap`. Each other cue starts at the end of the cue before, plus its `gap`. With `with`, it starts at the start of that cue, plus its `gap`. A start below 0 is 0. - Element `i` of `n` starts at the start of its cue, plus an extra time. The extra time follows the table of `Transition` (component reference 4.15). It is `i * stagger` from the start and `(n - 1 - i) * stagger` from the end. From the center, it is `floor(abs(2 * i - (n - 1)) / 2) * stagger`. - The end of a cue is its start, plus `ms`, plus the largest extra time of its elements. A cue with no elements keeps its `ms`. The length of the sequence is the latest end of its cues. - When the count of the elements of a cue changes, the places change with it. A new element takes the current time of its sequence. - Before its start, an element of a cue shows the first stop of its keyframes. After its end, it shows the last stop (layout spec 5.10, ADR-0150). **The timeline of a sequence.** A sequence has one of two timelines: - `play: <Bool>` runs the sequence in time. The value has the dependencies of a `derived` value. While it is true, the sequence runs to its end. While it is false, the sequence runs back to its start. A change during a run turns the sequence at its current time. - At the mount, a sequence with `play` true starts from its start, as a CSS animation that applies at the start does. With `play` false, it waits at its start. - `timeline: "<name>"` runs the sequence with the scroll. The name is a scroll timeline or a view timeline of the same file (layout spec 5.10). The optional `range: "<range>"` has the text of `animate-range-`, for example `"contain"`. Without it, the sequence uses the whole timeline. - With a `timeline`, the start of the range is the time 0, and the end of the range is the length of the sequence. The range uses one range name at both ends, or offsets alone. So each cue is a part of one range. Another range is a compile error. - A sequence with both `play` and `timeline`, or with neither, is a compile error. **Other rules.** - With the setting of reduced motion on, a sequence with `play` goes to its end or to its start at once. A `motion` value does the same with its target. A sequence with a `timeline` does not run, and each element shows its own style. - The runtime stops a sequence when its component leaves the tree. - Each feature of this section works on the web, on the native targets and in the interpreter. How a sequence relates to the other forms: | Form | Task | |---|---| | `transition` | A change of the style of one element | | `Transition` | An element that a block adds or removes. Its `stagger` is for a batch of elements that come in one frame | | `animate-<name>` | The keyframes of one element on its own timeline, with `animate-delay-` (layout spec 5.10) | | `motion` | One number that moves to a target (section 7.5) | | `sequence` | An order of several animations on one timeline, with gaps, overlaps and a stagger | A row that knows its index uses `delay-{i * 40}` or `animate-delay-{i * 40}`. The stagger of a cue is for elements that have no index in the source, for example the parts of a split text. ### 7.6 Events ```surf event submit(text: String); fn send() { emit submit(draft); } ``` The parent listens with `on:submit={handler}`. An event without a listener does nothing. Event handlers can be `async` functions. The runtime cancels a running handler task when its component is destroyed. An event that a component sends while it leaves the tree, in `on destroy` or in a cleanup, does not reach the parent. The parent is in step 3 of a batch then (section 10.5), and its handlers cannot run. ### 7.7 Slots A slot is a part of a component that the parent fills with markup. Declaration in the child: ```surf slot default; slot header?; slot row(bindable item: Todo); ``` Use in the child markup: ```surf <Column> @slot header #for item in items key item.id { @slot row(item) } @slot default </Column> ``` Fill in the parent: ```surf <TodoTable bind:items={todos}> #fill header { <Text style="text-20 font-600">Tasks</Text> } #fill row(todo) { <Toggle bind:checked={todo.done}>{todo.title}</Toggle> } <Text>Markup outside a fill goes into the default slot.</Text> </TodoTable> ``` - `TodoTable` declares `bindable prop items: List<Todo>`, and the parent binds it. A write in the fill then changes `todos` in the parent. - A slot name with `?` is optional. An unfilled optional slot renders nothing. - A required slot without a fill is a compile error in the parent. - A slot can have parameters. The fill receives the values from the child. - A slot parameter with `bindable` (`slot row(bindable item: Todo)`) must receive a place from the child. In the fill, the parameter is then also a place, and a write to it changes the original value. Without `bindable`, a fill parameter is a constant. - The built-in `List` declares `slot row(bindable item: T)`. When `items` is a place, each row can write to its item. ### 7.8 Traits and impl blocks A **trait** names methods that a type can have. An **impl block** gives the methods to a type (ADR-0134). Traits have the form of Rust. ```surf // shapes.surfs pub trait Shape { fn area(self) -> Float; fn describe(self) -> String = "a shape with the area {self.area()}"; } pub type Circle = { r: Float } pub type Square = { side: Float } impl Shape for Circle { fn area(self) -> Float = 3.14 * self.r * self.r; } impl Shape for Square { fn area(self) -> Float = self.side * self.side; } impl Circle { pub fn new(r: Float) -> Circle = { r: r }; pub fn grow(mut self, k: Float) { self.r *= k; } } ``` **Methods and associated items** - A method has `self` as its first parameter. `self` is the value, and `Self` is its type. A call is `c.area()`. - A method with `mut self` changes its value. Its call needs a place (section 5.2), for example `shape.grow(2.0)` on a state. The call is a statement of its own, as `xs.push(1)` is. A method without `mut` cannot write to `self`. - A function with no `self` is an **associated function**. A call names the type, for example `Circle.new(2.0)`. For a trait with `fn zero() -> Self`, the calls are `Int.zero()`, and `T.zero()` in a generic function. A plain impl can have associated functions too, for example a constructor. - A method of a trait can have a default body. An impl can replace it. - A trait can have an **associated type**: `type Item;`. Each impl gives it a type: `type Item = Int;`. A signature names it `Self.Item`, and a generic function names it `T.Item`. - An impl of a trait must have each method of the trait that has no default body, with the same signature. It cannot have other items. Two impls of one trait for one type are a compile error. **Where an impl is valid** - Traits and impl blocks are valid only in a module file (section 7.1). A method is a function of a module file, so it cannot read the reactive sources of a component (section 7.3). It can read `Locale`, as each function can (section 10.2). - An impl of a trait is valid only in the module of the trait or in the module of the type. A plain impl is valid only in the module of the type. This is the orphan rule of Rust. Rust applies it to a crate, and Surf applies it to a module. - A module exports a trait that starts with `pub`. A method of a plain impl is visible outside its module only when it starts with `pub`. A method of an impl of a trait has the visibility of the trait. - An impl can have type parameters: `impl<T: Text> Text for Page<T> { ... }` (section 7.9). **The import of a trait** A file calls a method of a trait only when it imports the trait, as in Rust (ADR-0134). A plain impl needs no import. ```surf // App.surf import { Circle, Shape } from "./shapes.surfs"; derived area = Circle.new(2.0).area(); ``` - `Circle.new` is a function of a plain impl, so it needs no import. `area` is a method of `Shape`, so the file imports `Shape`. - The file that declares the trait needs no import. - An associated function of a trait needs the import too, for example `Int.zero()` for a trait `Zero` of another file. - A call through a bound (`T: Shape`) or on a `dyn Shape` value needs no import, as in Rust. The bound or the type names the trait. - The built-in traits need no import (section 7.2). - A call without the import is a compile error. The message names the trait, and its help gives the import with the path from this file: ```text error: `area` is a method of the trait `Shape`, and this file does not import the trait = help: add `import { Shape } from "./shapes.surfs";` to this file ``` For an associated function, the message says "function" in place of "method". **Built-in traits** | Trait | Method | Gives | Built-in types with an impl | |---|---|---|---| | `Equatable` | `equals(self, other: Self) -> Bool` | `==` and `!=` | each type that `==` takes (section 5.1) | | `Comparable` | `compare(self, other: Self) -> Ordering` | `<`, `<=`, `>`, `>=`, `sort` and the key of `sortBy` | `Int`, `Int64`, `Float` and `String` | | `Hashable` | `hash(self) -> Int` | the key of a `Map` (section 4.5) | `Int`, `String` and `Bool` | | `Text` | `text(self) -> String` | `{x}` in a string and in the markup (section 5.4) | `String`, `Int`, `Float` and `Bool` | | `Number` | none | the operators of numbers | `Int` and `Float` | | `Token` | none | the value of `Theme.setToken` (ADR-0082, ADR-0130) | `Color` and `Float` | - `Equatable` and `Hashable` are automatic for records and enums. The automatic `==` compares each field (section 5.1). A record or an enum has the automatic `Hashable` when each of its fields has `Hashable`, as `#[derive(Hash)]` in Rust. - A record or an enum with a field whose type holds a `dyn` type gets no automatic `Equatable` and no automatic `Hashable` (ADR-0135). Examples of such fields are `shape: dyn Shape` and `shapes: List<dyn Shape>`. A program writes an impl when it needs one. - An impl of `Equatable` replaces the automatic comparison. A type with an impl of `Equatable` must also have an impl of `Hashable` to be a map key. The compiler checks this rule. - `Comparable` and `Text` need an impl. A record or an enum has no automatic impl of them. - `Comparable` includes `Equatable` (ADR-0135). A type with an impl of `Comparable` must have `Equatable`, from an impl or from the automatic `==`. A bound `T: Comparable` gives `==` and `!=` too. - `<`, `<=`, `>` and `>=` use the result of `compare`. `Ordering` is a built-in enum with the cases `less`, `equal` and `greater`. - A bound `T: Number` gives `+`, `-`, `*`, `/`, `%` and prefix `-` on values of `T`, and the traits `Comparable` (with `Equatable`) and `Text`. - A program cannot write an impl of `Number` or of `Token`. **Operator traits** | Trait | Operator | Method | |---|---|---| | `Add` | `a + b` | `add(self, other: Rhs) -> Self.Output` | | `Sub` | `a - b` | `sub(self, other: Rhs) -> Self.Output` | | `Mul` | `a * b` | `mul(self, other: Rhs) -> Self.Output` | | `Div` | `a / b` | `div(self, other: Rhs) -> Self.Output` | | `Rem` | `a % b` | `rem(self, other: Rhs) -> Self.Output` | | `Neg` | `-a` | `neg(self) -> Self.Output` | - Each operator trait has the form of its Rust trait. `Rhs` is the type of the right side. It is `Self`, unless the impl names another type: `impl Mul<Float> for Vector`. The associated type `Output` is the type of the result. - Each built-in type with an operator in section 6.2 has an impl of the trait of that operator, for example `Add` for `Int` and for `String`. - `a += b` is `a = a + b`, and the result must have the type of the place. The other assignment operators of section 5.2 follow the same rule. ```surf pub type Vector = { x: Float, y: Float } impl Add for Vector { type Output = Vector; fn add(self, other: Vector) -> Vector = { x: self.x + other.x, y: self.y + other.y }; } impl Mul<Float> for Vector { type Output = Vector; fn mul(self, k: Float) -> Vector = { x: self.x * k, y: self.y * k }; } impl Text for Vector { fn text(self) -> String = "({self.x}, {self.y})"; } ``` Each feature of this section works on the web, on the native targets and in the interpreter. A method cannot be an `async fn`. ### 7.9 Type parameters with bounds, and trait objects **Bounds** ```surf pub fn largest<T: Comparable>(xs: List<T>) -> T { let best = xs[0]; for x in xs { if x > best { best = x; } } return best; } pub fn label(value: impl Text) -> String = "Value: {value}"; pub type Page<T> = { items: List<T>, next: String? } ``` - A function, a record, an enum and a component can have type parameters with bounds (ADR-0135). A bound names one or more traits: `<T: Comparable + Text>`. - The type of a type parameter must have an impl of each trait of its bound. Another type is a compile error at the call or at the use. - `x: impl Token` is the short form of a type parameter with a bound. It is valid for a parameter and for a return type. Each `impl` in a parameter is a type parameter of its own. - Use the long form when two parameters, or a parameter and the return type, have the same type: `fn larger<T: Comparable>(a: T, b: T) -> T`. - A return type `impl Shape` is one type that the function chooses. Each `return` of the body must give that type. The caller sees only the methods of the trait. - An `impl` return type has only the traits of its bound. Each `impl` return type is a type of its own, also when two functions have the same bound. A generic function with an `impl` return type gives one type for each list of types of its calls. A method of a trait cannot return an `impl` type. - Dispatch is static. The compiler makes one copy of a generic function for each list of types that the app uses (ADR-0080). So an app pays only for what it uses. - A call through a bound needs no import, because the bound names the trait (section 7.8). A file that writes the bound `T: Shape` names the trait, so it declares or imports `Shape`. - In a generic function, `T.zero()` calls an associated function of a trait of the bound of `T`, and `T.Item` names an associated type of such a trait (section 7.8). `T: Mul<Float>` gives `a * k` with `k: Float`, and the result has the type `T.Output`. **Impls and methods with type parameters** ```surf pub type Page<T> = { items: List<T> } impl<T: Text> Text for Page<T> { fn text(self) -> String = "{self.items.count()} items"; } impl<T> Page<T> { pub fn first(self) -> T? = self.items.get(0); pub fn map<U>(self, f: (T) -> U) -> Page<U> = { items: self.items.map(f) }; } ``` - An impl with type parameters is for a generic record or enum. Each type parameter of the impl is a type argument of the type, once: `impl<T> Page<T>`. - A type has the methods of such an impl when its type arguments have the traits of the bounds of the impl. `Page<User>` has the method `text` only when `User` has `Text`. - Two impls of one trait for one generic type are a compile error, also with different bounds. - A method can have type parameters of its own, for example `U` of `map`. A method of a trait with type parameters has the same type parameters and bounds in each impl, in their order. Their names can be different. - The compiler makes one copy of each method of such an impl for each list of types that the app uses. - A call of an associated function of such an impl names the generic type without its types: `Page.empty()`. The expected type of the result and the values of the call give the types, as for a generic function (section 7.3): `const p: Page<Int> = Page.empty();`. **Generic components** ```surf // Picker.surf component Picker<T: Text>; prop items: List<T>; bindable prop selected: T? = none; --- <Column style="gap-1"> #for item in items { <Button on:press={selected = item}>{item}</Button> } </Column> ``` ```surf <Picker items={users} bind:selected={chosen} /> <Picker<User> items={users} /> ``` - `component Name<...>;` declares the type parameters of a component. It is valid only in the script of a component file, and its name must be the name of the file. A component without type parameters has no such declaration. - The root component, `App.surf` or a page in `routes`, cannot have type parameters, because no parent gives their types. - Each declaration of the script can use the type parameters, for example a prop, a state or a function. - The checker infers the types of each use from its props. A use can also name them after the name of the component: `<Picker<User> items={users} />`. The end tag has no types: `</Picker>`. - A prop gives a type when its value has a type without the help of the prop. A lambda without the types of its parameters and an empty list give no type. Then the use names the types. - The types of a use have the traits of the bounds. The props, the events and the slots of the use have these types. - The compiler makes one copy of a generic component for each list of types that the app uses, as for a generic function. The copies have the style rules of the component once. **Trait objects** ```surf import { Shape, Circle, Square } from "./shapes.surfs"; state shapes: List<dyn Shape> = [Circle.new(1.0), Square { side: 2.0 }]; derived total = shapes.map(s => s.area()).sum(); ``` - A type can be `dyn Trait` directly, for example `List<dyn Shape>` or `prop shape: dyn Shape`. There is no `Box`. The runtime holds the value, and a table of functions finds each method at run time (ADR-0135). - A value of a type with an impl of the trait becomes a `dyn` value where the expected type is `dyn Trait`. - A `dyn` value has the value semantics of section 5.1. An assignment gives the receiver its own copy. - A `dyn` value has only the methods of its trait. - `==` and `!=` on a `dyn` value are a compile error, because `Equatable` cannot be `dyn` (ADR-0135). A `dyn` type cannot be the key type of a `Map`, because a key needs `==`. A record or an enum with a `dyn` field gets no automatic `Equatable` and no automatic `Hashable` (section 7.8). - A trait can be `dyn` only when it follows the object rules of Rust: - Each function of the trait has `self`. A trait with an associated function cannot be `dyn`. - No method returns `Self`, and no method has a parameter of the type `Self`. So `Equatable` and `Comparable` cannot be `dyn`. - A `dyn` type names each associated type of its trait, for example `dyn Source<Item = Int>`. - A `dyn` type of an operator trait names the type of the right side, because `Self` has no meaning there: `dyn Mul<Float, Output = Vector>`. This is error E0393 of Rust. - `Number` and `Token` cannot be `dyn`. `Number` includes `Equatable`. A `dyn` type of `Token` is not in version 1. - A value of a `dyn` type has the trait of its type, as `dyn Shape: Shape` in Rust. So a generic function `fn total<T: Shape>(xs: List<T>)` takes a `List<dyn Shape>`. - A value becomes a `dyn` value with no import of the trait, as in Rust. A `List<Circle>` does not become a `List<dyn Shape>`, and a `Circle?` does not become a `dyn Shape?`. A value of `Circle` becomes a `dyn Shape?`. - A method with `mut self` of a `dyn` value changes the value. The `dyn` value keeps its table. - A type at the boundary of an extern module cannot be `dyn` or `impl` (section 13.1, ADR-0133). Each feature of this section works on the web, on the native targets and in the interpreter. --- ## 8. Async code - `async fn load() -> Result<Data, Error>` returns a `Task<Result<Data, Error>>`. A call starts the task at once. The task runs until its first `await` before the call returns. - `await task` waits for the task and gives its value. `await` is valid in an `async fn` and in an event handler. - In version 0, `await` must be at the start of a statement value: `const x = await load()`, `let x = await load()`, `x = await load()`, `await load()` and `return await load()`. Other positions are a compile error, for example `1 + await load()`. - `spawn task` starts a task and does not wait for it. A call of an `async fn` as a statement does the same. - A task can start only in a component: in a component function, an effect, `on mount`, `on destroy` or an event handler. In a module function and in an initial value, a call of an `async fn` must be a direct `await` in an `async fn`. - `sleep(ms: Int)` is a built-in `async fn`. Its task ends after `ms` milliseconds. - `interval(ms: Int)` is a built-in `async fn`. Its task ends at the next time that is a multiple of `ms` on the clock of the app. This clock counts from the start of the app. A loop with `await interval(ms)` does not drift: the time of its work does not move the next end. The first end can come before `ms`. For both functions, a time of 0 or less ends the task at once. ```surf state seconds = 0; async fn tick() { while true { await interval(1000); seconds += 1; } } on mount { tick(); } ``` The runtime cancels the task of `tick` when the component leaves the tree, so the loop needs no `cleanup`. ADR-0026 gives the reasons for this form in place of a `Timer` module with callbacks. - An event handler that contains `await` runs as a task. - All code runs on the UI thread. An `await` lets other events run until the task continues. - Each `await` ends the current batch (section 10.5). The UI shows the changes before the await. The end of a task also ends a batch. - A task belongs to the component that started it. The runtime cancels the task when the component is destroyed. A cancelled task does not continue after its current `await`. - Two tasks are equal only if they are the same task. --- ## 9. Markup ### 9.1 Elements ```text <Name attribute=value>children</Name> <Name attribute=value /> ``` - A name must be a built-in component or an imported component. - A lowercase name such as `<div>` is a compile error. There is no raw HTML, because raw HTML has no meaning on native targets. - A component file can have more than one root element. The parent lays out each root as a separate child. - The start tag of a generic component can name its types: `<Picker<User> items={users} />` (section 7.9, ADR-0135). ### 9.2 Attributes | Form | Meaning | |---|---| | `name="text"` | String value. `{...}` inside it inserts values. | | `name={expression}` | Value of any type | | `name` | Short form of `name={true}`. Valid only for `Bool` props. | | `{name}` | Short form of `name={name}` | | `on:event={handler}` | Event listener: a function name, statements, or a lambda whose body is statements | | `bind:prop={place}` | Two-way binding. `prop` must be a `bindable prop`. The value must be a place. | | `style="p-2 gap-1"` | Utilities: the styles of the element (section 12) | | `{...value}` | One attribute for each field of a record. An attribute on the element wins (ADR-0083). | | `testid="name"` | A name for tests. It has no effect on the app. Valid only on built-in components. | - Two attributes with the same name on one element are a compile error. - On an instance of a user component, the `style` of the instance applies to the root elements of the component, with its computed values and its conditions. This is the same as on an element (layout spec 12). - On a built-in component, `on:`, `bind:` and the attributes follow the [component reference](https://surface.solenovis.com/docs/components.md). - The value of an `on:` attribute is one of these: - a function name: `on:submit={add}`. The function gets the value of the event, if it takes a parameter. - statements, which run at each event: `on:press={count += 1}`, `on:press={remove(todo.id)}`, `on:press={save(); close()}`. - a lambda whose body is statements, to use the value of the event: `on:change={done => setDone(todo.id, done)}`, `on:keydown={key => last = key}`. ### 9.3 Text - Literal text and `{expression}` are valid only inside a component that accepts text. The built-in components that accept text are `Text`, `Span`, `Button`, `Link`, `Toggle` and `Option`. A user component accepts text in the fill of each of its slots that is inside one of these. For the `default` slot, the fill is the children of the instance. - Text in any other place is a compile error. - Each run of white space (spaces, tabs, line ends) becomes one space. White space at the start and at the end of a text block is removed. - Text has no HTML entities. `&` stays the five letters `&`. Write `&` for an ampersand. Write `{"<"}` for a `<`, and `{"\{"}` for a `{`, because these characters start a tag and an expression. - A `Text` and its parts are one text (ADR-0111): - A `Span` is valid only in a `Text` or in a `Span`. A `Link` in a `Text` is a part of the text. Other elements in a `Text` are a compile error. The fill of a slot in a `Text` takes a `Span` and a `Link` too. - The content of a `Text` is one text block. A `Span` and a `Link` do not end it, so a space next to them stays: `Run <Span>surf dev</Span>, then` has a space before the `Span` and after the comma. A run of white space across the edge of a `Span` is one space. - A block or a tag ends a text block, and the body of a block is a text block of its own. So a space next to a block goes: `#for t in tokens { <Span>{t.text}</Span> }` puts no space between the tokens. Write `{" "}` for a space there. ### 9.4 Interpolation `{expression}` in text follows section 5.4. The value updates when its dependencies change. ### 9.5 Blocks and tags The markup has two kinds of control words: | Sigil | Kind | Words | |---|---|---| | `#` | A **block**: a head, then a body in `{ ... }`. Some blocks continue after their `}`. | `#if` (`else if`, `else`), `#for` (`empty`), `#match`, `#await` (`then`, `catch`), `#fill`, `#try` (`catch`) | | `@` | A **tag**: one line, without a body. | `@const`, `@slot` | - The head of a block uses the syntax of the script. `#if` is the `if` statement, `#for` is the `for` loop, and `#match` is the `match` expression with markup in its arms. - A `#` or an `@` is a control word only when one of the words of the table follows it. It must also be at the start of a line, or directly after a tag or a `}`. Everywhere else it is text: `<Text>#1 of {n}</Text>`. - A `}` in markup ends the body of a block. For a `}` in text, write `{"}"}`. **Condition** ```surf #if count > 10 { ... } else if count > 5 { ... } else { ... } ``` **List** ```surf #for todo, index in todos key todo.id { ... } empty { <Text>No tasks.</Text> } ``` - The words and the order are the words and the order of the `for` loop of the script (section 6.1): `#for item in list { ... }`, `#for item, index in list { ... }`. - The value must be a `List<T>` or an `Int` range. - `key <expression>` is optional. A key must be an `Int` or a `String`. The runtime uses the key to keep the identity of an item when the list changes. - Two equal keys in one list are a runtime error. - Without a key, the position in the list is the identity. `surf lint` warns when a list of records has no key (ADR-0070). - If the list is a place, `todo` is also a place, and a write to `todo` changes the element in the list. - The list can also be a computed value, for example `#for todo in todos.filter(t => !t.done) key todo.id { ... }`. Then `todo` is a constant: a write to it or `bind:` with it is a compile error. To change the element, write to the original list, for example through a function that finds the element by its key. **Match** ```surf #match shape { .circle(r) => <Text>Circle, radius {r}</Text> .rect(w, h) => { <Text>Rectangle</Text> <Text>{w} by {h}</Text> } } ``` - Each arm is `pattern => markup`. The markup is one element, block or tag, or nodes in `{ ... }`. - The arms must cover every value (section 6.3). **Await** ```surf #await profile { <Text>Loading</Text> } then user { <Text>{user.name}</Text> } catch error { <Text style="text-$error">{error.message}</Text> } ``` - The value must be a `Task<T>` or a `Task<T>?`. While the value is `none`, the block shows nothing. - If `T` is `Result<V, E>`, `then` receives the `ok` value and `catch` receives the `err` value. Otherwise `catch` is not valid. - A new task in the value shows the first branch again, until the new task ends. **Slots and local constants** - `#fill name(parameters) { ... }` fills a slot of a child (section 7.7). It is valid only as a direct child of a component element. - `@slot name(arguments)` renders a slot in the component that declares it (section 7.7). - `@const x = expression` declares a constant for the rest of the enclosing block. The expression ends at the end of the line. ```surf #for item, i in items key item.id { @const label = "{i}: {item.name}" <Button alt={label}>{label}</Button> } ``` **Error boundary** ```surf #try { <Chart data={points} /> } catch message { <Text>The chart failed: {message}</Text> } ``` - The `catch` body shows when the mount or an update of the `#try` body has a runtime error (section 15.2). This includes the child components of the body. - `catch` is necessary. Its name is optional, and it is a `String` that does not change. - The `#try` body stays gone after the error, and the rest of the app goes on. - An error in an event handler, in an effect or in a task goes to `on error`. - An error in the `catch` body goes to `on error`. ### 9.6 Built-in components (version 0) | Component | Function | Accepts text | Web element | |---|---|---|---| | `Row` | Horizontal flex container | no | `<div>` | | `Column` | Vertical flex container | no | `<div>` | | `Stack` | Children on top of each other | no | `<div>` | | `Grid` | Grid container | no | `<div>` | | `Scroll` | Scrollable area | no | `<div>` | | `List` | Virtualized list, renders only the rows near its visible area (ADR-0027) | no | `<div role="list">` | | `Text` | Text | yes | `<span>` | | `Span` | A part of a `Text` with a style of its own (ADR-0111) | yes | `<span>` | | `Link` | A link. In a `Text`, a part of the text | yes | `<a>` | | `Image` | Image | no | `<img>` | | `Video` | A video with its sound, captions and controls (ADR-0156) | no: it takes `Source` and `Track` elements | `<video>` | | `Audio` | A sound, with controls or with no box (ADR-0156) | no: it takes `Source` and `Track` elements | `<audio>` | | `Source` | One file of a `Video` or an `Audio`, with its format | no | `<source>` | | `Track` | A WebVTT file of captions or subtitles (ADR-0157) | no | `<track>` | | `Icon` | Icon from an SVG file (ADR-0030) | no | `<svg>` with `<path>` elements | | `Button` | Button | yes | `<button>` | | `Input` | One-line text input. `kind="date"` or `kind="number"` gives the `type` of the element on the web. A native field stays a text field. | no | `<input>` | | `TextArea` | Multi-line text input | no | `<textarea>` | | `Toggle` | Switch or check box | yes | `<label>` with `<input type="checkbox">` (layout spec, section 4) | | `Slider` | Value slider | no | `<input type="range">` | | `Select` | Choice from a list | no: it takes `Option` elements | a `<button>` and a list in a `<div>`, or with `system` a `<select>` in a `<div>` | | `OptionList` | The box of the open list of a `Select`, for its styles | no: it takes `Option` elements | `<div>` | | `Option` | One choice of a `Select` | yes | `<option>` | | `Overlay` | An element above all others, next to its parent (ADR-0032) | no | an element with `popover` | | `Transition` | Animates the elements that its blocks add and remove (ADR-0036) | no | no element of its own | | `Drawing` | A box with its own coordinates for shapes (ADR-0029) | shapes and `Group` | `<svg viewBox>` | | `Line`, `Polyline`, `Polygon`, `Rect`, `Circle`, `Ellipse`, `Path`, `Group` | Shapes in a `Drawing` | `Group`: shapes | the SVG element of the name | | `Canvas` | Immediate-mode drawing | no | `<canvas>` | | `PlatformView` | Native view (native targets only) | no | not available | The [component reference](https://surface.solenovis.com/docs/components.md) gives the attributes, the bindings and the events of each component. An attribute or an event that is not in the reference is a compile error. Not in version 0: - `Canvas` and `PlatformView`. Each of them is a compile error (component reference, section 4.16). ### 9.7 The keyboard focus - `Focus.to(id)` gives the keyboard focus to the element with this `id` (ADR-0102). It takes effect at the end of the batch, so the element can come into the tree in the same batch. A row of a `List` that shows again can come in the next frames, so the web target tries again in the next three frames (ADR-0110). An `id` that no element has does nothing. - `Focus.newId()` gives a `String` that no other call gives in this run of the app. A component uses it for `id`, `controls` and `labelledBy`: `state uid = Focus.newId();`. - On the native targets, only a control can get the focus. --- ## 10. Reactivity ### 10.1 Sources and consumers - **Reactive sources:** `prop`, `bindable prop`, `state`, `derived` and `motion` declarations. - **Reactive consumers:** markup expressions, `derived` expressions and `effect` bodies. ### 10.2 Dependencies The compiler finds the dependencies of each consumer at compile time. The dependencies of a consumer are: 1. the sources that the consumer reads directly 2. the sources that the component functions read, for each component function that the consumer calls, transitively Functions from module files cannot read the sources of a component (section 7.3), so they add no dependencies on them. This is also true for the methods of an impl (section 7.8) and for a call through a `dyn` value (section 7.9). `Locale` is the exception (section 14, ADR-0132). Each function can read it, also a function of a module file and a method. The checker follows each call, transitively. A consumer that calls a function that reads `Locale` depends on `Locale`, so it updates after `Locale.set`. A call through a `dyn` value depends on `Locale` when an impl of the method in the app reads it. A call of a function value depends on `Locale` when a function value of the same type in the app reads `Locale` (ADR-0132). This applies to each function value: a prop (section 10.3), a state, a field, an item of a list or a map, and a parameter. For example, with `prop format: (Int) -> String`, the call `format(count)` depends on `Locale` when a lambda or a module function of the type `(Int) -> String` reads it. This is the conservative rule of a call through a `dyn` value. So the child updates after `Locale.set`. A write to a place is not a read of the place. `total = v` and `log.push(v)` read `v` and the indexes in the place, but not `total` or `log`. An operator such as `+=` reads its target. The analysis is conservative. A read inside a branch counts as a dependency, also when the branch does not run. The result can be more updates than necessary, but never a missed update, and the result is the same on every target. ### 10.3 Lambdas as props A lambda that reads reactive sources is itself a consumer in the parent. When one of its dependencies changes, the parent sends the lambda again, and the child sees a changed prop. This keeps the dependency analysis static across component boundaries. A function prop has a function type: `prop format: (Int) -> String`. Its value is a function value (section 6.4), for example: - a lambda: `format={(n) => "{n} {unit}"}`, - a module function, by its name: `format={formatWeight}`, - a function prop of the parent, by its name. The child calls it like a function: `{format(count)}`. These rules apply: - The lambda keeps copies of the values that it reads when the parent makes it. The parent makes it again when one of these values changes. - The lambda cannot call a function of the parent component, because the child runs the lambda. Use a module function. - An `async fn` cannot be a function prop. - The default value of a function prop cannot read the sources of its component. - A function prop cannot be `bindable`. Its type cannot take or give a task. - Two function values are equal only when they are the same value. So a new lambda from the parent is always a change. ### 10.4 Writes - A write to a source, or to a place inside a source, marks the source as changed (with the exception in section 5.3). - Writes are valid in functions, event handlers, effects, `on mount` and `on destroy`. - A write in a `derived` expression or in a markup expression is a compile error. - `Locale.set` is a write (section 14, ADR-0132). In a `derived` expression and in a markup expression, it follows the rules of a write of a state. So it is a compile error there. An `on:` attribute can call it, because its value is statements. ### 10.5 Batches Writes do not change the UI at once. The runtime collects writes into a **batch**. A batch ends when the current event handler, effect, `on mount` block or task step (up to the next `await`) returns. At the end of a batch, the runtime does these steps in this order: 1. It sends changed props from parents to children. 2. It marks the affected `derived` values as stale. A stale value is calculated again when something reads it. 3. It updates the markup of each affected component, parent before child. 4. It lays out and paints the changes in the next frame. 5. It runs the affected effects, in tree order (parent before child), and in declaration order inside one component. Writes from step 5 start a new batch. After 100 batches in a row from effects, without an event from outside, the runtime stops with a runtime error ("the effects started 100 batches in a row"). ### 10.6 Guarantees - **No partial states:** each consumer in a batch sees the values after all writes of the batch. - **Deterministic order:** the order of updates and effects depends only on the source code and the events, not on the target. --- ## 11. Component lifecycle 1. The parent calculates the props. 2. The `const` and `state` initializers run in declaration order. 3. The runtime creates the markup. Child components go through the same steps. 4. The first frame shows the component. 5. The effects run for the first time, then `on mount` runs. Children run these steps before their parent. 6. Each batch updates the component (section 10.5). 7. When the component leaves the tree, the runtime cancels its tasks, runs the effect cleanups, then runs `on destroy`. Children run these steps before their parent. --- ## 12. Styles (summary) The styles of an element are **utilities** in its `style` attribute. The [Surface Layout Spec v0](https://surface.solenovis.com/docs/layout.md), section 12, lists every utility. This section gives a summary. ```surf <Column style="p-6 gap-3 bg-$surface rounded max-600:p-4"> <Text style="text-20 font-600 text-$text-strong">Title</Text> <Button style="px-4 h-9 bg-$accent text-$on-accent hover:opacity-0.85 active:scale-0.97 {saved}:bg-$success" > Save </Button> <Column style="h-2 bg-$accent w-{progress}%" /> </Column> ``` - Each utility sets one style property of the layout spec, or one part of it: `p-2` sets all sides of `padding`, `pt-2` the top side. - For spacing and sizes, a number is a number of steps (ADR-0098). One step is `0.25rem`, so `p-2` is 8 logical px at the default root size. `p-2px` is exact, and a value can have a unit: `w-50vw`, `p-1rem` (ADR-0067). Other utilities keep exact numbers: `border-2`, `text-14`, `rounded-8`. - `$name` is a theme token (layout spec, section 2.1). A token always has `$`. An unknown token is a compile error. A bare name is a constant: `gap-inset`, `text-xs`. - A value with spaces has `_` for each space: `shadow-0_2_8_rgb(0_0_0_/_0.15)`. - Variants come before a utility. A variant is a state (`hover:`, `active:`, `focus-visible:`, `disabled:`) or a theme (`dark:`, `light:`). It can also be a platform (`ios:`, section 14), a named breakpoint (`md:`, `max-md:`) or a window width (`min-600:`, `max-600:`). - A computed value goes in braces after the prefix: `w-{width}px`. A computed transform runs no layout: `translate-x-{x}px`, `translate-y-{y}px`, `scale-{s}`, `rotate-{r}`. - `{condition}:` is a variant (ADR-0096): `{saved}:bg-$success`. The value must be a `Bool`. It can come with other variants: `{!open}:max-md:hidden`. A condition wins over the same utility with no condition. - `(...)` groups utilities under one variant: `{saved}:(bg-$success text-$on-accent)`, `hover:(underline opacity-0.8)`. - `{value}:{ case: utilities, }` is a case list (ADR-0096). The value must be an enum. The compiler checks each utility. - The list must name each case. A case with fields is a compile error. Write `case:,` for a case with no style. - A `String` value uses one condition for each value: `{status == "open"}:text-$success`. - The utilities of one case cannot set the same part of a property twice. Two cases can set the same property, because only one case is on. - The utilities are fixed text. A computed value goes outside the list. - `@name` uses a preset from `style-presets.toml` (ADR-0091, layout spec, section 2.2). A utility on the element wins over a preset. - A word that is not a utility is a compile error. Two utilities of one element can set the same part of a property with the same variants. This is a compile error, so the order of the words has no effect. - The `style` of a user component applies to the root elements of that component. - A style property as an attribute, for example `gap={12}`, is a compile error. The help text of the error gives the utility. The layout spec, section 12.3, gives the precedence of the declarations. --- ## 13. Extern modules An extern module declares functions that each target implements in its own language. ```surf // haptics.surfs pub enum Strength { light, medium, heavy } pub extern module Haptics { fn impact(strength: Strength); async fn isAvailable() -> Bool; fallback { fn impact(strength: Strength) {} async fn isAvailable() -> Bool = false; } } ``` ### 13.1 Types at the boundary Parameters and return values must use only these types: - `Int`, `Float`, `Bool`, `String`, `Void` - `List<T>`, `Map<K, V>`, `T?`, `Result<T, E>` - records and enums whose fields use only these types Function types are not valid at the boundary (ADR-0133). Events (section 13.3) and `async fn` calls (section 13.4) do the work of a callback. A function or an event of an extern module has no type parameters. A type at the boundary cannot be a `dyn` type or an `impl` type (ADR-0133). ### 13.2 Implementation files Implementation files are in the same folder as the module file. The name is `<module file>.<target>.<extension>`: | File | Used for | |---|---| | `haptics.ios.swift`, `haptics.macos.swift` | One Apple target | | `haptics.apple.swift` | Apple targets without a more specific file | | `haptics.android.kt` | Android | | `haptics.macos.rs`, `haptics.windows.rs`, `haptics.linux.rs` | One desktop target | | `haptics.native.rs` | Native targets without a more specific file | | `haptics.web.js` | Web (Phase 1). `haptics.web.ts` is a decision for later. | - The most specific file wins: target, then group, then `native`. - If a target has no implementation file, the target uses the `fallback` block. If there is no `fallback` block either, the build for that target fails with a compile error. - `surf extern stubs [folder]` writes a stub of each implementation file that does not exist yet: `haptics.native.rs` and `haptics.web.js`. A stub has each function of the module with its types, and a body that panics or throws. A stub replaces the `fallback` block on its targets. The command also writes `haptics.web.d.ts`, the TypeScript declarations of the web implementation file, as `surf build` does. - The desktop targets and a SurfWave server accept Rust implementation files. A `String` parameter is `&String` in Rust, and `surf extern stubs` writes the exact types. The file can use the standard library and the runtime, and no other crate. Swift and Kotlin files are for the mobile targets. - The `fallback` block must have each function of the module, with the same signature, and no other function. An app that embeds in a host shares its process with other apps (ADR-0056). `surf_core::task::current_view()` gives the number of the app that calls a function. `emit.to(view)` sends an event to that app only. `emit` alone sends to all apps. A web implementation file is an ES module. It exports one function for each function of the module, with the same name. The values cross the boundary as JavaScript values: | Surf type | JavaScript value | |---|---| | `Int`, `Float` | number | | `Bool`, `String` | boolean, string | | `List<T>`, `Map<K, V>` | array, `Map` | | `T?` | the value, or `null` | | `Result<T, E>` | `{ $: "ok", value }` or `{ $: "err", value }` | | record | object with the fields | | enum | the case name for a case without fields, else `{ $: "case", ...fields }` | An `async fn` returns a promise. ### 13.3 Events An extern module can send events to the app (ADR-0031). It declares each event, and a component listens with a declaration: ```surf // plugin.surfs pub extern module Plugin { fn setParam(id: Int, value: Float); event report(params: List<Float>, peak: Float); fallback { fn setParam(id: Int, value: Float) {} } } ``` ```surf import { Plugin } from "./plugin.surfs"; state level = 0.0; on Plugin.report(params, peak) { level = peak; } ``` - The values of an event follow the rules of section 13.1. A listener has one name for each value, and the names take the types of the event. - A listener can write the state of its component, and it runs as one batch. It lives as long as the instance of the component. All mounted instances with a listener get each event, in the order in which they mounted. - An event waits until the current batch ends. Events keep their order, and no event is dropped or merged. An event without a listener is lost. - An implementation file of a module with events has one more function, `start`, which takes `emit`. The runtime calls it once, when the first listener mounts. `emit` has a method for each event: `emit.state(params, peak)`. A web file exports `function start(emit)`. A Rust file has `pub fn start(emit: Emit)`. `Emit` is `Copy` and `Send`, so any thread can send, and the send wakes the event loop. - `start` is not a valid name for a function of a module with events. - A Rust file names the items of the module by their Surf names. A name that Rust or the generated code uses gets `__` at the end: a Rust keyword that cannot be a raw identifier (`self`), a name of the Rust prelude (`Vec`, `Some`), a name of `surf_core::prelude` (`Event`, `Tree`) and the names `Emit`, `imp`, `STYLESHEET` and `surf_bridge`. Another keyword is a raw identifier (`r#type`). An event `to` is the method `to__` of `Emit`, because `to` sends to one app (ADR-0115). - Without an implementation file, the module uses its `fallback` block and no event comes. A `fallback` block cannot send events in version 0. - The interpreter (`surf dev --target desktop`) delivers no events in version 0. A Rust implementation file has the items of a Rust module. The compiler includes the file in the module of the extern module. So the file can name the types of the module file and of its imports. The file has a `pub fn` for each function of the module, with the same name, and a `pub async fn` for each `async fn`. The file cannot have inner attributes (`#![...]` or `//!`). The values cross the boundary as Rust values: | Surf type | Rust value | |---|---| | `Int`, `Float`, `Bool` | `i64`, `f64`, `bool` | | `String` | `String` | | `List<T>`, `Map<K, V>` | `Vec<T>`, `surf_core::Map<K, V>` | | `T?` | `Option<T>` | | `Result<T, E>` | `Result<T, E>` | | record | a struct with the fields | | enum | an enum with the cases | A parameter of a type other than `Int`, `Float` and `Bool` is a shared reference, for example `&String` or `&Vec<Todo>`. The native executor has no wakers: a future of a Rust implementation calls `surf_core::task::progress()` when it becomes ready. `surf_core::data::dir()` gives the data folder of the app, where an implementation can keep files. ### 13.4 Calls - A synchronous call runs on the UI thread. The implementation must return quickly. - An `async fn` call can run on any thread. The result comes back on the UI thread and starts a new batch. - A call names the module: `Haptics.impact(.light)`. A call of an `async fn` starts a task, as other `async fn` calls do (section 8). - The compiler generates direct bindings. These are a Rust call for Rust, the C ABI for Swift, JNI for Kotlin, and an ES module import for the web. There is no message serialization. --- ## 14. Platform information - The platform names are `macos`, `windows`, `linux`, `android`, `ios` and `web`. The groups are `apple` (macos, ios), `mobile` (ios, android), `desktop` (macos, windows, linux) and `native` (all targets except web). A platform variant of a utility (`ios:p-8`, section 12) takes these names. - `Platform.os` has the type `enum Os { macos, windows, linux, android, ios, web }`. - `Platform.os` is a compile-time constant. `if Platform.os == .ios { ... }` keeps the block only in iOS builds. - The compiler type-checks code for all targets, also code that a target removes. - `Window.width` and `Window.height` are the size of the window, as `Float` values in logical px (ADR-0106). A read has the dependencies of a state: a `derived` value, an effect and the markup update after a resize. - `Window` is valid only in a component, and a program cannot write it. A function in a `.surfs` file takes the size as a parameter. A declaration or an import with the name `Window` comes first. - A server has no window, so both values are 0 there. **Locale** The module `Locale` formats values for a person in the locale of the app (ADR-0132). A **locale** is a language with an optional region, as a BCP 47 tag, for example `en`, `de` or `pt-BR`. ```surf not-surf state count = 3; state price = 1234.5; derived total = Locale.number(price, style: .currency(code: "EUR")); derived files = match Locale.plural(count) { .one => "{count} file", _ => "{count} files", }; --- <Column style="gap-2"> <Text>{total}</Text> <Text>{files}</Text> <Button on:press={Locale.set("de")}>Deutsch</Button> </Column> ``` - `surf.toml` lists the locales of the project, for example `locales = ["en", "de"]`. At the start, the app takes the locale of the system that matches the list best, else the first item of the list. The rule of the match follows below. - If a project uses `Locale` and its `surf.toml` has no key `locales`, the compiler gives an error. An empty list, `locales = []`, gives the same error (ADR-0132). The message gives an example line, for example `locales = ["en"]`. - The MCP server and the compiler in the browser give this error too, because they check source text with no `surf.toml` (ADR-0132). A sample that uses `Locale` needs a project with a `surf.toml`. - `Locale.set(tag)` changes the locale while the app runs, and each format follows. A tag that fits no item of the list gives the first item. - `Locale.set` is a write (section 10.4, ADR-0132). It follows each rule of a write of a state, in each place. So a `derived` value and a markup expression cannot call it. - `Locale.set` does not save the locale. At the next start, the app takes the locale of the system again. This is the rule of `Theme.setToken` (ADR-0082). An app can save the choice with `Storage`. - `Locale.current` is the tag of the current locale, as a `String`. It is always an item of the list. - A read of `Locale.current` and a call of a function of `Locale` have the dependencies of a state, as a read of `Window.width` has. A `derived` value, an effect and the markup update after `Locale.set`. - `Locale` is valid in each function, also in a function of a `.surfs` file and in a method. For example, a module file can have `fn due(t: Todo) -> String = Locale.date(t.due);`. The checker follows each call (section 10.2). So a component that calls a function that reads `Locale` updates after `Locale.set`. - A module constant cannot read `Locale`, also through a call (section 7.3, ADR-0132). This is a compile error, and the message says to use a function. An instance constant of a component cannot read `Locale` either, and its message says to use a `derived` value. A call of a function value reads `Locale` when a function value of its type reads it (section 10.2). - Only `Locale.set` changes the locale. A declaration or an import with the name `Locale` comes first. - On a SurfWave server, each request takes the best match of its `Accept-Language` header in the list, else the first item of the list. `Locale.current` is the locale of the request. The page hydrates with the same locale ([SurfWave](https://surface.solenovis.com/docs/routes.md), section 5). ```surf not-surf // labels.surfs const PRICE = Locale.number(9.99); // compile error: a module constant reads Locale pub fn price() -> String = Locale.number(9.99); // a function reads Locale at each call ``` **The match.** The app uses the lookup of RFC 4647 (ADR-0132). It takes the wanted tags in their order. For each wanted tag, it tries the tag, and then the tag without its last part, until a try fits an item of the list. So `de-CH` fits `de`, and `zh-Hant-TW` tries `zh-Hant` and then `zh`. A part of one letter, for example `x` in `de-x-private`, goes away together with the part after it. The lookup never adds a part, so `de` does not fit `de-CH`. A try fits an item when the two tags are equal apart from the case of the letters. BCP 47 gives the case no meaning. The first fit gives the locale. These are the wanted tags: - At the start of an app, the wanted tags are the languages of the system, in the order of the system. On the web, these are the languages of the browser. - On a SurfWave server, the wanted tags are the tags of the `Accept-Language` header of the request, in the order of their weights. Two tags with the same weight keep their order in the header. A tag with the weight 0 is not a wanted tag, because RFC 9110 gives it the meaning "not acceptable". - After `Locale.set` in the browser, each data request, each action and each remote call has the header `x-wave-locale` with the tag of `Locale.current`. The server uses this tag as the one wanted tag, in place of `Accept-Language` ([SurfWave](https://surface.solenovis.com/docs/routes.md), section 5). - `Locale.set(tag)` has one wanted tag. If no try fits, the app takes the first item of the list. Before 2026-11-04 the rule was exact tags only. The project owner changed it, because macOS often gives tags with a region, for example `de-DE`, and a German Mac then got English from the list `["en", "de"]`. | Function | Result | Rule | |---|---|---| | `Locale.number(x, style: NumberStyle = .decimal)` | `String` | `x` is an `Int` or a `Float` (`impl Number`). The text has the decimal separator and the grouping of the locale | | `Locale.date(ms, style: DateStyle = .medium)` | `String` | The date of `ms` in the time zone of the machine, as `Time.local` gives it, with the names of months and weekdays of the locale | | `Locale.time(ms, style: DateStyle = .medium)` | `String` | The time of day of `ms` in the time zone of the machine | | `Locale.relative(ms)` | `String` | The text of a difference of `ms` milliseconds, for example "3 days ago" or "in 2 hours". A negative value is in the past | | `Locale.list(items)` | `String` | The strings of a `List<String>` with the separators and the words of the locale, for example "a, b, and c" in `en` | | `Locale.plural(n)` | `PluralCategory` | The plural category of `n` in the locale. `n` is an `Int` or a `Float` | | `Locale.compare(a, b)` | `Ordering` | The order of two strings in the rules of the locale. `.less` when `a` comes before `b`. `xs.sortWith((a, b) => Locale.compare(a, b))` sorts a list in this order (section 4.5) | | `Locale.set(tag)` | nothing | Changes the locale. The tag is a `String`. The change is not saved | | `Locale.current` | `String` | The tag of the current locale | - `NumberStyle` is a built-in enum with the cases `decimal`, `percent` and `currency(code: String)`. `code` is a currency code of ISO 4217, for example `"EUR"`. A percent shows `x` times 100: `0.25` gives "25%" in `en`. - A decimal number has at most 3 digits after the separator, and a percent has none. A currency has the digits of the currency, for example 2 for `EUR`. The value rounds half away from zero, as `round` does (section 4.2). - `DateStyle` is a built-in enum with the cases `short`, `medium` and `long`. ICU and `Intl` both have these lengths. The locale gives the pattern of each length. - `Locale.relative` uses the largest unit in which the amount is at least 1. The units are year (365 days), month (30 days), week, day, hour and minute. Else it uses seconds. The amount drops its fraction. A negative value with the amount 0 stays in the past: `Locale.relative(-500)` is "0 seconds ago" in `en`. - `PluralCategory` is a built-in enum with the categories of CLDR: `zero`, `one`, `two`, `few`, `many` and `other`. A locale uses only some of them: English has `one` and `other`. - `Locale.plural` rounds a `Float` to 3 digits after the separator first, as the decimal format of `Locale.number` and `Intl.PluralRules` do. So `Locale.plural(1.0001)` is `.one` in `en`, and the word fits the number that `Locale.number` shows. - The web uses `Intl` of the browser. Its data costs no bytes. - The native targets and the interpreter use the ICU of the system: `libicucore` on macOS, `icu.dll` on Windows 10 version 1903 and later, and `libicu` on Linux (ADR-0132). An app carries no locale data. An app that does not use `Locale` does not load ICU. - An app that uses `Locale` loads ICU when it starts. When the system has no ICU, the app stops at its start with an error that names the package of ICU (ADR-0132). - The browser and the system can have different versions of ICU and of CLDR, so a few results can differ between the targets (see [native differences](https://surface.solenovis.com/docs/targets.md)). --- ## 15. Errors ### 15.1 Compile errors Each compile error gives the file, the line, the column, a message and, where possible, a suggested fix. ### 15.2 Runtime errors The text of a runtime error is `runtime error: ` and a text of this table. The text is the same on each target. | Text | Cause | |---|---| | the result is outside the integer range | An `Int` or `Int64` result outside the range (section 4.2) | | the divisor is zero | An integer division or remainder by zero | | the index is outside the list | `xs[i]` or `xs.insert(i, x)` with an index outside the list | | the Float is NaN or outside the Int range | `f.toInt()` with NaN or a value outside the range | | two rows have the same key | Two equal keys in one `#for` block or `List` | | the effects started 100 batches in a row | 100 batches in a row from effects (section 10.5) | | the message of the call | A call to `panic(message)` | - A runtime error stops the current batch. The writes of the batch before the error stay in effect. - In a development build, the app shows the error and its source location on top of the UI. This covers an error in the mount of the app and in the `on error` handler. An `on error` handler takes an error first, and the error then shows only when the handler fails. `surf dev` does this on the web and in the dev host of the desktop (ADR-0122). - In a release build, the runtime calls the `on error` handler of the app. The default handler logs the error and ends the app. - A `#try` block catches an error of the mount or of an update of its body (section 9.5). This error does not reach `on error`, and the batch goes on. - `on error(message) { ... }` is valid only in the root component, `App.surf`. `message` is the text of the error, for example `"runtime error: the divisor is zero"`. The handler runs as a new batch after the batch with the error, and the app goes on. The parameter is optional: `on error { ... }`. - An error in the handler ends the app. On the web, an app that ended takes no more events. - On the native targets, a release build of an app with `on error` unwinds its panics, and a release build without it aborts. Unwinding makes the binary larger: about 100 KB on macOS. - The behavior is the same on all targets. --- ## 16. Grammar summary ```ebnf component = ( componentItem* "---" )? markup ; module = moduleItem* ; moduleItem = import | const | typeDecl | enumDecl | fnDecl | externDecl | traitDecl | implDecl ; componentItem = import | const | typeDecl | enumDecl | fnDecl | componentDecl | propDecl | stateDecl | derivedDecl | motionDecl | sequenceDecl | effectDecl | eventDecl | slotDecl | lifecycle ; import = "import" ( Name | "{" ident ( "," ident )* "}" ) "from" string ";" ; const = "pub"? "const" ident ( ":" type )? "=" expr ";" ; typeDecl = "pub"? "type" Name typeParams? "=" "{" field ( "," field )* ","? "}" ; field = ident ":" type ( "=" expr )? ; enumDecl = "pub"? "enum" Name typeParams? "{" case ( "," case )* ","? "}" ; case = ident ( "(" field ( "," field )* ")" )? ; fnDecl = "pub"? "async"? "fn" ident typeParams? "(" ( selfParam ( "," params )? | params? ) ")" ( "->" type )? ( block | "=" expr ";" ) ; params = param ( "," param )* ; param = ident ":" type ( "=" expr )? ; externDecl = "pub"? "extern" "module" Name "{" ( externFn | externEvent )* fallback? "}" ; externEvent = "event" ident ( "(" params? ")" )? ";" ; externFn = "async"? "fn" ident "(" params? ")" ( "->" type )? ";" ; fallback = "fallback" "{" fnDecl* "}" ; typeParams = "<" typeParam ( "," typeParam )* ">" ; typeParam = Name ( ":" bound )? ; bound = traitRef ( "+" traitRef )* ; traitRef = Name ( "<" type ">" )? ; traitDecl = "pub"? "trait" Name "{" traitItem* "}" ; traitItem = "type" Name ";" | fnSig ";" | fnDecl ; implDecl = "impl" typeParams? ( type "for" )? type "{" implItem* "}" ; implItem = "type" Name "=" type ";" | fnDecl ; fnSig = "fn" ident typeParams? "(" ( selfParam ( "," params )? | params? ) ")" ( "->" type )? ; selfParam = "mut"? "self" ; componentDecl = "component" Name typeParams ";" ; propDecl = "bindable"? "prop" ident ":" type ( "=" expr )? ";" ; stateDecl = "state" ident ( ":" type )? "=" expr ";" ; derivedDecl = "derived" ident ( ":" type )? "=" expr ";" ; motionDecl = "motion" ident ( "(" motionArg ( "," motionArg )* ")" )? "=" expr ";" ; motionArg = "ms" ":" integer | "ease" ":" string ; sequenceDecl = "sequence" ident "(" sequenceArg ( "," sequenceArg )* ")" "{" cueDecl* "}" ; sequenceArg = "play" ":" expr | "timeline" ":" string | "range" ":" string ; cueDecl = "cue" ident ( "(" cueArg ( "," cueArg )* ")" )? "=" string ";" ; cueArg = ( "ms" | "gap" | "stagger" ) ":" "-"? integer | ( "ease" | "from" ) ":" string | "with" ":" ident ; effectDecl = "effect" block ; eventDecl = "event" ident "(" params? ")" ";" ; slotDecl = "slot" ident "?"? ( "(" slotParam ( "," slotParam )* ")" )? ";" ; slotParam = "bindable"? ident ":" type ; lifecycle = "on" ( "mount" | "destroy" | "error" ) block | "on" "change" "(" args ")" block | "on" "key" "(" ident ( "," ident )? ")" block | "on" ident "." ident [ "(" [ ident { "," ident } ] ")" ] block ; type = ( Name ( "<" type ( "," type )* ">" )? | "(" ( type ( "," type )* )? ")" "->" type | "dyn" Name ( "<" dynArg ( "," dynArg )* ">" )? | "impl" bound ) "?"? ; dynArg = type | Name "=" type ; markup = node+ ; node = element | text | interp | block | tag ; element = "<" Name ( "<" type ( "," type )* ">" )? attr* ( "/>" | ">" node* "</" Name ">" ) ; attr = ident ( "=" ( string | "{" expr "}" ) )? | "{" ident "}" | ( "on" | "bind" ) ":" ident "=" "{" expr "}" | "style" "=" '"' styleItem* '"' | "{" "..." expr "}" ; styleItem = ( styleMark ":" )* ( utility | "@" ident | "(" styleItem* ")" | caseList ) ; styleMark = variant | "{" expr "}" ; caseList = "{" ( ident ":" styleItem* "," )* "}" ; utility = "-"? word ; (* layout spec, section 12 *) interp = "{" expr "}" ; block = ifBlock | forBlock | matchBlock | awaitBlock | tryBlock | fillBlock ; ifBlock = "#if" expr body ( "else" "if" expr body )* ( "else" body )? ; forBlock = "#for" ident ( "," ident )? "in" expr ( "key" expr )? body ( "empty" body )? ; matchBlock = "#match" expr "{" ( pattern "=>" ( body | element | block | tag ) )* "}" ; awaitBlock = "#await" expr body ( "then" ident? body )? ( "catch" ident? body )? ; tryBlock = "#try" body "catch" ident? body ; fillBlock = "#fill" ident ( "(" ident ( "," ident )* ")" )? body ; body = "{" node* "}" ; tag = "@slot" ident ( "(" args ")" )? | "@const" ident "=" expr ; (* to the end of the line *) ``` The grammar is a summary. It leaves out the statements, which end with `;` (section 3.5). The sections above are the normative text where the two disagree. `on error` in `lifecycle` is valid only in the root component of an app. Only a function in an impl block or in a trait can have `selfParam`. In a `dyn` type, each `type` of `dynArg` comes before each `Name "=" type`. --- ## 17. Complete examples ### 17.1 Counter ```surf prop label: String = "Clicks"; state count = 0; derived doubled = count * 2; --- <Column style="p-6 gap-3 items-center bg-$surface"> <Text style="text-20 font-600">{label}: {count}</Text> <Text>Doubled: {doubled}</Text> <Button on:press={count += 1}>Add one</Button> #if count > 10 { <Text style="text-$warning">That is a lot of clicks</Text> } </Column> ``` ### 17.2 Todo list Module files have no mutable data, so the next id is a `state` in the component. ```surf // model.surfs pub type Todo = { id: Int, title: String, done: Bool = false } pub fn remainingText(todos: List<Todo>) -> String { const n = todos.filter(t => !t.done).count(); return n == 1 ? "1 task left" : "{n} tasks left"; } ``` `TodoApp.surf`: ```surf import { Todo, remainingText } from "./model.surfs"; state todos: List<Todo> = []; state draft = ""; state nextId = 1; fn add() { if draft.trim().isEmpty() { return; } todos.push({ id: nextId, title: draft.trim() }); nextId += 1; draft = ""; } --- <Column style="gap-2"> <Row style="gap-2"> <Input style="grow" bind:value={draft} placeholder="New task" on:submit={add} /> <Button on:press={add} disabled={draft.trim().isEmpty()}>Add</Button> </Row> <List items={todos} key={todo => todo.id}> #fill row(todo) { <Toggle bind:checked={todo.done}>{todo.title}</Toggle> } </List> <Text style="text-$text-secondary">{remainingText(todos)}</Text> </Column> ``` --- ## 18. Decisions for version 1 On 2026-11-04, the project owner decided the open questions of version 1: - The formats of a locale: the module `Locale`, valid in each function, and the sort `sortWith` (sections 4.5 and 14, ADR-0132). The details are in sections 4.5, 7.3, 10.2, 10.4 and 14: no `Locale` in a module constant, one stable merge sort, and the lookup of RFC 4647. - No function types in extern modules. Events and `async fn` calls do the work of a callback (section 13.1, ADR-0133). - Traits and impl blocks in the form of Rust (section 7.8, ADR-0134). - Type parameters with bounds, generic components and trait objects. `==` on a `dyn` value is a compile error (section 7.9, ADR-0135). On 2026-11-05, the project owner answered the open questions of the language for version 1: - A module file has a name in camelCase, and a component file has a name in PascalCase. A wrong name and two paths that differ only in case are compile errors (section 2.2, ADR-0140). - Each keyword is valid as a field name, also the six keywords of traits (section 3.2, ADR-0134). - The web compares strings by UTF-16 code units, as JavaScript does. The native targets keep the order of scalar values (section 4.4, ADR-0113). - `ease` of `motion` takes only the name of a utility, for example `"ease-spring"` (section 7.5, ADR-0104). - A file calls a method of a trait only when it imports the trait (sections 7.2 and 7.8, ADR-0134). - The other rules of ADR-0113 stay (points 1, 2, 5 and 6), and so do points 1 to 8 of ADR-0115. These items come after version 1: - Function types in extern modules. They need a real case and a new ADR (ADR-0133). <!-- https://surface.solenovis.com/docs/components.md --> # Surface component reference, v0 - **Status:** draft - **Edition:** none yet. Version 0 has no stability guarantee. - **Related documents:** [Language spec v0](https://surface.solenovis.com/docs/language.md) (sections 9.2 and 9.6), [Surface Layout Spec v0](https://surface.solenovis.com/docs/layout.md), [GLOSSARY.md](https://git.solenovis.com/henning.liemant/surface/src/branch/main/GLOSSARY.md) - **Changes:** 2026-11-06: `focusable={true}` and a computed `value` work on the native targets too (1, 3.2, ADR-0131). 2026-11-06: `Video`, `Track` and the bar of `controls` of a `Video` with its full screen work on all targets (4.5b to 4.5d, ADR-0155 to ADR-0158). The keys move the position `Slider` of the bar by 5 seconds, and its value stays exact (4.5d). 2026-11-06: the names of the styles of ADR-0161 in the samples and the text (4.2, 4.11, 4.13, 4.14). 2026-11-06: the events `resize` and `contextmenu`, `load` and `error` of `Image`, `target` of `Link`, `id` as a hook for an extern module, and the utilities of `inset` in `exit` of a `Transition` (1, 3, 3.1, 4.5, 4.5a, 4.15, ADR-0160). 2026-11-06: `srcset` and `loading` of `Image` work on all targets (4.5, ADR-0159). `Audio` and `Source` work on all targets, without the bar of `controls` and the media controls of the system (4.5b, 4.5c, ADR-0155, ADR-0156, ADR-0158). 2026-11-06: `bg-url()` paints a background picture on all targets (4.5, ADR-0127). 2026-11-05: the selection of a `Text` on all targets, and the text of a field stays selectable with `select-none` (4.4, 4.7, ADR-0131). On the native targets, a press that gives the focus to a field with `selectOnFocus` keeps the selection of its text, as in a browser (4.7). 2026-11-05: the alpha of the icon of `mask` counts, and the animated pictures play on the native targets (1, 4.5, ADR-0131). 2026-11-05: `Video`, `Audio`, `Source`, `Track` and the bar of `controls` (4.5b to 4.5d, ADR-0155 to ADR-0158), and `srcset` and `loading` of `Image` (4.5, ADR-0159). The spec marks them "Not in version 0". The answers of 2026-11-05 give five bindings, the full screen and the media controls of the system (ADR-0156). 2026-11-05: scroll snapping and `sticky` in a `Scroll` and a `List` on all targets (4.2, 4.3, ADR-0128). 2026-11-05: `text-nowrap` on a `Span` (4.4a). 2026-11-05: a `Link` to an address outside the app opens the browser of the system on the native targets (4.5a, W8 of `docs/plan/windows.md`). 2026-11-05: `ease` of `Transition` takes only the name of an easing utility, as `ease` of `motion` (4.15, ADR-0104). 2026-11-05: Enter that ends the composition of an input method gives no `change` and no `submit` (3.2). All targets have `change` at Enter. 2026-11-05: `change` of an `Input` also comes at Enter after an edit, before `submit`, as in a browser (3, 3.2, 4.7, ADR-0113). The spec marks this "Not in version 0". 2026-11-04: `side` of `Overlay` and of `Tooltip` takes `start` and `end` on all targets (4.14, 5.12, ADR-0124). 2026-11-04: `select-none` on an `Input` or a `TextArea` turns off the selection of the field, and `surf lint` reports it (4.7, ADR-0131). The spec marks this "Not in version 0". 2026-11-04: one behavior on all targets for `focusable={true}` and a `value` that the program computes again (1, 3.2, ADR-0131). Text is not selectable without `select-text`, and the text of an `Input` and a `TextArea` stays selectable (4.4, 4.7, ADR-0131). An animated GIF, an animated WebP and an APNG play on all targets (4.5, ADR-0131). `side` of `Overlay` and of `Tooltip` takes `start` and `end` (4.14, 5.12, ADR-0124). The spec marks these features "Not in version 0". 2026-11-04: scroll snapping and `sticky` in a `Scroll` and a `List` (4.2, 4.3, ADR-0128), and the paths of `bg-url()` (4.5, ADR-0127). The spec marks these features "Not in version 0". 2026-11-03: a control with the focus that gets `hidden` loses the focus on each target, `rtl` of `Tabs`, and `ToastList` (3.2, 5.9, 5.14, 5.15, ADR-0122). 2026-11-03: a shape and a `Group` take only their paint, the opacity, a text color and the cursor (4.11, ADR-0120). 2026-11-03: a computed value and a case list on a `Span`, and `focusable={true}` on an element that is not a control (1, 4.4a, ADR-0118). 2026-11-03: an `Input` has one line, a text control keeps the text of the user, and `autofocus` of a `Select` (4.7, 4.13, ADR-0113). 2026-11-03: `invalid`, a key in an overlay is `typing`, a `hidden` element is out of the accessibility tree and the order of the Tab key, `level` of `Card`, `alt` and `tabStop` of `Menu`, and `restart` and `action` of `Toast` (1, 3, 4.3, 5, ADR-0110). 2026-11-03: `Span`, and a `Link` in a `Text` (4.4, 4.4a, 4.5a, ADR-0111). 2026-11-03: the components of `surf:ui` (5, ADR-0102), `focus` and `passive` for `Overlay`, and the focus and the keys on containers. 2026-11-03: `scroll`, `visible`, `pointerenter` and `pointerleave` (3, 3.1, 4.2, ADR-0106). 2026-10-30: `Transition` (4.15, ADR-0036). 2026-10-28: `style` takes utilities, `class` is removed, `testid` is new, and the style properties are no longer attributes. `Box` and the `variant` and `tone` of `Text` are removed (ADR-0020). 2026-10-26: first version (P1.8). 2026-10-26: `autofocus` for `Input` and `TextArea` (P1.14). 2026-10-27: `alt` for all components (P2.8). This document gives the attributes, the bindings and the events of each built-in component. The compiler checks each attribute and each event on a built-in component against this document. An attribute or an event that is not in this document is a compile error. The checker takes the same table from `crates/surf-check/src/builtins.rs`. --- ## 1. Attributes of all components Each built-in component takes these attributes in addition to the attributes in its own table: - `style`: the utilities of the component (layout spec, section 12), for example `style="p-2 gap-1"`. A computed value of a utility must be an `Int`, a `Float` or a `String`. - `id` (`String`): the name of the element for the relations of a screen reader (`controls`, `labelledBy`, `describedBy`, `activeDescendant`) and for `Focus.to`. It is also the hook of an extern module: on the web, the element has the attribute `id`, so the web file of the module finds it with `document.getElementById` (ADR-0160). A fixed `id` must be unique (`surf lint`, rule `duplicate-id`). - `role` (a role in quotes), `level`, `datetime`, `expanded`, `pressed`, `selected`, `checked`, `decorative`, `focusable`, `current`, `controls`, `activeDescendant`, `autocomplete`, `keyShortcuts`, `labelledBy`, `describedBy`, `invalid`, `hasPopup`, `orientation`, `valueNow`, `valueMin`, `valueMax`, `valueText`: the meaning of the component for a screen reader (ADR-0051, ADR-0102, ADR-0110). `controls`, `labelledBy`, `describedBy` and `activeDescendant` name another element by its `id`. `focusable={false}` leaves a control out of the order of the Tab key. `focusable={true}` on an element that is not a control makes it a stop of the Tab key on all targets, and it gets the keys (ADR-0131). A `tabpanel` gets the focus in the same way. A `Scroll` with the focus scrolls with the keys of a browser: the arrow keys, Page Up, Page Down, Home, End and the space bar. `invalid` (`Bool?`) marks a field with an error: `aria-invalid` on the web, and the invalid state of AccessKit on the native targets. On the native targets, the text of the element of `describedBy` is also the description of the control, because not each adapter of AccessKit reads the relation. - `alt` (`String`): the name of the component for a screen reader. It replaces the text of the component, for example `<Button alt="Delete {todo.title}">×</Button>`. A container with `alt` is a group with this name. On the web, `alt` is the `alt` of an `Image` and the `aria-label` of each other component. - `title` (`String`): a text for a pointer that rests on the component, and its description for a screen reader (ADR-0032). On the web, it is the `title` attribute. On the native targets, a small label shows after 0.5 seconds. - `lang` (`String`): the language of the text of the component and of its children, as a tag such as `ja` or `zh-Hans` (ADR-0066). A Han character has different forms in Chinese and Japanese. On the web, it is the `lang` attribute. On the native targets, it chooses the font and the forms of the shaper. The first 8 bytes of a tag count, as whole subtags. Without it, the platform uses the languages of the user. - `mask` (`Icon`): limits the component and its children to the shape of an icon (ADR-0065). The shape is the largest that fits into the box, in its middle. The alpha of the icon counts, as in `mask-image` of CSS: a shape with an opacity of 0.4 shows 40 % of the element (ADR-0131). The hit area stays the box. The shapes of a `Drawing` do not take it. - `testid`: a name for tests, in quotes, without values. It has no effect on the app. On the web, it is the attribute `data-testid`. The test driver of the desktop runtime finds elements by it. A style property is not an attribute. `gap={12}` is a compile error, and its help text gives the utility `style="gap-3"`. --- ## 2. Props A prop of a built-in component has one of these types: | Type | Values | |---|---| | `String` | Any text. A string attribute can insert values: `placeholder="New {kind}"`. | | `Bool` | `true` or `false`. The attribute name alone means `true`: `disabled`. | | `Float` | A `Float` value. An `Int` is a compile error, as everywhere in the language: write `min={0.0}`. | | `List<Float>` | The points of a `Polyline` or a `Polygon`: x and y, one after the other. | | `Icon` | An icon: a constant from an `.svg` import, or a value of `Icon.parse` (section 4.12). | A prop without a value has the default that the table gives. A bindable prop can take `bind:` (language spec, section 9.2). Each component has at most one bindable prop, except `Video` and `Audio`, which have five (section 4.5b, ADR-0156). --- ## 3. Events An event handler is a function name, statements, or a lambda whose body is statements (language spec, section 9.2). Some events have a value. A handler can take the value as a parameter or ignore it: ```surf <Input on:keydown={key => { if key == "Escape" { editing = false; } }} /> <Input on:submit={add} /> <Button on:press={count += 1}>Add</Button> ``` | Event | Value | When | |---|---|---| | `press` | none | The user activates a `Button`: a click, a tap, or Enter or Space while the button has the focus. | | `doubleclick` | none | Two clicks in a short time on the component. All built-in components have it, shapes too. A `Button` also gets two `press` events before the `doubleclick`. | | `focus` | none | The control gets the keyboard focus. | | `blur` | none | The control loses the keyboard focus. | | `key` | `press: KeyPress` | As `keydown`, with `key`, `shift`, `ctrl`, `alt` and `meta` (ADR-0052). `on key(press, typing)` in a component gets each key press of the window (ADR-0075). `typing` is true while the focus is in an `Input`, a `TextArea`, a `Select` or an `Overlay`, so that a key of a menu or a dialog does not start a shortcut of the app (ADR-0110). | | `keydown` | `key: String` | A key goes down while the control has the focus. The value is the key name of the W3C UI Events specification, for example `"Enter"`, `"Escape"`, `"ArrowUp"` or `"a"`. | | `input` | the new value | Each edit of the value: each change of the text, or each step of a slider while the user drags it. | | `change` | the new value | The user commits a value. For `Input`: Enter after an edit, or the loss of the focus after an edit. For `TextArea`: the loss of the focus after an edit. For `Toggle`: each change. For `Slider`: the user releases it. | | `submit` | none | Enter in an `Input`. After an edit, `change` comes first. Enter does not send `submit` while an input method composes text. | | `scroll` | `position: ScrollPosition` | A `Scroll` or a `List` scrolled (ADR-0106). The record has `x` and `y`, how far the content moved, and `maxX` and `maxY`, the largest values, in logical px. The event does not go to a parent. | | `visible` | `visible: Bool` | A part of the element enters the window (`true`), or its last part leaves the window (`false`). A clip of an ancestor counts. Another element on top does not count. All built-in components with a box have it (ADR-0106). An element that starts outside the window sends nothing. | | `resize` | `size: BoxSize` | The border box of the element has a new size (ADR-0160). The built-in record `BoxSize` has `width` and `height` in logical px, before the transforms, as `ResizeObserver` with the border box. The size before the first event is 0 × 0, so an element with no area sends nothing at the start. A `hidden` element has the size 0 × 0. All built-in components with a box have it. | ### 3.1 Pointer events Every built-in component has the pointer events (P3.2). Their value is the built-in record `Pointer`: ```surf type Pointer = { x: Float, // the position in the border box of the component, in logical px y: Float, windowX: Float, // the position in the window, in px of the window: for a drag of a component that moves windowY: Float, dx: Float, // the distance of a wheel in logical px: positive to the right and down dy: Float, button: Int, // 0 main, 1 middle, 2 secondary; -1 for a move and a wheel shift: Bool, alt: Bool, ctrl: Bool, meta: Bool, // Command on macOS, the Windows key on Windows } ``` | Event | When | |---|---| | `pointerdown` | A button goes down on the component. The component captures the pointer: it gets the `pointermove` and the `pointerup` events until that button goes up, also when the pointer leaves its box. The press starts no text selection, no native drag and no context menu of the browser. | | `pointermove` | The pointer moves: to the component with the capture, else to the component under the pointer. | | `pointerup` | The button goes up: to the component with the capture, which then ends. A drag always ends with one `pointerup`: also after a lost capture, a context menu or the loss of the window focus. | | `wheel` | A wheel or a scroll gesture over the component. The component takes it: no `Scroll`, `List` or page under it scrolls. | | `pointerenter` | The pointer enters the component or one of its descendants (ADR-0106). The event does not go to a parent, and a parent gets its own event. | | `pointerleave` | The pointer leaves the component and its descendants. A component that left the tree gets no event. | | `contextmenu` | The user asks for a context menu (ADR-0160): a right click, at the press on macOS and Linux and at the release on Windows, as the system does. On macOS, Control and a click of the main button also ask for it, and that click is no press. The menu key and Shift with F10 ask for the menu of the component with the focus on each system: `x` and `y` are then the middle of the component, and `button` is -1. The menu of the browser does not open for a component that listens to the event. | - The nearest component from the target up that listens to the event gets it, as for the other events. `focus`, `blur`, `keydown` and `key` work in the same way, so a container gets them from a control in it (ADR-0102). `pointerenter` and `pointerleave` go only to their component. - A drag of a knob: `on:pointerdown` keeps the start, `on:pointermove` changes the value by `p.y`, and `p.shift` makes the steps smaller. - On the web, a wheel in lines or pages becomes pixels, and a cancel of the browser (`pointercancel`) is a `pointerup`. - `press` and `doubleclick` still come after a click, also on a component with pointer events. - A `Drawing` and its shapes get `x` and `y` in the coordinates of the view of the drawing (section 4.11), not in the border box. ### 3.2 Rules of all events These rules apply to all events: - An element with `hidden` and its children get no pointer events, are out of the order of the Tab key, and are not in the accessibility tree, as in a browser (ADR-0110). A control with the focus that gets `hidden` loses the focus after the next style pass, as in a browser. It gets `change` after an edit, then `blur`, and no control has the focus. The Tab key goes on from its place (ADR-0122). - On the native targets, a control that gets the focus from a key or from `Focus.to` scrolls into the area of each `Scroll` and `List` around it, as in a browser. It has the focus ring of the web target: 2 px of `$focus`, 1 px outside its box (ADR-0110). - A component gets only the events in its table, and the pointer events. - A control with `disabled={true}` gets no events. - A binding writes the new value before the `on:` handlers of the same event run. The `bind:value` of `Input`, `TextArea` and `Slider` writes on `input`. The `bind:checked` of `Toggle` writes on `change`. The bindings of `Video` and `Audio` write on their events (section 4.5b). - A change from the program does not send events. For example, a write to the bound place of an `Input` does not send `input`. - `change` of a text control comes once for one edit, on all targets, as in a browser (ADR-0113). Enter in an `Input` after an edit gives `change` and then `submit`. The loss of the focus after that gives no second `change`. Enter with no edit gives only `submit`. Enter in a `TextArea` makes a new line, and it gives `input`, not `change`. - Enter that ends the composition of an input method gives no `change` and no `submit`, as in a browser. The text of the composition stays, and a later Enter gives `change` and `submit`. - A computed `value` that gives the same result again writes nothing (ADR-0131). So a text control keeps the text of the user when an unrelated source of the value changes. - The runtime does not send an event to a component while a batch changes the tree. For example, the removal of a control that has the focus does not send `blur`. --- ## 4. Components ### 4.1 Containers: `Row`, `Column`, `Stack`, `Grid` | Attribute | Type | Default | |---|---|---| | (none) | | | Events: `doubleclick`, and `focus`, `blur`, `keydown` and `key` of the controls in the container (ADR-0102). ### 4.2 `Scroll` No attributes. The event `scroll` gives the position (section 3). - A `Scroll` takes the utilities of scroll snapping: `snap-x`, `snap-y` or `snap-both`, with `snap-mandatory` or `snap-proximity`. A child takes `snap-start`, `snap-center`, `snap-end` and `snap-always` (layout spec, section 5.12, ADR-0128). - A `sticky` element stays in the visible area of its nearest `Scroll`, `List` or element with `overflow-hidden` (layout spec, section 5.7, ADR-0128). - A screen reader can scroll each element into view, as in a browser. Each `Scroll` and `List` around the element then scrolls, also for a disabled control (ADR-0142). ### 4.3 `List` `List` takes `items` and `key`, and the fill `#fill row(item) { ... }`. Language spec, section 7.7, gives the rules of the `row` slot, and section 9.5 gives the rules of keys. `List` has no events of its own. | Attribute | Type | Default | |---|---|---| | `rowHeight` | `Float` | none | A `List` is a scroll area on its main axis, as `Scroll` is. Only the rows near its visible area exist: the rows that touch the area, and one area height above and below it (ADR-0027). - The runtime measures each row that it builds. A row that was never built has the average height of the first measured rows. With `rowHeight`, each row has that height, and the runtime does not measure. The rows must then have this height. - A `List` needs a bounded height to build fewer rows, for example `grow` in a `Column` with a height, or `h-100`. A container between the `List` and that `Column` needs `min-h-0` (layout spec, section 3). A `List` without a bounded height grows to the height of all rows, so all rows exist. - A row that leaves the window is destroyed, with its state, its effects and its tasks. A row that comes back is a new row. - The rows do not shrink: a row has `shrink` 0 unless its style says else. - The `List` has the role "list". Each row is in a container with the role "list item", the number of items and the position of its item. A row keeps its own role. - A `#for` block builds all rows. Use `List` for a long list. - A `List` takes the utilities of scroll snapping and holds `sticky` elements, as a `Scroll` does (section 4.2). Only the rows near the visible area exist, so a snap area of a row that does not exist is no snap point. - A `List` in a window builds the rows of a tall window before its first layout pass, not all rows. A `List` in a `hidden` element keeps its rows until it shows again (ADR-0110). ### 4.4 `Text` No attributes of its own. Events: `doubleclick`. `Text` accepts text (language spec, section 9.3). The web element is `<span>`. Utilities give the size, the weight and the color: `style="text-20 font-600 text-$error"`. A `Text` takes text, values, blocks, `Span` and `Link` (ADR-0111). Its parts are one paragraph: ```surf <Text style="text-16"> Run <Span style="font-mono bg-$surface">surf dev</Span>, then read the <Link href="/docs">documents</Link>. </Text> ``` - A line can break inside a part. The line height comes from the largest part on the line. - A screen reader gets the whole text of the `Text`, and a node for each `Link` in it. - A `Text` is not selectable, unless it or an ancestor has `select-text` (layout spec, section 5.10, ADR-0131). With `select-text`, a drag selects text, also across the parts of the `Text`, a double click selects a word, and the copy key copies it. ### 4.4a `Span` A part of a `Text` with a style of its own (ADR-0111). The web element is `<span>`. - A `Span` is valid only in a `Text` or in another `Span`. - Attributes: `title`, `lang` and `id`, with `style` and `testid`. A `Span` has no events. - Its utilities are the utilities of text: color (`text-$accent`), font weight (`font-600`) and font family (`font-mono`). Size (`text-14`, `text-sm`), italic, underline, line-through, background color (`bg-$surface`) and `text-nowrap` are utilities of text too. With `text-nowrap`, a line breaks only before or after the `Span`, not inside it, so a short code sample such as `a-b` stays on one line. A state (`hover:`), a condition (`dark:`, `{value}:`), a case list and a computed value (`text-{color}`, `text-{size}`) work. Each other utility is a compile error. - Blocks and values work in a `Span`: `#for t in tokens { <Span style="...">{t.text}</Span> }` writes a code sample from a list of tokens. ### 4.5 `Image` | Attribute | Type | Default | |---|---|---| | `src` | `String` | none | | `srcset` | `String`: a file for each pixel density | none | | `loading` | `String`: `eager` or `lazy` | `eager` | Events: `load` (no value) and `error` (`error: MediaError`) (ADR-0160). `alt` (section 1) is the text of the image for a screen reader. - `load` comes when the file arrived and the picture has its size. `error` comes when the file did not load. Each file of an element sends one event, so a new `src` sends again. A lazy image sends after its load starts. - The case of `MediaError` (section 4.5b) is `network` for a file that did not arrive: a file that is not in the build, an HTTP error, or no connection. It is `decode` for data with the signature of a format that each target shows (PNG, JPEG, GIF, WebP and SVG), and `format` for other data. On the web, the runtime reads the file again after an error to find the case. - `src` is a path in the build or a URL. A file in `src/assets/` of a project goes into each build, and its path is `assets/logo.png` (ADR-0033). The web build copies the folder next to the page, and the desktop build next to the binary, or into `Contents/Resources` of a macOS app bundle. - An `Image` has the size of its picture, one pixel of the file for each logical px. With a width or a height from the styles, the other side comes from the ratio of the picture. With both, the picture stretches to the content box, unless `object-contain`, `object-cover` or `object-none` says otherwise. - The native targets show PNG, JPEG, WebP, GIF and SVG. An animated GIF, an animated WebP and an animated PNG (APNG) play on all targets. They play with the delays of their frames and with their count of loops. A delay of 10 ms or less is 100 ms, as in the browsers (ADR-0131). Windows shows the first frame of an animated WebP, because its WebP codec gives no frames (`docs/native-differences.md`). A JPEG comes upright, as its EXIF orientation says. A file that does not load paints nothing there. An image from an `http` or `https` address loads in the background and has no size until it arrives (ADR-0037). An SVG file has the size of its view and the shapes of an `Icon`. A path in quotes under `assets/` must name a file, or the compiler gives an error. - `bg-url()` takes the same paths and URLs for the background of each element, and the same formats and rules of the load. A literal path under `assets/` must name a file (layout spec, section 5.8, ADR-0127). A background image is decoration with no alt text. Use an `Image` with `alt` for a picture with a meaning. **A file for each pixel density** (ADR-0159). ```surf <Image src="assets/logo.png" srcset="assets/logo.png 1x, assets/logo@2x.png 2x" alt="Surface" /> ``` - `srcset` has the form of HTML with densities only: candidates with a comma between them. A candidate is a path or a URL, a space and a density with `x`, for example `2x` or `1.5x`. If `srcset` has no `1x` candidate, `src` is the `1x` candidate. - A width with `w` and the attribute `sizes` are a compile error. - The size of a file in logical px is its size in pixels, divided by its density. So a file of 200 × 100 pixels with `2x` has 100 × 50 logical px, and each candidate gives the same layout. - A native target chooses the smallest density that is equal to or larger than the scale of the window. The scale is the count of device pixels for each logical px. If no candidate is that large, the largest density wins. A move to a screen with another scale, or a new scale of the UI, chooses again. - On the web, the browser chooses the candidate, as HTML allows. It can take a smaller file, for example to save data (see [native differences](https://surface.solenovis.com/docs/targets.md)). - In a literal `srcset`, each path under `assets/` must name a file. The syntax must be valid, and two candidates must not have one density. A computed `srcset` gets these checks at run time, and the runtime skips a candidate with an error. **Lazy images** (ADR-0159). - With `loading="lazy"`, the image starts its load when its box comes near the visible area of its nearest `Scroll`, `List` or window. With `eager`, it loads at once. - On the native targets, "near" is one height and one width of that area, as the rows that a `List` builds (section 4.3). On the web, the browser chooses the distance, because HTML gives a page no way to set it. - A `hidden` image with `lazy` does not load. A `hidden` image with `eager` loads, as in HTML. - Before its load, an image without a size from its styles has no size. Give it a width and a ratio, for example `w-full aspect-16/9`, so that the layout does not move. ### 4.5a `Link` A link (ADR-0051): an `<a>` on the web target, and a `Button` with the role of a link on the native targets. Props: `href` (`String`), `target` (`String`), `alt`. `href` takes a relative URL, `http`, `https`, `mailto` and `tel`. For another scheme, the web target writes no attribute (ADR-0109). `target="_blank"` opens the address outside the app (ADR-0160): in a new tab of the browser on the web, and in the browser of the system on the native targets. `target` takes only `"_blank"`. A literal address of the app with `target`, for example `href="/docs"`, is a compile error, because a native app has one window. A computed address of the app goes to the router of SurfWave on the native targets, as without `target`. Events: `press`, `doubleclick`, `focus`, `blur`, `keydown`. `Link` accepts text. With a handler for `press`, a press stays in the app. A press with a modifier key opens the address as the browser does. Without a handler, the browser follows the link. On a native target, a press without a handler opens an address with the scheme `http`, `https`, `mailto` or `tel` in the browser of the system. The system opens `mailto` and `tel` with the app for them. A relative URL goes to the router of SurfWave, and a link with another scheme does nothing. A `Link` in a `Text` is a part of the text (ADR-0111). It keeps the focus, the keys, the hover state, the router and the check of its URL. It takes the utilities of a `Span` and `cursor-`. A `Link` in a `Link` is a compile error. ### 4.5b `Video` and `Audio` A video and a sound (ADR-0155, ADR-0156). The web elements are `<video>` and `<audio>`. Not in version 0: a video that `autoplay` started does not pause while no part of it is in the window. ```surf <Video poster="assets/intro.jpg" controls alt="Introduction" style="w-full rounded-8" bind:paused={paused} bind:currentTime={time}> <Source src="assets/intro.mp4" type="video/mp4; codecs=\"avc1.42E01E, mp4a.40.2\"" /> <Source src="assets/intro.webm" type="video/webm; codecs=\"vp9, opus\"" /> <Track src="assets/intro.en.vtt" kind="captions" srclang="en" label="English" default /> </Video> <Audio src="assets/chime.mp3" controls alt="Chime" /> ``` | Attribute | Type | Default | |---|---|---| | `src` | `String` | none | | `poster` | `String` (only `Video`) | none | | `autoplay` | `Bool` | `false` | | `loop` | `Bool` | `false` | | `controls` | `Bool` | `false` | | `preload` | `String`: `none`, `metadata` or `auto` | `metadata` | | `paused` | `Bool`, bindable | `true` | | `currentTime` | `Float`, bindable: the time in seconds | `0.0` | | `volume` | `Float`, bindable: from 0 to 1 | `1.0` | | `muted` | `Bool`, bindable | `false` | | `playbackRate` | `Float`, bindable: from 0.25 to 4 | `1.0` | | `metadata` | `MediaMetadata` | the title from `alt`, the picture from `poster` | | `labels` | `MediaLabels` | English texts | Events: `play`, `pause`, `ended`, `timeupdate` (`time: Float`), `loadedmetadata` (`info: MediaInfo`), `volumechange` (`volume: MediaVolume`), `ratechange` (`rate: Float`), `nexttrack`, `previoustrack` and `error` (`error: MediaError`). With `controls`, also `focus`, `blur` and `keydown`. ```surf type MediaInfo = { duration: Float, width: Float, height: Float } type MediaVolume = { volume: Float, muted: Bool } type MediaMetadata = { title: String = "", artist: String = "", album: String = "", artwork: String = "" } enum MediaError { network, decode, format } ``` **Sources.** - `src` is a path in the build or a URL with `http` or `https`, as the `src` of an `Image` (section 4.5). Version 1 plays a file. HLS, DASH and live streams are not in version 1. - A list of `Source` elements gives more files (section 4.5c). `src` and a `Source` child on one element are a compile error. - The element tries the sources in their order, as HTML does. It skips a source whose `type` the system cannot play, and it tries the next source after an error. After the last source, it sends `error` with `format`. - Each target plays the formats of its media system, and ADR-0155 has the table. The media systems are AVFoundation on macOS, Media Foundation on Windows, GStreamer on Linux and the browser on the web. For a video, give an MP4 with H.264 and AAC first, then a WebM with VP9 and Opus. Each target then plays one of them. For a sound, an MP3 plays on each target. - `surf lint` gives the finding `media-format` when the sources do not cover each target of the table. For a literal path under `assets/`, the compiler reads the format from the file. - A target with no media system sends `error` with `format`, for example Linux without GStreamer. The poster stays. - The `.deb` of `surf package` for an app with media recommends `gstreamer1.0-libav`, so that Ubuntu plays H.264 and AAC (ADR-0155). **Events.** | Event | When | |---|---| | `play` | The element starts to play, also after a pause. | | `pause` | The element pauses: a press, the media controls of the system, or the end without `loop`. | | `ended` | The play reached the end without `loop`. | | `timeupdate` | Each 250 ms while the element plays, and after a seek of the user, a pause and the end. The value is the time in seconds. The rate is the same on all targets. | | `loadedmetadata` | The media system read the duration and the size of the file. `width` and `height` are in logical px, and they are 0 for a file without video. | | `volumechange` | The user changed the volume or `muted`. | | `ratechange` | The user changed the rate through the media controls of the system. | | `nexttrack`, `previoustrack` | The user pressed "next" or "previous" in the media controls of the system. | | `error` | No source plays (`format`), the network failed (`network`), or the file has an error (`decode`). | `Image` gets `on:load` and `on:error` with the same `MediaError` in P6.22. **Bindings** (ADR-0156). - A `Video` and an `Audio` have five bindable props: `paused`, `currentTime`, `volume`, `muted` and `playbackRate`. Each other built-in component has at most one (section 2). The rules of `bind:` of language spec 7.4 and 9.2 apply. - The element writes the bound place at its events: `paused` at `play` and `pause`, `currentTime` at each `timeupdate`, `volume` and `muted` at `volumechange`, and `playbackRate` at `ratechange`. The write comes before the `on:` handlers of the event (section 3.2). - A write of the program changes the media. `paused = false` plays, under the rule of sound below, and `paused = true` pauses. A new `currentTime` seeks to that exact time. A new `volume`, `muted` or `playbackRate` applies at once. - A change of the program and a change of the media do not make a loop. The element keeps the last value of each prop: the value that it wrote, or the value that it took from the program. A new value that is equal to the kept value does nothing. Only a different value changes the media. So the write at each `timeupdate` never comes back as a seek. - A change of the program sends no event (section 3.2). The next events come from the play. - A value outside its range applies as the nearest value of the range. The range of `currentTime` is from 0 to the duration, of `volume` from 0 to 1, and of `playbackRate` from 0.25 to 4. The pitch of the sound stays at each rate. - Before the metadata arrives, the element keeps a new `currentTime`, and it seeks when the metadata arrives. - If the rule of sound refuses a play, the element stays paused. It writes `true` to the bound `paused` again at the end of the batch, with no event. - Without `bind:`, a prop such as `muted={quiet}` sets the element. A change of the user stays until the program gives a new value, as the text of an `Input` (section 4.7). - The duration and the size are read-only. They come from `loadedmetadata`. **Size and paint.** - A `Video` has the size of its video, one pixel of the file for each logical px, as an `Image`. Before the metadata arrives, it has the size of its poster, or else 300 × 150 logical px. A width or a height from the styles keeps the ratio. - The picture fits into the box with `object-contain` by default. `object-cover`, `object-fill` and `object-none` work, as on an `Image`. The free area shows the background of the element. - A frame paints as each other element: with the clip, the radius, the opacity, the transform and the mask of its element. Another element can paint over it. - The poster shows until the first frame of the play, or until a seek. Without a poster, the first frame shows when `preload` loaded it. - An HDR video shows in SDR on the native targets (see [native differences](https://surface.solenovis.com/docs/targets.md)). - An `Audio` without `controls` has no box and is not in the accessibility tree, as in HTML. With `controls`, its box is the bar of the controls: 300 logical px wide by default. **Autoplay, sound and motion** (ADR-0158). - `autoplay` starts the element when its media is ready. - With the setting "reduce motion" on, `autoplay` does not start a `Video`. The poster or the first frame shows. When the setting comes on, a video that `autoplay` started pauses. A video that the user started plays on. - A video that `autoplay` started pauses while no part of it is in the window, and it plays again when a part comes back. The rule of the event `visible` (section 3) applies. - One rule of sound applies on all targets, the rule of the strictest browser: - An element plays with sound only when its play starts in the batch of an event of the user. The events are a press, a key and a pointer button. - `autoplay` without `muted` is a compile error for a literal. With a computed `muted`, `autoplay` starts only while `muted` is true. - A play with sound at another time does not start, and the element stays paused. - A muted element that plays gets its sound only in the batch of an event of the user. At another time, it pauses. - `surf lint` gives the finding `autoplay-stop` for a `Video` with `autoplay` and `loop`, without `controls` and without `bind:paused`. **The media controls of the system** (ADR-0156). - The media keys of the keyboard and the media controls of the system control one element. It is the element that last started to play with sound. The controls are "Now Playing" on macOS, the media controls of Windows, MPRIS on Linux, and the Media Session API on the web. - "With sound" means that `muted` is false, `volume` is above 0 and the file has a sound track. A file shorter than 5 seconds does not connect, so a short sound of the UI does not take the media keys. - The element stays connected while it plays and while it pauses, until another element connects or it leaves the tree. - The system can play, pause and seek. A change through it is a change of the user: it writes the bound places and sends the events. - "Next" and "previous" send `nexttrack` and `previoustrack`. The system shows these buttons only when the element has a handler for them. - The system shows the title, the artist, the album and the picture of `metadata`, and the duration, the position and the rate. `artwork` is a path or a URL, as `src` of an `Image`. Without `metadata`, the title is `alt` and the picture is `poster`. - On Linux, the name of the app on the session bus is `org.mpris.MediaPlayer2.` and the name of its binary. When another process has this name, the app adds `.instance` and its process id, as MPRIS suggests. **Other rules.** - An element that leaves the tree stops. `hidden` does not pause, as in HTML. - The play goes on while the window is behind another window, or in the Dock or the taskbar. - `alt` is the name for a screen reader. The role is "video" or "audio". `surf lint` gives the finding `no-name` for a `Video` without `alt` or `decorative`. ### 4.5c `Source` and `Track` `Source` and `Track` are valid only in a `Video` or an `Audio`. A `Video` and an `Audio` take only `Source`, `Track` and blocks as children. | Element | Attribute | Type | Default | |---|---|---|---| | `Source` | `src` | `String` | required | | `Source` | `type` | `String`: a MIME type with codecs, as in HTML | none | | `Track` | `src` | `String`: a WebVTT file | required | | `Track` | `kind` | `String`: `captions` or `subtitles` | `subtitles` | | `Track` | `srclang` | `String`: a language tag | none | | `Track` | `label` | `String` | the value of `srclang` | | `Track` | `default` | `Bool` | `false` | **`Source`** (ADR-0156). - Without `type`, the target must load the file to know its format. Give `type` with the codecs, so that a target skips a file that it cannot play. **`Track`** (ADR-0157). - `captions` give the speech and the sounds, for a person who cannot hear. `subtitles` translate the speech. The kinds `descriptions`, `chapters` and `metadata` are a compile error in version 1. - The file is WebVTT. A literal path under `assets/` must name a file that parses as WebVTT. An error gives the line of the file. - Version 1 has these parts of WebVTT: - The timings of each text cue. - The settings `line`, `position`, `size` and `align`. - The tags `<b>`, `<i>`, `<u>`, `<v>`, `<lang>` and `<c>`. A `<c>` gives no style. - The character references. - The other parts of WebVTT are not in version 1. In a literal file, regions, vertical text, `STYLE` blocks, `<ruby>` and the timestamps in a text cue are a compile error. In a file from a URL, they show as plain text on the native targets. - The text cues have the default look of WebVTT: white text on black with 80 % opacity, and a sans-serif font. The font size is 5 % of the height of the video. They are at the bottom of the video, and they move up while the bar of the controls shows. - On the web, the browser draws the text cues. It applies the caption style of the system where it reads it, or its own caption settings. While the bar shows, the runtime sets the `line` of each text cue with an automatic line, so that the text cue ends above the bar. - On the native targets, the runtime draws the text cues with the caption style of the system: MediaAccessibility on macOS, and `ClosedCaptionProperties` on Windows. Linux has no such setting, so the text cues have the default look of WebVTT. A change of the setting applies to the next frame. - At the start, the first track with `default` shows. Without `default`, no track shows. The button of the captions chooses a track or "Off". - A screen reader does not read the text cues aloud, as in each browser. - `surf lint` gives the finding `no-captions` for a `Video` without `muted` and without a `Track` of the kind `captions`. - Captions inside the media file, for example CEA-608 in an MP4, do not show in version 1. ### 4.5d The controls of `Video` and `Audio` With `controls`, an element shows the bar of the runtime on all targets, also on the web (ADR-0156). The browser does not show its own controls. Not in version 0: in the full screen, the pointer does not hide. The Tab key also reaches the elements outside the video. The X11 fallback of Linux keeps its window. The button of the captions counts only the `Track` elements outside a block. The bar is a component of the compiler. Only an app with `controls` has its code. On an `Audio`, the bar is the box of the element: a group with the name of `alt`. A `testid` of the element names the bar. - The bar has these parts: - A button to play and to pause. - The time and the duration as `m:ss`, or as `h:mm:ss` from one hour. - A `Slider` for the position. - A button to mute, and a `Slider` for the volume. - A button for the captions, when the element has a `Track`. With more than one track, the button opens a `Menu` of the tracks and "Off". - On a `Video`, a button for the full screen. - On a `Video`, the bar is at the bottom of the video, with white icons on a layer of 60 % black. On an `Audio`, the bar uses the theme tokens. - The bar of a `Video` shows in three cases: - While the video does not play. - For 3 seconds after each move of the pointer over the video. - While a control of the bar has the focus. - The bar takes 150 ms to fade in or out. With the setting "reduce motion", it shows and hides at once. - A change through the bar is a change of the user. It writes the bound places and sends the events. - `labels` gives the texts of the bar for a screen reader, with English defaults: ```surf type MediaLabels = { play: String = "Play", pause: String = "Pause", position: String = "Position", mute: String = "Mute", unmute: String = "Unmute", volume: String = "Volume", captions: String = "Captions", captionsOff: String = "Off", fullScreen: String = "Full screen", exitFullScreen: String = "Exit full screen", } ``` **The full screen.** - A press on the button of the full screen shows the video, its text cues and its bar on the whole screen. - On the web, the runtime calls `requestFullscreen()` on its element that holds the video, the text cues and the bar. - On a native target, the window goes into the full screen mode of the system. The video then covers the window above each other element, on black, with `object-contain`. - Escape, the button and the way of the system end the full screen. A window that was in the full screen mode before stays in it. - In the full screen, only the video and its bar are in the accessibility tree. The Tab key reaches only the video and the controls of its bar, as in a modal `Overlay`. The focus stays on the button. The pointer hides with the bar. - Only a press of the user starts the full screen. The program has no function for it in version 1. **Keys.** The keys follow the WAI-ARIA Authoring Practices. - Each button and each slider of the bar is a stop of the Tab key. They have the keys of a `Button` (section 4.6) and of a `Slider` (section 4.10). - The keys move the slider of the position by 5 seconds, and its value stays exact, as the keyboard step of a WAI-ARIA slider. Its value for a screen reader is a text, for example "1:05 of 3:20". The slider of the volume has a step of 0.1. - A `Video` with `controls` is also a stop of the Tab key. While it has the focus, Space plays or pauses, and the left and right arrow keys move the position by 5 seconds. - The bar has no letter keys. ### 4.6 `Button` | Attribute | Type | Default | |---|---|---| | `disabled` | `Bool` | `false` | Events: `press`, `doubleclick`, `focus`, `blur`, `keydown`. `Button` accepts text. ### 4.7 `Input` | Attribute | Type | Default | |---|---|---| | `value` | `String`, bindable | `""` | | `placeholder` | `String` | `""` | | `disabled` | `Bool` | `false` | | `autofocus` | `Bool` | `false` | | `selectOnFocus` | `Bool` | `false` | Events: `input` (`text: String`), `change` (`text: String`), `submit`, `focus`, `blur`, `keydown`. With `autofocus`, the control gets the keyboard focus at the end of the batch that puts it into the tree. When more than one control has it, the last one gets the focus. With `selectOnFocus`, the control selects its text each time it gets the focus. A press that gives the focus keeps this selection, unless a drag selects a part of the text, as in a browser. Only a new `value` from the program replaces the text. A control without a binding keeps the text that the user typed, also after the loss of the focus (ADR-0113). An `Input` has one line. It removes each line break from its text: from a `value` of the program and from a paste. The state keeps its line breaks until the next `input` event (ADR-0113). Enter after an edit sends `change` and then `submit`. The loss of the focus after an edit sends `change` too, but only once for one edit (section 3.2, ADR-0113). The text of an `Input` and of a `TextArea` stays selectable on all targets, also without `select-text` and with `select-none` (layout spec, section 5.10, ADR-0131). CSS UI 4 gives an editable element the used value `contain`, so `select-none` has no effect on a field, and `surf lint` gives the finding `field-select` for it. `selectOnFocus` selects the text, also with `select-none`. ### 4.8 `TextArea` The attributes of `Input`. Events: `input` (`text: String`), `change` (`text: String`), `focus`, `blur`, `keydown`. `TextArea` has no `submit`, because Enter starts a new line. ### 4.9 `Toggle` | Attribute | Type | Default | |---|---|---| | `checked` | `Bool`, bindable | `false` | | `disabled` | `Bool` | `false` | Events: `change` (`checked: Bool`), `focus`, `blur`, `keydown`. `Toggle` accepts text: the label. ### 4.10 `Slider` | Attribute | Type | Default | |---|---|---| | `value` | `Float`, bindable | `50.0` | | `min` | `Float` | `0.0` | | `max` | `Float` | `100.0` | | `step` | `Float` | `1.0` | | `disabled` | `Bool` | `false` | Events: `input` (`value: Float`), `change` (`value: Float`), `focus`, `blur`, `keydown`. The slider rounds its value to a step from `min` and keeps it between `min` and `max`. The order of the attributes has no effect. ### 4.11 `Drawing` and the shapes A `Drawing` is a box with its own coordinates, the view (ADR-0029). The shapes are valid only in a `Drawing`, or in a `Group` in a `Drawing`, and a `Drawing` takes only shapes and `Group`. Text is not a shape: put a `Text` above the drawing in a `Stack`. ```surf <Drawing viewWidth={200.0} viewHeight={100.0} alt="Transfer curve" style="w-full"> <Polyline points={curve} style="stroke-$text stroke-2 stroke-join-round" /> <Circle cx={handle} cy={20.0} r={8.0} alt="Handle" style="fill-$accent" on:pointerdown={grab} on:pointermove={drag} /> </Drawing> ``` | Element | Attributes (`Float` unless the table says otherwise) | |---|---| | `Drawing` | `viewWidth`, `viewHeight`: the size of the view | | `Line` | `x1`, `y1`, `x2`, `y2` | | `Polyline`, `Polygon` | `points: List<Float>`: x and y, one after the other | | `Rect` | `x`, `y`, `width`, `height`, `radius` (0 by default) | | `Circle` | `cx`, `cy`, `r` | | `Ellipse` | `cx`, `cy`, `rx`, `ry` | | `Path` | `d: String`: SVG path data with the commands M, L, H, V, C, S, Q, T, A and Z, absolute and relative | | `Group` | none: it groups shapes for `style`, `opacity` and `alt` | - The view maps into the content box of the `Drawing` with the same scale on both axes, in the middle of the box. Without a width from its styles, a `Drawing` has the width of its view. Its height comes from the ratio of the view (layout spec, section 4). - A shape has no layout. A change of an attribute of a shape paints again, but does not run the layout. - The paint of a shape comes from the utilities `fill-`, `stroke-`, `stroke-<n>`, `stroke-dash-`, `stroke-cap-` and `stroke-join-` (layout spec, section 5.11), and from `opacity-`. A `Group` gives its paint to its shapes. The states, `dark:`, the directives and computed values work as on other elements: `hover:stroke-$accent`, `stroke-{color}`. - A shape and a `Group` have no box. They take only these utilities, a text color (`text-$accent`, the color of a fill with no color of its own) and `cursor-`. Another utility is a compile error, for example `rotate-45`, `shadow-md` or `p-2` (ADR-0120). To move or turn a shape, change its coordinates, or put the transform on the `Drawing`. - A shape gets the pointer events (section 3.1) when the pointer is inside its fill, or within half its stroke width of its stroke. A fill or a stroke of `none` gets no events. - A `Drawing` with `alt` is an image for a screen reader. A shape with `alt` is a button when it has a `pointerdown` handler, else an image. A press of a screen reader on such a button sends `pointerdown` and `pointerup` in the middle of the shape. ### 4.12 `Icon` ```surf import close from "./icons/close.svg"; prop heldIcon: Icon = close; --- <Button on:press={cancel}> <Icon src={close} alt="Cancel" style="text-$accent" /> </Button> <Icon src={held ? heldIcon : close} style="w-6 h-6" /> ``` | Attribute | Type | Default | |---|---|---| | `src` | `Icon` | required | No events of its own. It has the pointer events of section 3.1, and the whole box gets them: `x` and `y` are in the border box, as for an `Image`. - An `Icon` is a value of the built-in type `Icon`. A prop, a state, a field or a list item can hold it. `==` compares two icons. - The icon has the text color: a shape without a `fill`, and a `fill` or a `stroke` of `currentColor`, take the text color of the `Icon`. The other colors of the file stay. - Without a size from its styles, an `Icon` has the height of its font size and the ratio of its view. `w-` and `h-` change each side: give both for another size. The view stays in the middle of the content box. - `alt` makes it an image for a screen reader. Without `alt`, a screen reader skips it. - An SVG file can have `svg` with `viewBox` (or `width` and `height`), `g`, `path`, `circle`, `ellipse`, `rect`, `line`, `polyline` and `polygon`, with the attributes `fill`, `stroke`, `stroke-width`, `stroke-linecap`, `stroke-linejoin`, `stroke-dasharray`, `opacity`, `fill-opacity` and `stroke-opacity`, also in a `style` attribute. Other elements, `transform` and `fill-rule="evenodd"` are compile errors. The `opacity` of a `g` multiplies the opacity of each of its shapes. - `Icon.parse(text)` gives an icon from SVG text at run time, with the same rules (ADR-0030). ### 4.13 `Select` and `Option` ```surf <Select bind:value={mode} on:change={v => save(v)} alt="Oversampling"> <Option value="off">Off</Option> #for f in factors { <Option value={f}>{f}x</Option> } </Select> ``` | Attribute | Type | Default | |---|---|---| | `value` (bindable) | `String` | the first option | | `disabled` | `Bool` | `false` | | `autofocus` | `Bool` | `false` | | `system` | `Bool` | `false` | Events: `change` (`value: String`), `focus`, `blur`, `keydown`. An `Option` has `value: String` and `disabled: Bool`, and its text is its label. `Option` is valid only in a `Select`, and a `Select` takes only `Option` elements, an `OptionList` and blocks. Without a value that matches an option, a `Select` shows the first option. On all targets, the closed box of a `Select` takes `bg-`, `border` and `rounded-`, and it has an arrow at its end. Without them, it has the colors `$surface` and `$border`. A press opens the list of the runtime, which is the same on all targets (ADR-0059). The list is below the box, or above it when it does not fit. The arrow keys move the selection, Enter takes it, and Escape or a press outside closes the list. A press on a row takes it at once. The list takes styles like each other element. An `Option` takes `style` for its row, with `hover:`, `focus-visible:` and `style:` directives. The optional `OptionList` holds the options, and its `style` applies to the box of the open list: its background, border, radius, padding, gap and size. Without styles, the list has the colors of the theme. ```surf <Select bind:value={mode} style="w-30 rounded-8"> <OptionList style="gap-0.5 p-1 rounded-8 border bg-$surface"> #for f in factors { <Option value={f} style="px-2 py-1 rounded-4 hover:bg-$accent {f == mode}:font-600"> {f} </Option> } </OptionList> </Select> ``` With `system`, a `Select` opens the control of the system instead, and an app accepts its look and its timing. `system` takes no value from a state. On macOS this is the menu of the system, in dark with a dark app (ADR-0042). macOS reports a pick more than 400 ms after the click. On the web it is the list of the `<select>` of the browser (ADR-0040). On Linux, which has no such control, it is the list of the runtime. A `system` control takes no styles for its list. ### 4.14 `Overlay` ```surf <Stack> <Button on:press={open = true}>Presets</Button> #if open { <Overlay side="bottom" on:close={open = false}> <Column style="bg-$surface p-2 rounded">...</Column> </Overlay> } </Stack> ``` | Attribute | Type | Default | |---|---|---| | `side` | `String`: `top`, `bottom`, `left`, `right`, `start` or `end` | `bottom` | | `offset` | `Float` | `4` | | `modal` | `Bool` | `false` | | `focus` | `Bool` | `false` | | `passive` | `Bool` | `false` | Events: `close`. - An `Overlay` is above all other elements. No `overflow-hidden` or `Scroll` of its parents cuts it, and it takes no space in its parent. It is a flex column as large as its content. - Its parent element is its anchor. It starts at the start edge of the anchor, `offset` from it on `side`. When it does not fit in the window there, it goes to the opposite side, and then it moves into the window. - `start` and `end` are logical sides (ADR-0124). `start` is the left side and `end` is the right side, unless the overlay has the direction `rtl` (layout spec, section 5.2). Then they change places. So above and below an anchor, an overlay with the direction `rtl` starts at the right edge of the anchor. - A press outside the last open `Overlay`, or Escape, sends it `close`. That press does not reach the element below. The app removes the overlay in its handler, or keeps it. - With `modal`, the overlay covers the window with a dark layer, and its content is in the middle. A press on the dark layer sends `close`. - With `modal` or `focus`, the first control in the overlay gets the focus when it opens. A control with `autofocus` wins (ADR-0102). - Tab and Shift+Tab stay in a modal overlay. In an overlay with `focus`, Tab sends `close`. - Each overlay gives the focus back when it closes, if the focus went away with it. - With `passive`, a press outside it and Escape pass through it, and it sends no `close`. A tooltip uses this. On the web, the control with the focus has `aria-describedby` with the `id` of the overlay while it shows. - With `modal`, it is a dialog for a screen reader, with `aria-modal`. Else `alt` makes it a named group. A `role` wins. ### 4.15 `Transition` Props: `enter` (`String`), `exit` (`String`), `ms` (`Int`, default 150), `ease` (`String`, default `ease`), `delay` (`Int`, default 0), `stagger` (`Int`, default 0), `from` (`String`, default `start`), `move` (`Bool`). ```surf <Transition enter="opacity-0 translate-y-2" exit="opacity-0" ms={200} ease="ease-out"> #if open { <Column>...</Column> } </Transition> ``` - `Transition` has no box. Its children are children of its parent for the layout and for the inherited styles. It takes no `style` and no events. - `enter` gives the utilities of the start of an enter, and `exit` the utilities of the end of an exit. It needs at least one of them, or `move`. They can set `opacity`, `transform`, `background`, `color` and `shadow`. `exit` can also set `absolute` and the utilities of `inset`, also the logical sides `inset-s-` and `inset-e-` (ADR-0124). These apply at the start of the exit. A value replaces the whole property. - `move` animates an element that changes its place, from its old place to its new place (ADR-0081). A `#for` needs a `key` for this. `move` needs no `enter` and no `exit`. - `ms`, `delay` and `stagger` are numbers in the source, in ms. `ease` is the name of an easing utility of the layout spec, section 12.4, as `ease` of a `motion` (language spec 7.5, ADR-0104). Examples are `ease-linear`, `ease-out-cubic`, `ease-spring` and `ease-spring-0.4`. A reader can then copy the name from a `style`. - A name without `ease-`, for example `spring` or `linear`, is a compile error. The message gives the correct name, for example `ease-spring`. - `delay` holds back each enter, exit and move. - `stagger` adds a time for each element of one batch, so that the rows of a list start one after the other (ADR-0104). The elements that start in one frame are one batch, in tree order. A new row that comes alone has no extra time. `from` gives the first element of the batch: ```surf <Transition enter="opacity-0 translate-y-2" ms={200} stagger={40} from="center"> #for row in rows key row.id { <Text>{row.title}</Text> } </Transition> ``` | `from` | The extra time of element `i` of `n` | |---|---| | `start` | `i * stagger` | | `end` | `(n - 1 - i) * stagger` | | `center` | `floor(abs(2 * i - (n - 1)) / 2) * stagger` | - A row that starts by its index for a change of its style uses a computed delay: `delay-{i * 40}` (layout spec 12.1). - An element animates if a `#if`, `#for`, `#match` or `#await` inside the `Transition` adds or removes it, and only blocks are between the two. A nested element goes with its parent. A text node goes at once. The rows of a `List` do not animate. - The block goes inside the `Transition`. A `Transition` inside a block goes with that block. - The children of its first update do not play an enter. A change of the theme or the window width plays no enter. - With the setting of reduced motion on, an element comes and goes at once (layout spec 5.10). - During an exit, the element keeps its place in the layout, gets no events and leaves the accessibility tree. It loses the focus. Its values no longer change. Its siblings close the gap at the end. With `exit="absolute"`, it leaves the flow at the start of the exit, and in a `Stack` this gives a cross-fade. - A block with no other element can show again during an exit. The new element of the same kind then starts at the shown values of the old element, and the old element goes at once. A new row beside other rows does not do this: the row that leaves plays to its end. Two branches that change in the same frame both play. ### 4.16 Components that version 0 does not support yet `Canvas` and `PlatformView` are in the language spec, section 9.6, but this reference does not define them yet. Each of them is a compile error in version 0. - `Canvas` is a drawing function. Use a `Drawing` with shapes (section 4.11). - `PlatformView` comes later. --- ## 5. The components of `surf:ui` `surf:ui` is a built-in module (ADR-0102). Each component is a `.surf` file in the compiler, so it works on each target. An app that does not import a component has no code of it. ```surf import { Dialog, Tabs, UiItem } from "surf:ui"; ``` These rules apply to each component: - It has the roles, the names and the keys of the WAI-ARIA Authoring Practices. On the native targets, the same roles go through AccessKit. - It uses the theme tokens (layout spec, section 2.1), so it works in each variant of the theme. - A `style` on an instance applies to its root elements (language spec, section 9.2). - Its motion stops with the setting "reduce motion" of the system. - A text in a component, such as `closeLabel`, is a prop with an English default. - A component name of `surf:ui` is a name of the project. A file with the same name in the app is a compile error when the app imports that component. `Tabs`, `RadioGroup`, `Accordion` and `Menu` take a list of `UiItem`: ```surf type UiItem = { value: String, label: String, disabled: Bool = false } ``` The module also has `Tone`, the enum of `Badge` and `Toast`, with the cases `neutral`, `accent`, `success`, `warning` and `error`. A parent writes `tone={.warning}` with no import. ### 5.1 `Card` A surface for one topic. It is a group, and `heading` is its name. ```surf <Card heading="Plan"> <Text>10 seats</Text> #fill footer { <Text>Renews in May</Text> } </Card> ``` | Prop | Type | Default | |---|---|---| | `heading` | `String` | `""`: no heading | | `level` | `Int` | `3`: the level of the heading, 2, 3 or 4. Another value is 3 | Slots: `default`, `footer?`. The heading has the role `heading`. Give a card directly under the heading of the page the level 2, so that the levels of the page skip no level (ADR-0110). ### 5.2 `Badge` A small label for a state or a count. ```surf <Badge tone={.success}>Paid</Badge> ``` | Prop | Type | Default | |---|---|---| | `tone` | `Tone` | `.neutral` | Slot: `default`, which accepts text. ### 5.3 `Separator` A line between two parts of a page. It has the role `separator` and an orientation. ```surf <Separator /> <Separator vertical /> ``` | Prop | Type | Default | |---|---|---| | `vertical` | `Bool` | `false` | ### 5.4 `Avatar` A picture of a person, or the initials of the name. It is an image, and `name` is its name. ```surf <Avatar name="Ada Lovelace" src="assets/ada.png" /> <Avatar name="Grace Hopper" /> ``` | Prop | Type | Default | |---|---|---| | `name` | `String` | required | | `src` | `String?` | `none`: the initials | ### 5.5 `Spinner` A wait with no known end. It has the role `progressbar` and no value. With "reduce motion", the ring stands still. ```surf <Spinner alt="Loading the list" /> ``` | Prop | Type | Default | |---|---|---| | `alt` | `String` | `"Loading"` | ### 5.6 `Progress` A bar for the progress of a task. It has the role `progressbar`, with `valueNow`, `valueMin` and `valueMax`. Without `value`, the end is not known, and the bar pulses. With "reduce motion", the bar stands still. ```surf <Progress alt="Upload" value={percent} /> <Progress alt="Waiting" /> ``` | Prop | Type | Default | |---|---|---| | `alt` | `String` | required | | `value` | `Float?` | `none` | | `max` | `Float` | `100.0` | ### 5.7 `Radio` One choice with a ring and a label. It is a `Button` with the role `radio`. Use a `RadioGroup` for a group: it gives the keys. A `Radio` alone suits one choice in a layout of the app. ```surf <Radio checked={agree} on:press={agree = !agree}>I agree</Radio> ``` | Prop | Type | Default | |---|---|---| | `checked` | `Bool` | `false` | | `disabled` | `Bool` | `false` | | `tabStop` | `Bool` | `true`: the Tab key reaches it | | `id` | `String` | `""` | Events: `press`, `key` (`name: String`). Slot: `default`, which accepts text. ### 5.8 `RadioGroup` A choice of one item. It has the role `radiogroup`. ```surf <RadioGroup alt="Size" items={sizes} bind:value={size} /> ``` | Prop | Type | Default | |---|---|---| | `items` | `List<UiItem>` | required | | `value` | `String`, bindable | `""` | | `alt` | `String` | required | | `horizontal` | `Bool` | `false` | Event: `change` (`value: String`). | Key | Effect | |---|---| | Tab | Goes to the checked radio button, and then leaves the group. | | Up, Left | Checks the previous item, around the ends, past a disabled item. | | Down, Right | Checks the next item. | | Home, End | Checks the first or the last item. | | Space | Checks the item with the focus. | ### 5.9 `Tabs` One panel at a time. The tabs have the role `tab`, and the panel has the role `tabpanel`. The panel follows the tab at once. ```surf <Tabs alt="Settings" items={pages} bind:value={page}> #fill panel(item) { <Text>{item.label}</Text> } </Tabs> ``` | Prop | Type | Default | |---|---|---| | `items` | `List<UiItem>` | required | | `value` | `String`, bindable | `""`: the first item that is not disabled | | `alt` | `String` | required | | `rtl` | `Bool` | `false`. `true`: the tabs go from right to left, for right-to-left text (ADR-0122) | Event: `change` (`value: String`). Slot: `panel(item: UiItem)`. | Key | Effect | |---|---| | Tab | Goes to the chosen tab, and then to the panel. | | Left, Right | Chooses the previous or the next tab, around the ends, past a disabled tab. With `rtl`, Left chooses the next tab, and Right the previous tab. | | Home, End | Chooses the first or the last tab. | The runtime does not know the direction of the text of an element, so an app in right-to-left text gives `rtl` to each `Tabs`. ### 5.10 `Accordion` Sections that open and close. Each header is a heading of level 3 with a `Button`. Each open panel has the role `region`, and its header names it. ```surf <Accordion items={sections} bind:open={shown} multiple> #fill panel(item) { <Text>{item.label}</Text> } </Accordion> ``` | Prop | Type | Default | |---|---|---| | `items` | `List<UiItem>` | required | | `open` | `List<String>`, bindable | `[]`: the values of the open sections | | `multiple` | `Bool` | `false`: one section at a time | Event: `change` (`value: String`). Slot: `panel(item: UiItem)`. | Key | Effect | |---|---| | Enter, Space | Opens or closes the section. | | Up, Down | Goes to the previous or the next header, around the ends. | | Home, End | Goes to the first or the last header. | ### 5.11 `Dialog` A modal dialog above a dark layer. It has the role `dialog` and `aria-modal`, and `heading` is its name. ```surf <Dialog heading="Delete the file?" bind:open={asking}> <Text>This action cannot be undone.</Text> #fill actions { <Button on:press={asking = false}>Cancel</Button> } </Dialog> ``` | Prop | Type | Default | |---|---|---| | `open` | `Bool`, bindable | `false` | | `heading` | `String` | required | | `closeLabel` | `String` | `"Close"`. Empty: no close button | Event: `close`. Slots: `default`, `actions?`. - When the dialog opens, the first control gets the focus. An `Input` with `autofocus` wins. - Tab and Shift+Tab stay in the dialog, around its ends. - Escape, a press on the dark layer and the close button close it, and send `close`. - When the dialog closes, the control that opened it gets the focus back. ### 5.12 `Tooltip` A short text for the control in it. The text shows while the control has the focus, and after a pointer rests on the control. It describes the control for a screen reader while it shows. ```surf <Tooltip text="Saves the file"> <Button on:press={save()}>Save</Button> </Tooltip> ``` | Prop | Type | Default | |---|---|---| | `text` | `String` | required | | `side` | `String`: `top`, `bottom`, `left`, `right`, `start` or `end`, as the `side` of an `Overlay` (section 4.14) | `"top"` | | `ms` | `Int` | `500`: the rest of the pointer before the text shows | Slot: `default`. Escape hides the text. The pointer can go onto the text, and the text stays (WCAG 1.4.13). ### 5.13 `Menu` A button that opens a list of actions. The button has `hasPopup="menu"`, and the rows have the role `menuitem`. ```surf <Menu label="Actions" items={actions} on:select={v => run(v)} /> ``` | Prop | Type | Default | |---|---|---| | `label` | `String` | required | | `items` | `List<UiItem>` | required | | `disabled` | `Bool` | `false` | | `alt` | `String` | `""`: the name of the button for a screen reader. Empty: the label. The name must hold the label, for voice control | | `tabStop` | `Bool` | `true`. `false`: the Tab key does not reach the button, for a list with one stop | Event: `select` (`value: String`). | Key | Effect | |---|---| | Enter, Space, Down on the button | Opens the menu on the first item. | | Up on the button | Opens the menu on the last item. | | Up, Down | Goes to the previous or the next item, around the ends, past a disabled item. | | Home, End | Goes to the first or the last item. | | A letter | Goes to the next item that starts with the letter. | | Enter, Space | Runs the item, and closes the menu. | | Escape, Tab | Closes the menu. | When the menu closes, the button gets the focus back. ### 5.14 `Toast` A short message that goes away by itself. The region has the role `status` and is always in the tree, so a screen reader says each new message. With `tone={.error}`, it has the role `alert`. ```surf <ToastList> <Toast bind:open={saved} tone={.success}>The file is saved.</Toast> </ToastList> ``` | Prop | Type | Default | |---|---|---| | `open` | `Bool`, bindable | `false` | | `tone` | `Tone` | `.neutral` | | `ms` | `Int` | `5000`. 0: it stays until the user closes it | | `closeLabel` | `String` | `"Close"` | | `restart` | `Int` | `0`. A new value starts the time again, for a new message in an open toast | Event: `close`. Slots: `default`, which accepts text, and `action?`, for a button such as "Undo". ```surf <Toast bind:open={shown} restart={round} ms={10000}> {notice} #fill action { <Button on:press={undo()}>Undo</Button> } </Toast> ``` A `Toast` is in the flow of its parent. A `ToastList` gives the toasts of a window their place. ### 5.15 `ToastList` The place of the toasts of a window (ADR-0122). It is `fixed`, at the bottom right of the window (layout spec, section 5.7). The toasts in it stack upward in the order of the source, so two messages at the same time do not cover each other. ```surf <ToastList> <Toast bind:open={saved} tone={.success}>The file is saved.</Toast> <Toast bind:open={failed} tone={.error} ms={0}>The file was not saved.</Toast> </ToastList> ``` Slot: `default`. Put each `Toast` of a window in one `ToastList`. <!-- https://surface.solenovis.com/docs/layout.md --> # Surface Layout Spec, v0 - **Status:** draft - **Edition:** none yet. Version 0 has no stability guarantee. - **Related documents:** [Language spec v0](https://surface.solenovis.com/docs/language.md) (section 12), [component reference](https://surface.solenovis.com/docs/components.md), [ADR-0005](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/adr/0005-css-subset-as-layout-model.md), [PLAN.md](https://git.solenovis.com/henning.liemant/surface/src/branch/main/PLAN.md) (section 10) - **Changes:** 2026-11-06: a press hits the shown position of a transform, a spring uses its `linear()` stops, the keys scroll a `Scroll` with the focus, `outline-none` removes the ring, and Linux follows a change of reduced motion, on the native targets too (5.10, 5.12, 12, ADR-0131). 2026-11-06: the compiler has the cues of a sequence on all targets (5.10, 12, ADR-0152). Each element of a cue has the length of its sequence on the web, so all elements turn together. 2026-11-06: the names of the styles (2.1, 4, 5.6, 5.7, 5.9, 5.10, 10, 11, 12, ADR-0161). `active:`, `focus-visible:`, `inset-s-`, `inset-e-`, `grid-cols-`, `grid-rows-`, `grid-areas-`, `overflow-hidden`, `overflow-visible`, `wrap-anywhere`, `wrap-normal`, `font-sans`, `stroke-dash-`, `stroke-cap-`, `stroke-join-`, `mask-ellipse` and `shadow-xs`, and the keys `color`, `length`, `font`, `breakpoint` and `font-weight` of `style.toml`. Each old name is a compile error that names the new name. `shadow-sm` has the value of Tailwind 4. The property `clip` is `overflow`. A form of Tailwind with another meaning, for example `scale-95`, is a compile error. The spec lists `forced-colors:`, a computed `tracking-` and the rules of the names of `keyframes`. 2026-11-06: the proposals of the website findings that fit (5.1, 5.5, 5.7, 5.8, 5.9, 5.10, 12, ADR-0160): `pointer-events-auto`, `visible`, `invisible`, `aspect-auto`, `origin-`, `skew-x-`, `skew-y-`, `object-<position>`, `indent-`, `decoration-`, `underline-offset-`, `font-stretch-`, `auto-cols-`, `caret-`, `placeholder-`, `accent-`, the cursors of CSS, `group-<state>:`, `peer-<state>:`, `focus-within:`, `motion-reduce:`, `rtl:` and `ltr:`. `pointer-events` inherits, as in CSS. `indent-` is not in version 0 on the native targets. 2026-11-06: the compiler has the split of a `Text` into lines, words and letters on all targets, with `split-clip` (5.10, 12, ADR-0153). The native targets find the words with the ICU of the system. 2026-11-06: the compiler has the animations that follow the scroll on all targets (5.10, 12, ADR-0151). A browser without `animation-timeline` gets their progress from the runtime. A timeline name takes no variant, and a range has no effect on the time, as in CSS. 2026-11-06: the compiler has `animate-delay-` and the fill `both` of each animation on all targets (5.10, 12, ADR-0150). An animation that has ended keeps its last stop and uses no frames. 2026-11-06: the effects of version 1 on all targets (5.7, 5.8, 5.9, 5.10, 9, 10, 12, ADR-0145). A text shadow with the sizes of Tailwind 4, the blur of a backdrop, the blend modes of CSS and `isolate`. `mask` gets the shapes of CSS `clip-path`: an inset with corners, a circle, an ellipse and a polygon. A shape can have a transition, and it limits the hit area, as `clip-path` does. 2026-11-06: the compiler has gradients, the four shadow layers and background images on all targets (2, 2.1, 5.8, 10, 12, ADR-0127). One declaration of `background-image` holds the gradient and the picture, and its parts are the parts of the gradient and the picture. The native renderers have a pipeline for each kind of gradient. 2026-11-06: the compiler has the lengths of `Theme.setToken` and the check of the kind of its value on all targets (2.1, 10, ADR-0130). A length token that no call changes is a constant of the build. 2026-11-05: the compiler has the selection of text on all targets (5.10, 10, 12, ADR-0131). The text of a field stays selectable, also with `select-none`, as CSS UI 4 says. `select-auto` is a compile error. 2026-11-05: the filters and a `fixed` element under a filter work on the native targets (5.7, 5.8, ADR-0131). 2026-11-05: the design of motion for version 1 (P6.24, P6.27, ADR-0150 to ADR-0154). Sections 5.10 and 12 get the delay and the fill of an animation, and the animations that follow the scroll. They also get the cues of a sequence, the split of a text and the transition names of a page. The spec marks the new features "Not in version 0". 2026-11-05: the compiler has `sticky` on the native targets, scroll snapping on all targets, and the paint order of `z-` on the native targets (5.7, 5.12, 9, 12, 12.3, ADR-0128). The native targets end a scroll gesture as the browsers do (5.12). 2026-11-05: the compiler has the answers of 2026-11-05 on all targets. `dir-ltr` and `dir-rtl` write the attribute `dir`, and `mask-fade-` has the logical edges. A variant wins over the base. An unknown area is an error, and a placement can have a name. The attribute of a `dir-` utility with a state, a theme or a window width follows the variant (5.6, 5.7, 5.9, 10, 12.3, 12.4, ADR-0124, ADR-0126). 2026-11-05: the answers of the project owner for version 1 (13). `dir-ltr` and `dir-rtl` write the attribute `dir`, and a logical fade uses `:dir(rtl)` (5.9, 10, 12.4, ADR-0124). A variant always wins over the base, also for a physical side and a logical side (12.3, ADR-0124). An unknown name in `area-` is a compile error, and `col-span-<name>` and `col-<n>_<name>` place an item at a named line (5.6, 12.4, ADR-0126). The spec marks the new rules "Not in version 0". 2026-11-05: the compiler has named lines, areas, `auto-fill`, `auto-fit` and dense on all targets. `flow-row` and `flow-col` are gone (5.5, 5.6, 12.4, ADR-0126). 2026-11-04: the compiler has the logical sides on all targets, and a utility of all sides or of the axis `x` also sets the logical sides (3, 5.2, 12.1, 12.3, 12.4, ADR-0124). 2026-11-04: `select-none` on an `Input` or a `TextArea` turns off the selection of the field, and `surf lint` reports it (5.10, 12, ADR-0131). 2026-11-04: the answers of the project owner to the details of version 1. A value of the wrong kind in `Theme.setToken` (2.1, ADR-0130). Computed values of `start-` and `end-`, the logical edges of `mask-fade-`, and the physical `translate-x-` and directions of a gradient (5.7, 5.8, 12, ADR-0124). `grid-flow-row` and `grid-flow-col` (5.5, 12, ADR-0126). The utilities that reset a value (5.8, 5.12, 12, ADR-0127, ADR-0128). The distance of `snap-proximity` (5.12, ADR-0128). Text is not selectable without `select-text` (5.10, 10, 12, ADR-0131). One behavior on all targets for `fixed` under a filter, the filters, a theme change during a transition and the stops of a spring (5.7, 5.8, 5.10, ADR-0131). One behavior on all targets for reduced motion on Linux, line height `normal`, the line breaks of ASCII text and `outline-none` (5.10, 7, 12, ADR-0131). 2026-11-04: the decisions for version 1 replace the open questions (13). Logical sides (3, 5.2, 5.7, 5.8, 5.9, 10, 12, ADR-0124). The rules of percentages (2, ADR-0125). Named lines, areas, `auto-fill`, `auto-fit` and dense (5.5, 5.6, 12, ADR-0126). Gradients, shadow layers and background images (2, 2.1, 5.8, 5.10, 10, 12, ADR-0127). `sticky` on the native targets, and scroll snapping (5.7, 5.12, 9, 12, ADR-0128). No reverse flex directions (5.3, 6, ADR-0129). Theme lengths at run time (2.1, 10, ADR-0130). The spec marks the new features "Not in version 0". 2026-11-03: a block has no wrapper element on the web, also a `#for` next to other nodes, an instance with fills and a `@slot` at the top of a fragment (10, ADR-0121). 2026-11-03: the parts of a property merge when two declarations both apply, also for two conditions (10, 12.3, ADR-0120). 2026-11-03: a condition takes the parts of `style` (12.3, ADR-0118). 2026-11-03: `font-mono` on the web takes `monospace` after `ui-monospace`, which only Safari knows (5.9, ADR-0112). 2026-11-03: `Span` and a `Link` in a `Text`: the paragraph of several runs (4, 7, 10, ADR-0111). 2026-11-03: `fixed`, `group`, `group-hover:` and the filters of the web (5.7, 5.8, 12, ADR-0106), the full token set and the high contrast variant (2.1, ADR-0103), and computed transforms, parts of `transform` that merge, named easings, springs, `delay-`, and the setting of reduced motion (5.8, 5.10, 10, 12, ADR-0104). 2026-11-02: a token always has `$` (2, ADR-0090), TOML for the theme and style presets (2.1, 2.2, ADR-0091, ADR-0092), conditions in style text (12, ADR-0096), named breakpoints (12.2, ADR-0097) and a spacing scale (12.1, ADR-0098). 2026-11-01: lengths with a unit (2, ADR-0067), `dir` and the layout of right-to-left text (3, 5, ADR-0066), and text marks (5, ADR-0068). 2026-10-31: `animation` and the `animate-` utilities (5.10, ADR-0049). 2026-10-30: all targets animate `transition` (5.10, ADR-0035). 2026-10-29: the default `min-height` is `auto`, as in CSS: a flex item keeps its content height, and a `Scroll` or `List` can shrink to 0 (section 3, ADR-0028). `min-width` stays 0. 2026-10-28: utilities replace the `<style>` section and the style attributes (section 12, ADR-0020). `Box` is removed. Section 5 is the model that the utilities expand to. 2026-10-26: P1.9 adds the theme tokens (2.1) and the stylesheet of the web mapping (10). 2026-10-25: section 11 names the CSS variables for the test font. 2026-10-25: `line-height` takes `normal` or a number, not a length (section 5.9). The value check of P1.1 found the conflict with section 2. This document specifies the layout and style model of Surface. It covers the units, the box model, the layout of each built-in component, every style property and the utilities that set them. For each property, it also gives the CSS property (web) and the Taffy 0.15 field (native). A developer writes styles as utilities in the `style` attribute (section 12). The compiler expands the utilities to declarations of the properties of section 5. The web emitter and the native runtime implement these declarations. The design rule: **one model, two implementations, the same boxes.** The browser implements this model with CSS. The native runtime implements it with Taffy. Both are CSS implementations, so this spec is a subset of CSS with fixed defaults. A property or value that the two implementations cannot give in the same way is not in the spec. The compiler rejects every property and value that is not in this document. --- ## 1. Conventions - **must** and **must not** state a rule. A violation of a rule is a compile error, unless the rule says otherwise. - "Logical px" is the unit of all layout values. The runtime multiplies by the scale factor of the screen to get device pixels. The web emitter writes a plain number in `rem`, as the number over 16. So a browser with a larger default font size scales the app. Borders and shadows stay in `px` (ADR-0067). - "CSS" in the mapping columns is the CSS property that the web emitter writes. "Taffy" is the field of `taffy::Style` that the native runtime sets. - The native runtime must set every `taffy::Style` field from this spec. It must not depend on a Taffy default, because a Taffy default can change in a new Taffy version. --- ## 2. Values | Value | Syntax | Example | Valid in | |---|---|---|---| | Length | A number in logical px, or a number with a unit (ADR-0067) | `24`, `0.5`, `1.5rem`, `50vw` | All length properties | | Percentage | A number with `%` | `50%` | `width`, `height`, `min-*`, `max-*`, `basis`, `inset`, and the position of a gradient stop (ADR-0127) | | Fraction | A number with `fr` | `1fr` | Grid tracks only | | `auto` | The keyword | `auto` | Where the table of the property lists it | | Integer | A number without a fraction | `2` | `z-index`, `max-lines`, `span` | | Number | A number | `0.8` | `grow`, `shrink`, `opacity`, `line-height`, `aspect-ratio` | | Color | `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb(r g b / a)`, `transparent` | `#007aff` | Color properties | | Token | `$name` | `$accent` | Wherever the type of the token fits | | Duration | A number with `ms` | `200ms` | `transition`, `animation` | | Angle | A number with `deg` | `45deg` | `transform`, and the angle of a gradient (ADR-0127) | | Image | `url(<path>)` | `url(assets/hero.png)` | `background-image` (ADR-0127) | Rules for values: - Percentages refer to the size of the parent's content box in the same axis. - `padding`, `margin` and `gap` never take a percentage (ADR-0125). CSS computes a vertical padding in percent from the width, and this surprises many developers. `aspect-ratio` already replaces the old trick of a padding in percent. - `radius` and `border` take no percentage in version 1. - A color is an sRGB color. All blending happens on sRGB values, in the same way on all targets (as browsers do). - A token comes from the theme (section 2.1). The type of the token must fit the property: a color token for `background`, a length token for `padding`. An unknown token is a compile error. - A token always has `$`: `bg-$accent`, `p-$gutter`. A token can change with the theme or while the app runs. A token with no `$` is a compile error, and the error gives the correct form (ADR-0090). - A constant never has `$`: `p-3`, `text-xs`, `gap-inset`. The compiler replaces a constant with its value. - A length can have the unit `rem`, `em`, `vw`, `vh`, `vmin` or `vmax`, or be `min()`, `max()`, `clamp()` or `calc()` of lengths (ADR-0067). `rem` is 16 logical px on the native targets. In a utility, `_` is a space: `w-calc(100vw_-_2rem)`. - `calc()` takes no percentage in version 1. A minor version after version 1 adds percentages in `calc()`, for example `w-calc(100%_-_2rem)` (ADR-0125). - The web output writes a plain number in `rem`, so the font size of the browser scales the app. Borders and shadows stay in `px`. - Named colors other than `transparent` are not valid in v0. ### 2.1 Theme tokens These are the default tokens. A color token has four values: light, dark, high contrast light and high contrast dark (ADR-0103). The system selects the variant on each target that has the settings: its dark appearance gives the dark value, and its setting for more contrast gives a high contrast value. `Theme.setMode` also selects it. A length token has the same value in all variants. A project can change them with `src/style.toml` (ADR-0034): new values, new tokens, fonts and the fit of the UI to the window. The compiler reads the file, so a new token is checked as a default token is, and a wrong name is a compile error. The values are fixed at compile time. ```toml step = "0.25rem" [color] accent = ["#9f40ff", "#d0bcff"] tertiary = ["#7d5260", "#efb8c8"] outline = "#79747e" [length] radius = 12 space-huge = 32 [spacing] inset = 12 [font] sans = "assets/Inter.ttf" display = "assets/Display.ttf" [breakpoint] md = 700 [scale] design = [460, 674] min = 0.75 max = 2.5 [keyframes.fade] ms = 500 ease = "ease-linear" 0 = "opacity-1" 100 = "opacity-0" ``` - A color is a text, a pair of the light and the dark value, or these two with the two high contrast values: `accent = ["#9f40ff", "#d0bcff", "#5a00b0", "#e8dcff"]`. A pair keeps its values in high contrast. A length is a number in logical px. A name of the default theme keeps its kind. - A token that a project leaves out follows the tokens that it sets. For example, `$accent-hover` and `$focus` follow `$accent`, and `$on-accent` is black or white, the one with the larger contrast. - `surf lint` checks the pairs of text and background tokens against WCAG AA in each variant (rule `contrast`, ADR-0103). - `color` and `length` hold tokens. `spacing`, `radius`, `text`, `shadow`, `font-weight`, `leading`, `tracking` and `container` hold constants (ADR-0053). `container` holds widths for `max-w-`, after the names of `spacing`. `spacing` holds lengths for the utilities of the scale (section 12.1) and for `mask-fade-`. `shadow` has the sizes `xs`, `sm`, `md`, `lg` and `xl`. Each size is the first layer of the size of Tailwind 4, without its spread. `inset-shadow` holds the sizes of the inset shadow in the same way, with the defaults `2xs`, `xs` and `sm` of Tailwind 4 (ADR-0127). If a token and a constant have the same name, a bare name is the constant and `$name` is the token. - A font has a name and a file in the assets, and the utility `font-<name>` selects it. `sans` and `mono` replace the two default fonts. The name `system` is a compile error that names `sans` (ADR-0161). A name has one file: a variable font gives all weights. - `keyframes` adds a looping animation for the utility `animate-<name>` (ADR-0049). Each number is a stop in percent. A stop holds utilities of the properties that can have a transition (5.10). Each stop sets the same properties. `ms` is the time of one round, and the default is 1000. `ease` is the name of an easing utility of section 12.4, as the `ease` of a `Transition` (component reference 4.15): `ease-linear`, `ease-out-cubic`. The default is `ease`. A name without `ease-`, for example `linear`, is a compile error that gives the correct name. An unknown name in `animate-<name>` is a compile error. - The name of an animation cannot be `none` or `infinite`. It cannot start with `delay-`, `timeline-`, `range-`, `duration-`, `ease-` or `count-`, because `animate-` with each of these words is a utility (ADR-0150, ADR-0151, ADR-0161). - `scale` fits the UI to the window. The factor is the smaller of two values: the window width divided by the design width, and the window height divided by the design height. It stays between `min` (0.5 by default) and `max` (4 by default). The lengths of the app stay in design px, also in the pointer events. | Token | Kind | Light | Dark | |---|---|---|---| | `$accent` | color | `#0064d2` | `#4da3ff` | | `$accent-hover` | color | `#0055b3` | `#73b7ff` | | `$accent-active` | color | `#004799` | `#94c8ff` | | `$on-accent` | color | `#ffffff` | `#000000` | | `$focus` | color | `#0064d2` | `#73b7ff` | | `$background` | color | `#f2f2f7` | `#000000` | | `$surface` | color | `#ffffff` | `#1c1c1e` | | `$hover` | color | `#e8e8ed` | `#2c2c2e` | | `$active` | color | `#dcdce1` | `#3a3a3c` | | `$disabled` | color | `#e5e5ea` | `#2c2c2e` | | `$on-disabled` | color | `#5c5c61` | `#b0b0b5` | | `$text` | color | `#1c1c1e` | `#f2f2f7` | | `$text-strong` | color | `#000000` | `#ffffff` | | `$text-secondary` | color | `#5c5c61` | `#b0b0b5` | | `$border` | color | `#d1d1d6` | `#38383a` | | `$border-strong` | color | `#86868b` | `#8e8e93` | | `$success` | color | `#1a7a35` | `#30d158` | | `$on-success` | color | `#ffffff` | `#000000` | | `$success-surface` | color | `#e3f5e8` | `#10321b` | | `$on-success-surface` | color | `#0f5a24` | `#8fe3a6` | | `$warning` | color | `#955700` | `#ffb340` | | `$on-warning` | color | `#ffffff` | `#000000` | | `$warning-surface` | color | `#fff2d9` | `#38280a` | | `$on-warning-surface` | color | `#6b3f00` | `#ffd08a` | | `$error` | color | `#c8251c` | `#ff6961` | | `$on-error` | color | `#ffffff` | `#000000` | | `$error-surface` | color | `#fdeaea` | `#3b1513` | | `$on-error-surface` | color | `#8e1710` | `#ffb4ae` | | `$font-size` | length | `15` | `15` | | `$font-size-small` | length | `13` | `13` | | `$font-size-large` | length | `17` | `17` | | `$font-size-title` | length | `28` | `28` | | `$space-small` | length | `4` | `4` | | `$space` | length | `8` | `8` | | `$space-large` | length | `16` | `16` | | `$radius` | length | `8` | `8` | | `$radius-large` | length | `14` | `14` | The names of the color tokens follow one pattern: `<role>`, `<role>-<state>`, `on-<role>`, `<role>-surface` and `on-<role>-surface`. A state token with no role, for example `$hover`, is the state of `$surface`. In the same way, `$text` and `$border` are the default text and the default border. The high contrast values are in `crates/surf-check/src/theme.rs`. The states of a control use these tokens: hover (`$hover`, `$accent-hover`), active (`$active`, `$accent-active`), focus (`$focus`), disabled (`$disabled`, `$on-disabled`), and the three status colors with their text and their surface. **Tokens at run time.** `Theme.setToken(name, value)` changes a token while the app runs (ADR-0082, ADR-0130). A `Color` changes a color token in each variant. A `Float` changes a length token, in logical px. A name that no token has has no effect. A length that is not finite has no effect. The kind of the value must fit the kind of the token (ADR-0130). - With a literal name, a value of the wrong kind is a compile error, for example a `Float` for the color token `accent`: `Theme.setToken("accent", 2.0)`. - With a computed name, a value of the wrong kind has no effect, as an unknown name has. These rules apply to a length: - An app that never gives a length to `setToken` has no run-time code for lengths. Its size does not change. - With a literal name, only that token can change. All other length tokens stay constants of the build. - With a computed name, each length token can change. - On the native targets, a change of a length makes a new layout in the next frame. - On the web, a length token becomes a CSS variable only when it can change (section 10). The new value is in `rem`, as each length of the web output (section 1). --- ### 2.2 Style presets A file `style-presets.toml` names style text (ADR-0091). It applies to its folder and to the subfolders of that folder. ```toml outlined = "border rounded-md p-2" strong = "@outlined border-2 hover:opacity-0.8" ``` - `@name` uses a preset: `style="@outlined px-4"`, `hover:@outlined`, `{active}:@outlined`. - The nearest file wins. A subfolder can replace a name of a parent folder. - A preset holds utilities, variants and other presets. A preset cannot hold a computed value. A cycle of presets is a compile error. - A utility on the element wins over a utility of the preset if it sets each part that the preset utility sets, with the same variants. An outer preset wins over a preset that it uses in the same way. - Two presets on one element that set the same part are a compile error. - The compiler expands a preset, so a preset costs nothing when the app runs. --- ## 3. Box model - **Border box.** `width` and `height` include the padding and the border. CSS: `box-sizing: border-box`. Taffy: `box_sizing: BorderBox`. - **Minimum width 0, automatic minimum height.** The default `min-width` is 0, not `auto`: a flex item can shrink below the width of its content. This removes the most common difference between flex implementations (the automatic minimum width of text) and makes text truncation predictable. The default `min-height` is `auto`, as in CSS (ADR-0028): - A flex item does not shrink below the height of its content. So a text next to a large `Scroll` keeps its height. - A `Scroll` and a `List` can shrink to 0, so they get the height that their siblings leave. - An element with a height but without content can shrink below its height, as in CSS. - A `Row` or a `Column` that contains a `Scroll` or a `List` is as tall as all their content. To make the `Scroll` or the `List` fit a bounded column, give the container `min-h-0`, as in CSS. A `List` without a bounded height builds all its rows. - **Every element is a positioning container.** An element with `position: absolute` is placed relative to its parent, not to the nearest positioned ancestor. CSS: every element gets `position: relative` unless it is `absolute`. Taffy places absolute children relative to their parent in all cases. - **Left to right by default.** `start` means left and `end` means right, unless the element has `dir: rtl`. Then they change places. `dir-rtl` mirrors the layout on all targets. `text-start` and `text-end` follow the paragraph (ADR-0066). - **Two kinds of sides** (ADR-0124). A physical side is the top, the right, the bottom or the left side. It does not change with the direction. A logical side is the start side or the end side. It follows the direction. Section 5.2 gives the rules. Use a logical side, unless a thing must not mirror, for example a map or a media control. --- ## 4. Built-in components Each built-in component has a fixed layout mode. Styles can change the properties of the mode, but not the mode. | Component | Layout mode | Fixed values | CSS (web element) | Taffy | |---|---|---|---|---| | `Column` | Flex column | `direction: column` | `<div>`, `display: flex; flex-direction: column` | `Display::Flex`, `FlexDirection::Column` | | `Row` | Flex row | `direction: row` | `<div>`, `display: flex; flex-direction: row` | `Display::Flex`, `FlexDirection::Row` | | `Stack` | Grid with one cell | every child in row 1, column 1 | `<div>`, `display: grid`, children `grid-area: 1 / 1` | `Display::Grid`, children `grid_row` and `grid_column` = line 1 | | `Grid` | Grid | none | `<div>`, `display: grid` | `Display::Grid` | | `Scroll` | Flex column | `overflow: hidden`, scrolls on the main axis | `<div>`, `display: flex; flex-direction: column; overflow: auto` | `Display::Flex`, `Overflow::Scroll` | | `List` | Flex column | scrolls on the main axis, only the rows near the visible area exist, the rows do not shrink (ADR-0027) | `<div role="list">`, as `Scroll` | as `Scroll` | | `Text` | Leaf: text | none | `<span>`, `display: block` | leaf with a measure function (section 7) | | `Span`, and a `Link` in a `Text` | A part of the text of its `Text` (ADR-0111) | no box of its own for the layout | `<span>` or `<a>`, `display: inline` | a run of the paragraph of its `Text` (section 7.1) | | `Button` | Flex row | `align: center`, `justify: center` | `<button>` | as `Row` | | `Toggle` | Flex row | `align: center` | `<label>` with `<input type="checkbox">` | as `Row`, the switch is a leaf | | `Image` | Leaf: the size and the ratio of its picture (ADR-0033) | none | `<img>` | leaf with `aspect_ratio` | | `Icon` | Leaf: the ratio of its view (ADR-0030) | without a size from the styles, the height of the font size and the width from the ratio | `<svg viewBox>` with `width` and `height` attributes in `em` | leaf with `aspect_ratio` | | `Input`, `TextArea`, `Slider`, `Select` | Leaf: control | none | `<input>`, `<textarea>`, `<div>` with a `<button>` or a `<select>` | leaf with a measure function | | `Overlay` | Flex column, out of the flow (ADR-0032) | no space in its parent, above all other elements | an element with `popover`, `position: fixed` | its own layout pass | | `Transition` | No box (ADR-0036) | its children take its place | `display: contents` | a fragment | | `Drawing` | Leaf: the ratio of its view (ADR-0029) | without a width from the styles, the width of the view. The shapes take no part in the layout | `<svg viewBox>` with the `width` attribute | leaf with `aspect_ratio` | | `Canvas`, `PlatformView` | Leaf: size from styles only | none | `<canvas>`, none | leaf | --- ## 5. Property reference The **Inh.** column says if a child gets the value of its parent when it has no value of its own. All other properties start from their default on each element. A row with "(not in version 0)" after the name of its property is a feature that the compiler does not have yet. It gets its fixtures when the compiler gets the feature (section 11). ### 5.1 Size | Property | Values | Default | Inh. | CSS | Taffy | |---|---|---|---|---|---| | `width` | length, %, `auto` | `auto` | no | `width` | `size.width` | | `height` | length, %, `auto` | `auto` | no | `height` | `size.height` | | `min-width` | length, % | `0` | no | `min-width` | `min_size.width` | | `min-height` | length, % | automatic: the content height, 0 for a Scroll and a List (section 3, fixture MinSize) | no | `min-height` | `min_size.height` | | `max-width` | length, %, `none` | `none` | no | `max-width` | `max_size.width` (`auto` for `none`) | | `max-height` | length, %, `none` | `none` | no | `max-height` | `max_size.height` (`auto` for `none`) | | `aspect-ratio` | number, `a / b`, or `auto` (ADR-0160) | none | no | `aspect-ratio` | `aspect_ratio` (`None` for `auto`) | ### 5.2 Space | Property | Values | Default | Inh. | CSS | Taffy | |---|---|---|---|---|---| | `padding` | 1 to 4 lengths (top, right, bottom, left, as in CSS) | `0` | no | `padding` | `padding` | | `margin` | 1 to 4 lengths or `auto` | `0` | no | `margin` | `margin` | | `gap` | 1 or 2 lengths (row gap, column gap) | `0` | no | `gap` | `gap` | A `gap` value of one length applies to both axes. **Logical sides** (ADR-0124). - `padding`, `margin`, `inset` and `border-width` have two logical sides: `start` and `end`. - `radius` has four logical corners. `start-start` is the corner of the top side and the start side. `start-end` is the top and the end, `end-start` is the bottom and the start, and `end-end` is the bottom and the end, as in CSS. - `start` is the left side and `end` is the right side, unless the element has the direction `rtl`. Then they change places. The direction comes from `dir` (section 5.9). - A physical side wins over a logical side, and a logical side wins over an axis (section 12.3). - The web writes the logical properties of CSS: `padding-inline-start`, `padding-inline-end`, `margin-inline-start`, `margin-inline-end`, `inset-inline-start`, `inset-inline-end`, `border-inline-start-width`, `border-inline-end-width`, `border-start-start-radius`, `border-start-end-radius`, `border-end-start-radius` and `border-end-end-radius`. - The native targets give the physical side to Taffy from the direction of the element. The renderer gets the physical corners in the same way. ### 5.3 Flex container (`Column`, `Row`, `Scroll`, `List`, `Button`, `Toggle`) | Property | Values | Default | Inh. | CSS | Taffy | |---|---|---|---|---|---| | `align` | `start`, `center`, `end`, `stretch`, `baseline` | `stretch` | no | `align-items` | `align_items` | | `justify` | `start`, `center`, `end`, `space-between`, `space-around`, `space-evenly` | `start` | no | `justify-content` | `justify_content` | | `wrap` | `nowrap`, `wrap` | `nowrap` | no | `flex-wrap` | `flex_wrap` | | `align-content` | `start`, `center`, `end`, `stretch`, `space-between`, `space-around` | `stretch` | no | `align-content` | `align_content` | The flex direction comes from the component (section 4). `flex-row` and `flex-col` change it. There are no reverse directions, and they never come (ADR-0129). A reversed row shows the children in an order that is different from the reading order and the focus order. `dir-rtl` mirrors a row, and an app can reverse its list in code: `items.reversed()`. ### 5.4 Flex item (a child of a flex container) | Property | Values | Default | Inh. | CSS | Taffy | |---|---|---|---|---|---| | `grow` | number ≥ 0 | `0` | no | `flex-grow` | `flex_grow` | | `shrink` | number ≥ 0 | `1` | no | `flex-shrink` | `flex_shrink` | | `basis` | length, %, `auto` | `auto` | no | `flex-basis` | `flex_basis` | | `align-self` | `auto`, `start`, `center`, `end`, `stretch`, `baseline` | `auto` | no | `align-self` | `align_self` (`None` for `auto`) | The utility `grow` without a value means `grow: 1`. ### 5.5 Grid container (`Grid`) | Property | Values | Default | Inh. | CSS | Taffy | |---|---|---|---|---|---| | `columns` | track list | none (one implicit column) | no | `grid-template-columns` | `grid_template_columns` | | `rows` | track list | none (implicit rows) | no | `grid-template-rows` | `grid_template_rows` | | `auto-rows` | one track size | `auto` | no | `grid-auto-rows` | `grid_auto_rows` | | `auto-columns` | one track size (ADR-0160) | `auto` | no | `grid-auto-columns` | `grid_auto_columns` | | `areas` | rows of names of cells (ADR-0126) | none | no | `grid-template-areas` | `grid_template_areas` | | `auto-flow` | `row`, `column` | `row` | no | `grid-auto-flow` | `grid_auto_flow` | | `auto-flow` | `dense`, `row dense`, `column dense` (ADR-0126) | `row` | no | `grid-auto-flow` | `grid_auto_flow` | | `align` | `start`, `center`, `end`, `stretch` | `stretch` | no | `align-items` | `align_items` | | `justify-items` | `start`, `center`, `end`, `stretch` | `stretch` | no | `justify-items` | `justify_items` | | `justify` | as in 5.3 | `start` | no | `justify-content` | `justify_content` | A **track list** is a list of track sizes, separated by spaces. A track size is one of these: - a length, a percentage or a fraction (`1fr`) - `auto`, `min-content` or `max-content` - `minmax(<min>, <max>)` of two of the values above (the minimum cannot be a fraction) - `repeat(<n>, <track list>)` with an integer `n` - `repeat(auto-fill, <track list>)` or `repeat(auto-fit, <track list>)` (ADR-0126) A name of a line goes in brackets before or after a track: `[main]` (ADR-0126). **Named lines, areas, `auto-fill`, `auto-fit` and dense** (ADR-0126). The rules of CSS apply, and Taffy implements them: - `auto-fill` repeats the tracks as often as they fit. `auto-fit` does the same, and then the tracks with no item collapse to 0. - A track list has at most one `repeat()` with `auto-fill` or `auto-fit`. Each track of that list has a fixed size. A fixed size is a length, a percentage, or `minmax()` with a length or a percentage as one of its two values. - `areas` names the cells of the grid, one row after the other. Each row has the same number of cells. The cells of one name make a rectangle. A `.` is a cell with no name. An area that breaks these rules is a compile error. - An area `nav` also names its lines `nav-start` and `nav-end`. - `dense` fills a hole of the grid with a later item. So the order on the screen can be different from the order of the markup and of the focus. - Subgrid comes after version 1, when Taffy supports it. The utilities of `auto-flow` have the names of Tailwind 4: `grid-flow-row`, `grid-flow-col`, `grid-flow-dense`, `grid-flow-row-dense` and `grid-flow-col-dense` (section 12.4, ADR-0126). They replace the names `flow-row` and `flow-col` of version 0, which are gone. ### 5.6 Grid item (a child of a `Grid`) | Property | Values | Default | Inh. | CSS | Taffy | |---|---|---|---|---|---| | `column` | `<start>`, `<start> / <end>`, `span <n>`, `<start> / span <n>`. A line can be `<n> <name>`, and a span can be `span <name>` (ADR-0126) | automatic | no | `grid-column` | `grid_column` | | `row` | as `column` | automatic | no | `grid-row` | `grid_row` | | `area` | the name of an area of the parent (ADR-0126) | automatic | no | `grid-area` | `grid_row` and `grid_column`, with the lines of the area | | `align-self` | as in 5.4, without `baseline` | `auto` | no | `align-self` | `align_self` | | `justify-self` | `auto`, `start`, `center`, `end`, `stretch` | `auto` | no | `justify-self` | `justify_self` | Line numbers start at 1. Negative line numbers are not valid in v0. A line can also be a name (ADR-0126). The rules of CSS apply: - `col-main` starts at the first line with the name `main`, and it spans one track. - With the name of an area, `col-nav` spans the columns of the area `nav`, from `nav-start` to `nav-end`. - `area-nav` places the item in the rows and the columns of the area `nav`. - `col-2_main` starts at the second line with the name `main` (`2 main`). When the grid has fewer such lines, each implicit line counts as a line with the name, as in CSS. - `col-1_/_span_main` starts at line 1 and spans to the next line with the name `main` (`span main`). A span to a name with no line before it, for example `col-span-main` alone, is a span of one track, as in CSS. - An `area-<name>` with a name that no `grid-areas-` utility of the parent has, with or without a variant, is a compile error. The check applies only when the parent is in the same file. A parent in another file gets no check, for example a `Grid` around the root of a component. ### 5.7 Position and order | Property | Values | Default | Inh. | CSS | Taffy | |---|---|---|---|---|---| | `position` | `relative`, `absolute`, `sticky`, `fixed` | `relative` | no | `position` | `position`. `sticky` is `relative`, and the native scroll code moves the element when it paints (ADR-0128). `fixed` is `absolute`, with the root as its parent | | `direction` | `row`, `column` | by element | no | `flex-direction` | Taffy `flex_direction` | | `display` | `none` | by element | no | `display` | Taffy `Display::None`, and the renderer skips the element | | `pointer-events` | `none`, `auto` | `auto` | yes | `pointer-events` | a hit test skips an element with `none`. A child gets the value, and `auto` lets it take the pointer again (ADR-0160) | | `visibility` | `visible`, `hidden` | `visible` | yes | `visibility` | an element with `hidden` keeps its box, and it paints nothing of its own. It gets no pointer and no focus, and it is not in the accessibility tree. A child with `visible` paints (ADR-0160) | | `inset` | 1 to 4 lengths, %, or `auto` (top, right, bottom, left), and the logical sides of section 5.2 | `auto` | no | `top`, `right`, `bottom`, `left` | `inset` | | `z-index` | integer | `0` | no | `z-index` | none (paint order, section 9) | | `overflow` | `visible`, `hidden` | `visible` | no | `overflow` | `overflow` = `Hidden` for `hidden`, and the renderer clips | | `object-fit` | `fill`, `contain`, `cover`, `none` | `fill` | no | `object-fit` | how the picture of an `Image` fills its box | | `object-position` | `center`, `top`, `bottom`, `left`, `right`, `left top`, `right top`, `left bottom`, `right bottom` | `center` | no | `object-position` | the place of the picture of an `Image` in the free space of its box (ADR-0160) | | `mask` | `none`, `ellipse`, or the word fade and 4 lengths (top, right, bottom, left). A fade at the start edge or the end edge follows the direction (ADR-0124). Or a shape: `inset(<sides> round <radii>)`, `circle(<radius> at <x> <y>)`, `ellipse(<rx> <ry> at <x> <y>)` or `polygon(<x> <y>, ...)` (ADR-0145) | `none` | no | `mask-image`, and `clip-path` for a shape | the mask table of the renderer (ADR-0065): a rounded box, an ellipse, or a tile of coverage for a polygon | | `group` | `on` | none | no | the class `s-group` | the variants `group-<state>:` of the descendants look for the class | | `peer` | `on` | none | no | the class `s-peer` | the variants `peer-<state>:` of the later siblings look for the class (ADR-0160) | An absolute element does not take space in its parent. Its position comes from `inset`, relative to the padding box of the parent. A `fixed` element has its place in the window (ADR-0106): - It takes no space in its parent. It does not move with a `Scroll`, and a clip of an ancestor does not limit it. - It must set a place on both axes: `top-` or `bottom-`, and `inset-s-`, `inset-e-`, `left-` or `right-`, or `inset-` (ADR-0124). A missing place is a compile error, because a browser keeps the place in the flow. - An ancestor with a `transform` or a `filter` takes the place of the window, as in CSS (ADR-0106, ADR-0131). The filter applies to the whole group, so the `fixed` element paints in the layer of the group. - It paints in tree order (section 9). **The shapes of a mask** (ADR-0145). A shape of `mask` is a basic shape of CSS `clip-path`. The element and its children paint only inside the shape. - The box of the shape is the border box of the element, as the default box of `clip-path`. A percentage of x is a percentage of the width, and a percentage of y is a percentage of the height. - `inset(<sides>)` is a rectangle inside the box. The sides are 1 to 4 lengths or percentages, as `padding` has them: top, right, bottom, left. `round <radii>` gives the rectangle 1 to 4 corner radii, as `radius` has them. A radius is a length. - `circle(<radius>)` is a circle. The radius is a length, a percentage of sqrt(width² + height²) / sqrt(2), `closest-side` or `farthest-side`. A side is the distance from the center to the nearest or the farthest side of the box. - `ellipse(<rx> <ry>)` is an ellipse. `rx` is a length, a percentage of the width, `closest-side` or `farthest-side`, on the axis x. `ry` is the same on the axis y. - `at <x> <y>` gives the center of a circle or an ellipse. A value is a length, a percentage, or a word: `left`, `center` or `right` for x, and `top`, `center` or `bottom` for y. One word gives one axis, and the other axis is `center`. Without `at`, the center is the center of the box. - `polygon(<x> <y>, ...)` has 3 or more points. Each point is two lengths or percentages from the top left corner of the box. The polygon uses the rule `nonzero` of CSS. - A shape limits the hit area, as `clip-path` does in CSS: a press outside the shape does not reach the element or its children. `ellipse`, a fade and the attribute `mask` do not change the hit area (ADR-0065). - A shape can have a transition (section 5.10). Two shapes of the same kind mix each value, and two polygons with the same number of points mix each point. Another pair changes at the half of the time, as CSS does for a value that cannot mix. - An element with a shape is a group (section 9). A `sticky` element stays in the visible area of its scroll container (ADR-0128). The rules of CSS apply: - It takes its place in the flow, as a `relative` element does. Its move changes no other box. - Its scroll container is its nearest ancestor that is a `Scroll` or a `List`, or that has `overflow-hidden`. - An inset gives its distance from an edge of the visible area of the scroll container. For example, `top-0` keeps it at the top edge. Without an inset, it is `relative`. - It does not leave the content box of its parent. So the header of a section scrolls away with the end of its section. - It is a group (section 9), and it paints in tree order. A later sibling paints over it, unless the `sticky` element has a higher `z-`. - A hit test finds it at the place where it paints. ### 5.8 Visual | Property | Values | Default | Inh. | CSS | Native renderer | |---|---|---|---|---|---| | `background` | color | `transparent` | no | `background-color` | instance fill | | `background-image` | `none`, or a gradient over a picture: a gradient, `url(<path>)`, or both in this order. A gradient is `linear-gradient(<angle or side>, <stops>)`, `radial-gradient(<stops>)` or `conic-gradient(from <angle>, <stops>)` with two or three stops, each a color and a position (ADR-0127) | `none` | no | `background-image`: the gradient is the first layer, and the picture the last layer | a pipeline for each kind of gradient, and the picture of an `Image` in the box | | `background-size` | `auto`, `cover`, `contain` | `auto` | no | `background-size` | the scale of the picture | | `background-position` | `left top`, `center`, a side (`top`, `bottom`, `left`, `right`), or a corner (`right top`, `left bottom`, `right bottom`) | `left top` | no | `background-position` | the place of the picture | | `background-repeat` | `repeat`, `no-repeat` | `repeat` | no | `background-repeat` | tiles of the picture | | `border` | `<width> <color>` | `0 transparent` | no | `border: <width> solid <color>` | Taffy `border`, instance border | | `border-width` | `<top> <right> <bottom> <left> <color>`, and the logical sides of section 5.2 | none | no | `border-style`, `border-width`, `border-color` | Taffy `border`, one strip for each side | | `radius` | 1 to 4 lengths (top left, top right, bottom right, bottom left), and the logical corners of section 5.2 | `0` | no | `border-radius` | instance radii | | `shadow` | four layers in this order: the inset shadow, the inset ring, the ring and the shadow. Each layer is `<dx> <dy> <blur> <spread> <color>` with an optional spread, and the word inset comes before an inset layer. A layer without a value is `none`. With only the shadow layer, the value is that layer: `<dx> <dy> <blur> <color>`, or `none` (ADR-0127) | `none` | no | `box-shadow` | shadow instances | | `opacity` | number from 0 to 1 | `1` | no | `opacity` | layer (section 9) | | `transform` | a list of `translate(<x>, <y>)`, `scale(<s>)`, `rotate(<angle>)`, `skewX(<angle>)`, `skewY(<angle>)` (ADR-0160) | none | no | `transform` | layer (section 9) | | `transform-origin` | `center`, `top`, `bottom`, `left`, `right`, `left top`, `right top`, `left bottom`, `right bottom` | `center` | no | `transform-origin` | the point of the box that `scale`, `rotate` and the skew keep (ADR-0160) | | `filter` | a list of `grayscale(<n>)` from 0 to 1, `brightness(<n>)` of 0 or more, `blur(<length>)` | none | no | `filter` | a layer with a filter: a color matrix, and two passes of a Gaussian kernel for `blur` (ADR-0131) | | `backdrop-filter` | `none`, or `blur(<length>)` (ADR-0145) | `none` | no | `backdrop-filter`, and `-webkit-backdrop-filter` for older WebKit | a copy of the content behind the element in its box, with the blur of the filters | | `mix-blend-mode` | `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, `color-dodge`, `color-burn`, `hard-light`, `soft-light`, `difference`, `exclusion`, `hue`, `saturation`, `color`, `luminosity`, `plus-lighter` (ADR-0145) | `normal` | no | `mix-blend-mode` | a layer that the blend pipeline paints over a copy of its group | | `isolation` | `auto`, `isolate` (ADR-0145) | `auto` | no | `isolation` | the start of a group for the blend modes | Rules for visual properties: - Visual properties do not change the layout. A `transform` moves the painted element, not its layout box. The origin of `scale`, `rotate` and the skew is the center of the element, unless `transform-origin` gives another point (ADR-0160). - A change of a computed transform or a computed `opacity` runs no layout (ADR-0104). The browser changes one custom property or `opacity`. A native target changes the paint values of the element and runs no style pass, unless the element has a `transition`. - Borders are solid. Each corner radius is limited to half the shorter side of the element, as in CSS. - `filter` works on all targets, as the CSS property `filter` (ADR-0131). `grayscale` and `brightness` are a color matrix. `blur(<n>)` is a Gaussian blur with a standard deviation of n logical px. Only an app with a filter has the code of the filters. `web:blur-4` stays valid, but an app does not need it. - `backdrop-filter` blurs the content behind the element, inside the border box of the element with its radius (ADR-0145). The blur is a Gaussian blur with a standard deviation of n logical px, as `blur-`. Then the background, the border and the content of the element paint over it. - The content behind the element is the content that paints before it, back to its **backdrop root**. This is the rule of Filter Effects 2. The backdrop root is the nearest ancestor with `opacity` below 1, a `filter`, a mask, a `backdrop-filter` or a `mix-blend-mode`, or else the root. A `transform` of an ancestor is not a backdrop root, so the content behind a moved element still counts. - The blur reads only the inside of the border box. At the edge of the box, the blur reads the content as in a mirror, as the edge mode `mirror` of Filter Effects 2. So a color outside the box does not come into the box. - `mix-blend-mode` mixes the colors of the element with the content of its group behind it, with the formulas of Compositing and Blending 1. `plus-lighter` adds the two, with the formula of Compositing and Blending 2. A group is the nearest ancestor that is a group (section 9), or the root. The root starts transparent, as a page with no background. - `isolate` makes the element a group, so a blend mode in it mixes only with its content. `isolation-auto` removes this. - Only an app with a text shadow, a backdrop, a blend mode or a shape of a mask has their code (ADR-0145). **Shadow layers** (ADR-0127). - `shadow` has four layers: `shadow`, `inset-shadow`, `ring` and `inset-ring`. An element has at most one value in each layer. - Each layer has two parts: a size and a color. A color utility replaces the color of the size, as in Tailwind 4: `shadow-md shadow-$border-strong`. - The size of `shadow` and of `inset-shadow` is `<dx> <dy> <blur> <spread>`. The spread is optional, and its default is 0. A spread makes the shadow larger on each side before the blur, and a negative spread makes it smaller, as in CSS. - The size of `ring` and of `inset-ring` is a width. A ring is a shadow with no offset and no blur, and its spread is the width. The default color of a ring is the text color of the element, as in Tailwind 4. - `shadow` and `ring` paint outside the element, under its background. `inset-shadow` and `inset-ring` paint inside the border, over the background. Each layer follows the radius of the box. - The edge of a shadow is a Gaussian blur with a standard deviation of `blur / 2`, as CSS `box-shadow` defines it. - The web writes the four layers in one `box-shadow`. The order is the order of Tailwind 4: the inset shadow, the inset ring, the ring and the shadow. An earlier layer paints over a later layer, as in CSS. - In an app with an inset layer or a ring, the web writes each `box-shadow` with its four layers. A layer without a value is a shadow of size 0 with no color. So a transition mixes each layer with the same layer, also when a variant adds a ring. - A spread changes the corner radii as CSS Backgrounds 3 says. Each radius grows by the spread. A radius below the spread grows by the spread times 1 + (r / spread − 1)³. - A shadow and a ring paint only outside the box, as in CSS. So a box with a half-transparent background does not show the shadow under it. **Gradients** (ADR-0127). - `linear` takes an angle or a side: `bg-linear-45` or `bg-linear-to-r`. 0 degrees point up, and the angle turns clockwise, as in CSS. A side to a corner, for example `to-tr`, gives the angle of CSS: the line of the middle color goes through the two other corners. - `radial` is an ellipse at the center of the box, out to its farthest corner. `conic` turns around the center of the box, and it starts at its angle. The default angle is 0. These are the defaults of CSS. - A gradient has three stops: `from`, `via` and `to`. Each stop has a color and a position. Without a `via` color, the gradient has only `from` and `to`. `via-none` removes the `via` stop, for example in a variant: `hover:via-none`. - The directions of `linear` are physical, as in Tailwind 4 (ADR-0124). `bg-linear-to-r` goes to the right, also in an element with the direction `rtl`. - The default color of a stop is `transparent`. The default positions are 0 %, 50 % and 100 %, as in Tailwind 4. - A position is a percentage of the gradient line. For `conic`, it is a percentage of the turn. - The colors mix on premultiplied sRGB values, as in CSS and as in a transition (section 5.10). So a stop of `transparent` fades the color of the next stop with no gray. - A variant can change one stop, for example `hover:from-$accent` (section 12.3). - Stops without a kind paint nothing, as in Tailwind 4. - Each kind of gradient has a shader of its own in the CPU, Metal, Vulkan and Direct3D 11 renderers. Only an app with a kind of gradient has the code and the shader of that kind. - The parts of `background-image` and of `shadow` merge when two declarations both apply (section 12.3). So `{a}:ring-2 {b}:ring-$error` gives a ring of 2 px in the color of the error while `a` and `b` are both true. **Background images** (ADR-0127). - `bg-url(<path>)` paints a picture in the box. The path follows the rules of the `src` of an `Image` (component reference, section 4.5). It is a path in the build or a URL, with the same formats and the same load. On the web, the path in the stylesheet is relative to the stylesheet, which is next to the page. - A computed `bg-url({path})` gives the picture to each rule of the element that sets `background-image`. So a gradient of a rule paints over it, and `bg-none` of a rule removes it. On the web, the element has the picture in the custom property `--surf-url`. - The picture has the size of an `Image`: one pixel of the file for each logical px. This is the default, and `bg-auto` sets it. `bg-cover` and `bg-contain` scale it as `object-cover` and `object-contain` scale an `Image`. - The picture starts at the top left corner, and `bg-top-left` sets this default. `bg-center` puts it in the middle of the box. A side, for example `bg-top`, puts it at the middle of that side. A corner, for example `bg-bottom-right`, puts it in that corner. These are the positions of CSS. They are physical, as in Tailwind 4. - `bg-repeat` repeats the picture, and this is the default. `bg-no-repeat` paints it one time. - `bg-none` removes the gradient and the picture, as `background-image: none` does in Tailwind 4. A variant can turn them off in this way: `md:bg-none`. - `background-size`, `background-position` and `background-repeat` apply to the picture and to the gradient, as one value of CSS applies to each layer. A gradient has no size of its own, so it always fills the padding box. - The padding box places the picture and the gradient, and the border box is their paint area. So a repeated picture or gradient also paints under the border. The radius of the box clips them. These are the defaults of CSS. - The color paints first. The picture paints over the color, and the gradient paints over the picture. - A background image is decoration. It has no alt text, and a screen reader does not find it. A picture with a meaning is an `Image` with `alt`. ### 5.9 Text (`Text`, and the labels of `Button`, `Toggle`, `Select`) | Property | Values | Default | Inh. | CSS | Native | |---|---|---|---|---|---| | `font` | `sans`, `mono`, or a font of the theme | `sans` | yes | `font-family: system-ui`, or `ui-monospace, monospace` (only Safari knows `ui-monospace`) | the platform system font or monospaced font | | `font-size` | length | `$font-size` (theme, 15 by default) | yes | `font-size` | `surf-text` size | | `font-weight` | integer from 100 to 900 | `400` | yes | `font-weight` | `wght` axis | | `line-height` | `normal`, or a number that multiplies the font size | `normal` | yes | `line-height` (unitless) | section 7 | | `color` | color | `$text` (theme) | yes | `color` | glyph fill | | `text-align` | `start`, `center`, `end` | `start` | yes | `text-align` | line offset in the text box. `start` is the side where the paragraph starts | | `font-style` | `normal`, `italic` | `normal` | yes | `font-style` | the italic face of the font: the italic file of the system font, or the `ital` or `slnt` axis. A font with no italic face gets a slant of 14 degrees, the default angle of `oblique` in CSS (ADR-0068, ADR-0131) | | `text-decoration` | `none`, `underline`, `line-through` | `none` | yes | `text-decoration-line` | a line from the metrics of the font | | `text-transform` | `none`, `uppercase`, `lowercase`, `capitalize` | `none` | yes | `text-transform` | the case of the shown text. A text control is not affected. | | `overflow-wrap` | `normal`, `anywhere` | `normal` | yes | `overflow-wrap` | a word that does not fit on a line breaks between two characters | | `dir` | `auto`, `ltr`, `rtl` | `auto` | yes | `direction`, and `unicode-bidi: plaintext` for `auto`. `ltr` and `rtl` also write the attribute `dir` of HTML on the element (section 10, ADR-0124) | the direction of each paragraph (ADR-0066). `auto` takes it from the first strong character. `ltr` and `rtl` also mirror flex rows and grid columns, with the `direction` of Taffy. The physical sides do not change, and the logical sides follow `dir` (section 5.2, ADR-0124). | | `text-wrap` | `wrap`, `none` | `wrap` | yes | `white-space: normal` or `nowrap` | line breaking on or off | | `font-variant-numeric` | `normal`, `tabular-nums` | `normal` | yes | the same | the OpenType feature `tnum`: all digits of the font have the same width, so a number that changes does not move. A font without `tnum` keeps its digits. | | `font-stretch` | `ultra-condensed`, `extra-condensed`, `condensed`, `semi-condensed`, `normal`, `semi-expanded`, `expanded`, `extra-expanded`, `ultra-expanded`, or a percentage from 50 % to 200 % | `normal` | yes | `font-stretch` | the axis `wdth` of a variable font. A font without the axis keeps its width (ADR-0160) | | `text-decoration-thickness` | number of logical px | the value of the font | yes | `text-decoration-thickness`, with `inherit` for each element in an app with it | the thickness of an underline and of a line through the text (ADR-0160) | | `text-underline-offset` | number of logical px | the value of the font | yes | `text-underline-offset` | the place of the top of an underline below the baseline (ADR-0160) | | `text-indent` (not in version 0) | length | `0` | yes | `text-indent` | the first line starts after the indent, on its start side. Not in version 0: the native targets have no indent (ADR-0160) | | `caret-color` | color | the text color | yes | `caret-color` | the caret of a text control (ADR-0160) | | `accent-color` | color | `$accent` | yes | `accent-color` | the box of a `Toggle` and the part of a `Slider` before its knob (ADR-0160) | | `placeholder-color` | color | `$text-secondary` | no | `color` of `::placeholder` | the placeholder of a text control (ADR-0160) | | `letter-spacing` | length | `0` | yes | `letter-spacing` | `surf-text` adds it after each glyph | | `max-lines` | integer, `none` | `none` | no | `-webkit-line-clamp` with `display: -webkit-box` | lines after the limit are cut, and the last line ends with "…" | | `field-sizing` | `content` | none | no | `field-sizing` on the `<select>` | a `Select` is as wide as its chosen option, not as its widest option | | `text-shadow` | `none`, or a list of layers `<dx> <dy> <blur> <color>`, separated by commas (ADR-0145) | `none` | yes | `text-shadow` | for each layer, a layer of the text with a filter: the color of the shadow and the blur | **Text shadows** (ADR-0145). - `text-shadow` paints a copy of the text under the text for each layer, as in CSS. The copy has the color of the layer, the offset `<dx> <dy>` and a Gaussian blur with a standard deviation of `blur / 2`, as CSS defines it. The first layer paints over the others. - A shadow is the shape of the glyphs: a color glyph gives a shadow of its shape in the color of the layer. The lines of `underline` and `line-through` have a shadow too. A background of a `Span` has no shadow. - `text-shadow` has two parts: the size and the color. The color part replaces the color of each layer of the size of the same element, as in Tailwind 4: `text-shadow-md text-shadow-$accent`. The parts merge when two declarations both apply (section 12.3). - The value inherits, with the color of the element that has the size. A color part alone changes nothing. - The text of `Input` and `TextArea` has the shadow too, as in CSS. A selection and the caret have no shadow. ### 5.10 Motion | Property | Values | Default | Inh. | CSS | |---|---|---|---|---| | `transition` | `<property> <duration> <easing> <delay>`, separated by commas. The delay is optional | none | no | `transition` | | `animation` | `<name> <duration> <easing> <rounds>`, or `none` (ADR-0049) | none | no | `animation` | | `cursor` | `default`, `pointer`, `text`, `grab`, `grabbing`, `ns-resize`, `ew-resize`, `nwse-resize`, `none`, `context-menu`, `help`, `progress`, `wait`, `cell`, `crosshair`, `vertical-text`, `alias`, `copy`, `move`, `no-drop`, `not-allowed`, `all-scroll`, `col-resize`, `row-resize`, `n-resize`, `e-resize`, `s-resize`, `w-resize`, `ne-resize`, `nw-resize`, `se-resize`, `sw-resize`, `nesw-resize`, `zoom-in`, `zoom-out` (ADR-0160) | `default` | no | `cursor` | | `user-select` | `none`, `text` (ADR-0131) | `none` | yes | `user-select`, and `-webkit-user-select` for WebKit | | `animation-delay` | a time in ms (ADR-0150) | `0` | no | `animation-delay` | | `animation-timeline` | `auto`, `scroll`, `view`, each on the axis `y` or `x`, or a name (ADR-0151) | `auto` | no | `animation-timeline` | | `animation-range` | a start and an end on the timeline (ADR-0151) | `normal` | no | `animation-range` | | `scroll-timeline` | a name and an axis, on a `Scroll` or a `List` (ADR-0151) | none | no | `scroll-timeline` | | `view-timeline` | a name and an axis (ADR-0151) | none | no | `view-timeline` | | `cue` | the name of a cue of a sequence (language spec 7.5a, ADR-0152) | none | no | the animations of the cue | | `split` | `none`, `lines`, `words`, `letters`, and a clip of each part, on a `Text` or a `Span` (ADR-0153) | `none` | no | the parts of the runtime | | `view-transition-name` | a name (wave spec 7, ADR-0154) | none | no | `view-transition-name` | The utilities give all properties of one `transition` the same duration, the same easing and the same delay. Only `opacity`, `transform`, `background`, `color`, `shadow`, the color of `border` and the shape of `mask` can have a transition. `transition` names the first six. The shape of `mask` needs `transition-mask`, as `transition` of Tailwind 4 does not name `clip-path` (ADR-0145). A fade of `mask` changes at the half of the time, as `mask-image` in CSS. Colors change on premultiplied sRGB values. A transition of `shadow` changes its four layers (section 5.8). A layer with no value is then a shadow of size 0 with the color `transparent`, as in CSS. A gradient and a background image have no transition, as in CSS (ADR-0127). A transition never changes the layout during the animation. All targets follow the rules of CSS for a change during an animation (ADR-0035). A new end value starts at the shown value, and a change back takes as long as the way there. **A theme change and a press during a transition** (ADR-0131). A change of the theme and a new window width animate each property with a `transition`, as each other change of a style does. A press hits the shown position of a transform, not its end position. Not in version 0: a theme change and a new window width apply at once on the native targets. **Easings.** An easing is one of these (ADR-0104): - `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`, or `cubic-bezier(a, b, c, d)`. `a` and `c` are numbers from 0 to 1. - A named curve: `ease-in-<name>`, `ease-out-<name>` or `ease-in-out-<name>`. The names are `sine`, `quad`, `cubic`, `quart`, `quint`, `expo`, `circ` and `back`. The compiler replaces each name with its `cubic-bezier`. - `spring(<bounce>)`: a spring. `<bounce>` is a number from 0 to 0.8. With 0, the value does not go past its end. A higher number goes past the end and comes back more times. The utility `ease-spring` has the bounce 0.25. A spring is a curve over the time of its transition, as the other easings are: - With the time `t` from 0 to 1, `k = 9.24`, `z = 1 - bounce`, `w = k / z` and `d = w * sqrt(1 - z * z)`, the progress is `1 - exp(-k * t) * (cos(d * t) + (k / d) * sin(d * t))`. - With the bounce 0, the progress is `1 - exp(-k * t) * (1 + k * t)`. - The curve is 0.1 % or less from its end at `t = 1`, and the progress at `t = 1` is exactly 1. - The compiler computes the curve as a list of `linear()` stops, with enough stops for an error of 1 % or less (ADR-0131). Each target uses these stops. The web writes them as a CSS `linear()` function. The native targets read them with the rule of `linear()`. A browser without `linear()` uses a `cubic-bezier` with a similar shape. - A `motion` value keeps the formula on each target. - A new end value during a spring starts at the shown value with a speed of 0, as with the other easings. Only a `motion` value keeps its speed (language spec 7.5). **Delay.** During its delay, a property shows its old value. A delay applies each time that the property gets a new end value. A change back keeps the delay, as in CSS. **The delay and the fill of an animation** (ADR-0150). - `animate-delay-<ms>` holds back the first round of an `animation`, as `animation-delay` of CSS. The default is 0. A computed value works too: `animate-delay-{i * 150}`. - An animation shows its first stop during its delay, and its last stop after its last round. This is the fill `both` of CSS. Without a stop at 0 or at 100, that stop is the style of the element (section 2.1). No utility changes the fill. - The delay starts again each time that the animation starts again, for example when its condition changes from false to true. - A name of `keyframes` cannot start with `delay-`, `timeline-`, `range-`, `duration-`, `ease-` or `count-` (section 2.1). - An animation that has ended uses no frames. **Animations that follow the scroll** (ADR-0151). An animation runs on a **timeline**. The default timeline is the time. `animate-timeline-` gives the animation a scroll position as its timeline, as `animation-timeline` of CSS. The progress of the animation then follows the scroll position in both directions. ```surf <Scroll style="h-100vh"> <Text style="animate-rise animate-timeline-view animate-range-entry">Hello</Text> </Scroll> ``` | Utility | Timeline | CSS | |---|---|---| | `animate-timeline-scroll`, `animate-timeline-scroll-x` | The scroll position of the scroll container of the element, on the axis `y` or `x` | `scroll(nearest y)`, `scroll(nearest x)` | | `animate-timeline-view`, `animate-timeline-view-x` | The place of the element in the visible area of its scroll container | `view(y)`, `view(x)` | | `animate-timeline-<name>` | A timeline that an element of the same file names with `scroll-timeline-<name>` or `view-timeline-<name>` | a dashed name, and `timeline-scope` | | `animate-timeline-auto` | The time, for a variant | `auto` | - The scroll container of a timeline is the nearest ancestor that is a `Scroll` or a `List`, or that has `overflow-hidden`. This is the rule of `sticky` (section 5.7) and of CSS. - `scroll-timeline-<name>` goes on a `Scroll` or a `List`, and it names its scroll position. `view-timeline-<name>` goes on an element with a box, and it names its place. The axis is `y`. `scroll-timeline-x-<name>` and `view-timeline-x-<name>` give the axis `x`. A timeline name takes no variant and no condition. - A name has letters, digits and `-`. It cannot be `auto`, `scroll` or `view`, and it cannot start with `x-`. One element of the file gives the name, and only elements of the same file use it. - The element with the name and each use need a common ancestor element in the file. A `#for` or a `List` around one of them must also be around the other, so each row has its own timeline. Each other case is a compile error. - `animate-range-<v>` gives the start and the end of the animation on the timeline, as `animation-range` of CSS. Without it, the animation uses the whole timeline. - `animate-range-<name>` is the whole named range: `animate-range-entry`. - `animate-range-<start>_<end>` gives the two ends. Each end is a range name with an offset, or an offset alone: `animate-range-entry_25%_cover_50%`, `animate-range-0_400`. - An offset is a percentage of the named range, or a length from its start. A number is a length in logical px. An offset alone counts from the start of the whole timeline. - The named ranges of a view timeline are the ranges of CSS. `o` is the scroll offset, and `V` is the size of the visible area on the axis. `S` and `E` are the start edge and the end edge of the layout box of the element, in the content of the scroll container: | Range | Start | End | |---|---|---| | `cover` | `S - V` | `E` | | `contain` | `min(S, E - V)` | `max(S, E - V)` | | `entry` | `S - V` | `min(S, E - V)` | | `exit` | `max(S, E - V)` | `E` | | `entry-crossing` | `S - V` | `E - V` | | `exit-crossing` | `S` | `E` | - The progress is `(o - start) / (end - start)`. The own transform of the element does not change `S` and `E`. Before the start, the element shows the first stop, and after the end, it shows the last stop. - A scroll timeline goes from the offset 0 to the largest offset. A percentage is a part of that distance. A named range on a scroll timeline is a compile error. A container with no overflow gives no progress, so the element shows its own style, as in CSS. - An animation on a scroll timeline has one round. `animate-count-`, `animate-infinite`, `animate-duration-` and `animate-delay-` are compile errors with it. The easing of the keyframes applies between the stops, as on the time. - **Scrub.** The progress follows the scroll position at once, with no smoothing, as in CSS. - **Pin.** A pinned element is a `sticky` element in a tall parent, with no utility of its own. The view timeline of the parent with the range `contain` gives the progress while the element stays in place: ```surf <Scroll style="h-100vh"> <Column style="h-300vh view-timeline-hero"> <Stack style="sticky top-0 h-100vh"> <Image src="assets/car.png" alt="A car" style="animate-zoom animate-timeline-hero animate-range-contain" /> </Stack> </Column> </Scroll> ``` - `on:scroll` gives the position to the program, for example to load more rows. An animation that follows the scroll uses a timeline, so it needs no state and no batch. - The web writes the CSS of the table. A browser without `animation-timeline` gets the same progress from the runtime: `scrollTimelines` in `surf.js` sets the time of each such animation after each scroll and each new size. Only an app with such an animation has this code. - The native targets compute the progress when they paint, as for `sticky` (section 5.7). A scroll paints again, and it runs no layout and no style pass. The hit test finds the element at its shown place. The code is behind an install line. Not in version 0: the hit test at the shown place on the native targets. There, a press hits the layout box, as for a transform (ADR-0131). - With the setting of reduced motion on, an animation on a scroll timeline does not run, and the element shows its own style. **Sequences** (language spec 7.5a, ADR-0152). - `cue-<name>` makes the element play a cue of a sequence of its component. The cue gives the keyframes, the time, the easing and the place on the timeline of the sequence. - An element with `cue-` cannot have `animate-<name>`, `animate-delay-`, `animate-timeline-` or `animate-range-`. A second `cue-` utility, and a `cue-` with a variant or a condition, are compile errors. - The web plays each cue as a CSS animation. The runtime writes the delay and the time of each element, or its part of the range on a scroll timeline. It turns the direction with the Web Animations API. The native targets run the cues in `animate.rs`, with one clock for each sequence. - On the web, each animation of a sequence has the length of the sequence. It has a delay before the start of its element, and an end delay after its end. So all animations of a sequence turn together. A browser without `animation-timeline` gets the part of the range of each element in `--surf-slice` (`scrollTimelines` in `surf.js`). - With the setting of reduced motion on, the stylesheet of the web removes each animation. So the runtime gives each element of a cue with `play` its first stop or its last stop. It writes an animation that does not run into the style attribute of the element. **Split text** (ADR-0153). ```surf <Text style="text-48 font-700 split-letters animate-rise">One universal best option</Text> ``` - `split-lines`, `split-words` and `split-letters` go on a `Text` or a `Span`. They divide its text into parts: - A letter is an extended grapheme cluster of Unicode (UAX #29). White space is not a part. - A word comes from the word boundaries of UAX #29. The web uses `Intl.Segmenter` with the granularity `word`. The native targets and the interpreter use the word break iterator of the ICU of the system (ADR-0132). - A line is the text of one line of the paragraph, after the layout. A new width or a new font gives new lines. - The words: - A segment of white space is not a part. - A segment that is not like a word, for example punctuation, a symbol or an emoji, belongs to the part before it. So `Hello,` is one part. At the start of the text and after white space, it belongs to the part after it. With white space on both sides, it is a part of its own. - A segment is like a word when it has letters, digits or ideographs: `isWordLike` of `Intl.Segmenter`, and the rule status of a word in the break iterator of ICU. - Both segmenters get the `lang` of the element or of its nearest ancestor with `lang`. Without a `lang`, they get no language. - Chinese, Japanese, Thai, Lao, Khmer and Burmese need a dictionary for their word boundaries. The ICU of each system and of each browser has these dictionaries, so an app carries none. - The parts have the order of the text, also in right-to-left text. A part can hold the runs of several `Span` elements. In a `Span` with a split, only the text of the `Span` has parts. - These utilities of the element apply to each part, not to the element. Each other utility stays on the element, for example the color, the font, the padding, the background, the shadow and the filters. | Kind | Utilities that apply to each part | |---|---| | Transform | `translate-x-`, `translate-y-`, `scale-` and `rotate-`, also with `-` before them and with a computed value | | Opacity | `opacity-`, also with a computed value | | Transition | `transition`, `transition-opacity`, `transition-transform`, `transition-color`, `duration-`, `delay-` and the easings of section 12.4 | | Animation | `animate-<name>`, `animate-none`, `animate-duration-`, `animate-ease-`, `animate-count-`, `animate-infinite`, `animate-delay-`, `animate-timeline-` and `animate-range-` | | Sequence | `cue-<name>` | | Clip | `split-clip` | - A variant or a condition of these utilities applies too. A state such as `hover:` is the state of the element, not of a part. - Each part turns and scales around the center of its own box. - A part paints only its glyphs, so it changes only `opacity`, `transform` and the text color. The keyframes of an animation or of a cue on a split text can set only these. `transition-background`, `transition-shadow` and `transition-border` on a split text are compile errors. - A part takes its text color from the element. The transition of a part animates a new color of the element. - A `Span` with a split takes the utilities of the table. A `Span` without a split keeps the utilities of text only (ADR-0111). - `split-clip` clips each part to its own box. A part that moves up from under the edge of its line then comes into view. It is valid only on an element with a split, and it takes no variant. - The stagger of a cue gives each part its own start (language spec 7.5a). Without a cue, all parts play at the same time. - A split changes no box. The text keeps its layout, its line breaks and its line height. Each part paints its glyphs at their place in the text. The box of a part has the height of its line, from the start of its first glyph to the end of its last glyph. - `split-letters` turns off the ligatures of the text, on all targets. Each letter is then shaped alone. A script that joins its letters, for example Arabic, then shows each letter in its isolated form. Use `split-words` for such a script. - A screen reader reads a split text as one text, on all targets. The parts are not nodes of the accessibility tree. The selection, the copy and the search of a browser use the text, not the parts. - A part gets no events. A press goes to the text at its place in the layout, also while a part moves. - A split in a split is a compile error. The `split-` utilities take no variant and no condition. - The web keeps the text in its element and paints it transparent. The runtime puts a copy of the parts over it, with `aria-hidden="true"` and `pointer-events: none`. With `split-clip`, each part of the copy is in a box with `overflow: clip`. It measures each part with the rectangles of the text. It measures again after a new size, a new font or a new text (`splitText` in `surf.js`). - The native targets take the parts from the paragraph (section 7.1), and they paint each part with its own transform, opacity and color. With `split-clip`, each part gets a clip rectangle. Only an app with a split has this code, and only an app with `split-words` has the word segmenter. - With the setting of reduced motion on, the parts do not move. - Not in version 0: a split on a `Span`, the underline and the line through of the parts, and a computed `opacity-` of a split text on the web. The checker takes a split on a `Span`, but only the parts of a `Text` paint. **Page transitions** (wave spec 7, ADR-0154). - `view-transition-<name>` gives an element a transition name. The old page and the new page can each show one element with the name. That element then moves from its old box to its new box, and its old picture and its new picture cross-fade. A computed name works too: `view-transition-{name}`. - A name has the scope of the window. Two shown elements with one name stop the transition, and the new page shows at once, as in CSS. - A fixed name in a row of a `#for` or of a `List` is a compile error, because each row then has the same name. - The web writes `view-transition-name`. The native targets keep a picture of each named element (wave spec 7). **Reduced motion.** Each target obeys the setting of the system (ADR-0104): - With the setting on, a `transition`, an `animation`, an enter, an exit, a move and a stagger do not run. Each element shows its end value at once. A `motion` value goes to its target at once. - The web target reads `prefers-reduced-motion`. macOS reads the display setting of the accessibility settings. Linux reads `enable-animations` of `org.gnome.desktop.interface` through the settings portal (ADR-0131). Without a portal, Linux reads the settings of GNOME, GTK and KDE at the start of the app. - A change of the setting applies at once on each target (ADR-0131). On Linux, the settings portal gives `enable-animations` of GNOME and `AnimationDurationFactor` of the group `KDE` of `kdeglobals` (`org.kde.kdeglobals.KDE`). A factor of 0 turns off the animations. - An app does not write a rule for this. **Text selection** (ADR-0131). - Text is not selectable by default, on all targets. `select-text` makes the text of an element and of its children selectable. `select-none` turns it off again for a part, for example for a `Text` in a `Column` with `select-text`. - In selectable text, a drag selects text, also across the parts of a `Text` and across `Text` elements. A selection does not hold the text of a part with `select-none`. A double click selects a word, and a triple click selects a paragraph. A drag after a double click or a triple click selects whole words or paragraphs. Shift and a click, or Shift and an arrow key, move the moving end of the selection. With Alt on macOS, and with Control on the other systems, an arrow key moves it by a word. - The copy key of the system copies the selected text: Command-C on macOS, Control-C on Linux, and Control-C or Control-Insert on Windows. A line break separates the text of two `Text` elements. The copy has the text that shows, after `text-transform`. - A press on content that is not selectable keeps the selection, as CSS UI 4 says. A press in a text control removes the selection, because the control has its own selection. - A screen reader reads the selection: on the native targets, the `Text` has the text runs and the text range of AccessKit. - The text of an `Input` and of a `TextArea` stays selectable on all targets, also with `select-none`. CSS UI 4 gives an editable element the used value `contain`. So `select-none` on a field has no effect, and `surf lint` gives the finding `field-select` for it. `selectOnFocus` selects the text of the field, also with `select-none` (component reference 4.7). - The web writes `-webkit-user-select: none` and `user-select: none` as the default, on the element of the app. `select-text` writes `user-select: text` (section 10). `Input` and `TextArea` get `user-select: text` with `!important`, so that each engine keeps the selection of a field. - The engines differ in the copy and in the keys of a selection, so the runtime of the web writes the copy and moves the moving end with Shift and an arrow key itself, in an app with `select-text` (`selectText` in `surf.js`). - Only an app with `select-text` has the code of the selection. `select-none` alone links no code. ### 5.11 Paint of shapes (`Drawing`, ADR-0029) | Property | Values | Default | Inh. | CSS | Native | |---|---|---|---|---|---| | `fill` | color, or `none` | the text color. A line and a polyline have no fill | yes | `fill` | coverage mask | | `stroke` | color, or `none` | `none` | yes | `stroke` | coverage mask of the outline of the stroke | | `stroke-width` | number of 0 or more, in view units | `1` | yes | `stroke-width` | | | `stroke-dasharray` | lengths in view units, or `none` | no dashes | yes | `stroke-dasharray` | the dashes cut the outline first | | `stroke-linecap` | `butt`, `round`, `square` | `butt` | yes | `stroke-linecap` | | | `stroke-linejoin` | `miter`, `round`, `bevel` | `miter` | yes | `stroke-linejoin` | a miter longer than 4 stroke widths is a bevel | The paint properties change no box. The fill rule is non-zero. A list of dashes with an odd count repeats, as in SVG. ### 5.12 Scroll snapping (`Scroll`, `List` and their children, ADR-0128) | Property | Values | Default | Inh. | CSS | Native | |---|---|---|---|---|---| | `scroll-snap-type` | `none`, or an axis (`x`, `y`, `both`) and a strictness (`mandatory`, `proximity`) | `none` | no | `scroll-snap-type` | the scroll code of the runtime | | `scroll-snap-align` | `none`, `start`, `center`, `end` | `none` | no | `scroll-snap-align` | a snap point of the scroll container | | `scroll-snap-stop` | `normal`, `always` | `normal` | no | `scroll-snap-stop` | a snap point that a gesture does not pass | The rules of CSS apply: - `scroll-snap-type` goes on the scroll container. It has two parts: the axis and the strictness. The default strictness is `proximity`, as in CSS and in Tailwind 4. - A child with `snap-start`, `snap-center` or `snap-end` is a snap point. At the snap point, the start edge, the center or the end edge of the child is at the same place in the visible area. - With `mandatory`, the content always comes to rest at a snap point. With `proximity`, it comes to rest at a snap point only when one is near. - CSS lets each browser choose the distance of `proximity`. WebKit and Firefox snap when a snap point is nearer than 0.3 of the size of the scrollport. macOS and Linux have no system setting for this distance. So the native targets use the same ratio (ADR-0128). There, a snap point is near when its distance is less than 0.3 of the size of the scroll container in that axis. - `snap-none`, `snap-align-none` and `snap-normal` set the defaults again: no snapping, no snap point, and a snap point that a gesture can pass. A variant can turn a feature off in this way: `md:snap-none`. - `snap-always` on a child stops a scroll gesture at that child, so the gesture does not pass it. - On the web, the browser snaps, and the runtime snaps a wheel where the engines differ (see below). On the native targets, the runtime moves the content to the nearest snap point when a scroll gesture ends. - A snap area is an element below the scroll container, also deeper than a child, whose nearest scroll container it is, as in CSS. A snap area that is larger than the visible area is valid at each position at which it covers that area. A snap area that does not show on the other axis is not a candidate. With `snap-both`, each axis snaps alone. - After a layout pass, a scroll container goes back to the snap area at which it rested, as in CSS. Without such an area, a container with `snap-mandatory` goes to its nearest snap position. On the native targets, the shell of each system tells the runtime when a scroll gesture ends. The table gives the end for each input, after the browsers. Then the content moves to its snap position in 250 ms, with the easing `ease-out-cubic`. With reduced motion, it moves at once. | Input | End of the gesture | Snap position | |---|---|---| | A wheel with steps | Each step ends its gesture at once. | From the snap points ahead of the start in the direction of the step, the point nearest to the end of the step. This is the rule of CSS Scroll Snap 1 (section 6.2), and Chromium and Gecko use it. | | A trackpad on macOS | The fingers leave the trackpad, or its momentum ends. The runtime waits 100 ms for momentum, as Chromium does. | The nearest snap point. | | A touchpad on Linux (Wayland) | The event `axis_stop` of the compositor, when the fingers leave the touchpad. The shell has no momentum. The runtime waits 100 ms. | The nearest snap point. | | A device without phases: a precise touchpad on Windows, a wheel without steps | 100 ms after the last scroll. | The nearest snap point. | | The keys | Each key ends its gesture at once, as a step of a wheel. Not in version 0: on the native targets, a scroll of a key does not snap (ADR-0131). | The nearest snap point in the direction of the key. | | A scroll bar | The native targets have no scroll bar. | | A gesture does not pass a snap point with `snap-always`. Its start is the offset at the first scroll of the gesture. A scroll of the program, for example a scroll of the focus into view, ends no gesture. The next layout pass then snaps a container with `snap-mandatory`. On the web, the browser snaps with the CSS of the stylesheet. The engines differ at a wheel event in pixels. The browser tests measured these results on 2026-11-06, and the source code of each engine gives the cause: - Chromium snaps a step of a wheel in its direction. It takes the wheel event of the browser tests as a precise scroll, as from a trackpad, and snaps it to the nearest snap point. - WebKit goes to the first snap point after the end of the step. On macOS, it first scrolls by the whole step, and it starts the snap 100 ms later. So a step can pass the snap point nearest to its end, and also a snap point with `snap-always`. - Gecko before version 150 does not snap after a wheel event in pixels (Mozilla bug 2008571). The browser tests send each wheel event in pixels. A wheel in lines snaps in the direction of the step, with the rule of the table. The web gives no phase of a wheel event, so the runtime cannot tell a step of a wheel from a move on a trackpad. An app with scroll snapping gets the snap of a wheel in the runtime. It acts only in these two cases. In the first case, a step and a move give the same snap point. In the second case, the browser did not snap: 1. A wheel event starts a gesture and passes the snap point of a step. This point is the first point with `snap-always` that the step passes, or the point nearest to its end. Then the runtime stops the scroll of the browser and moves the content to that point. 2. A gesture of one wheel event does not rest at a snap point 200 ms after its last scroll. Then the runtime moves the content to the snap point of a step. WebKit on macOS starts its own snap 100 ms after a step, so the runtime waits longer. A wheel event 100 ms or less after the last one is a part of the same gesture, as on a trackpad. The content moves with the smooth scroll of the browser, and with reduced motion it moves at once. Each other gesture keeps the snap of the browser, also a trackpad and a touch. So a gesture of one wheel event that passes no snap point can still come to rest at another point in each engine (`docs/native-differences.md`). --- ## 6. Layout algorithms - **Flex** follows CSS Flexible Box Layout Level 1, with the defaults of sections 3 and 5. `order` and `flex-flow` are not in v0. The reverse directions never come (ADR-0129). Baseline alignment uses the first baseline of each item. - **Grid** follows CSS Grid Layout Level 1 for the features of sections 5.5 and 5.6. - **Absolute** elements are placed after their parent's layout, from `inset`, `width` and `height`. - **Leaves** (text, controls, images) give their size through a measure function (section 7). The native runtime calls Taffy for one subtree and keeps the results of unchanged subtrees. A change of a style property marks only the element and its ancestors for layout (plan, section 8.1). --- ## 7. Text measurement A `Text` element is a leaf. Its measure function answers three questions from the layout engine: 1. **Max-content width**: the width of the text without line breaks. 2. **Min-content width**: the width of the widest word. 3. **Height for a width**: the number of lines at that width, multiplied by the line height. Rules: - **Line height** `normal` is the ascent, plus the descent, plus the line gap of the font, from the `hhea` table of the font. A number multiplies the font size. A line height in px is not in v0, because a number without a unit is a length everywhere else (section 2). - The text measure rounds each of the three metrics of `normal` to whole logical px before the addition, as WebKit and Chromium do (ADR-0131). A line box then has a height of whole px, so at the scale 1 each baseline is on a whole pixel. - **Line breaks** are at break opportunities of the Unicode line breaking algorithm (UAX #14). This applies to all text on all targets, also to ASCII text (ADR-0131). So a line can end after a hyphen. - **Tracking**: text has the tracking of the `trak` table of its font for its font size. The font size is also the optical size (`opsz`) of the font. A font without `trak` has no tracking. `letter-spacing` adds to this tracking (ADR-0131). - **Baseline**: the first baseline is the ascent of the first line, plus half of the extra line height (`(line height − ascent − descent) / 2`), as in CSS. - The web target uses the text engine of the browser, and the native target uses `surf-text`. The two engines can give different glyph widths for the same font. Section 11 explains how the conformance tests handle this. ### 7.1 A paragraph of several runs A `Text` with a `Span` or a `Link` in it is one paragraph of several runs (ADR-0111). A run is the text of one element with its style. The native targets use these rules, which are the rules of inline boxes in CSS: - The text of all runs is one text. The line breaks follow the rules above for the whole text, so a line can break inside a `Span`. A part of a run is not a break opportunity. - The native targets shape the runs of one font together. A change of the color, the background or the line of a run does not change a width. - Each line has the height of its inline boxes on one baseline: the `Text` itself, and each element with text on the line. An inline box is as high as its `line-height`. Half of the extra height is above its ascent, and half is below its descent. So a large `Span` makes its line taller, and a `leading-` of the `Text` also applies to its parts. - A space at the end of a wrapped line hangs. It takes no width for the line and for the alignment, and it stays in the box of its element. - The box of a part is the box of its text on each line. It goes from the ascent of its font above the baseline to the descent below the baseline. On more than one line, the box of the element holds all of them. - The min-content width is the width of the widest word of the whole text. - The background of a part paints behind the glyphs of its line. --- ## 8. Pixel snapping - Layout works in logical px with `f32` values. - Before painting, each edge is rounded to the nearest device pixel. The runtime rounds the absolute position of each edge, not the size, so that adjacent elements do not get gaps or overlaps. The width of an element is `round(x + width) − round(x)`. - Taffy does this with its rounding mode. Browsers snap at paint time in the same way. --- ## 9. Paint order - Elements paint in tree order: a parent before its children, and siblings in markup order. - `z-index` changes the order only among siblings. A higher value paints later. Siblings with the same value keep markup order. - The hit test uses the same order: the last element in paint order under the point gets it (ADR-0128). - An element with `z-index` other than 0, with `opacity` less than 1, with a `transform`, with `sticky` (ADR-0128), with `fixed`, with a `filter` or with a mask is a **group**. An element with `isolate`, a `mix-blend-mode`, a `backdrop-filter` or a shape of a mask is a group too (ADR-0145). Its children never paint between other elements outside the group. CSS creates a stacking context in the same cases, because every element has `position: relative` (section 3). - The native renderer draws a group with `opacity`, a `transform`, a `filter` or a `mix-blend-mode` into an offscreen layer, then draws the layer (plan, section 8.2). Another group paints into the layer around it, and the renderer keeps the place where its content starts. A blend mode and a backdrop read the content of the group from there (ADR-0145). --- ## 10. The web mapping The web emitter writes one stylesheet for each app. The runtime adds no styles of its own. The stylesheet has four parts, in this order: 1. The base stylesheet below. The class `s-<Component>` comes from the name of the built-in component. `s-app` is the element that holds the app. 2. The theme tokens of section 2.1 as CSS variables on `:root`. The dark values are in `@media (prefers-color-scheme: dark)`, the high contrast values are in `@media (prefers-contrast: more)`, and all are also under `data-theme` on the root (ADR-0072, ADR-0103). A length token is a variable only when `Theme.setToken` can change it (ADR-0130). 3. The declarations of the utilities of each component. A component comes after the components that it uses, so the utilities of a parent win on the roots of a child. Each declaration becomes one class, `<Component>-<n>`. Equal declarations in one component share one class. An element gets the classes of the declarations of its utilities. 4. The declarations are in the order of section 12.3. A selector of the base stylesheet is inside `:where()`, so each utility wins over it. Each utility has the specificity of one class, also with a state, so the order alone gives the precedence. ```css :where(.s-app) { font-family: var(--surf-font-sans, system-ui); font-size: var(--font-size); color: var(--text); } :where(.s-Column, .s-Row, .s-Stack, .s-Grid, .s-Scroll, .s-List, .s-Text, .s-Button, .s-Toggle, .s-Image, .s-Icon, .s-Input, .s-TextArea, .s-Slider, .s-Select, .s-Canvas) { box-sizing: border-box; position: relative; margin: 0; padding: 0; border: 0 solid transparent; min-width: 0; font: inherit; color: inherit; } :where(.s-Column, .s-List, .s-Scroll) { display: flex; flex-direction: column; } :where(.s-Row, .s-Button, .s-Toggle) { display: flex; flex-direction: row; } :where(.s-Button) { align-items: center; justify-content: center; background: none; } :where(.s-Toggle) { align-items: center; } :where(.s-Scroll) { overflow: auto; } :where(.s-Stack, .s-Grid) { display: grid; } :where(.s-Stack > *) { grid-area: 1 / 1; } :where(.s-Text) { display: block; white-space-collapse: preserve; } :where(.s-Text .s-Link) { display: inline; unicode-bidi: normal; } :where(.surf-fragment) { display: contents; } :where(g.surf-fragment) { display: inline; } ``` - Each property of section 5 becomes one CSS property, with the value written in CSS syntax. A length becomes `px`. A color token becomes `var(--name)`. A length token becomes `var(--name)` when it can change, and else its value (ADR-0130). - A logical side becomes the logical property of CSS, for example `padding-inline-start` (section 5.2, ADR-0124). - `mask-fade-s-` and `mask-fade-e-` have no logical property in CSS. Their rule has a second selector with `:dir(rtl)`, which swaps the start edge and the end edge (ADR-0124). `:dir()` reads the attribute `dir` of HTML, so `dir-ltr` and `dir-rtl` also write this attribute on the element. A host page with `dir="rtl"` then also works, because `:dir()` follows the attribute of each ancestor. - The attribute `dir` has the value of the declaration of `dir` that applies. `dir-auto` writes no attribute, so `:dir()` takes the direction of the parent. A condition changes the attribute when its value changes. The style of a component instance wins over the style of the root element of the component, also for the attribute. A `dir-` utility with a state, a theme or a window width changes the attribute when the variant changes, for example `hover:dir-rtl` or `md:dir-ltr`. The runtime reads the direction of the element from its rules. It does this in the next frame after a change of a state, of the window, of the theme or of the app. Only an app with such a utility has this code of the runtime. The native targets use the direction of the style of each element (section 5.9). - The four shadow layers become one `box-shadow`. A gradient and a background image become the two layers of one `background-image`, the gradient first (section 5.8, ADR-0127). - A text shadow becomes `text-shadow`. In an app with a color part of `text-shadow`, each layer of a size writes its color as `var(--surf-tsc, <color>)`, and the color part writes `--surf-tsc`. The stylesheet registers `--surf-tsc` with `@property` and `inherits: false`, as Tailwind 4 does. So the parts merge, and a color of a parent does not go to the size of a child (ADR-0145). - A shape of `mask` becomes `clip-path`. In an app with a shape and another mask, a shape also writes `mask-image: none`, and another mask writes `clip-path: none`. So a variant can change a fade to a shape. `transition-mask` writes `clip-path` into the transition (ADR-0145). - `backdrop-filter` also writes `-webkit-backdrop-filter`, for WebKit before Safari 18 (ADR-0145). - A utility with a computed value (`w-{width}px`) becomes an inline style on the element. It wins over every class. - An element where two variants or conditions set different parts of `shadow` or of `background-image` gets one class that writes the property from custom properties, as for `transform`. Each part is one custom property, for example `--surf-rw` for the width of the ring, so the parts merge (ADR-0120, ADR-0127). A stop without a color takes the stop before it, so it changes nothing. - An element with a computed transform, or with variants or conditions on different parts of `transform`, gets one class with the rule `transform: translate(var(--surf-tx), var(--surf-ty)) scale(var(--surf-s)) rotate(var(--surf-r))`. Each part is one custom property, so the parts merge (ADR-0104). A class or an inline style sets one part. The stylesheet registers the four properties with `@property` and with `inherits: false`, so a change does not restyle the children. - The states become pseudo-classes: `hover:` is `:hover`, `active:` is `:active`, `focus-visible:` is `:focus-visible` and `disabled:` is `:disabled`. A `Toggle` has the focus and the disabled state on its check box. `group-hover:` is `:where(.s-group:hover) .<class>`, so it keeps the specificity of one class. - `dark:` and `light:` become `@media (prefers-color-scheme: ...)` and a selector for `data-theme` (ADR-0072). `max-600:` becomes `@media (width < 600px)`, and `min-600:` becomes `@media (width >= 600px)`. The compiler keeps a declaration with a platform variant only when the platform includes `web`. - A condition (`{condition}:opacity-0.5`) adds the class of its declaration while the condition is true. - Two variants or conditions can set different parts of one property on one element. Then each declaration also gets rules for its parts, after the rules of the whole properties (ADR-0120). A part is a CSS longhand, for example `padding-top`, `row-gap`, `border-left-width`, `border-color`, `transition-duration` or `transition-timing-function`. A part of `filter` is a custom property, as a part of `transform` is, and a rule of the whole `filter` then reads `grayscale(var(--surf-gs)) brightness(var(--surf-br)) blur(var(--surf-bl))`. The native targets apply the same parts in the same order. - The nodes of a block have no wrapper: they go before a comment (ADR-0048, ADR-0121). So the elements of a block are children of the element around the block, as on the native targets. Four kinds of nodes have a wrapper with the class `surf-fragment`, which takes no part in the layout: the rows of a `List`, a `#try` block, a block in a `Drawing` (a `<g>`) and a `Transition`. - `testid` becomes the attribute `data-testid`. - The base stylesheet gives `:where(.s-app)` the rule `user-select: none`, and `:where(.s-Input, .s-TextArea)` the rule `user-select: text !important`. `select-text` becomes `user-select: text`. Each rule of `user-select` also has the name of WebKit, `-webkit-user-select` (section 5.10, ADR-0131). - `Button` and `Input` keep their semantic HTML elements for accessibility. The base stylesheet removes the browser's own sizes from them, so that the layout is the same as on native. --- ## 11. Conformance The conformance suite (plan, section 10) checks that the two implementations give the same boxes. - A **fixture** is a small `.surf` component with a fixed window size. The `testid` of an element is its label in the results. - The suite lays out each fixture in WebKit, Gecko and Chromium (through Playwright) and in the native runtime (Taffy). It records the border box of every element. - Two results conform when each edge of each box differs by 0.5 logical px or less. - Fixtures use a **test font with fixed metrics**, for example a font where each glyph is a square of 1 em. With this font, all engines measure text the same, and the suite tests the layout and not the text engines. On the web, `font: sans` and `font: mono` become `var(--surf-font-sans, system-ui)` and `var(--surf-font-mono, ui-monospace, monospace)`, and the page of the suite sets the two variables to the test font. - Every property, every value and every default in section 5 needs at least one fixture. A new property needs fixtures before it becomes valid. The test `crates/surf-check/tests/layout_coverage.rs` expands the utilities of the fixtures and checks this rule. --- ## 12. Utilities A **utility** is one word in the `style` attribute of an element. It sets one property of section 5, or one part of a property. The compiler knows every utility. A word that is not a utility is a compile error. ```surf <Row style="items-center gap-3 px-4 py-2 border rounded hover:bg-$surface max-600:gap-1" /> ``` ### 12.1 Values - For spacing and sizes, a number is a number of steps (ADR-0098). One step is `step` in `style.toml`, and the default is `0.25rem`. So `p-2` is `padding: 0.5rem`, which is 8 logical px at the default root size. A step can be a fraction: `p-0.5`, `p-2.5`. - The scale applies to the forms of padding, margin, `gap-`, `inset-`, `top-`, `right-`, `bottom-`, `left-`, `inset-s-`, `inset-e-`, `translate-x-`, `translate-y-` and `indent-`. It also applies to `w-`, `h-`, `size-`, `min-w-`, `min-h-`, `max-w-`, `max-h-` and `basis-`. - A value with a unit is not a number of steps: `p-2px` is 2 logical px, and `p-1rem` and `w-50%` keep their meaning. `0` stays `0`. - Other utilities keep exact numbers: `border-2` is 2 logical px, `text-14` is 14 logical px, `rounded-8` is 8 logical px and `blur-4` is 4 logical px. `ring-2` is 2 logical px, and `bg-linear-45` is 45 degrees (ADR-0127). - A theme token keeps its `$`: `bg-$surface`, `p-$space`. A token with no `$` is a compile error (ADR-0090). A name with no `$` is a constant of `style.toml` or a built-in name: `gap-inset`, `text-xs`. A constant is a length in logical px. - A value that has spaces in section 5 has `_` for each space: `shadow-0_2_8_rgb(0_0_0_/_0.15)`, `grid-cols-1fr_200_auto`. - The parentheses of a shape of `mask` hold the value of the CSS function, with `_` for each space: `mask-circle(40%_at_50%_0)`. A comma separates two points of a polygon, as in CSS. A number is a length in logical px, as in `rounded-8` (ADR-0145). - The parentheses of `bg-url()` hold the whole path (ADR-0127). In them, a `:` is not the end of a variant, and `_` stays `_`, as in a URL of Tailwind 4: `bg-url(https://example.com/a_b.png)`. - A `-` before a utility of a margin, an inset, `z-`, `translate-`, `rotate-`, `skew-`, `indent-` or `underline-offset-` makes its value negative: `-mt-1` is a top margin of one step up. A negative value cannot use a token. This is the one form: `translate-x--1` is a compile error (ADR-0104). - A computed value goes in braces after the prefix: `w-{steps}`, `w-{width}px`, `w-{percent}%`, `text-{size}`. It follows the rule of a literal, so `w-{steps}` is a number of steps. It cannot have a variant or a condition. A number for `text-` is a `font-size`, and a `String` is a `color`. These prefixes take a computed value: `w-`, `h-`, `min-w-`, `min-h-`, `max-w-`, `max-h-`, `p-`, `m-`, `gap-`, `inset-`, `grow-`, `shrink-`, `basis-`, `z-`, `opacity-`, `bg-`, `rounded-`, `leading-`, `tracking-`, `line-clamp-`, `text-`, `fill-`, `stroke-`, `left-`, `top-`, `right-`, `bottom-`, `inset-s-`, `inset-e-`, `cursor-`, `translate-x-`, `translate-y-`, `scale-`, `rotate-`, `delay-`, `animate-delay-`. - `inset-s-` and `inset-e-` take the same values as `left-` and `right-`, also a computed value: `inset-s-{x}px` (ADR-0124, ADR-0161). - `bg-url({path})` takes a computed path, as the `src` of an `Image` does (ADR-0127). The path can have computed parts: `bg-url(assets/{name}.png)`. - `translate-x-{x}` is a number of steps, `translate-x-{x}px` is a number of logical px, and `translate-x-{x}%` is a percentage of the box. `scale-{s}` is a factor. `rotate-{r}` is in degrees. `delay-{ms}` is in ms. A computed value of `delay-` takes the place of the delay of the `transition` of the element, so a row of a `#for` can read its index: `delay-{i * 40}`. - A number of `scale-` and `brightness-` is a factor below 10, and a number of `leading-` is a factor below 4. Tailwind 4 reads such a number as a percentage or as a number of steps. So a larger number is a compile error that gives the form of Surface: `scale-95` gives `scale-0.95`, `brightness-50` gives `brightness-0.5`, and `leading-6` gives `leading-1.5` (ADR-0161). A number of `opacity-` or `grayscale-` above 1 is a compile error with the same kind of help: `opacity-50` gives `opacity-0.5`. - An old name of ADR-0161 is a compile error. The help gives the new word with its value and its variants, for example `hover:grid-cols-3` for `hover:cols-3`. - `animate-delay-{ms}` takes a computed value in ms, as `delay-{ms}` does (ADR-0150). It takes the place of the delay of the `animation` of the element. - `view-transition-{name}` takes a computed `String` (ADR-0154). A computed text that is not a name of CSS gives no name, on all targets. ### 12.2 Variants A variant comes before a utility and ends with `:`. A utility can have one state and one condition of the table: `dark:hover:bg-$surface`. `{value}:` is also a variant (12.5). `(...)` groups utilities under the same variants: `hover:(underline opacity-0.8)`. | Variant | The declaration applies | |---|---| | `hover:`, `active:`, `focus-visible:`, `disabled:` | in this state of the element. `active:` applies while a press holds the element, and `focus-visible:` while the element has the keyboard focus and shows its focus ring | | `focus-within:` | while the element or one of its descendants has the keyboard focus, also without a focus ring, as `:focus-within` of CSS (ADR-0160) | | `group-hover:`, `group-active:`, `group-focus-visible:`, `group-disabled:`, `group-focus-within:` | while an ancestor with the utility `group` has the state (ADR-0106, ADR-0160) | | `peer-hover:`, `peer-active:`, `peer-focus-visible:`, `peer-disabled:`, `peer-focus-within:` | while an earlier sibling with the utility `peer` has the state, as the selector `~` of CSS (ADR-0160). The element needs such a sibling in the same list of children: a block has a list of its own. Else the variant is a compile error | | `motion-reduce:` | while the setting "reduce motion" of the system is on, as `prefers-reduced-motion: reduce` (ADR-0160) | | `rtl:`, `ltr:` | when the element has this direction, as `:dir()` of CSS (ADR-0160). They cannot change `dir` | | `forced-colors:` | while the system chooses the colors, as `forced-colors: active` of CSS (ADR-0051). Only a browser has this mode, so a native target never applies it | | `dark:`, `light:` | when the app shows this theme: the theme of the system, or the mode of `Theme.setMode`. A high contrast mode counts as its appearance. | | `macos:`, `windows:`, `linux:`, `android:`, `ios:`, `web:`, `apple:`, `mobile:`, `desktop:`, `native:` | on these targets. The groups are the groups of language spec, section 14. | | `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | when the window is as wide as the breakpoint, or wider. The defaults are 640, 768, 1024, 1280 and 1536 logical px. `[breakpoint]` in `style.toml` can change a value and add a name (ADR-0097). | | `max-sm:`, `max-md:`, `max-lg:`, `max-xl:`, `max-2xl:` | when the window is less wide than the breakpoint | | `min-<n>:` | when the window is `<n>` logical px wide or wider | | `max-<n>:` | when the window is less than `<n>` logical px wide | ### 12.3 Parts and precedence - Some properties have parts. The utilities of one element give one declaration for each property. These are the parts: - the four sides of `padding`, `margin` and `inset`, - the two axes of `gap`, - the width and the color of `border`, - the functions of `transform` and of `filter`, - the properties, the duration, the easing and the delay of `transition`, - the two logical sides of `padding`, `margin`, `inset` and `border-width`, and the four logical corners of `radius` (ADR-0124), - the kind of the gradient of `background-image`, the color and the position of each stop, and the picture (ADR-0127), - the size and the color of each layer of `shadow` (ADR-0127), - the size and the color of `text-shadow` (ADR-0145), - the axis and the strictness of `scroll-snap-type` (ADR-0128). `snap-mandatory` and `snap-proximity` need an axis: `snap-x`, `snap-y` or `snap-both`. Without one, they are a compile error. A variant takes the parts of `background-image` and of `shadow` that it does not set from the utilities without a variant. - A utility of one side wins over a utility of an axis. A utility of an axis wins over a utility of all sides. So `p-2 pt-0` gives `padding: 0 8 8 8`. - A physical side wins over a logical side, as in the stylesheet of Tailwind 4 (ADR-0124). So `ps-2 pl-4` gives a left padding of 16 in a left-to-right element. A logical side wins over an axis, so `px-2 ps-4` gives a start padding of 16. - These ranks count only inside one variant. A variant always wins over the base, also when the base sets a physical side and the variant sets a logical side (ADR-0124). So `pl-1 hover:ps-4` gives 16 px on the left side on hover, from left to right, as in Tailwind 4. Between two different variants, the order at the end of this section decides. A condition is a variant for this rule (section 12.2). - A utility of all sides or of the axis `x` also sets the two logical sides, as `padding` and `padding-inline` do in CSS. So `ps-4 hover:p-0` gives a start padding of 0 on hover. In the same way, `rounded-` sets the four logical corners. - For `radius`, a utility of one corner wins over a utility of a side, and a utility of a side wins over `rounded-`. For example, `rounded-8 rounded-s-0 rounded-ss-4` gives these radii: 4 at the top start corner, 0 at the bottom start corner, and 8 at the two end corners. - Two utilities of one element with the same variants that set the same part are an error, for example `p-2 p-4`. So the order of the words has no effect. - A utility with a variant takes the parts that it does not set from the utilities without a variant: `p-2 hover:pt-0` gives `padding: 0 8 8 8` on hover. - A utility with a condition also takes these parts: `transition {open}:duration-300` gives the transition of `transition` with 300 ms while `open` is true. It does not take the parts of another condition, so `{open}:transition {open}:duration-300` is a compile error. - The parts of a property merge when two declarations both apply. `{a}:pt-2 {b}:pb-3` gives the top padding of `a` and the bottom padding of `b` while `a` and `b` are both true, also when `a` and `b` are one condition (ADR-0120). `{a}:translate-x-5 {b}:rotate-20` moves and turns the element (ADR-0104). On hover while `a` is true, `border {a}:border-3 hover:border-$accent` gives a border of 3 px in the color of the accent. A part that neither declaration sets comes from the utilities without a variant, or it is the default. Two declarations that set the same part follow the order below. The six properties of `transition` are one part, so `{a}:transition-opacity {b}:transition-transform` gives the properties of one declaration. - A computed function of `transform` merges too: `translate-x-{x}px hover:scale-1.1` keeps the value of `x` on hover. - `animation` is one declaration, because the time and the easing of an animation come from its name. - `animate-delay-`, `animate-timeline-` and `animate-range-` change the one declaration of `animation`, as `animate-duration-` does (ADR-0150, ADR-0151). On the time, a range has no effect, as in CSS: `hover:animate-timeline-auto` keeps the range of the element. A range on an element with no `animate-timeline-` utility is a compile error. - Two declarations of one element can set the same property or the same part, and both apply. Then this order gives the winner, from the lowest to the highest: 1. no variant 2. a platform 3. `min-<n>:`, where a larger `<n>` wins 4. `max-<n>:`, where a smaller `<n>` wins 5. a direction: `rtl:`, `ltr:` (ADR-0160) 6. a theme 7. `motion-reduce:` (ADR-0160) 8. `forced-colors:` 9. a state, with or without a condition - A condition wins over the utilities of `style` with the same variants. - The `style` of a component instance applies to the root elements of the component, with its computed values and its conditions. ### 12.4 Reference | Utility | Property | Value | |---|---|---| | `w-<v>`, `h-<v>` | `width`, `height` | a length, `<n>%` or `auto` | | `min-w-<v>`, `min-h-<v>` | `min-width`, `min-height` | a length or `<n>%` | | `max-w-<v>`, `max-h-<v>` | `max-width`, `max-height` | a length, `<n>%` or `none`. A name of `max-w-` is a width of `container` or a length of `spacing` (section 2.1) | | `aspect-<n>`, `aspect-<a>/<b>`, `aspect-auto` | `aspect-ratio` | `auto`: no ratio (ADR-0160) | | `p-`, `px-`, `py-`, `pt-`, `pr-`, `pb-`, `pl-` | `padding`: all sides, an axis, one side | a length | | `ps-`, `pe-` | `padding`: the start side, the end side (ADR-0124) | a length | | `m-`, `mx-`, `my-`, `mt-`, `mr-`, `mb-`, `ml-` | `margin` | a length or `auto` | | `ms-`, `me-` | `margin`: the start side, the end side (ADR-0124) | a length or `auto` | | `gap-`, `gap-x-`, `gap-y-` | `gap`: both axes, the column gap, the row gap | a length | | `items-<v>` | `align` | `start`, `center`, `end`, `stretch`, `baseline` | | `justify-<v>` | `justify` | `start`, `center`, `end`, `between`, `around`, `evenly` | | `flex-wrap`, `flex-nowrap` | `wrap` | | | `content-<v>` | `align-content` | `start`, `center`, `end`, `stretch`, `between`, `around` | | `grow`, `grow-<n>`, `shrink`, `shrink-<n>` | `grow`, `shrink` | without a value: 1 | | `basis-<v>` | `basis` | a length, `<n>%` or `auto` | | `self-<v>` | `align-self` | the values of section 5.4 | | `grid-cols-<n>`, `grid-rows-<n>` | `columns`, `rows` | `<n>` equal tracks: `repeat(<n>, 1fr)` | | `grid-cols-<tracks>`, `grid-rows-<tracks>` | `columns`, `rows` | a track list: `grid-cols-1fr_200_auto`. One track of a length is `grid-cols-repeat(1,_200)`. A name of a line is in brackets: `grid-cols-[side]_200_[main]_1fr`. `repeat()` takes `auto-fill` and `auto-fit`: `grid-cols-repeat(auto-fill,_minmax(200,_1fr))` (ADR-0126) | | `grid-areas-<rows>` | `areas` | the names of the cells. `_` separates two cells, and `/` separates two rows: `grid-areas-head_head/nav_main` (ADR-0126) | | `auto-rows-<track>` | `auto-rows` | one track size | | `auto-cols-<track>` | `auto-columns` | one track size: `auto-cols-40`, `auto-cols-minmax(0,_1fr)` (ADR-0160) | | `grid-flow-row`, `grid-flow-col` | `auto-flow` | `row`, `column`. These names replace `flow-row` and `flow-col` (ADR-0126) | | `grid-flow-dense`, `grid-flow-row-dense`, `grid-flow-col-dense` | `auto-flow` | `dense`, `row dense`, `column dense` (ADR-0126) | | `justify-items-<v>`, `justify-self-<v>` | `justify-items`, `justify-self` | the values of sections 5.5 and 5.6 | | `col-<v>`, `row-<v>` | `column`, `row` | a placement: `col-2`, `col-1_/_3`. A line can be a name: `col-main` (ADR-0126). A line can be `<n>_<name>`, for example `col-2_main`, and a span can be `span_<name>`, for example `col-1_/_span_main` | | `area-<name>` | `area` | the name of an area of the parent: `area-nav` (ADR-0126). A name that the parent in the same file does not have is a compile error | | `col-span-<n>`, `row-span-<n>` | `column`, `row` | `span <n>` | | `col-span-<name>`, `row-span-<name>` | `column`, `row` | `span <name>`: to the next line with the name, for example `col-span-main` (ADR-0126) | | `relative`, `absolute`, `sticky`, `fixed` | `position` | `sticky` stays in the visible area of its scroll container, and `fixed` needs a place on both axes (section 5.7) | | `group` | `group` | a parent for `group-hover:`. It takes no variant | | `flex-row`, `flex-col` | `direction` | `row`, `column`. An element keeps its own direction without one | | `hidden` | `display` | `none`. `max-767:hidden` removes the element from the layout on a phone | | `pointer-events-none`, `pointer-events-auto` | `pointer-events` | `none`: the pointer goes to the element below. `auto`: a child takes the pointer again. Children get the value (ADR-0160) | | `visible`, `invisible` | `visibility` | `visible`, `hidden`: the element keeps its box and paints nothing of its own. Children get the value (ADR-0160) | | `peer` | `peer` | an earlier sibling for `peer-<state>:`. It takes no variant (ADR-0160) | | `inset-`, `inset-x-`, `inset-y-`, `top-`, `right-`, `bottom-`, `left-` | `inset` | a length, `<n>%` or `auto` | | `inset-s-`, `inset-e-` | `inset`: the start side, the end side (ADR-0124, ADR-0161) | the values of `left-` and `right-`: a length, `<n>%` or `auto`, also a computed value (12.1) | | `z-<n>` | `z-index` | an integer | | `overflow-hidden`, `overflow-visible` | `overflow` | `hidden`, `visible` | | `object-fill`, `object-contain`, `object-cover`, `object-none` | `object-fit` | how the picture of an `Image` fills its box | | `object-center`, `object-top`, `object-bottom`, `object-left`, `object-right`, `object-top-left`, `object-top-right`, `object-bottom-left`, `object-bottom-right` | `object-position` | the place of the picture in its box. The positions are physical, as in Tailwind 4.1 (ADR-0160) | | `mask-none`, `mask-ellipse` | `mask` | no mask, or the ellipse in the box. A mask limits the element and its children. The hit area stays the box (ADR-0065) | | `mask-fade-t-<n>`, `-r-`, `-b-`, `-l-`, `-x-`, `-y-`, `-s-`, `-e-` | `mask` | the element fades to nothing over `<n>` at that edge, or at both edges of `x` and `y`. `<n>` is a length, a length of `spacing` or a length token. `-s-` is the start edge, and `-e-` is the end edge (ADR-0124). On the web, their rule uses `:dir(rtl)` (section 10) | | `mask-inset(<sides>)`, `mask-inset(<sides>_round_<radii>)` | `mask` | a rectangle inside the border box, with round corners: `mask-inset(0_50%_0_0)`, `mask-inset(8_round_12)`. The sides are as `padding`, and the radii as `radius` (section 5.7, ADR-0145) | | `mask-circle(<radius>)`, `mask-circle(<radius>_at_<x>_<y>)` | `mask` | a circle: `mask-circle(40%)`, `mask-circle(closest-side_at_left_top)` (section 5.7, ADR-0145) | | `mask-ellipse(<rx>_<ry>)`, `mask-ellipse(<rx>_<ry>_at_<x>_<y>)` | `mask` | an ellipse: `mask-ellipse(50%_30%_at_50%_0)` (section 5.7, ADR-0145) | | `mask-polygon(<x>_<y>,<x>_<y>,...)` | `mask` | a polygon with 3 or more points: `mask-polygon(50%_0,100%_100%,0_100%)` (section 5.7, ADR-0145) | | `bg-<color>` | `background` | a color | | `bg-linear-to-<side>`, `bg-linear-<n>`, `bg-radial`, `bg-conic`, `bg-conic-<n>` | `background-image`: the kind of the gradient | `<side>` is `t`, `tr`, `r`, `br`, `b`, `bl`, `l` or `tl`. The sides are physical, as in Tailwind 4 (ADR-0124). `<n>` is an angle in degrees (ADR-0127) | | `from-<v>`, `via-<v>`, `to-<v>` | `background-image`: a stop of the gradient | a color or a position `<n>%`: `from-$accent`, `from-10%` (ADR-0127) | | `via-none` | `background-image`: the `via` stop | no `via` stop: the gradient has only `from` and `to` (ADR-0127) | | `bg-none` | `background-image`: the kind of the gradient and the picture | `none`: no gradient and no picture (ADR-0127) | | `bg-url(<path>)` | `background-image`: the picture | a path in the build or a URL, as the `src` of an `Image` (ADR-0127) | | `bg-auto`, `bg-cover`, `bg-contain` | `background-size` | `auto`, `cover`, `contain` (ADR-0127) | | `bg-center`, `bg-top`, `bg-bottom`, `bg-left`, `bg-right`, `bg-top-left`, `bg-top-right`, `bg-bottom-left`, `bg-bottom-right` | `background-position` | `center`, a side or a corner. `bg-top-left` is the default. The positions are physical, as in Tailwind 4 (ADR-0127) | | `bg-repeat`, `bg-no-repeat` | `background-repeat` | `repeat`, `no-repeat` (ADR-0127) | | `border` | `border` | `1 $border` | | `border-<n>`, `border-<color>` | `border`: the width, the color | the other part is `1` or `$border` | | `border-t`, `border-r`, `border-b`, `border-l`, and `-<n>` after them | `border-width` | one side. Other sides are 0 until a utility sets them | | `border-s`, `border-e`, and `-<n>` after them | `border-width` | the start side, the end side (ADR-0124) | | `rounded`, `rounded-<v>` | `radius` | without a value: `$radius`. Else 1 to 4 lengths. | | `rounded-s`, `rounded-e`, and `-<v>` after them | `radius`: the two corners of the start side, of the end side (ADR-0124) | without a value: `$radius`. Else a length | | `rounded-ss`, `rounded-se`, `rounded-es`, `rounded-ee`, and `-<v>` after them | `radius`: one corner. The first letter is the top side (`s`) or the bottom side (`e`). The second letter is the start side or the end side (ADR-0124) | without a value: `$radius`. Else a length | | `shadow-<v>` | `shadow`: the shadow layer | `<dx>_<dy>_<blur>_<color>` or `none`. A size of `style.toml`: `shadow-xs`, `shadow-sm`, `shadow-md`, `shadow-lg`, `shadow-xl` (section 2.1). A spread: `<dx>_<dy>_<blur>_<spread>_<color>`. A color: `shadow-$border-strong` (ADR-0127) | | `inset-shadow-<v>` | `shadow`: the inset shadow layer | as `shadow-`. The sizes are `2xs`, `xs` and `sm` (ADR-0127) | | `ring`, `ring-<n>`, `ring-<color>` | `shadow`: the ring layer | a width or a color. Without a value: 1 (ADR-0127) | | `inset-ring`, `inset-ring-<n>`, `inset-ring-<color>` | `shadow`: the inset ring layer | as `ring` (ADR-0127) | | `opacity-<n>` | `opacity` | a number from 0 to 1 | | `skew-x-<n>`, `skew-y-<n>` | `transform` | the angle of a skew in degrees, after the other functions, as in Tailwind 4. A `-` before the utility gives a negative angle (ADR-0160) | | `origin-center`, `origin-top`, `origin-bottom`, `origin-left`, `origin-right`, `origin-top-left`, `origin-top-right`, `origin-bottom-left`, `origin-bottom-right` | `transform-origin` | the point that a transform keeps. The positions are physical (ADR-0160) | | `translate-x-<v>`, `translate-y-<v>`, `scale-<n>`, `rotate-<n>` | `transform` | in this order: translate, scale, rotate. `scale-<n>` is a factor below 10 (12.1). `rotate-<n>` is in degrees. Each takes a computed value (12.1). `translate-x-` is physical, as in Tailwind 4: a positive value moves the element to the right, also in `rtl` (ADR-0124). | | `grayscale-<n>`, `brightness-<n>`, `blur-<v>` | `filter` | in this order. `grayscale-` is a number from 0 to 1, and `brightness-` a number from 0 to below 10 (section 12.1). `blur-` is a length: `blur-4` is 4 logical px. All targets (section 5.8, ADR-0131) | | `backdrop-blur-<v>` | `backdrop-filter` | a blur of the content behind the element: `backdrop-blur-8` has a standard deviation of 8 logical px. `backdrop-blur-0` is `none` (section 5.8, ADR-0145) | | `mix-blend-<mode>` | `mix-blend-mode` | a blend mode of section 5.8: `mix-blend-multiply`, `mix-blend-plus-lighter`. `mix-blend-normal` is the default (ADR-0145) | | `isolate`, `isolation-auto` | `isolation` | `isolate`, `auto` (section 5.8, ADR-0145) | | `font-sans`, `font-mono`, `font-<name>` | `font` | `<name>` is a font of the theme (section 2.1) | | `text-<n>` | `font-size` | a length | | `font-<n>` | `font-weight` | an integer from 100 to 900 | | `leading-<v>` | `line-height` | `normal`, or a number below 4 (section 12.1) | | `text-start`, `text-center`, `text-end` | `text-align` | | | `text-wrap`, `text-nowrap` | `text-wrap` | `wrap`, `none` | | `dir-auto`, `dir-ltr`, `dir-rtl` | `dir` | `auto`, `ltr`, `rtl`. On the web, `dir-ltr` and `dir-rtl` also write the attribute `dir` (section 10, ADR-0124) | | `italic`, `not-italic` | `font-style` | `italic`, `normal` | | `underline`, `line-through`, `no-underline` | `text-decoration` | `underline`, `line-through`, `none` | | `uppercase`, `lowercase`, `capitalize`, `normal-case` | `text-transform` | `uppercase`, `lowercase`, `capitalize`, `none` | | `wrap-anywhere`, `wrap-normal` | `overflow-wrap` | `anywhere`, `normal` | | `tabular-nums`, `normal-nums` | `font-variant-numeric` | `tabular-nums`, `normal` | | `font-stretch-<v>` | `font-stretch` | a word of CSS, for example `font-stretch-condensed`, or a percentage: `font-stretch-75%` (ADR-0160) | | `decoration-<n>` | `text-decoration-thickness` | logical px (ADR-0160) | | `underline-offset-<n>` | `text-underline-offset` | logical px. A `-` before the utility gives a negative offset (ADR-0160) | | `indent-<v>` | `text-indent` | a length: a number of steps, as `p-` (ADR-0098). A `-` before the utility gives a negative indent. Not in version 0: the native targets (ADR-0160) | | `caret-<color>`, `accent-<color>`, `placeholder-<color>` | `caret-color`, `accent-color`, `placeholder-color` | a color (ADR-0160) | | `tracking-N` | `letter-spacing` | `N` | | `line-clamp-<v>` | `max-lines` | an integer or `none` | | `field-sizing-content` | `field-sizing` | `content` | | `text-<color>` | `color` | a color | | `text-shadow-<v>` | `text-shadow`: the size | a size of `style.toml`: `2xs`, `xs`, `sm`, `md` or `lg`, with the values of Tailwind 4. One layer: `text-shadow-0_1_2_#00000080`. Or `none` (section 5.9, ADR-0145) | | `text-shadow-<color>` | `text-shadow`: the color | the color of each layer of the size: `text-shadow-$accent` (ADR-0145) | | `fill-<color>`, `fill-none` | `fill` | a color or `none` | | `stroke-<color>`, `stroke-none` | `stroke` | a color or `none` | | `stroke-<n>` | `stroke-width` | a number in view units | | `stroke-dash-<a>_<b>...`, `stroke-dash-none` | `stroke-dasharray` | lengths in view units | | `stroke-cap-butt`, `stroke-cap-round`, `stroke-cap-square` | `stroke-linecap` | | | `stroke-join-miter`, `stroke-join-round`, `stroke-join-bevel` | `stroke-linejoin` | | | `snap-x`, `snap-y`, `snap-both` | `scroll-snap-type`: the axis | on a `Scroll` or a `List` (ADR-0128) | | `snap-none` | `scroll-snap-type` | `none`: no snapping (ADR-0128) | | `snap-mandatory`, `snap-proximity` | `scroll-snap-type`: the strictness | without one: `proximity` (ADR-0128). They need an axis | | `snap-start`, `snap-center`, `snap-end`, `snap-align-none` | `scroll-snap-align` | on a child of the scroll container. `snap-align-none`: no snap point (ADR-0128) | | `snap-always`, `snap-normal` | `scroll-snap-stop` | `always`, `normal` (ADR-0128) | | `cursor-<name>` | `cursor` | the pointer over the element: a cursor of CSS of section 5.10. Each target shows the cursor of its system, or the nearest cursor that the system has (ADR-0160, `docs/native-differences.md`) | | `select-none`, `select-text` | `user-select` | `none`, `text`. Text is not selectable without `select-text`. `select-text` makes the text of the element and of its children selectable. The text of an `Input` and a `TextArea` stays selectable, also with `select-none`, and `surf lint` reports `select-none` on a field (rule `field-select`, section 5.10, ADR-0131). `select-auto` of version 0 is a compile error that names `select-text` | | `transition` | `transition` | all six properties of section 5.10 | | `transition-<property>` | `transition` | one property: `opacity`, `transform`, `background`, `color`, `shadow`, `border` (its color), `mask` (its shape, ADR-0145) | | `duration-<n>` | `transition` | the duration in ms. Without it: 150. | | `delay-<n>` | `transition` | the delay in ms. Without it: 0 (ADR-0104). | | `size-<value>` | `width`, `height` | both sizes (ADR-0053). | | `text-<name>` | `font-size`, `line-height` | a text size of the theme. A `leading-` of the element wins (ADR-0053). | | `sr-only`, `not-sr-only` | `position`, `width`, `height`, `overflow`, `opacity` | hides the box and keeps the element for a screen reader, and undoes this (ADR-0051). | | `outline-none` | `outline` | no focus ring, on all targets. An app that draws its own focus style with `focus-visible:` needs it (ADR-0131) | | `animate-<name>`, `animate-none` | `animation` | a looping animation: `pulse`, `spin`, `ping`, `bounce`, or a name of `keyframes` in `style.toml` (ADR-0049). | | `animate-duration-<n>`, `animate-ease-<easing>`, `animate-count-<n>`, `animate-infinite` | `animation` | the time of a round in ms, the easing and the number of rounds. Without them: the values of the animation, without an end. | | `animate-delay-<n>` | `animation`: the delay | the time in ms before the first round. Without it: 0. It takes a computed value: `animate-delay-{i * 150}` (section 5.10, ADR-0150) | | `animate-timeline-scroll`, `animate-timeline-scroll-x` | `animation-timeline` | the scroll position of the scroll container of the element, on the axis `y` or `x` (section 5.10, ADR-0151) | | `animate-timeline-view`, `animate-timeline-view-x` | `animation-timeline` | the place of the element in the visible area of its scroll container (ADR-0151) | | `animate-timeline-<name>`, `animate-timeline-auto` | `animation-timeline` | a timeline that `scroll-timeline-<name>` or `view-timeline-<name>` names in the same file, or the time (ADR-0151) | | `animate-range-<v>` | `animation-range` | the start and the end on the timeline: `animate-range-entry`, `animate-range-entry_25%_cover_50%`, `animate-range-0_400` (section 5.10, ADR-0151) | | `scroll-timeline-<name>`, `scroll-timeline-x-<name>` | `scroll-timeline` | names the scroll position of a `Scroll` or a `List`, on the axis `y` or `x`. It takes no variant (ADR-0151) | | `view-timeline-<name>`, `view-timeline-x-<name>` | `view-timeline` | names the place of the element in the visible area of its scroll container, on the axis `y` or `x`. It takes no variant (ADR-0151) | | `cue-<name>` | `cue` | the element plays the cue `<name>` of a sequence of its component (language spec 7.5a, ADR-0152). It takes no variant | | `split-lines`, `split-words`, `split-letters` | `split` | on a `Text` or a `Span`: the motion utilities of the element apply to each line, word or letter (section 5.10, ADR-0153). It takes no variant | | `split-clip` | `split` | each part of a split text paints only inside its own box (section 5.10, ADR-0153). It takes no variant | | `view-transition-<name>` | `view-transition-name` | the element moves from the old page to the new page in a page transition. It takes a computed `String`: `view-transition-{name}` (wave spec 7, ADR-0154) | | `ease`, `ease-in`, `ease-out`, `ease-in-out`, `ease-linear`, `ease-cubic-bezier(<a>,<b>,<c>,<d>)` | `transition` | the easing. Without it: `ease`. | | `ease-in-<name>`, `ease-out-<name>`, `ease-in-out-<name>` | `transition` | a named curve of section 5.10: `sine`, `quad`, `cubic`, `quart`, `quint`, `expo`, `circ`, `back` (ADR-0104). | | `ease-spring`, `ease-spring-<bounce>` | `transition` | a spring of section 5.10. `animate-ease-` and the `ease` of a `Transition` take the same names (ADR-0104). | A color is a token (`$accent`), `#rgb`, `#rrggbb`, `#rrggbbaa`, `transparent` or `rgb(<r>_<g>_<b>_/_<a>)`. ### 12.5 Conditions and case lists - `{condition}:` is a variant (ADR-0096): `{saved}:bg-$success`, `{!open}:max-md:hidden`. The value must be a `Bool`. - `{value}:{ case: utilities, }` gives one style for each case of an enum: ```surf <Button style=" px-4 {variant}:{ primary: bg-$accent text-$on-accent, ghost: text-$text-secondary, plain:, } ">Save</Button> ``` - The list must name each case. A case with no style is `name:,`. A case with fields is a compile error. - A case can hold utilities, variants, groups, presets and conditions. A variant can come first: `hover:{variant}:{ ... }`. - The value of a case list must be an enum. A `String` value uses one condition for each value. - A case list cannot have a condition, and a utility with a condition cannot have a computed value. --- ## 13. Decisions for version 1 This section listed the open questions for version 1. On 2026-11-04, the project owner decided them. Version 1 has these features: 1. Logical sides next to the physical sides: `ps-`, `pe-`, `ms-`, `me-`, `inset-s-`, `inset-e-`, `border-s-`, `border-e-` and the logical corners of `rounded-` (5.2, ADR-0124). 2. Percentages keep the rules of version 0. `padding`, `margin` and `gap` never take a percentage (2, ADR-0125). 3. Named lines, areas, `auto-fill`, `auto-fit` and dense in a grid (5.5, 5.6, ADR-0126). 4. Linear, radial and conic gradients, four shadow layers with a spread, and background images (5.8, ADR-0127). 5. `sticky` on the native targets, and scroll snapping (5.7, 5.12, ADR-0128). 6. No reverse flex directions, also not later (5.3, ADR-0129). 7. Theme lengths that `Theme.setToken` changes while the app runs (2.1, ADR-0130). On 2026-11-05, the project owner answered the open details of these features: 1. `dir-ltr` and `dir-rtl` also write the attribute `dir` of HTML. The web rule of `mask-fade-s-` and `mask-fade-e-` uses `:dir(rtl)`, so a host page with `dir="rtl"` also works (5.9, 10, 12.4, ADR-0124). 2. A variant always wins over the base. The rank of a physical side over a logical side counts only inside one variant (12.3, ADR-0124). 3. An `area-<name>` with a name that the parent in the same file does not have is a compile error (5.6, ADR-0126). 4. The placements `span <name>` and `<n> <name>` of CSS: `col-span-main` and `col-2_main` (5.6, 12.4, ADR-0126). These features come after version 1: - Percentages in `calc()`, in a minor version (ADR-0125). - Subgrid, when Taffy supports it (ADR-0126). <!-- https://surface.solenovis.com/docs/standard-library.md --> # Standard library v0 - **Status:** current on 2026-11-06, for version 0. The table of methods in `crates/surf-check/src/infer.rs` is the source of this page. - **Related documents:** [language spec](https://surface.solenovis.com/docs/language.md) (sections 4, 6 and 8), [getting started](https://surface.solenovis.com/docs/getting-started.md), [limits of version 0](https://surface.solenovis.com/docs/limits.md) The standard library of version 0 is small: the methods of the built-in types, `Int.parse` and `Float.parse`, the built-in names, and the operators. A file uses them without an import. Each method gives the same result on every target. `tests/compare.sh` compares the results of the native target, the interpreter and the web target. A method that changes its value needs a place, for example a `state` or a `let` variable (spec 5.2). A call of such a method is a statement of its own: `todos.push(t)` is valid, but `const n = todos.push(t)` is a compile error. --- ## 1. Int and Float `Int` is an integer from −(2^53 − 1) to 2^53 − 1 on all targets. `Float` is an IEEE 754 binary64 number. | Method | Result | Rule | |---|---|---| | `i.toFloat()` | `Float` | The same value as a `Float` | | `f.toInt()` | `Int` | Removes the fraction: `2.7` gives `2`, `-2.7` gives `-2` | | `f.round()` | `Int` | The nearest integer. A value halfway between two integers goes away from zero: `2.5` gives `3`, `-2.5` gives `-3` | | `f.floor()` | `Int` | The nearest integer that is not more than `f` | | `f.ceil()` | `Int` | The nearest integer that is not less than `f` | Read a number from a `String`: | Function | Result | Rule | |---|---|---| | `Int.parse(s)` | `Int?` | An optional `+` or `-`, then the digits `0` to `9`: `Int.parse("-042")` is `-42`. Other text, and a value outside the range, give `none` | | `Float.parse(s)` | `Float?` | An optional sign, digits with an optional `.` and fraction, and an optional exponent: `Float.parse("-.5e2")` is `-50.0`. Other text, and a value that is too large, give `none` | - Both functions accept no white space: `Int.parse(" 4")` is `none`. Use `Int.parse(draft.trim())` for the text of an `Input`. - The four conversions from `Float` give the runtime error "the Float is NaN or outside the Int range" for NaN, and for a result outside the `Int` range. - An `Int` result outside the range is the runtime error "the result is outside the integer range". - `a / b` with two `Int` values removes the fraction. `a % b` has the sign of `a`. Division by zero is a runtime error for `Int`, and gives infinity or NaN for `Float`. - An operation cannot mix `Int` and `Float`. Write `n.toFloat() * 1.5`. - `"{x}"` writes a `Float` with the shortest text that gives the same value again: `0.1 + 0.2` gives `0.30000000000000004`, and `2.0` gives `2`. Math: | Method | Result | Rule | |---|---|---| | `n.abs()`, `f.abs()` | `Int`, `Float` | The value without its sign. A negative literal needs parentheses: `(-3).abs()` | | `a.min(b)`, `a.max(b)` | the type of `a` | The smaller or the larger value. For `Float`, NaN when a value is NaN | | `x.clamp(lo, hi)` | the type of `x` | `lo.max(x.min(hi))`: `x` kept between `lo` and `hi`. When `lo` is above `hi`, the result is `lo` | | `f.sqrt()`, `f.sin()`, `f.cos()`, `f.tan()`, `f.exp()`, `f.log()` | `Float` | `log` is the natural logarithm. The angles are in radians | | `f.pow(e)`, `y.atan2(x)` | `Float` | `f` to the power `e`, and the angle of the point (`x`, `y`) | | `Float.pi` | `Float` | π | | `f.format(decimals)` | `String` | `f` with 0 to 15 digits after the point. The value times 10 to the power of `decimals` rounds half away from zero, as `round` does: `0.125.format(2)` is `"0.13"`, `1.005.format(2)` is `"1.00"`. A value that is too large at that scale, NaN and the infinities give the text of `"{f}"` | - `sqrt`, `sin`, `cos`, `tan`, `exp`, `log`, `pow` and `atan2` use the math library of the target. The results can differ in the last bit between targets, so show them with `format`. - `pow` follows IEEE 754 on each target: `1.0.pow(e)` is 1 for each `e`, also NaN, and `(-1.0).pow(e)` is 1 for an infinite `e`. --- ## 2. String A `String` is a sequence of Unicode scalar values. `count`, `slice` and `chars` count scalar values on all targets, not bytes or UTF-16 units. A string has no index operator. | Method | Result | Rule | |---|---|---| | `s.count()` | `Int` | The number of scalar values: `"aé😀".count()` is `3` | | `s.isEmpty()` | `Bool` | `true` for `""` | | `s.contains(t)` | `Bool` | `true` when `t` is in `s` | | `s.startsWith(t)`, `s.endsWith(t)` | `Bool` | | | `s.slice(from, to)` | `String` | The scalar values from `from` to `to`, without `to`. The method keeps each bound between 0 and `s.count()`, and `to` is at least `from`: `"abc".slice(-1, 9)` is `"abc"`, `"abc".slice(2, 1)` is `""` | | `s.chars()` | `List<String>` | One string for each scalar value | | `s.split(sep)` | `List<String>` | The parts between the separators: `"a,b,".split(",")` is `["a", "b", ""]`. An empty separator gives the same list as `chars()` | | `s.trim()` | `String` | Without the white space at the start and at the end. White space is each character with the property `White_Space` of Unicode, for example U+0085 and U+3000, and not U+FEFF | | `s.upper()`, `s.lower()` | `String` | The Unicode default case mapping, the same for every locale | | `s.replace(old, new)` | `String` | Each `old` becomes `new`. An empty `old` changes nothing | | `s.indexOf(t)` | `Int?` | The index of the first `t` in scalar values, or `none` | | `s.hash()` | `Int` | The hash of the trait `Hashable` (spec 7.8): the same number on each target. A later version can give another number | - `a + b` joins two strings. `String` has an impl of the operator trait `Add` (spec 7.8, ADR-0134). A record with an impl of an operator trait can use its operator too, for example `a + b` of two `Vector` values with an impl of `Add`. - `"{n} items"` puts values into a string. `{...}` takes an `Int`, a `Float`, a `Bool` or a `String`. A type with an impl of the trait `Text` can go into `{...}` too (spec 5.4, ADR-0134). - `==` compares the scalar values. `<`, `<=`, `>` and `>=` compare scalar value by scalar value on the native targets and in the interpreter. The web compares by UTF-16 code units, as JavaScript does (spec 4.4, ADR-0113). The two orders differ only for a few characters, for example most emoji. For the order of a language, use `Locale.compare` (section 6a) with `sortWith` (section 3). --- ## 3. List `List<T>` is an ordered sequence. A literal is `[1, 2, 3]`. An empty literal needs a known type: `state todos: List<Todo> = []`. Methods that give a value: | Method | Result | Rule | |---|---|---| | `xs[i]` | `T` | The element at `i`. An index outside the list is the runtime error "the index is outside the list" | | `xs.get(i)` | `T?` | The element at `i`, or `none` for an index outside the list, also for a negative index | | `xs.first()`, `xs.last()` | `T?` | `none` for an empty list | | `xs.count()` | `Int` | | | `xs.isEmpty()` | `Bool` | | | `xs.contains(v)` | `Bool` | `true` when an element is equal to `v` (`==`, spec 5.1) | | `xs.slice(from, to)` | `List<T>` | The elements from `from` to `to`, without `to`, with the bounds rule of `String.slice` | | `xs.filter(x => cond)` | `List<T>` | The elements for which `cond` is `true`, in their order | | `xs.flatMap(x => list)` | `List<U>` | The lists of the elements, as one list (ADR-0052) | | `xs.reversed()` | `List<T>` | A new list in the other order | | `xs.concat(ys)`, `xs + ys` | `List<T>` | A new list: the elements of `xs`, then of `ys` | | `xs.unique()` | `List<T>` | The first of each value, in the order of the list. For `Int`, `String` and `Bool` | | `xs.find(x => cond)` | `T?` | The first element for which `cond` is `true` | | `xs.map(x => value)` | `List<U>` | One value for each element | | `xs.any(x => cond)`, `xs.all(x => cond)` | `Bool` | `true` when `cond` is `true` for one element, or for all elements. An empty list gives `false` and `true` | | `xs.indexOf(v)` | `Int?` | The index of the first element that is equal to `v`, or `none` | | `xs.sum()` | `Int`, `Float` | The sum of a `List<Int>` or a `List<Float>`. An `Int` sum outside the range is a runtime error | | `xs.join(sep)` | `String` | The strings of a `List<String>` with `sep` between them | Methods that change the list. Each is a statement of its own: | Method | Rule | |---|---| | `xs.push(v)` | Adds `v` at the end | | `xs.insert(i, v)` | Puts `v` at index `i`. `i` can be from 0 to `xs.count()`. Another index is a runtime error | | `xs.removeAt(i)` | Removes the element at `i`. An index outside the list is a runtime error | | `xs.remove(v)` | Removes the first element that is equal to `v`. Without such an element, the list does not change | | `xs.clear()` | Removes all elements | | `xs.sort()` | Orders the list by `<`. The type of the elements must have the trait `Comparable` (spec 7.8, ADR-0134): `Int`, `Int64`, `Float`, `String`, or a type with an impl of `Comparable` | | `xs.sortBy(x => key)` | Orders the list by a key, for example `todos.sortBy(t => t.title)`. The type of the key must have the trait `Comparable` | | `xs.sortWith((a, b) => order)` | Orders the list with a compare function that gives an `Ordering` (ADR-0132). `.less` puts `a` before `b`. For example, `names.sortWith((a, b) => Locale.compare(a, b))` sorts in the order of the locale | - The three sorts are stable: equal elements keep their order. For `sortWith`, two elements are equal when the compare function gives `.equal`. - Each runtime uses the same stable merge sort for the three sorts, also the web (ADR-0132). So each target gives the same order, also when the order is not consistent. An example is a compare function or an impl of `Comparable` that gives `.less` for `(a, b)` and also for `(b, a)`. Spec 4.5 gives the exact form of the merge sort. - `sortBy` gets the key of each element one time, before the sort starts. `sortWith` calls the compare function for each comparison of the merge sort. - `sortWith` is for an order that no key gives, for example the order of a language (ADR-0132). - The lambda of `filter`, `find`, `map` and `sortBy` has one parameter and a value as its body. The lambda of `sortWith` has two parameters and a value as its body. Each runs before the call returns, so it can read the parameters, the local values and the reactive values around it. - `contains`, `indexOf` and `remove` compare with `==`. A list of `dyn` values cannot use them, because a `dyn` value has no `==` (spec 5.1, ADR-0135). - `xs[i] = v` and `xs[i].done = true` write one element. - `for x in xs { ... }` and `for x, i in xs { ... }` go through the list as it was when the loop started (spec 6.1). --- ## 4. Map `Map<K, V>` maps keys to values. A literal is `Map { "a": 1, "b": 2 }`. The key type must have the trait `Hashable` (spec 7.8, ADR-0134): `Int`, `String`, `Bool`, or a record or an enum whose fields all have `Hashable`. A `Float` and a `dyn` type cannot be a key. A type with an impl of `Equatable` needs an impl of `Hashable` too. Two keys are one key when their `hash` is the same and `==` gives `true`. | Method | Result | Rule | |---|---|---| | `m[k]` | `V?` | The value of `k`, or `none` | | `m.count()` | `Int` | The number of keys | | `m.isEmpty()` | `Bool` | | | `m.contains(k)` | `Bool` | `true` when `m` has the key `k` | | `m.keys()` | `List<K>` | The keys, in the order in which they were first added | | `m.values()` | `List<V>` | The values, in the order of their keys | Changes: | Statement | Rule | |---|---| | `m[k] = v` | Adds or replaces the value of `k`. A new key goes to the end. A replaced value keeps the position of its key | | `m.remove(k)` | Removes the key `k` and its value | | `m.clear()` | Removes all keys | - `for k, v in m { ... }` goes through the keys in their order. - Two maps are equal when they have the same keys with equal values. The order of the keys does not count. `Set<T>` is a `Map<T, Bool>` (ADR-0083). `s.insert(x)` adds a value, and `Map {}` is an empty set. --- ## 4a. Bytes `Bytes` is an immutable sequence of numbers from 0 to 255 (ADR-0037). `==` compares the content. A value cannot be a key of a `Map`, and the markup cannot show it. | Form | Result | Rule | |---|---|---| | `text.bytes()` | `Bytes` | The UTF-8 bytes of a `String` | | `Bytes.from(list)` | `Bytes` | The bytes of a `List<Int>`. A number outside 0 to 255 is a runtime error | | `b.count()` | `Int` | The number of bytes | | `b.isEmpty()` | `Bool` | True when there are no bytes | | `b.get(i)` | `Int?` | The byte at the index `i`, or `none` | | `b.slice(from, to)` | `Bytes` | The bytes from `from` to `to`, with the bounds of `xs.slice` | | `b.text()` | `String` | The UTF-8 text. An invalid sequence becomes U+FFFD | | `b.toList()` | `List<Int>` | The numbers | --- ## 5. Optional values and results | Form | Rule | |---|---| | `none` | The empty value of each type `T?` | | `a ?? b` | `a` when it has a value, else `b` | | `a?.b` | `none` when `a` is `none`, else `a.b` | | `if let x = a { ... }` | Runs the block when `a` has a value. `x` is the value | | `#if let x = a { ... } else { ... }` | The same in markup. `else if let` continues the block | | `a.toList()` | A list with the value of `a`, or an empty list | | `Result<T, E>` | A built-in enum with the cases `ok(T)` and `err(E)`. Use `match` to read it | | `r?` | In a function that gives `Result<_, E>`: returns the `err` value at once, or gives the `ok` value | --- ## 6. Built-in names | Name | Type | Rule | |---|---|---| | `sleep(ms)` | `async fn (Int) -> Void` | A task that ends after `ms` milliseconds. Use it with `await` (spec 8) | | `interval(ms)` | `async fn (Int) -> Void` | A task that ends at the next multiple of `ms` on the clock of the app. A loop with `await interval(1000)` ticks each second and does not drift. Use it in place of a timer (ADR-0026) | | `panic(message)` | `(String) -> Void` | Stops the current batch with a runtime error. A path that ends in `panic` needs no `return` | | `Int64.from(n)`, `Int64.parse(text)` | `Int64`, `Int64?` | A whole number with 64 bits (ADR-0084). `+`, `-`, `*`, `/`, `%` and compares work on two `Int64` values. `x.toInt()` gives an `Int` | | `Time.now()` | `Int` | The milliseconds since 1970-01-01 UTC | | `Time.utc(ms)`, `Time.local(ms)` | `TimeParts` | The date of `ms` in UTC or in the time zone of the machine: `year`, `month`, `day`, `hour`, `minute`, `second`, `millisecond` and `weekday` (ADR-0079). `weekday` is 1 for Monday | | `Time.iso(ms)` | `String` | The ISO 8601 text, for example `2026-11-01T12:00:00.000Z`. A year outside 0 to 9999 has a sign and six digits: `+010000-01-01T00:00:00.000Z` | | `Time.parse(text)` | `Int?` | The milliseconds of an ISO 8601 date. A text with no zone is in UTC. `none` for another text | | `Theme.designWidth()`, `Theme.designHeight()` | `Float` | `scale.design` of `style.toml`, at compile time | | `Theme.minScale()`, `Theme.maxScale()` | `Float` | `scale.min` and `scale.max` of `style.toml`, at compile time | | `Http.event(url)` | `Task<Result<String, String>>` | The next message of a stream of server-sent events (ADR-0077). An `err` ends the stream, and the next call opens it again | | `File.pick(accept)` | `Task<PickedFile?>` | The file dialog of the platform (ADR-0078). A `PickedFile` has `name` and `bytes`. `none` when the user closes it | | `Storage.get(key)` | `String?` | The text of `key` in the store of the app (ADR-0074). `none` on a server | | `Storage.set(key, value)`, `Storage.delete(key)` | nothing | Writes or deletes a value. A store that refuses a write has no effect | | `Theme.setScale(factor)` | nothing | Scales the whole UI, from 0.5 to 3 (ADR-0085) | | `Theme.setToken(name, value)` | nothing | Changes a token of the theme while the app runs (ADR-0082, ADR-0130). The signature is `Theme.setToken<T: Token>(name: String, value: T)`. `Token` is a built-in trait for `Color` and `Float` (ADR-0134). A `Color` changes a color in each variant. A `Float` changes a length in logical px. With a literal name, only that length token can change. With a computed name, each length token can change. An app that never gives a length has no run-time code for lengths (layout spec, section 2.1). With a literal name, a value of the wrong kind is a compile error, for example a `Float` for the color token `accent`. With a computed name, a value of the wrong kind has no effect, as an unknown name has. A length that is not finite has no effect | | `Theme.setMode(mode)` | nothing | Chooses the theme: `.system`, `.light`, `.dark`, `.contrastLight` or `.contrastDark` of the built-in enum `ThemeMode` (ADR-0072, ADR-0103). The app stores the choice. | | `Focus.to(id)` | nothing | Gives the keyboard focus to the element with this `id`, at the end of the batch (ADR-0102) | | `Focus.newId()` | `String` | An id that no other call gives, for `id`, `controls` and `labelledBy` (ADR-0102) | | `Json.embed("../x.json")` | The expected type | The value of a JSON file at compile time. The path starts at the folder of the sources, and it can leave that folder. A file that does not fit the type is a compile error. A key with no field is ignored. | | `Random.float()` | `Float` | A random number from 0 up to, but without, 1. It is not for secrets | | `Random.int(lo, hi)` | `Int` | A random `Int` from `lo` up to, but without, `hi`. `lo` when `hi` is not above `lo` | | `Color.parse(text)` | `Color?` | A color from text at run time: `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb(r g b / a)` or `transparent` (ADR-0050). `none` for another text | | `Color.rgb(r, g, b, a)` | `Color` | The color `rgb(r g b / a)` of three `Int` channels from 0 to 255 and a `Float` alpha from 0 to 1 (ADR-0160). A value outside its range takes the nearest end, as in CSS | | `Color.mix(a, b, t)` | `Color` | The color at `t` from `a` (0.0) to `b` (1.0): `color-mix(in srgb, a, b t%)` of CSS (ADR-0160). The channels mix with their alpha, as in a transition, so a mix with `transparent` has no gray. A token stays a token, so the mix follows the theme. `t` outside 0 to 1 takes the nearest end | | `Icon.parse(text)` | `Result<Icon, String>` | The icon of SVG text that the app gets while it runs, for example from the network. The rules are those of an imported file (ADR-0030). The error has the line. Only an app that calls it links the parser | | `Http.get(url)` | `Task<Result<HttpResponse, String>>` | A built-in `async fn`: a `GET` request (ADR-0037). `err` means that no response came. A status of 404 is `ok` | | `Http.send(request)` | `Task<Result<HttpResponse, String>>` | The same for an `HttpRequest`: `url`, `method` (default `"GET"`), `headers` and `body`. A request ends after 30 seconds, and with its component | | `Json.decode(text)` | `Result<T, String>` | The value of the expected type `T` (ADR-0038): `const feed: Feed = Json.decode(text)?`. The error has a path. A key that is not a field does not matter. A field of a type `T?` can be missing | | `Json.encode(value)` | `String` | The JSON text of a value, in the format of `JSON.stringify` | | `scalar.text()` | `String` | The text of a `Scalar` (ADR-0045): the text itself, a number as in `{x}` of a `Float`, or `true` or `false` | | `Window.width`, `Window.height` | `Float` | The size of the window in logical px (ADR-0106). A read has the dependencies of a state. Only a component can read it. A server gives 0 | | `Platform.os` | `Os` | The target: `.macos`, `.windows`, `.linux`, `.android`, `.ios` or `.web`. It is a constant of the build, so the compiler removes the code of the other targets in `if Platform.os == .web { ... }` | The theme tokens, for example `$accent` and `$text-secondary`, are also built-in names. The [layout spec](https://surface.solenovis.com/docs/layout.md), section 2.1, lists them. --- ## 6a. Locale The module `Locale` formats values for a person in the locale of the app (ADR-0132, spec 14). A locale is a BCP 47 tag, for example `en`, `de` or `pt-BR`. `surf.toml` lists the locales of the project: `locales = ["en", "de"]`. At the start, the app finds the first language of the system that fits an item of the list, with the lookup of RFC 4647: `de-CH` fits `de` (spec 14). Else it takes the first item of the list. If a project uses `Locale` and its `surf.toml` has no key `locales`, or an empty list, the compiler gives an error. The message gives an example line, for example `locales = ["en"]`. | Function | Result | Rule | |---|---|---| | `Locale.number(x, style: NumberStyle = .decimal)` | `String` | An `Int` or a `Float` with the decimal separator and the grouping of the locale: `Locale.number(1234.5)` is `"1,234.5"` in `en` and `"1.234,5"` in `de` | | `Locale.date(ms, style: DateStyle = .medium)` | `String` | The date of `ms` in the time zone of the machine, with the names of months and weekdays of the locale | | `Locale.time(ms, style: DateStyle = .medium)` | `String` | The time of day of `ms` in the time zone of the machine | | `Locale.relative(ms)` | `String` | The text of a difference of `ms` milliseconds: `Locale.relative(-3 * 86_400_000)` is `"3 days ago"` in `en`. A negative value is in the past | | `Locale.list(items)` | `String` | The strings of a `List<String>` with the words of the locale: `["a", "b", "c"]` gives `"a, b, and c"` in `en` and `"a, b und c"` in `de` | | `Locale.plural(n)` | `PluralCategory` | The plural category of an `Int` or a `Float` in the locale | | `Locale.compare(a, b)` | `Ordering` | The order of two strings in the rules of the locale. `.less` when `a` comes before `b`. `xs.sortWith((a, b) => Locale.compare(a, b))` sorts a list in this order (section 3) | | `Locale.set(tag)` | nothing | Changes the locale while the app runs. A tag that fits no item of the list gives the first item. The change is not saved. A `derived` value and a markup expression cannot call it, because it is a write | | `Locale.current` | `String` | The tag of the current locale. It is always an item of the list | - `NumberStyle` has the cases `decimal`, `percent` and `currency(code: String)`, with a currency code of ISO 4217: `style: .currency(code: "EUR")`. A percent shows `x` times 100. - A decimal number has at most 3 digits after the separator, and a percent has none. A currency has the digits of the currency. The value rounds half away from zero, as `round` does. - `DateStyle` has the cases `short`, `medium` and `long`. The locale gives the pattern of each length. - `Locale.relative` uses the largest unit in which the amount is at least 1. The units are year (365 days), month (30 days), week, day, hour and minute. Else it uses seconds. The amount drops its fraction. - `PluralCategory` has the categories of CLDR: `zero`, `one`, `two`, `few`, `many` and `other`. English uses only `one` and `other`. - A read of `Locale.current` and a call of a function of `Locale` have the dependencies of a state. After `Locale.set`, each `derived` value, effect and text that uses a format updates. - `Locale` is valid in each function, also in a function of a `.surfs` file: `fn due(t: Todo) -> String = Locale.date(t.due);`. The checker follows each call, so a component that calls such a function updates after `Locale.set` too (spec 10.2). - A module constant cannot read `Locale`, also through a call. This is a compile error. Use a function, for example `fn title() -> String` (spec 7.3). - A call of a function prop reads `Locale` when a function value of the type of the prop in the app reads `Locale` (spec 10.2). - `Locale.set` does not save the locale, as `Theme.setToken` does not save a token (ADR-0082). An app can save the choice with `Storage`. - On a SurfWave server, each request takes the first tag of its `Accept-Language` header that fits an item of the list, with the same lookup. The tags have the order of their weights. Else it takes the first item of the list. The page hydrates with the same locale. After `Locale.set`, the header `x-wave-locale` replaces `Accept-Language` ([SurfWave](https://surface.solenovis.com/docs/routes.md), section 5). - The web uses `Intl` of the browser. The native targets and the interpreter use the ICU of the system (ADR-0132). The two can have different versions of the data, so a few results can differ (see [native differences](https://surface.solenovis.com/docs/targets.md)). - `Locale.plural` rounds a `Float` to 3 digits after the separator first, as `Locale.number` does: `Locale.plural(1.0001)` is `.one` in `en`. - An app that uses `Locale` loads ICU when it starts. A system without ICU stops the app at its start with an error that names the package of ICU. The `.deb` of such an app depends on ICU, and its MSIX package needs Windows 10 version 1903 (ADR-0086, ADR-0141). --- ## 7. What the library does not have Version 0 has no functions for these tasks. The right column gives a way to do the task in version 0. | Task | Way in version 0 | |---|---| | A repeated action, as `setInterval` does | A loop with `await interval(ms)` in an `async fn`, started in `on mount`. The runtime stops it with its component | <!-- https://surface.solenovis.com/docs/routes.md --> # SurfWave v0 - **Status:** v0 (ADR-0055) - **Related documents:** [Language spec v0](https://surface.solenovis.com/docs/language.md), [Component reference](https://surface.solenovis.com/docs/components.md), [Security](https://surface.solenovis.com/docs/security.md) - **Changes:** 2026-11-06: a desktop app runs the `load` functions of the server in its own process, with no hooks. It keeps its cookies in its data folder, and `Wave.env` reads the environment of the app (2, 6). 2026-11-06: the server has the locale of each request, `x-wave-locale` and `Vary` (5, ADR-0132). 2026-11-05: page transitions on all targets, with transition names and a look of their own (7, ADR-0154). The spec marks them "Not in version 0". 2026-11-05: the server and the adapters have the default `HOST` and `--base`. A request outside the base path gets status 404, and the code of the server sees each path without the prefix (5, ADR-0139). 2026-11-05: the default of `HOST` is `127.0.0.1`, and the `Dockerfile` of `docker` and `fly` sets `HOST=0.0.0.0` (5, ADR-0109). `--base` works for the server target, the static target and the adapters (5, ADR-0139). The spec marks both "Not in version 0". 2026-11-04: the lookup of RFC 4647 finds the locale of `Accept-Language`, and after `Locale.set` the header `x-wave-locale` replaces `Accept-Language` (5, ADR-0132). 2026-11-04: each request has its own locale, and the page hydrates with it (5, ADR-0132). SurfWave gives a project routes from files. A project with a `src/routes` folder has no `src/App.surf`. The compiler writes the root component. ## 1. Files | File | Meaning | |---|---| | `Page.surf` | The page of the URL of its folder | | `Layout.surf` | The frame of each page in its folder. It declares `slot default`, and it shows the page with `@slot default`. | | `Error.surf` | The page for a failed `load` in its folder | | `load.surfs` | The data of the folder: `load` for the page and `layout` for the layout | | `load.server.surfs` | The same, on the server only. The browser reads `/__data.json`. | | `Page@.surf` | A page with no layout around it | | `server.surfs` | Endpoints: `pub async fn get(request: Request) -> Response`, also `post`, `put`, `patch` and `delete` | | `actions.surfs` | Forms: `pub async fn name(request: Request) -> Result<T, Failure>` | | Folder | Meaning | |---|---| | `tickets` | The fixed part `tickets` of the URL | | `[id]` | A route parameter `id` | | `[...rest]` | The rest of the URL, as the parameter `rest`. It can be empty. | | `(app)` | A group: it can hold a layout, and it adds nothing to the URL | | `[id=int]`, `[id=uuid]`, `[id=name]` | A parameter with a matcher. `name` is the function `name` of `params/name.surfs`. | | `[[lang]]` | A parameter that a URL can leave out. Its value is then empty. | A fixed part comes before a parameter, and a parameter comes before a rest. Two pages with one URL are a compile error. `surf routes` lists the routes. ## 2. Data ```surf // src/routes/tickets/[id]/load.surfs import { AppData } from "../../load.surfs"; import { ticketDetail } from "~/api.surfs"; import { TicketDetail } from "~/model.surfs"; pub type TicketData = { detail: TicketDetail, user: User } pub async fn load(id: String, parent: AppData) -> Result<TicketData, String> { const detail = await ticketDetail(id)?; return .ok({ detail: detail, user: parent.user }); } ``` - `load` and `layout` must be `pub async fn`. They return `Result<T, String>` or `Result<T, Failure>`. - A parameter has the name of a route parameter, or the name `parent`. `parent` is the data of the nearest outer layout with a `layout` function. - `later` next to `load` gives a page a value that it does not wait for: `pub async fn later(id: String) -> Result<Slow, String>`. The page has `prop later: Slow? = none`. `later` takes route parameters. On a first visit, the server sends the page after `load`, and it sends the value in the same response. After a navigation, the value comes when it is ready. A failed `later` leaves the prop at `none` (ADR-0062). - `load.server.surfs` takes `parent` too. When the browser asks for its data, the server runs the `load` functions of the outer layouts first, also for a function with no `parent`. The first failure stops the request, so a guard in a `layout` function holds for the data of each page in its folder. The server does not read a value of the browser (ADR-0109). - A `load` function of the server runs only for a URL of its route. The hook `before` reads the path of the page, not `/__data.json`. - The types of two `load` functions must have different names. The result type of a `load.server.surfs` file must be in a file that the browser can read. - `Failure` is `{ status, message, redirect }`. `failure(status, message)`, `redirect(url)` and `notFound()` make one. A `String` error has status 500. **The desktop target.** A desktop app has no server. So it runs each `load`, `layout` and `later` function of `load.server.surfs` in its own process, as a local call, as it does for a remote function (section 6). It sends no request for the data of a page. - The desktop build has each file of the server that such a function imports: a `*.server.surfs` file, and a file in a `server` folder. It has no endpoint (`server.surfs`), no form action (`actions.surfs`) and no hooks (`hooks.server.surfs`). - The `load` functions of the outer layouts run first, also for a function with no `parent`, as on a server. The first failure stops the navigation. So a guard in a `layout` function holds on the desktop target too. - The hooks do not run, because a desktop app gets no requests. A guard in `before` holds only on a server. Put a guard that each target needs in a `layout` function. A desktop build of an app with hooks gives a warning. - The app keeps its cookies as a browser does: in its data folder, until `maxAge` ends. A `maxAge` of 0 or less removes the cookie. A navigation reads the cookies that the app has at its start. So a cookie of `Wave.setCookie` counts from the next navigation, as in a browser, which sends a new cookie with the next request. - `Wave.env` reads the environment of the process of the app, in each file. The compiler checks the names in a file that the browser also reads, as for the web (section 5). ## 3. Props The router gives a page, a layout or an error page the props that it declares: | Prop | Value | |---|---| | A field of the data | That field | | `data` | The whole data | | A route parameter | Its text | | `path` | The path of the URL | | `url` | The path and the query. It changes with each navigation. | | `navigating` | True while a new route loads | | `failure` | The `Failure`, on an error page | | `form` | The result of the last action of the page, as JSON text. It is empty before an action. | A layout prop that has the name of a field of the data of a page gets that field while the page shows. Give it the default `none`. ## 4. The module `surf:wave` Import it with `import { Wave } from "surf:wave"`. `~/x.surfs` is a path from `src`. | Function | Meaning | |---|---| | `Wave.go(url)`, `Wave.replace(url)`, `Wave.back()` | Navigation | | `Wave.reload()` | Runs each `load` function of the route again | | `Wave.path()`, `Wave.url()` | The URL | | `Wave.query(name)`, `Wave.hasQuery(name)`, `Wave.queryOf(url, name)` | The query of the URL | | `Wave.title(text)` | The title of the page. Call it in an `effect` of the page. | | `Wave.notify(text)`, `on Wave.notified(text) { ... }` | A message from a page to a layout | A `<Link href="/x">` with no `on:press` goes to the route. The module also has the types and functions of the server: | Name | Meaning | |---|---| | `Request` | `method`, `url`, `path`, `headers`, `cookies`, `form`, `params` (the route parameters of a `server.surfs` file) and `body`. The maps have `String` keys and values. `path` has one form (section 5). | | `Response` | `status`, `headers` and `body` | | `textResponse(body)`, `jsonResponse(body)`, `response(status, kind, body)` | A response. `kind` is the media type. | | `redirectResponse(url)`, `methodNotAllowed()`, `failedResponse(failure)` | A response for these cases | | `Failure`, `failure(status, message)`, `redirect(url)`, `notFound()` | The error of a `load` or of an action | A form is an element with `role="form"`, `method="post"` and `action="/path?/name"`, where `name` is a function of `actions.surfs`. The value of each `Input` with a `name` is in `request.form`. A form works with no script. The server refuses a form from another site. [Build a full app](https://surface.solenovis.com/docs/full-app.md) has a complete example. `href` of a `Link` and `action` of a form take a relative URL, `http`, `https`, `mailto` and `tel`. The element gets no attribute for another scheme, because a scheme such as `javascript:` runs a script in the page (ADR-0109). ## 5. The server `surf build --target server` writes `build/server/<name>-server`. `HOST` is its address, and `PORT` is its port. `ORIGIN` is the address of the site behind a proxy. `API_ORIGIN` is the http address for a relative `Http` request, and for `/api/`. [Security](https://surface.solenovis.com/docs/security.md) lists each setting and each limit of the server. `surf build --target static` writes each page as a file. - Without `HOST`, the server binds to `127.0.0.1`, so only its own machine reaches it (ADR-0109). A container needs `HOST=0.0.0.0`, and the `Dockerfile` of the adapters `docker` and `fly` sets it. The unit of `systemd` keeps the default, because a proxy on the same machine is in front of it. - `surf build --base /docs/` serves the site at the base path `/docs/`, also with the server target, the static target and an adapter (ADR-0139). The base path is the prefix of each address. The page, the router, each `Link` and each address of a file get the prefix. Each request to the server code gets it too. These requests are the data of a page (`/__data.json`), a form action, a remote function (`/__remote/`), an endpoint and `/__version`. An `href` or an `action` that starts with `/` gets the prefix, and a relative `href` or an `href` with a scheme does not change. In the browser, a relative `Http` request with a path that starts with `/` gets the prefix too, because it goes to an endpoint or to `/api/`. `Wave.base()` gives the prefix without the last `/`, also on the server. - With the server target, the static target and an adapter, the base path starts and ends with `/`. Its parts have only letters, digits, `-`, `.`, `_` and `~`. - The root of the site is the base path, for example `/docs/`. The server sends status 308 from `/docs` to `/docs/`. A request outside the base path gets status 404, before a hook runs. - The code of the server sees each path without the prefix. A hook, a `load`, an action, an endpoint and a remote function read `/about` for the address `/docs/about`. So the code of an app does not change with its place on the host. A redirect of this code names a path without the prefix, and the server adds the prefix to the header `location`. - The static target writes each page at the root of its folder, for example `about/index.html`. The host serves the folder at the base path. - Not in version 0: a picture of `src/assets` reads its file relative to the address of the page. So on a page below the root of the site, a path such as `assets/logo.png` names no file. An example of such a page is `/docs/notes/1`. A web app with no routes gives the base path only to its script and its stylesheet, not to a `Link`, a form or an `Http` request. Each path has one form before a hook runs: no empty part, no `/` at the end, and a `%` escape only for a byte that needs it. A path in another form gets status 308 to the one form. A part `.` or `..` gets status 400. A part with `/` or `\`, also in the form `%2F` or `%5C`, gets status 400, because a rest parameter joins its parts with `/` (ADR-0109). So a route parameter has no `/`, and the parts of `request.path` are the parts of the router. The script uses the nodes of the HTML (ADR-0060). The app builds no second page, so focus, the scroll offsets and typed text stay. A value that differs on the server changes to the value of the browser. A block with other nodes on the server gets new nodes. A press before the script runs does nothing, but a link and a form work. - `src/hooks.server.surfs` can have `pub async fn before(request: Request) -> Response?` and `pub async fn after(request: Request, response: Response) -> Response`. For `/__data.json`, `request` has the path of the page. - `pub const ssr = false`, `csr = false`, `prerender = false` and `entries = ["/items/1"]` in `load.surfs` apply to the folder and to its subfolders. - A file with the name `*.server.surf`, and a file in a `server` folder, are not in the script of the page. - `Wave.cookie(name)`, `Wave.setCookie(name, value, maxAge)` and `Wave.env(name)` read and write the request, in a `load`, an action, an endpoint, a hook and a remote function. A cookie has `HttpOnly`, `SameSite=Lax` and `Path=/`, and `Secure` except on this machine. In the browser, `env` gives only what the page sets in `surfEnv`. - In a file that the browser also reads, the argument of `Wave.env` must be a name in quotes that starts with `PUBLIC_`. Another argument is a compile error (ADR-0109). A file of the server can read each variable: `*.server.surfs`, `server.surfs`, `actions.surfs`, `*.remote.surfs`, and a file in a `server` folder. - A relative `Http` request goes to `API_ORIGIN` with the `cookie` and `authorization` headers of the client. An absolute URL gets neither. The server has `http:` only. - `Locale` (language spec, section 14) takes a locale for each request. The server takes the tags of the `Accept-Language` header in the order of their weights, and finds an item of the list `locales` of `surf.toml` with the lookup of RFC 4647 (language spec, section 14). So `de-CH` fits `de`. Without a fit, it is the first item of the list. A `load`, an action, an endpoint, a hook, a remote function and the page that the server sends use this locale. The page hydrates with the same locale: the element of the app has the tag in `data-locale`, and the script takes it. `Locale.set` in the browser changes the locale after that. The server formats with the ICU of the system, as the native targets do. So a few texts can differ from the texts of the browser, and they change at hydration as each other value that differs does. A page of `surf build --target static` has no request, so it has no `data-locale`, and the browser chooses the locale with its languages. - After `Locale.set` in the browser, the client sends the header `x-wave-locale` with each data request (`/__data.json`), each action and each remote call (ADR-0132). Its value is the tag of `Locale.current`. For that request, the server uses this tag in place of `Accept-Language`, with the same rule of the fit. A new load of the page has no such header, because `Locale.set` does not save the locale. - A response that depends on the locale has the header `Vary: Accept-Language, x-wave-locale`, so a cache keeps one copy for each locale (ADR-0132). A response depends on the locale when its code reads `Locale`. Each page reads it, because the page has the tag in `data-locale`. - A server of an app with `Locale` loads ICU when it starts. Without ICU, the server stops at its start with an error that names the package (ADR-0132). This applies to a system and to the image of a container. The `Dockerfile` of an adapter must then install the package of ICU of its image. - A server has no Rust crates of its own: an extern Rust file has the standard library and the runtime. A Surf package (ADR-0071) brings `.surf` files, not crates. A database stays behind an API, and `API_ORIGIN` is its address. `surf build --adapter <name>` chooses the target, and it writes the files of a host (ADR-0064): | Adapter | Target | Files | |---|---|---| | `docker` | server | `Dockerfile`, `.dockerignore`. The `Dockerfile` sets `HOST=0.0.0.0` | | `fly` | server | The files of `docker`, and `fly.toml` | | `systemd` | server | `<name>.service`, with the default `HOST` | | `netlify` | static | `netlify.toml` | | `vercel` | static | `vercel.json` | | `cloudflare` | static | `web/_redirects` | | `github-pages` | static | `web/.nojekyll` | The files of `netlify`, `vercel` and `cloudflare` send each unknown URL to `404.html`. With `--base /docs/`, the rule names the prefix: `cloudflare` writes `/docs/* /docs/404.html 200` (ADR-0064, ADR-0139). ## 6. Remote functions A public `async` function of a `*.remote.surf` file runs on the server, and a component calls it as a local function (ADR-0063): ```surf // src/todos.remote.surfs import { Todo } from "./todo.surfs"; pub async fn addTodo(text: String) -> Result<Todo, String> { return .ok(await saveTodo(text)); } ``` ```surf import { addTodo } from "./todos.remote.surfs"; ``` - The result must be `Result<T, String>`. The error of the function, or a failed request, is `.err(text)`. - The types of the parameters and of the result come from another file, because the browser reads them too. - A function that is not public stays on the server. - `hooks.server.surfs` runs for each call, with the path `/__remote/<file>/<name>`. A guard of a page does not cover a remote function, so check the session in the function or in the hook. - The file needs `--target server`. On the desktop target, it is a local call, with no hooks. Its cookies follow the rules of a desktop app (section 2). ## 7. Navigation | Function or event | Meaning | |---|---| | `Wave.depends(key)`, `Wave.invalidate(key)` | A layout `load` that read `key` runs again | | `Wave.hold(true)`, `on Wave.blocked(url)` | A navigation stops, and the app can ask the user | | `on Wave.leaving(url)`, `on Wave.arrivedAt(url)` | The start and the end of a navigation | | `Wave.pushState(url, value)`, `on Wave.shallow(value)` | A URL with no new page | | `Wave.snapshot(text)`, `Wave.restored()` | The state of a page for the back button | | `Wave.animateRoutes(true)` | Page transitions between pages | | `await Wave.checkUpdate()` | True when the server has a new build | | `Wave.preload(url)`, `Wave.base()` | The code and the data of a route now, and the folder of the app, for example `/docs` for `--base /docs/` (ADR-0139) | `<Link>` takes `preload="off"` or `"tap"`, `reload`, `replace`, `noScroll` and `keepFocus`. **Page transitions** (ADR-0154). - `Wave.animateRoutes(true)` turns on the page transitions on all targets. `Wave.animateRoutes(false)` turns them off. They are off by default. - A navigation that changes the path of the URL has a page transition. This includes a `Link`, `Wave.go`, `Wave.replace`, `Wave.back` and the back and forward buttons. The first page, a change of the query only, `Wave.pushState` and `Wave.reload` have no page transition. - The transition starts when the data of the new page is ready, just before the router shows the new page. The picture of the old page shows the old page at that time. - By default, the picture of the old page and the new page cross-fade in 250 ms with `ease`, as in CSS View Transitions. The two pictures blend with `plus-lighter`, so a part that does not change stays the same. - An element with `view-transition-<name>` moves from its old box to its new box (layout spec 5.10). It is not a part of the picture of the page. - `[view-transition]` in `src/style.toml` gives the look. `old` and `new` name `keyframes` for the picture of the old page and of the new page. `ms` and `ease` give the time and the easing of each part of the transition, also of the moves of the names. `[view-transition.back]` gives the look of a navigation back. An unknown name is a compile error. A navigation back has the type `back` of CSS View Transitions. ```toml [view-transition] ms = 300 ease = "ease-out" old = "fade-out" new = "rise" [view-transition.back] old = "fade-out" new = "fall" ``` - During the transition, the new page is live. It gets each press and each key, and the accessibility tree is the tree of the new page. The pictures get no press. The focus follows the rules of the router. - With the setting of reduced motion on, a navigation shows the new page at once. - The web uses `document.startViewTransition`. A browser without it shows the new page at once. The native targets keep a picture of the window and of each named element, and they paint the transition over the new page. Only an app that calls `Wave.animateRoutes` has this code. - A `Transition` around `@slot default` of a layout does not animate a new page. A new page is not a block of the layout (component reference 4.15). <!-- https://surface.solenovis.com/docs/security.md --> # Security - **Status:** current on 2026-11-03. - **Related documents:** [Build a full app](https://surface.solenovis.com/docs/full-app.md), [SurfWave reference](https://surface.solenovis.com/docs/routes.md), [limits of version 0](https://surface.solenovis.com/docs/limits.md), [ADR-0109](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/adr/0109-the-limits-of-the-server.md) This page lists the limits of the server of `surf build --target server`, their settings, and what an app must do itself. --- ## 1. Settings The server reads these environment variables when it starts. A value that is not valid stops the start. | Variable | Default | Meaning | |---|---|---| | `HOST` | `127.0.0.1` | The address of the server. With the default, only this machine reaches the server, for example a proxy on the same machine. A container needs `0.0.0.0`, and the `Dockerfile` of `--adapter docker` and `--adapter fly` sets it (ADR-0109). | | `PORT` | `3000` | The port. | | `ORIGIN` | none | The address of the site in the browser, for example `https://example.com`. A site behind a proxy needs it. See section 3. | | `API_ORIGIN` | none | The `http` address of the API, for a relative `Http` request and for `/api/`, for example `http://api` or `http://127.0.0.1:8080`. With no port, the server uses port 80. A `/` at the end is optional. A path, or `https`, is not valid. | | `BODY_SIZE_LIMIT` | `10M` | The bytes of the body of one request. A number can end with `K`, `M` or `G`. A larger body gets status 413. All bodies in memory at one time have 16 times this limit, and a request above that gets status 503. | | `API_SIZE_LIMIT` | `64M` | The bytes of one answer of the API. A larger answer is an error of the `Http` call, or status 502 for `/api/`. | | `MAX_CONNECTIONS` | `1024` | The open connections. A new connection above this limit gets status 503. The limit of open files of the system must be higher. | | `REQUEST_TIMEOUT` | `30` | The seconds that a client has to send one request, and the seconds of one write. A slower client gets status 408. | | `SURF_DEV` | none | `surf dev` sets it. The server then marks the answer of each page with the header `x-surf-page`, so `surf dev` serves its own page for a page, and the answer of an endpoint as it is. Do not set it in a deployment. | --- ## 2. Fixed limits | Limit | Value | Result above the limit | |---|---|---| | The head of a request | 64 KiB | Status 431 | | The headers of a request | 100 | Status 431 | | The wait of a connection for its next request | 5 seconds | The connection closes | | The wait of a page for its data | 10 seconds | The page shows what it has | | A connection, a read and a write to the API | 30 seconds each | An error of the `Http` call | | A picture on a native target | 2^26 pixels | The picture does not paint | The server also refuses these requests, and it closes the connection after the answer: | Request | Status | |---|---| | A path with no first `/`, a full address, or a `#` | 400 | | A part `.` or `..` in a path, also in escapes | 400 | | A `/` or a `\` in a part of a path: `%2F`, `%5C` or `\`, in each case of the letters | 400 | | Two `content-length` values, or a value that is not digits | 400 | | A space before the colon of a header, a line that does not end with CR and LF, or a control character in a header | 400 | | HTTP/1.1 with no `Host`, or with two | 400 | | `transfer-encoding` | 501 | | Another version than HTTP/1.0 and HTTP/1.1 | 505 | Each response has `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`. A hook `after` can set another value. The server sends no `Content-Security-Policy`, because a policy depends on the app. A page with a `later` value has an inline script. --- ## 3. Run the server behind a proxy The binary has no TLS. A public site needs a proxy in front of it. The proxy must do these tasks: 1. Do TLS, and send `Strict-Transport-Security`. 2. Limit the rate and the connections of each client. The server sees only the address of the proxy. 3. Send a request body with a length. The server refuses a body in chunks. Set `ORIGIN` to the address that the browser shows. The server compares it with the `origin` of each form and each remote call. With no `ORIGIN`, the server compares the `origin` with `Host`, and a proxy that changes `Host` makes the server refuse each form of the site. `ORIGIN` also decides `Secure` on a cookie: `https` adds it, and `http` leaves it out. With no `ORIGIN`, each cookie has `Secure`, except on `localhost`, `*.localhost`, `127.0.0.1` and `[::1]`. A browser drops such a cookie on plain HTTP. So a site on plain HTTP in a private network needs `ORIGIN=http://...`. --- ## 4. What an app must do itself ### Guard each way to the data - `request.path` in a hook has one form: `/%61dmin/` reaches the hook as `/admin`, after a redirect. So compare it with `==`. For a folder, compare the folder and the text under it: `request.path == "/admin" || request.path.startsWith("/admin/")`. - A path with `%2F` or `%5C` gets status 400. A rest parameter `[...rest]` joins its parts with `/`, so `/files/a%2Fb` and `/files/a/b` gave the page the same value. The guard of a folder saw only the second path. A route parameter therefore has no `/`. Put such a value into the query. - A guard in the hook `before` also holds for the data of a page, because the hook reads the path of the page. - A guard in a `layout` function holds for the page and for the data of the page. For a data request, a `load` function runs after each outer `layout` function, and the first failure stops it. - A page render can start a `load` function with no `parent` together with the `layout` functions. The page does not show after a failure of a guard, but that `load` function can run. So a `load` function must not change data: use an action. - A remote function has the path `/__remote/<file>/<name>`, not the path of a page. Check the session in the function, or in the hook. - An endpoint of `server.surfs` and the forward of `/api/` have their own paths. Guard these paths too. An API must check its own requests, because a client can reach it through `/api/`. - A page with no server `load` function has no data on the server. A link in the browser shows it with no request. Its code is in the script of the page, so do not put a secret into a page. ### Keep sessions on the server - Store a random session id in a cookie, not a name. The server of `examples/notes` trusts a name, so that the guide stays short. - A cookie of `Wave.setCookie` has `HttpOnly` and `SameSite=Lax`. `HttpOnly` protects the read of the cookie, not the calls of a script in the page. - The server checks that a form comes from this site. An action must still check that the user can do the action. ### Treat each value of a user as hostile - Check a redirect to a value of a user. The server refuses a line end, but `https://evil.example` and `//evil.example` are valid addresses. Accept only a path of your site: ```surf fn safePath(url: String) -> String { return url.startsWith("/") && !url.startsWith("//") && !url.contains("\\") ? url : "/"; } ``` - Check a value before it goes into a URL of `Http`. The server refuses a control character and a step `..`, and a route parameter has no `/`. But `?` and `#` in a route parameter, and `/` in a value of the query, change the address. Use a matcher such as `[id=int]` or `[id=uuid]`, or a matcher of the project. - Do not give an address from a user to `Http` on the server. The server can reach hosts that the user cannot reach. - A relative URL of `Http` goes to `API_ORIGIN` with the cookies and the `authorization` header of the client. An absolute URL goes out with neither. - The server has no `https:` client. An absolute URL must use `http:`, so use it only inside your own network. - `href` and `action` take a relative URL, `http`, `https`, `mailto` and `tel`. The element gets no attribute for another scheme. ### Keep secrets out of the page - The value of each `load` function reaches the browser, also a value of `load.server.surfs`. Return only what the page shows. - `Wave.env` in a file that the browser also reads needs a name in quotes that starts with `PUBLIC_`. Read a secret in a file of the server: `*.server.surfs`, `server.surfs`, `actions.surfs`, `*.remote.surfs`, or a file in a `server` folder. - Do not commit a key. `.gitignore` of `surf new` names `.env`, `*.pem` and `*.key`. --- ## 5. Tools - `surf dev` listens on `127.0.0.1`. It answers only a `Host` of `127.0.0.1`, `localhost` or `[::1]` with its port. It serves only the files in `src/assets`. - `surf dev` and the inspector refuse a request of a page of another origin with status 403. A browser sends no `Origin` for `<script src>`, so the server reads `Sec-Fetch-Site`: only `same-origin` and `none` pass. A page on `localhost` with another port is `same-site`, so it does not pass. A link of another site can open a page of the app in a window, for example the redirect of a sign-in service. That site cannot read the window. A frame of another site cannot open a page. The inspector opens no page for a link of another site. - Each response of `surf dev` and of the inspector has `Cross-Origin-Resource-Policy: same-origin`. A browser with no `Sec-Fetch-Site` then does not give the response to a page of another origin. - `surf build --single-file` and the playground put the script and the styles into the page. `<` before `/script` or `!--` becomes `\x3C`, and `<` before `/style` becomes `<\`, in each case of the letters. So a string of the app cannot end the script of the page. - `surf upgrade` and `scripts/install.sh` check the SHA-256 of the archive with `SHA256SUMS` of the same release. This check finds a broken download, not a changed release. The Rust toolchain and the runtime pack have their SHA-256 in `surf`. - `surf upgrade` installs only a newer version, so a release server cannot give back an older version with a known fault. - A download of `surf` from an `https` address does not follow a redirect to `http`. - A new runtime pack removes the old versions in `toolchain` and `runtime` of `SURF_HOME`, and the build results in `target`. A folder with another name stays, and `target` stays when Cargo did not make it. - `surf install` runs no code of a package, and it refuses a symbolic link. A native build compiles the `.native.rs` files of a package, so use a package that you trust. - The editor extensions start `surf` from the settings of the user or from the `PATH`. A folder cannot choose this program. <!-- https://surface.solenovis.com/docs/commands.md --> # The `surf` command - **Status:** current on 2026-11-06, for version 0. - **Related documents:** [getting started](https://surface.solenovis.com/docs/getting-started.md), [build a full app](https://surface.solenovis.com/docs/full-app.md), [ADR-0101](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/adr/0101-one-install-for-every-target.md) `surf` is the only tool that a developer installs. This page describes the install, each command and each option. --- ## 1. Install ```bash curl -fsSL https://git.solenovis.com/henning.liemant/surface/raw/branch/main/scripts/install.sh | sh ``` The script finds the system, downloads the newest release, checks its SHA-256, and puts `surf` into `~/.surf/bin`. Add this folder to the `PATH`. The releases cover Linux x86-64 and macOS arm64. A web build needs nothing else. The first native build downloads two parts into `~/.surf`, and this takes some minutes: - A pinned Rust toolchain from `static.rust-lang.org`: about 150 MB on macOS and 210 MB on Linux. - The runtime pack of this version of `surf`: about 20 MB. `surf` checks the SHA-256 of each download before it unpacks the file. After these downloads, a build needs no network. All projects share one target folder in `~/.surf`, so a second project does not compile the runtime again. On a Mac with an M-series chip, the first build of the hello-world app took 50 to 200 seconds with the downloads. A second project took 10 to 30 seconds. The load of the machine gave these ranges. The folder `~/.surf` then held about 1.1 GB. A native build needs a linker, and Rust does not bring one: - On macOS, run `xcode-select --install`. - On Linux, install the package `gcc`. `surf doctor` shows what the machine has. The Linux binary comes from Debian 12 (glibc 2.36). An older system can refuse to start it. ### Build `surf` from a checkout Only framework developers do this. It needs Rust 1.99 or later: ```bash cargo build -p surf-cli ``` This binary uses the `cargo` on the `PATH` and the crates of its checkout, so it must stay in that checkout. It does not use `~/.surf`. --- ## 2. Projects ### `surf new <folder>` Makes a new project from the template. The folder must be empty, or must not exist. The project gets `src/App.surf`, `.gitignore`, and `surf.toml` with the newest edition, for example `edition = "2026"` ([stability](https://surface.solenovis.com/docs/stability.md)). Each command reads the edition of `surf.toml`, and the edition of each package in `src/pkg`. An edition that this `surf` does not know stops the command. ### `surf check <folder>` Checks all `.surf` files in the folder, or in its `src` folder. The command shows all errors, and it runs no code generator. ### `surf lint [folder]` Finds code that compiles but is probably wrong (ADR-0070). A finding gives a non-zero exit code. The default folder is the current folder. The rule `edition` finds a project that names no edition in `surf.toml`. The rule `field-select` finds `select-none` on an `Input` or a `TextArea`, because the utility does not turn off the selection of the text of a field (ADR-0131). The rule `media-format` finds the sources of a `Video` or an `Audio` that do not play on each target of ADR-0155. It reads the `type` of a `Source`, or the first bytes of a file under `assets/`. The rules `no-captions` and `autoplay-stop` find a `Video` with sound and no captions, and a `Video` that plays and loops by itself with no way to stop (ADR-0157, ADR-0158). ### `surf test [folder] [--filter <word>]` Runs each `pub fn test...` in the folder `tests` of the project, with no build, no browser and no window (ADR-0069). Section 8 of the [guide](https://surface.solenovis.com/docs/full-app.md) lists the functions of `surf:test`. A test has a clock of its own: `Test.wait(ms)` moves it, and no real time passes. | Option | Meaning | |---|---| | `--filter <word>` | Runs only the tests with this word in the name. | ### `surf fmt [paths...] [--check]` Formats `.surf` files. A folder means its `.surf` files and the files in its folders. The default path is the current folder. Each level of indent has 4 spaces. A project can set another number of spaces, from 1 to 8, in `surf.toml` (ADR-0162): ```toml [fmt] indent = 2 ``` A file uses the `surf.toml` of the nearest folder above it. A wrong value stops the command before it changes a file. | Option | Meaning | |---|---| | `--check` | Changes no file. The command fails when a file would change. | ### `surf routes [folder]` Shows the routes of a project with `src/routes` (ADR-0055). --- ## 3. Builds ### `surf build [folder]` Builds the project. The default folder is the current folder. | Option | Meaning | |---|---| | `--target web` | The default. Writes plain JavaScript and CSS into `build/web`. | | `--target desktop` | Writes a release binary for each system of the list `[desktop] systems` of `surf.toml` into `build/desktop/<system>`, and an app bundle for macOS. Without the list, it builds for the system of this machine (ADR-0137). | | `--os macos\|linux\|windows` | Desktop target: builds one system for this run, in place of the list. Without `--arch`, the system has the processor of this machine (ADR-0137). | | `--arch x86_64\|aarch64` | Desktop target: builds one system for this run, in place of the list. Without `--os`, the system has the `os` of this machine (ADR-0137). | | `--target server` | Writes a server binary and its web files into `build/server`, for a project with `src/routes` (ADR-0057). The binary listens on `HOST` and `PORT`. Without `HOST`, it binds to `127.0.0.1` (ADR-0109). | | `--target static` | Writes each page as a file into `build/static`, for a host with no server. | | `-o <out>` | Writes into the folder `out`. A desktop build writes each system into `out/<system>`. | | `--base <path>` | The base path: the prefix of each address of the site, for example `/a/` (ADR-0139). The web target, the static target, the server target and the adapters take it. The page, the router, each `Link`, each address of a file and each request to the server code get the prefix. Without it, the folder of a web build can go anywhere. With the server target, the static target and an adapter, the path starts and ends with `/`. Its parts have only letters, digits, `-`, `.`, `_` and `~`. The server answers a request outside the base path with status 404. The code of the server sees each path without the prefix (wave spec, section 5). The desktop target, `--embed` and `--single-file` give an error with this option. | | `--split auto\|on\|off` | Web, server and static targets: splits the script into chunks (ADR-0047). `auto` is the default. It makes chunks when they save more than the round trip that they cost. | | `--single-file` | Web target: writes one file, `index.html`, with the assets inside. Use it for a host that loads one HTML text, such as the webview of a plugin. The file has the text of `src/head.html`, as a normal build has. | | `--x11` | Desktop target on Linux: the binary can also open an X11 window (ADR-0088). | | `--embed` | Desktop target: writes a library crate into `build/embed` for a host app in Rust (ADR-0041). | | `--adapter <host>` | Writes the files of a host beside the build, and chooses the target (ADR-0064). `docker`, `fly` and `systemd` use the server target. `netlify`, `vercel`, `cloudflare` and `github-pages` use the static target. The `Dockerfile` of `docker` and `fly` sets `HOST=0.0.0.0`, because a container needs it. The unit of `systemd` keeps the default `127.0.0.1` (ADR-0109). With `--base`, the files of `netlify`, `vercel` and `cloudflare` name the prefix (ADR-0139). | #### The systems of a desktop build `surf.toml` lists the systems that `surf build --target desktop` and `surf package` build (ADR-0137): ```toml [desktop] systems = ["macos-aarch64", "windows-x86_64", "windows-aarch64"] ``` - A system has the form `<os>-<arch>`. `<os>` is `macos`, `linux` or `windows`. `<arch>` is `x86_64` or `aarch64`. - A name that is not a system stops the command with an error that lists the systems. An empty list and a system that is two times in the list are errors too. - Without the key, the list has only the system of this machine. - The binary of each system goes into `build/desktop/<system>`, for example `build/desktop/windows-x86_64/hello.exe`. This is also the folder of a build with one system. - `--os` and `--arch` build one system for one run. They do not change `surf.toml`. They are only for the desktop target, and not for `--embed`. - Each machine can build Windows (ADR-0136). Only a Mac can build macOS, for `x86_64` and for `aarch64`. A build for Linux needs a Linux machine with the processor of the system. Another system stops the command before the first build, with an error that names the system and the reason. - The private toolchain downloads the parts of each system when they are missing: `rust-std`, and `rust-mingw` for Windows. Each part has a SHA-256 in `surf`. - A binary from a checkout uses the Rust on the `PATH`. When that Rust has no parts for a system, the error gives the command that adds them, for example `rustup target add x86_64-pc-windows-gnullvm`. `rustup` adds `rust-mingw` only on Windows. So on macOS and on Linux, a binary from a checkout needs a manual copy of `rust-mingw` for a Windows build. A release binary does not need it. - `surf dev --target desktop` runs only on the system of this machine. ### `surf dev [folder]` Serves the project and reloads the page after each change. With `--target desktop`, it runs the project in a window and reloads it after each change, with the interpreter of the IR. A project with routes and server code (for example a `load.server.surfs` file, an `actions.surfs` file or a remote function) also needs its server. The command builds the binary of `surf build --target server` and runs it on a free port of the loopback address. Each request that is not for a page or for a file of `surf dev` goes to that binary: the data of a page, a form, a remote function and an endpoint. A request is for a page when it is a `GET` that accepts `text/html`. After each change, the binary builds again in the background, and the old binary answers until the new one starts. The first build needs the tools of a native build (see `surf doctor`). Later builds take some seconds. The command prints the address of the [inspector](https://surface.solenovis.com/docs/inspector.md), the dev tools of the running app. The inspector listens only on this machine. | Option | Meaning | |---|---| | `--port <number>` | The port of the server. The default is 8000. Port 0 takes any free port. With `--target desktop`, the inspector uses this port. Without this option, the inspector takes a free port when another program has port 8000. | | `--target web\|desktop` | `web` is the default. | ### `surf package [folder] [-o <out>]` Builds an installer (ADR-0086). macOS gets a signed disk image, Linux gets a Debian package and a tar file, and Windows gets an MSIX package (ADR-0141). The default output folder is `build/package` in the project. It builds the systems of the list `[desktop] systems`, or the one system of `--os` and `--arch` (ADR-0137). The files of each system go into `build/package/<system>`, for example `build/package/macos-aarch64/hello-0.1.0.dmg` or `build/package/windows-x86_64/hello-0.1.0.msix`. `surf` writes the MSIX package and its signature itself, with no other tool. So each machine that can build Windows can make the package. The package needs a code-signing certificate, because Windows installs only a signed package. A test can use a self-signed certificate, and the test machine must trust it. Without a certificate, the command stops with an error for a system of Windows. | Option | Meaning | |---|---| | `-o <out>` | Writes into the folder `out`. The command then needs the name of the project folder too. | | `--os macos\|linux\|windows` | Builds one system for this run, in place of the list. Without `--arch`, the system has the processor of this machine. | | `--arch x86_64\|aarch64` | Builds one system for this run, in place of the list. Without `--os`, the system has the `os` of this machine. | | `--pfx <file>` | Windows: the `.pfx` file with the code-signing certificate and its RSA key. Without it, the command reads `SURF_PFX`. | | `--pfx-password <password>` | Windows: the password of the `.pfx` file. Without it, the command reads `SURF_PFX_PASSWORD`, and then uses an empty password. Other users of the machine can see an option in the list of processes, so prefer the variable. | #### The MSIX package of Windows - The package has the binary, the assets and the fonts of the build, and three gray pictures for the Start menu. It also has the files of the format (ADR-0141): `AppxManifest.xml`, `AppxBlockMap.xml`, `[Content_Types].xml` and the signature `AppxSignature.p7x`. - The name of the package is the name of the binary, for example `hello`. Windows needs 3 to 50 letters, digits, `-` or `.`. - The publisher of the package is the subject of the certificate, in the form of Windows, for example `CN=Surface Test, O=Solenovis`. The name of the publisher that Windows shows is the common name (`CN`) of the subject. - The version has four numbers from 0 to 65535. `version = "0.1.0"` of `surf.toml` becomes `0.1.0.0`. Another form, for example `1.0.0-beta`, stops the command. - The command reads the certificate before the first build. A wrong password stops the command with an error that asks for the correct password. - `surf` reads a `.pfx` file with PBES2 and AES, which OpenSSL 3 writes by default, or with 3DES, which `Export-PfxCertificate` of Windows writes by default. It does not read a file with RC2, which LibreSSL writes by default for the certificates. Export such a file again with `openssl pkcs12 -export -keypbe AES-256-CBC -certpbe AES-256-CBC`. - The signature has no timestamp, so Windows accepts it only while the certificate is valid. A `.pfx` file can sign a test package, but not a release for the public: a public code-signing certificate keeps its key on hardware. The [Windows plan](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/plan/windows.md) (W11) compares the ways to sign a release. Make a test certificate and install a test package in Windows PowerShell. `Add-AppxPackage` installs a package only when a store of the machine trusts its certificate. The store of the user is not enough. So the import into `LocalMachine\TrustedPeople` needs PowerShell as administrator, and it changes the trust of the whole machine. Remove the certificate from that store after the test. ```powershell $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject 'CN=My Test' -CertStoreLocation Cert:\CurrentUser\My $password = Read-Host -AsSecureString 'Password of the .pfx file' Export-PfxCertificate -Cert $cert -FilePath test.pfx -Password $password Export-Certificate -Cert $cert -FilePath test.cer Import-Certificate -FilePath test.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople $env:SURF_PFX = 'test.pfx' $env:SURF_PFX_PASSWORD = '<the password>' surf package --os windows Add-AppxPackage build\package\windows-x86_64\hello-0.1.0.msix ``` ### `surf extern stubs [folder]` Writes the stubs of the implementation files of the extern modules (language spec, section 13.2): `<file>.native.rs` and `<file>.web.js` when they do not exist, and `<file>.web.d.ts`. ### `surf emit-js <folder>` Writes the generated JavaScript module to stdout. | Option | Meaning | |---|---| | `--values ref\|clone\|path` | How the code copies a value. `path` is the default (path copying). | | `--runtime <path>` | The import path of the web runtime. The default is `./surf.js`. | | `-o <out>` | Writes the module to the file `out`. | | `--css <out>` | Writes the stylesheet to the file `out`. | ### `surf emit-rust <folder>` Writes the generated Rust code to stdout. | Option | Meaning | |---|---| | `-o <out>` | Writes the code to the file `out`. | --- ## 4. Packages `surf.toml` names the packages of a project, and `surf.lock` holds the commit of each package (ADR-0071). Run these commands in the folder of the project. ### `surf add <name> <git or path> [--rev <commit>]` Installs a package, and then adds it to `surf.toml`. When the install fails, `surf.toml` and `src/pkg` do not change. The command changes only the package in `surf.toml`, so the comments and the other lines stay (ADR-0114). The `path` of a package is relative to the file that names it: `surf.toml` of the project, or `surf.toml` of a package. | Option | Meaning | |---|---| | `--rev <commit>` | A package from git: uses this commit, tag or branch. Without it, `surf update` takes the newest commit of the default branch. A package from a folder cannot have a `rev`. For a commit of a git folder, give the address `file://<folder>`. | ### `surf remove <name>` Removes a package. ### `surf install` Installs the packages at the commits of `surf.lock`. A commit of `surf.lock` is valid only while the `git` and the `rev` of the package in `surf.toml` stay the same. After a change of these values, the command takes the commit of the new values. ### `surf update` Installs the newest commit of each package. --- ## 5. Tools ### `surf doctor` Checks that this machine has the tools of a build. A release binary reports the private Rust toolchain, the runtime pack and the linker. The first native build downloads the toolchain and the pack, so their absence is not an error. On Linux, the command also reports the libraries of a desktop app. On Windows, the Rust toolchain brings the linker. `rust-lld` links with the MinGW libraries of the Rust part `rust-mingw`, so a build needs no Visual Studio (ADR-0136). The command reports these files. A binary from a checkout on Windows also needs Rust for the system `x86_64-pc-windows-gnullvm`. The build scripts of the runtime run on the system of `rustc`, and the Rust for the system `x86_64-pc-windows-msvc` links them with Visual Studio. A native build (`--target desktop`, `--target server` and `surf package`) needs the linker. A web build does not need it. So a missing linker shows `absent`, and the exit code stays 0. The last line then says that a web build works and that a native build needs the linker first. Install the linker before the first native build (section 1). A binary from a checkout also needs Rust: a missing `rustc` or `cargo` shows `MISSING` and gives a non-zero exit code. ### `surf upgrade` Replaces this binary with the newest release after a SHA-256 check. The next native build downloads the runtime pack of the new version, and it removes the old pack and the old build results. A binary from a checkout cannot upgrade. The command installs only a newer version. When the server names an older version as the newest release, the command stops with an error. To install a specified version, use `scripts/install.sh` with `SURF_VERSION`. ### `surf lsp` Runs the language server on stdin and stdout. `editors/vscode` and `editors/zed` use it. ### `surf mcp [--http <port>] [--bind <address>] [--origin <origin>]` Serves the documents of Surface and the checks of the compiler to an agent, through the Model Context Protocol (ADR-0107). - With no option, it uses stdin and stdout. - `--http <port>` serves the Streamable HTTP transport at `/mcp`. - `--bind <address>` sets the address of that server. The default is the loopback address. - `--origin <origin>` adds a value to the list of accepted `Origin` headers. You can give it more than once. The tools are `list_sections`, `get_documentation`, `check_code` and `format_code`. The server reads no project file, and it runs no code of a request. So `check_code` gives the error of a missing `locales` key for a sample that uses `Locale` (ADR-0132). ### `surf --help` Shows the short list of the commands. `surf help` and `-h` do the same. ### `surf --version` Shows the version. `-V` does the same. --- ## 6. Environment variables | Variable | Meaning | |---|---| | `SURF_HOME` | The folder of the private tools. The default is `~/.surf`. A new version removes the old versions in `toolchain` and `runtime`, and the build results in `target`. A folder with a name that does not start with a digit stays, and `target` stays when Cargo did not make it. | | `SURF_RELEASES` | The address of the releases. `surf` and `scripts/install.sh` read it. Use it for a mirror. A redirect from an `https` address to `http` is an error. | | `SURF_VERSION` | `scripts/install.sh` installs this version. The default is the newest release. | | `CARGO_TARGET_DIR` | The target folder of a native build, in place of the folder in `~/.surf`. | | `CARGO` | A binary from a checkout runs this program in place of `cargo`. | | `SURF_SIGN_IDENTITY` | `surf package` on macOS: a certificate of the keychain. Without it, the signature is an ad-hoc one, so the app runs only on this Mac. | | `SURF_NOTARY_PROFILE` | `surf package` on macOS: a profile of `notarytool`. With it, Apple checks the disk image. | | `SURF_PFX` | `surf package` for Windows: the `.pfx` file of the code-signing certificate. `--pfx` replaces it. | | `SURF_PFX_PASSWORD` | `surf package` for Windows: the password of the `.pfx` file. `--pfx-password` replaces it. | <!-- https://surface.solenovis.com/docs/inspector.md --> # The inspector The inspector shows what a running app does. `surf dev` starts it, for the web target and for the desktop target. [ADR-0105](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/adr/0105-the-dev-tools-inspector.md) gives the reasons for this design. ## Open the panel 1. Start the app with `surf dev`, or with `surf dev --target desktop`. 2. Find the line `surf dev: inspector at http://127.0.0.1:8000/__inspector/` in the output. 3. Open this address in a browser. A desktop app has the same panel, in a browser. The default port of a desktop app is 8000. When another program has this port, `surf dev` chooses a free port and prints it. Use `--port` to choose a port. The panel listens only on the loopback address, so other machines cannot reach it. ## What the panel shows | Part | What it shows | |---|---| | Tree | The components and their elements, in tree order. An element shows its own text. | | Values | The props, the states and the derived values of the selected component. For a selected element, the values of the component that owns it. | | Layout box | The position, the size, the padding, the margin, the border and the gap of the selected element, in logical px. For a selected component, the box of its first element. | | Frame timings | The number of frames, the average and worst time, and a bar for each frame. The panel has the last 120 frames that changed something. | | Redraw regions | The last 60 regions, the newest first. | The sides of a box have the order top, right, bottom, left. A gap has the order row gap, column gap. ### Select an element - Press a row of the tree. The app marks the element. - Press **Pick**, and move the pointer over the app. The app marks the element under the pointer. Press the element to select it. The press does not reach the app. Press Escape to stop. ### See the redraw regions Press **Redraw regions**. The app then shows each region for 300 ms. The marks and the regions are not part of the app. They are not in its tree, and a mark is not a redraw region. ### Read a value A value shows as text: | Value | Text | |---|---| | `Int`, `Float`, `Bool` | `5`, `2.5`, `true` | | `String` | `"first"` | | No value | `none` | | A list | `["a", "b"]` | | A map or a `Set` | `Map {"p": 1}` | | A record | `{x: 1, y: 2.5}` | | An enum | `.idle`, `.running(level: 3)` | | `Bytes` | `Bytes(16)` | | A function, a task | `fn`, `task` | A part deeper than 6 levels shows as `...`, and a text stops after 2000 characters. The text `(not computed)` means that nothing read the derived value after its last change. The inspector runs no code of the app, so it does not compute the value. ## What it costs A release build does not have the inspector. `tests/inspector-release.sh` proves this for the web target and for the desktop target. The agent in the app has no hooks while no panel is open. A panel attaches with its first poll. The agent removes its hooks 3 seconds after the last poll, for example after you close the panel. ## The protocol Three parts talk to each other. All text is JSON in UTF-8, and all connections stay on the loopback address. | Part | Where it runs | |---|---| | Panel | A browser. Its source is in `crates/surf-cli/src/inspector/panel`. `surf` compiles it for the first request. | | Broker | The `surf` process: `crates/surf-cli/src/inspector/mod.rs`. | | Agent | The app: `agent.js` on the web, and `crates/surf-desktop/src/dev/inspector.rs` on the desktop. | The broker refuses a request with a `Host` header that is not a loopback name, or with an `Origin` header of another address. It also refuses a `Sec-Fetch-Site` header other than `same-origin` or `none`, because a browser sends no `Origin` for `<script src>`. Each response has `Cross-Origin-Resource-Policy: same-origin`. [Security](https://surface.solenovis.com/docs/security.md), section 5, has the rules of `surf dev`. ### Paths Each path starts with `/__inspector/`. | Request | Who sends it | Function | |---|---|---| | `GET ` (the path alone) | Browser | The page of the panel. | | `GET app.js`, `GET app.css` | Browser | The compiled panel. | | `GET agent.js` | Web page | The web agent. A page loads it for the first message. | | `GET poll?after=N` | Panel | The events after number `N`. The answer comes after an event, or after 1 second. A poll also tells the broker that the panel is open. | | `POST call` | Panel | Sends a request to the agent. The answer is the `result`, or status 502 with the text of the error. | | `GET agent` | Dev host | The stream of messages for the agent. | | `POST reply` | Agent | An answer or an event. | A web page gets its messages on `/events`, the stream of hot reload. The dev host uses `GET agent`. The frames are the same: `event: inspect`, then one `data:` line with a JSON message. With more than one app, the newest app gets the messages. The answer of a poll: ```json { "protocol": 1, "target": "web", "connected": true, "next": 12, "events": [{ "event": "changed" }] } ``` ### Messages to the agent A message has the field `method`. The broker adds `seq`, the number of the request. `attach` and `detach` come from the broker, and they get no answer. | Method | Fields | Result | |---|---|---| | `attach` | | The agent adds its hooks, and sends the event `attached`. | | `detach` | | The agent removes its hooks, its marks and its records. | | `tree` | | `{ rows }` | | `inspect` | `id` | `{ id, name, component, values, box }` | | `select` | `id`, or `null` for no selection | `null`. The app marks the element. | | `pick` | `on` | `null`. Turns the pick mode on or off. | | `flash` | `on` | `null`. Shows the redraw regions in the app, or stops. | | `frames` | | `{ frames }` | | `regions` | | `{ regions }` | An answer is `{ "seq": 7, "result": ... }` or `{ "seq": 7, "error": "text" }`. Without `attach`, each method gives the error `no panel is attached`. | Record | Fields | |---|---| | A row | `id`, `depth`, `kind` (`component` or `element`), `name`, `text` | | A value | `name`, `kind` (`prop`, `state` or `derived`), `ty`, `value` | | A box | `x`, `y`, `width`, `height`, `padding`, `margin`, `border`, `gap`. It is `null` for a component with no element. | | A frame | `n`, `at`, `update`, `render` | | A region | `n`, `at`, `x`, `y`, `width`, `height` | - An `id` is text. It stays the same for the life of its element or component, and it has no other meaning. - `n` counts the frames and the regions of a session. - `at` is the time in milliseconds after `attach`. - `update` is the time of the batches or events in milliseconds, and `render` is the time of the frame in milliseconds. ### Events from the agent | Event | Fields | Meaning | |---|---|---| | `attached` | `protocol`, `target` | The hooks are ready. | | `changed` | | The tree or a value can have changed. It comes at most once in 200 ms. | | `picked` | `id`, or `null` after Escape | The pick mode ended. | | `reset` | | A new app took the place of the old app, for example after a reload. Each `id` is old. | The broker also adds `reset` when an app connects during a session, and when an app goes away. ## Add to the inspector Each change of the protocol changes three files: `agent.js`, `crates/surf-desktop/src/dev/inspector.rs` and `panel/api.surfs`. Use these tests: - `cargo test -p surf-cli inspector`: the broker, and the compile of the panel. - `cargo test -p surf-desktop --features dev inspector`: the desktop agent. - `tests/browser/inspector.spec.js`: the panel and the web agent, in Chromium. - `tests/inspector-desktop.sh`: a real window, on a Mac. - `tests/inspector-release.sh`: the size rule. <!-- https://surface.solenovis.com/docs/ai.md --> # Surface for agents - **Status:** current on 2026-11-06, for version 0. - **Related documents:** [MCP server](https://surface.solenovis.com/docs/mcp-server.md), [skills](https://surface.solenovis.com/docs/skills.md), [plugins](https://surface.solenovis.com/docs/plugins.md), [llms.txt](https://surface.solenovis.com/docs/llms-txt.md), [ADR-0107](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/adr/0107-an-mcp-server-for-agents.md) A coding agent knows little about Surf. Surf looks like Svelte, so an agent often writes syntax that does not exist in Surf. Surface gives agents four tools. Each tool works alone, and they work best together. | Tool | What it gives | Where it runs | |---|---|---| | [MCP server](https://surface.solenovis.com/docs/mcp-server.md) | The documents of this version, and the checks of the compiler | `surf mcp` on your computer, or the hosted server | | [Skills](https://surface.solenovis.com/docs/skills.md) | Short rules that an agent reads when it writes Surf code | The folder of skills of your agent | | [Plugins](https://surface.solenovis.com/docs/plugins.md) | The MCP server, the skills, a subagent and a rule, in one install | Claude Code, Codex CLI and Cursor | | [llms.txt](https://surface.solenovis.com/docs/llms-txt.md) | The documents as plain text files | This website | --- ## 1. Choose a tool - **Your agent has plugins.** Install the [plugin](https://surface.solenovis.com/docs/plugins.md). It adds the other tools. - **Your agent has MCP.** Add the [MCP server](https://surface.solenovis.com/docs/mcp-server.md). Use the local server when `surf` is on your computer. Use the hosted server when it is not. - **Your agent reads files or web pages only.** Give it [llms.txt](https://surface.solenovis.com/docs/llms-txt.md), or one document as Markdown. --- ## 2. How the tools help The compiler knows each name of the language. A wrong utility, a wrong method or a wrong attribute is a compile error. So the best tool is the compiler itself: 1. The agent reads the document of the feature. 2. The agent writes the code. 3. The agent sends the code to `check_code`. 4. The agent fixes each error, and sends the code again. 5. The agent stops when `check_code` reports no error. `check_code` runs `surf check` and `surf lint`. It does not run the code, and it writes no file. --- ## 3. Instructions for your project Put this text into the instructions file of your project, for example `AGENTS.md` or `CLAUDE.md`: ```text This project uses Surface. Its language is Surf. Surf is not Svelte, React or Flutter. Before you write Surf code, call `list_sections` and `get_documentation` of the `surface` MCP server. Call `check_code` on each new text until it reports no error. Then call `format_code`. ``` The text is short, so it costs little context. The skills and the documents give the details when the agent needs them. <!-- https://surface.solenovis.com/docs/mcp-server.md --> # MCP server - **Status:** current on 2026-11-06, for version 0. - **Related documents:** [Surface for agents](https://surface.solenovis.com/docs/ai.md), [the `surf` command](https://surface.solenovis.com/docs/commands.md), [ADR-0107](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/adr/0107-an-mcp-server-for-agents.md) The Model Context Protocol (MCP) is the standard way to give an agent tools. The MCP server of Surface gives the documents of this version, and it checks and formats Surf code. There are two ways to use it: - **Local:** `surf mcp`. The agent starts it on your computer. It uses the `surf` command that you installed, so the documents and the checks match your version. - **Remote:** `https://surface.solenovis.com/mcp`. It needs no install. It has the documents and the checks of the version of `surf` that this website runs. --- ## 1. Tools, resources and prompt | Name | Kind | What it does | |---|---|---| | `list_sections` | Tool | Gives the id, the title and the summary of each document. | | `get_documentation` | Tool | Gives the text of one or more documents, by id. | | `check_code` | Tool | Runs `surf check` and `surf lint` on source text. Each error and each finding has the file, the line, the column, a message and a fix hint. | | `format_code` | Tool | Runs `surf fmt` on the text of one file. | | `surf://docs/<id>.md` | Resource | One document. | | `surf_task` | Prompt | The steps that an agent follows to write Surf code with these tools. | `check_code` takes an object of file names and source text, for example `{"App.surf": "...", "model.surfs": "..."}`. A file can also be an `.svg` icon or `style.toml`. The server reads no file of your project, and it runs no code of the text. The server speaks revision `2026-07-28` of the protocol, and the four earlier revisions. --- ## 2. Local setup The local server needs the `surf` command. [The `surf` command](https://surface.solenovis.com/docs/commands.md) has the install. The command is `surf mcp`, and it uses stdin and stdout. A desktop app can start with another `PATH` than your shell. If the app does not find `surf`, use the full path, for example `/Users/you/.surf/bin/surf`. The settings of a client can change with a new version of the client. If a setting does not work, read the documents of the client. ### Claude Code Run this command. The scope is `user`, `project` or `local`: ```bash claude mcp add -t stdio -s user surface -- surf mcp ``` The [plugin](https://surface.solenovis.com/docs/plugins.md) also adds the server. ### Claude Desktop 1. Open Settings, then Developer, then Edit Config. 2. Add the server to `claude_desktop_config.json`: ```json { "mcpServers": { "surface": { "command": "surf", "args": ["mcp"] } } } ``` 3. Start Claude Desktop again. ### Codex CLI Add the server to `~/.codex/config.toml`: ```toml [mcp_servers.surface] command = "surf" args = ["mcp"] ``` The [plugin](https://surface.solenovis.com/docs/plugins.md) also adds the server. ### Cursor 1. Open the command palette, and run "View: Open MCP Settings". 2. Select "Add custom MCP". Cursor opens its file of MCP servers. 3. Add the server: ```json { "mcpServers": { "surface": { "command": "surf", "args": ["mcp"] } } } ``` ### VS Code 1. Open the command palette, and run "MCP: Add Server...". 2. Select "Command (stdio)". 3. Type the command `surf mcp`, and the name `surface`. The same server in `.vscode/mcp.json`: ```json { "servers": { "surface": { "type": "stdio", "command": "surf", "args": ["mcp"] } } } ``` ### Zed 1. Open the command palette, and run "agent: open settings". 2. Under Model Context Protocol (MCP) Servers, select Add Server, then Add Custom Server. 3. Add the server: ```json { "surface": { "command": "surf", "args": ["mcp"] } } ``` ### Other clients Use a stdio server with the command `surf` and the argument `mcp`. --- ## 3. Remote setup The address of the hosted server is `https://surface.solenovis.com/mcp`. It uses the Streamable HTTP transport. ### Claude Code ```bash claude mcp add -t http -s user surface https://surface.solenovis.com/mcp ``` ### Claude Desktop 1. Open Settings, then Connectors. 2. Select Add Custom Connector. 3. Type the name `surface` and the address `https://surface.solenovis.com/mcp`. ### Codex CLI Add the server to `~/.codex/config.toml`: ```toml experimental_use_rmcp_client = true [mcp_servers.surface] url = "https://surface.solenovis.com/mcp" ``` ### Cursor ```json { "mcpServers": { "surface": { "url": "https://surface.solenovis.com/mcp" } } } ``` ### VS Code 1. Open the command palette, and run "MCP: Add Server...". 2. Select HTTP. 3. Type the address `https://surface.solenovis.com/mcp`, and the name `surface`. The same server in `.vscode/mcp.json`: ```json { "servers": { "surface": { "type": "http", "url": "https://surface.solenovis.com/mcp" } } } ``` ### Other clients Read the documents of the client for a remote server, and use the address above. --- ## 4. Limits of the hosted server The hosted server has no sign-in. So it trusts nothing that a client sends: | Limit | Value | Result above the limit | |---|---|---| | The body of a request | 1 MB | Status 413 | | The head of a request | 16 KB | Status 431 | | Open connections | 32 | Status 503 | | The time of one request | 10 seconds | Status 408, or a tool error for a compile | | Items in one result of `check_code` | 100 | The result has `truncated: true` | | The `Origin` header | Absent, a loopback origin, or the origin of the website | Status 403 | A new process compiles each text, with its own limits of time and memory. The server logs no text of a request. The reverse proxy in front of the server limits the rate of each client. --- ## 5. Run your own hosted server `surf mcp --http <port>` serves the same tools at `/mcp`: ```bash surf mcp --http 8080 --bind 0.0.0.0 --origin https://example.com ``` - `--bind` sets the address. The default is `127.0.0.1`. - `--origin` adds an accepted `Origin` header. You can give it more than once. The server has no TLS and no rate limit. Put a reverse proxy in front of it, with TLS and a limit for each client. [Security](https://surface.solenovis.com/docs/security.md) lists the settings of a server behind a proxy. <!-- https://surface.solenovis.com/docs/skills.md --> # Skills - **Status:** current on 2026-11-06, for version 0. - **Related documents:** [Surface for agents](https://surface.solenovis.com/docs/ai.md), [plugins](https://surface.solenovis.com/docs/plugins.md), [MCP server](https://surface.solenovis.com/docs/mcp-server.md) A skill is a Markdown file with a name, a description and instructions. An agent reads the description first. It reads the instructions only when a task needs them, so a skill costs little context. --- ## 1. The skills of Surface | Skill | When the agent uses it | What it gives | |---|---|---| | `surface-code-writer` | It creates, edits or analyzes a `.surf` or `.surfs` file | The tools of the MCP server, the order in which to call them, and the commands without the server | | `surface-best-practices` | It writes Surf code | The rules of the files, the declarations, the traits, the formats of `Locale`, the markup and the styles that differ from TypeScript, Rust, Svelte and Tailwind CSS | The files are in `plugins/surface/skills` of the repository. This website serves each one: - [`/SKILLS.md`](https://surface.solenovis.com/SKILLS.md) lists the skills, with a link to each file. - `/skills/<name>/SKILL.md` is one skill, for example [`/skills/surface-best-practices/SKILL.md`](https://surface.solenovis.com/skills/surface-best-practices/SKILL.md). --- ## 2. Install a skill The [plugin](https://surface.solenovis.com/docs/plugins.md) installs both skills. To install a skill without the plugin: 1. Make a folder with the name of the skill in the folder of skills of your agent. For Claude Code, the folder is `~/.claude/skills` for each project, or `.claude/skills` for one project. 2. Download the file into that folder: ```bash mkdir -p ~/.claude/skills/surface-best-practices curl -fsSL https://surface.solenovis.com/skills/surface-best-practices/SKILL.md -o ~/.claude/skills/surface-best-practices/SKILL.md ``` 3. Do the same steps for `surface-code-writer`. An agent with no support for skills can read the same file as an instruction file. --- ## 3. A skill and the MCP server A skill gives rules. It cannot know each name of the language. The MCP server checks each name with the compiler. So use both: the skill tells the agent to call `check_code`, and `check_code` finds what the rules miss. <!-- https://surface.solenovis.com/docs/plugins.md --> # Plugins - **Status:** current on 2026-11-06, for version 0. - **Related documents:** [Surface for agents](https://surface.solenovis.com/docs/ai.md), [MCP server](https://surface.solenovis.com/docs/mcp-server.md), [skills](https://surface.solenovis.com/docs/skills.md) A plugin puts the tools of Surface into an agent with one install. The plugin `surface` has one source for Claude Code, Codex CLI and Cursor. It is in `plugins/surface` of the repository. | Part | Name | What it does | |---|---|---| | MCP server | `surface` | Starts `surf mcp`. The `surf` command must be on the `PATH`. | | Skill | `surface-code-writer` | The steps with the tools of the MCP server. | | Skill | `surface-best-practices` | The rules of Surf. | | Subagent | `surface-file-editor` | Edits `.surf` and `.surfs` files, and repeats the checks until they pass. | | Rule | `rules/surface.mdc` | A rule of Cursor for `.surf` and `.surfs` files. | The repository is private until version 1. An install from its address needs access to the repository. --- ## 1. Claude Code 1. Add the marketplace of the repository: ```text /plugin marketplace add https://git.solenovis.com/henning.liemant/surface ``` 2. Install the plugin: ```text /plugin install surface@solenovis ``` The marketplace is `.claude-plugin/marketplace.json` at the root of the repository. Its name is `solenovis`. --- ## 2. Codex CLI 1. Add the marketplace: ```bash codex plugin marketplace add https://git.solenovis.com/henning.liemant/surface ``` 2. Run `/plugins` in `codex`. 3. Install `surface`. --- ## 3. Cursor The plugin has the folder `.cursor-plugin` for Cursor. Without the plugin, do these two steps: 1. Add the [MCP server](https://surface.solenovis.com/docs/mcp-server.md) in the MCP settings of Cursor. 2. Copy `plugins/surface/rules/surface.mdc` into `.cursor/rules` of your project. --- ## 4. Other agents An agent with no plugins can use each part alone: - Add the [MCP server](https://surface.solenovis.com/docs/mcp-server.md). - Install the [skills](https://surface.solenovis.com/docs/skills.md). - Give the agent [llms.txt](https://surface.solenovis.com/docs/llms-txt.md). <!-- https://surface.solenovis.com/docs/llms-txt.md --> # llms.txt - **Status:** current on 2026-11-06, for version 0. - **Related documents:** [Surface for agents](https://surface.solenovis.com/docs/ai.md), [MCP server](https://surface.solenovis.com/docs/mcp-server.md) This website serves its documents as plain text for agents. A file is one request, so an agent with no MCP can read the documents too. | Address | Content | |---|---| | [`/llms.txt`](https://surface.solenovis.com/llms.txt) | The list of the documents, with a link to each one and its summary, in the format of llmstxt.org | | [`/llms-full.txt`](https://surface.solenovis.com/llms-full.txt) | All documents in one file | | [`/llms-small.txt`](https://surface.solenovis.com/llms-small.txt) | A short form for a small context. It has the full text of getting started. Of each other document, it has the headings, the first paragraph under each heading, and the Surf samples | | `/docs/<page>.md` | One document as Markdown, for example [`/docs/getting-started.md`](https://surface.solenovis.com/docs/getting-started.md) | | [`/SKILLS.md`](https://surface.solenovis.com/SKILLS.md) | The list of the [skills](https://surface.solenovis.com/docs/skills.md) | | [`/install.sh`](https://surface.solenovis.com/install.sh) | The install script of `surf` | Each page of the documents has a link to its Markdown file. --- ## 1. Choose a file - **The agent needs one feature.** Give it the Markdown file of that page. It is the smallest file. - **The agent has a large context.** Give it `/llms-full.txt`. - **The agent has a small context.** Give it `/llms-small.txt`, and the Markdown file of each page that the task needs. The files and the MCP server have the same text. The MCP server also checks the code, so use it when the agent has MCP. --- ## 2. The source of the files The build of the website writes the files. It reads the list `DOCUMENTS` of `crates/surf-mcp`, which is also the list of the MCP server. So the files, the pages and the server always have the same documents. <!-- https://surface.solenovis.com/docs/targets.md --> # Known differences between the native and the web target - **Date:** 2026-11-06 - **Related documents:** [PLAN.md](https://git.solenovis.com/henning.liemant/surface/src/branch/main/PLAN.md) (section 10, point 5), [layout spec](https://surface.solenovis.com/docs/layout.md), [Phase 2 plan](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/plan/phase-2.md) (P2.3, P2.4), [ADR-0131](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/adr/0131-one-behavior-on-all-targets.md) The native target paints with its own layout, renderer and text stack. The web target uses the browser. This document lists each known difference that the eye can see. The conformance suite (`tests/layout`) uses a test font with fixed metrics, so it does not see the differences of text. Version 1 has one behavior on all targets (ADR-0131). The column `Plan` gives the plan of each row: - "1.0 (ADR-0131)": version 1 removes the difference. The ADR gives the standard for all targets. The code step removes the row when its fix is in. - "Keep": the difference stays. Most such rows are a limit of a platform, and ADR-0131 gives the reason. A row with another reason names it in the column `Plan`. ## How to compare 1. Run the paint test. It lays out and paints `examples/reference` in 1200 × 900 logical px with the system font, and writes the image to `target/tmp/reference-native.png`: ```bash cargo test -p spike-apps --test paint ``` 2. Open the same app on the web (`spike/web/index.html?app=reference`) in a window of the same size, in the light theme, and take a screenshot. The web page has a link bar above the app and a margin, so its boxes start at (16, 50). ## Differences | Difference | Web (Chromium on macOS) | Native | Cause | Plan | |---|---|---|---|---| | Edges of shapes (ADR-0029) | The browser anti-aliases each shape with its own approximation of the area. | Coverage masks with the exact area of each pixel, also where two parts of a stroke overlap (ADR-0131). A curve becomes lines with at most a quarter of a device pixel of distance from it. In the plot of `tests/fixtures/drawing`, 0.16 % of the pixels of Chromium differ by more than 32 of 255, at the edges of curves. No pixel differs by more than 128 (2026-11-05). | Each browser computes the coverage of a curve in its own way, and CSS cannot choose it. | Keep: the anti-aliasing of the browser (ADR-0131) | | Transitions (ADR-0035) | A theme change and a new window width animate each property with a `transition`. | A theme change or a new window width applies at once. A press hits the shown position of a transform, as on the web (ADR-0131). | The style pass of a new environment computes each style again, and it has no old style to animate from. | 1.0 (ADR-0131) | | The words of `split-words` (ADR-0153) | `Intl.Segmenter` of the browser, with the ICU and the dictionaries of the browser. | The word break iterator of the ICU of the system. | The browser and the system can have different versions of ICU and of its data. | Keep: the data of the platform | | The letters of `split-letters` (ADR-0153) | `Intl.Segmenter` with the version of Unicode of the browser. | `unicode-segmentation` with its version of Unicode. A new emoji can be one letter on one target and two letters on another. | The versions of Unicode differ until both have the same version. | Keep: the data of the platform | | The parts of a split text with a `Span` in it (ADR-0153) | The copy of the parts has the font and the color of the `Text`, not of the `Span`. | Each part paints its glyphs with the font and the color of its run. | The copy of the web has one element for each part. | 1.0 (ADR-0131) | | The ligatures of `split-letters` (ADR-0153) | `font-variant-ligatures: none` on the text: its layout has no ligatures. | Each letter paints alone, but the layout of the text keeps its ligatures, so a font with ligatures places the letters a little differently. | The text system of the native targets has no switch for the ligatures yet. | 1.0 (ADR-0131) | | A computed part during an enter or an exit (ADR-0104) | The `transform` of `enter` or `exit` replaces the whole property. | A computed part stays on top of the value of `enter` or `exit`. | A style attribute comes after the rules. | 1.0 (ADR-0131) | | `on:visible` at the edge (ADR-0106) | The browser computes the area from its own rounding of the boxes. | The area comes from the boxes of the layout, before pixel snapping. A difference of less than 1 px can come one frame earlier or later. | Each target has its own boxes. Only one layout engine for all targets can remove this, and the web uses the browser (ADR-0131). | Keep | | The scheme of `href` (ADR-0109) | The element gets no attribute for a scheme that can run a script. | The value stays, and a press on the `Link` does nothing. A `Link` with `http`, `https`, `mailto` or `tel` opens the browser of the system, and a relative URL goes to the router of the app. | A native target has no script in a URL. | Keep | | Theme choice (ADR-0044) | `data-theme` on the root element chooses light or dark. | The system chooses. | The native targets have no such attribute. | Keep | | The theme of the system (ADR-0103, ADR-0122) | `prefers-color-scheme` and `prefers-contrast` select the variant before the first paint. | macOS reads both settings. Linux reads `color-scheme` and `contrast` of the settings portal, and follows each change. The first frame on Linux can have the light theme, while the bus starts the portal. Without a portal, the light theme. Windows reads `AppsUseLightTheme` and high contrast before the first frame, and follows each change. In high contrast, the color of a window of the theme chooses the dark or the light variant. | The read of the portal does not stop the first frame, because the start of a portal can take some seconds. | Keep | | The text size of the system (ADR-0085, ADR-0122) | The default font size of the browser scales each length in `rem`. | On Linux, the `text-scaling-factor` of GNOME from the settings portal scales the whole UI, also borders. Other desktops have no such factor. macOS has a text size for each app in its settings of accessibility. No public API gives it to other apps (the Apple developer forums, 2026), so macOS has no factor. On Windows, "Make text bigger" (`TextScaleFactor`, 100 % to 225 %) scales the whole UI. | A native target scales the whole UI (ADR-0085). | Keep | | The input method on Windows (W6 of [windows.md](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/plan/windows.md)) | The browser gives the input method the text of the field. A reconversion of committed text works, and an input method can use the text around the caret for its suggestions. | The context of the Text Services Framework holds only the text of the composition. A reconversion of committed text does not work, and an input method does not get the text around the caret. | The shell uses the text store of TSF, which keeps the text only during a composition. A text store of the shell (`ITextStoreACP`) can give the whole text. | 1.0 (ADR-0131) | | The cursors of CSS that a system does not have (ADR-0160) | Each browser shows the cursor of its system, or an image of its own for a cursor that the system does not have. | Each shell shows the cursor of its system, or the nearest cursor that the system has. Windows: `grab` and `grabbing` show the arrows of a move (`IDC_SIZEALL`), `context-menu`, `alias`, `copy`, `zoom-in` and `zoom-out` show the arrow, `cell` shows the cross, and each side of `*-resize` shows the arrows of its axis. macOS: `help`, `progress`, `wait`, `move` and `all-scroll` show the arrow, and `cell` shows the cross. Before macOS 15, `zoom-in` and `zoom-out` show the arrow, and `col-resize` and `row-resize` show the arrows of an axis. Linux: the cursor theme of the user gives each cursor. The X11 window of ADR-0088 shows the arrow for each cursor. | A system has its own set of cursors, and a native app has no cursor images of its own. | Keep: a limit of the platform (ADR-0160) | | The formats of an `Image` (ADR-0033, ADR-0160) | The browser shows each format that it knows, for example AVIF. | The native targets show PNG, JPEG, WebP, GIF and SVG. Another format gives `error` with the case `format`. | Surface ships no other decoder, and a decoder in each app is a new dependency (`AGENTS.md`, rule 9). | Keep | | The case of `error` of an `Image` from another origin (ADR-0160) | The runtime reads the file again to find the case. The browser blocks this read for a file of another origin without CORS, so such a file that did not show gives `network`. | The case comes from the data of the file. | The browser applies CORS (ADR-0037). | Keep | | The sizes of `resize` (ADR-0160) | The browser gives the size of its own box. | The size of the box of the layout. | The boxes of the browser and of Taffy can differ by up to 0.5 logical px (layout spec section 11), as for `on:visible` at the edge. | Keep | | `line-clamp-` on a `Text` with parts (ADR-0111) | The box of a part holds the lines that the clamp hides. | The box of a part holds only the lines that show. The box of the `Text` is the same. | The native paragraph has no hidden lines. | 1.0 (ADR-0131) | | `no-underline` on a `Span` in an underlined `Text` (ADR-0111) | The line of the `Text` goes on under the `Span`, as CSS draws a line of an ancestor. | The `Span` has no line. | Each run has its own `text-decoration` on the native targets. | 1.0 (ADR-0131) | | The time at which a module constant gets its value (ADR-0118) | When the page loads, in the order of the modules. | At the first read. A server has one value for each thread. | The native code has no step that runs before the first component. A constant with a value that is not a literal is a value of the thread. | 1.0 (ADR-0131) | | The order of strings (ADR-0113) | `<`, `sort` and `sortBy` compare by UTF-16 code units, as JavaScript does. | They compare by Unicode scalar values, also in `surf test` and on a server. Some emoji and rare characters sort in another order. A character above U+FFFF, for example most emoji, comes before a character from U+E000 to U+FFFF on the web. On a native target, it comes after that character. | UTF-16 code units are the order of JavaScript. On the web, a comparison by scalar values costs a function call for each comparison. | Keep: a choice of the project owner for speed (ADR-0113) | | HTTP (ADR-0037) | The browser applies CORS, sets some headers itself, and hides some headers of a response from another origin. | No CORS. An app sees each header. | The rules of the browser. | Keep | | Scaled images (ADR-0033), also a background image with `bg-cover` or `bg-contain` (ADR-0127) | The browser scales with its own filters. | The CPU averages the pixels for a smaller image and mixes the four nearest for a larger one. | Each browser chooses its own filter, and CSS cannot choose it. So no native filter can match all browsers (ADR-0131). | Keep | | An animated WebP on Windows (ADR-0033, ADR-0131) | The browser plays it. | The first frame, when the system has the WebP codec. Windows Server has no WebP codec, so it shows nothing there. An animated GIF and an APNG play on each target, and an animated WebP plays on macOS and Linux. | The WebP codec of Windows (the app "WebP Image Extensions") gives no frames of an animation and no delays. Only a decoder of WebP in each app can remove this, and that needs a new dependency (`AGENTS.md`, rule 9). | Keep: a limit of the platform | | Glyph edges | Chromium on macOS: glyph positions at a quarter of a pixel, and linear coverage. At 10, 11, 12, 14, 16 and 18 px, some glyphs of SF are up to 0.5 px taller than their outlines. An example is `l` at 12 px. Chromium on Windows: the coverage grows with the luminance of the text. At 12 px, DirectWrite moves the edges of a glyph to whole pixels. Chromium on Linux: not measured. | Glyph positions at a quarter of a device pixel. The coverage is linear. On Windows, it has the table of Chromium for each of 8 steps of the luminance of the text. Each glyph has the height and the edges of its outline. | The rasterizer of the platform changes the height and the edges of a glyph at some sizes. ADR-0131 has the measurements of 2026-11-04 and 2026-11-05. | 1.0 (ADR-0131) | | Redraw regions of the inspector (ADR-0105) | The box of each element that a batch changed. A region is an estimate, and one batch can give many regions. | The damage of the frame: one rectangle, the area that the shell paints again. | A browser does not tell a page what it paints. | Keep | | Frame timings of the inspector (ADR-0105) | `update` is the time of the batches. `render` is the time from the animation frame to the next task, which holds style, layout and paint of the browser. Only a frame after a batch counts. | `update` is the time of the events. `render` is the time of the frame in the dev host: tasks, layout and paint into the scene, without the GPU. The dev host runs in the interpreter, so the times are larger than in a release build. | The browser does this work itself. | Keep | | A case without values in a part of a value, in the inspector (ADR-0105) | A case in a record or a list shows as text: `"idle"`. | `.idle`. | The web target writes such a case as a string, and the compiler gives the agent only the type of the whole value. | 1.0 (ADR-0131) | | The formats of `Locale` (ADR-0132) | `Intl` of the browser formats the values of `Locale`, and it compares strings, with the data of the browser. Chromium and WebKit write U+0020 before AM and PM in a time, for example `1:00:00 PM` in `en`. | The ICU of the system formats them with the data of the system (ADR-0132). ICU 72 and later write U+202F before AM and PM. The long time has the name of the time zone of the data of the system, for example `GMT+1` or `MEZ`. | Each browser and each system has its own version of ICU and of CLDR. A newer CLDR can change a pattern, a name or a space, mostly in the dates, the times and the names of time zones. On 2026-11-06, `en` and `de` gave the same text in Chromium, in WebKit, on macOS 27 and on Windows Server 2025. This was true for the numbers, the percents, the currencies, the relative times, the lists, the plurals, the order of strings and the dates. | Keep | | The words of `split-words` (ADR-0153) | `Intl.Segmenter` of the browser finds the words with the data of the browser, with a dictionary for each language that needs one. | The word break iterator of the ICU of the system finds the words, with the dictionaries of the system (ADR-0132, ADR-0153). | The browser and the system can have different versions of ICU, so a few words can differ. | Keep: the data of the browser and of the system | | The formats of `Video` and `Audio` (ADR-0155) | Each browser plays its own formats. A build of Chromium, for example the Chromium of Playwright, plays no H.264 and no AAC. | Each media system plays its own formats. AVFoundation on macOS plays no WebM. A default Ubuntu plays no H.264 and no AAC, unless the `.deb` of the app installs `gstreamer1.0-libav`. Windows needs a free extension of the Microsoft Store for VP9 and AV1. ADR-0155 has the table. | Surface ships no decoder. The system chooses its decoders and the patent licences of its formats. | Keep | | The picture of `metadata` in the media controls of the system (ADR-0156) | The browser shows a picture from a path or a URL. | Windows shows a picture only from a URL with `http` or `https`. The call of `RandomAccessStreamReference` for a URL reads no path of a file. | A path needs the file API of WinRT, with its asynchronous calls. | 1.0 | | An HDR video (ADR-0155) | A browser can show an HDR video on an HDR screen. | The frames are 8-bit sRGB, so an HDR video shows in SDR. | The renderers paint in sRGB. | Keep: HDR comes after version 1.0 (ADR-0155) | | The parts of WebVTT in a track from a URL (ADR-0157) | The browser shows the regions, the vertical text and the styles of the file. | They show as plain text at the bottom of the video. In a literal file, they are a compile error. | Version 1 has a part of WebVTT on the native targets. | Keep: a part of WebVTT in version 1 (ADR-0157) | | The caption style (ADR-0157) | The browser draws the text cues. A browser applies the caption style of the system, or caption settings of its own. | The runtime applies the caption style of the system on macOS and on Windows. Linux has no such setting, so the text cues have the default look of WebVTT. | A page cannot read the caption style of the system, so the browser chooses the look of the text cues. | Keep | | The candidate of `srcset` (ADR-0159) | The browser chooses. It can take a smaller file, for example to save data. | The smallest density that is equal to or larger than the scale of the window, else the largest density. | HTML lets the browser choose. | Keep | | The distance of `loading="lazy"` (ADR-0159) | Each browser chooses its distance, also from the speed of the network. | One height and one width of the visible area of the nearest `Scroll`, `List` or window. | HTML gives a page no way to set the distance. | Keep | | The snap of a wheel event in pixels (ADR-0128) | The runtime snaps a step that passes a snap point, and a gesture of one wheel event that the browser does not snap (layout spec 5.12). Each other gesture keeps the snap of the browser. So a short wheel event of the browser tests goes back to its start in Chromium, and on to the next snap point in WebKit and Gecko. With `snap-proximity`, WebKit on Linux also snaps a step to a point farther than 0.3 of the visible area. | The shell tells the runtime the input and the end of each gesture, and the rules of layout spec 5.12 apply. | A browser gives a page no phase of a wheel event. So the runtime cannot tell a step of a wheel from a move on a trackpad, and a trackpad keeps the snap of the browser. | Keep | | The hooks of SurfWave in a desktop app (wave spec 2) | A server runs `before` and `after` of `hooks.server.surfs` for each request. A request is the first page, the data of a page, a form, an endpoint or a remote function. A cookie of the server counts from the next request. | A desktop app runs no hooks. It runs the `load` functions and the remote functions of the server in its own process. A cookie of the server counts from the next navigation. | A desktop app has no server, so it gets no requests. | Keep: a desktop app has no server | <!-- https://surface.solenovis.com/docs/limits.md --> # Limits of version 0 - **Status:** current on 2026-11-06, for version 0. - **Related documents:** [language spec](https://surface.solenovis.com/docs/language.md), [component reference](https://surface.solenovis.com/docs/components.md), [Surface Layout Spec](https://surface.solenovis.com/docs/layout.md), [differences between the web and the native targets](https://surface.solenovis.com/docs/targets.md) The specs describe the language that Surface will have. The compiler of version 0 does not have all of it yet. This page lists each gap and each limit of version 0 on one page. The specs mark the same gaps with "Not in version 0". Two private enums of two child components can have the same name in one file. `tone={.warm}` works for both. A longer expression, such as `tone={a ? .warm : .cold}`, finds only the first enum. Use another name, or export the enum. --- ## 1. Features that are compile errors The compiler rejects these features with an error. The message of most errors ends with "is not supported in version 0". | Feature | Spec | What to do in version 0 | |---|---|---| | `Canvas` and `PlatformView` | 9.6, component reference 4.16, ADR-0089 | Version 1 does not have them. For a drawing, use a `Drawing` with shapes (component reference 4.11). | | `transform`, `fill-rule="evenodd"`, gradients, `text` and `use` in an SVG file of an icon | component reference 4.12 | Convert the file first, for example with `svgo` and its `convertTransform` plugin. | | A user component, text or a block element in a `Drawing` | component reference 4.11 | Put the shapes into the `Drawing` itself, and text above it in a `Stack`. | | A utility that is not paint on a shape or a `Group`, for example `rotate-45` on a `Circle` | component reference 4.11, ADR-0120 | A shape has no box, so the native targets cannot apply these utilities. Change the coordinates of the shape, or put the transform on the `Drawing`. | | More than 31 props and states in a component, 30 in a component with `#fill`, or more than 31 effects | 7.4, 7.5 | The web target keeps the dirty bits in 32-bit integers, so each target has this limit. A `motion` counts as a state (ADR-0104), and `Window` counts as a state in a component that reads it (ADR-0106). Move a group of values into a record, or a part of the markup into a component. | | A record or an enum that holds a value of its own type outside a `List` or a `Map` | 4.6 | A native build gives each field a fixed size. Use `children: List<Node>` in place of `next: Node?`. | | A `fixed` element without a place on both axes | layout spec 5.7, ADR-0106 | A browser keeps the place in the flow, and a native target has no such place. Add `top-` or `bottom-`, and `inset-s-`, `inset-e-`, `left-` or `right-`, or `inset-`. | | A box utility, a computed value, a variant form or an event on a `Span` | component reference 4.4a, ADR-0111 | A `Span` takes only the utilities of text, and `hover:` and `dark:` with them. Put a part with padding, a border or a transform outside the `Text`, for example in a `Row`. | | A name with `__` | 3.1, ADR-0115 | The compiler makes its own names with `__`. A JSON key with `__`, for example `__typename`, cannot be a field, and `Json.decode` ignores it. | --- ## 2. Features that behave differently in version 0 | Feature | Spec | What version 0 does | |---|---|---| | A `fallback` block that sends events | 13.3 | Not in version 0: a `fallback` block has only the functions of the module. | | The theme while the app runs | layout spec 2.1 | `Theme.setMode` chooses a variant of the theme (ADR-0072, ADR-0103), and `Theme.setToken` changes a color or a length (ADR-0082, ADR-0130). The title bar of a native window follows the system. | | Animation | layout spec 5.10, ADR-0104 | `transition` cannot animate a filter. | | A spring in Safari before 17.2 | layout spec 5.10, ADR-0104 | That browser has no `linear()`. A spring in a rule uses a `cubic-bezier` of a similar shape, and a move uses `ease-out`. The enter of a `Transition` with a spring plays no animation. | | An app in a view of a host | ADR-0041 | `surf build --target desktop --embed` works on macOS only. | | An `Image` on the native targets | component reference 4.5 | Windows shows the first frame of an animated WebP. The cache is in memory only. A picture needs room in a texture of 4096 × 4096 device px: a larger one does not paint fully. A picture above 2^26 pixels does not paint, also after its scale (ADR-0109). | | Text in other languages | ADR-0066 | Chinese and Japanese lines break, Arabic and Hebrew paragraphs take their direction, and `dir` and `lang` work on all targets. Vertical text is not done. The selection of a mixed line is one rectangle. | | Italic text on the native targets | ADR-0068, ADR-0131 | The system font and the monospaced system font use the italic face of the platform. A variable font uses its axis. Another font gets a slant of 14 degrees. A theme cannot name a second file for italic. | | An error boundary | language spec 9.5 | `#try` has no retry. It catches the mount and the updates of its body, not event handlers, effects or tasks. A page from a server has no test of it yet. | | A missing asset | component reference 4.5 | The compiler checks a path in quotes. A computed path has no check: on the native targets, the image then paints nothing. | --- ## 3. Checks that version 0 does not do | Check | Spec | What version 0 does | |---|---|---| | A utility that has no effect on its element, for example `gap-1` on a `Text` or `text-20` on an `Image` | layout spec 12 | The compiler checks the name and the value of each utility, but not the element. `surf lint` finds the common cases (rule `no-effect`). Its list of rules is short, so it can miss a case. | --- ## 4. Targets | Target | State | |---|---| | Web | Works, also with server rendering and hydration (SurfWave). | | macOS | Works. | | Linux | Works on Wayland. `--x11` adds an X11 window with fewer features (ADR-0088). | | Windows | Works on x86_64. CI builds the aarch64 binary and checks its size, but no test runs on an ARM device yet (W10). A `surf` from a checkout builds the binary with `rust-lld` and no Visual Studio (ADR-0136). CI runs the TodoMVC source and the mail client on Windows. `surf package` writes a signed MSIX package (ADR-0141). A release of `surf` for Windows and a signature for the public are not done ([windows.md](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/plan/windows.md)). | | iOS and Android | Not done. | The web and the native targets have small differences. [A separate page](https://surface.solenovis.com/docs/targets.md) lists them. --- ## 5. A server | Limit | What to do in version 0 | |---|---| | A server has no Rust crates, so it has no database client | Put the database behind an API, and set `API_ORIGIN`. | | A server is a binary with a port | No serverless host and no Workers. Use a container or a machine. | | A press before the script runs | It does nothing. A link and a form work with no script. | | A server has no TLS | Put a proxy in front of it, and set `ORIGIN`. See [security](https://surface.solenovis.com/docs/security.md). | | A server has no `https:` client | `Http` on the server takes `http:` only. Keep the API in your own network. | | A server does not read a body in chunks | The answer is status 501. A proxy must send a length. | | A server has no limit for each client | Let the proxy limit the rate and the connections of each client. | | `href` and `action` take four schemes | A relative URL, `http`, `https`, `mailto` and `tel`. The element gets no attribute for another scheme (ADR-0109). | --- ## 6. Tools | Limit | What to do in version 0 | |---|---| | The releases of `surf` cover Linux x86-64 and macOS arm64 | On another system, build `surf` from a checkout. This needs Rust 1.99 or later. | | The Linux release of `surf` comes from Debian 12, so it needs glibc 2.36 or later | On an older system, build `surf` from a checkout. The apps that `surf` builds keep the minimum of the target matrix (PLAN.md section 12.3). | | The first native build needs the network, and a native build needs a linker | Run `surf doctor`. It names the package of the linker. ADR-0101 gives the reason. | | The host of `--embed` is a Rust crate, so it has its own Rust install | Only this case needs Rust. An app and its extern modules use the private toolchain. | | `surf test` runs the native layout, not the CSS of the web | Test the web output with Playwright against a build. | | A test that calls an extern module needs a host function or a `fallback` block | Give the module a `fallback` block. | | A package has `.surf` files and extern files only | The app keeps the assets and the theme. | | There is no central registry of packages | A git address or a path names a package. | | `surf dev` answers only `127.0.0.1`, `localhost` and `[::1]` on its own port (ADR-0109) | To try a page on another device, serve a build with another server. | | The inspector only shows values (ADR-0105) | It cannot change a state. A derived value shows only after the app computed it. The paths under `/__inspector/` belong to the inspector, so an app with routes cannot use them. | | `surf mcp --http` has no TLS, no sign-in and no limit for each client (ADR-0107) | Put a reverse proxy in front of it. The proxy gives TLS and the rate limit of each client. | --- ## 7. Decided for version 1, not built yet On 2026-11-04, the project owner decided section 13 of the layout spec and section 18 of the language spec (ADR-0124 to ADR-0135). On 2026-11-05, the project owner answered the open questions for version 1 (ADR-0104, ADR-0109, ADR-0113, ADR-0124, ADR-0126, ADR-0134 and ADR-0139 to ADR-0141). On the same day, the project owner decided the motion of version 1 (ADR-0150 to ADR-0154). On the same day, ADR-0155 to ADR-0159 gave the media of P6.25. The specs describe these features, but the compiler of version 0 does not have them yet. Version 0 gives a compile error for the new syntax, or it does what the last column says. | Feature | Spec | What to do in version 0 | |---|---|---| | The other fixes of the native differences | ADR-0131 | `docs/native-differences.md` lists each row with the plan "1.0". | | A module constant gets its value at its first read, with one value for the process on a server | language spec 7.3, ADR-0131 | The web computes it at the load, and a server has one value for each thread. | | The base path for a picture of `src/assets` on a page below the root of an app with routes | wave spec 5, ADR-0139 | A path such as `assets/logo.png` reads its file relative to the address of the page. Show such a picture only on the root page, or give it a full URL. | | The base path for a `Link`, a form and an `Http` request of a web app with no routes | wave spec 5, ADR-0139 | Only the script and the stylesheet get the base path. Write the prefix into each address that starts with `/`. | | The split of a `Span` into parts, the underline and the line through of the parts of a split text, and a computed `opacity-` on a split text on the web | layout spec 5.10, ADR-0153 | Split the whole `Text`. Give the opacity with a fixed `opacity-` or with an animation. | | The hidden pointer, and the Tab key and the accessibility tree only in the video and its bar, in the full screen of a `Video` | component reference 4.5d, ADR-0156, decision 29 | The video and its bar cover the window. A native window goes into the full screen mode of the system. | | The full screen mode of the window of the X11 fallback of Linux | component reference 4.5d, ADR-0156, decision 27 | The video and its bar cover the window. The window keeps its size. | | The pause of a video that `autoplay` started while no part of it is in the window | component reference 4.5b, ADR-0158, decision 4 | Give the `Video` `bind:paused`, and pause it in `on:visible`. | | `object-` utilities on a `Video` with `controls` | component reference 4.5b, 4.5d | The bar has the style of the element, and its video fits with `object-contain`. | | The `Track` elements of a block in the menu of the captions | component reference 4.5d, ADR-0157, decision 9 | Write each `Track` outside a block. | | The tags `<i>` and `<u>` of a text cue on the native targets | component reference 4.5c, ADR-0157, decision 5 | The text shows without the italic and the underline. `<b>` works. | | `indent-` (`text-indent`) on the native targets | layout spec 5.9, 12, ADR-0160 | The web indents the first line. The native targets have no indent. Put the first word of the text into a `Span` with a margin, or use a `Row`. | Function types in extern modules come after version 1 (ADR-0133). Percentages in `calc()` and subgrid also come after version 1 (ADR-0125, ADR-0126). <!-- https://surface.solenovis.com/docs/stability.md --> # Stability - **Status:** accepted by the project owner on 2026-11-06. It applies from the release of version 1.0. The edition `2026` is frozen. - **Related documents:** [limits of version 0](https://surface.solenovis.com/docs/limits.md), [language spec](https://surface.solenovis.com/docs/language.md), [PLAN.md](https://git.solenovis.com/henning.liemant/surface/src/branch/main/PLAN.md) (section 13) This page says what an app can rely on after version 1.0. Version 0 has no such promise. ## What stays stable Each release of version 1 keeps these parts: - The syntax and the rules of the language spec. - The built-in components, their attributes and their events. - The utilities of the layout spec, and their results. - The functions of the standard library. - The commands of `surf` and their options. - The format of `surf.toml` and `surf.lock`. - The texts of the run-time errors in section 15.2 of the language spec. An app gets these texts in `on error`. A project that builds with version 1.0 builds with each later version 1 release. ## What can change - The generated code, the file names in a build, and the code of the runtime. These are not an API. - The text of a message of the compiler, of `surf lint` and of `surf`. - A new finding of `surf lint`. A CI job that fails on a finding can then fail after an update. - A bug fix can change a result that the specs do not promise. - A size or a speed, within the budgets of `PLAN.md`. - A minimum version of a system or of a browser, for example Windows 10 version 1903 or the ICU of the system. Only a minor release can raise a minimum, and its release notes say so. ## Editions A change that breaks a project waits for a new edition (ADR-0123). A project names its edition in `surf.toml`: ```toml edition = "2026" ``` - The first edition is `2026`. `surf new` writes the newest edition. - A project with no `edition` has the first edition, and `surf lint` gives the finding `edition`. So a project never moves to a newer edition without a change of its file. - An edition that this `surf` does not know stops each command. The message names the editions that it knows. - Each release of `surf` builds each edition that it knows, so an old project keeps working. - An edition gets this support for at least two years after the release of the next edition. After that time, a release can remove the edition. Its release notes then say so. - `surf migrate` will update a project to a new edition. This command does not exist yet, because version 1 has one edition. - Each package keeps its edition in its own `surf.toml`. Packages of different editions work together in one project. ## Releases - Version numbers follow Semantic Versioning. - A release lists its changes and its new lint rules. - A feature that is not stable has a name with `experimental`, or it stays out of the specs.