fix(ui): the column headers were never above their own data (G0-5) - #548
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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 (truncatesetswhite-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-0on the row buttons, and one gutter value instead of the header'sgap-2against the rows'gap-3(4 px of drift per column, 36 px of the total).Gate:
tests/e2e/tableColumnAlignment.spec.tsflattens 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.tscaptures the login, forgot-password, project-create and notification-preferences pages in Korean viasetUiLanguage. That helper wrotei18nextLng;lib/i18n.tsconfiguresdetection.order: ["localStorage"]againsttrustedoss.langand 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.pngshowed a "Sign in to TRUSCA" card with an "English" locale toggle to readers of the Korean guide, anduser-projects-create-form-ko.pngwas byte-identical to its English sibling.The key is now a single exported constant in
src/lib/languageStorage.ts— its own module becauselib/i18n.tscallsi18n.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:inline-flexsized to 767 px with no wrap pointThe 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 forcreated_byand its drawer.a11y ratchet 8 → 0, both themes.
aria-sortsat on abutton, 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 nocolumnheaderto 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
Project Not Found / project <uuid> not foundinside an otherwise Korean page. It is now one localized surface with the reason and the way back.ko-styleandi18n:checkare both clean.make screenshots-optimizewas 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-sguard correctly kept all 44 originals while the recipe reported no change (and left thescreenshots/-artifact). With--stdoutthe 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
--config=playwright.a11y.config.ts16/16 (8 screens × light/dark)--config=playwright.responsive.config.ts8/8 at a zeroed budgettests/e2e/tableColumnAlignment.spec.ts2/2, and confirmed failing on the old floorsnpm run lint0 errors,tsc --noEmitclean,i18n:checkin sync,ko-style --all --fail-on S20 findingsui-gates.ymlrun 30515931160), never locallyThe 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 "에 의해" andpassive-eui-haematched 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:
sedwould have edited inside code blocksselftest.mjsgains 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.)grep "oncall-runbook#"came back empty because the real form isoncall-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.