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/publishskill /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 intoindex.htmlis the entry, not one of the app's lazy routes. esbuild setsentryPointon every code-split chunk it names after a module, so alazy()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-namebuilds an app with forty lazy routes and asserts the injected script is the entry; the count is the point, becauseverify:base-pathhad 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 bareEmpty template fragmentwith no offset, so the dev overlay andweave checkhad nothing to point at and the message described fragments while the mistake was a brace. It is now a locatedParseErrorcarrying 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 byverify:hostile-input, which mutates the repository's real templates — the first template to expose it was the docs' own callout. -
fix(runtime):
dom.tscarried a raw NUL byte where\x00was 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 —greprefused to search it. -
build: the
runtime/domgzipped budget goes 5,504 → 5,632 (+128), a deliberate call recorded intools/verify-size.mjs: thehref-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, soverify:prosepassed while the next build wrote them straight back. The gate now scansskills/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, afade, afly) 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.mjsholds 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
:::callouttitle renders its markdown instead of publishing it.titleis a string prop set as text, so a title written asIt 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.mjsfails 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.mjsmeasures 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) andtools/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:valuefor a textarea, aneffectwritingdocument.title). Pinned by six cases inpackages/compiler/test/entity-text.smoke.mjs, three of them asserting silence on markup that is fine. -
fix(cli):
weave check --fixnow repairs everything it is certain of in one run. Every declarationgrow-setupoffers is inserted at the same offset — the end ofsetup's body — so they all overlap, andapplyFixesskips 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+doneon the first run andage+labelonly on a second, against all four in one. Pinned by a fourth case inpackages/cli/test/check-fix.smoke.mjs. -
feat(compiler): the template linter warns on an HTML entity in a template. A Weave template is TEXT —
escapeTextwrites&as&on the way into the emitted<template>, which is exactly what lets a template hold<,{and a code sample verbatim — so—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 && band?a=1&b=2stay silent, and the message names the character to type instead (with a fix, soweave check --fixapplies it). Pinned bypackages/compiler/test/entity-text.smoke.mjs(14 cases including a control that the linter fires at all, wired intopnpm verify:entity-text), and confirmed to surface in a realweave buildwith the offending line framed. -
fix(check):
weave checknow reads the project's own.d.tsfiles.collect()skipped every declaration file outright — right to skip as a COMPONENT, wrong to drop from the program, since an ambientdeclare moduleordeclare globalonly takes effect when its file is a root of it. So an untyped npm import reportedTS7016and printed TypeScript's own advice ("add a new declaration (.d.ts) file") at an author who had already added one, whiletsc --noEmitwent green on the same tree.declare global, module augmentation, and an app's asset shims were all invisible for the same reason. Pinned bypackages/check/test/ambient-dts.smoke.mjs(4 cases, wired intopnpm 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 (ause:action, whose{ update, destroy }handle is the lifecycle you would otherwise write by hand), and one that toucheswindowat import time (which works inweave devand 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 --checkruns 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:
| written | before |
|---|---|
route('/doc/:id', { component: Page }) | rejected |
mountComponent(App, '#app') — every app's bootstrap | rejected |
defineCustomElement('x-page', Page) | rejected |
lazy(…, { loading: Spinner }) | rejected |
renderComponent(Page, props) — SSR | rejected |
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:
| written | declared |
|---|---|
bind:checked | signal(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 else | signal('') — 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 callednormalize, which RESOLVES..without refusing it, so an encoded..%2f..%2f..%2fpackage.jsonwas 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 fromexamples/demo/distisexamples/, where nopackage.jsonexists, 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 reachedreadFile. 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--restoreto 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 linkor afile:dependency onpackages/<name>resolves to./src/index.ts, since onlypublishConfigswapsmaintodistat 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-privateexists precisely for a machine where the pre-commit hook is missing — hooks are never cloned — but it listed fewer paths than the hook refused, soeditor/,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, withexamples/democarved 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'sscrollTodeleted, 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:allruns locally exactly what CI runs, by PARSING.github/workflows/ci.ymlrather 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 checkreports 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 checkexits non-zero on errors only, so a pipeline that was green stays green.weave check --fixapplies 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
clikwithclick". Both editors andweave checkrun the same code, so they cannot disagree about the same file. (VS Code0.6.3, WebStorm0.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 aSignal<T[]>— the implementation calls whatever function it is given, and acomputedis the natural thing to hand it.<Stepper steps>takes a getter, which is exactly what the documentedlinearexample uses to build steps out of signals.<Toolbar role>exists. The toolbar documentation has always shownrole="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:sizemeasured the rawtscemit, 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 devturned only aParseErrorinto a located esbuild error; every other failure was re-thrown, and an exception escaping anonLoadcallback 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 inwatchFilesso 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
baseinweave.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 — serveduser.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 devanswers 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 withroutesDir: 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' eventand a raw EADDRINUSE stack — for the most ordinary situation there is, a second terminal already runningweave dev. It steps to the next free port and says which one it took. weave --helpexited 1 with a single usage line, andweave build --helpran a build (wipingoutDir) 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 — whileweave checkreportedCannot 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 isweave checkcould pass with a type error plaintsc --noEmitrefuses. Every non-component.tsunder 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 staticonclick="…"is real HTML,xlink:hrefis 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,htmlandbodylive 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 Code0.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-:roottrap 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) andnx(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 inpnpm-workspace.yaml— pnpm 11 ignorespnpm.overridesinpackage.jsonand only warns — plus a direct range bump fornx, 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 made182_400come out as182ctx._400and the build die on generated code. Only a plain integer and a plain decimal survived;0xFF,0b1010,0o17,1e3,1e+3,1.5e-3,1nand 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 BigIntnsuffix — in BOTH scanners, because the analysis half would otherwise infer the tail of a literal as a context name and handsetupa 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: onexport const templatefollowed 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>withoutoptionValue/optionLabelcompiled 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 renderedundefined) — but a type that stops accepting previously-accepted code is a break either way, andVERSIONING.mddoes 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 demandoptionValue+optionLabelfor an option type that is not self-describing. The type used to say the accessors were optional while the runtime went on reading.valueand.label, so passing an API row type-checked clean and renderedundefinedin 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 ofT, so any array satisfied it. Self-describing means a plain string, or an object carryingvalue(labelfalls 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>(RequiredAccessorsandSelfDescribingOptionare 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 emptyoptions={{ [] }}(T = never) on the optional side instead of collapsing the props type tonever.
Fixed — generic components lost their type parameter (W-8)
- A component whose
setupis generic shipped a default export with the type parameter thrown away. Both producers of that default flattenedsetup's first parameter —Parameters<typeof setup>[0]in the.d.ts@weave-framework/uiships,F extends (props: infer P, …)in the virtual moduleweave checkand the editor type against — and TypeScript resolves an uninstantiated generic's parameter tounknown. 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 thatSelect<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 wroteSelectProps<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 overtypeof setup— the list has to be written out), by a single reader in the compiler that both producers call, so the shipped.d.tsand the editor cannot check different contracts. A non-generic component's emitted default is byte-for-byte unchanged. A genericsetupwhose props parameter carries no type annotation now fails the ui build naming the component, rather than silently degrading tounknown— 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
templatedeclaration 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 — threwweave: `template` must be a static stringabout its own prose, and one such file aborted the wholeweave checkwith 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
patchops is type-checked. (RFC 0008's last follow-on.) A#3extension 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@forsees that block's local, and the context is the base's plus the extension's own, which is whatextendSetupbuilds 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.htmlsaid: the viewport meta (so every generated page rendered unscaled on a phone),langand 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/templatesresolves relative URLs against/learn/, so with no<base>a sharedhref="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.DocumentOptionsgainshtmlAttrs. 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
returnnaming bindings the setup cannot see. Whensetupomits itsreturn, one is inserted exposing the names the template reads. For a#3patch extension those names include the BASE's — so a setup that could see only its ownpickwas handedreturn { items, pick, title }, and the component threwReferenceErrorthe 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 (staticclass="…"tokens andclass: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.vsixreplaces0.6.0, rebuilt onvscode-languageclient@10(whoseminimatch@10pulls the patchedbrace-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.vsixcarries 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.zipreplaces0.23.0— identical plugin code, rebuilt with nountil-build. The old build declareduntil-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
buildPlugindepended 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 buildPluginfetches 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
-
tooltiptakes aclass, 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-tooltipitself, 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
classleaves the panel's class list exactly as before.
2.2.1 — 2026-07-29
Fixed — router
-
A
guardorredirectonpath: '/'(or an index childpath: '') 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()[]andredirectTo()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 applyingredirectandguard, 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.falsekeeps 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.errornaming the cycle it followed (/users → /login → /users → …) instead of just rendering nothing, and a matched route with nocomponent— 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 amigration-plan.mdyou read before anything changes, and then — only if you say yes — writes the converted code intosrc/. Your Angular project is only ever read, and an existing file in the target is never overwritten.What it converts:
@Component→setup()+ a sibling template ·@Injectable({providedIn:'root'})→store()and a scoped one →createContext+provide/inject·@Pipe→ a plain function ·@Directive→ ause: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 testsnav.to, acanDeactivatetestsnav.from) ·HttpClient→@weave-framework/data·InjectionToken→createContext· RxJS → plain values, arrays, promises and signals (see below). An@NgModulebecomes no code — Weave has no modules — but a note records what it declared, provided and exported. -
weave migratetranslates RxJS instead of describing it. Weave has no stream primitive, so an app that finished a migration still importingrxjshad been moved, not migrated. Chains are now rewritten by folding the operators over the shape of their source:of(x)is one synchronous emission, somap(f)isf(x);of(a, b)/concat(…)/EMPTYare a finite sequence, somergeMapisflatMap,distinctis aSetandtoArrayis 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))]. ABehaviorSubjectbecomes asignal—.next(v)is.set(v),.valueis(),.complete()is dropped — andfirstValueFrom(x)isawait x, with the enclosing function markedasync. The signature follows the body: a folded chain'sObservable<T>becomesTorPromise<T>. This runs on converted components and services and on the plain.tsfiles carried across, which is where the streams usually hide; therxjsimports the rewrite made dead are pruned per binding.An operator with no equivalent stops its own chain, and the whole chain is left standing —
debounceTime,delay,scanover a live source and the rest of the genuinely time-based operators keep theirrxjsimport and theirObservablesignature, 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
rxjsimports and 0 liveObservablementions in the converted output, down from 3 and 6. -
weave migratefolds chains that start at a CALL, which is what real code has. The fold could only classify a literal source (of(…),concat(…),from(…)), sothis._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 returningObservable<…>before any file is converted, and the fold classifies calls to them as one emission.x instanceof Observablebecomesfalse(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 migrateno 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 oncethis.is gone, and a placeholder no longer names an Angular type the converted file does not import. -
weave migratederives 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
computedif 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 namedcomputedwithout importing it; the same hole swallowed everyinject(…). - 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
CrumbsResolverbecameloadCrumbson 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
anyand reported once, the same rule the value side already had.@angularimports are dropped, so a drafted signature sayingActivatedRouteSnapshotnamed nothing.
Repointing an import fixes the module, not the meaning — so
new X()andx instanceof Xon 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.
- Imports now come from what the finished draft NAMES, not from rules like "import
-
weave migrateasks 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 asrc/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 escapesrc/(absolute, a drive letter, a..segment) is refused and re-asked rather than resolved. -
weave migrateoffers 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 firstweave checkafter 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 (packageManagerinpackage.json, then the lockfile) and pinning each package to the version the source app declared, read from the nearestpackage.jsonat or above the migrated folder. Offered rather than run, because installing writespackage.jsonand 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.dependenciesordevDependenciesis 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 indevDependenciesvanishes undernpm ci --omit=dev. A package reached only throughimport typeis 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
importspecifiers 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 alongsideshell: 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 throwasReadonly is not a functionon creation.Computed<T>is() => T, so it becomescomputed(() => 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(…))becamesignal(any(…)). A type position is after:, inside<…>or in a union — not after(or,. - A
@foralias (let last = $last) was dropped from the header and left standing in the body, so the loop rendered once and thenlast 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 plainconst.
-
weave migrateguards 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 asconst _router = null as anywith 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 insideif (_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 rewrittenopened 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
useXwhile its importer asked forXService; 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
ShortenPipeis the functionshorteninpipes/shorten.ts. It does not know what anObservablein 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
formshadowed theformimported from@weave-framework/forms, so the draftedform({ … })called the signal (reported ten lines away as "Expected 0 arguments, but got 1"); a field built fromnew 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 ofActivatedRouteSnapshotmoved 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@angularnow 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
rxjsis replaced by Weave's reactivity, and then the converted files import it anyway — because apipe(…)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, withrxjsmarked 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(), orinject(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)becomesonDispose(afterEach(cb))— both operators are whatafterEachalready 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
afterEachleftimport { 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,@HostListenerand the decorator's ownhost: { … }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.x→class:x,style.width.px→style:widthwith the unit kept,attr.role→ the attribute, a bare target → the DOM property, and a listener →on:. Awindow:/document:listener is not an element binding at all — it becomes anonMountsubscription 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.tsbeside a.htmlis a component — so carrying a library's barrel there turned the shell into a component template, andweave checkbegan 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
tsconfigalias, 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 whereRouterlives is nonsense.A
@Directiveconverts 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, soupdatere-running the bindings actually works — and its host declarations become real work on the element the action is handed:effects for the bindings,addEventListenerfor the listeners, and adestroythat removes every one of them.this.el.nativeElementbecomes the element, since being handed it is whatElementRefwas 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 tosetProperty, which only knowsbackground-color, so the binding compiled, ran, and set nothing.Router.navigate([…])takes an array of commands; Weave'snavigatetakes the path. That is the only difference, so a small local shim joins the commands and nothing more —navigateByUrlalready takes a path and isnavigateoutright. 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 totruewith 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
: voidon everything — a method ending inreturn 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'simports: [ … ]is accounted for in the output instead of read past.A field carries what it held:
count = 0becomessignal<number>(0), notsignal<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 thesetupsignature —propDefaultsguarantees it a value there; optionality is for the parent, which is whatpropDefaultsalready 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;nullfor 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 existingvirtualScrollengine, 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, becausetrackBymoves existing DOM.It needs
maxHeightand a uniform row height (rowHeight, default 34);overscandefaults to 6.expandableis refused in combination, and so is a missingmaxHeight— 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 carriesaria-rowcountand each row its truearia-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 onbatchDepth, 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 inRangeError: 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.batchcould not help and was measured not to: it decrementsbatchDepthbefore flushing — precisely so the queued effects run unbatched — so every write inside them saw depth 0 and recursed anyway.flushnow refuses re-entry and lets the outer loop pick the work up. Nothing is deferred past the outermost write:queueis aSet, whose iteration visits entries added during the iteration, so an effect queued mid-drain still runs beforesetreturns.
Fixed — UI (CDK)
- An open overlay now follows its trigger when the page rearranges under a still window.
connectedPosition's live listeners werescrollandresizeonwindow— 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 aResizeObserver, and disconnects it indispose()alongside the two listeners. (Anoverlay.tscomment 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 neitherrootnorentry, so a package that ships components but has no app of its own could not declarestyleLanganywhere supported — andweave checkfailed on it outright.styleLangis 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 andweave checkruns;weave build/weave devstill require an entry and now say so themselves, naming both fields, instead of handing esbuild an undefined entry point. -
weave buildno longer fails whenpublicDiris left at its default, and no longer ships your project directory when it is.publicDirdefaults to the config's own directory andoutDirtodistinside it, so the copy step asked Node to copy a directory into itself and got a rawEINVAL … cannot copy … to a subdirectory of self— an app that simply omittedpublicDir, 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, sopublicDir: 'public'withoutDir: 'public/dist'works), and a build copies a static root only when the config declares one — copying an undeclared project directory intodist/would have shippedsrc/,node_modules/, the config and any.env.weave devstill serves the config directory by default, unchanged.
Fixed — security / tooling
-
brace-expansionis 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.7target would have let a future resolution land back on a vulnerable version. The pin is now>=5.0.8over<=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 viavscode-languageclient@9 → minimatch@5, so the VS Code extension moved tovscode-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 migrategate 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\wis string-escaped to a plainw— so it asserted "not adjacent to the letter w" instead. Measured: an index exportingrestorebut notstoresatisfied it.
Fixed — compiler
- A comment between the pieces of a split
template/stylesno longer fails the build. A long template is routinely split across lines with+(every@weave-framework/uicomponent does it), and annotating one of those lines is the next thing an author reaches for — but the extractor's+scan saw the/and reportedweave: `template`/`styles` must be a static stringabout 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, soweave checkline: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) saidunknown. Every imperative call site therefore needed a cast, which is most of the composition surface: a<Table>column'scell: (row) => Node | string, an<Expansion>panel body, any API taking aNode. Slots narrow the same way (() => Node). Pure narrowing — no runtime change.
Fixed — build tooling
- The
@weave-framework/uipublish build no longer breaks on a component that declarespropDefaultsorextend. 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 forpropDefaults(shipped since 1.5.17) and anextendSetup(…)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'strue → falseedge 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
hasMoreis false and re-arms when it flips back (a filter reset), disconnects on owner disposal, and re-observes through the action'supdatewhenroot/rootMarginchange. - Works in the viewport or inside any scroll container (
root), withrootMargin(default150px) deciding how early it fires. Composes withvirtualScrollrather than competing: that one decides what to render, this one when to fetch. - Not included: the data-side helper.
cursorList()overresourcewould belong in@weave-framework/dataand is a separate ask; this is the DOM half and stands alone.
- A short page chains.
Fixed — skills
weave-uiandweave-datadocumented two components that do not exist. Both taughtInfiniteScroll, andweave-uialsoCursorList, 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 atuse:infiniteScroll(above) and say plainly that it is headless and composes withvirtualScroll.- App-wide defaults for the date/time pickers —
provideDateTimeDefaults()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'suse24/stepand both date pickers'adapterreactive — they wereconsts 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/openBottomSheetcan host a live component (FW-18). A region (content/header/actions) now accepts a[Component, props?]tuple — or thecomponent(Comp, props)helper — and mounts it under its own owner, so the component'sonMount,effects andonDisposerun, 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 throughopenDialog, with no per-app mount/dispose adapter.- Before,
contentwasNode | string | (() => Node). A component factory type-checked as() => Node, so it compiled — buttoNodecalled 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, astringand a bare() => Nodefactory 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.
- Before,
Fixed — UI
use:masknow 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.tscastelstraight toHTMLInputElementand drove.selectionStart/.valueon it —undefinedon a<div>— so the field simply never masked, with no error. The skill's own<Input use:mask>example did not work.masknow 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 keepsmaskthe 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 forwardsuse:maskthe way the compiler does — confirming Input's own.valuebinding andon:inputdo 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'serrorhandler 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 calledres.writeHeada second time, Node threwERR_HTTP_HEADERS_SENTfrom 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 cleansocket.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.
- The handler now distinguishes the two cases by
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 are9(digit),a(letter),*(either) and\(escape); the alphabet is extensible viatokens, and redefining a builtin throws rather than silently changing what a template means. ShipscompileMask(the pure, DOM-free core) andmatchesMask, an ordinary(value) => string | nullvalidator 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:controlon the same element; bind the field's own signal instead. <Input>is unchanged, and@weave-framework/uistill depends only on@weave-framework/runtime.
- A numeric mask mode, for amounts —
use:mask={{ { value: price, numeric: { decimals: 2, decimalSeparator: ',', groupSeparator: '.' } } }}. Digits fill from the right, so1,0,5,0reads0,01→0,10→1,05→10,50, the integer part is unbounded, and grouping is inserted as you type.templateandnumericare mutually exclusive; passing both throws.- Why a second mode rather than a template. A positional template's
9count is its width, fixed at compile time, and it fills left-to-right. Measured on the first release:234569871.36typed into'999.99'became234.56and reportedcomplete: true, and entering one cent into'999,99'required typing00001. Widening the template makes it worse —123.58into'999999999.99'renders12358____.__. 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 typed0is a value and yields'0.00'. - Separators are props; the ambient locale is never read — no
navigator.language, noIntldefault. The displayed format belongs to the organization, not the viewer. Grouping is inserted directly rather than throughIntl.NumberFormat, which always wants a locale and in some of them emits non-ASCII digits. maxIntegerDigitsrefuses 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: 0gives a grouped integer field. Completeness does not apply to a variable-width value, somatchesMaskstays 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.
- Why a second mode rather than a template. A positional template's
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-blockafter()/offset rebasing — with anAdoptCursorthat 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
@letafter 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.
- A
2.0.1 — 2026-07-19
Fixed — scaffold
npm create weaveinstalls 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, andverify:template-rangesfails 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.
@awaitrebuilds its@thensubtree 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
--checkstep 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
markDirtyreturned 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 + 1has 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 + xemitted a trailing barex— 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
-
@awaitre-renders when the resource's data changes without a loading bounce. The then-branch was rebuilt only when the await STATE changed, andresource.mutate(next)— the documented optimistic-update path — writesdatawhile leavingloadingfalse. So the state stayedthen, 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 throughpendingfirst, a different transition. Two documented APIs were silently incompatible. The branch now tracks the value.The whole
@thensubtree 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@thenbody, 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 toSignal.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 thefield.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 checkhonours the project'stsconfig.json. It used a hardcoded option set with nopathsand nobaseUrl, 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-capablelibfloor 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/excludeand 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 andgit add .stages the private working notes into public history, where a push is permanent.verify:no-privatereads 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/vscodeis a workspace member again. It declares@weave-framework/language-server: workspace:*but was not listed inpnpm-workspace.yaml, so a fresh clone could not link it and installing from the extension directory rejectsworkspace:*outright. It packaged only because a stalenode_modulessurvived 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 devcleared 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.jsthat 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 byverify:dev-overlay, which drives the real dev server through break-and-repair.
Added — CLI
- Source maps in
weave devandweave 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.mapfiles, opt-out viabuild.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. SoremoveAt(0)followed bypush(...)read clean, as did a pure reorder: same length, every item pristine against the value it was constructed with.dirtyis 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.settreats 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 throughset(() => value), whichresource.mutateand the forms submit path already did.
Fixed — UI
- Layered modals released out of order no longer strand the page unscrollable.
blockScrollsnapshottedbody.style.overflowper 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 restoredhiddenwith 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
onMountby a single query, but the header is a keyed@forover 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.
decodeURIComponentthrowsURIErroron a lone%, and it ran unguarded inside the route-resolution computed — so the throw escaped throughmatched()/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
/adminthat redirects to/loginleft both entries in history: Back returned to/admin, the guard fired again and pushed/logina 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.stateat startup:pushStatestate 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 ... trackis keyed by the row, not by a same-named component binding. The key function is(item, $index) => <track>, sotrackmust resolve against the row parameter — but it was rewritten against the PARENT scope. When asetupbinding shared the loop variable name, the parameter stopped shadowing and every row keyed on the samectx.<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 scopedtrackcorrectly; 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)
renderDocumentescapes what it interpolates (stored XSS in SSG output). The page title,langand 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,langandentryattribute-escaped.headstays 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. Everyeffect/watch/computedcreated 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()useswatchinternally, which makes a store-created optimistic exactly this case — its overlay would never clear again. The factory now runs in its ownroot(), which is what a global store's lifetime always meant.@weave-framework/storeconsequently depends on@weave-framework/runtime(forroot), asforms/router/data/i18nalready do. Still zero third-party dependencies.
Fixed — prettier plugin
-
Formatting no longer rewrites an explicitly empty attribute into a bare one.
disabled=""was printed asdisabled, and the two are not interchangeable: the parser marks a valueless attributebare, and on a component tag that becomes the boolean proptruewhiledisabled=""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 thebareflag 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
setupbinding 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 ownid, and({ a: { b } }) => …its ownb. A binding blamed for a name it does not read is refused, and a refused handler falls back to actx.<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/passivewere dropped with only a code comment saying so, soon:click|oncefired on EVERY click while the same template on the eager target fired one.onceis now carried through the delegated dispatch (the runtime removes that event's marker after the first invoke).captureandpassivecannot be expressed by one delegated listener per event type —captureneeds its own capture-phase listener andpassiveis 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
adoptTextcannot 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$_builtinsregistry the engine already had) and warned, not rejected: adding a token is legitimate, and a component youdefine()d yourself has no builtin schema, so it never warns.
Changed — runtime
@forwrites one$countper 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$lastwas 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/formssaidvalidateAsync()is "a bounded poll (~30 ms ticks, capped at ~2 s)". It watchesvalidating()flip to false with aneffect— no polling, no timeout, no chance of resolving mid-validation. The source comment says so outright; the docs were describing a replaced version. - The
fieldArrayJSDoc example did not type-check. A group-returning factory with['Write tests']makes the item type bothstringand{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 membervalues; it isvalue.
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), includingonMount,provide/inject, every devtools export, every transition,InterceptorandOptimistic. 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 fencedts/htmlexample must parse;weave-templatesmust show every block, directive and special attribute the parser accepts. A fence now carries a promise —ts/htmlis real code and is checked, shorthand notation goes intxt.
Fixed — skills
- The component skill taught a resource leak. Its lifecycle example was
onMount(() => { const id = setInterval(…); onCleanup(() => clearInterval(id)) }).onCleanupregisters on the running computation (if (listener) …) and anonMountcallback fires later on a microtask, outside any computation — so it silently registered nothing and the interval outlived the component. UsesonDisposenow, and both hooks are documented with the distinction spelled out.
Changed — mcp
- The server now speaks MCP up to
2025-11-25(was2024-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 onlytools, 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
requiredargument was never enforced. Every tool listedrequiredin itsinputSchemaand the server never read it, so a caller that omitted or misspelled an argument fell through to the handler withundefined—weave_compile_templateanswered "Empty template fragment", which points an agent at its markup instead of at its own call. Missing arguments are now named in anisErrorresult. (McpTool.inputSchemais typed rather thanobject, 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.vsixbundled a language server built on 30 June — it predatedauto-expose, so every{{ binding }}of a component whosesetup()omits itsreturncame up red (11 false errors in one file, 1642 across 41, withweave checkclean 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.tsside silently loaded no plugin and kept itsTS1192. Ships0.6.0, verified at 0 diagnostics across the same 41 files.editor/vscode/build.mjsalso 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-plugin→verify:editor-plugins; it covers both editors, and additionally asserts the.vsixcarries 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-sizeandtypography.cell-sizeon both pickers stopped being read when their glyphs became lucide icons (and the picker token was renamed tocell-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 inpackages/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 afield()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_CALLfell back toDEFAULT_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 (itsDEFAULT_INSTANCE_FIELDfallback is), every{{ foo() }}was not, and the highlighting read as broken. Colors are now stated outright in bundledcolorSchemes/Weave{Default,Darcula}.xmlviaadditionalTextAttributes, for calls and for theon:/use:/bind:prefixes (same hole in the light scheme).verify:webstorm-plugingained two checks: everyWEAVE_*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.zipwas built just beforeauto-exposelanded, so it typed asetup()that omits itsreturnasvoidand 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, whileweave checkon the same tree reported none. Shipped0.22.0with a server built from the same commit. New gateverify:webstorm-plugin(in CI) fails if theserver/server.cjsinside the shipped.zipis not byte-identical topackages/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 Childdegraded toanyand 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 checkwas 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 anameOffsetthrough the parser, and the emitter writes the key mapped — the error lands on the prop name in the template.
Fixed — compiler
- Nested
@forrow 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_bblock numbering is unchanged.$index/$count/$first/$last/$even/$oddstill rebind to the innermost loop (correct shadowing, viachildScopelayering). 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-sidenavgetsheight: 100%so the shell fills a sized container (no-op in an auto-height parent); drawer and content stretch via the default flexalign-items: stretch.<Tree>: disclosure marker is a lucidechevron-right<Icon>instead of a CSS::before▸glyph, rotated 90° when expanded; rendered only for expandable nodes. Tokentoggle-glyph12px → 14px.<Tree>/<List>: reorder drag handle is a lucidegrip-vertical<Icon>instead of a⠿character.grip-verticaladded 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 —
slugifylowercases, 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
--ssgprerenders every route to real HTML at build time and derives the route list automatically (E1.2, E1.3a–d). Per-page<title>captured fromdocument.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/datadoes not pull the headless render into a client bundle.lazy()prerenders: itsimport()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),
RouterViewadopts 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 nosetup()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/@foradopt 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), bareeffect()s (E1.47), andonMount()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-levelon: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, threwnextSibling of nullon 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 noadoptCSRs outright rather than arming handlers over unadopted DOM. Gated byverify: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()insetup()binds no name, soderivenever rebuilt it — a per-routedocument.titleeffect froze at the server's value forever (E1.47). - 1.6.0 — fix(compiler): an
onMount()insetup()resumes (E1.49). A prior build refused to adopt any component with one, calling it a structural limit;derivere-creates the hook exactly as it re-creates an effect, and the refusal was strictly more expensive (client-rendering re-runssetup()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, andreturnEntriesdropped a comment-introduced entry and everything after it. - 1.6.0 — fix(runtime):
onMountis 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,ascasts, 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.ymlnow runs build · test · verify on every push (~1.5 min), includingverify:resume: a real app, the real CLI, a real browser, real clicks. - 1.6.0 — build(size):
verify:sizeenforces the budgets. SPA core 21.2 KB gz; resume/adopt/serialize sit on their own lines. - 1.6.0 — style:
pnpm lintwent 916 errors → 0 (the rules were fixed, never relaxed) and is now a CI step;no-unused-varswas 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, everymouseenterre-ran a fullcalendar.render()that rebuilt the entire day grid, detaching the cell under the cursor mid-click —mousedownon the old node,mouseupon the replacement → the browser fires noclick. Two causes fixed: (1) the hover preview + the anchor-set now call a newrefreshDays()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 realmousedown+mouseupalways lands; (2) the value-synceffectwas implicitly trackingpendingStart/hoverDate(read bydecorateDayduring itsrender()), so a hover re-ran it → another rebuild — it now wrapsrender()inuntrack()and depends only on the externalrawValue().<Datepicker>shares the core but has no hover path, so it is unchanged (32 tests green). Pinned by a newdate-range-picker.browser.tsregression test that drives a realmousedown → mid-click hover → mouseupand 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-pickerfor picking a start/end date range (FW-17). An underline trigger field showsstart – endand 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-edgering on the tentative end). The value is aDateRange({ start: Date | null, end: Date | null }) bound viavalue+onChangeor a formscontrol(Field<DateRange>); it commits only on the second click and a half-picked range is discarded on close. Supportsmin/max/dateFilter,firstDayOfWeek(default Monday),labels(translatable chrome),separator(default' – '),clearable,required,disabled,position, and full keyboard nav. Pinned by 13date-range-picker.browser.tstests (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-parameterizedcreateCalendarViewcore (src/shared/calendar-view.ts) + a matchingcalendar($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 32datepicker.browser.tstests stay green (the guardrail). The$rangeflag 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, ariachooseYear) 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 arole="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 outsidemin/maxare disabled in their grids. NewfirstDayOfWeekprop (0Sun …6Sat) defaulting to Monday (1) — a component default, not the locale's — overridable per instance. Newlabelsprop (Partial<DatepickerLabels>) translates every chrome string (nav aria-labels, year switch, dialog name, clear, open-calendar) with English defaults; being reactive props they can carryt('…')from i18n. Month/weekday/year text stays locale-driven (Intl). The imperative panel was refactored to a singlerenderPanel()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 newdatepicker.browser.tstests (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 itstabTemplatebody had re-rendered/laid out for the new selection — a small non-zero (icon-sized) width, i.e. the circle in the wrong place (theoffsetWidth === 0guard couldn't catch a partial). The measurement is now deferred to a coalescedrequestAnimationFrame, 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); theResizeObserver(list + active button) still re-fires for genuinely-later async resizes (font/icon load). Pinned by two newtabs.browser.tstests — 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 atabTemplate(FW-15). WithslidingIndicator+ a customtabTemplate, 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-onMountlist 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 thetabsset 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 newtabs.browser.tstests (switch-under-template, late-frame layout, tabs-set change) — the late-layout + set-change tests fail without the fix. WithslidingIndicator+ a customtabTemplate, 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-onMountlist 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 thetabsset 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 newtabs.browser.tstests (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-containedSKILL.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@snippetparams, novoidkeep-alives).create-weavenow scaffolds them into a new app's editor-skills dir viatools/sync-skills.mjs(wired intobuild-packages.mjs), so every scaffolded app ships them auto-discovered; existing apps copyskills/in.skills/is the single source of truth. - 1.5.19 — feat(compiler+check): (A5)
bind:value={{ sig }}(and anybind:<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 checktypes the signal against the child's prop; the old'bind' binding on <Tag> is not supported yetthrow is gone. Pinned incomponent.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:groupcompares 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'svalidateAsync()waits for async validation by watchingvalidating()flip to false (a one-shoteffect) 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 (markDirtyearly-return) already terminates a self-writing effect, so there is no stack-overflow to guard; verified empirically.) Pinned inbind.browser.ts+ the existingvalidateAsyncsubmit test. - 1.5.17 — feat(runtime+compiler+check): (A2) prop defaults —
export const propDefaults = { … }gives a component static default prop values, cutting the() => props.x ?? defaultwrappers that pepper component authoring. The loader passes it asdefineComponent(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 explicitundefined/falsy counts as passed).weave checkmakes the defaulted keys optional for the parent (__WeaveWithDefaults<P, typeof propDefaults>) whilesetupstill sees them as declared; a required non-defaulted prop stays required. Pinned incomponent.browser.ts(emit + runtime + reactive + falsy-wins) and a newpropdefaultssmoke (verify:check). Docs: Learn → Components → Prop defaults (replaces the old "no default-props mechanism" note). - 1.5.16 — feat(compiler+check): (A3)
@snippetparameters may carry a TS type annotation —@snippet row(ctx: ListRowContext<Task>) { … }— andweave checktype-checks the body against it (a typo inctx.itemis caught); an un-annotated param staysany(backward compatible). This closes the type hole under the template-prop features (rowTemplate/itemTemplate/tabTemplate), whose snippet bodies were previously untyped. Parser splitsname: Typewith 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 insnippet.browser.ts+ thesnippet-typesmoke. Documented in Learn → Components. - 1.5.15 — feat(compiler+check): (A1) a bare attribute on a component tag (
<Button disabled>) now passes the boolean proptrueinstead of the empty string""— so abooleanprop actually receivestrue, andweave checkstops flagging'' is not assignable to boolean. A quoted value (label="Go") or an explicit empty (hint="") still passes a string. Implemented via abareflag on the parser's static attr (valueless = no=); DOM elements are unchanged (a bare attribute still renders bare). Pinned incomponent.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 asctxbindings, so an inline handler likeon:click={{ () => setTimeout(close, 200) }}resolves the real global instead of compiling toctx.setTimeout(…)(a runtimeTypeError). (A6) every parserParseErrornow carries its source offset (35 throws that previously reported none), so the dev-server overlay frames the realline:colinstead 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
setupbinding.items().map((value) => value * 2)compiled toctx.items().map((ctx.value) => ctx.value * 2)—(ctx.value) =>is a SyntaxError — whenevervaluewas also a component binding (a real, if narrow, build-breaker).rewrite()now spares arrow parameters (same basis asfreeIdentifiers/inferCtxNames, so inference and codegen agree on what is a parameter). Pinned inscope.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 astypeof Tag, sonoUnusedLocalsstays quiet and the oldvoid Badge;keep-alive lines are unnecessary whenever the Weave editor tooling (@weave-framework/typescript-plugin/ VS Code / WebStorm) is active. Newpackages/typescript-plugin/test/unused-import.smoke.mjs(used-in-template → 0 unused; genuinely-unused import → still flagged, as a control) wired asverify:tsplugin; documented in Learn → Components. (Chose "keep the imports, drop thevoid" over resolving tags without imports — imports stay explicit for go-to-definition.) - 1.5.11 — feat(compiler+check): auto-expose — a component's
setupmay omit itsreturn. When there is no top-levelreturn, the loader (runtime module) and@weave-framework/checkboth synthesizereturn { …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-levelreturnopts out and is used verbatim, so every existing component (all of which return) is unaffected. Newpackages/compiler/src/auto-return.ts: a hand-rolled string/comment/regex/template-literal-aware scanner (zero-dep) that classifies each{FUNCTION-vs-BLOCK, so areturninside a nested arrow/function is ignored while one in a top-levelif/switchblock still counts; fail-safe — any ambiguity (odd signature, return-type annotation, unbalanced scan) leaves the script byte-for-byte untouched.checkmaps its script region as two runs around the injected span so.tsdiagnostics still land correctly. Pinned byauto-return.browser.ts(16 cases), a real mount incomponent.browser.ts, andauto-return.smoke.mjs(wired intoverify:check). Also fixed 3 pre-existingSignal<T>-invariance type errors inlist.browser.ts(FW-14 test) that were failingtypecheck.
1.5.10 — 2026-07-09
- 1.5.10 — feat(publish):
@weave-framework/typescript-pluginis now published to npm (16th package). It's the.ts-side editor support — a tsserver plugin that synthesizes the loader-generated default export, soimport 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 Nxapplicationgenerator and thecreate-weavetemplate 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 tobuild-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 theconst x = …declaration insetup()instead of thereturn { x, … }shorthand. Template vars emit as__ctx.xoverReturnType<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, wrappingprovideDefinition) detects aShorthandPropertyAssignmentinside asetupreturn and re-points it at the same-namedconst. Ships in the editors (WebStorm plugin0.15.0; benefits the VS Code extension on rebuild). DoD-pinned inverify: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
.htmltemplates show native/other-framework errors in the editor. The Nxapplicationgenerator now also scaffolds a project-localtsconfig.json(mirrorscreate-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 aproject.jsontarget override (which outranks any inferred target) so bothnxCLI 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 dynamictabs— same fix as<List>FW-14: the button body moved from a one-shotonMountsnapshot into the reactive keyed@forblock (@if (hasTemplate()) { @key (tabKey) { @render (tabBody) } }), so tabs added/edited after mount get (and refresh) their template body.tabKeyfolds a per-tab WeakMap version + selected state. (Panel content still mounts inonMount— tabs are a fixed strip by design.) - 1.5.7 — fix(check): a
@snippetis now typed() => Node(was() => void), so passing one to a component's template prop typed(row) => Node(rowTemplate/itemTemplate/tabTemplateon a locally-typed component) no longer flags a spurious'void' is not assignable to 'Node'error inweave check. Newsnippet-typesmoke pins it (part ofverify: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 byselected:disabled, constant for a non-selectable list — so a reused row (eachBlockkeyed byitem.value) kept its stale body after an edit-then-reload (same id, new data). The@keynow 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 dynamicitems. The row body was wired once inonMountover a staticquerySelectorAllsnapshot, 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@forblock (@renderguarded byhasTemplate(), wrapped in@key(selected:disabled)for the per-row selected/disabled re-render) — create / append / replace / remove all flow through the block'strack item.valuediffing. NoonMount, no index-fragilerows[i]. API unchanged. - 1.5.4 — feat(ui):
<List>rowTemplate(FW-14) — an authored@snippetrenders the whole body of each.weave-list__row(colour dot, name, tag pills, description, trailing action buttons) from the row'sListRowContext(item+data,value,title,meta,index, reactiveselected,disabled).<List>/ListItemare now generic over the item payload (data?: T). The framework keeps the row, its role,aria-selected, roving tabindex, keyboard nav and (whenreorderable) the drag handle rendered before the template;titlestays the accessible name + typeahead. Re-renders per row onselectedchange, 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'sitemTemplate(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__indicatorin 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 withtabTemplate.
1.5.2 — 2026-07-08
- 1.5.2 — feat(ui):
<Tabs>tabTemplate(FW-12) — an authored@snippetrenders the whole content of eachrole="tab"button (icon + label, badge, two lines) from the tab'sTabRowContext(item+data,label,index, reactiveselected,disabled).<Tabs>/TabItemare now generic over the item payload (data?: T). The framework keeps the button, ARIA, roving tabindex and panels;labelstays the accessible name. Re-renders per tab onselectedchange, bindings owned/disposed cleanly. Omit → the default label span (back-compatible). Mirrors the menu'sitemTemplate(FW-10).
1.5.1 — 2026-07-08
- 1.5.1 — fix(prettier-plugin), security: hardened the SFC/template tag-detection regexes in
parse.tsagainst polynomial ReDoS (CodeQLjs/polynomial-redos, 5 alerts). The ambiguous(\s[^>]*)?(where\s ⊆ [^>]) is replaced by a zero-width(?=[\s>])assertion, andlangis 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.1→1.4.22 (npm went 1.4.0 → 1.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.), souse:menu={{ { ...opts, itemTemplate: row } }}resolvesopts. Fixed in bothrewriteandinferCtxNames. - 1.4.19 — feat(ui): menu/contextMenu
itemTemplate(FW-10) — authored@snippetrenders 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 bodyNode;optionLabelstill 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):
stylesurl() 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/uicomponents. - 1.4.6 — feat(ui): Input
revealTooltipselector (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
titletooltip (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 .html → weave 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. M2 — eachBlock 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. M4 — inferCtxNames' 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. H3 — Select 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: H1 — computed() 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)(ause:popoverEditaction) — click / Enter / F2 opens a non-modal CDK-overlay editor (the U3 overlay-republic chrome) seeded fromconfig.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'sfield-underline(RULE #1); a customeditorfactory ({ element, read, focusTarget? }) supplies a Select/date/etc.aria-haspopup=dialog. Deferred: Tablecolumn.editablewiring, 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
SelectionModelalready 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>— arole=menubarof 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.onDisposetears 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-levelresizableColumns) puts arole=separatorgrip on each resizable<th>. Pointer drag via the CDKdraggable(axis x) sets a live width (clamped tominWidth, default 48); keyboard Arrow Left/Right resize by 16px. Widths ride an internal signal (a controlledcolumnWidthsprop wins) sowidthCss+ the sticky-offset maths recompute reactively. EmitsonColumnResize({ key, width });[data-resizing]marks the table during a drag. Deferred: double-click auto-fit, column reorder. - Fix:
datepicker.browser.tshad two test-only type errors (avoidarrow returning a boolean; a 3-argmatchRe) that slipped into0.2.42(committed after eslint but beforetsc). 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 CDKadapter.parse→ commits (clamped + normalized to the display format), OR flagsaria-invalid+--invalidand 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-buttonstyles.- Gates: 926 tests (+6); verify:ui-sass 284 (+1); typecheck +
eslint .clean.
Plan
UI-PLAN-U5.mdwritten (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-handlevia the CDKdropList(handleselector, 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— newkeyboard?: boolean(default true). List + Tree passkeyboard: falseso 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 CDKdropListwith ahandleselector, so a row-body click still selects and only the handle starts a drag. EmitsonReorder({ previousIndex, currentIndex }); the List is controlled (the consumer reordersitems). 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__handlegrabber wired via the CDKdraggable(axisy): dragging the handle down translates the sheet; releasing pastmax(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 (sharedfield-underlinechrome) + clock icon opens a CDK-overlay panel of hour ▲/▼ : minute ▲/▼role=spinbuttoncolumns + an AM/PM toggle (12-hour locales). 12h vs 24h is derived from the locale (use24override);step(minutes, default 5);min/maxclamp the committed time.- Value — a neutral
{ hours, minutes }(24-hour internal). Binding follows the Weave form convention (value/onChangeOR acontrol; 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'sfield-underlinechrome; the design's field is a button trigger) with a calendar icon, opening a CDK-overlay calendar (non-modal — transparent backdrop + Esc). Calendar = arole=gridmonth view: ‹/› month nav, a locale weekday header (reordered byfirstDayOfWeek),role=gridcellday 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+ adateFilterpredicate disable cells. - Binding: the Weave form convention —
value(Date | null) +onChange, OR acontrolField<Date>(touched-on-close,aria-invalid). Compose with<FormField>for label/hint/error. - Deferred (noted): text-entry parsing (the
adapter.parseis 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— nativeDate+Intlonly (rule #1, no date library). Neutral value type = a plain local-midnightDate.- 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.
formatviaIntl.DateTimeFormat;parse= ISOyyyy-mm-ddfast-path + the locale's numeric field order (fromformatToParts), rejecting overflow (Feb 30 → null) + expanding 2-digit years.- Calendar helpers: locale
firstDayOfWeek(Intl.LocaleweekInfo, 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: anoffset()signal (constrainable to oneaxis), athreshold(click-vs-drag), ahandle, andonStart/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 (clampsto).- 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 emitonExpandedChangewithout 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@forover the visible flattened nodes, arbitrary content via@render). Two data models: nested (achildrenaccessor,node.childrenby default, recursed; descendants show only while expanded) or flat (passgetLevel→ a DFS scan hides descendants of collapsed nodes). Both emitaria-level/-setsize/-posinset.- Expansion + selection ride the CDK
SelectionModel(expansion uncontrolled +onExpandedChange; selection optionalselectablesingle/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-depthcustom prop × theindenttoken (design: depth × 18px); rotating ▸ disclosure marker (CSS::before)../treesubpath (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 consumesonChangevia a setup binding fired by an inner<input>'s bubblingchange(mirrors Checkbox). Asserts it fires once. Verified it fails (calls=2) whendefineComponentis 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
$eventsmarker listing only its realon:Xevent-attr prop keys (e.g.<Checkbox on:click … onChange={{…}}>→$events: ['onClick'],onChangeexcluded). Data-callback props (onChange,onInput) are ordinary reactive getters, not events.
Runtime (@weave-framework/runtime)
defineComponentnow auto-forwards only the$eventskeys to the child root element (previously it forwarded any/^on[A-Z]/function prop). A data-callback consumed inside the child (e.g. Checkbox'sonChange, fired by its ownon:change) is no longer ALSO attached as a bubbled DOM listener — so it fires exactly once instead of twice.on:Xforwarding (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; thecheckedFrombool-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
@forover 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__checkboxrestyle (a RULE #1 violation the user caught) is gone, along with its tokens. A selectable Table therefore pulls in@weave-framework/ui/checkboxstyles. - 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 acellsFor(row)array so the inner@foronly 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@rendercell content. - The composed
<Checkbox>'sonChangefires 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 idempotentselect/deselect/setSelection— so the row lands in the right state regardless.aria-expandedis emitted as a string.
- Nested
- 13 browser tests (all green);
verify:ui-sass262; 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 hadoverflow-xonly). The scroll box is nowoverflow: auto(both axes), so amax-heightgives 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 insetup()(cells are arbitraryNodes 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 withdisableClear), setsaria-sort, shows the accent arrow, single active column; emitsonSortand 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-selectedon 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 whencolumnsis bound), and expandable detail rows (expandable+detail(row), chevron toggle + full-width detail<tr>, expansion state in its ownSelectionModel). Sticky header + hairline separators + tabular-nums numeric cells. - Virtual body: plain-scroll in v1; the CDK
virtualScrollhook is ready for the follow-on../tablesubpath (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-sass262 (+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 fixeditemSizeand atotal(number or getter), it computes the buffered slice to render —renderedRange()[start, end),scrollOffset()(top spacer),endOffset()(bottom spacer),totalSize()— all as signals; plusscrollToIndex(),measure(),destroy(). Fixed-size strategy first (autosize is a follow-on). Built on the U1onScrolldispatcher +resizeSignal(ResizeObserver → viewport height);renderedRangeis acomputedwith 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 tototal.- 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,singlevsmultiple, an optionalcompareWith(object copies match by key), reactiveselected()/count()/isEmpty()/isSelected(), and anonChangedelta 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().ArrayDataSourcewraps a static array or a signal (reactive updates propagate throughconnect());isDataSource()guard. Signal-native, no RxJS.- 15 headless tests (single/multi transitions, no-op guards,
compareWithidentity, 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__drawerbeside a__contentwith 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: omitmodeand it consumes the CDKbreakpointSignal— below the WeaveNarrowbreakpoint (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 uncontrolleddefaultOpened; imperativeopen()/close()/toggle()/opened()exposed via theapiref callback (like Input'sonInputRef). Drawer edge viaposition: 'start' | 'end'. - State rides root modifier classes (
--side/--over/--push,--opened,--end,--backdrop) — no per-element state class. Theoverbackdrop 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-sass253.
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: 400subtext weights, smallgap/padding-yvalues, the chips × glyph size + edge nudge, and the menu divider height — each now avar(--weave-<c>-…)backed by a new key in the component's_tokens.scss. Structural constants (0,100%,50%,line-height: 1resets, 1px hairline borders, keyframe transforms) stay literal, matching the established convention. Compiled CSS is byte-identical (token value = former literal) —verify:ui-sass245 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)
defineComponentauto-forwards component-levelon:Xhandlers 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. ButtongainsariaCurrent;InputgainsonInputRef(composers add combobox ARIA) andclear()dispatches a realinputevent so composers react.- Internal
src/internal/compose.ts(toComponent) + the_cchild-component map power composition in the library's own tests/gallery (a realweave buildemits the same shape).
UI — new U4 components (Phase A)
- Expansion Panel (accordion), Tabs, Stepper, Slider, Paginator.
Gates
- 796 browser tests,
verify:ui-sass245, 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.