Skip to content

🐛 fix: a curve in the browser is the record C# reads - #731

Merged
edgarmesquita merged 17 commits into
mainfrom
fix/a-curve-in-the-browser-is-the-record-csharp-reads
Oct 10, 2026
Merged

edgarmesquita merged 17 commits into
mainfrom
fix/a-curve-in-the-browser-is-the-record-csharp-reads

Conversation

@edgarmesquita

@edgarmesquita edgarmesquita commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Closes #518

What

A Curve in browser code is the record C# reads: { x1, y1, x2, y2 }, marked [TwinIsData] as Color is. Everything that makes a curve in the browser writes that shape: the design system's presets, its motion roles (each now the runtime's MotionSpec, as C#'s is), the transition's default easing, a curve a component makes, and a page's state, which crosses a curve as its four members typed as singles. Curve.Standard.X1 reads 0.2, new Curve(0.2f, 0.9f, 0.3f, 1.25f) builds, two curves compare as .NET compares them, and the lowering writes cubic-bezier(…) from the record. A record's text writes a float member as the single it is (0.2, not the double's 0.20000000298023224).

CurveEvaluator stays on the host: it is marked [ServerOnly], and a component that calls curve.Ease(t) or CurveEvaluator.Ease from browser code fails the build with EQ2010, where it used to build with no diagnostic and throw in the browser. The fence now asks about an extension's HOME rather than its receiver, which also stops EffectiveTypeStyle's extensions called on a TypeStyle or a Text.

Why

#518: the generated design system exported a preset as an array ([0.2, 0, 0, 1]) and the runtime's MotionSpec held a preset NAME, so Curve.Standard.X1 and Motion.Press.Curve.X1 read undefined, and new Curve(…) threw TypeError: Curve2 is not a constructor (Falei.pt's repro, a TransitionSpec { Easing = new Curve(…) }). The evaluator's call and method group built and failed in the browser, because the host-only fence looked at the receiver, and a Curve crosses.

The evaluator gets no twin: a web transition is a CSS timing function and the browser evaluates the curve, nothing in the runtime, the libraries or the samples calls Ease, and a twin would be a cubic-bézier solver that must match .NET's single-precision Newton-Raphson steps for no caller. If a browser caller appears (a canvas painter easing its own frames), it can get a [RuntimeProvided] twin then.

OpenSpec

2026-10-08-a-curve-in-the-browser-is-the-record-csharp-reads, archived into openspec/specs/transpiler-vocabulary-values (two requirements, with the design of the evaluator decision).

Proof

The same filters on every side, the tests at this head, side A being the base version of the 17 files the fix touches:

Suite Side A (base) Side B (fix)
Compiler 9 of 73 fail 73 of 73 pass
Conformance 17 of 49 fail 54 of 54 pass
Web 2 of 59 fail 59 of 59 pass
vitest, the three changed specs 6 of 55 fail 55 of 55 pass
tsc exit 2 exit 0
  • Conformance: 16 of the 19 ACurveAnswersAsInDotNet cases fail on the base (new Curve(…) throws "Object is not a constructor", Curve.Standard.X1 answers null where .NET answers 0.2), and so does the export check. Side B has five more cases because the member coverage now enumerates Curve's X1, Y1, X2, Y2 and ToString.

  • The wrong fix, measured too: [ServerOnly] alone, without the fence's change, leaves 5 of the 7 fence rows failing, every form reached on a receiver.

  • At the committed tree: the full runtime suite (TestRuntime, 2,167 vitest tests, tsc clean), the wiki guards reading this pull request's wiki branch, FlutterParityPinTests, and samples/DefaultUIDashboard with no warning. In the browser, on the dashboard: Curve.standard is the record, Motion.press a MotionSpec, and after a client navigation the 18 elements with a transition use the three class hashes the server wrote, all cubic-bezier(0.2, 0, 0, 1), with no NaN and no console error.

  • Copilot's first round found a data twin's Equals and ToString as method groups bound to the design system's object, which has neither (Curve.equals.bind threw, a ToString group answered [object Object]), and Color the same way: they are the delegates their calls are now, made by the runtime ($eq.equalsGroup, $eq.text.recordGroup) with the receiver read once, 10 of 12 new conformance cases failing before. Its second round found Equals(object) accepting a same-shaped anonymous object, measured to be the data-twin representation's limit that Color has had since WithOpacity or MidpointWith on a Color value throws in the browser, where Color is a plain object #494, filed as Equals(object) on a value the browser holds as data accepts a same-shaped value of another type #742 with An anonymous object's Equals throws in the browser #743.

Breaking, as a preview breaks: a component that calls CurveEvaluator.Ease, or EffectiveTypeStyle's extensions on a receiver, from browser code now fails the build (EQ2010) where it threw in the browser. No public C# signature changed. In the runtime, a preset and a motion role's curve are the curve's data, where they were an array and a name.

Checklist

  • dotnet test on the affected test projects, and dotnet build src/eQuantic.UI.Runtime -t:TestRuntime (the embedded Bun runs tsc, then vitest run)
  • This PR's checks include a run named CI with jobs in it
  • ./scripts/check-openspec.sh passes: the change validates strictly and is archived
  • dotnet build samples/DefaultUIDashboard
  • A broken contract has a line in the migration notes (above, and the Upgrading page at the release)
  • The documentation changed with the behaviour: docs/FLUTTER-PARITY.md's Curves row, and the wiki's Styling and Diagnostics pages in English and Portuguese on the wiki branch of the same name
  • One docs/LEDGER.md line for this event, citing the issue
  • The diff reviewed by its author before the PR opened (/code-review high)
  • No "widget" in prose

The text of a value the browser holds as data ([TwinIsData]) wrote each
member as JavaScript writes a number, which for a float is the double's
digits: 0.2f printed 0.20000000298023224 where .NET prints 0.2. eqc now
passes each member's number kind to $eq.text.record, which writes the
member as an interpolation hole of that kind writes it. An integer's
text is its digits at any width, so a type with no other member passes
no kinds, and Color's text is emitted as it was.

No data twin had a float member until Curve becomes one (#518).
A Curve was two other shapes in the browser: the generated design
system exported a preset as an array, and the runtime's MotionSpec
declared its curve a preset name. Curve.Standard.X1 and
Motion.Press.Curve.X1 read undefined there, and new Curve(...) was
emitted against an object that is no constructor, so a transition's
easing built with no diagnostic and threw "Curve2 is not a constructor"
in the browser.

Curve carries [TwinIsData], as Color does, and every producer writes
the record's data, { x1, y1, x2, y2 }: the design system's presets, each
point the double that holds its single so a preset equals the curve a
component makes from the same points, its motion roles as MotionSpec
instances, TransitionSpec's default easing, and the lowering, which
reads the points into cubic-bezier(). The CSS is unchanged. A page's
state crosses a curve as its members, and the export check loses its
last exception.

Refs #518
curve.Ease(t) is CurveEvaluator.Ease(curve, t), and the runtime ships
no twin of the evaluator: a web transition is a CSS timing function,
and the browser evaluates the curve itself. The call kept its reduced
form, curve.ease(t), built with no diagnostic and threw in the browser,
and so did a method group of it. The host-only fence was asked about
the receiver, a type that crosses, and waved the call on. The same hole
let EffectiveTypeStyle's extensions through on a Text or a TypeStyle.

ExtensionHome, which a call and a method group both go through, now
asks the fence about the extension's own static with no receiver, so an
extension over a [ServerOnly] home fails the build with EQ2010 and
imports nothing. CurveEvaluator is [ServerOnly], and Photon, which
draws its own frames, keeps calling it in .NET.

Refs #518
…nd the ledger

The OpenSpec change that proposed #518, archived: transpiler-vocabulary-
values gains a curve that is the record C# reads wherever the browser
holds one, and an extension whose home stays on the host failing the
build. One LEDGER line for the event, and the Curves row of the Flutter
parity audit says what a curve is here: four control points, never an
enum, which the web hands to CSS and Photon evaluates.
Copilot AI balanced review requested due to automatic review settings October 8, 2026 13:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Curve method-group delegates still produce incorrect text or throw during delegate creation.

1 open finding
What changed in this PR

Addresses #518 by aligning browser curves with C# record data across the compiler, runtime, and design system.

Changes:

  • Unifies curve presets, motion roles, transitions, and hydration around four single-precision members.
  • Preserves float formatting in record text and rejects host-only extension calls with EQ2010.
  • Adds regression coverage and updates specifications and documentation.
File Description
tests/​eQuantic.UI.Web.Tests/​SharedComponentTranspilationTests.cs Verifies host-only extensions are rejected without imports.
tests/​eQuantic.UI.Web.Tests/​S6TransitionRealizerTests.cs Pins custom-curve CSS output.
tests/​eQuantic.UI.Web.Tests/​PrimitiveValueFixtureTests.cs Pins curve and motion values from C#.
tests/​eQuantic.UI.Conformance.Tests/​VocabularyValueConformanceTests.cs Adds curve parity cases and removes the export exception.
tests/​eQuantic.UI.Compiler.Tests/​TwinIsDataEmissionTests.cs Checks curve construction and text emission.
tests/​eQuantic.UI.Compiler.Tests/​HydrationSpecEmissionTests.cs Checks member-based curve hydration metadata.
tests/​eQuantic.UI.Compiler.Tests/​HostOnlyInASignatureTests.cs Updates fence-count commentary.
tests/​eQuantic.UI.Compiler.Tests/​HostOnlyFrameworkTypeTests.cs Covers host-only extensions and allowed curve usage.
src/​eQuantic.UI.Web.Build/​DesignSystemTsGenerator.cs Generates curve records and MotionSpec instances.
src/​eQuantic.UI.Runtime/​src/​utils/​format.ts Formats record members by numeric kind.
src/​eQuantic.UI.Runtime/​src/​utils/​format.spec.ts Tests single-precision record text.
src/​eQuantic.UI.Runtime/​src/​shared/​vocabulary.ts Uses curve data in transitions.
src/​eQuantic.UI.Runtime/​src/​shared/​s6-transition.spec.ts Tests record-shaped easing and CSS parity.
src/​eQuantic.UI.Runtime/​src/​shared/​primitives-types.fixture.json Removes the host-only evaluator.
src/​eQuantic.UI.Runtime/​src/​shared/​primitives-exports.spec.ts Checks curve presets, motion roles, and defaults.
src/​eQuantic.UI.Runtime/​src/​shared/​primitive-values.ts Types MotionSpec curves as data.
src/​eQuantic.UI.Runtime/​src/​shared/​primitive-values.fixture.json Adds pinned curve and motion values.
src/​eQuantic.UI.Runtime/​src/​shared/​nodes.ts Defines CurveValue and updates transition typing.
src/​eQuantic.UI.Runtime/​src/​shared/​lowering.ts Reads named curve members for CSS.
src/​eQuantic.UI.Runtime/​src/​shared/​design-system.generated.ts Regenerates presets and motion roles.
src/​eQuantic.UI.Runtime/​src/​index.ts Exports CurveValue typing.
src/​eQuantic.UI.Primitives/​Theme/​Tokens.cs Marks Curve as a data twin.
src/​eQuantic.UI.Primitives/​Theme/​CurveEvaluator.cs Marks the evaluator host-only.
src/​eQuantic.UI.Compiler/​Services/​RuntimeProvidedTypeScanner.cs Updates fence-count documentation.
src/​eQuantic.UI.Compiler/​CodeGen/​TypeScriptEmitter.cs Updates host-only diagnostic commentary.
src/​eQuantic.UI.Compiler/​CodeGen/​Strategies/​TwinData.cs Exposes member-type lookup internally.
src/​eQuantic.UI.Compiler/​CodeGen/​Strategies/​StringConversion.cs Emits numeric kinds for record text.
src/​eQuantic.UI.Compiler/​CodeGen/​Strategies/​Expressions/​InvocationStrategy.cs Checks reduced extensions against their host.
src/​eQuantic.UI.Compiler/​CodeGen/​Extensions/​TypeSymbolExtensions.cs Clarifies extension-routing documentation.
src/​eQuantic.UI.Compiler/​CodeGen/​Extensions/​HostOnlySymbolExtensions.cs Documents the extension fence path.
openspec/​specs/​transpiler-vocabulary-values/​spec.md Adds curve and host-only extension requirements.
openspec/​changes/​archive/​2026-10-08-a-curve-in-the-browser-is-the-record-csharp-reads/​tasks.md Records completed implementation and verification tasks.
openspec/​changes/​archive/​2026-10-08-a-curve-in-the-browser-is-the-record-csharp-reads/​specs/​transpiler-vocabulary-values/​spec.md Archives the requirement additions.
openspec/​changes/​archive/​2026-10-08-a-curve-in-the-browser-is-the-record-csharp-reads/​proposal.md Documents scope and motivation.
openspec/​changes/​archive/​2026-10-08-a-curve-in-the-browser-is-the-record-csharp-reads/​design.md Explains representation and evaluator decisions.
openspec/​changes/​archive/​2026-10-08-a-curve-in-the-browser-is-the-record-csharp-reads/​.openspec.yaml Records archived change metadata.
docs/​LEDGER.md Records the fix and verification history.
docs/​FLUTTER-PARITY.md Corrects the curve representation description.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread src/eQuantic.UI.Primitives/Theme/Tokens.cs
The review of #731 found the record's own members bound to the
companion as method groups. The companion carries the type's methods
and its presets, never equals or toString, so
Func<Curve, bool> same = Curve.Standard.Equals threw "undefined is not
an object" making the delegate, and Func<string> text =
Curve.Standard.ToString answered [object Object]. A Color, a data twin
before this pull request, failed the same way.

Each group answers through the helper its call uses: Equals over
$eq.equals, ToString over the record text with each member's number
kind, so a curve's points print as singles. The receiver is the
argument of the function that makes the delegate, read once when the
delegate is made, as C# copies it into the delegate. GetHashCode's
group already went through its own strategy.

Refs #518
…he ledger

The archived change and the main spec gain the requirement that a
record member of a value the browser holds as data is the delegate its
call is, with its scenarios, and the change's proposal and tasks say so.
The LEDGER line for #518 gains one sentence for the review's finding.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new object-overload equality delegate can incorrectly accept unrelated objects with matching members.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/MemberAccessStrategy.cs Outdated
… runtime

IntroducedFunctionsCoverageTests caught RecordMemberGroup writing its
delegates as JavaScript functions in text, three arrows a lowering held
by hand. The delegates come from the runtime now, as GetHashCode's
comes from $eq.hash.group: $eq.equalsGroup(value) and
$eq.text.recordGroup(value, name, members, kinds), each taking the
receiver as its argument, so it is still read once, when the delegate
is made. The record text's arguments are built in one place,
StringConversion.RecordText, for the call and for the group, so the
float kinds stay the same in both. Nothing a delegate answers changes,
and the conformance cases of #731's review hold it.
#731 added a line near the top of lowering.ts and seven to Tokens.cs
above the motion tokens, so 48 citations in the handoff fidelity audit
pointed one line or seven lines above what they quote, and
DocsIndexTests failed on ten of them. Each is repointed by the line map
from main to this branch, so it names the line it named on main.
Two conflicts:

- The transpiler-vocabulary-values spec: main's requirement (a TypeStyle
  scales as .NET scales it) and this pull request's three, all kept.
- The handoff audit, where both sides moved line numbers of citations.
  It takes main's text, whose rows main's pull requests re-quoted, and
  each citation is mapped through this pull request's own changes to
  the files it cites (repoint-citations.py --map-from origin/main: 49
  mapped, and --check finds none left).
Merging main left this pull request's entry above the ones that landed
before it, #730 to #690. The list is in the order things land, so it
moves to the end, unchanged.
Merging main left this pull request's entry above #696's, which landed
before it. The list is in the order things land, so it moves to the end,
unchanged.
Merging main left this pull request's entry above #733's, which landed
before it. The list is in the order things land, so it moves to the end,
unchanged.
Merging main left this pull request's entry above #748's, which landed
before it. The list is in the order things land, so it moves to the end,
unchanged.
@edgarmesquita
edgarmesquita merged commit f979d75 into main Oct 10, 2026
10 checks passed
@edgarmesquita
edgarmesquita deleted the fix/a-curve-in-the-browser-is-the-record-csharp-reads branch October 10, 2026 20:51
edgarmesquita pushed a commit that referenced this pull request Oct 10, 2026
edgarmesquita added a commit that referenced this pull request Oct 10, 2026
… or an alias (#705)

Closes #625

## What

A type the C# reaches by a name other than the one its module exports is
written by its own name and imported, as a type reached through a using
is.

- Through its namespace: part of it (`Portal.Fold.Text(n)` inside
`Falei.Web.Chat`), the whole of it (`Falei.Web.Portal.Fold`) or
`global::`. The strategy that strips the namespace registers the type
the name binds, which every emitter imports.
- Through a using alias: `using F = Falei.Web.Portal.Fold;` then
`F.Text(n)` writes `Fold.text(n)` and imports `Fold`, and `new F()`
imports it too.
- An enum or an interface reached that way imports nothing, having no
JavaScript value, and a host-only type is left to the strategies that
refuse it however it is spelled. The stripped name is its ValueText, so
a verbatim `@Name` never reaches the output.

No public API moves, and nothing in an app's code changes.

## Why

Reported by the Falei.pt app: inside `Falei.Web.Chat`,
`Portal.Fold.Text(n)` wrote `Fold.text(n)` and imported nothing, so the
page threw `Fold is not defined`, while `Fold.Text(n)` under a using
worked. The import was decided by the name as written. The review of
this branch found the same defect through an alias, `F.text(n)`, which
names nothing at all.

## OpenSpec

`a-type-is-imported-by-what-it-binds`, archived in this pull request. It
adds to `transpiler-names` (a type reached through its namespace or an
alias is imported by what it binds, and an enum reached that way imports
no module).

## Proof

A/B against main (00714cc), the same conformance theory on both sides,
run through the module graph an app's build writes, where each type is a
module of its own, in both annotation modes. It fails on main in both
(the partial and whole namespace imported nothing, and the alias wrote
`F.text(4)`) and passes here. Its five cases cover part of a namespace,
the whole of it with `global::`, a using alias read and built, an enum's
members through their namespace, which already held on main and stays as
a pin, and a class built through an alias in a module that names it
nowhere else.

Copilot's first round found that last one: the construction wrote the
type's own name but nothing imported it, which the alias case's static
read in the same module had hidden. That case fails on the head before
the round (e49a721) in both annotation modes and passes here. Its
second round found the registration reaching .NET's own types: an alias
of `List<int>` imported an app's own `List`, and `System.Math.PI` an
app's `Math`, which hid the browser's. A type is registered only where
the browser has a twin of it now, one eqc writes or one the runtime
exports, and `TypeImportScopeTests` fails on the head before that round
(2c0d90e) and passes here.

The suites on this branch merged with main 00714cc: Compiler 1730,
Server 273, Web 953 against this branch's wiki page, and Conformance
4198 with 1 skipped, on the second round's head. The served runtime does
not move: it is the 203,119 gzipped bytes main serves.

The rest of the second round, finished by the Roadmap session on Edgar's
call while this PR's session was away: `HasTwin` took a type the
vocabulary's namespace routes to the runtime as one the runtime exports,
and `CurveEvaluator` has no twin. Measured on main's compiler and on
this branch: named bare or qualified it already imported itself on main,
and through an alias main wrote `Ev.ease(...)`, which fails at the call,
where this branch imported it and failed at load. `CurveEvaluator` was
marked `[ServerOnly]` here (15d9ddc), and #731 landed the same mark on
main first, with `curve.Ease(t)` refused too, so the merge of main
20066eb takes #731's version and this PR keeps `HasTwin` answering
false for a host-only type for every caller. On main 20066eb: Compiler
1841, Web 989, the wiki guards 7. The scope test's three new spellings
compile with no error and import it on c1dbaee, and fail with EQ2010
after the fix. On main e533966: Compiler 1802, Web 988 (and the wiki
guards 7 against this branch's wiki), runtime 2246.

**Migration:** a component that names `CurveEvaluator` now fails the
build with EQ2010. It never worked in the browser, which has no twin of
it: on the web a transition is a CSS timing function.

The review also found a type declared with a verbatim name (`public
static class @Shelf`) written as `export class @Shelf` on the
declaration's side, which is not this change: #704.

## Checklist

- [x] `dotnet test` on the affected test projects, and `dotnet build
src/eQuantic.UI.Runtime -t:TestRuntime` if TypeScript changed (the
embedded Bun runs `tsc`, then `vitest run`)
- [x] This PR's checks include a run named `CI` with jobs in it
(`build-packages`, `test (ubuntu-latest)`, `test (windows-latest)`,
`openspec`, `session-start`, `wiki-checkout`). A workflow whose
expression does not parse creates zero jobs, and the ruleset's required
checks then keep the PR blocked. GitHub lists the workflow by its file
path instead of `CI`. While GitHub Actions has no credits, the same run
is on eQuantic Space (`eqs runs ls`)
- [x] `./scripts/check-openspec.sh` passes: the OpenSpec change
validates strictly, and it is archived before the merge
- [x] `dotnet build samples/DefaultUIDashboard` (and `PhotonDesktop` /
`WalletMobile` if the native track changed): CI's `samples` job builds
all three on macOS only (#149), the other hosts are yours
- [x] A broken contract has a line in the migration notes (we are in
preview: break freely, hide nothing)
- [x] The documentation changed with the behaviour: this repository's
Markdown here, and the wiki page in English AND Portuguese, in one
commit on a branch of the [wiki
repository](https://github.com/eQuantic/equantic-ui/wiki) named exactly
like this pull request's branch, merged into the wiki's master when this
merges (the twin lives at `locale/pt-BR/<Page>-pt-BR.md` there)
- [x] One `docs/LEDGER.md` line for this event, citing the issue
- [x] The diff reviewed by its author before the PR opened (in Claude
Code, `/code-review high`), every Copilot thread answered and resolved,
and a new round asked for (`gh pr edit <n> --add-reviewer @copilot`)
only after a defect, and three rounds at most
- [x] No "widget" in prose: the project's word is *component*

---------

Co-authored-by: Edgar Mesquita <edgar@equantic.tech>
edgarmesquita pushed a commit that referenced this pull request Oct 10, 2026
…ain took first

Merging main left this pull request's entry above #733's, #748's and
#731's, which landed before it. The list is in the order things land,
so it moves to the end, unchanged.
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
Merges f979d75, the fourth of the five steps that bring this branch up to main eb33f44. #731
gave a data twin's record text the number kinds of its members, and a ToString method group its
delegate, and this branch had given the same record text a descriptor per member. They become one
record text.

How each source conflict was resolved:

- utils/format.ts: #731 widened recordText(value, name, members) with a kinds array beside the
  names, and added recordTextGroup with the same two arrays. This branch had made each member a
  PrintedMember, its name alone or its name with a number kind or a function of its value (an
  enum's member name, a data twin's own text), and written every member in the page's culture.
  The descriptor carries the kinds #731 passes, so the kinds array goes: recordText(value, name,
  members) and recordTextGroup(value, name, members) take the same PrintedMember list, and the
  group is a delegate over recordText with it.
- StringConversion.cs: #731 wrote a data twin's text, and the delegate of its ToString method group,
  through RecordText(data, value, helper, context), which named only a float's kind. This branch
  wrote a record's text, a data twin's or a record twin's toString, through RecordText(value, name,
  members, context), which names every member's kind or function. Both stay, as one writer: the
  data twin's overload passes its printed members, typed by TwinData.TypeOf, to
  RecordText(helper, value, name, members, context), which writes helper(value, 'Name', [members])
  for $eq.text.record and $eq.text.recordGroup alike. A record twin's toString passes Eq.RecordText
  through CSharpToJsConverter.RecordText.
- utils/format.spec.ts: this branch's imports and culture fixtures, with recordTextGroup imported.
  #731's cases are kept and written with the descriptors: a curve's points as ['X1', 'single'], a
  member with no kind as a double, a mixed list, and the group over the same list.

The compiler's own test of #731, TwinIsDataEmissionTests, now pins the descriptor shape for a
Curve's text and its method group. A Color's members are bytes, whose text is the same in every
culture, so its call keeps the plain names. docs/LEDGER.md merged as a union, and this branch's line
moves back to the end of the events.
edgarmesquita added a commit that referenced this pull request Oct 11, 2026
Closes #454
Closes #455
Closes #469
Closes #470
Closes #471
Closes #634
Closes #570

## What

A number and a date print in the browser as .NET prints them on the
server, in the culture the page was rendered in, whatever path C# writes
them through.

- The format culture travels with every page, whether or not the app has
a string to translate (#471). The server writes the request's culture as
.NET holds it, its `NumberFormatInfo` and `DateTimeFormatInfo`, into
`__EQ_CULTURE__.format`, and a switch with no reload fetches the same
document from `/_equantic/culture/{name}.json`. A page with no culture
installed formats in the invariant culture, never in its host's locale.
The string catalogs carry strings only.
- A number with no specifier is the culture's text wherever C# writes
it: a concatenation, an interpolation hole, `ToString()`,
`Convert.ToString`, `string.Format("{0}")`, `string.Join`, a
`StringBuilder` (its value written by position or by name, or an
interpolated string handed to it) and a record's text (#454). An integer
never prints `-0`, which JavaScript makes of `-1 / 2`.
- The compiler tells the formatter what a number is (#455): a double
refuses `D`, `X` and `B` as .NET does, a `nint` and a `nuint` are 64-bit
integers, and the per mille sign, an exponent's signs and the percent
sign are the culture's own.
- `N`, `F`, `C` and `P` are laid out from the culture's data, its
patterns, symbols, separators, group sizes and default digits (#634).
`Intl` laid them out, and wrote ar-EG's own digits where .NET writes
ASCII ones, a no-break space where .NET's currency pattern has a plain
one, and two digits for `N` where .NET reads three on ICU.
- `DateOnly`, `TimeOnly` and `DateTimeOffset` print through the
formatter a `DateTime` prints through, each with the specifiers its type
takes (#469), and a custom picture writes the culture's `/` and `:`, the
offset for `z`, `zz` and `zzz`, and the era for `g` (#470).
- A calendar's names are the format data's, one copy, where they
travelled twice.
- A plain class or struct that declares no `ToString`, and derives none,
writes its type's full name as .NET does (`My.App.Outer+In`), where the
twin wrote `[object Object]` or a record's text (#570). A record keeps
its synthesized text, and a generic type is left as it was. 46 of the
runtime's own types write their name now.

EQ2109 is retired. The public surface of `eQuantic.UI.Compiler` replaces
`Eq.AsInteger` and `Eq.AsSingle` with `Eq.AsNumber` and gains
`TypeSymbolExtensions.IsDate`, and `eQuantic.UI.Web` gains
`CultureFormatBridge`. The developer surface does not move.

## Why

The server renders a page in the request's culture, and the browser
wrote numbers and dates its own way, so the SSR markup and the hydrated
page printed one value two ways: `1.5` where the server wrote `1,5` in
pt-BR, a currency with a no-break space where the server wrote a plain
one, a `DateOnly` in a pattern nobody asked for. On an app with no
`.resx`, the browser formatted in its host's locale, and the conformance
suite passed or failed by the machine it ran on.

## OpenSpec

`a-number-and-a-date-print-in-the-pages-culture`, archived in this pull
request. It creates `page-culture` (the format culture a page is in, how
it travels, the invariant culture with none installed, a standard number
format laid out from the data, a calendar's names) and extends
`transpiler-bcl` (a number's text with no specifier, a number's kind)
and `runtime-dates` (the date types and a custom picture). Its design
says what is not here: the culture a server action runs in (#580), a
`DateTimeOffset`'s local time (#626, which #660 has landed since), and a
non-Gregorian default calendar.

## Proof

A/B against main (98c2b18, with #660, #748 and the whole queue landed),
the same 222 conformance cases on both sides, both executed, with main's
side given the culture as main installs it on a page with a catalog, its
best case. 176 fail on main and all 222 pass here: 49 of the 70 number
texts in five cultures, 7 of the 11 number kinds, all 6 standard
layouts, 74 of the 75 date type texts, 38 of the 55 date pictures and 2
of the 5 harness cases. The other 46 already held on main and stay as
pins. Against main at dc4a410 it was 187 of 222: #660 has taught a
date's picture its offset and its kind since, ten cases, and #748 a
builder's interpolated holes their text, one. Main's side was built on
#708's head 78215e9, whose tree is main's (8833abd on both). The
default text's seven cases, run through the module graph an app's build
writes in TypeScript and in JavaScript, fail in both on the head before
them (446542b) and pass here.

The format subset is generated from .NET and replayed through the
runtime byte for byte, in seven cultures, ar-EG and fr-FR among them. It
folded the no-break spaces on both sides, which is how #634 passed it.

The suites ran on a7155cc, a tree the merge of main 98c2b18 left as it
was, since that merge records #708's squash with the ours strategy:
Compiler 1898, Web 1021 against this branch's wiki pages, Server 374,
Conformance 5087 with 1 skipped, and the runtime's 2355 in 170 files
(`tsc` and vitest through the embedded bun). The only commit after it
changes the ledger line's numbers, and the Web suite, the one that reads
the ledger, ran again on it: 1021. The served runtime is 223,575 bytes
gzipped against the 219,741 main serves (3,834 more, +1.74%, main's
bundle built and measured the same way, and its record says the same),
and its budget is regenerated to what this branch serves: the layout of
N, F, C and P and the date types' formatter, the twins, whose numbers
now say their kind, and the runtime types that now write their full
name.

Main moved under this branch more than once, and its pull requests
changed the same files for their own reasons. Each merge keeps both
behaviours.

- #660 taught the formatter a `DateTime`'s kind. This branch had
rewritten the date code around one moment per value, so the kind and the
zone's offset moved into it: the round trip and `K` write `Z`, a local
time's offset or nothing, `z` writes the offset, and `U` converts by the
kind and stays inside the calendar. #660's own cases pass on this
branch's formatter.
- #661 printed a nullable float through `$eq.num.single`. Here the
culture's formatter writes it with the kind `single`, the same digits in
the culture's symbols. Every binding the emitted code declares starts
with a `$`, the record text's arrow included.
- #748 rewrote the builder. Its dispatch stays, a hole of an
interpolated append is decided by the bound operation as #748 decides
it, and the value `Append` and `Insert` write goes through the culture's
text in front of it. So `Append(0.1f)`, `Append(1e21)`, `Append(-0.0)`,
`Append(1e-7)` and `Append(Tone.Warm)` write what .NET writes, the rows
#750 lists, measured through this branch's served runtime. Its last row
stays: an enum held as an `object` still prints its key.
- #731 gave a data twin's record text its members' number kinds and a
delegate for its `ToString` method group. This branch had given the same
text a descriptor per member. They are one `recordText(value, name,
members)` now, each member its name alone or its name with a kind or a
function, and `recordTextGroup` takes the same list.
- #654 names a nested record's twin by its owner, and its text still
prints its C# name.
- #715's null-argument baseline loses 10 of its 401 entries and gains
none. Seven now answer as .NET does: `Convert.ToString` of an object and
of a string, with and without a provider, and a null format handed to
`DateOnly`, `TimeOnly` and `DateTimeOffset.ToString(format, provider)`.
Three are probes the build now refuses with EQ2108, a provider held in a
variable, as it already refuses a `DateTime`'s. Two reasons that no
longer said what the measurement shows are rewritten by hand.

The merge with #667 met two things. Its builder refuses a null string
handed to a ranged overload, as .NET does, and this branch wrote every
bound value through the culture's text, which made that null an empty
string. A value that is text already (a string, a char, a char[], a
builder) now passes as it is, and only a number, a bool, an enum or an
object is written in the culture. It also pinned an exception's
interpolated message as it was before the culture, and that message now
prints the number's hole in the culture. And the Windows runner failed
one case on the previous head, where `AppendLine` writes the host's
`"\r\n"`: the case reads `"\r\n"` as `"\n"` on both sides.

And it runs, on this head: the dashboard sample, built with no warning
and served from its built binary, wrote `R$ 1.234,50 · 98,2% ·
13/08/2026` on its localization screen in pt-BR, byte for byte what the
server rendered, with a plain space after `R$` on both sides, and wrote
it again when the browser rendered the screen after a click. It switched
to es with no reload by fetching `/_equantic/culture/es.json` and wrote
exactly the server's es text, `1.234,50 ¤ · 98,2 % · 13/8/2026` (es is a
neutral culture, with the generic ¤), and its charts' axes after a
client navigation read as the server rendered them (`50.000`,
`100.000`), with no console error.

## Checklist

- [x] `dotnet test` on the affected test projects, and `dotnet build
src/eQuantic.UI.Runtime -t:TestRuntime` if TypeScript changed (the
embedded Bun runs `tsc`, then `vitest run`)
- [x] This PR's checks include a run named `CI` with jobs in it
(`build-packages`, `test (ubuntu-latest)`, `test (windows-latest)`,
`openspec`, `session-start`, `wiki-checkout`). A workflow whose
expression does not parse creates zero jobs, and the ruleset's required
checks then keep the PR blocked. GitHub lists the workflow by its file
path instead of `CI`. While GitHub Actions has no credits, the same run
is on eQuantic Space (`eqs runs ls`)
- [x] `./scripts/check-openspec.sh` passes: the OpenSpec change
validates strictly, and it is archived before the merge
- [x] `dotnet build samples/DefaultUIDashboard` (and `PhotonDesktop` /
`WalletMobile` if the native track changed): CI's `samples` job builds
all three on macOS only (#149), the other hosts are yours
- [x] A broken contract has a line in the migration notes (we are in
preview: break freely, hide nothing)
- [x] The documentation changed with the behaviour: this repository's
Markdown here, and the wiki page in English AND Portuguese, in one
commit on a branch of the [wiki
repository](https://github.com/eQuantic/equantic-ui/wiki) named exactly
like this pull request's branch, merged into the wiki's master when this
merges (the twin lives at `locale/pt-BR/<Page>-pt-BR.md` there)
- [x] One `docs/LEDGER.md` line for this event, citing the issue
- [x] The diff reviewed by its author before the PR opened (in Claude
Code, `/code-review high`), every Copilot thread answered and resolved,
and a new round asked for (`gh pr edit <n> --add-reviewer @copilot`)
only after a defect, and three rounds at most
- [x] No "widget" in prose: the project's word is *component*

---------

Co-authored-by: Edgar Mesquita <edgar@equantic.tech>
@edgarmesquita edgarmesquita mentioned this pull request Oct 11, 2026
5 of 6 tasks
edgarmesquita added a commit that referenced this pull request Oct 11, 2026
Closes #756

The bump to `0.2.0-preview.61`, cut from main at 9305100 (#637) at
Edgar's request. It carries forty-five pull requests since `.60`: a
component that hears the server on a typed topic (#647), a page that
requires its authorization and a Server Action that refuses another
site's request (#686, #737), a layout that follows its box and type that
follows the window (#648, #665), a control's press and focus on every
target with a 24dp floor under a pointer (#617, #628), the code editor's
completion (#618, #653, #733), a hot reload under `dotnet watch` that
keeps the page's state (#666, #672), the image optimizer on SkiaSharp
(#730), the places where the producers drew one tree differently, with
Photon's hit-testing and its text layout (#688, #690, #699, #739, #741,
#749), a number and a date printed in the page's culture (#637), and a
long family of browser answers made .NET's (#579, #588, #594, #607,
#608, #621, #654, #656, #660, #661, #667, #668, #689, #696, #697, #705,
#707, #708, #709, #715, #731, #748, #752), with a page that answers HEAD
(#610) and the conformance harness's own comparison (#609).

## What this commit does

- `Directory.Build.props`: `0.2.0-preview.60` → `0.2.0-preview.61`.
- `./scripts/public-api.sh ship`: nine projects, 462 declarations join
the shipped surface and 43 retire, and every `Unshipped` is empty
afterwards. Checked after the fold, per project and compared as sets:
the lines that left `Shipped` are exactly the 43 `*REMOVED*` lines
declared on main, and the lines that joined are exactly its 462
declarations, so an orphan could not vanish unnoticed. The line diff
reads +463 −549: the version line, 462 lines into `Shipped` and 43 out
of it, and the 505 that leave the `Unshipped` files.
- The analyzer accepts the fold: the solution builds with no RS0016 or
RS0017. And it read the files: one bogus declaration appended to an
`Unshipped` failed the build with RS0017.
- Against `v0.2.0-preview.60` the `Shipped` diff has those 43 lines gone
and no other, so no pull request rewrote a shipped line in place this
time, as #512 did in `.60`.

## What the retirements are

Read out of the `*REMOVED*` lines, each paired with the line that joined
under the same member and attributed by the commit that wrote it, not
from the log. Only `ServerActionHub` left with nothing in its place, and
the rest changed shape:

- `ServerActionHub` and its constructor (#647): the hub, its route
`/_equantic/hub` and the `AddSignalR()` call `AddUI` made are gone. An
app that injected `IHubContext<ServerActionHub>` publishes through
`IServerEventPublisher`, and one that maps hubs of its own calls
`AddSignalR()` itself.
- `ICodeCompletionProvider.CompleteAsync` (#618) takes a
`CodeCompletionContext` and answers a `CodeCompletionList`.
`CurlyBraceLanguage.Keywords` was the protected abstract set a language
overrode, and it is the public list a completion offers now.
- `Drawing`'s constructor, `Drawing.Width` and `UI.Drawing` (#648) hold
the width as a `SizeValue`. `new Drawing(art, 240)` converts, and a read
into a `float` becomes `drawing.Width.Value`.
- `ImageOptimizer()` and `BlurPlaceholderGenerator()` (#730) take the
`ImageOptimizationOptions`. `UseImageOptimization` registers both, so
only an app that built one with `new` meets it.
- `HitRegion` (#690) gains `Drawn` after `Bounds`, so building one and
deconstructing one both change. `CodeRegion` (#653), `SheetRegion` and
`TextRegion` (#690) gain optional members at the end, so only a
positional deconstruction changes.
- `ServerActionsMiddleware`'s constructor (#737) takes an
`IOptionsMonitor<ServerActionsOptions>`. `UseServerActions()` builds it,
so only an app that constructs the middleware itself meets it.
- Four signatures widened at the end by optional parameters, which
compiles as before from source: `UI.Positioned` by the four fractions
and the two shifts (#648), `UI.Pressable` by `canRequestFocus` (#653),
`WebRealizer.Lower` by `density` (#688) and `PhotonRealizer.Realize` by
`sizedLike` (#690).
- `eQuantic.UI.Compiler`, which no app references, 21 lines: eleven of
#661 (`JsTemplate`'s `Annotate`, with its constructor, its `Deconstruct`
and the factory that took it, and six LINQ strategies that crossed to
the IR and lost their text `Convert`), four of #608 (`ValueMember`,
`ComponentDependencyResolver`'s constructor and
`RecordTypeEmitter.CanEmit`), two of #637 (`Eq.AsInteger` and
`Eq.AsSingle`), two of #654 (`UserDefinedOperators.Binary` and `Unary`),
and `HashSetStrategy` (#594) and `RangeIndexerStrategy` (#707) crossing
to the IR.

One break is an addition, which no `*REMOVED*` line shows.
`CurlyBraceLanguage.ReservedWords` (#618) is a new abstract member, so a
language derived from it renames its `Keywords` override to
`ReservedWords`. I listed every member that joined an interface or an
abstract class already shipped, and it is the only one an implementer
has to write: `ICodeLanguage.Keywords` and
`ICodeCompletionProvider.ResolveAsync` have default implementations.

The additions are 118 in `eQuantic.UI.Code` (the completion), 116 in
`Primitives` (the server events vocabulary, `FluidSize`, `Positioned`'s
fractions and shifts, `BoxStyle.Pressed`, `GridTrack.AutoFill`,
`Touch.MinPointerTarget`), 98 in `Server` (server events, the origins a
Server Action allows), 86 in the compiler, 19 in `Native.Components`, 12
in `Web`, 7 in `Components`, 4 in `Native.Framework` and 2 in `Images`.

## The rest of the surface

`developer-surface.baseline.txt` gains two configuration sections since
`v0.2.0-preview.60`, `EQuantic:ServerActions` (#737) and
`EQuantic:ServerEvents` (#647), and loses nothing: no MSBuild property
or template parameter came or went.

What an app meets with no signature moving:

- Three new build errors. EQ1009 (#608, #621, #708) stops two
constructors that take the same number of arguments, in a class, a
record or a struct that reaches the browser, where the twin kept one and
a `new` that meant another ran it. EQ2014 (#708) stops an exception
built or tested where a type argument it carries is a type parameter.
EQ2013 (#647) stops a `ServerTopic<T>` built over a type parameter,
which only new code can meet.
- Refusals widened under existing codes: EQ1007 by a second indexer and
a sequence over two types (#707, #708), EQ2004 by a stored `Range`, a
method group over a .NET extension and a .NET collection's own
`GetEnumerator()` (#656, #707, #708), EQ2007 by a comparer handed to
`ToDictionary`, `ToLookup`, `GroupBy` or `Distinct` (#707), and EQ2010
by an extension method of a server-only class and a type nested in one
(#654, #705, #731).
- Two refusals a running app meets on the server: a Server Action called
from another origin answers 403 until the app lists that origin (#737),
and a class-level `[Authorize]` guards the page as well as its actions
(#686).
- `image/avif` in the optimizer's `Formats` stops the app at startup
(#730), and a `Spacer` of weight below 1 or a negative `Flexible` weight
throws where it is written (#741).
- EQ2109 is retired and EQ2110 is about reading a number now, since a
number written as text follows the page's culture on both sides (#637).

The rest are browser answers made .NET's and drawings made alike across
the producers, listed in the tag.

## The wiki's version marks

`WikiVersionMarkTests` compares a mark only once a tag holds its symbol,
so the `.61` tag is when this release's marks are judged for the first
time. Emulated before the tag, with the test's own pattern and lookup
and a `.61` at 9305100: 145 marks, no mismatch. The thirteen it judges
first resolve to `.61`, as written: `StartFraction`,
`GridTrack.AutoFill`, `canRequestFocus`, `DENSITY_COOKIE`, `Drawing`'s
`SizeValue` width, `WithPageAuthorization`, `AllowServerActionOrigins`,
`IServerEvents`, `MapGetAndHead`, `UseServerEvents`, `TwinIsData` on the
tokens, `BoxStyle.Pressed` and `ScrolledThreshold`.

## The tag's gate, rehearsed

`gate-templates` runs only on a `v*` tag, so a template that does not
build costs a release attempt. Rehearsed on main at 9305100, before
this branch: 50 packages packed at `1.0.0-dev`, the three web and the
four native template shapes scaffolded and built in Release against that
feed alone, with no warning and with every eQuantic package restored at
the version packed, the published `runtime.js` identical byte for byte
to the Server package's, and the dashboard template served, hydrated and
navigated in a browser with no console error.

## Not in this release

No pull request is open. Forty-eight bugs are, none of them a regression
against `.60`. Three issues came out of this stretch and wait for a
decision or their turn: #753 (`string.Split` with an array of
separators, a count or a null separator), #754 (an app's exception class
in a snippet compiled on its own) and #755 (whether generic code should
carry its type arguments at run time, which EQ2014 fences).

The release notes travel in the annotated tag. The wiki's Upgrading page
carries the distilled entry in both languages, on the wiki branch
`chore/0.2.0-preview.61`, which merges into the wiki's master when this
merges and before the tag. The `docs/LEDGER.md` line for the release
follows the tag, as `.60`'s did, because it names the commit the tag
lands on.

## Checklist

- [x] The diff reviewed by its author before the PR opened: a version
line and a mechanical fold, verified as sets above
- [x] `dotnet test` on this branch: Compiler 1898, Server 374,
Conformance 5087 with one skipped, Web 1021, and the runtime's own 2355
in 170 files, none aborted
- [ ] This PR's checks include a run named CI with jobs in it
- [x] `./scripts/public-api.sh ship` run, every `Unshipped` empty, the
retirements read from the diff
- [x] The documentation changed with the release: the Upgrading entry in
English and Portuguese, on the wiki branch `chore/0.2.0-preview.61`,
with the wiki guards passing against it (9 of 9, and the vocabulary
guard fails on that page when a word it refuses is written into it)
- [x] Copilot's rounds answered: one round, approval recommended with no
finding and no thread, so the loop ends there

Co-authored-by: Edgar Mesquita <edgar@equantic.tech>
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.

A Curve in browser code is a preset array or a preset name, never the record C# reads

3 participants