# Architecture

pytypehintweb is a rendering adapter, not an application server. The library ends at plan generation, widget construction and transport reading: plan_of() returns a dictionary, compileForm() returns widgets, form.read() returns a value. HTTP routing, static-file delivery, authentication, submission handling and function execution belong to the host application. The bundled pytypehintweb.demo is such an application (FastAPI, mounted static dir, /plans and /build/{id}); it is a showcase, not an API of the library.

# Layers

pytypehint          compiles types, decodes the portable form, validates, builds
plan.py             Python adapter: schema -> expanded plan
decode.py           Python adapter: schema.decode() plus file-reference resolution
static/defaults.js  official runtime defaults and known property names
static/slider.js    shared, dependency-free slider position arithmetic
static/normalize.js structural validation and normalization
static/contract.js  semantic validation of the normalized plan
static/form.js      orchestration: widgets, initial values, transport
static/inputs.js    scalar widgets
static/fields.js    the widget contract and the containers
static/widgets.css  optional presentation
static/icons/*.svg  the icons that stylesheet references

Each layer depends only on the ones above it: the core knows nothing about the web layer, and the widgets know nothing about the plan protocol. slider.js is a dependency-free leaf whose position arithmetic (firstSliderValue, sliderReaches) is shared by contract.js (validation) and inputs.js (IntWidget initialization), so the congruence is implemented once.

forward:  function/dataclass -> pytypehint schema -> plan_of() -> expanded plan
          -> normalize/check -> compileForm() -> widgets
reverse:  widgets -> form.read() -> transport object -> (host carries it)
          -> decode() = schema.decode() + file references
          -> schema.build() or another consumer

decode() is the reverse-pipeline counterpart of plan_of(): the forward path turns a schema into a plan, and the reverse path turns the transport object back into something schema.build() accepts by exact type. It does not read the portable representation itself — schema.decode() does, because that representation is the core's. The int where the shape is Float, the ISO string where it is Date/Time, the member name where it is an enum and the $type/$value wrapper are all restored there, by the shape and never by a value's content, and anything wrong is left for the core to reject. What decode() adds on top is the one reading the core has no opinion about: when a file_resolver is given it walks the already decoded tree and hands every file reference to it. Storage is not a type question, so it cannot be the core's.

value() belongs to the components (the plain value they represent); read() belongs to the orchestration (the transport shape a consumer expects, wrapping union branches). A widget never decides whether its value travels plain, inline or wrapped.

# Source of truth

pytypehint is the source of truth for types, constraints, defaults, unions, the portable representation of a value, construction and final validation. plan.py is the only adapter of that contract: it translates a compiled schema into the flat, serializable plan contract, and when a guarantee the core makes cannot be preserved in the browser it raises TypeError — no rule is ever degraded silently. The browser may anticipate errors to improve the experience, but it does not replace the core's validation and is not a second source of truth.

That extends to the wire. The $type/$value wrapper, and the branch names inside it, are not this library's invention: they are the same portable value format the core writes in to_dict() and reads back in schema.decode(), named by the core's own option_id(). plan.py still computes each branch's plain / inline / wrapped mode, because the core does not publish that rule as public API; what it computes is the same reading the core applies when it decodes. That agreement is pinned end to end rather than branch by branch: the round-trip tests drive a real browser transport object through decode() into schema.build(), so a mode that stopped matching the core's reading would fail there. So the transport is one format with one reader, not a web dialect someone has to translate back.

That is why Python vocabulary stops at plan.py: Shape, Struct, option_id, MISSING and the $type rules never surface in the widget API. form.js consumes a hand-written plan and a plan from another backend identically.

# Validation

structural validation and normalization
    -> semantic validation of the normalized plan
    -> form construction

The structural pass runs on the plan as received (types of present properties, required and unknown properties, valid kind and mode, the hasDefault / default pair). The semantic pass runs on the normalized plan, where every option holds a real value. Checking types before anything else, and never filling absences, keeps every rule free of "if absent, assume this": an omitted property is <path>: is required, not an implied default.

A plan default is producer configuration, not a live user value, so it is checked against the complete normalized node — both structurally compatible and constraint-valid. A default that breaks a constraint fails checkPlan() and never mounts; a value the user later types may stay representable while invalid, and the widget reports that through hasError() / isReady().

A file node is a node like any other, and the compiler treats it that way. There is no list of allowed file positions and no predicate asking whether a shape "contains a file": a shape is representable when each of its nodes is, and _options_node / compileNode() recurse over lists, optionals, choices and structs without knowing what is at the bottom. The single file-specific decision is a shortcut — a bare list[File] becomes one multiple file node instead of a list of single-file widgets — and it is chosen by an exact shape match, so it cannot capture anything wider.

A file default is the one case where the check stops short of the value on purpose. checkPlan() owns the shape a browser can see — a str for a single node, a list[str] for a multiple one, non-empty, extension-filtered, within the file-count bounds — and stops there, because existence, regular-file and byte size are not observable from a page. They are not observable from the core either: FileHint reads the extension off the text and opens nothing, so those questions have no answer anywhere in this stack. They belong to the host, which is the only layer that knows where the bytes went, and it answers them in decode(..., file_resolver=...). The compiler applies the default by calling the widget's public setValue(), so a default and a runtime assignment are one implementation rather than two.

checkPlan() validates everything a hand-written expanded plan needs to be buildable — structure, the network boundary, and the semantics of the normalized document including its defaults — but it does not restate schema-compiler invariants that cannot affect runtime integrity (e.g. ordinary-range multipleOf reachability). New semantic rules are still written once, in Python.

# Who owns which rule

Three layers, and the line between them is the same one everywhere:

pytypehint      the typed contract: schema semantics, the portable
                representation, reading it back into exact Python
                (decode), resolve, build
pytypehintweb   the contract adapted to a browser: the web plan, what
                JavaScript and the page restrict, the widgets, the web
                transport, and the resolution of file references
the host        storage itself: existence, authorization, lifecycle, and
                the authoritative size of bytes it owns

Each layer owns what only it can answer. The core cannot know that a 2⁵⁴ integer stops being an integer in JavaScript; the adapter cannot know whether a reference still names bytes; the host cannot be asked what a Float means. The table below is that split in detail.

Layer Owns
pytypehint core schema validity: positive Rows and Step, non-empty and non-repeating Choices, non-empty ranges, ordinary and slider ranges that admit a valid multiple of MultipleOf, choices consistent with their constraints, unions without repeated option types or homonym discriminators (dataclasses or enums that would share a $type name) — and the portable representation of a value in both directions: what to_dict() writes, and what schema.decode() reads back into exact Python (the int under a Float, the ISO text under a Date/Time, the name under an enum, the $type/$value wrapper)
plan_of() web representability and the exact converted browser contract it emits: every value a JavaScript safe integer, portable patterns, control combinations that ask for different widgets, unique branch option ids (defense in depth — the core compiler rejects that collision on every path it compiles, a field's union and a list's items alike, so this one only ever fires on a shape assembled by hand), nested objects with at least one field, exclusive integer bounds that still leave a value after integer conversion, sliders with both converted limits, sliders with a reachable valid position, converted defaults valid against their node including the slider Step grid
normalizePlan() structural validity: every non-conditional property present (a missing one is <path>: is required), no unknown keys, types of values, the hasDefault / default pair, canonical scalar forms (a date value is a real calendar date, not merely the YYYY-MM-DD shape), and the structural shape invariants — non-empty field names, optional nodes only in list-item position, and optional / enabled field coherence
checkPlan() everything a hand-written expanded plan needs to be buildable — structure, network boundary, and the semantics of the normalized document: coherent ranges, choices against their constraints, reachable slider positions, unique and non-empty field names within each scope, inline transport only on object branches, optional nodes only as list items, unique branch values, and every plan default validated against the full constraints of its node. It does not restate schema-compiler invariants that cannot affect runtime integrity (e.g. ordinary-range multipleOf reachability)
decode() nothing about types: it hands the transport object to schema.decode() and adds only the reading the core has no opinion about — every file reference goes to the host's file_resolver, wherever a file node sits in the decoded tree
schema.decode() and schema.build() the portable form as it arrives, and the values actually submitted
the host application everything about stored bytes: where an upload went, whether a reference still stands for something, whether it has expired, whether it is this caller's to redeem, and any byte-size guarantee that has to hold authoritatively. decode(..., file_resolver=...) is the seam, and an exception raised there propagates unchanged

The adapter does not restate the core's schema semantics for their own sake; it re-checks only the parts that become concrete once the plan is converted for the browser (exclusive bounds collapse after integer conversion, a slider's reachability depends on the converted limits, safe-integer representability is a browser property the core need not know). These are defense in depth checked in Python before the plan leaves the process — the core validates its schema semantics, and the adapter additionally verifies the exact converted browser contract it is about to emit. The normalized semantic layer is broader still, because a hand-written plan never passed through pytypehint; for a generated plan those invariants were guaranteed upstream and the browser only confirms them.

# Widget contract

Widget requires isEmpty() and value(); the base provides onChange(), error() (default null), hasError() and isReady(). Scalar widgets add _check()/_apply() and inherit setValue() (validate-and-apply); containers are built from a plan, not reassigned, so they have no setValue(). Containers use only that contract on their children — they never inspect a child's concrete class. The full public API is in the JavaScript API.

Container Role
Field label, description, optional toggle
GroupWidget several named widgets travelling as one object
ListWidget rows created by a factory, with minItems / maxItems
ChoiceWidget one branch active at a time, selected by an opaque value (Shape.option_id() when the plan comes from Python)

# Styling

widgets.css gives a polished appearance and is not part of the contract. Its pth-* classes are a technical namespace with no global selectors, driven by --pth-* design tokens; semantic behaviour and keyboard accessibility do not depend on it.

Presentation stops at the stylesheet — including its icons, which are .svg files under static/icons/ that the sheet addresses relative to itself. Nothing is embedded as a data URI, so a host needs no img-src data:, and the runtime builds no SVG of its own; it only has to serve the whole static directory.

Every rule starts at the .pth-root container the host mounts the widgets in, colours resolve through --pth-<name>-light / --pth-<name>-dark palette pairs into the active --pth-<name> tokens the widgets read, and the theme is chosen by prefers-color-scheme or by a data-pth-theme="light|dark" override on the root or any ancestor. That is the whole theme API: it is not in the plan, not in compileForm(), not in the transport and not in validation, and the runtime carries no theme JavaScript at all — which is also why the automatic mode cannot flash. See the JavaScript API for the tokens and the theme contract.