Skip to content

feat(ui): UX pass — funnel, results triage, identity, polish#57

Merged
ringo380 merged 2 commits into
mainfrom
feat/ux-pass-funnel-triage-identity-polish
May 13, 2026
Merged

feat(ui): UX pass — funnel, results triage, identity, polish#57
ringo380 merged 2 commits into
mainfrom
feat/ux-pass-funnel-triage-identity-polish

Conversation

@ringo380
Copy link
Copy Markdown
Owner

Summary

Four-slice frontend overhaul driven by a research → critique → design pipeline. Touches the anon→paid funnel, the results page, visual identity, and polish.

  • Slice A — Funnel + a11y foundations: asymmetric CTA pattern, mobile credits pill, role="radiogroup" DB chips, honest loading copy, SQL field error association, toast/messages aria-live, theme-toggle aria-pressed, click-toggle user dropdown, focus-ring rework, reusable _grade_pill.html partial with aria-label, CodeMirror font-size 16px (kills iOS zoom-on-focus), h1 step-down.
  • Slice B — Results becomes a triage tool: top-issue hero with CodeMirror line-highlight (graceful "Found in WHERE clause" fallback — no invented line numbers); severity dots paired with shape + text (1.4.1); #issue-N/#fix-N anchor system with scroll-margin-top: 5rem, focus shift, and reduced-motion-gated pulse; sticky right-rail TOC hidden lg:block; index-recs auto-collapsed for grades A/B.
  • Slice C — Visual identity + dark mode: self-hosted JetBrains Mono 500/600 woff2; Tailwind font-mono extended; h1/h2/score numbers swapped to mono + tabular-nums; grade-pill dark-mode contrast rework (distinct rgba bgs, brighter *-300 text); surface tokens (--qg-surface, --qg-ink, etc.) planted; every text-*-900 heading paired with dark:text-white.
  • Slice D — Polish: <dialog> shortcuts modal with showModal() + global ? hotkey (gated to non-input focus); footer with status dot + "What's new" + GitHub link; mobile hamburger nav at md breakpoint; history-page filter row → responsive grid (1/2/4 cols).

Why

UX research + opinionated critique flagged three structural problems:

  1. The anon→paid funnel leaked at every CTA pair (no primary action — Hick's Law).
  2. The results page was a static report, not a triage tool (no "worst thing first," no link between issue and fix).
  3. Dark mode was a recolor of light mode; several text-*-900 headings were near-invisible on dark surfaces.

Plus WCAG 2.1 AA failures (hover-only user dropdown, focus ring failed contrast in dark, no field-level error association, color-only severity dots) and mobile breakage (navbar overflow ~375px, iOS zoom on CodeMirror focus from font-size: 14px, <details> DB picker burying a core differentiator).

Files

  • 9 modified templates (base.html, index.html, grade_form.html, grade_results.html, account.html, query_history.html, login.html, register.html, plus dark-mode.css)
  • 1 new partial (_grade_pill.html)
  • 2 new font files (jetbrains-mono-{500,600}.woff2, ~94 KB each, self-hosted — no CSP changes needed)

Out of scope (intentionally deferred)

  • Lucide stroke icon migration (separate slice)
  • Analytics events for new funnel surfaces (coordinate dimension registration)
  • Build SHA pill in the footer (no env wiring exists)
  • Anchor system on compare_results.html / batch_results.html
  • enhanced_grade_results.html (pre-existing XSS pattern flagged separately)

Test plan

  • python manage.py check → 0 issues (passes locally)
  • python manage.py collectstatic --noinput → 169+ files, fonts present
  • Anon landing (/): DB chips render, keyboard nav works (arrows + Space), inline grade fires, "Open full report" is the primary CTA, loading bar is honest, error states show red border + role="alert"
  • Grade form (/grade/): Shortcuts dialog opens via button and ? key (when not in editor), traps focus, ESC closes; loading overlay says "Deep analysis", not "ML analysis"
  • Results (/grade/results/<id>/): top-issue hero renders, CodeMirror line-highlight if regex matches, "Jump to fix" anchors work and shift focus, right-rail TOC visible at lg+, index-recs collapsed for A/B
  • Account / History: grade pills carry aria-label, filter row stacks responsively on mobile, no pill-contrast collisions in dark mode
  • Auth pages: h1 promoted, mono headings render
  • Navbar: credits pill visible on mobile (compact dot+count), hamburger appears <md, drawer toggles cleanly, user dropdown still works (click-toggle, ESC closes)
  • Dark mode: toggle works, aria-pressed updates, no invisible headings, grade pills are visually distinct from each other
  • Reduced motion: pulse + indeterminate-bar animations honor prefers-reduced-motion
  • Mobile 375px: no horizontal overflow on any in-scope page; tap targets meet 44px on primary CTAs

Four-slice frontend overhaul driven by a research → critique → design pipeline.

Slice A — Funnel + a11y foundations
- Asymmetric CTA pattern: one solid primary + text-link sibling on anon
  result, exhausted-trial card, and results header (Hick's Law fix).
- Mobile credits pill: drop hidden sm:inline-flex; compact dot+count <sm,
  full label sm+. aria-label replaces title.
- Replace <details> DB picker with role="radiogroup" chip row (roving
  tabindex, arrow-key nav, Space/Enter select, localStorage persistence).
- Honest loading copy on anon path: "Analyzing your query…" + indeterminate
  bar, animation gated behind prefers-reduced-motion. Authed copy shifts
  "ML analysis" → "Deep analysis".
- SQL field error association (aria-invalid, aria-describedby, role="alert",
  red wrapper border).
- Toast + messages region get role="status" aria-live="polite"; errors
  upgrade to role="alert".
- Theme toggle gains aria-pressed; <main> gets tabindex="-1" so the skip
  link works in Safari/Firefox.
- User dropdown converted from hover-only to click-toggle (aria-expanded,
  aria-controls, ESC + click-outside close, focus first item on open).
- Focus ring rework: focus-visible:ring-2 ring-offset-2 ring-indigo-500
  globally on primary CTAs (replaces low-opacity ring that failed contrast).
- Reusable _grade_pill.html partial with aria-label="Grade D, score 42 of
  100"; account/history/results all use it.
- CodeMirror font-size 14px → 16px (kills iOS Safari zoom-on-focus).
- Anon hero h1 steps down: text-3xl sm:text-4xl md:text-5xl.

Slice B — Results page becomes a triage tool
- Top-issue hero at top of grade_results: highest-severity issue rendered
  next to a read-only CodeMirror with .qg-line-issue line-highlight when
  the offending token can be located via regex. Graceful fallback to a
  "Found in <clause>" pill — no invented line numbers.
- Severity indicators pair color + shape (● high, ■ medium, ▲ low) + text
  label (WCAG 1.4.1).
- "Other issues" feed with Jump-to-fix links; recommendations carry
  id="fix-N" and a Back-to-issue link when matched.
- #issue-N / #fix-N anchor system: .qg-anchor-target gets
  scroll-margin-top: 5rem; hashchange listener shifts focus to target
  (tabindex="-1"), applies .qg-pulse-target flash gated behind
  prefers-reduced-motion: no-preference.
- Sticky right-rail TOC hidden lg:block with <nav aria-label> and
  aria-current="true" on the active anchor.
- Index recommendations auto-collapse for grades A/B via <details>.

Slice C — Visual identity + dark mode rework
- Self-host JetBrains Mono 500/600 woff2 at
  analyzer/static/analyzer/fonts/; register via @font-face in base.html
  with font-display: swap.
- Extend Tailwind font-mono via Play CDN tailwind.config.
- Apply font-mono + tabular-nums to h1/h2 and numeric displays (score,
  big grade glyph, stats) across account/history/results/auth pages.
  Body stays Inter.
- Grade-pill dark-mode contrast rework: distinct rgba backgrounds and
  brighter *-300 text per grade (each pair clears 4.5:1).
- Surface tokens planted at :root / html.dark (--qg-surface,
  --qg-surface-2, --qg-border, --qg-ink, --qg-ink-muted) for future use.
- Audit text-*-900 headings; pair every one with dark:text-white.
- One indigo→slate decorative swap on account.html stat tile.

Slice D — Polish
- Replace native alert() shortcuts in grade_form with <dialog
  id="qg-shortcuts-dialog"> via showModal() (native focus trap + ESC).
  Global "?" hotkey opens it, gated to non-input focus so it doesn't
  fight the SQL editor.
- New footer: wordmark + © year, status dot, "What's new" → Releases,
  GitHub link.
- Authenticated navbar gains an md:hidden hamburger that toggles a
  stacked nav drawer (44px tap targets, ESC + click-outside + link-click
  close, aria-expanded).
- History filter row: flex-wrap → grid grid-cols-1 sm:grid-cols-2
  lg:grid-cols-4; selects span full width on mobile.

Verification: python manage.py check → 0 issues. collectstatic copies
the new font files. No new bare text-*-900 introduced. dark-mode.css
audited — no */ inside comments.

Out of scope (deferred): Lucide stroke icon migration; analytics events
for the new funnel surfaces; build SHA pill in the footer
(no env wiring); compare_results / batch_results anchor rollout.
@ringo380
Copy link
Copy Markdown
Owner Author

Code review

Found 2 issues:

  1. Selected DB chip becomes invisible in dark mode. dark-mode.css adds html.dark .bg-slate-900 { background-color: #e6edf3; }, which has equal specificity to Tailwind's generated html.dark .dark\:bg-white rule. Since dark-mode.css loads after the Tailwind CDN, source-order makes the override win — the selected chip's bg-slate-900 text-white dark:bg-white dark:text-slate-900 resolves to white text on a near-white (#e6edf3) background in dark mode.

html.dark .bg-slate-100 { background-color: #21262d; }
html.dark .bg-slate-800 { background-color: #21262d; }
html.dark .bg-slate-900 { background-color: #e6edf3; }
html.dark .text-slate-200 { color: #adbac7; }
html.dark .text-slate-300 { color: #adbac7; }

  1. CodeMirror editors inside the now-collapsible index-recommendations panel won't render correctly when expanded for grades A/B (CLAUDE.md says: "CodeMirror in hidden tabs: always call refresh() inside setTimeout(0) after unhiding, so the browser repaints before CodeMirror remeasures."). The <details> defaults closed for grades A/B, but the DOMContentLoaded init loop converts every textarea.sql-display — including the idx-ddl ones inside the closed details — at page load. No toggle listener is wired to call .refresh() on expand.

{% with idx_block=analysis.index_recommendations %}
{% if idx_block.recommendations %}
<details id="index-recommendations" class="bg-white dark:bg-[#161b22] rounded-lg border border-gray-200 dark:border-[#30363d] p-5" {% if g == 'c' or g == 'd' or g == 'f' %}open{% endif %}>
<summary class="cursor-pointer flex items-center justify-between flex-wrap gap-2 list-none">
<h3 class="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<span>🗂️</span><span>Index recommendations</span>
<span class="text-xs font-normal text-gray-500">schema-aware · live DB</span>
</h3>
<span class="text-xs text-slate-500 dark:text-slate-400">{{ idx_block.recommendations|length }} ranked</span>
</summary>
<div class="mt-3 flex items-center justify-between flex-wrap gap-2">
<p class="text-xs text-gray-500">
{{ idx_block.total_candidates }} candidate{{ idx_block.total_candidates|pluralize }} considered ·
{{ idx_block.filtered_redundant }} filtered as redundant
</p>
<button onclick="copyAllIndexDDL()" class="text-xs text-indigo-600 dark:text-indigo-400 hover:underline">📋 Copy all DDL</button>
</div>
{% for advisory in idx_block.advisories %}
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">⚠️ {{ advisory }}</div>
{% endfor %}
<ul class="mt-4 space-y-3">
{% for rec in idx_block.recommendations %}
<li class="rounded-md border border-gray-200 dark:border-[#30363d] p-4">
<div class="flex items-center justify-between gap-2 flex-wrap">
<span class="text-sm font-semibold text-gray-900 dark:text-white">
{{ rec.table }}({{ rec.columns|join:", " }})
</span>
<div class="flex items-center gap-2">
{% if rec.redundancy == 'REDUNDANT_EXACT' %}
<span class="text-xs font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-600">✓ Already exists</span>
{% elif rec.redundancy == 'REDUNDANT_SUBSUMED' %}
<span class="text-xs font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-600">Covered by composite</span>
{% else %}
<span class="text-xs font-medium px-2 py-0.5 rounded
{% if rec.confidence == 'HIGH' %}bg-emerald-100 text-emerald-700
{% elif rec.confidence == 'MEDIUM' %}bg-amber-100 text-amber-700
{% else %}bg-gray-100 text-gray-600{% endif %}">
{{ rec.confidence|title }} confidence
</span>
<span class="text-xs font-semibold text-emerald-700">~{{ rec.estimated_improvement_pct }}% faster</span>
{% endif %}
</div>
</div>
<p class="mt-2 text-sm text-gray-700 dark:text-slate-300">{{ rec.rationale }}</p>
{% if rec.affected_clauses %}
<p class="mt-1 text-xs text-gray-500">Helps: {{ rec.affected_clauses|join:", " }}</p>
{% endif %}
{% if rec.redundancy != 'REDUNDANT_EXACT' %}
<div class="mt-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wide">DDL</span>
<button class="text-xs text-indigo-600 dark:text-indigo-400 hover:underline" onclick="copyToClipboard('idx-ddl-{{ forloop.counter }}', 'DDL copied!')">📋 Copy</button>
</div>
<textarea class="sql-display idx-ddl" id="idx-ddl-{{ forloop.counter }}">{{ rec.create_sql }}</textarea>
</div>
{% endif %}

- base.html: drop role="status" aria-live="polite" from the Django
  messages wrapper. Wrapping per-message role="alert" children in a
  polite live region produces implementation-defined behavior across
  screen readers; individual role="alert" on error messages is
  sufficient and non-error messages render at page load.

- dark-mode.css: remove the html.dark .bg-slate-900 and
  html.dark .text-slate-900 global overrides. Same-specificity
  selectors lose to Tailwind dark: variants only by source order, and
  since dark-mode.css loads after the Tailwind CDN, the override won —
  inverting the selected DB chip's intended dark:bg-white +
  dark:text-slate-900 back to near-white-on-white. Inline comment now
  flags the cascade trap.

- grade_results.html: refresh CodeMirror editors when the
  index-recommendations <details> opens. The panel defaults closed for
  grades A/B; CodeMirror editors initialized inside a hidden container
  measure zero gutter width until the browser repaints. Per CLAUDE.md:
  "CodeMirror in hidden tabs: always call refresh() inside setTimeout(0)
  after unhiding."
@ringo380 ringo380 merged commit d24392a into main May 13, 2026
3 of 4 checks passed
@ringo380 ringo380 deleted the feat/ux-pass-funnel-triage-identity-polish branch May 13, 2026 01:39
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