Skip to content

feat(localize): halo Submit once every object is accepted - #380

Merged
Chouffe merged 2 commits into
mainfrom
worktree-localize-submit-glow
Aug 12, 2026
Merged

feat(localize): halo Submit once every object is accepted#380
Chouffe merged 2 commits into
mainfrom
worktree-localize-submit-glow

Conversation

@Chouffe

@Chouffe Chouffe commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What

Pulses a green halo on the Localize Submit button the moment the alert becomes submittable.

Why

The localize gate opens silently. Submit sits in the rail footer below every object row, while the last accept usually happens up in the editor — so the moment the alert becomes shippable is exactly the moment the annotator is looking somewhere else. The button just quietly stops being grey, and nothing invites the click.

How

One conditional class on the existing button:

!submitBlocked && !submitAlert.isPending
  ? ' animate-pine-glow focus-visible:animate-none motion-reduce:animate-none' : ''

No new CSS. animate-pine-glow already exists in tailwind.config.js and already rings the solid-pine "Create object" CTA in AddObjectOverlay.tsx, so this reuses the established "work is done, move forward" signal rather than inventing a second one.

The condition is the substantive decision. Keyed on !submitBlocked, not on allObjectsAccepted — those diverge. An alert can have every box drawn while a sibling lane is still marked unsure, which keeps Submit disabled; haloing on the boxes alone would pulse a button that cannot be clicked.

focus-visible:animate-none is load-bearing, not decoration. Tailwind's focus ring is a box-shadow, the halo animates box-shadow, and animation declarations outrank normal author ones — so a running halo erases the focus ring, and focus:outline-none has already removed the native fallback. Without this, a keyboard user tabbing to the page's primary action would see no focus indicator at all. Confirmed against the generated CSS: the ring is emitted as box-shadow, the variant is emitted, and it wins on both specificity (0,2,0 vs 0,1,0) and source order.

Testing

Four assertions in the existing Submit alert block, TDD'd — the first was watched failing before implementing (button reached enabled, class absent).

  • halos once the gate opens
  • starts the halo when the last object is accepted — accepts both objects through the CTA bar with a stateful mock, asserting disabled-at-arrival so the transition is genuine
  • no halo while an object still has a pending frame
  • no halo while a sibling is undecided (every box drawn, still blocked)

Every assertion was mutation-tested, since both "does not have class" checks and already-settled-state checks are easy places to write something vacuous:

mutant killed by
unconditional halo pending frame test
keyed on allObjectsAccepted undecided sibling test
halo removed entirely gate opens + last object accepted tests

Full frontend suite: 1474 passed / 108 files. type-check, lint --max-warnings 0, format:check all clean.

Verified in the browser against the local stack — the halo paints and pulses on the real button, which a class assertion alone cannot prove.

Known follow-up (out of scope)

AddObjectOverlay.tsx has the identical focus-ring bug — animate-pine-glow with focus:outline-none focus:ring-2 and no focus-visible:animate-none. Pre-existing; left alone deliberately. One-class fix whenever someone wants it.

The localize gate opens silently. Submit sits in the rail footer under
every object row, while the last accept usually happens up in the
editor — so the moment the alert becomes shippable is exactly the moment
the annotator is looking somewhere else, and the button just quietly
stops being grey.

Pulse the existing `animate-pine-glow` halo on it instead. Same keyframe
the add-object flow puts on its solid-pine "Create object" CTA, so "the
work is done, move forward" reads as one signal rather than two.

Keyed on `!submitBlocked`, not on `allObjectsAccepted`: an alert whose
boxes are all drawn but whose sibling is still marked unsure stays
blocked, and haloing that would pulse a button that cannot be clicked.
Dropped again on `isPending` so the halo and the submit spinner never
run at once.
Review caught that the halo was erasing the focus indicator. The focus
ring is a box-shadow, `pine-glow` animates box-shadow, and animation
declarations outrank normal author ones — so on a haloed button the ring
never painted, and `focus:outline-none` had already removed the native
fallback. A keyboard user tabbing to the primary action saw nothing.

`focus-visible:animate-none` stops the halo on keyboard focus and hands
the ring back. Nothing is lost: the halo's job is to get you to the
button, and you cannot focus it without having found it.

Also add the transition test the other three were missing. They only
ever observed a gate that was already open, or already shut, at arrival
— an implementation that decided the halo once at mount would have
passed all of them. The new one accepts both objects through the CTA bar
and watches the halo arrive with the enablement.

And correct the comment's claim that the halo drops "the moment the
click lands": on the soft-confirm path handleSubmitClick opens the
dialog and returns without mutating, so isPending stays false.
@Chouffe
Chouffe merged commit 8fa0990 into main Aug 12, 2026
3 checks passed
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