The Number Is Not in the File — One File, Two Pictures

5 min
GoalScreen course, part 3. One `{{now}}` splits the screen from the value. Whether it really split is settled by rendering the same file twice under two different states.

Through part 2 a screen shows only what the file says. Build a queue display that way and every new number means sending a new screen file.

Leave the spot open

{ "type": "page", "title": "Step 3",
  "initialState": { "now": "0", "waiting": 0 },
  "content": { "type": "center",
    "child": { "type": "linear", "direction": "vertical", "spacing": 10, "alignment": "center",
      "children": [
        { "type": "text", "text": "NOW SERVING", "style": { "fontSize": 15, "letterSpacing": 5, "color": "#6b7280" } },
        { "type": "text", "text": "{{now}}", "style": { "fontSize": 92, "fontWeight": "bold", "color": "#111827" } },
        { "type": "text", "text": "{{waiting}} waiting", "style": { "fontSize": 20, "color": "#6b7280" } } ] } } }

Two things are new.

initialState — what this screen holds the moment it opens, so nobody stares at an empty slot.

{{now}} — put the state's now here. It can stand alone or sit inside a sentence, as in {{waiting}} waiting.

Putting values in

Pass a key and a value — rt.stateManager.set('now', '42') — and whichever nodes use that key redraw. The host does not know which ones, and does not need to.

Wired to what the client course built, there is nothing to set one at a time.

onToolCall: (tool, params) async {
  // pour the server's map straight in
  final r = await client.callTool(tool, params);
  final state = jsonDecode((r.content.first as TextContent).text)
      as Map<String, dynamic>;
  state.forEach(rt.stateManager.set);
},

Server part 3 made the tool return {"now": ..., "waiting": ...}. When that map's keys match the {{...}} names on the screen, there is no translation code in between.

How to tell a real binding from a decorative one

Write {{now}}, see 42 on screen, call it done — that is the easy mistake. Writing 42 into the file also puts 42 on screen.

So the check goes two ways.

# Fails if the value is baked into the file
grep -qE '"(42|7)"' screens/step3.json && die "step3: the value is baked in"
grep -q '{{now}}' screens/step3.json || die "step3: nothing is bound"

# One file, two states. If the picture does not move, the binding is decoration.
cmp -s captures/step3.png captures/step3_alt.png && \
  die "step3: the same file rendered the same picture for two different states"

The harness renders step3.json twice: once with now: 42, waiting: 3, once with now: 7, waiting: 0. Same file.

step3      587 B, 8 lines, type "page" -> step3.png
step3_alt  587 B, 8 lines, type "page" -> step3_alt.png
step3  one file, two states, two pictures (27577 B vs 24734 B)

The two PNGs differ. There is one file, so what made them differ is the state.

The checks were confirmed to catch things

A suite where everything passes proves nothing. So {{now}} was replaced with 42 and the run repeated.

   [4/8] step3 — the number is not in the file
   step3: the value is baked in

Caught. Parts 4 and 5 were broken the same way, once each, and all three checks fired.

How much belongs in state

NOW SERVING is fixed text. It could have been pulled out as {{label}} and made state.

It was not. State means somebody has to fill it every time. Miss it and the screen is blank there. Text that never changes is one fewer place to fail if it stays in the file.

The rule: if the value is decided outside the screen, it is state; otherwise it is the file.

Run it yourself

cd content/sample/course-ui
bash verify.sh
open captures/step3.png captures/step3_alt.png

What to take away

  1. initialState + {{key}} — leave the spot open and give it a name
  2. stateManager.set — the host only knows a key and a value
  3. Two states, two captures — the picture has to move for it to be a binding

Next

Values came in. Now the way out — what happens when a button is pressed.

Run the sample

cd course-ui
bash verify.sh