# Changelog

# 1.2.3 - 2026-09-29

# Changed

  • Documentation only: the README's Documentation list links each page on the documentation site, so readers on GitHub and PyPI land there. The code is the same as 1.2.2.

# 1.2.2 - 2026-09-29

# Changed

  • Documentation only: the README becomes a short entrance to the documentation site at https://offerrall.github.io/pytypehintweb/, and docs/overview.md holds the introduction (what the library does, where it stops, its features and its stability promise), stated once instead of on several pages.
  • The documentation drops install commands, badges and version numbers in prose, and states its limits as facts: the enum labels slot, the absence of a Python plan validator and the plan version policy are each described once. "Current limitations" is now "Limitations", testing and CI are listed as maintainer pages, and links to pytypehint and func-to-web point to their documentation sites.
  • The changelog headings use one format, the dates of 0.0.1, 0.0.2 and 0.0.4 are corrected to their release dates, and the missing 0.0.6 entry is added.

The code is the same as 1.2.1.

# 1.2.1 - 2026-09-29

  • Documentation only: the README title no longer carries the version. The code is the same as 1.2.0.

# 1.2.0 - 2026-09-13

  • compileForm(plan, {hidden}) accepts root names, dotted object paths and list wildcards. Initial values and visibility use the same compiler traversal, including optional and union branches and newly added list items. Hidden fields retain their values, validation and uploads. No plan format change.

# 1.1.0 - 2026-08-13

decode() stopped carrying a reader of the portable representation and now asks the core for one. Everything it used to restore by hand — the float a JSON writer flattened to an int, the date and time the wire carried as text, the enum member it carried as a name — is schema.decode(), which pytypehint 1.0.0 published and which this package had been reimplementing beside it. So is the union routing: the $type / $value wrapper, its consumption where the exact value is enough on its own, and the inline $type of a dataclass union.

The public API is unchanged. decode(schema, data, *, file_resolver=None) keeps its signature, its call shape and its refusal of anything that is not a compiled Signature or Struct; it is now annotated, so a caller running mypy no longer needs to exclude it from untyped-call checking. plan_of(), WebConfig, STATIC, PLAIN / INLINE / WRAPPED, Color, Email and the two patterns are untouched. The plan contract stays at v: 1, the wire format stays exactly as it was, and no JavaScript changed.

What remains in decode() is the part the core deliberately does not own. FileHint describes a file; it does not stand for stored bytes, and the core opens nothing. So file_resolver is now a separate pass over the tree the core already decoded, and it knows one thing about a node: whether it is a file. It no longer decides what a date, a time, an enum member or a number means, because it no longer has any reason to know.

decode.py went from 271 lines to 166 (140 to 77 excluding blanks and comments), from 18 helpers to 8, from 20 type(x) is tests to 12, and from importing 10 names out of the core to 7 — Date, Time, Float, Int and EnumShape are gone from this package's decoding path, which is the point rather than the line count. Source-hygiene tests now name the helpers that must not grow back and assert the import list itself.

Five behaviours changed, and each one was the core being right where this package was wrong. They are visible only to transport that was already malformed or already out of range:

  • A date or time is read only in its canonical spelling. date.fromisoformat and time.fromisoformat accept far more than that and their grammars overlap — "20200101" reads as a date and as a time, and "2020" read as 20:20 — so the text of a value could select an option. It cannot now: a non-canonical spelling travels intact and build() reports it.
  • An integer no float equals is no longer turned into one. Above 2**53 the floats thin out, so float(2**53 + 1) answers with a neighbour; restoring it handed build() a number the transport never carried.
  • decode() no longer raises on a value of its own accord, which is what the documentation always said it did. An integer too large to convert — 10**400 in a float field — raised OverflowError out of float().
  • A wrapper whose payload never read as the option it names survives instead of being consumed. {"$type": "date", "$value": "not-a-date"} in a str | date field used to be filed as the str beside it, in silence, and build() accepted it. This also closed a hole in the file boundary: a reference could land in a FileHint field without file_resolver ever being asked about it.
  • A subtree decode() cannot route is copied rather than shared with the caller's document, which is what "the returned dict is always new" meant.

plan_of() was not migrated to schema.to_dict(), and the reason is worth recording. The portable contract publishes everything a plan node needs except the one thing that would remove duplication: the transport mode of each branch. plain / inline / wrapped agree with the core's rule on every union tested, but the rule itself is not published, so consuming the document would leave that calculation exactly where it is and add a serialization step between two components that both already hold the compiled schema. Worse, to_dict() writes a union default in portable form, so the branch a default inhabits — decided today against real Python objects — would have to be inferred back out of flattened JSON. plan.py is unchanged in this release.

The dependency is pytypehint >= 1.0.0 again, with no upper bound, matching what the 1.0.0 entry below and the documentation have said all along; the exact pin introduced after that release contradicted them. The package no longer classifies itself as Development Status :: 3 - Alpha.

# 1.0.0 - 2026-08-13

The core is now pytypehint >= 1.0.0, and the dependency has no upper bound. There is no compatibility path back to 0.x: the atom IsPathFile is gone and the shape attribute it fed, Str.is_path_file, is now Str.file_hint. Since _str_node() reads that attribute on its first line, every plan_of() call carrying a str — not only a file one — failed with AttributeError against a freshly resolved core. Renaming it is the small half of this change.

The large half is that the core stopped looking at the filesystem, and the documentation that said otherwise was wrong from the moment 1.0.0 resolved. FileHint reads one thing off a value: its extension, off the text. It opens nothing, so existence, regular-file-ness and byte size are no longer checked anywhere in the stack — not in the browser, which never had the bytes, and not in the core, which no longer looks. file does not exist, not a file, file too small and file too large cannot be raised any more, and every claim in these documents that rested on them (see the 0.0.3 and 0.0.4 entries below, written when they held) is superseded by this one.

Nothing was reimplemented here to compensate. plan_of() and decode() check nothing about storage, on purpose: a reference is not a path and carries no bytes, and only the host knows what it stands for. That answer has always had exactly one seam, decode(..., file_resolver=...), and it is now the only one there is: a host decides there whether a reference exists, has expired, belongs to this caller or is the right size, and an exception it raises still propagates unchanged. One consequence worth knowing: the resolver's answer is the value that continues, so it faces the extension check too — resolving report.pdf to a bare s3://bucket/9f3a1c fails at build().

FileHint(min_size=...) / max_size=... still travel to the plan as minSize and maxSize, unchanged. What changed is that the browser is now the only place they are ever applied, against a local File whose .size it can read, before any upload. A reference — a plan default or a setValue() — carries no bytes, so nothing weighs it. A byte bound that has to hold authoritatively belongs beside the storage that holds the bytes.

What a schema default still faces is the extension, which means the road a prefill takes is no longer a choice between "strict" and "opaque". A host whose references are object-store keys can now write one straight into a schema default; it renders, travels and comes back intact, exactly as one planted with setValue() does.

Also from the core's 1.0.0 rules: the option-identity check reaches inside lists, so list[File | str] is refused while compiling, in its own words rather than the field's (both compile to str). The adapter's own duplicate-option-id guard in _check_branches stays, now purely as defense in depth for shapes assembled off the compiler's path, and a test drives it there directly.

The bundled demo no longer seeds sample files at startup. It existed only to make the two "edit an existing record" prefills satisfy the core's old existence check; nothing in the demo reads those bytes, so the seeding, its file list and the test pinning them are gone. The upload endpoint, which does write real bytes, is untouched.

The plan document is unchanged: "v" stays 1, no key was added, renamed or removed, and the browser runtime needed no edit — it never knew the atom's name. pytypehintweb's own version is unchanged too.

# 0.0.6 - 2026-08-11

  • The pytypehint dependency is pinned to exactly 0.0.7 instead of a minimum. No code change.

# 0.0.5 - 2026-08-03

A file field works again on a page that is not served from localhost. mintFileReference reached straight for crypto.randomUUID(), and that method exists only in a secure context. A panel opened at http://192.168.0.132 is not one, so the call threw a TypeError inside the change handler of the file input: nothing was ever recorded, _files stayed empty, and the form then asked for a file that the page appeared to be holding. The same page reached at localhost worked, because a browser grants that origin a secure context over plain http.

What made it hard to see is that neither end was wrong. The bytes served were identical, the plan was identical, the backend accepted the upload over curl without complaint, and the only difference between a form that worked and one that did not was the address in the bar. The failure also read as the wrong kind of problem: an empty file field reports absence rather than an error, so the form said Complete: — a field left blank — and never Fix:.

randomUUID is used when it is there and getRandomValues builds the same shape when it is not, which is the fallback this package already had in sdk.js and had not been given here. The identifier is a version 4 UUID either way, with the version and variant bits set by hand on the fallback path, so a reference minted on a LAN address is indistinguishable from one minted on localhost.

This is not about how random the value has to be. A file reference only has to avoid colliding with the other references a space is holding, and getRandomValues is a cryptographic source in its own right; the point is that one of the two APIs is withheld outside a secure context and the other is not.

# 0.0.4 - 2026-07-29

The current-file label is compacted now. A planted reference is whatever the host already holds — a local server path, a stored name, a URL — and the widget used to print it whole, so a deep UPLOADS_DIR path or a long URL spilled across the field and put the server's directory layout on screen. It shows the name after the last / or \, and when even that is long, a … followed by its last 32 characters. A name that already fits is untouched.

The cut is by characters, not by UTF-16 units, so a name of astral symbols is never split mid pair, and it happens at render time only: value(), read() and uploads() carry the reference exactly as it arrived, the extension filter still sees the whole string, and replacing the current file with a local pick behaves as it always did. Nothing about it is configurable and nothing new travels in the plan — the shortened text exists only inside the label element, written with textContent, and the full reference is not mirrored into a title, which would have put it back on screen on hover.

This is presentation for every file field, not for images. The layer has no notion of an image: one FileWidget serves a .png and a .pdf alike, and the plan carries extensions, not types. Compacting only some of them would have meant inventing a category here to decide how a label reads, which is the wrong place for it — the problem was never the kind of file, it was the length of the string.

# 0.0.3 - 2026-07-27

A file field can carry a default now, and it means what FileWidget.setValue() has always meant: an existing reference the host declares, shown as the current file and transported back untouched. It carries no bytes, it is not a local selection and it starts no upload — file() and files() stay empty and uploads() reports nothing, while a user's own pick appears in all three. A single node takes a str, a multiple one a list[str], and an optional file takes null for its off state.

The refusal was incoherent rather than merely strict. The widget already represented an existing file, value() and read() already carried it back, and no upload was ever started for it — but the plan rejected the same reference on the way in, so the general prefill mechanism broke on file fields: a prefill is expressed as a temporary default, and a form with a file could not have one. There were three separate refusals, one in plan_of() for a lone file field, one deeper in the value walk for a file nested anywhere, and one in checkPlan().

Underneath, there is now a single implementation. compileForm() builds the FileWidget and applies the default by calling its public setValue() — the same entry point a host uses — so the two paths cannot drift, and the tests assert exactly that: compiling with a default produces the same observable state as compiling without one and calling setValue() afterwards, both at the widget level and across the real Python → plan → browser boundary. It also fixes the second half of the incoherence, since the compiler was ignoring the initial value for file nodes even where one could be expressed.

checkPlan() polices the shape and the widget owns the semantics: a default must match its node's arity (a bare string on a multiple node and an array on a single one are both rejected, with the failing path), every reference must be a non-empty string passing the same endswith extension filter, and a multiple default must sit within minFiles and maxFiles.

One thing worth stating plainly, because it decides where a prefill can come from. A default written into a Python schema is certified by IsPathFile while the schema compiles, so plan_of() only ever sees a reference the core already accepted — which, since 0.0.7, means a real local file. A host whose references are not local paths, an object-store key say, cannot pass one through a schema default; it plants it with setValue() after mounting, which the plan layer and the widget accept as the opaque reference it is, and resolves it on the way back with decode(..., file_resolver=...). Nothing in the browser ever checks that bytes stand behind a reference: an expired one shows in the form and fails when the host resolves it, which is correct, because the form and the storage are different layers.

Nothing about the transport moved: the plan version, the property names, the file node's shape, the reference on the wire, the upload API and decode(file_resolver=...) are all unchanged, and a plan without file defaults behaves exactly as before.

A file composes like any other node now, at any depth. list[File | None], list[File | int], list[list[File]] and list[SomeDataclassHoldingAFile] all compile, along with anything else built from them.

They were never unrepresentable. The plan already had every node the shapes need — list, optional, choice, object, file — the Python adapter already recursed over them, compileForm() already compiled a list item by calling compileNode() on it, checkPlan() already validated defaults recursively, and decode() already walked files to any depth. Hand-writing those plans and mounting them worked before this release; the only thing standing in the way was one predicate in plan_of() that asked "does this list's item contain a file anywhere?" and refused if so. That is a blanket policy, not a structural limit, and it is gone — with the predicate itself, which had no other caller.

What remains is the one file-specific decision worth keeping, and it is a shortcut rather than a rule: a bare list[File] still becomes a single multiple file node, because that is what a user expects from it. It is chosen by an exact shape match, so it captures nothing wider — which is also why list[list[File]] is a list whose rows are multiple file nodes, the shortcut applying at the inner level while the outer list stays a list. The structure is never flattened.

The genuine ambiguity is documented as what it is. File | str, and so list[File | str], is inconstructible because a file is a Str: both branches carry the option id "str" and the core rejects the schema with duplicate option types in shape. Nothing can tell them apart on the wire. That says nothing about File | None or File | int, whose branches are distinguishable and which both work.

The two roads a reference can take are now stated apart, since only one of them carries a guarantee when the page renders. A default in a Python schema — a prefill included, a prefill being a temporary default — is certified by IsPathFile before a plan can exist: exact str, the file exists, it is a regular file, the extension matches, and the byte bounds hold when used. A missing one fails with file does not exist and produces no plan, which is what stops a form from displaying a file nobody has; a list[File] default is certified element by element, list bounds included. A reference applied at runtime with setValue() certifies nothing by itself: it is frontend state, and the guarantee arrives later, when the host resolves it with decode(..., file_resolver=...) and the core checks the path it resolved to. Both land the widget in the same observable state; only the first has been checked by render time.

IsPathFile(min_size=...) and max_size=... stopped being refused and started being carried. The refusal rested on a rule worth stating the other way round: a restriction the browser cannot check must not make the type unrepresentable. Refusing the plan did not protect anyone — it only meant a schema the core validates perfectly could not produce a form at all.

They travel as minSize and maxSize on the file node, in bytes and per file, so three 4 MB files under a 5 MB bound are three valid files; counting them stays minFiles / maxFiles. They belong to the node, so they arrive wherever a file node arrives — inside a list, a union branch, a nested struct — with no rule about depth.

A local File carries a .size, so FileWidget now weighs one and refuses it before any upload: exactly at a bound is inside, one byte past it is not, the message names the bound the way the widget names a size ("minimum 2 KB"), the error joins hasError() / isReady() / showErrors(), and a refused pick mints no reference, so uploads() never offers it. A batch that contains one bad file is refused whole rather than half-accepted. WebConfig gained file_min_size_message and file_max_size_message for the two texts.

What the widget does not do is guess. A reference the host plants — a default, or a runtime setValue() — names a file the browser never saw, so it carries no size and none is invented, no request is made to find one out, and nothing marks it as certified. Both readings end in the same place: build() measures the real file. That is what a hand-written HTTP call, a stale reference, or a file edited after upload all run into, and it is why the browser check is a courtesy rather than the verdict.

Every icon the interface draws is a file now. The select arrow was a data:image/svg+xml written into widgets.css and the list's remove glyph was an <svg> the runtime assembled node by node; both are .svg files under static/icons/, referenced from the stylesheet and from nowhere else.

The point is the page's Content-Security-Policy. A data URI in a stylesheet is still an image the browser fetches, so the arrow forced every host that loads widgets.css to allow img-src data: — a broad permission granted for one 12×12 chevron. The icons now load under a plain img-src 'self', and the sheet contains no data: and no base64 at all.

Paths are relative to the stylesheet (url("./icons/select-arrow-light.svg")), not to the document and not absolute, so they follow it under any static prefix: a host that serves the package's static directory at /assets/pth/ needs to configure nothing. What it does have to serve is the whole directory, the new icons/ subdirectory included. The bundled demo's own static route was reaching only the flat files, so it grew a path segment — with the containment check tightened to match, since accepting a slash is exactly how a traversal gets in.

The remove glyph keeps its colour. An external SVG loaded as an image cannot see the page's currentColor, so it would have frozen to one colour and lost both the theme and the red it turns on hover; it is painted through a mask instead, with the colour coming from the element. The runtime therefore builds no SVG of its own any more — it mints an empty, aria-hidden span the stylesheet masks — and the button's size, accessible name and behaviour are unchanged. If the file does not load the glyph is simply not painted: measured, not assumed.

The tokens are the same three, --pth-select-arrow-light / -dark and the active --pth-select-arrow, and the select rule still reads only the active one. Contrast is still asserted, but now against the asset: the test reads the stroke colour out of the .svg the browser will actually fetch, so the guarantee covers what is painted rather than a copy of it kept in the stylesheet.

Packaging was verified rather than assumed — nothing under src/ ships just for being there — and static/icons/*.svg is declared as package data. The three files appear in both the wheel and the sdist.

The documentation's own anchor checker was wrong, and quietly so. It slugged headings by deleting every character outside [a-z0-9 -], underscores included, so a correct link like [file_resolver](#file_resolver) over a ### \file_resolver`heading was reported as broken — and had been worked around by demoting the link to plain text. It also read every#` line in the document as a heading, so the shell comments inside fenced code blocks became anchors the rendered page never offers.

tests/python/markdown_anchors.py now reproduces GitHub's rule and documents it: inline Markdown is reduced to the text it renders as, the text is lowercased, everything that is not a letter, digit, _, - or space is dropped without leaving a separator (so HTTP / transport gives http--transport, with both spaces surviving), spaces become hyphens, non-ASCII letters are kept (Café gives café), and a repeated slug takes -1, -2. The heading parser reads ATX and Setext, skips fenced blocks whole, and honours fence lengths. The link is a link again, and a table of thirty-odd cases pins the semantics so the next change to it has to be deliberate.

# 0.0.2 - 2026-07-27

A minted file reference now carries the name of the file it came from. When the user picks a file, FileWidget compresses that file's name to bare ASCII — diacritics folded away, lowercased, every other run of characters collapsed to - — keeps at most its first 15 characters, and puts it in front of the UUID: informe-anual-<uuid>.pdf instead of <uuid>.pdf. A name that keeps nothing (an empty stem, or one written in a script that folds away entirely) still mints the bare <uuid>.pdf.

Uniqueness is unchanged — it lives entirely in the UUID, so two picks of the same name still mint two distinct references — and so is every contract around it: the reference is still opaque to the core, still filtered only by extension, still a str on the wire, and an existing reference planted with setValue() is still transported verbatim. The slug alphabet is [a-z0-9-], so a reference remains a safe single path segment.

decode() now accepts an optional keyword-only file_resolver (Callable[[str], str]). When supplied, every file reference the existing transport walk reaches is passed through that callable and its return value continues down the pipeline: file fields at the root, list[File] (once per reference, in order), files inside structs, inside lists, and inside the selected branch of a union — plain, inline and wrapped alike. Without a resolver, references travel untouched exactly as before, so the call with no keyword is unchanged in every respect.

The resolver is deliberately storage-agnostic: pytypehintweb still knows only that the value belongs to a file node — decided by the shape, never by what the string looks like — while the host decides whether that reference becomes a local path, an object-store key or any other string. Nothing about storage, existence, paths or security enters the library. An absent field, a None and an empty list never reach the resolver, and an exception it raises propagates unchanged: it is the host's error, not one pytypehintweb names or swallows. This is the one way decode() can raise on a value, and only because the host asked for it.

A host like FuncToWeb can therefore turn references into persistent paths without reimplementing the walk over objects, lists, optionals and unions.

The browser modules now ship without comments. They are served exactly as they are written, with no build step in between, so every explanatory line was weight downloaded by every page that loads the runtime. The reasoning they held is not lost — it lives in docs/, which is where a reader looks for it anyway.

Only comments were removed: same code, same tokens, same behaviour, whole suite green. Measured with LF endings, the runtime drops from 143 130 to 116 829 raw bytes and from 33 337 to 21 939 gzipped, a third of the compressed download. The size budget's ceilings drop with it, keeping the same headroom as before. The demo page's inline script was stripped the same way, keeping only the four markers its tests use to extract helpers.

A float field now accepts a comma as the decimal separator. The comma is folded to a point over the trimmed text before the parsing grammar runs, so 1,5 and 1.5 are the same value and every restriction the grammar already had survives untouched: one separator at most, so 1.000,5 and 3,1,4 stay invalid, as does a lone ,, and 1,000 reads as 1 — a comma is never a thousands mark. An integer field is unaffected and still refuses it.

This is an input convenience, not a contract change. The widget never rewrites what was typed, and value(), read() and the plan carry the plain number they always did, so nothing downstream — transport, decode() or the core — can tell which separator was used. It costs 19 bytes.

The motivation is a mobile keyboard. A float control is a text input with inputmode="decimal", which on iOS opens the system numeric keypad; on a device whose locale writes decimals with a comma, that keypad offers a comma and no point. The key the phone hands the user was the one the widget rejected, and reaching a point meant switching keyboards. On a desktop the problem is invisible because the point is typed without thinking.

A time field now completes the seconds a picker does not offer. The time node asks its control for step=1, which opens the seconds field on a desktop picker and made HH:MM:SS the value the widget could count on. iOS ignores the request: its wheel picker has hours and minutes only and reports HH:MM, so on an iPhone every time a user picked read as invalid and no form carrying one could be sent. Whole minutes are inside the domain the core admits, so TimeWidget now reads a well-formed, in-range HH:MM as HH:MM:00 instead of rejecting it.

The completion is deliberately narrow. Only a value that is already a whole, in-range HH:MM is completed; 12:3, 24:00, 12:60 and a stray fraction stay exactly as the control reported them and stay invalid, so nothing malformed is repaired into something plausible. A control that does report seconds is left untouched, the widget never rewrites the text its control shows, and setValue() is unchanged — it still demands the canonical HH:MM:SS. Bounds are compared after completion, so an exclusive 09:00:00 still rejects a picked 09:00, and read() transports whole seconds exactly as before.

A slider now reaches its maximum even when the stride cannot land on it. Annotated[int, Min(1), Max(100), Step(5), Slider()] used to stop at 96: the grid was min + k*step and nothing else, so 100 — a value the plan itself declares valid — was impossible to choose. The control was refusing a valid value, which the doctrine forbids. The maximum is now a grid position of its own, reached by a final short step (… 91, 96, 100).

Only the maximum is added, never a value inside a stride: 97, 98 and 99 are still refused, exactly as 2 and 3 are. multipleOf reachability follows the same grid, so a slider whose only valid multiple is the maximum is reachable and starts there instead of being rejected as unsatisfiable. The rule lives in slider.js and is read by both the widget and checkPlan(), and the Python adapter's _slider_reaches and slider-default check match it, so a plan plan_of() emits and a plan the browser accepts still agree exactly.

A native <input type="range"> cannot express this grid — it only offers min + k*step and snaps anything else — so a slider whose stride misses the maximum now drives its range input by grid index and maps index to value, and carries aria-valuetext so a screen reader announces the value rather than the index. The value contract is untouched: value() returns the integer, setValue() takes the integer, and an off-grid value is still refused rather than silently snapped. A slider whose stride does divide its range is unchanged in every respect — its range input still carries the real min, max and step, with no mapping.

The stylesheet now has one theme contract, and it is entirely CSS. Widgets are mounted inside a .pth-root container; without an override that root follows prefers-color-scheme, and data-pth-theme="light" or "dark" — on the root or on any ancestor, <html> included — forces one. Nothing else exists: no theme in the plan, no option on compileForm(), no theme JavaScript in the runtime, no localStorage, no global theme manager. Because the automatic mode resolves in pure CSS the widgets cannot flash light-then-dark; a host that restores a remembered manual preference still has to write the attribute before the first paint, which is its own job and is documented as such.

Overrides win by source order rather than by weight — the manual blocks follow the automatic one and the automatic one skips any root carrying an override itself or inheriting one — so there is no !important anywhere near a theme. The tokens land on the element holding the attribute and reach the widgets by inheritance, which is what makes the nearest override win: two roots on one page can hold different themes, and a subtree can disagree with its ancestor.

The pre-1.0 [data-theme="light"|"dark"] attribute and the .light-mode / .dark-mode classes are gone, not aliased, and the sheet no longer writes to :root at all. Every rule now starts at .pth-root, so loading the stylesheet cannot reach a host element and widgets mounted outside a root are plainly unstyled instead of half-styled. color-scheme is set on the library's own controls only. The root paints --pth-surface, the background the palette is calibrated against, which is what lets a forced dark form sit on a light page without unreadable labels.

Colours are two levels now: a --pth-<name>-light / --pth-<name>-dark pair per palette entry, and the active --pth-<name> token the widgets read. Nothing outside a theme block reads half a pair, and no colour is written into a rule. The naming inconsistency is gone (--pth-nested-bg-light vs --pth-nested-background), the colour aliases that had no per-theme source (--pth-item-background, --pth-choice-border-color, --pth-index-color and the rest) were dropped in favour of the tokens they pointed at, and -bg became -background throughout.

Three colours that could not follow the theme now do. Button text is --pth-submit-text instead of a hard #ffffff, on the submit-coloured controls and on every hover that paints text over the focus colour; the error red is a pair, because one red cannot clear 4.5:1 on both a white and a near black background; and the select chevron, which was a #6b7280 baked into its data URI, is a pair of its own. The switch knob gained one too.

The palette was retuned for contrast and measured rather than eyeballed. Input borders were the worst offender — #d1d5db on white is 1.5:1, and the border is the main signal that a control is there — and now clear 3:1 against the surface, the input, the nested background and the hover. Text, error messages and button text clear 4.5:1 in both themes; borders, focus rings, the knob and the arrow clear 3:1. The tests assert those relations against thresholds, never a specific hex, so the palette stays tunable.

The sheet is now tested as a contract rather than as a file. The Python suite reads it as data — selectors, blocks, token sets, palette pairs — and measures the contrast relations, and a new static page, tests/browser/theme.html, puts the cascade itself in front of a real browser, because specificity, inheritance and proximity do not exist in a text assertion. CI runs it once per system preference so automatic mode is covered both ways.

pytypehint 0.0.7 hardened IsPathFile, and the file field follows it. The core now certifies the file itself on the way in — extension, existence, regular file and byte size — so a reference the host never turned into real storage is refused by build() with file does not exist instead of travelling on as a promise nobody checks. Nothing in the browser changes: the widget still mints an opaque reference and still filters by extension alone. What changes is that the host has to close the gap, and the seam for that already existed — decode(..., file_resolver=...), added earlier in this release.

The bundled demo now uses it, because without it every one of its six file forms answered file does not exist. /build passes a resolver that maps a reference to the temp directory /upload writes into, and the two "edit an existing record" cases seed their sample files at startup, since a prefilled record names a file the host is supposed to already hold. The tests follow the same rule: anything that reaches a Choices list, a default or build() now points at a real file created under tmp_path, while the many tests that only inspect a plan still need nothing on disk — a plan is compiled from the shape, never from a value.

IsPathFile's new min_size and max_size are refused at compile rather than dropped. The plan has no way to carry a byte bound and the widget has no way to show one, so a form would have accepted a file the core then rejected after the upload had already happened. plan_of() raises TypeError ("IsPathFile.min_size is not supported yet"), the same deferral Float.slider and the other Str atoms get, until the widget can check File.size itself. (Reversed in 0.0.3: the widget checks it now, and the bounds travel.)

Two testing defects surfaced while checking the theme work and are fixed here. The browser smoke page had never actually gated anything: CI matched a bare SMOKE: PASS, which the page's own comment contains, so the step reported success whatever the page did — and it did fail, silently, about half the time. That half was a race, not a shortfall: a top-level await import(...) settles after the load event, so --dump-dom captured PENDING. Both pages now import statically (which also loses nothing — the try/catch started after the awaits, so it never caught an import failure), and both CI steps read the verdict from the #result element and dump the page when it is not PASS. Measured after the fix: 24 consecutive runs, all green.

Not touched, deliberately: prefers-reduced-motion is unchanged and still has nothing to do with the theme, no transition animates all, no light-dark() — explicit blocks keep a subtree themable without a global color-scheme — and the widgets, the plan, the transport and validation are byte-for-byte the same.

# 0.0.1 - 2026-07-24

First release. pytypehintweb is the browser form layer for pytypehint: it converts a compiled type schema into a JSON-serializable form plan and renders it with framework-free JavaScript widgets. Requires pytypehint >= 0.0.6.