{
  "id": "app-dashboard",
  "kind": "app",
  "tags": [
    "charts"
  ],
  "title": "Pulse — a dashboard",
  "note": "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.",
  "className": "DashboardApp",
  "package": "dev.cf.app.dashboard",
  "doc": "<p><b>Pulse</b> — the dashboard shape: one screen, read rather than operated.</p>\n\n<pre>\ngradlew lwjgl3:run --args=\"classname dev.cf.app.dashboard.DashboardApp\"\n</pre>\n\n<h2>What makes a dashboard different from a listing</h2>\n\n<p>A listing is scanned for one row. A dashboard is read in a fixed order, and the order is the\ndesign: <b>headline, then supporting figures, then the shape over time, then the breakdown</b>.\nA reader who has to hunt for the number the screen exists to show is looking at a chart gallery,\nnot a dashboard.</p>\n\n<p>Three decisions carry most of the weight here.</p>\n\n<h3>1 · One period, one snapshot, one rebuild</h3>\n\n<p>The period chips are the only control. Changing one takes a fresh <code>Metrics</code> and rebuilds\nthe body from it. Patching the pieces individually is faster and is how a dashboard ends up with\na KPI from March next to a chart from April — the failure nobody notices in review and everybody\nnotices in a meeting.</p>\n\n<h3>2 · Semantic colour is separate from the accent</h3>\n\n<p>Up and down are read at a glance and must not be the brand colour, or \"on brand\" and \"doing\nwell\" become the same signal. They come from theme tokens all the same\n(<code>Styles.primaryColor</code>, <code>secondary()</code>), so the whole screen still follows the skin\nand dark mode.</p>\n\n<h3>3 · A number states its own units and its own comparison</h3>\n\n<p>\"12,480\" is not information. \"12,480, up 12.4% on last week\" is. Every KPI here carries a\ncaption saying what it counts, and the headline carries its delta.</p>",
  "api": [
    {
      "name": "Card",
      "doc": null
    },
    {
      "name": "CardGrid",
      "doc": null
    },
    {
      "name": "ChartView",
      "doc": "/docs/ui/widgets/chartview"
    },
    {
      "name": "Chip",
      "doc": null
    },
    {
      "name": "ColorUtils",
      "doc": null
    },
    {
      "name": "ComboChart",
      "doc": null
    },
    {
      "name": "Component",
      "doc": null
    },
    {
      "name": "Debug",
      "doc": null
    },
    {
      "name": "DonutChart",
      "doc": null
    },
    {
      "name": "Gap",
      "doc": null
    },
    {
      "name": "HorizontalLayout",
      "doc": null
    },
    {
      "name": "Label",
      "doc": "/docs/ui/widgets/label"
    },
    {
      "name": "PalColor",
      "doc": null
    },
    {
      "name": "RadialProgress",
      "doc": null
    },
    {
      "name": "Relative",
      "doc": null
    },
    {
      "name": "Screen",
      "doc": null
    },
    {
      "name": "Styles",
      "doc": null
    },
    {
      "name": "VerticalLayout",
      "doc": null
    },
    {
      "name": "Widgets",
      "doc": null
    },
    {
      "name": "Window",
      "doc": null
    }
  ],
  "files": [
    {
      "path": "dev/cf/app/dashboard/DashboardApp.java",
      "name": "DashboardApp.java",
      "code": "package dev.cf.app.dashboard;\n\nimport dev.cf.Described;\nimport dev.cf.Tones;\nimport dev.cf.app.AppBar;\nimport dev.cf.app.AppTheme;\n\nimport dev.crossfly.ui.*;\nimport dev.crossfly.ui.chart.*;\nimport dev.crossfly.ui.color.*;\nimport dev.crossfly.ui.color.ColorUtils;\nimport dev.crossfly.ui.graphics.*;\nimport dev.crossfly.ui.layout.*;\nimport dev.crossfly.ui.style.*;\n\nimport static dev.crossfly.ui.Widgets.*;\n\n/**\n * <b>Pulse</b> — the dashboard shape: one screen, read rather than operated.\n *\n * <pre>\n * gradlew lwjgl3:run --args=\"classname dev.cf.app.dashboard.DashboardApp\"\n * </pre>\n *\n * <h2>What makes a dashboard different from a listing</h2>\n *\n * <p>A listing is scanned for one row. A dashboard is read in a fixed order, and the order is the\n * design: <b>headline, then supporting figures, then the shape over time, then the breakdown</b>.\n * A reader who has to hunt for the number the screen exists to show is looking at a chart gallery,\n * not a dashboard.</p>\n *\n * <p>Three decisions carry most of the weight here.</p>\n *\n * <h3>1 · One period, one snapshot, one rebuild</h3>\n *\n * <p>The period chips are the only control. Changing one takes a fresh {@link Metrics} and rebuilds\n * the body from it. Patching the pieces individually is faster and is how a dashboard ends up with\n * a KPI from March next to a chart from April — the failure nobody notices in review and everybody\n * notices in a meeting.</p>\n *\n * <h3>2 · Semantic colour is separate from the accent</h3>\n *\n * <p>Up and down are read at a glance and must not be the brand colour, or \"on brand\" and \"doing\n * well\" become the same signal. They come from theme tokens all the same\n * ({@code Styles.primaryColor}, {@code secondary()}), so the whole screen still follows the skin\n * and dark mode.</p>\n *\n * <h3>3 · A number states its own units and its own comparison</h3>\n *\n * <p>\"12,480\" is not information. \"12,480, up 12.4% on last week\" is. Every KPI here carries a\n * caption saying what it counts, and the headline carries its delta.</p>\n */\npublic class DashboardApp extends Window implements Described\n{\n   private Metrics.Period period = Metrics.Period.WEEK;\n   private Metrics data;\n\n   private Component body;\n   private Chip[] chips;\n\n   @Override\n   public String title()\n   {\n      return \"Pulse — a dashboard\";\n   }\n\n   @Override\n   public String note()\n   {\n      return \"One screen, read rather than operated: a headline number, a KPI grid and charts,\"\n         + \" all re-fed by the single control that changes the period.\";\n   }\n\n   /** Hands the palette back to whatever was in force before this app opened. */\n   @Override\n   public void onRemove()\n   {\n      super.onRemove();\n      AppTheme.leave();\n   }\n\n   @Override\n   public void onAdd(boolean firstTime) throws Exception\n   {\n      if (!firstTime)\n         return;\n      // The brand FIRST: fullscreenColor below reads a token, and reading it before the palette\n      // is declared bakes in the skin's neutral grey for the life of the window.\n      AppTheme.enter();\n      // The PAGE, not the surface. Cards, buttons and fields are all drawn on the surface, so a\n      // page painted the same colour makes every one of their edges disappear. Read through\n      // onRestyle so a light/dark flip re-derives it instead of keeping the old mode's value.\n      fullscreenColor = AppTheme.pageColor();\n      onRestyle(() -> fullscreenColor = AppTheme.pageColor());\n      setLayout(new VerticalLayout(Position.START, Position.START));\n\n      add(0f, new AppBar(\"Pulse\", isRoot() ? null : this::hide));\n      add(0f, periodRow());\n\n      body = new Component();\n      body.setLayout(new VerticalLayout(Position.START, Position.START));\n      body.setScrollable(ScrollDirection.VERTICAL);\n      body.getStyle()\n         .ingap(Gap.ofFont(1f, .5f, 1f, 1f));\n      body.testTag(\"dash.body\");\n      add(LAY_FILL, body);\n\n      rebuild();\n   }\n\n   /** The one control. Chips rather than a dropdown: three options a thumb can reach. */\n   private Component periodRow() throws Exception\n   {\n      Component row = new Component();\n      row.setLayout(new HorizontalLayout(Position.START, Position.CENTER));\n      row.getStyle()\n         .ingap(Gap.ofFont(1f, .5f, 1f, .5f));\n\n      Metrics.Period[] all = Metrics.Period.values();\n      chips = new Chip[all.length];\n      for (int i = 0; i < all.length; i++)\n      {\n         final Metrics.Period p = all[i];\n         Chip c = new Chip(p.label);\n         c.testTag(\"dash.period.\" + p.name()\n            .toLowerCase());\n         c.setSelected(p == period);\n         c.onAction(e ->\n         {\n            period = p;\n            for (Chip other : chips)\n               other.setSelected(false);\n            c.setSelected(true);\n            rebuild();\n            Screen.repaint();\n         });\n         c.getStyle()\n            .outgap(Gap.ofFont(0, 0, .4f, 0));\n         chips[i] = c;\n         row.add(LAY_AUTO, c);\n      }\n      return row;\n   }\n\n   /** Takes a snapshot and builds the whole body from it. Never partially. */\n   private void rebuild()\n   {\n      try\n      {\n         data = Metrics.of(period);\n         body.removeAll();\n         // removeAll() drops the layout too — put it back before adding anything.\n         body.setLayout(new VerticalLayout(Position.START, Position.START));\n\n         body.add(LAY_AUTO, outgap(hero(), 0, 0, 0, 1f));\n         body.add(LAY_AUTO, outgap(kpis(), 0, 0, 0, 1.2f));\n         body.add(LAY_AUTO, outgap(sectionTitle(\"Revenue over time\"), 0, 0, 0, .5f));\n         body.add(LAY_AUTO, outgap(trend(), 0, 0, 0, 1.2f));\n         body.add(LAY_AUTO, outgap(sectionTitle(\"Where it came from\"), 0, 0, 0, .5f));\n         body.add(LAY_AUTO, channels());\n\n         body.setValidLayout(false);\n      }\n      catch (Exception e)\n      {\n         dev.crossfly.util.Debug.handle(e);\n      }\n   }\n\n   /**\n    * The hero: the one number the screen exists to show, its delta, and nothing else.\n    *\n    * <p>Resisting the fourth item here is most of the work. A hero with four figures in it has no\n    * hero, and the reader has to decide which one matters — which is the decision the dashboard was\n    * supposed to have made for them.</p>\n    */\n   private Component hero() throws Exception\n   {\n      Card c = new Card();\n      c.setLayout(new VerticalLayout(Position.START, Position.START));\n      c.testTag(\"dash.hero\");\n\n      c.add(LAY_AUTO, label(\"REVENUE · \" + period.label.toUpperCase(), 1f, true,\n         Styles.captionColor, Position.START));\n\n      Label big = label(data.revenue(), 3f, true, Styles.textColor, Position.START);\n      big.testTag(\"dash.revenue\");\n      c.add(LAY_AUTO, outgap(big, 0, .2f, 0, .3f));\n\n      Component deltaRow = new Component();\n      deltaRow.setLayout(new HorizontalLayout(Position.START, Position.CENTER));\n      PalColor tone = data.deltaIsGood() ? Styles.primaryColor : secondary();\n      deltaRow.add(LAY_AUTO, matglyph(data.deltaIsGood() ? 0xE5D8 : 0xE5DB, tone, 1.2f));\n      Label d = label(data.delta() + \" on the previous period\", 1f, true, tone, Position.START);\n      d.testTag(\"dash.delta\");\n      deltaRow.add(LAY_AUTO, outgap(d, .3f, 0, 0, 0));\n      c.add(LAY_AUTO, deltaRow);\n\n      return c;\n   }\n\n   /**\n    * The supporting figures.\n    *\n    * <p>A {@link CardGrid} rather than a fixed row: on a phone these stack, on a tablet they sit\n    * side by side, and neither case needed a width check or an orientation listener. Reflow is the\n    * framework's job.</p>\n    */\n   private Component kpis() throws Exception\n   {\n      CardGrid g = new CardGrid().columns(2, 4)\n         .cardWidth(11f)\n         .gap(.6f);\n      g.testTag(\"dash.kpis\");\n      g.card(kpi(\"Orders\", \"\" + data.orders, \"closed in the period\", 0xE8CC));\n      g.card(kpi(\"Customers\", \"\" + data.customers, \"who bought at least once\", 0xE7EF));\n      g.card(kpi(\"Average order\", Metrics.money(data.orders == 0 ? 0\n         : data.revenueCents / data.orders), \"revenue ÷ orders\", 0xE263));\n      g.card(gauge());\n      return g;\n   }\n\n   private Component kpi(String title, String value, String caption, int glyph) throws Exception\n   {\n      Card c = new Card();\n      c.setLayout(new VerticalLayout(Position.START, Position.START));\n      c.testTag(\"dash.kpi.\" + title.toLowerCase()\n         .replace(' ', '.'));\n      c.add(LAY_AUTO, matglyph(glyph, Styles.primaryColor, 1.4f));\n      c.add(LAY_AUTO, outgap(label(value, 2f, true, Styles.textColor, Position.START), 0, .3f, 0,\n         .1f));\n      c.add(LAY_AUTO, label(title, 1f, true, Styles.textColor, Position.START));\n      // The caption is what turns a number into a fact. It is the first thing cut for space and the\n      // first thing missed in a meeting.\n      c.add(LAY_AUTO, label(caption, 1f, false, Styles.captionColor, Position.START));\n      return c;\n   }\n\n   /** The one KPI that is a ratio, so it gets the shape a ratio deserves. */\n   private Component gauge() throws Exception\n   {\n      Card c = new Card();\n      c.setLayout(new VerticalLayout(Position.CENTER, Position.CENTER));\n      c.testTag(\"dash.kpi.conversion\");\n      RadialProgress r = new RadialProgress().progress(data.conversion)\n         .text(data.conversionText())\n         .caption(\"converted\");\n      c.add(LAY_AUTO, r);\n      c.add(LAY_AUTO, outgap(label(\"Conversion\", 1f, true, Styles.textColor, Position.CENTER), 0,\n         .4f, 0, 0));\n      return c;\n   }\n\n   /**\n    * Revenue and orders on one chart.\n    *\n    * <p>Two series that share a period belong on one chart; two charts side by side make the reader\n    * do the alignment themselves and they will do it wrong. The categories come from the same\n    * snapshot as the values, which is what keeps the axis honest when the period changes.</p>\n    */\n   private Component trend() throws Exception\n   {\n      Card c = new Card();\n      c.setLayout(new VerticalLayout(Position.START, Position.START));\n\n      ComboChart chart = new ComboChart();\n      chart.categories(data.labels);\n      // The colour is passed explicitly. autoThemeColors below themes the axes, the grid and the\n      // plot background, but NOT the series: a series left at colour 0 falls back to\n      // ChartView.DEFAULT_COLORS, whose first entry is a dark grey — so the largest thing on the\n      // screen ends up being the one thing that ignores the brand.\n      chart.addSeries(new ChartView.Series(\"Revenue\", data.revenueSeries,\n         Styles.primaryColor.color()).bar());\n      // The second series is the SAME hue, shaded towards the text colour rather than a different\n      // colour entirely: the two are measuring the same thing over the same period, so they should\n      // look related. Shading towards the text and not towards black is what keeps it working in\n      // dark mode, where \"darker\" is the wrong direction.\n      // A ROLE rather than the brand dimmed towards the text colour: two measures on one chart\n      // have to be told apart without consulting the legend, and the dimmed brand read as grey.\n      chart.addSeries(new ChartView.Series(\"Orders\", data.ordersSeries, Tones.highlight().color())\n            .line());\n      chart.showLegend = true;\n      chart.autoThemeColors(true);\n      chart.getStyle()\n         .autoHeight(Relative.ofFont(14f));\n      chart.testTag(\"dash.trend\");\n      c.add(LAY_AUTO, chart);\n      return c;\n   }\n\n   /** The breakdown. A donut, because the question is \"what share\", not \"how much\". */\n   private Component channels() throws Exception\n   {\n      Card c = new Card();\n      c.setLayout(new VerticalLayout(Position.START, Position.START));\n\n      DonutChart d = new DonutChart();\n      // One hue, four steps, darkest first — a share-of-total chart is asking \"how much of it\",\n      // and four unrelated colours make the reader learn a legend to answer that. The ramp is\n      // derived from the brand, so it follows a change of BRAND and it follows dark mode.\n      int base = Styles.primaryColor.color(), ground = Styles.backColor.color();\n      for (int i = 0; i < Metrics.CHANNEL.length; i++)\n         d.slice(Metrics.CHANNEL[i], data.channelValues[i],\n            ColorUtils.lerpColor(base, ground, i * .21f));\n      d.hole(.62f);\n      d.autoThemeColors(true);\n      d.getStyle()\n         .autoHeight(Relative.ofFont(14f));\n      d.testTag(\"dash.channels\");\n      c.add(LAY_AUTO, d);\n      return c;\n   }\n\n   private Component sectionTitle(String t) throws Exception\n   {\n      return label(t, 1.2f, true, Styles.textColor, Position.START);\n   }\n}\n"
    },
    {
      "path": "dev/cf/app/dashboard/Metrics.java",
      "name": "Metrics.java",
      "code": "package dev.cf.app.dashboard;\n\n/**\n * The numbers behind the dashboard, and the one place that knows what a period means.\n *\n * <p>A dashboard has exactly one control — the period — and every single thing on the screen has to\n * answer to it. The failure mode is not subtle and it is extremely common: one card keeps showing\n * last month while the chart beside it has already moved, and from that moment the screen is worse\n * than no dashboard, because it is confidently wrong.</p>\n *\n * <p>The defence is structural rather than careful: <b>one object holds the whole snapshot, and the\n * screen rebuilds from it.</b> There is no path by which half the screen can be from one period.\n * Nothing here reaches out for its own number.</p>\n *\n * <p>The figures are generated so the example has no backend to depend on, but they are generated\n * <i>deterministically</i> from the period — a dashboard whose numbers change every time you look\n * at it is impossible to develop against and impossible to screenshot-test.</p>\n */\npublic class Metrics\n{\n   /** The periods the header offers, and the only axis this screen has. */\n   public enum Period\n   {\n      /** The last seven days, by day. */\n      WEEK(\"This week\", 7),\n      /** The last four weeks, by week. */\n      MONTH(\"This month\", 4),\n      /** The last twelve months, by month. */\n      YEAR(\"This year\", 12);\n\n      /** What the chip says. */\n      public final String label;\n      /** How many buckets the charts have. */\n      public final int buckets;\n\n      Period(String label, int buckets)\n      {\n         this.label = label;\n         this.buckets = buckets;\n      }\n   }\n\n   /** The period this snapshot was taken for. */\n   public final Period period;\n\n   /** The headline figure, in cents. */\n   public final long revenueCents;\n\n   /** Change against the previous comparable period, as a percentage. May be negative. */\n   public final float revenueDelta;\n\n   /** Orders closed. */\n   public final int orders;\n\n   /** Distinct customers who bought. */\n   public final int customers;\n\n   /** Share of visits that ended in an order, 0..1 — the gauge. */\n   public final float conversion;\n\n   /** Revenue per bucket, oldest first. */\n   public final double[] revenueSeries;\n\n   /** Orders per bucket, oldest first — the second series on the same chart. */\n   public final double[] ordersSeries;\n\n   /** The bucket labels, matching {@link #revenueSeries}. */\n   public final String[] labels;\n\n   /** Revenue split by channel — the donut. */\n   public final double[] channelValues;\n\n   /** The channel names, matching {@link #channelValues}. */\n   public static final String[] CHANNEL = { \"Field\", \"Web\", \"Phone\", \"Partner\" };\n\n   private Metrics(Period p)\n   {\n      period = p;\n      labels = labelsFor(p);\n      revenueSeries = new double[p.buckets];\n      ordersSeries = new double[p.buckets];\n\n      long total = 0;\n      int ord = 0;\n      for (int i = 0; i < p.buckets; i++)\n      {\n         // Deterministic, and shaped like something rather than flat: a gentle rise with a wobble.\n         double base = 900 + i * 140 + (i % 3) * 210 - (i % 5) * 90;\n         double scale = p == Period.WEEK ? 1 : p == Period.MONTH ? 5.5 : 23;\n         revenueSeries[i] = Math.round(base * scale);\n         ordersSeries[i] = Math.round(revenueSeries[i] / 148.0);\n         total += (long) (revenueSeries[i] * 100);\n         ord += (int) ordersSeries[i];\n      }\n      revenueCents = total;\n      orders = ord;\n      customers = (int) (ord * 0.72);\n      conversion = p == Period.WEEK ? .41f : p == Period.MONTH ? .38f : .35f;\n      revenueDelta = p == Period.WEEK ? 12.4f : p == Period.MONTH ? 6.1f : -3.8f;\n\n      channelValues = new double[] { total * .46 / 100, total * .28 / 100, total * .17 / 100,\n         total * .09 / 100 };\n   }\n\n   /**\n    * Takes one snapshot for a period.\n    *\n    * <p>Everything the screen shows comes from the object this returns, and the screen holds no\n    * other source. That is the invariant worth keeping when you replace the generator with a real\n    * query — including when the real query is four queries, which should still be assembled into\n    * one snapshot here rather than fetched one card at a time.</p>\n    *\n    * @param p the period\n    * @return the snapshot\n    */\n   public static Metrics of(Period p)\n   {\n      return new Metrics(p);\n   }\n\n   /** The headline revenue, formatted. */\n   public String revenue()\n   {\n      return money(revenueCents);\n   }\n\n   /** The delta, with its sign, as the header shows it. */\n   public String delta()\n   {\n      String s = \"\" + Math.round(Math.abs(revenueDelta) * 10) / 10f;\n      return (revenueDelta >= 0 ? \"+\" : \"−\") + s + \"%\";\n   }\n\n   /** Whether the delta is an improvement — which is not the same as being positive, for a cost. */\n   public boolean deltaIsGood()\n   {\n      return revenueDelta >= 0;\n   }\n\n   /** The conversion gauge's centre text. */\n   public String conversionText()\n   {\n      return Math.round(conversion * 100) + \"%\";\n   }\n\n   /**\n    * Formats cents as money.\n    *\n    * @param cents the amount\n    * @return the formatted amount, thousands separated\n    */\n   public static String money(long cents)\n   {\n      long units = cents / 100;\n      StringBuilder sb = new StringBuilder();\n      String s = \"\" + units;\n      for (int i = 0; i < s.length(); i++)\n      {\n         if (i > 0 && (s.length() - i) % 3 == 0)\n            sb.append(',');\n         sb.append(s.charAt(i));\n      }\n      return sb.toString();\n   }\n\n   private static String[] labelsFor(Period p)\n   {\n      if (p == Period.WEEK)\n         return new String[] { \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\" };\n      if (p == Period.MONTH)\n         return new String[] { \"W1\", \"W2\", \"W3\", \"W4\" };\n      return new String[] { \"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\",\n         \"Nov\", \"Dec\" };\n   }\n}\n"
    }
  ],
  "lines": 495,
  "sharedFiles": [
    {
      "path": "dev/cf/app/AppBar.java",
      "name": "AppBar.java",
      "code": "package dev.cf.app;\n\nimport dev.crossfly.ui.*;\nimport dev.crossfly.ui.graphics.*;\nimport dev.crossfly.ui.layout.*;\nimport dev.crossfly.ui.style.*;\n\nimport static dev.crossfly.ui.Widgets.*;\n\n/**\n * The top bar the four example apps share: back, title, and up to a couple of actions.\n *\n * <p>There is no top bar in CrossFly, and that is not an omission. A title bar is the single most\n * app-specific piece of chrome there is — height, colour, whether it collapses, whether the title\n * centres, what sits on the right — and a framework that decided those would be fought by every\n * app that used it. What the framework supplies is what a bar is <i>made of</i>: a container, a\n * layout, buttons, glyphs, and a style that follows the skin.</p>\n *\n * <p>So this is an <b>app-level</b> class, shared by the four examples here because they are four\n * examples in one repository — in a real project it would live in your own {@code ui} package. It\n * is about forty lines, which is roughly what a top bar costs, and it is worth reading as the\n * answer to \"how do I compose chrome out of primitives\".</p>\n *\n * <pre>\n * add(0f, new AppBar(\"Part\", this::hide).action(0xE3C9, this::edit));\n * </pre>\n */\npublic class AppBar extends Component\n{\n   private final String title;\n   private final Runnable onBack;\n\n   private final int[] icons = new int[3];\n   private final Runnable[] actions = new Runnable[3];\n   private final String[] tags = new String[3];\n   private int count;\n\n   /**\n    * @param title  the heading\n    * @param onBack what the back arrow does, or null for a bar with no back arrow\n    */\n   public AppBar(String title, Runnable onBack)\n   {\n      this.title = title;\n      this.onBack = onBack;\n   }\n\n   /**\n    * Adds a right-hand action. Up to three; past that the answer is a menu, not a fourth glyph.\n    *\n    * @param codepoint the Material Icons codepoint\n    * @param tag       the test tag for the button\n    * @param action    what it does\n    * @return this bar, for chaining\n    */\n   public AppBar action(int codepoint, String tag, Runnable action)\n   {\n      if (count < icons.length)\n      {\n         icons[count] = codepoint;\n         tags[count] = tag;\n         actions[count] = action;\n         count++;\n      }\n      return this;\n   }\n\n   @Override\n   public void onAdd(boolean firstTime) throws Exception\n   {\n      if (!firstTime)\n         return;\n      // A strip of surface with a hairline of elevation under it. Both colours are theme tokens, so\n      // the bar is right in dark mode and under every skin without a branch anywhere — and the\n      // shadow is expressed as a ratio of the font height, so it survives a high-DPI screen, where\n      // a shadow measured in pixels all but disappears.\n      getStyle().backColor(Styles.backColor)\n         .shadow(0, .06f, .25f, Styles.borderColor)\n         .ingap(Gap.ofFont(.5f, .4f, .5f, .4f));\n\n      setLayout(new GridLayout(Position.START, Position.CENTER));\n\n      // A bar with no back arrow gets no COLUMN for one. The obvious shortcut — always build the\n      // column and fill it with a bare `new Component()` — silently kills the entire row: an empty\n      // container has no layout, an auto-width column cannot measure it, and the bar comes out\n      // zero-high with the title inside it. It looks like the title was never added.\n      boolean hasBack = onBack != null;\n      int columns = (hasBack ? 1 : 0) + 1 + count;\n      Object[] cells = new Object[columns * 2];\n\n      int k = 0;\n      if (hasBack)\n         cells[k++] = 0f;                  // back: its own width\n      cells[k++] = 1f;                     // title: everything left over\n      for (int i = 0; i < count; i++)\n         cells[k++] = 0f;                  // actions: their own width\n\n      if (hasBack)\n      {\n         Button b = new Button(MaterialIcon.of(0xE5C4, 1.4f));   // arrow_back\n         b.testTag(\"bar.back\");\n         b.onAction(c -> onBack.run());\n         b.getStyle()\n            .outgap(Gap.ofFont(0, 0, .5f, 0));\n         cells[k++] = b;\n      }\n\n      Label t = label(title, 1.5f, true, Styles.textColor, Position.START);\n      t.testTag(\"bar.title\");\n      cells[k++] = t;\n\n      for (int i = 0; i < count; i++)\n      {\n         Button b = new Button(MaterialIcon.of(icons[i], 1.4f));\n         final Runnable a = actions[i];\n         b.testTag(tags[i]);\n         b.onAction(c -> a.run());\n         b.getStyle()\n            .outgap(Gap.ofFont(.4f, 0, 0, 0));\n         cells[k++] = b;\n      }\n\n      add(cells);\n   }\n}\n"
    },
    {
      "path": "dev/cf/app/AppTheme.java",
      "name": "AppTheme.java",
      "code": "package dev.cf.app;\n\nimport dev.crossfly.ui.color.ColorScheme;\nimport dev.crossfly.ui.style.Styles;\n\n/**\n * The one line that gives an app its colour, and the reason the four examples here have one.\n *\n * <p><b>An app declares its brand; it does not pick colours.</b> One seed goes in, and CrossFly\n * derives the whole scheme from it — primary, surface, on-surface, containers, the tonal fills\n * behind an active tab, the ring of a gauge, both themes. Nothing downstream names a colour, which\n * is what makes dark mode and the skin picker work without a single branch.</p>\n *\n * <p>Skip this and the app is not \"unstyled\", it is <b>grey</b>: the skin's neutral palette is what\n * you get when nobody declared anything, and it looks exactly like a bug. That is what happened to\n * these four — they were correct from the samples menu, which declares a brand of its own, and\n * monochrome when launched straight from {@code classname}, which is how anybody copying them would\n * run them.</p>\n *\n * <h3>Why enter/leave and not just a call at startup</h3>\n *\n * <p>The brand is global state. A real app declares it once in {@code Main} and never thinks about\n * it again — and if that is your case, {@code Styles.setBrand(BRAND)} at startup is the whole story\n * and this class is more machinery than you need.</p>\n *\n * <p>These four are different: they are guests. They open from a menu that has its own brand, and\n * from a test suite whose later screenshots are compared against committed images. A guest that\n * changes global state and walks away leaves its host repainted in someone else's colour — the\n * samples menu comes back blue, the goldens fail three tests later, and neither failure points at\n * the app that caused it. So each app calls {@link #enter()} when it opens and {@link #leave()}\n * when it closes, and the palette that was in force is handed back.</p>\n */\npublic final class AppTheme\n{\n   /**\n    * The seed every colour in the four example apps is derived from.\n    *\n    * <p>Deep blue, chosen against orange for one reason: orange is the brand of the production app\n    * these examples were extracted from, and an example that wears it invites the reader to think\n    * the two are related. For orange instead, this is the only line to change —\n    * {@code 0xFFE2622C} is a good one. Nothing else in the four apps names a colour.</p>\n    */\n   public static final int BRAND = 0xFF1565C0;\n\n   /**\n    * The page behind the cards: the surface, pulled a little way towards the brand.\n    *\n    * <p><b>Why a page needs its own colour at all.</b> Cards, buttons and fields are all drawn on\n    * the skin's surface, which in a light theme is white or near it. Put them on a white page and\n    * every edge disappears — the card stops reading as a card and a white button on white looks\n    * like a mistake rather than a control. What separates them is not a border, it is the page\n    * being a shade the foreground is not.</p>\n    *\n    * <p><b>Why it is derived and not a hex.</b> {@code 0xFFF2F5F9} would be a perfectly nice light\n    * blue and would be wrong twice: it stops being light blue the moment somebody changes\n    * {@link #BRAND} to orange, and it stays light in dark mode, where the page must go darker than\n    * the cards rather than lighter. Mixing the primary into the surface at 8% follows both — the\n    * tint always matches the brand, and in a dark theme the same call yields a deep, slightly\n    * tinted ground.</p>\n    *\n    * <p>Read it through {@code onRestyle} rather than once, so a theme flip re-derives it.</p>\n    *\n    * @return the ARGB page colour for the palette currently in force\n    */\n   public static int pageColor()\n   {\n      return dev.cf.Tones.page();\n   }\n\n   /** What was in force before the outermost {@link #enter()}. */\n   private static Integer previousBrand;\n   private static ColorScheme previousScheme;\n\n   /** How many example apps are open. Restores only when the last one closes. */\n   private static int depth;\n\n   private AppTheme()\n   {\n   }\n\n   /**\n    * Declares {@link #BRAND}, remembering whatever was in force.\n    *\n    * <p>Call it first thing in {@code onAdd}. It triggers a restyle, so every window already open\n    * re-resolves its colours — including this one, which is why it can run before the screen is\n    * built.</p>\n    */\n   public static void enter()\n   {\n      if (depth++ == 0)\n      {\n         previousBrand = Styles.hasBrand() ? Integer.valueOf(Styles.brand()) : null;\n         ColorScheme live = Styles.colorScheme();\n         if (live != null)\n         {\n            // A SNAPSHOT, never the reference. Styles keeps ONE ColorScheme instance for the life\n            // of the app and mutates its roles in place on every seed change — deliberately, so\n            // that a PalColor captured into a style at build time keeps resolving and follows the\n            // theme forever. The consequence for anybody saving and restoring: the object you kept\n            // IS the object about to be overwritten, so handing it back restores nothing at all.\n            // This cost four broken golden images in a suite that had been green all morning.\n            previousScheme = ColorScheme.fromSeed(BRAND, live.isDark);\n            previousScheme.copyFrom(live);\n         }\n      }\n      Styles.setBrand(BRAND);\n   }\n\n   /**\n    * Hands the palette back to the host.\n    *\n    * <p>Call it from {@code onRemove}, which CrossFly runs on {@code hide()}. Two cases: the host\n    * had declared a brand, so re-declaring it regenerates everything; or nothing was declared, so\n    * the declaration is dropped <b>and</b> the scheme that was actually in force is restored —\n    * clearing the brand alone would leave this app's blue behind, still painted, just no longer\n    * owned by anybody.</p>\n    */\n   public static void leave()\n   {\n      if (depth > 0 && --depth == 0)\n      {\n         if (previousBrand != null)\n            Styles.setBrand(previousBrand.intValue());\n         else\n         {\n            Styles.clearBrand();\n            if (previousScheme != null)\n               Styles.setColorScheme(previousScheme);\n         }\n         previousBrand = null;\n         previousScheme = null;\n      }\n   }\n}\n"
    }
  ],
  "page": "https://crossfly.dev/examples/app-dashboard",
  "run": "https://crossfly.dev/playground/index.html?ex=app-dashboard",
  "runLocally": "gradlew lwjgl3:run --args=\"classname dev.cf.app.dashboard.DashboardApp\"",
  "license": "See crossfly.dev for licensing. The code is published to be copied into your own project."
}