Skip to content

Give the action buttons states, and stop swallowing failures - #37

Merged
NerdyHank merged 2 commits into
mainfrom
ui-states-and-error-feedback
Oct 6, 2026
Merged

NerdyHank merged 2 commits into
mainfrom
ui-states-and-error-feedback

Conversation

@NerdyHank

Copy link
Copy Markdown
Contributor

Follow-up to #32. The feature itself is sound — minting the name client-side against the
existing assign endpoint, leaving ASSIGN alone — but checking it in a browser instead of in
the diff turned up three things, and the third one quietly damages the gallery.

A failed assign created a person anyway

POST /api/unknowns/assign created the person first and assigned afterwards, so when no
face could be assigned — stale ids, an empty selection, or a second tab that got there first
— it answered HTTP 200 with {"assigned": 0} and the person stayed. Through "Track as
new" that means every failed attempt leaves an empty Unnamed-xxxxxx in the gallery, while
the UI reports 0 face(s) → Unnamed-ab12cd and looks like success.

Measured on a second instance before the fix:

$ curl -s -X POST .../api/unknowns/assign -d '{"ids":["nope"],"person":"Testperson"}'
{"assigned":0,"slug":"testperson"}
$ curl -s .../api/persons
persons: ['testperson']

It now answers 409 and deletes a person that was created for that very call. An existing
person is never deleted — there is a test for that, because getting this wrong would be far
worse than the bug.

The action buttons had no states

.act — ASSIGN, ASSIGN ALL, Track as new — had no :hover, no :active and no
:focus-visible rule. Only .ghost:hover existed. Pointing at a button that creates a
person looked exactly like not pointing at it.

Measured rather than guessed, via canvas pixels and the WCAG formula:

contrast
rest → hover 1.66:1
hover → active 1.50:1
label on hover 7.31:1
label on active 4.89:1
focus ring vs. panel 13.1:1

Below roughly 1.5:1 a state change is not visible, which is why the first attempt
(oklch(78% …), 1.26:1) was thrown away. Active also shifts by 1px, so the press is
signalled twice. The focus ring sits on the panel because of outline-offset: 2px, which is
where those 13:1 come from. [disabled] gets 45 % opacity and not-allowed.

Failures were invisible

56 await j(...) calls across 25 handlers, most without a catch — so a click whose
request failed did nothing and said nothing. That is the same defect class as the Settings
form in #36. One listener covers every handler, including the ones written next:

window.addEventListener('unhandledrejection', ev => {
  toast('Failed: ' + errText(ev.reason).slice(0, 120));
  ev.preventDefault();
});

Verified end to end on the second instance: triggering the 409 now shows
Failed: no face was assigned — Unnamed-ab12cd was not created, and /api/persons stays
empty.

Not in here

The action buttons are 26 px tall against the 44 pt minimum for a touch target. That is true
of every button in the UI, not just these, so it is a separate change rather than something
to bolt on here.

104 tests green. Production was not touched: everything was measured against a second
instance on port 8699 with its own empty data directory.

Three defects found while checking #32 in the browser rather than in the diff.

The .act buttons (ASSIGN, ASSIGN ALL, Track as new) had no hover, no
active and no focus style at all — pointing at a button that creates a
person looked exactly like not pointing at it. Measured rather than
guessed: 1.66:1 from rest to hover, 1.50:1 from hover to active, with the
dark label staying above 4.5:1 on both (7.3:1 and 4.9:1). The focus ring
sits on the panel thanks to outline-offset and stands there at 13:1.

Network and server failures were invisible: 56 'await j(...)' calls across
25 handlers, most without a catch, so a click that failed did nothing at
all and said nothing. One 'unhandledrejection' listener covers every
handler, including the ones written next.

POST /api/unknowns/assign created the person and then returned HTTP 200
with {"assigned": 0} when no face could be assigned — stale ids, an empty
selection, or a second tab that got there first. Through Track as new that
left an empty Unnamed-xxxxxx in the gallery on every failed attempt, while
the UI reported '0 face(s) → Unnamed-ab12cd' and looked like success. It
now answers 409 and removes a person that was created for that very call;
an existing person is never deleted.

Measured against a second instance on port 8699 with an empty data
directory, production untouched: the toast reads 'no face was assigned —
Unnamed-ab12cd was not created' and the person list stays empty.
@the-codemole

the-codemole Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🧪 Automated PR Checks

Profile: python-app · auto-detected · ⚙ Configurable

✅ python-syntax — 3 Python file(s) compile cleanly
✅ ruff — 3 Python file(s) — ruff clean (E9/F)
⚪ json-valid — ⏭️ No JSON files in the diff
✅ diff-size — Diff +229/-3 in 8 files
✅ secret-scan — No plaintext secrets in the added lines
✅ conflict-markers — No merge conflict markers
✅ sensitive-files — No sensitive file types in the diff

hermes-work · branch ui-states-and-error-feedback · base main · What do these checks do?

Comment thread app/webui.py
Comment thread static/index.html Outdated
Comment thread static/index.html
create_person() is deliberately idempotent: 'Juli', ' juli ' and a second
tab all resolve to the existing slug. Checking body.person against the
slug keys therefore took the else branch for a display name, got the
pre-existing slug back, and on zero assignments deleted an existing person
with all its stored faces — the exact case the comment claimed could not
happen. The flag now comes from the resolved slug, and a test covers the
display-name, casing and whitespace paths. Verified the other way round
too: with the old condition that test fails.

Two more from the same review. The prefers-reduced-motion block reset
transition, which is never declared here, while the motion actually added
— translateY(1px) on :active — stayed on; it now resets transform. And the
global rejection handler called preventDefault() without logging, so a
TypeError survived only as a 120-character toast; it logs the original
reason to the console first.

Release 0.25.1.

@the-codemole the-codemole Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — review complete.

  • Checks: 6 passed, 1 skipped, 0 failed
  • 3 finding(s) addressed, no open threads

@the-codemole the-codemole Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — review complete.

  • Checks: 6 passed, 1 skipped, 0 failed
  • 3 finding(s) addressed, no open threads

@NerdyHank
NerdyHank merged commit fa352bb into main Oct 6, 2026
1 check passed
@NerdyHank
NerdyHank deleted the ui-states-and-error-feedback branch October 6, 2026 13:31
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.

2 participants