v2.63.0
Added
-
Third-party modules can declare capabilities in
module.jsonfor dashboard widgets, household permissions (ext:<module-id>), and API token scopes - the same surfaces core modules use, without changing core application code. -
The dashboard dynamically loads third-party widget entry points (
renderWidget) from protected module assets, with per-widget error isolation and an optional generic options dialog driven byoptionsSchema. -
Third-party modules can ship UI translations in
locales/{locale}.jsonwith manifesti18n.defaultLocale,labelKey/titleKey, and the same 24 core languages as Yuvomi. -
OpenAPI now documents extension module capabilities and module i18n metadata.
-
Every page behind the app shell is now held to one page composition contract (#929).
Layout primitives (.app-page--*,page-measure, the bleed section), the--layout-*width
tokens and thepage-layout.jshelpers arrive together with an audit that enforces them. The
audit derives its scope fromrouter.jsrather than from a list: a route with
requiresAuth: falserenders without navigation and is outside the contract, everything else
is inside it, and a page added tomorrow is covered the day it gets a route. Three pages that
predate the contract are named in the guard, and a second test fails if that list grows, so
migrating a page is a deletion rather than a table somebody has to keep honest. The first draft
marked one reference page in the production markup instead; that attribute shipped to every
visitor and said nothing about the other thirty pages, so the guarantee moved into the guard.
The spec lives atdocs/PAGE-COMPOSITION.md. Two reviews on the way in found what the first
cut had left undone: an extension module'spage.compositionandpage.widthwere checked
on the server and shown in the admin list but never applied to the page, sodatalooked
exactly likereading; now the router mounts the module in the declared root (the
containera module'srender()receives is that root, andcontext.pagesays which). The
reference page had lost the gap between its header and its body, and its measured header
wrapped the title in a rail that hid the module seal and the docked title; notes was declared a
reading page while its masonry ran the full width beside a 720px header, and is nowfull.
Three of the new guards were green without seeing anything: the breakpoint check let the
spec's own forbidden example through, the negative-margin check did not knowcalc(-1 * ...),
and the inline-width check read the wholestyleattribute instead of the value. Each of
them now has a counter-proof. A second round found the header formula for a tab bar still
subtracting the reading width by name, so the housekeeping header (adatapage) stopped
240px before its cards; it now subtracts the measure of its own page, and pages without one
(full,split) set that measure to100%rather thannone, becausenoneinside a
calc()does not degrade, it invalidates. The split grid sat on the page root, where the
helper's header and body would have become its two cells; it sits on the body now, and
full/splitroots built by the helper take the shell height so a body can scroll on its
own. Subscriptions was declaredreadingwhile its analytics grid and list never met the
measure - only the KPI band did, and the page acquired a width jump; it isfulluntil its
sections follow one measure, and split-expenses, a header over a two-column layout, is
split. The reach proof of the audit could not catch the failure it was written for: with a
dead router expression the scope did not shrink to nothing but widened to every page, login
included, and every lower bound stayed green; the expression now has to have read the
standalone routes before the file walk is allowed to add anything. A third round found the
helpers escaping only attribute values:id,classNameand attribute keys went into the
markup raw, and these helpers are the API an extension is told to build its page with, so a
per-record id was the expected way to hit it; every attribute value now goes through the
sharedesc(). A fourth round showed thatesc()is the wrong tool for an attribute key: it
knows& < > " 'and not the space or=that end a name outside the quotes, so a key with
either in it became three attributes, one of them live, while the new guard stayed green with
its quote-based payload. Keys are now validated against an attribute-name pattern and an
invalid one throws like an unknown mode does; the guard tokenizes the opening tag the way a
browser does instead of reading the string. A fifth round caught the split grid measuring
the viewport: beside the expanded sidebar a 1024px screen leaves the page about 804px, and a
master rail allowed 720px of that left the detail rail a few pixels wide on common laptops;
the split root is a container now, the grid switches on the page's own width like the
expenses split already did, and the master rail never takes more than half. A sixth round
found the header helper still building a rail box formeasuredwithoutnarrow, the one
combination the spec still offered: a real element, not adisplay: contentsshim, and it
put the title one level below the toolbar where the large-title rules and the collapsing
header look for a direct child. No page used it. The option, the element and its rules are
gone, every option combination renders the slots as direct children, and a guard fails on
either class name anywhere underpublic/. A seventh round found the schedule page declared
data(960px) under a header that runs full width: nothing showed the measure except the
primitives that happen to consume it, so the KPI band of the statistics ended at 960 while
the filter card and the result cards beside it did not (on main nothing was capped).
Schedule and documents, the two pages with that shape, declarefulland cap nothing,
which is what they looked like before; the page's own rows inside its full-width cards
follow suit and no longer stop at the reading width. A guard reads the measure consumers
from the stylesheets and fails on any measured page whose header does not narrow but whose
markup contains one of them. The budget
reports panel had declared itself adashboardinside thereading
budget page, which set the measure of its subtree to 1200px while the shared header and
every other tab end at 720px; it declares the mode of the page it lives in, and a guard holds
the two budget panels to that. A split body now carries the page gutter like the measured
modes (its rails started at x=0, left of the title), the manifest fieldspage.navigation
andpage.responsivefall back tostandardlikecompositionandwidthdo instead of
passing a typo through, and the worked example in the spec no longer draws the rail element
that the helper had stopped emitting. -
A third-party module now declares which manifest format it is written in (
manifestVersion),
and Yuvomi refuses one it cannot read instead of reading it in part. The extension surface from
#919 - widgets,ext:<module-id>permissions, an API prefix, a locale chain - is a promise made
to code nobody here can see:modules/is gitignored, modules arrive at runtime. Without a format
number, renaming a field later would have been a silent break, where the module still loads, the
field is gone, and the household notices a widget that stopped doing anything.Omitting the field means 1, so manifests written before it keep working. A manifest declaring a
higher version is rejected outright, and the error names both numbers, because loading it halfway
would silently ignore fields it considers essential.New optional fields never require a bump; the number moves only when one is removed or
renamed, and then the older format stays readable. A guard drives a manifest carrying every
promised field through the real normaliser, so dropping one turns the suite red rather than
turning somebody's widget blank.
Changed
GET /api/v1/modulesincludes normalizedcapabilitiesandi18nmetadata (widgets, permission module metadata, API prefix, available locale files) for each installed extension module.- Dashboard widgets, navigation, route guards, and admin permissions merge extension entries at runtime from enabled modules, so third-party widget ids (
<module-id>:<widget-id>) andext:<module-id>permission keys behave like core modules. - API token and MCP scope pickers include extension modules from the live permissions catalog instead of a fixed core-only list.
- Extension
capabilities.api.prefixmust be exactly/api/extensions/<module-id>- any other prefix, including a core path such as/api/tasks, is rejected so an installed module cannot take over a core token scope. - Extension UI labels resolve through a locale fallback chain (UI language, module default,
en,de, then static manifest labels) in navigation, Settings, permissions admin, and the dashboard widget chrome. CONTRIBUTING.mdsays who cleans up a stale PR: it follows from the cause, not from who has
time.mainmoves faster than a review cycle. Mechanical fallout of that - rebases,CHANGELOG
collisions, the version line,sw.js, migration numbering - is the maintainer's; decisions inside
the feature stay with its author. Two promises follow: rebase once, after the review, and an open
architecture question never blocks a PR (#621 died waiting on one).
Fixed
-
A failed
GET /modulesno longer wipes the household's extension widget layout. A network hiccup, a server restart, or the/api/rate limit used to empty the in-memory module list; the next dashboard save then persisted a config with everyexttile gone. On recovery the widget came back as a newcomer: default size, default position, options lost. A failed fetch now keeps the previous list, and stored<module-id>:<widget-id>entries survive normalize even while the module is disabled or the catalog is empty. -
The extension permission catalog is scanned before the server accepts requests. Starting the scan inside the
app.listencallback left a window where storedext:<module-id> → nonerows were dropped and the deny-list treated a missing key as allow. -
Extension locale lookup no longer throws for module ids that collide with
Object.prototype.constructor(andtoString) pass the module-id regex; looking them up on a plain{}store madet()throw instead of returning the key. -
The empty options dialog for a third-party widget no longer quotes the task-categories copy. It has its own string.
-
An
allowScriptspin no longer points at a version that is not installed. The field names
every package allowed to run install scripts, with an exact version, because the permission
applies to the reviewed build rather than to the name. Dependabot raises the dependency and the
lockfile but never touches that field - it does not know about it - so after every bump the pin
referred to a version that had been replaced. Nothing broke visibly, which is exactly why nobody
noticed: a permission pointing nowhere looks like one that holds. A guard now compares each pin
against the lockfile, so the next bump turns the suite red instead of leaving a dead pin behind.