Skip to content
C.W.K.
Lesson 02 of 05 · published

Four Pages, One Responsive WebUI

~13 min · react, drafts, localstorage, lightbox, responsive-ui

Level 0Sketch
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Six surfaces on paper, four pages in the build

The architecture document lists the studio's main surfaces as six: Souls, Sets, On Air, Studio, Batches and References. Open the app and you find four pages in the sidebar. The code is the authority here, and the list lives in App.tsx: Soul collections, Create image, Image library, and Batches & review.

The other names did not vanish. They moved inside the four.

Name in the documentWhere it lives in the shipped app
Souls, SetsSoul collections: one soul's everyday and preset sets
On AirA channel toggle inside Soul collections, beside Everyday & presets
ReferencesA soul's reference editor, opened from that soul, plus the ordered reference tray
StudioA segmented control inside Create image: studio, diary, thumbnail, clip, create, regenerate, remix
BatchesBatches & review

One English interface, dark by default, serves a desk monitor, an iPad and a phone. The same built bundle goes into the iOS app, which the next lesson opens up.

Hidden, not unmounted

Three of the pages are rendered all the time and switched with the hidden attribute. Leaving Create image for the library does not throw away the prompt you were halfway through, the subjects you picked or the generators you set up, because the component never unmounted. Batches & review is the exception: it mounts only while it is open. That page polls the engine every 2.5 seconds while any task is queued, preparing, submitting, running or copying, and a hidden page that keeps polling is a quiet cost. The rule underneath is simple. Keep a page alive when it holds the user's unfinished work. Let it go when it only holds a view of the server.

Drafts that survive a restart

Hidden pages survive a page switch. A relaunch needs storage. draft.ts is 18 lines: read and save JSON under an avatars: prefix in localStorage, and swallow every storage error. Its comment states the boundary: device-local drafts complement server-owned jobs and selections, and a quota or privacy setting must never prevent editing or submission.

Three kinds of draft use it. The ordered reference tray is saved as a list of asset ids and reloaded from the engine on start, skipping any that no longer resolve. Each generation form is saved under a key per set, or scene when no set is chosen. And the review step is saved too, which matters more than it looks. The review holds the frozen request, including the request_id minted with crypto.randomUUID() when the review opened. Close the studio on the review screen and the review stays saved under its form's key. A review made without a set is waiting the next time Create image opens. A set's review comes back when that set is opened for generation again, because which set you came from is not saved and a relaunch starts on Soul collections. Either way, pressing submit sends the same id, so the engine answers with the batch it may already have created. The Record Keeper track tells the server side of that promise.

None of this is canonical work. Batches, selections and publications live on the engine, shared by web and phone. A draft is only the unfinished sentence on one device.

The library and the lightbox

Image library searches labels and prompts, filters by soul and by origin, shows archived images on request, and pages 24 at a time with first, last and a page number you can type. A query waits 180 milliseconds after the last keystroke, and a page past the end jumps to the last real page.

Library, collections and batch review all open the same lightbox, built on a native <dialog> opened with showModal(). Escape closes it, the arrow keys move through the gallery, and closing returns focus to the element that opened it. Zoom runs from 1x to 4x in half steps, with a Fit button. A clip plays inline in a <video> with playsInline and a poster frame, and Open in Ashen Reel asks the engine to open the file it holds on the Mac that runs Avatars; the browser names only the asset. The architecture's rule for the viewer is that opening or navigating it never changes a candidate selection or publishes a frame.

Small things a phone notices

  • Safe areas. The page uses viewport-fit=cover, so every top or bottom surface pads itself with env(safe-area-inset-*).
  • Buttons stay on one line. A shared kit stylesheet keeps a control's label from wrapping; prose buttons opt out by name.
  • A failure that names its cause. A fetch that reached nothing says to check Tailscale, unless the page was talking to its own Mac's loopback, where Tailscale is not the problem.
  • One sync event. A studio:sync event reloads souls, sets, generators and config, and the open batch listens too. The iOS host fires it after every successful connection check.
Principle: The device keeps the unfinished sentence. The server keeps everything that has been said.

Code

cwkAvatars, frontend/src/App.tsx: the four pages the app ships (verbatim, lines 37-47)·typescript
type Page = "collections" | "generate" | "library" | "batches";
const pages = [
  { id: "collections" as const, label: "Soul collections", icon: Layers3 },
  { id: "generate" as const, label: "Create image", icon: Sparkles },
  { id: "library" as const, label: "Image library", icon: Images },
  {
    id: "batches" as const,
    label: "Batches & review",
    icon: GalleryVerticalEnd,
  },
];
cwkAvatars, frontend/src/App.tsx: the library stays mounted and hidden, batches mount only when open (verbatim, lines 365-382)·tsx
              <div hidden={page !== "library"}>
                <Library
                  souls={souls}
                  references={references}
                  onReferences={setReferences}
                  error={error}
                  refresh={revision}
                />
              </div>
              {page === "batches" && (
                <Batches
                  sets={sets}
                  selectedId={batch}
                  onReload={() => void reload()}
                  onReference={addReference}
                  error={error}
                />
              )}
cwkAvatars, frontend/src/draft.ts: device-local drafts that a storage failure cannot break (verbatim, lines 1-18)·typescript
// Device-local drafts complement server-owned jobs and selections. A storage
// quota or privacy setting must never prevent editing or submission.
export function readDraft<T>(key: string, fallback: T): T {
  try {
    return (
      JSON.parse(localStorage.getItem(`avatars:${key}`) ?? "null") ?? fallback
    );
  } catch {
    return fallback;
  }
}
export function saveDraft(key: string, value: unknown) {
  try {
    localStorage.setItem(`avatars:${key}`, JSON.stringify(value));
  } catch {
    /* Editing remains available. */
  }
}

External links

Exercise

Take a React app with tabs, one of which holds a long form. Render the form's tab with the hidden attribute instead of conditional rendering, and confirm that switching tabs keeps what you typed. Then add a draft helper that reads and writes JSON under a prefix in localStorage inside try/catch, save the form on every change, and reload the page to see it come back. Finally, add a confirmation step that mints an id with crypto.randomUUID(), save the step together with its id, and show that a reload followed by submit sends the same id.
Hint
Test the failure path too: in the browser console, replace Storage.prototype.setItem with a function that throws, then keep typing. Assigning to localStorage.setItem itself only stores an item named setItem and leaves the method working. Editing and submitting should keep working, with only the draft lost. Put the original method back afterwards.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.