Changelog

All notable changes to the Pebble App Framework are documented in this file. Each version lists the framework changes a face developer has to know about first, then the wearer-visible changes, the ones someone wearing any face built on it would see without the face changing its code.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

4.6.0 - 2026-10-16

Framework Changes

Added

  • A themePreview config item takes an optional previewsByKey, the messageKey of another control on the page. Its previews then nest, keyed first by that control's value and then by the option's value, so the shots drawn under the picker follow a second choice on the page such as a scene or a mode. One picker still owns APPEARANCE_THEME, so the value saves the usual way. The named control is read once the page is built, and the preview redraws whenever it changes.

4.5.0 - 2026-10-15

Framework Changes

Changed

  • The theme picker can show a different preview per platform, so a face built for more than one watch shows each watch its own shots. A theme-previews.json value can now be a map keyed by platform, { "emery": <path|list>, "gabbro": <path|list> }, as well as the path or list it already took. paf gen <face> theme-previews writes the map through to theme-previews.g.js, and the themePreview component reads the watch the settings page was opened from and draws that platform's shots. A platform name has to be one Pebble builds for, so a typo stops the generator. A flat value is unchanged, and a theme whose map leaves out the watch in hand falls back to any shots the map carries. A shot a map names for a round watch is masked to the circle the screen shows, since a round watch's screenshot is a square framebuffer with the corners outside the circle. A flat value names no platform, so it is left square.

Wearer-Visible Changes

Changed

  • A theme preview picture is now kept crisp only when it is drawn larger than its own pixels, and smoothed when a higher-resolution shot is shrunk to fit the picker, so a watch's small text no longer looks rough in the settings page.

4.4.1 - 2026-10-14

Framework Changes

Fixed

  • Fixed the phone-side script dying in the emulator on every weather fetch, every settings send with a timezone field, and every stock poll. The emulator's JavaScript runtime has no locale data, and building an Intl.DateTimeFormat there runs the process out of memory rather than throwing, which no try/catch can stop. timezone.ts now asks Intl.DateTimeFormat.supportedLocalesOf first and answers null for every zone when nothing comes back, so weather is asked in the location's own zone and moved onto the phone's clock, a timezone field sends its saved offset, and the stock schedule reads the market as shut. Real phones have the locale data and are unaffected.

4.4.0 - 2026-10-14

Framework Changes

Added

  • Added a theme picker that shows a picture of each theme. Pass previews to the theme section of buildConfig, a map from a theme's option value to a list of data URLs. The picker then keeps the dropdown and draws the chosen theme's pictures under it, a theme with a day and a night shot getting both side by side. It still saves under APPEARANCE_THEME exactly as the plain select. The themePreview component is registered only when a page holds one, so a face without previews is unchanged.
  • Added a theme-previews generator and check to the thumbnails plugin. A face lists the plugin and keeps a resources/theme-previews.json, which maps each theme's value to a picture path or a list of paths relative to the face folder. paf gen <face> theme-previews base64-encodes them into src/pkjs/clay/theme-previews.g.js, which the face's config.ts imports and passes as theme.previews. paf check fails when the committed file is stale or a path is missing or is not a PNG.
  • Added role_pick and the Role enum in role/role.h, for a face that shows a different look by day, after dark, and during Quiet Time. It takes each trigger as a pair, whether it is firing and whether a look is assigned to it, and returns ROLE_DAY, ROLE_NIGHT, or ROLE_QUIET, with Quiet Time winning over night and a trigger with nothing assigned falling through to the next. It is host-tested and touches no SDK.
  • Added rotate_index in clock/rotate.h, which picks which of a set is up now on a timed rotation. Pass it the clock's tm_year, tm_yday, tm_hour, and tm_min, how many minutes each one is shown for, and how many there are, and it returns the index, worked out from the time rather than a saved counter so it survives a restart. It holds steady through a slot and steps by one on each boundary, except at a non-leap new year, which it counts as a 366-day year so the one boundary there jumps by more than a step.
  • Added visibleWhen on a Clay config item, which shows it only while another control holds one of the given values. Pass one condition { key, in } or a list that all have to hold, with key the controlling item's messageKey and in the values that reveal the row (a toggle's are true/false). A page that declares any gets the framework's show and hide as its custom function, so a row that does nothing under the current choice is off the page rather than sitting there looking live. A face that passes its own customClay owns the page and wires the showing itself.

Changed

  • The thumbnails plugin now offers two generators, thumbnails for the layout builder's panel pictures and theme-previews for the theme picker. A face lists the plugin for either.

Deprecated

  • Deprecated layout_role_pick, LayoutRole, and the LAYOUT_ROLE_* constants in layout/layout_role.h. They are now thin aliases for role_pick, Role, and the ROLE_* constants in role/role.h, which do the same job for any face rather than only a layout one. The old names still work and go at the next major release. Move to role_pick and Role when convenient.

4.3.0 - 2026-10-11

Framework Changes

Added

  • Added vibe_hourly in vibe.h for an hourly buzz. Call vibe_hourly(time_store_tm(), choice) from the face's time callback on every tick. It buzzes each real hour once however many tickers land in minute 0, including the hour the clocks repeat when they fall back. Its first call after launch only remembers the hour, so a face opened in minute 0 does not buzz an hour that already buzzed. The decision is hour_latch.h in the core.

4.2.0 - 2026-10-11

Framework Changes

Added

  • Added hourly to TimeConfig. With minute_tick off, the time store subscribes to the SDK's hour tick, so a face wakes at the top of each hour without the minute tick. Set it on a .beats clock that buzzes on the hour. The .beats timer alone lands in minute 0 of only two hours in three in a time zone a half hour or 45 minutes off UTC, and the minute tick would wake the watch sixty times an hour for one buzz.

4.1.0 - 2026-10-11

Framework Changes

Added

  • Added KNOWN_HEADER_FONT(off, count) to settings_catalog.h for SETTING_HEADER_FONT, with count the size of the face's own header font table.
  • Added defines to the appinfo, for C defines a face sets for its own build, such as "defines": { "ENGINE_SLOTS_MAX": 12 }. The build passes each one as a -D flag. ENGINE_SLOTS_MAX, FONT_SLOTS_MAX, and ICON_CACHE_MAX can each be raised this way, up to 254 for the slots and 255 for the fonts and the icon cache, and a value outside that stops the build. A name that is not a macro name, a value that is not a whole number, and a name the build sets itself, a HAS_ switch or BUILD_WATCHAPP, stop the build with a message naming the face.
  • Added SETTINGS_BLOB_SIZE in settings.h, which gives a settings struct's size for a schema's blob_size and stops the build when the struct is over the 256 bytes one persist key holds. The watch saves only the first 256 bytes of a longer struct, so the fields past that come back as their defaults on every launch. Write .blob_size = SETTINGS_BLOB_SIZE(s_settings) in place of sizeof(s_settings) to catch it at build time.

Changed

  • beats_has_token and beats_expand_token find the .beats token with strncmp, which the firmware exports, rather than strstr, which it does not. A face that references the date readout no longer links about 1.4 KB of newlib for it.
  • setup-pebble now warns when a node-version it ignores differs from the Node the face's framework records, the same as for the SDK and pebble-tool, and when the Node on the path before it, normally the one paf sync installed the unit under, is a different major from the one the build runs on. Its node-version input now defaults to empty, which takes 24 when there is no face.
  • The icons plugin's check, which paf check runs, now also fails when a committed icon PNG is missing, is not a PNG, or is not the size resources/icons.json asks for. A size changed in icons.json without a paf gen <face> icons no longer passes and ships the old icon. It reads the size from each PNG's header, so it still needs no sources folder. Its line in the output is now icons: rather than icon media:.
  • The dev plugin's clay-preview now stops on an argument it does not know and names it, rather than ignoring it. So --settings saved.json, with a space in place of the =, no longer previews a fresh install. -h and --help print the usage, which now lists --settings=<file>.
  • The icons generator now reads an icon's svg in resources/icons.json as a path from the sources folder, without the .svg, so status/bluetooth-on reads status/bluetooth-on.svg. The SVGs can sit in any folder under sources, at any depth, and a name that leads outside sources stops the run. The run stops when sources holds none of the SVGs a run's icons read, rather than none of the three fixed sets. So paf gen <face> icons on a face whose SVGs are all missing now stops, where it kept every PNG with a warning each.

Deprecated

  • Deprecated WeatherOpts.place and the WeatherAPI place-name lookup it feeds. The weather feature always passes coords, so nothing sets it. It goes at the next major release. Pass coords to a provider called directly.
  • Deprecated labelShort on ConditionEntry and ConditionFallback. Nothing reads it, since the watch's wx_label_short shows the token itself, so it is now optional and the framework's rows leave it out. It goes at the next major release.
  • Deprecated the wi, ux, and sr keys at the start of an icon's svg. They still read from weather-icons/svg/, uxwing/, and svgrepo/, and paf gen <face> icons warns once for each key a face uses. Write the folder in place of the key, so wi/wi-day-sunny becomes weather-icons/svg/wi-day-sunny. The keys go at the next major release. A name whose SVG is there as written, such as ux/thermometer-icon with a real ux/ folder holding it, reads from there instead.
  • Deprecated the limits named with MAX in the middle, for names that end in MAX like FONT_SLOTS_MAX and ICON_CACHE_MAX. In C, ENGINE_MAX_SLOTS is now ENGINE_SLOTS_MAX. In C and in WIRE_CAPS, STOCK_MAX_SLOTS is now STOCK_SLOTS_MAX and CALENDAR_MAX_SLOTS is CALENDAR_SLOTS_MAX. WIRE_CAPS.FORECAST_MAX_COLS is WIRE_CAPS.FORECAST_COLS_MAX, and the wire module's default export carries STOCK_SLOTS_MAX beside the deprecated STOCK_MAX_SLOTS. The old names still work, including ENGINE_MAX_SLOTS in a face's defines, and go at the next major release. A face that sets both engine names has to set them to the same value. Move to the new names when convenient.

Fixed

  • Fixed a url() in a theme's frame/css/theme_<name>.css breaking in paf gen <face> background. The bake inlined the sheet, so the address resolved from frame/ rather than frame/css/. The sheet is now added as a link, the same as a browser loads it.
  • Fixed paf gen <face> icons stopping on an SVG whose root sets its own stroke but no fill. Making it white gave the root a second stroke, which sharp refuses. A root now gets only the white attributes it does not already set, as an attribute or in its style, and gets a white color so a glyph drawn with currentColor comes out white rather than black. A black color, and a black colour inside a style, is now whitened like a black fill or stroke.
  • Fixed paf check and paf gen <face> thumbnails saying a thumbnail PNG names no module when the module exists and only its size is not one the face lists in thumbnailSizes. The line now names the size and the sizes the face lists, a PNG named for a module with no size after it gets a line of its own, and neither is also reported as a module with no thumbnail. A thumbnailSizes entry that is empty or holds a dash now stops the run, since no thumbnail's name can carry it.
  • Fixed clay-preview showing a working settings page for a face that leaves a component out of startPebbleApp, where the phone stops with "The component: X is not registered" and the page never opens. The preview now loads the face's src/pkjs/index.ts and takes the page, the components, and customClay from what it hands startPebbleApp, and registers only those and the location component, as the phone does. A face with no src/pkjs/index.ts, or one whose index.ts never calls startPebbleApp, stops the preview.
  • Fixed a settings struct that could not be saved whole going unnoticed. settings_init now logs an error naming the key for a struct over 256 bytes, and settings_save logs one when a struct is not written whole, which also happens when the app's persist storage is full. A failed save is not tried again.
  • Fixed a text slot with a zone and no text function crashing the watch on its first repaint. The slot now stays empty, the same as a draw slot with no draw.

Wearer-Visible Changes

Fixed

  • Fixed the tide reaching high water for only 4 minutes of each cycle while it sat at low water for 41, and the water jumping two points at a time. The level now holds at 100 for 30 minutes and at 0 for 31, each centred on the turn, and moves at most one point a minute. The fix is in tide_level.
  • Fixed a watch that was factory reset or reinstalled staying on the face's defaults until the wearer pressed Save, and the watch's own weather, stock, and calendar requests going unanswered, on a real phone. The Pebble phone app keys a watch message by name, and startPebbleApp read it by number, so it missed every message from the watch. Each message now gets the number for every key the face declares before the restore or any feature's message hook reads it.
  • Fixed a reset watch's settings not coming back when the face had not opened AppMessage yet as the phone's code started. The settings request sent on ready was lost. PebbleOS turns away a message that lands before the face's init opens AppMessage. The send queue's three tries ran out before a face that opens it late was listening, and that was the only request of the session. A request the watch turns away now goes again a second later, up to 8 more times, and stops as soon as the watch takes one. A repeat of the watch's reply is ignored, so a reply that arrives twice restores or seeds once.
  • Fixed a second clock whose zone had changed its clocks since the last launch staying on the old hour until the next background tick, when the face had not opened AppMessage yet. The second clocks pushed on ready were lost. The zones now go again when the watch's settings reply lands, since the reply proves the face is listening. A push still waiting in the queue is not queued twice.
  • Fixed the settings page saying Fallback to Manual Location also covers GPS being turned off. With Enable Phone GPS off the manual city is always used, and the toggle only decides what happens when GPS is on but cannot find the phone. Its description in config-builder.ts now says so.

4.0.0 - 2026-10-02

Framework Changes

Added

  • Added a paf key to the framework's package.json and to each plugin's, naming the build, its generators, what a face has to hold for each to apply, the checks it adds to paf check, the tools paf tool <face> <name> runs, and the scripts paf lint and paf format run. For a path under src/pkjs/, a family face's core counts too, at the matching folder under core/pkjs/. paf build, paf gen, paf check, paf tool, paf lint, and paf format read them, so a face's package.json no longer needs scripts that point into the framework. A face's own generators go under gen in its paf.config.json, each naming its script and the generator it runs after.
  • Added tools/build.ts, which builds a face's .pbw with the same arguments and steps as build.sh, under Node like the framework's other tools.
  • Added io/appmessage/appmessage_features.h, which defines APPMESSAGE_HAS_WEATHER and APPMESSAGE_HAS_LOCATION from the face's message keys. A face's own C can read them in place of testing the HAS_MESSAGE_KEY_* defines itself.
  • Added isMainScript in tools/shared/entry.ts, which a tool calls as if (isMainScript(import.meta)) to run only when started as the script. A face's own tools can use it in place of reading import.meta.main.
  • Added tools/clay-components/check-components.ts, plugins/icons/check-icons.ts, and plugins/thumbnails/check-thumbnails.ts, which paf check runs to find a face's committed Clay components, icon media, and thumbnails that are out of date. Each checks every face on its own, so one broken face is reported without hiding the rest, and a check that finds no faces to look at fails rather than passing.
  • Added the code-style plugin, which holds the house ESLint style and the ESLint packages, with jiti so an editor's ESLint can load a .ts config. List it under plugins and paf lint lints the unit with it, so a unit needs no eslint.config.ts, no lint script, and no ESLint packages of its own. A unit that adds rules keeps a config/eslint.config.ts that spreads the default export of paf/plugins/code-style/eslint.config.ts and adds to it. One at the unit's root is read too, which is where an editor's ESLint looks, and the one in config/ wins when a unit has both. paf lint prints which config it used. paf format runs Prettier over a unit's CSS, JSON, and YAML once the unit sets "plugins": { "code-style": { "prettier": true } }, and prints one line and passes until then. It never touches a generated file, pebble.appinfo.json, package.json, package-lock.json, paf.config.json, resources/icons.json, or a folder whose name starts with a dot other than .github/. A unit with no TypeScript passes paf lint with one line saying so.
  • Added tsconfig.spec.json and tsconfig.tools.json, which hold the compiler options for a face's specs and for its tools and configs that run under Node. A face's own spec and tools tsconfigs extend them from paf/ and set their own include and exclude. The spec one brings in ts/pkjs/pebble.d.ts through its files, so a face's spec tsconfig no longer lists it and sets no files of its own.
  • Breaking: Added ts/generated.d.ts, which types every *.g module, whether a framework generator or the unit's own wrote it. The pkjs build takes it in from paf/ts/, and so does a face tsconfig that includes paf/ts/**/*.d.ts. Delete the face's own declare module '*.g', since two copies clash and stop the build.

Changed

  • The framework now runs under Node 22.18 or newer on Node 22, as well as 24.2 or newer. Its engines reads ^22.18.0 || >=24.2.0, which is every Node with import.meta.main. A face's CI still installs Node 24, which setup-pebble reads from toolchain.json.
  • Breaking: This version needs paf 2.0.0, which renames a unit's paf.json to paf.config.json and adds the unit's plugin list to it. Everything a face gets now lives under src/ in the framework repo, and paf 2.0.0 copies that folder into a unit, leaving out the specs, their fixtures, and any plugin the unit does not list.
  • Breaking: A unit's framework folder is paf/ rather than lib/. Change every import from lib/ to paf/, the extends and globs in each tsconfig, the workspaces entry to paf and paf/plugins/*, and anything that runs a tool under lib/. The .gitignore lines for lib/ and lib.paf-*/ become paf/ and paf.paf-*/. The face actions at this tag read the framework from paf/, so load them at @v4.0.0 in the face's workflows. A unit still holding a lib/ from paf 1.0.0 is told so rather than told to sync again.
  • Breaking: A face's appinfo is now pebble.appinfo.json at the face's own root rather than config/pebble.appinfo.json, and a face is a folder holding one, at the unit's root or beside a family's core/. Move each face's appinfo up a folder. The face actions at this tag name one still in config/. A unit whose root is a face is that one face, so a face folder beside a core/ under it is no longer found. A unit's config/ is left for its own tool configs, such as its tsconfigs and vitest.config.ts.
  • Breaking: tools/faces.ts, tools/paths.ts, and tools/files.ts are now in tools/shared/, beside the new checks.ts and tool-error.ts. Point a face tool's imports there, as in paf/tools/shared/faces.ts.
  • Breaking: The frame, icons, and thumbnails generators and the dev tools are now plugins, each in its own folder with its own package.json: plugins/frame/, plugins/icons/, plugins/thumbnails/, and plugins/dev/. List the ones a unit uses under plugins in paf.config.json. paf gen <face> <kind> runs a generator, so drop a face's gen:frame, gen:icons, gen:thumbnails, and gen:clay scripts. Each plugin's dependencies install with it, so a unit without frame no longer installs Playwright.
  • Breaking: css/pebble-colors.css is now part of the frame plugin, at plugins/frame/css/pebble-colors.css. Point each frame HTML's stylesheet link there.
  • Breaking: The Clay preview and the tap walk are now part of the dev plugin, as plugins/dev/clay-preview.ts and plugins/dev/tap-walk.ts, and run as paf tool <face> clay-preview and paf tool <face> tap-walk. Drop the face's dev:clay script. The preview page lands at paf/plugins/dev/clay-preview.html. The tap walk takes the face as its first argument and walks whatever is on the emulator. --install puts the face's latest build there first rather than building one. Run paf build <face> before it. --target picks the build for a face that ships several targets, and is refused without --install.
  • Breaking: The dev harness's C now comes with the dev plugin, at plugins/dev/c/dev/. The waf build stages each folder under a plugin's c/ beside the framework's own C, so #include "dev/dev_walk.h" resolves as before. A face without the plugin still builds while its DEV_MODE is 0. List dev under plugins to turn DEV_MODE on.
  • Breaking: The icons generator now reads its SVG folder from the icons plugin's sources setting in the unit's paf.config.json, relative to the unit, or from the ICON_SOURCES environment variable. It no longer reads framework.iconSources from the unit's package.json, so move the path to "plugins": { "icons": { "sources": "<folder>" } }.
  • Breaking: module-thumbnails.g.js and each generated Clay component now open with a header naming the paf gen command that makes them, rather than the generator's own file path, so the header stays the same wherever the generator lives. A face's committed copies read as stale until they are generated again.
  • Breaking: A face that declares only one of LOCATION_LATITUDE and LOCATION_LONGITUDE now fails to build, with the missing key named. Declare both, or neither.
  • Breaking: resolveTargets in tools/manifest/build-manifests.ts takes the face's name as its second argument, which its errors name. Pass it wherever a face's own tool calls it, or call the new faceTargetNames(face), which reads the appinfo too.
  • Breaking: buildMedia, replaceMediaArray, resourceName, IconManifest, and IconSpec are now exported from plugins/icons/media.ts rather than plugins/icons/generate-icons.ts. Import them from there.
  • Breaking: iconSourcesDir in plugins/icons/generate-icons.ts takes the unit's parsed paf.config.json, typed IconSourcesConfig, in place of its package.json and IconSourcesPkg.
  • The tap walk refuses a --target the face does not declare, and an --out outside the unit or holding files the walk did not write, since the folder is emptied first. It stops with the dev switch to check when the first tap changes nothing. A pebble step that fails stops the walk with pebble's own message and exit code. A step still running after 30 seconds stops it too, or after 3 minutes for the install or the first screenshot, either of which may boot the emulator, so a stuck emulator cannot hang the walk.
  • The dev harness now fills the weather and location stores only for a face that declares their keys, and sets them up empty otherwise, so a face without weather no longer boots the harness with a reading it cannot show.
  • The waf build stops when two plugins have a c/ folder of the same name, when a plugin has a c/core or c/pebble folder, when a plugin has a C source or header straight inside its c/, or when a plugin's C file has the same path as one of the face's own, the framework's, or the family core's. Other files straight inside a plugin's c/ are skipped.
  • project/toolchain.json is now toolchain.json at the top of the framework, and the setup-pebble action at this tag reads it there.
  • Breaking: config/tsconfig.pkjs.json is now tsconfig.pkjs.json at the top of the framework. Point a unit tsconfig that extends it at paf/tsconfig.pkjs.json.
  • Breaking: The house ESLint style now wants a blank line before and after every block, and after a run of const or let declarations. Run paf lint --fix to add them.
  • Breaking: The frame plugin's generator is now background, named after what it writes like the other generators, and its script is generate-background.ts. Run paf gen <face> background wherever a script or a README runs paf gen <face> frame. The plugin, a face's frame/ folder, and frame/frame.config.json keep their names.
  • Breaking: The background generator takes its frame and theme as flags. Pass the frame as --frame <name>, since a frame named without the flag now stops the run. Pass --theme <name> or --theme all on a face with themes, which no longer bakes without one. Each flag takes a name or all, and all bakes every one the face has. paf gen <face> all passes --frame all --theme all.
  • The background generator now stops on a frame or a theme the face does not have and names the ones it has. It also stops on a named theme for a face without themes, and on a frame page or a theme sheet called all. A face with themes bakes one frame, since a themed background is named after its theme alone, so --frame all stops on one with more. Only a frame with a page for a platform the face targets counts.

Removed

  • Breaking: Removed the files list from package.json. paf 1.0.0 reads that list to decide what ships, and without it copies the whole repo into lib/, with the framework one folder further down at lib/src/, where a unit does not build. Fill the unit with paf 2.0.0.
  • Breaking: Removed the test and lint packages from what the framework installs. Its package.json now lists only what its tools need to run, as dependencies: @rebble/clay, esbuild, ical.js, and typescript. Playwright and sharp come with the plugins that use them. ESLint and Prettier come with the code-style plugin. List vitest, jsdom, @types/node, and anything else a face's own checks use in the face's own devDependencies. ts/testing/ and ts/clay/builder/ts/testing/ import vitest, so a face using those helpers needs it installed.
  • Breaking: Removed config/eslint.config.ts, config/vitest.config.ts, config/typedoc.json, and the builder, tools, and spec tsconfigs in config/ from what ships. They check the framework itself. List the code-style plugin for the lint. Give a face its own vitest.config.ts, list vitest and what it loads in its own devDependencies, and point its test script at it. A face spec that imports the calendar code adds the ./icaljs alias to its Vitest config, using icaljsBundle() from tools/shared/paths.ts.
  • Breaking: Removed tools/typecheck.ts from what ships. It now checks only the framework's own projects, and paf typecheck runs tsc -p on each tsconfig a unit holds outside paf/. Add a tsconfig for any builder, tools, or spec code the face wants checked, and give each its own include and exclude. A spec or tools one extends paf/tsconfig.spec.json or paf/tsconfig.tools.json for the compiler options it needs. One that extends paf/tsconfig.json without an include checks the framework's ts/ rather than the face, and one without an exclude takes the framework's, which leaves the face's specs in.
  • Breaking: Removed tools/build-conditions.ts from what ships. It writes headers into the framework's own c/ tree, and those are committed in the framework, so a face never needs it. Drop the build:conditions script from a face's package.json.
  • Breaking: Removed build.sh. Replace anything that runs bash lib/build.sh <face> with paf build <face>.
  • Breaking: Removed tools/dev/tap-walk.sh. Replace anything that runs it with paf tool <face> tap-walk.
  • Breaking: Removed listFaces from tools/shared/faces.ts and the --faces option of tools/manifest/build-manifests.ts. Use listFaceNames for either. Also removed the builderDir, resolveIn, overlayPlugin, bundleInitialize, generateAll, indentBlock, buildTemplate, and buildStyle exports of tools/clay-components/generate-components.ts, which only the generator itself uses.
  • Breaking: Removed the framework's specs from a face's test run, including the ones that checked a face's committed Clay components, icon media, and thumbnails. Those checks are now scripts, so add paf check to a face repo's CI beside paf test, paf lint, and paf typecheck.

Fixed

  • Fixed every framework tool exiting 0 having done nothing under a Node with no import.meta.main, which is 22 before 22.18, any 23, and 24 before 24.2. A generator reported success with nothing written, and build.sh stopped saying the unit had no faces. Each tool now stops with exit code 1 and names the Nodes that have it.

3.0.0 - 2026-09-27

Framework Changes

Added

  • Added weather_reading_apply and the WeatherMessage and WeatherState types in core/weather/weather_reading.h. They turn one weather message off the phone into the kept reading, and every missing value is converted there once.
  • Added StorePoll with store_poll_set and store_poll_turn to io/stores/store_poll.h, the polling decisions the weather, stock, and calendar stores share. Each store keeps its own interval and its own first fetch.
  • Added the settings page's sections as their own builders in ts/pkjs/config-builder: appearanceSection, bluetoothSection, clockSection, healthSection, locationSection, and weatherSection. Each takes the same options as buildConfig, which is now those builders in their usual order, so a face that needs its own sections in between can lay out its page from the ones it wants. defaultDateOptions and beatsDateOptions are named exports too.
  • Added ts/pkjs/config-rows with select, heading, toggle, and VIBE_OPTIONS, the plain rows a hand-built settings page is made of.
  • Added ClayOption to ts/clay/types, and id and sunlight to ClayConfigItem, so a face's own settings rows need no copy of the type.
  • Added readPlace in ts/pkjs/place, which reads what a location field saved: the place as JSON, the place already parsed, or the offset,label an older time zone field kept. The weather fetch and a time zone field's send both read through it. validCoord moves there too, and ts/weather/feature still exports it.
  • Added watchSettings to ts/pkjs/settings-store, which snapshots a list of settings as the settings page opens and reports whether any of them moved once it saves. Weather, stocks, and the calendar each refetch through it.
  • Added ts/pkjs/asks, the rule weather, stocks, and the calendar share for who fetches between the watch and the phone. askShouldFetch folds a watch ask into a pending save refetch, slowTickShouldFetch skips a slow tick the watch already covered, and refetchAfterSave runs a forced refetch after the settings page saves. A face writing its own feature can use them with createAsks.
  • Added ts/pkjs/round, the one-round-at-a-time rule weather, stocks, and the calendar share for a fetch that answers later. startRound drops an unforced start while a round runs and gives a forced one the round, roundIsCurrent and finishRound drop a late answer from a round that was taken over or already ended, and shutOutRound drops the running round's answer after a settings save. A face writing its own feature can use them with createRound. StockState extends its RoundState, and the weather feature's WeatherState is the same type.
  • Added requestJson, cacheBust, safeParse, and the RequestFn type to ts/pkjs/request, the JSON layer the weather, stock, and calendar fetches share. requestJson hands every reply on with its error, so a provider can read a bodyless 401 or 429.
  • Added requests to a feature's hooks, naming the watch requests it answers. The phone logs one warning when the watch sends a request no listed feature answers.
  • Added settings_mark_restored, which clears settings_was_fresh. The transport calls it once a save or restore from the settings page has been saved.
  • Added tools/typecheck.ts, which runs the four typecheck projects side by side and reports every one that fails. Point a face's typecheck script at node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON lib/tools/typecheck.ts to use it.
  • Added layout_parse_int in c/core/layout/layout_string.h, which reads one layout field and gives -1 for a number past LAYOUT_INT_MAX rather than overflowing. A face that parses layout strings itself can call it in place of its own copy.
  • Added store_save_changed and store_restore_reading in io/stores/store_persist.h, with store_sum and STORE_READING_SIZE in io/stores/store_sum.h, for a store that wants to skip writing a reading it already saved.
  • Added callback_list_add and callback_list_fire in c/core/io/callback_list.h, a short fixed list of callbacks run in the order they were added.
  • Added an optional uuid to each entry in an appinfo's targets map, which that target's manifest uses in place of the face's. Targets sharing the face's uuid replace each other on the watch and share the phone's saved settings. A target with its own installs beside them, with settings of its own.
  • Added zone_setting_is_set, zone_setting_offset, and zone_setting_label in c/core/clock/zone_setting.h, which read a time zone setting as the phone sends it. An empty setting is no zone rather than UTC.
  • Added SETTINGS_REQUEST_FULL and SETTINGS_REQUEST_FRESH to WIRE_CAPS and wire_caps.g.h, the two values a settings request carries.
  • Added support for a family as the root of its own project, with its core/ and faces at the top and the framework mounted inside it at lib/. isFamilyRoot in tools/paths.ts recognizes one, and workspaceFor, findFaces, and familyCoreFor find its faces and core. familyNameFor in tools/faces.ts gives the family's name, which the build puts in front of the family's headers. The name is the family's folder, or "framework": { "family": "<name>" } in its package.json. A family in a repo of its own sets it when the repo has another name, since CI checks the repo out under that name. A family's name cannot start with a dot or hold a quote, a slash, or a backslash, since it goes into the generated wscript and names a folder in the sandbox.
  • Added project/toolchain.json, recording the Pebble SDK, the pebble-tool, and the Node major the framework is built and tested with. It carries a format number, and ships in the files list.
  • Added stocksSection and calendarSection in ts/pkjs/config-builder, and the stocks and calendar options that put them on a buildConfig page. The stock section holds the data source, its API key, and the tickers, and the calendar section holds the private iCal link. Each takes items for a face's own controls under them, such as a refresh interval or reminders the face's watch reads.
  • Added store_poll_reconnect_due in c/pebble/io/stores/store_poll.h, which says whether a reading is worth asking for again when the phone reconnects: one that never arrived, or one at least its own poll interval old. A face that keeps its own copy of the check can call this instead.
  • Added a files list to the framework's package.json, naming what a face project needs from the framework. The C host specs, the docs site, the hooks, and all of .github/ are left out, the face actions included. The TypeScript specs stay in, since the ones that check a face's generated files only run beside faces.

Changed

  • The icon cache holds 24 icons rather than 64, which covers every icon the busiest layout draws at once and saves 800 bytes of watch memory. Icons nothing draws any more, such as past days' moon glyphs, are dropped rather than kept. The cap is the ICON_CACHE_MAX compile-time define. A face has no hook to raise it yet, since its sandbox wscript is generated, so one drawing more than 24 distinct icons in one redraw sees the oldest reloaded.
  • timeband_clip now works out its minutes in 32-bit maths, so a face that clips spans onto a window no longer links libgcc's 64-bit division, about 750 bytes of watch binary.
  • Breaking: A cleared time zone field now reaches the watch as an empty string, rather than being left out of the save, so clearing the picker no longer leaves the old zone on the watch while the phone shows none. Read the setting with zone_setting_is_set and show no zone when it is not set, or a cleared picker shows UTC with no name. Give the watch field an empty or no default_str, since the watch skips an empty save for a field with a real default, and the clear would never reach it.
  • Breaking: A time zone picker is now a locationsearch item marked timeZone: true, rather than any key whose name contains TIMEZONE or TIME_ZONE. buildConfig's clock: { timeZone: true } picker is marked already. A face that builds its own picker adds timeZone: true to the item, or the picker offers no zones and the watch gets the saved place rather than offset,label. retimeSettings now takes the settings page after the message keys.
  • The iCal parse now logs when a repeating event runs out of steps before it reaches this week and is left off the agenda. An hourly event set up more than about fourteen months ago does, since the walk starts at its first occurrence.
  • The iCal parse now groups moved occurrences by UID once, rather than walking every one of them for each recurring event. A feed full of recurring meetings with moved instances parses in time proportional to its size.
  • OpenWeatherMap now skips its Open-Meteo call when the face shows none of the readings it lends, which are UV, dew point, the daily high and low, rain chance, and the forecast. A face that shows the forecast gets only the 16 hourly rows the strip reads rather than every hour of all eight days, and the daily strip still gets eight days. The feature passes the readings a face has keys for as fields in WeatherOpts, and a caller that leaves fields out still gets every reading.
  • The weather store's saved format changes, and STORE_TAG_WEATHER moves to 0x13, so a saved reading is refused once after the update and the weather panels show placeholders until the first poll answers.
  • Breaking: Weather is now an opt-in feature. Import it from lib/ts/weather/feature and pass features: [weather] to startPebbleApp. Faces that omit it no longer bundle the weather providers. The settings page's default intro no longer mentions weather.
  • A face without weather no longer needs the WEATHER_* or LOCATION_* message keys. A face with weather declares WEATHER_REQUEST, WEATHER_TEMPERATURE, WEATHER_CONDITIONS, and WEATHER_OK as before, and one that declares only some of them fails to build, with each missing key named. The coordinate keys are optional, and a face without them gets no coordinates. A pair where either half is not a string is ignored rather than blanking the saved fix. A face without weather must not use KNOWN_TEMPERATURE_UNIT, which reads WEATHER_TEMPERATURE_UNIT. The same holds for each group of extra readings: the humidity, wind, and sun times, today's high, low, UV, and rain chance, and the feels-like, pressure, and dew point. A face that declares only part of a group now fails to build rather than getting none of it.
  • Breaking: appmessage_open now takes the inbox size in bytes. The outbox is sized from the face's settings table, rather than both buffers opening at the platform maximum. Pass the size of the settings page's biggest save. Clay sends every message key on a save, including the ones the watch never reads, so a page with a URL field, a layout string, and many keys can need 6 KB where a small one fits in 2 KB. The size is held between the SDK's minimum and the platform maximum. A message bigger than the inbox is dropped whole and logged with its reason, and a failed open is logged too.
  • Breaking: build-pkjs.ts now takes a face rather than a target, and builds every target the face declares. It compiles once and copies the emit into the other targets. Run npm run build:pkjs -- <face>. build.sh already does.
  • Stock symbols can now hold digits and ^, =, /, :, and _, up to 15 characters. 7203.T, 0700.HK, ^GSPC, EURUSD=X, BTC/USD, and BINANCE:BTCUSDT now reach the provider rather than being dropped. The watch shows the first 11 characters. A face whose fonts leave out ^, =, or _ shows those as missing glyphs, so add them to the font's characterRegex.
  • Breaking: createDedupedSender now takes only the send queue and a label, and push takes the finished AppMessageDict. Pushes are compared by their JSON. Build the dict before pushing, and drop the same function from the call.
  • The Clay builder's drag now follows only the finger that started it. Another finger's moves and releases are ignored, and a drag that starts while one is under way cancels the first. Rerun gen:clay to take this into a face's committed .g.js.
  • The pkjs emit now drops comments, so a face's phone bundle is smaller.
  • Breaking: gen:frame now bakes a frame for each platform in the face's appinfo, from frame/<name>~<platform>.html to a background tagged ~<platform>, such as background~emery.png. A platform with no frame of its own is skipped with a warning. Add the platform tag to each frame HTML and baked background. The appinfo's media entries keep their untagged names, and the SDK picks the tagged file for each build. With --out and several themes, each theme lands beside the --out path under its own name rather than over the committed backgrounds.
  • The shared Vitest config now runs specs with TZ set to America/Toronto, a zone whose clocks change, so a spec built from local dates covers daylight saving on any machine, a UTC runner included. A face spec that leaned on the machine's own zone may need a fixed moment.
  • The waf build now stages only the framework's c/core, c/pebble, and c/dev into each sandbox, without the host specs, and clears anything else an older build left there. It no longer regenerates the weather tables during pebble build, since they are committed and a spec checks them. An array message key such as SLOT[4] now builds, where its bracket reached a compiler flag and failed every compile.
  • build-manifests.ts <face> now prints each sandbox's path on stdout and its note on stderr. It only rewrites a sandbox's package.json and wscript when they change.
  • Breaking: timeband_window_epoch now takes the clock as an epoch in place of today's midnight, and counts back from it. Pass now and the wall clock's minute of the day. A window on the morning the clocks change now starts at the right hour, and one that has not opened yet, such as a forecast strip starting at the next hour, is today's rather than yesterday's.
  • Breaking: The health store only reads the walked distance for a face that sets distance in HealthConfig. Set .distance = true if the face shows distance, including a steps readout that can switch to it. Without it health_store_distance_m stays 0.
  • Breaking: appmessage_on_inbox_complete is now appmessage_add_inbox_complete, and it adds to a list of up to four handlers rather than replacing the one there was room for. Several stores can each commit what one message brought them. Rename the call.
  • On a face that declares SETTINGS_FRESH, a phone that already holds settings now asks the watch only whether it booted empty, rather than for the whole settings table on every start. The watch answers with SETTINGS_FRESH and the request marker only.
  • The watch's settings reply now carries SETTINGS_REQUEST back as its marker, and the phone recognises the reply by it rather than by CLOCK_DATE_FORMAT or APPEARANCE_THEME being there. A face without those two keys is now seeded too.
  • npm run typecheck in a family now writes its builder, tools, and spec projects under targets/.typecheck/, naming each face's folders. The framework's own configs reach faces through globs that start one folder above the framework, and a glob wide enough for a family's faces also reaches the repos cloned beside it. The family's own tsconfig.json still covers its phone code, so a family writes one taking in */src/pkjs/**/*.ts and lib/ts/**/*.ts.
  • setup-pebble now takes a face. Given one that paf sync has filled and installed, it installs the Node major, SDK, and pebble-tool that framework records in project/toolchain.json. A face whose project has no node_modules stops the step. It also takes pebble-tool-version, and pinned versions are cached by SDK and pebble-tool together. A pinned SDK with no pebble-tool-version caches the pebble-tool that was newest on the first run, and keeps it until the runner image changes. With a face, the recorded versions win over node-version, sdk-version, and pebble-tool-version, and a run that gave a different SDK or pebble-tool gets a warning naming both.
  • publish-release and report-memory now find a face's build output and lib/ in the face's project. That is the repo root in a repo that is one face or one family, and the face's or family's folder under watchfaces/ or watchapps/ in a repo of several. A face whose lib/ holds no framework stops the release. A workflow loads the actions from this repo at a tag, such as AKlitbo/pebble-app-framework/.github/actions/setup-pebble@v3.0.0, since a lib/ filled from the files list holds no .github/.
  • prepare-release now finds the face in its project the same way, and reads the face and its version with the tools in the face's lib/, the same as its build. The framework is held to the version in lib/package.json, and a face whose lib/ holds no framework stops the release.
  • Breaking: buildConfig now leaves the Health section out unless the face passes steps or battery, and healthSection returns null then, the same as the location and weather sections. A face with no steps readout no longer has to declare HEALTH_STEPS_MODE or the health capability. Pass steps: {} to keep the steps control. A face that passes only battery gets a section headed Battery holding that control alone.
  • Breaking: gen:icons no longer reads its SVG sources from vendor/. Name the folder as "framework": { "iconSources": "vendor" } in the repo's package.json, relative to that file. Without the setting it reads the folder from ICON_SOURCES, and with neither it stops and names the setting. An icon whose source is missing now keeps the PNG it has, with a warning, as long as that PNG is the size icons.json asks for, so a face drawing from a set the machine does not have still regenerates the rest. A named folder that is not there stops the run, and so does one holding none of the icon sets.

Removed

  • Breaking: Removed support for a repo mounting one framework at its root with its faces under watchfaces/. The tools, typecheck, lint, and Vitest now take a face or a family as the folder mounting the framework, and the face actions read each face's framework from its own project's lib/. Give each face and each family under watchfaces/ its own lib/ with paf, and run paf sync before setup-pebble, which no longer installs a face's packages. prepare-release no longer asks a lib/ checkout for its tag.
  • Breaking: Removed clockstr_parse and clockstr_minutes. weather_store_sunrise and weather_store_sunset now return minutes past midnight, -1 for none, and the phone sends WEATHER_SUNRISE and WEATHER_SUNSET as numbers. WeatherSeed.sunrise and .sunset are minutes too. Read the getter where a face parsed the string, and format it with the minutes and hours it already holds.
  • Breaking: Removed wx_label_long, ui/weather/labels.h, and the generated labels_table.g.h. wx_label_short moves to c/core/weather/wx_label.h and writes the word into a buffer, wx_label_short(out, n, condition). A face that shows the sky in words declares WEATHER_CONDITION_LABEL, which the phone fills from the vocabulary, and reads it with weather_store_cond_label.
  • Breaking: Removed appmessage_on_weather_extra, appmessage_on_weather_forecast, appmessage_on_weather_air, appmessage_on_weather_forecast_hourly, and appmessage_on_weather_forecast_daily, with their handler types. appmessage_on_weather now takes a WeatherHandler that gets one WeatherMessage holding every group the phone sent. A face that registered its own weather handlers reads the message with weather_reading_apply, passing the clock as a WeatherClock.
  • Breaking: Removed weatherSettingsSnapshot and weatherSettingsChanged from ts/weather/feature, and stockSettingsSnapshot and stockSettingsChanged from ts/stock/feature. Use watchSettings(WEATHER_KEYS) or watchSettings(STOCK_KEYS) from ts/pkjs/settings-store, calling opened() as the page opens and changed() once it has saved.
  • Breaking: Removed safeParse from the default exports of ts/weather/util and ts/stock/util. Import it from ts/pkjs/request instead.
  • Breaking: Removed appmessage_on_location_name and the LocationNameHandler type. The phone no longer sends the resolved place name as LOCATION_NAME on every weather message. LOCATION_NAME stays the key of the saved city setting. Drop any appmessage_on_location_name call.
  • Breaking: Removed the WEATHER_PRECIPITATION, WEATHER_CLOUD, WEATHER_WIND_GUST, WEATHER_PRECIP_TOTAL, and WEATHER_UV_MAX weather extras. The watch side never had a handler or a store getter for them, so the phone was fetching values nothing could show. Every provider stops asking for them, and Open-Meteo's weather request is smaller for it. A face that read one of them with its own inbox code has to drop that code.
  • Breaking: Removed formatCoords from startPebbleApp's options. Pass the formatter to weather.withCoords instead. It still runs on every weather result, a failed one included, so a face can show its own text when there is no fix.
  • Breaking: Removed location.timeZone from buildConfig. Pass the new clock: { timeZone: true } instead, which puts the alternate time zone picker in the Clock section rather than Location Settings. It needs the CLOCK_TIMEZONE_1 key and works without weather, and the saved zone is kept since the key is the same. buildConfig logs a warning if a face still passes location.timeZone.
  • Breaking: Removed bytesEqual from ts/pkjs/wire's default export.
  • Breaking: Removed wx_resource_for_forecast_code from ui/weather/icons.h, and the generated icon_codes_table.g.h behind it. A face drawing the forecast strips maps the code to its own icons: the low bits are the condition's place in the vocabulary in ts/weather/conditions.ts, and WX_FORECAST_NIGHT_BIT marks a column after dark.
  • Breaking: Removed enabled from every store's config: TimeConfig, WeatherConfig, HealthConfig, SystemConfig, LocationConfig, StockConfig, and CalendarConfig. Drop .enabled = true from each *_store_init call. A face that does not want a store leaves its init call out.
  • Breaking: ts/pkjs/app's default export now holds only startPebbleApp. Import request from ts/pkjs/request, getConfig, readValue, and readBool from ts/pkjs/settings-store, and runWeatherRound, validCoord, getManualLocation, weatherRetryDelayMs, WEATHER_RETRY_DELAYS_MS, WEATHER_KEYS, and the WeatherState and WeatherDeps types from ts/weather/feature. collectDefaults, seedConfigFromWatch, retimeSettings, and SETTINGS_REFETCH_DELAY_MS are named exports of ts/pkjs/app.
  • Breaking: Removed settings_serialized_size. Use settings_serialized_size_max, the most bytes a face's settings reply can take. The outbox is opened at that size, so the settings reply always fits and is no longer measured before each send. appmessage_open logs once when a settings table could outgrow the platform's largest outbox.
  • Breaking: Removed seedKeys, seedColorKeys, and seedBoolKeys from startPebbleApp's options. The phone now seeds every setting the watch sends that the settings page has an item for, converted by the item's type: a toggle as a boolean, a colour or slider as a number, and anything else as a string. Place fields are skipped, and time zone fields seed as before. Drop the three options from the face's startPebbleApp call. seedConfigFromWatch takes the settings page in their place.

Fixed

  • Fixed a layout build that returned more slots than it was given room for writing past the engine's slot arrays. The count is now held to ENGINE_MAX_SLOTS.
  • Fixed a settings save that changed a time zone field sending the same zone to the watch again on the next background push.
  • Fixed a face that lists the stocks feature without STOCK_STRIP sending its saved strip under an undefined key at every start.
  • Fixed tap-walk.sh reporting one screenshot state fewer than it captured.
  • Fixed a theme walk ignoring the face's DEV_TIME_MIN. Each shot moved the pinned clock to a minute of its own, so a panel that builds its own time through dev_force_time disagreed with the clock. Every shot now keeps the hour and minute the face pinned.
  • Fixed a saved weather or stock provider of constructor or __proto__ crashing the fetch. The provider lookup read any property of its table, so these picked up a built-in and called .fetch on it. They now fall back to the default provider like any other unknown name. A face with no weather provider setting now uses Open-Meteo without logging an unknown provider called undefined on every fetch.
  • Fixed build.sh all exiting 0 having built nothing when the face lookup failed or the repo had no faces, and a broken sandbox package.json building the face with every optional feature off. Both now stop and say what is wrong.
  • Fixed gen:clay resolving a core or lib piece's import to its own folder when the face has its own copy, writing output that changed with the folder it was run from, and dropping the space before a colon in builder CSS, which turned a descendant selector such as .grid :first-child into a different one. The face's copy now wins, the same way manifests shadow each other, the path comments always read from the repo root, and the space stays. A quoted string in builder CSS, such as a content value or an attribute selector, now keeps its spaces and commas as written. Rerun gen:clay if a face's builder CSS has such a selector.
  • Fixed the build only looking for ical.js in the mounting repo's node_modules. It now finds the package the way node and the specs do.
  • Fixed fmt_hundredths and fmt_pct_signed printing garbage for INT_MIN. A stock price comes off the wire as an int32, so a corrupt saved strip can hold it.
  • Fixed layout_has_any_block overflowing on a corrupt module number of ten or more digits, which could wrap onto a real module and count an empty layout as placed.
  • Fixed gen:icons dropping every field but type, name, and file from a bitmap that is not an icon, such as a background's memoryFormat or targetPlatforms, and from an icon it kept, and taking a "media" string earlier in the appinfo for the media array. Each field now stays on the bitmap's line, and an icon keeps its own through a re-gen.
  • Fixed WEATHER_NO_TEMP in wire_caps.g.h expanding without parentheses, so face code writing -WEATHER_NO_TEMP read it as a decrement and failed to build. It is now (-1000).
  • Fixed an appinfo with an empty targets map, or a target with no name, failing the build with a TypeError or building into targets/undefined. Both now stop the build with an error naming the problem. Two faces declaring the same target name, or one face declaring it twice, now stop the build too, where the second built over the first's .pbw.
  • Fixed two modules sharing a slug in module-meta.ts passing the thumbnail check, which left the first one's tile blank on the settings page. The build now stops and names both.
  • Fixed clay-preview.ts crashing on a face that declares messageKeys as a map of name to id, and --watch missing edits to the family core's settings sections and to lib/ts.
  • Fixed timeband_rolling putting now just past the far end when the lead was as long as the window. The lead is now pinned to the window's last minute, so the window always holds the moment it was built around.
  • Fixed the system store's reconnect callback running before the store recorded the phone as connected, so a face's reconnect handler that read system_store_bluetooth saw it disconnected.
  • Fixed the weather, stock, calendar, and location stores writing to flash on every reply, even when the reading had not changed. Each keeps a checksum of the reading it last saved and skips a write that would only store a new sync time. A relaunch then reads the saved reading as older than it is, so the catch-up poll after a reconnect can fire a little sooner.
  • Fixed the stores repainting the face when nothing new had arrived. The health store asked for a repaint every minute, and the weather store once for every weather channel in a message, up to six times. The health store now asks only when a reading moved, which on a face that graphs the heart rate window is still once a minute. The weather store repaints once after the whole message, so a face reading several values together, such as sunset beside the temperature, never sees half an update.
  • Fixed the weather store re-asking for weather every 3 seconds at launch after the phone had already answered, up to 9 requests when the fetch failed. Any weather reply now ends the launch re-asks, a failed one included, and the recurring poll takes over.
  • Fixed a dispatch_bytes defined but not used warning when building a face with none of the forecast, stock, or calendar strip keys.
  • Fixed publish-release publishing a face tag with a pre-release version, such as gridlock-v3.0.0-rc.1, as a full release that GitHub marked Latest. It now goes out as a pre-release. A tag that was never pushed now stops the release, where gh made it at the default branch and attached pbws built from another commit. A face whose appinfo lists no targetPlatforms now stops with a message naming the field, where the step crashed after the build had finished.
  • Fixed prepare-release stopping every release of a face with no version in its appinfo. It is now held to the repo's package.json version, the one it builds as, and a tag whose pre-release label holds -v now splits at the version. It now stops before the build when the appinfo lists no targetPlatforms, when the version is not shaped X.Y.Z or X.Y.Z-label, or when the changelog heading's date does not exist, such as 2026-19-07. An appinfo or package.json that is not valid JSON now fails with the file's name rather than a stack trace.
  • Fixed a re-run of a face's build job failing at report-memory's upload when the first run had already uploaded the memory rows. Its log input is now refused when it points outside the repo, the same as every other action's path input. render-memory now warns rather than failing when no build left any rows, since the builds that broke already fail the run. Its table now puts the warning mark on whichever limit a row is closest to, and lists a row whose binary was missing or too short to read first as not measured rather than last at 0%. report-memory now warns when a platform's memory block is missing figures, where the platform dropped out of the table without a word.
  • Fixed setup-pebble only caching a pinned SDK from a job that passed, so a face build that failed downloaded pebble-tool and the SDK again on every run. The cache now saves straight after the install.
  • Fixed gen:frame baking a frame whose page linked a local stylesheet that was not there, leaving every colour and font the sheet declared out of the background with no error. It now stops and names the page and the link, before any platform's frames are baked.

Wearer-Visible Changes

Changed

  • A time zone picked before the zone was kept now shows its label on the watch flattened to ASCII, the same as every other time zone field.

Fixed

  • Fixed the text fields on the settings page, such as the API keys, the tickers, and the iCal link, taking any length. buildConfig wrote limit, which Clay copies onto the field as it is and a browser does not read, so the builder now writes maxlength.
  • Fixed a place typed into a location picker and saved without tapping a suggestion saving as nothing, while the box still showed the name. The picker now shows "Pick a place from the list to save it." while the box holds text that is not a pick.
  • Fixed a stock symbol, provider, or key change whose forced fetch failed bringing the old watchlist back the next time the quota gate held a fetch. The strip kept for a shut gate and the throttle stamps are now forgotten when a stock setting changes, and a round still out for the old list is shut out so a late answer cannot put them back. A changed iCal URL now does the same for a download of the old feed still out, which pushed the old agenda to the watch in the moment before the refetch and kept it there when the new feed's download failed.
  • Fixed the restore of a wiped watch dropping a checkboxgroup's setting from the phone for good, sending a slider with decimal steps unscaled so 1.5 reached the watch as 1, and sending a setting the face no longer has under a key called undefined0, which can stop the whole restore reaching the watch. The saved settings now go to Clay wrapped the way the settings page returns them, only the ones with a message key go, and a slider's precision comes from its step, which is now on ClayConfigItem. A slider seeded from the watch is scaled back down too, where it came back ten times too big and reset to its default. A setting on an array message key, such as SLOT[1], now goes with a restore and seeds from the watch, where it was left out as having no key, and a time zone picker on one is retimed like any other.
  • Fixed a restored weather, stock, calendar, or location reading with a damaged string printing past the end of its field. Each string field now gets its terminator back after a restore.
  • Fixed one calendar event ical.js cannot read, such as a weekly rule with BYMONTHDAY or an event with no start, stopping the whole agenda from updating while it stayed in the feed. That event is now skipped and logged.
  • Fixed a cancelled calendar event still showing on the watch. A feed marks one STATUS:CANCELLED rather than taking it out, for a whole event or series or for one occurrence of a series. The reader now leaves them off, and a cancelled occurrence leaves a gap in its series.
  • Fixed an emoji in a calendar title or place reaching the watch as two or more question marks, which used up the field's 24 characters. An emoji, a flag, or an emoji built from several now goes over as one ?, and so does a Hangul syllable. A keycap keeps its digit.
  • Fixed a choice on the settings page being ignored by the watch when its select sends it as an integer, as one using Clay's serializeValueAs: 'integer' does, and being overwritten with its first choice when sent empty or as a word. An integer is now read, and a value that is not a number is left alone.
  • Fixed the step chart shifting every bar by an hour on the days the clocks change, with the current hour's bar near empty or doubled. Each hour now starts on the wall clock, and the catch-up after a relaunch reads the batch holding the hour the clocks go back through for its whole length, where its last hour came up empty and its steps landed in the current hour's bar.
  • Fixed a busy repeating event, one with more occurrences this week than the agenda keeps, leaving off an occurrence moved into the week from further out. An occurrence whose own slot the walk never reached is now looked up wherever that slot sits, so one moved from past where a full walk stopped, or from a slot the rule no longer makes after it was edited, shows too.
  • Fixed one error on the phone stopping everything after it, which could skip every feature's refetch after a settings save, stop the background refresh for the rest of the session, or skip the settings restore. A message the phone could not encode threw out of the send queue, and a feature hook that threw stopped every feature after it. On a settings save that skipped every feature's refetch, on ready it stopped the refresh timer for the rest of the session, and in a watch message it skipped the settings restore. A send callback that threw stalled every send queued behind it, and a settings page closed with a response that is not the settings, such as CANCELLED, threw out of webviewclosed. A send that throws now counts as a failed one, a callback that throws is logged and the next send still goes, each hook runs on its own with a throw logged, and a page closed without settings is logged and skipped.
  • Fixed the watchlist spending a call on every watch poll on a metered Twelve Data or Alpha Vantage plan, or sitting blank until the next weekday close, because of the stock quota gate. A round the provider answered with nothing good, such as NO SYMBOL for a mistyped ticker, left it open, so every watch poll spent another call on a metered plan. A fetch stamped while the phone's clock ran ahead held it shut until the clock caught up. A phone with no stamp at all, such as after its storage was cleared, held Alpha Vantage shut outside the evening window, so the watchlist sat blank until the next weekday close. Such a round now records its time and keeps it through a restart of the phone's JS. Only a round the provider never answered retries on the next poll, a stamp in the future counts as due, and Alpha Vantage with no stamp fetches once at any hour. A provider name saved with capitals, such as TwelveData, now gets the same gate as one in lowercase.
  • Fixed an all-day event on a day the clocks change ending an hour into the next day, or an hour short of its own. It now ends at the next midnight on the wall clock.
  • Fixed the steps readout showing -1 before the first reading, and 0 or 0.0 on a watch with Health off. It now shows -- for no reading in either mode, steps or distance, and health_store_steps returns -1 when there is none, as its doc says.
  • Fixed a stock round that never reached the provider replacing the watch's last good quotes with NET ERROR in every slot, until the next good round and through a relaunch. The watch keeps the last good strip. An answer the wearer has to act on, such as INVALID KEY, still shows.
  • Fixed the weather spending up to six calls a round on the provider's quota retrying a failure that waits on the settings, such as a bad key or no location, for an answer that could not change. Only a failure that can clear, such as a network error, retries. A provider's rate limit, OpenWeatherMap's 429 or WeatherAPI's used-up monthly quota, now reads RATE LIMIT and is not retried either, where it read API ERROR.
  • Fixed a forecast strip that outlived its fetch labelling its columns with hours already over, and keeping yesterday as its first day, when only the forecast half of later fetches failed. weather_store_forecast_hourly and weather_store_forecast_daily now drop the columns already over and move base_hour and base_weekday with them. A strip that lands after its first hour or day, such as one held while the watch was out of range, is anchored behind the clock rather than most of a cycle ahead, so it ages too.
  • Fixed a watch that reset its settings on a saved blob it could not read, such as after going back to an older build, never asking the phone to restore them. It now counts as fresh, and the reset removes the saved blob rather than writing the defaults, so a relaunch before the restore lands still counts as fresh and asks again. Every schema in the chain is checked, so a companion reset the same way is restored too.
  • Fixed the calendar window ending six days on from the fetch rather than at the end of the sixth day, so an evening event on that last day was dropped or kept by the time of day the feed was fetched.
  • Fixed the night schedule and the forecast hours being off for a manual location in another time zone. Sunrise, sunset, and the forecast strips' first hour and day were sent in the weather location's own zone. They now come on the phone's clock. Open-Meteo is asked in the phone's zone. When the phone cannot name its zone, or Open-Meteo refuses the name, it is asked in the location's, and its times are moved onto the phone's clock by the response's utc_offset_seconds. The daily strip drops the days the phone has finished with, and today's high, low, and rain chance come from the phone's today, or are left out when the response does not hold it. The Open-Meteo calls OWM and WeatherAPI make ask again in the location's zone too, where a refused zone left them without UV, the high and low, and the forecast. OpenWeatherMap's times are read on the phone's clock, and WeatherAPI's are moved onto it. WeatherAPI's high, low, and rain chance come from the phone's today too, and it is asked for days=2 so that day is there when the location is behind the phone.
  • Fixed today's high, low, UV, and rain chance still showing the day after they came, when only the forecast half of later fetches failed. weather_store_temp_max, weather_store_temp_min, weather_store_uv, and weather_store_precip_chance now report no data once the day they came is over.
  • Fixed the temperature showing in the old unit with the new letter, such as 23F for 23 degrees Celsius, after the wearer switched units while the phone was offline or the fetch failed, or when a fetch from before the switch answered just after it. The weather already on the watch now converts to the new unit when the wearer switches, through weather_reading_convert, a restore from the phone is left alone, and a save shuts out a fetch still out for the old settings. Faces are told through the new appmessage_on_unit_changed, which the weather store registers.
  • Fixed the heart rate graph falling out of step with the clock. The backfill after a first launch placed its records as if the last one were the current minute, which shifted the graph when the watch's minute log ran behind the clock. A watch clock set back froze it, with only its last bar moving until the clock caught up. Each record now lands in the slot for its own minute, through minute_window_first_slot in core/health/minute_window.h, and the graph starts over when the clock goes back. A relaunch after time in a watchapp now fills the minutes it was away from the watch's log, where the graph came back with a hole in it, or blank after an hour, and a minute holding a live reading keeps it.
  • Fixed a wiped watch staying on its defaults for the whole session when the phone nacked the watch's settings reply at cold boot, since the phone asks for settings once per launch. The reply was dropped. The full and fresh replies now get the same retries as a request, and each retry is rebuilt with the settings as they are then.
  • Fixed a webcal:// calendar link, such as an iCloud Subscribe link, never loading. It is now fetched over https. A link with a # part now gets its cache stamp before the #, where the stamp never reached the server and a cached copy of the feed could come back. Also fixed the calendar stopping its refreshes for the rest of the session when the feed was cleared while a download was out.
  • Fixed a weather provider's missing reading reaching the watch as a real one. Open-Meteo's null rain chance or UV read as 0, OpenWeatherMap's null wind speed as 0 km/h, and a missing Open-Meteo weather code as the fog glyph on the forecast strips or CLEAR for the current reading. Each now reads as no data. A high, low, feels-like, or dew point out of range, or a forecast column's temperature, is now held to -99 to 199 the way the current temperature is.
  • Fixed a restored watchlist or agenda staying stale until the next poll, and the weather panels sitting on placeholders for up to a whole poll interval, when a store's first fetch went missing. A settings push in the first second after launch cancelled it, which also stopped the weather store's boot re-asks. weather_store_reconfigure never fetched when it turned polling on for a store with no reading. A reconfigure now only cancels a waiting fetch when polling turns off, and every store catches up right away when it turns polling on with nothing to show.
  • Fixed a text setting whose default is empty, such as a picker whose None is "", being impossible to clear once set, and being rewritten to flash on every launch. Such a field now keeps an empty value, through cstring_setting_is_clean in core/text/number_format.h. A field with a real default still ignores an empty save.
  • Fixed weather, stocks, and the calendar each fetching for both the watch's poll and the phone's own refresh. Weather was fetched twice per 30 minute poll, stocks spent their quota twice, and the calendar downloaded and parsed the whole feed every 5 minutes whatever interval the watch asked on. The phone's slow refresh now skips when the watch asked since the last one, and only fetches on its own once the watch stops asking, such as when it is out of range. A watch ask or a slow tick while a fetch is already out, or just after a settings save that refetches, no longer starts a second one.
  • Fixed a text setting with a defaultValue, such as the tickers, the iCal link, or an API key, coming back as its default on the phone when the wearer emptied it. The features now read them with readText from ts/pkjs/settings-store, which keeps an empty value and takes the default only for a field never saved.
  • Fixed a typed offset such as utc+5 on the time zone picker showing only its Etc/GMT-5 name, whose sign runs the other way. It now reads UTC+05:00.
  • Fixed Open-Meteo pressure reading far too low at altitude, about 840 hPa in Denver rather than about 1013. It sent ground level pressure where OpenWeatherMap and WeatherAPI send sea level. WEATHER_PRESSURE now carries Open-Meteo's pressure_msl, so the reading no longer jumps when the provider changes.
  • Fixed settings being rewritten to flash on every weather, stock, or calendar message after a fresh install, until the face relaunched. On a face that declares SETTINGS_FRESH, the phone now sends the key with every save and restore from the settings page. While fresh, the watch only writes to flash on that message, and saving it ends fresh. Anything else, such as the time zone push on every ready, stays in memory until the restore lands. The phone also stops pushing the whole config back on every ready. A face without the key saves only when a setting changes. The key carries 1 on a restore and 0 on a save, so a unit switch on the first save of a fresh watch converts the reading in hand, where it read as a restore and showed 21 degrees C as 21F.
  • Fixed the sun readouts when sunset falls after midnight, as in a high latitude summer. Daytime read as night and the next event read as a sunrise hours away. solar_day_progress, solar_night_progress, and solar_next_event now handle it, and equal sunrise and sunset times, and readings outside the day, now return no data.
  • Fixed the time zone picker on the settings page reopening its list after a zone was tapped, while a search was still waiting or the geocoder had not answered, where a stray tap could overwrite the pick. Also fixed a zone's UTC offset hint coming out a minute short when the page opened as the minute rolled over.
  • Fixed the moon countdown reading 30 right on the new moon. moon_days_to_phase now returns 0 to 29, and the half day after the moon counts as now as well as the half day before.
  • Fixed a symbol outside the US showing NET ERROR on Finnhub's free plan, which answers 403 for one, such as SHOP.TO. That now shows NO ACCESS. Twelve Data's 403 for a symbol outside the plan now shows NO ACCESS too, and its server errors show NET ERROR. Both read as INVALID KEY, which shut the quota gate for a problem the key did not cause.
  • Fixed every settings save counting as a change, which rebuilt the face and made the calendar store refetch the whole feed. The custom colours are now compared with what the face holds, up to as much as it can hold, before they count as changed. The stock and calendar stores only catch up after a save when they have never had an answer, so a cleared watchlist or an empty agenda no longer asks again on every save.
  • Fixed a setting only seeding from the watch when the face listed it by hand, so a select such as the temperature unit opened the settings page on its default rather than what the watch was set to. A phone with nothing saved now refetches weather, stocks, and the calendar once the watch's settings are seeded, where the first fetch ran on the defaults and a watch on Fahrenheit showed a Celsius reading as 22F. A face without SETTINGS_FRESH gets the same refetch on its first seed, and a setting with nothing saved now compares as its default, with a single value compared as text so a number default matches the string Clay saves, so a seed or first save that writes the default back no longer spends a second call.

2.2.0 - 2026-09-23

Framework Changes

Changed

  • Renamed the repo to pebble-app-framework, and its package to match. GitHub redirects the old URL, so a face's .gitmodules keeps working. Point it at https://github.com/AKlitbo/pebble-app-framework.git and run git submodule sync. A face moving lib to this release has to run npm install and commit package-lock.json, because the lock names the lib workspace and its node_modules link after the package. npm ci, and so CI, fails until it does.

2.1.1 - 2026-09-22

Framework Changes

Fixed

  • Fixed -DBUILD_WATCHAPP never being passed, so an app target built as though it were a watchface. A face's #ifdef BUILD_WATCHAPP code now reaches the app, which for Gridlock is the weather request it makes as it opens. Run npm run build:manifests to rewrite the sandboxes, which build.sh and CI already do.

Wearer-Visible Changes

Changed

  • The settings page's prompt to pick a city again now draws as a callout, with an accent bar and a badge. It is the locationsearch prompt, and a face can repaint it through .loc-note.

Fixed

  • Fixed a timezone key never being seeded back from the watch, so the config page opened on an empty field when the phone had nothing saved. The place name now comes back and the picker asks for the city again. The watch's value only fills a field the phone has nothing saved in, so a saved place keeps its zone.
  • Fixed the watch showing a time zone of zero minutes under no name before a place was saved. An empty timezone key was sent to the watch, which reads it that way. The key is now left out until a place is saved.
  • Fixed a city picked for a time zone on the settings page saving as UTC when the page was saved before the zone lookup answered, or while offline. A pick in a locationsearch timezone field now takes its zone from the geocoder result straight away, and the prompt to pick again shows when the result has none.
  • Fixed a moved occurrence of one recurring iCal event replacing the occurrence of every other recurring event at the same time, and showing once for each of them. parseIcal now relates a RECURRENCE-ID VEVENT only to the event with the same UID.
  • Fixed an older calendar fetch that answered last overwriting a newer one, such as the old feed showing after the iCal URL changed. Only the newest fetch's answer is sent to the watch.

2.1.0 - 2026-09-20

Framework Changes

Added

  • Added layout_has_any_block in c/core/layout/layout_string.h, which says whether a layout wire string holds a placeable block. The catalog bound comes in as an argument.
  • Added layout_role_pick in c/core/layout/layout_role.h, which picks between a face's day, night and Quiet Time layouts. A trigger with no layout assigned falls through to the next one rather than drawing an empty grid.
  • Added time zones to a locationsearch field on a timezone key. It matches UTC, a zone name, or a typed offset such as utc+05:30 as well as a city, and a zone pick persists {label, offset, tz, fixed} with no coordinates.

2.0.0 - 2026-09-17

Framework Changes

Added

  • Added node-version and sdk-version inputs to the setup-pebble action. Both are optional and default to Node 24 and the latest SDK.
  • Added report-memory and render-memory actions, which rank each face's memory use in the CI run summary.
  • Added prepare-release and publish-release actions for checking and publishing face releases.
  • Added an optional cancel callback to the builder's DragSpec, called when a pointer is taken away mid-drag. Builders that remove an item from their model in lift can restore it here.
  • Added createDedupedSender to ts/pkjs/send-queue.ts. It wraps a queueSend so an unchanged payload is skipped and a payload whose send failed is not counted as delivered, which is what the weather, stock, and calendar pushes all use now.

Changed

  • Breaking: The engine can now be mounted under any folder name. The face repo declares that folder in its package.json workspaces, which is how the tools locate the engine.
  • build.sh and the waf build now find faces through the same lookup as the other tools. build.sh no longer needs the repo's build:pkjs script.
  • Breaking: Stocks and iCal are now opt-in features. Pass features: [stocks, calendar] to startPebbleApp. Faces that omit them no longer bundle their code, so Gridlock must list both.
  • Breaking: The Clay, thumbnail, and icon generators now take the face as an optional argument and run for every face that needs them when it is omitted. A repo now needs one gen:clay, gen:thumbnails, and gen:icons script instead of one per face. Their banners name those scripts, so committed output must be regenerated.
  • ical.js is now copied into a face's build only when the face uses iCal.
  • Breaking: The thumbnail generator now takes panel sizes from the face instead of a fixed list. A face exports them as thumbnailSizes from module-meta.ts.
  • Exported the option and state types used by startPebbleApp, runWeatherRound, buildConfig, the hidden store component, and the WeatherAPI provider so faces can name them in their own code.
  • Pinned @rebble/clay to 1.1.0, which adds the TOUCH, SPEAKER, and RGB_BACKLIGHT capabilities and integer values for inputs, selects, and radio groups.
  • The setup-pebble action now verifies that pebble-tool runs and adds the pebble-tool and SDK versions to the job summary. A pinned sdk-version is cached between runs.
  • Breaking: A locationsearch field now persists the saved place as JSON for every key type, including its timezone alongside the coordinates. The pkjs side builds the offset,label value sent by a timezone field, so a face reading the setting from the phone's config now receives the JSON blob instead of the pair.
  • Breaking: LocationConfig now carries a persist_key, like every other store's config. Pass the key you want location_store_init to save the last fix under. A face that omits it gets key 0, and 253 is what earlier versions used.
  • The strip caps and marker values the phone and the watch both bound against are now generated into c/core/wire/wire_caps.g.h from WIRE_CAPS in ts/pkjs/wire.ts, rather than defined once per language. npm run build:conditions writes it alongside the weather tables, and it is committed like them.
  • The OpenWeatherMap provider now fetches its own endpoint and the Open-Meteo extras at the same time instead of one after the other. A weather round spends one request timeout rather than two, and the extras call goes out even when the OWM key is rejected.

Removed

  • Breaking: Removed the tools/ci/ scripts. A face repo moving to this engine should switch its workflows to the new actions in the same commit.
  • Breaking: Removed weather_store_location_name and the weather store's LOCATION_NAME handler. Nothing read them. STORE_TAG_WEATHER moves to 0x12 with the struct, so a saved reading is refused once after the upgrade and the weather panels show placeholders until the next poll.

Fixed

  • Fixed a nacked send retrying immediately when another send was queued during its backoff.
  • Fixed a store enabled after startup while disabled not receiving the reply to its poll.
  • Fixed ICON_AUTOTRIM and ICON_TRIM_LOG being impossible to override. They are #ifndef-guarded now, so a face can set either one before including icon_cache.h as the header always claimed.

Wearer-Visible Changes

Changed

  • The config page now prompts for a timezone city again when the saved place has no timezone.

Fixed

  • Fixed settings replies larger than the outbox being sent partially. They are now skipped unless they fit in full.
  • Fixed deleted calendar events remaining on the agenda. An empty calendar or removed feed now clears them.
  • Fixed a cleared watchlist remaining on the watch and returning on the next launch.
  • Fixed moved occurrences of repeating events disappearing from the agenda.
  • Fixed repeated weather requests from the watch each starting a provider fetch, and a failed send leaving weather stuck until the phone app restarted.
  • Fixed an error watchlist being sent back to the watch while the provider's quota gate remained closed.
  • Fixed drizzle displaying the N/A icon with OpenWeatherMap.
  • Fixed accented letters in calendar titles and stock statuses reaching the watch as different letters. The accent is now removed and the plain letter is retained.
  • Fixed an overlong date format leaving the readout buffer unterminated for subsequent passes.
  • Fixed every settings save requesting fresh weather. A field now counts as changed only when its value actually changes.
  • Fixed timezone fields retaining the offset from when their city was selected. For example, a London selected in January previously ran an hour behind during summer. Places selected before this release have no saved timezone, so they retain the old offset until the city is selected again.
  • Fixed the weather condition readout cutting a condition at the first underscore rather than at a trailing _NIGHT. readout_weather_cond now reads the generated label table, which applies the same rule as the phone.

1.1.0 - 2026-09-12

Framework Changes

Added

  • Added support for a repo that holds a single face at its root, laid out like a plain Pebble project with config/, src/ and resources/. The build and the face release find that face by the name in its config/pebble.appinfo.json, and its changelog sits at the repo root.

1.0.0 - 2026-09-12

Framework Changes

Added

  • First release of the engine as its own repo, split out of the shared lib/ in pebble-watchfaces.