volaro 0.1.0-alpha.4 → 0.1.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,7 @@
1
1
  # Volaro
2
2
 
3
- **Limited alpha, published under the `alpha` dist-tag** (this is `0.1.0-alpha.4`) —
4
- `npm install volaro@alpha`. Not `latest` (that still resolves to an earlier
5
- `0.0.x` placeholder) — always specify `@alpha` or the exact version.
3
+ **Limited alpha** (this is `0.1.0-alpha.5`), published under both the
4
+ `latest` and `alpha` dist-tags: `npm install volaro`.
6
5
 
7
6
  Volaro is an experimental application language intended for AI authoring and
8
7
  human review. This package ships the language reference **and a working
@@ -113,11 +112,11 @@ dynamic route), so `volaro dev` on a project directory runs that server.
113
112
  ## Scaffold a project
114
113
 
115
114
  ```bash
116
- npm create volaro@alpha my-app # the separate create-volaro package
115
+ npm create volaro my-app # the separate create-volaro package
117
116
  ```
118
117
 
119
- (`@alpha` matters — `npm create volaro` with no tag resolves `latest`, which
120
- is still an earlier `0.0.x` placeholder, not this build.)
118
+ (If `npm create volaro` prints "This release is a placeholder", npm is
119
+ reusing a cached copy of the old `0.0.1`: run `npm cache clean --force`.)
121
120
 
122
121
  It writes one single-page starter, a `volaro.json`, and `package.json` scripts
123
122
  (`dev` / `build` / `check`) that call `volaro`.
@@ -1,6 +1,6 @@
1
1
  {
2
- "revision": "0d5741da1385dd81e277b16715a93592744d8c87",
2
+ "revision": "8c0952905f3449e5dc7c4fe75c2ba4b718b34148",
3
3
  "source": "git",
4
4
  "dirty": false,
5
- "content_sha256": "611292080bf797022be28aea42376ca55bbeba3e680c2ec638ae4a1417981ff0"
5
+ "content_sha256": "24266586ae4ce6a2122f6893b7b44cf1cf721c2e97785afe95751a8109a2cb7a"
6
6
  }
@@ -1 +1 @@
1
- 0d5741da1385dd81e277b16715a93592744d8c87
1
+ 8c0952905f3449e5dc7c4fe75c2ba4b718b34148
@@ -488,13 +488,14 @@ class Checker:
488
488
  if not valid:
489
489
  self._d("E-THEME-SHAPE", Severity.ERROR,
490
490
  "malformed %s token row" % category.head, category)
491
- allowed = {"button", "input", "card"}
491
+ allowed = {"button", "input", "card", "select", "textarea", "checkbox",
492
+ "radio", "link", "fieldset", "table", "details"}
492
493
  seen_recipes: set[str] = set()
493
494
  for recipe in (x for x in mod.items if isinstance(x, A.RecipeDecl)):
494
495
  if recipe.primitive not in allowed:
495
496
  self._d("E-RECIPE-UNKNOWN-PRIMITIVE", Severity.ERROR,
496
- "Slice 1 recipes support button, input, and card; got %r"
497
- % recipe.primitive, recipe)
497
+ "recipes support %s; got %r"
498
+ % (", ".join(sorted(allowed)), recipe.primitive), recipe)
498
499
  continue
499
500
  if recipe.primitive in seen_recipes:
500
501
  self._d("E-RECIPE-SHAPE", Severity.ERROR,
@@ -541,7 +542,8 @@ class Checker:
541
542
  if value not in axes.get(axis, set()):
542
543
  self._d("E-RECIPE-SHAPE", Severity.ERROR,
543
544
  "default %s:%s is not declared" % (axis, value), recipe)
544
- if recipe.primitive in ("button", "input"):
545
+ if recipe.primitive in ("button", "input", "select", "textarea",
546
+ "checkbox", "radio", "link"):
545
547
  all_tokens = " ".join(" ".join(r.tokens) for r in recipe.body)
546
548
  if "focus-visible:" not in all_tokens:
547
549
  self._d("E-RECIPE-FOCUS", Severity.ERROR,
@@ -15,8 +15,15 @@
15
15
  /* Keep white action text at 5.99:1 in both schemes. The lighter dark-mode
16
16
  * accent is for links/borders, not a white-text button background. */
17
17
  --action-bg: #2f6f4f;
18
+ /* White text on a danger button needs the darker red in BOTH schemes;
19
+ * the lighter dark-mode --danger is for text on the dark surface. */
20
+ --danger-bg: #b91c1c;
18
21
  --danger: #b91c1c;
19
22
  --warn: #b45309;
23
+ /* Native controls (checkbox, select, scrollbars) follow the scheme; without
24
+ * this they stayed light-mode white boxes on the dark page. */
25
+ color-scheme: light dark;
26
+ accent-color: var(--accent);
20
27
  }
21
28
  @media (prefers-color-scheme: dark) {
22
29
  :root {
@@ -56,10 +63,13 @@ code {
56
63
  .vl-spacer { flex: 1; }
57
64
  .vl-fragment { display: contents; }
58
65
 
59
- a.vl-link { color: var(--accent); text-decoration: none; }
60
- a.vl-link:hover { text-decoration: underline; }
66
+ /* `a` (45.4) is the same link as the `link` convenience, so it looks the
67
+ * same; unstyled it was browser blue, unreadable on the dark surface. */
68
+ a.vl-link, a.vl-a { color: var(--accent); text-decoration: none; }
69
+ a.vl-link:hover, a.vl-a:hover { text-decoration: underline; }
61
70
 
62
- input.vl-input {
71
+ /* The text-entry controls share one look. */
72
+ input.vl-input, select.vl-select, textarea.vl-textarea {
63
73
  font: inherit;
64
74
  padding: 4px 8px;
65
75
  border: 1px solid var(--border);
@@ -67,13 +77,51 @@ input.vl-input {
67
77
  background: var(--surface);
68
78
  color: var(--ink);
69
79
  }
70
- input.vl-input[aria-invalid="true"] { border-color: var(--danger); }
80
+ select.vl-select { min-height: 32px; }
81
+ textarea.vl-textarea { line-height: 1.5; resize: vertical; }
82
+ input.vl-input[aria-invalid="true"], select.vl-select[aria-invalid="true"],
83
+ textarea.vl-textarea[aria-invalid="true"] { border-color: var(--danger); }
84
+
85
+ /* Checkbox, radio, range and colour are not text boxes: drop the box
86
+ * styling and let the native control (tinted by accent-color) show. */
87
+ input.vl-input:is([type="checkbox"], [type="radio"], [type="range"], [type="color"]) {
88
+ padding: 0;
89
+ border: 0;
90
+ background: none;
91
+ }
92
+ input.vl-input:is([type="checkbox"], [type="radio"]) { width: 16px; height: 16px; margin: 0; }
93
+
94
+ /* Disabled looks disabled, on every control. */
95
+ :is(input.vl-input, select.vl-select, textarea.vl-textarea, button.vl-button):disabled,
96
+ fieldset.vl-fieldset:disabled {
97
+ opacity: 0.55;
98
+ cursor: not-allowed;
99
+ }
100
+ /* ...and so does its label, so the whole field reads as unavailable. */
101
+ .vl-field:has(> :disabled) > .vl-label { opacity: 0.55; }
71
102
 
72
103
  /* accessible input field (spec 8.9): label / help / error wrapper */
73
104
  .vl-field { display: flex; flex-direction: column; gap: 4px; }
74
105
  .vl-field > .vl-label { font-size: 13px; font-weight: 600; }
75
106
  .vl-field > .vl-help { font-size: 12px; color: var(--muted); }
76
- .vl-field > .vl-error { font-size: 12px; color: var(--danger); }
107
+ /* The field error shares the class name of the runtime crash box (.vl-error,
108
+ * monospace, below); inside a field it is ordinary text. */
109
+ .vl-field > .vl-error { font-size: 12px; font-family: inherit; white-space: normal; color: var(--danger); }
110
+ /* A checkbox or radio sits BESIDE its label, box first. The label stays
111
+ * first in the DOM (reading and tab order unchanged); only the grid places
112
+ * it. Help and error text line up under the label. */
113
+ .vl-field:has(> input.vl-input:is([type="checkbox"], [type="radio"])) {
114
+ display: grid;
115
+ grid-template-columns: auto 1fr;
116
+ column-gap: 8px;
117
+ row-gap: 2px;
118
+ align-items: center;
119
+ }
120
+ .vl-field:has(> input.vl-input:is([type="checkbox"], [type="radio"])) > input { grid-column: 1; grid-row: 1; }
121
+ .vl-field:has(> input.vl-input:is([type="checkbox"], [type="radio"])) > .vl-label {
122
+ grid-column: 2; grid-row: 1; font-size: inherit; font-weight: 400;
123
+ }
124
+ .vl-field:has(> input.vl-input:is([type="checkbox"], [type="radio"])) > :is(.vl-help, .vl-error) { grid-column: 2; }
77
125
  button.vl-button {
78
126
  font: inherit;
79
127
  padding: 6px 12px;
@@ -88,12 +136,64 @@ button.vl-button[data-variant="primary"] {
88
136
  border-color: transparent;
89
137
  color: #fff;
90
138
  }
91
- button.vl-button:hover { border-color: var(--accent); }
92
- input.vl-input:focus-visible, button.vl-button:focus-visible, a.vl-link:focus-visible {
139
+ button.vl-button[data-variant="ghost"] {
140
+ background: transparent;
141
+ border-color: transparent;
142
+ color: var(--accent);
143
+ }
144
+ button.vl-button[data-variant="danger"] {
145
+ background: var(--danger-bg);
146
+ border-color: transparent;
147
+ color: #fff;
148
+ }
149
+ button.vl-button:not(:disabled):hover { border-color: var(--accent); }
150
+ :is(input.vl-input, select.vl-select, textarea.vl-textarea, button.vl-button,
151
+ a.vl-link, a.vl-a, summary.vl-summary):focus-visible {
93
152
  outline: 3px solid var(--ink);
94
153
  outline-offset: 3px;
95
154
  }
96
155
 
156
+ /* Inside a layout primitive, `gap:` is the spacing; the browser's own
157
+ * margins on these would double it. Ordinary flow keeps them. */
158
+ :is(.vl-col, .vl-row, .vl-stack, .vl-grid, .vl-card) > :is(
159
+ .vl-p, .vl-h1, .vl-h2, .vl-h3, .vl-h4, .vl-h5, .vl-h6, .vl-ul, .vl-ol,
160
+ .vl-menu, .vl-dl, .vl-table, .vl-figure, .vl-blockquote, .vl-pre, .vl-hr,
161
+ .vl-fieldset, .vl-form, .vl-details) { margin: 0; }
162
+
163
+ /* fieldset groups its controls like a column; legend reads like a label. */
164
+ fieldset.vl-fieldset {
165
+ display: flex;
166
+ flex-direction: column;
167
+ gap: 10px;
168
+ min-width: 0;
169
+ padding: 12px 16px 16px;
170
+ border: 1px solid var(--border);
171
+ border-radius: 8px;
172
+ }
173
+ legend.vl-legend { padding: 0 4px; font-size: 13px; font-weight: 600; }
174
+
175
+ /* Tables: left-aligned, ruled rows, numbers that line up. */
176
+ table.vl-table { border-collapse: collapse; font-variant-numeric: tabular-nums; }
177
+ caption.vl-caption { text-align: start; font-weight: 600; padding-bottom: 6px; }
178
+ :is(th.vl-th, td.vl-td) {
179
+ padding: 6px 12px;
180
+ text-align: start;
181
+ border-bottom: 1px solid var(--border);
182
+ }
183
+ thead.vl-thead th.vl-th { border-bottom-width: 2px; }
184
+
185
+ /* Disclosure panel. */
186
+ details.vl-details {
187
+ border: 1px solid var(--border);
188
+ border-radius: 8px;
189
+ padding: 8px 12px;
190
+ background: var(--surface);
191
+ }
192
+ summary.vl-summary { cursor: pointer; font-weight: 600; }
193
+ details.vl-details[open] > summary.vl-summary { margin-bottom: 8px; }
194
+
195
+ progress.vl-progress, meter.vl-meter { width: 100%; max-width: 320px; }
196
+
97
197
  /* overlay layer: a positioned mount point above the app. */
98
198
  .vl-overlay { position: fixed; inset: 0; z-index: 1000; }
99
199
 
@@ -1332,8 +1332,7 @@ class Emitter:
1332
1332
  for k, v in el.attrs:
1333
1333
  if k in skip_attrs:
1334
1334
  continue
1335
- if k == "class" or (self.style_project and el.name in self.style_project.recipes
1336
- and k in self.style_project.recipes[el.name].axes):
1335
+ if k == "class" or k in self._recipe_axes(el):
1337
1336
  continue
1338
1337
  self._apply_attr(el.name, k, v, style, attrs, handlers)
1339
1338
  self._link_safety(el.name, attrs)
@@ -1589,8 +1588,7 @@ class Emitter:
1589
1588
  attrs: dict[str, str] = {}
1590
1589
  handlers: dict[str, str] = {}
1591
1590
  for k, v in rest:
1592
- if k == "class" or (self.style_project and el.name in self.style_project.recipes
1593
- and k in self.style_project.recipes[el.name].axes):
1591
+ if k == "class" or k in self._recipe_axes(el):
1594
1592
  continue
1595
1593
  if k == "bind" and el.name == "input" and input_type in INPUT_TYPES_CHECKED:
1596
1594
  # action-45.2: checkbox/radio bind a *checked* boolean, not a
@@ -1696,6 +1694,16 @@ class Emitter:
1696
1694
  spec.append("error: %s" % self.expr(a11y["error"]))
1697
1695
  return "VL.field({%s})" % ", ".join(spec)
1698
1696
 
1697
+ def _recipe_axes(self, el: A.Element) -> dict:
1698
+ """The style axes of the recipe `el` uses (checkbox/radio resolve to
1699
+ their own recipe, not `input`'s); those attributes are styling, not
1700
+ DOM attributes. Imported lazily: style_config imports this module."""
1701
+ if self.style_project is None:
1702
+ return {}
1703
+ from .style_config import recipe_for
1704
+ recipe = recipe_for(self.style_project, el)
1705
+ return recipe.axes if recipe else {}
1706
+
1699
1707
  def _style_classes(self, el: A.Element) -> list[str]:
1700
1708
  if self.style_project is None:
1701
1709
  # class: is a styling feature: if present, a default style project
@@ -172,6 +172,15 @@ def build_pages(root: Path, out_public: Path, styled: bool = False) -> dict:
172
172
  check_no_module_id_collisions({path: module_id_for(str(root), path)
173
173
  for path in files})
174
174
 
175
+ # Styling (theme.vl / recipes.vl): a project keeps them at its root,
176
+ # beside app/, exactly where a single-file build finds them beside
177
+ # app.vl. Every page, layout and boundary compiles against the same
178
+ # style project and one app.css covers them all. Multi-page builds used
179
+ # to ignore styling entirely.
180
+ from .style_config import load_style_project
181
+ style_project = load_style_project(root / "app.vl", A.Module(items=[]))
182
+ style_candidates: list[dict[str, str]] = []
183
+
175
184
  compiled: dict[str, str] = {} # module id -> emitted JS
176
185
  for path, info in files.items():
177
186
  mod, parse_diags, src = _parse(path)
@@ -187,8 +196,16 @@ def build_pages(root: Path, out_public: Path, styled: bool = False) -> dict:
187
196
  if check_errors:
188
197
  problems.extend(f"{d.code}: {d.message} ({path})" for d in check_errors)
189
198
  continue
199
+ if any(isinstance(x, A.RecipeDecl)
200
+ or (isinstance(x, A.Directive) and x.head == "theme")
201
+ for x in mod.items):
202
+ problems.append(
203
+ "E-STYLE-LOCATION: a project's theme and recipes live in theme.vl / "
204
+ "recipes.vl at the project root, beside app/, not in a page, layout "
205
+ "or boundary (%s)" % path)
206
+ continue
190
207
  mid = module_id_for(str(root), path)
191
- emitter = Emitter(src, os.path.basename(path))
208
+ emitter = Emitter(src, os.path.basename(path), style_project=style_project)
192
209
  try:
193
210
  if info["role"] == "layout":
194
211
  js = emitter.build_module(mod, mid, path,
@@ -203,10 +220,24 @@ def build_pages(root: Path, out_public: Path, styled: bool = False) -> dict:
203
220
  problems.append(f"{exc} ({path})")
204
221
  continue
205
222
  compiled[mid] = js
223
+ style_candidates.extend(emitter.style_candidates)
206
224
 
207
225
  if problems:
208
226
  raise BuildError("browser-page compilation failed:\n " + "\n ".join(sorted(set(problems))))
209
227
 
228
+ # Compiled before anything is written, so a CSS failure leaves no output.
229
+ app_css = None
230
+ if style_project.active or style_candidates:
231
+ from .styling import compile_css
232
+ app_css = compile_css(style_candidates, style_project.theme_css())
233
+
234
+ out_public.mkdir(parents=True, exist_ok=True)
235
+ stale_css = out_public / "app.css"
236
+ if app_css is not None:
237
+ stale_css.write_text(app_css, encoding="utf-8")
238
+ elif stale_css.exists():
239
+ stale_css.unlink() # a rebuild after the styling was removed
240
+
210
241
  pages_dir = out_public / "pages"
211
242
  pages_dir.mkdir(parents=True, exist_ok=True)
212
243
  for mid, js in compiled.items():
@@ -258,7 +289,7 @@ def static_routes(client_manifest: dict) -> list[dict]:
258
289
  _ASSETS = Path(__file__).resolve().parent / "assets"
259
290
 
260
291
 
261
- def shell_html(title: str = "Volaro") -> str:
292
+ def shell_html(title: str = "Volaro", styled: bool = False) -> str:
262
293
  """The ONE application shell served for every route (static-generated or
263
294
  server-fallback) -- byte-identical regardless of route, deliberately:
264
295
  architecture decision §4, no SSR/hydration. The client router does the
@@ -271,6 +302,7 @@ def shell_html(title: str = "Volaro") -> str:
271
302
  <meta name="viewport" content="width=device-width, initial-scale=1" />
272
303
  <title>{escape(title)}</title>
273
304
  <link rel="stylesheet" href="/vlrt.css" />
305
+ {'<link rel="stylesheet" href="/app.css" />' if styled else ''}
274
306
  </head>
275
307
  <body>
276
308
  <div id="vl-root">loading…</div>
@@ -328,7 +360,7 @@ def build_project(root: Path, out_dir: Path) -> dict:
328
360
  (out_public / "vlrouter.js").write_text(
329
361
  (_ASSETS / "vlrouter.js").read_text(encoding="utf-8"), encoding="utf-8")
330
362
  (out_public / "bootstrap.js").write_text(bootstrap_js(), encoding="utf-8")
331
- shell = shell_html()
363
+ shell = shell_html(styled=(out_public / "app.css").is_file())
332
364
  (out_public / "index.html").write_text(shell, encoding="utf-8")
333
365
 
334
366
  for page in static_routes(client_manifest):
@@ -32,7 +32,26 @@ DEFAULT_THEME = {
32
32
  }
33
33
  RESERVED_COLORS = {"white", "black", "transparent", "current"}
34
34
  RESERVED_ROOTS = {"container", "columns", "break", "box", "object", "overflow"}
35
- STYLEABLE = {"button", "input", "card"}
35
+ STYLEABLE = {"button", "input", "card", "select", "textarea", "checkbox", "radio",
36
+ "link", "fieldset", "table", "details"}
37
+ # Interactive targets must declare visible focus (E-RECIPE-FOCUS).
38
+ FOCUS_REQUIRED = {"button", "input", "select", "textarea", "checkbox", "radio", "link"}
39
+
40
+
41
+ def recipe_key(el: A.Element) -> str:
42
+ """The recipe target an element uses. `checkbox` and `radio` are
43
+ `input type:checkbox|radio` in source but are their own targets: a
44
+ `recipe input` is a text box ("w-full px-sm ..."), which would stretch
45
+ and pad a checkbox. Every other element uses its own name."""
46
+ if el.name == "input":
47
+ for k, v in el.attrs:
48
+ if k == "type" and isinstance(v, A.Name) and v.id in ("checkbox", "radio"):
49
+ return v.id
50
+ return el.name
51
+
52
+
53
+ def recipe_for(project: "StyleProject", el: A.Element):
54
+ return project.recipes.get(recipe_key(el)) if project else None
36
55
 
37
56
 
38
57
  def _string(raw: str) -> str:
@@ -174,7 +193,8 @@ def _parse_theme(node: A.Directive) -> dict:
174
193
 
175
194
  def _parse_recipe(node: A.RecipeDecl) -> Recipe:
176
195
  if node.primitive not in STYLEABLE:
177
- raise BuildError("E-RECIPE-UNKNOWN-PRIMITIVE: %r; valid: button, input, card" % node.primitive)
196
+ raise BuildError("E-RECIPE-UNKNOWN-PRIMITIVE: %r; valid: %s"
197
+ % (node.primitive, ", ".join(sorted(STYLEABLE))))
178
198
  recipe = Recipe(node.primitive)
179
199
  for row in node.body:
180
200
  toks = row.tokens
@@ -207,7 +227,7 @@ def _parse_recipe(node: A.RecipeDecl) -> Recipe:
207
227
  if axis not in recipe.axes or value not in recipe.axes[axis]:
208
228
  raise BuildError("E-RECIPE-SHAPE: default %s:%s is not declared" % (axis, value))
209
229
  all_classes = recipe.base + [c for vals in recipe.axes.values() for cs in vals.values() for c in cs]
210
- if node.primitive in ("button", "input") and not any("focus-visible:" in c for c in all_classes):
230
+ if node.primitive in FOCUS_REQUIRED and not any("focus-visible:" in c for c in all_classes):
211
231
  raise BuildError("E-RECIPE-FOCUS: interactive recipe %r needs focus-visible styling" % node.primitive)
212
232
  if (any(_utility(c).startswith(("transition", "animate-")) for c in all_classes)
213
233
  and not any("motion-reduce:" in c for c in all_classes)):
@@ -291,7 +311,8 @@ INLINE_GROUPS = {"pad": "padding", "pad_x": "padding", "pad_y": "padding",
291
311
 
292
312
 
293
313
  def resolve_classes(project: StyleProject, el: A.Element) -> tuple[list[str], list[tuple[str, str]]]:
294
- recipe = project.recipes.get(el.name)
314
+ recipe = recipe_for(project, el)
315
+ key = recipe_key(el)
295
316
  selected: dict[str, str] = {}
296
317
  custom = ""
297
318
  for key, value in el.attrs:
@@ -313,20 +334,20 @@ def resolve_classes(project: StyleProject, el: A.Element) -> tuple[list[str], li
313
334
  continue
314
335
  if choice not in values:
315
336
  raise BuildError("E-RECIPE-VARIANT: recipe %r has no %s %r; valid: %s" %
316
- (el.name, axis, choice, ", ".join(sorted(values))))
337
+ (key, axis, choice, ", ".join(sorted(values))))
317
338
  classes.extend(values[choice])
318
339
  occupied = {g for c in classes if (g := conflict_group(c))}
319
340
  for key, _value in el.attrs:
320
341
  if (key in INLINE_GROUPS and key not in recipe.axes
321
342
  and any(group == INLINE_GROUPS[key] and state == ""
322
343
  for state, group in occupied)):
323
- raise BuildError("E-STYLE-RECIPE-MIX: %s recipe and inline %s: set the same property" % (el.name, key))
344
+ raise BuildError("E-STYLE-RECIPE-MIX: %s recipe and inline %s: set the same property" % (recipe_key(el), key))
324
345
  for c in custom.split():
325
346
  if _utility(c).startswith("!") or _utility(c).endswith("!"):
326
347
  raise BuildError("E-CLASS-IMPORTANT: class: cannot force precedence with !important")
327
348
  group = conflict_group(c)
328
349
  if group and group in occupied:
329
- raise BuildError("E-CLASS-RECIPE-CONFLICT: %r conflicts with recipe %r" % (c, el.name))
350
+ raise BuildError("E-CLASS-RECIPE-CONFLICT: %r conflicts with recipe %r" % (c, key))
330
351
  custom_classes = custom.split()
331
352
  all_classes = classes + custom_classes
332
353
  if any("[" in c and "]" in c for c in custom_classes):
@@ -336,12 +357,12 @@ def resolve_classes(project: StyleProject, el: A.Element) -> tuple[list[str], li
336
357
  raise BuildError("E-CLASS-LIVE-HIDDEN: a live region must remain exposed")
337
358
  if el.name == "status" and "sr-only" in utilities:
338
359
  project.warnings.append("W-CLASS-LIVE-SR-ONLY: status feedback should remain visually present")
339
- if el.name in ("button", "input", "link") and any(c in ("outline-none", "outline-0", "ring-0") for c in utilities):
360
+ if el.name in ("button", "input", "link", "select", "textarea") and any(c in ("outline-none", "outline-0", "ring-0") for c in utilities):
340
361
  if not any("focus-visible:" in c and _utility(c) not in ("outline-none", "outline-0", "ring-0") for c in all_classes):
341
362
  raise BuildError("E-CLASS-FOCUS-REMOVED: removed focus needs a focus-visible replacement")
342
363
  seen: set[str] = set(); result = []
343
364
  for c in all_classes:
344
365
  if c not in seen:
345
366
  seen.add(c); result.append(c)
346
- origins.append((c, "class on %s" % el.name if c in custom_classes else "recipe %s" % el.name))
367
+ origins.append((c, "class on %s" % el.name if c in custom_classes else "recipe %s" % key))
347
368
  return result, origins
@@ -18,8 +18,11 @@ def compile_css(candidates: list[dict[str, str]], theme_css: str = "") -> str:
18
18
  """
19
19
  if not (_TOOLS / "node_modules" / "tailwindcss" / "package.json").is_file():
20
20
  raise BuildError(
21
- "E-CSS-TOOLCHAIN: missing local styling build dependencies; "
22
- "run npm ci --prefix prototype/vlbuild/styling")
21
+ "E-CSS-TOOLCHAIN: theme / recipe / class: styling needs the Tailwind "
22
+ "toolchain, which the volaro npm package does not include yet. "
23
+ "Remove theme.vl, recipes.vl and class: to build with the default "
24
+ "styles, which cover every control. (In the Volaro repository, run "
25
+ "npm ci --prefix prototype/vlbuild/styling.)")
23
26
  try:
24
27
  result = subprocess.run(
25
28
  ["node", str(_TOOLS / "build-css.mjs")],
package/language/crib.md CHANGED
@@ -320,7 +320,15 @@ select label:"Size" bind:size
320
320
  `select`/`optgroup`/`datalist` only. `datalist id:"…"` holds `option`
321
321
  children; an `input list:"…"` names that same id (a plain string match, not a
322
322
  compiler-verified link). `fieldset` needs exactly one `legend` **first**
323
- child — that legend is the group's accessible name.
323
+ child — that legend is the group's accessible name. `legend` is a container,
324
+ so its text is a child:
325
+
326
+ ```vl
327
+ fieldset
328
+ legend
329
+ text "Size"
330
+ input type:radio value:"s" label:"Small" bind:size
331
+ ```
324
332
 
325
333
  Attributes and events are **closed**, like element names. An attribute with no
326
334
  Volaro contract is `E-ATTR-UNKNOWN`; a real one on the wrong element is
@@ -342,8 +350,8 @@ need a real checked state).
342
350
 
343
351
  `variant:` takes a **bare style name only** — a
344
352
  computed value is `E-VARIANT-LITERAL` (vary the positional label instead).
345
- `variant:primary` is styled by the minimal build; `ghost` / `danger` and other
346
- names are valid and emit `data-variant=` but render as a plain button until a
353
+ The minimal build styles `variant:primary`, `ghost` and `danger`; other names
354
+ are valid and emit `data-variant=` but render as a plain button until a
347
355
  `theme` / `recipe` styles them. The page mounts client-side (`#vl-root` shows
348
356
  `loading…` until `app.js` runs). `text ... pre:true` (`text` only,
349
357
  `E-PRE-TARGET` elsewhere) preserves whitespace exactly, including embedded
@@ -759,7 +767,14 @@ Client calls: `auth.sign_in(email:, password:)`, `auth.sign_up(...)`,
759
767
 
760
768
  ## Styling
761
769
 
762
- One sibling `theme.vl` groups `color`, `space`, `radius`, `font`, `screen`, and
770
+ **From an npm install, skip this section:** `theme`, `recipe` and `class:` need
771
+ the Tailwind toolchain, which the volaro package does not ship yet
772
+ (`E-CSS-TOOLCHAIN`). The default styles cover every control in light and dark
773
+ mode; use layout attributes (`gap:`, `pad:`, `max_w:` …) for spacing.
774
+
775
+ One `theme.vl` (beside `app.vl`, or at a project's root beside `app/`, with
776
+ `recipes.vl` beside it; a page may not declare either: `E-STYLE-LOCATION`) groups
777
+ `color`, `space`, `radius`, `font`, `screen`, and
763
778
  `motion reduce respect`; colors may add positional `dark "…"`. Numeric
764
779
  `gap:`/`pad:` values are px, while `gap:sm` resolves `theme.space.sm`.
765
780
 
@@ -773,7 +788,9 @@ card variant:raised
773
788
  button "Save" variant:primary class:"w-full sm:w-auto"
774
789
  ```
775
790
 
776
- Slice 1 recipes are `button`, `input`, and `card`. A recipe has one literal
791
+ Recipe targets: `button input card select textarea checkbox radio link fieldset
792
+ table details`. `checkbox`/`radio` style `input type:checkbox|radio` (they never
793
+ take `recipe input`, which is for text boxes). A recipe has one literal
777
794
  `base`, orthogonal `axis value "classes"` rows, and `default axis:value`.
778
795
  `class:` is one complete literal and additive only: no interpolation,
779
796
  `!important`, recipe property conflict, or recipe-plus-inline override.
package/language/spec.md CHANGED
@@ -683,10 +683,15 @@ Numbers in `gap:` / `pad:` remain pixels; a name such as `gap:sm` resolves to
683
683
  `theme.space.sm`. `color:muted` resolves to `var(--vl-color-muted)`. Dark values
684
684
  apply through both `prefers-color-scheme` and `html[data-theme="dark"]`.
685
685
 
686
- `recipe button`, `recipe input`, and `recipe card` declare literal Tailwind
687
- classes as one `base`, orthogonal axes, and defaults. `card` is a grouping
688
- `<div>` with a child slot. Interactive recipes require a `focus-visible:`
689
- treatment and motion utilities require a `motion-reduce:` counterpart:
686
+ A `recipe` declares literal Tailwind classes as one `base`, orthogonal axes,
687
+ and defaults, for one of these targets: `button`, `input`, `card`, `select`,
688
+ `textarea`, `checkbox`, `radio`, `link`, `fieldset`, `table`, `details`
689
+ (anything else is `E-RECIPE-UNKNOWN-PRIMITIVE`). `checkbox` and `radio` style
690
+ `input type:checkbox` / `input type:radio`, which never take `recipe input`:
691
+ that recipe is for text boxes. `card` is a grouping `<div>` with a child slot.
692
+ The interactive targets (`button`, `input`, `select`, `textarea`, `checkbox`,
693
+ `radio`, `link`) require a `focus-visible:` treatment (`E-RECIPE-FOCUS`), and
694
+ motion utilities require a `motion-reduce:` counterpart:
690
695
 
691
696
  ```vl
692
697
  recipe button
@@ -2498,6 +2503,7 @@ multi-owner shared state remains open — not decided by this amendment.
2498
2503
  26. **Media and image maps (§8.9.5)**, action-45.6, the sixth slice of the safe-UI-catalogue programme. Added `audio`, `video`, `source`, `track`, `picture`, `map` and `area` to the closed lowercase element set; `img` kept §8.10's `alt:` contract unchanged and gained the resource-loading and intrinsic-size surface. Every element here is a network request the browser makes on the page's behalf, so the policy is resource-loading policy: a literal `src:`/`srcset:`/`poster:` must be `http`, `https` or a relative path (`E-MEDIA-SRC`), a **narrower** set than §8.9.3's navigation one because `mailto:`/`tel:` are navigation targets rather than resources and a fetch has no user-visible confirmation step — `data:` is refused despite the legitimate tiny-inline-PNG case, since the same URL form carries arbitrary bytes of any type the browser will sniff, is unreviewable in source, and defeats every cache and size budget — with a computed URL running the same policy in the browser through `VL.fetchUrl`, which neutralises an unsafe value to the empty string. `autoplay:true` is now only expressible alongside `muted:true` (`E-AUTOPLAY-UNMUTED`): every current browser blocks unmuted autoplay, so `autoplay` alone read as media that starts on load and shipped media that did not start at all. `audio`/`video` need `controls:true` or `muted:true` (`E-MEDIA-CONTROLS`) — with sound and no controls a keyboard user cannot pause what they hear — and must **declare** their text alternative the way an `img` declares `alt:`, either a `track kind:captions` child or `captions:none` (`E-MEDIA-CAPTIONS`), because uncaptioned speech is simply unavailable to a deaf viewer and nothing downstream can recover it. An `area` with an `href:` needs an `alt:` (`E-AREA-ALT`), its `shape:`/`coords:` must match in count (`E-AREA-SHAPE` / `E-AREA-COORDS`; a browser silently ignores a mismatched area, so that region is not clickable and nothing says so), and `area` shares §8.9.3's link policy rather than getting a second one. `picture` needs exactly one `img` last (`E-PICTURE-SHAPE`), `source` uses `srcset:` in a picture and `src:` in a media element (`E-SOURCE-SHAPE`), `track` needs `kind:`/`src:` plus `srclang:`/`label:` when it carries speech (`E-TRACK-KIND` / `E-TRACK-SHAPE`), `map` needs a `name:` and at least one `area` (`E-MAP-NAME` / `E-MAP-CHILD`), and an orphaned `source`/`track`/`area` is `E-MEDIA-PARENT`. Typed values are closed with `E-MEDIA-VALUE`, `E-MEDIA-PRELOAD`, `E-MEDIA-SIZE` and `E-MEDIA-FLAG`. Verifying this in real Chromium (part 29, against the new `test-only/media-maps-45-6-acceptance` page, whose assets are a real decodable PNG and a genuine 8-bit PCM WAV) caught a third real defect in the programme: `muted` is the same trap as `checked` — the content attribute sets `defaultMuted` and the live property is initialised from it at creation — so autoplaying media the compiler had proved muted in source was shipping **unmuted**, defeating `E-AUTOPLAY-UNMUTED` entirely. `VL.el` now sets `muted` as a live property, and the emitter emits a real `true` rather than `""`, which is falsy on that path. Part 29 also proves the bytes genuinely fetched and decoded, `<picture>` having Chromium resolve `currentSrc` to the media-matched candidate, both `<track>` children becoming real `TextTrack`s whose cues come from Chromium's own WebVTT parser, a real WAV playing so `on play`/`on pause` observe genuine playback, an image-map area being a real focusable link whose `alt:` is its accessible name, and a computed `data:` resource URL neutralised with Chromium's own network log showing it was never requested.
2499
2504
  27. **Global attributes, the layout surface, and refused elements (§8.9.6)**, action-45.7, the closure slice of the safe-UI-catalogue programme. §8.9.1 closed control attributes and events and §8.9.2-§8.9.5 closed each family's surface; this closes what applies to every element. The global attributes each got an outcome: `dir:` became genuinely global (it was scoped to `bdi`/`bdo` by §8.9.3, but it is element-agnostic by definition and a `lang:"ar"` subtree needs a direction on whatever element carries it), `lang:` must be a parseable tag (`E-LANG-TAG`; an unparseable one is ignored, so a screen reader keeps the page language), `tabindex:` is restricted to `0` and `-1` (`E-TABINDEX`; a **positive** tabindex pulls the element ahead of everything with a natural tab order, for the whole document, invisibly to whoever wrote it), `hidden:` is a literal initial state (`E-HIDDEN-VALUE`) and refused on a live region (`E-HIDDEN-LIVE`, since a hidden element leaves the accessibility tree and a hidden live region announces nothing), `spellcheck:` is scoped to where a viewer types, and `title:` outside `abbr` is `W-TITLE-TOOLTIP` — a warning, because a tooltip is invisible on touch, unreachable by keyboard, and read *instead* of the element's own text by most screen readers. Refused globally with `E-ATTR-REFUSED` and a stated reason: `contenteditable` (an editor with no state binding, no paste sanitisation, no undo), `draggable` (a gesture that starts and cannot finish), `style` (the compiler owns the style surface), `role` (it can only contradict its element), any inline handler attribute, `srcdoc`, and `action:`/`method:`/`formaction:` — the **form-submission bypass**, a second unchecked path straight past every guard, auth rule and pending-state check `on submit` provides. There is no `data-*` or `aria-*` passthrough, stated as policy: `aria_label:` is the single aria surface, and the `data-*` passthrough was the fail-open path §8.9.1 removed. The layout/style surface, which §8.9.1 deliberately left element-agnostic and deferred here, is now scoped to three families (`E-LAYOUT-TARGET`): flex/grid container properties only on the elements this compiler gives `display: flex`/`grid`, and box/text styling on anything except the twelve elements that generate no styleable box of their own — with the table `col` following §8.9.4's name-by-position decision, so the flex-column convenience keeps its full style surface. Every element the catalogue refuses now carries its own diagnostic and reason rather than a bare `E-UNKNOWN-ELEMENT`: `E-ELEMENT-COMPILER-OWNED` (10 names), `E-ELEMENT-UNSAFE` (10), `E-ELEMENT-DEFERRED` (13, each naming the contract it waits on) and `E-ELEMENT-OBSOLETE` (17, each naming its replacement). A custom element needs none: the hyphen is not part of a Volaro identifier, so the name cannot be lexed as an element name at all. The closure claim is a **test**, not a sentence — the validator suite carries the whole HTML element set and asserts that no name is both supported and refused, that every refusal has a stated reason, and that no HTML element has neither outcome (88 supported, 46 refused, 0 unclassified). Writing that assertion immediately found four real gaps (`basefont`, `noembed`, `noframes`, and the `style` *element*, dropped from the compiler-owned table while the style *attribute* was handled separately), all now closed. Verified in real Chromium (part 30, against the new `test-only/global-closure-45-7-acceptance` page) for the permitted half — the refusals are proved by the negative controls, since each stops the build and cannot reach a browser: `lang:` reaching the real property, `dir:rtl` genuinely changing the layout against an otherwise pixel-identical `ltr` subtree, a real Tab sequence landing on the `tabindex:0` element at its document position and never on the `tabindex:-1` one (which stays focusable programmatically), `hidden:true` removing the element from Chromium's own accessibility tree rather than merely from view, and every permitted layout/style attribute still resolving to a real computed style.
2500
2505
  28. **Conditional `where` predicates** (§13.1), action-44. A `where` value written as an `if` expression, such as `where(title: if query == "" nil else contains(query))`, now lowers branch by branch: a `nil` branch skips the clause and a predicate branch filters. Before, the whole expression went through generic lowering, so the predicate name was emitted as a call to a function that does not exist; it built clean and threw on every request. Precheck now validates each branch, so a non-predicate call inside one fails the build. Verified against a real seeded node+sqlite server (`test_where_predicate_conditional`). The unconditional sentinel workaround (`contains("")`) still works and is no longer required.
2506
+ 29. **Recipe targets beyond button, input and card** (§ styling), 2026-09-24. `recipe` now also targets `select`, `textarea`, `checkbox`, `radio`, `link`, `fieldset`, `table` and `details`. `checkbox` and `radio` resolve to their own recipe: before, `recipe input` applied to every `input`, so a text-box recipe (`w-full px-sm ...`) stretched and padded checkboxes and radios. The interactive new targets must declare visible focus, like `button` and `input`. In a multi-page project, `theme.vl` and `recipes.vl` live at the project root beside `app/`; a page, layout or boundary that declares a theme or recipe is `E-STYLE-LOCATION`. Verified by a full build with the CSS compile (`test_styling_new_recipe_targets`) and, for projects, real Chromium (pages-routing e2e part 16).
2501
2507
 
2502
2508
  **Dated correction, 2026-09-23 — amendments 19, 20 and 21 carry the wrong action ID.**
2503
2509
  Those three amendments attribute the pages/layouts/router work to "action-20". The
@@ -354,8 +354,14 @@ audio video source track picture map area
354
354
  `aria-modal` / Escape are carried for you.
355
355
  - `variant:` takes a **bare style name only** (`variant:primary`). A computed
356
356
  value, or a `state` / loop variable, is `E-VARIANT-LITERAL`. The minimal
357
- build styles `variant:primary`; other names (`ghost` / `danger` / …) emit
357
+ build styles `variant:primary`, `ghost` and `danger`; other names emit
358
358
  `data-variant=` but render plain until a `theme` / `recipe` styles them.
359
+ - The default stylesheet styles every control and content element in light
360
+ and dark mode: text inputs, `select` and `textarea` share one look;
361
+ `checkbox`/`radio` sit beside their label; disabled controls (and their
362
+ labels) are faded; `fieldset`, tables, `details`, `progress`/`meter` and
363
+ `a` links are themed. It sits in a low cascade layer, so a `theme` /
364
+ `recipe` overrides it.
359
365
  - `text ... pre:true` (`text` only — any other element is `E-PRE-TARGET`)
360
366
  emits inline `white-space: pre-wrap` on that element, so embedded
361
367
  newlines and runs of whitespace render and copy exactly as given instead
@@ -615,15 +621,16 @@ shell is the same regardless of route; the client does the first render).
615
621
  through 45.7 in `NATIVE-ELEMENT-COVERAGE.md`. Document structure, block
616
622
  content and lists landed with 45.3 (above); `details`/`summary` are part
617
623
  of 45.5, not of it.
618
- - **Multi-page project builds do not apply `theme` / `recipe` styling.** The
619
- pipeline emits no `app.css`; styled output is single-entry only. Not a
620
- diagnostic — the project simply builds unstyled.
621
624
  - **No server-side rendering or hydration** for a multi-page project. The
622
625
  application shell is the same for every route and the client does the first
623
626
  render. Deliberate, not a gap being worked around.
624
- - No migrations, no OAuth / TOTP, no deployable production output, no styling
625
- themes (`theme` / `recipe`) in the single-page starter, no npm escape
626
- hatches (email / payments / storage).
627
+ - No migrations, no OAuth / TOTP, no deployable production output, no npm
628
+ escape hatches (email / payments / storage).
629
+ - **`theme` / `recipe` / `class:` styling does not work from an npm
630
+ install.** It needs the Tailwind toolchain, which this package does not
631
+ ship; a build that uses any of them fails with `E-CSS-TOOLCHAIN`. The
632
+ default styles (above) cover every control; the styling features work
633
+ only inside the Volaro repository.
627
634
  - Real screen-reader behaviour is **not certified** (build-time a11y rules
628
635
  are enforced; a full SR pass is owed). The manual SR-1–SR-11 protocol has
629
636
  **not** been walked against the client router either — its results template
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volaro",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.5",
4
4
  "description": "Volaro \u2014 an application language written to be authored by an AI agent and read by a person. Ships the language reference and a working compiler for a supported subset (volaro check / build / dev). CLI: `volaro` (alias `vl`).",
5
5
  "repository": {
6
6
  "type": "git",