Anchor

Changelog

All notable changes to Anchor are recorded here, following Keep a Changelog and Semantic Versioning.

The build diary at anchor.ryanio.com has the story behind these; this file has the facts.

[Unreleased]

Everything below is the first release, still being assembled. Nothing is tagged yet — this heading becomes ## [0.1.0] - YYYY-MM-DD at the moment the tag is pushed, and not before.

Added

The ESP32 manages its own wallets. Settings has a Wallets screen that adds one by OpenSea username, ENS name (when it is attached to an OpenSea profile) or 0x address typed on the keypad, looked up on the unit, and removes one with a tap. The list is kept on the unit and replaces the built-in wallet as soon as it is edited.

The ESP32 companion has four faces to choose from, two serious and two fun: Halo (the new default, a glowing ring), Visor (a small robot), Pixel (eight-bit) and Buddy (the round face, redrawn). Each draws every mood, painted with real curves on a canvas in PSRAM. A gear on the home screen opens a new Settings screen, whose face picker previews every mood before one is chosen; setup offers the picker once, after the first network joins.

A companion character is the ESP32's home screen: a glowing face that blinks, breathes, looks happy (blushing, with the odd hop) or worried (with a sweat drop) with the portfolio's day, sleepy when offline, curious while fetching, lost with no Wi-Fi, and says the portfolio total or the top trending token when tapped, using only readings the feed already formatted. It builds its objects only while it is on screen, so the crowded Wi-Fi scan keeps the LVGL pool headroom it had. The power hold moved with it: the companion has its own battery chip, and the confirmation draws over any screen. PULSE_COMPANION_HOME=0 restores the ambient readout.

An ESP32 audio check sketch, devices/firmware/esp32/audio/, is the first step toward talking to the companion. It plays a 1 kHz tone while recording and prints how far the tone rose above a quiet second, which proves the speaker and microphone from serial alone, then plays back three seconds of speech. The pins come from Waveshare's source at the pinned vendor commit. It passed on the first unit: the tone rose over 90 dB above silence in every cycle, and voice playback sounded good.

Cardputer OpenSea reads now run on a bounded background worker. App exit, token changes, and network changes invalidate unfinished work without destroying another task's HTTP objects. ESP32 feed reads use the same generation gate and one worker slot, and clear old portfolio totals when configured wallets change. Host regressions exercise the production request and publication rules.

Both handhelds remember up to four successful Wi-Fi networks and preserve the previous configuration when a replacement join fails. Association and API availability remain separate states. Flint's blocking boot HTTPS probe is now opt-in.

The ESP32 ambient display now opens Explore: trending tokens, token readings and full identity, portfolio coverage, and Wi-Fi settings. Selection survives list refresh by chain and address. Cached readings show their age and stale state. Scripted simulator taps check navigation and visible text. The LVGL simulator initializes the actual firmware once, removing duplicate Wi-Fi/power setup. Its allocation checks caught an insufficient display heap during Wi-Fi scans. The pool now supports Explore, all 32 scan results, and keyboard entry together; simulator interaction runs are time-bounded.

Firmware CI checks changed paths before installing board toolchains. Each board has a parallel job and its own dependency cache. Target-specific edits compile that board; shared inputs select both. Documentation and known independent workspace changes skip compilation. Unknown inputs or an unavailable Git comparison run the full check. Both firmware variants still check active API readers. Cardputer bootstrap installs the full pinned library set without independently resolving their broad dependency ranges. A cold-cache CI run exposed newer M5 libraries being downloaded before the version check rejected them. Platform compiler dependencies remain enabled. Simulators now run before the physical builds so native compiler and interaction failures report earlier.

Device development now has a shared command for setup checks, pinned dependencies, physical firmware builds, and simulator smoke tests. devices/toolchain.json records the toolchain; docs/device-development.md explains the workflow. CI compiles the Cardputer ADV and Waveshare pulse/ firmware, including a separate compile check of their active network readers with nonsecret placeholders. The hardware inventory records the three Cardputer ADVs and three Waveshare AMOLED units intended for onsite use, with reported battery presence and unverified per-unit details kept separate. docs/device-roadmap.md records the planned framework and app work.

The ESP32 LVGL simulator builds on macOS without colliding with the system PASS_MAX macro.

The Cardputer simulator shuts down through SDL instead of exiting from its app worker. This fixes a Linux crash after timed screenshot runs. The device command also identifies termination signals when a compiler or simulator crashes.

Cardputer token detail caching, display guards, and selection restoration now compare both chain and address. The same contract address on two chains can no longer reuse the other chain's details. A host C++ regression checks the shared firmware comparison, including EVM checksum casing and case-sensitive Solana addresses.

The ESP32 panel shows a portfolio, from addresses it is given rather than a credential it holds. devices/firmware/esp32/app/feed.{h,cpp} now reads /api/v2/account/{address}/portfolio for every configured address with the same read-only key it already carried for trending tokens, and the ambient rotation leads with the result: total, 24h move, NFT value, and how many wallets are in the number. Addresses are a list — ANCHOR_WALLETS in app/secrets.h as the default a unit ships with, overridden by the anchor-wallets NVS namespace so a unit can be re-pointed without reflashing — because a wallet-scoped read means every wallet. Money is summed as fixed-point integers and never through a float; a figure that did not arrive renders "--" and never "$0.00".

A portfolio that covers some of its wallets says so. Four of six addresses answering produces a total over four, a "Wallets 4 of 6" row and a footer reading "partial" — never a quietly smaller number drawn the way a complete one is. A list longer than the twelve addresses a unit will read is reported at its real length for the same reason. A portfolio with nothing to show gets the status archetype and a sentence: no key, no network, no addresses configured, still reading, or nothing came back. sim/lvgl.sh --feed portfolio-partial and its six siblings photograph each of them.

The ESP32 panel can be switched off, and says how much battery is left. devices/firmware/esp32/pulse/pulse_power.{h,cpp} is the first firmware in this tree to speak to the AXP2101 power-management IC that probe/ found at 0x34. It reads battery voltage, state of charge, whether a cell is fitted, whether USB is present and whether it is charging, and it commands a deliberate shutdown. Every register is cited in the source to one of two vendor drivers — M5Unified's AXP2101_Class.cpp and lewisxhe/XPowersLib — which agree on all of them; docs/devices-esp32.md has the table. It writes exactly two bits ever, both read-modify-written: the battery-voltage ADC enable (only when it is found clear) and the soft power-off. Charge current, termination voltage, the rail enables and the power-key configuration are read-only by design.

The charge appears as a battery chip on the metadata line of every ambient screen, and a 1.4-second hold on that chip offers to power the unit off — a confirmation screen with a Cancel and a Power off button that cancels itself after ten seconds. A brush cannot produce it: it takes a hold on a 132×36 target in one corner and then a second deliberate press somewhere else. Not verified on hardware; the boards are unplugged.

The ESP32 panel's reading screen has a hierarchy. Its four rows were four identical 22 px labels against 40 px values, so a token's price and the literal word "trending" carried the same weight. They are now four slots in rank order — a 48 px lead under a letter-spaced eyebrow, a 32 px second voice, and two 22 px supporting rows — and which row takes which slot is the composer's decision (pulse_ui::Reading::lead and ::second), because a portfolio and a trending token do not lead with the same fact.

The ESP32 panel has a design system, and every state that has no data now gets a screen built for saying so. devices/firmware/esp32/pulse/pulse_design.{h,cpp} holds one palette addressed by role (ink, ink_dim, accent, good, bad, warn, …) with a Tone enum in front of it, a six-step spacing scale whose lg is also the panel's 20 px corner clearance, a nine-step type scale that bottoms out at 16 px because this panel is read from across a room, and four screen archetypes — reading, status, chooser, input — that every screen in the firmware is now built from. The palette previously existed twice, verbatim, in pulse_ui.cpp and pulse_wifi.cpp.

The status archetype replaces the thing that prompted this: a unit with no network typed into it was drawn as a reading with one row filled, so "not set up" landed in the right-aligned 40 px value column at x=123 with three empty label/value pairs under it and about 250 px of dead panel below that. All six no-data states — no network saved, joining, fetching, failed, stale, and built without an API key — are now a centred composition with an eyebrow, the state in the largest face that fits it, a tone bar, what to do about it, and whatever the feed itself said; each keeps the distinct copy it had. The Wi-Fi join result is the same archetype, so a unit saying "Joining" during setup and one saying it at rest are one screen rather than two that happened to agree about a font.

Four things the renders caught on the way: the picker's three-button row was 15 px wider than the safe area (109 px buttons sized when INSET was 12, never updated when it became 20, leaving the last button 5 px from the panel edge); the keyboard ran 12 px past the safe rectangle into both bottom corners; "ABC" on the mode key had never fitted at 24 px; and a price of $26,444,366 came out as $26,444… — a reading’s value now steps down a face rather than losing its tail. Costs 2,116 bytes of flash (1,713,839 → 1,715,955, 54% either way) and 216 bytes of static RAM. The corner clearance now has its own heading in docs/devices-esp32.md, where the next driver will look.

The Cardputer browses what is trending, not just what Ryan owns, with three facets per item. Two new pages — browse-tokens and browse-nfts — carry layout: "screen", a new optional PageConfig field that tells Panel.build to fill a device's screen slot and blank its keys rather than filling both. capabilitiesFor in adapters/cardputer.ts declares that slot over the same 240×105 body the nine tiles cover, derived from the tile size so the two cannot disagree. Arrow keys move the list selection, Enter opens the selected token or collection, Tab steps Overview → Holders → Activity and wraps, and Esc backs out — all of it on the press/swipe inputs and the list/detail surfaces that already existed, so types.ts is untouched and no keystroke can reach a verb it could not reach before. The rows come from state/discovery.ts through PanelState, the one path the ESP32's ambient tokens/nfts pages already use, and a facet refuses to draw holders or activity whose id is not the open item's: a holder list under the wrong token's name is the failure this project rates above every other.

The Cardputer draws list and detail itself, in flint's own primitives. The firmware (devices/firmware/cardputer/app/src/anchor.cpp) accepted tile and bar and left everything else blank; it now accepts the slot id screen:0 as well as strip:0 and key:0..8, and paints rows with a label, an optional reading and an optional icon, the selected row marked in the accent, and the empty string when there are none. More rows than the 240×105 body holds are windowed around selected, the way views/setup.cpp windows a network list, so the row the arrow keys are moving is always on the glass; the strip under the rows counts against the total the host sent rather than the twelve the device keeps, because "4 of 12" on a list of 23 is exactly the plausible-but-wrong number this project rates worst. A detail gets its title, its labelled lines, a badge drawn as the same warning pill the Stream Deck draws, and a footer wrapped over as many rows as the sentence needs rather than truncated — that is the line that says approving happens somewhere else. Every string is trimmed to its box and every surface is clipped to the rect the host gave it, and the body is painted as nine tiles or as one screen and never both: whichever half a frame paints, the other is dropped rather than left underneath. Still no pixel transport on that cable.

WiFi is typed on the unit, not baked in before it leaves the desk, on both portable devices. The Cardputer already had this built into flint (views/setup.cpp: scan, pick, type a passphrase on the keyboard, save to NVS) but the Anchor profile never registered the view; it now ships alongside Anchor, Maze and Calm. The ESP32 pulse display had no keyboard to do the same with, so devices/firmware/esp32/app/wifi_setup.{h,cpp} adds one: a touch-drawn network list and an on-screen keyboard, entered automatically when nothing is saved and no host is on the cable, or by tapping the top-left corner five times within three seconds. This is a deliberate, reasoned exception to docs/devices-esp32.md's "the host renders, the device blits" rule — see that doc for the trade-off and why it stops at WiFi rather than becoming a general renderer.

A tap gets a flash, not just a moved selection ring. PRESS_FLASH_MS (400ms) in panel.ts marks the tapped grid cell as pressed for a short window after a touch lands, and svg.ts's pressedFlash washes the whole cell in the accent colour for exactly that long — the one feedback a device with no key travel has for "you hit this," which the steady selection ring alone read as too subtle to notice.

A page of keys is a grid of tappable tiles on a screen device, not a list of thin rows. The grid surface carries the page's keys as cells — a label, a reading, an icon, a tone and whether the thing it controls is currently on — and svg.ts divides the panel into whole columns of at least TOUCH_TARGET_PX. That is 120 px, worked out from the one panel that has been driven: 368×448 across a 1.8 in diagonal is about 322 ppi, so 9-10 mm of finger is 114-127 px. The 368×448 board comes out three columns of 122×149; the 1.28 in round 240×240 comes out two by two with the rest of the page a whole page-turn away. Each cell is drawn by renderTile, so a pulse panel and a Stream Deck key are one face at two sizes. The rotating ambient pages — portfolio, gallery, tokens, nfts — are unchanged and still pulseDetail's.

A tap on a pulse panel opens the cell under the finger. The panel used to refuse, because a tap carries pixels and pixel geometry lives in the renderer; svg.gridCellAt is the same function renderGrid places the cells with, so the hit test cannot disagree with the picture. The gutter between two tiles is dead space, a tap landing between a page change and its repaint resolves against nothing, and a cell can only reach the action its page config declares — never one built from marketplace data. Per invariant 1, actions.ts still has no verb that signs, spends or approves anything.

Every device, in every state, on the review page. node scripts/review.ts devices renders thirty-eight frames — Stream Deck +, XL and Mini, two ESP32 panels and a Cardputer, across the states the panel is actually in: service down, service refusing, no wallet configured, still loading, a reading absent while everything else arrived, stale data, an empty gallery, labels that must truncate, a light theme, white, vantablack, and a device blanked by the lock. It needs no hardware, no compositor and no service, takes under a second, and -w re-renders on every save. There is one review tool rather than two: the frames land on the same review/index.html the widget and the bar use, with the same pins, notes, chapters and walkthrough. devices/src/review.test.ts fails when a failure state/anchor.ts can produce has no card, so the page cannot quietly shrink.

Devices — Anchor on physical hardware, starting with the Elgato Stream Deck. Opt-in: the workspace keeps its own dependency, so a machine with no device attached installs nothing.

  • Anchor frames on a real display. The ESP32 board is a Waveshare ESP32-S3-Touch-AMOLED-1.8 (V2): a 368x448 CO5300 AMOLED, driven over the USB cable. The host renders and the device blits, so a surface in the live Omarchy theme, in the user's fontconfig monospace, appears on a panel on the desk with nothing drawn on the device. Measured with the panel presenting: a full frame is 23,219 bytes on the wire and ~213ms end to end, a changed reading is 70ms, and an unchanged frame costs zero bytes. The decoder now reports which rows a frame touched, so the device pushes only the band that changed — that alone took a changed reading from 105ms to 70ms.
  • A tap skips the pulse display to the next item, and holds it there. On a screen device, a tap on a rotating ambient page (portfolio, gallery, tokens, nfts) advances one item immediately and keeps it up for ten seconds before the wall clock takes the page back — long enough to read a name and a price off a tap that landed a moment before the page would have flipped anyway. Several panels at one desk still agree with each other: the tap is an offset and a deadline laid over the shared clock index rather than a private counter, so an untouched unit is unchanged to the millisecond and a tapped one rejoins the others as soon as the clock catches up. The host half only; firmware that reports the CST820's touches is not written yet. A tap on the Stream Deck's strip, and on a page that is a list rather than a rotation, still does nothing — deliberately, with the reason in the code.
  • The firmware previously reported that no display was attached, and docs/devices.md shipped that claim. It had scanned I2C on the Arduino default pins; the real bus is SDA=15/SCL=14. Zero devices on the wrong pins is not a negative result. devices/firmware/esp32/probe/ now finds a board's bus physically — by driving each safe GPIO's internal pull-down and reporting the pins an external pull-up still holds high — and identifies every device by its identity register rather than its address. That fingerprint named the board and its hardware revision: a CST820 touch controller is what distinguishes V2 from V1's FT3168, and the revision determines the display driver.
  • ESP32 pulse firmware, running on hardware. The device half of the wire format is portable C99 with no allocation and no platform calls; it is compiled and driven on every npm test against frames from the real host adapter, so the encoder is proved against a second implementation rather than its own decoder. An Arduino application speaks it over USB CDC to a bare ESP32-S3 N16R8, and a serial transport (esp32-serial.ts) drives it — which keeps invariant 6 by removing the socket rather than inverting it. Measured on the board: a 466×466 framebuffer allocates in PSRAM, a full frame is 23 KB on the wire and ~190 ms end to end, and an unchanged frame costs zero bytes. The adapter gained setBlanked (the contract's lock hook) and ping/onPong, which is the only acknowledgement the protocol has and therefore the instrument that timing is measured with. There is no display attached and no pulse panel exists yet; devices/firmware/esp32/README.md says what was measured, what was not, and the five bugs the hardware found.
  • One mark, at every size: a ring, two arms, a crossbar, and a fluke curling up at the end of each arm — an anchor that reads as an A, on square bounds of 38×38, in three files that are the same drawing at three weights. A bar had broken the old six-stroke mark two ways: 10×12 in a row of glyphs drawing 9–11 square, and a stroke computing to 0.93 device pixels, under one, so it antialiased to grey. Neither is a size problem — scaling preserves aspect ratio, and a smaller slot makes the stroke smaller with it. Only fewer elements buy the weight, so the element count became the constraint and the drawing was made to fit inside it: each fluke folds into the end of its own arm as a curve rather than a separate stroke. Four elements, stroke 6, 1.5 device pixels at bar size — and the flukes cost nothing.
  • A device contract (AnchorDevice) that panels are written against rather than a Stream Deck program: devices declare named slots, Anchor paints medium-neutral surfaces into them and receives input back. A VirtualDevice is the second implementation, so panels render and are reviewable with no hardware attached.
  • Colour resolves through the live Omarchy theme, never a literal, and the font family stays monospace so a device follows omarchy font set. Marks are held to WCAG AA over every installed theme; a colour that misses the floor is corrected rather than drawn illegibly.
  • A key that is on is lit. An active key used to be tinted 30% toward its tone with the tone itself drawn on top, which works on a dark theme and inverts on a light one — the accent measured 2.25:1 against the tile it was painted on for rose-pine, 2.63:1 for catppuccin-latte, so the "on" key was less legible than the "off" ones beside it. A fixed blend fraction was the cause: 30% barely moves a near-black ground and halves the contrast of a near-white one, so one number was two different designs. The tile now takes its tone as a fill and the marks take whichever palette colour stands furthest from it, which is unmistakable at arm's length on every theme and has a guaranteed answer for contrast. The underline went with it; a filled key does not need one.
  • The three surfaces are real steps, derived when a theme has no opinion about depth. Five stock themes set lighter_background to their own background, so a pressed key flashed at 1.02:1 — through the deck's diffuser, no feedback at all — and nine put the gap between keys within 1.02:1 of the key, leaving the grid as one unbroken slab. deriveSurfaces takes the theme's own value whenever it is a genuine step off this ground and derives one from the ground when it is not, reversing direction where the ground has no headroom. It is the rule widget/PulseModel.js already used for the panel, so the two surfaces are one system rather than two guesses.
  • A reading is text, so tones are held to 4.5:1, not 3:1. Every toned colour is drawn as a tile's value — the portfolio total, the day's P&L — and autoSize shrinks a long one to 13px to keep its last digits, which is not large text by any reading. On solitude the P&L came out at 3.16:1. The floor now slides a colour's lightness rather than blending it toward the foreground, because blending is a hue change: rescuing rose-pine's teal that way produced a slate purple that was legible and no longer Rose Pine.
  • Three themes, authored for a key as well as a screen — themes/harbor, themes/lantern and themes/driftwood, installed to ~/.config/omarchy/themes/. Two rules, both learned from the stock set: the ground is not black, because a key face at #000000 reads as a dead key on a lit deck and leaves the gap between keys nowhere to go; and selection sits well clear of the ground, because it is what draws a key's outline. They are the only themes on this machine whose surface ladder, tile edge and four tones all pass as authored, with nothing corrected at paint time. Driftwood is the light one, built around the fact that a mark on a light key has to go down: every coloured role is a deep, saturated version of its hue rather than a pastel.
  • The contrast gate reads each colors.toml from disk and covers the repo's own themes and the user's, not only /usr/share. It went through loadTokens, which falls back to Tokyo Night for a name it cannot resolve — so a theme with no readable palette was measured as Tokyo Night and passed. It also only ever compared marks to ground, a surface an active key's marks never touch.
  • Key faces are authored as SVG and rasterised straight to raw RGB by ImageMagick, so rendering adds no dependency. Glyph ink is measured through the same rasteriser and cached per font, because Nerd Font symbols advance 0.6em but paint up to 1.04em wide.
  • A portfolio page: total, NFT and token value, P&L, and the largest holdings, read from the local data service. Field names come from @opensea/api-types rather than memory, and only holdings OpenSea classifies OK are shown — an unfiltered list on an airdropped-at wallet is a list of scams. An absent reading renders as an em dash, never a zero. The strip carries the figures' age and staleness, and a dial scrubs the window through the four timeframes the endpoint accepts.
  • The device contract gains list and detail surfaces, committed-text input, screen slots, and optional blanking. Both were asked for independently by the ESP32 and Cardputer designs, which is what made them contract changes rather than adapter concerns: the panel is the only thing that turns config plus state into surfaces, so an adapter-local list would have to fetch its own data. The same page now composes as a key grid or as rows, and a test asserts both carry identical readings. Text narrows what is on screen and is never dispatched. A display blanks on logind's LockedHint, clearing the keys as well as the backlight.
  • No device action can sign, spend or approve. The vocabulary has no such verb and a test asserts it.
  • The Cardputer has a device end. Anchor now paints an M5Stack Cardputer over USB CDC. The app is devices/firmware/cardputer/app/ — a view drawing the surfaces the adapter sends, in the palette of the live Omarchy theme — built against flint (ryanio/cardputer), Ryan's Cardputer ADV firmware, which is vendored as a pinned git submodule rather than forked or reimplemented. Nothing on the unit fetches anything, holds a credential or can ask for an action; the desktop composes every frame, because the data service binds loopback and no device should hold a key to it. Typing on the keyboard opens a filter that narrows rows the host already has, and no keystroke while it is open reaches the panel at all. Verified in flint's simulator, which renders the real view code at the real 240x135; no Cardputer has run it yet.
  • Tilting the Cardputer pages the panel. Tip the right hand edge down for the next page and the left edge down for the previous one — the same page change Tab and shift-Tab make, sent as the same tab keystroke, so the host keeps one mapping rather than gaining a gesture message. It reads flint's motion:: rather than the IMU, takes a 25 degree lean to fire and a return to within 10 degrees to re-arm, so a held tilt pages once and desk noise pages not at all, and it is inert while the filter box is open. Compiled for both the unit and the simulator; the angles are reasoned from flint's filters, not yet felt in a hand.
  • An Anchor unit is one app, as a build fact rather than a setting. The app used to live in flint's tree, where a build profile arranged for the menu to show one card out of eleven. It now lives here and flint is the platform: devices/firmware/cardputer/platformio.ini leaves flint/src/views/ out of the source filter entirely, so flint's ten other apps are not compiled, not linked and not reachable by any keystroke. Measured on the ADV target: 18.0% RAM and 33.5% of the app slot, against the full flint firmware's 20.5% and 37.7%. The reason for the move is in docs/devices-cardputer.md: someone installing Anchor should not find ten unrelated apps on the unit, and someone reading flint should not find Anchor's wire protocol in it. git submodule update --init is the one command a contributor has to run, and a build without it stops in a fifth of a second saying so.
  • anchor-devices --cardputer drives one, and waits for the device's hello before painting when the port was guessed: every ESP32-S3 with native USB enumerates through the same Espressif descriptor, so a matching port name identifies a chip family and not a device.
  • Two pages ship: Omarchy desktop control (workspaces, theme, night light, screenshot, volume and workspace dials) and an Anchor page showing service reachability and whether a wallet is configured.

The data service — the foundation everything else reads from, so there is one cache, one outbound rate limit, and one place where freshness is tracked.

  • Read-only by construction: only GET and HEAD are routed inbound, and the OpenSea client's post() and request() throw, so every SDK write path fails at the transport rather than by convention.
  • Cache-first, backed by node:sqlite, with an explicit freshness envelope on every response and a stale-on-failure fallback. Staleness is millisecond-based and treats a backwards clock as stale.
  • Built on @opensea/sdk and @opensea/api-types. Every call goes through the SDK and every response is typed from the generated OpenAPI types.
  • Loopback-only, and it requires a loopback Host header — binding to 127.0.0.1 stops the network reaching it but not a browser whose DNS rebinds to it.
  • When no wallet is configured and the stored PAT is opaque, it is exchanged at /api/v2/auth/tokens/exchange and the resulting JWT's wallet and linked_wallets claims supply the wallet list, filtered to the configured chains. One network call, only on the path where nothing else supplied a wallet, and every failure leaves the service as it was.
  • When no wallet is configured, the address is derived from the wallet PAT's wallet claim via @opensea/sdk's extractWalletAddress — a local decode, so it needs no scope and cannot 401. A configured wallet always wins; a token that is opaque, carries no wallet claim, or names an address for a chain that is not configured contributes nothing and says which. /health reports walletSource, because a wallet nobody typed in should name its source rather than just appear. The SDK's warning that sub is an account identifier and never a wallet is pinned by a test.
  • Credentials live in the OS keyring, never in config, argv or logs. Errors are rebuilt from a status code, so a remote response body can never carry a credential back out.

@opensea/sdk 12.4.1 and @opensea/api-types 0.9.3. PortfolioArgs.chains, GetTokensArgs.chains and an array-valued GetEventsArgs.eventType all landed upstream, so three casts that existed only because the arg types were narrower than the API are gone and the calls say what they mean. chains on the portfolio call is also the parameter that makes the endpoint's deterministic 500 go away.

@opensea/wallet-adapters 1.1.0 signs Solana. PrivySvmAdapter replaces the hand-written Privy transport in the executor — the first runtime dependency that workspace has taken, and it earned it: the code it replaces was four upstream findings' worth of somebody else's evolving API. The adapter carries the idempotency guarantee Anchor's retry safety rests on, and PrivySolanaSigner refuses to be constructed on a provider that cannot honour a key, because discovering that while holding an approved action is discovering it too late. The policy half stays ours: the thing that signs and the thing that decides what may be signed are not the same dependency.

@opensea/sdk 12.5.0, @opensea/api-types 0.10.0, @opensea/wallet-adapters 1.2.0. Two things Anchor had asked for shipped, and both replaced code here. extractLinkedWallets is now the source of the wallet set, so the service no longer reads the linked_wallets claim itself — order and the chain filter stay ours, because wallets[0] is the primary and the SDK leaves address validation to the server. fetchImpl on PrivyConfig means the executor's tests inject a stub instead of replacing globalThis.fetch, which is one fewer global for a test to leak.

@opensea/sdk 12.7.0, @opensea/api-types 0.11.1, @opensea/wallet-adapters 1.2.1. A reported camelCase/snake_case mismatch turned out to be a false alarm — camelizeResponse (default true) rewrites every SDK response before this codebase sees it, so devices/src/state/anchor.ts never had two spellings to reconcile. field() and its per-call spelling list are gone; every read now names the one field that arrives. CHAIN_IDENTIFIERS and isChainIdentifier are now generated and exported at runtime by @opensea/api-types itself, so ChainsAgree, the compile-time proof this repo carried instead, is gone from service/src/chains.ts too. GetTokensArgs.sortBy and .sortDirection are surfaced on /tokens/trending and /tokens/top. tryDecodeJwtPayload replaces three try/catch blocks around decodeJwtPayload in service/src/wallet-token.ts with a direct null check — the SDK's own fix for a decodeJwtPayload bug where a non-object JWT payload parsed without error and every claim read undefined. See docs/upstream.md entries 0, 2, 4, 5 and 14 for what shipped and what did not.

Both marketplaces. OpenSea is NFTs and fungible tokens, and Anchor treats them as different products rather than one with a quantity field. The token half covers portfolio value, balances, trending and top tokens, individual tokens and price history. docs/tokens.md sets out the five places they genuinely diverge — including slippage, which is a spend control: a $500 per-transaction cap means nothing if a swap executes at 90% price impact.

Chains are configuration, and Solana is native. chains is a validated list, so a Solana-only setup is as ordinary as an EVM one. The list is derived from the SDK at runtime rather than copied, with a compile-time check that fails if the SDK enum and the generated types ever disagree. Address validation is chain-aware — EVM is 0x plus 40 hex, Solana is base58 decoding to exactly 32 bytes — and runs at config load, so a mismatch is a startup error naming the field rather than a 400 hours later. docs/chains.md covers where chains actually differ.

One credential, and a guard on it. Every read Anchor makes needs the OpenSea API key and nothing else. An earlier version of this file claimed account-scoped reads also required a wallet JWT; that was wrong, and the story is in the build diary. A wallet token is still supported — anchor-service --set-pat stores a personal access token, exchanged for a JWT and refreshed before expiry — because the spec declares WalletAuth on fifty paths Anchor does not call yet, and writing will need it. It gates nothing.

Credentials are validated on the way in. A credential is one opaque token, and a value carrying whitespace or control characters is refused rather than stored, because a reader that accepts "whatever line arrived" will cheerfully store a shell command — which is exactly what happened.

Policy-bound execution (preview — see Known limitations). An Executor interface splitting request → simulate → decide → submit, so holding one stage does not grant the next. Approvals are unforgeable two ways: an ApprovedAction carries a property keyed by a non-exported unique symbol, and because a cast defeats types, the brand is backed by a module-private WeakSet that Signer.submit checks. The witness has no runtime representation, so a spread, structuredClone or JSON round-trip fails closed. Actions that delegate standing authority are excluded from the delegable action type altogether — they cannot be configured into an allowlist by a typo or a future refactor.

The executor speaks both chains. Address is a union of two nominally distinct branded types, EvmAddress | SolanaAddress, parsed rather than pattern-matched: EVM hex is lowercased because EIP-55 casing is a checksum, and base58 is preserved verbatim because its casing is the value. Every allowlist entry is a (chain, address) pair, so an entry for one chain never matches an address on another — the same twenty hex bytes name a different contract on every EVM chain, and CREATE2 puts chosen code at a chosen address on a chain nobody configured.

setApprovalForAll turned out to have several Solana counterparts rather than none. The membership test is written down — an action that moves no value, grants an authority outliving the transaction, and needs a revocation nobody can guarantee — and it admits the SPL Approve delegate and SetAuthority, which does not bound what a delegate may spend but hands over the account. It also admits the System Program's Assign, the widest member of the class and the one an SPL-only reading misses: a wallet account is system-owned, an account's owner program may debit its lamports with no signature, so one Assign hands over the entire native balance having moved nothing. Closing an account is deliberately outside the class (it moves value, so the withdrawal allowlist covers it), and arbitrary program invocation is not an action kind at all: an ActionRequest carries intent and has no member that can hold instructions or bytes.

A dependency-free guard parses legacy and v0 Solana transactions and refuses a delegation, an unallowlisted program, or an unrecognised instruction — soundly in the presence of address lookup tables, because it reads program ids and instruction data rather than resolved accounts. It is explicit that it cannot check where value goes when operands resolve through a table: a refusal filter, never a simulation.

Being on the program allowlist means permission to be inspected, not permission to run. Four of the six allowlisted programs have their instructions classified, across three discriminant encodings in one file: SPL Token's tag is one byte, the System Program's is a four-byte little-endian u32, and the Compute Budget program's is a one-byte borsh tag. Only Memo and the Associated Token program are allowed unconditionally. The Compute Budget program is included because it is not inert: SetComputeUnitPrice names a price per compute unit, and at the maximum unit limit it commits the account's entire native balance to a validator tip. That is a spend no cap can see, because a priority fee produces no asset delta and appears in no simulation, so it is refused here against a ceiling of 0.01 SOL rather than upstream where the number does not exist. Unusually for this module the check is exact: both operands are inline literals, so no lookup table can move the answer.

A Privy backend is the first authority where enforcement lives outside Anchor's process: the key is in Privy's enclave, and Anchor audits the remote policy at startup and refuses to run when its local limits claim more than that policy grants. Caps charge gross outflow, because netting would permit wash-trade drains, and budget is reserved at approval rather than settlement. The value ladder — $100 to $100k+, each tier earned by a clean incident record — is in docs/autonomy.md.

The Quickshell bar widget — roadmap step 2. Portfolio value, incoming offers, closing deadlines and an activity count, in the Omarchy top bar, read from the local service on loopback.

  • It never blocks the bar. Every fetch is a detached curl; the last reading is restored from a snapshot on disk before any of them start, so the bar has content on its first frame. This runs inside the single process that draws the whole desktop.
  • Every degraded state is a designed state. Service down, no API key, no wallet, offline — each renders as a calm dimmed mark and a panel saying what is missing and offering the button that fixes it, never as an error. A red box on a fresh install is a bad first impression.
  • The panel opens on a few things. Hero, the number and where it came from, what is closing, one row of controls. The breakdown, the floor list, the raw command behind each setup step and the bar-item toggles are behind a details disclosure — deferred, not deleted.
  • The bar starts sparse and is widened by the person who wants more. The mark, the value and the countdown to the next offer that closes. The change, the offer count and the activity count are off by default and switch on from the panel's details view or with omarchy bar set.
  • The number says where it came from: which wallets, and how old, in one line under the total. A total is a claim about specific addresses at a specific moment, and a panel that prints the figure without either is asking to be believed rather than read.
  • A portfolio breakdown, behind the disclosure, as a labelled split bar rather than a pie — part-to-whole is a stacked bar, and a pie of two slices is the canonical way to make a ratio harder to read than the sentence it replaced. Three views: type (the whole portfolio, from nftValueUsd/tokenValueUsd), assets and chains (from /balances). The last two are the token half and say so, with their own total: NFT value is not broken down by chain or by asset by any endpoint, and distributing it across token rows would print a number that is not true.
  • USD always carries two decimal places. $125,430.5 reached the bar. The rule is keyed on the denomination and applies only to it — ETH at eight places must not become 1.50000000 — and to amounts rather than magnitudes, so $125K is not padded and $11.10 is.
  • Collections are called by their name. /collections now returns each collection's display name alongside its stats, and the panel shows "Bored Ape Yacht Club" where it showed boredapeyachtclub. Slugs stay where the exact identifier is the point: links, and the config.
  • Every configured wallet is watched. wallets is a list in the config and wallet: "0x…" is read as a one-element one. Anchor still cannot discover a person's wallets — it holds no wallet credential and no endpoint maps a human to their addresses — so the setup step is widened rather than deleted, and the no-wallets state is unchanged.
  • Depth comes from the active Omarchy theme. The panel draws on three surfaces — ground, raised and sunken — read from the theme's own colors.toml (lighter_background, dark_background, selection), three keys the shell's Color singleton does not expose. Where a theme's value is not a usable step off the popup's actual ground, one is derived from it. Checked against all 22 stock themes, including white and vantablack, which have no headroom in one direction.
  • A setup step does the thing rather than describing it. Step 1 is a Start Anchor button running systemctl --user start anchor-service (with and at login for enable --now); step 2 opens a terminal on the API-key prompt; step 3 opens the config file in Omarchy's editor. The raw commands moved behind the details disclosure.
  • Staleness is shown, not hidden, using the service's own meta.ageSeconds so a reading keeps ageing honestly across a reboot.
  • Setup is three steps, not a wall. Completed steps leave the queue and a segmented bar keeps the record; one step is current and only it offers an action; the optional step collapses behind a pill you can press. Each step says what it gets you rather than what it configures. The "Set up Anchor · step 2 of 3" header was removed: between it, the counter, the segments and the numbered discs, the panel was saying one fact four ways.
  • Every panel state can be looked at. The panel has fifteen — starting, four setup steps, offline cold and warm, stale, a rejected key, a collection that failed, and the working ones — and a live machine is in exactly one of them, so its error screens had never been reviewed by anyone. PanelContent.qml renders from a single reading and takes no action itself, so widget/gallery/ mounts it against a fixture per state and photographs all fifteen with no service, bar or desktop.
  • And the review page walks you through them. Photographing every state solved half the problem; the other half is that twenty-three cards in one column get read attentively for four and scrolled past for the rest. They are chapters now, in the order a person meets them — a fresh install, the state it is in almost always, everything at once, the failures, the bar — each closed but showing a contact strip of its own thumbnails, so the whole review is one screen rather than a scroll nobody finishes. Walk through moves across all of them on the arrow keys, and a bar strip is finally shown at a size anyone can judge: 300×26 at 4×, nearest-neighbour, because smoothing invents detail that is not in the capture. A surface counts as looked at once it is marked right or carries a note, and the tally says which. --page-only rebuilds the page over the shots already on disk, so changing it no longer costs a photograph session.
  • A scoped capture no longer deletes the review it was not asked to retake. review.ts widget emptied review/ before photographing the one group named, so a run that wanted the bar threw away twenty-four panel shots and rebuilt the page around the single surface left — and it looked finished, with one card on it. Each shot is now removed immediately before it is replaced, so a half-failed capture still leaves nothing pretending to be new, and the page is built over everything on disk rather than over the group that happened to run. The cost of keeping older shots is that they are older, which is this project's most expensive failure shape, so every card carries the age of its shot and the page says how many did not come from the newest run. scripts/ gets a test step in CI along with it: a bug with no runner is a bug that comes back.
  • One mark, at every size. A ring, two branches off it, a crossbar — square bounds of 36×36, and three files that are the same drawing at three weights. It briefly was two marks, a whole anchor plus a cropped variant for the bar, and two marks for one product is a cost with no payer: the bar showed one thing and the site showed another. A bar broke the whole anchor two ways — 10×12 in a row of glyphs drawing 9–11 square, and a stroke computing to 0.93 device pixels, under one, so it antialiased to grey. Neither is a size problem: scaling preserves aspect ratio, and a smaller slot makes the stroke smaller with it. Only fewer elements buy the weight, so six became four and the stroke became 1.57 pixels. What it costs is the anchor read at large sizes, which was the trade — a mark that only works when it is large is not a mark.
  • The bar's open-panel underline tracks what the widget paints. Bar.qml looks for an openPanelIndicatorWidth on a module and otherwise falls back to 55% of the slot, a figure calibrated for a text label in a padded slot. Measured on the running bar: a 172px slot drew a 96px underline against 154px of content.
  • The scrollbar has its own lane. An attached ScrollBar.vertical reserves nothing — measured, 10px wide at flick.width - 10 — so it painted over the right edge of the panel's cards the moment a flick made it appear. The content column is inset by that width on both sides, unconditionally, so the bar has somewhere to be and nothing moves when a list gets longer.
  • The bar's hover tooltip is one line. The shell owns a single shared tooltip whose label is hardcoded centre-aligned with one weight and no per-module override, so a four-line paragraph handed to it read as a centred block with nothing leading. The alignment was never the widget's to set; the paragraph was.
  • Something leads in every row. The value is bold, the name is regular, the sub-line is dimmed. A list where the label and the value are the same weight and colour reads as a wall.
  • The step marker is aligned by measurement. The numeral sits on the step title's own baseline and the disc is centred on the numeral's ink, both derived from FontMetrics/TextMetrics at runtime. It was a fixed 1px top margin against a metric-derived label, which put the disc 2.1px below the title's cap-height centre and grew worse as [font] base-size rose.
  • A stored credential is not a working one. A 401 on any read sends the API-key step back to current and says the key is being rejected, rather than ticking it because /health says a string exists.
  • Marketplace names are sanitised — bidi overrides, zero-width padding, combining-mark runs and control characters — and rendered as plain text. Money stays a decimal string, rounded digit by digit; no denomination is ever converted, because there is no exchange rate in the widget.
  • Read-only: no credential of its own, loopback only, and clicking a row opens a browser.
  • The panel passes an identifier, never a command. Model.actionArgv owns a closed table of literal argv arrays and returns null for anything else, so nothing that arrives over the network or out of shell.json has a path to something that executes. The one non-literal value — the config file's path — is built from $HOME/$XDG_CONFIG_HOME and rejected if it is relative, contains .., or carries a control character. No credential passes through the widget: the API key is typed into a terminal that writes it straight to the keyring.

A component layer above the tokens — theme/components.css, with theme/README.md documenting each piece and when to use it. Pills, a step primitive, segmented progress, buttons in two weights, list rows, and generalised surfaces (.card, .card--raised, .well, .lift), so the site, the widget and the standalone pages compose from one vocabulary instead of each inventing its own. Variants choose weight, never hue, which is what makes "one accent per view" enforceable rather than aspirational.

Design principles, written down — a short section at the top of theme/README.md that governs every surface Anchor draws: open on a few things, reassurance is not information, defer rather than delete but be willing to delete, a step does the thing rather than describing it, depth instead of dividers, and never a colour at a call site. It also records where a future NFT-driven theme plugs in — tokens.css for the web, the Omarchy theme's colors.toml for the widget — and there is no third seam, because no colour the widget draws is a literal.

widget/SplitBar.qml — a part-to-whole split as a bar plus one labelled row per part, on a sequential single-hue scale taken from the live theme. Documented in theme/README.md as a house rule: never a pie, never a legend that is the only way to read the chart, and text never wears a segment's colour.

packaging/anchor-service.service — the data service as a systemd user unit, so starting it is a button in the bar rather than a command to copy. Installed to /usr/lib/systemd/user/ by the PKGBUILD. /usr/bin/anchor-service, which the unit's ExecStart names, does not exist yet — it needs to be a wrapper around the packaged service and its node_modules, and until it is, the unit must be written against a checkout (see service/README.md). The widget probes the unit rather than assuming it, so a machine without one shows the command exactly as before.

The site, the brand, and the tooling.

  • anchor.ryanio.com: build diary, changelog and llms.txt, on a deep-water palette with shared tokens in theme/tokens.css reused across the project rather than redefined.
  • One mark that reads as an A and as an anchor's head, stroke-based on currentColor, square, legible at 11px — three files that are the same drawing at three weights.
  • scripts/check-contrast.ts computes WCAG ratios from the tokens and fails CI — all 38 text, accent and component pairs clear AA in both themes, so a colour that looks good but is unreadable cannot land. The widget's colours cannot be reached from there, because they are the user's live theme; widget/test/model.test.mjs runs the same arithmetic over the stock Omarchy palettes and is in CI too.
  • scripts/check-versions.ts enforces the Node floor across .node-version, CI and the PKGBUILD, and requires GitHub Actions to be pinned to commit SHAs rather than mutable tags.
  • Biome as the single formatter and linter — never ESLint or Prettier.
  • scripts/generate-asset.ts for images and video from xAI, key in the keyring, zero dependencies.
  • docs/upstream.md — every workaround that exists only because of an upstream gap, and what to delete when each is fixed.

Fixed

The trending list loads again on both handhelds. From 2026-09-26 OpenSea's /tokens/trending answered without a Content-Length, and both firmwares refused any response that did not state its size, so every trending fetch failed while the portfolio, which still sends one, kept working. A missing length is now accepted, and the size limit is enforced by counting bytes as they are read.

Changed

The ESP32's palette moved towards black for the AMOLED, and its dim text is lighter: it measured 3.0:1 against the old background, under the 4.5:1 that body text needs, and is 5.3:1 now. Prices and the portfolio total use the hero size, and list rows separate the chain and the change with a dot.

The ESP32 responds to touch about three times faster. Each frame waited for the panel's tearing signal before every band it drew, which took about 50 ms of a 60-70 ms frame and left touch unread for up to 217 ms. It now waits once per frame and reads touch every 15 ms: frames take about 20 ms, and the longest gap between touch reads is about 70 ms. The health line is followed by a once-a-minute frame timing line.

Scrolling the ESP32's Wi-Fi and Explore lists carries a flick instead of stopping short, and a swipe no longer lights up the row it started on. The touch driver held the finger still while confirming a lift, which cancelled most of the fling speed, and rows showed their pressed colour before a swipe had moved far enough to count as a scroll.

On the ESP32, the keypad no longer tears when a key is pressed: each frame is assembled in PSRAM and sent to the panel in one write at the start of a blanking interval. A touch that stops a coasting list, or a press while the list moves, no longer picks a network. Buttons near the top and bottom edges answer a little above and below what they draw.

On the Cardputer, a Wi-Fi rescan works while the saved network is out of range (Flint d8ff2a4): the scan pauses the background join instead of failing against it, a scan that cannot run says so, and a network with several access points is listed once. Setup no longer shows the saved network as "wifi <name>" while not on it, and the Anchor strip keeps "Wi-Fi not found: Setup" through the retries instead of dropping back to "joining".

Cardputer hints draw the key they name as a keycap, a small bordered label, using what the key prints: [ok] rather than enter, [del], [esc], [tab], [fn], and capital letters (Flint 5b4dc06). Flint's docs/KEYBOARD.md records every legend on the ADV keyboard.

  • Every shipped device key does something when pressed. The six chain shares on the chains page did nothing; they now open the wallet's OpenSea profile, as the portfolio sparklines do. With no wallet configured, both open ~/.config/anchor/config.json in the editor, where a wallet is added, the same file the widget's setup step opens.
  • A Cardputer filter stays on the page it was typed on. The filter text carried across a page change, so the next page's grid came up narrowed, or empty with nothing matches, while the device showed no filter box. Changing page now clears it.
  • A single wallet that could not be read is labelled. With one wallet configured and its read failed, the panel still named the address "as of now" over an empty total. It now says "0 of 1 wallet", the same label a partial multi-wallet total already gets.
  • A malformed percent-escape in a path is a 400. /tokens/%E0 or /collections/%E0 made decodeURIComponent throw, and the service answered 502 "URI malformed", which reads as an upstream failure. It is the caller's bad request, so it now gets 400 "Malformed request URL."
  • Enter on a screen runs only a cell that is on the glass. A screen press had no page check, so an Enter landing after a page change but before the repaint ran the new page's key at the selected index. On the Cardputer, Tab off a browse page then a quick Enter could run desktop key 0 (theme next). It now does nothing until the new page is painted, as a tap already did.
  • A locked session stays dark on a host-driven display. Only the 1 s tick stopped on lock, so a service poll, a Hyprland event, a key press or a fetch landing still repainted the panel. On the ESP32 that COMMIT turned the backlight back up, and a portfolio page reappeared on a locked desk within about 15 s. Nothing paints while locked now, and unlocking paints one fresh frame.
  • A display that reconnects while the session is locked stays dark. A reset ESP32 or a wedged Stream Deck is reopened, and the fresh device was never told about the lock, so it sat lit until the next unlock. It is sent the blank again now, after its brightness.
  • ESP32 delete deletes once per tap, and the keypad stays off the rounded corners. Delete fired on touch and repeated from 400 ms, so a slightly long tap removed two or three characters; it now acts on release and repeats only after a hold of about 0.8 s. The keypad moved in to the 20 px safe inset because its bottom corner keys were cut off. The simulator now fails any screen that draws past the panel's rounded outline.
  • Sub-cent token prices read as prices. Both handhelds formatted every price under a dollar to four places, so a token at $0.000021 showed as "$0.0000". On 2026-09-23, 10 of the live top 20 trending tokens were under a cent, and Agrippa at $0.0000862 showed as "$0.0001". Prices under a cent now show three significant figures ("$0.000021", "$0.0000862"). Anything below $0.0000000001 says so rather than printing zeros. The ESP32 simulator's fixture prices are now numbers run through the same function, because its hand-typed strings were shapes the formatter could not produce and hid the bug.
  • The Cardputer says when its saved Wi-Fi is not found ("Wi-Fi not found: Setup") instead of "joining the saved network" indefinitely, once a join has failed.
  • The Cardputer prints a health line every minute while browsing, with free, largest and lowest heap, the fetch worker's stack high-water mark, the last HTTP status, the list state and age, and battery.
  • The ESP32 prints a health line every minute for endurance and battery runs: uptime, internal heap (free, largest block, lowest since boot), PSRAM, the LVGL pool, the fetch worker's stack high-water mark, the last HTTP status, both feed states and battery. The simulator gains --expect-serial and a scenario that requires the line.
  • The ESP32 Wi-Fi keyboard is a phone-style keypad with keys a finger can hit. The panel is about 29 mm wide, and lv_keyboard gave it 27 to 32 px keys (about 2.5 mm), which the first physical session found extremely hard to use. Three columns of 104 x 63 px keys now hold letter groups, and a tap on a group shows its letters at 48 px, lower case above upper case. Digits and all 32 ASCII symbols have their own pages, so every printable character is within three taps. A host test checks that by search, and a simulator scenario types a network name and passphrase and checks that the saved profile holds exactly what was typed. Explore rows, the chooser status line and buttons, and Wi-Fi network rows are larger too. Not yet checked on the glass.
  • The Cardputer says how old its trending rows are. It kept the last list on screen through a lost network or a failed refresh, with nothing saying how old it was. The strip now leads with "live, just now" or, when the unit cannot refresh, "stale, 4m ago" in the warning colour before the reason. An open token shows the age of its own reading. Both handhelds take the wording from one shared, host-tested firmware/common/freshness.h.
  • The ESP32 pulse display's systemd service opens on portfolio instead of desktop. desktop is a page of desktop controls a touch-only ambient display can't do much with; panel.json itself is unchanged, so the Stream Deck and Cardputer still default to desktop. --page portfolio on the service unit's ExecStart, per docs/devices-esp32.md's own list of things worth doing next.
  • ~/Arduino/libraries/AnchorPulse pointed at a stale worktree, not this checkout. Every ESP32 compile was silently building anchor_pulse.h/.c — the file app.ino calls "everything with judgement in it" — from a merged-and-abandoned agent worktree rather than the real source tree. The two happened to agree at the time this was found, which is the only reason it never produced a visibly wrong build; it is exactly the class of problem AGENTS.md's "check the instrument, not just the reading" warns about. Repointed at devices/firmware/esp32, and the merged worktrees removed.
  • Every size a device surface draws now follows the slot it is drawn in. The radius, insets, gaps, meter, sparkline and font sizes in devices/src/svg.ts were tuned on a 120×120 Stream Deck key and inherited unchanged by an 80×35 Cardputer tile and an 18px status bar. What that produced, found by rendering it: keys shaped like lozenges, 4px captions, a sparkline running out through a rounded corner, and a status bar nobody could read. Marks now scale, a mark under the legibility floor is dropped rather than drawn as texture, and no two marks are placed in the same band — a reading is never dropped for a caption. The 120px face is unchanged, and a test holds it there.
  • An artwork key keeps its outline. The tile's edge was drawn before the image and painted out by it, so a gallery key was the one key on the deck with no boundary — a dark piece on a dark theme dissolved into the gap. Everything inside a key is now clipped to the key, and the edge is drawn last.
  • A gallery row on a screen device says which piece it is. KEY_SOURCES.nft blanked the piece's name once its artwork arrived, to keep a caption off a 120px key. A device with one screen instead of eight keys draws the same page as a list, where that left rows reading "Key 2". Suppressing the caption is the renderer's decision now, which is also where types.ts rule 1 says it belongs.
  • A truncated label keeps both ends. token:N appends the chain to a ticker precisely when the ticker alone is ambiguous, and cutting from the tail removed the disambiguation and nothing else: two keys holding wstETH on two chains both read WSTETH·arbit….
  • A device preview is laid out the way the device is. composeSvg takes the adapter's own slot geometry for a device that owns one framebuffer, so the Cardputer's status bar is at the top where the firmware puts it rather than at the bottom with imaginary plastic between the tiles. A device with no keys no longer loses a gap off its top margin, and a blanked device renders at the same size as a lit one so the two can be compared.
  • "not loaded" on the strip is now "loading…". It is what a person sees for the first seconds after a cold start, beside four em dashes, where a fact about our variable reads as a failure.
  • A batch of shots is cleared once per run. review.ts removes each shot immediately before retaking it, which is right for a photograph and wrong for a group rendered in one pass: the second member deleted its own freshly-written PNG and waited on a promise that had already resolved. Thirty-two of thirty-three frames came back as "the tool reported success but wrote no file", on a page that still looked finished. scripts/review-shots.test.ts covers it.
  • @opensea/sdk 12.1.1 and @opensea/api-types 0.9.2. Three findings Anchor reported upstream are fixed in this release. Path parameters are encoded by the SDK, so Anchor stopped encoding them — doing both double-encodes, which its own traversal test caught. segment() remains, and still refuses . and .., because encoding never handled those and the URL parser strips escapes before removing dot segments.
  • A retry ladder. Every SDK error now carries a statusCode, which makes it possible to tell a retryable failure from a permanent one for the first time. 502/503/504 and status-less transport errors are retried twice with jittered backoff, outside the shared rate limiter so a sleeping request cannot hold a slot. 500 is excluded deliberately: /account/{address}/portfolio returns a deterministic one, and retrying it only delays the stale-cache fallback.
  • The pulse display lights when a session starts, not at its first frame. set_backlight(0) runs at boot and brightness was only raised on the first COMMIT, so a board that had handshaked and was waiting for pixels sat at brightness zero — which on an AMOLED is indistinguishable from a unit that is off, faulted, or was never flashed. An evening went into that ambiguity while the tearing line reported the panel refreshing the whole time. READY now paints a flat ground fill and raises the backlight, which is not a reading and cannot be mistaken for one — the argument the Cardputer's own "host connected, no frame yet" standby already makes. The firmware also announces anchor-pulse-s3-nopanel when panel->begin() fails, because from the host's end a panel that never came up and a host that never painted are the same dark screen.
  • Writes to the pulse display are serialised. send was async with nothing ordering it, and the adapter keeps a keepalive on a 2s timer, so any paint outlasting one interval had a PING spliced into the middle of its pixels and the decoder faulted on a header read out of a payload. It was misread as the Cardputer's dropped-write bug and "fixed" by carrying that device's 64-byte/8ms pacing across — 8 KB/s against a 368×448 panel, which turned a 213ms frame into tens of seconds and made the collision certain rather than occasional. Now a promise chain with backpressure on drain, in 8KB pieces matching the 64KB receive buffer the firmware sizes to absorb them.
  • The daemon probes serial ports instead of taking the first. Every ESP32-S3 with native USB enumerates through the same Espressif descriptor, so listPorts()[0] was a coin flip whenever a Cardputer and a pulse display were both plugged in; losing it cost a five second handshake timeout, an exit, and a restart into the same coin flip. That loop ran 6,601 times before anybody read the journal. Candidates are now tried until one answers as the device asked for, a port that is not ours is closed rather than held, and the unit files pin their port by its by-id path.
  • A key that does nothing but look like a key now fails the build. Four keys shipped with "icon": "" and rendered as labels floating in empty squares. The existing icon test collected icons with if (key.icon), so an absent one skipped the check entirely — a test that steps over the case it exists for. A key with no source must now carry an icon.
  • The Cardputer wears one brand. The desktop's live theme reached only Anchor's own view, so backing out to the menu landed on flint's coral. flint grew ui::setPalette (upstream, in ryanio/cardputer), Anchor hands the desktop's tokens to it, and a brand palette taken from site/brand/favicon.svg is set at boot so a unit with no host on the cable still looks like Anchor. flint's carousel also stopped wrapping below four views while the selection wrapped anyway, which left a three-view unit selecting the first card without moving the strip.

Known limitations

  • The wallet-token exchange has never been run end to end. Its request and response shapes are read from @opensea/sdk's source rather than from a successful call. We now hold a scoped token, but no route Anchor calls needs one, so nothing exercises the path.
  • /account/{address}/portfolio returns 500 upstream for a large account with no query parameters, and 200 for that same account with any parameter. Reported to OpenSea; see docs/upstream.md.
  • Privy's policy engine cannot express Anchor's cumulative caps. Its spend-limit primitive tops out at a 72-hour rolling window and does not observe eth_sendTransaction at all, so the rolling 24h and 7d caps in docs/autonomy.md are enforced only by the in-process mirror. The per-transaction cap, the contract and withdrawal allowlists, and the delegation refusal are enforced by Privy and survive a compromised desktop. The rolling totals do not.
  • Privy's Solana policy engine is materially weaker than its EVM one, and this is the finding to read twice. It has no condition that can name Approve, ApproveChecked or SetAuthority — its token decoder covers six other instructions — so there is no Solana equivalent of the ethereum_calldata + function_name condition that refuses setApprovalForAll by name. The only remote control is an ALLOW rule that positively lists the instructions Anchor sends, letting default-deny refuse the rest; omit that one condition and the control disappears with no error anywhere, so Anchor's startup audit refuses to run against a policy missing it. Solana also has no aggregation primitive at all, so both cumulative caps are local-only there, and Privy reject outright any transaction whose conditions need an address loaded from an address lookup table. Starkest of all: there is no Compute Budget condition source, so a Privy policy can permit the program and then say nothing whatever about the priority fee it names. The other gaps are controls weaker than their EVM counterparts; this one has no remote expression at all, and the local ceiling is the only thing standing there. The startup audit states it every time rather than refusing, because there is no policy change that would fix it.
  • Anchor cannot build a Solana transaction. Compiling an SPL transfer needs associated token account derivation — ed25519 on-curve arithmetic — and a live blockhash. The builder refuses and explains itself; the policy, guard and signer around it are complete and tested. Closing this needs either a compiled transaction from OpenSea's /swap/execute or a runtime dependency, which is a human's decision.
  • ActionRequest does not model a signed Seaport order, so marketplace actions are approved by policy and then refused by the transaction builder. ERC-721 withdrawals work end to end. Seaport is EVM-only upstream, so there is no Solana version of this gap.
  • No package yet. packaging/PKGBUILD is a skeleton targeting the [omarchy] repository; the install path is not yet omarchy pkg install anchor.

[Unreleased]: https://github.com/ryanio/anchor/compare/main...HEAD