Skip to content

fix(ui): refresh stale lists, theme native controls, validate IPC payloads#27

Merged
chrystiamjr merged 3 commits into
mainfrom
fix/list-refresh-dark-controls
Jul 25, 2026
Merged

fix(ui): refresh stale lists, theme native controls, validate IPC payloads#27
chrystiamjr merged 3 commits into
mainfrom
fix/list-refresh-dark-controls

Conversation

@chrystiamjr

@chrystiamjr chrystiamjr commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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.msc stayed on screen. Root cause: src/App.tsx dispatched the GET_* 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.

  • DataTable gains an optional refresh control, so all three management screens get it at once.
  • Each screen re-queries its own data set on entry. Settings is deliberately excluded: it holds unsaved local edits a refetch would clobber.
  • Refresh is a read, so it uses handleAction and does not take the action lock — but it is disabled while a mutation is in flight.

Two placement details that are load-bearing:

  • The control sits above the scroll wrapper. Inside it, scrolling a narrow table sideways would carry the button out of view.
  • It renders outside the empty-state branch. An empty list is precisely when the host needs re-querying — deleting the last task in Windows leaves nothing to hang a control off.

The refetch is deliberately not hooked to subscribeActionFinished: the host emits onActionFinished for every action including the GET_* reads themselves (MainWindow.xaml.cs finally block), 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 declared color-scheme, and WebView2 is Chromium, so it painted every native control in its light default. Declared dark in index.css and 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

Select and FormField are documented in AGENTS.md but did not exist. The label pattern was repeated 5× in SchedulingPage and selectClass was a loose const, so the appearance-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 a radiogroup, which is not labelable, so it takes its accessible name from aria-label rather than <label for>.

Design token violations

Badge defaulted text to the hardcoded Portuguese 'Administrador Ativo' — dead code that bypassed i18n. Now required. Both Badge and Dot used the raw Tailwind palette (emerald/amber/rose/sky) and hardcoded hex glows instead of the design tokens, against the explicit rule in AGENTS.md. The glows are now boxShadow tokens. Expected visual change: the "Pronto" badge moves from emerald-400 to the project's success green, matching the Schedule button.

Found while testing

  • React.StrictMode double-invokes effects in dev, so bootstrap GET_* counts are doubled locally. Absolute IPC call counts are therefore not assertable — the new tests compare before/after deltas.
  • The i18n context recreated setLang on 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 run artifacts (cypress/screenshots/) were untracked and ungitignored. Added.

Verification

npm run validate    21 files, 132 tests green (from 108)
npx cypress run     78 e2e green (from 70)
npm run build       clean
dotnet build        clean

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 produces day: "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.parse returns any and 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 in src/domain/schemas.ts, with the interfaces in types.ts inferred from it, and bridge.ts validates with safeParse and 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 — which z.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: onActionFinished fires for the GET_* 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

npm run validate    164 unit tests green (from 108)
npx cypress run     80 e2e green (from 70)
npm run build       clean
dotnet build        clean

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>
@chrystiamjr chrystiamjr self-assigned this Jul 25, 2026
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 chrystiamjr changed the title fix(ui): refresh stale lists and theme the native form controls fix(ui): refresh stale lists, theme native controls, validate IPC payloads Jul 25, 2026
@chrystiamjr
chrystiamjr merged commit 03e5695 into main Jul 25, 2026
3 checks passed
@chrystiamjr
chrystiamjr deleted the fix/list-refresh-dark-controls branch July 25, 2026 04:48
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))
@chrystiamjr

Copy link
Copy Markdown
Owner Author

🎉 This PR is included in version 0.1.5 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant