volaro 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/bin/vl.js +34 -0
- package/examples/sensors.vl +30 -0
- package/examples/station.vl +47 -0
- package/language/crib.md +262 -0
- package/language/spec.md +1840 -0
- package/package.json +29 -0
package/language/spec.md
ADDED
|
@@ -0,0 +1,1840 @@
|
|
|
1
|
+
# Volaro Language Design Specification
|
|
2
|
+
|
|
3
|
+
Version 0.2 (draft)
|
|
4
|
+
Status: design, pre-implementation
|
|
5
|
+
Changelog: see section 22
|
|
6
|
+
File extension: `.vl`
|
|
7
|
+
Reference implementation language: Rust
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. What Volaro is
|
|
12
|
+
|
|
13
|
+
Volaro is an application language written to be generated by a model and read by a person. It targets one narrow band of software: the kind of application built from views, HTTP endpoints, outbound API calls, and user accounts. Inside that band it aims to express a working feature in roughly a fifth of the tokens a TypeScript, React, and Express stack needs, with no wiring layer and no framework glue. A project has exactly one configuration file, `volaro.toml`, and it holds only what cannot be derived from source: the project name, its version, and its dependencies (§7). Everything a framework would normally push into config — routes, schema, sessions, build steps — is a declaration in the language instead.
|
|
14
|
+
|
|
15
|
+
The design starts from a specific failure pattern. When a model writes a login flow in a general-purpose stack, it produces four files, invents a session helper, forgets `httpOnly` on the cookie, and picks a password hash from whatever was popular in its training data. None of those mistakes are hard problems. They are boilerplate problems. Volaro removes the boilerplate by promoting views, API clients, HTTP services, and authentication into first-class syntax, so there is nothing left to wire incorrectly.
|
|
16
|
+
|
|
17
|
+
### 1.1 Goals
|
|
18
|
+
|
|
19
|
+
1. **Token density.** A feature should cost few tokens to write and few tokens to re-read. Density comes from removing redundant delimiters and from making common patterns into declarations, not from cryptic operators.
|
|
20
|
+
2. **One canonical form.** For any given construct there is exactly one way to write it. No braces-or-indentation choice, no `function` versus arrow, no import style debate. A single target form makes generation more reliable and diffs stable.
|
|
21
|
+
3. **Readable by a person who has never seen it.** A developer reviewing generated Volaro should follow it without a manual. Keywords are words, not sigils. Structure is visible as shape on the page.
|
|
22
|
+
4. **Secure by construction.** Password storage, session cookies, CSRF tokens, and authorization checks are compiler concerns. The insecure version is not expressible.
|
|
23
|
+
5. **Errors written as repair instructions.** Diagnostics are machine-readable and each one carries a suggested patch, so a model gets a fix rather than a complaint.
|
|
24
|
+
|
|
25
|
+
### 1.2 Non-goals
|
|
26
|
+
|
|
27
|
+
Volaro is not a systems language. There is no manual memory management, no pointer arithmetic, no unsafe escape hatch, no operator overloading, no user-defined macros, no inheritance, and no configurable code formatting. Anything outside the application band (drivers, kernels, game engines, numeric kernels) belongs in Rust, and Volaro calls into Rust through a foreign function boundary rather than growing to cover it.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. Design principles
|
|
32
|
+
|
|
33
|
+
### 2.1 Delimiters carry meaning once
|
|
34
|
+
|
|
35
|
+
Most languages encode block structure twice: braces for the compiler and indentation for the reader. Volaro encodes it once. Newline ends a statement. Indentation opens a block. Dedent closes it. Semicolons and braces do not appear in statement position at all.
|
|
36
|
+
|
|
37
|
+
The saving is not trivial. In a 200-line TypeScript module, roughly 8 to 12 percent of tokens are `{`, `}`, `;`, `(`, `)`, and `,` characters serving purely as structure. Removing the redundant half of those is free density with no loss of clarity.
|
|
38
|
+
|
|
39
|
+
### 2.2 Declarations over instructions
|
|
40
|
+
|
|
41
|
+
The stack a model finds hardest to write correctly is the part where a declarative intent gets expressed as a sequence of imperative steps: fetch data, track loading, track error, render three ways, clean up on unmount. Volaro names the intent. A `load` block in a view declares the request and its three visual states in five lines, and the compiler emits the state machine.
|
|
42
|
+
|
|
43
|
+
The same logic applies to retries, rate limits, session rotation, and role checks. Each is a property of the thing, so each is written as a property of the thing.
|
|
44
|
+
|
|
45
|
+
### 2.3 Inference where it is safe, annotation where it is contractual
|
|
46
|
+
|
|
47
|
+
Local variables are inferred. Function parameters, return types, record fields, and anything crossing a module or network boundary are annotated. The rule is easy to state and easy for a generator to follow: if a human on the other side of the boundary would need to know the type, write it down.
|
|
48
|
+
|
|
49
|
+
### 2.4 Compact project map
|
|
50
|
+
|
|
51
|
+
Every project compiles to a generated `map.vks` summary: every type, view, route, outbound endpoint, and role in the codebase, one line each. A model editing a 40-file project reads the map, not the files. On a mid-sized app the map runs about 300 lines against 6,000 lines of source, which changes what fits in a context window.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 3. Lexical structure
|
|
56
|
+
|
|
57
|
+
### 3.1 Source encoding
|
|
58
|
+
|
|
59
|
+
Source files are UTF-8. A byte-order mark at the start of a file is an error. Line endings are `\n`; a `\r\n` file is accepted by the reader and normalized by the formatter.
|
|
60
|
+
|
|
61
|
+
### 3.2 Indentation
|
|
62
|
+
|
|
63
|
+
Indentation is two spaces per level. Tabs in leading whitespace are a hard error with no configuration option, which removes an entire category of mixed-whitespace bug.
|
|
64
|
+
|
|
65
|
+
The lexer maintains an indent stack. At the first non-whitespace token of a line it compares the line's indent width against the top of the stack and emits `INDENT`, one or more `DEDENT`, or nothing. A line whose indent matches no level on the stack is an error.
|
|
66
|
+
|
|
67
|
+
Inside an open bracket pair (`(`, `[`, `{`), newlines and indentation produce no tokens. This allows multi-line argument lists and literals without indentation rules interfering.
|
|
68
|
+
|
|
69
|
+
### 3.3 Comments
|
|
70
|
+
|
|
71
|
+
```vl
|
|
72
|
+
# line comment, runs to end of line
|
|
73
|
+
## doc comment, attaches to the following declaration
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
There is no block comment form. Editors comment ranges line by line, and a single form keeps nesting rules out of the lexer.
|
|
77
|
+
|
|
78
|
+
### 3.4 Identifiers
|
|
79
|
+
|
|
80
|
+
Identifiers match `[a-z_][a-z0-9_]*` for values, functions, fields, and modules. Types match `[A-Z][A-Za-z0-9]*`, except the built-in primitives, which are lowercase words (`int`, `str`, `bool`).
|
|
81
|
+
|
|
82
|
+
**Components may be either case, and the case carries meaning.** A lowercase component is a *primitive element* — the layout and input set in `std.ui` (`col`, `row`, `stack`, `grid`, `text`, `img`, `button`, `input`, `link`, `spacer`), which have no children beyond what they render and are the leaves and boxes of a tree. An uppercase component is a *user or library view* declared with `view` (`PostCard`, `Spinner`, `Avatar`). Reading a view body, the case tells you whether a line is layout or a call into another component, which is the distinction that matters when scanning a tree.
|
|
83
|
+
|
|
84
|
+
A project shadowing a primitive keeps the lowercase name (§8.2). Case otherwise carries meaning as usual, so a reader knows what a bare name refers to without looking it up.
|
|
85
|
+
|
|
86
|
+
### 3.5 Keywords
|
|
87
|
+
|
|
88
|
+
**Reserved.** These are never identifiers, in any position:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
and as auth break catch derive else err false
|
|
92
|
+
fn for if in let match mut nil not
|
|
93
|
+
on or par return skip spawn true try type
|
|
94
|
+
use while
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Twenty-nine reserved words.
|
|
98
|
+
|
|
99
|
+
**Contextual.** These are keywords only as the head of a declaration or of a block that expects them, and are ordinary identifiers everywhere else. `list` is the clearest case: `list posts as p key:p.id` is a view statement, while `list<Post>` is a type and `posts.list()` is a method call.
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
api error errors index key list load model none
|
|
103
|
+
pending prefix public role service session state theme view
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A contextual keyword is recognised by position, so a field named `state` or a
|
|
107
|
+
function named `load` stays legal. Everything else, including all layout
|
|
108
|
+
components, lives in the standard library.
|
|
109
|
+
|
|
110
|
+
### 3.6 Literals
|
|
111
|
+
|
|
112
|
+
```vl
|
|
113
|
+
42 # int
|
|
114
|
+
1_000_000 # int, underscores ignored
|
|
115
|
+
3.14 # float
|
|
116
|
+
0xff 0b1010 # int, alternate bases
|
|
117
|
+
"hello" # str
|
|
118
|
+
"hi {name}" # str with interpolation
|
|
119
|
+
"""
|
|
120
|
+
multi-line
|
|
121
|
+
""" # str, leading indent stripped to block level
|
|
122
|
+
true false # bool
|
|
123
|
+
nil # absence
|
|
124
|
+
10s 500ms 30d # duration
|
|
125
|
+
[1, 2, 3] # list
|
|
126
|
+
{a: 1, b: 2} # record or map, depending on inferred type
|
|
127
|
+
0..10 # range, end exclusive
|
|
128
|
+
0..=10 # range, end inclusive
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Interpolation takes a full expression: `"total {price * qty}"`. Escapes are `\n`, `\t`, `\\`, `\"`, `\{`, `\u{1F600}`.
|
|
132
|
+
|
|
133
|
+
Durations are a distinct literal type rather than an int, so `ttl 30` is a type error and `ttl 30d` is not. Timeouts and expiry windows are the two places where unit confusion causes real outages.
|
|
134
|
+
|
|
135
|
+
### 3.7 Separators
|
|
136
|
+
|
|
137
|
+
A newline separates items in block position. A comma separates items on one line. Both forms are accepted in list and record literals and in argument lists:
|
|
138
|
+
|
|
139
|
+
```vl
|
|
140
|
+
let ports = [80, 443, 8080]
|
|
141
|
+
|
|
142
|
+
let ports = [
|
|
143
|
+
80
|
|
144
|
+
443
|
|
145
|
+
8080
|
|
146
|
+
]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Trailing commas are a syntax error, since the newline form already handles clean diffs.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 4. Types
|
|
154
|
+
|
|
155
|
+
### 4.1 Primitives
|
|
156
|
+
|
|
157
|
+
| Type | Notes |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `int` | 64-bit signed |
|
|
160
|
+
| `float` | IEEE 754 double |
|
|
161
|
+
| `str` | UTF-8, immutable |
|
|
162
|
+
| `bool` | |
|
|
163
|
+
| `bytes` | immutable byte string |
|
|
164
|
+
| `time` | instant, UTC, nanosecond resolution |
|
|
165
|
+
| `dur` | duration, produced by duration literals |
|
|
166
|
+
| `uuid` | RFC 9562 UUID |
|
|
167
|
+
| `json` | dynamically shaped value, checked at use |
|
|
168
|
+
| `nil` | the unit of absence, inhabits `T?` |
|
|
169
|
+
|
|
170
|
+
There is no `char`, no unsigned family, and no width variants. Numeric width is a systems concern, and code needing it uses the Rust boundary.
|
|
171
|
+
|
|
172
|
+
### 4.2 Type constructors
|
|
173
|
+
|
|
174
|
+
```vl
|
|
175
|
+
T? # optional: T or nil
|
|
176
|
+
list<T>
|
|
177
|
+
map<K, V>
|
|
178
|
+
set<T>
|
|
179
|
+
T! # fallible: T or the standard err type
|
|
180
|
+
T!E # fallible: T or E
|
|
181
|
+
fn(int, str) bool # function type
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Optionals do not nest. `T??` is a type error, which removes a class of confusion that costs nothing to forbid.
|
|
185
|
+
|
|
186
|
+
### 4.3 Records
|
|
187
|
+
|
|
188
|
+
```vl
|
|
189
|
+
type User
|
|
190
|
+
id uuid
|
|
191
|
+
email str
|
|
192
|
+
name str?
|
|
193
|
+
role Role = member
|
|
194
|
+
created_at time
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Fields are ordered and immutable after construction. Records are **nominally typed and structurally compared**: two record types with identical fields are still different types and never interchangeable, but two values of the *same* type are equal when their fields are equal, with no identity or reference semantics. Nominal typing is what makes `distinct` (§4.5) meaningful; structural equality is what makes records usable as map keys and in tests. A field with a default may be omitted at construction. Construction uses record literal syntax with the type name:
|
|
198
|
+
|
|
199
|
+
```vl
|
|
200
|
+
let u = User{email: "ada@example.com", created_at: now()}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### 4.4 Unions
|
|
204
|
+
|
|
205
|
+
```vl
|
|
206
|
+
type Role = admin | member | guest
|
|
207
|
+
|
|
208
|
+
type Shape =
|
|
209
|
+
circle(r float)
|
|
210
|
+
rect(w float, h float)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Bare variants take no payload and compare by identity. Payload variants construct as calls: `circle(2.0)`. Union names in pattern position do not need qualification, so `match role` matches on `admin`, not `Role.admin`.
|
|
214
|
+
|
|
215
|
+
### 4.5 Aliases and newtypes
|
|
216
|
+
|
|
217
|
+
```vl
|
|
218
|
+
type Email = str # alias, freely interchangeable
|
|
219
|
+
type UserId = uuid distinct # newtype, requires explicit conversion
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`distinct` exists because passing an order ID where a user ID belongs is the most common wrong-argument bug in application code, and the type system already has the machinery to prevent it.
|
|
223
|
+
|
|
224
|
+
### 4.6 Inference rules
|
|
225
|
+
|
|
226
|
+
Local bindings infer from their initializer. Function signatures never infer. Empty collection literals require an annotation:
|
|
227
|
+
|
|
228
|
+
```vl
|
|
229
|
+
let names = [] # error: cannot infer element type
|
|
230
|
+
let names list<str> = [] # fine
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Inference is local and unidirectional. There is no global constraint solving, no return-type polymorphism driven by call site, and no implicit numeric conversion. `1 + 1.0` is a type error; write `1.0 + 1.0` or `int_to_float(1) + 1.0`.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## 5. Expressions and statements
|
|
238
|
+
|
|
239
|
+
### 5.1 Bindings
|
|
240
|
+
|
|
241
|
+
```vl
|
|
242
|
+
let name = "Ada"
|
|
243
|
+
let mut count = 0
|
|
244
|
+
count = count + 1
|
|
245
|
+
count += 1
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Bindings are immutable by default. `mut` is required for reassignment. Shadowing in the same scope is an error, since a generator that shadows accidentally produces code a reviewer misreads.
|
|
249
|
+
|
|
250
|
+
### 5.2 Operators
|
|
251
|
+
|
|
252
|
+
Precedence, tightest first:
|
|
253
|
+
|
|
254
|
+
| Level | Operators | Associativity |
|
|
255
|
+
|---|---|---|
|
|
256
|
+
| 1 | `.` `()` `[]` `?` | left |
|
|
257
|
+
| 2 | `not` unary `-` | right |
|
|
258
|
+
| 3 | `*` `/` `%` | left |
|
|
259
|
+
| 4 | `+` `-` | left |
|
|
260
|
+
| 5 | `..` `..=` | none |
|
|
261
|
+
| 6 | `<` `<=` `>` `>=` | none |
|
|
262
|
+
| 7 | `==` `!=` | none |
|
|
263
|
+
| 8 | `and` | left |
|
|
264
|
+
| 9 | `or` | left |
|
|
265
|
+
| 10 | `??` | right |
|
|
266
|
+
| 11 | `\|>` | left |
|
|
267
|
+
|
|
268
|
+
`and` and `or` short-circuit. Comparison operators do not chain, so `a < b < c` is a syntax error with a suggested fix of `a < b and b < c`.
|
|
269
|
+
|
|
270
|
+
### 5.3 Pipeline
|
|
271
|
+
|
|
272
|
+
```vl
|
|
273
|
+
let top = posts
|
|
274
|
+
|> filter(fn(p) p.published)
|
|
275
|
+
|> sort_by(fn(p) p.score)
|
|
276
|
+
|> take(10)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`x |> f(a)` means `f(x, a)`. The pipeline exists because deeply nested calls are where generated code most often loses track of parentheses, and the linear form reads the way the data flows.
|
|
280
|
+
|
|
281
|
+
### 5.4 Closures
|
|
282
|
+
|
|
283
|
+
```vl
|
|
284
|
+
fn(p) p.published # expression body
|
|
285
|
+
fn(a int, b int) int # annotated
|
|
286
|
+
a + b # block body
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Closures capture by value. A captured `mut` binding is captured as a copy, so a closure never mutates its defining scope. Shared mutable state goes through explicit reference types in `std.sync`.
|
|
290
|
+
|
|
291
|
+
### 5.5 Conditionals
|
|
292
|
+
|
|
293
|
+
`if` is an expression. Both forms exist, and the formatter picks between them by whether the result fits on one line:
|
|
294
|
+
|
|
295
|
+
```vl
|
|
296
|
+
let label = if score > 50 "pass" else "fail"
|
|
297
|
+
|
|
298
|
+
if user.role == admin
|
|
299
|
+
grant_all()
|
|
300
|
+
else if user.verified
|
|
301
|
+
grant_basic()
|
|
302
|
+
else
|
|
303
|
+
deny()
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
An `if` used as an expression requires an `else`. An `if` used as a statement does not.
|
|
307
|
+
|
|
308
|
+
### 5.6 Match
|
|
309
|
+
|
|
310
|
+
```vl
|
|
311
|
+
let msg = match shape
|
|
312
|
+
circle(r) -> "circle of radius {r}"
|
|
313
|
+
rect(w, h) if w == h -> "square of {w}"
|
|
314
|
+
rect(w, h) -> "{w} by {h}"
|
|
315
|
+
|
|
316
|
+
match response.status
|
|
317
|
+
200..=299 -> ok(response.body)
|
|
318
|
+
404 -> err(not_found)
|
|
319
|
+
_ -> err(unexpected(response.status))
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Match is exhaustive. A missing case is a compile error listing the uncovered variants by name. Guards use `if` after the pattern. `_` matches anything and binds nothing.
|
|
323
|
+
|
|
324
|
+
Patterns destructure records and lists:
|
|
325
|
+
|
|
326
|
+
```vl
|
|
327
|
+
match event
|
|
328
|
+
Click{x, y} -> move_to(x, y)
|
|
329
|
+
Key{code: "Escape"} -> close()
|
|
330
|
+
_ -> skip
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### 5.7 Loops
|
|
334
|
+
|
|
335
|
+
```vl
|
|
336
|
+
for post in posts
|
|
337
|
+
render(post)
|
|
338
|
+
|
|
339
|
+
for i in 0..10
|
|
340
|
+
emit(i)
|
|
341
|
+
|
|
342
|
+
for k, v in headers
|
|
343
|
+
write("{k}: {v}")
|
|
344
|
+
|
|
345
|
+
while queue.len() > 0
|
|
346
|
+
handle(queue.pop())
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`break` exits. `skip` advances to the next iteration. Neither takes a label; nested loops needing early exit extract a function, which is the clearer shape anyway.
|
|
350
|
+
|
|
351
|
+
### 5.8 Functions
|
|
352
|
+
|
|
353
|
+
```vl
|
|
354
|
+
## Returns the display name, falling back to the email local part.
|
|
355
|
+
fn display_name(u User) str
|
|
356
|
+
u.name ?? u.email.split("@")[0]
|
|
357
|
+
|
|
358
|
+
fn retry_count(kind str = "default") int
|
|
359
|
+
match kind
|
|
360
|
+
"aggressive" -> 5
|
|
361
|
+
_ -> 2
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The final expression of a body is the return value. `return` exits early and is otherwise unnecessary. Parameters with defaults follow parameters without. Named arguments at call sites are allowed for any parameter and required for `bool` parameters, because `create(true, false)` tells a reviewer nothing.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## 6. Errors
|
|
369
|
+
|
|
370
|
+
### 6.1 The fallible marker
|
|
371
|
+
|
|
372
|
+
A function that fails declares it in the return type:
|
|
373
|
+
|
|
374
|
+
```vl
|
|
375
|
+
fn parse_port(s str) int!ParseErr
|
|
376
|
+
let n = to_int(s)?
|
|
377
|
+
if n < 1 or n > 65535
|
|
378
|
+
return err(out_of_range(n))
|
|
379
|
+
n
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`T!` uses the standard `Err` record. `T!E` uses a declared union. There is no exception mechanism, no panic in user code, and no way to ignore a fallible result silently: an unused `T!` value is a compile error.
|
|
383
|
+
|
|
384
|
+
### 6.2 Propagation and recovery
|
|
385
|
+
|
|
386
|
+
```vl
|
|
387
|
+
let user = load(id)? # propagate to caller
|
|
388
|
+
let user = load(id) ?? guest_user # fall back to a value
|
|
389
|
+
|
|
390
|
+
let user = try load(id)
|
|
391
|
+
catch not_found
|
|
392
|
+
create_guest()
|
|
393
|
+
catch e
|
|
394
|
+
log.error("load failed", e)
|
|
395
|
+
return err(e)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
`?` requires the enclosing function to be fallible with a compatible error type. `??` takes a value of the success type. `try`/`catch` matches on the error union with the same exhaustiveness rules as `match`.
|
|
399
|
+
|
|
400
|
+
**A handled error does not fall through.** When a `catch` arm runs, the statements after the `try` block do **not** execute — the `try` statement is complete. This is the opposite of the usual imperative reading and it is chosen deliberately: the fall-through version produced a login form that showed an error message *and* redirected to the success page, which was the single most common bug in early Volaro examples.
|
|
401
|
+
|
|
402
|
+
```vl
|
|
403
|
+
try auth.sign_in(email: email, password: password)
|
|
404
|
+
catch invalid_credentials
|
|
405
|
+
error = "Wrong email or password." # no `return` needed
|
|
406
|
+
catch e
|
|
407
|
+
error = "Something went wrong."
|
|
408
|
+
route.to("/") # reached only when nothing was caught
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
In expression position both arms must produce a value of the same type, and the block's value is whichever arm ran:
|
|
412
|
+
|
|
413
|
+
```vl
|
|
414
|
+
let user = try load(id)
|
|
415
|
+
catch not_found
|
|
416
|
+
create_guest()
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Code that must run whether or not an error was caught goes **before** the `try`, or
|
|
420
|
+
uses `spawn` (§12) for fire-and-forget; there is no fall-through and no keyword to
|
|
421
|
+
opt into it.
|
|
422
|
+
|
|
423
|
+
### 6.3 The standard error record
|
|
424
|
+
|
|
425
|
+
```vl
|
|
426
|
+
type Err
|
|
427
|
+
code str
|
|
428
|
+
message str
|
|
429
|
+
cause Err?
|
|
430
|
+
fields map<str, json>
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
`code` is a stable, machine-readable identifier. `message` is human text. `fields` carries structured context so a log line does not need string formatting to stay searchable.
|
|
434
|
+
|
|
435
|
+
**Three spellings, three meanings**, which are distinguished by case and position and are worth stating once:
|
|
436
|
+
|
|
437
|
+
| Spelling | What it is |
|
|
438
|
+
|---|---|
|
|
439
|
+
| `Err` | the record type above — a value's *type* |
|
|
440
|
+
| `err(...)` | the constructor: wraps a variant or an `Err` into a failed `T!`, as in `return err(not_found("no such post"))` |
|
|
441
|
+
| `err` | the reserved word, valid only as a `catch` binding or a match pattern head |
|
|
442
|
+
|
|
443
|
+
An `Err` is never constructed with record-literal syntax (`Err{...}`); `err(...)` is the only way to produce one, so every failure has a single construction site.
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## 7. Modules
|
|
448
|
+
|
|
449
|
+
A file is a module and its path is its name. There is no export keyword. Names beginning with `_` are private to the file and everything else is public, which means visibility is readable from the name alone with no second place to check.
|
|
450
|
+
|
|
451
|
+
```vl
|
|
452
|
+
use std.http
|
|
453
|
+
use std.time { now, Duration }
|
|
454
|
+
use ./models { User, Role }
|
|
455
|
+
use ../shared/format
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`std.*` resolves to the standard library. `./` and `../` resolve relative to the current file. A bare name resolves to a dependency declared in `volaro.toml`. Circular imports are an error reported with the full cycle.
|
|
459
|
+
|
|
460
|
+
A module holds **declarations only** — `use`, `type`, `model`, `fn`, `view`, `service`, `api`, `auth`, `theme`. There is no module-level value binding: `let x = …` at file scope is a syntax error. This keeps module load free of ordering and side-effect questions, and there is exactly one place a name at file scope can come from. Where a value is needed: configuration (limits, base URLs, tokens) lives in `volaro.toml` or `env`; a constant used by one `view` or `fn` is a `let` inside it; a value shared across a module is a zero-parameter `fn` (`fn tabs() list<Tab> …`, called as `tabs()`); a set of named cases is a `type` with variants. If real use shows a genuine need for a shared literal that none of these fit, a restricted module `let` (literal or const-expression right-hand side, evaluated once) can be added without breaking anything — it is left out until then.
|
|
461
|
+
|
|
462
|
+
Project layout:
|
|
463
|
+
|
|
464
|
+
```
|
|
465
|
+
volaro.toml
|
|
466
|
+
src/
|
|
467
|
+
main.vl
|
|
468
|
+
auth.vl
|
|
469
|
+
models.vl
|
|
470
|
+
views/
|
|
471
|
+
Feed.vl
|
|
472
|
+
UserCard.vl
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## 8. Views
|
|
478
|
+
|
|
479
|
+
Views are the first of three domain constructs. A view declares a component: its inputs, its local state, its data dependencies, and its rendered tree.
|
|
480
|
+
|
|
481
|
+
### 8.1 Shape
|
|
482
|
+
|
|
483
|
+
```vl
|
|
484
|
+
view UserCard(user User, compact bool = false)
|
|
485
|
+
state expanded = false
|
|
486
|
+
derive initials = (user.name ?? user.email) |> split(" ") |> map(first_char) |> join("")
|
|
487
|
+
|
|
488
|
+
col gap:12 pad:16 radius:8 bg:surface
|
|
489
|
+
row gap:8 align:center
|
|
490
|
+
Avatar(src: user.avatar, fallback: initials, size: 40)
|
|
491
|
+
col
|
|
492
|
+
text user.name size:16 weight:600
|
|
493
|
+
text user.email size:13 color:muted
|
|
494
|
+
if expanded and not compact
|
|
495
|
+
text user.bio ?? "No bio yet." size:14
|
|
496
|
+
button (if expanded "Hide details" else "Show details") variant:ghost
|
|
497
|
+
on tap
|
|
498
|
+
expanded = not expanded
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Props are the parameter list. `state` declares reactive local state; assignment to it schedules a re-render. `derive` declares a value recomputed when its dependencies change, and the compiler works out the dependencies from the expression, so there is no dependency array to get wrong.
|
|
502
|
+
|
|
503
|
+
### 8.2 Elements
|
|
504
|
+
|
|
505
|
+
An element is a call in statement position with attribute syntax:
|
|
506
|
+
|
|
507
|
+
```
|
|
508
|
+
Name attr:value attr:value
|
|
509
|
+
children...
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Attribute values are expressions. `attr:value` with no comma is the element form; the ordinary call form `Name(a: 1)` is used in expression position, such as inside a list. Both build the same node.
|
|
513
|
+
|
|
514
|
+
Layout components (`col`, `row`, `stack`, `grid`, `text`, `img`, `button`, `input`, `link`, `spacer`) live in `std.ui` and are imported into every view automatically. They are ordinary components with no compiler privileges, so a project replaces them by shadowing the import. `dialog` and `tabs` / `tab` (§8.8) also live in `std.ui`, but have required structure the compiler checks and behaviour the compiler emits.
|
|
515
|
+
|
|
516
|
+
### 8.3 Lists
|
|
517
|
+
|
|
518
|
+
```vl
|
|
519
|
+
list posts as p key:p.id
|
|
520
|
+
PostCard(post: p)
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`key` is required. A list without a stable key is a compile error, which removes the single most common reconciliation bug in component frameworks.
|
|
524
|
+
|
|
525
|
+
### 8.4 Data loading
|
|
526
|
+
|
|
527
|
+
```vl
|
|
528
|
+
view Feed(topic str)
|
|
529
|
+
load posts = blog.list_posts(topic: topic)
|
|
530
|
+
pending
|
|
531
|
+
Spinner()
|
|
532
|
+
error e
|
|
533
|
+
col gap:8
|
|
534
|
+
text "Could not load posts." color:danger
|
|
535
|
+
button "Retry" on tap: posts.reload()
|
|
536
|
+
|
|
537
|
+
list posts as p key:p.id
|
|
538
|
+
PostCard(post: p)
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
`load` binds a value and declares its pending and error rendering in the same block. Inside the success region the binding is the unwrapped value, not a wrapper, so `posts` is a `list<Post>` with no `.data` access and no null check. The binding also exposes `.reload()` and `.loading` for refresh cases.
|
|
542
|
+
|
|
543
|
+
The compiler emits request deduplication, cancellation on unmount, and a cache keyed on the call arguments. Re-entering `Feed` with the same `topic` within the cache window does not refetch.
|
|
544
|
+
|
|
545
|
+
### 8.5 Lifecycle and effects
|
|
546
|
+
|
|
547
|
+
```vl
|
|
548
|
+
on mount
|
|
549
|
+
analytics.view("feed", topic)
|
|
550
|
+
|
|
551
|
+
on change topic
|
|
552
|
+
scroll_to_top()
|
|
553
|
+
|
|
554
|
+
on unmount
|
|
555
|
+
ws.close()
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Effects run after the render that triggered them. `on change` takes one or more bindings and fires when any of them changes by value.
|
|
559
|
+
|
|
560
|
+
`spawn` is legal in a view body, including inside an event handler. Unlike `load` (§8.4), a spawned task is not cancelled when the view unmounts: it runs to completion, and a failure logs without affecting the view.
|
|
561
|
+
|
|
562
|
+
### 8.6 Slots
|
|
563
|
+
|
|
564
|
+
```vl
|
|
565
|
+
view Panel(title str, body slot)
|
|
566
|
+
col gap:8
|
|
567
|
+
text title weight:600
|
|
568
|
+
body
|
|
569
|
+
|
|
570
|
+
# call site
|
|
571
|
+
Panel(title: "Settings")
|
|
572
|
+
SettingsForm()
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
A `slot` parameter receives the element's children. One slot per component keeps composition obvious; multiple named regions use ordinary component props instead.
|
|
576
|
+
|
|
577
|
+
### 8.7 Styling
|
|
578
|
+
|
|
579
|
+
Attributes cover layout, spacing, color, typography, and border. A project may
|
|
580
|
+
declare one conventional `theme.vl`; its nested categories replace Tailwind's
|
|
581
|
+
owned scales and emit `--vl-<category>-<name>` properties:
|
|
582
|
+
|
|
583
|
+
```vl
|
|
584
|
+
theme
|
|
585
|
+
color
|
|
586
|
+
bg "#fff" dark "#0b0b0c"
|
|
587
|
+
fg "#111" dark "#f2f2f3"
|
|
588
|
+
accent "#2563eb" dark "#2563eb"
|
|
589
|
+
space xs 4 sm 8 md 16 lg 24
|
|
590
|
+
radius sm 4 md 8 lg 16
|
|
591
|
+
font sans "system-ui, sans-serif" mono "ui-monospace, monospace"
|
|
592
|
+
screen sm 640 md 768 lg 1024
|
|
593
|
+
motion reduce respect
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Numbers in `gap:` / `pad:` remain pixels; a name such as `gap:sm` resolves to
|
|
597
|
+
`theme.space.sm`. `color:muted` resolves to `var(--vl-color-muted)`. Dark values
|
|
598
|
+
apply through both `prefers-color-scheme` and `html[data-theme="dark"]`.
|
|
599
|
+
|
|
600
|
+
`recipe button`, `recipe input`, and `recipe card` declare literal Tailwind
|
|
601
|
+
classes as one `base`, orthogonal axes, and defaults. `card` is a grouping
|
|
602
|
+
`<div>` with a child slot. Interactive recipes require a `focus-visible:`
|
|
603
|
+
treatment and motion utilities require a `motion-reduce:` counterpart:
|
|
604
|
+
|
|
605
|
+
```vl
|
|
606
|
+
recipe button
|
|
607
|
+
base "rounded-md focus-visible:outline focus-visible:outline-accent"
|
|
608
|
+
variant primary "bg-accent text-white"
|
|
609
|
+
default variant:primary
|
|
610
|
+
|
|
611
|
+
button "Save" variant:primary class:"w-full sm:w-auto"
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
`class:` is the only escape hatch and must be one complete, non-interpolated
|
|
615
|
+
literal. It is additive: it may not share a property group with the resolved
|
|
616
|
+
recipe, carry `!important`, or overlap a recipe with an inline visual attr.
|
|
617
|
+
Class-token order never decides precedence. Runtime compatibility rules occupy
|
|
618
|
+
a lower cascade layer, generated utilities a later layer, and inline attrs stay
|
|
619
|
+
highest only on recipe-less elements. Every requested utility must emit a
|
|
620
|
+
parsed CSS selector or the build fails `E-CLASS-NO-CSS`; `group` / `peer`
|
|
621
|
+
markers are the sole bounded exemption. The complete diagnostics and conflict
|
|
622
|
+
groups are normative in the Tailwind-backed styling design contract.
|
|
623
|
+
|
|
624
|
+
### 8.8 Accessible primitives
|
|
625
|
+
|
|
626
|
+
Two `std.ui` primitives carry accessibility by construction: an inaccessible dialog or tab set is not expressible, the same property §1.1 goal 4 gives security.
|
|
627
|
+
|
|
628
|
+
**`dialog`** — a modal dialog.
|
|
629
|
+
|
|
630
|
+
```vl
|
|
631
|
+
if confirming
|
|
632
|
+
dialog title:"Discard changes?" on dismiss: confirming = false
|
|
633
|
+
text "This cannot be undone." color:muted
|
|
634
|
+
row gap:8 justify:end
|
|
635
|
+
button "Cancel" variant:ghost on tap: confirming = false
|
|
636
|
+
button "Discard" variant:danger on tap: discard()
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
`title` is required and becomes the accessible name. `on dismiss` is required and runs on `Escape`, on a backdrop click, and on the close affordance. The compiler emits: a portal above the page, `role="dialog"` with `aria-modal`, the rest of the page made `inert`, focus moved into the dialog and trapped there while it is open, and focus restored to the previously focused element when it closes.
|
|
640
|
+
|
|
641
|
+
A dialog is shown by rendering it — an `if` that includes `dialog`, as above — and hidden by not rendering it. **There is no `open` attribute.** A closed dialog is absent from the tree, so a control inside it cannot be reached, focused, activated, or found while it is closed; "is the dialog open" and "is its subtree present" cannot drift apart.
|
|
642
|
+
|
|
643
|
+
**`tabs` / `tab`** — a tab set. `tab` is valid only as a direct child of `tabs`.
|
|
644
|
+
|
|
645
|
+
```vl
|
|
646
|
+
tabs bind:tab
|
|
647
|
+
tab "overview" label:"Overview"
|
|
648
|
+
text "Overview panel"
|
|
649
|
+
tab "changelog" label:"Changelog"
|
|
650
|
+
text "Changelog panel"
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
`bind:tab` is a two-way binding to a `state` holding the selected tab id (§8.2 `bind`); the explicit form is `tabs selected:<id> on select t: …`. Each `tab` takes an id — the first positional value — and a required `label`. The compiler emits `role="tablist"` / `"tab"` / `"tabpanel"`, `aria-selected`, `aria-controls` / `aria-labelledby` wiring, a roving `tabindex`, and `Arrow` / `Home` / `End` key handling. Only the selected tab's panel is in the tree.
|
|
654
|
+
|
|
655
|
+
**Scope.** This is two primitives, added because two real features (`corpus/views/11-modal`, `12-tabs`) could not otherwise be expressed accessibly. It is **not** a component library. `menu`, `tooltip`, `combobox`, `disclosure` and the rest are not part of the language; each is added only when a feature demands it, the same evidence-first discipline the rest of the toolchain follows. Raw `role` / `aria-*` / `tabindex` are not an attribute surface — a primitive carries them or they are absent.
|
|
656
|
+
|
|
657
|
+
### 8.9 Accessible inputs
|
|
658
|
+
|
|
659
|
+
`input` is accessible by construction, the same way `dialog` and `tabs` are: **an `input` with no programmatic label is not expressible.** Every `input` must carry an accessible name through exactly one of:
|
|
660
|
+
|
|
661
|
+
```vl
|
|
662
|
+
col gap:4
|
|
663
|
+
input label:"Email" bind:email type:email help:"We never share it." error:email_error
|
|
664
|
+
input aria_label:"Search posts" bind:q type:search
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
- **`label:<text>`** — a visible label. The compiler emits a `<label for=…>` bound to the control by a generated id (the compiler owns both ends, so the pair cannot drift or be forgotten) and the text is the accessible name. Help and error text are rendered as siblings, never inside the label, so the name stays exactly the label text.
|
|
668
|
+
- **`aria_label:<text>`** — an accessible name with **no** visible label, for a control whose purpose is already clear from adjacent visible content (a search field beside a search icon). Emits `aria-label`.
|
|
669
|
+
|
|
670
|
+
**Exactly one, and never empty.** Giving both `label:` and `aria_label:` on one `input` is `E-INPUT-NAME-DUP` (a visible label plus a silent `aria-label` override is ambiguous — keep the visible one). A `label:` / `aria_label:` whose value is a statically empty or whitespace-only string is `E-INPUT-NAME`, the same as no name at all. A name that is a non-empty expression at build time but evaluates to empty / whitespace at **render** time raises `render.empty_input_name` — a control is never rendered without a name.
|
|
671
|
+
|
|
672
|
+
**One id per rendered instance.** The generated `for` / `id` / `aria-describedby` ids are allocated per *rendered field*, not per source site, so a component used many times or a keyed-list row each gets its own label / help / error ids; every `<label>` activates its own control and every `aria-describedby` resolves within its own field. The links are rebuilt from one base each render, so they stay consistent across insertion and reordering. (The id *strings* are not stable across re-renders — like per-row handlers in a keyed list, they are refreshed each pass; the accessible name and the described text are what is stable.)
|
|
673
|
+
|
|
674
|
+
`placeholder:` is **not** a label. It vanishes on the first keystroke, is exempt from contrast rules, and is skipped by some assistive tech. An `input` that has only `placeholder:` — or neither — is `E-INPUT-NAME`, an error, with the fix naming `label:` / `aria_label:`. `placeholder:` remains legal as a *supplement* to a real name.
|
|
675
|
+
|
|
676
|
+
Two optional association attributes, both wired by the compiler so the author cannot forget the link:
|
|
677
|
+
|
|
678
|
+
- **`help:<text>`** — persistent helper text, rendered after the control and referenced from the control's `aria-describedby`.
|
|
679
|
+
- **`error:<text?>`** — a validation message. The expression is `nil` / empty while the field is valid. When it holds a message the compiler renders it, adds it to `aria-describedby`, and sets `aria-invalid="true"` on the control; when it is empty the live error region remains present but empty, without an error association or `aria-invalid` (§8.12). Typically a `derive` (§8.1), as in `corpus/views/08-validated-form`.
|
|
680
|
+
|
|
681
|
+
**Scope.** This slice is the `input` primitive: its name, its help text, and its error association / invalid state. Live error/status semantics are defined in §8.12; page titles and heading/landmark structure are defined in §8.11. As with §8.8, raw `aria-*` is not an attribute surface: the primitive carries these or they are absent.
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
### 8.10 Accessible images
|
|
686
|
+
|
|
687
|
+
`img` is accessible by construction, the same way `input` is (§8.9): **an `img` with no declared alternative is not expressible.** Every `img` states its alternative through exactly one form of a single `alt:` attribute:
|
|
688
|
+
|
|
689
|
+
```vl
|
|
690
|
+
col gap:8
|
|
691
|
+
img src:hero alt:"The team on stage at the launch"
|
|
692
|
+
img src:user.avatar_url alt:"{user.name}'s profile photo"
|
|
693
|
+
img src:"divider.svg" alt:decorative
|
|
694
|
+
img src:chart_png alt_todo:"bar chart of weekly signups — confirm axis labels"
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
- **`alt:<text>`** — meaningful alternative text: what the image conveys, for a reader who cannot see it. Emits `alt="<text>"`. A dynamic value (`alt:"{caption}"`) is checked at render: an `alt:` that resolves to empty / whitespace raises `render.empty_alt` rather than silently emitting `alt=""` — the author said the image is meaningful, so an empty value contradicts the declaration.
|
|
698
|
+
- **`alt:decorative`** — the bare word `decorative`: the image is purely presentational (a rule, a texture, an icon that only repeats adjacent text). Emits `alt=""`, the WAI-ARIA-sanctioned signal for "assistive tech may skip this". A *statically* empty `alt:""` is `E-IMG-ALT`, not a synonym for this — say `decorative` when that is the intent.
|
|
699
|
+
- **`alt:todo`** / **`alt_todo:<text>`** — a development placeholder: real alt text is owed but not written. The build still emits a navigable `alt` (`alt_todo:`'s note, or `"TODO: describe this image"` for the bare `alt:todo`), and plain `vlcheck` / `vlbuild` **warn** (`W-IMG-ALT-TODO`). `vlcheck --release` / `vlbuild --release` promote it to `E-IMG-ALT-TODO` — the ship gate that fails a build with any unresolved placeholder.
|
|
700
|
+
|
|
701
|
+
An `img` with none of these is `E-IMG-ALT`; with more than one (e.g. `alt:` and `alt_todo:` together) it is `E-IMG-ALT-ONE`. As with §8.8 / §8.9, raw `aria-*` / `role` are not an attribute surface.
|
|
702
|
+
|
|
703
|
+
**Scope.** This slice is the `img` primitive's alternative text. Live error/status semantics are defined in §8.12.
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
### 8.11 Page title, headings, and landmarks
|
|
708
|
+
|
|
709
|
+
A build produces one document, rendered by a **root view** (§8, the no-argument view `VL.mount` mounts). That document — not any reusable `view` — is the page. Navigation between pages is a full browser navigation; there is no client-side router.
|
|
710
|
+
|
|
711
|
+
**Page title.** A module that builds as a page declares one, at module scope:
|
|
712
|
+
|
|
713
|
+
```vl
|
|
714
|
+
page title:"Weekly Report"
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
`title:` is an expression evaluated at module scope (a literal, or something a module-level `fn` / config produces — not view state). It becomes the document `<title>` when it is a literal, and is assigned to `document.title` at boot regardless, so a full navigation to another page picks up that page's own title. `page` with no `title:`, or a statically empty one, is `E-PAGE-TITLE`; a build that renders a page with no `page` declaration at all is refused (`E-PAGE-TITLE` at build time). A `title:` expression that resolves to empty at load time raises `render.empty_page_title`. One `page` per module (`E-PAGE-DUP`).
|
|
718
|
+
|
|
719
|
+
**Headings.** `h1`…`h6` are `std.ui` primitives that emit the real HTML heading elements. The level is **semantic and independent of visual size** — `h2 "…" size:28` is a second-level heading that happens to be large. Within one view, a heading that jumps more than one level below the previous one is `W-HEADING-SKIP`, and a second `h1` is `W-HEADING-MULTI-H1` — warnings, not errors, and scoped to a single view so a reusable component that legitimately starts at `h2` or `h3` under some page's `h1` is never flagged.
|
|
720
|
+
|
|
721
|
+
**Landmarks.** `main` and `nav` are `std.ui` primitives emitting `<main>` / `<nav>`. There is one `main` region per page (`E-MAIN-DUP` for two in a view). A `nav` takes `aria_label:` (emitted as `aria-label`); when a view has more than one `nav`, each **must** carry a distinct name (`E-NAV-NAME`) so assistive tech can tell them apart. These rules hold on the **composed** document, not just within one view: a `main` in the page-root view and in a view it renders (or a view whose `main` is reused) is `E-MAIN-COMPOSED` — every `main` emits `id="vl-main"`, so a second one is also a duplicate id and an ambiguous skip target; two `<nav>`s assistive tech cannot tell apart — both unnamed, or sharing one `aria_label:` — from different composed views are likewise `E-NAV-NAME`; and one static `id:` rendered by more than one element is `E-ID-DUP`. The composed pass follows component instantiation within a module from the page root and treats `if` / `else` branches as mutually exclusive (a landmark in both branches counts once); it does not evaluate conditions, and cross-file `use` composition is not yet followed.
|
|
722
|
+
|
|
723
|
+
**Skip link.** When the page renders a `main` landmark, the compiler tags it with a stable id and `tabindex="-1"` and injects a **"Skip to main content"** link as the first thing in the tab order, off-screen until focused and then visible (styled `.vl-skip`). Activating it moves focus into `main`. The author writes nothing.
|
|
724
|
+
|
|
725
|
+
As with §8.8–§8.10, raw `role` / `aria-*` are not an attribute surface.
|
|
726
|
+
|
|
727
|
+
**Scope.** This slice is the page title, heading elements, the `main` / `nav` landmarks, and the skip link. These structural checks do not prove a page is accessible or replace screen-reader testing. Live error/status semantics are defined in §8.12.
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
|
|
731
|
+
### 8.12 Live status and validation feedback
|
|
732
|
+
|
|
733
|
+
`status <message>` is a text-only `std.ui` primitive. It takes exactly one
|
|
734
|
+
string-or-nil expression, no attributes and no children (`E-STATUS-SHAPE`
|
|
735
|
+
with a repair example otherwise). This bounded form does not expose raw ARIA,
|
|
736
|
+
priority switches or focus controls.
|
|
737
|
+
|
|
738
|
+
```vl
|
|
739
|
+
view Feedback()
|
|
740
|
+
state message = ""
|
|
741
|
+
status message
|
|
742
|
+
button "Save"
|
|
743
|
+
on tap
|
|
744
|
+
message = "Saved."
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
Keep the status element mounted, using nil or an empty string while idle,
|
|
748
|
+
rather than conditionally inserting a populated region. The compiler emits a
|
|
749
|
+
span with `role="status"`, `aria-live="polite"`, and `aria-atomic="true"`.
|
|
750
|
+
The region remains exposed when empty. A non-string/non-nil runtime value
|
|
751
|
+
raises `render.invalid_status`. Initial populated content is visible but is
|
|
752
|
+
not promised to be announced; the contract concerns subsequent updates.
|
|
753
|
+
|
|
754
|
+
An input declaring `error:` automatically gets the same polite atomic
|
|
755
|
+
live-region behavior in its existing error span, outside the label. The
|
|
756
|
+
region is present even when empty, without `hidden` or `display:none`.
|
|
757
|
+
Non-empty errors still participate in `aria-describedby` and set
|
|
758
|
+
`aria-invalid`; clearing removes those error associations and the invalid
|
|
759
|
+
state, but retains the empty region.
|
|
760
|
+
|
|
761
|
+
For a retained element, the runtime updates the existing text node only when
|
|
762
|
+
its value changes. Unrelated renders and repeated assignment of the same
|
|
763
|
+
message do not rewrite it. Clearing and subsequently setting a message allows
|
|
764
|
+
a new update. There is no automatic reannouncement of an unchanged error on
|
|
765
|
+
repeated submission. Updates do not move focus. Regions in dialogs live inside
|
|
766
|
+
the dialog portal, not in an inert background or a global announcement queue.
|
|
767
|
+
|
|
768
|
+
Authors choose when to expose validation errors (for example after submission
|
|
769
|
+
or blur) and supply useful context in messages. The compiler cannot infer that
|
|
770
|
+
ordinary dynamic `text` is a status message. Structural removal/reinsertion
|
|
771
|
+
does not preserve region identity, and no speech guarantee is made for it.
|
|
772
|
+
|
|
773
|
+
**Evidence boundary.** Browser checks establish live-region properties,
|
|
774
|
+
in-place text mutation, unchanged-message suppression, clearing and portal
|
|
775
|
+
behavior. They do not establish audible output, announcement order for multiple
|
|
776
|
+
simultaneous regions, or screen-reader interoperability. Manual AT testing
|
|
777
|
+
remains required. This follows the pre-existing-region approach in
|
|
778
|
+
[W3C ARIA22](https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22);
|
|
779
|
+
[ARIA19](https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA19) discusses error
|
|
780
|
+
notification. Polite feedback is the language default; urgent alerts are
|
|
781
|
+
outside this slice.
|
|
782
|
+
|
|
783
|
+
---
|
|
784
|
+
|
|
785
|
+
### 8.13 Project preferences and automation hooks
|
|
786
|
+
|
|
787
|
+
The checker and builder read versioned `volaro.json`, independently of agent
|
|
788
|
+
chat history. Version 1 is a JSON object with required integer
|
|
789
|
+
`"schema_version": 1` and optional `"testing": {"emit_ids": true}`.
|
|
790
|
+
The default is true. Unknown keys, duplicate JSON keys, wrong types and
|
|
791
|
+
unsupported versions are errors (E-CONFIG-JSON / E-CONFIG-OPTION /
|
|
792
|
+
E-CONFIG-VERSION); unreadable files are E-CONFIG-READ. A config found under the
|
|
793
|
+
former name `volara.json` is E-CONFIG-LEGACY, never a silent skip and never a
|
|
794
|
+
silent fallback to defaults. No accessibility opt-out or not-yet-supported
|
|
795
|
+
wizard setting is accepted.
|
|
796
|
+
|
|
797
|
+
Discovery starts at the entry source directory, selects the nearest
|
|
798
|
+
`volaro.json`, and stops at a Git worktree boundary (a .git file or directory)
|
|
799
|
+
or filesystem root. An explicit `--config PATH` selects exactly that file
|
|
800
|
+
relative to the command's working directory. A bad nearest file is an error,
|
|
801
|
+
not a fallback. A `volara.json` at or above the entry directory — or named by
|
|
802
|
+
`--config` — raises E-CONFIG-LEGACY naming the `volara.json` → `volaro.json`
|
|
803
|
+
rename, and if both names sit in one directory that is also E-CONFIG-LEGACY.
|
|
804
|
+
Builds never write configuration. Included modules use the entry
|
|
805
|
+
settings. No secrets or environment credentials belong in this file.
|
|
806
|
+
|
|
807
|
+
`test_id:"save"` emits `data-testid="save"`. IDs are static, non-interpolated
|
|
808
|
+
lowercase slugs matching `[a-z][a-z0-9-]{0,79}`. Empty/invalid/duplicate
|
|
809
|
+
attributes and unsupported targets are E-TEST-ID. Supported targets are
|
|
810
|
+
col/row/stack/grid/card/text/img/button/input/link/spacer, h1–h6, main/nav,
|
|
811
|
+
and status. Input hooks attach to the input, not its field wrapper. Hook names
|
|
812
|
+
do not replace HTML IDs, accessible names, or reconciliation keys.
|
|
813
|
+
|
|
814
|
+
`test_scope:<string expression>` creates a namespace for its element and
|
|
815
|
+
descendants, including local component calls and dialog content. Joined IDs
|
|
816
|
+
use slash separators: `editor/name`. Each scope segment obeys the same slug
|
|
817
|
+
rule. Static invalid scopes are E-TEST-ID-SCOPE; dynamic invalid scopes raise
|
|
818
|
+
`render.invalid_test_scope` without echoing the value. Component calls,
|
|
819
|
+
dialog and tabs may carry scopes, but not direct test_id hooks. Individual
|
|
820
|
+
tab headers do not support either attribute in this pass; unsupported targets
|
|
821
|
+
fail rather than being silently ignored.
|
|
822
|
+
|
|
823
|
+
Repeated rows author an explicit stable public scope on their row or component.
|
|
824
|
+
The compiler NEVER derives scope from the list key, index, label or source
|
|
825
|
+
location. Authors must not use emails, credentials, tokens or private record
|
|
826
|
+
identifiers. Validation cannot establish that an otherwise valid value is
|
|
827
|
+
non-sensitive or stable; review remains required.
|
|
828
|
+
|
|
829
|
+
Provable simultaneous literal collisions are E-TEST-ID-DUP. Static analysis
|
|
830
|
+
expands local views, adds sibling counts and takes maximum per-ID counts over
|
|
831
|
+
if/else alternatives; repeated literal scopes collide. Dynamic scopes and
|
|
832
|
+
relationships not statically known are checked during rendering. The next
|
|
833
|
+
render uses a fresh registry spanning page and prepared portals; a collision
|
|
834
|
+
raises `render.duplicate_test_id` before committing the new page or portals,
|
|
835
|
+
preserving the preceding valid DOM. Scope identity survives keyed reordering.
|
|
836
|
+
Closed/removed content may reuse its IDs on a later render.
|
|
837
|
+
|
|
838
|
+
With emission disabled, no hook attributes, scope-expression evaluation or
|
|
839
|
+
hook registry calls are emitted in application code. Static validation still
|
|
840
|
+
runs. Accessibility, handlers and reconciliation behaviour are unchanged;
|
|
841
|
+
dynamic-hook errors are emission-only. Existing single-module view compilation
|
|
842
|
+
boundaries remain: this does not add cross-file view loading or a router.
|
|
843
|
+
|
|
844
|
+
## 9. Outbound API integration
|
|
845
|
+
|
|
846
|
+
An `api` block declares a client for a service someone else runs.
|
|
847
|
+
|
|
848
|
+
```vl
|
|
849
|
+
api blog
|
|
850
|
+
base "https://api.example.com/v2"
|
|
851
|
+
auth bearer env.BLOG_TOKEN
|
|
852
|
+
timeout 10s
|
|
853
|
+
retry 3 backoff:exp on:[429, 502, 503, 504]
|
|
854
|
+
rate 100/min
|
|
855
|
+
|
|
856
|
+
headers
|
|
857
|
+
accept "application/json"
|
|
858
|
+
|
|
859
|
+
get list_posts(topic str, limit int = 20) list<Post>
|
|
860
|
+
path "/posts"
|
|
861
|
+
query topic, limit
|
|
862
|
+
|
|
863
|
+
get get_post(id uuid) Post!
|
|
864
|
+
path "/posts/{id}"
|
|
865
|
+
|
|
866
|
+
post create_post(body NewPost) Post!
|
|
867
|
+
path "/posts"
|
|
868
|
+
|
|
869
|
+
delete remove_post(id uuid) nil!
|
|
870
|
+
path "/posts/{id}"
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
Each endpoint becomes a function at `blog.list_posts(...)`, namespaced under the block name. Path parameters interpolate from the parameter list by name and are URL-escaped. `query` names which parameters go in the query string.
|
|
874
|
+
|
|
875
|
+
Where an unrouted parameter goes depends on whether the method carries a request body. For `get`, `delete`, and `head` a parameter that is in neither the path nor `query` has nowhere to go: it is a compile error, so a forgotten parameter never silently disappears. For `post`, `put`, and `patch` the unrouted parameters form the request body — a parameter named `body` is sent as the body verbatim, and any other unrouted parameters are collected into a JSON object keyed by parameter name.
|
|
876
|
+
|
|
877
|
+
Response bodies decode into the declared type. A field present in the type and missing from the response is a runtime `Err` with code `decode.missing_field` and the JSON pointer in `fields`. A field present in the response and absent from the type is ignored.
|
|
878
|
+
|
|
879
|
+
`timeout` defaults to `30s` when the block omits it; `timeout none` disables it, and is the only way to make a call that can hang forever. `retry` applies only to idempotent methods unless `retry ... idempotent:true` is stated on a `post`. `rate` throttles client-side with a token bucket shared across the process.
|
|
880
|
+
|
|
881
|
+
### 9.1 Generating a client from a schema
|
|
882
|
+
|
|
883
|
+
```vl
|
|
884
|
+
api stripe from openapi("./schemas/stripe.json")
|
|
885
|
+
auth bearer env.STRIPE_KEY
|
|
886
|
+
timeout 30s
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
The compiler reads the schema at build time and generates typed endpoints. Overrides are written in the block body and take precedence over the schema.
|
|
890
|
+
|
|
891
|
+
---
|
|
892
|
+
|
|
893
|
+
## 10. Inbound services
|
|
894
|
+
|
|
895
|
+
A `service` block declares HTTP routes the application serves.
|
|
896
|
+
|
|
897
|
+
```vl
|
|
898
|
+
service api
|
|
899
|
+
prefix "/api/v1"
|
|
900
|
+
|
|
901
|
+
get /posts(topic str?, limit int = 20) list<Post>!
|
|
902
|
+
db.posts.where(topic: topic).limit(limit).all()?
|
|
903
|
+
|
|
904
|
+
get /posts/:id Post!
|
|
905
|
+
db.posts.get(id)?
|
|
906
|
+
|
|
907
|
+
post /posts(body NewPost) Post!
|
|
908
|
+
auth role:author
|
|
909
|
+
let post = db.posts.insert(body, author_id: session.user.id)?
|
|
910
|
+
events.emit("post.created", post.id)
|
|
911
|
+
post
|
|
912
|
+
|
|
913
|
+
patch /posts/:id(body PostPatch) Post!
|
|
914
|
+
auth owner:db.posts.get(id)?.author_id
|
|
915
|
+
db.posts.update(id, body)?
|
|
916
|
+
|
|
917
|
+
delete /posts/:id nil!
|
|
918
|
+
auth role:admin
|
|
919
|
+
db.posts.delete(id)?
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
Path parameters are typed from the route pattern and available as bindings. `:id` in a route whose handler expects `uuid` parses and validates before the body runs; a malformed value returns 400 without entering user code.
|
|
923
|
+
|
|
924
|
+
Return values serialize to JSON. A `T!` handler maps error codes to status codes through a table declared once:
|
|
925
|
+
|
|
926
|
+
```vl
|
|
927
|
+
service api
|
|
928
|
+
errors
|
|
929
|
+
not_found -> 404
|
|
930
|
+
forbidden -> 403
|
|
931
|
+
conflict -> 409
|
|
932
|
+
_ -> 500
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
Unmapped error codes return 500 and log the full error with cause chain. The response body for a 500 never includes the internal message.
|
|
936
|
+
|
|
937
|
+
**Public routes.** A route with no `auth` line is public, and the compiler warns when more than half of a service's routes are public, on the assumption that the guards were forgotten. A service that is *meant* to be public says so once and the warning does not apply:
|
|
938
|
+
|
|
939
|
+
```vl
|
|
940
|
+
service content public # every route is public unless it says otherwise
|
|
941
|
+
get /posts list<Post>!
|
|
942
|
+
get /posts/:id Post!
|
|
943
|
+
|
|
944
|
+
post /posts(body NewPost) Post!
|
|
945
|
+
auth role:author # a guard still overrides, per route
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
A single deliberately public route inside an otherwise guarded service uses `auth none`, which is distinct from omitting the line: it records the decision, and it silences the warning for that route only.
|
|
949
|
+
|
|
950
|
+
```vl
|
|
951
|
+
get /health str
|
|
952
|
+
auth none
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
`auth none` on a write route (`post`/`put`/`patch`/`delete`) is still a warning, because an unauthenticated write is a different claim from an unauthenticated read and deserves a second look.
|
|
956
|
+
|
|
957
|
+
A `service` also generates a typed client under its own name, so view code calls `api.posts(limit: 20)` and gets a `list<Post>` back with no URL string, no fetch wrapper, and no hand-written response type. Changing a route signature breaks its callers at compile time, which is the property a hand-rolled client never has.
|
|
958
|
+
|
|
959
|
+
**Generated method names.** The name comes from the route's path *and* its HTTP method, not its handler. Three shapes, chosen by where the path parameters sit:
|
|
960
|
+
|
|
961
|
+
1. **Collection** — no path parameters. `get` keeps the last segment as written (`get /posts` → `api.posts(...)`); `post` is `create_<singular>` (`post /posts` → `api.create_post(...)`); `put`/`patch` are `update_<singular>`; `delete` is `delete_<singular>`.
|
|
962
|
+
2. **Item** — the path ends in a parameter. `get` is the singularised last non-parameter segment (`get /posts/:id` → `api.post(id: ...)`); `post` is `create_<singular>`; `put`/`patch` are `update_<singular>`; `delete` is `delete_<singular>` (`delete /posts/:id` → `api.delete_post(id: ...)`).
|
|
963
|
+
3. **Action** — the path ends in a non-parameter segment but has a parameter earlier. The name is that segment verbatim, for any method (`post /posts/:id/publish` → `api.publish(id: ...)`).
|
|
964
|
+
|
|
965
|
+
Singularisation strips a trailing `es` after `s`, `x`, `z`, `ch`, `sh`, then a trailing `s`; a name that neither rule changes gets the suffix `_item` (`/data/:id` → `api.data_item`).
|
|
966
|
+
|
|
967
|
+
Path parameters become required named arguments, in path order, before any query or body arguments.
|
|
968
|
+
|
|
969
|
+
Two routes generating the same name is a compile error naming both — a single namespace, regardless of method. Disambiguate with `as`:
|
|
970
|
+
|
|
971
|
+
```vl
|
|
972
|
+
get /users/:id/avatar File! # action → api.avatar(id:)
|
|
973
|
+
get /teams/:id/avatar File! as team_avatar # would also be `avatar` — clashes
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
Method names are part of the service's public surface: renaming a path renames the client call and breaks callers at compile time, which is the intent.
|
|
977
|
+
|
|
978
|
+
### 10.1 Middleware
|
|
979
|
+
|
|
980
|
+
```vl
|
|
981
|
+
service api
|
|
982
|
+
before
|
|
983
|
+
log.request()
|
|
984
|
+
cors(origins: ["https://app.example.com"])
|
|
985
|
+
|
|
986
|
+
after
|
|
987
|
+
log.response()
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
`before` runs in declaration order and short-circuits on the first error. There is no `next()` call to forget.
|
|
991
|
+
|
|
992
|
+
---
|
|
993
|
+
|
|
994
|
+
## 11. Authentication and authorization
|
|
995
|
+
|
|
996
|
+
The `auth` block is declared once per project.
|
|
997
|
+
|
|
998
|
+
```vl
|
|
999
|
+
auth
|
|
1000
|
+
user User # the record sessions attach to
|
|
1001
|
+
identity email # the field used as login identity
|
|
1002
|
+
|
|
1003
|
+
provider password
|
|
1004
|
+
min_len 12
|
|
1005
|
+
breached_check true
|
|
1006
|
+
verify_email true
|
|
1007
|
+
|
|
1008
|
+
provider oauth google
|
|
1009
|
+
client_id env.GOOGLE_CLIENT_ID
|
|
1010
|
+
secret env.GOOGLE_SECRET
|
|
1011
|
+
scopes ["openid", "email", "profile"]
|
|
1012
|
+
|
|
1013
|
+
provider totp
|
|
1014
|
+
issuer "Example"
|
|
1015
|
+
required_for [admin]
|
|
1016
|
+
|
|
1017
|
+
session
|
|
1018
|
+
store cookie
|
|
1019
|
+
ttl 30d
|
|
1020
|
+
idle_timeout 12h
|
|
1021
|
+
rotate_on_login true
|
|
1022
|
+
rotate_on_privilege_change true
|
|
1023
|
+
|
|
1024
|
+
roles admin, author, member
|
|
1025
|
+
default_role member
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
### 11.1 What the compiler guarantees
|
|
1029
|
+
|
|
1030
|
+
These properties hold with no way to disable them from Volaro source:
|
|
1031
|
+
|
|
1032
|
+
- Passwords hash with Argon2id at parameters the compiler pins and updates by release. There is no field type that stores a plaintext password and no library call that accepts one.
|
|
1033
|
+
- Session cookies are `HttpOnly`, `Secure`, and `SameSite=Lax` (`Strict` when no OAuth provider is declared). The names and flags are not user-settable.
|
|
1034
|
+
- State-changing routes (`post`, `patch`, `put`, `delete`) require a valid CSRF token when the session store is `cookie`. The token is issued and checked without appearing in application code.
|
|
1035
|
+
- Session identifiers are 256 bits from the platform CSPRNG, stored hashed, and rotated on login and on any role change.
|
|
1036
|
+
- Login, password reset, and TOTP verification routes are rate-limited by identity and by source address.
|
|
1037
|
+
- Timing-safe comparison is the only comparison available for secrets, tokens, and hashes.
|
|
1038
|
+
- Password reset tokens are single-use, expire in 30 minutes, and never appear in logs or error messages.
|
|
1039
|
+
- Email verification tokens carry the same discipline with a longer life: single-use, expiring in 24 hours, stored hashed, and never appearing in logs, error messages, or response bodies. Redeeming one marks the account verified through the auth API. This does not make the model's `verified` field compiler-owned or prohibit ordinary model updates to it.
|
|
1040
|
+
|
|
1041
|
+
An audit of generated authentication code usually finds two or three of these missing. Moving them below the source line means a generator cannot omit them.
|
|
1042
|
+
|
|
1043
|
+
These guarantees cover *who may call a route*. They do **not** yet cover *what a caller may see*: nothing stops `db.posts.get(id)` from returning a row outside a record's intended public scope (e.g. an unpublished `Post`). Data-scope authorization is open question §20.7.
|
|
1044
|
+
|
|
1045
|
+
### 11.2 Guards
|
|
1046
|
+
|
|
1047
|
+
```vl
|
|
1048
|
+
auth required # any signed-in user
|
|
1049
|
+
auth role:admin # role check
|
|
1050
|
+
auth role:[admin, author] # any listed role
|
|
1051
|
+
auth owner:post.author_id # session user owns the resource
|
|
1052
|
+
auth optional # populate session if present, allow if not
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
A route with no `auth` line is public, and the compiler warns once per project if more than half the routes are public, which catches the case where a generator forgot the guards entirely.
|
|
1056
|
+
|
|
1057
|
+
Inside a guarded handler, `session.user` is the non-optional user record. Inside an `auth optional` handler it is `User?`. The type difference is what stops a null dereference.
|
|
1058
|
+
|
|
1059
|
+
Guards do not currently compose. In particular, there is no declarative form
|
|
1060
|
+
for the common policy “an administrator **or** this resource's owner.” Writing
|
|
1061
|
+
that comparison in the handler is possible, but makes authorization omissible
|
|
1062
|
+
application code and therefore weakens goal 4 in §1.1. This is an explicit v0.3
|
|
1063
|
+
language gap, not an idiomatic workaround; see open question §20.8.
|
|
1064
|
+
|
|
1065
|
+
### 11.3 Auth in views
|
|
1066
|
+
|
|
1067
|
+
```vl
|
|
1068
|
+
view Nav()
|
|
1069
|
+
row gap:16
|
|
1070
|
+
link "Home" to:"/"
|
|
1071
|
+
if session.user
|
|
1072
|
+
link "Write" to:"/new"
|
|
1073
|
+
if session.user.role == admin
|
|
1074
|
+
link "Admin" to:"/admin"
|
|
1075
|
+
button "Sign out" on tap: auth.sign_out()
|
|
1076
|
+
else
|
|
1077
|
+
link "Sign in" to:"/login"
|
|
1078
|
+
```
|
|
1079
|
+
|
|
1080
|
+
The client-side surface is eight calls. Each returns a fallible value with a documented error union, so a form handles failures by name rather than by string matching an error message.
|
|
1081
|
+
|
|
1082
|
+
| Call | Returns | Error union |
|
|
1083
|
+
|---|---|---|
|
|
1084
|
+
| `auth.sign_up(email:, password:)` | `Session!` | `email_taken`, `weak_password`, `breached_password`, `rate_limited(retry_after dur)` |
|
|
1085
|
+
| `auth.sign_in(email:, password:)` | `Session!` | `invalid_credentials`, `unverified_email`, `totp_required`, `rate_limited(retry_after dur)` |
|
|
1086
|
+
| `auth.sign_out()` | `nil!` | — |
|
|
1087
|
+
| `auth.start_oauth(provider)` | `nil!` | `provider_error`, `rate_limited(retry_after dur)` |
|
|
1088
|
+
| `auth.reset_password(email:)` | `nil!` | `rate_limited(retry_after dur)` |
|
|
1089
|
+
| `auth.confirm_reset(token:, password:)` | `Session!` | `invalid_token`, `expired_token`, `weak_password`, `breached_password` |
|
|
1090
|
+
| `auth.confirm_verify(token:)` | `Session!` | `invalid_token`, `expired_token` |
|
|
1091
|
+
| `auth.verify_totp(code:)` | `Session!` | `invalid_code`, `no_totp_enrolled`, `rate_limited(retry_after dur)` |
|
|
1092
|
+
|
|
1093
|
+
`sign_up` returns a session only when `verify_email false`; with `verify_email true` (the default) it returns after sending the verification mail and the caller routes to a "check your email" state. `confirm_verify` is the other half of that flow, and without it `verify_email true` has no exit: it redeems the token from that mail, marks the account verified, and establishes a session. **Because it returns `Session!`, a successful confirmation also signs the user in** — the newcomer follows the link and lands in the application authenticated, with no sign-in form in between. A caller that presents one anyway is asking an already-authenticated user to prove themselves twice. `reset_password` never reveals whether the address is registered: it succeeds uniformly for known and unknown addresses. `confirm_reset`, `confirm_verify` and `verify_totp` establish a fresh session and rotate the session id, so no separate sign-in call follows them.
|
|
1094
|
+
|
|
1095
|
+
`start_oauth` takes a bare provider name matching a `provider oauth <name>` declaration in the `auth` block (`auth.start_oauth(google)`).
|
|
1096
|
+
|
|
1097
|
+
---
|
|
1098
|
+
|
|
1099
|
+
## 12. Concurrency
|
|
1100
|
+
|
|
1101
|
+
Every IO operation is asynchronous. There is no `async` keyword and no `await`, because the color of a function is derivable from its body and carrying it in the signature adds tokens without adding information. The compiler computes it and the type system enforces the same rules a colored system would.
|
|
1102
|
+
|
|
1103
|
+
Sequential code is sequential:
|
|
1104
|
+
|
|
1105
|
+
```vl
|
|
1106
|
+
let user = db.users.get(id)?
|
|
1107
|
+
let posts = db.posts.by_author(user.id)?
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
Parallel code says so:
|
|
1111
|
+
|
|
1112
|
+
```vl
|
|
1113
|
+
par
|
|
1114
|
+
let user = db.users.get(id)?
|
|
1115
|
+
let posts = blog.list_posts(topic: "rust")?
|
|
1116
|
+
let quota = billing.quota(id)?
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
Bindings inside `par` start together and are available after the block. A failure in any branch cancels the others and propagates.
|
|
1120
|
+
|
|
1121
|
+
Background work is explicit and does not block a response:
|
|
1122
|
+
|
|
1123
|
+
```vl
|
|
1124
|
+
spawn
|
|
1125
|
+
mailer.send_welcome(user.email)
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
A spawned task that fails logs and does not affect the caller. Fire-and-forget is the only spawn form; anything needing a result uses `par`.
|
|
1129
|
+
|
|
1130
|
+
`spawn` is legal in a view body, including inside an event handler. Unlike `load` (§8.4), a spawned task is not cancelled when the view unmounts: it runs to completion, and a failure logs without affecting the view.
|
|
1131
|
+
|
|
1132
|
+
---
|
|
1133
|
+
|
|
1134
|
+
## 13. Standard library surface
|
|
1135
|
+
|
|
1136
|
+
The library is small on purpose. A large surface is a large thing to remember and a large thing to hallucinate.
|
|
1137
|
+
|
|
1138
|
+
| Module | Contents |
|
|
1139
|
+
|---|---|
|
|
1140
|
+
| `std.ui` | layout and input components, auto-imported in views |
|
|
1141
|
+
| `std.http` | request, response, status codes, header helpers |
|
|
1142
|
+
| `std.db` | query builder, migrations, transactions |
|
|
1143
|
+
| `std.time` | `now`, formatting, parsing, arithmetic on `time` and `dur` |
|
|
1144
|
+
| `std.str` | split, join, trim, case, replace, regex |
|
|
1145
|
+
| `std.list` | map, filter, fold, sort_by, group_by, take, chunk, concat, append |
|
|
1146
|
+
| `std.map` | keys, values, merge, get_or |
|
|
1147
|
+
| `std.json` | encode, decode, pointer access |
|
|
1148
|
+
| `std.crypto` | random bytes, hashing, timing-safe compare |
|
|
1149
|
+
| `std.log` | structured logging at five levels |
|
|
1150
|
+
| `std.test` | assertions, fixtures, HTTP and view test harnesses |
|
|
1151
|
+
| `std.sync` | reference cells, channels, mutex |
|
|
1152
|
+
| `std.ffi` | Rust boundary |
|
|
1153
|
+
|
|
1154
|
+
### 13.1 Database access
|
|
1155
|
+
|
|
1156
|
+
```vl
|
|
1157
|
+
model Post
|
|
1158
|
+
id uuid pk
|
|
1159
|
+
title str
|
|
1160
|
+
body text
|
|
1161
|
+
author_id uuid ref:User.id
|
|
1162
|
+
published bool = false
|
|
1163
|
+
created_at time = now()
|
|
1164
|
+
|
|
1165
|
+
index (author_id, created_at desc)
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
`model` is a record with persistence. Migrations generate from the diff between committed models and the database schema, and `vl migrate` applies them with a printed plan and a required confirmation for destructive steps.
|
|
1169
|
+
|
|
1170
|
+
Queries are typed:
|
|
1171
|
+
|
|
1172
|
+
```vl
|
|
1173
|
+
db.posts
|
|
1174
|
+
.where(published: true, author_id: user.id)
|
|
1175
|
+
.order(created_at: desc)
|
|
1176
|
+
.limit(20)
|
|
1177
|
+
.all()?
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
A bare value in `where` is exact equality, as above. A **comparison predicate**
|
|
1181
|
+
wraps the value instead of matching it, for everything equality can't express —
|
|
1182
|
+
`where` still ANDs one predicate per named column:
|
|
1183
|
+
|
|
1184
|
+
```vl
|
|
1185
|
+
db.posts
|
|
1186
|
+
.where(published: true, created_at: lt(cursor))
|
|
1187
|
+
.order(created_at: desc)
|
|
1188
|
+
.limit(20)
|
|
1189
|
+
.all()?
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
| predicate | SQL |
|
|
1193
|
+
|---|---|
|
|
1194
|
+
| `lt(x)` / `lte(x)` | `< x` / `<= x` |
|
|
1195
|
+
| `gt(x)` / `gte(x)` | `> x` / `>= x` |
|
|
1196
|
+
| `ne(x)` | `!= x` |
|
|
1197
|
+
| `between(lo, hi)` | `BETWEEN lo AND hi` |
|
|
1198
|
+
| `any_of(xs)` | `IN (xs…)` — `xs` a `list<T>`; an empty list matches no rows |
|
|
1199
|
+
|
|
1200
|
+
Each predicate lowers to exactly one parameterized SQL fragment — there is no
|
|
1201
|
+
way to interpolate a raw comparison, and the emitter rejects a `where` value
|
|
1202
|
+
it does not recognize (a call to any name outside this table) rather than
|
|
1203
|
+
silently binding it as an opaque parameter. `where` composes **only** by AND
|
|
1204
|
+
across its named arguments; there is no `or` or nested-boolean form. A query
|
|
1205
|
+
needing one is a `db.raw` case, not a reason to grow this table speculatively
|
|
1206
|
+
— add the next predicate when a real feature needs it, the same discipline
|
|
1207
|
+
`std.ui` follows for accessible primitives (§8.8).
|
|
1208
|
+
|
|
1209
|
+
Raw SQL is available through `db.raw("...", args)` and returns `list<json>`, forcing an explicit decode. Interpolation into `db.raw` is a compile error; parameters are positional only.
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
---
|
|
1213
|
+
|
|
1214
|
+
## 14. Token economy
|
|
1215
|
+
|
|
1216
|
+
The claim to test is that a feature costs fewer tokens in Volaro. Here is one feature, written both ways: a sign-in form that posts credentials, handles four failure modes, and redirects on success.
|
|
1217
|
+
|
|
1218
|
+
### 14.1 Volaro
|
|
1219
|
+
|
|
1220
|
+
```vl
|
|
1221
|
+
view SignIn()
|
|
1222
|
+
state email = ""
|
|
1223
|
+
state password = ""
|
|
1224
|
+
state error str? = nil
|
|
1225
|
+
|
|
1226
|
+
col gap:12 pad:24 max_w:360
|
|
1227
|
+
text "Sign in" size:24 weight:600
|
|
1228
|
+
input bind:email type:email placeholder:"Email"
|
|
1229
|
+
input bind:password type:password placeholder:"Password"
|
|
1230
|
+
if error
|
|
1231
|
+
text error color:danger size:13
|
|
1232
|
+
button "Sign in" variant:primary
|
|
1233
|
+
on tap
|
|
1234
|
+
error = nil
|
|
1235
|
+
try auth.sign_in(email: email, password: password)
|
|
1236
|
+
catch invalid_credentials
|
|
1237
|
+
error = "Wrong email or password."
|
|
1238
|
+
catch unverified_email
|
|
1239
|
+
route.to("/verify")
|
|
1240
|
+
catch rate_limited(retry_after)
|
|
1241
|
+
error = "Too many attempts. Try again in {retry_after}."
|
|
1242
|
+
catch e
|
|
1243
|
+
error = "Something went wrong."
|
|
1244
|
+
route.to("/")
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
24 lines, 768 characters.
|
|
1248
|
+
|
|
1249
|
+
### 14.2 The same feature on a React, Express, and Passport stack
|
|
1250
|
+
|
|
1251
|
+
The equivalent needs a client component with four `useState` hooks and a submit handler, a `POST /login` route, a Passport local strategy, a session middleware configuration, a bcrypt comparison, and a rate limiter registration. Across those files the working version runs 180 to 220 lines and 5,500 to 6,500 characters, before the imports.
|
|
1252
|
+
|
|
1253
|
+
### 14.3 Ratio
|
|
1254
|
+
|
|
1255
|
+
**Measured, 2026-09-03** (`o200k_base`, 7 hand-written features, both arms, against Next.js 15 + Auth.js v5 + Prisma + `@node-rs/argon2` — a stack that already collapses much of the boilerplate):
|
|
1256
|
+
|
|
1257
|
+
| | per feature |
|
|
1258
|
+
|---|---|
|
|
1259
|
+
| Volaro | 297 tokens |
|
|
1260
|
+
| baseline | 1 192 tokens |
|
|
1261
|
+
| **ratio** | **4.01×** |
|
|
1262
|
+
|
|
1263
|
+
That is below the 5:1–8:1 the earlier draft projected, and the band structure is not what was predicted either: the widest feature is TOTP (7.0×) and the narrowest so far is a list-and-detail view (2.2×), so "views are the wide end" does not hold against modern server components. Authentication is where the claim is strongest.
|
|
1264
|
+
|
|
1265
|
+
The number that matters for an agent is context: a feature costs 895 fewer tokens to hold in a window, so a 40-feature codebase is 3.5× cheaper to work in, and the ~1 700-token generation cheatsheet pays for itself after two files.
|
|
1266
|
+
|
|
1267
|
+
Seven of the planned forty features are written. The measurement is provisional in n, not in method.
|
|
1268
|
+
|
|
1269
|
+
### 14.4 Reading cost
|
|
1270
|
+
|
|
1271
|
+
Generation cost is only half the number. A model editing an existing project spends most of its budget reading. `vl map` emits the project surface:
|
|
1272
|
+
|
|
1273
|
+
```
|
|
1274
|
+
view SignIn()
|
|
1275
|
+
view Feed(topic str)
|
|
1276
|
+
view UserCard(user User, compact bool = false)
|
|
1277
|
+
model Post{id, title, body, author_id -> User.id, published, created_at}
|
|
1278
|
+
model User{id, email, name?, role, created_at}
|
|
1279
|
+
service api GET /api/v1/posts(topic str?, limit int) -> list<Post>! public
|
|
1280
|
+
service api POST /api/v1/posts(body NewPost) -> Post! auth:role[author]
|
|
1281
|
+
api blog GET list_posts(topic str, limit int) -> list<Post>
|
|
1282
|
+
auth providers[password, oauth:google, totp] roles[admin, author, member]
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
One line per surface element. A 6,000-line project produces a map of roughly 300 lines, so the whole shape of the application fits in a few thousand tokens and a model reads only the files it intends to change.
|
|
1286
|
+
|
|
1287
|
+
---
|
|
1288
|
+
|
|
1289
|
+
## 15. Worked example
|
|
1290
|
+
|
|
1291
|
+
A complete application: a blog with public reading, authenticated writing, and an outbound call to a moderation API.
|
|
1292
|
+
|
|
1293
|
+
```vl
|
|
1294
|
+
# src/models.vl
|
|
1295
|
+
use std.time { now }
|
|
1296
|
+
|
|
1297
|
+
type Role = admin | author | member
|
|
1298
|
+
|
|
1299
|
+
model User
|
|
1300
|
+
id uuid pk
|
|
1301
|
+
email str unique
|
|
1302
|
+
name str?
|
|
1303
|
+
role Role = member
|
|
1304
|
+
created_at time = now()
|
|
1305
|
+
|
|
1306
|
+
model Post
|
|
1307
|
+
id uuid pk
|
|
1308
|
+
title str
|
|
1309
|
+
body text
|
|
1310
|
+
author_id uuid ref:User.id
|
|
1311
|
+
published bool = false
|
|
1312
|
+
created_at time = now()
|
|
1313
|
+
|
|
1314
|
+
index (published, created_at desc)
|
|
1315
|
+
|
|
1316
|
+
type NewPost
|
|
1317
|
+
title str
|
|
1318
|
+
body text
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
```vl
|
|
1322
|
+
# src/auth.vl
|
|
1323
|
+
use ./models { User }
|
|
1324
|
+
|
|
1325
|
+
auth
|
|
1326
|
+
user User
|
|
1327
|
+
identity email
|
|
1328
|
+
|
|
1329
|
+
provider password
|
|
1330
|
+
min_len 12
|
|
1331
|
+
breached_check true
|
|
1332
|
+
verify_email true
|
|
1333
|
+
|
|
1334
|
+
session
|
|
1335
|
+
store cookie
|
|
1336
|
+
ttl 14d
|
|
1337
|
+
rotate_on_login true
|
|
1338
|
+
|
|
1339
|
+
roles admin, author, member
|
|
1340
|
+
default_role member
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
```vl
|
|
1344
|
+
# src/api.vl
|
|
1345
|
+
use ./models { Post, NewPost }
|
|
1346
|
+
|
|
1347
|
+
api moderation
|
|
1348
|
+
base "https://mod.example.com/v1"
|
|
1349
|
+
auth bearer env.MOD_TOKEN
|
|
1350
|
+
timeout 5s
|
|
1351
|
+
retry 2 backoff:exp on:[429, 503]
|
|
1352
|
+
|
|
1353
|
+
post check(text str) Verdict!
|
|
1354
|
+
path "/check"
|
|
1355
|
+
|
|
1356
|
+
type Verdict
|
|
1357
|
+
allowed bool
|
|
1358
|
+
reason str?
|
|
1359
|
+
|
|
1360
|
+
service api
|
|
1361
|
+
prefix "/api"
|
|
1362
|
+
|
|
1363
|
+
errors
|
|
1364
|
+
not_found -> 404
|
|
1365
|
+
forbidden -> 403
|
|
1366
|
+
rejected -> 422
|
|
1367
|
+
_ -> 500
|
|
1368
|
+
|
|
1369
|
+
get /posts(limit int = 20) list<Post>!
|
|
1370
|
+
db.posts.where(published: true).order(created_at: desc).limit(limit).all()?
|
|
1371
|
+
|
|
1372
|
+
get /posts/:id Post!
|
|
1373
|
+
db.posts.get(id)?
|
|
1374
|
+
|
|
1375
|
+
post /posts(body NewPost) Post!
|
|
1376
|
+
auth role:[author, admin]
|
|
1377
|
+
let verdict = moderation.check(text: body.body)?
|
|
1378
|
+
if not verdict.allowed
|
|
1379
|
+
return err(rejected(verdict.reason ?? "content rejected"))
|
|
1380
|
+
db.posts.insert(body, author_id: session.user.id)?
|
|
1381
|
+
|
|
1382
|
+
delete /posts/:id nil!
|
|
1383
|
+
auth owner:db.posts.get(id)?.author_id
|
|
1384
|
+
db.posts.delete(id)?
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
```vl
|
|
1388
|
+
# src/views/Feed.vl
|
|
1389
|
+
use ../models { Post }
|
|
1390
|
+
|
|
1391
|
+
view Feed()
|
|
1392
|
+
load posts = api.posts(limit: 20)
|
|
1393
|
+
pending
|
|
1394
|
+
Spinner()
|
|
1395
|
+
error e
|
|
1396
|
+
col gap:8 pad:24
|
|
1397
|
+
text "Could not load posts." color:danger
|
|
1398
|
+
button "Retry" on tap: posts.reload()
|
|
1399
|
+
|
|
1400
|
+
col gap:16 pad:24 max_w:720
|
|
1401
|
+
row justify:between align:center
|
|
1402
|
+
text "Latest" size:28 weight:700
|
|
1403
|
+
if session.user and session.user.role != member
|
|
1404
|
+
link "Write" to:"/new"
|
|
1405
|
+
|
|
1406
|
+
if posts.len() == 0
|
|
1407
|
+
text "Nothing published yet." color:muted
|
|
1408
|
+
list posts as p key:p.id
|
|
1409
|
+
col gap:4
|
|
1410
|
+
link p.title to:"/posts/{p.id}" size:18 weight:600
|
|
1411
|
+
text p.created_at.format("MMM d, yyyy") size:13 color:muted
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
Four files, 109 lines, and the result is a deployable application with hashed passwords, rotated sessions, CSRF protection on the write routes, a retrying moderation client, keyed list rendering, and a loading state.
|
|
1415
|
+
|
|
1416
|
+
---
|
|
1417
|
+
|
|
1418
|
+
## 16. Grammar
|
|
1419
|
+
|
|
1420
|
+
Core grammar in EBNF. `NL`, `INDENT`, and `DEDENT` are lexer-synthesized tokens. `block(X)` abbreviates `NL INDENT X+ DEDENT`.
|
|
1421
|
+
|
|
1422
|
+
```ebnf
|
|
1423
|
+
program = { item } ;
|
|
1424
|
+
item = use | type_decl | model_decl | fn_decl | view_decl
|
|
1425
|
+
| api_decl | service_decl | auth_decl | theme_decl ;
|
|
1426
|
+
|
|
1427
|
+
use = "use" path [ "{" ident { "," ident } "}" ] NL ;
|
|
1428
|
+
path = ident { "." ident } | rel_path ;
|
|
1429
|
+
|
|
1430
|
+
type_decl = "type" TypeName ( record_body | "=" union_body | "=" type ) ;
|
|
1431
|
+
record_body = block( field ) ;
|
|
1432
|
+
field = ident type [ "=" expr ] NL ;
|
|
1433
|
+
union_body = variant { "|" variant } NL
|
|
1434
|
+
| block( variant ) ;
|
|
1435
|
+
variant = ident [ "(" param { "," param } ")" ] ;
|
|
1436
|
+
|
|
1437
|
+
model_decl = "model" TypeName block( field | index_decl ) ;
|
|
1438
|
+
index_decl = "index" "(" ident [ "desc" ] { "," ident [ "desc" ] } ")" NL ;
|
|
1439
|
+
|
|
1440
|
+
fn_decl = [ doc ] "fn" ident "(" [ params ] ")" [ type ] block( stmt ) ;
|
|
1441
|
+
params = param { "," param } ;
|
|
1442
|
+
param = ident type [ "=" expr ] ;
|
|
1443
|
+
|
|
1444
|
+
type = TypeName [ "<" type { "," type } ">" ]
|
|
1445
|
+
| prim
|
|
1446
|
+
| type "?"
|
|
1447
|
+
| type "!" [ type ]
|
|
1448
|
+
| "fn" "(" [ type { "," type } ] ")" type ;
|
|
1449
|
+
|
|
1450
|
+
stmt = let | assign | if_stmt | match_stmt | for | while
|
|
1451
|
+
| "break" NL | "skip" NL | "return" [ expr ] NL
|
|
1452
|
+
| par | spawn | try_stmt | expr NL ;
|
|
1453
|
+
|
|
1454
|
+
let = "let" [ "mut" ] ident [ type ] "=" expr NL ;
|
|
1455
|
+
assign = lvalue ( "=" | "+=" | "-=" | "*=" | "/=" ) expr NL ;
|
|
1456
|
+
|
|
1457
|
+
if_stmt = "if" expr block( stmt ) { "else" "if" expr block( stmt ) }
|
|
1458
|
+
[ "else" block( stmt ) ] ;
|
|
1459
|
+
match_stmt = "match" expr block( arm ) ;
|
|
1460
|
+
arm = pattern [ "if" expr ] "->" ( expr NL | block( stmt ) ) ;
|
|
1461
|
+
|
|
1462
|
+
for = "for" ident [ "," ident ] "in" expr block( stmt ) ;
|
|
1463
|
+
while = "while" expr block( stmt ) ;
|
|
1464
|
+
par = "par" block( let ) ;
|
|
1465
|
+
spawn = "spawn" block( stmt ) ;
|
|
1466
|
+
try_stmt = "try" expr NL { "catch" pattern block( stmt ) } ;
|
|
1467
|
+
|
|
1468
|
+
view_decl = "view" TypeName "(" [ params ] ")" block( view_stmt ) ;
|
|
1469
|
+
view_stmt = "state" ident [ type ] "=" expr NL
|
|
1470
|
+
| "derive" ident "=" expr NL
|
|
1471
|
+
| load_stmt
|
|
1472
|
+
| "on" lifecycle block( stmt )
|
|
1473
|
+
| "list" expr "as" ident "key" ":" expr block( view_stmt )
|
|
1474
|
+
| element
|
|
1475
|
+
| if_stmt | match_stmt | stmt ;
|
|
1476
|
+
load_stmt = "load" ident "=" expr
|
|
1477
|
+
block( "pending" block( view_stmt )
|
|
1478
|
+
| "error" ident block( view_stmt ) ) ;
|
|
1479
|
+
lifecycle = "mount" | "unmount" | "change" ident { "," ident } ;
|
|
1480
|
+
element = TypeName { attr } [ block( view_stmt ) ] NL ;
|
|
1481
|
+
attr = ident ":" expr ;
|
|
1482
|
+
|
|
1483
|
+
api_decl = "api" ident [ "from" expr ] block( api_setting | endpoint ) ;
|
|
1484
|
+
endpoint = method ident "(" [ params ] ")" type block( ep_setting ) ;
|
|
1485
|
+
ep_setting = "path" str NL | "query" ident { "," ident } NL | "body" ident NL ;
|
|
1486
|
+
|
|
1487
|
+
service_decl= "service" ident block( svc_setting | route ) ;
|
|
1488
|
+
route = method route_path [ "(" params ")" ] type block( route_stmt ) ;
|
|
1489
|
+
route_stmt = "auth" guard NL | stmt ;
|
|
1490
|
+
guard = "required" | "optional" | "role" ":" role_list
|
|
1491
|
+
| "owner" ":" expr ;
|
|
1492
|
+
|
|
1493
|
+
auth_decl = "auth" block( auth_setting ) ;
|
|
1494
|
+
method = "get" | "post" | "put" | "patch" | "delete" ;
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
The full grammar including operator precedence and the theme block lives in `grammar/volaro.ebnf` in the reference repository.
|
|
1498
|
+
|
|
1499
|
+
---
|
|
1500
|
+
|
|
1501
|
+
## 17. Reference implementation in Rust
|
|
1502
|
+
|
|
1503
|
+
Rust is the implementation language for three reasons: the compiler ships as a single static binary with no runtime dependency, the server backend compiles to Rust and reuses the same type representations, and the incremental query and CST libraries the project needs already exist there and are proven in `rust-analyzer`.
|
|
1504
|
+
|
|
1505
|
+
### 17.1 Workspace layout
|
|
1506
|
+
|
|
1507
|
+
```
|
|
1508
|
+
volaro/
|
|
1509
|
+
crates/
|
|
1510
|
+
vl-lexer # source -> tokens, indentation handling
|
|
1511
|
+
vl-syntax # CST (rowan), parser, typed AST view
|
|
1512
|
+
vl-hir # desugared, name-resolved tree
|
|
1513
|
+
vl-types # type checker, inference, exhaustiveness
|
|
1514
|
+
vl-vir # typed IR, the input to every backend
|
|
1515
|
+
vl-interp # tree-walking interpreter over VIR
|
|
1516
|
+
vl-emit-ts # TypeScript emitter for views
|
|
1517
|
+
vl-emit-rs # Rust emitter for services
|
|
1518
|
+
vl-auth # auth code generation, pinned crypto parameters
|
|
1519
|
+
vl-db # model diffing, migration generation
|
|
1520
|
+
vl-diag # diagnostic types, rendering, JSON output
|
|
1521
|
+
vl-fmt # canonical formatter
|
|
1522
|
+
vl-lsp # language server
|
|
1523
|
+
vl-cli # the `vl` binary
|
|
1524
|
+
```
|
|
1525
|
+
|
|
1526
|
+
### 17.2 Lexer
|
|
1527
|
+
|
|
1528
|
+
Hand-written rather than generated. `logos` handles token patterns well but does not model an indent stack, and the indentation rules are the part most worth controlling directly.
|
|
1529
|
+
|
|
1530
|
+
The lexer holds:
|
|
1531
|
+
|
|
1532
|
+
- a byte cursor and a line-start flag
|
|
1533
|
+
- a `Vec<u32>` indent stack, initialized to `[0]`
|
|
1534
|
+
- a bracket depth counter
|
|
1535
|
+
|
|
1536
|
+
At each line start it measures indent width, rejects tabs, and compares against the stack top. Greater width pushes and emits `Indent`. Lesser width pops repeatedly, emitting `Dedent` per pop, and errors if no stack entry matches. Blank lines and comment-only lines emit nothing. When bracket depth is above zero the whole procedure is skipped.
|
|
1537
|
+
|
|
1538
|
+
Tokens carry a `TextRange` of byte offsets, never line and column pairs. Line numbers are computed once, at diagnostic rendering time, from a line-index table.
|
|
1539
|
+
|
|
1540
|
+
```rust
|
|
1541
|
+
pub struct Token {
|
|
1542
|
+
pub kind: SyntaxKind,
|
|
1543
|
+
pub range: TextRange,
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
pub struct Lexer<'src> {
|
|
1547
|
+
src: &'src str,
|
|
1548
|
+
pos: TextSize,
|
|
1549
|
+
indents: Vec<u32>,
|
|
1550
|
+
bracket_depth: u32,
|
|
1551
|
+
at_line_start: bool,
|
|
1552
|
+
errors: Vec<LexError>,
|
|
1553
|
+
}
|
|
1554
|
+
```
|
|
1555
|
+
|
|
1556
|
+
The lexer never fails hard. An unterminated string produces a token covering the rest of the line plus an error, so the parser keeps making progress and the editor keeps highlighting.
|
|
1557
|
+
|
|
1558
|
+
### 17.3 Parser
|
|
1559
|
+
|
|
1560
|
+
Recursive descent for statements and declarations, Pratt parsing for expressions. The parser writes into a `rowan` green tree builder, producing a lossless CST that preserves whitespace and comments. The formatter and language server read the CST; everything past name resolution reads the typed AST view layered on top.
|
|
1561
|
+
|
|
1562
|
+
Error recovery synchronizes on three anchors: a `Dedent` to the enclosing block level, a statement-start keyword, and end of file. A parse error inside a view body recovers at the next sibling element, so one bad attribute does not lose the rest of the tree.
|
|
1563
|
+
|
|
1564
|
+
Expression parsing uses a binding-power table matching the precedence in section 5.2:
|
|
1565
|
+
|
|
1566
|
+
```rust
|
|
1567
|
+
fn binding_power(op: SyntaxKind) -> Option<(u8, u8)> {
|
|
1568
|
+
Some(match op {
|
|
1569
|
+
T![|>] => (1, 2),
|
|
1570
|
+
T![??] => (4, 3), // right associative
|
|
1571
|
+
T![or] => (5, 6),
|
|
1572
|
+
T![and] => (7, 8),
|
|
1573
|
+
T![==] | T![!=] => (9, 9), // non-associative, checked after parse
|
|
1574
|
+
T![<] | T![<=] | T![>] | T![>=] => (11, 11),
|
|
1575
|
+
T![..] | T![..=] => (12, 12), // non-associative, section 5.2 level 5
|
|
1576
|
+
T![+] | T![-] => (13, 14),
|
|
1577
|
+
T![*] | T![/] | T![%] => (15, 16),
|
|
1578
|
+
_ => return None,
|
|
1579
|
+
})
|
|
1580
|
+
}
|
|
1581
|
+
```
|
|
1582
|
+
|
|
1583
|
+
Equal binding powers on both sides mark non-associative operators; the parser detects a second occurrence at the same level and emits the chained-comparison diagnostic with its suggested rewrite.
|
|
1584
|
+
|
|
1585
|
+
### 17.4 Name resolution and HIR
|
|
1586
|
+
|
|
1587
|
+
`vl-hir` lowers the CST into an arena-allocated tree with every name resolved to a `DefId`. Desugaring happens here, so later stages see a smaller language:
|
|
1588
|
+
|
|
1589
|
+
- `x |> f(a)` becomes `f(x, a)`
|
|
1590
|
+
- `a ?? b` becomes a match on the optional
|
|
1591
|
+
- `if` as expression becomes a match on the condition
|
|
1592
|
+
- string interpolation becomes a `concat` call over formatted parts
|
|
1593
|
+
- `derive` becomes a memoized function plus a dependency set computed from the free variables of its body
|
|
1594
|
+
- `load` becomes a state machine with four states and a cache key derived from the call arguments
|
|
1595
|
+
- an `auth role:x` guard becomes a `before` handler prepended to the route body
|
|
1596
|
+
|
|
1597
|
+
Views, routes, and endpoints reduce to ordinary functions and records by the end of lowering. Everything domain-specific lives in the front end, which keeps the backends small.
|
|
1598
|
+
|
|
1599
|
+
### 17.5 Type checking
|
|
1600
|
+
|
|
1601
|
+
Local Hindley-Milner with no let-generalization across item boundaries, since item signatures are always annotated. Unification runs over a union-find of type variables. Row polymorphism is not needed, since records are nominal (§4.3); equality is structural but plays no part in unification.
|
|
1602
|
+
|
|
1603
|
+
Three checks run alongside inference:
|
|
1604
|
+
|
|
1605
|
+
1. **Exhaustiveness** over union patterns, using the usefulness algorithm from Maranget's work on ML pattern matching, adapted to handle guards conservatively (a guarded arm never counts toward coverage).
|
|
1606
|
+
2. **Fallibility**, verifying `?` appears only in fallible functions with a compatible error type, and that no `T!` value goes unused.
|
|
1607
|
+
3. **Effect coloring**, marking every function that reaches IO transitively. The result drives async code generation without appearing in signatures.
|
|
1608
|
+
|
|
1609
|
+
### 17.6 VIR and backends
|
|
1610
|
+
|
|
1611
|
+
VIR is a typed tree IR, not SSA. Application code is not compute-bound, and the optimizations worth doing (dead code, constant folding, request deduplication) are tree rewrites. Skipping SSA saves a large amount of implementation effort with little cost.
|
|
1612
|
+
|
|
1613
|
+
Three consumers:
|
|
1614
|
+
|
|
1615
|
+
- **`vl-interp`** walks VIR directly. It backs `vl run`, the REPL, and the test harness. Startup is under 50 ms on a mid-sized project, which makes the edit-test loop fast enough that the compiled backends are not needed during development.
|
|
1616
|
+
- **`vl-emit-ts`** emits TypeScript for views. Reactive state becomes signals, `load` becomes a request hook, and elements become framework component calls. The emitter targets a thin runtime library rather than a specific framework, so the framework is a swappable backend detail.
|
|
1617
|
+
- **`vl-emit-rs`** emits Rust for services. Routes become `axum` handlers, models become `sqlx` queries checked at build time, and `par` becomes `tokio::try_join!`. The generated crate is readable and committed to the build directory, which means a team can inspect what runs in production.
|
|
1618
|
+
|
|
1619
|
+
Generated code carries source maps back to Volaro spans so a runtime stack trace names `.vl` files and lines.
|
|
1620
|
+
|
|
1621
|
+
### 17.7 Crate dependencies
|
|
1622
|
+
|
|
1623
|
+
| Crate | Use |
|
|
1624
|
+
|---|---|
|
|
1625
|
+
| `rowan` | lossless CST |
|
|
1626
|
+
| `la-arena` | HIR and VIR arenas |
|
|
1627
|
+
| `salsa` | incremental query framework for the LSP |
|
|
1628
|
+
| `rustc-hash` | fast hash maps |
|
|
1629
|
+
| `text-size` | byte offsets and ranges |
|
|
1630
|
+
| `ariadne` | terminal diagnostic rendering |
|
|
1631
|
+
| `serde` / `serde_json` | JSON diagnostics, schema import |
|
|
1632
|
+
| `tokio` | async runtime for generated services and the LSP |
|
|
1633
|
+
| `argon2` | password hashing, parameters pinned by `vl-auth` |
|
|
1634
|
+
| `insta` | snapshot tests for parser, formatter, and emitters |
|
|
1635
|
+
| `proptest` | round-trip property tests |
|
|
1636
|
+
|
|
1637
|
+
The compiler itself has no `unsafe` blocks, enforced by `#![forbid(unsafe_code)]` at every crate root.
|
|
1638
|
+
|
|
1639
|
+
### 17.8 Testing strategy
|
|
1640
|
+
|
|
1641
|
+
- **Parser**: snapshot tests over a corpus of `.vl` files, plus a property test asserting that formatting is idempotent and that print-then-parse round-trips to an identical CST modulo whitespace.
|
|
1642
|
+
- **Type checker**: a `tests/ui` directory of files with expected diagnostics inline as comments, in the style of `rustc`'s UI tests.
|
|
1643
|
+
- **Backends**: differential testing. The same program runs on the interpreter and on each emitted backend, and the outputs must match.
|
|
1644
|
+
- **Auth**: an adversarial suite asserting that no reachable source program produces a cookie without `HttpOnly`, a password comparison that is not timing-safe, or a state-changing route without CSRF verification.
|
|
1645
|
+
|
|
1646
|
+
---
|
|
1647
|
+
|
|
1648
|
+
## 18. Diagnostics
|
|
1649
|
+
|
|
1650
|
+
Errors are the interface between the compiler and whatever is writing the code. Two formats, one source.
|
|
1651
|
+
|
|
1652
|
+
Human format:
|
|
1653
|
+
|
|
1654
|
+
```
|
|
1655
|
+
error[E0412]: missing `key` on list
|
|
1656
|
+
┌─ src/views/Feed.vl:19:5
|
|
1657
|
+
│
|
|
1658
|
+
19 │ list posts as p
|
|
1659
|
+
│ ^^^^^^^^^^^^^^^ list needs a stable key expression
|
|
1660
|
+
│
|
|
1661
|
+
= fix: list posts as p key:p.id
|
|
1662
|
+
```
|
|
1663
|
+
|
|
1664
|
+
Machine format, from `vl check --json`:
|
|
1665
|
+
|
|
1666
|
+
```json
|
|
1667
|
+
{
|
|
1668
|
+
"code": "E0412",
|
|
1669
|
+
"severity": "error",
|
|
1670
|
+
"message": "missing `key` on list",
|
|
1671
|
+
"file": "src/views/Feed.vl",
|
|
1672
|
+
"range": { "start": 412, "end": 427 },
|
|
1673
|
+
"fix": {
|
|
1674
|
+
"description": "add a key expression",
|
|
1675
|
+
"edits": [{ "start": 427, "end": 427, "text": " key:p.id" }]
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
```
|
|
1679
|
+
|
|
1680
|
+
Every diagnostic carries at most one suggested fix, and the fix is a byte-range edit rather than prose. A generator applies it without re-reading the file. Where a fix is ambiguous the compiler emits no fix rather than a guess, since a wrong automatic edit is worse than none.
|
|
1681
|
+
|
|
1682
|
+
`vl explain E0412` prints the extended description with a correct and an incorrect example.
|
|
1683
|
+
|
|
1684
|
+
---
|
|
1685
|
+
|
|
1686
|
+
## 19. Tooling
|
|
1687
|
+
|
|
1688
|
+
| Command | Behavior |
|
|
1689
|
+
|---|---|
|
|
1690
|
+
| `vl new NAME` | scaffold a project |
|
|
1691
|
+
| `vl check` | typecheck, no output artifacts; `--json` for machine format |
|
|
1692
|
+
| `vl fmt` | canonical formatting, no options, exits non-zero on change with `--check` |
|
|
1693
|
+
| `vl run` | interpret, watch, hot reload |
|
|
1694
|
+
| `vl build` | emit TypeScript and Rust, compile, bundle |
|
|
1695
|
+
| `vl test` | run `std.test` suites on the interpreter |
|
|
1696
|
+
| `vl map` | print the project surface summary |
|
|
1697
|
+
| `vl migrate` | diff models against the database, print plan, apply |
|
|
1698
|
+
| `vl explain CODE` | extended diagnostic description |
|
|
1699
|
+
| `vl lsp` | language server over stdio |
|
|
1700
|
+
|
|
1701
|
+
The formatter has no configuration. Line width is 100. A single canonical form means generated and hand-written code are indistinguishable after formatting, and diffs never carry style noise.
|
|
1702
|
+
|
|
1703
|
+
---
|
|
1704
|
+
|
|
1705
|
+
## 20. Open questions
|
|
1706
|
+
|
|
1707
|
+
These are unresolved and should be settled before the grammar freezes.
|
|
1708
|
+
|
|
1709
|
+
1. **Mutation inside closures.** Capture-by-value keeps reasoning simple but makes some event-handler patterns awkward. The alternative is capture-by-reference for `state` bindings specifically, which is a special case in exchange for ergonomics.
|
|
1710
|
+
2. **Generics for user code.** The specification has generic types in the library (`list<T>`) but no way to declare one. Adding them costs syntax and inference complexity; omitting them forces duplication. A restricted form allowing type parameters on functions but not on records may be the right middle.
|
|
1711
|
+
3. **Streaming responses.** `load` covers request and response. Server-sent events and WebSocket subscriptions have no construct yet, and bolting them onto `load` versus adding a `subscribe` block is undecided.
|
|
1712
|
+
4. **The `distinct` boundary.** Newtypes prevent argument mix-ups but multiply conversion calls at the database and JSON edges. Automatic conversion at serialization boundaries would help and would also hide a real conversion.
|
|
1713
|
+
5. **Whether `derive` dependency inference is legible.** Automatic dependency tracking removes a common bug and also removes a visible declaration. If a reader cannot predict when a `derive` recomputes, the feature has traded one confusion for another.
|
|
1714
|
+
6. **Multi-tenancy in `auth`.** Roles are global. Organization-scoped roles are the common case in business software, and modeling them as a guard (`auth role:admin in:org`) versus leaving them to application code is open.
|
|
1715
|
+
7. **Data-scope authorization.** §11 makes auth *guards* a compiler concern, but the *public scope* of a record — a `Post` is visible only where `published: true` — is not expressible. `db.posts.get(id)` reads any row by id, so a route can leak a draft even with every guard in place, and the §15 hand-written reference did exactly that (a `TASK.md` requirement: "a missing post and an unpublished post look the same … do not reveal that a draft exists"). Candidates: a `scope published: true` clause on the `model`, or requiring a public read to name its scope. This is the gap between "secure by construction" covering *who can call* and covering *what they can see*, surfaced by a real information-disclosure bug — it should feed v0.3.
|
|
1716
|
+
8. **Disjunctive and composable authorization guards.** §11.2 can express one role set or one ownership predicate, but not a common policy such as “`admin` **or** the post's owner.” Feature 06 therefore had to fall back to `auth required` plus a hand-written handler condition. If that condition is omitted, the destructive route still compiles and deletes any matching row — the exact security-boilerplate failure goal 4 in §1.1 is meant to remove. Candidates include an explicit boolean guard form (`auth any(role:admin, owner:<expr>)`), a small `any`/`all` guard block, or a named reusable policy. Any design must define evaluation order, missing-resource behavior (including non-enumerating 403s), `session.user` typing, and how composed guards interact with CSRF and data scopes. Until resolved, hand-written authorization composition must be marked as a language gap rather than presented as idiomatic Volaro.
|
|
1717
|
+
9. ~~**List growth: concatenation, append, or spread.**~~ **Resolved
|
|
1718
|
+
2026-09-05.** There was no way to combine two `list<T>` values or add one
|
|
1719
|
+
element to an existing list — no operator (`+` on two lists lowered
|
|
1720
|
+
straight to the host's binary `+`, which is not list concatenation in
|
|
1721
|
+
every target), no `STD_FNS` entry, no spread syntax in the grammar.
|
|
1722
|
+
Found implementing feature 10 (infinite scroll): the reactive `load`
|
|
1723
|
+
re-fetches and replaces its bound value on every dependency change, so a
|
|
1724
|
+
paginated feed had no expressible way to keep earlier pages when a later
|
|
1725
|
+
one arrived — confirmed against emitted output, not just reasoned about.
|
|
1726
|
+
Decided over a `+` overload (would make one symbol mean two things, and
|
|
1727
|
+
`xs + x` vs `xs + ys` is genuinely ambiguous for a generating model —
|
|
1728
|
+
§4.6 is deliberate about avoiding exactly this kind of implicit,
|
|
1729
|
+
context-dependent meaning) and over spread syntax (new grammar for one
|
|
1730
|
+
operation, and `...` means nothing else in Volaro today): **`std.list`
|
|
1731
|
+
gains `concat(a, b)` and `append(xs, x)`**, ordinary functions that
|
|
1732
|
+
compose with the pipeline (`xs |> append(x)`) like `map`/`filter`
|
|
1733
|
+
already do, adding zero grammar. Resolving the primitive alone was not
|
|
1734
|
+
sufficient — see the companion decision below — and feature 10's own
|
|
1735
|
+
`Feed` view needed genuine compiler-bug fixes (`on mount` was
|
|
1736
|
+
AST-complete and precheck-passed but silently unemitted; the spec's own
|
|
1737
|
+
§6.2 expression-form `try`/`catch` had never been implemented) before it
|
|
1738
|
+
could actually use it. Full account: `views/10-infinite-scroll/DOGFOOD.md`.
|
|
1739
|
+
|
|
1740
|
+
**Companion decision: `load` does not gain an accumulate mode.** The
|
|
1741
|
+
alternative to a list primitive was extending `load` itself to keep
|
|
1742
|
+
prior pages instead of replacing them. Rejected: `load`'s `pending`
|
|
1743
|
+
region rendering *replaces the whole view body* while a fetch is in
|
|
1744
|
+
flight — correct for one first fetch, wrong for a later page, where
|
|
1745
|
+
already-loaded rows must stay visible with a loading indicator alongside
|
|
1746
|
+
them, not disappear behind a full-screen spinner. Redesigning `load`'s
|
|
1747
|
+
pending/error contract to support that was judged a bigger change than
|
|
1748
|
+
this pass's scope. The idiom instead: explicit `state` for the
|
|
1749
|
+
accumulator plus `on mount` (now real) and the feature's own event
|
|
1750
|
+
handlers drive fetches directly, giving full control over what stays
|
|
1751
|
+
visible — at the cost of losing `load`'s automatic request
|
|
1752
|
+
dedup/cache/cancellation-on-unmount for any feature that needs to
|
|
1753
|
+
accumulate, which is why this is recorded as a real trade-off, not a
|
|
1754
|
+
free win.
|
|
1755
|
+
|
|
1756
|
+
---
|
|
1757
|
+
|
|
1758
|
+
## 21. Milestones
|
|
1759
|
+
|
|
1760
|
+
| Milestone | Scope | Exit criterion |
|
|
1761
|
+
|---|---|---|
|
|
1762
|
+
| M0 | Lexer, parser, CST, formatter | Round-trip and idempotence property tests pass on a 5,000-line corpus |
|
|
1763
|
+
| M1 | HIR, type checker, interpreter | The worked example in section 15 runs, minus views |
|
|
1764
|
+
| M2 | Views, TypeScript emitter, token benchmark | 40-feature corpus implemented both ways, ratio measured and published |
|
|
1765
|
+
| M3 | `service`, `api`, Rust emitter | Worked example deploys and serves traffic |
|
|
1766
|
+
| M4 | `auth`, `vl-db`, migrations | Adversarial auth suite passes with zero reachable insecure programs |
|
|
1767
|
+
| M5 | LSP, incremental compilation, `vl map` | Sub-100 ms keystroke-to-diagnostic on a 6,000-line project |
|
|
1768
|
+
|
|
1769
|
+
M0 through M2 are the point where the central claim becomes testable. The density half is now measured ahead of M2 at **4.01×** on a 7-feature corpus (§14.3), above the 3:1 floor that would have called the design into question. What remains untested is whether an agent *writes* it correctly and whether "secure by construction" survives contact with an implementation — neither of which a token count can answer.
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
---
|
|
1773
|
+
|
|
1774
|
+
## 22. Changelog
|
|
1775
|
+
|
|
1776
|
+
### v0.2 — 2026-09-03
|
|
1777
|
+
|
|
1778
|
+
Ten inconsistencies found by implementing a 7-feature corpus by hand in both Volaro and a mainstream stack, and by writing a validator (`vlcheck`) against the grammar. Every change below closes a gap that made a real program unwritable or ambiguous.
|
|
1779
|
+
|
|
1780
|
+
**Blocking gaps — these made corpus features unwritable:**
|
|
1781
|
+
|
|
1782
|
+
1. **§11.3 client surface was incomplete.** It defined four calls; a signup, a reset-confirm and a TOTP step need three more. Added `auth.sign_up`, `auth.confirm_reset` and `auth.verify_totp`, and gave all seven a documented return type and error union in a table. Without these, features that the spec's own §14 motivates could not be expressed.
|
|
1783
|
+
2. **Item-route client names were undefined.** §10 showed `get /posts` and `get /posts/:id` as siblings but only ever called the collection. Added the naming rule (last non-parameter segment; singularised when the route has path parameters; `as` to disambiguate; duplicate names are a compile error). This unblocks generating a client for any route with a path parameter.
|
|
1784
|
+
3. **No way to declare a service intentionally public.** A genuine public-read API always tripped the "more than half these routes are public" warning, training the reader to ignore it. Added `service X public` and per-route `auth none`, with `auth none` on a write route still warning.
|
|
1785
|
+
|
|
1786
|
+
**Semantic corrections:**
|
|
1787
|
+
|
|
1788
|
+
4. **`try`/`catch` fell through to the success path.** The §14.1 sign-in example showed an error message being set *and* the success redirect running. Specified that a handled error terminates the `try` statement; code that must run regardless is placed **before** the `try`, or uses `spawn` for fire-and-forget. The example in §14.1 was right; the semantics were wrong. *(Amended below: an earlier draft of this fix added a `resume` keyword, since removed.)*
|
|
1789
|
+
5. **Records were "structurally compared" (§4.3) and "nominal" (§17.5).** Both are true of different things: nominal typing, structural equality. Stated precisely, and noted that nominal typing is what makes `distinct` meaningful.
|
|
1790
|
+
6. **`err` meant three things.** Separated the type `Err`, the constructor `err(...)`, and the reserved word `err`, and specified that `Err` is never record-literal constructed.
|
|
1791
|
+
|
|
1792
|
+
**Grammar corrections:**
|
|
1793
|
+
|
|
1794
|
+
7. **The keyword list omitted words the spec itself uses as declaration heads** — `model`, `spawn`, `theme`, `list`. Split into a reserved set and a contextual set recognised by position, so a field named `state` or a function named `load` stays legal. (Reserved count settled at 29 in the v0.2 amendments below.)
|
|
1795
|
+
8. **§3.4 required components to be `[A-Z]`** while every example used `col`, `row`, `text`. Made the case distinction meaningful: lowercase is a `std.ui` primitive element, uppercase is a `view`.
|
|
1796
|
+
9. **§17.3's binding-power table omitted `..` and `..=`**, which §5.2 places at level 5. Added at (12, 12), non-associative.
|
|
1797
|
+
10. **§1 said "no configuration files" while §7 and §13 required `volaro.toml`.** Narrowed the claim: one config file holding only what cannot be derived from source.
|
|
1798
|
+
|
|
1799
|
+
**Known follow-on:** the corpus adds an explicit `return` to every `catch` arm to work around finding 4. Those returns are now redundant. Removing them will shrink the Volaro side of the corpus and improve the measured ratio in §14.3, so the corpus should be simplified and re-measured rather than left as-is.
|
|
1800
|
+
|
|
1801
|
+
### v0.2 amendments — 2026-09-05
|
|
1802
|
+
|
|
1803
|
+
One blocking gap, found by tracing the default signup path through the reference implementation rather than by writing a feature against it.
|
|
1804
|
+
|
|
1805
|
+
1. **§11.3 had no way to complete email verification — so `verify_email true`, the default, was a dead end.** `sign_up` issues a verification token and leaves the account unverified; `sign_in` refuses an unverified account with `unverified_email`. Nothing in the seven-call client surface redeemed that token, so the compiler-owned auth API had no verification-completion path; ordinary model writes could still change the flag without redeeming a token. A project built on default settings therefore compiled, ran, accepted signups, and could not complete verification through the auth API, with no diagnostic for this missing step. The reference implementation's own tests concealed it: they marked the seeded user verified with a direct `UPDATE`, so the path was never exercised end to end. Added **`auth.confirm_verify(token:)` → `Session!`** with the error union `invalid_token`, `expired_token`, bringing the client surface to eight calls; §11.1 gains a matching bullet giving verification tokens the same single-use, hashed-at-rest, never-logged discipline as reset tokens, with a 24-hour life. This is the same class of gap as v0.2 finding 1 above and is recorded the same way.
|
|
1806
|
+
|
|
1807
|
+
**A successful confirmation also signs the user in.** `confirm_verify` returns `Session!`, so it establishes a session and rotates the session id exactly as `confirm_reset` does — the verification link is the last step before the application, not a step before a login form. This is a deliberate semantic choice and is stated in §11.3 rather than left to be inferred from the return type.
|
|
1808
|
+
|
|
1809
|
+
### v0.2 amendments — 2026-09-03
|
|
1810
|
+
|
|
1811
|
+
Corrections made while catching `vlcheck` up to v0.2 and building the first outbound demo, before either encoded the rules.
|
|
1812
|
+
|
|
1813
|
+
1. **§10 client naming is method-aware.** Finding 2 above keyed the generated name on the path alone, which made `get /x/:id` + `patch /x/:id` — ordinary CRUD — a name clash, and invalidated the §15 worked example. The name now depends on the HTTP method too: a collection `get` keeps the plural segment, every other collection verb and every item verb takes a `create_` / `update_` / `delete_` prefix on the singular, and an action segment (`…/:id/publish`) is used verbatim. The single-namespace clash rule stands; it just stops firing on well-formed REST.
|
|
1814
|
+
2. **`resume` removed.** Finding 4 above added a `resume` keyword to opt into `try` fall-through. Its one motivating call site (the corpus password-reset request form) is better written by putting the shared statement before the `try`, which leaves `resume` with no use — a second way to say one thing, and pure hallucination surface. §3.5 drops it: **29 reserved words**, not the 30 the list held or the "Thirty-one" the prose claimed. §6.2 drops the sentence.
|
|
1815
|
+
3. **`spawn` is legal in a view.** §12 and §8.5 now state that a `spawn` block may appear in a view body or event handler, and that — unlike `load` — it is not cancelled on unmount: it runs to completion and a failure only logs. This is the fire-and-forget form for view code, and the replacement for `resume` in the reset-request form.
|
|
1816
|
+
4. **§9 parameter routing is split by method body** (the 11th §9 inconsistency, found building the outbound weather demo). §9 said any parameter in neither the path nor `query` is a compile error, but its own examples — `post check(text str)` with `path "/check"`, `post create_post(body NewPost)` with `path "/posts"` — put `text` / `body` in neither. Resolved: the compile error applies to `get` / `delete` / `head` only; for `post` / `put` / `patch` the unrouted parameters are the request body (`body` verbatim, others as a JSON object).
|
|
1817
|
+
5. **§9 `timeout` has a default** (finding 12). It was optional with no stated value, so an omitted `timeout` meant "no timeout" — a call that hangs forever, silently. Now: `30s` when omitted, `timeout none` to opt out explicitly.
|
|
1818
|
+
6. **No module-level value binding** (surfaced by the feature-12 dogfood, which wrote `let tabs = […]` at file scope). §7 listed only declarations at module scope but never said `let` there was disallowed, and `vlcheck` rejected it with a bare `E-PARSE`. Settled as a **non-goal**: a module is declarations only; a file-scope constant is a zero-parameter `fn`, a `let` inside the one consumer, `volaro.toml` / `env` for config, or a `type` for named cases. Reversible — a restricted `let` (const-expression RHS) can be added later if a real need appears. §7 now states this.
|
|
1819
|
+
7. **Accessible compound primitives `dialog` and `tabs` / `tab`** (§8.8), from the feature 11/12 dogfood — a modal or tab set could be authored as valid Volaro but its keyboard, focus, portal and ARIA half was inexpressible, so the only expressible version was an inaccessible one. Resolved with **option A** of the v0.3 accessibility decision: two `std.ui` primitives that carry accessibility by construction. Option B (raw `role` / `aria-*` / key events) was rejected — it restores expressiveness by also making the broken version expressible again. The portal + `inert` + focus-save/restore runtime (`VL.overlay`) was built first and is decision-independent. Scope is deliberately two primitives behind two real features, not a component library; further widgets earn their place one real feature at a time.
|
|
1820
|
+
8. **Comparison predicates in `where`** (§13.1), from the feature 10 dogfood — the worst defect class this project has found. `where` was equality-only with no stated limitation, so `where(created_at: cursor)`, written to mean "before this timestamp", passed `vlcheck` clean and `vlbuild` built it — compiling silently to `created_at = ?`. A build that reports success and returns the wrong rows, unlike every prior failure mode (no output, or a crash). Resolved by adding seven named predicate constructors (`lt`, `lte`, `gt`, `gte`, `ne`, `between`, `any_of`) as `where` values, each lowering to exactly one parameterized SQL fragment; the emitter now hard-errors on a `where` value it does not recognize instead of binding it as an opaque parameter, closing the same silent-success class for any future or mistyped predicate name. `where` still composes only by AND across named columns; a query needing `or` or nesting is a `db.raw` case. `db.raw` itself remains unlowered (a real gap, tracked separately) — this amendment fixes the common case, not raw SQL.
|
|
1821
|
+
9. **List growth (§20.9) and two dead/unimplemented constructs found closing it**, from the feature 10 dogfood, fourth pass. `std.list` gains `concat(a, b)` / `append(xs, x)` — see §20.9 for why these and not a `+` overload or spread syntax. Closing the feature that motivated it also surfaced that `on mount` (§8.5) was AST-complete and `vlcheck`-clean but silently unemitted (a warning, not a build failure — dead code behind a passing check, the same shape as finding 8 above but one layer further from the network) and that the §6.2 expression-position `try`/`catch` (`let user = try load(id) catch not_found create_guest()`, shown in this very document) had never been implemented by the parser at all (`expected an expression, found 'try'`). Both fixed. A live-verified example clicking through a real running server now exists (`views/10-infinite-scroll/`); the `load` accumulate-mode alternative was considered and rejected — see §20.9's companion decision.
|
|
1822
|
+
10. **Accessible inputs `label` / `aria_label` / `help` / `error`** (§8.9), first slice of the accessibility-by-default milestone. `input` was a plain `std.ui` element: the only way to name one was an adjacent `text` (not associated) or a `placeholder:` (not a label). `vlcheck` and `vlbuild` both accepted a nameless field, so the expressible form of a form was the inaccessible one — the same gap §8.8 closed for `dialog` / `tabs`, resolved the same way (option A: the primitive carries accessibility; no raw `aria-*` surface). `input` now requires `label:` or `aria_label:`; `E-INPUT-NAME` is an error naming the fix, and `placeholder:`-only does not satisfy it. `help:` and `error:` are compiler-wired to the control's `aria-describedby`, and a non-empty `error:` sets `aria-invalid="true"`. Bounded deliberately: live error announcement (`aria-live`), image alt text, page titles and heading / landmark structure are the rest of the milestone, not this amendment. `corpus/views/08-validated-form` was rewritten onto the syntax — its adjacent-`text` labels were the motivating case — so measured source moved and density was re-run; see `corpus/G1-RESULTS.md`. **Hardened 2026-09-06** (two review follow-ups, one patch): the name form is now *exactly one* of `label:` / `aria_label:` (`E-INPUT-NAME-DUP` when both are given), a statically empty / whitespace name is `E-INPUT-NAME`, and an at-runtime-empty name raises `render.empty_input_name` rather than rendering nameless. The generated `for` / `id` / `aria-describedby` ids are allocated **per rendered field instance** (lowering moved to a `VL.field` runtime helper), so a repeated component or a keyed-list row gets its own links and every `<label>` activates its own control — verified in real Chromium including inside a `dialog` and across a list reorder.
|
|
1823
|
+
11. **Accessible images `alt` / `alt_todo`** (§8.10), second slice of the accessibility-by-default milestone. `img` was a plain `std.ui` element with `alt` a straight pass-through attribute — `img src:…` with no `alt` built and rendered, so the inaccessible image was the expressible default, the same gap §8.9 had for inputs. Resolved the same way (option A): `img` now declares its alternative through exactly one form of `alt:` — real text (`E-IMG-ALT` if absent or statically empty, `E-IMG-ALT-ONE` if two forms are given), `alt:decorative` for a presentational image (`alt=""`), or `alt:todo` / `alt_todo:"…"` for an unfinished description. The placeholder forms build with a navigable `alt` and **warn** (`W-IMG-ALT-TODO`); a new **`--release`** flag on `vlcheck` and `vlbuild` promotes them to `E-IMG-ALT-TODO` — the ship gate for unresolved markers. A meaningful `alt:` is lowered through `VL.reqAlt`, so a dynamic value (`alt:"{caption}"`) that resolves to empty raises `render.empty_alt` instead of silently degrading to `alt=""`. No measured corpus source uses `img`, so density is unchanged; only the spec / crib grew. Live error announcement (`aria-live`), page titles and heading / landmark structure remain the rest of the milestone.
|
|
1824
|
+
12. **Page title, headings, and landmarks `page` / `h1`–`h6` / `main` / `nav`** (§8.11), third slice of the accessibility-by-default milestone. A build's root view is the page; there is no client-side router. New module-level `page title:<expr>` — written into `<title>` when literal, assigned to `document.title` at boot, so each full navigation carries its own title. A build that renders a page without one is refused (`E-PAGE-TITLE`); an empty title is `E-PAGE-TITLE`; a title that goes empty at load raises `render.empty_page_title`; two `page` blocks are `E-PAGE-DUP`. `h1`–`h6` emit real heading elements with the level independent of `size:`; an intra-view level skip is `W-HEADING-SKIP` and a second `h1` is `W-HEADING-MULTI-H1` — warnings only, per-view, so a reusable component starting at `h2`/`h3` is not flagged. `main` / `nav` emit the landmarks; one `main` per page (`E-MAIN-DUP`), and sibling `nav` regions need distinct `aria_label:` (`E-NAV-NAME`). When a `main` renders, the compiler tags it focusable and injects a keyboard-operable, focus-visible “Skip to main content” link — the author writes nothing. The 12 corpus features each gained a one-line `page title:`, so measured source moved and density was re-run 3.05× → 3.00× (`corpus/G1-RESULTS.md`). These structural checks do not prove accessibility or replace screen-reader testing. Live `aria-live` announcements are the remaining slice.
|
|
1825
|
+
|
|
1826
|
+
13. **Live status and error regions** (§8.12). Added text-only `status message` with E-STATUS-SHAPE, compiler-owned polite/atomic semantics, and runtime string-or-nil validation. Existing input error regions remain exposed while empty and update retained text nodes without focus movement or repeated unchanged-message mutations. Browser Part 10 proves DOM behavior, not audible screen-reader output; no measured corpus source changed.
|
|
1827
|
+
|
|
1828
|
+
14. **Composed-page landmark review** (§8.11), the accessibility-milestone review before styled-app work. The §8.11 landmark checks were per-view; the runtime composes one document from a page-root view that renders and repeats other views. Added a composition pass (runs when a module declares a `page`, follows component instantiation within the module from the page root, treats `if` / `else` branches as mutually exclusive, does not evaluate conditions): a second `main` across composed views or a reused `main` is `E-MAIN-COMPOSED`; two `<nav>`s that share one name or are both unnamed across composed views are `E-NAV-NAME`; one static `id:` rendered by more than one element is `E-ID-DUP`. Runtime: the auto-injected skip link is removed when its `main` target leaves the document and re-resolves the target at activation time. Cross-file `use` composition is not yet followed. Automated structural checks still do not prove accessibility or replace screen-reader testing; that pass is recorded separately. No measured corpus source changed.
|
|
1829
|
+
|
|
1830
|
+
15. **Tailwind-backed styling Slice 1** (§8.7). Replaced the abandoned flat
|
|
1831
|
+
theme / `space unit:` / `raw_style:` sketch with nested `theme` categories,
|
|
1832
|
+
static `recipe button|input|card` declarations, the `card` grouping primitive,
|
|
1833
|
+
literal additive `class:`, and named space-token layout attrs. The compiler
|
|
1834
|
+
generates an explicit candidate manifest, invokes pinned upstream Tailwind at
|
|
1835
|
+
build time, parses the output and refuses unknown utilities. Runtime CSS is in
|
|
1836
|
+
a lower cascade layer; generated utilities are later; recipe-vs-class and
|
|
1837
|
+
recipe-vs-inline property conflicts are errors, so class token order is never
|
|
1838
|
+
claimed as precedence. Both dark selectors and reduced-motion output are
|
|
1839
|
+
generated. This implementation and its browser checks do not complete or
|
|
1840
|
+
replace the pending full-screen-reader acceptance protocol.
|