# UI / UX review — AI SEO Manager

Reviewed against the live install at `dghanalytics.com/aiseo` and the full source
in `D:\aiseo`, on 19 August 2026. Everything below was checked by rendering the
real templates in a browser at 1440px, 1280px and 390px, in light and dark, and
by reading the computed styles — not by reading the CSS and assuming.

---

## The short version

The design system is the strongest part of this codebase and it is better than
most commercial SEO tools. `tokens.css` has a validated colour ramp with recorded
contrast and colour-blindness deltas, severity is never carried by hue alone, and
`app.css` already contains a complete component library.

**The problem was not the design. It was that roughly half the library had never
been connected to a page.** Filter bars, search fields, tabs, segmented controls,
skeletons, progress bars, modals and drawers were all fully styled and rendered
nowhere. Meanwhile the pages that exist made the reader do work the components
were built to do for them: sixty findings with no way to filter them, a run
button that reported progress as a text badge, a JSON dump under every finding.

Three of the components were also silently broken — see §3.

So this build wires up what was already there, adds the accounts layer you asked
for, and adds one thing the codebase was missing entirely: **a sentence, in
English, at the top of every page, saying what the numbers mean.** You said
directors read this, and a director does not read a gauge.

---

## 1. What was already right (do not change these)

- **Scores are per site and never averaged into a single company number.** This
  is stated on the dashboard and it is the correct decision. An average of 80
  hides a site at 30.
- **A run that fetched nothing is `inconclusive`, not `completed` at 100.** The
  UI shows the reason instead of a score. Very few tools get this right.
- **Coverage is stated** — "38 checks had eligible URLs, out of 56 implemented
  and 134 defined". A clean report that does not say what was skipped is a lie of
  omission, and this one refuses to tell it.
- **Every severity badge carries an icon and a word**, not just a colour.
- **Risk is reported beside strength, never blended into it.**
- **The typography scale is one family with a sane ramp** and no display face.
  Correct for an instrument.

The improvements below are all downstream of this. None of them touch the colour
system, the scale, or the scoring language.

---

## 2. What was wrong, and what this build does about it

### 2.1 There was no login

Anyone with the URL could read every audit. Worse, the API endpoints that queue
crawls (`POST /api/sites/{id}/audits`) and mark findings fixed accepted any
request from any browser that had visited the page once. `<meta name="robots"
content="noindex">` is not access control — it is a request to Google, and it
does nothing about anyone who has the link.

**Now:** three roles (Administrator / Analyst / Viewer), a `People` page,
per-user sign-in with lockout after six failures, an append-only sign-in log, an
eight-hour idle timeout, and session-id regeneration on login. The guard runs
*before* dispatch, so a route added next year is protected unless it explicitly
opts out. Viewer accounts are enforced server-side, not by hiding buttons.

There is deliberately **no default account and no password-reset email**. The
first visit shows a one-time "create the first administrator" screen. An app that
mails password links is an app whose security depends on an inbox.

### 2.2 Nothing said what the numbers meant

A score of 63 in a ring with an orange stroke tells a technical reader a lot and
a director nothing. Both audiences open the same URL.

**Now:** every page opens with a summary band — one heading, one paragraph, one
"next step". On the dashboard it names the weakest site and links to it. On a
site page it says whether the score is *capped* (a hard gate, fix that first) or
merely low, names the weakest category, and says which finding to start with. The
band thresholds moved out of body text and into a `?` tooltip, so the sentence
reads cleanly and the definition is one hover away.

This is the change most likely to alter how the tool actually gets used.

### 2.3 Sixty findings, no way to search them

The site page loaded up to 60 findings, displayed 25, and offered "Full JSON" for
the rest — a link that opens a wall of unformatted text in a browser tab. Nobody
pastes that into a ticket.

**Now:** a filter bar over the priority queue with free-text search across code,
title, URL and remediation; a segmented severity filter with live counts; an
"only with traffic" switch; and a live "12 of 60 shown" counter. All of it
filters rows already in the DOM — no request, no spinner, and the count cannot
lie because nothing is paginated away behind it. The JSON link is replaced by
**Export CSV**, with a UTF-8 BOM so Excel on Windows does not mangle it.

### 2.4 Evidence was shouting

Every finding printed its evidence JSON expanded. Twenty findings produced a page
of JSON nobody scrolled past — which meant the *remediation text*, the useful
part, was buried. Evidence is proof, not the message.

**Now:** collapsed behind a `<details>` toggle showing the field count. It still
prints expanded, because a printed report that hides its evidence is not
auditable.

### 2.5 "Is it working?" was answered by a text badge

The run poller wrote `Crawling — 412 / 1,600` into a badge. A `.progress`
component existed and was never used.

**Now:** a determinate bar in the topbar that switches from indeterminate to a
real percentage as soon as the crawl reports totals, plus explicit handling of
the `inconclusive` outcome the poller previously ignored (it fell through to no
message at all).

### 2.6 The mobile drawer had no way out

Opening the sidebar on a phone covered the hamburger that closed it. There was no
scrim, no tap-outside, no Escape.

**Now:** scrim, tap-outside, Escape, and `aria-expanded` on the trigger. The
collapsed desktop sidebar now shows names on hover — previously it collapsed to a
column of unlabelled glyphs.

### 2.7 Smaller things, all fixed

| Was | Now |
|---|---|
| Timestamps as raw `2026-08-19 03:11:00 UTC` | "17 hours ago", exact UTC in the title and in print |
| Sidebar sites in database ID order | Worst-first, so the site needing attention is at the top |
| Three equally-weighted audit buttons | One button group, primary action last, each with a tooltip saying what it costs |
| `/` shortcut focused a search box that did not exist | Real search boxes, plus `g d`, `g i`, `1`–`9`, `t`, `[`, `?`, `Esc` and a shortcut sheet |
| Table columns not sortable | Health and open-issue columns sort, numerically, on `data-value` |
| Sidebar showed a hardcoded name and title | The signed-in user, with an account menu |
| Findings marked fixed recorded `assigned_to` from the request body | Recorded from the session — a client-supplied "who did this" is a wish, not a fact |
| `/api/health` exposed queue depth, PHP build and the MySQL error to anyone | Up-or-down only unless signed in |
| No print path | The site page **is** the report: `@media print` drops chrome, expands evidence, un-hides filtered rows, and prints link targets |

---

## 3. Three CSS bugs that were live on the site

These were in the shipped stylesheet and visible on the production page. All
three were the same class of mistake — a later rule quietly overriding an earlier
one — and none would show up in a code review.

**a. Every input had a black 2px border.** The reset clears `border` on `button`
but not on `input`, so every field carried the browser default
`2px inset #767676` *underneath* the token-coloured ring. Measured computed
value: `border: 2px inset rgb(118,118,118)`. That is why the forms looked heavy
against an interface built from hairlines. Fixed by adding `border: 0` to
`.input, .select, .textarea`.

**b. The score gauge collapsed to 48px.** `img, svg { max-width: 100% }` in the
reset let the 104px gauge shrink whenever it shared a flex row with text — the
ring and the figure overlapped. Fixed by pinning its size; a gauge is a fixed
instrument, not fluid media.

**c. The sparkline sprayed across the whole stat tile.** `.sparkline { width:
100% }` is declared *after* `.stat__spark { width: 84px }`, so it won every time.
Measured: 236px wide in an 84px slot. Fixed with a same-element combo selector
(`.sparkline.stat__spark`) so the intent survives any future reordering of the
file.

---

## 3b. What a second pass found in the first pass

The work above was then reviewed adversarially — once for security, once for
copy and interaction logic — and both passes found real defects in it. Recording
them here because the pattern is more useful than the list: **every one was a
number or a rule that was true in the code and false on the screen.**

Fixed before this build shipped:

- **The site page would have said "60 open findings" on a site with 212.** The
  query caps at 60 and I counted the array. Every derived figure — the summary
  sentence, the stat tile, the filter chips — inherited it, and the dashboard's
  uncapped tile on the previous screen would have contradicted it by hundreds.
  Counts now come from SQL over the whole run; `count()` is used only for
  "showing N of M".
- **"Traffic at risk" triple-counted a page with three findings on it.** It
  summed `affected_clicks` per finding. Now `SUM` over distinct URLs. This is
  the single number most likely to be read aloud in a meeting.
- **`display: revert` did not un-hide filtered rows for print** — it reverts to
  the user-agent origin, which is exactly where `[hidden] { display: none }`
  lives, so the rule computed to `none` and did nothing. Filter to "critical
  only", print, and the PDF would have contained only the critical findings
  under a summary saying there were 212.
- **Sorting "Open critical + high" did nothing** while announcing that it had:
  the column index came from the filtered list of sortable headers, not from the
  row, so it sorted the Band column as a number and got `NaN` for every row.
- **The "no worker cron" alarm fired on a correctly configured cron.** One
  pending job with nothing reserved is the normal state between five-minute
  ticks. It now reads as an observation.
- **`setup-env.php` was a complete takeover path.** Gating `migrate-web.php` was
  not enough: the unauthenticated `.env` writer could repoint the app at an empty
  database, and an app with no accounts offers "create the first administrator"
  to whoever asks next. All three installers now share one gate, and because
  "are there accounts?" is a database question that a wrong DB password makes
  unanswerable, the fact is also recorded in `var/.installed` so the tools stay
  open during a genuine install and shut during an outage.
- **The `next=` parameter was an open redirect** via `/\evil.com` — PHP does not
  treat a backslash as a separator, browsers do.
- **A viewer could rebind the org's Google Search Console account.** The
  `manage_settings` capability existed in the table and was never enforced
  anywhere.
- **The CSRF token was being sent to Google as the OAuth `state`**, putting the
  one token that protects every mutating route into browser history and Google's
  request logs.
- Plus: "of which 1 is critical" after "one open finding"; "All 1 measured site
  are"; "0 websites are configured and ready to audit" during a database outage;
  a compulsory change-password screen with no way to sign out; `g p` reachable
  but undocumented, landing non-admins on a 403 that called them read-only under
  a heading saying "Nothing here".

The lesson worth keeping: **a count in a sentence and a count in a query are two
different numbers until something forces them to agree.** If you add a figure to
a page later, get it from the same place the page's other figures come from.

## 4. Accessibility

Fixed in this build: `aria-expanded` on the sidebar toggle; `aria-pressed` on the
severity filters; `aria-sort` on sortable headers; `role="img"` with a text label
on the sparkline and the category meters, so the trend is readable by screen
reader rather than being a decorative path; visually-hidden text in every matrix
cell (previously the whole grid was `✓`/`✕` glyphs with a `title=`, which never
appears on keyboard focus); a real tooltip element replacing `title=` on the
matrix and the `?` affordances; `aria-label` mirrored onto every tooltip trigger.

Still outstanding, in order of how much it matters:

1. **The AI access matrix is a wide table with rotated headers.** It works with a
   screen reader now, but on a phone it is a horizontal scroll with no
   indication that more columns exist. A stacked per-crawler card list under
   600px would be better than a scroll.
2. **No focus trap in the shortcut modal.** Tab can leave it. Low impact — it is
   informational and Escape closes it — but it is not correct.
3. **`prefers-reduced-motion` zeroes the durations** but the sidebar drawer and
   the indeterminate progress bar still animate. Worth auditing once.

---

## 5. What I did not do, and what I would do next

Ranked by value per hour. None of these are needed for this release.

1. **Trend over time, properly.** There is a 12-run sparkline in one stat tile
   and nothing else. A "score over the last 12 runs" line chart per site, and a
   "findings opened vs closed" chart, would turn this from a snapshot into a
   record of whether the work is paying off. The chart primitives (`.viz-line`,
   `.viz-axis`, `.viz-grid`, `.legend`, the 8-slot categorical ramp) are all
   already in `app.css` and still unused. **Half a day.**

2. **Group findings by check code.** Forty instances of one missing-canonical
   check currently read as forty problems. One row saying "T012 · 40 URLs
   affected · expand" is one problem, which is what it is. **Half a day.**

3. **A scheduled monthly audit plus an email digest.** You already have a worker
   and a queue; what is missing is a schedule and a "what changed since last
   month" diff. That is the feature that makes people open the tool without being
   asked. **One to two days.**

4. **Per-site settings in the UI.** `crawl_max_urls`, `crawl_concurrency`,
   `gsc_property` and the domain itself are all editable only by SQL. **Half a
   day.**

5. **A client-facing report view.** Distinct from the print stylesheet: cover
   page, no check codes, no evidence JSON, plain-language findings only. Worth
   doing only if audits actually go to clients. **One day.**

6. **Delete `setup-env.php`, `migrate-web.php` and `preflight.php`.** Not a
   feature — a task. `migrate-web.php` now refuses anonymous visitors once
   accounts exist, but `setup-env.php` can still rewrite your database
   credentials and `preflight.php` still prints server paths and the error-log
   tail to anyone who asks. Neither can be gated without breaking the
   "works when everything else is broken" property that makes them useful, so
   the app now nags administrators by filename until they are gone. **Two
   minutes.**

---

## 6. One thing worth disagreeing with me about

I added the executive summary band to the top of every page, above the numbers.
An argument against: for a daily user it is a paragraph they will read twice and
then scroll past forever, and it pushes the instruments below the fold on a
laptop.

The counter-argument, which is why I did it: this tool is opened rarely and read
by people who did not run the audit. For that reader the paragraph *is* the
product and the gauge is supporting evidence. If your team ends up using it daily
and finds the band in the way, collapsing it to one line with a "why" toggle is a
twenty-minute change — the markup is one `<section class="summary">` in
`dashboard.php` and `site.php`.

---

# Build 3.0 addendum — the content pipeline

*19 August 2026. Seven new screens, reviewed the same way: rendered with real
fixtures in Chromium at 1440px and 390px in both themes, computed styles read
rather than CSS assumed, and every interactive control exercised by a scripted
browser. Fifty-five rendered pages, a hundred browser assertions.*

## 7. The shape of the new screens

The pipeline is a line, so the navigation is a line:
**Analyze → Opportunities → Calendar → Articles → Publishing.**
Each screen answers one question and hands you to the next.

**Analyze** asks *which markets?* A picker rather than a dropdown, because
selecting twelve countries out of two hundred is a different task from selecting
one, and a `<select multiple>` is the wrong control for it. The estimated cost of
the run is stated on the button, in dollars, before you press it — a button that
spends money without saying how much is a trap.

**Competitors** asks *are these actually our rivals?* This is the one judgement
the pipeline cannot make for itself, so it is a screen rather than a
configuration file. The class dropdown is on every row, and your answer is never
overwritten by a later analysis. That last sentence was previously false; it is
now true, and there is a test that fails if it stops being.

**Opportunities** asks *what should we write?* Every row carries the sentence
that explains why, in English, with the numbers in it. A ranked list with no
reasoning is a list nobody trusts enough to act on.

**Calendar** asks *when?* Two slots a week per site, Tuesday and Thursday. Empty
slots are shown rather than hidden, because an empty slot next Tuesday is a
question with a deadline and an invisible one is nothing at all.

**Article** asks *is this fit to go on a live company website?* The draft is
readable at full width. Every sentence and table row containing a number is
listed separately in the sidebar, because the failure that matters is not clumsy
prose — it is a confident, specific, wrong figure. The generated image sits under
a note saying no API setting guarantees it is free of text or logos, so look.

**Articles** is the index the calendar cannot be: the calendar shows six weeks,
and an article outlives its slot. Anything that cost money to produce must never
be reachable only by remembering its URL.

**Publishing** asks *where does this go?* One card per site, and the fields
change as you change the platform.

## 8. Decisions worth arguing about

**Publishing is disabled until the connection has passed a test.** One extra
click before the first publish. The alternative is discovering a typo'd site URL
or an expired application password by sending a real article at a real website
and watching it fail. The test is free and instant.

**A publish that we cannot confirm blocks the next one.** If the request reached
the CMS but the answer was lost, this app cannot tell whether the post exists. It
refuses to guess, says so, and gives you a button to release it after you have
looked. That is friction in a rare case, and the alternative is the same article
appearing twice on a customer-facing website — which is the worst thing this tool
could do.

**Drafts are not retried automatically.** A model that refuses a prompt refuses
it three times, and charges three times to establish that. The failure is written
on the article where a person can read it and decide whether pressing the button
again is worth the money.

**Every screen states the money.** Estimated before, actual after, per article
and per run. A tool that spends on your behalf and does not show the meter is one
you stop trusting the first time a bill surprises you.

## 9. What was wrong, and is now not

Two adversarial reviews of the new pipeline and a third of the fixes turned up
roughly forty defects. The ones that changed the interface:

- Articles could get stuck in `drafting` or `publishing` with every button
  disabled and no explanation. Now: after ten minutes, a banner that names the
  likely cause — the worker cron — and a button that moves it out.
- A failed draft was a closed loop. The article page said "re-draft it from the
  calendar"; the calendar offered only "Open", which came back to the article
  page. Both screens now offer **Write it again**.
- The Publish card printed the raw database enum: *"Will be created as a
  publish."* It now says what will actually happen, and says the truth for Wix,
  whose API creates drafts only whatever the setting says.
- Choosing a CMS showed no credential fields until after you had saved a
  connection with no credentials in it. Every platform's fields are now present
  and swap instantly — and the hidden ones lose their `name` attribute, so a
  Shopify token is never posted alongside a WordPress password.
- A green "Tested OK" survived a change of platform, which enabled Publish for
  a connection nothing had ever contacted. Any save now clears the test result.
- Ticking twelve markets and pressing a key navigated away and discarded them
  without a word. There is now an unsaved-changes guard — which deliberately
  ignores typing in the search box, because filtering a list is not editing it.
- Read-only accounts were shown live controls with no Save button, and
  instructions naming buttons they cannot see. Both fixed throughout.
- Counting and grammar: "1 countries", "Check these 0 figures", "1 competitor
  cover it", "5 open findings" above a list of article ideas, and "about 0
  searches a month" — which is not a missing number, it is a claim, and the
  opposite of what a missing figure means.
- The brief demanded six internal links when the site had none to offer (an
  instruction to invent URLs, and an invented internal URL is a 404 on a live
  company website) and an author byline the writer is explicitly forbidden to
  invent.
- "Every save is kept as a revision" was true and invisible — the revisions were
  written and read by nothing. There is now a History card, and you can restore
  a version, which is itself reversible.

## 10. What I would do next

Ranked by value per hour, and none needed for this release.

1. **A "publish on this date" scheduler.** The calendar has dates; publishing is
   still manual on the day. A queued publish at a set time closes the loop, and
   the queue already supports a delay. **Half a day.**
2. **Show the analysis cost trend.** `api_usage` has everything needed for a
   spend-per-week chart, and the chart primitives in `app.css` are still unused.
   Money is the thing people want to see over time. **Half a day.**
3. **Group opportunities by cluster on screen.** They are clustered in the
   database and listed flat. One row saying "chiller maintenance · 6 keywords ·
   one article" is truer than six rows. **Half a day.**
4. **Let a person edit the brief before drafting.** The brief is generated and
   frozen at draft time; a subject-matter expert who could add two sentences to
   it before the writer starts would raise the quality of the output more than
   any prompt change. **One day.**
5. **Track what published articles actually did.** Search Console is already
   connected. Reading impressions and position for the published URL twelve
   weeks later is what turns this from a content generator into a record of
   whether the work paid off. **One to two days.**
