feat: the wardrobe workshop M2 — widened font law + the v2 theme editor (F104, T205–T209) - #395
Merged
Conversation
ThemeFontProvenanceValidator now accepts a face from GenWave's vendored curated set OR the station's currently-installed font packs (InstalledFontCatalog); the per-theme byte ceiling sums recorded bytes across both sets. Import's 400 names the missing face and, when the catalog index knows a pack that provides it, the pack's own slug too (fail-soft: an unreachable/disabled catalog still names the face, just without the suggestion). Preview widens the same way but never pays for the catalog-index round trip since nothing it composes persists. Catalog CI stays curated-only — themes may still reference only the vendored set, pinned separately in genwave-catalog. 5 new specs in Story285_WidenedFontLaw.cs drive the real production pipeline (install then import through WebApplicationFactory<Program>), smoke-tested end to end against a real Postgres + Kestrel + local catalog origin.
… (T206, STORY-286)
The v2 editor mixes a base theme's palette with a face per role (display/sans),
previewing through the transient scoped compose the T186 mechanism already
established (POST /api/themes/preview); the remix lives in client state only —
nothing persists, nothing is served station-wide at any point.
The assignable face set is derived exactly once, server-side: GET
/api/fonts/vendored now projects vendored ∪ installed into one union, one row
per family, so the client stops re-deriving it with its own heuristic. An
unassigned role carries the base theme's own font declaration through
byte-untouched (weight ranges, italic siblings included); only an explicit
assignment replaces it with the single-face 400/normal shape SPEC F104.11
scopes assignment to. The catalog kill-switch posture is Fact-pinned: GET
/api/fonts/vendored and GET /api/themes stay 200 with the catalog disabled
while POST /api/fonts/{slug}/install still 404s.
…ared write gate (T207, STORY-287) - ThemeWriteGate: the two station.theme write routes now share one two-phase gate pipeline (ValidateSlug + ReadParseAndValidateAsync) instead of two hand-copied blocks — gate-identical by construction, proven by a 7-row parity table driving both routes with the same bad bodies. - Fail-closed 409 when a save-as-own targets a slug that already holds an imported theme — an authored save can never silently erase another theme's provenance; authored-over-authored re-saves stay allowed. - ThemeRepository.UpsertAsync: imported_at is now a CASE expression, stamped only when imported_from is non-null — restores OwnerTheme's own "ImportedAt is null exactly when ImportedFrom is" invariant. - Renamed GET /api/fonts/vendored -> /api/fonts/assignable end to end (route, AssignableFaceDto, admin-ui) — the old name promised "vendored only" while the response has carried installed faces too since T206. - Restored catalogSlug's original precedence ahead of the shared gate's body-read/parse/font-law phase, pinned by a live-precedence Fact. Production smoke (isolated scratch stack, Playwright + curl): editor save flow verified live (Space Grotesk assigned, saved, immediately selectable with no reload); theme.css resolves the saved slug; import vs save-as-own byte-identical 400 on the same bad manifest; fail-closed 409 on an already-imported slug with provenance intact; base theme byte-identical after the save.
…al (T208, STORY-288)
DELETE /api/fonts/{slug} removes an installed pack unless a saved/imported
station.theme row still references one of its faces. The guard lives inside
the DELETE statement itself (WHERE NOT EXISTS over a shared substring
predicate) rather than as an advisory pre-check a concurrent write could
race past — refusal names every referencing theme by slug; on success,
InstalledFontCatalog.ReloadAsync stops /fonts/{file} serving on the very
next request, vendored faces are untouched, and provenance rows are gone
via ON DELETE CASCADE. A Host-side tripwire pins the exact quoted
"/fonts/<file>" shape the guard depends on, so a future change to how
themes reference faces fails loudly instead of silently blinding the guard.
…tator counterexample pinned (T209, STORY-289)
Consolidated AdminSurface+Settings sweep with per-endpoint public-404 probes
and internal-listener positive controls; the one sanctioned spectator change
(theme options gain exactly {slug,name}, F102.10a) pinned; GuardedRouteInspector
extraction (Story278/283 migrated, coverage byte-identical); FontEndpoints
Last-Modified asymmetry RULED cosmetic and recorded.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
👗 What
F104 M2 — the editor half of the wardrobe workshop, built on the released M1 library (v3.1.0/v3.1.1). Five tasks, one commit each:
ThemeFontProvenanceValidatorwidens to vendored ∪ installed; ceiling sums across both sets; import 400 names the missing face and the providing pack slug (fail-soft index lookup); catalog CI pinned curated-onlyThemeWriteGate(gate-identical by construction, 7-row parity table);imported_fromNULL; fail-closed 409 when a slug holds an imported theme;imported_atCASE fix;/api/fonts/vendored→/api/fonts/assignablerename (pre-ship)WHERE NOT EXISTS, no advisory pre-check); refusal names the referencing themes;/fontsstops serving next request; vendored untouched{slug, name}, F102.10a) pinned;GuardedRouteInspectorextraction✅ Gates
📎 Companion
🧾 Carry-forwards (known, not in this PR)
GET /api/fonts/assignabledepends on Postgres (union includes installed) — pickers degrade to empty with DB down; matches sibling routes' posture.🚦 Next
T210 🖐️ exit-demo (mix, save, activate on the demo box) closes F104 — after merge → release → pin →
--pinned.