Skip to content

fix(ui): the column headers were never above their own data (G0-5) - #548

Merged
haksungjang merged 4 commits into
mainfrom
fix/g05-final-audit
Jul 30, 2026
Merged

fix(ui): the column headers were never above their own data (G0-5)#548
haksungjang merged 4 commits into
mainfrom
fix/g05-final-audit

Conversation

@haksungjang

@haksungjang haksungjang commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

G0-5 is the differentiation plan's last step (docs/ux/differentiation-plan-2026-07-27.md §3.7): a final pass over what the gates cannot see. The three axes the plan wanted to reduce the scope to — visual baselines, axe, translation-key parity — have all become automatic since it was written, so the scope here is the remaining frozen debt plus a sampled human pass over Korean, the phone band, and the empty/error surfaces.

The headline

Both project-detail tables render their header and their rows as two independent flex rows that agree on column widths only by convention, and the convention had broken on both.

track floor declared columns result
Vulnerabilities 1250 px 1596 px rows overflow 316 px; last column 327 px off
Components 820 px 1120 px rows overflow 321 px

The header, being an ordinary flex row, absorbed the shortfall by shrinking its cells. The rows would not — their inner button had min-width: auto, so it sized to the untruncated text of every cell (truncate sets white-space: nowrap, which does not reduce min-content). "Discovered" ended up above the SLA dates.

Both are visible in the committed 1440 px visual baselines, and both were approved by eye. That is the point: a screenshot review answers "does this look like the product", not "is CVSS above the CVSS numbers".

Fix: the track is now authoritative — floor raised to the sum of the declared widths, min-w-0 on the row buttons, and one gutter value instead of the header's gap-2 against the rows' gap-3 (4 px of drift per column, 36 px of the total).

Gate: tests/e2e/tableColumnAlignment.spec.ts flattens header cells and row cells into two comparable lists and compares them position by position. It is column-agnostic, so adding a column without moving the floor fails it — which is what happened four times (Title, Discovered, SLA due, and the Components set) with nothing to notice. Verified it fails on each table's old value before landing.

Four Korean screenshots in the user guide were in English

tests/screenshots/capture_ko_locale.spec.ts captures the login, forgot-password, project-create and notification-preferences pages in Korean via setUiLanguage. That helper wrote i18nextLng; lib/i18n.ts configures detection.order: ["localStorage"] against trustedoss.lang and nothing else. So the write landed somewhere nothing reads and all four captures came out in English — and they are the assets the Korean documentation embeds. user-auth-login-ko.png showed a "Sign in to TRUSCA" card with an "English" locale toggle to readers of the Korean guide, and user-projects-create-form-ko.png was byte-identical to its English sibling.

The key is now a single exported constant in src/lib/languageStorage.ts — its own module because lib/i18n.ts calls i18n.init() at import time and imports every locale JSON, so a Playwright helper under Node cannot pull it in. The four assets are Korean in this branch's recapture.

Frozen debt cleared

Narrow-viewport budget 98 → 0, confirmed by the runner (including projects-list, whose numbers had been the ones that disagreed between local and CI). Four causes:

screen was cause
project-detail-vulnerabilities 32 the rows above
projects-list 8 / 14 fixed right-hand clusters totalled 386 px and collapsed the project name to exactly 0 px while still overflowing
dashboard 4 recent-scans table needs 474 px of min-content inside a 340 px card
project-detail-overview 2 the 8-tab strip is an inline-flex sized to 767 px with no wrap point

The project row also gave its repository URL 121 px of the 169 px available, leaving the name four characters and an ellipsis — both are lg-gated now, the same breakpoint the shell already uses for created_by and its drawer.

a11y ratchet 8 → 0, both themes. aria-sort sat on a button, where it is invalid ARIA and assistive technology ignores it — so it announced nothing while looking like it did. Four unit tests asserted it, which is how it survived. The accessible name already carries the sort state and what the next click does, in EN and KO. The callers are div tables with no columnheader to move the attribute to; giving them real grid semantics (TableVirtuoso, aria-rowcount, a role per cell) is separate work and is noted in the component.

What else the human pass found

  • The vulnerabilities toolbar collapses at 1440 px. Nine controls in a row with no wrap point, so flexbox squeezed the last of them: "Add filter" rendered one character per line, the VEX checkbox label broke into four, the EPSS placeholder was cut mid-number. Korean was worse — "업그레이드별" arrived as "업그". Both toolbars now wrap.
  • A project id that does not resolve drew the whole console around nothing: eight clickable tabs each leading to another error, a Scan button, a release picker reading "No version", the governance band reporting a second failure of its own, and the backend's untranslated Project Not Found / project <uuid> not found inside an otherwise Korean page. It is now one localized surface with the reason and the way back.
  • The Trivy DB panel's "Next refresh" tile put the same relative time in its label and its value, inside a label that assumed the future: "Next refresh in 5 days ago", with "5 days ago" repeated underneath. This read the same way in English; Korean only made it easier to notice.
  • The compliance rows' overflow markers were the last item in a flex row whose badges claimed the width first, so "+10개 더" wrapped into three stacked lines and the first row's height stopped matching its neighbours'.
  • The overview's recent-scans card described itself as "Last five scans for this project" directly above "No scans have been recorded yet."
  • Korean said "critical" in five places whose severity legend, on the same screen, said "치명"; nine strings spaced a particle after a Latin word ("SBOM 은", "CVE 에"). ko-style and i18n:check are both clean.
  • make screenshots-optimize was a no-op. oxipng has no convention that --out - means stdout — it wrote the optimized bytes into a file named - and left stdout empty, so the -s guard correctly kept all 44 originals while the recipe reported no change (and left the screenshots/- artifact). With --stdout the first asset measured goes 128,443 → 38,988 bytes. The recipe is fixed; the assets are not recompressed here.

Checked and not a defect: the scans table's last column looks clipped at 1440 px in both locales, but nothing on that page extends past the right edge — measured, not eyeballed.

The narrow spec now records what each screen spilled on every run, not only on a baseline update — the frozen 98 told nobody what it consisted of, and re-deriving that meant hand-patching the spec.

Verification

  • unit 1900 passed / 1 skipped (178 files) — two new guards
  • --config=playwright.a11y.config.ts 16/16 (8 screens × light/dark)
  • --config=playwright.responsive.config.ts 8/8 at a zeroed budget
  • tests/e2e/tableColumnAlignment.spec.ts 2/2, and confirmed failing on the old floors
  • npm run lint 0 errors, tsc --noEmit clean, i18n:check in sync, ko-style --all --fail-on S2 0 findings
  • baselines and the 44 guide screenshots recaptured on CI (ui-gates.yml run 30515931160), never locally

The Korean particle sweep

864 places in the Korean guides wrote a particle with a space after the word it belongs to — "SBOM 은", "CVE 에", "설정 을". Korean orthography attaches it, and the guides did it both ways with no rule to settle it. latin-particle-space (S1) settles it and the 864 are fixed.

The linter had to change to see them. After the first 440 edits it reported a clean run while 357 instances of its own error remained: inline code spans were masked including their backticks, so the most common form — a particle right after a code span — presented to the rules as a plain space, which no leading character class can match. The mask now blanks the span's content and keeps the delimiters. Still length-preserving, and it cannot make another rule fire (no pattern in the catalogue matches a backtick); it removes a false-positive shape instead, since "에 x 의해" used to mask down to "에 의해" and passive-eui-hae matched across a code span that was never part of the phrase.

The character class is deliberately narrow. A period or comma cannot be in it — "…합니다. 이 페이지는" and "…남고, 이 표기는" are the determiner 이, 66 of them, all correct — and neither can | or -, for the same reason at the start of a table cell or list item. The trailing lookahead is what keeps the rest honest: without it "로그인", "라이선스", "의존성", "이하" and "가져오기" all read as a particle plus a space.

How it was verified, since a mechanical sweep over 32 documentation files is only as good as its guardrails:

  • the fixer copies the linter's masking functions verbatim and applies edits at indices found in the masked line, which is sound only because the masking is length-preserving — a naive sed would have edited inside code blocks
  • all 481 changed lines are byte-identical to their originals with spaces stripped: every edit removes one space and nothing else
  • no line inside a fence, front matter or HTML comment moved, checked with the same state machine the linter uses
  • selftest.mjs gains three positive guards, because the failure being repaired here was a gate reporting zero while the error stood. Re-blanking the delimiters now fails the self-test — verified by doing exactly that and watching it fail. (The inline-code masking guard's own fixture was "에 의해 는", which is the error.)
  • nine headings changed width, which moved their anchors. The three inbound links to the one that was referenced are updated and the Docusaurus build reports no broken anchors for EN or KO. grep "oncall-runbook#" came back empty because the real form is oncall-runbook.md# — the build was the oracle, not the grep.

Left conservative on purpose: 만 / 도 / 나 / 라 are out of the particle set because they collide with words and numerals (만 = 10,000), and forms where a syllable continues after the particle ("UTF-8 이며") are outside the lookahead.

G0-5 is the differentiation plan's last step: a final pass over what the
gates cannot see. The gates were green, so what follows is what they were
not looking at.

Both project-detail tables render their header and their rows as two
independent flex rows that agree on column widths by convention, and the
convention had broken on both. The vulnerabilities track floor said 1250 px
while the declared columns summed to 1596; the header absorbed the shortfall
by shrinking its cells, the rows would not (their inner button had
`min-width: auto`, so it sized to untruncated text) and overflowed instead.
By the last column the two were 327 px apart — "Discovered" stood above the
SLA dates. Components had the same shape: floor 820 px, columns 1120,
321 px of overflow. Both are visible in the committed 1440 px baselines,
both were approved by eye, because a screenshot review answers "does this
look like the product" and not "is CVSS above the CVSS numbers".

Fixed by making the track authoritative — floor raised to the declared sum,
`min-w-0` on the row buttons, one gutter value instead of the header's
`gap-2` against the rows' `gap-3` (4 px of drift per column, 36 px of the
total). `tests/e2e/tableColumnAlignment.spec.ts` compares header cells to
row cells position by position and is column-agnostic, so the next column
added without moving the floor fails a check; verified it fails on each
table's old value before landing.

The rest of the audit, in order of how visible it was:

- The narrow-viewport budget goes 98 -> 0. Four causes: the 8-tab strip was
  an `inline-flex` sized to 767 px with no wrap point; the dashboard's
  recent-scans table needed 474 px inside a 340 px card; the project row's
  fixed right-hand clusters came to 386 px and collapsed the project name —
  the row's identity — to exactly 0 px while still overflowing; and the
  vulnerabilities rows above. The row's repository URL was taking 121 px of
  the 169 px left, so the name got four characters and an ellipsis.
- The a11y ratchet goes 8 -> 0, on both themes. `aria-sort` sat on a
  `button`, where it is invalid ARIA and assistive technology ignores it, so
  it announced nothing while looking like it did — and four unit tests
  asserted it, which is how it survived. The accessible name already carries
  the state and the next action in both locales.
- The vulnerabilities toolbar had nine controls in a row with no wrap point.
  At 1440 px flexbox squeezed the last of them: "Add filter" rendered one
  character per line. Korean, whose labels are wider, got "업그" for
  "업그레이드별".
- A project id that does not resolve drew the whole console around nothing:
  eight clickable tabs each leading to another error, a Scan button, a
  release picker reading "No version", the governance band reporting a
  second failure of its own, and the backend's untranslated "Project Not
  Found / project <uuid> not found" inside an otherwise Korean page.
- `setUiLanguage` wrote `i18nextLng`; `lib/i18n.ts` reads `trustedoss.lang`
  and nothing else. Setting a language in a spec did nothing at all and the
  capture stayed English. Nothing called it yet, which is the only reason no
  mislabelled screenshot shipped — the key is now one exported constant.
- Korean said "critical" in five places whose own severity legend, on the
  same screen, said "치명"; nine strings spaced a particle after a Latin
  word ("SBOM 은", "CVE 에").
- The overview's recent-scans card described itself as "Last five scans for
  this project" directly above "No scans have been recorded yet."
- `make screenshots-optimize` was a no-op. oxipng has no convention that
  `--out -` means stdout: it wrote the optimized bytes to a file named `-`
  and left stdout empty, so the `-s` guard correctly kept all 44 originals
  while the recipe reported no change. With `--stdout` the first asset
  measured goes 128,443 -> 38,988 bytes.

The narrow spec now records what each screen spilled on every run, not only
on a baseline update, because the frozen number told nobody what it was made
of.

Visual baselines and the guide screenshots still need a CI recapture: the
tables now truncate and align where they used to overflow.
Continuing the G0-5 pass over the Korean and dark surfaces.

The Trivy DB panel's "Next refresh" tile put the same relative time in its
label and in its value, inside a label that assumed the value was in the
future: "Next refresh in 5 days ago", with "5 days ago" repeated underneath.
A scheduled refresh that has not happened is the case the panel exists to
report — the stale badge beside it says so — so the label now states the
field and the value states when, which is what the sibling feeds' tiles
already did ("Next sync", "Next tick"). This one read the same way in
English; Korean only made it easier to notice.

The compliance rows' overflow markers ("+10 more") were the last item in a
flex row whose badges claimed the width first, so they were squeezed to a few
pixels and wrapped their own text: "+10개 더" came out as three stacked
lines, and the first row's height no longer matched its neighbours'. A short
fixed count is the one thing in that row that must never give.

Also checked and NOT a defect: the scans table's last column looks clipped at
1440 px in both locales, but nothing on that page extends past the right
edge — measured, not eyeballed.
…hat were English

From `ui-gates.yml` run 30515931160 (`update_baselines=true` +
`capture_screenshots=true`) on this branch. Both are CI-only by policy: a
local run measures a local seed, and the guide assets need a database seeded
from empty.

The runner agrees with the local verdict on both budgets — the accessibility
ratchet is empty for all 16 screen/theme pairs and every narrow-viewport
entry is 0, including `projects-list`, whose numbers had been the ones that
disagreed between local and CI.

The correction in this commit is the four `-ko` guide screenshots. The
previous commit described `setUiLanguage` as having no callers, so the wrong
localStorage key was harmless. It has four:
`tests/screenshots/capture_ko_locale.spec.ts` uses it for the login,
forgot-password, project-create and notification-preferences captures. It
wrote `i18nextLng` while `lib/i18n.ts` reads `trustedoss.lang` and nothing
else, so every one of those four came out in English — and they are the
assets the Korean user guide embeds. `user-auth-login-ko.png` shipped a
"Sign in to TRUSCA" card with an "English" locale toggle to readers of the
Korean documentation. They are Korean in this capture.

The visual baselines change on all seven authenticated screens because the
two project-detail tables now align and truncate where they used to overflow,
and because both toolbars wrap instead of squeezing their last controls.
864 places in the Korean guides wrote a particle with a space after the word
it belongs to — "SBOM 은", "`CVE` 에", "**설정** 을". Korean orthography
attaches a particle to the preceding word, including after Latin script, and
the guides did it both ways with no rule to settle it.

`latin-particle-space` (S1) settles it. Its leading character class is
deliberately narrow: Latin letters, digits, `)`, `]`, a backtick, `*` and `"`.
A period or a comma cannot be in it, because "…합니다. 이 페이지는" and
"…남고, 이 표기는" are the determiner 이, not the subject particle, and there
are 66 of those. Nor can `|` or `-`, for the same reason at the start of a
table cell or a list item. A trailing lookahead is what keeps the rest honest:
without it "로그인", "라이선스", "의존성", "이하" and "가져오기" all read as a
particle plus a space.

The mask had to change for the rule to work at all. Inline code spans were
blanked including their delimiters, so the most common form of this error —
357 of the 864, a particle right after a code span — presented to the rules as
a space, which no leading class can match. The linter reported a clean run
over every one of them. The mask now blanks the span's CONTENT and keeps the
backticks, which is length-preserving as before and cannot make another rule
fire (no pattern in the catalogue matches a backtick). It removes a
false-positive shape too: "에 `x` 의해" used to mask down to "에       의해",
which `passive-eui-hae` matched across a code span that was never part of the
phrase.

`selftest.mjs` gains three positive guards, because the failure this repairs
was a gate reporting zero while the error stood. Re-blanking the delimiters
now fails the self-test instead of quietly costing the catalogue a rule.
Verified by doing exactly that and watching it fail. The inline-code masking
guard's own fixture was "`에 의해` 는", which is the error — corrected.

Every edit removes one space and nothing else: all 481 changed lines are
byte-identical to their originals with spaces stripped, and no line inside a
fence, front matter or HTML comment moved. Nine headings changed width, which
moved their anchors; the three inbound links to the one that was referenced
(`oncall-runbook` scenario 3) are updated, and the Docusaurus build reports no
broken anchors for EN or KO.
@haksungjang
haksungjang merged commit 169b618 into main Jul 30, 2026
27 checks passed
@haksungjang
haksungjang deleted the fix/g05-final-audit branch July 30, 2026 14:40
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