# Fieldwork — capturing from the device

> A complete example app — several screens showing how the pieces fit together.

Every capture has three states, not two: empty, captured, and unavailable on this device — and the third is the one a phone-only developer never sees.

- Framework: CrossFly (Java; one source for Android, iOS, desktop (Windows, macOS and Linux) and the web — Chrome, Firefox, Safari and other browsers)
- Entry point: `dev.cf.app.capture.CaptureApp`
- Topics: device
- Run it locally: `gradlew lwjgl3:run --args="classname dev.cf.app.capture.CaptureApp"`
- Run it in a browser: https://crossfly.dev/playground/index.html?ex=app-capture
- Page: https://crossfly.dev/examples/app-capture

## What this shows

**Fieldwork** — the field-capture shape: a checklist of device captures, every one of which
can fail.

```
gradlew lwjgl3:run --args="classname dev.cf.app.capture.CaptureApp"
```

## What is actually hard about this shape

Nothing here is hard to *draw*. Four rows and a button is an afternoon. What is hard is
that each of the four is a request to hardware that can be refused, absent, or simply broken —
and the interface has to stay honest about it while somebody stands in a yard holding a phone.
The whole example is organised around that.

### 1 · Every capture has three states, not two

Empty, captured, and **unavailable on this device**. The third is the one that gets
forgotten, and forgetting it produces the worst version of this screen: a button that does
nothing when tapped, on a device with no such hardware, with no explanation. Here
`hasCamera()` and `hasLocation()` decide up front — by asking the capability, never
by naming platforms — and an unavailable capture says why instead of pretending.

### 2 · The pickers are windows, and they own the camera

`BarcodePicker`, `PhotoPicker` and `LocationPicker` are Windows layered over
a native view. They must be opened and closed, never embedded in a scrolling body: a native
camera surface sits on top of the GL canvas rather than inside it, so it does not scroll, does
not clip, and does not take part in a transition. That is also why a picker that hosts one turns
transitions off — an animated fade over a native view shows a black rectangle for its whole
duration.

### 3 · Finishing states what is missing, and lets you finish anyway

Field work is not tidy: sometimes there is genuinely no signature because nobody was there.
The screen does not block on it — it records what is missing, so the debt is visible later
instead of being invented later. Blocking would only teach people to capture a scribble.

## Framework types it uses

`Alert`, `BarcodePicker`, `BottomSheet`, `Button`, `Capability`, `Card`, `Component`, `DrawingBoard`, `Font`, `Gap`, `GridLayout`, `Hint`, `Label`, `LatLon`, `LocationPicker`, `MaterialIcon`, `PalColor`, `PhotoPicker`, `PlatformUtils`, `Relative`, `Screen`, `Styles`, `VerticalLayout`, `Widgets`, `Window`

## Source

### dev/cf/app/capture/CaptureApp.java

```java
package dev.cf.app.capture;

import dev.cf.Described;
import dev.cf.Tones;
import dev.cf.app.AppBar;
import dev.cf.app.AppTheme;

import dev.crossfly.Capability;
import dev.crossfly.PlatformUtils;
import dev.crossfly.map.LatLon;
import dev.crossfly.ui.*;
import dev.crossfly.ui.color.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.picker.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * <b>Fieldwork</b> — the field-capture shape: a checklist of device captures, every one of which
 * can fail.
 *
 * <pre>
 * gradlew lwjgl3:run --args="classname dev.cf.app.capture.CaptureApp"
 * </pre>
 *
 * <h2>What is actually hard about this shape</h2>
 *
 * <p>Nothing here is hard to <i>draw</i>. Four rows and a button is an afternoon. What is hard is
 * that each of the four is a request to hardware that can be refused, absent, or simply broken —
 * and the interface has to stay honest about it while somebody stands in a yard holding a phone.
 * The whole example is organised around that.</p>
 *
 * <h3>1 · Every capture has three states, not two</h3>
 *
 * <p>Empty, captured, and <b>unavailable on this device</b>. The third is the one that gets
 * forgotten, and forgetting it produces the worst version of this screen: a button that does
 * nothing when tapped, on a device with no such hardware, with no explanation. Here
 * {@link #hasCamera()} and {@link #hasLocation()} decide up front — by asking the capability, never
 * by naming platforms — and an unavailable capture says why instead of pretending.</p>
 *
 * <h3>2 · The pickers are windows, and they own the camera</h3>
 *
 * <p>{@link BarcodePicker}, {@link PhotoPicker} and {@link LocationPicker} are Windows layered over
 * a native view. They must be opened and closed, never embedded in a scrolling body: a native
 * camera surface sits on top of the GL canvas rather than inside it, so it does not scroll, does
 * not clip, and does not take part in a transition. That is also why a picker that hosts one turns
 * transitions off — an animated fade over a native view shows a black rectangle for its whole
 * duration.</p>
 *
 * <h3>3 · Finishing states what is missing, and lets you finish anyway</h3>
 *
 * <p>Field work is not tidy: sometimes there is genuinely no signature because nobody was there.
 * The screen does not block on it — it records what is missing, so the debt is visible later
 * instead of being invented later. Blocking would only teach people to capture a scribble.</p>
 */
public class CaptureApp extends Window implements Described
{
   /** The four captures, in the order the visit does them. */
   private static final int ROW_LOCATION = 0, ROW_BARCODE = 1, ROW_PHOTO = 2, ROW_SIGNATURE = 3;

   private static final String[] ROW_TITLE = { "Location", "Asset code", "Photo", "Signature" };
   private static final String[] ROW_HINT = { "Where the visit happened", "Scan the asset label",
      "One photo of the installation", "Signed off by whoever received it" };
   private static final int[] ROW_ICON = { 0xE55F, 0xE905, 0xE412, 0xE745 };

   /** What each row captured, or null while it is still empty. */
   private final String[] captured = new String[4];

   private final Component[] rows = new Component[4];
   private final Label[] status = new Label[4];

   private Label summary;
   private LatLon where;

   @Override
   public String title()
   {
      return "Fieldwork — capturing from the device";
   }

   @Override
   public String note()
   {
      return "Every capture has three states, not two: empty, captured, and unavailable on this"
         + " device — and the third is the one a phone-only developer never sees.";
   }

   /** Hands the palette back to whatever was in force before this app opened. */
   @Override
   public void onRemove()
   {
      super.onRemove();
      AppTheme.leave();
   }

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      if (!firstTime)
         return;
      // The brand FIRST: fullscreenColor below reads a token, and reading it before the palette
      // is declared bakes in the skin's neutral grey for the life of the window.
      AppTheme.enter();
      // The PAGE, not the surface. Cards, buttons and fields are all drawn on the surface, so a
      // page painted the same colour makes every one of their edges disappear. Read through
      // onRestyle so a light/dark flip re-derives it instead of keeping the old mode's value.
      fullscreenColor = AppTheme.pageColor();
      onRestyle(() -> fullscreenColor = AppTheme.pageColor());
      setLayout(new VerticalLayout(Position.START, Position.START));

      add(0f, new AppBar("Visit 4812", isRoot() ? null : this::hide));

      Component body = new Component();
      body.setLayout(new VerticalLayout(Position.START, Position.START));
      body.setScrollable(ScrollDirection.VERTICAL);
      body.getStyle()
         .ingap(Gap.ofFont(1f, 1f, 1f, 1f));
      body.testTag("capture.body");
      add(LAY_FILL, body);

      body.add(LAY_AUTO, outgap(label("Northgate Industrial · Bay 3", 1.5f, true, Styles.textColor,
         Position.START), 0, 0, 0, .2f));
      body.add(LAY_AUTO, outgap(label("Capture what you can. Anything missing is recorded as"
         + " missing.", 1f, false, Styles.captionColor, Position.START), 0, 0, 0, 1.2f));

      for (int i = 0; i < rows.length; i++)
      {
         rows[i] = captureRow(i);
         body.add(LAY_AUTO, outgap(rows[i], 0, 0, 0, .6f));
      }

      summary = label("", 1f, false, Styles.captionColor, Position.START);
      summary.testTag("capture.summary");
      body.add(LAY_AUTO, outgap(summary, 0, .8f, 0, .6f));

      Button finish = Tones.action("Finish visit");
      finish.testTag("capture.finish");
      finish.onAction(c -> finish());
      body.add(LAY_AUTO, finish);

      updateSummary();
   }

   /**
    * One capture row: glyph, title, current state, and the button that starts the capture.
    *
    * <p>Built once and updated in place rather than rebuilt — a capture is the one interaction here
    * where the user is watching the exact row they just acted on, and rebuilding the list under
    * them makes the screen flinch.</p>
    */
   private Component captureRow(final int i) throws Exception
   {
      Card card = new Card();
      card.setLayout(new GridLayout(Position.START, Position.CENTER));
      card.testTag("capture.row." + i);

      Component text = new Component();
      text.setLayout(new VerticalLayout(Position.START, Position.START));
      text.add(LAY_AUTO, label(ROW_TITLE[i], 1.2f, true, Styles.textColor, Position.START));
      status[i] = label(ROW_HINT[i], 1f, false, Styles.captionColor, Position.START);
      status[i].testTag("capture.status." + i);
      text.add(LAY_AUTO, status[i]);

      String blocked = unavailable(i);
      // The whole point of the screen is that "unavailable" is a THIRD state, so it gets a tone of
      // its own. Written in the same grey as the hint it replaces, it was a fourth kind of caption.
      PalColor tom = blocked == null ? Tones.accent() : Tones.danger();
      Button go = new Button(MaterialIcon.of(blocked == null ? ROW_ICON[i] : 0xE002, 1.4f));
      go.getStyle()
         .iconColor(tom);
      go.testTag("capture.go." + i);
      go.setEnabled(blocked == null);
      go.onAction(c -> capture(i));

      if (blocked != null)
      {
         // Not a silent disable: a dead button with no explanation reads as a bug, and the person
         // holding the phone has no way to tell the difference.
         status[i].setText(blocked);
         status[i].getStyle()
            .textColor(Tones.danger())
            .font(Font.get(1f, BOLD));
         status[i].setValidLayout(false);
      }

      card.add(0f, 1f, 0f, Tones.tile(ROW_ICON[i], tom), outgap(text, .8f, 0, .8f, 0), go);
      return card;
   }

   /**
    * Why this capture cannot run here, or null when it can.
    *
    * @param i the row
    * @return the message to show, or null
    */
   private String unavailable(int i)
   {
      if ((i == ROW_BARCODE || i == ROW_PHOTO) && !hasCamera())
         return "No camera on this device.";
      if (i == ROW_LOCATION && !hasLocation())
         return "No location service on this device.";
      return null;
   }

   /**
    * Whether this device can drive a camera.
    * <p>
    * This read {@code Device.platform == ANDROID || == IPHONE} until the capability contract
    * existed, and a list of platform names is wrong in every direction at once. It was wrong about
    * what it excluded — the desktop has had a webcam backend all along, and the browser gets the
    * camera through {@code getUserMedia}, so this screen dimmed two rows that worked. It was wrong
    * about what it included — an iPad reports {@code IPAD}, not {@code IPHONE}, so the one device
    * most likely to be held in a yard got the "no camera" message. And it goes stale on its own:
    * adding a platform silently subtracts from every list like it, which is the failure mode that
    * needs no commit to appear.
    * <p>
    * An example that models honest degradation cannot be the thing teaching the dishonest check.
    * {@code R21Device} carries the same correction; this screen is where the pattern gets used in
    * anger.
    */
   private static boolean hasCamera()
   {
      return PlatformUtils.isSupported(Capability.CAMERA);
   }

   /** Same question for GPS. Kept separate: a device may have one and not the other. */
   private static boolean hasLocation()
   {
      return PlatformUtils.isSupported(Capability.LOCATION);
   }

   /** Starts one capture. Each branch opens a Window and comes back through a callback. */
   private void capture(int i)
   {
      switch (i)
      {
         case ROW_LOCATION:
            // minPdop 50, draggable pin, no high precision, no street view — the ordinary case.
            new LocationPicker(latlon ->
            {
               where = latlon;
               set(ROW_LOCATION, latlon == null ? null : latlon.toString());
            }, 50.0, true, false, false).show();
            break;

         case ROW_BARCODE:
            new BarcodePicker("Point at the asset label")
            {
               protected void onBarcode(com.google.zxing.BarcodeFormat format, String code)
               {
                  set(ROW_BARCODE, code);
                  hide();
               }
            }.show();
            break;

         case ROW_PHOTO:
            new PhotoPicker(PhotoPicker.Preview.PHOTO)
               .show(answer -> set(ROW_PHOTO, answer == null ? null : "photo captured"));
            break;

         case ROW_SIGNATURE:
            new SignatureSheet(() -> set(ROW_SIGNATURE, "signed")).show();
            break;

         default:
            break;
      }
   }

   /** Records one capture and updates just that row. */
   private void set(int i, String value)
   {
      captured[i] = value;
      status[i].setText(captured[i] == null ? ROW_HINT[i] : captured[i]);
      status[i].setValidLayout(false);
      updateSummary();
      Screen.repaint();
   }

   private void updateSummary()
   {
      int done = 0;
      for (String s : captured)
         if (s != null)
            done++;
      summary.setText(done + " of " + captured.length + " captured");
      summary.setValidLayout(false);
   }

   /**
    * Ends the visit, naming what is missing.
    *
    * <p>The message lists the gaps rather than counting them. "2 items missing" sends the user back
    * to look; "Missing: photo, signature" tells them what to decide about.</p>
    */
   private void finish()
   {
      StringBuilder missing = new StringBuilder();
      for (int i = 0; i < captured.length; i++)
         if (captured[i] == null)
            missing.append(missing.length() == 0 ? "" : ", ")
               .append(ROW_TITLE[i].toLowerCase());

      if (missing.length() == 0)
      {
         Hint.show("Visit closed — everything captured", 2500);
         return;
      }
      Alert a = new Alert("Finish with gaps?", "Missing: " + missing + ".\nThey will be recorded"
         + " as missing, not as done.", "Go back", "Finish anyway");
      a.buttonAction(1, c -> Hint.show("Visit closed with " + missing + " missing", 3000));
      a.show();
   }

   /** Where the visit happened, once captured — kept so a later screen could plot it. */
   public LatLon location()
   {
      return where;
   }
}
```

### dev/cf/app/capture/SignatureSheet.java

```java
package dev.cf.app.capture;

import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * Capturing a signature: a {@link DrawingBoard} in a sheet, with a clear and a confirm.
 *
 * <p><b>Why a sheet and not a screen.</b> Signing is a step inside the visit, not a departure from
 * it. A sheet keeps the checklist visible behind it, so the person signing can see what they are
 * signing for, and dismissing it is a gesture rather than a navigation.</p>
 *
 * <p>Three details are what separate a signature pad that works in the field from one that does
 * not, and none of them is about drawing:</p>
 *
 * <ul>
 *   <li><b>The board gets a white background of its own</b>, not the theme's surface. A signature
 *       is captured to be printed and attached to a document; capturing white-on-navy because the
 *       user happened to have dark mode on produces an unusable image.</li>
 *   <li><b>Confirm is disabled until something is drawn.</b> An empty signature is the most common
 *       thing to capture by accident, and it is indistinguishable from a real one downstream.</li>
 *   <li><b>Clear asks nothing.</b> It is instantly and obviously reversible — you sign again — so a
 *       confirmation dialog here is friction with no payoff.</li>
 * </ul>
 */
public class SignatureSheet extends BottomSheet
{
   private final Runnable onSigned;

   private DrawingBoard board;
   private Button confirm;
   private boolean drawn;

   /**
    * @param onSigned runs when the user confirms a non-empty signature
    */
   public SignatureSheet(Runnable onSigned)
   {
      super("SIGNATURE", "Sign below");
      this.onSigned = onSigned;
      heightRatio(.7f);
   }

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      super.onAdd(firstTime);
      if (!firstTime)
         return;

      Component b = body();
      b.setLayout(new VerticalLayout(Position.START, Position.START));

      // The pad: white, framed, and tall enough that a signature is not cramped into a strip.
      Component pad = new Component();
      pad.setLayout(new VerticalLayout(Position.START, Position.START));
      pad.getStyle()
         .backColor(0xFFFFFFFF)
         .borderColor(Styles.borderColor)
         .borderThick(Styles.borderThick)
         .borderRadius(Styles.borderRadius)
         .autoHeight(Relative.ofFont(11f));
      pad.testTag("sign.pad");

      // Anonymous subclass rather than a listener: the DrawingBoard reports strokes by letting you
      // override the touch handlers, which is how most CrossFly components are extended. There is
      // no callback to look for, and adding one to the framework for this would be the wrong fix.
      board = new DrawingBoard()
      {
         @Override
         public void onTouchUp(float x, float y, int button) throws Exception
         {
            super.onTouchUp(x, y, button);
            setDrawn(true);
         }
      };
      board.setPenColor(0xFF111111);
      pad.add(LAY_FILL, board);
      b.add(LAY_AUTO, outgap(pad, 0, 0, 0, .6f));

      b.add(LAY_AUTO, outgap(label("Sign with your finger or a stylus.", 1f, false,
         Styles.captionColor, Position.START), 0, 0, 0, .8f));

      Component row = new Component();
      row.setLayout(new GridLayout(Position.START, Position.CENTER));

      Button clear = new Button("Clear");
      clear.testTag("sign.clear");
      clear.onAction(c ->
      {
         board.clear();
         setDrawn(false);
         Screen.repaint();
      });

      confirm = new Button("Confirm");
      confirm.testTag("sign.confirm");
      confirm.setEnabled(false);
      confirm.onAction(c ->
      {
         onSigned.run();
         hide();
      });

      row.add(.5f, .5f, clear, confirm);
      b.add(LAY_AUTO, row);
   }

   private void setDrawn(boolean b)
   {
      if (drawn == b)
         return;
      drawn = b;
      confirm.setEnabled(b);
   }
}
```

## Shared scaffolding

Not part of this example, but read by it: the app-level classes the four example apps share.

### dev/cf/app/AppBar.java

```java
package dev.cf.app;

import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * The top bar the four example apps share: back, title, and up to a couple of actions.
 *
 * <p>There is no top bar in CrossFly, and that is not an omission. A title bar is the single most
 * app-specific piece of chrome there is — height, colour, whether it collapses, whether the title
 * centres, what sits on the right — and a framework that decided those would be fought by every
 * app that used it. What the framework supplies is what a bar is <i>made of</i>: a container, a
 * layout, buttons, glyphs, and a style that follows the skin.</p>
 *
 * <p>So this is an <b>app-level</b> class, shared by the four examples here because they are four
 * examples in one repository — in a real project it would live in your own {@code ui} package. It
 * is about forty lines, which is roughly what a top bar costs, and it is worth reading as the
 * answer to "how do I compose chrome out of primitives".</p>
 *
 * <pre>
 * add(0f, new AppBar("Part", this::hide).action(0xE3C9, this::edit));
 * </pre>
 */
public class AppBar extends Component
{
   private final String title;
   private final Runnable onBack;

   private final int[] icons = new int[3];
   private final Runnable[] actions = new Runnable[3];
   private final String[] tags = new String[3];
   private int count;

   /**
    * @param title  the heading
    * @param onBack what the back arrow does, or null for a bar with no back arrow
    */
   public AppBar(String title, Runnable onBack)
   {
      this.title = title;
      this.onBack = onBack;
   }

   /**
    * Adds a right-hand action. Up to three; past that the answer is a menu, not a fourth glyph.
    *
    * @param codepoint the Material Icons codepoint
    * @param tag       the test tag for the button
    * @param action    what it does
    * @return this bar, for chaining
    */
   public AppBar action(int codepoint, String tag, Runnable action)
   {
      if (count < icons.length)
      {
         icons[count] = codepoint;
         tags[count] = tag;
         actions[count] = action;
         count++;
      }
      return this;
   }

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      if (!firstTime)
         return;
      // A strip of surface with a hairline of elevation under it. Both colours are theme tokens, so
      // the bar is right in dark mode and under every skin without a branch anywhere — and the
      // shadow is expressed as a ratio of the font height, so it survives a high-DPI screen, where
      // a shadow measured in pixels all but disappears.
      getStyle().backColor(Styles.backColor)
         .shadow(0, .06f, .25f, Styles.borderColor)
         .ingap(Gap.ofFont(.5f, .4f, .5f, .4f));

      setLayout(new GridLayout(Position.START, Position.CENTER));

      // A bar with no back arrow gets no COLUMN for one. The obvious shortcut — always build the
      // column and fill it with a bare `new Component()` — silently kills the entire row: an empty
      // container has no layout, an auto-width column cannot measure it, and the bar comes out
      // zero-high with the title inside it. It looks like the title was never added.
      boolean hasBack = onBack != null;
      int columns = (hasBack ? 1 : 0) + 1 + count;
      Object[] cells = new Object[columns * 2];

      int k = 0;
      if (hasBack)
         cells[k++] = 0f;                  // back: its own width
      cells[k++] = 1f;                     // title: everything left over
      for (int i = 0; i < count; i++)
         cells[k++] = 0f;                  // actions: their own width

      if (hasBack)
      {
         Button b = new Button(MaterialIcon.of(0xE5C4, 1.4f));   // arrow_back
         b.testTag("bar.back");
         b.onAction(c -> onBack.run());
         b.getStyle()
            .outgap(Gap.ofFont(0, 0, .5f, 0));
         cells[k++] = b;
      }

      Label t = label(title, 1.5f, true, Styles.textColor, Position.START);
      t.testTag("bar.title");
      cells[k++] = t;

      for (int i = 0; i < count; i++)
      {
         Button b = new Button(MaterialIcon.of(icons[i], 1.4f));
         final Runnable a = actions[i];
         b.testTag(tags[i]);
         b.onAction(c -> a.run());
         b.getStyle()
            .outgap(Gap.ofFont(.4f, 0, 0, 0));
         cells[k++] = b;
      }

      add(cells);
   }
}
```

### dev/cf/app/AppTheme.java

```java
package dev.cf.app;

import dev.crossfly.ui.color.ColorScheme;
import dev.crossfly.ui.style.Styles;

/**
 * The one line that gives an app its colour, and the reason the four examples here have one.
 *
 * <p><b>An app declares its brand; it does not pick colours.</b> One seed goes in, and CrossFly
 * derives the whole scheme from it — primary, surface, on-surface, containers, the tonal fills
 * behind an active tab, the ring of a gauge, both themes. Nothing downstream names a colour, which
 * is what makes dark mode and the skin picker work without a single branch.</p>
 *
 * <p>Skip this and the app is not "unstyled", it is <b>grey</b>: the skin's neutral palette is what
 * you get when nobody declared anything, and it looks exactly like a bug. That is what happened to
 * these four — they were correct from the samples menu, which declares a brand of its own, and
 * monochrome when launched straight from {@code classname}, which is how anybody copying them would
 * run them.</p>
 *
 * <h3>Why enter/leave and not just a call at startup</h3>
 *
 * <p>The brand is global state. A real app declares it once in {@code Main} and never thinks about
 * it again — and if that is your case, {@code Styles.setBrand(BRAND)} at startup is the whole story
 * and this class is more machinery than you need.</p>
 *
 * <p>These four are different: they are guests. They open from a menu that has its own brand, and
 * from a test suite whose later screenshots are compared against committed images. A guest that
 * changes global state and walks away leaves its host repainted in someone else's colour — the
 * samples menu comes back blue, the goldens fail three tests later, and neither failure points at
 * the app that caused it. So each app calls {@link #enter()} when it opens and {@link #leave()}
 * when it closes, and the palette that was in force is handed back.</p>
 */
public final class AppTheme
{
   /**
    * The seed every colour in the four example apps is derived from.
    *
    * <p>Deep blue, chosen against orange for one reason: orange is the brand of the production app
    * these examples were extracted from, and an example that wears it invites the reader to think
    * the two are related. For orange instead, this is the only line to change —
    * {@code 0xFFE2622C} is a good one. Nothing else in the four apps names a colour.</p>
    */
   public static final int BRAND = 0xFF1565C0;

   /**
    * The page behind the cards: the surface, pulled a little way towards the brand.
    *
    * <p><b>Why a page needs its own colour at all.</b> Cards, buttons and fields are all drawn on
    * the skin's surface, which in a light theme is white or near it. Put them on a white page and
    * every edge disappears — the card stops reading as a card and a white button on white looks
    * like a mistake rather than a control. What separates them is not a border, it is the page
    * being a shade the foreground is not.</p>
    *
    * <p><b>Why it is derived and not a hex.</b> {@code 0xFFF2F5F9} would be a perfectly nice light
    * blue and would be wrong twice: it stops being light blue the moment somebody changes
    * {@link #BRAND} to orange, and it stays light in dark mode, where the page must go darker than
    * the cards rather than lighter. Mixing the primary into the surface at 8% follows both — the
    * tint always matches the brand, and in a dark theme the same call yields a deep, slightly
    * tinted ground.</p>
    *
    * <p>Read it through {@code onRestyle} rather than once, so a theme flip re-derives it.</p>
    *
    * @return the ARGB page colour for the palette currently in force
    */
   public static int pageColor()
   {
      return dev.cf.Tones.page();
   }

   /** What was in force before the outermost {@link #enter()}. */
   private static Integer previousBrand;
   private static ColorScheme previousScheme;

   /** How many example apps are open. Restores only when the last one closes. */
   private static int depth;

   private AppTheme()
   {
   }

   /**
    * Declares {@link #BRAND}, remembering whatever was in force.
    *
    * <p>Call it first thing in {@code onAdd}. It triggers a restyle, so every window already open
    * re-resolves its colours — including this one, which is why it can run before the screen is
    * built.</p>
    */
   public static void enter()
   {
      if (depth++ == 0)
      {
         previousBrand = Styles.hasBrand() ? Integer.valueOf(Styles.brand()) : null;
         ColorScheme live = Styles.colorScheme();
         if (live != null)
         {
            // A SNAPSHOT, never the reference. Styles keeps ONE ColorScheme instance for the life
            // of the app and mutates its roles in place on every seed change — deliberately, so
            // that a PalColor captured into a style at build time keeps resolving and follows the
            // theme forever. The consequence for anybody saving and restoring: the object you kept
            // IS the object about to be overwritten, so handing it back restores nothing at all.
            // This cost four broken golden images in a suite that had been green all morning.
            previousScheme = ColorScheme.fromSeed(BRAND, live.isDark);
            previousScheme.copyFrom(live);
         }
      }
      Styles.setBrand(BRAND);
   }

   /**
    * Hands the palette back to the host.
    *
    * <p>Call it from {@code onRemove}, which CrossFly runs on {@code hide()}. Two cases: the host
    * had declared a brand, so re-declaring it regenerates everything; or nothing was declared, so
    * the declaration is dropped <b>and</b> the scheme that was actually in force is restored —
    * clearing the brand alone would leave this app's blue behind, still painted, just no longer
    * owned by anybody.</p>
    */
   public static void leave()
   {
      if (depth > 0 && --depth == 0)
      {
         if (previousBrand != null)
            Styles.setBrand(previousBrand.intValue());
         else
         {
            Styles.clearBrand();
            if (previousScheme != null)
               Styles.setColorScheme(previousScheme);
         }
         previousBrand = null;
         previousScheme = null;
      }
   }
}
```
