Skip to content

Add jaspr_class_scope package for CSS classname scoping - #570

Merged
Komoszek merged 41 commits into
masterfrom
claude/jaspra-scoped-classnames-kzspx3
Sep 15, 2026
Merged

Komoszek merged 41 commits into
masterfrom
claude/jaspra-scoped-classnames-kzspx3

Conversation

@Komoszek

@Komoszek Komoszek commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Problem

Jaspr collects every @css getter in the app into one stylesheet for the page, and class names in it
are plain strings. Two components that both call something title style each other, a rename in one
file repaints another, and a selector can drift from the classes: attribute it was written for.
Nothing on pub.dev scopes class names for Jaspr; the advanced_forms landing page needed it.

What this adds

jaspr_class_scope (no dependencies) holds the vocabulary: a ClassName is written once and asked
for both spellings — name for classes:, selector for the rule — so the two cannot drift.
ClassName.shared renders a name as written, for the classes a hand-written stylesheet or a bit of
JavaScript looks up.

jaspr_class_scope_builder (a dev dependency) gives each component its own namespace:

part 'hero.scopes.dart';

@scopedCss
class Hero extends StatelessComponent {
  static const _class = _$HeroScope;
  static final _grid = _class('grid');
}

_grid renders as grid-<suffix>, the suffix being the md5 of package|path#Component in six
base-36 digits. Build time, because Type.hashCode differs between the VM and JS, a type's name is
gone after minification, and a scope has to be const to be spelled in a @css getter.

Two components can still land on the same suffix, rarely. A second build phase catches that and fails
the build with both names. It reads a manifest each package writes into the build cache, so it covers the
whole package graph — a component of a dependency is checked against yours.

Test plan

  • dart test — 6 in jaspr_class_scope, 14 in the builder, including real colliding pairs found
    by searching the hash, within one package and across two
  • dart analyze --fatal-infos and dart format clean; compiles against analyzer 12 and 14, since
    jaspr_builder 0.23 pins analyzer: ^12.1.0
  • Drives the advanced_forms landing page (Align the site with the LeanCode palette, from one source advanced_forms#80): 21 scopes over 11
    components, 67 scoped class names in the built page, and the names shared with landing.js and
    the docs' stylesheet unchanged

🤖 Generated with Claude Code

https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

Jaspr collects every `@css` getter into one global stylesheet and scopes
nothing, so a class name is a global name and two components that both
style `.grid` style each other. `ClassScope` does at build time what CSS
modules do: a component declares one scope, makes its classes from it,
and each renders as `grid-<suffix>` — five base-36 digits of an FNV-1a
hash of the scope's name. `ClassName.shared` keeps the names another file
knows as written, `+` puts two classes on one element, and both the
`classes:` attribute and the selector are spelled from the same constant.

The hash multiplies in 16-bit halves so no intermediate product exceeds
2^53: on the web an `int` is a double, and a plain `hash * prime` rounds
there but not on the VM, which would hand the same component two
different suffixes and leave server-rendered markup not matching its
client-rendered stylesheet. CI runs the tests on the VM and in Chrome to
hold the two to the same goldens.

Two scopes that would hash alike throw the first time either one renders,
so a collision cannot slip through unnoticed.

Extracted from the advanced_forms landing page, which grew this in
leancodepl/advanced_forms#80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
A scope named after a class is only as unique as the class name:
`Type.toString()` drops the library, so two `Card` components in two
files hash alike. The runtime can only notice that and throw — it has no
way to tell the two apart, and a suffix handed out in first-render order
would not survive a rebuild, let alone agree between server and client.

So the disambiguation moves to where the location is known.
`jaspr_class_scope_builder` writes the scope of every `@scoped` component
from the asset it is declared in — `site|lib/components/hero.dart#Hero` —
as `const _$heroScope = ClassScope.literal('Hero', '16rv7')` in a part
file next to it. Two `Hero` classes in two files are then two scopes by
construction, nothing throws, the class name never reaches the page
through `Type.toString()`, and no hash runs in the browser. It is what
CSS modules do, where the hash covers the file path.

The hand-written forms stay for projects without build_runner, and the
runtime check behind them is now keyed by what a scope *is* — the type it
was made for, or its literal name — rather than by the name it renders,
so two same-named classes throw instead of quietly sharing a namespace.

`classScopeSuffix` is now public: the builder hashes with it, so both
sides of the package agree by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The generator makes two same-named components two scopes, but five base-36
digits still leave room for two unrelated files to hash alike, and until
now the only thing watching for that was a registry consulted while
rendering — work a shipped page did on every scope, to catch something a
build could have caught.

`class_scope_check` runs once per package after the scopes are written,
reads them back and fails `build_runner` when two components hold one
suffix, naming both files. The runtime registry moves behind an `assert`:
loud in development and in tests, where a hand-written scope can still
collide, and compiled out of a release build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
`@scoped` says nothing about what it scopes, and a component file that
already carries jaspr's `@css` reads better when the two sit next to each
other. `ScopedCss` is the class behind it; the builder accepts either
spelling, prefixed or not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Both readmes take the shape the other packages in this repo use — badges,
a short description, `## Usage`, then the maintainer block — and lose the
paragraphs that argued with alternatives instead of showing the package.
The doc comments lose the same: what a member does and the one trap worth
knowing, not the reasoning behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
A scope named by hand is only as unique as the name, and `ofType` reads it
through `Type.toString()`, which a minifying compiler rewrites and which
drops the library. Both invited the mistake the builder exists to prevent,
so both are gone: `ClassScope(name, suffix)` is now the one constructor,
written by `jaspr_class_scope_builder` and never by hand.

What follows from that: the hash moves into the builder, where it runs on
the VM and its result is baked into a `const`, so a suffix can no longer
differ between the server and the browser — the runtime carries no hashing
at all, and the runtime package's Chrome job is gone with the reason for
it. The builder no longer depends on `jaspr_class_scope`, so its CI stops
linking the sibling package and the two can be published in either order.
The debug registry stays: within a package the check phase catches a
clash, and this also covers two packages meeting on one page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Someone reading the pub.dev card or the first paragraph wants to know they
get unique class names. The stylesheet Jaspr builds, the hash, the file it
covers — that is all still there, further down, for whoever is choosing
between this and writing the names by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Five was picked by eye, for something short enough to read in the
inspector, and it does not hold up when measured. Base 36 of a 32-bit
hash is six-and-a-bit digits, so keeping the first five crowded every
long hash into the suffixes beginning with a 1: over simulated projects
of 500 components, one in 43 had a clash. Taking the hash modulo 36^6
spreads it evenly and gives a suffix per two billion — measured at 3 in
10,000 for the same 500 components, and comparable to the base-52 name
styled-components generates, which is six characters wide too.

The check phase stays; it is a formality now rather than the only thing
between us and a silent clash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
FNV-1a mixes its low bits poorly, and those are exactly the bits the
suffix is taken from. Measured over file names differing in one letter,
one output bit never changed at all and the lowest four flipped 37.6% of
the time where 50% is the mark — sibling files could land a digit apart,
or on the same suffix. djb2, which styled-components uses, is worse
still: 25%.

Murmur3's finalizer after the FNV loop puts the worst bit at 49.2% and
the low four at 50.0%, matching the md5 that css-loader hashes idents
with, in six lines and without a dependency. Collisions were already
negligible at 36^6; this is about two neighbouring files not looking
related in the stylesheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The hybrid was an accident of where the code came from: FNV-1a arrived
with the scope, and the finalizer was bolted on when its low bits turned
out to be too still. Nobody can check a hybrid against anything.

MurmurHash3 x86_32 over the UTF-8 bytes is the same six lines of
arithmetic, one named algorithm, and it can be verified: the tests now
assert its published vectors ('' to 0, 'a' to 0x3c2569b2, 'abc' to
0xb3dd93fa), and every suffix here was cross-checked against the
murmurhash3js package, which agrees on all of them except the one
non-ASCII string, where it hashes code units and we hash bytes as the
algorithm specifies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The check phase recovered its input by regex-matching the exact rendering
of `ClassScope('Hero', 'iur6ms')`, in a file `build_to: source` puts in
the consumer's tree, where their formatter runs over it: one long
component name past 80 columns and the call splits across lines, the
regex matches nothing, and the check passes while detecting nothing. A
collision guard that silently stops guarding is the worst failure it
could have.

It needs none of that. The suffix is a function of where a class is
declared, which is in the source, so the check now parses the same
sources the generator does and hashes them the same way, through helpers
both builders share. The regex is gone, `required_inputs` with it, and
the error names `lib/components/hero.dart` rather than its part file. The
test feeds sources rather than hand-written generated text — and a real
colliding pair, found by searching the hash, instead of two fixtures
typed to match.

The runtime registry goes too. It was a second implementation of the same
check, kept a mutable static in a value library, made `suffix` a getter
with a global side effect, put `resetRegistry` in the public API to make
that testable, and cost the package its only dependency — all to catch
two components from different packages, which it could not do anyway: it
compares by name, so the two `Hero`s it was there for record as one
owner and pass.

Also: the generated part file is excluded in `build.yaml` rather than by
an `if` the build graph cannot see; `ClassName` renders without building
a list per read; the hash drops the JS-safe multiplication a builder can
never need; and the docs stop saying the suffix comes from the file when
it comes from the file and the class name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Measured over six million distinct paths, both land on the birthday
bound — 2779 collisions for Murmur against 2854 for md5 where a perfect
hash gives 2756, inside the noise. Murmur is the faster of the two by
0.4ms across a five-hundred-component project, which is not a reason for
anything. What is left is 36 lines of block loop, tail handling, rotates
and masking against five, and `crypto` is already in the lock through
`build`, so it costs a version constraint rather than a package.

It also gets easier to check: `printf '…' | md5sum` says what the builder
says, and the tests pin the two digests everyone knows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The readmes kept explaining the design where the code is enough: how the
suffix follows the file, what the check reads, what does not reach the
page. What is left is the description, the code you write, what it
renders, and the two things you cannot guess — a shared class and two
classes on one element. The doc comments lose the same, keeping the one
line each member needs and the two notes that are not obvious from the
code: why the parse is unresolved and why the annotation is matched by
name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
It spelled `ClassScope('Hero', 'lz7xyh')` by hand under a comment saying
the builder writes it — the one thing the package tells you never to do.
Now the example is the real arrangement: `hero.dart` carries the part
directive and `@scopedCss`, `hero.scopes.dart` beside it is what the
builder would write for that asset, down to the suffix, which is the md5
of `jaspr_class_scope|example/hero.dart#Hero`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The first paragraph explained how Jaspr collects stylesheets and which
JavaScript tool this resembles, neither of which tells a reader whether
they want the package. It now says what they get: every component with
its own namespace, so a name can be reused without the styles meeting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The opening line counted components and named a class before any code
had introduced either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The cards now lead with what the package spares you rather than what it
is, in the 60-180 characters pana scores in full, and both carry five
topics instead of four. Where the suffix comes from is out: a reader
choosing a package does not care that it is hashed from a path, only
that two components cannot collide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
`grid-lz7xyh` told a reader nothing they could act on: which six
characters land on a class is the one thing about this package that never
matters. The readmes now say a name carries a suffix belonging to its
component and leave it there, and the clash error says which two files
collided rather than what they collided on.

The first line also says what the package does rather than what it
prevents: every component gets its own class names, so a name used twice
cannot carry one component's styles into another's markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
"Locally scoped" is the phrase CSS modules put on this twenty years of
stylesheets ago, and it is what someone types into pub.dev when they want
it. The card and the readme now open with it, and say what it buys in the
same breath: a name written in one component cannot style another's
markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The two packages had three vocabularies for one idea: class names of
their own, a namespace per component, a class-name scope. Everything now
says scope, and the phrase both cards lead with is the one CSS modules
put on this long ago: locally scoped class names. Suffix survives only
where it is the subject — the field that holds it, the hash that makes
it, the check that catches two of them meeting — and the generated header
says whose scope it is rather than what it was hashed from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
`ClassScope` explained that the builder writes it and never to write one
by hand, which the annotation, the part file and the readme all say
already, and carried a code sample for a class with one constructor and
two fields. Same for the sample suffixes in `ClassName`, the aside about
scripts and stylesheets, and the builder's restatement of its own output.
What remains is one line per member and the two notes the code cannot
make: why the parse is unresolved, and why the check reads sources rather
than what the generator wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Two tests carried a comment restating the name of the test above it; the
annotation's second doc repeated its own first line; the example's header
explained that a component is a component. Those are gone. What stays is
one line per public member, because the package lints require it, and
five notes: why the example has no Jaspr in it, why the parse is
unresolved, why the annotation is matched by name, why the check reads
sources, why the assets are sorted, where the colliding test pair came
from, and why the golden suffixes may not move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
jaspr_builder 0.23.4 depends on analyzer ^12.1.0, so pinning this builder
to >=14.3.0 made it unusable in the one kind of project it exists for —
the first real consumer could not resolve it.

The only thing that needed analyzer 14.3 was reading a class name through
`namePart.typeName`, a getter that replaced `name` in that release. Off
the tokens the name is the identifier after the `class` keyword, which
has been true of the Token API for years, so the constraint widens to
>=12.1.0 <15.0.0. Verified both ends: the tests pass on 14.4, and on an
analyzer 12.1 override the parse returns the same components.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The check globbed its own package, so two components that scope to the
same suffix were only found when both were in the package being built.

A builder now lists every package's scopes into lib/ — the only
directory of a dependency a build can read — and the check merges those
manifests, reading the package graph off BuildStep.packageConfig. It
applies to all packages, not only dependents: a package that uses
jaspr_class_scope keeps this builder to itself, in its dev_dependencies.

Reading a manifest costs a glob and a read per package, so parsing is
skipped for a source that does not mention the annotation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
A component named at any length makes a line dart format would rewrite,
so a project that commits its part files and runs the formatter in CI
fails on a file it does not write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Two asserted properties of their own fixtures rather than of the code:
one compared the rendering of two hardcoded suffixes, the other that two
md5s of different strings differ. A third repeated goldens for inputs
the builder never produces.

Two more could not fail either, but for a reason worth fixing instead:
no input reached the padding, and the part file the manifest is supposed
to skip held nothing it could have read. Both now carry a case that
fails when the code stops doing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The tests computed what they expected with the same functions under
test, so an assertion read f(x) == f(x) and the format the suffix is
hashed from was pinned only by the literal beside it. They now hold the
suffix itself, which is what ends up in a page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
A value type was tested for == and not for hashCode, so a set could
have held it twice unnoticed. The token walk that reads a class name
past its modifiers had no input with a modifier. The manifest test said
"every" component and listed one. The shape assertion on the suffix
could not fail without a golden failing first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
@Komoszek
Komoszek marked this pull request as ready for review September 11, 2026 21:35
Comment thread packages/jaspr_class_scope/LICENSE Outdated
Comment thread packages/jaspr_class_scope/example/hero.dart
Comment thread packages/jaspr_class_scope/lib/src/class_name.dart Outdated
Comment thread packages/jaspr_class_scope_builder/lib/src/scoped_components.dart Outdated
Komoszek and others added 3 commits September 14, 2026 09:51
Co-authored-by: Piotr Rogulski <piotr.rogulski@leancode.pl>
The name and the comment read as though the builder wrote into the
package's own `lib/`. It writes a build cache asset, whose path begins
with `lib/` because nothing else of a dependency is visible to another
package's build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The parameters became `this._local` and `this._scope`, so `[local]` and
`[scope]` no longer resolved and `dart analyze` failed on
comment_references.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Comment thread packages/jaspr_class_scope_builder/lib/src/scoped_components.dart Outdated
Comment thread packages/jaspr_class_scope_builder/lib/src/scoped_components.dart Outdated
Comment thread packages/jaspr_class_scope_builder/build.yaml Outdated
The scope constant keeps the component's name (`_$HeroScope`), which an
acronym survives: `CTAButton` was giving `_$cTAButtonScope`.

A class is this package's to scope only if its file asks for the part
file this builder writes. Checking the annotation's import instead would
miss a project that re-exports it, and resolving the annotation would
tie the builder to one version of the analyzer.

Also block syntax in build.yaml, and the year in both LICENSE files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Comment thread packages/jaspr_class_scope_builder/lib/src/class_scope_check_builder.dart Outdated
Comment thread packages/jaspr_class_scope_builder/lib/src/class_scope_check_builder.dart Outdated
Comment thread packages/jaspr_class_scope_builder/lib/src/scoped_components.dart Outdated
The packages to read are a plain list now, and each manifest entry is
destructured where it is read. The list still comes from a set, because
`packageConfig` lists the package the check runs in and reading its
manifest twice reports it as clashing with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Comment thread packages/jaspr_class_scope_builder/lib/src/scoped_components.dart Outdated
The name was all this checked, which took anyone else's `@scopedCss` for
ours. It now resolves the class and asks which library the annotation
comes from, which also follows a re-export: the advanced_forms landing
reaches `scopedCss` through its own `styles.dart` and its components
import nothing of this package's.

The element API this needs — `LibraryElement.classes`, `Element.library`,
`ElementAnnotation.element` — reads the same on analyzer 12 and 14, and
resolution only happens for a file that names the annotation and asks for
the part file, so a clean build of that landing takes the time it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Comment thread packages/jaspr_class_scope_builder/README.md Outdated
Comment thread packages/jaspr_class_scope_builder/lib/src/scopes_manifest_builder.dart Outdated
Comment thread packages/jaspr_class_scope_builder/lib/src/scoped_components.dart Outdated
…lone

`BuildPackages.asPackageConfig` is built from every package in the build,
the one being built included, so adding it by hand only made the list
need deduplicating.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The manifest's entries are a list literal now, the README no longer
explains that a same-named annotation from elsewhere is left alone, and
the comments that restated their code are gone.

`libraryFor` no longer allows syntax errors: the part file missing on a
first build is a resolution error, which it never minded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Comment thread packages/jaspr_class_scope_builder/README.md Outdated
Comment thread packages/jaspr_class_scope_builder/README.md Outdated
Comment thread packages/jaspr_class_scope_builder/README.md Outdated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
@Komoszek
Komoszek merged commit f7ac4ed into master Sep 15, 2026
5 of 7 checks passed
@Komoszek
Komoszek deleted the claude/jaspra-scoped-classnames-kzspx3 branch September 15, 2026 07:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants