Weave

September 1, 2026 · View on GitHub

Versioning discipline. The version is semver, decided by the change — see VERSIONING.md, which is the public promise: PATCH for a compatible fix, MINOR for new compatible surface, MAJOR for a break. All framework packages move in lockstep (one version across @weave-framework/*); workspace:* deps resolve to the concrete version at publish. The VS Code extension (editor/vscode) is versioned independently. This version is exactly what is published to npm. Publishing is a separate, explicit step (the /publish skill / pnpm publish:packages) — pushing code does not publish to npm.

(Corrected 2026-07-17. This note used to read "every commit bumps the patch version by 1", a pre-1.0 bookkeeping habit from 2026-07-02. It contradicted VERSIONING.md the moment the API was frozen on 2026-07-05: a scheme where a bug fix and a new feature both cost +1 patch cannot express semver. Practice had already left it behind — Phase E ran 94 commits without a bump, and then released as one MINOR. The public promise wins; the habit is retired.)*

3.4.1

  • fix(cli): the <script> the build injects into index.html is the entry, not one of the app's lazy routes. esbuild sets entryPoint on every code-split chunk it names after a module, so a lazy() route is an entry point in the metafile exactly like the real one; the build read "the first output that has one". For an app with enough routes that is a route, and the page then fetches, parses, runs, and mounts nothing — no 404, no console error, no failing build, a blank page. This shipped: the 3.4.0 documentation deploy was blank until this fix. verify:entry-name builds an app with forty lazy routes and asserts the injected script is the entry; the count is the point, because verify:base-path had asserted the same name for a year against a fixture with one module.

3.4.0

  • fix(compiler): an empty block body says where. @if (cond) { } — and every template a stray } turns into one, because the brace closes the block before its content — threw a bare Empty template fragment with no offset, so the dev overlay and weave check had nothing to point at and the message described fragments while the mistake was a brace. It is now a located ParseError carrying the block's own expression offset. This is the same failure class as the unterminated comment fixed earlier: a true sentence about the wrong thing. Found by verify:hostile-input, which mutates the repository's real templates — the first template to expose it was the docs' own callout.

  • fix(runtime): dom.ts carried a raw NUL byte where \x00 was meant, inside the character class that normalizes a URL before its scheme is read. The regex behaved correctly, but every text tool read the file as binary — grep refused to search it.

  • build: the runtime/dom gzipped budget goes 5,504 → 5,632 (+128), a deliberate call recorded in tools/verify-size.mjs: the href-scheme warning costs 47 bytes it did not have, and shortening the sentence to fit would have spent the part that helps. A stale note in the same file claimed the gate measures unminified bytes; it has minified before gzipping for some time, so doc comments cost nothing.

  • fix(skills): two British spellings lived in skills/ — the source the create-weave template is generated from. Both had been fixed in the generated copy, so verify:prose passed while the next build wrote them straight back. The gate now scans skills/ too: a corpus that holds a generated file and not its input measures the wrong thing.

  • docs: the Static generation page now lists every reason a component can refuse to resume. The compiler refuses for eleven distinct constructs; the page named three, and four of the seven real refusals measured across 622 components (bind: twice, a fade, a fly) were caused by constructs it never mentioned once — so a build warning naming one had nowhere to lead. Each row says why the resume walk cannot adopt it and what to write instead, and the page shows the warning as it actually arrives. tools/verify-resume-reasons.mjs holds both sides together: every reason must exist in the compiler and be named on the page, and the count of refusal sites must match, so a twelfth reason cannot ship undocumented.

  • fix(compiler): the refusal for a block the resume walk cannot adopt in place reads as a sentence. It was joined into its template uses `@defer` cannot be adopted in place. — the reason was a clause where every other one is a noun phrase. Now: its template uses `@defer` in a position resume cannot adopt.

  • fix(docs): a :::callout title renders its markdown instead of publishing it. title is a string prop set as text, so a title written as It works in `weave dev` reached the reader with its backticks intact — 34 titles on 25 pages did. The title is now a slot the markdown renderer fills, with the plain string as fallback; docs/tools/verify-markdown.mjs fails if either half is removed.

  • docs: the whole site rebuilt around one shape — what a thing is, something live, the scenarios you will meet, and what to do when it breaks. Every Learn page now has all four (tools/audit-scenarios.mjs measures it); live demos in Learn went 2 → 36; all 41 UI pages, 44 of 45 Examples pages and both Reference guides gained a failure section quoting the real messages. Two new instruments keep it honest: tools/audit-docs.mjs (dead imports, API coverage, template and CLI surface) and tools/audit-scenarios.mjs (per page: API named, messages shown, page shape). The generated API reference grew from 174 to 349 exports — the CDK's 102 and nine published entries had no page at all. The Performance page was removed: it compared by proxy, against a standing rule not to, and its numbers were a single dated run.

  • feat(compiler): the template linter warns on {{ … }} inside <textarea> or <title>. A browser reads those elements' content as RCDATA — text, not markup — so the <!----> placeholder the runtime writes for a dynamic text position is not parsed as a comment: it becomes six literal characters in the value. <textarea>{{ draft() }}</textarea> showed <!----> to the user and handed it back through .value; <title>{{ name() }}</title> put it in the browser tab. Neither warned. Found by a documentation demo that read its own textarea back and parsed [...rest].ts<!----> as a filename. The message names the form that does work (bind:value for a textarea, an effect writing document.title). Pinned by six cases in packages/compiler/test/entity-text.smoke.mjs, three of them asserting silence on markup that is fine.

  • fix(cli): weave check --fix now repairs everything it is certain of in one run. Every declaration grow-setup offers is inserted at the same offset — the end of setup's body — so they all overlap, and applyFixes skips a fix overlapping one already applied. With a single apply-then-recheck round, a template naming four missing names got two of them and printed the other two as errors; running the identical command again finished the job. It now loops until a round changes nothing, bounded at ten. Measured before and after on the same fixture: save + done on the first run and age + label only on a second, against all four in one. Pinned by a fourth case in packages/cli/test/check-fix.smoke.mjs.

  • feat(compiler): the template linter warns on an HTML entity in a template. A Weave template is TEXT — escapeText writes & as &amp; on the way into the emitted <template>, which is exactly what lets a template hold <, { and a code sample verbatim — so &mdash; reaches the reader as those seven characters, never as an em dash. Nothing said so anywhere. Narrow like the other rules: only a named entity that resolves to a different character, or a numeric one, warns; Tall & scrolling, a && b and ?a=1&b=2 stay silent, and the message names the character to type instead (with a fix, so weave check --fix applies it). Pinned by packages/compiler/test/entity-text.smoke.mjs (14 cases including a control that the linter fires at all, wired into pnpm verify:entity-text), and confirmed to surface in a real weave build with the offending line framed.

  • fix(check): weave check now reads the project's own .d.ts files. collect() skipped every declaration file outright — right to skip as a COMPONENT, wrong to drop from the program, since an ambient declare module or declare global only takes effect when its file is a root of it. So an untyped npm import reported TS7016 and printed TypeScript's own advice ("add a new declaration (.d.ts) file") at an author who had already added one, while tsc --noEmit went green on the same tree. declare global, module augmentation, and an app's asset shims were all invisible for the same reason. Pinned by packages/check/test/ambient-dts.smoke.mjs (4 cases, wired into pnpm verify:check), which includes a control case asserting the error still fires WITHOUT a shim — otherwise the fix would be indistinguishable from checking nothing.

  • docs(learn): a new Using a package from npm section on Installation — Weave's zero-dependency rule is one it keeps for itself, not one it puts on your node_modules. Covers the three cases that behave differently: pure logic (install and import), a library that draws into the DOM (a use: action, whose { update, destroy } handle is the lifecycle you would otherwise write by hand), and one that touches window at import time (which works in weave dev and breaks in --ssg).

3.3.0

Four security fixes, found by auditing rather than by a report

A snapshot could choose an object's prototype. deserialize rebuilt plain objects with obj[key] = value, and __proto__ is not an ordinary key — assigning it replaces the prototype. A wire graph carrying it therefore decided every property the application had not set itself, and invisibly: Object.keys and JSON.stringify show nothing, so if (state.user.isAdmin) passes with nothing in the state to explain why. serialize/deserialize are public, so any app that stores or transmits state feeds this path from whatever it was handed. The key is now defined rather than assigned — the data survives a round trip, the prototype stays the runtime's.

weave migrate could be made to run a command by the repository it was migrating. The install it offers is a single shell line, so the grammar every spec is checked against is the only thing keeping it safe — and that grammar allowed |, spaces and redirects, because semver ranges use them. A spec like pkg@|| calc passed the check, so it never appeared in the refused list, so the prompt looked ordinary. Shell operators are gone from the accepted set; a range that needs || is refused rather than quoted.

Two SVG sanitizer bypasses. <Icon svg={…}> scrubbed javascript: with a pattern that spelled the scheme out, but browsers strip tabs and control characters from a URL before reading its scheme, so java<TAB>script: survived. And <animate attributeName="href" to="javascript:…"> writes the attribute after the scrub has finished. URLs are now checked against an allow-list of schemes (http/https/mailto/tel, or no scheme at all), and an animation that targets a URL attribute is removed.

weave migrate silently removed a sanitizer. Angular's [innerHTML] runs through DomSanitizer; Weave's .innerHTML assigns raw. The binding converted cleanly and the SAFETY did not, so an app that relied on Angular to scrub that value lost the scrubbing without a word. The conversion now carries a TODO saying exactly that. This is the failure mode a migration tool has and a compiler does not: the output looks right.

Each is held by a gate that was proven able to fail first: verify:deserialize-proto, verify:install-spec, verify:dev-traversal, and two browser tests for the sanitizer.

Built assets carry their version in the name

weave build wrote main.js and app.css and pointed at them with a content query (/main.js?v=1a2b3c). That busts a cache correctly, but the filename never changes — so a host can never serve them as immutable, and every repeat visit re-asks whether they changed. The split chunks were already named [name]-[hash]; the entry and the stylesheet were the ones left out.

They are now main-<hash>.js and app-<hash>.css, and the injected URLs are read from what the build wrote rather than composed — with a hash in the name there is no way to know it in advance, and a guess that misses gives a page that loads, renders nothing and reports nothing.

pnpm verify:injected-assets holds that: every URL in the built HTML must name a file the build actually wrote, in the ordinary build and in a prerendered page. It was written before the change and earned it immediately — the --ssg path composed its own URLs separately and would have pointed all 113 prerendered documents at files that no longer existed.

If you referenced the built filenames — a hand-written preload, a service worker, a CSP hash, a deploy rule naming main.js — read them from the emitted index.html instead.

A comment in setup's return no longer costs you resumability

The reader that works out what a component hands out answered "nothing" whenever the returned object contained a comment — a note on its own line, a block comment, or one trailing a value. It tests each entry against an identifier pattern and gives up on anything else, which is the right instinct for a spread or a computed key and the wrong one for somebody explaining their code.

What it cost was not small: with the return unreadable, the resumable target cannot show that a module import handed straight out — a use: action, typically — survives to the client, so the whole subtree is refused and client-rendered. Silently, and as a tax on writing clearly.

Comments are now removed before the keys are read. A spread and a computed key still yield "unknown", because those genuinely are.

Across four real applications this and the auto-expose fix above take resumable refusals from 29 to 12; one application went from 12 to 5, another from 10 to 1.

Omitting setup's return no longer costs you resumability

A component may leave out setup's return — the compiler writes it, from the names the template uses. That is documented as a convenience with no other consequence, and it had one: the resumable analysis read the raw script, so a component relying on it looked like it returned nothing. An imported use: action could then not be shown to survive to the client, and the whole subtree was refused and client-rendered instead — silently, for writing less. The identical component with the return spelled out adopted fine.

Measured across 574 components in four real applications: use: actions were 18 of 29 resumable refusals, and the fix clears 9 of them. One application went from 10 refusals to 1.

@weave-framework/ui/testing — driving components in a test

The parts a consumer cannot reasonably write: mount (either the built default export or a { template, setup } module), press / click, overlay() / overlays() for what a component rendered into the overlay container, focused(), and tick.

Its shape came from measuring this library's own suite rather than from imagining one. Across 61 browser-test files there were ~95 hand-rolled owner/mount pairs and 63 compileTemplate calls, each carrying a hand-written list of the names its setup returns — duplication of something the compiler already computes, and one that goes stale in silence. Adding a binding to a component makes its own tests fail with <name> is not defined, a message about the harness rather than the component. That happened twice in a single day while writing this release.

There is no query language and no assertions: a test runner has both already.

It is a subpath rather than a package because it needs the library's internals, and a separate package would force those into a frozen public API. pnpm verify:ui-testing measures that an ordinary bundle carries none of it — 11,923 bytes without, 26,023 with.

The build says what it did not check

weave check is the gate, and weave build never ran it — so a build succeeded on code the checker refuses, and nothing in its output said so. A template calling a name setup does not return bundles cleanly and throws in the browser; the build's silence looked like a verdict.

Two changes, and the default is deliberately not one of them:

  • A build that did not type-check now says so, in its summary.
  • weave build --check runs the checker first and writes nothing when it finds errors. An artifact built from code known to be broken is worse than no artifact.

Making the check mandatory would turn a green pipeline red on unchanged code, which VERSIONING.md grades as a break however right the new answer is. It stays opt-in until a major.

Every entry point accepts a component with typed props

Component is the type of a component being CALLED — props optional, because the caller may pass none. Using it for a PARAMETER is the opposite direction, and parameters are contravariant: a component the compiler emitted from a template with typed props is (props: TheseProps, …) => Node, which is not assignable to one that may be called with undefined. So every API that asked for a Component refused the ordinary case.

This had already been fixed twice, one instance at a time — lazy() in 3.2.0, and dialog regions earlier in this release. A probe over every author-facing entry point found all five refusing it:

writtenbefore
route('/doc/:id', { component: Page })rejected
mountComponent(App, '#app') — every app's bootstraprejected
defineCustomElement('x-page', Page)rejected
lazy(…, { loading: Spinner })rejected
renderComponent(Page, props) — SSRrejected

There is now one AcceptedComponent used at all of them, and the framework converts explicitly where it calls what it was handed. LoadedComponent remains as its earlier name.

Found by a fourth real application, which hit the dialog case 16 more times independently — and finding it twice in one day is what turned a third point fix into a sweep of the family.

A component that provides a child's name itself keeps it

weave check writes an import for every <Tag> a template composes, unless the script already imports it — and "already imports" was the wrong question. A wrapper component can DECLARE the name instead (const Chart = (ChartModule as …).default, re-exported, which is how you wrap a child you also want to pass along), and the synthesized import then landed on top of the author's own declaration:

error TS2440: [generated] Import declaration conflicts with local declaration of 'Chart'.

The [generated] marker is the tell — the error sat on a line the author cannot edit, and the build had no such error. weave check disagreeing with the build about child components is the failure 3.2.0 closed once already, in the other direction.

The question is now whether the script provides the name at all, by import or by declaration. Erring towards "it does" is the safe direction: skipping an import the script did not in fact provide yields Cannot find name '<Tag>', which says what is missing and where, while a collision says neither.

Found by running the checker against a third real application.

A component with typed props goes into a dialog

component(X, props) — the documented way to put a component into a dialog or a sheet — took a Component, which is (props?: Record<string, unknown>, …) => Node. A component compiled from a template that declares typed props is (props: TheseProps, …) => Node, and that is not assignable: parameters are contravariant, so a function requiring TheseProps cannot stand in for one that accepts undefined. The effect was that the normal case — a dialog whose header, content and actions take props — failed to type-check at the exact call its own documentation shows.

The props position is now never, which accepts any props shape and is honest because this helper only forwards what it is handed. It is the same fix lazy() already carries for routed pages that declare required props.

Found by running this checker against a real 61-template application, where it accounted for 57 of the 59 errors reported. Neither the docs site, the demo, nor the migrated app has a single typed component inside an imperative overlay, which is why none of them could have found it.

<Icon> takes a class

Thirty of the library's forty-five components accept a per-instance class; the icon — the element most often sized or coloured differently from its neighbours — did not. Same application, same run.

A malformed template says where it went wrong, instead of crashing

The parser is the piece most exposed to input nobody wrote on purpose — a half-finished edit, a truncated paste, the intermediate states of a migration — and two shapes made it fail without saying anything useful.

Thousands of unclosed tags or blocks overflowed the stack: the author was told Maximum call stack size exceeded about a file, with nothing about where. Nesting is now capped at 500 levels with a located error naming the likely cause. That bound was measured rather than picked: the stack gives out at around 2,500 levels, and the deepest template in this repository nests 25.

An unterminated <!-- swallowed the rest of the file in silence, and what surfaced was codegen's Empty template fragment — a true sentence about the wrong thing, with no position on it. It is now reported at the comment.

Both are held by pnpm verify:hostile-input, which mutates every template in the repository about 2,600 ways — truncated at forty points, single characters removed at twenty more, each structural character injected — and requires every rejection to carry an offset. It also requires at least a tenth of that corpus to actually be rejected, since otherwise a parser that accepts everything would pass it.

A two-way binding declares its signal

weave check --fix and the editor lightbulb answered one shape: a name bound to an on: handler, which can only be () => void. They now answer bind: as well, and for the same reason rather than a looser one — the runtime writes a specific type BACK into the signal, and which one is settled by the markup:

writtendeclared
bind:checkedsignal(false) — the runtime writes el.checked
bind:value on type="number" or type="range"signal(0) — it writes valueAsNumber
bind:value on <select multiple>signal<string[]>([]) — it writes the selected option values
bind:value anywhere elsesignal('') — it writes input.value

Two shapes are still refused, because the markup genuinely does not settle them. bind:group writes back in whatever type the signal already holds, so a fresh declaration has no forced type at all. And an input whose type is itself a binding is a string one render and a number the next.

A declaration that needs signal now brings the import with it, folded into the same single edit — joining the existing @weave-framework/runtime import where there is one, opening one where there is not. The Signal type is imported only when the script has an explicit return annotation for it to land in, so a component relying on auto-expose does not gain an unused import.

The editor declares into a .weave too

"Declare <name> in setup()" — the lightbulb that writes a declaration the template asks for — was offered only for the two-file form. An SFC keeps its script inside the same file, so the edit growSetup returns, whose offsets are into the script, had to be shifted by where that script begins; without that shift the action was simply declined, and every .weave author was left with weave check --fix in the terminal. The shift is the one the checker already applied, so the two sides still cannot offer different things.

Two details the SFC form needs and the sibling form does not: the "is this name already known" test reads the script REGION rather than the file, since a name is in the template by definition and reading the whole file would make every name look known; and the edit targets the .weave itself.

Editor plugins rebuilt around the changed server: VS Code 0.6.6, WebStorm 0.23.6.

Two compiler scans could be made to hang on hostile input

Both read text an author controls and can make arbitrarily long — a component's own script, and the prose inside a template — and both backtracked polynomially, so a single file could stall a build or an editor for minutes.

importsBinding matched import\s+([^;]*?)\s+from\s+['"][^'"]+['"], where a run of whitespace can be divided between the two \s+ and the lazy group in every possible way. The word import followed by 8,000 spaces and no from took 59 seconds; 16,000 did not finish in two minutes. It now locates each import in one pass and reads only that statement, bounded by its ; or by where the next import begins — the second bound matters, because without it two semicolon-less imports read as one and the second binding was invisible.

The text lint matched @([A-Za-z]+)\s*(\([^{}]*\))?\s*\{, with the same split available across the optional group: 120 KB of @A( took 5.7 seconds. The whitespace before ( now sits inside the optional group, and the block head is bounded, so a head longer than 512 characters simply goes unremarked — this rule offers a spelling hint, and silence is the right failure for it.

Both are guarded by pnpm verify:redos, which asserts the same inputs complete in milliseconds and that the scans still find what they are meant to find. Against the previous code its three timing claims fail at 71s, 10.3s and 23.8s. Reported by GitHub code scanning as js/polynomial-redos.

Internal

  • Two planned framework features were declined on their own measurements, and say so. RFC 0012 ("an app with no plumbing") named its trigger — re-measure on a deep real application, single digits and it is not worth its risk — and the answer across 585 components in five applications was zero. "Splitting below the component" would have shipped one handler instead of a module; handler bodies measure 0–3% of a compiled component, so it would have saved almost nothing. Both moved from the roadmap to the out-of-scope list, with the numbers, so neither is proposed again from intuition.

  • The retained app's test server could be talked out of its own directory. It joined the request path onto dist/ and called normalize, which RESOLVES .. without refusing it, so an encoded ..%2f..%2f..%2fpackage.json was served from outside the build. It only ever runs on a developer's machine, which is why nobody looked — and making the file public is what made GitHub look (js/path-injection). The resolved path must now stay inside the served directory. The first version of the test proved nothing: two levels up from examples/demo/dist is examples/, where no package.json exists, so the 404 that came back looked like a refusal. Three levels names a file that exists, and without the containment that request returns 200. The first FIX did not hold either — a containment check on the resolved path left the alert open, because the request still reached readFile. The server now lists the build once and looks a request up in that list, so nothing from the request reaches the filesystem at all. An allow-list beats a sanitiser: it cannot be reasoned around, by an analyser or by anyone else.

  • tools/link-local.mjs — test a fix in a real app without publishing it. It packs this checkout the way npm would receive it and points an app at those tarballs, with --restore to undo. The approach is not new — one consuming app has carried its own version of this since July — but it lived outside the framework, so every other app had to reinvent it, along with the two things that make it work. pnpm link or a file: dependency on packages/<name> resolves to ./src/index.ts, since only publishConfig swaps main to dist at publish time, so the app would consume TypeScript source rather than the artifact people install. And every Weave package has to be overridden rather than just the app's direct dependencies, or a transitive request escapes to a registry that does not have this version. Proven by installing it into a scaffolded app and watching that app produce a diagnostic that exists in no published version.

  • The public-repo private-path gate was weaker than the local hook it backs up. verify:no-private exists precisely for a machine where the pre-commit hook is missing — hooks are never cloned — but it listed fewer paths than the hook refused, so editor/, publish/, drafts/, archive/, the private plan documents and the example fixtures could have been committed and CI would have reported "no private paths tracked". Measured, not assumed: with three such files staged, the old gate passed. The two lists now say the same thing, with examples/demo carved out of both as deliberately public.

  • One real app is now retained as a CI gate (examples/demo, pnpm verify:demo). The Weave Board demo — file-based router with code-split lazy chunks, three stores, an optimistic create, a form, an overlay modal, @defer, an error boundary, a 404, keyed 1,000-row reconcile, transitions and scroll restoration — is built by the real CLI and driven in a real browser. Its end-to-end test existed since June but was never wired to anything, so nothing had run it for two months. Every per-fix test guards its own fix in isolation; only a whole app can catch a refactor that breaks them in combination. Two of its claims turned out not to hold, and were found by mutating the framework under them: the keyed-swap check read the row TEXT, so a reconciler rewritten to rebuild every row still passed (it now checks node identity — what "minimal moves" actually promises); and the scroll-to-top check passed with the router's scrollTo deleted, because leaving the 1,000-row page makes the document shorter than the viewport and the browser clamps the scroll itself (the document is now kept tall across the navigation).

  • pnpm verify:all runs locally exactly what CI runs, by PARSING .github/workflows/ci.yml rather than keeping a second list of gates. 3.2.0 shipped with a red CI because the pre-release check was a hand-picked list of about 18 commands and the workflow has 52; the one that failed (verify:skills — five new runtime exports were undocumented) was not on the list. A second list drifts; a list read from the first one cannot.

3.2.0

A template mistake now says WHERE, and weave check --fix repairs the certain ones

The five template lint rules have always produced the right sentence. Three of them even computed the exact answer — on:clik knew it meant on:click, onclick={{ … }} knew it meant on:click, @fro knew it meant @for — and that answer only ever reached a human, as prose, with no position attached.

  • Warnings carry a position. A finding is now framed at the line it is on, in the template file it is in — with the source line underlined — instead of naming the component module (and naming the .ts, which is not even the file the mistake is in). In a 200-line template the old message said only "this component".
  • weave check reports them at all. It used to type-check the template and say nothing about markup that compiles clean and fails silently, so the checker and the build disagreed about whether the same file was fine. They are warnings: weave check exits non-zero on errors only, so a pipeline that was green stays green.
  • weave check --fix applies every fix a rule is certain of, then re-checks. A rule with more than one plausible answer (an unknown binding prefix) offers no fix — a wrong auto-fix is worse than none.
  • The editor shows them too, with a quick fix. VS Code and WebStorm underline the mistake where it is and offer "Replace clik with click". Both editors and weave check run the same code, so they cannot disagree about the same file. (VS Code 0.6.3, WebStorm 0.23.3.)

Measured on real code: 0 new warnings across the docs site's 446 templates and the demo app — the rules stay as narrow as they were. On a deliberately broken template, --fix turned three warnings and one type error into a clean check in a single run (repairing @fro@for makes the loop variable real, so the type error it caused disappears with it).

New API, additive: lintTemplateFindings() returns { message, offset?, fix? } and compileComponent returns findings alongside warnings, which stays its exact string projection. TextNode.offset and EventAttr.nameOffset are new optional AST fields; a text run coalesced with another (a comment between them) clears its offset rather than keeping one that no longer maps to the source.

The template declares into setup for you

A component is two files, and you used to say every name twice — once where you use it, once where you define it. One of those mirrors was already gone (auto-expose writes setup's return). This removes the other, for the cases where the markup says without doubt what the missing thing is.

<button on:click={{ save }}> with no save can only be () => void. weave check --fix now writes the declaration, adds it to the return, and adds it to the declared return type — one edit, and the result is byte-identical to what you would have typed:

export function setup(): { n: number; save: () => void } {
  const n = 1;
  const save = (): void => {
    // TODO
  };
  return { n, save };
}

The editor offers the same thing on the lightbulb — Declare save in setup() — from the same code, so the two can never propose different things. It is offered once: if the name appears anywhere in the script already, nothing is proposed, because a duplicate declaration would be worse than no offer.

It writes declarations, never logic — the TODO is yours — and it declines wherever the shape is a guess: {{ total }} could be anything, and a return type that is not a type literal belongs to some other declaration. The error stays, so nothing is hidden. A plausible-looking guess is the fastest way to make a helpful tool something people turn off.

Renaming a template binding renames the const behind it

Renaming from a template always edited both files — and left two names for one thing. return { count } is a shorthand, and renaming the context PROPERTY cannot rename the const through it, so TypeScript took the only safe option it had and expanded it to return { total: count }. It compiled; it was not what anyone meant.

Now the const and everything that reads it follow, and the shorthand stays a shorthand:

// F2 on `{{ count() }}` in the template, renaming it to `total`
const total = (): number => 1;
const twice = (): number => total() * 2;   // the reader follows too
return { total, twice };

The const's references are not found by us — they are asked of TypeScript, which gets them right. That matters: renaming a declaration without its references is a silent breakage, and the scaffold's own inc reads count. Renaming from the .ts is unchanged; { count: total } is already correct there.

weave check --impact <file> — what renders this component

The question asked before editing a component, answered from the composition graph rather than a search: grep finds a tag's name, which is not the same as the components that actually resolve to this file.

src/lib/code-block/code-block.ts is rendered by 2 files

  directly (1):
    src/lib/api-page/api-page.ts

  and reached through those (1):
    src/pages/reference/[pkg].ts

Direct and transitive are separated because they mean different things: a direct user is a file you will probably read; a transitive one is a screen that can change under you without its own file being touched. It resolves children both ways — by explicit import and by the no-import convention — and it answers without type-checking, since the question is usually asked while the tree is red.

weave merge — git stops inventing template conflicts

Two people on one template is the everyday case, and git merges lines. A tag and its text share a line, so a handler added to a button and that button's label reworded are one hunk to git: a conflict, with nothing actually in disagreement.

weave merge --install (once per clone) registers a merge driver that reads the file as a tree. Different nodes merge — an attribute here, a label there, two different attributes on one tag. The same node changed two ways stays a conflict, because it is one.

Three properties make it safe to install and keep:

  • Git goes first. Its own merge runs before anything else, and its result is used whenever it is clean. The tree merge only sees files git already failed on, so it can add resolutions and never change one that already worked.
  • Nothing is reformatted. The merge splices the original source text of each node; untouched lines come out byte-for-byte unchanged.
  • It declines loudly rather than guessing. Control-flow blocks (@if, @for, …) are opaque units, a result that does not re-parse is thrown away, and a file that is not a template (a page with a <!DOCTYPE>) is left to git untouched.

Any screen, in any state, in one second

Getting a screen into the state you need to look at meant driving the app there by hand, every time. The DevTools panel now has a States tab: get the app where you want it, name the state, save it. It is written to .weave/states/<name>.json — plain JSON you can commit — and weave dev --state <name> opens the app already in it. Apply in the panel does the same live, with no reload.

A state is exactly the values of the signals you named; nothing is predicted, nothing is inferred, and a computed is not saved because setting its sources reproduces it. Values go through Weave's own serialization, so a Date/Map/Set survives the round trip. Names the app no longer has are skipped, and the count of signals actually set is reported. Dev-only in every part.

New API: captureState() / applyState() on the devtools registry, devServerStates() and startInState() for the dev-server protocol, and a states option on mountDevtoolsPanel.

weave dev --devtools — the reactive graph, in the page

The introspection registry and the panel have been in the runtime for a long time, and no app ever saw them: they only work if the author calls enableDevtools() before the mount and then mounts the panel. A real capability, effectively unreachable. weave dev --devtools does both, and weave dev prints one line saying the flag exists.

It is off unless asked for. An overlay nobody requested, appearing over their app, is worse than no feature — so the gate asserts both that it appears with the flag and that nothing appears without it.

Named nodes only, and only ones with an owner: a signal created at module scope is never registered, so it does not appear however early devtools is switched on.

Reordering a list no longer resets the row it moves

insertBefore on a node that is already in the document removes it and puts it back, and the removal throws away what the DOM never restores: focus and selection, a scrolled position, a playing <video>, a CSS animation, an <iframe>'s whole document. A keyed @for did that to every row it moved — so sorting a table while someone was typing in it silently took their cursor away.

Keyed-list moves now use moveBefore, which reparents without the removal. Feature-detected, with the ordinary path kept for browsers that do not have it and for nodes that cannot be moved. Nothing changes for anyone; things simply stop being lost.

weave check stops erroring on components it did not compile

A Weave component's default export is synthesized by the compiler, so the .ts on disk really has none. The checker built virtuals only for components under the roots it was given, so importing a component from anywhere else — a shared package, a sibling library, a directory the command was not pointed at — was reported as has no default export. Pointed at this repo's own docs site, that was 396 errors on correct code.

Such a component is now compiled on demand as a dependency. Nothing is reported from it (it is not a checked file); what it contributes is its export and its prop types — so a wrong prop handed to a component across a package boundary is now caught, where before the import itself was the only thing the checker had to say.

A component may name its own types whatever it likes

weave check embeds a component's script verbatim into the module it synthesizes around it, and that module described the component using the bare global name Node. A component that declared its own interface Node — a tree, a menu, a graph — therefore retyped its own default export, and anything that registered or lazily imported it got the memorable Type 'Node' is not assignable to type 'Node'. Nine of this site's own demos were in that state.

The synthesized types now reach the DOM type through globalThis, which a local declaration cannot shadow. The component's own Node still means exactly what its author wrote.

field() takes its type from the value, not from its validators

field('') gave a Field<string>, but field('', [validators.required()]) — the same line with the most ordinary validator on it — gave a Field<''>: a field that could never hold another string. The same happened for field(false, …) (Field<false>) and field(0, …) (Field<0>).

The ready-made validators are typed for what they ACCEPT (required takes unknown), so the validator array was a second, contradictory inference site for the value type. The validator and options parameters no longer take part in that inference, so the value alone decides.

control={{ field }} on the pickers type-checks

The datepicker, timepicker and date-range picker declared their control binding as Signal<X | null | undefined>. A Signal is invariant — read and written — so a Signal<X | null> is not one of those, and the exact line each component's own documentation recommends (field<Date | null>(null) handed to control) did not type-check.

The binding now says what the components actually do, which was measured from them: they read tolerantly and write narrowly (ControlValue<R, W>, exported from @weave-framework/ui/cdk). A Field<Date | null> fits, a Field<Date | null | undefined> still fits, and a value that cannot be given null is still refused — because null is what these components write when cleared.

Three UI props now accept what their own documentation shows

Pointing weave check at the docs site turned up three props whose declared type was narrower than what the component actually accepts, so the pattern each one's own example demonstrates did not type-check:

  • <Table dataSource> takes any () => T[], not only a Signal<T[]> — the implementation calls whatever function it is given, and a computed is the natural thing to hand it.
  • <Stepper steps> takes a getter, which is exactly what the documented linear example uses to build steps out of signals.
  • <Toolbar role> exists. The toolbar documentation has always shown role="banner" on it; the prop was never declared and the attribute landed nowhere.

lazy() can load a page that takes props

A routed page declares the props the router hands it (setup(props: { params: { pkg: string } })). lazy() required a Component, whose props are optional, so the module weave routes generates did not type-check for any app with a dynamic route. lazy now accepts a component whose props shape it does not know (it only forwards them).

Internal

  • verify:size measured the raw tsc emit, so doc comments counted against a budget no browser ever downloads — 70% of the number was prose. It now minifies before gzipping and every budget is re-baselined: the SPA core reads 6.6 KB, not 22.1 KB.

3.1.0

A first-run audit — scaffold an app from the published package, follow the docs, then make the mistakes a beginner makes — found the framework SILENT in the places a beginner needs it loudest: a clean build, a green check, and a broken app. Every item below was measured on that app, not on this repo.

Fixed — the dev loop

  • A compiler error used to end the dev session. weave dev turned only a ParseError into a located esbuild error; every other failure was re-thrown, and an exception escaping an onLoad callback takes esbuild's watch state with it. After one such error the server kept serving the last good bundle forever and ignored every later save, with no message anywhere — the cure was restarting. Reachable by an ordinary typo (an editor truncating a template on save, a component tag with nothing to resolve, a missing style file). Failures are now diagnostics located at the author's file, with the template and style files kept in watchFiles so the save that repairs the error is still watched.

Fixed — the dev loop (continued)

  • A runtime error that renders nothing now says so, in the page. A setup() that throws produced a blank white document with the message only in the console — nothing to read, nothing to click, no sign anything was wrong. The dev client's overlay (until now only for build errors) also paints an uncaught error when the page came out empty. Only when it is empty: an app that rendered and then threw keeps its screen, because covering a working UI with a modal would be worse than saying nothing.

Added — deploying

  • base in weave.config.ts — the sub-path an app is served under (/my-app/). The build injected root-absolute URLs and nothing else, so a GitHub Pages project site — which the installation page recommends by name — served user.github.io/main.js, got a 404, and showed a white page, with no setting that could fix it. Now every injected URL carries the base, weave dev answers under it (so a sub-path is developed against, not discovered in production), and the router picks it up as its basename with nothing for the author to wire. Static generation carries it too.
  • An app with client routes also gets 404.html — the same document as the shell. It is what a static host serves for an unknown path, so it is what makes a deep-link refresh work where rewrite rules are not available (GitHub Pages). Only for apps with routesDir: a single-page app has no deep links to lose.
  • Injected assets carry a content version (/main.js?v=1a2b3c), derived from the built file's own bytes: a CDN can no longer answer fresh HTML with a stale bundle, and an unchanged rebuild keeps its URL so nothing re-downloads.

Fixed — the CLI's front door

  • A busy port crashed the dev server with Node's Unhandled 'error' event and a raw EADDRINUSE stack — for the most ordinary situation there is, a second terminal already running weave dev. It steps to the next free port and says which one it took.
  • weave --help exited 1 with a single usage line, and weave build --help ran a build (wiping outDir) instead of printing anything. There is now real help — commands, options, examples — printed on --help/-h/help/no command, and an unknown command says so before printing it.
  • A finished build reports what it produced — elapsed time, then each emitted file with its size, source maps summarized in one line. weave build → dist/ alone said only that the command had run.

Changed — weave check

  • It agrees with the build about child components. The loader resolves a PascalCase tag by convention (<TodoItem>./todo-item/todo-item.ts) and wires the import itself, so an app that never writes that import compiles, runs and renders — while weave check reported Cannot find name 'TodoItem' about it. The resolution rule now has ONE implementation, which both sides import. A tag that resolves to nothing is still an error in both.
  • It checks the whole project now, not only the components. Plain modules — services, stores, helpers, routes.gen.ts — were pulled into the program as dependencies and never reported on, so an app whose only quality script is weave check could pass with a type error plain tsc --noEmit refuses. Every non-component .ts under the checked roots is now a program root too, under the same tsconfig, with paths printed relative to the working directory like a component's. This can surface errors in code that was never checked before.

Added — diagnostics

  • The four silent template mistakes now speak. Measured on a scaffolded app, each of these compiled clean, passed weave check, and failed invisibly at runtime: onclick={{ fn }} (an attribute set to the function's source text — the button does nothing), an unknown binding prefix (xyz:abc, emitted as a plain attribute), a misspelled event name (on:clik), and a misspelled block (@fro (…) { … }, left in the page as literal text). Each is a build warning naming the fix. The rules are narrow by design — a static onclick="…" is real HTML, xlink:href is a real namespace, and a genuinely custom event name is nobody's typo, so none of those warn.
  • {{ count }} without its () is now a type error. A function in a text position is rendered as its own source, so the page read () => { track(node); return node.value; } with nothing reported anywhere. The check harness routes every text interpolation through a parameter type no callable satisfies, and the message says what to do rather than what failed to assign. Only text interpolation — a function passed to an event or a callback prop is exactly right. Verified against the whole docs app: zero new diagnostics.
  • A selector scoped into something that can never match is now a build warning. :root, html and body live outside anything a component renders, so scoping them produces [data-w-xxxxxx]:root — valid CSS matching nothing. That is exactly how the UI library's theme fails when it is pasted into a component stylesheet instead of a global one: it compiles, ships ~120 KB, and applies to nothing, silently.

Editor plugins

  • Rebuilt and re-shipped — WebStorm 0.23.2, VS Code 0.6.2. Both bundle a copy of the language server, and this release changed it (a child component resolves by convention in the editor exactly as it does in the build). A stale bundled server does not fail loudly; it reports wrong diagnostics on correct code, so the freshness gate treats "the server moved and the plugins did not" as a failure. It caught this one on the release commit.

Docs

  • A UI → Installation page, because there was none: nothing in the docs said npm install @weave-framework/ui, and the theming page never said where its Sass block belongs. Four steps, with the scoped-:root trap named. The theming page, the package README, and the framework's own installation page now point at it.
  • The scaffold ships a README.md — it had none, while shipping 120 KB of AI agent skills. Scripts, layout, how to add a component, the UI-library recipe, and the SPA-rewrite note for deploying.

3.0.1

Security

  • Every open advisory closed: 8 Dependabot alerts + 2 code-scanning alerts. The dependency half is undici (five advisories at once, all fixed in 7.29.0), fast-uri (a second host-confusion advisory the earlier pin did not reach — an override is only as current as the last advisory it was written for), js-yaml (quadratic CPU on !!omap) and nx (Zip-Slip in the self-hosted remote cache). All four are build-tooling transitives of the nx toolchain and none is in a published Weave package's dependency tree; the zero-runtime-dependency promise is unaffected. Fixed with scoped overrides in pnpm-workspace.yaml — pnpm 11 ignores pnpm.overrides in package.json and only warns — plus a direct range bump for nx, which is a declared dev dependency rather than a transitive.

Fixed — compiler

  • Every non-decimal numeric literal in a template expression was mis-lexed (W-9). The expression tokenizer had no number branch at all: digits fell through to its copy-a-character default, so the first character inside a literal that can also START an identifier — _, x, b, o, e, n — began one, and the scope pass qualified it against the component context. In ctx mode, which is what every real component compiles in, that made 182_400 come out as 182ctx._400 and the build die on generated code. Only a plain integer and a plain decimal survived; 0xFF, 0b1010, 0o17, 1e3, 1e+3, 1.5e-3, 1n and every separator form were build errors on valid ECMAScript. A numeric literal is now one token, covering hex, binary, octal, exponents with a sign, _ separators wherever they are legal, and the BigInt n suffix — in BOTH scanners, because the analysis half would otherwise infer the tail of a literal as a context name and hand setup a binding to return that does not exist. It is a real token rather than "refuse an identifier that follows a digit": the narrow patch fixes the output while leaving the scanner unable to say where a number ends.

Fixed — compiler

  • Two polynomial-backtracking regexes, on input that is a user's own source file. Both read a declaration head with the type annotation folded in ((?::[^= ]+)?), which puts two whitespace-accepting quantifiers either side of an optional group: on export const template followed by a run of spaces and no =, the engine retries every split of that run at every start position. CodeQL flagged both (js/polynomial-redos). The annotation is no longer part of either pattern — the assignment is found by a linear scan that also steps over the => an annotation routinely contains (: (p: P) => Ctx), which the regex never handled either.

3.0.0

⚠️ MAJOR — this release contains a BREAKING change — see the option-accessor entry immediately below. Code that passes a domain object to <Select>/<Autocomplete> without optionValue/optionLabel compiled before and does not now. It never worked at runtime (the defaults read .value/.label, which such an option does not have, so every row rendered undefined) — but a type that stops accepting previously-accepted code is a break either way, and VERSIONING.md does not grade breaks by whether the old behaviour was any good.

BREAKING — the option accessors are required when the defaults cannot read the option

  • <Select> / <Autocomplete> now demand optionValue + optionLabel for an option type that is not self-describing. The type used to say the accessors were optional while the runtime went on reading .value and .label, so passing an API row type-checked clean and rendered undefined in every row — the silent half of W-8, and the reason its reported case (options: [{ nothing: 'x' }, 42, null]) still compiled after the type parameter was restored: SelectProps<T> asked nothing of T, so any array satisfied it. Self-describing means a plain string, or an object carrying value (label falls back to it) — every shape the defaults genuinely handle. Those keep the accessors optional, so a { value, label } list, a string list, an empty list, and a domain object that already supplies its accessors are all unchanged. The migration is to write the two accessors you already needed, or to name the full contract in an annotation: SelectProps<Row> & RequiredAccessors<Row> (RequiredAccessors and SelfDescribingOption are exported from both components). Deliberately non-distributive ([T] extends [ … ]): a union of option shapes is self-describing only if all of it is, and it keeps an empty options={{ [] }} (T = never) on the optional side instead of collapsing the props type to never.

Fixed — generic components lost their type parameter (W-8)

  • A component whose setup is generic shipped a default export with the type parameter thrown away. Both producers of that default flattened setup's first parameter — Parameters<typeof setup>[0] in the .d.ts @weave-framework/ui ships, F extends (props: infer P, …) in the virtual module weave check and the editor type against — and TypeScript resolves an uninstantiated generic's parameter to unknown. The declared default (T = { value: string; label: string }) does not apply: a default is for a CALL, not for destructuring a type. The loud half was that Select<Option>({ options }) would not compile, and that an accessor typed to a domain object (optionValue: (o: Option) => o.value) was rejected against (item: unknown). The quiet half is the one that mattered: a template checked its props against that same flattened default, so the checking the component's author wrote SelectProps<T> to provide was simply absent — and a template cannot write a type argument, so there was no way for an author to opt out of it. Six components were affected: autocomplete, list, select, table, tabs, tree. The parameters are now re-declared from the source onto the synthesized default (they cannot be recovered by substitution over typeof setup — the list has to be written out), by a single reader in the compiler that both producers call, so the shipped .d.ts and the editor cannot check different contracts. A non-generic component's emitted default is byte-for-byte unchanged. A generic setup whose props parameter carries no type annotation now fails the ui build naming the component, rather than silently degrading to unknown — which is the defect itself.
  • And the template side, which the declaration alone does not fix. A component tag's props were checked by annotating a const with NonNullable<Parameters<typeof Child>[0]>, which re-flattens a generic exactly the same way. Props are now checked by calling the component, so the parameter is inferred from the props being passed — which is what the runtime does with them anyway. One visible consequence: TypeScript pins a prop error in a call argument to the value, where it pinned an annotated const's to the key, so a wrong prop type is now reported on its expression. Both spans are mapped, so both still reach the editor.

Fixed — weave check died on a module that quotes a component declaration

  • A template declaration written inside a STRING is no longer read as a real one. The extractor scanned raw text, so any module holding Weave examples as data — every generated documentation page does — threw weave: `template` must be a static string about its own prose, and one such file aborted the whole weave check with a stack trace instead of a diagnostic. It now scans a copy with comments and string contents blanked; blanking is length-preserving, so every offset still indexes the original and the values are parsed out of that.

Added — weave check

  • The markup inside patch ops is type-checked. (RFC 0008's last follow-on.) A #3 extension writes no template of its own, so the checker classified it as an ordinary module and its patched markup was the one template Weave never looked at: a typo in a patched expression surfaced at build or run time instead of in the editor. It is now checked in place — patched into the base's template, so an op landing inside the base's @for sees that block's local, and the context is the base's plus the extension's own, which is what extendSetup builds at runtime. Errors in the base's own markup stay the base's: reported once, against the base, never repeated against every extension that patches it. A selector matching no element is reported too — at runtime that patch simply does nothing. If the base lies outside the checked roots its half of the context cannot be typed and goes unchecked; nothing is reported that is not real.

Fixed — weave build --ssg

  • A prerendered page is now the app's own document, not one assembled from nothing. Every generated route carried a synthesized head — charset, title, stylesheet — and silently dropped everything the author's index.html said: the viewport meta (so every generated page rendered unscaled on a phone), lang and any theme attribute on <html>, the description and social meta that static generation exists to serve, the favicon, and <base>. That last one broke more than itself: a page at /learn/templates resolves relative URLs against /learn/, so with no <base> a shared href="favicon.svg" 404ed on every nested route. The <html> attributes and the whole <head> are now inherited; <meta charset> and <title> stay the generator's, the title being the route's own. A stylesheet link the shell already carries is not added twice. DocumentOptions gains htmlAttrs. Found by serving the built site locally the way it is actually hosted — the preview server had been mimicking GitHub Pages, which the docs left for Cloudflare, and Pages has no directory-index step, so a prerendered route was answered by the SPA fallback and looked identical to the SPA build.

Fixed — compiler

  • Auto-expose no longer synthesizes a return naming bindings the setup cannot see. When setup omits its return, one is inserted exposing the names the template reads. For a #3 patch extension those names include the BASE's — so a setup that could see only its own pick was handed return { items, pick, title }, and the component threw ReferenceError the first time it was created. The synthesized return now names only identifiers that actually occur in the module's own code (strings and comments do not count). The test is deliberately over-generous: it can only ever drop a name that provably could not resolve, so no working component changes. Found by the new patch-checking gate.

Added — weave migrate

  • A migrated component now says where its missing styles live. A component carries its own styleUrls; a project that keeps a shared stylesheet library keeps half its look in no component folder at all, so the converted component landed correct and rendered unstyled — default bullets, no separators — with nothing saying why. Every converted template is now read for the classes it applies (static class="…" tokens and class: toggles), and any class its own stylesheets do not define is looked up across the source workspace; the file that defines it is named at the top of the template and once more for the whole run before anything is written. SCSS & composition is resolved, so .crumbs { &__item { … } } is found under the name the markup actually uses — a flat scan would have reported that file as irrelevant to the very class that sent you looking. The rules are named, never copied: lifted out of its library a rule loses the variables and mixins around it, so a carried copy is about as likely to fail to compile as to work. A class assembled at runtime (class="icon-{{ kind }}") is not a name and nothing is claimed about it; a class defined nowhere in the workspace is not reported, since it belongs to a global stylesheet the app already loads.

Fixed — editor tooling

  • The shipped VS Code extension no longer carries the vulnerable brace-expansion. weave-language-0.6.1.vsix replaces 0.6.0, rebuilt on vscode-languageclient@10 (whose minimatch@10 pulls the patched brace-expansion@5.0.8). 2.2.1 closed CVE-2026-14257 in the repository, but the extension bundles its dependencies, so the vulnerable copy stayed inside the artifact users actually install. Verified in the archives themselves: the new .vsix carries the 5.x fingerprint, the old one does not. The extension now requires VS Code ^1.91 (client 10's own floor); the four client symbols it uses are unchanged.

  • The WebStorm plugin no longer declares an expiry. weave-webstorm-0.23.1.zip replaces 0.23.0 — identical plugin code, rebuilt with no until-build. The old build declared until-build="261.*", so the day WebStorm 2026.2 (build 262) arrived, every user was told "Plugin incompatible with the new build found: Weave" and the plugin was disabled on update, with nothing actually broken. A pinned ceiling expires on a date nobody chose; this plugin is a thin shell around a bundled LSP server, so the exposure to a real platform break is small, and a certain recurring outage is the worse of the two.

  • The WebStorm plugin builds from a clean clone now. It had no Gradle wrapper, so buildPlugin depended on whatever Gradle and JDK happened to be on the machine — which is why it built fine for months and then appeared to "stop working" the moment anyone tried it from a shell rather than from the IDE. gradlew/gradlew.bat + gradle/wrapper/ are now committed: ./gradlew buildPlugin fetches its own Gradle. A JDK 21 is still required (jvmToolchain(21), and there is no toolchain resolver configured to download one).

2.3.0 — 2026-07-31

Added — UI

  • tooltip takes a class, so one tooltip can look different from the rest. The bubble renders into the CDK overlay container at the top of the document, not inside the component that asked for it — so component-scoped CSS never reaches it, and neither does a --weave-tooltip-* custom property set on an ancestor of the host. The only lever was .weave-tooltip itself, which is every tooltip in the app. The class lands on the same element that carries .weave-tooltip, so a consumer's rule sets that component's own tokens:

    <span use:tooltip={{ { text: message(), class: 'tooltip-error' } }}>!</span>
    
    .tooltip-error { --weave-tooltip-background: var(--weave-color-error); }
    

    A field's validation bubble reads as an error while the submit button's tooltip on the same screen stays neutral. Omitting class leaves the panel's class list exactly as before.

2.2.1 — 2026-07-29

Fixed — router

  • A guard or redirect on path: '/' (or an index child path: '') no longer hijacks every sibling path. Matching is prefix-based and those patterns compile to zero segments, so they are a prefix of every URL at their level — and policy was evaluated on such a candidate before anything checked whether a child consumed the remainder. A redirect meant for the index therefore fired on unrelated paths, sent the router back to a path matching the same route again, and the 16-hop cap returned an empty chain: a blank outlet with no throw, no warning, chain() [] and redirectTo() null. From the outside the router simply looked broken.

    Resolution is now two passes. First it matches structurally, ignoring policy, under the rule the old code stated in a comment but never applied to guard/redirect: a route that cannot complete is not a match. Then it walks the resulting chain outside-in applying redirect and guard, so a layout's auth check still decides before its child is consulted. path: '/' is usable both as an exact route and as a root layout with children, and this also covers the case a narrower fix would miss: a route with children whose guard used to run for a sub-path none of them matched.

    false keeps meaning "block this branch" — the blocked route is struck out and matching runs again, so the next route matching the same URL gets its turn at any depth. That is what the docs always promised; at the top level the old code did not do it.

  • The two silent failures are silent no longer. A redirect loop that exhausts the 16 hops logs a console.error naming the cycle it followed (/users → /login → /users → …) instead of just rendering nothing, and a matched route with no component — which renders a blank outlet and stops any nested <RouterView> below it from mounting — is reported once by name.

2.2.0 — 2026-07-29

Added — CLI

  • weave migrate — assisted migration of an Angular app into Weave (RFC 0011). Run it from inside the Weave app you are migrating into; it asks for the source path, analyses it, writes a migration-plan.md you read before anything changes, and then — only if you say yes — writes the converted code into src/. Your Angular project is only ever read, and an existing file in the target is never overwritten.

    What it converts: @Componentsetup() + a sibling template · @Injectable({providedIn:'root'})store() and a scoped one → createContext+provide/inject · @Pipe → a plain function · @Directive → a use: action · *ngIf/*ngFor/*ngSwitch@if/@for/@switch · [prop]/(event)/[(ngModel)].prop/on:/bind:value · <ng-template>/*ngTemplateOutlet@snippet/@render (arguments ordered by the snippet's parameters, not the context's keys) · <ng-content>/<router-outlet><slot>/<RouterView> · reactive forms → @weave-framework/forms · route guards → beforeEach (an entry guard tests nav.to, a canDeactivate tests nav.from) · HttpClient@weave-framework/data · InjectionTokencreateContext · RxJS → plain values, arrays, promises and signals (see below). An @NgModule becomes no code — Weave has no modules — but a note records what it declared, provided and exported.

  • weave migrate translates RxJS instead of describing it. Weave has no stream primitive, so an app that finished a migration still importing rxjs had been moved, not migrated. Chains are now rewritten by folding the operators over the shape of their source: of(x) is one synchronous emission, so map(f) is f(x); of(a, b)/concat(…)/EMPTY are a finite sequence, so mergeMap is flatMap, distinct is a Set and toArray is nothing at all; from(p)/forkJoin([…]) are one asynchronous emission, so the chain is .then(…). concat(of(ids), of([])).pipe(mergeMap((xs) => xs), filter(p), distinct(), toArray()) becomes [...new Set([ids, []].flatMap((xs) => xs).filter(p))]. A BehaviorSubject becomes a signal.next(v) is .set(v), .value is (), .complete() is dropped — and firstValueFrom(x) is await x, with the enclosing function marked async. The signature follows the body: a folded chain's Observable<T> becomes T or Promise<T>. This runs on converted components and services and on the plain .ts files carried across, which is where the streams usually hide; the rxjs imports the rewrite made dead are pruned per binding.

    An operator with no equivalent stops its own chain, and the whole chain is left standingdebounceTime, delay, scan over a live source and the rest of the genuinely time-based operators keep their rxjs import and their Observable signature, so a signature never promises a plain value over code that still returns a stream. A chain rewritten up to the operator that stopped it would compile and lie.

    Measured on a real Angular component library (8 files, 3 services, a resolver): 0 live rxjs imports and 0 live Observable mentions in the converted output, down from 3 and 6.

  • weave migrate folds chains that start at a CALL, which is what real code has. The fold could only classify a literal source (of(…), concat(…), from(…)), so this._resolveCrumbs(route).pipe(mergeMap(…), distinct(…), toArray()) — the shape almost every real chain has — gave up on its first operator and came out untranslated. The whole unit is now scanned for declarations returning Observable<…> before any file is converted, and the fold classifies calls to them as one emission. x instanceof Observable becomes false (nothing in a Weave app is an Observable, so that branch is dead), and a one-use projection is inlined rather than left as a nest of IIFEs.

  • weave migrate no longer emits imports and bindings the target app cannot resolve. A symbol imported through a workspace alias (@my-org/interfaces) was migrated into the output and then still imported from the alias: only decorated classes were in the symbol table, so nothing a carried file exported could be repointed. An injected service whose calls were rewritten is now still declared when something reads the service itself (_router.url) instead of leaving the draft naming a binding that was never there, the local alias a constructor wrote for it (const _router: Router = this._router) is dropped once this. is gone, and a placeholder no longer names an Angular type the converted file does not import.

  • weave migrate derives three answers it used to keep by hand. Each was a list that could only be as complete as the day it was written, and each had gone out of date on a real Angular library:

    • Imports now come from what the finished draft NAMES, not from rules like "import computed if some member is a getter". A field that was already a derived signal is neither a getter nor anything the rules asked about, so the draft named computed without importing it; the same hole swallowed every inject(…).
    • The symbol table and the "already handled" set come from ONE list of what this migration renames. They had stopped agreeing: resolvers were in the skip-set but in no table, so a converted CrumbsResolver became loadCrumbs on disk while its importer went on naming the class — the bundler stopped at "no matching export", an error the type-check could not attribute to anything.
    • Angular names in TYPE positions are replaced by any and reported once, the same rule the value side already had. @angular imports are dropped, so a drafted signature saying ActivatedRouteSnapshot named nothing.

    Repointing an import fixes the module, not the meaning — so new X() and x instanceof X on a declaration that became a FUNCTION (a resolver, pipe or directive) are now flagged where they stand, rather than left as code that resolves and cannot run.

    Measured on a real Angular component library: the migrated output went from not building at all to building, with the type errors in it down from 25 to 21 — the rest are the genuine "needs you" items the migration reports rather than guesses at.

  • weave migrate asks where the converted code should go. The output mirrors the source layout, so a whole Angular folder tree dropped into the root of an app that already had a src/ of its own. The destination is now prompted for — Enter keeps the root (exactly the previous behaviour), and a typed folder puts the whole tree under it. The symbol table follows the writer, so imports between the written files point where those files actually landed. A path that would escape src/ (absolute, a drive letter, a .. segment) is refused and re-asked rather than resolved.

  • weave migrate offers to install the packages the written code needs. It named the third-party packages the converted code still imports (lodash, @ngx-translate/core) and stopped there, so the first weave check after a migration was a wall of "cannot find module" with the real TODOs buried in it. The install command is now printed and offered — using your package manager (packageManager in package.json, then the lockfile) and pinning each package to the version the source app declared, read from the nearest package.json at or above the migrated folder. Offered rather than run, because installing writes package.json and the lockfile and goes to the network. @angular/* is never on the list: those imports come from files that were carried rather than converted, and installing Angular to make them resolve is the migration undone.

    dependencies or devDependencies is decided by how the package is imported. A package the converted code calls is a real dependency — it is in the bundle, and a runtime dependency parked in devDependencies vanishes under npm ci --omit=dev. A package reached only through import type is erased by TypeScript and never reaches the bundle, so it is installed with -D. Imported both ways counts as runtime, whichever file is read last. That is two commands, because they land in two places.

    The specs are checked before anything runs. Package names come from import specifiers in the code being migrated, so migrating a repository you did not write must not be able to run a command. Anything outside the npm-name-plus-range grammar stops the whole install and is named. (The first version passed an argument array alongside shell: true, which Node concatenates into a shell line without escaping — its own DEP0190 — under a comment claiming the opposite. The command is now a single validated string, and the decision to run is a pure function, so the gate for "this must not execute" is not itself the thing that executes it.)

  • weave migrate — six defects only RUNNING the migrated code could find. The type-check was clean and the app still threw, because each of these is valid TypeScript that means the wrong thing:

    • asReadonly() is Angular's read-only view of a writable signal; Weave has none, and the call survived to throw asReadonly is not a function on creation. Computed<T> is () => T, so it becomes computed(() => x()) — the same value, read-only, no judgement call.
    • A translated initializer that is already a signal was wrapped in a second one (signal(computed(…))).
    • A call's arguments were read as a TYPE position, so an Angular name in signal(foo(…)) became signal(any(…)). A type position is after :, inside <…> or in a union — not after ( or ,.
    • A @for alias (let last = $last) was dropped from the header and left standing in the body, so the loop rendered once and then last is not defined. It is now renamed inside its own block, brace-matched.
    • Imports are derived from the TEMPLATE too, not just the module — a name only the markup used (t(…) from a translate pipe) was never imported, and the component threw on first render.
    • A field holding a FUNCTION became a signal, so @if(showLink(…)) got a function back instead of calling it, and the branch never ran. An initializer that is a function value stays a plain const.
  • weave migrate guards a constructor body that reads a hole it declared itself. A method body waits to be called; a constructor body runs the moment the thing is created. So a dependency Weave does not provide — emitted as const _router = null as any with the TODO that says so — was not a compile error there but a crash at startup, or on the first navigation if the body registered a callback (Cannot read properties of null). The body is now emitted whole inside if (_router) { … }, named in its own TODO: the app runs, the hole is impossible to miss, and the guard falls away on its own once the dependency is real. Both spellings count, the injected field and the constructor parameter-property.

  • Fixed: an apostrophe in a comment switched the RxJS translation off for the rest of the file. The scanners tracked string literals so no rewrite could match across one, but not comments — so prose like // Router's calls were rewritten opened a string that never closed, and every declaration after it was invisible. The migration writes such comments itself; so does everybody's source.

    It reports what it cannot do. Every run prints how much of the source is genuinely converted versus merely carried across unchanged, names every declaration in each group, and says why. Anything without a faithful Weave equivalent is left in place with a TODO(weave migrate) comment instead of being guessed at, and a method's original body travels with it as comments beside the new signature rather than being discarded.

    There is now a MODEL of the migration, not a pipeline of independent rewrites. The mapping from every source declaration to what it becomes — new name, new file, whether it is a default export — is built once, for the whole unit, before any file is finished, and every emitted file's imports are resolved against it in a single pass. A component becomes a default export while its importers went on naming the class; a service becomes useX while its importer asked for XService; a pipe becomes a function its own consumer is told does not exist. Those were three patches for one problem. Now one place knows what everything became, and nothing written can name something that no longer exists.

    What the model deliberately does not cover is intent. It knows ShortenPipe is the function shorten in pipes/shorten.ts. It does not know what an Observable in that file ought to become — structure is knowable and is modelled exhaustively; meaning is a decision and stays with the reader.

    Three defects fell out of measuring the result rather than the parts: a member named form shadowed the form imported from @weave-framework/forms, so the drafted form({ … }) called the signal (reported ten lines away as "Expected 0 arguments, but got 1"); a field built from new FormGroup(…) was carried as live code naming a class that does not come across; and a dependency declared as a constructor parameter was never declared at all, so every call through it named nothing.

    A route resolver becomes a route loader. It carries no decorator, so it used to fall through as "plain TypeScript, carried as-is" — a file full of ActivatedRouteSnapshot moved unchanged, under a banner saying most of it already works. It does not work: nothing in Weave will ever call it. And that banner is fixed generally — a carried file that still imports from @angular now says NOT CONVERTED, because claiming otherwise about the framework being migrated away from is what makes a reader skip the file.

    A big unit migrates a section at a time. Past about twenty files the write prompt offers the top-level folders as sections rather than one list nobody reads. The mapping spans the whole unit either way — section two knows what section one renamed — and what a chosen section needs from one left behind is named outright, because otherwise the code lands not resolving and the reason was a decision made three prompts earlier.

    The output is verified as a WHOLE, before a byte is written. Every other check in the tool looks at one declaration at a time — the converter walks components, then services, then pipes, each in isolation, and the writer puts bytes on disk. Nothing ever looked at the result. So a rename that landed in one file and not in its importer, or two sources arriving at one output path, shipped silently and turned up later as "you migrated one place and left rubbish in another". The planned files are now type-checked together, in memory, against the target app's real node_modules, and the two causes are told apart because only one is the tool's fault: a module the app does not have is an install; anything else is a defect in the conversion. Two files planned for one path is reported without a compiler at all.

    The dependencies the migration hands your app are named. The plan says rxjs is replaced by Weave's reactivity, and then the converted files import it anyway — because a pipe(…) chain is not a rename, and a guessed rewrite is worse than an honest one left standing. Both halves are defensible; saying only the first was not. Before writing, every third-party package the converted output still imports is listed, with rxjs marked for what it is: what could not be translated without guessing.

    A service this run converts is not "unknown". A call into one used to print "has no recorded Weave equivalent — migrate it first" about a class being migrated in the same run: work already happening, about a call already correct. The converter knows what it is converting now — the injected field becomes a real binding (const svc = useBreadcrumbs(), or inject(XContext) for a scoped one), the import is repointed to what the converted file actually exports, and a service's own imports are carried at all, which they were not.

    The constructor's body is translated, like every other body. It was the one that was not: it came out as a TODO over a commented original while every other member was rewritten. In a store the factory body is the constructor, so what ran on creation runs there. router.events.pipe(filter(e => e instanceof NavigationEnd), takeUntilDestroyed()).subscribe(cb) becomes onDispose(afterEach(cb)) — both operators are what afterEach already is. Any other event type, any other operator, and a callback parameter are reported, not assumed.

    Dead imports are dropped. A translated body no longer calls what it replaced, so the RxJS chain that became afterEach left import { filter } from 'rxjs/operators' behind — a dependency the target app has no reason to take on, for a name that is gone. A name surviving only inside the carried original is not a use; an import the template needs is.

    The host element migrates too. @HostBinding, @HostListener and the decorator's own host: { … } map all describe the element Angular put the component's selector on; Weave has no such element, so they land on the template's single root: class.xclass:x, style.width.pxstyle:width with the unit kept, attr.role → the attribute, a bare target → the DOM property, and a listener → on:. A window:/document: listener is not an element binding at all — it becomes an onMount subscription with its removal as the cleanup. When the template has no single root there is no honest place for any of it, so the whole set is reported rather than attached to an arbitrary element.

    Granting access is not "take the whole library". A library entry is a barrel of export *, so analysing one from its entry reaches every file in it — an import of ONE interface migrated two hundred, which is the complaint this whole feature was built to answer, arriving back through the door it opened. The files that DECLARE the wanted names are the roots now, and the walk runs from those. A name found nowhere in the unit falls back to the whole unit, never to nothing.

    Nothing is written to src/index.ts. That is where a Weave app keeps its HTML shell (src/index.html), and a .ts beside a .html is a component — so carrying a library's barrel there turned the shell into a component template, and weave check began reporting parse errors inside the doctype. The barrel is still carried, under a name that collides with nothing.

    A mixed attribute value becomes one expression. Angular interpolates inside an attribute; Weave does not — its dynamic form is the whole value or nothing. class="logo-{{ name }}-svg" was passed through and rendered the braces onto the element as literal text: visible in a browser, invisible to every string assertion.

    It asks for what it cannot see. A method calls a method calls a method, and some of those live in a workspace library reached through a tsconfig alias, or in an injected class with no definition in this unit. Neither default is right: following every library turns one imported type into hundreds of files, following none migrates a service the app leans on as a name and nothing else. So each is asked for by name, with what is at stake shown — grant it and the analysis goes in, folds the unit in, recomputes coverage over the combined source, and asks again, because opening one thing reveals the next; refuse and its calls arrive as TODOs with the original code beside them. Both answers are recorded in the plan, because "you chose not to show me this" and "this wasn't there" are different answers. A granted unit's output lands under its own folder, so it cannot land on top of the app's own files. Angular's own injectables are never asked about — they have a recorded answer, and asking where Router lives is nonsense.

    A @Directive converts too, rather than arriving commented out. Its fields and getters become signals and computeds, its @Inputs become the action's one argument — held in a signal, so update re-running the bindings actually works — and its host declarations become real work on the element the action is handed: effects for the bindings, addEventListener for the listeners, and a destroy that removes every one of them. this.el.nativeElement becomes the element, since being handed it is what ElementRef was for.

    A camelCase CSS name is kebab-cased wherever it appears — [style.backgroundColor] and @HostBinding('style.outlineWidth.px') alike. Angular normalizes the name; Weave passes it to setProperty, which only knows background-color, so the binding compiled, ran, and set nothing.

    Router.navigate([…]) takes an array of commands; Weave's navigate takes the path. That is the only difference, so a small local shim joins the commands and nothing more — navigateByUrl already takes a path and is navigate outright. Angular's promise is not reproduced: navigation is synchronous in Weave, so a .then(…) that followed the call is unwrapped into the statements after it, which is what the code meant. A callback parameter (Angular's success boolean) is bound to true with a note that a guard cancelling is the one case Weave does not report. A .then() used as a value, or a .catch(), is reported rather than guessed.

    A drafted function's return type is the source's, not : void on everything — a method ending in return false; was a type error, and a type the source never stated is not one to invent. A dependency whose calls were rewritten is no longer also listed as work still to do, and the decorator's imports: [ … ] is accounted for in the output instead of read past.

    A field carries what it held: count = 0 becomes signal<number>(0), not signal<unknown>(undefined). A template reading it calls it, since {{ label }} in Weave renders the function rather than its value. And a prop with a default is not optional in the setup signature — propDefaults guarantees it a value there; optionality is for the parent, which is what propDefaults already states.

Added — UI

  • <Table headerRow> — a second header row, for per-column filters. Rendered inside <thead> directly under the column headers, one cell per visible column ((col) => Node | null; null for a column that gets no control). Each cell inherits its header's width, alignment and sticky treatment, and pins under the header when the body scrolls; the synthetic expand/select columns get empty cells. It has to live in <thead>: a filter row rendered as a sibling above the table only lines up while every column has an explicit width, and drifts the moment one auto-sizes — so there was no version of this a consumer could build from outside.

  • <Table virtual> — a virtual body. Renders only the rows in view plus overscan, on the CDK's existing virtualScroll engine, with spacer rows keeping the native scrollbar honest. First render of a large page is what this removes: the cost is linear in cells (~15 µs each — a 1000×20 grid measured at 482 ms of build, 851 ms laid out), while a viewport holds 20–40 rows however long the data is. Re-sorting was already cheap, because trackBy moves existing DOM.

    It needs maxHeight and a uniform row height (rowHeight, default 34); overscan defaults to 6. expandable is refused in combination, and so is a missing maxHeight — both would leave the window showing the wrong rows rather than merely looking wrong, so they are reported at setup. Selection, select-all and the empty state still read the whole data set; the table carries aria-rowcount and each row its true aria-rowindex, so a reader is told what it cannot see.

Fixed — reactivity

  • A signal written during an effect no longer drains the queue on top of that effect. flush() guarded only on batchDepth, so a write that happened while the queue was draining started a second drain nested inside the effect still running. Writing a signal from an effect is ordinary — ref={{ el }} alone does it, on every component that takes a ref — so a long list of such components nested one stack frame per item and ended in RangeError: Maximum call stack size exceeded, with the render abandoned mid-list and the DOM left half-updated (rows still carrying a column the render was in the middle of removing). Reported against a <Table selectable expandable resizableColumns> at a few hundred rows, where a column-set change tipped it over.

    batch could not help and was measured not to: it decrements batchDepth before flushing — precisely so the queued effects run unbatched — so every write inside them saw depth 0 and recursed anyway. flush now refuses re-entry and lets the outer loop pick the work up. Nothing is deferred past the outermost write: queue is a Set, whose iteration visits entries added during the iteration, so an effect queued mid-drain still runs before set returns.

Fixed — UI (CDK)

  • An open overlay now follows its trigger when the page rearranges under a still window. connectedPosition's live listeners were scroll and resize on window — both of which see the window moving and neither of which fires when a container changes size on its own. So a Select listbox, Menu, Autocomplete panel or Tooltip stayed exactly where it was placed while its trigger slid out from under it: a splitter dragged, a sidebar collapsing, a drawer animating open, a flex reflow after content changed. The strategy now also observes the origin, its containing block and the panel with a ResizeObserver, and disconnects it in dispose() alongside the two listeners. (An overlay.ts comment had claimed this observer existed since the detach-leak fix — it did not; that is now true.)

Fixed — CLI

  • A component library may declare a weave.config.ts. Resolving a config rejected one that declared neither root nor entry, so a package that ships components but has no app of its own could not declare styleLang anywhere supported — and weave check failed on it outright. styleLang is not inferable: the loader pairs a component with <base>.<styleLang> and does not probe, while a component with no stylesheet is perfectly legal, so a wrong value ships unstyled components in silence. Such a config now loads and weave check runs; weave build/weave dev still require an entry and now say so themselves, naming both fields, instead of handing esbuild an undefined entry point.

  • weave build no longer fails when publicDir is left at its default, and no longer ships your project directory when it is. publicDir defaults to the config's own directory and outDir to dist inside it, so the copy step asked Node to copy a directory into itself and got a raw EINVAL … cannot copy … to a subdirectory of self — an app that simply omitted publicDir, the documented default, failed its first build with what read like a filesystem fault. Two changes: the output directory is now skipped when copying a static root (at any depth, so publicDir: 'public' with outDir: 'public/dist' works), and a build copies a static root only when the config declares one — copying an undeclared project directory into dist/ would have shipped src/, node_modules/, the config and any .env. weave dev still serves the config directory by default, unchanged.

Fixed — security / tooling

  • brace-expansion is pinned past CVE-2026-14257 (GHSA-mh99-v99m-4gvg — unbounded expansion → an OOM crash; <= 5.0.7). The workspace already pinned the 3.x–5.x line for an earlier ReDoS, but this advisory has no lower bound, so the 2.x copy was in range too and the old >=5.0.7 target would have let a future resolution land back on a vulnerable version. The pin is now >=5.0.8 over <=5.0.7.

    The 2.x copy could not simply be overridden — v2 exports the function itself (module.exports = expand) while v5 exports { expand }, so its consumer would have called an object. It arrived via vscode-languageclient@9 → minimatch@5, so the VS Code extension moved to vscode-languageclient@10 (minimatch 10 → brace-expansion 5.0.8), which raises the extension's floor to VS Code ^1.91. The client surface it uses is unchanged.

  • The migration plan's table cells escape backslashes, not only pipes. A value carrying a backslash immediately before a pipe — a Windows path, a note quoting source — was emitted as a\\|b, which a markdown reader resolves as a literal backslash followed by a live pipe: the row gained a column at exactly the value that was supposed to be protected. Embedded newlines (which end a row outright) now collapse to a space.

  • A weave migrate gate stopped accepting the wrong symbol. The check that every name in the converter's API map is really exported built its word boundary inside a template literal, where \w is string-escaped to a plain w — so it asserted "not adjacent to the letter w" instead. Measured: an index exporting restore but not store satisfied it.

Fixed — compiler

  • A comment between the pieces of a split template/styles no longer fails the build. A long template is routinely split across lines with + (every @weave-framework/ui component does it), and annotating one of those lines is the next thing an author reaches for — but the extractor's + scan saw the / and reported weave: `template`/`styles` must be a static string about a template that is entirely static, which reads as the compiler being wrong rather than the code. Comments are now skipped as trivia (// and /* … */, before the first literal and around every +), exactly like whitespace. The declaration is still blanked out of the emitted script with its newlines preserved, so weave check line:col mapping is unchanged — and a genuinely non-static join ('<div>' + title) is still refused, loudly.

Fixed — types

  • A component's synthesized default export is typed => Node, not => unknown. The runtime has always said so — Component = (props?, slots?) => Node; an instance returns its DOM — but both places that synthesize the declaration (weave check / the editor plugin, and the built @weave-framework/ui .d.ts) said unknown. Every imperative call site therefore needed a cast, which is most of the composition surface: a <Table> column's cell: (row) => Node | string, an <Expansion> panel body, any API taking a Node. Slots narrow the same way (() => Node). Pure narrowing — no runtime change.

Fixed — build tooling

  • The @weave-framework/ui publish build no longer breaks on a component that declares propDefaults or extend. The step that gives each built component a props-typed default export reconstructed the tail it expected (export default defineComponent(render, setup);) and threw on anything else — but the compiler emits a third argument for propDefaults (shipped since 1.5.17) and an extendSetup(…) wrapper for RFC 0008 extension components. The first ui component to use either would have failed the publish build with an error blaming the compiler. It now reuses whatever the compiler emitted, verbatim.

2.1.0 — 2026-07-24

Added — UI

  • infiniteScroll — a load-more sentinel in the CDK (@weave-framework/ui/cdk, FW-20). The scroll family covered overlay strategies (scroll), windowing data you already have (virtual-scroll) and resize/mutation signals (observers); when to ask for the next page was missing, and it is the paging model every cursor-based list uses. Headless — no markup, spinner or empty state:

    <div use:infiniteScroll={{ { hasMore: () => list.hasMore(),
                                 loading: () => list.loading(),
                                 onLoad:  () => list.loadMore() } }}></div>
    
    • A short page chains. loading's true → false edge re-evaluates, so when a page too small to fill the viewport arrives the next one is requested immediately. This is the bug hand-rolled sentinels hit: the observer never re-fires because the sentinel never left the screen, and the list stops after page 1 on a tall display.
    • Never overlaps a request in flight, idles while hasMore is false and re-arms when it flips back (a filter reset), disconnects on owner disposal, and re-observes through the action's update when root/rootMargin change.
    • Works in the viewport or inside any scroll container (root), with rootMargin (default 150px) deciding how early it fires. Composes with virtualScroll rather than competing: that one decides what to render, this one when to fetch.
    • Not included: the data-side helper. cursorList() over resource would belong in @weave-framework/data and is a separate ask; this is the DOM half and stands alone.

Fixed — skills

  • weave-ui and weave-data documented two components that do not exist. Both taught InfiniteScroll, and weave-ui also CursorList, as the way to build a cursor-paged list. Neither has ever existed in the codebase, so an agent following either skill wrote against an API it could not import — the same silent-drift failure class the skills sync gate was built for, except here the source of truth itself was wrong. Both now point at use:infiniteScroll (above) and say plainly that it is headless and composes with virtualScroll.
  • App-wide defaults for the date/time pickersprovideDateTimeDefaults() from @weave-framework/ui (FW-19). <Datepicker>, <DateRangePicker> and <Timepicker> were configured per instance: adapter, locale, first day of week, display format, translated chrome, 12/24h, minute step. Every one of those is an application decision, so a field that omitted any of them was wrong — a date in a format used nowhere else, or English chrome inside a translated UI. In practice none of the props was optional, and the only way to supply them all was a wrapper component per picker holding nothing but config.
    • Provide once at the app root; each picker reads the context whenever its own prop is absent:

      provideDateTimeDefaults({
        locale: () => locale(),                        // getters — a settings change flows through
        firstDayOfWeek: () => weekStartIndex(),
        displayFormat: () => ({ dateStyle: 'medium' }),
        datepickerLabels: () => ({ prevMonth: t('datepicker.prevMonth'), clear: t('common.clear') }),
        timepicker: () => ({ use24: timeFormat() === '24h', step: timeStep() }),
      });
      
    • Resolution is always instance prop → context → the component's built-in, so a single field can still opt out of any one default.

    • Getters, read at each use rather than captured at setup, so a language or settings change reaches a mounted field. A plain value is accepted and simply never changes. This made Timepicker's use24/step and both date pickers' adapter reactive — they were consts resolved once at setup, so a settings change never reached an open field.

    • Labels merge shallowly at each step: a partial object never blanks the keys it omits.

    • Fully backward compatible — an app that provides nothing behaves exactly as before (Monday start, English labels, runtime locale). Nothing about the calendar engine, overlay, keyboard or ARIA changes.

    • Deliberately not included: an ISO-string binding mode (valueFormat: 'iso'). The request marked it a nice-to-have; the defaults context is what forced the wrappers to exist.

  • openDialog / openBottomSheet can host a live component (FW-18). A region (content / header / actions) now accepts a [Component, props?] tuple — or the component(Comp, props) helper — and mounts it under its own owner, so the component's onMount, effects and onDispose run, and disposes that owner when the dialog closes. A form-in-a-dialog (the shape every editor is) is now a plain component opened straight through openDialog, with no per-app mount/dispose adapter.
    • Before, content was Node | string | (() => Node). A component factory type-checked as () => Node, so it compiled — but toNode called it bare, outside any owner and never disposed: reactivity inside the dialog was dead and its graph leaked on close. That case now has a real lifecycle; the tuple is told apart from a factory by being an array.
    • Backward compatible. A Node, a string and a bare () => Node factory behave exactly as before (the factory is still called once, without an owner). Only the new tuple form is owner-aware. The overlay, positioning, focus-trap, Esc and scroll-lock are untouched.

Fixed — UI

  • use:mask now works on <Input>, not just a bare <input> (FW-17). use: on a component forwards to its root element; <Input>'s root is the <div class="weave-input"> wrapper, so the mask landed on the div. mask.ts cast el straight to HTMLInputElement and drove .selectionStart / .value on it — undefined on a <div> — so the field simply never masked, with no error. The skill's own <Input use:mask> example did not work.
    • mask now resolves the control: when the element it is handed is not itself an <input> / <textarea>, it binds the first one inside it. A wrapper containing neither is a misuse and throws, rather than silently doing nothing. <Input> is untouched — the fix keeps mask the single place that knows how to drive a text control.
    • Covered by cdk tests (a hand-built wrapper) and an integration test that mounts a real <Input> and forwards use:mask the way the compiler does — confirming Input's own .value binding and on:input do not fight the mask. Both proven to fail against the pre-fix cast.

Fixed — CLI

  • A proxied long-lived stream no longer kills weave dev. The dev proxy's error handler wrote a 502 unconditionally. That is right when the backend was unreachable and nothing has been sent — and fatal for an SSE stream, whose head went out the moment the backend responded: when the upstream socket later drops (the normal end of a long-lived stream, not an exception) the handler called res.writeHead a second time, Node threw ERR_HTTP_HEADERS_SENT from inside an event handler, and the unhandled throw took the whole dev server down. A notification stream reconnecting cost the developer their UI server.
    • The handler now distinguishes the two cases by res.headersSent, an upstream response that fails after its headers is handled rather than left to throw, and a client navigating away destroys the upstream request instead of leaving a socket nobody reads.
    • Found via a real consumer, which had worked around it by bypassing the proxy for SSE — so the proxied path stopped being exercised at all.
    • Covered by verify:dev-proxy, which now also drives a stream that is RESET mid-flight. The first version of that test used a clean socket.destroy() and passed against the unfixed proxy: a clean close ends the piped response with no error event, so the error path never ran. It needed an RST to be a test at all.

Added — UI

  • Input masking — @weave-framework/ui/mask (RFC 0010). A headless CDK primitive that formats a text input as the user types, against a template the caller writes: use:mask={{ { value: phone, template: '(999) 999-9999' } }}. Tokens are 9 (digit), a (letter), * (either) and \ (escape); the alphabet is extensible via tokens, and redefining a builtin throws rather than silently changing what a template means. Ships compileMask (the pure, DOM-free core) and matchesMask, an ordinary (value) => string | null validator for @weave-framework/forms.
    • The caller's signal holds the model value — typed characters only, never the formatted display — so what is submitted never depends on the mask.
    • Caret behaviour is the substance, not a detail: typing steps over literals, backspace across a separator removes a data character rather than appearing to do nothing, a paste is re-masked, a rejected character leaves the caret where it was, and an IME composition is left alone until it commits.
    • The mask owns the element's value channel, so it is not combined with use:control on the same element; bind the field's own signal instead.
    • <Input> is unchanged, and @weave-framework/ui still depends only on @weave-framework/runtime.
  • A numeric mask mode, for amountsuse:mask={{ { value: price, numeric: { decimals: 2, decimalSeparator: ',', groupSeparator: '.' } } }}. Digits fill from the right, so 1,0,5,0 reads 0,010,101,0510,50, the integer part is unbounded, and grouping is inserted as you type. template and numeric are mutually exclusive; passing both throws.
    • Why a second mode rather than a template. A positional template's 9 count is its width, fixed at compile time, and it fills left-to-right. Measured on the first release: 234569871.36 typed into '999.99' became 234.56 and reported complete: true, and entering one cent into '999,99' required typing 00001. Widening the template makes it worse — 123.58 into '999999999.99' renders 12358____.__. No template width is right for a value whose width is not known in advance.
    • The model is a canonical decimal string ('10.50') — always a ., never grouped, never carrying the prefix/suffix. This widens the value contract deliberately: in positional mode the model is the characters the user supplied, while here its . is one the user never typed. An amount travels as a decimal string end-to-end, so a model carrying the display's comma would have to be un-formatted at every call site.
    • Empty stays empty ('', not '0.00') — "no price set" and "free" are different states. A typed 0 is a value and yields '0.00'.
    • Separators are props; the ambient locale is never read — no navigator.language, no Intl default. The displayed format belongs to the organization, not the viewer. Grouping is inserted directly rather than through Intl.NumberFormat, which always wants a locale and in some of them emits non-ASCII digits.
    • maxIntegerDigits refuses a digit past the bound and leaves the caret alone rather than dropping it — silent truncation is the defect above, and it does not survive into this mode. Excess precision on a programmatic value is truncated, not rounded ('10.567'10.56): a mask is not an arithmetic layer.
    • decimals: 0 gives a grouped integer field. Completeness does not apply to a variable-width value, so matchesMask stays positional-only and bounds are ordinary validators.
    • Pulled by the dogfooding consumer's price field (FW-16); RFC 0010 records why its first draft cut this mode and why that argument does not survive contact with the problem.

Changed — resume/adopt internals

  • The adopt (resume) DOM navigation is now one sequential cursor walk. It replaces the absolute build-time child-index math — child(_r, …) plus dynamic-text index shifts and post-block after()/offset rebasing — with an AdoptCursor that mirrors the render walk one node at a time (here/step/enter/exit/text/block). A post-block sibling is reached by stepping from the block's ], so position-dependent special cases are gone. No public API or template-behaviour change; the eager (non-resumable) output is byte-for-byte identical, and the client-only SPA is unaffected (0 bytes). Two observable effects, recorded honestly:
    • A @let after a control-flow block now resumes instead of client-rendering the whole fragment — the old navigation could not reach a binding past a block through a @let, so it fell back to CSR.
    • A resumed page carries ~0.9 KB more gzipped JS: the navigation logic now lives in the runtime (shipped once) rather than as compile-time index math. A deliberate one-time cost for the simpler, robust walk; it should fall when per-island code-splitting lands.

2.0.1 — 2026-07-19

Fixed — scaffold

  • npm create weave installs the current major. The scaffold template caret-pinned every @weave-framework/* dependency at ^1.0.0, and a caret does not cross a major — so a project created after 2.0.0 shipped still installed 1.8.0, missing every fix in that release including both security fixes. The ranges track the release major now, and verify:template-ranges fails the build if they drift again. Caught by the post-publish end-to-end scaffold; neither the dry run (which packs but does not resolve) nor the browser suite (whose bundler resolves workspace paths directly) can see this.

2.0.0 — 2026-07-19

Why this is a MAJOR and not a patch. Almost everything here is a bug fix, and most of it moves behaviour toward what was always documented. But four changes make existing code behave differently without being edited, and VERSIONING.md is explicit that a changed default behaviour is a major:

  • A reactive update aimed at a running effect is no longer discarded. A mutually-writing effect pair that has no fixed point used to terminate quietly — because the update was dropped. It now converges if it can, and throws after 100 passes if it cannot. If you relied on such a pair settling on an arbitrary value, it will now report instead.
  • @await rebuilds its @then subtree when the awaited value changes. It previously kept rendering the old value; DOM state inside that branch is now reset on a data change, where before it persisted (while showing stale data).
  • store() factories run in their own root. Effects created inside a store used to die with the first component that used it. They now live for the app's lifetime — so effects that silently stopped will start running again.
  • The Prettier plugin's output changed in two places (an explicitly empty attribute is no longer printed bare; whitespace between inline elements is preserved). A --check step in CI will flag files formatted by an older version until they are reformatted.

Nothing was removed or renamed, and no signature changed: code that compiles against 1.x still compiles.

This release also closes a full external audit (24 defects) and every open GitHub code-scanning alert (11), including two security fixes: a stored XSS in SSG document generation, and a code-injection path in the compiler's own emitted output.

Fixed — compiler (security hardening)

  • Emitted code escapes sequences that can break out of a JavaScript string literal. The compiler builds JS source by interpolating template text into string literals, and quoted it with JSON.stringify — which is correct for JSON but leaves two things raw that JavaScript source cannot carry. A template value containing a closing script tag came through verbatim, so the generated module terminated any script block it was inlined into; and U+2028/U+2029 stayed raw, which is legal in a modern JS string but is still a line terminator to plenty of tooling. Only the slash of a closing tag is escaped, so ordinary markup in the hoisted template stays readable. Three emit sites that bypassed the quoting helper now use it.

Fixed — reactivity (behaviour change)

  • An invalidation arriving while an effect runs is no longer discarded. A running computation is DIRTY for its whole execution and markDirty returned early on an already-DIRTY node, so an update aimed at an effect mid-run was dropped: the effect finished on a value that was already stale, went CLEAN and left the queue. Nothing threw and nothing looped — it was simply one update behind, permanently, and every later run landed one behind again. The invalidation is now recorded and the computation re-runs until it settles.

    This changes a documented behaviour. Mutual effect writes previously "settled" precisely BECAUSE the update was dropped — a pair like y = x + 1 / x = y + 1 has no fixed point, so settling meant stopping at whatever value the lost update happened to leave. A pair that can converge now does; one that cannot throws (after 100 passes) naming the cause, instead of silently producing an arbitrary answer. If you relied on a divergent effect pair terminating quietly, it will now report.

Fixed — compiler

  • An arrow parameter shadows a component binding only inside its own body. Parameters were collected into one flat set applied to the ENTIRE expression, so a binding sharing a name with any arrow parameter anywhere was left unrewritten everywhere: items().map((x) => x * 2).length + x emitted a trailing bare x — a ReferenceError at runtime, or a silent read of a same-named global. Shadowing is now lexical, from the parameter list to the end of the body, in both the rewrite and the inference pass (they must agree, or a name inference drops is one the emit would have prefixed).

Fixed — runtime

  • @await re-renders when the resource's data changes without a loading bounce. The then-branch was rebuilt only when the await STATE changed, and resource.mutate(next) — the documented optimistic-update path — writes data while leaving loading false. So the state stayed then, the write was a no-op by equality, and the rendered branch went on showing the previous value with no error anywhere. Refetches survived only because they bounce through pending first, a different transition. Two documented APIs were silently incompatible. The branch now tracks the value.

    The whole @then subtree is rebuilt rather than patched in place. That is deliberate: the alias is a plain function parameter by contract, not an accessor, so nothing inside the branch can track the value. Making it an accessor would be the fine-grained fix and a breaking change to every existing @then body, which the frozen API does not allow.

  • An awaited value that IS a function is stored, not invoked. value.set(data) handed a raw value to Signal.set, which reads any function argument as an updater — so a resource or promise resolving to a function was called with the previous value. Same class as the field.reset/route-loader fix above.

Fixed — prettier plugin

  • Formatting no longer changes rendered whitespace between inline elements. A body made only of elements took the block layout, which drops whitespace-only text nodes and rejoins children with a newline and indent — and HTML collapses that back to a single space. So <span><b>a</b><b>b</b></span> ("ab") became "a b", and a document that had a space became indistinguishable from one that did not. Elements whose children are all inline-level now keep the inline layout, where whitespace is preserved as written. Bodies of block-level elements are still reflowed onto their own lines.

Fixed — weave check

  • weave check honours the project's tsconfig.json. It used a hardcoded option set with no paths and no baseUrl, so any app with path aliases — the norm in a real codebase, and universal in one being migrated from another framework — got "Cannot find module" on every aliased import. The project config is now discovered and parsed, with only the checker's own invariants overlaid (noEmit, skipLibCheck, and a DOM-capable lib floor when the project sets none). Everything else — paths, baseUrl, strict, types, lib — belongs to the project, since disagreeing with its tsconfig means disagreeing with the editor. A missing or malformed config falls back to the previous defaults rather than failing.

Added — repository safety

  • A tracked gate keeps private working files out of the public repository. The public/private split was enforced entirely by .git/info/exclude and a local .git/hooks/pre-commit — git versions and clones neither, so the whole boundary rested on one manual restore step being remembered on every machine. One miss and git add . stages the private working notes into public history, where a push is permanent. verify:no-private reads the tracked file list in CI and fails if any private path appears. The path names themselves reveal nothing, which is what makes the check safe to run in public.

Fixed — release engineering

  • Publishing is gated on a green suite. The publish workflow was conditioned only on the [publish] marker: a marked commit with failing tests shipped to npm — with a provenance attestation — while CI went red minutes later in a parallel workflow, and an npm version is permanent. Typecheck, lint and the browser suite now run inside the publish job, before anything is uploaded.
  • A partial publish can be resumed. The failure message promised that "already-published packages will be skipped by npm"; they are not — publishing over an existing version fails with E403. So a run that died on package 9 of 16 died again on package 1 when re-run, stranding a half-published lockstep release. Each package is now checked against the registry first and skipped if present, so a re-run resumes where it stopped, and the message says what actually happens.
  • editor/vscode is a workspace member again. It declares @weave-framework/language-server: workspace:* but was not listed in pnpm-workspace.yaml, so a fresh clone could not link it and installing from the extension directory rejects workspace:* outright. It packaged only because a stale node_modules survived locally; the hash gate checks the shipped artifact, not that it still builds.

Fixed — CLI / dev loop

  • A failed rebuild no longer reloads the browser into a white page. weave dev cleared its in-memory outputs, repopulated them from a failed build's empty output list, and notified every client anyway — so a syntax error reloaded the page into a /main.js that no longer existed, and the real error was visible only in the terminal. The last good bundle is now kept, and the client is sent the build error instead, painted as an overlay over the still-working page. The next successful build swaps the bundle, reloads and clears the overlay. Gated by verify:dev-overlay, which drives the real dev server through break-and-repair.

Added — CLI

  • Source maps in weave dev and weave build. Neither emitted any, so breakpoints and stack traces landed in bundled output with no way back to the author's .ts/.html. Dev emits inline maps (it serves from memory); production emits linked .js.map files, opt-out via build.sourcemap: false.

Fixed — forms + router

  • fieldArray.dirty() sees compensating edits. It compared the item COUNT against the seeds and asked each item whether it differed from its own initial — never the array value against the seed values. So removeAt(0) followed by push(...) read clean, as did a pure reorder: same length, every item pristine against the value it was constructed with. dirty is the documented unsaved-changes signal and feeds router leave-guards, so the prompt was never raised and the edit was lost without warning.
  • A function-valued signal write stores the function. Signal.set treats any function argument as an updater (prev) => next, so two places that wrote a raw value invoked it instead: field.reset() called a function-valued initial and stored the result, and a route loader resolving to a function (a component, a factory, a formatter) had it called with the previous data. Both now write through set(() => value), which resource.mutate and the forms submit path already did.

Fixed — UI

  • Layered modals released out of order no longer strand the page unscrollable. blockScroll snapshotted body.style.overflow per instance, so opening A then B and closing A FIRST restored the pre-A value while B was still open (the page scrolled behind the modal), and closing B then restored hidden with nothing open — leaving the page permanently unscrollable short of a reload. The lock is now reference-counted: captured on the first acquire, restored on the last release, order-independent.
  • A Table column revealed after mount gets a working pointer drag. The resize grips were attached once in onMount by a single query, but the header is a keyed @for over reactive columns — so a column unhidden or appended later produced a grip whose keyboard resize worked (a template binding, re-applied per element) while pointer drag silently did not. Columns arriving asynchronously got no drag at all. Attachment now re-runs whenever the column set changes, destroying the previous handles first.

Fixed — router

  • A malformed percent-escape in the URL no longer blanks the app. decodeURIComponent throws URIError on a lone %, and it ran unguarded inside the route-resolution computed — so the throw escaped through matched()/params(), killed the RouterView effect and emptied the page instead of falling back to *. An undecodable segment is now passed through raw. Any user-controlled URL is an input, including one a mis-built link produced.
  • A guard redirect replaces its history entry instead of pushing one. Visiting a guarded /admin that redirects to /login left both entries in history: Back returned to /admin, the guard fired again and pushed /login a second time, so the user could never navigate back out.
  • A guard-vetoed pop rolls back by the distance actually travelled. The rollback was hardcoded to one entry while a pop can jump any distance (a history dropdown, a long-press back), so vetoing a three-entry jump left the URL on an intermediate entry with the previous page still rendered. Relatedly, the current history position is now seeded from history.state at startup: pushState state survives a reload, so after a refresh mid-history the position reset to 0 and every direction test was wrong — a Back read as a Forward, and a vetoed pop rolled further back instead of returning.

Fixed — compiler

  • @for ... track is keyed by the row, not by a same-named component binding. The key function is (item, $index) => <track>, so track must resolve against the row parameter — but it was rewritten against the PARENT scope. When a setup binding shared the loop variable name, the parameter stopped shadowing and every row keyed on the same ctx.<name>: one constant key for the whole list, so keyed reconciliation reused the wrong nodes, state bled between rows and removals collapsed, all silently. The inference pass already scoped track correctly; this was the codegen half disagreeing with the compiler own scope model.

    The trigger is narrower than it looks: the name must ALSO be referenced outside the loop, since a name used only as a loop variable never enters the inferred component scope in the first place.

Fixed — runtime (security)

  • renderDocument escapes what it interpolates (stored XSS in SSG output). The page title, lang and the client-entry URL were written into the document raw. A title is routinely DERIVED FROM DATA — a route-title effect reading a CMS record, a product name, a username — so a title containing a closing title tag followed by a script tag was executable markup baked into every statically generated page, and it persisted there. The snapshot JSON was already escaped; the document own interpolations were not. Title is now HTML-escaped, lang and entry attribute-escaped. head stays raw — injecting markup is that option documented purpose, and it takes author markup, not data.

Fixed — store

  • A store no longer dies with the component that happened to use it first. store(factory) is an app-lifetime singleton, but the factory ran synchronously under whatever owner was ambient at the FIRST call — the first consuming component. Every effect/watch/computed created inside registered its disposer there, so unmounting that one component permanently killed the store's own reactions while every other consumer went on holding the same, now half-dead, instance. The failure is silent and depends on mount order: the signals keep working, so the store still looks alive. optimistic() uses watch internally, which makes a store-created optimistic exactly this case — its overlay would never clear again. The factory now runs in its own root(), which is what a global store's lifetime always meant.

    @weave-framework/store consequently depends on @weave-framework/runtime (for root), as forms/router/data/i18n already do. Still zero third-party dependencies.

Fixed — prettier plugin

  • Formatting no longer rewrites an explicitly empty attribute into a bare one. disabled="" was printed as disabled, and the two are not interchangeable: the parser marks a valueless attribute bare, and on a component tag that becomes the boolean prop true while disabled="" is the string "". One formatting pass therefore changed a child component's prop type and value — a formatter silently editing the program, which is the one thing a formatter may never do. The printer now branches on the bare flag the parser actually sets, instead of inferring it from an empty value.

    The package's own "no semantic change" gate compares normalized ASTs before and after formatting and would have caught this, but no fixture contained an empty attribute — so the case was never presented to it. A fixture now covers both forms, and it fails without the fix.

Fixed — compiler + runtime

  • A destructured handler parameter no longer kills the handler on resume. Deciding whether a setup binding can be rebuilt after resume meant asking which names its body reads that the client will not have. That answer was assembled lexically, and a destructuring pattern defeated it: ({ id, label }) => … was reported as reading its own id, and ({ a: { b } }) => … its own b. A binding blamed for a name it does not read is refused, and a refused handler falls back to a ctx.<name> that resume never reconstructs — so the control was inert after resume, silently. The analysis now runs on the TypeScript AST, where a parameter pattern binds exactly the names it binds and a type annotation references nothing. TypeScript is injected by the CLI, never imported by the compiler (an optional peer dependency), so the compiler keeps its zero-dependency install and nothing new reaches the browser bundles.
  • Listener modifiers no longer change meaning between build targets. In a resumable build (--ssg + ssg.resume), once/capture/passive were dropped with only a code comment saying so, so on:click|once fired on EVERY click while the same template on the eager target fired one. once is now carried through the delegated dispatch (the runtime removes that event's marker after the first invoke). capture and passive cannot be expressed by one delegated listener per event type — capture needs its own capture-phase listener and passive is a property of the listener REGISTRATION, which delegation shares — so a component using either now refuses adoption and client-renders, where the eager path applies them correctly. Silent divergence is gone either way.
  • A server/client DOM mismatch is no longer silent. When adoptText cannot find the server text node it was compiled to adopt, it still recreates one — a mismatch should not blank a page over one binding — but it now warns once per page. A mismatch means the adopt walk disagreed with the DOM the server wrote, which is the failure this subsystem hides best: resume on the documentation site was dead for an unknown period and nothing said a word. Resilient AND audible.

Changed — ui

  • A typo in overrides() now warns. overrides('button', (backgrond: red)) emitted --weave-button-backgrond — a perfectly valid custom property that no rule reads, so nothing happened and nothing said why. Unknown keys are checked against the component's real token schema (the $_builtins registry the engine already had) and warned, not rejected: adding a token is legitimate, and a component you define()d yourself has no builtin schema, so it never warns.

Changed — runtime

  • @for writes one $count per block instead of one per row. The value is identical for every row, so a 1000-row list was doing 1000 writes and 1000 equality checks per reconcile for a number that changes at most once — a third of the refresh loop. Also adds the first direct test of $count, covering shrink as well as growth (only $last was exercised before, and only while growing).

Added — tests

  • packages/ui/src/shared/ has tests. 760 lines shared by up to seven components had zero direct coverage — only whatever their consumers happened to exercise, so a defect in the engine surfaced as "the datepicker behaves oddly" and every fix had to be verified twice. 25 tests added: the calendar engine on the boundaries where date arithmetic breaks (month ends, leap day, year rollover, min/max, dateFilter, roving focus, the 24-year page), the option model's fallback chain, and the position table's flip invariants. Mutation-checked — each deliberately broken behaviour fails its own test.

1.8.0 — 2026-07-19

Fixed — forms docs

  • The submit page described an implementation that no longer exists. /learn/forms said validateAsync() is "a bounded poll (~30 ms ticks, capped at ~2 s)". It watches validating() flip to false with an effect — no polling, no timeout, no chance of resolving mid-validation. The source comment says so outright; the docs were describing a replaced version.
  • The fieldArray JSDoc example did not type-check. A group-returning factory with ['Write tests'] makes the item type both string and {text,done} (TS2322, verified). That example shows up in editor tooltips and the generated API reference. Seeds now mirror the group shape. Also: the module JSDoc named the aggregate member values; it is value.

Changed — skills

  • The skills now cover the whole public API, and a gate keeps them there. They are what an AI agent reads before writing Weave code, so an omission is not a documentation gap — it is an agent inventing an API in its place, which is exactly how field('', { validate }) came to be taught. Measured: 84 public exports were never mentioned (41 of 56 in runtime, 18 in router, 12 in data, 10 in forms, 3 in i18n), including onMount, provide/inject, every devtools export, every transition, Interceptor and Optimistic. All now documented from source, with the failure modes that matter.
  • New gate verify:skills (in CI): every public export of a package must appear in its skill; every fenced ts/html example must parse; weave-templates must show every block, directive and special attribute the parser accepts. A fence now carries a promise — ts/html is real code and is checked, shorthand notation goes in txt.

Fixed — skills

  • The component skill taught a resource leak. Its lifecycle example was onMount(() => { const id = setInterval(…); onCleanup(() => clearInterval(id)) }). onCleanup registers on the running computation (if (listener) …) and an onMount callback fires later on a microtask, outside any computation — so it silently registered nothing and the interval outlived the component. Uses onDispose now, and both hooks are documented with the distinction spelled out.

Changed — mcp

  • The server now speaks MCP up to 2025-11-25 (was 2024-11-05) and NEGOTIATES. The revision date marks the last backwards-incompatible protocol change, not a release of this package, and the server was about a year behind. Checked against the spec changelogs rather than assumed: for a stdio server that advertises only tools, everything added since is HTTP-transport business, gated behind a capability it does not advertise, or purely additive — and the one REMOVAL (JSON-RPC batching, dropped in 2025-06-18) was never implemented here. It now declares the full supported range and echoes the version the client asked for when it is one of them, per the spec: answering with its own constant regardless would make an older client disconnect, which the spec tells it to do, over a session that would have worked.

Fixed — mcp

  • A declared required argument was never enforced. Every tool listed required in its inputSchema and the server never read it, so a caller that omitted or misspelled an argument fell through to the handler with undefinedweave_compile_template answered "Empty template fragment", which points an agent at its markup instead of at its own call. Missing arguments are now named in an isError result. (McpTool.inputSchema is typed rather than object, so the schema is a contract the server can actually read.)
  • The scaffold emitted a directive that does not exist. A generated component carried // styles: ./name.css, which reads like a mechanism and is not one: the sibling stylesheet is picked up by the same convention as the sibling template. Verified by building a scaffolded component in a real app with and without the line — identical CSS. It now states what is actually true.

Fixed — editor tooling

  • The VS Code extension had the WebStorm bug, older. The shipped weave-language-0.5.0.vsix bundled a language server built on 30 June — it predated auto-expose, so every {{ binding }} of a component whose setup() omits its return came up red (11 false errors in one file, 1642 across 41, with weave check clean on the same tree). Its staged tsserver plugin was also still under the pre-rename @weave/ scope, which VS Code resolves by name, so the .ts side silently loaded no plugin and kept its TS1192. Ships 0.6.0, verified at 0 diagnostics across the same 41 files. editor/vscode/build.mjs also stops hard-coding the staged plugin's version.
  • The editor-plugin gate went red on a correct tree the first time CI ran it. It compared the server inside the shipped archive to a fresh local build byte for byte, and esbuild is not byte-reproducible across platforms — the Linux runner's bundle was 76 bytes larger than the Windows one from the same commit. Now pinned to two platform-stable hashes (the shipped bytes, and the language-server sources with line endings normalized) in a committed manifest per plugin. Renamed verify:webstorm-pluginverify:editor-plugins; it covers both editors, and additionally asserts the .vsix carries a correctly scoped tsserver plugin.

1.7.0 — 2026-07-18

Deprecated — ui

  • Five design tokens are inert but kept. button.mark-width, chips.remove-font-size, input.clear-size and typography.cell-size on both pickers stopped being read when their glyphs became lucide icons (and the picker token was renamed to cell-font). They had been deleted; deleting a public token is a MAJOR change that fails silently for the consumer — their override simply stops applying — so all five are restored as deprecated no-ops, each naming its replacement. See RELEASE-NOTES for the table.

Added — tooling

  • verify:ui-tokens (in CI): the --weave-* names the library actually emits are snapshotted in packages/ui/token-contract.json. A removed token fails hard; a new one fails until recorded with --update. Ground truth is the built stylesheet, not the SCSS source. 617 tokens recorded.
  • skills:check / skills:install: the skill suite exists in three places and two are derived copies. The one in a user profile — what an agent actually loads — sat two months behind, teaching a field() signature that does not exist. The template copy is checked in CI; the profile copy cannot be reached from CI, so it is checked at session start instead.

Fixed — editor tooling

  • A call inside a binding had no color (WebStorm plugin 0.23.0). WEAVE_BINDING_CALL fell back to DEFAULT_FUNCTION_CALL, which has no foreground in any scheme the IDE ships — Default, IntelliJ Light and Darcula all leave it as plain text. {{ onPick }} was colored (its DEFAULT_INSTANCE_FIELD fallback is), every {{ foo() }} was not, and the highlighting read as broken. Colors are now stated outright in bundled colorSchemes/Weave{Default,Darcula}.xml via additionalTextAttributes, for calls and for the on:/use:/bind: prefixes (same hole in the light scheme). verify:webstorm-plugin gained two checks: every WEAVE_* key must have an explicit color in both schemes or a recorded measurement proving its fallback is colored, and every <additionalTextAttributes file=…> path must resolve inside the jar — a wrong path is not a build error, the IDE just logs it and leaves the colors unset.
  • The WebStorm plugin's bundled language server was stale, and it made every binding red. The server inside weave-webstorm-0.21.0.zip was built just before auto-expose landed, so it typed a setup() that omits its return as void and reported "Property 'x' does not exist on type 'void'" on every {{ }} binding of every such component: 1642 false errors across 39 of 41 files in a real app, while weave check on the same tree reported none. Shipped 0.22.0 with a server built from the same commit. New gate verify:webstorm-plugin (in CI) fails if the server/server.cjs inside the shipped .zip is not byte-identical to packages/language-server/dist/server.cjs — nothing checked that copy before.

Fixed — language server

  • A child component's prop contract was inert in the editor. The server never built a virtual for an imported component .ts, so it never saw the synthesized default export; typeof Child degraded to any and every <Child prop={{ … }}> silently passed — a wrong prop type, a prop the child does not declare, both accepted. The same degradation stripped an inline handler parameter of its contextual type, producing a spurious "implicitly has an 'any' type" on correct code. The server now claims a component .ts (script region mapped only, so it does not duplicate the editor's own TypeScript diagnostics). weave check was unaffected throughout — the CLI and the editor disagreed, which is exactly what the shared emitter exists to prevent.

Fixed — check

  • A prop-contract diagnostic mapped nowhere and was dropped. TypeScript pins a mismatched-prop (TS2322) or unknown-prop (TS2353) error to the property key, and the key was emitted as unmapped scaffolding. weave check (line-mapped) still reported them, so the loss was invisible from the CLI. Attribute names on a component tag now carry a nameOffset through the parser, and the emitter writes the key mapped — the error lands on the prop name in the template.

Fixed — compiler

  • Nested @for row shadowing. Row functions all took a parameter named _row, so a nested loop shadowed its parent's and an outer loop variable read inside the inner loop resolved to the inner item — no error, just the wrong object. Each loop now gets its own identifier (_row0, _row1, …) from a dedicated counter, so _b block numbering is unchanged. $index/$count/$first/$last/ $even/$odd still rebind to the innermost loop (correct shadowing, via childScope layering). Regression test renders a real nested loop and asserts the outer variable resolves in both a text interpolation and an attribute.

Fixed — router

  • navigate('#fragment') no longer navigates to /. The target was split on # and the empty remainder taken as the path. A bare fragment now preserves the current path and query, and falls through to the existing fragment-scroll handling.

Fixed — ui

  • <Sidenav>: .weave-sidenav gets height: 100% so the shell fills a sized container (no-op in an auto-height parent); drawer and content stretch via the default flex align-items: stretch.
  • <Tree>: disclosure marker is a lucide chevron-right <Icon> instead of a CSS ::before glyph, rotated 90° when expanded; rendered only for expandable nodes. Token toggle-glyph 12px → 14px.
  • <Tree> / <List>: reorder drag handle is a lucide grip-vertical <Icon> instead of a character. grip-vertical added to the built-in lucide set (and to the generator's name list).

Documentation site

  • API reference package pages open with a jump index of their exports, grouped by kind, with a per-entry kind badge; anchor jumps scroll smoothly (honouring prefers-reduced-motion).
  • Generated API anchors are now unique per package — slugify lowercases, so a function and a same-named type produced one shared #anchor (a duplicate DOM id). Anchors are assigned after the sort, so the established anchor is kept and the collision is disambiguated by kind.
  • Demo-stage presentation: raised surfaces for tabs/stepper/menubar/list/paginator/tree/table/ grid-list/expansion, full-width tabs/stepper/menubar, visible Progress Bar, and a context-menu right-click target that reads as a box.

1.6.0 — 2026-07-17

Phase E — SSG + a resumable signal core. 94 commits, released as one MINOR: everything below is new, opt-in surface with a safe default. The eager SPA path is untouched by design (byte-for-byte), and a SPA-only app pays zero bytes for any of it.

Static generation — weave build --ssg

  • --ssg prerenders every route to real HTML at build time and derives the route list automatically (E1.2, E1.3a–d). Per-page <title> captured from document.title (E1.3d). Per-route chunks: a reader downloads the page they opened (E1.2) — docs page 1555.7 KB → 169.7 KB, measured in the browser, not on disk.
  • resource() data is awaited before the HTML is written and travels in the snapshot (E1.3). The build settles tracked async work via a global sink, so @weave-framework/data does not pull the headless render into a client bundle.
  • lazy() prerenders: its import() joins the same sink, so a lazily-imported component writes real HTML and stays out of other bundles. This made per-component splitting free and retired the eager-routes twin built to work around the old constraint.
  • Router: headless location injection (E1.3c-1), per-route SSG via router-SSR (E1.3c-2), RouterView adopts its server-rendered view (E1.12).

Resume — ssg: { resume: true }

  • Wire format + resume entry (E0.1–E0.3): serialize/deserialize, resumable event dispatch via data-won-* markers, graph rebuild with no setup() re-run. Headless render to an HTML string (E0.4) — the DOM seam.
  • Adopt-mode render (E1.2a–E1.2c-6): reactive bindings re-attach to server DOM in place; block-boundary markers give a cursor walk; @if/@switch/@for adopt via island-replay; post-block elements and interpolations adopt; multi-root fragments; nested component resume with per-instance state + shared-signal dedup.
  • derive(ctx, props) rebuilds what cannot serialize: computeds (E1.6), module-scope bindings so a router no longer blocks resume (E1.11), props (E1.25), bare effect()s (E1.47), and onMount() hooks (E1.49).
  • Coverage for real components: element refs re-bound from the adopted DOM rather than serialized (E1.16 — refusals 52 → 0, drops 228 → 29); <slot> (E1.17); use: actions, including forwarded onto a component (E1.21, E1.22); @key/@render/@snippet (E1.24); component-level on: handlers (E1.13); named + inline-in-return handlers (E1.5, E1.34); nested-component events with ancestry-scoped resolution (E1.8).
  • Never silent: a component that cannot be adopted names its cause at build time (E1.14), a handler that won't inline or a computed that can't be rebuilt emits a real esbuild warning under a resume build (E1.7), and a non-serializable binding degrades to client rendering instead of failing the build (E1.9).

Fixes

  • 1.6.0 — fix(cli): resume adopted off the wrong root on every multi-root app (E1.46). The entry hard-coded _m.firstElementChild; a multi-root component's roots are the mount target's children, so the walk got the first root, threw nextSibling of null on step one, and nothing ever adopted — silently, because the throw precedes any console listener and the server HTML looks right. Our own documentation site had never resumed, not once. The compiler now publishes the contract (adopt.container) instead of the caller guessing; a root that emitted no adopt CSRs outright rather than arming handlers over unadopted DOM. Gated by verify:resume (a real multi-root app — every prior test app was single-root, which is why the bug could exist).
  • 1.6.0 — fix(compiler): a bare effect() in setup() binds no name, so derive never rebuilt it — a per-route document.title effect froze at the server's value forever (E1.47).
  • 1.6.0 — fix(compiler): an onMount() in setup() resumes (E1.49). A prior build refused to adopt any component with one, calling it a structural limit; derive re-creates the hook exactly as it re-creates an effect, and the refusal was strictly more expensive (client-rendering re-runs setup() and fires the hook anyway, plus a full re-render). Docs cannot-adopt 34 → 6. Enabling hooks ran their bodies through the setup scanners for the first time and exposed three real bugs: comments were not skipped (an apostrophe in prose opened a string and swallowed live code), the previous-significant-character was read from raw text, and returnEntries dropped a comment-introduced entry and everything after it.
  • 1.6.0 — fix(runtime): onMount is inert during a headless render by construction (__weaveHeadless), not merely because the render used to be synchronous — E1.3's settling would otherwise have let mount hooks fire at build time.
  • 1.6.0 — fix(compiler): the setup analysis no longer misreads regex literals (/\/+$/ reported $ as a variable), comments, TS type annotations, as casts, function-type annotations, generics with commas, destructured declarations, optional params, shadowed declarations, or a handler's own locals as ctx references (E1.18–E1.48). Each quietly narrowed what could resume without ever being visible.
  • 1.6.0 — fix(ui): <Timepicker>, <Select>, <Datepicker> and <DateRangePicker> use lucide icons instead of hand-drawn Unicode/CSS glyphs.

Under the hood

  • 1.6.0 — ci: this repository had no CI at all — only a docs deploy and an npm publish, neither of which ran a test. Every gate ran on memory alone. .github/workflows/ci.yml now runs build · test · verify on every push (~1.5 min), including verify:resume: a real app, the real CLI, a real browser, real clicks.
  • 1.6.0 — build(size): verify:size enforces the budgets. SPA core 21.2 KB gz; resume/adopt/serialize sit on their own lines.
  • 1.6.0 — style: pnpm lint went 916 errors → 0 (the rules were fixed, never relaxed) and is now a CI step; no-unused-vars was added after a dead import survived a retraction unnoticed.

1.5.28 — 2026-07-14

  • 1.5.28 — fix(ui): <DateRangePicker> — the second click now always commits (FW-17 follow-up). Selecting the first date worked, but the second click frequently did nothing: while the pointer moved toward the end date, every mouseenter re-ran a full calendar.render() that rebuilt the entire day grid, detaching the cell under the cursor mid-click — mousedown on the old node, mouseup on the replacement → the browser fires no click. Two causes fixed: (1) the hover preview + the anchor-set now call a new refreshDays() on the shared calendar core, which re-decorates the existing day buttons in place (reset className → re-derive selected/today/range/preview + focus) instead of recreating them, so each cell keeps its element identity and a real mousedown+mouseup always lands; (2) the value-sync effect was implicitly tracking pendingStart/hoverDate (read by decorateDay during its render()), so a hover re-ran it → another rebuild — it now wraps render() in untrack() and depends only on the external rawValue(). <Datepicker> shares the core but has no hover path, so it is unchanged (32 tests green). Pinned by a new date-range-picker.browser.ts regression test that drives a real mousedown → mid-click hover → mouseup and asserts the target cell is never detached + the range commits (fails against the pre-fix rebuild-on-hover). Root cause: my original tests used a synthetic atomic .click() which never split mousedown/mouseup, so they missed the real-cursor failure.

1.5.27 — 2026-07-13

  • 1.5.27 — release: documentation reconcile + lockstep version bump (1.5.23 → 1.5.27) for the FW-17 batch; this is the published [publish] commit.
  • 1.5.26 — docs(ui): <DateRangePicker> reference page (/ui/date-range-picker — prose + API table + live basic demo) and examples gallery (/examples/components/date-range-picker — basic, bounds+filter, forms control), both registered in nav. Mirrors the Datepicker docs surface.
  • 1.5.25 — feat(ui): <DateRangePicker> — a new @weave-framework/ui/date-range-picker for picking a start/end date range (FW-17). An underline trigger field shows start – end and opens the shared calendar popover (day → year grid → month grid, one month at a time). Range selection is two clicks: the first sets the anchor (accent-filled), the second completes it with the ends auto-ordered (click before the anchor and it becomes the new start); while picking the end, hovering previews the span (a --in-range-style tinted band + a dashed --preview-edge ring on the tentative end). The value is a DateRange ({ start: Date | null, end: Date | null }) bound via value + onChange or a forms control (Field<DateRange>); it commits only on the second click and a half-picked range is discarded on close. Supports min/max/dateFilter, firstDayOfWeek (default Monday), labels (translatable chrome), separator (default ' – '), clearable, required, disabled, position, and full keyboard nav. Pinned by 13 date-range-picker.browser.ts tests (two-click commit, order swap, hover preview, keyboard, discard-on-Escape, bounds, clear, value/onChange, shared drill-down) + verified live in the docs. Docs: new UI → DateRangePicker reference + Examples → Components → DateRangePicker gallery.
  • 1.5.24 — refactor(ui): extracted the three-view calendar engine out of <Datepicker> into a shared, prefix-parameterized createCalendarView core (src/shared/calendar-view.ts) + a matching calendar($name, $range) SCSS mixin (src/styles/_calendar.scss), consumed by both <Datepicker> and <DateRangePicker> — zero calendar duplication (UI RULE #1). <Datepicker> delegates its popover to the core with identical class names/CSS/behaviour; all 32 datepicker.browser.ts tests stay green (the guardrail). The $range flag emits the range-only day modifiers (in-between band, rounded ends, hover preview).

1.5.23 — 2026-07-10

  • 1.5.23 — feat(ui): <Datepicker> year + month drill-down views, configurable first day of week, and translatable chrome (FW-16). The popover is now three views in one panel: clicking the day-view header ("June 2026" — now a button, aria chooseYear) opens a year grid (24 years, 4×6; ‹/› page ±24; range label "2016 – 2039"); picking a year opens a month grid (Jan–Dec, 3×4, no paging; its header year switches back to the year grid); picking a month opens that month's day calendar — so navigating across decades is a couple of clicks instead of dozens of month steps. Each grid is a role="grid" with full keyboard nav (Arrows within the page — year row = 4, month row = 3; PageUp/Down jump a 24-year page; Home/End to edges; Enter/Space drills down or commits; Esc closes). Years/months entirely outside min/max are disabled in their grids. New firstDayOfWeek prop (0 Sun … 6 Sat) defaulting to Monday (1) — a component default, not the locale's — overridable per instance. New labels prop (Partial<DatepickerLabels>) translates every chrome string (nav aria-labels, year switch, dialog name, clear, open-calendar) with English defaults; being reactive props they can carry t('…') from i18n. Month/weekday/year text stays locale-driven (Intl). The imperative panel was refactored to a single renderPanel() dispatching per view (day/year/month), each with its own keyboard handler; the day view keeps its exact classes/structure so it is fully back-compatible. Pinned by 13 new datepicker.browser.ts tests (drill-down chain, year paging, per-view keyboard, first-day default + override + lead-blank shift, label overrides, year/month min-max disabling) + verified live in the docs. Docs: UI → Datepicker updated (new views, first day, labels, keyboard, props).
  • 1.5.22 — fix(ui): <Tabs> sliding indicator — measure the active tab on the next animation frame, not mid-selection (FW-15 follow-up). 1.5.21 re-queried the live button but still measured synchronously inside the reactive flush; on a direction reversal (clicking a tab on the opposite side of the active one, after moving one way) that read the newly-active button before its tabTemplate body had re-rendered/laid out for the new selection — a small non-zero (icon-sized) width, i.e. the circle in the wrong place (the offsetWidth === 0 guard couldn't catch a partial). The measurement is now deferred to a coalesced requestAnimationFrame, so it always runs after the active button's DOM + layout have settled and never captures a pre-render/partial box — every selection, any direction, any distance. Rapid selections cancel the pending frame (only the final one measures); the ResizeObserver (list + active button) still re-fires for genuinely-later async resizes (font/icon load). Pinned by two new tabs.browser.ts tests — a pre-layout guard (a template whose selected body finishes a frame late: the indicator must NOT snap to the partial width on the selection tick — fails under the old synchronous measure) and a direction-reversal sequence.
  • 1.5.21 — fix(ui): <Tabs> sliding indicator now tracks the active tab under a tabTemplate (FW-15). With slidingIndicator + a custom tabTemplate, switching tabs left the indicator the wrong size and place — it collapsed to a tiny box (a circle under a pill skin) parked near the first tab, because the geometry was read off a captured-once-in-onMount list of tab buttons that goes stale when the templated button bodies re-render. The indicator effect now (a) re-queries the live active button each run instead of a snapshot, (b) also depends on the tabs set so add/remove/reorder re-places it, (c) observes the active button (not only the tab list) so content that lays out a frame later — an icon/label sizing after render — re-fires a re-measure even when the list's own box is unchanged, and (d) never settles on a zero width (a body mid-re-render is skipped; a later resize tick re-places it). No API change; plain (no-tabTemplate) tabs behave exactly as before. Pinned by three new tabs.browser.ts tests (switch-under-template, late-frame layout, tabs-set change) — the late-layout + set-change tests fail without the fix. With slidingIndicator + a custom tabTemplate, switching tabs left the indicator the wrong size and place — it collapsed to a tiny box (a circle under a pill skin) parked near the first tab, because the geometry was read off a captured-once-in-onMount list of tab buttons that goes stale when the templated button bodies re-render. The indicator effect now (a) re-queries the live active button each run instead of a snapshot, (b) also depends on the tabs set so add/remove/reorder re-places it, (c) observes the active button (not only the tab list) so content that lays out a frame later — an icon/label sizing after render — re-fires a re-measure even when the list's own box is unchanged, and (d) never settles on a zero width (a body mid-re-render is skipped; a later resize tick re-places it). No API change; plain (no-tabTemplate) tabs behave exactly as before. Pinned by three new tabs.browser.ts tests (switch-under-template, late-frame layout, tabs-set change) — the late-layout + set-change tests fail without the fix.

1.5.20 — 2026-07-10

  • 1.5.20 — feat(skills): shipped a suite of 11 focused Weave skills (skills/weave-*) — per-subsystem guidance for AI systems building Weave apps of any complexity: weave-app (orchestrator/index) · weave-component · weave-reactivity · weave-templates · weave-router · weave-forms · weave-store · weave-i18n · weave-data · weave-ui · weave-tooling. Each is a self-contained SKILL.md (a specific trigger description + accurate API + examples + "which tool when" tables + gotchas), grounded in the real package exports and the 1.5.x authoring DX (auto-expose, propDefaults, bind: on components, typed @snippet params, no void keep-alives). create-weave now scaffolds them into a new app's editor-skills dir via tools/sync-skills.mjs (wired into build-packages.mjs), so every scaffolded app ships them auto-discovered; existing apps copy skills/ in. skills/ is the single source of truth.
  • 1.5.19 — feat(compiler+check): (A5) bind:value={{ sig }} (and any bind:<prop>) now works on a component tag, not only DOM elements — it passes the signal itself by reference (sugar for the "hand the child the writable signal" convention), so stepper/form-style two-way reads uniformly across elements and components. weave check types the signal against the child's prop; the old 'bind' binding on <Tag> is not supported yet throw is gone. Pinned in component.browser.ts (emit + a real two-way mount). Docs updated (Learn → Components).
  • 1.5.18 — fix(runtime+forms): two robustness fixes from the DX audit. (B6) bind:group compares stringwise (String(sig()) === radio.value) so a non-string signal (e.g. Signal<number>) checks the right radio instead of never matching, and writes back in the signal's own type (a number stays a number, not the string "1"). (B7) a form's validateAsync() waits for async validation by watching validating() flip to false (a one-shot effect) instead of a 30 ms poll capped at ~2 s — no latency quantum, no premature resolve mid-validation. (B5 from the audit — an "infinite-loop guard" — was investigated and dropped: the reactive core's existing loop-safety (markDirty early-return) already terminates a self-writing effect, so there is no stack-overflow to guard; verified empirically.) Pinned in bind.browser.ts + the existing validateAsync submit test.
  • 1.5.17 — feat(runtime+compiler+check): (A2) prop defaultsexport const propDefaults = { … } gives a component static default prop values, cutting the () => props.x ?? default wrappers that pepper component authoring. The loader passes it as defineComponent(render, setup, propDefaults); the runtime layers it under props (Object.create(defaults) + the parent's own descriptors on top), so a prop the parent omits reads the default while one it passes wins and stays reactive (an explicit undefined/falsy counts as passed). weave check makes the defaulted keys optional for the parent (__WeaveWithDefaults<P, typeof propDefaults>) while setup still sees them as declared; a required non-defaulted prop stays required. Pinned in component.browser.ts (emit + runtime + reactive + falsy-wins) and a new propdefaults smoke (verify:check). Docs: Learn → Components → Prop defaults (replaces the old "no default-props mechanism" note).
  • 1.5.16 — feat(compiler+check): (A3) @snippet parameters may carry a TS type annotation@snippet row(ctx: ListRowContext<Task>) { … } — and weave check type-checks the body against it (a typo in ctx.item is caught); an un-annotated param stays any (backward compatible). This closes the type hole under the template-prop features (rowTemplate/itemTemplate/tabTemplate), whose snippet bodies were previously untyped. Parser splits name: Type with type-aware bracket depth (generics with commas — Map<K, V> — and arrow types — (n) => T — survive); the prettier-plugin re-attaches the annotation when formatting. Pinned in snippet.browser.ts + the snippet-type smoke. Documented in Learn → Components.
  • 1.5.15 — feat(compiler+check): (A1) a bare attribute on a component tag (<Button disabled>) now passes the boolean prop true instead of the empty string "" — so a boolean prop actually receives true, and weave check stops flagging '' is not assignable to boolean. A quoted value (label="Go") or an explicit empty (hint="") still passes a string. Implemented via a bare flag on the parser's static attr (valueless = no =); DOM elements are unchanged (a bare attribute still renders bare). Pinned in component.browser.ts; documented in Learn → Components.
  • 1.5.14 — feat(compiler): two template-authoring DX wins. (A4) common DOM/timer globals — setTimeout/clearTimeout/setInterval/clearInterval, requestAnimationFrame/cancelAnimationFrame/queueMicrotask, alert/confirm/prompt, performance, crypto, Event/CustomEvent, AbortController, FormData, Blob/File, Image/Audio, getComputedStyle, atob/btoa — are no longer inferred as ctx bindings, so an inline handler like on:click={{ () => setTimeout(close, 200) }} resolves the real global instead of compiling to ctx.setTimeout(…) (a runtime TypeError). (A6) every parser ParseError now carries its source offset (35 throws that previously reported none), so the dev-server overlay frames the real line:col instead of collapsing to line 1. (From the DX audit.)
  • 1.5.13 — fix(compiler): codegen no longer rewrites an arrow-function parameter that shadows a same-named setup binding. items().map((value) => value * 2) compiled to ctx.items().map((ctx.value) => ctx.value * 2)(ctx.value) => is a SyntaxError — whenever value was also a component binding (a real, if narrow, build-breaker). rewrite() now spares arrow parameters (same basis as freeIdentifiers/inferCtxNames, so inference and codegen agree on what is a parameter). Pinned in scope.browser.ts. (Found by a framework DX/optimization audit.)
  • 1.5.12 — test+docs(typescript-plugin): pinned that a child component imported only for a template tag (<Badge/>, never referenced elsewhere in the .ts) is not reported "unused" — the plugin's virtual harness already references each tag as typeof Tag, so noUnusedLocals stays quiet and the old void Badge; keep-alive lines are unnecessary whenever the Weave editor tooling (@weave-framework/typescript-plugin / VS Code / WebStorm) is active. New packages/typescript-plugin/test/unused-import.smoke.mjs (used-in-template → 0 unused; genuinely-unused import → still flagged, as a control) wired as verify:tsplugin; documented in Learn → Components. (Chose "keep the imports, drop the void" over resolving tags without imports — imports stay explicit for go-to-definition.)
  • 1.5.11 — feat(compiler+check): auto-expose — a component's setup may omit its return. When there is no top-level return, the loader (runtime module) and @weave-framework/check both synthesize return { …names } exposing exactly the identifiers the template references (inferCtxNames) — a private local/timer the template never names is not exposed, and a module-scope name the template uses (t, an icon map) is forwarded for free. An explicit top-level return opts out and is used verbatim, so every existing component (all of which return) is unaffected. New packages/compiler/src/auto-return.ts: a hand-rolled string/comment/regex/template-literal-aware scanner (zero-dep) that classifies each { FUNCTION-vs-BLOCK, so a return inside a nested arrow/function is ignored while one in a top-level if/switch block still counts; fail-safe — any ambiguity (odd signature, return-type annotation, unbalanced scan) leaves the script byte-for-byte untouched. check maps its script region as two runs around the injected span so .ts diagnostics still land correctly. Pinned by auto-return.browser.ts (16 cases), a real mount in component.browser.ts, and auto-return.smoke.mjs (wired into verify:check). Also fixed 3 pre-existing Signal<T>-invariance type errors in list.browser.ts (FW-14 test) that were failing typecheck.

1.5.10 — 2026-07-09

  • 1.5.10 — feat(publish): @weave-framework/typescript-plugin is now published to npm (16th package). It's the .ts-side editor support — a tsserver plugin that synthesizes the loader-generated default export, so import X from './x-component' no longer reports TS1192 "no default export" in WebStorm (and any editor using the project's tsconfig). Wire it up per project: compilerOptions.plugins: [{ "name": "@weave-framework/typescript-plugin" }] + install it (dev). The Nx application generator and the create-weave template now scaffold both. (VS Code's extension already bundles it; WebStorm needs the tsconfig entry since its tsserver loads plugins only from there.) Publish pipeline: added to build-packages.mjs (its esbuild bundle) + the publish ORDER + publishConfig.access=public.
  • 1.5.9 — fix(language-server): go-to-definition on a template binding ({{ x }}) now lands on the const x = … declaration in setup() instead of the return { x, … } shorthand. Template vars emit as __ctx.x over ReturnType<typeof setup>, so TS resolved the member to the return object's shorthand property (a "huge return" jump); a definition post-processor in the language server (redirect-definition.ts, wrapping provideDefinition) detects a ShorthandPropertyAssignment inside a setup return and re-points it at the same-named const. Ships in the editors (WebStorm plugin 0.15.0; benefits the VS Code extension on rebuild). DoD-pinned in verify:ls.
  • 1.5.8 — feat(nx) + docs: in a mixed Nx workspace (Weave beside another framework, e.g. an Angular→Weave migration) a project keeps behaving like the old framework until its own config says otherwise — that's what made a migrated project's .html templates show native/other-framework errors in the editor. The Nx application generator now also scaffolds a project-local tsconfig.json (mirrors create-weave; scopes the app as its own Weave TS program), and a new docs section — "Make a project use Weave — not the framework beside it" — documents the three markers (weave.config.* + tsconfig.json + .prettierrc) and a project.json target override (which outranks any inferred target) so both nx CLI and the editor treat the project, and its templates, as Weave. No plugin-code change — the WebStorm/VS Code Weave tooling already keys off these once the project stops declaring the old framework's targets.
  • 1.5.7 — fix(ui): <Tabs> tabTemplate (FW-12) now renders over dynamic tabs — same fix as <List> FW-14: the button body moved from a one-shot onMount snapshot into the reactive keyed @for block (@if (hasTemplate()) { @key (tabKey) { @render (tabBody) } }), so tabs added/edited after mount get (and refresh) their template body. tabKey folds a per-tab WeakMap version + selected state. (Panel content still mounts in onMount — tabs are a fixed strip by design.)
  • 1.5.7 — fix(check): a @snippet is now typed () => Node (was () => void), so passing one to a component's template prop typed (row) => Node (rowTemplate / itemTemplate / tabTemplate on a locally-typed component) no longer flags a spurious 'void' is not assignable to 'Node' error in weave check. New snippet-type smoke pins it (part of verify:check).

1.5.6 — 2026-07-09

  • 1.5.6 — fix(ui): <List> rowTemplate (FW-14 follow-up #2) now re-renders a row body when the item's data changes, not only on a selected/disabled flip. The body was keyed solely by selected:disabled, constant for a non-selectable list — so a reused row (eachBlock keyed by item.value) kept its stale body after an edit-then-reload (same id, new data). The @key now folds in a per-item version (a data edit hands a fresh object → new version), so editing a record refreshes every templated field with no app-side key hack. Selected/disabled re-render preserved.
  • 1.5.5 — fix(ui): <List> rowTemplate (FW-14 follow-up) now renders over dynamic items. The row body was wired once in onMount over a static querySelectorAll snapshot, so rows created after mount (async initial load, infinite-scroll append, reload after create/edit/delete) rendered empty (default title/meta suppressed). The body now mounts inside the reactive keyed @for block (@render guarded by hasTemplate(), wrapped in @key(selected:disabled) for the per-row selected/disabled re-render) — create / append / replace / remove all flow through the block's track item.value diffing. No onMount, no index-fragile rows[i]. API unchanged.
  • 1.5.4 — feat(ui): <List> rowTemplate (FW-14) — an authored @snippet renders the whole body of each .weave-list__row (colour dot, name, tag pills, description, trailing action buttons) from the row's ListRowContext (item + data, value, title, meta, index, reactive selected, disabled). <List>/ListItem are now generic over the item payload (data?: T). The framework keeps the row, its role, aria-selected, roving tabindex, keyboard nav and (when reorderable) the drag handle rendered before the template; title stays the accessible name + typeahead. Re-renders per row on selected change, bindings owned/disposed cleanly. In selectable mode a click on an interactive descendant (button/a/[role=button]) inside the template no longer toggles selection. Omit → the default title + meta spans (back-compatible). Mirrors the menu's itemTemplate (FW-10) and tabs' tabTemplate (FW-12).

1.5.3 — 2026-07-08

  • 1.5.3 — feat(ui): <Tabs> slidingIndicator (FW-13) — opt-in animated marker. When set, the framework renders one .weave-tabs__indicator in the tab list and slides + resizes it (transform: translateX + width) to the active tab's box on every selection and on resize (ResizeObserver), the CSS transition doing the animation. Default look is a bottom accent underline (--weave-tabs-indicator-* tokens); app CSS re-skins it to a pill. Off by default (Weave has no sliding marker unless asked); torn down (observer disconnected) on unmount. Composes with tabTemplate.

1.5.2 — 2026-07-08

  • 1.5.2 — feat(ui): <Tabs> tabTemplate (FW-12) — an authored @snippet renders the whole content of each role="tab" button (icon + label, badge, two lines) from the tab's TabRowContext (item + data, label, index, reactive selected, disabled). <Tabs>/TabItem are now generic over the item payload (data?: T). The framework keeps the button, ARIA, roving tabindex and panels; label stays the accessible name. Re-renders per tab on selected change, bindings owned/disposed cleanly. Omit → the default label span (back-compatible). Mirrors the menu's itemTemplate (FW-10).

1.5.1 — 2026-07-08

  • 1.5.1 — fix(prettier-plugin), security: hardened the SFC/template tag-detection regexes in parse.ts against polynomial ReDoS (CodeQL js/polynomial-redos, 5 alerts). The ambiguous (\s[^>]*)? (where \s ⊆ [^>]) is replaced by a zero-width (?=[\s>]) assertion, and lang is now read from the captured <style> attribute slice instead of a second full-document scan. Detection semantics unchanged; smoke tests + a regression sentinel added.

1.5.0 — 2026-07-07

Released from the local batch 1.4.11.4.22 (npm went 1.4.01.5.0 directly). Per-step log:

  • 1.4.22 — docs(site): context-menu example galleries for selected, optionContent, itemTemplate (3 demos) — parity with the menu galleries.
  • 1.4.21 — docs(site): menu example galleries for selected, optionContent, itemTemplate (3 demos + reference prose); RELEASE-NOTES/CHANGELOG batch ledger opened.
  • 1.4.20 — fix(compiler): object spread/rest ({ ...opts }) in a template expression is now scope-rewritten (the ... was mistaken for a member .), so use:menu={{ { ...opts, itemTemplate: row } }} resolves opts. Fixed in both rewrite and inferCtxNames.
  • 1.4.19 — feat(ui): menu/contextMenu itemTemplate (FW-10) — authored @snippet renders the whole row from the full row context (item + checked/active()/index/disabled).
  • 1.4.18 — feat(ui): menu/contextMenu optionContent (FW-9) — custom row body Node; optionLabel still drives the accessible name + typeahead.
  • 1.4.17 — fix(compiler): self-closing SVG/foreign tags (FW-8) serialize with a close tag → siblings, not nested.
  • 1.4.16 — fix(cli): styles url() assets (FW-7) hashed, emitted into the build + served in dev (no more font 404s).
  • 1.4.15 — fix(compiler): parenthesize reactive binding expressions so object literals in use: compile.
  • 1.4.14 — feat(ui): menu/contextMenu selected — value-picker rows (role=menuitemradio + aria-checked + check).
  • 1.4.7–1.4.13 — docs(site): per-component example galleries for all 38 @weave-framework/ui components.
  • 1.4.6 — feat(ui): Input revealTooltip selector (FW-6) — 'none' | 'native' | 'weave'.
  • 1.4.5 — feat(ui): Input onRevealToggle(shown) callback.
  • 1.4.4 — feat(i18n): standalone Intl formatters (formatNumber/formatCurrency/formatPercent/formatDate/formatRelativeTime/formatList).
  • 1.4.3 — feat(ui): Input reveal toggle native title tooltip (FW-5).
  • 1.4.2 — feat(runtime): Observable↔signal bridge (fromObservable / toObservable).
  • 1.4.1 — feat(ui): Input password/secret reveal (revealable) — eye toggle.

1.4.0 — 2026-07-06

Feature (@weave-framework/router) — async before-leave / canDeactivate guards (beforeEach). Adds beforeEach(fn: LeaveGuard): () => void: a guard run before every navigation commits (push / replace / pop) that may return boolean | Promise<boolean> — a false cancels and the current path + address bar stay put. All guards must allow to proceed; the first false short-circuits; the returned function unregisters. Push/replace are gated in navigateState (with a synchronous fast path when no guard is registered, so existing behavior/timing is unchanged); popstate awaits the guards and, on a veto, rolls history back via history.go so the URL matches staying put (no half-state). afterEach fires only on a committed navigation. Also adds navigate(to, { replace: true }) + the NavigateOptions type (via history.replaceState), making the previously internal 'replace' NavType a public API. New types exported: LeaveGuard, LeaveInfo, NavigateOptions. Tests: packages/router/test/router.browser.ts (cancel navigate, multi-guard short-circuit, <Link> click, replace gating + afterEach-only-on-commit, pop gate) — DoD-proven (all go red when the gate is neutered).

1.3.2 — 2026-07-06

Fix (@weave-framework/check, @weave-framework/cli, @weave-framework/compiler) — a template parse error is now a located diagnostic, not a stack trace. 1.3.1's parseAttrs advance-guard stopped the hang/OOM but the ParseError still bubbled up as a raw parser stack with no filename. Now ParseError carries a structured offset; weave check catches it in checkProject and reports file:line:col - error: <message> (so one malformed template no longer aborts the whole check), and the build loader (packages/cli/src/plugin.ts) returns an esbuild error framed at the template's file:line:col (source line + caret). weave build summarizes a build failure as weave build failed — N errors. instead of dumping esbuild's internal stack; non-esbuild failures still show their message. New verify:check smoke asserts checkProject returns a precise diagnostic (not a throw) for a malformed attribute; braces.browser.ts asserts the ParseError carries .offset. Patch release.

1.3.1 — 2026-07-06

Fix (@weave-framework/nx) — build output follows the Nx convention. The build executor now defaults outputPath to <workspaceRoot>/dist/<projectRoot> (forwarded to the CLI as --out, via a pure withBuildDefaults helper) and the application generator scaffolds outputs: ["{workspaceRoot}/dist/{projectRoot}"], so a Weave app's artifact lands where every other Nx plugin puts it and cache restore targets the same place. Projects may override with outputPath in project.json. To enable this, weave build in config mode now honors an explicit --out as an override of the config's outDir; standalone builds with no --out are unchanged.

Fix (@weave-framework/nx) — the build executor is now actually published. The repo's .gitignore had an unanchored build/ rule that swallowed packages/nx/src/executors/build/, so the source was never committed and @weave-framework/nx shipped without its build executor (nx build → "Unable to resolve @weave-framework/nx:build"). Un-ignored via a negation + committed; verify:nx now asserts every executor in executors.json has a non-ignored source and (when built) resolves in dist/.

Fix (@weave-framework/compiler) — parser fails loud instead of hanging on a bad attribute. parseAttrs gained an advance-guard: if readAttrName can't consume the current character and it isn't a terminator (e.g. }, (, [, *, #), the parser throws Unexpected character '<c>' in attributes of <tag> (line N, col M) instead of looping forever until Node OOMs (~5 GB / RangeError: Invalid array length). Regression-tested with the real repro <RouterView router="{{" router }} /> (a Prettier-mangled binding — also fixed at the source by @weave-framework/prettier-plugin).

Scaffold — Weave Prettier plugin wired in. The Nx application generator now adds @weave-framework/prettier-plugin as a devDependency and writes a project .prettierrc (plugins: ["@weave-framework/prettier-plugin"] + an .htmlweave parser override), written after formatFiles (the plugin isn't installed at generation time), so a generated app formats its templates instead of mangling {{ }} bindings. Patch release.

1.3.0 — 2026-07-06

New package — @weave-framework/prettier-plugin. A Prettier plugin that formats Weave templates (.weave SFCs + Weave-template .html). It reuses @weave-framework/compiler's parser (no separate grammar, so it can't drift from what compiles): elements/attributes lay out by width, bindings are preserved by kind (on:/bind:/use:/class:/style:/ref/.prop), control-flow @-blocks reindent with @@ kept escaped, {{ }} expressions format via Prettier's typescript printer, and SFC <script>/<style> via typescript/css/scss. .weave is picked up automatically; Weave .html opts in via a Prettier overrides glob (parser: "weave") so plain HTML is untouched. Output is idempotent; whitespace handling is conservative (block-level reindent only, no inline reflow, <pre> verbatim) in this first release. Smoke-tested via verify:prettier — no SyntaxError, idempotency, and a normalized-AST round-trip proving attribute kinds, comments, and @@ escaping are preserved.

Compiler — opt-in comment preservation. parseTemplate(src, { comments: true }) emits CommentNodes instead of discarding <!-- … -->; off by default, so the compile path (codegen, weave check) is unchanged — proven by the existing compiler+check browser suite (214 tests) staying green. The Prettier plugin is the only consumer. First minor bump for the new package.

1.2.0 — 2026-07-06

Feature (compiler, cli) — component-extension template patches (#3), RFC 0008. An extension file that exports const extend = Base + a STATIC const patch = [ … ] (and no own template) patches the base template instead of overriding it. The loader (packages/cli/src/plugin.ts) resolves the LOCAL base's raw template, reads the ops statically (isolated new Function eval — no module evaluation), and compileComponent applies them via packages/compiler/src/patch.ts applyPatches on the base AST (new compileTemplateAst compiles from the transformed AST, no text round-trip). Ops: attr/removeAttr/prepend/append/before/after/replace/remove/wrap; selectors by tag/.class/[attr]/[attr=value]; inserted markup + added attributes are parsed by the same Weave parser; a zero-match selector is a loud build error. Build-time (a patch on a @for row applies to dynamically-added rows — runtime DOM patching would not); compiles with the base's style hash so the base's scoped CSS still matches; base child tags resolve relative to the base dir. LOCAL base only (published packages ship no raw template) and one template mode per extension (#1 xor #3). New gate verify:extend (end-to-end through the real loader, DoD revert-proven) + patch.browser.ts (10 tests). Completes RFC 0008 (both modes). Known limitation: weave check doesn't type-check patch markup yet (a deferred follow-up; #1 full-override extensions are fully checked). First minor bump for this additive surface.

1.1.0 — 2026-07-06

Feature (compiler, runtime) — component extension (extend), RFC 0008 mode #1. A component whose script exports const extend = Base compiles to defineComponent(render, extendSetup(extend, setup?, extendProps?)): it reuses the base component's whole setup context and its own setup(props, base) overrides/adds on top, with its own template as the full override. extendProps(props) reshapes props BEFORE the base setup (the deep seam past closure privacy — a returned-key override only changes what the template sees, not what the base's internal closures read). defineComponent now attaches the raw setup as __wSetup so extendSetup can compose it; chaining works by construction (an extended component's __wSetup is the composed function). The runtime helper is @internal (api-gen skips it — runtime/dom stays 21 documented exports); no loader change. Declarative template patches (#3) remain a planned follow-up. First minor since 1.0 — additive, nothing existing changes. Docs: learn/components "Extending a component".

1.0.15 — 2026-07-06

Feature (compiler, check) — use: actions on component tags. use:action={{ arg }} on a <Component> now forwards to the component's single root DOM element through the same applyAction path elements use — identical lifecycle (mount timing, returned cleanup or { update, destroy }, update(arg) on change, multiple in order). The compiler no longer rejects use: on a component tag; the mounted node is resolved to its root via a new @internal componentRoot(node, tag) guard that throws a clear single-root error for a fragment/text/empty root ("use: on <Tag>: actions attach to a single root element, but <Tag> renders N nodes.") — never a silent mis-attach. @weave-framework/check already type-checked component directives as (Element, arg); a parity test pins it. Props, on: events, and element use: are unchanged. Docs updated (learn/templates + components, reference/template-syntax).

Docs (rfc) — RFC 0008 accepted. extendComponent — a future primitive to subclass any component (reuse its setup + template, override/add on both sides) without forking. Design record only; not implemented.

1.0.12 — 2026-07-05

Feature (cli) — weave dev proxy (dev.proxy). A Vite/Angular/Next-style dev proxy so an app's API calls stay same-origin in dev (no CORS; HttpOnly cookie auth works): dev: { proxy: { '/api': 'http://localhost:5201' } } (shorthand) or the full { target, changeOrigin, rewrite } form. A request is proxied when its path equals a key or starts with key + '/' (/api matches /api//api/x, not /apiary; first key wins), checked before the dev server's own routes. Method/headers/body/query stream to the backend and the response pipes back unchanged, so Cookie/Set-Cookie pass both ways; changeOrigin (default true) sets the forwarded Host; rewrite rewrites the path only (query preserved); an unreachable backend → 502, no crash. Dev-only, zero new deps (Node http/https). Pinned by a new verify:dev-proxy gate (boots the real dev server + a throwaway backend; 5 checks fail without the proxy).

1.0.10 — 2026-07-05

Fix (ui) — @weave-framework/ui dist now ships a real export default, so components are consumable in a real app. The ui build was plain tsc, which shipped components UNCOMPILED (export const template / export function setup, no render, no default export), so the documented import Button from '@weave-framework/ui/button' failed a real consumer's weave build ("No matching export for default") and weave check (TS1192) — masked in the monorepo, where dev exports resolve to src and the loader compiles on the fly. The ui build now compiles each component at build time through the loader's own compileComponent (tools/build-ui-components.mjs → staged tree → tsconfig.compiled.json), emitting export default defineComponent(render, setup) + a props-typed .d.ts default; weave check gained esModuleInterop + resolveJsonModule. New gate verify:ui-consume proves consumption against the built dist for all 29 components (fails on the old output — DoD-proven).

Infrastructure — docs deploy moved from GitHub Pages to Cloudflare Workers (docs/wrangler.toml + .github/workflows/docs.yml). The Pages deploy step had begun intermittently returning a terminal "Deployment failed, try again later." (build always passed); the docs now deploy to the same reliable Cloudflare static-assets path as the flagship demo, still [publish]-gated. No framework change.

1.0.0 — 2026-07-05

🏆 1.0 — the public API is frozen and stable. The 0.2.108→1.0.0 arc (see RELEASE-NOTES.md for the highlights) shipped Phase C (transition callbacks, reactive @await, DevTools panel + trigger-trace + component tree, Forms v2 dirty(), Router v2), all Tier-2 template features (<Teleport>/<Dynamic>/<KeepAlive>, reactive style:/use:), schema-driven forms, and two new packages — @weave-framework/mcp (MCP server) and @weave-framework/nx (Nx plugin). The freeze (RFC 0005) @internal-tagged the compiler-emitted runtime/dom helpers and made VERSIONING.md's stability promise binding. All 14 packages went live on npm at 1.0.0; 1.0.1→1.0.5 followed with the README 1.0 hero, the create-weave template version pin, and three scaffolder hotfixes (nx exports / nx generators / scaffolded-starter type error).

0.2.120 — 2026-07-04

Fix (compiler + runtime) — SVG child elements in a nested fragment now get the SVG namespace. An SVG-only element (<path>, <g>, <circle>, <rect>, …) that is the root of a separately-compiled fragment — an @if / @for / @key body, or a component/slot root — was parsed at the top level of a plain <template>, where the HTML parser (having no <svg> ancestor to enter foreign content) created an inert HTMLUnknownElement in the XHTML namespace: it appeared in the DOM but the browser never painted it. This is why a @for-driven SVG chart (e.g. bars/paths bound to data) silently failed and had to be worked around with <div>s. The compiler now detects a fragment rooted at an SVG-only tag and emits a namespace-aware templateSvg() runtime helper (parses inside a throw-away <svg> wrapper, then lifts the children out) so those nodes are real SVG elements. <svg> itself is unaffected (the HTML parser handles it correctly), and an SVG child in the same template already worked. Pinned by five browser tests (packages/compiler/test/svg.browser.ts), three of which fail on revert.

0.2.108 — 2026-07-04

Docs — new Examples section (six complete, runnable apps built with nothing but Weave). A new top-level Examples area (/examples) sits alongside Learn / Reference / UI, each page an end-to-end mini-app with the live demo running on the page and its full app.html / app.ts / app.scss source beneath it. Todo list (signals, computed, store, localStorage via an effect, keyed @for), Data dashboard (a filtered → sorted → paginated pipeline owning the Table with clientSort off, custom cell renderers, live KPI Cards), Settings panel (every form control bound one way, a live Tabs preview via factory content + effect, snackbar()), Sign-up wizard (@weave-framework/forms field/validators wired to a linear Stepper's per-step completed, the idiomatic control binding on Input/Select/Checkbox, a Finish guard), and Kanban board (the CDK dropList + moveItemInArray for drag-to-reorder, arrow buttons for lane moves). Each demo dogfoods the real @weave-framework/ui components and was live-verified. No framework code changed.

0.2.87 — 2026-07-03

Fix — composed child components resolve when nested inside @if/@for and documented as an import example (@weave-framework/cli). <Table selectable> silently blanked the whole render: its selection column composes the real <Checkbox> (inside @if/@for blocks), but the child-import auto-resolver in the esbuild loader skipped wiring it, so the compiled module referenced a bare Checkbox and threw a swallowed ReferenceError. Root cause was in importsBinding — it scanned the component's whole script including comments, so Table's JSDoc usage example (import Checkbox from '@weave-framework/ui/checkbox') was mistaken for a real import and the resolver assumed the child was already provided. It now scans a comment-stripped copy of the script (a small tokenizer that preserves string/template literals so a // inside a string is not treated as a comment), so a documented import example no longer suppresses auto-resolution. The compiler already collected nested PascalCase children correctly; an audit of every UI component confirmed Table→Checkbox was the only one affected. Pinned by a failing-first end-to-end test (tools/verify-ui-compose.mjs) that builds <Table selectable> through the real consumer loader and asserts the composed <Checkbox> selection column mounts. The docs /ui/table page's Selection section is now a live demo.

0.2.61 — 2026-07-03

U6 a11y audit — cross-cutting pass (reduced motion + RTL, @weave-framework/ui). Completes the U6 accessibility audit. Reduced motion: a new reduced-motion() mixin (included automatically by all-styles()) emits one @media (prefers-reduced-motion: reduce) block, scoped to weave-* classes, that collapses every transition and animation the library owns — including the previously-unguarded infinite Progress-Bar and Progress-Spinner loops — to an instant duration, while keeping animation end-states intact. It never touches the consumer's own markup, and is exposed standalone for per-component compiles. RTL: the cheap, direction-safe spacing swaps are now logical (margin-inline-* on Chips/Paginator/Snackbar/Stepper); the deeper RTL work (bidi-aware keyboard arrows, fill/sticky positioning) is a scoped follow-on. With this, all 37 styled components have been audited across roles/states, keyboard, focus, reduced-motion, and RTL, with every fix pinned by a test.

0.2.60 — 2026-07-03

U6 a11y audit — Batch D (power-user, @weave-framework/ui). Audited Menubar, Popover-edit, and the Table column-resize grip. One genuine fix, pinned by a failing-first test: the Table's role="separator" resize grip now exposes aria-valuenow (the current column width, reactive as you resize) and aria-valuemin (the min-width clamp) — the WAI-ARIA window-splitter values it was missing (aria-valuemax is intentionally omitted since a column has no hard maximum). Menubar and Popover-edit audited fully conformant (roles/states, keyboard, focus). Reduced-motion and RTL (arrow/drag direction) findings are batched into the centralized cross-cutting pass.

0.2.59 — 2026-07-03

U6 a11y audit — Batch C (complex/data, @weave-framework/ui). Audited the 10 complex components (Tabs, Sidenav, Expansion, Stepper, Slider, Paginator, Table, Tree, Datepicker, Timepicker). Three genuine ARIA fixes, each pinned by a failing-first test: <Datepicker> now exposes aria-controls from its combobox trigger to the calendar panel (set on open, cleared on close), matching Select/Autocomplete; <Timepicker> spinbutton columns now carry the APG-required aria-valuemin/aria-valuemax (hour 0–23 or 1–12 by 12/24h, minute 0–59); <Sidenav> declares aria-modal="true" on the over-mode drawer while open (it already trapped focus and closed on Esc). Everything else audited conformant on roles/states, keyboard, and focus; reduced-motion and RTL findings are batched into the upcoming centralized cross-cutting pass. No behaviour change beyond the added ARIA.

0.2.58 — 2026-07-03

U6 a11y audit — Batch B (overlay, @weave-framework/ui). Audited the 8 overlay components (Tooltip, Menu, Context-Menu, Dialog, Bottom-Sheet, Snackbar, Select, Autocomplete). The focus machinery is sound — modal focus-trap activates after attach and restores focus on close; non-modal surfaces don't steal focus. One genuine fix: <Autocomplete> used to set aria-controls once and leave it pointing at its (detached) listbox after close; it now sets aria-controls on open and removes it on close, matching <Select> (pinned by a failing-first test). Reduced-motion and one RTL (Snackbar start/end positioning) finding are batched into the upcoming centralized cross-cutting pass; modal background inert/aria-hidden is logged as a scoped follow-on (the components are already aria-modal-conformant). No other behaviour change.

0.2.57 — 2026-07-03

U6 a11y audit — Batch A (foundational, @weave-framework/ui). Audited the 17 foundational components (Button, Button-Toggle, Icon, Badge, Card, Toolbar, List, Grid-List, Progress-Bar/Spinner, Checkbox, Radio, Slide-Toggle, Form-Field, Input, Chips, Ripple) across roles/states, keyboard, and focus management: all conformant — no behavioural defects found (several speculative findings were verified against the source and rejected). Added a regression test pinning that <ButtonToggle>'s aria-checked tracks its bound value signal reactively after mount. The only genuine issues are reduced-motion (unguarded CSS animations/transitions) and a few RTL physical-property sites; both are batched into the upcoming centralized cross-cutting pass rather than fixed per-component. No behaviour change ships in this version.

0.2.56 — 2026-07-03

U6 a11y audit — start (@weave-framework/ui). First unit of the structural accessibility audit (see UI-PLAN-U6.md / UI-A11Y-AUDIT.md): the pre-identified M9 — Select finding. The <Select> combobox trigger now exposes aria-controls pointing at its listbox (the listbox gained a stable id; the attribute is set on open and removed on close, since the popup is detached while closed), and Space now selects/toggles the active option in the open listbox exactly like Enter (WAI-ARIA APG listbox behaviour — previously Space only worked when no option was active). Two failing-first tests pin both. No visual/token change. The U6 scope is structural a11y only (roles/states, keyboard, focus, reduced-motion, RTL); contrast is consumer-owned and intentionally out of scope.

0.2.54 — 2026-07-03

Security hardening (CodeQL code-scanning). Fixed the flagged findings on the published packages, no API or behaviour change: the weave dev static-file handler now rejects path traversal (a requested asset that resolves outside servedir returns 403); the router's basename normalizer and the compiler's template/styles extractor drop polynomial-ReDoS regex shapes (non-regex trailing-slash trim; the optional type-annotation match is bounded to a single line); and the gen-lucide-icons build tool strips HTML comments to a fixpoint. The remaining CodeQL findings (compiler codegen constructing code from the developer's own compile-time source; <Icon> markup that is always run through sanitizeSvg before innerHTML) were reviewed as false positives and dismissed.

0.2.53 — 2026-07-03 (first CI npm release since 0.2.0)

Release automation: a [publish]-marked commit → GitHub Actions publishes all @weave-framework/* + create-weave to npm (provenance) and cuts a GitHub Release from RELEASE-NOTES.md. See RELEASE-NOTES.md for the highlights shipped in this release.

0.2.52 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

polish + version sync. Two low-risk correctness fixes (each with a test that fails without it): numeric bind:value compares with Object.is, not !==, so a NaN model value no longer always clobbers a mid-edit input (NaN !== NaN was always true); validators.pattern clones a g/y regex without those flags, so .test() is no longer stateful across calls (it alternated as lastIndex advanced). Also synced the private root package.json to the lockstep version (was 0.2.32). Deferred (riskier behaviour changes, tracked for a dedicated pass): custom-element disconnect-on-move grace, connectedPosition listener cleanup between detach/attach, dropList unconditional preventDefault, ParseError line:col, first-memo equals(undefined,…). 962 tests green. Phase A complete.

0.2.51 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Icon SVG sanitization (security). M5<Icon> now sanitizes any SVG before it reaches innerHTML (both the svg/registry markup and a fetched src): a zero-dep sanitizeSvg parses it as image/svg+xml (nothing executes on parse) and strips <script>/<foreignObject>, every on* event-handler attribute, and javascript: URLs — closing a <svg onload=…> execution vector. Also, <w:element this="…"> now refuses to build a <script> element (a dynamic tag is attacker-influenceable and would execute). Both have tests that fail without them. 961 tests green.

0.2.50 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

reactivity performance. M1 — block/component construction is now wrapped in untrack: ifBlock's branch, eachBlock's renderRow/empty, and every defineComponent instance, so a signal read synchronously during render no longer subscribes the enclosing block/effect (their own bindings self-subscribe) — an unrelated change won't re-run a whole @for reconcile or re-instantiate a component. M2eachBlock wraps its per-row positional writes (item/index/count) in a single batch, so a binding that reads more than one recomputes once per reconcile instead of up to three times per row. Both have tests that fail without them. 959 tests green.

0.2.49 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

compiler rewrite robustness. H4 — the expression rewriter now (a) resolves bindings inside a template literal's ${ … } (so {{ `Hi ${name}` }} becomes `Hi ${ctx.name}` instead of leaving name a bare global — spliced in WITH source-map segments), and (b) expands object shorthand, so {{ { name } }} emits { name: ctx.name } instead of the invalid { ctx.name }; freeIdentifiers scans ${ … } too, so auto-scope infers those names. M4inferCtxNames' declared set is now per-scope, not global: a @for item / @let / @if (… as x) / await-alias / snippet-param name is subtracted only within its own block, so the same name used as component data elsewhere is still inferred as ctx (snippet names stay template-wide via a pre-pass). Both have tests that fail without them. 957 tests green.

0.2.48 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Select reactivity + parser strings. H3Select no longer builds its option listbox once and caches it; a reactive effect (re)renders the open panel's options from the current props.options, so async-loaded or edited options reflect live and every re-open renders fresh (mirrors Autocomplete). M3 — text interpolation now uses the same brace-balanced, string-aware scan as attribute {{ }}, so a literal }} inside a string ({{ fn("}}") }}) or an inner object literal no longer cuts the expression short at a naive indexOf('}}'). Both fixes have tests that fail without them. 952 tests green.

0.2.47 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

reactive-core hardening. Two verified core fixes, each with a test that fails without it: H1computed() now registers an owner-disposer, so a memo reading a long-lived signal (router / i18n / store / @let) is detached (unlink + cleanups) on unmount instead of leaking its subscription (and closure) forever; reads after disposal recompute and re-link (Solid semantics). H2 — a memo that throws is now left DIRTY instead of silently CLEAN, so the next read recomputes (and re-throws, or succeeds once fixed) rather than returning a stale value — restoring fail-loud. Investigated M8 (runaway-loop guard): not reachable — markDirty's DIRTY-guard + eager synchronous flush already terminate mutual/self cycles; added a loop-safety regression test, no hot-path guard. packages/runtime/src/reactive.ts. 949 tests green.

0.2.46 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Session wrap-up (docs). No code change — added a cross-cutting UI Library (U0–U5) section to NOTES.md (the arc + the durable decisions/gotchas; per-milestone detail stays in UI-PLAN-U<n>.md), refreshed HANDOFF + the auto-memory. U4 + U5 complete; next is U6. Not published, not mirrored.

0.2.45 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Popover-edit — inline cell editing (U5 §5.3). This completes U5.

UI (@weave-framework/ui) — ./popover-edit

  • popoverEdit(host, config) (a use:popoverEdit action) — click / Enter / F2 opens a non-modal CDK-overlay editor (the U3 overlay-republic chrome) seeded from config.value(). Enter and click-away commit (onCommit), Esc cancels; focus moves into the editor and back to the host. Default editor = a text field sharing Input's field-underline (RULE #1); a custom editor factory ({ element, read, focusTarget? }) supplies a Select/date/etc. aria-haspopup=dialog. Deferred: Table column.editable wiring, multi-cell edit.
  • Gates: 946 tests (+8); verify:ui-sass 287 (+1); typecheck + eslint . clean.

✅ U5 (Experimental) COMPLETE — Table column-resize · Menubar · Popover-edit. (Dropped the standalone "selection" widget — the U4 CDK SelectionModel already closes it.) Next: U6 (harnesses + docs + gallery).

0.2.44 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Menubar — an app menu bar (WAI-ARIA menubar, U5 §5.2).

UI (@weave-framework/ui) — ./menubar

  • <Menubar menus onSelect> — a role=menubar of top <button role=menuitem>s; each opens the shared Menu panel (menu-core.openMenuPanel, so the panel chrome / roving / typeahead / Esc / backdrop are reused — RULE #1, no new dropdown). Roving Left/Right/Home/End + typeahead; ArrowDown/Enter/Space open (focused on the first item); click toggles; Left/Right switch to the neighbour menu while one is open; Esc closes + returns focus. onDispose tears down any open dropdown. The dropdown reuses .weave-menu.
  • Deferred: nested submenus.
  • Gates: 938 tests (+9); verify:ui-sass 286 (+1); typecheck + eslint . clean.

0.2.43 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Table column-resize (U5 §5.1) + a datepicker.browser.ts typecheck fix.

UI (@weave-framework/ui) — ./table

  • Column resize — a per-column resizable (or table-level resizableColumns) puts a role=separator grip on each resizable <th>. Pointer drag via the CDK draggable (axis x) sets a live width (clamped to minWidth, default 48); keyboard Arrow Left/Right resize by 16px. Widths ride an internal signal (a controlled columnWidths prop wins) so widthCss + the sticky-offset maths recompute reactively. Emits onColumnResize({ key, width }); [data-resizing] marks the table during a drag. Deferred: double-click auto-fit, column reorder.
  • Fix: datepicker.browser.ts had two test-only type errors (a void arrow returning a boolean; a 3-arg matchRe) that slipped into 0.2.42 (committed after eslint but before tsc). Restored a clean typecheck.
  • Gates: 929 tests (+3); verify:ui-sass 285 (+1); typecheck + eslint . clean.

0.2.42 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Datepicker text-entry (opt-in editable) + the U5 sub-plan is written.

UI (@weave-framework/ui) — ./datepicker

  • <Datepicker editable> — swaps the design's button trigger for a typeable input-as-combobox (role moves to the input; the wrapper drops its role). Typing + Enter/blur parses via the CDK adapter.parse → commits (clamped + normalized to the display format), OR flags aria-invalid + --invalid and keeps the text. The calendar icon becomes a toggle button; ArrowDown opens the calendar; clear × empties. Default (non-editable, the design's button) is unchanged. New __input + __icon-button styles.
  • Gates: 926 tests (+6); verify:ui-sass 284 (+1); typecheck + eslint . clean.

Plan

  • UI-PLAN-U5.md written (Experimental milestone): Table column-resize · Menubar · Popover-edit.

0.2.41 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Tree reorder + a dropList keyboard opt-out.

UI (@weave-framework/ui) — ./tree, ./cdk

  • <Tree reorderable onReorder> — a per-node __drag-handle via the CDK dropList (handle selector, so node clicks still select/expand). onReorder({ previousIndex, currentIndex }) — indices over the visible node order (visible()[i].node); the consumer applies it. (Hierarchy-aware reparenting is a deferred refinement.)
  • CDK dropList — new keyboard?: boolean (default true). List + Tree pass keyboard: false so the listbox/tree keeps Space/Arrows for selection + roving (dropList's Space-to-lift would otherwise hijack them).
  • Gates: 921 tests (+4); verify:ui-sass 283 (+1); typecheck + eslint . clean.

0.2.40 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

List reorder — drag-to-reorder rows (via the CDK dropList).

UI (@weave-framework/ui) — ./list

  • <List reorderable onReorder> — a per-row __drag-handle (⠿ grip) wired via the CDK dropList with a handle selector, so a row-body click still selects and only the handle starts a drag. Emits onReorder({ previousIndex, currentIndex }); the List is controlled (the consumer reorders items). New handle tokens + touch-action: none.
  • Gates: 917 tests (+3); verify:ui-sass 282 (+1); typecheck + eslint . clean.

0.2.39 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Bottom Sheet drag-to-dismiss — the U3-deferred gesture, now unblocked by the CDK Drag & Drop (§4.11).

UI (@weave-framework/ui) — ./bottom-sheet

  • openBottomSheet({ dragToDismiss }) (default true) — a top __handle grabber wired via the CDK draggable (axis y): dragging the handle down translates the sheet; releasing past max(80, 0.3·height) closes it, else it snaps back. New handle tokens + touch-action: none.
  • Gates: 914 tests (+3); verify:ui-sass 281 (+1); typecheck + eslint . clean.

0.2.38 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Timepicker — a time field + spinner popover (U4 §4.14, Phase D). This completes U4.

UI (@weave-framework/ui) — ./timepicker

  • <Timepicker> — the design's spinner-column variant: a Select-style trigger field (shared field-underline chrome) + clock icon opens a CDK-overlay panel of hour ▲/▼ : minute ▲/▼ role=spinbutton columns + an AM/PM toggle (12-hour locales). 12h vs 24h is derived from the locale (use24 override); step (minutes, default 5); min/max clamp the committed time.
  • Value — a neutral { hours, minutes } (24-hour internal). Binding follows the Weave form convention (value/onChange OR a control; touched-on-close; aria-invalid).
  • Keyboard — Arrow Up/Down per column (aria-valuenow/-valuetext), Esc close. Deferred: the interval- listbox alternative, text-entry parsing, seconds.
  • Gates: 911 tests (+13); verify:ui-sass 280 (+5); typecheck + eslint . clean.

✅ U4 (Complex / data) COMPLETE — 14 units: Expansion · Tabs · Stepper · Slider · Paginator · Sidenav · CDK SelectionModel/DataSource · CDK Virtual Scroll · Table · Tree · CDK Drag&Drop · CDK Date-adapter · Datepicker · Timepicker. Next: U5 (Experimental), then U6 (harnesses + docs + gallery).

0.2.37 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Datepicker — a date field + calendar popover (U4 §4.13, Phase D).

UI (@weave-framework/ui) — ./datepicker

  • <Datepicker> — a Select-style trigger field (shares Input's field-underline chrome; the design's field is a button trigger) with a calendar icon, opening a CDK-overlay calendar (non-modal — transparent backdrop + Esc). Calendar = a role=grid month view: ‹/› month nav, a locale weekday header (reordered by firstDayOfWeek), role=gridcell day buttons — selected = accent fill + white, today = an inset accent ring.
  • Keyboard: Arrows (day), PageUp/Down (month), Shift+PageUp/Down (year), Home/End (week edges), Enter/Space (select), Esc (close + return focus). All date math via the CDK Date adapter; min/max + a dateFilter predicate disable cells.
  • Binding: the Weave form convention — value (Date | null) + onChange, OR a control Field<Date> (touched-on-close, aria-invalid). Compose with <FormField> for label/hint/error.
  • Deferred (noted): text-entry parsing (the adapter.parse is ready — a cheap follow-up), date-range, year-picker view.
  • Gates: 898 tests (+12); verify:ui-sass 275 (+6); typecheck + eslint . clean.

0.2.36 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

CDK Date adapter — the zero-dep date model under the pickers (U4 §4.12, Phase D).

UI (@weave-framework/ui) — ./cdk

  • createDateAdapter({ locale?, firstDayOfWeek? }) → DateAdapter — native Date + Intl only (rule #1, no date library). Neutral value type = a plain local-midnight Date.
  • Arithmetic: create/clone/today; add days/months/years (overflow-clamped — Jan 31 + 1 month → Feb 28/29; DST-safe); start/end of month + days-in-month (leap-year correct, incl. 1900/2000); compare / isSameDay / clamp.
  • format via Intl.DateTimeFormat; parse = ISO yyyy-mm-dd fast-path + the locale's numeric field order (from formatToParts), rejecting overflow (Feb 30 → null) + expanding 2-digit years.
  • Calendar helpers: locale firstDayOfWeek (Intl.Locale weekInfo, override-able), getDayOfWeekNames / getMonthNames (JS order). Deferred: custom parse masks, non-Gregorian calendars.
  • Gates: 886 tests (+13); verify:ui-sass 269 (unchanged — headless); typecheck + eslint . clean.

0.2.35 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

CDK Drag & Drop — the headless pointer-drag + reorder engine (U4 §4.11, Phase D).

UI (@weave-framework/ui) — ./cdk

  • draggable(el, opts) — standalone free-drag via pointer capture: an offset() signal (constrainable to one axis), a threshold (click-vs-drag), a handle, and onStart/onMove/onEnd. The single-gesture case (the Bottom Sheet's drag-to-dismiss).
  • dropList(container, opts) — a reorderable list: the insertion index = the count of non-dragged sibling midpoints the pointer has crossed; dragging()/activeIndex()/overIndex() signals; onDrop({previousIndex, currentIndex}). Full keyboard DnD (Space lift → Arrows move → Space drop, Escape cancel). Event delegation.
  • moveItemInArray(array, from, to) — immutable reorder applier (clamps to).
  • Deferred (noted): cross-list transfer (connectedTo), a drag-preview helper. Unblocks the U3 Bottom Sheet drag-dismiss + reorderable List/Table-row/Tree.
  • Gates: 873 tests (+10); verify:ui-sass 269 (unchanged — headless); typecheck + eslint . clean.

0.2.34 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Tree — controlled expanded (follow-up to 0.2.33).

UI (@weave-framework/ui) — ./tree

  • <Tree expanded> — expansion is now controlled (expanded? is the source of truth) OR uncontrolled (defaultExpanded), the Tabs convention. When controlled, expand/collapse emit onExpandedChange without self-mutating — the owner applies the next set. Pinned by a guard test (no self-open — the prop still says collapsed). Added after review flagged that deferring it was wrong (cheap + the library's own binding convention). No CSS change.
  • Gates: 863 tests (+1); verify:ui-sass 269 (unchanged); typecheck + eslint . clean.

0.2.33 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Tree — the WAI-ARIA role=tree hierarchy (U4 §4.10, Phase C).

UI (@weave-framework/ui) — ./tree

  • <Tree> — a template-based hierarchical disclosure surface (keyed @for over the visible flattened nodes, arbitrary content via @render). Two data models: nested (a children accessor, node.children by default, recursed; descendants show only while expanded) or flat (pass getLevel → a DFS scan hides descendants of collapsed nodes). Both emit aria-level/-setsize/-posinset.
  • Expansion + selection ride the CDK SelectionModel (expansion uncontrolled + onExpandedChange; selection optional selectable single/multiple + onSelectionChange + compareWith; selected node = accentSoft tint + 2px accent left border, the List visual).
  • Keyboard = CDK listKeyManager (vertical, typeahead) for Up/Down/Home/End + a single roving tab stop, plus Right (expand / step into first child) / Left (collapse / move to parent) / Enter-Space (activate).
  • Indent = an inline --weave-tree-depth custom prop × the indent token (design: depth × 18px); rotating ▸ disclosure marker (CSS ::before). ./tree subpath (JS + SCSS); tree-overrides() wired.
  • Deferred (noted): checkbox nodes + parent/child cascade, drag-reorder (Phase D DnD), virtual body, controlled expanded.
  • Gates: 862 tests (+13); verify:ui-sass 269 (+5); typecheck (all 12 pkgs) + eslint . clean.

0.2.32 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Regression guard for the 0.2.31 double-fire fix. The existing suite passed the fix independently of it (the Table test only survived via idempotent select; the isolated Checkbox test never exercised the runtime forward loop) — so the fix was not actually pinned.

Compiler (@weave-framework/compiler) — component.browser.ts

  • defineComponent does NOT forward a data-callback prop (no double-fire) — composes a child that consumes onChange via a setup binding fired by an inner <input>'s bubbling change (mirrors Checkbox). Asserts it fires once. Verified it fails (calls=2) when defineComponent is reverted to the old /^on[A-Z]/ forward — a true guard.
  • defineComponent forwards a real on:X event to the child root — asserts $events-marked events are still forwarded (guards the other direction — that the fix didn't break Button-style composition).

0.2.31 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Framework fix — composed-component event handlers no longer double-fire. This removes the Table selection workaround (idempotent select + bool-or-Event normalization) and is the correct foundation for every future component that passes a data-callback prop to a child.

Compiler (@weave-framework/compiler)

  • A component tag now emits a hidden $events marker listing only its real on:X event-attr prop keys (e.g. <Checkbox on:click … onChange={{…}}>$events: ['onClick'], onChange excluded). Data-callback props (onChange, onInput) are ordinary reactive getters, not events.

Runtime (@weave-framework/runtime)

  • defineComponent now auto-forwards only the $events keys to the child root element (previously it forwarded any /^on[A-Z]/ function prop). A data-callback consumed inside the child (e.g. Checkbox's onChange, fired by its own on:change) is no longer ALSO attached as a bubbled DOM listener — so it fires exactly once instead of twice. on:X forwarding (Button's click, etc.) and consume-by-name are both unchanged.

UI (@weave-framework/ui)

  • Table selection simplified now that the double-fire is gone: toggleSelect(row, checked)
    • onSelectAll(checked) take a plain boolean; the checkedFrom bool-or-Event normaliser and the idempotent-select workaround are removed.

0.2.30 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Table — RULE #1 correctness: the selection checkboxes now COMPOSE the real Checkbox component (not a restyled native input), which forced a rewrite of the Table to a template-based component.

UI (@weave-framework/ui)

  • Table is now a template-based component (was built imperatively). The rows are a keyed @for over the sorted data, cells mount via @render, and — crucially — the selection column composes the real <Checkbox> (full behaviour + one checkbox visual in the library), exactly like Paginator composes <Button>. The earlier native-<input> + .weave-table__checkbox restyle (a RULE #1 violation the user caught) is gone, along with its tokens. A selectable Table therefore pulls in @weave-framework/ui/checkbox styles.
  • Gotchas fixed along the way:
    • Nested @for (rows × columns) can't reference the outer row — the compiler names every loop item _row, so the inner loop shadows it. Cells are pre-resolved per row into a cellsFor(row) array so the inner @for only touches its own item.
    • Rows are keyed by object identity (or trackBy), not index, so a sort reorders the existing DOM by identity instead of stranding one-shot @render cell content.
    • The composed <Checkbox>'s onChange fires twice (once as its data callback, once via the runtime's event auto-forward to the child root). The Table's handlers read the checkbox's actual checked state and use idempotent select/deselect/setSelection — so the row lands in the right state regardless. aria-expanded is emitted as a string.
  • 13 browser tests (all green); verify:ui-sass 262; full typecheck + eslint clean. Live-verified: select/deselect a row, select-all + indeterminate + uncheck, expand/collapse.

0.2.29 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Table follow-up — inner vertical scroll with a fixed header.

UI (@weave-framework/ui)

  • <Table maxHeight> — caps the body height so the <tbody> scrolls vertically inside the table while the sticky header stays pinned (previously the header only stuck to the page because the scroll box had overflow-x only). The scroll box is now overflow: auto (both axes), so a max-height gives an inner vertical scroll and a wide table an inner horizontal scroll — sticky header + sticky columns both pin to the scroll-box edges. Live-verified (body scrolls 200px, header delta 0; sticky Order column offset; live show/hide columns).

0.2.28 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

U4 Phase C — the Table, the flagship data surface.

UI (@weave-framework/ui)

  • New <Table> component (@weave-framework/ui/table) — a real <table> (native <thead>/<tbody>/<th scope=col>/<td> semantics) driven by a column-def + DataSource API. Built imperatively in setup() (cells are arbitrary Nodes and the body is reactive over data/sort/selection/expansion — text interpolation carries neither), styled by the Weave design (hairline rows, compact 34px, accent-as-a-mark).
  • Sort headers: sortable <th> = a <button> cycling asc→desc→none (asc↔desc with disableClear), sets aria-sort, shows the accent arrow, single active column; emits onSort and convenience client-side sort for array/signal sources (a custom DataSource owns its own order).
  • Row selection via the CDK SelectionModel: leading checkbox column, header select-all + indeterminate, single/multiple, aria-selected on the <tr> + accentSoft tint + 2px accent left border; onSelectionChange / bring-your-own model.
  • Beyond the base plan (user-requested): sticky columns (column.sticky: 'start'|'end', any column, computed offsets; the select/expand columns auto-stick), show/hide columns (column.hidden, reactive when columns is bound), and expandable detail rows (expandable + detail(row), chevron toggle + full-width detail <tr>, expansion state in its own SelectionModel). Sticky header + hairline separators + tabular-nums numeric cells.
  • Virtual body: plain-scroll in v1; the CDK virtualScroll hook is ready for the follow-on. ./table subpath (JS + SCSS).
  • 13 browser tests (structure, node cells, sort cycle + client-sort + aria-sort, selection + select-all + indeterminate + single, expandable, show/hide, sticky column, ArrayDataSource + reactive signal source, numeric); verify:ui-sass 262 (+9). Live-verified in the gallery.

0.2.27 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

U4 Phase C — the Virtual Scroll headless engine.

UI CDK (@weave-framework/ui/cdk)

  • virtualScroll(options) (cdk/virtual-scroll.ts) — the rendered-window engine under large Table/Tree bodies + long lists. Given a viewport element, a fixed itemSize and a total (number or getter), it computes the buffered slice to render — renderedRange() [start, end), scrollOffset() (top spacer), endOffset() (bottom spacer), totalSize() — all as signals; plus scrollToIndex(), measure(), destroy(). Fixed-size strategy first (autosize is a follow-on). Built on the U1 onScroll dispatcher + resizeSignal (ResizeObserver → viewport height); renderedRange is a computed with a start/end equality guard so it only notifies when the window actually changes (not every scroll pixel). Edge-cased: empty/short lists never produce negative ranges; the window clamps to total.
  • 11 headless tests (window math at scroll 0/mid/end, buffer overscan + top clamp, empty + short lists, reactive total, sub-item-scroll stability, scrollToIndex clamp, ResizeObserver recompute).

0.2.26 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

U4 Phase C (start) — two headless CDK data primitives, built before Table/Tree.

UI CDK (@weave-framework/ui/cdk)

  • selectionModel<T>(options) (cdk/selection-model.ts) — the signal-native selection engine under Table rows / Tree nodes / List multi-select. select/deselect/toggle/ setSelection/clear, single vs multiple, an optional compareWith (object copies match by key), reactive selected()/count()/isEmpty()/isSelected(), and an onChange delta stream ({ added, removed }) that only fires on a real change. Zero DOM.
  • DataSource<T> + ArrayDataSource (cdk/data-source.ts) — the collection-viewer contract a Table/Tree consumes so paging/sorting/filtering/virtualization can be swapped without the component knowing: connect(viewer?) → Computed<T[]> (read-only signal) / disconnect(). ArrayDataSource wraps a static array or a signal (reactive updates propagate through connect()); isDataSource() guard. Signal-native, no RxJS.
  • 15 headless tests (single/multi transitions, no-op guards, compareWith identity, delta payloads, reactivity; DataSource static + reactive-signal propagation + read-only view).

0.2.25 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

U4 Phase B — the Sidenav responsive layout shell.

UI (@weave-framework/ui)

  • New <Sidenav> component (@weave-framework/ui/sidenav) — a __drawer beside a __content with three modes: side (drawer in flow, pushes content), over (drawer floats over a dimming backdrop; a modal focus context — CDK focus-trap in, Esc + backdrop-click close), push (drawer floats + shifts content). Responsive: omit mode and it consumes the CDK breakpointSignal — below the Weave Narrow breakpoint (900px) it auto-switches to over + closed, above to side + open. This fulfils the off-canvas drawer deferred from the U2 Toolbar (a Toolbar hamburger toggles it).
  • Open state follows the Weave convention: controlled opened (getter) + onOpenedChange, or uncontrolled defaultOpened; imperative open()/close()/toggle()/opened() exposed via the api ref callback (like Input's onInputRef). Drawer edge via position: 'start' | 'end'.
  • State rides root modifier classes (--side/--over/--push, --opened, --end, --backdrop) — no per-element state class. The over backdrop reuses the shared overlay scrim token (--weave-sidenav-backdrop: var(--weave-overlay-backdrop)) so every scrim in the library reads identically. Fully tokenized SCSS (RULE #1). 12 browser tests (structure/modes/controlled/api/Esc/responsive/focus-trap); verify:ui-sass 253.

0.2.24 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

Completes the RULE #1 tokenization pass — the last per-component spacing/typography literals now resolve from each component's own token schema (no hard-coded values left).

UI (@weave-framework/ui)

  • Tokenized the remaining literals in 10 components (chips, menu, dialog, bottom-sheet, card, tooltip, snackbar, list, expansion, autocomplete): line-height (1.3/1.4/1.5), font-weight: 400 subtext weights, small gap/padding-y values, the chips × glyph size + edge nudge, and the menu divider height — each now a var(--weave-<c>-…) backed by a new key in the component's _tokens.scss. Structural constants (0, 100%, 50%, line-height: 1 resets, 1px hairline borders, keyframe transforms) stay literal, matching the established convention. Compiled CSS is byte-identical (token value = former literal) — verify:ui-sass 245 unchanged, confirming no visual change.
  • ✅ RULE #1 fully satisfied across the UI library: every component composes the real child components and every SCSS value flows from a token schema.

0.2.23 — 2026-07-02 (unpublished; on main, ahead of the 0.2.0 npm release)

The U4 (complex/data) build plus a mid-milestone architecture correction — RULE #1: UI components must compose already-built components, never re-create them.

Framework (runtime/compiler)

  • defineComponent auto-forwards component-level on:X handlers to the rendered root element. <Button on:click={{…}}> now just works — a component never re-declares events to be composable. Skips events the component consumes itself (a setup binding shadows it).

UI — RULE #1 composition (no duplicates)

  • Components now compose the real components instead of re-creating look-alikes: Stepper Back/Continue → <Button>; Paginator page/nav → <Button>, jump field → <Input>, page-size → <Select>; Autocomplete field → <Input>.
  • Shared style helpers (single source) in styles/_helpers.scss: field-underline, clear-button, checkmark — used by Input/Select (and Autocomplete via Input) and Checkbox/Stepper. No duplicated field chrome or glyphs.
  • Button gains ariaCurrent; Input gains onInputRef (composers add combobox ARIA) and clear() dispatches a real input event so composers react.
  • Internal src/internal/compose.ts (toComponent) + the _c child-component map power composition in the library's own tests/gallery (a real weave build emits the same shape).

UI — new U4 components (Phase A)

  • Expansion Panel (accordion), Tabs, Stepper, Slider, Paginator.

Gates

  • 796 browser tests, verify:ui-sass 245, monorepo typecheck + eslint . — all green.

0.2.0 — 2026-06-30

First npm release: 10 @weave-framework/* packages + create-weave. Framework (runtime/ compiler/store/router/forms/i18n/data/cli), editor tooling, docs site, and U0–U3 of the UI library. See NOTES.md / git history for detail.