# WeatherLab coding manual — sections & widgets

This manual explains everything you (or an AI) need to write a **custom section** or a
**custom widget template** for the WeatherLab weather app. It is self-contained: you can
paste this whole document into any AI chatbot together with an example template and ask it
to write or modify a template for you.

Canonical URL of this document:
`https://www.hectopixel.com/weatherlab/doc/README-coding-manual-1.md`

---

## 1. What a template is

Every non-map section of the app (and every home-screen widget) is drawn by a small
JavaScript program called a **template**. A template is one function:

```js
function draw(ctx, data, theme, size, props) { ... }
```

It receives a Canvas2D-style drawing context (`ctx`), the weather data (`data`, already in
the user's display units), the color theme (`theme`), the pixel-independent size of the
section (`size`), and the values of the settings the template declared (`props`). It draws
the whole section with plain Canvas2D calls: `fillRect`, `fillText`, `arc`, `lineTo`,
gradients, sprites, and so on.

Templates run inside a **QuickJS sandbox** — an isolated JavaScript engine with no access
to the network, the file system, the DOM, React, or the app itself. The drawing calls are
recorded into a command buffer and replayed by the app on a GPU (Skia) canvas. This means:

- A template **cannot fetch data**. Everything it may show is already in `data`.
- A template must be **synchronous**: no `async`, no `setTimeout`, no promises. `draw`
  runs to completion and returns.
- There is an **execution deadline** per render. Runaway loops are killed and reported as
  an error. Keep `draw` fast (it re-runs on every data/theme/size change).
- A render must be a **pure function** of its five arguments. No hidden state between
  renders, no `Math.random()` for layout (it would flicker on each redraw).

## 2. Template file format

A template is a single self-contained script — an IIFE that publishes its exports on
`globalThis.__templateExports`. No `import`/`export`/`require`; helper libraries are
provided as globals (see §9).

```js
"use strict";
(() => {
  function draw(ctx, data, theme, size, props) {
    const { width, height } = size;
    ctx.fillStyle = theme.colors.cardBackground;
    ctx.fillRect(0, 0, width, height);

    ctx.fillStyle = theme.colors.text;
    ctx.font = "bold 32px sans-serif";
    ctx.textAlign = "left";
    ctx.textBaseline = "top";
    if (data.current) {
      ctx.fillText("Now: " + Math.round(data.current.temperature) + "°", 16, 16);
    }
  }

  globalThis.__templateExports = {
    draw,
    // optional — settings shown in the section's settings screen (see §7)
    props: {
      showAccent: { type: "boolean", label: "Accent line", default: true },
    },
  };
})();
```

That is the exact form the in-app source editor works with, and the exact form you should
ask an AI to produce.

## 3. Workflow in the app

**Create a custom section:** dashboard edit mode → add section → *Custom section*. Give it
a name; it starts from a working sample template and opens the editor.

**Edit any existing section's code:** the section's settings (gear icon) → **Edit source
code**. Editing a built-in section creates a personal override; you can always reset back
to the built-in version.

**Edit a widget's template:** widget settings (gear on the widget, or Settings → Widgets)
→ **Edit source code**. Edits stay local to the widget you opened the editor from: on iOS
each *size* of a kind (e.g. Customizable widget Small / Medium / Large) keeps its own
source, and on Android every *placed* widget keeps its own — the same scoping as the
widget's other settings.

**Create a custom widget:** place the **Customizable widget** from the home-screen widget
gallery — it is the widget-side counterpart of the Coding example section: a commented
starter template meant to be rewritten. It receives every data block, supports all three
iOS sizes, and on Android resizes freely from a single 1×1 cell.

**The editor:** a code editor with a live preview. The preview re-renders as you type
(auto-refresh) and shows compile/runtime errors with line numbers, plus a copy button for
the error text. There are also buttons to copy the whole source to the clipboard and to
paste it back — designed exactly for the AI round-trip below.

### Writing templates with an AI

You do not have to write the code by hand. Any capable AI chatbot (Claude, ChatGPT,
Gemini, …) can generate a template. The recommended loop:

1. In the app, open the **Coding example** section's source (or any template close to what
   you want) and tap **Copy source**.
2. Paste it into the AI along with this manual (or its URL) and describe what you want,
   e.g. *"Using the same API and file format as this example, draw a 7-day temperature
   range chart with sunrise/sunset times."*
3. Copy the AI's answer and tap **Paste from clipboard** in the editor.
4. Check the live preview. If there is an error, copy the error message back to the AI and
   ask it to fix it. Iterate.

The **Coding example** section (add it from the section catalog) is a live tour of the
whole API — text, shapes, gradients, icons, tap regions, auto-height, and a dump of every
data field available on your device. It is the best "few-shot example" to give an AI.

## 4. The drawing context (`ctx`)

`ctx` is a subset of the HTML Canvas2D API. Coordinates are in logical points (not device
pixels); `(0,0)` is the top-left of the section.

**Paths:** `beginPath()`, `closePath()`, `moveTo(x,y)`, `lineTo(x,y)`, `rect(x,y,w,h)`,
`arc(x,y,r,startAngle,endAngle,ccw?)`, `bezierCurveTo(...)`, `quadraticCurveTo(...)`,
then `fill()`, `stroke()`, or `clip()`.

**Rectangles:** `fillRect(x,y,w,h)`, `strokeRect(x,y,w,h)`, `clearRect(x,y,w,h)`.

**Text:** `fillText(text,x,y)`, `strokeText(text,x,y)`, `measureText(text)` → `{ width }`
(measured with the currently set `ctx.font`).

**State:** `save()` / `restore()`, `translate(x,y)`, `scale(x,y)`, `rotate(rad)`.

**Style setters:** `fillStyle` (CSS color string or a linear-gradient handle),
`strokeStyle`, `lineWidth`, `lineCap` (`butt|round|square`), `lineJoin`
(`miter|round|bevel`), `globalAlpha`, `font` (e.g. `"bold 15px sans-serif"`), `textAlign`
(`left|center|right|start|end`), `textBaseline` (`top|middle|alphabetic|bottom`),
`setLineDash([on, off, ...])` (`[]` restores solid).

**Gradients:** `createLinearGradient(x0,y0,x1,y1)` returns a handle; call
`.addColorStop(offset, color)` on it, then assign it to `ctx.fillStyle`. Linear gradients
only — there is no `createRadialGradient`.

**Layers / blur:** `beginLayer({ blur?: number })` … `endLayer()` composites everything
drawn in between as one offscreen layer, optionally gaussian-blurred. Also
snapshots/restores style and transform state like `save()`/`restore()`.

**Sprites:** `drawSprite(name, sx, sy, sw, sh, dx, dy, dw, dh)` blits a rectangle from a
named host-registered image. The only guaranteed atlas is `'weather-icons'` (see §8).

**App integration (not in standard Canvas2D):**

- `reportContentHeight(h)` — auto-height, see §6.
- `reportHitRegion(x, y, w, h, action)` — tappable areas, see §6.
- `reportContentWidth(w)` — horizontal scrolling, see §6.
- `reportZoomWindow(opts?)` — pinch-zoom + panning via `size.window`, see §6.
- `reportText(role, text)` — native text slots, see §6.

Not available (silently missing — don't use): `drawImage`, `ellipse`, `arcTo`,
`createRadialGradient`/`createConicGradient`, shadows (`shadowBlur` etc.),
`globalCompositeOperation`, `getImageData`/`putImageData`, `Path2D`.

### Gotchas

- **Translucent fills:** `rgba(...)` / 8-digit hex fill styles render **opaque** in this
  pipeline. To draw translucent shapes, set `ctx.globalAlpha = 0.3` with a solid color,
  draw, then restore `ctx.globalAlpha = 1`.
- `measureText` uses the font set via `ctx.font` at the moment of the call — set the font
  first.
- Angles for `arc`/`rotate` are radians.
- The section has a card background painted by the app; a template does not *have* to
  fill its background (widgets are the exception, §10).

## 5. The data (`data`)

`data` is a `NormalizedWeather` object. Key facts:

- **All numbers are already in the user's display units** (°C or °F, km/h / mph / m/s /
  kt / Bft, mm or inch). Never convert; for unit *labels* use `theme.units` (§5a). If you
  need a threshold (e.g. "windy above 30 km/h"), remember the number you compare against
  changes meaning with the unit — derive thresholds from `theme.units` when it matters.
  Beaufort is the sharp edge here: wind then arrives as a 0–12 force number, not a speed,
  so a raw `> 30` test never fires.
- **Every block is optional.** A section only receives the blocks its type declares as
  data needs; models and servers differ in what they provide. Always guard:
  `if (data.current) { ... }`, `data.hourly?.uvIndex?.[i]`.
- Hourly/daily fields are **parallel arrays** indexed against `time`.
- `null` inside an array means "no data for this hour", not zero.
- Times are ISO strings in the location's local time (e.g. `"2026-07-14T09:00"`), so
  `new Date(t).getHours()` gives location-local hours.

```
data.location   { lat, lon, name, timezone, utcOffsetSeconds? }
data.model      forecast model id (string, e.g. 'best_match', 'icon_global')
data.modelLabel? display-ready short model name (e.g. 'ECMWF'); fall back to data.model
data.updatedAt  ISO fetch time; data.modelRunAt? ISO model run time

data.current?   { time, temperature, apparentTemperature, weatherCode, windSpeed,
                  windDirection, humidity, pressure, isDay, cloudCover, precipitation }

data.hourly?    { time[], temperature[], apparentTemperature?[], precipitation[],
                  precipitationProbability[], windSpeed[], windGusts[], windDirection[],
                  weatherCode[], cloudCover[], snowfall?[], humidity?[], dewPoint?[],
                  uvIndex?[], visibility?[],
                  cloudCoverLow?[], cloudCoverMid?[], cloudCoverHigh?[],
                  cloudLevels? { hPa[], cover[][] } }

data.daily?     { time[], tempMax[], tempMin[], precipitationSum[], snowfallSum?[],
                  weatherCode[], precipitationProbabilityMax?[], sunrise[], sunset[],
                  conditionSummary? { median[], pessimistic[], night? } }
                  // conditionSummary entries: { text, short?, code } — ready-made
                  // per-day condition wording; prefer it over hand-rolled WMO text.

data.airQuality? { time[], pm25[], pm10[], o3[], no2[], so2?[], co?[],
                   europeanAqi[], usAqi?[] }
```

`weatherCode` values are WMO interpretation codes (0 clear, 1–3 clouds, 45/48 fog, 51–57
drizzle, 61–67 rain, 71–77 snow, 80–86 showers, 95–99 thunderstorm). Some models return
`null` codes but real `cloudCover`/`precipitation` — derive the condition from those as a
fallback (the Coding example and §8's icon helper show how).

The sky codes 0–3 follow the user's **Sky-based weather symbols** setting (on by default):
when it is on, they are re-derived from `cloudCoverLow`/`Mid`/`High` with high cloud
weighted at 0.4, so thin cirrus doesn't turn a sunny hour overcast. Codes for actual
weather (fog, drizzle, rain, snow, thunder) are always the model's own. `cloudCover` itself
stays the model's total cover either way.

The easiest way to see exactly what *your* device receives: add the **Coding example**
section and enable its "List all weather data" switch — it dumps every block and field.

### 5a. Units (`theme.units`)

`theme.units` holds the display-unit **labels**: `{ temp: '°C', wind: 'km/h',
precip: 'mm', distance: 'km', pressure: 'hPa' }` (values reflect the user's settings).
Use them whenever you print a unit next to a number. `wind` is one of
`'km/h' | 'mph' | 'm/s' | 'kt' | 'Bft'` — pass it with the value to
`windToKmh(v, theme.units.wind)` to get back to a canonical speed before comparing
against a fixed threshold.

`pressure` is `'hPa' | 'inHg' | 'mmHg'`, and precision differs per unit (inHg carries
its meaning in the decimals: 30.11, not 30). Print it with
`fmtPressure(value, theme.units.pressure)`, which returns the number alone —
append the label yourself. Note that the *pressure levels* in
`data.hourly.cloudLevels.hPa` are a vertical coordinate, not a barometric reading:
they stay in hPa whatever the user picked.

### 5b. Clock format (`theme.clock`)

`theme.clock` is `'12h'` or `'24h'` — Settings → Clock, seeded from the device on first
launch. Times arrive in `data` as location-local wall clock (`"2026-07-28T15:00"`), never
pre-formatted, so **every time you print is yours to format** and must honour this flag:

```js
const h = new Date(data.hourly.time[i]).getHours();
// dense hour columns and chart axes: no room for a day-period word
const tick = theme.clock === '12h' ? `${h % 12 || 12}${h < 12 ? 'a' : 'p'}` : `${h}`;
// readouts, headers, captions: spell it out
const full = theme.clock === '12h' ? `${h % 12 || 12} ${h < 12 ? 'AM' : 'PM'}` : `${h}:00`;
```

The built-in templates use exactly these two widths (`fmtHourTick` / `fmtHourFull` in
`lib/timeFormat.ts`), so matching them keeps a custom section looking native next to them.

## 6. Size, auto-height, tap regions, scrolling, and zoom

`size` is `{ width, height, dpr }` in logical points. Lay out from `size.width` and
`size.height`; `dpr` is informational (rendering is already density-scaled) — you almost
never need it.

**Auto-height.** If your content's natural height differs from the section's current
height, call `ctx.reportContentHeight(h)` at the end of `draw`. The app resizes the
section and redraws. Rules:

- The reported height must depend only on `data`, `theme`, `size.width`, and `props` —
  **never on `size.height`** — or the resize→redraw loop never settles.
- Last call wins. Report the total content height including your bottom padding.

**Tap regions (sections only — widgets are static images).**
`ctx.reportHitRegion(x, y, w, h, action)` declares a tappable rectangle (purely
declarative, draws nothing; regions declared later win overlaps). `action` is one of:

```js
{ type: 'toast', message: 'text shown in a transient toast' }
{ type: 'openSection', typeId: 'daily', model?, props? }   // opens that section as a popup
```

Valid `typeId`s for `openSection`: `current`, `current_values`, `hourly`, `hourly_chart`,
`compact_table`, `hourly_images`, `multi_line_graph`, `interactive_graph`, `cloud_cover`,
`daily`, `daily_columns`, `precipitation`, `day_focus`, `sun_moon`, `air_quality`,
`coding_example`. `props` overrides the popup's template props (e.g. `day_focus` accepts
`initialDay`). Unknown/malformed actions are ignored. Templates never receive raw touch
events; taps arrive through hit regions, and drags/pinches only through the horizontal
scroll and zoom-window mechanisms below.

**Horizontal scrolling (sections only).** If your content is naturally wider than the
viewport (e.g. 72 hour columns at a fixed column width, like the Hourly images section),
call `ctx.reportContentWidth(w)` at the end of `draw`. The app re-renders the section at
that width and the user pans it horizontally (with fling). Rules mirror auto-height:

- The reported width must depend only on `data`, `theme`, and `props` — **never on
  `size.width`** — or the resize→redraw loop never settles. Last call wins.
- After the report takes effect, `size.width` **is** your reported width — lay out
  against it as usual. `size.height` and auto-height work unchanged.

**Pinch zoom & panning (sections only).** Call `ctx.reportZoomWindow(opts?)` (anywhere in
`draw`; it draws nothing) to opt in to app-managed zoom, like the Hourly chart: pinching
zooms the horizontal axis around the fingers' midpoint, a one-finger drag pans it. The
app then passes the visible **window** on every render as `size.window`:

```js
function draw(ctx, data, theme, size, props) {
  ctx.reportZoomWindow({ minSpan: 0.1 });          // stay zoomable on the next render
  const win = size.window || { start: 0, span: 1 }; // fractions of your full domain
  const hours = data.hourly ? data.hourly.time.length : 0;
  const first = Math.round(win.start * hours);      // draw ONLY this slice,
  const count = Math.max(2, Math.round(win.span * hours)); // spread across size.width
  // ...
}
```

`opts` (all optional): `minSpan` — smallest visible fraction a pinch can reach (default
0.05); `plotLeft` / `plotRight` — logical-px gutters if your plot area is inset from the
section edges (keeps the zoom anchored under the fingers; default 0).

- Draw only the window's slice of your domain, spread across the full plot width — the
  template is re-run on every pinch/pan step, so keep `draw` fast.
- The window always starts at `{ start: 0, span: 1 }` and resets when the app restarts.
- Zoom-window and `reportContentWidth` are mutually exclusive; if you call both, the zoom
  window wins and the width report is ignored.
- Widgets are static images: both calls do nothing there.

**Native text slots.** When the host declares `size.textSlots` (an array of free-form role
names, e.g. `['title', 'subtitle']` — see §11 for where widgets populate it), call
`ctx.reportText(role, text)` for each role instead of drawing that text into the canvas —
the host displays it as native text instead. Feature-detect with
`size.textSlots?.includes(role)`; when the role is absent, draw the text into the canvas as
usual — this is the same convention as `reportContentHeight`/`reportHitRegion`. Role names
are a free-form contract between your template and whatever native layout consumes that
render — not a runtime-enforced enum, the same spirit as `reportHitRegion`'s action payload.

Only the **iOS notification attachment** declares text slots today: the render is shown as a
small square image thumbnail, so the reported `title`/`subtitle` become the notification's
actual title and body text (a `\n` in the subtitle starts a new body line), separate from
the image. The **Android** persistent notification declares *no* text slots — its collapsed
strip and expanded panel draw every label straight into the PNG (`size.textSlots` is
`undefined` there, so the feature-detect naturally falls through to canvas drawing). So a
notification template should always be able to render its text into the image, and treat
`reportText` as an iOS-only enhancement layered on top.

## 7. Template props (user settings)

Export a `props` schema next to `draw` and the app renders a settings screen for the
section (gear icon). The chosen values arrive as `draw`'s fifth argument, with defaults
merged in — every declared key is always present.

```js
props: {
  showFeels: { type: "boolean", label: "Show feels-like", default: true },
  hours:     { type: "number",  label: "Hours shown", default: 24, min: 6, max: 72, step: 6 },
  mode:      { type: "select",  label: "Style", default: "bars",
               options: [ { value: "bars", label: "Bars" },
                          { value: "line", label: "Line" } ] },
}
```

- Types are exactly `boolean`, `number` (`min`/`max`/`step` optional), and `select`
  (non-empty `options` required). Anything malformed is dropped.
- A number prop may declare `unit: 'temp' | 'wind' | 'precip'`. Its value is then
  **stored in the canonical unit** (°C, km/h, mm) and shown converted in the settings UI —
  but note `data` numbers are in *display* units, so normalize before comparing.
- `hidden: true` keeps a prop out of the settings list (host-driven props).

## 8. Weather icons

A sprite atlas named `'weather-icons'` is always registered: a single row of **132×132**
cells; cell `i` is at `x = i * 132, y = 0`.

```js
// draw cell 4 (overcast cloud) at (x, y) sized 34×34:
ctx.drawSprite('weather-icons', 4 * 132, 0, 132, 132, x, y, 34, 34);
```

The atlas is theme-aware: on the light theme the host swaps in artwork drawn for
light backgrounds, on dark and blue it uses the original sheet. Both sheets share
this grid and these indices, so always ask for `'weather-icons'` — don't branch on
`theme.scheme` to pick a sheet.

Cell indices:

| # | icon | # | icon |
|---|------|---|------|
| 0 | clear day (sun) | 11 | thunderstorm |
| 1 | clear night (moon) | 12 | thunderstorm + hail |
| 2 | partly cloudy day | 13 | sleet / freezing rain |
| 3 | partly cloudy night | 14 | snow shower night |
| 4 | overcast | 15 | snow shower day |
| 5 | fog | 16 | snow (light/moderate) |
| 6 | rain shower day | 17 | snow heavy |
| 7 | rain shower night | 18 | mainly clear day |
| 8 | rain light | 19 | mainly clear night |
| 9 | rain moderate | 20 | drizzle |
| 10 | rain heavy | | |

Typical WMO-code mapping: 0→0/1, 1→18/19, 2→2/3, 3→4, 45/48→5, 51–55→20, 56/57/66/67→13,
61→8, 63→9, 65/82→10, 71/73/77→16, 75→17, 80/81→6/7, 85/86→15/14, 95→11, 96/99→12 (the
day/night pairs pick by `isDay` / hour vs. sunrise–sunset). When `weatherCode` is null,
fall back to `cloudCover` (<12 % clear, <50 % mainly clear, <87 % partly, else overcast)
and `precipitation` (≥0.1 mm → drizzle/rain/snow by amount and temperature).

## 9. Sandbox environment

Available globals beyond standard JavaScript (ES2020: `Math`, `Date`, `JSON`, arrays,
strings, etc.):

- `globalThis.__d3` — a small d3 subset: `__d3.line()`, `__d3.area()`,
  `__d3.curveMonotoneX`, and `__d3.scaleLinear()` (only `.domain()`, `.range()`,
  `.clamp()`, `.nice()`). `.domain()`/`.range()` follow d3 getter/setter semantics
  (no argument returns the current `[lo, hi]`), but `.clamp()` and `.nice()` are
  **no-ops** — don't rely on niced tick values; compute tick steps yourself.
  d3 line/area generators accept a `RecorderContext2D` via
  `.context(ctx)` and emit path commands directly.

Not available:

- **No `Intl`** — `toLocaleDateString`/`toLocaleTimeString` fall back to plain formats and
  ignore options. Build weekday/month names yourself:
  `const WD = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; WD[new Date(t).getDay()]`.
- No `fetch`/network, no timers, no `console` guarantees, no DOM, no `require`/`import`,
  no filesystem.

One long-lived sandbox context serves each section instance; only `data`/`theme`/`size`/
`props` change between renders. Top-level IIFE code runs once — fine for constants, but
keep per-render state inside `draw`.

## 10. Theme reference (`theme`)

```
theme.colors        background, backgroundElement, cardBackground, text, textSecondary,
                    tint, onTint, border, error   (hex strings for the active scheme)
theme.scheme        'light' | 'dark' | 'blue'
theme.isDark        true for every non-light scheme (dark AND blue) — use for contrast
                    decisions; use `scheme` for scheme-specific looks
theme.fontSize      { xs: 11, sm: 13, md: 15, lg: 17, xl: 22, xxl: 28 }
theme.spacing       { one: 4, two: 8, three: 12, four: 16, five: 20, six: 24 }
theme.chartPalette  6 weather-semantic series colors:
                    [0] temperature (warm amber), [1] rain blue, [2] snow ice-blue,
                    [3] wind teal, [4] pressure violet, [5] misc rose
theme.units         see §5a
theme.clock         '12h' | '24h' — the user's clock format; see §5b
theme.background    widgets only — see §11
```

Always take colors from `theme`, never hardcode — the template must look right in light,
dark, and blue schemes. Keep `tint` for interactive affordances; use `chartPalette` for
data series.

## 11. Widgets: what's different

Widget templates use the identical API and file format, plus these rules:

- **Static image.** The widget is rendered to a PNG. `reportHitRegion` does nothing;
  animations are impossible.
- **Paint your own background** (there is no card behind a widget), and honor the
  per-widget appearance override the app passes in `theme.background`
  (`{ color?, opacity? }`):

```js
function paintBackground(ctx, theme, w, h) {
  const bg = theme.background || {};
  const opacity = bg.opacity == null ? 1 : bg.opacity;
  if (opacity < 1) ctx.globalAlpha = opacity;          // NOT rgba() — see §4 gotchas
  ctx.fillStyle = bg.color || theme.colors.cardBackground;
  ctx.fillRect(0, 0, w, h);
  if (opacity < 1) ctx.globalAlpha = 1;
}
```

- **Sizes vary a lot.** The same template renders small (~158×158), medium (~338×158),
  and possibly large families, and Android widgets are freely resizable. Lay out from
  `size.width`/`size.height` ratios; make sure nothing clips at extreme aspect ratios.
- **Text scale.** The user's per-widget text size (S/M/L/XL) shrinks or grows the logical
  `size` your template sees and scales the result to fill the slot — another reason
  layouts must be proportional, not absolute. `size.slotHeight` (when present) is the
  physical slot height in dp, unaffected by text scale — use it if a layout-mode decision
  must not flip with the zoom.
- **Safe inset.** `size.safeInset` (when present and non-zero) means the render's true
  display size isn't known yet and the widget host may crop up to that many dp off each
  edge (`scaledToFill` on an aspect mismatch). Paint your background full-bleed, but keep
  text and content `safeInset` dp inside every edge. It's `0` once the exact slot size is
  known, so honoring it costs nothing on the renders users normally see.
- **Native text slots.** `size.textSlots` (when present) lists the role names the widget
  host can render as native text instead of baked-in canvas text (used by the persistent
  notification's title/subtitle) — see §6 for the `ctx.reportText` API and, on Android,
  the top-left overlay geometry your canvas must keep clear when it reports a role.
- Widget props work like section props; the settings live in the widget's settings screen.
- **Declare your data needs.** Widget templates may export `dataNeeds` next to `draw` and
  `props` — the list of `data` blocks the code reads:

```js
globalThis.__templateExports = {
  draw,
  props,
  // optional — which parts of `data` this widget reads; the app then only
  // downloads those on a background refresh. Valid entries:
  // 'current', 'hourly', 'daily', 'airQuality', 'cloudLayers'.
  dataNeeds: ["current", "hourly"],
};
```

  Without a declaration the app fetches **everything** for a user-installed or edited
  widget source (safe, but heavier), and the builtin baseline for untouched builtins.
  Unknown entries are ignored; an empty list counts as "not declared". Any published
  widget can be installed onto any widget kind precisely because of this fallback — but
  declare the real needs and background refreshes get cheaper. (Sections ignore
  `dataNeeds`; their fetch is fixed per section type.)

## 12. Checklist before you ship a template

1. Guard every data access (`data.current?`, optional arrays, `null` hours).
2. Colors from `theme`; test light **and** dark (the editor preview follows the app
   theme).
3. Unit labels from `theme.units`; never convert numbers yourself. Times formatted
   per `theme.clock`.
4. Translucency via `globalAlpha`, never `rgba()` fill styles.
5. `reportContentHeight` independent of `size.height`, `reportContentWidth` independent
   of `size.width` (if you use them).
6. No unbounded loops; `draw` finishes quickly — especially with a zoom window, where it
   re-runs on every pinch/pan step.
7. The file is a single IIFE setting
   `globalThis.__templateExports = { draw, props?, dataNeeds? }`.
8. Widgets: `dataNeeds` declares exactly the `data` blocks you read (§11) — leaner
   background refreshes for everyone who installs your widget.
