# Pulse — a dashboard

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

One screen, read rather than operated: a headline number, a KPI grid and charts, all re-fed by the single control that changes the period.

- 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.dashboard.DashboardApp`
- Topics: charts
- Run it locally: `gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"`
- Run it in a browser: https://crossfly.dev/playground/index.html?ex=app-dashboard
- Page: https://crossfly.dev/examples/app-dashboard

## What this shows

**Pulse** — the dashboard shape: one screen, read rather than operated.

```
gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"
```

## What makes a dashboard different from a listing

A listing is scanned for one row. A dashboard is read in a fixed order, and the order is the
design: **headline, then supporting figures, then the shape over time, then the breakdown**.
A reader who has to hunt for the number the screen exists to show is looking at a chart gallery,
not a dashboard.

Three decisions carry most of the weight here.

### 1 · One period, one snapshot, one rebuild

The period chips are the only control. Changing one takes a fresh `Metrics` and rebuilds
the body from it. Patching the pieces individually is faster and is how a dashboard ends up with
a KPI from March next to a chart from April — the failure nobody notices in review and everybody
notices in a meeting.

### 2 · Semantic colour is separate from the accent

Up and down are read at a glance and must not be the brand colour, or "on brand" and "doing
well" become the same signal. They come from theme tokens all the same
(`Styles.primaryColor`, `secondary()`), so the whole screen still follows the skin
and dark mode.

### 3 · A number states its own units and its own comparison

"12,480" is not information. "12,480, up 12.4% on last week" is. Every KPI here carries a
caption saying what it counts, and the headline carries its delta.

## Framework types it uses

`Card`, `CardGrid`, `ChartView`, `Chip`, `ColorUtils`, `ComboChart`, `Component`, `Debug`, `DonutChart`, `Gap`, `HorizontalLayout`, `Label`, `PalColor`, `RadialProgress`, `Relative`, `Screen`, `Styles`, `VerticalLayout`, `Widgets`, `Window`

## Source

### dev/cf/app/dashboard/DashboardApp.java

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

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

import dev.crossfly.ui.*;
import dev.crossfly.ui.chart.*;
import dev.crossfly.ui.color.*;
import dev.crossfly.ui.color.ColorUtils;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

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

/**
 * <b>Pulse</b> — the dashboard shape: one screen, read rather than operated.
 *
 * <pre>
 * gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"
 * </pre>
 *
 * <h2>What makes a dashboard different from a listing</h2>
 *
 * <p>A listing is scanned for one row. A dashboard is read in a fixed order, and the order is the
 * design: <b>headline, then supporting figures, then the shape over time, then the breakdown</b>.
 * A reader who has to hunt for the number the screen exists to show is looking at a chart gallery,
 * not a dashboard.</p>
 *
 * <p>Three decisions carry most of the weight here.</p>
 *
 * <h3>1 · One period, one snapshot, one rebuild</h3>
 *
 * <p>The period chips are the only control. Changing one takes a fresh {@link Metrics} and rebuilds
 * the body from it. Patching the pieces individually is faster and is how a dashboard ends up with
 * a KPI from March next to a chart from April — the failure nobody notices in review and everybody
 * notices in a meeting.</p>
 *
 * <h3>2 · Semantic colour is separate from the accent</h3>
 *
 * <p>Up and down are read at a glance and must not be the brand colour, or "on brand" and "doing
 * well" become the same signal. They come from theme tokens all the same
 * ({@code Styles.primaryColor}, {@code secondary()}), so the whole screen still follows the skin
 * and dark mode.</p>
 *
 * <h3>3 · A number states its own units and its own comparison</h3>
 *
 * <p>"12,480" is not information. "12,480, up 12.4% on last week" is. Every KPI here carries a
 * caption saying what it counts, and the headline carries its delta.</p>
 */
public class DashboardApp extends Window implements Described
{
   private Metrics.Period period = Metrics.Period.WEEK;
   private Metrics data;

   private Component body;
   private Chip[] chips;

   @Override
   public String title()
   {
      return "Pulse — a dashboard";
   }

   @Override
   public String note()
   {
      return "One screen, read rather than operated: a headline number, a KPI grid and charts,"
         + " all re-fed by the single control that changes the period.";
   }

   /** 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("Pulse", isRoot() ? null : this::hide));
      add(0f, periodRow());

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

      rebuild();
   }

   /** The one control. Chips rather than a dropdown: three options a thumb can reach. */
   private Component periodRow() throws Exception
   {
      Component row = new Component();
      row.setLayout(new HorizontalLayout(Position.START, Position.CENTER));
      row.getStyle()
         .ingap(Gap.ofFont(1f, .5f, 1f, .5f));

      Metrics.Period[] all = Metrics.Period.values();
      chips = new Chip[all.length];
      for (int i = 0; i < all.length; i++)
      {
         final Metrics.Period p = all[i];
         Chip c = new Chip(p.label);
         c.testTag("dash.period." + p.name()
            .toLowerCase());
         c.setSelected(p == period);
         c.onAction(e ->
         {
            period = p;
            for (Chip other : chips)
               other.setSelected(false);
            c.setSelected(true);
            rebuild();
            Screen.repaint();
         });
         c.getStyle()
            .outgap(Gap.ofFont(0, 0, .4f, 0));
         chips[i] = c;
         row.add(LAY_AUTO, c);
      }
      return row;
   }

   /** Takes a snapshot and builds the whole body from it. Never partially. */
   private void rebuild()
   {
      try
      {
         data = Metrics.of(period);
         body.removeAll();
         // removeAll() drops the layout too — put it back before adding anything.
         body.setLayout(new VerticalLayout(Position.START, Position.START));

         body.add(LAY_AUTO, outgap(hero(), 0, 0, 0, 1f));
         body.add(LAY_AUTO, outgap(kpis(), 0, 0, 0, 1.2f));
         body.add(LAY_AUTO, outgap(sectionTitle("Revenue over time"), 0, 0, 0, .5f));
         body.add(LAY_AUTO, outgap(trend(), 0, 0, 0, 1.2f));
         body.add(LAY_AUTO, outgap(sectionTitle("Where it came from"), 0, 0, 0, .5f));
         body.add(LAY_AUTO, channels());

         body.setValidLayout(false);
      }
      catch (Exception e)
      {
         dev.crossfly.util.Debug.handle(e);
      }
   }

   /**
    * The hero: the one number the screen exists to show, its delta, and nothing else.
    *
    * <p>Resisting the fourth item here is most of the work. A hero with four figures in it has no
    * hero, and the reader has to decide which one matters — which is the decision the dashboard was
    * supposed to have made for them.</p>
    */
   private Component hero() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));
      c.testTag("dash.hero");

      c.add(LAY_AUTO, label("REVENUE · " + period.label.toUpperCase(), 1f, true,
         Styles.captionColor, Position.START));

      Label big = label(data.revenue(), 3f, true, Styles.textColor, Position.START);
      big.testTag("dash.revenue");
      c.add(LAY_AUTO, outgap(big, 0, .2f, 0, .3f));

      Component deltaRow = new Component();
      deltaRow.setLayout(new HorizontalLayout(Position.START, Position.CENTER));
      PalColor tone = data.deltaIsGood() ? Styles.primaryColor : secondary();
      deltaRow.add(LAY_AUTO, matglyph(data.deltaIsGood() ? 0xE5D8 : 0xE5DB, tone, 1.2f));
      Label d = label(data.delta() + " on the previous period", 1f, true, tone, Position.START);
      d.testTag("dash.delta");
      deltaRow.add(LAY_AUTO, outgap(d, .3f, 0, 0, 0));
      c.add(LAY_AUTO, deltaRow);

      return c;
   }

   /**
    * The supporting figures.
    *
    * <p>A {@link CardGrid} rather than a fixed row: on a phone these stack, on a tablet they sit
    * side by side, and neither case needed a width check or an orientation listener. Reflow is the
    * framework's job.</p>
    */
   private Component kpis() throws Exception
   {
      CardGrid g = new CardGrid().columns(2, 4)
         .cardWidth(11f)
         .gap(.6f);
      g.testTag("dash.kpis");
      g.card(kpi("Orders", "" + data.orders, "closed in the period", 0xE8CC));
      g.card(kpi("Customers", "" + data.customers, "who bought at least once", 0xE7EF));
      g.card(kpi("Average order", Metrics.money(data.orders == 0 ? 0
         : data.revenueCents / data.orders), "revenue ÷ orders", 0xE263));
      g.card(gauge());
      return g;
   }

   private Component kpi(String title, String value, String caption, int glyph) throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));
      c.testTag("dash.kpi." + title.toLowerCase()
         .replace(' ', '.'));
      c.add(LAY_AUTO, matglyph(glyph, Styles.primaryColor, 1.4f));
      c.add(LAY_AUTO, outgap(label(value, 2f, true, Styles.textColor, Position.START), 0, .3f, 0,
         .1f));
      c.add(LAY_AUTO, label(title, 1f, true, Styles.textColor, Position.START));
      // The caption is what turns a number into a fact. It is the first thing cut for space and the
      // first thing missed in a meeting.
      c.add(LAY_AUTO, label(caption, 1f, false, Styles.captionColor, Position.START));
      return c;
   }

   /** The one KPI that is a ratio, so it gets the shape a ratio deserves. */
   private Component gauge() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.CENTER, Position.CENTER));
      c.testTag("dash.kpi.conversion");
      RadialProgress r = new RadialProgress().progress(data.conversion)
         .text(data.conversionText())
         .caption("converted");
      c.add(LAY_AUTO, r);
      c.add(LAY_AUTO, outgap(label("Conversion", 1f, true, Styles.textColor, Position.CENTER), 0,
         .4f, 0, 0));
      return c;
   }

   /**
    * Revenue and orders on one chart.
    *
    * <p>Two series that share a period belong on one chart; two charts side by side make the reader
    * do the alignment themselves and they will do it wrong. The categories come from the same
    * snapshot as the values, which is what keeps the axis honest when the period changes.</p>
    */
   private Component trend() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));

      ComboChart chart = new ComboChart();
      chart.categories(data.labels);
      // The colour is passed explicitly. autoThemeColors below themes the axes, the grid and the
      // plot background, but NOT the series: a series left at colour 0 falls back to
      // ChartView.DEFAULT_COLORS, whose first entry is a dark grey — so the largest thing on the
      // screen ends up being the one thing that ignores the brand.
      chart.addSeries(new ChartView.Series("Revenue", data.revenueSeries,
         Styles.primaryColor.color()).bar());
      // The second series is the SAME hue, shaded towards the text colour rather than a different
      // colour entirely: the two are measuring the same thing over the same period, so they should
      // look related. Shading towards the text and not towards black is what keeps it working in
      // dark mode, where "darker" is the wrong direction.
      // A ROLE rather than the brand dimmed towards the text colour: two measures on one chart
      // have to be told apart without consulting the legend, and the dimmed brand read as grey.
      chart.addSeries(new ChartView.Series("Orders", data.ordersSeries, Tones.highlight().color())
            .line());
      chart.showLegend = true;
      chart.autoThemeColors(true);
      chart.getStyle()
         .autoHeight(Relative.ofFont(14f));
      chart.testTag("dash.trend");
      c.add(LAY_AUTO, chart);
      return c;
   }

   /** The breakdown. A donut, because the question is "what share", not "how much". */
   private Component channels() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));

      DonutChart d = new DonutChart();
      // One hue, four steps, darkest first — a share-of-total chart is asking "how much of it",
      // and four unrelated colours make the reader learn a legend to answer that. The ramp is
      // derived from the brand, so it follows a change of BRAND and it follows dark mode.
      int base = Styles.primaryColor.color(), ground = Styles.backColor.color();
      for (int i = 0; i < Metrics.CHANNEL.length; i++)
         d.slice(Metrics.CHANNEL[i], data.channelValues[i],
            ColorUtils.lerpColor(base, ground, i * .21f));
      d.hole(.62f);
      d.autoThemeColors(true);
      d.getStyle()
         .autoHeight(Relative.ofFont(14f));
      d.testTag("dash.channels");
      c.add(LAY_AUTO, d);
      return c;
   }

   private Component sectionTitle(String t) throws Exception
   {
      return label(t, 1.2f, true, Styles.textColor, Position.START);
   }
}
```

### dev/cf/app/dashboard/Metrics.java

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

/**
 * The numbers behind the dashboard, and the one place that knows what a period means.
 *
 * <p>A dashboard has exactly one control — the period — and every single thing on the screen has to
 * answer to it. The failure mode is not subtle and it is extremely common: one card keeps showing
 * last month while the chart beside it has already moved, and from that moment the screen is worse
 * than no dashboard, because it is confidently wrong.</p>
 *
 * <p>The defence is structural rather than careful: <b>one object holds the whole snapshot, and the
 * screen rebuilds from it.</b> There is no path by which half the screen can be from one period.
 * Nothing here reaches out for its own number.</p>
 *
 * <p>The figures are generated so the example has no backend to depend on, but they are generated
 * <i>deterministically</i> from the period — a dashboard whose numbers change every time you look
 * at it is impossible to develop against and impossible to screenshot-test.</p>
 */
public class Metrics
{
   /** The periods the header offers, and the only axis this screen has. */
   public enum Period
   {
      /** The last seven days, by day. */
      WEEK("This week", 7),
      /** The last four weeks, by week. */
      MONTH("This month", 4),
      /** The last twelve months, by month. */
      YEAR("This year", 12);

      /** What the chip says. */
      public final String label;
      /** How many buckets the charts have. */
      public final int buckets;

      Period(String label, int buckets)
      {
         this.label = label;
         this.buckets = buckets;
      }
   }

   /** The period this snapshot was taken for. */
   public final Period period;

   /** The headline figure, in cents. */
   public final long revenueCents;

   /** Change against the previous comparable period, as a percentage. May be negative. */
   public final float revenueDelta;

   /** Orders closed. */
   public final int orders;

   /** Distinct customers who bought. */
   public final int customers;

   /** Share of visits that ended in an order, 0..1 — the gauge. */
   public final float conversion;

   /** Revenue per bucket, oldest first. */
   public final double[] revenueSeries;

   /** Orders per bucket, oldest first — the second series on the same chart. */
   public final double[] ordersSeries;

   /** The bucket labels, matching {@link #revenueSeries}. */
   public final String[] labels;

   /** Revenue split by channel — the donut. */
   public final double[] channelValues;

   /** The channel names, matching {@link #channelValues}. */
   public static final String[] CHANNEL = { "Field", "Web", "Phone", "Partner" };

   private Metrics(Period p)
   {
      period = p;
      labels = labelsFor(p);
      revenueSeries = new double[p.buckets];
      ordersSeries = new double[p.buckets];

      long total = 0;
      int ord = 0;
      for (int i = 0; i < p.buckets; i++)
      {
         // Deterministic, and shaped like something rather than flat: a gentle rise with a wobble.
         double base = 900 + i * 140 + (i % 3) * 210 - (i % 5) * 90;
         double scale = p == Period.WEEK ? 1 : p == Period.MONTH ? 5.5 : 23;
         revenueSeries[i] = Math.round(base * scale);
         ordersSeries[i] = Math.round(revenueSeries[i] / 148.0);
         total += (long) (revenueSeries[i] * 100);
         ord += (int) ordersSeries[i];
      }
      revenueCents = total;
      orders = ord;
      customers = (int) (ord * 0.72);
      conversion = p == Period.WEEK ? .41f : p == Period.MONTH ? .38f : .35f;
      revenueDelta = p == Period.WEEK ? 12.4f : p == Period.MONTH ? 6.1f : -3.8f;

      channelValues = new double[] { total * .46 / 100, total * .28 / 100, total * .17 / 100,
         total * .09 / 100 };
   }

   /**
    * Takes one snapshot for a period.
    *
    * <p>Everything the screen shows comes from the object this returns, and the screen holds no
    * other source. That is the invariant worth keeping when you replace the generator with a real
    * query — including when the real query is four queries, which should still be assembled into
    * one snapshot here rather than fetched one card at a time.</p>
    *
    * @param p the period
    * @return the snapshot
    */
   public static Metrics of(Period p)
   {
      return new Metrics(p);
   }

   /** The headline revenue, formatted. */
   public String revenue()
   {
      return money(revenueCents);
   }

   /** The delta, with its sign, as the header shows it. */
   public String delta()
   {
      String s = "" + Math.round(Math.abs(revenueDelta) * 10) / 10f;
      return (revenueDelta >= 0 ? "+" : "−") + s + "%";
   }

   /** Whether the delta is an improvement — which is not the same as being positive, for a cost. */
   public boolean deltaIsGood()
   {
      return revenueDelta >= 0;
   }

   /** The conversion gauge's centre text. */
   public String conversionText()
   {
      return Math.round(conversion * 100) + "%";
   }

   /**
    * Formats cents as money.
    *
    * @param cents the amount
    * @return the formatted amount, thousands separated
    */
   public static String money(long cents)
   {
      long units = cents / 100;
      StringBuilder sb = new StringBuilder();
      String s = "" + units;
      for (int i = 0; i < s.length(); i++)
      {
         if (i > 0 && (s.length() - i) % 3 == 0)
            sb.append(',');
         sb.append(s.charAt(i));
      }
      return sb.toString();
   }

   private static String[] labelsFor(Period p)
   {
      if (p == Period.WEEK)
         return new String[] { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" };
      if (p == Period.MONTH)
         return new String[] { "W1", "W2", "W3", "W4" };
      return new String[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
         "Nov", "Dec" };
   }
}
```

## 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;
      }
   }
}
```
