diff --git a/host/client_app/pages/Admin.tsx b/host/client_app/pages/Admin.tsx index b83feca5..e09618c5 100644 --- a/host/client_app/pages/Admin.tsx +++ b/host/client_app/pages/Admin.tsx @@ -40,7 +40,11 @@ function ToolCard({ item }: { item: MenuItem }) { {item.label} - {item.url} + {/* The url is the card's only disambiguator when two tools share a + label, so clipping it silently makes them indistinguishable. */} + + {item.url} + ); diff --git a/modules/dashboard/dashboard/pages/components/doctor/MigrationsCard.tsx b/modules/dashboard/dashboard/pages/components/doctor/MigrationsCard.tsx index e00bfe73..dd0d94a0 100644 --- a/modules/dashboard/dashboard/pages/components/doctor/MigrationsCard.tsx +++ b/modules/dashboard/dashboard/pages/components/doctor/MigrationsCard.tsx @@ -72,7 +72,11 @@ export function MigrationsCard({ migrations, commands, onCopyCommand }: Props) { {row.module} )} - {row.message} + {/* Clips at narrow widths, and the row offers no other way to + read it — the same remedy the short id above already uses. */} + + {row.message} + - {jobs.map((job) => ( -
- {job.name} - {job.status === 'error' ? ( - <> - - {job.reason - ? t(keys.file_storage.upload.failed_reason, { reason: job.reason }) - : t(keys.file_storage.toasts.upload_failed)} - - - onDismiss(job.id)} - /> - - ) : ( - <> - - - {job.percent}% - - onCancel(job.id)} - /> - - )} -
- ))} + {jobs.map((job) => { + // Filenames are arbitrary-length and the fixed 160px column clips them, + // while this row is the only place an in-flight upload is named at all. + // The failure text is the server's own reason, equally unbounded. + const failure = job.reason + ? t(keys.file_storage.upload.failed_reason, { reason: job.reason }) + : t(keys.file_storage.toasts.upload_failed); + return ( +
+ + {job.name} + + {job.status === 'error' ? ( + <> + + {failure} + + + onDismiss(job.id)} + /> + + ) : ( + <> + + + {job.percent}% + + onCancel(job.id)} + /> + + )} +
+ ); + })} ); } diff --git a/modules/settings/settings/pages/components/StoreTable.tsx b/modules/settings/settings/pages/components/StoreTable.tsx index 0a5d9ffa..3f1a820e 100644 --- a/modules/settings/settings/pages/components/StoreTable.tsx +++ b/modules/settings/settings/pages/components/StoreTable.tsx @@ -85,7 +85,14 @@ export function StoreTable({ settings, onDelete }: Props) { style={{ background: setting.value }} /> )} - {setting.value} + {/* `title` carries the full text: the cell clips most of a + realistic value (a connection string measured 66% hidden at + 1440px, not just on phones) and the row's only other route + to it is opening the editor. It repeats what is rendered, + so a masked secret stays masked here too. */} + + {setting.value} + diff --git a/modules/users/users/admin/components/UserRow.tsx b/modules/users/users/admin/components/UserRow.tsx index ea7f0189..6624bb9f 100644 --- a/modules/users/users/admin/components/UserRow.tsx +++ b/modules/users/users/admin/components/UserRow.tsx @@ -55,6 +55,18 @@ export function UserRow({ user, actions }: Props) { const { ago, until } = useRelativeTime(); const emptyValue = t(keys.users.common.empty_value); const invited = user.state === 'invited'; + // Both member lines truncate, and this row is the only place a name or an + // address is shown, so each carries its own rendered text in `title`: + // arbitrary-length user data clips on real accounts long before seeded ones. + // Each `title` sits on an element lifted above the link's row overlay (see + // below) — under it, the overlay would answer the hover instead. + const primary = invited ? user.email : user.full_name || user.email.split('@')[0]; + const secondary = invited + ? t(keys.users.user_row.invited_meta, { + ago: ago(user.invited_at), + until: until(user.invite_expires_at), + }) + : user.email; return ( - {invited ? user.email : user.full_name || user.email.split('@')[0]} + {/* `relative` puts the text back on top of the link's own overlay + so it answers the hover: on the overlay the tooltip would + belong to the whole row and follow the pointer across every + other cell. Wrapping the text does not stop it truncating — + the anchor is still the element that clips. */} + + {primary} + -
- {invited - ? t(keys.users.user_row.invited_meta, { - ago: ago(user.invited_at), - until: until(user.invite_expires_at), - }) - : user.email} + {/* Lifted for the same reason. The cost is that this one line stops + being part of the row-wide click target; the rest of the row, + including the name above it, still opens the user. */} +
+ {secondary}