Skip to content

Finish the brand migration, and give the composer a focus ring - #155

Merged
GuyNachshon merged 5 commits into
mainfrom
guy/ui-brand-tokens
Aug 23, 2026
Merged

GuyNachshon merged 5 commits into
mainfrom
guy/ui-brand-tokens

Conversation

@GuyNachshon

Copy link
Copy Markdown

A design pass over workshop-frontend. Three changes, all of them measured defects rather than taste, plus the PRODUCT.md / DESIGN.md that record why.

1. The brand migration was half-finished

The fork re-themed the light-mode primary button to green and stopped. What was left behind:

before after
light brand → brand-hover #2d6a4f → #e03f00, a 137° hue swap green→orange same hue, darkens. 0°
that hover, white text 4.30:1 — under the 4.5 AA floor 9.50:1
selected text #ff500a on #ffe9e0 = 2.81:1 8.66:1
dark theme never migrated at all: #b84e00, #ff8a5c FieldOS green, 5.29 / 6.74:1

Six more orange survivors (accent-200, selection-*, shadow-accent-*) went with them. All values are OKLCH at hue 152, matching how the dark palette was already written.

Two details worth seeing rather than reading:

  • The pale orange --color-selection-bg is also the composer's focus ring. Focusing the most-used control drew a green border inside a pink halo. The token name gives no hint it is a focus ring; rendering it side by side is what made it obvious.
  • The file's own comment said the accent was orange while the token directly beneath it was green. The comment, the hover, and the entire dark theme all still described the pre-fork design.

2. The composer had no keyboard focus indicator

Measured on the live deployment: computed styles were byte-identical resting, after click, and after tabbing to it, while an injected control button under the same probe went none → auto 1px. The probe could see focus rings; there was none here to see.

The card did have a :focus-within rule, but it only deepened an ambient drop shadow from 28px to 34px blur. That is an elevation cue, not a focus cue.

Two corrections to my own earlier readings, both caught by the control: the rest of the app is fine (14 of 14 real tab stops paint an indicator), and a probe using .click() sees no ring anywhere because click sets :focus without :focus-visible. The app is correctly keyboard-gated; an earlier "no focus indicators" reading was the probe, not the app. The new rule uses :has(:focus-visible) to match.

Also deletes .prompt-input — the rule that was presumably meant to do this job. It sets exactly the right border and ring on :focus-within, and is applied to no element anywhere in the codebase.

3. The broken-gadget state looked like a waiting state

Reviewing my own change from earlier today. The two empty states got different words and identical visual weight: the same sparkle and accent glow announced both "no UI yet" and "this gadget renders nothing" — a weaker version of the exact bug that pane exists to break. It also put a sparkle on an error, in a product whose anti-reference is the AI-chat idiom sparkles belong to.

Now: Warning (the icon already used in 17 other places) on warning-tint, no glow. Icon contrast 5.38:1 light / 8.79:1 dark, against a 3:1 bar.

Context files

PRODUCT.md and DESIGN.md, written via /impeccable teach by reading styles.css rather than proposing something new. The load-bearing part is a constraint upstream states outright and the design has to hold: the same screen serves sales through engineering, so neither "simplify for beginners" nor "dense tool for experts" is available.

Verification

pnpm lint 0 · pnpm test 0 (15 suites) · frontend build + airgap check pass · GadgetUI suite 15/15.

Every contrast figure above is computed, not estimated. The before/after and both empty states were rendered in Chrome and looked at.

The fork re-themed the light primary button to green and stopped there,
which left three measured defects rather than a matter of taste.

The hover was still Cloudflare orange: touching the primary CTA swung
the hue 137 degrees, green to orange, and landed on 4.30:1 against white
text -- under the AA floor, at the exact moment of interaction. Dark
mode shifts 1 degree, which is what correct looks like, because dark
mode was never migrated at all and is internally consistent orange.

Selected text was 2.81:1 (#ff500a on #ffe9e0), and that same pale orange
is the composer's focus ring, so focusing the most-used control in the
product drew a green border inside a pink halo. Rendering it side by
side is what made that obvious; the token name gives no hint it is also
a focus ring.

Both themes now carry one FieldOS green at hue 152, expressed in OKLCH
like the rest of the dark palette already was. Hover darkens instead of
changing hue, in both themes. Every value verified: 7.52 / 9.50 light,
5.29 / 6.74 dark on white text, 8.66 for selected text -- all above 4.5.
The file's own comment claimed the accent was orange while the token
beneath it was green; it now says green.

Adds PRODUCT.md and DESIGN.md, which record why these are defects rather
than preferences, and the two-audiences constraint that upstream states
outright: sales through engineering, on the same screen.
Measured on the live deployment: the composer textarea carries
outline-none and nothing in its ancestor chain replaced it, so the
product's primary input painted nothing a keyboard user could act on.
Computed styles were byte-identical resting, after click, and after
tabbing to it, while an injected control button under the same probe
went from `none` to `auto 1px` -- so the probe could see focus rings,
and there was none here to see.

The card did have a :focus-within rule, but it only deepened an ambient
drop shadow from 28px to 34px blur. That is an elevation cue, not a
focus cue, and it is why a computed-style diff registered nothing.

Two corrections to earlier readings of mine, both from the control. The
rest of the app is fine: 14 of 14 real tab stops paint an indicator. And
a probe using .click() sees no ring anywhere, because click sets :focus
without :focus-visible -- the app is correctly keyboard-gated, and an
earlier "no focus indicators" reading was the probe, not the app. The
new rule uses :has(:focus-visible) to match that behaviour rather than
lighting up on every mouse click.

Also deletes .prompt-input, which is the rule that was presumably meant
to do this. It sets border-color and a 3px ring on focus-within and is
applied to no element anywhere in the codebase.
Reviewing my own change from earlier today against the anti-references
just written into PRODUCT.md. The two empty states got different words
and identical visual weight: the same sparkle icon and the same accent
glow announced both "the agent has not built a UI yet" and "this gadget
renders nothing".

That is a weaker version of the bug the pane exists to break. The whole
point was that a waiting state and a defect must be distinguishable, and
they still were not -- at a glance, across a room, only by reading.

It also put a sparkle on an error, in a product whose stated
anti-reference is the AI-chat idiom that sparkle belongs to.

The problem tone now uses the Warning icon this codebase already uses in
17 other places, on warning-tint, and drops the glow. Icon contrast
measured on its own background: 5.38:1 light, 8.79:1 dark, against a
3:1 bar for non-text.
A ratio without its inputs is the same defect as a count without its
scope, and this file now carries several ratios that someone will
re-derive later.

States the formula, which foreground/background pair backs each claim,
which threshold applies and why the icon is judged at 3:1 rather than
4.5:1 -- and, importantly, what was not measured. Neutral body text and
the inherited status colors carry no number here, and absence of a
number is not a pass.
Verified end to end on the live deployment. Adding a model at
/providers pre-fills the API URL with http://localhost:11434 -- an
actual value, not placeholder text -- and addModel stores it without
ever contacting it. Submitting the default unchanged produced "AI model
added successfully", no failure, and a listed, selectable model
indistinguishable from a working one (#156).

The distinction that matters: a placeholder is a suggestion you must
replace, while a pre-filled value is a form that already looks complete,
so the natural action is to submit it. On this deployment the
OpenAI-compatible provider is the only option, so every user takes this
path, and it is the first thing a new user does.

Removing the pre-fill does not add a failure mode -- it activates one
that was already written. validate() at line 211 requires a non-empty
apiUrl for this provider and says "Please enter the server URL"; that
branch was unreachable while the field was always populated. The
placeholder still carries the hint.

Also fixes the doc that sends people to a 404: trying-it-out.md's first
precondition says "Settings -> AI models", and there is no /settings
route. It is /providers, confirmed by navigating to both.

Reachability checking at add time is the other half and is left to #156,
since whether an unreachable endpoint should warn or block is a product
question on an airgapped install.
@GuyNachshon

Copy link
Copy Markdown
Author

Two more, found by driving the model-setup flow

Adding an unreachable model reports success. Verified end to end on the live deployment:

apiUrl PREFILLED WITH: "http://localhost:11434"
SUCCESS TOAST?  YES     ("AI model added successfully")
FAILURE SHOWN?  no
NOW LISTED?     YES

AddModelModal.tsx:177 pre-fills the API URL with http://localhost:11434 — an actual value, not placeholder text — and user.ts:691 addModel() validates authorization then goes straight to storage.aiModels.put(). The endpoint is never contacted. On this deployment the OpenAI-compatible provider is the only option, so every user takes this path, and it is the first thing a new user does.

The distinction that matters: a placeholder is a suggestion you must replace; a pre-filled value is a form that already looks complete, so the natural action is to submit it.

Removing the pre-fill does not add a failure mode — it activates one already written. validate() at line 211 requires a non-empty apiUrl for this provider and says "Please enter the server URL". That branch was unreachable while the field was always populated. Verified across four inputs; the placeholder still carries the hint.

Reachability checking is the other half and is deliberately left to #156: whether an unreachable endpoint should warn or hard-block is a product question on an airgapped install, where the address may be legitimately down at setup time.

docs/trying-it-out.md sent users to a 404. Its first precondition read "A model must be configured. Settings → AI models". There is no /settings route — it renders "Not Found". The page is /providers. Confirmed by navigating to both.

Also recorded, not fixed

DESIGN.md now states how every contrast number here was derived — the formula, which foreground/background pair backs each claim, which threshold and why the icon is judged at 3:1 rather than 4.5:1, and explicitly what was not measured (neutral body text, the inherited status colors). A ratio without its inputs is the same defect as a count without its scope, and absence of a number is not a pass.

One finding is filed as an observation without a mechanism rather than a bug: clicking Delete provider produced no confirmation and left the row in place after reload. That could be a genuine defect, a confirmation step I failed to find, or a synthetic click not reaching React's handler — I could not distinguish them, so #156 says so rather than guessing.

@GuyNachshon
GuyNachshon merged commit c53421f into main Aug 23, 2026
5 checks passed
@GuyNachshon
GuyNachshon deleted the guy/ui-brand-tokens branch August 23, 2026 19:55
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