Skip to content

feat(web): rebuild the dashboard with React and Fluent UI - #277

Open
Menci wants to merge 1067 commits into
mainfrom
fluent-dashboard
Open

feat(web): rebuild the dashboard with React and Fluent UI#277
Menci wants to merge 1067 commits into
mainfrom
fluent-dashboard

Conversation

@Menci

@Menci Menci commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • rebuild the dashboard as a React Router SPA on Vite 8 with Fluent UI, English and Simplified Chinese localization, official Segoe UI Web fonts, responsive navigation, accessible interactions, and a consistent compact table/action system
  • make route transitions atomic: loaders resolve all route resources before URL and component-tree commit, with no content-level loading state after bootstrap
  • opt every scrollable region into OverlayScrollbars, eliminate document scrolling, and keep navigation, request inspection, and the two-pane upstream workspace geometrically stable
  • rebuild upstream/model editing, lazy Monaco YAML editing, aliases, proxies, API keys, request dumps, Playground, Usage, Performance, Users, Search, backup/restore, and API Docs on shared Fluent patterns
  • share chart callout tables across Usage, Search Usage, and Performance; preserve exact time-range boundaries, edge labels, compact points, and dual TTFT/output-speed performance details
  • cover the complete public data-plane surface in API Docs from the same method/path manifest used by gateway registration
  • make control-plane response contracts gateway-owned and projection-specific, including upstream records, usage views, dump pagination, API keys, backup validation, and generation-safe sessions

Verification

  • pnpm run lint
  • pnpm run typecheck
  • pnpm run test — 429 files, 4,781 tests passed
  • pnpm run test:agent-setup-installers — 105 passed, 4 skipped
  • pnpm --filter @floway-dev/web run build — includes the Monaco lazy-chunk assertion
  • headless Chrome at 1600×1000 and 390×844: 13 primary dashboard routes opened without ErrorBoundary, Vite overlay, body overflow, or document-level horizontal scrolling
  • headless interaction checks: route-progress restart, model edit/detail/flags switching, manual-model add/delete, Monaco YAML persistence, Azure endpoint retention, dialog save/exit stability, proxy validation isolation, request inspection, chart callouts, and exact chart range boundaries

Menci added 19 commits August 1, 2026 05:31
The rows read the shared danger text class and the memo did not name it, so a
row rendered before the class resolved would hold the old value.
A mutation in this app disables what could start a second one and hides what
would be edited underneath it. Three places did not.

The proxy backoff reset had no in-flight state at all, so a second click posted
a second reset while the first was still going. The alias dialog's Add target
stayed live while every other control in the same form went disabled. The
retention field had no disabled prop to be given one, so both key retention
rows kept their combo box editable through a save.

Also makes the upstreams catalog warning dismissible, which its counterpart on
the aliases page already was. The two report the same condition and the page
already holds its whole loader payload in state and reseeds it on refresh, so
there was nothing to the difference but the bar not being asked.
The key dialog said "this retention does not parse" in three places: the
retention row re-parsed its own draft to decide whether to show a message,
the dialog schema re-tested the sentinel under a second wording that was
never rendered anywhere, and the submit handler opened with a guard that
the schema had already made unreachable.

The control already emits `'invalid'` when its draft stops parsing, so
that sentinel is now the whole of the rule: the row renders on it, the
schema refuses the submit on it under the string the row actually shows,
and the request body narrows it through an assertion rather than a silent
early return. The second wording is gone from both locales.
Three of the alias dialog's rules were stated and then never shown. The
per-target ones -- an empty model id, a fractional or negative reasoning
budget, adaptive combined with a fixed budget -- were attached to a target
path nothing read, and the announced-metadata ones to a path nothing read
either, so Save was live, blocked, and silent. The one metadata rule that
did render, the budget range, was written a second time inside the editor
and could drift from the schema's copy. The array-level "at least one
target" message carried no key at all and came out as zod's own English in
secondary text.

The rules now live once, in a validation module the dialog's schema and
the editors both read: a target renders its message on its own row, each
metadata number renders on its own field, and the array message is a real
string in the colour every other failure wears.
The proxy dialog stated its rules twice and the two copies disagreed. The
form derived a per-field flag for the name, URL, host, port, UUID, secret,
path, server name, and public key; the save handler re-listed only the
four fields every protocol has. A blank Shadowsocks password or a
malformed UUID therefore reddened its own field and went to the gateway
anyway, and the operator got a server rejection on top of an answer the
dialog had already given.

`proxyDraftIssues` now computes the whole set once. The form renders what
it is handed, the dialog decides when to hand it over -- still on the save
click, which the fields are there to answer -- and refuses the submit when
the set is non-empty. Test reads the three entries it shares rather than
keeping a third list.
The password form ran its schema twice: once through the resolver, which
renders each rule on the field that broke it, and again inside the route
action, which returned the same rule's message as an action failure and
put it in the page-level bar. The second copy was unreachable -- the
resolver gates the submit -- but it was the page's only other way to
describe a field, and it described it in the wrong place.

The action now does the call and nothing else, reading its three fields
strictly rather than defaulting a missing one to empty. What it reports is
the gateway's own prose, which is rendered as prose: passing it to `t`
made i18next read the first colon as a namespace separator and return the
tail of the sentence.
The custom-hex field reported a draft it could not parse in the tertiary
text colour, which is the colour a hint wears everywhere else in the
dashboard. The same draft is already refused in red by the editor section
behind the popover, so the two halves of one rule were speaking in
different voices; the field now uses the shared danger class every other
inline validation message uses.
Consolidating the copy button onto one outcome left three kinds of residue, all
of it created by that commit rather than found beside it.

The hook still returned copiedTag and copyFailedTag. Nothing outside the module
reads either any more -- the five destructures and two prop lists that did were
all rewritten -- so they are its backing store now, not its surface.

Two label families went with their last readers: dashboard.apiKeys.copy, whose
three render sites now resolve common.copy.*, and dashboard.requests.copied,
whose sole reader was the request detail's copy button. Both proven dead by
reading the render sites rather than grepping the literal, since i18next
resolves plural suffixes and computed paths a grep would miss.

And three shapes the same run left slightly wrong: mono-label handed back a
one-key sheet that all three callers immediately unwrapped, where the two other
shared class hooks this work added return the string; and MaskedIcon and
ScrollArea kept an empty-string className default that existed only to keep
undefined out of an interpolated string, which mergeClasses now drops itself.
Moving the bucket and series shapes into charts left four aliases behind --
UsageBucket, ChartEntry, PerformanceBucket, PerformanceChartEntry -- each of
which had been a real declaration and became a rename that carries nothing the
imported type does not. The concept spanned two modules for the sake of a name.

Consumers import ChartBucket and ChartSeries from where they live.
…source

A review of every factual claim the run added found seven false ones. Five are
about mechanism, and each was written confidently:

- The text control's padding and border are stated in the controls dictionary,
  as 10,5,6,6 over a 1px border, not the 10,3,6,6 over 2px in the framework's
  legacy generic.xaml. This comment has now been wrong twice in opposite ways --
  first that the keys were absent, then that they lived outside the corpus --
  and both times because nobody opened CommonStyles/Common_themeresources.xaml.
  The arithmetic follows: WinUI puts the two fields at 33 and 34, both above
  their floors, so the departure to 34 is a 1px unification rather than a 2px
  one and neither number comes from a floor.
- The radio dot's growth is a VisualState storyboard, and RadioButton declares
  no VisualTransition anywhere, so shipped WinUI keeps growing the dot with
  animations off. Our suppression is a departure, of the same kind as the
  expander's, and now says so.
- The scrollbar has exactly one VisualTransition, Expanded to Collapsed. Only
  the contract is gated; the expansion keeps running. Suppressing both is one
  more than WinUI, and suppressing only its half would leave a bar that grows
  smoothly and vanishes instantly.
- The combo box popup is reached by neither of the two gates this layer cites
  elsewhere: its storyboard is a VisualState's own, but SplitOpenThemeAnimation
  is a DynamicTimeline, so the VSM generates it in SteadyState mode instead.
- A saturated yellow does reach 4.5 against white by darkening; every hue
  reaches black. The hue that forces desaturation is a saturated blue on the
  dark card, which is 1.39:1 at full value with its channels already at their
  limit. The test asserted the wrong one of the two and passed only on a
  rounding accident.

Also: accordion and list declared their motion under no-preference while saying
it was the layer's own, which is the other shape index.ts describes -- and
Fluent ships no reduced-motion rule for either, where it does ship four each for
tabs and switch. They take the clamp now, so the discriminator index.ts states
is the one the layer follows.

Smaller corrections: the danger foreground reads 1.74:1 in light and 1.17:1 in
dark rather than 'roughly 1.9'; the badge card composites through the card fill
rather than the layer fill, which is why it lands on #373737; four names, not
five; the operator, not the user.
Round one of the review found them; each traces to a hunk from this session.

The request row's new hover fill erased the selection fill. Both classes land on
the same element, and a pseudo-class rule outranks a bare declaration, so
pointing at the selected request replaced its brand fill with a 3.5% wash and
made it read as deselected -- while the comment beside it claimed the opposite.
The selected fill states itself per state now, and mergeClasses puts it last.

The drop-down reveal clipped the popup's own elevation shadow. clip-path clips
every painted thing, and the shadow is declared on the element the animation
runs on, so it was suppressed for the whole 250ms and snapped in when the clip
was dropped. The keyframes end outside the border box instead.

The scrollbar track escaped the delay the same commit added to the thumb, so the
channel appeared instantly while the pill waited 400ms -- defeating, for half
the control, the reason the delay exists. WinUI begins the track's opacity at
the same instants it begins the thumb's size.

readableTone had two latent defects in its brightening direction. Its terminal
saturation test ran after the loop body rather than before, so the last pass ran
at a negative saturation, overflowed a channel past 255 and produced a
seven-digit hex that invalidates the whole declaration it lands in. And it chose
its direction by whether the surface was light, where the question is which
extreme actually clears the floor: between luminance 0.183 and 0.5 white misses
and black clears, so it searched the one direction that cannot arrive. Both are
covered by tests now.

And Save was dead on an untouched create form. The create loader opens on a
prefilled blueprint, so the form is clean at first render -- but the two
credential gates are submit-time schema issues, and Save was the only thing that
could raise them, so 'import a credential first' had become unreachable. The
no-op rule holds where the baseline is a saved record.
The search moves in two directions and gives up saturation in a loop, and its
last defect was an input escaping it malformed -- a negative saturation
overflowing a channel past 255 and producing a seven-digit hex. The property
worth asserting is that no input can: 216 hues against 18 grey surfaces, every
one a well-formed #RRGGBB.
…true

Both introduced by the repair that moved readableTone's terminal test into the
loop header. The Math.max(0, saturation) beneath it is the vestige of the test
that used to run after the body: the header now guarantees what it clamps, so it
is identity on every path -- instrumented, it fired zero times across 67 million
inputs -- and a guard that cannot fire suggests the invariant above it is not
trusted.

The fallback comment claimed it was reached only for a surface no foreground
clears. Both halves are false. Such a surface cannot exist, because the ratios a
surface gives black and white multiply to exactly 21, so the larger is never
below sqrt(21); and the line is reached routinely -- the saturation ladder steps
by a tenth and only lands on zero when the colour's own saturation is a multiple
of one, so for most hues it goes negative with the last candidate still short.

And the contrast figure named the wrong surface: a saturated blue reads 1.44:1
on its own chip, which is what the caller resolves against and what the test
asserts. 1.39 is its reading against the bare card.
Round two of the review.

Gating Save on the dirty flag fixed a no-op press and opened something worse.
The saving flag was cleared when the write returned, not when the save
finished -- and what follows a create is a navigation whose loader probes the
provider for its catalog, so the editor stays mounted and interactive for that
whole round trip with a form the dirty gate no longer covers. A second click
there posts a second create, and for the provider kinds with no credential gate
it succeeds. The flag now means what its name says: cleared on the error branch
and after an edit settles, left set through a create's hand-off, because the
editor unmounts when the target route commits.

The menu flyout clips its own elevation shadow exactly as the drop-down did, but
it cannot take the same fix: it travels as well as clips, and beyond the
travelling edge lies the element's own body rather than shadow, so a negative
inset there lets the surface overshoot its final position mid-reveal. Only the
three stationary edges go outside the box.

Two dead things: the alias dialog refused an empty target array under a message
no render path can produce -- the per-row delete is disabled on the sole row --
and the commit that added it added a locale key in both languages to serve it.
And the proxy dialog's save callback listed a dependency rebuilt on every
render, so its useCallback memoised nothing; it computes the draft's issues
itself now.

Five comments corrected: two still named the reduced-motion shape they had just
been moved out of, two asserted the legacy dictionary's values after their
citation was deleted, one leaned on a cross-reference to a rule that does not
exist and claimed upstream intent without a source, one explained half of the
mechanism it describes, and one stated a clearance as a round number without
saying what it clears.
Round three of the review. The 32px the two reveal clips leave outside their
border box was justified with two numbers that are not in the recipe they cite:
shadow16 is "0 0 2px ambient, 0 8px 16px key", so its key term reaches 16px
below the box and 8px to either side, and its ambient reaches 1px all round --
not 28 below and 20 sideways. The 28 belongs to shadow28. The literal is still
right, with room to spare; the reason for it was invented, and the permalink one
line down refutes it.

Two smaller ones from the same round. The proxy dialog's save callback still did
not memoise after being given its own issue computation: dialTimeout is a fresh
literal every render too, so the repair swapped one unstable dependency for
another. It reads the two values it needs. And the layer's one appeal to WCAG
now carries the clause it appeals to, like every other claim here.
Round four. The clearance comment stated a reach convention with no source and
then contradicted it in its own next sentence: under half-a-blur-radius,
shadow28 reaches 28px, which is less than the 32px literal, so "a deeper
elevation would need more" was refuted by the example given to support it.

CSS Backgrounds 3 settles it. A blur radius paints a transition roughly twice
its own length, centred on the offset edge, so a shadow spreads by about a full
radius: shadow16's key term reaches 24px below and 16px to either side, its
ambient 2px, and shadow28's key term 42px. 32px clears the first and not the
second, which is what the aside claims.

Both reveal comments now say it and cite the clause.
Both expanders could refuse a submit and put the reason behind a closed row. The
upstream access control opens itself when its switch is thrown, so the error is
normally on screen -- but an operator who collapses the row by hand and then
unchecks every upstream gets a live Save that refuses with nothing visible to
say why. The alias dialog's metadata section has the same shape.

SettingsExpander takes a revealOn flag, and it is deliberately one-directional:
rising, it opens the row, because the control that produced the message is in
there too; falling, it does nothing, because closing the row while someone is
still editing inside it takes their work off the screen mid-correction.

Both sites key it on the form's own error state rather than on whether the value
is currently invalid. A value is momentarily invalid while it is being typed,
and a row that threw itself open at every keystroke would be worse than the
thing this fixes; a refused submit is the moment the operator is owed an answer.
…bout

Two things about the warning bar, both visible the moment the dialog opens.

It said no target resolves to a model on this gateway, on a form that opens with
one blank target row -- true by construction, and reporting the starting state
as a fault. The warning waits until a target has actually been entered now;
entering one that does not resolve still raises it, and a blank row alongside a
real one no longer suppresses it.

And it drew a single warning as a one-item bullet list, which is more furniture
than one sentence carries. The lines are separate blocks instead, so several
still stack rather than running together.
Menci added 30 commits August 2, 2026 15:10
…freeze

danger.ts argued neutral-at-rest from taste -- "resident red would shout
from every row and break the rhythm of the list it sits in" -- for a rule
the operator actually decided: he asked for the delete icon to go red and
then settled it at hover. Stated as his; the mechanism note about
`data-fui-focus-visible` is unchanged.

The two playground opt-out notes described the frozen subtree as a fact
about how it was built. It is his instruction that the hand-designed chat
UI keeps its styling, which is what the boundary is for, so both notes
now say so.

text-input.css.ts described the red aria-invalid stroke as an affordance
WinUI lacks and left it at that, which reads as a settled departure. The
operator asked whether a mis-filled field should carry a red box at all
and has not answered; the comment now records the question as open.
…hat UI

The 2023 capture is the source of truth for this region and the operator
declared it frozen. Two rules depart from it and neither said so plainly.

The Balanced hover wash is transposed above the gradient because the
capture's own order paints nothing; that is argued from the source and
stays, but it is the one departure in these tokens and the operator was
shown it and did not rule, which the comment now records.

The composer's wash swap eases where the original steps. The rule read as
though it only borrowed the layer's fill duration; it also changes the
original's behaviour, and that second departure is now named as ours and
unruled. Neither value moves.
`ResourceListActions` declares `appearance?: 'secondary' | 'subtle'`, and all
three call sites pass `'subtle'`; its `onCreate` arm declares
`createTrailingIcon`, which only ever appears beside `createTrigger`
(`dashboard-providers-upstreams.tsx`), so that arm's copy is unexercised.
Both are type-only.

`defaultAgentSetupConfiguration(apiKeyId = '')` was passed an id by two
suites and by no production call: the card calls it bare and
`applyLocalAgentSetupChanges` stamps the real id later. The parameter goes
and the suites spread the id in themselves, which is what the production
path does.
The area-chart line reorder threw from inside a `requestAnimationFrame`
callback when a line had no parent. Nothing can catch that -- the frame runs
outside React, so it reaches `window.onerror` past every error boundary --
and the condition cannot arise, because `querySelectorAll` only returns
descendants of the mounted host. The reorder now appends through the
optional call the DOM type asks for and says why it never fires.

The backup import's `handleFile` started a `FileReader` per dropped file
with nothing stopping the previous one, so dropping a second file while the
first was still reading left two reads racing and the later-finishing one
won regardless of which file was dropped last. The reader is held in a ref,
aborted at the top of the handler and on unmount; `abort()` raises neither
`load` nor `error`, so a losing read reaches no state. Its confirm dialog
also stops passing the cancel label that `ConfirmDialog` already defaults to.
…espace

`model-info-badges.tsx` was generalised into `components/models/`, where its
neighbours serve four feature areas, but its twenty-odd keys stayed under
`dashboard.playground.badges.*`. The next surface that wants a badge row
either reaches into a playground namespace or forks the strings. The subtree
moves to `dashboard.models.badges.*` in the component, both locales and the
enum-coverage suite. Every rendered string is byte-identical.
The one-line module is the vendor's own documented Vite workaround, not an
accidental split, and carried nothing that said so. It now names the failure
it avoids and permalinks the README section that prescribes it.
Three suffixes carried one idea and one of them carried two. `-data.ts` meant
network I/O in `usage/usage-data.ts` and meant types plus URL state plus
query building in `performance/performance-data.ts`; `-model.ts` and
`-logic.ts` named modules that do the same kind of work as each other. The
directory name was also repeated in the file name, where the path already
says it.

One rule: `-data.ts` is a module that talks to the network, `types.ts` is
types, `*-styles.ts` is a shared `makeStyles` module, and everything else is
named for the job it does. Under it:

- `performance/performance-data.ts` -> `performance/overview.ts`. Every
  export builds the overview request or reads the overview response, which
  is what the name now says; it does no I/O, so it is not `-data`.
- `performance/performance-chart-model.ts` -> `performance/plot.ts`, and its
  `PerformanceChartModel` -> `PerformancePlot`, matching `usage/types.ts`'s
  existing `ChartPlot`.
- `usage/chart-model.ts` -> `usage/plot.ts`.
- `playground/playground-logic.ts` -> `playground/request.ts`, which is what
  its API list, model gates, custom-JSON merge, fetch wrapper and generation
  options all serve.

The three suites follow their modules. Renames only; no export moved between
modules and no body changed.
The card exported four helpers -- two snippet builders and two option
builders -- that nothing but its suite imported, while three helpers of the
same size sat in modules of their own. That is two conventions in one
directory, and a reader cannot predict which one a given helper follows.

Both snippet builders are pure functions over a configuration, so they join
the Codex credential snippets in `agent-setup.ts`, taking the attribution
constant only they use. The two option builders wrap `buildAgentModelOptions`
and join it in `agent-setup-models.ts`, so option building is one module.
The card is now a component file that exports a component.
Three modules export a helper their own file is the only caller of, so an
export-vs-reference scan reads them as dead surface. Each is reached from a
suite instead, and now says so at the export.
`PerformanceUrlState` is exported and the one consumer that needs it wrote
`ReturnType<typeof parsePerformanceUrlState>` instead, which names the parse
function to say the shape it returns. The route names the type.
The duration, request-size and response-size icons were 18px, set when the
text beside them was going to 14px. The text stayed at 12px, so the pairing
has read mismatched since: an icon half again the height of the numeral it
labels.

They take 16px, which is the step the row's own text sits under rather than
one chosen against a size the row never got.
A page kept on screen while it leaves was mounting again on the way out,
so it faded out showing the state it had when it was entered rather than
the state it was left in. On the API keys page that was visible as the
selected key snapping back, and it also persisted: the selection effect
re-ran with the reset value and rewrote localStorage.

The cause is the frame's element type. A frame renders its page bare
while it is current and wrapped in the frozen router contexts once it
leaves, and React reconciles by type at a position, so the whole page
unmounted and mounted again under the same key. Measured in Chrome: a
DOM node tagged before navigating away is gone from the leaving frame.

Every frame now goes through the same wrapper, the current one included,
which hands back the values already in scope and leaves the tree it
would be without them.
Nine buttons swap their icon for a Spinner while working. Fluent paints a
spinner's arc from colorBrandStroke1, which the layer maps to the WinUI accent
-- right for a ring standing on the page, wrong for one standing inside a
button. On the accent-filled Export and Import buttons that put a blue arc on
a blue fill beside a white label.

WinUI states ProgressRing's Foreground as a Style setter rather than a fixed
brush, so an instance overriding it is the specified behaviour, not a
departure. `currentColor` is that override written once: white on the accent
button, the neutral foreground on a secondary one, the disabled foreground
while the host is disabled. Naming a brush instead would have been correct for
one appearance and wrong for the others.

The track needed nothing -- it is already ControlFillColorTransparent.
Editing, rotating, deleting or copying an API key also selected that
key, which the Agent Setup card below the table then reconfigured
itself for.

Fluent raises a row's selection from a plain click: DataGridRow toggles
its row from the row's own onClick and ListItem triggers its action from
the item's, and a button inside either one carries its click up to them
-- through a portal too, since the narrow layout's menu popover is a
React child of the trigger it was opened from. Both keyboard paths went
the same way, because activating a button by Enter or Space dispatches
the click that bubbles.

The command cluster now stops the click where it was handled, as
settings-card.tsx's expander does with its action slot. The click only:
DataGridRow selects on Space just when the target is not interactive and
ListItem acts on a key only when it is the target itself, so keyboard
activation never selected on its own, and arrow keys have to keep
travelling for the grid's own cell navigation to answer them. Verified
in Chrome across both layouts: commands no longer select by click, Enter
or Space, while a click on the row body, Space on the radio and the same
pair on a narrow item still do, and arrow travel through the row is
unchanged.
The two modes sat side by side with a full sentence each, so the descriptions
met in the middle of the row and the trailing radio read as the last word of
the preceding sentence rather than as the start of its own option. They stack
now, which is what a two-line option wants.

The label pair was also its own invention: a semibold header over a tertiary
caption with 2px between them. WinUI's RadioButton states the content size and
states no weight, and ../components/ui/settings-card.tsx already builds this
exact pair from the toolkit -- body regular over the caption in the secondary
fill, no gap. The emphasis belongs in the fill step, not in a heavier face, and
there is no reason for the dashboard to carry two spellings of one idiom.
The form derives both outcomes during render: a 401 is the gateway
rejecting the credentials and lands on the password field, anything else
is the attempt failing and lands on the dismissible bar. A second effect
took the same response and pushed every failure onto the password field
as well, so a gateway fault painted the same words twice -- in the bar
and, one frame later, under the password input, where dismissing the bar
could not clear them. It also re-raised a field error the user had
already typed away whenever it re-ran against an unchanged response.

The render-phase split already feeds the field through credentialError,
so the effect is removed rather than narrowed.
… last pass left

Appending the lines in reverse document order is an involution rather
than a normalisation: the pass starts from the order its predecessor
produced, so a resize or a data reload swaps the lines end for end.
Wherever two boundaries coincide -- any bucket range where the upper
series contributes nothing -- the two 2px strokes overlap and the last
one painted wins, so the same data drew a differently coloured boundary
before and after a resize.

Sorting by the series index the path id carries reaches the same order
from any starting order, and that order is the one the first pass
already produced, so nothing on screen moves.
…e paint

The rate inputs are keyed by metric, so selecting another pricing rule
reuses the same input with a new value prop. Mirroring that prop into
the draft from an effect meant the commit that opened the new rule was
painted with the previous rule's numbers still in the fields, corrected
only on the following frame. Taking it during render collapses the two
into one commit.

The focus guard is unchanged and stays a ref: it answers a question
about the input the operator is in right now, and the render-phase
adoption is skipped for exactly as long as that is true.
A visit that could not load the key list erased the stored Agent Setup
selection. The loader answers with no id when the list did not arrive,
and the effect mirroring that state onto localStorage then removed the
entry -- so the selection was gone for good, including for the next
visit that loaded fine. Measured in Chrome against a failing GET
/api/keys: the entry was deleted, and it now survives and re-selects on
the following successful load.

The write moves to the selection itself. A mirror cannot tell a
selection the operator cleared from one the page could not resolve;
recording the act instead states which of the two happened. The reload
path clears the entry only for a key that a successful reload no longer
lists, which is a key that is gone rather than one that failed to
arrive.
The loop was started from the Connect handler and only torn down by an
effect, so a teardown that preserved state left the panel drawing a live
user code, its verification link and the waiting spinner with nothing
polling for the authorisation. Arming the first tick from `flow` puts
the loop where the UI that depends on it already is: whatever renders
the code schedules the tick, and the same effect stops it.

Each tick also re-entered the closure the flow started in, so every
request for the code's whole lifetime -- a quarter of an hour -- carried
the upstream as it read when Connect was clicked. An operator fixing the
proxy while the first attempts failed through the wrong one saw no
effect until they restarted the flow. The re-arm now goes through the
newest closure, so the request body is the upstream as it currently
reads.
The save debounce listed the lease and the draft objects among its
dependencies while reading each of them only as a presence, so every
heartbeat -- which adopts a freshly issued lease -- restarted the
window. An edit made shortly before a heartbeat therefore waited a
second full debounce for a reason that had nothing to do with what was
typed, and an operator editing across heartbeats could be deferred again
and again.

The effect now depends on whether a lease and a draft exist, and on the
generation counters, which are what say an edit happened. The save reads
`draftRef` and `leaseRef` when the timer fires, so nothing it sends
depended on those identities.
Fluent's single-select calls onOptionSelect for a click on the option
already selected, and both producers allocated unconditionally, so the
guard comparing filters by identity saw a new query where the operator
had picked the same one. Measured in Chrome against the performance
page: opening the Upstream filter and clicking the selected option
issued a second GET /api/performance/overview, which resets loadedAt and
shifts the chart's bucket axis; re-picking the current Group by did the
same and additionally cleared every series the operator had hidden. Both
now issue nothing, while a genuine change still refetches.

The no-op is recognised where the selection arrives rather than at the
guard, so nothing downstream has to tell a repeated pick from a new
one.
The zone's dashed stroke and its accent accepting state had no counterpart
in microsoft-ui-xaml or in the Community Toolkit -- neither ships a file
drop target, a drop-area brush, or a dashed border, and WinUI's only drag
affordance is the 0.80 opacity a ListViewItem takes while it is being
dragged. The sheet was therefore inventing a control rather than
transcribing one, and the file picker it fronted is rebuilt on a shape both
libraries do state.
Both monitor pages wrote their loaded-query ref before issuing the
fetch, so a run torn down before it answered was remembered as the data
on screen. The abort comes from useRefresh's teardown and the reload
returns without writing any state, so a setup that follows finds the ref
already matching and issues nothing: the controls read the new range
while the chart, its bucket axis and the tables still show the previous
query's data until the next minute's poll.

Measured in Chrome with the usage route wrapped in <Activity> for the
occasion: switching the range under a three-second latency and hiding
then re-showing the subtree while that fetch was open left the control
reading 7 Days with no further request; with the write moved next to the
state the same run commits, the fetch is re-issued on the way back in. A
plain range switch still costs exactly one request.
The toolkit's SettingsCard has a third variant this app did not carry: with
IsClickEnabled the card becomes the ButtonBase it already derives from, takes
the pointer ramp a plain row never wires up, and shows a 13px action glyph
held 14 clear of what precedes it. The toolkit means it for a row that opens
something, which is what a file picker is, so the variant is added beside the
other two rather than rebuilt per page.

Its pointer-over declarations are lifted out of the interactive sheet so the
clickable row can assert that state rather than match it: during an HTML drag
the user agent matches no :hover anywhere, and a row that accepts a drop has
to answer while the drag is over it. Reusing the state the card already has
for 'the pointer is on me' is what keeps the accepting state from being a
colour nobody sources.

SettingsSection is the group those rows are read in -- BodyStrong over the
rows, 6 below the heading and 4 between them, which is the Gallery's settings
section header over the spacing its own StackPanel states.
Every part of the page now comes from a row Windows already states.

Export is a section heading over one SettingsCard: the telemetry toggle as
its header and hint, a switch at the trailing edge, and the download beneath
as the page's primary action -- the same free-standing action row the search
provider page ends on.

Import is the same section holding two rows. Before a file is chosen the row
IS the picker, clickable and accepting a drop; the accepting state is the
card's own pointer-over rather than a dashed accent border, since neither
library ships a drop target to copy. Once a file is chosen the row reports it:
the file name and size as the header, what the file holds as the description,
and the picker handed to a standard button at the trailing edge -- the
appearance both the Gallery and the toolkit put in that slot for an action
that is not the page's primary one.

The two import modes are a picker in a row rather than a stack of radios with
hand-built two-line labels. That is how the Gallery's own settings page
answers Light / Dark / Use system setting, with the one description carried by
the card, so the card shows the chosen mode's sentence and 'Import Mode' is a
card header rather than a form label standing in for a heading.

The preview tiles go with it: the counts read as the same sentence the server
answers with after the import, so one function now writes both and the
operator is told what happened in the vocabulary they were shown.
This reverts commit a335526.

The mode has no call site, which is why a dead-code sweep took it. But it has
no call site on purpose: it was kept deliberately, over the same argument the
sweep made for removing it. A prop nothing passes yet is not the same thing as
a prop nothing wants.
…sits in"

This reverts commit c9b31c0e79c33ff87c6e5b3ac5a02c9e63c3fb45.

The settings row presents a setting -- a persisted piece of configuration
the system remembers. Backup and restore holds no such thing: export and
import are operations, and the toggle and the mode beside them are arguments
to a run that is about to happen, alive only while the form is on screen.
Dressing them as settings would tell the operator something false about what
they are looking at, so the row this added has no caller and goes with the
page that was going to be its only one.
Switching API keys re-renders the hook instead of unmounting it, so a
'load older' request issued for one key resolved into the next key's
list: its rows were appended under the new key's heading, where opening
one navigates to a record id the new key does not have and the detail
fetch 404s, and its setError/setHasOlder decided the new key's list too
-- a short stale page turned pagination off for a key that had more.

Measured in Chrome against two dump-enabled keys of 240 records each,
with the older-page request held open across the switch: the new key's
list came back holding 300 rows, 100 of them the other key's. It now
holds only its own, and pages on to all 240.

A generation is taken when the key's accumulation is discarded and read
back after the await, so the whole completion path is answered rather
than only the append -- an abort alone would not do, since callApi
reports a cancelled request as an ordinary failure and would paint 'The
operation was aborted' under the new key. The abort is kept for the
round trip it saves. The in-flight marker holds that generation instead
of a boolean, so a request left behind by another key cannot hold the
new key's pagination shut and a finished request releases only its own
claim.
Export and import are operations, not settings: nothing on the page is
remembered, and the toggle and the mode beside them are arguments to a run
that has not happened yet. So the page is shaped like the one other page here
whose content is a thing to be done -- a panel per operation, the parameters
above the command, the command against the trailing edge -- rather than like a
list of settings rows.

Every parameter is a Field with one control in it, which is how every other
parameter in this app is spelled, and which the WinUI layer already maps onto
a control's Header over its Description presenter. That gives 'Import Mode' a
real control header instead of a form label standing in for a heading, and it
gives each option's sentence a home the layer already sources. The choice
itself is ChoiceGroup, the SelectorBar this app already spells a two-way
choice with in the alias dialog and the key form, so the two options no longer
stack and there is no vertical gap left to get wrong. The telemetry parameter
is a check box, per Microsoft's own rule that a value applied by a separate
command is a check box rather than a switch.

The file zone stops inventing a control. Neither library ships a drop target,
so it is drawn as the bounded region inside a panel that ../ui/code-block.tsx
already had to answer -- the solid ramp's page-canvas step framed in
ControlStrokeColorDefault -- with the subtle button's ramp on it while it is
the picker, and the accepting state asserting the pointer-over wash rather
than an accent stroke nothing sources. Filled, the same region reports the
file name over what the file carries and keeps accepting a replacement, with
the standard button that re-opens the picker at its trailing edge.

The preview tiles go with it: the counts read as the same sentence the server
answers with after the import, so one function now writes both.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant