Repository navigation
🐛 fix: a curve in the browser is the record C# reads - #731
Merged
edgarmesquita merged 17 commits intoOct 10, 2026
Merged
Conversation
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.
There was a problem hiding this comment.
🟡 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.
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.
There was a problem hiding this comment.
🟡 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.
This was referenced Oct 8, 2026
Equals(object) on a value the browser holds as data accepts a same-shaped value of another type
#742
Open
… 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 #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
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
9 tasks done
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
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.
9 tasks done
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Closes #518
What
A
Curvein browser code is the record C# reads:{ x1, y1, x2, y2 }, marked[TwinIsData]asColoris. Everything that makes a curve in the browser writes that shape: the design system's presets, its motion roles (each now the runtime'sMotionSpec, 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.X1reads 0.2,new Curve(0.2f, 0.9f, 0.3f, 1.25f)builds, two curves compare as .NET compares them, and the lowering writescubic-bezier(…)from the record. A record's text writes a float member as the single it is (0.2, not the double's0.20000000298023224).CurveEvaluatorstays on the host: it is marked[ServerOnly], and a component that callscurve.Ease(t)orCurveEvaluator.Easefrom 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 stopsEffectiveTypeStyle's extensions called on aTypeStyleor aText.Why
#518: the generated design system exported a preset as an array (
[0.2, 0, 0, 1]) and the runtime'sMotionSpecheld a preset NAME, soCurve.Standard.X1andMotion.Press.Curve.X1read undefined, andnew Curve(…)threwTypeError: Curve2 is not a constructor(Falei.pt's repro, aTransitionSpec { 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 aCurvecrosses.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 intoopenspec/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:
Conformance: 16 of the 19
ACurveAnswersAsInDotNetcases fail on the base (new Curve(…)throws "Object is not a constructor",Curve.Standard.X1answers null where .NET answers 0.2), and so does the export check. Side B has five more cases because the member coverage now enumeratesCurve'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,tscclean), the wiki guards reading this pull request's wiki branch,FlutterParityPinTests, andsamples/DefaultUIDashboardwith no warning. In the browser, on the dashboard:Curve.standardis the record,Motion.pressaMotionSpec, and after a client navigation the 18 elements with a transition use the three class hashes the server wrote, allcubic-bezier(0.2, 0, 0, 1), with noNaNand no console error.Copilot's first round found a data twin's
EqualsandToStringas method groups bound to the design system's object, which has neither (Curve.equals.bindthrew, aToStringgroup answered[object Object]), andColorthe 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 foundEquals(object)accepting a same-shaped anonymous object, measured to be the data-twin representation's limit thatColorhas 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, orEffectiveTypeStyle'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 teston the affected test projects, anddotnet build src/eQuantic.UI.Runtime -t:TestRuntime(the embedded Bun runstsc, thenvitest run)./scripts/check-openspec.shpasses: the change validates strictly and is archiveddotnet build samples/DefaultUIDashboarddocs/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 namedocs/LEDGER.mdline for this event, citing the issue/code-review high)