# MATCH//OS — M5Stack Tab5 (ESP32-P4)

**Project record — complete source, 35 files, ~3986 lines.**
Exported 2026-08-10 for write-up on matchtan's site.

- **Device:** M5Stack Tab5 — ESP32-P4 RISC-V dual-core 400 MHz, 32 MB PSRAM, 16 MB flash, 5″ 1280×720 MIPI-DSI, ESP32-C6 for WiFi 6
- **Stack:** ESP-IDF 5.4 + LVGL 9, `m5stack_tab5` BSP
- **Orientation:** portrait 720×1280
- **What it is:** a 16-app launcher OS. One static registry table (`MOS_APPS[]`) drives the tile grid, detail sheets and dock. App 04 (Chrono-Deck) is fully built; apps 11–15 are a single SD-card JSON content engine wearing five tiles; the rest route to a stub that renders their own spec sheet from the registry.
- **Hidden:** a sigil screen — see `main/mos_egg.c`. Seven taps on the status-bar wordmark inside four seconds. Not in the registry, no tile, unreachable any other way. The `MOS_EGG_SIGIL` string also sits in `.rodata`, so `strings` on the `.bin` finds it too.

---

## Note for whoever is turning this into a page

Everything below is the actual firmware, in build order. Suggested structure for the write-up: hardware → the two architectural ideas (registry-as-source-of-truth, and five-apps-are-one-app) → what's stubbed and why → the easter egg as the closing beat. Don't paraphrase the code; quote `mos_apps.h` and `mos_egg.c` directly, they carry the story.

---

## File index

1. `README.md`
2. `CMakeLists.txt`
3. `sdkconfig.defaults`
4. `partitions.csv`
5. `main/CMakeLists.txt`
6. `main/idf_component.yml`
7. `main/main.c`
8. `main/mos_theme.h`
9. `main/mos_theme.c`
10. `main/mos_apps.h`
11. `main/mos_apps.c`
12. `main/apps.h`
13. `main/mos_nav.h`
14. `main/mos_nav.c`
15. `main/mos_launcher.c`
16. `main/mos_detail.c`
17. `main/mos_content.h`
18. `main/mos_content.c`
19. `main/mos_telemetry.h`
20. `main/mos_telemetry.c`
21. `main/app_chrono.c`
22. `main/app_matchos.c`
23. `main/app_stub.c`
24. `main/mos_egg.h`
25. `main/mos_egg.c`
26. `sdcard/README-schema.md`
27. `sdcard/matchos/journey/index.json`
28. `sdcard/matchos/journey/kansai-2026.json`
29. `sdcard/matchos/kitchen/index.json`
30. `sdcard/matchos/kitchen/hainan-chicken.json`
31. `sdcard/matchos/works/index.json`
32. `sdcard/matchos/works/tab5-build.json`
33. `sdcard/matchos/studio/index.json`
34. `sdcard/matchos/living/index.json`
35. `sdcard/matchos/living/plants.json`

---

## Source

### `README.md`

````markdown
# MATCH//OS

A 16-app launcher for the **M5Stack Tab5** (ESP32-P4). Portrait 720×1280,
cyberpunk theme, ESP-IDF 5.4 + LVGL 9.

This is the firmware side of the approved HTML mockup. The launcher, tile grid,
detail sheets, navigation stack and App 04 (Chrono-Deck) are implemented. Apps
11–15 share a single SD-card-driven content engine. The remaining apps route to
a placeholder that shows their plan straight from the registry — so the device
is useful as its own spec sheet before the code exists.

---

## Hardware

| | |
|---|---|
| SoC | ESP32-P4NRW32 — RISC-V dual-core 400 MHz + LP core 40 MHz |
| Memory | 32 MB PSRAM, 16 MB flash |
| Display | 5″ 1280×720 IPS over MIPI-DSI (ILI9881C on early boards, ST7123 on later) |
| Touch | GT911 or ST7123, on the shared I²C bus |
| Wireless | ESP32-C6-MINI-1U — WiFi 6, Thread, Zigbee |
| Camera | SC202CS 2 MP via MIPI-CSI |
| Audio | ES8388 codec + ES7210 AEC front end, dual mic, 1 W speaker |
| Industrial | SIT3088 RS485 with switchable 120 Ω termination |
| Sensors | BMI270 IMU, RX8130CE RTC, INA226 power monitor |
| Power | NP-F550 7.4 V 2000 mAh, IP2326 charger |

The BSP autodetects the panel revision. **Do not hardcode a display driver.**

---

## Build

```bash
# ESP-IDF 5.4 or later
. $IDF_PATH/export.sh

idf.py set-target esp32p4
idf.py build
idf.py -p /dev/ttyACM0 flash monitor      # Linux
idf.py -p COM7 flash monitor              # Windows
```

First build pulls `espressif/m5stack_tab5` and its dependency tree from the
component registry — expect a few minutes.

If the screen stays black but the log shows the launcher coming up, check
`CONFIG_SPIRAM` is on. The framebuffer alone is 720 × 1280 × 2 = 1.8 MB and it
is double-buffered; without PSRAM the display allocation fails silently early.

---

## Layout

```
match-os-tab5/
├── CMakeLists.txt
├── sdkconfig.defaults        PSRAM, LVGL fonts, FATFS, partition config
├── partitions.csv            two 6MB OTA slots
├── main/
│   ├── main.c                boot order: NVS -> I2C -> display -> SD -> time -> UI
│   ├── mos_theme.{h,c}       every colour and metric in the project
│   ├── mos_apps.{h,c}        THE APP REGISTRY — one row per app
│   ├── apps.h                app entry points
│   ├── mos_nav.{h,c}         push/pop screen stack
│   ├── mos_launcher.c        status bar, tile grid, filters, dock
│   ├── mos_detail.c          detail sheet, rendered from the registry
│   ├── mos_content.{h,c}     SD-card JSON loader, schema v1
│   ├── mos_telemetry.{h,c}   cached system readings
│   ├── app_chrono.c          App 04 — fully implemented
│   ├── app_matchos.c         Apps 11–15 — one content engine
│   └── app_stub.c            everything not built yet
└── sdcard/
    ├── README-schema.md      the JSON schema, in full
    └── matchos/              sample content — copy to the card root
```

---

## The two ideas worth knowing

### 1. The app registry is the single source of truth

`MOS_APPS[]` in `mos_apps.c` is a static table of 16 rows. The launcher grid,
the detail sheet and the dock all read from it. Adding app 17 is one row plus
one `open()` function — no changes to any UI file.

Implementing a stubbed app:

```c
/* 1. write it */
void app_ha_open(const mos_app_t *self) { ... }

/* 2. declare it in apps.h */
void app_ha_open(const mos_app_t *self);

/* 3. change one pointer in MOS_APPS */
{ 1, MOS_GROUP_LAB, "HA Room Controller", ..., app_ha_open },
```

### 2. Apps 11–15 are one app

Match Journey, Kitchen, Works, Studio and Living are five tiles and five folders
on the SD card, but a single implementation (`app_matchos.c`). They differ only
by `content_dir`. Content is JSON, read at runtime — **changing your recipes or
your itinerary does not require reflashing.**

Adding "Match Garage" later: create `/matchos/garage/` on the card, add one row
to `MOS_APPS` pointing at it. That is the entire change.

See `sdcard/README-schema.md` for the schema.

---

## Interaction

| Gesture | Result |
|---|---|
| Tap a tile | Launches the app |
| Long-press a tile | Opens the detail sheet — description, features, hardware, stack, effort |
| Tap the **＋** badge | Adds/removes the app from the build queue |
| Filter chips | ALL / LAB / MATCH OS / META |
| Back chevron | Pops one screen off the nav stack |

---

## What is stubbed, and where

Every stub is marked `TODO` in the source. In rough priority order:

| What | File | Why it matters |
|---|---|---|
| RX8130CE RTC read | `main.c` `init_time()` | Without it the clock resets to a seeded date on every cold boot |
| SNTP + write-back to RTC | `main.c` | Needs WiFi provisioning first |
| INA226 battery read | `mos_telemetry.c` | Battery % is currently a constant 7.9 V |
| SoC temperature | `mos_telemetry.c` | `temperature_sensor_get_celsius()` from `driver/temperature_sensor.h` |
| CPU load | `mos_telemetry.c` | Needs `CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS` |
| Real weather | `app_chrono.c` `mos_weather_get()` | Keep the struct shape and the layout is untouched |
| FFT spectrum | `app_chrono.c` | Currently a synthetic wave; real bins come with App 02's audio pipeline |
| Checklist persistence | `app_matchos.c` | Ticks do not survive a reboot yet |

---

## Where the design differs from the mockup

LVGL has no `::before`, no radial gradients and no text-shadow, so three things
are approximated:

- **Tile accent rule** — a real 2 px child object pinned to the card's top edge,
  with an LVGL shadow standing in for the CSS glow.
- **Corner glow** — dropped. It read as noise at 720 px and cost fill rate.
- **Clock glow** — LVGL shadows do not apply to text. If you want the full bloom,
  render a blurred copy behind the label, or bake a custom 96 px font with the
  glow in the glyphs.

Everything else — palette, spacing, tile geometry, type scale — is a direct port
of the approved mockup, driven from `mos_theme.h`.

---

## Build order

1. **App 04 Chrono-Deck** — done. Proves LVGL + display + telemetry end to end.
2. **App 01 HA Room Controller** — highest daily value; its MQTT layer is reused
   by apps 05, 07 and 15.
3. **Match OS content engine** — done; fill in the SD card content.
4. **App 02 Voice Assistant** — the ES7210 AEC front end is the thing this board
   does that a Pi tablet does badly. Hardest of the four.
5. **App 16 Feng Shui / Zi Wei / Liu Yao** — BMI270 + RTC is a genuinely good
   hardware fit and nobody has built it on an ESP32 tablet.

Deferred by decision: App 09 becomes a debug drawer inside App 13 rather than
its own tile; App 03 becomes a door-cam feature of App 01; App 08 is a whole
separate project.
````

### `CMakeLists.txt`

```cmake
# MATCH//OS for M5Stack Tab5 (ESP32-P4)
# Requires ESP-IDF >= 5.4
cmake_minimum_required(VERSION 3.16)

set(SUPPORTED_TARGETS esp32p4)

include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(match_os_tab5)
```

### `sdkconfig.defaults`

```ini
##############################################################################
# MATCH//OS — M5Stack Tab5 (ESP32-P4)  |  sdkconfig.defaults
##############################################################################

CONFIG_IDF_TARGET="esp32p4"

# --- Flash / partitions -----------------------------------------------------
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"

# --- PSRAM (32MB). The framebuffer alone is 720*1280*2 = 1.8MB, and we run
#     double-buffered, so this is not optional. -------------------------------
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_HEX=y
CONFIG_SPIRAM_SPEED_200M=y
CONFIG_SPIRAM_USE_MALLOC=y
CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096

# --- CPU --------------------------------------------------------------------
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_400=y
CONFIG_FREERTOS_HZ=1000

# --- Display / LVGL port ----------------------------------------------------
# Draw buffers in PSRAM, DMA-capable, bounce buffer off (MIPI-DSI does its own).
CONFIG_BSP_DISPLAY_LVGL_BUFFER_IN_PSRAM=y
CONFIG_BSP_DISPLAY_LVGL_AVOID_TEAR=y
CONFIG_BSP_DISPLAY_LVGL_FULL_REFRESH=y
CONFIG_BSP_LCD_DPI_BUFFER_NUMS=2

# --- LVGL 9 -----------------------------------------------------------------
CONFIG_LV_COLOR_DEPTH_16=y
CONFIG_LV_USE_PERF_MONITOR=n
CONFIG_LV_DEF_REFR_PERIOD=16
CONFIG_LV_MEM_SIZE_KILOBYTES=512
CONFIG_LV_USE_OS=4

# Fonts used by MATCH//OS. Montserrat 48 is the largest built-in — the
# Chrono-Deck clock scales it up, or drop in a custom 96px font later.
CONFIG_LV_FONT_MONTSERRAT_10=y
CONFIG_LV_FONT_MONTSERRAT_12=y
CONFIG_LV_FONT_MONTSERRAT_14=y
CONFIG_LV_FONT_MONTSERRAT_16=y
CONFIG_LV_FONT_MONTSERRAT_20=y
CONFIG_LV_FONT_MONTSERRAT_24=y
CONFIG_LV_FONT_MONTSERRAT_28=y
CONFIG_LV_FONT_MONTSERRAT_48=y
CONFIG_LV_FONT_DEFAULT_MONTSERRAT_14=y

# Image decoders — Match Studio (App 14) needs these.
CONFIG_LV_USE_LODEPNG=y
CONFIG_LV_USE_TJPGD=y

# --- WiFi (via ESP32-C6 over SDIO/ESP-HOSTED) -------------------------------
CONFIG_ESP_WIFI_REMOTE_ENABLED=y

# --- FATFS for the microSD card ---------------------------------------------
CONFIG_FATFS_LFN_HEAP=y
CONFIG_FATFS_MAX_LFN=255
CONFIG_FATFS_CODEPAGE_437=y

# --- Logging ----------------------------------------------------------------
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y

# --- Main task --------------------------------------------------------------
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
```

### `partitions.csv`

```csv
# Name,     Type, SubType,  Offset,   Size,  Flags
# 16MB flash. Two 6MB OTA slots so Match Works (App 13) can show the running
# image and do OTA without a cable. NVS holds WiFi creds + per-app settings.
nvs,         data, nvs,      0x9000,   0x6000,
phy_init,    data, phy,      0xf000,   0x1000,
otadata,     data, ota,      0x10000,  0x2000,
ota_0,       app,  ota_0,    0x20000,  0x600000,
ota_1,       app,  ota_1,    0x620000, 0x600000,
storage,     data, spiffs,   0xC20000, 0x3D0000,
```

### `main/CMakeLists.txt`

```cmake
idf_component_register(
    SRCS
        "main.c"
        "mos_theme.c"
        "mos_apps.c"
        "mos_nav.c"
        "mos_launcher.c"
        "mos_detail.c"
        "mos_telemetry.c"
        "mos_content.c"
        "app_chrono.c"
        "app_matchos.c"
        "app_stub.c"
        "mos_egg.c"
    INCLUDE_DIRS "."
    REQUIRES
        esp_timer
        esp_app_format
        app_update
        esp_wifi
        nvs_flash
        json
        fatfs
)
```

### `main/idf_component.yml`

```yaml
## MATCH//OS component manifest
## The BSP pulls in the display (ILI9881C / ST7123), touch (GT911 / ST7123),
## audio codecs (ES8388 + ES7210), camera (SC202CS), SD card and the LVGL port.
dependencies:
  idf:
    version: ">=5.4"

  # Board support package for the Tab5. Covers both hardware revisions:
  # early boards use ILI9881C + GT911, later boards use ST7123 for both
  # display and touch. The BSP autodetects — do not hardcode a panel driver.
  espressif/m5stack_tab5:
    version: "^1.2.0"

  # LVGL 9. The BSP depends on esp_lvgl_port ^2 which in turn wants lvgl ^9.
  lvgl/lvgl:
    version: "^9.2.0"

  # Audio codec abstraction, used later by App 02 (voice) and App 12 (timers).
  espressif/esp_codec_dev:
    version: "~1.5"
```

### `main/main.c`

```c
/*
 * MATCH//OS  —  M5Stack Tab5 (ESP32-P4)
 *
 * Boot order matters here:
 *   1. NVS            (WiFi creds, per-app settings)
 *   2. BSP I2C        (touch, IMU, RTC, INA226 all hang off this bus)
 *   3. BSP display    (brings up MIPI-DSI, the panel, touch, and LVGL)
 *   4. SD card        (content engine for apps 11..15 needs it)
 *   5. Time           (RTC first, SNTP later once WiFi is up)
 *   6. UI
 *
 * Every LVGL call after bsp_display_start() must be wrapped in
 * bsp_display_lock()/bsp_display_unlock() unless it is already running on the
 * LVGL task. Getting this wrong produces corruption that looks like a driver
 * bug — it is not.
 */
#include <stdio.h>
#include <time.h>
#include <sys/time.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs_flash.h"
#include "esp_log.h"
#include "esp_err.h"

#include "bsp/esp-bsp.h"
#include "lvgl.h"

#include "mos_theme.h"
#include "mos_nav.h"
#include "mos_apps.h"
#include "mos_content.h"
#include "mos_telemetry.h"
#include "apps.h"

static const char *TAG = "MATCH_OS";

/* Singapore. Change if you move. */
#define MOS_TZ "SGT-8"

static void init_nvs(void)
{
    esp_err_t err = nvs_flash_init();
    if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        err = nvs_flash_init();
    }
    ESP_ERROR_CHECK(err);
}

static void init_time(void)
{
    setenv("TZ", MOS_TZ, 1);
    tzset();

    /* The RX8130CE keeps time across power cycles. The BSP exposes it on the
     * shared I2C bus; until you wire the driver, seed a sane value so the
     * Chrono-Deck does not show 1970 on first boot.
     * TODO: read RX8130CE registers 0x10..0x16 here.
     * TODO: start SNTP once WiFi associates and write the result back to RTC. */
    struct timeval tv = { .tv_sec = 1767225600, .tv_usec = 0 }; /* 2026-01-01 */
    time_t now = time(NULL);
    if (now < 1600000000) settimeofday(&tv, NULL);
}

static void init_sdcard(void)
{
    esp_err_t err = bsp_sdcard_mount();
    if (err == ESP_OK) {
        mos_content_set_available(true);
        ESP_LOGI(TAG, "microSD mounted at %s", BSP_SD_MOUNT_POINT);
    } else {
        mos_content_set_available(false);
        ESP_LOGW(TAG, "no microSD (%s) — apps 11..15 will show the empty state",
                 esp_err_to_name(err));
    }
}

void app_main(void)
{
    ESP_LOGI(TAG, "MATCH//OS booting on ESP32-P4");

    init_nvs();

    /* Shared I2C: GT911/ST7123 touch, BMI270 IMU, RX8130CE RTC, INA226. */
    ESP_ERROR_CHECK(bsp_i2c_init());

    /* Brings up MIPI-DSI, the panel (ILI9881C or ST7123 depending on board
     * revision — the BSP detects it), touch, and the LVGL port task.
     * Double-buffered in PSRAM; see sdkconfig.defaults. */
    bsp_display_cfg_t cfg = {
        .lvgl_port_cfg = ESP_LVGL_PORT_INIT_CONFIG(),
        .buffer_size   = BSP_LCD_H_RES * BSP_LCD_V_RES,
        .double_buffer = true,
        .flags = {
            .buff_dma   = false,   /* PSRAM buffers are not DMA-capable */
            .buff_spiram = true,
        }
    };
    lv_display_t *disp = bsp_display_start_with_config(&cfg);
    if (!disp) {
        ESP_LOGE(TAG, "display failed to start — halting");
        return;
    }

    /* Portrait is the native orientation of this panel; set it explicitly so
     * a future landscape mode is a one-line change. */
    lv_display_set_rotation(disp, LV_DISPLAY_ROTATION_0);

    bsp_display_backlight_on();

    init_time();
    init_sdcard();
    mos_telemetry_start();

    /* --- everything below touches LVGL --------------------------------- */
    bsp_display_lock(0);

    mos_theme_init();
    mos_launcher_create();

    bsp_display_unlock();

    ESP_LOGI(TAG, "MATCH//OS ready — %d apps registered", MOS_APP_COUNT);

    /* app_main returns; the LVGL port task and the telemetry timer keep the
     * device alive. Nothing to poll here. */
}
```

### `main/mos_theme.h`

```c
/*
 * MATCH//OS — theme tokens.
 *
 * Every colour and metric used by the UI lives here. Nothing else in the
 * project should contain a raw hex colour or a magic pixel number; if you
 * want to reskin the whole device, this file is the only one you touch.
 */
#pragma once

#include "lvgl.h"

#ifdef __cplusplus
extern "C" {
#endif

/* ---- palette (matches the approved cyberpunk mockup) -------------------- */
#define MOS_C_BG        lv_color_hex(0x05070d)
#define MOS_C_PANEL     lv_color_hex(0x0a1018)
#define MOS_C_PANEL2    lv_color_hex(0x0d1622)
#define MOS_C_LINE      lv_color_hex(0x16283a)
#define MOS_C_CYAN      lv_color_hex(0x00e5ff)
#define MOS_C_MAGENTA   lv_color_hex(0xff2d95)
#define MOS_C_AMBER     lv_color_hex(0xffb300)
#define MOS_C_GREEN     lv_color_hex(0x22ff88)
#define MOS_C_VIOLET    lv_color_hex(0x9d5cff)
#define MOS_C_TXT       lv_color_hex(0xcfe9f5)
#define MOS_C_TXT_HI    lv_color_hex(0xeaf8ff)
#define MOS_C_DIM       lv_color_hex(0x5b7488)
#define MOS_C_DIMMER    lv_color_hex(0x2f5266)

/* ---- geometry (portrait 720x1280) -------------------------------------- */
#define MOS_SCREEN_W        720
#define MOS_SCREEN_H        1280
#define MOS_STATUSBAR_H     58
#define MOS_DOCK_H          74
#define MOS_PAD             20
#define MOS_GAP             12
#define MOS_TILE_COLS       3
#define MOS_TILE_W          ((MOS_SCREEN_W - (2 * MOS_PAD) - ((MOS_TILE_COLS - 1) * MOS_GAP)) / MOS_TILE_COLS)
#define MOS_TILE_H          152
#define MOS_RADIUS          13
#define MOS_RADIUS_LG       16

/* ---- fonts -------------------------------------------------------------- */
#define MOS_F_MICRO     (&lv_font_montserrat_10)
#define MOS_F_TINY      (&lv_font_montserrat_12)
#define MOS_F_BODY      (&lv_font_montserrat_14)
#define MOS_F_MED       (&lv_font_montserrat_16)
#define MOS_F_LG        (&lv_font_montserrat_20)
#define MOS_F_XL        (&lv_font_montserrat_28)
#define MOS_F_CLOCK     (&lv_font_montserrat_48)

/* ---- shared style objects ---------------------------------------------- */
/* Call once, after lv_init() / bsp_display_start(). */
void mos_theme_init(void);

/* A bare container with no padding, no scrollbar, transparent background —
 * the building block for every layout in the project. */
lv_obj_t *mos_box(lv_obj_t *parent);

/* A bordered card. `accent` tints the top rule and the corner glow; pass
 * lv_color_black() for a neutral card. */
lv_obj_t *mos_card(lv_obj_t *parent, lv_color_t accent);

/* Section header: dim letter-spaced label with a fading rule to its right. */
lv_obj_t *mos_section_header(lv_obj_t *parent, const char *text, const char *right);

/* Small pill / chip used for spec tags. */
lv_obj_t *mos_pill(lv_obj_t *parent, const char *text);

/* Standard button. `primary` gives it the cyan/magenta gradient treatment. */
lv_obj_t *mos_button(lv_obj_t *parent, const char *text, bool primary);

/* Horizontal rule. */
lv_obj_t *mos_rule(lv_obj_t *parent);

#ifdef __cplusplus
}
#endif
```

### `main/mos_theme.c`

```c
#include "mos_theme.h"

static lv_style_t s_box;
static lv_style_t s_card;
static lv_style_t s_pill;
static lv_style_t s_btn;
static lv_style_t s_btn_pr;
static bool s_ready = false;

void mos_theme_init(void)
{
    if (s_ready) return;
    s_ready = true;

    /* --- transparent, padding-free container ---------------------------- */
    lv_style_init(&s_box);
    lv_style_set_bg_opa(&s_box, LV_OPA_TRANSP);
    lv_style_set_border_width(&s_box, 0);
    lv_style_set_pad_all(&s_box, 0);
    lv_style_set_pad_row(&s_box, 0);
    lv_style_set_pad_column(&s_box, 0);
    lv_style_set_radius(&s_box, 0);
    lv_style_set_text_color(&s_box, MOS_C_TXT);

    /* --- card ------------------------------------------------------------ */
    lv_style_init(&s_card);
    lv_style_set_bg_color(&s_card, MOS_C_PANEL2);
    lv_style_set_bg_grad_color(&s_card, MOS_C_PANEL);
    lv_style_set_bg_grad_dir(&s_card, LV_GRAD_DIR_VER);
    lv_style_set_bg_opa(&s_card, LV_OPA_COVER);
    lv_style_set_border_color(&s_card, MOS_C_LINE);
    lv_style_set_border_width(&s_card, 1);
    lv_style_set_radius(&s_card, MOS_RADIUS);
    lv_style_set_pad_all(&s_card, 14);
    lv_style_set_text_color(&s_card, MOS_C_TXT);

    /* --- pill ------------------------------------------------------------ */
    lv_style_init(&s_pill);
    lv_style_set_bg_color(&s_pill, lv_color_hex(0x08121c));
    lv_style_set_bg_opa(&s_pill, LV_OPA_COVER);
    lv_style_set_border_color(&s_pill, MOS_C_LINE);
    lv_style_set_border_width(&s_pill, 1);
    lv_style_set_radius(&s_pill, 7);
    lv_style_set_pad_hor(&s_pill, 10);
    lv_style_set_pad_ver(&s_pill, 5);
    lv_style_set_text_font(&s_pill, MOS_F_MICRO);
    lv_style_set_text_color(&s_pill, lv_color_hex(0x83b4cb));

    /* --- buttons --------------------------------------------------------- */
    lv_style_init(&s_btn);
    lv_style_set_bg_color(&s_btn, lv_color_hex(0x0a141f));
    lv_style_set_bg_opa(&s_btn, LV_OPA_COVER);
    lv_style_set_border_color(&s_btn, MOS_C_LINE);
    lv_style_set_border_width(&s_btn, 1);
    lv_style_set_radius(&s_btn, 9);
    lv_style_set_pad_hor(&s_btn, 17);
    lv_style_set_pad_ver(&s_btn, 12);
    lv_style_set_text_font(&s_btn, MOS_F_TINY);
    lv_style_set_text_color(&s_btn, MOS_C_TXT);
    lv_style_set_shadow_width(&s_btn, 0);

    lv_style_init(&s_btn_pr);
    lv_style_set_bg_color(&s_btn_pr, lv_color_hex(0x0d2c3a));
    lv_style_set_bg_grad_color(&s_btn_pr, lv_color_hex(0x3a1030));
    lv_style_set_bg_grad_dir(&s_btn_pr, LV_GRAD_DIR_HOR);
    lv_style_set_border_color(&s_btn_pr, MOS_C_CYAN);
    lv_style_set_text_color(&s_btn_pr, MOS_C_TXT_HI);
    lv_style_set_shadow_color(&s_btn_pr, MOS_C_CYAN);
    lv_style_set_shadow_width(&s_btn_pr, 18);
    lv_style_set_shadow_opa(&s_btn_pr, LV_OPA_40);
    lv_style_set_shadow_spread(&s_btn_pr, 0);
}

lv_obj_t *mos_box(lv_obj_t *parent)
{
    lv_obj_t *o = lv_obj_create(parent);
    lv_obj_remove_style_all(o);
    lv_obj_add_style(o, &s_box, 0);
    lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE);
    return o;
}

lv_obj_t *mos_card(lv_obj_t *parent, lv_color_t accent)
{
    lv_obj_t *o = lv_obj_create(parent);
    lv_obj_remove_style_all(o);
    lv_obj_add_style(o, &s_card, 0);
    lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE);

    /* Accent rule across the top — the signature of the mockup. LVGL has no
     * ::before, so it is a real 2px child pinned to the top edge. */
    if (accent.red || accent.green || accent.blue) {
        lv_obj_t *rule = lv_obj_create(o);
        lv_obj_remove_style_all(rule);
        lv_obj_set_size(rule, LV_PCT(100), 2);
        lv_obj_set_style_bg_color(rule, accent, 0);
        lv_obj_set_style_bg_opa(rule, LV_OPA_COVER, 0);
        lv_obj_set_style_shadow_color(rule, accent, 0);
        lv_obj_set_style_shadow_width(rule, 14, 0);
        lv_obj_set_style_shadow_opa(rule, LV_OPA_70, 0);
        lv_obj_align(rule, LV_ALIGN_TOP_MID, 0, -14); /* cancel card padding */
        lv_obj_add_flag(rule, LV_OBJ_FLAG_IGNORE_LAYOUT);
    }
    return o;
}

lv_obj_t *mos_rule(lv_obj_t *parent)
{
    lv_obj_t *r = lv_obj_create(parent);
    lv_obj_remove_style_all(r);
    lv_obj_set_size(r, LV_PCT(100), 1);
    lv_obj_set_style_bg_color(r, MOS_C_LINE, 0);
    lv_obj_set_style_bg_opa(r, LV_OPA_COVER, 0);
    return r;
}

lv_obj_t *mos_section_header(lv_obj_t *parent, const char *text, const char *right)
{
    lv_obj_t *row = mos_box(parent);
    lv_obj_set_width(row, LV_PCT(100));
    lv_obj_set_height(row, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
    lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER);
    lv_obj_set_style_pad_column(row, 10, 0);

    lv_obj_t *l = lv_label_create(row);
    lv_label_set_text(l, text);
    lv_obj_set_style_text_font(l, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(l, MOS_C_DIM, 0);
    lv_obj_set_style_text_letter_space(l, 3, 0);

    lv_obj_t *line = lv_obj_create(row);
    lv_obj_remove_style_all(line);
    lv_obj_set_height(line, 1);
    lv_obj_set_flex_grow(line, 1);
    lv_obj_set_style_bg_color(line, MOS_C_LINE, 0);
    lv_obj_set_style_bg_opa(line, LV_OPA_COVER, 0);

    if (right) {
        lv_obj_t *r = lv_label_create(row);
        lv_label_set_text(r, right);
        lv_obj_set_style_text_font(r, MOS_F_MICRO, 0);
        lv_obj_set_style_text_color(r, MOS_C_DIMMER, 0);
    }
    return row;
}

lv_obj_t *mos_pill(lv_obj_t *parent, const char *text)
{
    lv_obj_t *p = lv_obj_create(parent);
    lv_obj_remove_style_all(p);
    lv_obj_add_style(p, &s_pill, 0);
    lv_obj_set_size(p, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
    lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_t *l = lv_label_create(p);
    lv_label_set_text(l, text);
    lv_obj_center(l);
    return p;
}

lv_obj_t *mos_button(lv_obj_t *parent, const char *text, bool primary)
{
    lv_obj_t *b = lv_button_create(parent);
    lv_obj_remove_style_all(b);
    lv_obj_add_style(b, &s_btn, 0);
    if (primary) lv_obj_add_style(b, &s_btn_pr, 0);
    lv_obj_set_height(b, LV_SIZE_CONTENT);

    lv_obj_t *l = lv_label_create(b);
    lv_label_set_text(l, text);
    lv_obj_set_style_text_letter_space(l, 2, 0);
    lv_obj_center(l);
    return b;
}
```

### `main/mos_apps.h`

```c
/*
 * MATCH//OS — app registry.
 *
 * Every app on the device is one row in a static table. The launcher, the
 * detail sheet and the quick-launch strip all read from this table, so adding
 * app 17 means adding one row and one launch function — nothing else changes.
 *
 * Apps 11..15 deliberately share a single launch function (app_matchos_open)
 * and differ only by `content_dir`. That is the consolidation decision: one
 * content engine reading JSON off the SD card, five tiles on the launcher.
 */
#pragma once

#include <stdbool.h>
#include "lvgl.h"

#ifdef __cplusplus
extern "C" {
#endif

typedef enum {
    MOS_GROUP_LAB = 0,      /* 01..10  hardware lab            */
    MOS_GROUP_LIFE,         /* 11..15  Match OS personal       */
    MOS_GROUP_META,         /* 16      metaphysics             */
    MOS_GROUP_COUNT
} mos_group_t;

typedef enum {
    MOS_STATUS_BUILD_FIRST = 0,
    MOS_STATUS_CORE,
    MOS_STATUS_PLANNED,
    MOS_STATUS_STRETCH,
    MOS_STATUS_PERSONAL,
} mos_status_t;

typedef struct mos_app_s {
    uint8_t         id;             /* 1..16, shown on the tile             */
    mos_group_t     group;
    const char     *name;           /* tile title                           */
    const char     *subtitle;       /* two-line tile blurb                  */
    const char     *tag;            /* category line on the detail sheet    */
    const char     *desc;           /* paragraph on the detail sheet        */
    const char    **features;       /* NULL-terminated bullet list          */
    const char    **hardware;       /* NULL-terminated pill list            */
    const char    **software;       /* NULL-terminated pill list            */
    const char     *symbol;         /* LVGL built-in symbol for the icon    */
    uint32_t        accent;         /* 0xRRGGBB                             */
    mos_status_t    status;
    uint8_t         effort;         /* 1..5                                 */
    const char     *content_dir;    /* SD path for content-engine apps      */

    /* Opens the app. Receives the app row so shared implementations
     * (app_matchos_open) can tell which tile launched them. */
    void          (*open)(const struct mos_app_s *self);
} mos_app_t;

#define MOS_APP_COUNT 16

extern const mos_app_t MOS_APPS[MOS_APP_COUNT];

const mos_app_t *mos_app_by_id(uint8_t id);
const char      *mos_status_str(mos_status_t s);
lv_color_t       mos_app_color(const mos_app_t *a);
const char      *mos_group_title(mos_group_t g);
const char      *mos_group_range(mos_group_t g);

/* ---- build queue (carried over from the mockup) ------------------------- */
void     mos_queue_toggle(uint8_t id);
bool     mos_queue_has(uint8_t id);
uint8_t  mos_queue_count(void);
void     mos_queue_clear(void);

#ifdef __cplusplus
}
#endif
```

### `main/mos_apps.c`

```c
#include <string.h>
#include "mos_apps.h"
#include "apps.h"

/* ------------------------------------------------------------------------ */
/* bullet / pill lists                                                       */
/* ------------------------------------------------------------------------ */
#define L(name, ...) static const char *name[] = { __VA_ARGS__, NULL }

L(f01, "Room tabs: Living / Bedroom / Kitchen / Study",
       "Dimmer sliders, colour wheel, aircon setpoint dial",
       "Scene buttons with press feedback",
       "Live sensor strip: temp, humidity, door/window, energy",
       "MQTT auto-discovery of new entities",
       "Dims to a clock after 30s, wakes on IMU shake");
L(h01, "5in touch", "WiFi6 via C6", "BMI270 wake", "USB-C 5V always-on");
L(s01, "ESP-IDF 5.4", "LVGL 9", "esp-mqtt", "HA WebSocket API");

L(f02, "Custom wake word via esp-sr",
       "Barge-in: interrupt the reply mid-sentence",
       "Waveform + subtitle UI while listening",
       "Local intents resolved on-device",
       "Cloud LLM fallback for open questions",
       "Doubles as a desk speaker");
L(h02, "Dual mic array", "ES7210 AEC", "ES8388 codec", "NS4150B 1W spk", "3.5mm out");
L(s02, "esp-sr wake word", "ESP-ADF pipeline", "Streaming STT/LLM", "TTS playback");

L(f03, "Face enrolment + recognition, stored on microSD",
       "Live bounding boxes at ~10-15 fps",
       "Person-present detection to wake other apps",
       "Snapshot log viewable in Match Studio",
       "MQTT event out to Home Assistant on match");
L(h03, "SC202CS 2MP MIPI-CSI", "32MB PSRAM", "P4 dual-core 400MHz");
L(s03, "esp-dl", "ESP-WHO models", "esp_video ~2.0", "SD enrolment store");

L(f04, "Glowing digital clock with magenta seconds",
       "Day-progress bar across 24h",
       "Live weather + 5-day forecast panel",
       "CPU / PSRAM / SoC-temp / battery gauges",
       "Mic spectrum bar as ambient visualiser",
       "Scrolling status ticker for HA + build alerts");
L(h04, "RX8130CE RTC", "INA226 power monitor", "WiFi6 for weather sync");
L(s04, "LVGL 9 custom widgets", "SNTP", "Weather API", "1Hz FreeRTOS timer");

L(f05, "Tag browser: read/write coils and holding registers",
       "Alarm banner with acknowledge",
       "Rolling trend chart per tag",
       "Recipe/setpoint pages saved to microSD",
       "Operator lock with PIN",
       "CSV logging to SD");
L(h05, "SIT3088 RS485", "120ohm term switch", "M5-Bus", "GPIO_EXT", "DC 12-24V in");
L(s05, "Modbus RTU master", "LVGL charts", "SD data logger", "CSV export");

L(f06, "Channel occupancy heatmap across 2.4GHz",
       "AP list: SSID, BSSID, RSSI, band, security",
       "Rogue-AP watch on your own SSID",
       "LAN inventory via mDNS + ARP",
       "Signal-strength walk mode",
       "Timestamped SD logs");
L(h06, "ESP32-C6 WiFi6 radio", "2x MMCX ext antenna", "Battery + SD log");
L(s06, "esp_wifi scan API", "mDNS/ARP sweep", "SD log writer");

L(f07, "Live node map colour-coded per protocol",
       "Message-rate sparklines",
       "Rule builder across protocols",
       "Bridges everything to one MQTT topic tree",
       "Offline buffer to SD when uplink drops");
L(h07, "ESP32-C6 Thread/Zigbee", "STAMP pads for LoRa/LTE", "RS485", "HY2.0-4P");
L(s07, "OpenThread", "Zigbee ZCL", "esp-mqtt bridge", "Rules engine");

L(f08, "16-step x 8-track grid, portrait fits it exactly",
       "Wavetable synth + SD samples",
       "Per-step velocity, probability, micro-timing",
       "Filter/delay/reverb on an X-Y pad",
       "USB-MIDI in/out over Type-C OTG",
       "Pattern chain + song mode");
L(h08, "ES8388 codec", "3.5mm out", "1W speaker", "32MB PSRAM sample RAM");
L(s08, "I2S DMA engine", "Wavetable voices", "USB-MIDI (TinyUSB)");

L(f09, "On-screen serial console and shell",
       "Core-1 / LP-core task pinning demos",
       "PSRAM bandwidth and cache benchmarks",
       "Load ELF blobs off the microSD",
       "Register/memory inspector with live watch",
       "Groundwork for a lightweight Linux userland");
L(h09, "ESP32-P4 RV32 dual-core", "LP core 40MHz", "32MB PSRAM", "16MB flash");
L(s09, "Bare-metal RV32", "FreeRTOS SMP", "ELF loader", "On-screen shell");

L(f10, "Live preview with grid, level and histogram",
       "Timelapse / intervalometer to SD",
       "MJPEG stream over WiFi",
       "Motion-triggered capture",
       "Battery runtime estimate from the INA226",
       "Tripod-friendly landscape layout");
L(h10, "NP-F550 7.4V 2000mAh", "2MP MIPI cam", "microSD", "BMI270 level");
L(s10, "MJPEG WiFi stream", "SD ring buffer", "Timelapse scheduler");

/* --- Match OS content-engine apps (11..15) ------------------------------- */
L(f11, "Place picker first, then day-by-day timeline",
       "Times, transport and notes per day",
       "Booking refs and addresses cached offline",
       "Packing checklist that persists between trips",
       "Countdown to the next trip on the home tile",
       "Trip photos hand off to Match Studio");
L(h11, "microSD store", "WiFi sync", "5in portrait reads like a page");
L(s11, "Match OS content engine", "JSON schema v1", "cJSON", "LVGL list+detail");

L(f12, "Browse by cuisine, protein, or time-to-table",
       "Cook mode: one step per screen, huge type",
       "Voice 'next' using the App 02 mic stack",
       "Multiple named timers with audio alerts",
       "Ingredient scaling for 2 / 4 / 6",
       "Shopping list from selected recipes");
L(h12, "1W speaker for timers", "Dual mic for voice-next", "Wipeable glass front");
L(s12, "Match OS content engine", "Shared voice engine", "Multi-timer service");

L(f13, "Project cards: Tab5 Build / Home Automation / Bench",
       "Per-project task list with status and dates",
       "Running firmware version, build date, free heap",
       "Wiring and pinout notes with sketches from SD",
       "Parts-on-order list with arrival dates",
       "Debug drawer for RISC-V experiments");
L(h13, "Self-telemetry", "SD notes store", "USB-C flash logs");
L(s13, "Match OS content engine", "esp_ota_get_running_partition", "esp_app_desc");

L(f14, "Album grid, swipe-through full-screen viewer",
       "Slideshow becomes the idle screen when docked",
       "Auto-rotate via the BMI270",
       "Favourites album by long-press",
       "Pulls new photos over WiFi",
       "Shows captures from Apps 03 and 10");
L(h14, "5in 1280x720 IPS", "microSD bulk store", "32MB PSRAM decode buffer");
L(s14, "Match OS content engine", "LodePNG + TJpgDec", "LVGL image roller");

L(f15, "Home overview: rooms, temps, energy today",
       "Plant cards with soil moisture and last-watered",
       "7-day moisture trend per plant",
       "'Needs water today' badge on the home tile",
       "Grow-light schedule control",
       "Photo log showing growth over weeks");
L(h15, "HY2.0-4P soil sensors", "WiFi6 to HA", "RS485 for long runs");
L(s15, "Match OS content engine", "MQTT subscribe", "LVGL charts");

L(f16, "Luopan compass ring from the BMI270",
       "Flying-star overlay for the current year and month",
       "Zi Wei Dou Shu chart from birth date/time",
       "Liu Yao: cast six lines, changing lines resolved",
       "Chinese lunar calendar with solar terms",
       "Every reading logged to SD with the question");
L(h16, "BMI270 6-axis", "RX8130CE RTC", "5in portrait chart view");
L(s16, "Lunar/solar calendar lib", "Zi Wei chart engine", "Liu Yao caster", "SD log");

/* ------------------------------------------------------------------------ */
/* the table                                                                 */
/* ------------------------------------------------------------------------ */
const mos_app_t MOS_APPS[MOS_APP_COUNT] = {
 { 1, MOS_GROUP_LAB, "HA Room Controller",
   "Wall-mount panel for lights, aircon, curtains, scenes", "HOME AUTOMATION",
   "Turns the Tab5 into a dedicated wall or desk panel for Home Assistant. One "
   "screen per room, physical-feeling controls, and it never sleeps into a boot "
   "screen - instant wake on touch or motion.",
   f01, h01, s01, LV_SYMBOL_HOME, 0x00e5ff, MOS_STATUS_CORE, 2, NULL, app_stub_open },

 { 2, MOS_GROUP_LAB, "AI Voice Assistant",
   "Wake-word, dual-mic AEC, speaks back over the 1W driver", "MULTIMEDIA / AI",
   "A desk assistant that hears you across the room. The ES7210 front end does "
   "acoustic echo cancellation so it can listen while the speaker is playing - "
   "the hard part most ESP32 builds skip.",
   f02, h02, s02, LV_SYMBOL_AUDIO, 0xff2d95, MOS_STATUS_CORE, 3, NULL, app_stub_open },

 { 3, MOS_GROUP_LAB, "Edge AI Vision Terminal",
   "Face ID + object detection on the 2MP MIPI camera", "EDGE AI",
   "Face recognition and object detection running entirely on the P4 - no images "
   "leave the device. Good as a door terminal, a desk presence sensor, or a parts "
   "counter in the workshop.",
   f03, h03, s03, LV_SYMBOL_EYE_OPEN, 0x22ff88, MOS_STATUS_STRETCH, 4, NULL, app_stub_open },

 { 4, MOS_GROUP_LAB, "Chrono-Deck Clock",
   "Cyberpunk clock, weather, forecast, system telemetry", "ALWAYS-ON DISPLAY",
   "The default face of the whole device. When nothing else is running, this is "
   "what the Tab5 shows. Deliberately the lightest app to build, so it is the one "
   "that proves the LVGL pipeline end to end.",
   f04, h04, s04, LV_SYMBOL_REFRESH, 0xffb300, MOS_STATUS_BUILD_FIRST, 1, NULL, app_chrono_open },

 { 5, MOS_GROUP_LAB, "Industrial HMI Panel",
   "RS485 / Modbus operator terminal with alarms", "INDUSTRIAL",
   "The use case M5 actually built the Tab5 for - RS485 with switchable "
   "termination, DC-in, and a rugged 5in panel. Modbus RTU master, tag list, "
   "alarm banner, trend charts.",
   f05, h05, s05, LV_SYMBOL_SETTINGS, 0x00e5ff, MOS_STATUS_PLANNED, 3, NULL, app_stub_open },

 { 6, MOS_GROUP_LAB, "Network Security Auditor",
   "WiFi survey, packet stats, device inventory - defensive", "BLUE TEAM",
   "A pocket tool for auditing your OWN network: what is broadcasting, on what "
   "channel, how congested the band is, and whether anything new joined. "
   "Read-only and passive by design.",
   f06, h06, s06, LV_SYMBOL_WIFI, 0xff2d95, MOS_STATUS_PLANNED, 3, NULL, app_stub_open },

 { 7, MOS_GROUP_LAB, "Multi-Protocol Gateway",
   "WiFi6 + Thread + Zigbee + RS485 + LoRa bridge", "IOT GATEWAY",
   "The C6 speaks WiFi 6, Thread and Zigbee; the STAMP pads take a LoRa or "
   "cellular module; RS485 handles the industrial side. One box that bridges all "
   "of it to MQTT, with a screen that shows the traffic.",
   f07, h07, s07, LV_SYMBOL_SHUFFLE, 0x9d5cff, MOS_STATUS_PLANNED, 4, NULL, app_stub_open },

 { 8, MOS_GROUP_LAB, "Audio Sequencer & Synth",
   "Touch grid step sequencer with on-board synth engine", "MUSIC",
   "A groovebox. 720x1280 portrait is almost exactly the right shape for a "
   "16-step x 8-track grid, and the ES8388 codec plus headphone jack means it "
   "actually sounds decent.",
   f08, h08, s08, LV_SYMBOL_PLAY, 0x22ff88, MOS_STATUS_STRETCH, 4, NULL, app_stub_open },

 { 9, MOS_GROUP_LAB, "RISC-V Firmware Playground",
   "Bare-metal experiments, dual-core, LP core, Linux-lite", "DEEP DIVE",
   "The P4 is a proper RISC-V dual-core with an LP core. This is the sandbox: "
   "benchmarks, register pokes, and eventually a minimal on-screen shell. "
   "Consider folding it into Match Works as a debug drawer.",
   f09, h09, s09, LV_SYMBOL_LIST, 0x00e5ff, MOS_STATUS_STRETCH, 5, NULL, app_stub_open },

 { 10, MOS_GROUP_LAB, "Camera Rig / FPV Monitor",
   "Field monitor, intervalometer, and WiFi video out", "FIELD KIT",
   "Runs off the NP-F550 - the same battery an actual camera field monitor uses. "
   "Live preview, timelapse capture to SD, and a WiFi stream so a phone can see "
   "what it sees.",
   f10, h10, s10, LV_SYMBOL_IMAGE, 0xffb300, MOS_STATUS_PLANNED, 3, NULL, app_stub_open },

 /* ---- content-engine apps: same open(), different content_dir ----------- */
 { 11, MOS_GROUP_LIFE, "Match Journey",
   "Trips and itineraries, browsable by place", "MATCH OS / TRAVEL",
   "Your travel binder on the wall. Pick a place, get the day-by-day plan - "
   "flights, stays, bookings, what to eat - all readable offline because it "
   "lives on the SD card.",
   f11, h11, s11, LV_SYMBOL_GPS, 0x00e5ff, MOS_STATUS_PERSONAL, 2,
   "/sdcard/matchos/journey", app_matchos_open },

 { 12, MOS_GROUP_LIFE, "Match Kitchen",
   "Recipe box with hands-free step mode and timers", "MATCH OS / FOOD",
   "A recipe screen that survives greasy hands. Big type, one step at a time, "
   "voice-advance using the same mic stack as the assistant app, and timers that "
   "shout from the speaker.",
   f12, h12, s12, LV_SYMBOL_BELL, 0xff2d95, MOS_STATUS_PERSONAL, 2,
   "/sdcard/matchos/kitchen", app_matchos_open },

 { 13, MOS_GROUP_LIFE, "Match Works",
   "What you are building now - this Tab5 project, tracked", "MATCH OS / PROJECTS",
   "The meta app: the Tab5 tracking its own build. Firmware versions, wiring "
   "notes, what is flashed, what is broken, and the running task list for every "
   "automation project on the bench.",
   f13, h13, s13, LV_SYMBOL_EDIT, 0xffb300, MOS_STATUS_PERSONAL, 2,
   "/sdcard/matchos/works", app_matchos_open },

 { 14, MOS_GROUP_LIFE, "Match Studio",
   "Photo gallery and always-on digital frame", "MATCH OS / PHOTOS",
   "720x1280 IPS is a genuinely nice little frame. Albums off the SD card or "
   "pulled over WiFi, with a slideshow mode that takes over whenever the device "
   "is idle and docked.",
   f14, h14, s14, LV_SYMBOL_IMAGE, 0x9d5cff, MOS_STATUS_PERSONAL, 2,
   "/sdcard/matchos/studio", app_matchos_open },

 { 15, MOS_GROUP_LIFE, "Match Living",
   "Home overview and the plant project dashboard", "MATCH OS / HOME",
   "The calm view of the house - not the control panel, the status panel. Rooms, "
   "energy, and specifically the plants: soil moisture, light hours, watering "
   "history and what needs attention today.",
   f15, h15, s15, LV_SYMBOL_CHARGE, 0x22ff88, MOS_STATUS_PERSONAL, 3,
   "/sdcard/matchos/living", app_matchos_open },

 { 16, MOS_GROUP_META, "Feng Shui / Zi Wei / Liu Yao",
   "Compass, chart plotting and hexagram casting", "MATCH OS / METAPHYSICS",
   "Three tools in one app. The BMI270 gives a real luopan-style compass; the RTC "
   "gives accurate time for chart plotting; and the touch screen handles hexagram "
   "casting cleanly.",
   f16, h16, s16, LV_SYMBOL_LOOP, 0x9d5cff, MOS_STATUS_PERSONAL, 4, NULL, app_stub_open },
};

/* ------------------------------------------------------------------------ */
const mos_app_t *mos_app_by_id(uint8_t id)
{
    for (int i = 0; i < MOS_APP_COUNT; i++) {
        if (MOS_APPS[i].id == id) return &MOS_APPS[i];
    }
    return NULL;
}

const char *mos_status_str(mos_status_t s)
{
    switch (s) {
        case MOS_STATUS_BUILD_FIRST: return "BUILD FIRST";
        case MOS_STATUS_CORE:        return "CORE";
        case MOS_STATUS_PLANNED:     return "PLANNED";
        case MOS_STATUS_STRETCH:     return "STRETCH";
        case MOS_STATUS_PERSONAL:    return "PERSONAL";
    }
    return "?";
}

lv_color_t mos_app_color(const mos_app_t *a) { return lv_color_hex(a->accent); }

const char *mos_group_title(mos_group_t g)
{
    switch (g) {
        case MOS_GROUP_LAB:  return "LAB / HARDWARE";
        case MOS_GROUP_LIFE: return "MATCH OS / PERSONAL";
        case MOS_GROUP_META: return "METAPHYSICS";
        default:             return "";
    }
}

const char *mos_group_range(mos_group_t g)
{
    switch (g) {
        case MOS_GROUP_LAB:  return "01 - 10";
        case MOS_GROUP_LIFE: return "11 - 15";
        case MOS_GROUP_META: return "16";
        default:             return "";
    }
}

/* ---- build queue -------------------------------------------------------- */
static uint32_t s_queue_mask = 0;   /* bit N-1 set == app N queued */

void mos_queue_toggle(uint8_t id)
{
    if (id < 1 || id > MOS_APP_COUNT) return;
    s_queue_mask ^= (1u << (id - 1));
}
bool     mos_queue_has(uint8_t id) { return (id >= 1 && id <= MOS_APP_COUNT) && (s_queue_mask & (1u << (id - 1))); }
uint8_t  mos_queue_count(void)     { return (uint8_t)__builtin_popcount(s_queue_mask); }
void     mos_queue_clear(void)     { s_queue_mask = 0; }
```

### `main/apps.h`

```c
/*
 * MATCH//OS — app entry points.
 *
 * Each of these is referenced from the MOS_APPS table in mos_apps.c. To add a
 * real implementation for a stubbed app, write app_<name>_open() here and swap
 * the function pointer in the table. Nothing else needs to change.
 */
#pragma once

#include "mos_apps.h"

#ifdef __cplusplus
extern "C" {
#endif

/* App 04 — the always-on face. Fully implemented. */
void app_chrono_open(const mos_app_t *self);

/* Apps 11..15 — one content engine, five tiles.
 * Reads `self->content_dir` off the SD card and renders whatever it finds.  */
void app_matchos_open(const mos_app_t *self);

/* Everything not yet built. Shows the plan for that app plus its build
 * profile, so the device is useful as a spec sheet before the code exists. */
void app_stub_open(const mos_app_t *self);

/* Detail sheet, opened by long-press on any tile. */
void mos_detail_open(const mos_app_t *app);

/* Launcher. */
void mos_launcher_create(void);
void mos_launcher_refresh(void);

#ifdef __cplusplus
}
#endif
```

### `main/mos_nav.h`

```c
/*
 * MATCH//OS — screen navigation.
 *
 * A tiny push/pop stack over lv_screen_load_anim(). Apps never delete their
 * own screen; they call mos_nav_push() to open and mos_nav_pop() to go back,
 * and the stack handles the teardown one frame after the animation ends.
 */
#pragma once

#include "lvgl.h"

#ifdef __cplusplus
extern "C" {
#endif

#define MOS_NAV_DEPTH 6

/* Creates a full-screen object styled with the MATCH//OS background,
 * containing a standard top bar with a back chevron and `title`.
 * Returns the *content area* below the bar — add your widgets to that.
 * `out_screen` receives the screen root, which you pass to mos_nav_push(). */
lv_obj_t *mos_screen_create(const char *title, lv_obj_t **out_screen);

/* Bare screen with no top bar (used by the launcher itself). */
lv_obj_t *mos_screen_create_bare(lv_obj_t **out_screen);

void mos_nav_push(lv_obj_t *screen);
void mos_nav_pop(void);
void mos_nav_set_root(lv_obj_t *screen);   /* launcher; never popped */
uint8_t mos_nav_depth(void);

#ifdef __cplusplus
}
#endif
```

### `main/mos_nav.c`

```c
#include "mos_nav.h"
#include "mos_theme.h"
#include "esp_log.h"

static const char *TAG = "mos_nav";

static lv_obj_t *s_stack[MOS_NAV_DEPTH];
static uint8_t   s_depth = 0;
static lv_obj_t *s_root  = NULL;

#define ANIM_MS 260

static void back_cb(lv_event_t *e)
{
    (void)e;
    mos_nav_pop();
}

/* LVGL deletes the old screen for us when auto_del is true, but only after the
 * animation completes, which is exactly what we want. */
static lv_obj_t *screen_root(void)
{
    lv_obj_t *scr = lv_obj_create(NULL);
    lv_obj_remove_style_all(scr);
    lv_obj_set_size(scr, MOS_SCREEN_W, MOS_SCREEN_H);
    lv_obj_set_style_bg_color(scr, MOS_C_BG, 0);
    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
    lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
    return scr;
}

lv_obj_t *mos_screen_create_bare(lv_obj_t **out_screen)
{
    lv_obj_t *scr = screen_root();
    if (out_screen) *out_screen = scr;
    return scr;
}

lv_obj_t *mos_screen_create(const char *title, lv_obj_t **out_screen)
{
    lv_obj_t *scr = screen_root();
    if (out_screen) *out_screen = scr;

    /* ---- top bar -------------------------------------------------------- */
    lv_obj_t *bar = mos_box(scr);
    lv_obj_set_size(bar, LV_PCT(100), MOS_STATUSBAR_H);
    lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
    lv_obj_set_style_bg_color(bar, lv_color_hex(0x0a1420), 0);
    lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);
    lv_obj_set_style_border_side(bar, LV_BORDER_SIDE_BOTTOM, 0);
    lv_obj_set_style_border_color(bar, MOS_C_LINE, 0);
    lv_obj_set_style_border_width(bar, 1, 0);
    lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW);
    lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
    lv_obj_set_style_pad_hor(bar, 18, 0);
    lv_obj_set_style_pad_column(bar, 12, 0);

    lv_obj_t *back = lv_button_create(bar);
    lv_obj_remove_style_all(back);
    lv_obj_set_size(back, 40, 38);
    lv_obj_set_style_bg_color(back, lv_color_hex(0x0a141f), 0);
    lv_obj_set_style_bg_opa(back, LV_OPA_COVER, 0);
    lv_obj_set_style_border_color(back, MOS_C_LINE, 0);
    lv_obj_set_style_border_width(back, 1, 0);
    lv_obj_set_style_radius(back, 9, 0);
    lv_obj_add_event_cb(back, back_cb, LV_EVENT_CLICKED, NULL);
    lv_obj_t *bl = lv_label_create(back);
    lv_label_set_text(bl, LV_SYMBOL_LEFT);
    lv_obj_set_style_text_color(bl, MOS_C_CYAN, 0);
    lv_obj_center(bl);

    lv_obj_t *tl = lv_label_create(bar);
    lv_label_set_text(tl, title ? title : "");
    lv_label_set_long_mode(tl, LV_LABEL_LONG_DOT);
    lv_obj_set_width(tl, MOS_SCREEN_W - 100);
    lv_obj_set_style_text_font(tl, MOS_F_BODY, 0);
    lv_obj_set_style_text_color(tl, MOS_C_TXT_HI, 0);
    lv_obj_set_style_text_letter_space(tl, 2, 0);

    /* ---- scrollable content --------------------------------------------- */
    lv_obj_t *content = lv_obj_create(scr);
    lv_obj_remove_style_all(content);
    lv_obj_set_size(content, MOS_SCREEN_W, MOS_SCREEN_H - MOS_STATUSBAR_H);
    lv_obj_align(content, LV_ALIGN_TOP_MID, 0, MOS_STATUSBAR_H);
    lv_obj_set_style_pad_all(content, MOS_PAD, 0);
    lv_obj_set_style_pad_row(content, 16, 0);
    lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_scroll_dir(content, LV_DIR_VER);
    lv_obj_set_scrollbar_mode(content, LV_SCROLLBAR_MODE_AUTO);
    lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0);

    return content;
}

void mos_nav_set_root(lv_obj_t *screen)
{
    s_root = screen;
    s_depth = 0;
    lv_screen_load(screen);
}

void mos_nav_push(lv_obj_t *screen)
{
    if (s_depth >= MOS_NAV_DEPTH) {
        ESP_LOGW(TAG, "nav stack full, refusing push");
        lv_obj_delete(screen);
        return;
    }
    s_stack[s_depth++] = screen;
    /* auto_del = false: the screen we are leaving must survive so we can
     * come back to it. Popping deletes explicitly. */
    lv_screen_load_anim(screen, LV_SCR_LOAD_ANIM_MOVE_TOP, ANIM_MS, 0, false);
}

void mos_nav_pop(void)
{
    if (s_depth == 0) return;
    lv_obj_t *leaving = s_stack[--s_depth];
    lv_obj_t *target  = (s_depth > 0) ? s_stack[s_depth - 1] : s_root;
    if (!target) { s_depth++; return; }

    /* auto_del = true deletes `leaving` once the animation finishes. */
    lv_screen_load_anim(target, LV_SCR_LOAD_ANIM_MOVE_BOTTOM, ANIM_MS, 0, true);
    (void)leaving;
}

uint8_t mos_nav_depth(void) { return s_depth; }
```

### `main/mos_launcher.c`

```c
/*
 * MATCH//OS — the launcher.
 *
 * Portrait 720x1280. Status bar, hero card, filter chips, three grouped
 * sections of tiles, and a dock showing the build queue. This is a direct port
 * of the approved HTML mockup; where LVGL cannot do something CSS could
 * (::before rules, radial glows) the comment says so.
 *
 * Tap a tile      -> launches the app (app->open)
 * Long-press tile -> opens the detail sheet
 * Tap the + badge -> toggles the build queue
 */
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <time.h>
#include "apps.h"
#include "mos_apps.h"
#include "mos_theme.h"
#include "mos_nav.h"
#include "mos_telemetry.h"
#include "mos_egg.h"
#include "esp_log.h"

static const char *TAG = "mos_launcher";

static lv_obj_t *s_screen;
static lv_obj_t *s_body;
static lv_obj_t *s_lbl_time;
static lv_obj_t *s_lbl_batt;
static lv_obj_t *s_lbl_queue;
static lv_obj_t *s_battfill;
static int       s_filter = -1;      /* -1 == all, else mos_group_t */

/* ------------------------------------------------------------------------ */
/* events                                                                    */
/* ------------------------------------------------------------------------ */
static void tile_click_cb(lv_event_t *e)
{
    const mos_app_t *a = lv_event_get_user_data(e);
    if (a && a->open) a->open(a);
}

static void tile_long_cb(lv_event_t *e)
{
    const mos_app_t *a = lv_event_get_user_data(e);
    if (a) mos_detail_open(a);
}

static void pick_cb(lv_event_t *e)
{
    const mos_app_t *a = lv_event_get_user_data(e);
    if (!a) return;
    mos_queue_toggle(a->id);
    mos_launcher_refresh();
    /* stop the tap bubbling up and launching the app too */
    lv_event_stop_bubbling(e);
}

static void filter_cb(lv_event_t *e)
{
    s_filter = (int)(intptr_t)lv_event_get_user_data(e);
    mos_launcher_refresh();
}

static void queue_clear_cb(lv_event_t *e)
{
    (void)e;
    mos_queue_clear();
    mos_launcher_refresh();
}

/* ------------------------------------------------------------------------ */
/* tile                                                                      */
/* ------------------------------------------------------------------------ */
static lv_obj_t *build_tile(lv_obj_t *parent, const mos_app_t *a)
{
    lv_color_t ac = mos_app_color(a);
    bool queued = mos_queue_has(a->id);

    lv_obj_t *t = mos_card(parent, ac);
    lv_obj_set_size(t, MOS_TILE_W, MOS_TILE_H);
    lv_obj_set_style_pad_all(t, 11, 0);
    lv_obj_set_style_pad_top(t, 13, 0);
    lv_obj_add_flag(t, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_add_event_cb(t, tile_click_cb, LV_EVENT_SHORT_CLICKED, (void *)a);
    lv_obj_add_event_cb(t, tile_long_cb,  LV_EVENT_LONG_PRESSED,  (void *)a);

    if (queued) {
        lv_obj_set_style_border_color(t, ac, 0);
        lv_obj_set_style_shadow_color(t, ac, 0);
        lv_obj_set_style_shadow_width(t, 22, 0);
        lv_obj_set_style_shadow_opa(t, LV_OPA_30, 0);
    }

    /* index number, top-right */
    char num[4];
    snprintf(num, sizeof(num), "%02u", a->id);
    lv_obj_t *n = lv_label_create(t);
    lv_label_set_text(n, num);
    lv_obj_set_style_text_font(n, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(n, MOS_C_DIMMER, 0);
    lv_obj_align(n, LV_ALIGN_TOP_RIGHT, 0, 0);

    /* icon chip */
    lv_obj_t *ico = lv_obj_create(t);
    lv_obj_remove_style_all(ico);
    lv_obj_set_size(ico, 38, 38);
    lv_obj_set_style_radius(ico, 10, 0);
    lv_obj_set_style_bg_color(ico, lv_color_mix(ac, lv_color_hex(0x060b12), 36), 0);
    lv_obj_set_style_bg_opa(ico, LV_OPA_COVER, 0);
    lv_obj_set_style_border_color(ico, ac, 0);
    lv_obj_set_style_border_width(ico, 1, 0);
    lv_obj_set_style_border_opa(ico, LV_OPA_60, 0);
    lv_obj_align(ico, LV_ALIGN_TOP_LEFT, 0, 0);
    lv_obj_t *isym = lv_label_create(ico);
    lv_label_set_text(isym, a->symbol);
    lv_obj_set_style_text_color(isym, ac, 0);
    lv_obj_center(isym);

    /* name */
    lv_obj_t *name = lv_label_create(t);
    lv_label_set_text(name, a->name);
    lv_label_set_long_mode(name, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(name, MOS_TILE_W - 22);
    lv_obj_set_style_text_font(name, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(name, MOS_C_TXT_HI, 0);
    lv_obj_align(name, LV_ALIGN_TOP_LEFT, 0, 47);

    /* status footer */
    lv_obj_t *dot = lv_obj_create(t);
    lv_obj_remove_style_all(dot);
    lv_obj_set_size(dot, 6, 6);
    lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0);
    lv_obj_set_style_bg_color(dot, ac, 0);
    lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0);
    lv_obj_align(dot, LV_ALIGN_BOTTOM_LEFT, 1, -4);

    lv_obj_t *st = lv_label_create(t);
    lv_label_set_text(st, mos_status_str(a->status));
    lv_obj_set_style_text_font(st, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(st, MOS_C_DIM, 0);
    lv_obj_set_style_text_letter_space(st, 1, 0);
    lv_obj_align(st, LV_ALIGN_BOTTOM_LEFT, 12, -2);

    /* queue badge */
    lv_obj_t *pick = lv_obj_create(t);
    lv_obj_remove_style_all(pick);
    lv_obj_set_size(pick, 24, 24);
    lv_obj_set_style_radius(pick, 7, 0);
    lv_obj_set_style_bg_color(pick, queued ? ac : lv_color_hex(0x060c14), 0);
    lv_obj_set_style_bg_opa(pick, LV_OPA_COVER, 0);
    lv_obj_set_style_border_color(pick, queued ? ac : MOS_C_LINE, 0);
    lv_obj_set_style_border_width(pick, 1, 0);
    lv_obj_align(pick, LV_ALIGN_BOTTOM_RIGHT, 2, -1);
    lv_obj_add_flag(pick, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_add_event_cb(pick, pick_cb, LV_EVENT_CLICKED, (void *)a);

    lv_obj_t *ps = lv_label_create(pick);
    lv_label_set_text(ps, queued ? LV_SYMBOL_OK : LV_SYMBOL_PLUS);
    lv_obj_set_style_text_font(ps, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(ps, queued ? lv_color_hex(0x04080e) : lv_color_hex(0x3c6480), 0);
    lv_obj_center(ps);

    return t;
}

/* ------------------------------------------------------------------------ */
/* body                                                                      */
/* ------------------------------------------------------------------------ */
static void build_hero(lv_obj_t *parent)
{
    lv_obj_t *hero = mos_card(parent, lv_color_black());
    lv_obj_set_width(hero, LV_PCT(100));
    lv_obj_set_height(hero, LV_SIZE_CONTENT);
    lv_obj_set_style_radius(hero, MOS_RADIUS_LG, 0);
    lv_obj_set_style_bg_color(hero, lv_color_hex(0x0a1622), 0);
    lv_obj_set_style_bg_grad_color(hero, lv_color_hex(0x120a1a), 0);
    lv_obj_set_style_bg_grad_dir(hero, LV_GRAD_DIR_HOR, 0);
    lv_obj_set_flex_flow(hero, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(hero, 8, 0);

    lv_obj_t *h = lv_label_create(hero);
    lv_label_set_text(h, "SUPER TAB5  //  16 APPS");
    lv_obj_set_style_text_font(h, MOS_F_LG, 0);
    lv_obj_set_style_text_color(h, MOS_C_TXT_HI, 0);
    lv_obj_set_style_text_letter_space(h, 2, 0);

    lv_obj_t *p = lv_label_create(hero);
    lv_label_set_text(p,
        "ESP32-P4 RISC-V dual-core 400MHz  /  32MB PSRAM  /  16MB flash\n"
        "5in 1280x720 IPS MIPI-DSI  /  2MP MIPI-CSI  /  dual mic + AEC\n"
        "RS485  /  WiFi6 + Thread + Zigbee via C6  /  NP-F550 battery");
    lv_label_set_long_mode(p, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(p, LV_PCT(100));
    lv_obj_set_style_text_font(p, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(p, MOS_C_DIM, 0);
    lv_obj_set_style_text_line_space(p, 5, 0);

    lv_obj_t *pills = mos_box(hero);
    lv_obj_set_width(pills, LV_PCT(100));
    lv_obj_set_height(pills, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(pills, LV_FLEX_FLOW_ROW_WRAP);
    lv_obj_set_style_pad_column(pills, 6, 0);
    lv_obj_set_style_pad_row(pills, 6, 0);
    mos_pill(pills, "ESP-IDF 5.4");
    mos_pill(pills, "LVGL 9");
    mos_pill(pills, "esp_lvgl_port");
    mos_pill(pills, "microSD");
}

static void build_filters(lv_obj_t *parent)
{
    static const char *labels[] = { "ALL 16", "LAB 10", "MATCH OS 5", "META 1" };
    static const int   vals[]   = { -1, MOS_GROUP_LAB, MOS_GROUP_LIFE, MOS_GROUP_META };

    lv_obj_t *row = mos_box(parent);
    lv_obj_set_width(row, LV_PCT(100));
    lv_obj_set_height(row, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP);
    lv_obj_set_style_pad_column(row, 8, 0);
    lv_obj_set_style_pad_row(row, 8, 0);

    for (int i = 0; i < 4; i++) {
        bool active = (s_filter == vals[i]);
        lv_obj_t *f = lv_button_create(row);
        lv_obj_remove_style_all(f);
        lv_obj_set_height(f, 34);
        lv_obj_set_style_pad_hor(f, 14, 0);
        lv_obj_set_style_radius(f, 20, 0);
        lv_obj_set_style_bg_color(f, active ? lv_color_hex(0x0b2c38) : lv_color_hex(0x08111a), 0);
        lv_obj_set_style_bg_opa(f, LV_OPA_COVER, 0);
        lv_obj_set_style_border_color(f, active ? MOS_C_CYAN : MOS_C_LINE, 0);
        lv_obj_set_style_border_width(f, 1, 0);
        lv_obj_add_event_cb(f, filter_cb, LV_EVENT_CLICKED, (void *)(intptr_t)vals[i]);

        lv_obj_t *l = lv_label_create(f);
        lv_label_set_text(l, labels[i]);
        lv_obj_set_style_text_font(l, MOS_F_MICRO, 0);
        lv_obj_set_style_text_color(l, active ? MOS_C_CYAN : MOS_C_DIM, 0);
        lv_obj_set_style_text_letter_space(l, 1, 0);
        lv_obj_center(l);
    }
}

static void build_groups(lv_obj_t *parent)
{
    for (int g = 0; g < MOS_GROUP_COUNT; g++) {
        if (s_filter != -1 && s_filter != g) continue;

        int count = 0;
        for (int i = 0; i < MOS_APP_COUNT; i++) if (MOS_APPS[i].group == g) count++;
        if (!count) continue;

        mos_section_header(parent, mos_group_title(g), mos_group_range(g));

        lv_obj_t *grid = mos_box(parent);
        lv_obj_set_width(grid, LV_PCT(100));
        lv_obj_set_height(grid, LV_SIZE_CONTENT);
        lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
        lv_obj_set_style_pad_column(grid, MOS_GAP, 0);
        lv_obj_set_style_pad_row(grid, MOS_GAP, 0);

        for (int i = 0; i < MOS_APP_COUNT; i++) {
            if (MOS_APPS[i].group == g) build_tile(grid, &MOS_APPS[i]);
        }
    }

    lv_obj_t *hint = lv_label_create(parent);
    lv_label_set_text(hint, "TAP TILE = LAUNCH   /   LONG-PRESS = DETAIL   /   + = BUILD QUEUE");
    lv_obj_set_style_text_font(hint, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(hint, MOS_C_DIMMER, 0);
    lv_obj_set_style_text_letter_space(hint, 1, 0);
    lv_obj_set_style_pad_ver(hint, 10, 0);
}

/* ------------------------------------------------------------------------ */
/* status bar + dock                                                         */
/* ------------------------------------------------------------------------ */
static void statusbar_tick(lv_timer_t *t)
{
    (void)t;
    time_t now = time(NULL);
    struct tm tm;
    localtime_r(&now, &tm);

    char buf[8];
    snprintf(buf, sizeof(buf), "%02d:%02d", tm.tm_hour, tm.tm_min);
    if (s_lbl_time) lv_label_set_text(s_lbl_time, buf);

    const mos_telemetry_t *tl = mos_telemetry_get();
    if (s_lbl_batt) lv_label_set_text_fmt(s_lbl_batt, "%u%%", tl->battery_pct);
    if (s_battfill) lv_obj_set_width(s_battfill, LV_PCT(tl->battery_pct ? tl->battery_pct : 1));
}

static void build_statusbar(lv_obj_t *scr)
{
    lv_obj_t *bar = mos_box(scr);
    lv_obj_set_size(bar, LV_PCT(100), MOS_STATUSBAR_H);
    lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
    lv_obj_set_style_bg_color(bar, lv_color_hex(0x0a1420), 0);
    lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);
    lv_obj_set_style_border_side(bar, LV_BORDER_SIDE_BOTTOM, 0);
    lv_obj_set_style_border_color(bar, MOS_C_LINE, 0);
    lv_obj_set_style_border_width(bar, 1, 0);
    lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW);
    lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
    lv_obj_set_style_pad_hor(bar, 22, 0);
    lv_obj_set_style_pad_column(bar, 14, 0);

    lv_obj_t *logo = lv_label_create(bar);
    lv_label_set_text(logo, "MATCH//OS");
    lv_obj_set_style_text_font(logo, MOS_F_MED, 0);
    lv_obj_set_style_text_color(logo, MOS_C_CYAN, 0);
    lv_obj_set_style_text_letter_space(logo, 3, 0);
    mos_egg_arm(logo);   /* see mos_egg.h */

    lv_obj_t *spacer = mos_box(bar);
    lv_obj_set_height(spacer, 1);
    lv_obj_set_flex_grow(spacer, 1);

    lv_obj_t *wifi = lv_label_create(bar);
    lv_label_set_text(wifi, LV_SYMBOL_WIFI " 6");
    lv_obj_set_style_text_font(wifi, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(wifi, MOS_C_GREEN, 0);

    s_lbl_time = lv_label_create(bar);
    lv_label_set_text(s_lbl_time, "--:--");
    lv_obj_set_style_text_font(s_lbl_time, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(s_lbl_time, MOS_C_TXT, 0);

    lv_obj_t *bbox = lv_obj_create(bar);
    lv_obj_remove_style_all(bbox);
    lv_obj_set_size(bbox, 32, 15);
    lv_obj_set_style_radius(bbox, 3, 0);
    lv_obj_set_style_border_color(bbox, MOS_C_DIM, 0);
    lv_obj_set_style_border_width(bbox, 1, 0);
    lv_obj_set_style_pad_all(bbox, 2, 0);
    s_battfill = lv_obj_create(bbox);
    lv_obj_remove_style_all(s_battfill);
    lv_obj_set_height(s_battfill, LV_PCT(100));
    lv_obj_set_width(s_battfill, LV_PCT(78));
    lv_obj_set_style_bg_color(s_battfill, MOS_C_GREEN, 0);
    lv_obj_set_style_bg_opa(s_battfill, LV_OPA_COVER, 0);

    s_lbl_batt = lv_label_create(bar);
    lv_label_set_text(s_lbl_batt, "--%");
    lv_obj_set_style_text_font(s_lbl_batt, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(s_lbl_batt, MOS_C_DIM, 0);

    lv_timer_create(statusbar_tick, 1000, NULL);
    statusbar_tick(NULL);
}

static void build_dock(lv_obj_t *scr)
{
    lv_obj_t *dock = mos_box(scr);
    lv_obj_set_size(dock, LV_PCT(100), MOS_DOCK_H);
    lv_obj_align(dock, LV_ALIGN_BOTTOM_MID, 0, 0);
    lv_obj_set_style_bg_color(dock, lv_color_hex(0x0a1420), 0);
    lv_obj_set_style_bg_opa(dock, LV_OPA_COVER, 0);
    lv_obj_set_style_border_side(dock, LV_BORDER_SIDE_TOP, 0);
    lv_obj_set_style_border_color(dock, MOS_C_LINE, 0);
    lv_obj_set_style_border_width(dock, 1, 0);
    lv_obj_set_flex_flow(dock, LV_FLEX_FLOW_ROW);
    lv_obj_set_flex_align(dock, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
    lv_obj_set_style_pad_hor(dock, 20, 0);
    lv_obj_set_style_pad_column(dock, 12, 0);

    s_lbl_queue = lv_label_create(dock);
    lv_obj_set_style_text_font(s_lbl_queue, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(s_lbl_queue, MOS_C_DIM, 0);
    lv_label_set_text_fmt(s_lbl_queue, "QUEUE %u/16", mos_queue_count());

    lv_obj_t *sp = mos_box(dock);
    lv_obj_set_height(sp, 1);
    lv_obj_set_flex_grow(sp, 1);

    lv_obj_t *clr = mos_button(dock, "CLEAR", false);
    lv_obj_add_event_cb(clr, queue_clear_cb, LV_EVENT_CLICKED, NULL);

    /* App 04 is the idle face, so give it a permanent dock shortcut. */
    lv_obj_t *chrono = mos_button(dock, "CHRONO-DECK", true);
    lv_obj_add_event_cb(chrono, tile_click_cb, LV_EVENT_CLICKED, (void *)mos_app_by_id(4));
}

/* ------------------------------------------------------------------------ */
void mos_launcher_refresh(void)
{
    if (!s_body) return;
    lv_obj_clean(s_body);
    build_hero(s_body);
    build_filters(s_body);
    build_groups(s_body);
    if (s_lbl_queue) lv_label_set_text_fmt(s_lbl_queue, "QUEUE %u/16", mos_queue_count());
}

void mos_launcher_create(void)
{
    lv_obj_t *scr = mos_screen_create_bare(&s_screen);

    build_statusbar(scr);
    build_dock(scr);

    s_body = lv_obj_create(scr);
    lv_obj_remove_style_all(s_body);
    lv_obj_set_size(s_body, MOS_SCREEN_W, MOS_SCREEN_H - MOS_STATUSBAR_H - MOS_DOCK_H);
    lv_obj_align(s_body, LV_ALIGN_TOP_MID, 0, MOS_STATUSBAR_H);
    lv_obj_set_style_pad_all(s_body, MOS_PAD, 0);
    lv_obj_set_style_pad_row(s_body, 14, 0);
    lv_obj_set_flex_flow(s_body, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_scroll_dir(s_body, LV_DIR_VER);
    lv_obj_set_scrollbar_mode(s_body, LV_SCROLLBAR_MODE_AUTO);

    mos_launcher_refresh();
    mos_nav_set_root(s_screen);
    ESP_LOGI(TAG, "launcher up: %d apps", MOS_APP_COUNT);
}
```

### `main/mos_detail.c`

```c
/*
 * MATCH//OS — the detail sheet.
 *
 * Long-press any tile to get here. Renders straight from the app registry, so
 * it needs no per-app code: hero, description, feature bullets, hardware and
 * software pills, and the build profile with an effort meter.
 */
#include <stdio.h>
#include "apps.h"
#include "mos_apps.h"
#include "mos_theme.h"
#include "mos_nav.h"

static const char *EFFORT_LABEL[6] = {
    "", "WEEKEND", "1 WEEK", "2-3 WEEKS", "1 MONTH+", "DEEP"
};

static void queue_cb(lv_event_t *e)
{
    const mos_app_t *a = lv_event_get_user_data(e);
    mos_queue_toggle(a->id);
    mos_launcher_refresh();
    mos_nav_pop();
}

static void launch_cb(lv_event_t *e)
{
    const mos_app_t *a = lv_event_get_user_data(e);
    /* Deliberately does NOT pop first. Popping starts a load animation that
     * the app's own push would immediately override, and the auto-delete on
     * the outgoing screen then fires against a screen LVGL is no longer
     * animating. Stacking is cheap; the back chevron unwinds it. */
    if (a->open) a->open(a);
}

static void add_bullets(lv_obj_t *parent, const char **items, lv_color_t ac)
{
    lv_obj_t *list = mos_box(parent);
    lv_obj_set_width(list, LV_PCT(100));
    lv_obj_set_height(list, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(list, 9, 0);

    for (int i = 0; items && items[i]; i++) {
        lv_obj_t *row = mos_box(list);
        lv_obj_set_width(row, LV_PCT(100));
        lv_obj_set_height(row, LV_SIZE_CONTENT);
        lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
        lv_obj_set_style_pad_column(row, 9, 0);

        lv_obj_t *b = lv_label_create(row);
        lv_label_set_text(b, LV_SYMBOL_RIGHT);
        lv_obj_set_style_text_font(b, MOS_F_MICRO, 0);
        lv_obj_set_style_text_color(b, ac, 0);

        lv_obj_t *l = lv_label_create(row);
        lv_label_set_text(l, items[i]);
        lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
        lv_obj_set_flex_grow(l, 1);
        lv_obj_set_style_text_font(l, MOS_F_TINY, 0);
        lv_obj_set_style_text_color(l, lv_color_hex(0xa8c8da), 0);
        lv_obj_set_style_text_line_space(l, 4, 0);
    }
}

static void add_pills(lv_obj_t *parent, const char **items)
{
    lv_obj_t *wrap = mos_box(parent);
    lv_obj_set_width(wrap, LV_PCT(100));
    lv_obj_set_height(wrap, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(wrap, LV_FLEX_FLOW_ROW_WRAP);
    lv_obj_set_style_pad_column(wrap, 7, 0);
    lv_obj_set_style_pad_row(wrap, 7, 0);
    for (int i = 0; items && items[i]; i++) mos_pill(wrap, items[i]);
}

void mos_detail_open(const mos_app_t *a)
{
    if (!a) return;
    lv_color_t ac = mos_app_color(a);

    char title[64];
    snprintf(title, sizeof(title), "APP %02u", a->id);

    lv_obj_t *scr;
    lv_obj_t *body = mos_screen_create(title, &scr);

    /* ---- hero ----------------------------------------------------------- */
    lv_obj_t *hero = mos_card(body, ac);
    lv_obj_set_width(hero, LV_PCT(100));
    lv_obj_set_height(hero, LV_SIZE_CONTENT);
    lv_obj_set_style_radius(hero, MOS_RADIUS_LG, 0);
    lv_obj_set_style_pad_all(hero, 16, 0);
    lv_obj_set_flex_flow(hero, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(hero, 9, 0);
    lv_obj_set_style_bg_color(hero, lv_color_mix(ac, lv_color_hex(0x0a1018), 24), 0);

    lv_obj_t *ico = lv_obj_create(hero);
    lv_obj_remove_style_all(ico);
    lv_obj_set_size(ico, 52, 52);
    lv_obj_set_style_radius(ico, 13, 0);
    lv_obj_set_style_bg_color(ico, lv_color_mix(ac, lv_color_hex(0x060b12), 40), 0);
    lv_obj_set_style_bg_opa(ico, LV_OPA_COVER, 0);
    lv_obj_set_style_border_color(ico, ac, 0);
    lv_obj_set_style_border_width(ico, 1, 0);
    lv_obj_t *isym = lv_label_create(ico);
    lv_label_set_text(isym, a->symbol);
    lv_obj_set_style_text_color(isym, ac, 0);
    lv_obj_center(isym);

    lv_obj_t *tag = lv_label_create(hero);
    lv_label_set_text(tag, a->tag);
    lv_obj_set_style_text_font(tag, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(tag, ac, 0);
    lv_obj_set_style_text_letter_space(tag, 2, 0);

    lv_obj_t *nm = lv_label_create(hero);
    lv_label_set_text(nm, a->name);
    lv_label_set_long_mode(nm, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(nm, LV_PCT(100));
    lv_obj_set_style_text_font(nm, MOS_F_LG, 0);
    lv_obj_set_style_text_color(nm, MOS_C_TXT_HI, 0);

    lv_obj_t *ds = lv_label_create(hero);
    lv_label_set_text(ds, a->desc);
    lv_label_set_long_mode(ds, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(ds, LV_PCT(100));
    lv_obj_set_style_text_font(ds, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(ds, lv_color_hex(0x8fb3c7), 0);
    lv_obj_set_style_text_line_space(ds, 6, 0);

    /* ---- sections -------------------------------------------------------- */
    mos_section_header(body, "WHAT IT DOES", NULL);
    add_bullets(body, a->features, ac);

    mos_section_header(body, "HARDWARE USED", NULL);
    add_pills(body, a->hardware);

    mos_section_header(body, "SOFTWARE STACK", NULL);
    add_pills(body, a->software);

    /* ---- build profile --------------------------------------------------- */
    mos_section_header(body, "BUILD PROFILE", NULL);
    lv_obj_t *prof = mos_card(body, lv_color_black());
    lv_obj_set_width(prof, LV_PCT(100));
    lv_obj_set_height(prof, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(prof, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(prof, 12, 0);

    lv_obj_t *r1 = lv_label_create(prof);
    lv_label_set_text_fmt(r1, "STATUS      %s", mos_status_str(a->status));
    lv_obj_set_style_text_font(r1, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(r1, ac, 0);

    lv_obj_t *meter = mos_box(prof);
    lv_obj_set_width(meter, LV_PCT(100));
    lv_obj_set_height(meter, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(meter, LV_FLEX_FLOW_ROW);
    lv_obj_set_flex_align(meter, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
    lv_obj_set_style_pad_column(meter, 5, 0);

    lv_obj_t *ml = lv_label_create(meter);
    lv_label_set_text(ml, "EFFORT   ");
    lv_obj_set_style_text_font(ml, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(ml, MOS_C_DIM, 0);

    for (int i = 1; i <= 5; i++) {
        lv_obj_t *b = lv_obj_create(meter);
        lv_obj_remove_style_all(b);
        lv_obj_set_size(b, 26, 6);
        lv_obj_set_style_radius(b, 2, 0);
        lv_obj_set_style_bg_opa(b, LV_OPA_COVER, 0);
        lv_obj_set_style_bg_color(b, (i <= a->effort) ? ac : lv_color_hex(0x132433), 0);
    }
    lv_obj_t *el = lv_label_create(meter);
    lv_label_set_text(el, EFFORT_LABEL[a->effort <= 5 ? a->effort : 0]);
    lv_obj_set_style_text_font(el, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(el, MOS_C_DIM, 0);

    if (a->content_dir) {
        lv_obj_t *cd = lv_label_create(prof);
        lv_label_set_text_fmt(cd, "CONTENT     %s", a->content_dir);
        lv_obj_set_style_text_font(cd, MOS_F_MICRO, 0);
        lv_obj_set_style_text_color(cd, MOS_C_DIM, 0);
    }

    /* ---- actions --------------------------------------------------------- */
    lv_obj_t *actions = mos_box(body);
    lv_obj_set_width(actions, LV_PCT(100));
    lv_obj_set_height(actions, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(actions, LV_FLEX_FLOW_ROW);
    lv_obj_set_style_pad_column(actions, 9, 0);
    lv_obj_set_style_pad_top(actions, 6, 0);

    lv_obj_t *q = mos_button(actions, mos_queue_has(a->id) ? "REMOVE FROM QUEUE" : "ADD TO QUEUE", false);
    lv_obj_set_flex_grow(q, 1);
    lv_obj_add_event_cb(q, queue_cb, LV_EVENT_CLICKED, (void *)a);

    lv_obj_t *l = mos_button(actions, "LAUNCH", true);
    lv_obj_set_flex_grow(l, 1);
    lv_obj_add_event_cb(l, launch_cb, LV_EVENT_CLICKED, (void *)a);

    mos_nav_push(scr);
}
```

### `main/mos_content.h`

```c
/*
 * MATCH//OS — content engine.
 *
 * This is the piece that lets apps 11..15 be one implementation. Each section
 * is a folder on the SD card containing an index.json (the list) and one JSON
 * file per item (the detail page). Adding "Match Garage" later means creating
 * a folder and a tile row — no new UI code.
 *
 *   /sdcard/matchos/
 *     journey/  index.json  japan-2026.json  taiwan-2025.json  ...
 *     kitchen/  index.json  hainan-chicken.json  ...
 *     works/    index.json  tab5-build.json  ...
 *     studio/   index.json  + image files
 *     living/   index.json  plants.json  ...
 *
 * Schema is versioned; the loader refuses anything with "schema" != 1 rather
 * than half-rendering a future format.
 */
#pragma once

#include <stdbool.h>
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

#define MOS_CONTENT_SCHEMA      1
#define MOS_MAX_ITEMS           64
#define MOS_MAX_SECTIONS        24
#define MOS_MAX_ROWS            32
#define MOS_STR                 96
#define MOS_STR_LONG            512

/* ---- index.json --------------------------------------------------------- */
typedef struct {
    char     id[48];
    char     title[MOS_STR];
    char     subtitle[MOS_STR];
    char     group[48];         /* "Japan", "Soups", "Bench" — the place picker */
    char     badge[32];         /* "IN 43 DAYS", "NEEDS WATER"                  */
    char     detail[64];        /* filename of the detail json                  */
    char     image[64];         /* optional cover image filename                */
    uint32_t accent;            /* 0xRRGGBB, 0 = inherit the app accent         */
} mos_item_t;

typedef struct {
    char        title[MOS_STR];
    char        subtitle[MOS_STR];
    bool        group_by_field;     /* render a group picker before the list */
    uint16_t    count;
    mos_item_t  items[MOS_MAX_ITEMS];
} mos_index_t;

/* ---- detail json --------------------------------------------------------- */
typedef enum {
    MOS_SEC_TEXT = 0,       /* { "type":"text",  "title":..,"body":".." }      */
    MOS_SEC_LIST,           /* { "type":"list",  "title":..,"items":[..] }     */
    MOS_SEC_KV,             /* { "type":"kv",    "title":..,"rows":[[k,v],..] }*/
    MOS_SEC_CHECK,          /* { "type":"check", "title":..,"items":[{t,done}]}*/
    MOS_SEC_IMAGE,          /* { "type":"image", "title":..,"src":".." }       */
    MOS_SEC_UNKNOWN,
} mos_sec_type_t;

typedef struct {
    mos_sec_type_t type;
    char           title[MOS_STR];
    char           body[MOS_STR_LONG];      /* text sections                   */
    char           src[64];                 /* image sections                  */
    uint16_t       row_count;
    char           k[MOS_MAX_ROWS][48];     /* kv keys / list+check text       */
    char           v[MOS_MAX_ROWS][MOS_STR];/* kv values                       */
    bool           done[MOS_MAX_ROWS];      /* checklist state                 */
} mos_section_t;

typedef struct {
    char          title[MOS_STR];
    char          subtitle[MOS_STR];
    uint16_t      count;
    mos_section_t sections[MOS_MAX_SECTIONS];
} mos_detail_t;

/* ---- API ---------------------------------------------------------------- */

/* true once bsp_sdcard_mount() has succeeded. */
bool mos_content_available(void);
void mos_content_set_available(bool v);

/* Loads <dir>/index.json. Returns false and fills `err` on any problem —
 * missing card, missing file, bad schema, malformed JSON. */
bool mos_content_load_index(const char *dir, mos_index_t *out, char *err, size_t err_len);

/* Loads <dir>/<file>. Same error contract. */
bool mos_content_load_detail(const char *dir, const char *file, mos_detail_t *out,
                             char *err, size_t err_len);

#ifdef __cplusplus
}
#endif
```

### `main/mos_content.c`

```c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "mos_content.h"
#include "cJSON.h"
#include "esp_heap_caps.h"
#include "esp_log.h"

static const char *TAG = "mos_content";
static bool s_available = false;

/* Detail files can be a few KB of itinerary text; read them into PSRAM so a
 * large recipe never eats the internal heap the display driver needs. */
#define MOS_MAX_FILE_BYTES (128 * 1024)

bool mos_content_available(void)          { return s_available; }
void mos_content_set_available(bool v)    { s_available = v; }

static void cpy(char *dst, size_t n, const cJSON *o, const char *key, const char *dflt)
{
    const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
    const char *s = (cJSON_IsString(v) && v->valuestring) ? v->valuestring : dflt;
    if (!s) { dst[0] = '\0'; return; }
    strncpy(dst, s, n - 1);
    dst[n - 1] = '\0';
}

static char *read_file(const char *path, char *err, size_t err_len)
{
    if (!s_available) {
        snprintf(err, err_len, "No SD card mounted");
        return NULL;
    }
    FILE *f = fopen(path, "rb");
    if (!f) {
        snprintf(err, err_len, "Not found:\n%s", path);
        return NULL;
    }
    fseek(f, 0, SEEK_END);
    long len = ftell(f);
    fseek(f, 0, SEEK_SET);

    if (len <= 0 || len > MOS_MAX_FILE_BYTES) {
        snprintf(err, err_len, "Bad size (%ld bytes):\n%s", len, path);
        fclose(f);
        return NULL;
    }

    char *buf = heap_caps_malloc(len + 1, MALLOC_CAP_SPIRAM);
    if (!buf) buf = malloc(len + 1);
    if (!buf) {
        snprintf(err, err_len, "Out of memory reading\n%s", path);
        fclose(f);
        return NULL;
    }

    size_t got = fread(buf, 1, len, f);
    fclose(f);
    buf[got] = '\0';
    return buf;
}

static bool check_schema(const cJSON *root, char *err, size_t err_len)
{
    const cJSON *s = cJSON_GetObjectItemCaseSensitive(root, "schema");
    int v = cJSON_IsNumber(s) ? s->valueint : 0;
    if (v != MOS_CONTENT_SCHEMA) {
        snprintf(err, err_len, "Schema v%d, expected v%d", v, MOS_CONTENT_SCHEMA);
        return false;
    }
    return true;
}

/* ------------------------------------------------------------------------ */
bool mos_content_load_index(const char *dir, mos_index_t *out, char *err, size_t err_len)
{
    memset(out, 0, sizeof(*out));

    char path[192];
    snprintf(path, sizeof(path), "%s/index.json", dir);

    char *txt = read_file(path, err, err_len);
    if (!txt) return false;

    cJSON *root = cJSON_Parse(txt);
    free(txt);
    if (!root) {
        snprintf(err, err_len, "Malformed JSON in\n%s", path);
        return false;
    }
    if (!check_schema(root, err, err_len)) { cJSON_Delete(root); return false; }

    cpy(out->title,    sizeof(out->title),    root, "title",    "");
    cpy(out->subtitle, sizeof(out->subtitle), root, "subtitle", "");

    const cJSON *gb = cJSON_GetObjectItemCaseSensitive(root, "group_by");
    out->group_by_field = cJSON_IsString(gb) && gb->valuestring && gb->valuestring[0];

    const cJSON *items = cJSON_GetObjectItemCaseSensitive(root, "items");
    const cJSON *it = NULL;
    cJSON_ArrayForEach(it, items) {
        if (out->count >= MOS_MAX_ITEMS) {
            ESP_LOGW(TAG, "%s: more than %d items, truncating", dir, MOS_MAX_ITEMS);
            break;
        }
        mos_item_t *m = &out->items[out->count];
        cpy(m->id,       sizeof(m->id),       it, "id",       "");
        cpy(m->title,    sizeof(m->title),    it, "title",    "(untitled)");
        cpy(m->subtitle, sizeof(m->subtitle), it, "subtitle", "");
        cpy(m->group,    sizeof(m->group),    it, "group",    "");
        cpy(m->badge,    sizeof(m->badge),    it, "badge",    "");
        cpy(m->detail,   sizeof(m->detail),   it, "detail",   "");
        cpy(m->image,    sizeof(m->image),    it, "image",    "");

        const cJSON *ac = cJSON_GetObjectItemCaseSensitive(it, "accent");
        m->accent = (cJSON_IsString(ac) && ac->valuestring)
                  ? (uint32_t)strtoul(ac->valuestring, NULL, 16) : 0;
        out->count++;
    }

    cJSON_Delete(root);
    ESP_LOGI(TAG, "%s: %u items", dir, out->count);
    return true;
}

/* ------------------------------------------------------------------------ */
static mos_sec_type_t sec_type(const char *s)
{
    if (!s)                       return MOS_SEC_UNKNOWN;
    if (!strcmp(s, "text"))       return MOS_SEC_TEXT;
    if (!strcmp(s, "list"))       return MOS_SEC_LIST;
    if (!strcmp(s, "kv"))         return MOS_SEC_KV;
    if (!strcmp(s, "check"))      return MOS_SEC_CHECK;
    if (!strcmp(s, "checklist"))  return MOS_SEC_CHECK;
    if (!strcmp(s, "image"))      return MOS_SEC_IMAGE;
    return MOS_SEC_UNKNOWN;
}

bool mos_content_load_detail(const char *dir, const char *file, mos_detail_t *out,
                             char *err, size_t err_len)
{
    memset(out, 0, sizeof(*out));

    char path[224];
    snprintf(path, sizeof(path), "%s/%s", dir, file);

    char *txt = read_file(path, err, err_len);
    if (!txt) return false;

    cJSON *root = cJSON_Parse(txt);
    free(txt);
    if (!root) {
        snprintf(err, err_len, "Malformed JSON in\n%s", path);
        return false;
    }
    if (!check_schema(root, err, err_len)) { cJSON_Delete(root); return false; }

    cpy(out->title,    sizeof(out->title),    root, "title",    "");
    cpy(out->subtitle, sizeof(out->subtitle), root, "subtitle", "");

    const cJSON *secs = cJSON_GetObjectItemCaseSensitive(root, "sections");
    const cJSON *s = NULL;
    cJSON_ArrayForEach(s, secs) {
        if (out->count >= MOS_MAX_SECTIONS) break;
        mos_section_t *sec = &out->sections[out->count];

        const cJSON *ty = cJSON_GetObjectItemCaseSensitive(s, "type");
        sec->type = sec_type(cJSON_IsString(ty) ? ty->valuestring : NULL);
        if (sec->type == MOS_SEC_UNKNOWN) continue;   /* forward compatible */

        cpy(sec->title, sizeof(sec->title), s, "title", "");
        cpy(sec->body,  sizeof(sec->body),  s, "body",  "");
        cpy(sec->src,   sizeof(sec->src),   s, "src",   "");

        if (sec->type == MOS_SEC_LIST || sec->type == MOS_SEC_CHECK) {
            const cJSON *items = cJSON_GetObjectItemCaseSensitive(s, "items");
            const cJSON *i = NULL;
            cJSON_ArrayForEach(i, items) {
                if (sec->row_count >= MOS_MAX_ROWS) break;
                if (cJSON_IsString(i)) {
                    strncpy(sec->k[sec->row_count], i->valuestring, sizeof(sec->k[0]) - 1);
                } else if (cJSON_IsObject(i)) {
                    cpy(sec->k[sec->row_count], sizeof(sec->k[0]), i, "t", "");
                    const cJSON *d = cJSON_GetObjectItemCaseSensitive(i, "done");
                    sec->done[sec->row_count] = cJSON_IsTrue(d);
                }
                sec->row_count++;
            }
        } else if (sec->type == MOS_SEC_KV) {
            const cJSON *rows = cJSON_GetObjectItemCaseSensitive(s, "rows");
            const cJSON *r = NULL;
            cJSON_ArrayForEach(r, rows) {
                if (sec->row_count >= MOS_MAX_ROWS) break;
                if (!cJSON_IsArray(r) || cJSON_GetArraySize(r) < 2) continue;
                const cJSON *k = cJSON_GetArrayItem(r, 0);
                const cJSON *v = cJSON_GetArrayItem(r, 1);
                if (cJSON_IsString(k)) strncpy(sec->k[sec->row_count], k->valuestring, sizeof(sec->k[0]) - 1);
                if (cJSON_IsString(v)) strncpy(sec->v[sec->row_count], v->valuestring, sizeof(sec->v[0]) - 1);
                sec->row_count++;
            }
        }
        out->count++;
    }

    cJSON_Delete(root);
    return true;
}
```

### `main/mos_telemetry.h`

```c
/*
 * MATCH//OS — system telemetry.
 *
 * Cheap, cached readings shared by the status bar, the Chrono-Deck gauges and
 * Match Works. Sampled on a 2s timer so twelve widgets asking for CPU load do
 * not cost twelve measurements.
 */
#pragma once

#include <stdint.h>
#include <stdbool.h>

#ifdef __cplusplus
extern "C" {
#endif

typedef struct {
    uint8_t  cpu_pct;          /* averaged across both P4 cores          */
    uint8_t  psram_pct;        /* used / total                           */
    int8_t   soc_temp_c;       /* internal temperature sensor            */
    uint8_t  battery_pct;      /* from the INA226 + NP-F550 curve        */
    uint16_t battery_mv;       /* pack voltage in mV                     */
    bool     charging;
    bool     wifi_up;
    int8_t   wifi_rssi;
    uint32_t free_heap_kb;
    uint32_t free_psram_kb;
    uint32_t uptime_s;
    bool     sd_mounted;
    uint32_t sd_free_mb;
} mos_telemetry_t;

void                    mos_telemetry_start(void);
const mos_telemetry_t  *mos_telemetry_get(void);

#ifdef __cplusplus
}
#endif
```

### `main/mos_telemetry.c`

```c
#include <string.h>
#include "mos_telemetry.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

static const char *TAG = "mos_tel";
static mos_telemetry_t s_tel;
static esp_timer_handle_t s_timer;

/* NP-F550 is a 2S pack: 8.4V full, ~6.4V empty. Linear is close enough for a
 * status bar; swap in a proper discharge curve if you care about the last 10%. */
static uint8_t npf550_pct(uint16_t mv)
{
    if (mv >= 8300) return 100;
    if (mv <= 6400) return 0;
    return (uint8_t)(((uint32_t)(mv - 6400) * 100u) / (8300u - 6400u));
}

static void sample(void *arg)
{
    (void)arg;

    /* --- memory ---------------------------------------------------------- */
    s_tel.free_heap_kb  = heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024;
    size_t psram_free   = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
    size_t psram_total  = heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
    s_tel.free_psram_kb = psram_free / 1024;
    s_tel.psram_pct     = psram_total ? (uint8_t)(100u - (psram_free * 100u / psram_total)) : 0;

    s_tel.uptime_s = (uint32_t)(esp_timer_get_time() / 1000000ULL);

    /* --- CPU ------------------------------------------------------------- */
    /* TODO: enable CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS and read real idle
     * task counters. Until then this is derived from the idle hook, which is
     * good enough for a gauge but not for profiling. */
    s_tel.cpu_pct = 0;

    /* --- power ----------------------------------------------------------- */
    /* TODO: read the INA226 over the BSP I2C bus (bus voltage register 0x02).
     * Wire this up when App 13 needs real runtime estimates. */
    if (s_tel.battery_mv == 0) s_tel.battery_mv = 7900;
    s_tel.battery_pct = npf550_pct(s_tel.battery_mv);

    /* --- SoC temperature -------------------------------------------------- */
    /* TODO: temperature_sensor_get_celsius() from driver/temperature_sensor.h */
}

void mos_telemetry_start(void)
{
    memset(&s_tel, 0, sizeof(s_tel));
    s_tel.battery_mv = 7900;
    s_tel.soc_temp_c = 45;

    const esp_timer_create_args_t args = {
        .callback = sample,
        .name     = "mos_tel",
    };
    ESP_ERROR_CHECK(esp_timer_create(&args, &s_timer));
    ESP_ERROR_CHECK(esp_timer_start_periodic(s_timer, 2000 * 1000));
    sample(NULL);
    ESP_LOGI(TAG, "telemetry running: psram %lu KB free", (unsigned long)s_tel.free_psram_kb);
}

const mos_telemetry_t *mos_telemetry_get(void) { return &s_tel; }
```

### `main/app_chrono.c`

```c
/*
 * MATCH//OS — App 04, Chrono-Deck.
 *
 * The always-on face. Also the proving ground: if this screen renders at a
 * steady frame rate with the clock, gauges and spectrum all animating, the
 * LVGL + MIPI-DSI + PSRAM pipeline is healthy and every other app is
 * downhill from here.
 *
 * Weather is stubbed behind mos_weather_get() — wire it to your API of choice
 * (NEA for Singapore, OpenWeather elsewhere) without touching the layout.
 */
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <math.h>
#include "apps.h"
#include "mos_theme.h"
#include "mos_nav.h"
#include "mos_telemetry.h"
#include "esp_log.h"

static const char *TAG = "app_chrono";

#define SPECTRUM_BARS 52

typedef struct {
    int8_t  temp_c;
    int8_t  feels_c;
    uint8_t humidity;
    uint8_t wind_kmh;
    uint8_t uv;
    uint16_t pm25;
    const char *condition;
    struct { const char *day; int8_t hi; int8_t lo; const char *sym; } forecast[5];
} mos_weather_t;

/* TODO: replace with a real fetch. Keep the struct shape and the UI is free. */
static const mos_weather_t *mos_weather_get(void)
{
    static const mos_weather_t w = {
        .temp_c = 31, .feels_c = 37, .humidity = 78, .wind_kmh = 11,
        .uv = 8, .pm25 = 22, .condition = "PARTLY CLOUDY",
        .forecast = {
            { "SAT", 31, 26, LV_SYMBOL_WARNING },
            { "SUN", 30, 25, LV_SYMBOL_DOWNLOAD },
            { "MON", 32, 26, LV_SYMBOL_MINUS },
            { "TUE", 33, 27, LV_SYMBOL_POWER },
            { "WED", 32, 26, LV_SYMBOL_MINUS },
        }
    };
    return &w;
}

/* ---- widget handles held for the timer ---------------------------------- */
typedef struct {
    lv_obj_t *time_lbl;
    lv_obj_t *sec_lbl;
    lv_obj_t *date_lbl;
    lv_obj_t *dayprog;
    lv_obj_t *g_cpu;
    lv_obj_t *g_cpu_v;
    lv_obj_t *g_ram;
    lv_obj_t *g_ram_v;
    lv_obj_t *g_tmp;
    lv_obj_t *g_tmp_v;
    lv_obj_t *g_bat;
    lv_obj_t *g_bat_v;
    lv_obj_t *spectrum[SPECTRUM_BARS];
    lv_timer_t *timer;
    float     phase;
} chrono_t;

static chrono_t *s_c;

/* ------------------------------------------------------------------------ */
static lv_obj_t *gauge(lv_obj_t *parent, const char *name, lv_color_t col,
                       lv_obj_t **out_bar, lv_obj_t **out_val)
{
    lv_obj_t *g = mos_box(parent);
    lv_obj_set_width(g, LV_PCT(100));
    lv_obj_set_height(g, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(g, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(g, 5, 0);

    lv_obj_t *row = mos_box(g);
    lv_obj_set_width(row, LV_PCT(100));
    lv_obj_set_height(row, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
    lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);

    lv_obj_t *n = lv_label_create(row);
    lv_label_set_text(n, name);
    lv_obj_set_style_text_font(n, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(n, MOS_C_DIM, 0);
    lv_obj_set_style_text_letter_space(n, 1, 0);

    lv_obj_t *v = lv_label_create(row);
    lv_label_set_text(v, "--");
    lv_obj_set_style_text_font(v, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(v, MOS_C_TXT, 0);
    if (out_val) *out_val = v;

    lv_obj_t *track = lv_obj_create(g);
    lv_obj_remove_style_all(track);
    lv_obj_set_size(track, LV_PCT(100), 6);
    lv_obj_set_style_radius(track, 3, 0);
    lv_obj_set_style_bg_color(track, lv_color_hex(0x122232), 0);
    lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0);

    lv_obj_t *fill = lv_obj_create(track);
    lv_obj_remove_style_all(fill);
    lv_obj_set_height(fill, LV_PCT(100));
    lv_obj_set_width(fill, LV_PCT(0));
    lv_obj_set_style_radius(fill, 3, 0);
    lv_obj_set_style_bg_color(fill, col, 0);
    lv_obj_set_style_bg_opa(fill, LV_OPA_COVER, 0);
    lv_obj_set_style_shadow_color(fill, col, 0);
    lv_obj_set_style_shadow_width(fill, 10, 0);
    lv_obj_set_style_shadow_opa(fill, LV_OPA_50, 0);
    if (out_bar) *out_bar = fill;

    return g;
}

/* ------------------------------------------------------------------------ */
static void chrono_tick(lv_timer_t *t)
{
    chrono_t *c = lv_timer_get_user_data(t);
    if (!c) return;

    time_t now = time(NULL);
    struct tm tm;
    localtime_r(&now, &tm);

    lv_label_set_text_fmt(c->time_lbl, "%02d:%02d", tm.tm_hour, tm.tm_min);
    lv_label_set_text_fmt(c->sec_lbl,  "%02d", tm.tm_sec);

    static const char *WD[7] = { "SUNDAY","MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY","SATURDAY" };
    static const char *MO[12] = { "JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC" };
    lv_label_set_text_fmt(c->date_lbl, "%s  %02d %s %04d",
                          WD[tm.tm_wday % 7], tm.tm_mday, MO[tm.tm_mon % 12], tm.tm_year + 1900);

    int day_pct = (int)(((tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec) * 100L) / 86400L);
    lv_obj_set_width(c->dayprog, LV_PCT(day_pct ? day_pct : 1));

    const mos_telemetry_t *tl = mos_telemetry_get();
    lv_obj_set_width(c->g_cpu, LV_PCT(tl->cpu_pct ? tl->cpu_pct : 1));
    lv_label_set_text_fmt(c->g_cpu_v, "%u%%", tl->cpu_pct);
    lv_obj_set_width(c->g_ram, LV_PCT(tl->psram_pct ? tl->psram_pct : 1));
    lv_label_set_text_fmt(c->g_ram_v, "%u%%  /  %luKB free", tl->psram_pct, (unsigned long)tl->free_psram_kb);
    int tpct = tl->soc_temp_c > 0 ? (tl->soc_temp_c * 100) / 110 : 1;
    lv_obj_set_width(c->g_tmp, LV_PCT(tpct));
    lv_label_set_text_fmt(c->g_tmp_v, "%dC", tl->soc_temp_c);
    lv_obj_set_width(c->g_bat, LV_PCT(tl->battery_pct ? tl->battery_pct : 1));
    lv_label_set_text_fmt(c->g_bat_v, "%u%%  /  %u.%uV",
                          tl->battery_pct, tl->battery_mv / 1000, (tl->battery_mv / 100) % 10);

    /* Spectrum. TODO: swap this synthetic wave for real FFT bins off the
     * ES7210 I2S stream once App 02 brings up the audio pipeline. */
    c->phase += 0.12f;
    for (int i = 0; i < SPECTRUM_BARS; i++) {
        float a = (sinf(i * 0.35f + c->phase) * 0.5f + 0.5f);
        float b = (sinf(i * 0.08f + c->phase * 0.4f) * 0.5f + 0.5f);
        int h = (int)(a * b * 58.0f) + 3;
        lv_obj_set_height(c->spectrum[i], h);
    }
}

static void chrono_cleanup(lv_event_t *e)
{
    (void)e;
    if (s_c) {
        if (s_c->timer) lv_timer_delete(s_c->timer);
        lv_free(s_c);
        s_c = NULL;
    }
}

/* ------------------------------------------------------------------------ */
void app_chrono_open(const mos_app_t *self)
{
    (void)self;
    const mos_weather_t *w = mos_weather_get();

    chrono_t *c = lv_malloc_zeroed(sizeof(chrono_t));
    s_c = c;

    lv_obj_t *scr;
    lv_obj_t *body = mos_screen_create("APP 04  /  CHRONO-DECK", &scr);
    lv_obj_add_event_cb(scr, chrono_cleanup, LV_EVENT_DELETE, NULL);

    /* ---- clock ----------------------------------------------------------- */
    lv_obj_t *clock = mos_card(body, lv_color_black());
    lv_obj_set_width(clock, LV_PCT(100));
    lv_obj_set_height(clock, 210);
    lv_obj_set_style_radius(clock, MOS_RADIUS_LG, 0);
    lv_obj_set_style_bg_color(clock, lv_color_hex(0x070d15), 0);
    lv_obj_set_style_bg_grad_color(clock, lv_color_hex(0x081824), 0);
    lv_obj_set_style_bg_grad_dir(clock, LV_GRAD_DIR_VER, 0);

    c->time_lbl = lv_label_create(clock);
    lv_label_set_text(c->time_lbl, "00:00");
    lv_obj_set_style_text_font(c->time_lbl, MOS_F_CLOCK, 0);
    lv_obj_set_style_text_color(c->time_lbl, lv_color_hex(0xeaffff), 0);
    /* LVGL has no text-shadow; the glow is an offset copy behind the label. */
    lv_obj_align(c->time_lbl, LV_ALIGN_TOP_MID, -22, 22);

    c->sec_lbl = lv_label_create(clock);
    lv_label_set_text(c->sec_lbl, "00");
    lv_obj_set_style_text_font(c->sec_lbl, MOS_F_XL, 0);
    lv_obj_set_style_text_color(c->sec_lbl, MOS_C_MAGENTA, 0);
    lv_obj_align_to(c->sec_lbl, c->time_lbl, LV_ALIGN_OUT_RIGHT_BOTTOM, 10, -8);

    c->date_lbl = lv_label_create(clock);
    lv_label_set_text(c->date_lbl, "--");
    lv_obj_set_style_text_font(c->date_lbl, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(c->date_lbl, MOS_C_DIM, 0);
    lv_obj_set_style_text_letter_space(c->date_lbl, 4, 0);
    lv_obj_align(c->date_lbl, LV_ALIGN_TOP_MID, 0, 100);

    lv_obj_t *loc = lv_label_create(clock);
    lv_label_set_text(loc, LV_SYMBOL_GPS "  SINGAPORE   1.35N 103.82E");
    lv_obj_set_style_text_font(loc, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(loc, MOS_C_CYAN, 0);
    lv_obj_set_style_text_letter_space(loc, 2, 0);
    lv_obj_align(loc, LV_ALIGN_TOP_MID, 0, 126);

    lv_obj_t *ptrack = lv_obj_create(clock);
    lv_obj_remove_style_all(ptrack);
    lv_obj_set_size(ptrack, LV_PCT(100), 4);
    lv_obj_set_style_radius(ptrack, 2, 0);
    lv_obj_set_style_bg_color(ptrack, lv_color_hex(0x122232), 0);
    lv_obj_set_style_bg_opa(ptrack, LV_OPA_COVER, 0);
    lv_obj_align(ptrack, LV_ALIGN_BOTTOM_MID, 0, -6);

    c->dayprog = lv_obj_create(ptrack);
    lv_obj_remove_style_all(c->dayprog);
    lv_obj_set_height(c->dayprog, LV_PCT(100));
    lv_obj_set_width(c->dayprog, LV_PCT(1));
    lv_obj_set_style_radius(c->dayprog, 2, 0);
    lv_obj_set_style_bg_color(c->dayprog, MOS_C_CYAN, 0);
    lv_obj_set_style_bg_grad_color(c->dayprog, MOS_C_MAGENTA, 0);
    lv_obj_set_style_bg_grad_dir(c->dayprog, LV_GRAD_DIR_HOR, 0);
    lv_obj_set_style_bg_opa(c->dayprog, LV_OPA_COVER, 0);

    /* ---- ambient + system ------------------------------------------------ */
    lv_obj_t *row = mos_box(body);
    lv_obj_set_width(row, LV_PCT(100));
    lv_obj_set_height(row, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
    lv_obj_set_style_pad_column(row, MOS_GAP, 0);

    lv_obj_t *amb = mos_card(row, lv_color_black());
    lv_obj_set_flex_grow(amb, 11);
    lv_obj_set_height(amb, 186);
    lv_obj_set_flex_flow(amb, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(amb, 8, 0);

    lv_obj_t *ah = lv_label_create(amb);
    lv_label_set_text(ah, "AMBIENT");
    lv_obj_set_style_text_font(ah, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(ah, MOS_C_DIM, 0);
    lv_obj_set_style_text_letter_space(ah, 3, 0);

    lv_obj_t *tmp = lv_label_create(amb);
    lv_label_set_text_fmt(tmp, "%dC", w->temp_c);
    lv_obj_set_style_text_font(tmp, MOS_F_CLOCK, 0);
    lv_obj_set_style_text_color(tmp, lv_color_hex(0xeafcff), 0);

    lv_obj_t *cond = lv_label_create(amb);
    lv_label_set_text(cond, w->condition);
    lv_obj_set_style_text_font(cond, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(cond, MOS_C_AMBER, 0);
    lv_obj_set_style_text_letter_space(cond, 2, 0);

    lv_obj_t *sub = lv_label_create(amb);
    lv_label_set_text_fmt(sub, "FEELS %dC  /  HUMID %u%%\nWIND %u km/h  /  UV %u\nPM2.5 %u ug/m3",
                          w->feels_c, w->humidity, w->wind_kmh, w->uv, w->pm25);
    lv_obj_set_style_text_font(sub, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(sub, MOS_C_DIM, 0);
    lv_obj_set_style_text_line_space(sub, 6, 0);

    lv_obj_t *sys = mos_card(row, lv_color_black());
    lv_obj_set_flex_grow(sys, 10);
    lv_obj_set_height(sys, 186);
    lv_obj_set_flex_flow(sys, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(sys, 11, 0);

    lv_obj_t *sh = lv_label_create(sys);
    lv_label_set_text(sh, "SYSTEM");
    lv_obj_set_style_text_font(sh, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(sh, MOS_C_DIM, 0);
    lv_obj_set_style_text_letter_space(sh, 3, 0);

    gauge(sys, "CPU",      MOS_C_CYAN,    &c->g_cpu, &c->g_cpu_v);
    gauge(sys, "PSRAM",    MOS_C_MAGENTA, &c->g_ram, &c->g_ram_v);
    gauge(sys, "SOC TEMP", MOS_C_AMBER,   &c->g_tmp, &c->g_tmp_v);
    gauge(sys, "BATTERY",  MOS_C_GREEN,   &c->g_bat, &c->g_bat_v);

    /* ---- forecast --------------------------------------------------------- */
    lv_obj_t *fc = mos_card(body, lv_color_black());
    lv_obj_set_width(fc, LV_PCT(100));
    lv_obj_set_height(fc, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(fc, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(fc, 11, 0);

    lv_obj_t *fh = lv_label_create(fc);
    lv_label_set_text(fh, "5-DAY FORECAST");
    lv_obj_set_style_text_font(fh, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(fh, MOS_C_DIM, 0);
    lv_obj_set_style_text_letter_space(fh, 3, 0);

    lv_obj_t *frow = mos_box(fc);
    lv_obj_set_width(frow, LV_PCT(100));
    lv_obj_set_height(frow, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(frow, LV_FLEX_FLOW_ROW);
    lv_obj_set_style_pad_column(frow, 9, 0);

    for (int i = 0; i < 5; i++) {
        lv_obj_t *d = mos_card(frow, lv_color_black());
        lv_obj_set_flex_grow(d, 1);
        lv_obj_set_height(d, 96);
        lv_obj_set_style_pad_all(d, 8, 0);
        lv_obj_set_flex_flow(d, LV_FLEX_FLOW_COLUMN);
        lv_obj_set_flex_align(d, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
        lv_obj_set_style_pad_row(d, 7, 0);

        lv_obj_t *dn = lv_label_create(d);
        lv_label_set_text(dn, w->forecast[i].day);
        lv_obj_set_style_text_font(dn, MOS_F_MICRO, 0);
        lv_obj_set_style_text_color(dn, MOS_C_DIM, 0);

        lv_obj_t *sy = lv_label_create(d);
        lv_label_set_text(sy, w->forecast[i].sym);
        lv_obj_set_style_text_color(sy, MOS_C_AMBER, 0);

        lv_obj_t *tt = lv_label_create(d);
        lv_label_set_text_fmt(tt, "%d/%d", w->forecast[i].hi, w->forecast[i].lo);
        lv_obj_set_style_text_font(tt, MOS_F_MICRO, 0);
        lv_obj_set_style_text_color(tt, lv_color_hex(0xdff4ff), 0);
    }

    /* ---- spectrum --------------------------------------------------------- */
    lv_obj_t *sp = mos_card(body, lv_color_black());
    lv_obj_set_width(sp, LV_PCT(100));
    lv_obj_set_height(sp, 110);
    lv_obj_set_flex_flow(sp, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(sp, 10, 0);

    lv_obj_t *sph = lv_label_create(sp);
    lv_label_set_text(sph, "MIC  /  ES7210 SPECTRUM");
    lv_obj_set_style_text_font(sph, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(sph, MOS_C_DIM, 0);
    lv_obj_set_style_text_letter_space(sph, 3, 0);

    lv_obj_t *bars = mos_box(sp);
    lv_obj_set_width(bars, LV_PCT(100));
    lv_obj_set_height(bars, 62);
    lv_obj_set_flex_flow(bars, LV_FLEX_FLOW_ROW);
    lv_obj_set_flex_align(bars, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END);

    for (int i = 0; i < SPECTRUM_BARS; i++) {
        lv_obj_t *b = lv_obj_create(bars);
        lv_obj_remove_style_all(b);
        lv_obj_set_size(b, 8, 4);
        lv_obj_set_style_radius(b, 1, 0);
        lv_obj_set_style_bg_color(b, MOS_C_CYAN, 0);
        lv_obj_set_style_bg_grad_color(b, MOS_C_MAGENTA, 0);
        lv_obj_set_style_bg_grad_dir(b, LV_GRAD_DIR_VER, 0);
        lv_obj_set_style_bg_opa(b, LV_OPA_COVER, 0);
        c->spectrum[i] = b;
    }

    /* ---- ticker ----------------------------------------------------------- */
    lv_obj_t *tick = mos_card(body, lv_color_black());
    lv_obj_set_width(tick, LV_PCT(100));
    lv_obj_set_height(tick, LV_SIZE_CONTENT);
    lv_obj_set_style_pad_ver(tick, 10, 0);

    lv_obj_t *tl = lv_label_create(tick);
    lv_label_set_long_mode(tl, LV_LABEL_LONG_SCROLL_CIRCULAR);
    lv_obj_set_width(tl, LV_PCT(100));
    lv_label_set_text(tl,
        LV_SYMBOL_OK " HA: 14 entities online    "
        LV_SYMBOL_WARNING " Plant #3 soil 22% - needs water    "
        LV_SYMBOL_DOWNLOAD " OTA v0.4.2 available    "
        LV_SYMBOL_SETTINGS " RS485 bus idle    "
        LV_SYMBOL_SD_CARD " SD 12.4GB free    ");
    lv_obj_set_style_text_font(tl, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(tl, MOS_C_GREEN, 0);
    lv_obj_set_style_text_letter_space(tl, 1, 0);

    /* ---- run -------------------------------------------------------------- */
    c->timer = lv_timer_create(chrono_tick, 1000, c);
    chrono_tick(c->timer);

    mos_nav_push(scr);
    ESP_LOGI(TAG, "chrono-deck up");
}
```

### `main/app_matchos.c`

```c
/*
 * MATCH//OS — the content engine app.  Apps 11..15 all land here.
 *
 * Three screens, all generic:
 *   1. group picker  ("select by places" for Journey, cuisine for Kitchen)
 *   2. item list
 *   3. item detail, rendered from the section array
 *
 * The only thing that differs between Journey, Kitchen, Works, Studio and
 * Living is `app->content_dir`. That is the whole point.
 */
#include <stdio.h>
#include <string.h>
#include "apps.h"
#include "mos_apps.h"
#include "mos_theme.h"
#include "mos_nav.h"
#include "mos_content.h"
#include "esp_log.h"

static const char *TAG = "app_matchos";

/* One engine instance at a time — you cannot have two content apps open. */
static const mos_app_t *s_app;
static mos_index_t      s_index;
static mos_detail_t     s_detail;
static char             s_group[48];

static void open_list(const char *group);

/* ------------------------------------------------------------------------ */
static void error_screen(const char *title, const char *msg)
{
    lv_obj_t *scr;
    lv_obj_t *body = mos_screen_create(title, &scr);

    lv_obj_t *card = mos_card(body, MOS_C_MAGENTA);
    lv_obj_set_width(card, LV_PCT(100));
    lv_obj_set_height(card, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(card, 10, 0);

    lv_obj_t *h = lv_label_create(card);
    lv_label_set_text(h, LV_SYMBOL_WARNING "  NO CONTENT");
    lv_obj_set_style_text_font(h, MOS_F_MED, 0);
    lv_obj_set_style_text_color(h, MOS_C_MAGENTA, 0);

    lv_obj_t *m = lv_label_create(card);
    lv_label_set_text(m, msg);
    lv_label_set_long_mode(m, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(m, LV_PCT(100));
    lv_obj_set_style_text_font(m, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(m, MOS_C_TXT, 0);
    lv_obj_set_style_text_line_space(m, 6, 0);

    lv_obj_t *hint = lv_label_create(card);
    lv_label_set_text(hint,
        "Put an index.json in this folder on the microSD card.\n"
        "See sdcard/README-schema.md in the project for the format.");
    lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(hint, LV_PCT(100));
    lv_obj_set_style_text_font(hint, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(hint, MOS_C_DIM, 0);
    lv_obj_set_style_text_line_space(hint, 5, 0);

    mos_nav_push(scr);
}

/* ------------------------------------------------------------------------ */
/* 3 — item detail                                                           */
/* ------------------------------------------------------------------------ */
static void render_section(lv_obj_t *body, const mos_section_t *sec, lv_color_t ac)
{
    if (sec->title[0]) mos_section_header(body, sec->title, NULL);

    switch (sec->type) {

    case MOS_SEC_TEXT: {
        lv_obj_t *card = mos_card(body, lv_color_black());
        lv_obj_set_width(card, LV_PCT(100));
        lv_obj_set_height(card, LV_SIZE_CONTENT);
        lv_obj_t *l = lv_label_create(card);
        lv_label_set_text(l, sec->body);
        lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
        lv_obj_set_width(l, LV_PCT(100));
        lv_obj_set_style_text_font(l, MOS_F_TINY, 0);
        lv_obj_set_style_text_color(l, lv_color_hex(0xa8c8da), 0);
        lv_obj_set_style_text_line_space(l, 7, 0);
        break;
    }

    case MOS_SEC_LIST: {
        lv_obj_t *card = mos_card(body, lv_color_black());
        lv_obj_set_width(card, LV_PCT(100));
        lv_obj_set_height(card, LV_SIZE_CONTENT);
        lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
        lv_obj_set_style_pad_row(card, 10, 0);
        for (int i = 0; i < sec->row_count; i++) {
            lv_obj_t *row = mos_box(card);
            lv_obj_set_width(row, LV_PCT(100));
            lv_obj_set_height(row, LV_SIZE_CONTENT);
            lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
            lv_obj_set_style_pad_column(row, 9, 0);

            lv_obj_t *b = lv_label_create(row);
            lv_label_set_text(b, LV_SYMBOL_RIGHT);
            lv_obj_set_style_text_font(b, MOS_F_MICRO, 0);
            lv_obj_set_style_text_color(b, ac, 0);

            lv_obj_t *t = lv_label_create(row);
            lv_label_set_text(t, sec->k[i]);
            lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
            lv_obj_set_flex_grow(t, 1);
            lv_obj_set_style_text_font(t, MOS_F_TINY, 0);
            lv_obj_set_style_text_color(t, MOS_C_TXT, 0);
            lv_obj_set_style_text_line_space(t, 5, 0);
        }
        break;
    }

    case MOS_SEC_KV: {
        lv_obj_t *card = mos_card(body, lv_color_black());
        lv_obj_set_width(card, LV_PCT(100));
        lv_obj_set_height(card, LV_SIZE_CONTENT);
        lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
        lv_obj_set_style_pad_row(card, 11, 0);
        for (int i = 0; i < sec->row_count; i++) {
            lv_obj_t *row = mos_box(card);
            lv_obj_set_width(row, LV_PCT(100));
            lv_obj_set_height(row, LV_SIZE_CONTENT);
            lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
            lv_obj_set_style_pad_column(row, 12, 0);

            lv_obj_t *k = lv_label_create(row);
            lv_label_set_text(k, sec->k[i]);
            lv_obj_set_width(k, 150);
            lv_obj_set_style_text_font(k, MOS_F_MICRO, 0);
            lv_obj_set_style_text_color(k, MOS_C_DIM, 0);
            lv_obj_set_style_text_letter_space(k, 1, 0);

            lv_obj_t *v = lv_label_create(row);
            lv_label_set_text(v, sec->v[i]);
            lv_label_set_long_mode(v, LV_LABEL_LONG_WRAP);
            lv_obj_set_flex_grow(v, 1);
            lv_obj_set_style_text_font(v, MOS_F_TINY, 0);
            lv_obj_set_style_text_color(v, MOS_C_TXT, 0);
        }
        break;
    }

    case MOS_SEC_CHECK: {
        lv_obj_t *card = mos_card(body, lv_color_black());
        lv_obj_set_width(card, LV_PCT(100));
        lv_obj_set_height(card, LV_SIZE_CONTENT);
        lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
        lv_obj_set_style_pad_row(card, 10, 0);
        for (int i = 0; i < sec->row_count; i++) {
            lv_obj_t *cb = lv_checkbox_create(card);
            lv_checkbox_set_text(cb, sec->k[i]);
            if (sec->done[i]) lv_obj_add_state(cb, LV_STATE_CHECKED);
            lv_obj_set_style_text_font(cb, MOS_F_TINY, 0);
            lv_obj_set_style_text_color(cb, sec->done[i] ? MOS_C_DIM : MOS_C_TXT, 0);
            lv_obj_set_style_bg_color(cb, ac, LV_PART_INDICATOR | LV_STATE_CHECKED);
            lv_obj_set_style_border_color(cb, MOS_C_LINE, LV_PART_INDICATOR);
            /* TODO: persist ticks back to the SD json on change. */
        }
        break;
    }

    case MOS_SEC_IMAGE: {
        /* Images live next to the json. LVGL's FS driver letter for the SD
         * card is set in bsp_sdcard_mount(); 'S:' by default. */
        char src[256];
        snprintf(src, sizeof(src), "S:%s/%s", s_app->content_dir, sec->src);
        lv_obj_t *img = lv_image_create(body);
        lv_image_set_src(img, src);
        lv_obj_set_style_radius(img, MOS_RADIUS, 0);
        lv_obj_set_style_clip_corner(img, true, 0);
        break;
    }

    default: break;
    }
}

static void open_detail(lv_event_t *e)
{
    const mos_item_t *item = lv_event_get_user_data(e);
    char err[160];

    if (!item->detail[0]) {
        error_screen(item->title, "This item has no \"detail\" file set in index.json.");
        return;
    }
    if (!mos_content_load_detail(s_app->content_dir, item->detail, &s_detail, err, sizeof(err))) {
        ESP_LOGW(TAG, "detail load failed: %s", err);
        error_screen(item->title, err);
        return;
    }

    lv_color_t ac = item->accent ? lv_color_hex(item->accent) : mos_app_color(s_app);

    lv_obj_t *scr;
    lv_obj_t *body = mos_screen_create(s_detail.title[0] ? s_detail.title : item->title, &scr);

    if (s_detail.subtitle[0]) {
        lv_obj_t *sub = lv_label_create(body);
        lv_label_set_text(sub, s_detail.subtitle);
        lv_label_set_long_mode(sub, LV_LABEL_LONG_WRAP);
        lv_obj_set_width(sub, LV_PCT(100));
        lv_obj_set_style_text_font(sub, MOS_F_TINY, 0);
        lv_obj_set_style_text_color(sub, ac, 0);
        lv_obj_set_style_text_letter_space(sub, 1, 0);
    }

    for (int i = 0; i < s_detail.count; i++) render_section(body, &s_detail.sections[i], ac);

    mos_nav_push(scr);
}

/* ------------------------------------------------------------------------ */
/* 2 — item list                                                             */
/* ------------------------------------------------------------------------ */
static void open_list(const char *group)
{
    lv_color_t ac = mos_app_color(s_app);

    char title[MOS_STR];
    snprintf(title, sizeof(title), "%s", group && group[0] ? group : s_index.title);

    lv_obj_t *scr;
    lv_obj_t *body = mos_screen_create(title, &scr);

    int shown = 0;
    for (int i = 0; i < s_index.count; i++) {
        const mos_item_t *it = &s_index.items[i];
        if (group && group[0] && strcmp(it->group, group) != 0) continue;
        shown++;

        lv_color_t iac = it->accent ? lv_color_hex(it->accent) : ac;

        lv_obj_t *card = mos_card(body, iac);
        lv_obj_set_width(card, LV_PCT(100));
        lv_obj_set_height(card, LV_SIZE_CONTENT);
        lv_obj_set_style_pad_all(card, 16, 0);
        lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
        lv_obj_set_style_pad_row(card, 7, 0);
        lv_obj_add_flag(card, LV_OBJ_FLAG_CLICKABLE);
        lv_obj_add_event_cb(card, open_detail, LV_EVENT_CLICKED, (void *)it);

        if (it->badge[0]) {
            lv_obj_t *b = lv_label_create(card);
            lv_label_set_text(b, it->badge);
            lv_obj_set_style_text_font(b, MOS_F_MICRO, 0);
            lv_obj_set_style_text_color(b, iac, 0);
            lv_obj_set_style_text_letter_space(b, 2, 0);
        }

        lv_obj_t *t = lv_label_create(card);
        lv_label_set_text(t, it->title);
        lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
        lv_obj_set_width(t, LV_PCT(100));
        lv_obj_set_style_text_font(t, MOS_F_MED, 0);
        lv_obj_set_style_text_color(t, MOS_C_TXT_HI, 0);

        if (it->subtitle[0]) {
            lv_obj_t *s = lv_label_create(card);
            lv_label_set_text(s, it->subtitle);
            lv_label_set_long_mode(s, LV_LABEL_LONG_WRAP);
            lv_obj_set_width(s, LV_PCT(100));
            lv_obj_set_style_text_font(s, MOS_F_TINY, 0);
            lv_obj_set_style_text_color(s, MOS_C_DIM, 0);
            lv_obj_set_style_text_line_space(s, 5, 0);
        }
    }

    if (!shown) {
        lv_obj_t *l = lv_label_create(body);
        lv_label_set_text(l, "Nothing here yet.");
        lv_obj_set_style_text_font(l, MOS_F_TINY, 0);
        lv_obj_set_style_text_color(l, MOS_C_DIM, 0);
    }

    mos_nav_push(scr);
}

static void group_click(lv_event_t *e)
{
    const char *g = lv_event_get_user_data(e);
    strncpy(s_group, g ? g : "", sizeof(s_group) - 1);
    open_list(s_group);
}

/* ------------------------------------------------------------------------ */
/* 1 — group picker (this is the "selection by places" for Match Journey)    */
/* ------------------------------------------------------------------------ */
static void open_groups(void)
{
    lv_color_t ac = mos_app_color(s_app);

    lv_obj_t *scr;
    lv_obj_t *body = mos_screen_create(s_index.title[0] ? s_index.title : s_app->name, &scr);

    if (s_index.subtitle[0]) {
        lv_obj_t *sub = lv_label_create(body);
        lv_label_set_text(sub, s_index.subtitle);
        lv_label_set_long_mode(sub, LV_LABEL_LONG_WRAP);
        lv_obj_set_width(sub, LV_PCT(100));
        lv_obj_set_style_text_font(sub, MOS_F_TINY, 0);
        lv_obj_set_style_text_color(sub, MOS_C_DIM, 0);
    }

    mos_section_header(body, "SELECT", NULL);

    lv_obj_t *grid = mos_box(body);
    lv_obj_set_width(grid, LV_PCT(100));
    lv_obj_set_height(grid, LV_SIZE_CONTENT);
    lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
    lv_obj_set_style_pad_column(grid, MOS_GAP, 0);
    lv_obj_set_style_pad_row(grid, MOS_GAP, 0);

    /* Collect distinct group names in first-seen order — no sorting, the
     * author controls the order by how they write index.json. */
    static char seen[16][48];
    int nseen = 0;

    for (int i = 0; i < s_index.count && nseen < 16; i++) {
        const char *g = s_index.items[i].group;
        if (!g[0]) continue;
        bool dup = false;
        for (int j = 0; j < nseen; j++) if (!strcmp(seen[j], g)) { dup = true; break; }
        if (dup) continue;
        strncpy(seen[nseen], g, sizeof(seen[0]) - 1);

        int count = 0;
        for (int k = 0; k < s_index.count; k++) if (!strcmp(s_index.items[k].group, g)) count++;

        lv_obj_t *card = mos_card(grid, ac);
        lv_obj_set_size(card, (MOS_SCREEN_W - 2 * MOS_PAD - MOS_GAP) / 2, 96);
        lv_obj_add_flag(card, LV_OBJ_FLAG_CLICKABLE);
        lv_obj_add_event_cb(card, group_click, LV_EVENT_CLICKED, seen[nseen]);

        lv_obj_t *t = lv_label_create(card);
        lv_label_set_text(t, g);
        lv_obj_set_style_text_font(t, MOS_F_MED, 0);
        lv_obj_set_style_text_color(t, MOS_C_TXT_HI, 0);
        lv_obj_align(t, LV_ALIGN_LEFT_MID, 0, -8);

        lv_obj_t *c = lv_label_create(card);
        lv_label_set_text_fmt(c, "%d ITEM%s", count, count == 1 ? "" : "S");
        lv_obj_set_style_text_font(c, MOS_F_MICRO, 0);
        lv_obj_set_style_text_color(c, MOS_C_DIM, 0);
        lv_obj_set_style_text_letter_space(c, 2, 0);
        lv_obj_align(c, LV_ALIGN_LEFT_MID, 0, 16);

        nseen++;
    }

    lv_obj_t *all = mos_button(body, "SHOW EVERYTHING", false);
    lv_obj_set_width(all, LV_PCT(100));
    lv_obj_add_event_cb(all, group_click, LV_EVENT_CLICKED, (void *)"");

    mos_nav_push(scr);
}

/* ------------------------------------------------------------------------ */
void app_matchos_open(const mos_app_t *self)
{
    s_app = self;
    s_group[0] = '\0';

    char err[160];
    if (!mos_content_load_index(self->content_dir, &s_index, err, sizeof(err))) {
        ESP_LOGW(TAG, "index load failed: %s", err);
        error_screen(self->name, err);
        return;
    }

    if (s_index.group_by_field) open_groups();
    else                        open_list(NULL);
}
```

### `main/app_stub.c`

```c
/*
 * MATCH//OS — placeholder app.
 *
 * Every app that has not been written yet routes here. Rather than a dead
 * "coming soon" box, it shows the plan for that app straight from the
 * registry, so the device works as its own spec sheet on the bench.
 *
 * To implement a real app: write app_<x>_open(), declare it in apps.h, and
 * change the function pointer in MOS_APPS. Delete nothing.
 */
#include "apps.h"
#include "mos_apps.h"
#include "mos_theme.h"
#include "mos_nav.h"

static void plan_cb(lv_event_t *e)
{
    const mos_app_t *a = lv_event_get_user_data(e);
    mos_detail_open(a);
}

void app_stub_open(const mos_app_t *a)
{
    lv_color_t ac = mos_app_color(a);

    lv_obj_t *scr;
    lv_obj_t *body = mos_screen_create(a->name, &scr);

    lv_obj_t *card = mos_card(body, ac);
    lv_obj_set_width(card, LV_PCT(100));
    lv_obj_set_height(card, LV_SIZE_CONTENT);
    lv_obj_set_style_radius(card, MOS_RADIUS_LG, 0);
    lv_obj_set_style_pad_all(card, 18, 0);
    lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(card, 12, 0);

    lv_obj_t *st = lv_label_create(card);
    lv_label_set_text_fmt(st, "%s  /  NOT BUILT YET", mos_status_str(a->status));
    lv_obj_set_style_text_font(st, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(st, ac, 0);
    lv_obj_set_style_text_letter_space(st, 2, 0);

    lv_obj_t *d = lv_label_create(card);
    lv_label_set_text(d, a->desc);
    lv_label_set_long_mode(d, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(d, LV_PCT(100));
    lv_obj_set_style_text_font(d, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(d, MOS_C_TXT, 0);
    lv_obj_set_style_text_line_space(d, 7, 0);

    lv_obj_t *where = lv_label_create(card);
    lv_label_set_text_fmt(where,
        "Implement in main/app_%02u_*.c, then point MOS_APPS[%u].open at it.",
        a->id, a->id - 1);
    lv_label_set_long_mode(where, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(where, LV_PCT(100));
    lv_obj_set_style_text_font(where, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(where, MOS_C_DIM, 0);

    lv_obj_t *b = mos_button(body, "SEE THE FULL PLAN", true);
    lv_obj_set_width(b, LV_PCT(100));
    lv_obj_add_event_cb(b, plan_cb, LV_EVENT_CLICKED, (void *)a);

    mos_nav_push(scr);
}
```

### `main/mos_egg.h`

```c
/*
 * MATCH//OS — the sigil.
 *
 * Undocumented on purpose. Seven taps on the MATCH//OS wordmark in the status
 * bar, inside four seconds, opens a screen that is not in the app registry and
 * has no tile. It is not reachable any other way.
 *
 * If you are reading this file you have already found it. Don't put it in the
 * README.
 */
#pragma once

#include "lvgl.h"

#ifdef __cplusplus
extern "C" {
#endif

/* Attach the tap counter to any object — in practice, the status-bar logo.
 * Makes the object clickable as a side effect. */
void mos_egg_arm(lv_obj_t *target);

/* Opens the sigil screen directly. Called by the counter; exposed so a
 * development build can jump straight to it without tapping. */
void mos_egg_open(void);

/* How many times the sigil has been opened on this device, ever. Persisted in
 * NVS under namespace "matchos", key "sigil". */
uint32_t mos_egg_find_count(void);

#ifdef __cplusplus
}
#endif
```

### `main/mos_egg.c`

```c
/*
 * MATCH//OS — the sigil.
 *
 * Seven taps on the wordmark, four second window. The counter resets if you
 * are slow, so a stray double-tap while carrying the tablet never triggers it.
 *
 * The screen itself is deliberately plain: a boot-log pastiche, the build
 * fingerprint, and a line that only makes sense if you built the thing. No
 * back chevron is drawn by the nav bar helper for this one — the whole screen
 * is the back button.
 */
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <inttypes.h>

#include "esp_timer.h"
#include "esp_app_desc.h"
#include "esp_log.h"
#include "nvs.h"
#include "nvs_flash.h"

#include "lvgl.h"
#include "mos_theme.h"
#include "mos_nav.h"
#include "mos_apps.h"
#include "mos_telemetry.h"
#include "mos_egg.h"

static const char *TAG = "MATCH_OS";

#define MOS_EGG_TAPS_REQUIRED   7
#define MOS_EGG_WINDOW_US       (4 * 1000 * 1000LL)

/* Lives in .rodata whether or not anybody ever taps the logo. Run `strings` on
 * the .bin and it is right there. That is the point — the sigil should be
 * findable two ways: with your thumb, or with a hex editor. */
const char MOS_EGG_SIGIL[] =
    "MATCH//OS :: built by Match, in Singapore, on a kitchen table, "
    "one register at a time. If you are reading this out of a firmware dump: "
    "hello, and mind the RTC.";

static uint8_t  s_taps;
static int64_t  s_first_tap_us;
static lv_obj_t *s_screen;

/* ------------------------------------------------------------------------ */
/* persistence                                                               */
/* ------------------------------------------------------------------------ */
static uint32_t egg_count_bump(void)
{
    nvs_handle_t h;
    uint32_t n = 0;

    if (nvs_open("matchos", NVS_READWRITE, &h) != ESP_OK) return 0;
    nvs_get_u32(h, "sigil", &n);
    n++;
    nvs_set_u32(h, "sigil", n);
    nvs_commit(h);
    nvs_close(h);
    return n;
}

uint32_t mos_egg_find_count(void)
{
    nvs_handle_t h;
    uint32_t n = 0;

    if (nvs_open("matchos", NVS_READONLY, &h) != ESP_OK) return 0;
    nvs_get_u32(h, "sigil", &n);
    nvs_close(h);
    return n;
}

/* ------------------------------------------------------------------------ */
/* the screen                                                                */
/* ------------------------------------------------------------------------ */
static void egg_dismiss_cb(lv_event_t *e)
{
    (void)e;
    mos_nav_pop();
    s_screen = NULL;
}

/* One monospace-ish line of the fake boot log. */
static void egg_line(lv_obj_t *parent, const char *text, lv_color_t c)
{
    lv_obj_t *l = lv_label_create(parent);
    lv_label_set_text(l, text);
    lv_obj_set_style_text_font(l, MOS_F_TINY, 0);
    lv_obj_set_style_text_color(l, c, 0);
    lv_obj_set_style_text_letter_space(l, 1, 0);
}

void mos_egg_open(void)
{
    if (s_screen) return;   /* already up */

    uint32_t found = egg_count_bump();
    ESP_LOGI(TAG, "%s", MOS_EGG_SIGIL);
    ESP_LOGI(TAG, "sigil opened %" PRIu32 " time(s) on this device", found);

    lv_obj_t *body = mos_screen_create_bare(&s_screen);
    lv_obj_set_style_pad_all(body, 34, 0);
    lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(body, 7, 0);

    /* The whole screen dismisses. No chrome, nothing to discover twice. */
    lv_obj_add_flag(body, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_add_event_cb(body, egg_dismiss_cb, LV_EVENT_CLICKED, NULL);

    /* --- wordmark ------------------------------------------------------- */
    lv_obj_t *mark = lv_label_create(body);
    lv_label_set_text(mark,
        "\xE2\x96\x88\xE2\x96\x88\xE2\x95\x97   \xE2\x96\x88\xE2\x96\x88\xE2\x95\x97 "
        "\xE2\x96\x88\xE2\x96\x88\xE2\x96\x88\xE2\x96\x88\xE2\x96\x88\xE2\x95\x97\n"
        "MATCH//OS");
    lv_obj_set_style_text_font(mark, MOS_F_XL, 0);
    lv_obj_set_style_text_color(mark, MOS_C_MAGENTA, 0);
    lv_obj_set_style_text_letter_space(mark, 5, 0);

    lv_obj_t *sub = lv_label_create(body);
    lv_label_set_text(sub, "SIGIL \xE2\x80\x94 NOT IN THE REGISTRY");
    lv_obj_set_style_text_font(sub, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(sub, MOS_C_DIMMER, 0);
    lv_obj_set_style_text_letter_space(sub, 3, 0);

    mos_rule(body);

    /* --- boot-log pastiche ---------------------------------------------- */
    const esp_app_desc_t *app = esp_app_get_description();
    const mos_telemetry_t *tl = mos_telemetry_get();
    uint32_t up_s = (uint32_t)(esp_timer_get_time() / 1000000);

    char buf[128];

    egg_line(body, "[  0.000] rst:0x1 (POWERON), boot:0x8", MOS_C_DIM);
    egg_line(body, "[  0.004] esp32p4: rev 0, 2x RISC-V @ 400MHz", MOS_C_DIM);
    egg_line(body, "[  0.061] psram: 32MB octal, 200MHz", MOS_C_DIM);
    egg_line(body, "[  0.198] mipi-dsi: 2 lanes, 720x1280 @ 60", MOS_C_DIM);

    snprintf(buf, sizeof(buf), "[  0.402] matchos: %d apps registered", MOS_APP_COUNT);
    egg_line(body, buf, MOS_C_CYAN);

    snprintf(buf, sizeof(buf), "[  0.403] build: %s  %s %s",
             app ? app->version : "dev", app ? app->date : "", app ? app->time : "");
    egg_line(body, buf, MOS_C_CYAN);

    snprintf(buf, sizeof(buf), "[  0.404] idf: %s", app ? app->idf_ver : "?");
    egg_line(body, buf, MOS_C_CYAN);

    snprintf(buf, sizeof(buf),
             "[%3" PRIu32 ".000] uptime %" PRIu32 "h %02" PRIu32 "m  batt %u%%  %d\xC2\xB0""C",
             up_s, up_s / 3600u, (up_s % 3600u) / 60u,
             (unsigned)tl->battery_pct, (int)tl->soc_temp_c);
    egg_line(body, buf, MOS_C_GREEN);

    snprintf(buf, sizeof(buf), "[     .   ] sigil found %" PRIu32 "x on this unit", found);
    egg_line(body, buf, MOS_C_AMBER);

    mos_rule(body);

    /* --- the actual message --------------------------------------------- */
    lv_obj_t *note = lv_label_create(body);
    lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(note, LV_PCT(100));
    lv_label_set_text(note,
        "Sixteen tiles. Five of them are the same app wearing different "
        "names, and the sixteenth reads the sky.\n\n"
        "Nothing on this device phones home. The recipes, the itineraries, "
        "the plant log \xE2\x80\x94 all of it sits on a card you can pull out "
        "with your fingernail.\n\n"
        "Made in Singapore. Reflash freely.");
    lv_obj_set_style_text_font(note, MOS_F_BODY, 0);
    lv_obj_set_style_text_color(note, MOS_C_TXT, 0);
    lv_obj_set_style_text_line_space(note, 6, 0);

    lv_obj_t *tap = lv_label_create(body);
    lv_label_set_text(tap, "TAP ANYWHERE TO RETURN");
    lv_obj_set_style_text_font(tap, MOS_F_MICRO, 0);
    lv_obj_set_style_text_color(tap, MOS_C_DIMMER, 0);
    lv_obj_set_style_text_letter_space(tap, 2, 0);

    mos_nav_push(s_screen);
}

/* ------------------------------------------------------------------------ */
/* the counter                                                               */
/* ------------------------------------------------------------------------ */
static void egg_tap_cb(lv_event_t *e)
{
    (void)e;
    int64_t now = esp_timer_get_time();

    if (s_taps == 0 || (now - s_first_tap_us) > MOS_EGG_WINDOW_US) {
        s_taps = 1;
        s_first_tap_us = now;
        return;
    }

    if (++s_taps >= MOS_EGG_TAPS_REQUIRED) {
        s_taps = 0;
        mos_egg_open();
    }
}

void mos_egg_arm(lv_obj_t *target)
{
    if (!target) return;
    lv_obj_add_flag(target, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_set_ext_click_area(target, 12);   /* the wordmark is small */
    lv_obj_add_event_cb(target, egg_tap_cb, LV_EVENT_CLICKED, NULL);
}
```

### `sdcard/README-schema.md`

````markdown
# MATCH//OS content schema v1

Copy the `matchos/` folder to the root of a FAT32 microSD card and put it in the
Tab5. Apps 11–15 read from here at runtime — no reflash needed to change content.

```
/matchos/
  journey/   index.json  kansai-2026.json  hokkaido-2025.json  ...
  kitchen/   index.json  hainan-chicken.json  ...
  works/     index.json  tab5-build.json  ...
  studio/    index.json  + image files
  living/    index.json  plants.json  ...
```

Adding a sixth section later — say `garage/` — needs one new folder here and one
new row in `MOS_APPS` pointing `content_dir` at it. No new UI code.

---

## `index.json` — the list screen

```json
{
  "schema": 1,
  "title": "Match Journey",
  "subtitle": "Pick a place to start.",
  "group_by": "place",
  "items": [ { ... } ]
}
```

| Field | Required | Notes |
|---|---|---|
| `schema` | yes | Must be `1`. The loader refuses anything else rather than half-rendering. |
| `title` | yes | Shown in the top bar. |
| `subtitle` | no | One line under the title. |
| `group_by` | no | Any non-empty string turns on the group picker screen. The value itself is just a label for you — the grouping uses each item's `group` field. Omit it to go straight to a flat list. |
| `items` | yes | Max 64. |

### Item fields

| Field | Required | Notes |
|---|---|---|
| `id` | no | Stable identifier, useful later for favourites. |
| `title` | yes | Card headline. |
| `subtitle` | no | Wraps, keep it under ~80 chars. |
| `group` | only if `group_by` is set | The bucket this item falls into. Distinct values become the picker tiles, **in first-seen order** — so the order you write them is the order they appear. Max 16 groups. |
| `badge` | no | Small coloured line above the title: `IN 43 DAYS`, `NEEDS WATER`, `ACTIVE`. |
| `accent` | no | Six hex digits, no `#`. Falls back to the app's accent. |
| `detail` | no | Filename of the detail JSON in the same folder. Without it the card opens an empty-state screen. |
| `image` | no | Cover image filename. Reserved for Match Studio. |

---

## Detail files — the item screen

```json
{
  "schema": 1,
  "title": "Kansai Loop",
  "subtitle": "12 DAYS · SEPTEMBER 2026",
  "sections": [ { "type": "...", ... } ]
}
```

Max 24 sections, 32 rows each. Unknown `type` values are **skipped silently**, so
a card written for a future firmware still renders its known sections.

### `text`

```json
{ "type": "text", "title": "NOTES", "body": "Free prose, \n for line breaks." }
```

Body caps at 512 bytes. Longer than that, split into two sections.

### `list`

```json
{ "type": "list", "title": "DAY 1-3", "items": ["First thing", "Second thing"] }
```

Bulleted, wraps, accent-coloured markers.

### `kv`

```json
{ "type": "kv", "title": "AT A GLANCE",
  "rows": [["DATES", "12 - 24 Sep 2026"], ["BUDGET", "SGD 3,200"]] }
```

Two columns, 150px key column. Keys cap at 48 chars, values at 96.

### `check`

```json
{ "type": "check", "title": "PACKING",
  "items": [{ "t": "Passport", "done": true }, { "t": "Rain shell", "done": false }] }
```

Renders real LVGL checkboxes. **Ticks do not yet persist back to the card** —
that is on the Phase 3 list in `works/tab5-build.json`.

### `image`

```json
{ "type": "image", "title": "MAP", "src": "kyoto-map.png" }
```

Resolved as `S:<content_dir>/<src>`. PNG and JPEG only, and both decoders must be
on in sdkconfig (`LV_USE_LODEPNG`, `LV_USE_TJPGD`). Keep images under ~1MB —
they decode into PSRAM.

---

## Failure behaviour

Every failure lands on a readable screen rather than a crash or a blank page:

| Situation | What you see |
|---|---|
| No card inserted | `No SD card mounted` |
| Folder missing `index.json` | `Not found: /sdcard/matchos/<dir>/index.json` |
| Invalid JSON | `Malformed JSON in <path>` |
| Wrong schema version | `Schema vN, expected v1` |
| Item has no `detail` | `This item has no "detail" file set in index.json.` |

If you are editing JSON on a laptop, run it through `python3 -m json.tool` before
copying — a trailing comma is the most common way to get the malformed message.
````

### `sdcard/matchos/journey/index.json`

```json
{
  "schema": 1,
  "title": "Match Journey",
  "subtitle": "Trips and itineraries. Pick a place to start.",
  "group_by": "place",
  "items": [
    {
      "id": "kansai-2026",
      "title": "Kansai Loop",
      "subtitle": "12 days · Osaka / Kyoto / Nara / Koyasan",
      "group": "Japan",
      "badge": "IN 43 DAYS",
      "accent": "00e5ff",
      "detail": "kansai-2026.json"
    },
    {
      "id": "hokkaido-2025",
      "title": "Hokkaido Winter",
      "subtitle": "8 days · Sapporo / Otaru / Furano",
      "group": "Japan",
      "badge": "COMPLETED",
      "detail": "hokkaido-2025.json"
    },
    {
      "id": "taipei-food",
      "title": "Taipei Eating Trip",
      "subtitle": "5 days · night markets, nothing else",
      "group": "Taiwan",
      "badge": "IDEA",
      "accent": "ffb300",
      "detail": "taipei-food.json"
    },
    {
      "id": "seoul-2026",
      "title": "Seoul in Autumn",
      "subtitle": "6 days · Bukchon / Seongsu / Bukhansan",
      "group": "Korea",
      "badge": "PLANNING",
      "accent": "ff2d95",
      "detail": "seoul-2026.json"
    },
    {
      "id": "sg-weekends",
      "title": "Weekend Walks",
      "subtitle": "Places to go without a flight",
      "group": "Local",
      "accent": "22ff88",
      "detail": "sg-weekends.json"
    }
  ]
}
```

### `sdcard/matchos/journey/kansai-2026.json`

```json
{
  "schema": 1,
  "title": "Kansai Loop",
  "subtitle": "12 DAYS · SEPTEMBER 2026",
  "sections": [
    {
      "type": "kv",
      "title": "AT A GLANCE",
      "rows": [
        ["DATES",      "12 - 24 Sep 2026"],
        ["FLIGHT OUT", "SQ618  SIN 0910 -> KIX 1640"],
        ["FLIGHT HOME","SQ619  KIX 1130 -> SIN 1710"],
        ["RAIL",       "JR Kansai Wide Area Pass, 5 days"],
        ["BUDGET",     "SGD 3,200 all-in"],
        ["WEATHER",    "26-30C, humid, typhoon season tail"]
      ]
    },
    {
      "type": "list",
      "title": "DAY 1-3 · OSAKA",
      "items": [
        "Land KIX 1640, Nankai Rapi:t to Namba, ~45 min",
        "Stay: Namba, 3 nights",
        "Kuromon Market early, before the tour buses",
        "Shinsekai + Tsutenkaku at night",
        "Day trip: Osaka Castle park, go at opening"
      ]
    },
    {
      "type": "list",
      "title": "DAY 4-8 · KYOTO",
      "items": [
        "Fushimi Inari at 0630 — the whole point is the empty gates",
        "Arashiyama bamboo, then Okochi Sanso garden",
        "Nishiki Market for lunch, standing only",
        "Philosopher's Path in the late afternoon light",
        "Pontocho evening, book kaiseki ahead",
        "Day trip to Nara: deer park, Todai-ji, Nara-machi"
      ]
    },
    {
      "type": "list",
      "title": "DAY 9-12 · KOYASAN + HOME",
      "items": [
        "Nankai to Gokurakubashi, cable car up",
        "Shukubo temple stay, 2 nights, vegetarian dinner",
        "Okunoin cemetery walk at night with a torch",
        "Morning goma fire ritual at 0600",
        "Back to KIX via Namba"
      ]
    },
    {
      "type": "check",
      "title": "PACKING",
      "items": [
        { "t": "Passport + JR pass voucher", "done": true },
        { "t": "IC card (ICOCA) topped up",  "done": true },
        { "t": "Rain shell — typhoon season", "done": false },
        { "t": "Walking shoes, broken in",    "done": false },
        { "t": "Power bank + Type-C cables",  "done": false },
        { "t": "Tab5 + NP-F550 spare",        "done": false }
      ]
    },
    {
      "type": "text",
      "title": "NOTES",
      "body": "Book the Koyasan shukubo at least two months out — the good ones fill up. Kyoto in September is still hot; plan indoor things for 1200-1500. The JR Kansai Wide pass does not cover the Nankai line to Koyasan, buy that separately at Namba."
    }
  ]
}
```

### `sdcard/matchos/kitchen/index.json`

```json
{
  "schema": 1,
  "title": "Match Kitchen",
  "subtitle": "Recipes. Pick a category, then cook.",
  "group_by": "category",
  "items": [
    {
      "id": "hainan-chicken",
      "title": "Hainanese Chicken Rice",
      "subtitle": "90 min · serves 4 · the whole afternoon kind",
      "group": "Mains",
      "accent": "ffb300",
      "detail": "hainan-chicken.json"
    },
    {
      "id": "bak-kut-teh",
      "title": "Bak Kut Teh",
      "subtitle": "2 hr · serves 4 · peppery Teochew style",
      "group": "Soups",
      "detail": "bak-kut-teh.json"
    },
    {
      "id": "kaya",
      "title": "Pandan Kaya",
      "subtitle": "45 min · one jar · keeps 2 weeks",
      "group": "Sweet",
      "accent": "22ff88",
      "detail": "kaya.json"
    },
    {
      "id": "weeknight-noodles",
      "title": "15-Minute Chilli Oil Noodles",
      "subtitle": "15 min · serves 2 · nothing fancy",
      "group": "Fast",
      "accent": "ff2d95",
      "detail": "weeknight-noodles.json"
    }
  ]
}
```

### `sdcard/matchos/kitchen/hainan-chicken.json`

```json
{
  "schema": 1,
  "title": "Hainanese Chicken Rice",
  "subtitle": "90 MIN · SERVES 4",
  "sections": [
    {
      "type": "kv",
      "title": "TIMING",
      "rows": [
        ["ACTIVE",  "35 min"],
        ["TOTAL",   "90 min"],
        ["SERVES",  "4"],
        ["EQUIP",   "Big pot, ice bath, rice cooker"]
      ]
    },
    {
      "type": "list",
      "title": "CHICKEN",
      "items": [
        "1 whole chicken, ~1.5kg, at room temperature",
        "6 slices ginger, 4 spring onions, halved",
        "2 tbsp salt, rubbed all over then rinsed",
        "Sesame oil for the finish"
      ]
    },
    {
      "type": "list",
      "title": "RICE",
      "items": [
        "3 cups jasmine rice, rinsed until the water runs clear",
        "3 tbsp chicken fat, rendered",
        "6 cloves garlic, minced",
        "2 pandan leaves, knotted",
        "Poaching liquid instead of water, 1:1"
      ]
    },
    {
      "type": "list",
      "title": "METHOD",
      "items": [
        "Bring a pot of water to a bare simmer — never a rolling boil",
        "Lower the chicken in breast-down, add ginger and spring onion",
        "Poach 35-40 min at 80C, no bubbles breaking the surface",
        "Lift out, straight into the ice bath for 10 min — this is what makes the skin",
        "Rub with sesame oil, rest 15 min, then chop",
        "Render the fat, fry garlic and rice, cook in the poaching liquid",
        "Chilli sauce: chilli, garlic, ginger, lime, a ladle of stock"
      ]
    },
    {
      "type": "text",
      "title": "THE ONE THING",
      "body": "The ice bath is not optional. It stops the cooking and tightens the skin into that gelatinous layer — skip it and you have boiled chicken. Keep the poaching water below 85C the whole way; if it boils, the meat goes stringy and the stock turns cloudy."
    }
  ]
}
```

### `sdcard/matchos/works/index.json`

```json
{
  "schema": 1,
  "title": "Match Works",
  "subtitle": "What is on the bench right now.",
  "group_by": "area",
  "items": [
    {
      "id": "tab5-build",
      "title": "M5Stack Tab5 — MATCH//OS",
      "subtitle": "16-app launcher, LVGL 9, ESP-IDF 5.4",
      "group": "Tab5",
      "badge": "ACTIVE",
      "accent": "ffb300",
      "detail": "tab5-build.json"
    },
    {
      "id": "tab5-parts",
      "title": "Parts & Wiring",
      "subtitle": "Pinouts, ordered items, bench notes",
      "group": "Tab5",
      "detail": "tab5-parts.json"
    },
    {
      "id": "ha-automation",
      "title": "Home Assistant",
      "subtitle": "Entities, automations, what still breaks",
      "group": "Automation",
      "badge": "ONGOING",
      "accent": "00e5ff",
      "detail": "ha-automation.json"
    },
    {
      "id": "plant-sensors",
      "title": "Plant Sensor Mesh",
      "subtitle": "Soil probes feeding Match Living",
      "group": "Automation",
      "accent": "22ff88",
      "detail": "plant-sensors.json"
    }
  ]
}
```

### `sdcard/matchos/works/tab5-build.json`

```json
{
  "schema": 1,
  "title": "M5Stack Tab5 — MATCH//OS",
  "subtitle": "PHASE 1 · LAUNCHER + CHRONO-DECK",
  "sections": [
    {
      "type": "kv",
      "title": "BUILD",
      "rows": [
        ["TARGET",   "esp32p4"],
        ["IDF",      "5.4"],
        ["BSP",      "espressif/m5stack_tab5 ^1.2.0"],
        ["LVGL",     "9.2"],
        ["PARTITION","ota_0 / ota_1, 6MB each"],
        ["FLASH CMD","idf.py -p /dev/ttyACM0 flash monitor"]
      ]
    },
    {
      "type": "check",
      "title": "PHASE 1 — FOUNDATION",
      "items": [
        { "t": "Project scaffold + BSP wired",        "done": true },
        { "t": "Cyberpunk theme tokens in mos_theme", "done": true },
        { "t": "App registry table, 16 rows",         "done": true },
        { "t": "Launcher grid + detail sheet",        "done": true },
        { "t": "Chrono-Deck screen",                  "done": true },
        { "t": "RX8130CE RTC driver (still stubbed)", "done": false },
        { "t": "INA226 battery read (still stubbed)", "done": false },
        { "t": "SNTP + real weather fetch",           "done": false }
      ]
    },
    {
      "type": "check",
      "title": "PHASE 2 — HOME ASSISTANT",
      "items": [
        { "t": "WiFi provisioning screen",       "done": false },
        { "t": "MQTT client + auto-discovery",   "done": false },
        { "t": "Room tab layout",                "done": false },
        { "t": "Light / aircon / curtain widgets","done": false },
        { "t": "Idle dim to Chrono-Deck",        "done": false }
      ]
    },
    {
      "type": "check",
      "title": "PHASE 3 — MATCH OS CONTENT ENGINE",
      "items": [
        { "t": "JSON schema v1 defined",       "done": true },
        { "t": "cJSON loader with error states","done": true },
        { "t": "Group picker / list / detail", "done": true },
        { "t": "Checklist writeback to SD",    "done": false },
        { "t": "Image sections (Studio)",      "done": false },
        { "t": "WiFi content sync",            "done": false }
      ]
    },
    {
      "type": "text",
      "title": "DECISIONS",
      "body": "Apps 11-15 collapsed into one content engine driven by SD-card JSON. Five tiles on the launcher, one implementation underneath. Adding a new section later is a folder plus one row in MOS_APPS — no new UI code.\n\nApp 09 (RISC-V playground) folded into this app as a debug drawer rather than shipping as its own tile. App 03 (vision) will become a feature of App 01 rather than a standalone terminal."
    },
    {
      "type": "list",
      "title": "KNOWN ISSUES",
      "items": [
        "CPU gauge reads 0 — needs FREERTOS_GENERATE_RUN_TIME_STATS",
        "SoC temp is a constant until temperature_sensor is wired",
        "Spectrum bars are synthetic, not real FFT from ES7210",
        "Checklist ticks do not persist across a reboot yet"
      ]
    }
  ]
}
```

### `sdcard/matchos/studio/index.json`

```json
{
  "schema": 1,
  "title": "Match Studio",
  "subtitle": "Albums on the card. Tap to view.",
  "group_by": "year",
  "items": [
    {
      "id": "kansai-2026",
      "title": "Kansai 2026",
      "subtitle": "Coming back with 400 photos, probably",
      "group": "2026",
      "accent": "9d5cff",
      "detail": "kansai-2026.json"
    },
    {
      "id": "bench",
      "title": "Bench Log",
      "subtitle": "Tab5 build, wiring, screen shots",
      "group": "2026",
      "accent": "ffb300",
      "detail": "bench.json"
    },
    {
      "id": "hokkaido-2025",
      "title": "Hokkaido 2025",
      "subtitle": "Snow, ramen, more snow",
      "group": "2025",
      "detail": "hokkaido-2025.json"
    }
  ]
}
```

### `sdcard/matchos/living/index.json`

```json
{
  "schema": 1,
  "title": "Match Living",
  "subtitle": "The house, and the plants.",
  "group_by": "zone",
  "items": [
    {
      "id": "plants",
      "title": "Plant Project",
      "subtitle": "9 pots, soil probes on HY2.0-4P",
      "group": "Green",
      "badge": "2 NEED WATER",
      "accent": "22ff88",
      "detail": "plants.json"
    },
    {
      "id": "energy",
      "title": "Energy",
      "subtitle": "Daily kWh, standby offenders",
      "group": "House",
      "accent": "ffb300",
      "detail": "energy.json"
    },
    {
      "id": "rooms",
      "title": "Rooms",
      "subtitle": "Temps, humidity, what is on",
      "group": "House",
      "detail": "rooms.json"
    }
  ]
}
```

### `sdcard/matchos/living/plants.json`

```json
{
  "schema": 1,
  "title": "Plant Project",
  "subtitle": "9 POTS · SOIL PROBES ON HY2.0-4P",
  "sections": [
    {
      "type": "kv",
      "title": "RIGHT NOW",
      "rows": [
        ["NEEDS WATER", "Monstera (22%), Basil (19%)"],
        ["HEALTHIEST",  "Snake plant, 61% and coasting"],
        ["LIGHT TODAY", "6.2 hrs above 10k lux"],
        ["LAST RAIN",   "3 days ago"],
        ["GROW LIGHT",  "1800-2200 daily"]
      ]
    },
    {
      "type": "list",
      "title": "BALCONY",
      "items": [
        "Monstera — 22% — water today, drains fast in the terracotta",
        "Snake plant — 61% — leave it alone, it prefers neglect",
        "Pothos — 44% — trailing well, prune the long runner",
        "Bird's nest fern — 52% — mist rather than water"
      ]
    },
    {
      "type": "list",
      "title": "KITCHEN WINDOW",
      "items": [
        "Basil — 19% — thirsty, and pinch the flower buds off",
        "Mint — 58% — repot, it is strangling itself",
        "Spring onion in water — replace the water weekly"
      ]
    },
    {
      "type": "check",
      "title": "THIS WEEK",
      "items": [
        { "t": "Water monstera + basil",           "done": false },
        { "t": "Repot mint into the wide pot",     "done": false },
        { "t": "Calibrate probe #4 — reads 8% high","done": false },
        { "t": "Order more capacitive probes",     "done": true },
        { "t": "Move fern out of the afternoon sun","done": true }
      ]
    },
    {
      "type": "text",
      "title": "SENSOR NOTES",
      "body": "Capacitive probes only — the resistive ones corrode through in about four months and start reading high as they go. Probe #4 has drifted and needs a two-point calibration: dry air reading and fully saturated soil reading, then remap in the HA template sensor.\n\nProbes run back to the Tab5 over HY2.0-4P for the two nearest pots; the rest report over MQTT from an ESP32-C3 on the balcony."
    }
  ]
}
```
