|
| 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`. |
0 commit comments