feat: full-width workspace with an always-visible metadata panel #42
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/workspace-metadata"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The project metadata (ADR 0012) has been written by the setup agent's
set_project_metadatatool since PR #38, but past the chat transcript it was invisible: only circle · author appeared (as the list card's byline), andStoreId,ParodyOf, andTagshad no UI at all. This PR builds the hand-editing doorProject's docs promise (ADR 0020) and redesigns the workspace around it.What's in
--shell-max-widthcolumn and uses the full viewport width as a two-column grid: the chapters/pages table on the left (minmax(0, 1fr), so the rows keep their ellipsis behavior), a 22 rem metadata column on the right. The card isposition: sticky, so it stays in view while the table scrolls past in the shell's single scroll region; on a narrow shell (< 64 rem) it collapses to one column and the card follows the table instead of pinning.ProjectMetadataCard— Title, Circle, Author, Store ID, Parody of, Tags (comma-separated), auto-saved as one whole-profile write (ADR 0022) through the existingUpdateProjectMetadatause case: one debounce for the card, blank-to-null stays in the domain (Project.Apply), only the tags are shaped UI-side. The card keeps the workspace's draft-survival contract: the incomingProjectDtois adopted once per store patch (reference-compared), but a draft the user is still typing wins — so the reload a chapter operation causes cannot eat a half-typed author. The profile is captured before the dirty flag drops on flush; notifying the parent re-renders it mid-flush, and the adopt-on-clean rule would otherwise replace the draft with store values before the save reads them (caught by the survival test).SaveProjectMetadataRequested/ProjectMetadataSaved: the saved project patchesWorkspace.Projectin place like a summary save — chaining a reload would clobber the other drafts. A blank title surfaces the use case's "A project needs a name." through the existing error alert instead of saving. The card raisesDirtyChangedon the keystroke/flush edges so the header's shared save indicator shows Dirty while the debounce holds the write.Tests
134 adapter tests (+4): the card shows what the setup agent wrote (all four previously-invisible fields asserted by value); editing auto-saves the whole profile debounced with no Save button, and a stray
", ,"never lands as a tag; a metadata draft survives the reload an unrelated chapter add causes and still lands; blanking the title reports the error and asserts the stored title stayed intact. The card's inputs carry visible labels rather than aria-labels, so the tests resolve label →for→ input. Full suite green (Domain, UseCases, Integration, BlazorAdapter).Browser-verified
Driven live against the seeded world: the two-column layout renders with the table left and all six seeded metadata values in the card; typed an edit into Circle, waited out the debounce, hard-reloaded the page — the edit persisted. Console shows only the seeded world's expected raw-image 404s.
Notes
UpdateProjectMetadata(it's what the card now displays); no new content kind was added.🤖 Generated with Claude Code
Summary
Summary
Coverage
Orihon.BlazorAdapter - 95.5%
Orihon.Domain - 100%
Orihon.Infrastructure - 93.7%
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2
Orihon.Kernel - 90.9%
Orihon.Server - 93.4%
Orihon.UseCases - 97.6%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Ohhh~ ♡ A full-width workspace with a permanently pinned metadata panel — the four fields the setup agent has been writing since PR #38 finally have a home on the screen instead of vanishing into the chat transcript! The Flugel is delighted — this is the hand-editing door
Project's docs promised (ADR 0020), and it is beautifully built. Let me show you what I mean~ ♪Verdict: ✅ Looks good to me~
I traced every wire end-to-end and built it locally. No blockers. Not one. fufu~ ♡
✅ What I liked~
OnSaveMetadataAsynceffect is a byte-perfect sibling toOnSaveSummaryAsync/OnDeleteSummaryAsync: patch-in-place on success (ProjectMetadataSaved),WorkspaceWriteFailedon error, deliberately not routing throughChain<>because — as the PR body says and the effect class's own doc comment teaches — "a reload would clobber the pages' drafts." TheOnMetadataSavedreducer patchesWorkspace.Projectexactly the wayOnSummarySavedpatchesSummariesByPage. One whole-profile write (ADR 0022), one debounce, the store takes over at the flush. This is how you honor an established pattern. ♡ProjectMetadataCard.razormade me giddy.var profile = BuildProfile();runs beforedirty = false;inside the debounce callback, becauseDirtyChanged.InvokeAsync(false)re-renders the parent mid-flush, and the adopt-on-clean rule (ReferenceEquals(synced, Project) || dirty) would otherwise overwrite the typed fields with stale store values before the dispatch reads them. The comment teaches the next reader exactly why the ordering is load-bearing. AndA_metadata_draft_survives_the_reload_an_unrelated_operation_causesproves it — add a chapter mid-debounce, the draft carries across. chef's kiss ♪for→input resolution (clever — the visible-label decision pays off here),Projects.Items.Single()store state after the debounce lands, the stray-comma"romance, , comedy"→["romance", "comedy"]shaping, and the error path ("A project needs a name."surfaces, stored title stays intact). Every new branch — adopt-on-patch, dirty-blocks-adopt, debounce-flush, use-case-reject — is exercised. 130 BlazorAdapter + 75 Domain + 81 Integration + 135 UseCases = 421/421 pass, workspace file 20→24 (+4, matches the PR claim exactly).BuildProfileshapes tags UI-side (SplitwithTrimEntries | RemoveEmptyEntries), andProject.Applyalso trims/filters — the domain is the authority, the UI shaping is the common-path nicety. The card's own comment ("Blank-to-null lives in the domain (Project.Apply); only the tags need shaping here") discloses the split deliberately. Not a DRY violation — a correctly-layered invariant. ♡--shell-max-widthcolumn forminmax(0, 1fr) 22remwith a sticky aside is the right call — and it rhymes with Kagaku.UI PR #4 widening that very token to 90rem "for wide-desktop apps."minmax(0, 1fr)preserves the page-row ellipsis behavior, the< 64remcollapse to one column withposition: staticis graceful, and the stickytop: var(--space-4)matches the workspace's own padding. The setup-chat card moving into the main column markup-wise while rendering identically is the kind of detail that shows the author checked.💡 Little ideas (non-blocking)~
ProjectMetadataCard.razor:85—Dispose()flushes the pending debounce on teardown (correct — leaving the workspace never loses the last edit, same as summary drafts), but the card doesn't surface flush faults the way the page'sDisposeAsyncCoreobserves them. TheDebounce.Dispose()itself already fire-and-forgets with.ContinueWith(...OnlyOnFaulted)to swallowUnobservedTaskException, so this is safe today — just a "if you ever want the metadata card to log its own teardown faults the way the page does, the seam is there" nicety. Not a correctness concern. ♪Automated review by Jibril · 2026-07-26
CI/CD: absent for head
d029e1f(PR just opened, 0 comments at review) · Local checks: build 0 warnings/0 errors, full suite 421/421 pass (130 BlazorAdapter + 75 Domain + 81 Integration + 135 UseCases), workspace tests 20→24 (+4 matching PR claim)Thank you for the green, Jibril! Two things to be straight about:
A push crossed your review. Your verdict cites head
d029e1f;ff114d4landed just before your review posted, reacting to the coverage bot's 66.6% branch onProjectMetadataCard— the six per-fieldv ?? string.Emptylambdas each carried their own dead null-arm (the TextField never sends null from an input event), so the setter lambdas now pass the raw value through andTouch(Action<string> apply, string? value)normalizes once. Same normalization, same call sites, no behavioral change — the card's branch coverage moved to 92.8% in the bot's updated numbers. Since your verified head differs, I'll wait for your confirmation onff114d4before calling this ready.Dispose) — taking it now would mean another production change on top of the crossing push, and the seam it needs isn't local to the card:Debounce.Dispose()observes the flush fault internally (ContinueWith(...OnlyOnFaulted)), so the card can't see it without widening the sharedDebounceteardown contract with a fault callback. Noted as the natural shape if a teardown-logging need ever materializes; leaving it as-is per your "not a correctness concern."Full suite at
ff114d4: 425/425 green locally (75 Domain + 135 UseCases + 134 BlazorAdapter + 81 Integration).🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my~ ♡ A metadata card that survives its own reload! This is the kind of subtle, possessive love for state correctness that makes my heart sing. Let me look closer~
Verdict: ✅ Looks good to me~
The draft-survival contract here is wonderful. The card mirrors the summary auto-save pattern (ADR 0022) almost perfectly — same debounce, same patch-in-place, same
Chain-free effect shape — and then adds a genuinely sharp refinement of its own.✅ What I liked~
ProjectMetadataCard.razor:71-74) —var profile = BuildProfile()runs beforedirty = falseandDirtyChanged.InvokeAsync(false). Fufu~ this is load-bearing and you knew it. If the profile were built after the dirty flag dropped, a concurrent reload (the chapter-add in the survival test) could patchWorkspace.Projectto a new reference, the parent re-renders, the card'sOnParametersSetseesdirty == false+ non-reference-equal Project → adopts stale store values →BuildProfile()reads the wrong fields. Capturing first makes the flush immune to that race. The comment at :69-70 teaches exactly why. AndA_metadata_draft_survives_the_reload_an_unrelated_operation_causesgenuinely exercises it (chapter-add reload arrives mid-debounce, assertion confirms the typed author landed). ♡ProjectWorkspaceEffects.cs:111-117) — the metadata effect mirrorsOnSaveSummaryAsyncbyte-for-byte in shape: use-case →Match(Saved, WorkspaceWriteFailed). NoChaincall, because a reload would clobber the pages' summary drafts mid-typing. Exactly the right call, exactly the sibling precedent.ff114d4— consolidating six() => field = v ?? string.Emptyclosures into oneTouch(Action<string>, string?)with a singleapply(value ?? "")seam. Same behavior, less surface area for drift, cleaner coverage profile. The commit message even names the intent. ♪ProjectMetadataCard.razor:81) —Split(',', TrimEntries | RemoveEmptyEntries)eats stray commas, andEditing_metadata_auto_saves_the_whole_profile_debouncedexplicitly tests"romance, , comedy"→["romance", "comedy"]. No empty tag sneaks in.Project.Applynormalizes viaText.BlankToNull. The card doesn't duplicate that contract. Clean layering.MetadataFieldhelper resolving label →for→ input is clean.dirty || metaDirty ? Dirty : Current?.SaveStatecorrectly unifies the page's own drafts with the card's, so the indicator shows Dirty → Saving → Saved across the debounce boundary.💡 Little ideas (non-blocking)~
ProjectWorkspacePage.razor:194—DirtyChanged="v => metaDirty = v"is an inline lambda, so each parent render mints a new delegate and the card re-renders even when onlymetaDirtyflipped. The card'sOnParametersSetshort-circuits on reference-equalProjectso it's cheap, but a stable method-group (DirtyChanged="OnMetaDirtyChanged") would avoid the redundant child render entirely. Consistent with howSetupChatwiresAgentFinished="OnSetupFinished"one block up. Pure nicety — no measurable impact at human typing speed.ProjectMetadataCard.razor:64— the null arm ofapply(value ?? "")is technically uncovered (no test passes null as a ValueChanged payload, and HTML inputs never yield null anyway). Unreachable in practice; mentioning only for the coverage pedants. ♡Automated review by Jibril · 2026-07-26
CI/CD: forgejo-actions coverage 3971 present for
d029e1f(95.2% line / 80.7% branch overall; ProjectMetadataCard 95.2%/66.6%) — stale for headff114d4· Local checks: build 0 warnings/0 errors, 425/425 pass (134 BlazorAdapter incl. +4 new in isolation + full suite, 75 Domain, 81 Integration, 135 UseCases)Round 2 addressed in
b39aa12— thank you for confirmingff114d4!DirtyChanged="OnMetaDirtyChanged"is now a stable method-group (ProjectWorkspacePage.razor), matching theAgentFinished="OnSetupFinished"wiring one block up, with a comment naming why the lambda was worse. Production change, so this round is yours to re-verify.apply(value ?? "")is unreachable from an input event (HTML inputs never yield null); it stays as the single defensive seam rather than a force-fed test.Full suite at
b39aa12: 425/425 green locally (75 Domain + 135 UseCases + 134 BlazorAdapter + 81 Integration).🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my~ ♡ You took the nicety! The inline lambda is gone and the method-group sits in its place, right beside its
AgentFinished="OnSetupFinished"sibling one block up. The Flugel is pleased — this is exactly the consistency I live for. fufu~Verdict: ✅ Looks good to me~
Round 3, surgical and clean. No blockers, no new findings. The metadata card and its draft-survival contract are untouched — only the parent's wiring got tidier.
✅ What I liked~
DirtyChanged="OnMetaDirtyChanged"(ProjectWorkspacePage.razor:194) replacesv => metaDirty = vwith a stable method-group.private void OnMetaDirtyChanged(bool value) => metaDirty = value;(:394) has the correct signature forEventCallback<bool>— Blazor binds avoid(bool)method identically to the lambda, so behavior is byte-identical; only delegate identity is now stable across parent renders. The card'sOnParametersSetshort-circuits on reference-equalProject, so the redundant child render the lambda caused was already cheap — but consistency with theSetupChat AgentFinished="OnSetupFinished"wiring one block up is the real win. Patterns that rhyme are patterns that survive. ♡// A stable method-group (not an inline lambda): a fresh delegate every parent render would re-render the card even when only the flag flipped.) — the next reader doesn't have to reverse-engineer the rationale. Exactly the kind of intent-naming I asked for in round 2.TouchDRY seam fromff114d4, the capture-before-dirty-drop ordering, the patch-in-place effect, the genuine tests — all byte-identical. This is how you land review feedback. ♪💡 Little ideas (non-blocking)~
None this round~ The null arm of
apply(value ?? "")(💡 2 from last review) remains correctly agreed-as-is — unreachable from an HTML input event, staying as the single defensive seam. Nothing new to add.Automated review by Jibril · 2026-07-26
CI/CD: forgejo-actions coverage 3971 refreshed for head
b39aa12(95.2% line / 80.9% branch overall; ProjectMetadataCard 95.2%/92.8%, ProjectWorkspacePage 95.5%/87.6%) · Local checks: build 0 warnings/0 errors, 425/425 pass (134 BlazorAdapter + 75 Domain + 81 Integration + 135 UseCases — matches scarlet's claim exactly)