#301: the token tables need the same scroll wrapper

The sorted report finally named it: `table.api-tokens__table` at 833px
wide, with its `.visually-hidden` heading reaching right=737 — exactly
the document's scrollWidth. Same mechanism as the sessions table, a
second table I had not wrapped.

Locally the API-tokens table was empty and therefore narrow, which is why
this only ever appeared in CI. With a token present it reproduces:
without the wrapper 345px of page overflow, with it none.

The feed-token table gets the same treatment — it is built the same way
and would fail as soon as someone holds a feed token with a long name.

The "[in fitting scroller]" marker in the report is misleading for these:
`main.main` is a scroller, but it is `position: static`, so it never
clipped the absolutely positioned heading. Only a positioned ancestor
does — which is what `.table-scroll` now is.

Verified locally against a real stack, with a wide token table present:
reflow guard green, whole a11y pack green in both colour schemes.
This commit is contained in:
Claude Opus 5 2026-08-01 11:34:13 +02:00 committed by Claude Fable 5
parent 2422f3a28f
commit 69882ecbea
2 changed files with 78 additions and 70 deletions

View File

@ -172,52 +172,54 @@ function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element {
return ( return (
<> <>
<FormError error={error} /> <FormError error={error} />
<table className="table api-tokens__table"> <div className="table-scroll" tabIndex={0} role="region" aria-label={t('section.title')}>
<thead> <table className="table api-tokens__table">
<tr> <thead>
<th>{t('fields.name')}</th> <tr>
<th>{t('fields.scope')}</th> <th>{t('fields.name')}</th>
<th>{t('fields.ponds')}</th> <th>{t('fields.scope')}</th>
<th>{t('list.created')}</th> <th>{t('fields.ponds')}</th>
<th>{t('list.lastUsed')}</th> <th>{t('list.created')}</th>
<th>{t('list.expires')}</th> <th>{t('list.lastUsed')}</th>
<th>{t('list.status')}</th> <th>{t('list.expires')}</th>
<th> <th>{t('list.status')}</th>
<span className="visually-hidden">{t('common:tableActions')}</span> <th>
</th> <span className="visually-hidden">{t('common:tableActions')}</span>
</tr> </th>
</thead>
<tbody>
{tokens.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')}</td>
<td>
{token.ponds.length === 0
? t('list.allPonds')
: token.ponds.map((pond) => pond.name).join(', ')}
</td>
<td>{new Date(token.createdAt).toLocaleDateString()}</td>
<td>
{token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')}
</td>
<td>{token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}</td>
<td>{t(`list.${statusOf(token)}`)}</td>
<td>
{!token.revokedAt && (
<button
type="button"
className="button api-tokens__revoke"
onClick={() => void revoke(token.id)}
>
{t('list.revoke')}
</button>
)}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {tokens.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')}</td>
<td>
{token.ponds.length === 0
? t('list.allPonds')
: token.ponds.map((pond) => pond.name).join(', ')}
</td>
<td>{new Date(token.createdAt).toLocaleDateString()}</td>
<td>
{token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')}
</td>
<td>{token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}</td>
<td>{t(`list.${statusOf(token)}`)}</td>
<td>
{!token.revokedAt && (
<button
type="button"
className="button api-tokens__revoke"
onClick={() => void revoke(token.id)}
>
{t('list.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</> </>
); );
} }

View File

@ -85,32 +85,38 @@ export function FeedTokensSection(): React.JSX.Element {
)} )}
{tokens.data && tokens.data.length === 0 && <p>{t('feed.empty')}</p>} {tokens.data && tokens.data.length === 0 && <p>{t('feed.empty')}</p>}
{tokens.data && tokens.data.length > 0 && ( {tokens.data && tokens.data.length > 0 && (
<table className="table"> <div className="table-scroll" tabIndex={0} role="region" aria-label={t('feed.title')}>
<thead> <table className="table">
<tr> <thead>
<th>{t('fields.name')}</th> <tr>
<th>{t('list.created')}</th> <th>{t('fields.name')}</th>
<th>{t('list.lastUsed')}</th> <th>{t('list.created')}</th>
<th> <th>{t('list.lastUsed')}</th>
<span className="visually-hidden">{t('common:tableActions')}</span> <th>
</th> <span className="visually-hidden">{t('common:tableActions')}</span>
</tr> </th>
</thead>
<tbody>
{tokens.data.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{formatTime(token.createdAt)}</td>
<td>{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'}</td>
<td>
<button type="button" className="linklike" onClick={() => void remove(token.id)}>
{t('feed.delete')}
</button>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {tokens.data.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{formatTime(token.createdAt)}</td>
<td>{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'}</td>
<td>
<button
type="button"
className="linklike"
onClick={() => void remove(token.id)}
>
{t('feed.delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</section> </section>
); );