Skip to content

Commit 92efd0d

Browse files
haliceaclaude
andcommitted
Add cross-framework comparison sample (htnet vs React vs Blazor)
Move the dashboard comparison study under samples/: the same Pulse dashboard implemented in CC.CSX (htnet), React, and Blazor, with a shared CSS, line-count measurement script, and writeup (SPEC, RESULTS, HTNET_GUIDE). Also bump the pinned SDK in global.json to 10.0.301. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ce1f73d commit 92efd0d

43 files changed

Lines changed: 4230 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎global.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"sdk": {
3-
"version": "10.0.100"
3+
"version": "10.0.301"
44
}
55
}

‎samples/comparison/.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# build artifacts
2+
**/bin/
3+
**/obj/
4+
**/node_modules/
5+
react/dist/
6+
**/publish-out/

‎samples/comparison/HTNET_GUIDE.md‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# htnet (CC.CSX.Browser) — primer for this task
2+
3+
You are building a **client-side .NET WASM** app with **CC.CSX**, a C# library that
4+
generates HTML from pure C# function calls (think JSX/Hiccup, but C#). There is **no
5+
Blazor, no Razor, no .cshtml** — you write C# that returns an `HtmlNode` tree, and
6+
`CC.CSX.Browser` renders it into the page and **morphs the live DOM** on every update.
7+
Read this whole file before writing code; the API is small but unfamiliar.
8+
9+
## How an app is structured
10+
11+
Everything goes in **`Program.cs`** (top-level statements). The shape is:
12+
13+
```csharp
14+
using System;
15+
using System.Collections.Generic;
16+
using System.Linq;
17+
using CC.CSX;
18+
using CC.CSX.Browser;
19+
using static CC.CSX.HtmlElements; // Div, Span, Table, Button, Select, ...
20+
using static CC.CSX.HtmlAttributes; // id, @class, style, value, type, ...
21+
using static CC.CSX.Browser.HtmlEvents; // onClick, onChange, onInput, ...
22+
23+
// 1) mutable in-memory state lives as plain variables/fields
24+
int count = 0;
25+
26+
// 2) mount the view, then idle forever
27+
await BrowserApp.MountAsync("#app", View);
28+
await BrowserApp.RunAsync();
29+
30+
// 3) the view is a function returning a single root HtmlNode
31+
HtmlNode View() =>
32+
Div(@class("app"),
33+
Button(onClick(() => count++), $"Clicked {count}")
34+
);
35+
```
36+
37+
That's the whole lifecycle. `MountAsync` renders `View()` into `#app`; `RunAsync()`
38+
keeps the WASM app alive.
39+
40+
## The golden rule: state + auto re-render
41+
42+
- Keep app state in **ordinary C# variables** (captured by the `View()` closure) or
43+
static fields. To build a screen from state, just **read those variables inside
44+
`View()`**.
45+
- **Event handlers mutate the state, and the framework automatically re-renders**
46+
after every delegate handler runs. You do **not** need to call anything after a
47+
click/change — `onClick(() => count++)` updates the screen by itself.
48+
- (If you ever mutate state *outside* an event handler, e.g. a timer, call
49+
`BrowserApp.Refresh()` to re-render. You won't need that here.)
50+
- Re-render morphs the DOM in place, so input focus/caret survive.
51+
52+
## Elements (factories from `HtmlElements`, PascalCase)
53+
54+
Each HTML element is a method taking `params HtmlItem[]` — you pass **attributes and
55+
children mixed together, in any order**:
56+
57+
```csharp
58+
Div(@class("card"), id("x"), H1("Title"), P("body"))
59+
Section(...), Header(...), Main(...), Span(...), Button(...), Select(...), Option(...),
60+
Table(...), Thead(...), Tbody(...), Tr(...), Th(...), Td(...), H1(...)..H3(...), P(...), Ul(...), Li(...)
61+
```
62+
63+
Strings/ints/etc. passed as children become text nodes (auto-escaped). Example:
64+
`Td(@class("num"), $"${amount:N0}")`.
65+
66+
## Attributes (factories from `HtmlAttributes`, lowercase)
67+
68+
```csharp
69+
id("main")
70+
@class("card", "kpi") // params string[] -> joined with spaces -> class="card kpi"
71+
@class(active ? "filter-tab active" : "filter-tab") // conditional classes: build the string
72+
style("height:42%") // raw inline style string (this is the ONLY inline style you need)
73+
value("Last 7 days") // e.g. on <select> to reflect current selection
74+
type("button")
75+
("data-foo", "bar") // a (string,string) tuple also works as an attribute
76+
```
77+
78+
Note `@` prefix on `@class` (because `class` is a C# keyword). `style(...)` here takes a
79+
plain CSS string — use `style($"height:{pct}%")` for the bars.
80+
81+
## Events (from `CC.CSX.Browser.HtmlEvents`)
82+
83+
Handlers are C# delegates. Each event has `Action`, `Action<DomEvent>`, and async
84+
overloads:
85+
86+
```csharp
87+
Button(onClick(() => selected = "Paid"), "Paid") // no-arg
88+
Button(onClick(e => sortDir = -sortDir), "Amount") // with event
89+
Select(onChange(e => { range = e.Value!; Reseed(); }), // <select> change
90+
Option(value("Today"), "Today"),
91+
Option(value("Last 7 days"), "Last 7 days"))
92+
```
93+
94+
`DomEvent` gives you what you need here: **`e.Value`** (string? — the value of the
95+
changed `<input>`/`<select>`), plus `e.Checked`, `e.Key`, `e.TargetId`, etc.
96+
For a `<select>`, read the chosen option from `e.Value` in `onChange`.
97+
Remember: after the handler runs, the view re-renders automatically.
98+
99+
## Rendering dynamic lists / collections
100+
101+
Build child arrays with LINQ and splat them in. A node accepts an `HtmlNode[]` as a
102+
child (it becomes a fragment):
103+
104+
```csharp
105+
Tbody(
106+
orders.Where(o => filter == "All" || o.Status == filter)
107+
.Select(o => Tr(
108+
Td(@class("mono"), $"#{o.Id}"),
109+
Td(o.Customer),
110+
Td(Span(@class("badge", o.Status.ToLower()), o.Status)),
111+
Td(@class("num"), $"${o.Amount:N0}")
112+
))
113+
.ToArray()
114+
)
115+
```
116+
117+
The bar chart row is the same idea: `.Select(d => Div(@class("bar-col"),
118+
Div(@class("bar"), style($"height:{d.Pct}%")), Span(@class("bar-label"), d.Label)))
119+
.ToArray()`.
120+
121+
Conditionals: use normal C# ternaries. For "show empty row when no matches", compute
122+
the filtered list first, then return either the rows or a single
123+
`Tr(Td(("colspan","4"), @class("empty"), "No orders"))`.
124+
125+
## Things specific to this codebase
126+
127+
- Project file, `index.html`, `main.js`, the WASM boot, and `dashboard.css` are
128+
**already wired** — don't touch them. You only write `Program.cs`.
129+
- `#app` is the mount point; `dashboard.css` is already linked in `index.html`.
130+
- Use only the CSS classes from `dashboard.css` (see SPEC + STYLEGUIDE). The only
131+
inline style allowed is the bar `height:NN%`.
132+
- Build/run with `dotnet run` from the project dir (it's a `Microsoft.NET.Sdk.WebAssembly`
133+
project). To just check it compiles: `dotnet build`.
134+
- There is no `Console`-style UI; everything is the `HtmlNode` tree returned by `View()`.
135+
136+
## Minimal end-to-end example (counter + select + list) — for shape only
137+
138+
```csharp
139+
using System; using System.Collections.Generic; using System.Linq;
140+
using CC.CSX; using CC.CSX.Browser;
141+
using static CC.CSX.HtmlElements;
142+
using static CC.CSX.HtmlAttributes;
143+
using static CC.CSX.Browser.HtmlEvents;
144+
145+
string range = "Last 7 days";
146+
List<int> data = [3, 7, 5, 9];
147+
148+
await BrowserApp.MountAsync("#app", View);
149+
await BrowserApp.RunAsync();
150+
151+
HtmlNode View() =>
152+
Div(@class("app"),
153+
Select(value(range), onChange(e => range = e.Value!),
154+
Option(value("Today"), "Today"),
155+
Option(value("Last 7 days"), "Last 7 days")),
156+
Div(@class("chart"),
157+
data.Select(v => Div(@class("bar-col"),
158+
Div(@class("bar"), style($"height:{v * 10}%")))).ToArray())
159+
);
160+
```
161+
162+
Now read `SPEC.md` and implement Pulse in `Program.cs`.

‎samples/comparison/RESULTS.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Pulse — three-way build comparison (results)
2+
3+
Same spec (`SPEC.md`), same style guide (`shared/dashboard.css`), three stacks, one
4+
build agent each (same model, run in parallel). The htnet agent additionally received
5+
`HTNET_GUIDE.md` because the model has no prior knowledge of CC.CSX.
6+
7+
## Metrics
8+
9+
| Metric | Blazor (WASM) | React (Vite) | htnet (CC.CSX.Browser) |
10+
|---|---:|---:|---:|
11+
| **Tokens — total processed** | 290,311 | 318,492 | 651,174 |
12+
| &nbsp;&nbsp;input (fresh) | 14,305 | 14,305 | 17,360 |
13+
| &nbsp;&nbsp;cache write (incl. generated files) | 70,215 | 52,593 | 77,062 |
14+
| &nbsp;&nbsp;cache read (context re-read/turn) | 205,032 | 247,576 | 552,117 |
15+
| &nbsp;&nbsp;output | 759 | 4,018 | 4,635 |
16+
| Agent turns | 13 | 13 | 22 |
17+
| Harness "subagent_tokens" | 28,056 | 30,884 | 37,657 |
18+
| **Lines authored (non-blank)** | 208 | 188 | 155 |
19+
| Lines authored (total) | 231 | 206 | 179 |
20+
| Files authored | 1 (`Home.razor`) | 1 (`App.jsx`) | 1 (`Program.cs`) |
21+
| **Payload — raw** | 9.11 MB | 217 KB | 7.73 MB |
22+
| **Payload — compressed transfer** | 2.71 MB (brotli) | ~64 KB (gzip) | 2.22 MB (brotli) |
23+
| Payload file count | 46 | 6 | 23 |
24+
| Build result | ✅ clean | ✅ clean | ✅ (expected CA1416/CS1591 warnings) |
25+
26+
## Notes / caveats
27+
28+
- **Token accounting:** this harness records model-generated *tool content* (the written
29+
files) under `cache write`, not `output`, so `output` alone understates work. Use
30+
**total processed** for comparison. htnet's ~2× total is dominated by **cache read**:
31+
it took 22 turns vs 13 (reading the primer + more build/iterate round-trips), and every
32+
turn re-reads the cached context (billed at ~10%). Genuinely-new tokens
33+
(input + cache-write + output) were closer: Blazor ~85k, React ~71k, htnet ~99k.
34+
- **Lines:** htnet was the *most* compact implementation (155 non-blank), then React,
35+
then Blazor — even though htnet was the "unknown" framework. The shared CSS did the
36+
visual work in all three; agents only wrote structure + state + handlers.
37+
- **Payload / "final page size":** React ships ~64 KB gzip (just app JS + CSS + HTML).
38+
Blazor and htnet each ship the .NET WASM runtime + assemblies → ~2–2.7 MB compressed.
39+
htnet's payload is ~0.5 MB smaller and ~half the file count of Blazor (no Blazor
40+
component framework / no `blazor.webassembly.js` machinery — just CC.CSX + runtime).
41+
- **Rendered DOM size** was not captured live (browser navigation was denied during the
42+
run). By construction it is ~identical across the three (same spec, same classes,
43+
same node count) — re-runnable if browser access is enabled.
44+
45+
## How to reproduce / run
46+
47+
```
48+
# each app:
49+
cd comparison/react && npm run dev # http://localhost:5173
50+
cd comparison/blazor && dotnet run # serves the WASM app
51+
cd comparison/htnet && dotnet run # serves the WASM app
52+
```
53+
54+
Measurement scripts/data: `measure-lines.sh`; token data parsed from
55+
`~/.claude/projects/<proj>/<session>/subagents/agent-*.jsonl`.

‎samples/comparison/SPEC.md‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Pulse — Analytics Dashboard (shared spec)
2+
3+
Build a **single-page analytics dashboard** called **Pulse**. This is a client-side
4+
interactive SPA. The same spec is implemented in three stacks (Blazor, React, htnet);
5+
your job is one of them. Match this spec exactly so the three are comparable.
6+
7+
All data is **mock/in-memory** — no backend, no network calls. Generate it in code.
8+
9+
## Layout (top to bottom)
10+
11+
1. **Top bar**
12+
- Brand on the left: a small colored dot (`<span class="dot">`), the title `Pulse`,
13+
and a muted subtitle `Analytics overview`.
14+
- Controls on the right:
15+
- A **date-range `<select class="select">`** with options: `Today`, `Last 7 days`,
16+
`Last 30 days` (default `Last 7 days`).
17+
- A **Refresh `<button class="btn btn-primary">`**.
18+
19+
2. **KPI row** — a `.kpi-grid` of **four** `.card.kpi` cards. Each card:
20+
- `.kpi-label` (uppercase label)
21+
- `.kpi-value` (the big number)
22+
- `.kpi-delta` with class `up` or `down` and text like `▲ 12.4%` / `▼ 3.1%`
23+
- The four KPIs: **Revenue** (currency, e.g. `$48,250`), **Orders** (integer),
24+
**Visitors** (integer), **Conversion** (percent, e.g. `3.8%`).
25+
26+
3. **Revenue chart section** — a `.section` with `.section-head` (title
27+
`Revenue over time`) and a `.card` containing a `.chart`. The chart is a row of
28+
bar columns, one per data point. Each column is:
29+
```
30+
<div class="bar-col"><div class="bar" style="height:NN%"></div><span class="bar-label">Mon</span></div>
31+
```
32+
Bar height is a percentage of the max value in the current dataset.
33+
7 bars (weekday labels Mon–Sun) for the 7-day range; use a sensible set for the
34+
other ranges (e.g. `Today` → hourly buckets, `Last 30 days` → 4 weekly buckets).
35+
36+
4. **Recent orders section** — a `.section` with a `.section-head`: title
37+
`Recent orders` on the left, and on the right a **status filter** as
38+
`.filter-tabs` containing `.filter-tab` buttons: `All`, `Paid`, `Pending`,
39+
`Refunded` (the active one has the extra class `active`).
40+
Below, a `.card` wrapping a `<table class="table">` with columns:
41+
**Order** (id, render in a `.mono` cell, e.g. `#10428`), **Customer**,
42+
**Status** (a `.badge` whose extra class is `paid` / `pending` / `refunded`),
43+
**Amount** (right-aligned, use `<td class="num">`, currency).
44+
Seed ~10 orders with mixed statuses.
45+
46+
## Interactivity (all client-side, in-memory state)
47+
48+
- **Date-range select** changes the KPI values **and** the chart dataset. Provide three
49+
distinct datasets (one per range) so switching is visibly different.
50+
- **Refresh button** re-randomizes the KPI numbers and the chart values for the
51+
currently selected range (deltas may flip up/down), and re-rolls the orders list.
52+
- **Status filter tabs** filter the orders table to the selected status; `All` shows
53+
everything. Update the `active` class on the chosen tab. If a filter yields no rows,
54+
show a single full-width `.empty` cell with text `No orders`.
55+
- **Sortable Amount column**: the `Amount` `<th>` has class `sortable`; clicking it
56+
toggles ascending/descending sort of the table by amount. Show a `▲`/`▼` indicator
57+
in a `<span class="arrow">` inside that header for the active direction.
58+
59+
## Styling rules (important for fairness)
60+
61+
- Use **only** the classes defined in `dashboard.css` (provided, already linked in the
62+
page). Do **not** add new CSS, do not edit `dashboard.css`, do not pull in a CSS
63+
framework. The only inline style you may set is the bar `height:NN%`.
64+
- Keep the markup structure as described so all three render identically.
65+
66+
## Scope rules
67+
68+
- Keep everything in the app's main view/component file(s) for your stack — don't
69+
over-engineer into many files. Idiomatic for your framework, but lean.
70+
- No tests, no README, no comments beyond what's natural. Just the working app.
71+
- When done, the app must build and render the dashboard with all four interactions working.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
2+
<Found Context="routeData">
3+
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
4+
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
5+
</Found>
6+
</Router>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@inherits LayoutComponentBase
2+
@Body

0 commit comments

Comments
 (0)