fix(ui): refresh stale lists, theme native controls, validate IPC payloads#27
Merged
Conversation
A task deleted in taskschd.msc stayed on screen: the GET_* actions were only dispatched on mount, with no polling, no refetch on navigation and none after a mutation. The data was always read live from Windows, but the UI never asked again. Add a refresh control to DataTable and re-query each screen's data set on entry. Refresh is a read, so it does not take the action lock. The refresh control sits above the scroll wrapper, so scrolling a narrow table sideways cannot carry it out of view, and outside the empty-state branch, since an empty list is exactly when the host needs re-querying. The time field's clock indicator rendered black and select popups opened white because the app never declared color-scheme; WebView2 is Chromium and painted every native control in its light default. Declare it dark in CSS and in the document head for first paint. Extract Select and FormField, which AGENTS.md documents but the repo lacked, so appearance-none plus the inset chevron is defined once instead of copied across four controls. Replace the 31-option day-of-month select with a calendar grid. Badge no longer defaults text to a hardcoded Portuguese string, and Badge and Dot now use design tokens instead of the raw Tailwind palette and hex glows. Memoize the i18n context so switching language no longer re-runs the bootstrap effect and re-requests every data set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
> **Stacked on #27** (`fix/list-refresh-dark-controls`) — merge that one first. ## Problem Every inbound IPC channel did an unchecked cast: ```ts const data: ScheduledTaskItem[] = typeof tasksJson === 'string' ? JSON.parse(tasksJson) : tasksJson; ``` `JSON.parse` returns `any` and TypeScript types are erased at runtime, so this is a promise the compiler cannot keep. The host serializes its own records and injects them straight into the page (`MainWindow.xaml.cs` → `EvalJs`). The only guard in the whole file was `info && info.version`. If `TaskInfo`, `BackupInfo` or the restore-point shape changed on the C# side, **nothing failed loudly** — the table rendered wrong or blank with no diagnostic. This is the failure class `.agents/rules/fullstack-sync.md` exists to prevent, and one this codebase has already hit twice (the localized `State` column, the culture-formatted sizes). ## What changed - **`src/domain/schemas.ts`** — each shape defined once as a Zod schema, with the types in `types.ts` inferred from it via `z.infer` and re-exported. A schema and its interface can no longer drift. - **`bridge.ts`** — casts replaced with `safeParse`. Rejected payloads log which fields failed. Collections fall back to `[]`; **settings keep app defaults** instead of receiving an empty object that would blank every preference. - Objects are non-strict, so a field the host adds later is stripped rather than rejected. - Deliberate strictness calls: `AppSettings.language` is `z.enum(['pt_BR','en_US'])` because an unknown locale leaves the UI untranslated, while `ScheduledTaskItem.State` stays `z.string()` since `describeTaskState` already handles unknown values. ### One implementation trap worth noting JSON unwrapping is a **separate helper** from plain validation. An earlier version unwrapped every string, which broke the log channel: the severity arrives as a bare `'success'`, and `JSON.parse('success')` throws. The bridge test caught it. ## On React Hook Form Considered and rejected. The app's only form has closed-set fields (you cannot select an invalid `<select>` option), no free-text inputs anywhere, no cross-field rules beyond conditional rendering, no async validation and no error state to render. A resolver would add a dependency and indirection with no rule to enforce. The real unguarded boundary was the IPC layer, which is what this PR addresses. Worth revisiting if a form with free text or cross-field rules appears. ## Verification ``` npm run validate 22 files, 164 tests green (from 132) npx cypress run 80 e2e green npm run build clean ``` Per `.agents/rules/falsifiable-test-guards.md`, **every one of the 13 schema fields was individually probed** by relaxing it to `z.any()` and confirming a test fails. That surfaced four real coverage gaps in my own first draft — `Size`, `Description`, `Path` and `url` were only covered by "field missing" cases, which `z.any()` accepts, so the type was never actually asserted. Per-field wrong-type cases were added and all 13 now fail when relaxed. The e2e suite gained the case that motivated the change: a **well-formed payload of the wrong shape**, which previously sailed through the cast and only surfaced as a wrong table. ## Manual check Open the app and confirm all three lists still load. A `[IPC Bridge]` console error during normal use would mean a schema is wrong, not the host. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The stale-list report had an architectural cause worth recording: when Windows owns the data and the host only pushes on request, freshness is the frontend's job. The rule fixes the two traps found while implementing it — onActionFinished fires for the GET_* reads themselves, so refetching there loops, and a refresh is a read that must not take the action lock. The black clock icon and the white select popup shared one cause, an undeclared color-scheme. Recording it prevents the wrong conclusion that was nearly drawn: that the native time picker was broken and needed replacing, when it only needed theming. Extend falsifiable-test-guards with per-field probing. Applying the existing "prove it fails" requirement to a schema as a unit passes trivially, and a "field missing" case does not test that field's type because z.any() accepts undefined — four fields looked covered and were not. Also record that StrictMode double-invokes effects, so IPC call counts must be compared as deltas. Add the learning record for validating at the untrusted boundary rather than at a form whose fields are already closed sets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chrystiamjr
pushed a commit
that referenced
this pull request
Jul 25, 2026
## [0.1.5](v0.1.4...v0.1.5) (2026-07-25) ### Bug Fixes * **ui:** refresh stale lists, theme native controls, validate IPC payloads ([#27](#27)) ([03e5695](03e5695))
Owner
Author
|
🎉 This PR is included in version 0.1.5 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Fixes everything reported while testing 0.1.4 in the real app. The multi-schedule feature itself was confirmed working — two Quick/Daily tasks at 03:00 and 05:00 coexist.
Stale lists
A task deleted in
taskschd.mscstayed on screen. Root cause:src/App.tsxdispatched theGET_*actions only on mount — no polling, no refetch on navigation, none after a mutation. The data is always read live from Windows (there is no sidecar index), but the UI never asked again.DataTablegains an optional refresh control, so all three management screens get it at once.handleActionand does not take the action lock — but it is disabled while a mutation is in flight.Two placement details that are load-bearing:
The refetch is deliberately not hooked to
subscribeActionFinished: the host emitsonActionFinishedfor every action including theGET_*reads themselves (MainWindow.xaml.csfinallyblock), which would loop forever.Native controls painted light
The clock indicator on the time field rendered black and
<select>popups opened white on dark fields. The app never declaredcolor-scheme, and WebView2 is Chromium, so it painted every native control in its light default. Declared dark inindex.cssand in the document head so the first paint is already correct.With that fixed the native time picker opens dark and legible, so it is kept rather than replaced.
Atomic Design gaps
SelectandFormFieldare documented inAGENTS.mdbut did not exist. The label pattern was repeated 5× inSchedulingPageandselectClasswas a loose const, so theappearance-none+ inset chevron fix would have been copy-pasted across four controls. Extracted both.The 31-option day-of-month
<select>is replaced by a calendar grid — the one control where the native element is genuinely worse than a custom one. It is aradiogroup, which is not labelable, so it takes its accessible name fromaria-labelrather than<label for>.Design token violations
Badgedefaultedtextto the hardcoded Portuguese'Administrador Ativo'— dead code that bypassed i18n. Now required. BothBadgeandDotused the raw Tailwind palette (emerald/amber/rose/sky) and hardcoded hex glows instead of the design tokens, against the explicit rule inAGENTS.md. The glows are nowboxShadowtokens. Expected visual change: the "Pronto" badge moves fromemerald-400to the project'ssuccessgreen, matching the Schedule button.Found while testing
React.StrictModedouble-invokes effects in dev, so bootstrapGET_*counts are doubled locally. Absolute IPC call counts are therefore not assertable — the new tests compare before/after deltas.setLangon every render, so the bootstrap effect (which lists it as a dependency) re-ran and re-requested every data set whenever the language changed. Memoized. This was real in production, independent of StrictMode.cypress/screenshots/) were untracked and ungitignored. Added.Verification
Both new guards were proven falsifiable: emptying
SCREEN_REFRESH_ACTIONS[Scheduling]fails the navigation test, and hiding the refresh control fails 4 DataTable tests.Manual check
Reproduce the original report: schedule two tasks, delete one in
taskschd.msc, click Atualizar → the row disappears; leaving and re-entering the screen does it without a click. Then confirm the chevrons are inset, the clock icon is white, the time picker opens dark, and the monthly grid producesday: "15".🤖 Generated with Claude Code
Also in this PR
IPC payload validation (was #28, merged into this branch)
Every inbound channel did an unchecked cast —
JSON.parsereturnsanyand TS types are erased at runtime, so the host could change a shape and the table would render blank with no diagnostic. Each shape is now a Zod schema insrc/domain/schemas.ts, with the interfaces intypes.tsinferred from it, andbridge.tsvalidates withsafeParseand logs the failing fields. Collections fall back to[]; settings keep app defaults.React Hook Form was considered and declined: the only form has closed-set fields with nothing to validate. The full rationale is in learning record 0003.
All 13 schema fields were individually probed by relaxing each to
z.any(). That surfaced four fields (Size,Description,Path,url) that only had "field missing" cases — whichz.any()accepts, so the type was never asserted. Fixed.Learned rules
Three artifacts capturing what this work taught, indexed in
AGENTS.md(now 11 rules):os-backed-list-freshness.md— the stale-list report had an architectural cause, plus the two traps hit while fixing it:onActionFinishedfires for theGET_*reads themselves so refetching there loops, and a refresh is a read that must not take the action lock.webview2-native-control-theming.md— records that the black clock icon and white select popup shared one cause, to prevent the wrong conclusion that was nearly drawn: replacing a native picker that only needed theming.falsifiable-test-guards.md(extended) — per-field probing, why a "field missing" case does not test that field's type, and why StrictMode makes absolute IPC call counts unassertable.Combined verification