For web developers

You already know most of this. Here is the vocabulary.

RayClay is immediate-mode - your layout function is UI = f(state), called whenever the UI needs to redraw, with the reconciler removed. There is no virtual DOM, no hooks, no cascade. You mutate state directly and describe the whole UI in one pass; nothing diffs, everything lays out.

In React the reconciler is the part you never write and cannot see: you call a setter, a tree is diffed against the last one, and some subset of your components runs again. RayClay deletes that machinery rather than reimplementing it in C. Your layout function declares every element in the frame every time it runs - the sidebar, the list, the row, the label inside the row - and nothing survives between frames except the state struct you own.

Most of the vocabulary survives the translation. A component is an ordinary C function that emits elements; props are its arguments; conditional rendering is an if, re-evaluated on every redraw exactly the way a re-render re-evaluates it; .map() is a for. What does not survive is everything that existed to make the diff cheap or the cascade tractable: hooks, memoisation, selectors, specificity. You will miss almost none of it. Two absences are real and both have a section below - string lifetimes, which is where C genuinely bites, and stable ids, which is your key doing three more jobs than a key does.

One thing does not map at all, and it is the first thing to get straight. You never call the layout function. The runner calls it, and only when it has a reason to.

Nothing redraws for free

RayClay draws when something happened, and otherwise parks.
The default is RC_RENDER_ON_DEMAND: the runner wakes for everything it can see - pointer and key input, resize, focus, a change of device pixel ratio - lays out that frame, and goes back to sleep at roughly zero CPU. That is a native toolkit’s bargain, not a while (true) game loop’s.
State it cannot see has to ask.
A socket, a worker thread, a timer, an animation you drive yourself: RayClay has no idea any of them changed anything. rcAppRequestFrame(app) means redraw now; rcAppRequestFrameAfter(app, 0.1) means redraw in 100 ms. This is the same bargain requestAnimationFrame makes with you in the browser, and it is the one a React developer forgets, because useEffect and a setter were doing the asking.
If every frame genuinely is needed, say so once.
A game or a simulation sets .renderMode = RC_RENDER_CONTINUOUS in RC_AppOptions and draws every vsync. An ordinary app should not: the idle behaviour is where the flat CPU line comes from.

The mapping

What maps to what.

Eight rows cover almost everything you reach for in a day. The term is the thing you already know; the definition is what replaces it, and the part of the replacement that will actually catch you out.

JSX / HTML nesting
Nested rcBox / rcRow / rcColumn braces. It is a C-preprocessor DSL, with no DOM behind it. The brace body is a macro that opens an element and closes it again, so the nesting in your source is the nesting in the layout - and a break inside one is safe, because RayClay closes the element for you.
A component
An ordinary C function. There is no instance and no object - just a function that emits elements. It takes what it needs as arguments, emits as a side effect and returns nothing; there is no lifecycle to hook and nothing to memoise. Call it twice with two different ids and you get two independent subtrees.
Props
Function arguments. C value and pointer lifetime rules apply to them: a scalar is copied, and a const char * is borrowed until the frame is drawn, not copied. That second half is the whole of the gotcha.
useState
A long-lived struct you own. There is no hook storage; you hold the state. You mutate the field directly - no setter, no batching, and no stale closure, because there is no closure.
A controlled input
A pointer to a field in your state: rcTextInput("name", state->name, sizeof state->name). It returns true on a frame the buffer changed, which is where your onChange body goes; the third argument is the buffer’s capacity, which is why the idiom passes sizeof rather than a number.
.map() over a list
An ordinary for loop. Stable ids are still required.
React key
The element or widget id. The id also carries hover, scroll and focus state, so it is doing more work than a key does. That is why it has to be stable across frames: it is the handle interaction state is attached to, not a hint to a diff. Ids and long lists covers generating them.
Tailwind utility classes
Designated option fields with CSS-familiar values. Tailwind-style names, not class syntax. The names came from Tailwind; the semantics did not always follow them across - the boundary is field by field.

Seven more rows exist in the library's own reference, and none of them will surprise you. onClick is rcClicked(id), with the same release edge the DOM has: press it, slide off, release, and it cancels. onMouseDown is rcPressed(id) - eager, uncancellable, one fire per press and no auto-repeat. A useEffect that exists to force a re-render is rcAppRequestFrame(app). Conditional rendering is an if. theme.extend.colors is your own RC_Color constants, one name per shade, in plain C. Inline style={{…}} is the designated option fields .bg, .p, .gap. And a media query is a width test - with one wrinkle about zoom that is covered below.

The complete table is in the library's own reference (opens in a new tab), alongside the per-field CSS map it links to. Every signature either of them names is on the cheatsheet, one line each.

State

There is no useState, because there is no setter.

rcCheckbox("enabled", "Enabled", &state->enabled);
rcSlider("volume",  &state->volume, 0.0f, 1.0f);
rcTextInput("name",  state->name, sizeof state->name);
That &state->field is RayClay’s controlled-component model: you own the storage, RayClay reads and writes it. No useState, no setter, no re-render bookkeeping.

The struct is an ordinary local variable in main. You hand its address to the runner as userData, and the runner hands it straight back to every callback it makes - updateCallback before layout, layoutCallback during it, frameEndCallback after the frame has drawn.

typedef struct {
    bool  shuffle;
    float volume;      /* 0..1 */
    int   selected;    /* index into your row array */
} AppState;

static void layout(RC_App *app, void *user) {
    (void)app;
    AppState *st = (AppState *)user;   /* the same struct, every frame */
    /* ... every widget in the frame reads and writes st ... */
}

int main(void) {
    AppState st = { .volume = 0.6f };
    RC_AppOptions opts = { .title = "Player", .layoutCallback = layout, .userData = &st };
    return rcRunApp(&opts);
}
On the desktop rcRunApp runs until the window closes, so st outlives every frame that reads it. No malloc, no destructor, no provider, no store. On the web it does not return at all: it hands the frame to the browser's own loop, so anything you write after it runs on the desktop only, and teardown belongs in frameEndCallback rather than after the call. Ending on return rcRunApp(&opts);, as here, is right on both.

That is the whole of “how it survives between frames”: it is a local in a function that has not returned yet. RayClay keeps no copy. The only state the library holds on your behalf is the interaction state attached to an element id - what is hovered, what holds keyboard focus, where a scroll container sits, where the caret is in a text field - and even that is keyed by a string you chose.

When two parts of the UI read the same field, nothing happens. There is no context, no provider, no subscription and no invalidation, because there is nothing to invalidate: both call sites read st->selected during the same pass through the same function. The one consequence worth knowing is ordering. Elements are declared in call order, so a widget you emitted at the top of the frame carries the value the field held at the top of the frame; if a click handled further down changes it, that earlier widget is one frame stale. The frame that fixes it is already scheduled, because input is one of the things the runner wakes for.

Each control tells you when it moved. rcCheckbox, rcToggle, rcSlider and rcCombo return true on the frame the value changes; rcTextInput returns true on a frame the buffer changed. That return value is your onChange. If you would rather keep mutations out of the layout pass altogether, updateCallback runs first, with the same pointer.

The gotcha

String and pointer lifetimes: the single place where C bites you.

rcText and rcTextC do not copy the string. The layout engine keeps the pointer you handed it until the frame is drawn, and the frame is drawn some time after your function returned. So the most natural move in JavaScript - build a string, pass it, forget it - is a use-after-scope bug in C:

/* BROKEN: buf dies when the function returns, before the frame draws. */
static void row(int n) {
    char buf[32];
    snprintf(buf, sizeof buf, "%d items", n);
    rcTextC(buf);            /* RayClay retains &buf[0] - dangling by draw time */
}
There is no runtime canary for this. A stack frame nothing has reused yet still holds the digits you wrote, so the usual symptom is text that is correct for a while and then intermittently is not, depending on what the rest of your app did between the call and the draw.

Three patterns are always safe, and between them they cover everything a UI needs to say:

rcTextL("Static label");                            /* a literal - always alive */
rcTextC(state->name);                               /* a buffer YOU own that outlives the frame */
rcText(rcFormat(rcAppArena(app), "%d items", n));   /* the per-frame arena */
rcTextL takes a compile-time literal, rcTextC a const char *, and rcText the RC_String that rcFormat returns. None of the three copies. The arena string stays valid until the arena resets next frame - exactly as long as you need it and not one frame longer.

The arena pattern is the one with a setting behind it. rcAppArena(app) hands back the runner’s per-frame scratch arena, and that needs RC_AppOptions.scratchArenaBytes to be greater than zero. At the default of 0, rcFormat returns a visible marker rather than failing silently, so the first symptom is a marker on screen rather than a crash.

The full rule, once, for every kind of thing you can hand the API:

A string literal - rcTextL, and every id
The binary owns it. Always valid.
A dynamic string - rcText / rcTextC
You own it, and it must outlive the frame. That rules out a function-local buffer.
A scalar - .p, .bg, &state->volume
Copied, or read in place. No lifetime concern.
An rcFormat(...) string
The frame arena owns it. Valid for this frame only; format it again next frame.
A loaded font or image handle
RayClay owns it. Keep the id; the resource is retained for you.
Callback userData
You own it. The pointer you pass is handed back unchanged.

Treat that as the contract, because nothing enforces it at runtime. It is one rule and one habit, and it is the entire price of not shipping a garbage collector with your note-taking app.

The boundary

CSS-familiar and Tailwind-inspired, not Tailwind-compatible.

The names and values will look familiar and that is where the resemblance stops. There is no class syntax, no cascade, no selectors, no hover: variants, no responsive breakpoints and no grid. if (rcIsHovered(id)) is your :hover; if (width < 768) is your media query.

Direct. Four token families - spacing, colour, radius, size - carry most of a UI, and these behave the way the CSS behaves.

padding, gap
.p · .px/.py · .pt/.pb/.pl/.pr · .gap. Px scalars only - no em, no percentages. The three levels resolve the way Tailwind’s do: a side beats an axis, an axis beats the whole box.
color, hex, rgb(), rgba(), colour names
rcColor("#1e293b") reads like the CSS; rcHex(0x1e293b), rcRgb(30, 41, 59) and rcAlpha(c, 200) are the constant forms. Hex in 3, 4, 6 or 8 digits, rgb(), rgba(), 20 names and transparent - no hsl(), and not the full 148-name set. rcColor re-parses its string on every frame it runs; the other three do no parsing.
The palette, and theme.extend.colors
RC_SLATE_800 and friends: 22 of Tailwind’s 26 families across 11 shades, carrying Tailwind v3’s sRGB values. They will not byte-match today’s Tailwind, which re-based the palette onto oklch in v4 - that is a pinned-provenance decision, and it is most visible on a wide-gamut display. Your own tokens are one #define BRAND_500 rcRgb(99, 102, 241) per shade: the same shape as theme.extend.colors, with no registry and no build step.
border-radius
.borderRadius, a "{side}-{size}" string. Side is all or empty, t/b/l/r, tl/tr/bl/br; size is a number, Npx, or xs/sm/md/lg/xl/2xl/full. An empty side means all four, which is the Tailwind shape - "-md" is "all-md" - and a bare number means all four too. A bare keyword does not: "md" warns, and what you wanted was "-md".
width, height
.w and .h take CSS-like strings: "grow", "fit", "200px", "50%", "50vw". The typed constants - RC_PX, RC_PCT, RC_VW, RC_VH, RC_GROW, RC_FIT - go on .wType/.hType and are for a size you compute every frame; for a static literal the two forms measured 1:1. One deviation: a percentage must be 0 to 100 and is not clamped, so "150%" warns once and falls back to fit, exactly as a bad unit does.

Near, or different enough to catch you. The field exists and does roughly the job the CSS property does. The gap between “roughly” and “exactly” is where the afternoon goes.

justify-content, align-items
The two letters of .align, vertical then horizontal: "cc" centres both ways, "tr" pins children top-right. The main-axis letter is the second in a row and the first in a column, so the two letters swap roles by direction - and "cc" reads correctly either way, which is why the swap bites late. Start, centre and end only: there is no space-between, space-around or space-evenly.
What a percentage is a percentage of
Parent-relative, like CSS, with three differences that each produce a correct-looking layout you did not ask for. On the main axis a percentage is of what is left after the gaps are reserved, so two 50% siblings with a gap fit the row instead of overflowing it. On the cross axis gaps are not involved. And a percentage inside a shrink-to-fit parent resolves to 0 with nothing logged - the box is not there at all, which looks exactly like a mistake in your own code. That includes the default, because .w unset is fit. If a box vanishes, check the parent first. vw and vh resolve against the window and sidestep the whole question at any depth.
margin
No field. rcMargin is a fit-sized spacer element and rcSeparator a stretchy one, so you space siblings with a box rather than with a property.
border, box-shadow, linear-gradient(), background-image
.border is all-sides only in the rc spelling; per-side widths, and a rule drawn between children, are reachable on the defaults declaration described below. .shadow is a single layer with a linear rather than Gaussian falloff. .gradient is two stops and a direction of v, h, d or u, and it replaces the flat fill rather than layering over it. Shadows and gradients each need the element’s .id and cap at 64 a frame. Past either cap the element still draws, wrong and quietly: a gradient falls back to the flat .bg, a shadow is simply not drawn, and the warning fires once per process rather than once per frame, so a grid of eighty styled cards logs on the first frame and then renders sixteen of them wrong for the rest of the run. .image always stretches to the element box: no repeat, no position, no size. And .bg on the same element is not just painted behind it: the colour is multiplied into the picture as a tint, so the CSS habit of giving a background-image element a placeholder colour will recolour it. Leave .bg unset for an untinted image.
overflow
One thing does not map: the scrollbar. In CSS overflow: scroll draws one. Here the container clips and scrolls, and draws nothing until you call rcScrollbar(id) from inside your layout callback - and in v0.8 that bar is vertical only, so horizontal overflow scrolls with no bar at all and nothing warns you. Everything else does map: .overflow takes "visible" by default, then "hidden"/"clip" and "scroll"/"auto". Per-axis scrolling is .scroll - "v", "h", "b" - and .scroll wins if you set both. There are 100 clip slots a frame, taken once per element that ends up clipping rather than once per field: set .scroll and .overflow on the same element and it costs one, and .overflow is ignored outright when .scroll is set. The default "visible" costs nothing. Past the hundredth, an element still clips but loses its scroll offset: it reads zero and reports itself as not a scroll container.
position: absolute / fixed, z-index
.floating, which is anchor-based rather than coordinate-based: you pin an anchor on the target to an anchor on yourself and then nudge with .offset. There are no top/left offsets from the viewport. z-index exists only on a floating element, as RC_Float.zIndex; in normal flow, order is declaration order.
white-space, text-align
RC_TextOptions.wrap and .textAlign, and this is the row where CSS habits mislead. "n" and "l" suppress the break the layout would invent, never the break you typed, where white-space: nowrap does both. Measured in both directions: in a box too narrow to hold it, "alpha bravo charlie" is three lines by default and one line at "n", while "alpha\nbravo\ncharlie" is three lines under every value, "n" included. If you want one line, put one line in the string. .textAlign (l/c/r) is for multi-line blocks; a single line centres via the parent’s .align.
:hover, :active
Poll and branch: if (rcIsHovered(id)), if (rcClicked(id)). The theme ships a hover token beside each of its four accents - primary, danger, success, warning - for exactly that ternary.
A media query
A width test in C - but not on rcGetWindowDimensions() alone. Zoom is on by default, and the default mode reflows the UI by laying it out into window / zoom, so a 1400px window at 200% is really being given 700 logical px - tablet width, where a sidebar should collapse - while the window still reports 1400 and your breakpoint never fires. float vw = rcGetWindowDimensions().width / rcAppZoom(app); is the equivalent of 100vw. Sizing needs none of this: "50vw", "100%" and "grow" are resolved inside the layout pass and are already correct.
background on html or body
RC_AppOptions.clearColor at create, rcAppSetClearColor(app, c) live. It is the window surface behind your whole UI, a different thing from .bg, and no element field reaches it. Always opaque, and a snapshot rather than a live link - so a dark/light toggle has to call the setter as well as rcSetStyle, or the old background stays wherever your layout does not cover.

Absent. Absent today, by design or by schedule. Each has a way round it and none of them has a field.

transition, @keyframes
Poll state and set the values yourself. Your editor will offer you .transition on RC_ElementDeclaration: it is visible and unsupported. Under the default on-demand runner it freezes mid-curve, because a transition only advances on a frame that actually lays out and nothing is asking for the next one.
opacity on an element or subtree
There is no true opacity. .overlay tints a whole subtree by compositing a colour on top of it, which covers a dim or a scrim, and it is square-cornered. For a single colour, use 8-digit hex or rcAlpha.
text-overflow: ellipsis
Clip with .overflow = "hidden", or shorten the string yourself.

Present, but not through a RayClay field. These two look absent if you go looking for .minWidth or .aspectRatio beside .p and .gap, because RayClay does not define them. They belong to the layout engine underneath, and rcBeginComponent(options, defaults) hands that engine's own element declaration straight through - RC_ElementDeclaration is a typedef of it. Anything the engine can express, you can set there, with one precondition and one caveat. The precondition: leave the axis unset in the rc spelling. Writing .w or .h replaces that whole sizing axis and zeroes the bounds you set, with no diagnostic. The caveat: this is the raw layout layer, deliberately, so it is not covered by the string DSL's validation and a future RayClay spelling may supersede it.

min-width, max-width on an element
Both bounds are one field: the sizing helpers take them as a pair, so CLAY_SIZING_GROW(120, 480) is "grow, but never below 120 and never past 480", and CLAY_SIZING_FIT(0, 320) caps a fit. Do not confuse this with RC_AppOptions.minWidth and minHeight, which are the window minimum and reach no element.
aspect-ratio
An aspectRatio field on the element declaration, taking final width divided by final height - the same number CSS takes. You do not have to compute the dependent axis yourself.

Add to those the absences this section opened with - class syntax, the cascade, selectors and specificity, hover:-style state variants, responsive breakpoints, grid - plus container queries, flex-wrap and logical properties. When you reach for one of them, the answer is almost always an ordinary if in C: less elegant to look at, and considerably easier to debug at two in the morning.

Lists

Ids are keys, and long lists want virtualising.

Every interactive element needs an id, and - exactly like a React key - it has to be stable across frames, because the id is what carries hover, scroll, focus and selection. Give each row an id backed by memory that lives as long as the row does. For a small fixed list that is a table of literals, and there is no key={i} shorthand yet:

static const char *ROW_IDS[] = { "row0", "row1", "row2", "row3" };

for (int i = 0; i < n; i++)
    rcRow(.id = ROW_IDS[i]) { /* ... */ }
A scratch buffer rebuilt every frame still hashes correctly, so layout and clicks work either way. The difference shows up in the debug inspector, which keeps the id string: scratch ids arrive there garbled.

Ids are 32-bit hashes of the string, so two siblings can in principle land on the same one - and then they are one element as far as every rcIsHovered, rcClicked and scroll lookup after them is concerned. It is not silent: the warning names the exact id. How many siblings it takes depends on the prefix you happened to pick, which is not something you can reason about from your own code. Measured: "item%d" is clean at 15,000 siblings and collides at 20,000, while "Row %d" and "e%d" stay clean through 65,536. Getting there at all takes a non-virtualised list of 15,000-plus siblings and a deliberately raised RC_AppOptions.startLayoutElements, since the arena starts at 2,048.

The real reason to virtualise arrives long before any of that. Immediate mode rebuilds every element every frame, and layout charges per declared element rather than per visible one - roughly 1,470 instructions each, so multiply by however many elements one of your rows declares. A 5,000-row list lays out 5,000 rows to show fifteen of them, and culling cannot rescue you: an element has to be sized and positioned before anything knows it is offscreen, which is why culling happens at draw time, too late to matter. One such list costs several times an entire 240-widget screen. It is a memory lever too - one full-list frame permanently ratchets the element arena up to 10.97 MiB at 5,000 rows, where the virtualised list holds the 1.43 MiB floor.

The fix is the one you already know from react-window, and RayClay ships it as a loop macro. rcVirtualList works out the visible window from last frame’s scroll position, adds overscan rows so a fast fling has no gap, and emits the two spacer boxes that hold the total content height constant - so the scrollbar and the scroll position behave exactly as if every row were there.

enum { ROW_COUNT = 5000, ROW_H = 28 };

typedef struct {
    const char *labels[ROW_COUNT];   /* your row data (must outlive the frame) */
    int         selected;
} ListState;

static void virtual_list(ListState *st) {
    rcColumn(.id = "list", .w = "grow", .h = "grow", .scroll = "v") {
        rcVirtualList(row, "list", ROW_COUNT, ROW_H) {
            char id[16];
            snprintf(id, sizeof id, "row%d", row.index);   /* key by the DATA index */
            rcRow(.id = id, .w = "grow", .hType = RC_PX(ROW_H), .p = 6,
                  .bg = (row.index == st->selected) ? rcGetStyle().primary
                                                    : rcGetStyle().surface) {
                rcTextC(st->labels[row.index]);
            }
            if (rcClicked(id)) st->selected = row.index;
        }
    }
    rcScrollbar("list");
}
No first/last arithmetic, no spacer bookkeeping, and no table of pre-baked ids: char id[16] is fine here because the id is hashed as the element opens and never has to outlive the frame. The label does, which is why it lives in st. snprintf needs <stdio.h>; rayclay.h does not include it for you.

Three rules, and one way to lose the last spacer

The id must name the enclosing scroll container.
The macro sits directly inside that container, and reads its scroll position back by that id.
Every row must really be rowHeight tall.
The spacers are computed from that number, so a wrong pitch skews the scrollbar. Uniform rows only: a variable-height list still needs per-row measured offsets, which RayClay has no built-in for yet.
Key each row by its data index, never by its position in the window.
Keying by position retires and rebuilds the whole hashmap working set on every scroll step - it gives back most of the win and makes hover and selection jump between rows as you scroll.
Do not break out of the loop body.
The trailing spacer is emitted by the loop’s final step, so breaking skips it and the content ends up short by the rows you never declared. continue is fine. This is a different hazard from a break inside an rcBox body, which RayClay closes for you.

One shape to avoid: a scroll container inside another scroll container. Nesting overflow-y: auto is unremarkable on the web, so this is the trap most likely to find you here. A scrolling parent is unbounded along its scroll axis, so the "grow" above resolves against its own content instead - and the spacers are that content, which is what the helper reads back as a viewport. Give the inner container a real height whenever something above it scrolls. RayClay clamps the sampled viewport to the layout and logs one line naming the list, so the symptom is a stuck screenful and a diagnostic rather than a hang.

Putting it together

A small app, start to finish.

Everything above, in one program: your own colour tokens, an AppState struct, a reusable component function, a list with stable ids, two controlled widgets and a conditional block. It compiles as it stands on desktop and on the web, and it loads no asset files.

#include "rayclay.h"

/* Project colour tokens - theme.extend.colors, RayClay-style. In C use
   #define rather than `static const RC_Color`: rcRgb expands to a compound
   literal, which is not a constant expression, so a file-scope static
   initialiser is rejected under -std=c99 -pedantic-errors. */
#define BRAND   rcRgb(99, 102, 241)
#define SURFACE rcRgb(30, 41, 59)

typedef struct {
    bool  shuffle;
    float volume;                 /* 0..1 */
    int   selected;               /* index into TRACKS[] */
} AppState;

static const char *TRACKS[]    = { "Intro", "Nightfall", "Signals", "Afterglow" };
static const char *TRACK_IDS[] = { "t0", "t1", "t2", "t3" };
enum { TRACK_COUNT = 4 };

/* A reusable component: one list row, highlighted when selected. */
static void track_row(AppState *st, int i) {
    RC_Style s = rcGetStyle();
    bool active = (st->selected == i);
    rcRow(.id = TRACK_IDS[i], .w = "grow", .p = 10,
          .bg = active ? BRAND : SURFACE, .borderRadius = "-md") {
        rcTextC(TRACKS[i], .color = active ? RC_WHITE : s.text);   /* runtime string */
    }
    if (rcClicked(TRACK_IDS[i]))
        st->selected = i;
}

static void layout(RC_App *app, void *user) {
    (void)app;
    AppState *st = (AppState *)user;
    RC_Style s = rcGetStyle();

    rcColumn(.w = "grow", .h = "grow", .bg = s.background, .p = 20, .gap = 12) {
        rcTextL("Now playing", .color = s.text, .size = 22);

        for (int i = 0; i < TRACK_COUNT; i++)             /* a list, stable ids */
            track_row(st, i);

        rcCheckbox("shuffle", "Shuffle", &st->shuffle);   /* controlled widgets */
        rcSlider("vol", &st->volume, 0.0f, 1.0f);

        if (st->shuffle)                                  /* conditional block */
            rcTextL("Shuffle is on", .color = BRAND);
    }
}

int main(void) {
    AppState st = { .volume = 0.6f };
    RC_AppOptions opts = { .title = "Player", .layoutCallback = layout, .userData = &st };
    return rcRunApp(&opts);
}
Read it in the order the runner does. main builds the state and hands it over; layout runs once per frame and declares the whole window; track_row is called four times and emits four subtrees that differ only by their id, their colour and the string inside them. rcTextL takes the literals, rcTextC takes the array element - and TRACKS is static, so it outlives the frame. One field, both platforms: .title names the OS window on the desktop and the browser tab on the web. Leave it empty and the page keeps whatever name its own shell gives it, which is deliberate rather than a gap.

Notice what is not there: no malloc, no destructor, no re-render bookkeeping. The state lives on the stack in main; every widget reads and writes it directly; the whole UI is rebuilt each frame from that one struct. That is the entire RayClay model.

Build a window in two lines Every function, one line each