HIP-1040: Appearance. Status Final. Hanzo architectural specification.
/v1/account/appearance is one person's reading of the design system — text size, spacing density, one accent hue — held on their IAM account rather than in a browser. It is a facet of the account capability (HIP-1200), served by apps/account in hanzoai/cloud (apps/account/appearance.go); the router still serves it at the bare root today, a pair hanzoai/cloud openapi/misfiled.txt carries.
The whole claim is where the value lives. A preference kept in local storage is a fact about a device; kept on the identity it is a fact about the person, so it is the same in the console, in chat, on the desktop and on a phone that has never seen any of them.
Every browser surface that could set this is either a static export or a separate frontend, so none of them holds the confidential IAM credential needed to write a user row. Each one growing its own writer would mean several read-merge-write implementations against one row, and a row whose password hash a partial write can blank. There is one writer, at the unified API host, acting as the confidential console client against the already-validated caller's own record.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
The subject is the validated caller and MUST NOT be nameable. There is no parameter for whose appearance is read or written; an unauthenticated request is refused rather than answered with a default (apps/account/appearance.go:83, :106).
The preference is one JSON value in the IAM user row's properties map. Gaining it changes no IAM schema.
A write MUST read the full row, change that one property, and submit the whole row back (apps/account/appearance.go:151). IAM's update overwrites the default column set, so a partial submission blanks every field the body did not restate — including the password hash. A write that cannot first read its row MUST fail rather than write a row it did not read.
Three axes, each OPTIONAL:
| axis | domain | |---|---| | type | text-size multiplier, clamped to [0.85, 1.4] | | density | compact, default or comfortable | | accent | one CSS colour token |
An unset axis is ABSENT and MUST NOT be stored as a neutral value. Absence and "the published default" are the same state, so an empty preference cannot stamp a scale over a brand that set its own.
A value outside its domain is DROPPED and the rest of the preference is stored: one bad axis narrows the result, it never refuses the write (cleanAppearance, apps/account/appearance.go:61).
accent is rendered by surfaces into a <style> body, and its value is chosen by its owner. It MUST therefore match a closed colour grammar before it is stored — a hex literal, or a bounded functional-colour form — and anything carrying ;, {, }, < or url( cannot match (apps/account/appearance.go:56). Validation is at the WRITE, so no reader has to be trusted to sanitize.
A transient identity-service failure on READ answers the empty preference, not a 5xx (apps/account/appearance.go:83). A surface then applies its published default, which is what it would have done for a person who never set one. A WRITE does not fail soft: an unstored preference MUST be reported as unstored.
The obvious alternative is a per-application setting, filed with the rest of an app's preferences. It is wrong by counting: there is one of these per identity and not one per app, and a person who enlarges text does not mean "in this tab".
Reusing the IAM properties map rather than adding a column costs one JSON decode per read and buys a preference that ships without a migration.
The stored accent reaches a stylesheet. Treating it as opaque text and validating it only where it is rendered would put the check in as many places as there are surfaces, and a new surface would default to unsafe; the grammar at the write is the one place that cannot be skipped.
The whole-row re-submit is a footgun that has to be stated: the row carries credential material, so the write path's correctness is the row's integrity. The same read-merge-write serves the avatar (HIP-1042), and both MUST refuse to write a row they could not read.
Released under CC0 1.0 Universal Public Domain Dedication.