From 9fc620d9738496209c3a3463dafc2a9144259949 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 15 Apr 2026 16:47:00 +0100 Subject: [PATCH 1/6] Improved mini-table resource name truncation --- app/components/form/fields/DisksTableField.tsx | 5 ++--- app/table/columns/common.tsx | 2 +- app/ui/lib/MiniTable.tsx | 18 ++++++++++++++---- app/ui/styles/components/mini-table.css | 2 +- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index 2f56e61aef..4be93dd5b5 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -16,8 +16,7 @@ import { CreateDiskSideModalForm } from '~/forms/disk-create' import type { InstanceCreateInput } from '~/forms/instance-create' import { sizeCellInner } from '~/table/columns/common' import { Button } from '~/ui/lib/Button' -import { MiniTable } from '~/ui/lib/MiniTable' -import { Truncate } from '~/ui/lib/Truncate' +import { MiniTable, TruncateNameCell } from '~/ui/lib/MiniTable' export type DiskTableItem = | (DiskCreate & { action: 'create' }) @@ -52,7 +51,7 @@ export function DisksTableField({ columns={[ { header: 'Name', - cell: (item) => , + cell: (item) => , }, { header: 'Action', diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index e6cb831039..e6904cdf02 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -42,7 +42,7 @@ function instanceStateCell(info: Info) { export function sizeCellInner(value: number) { const size = filesize(value, { base: 2, output: 'object' }) return ( - + {size.value} {size.unit} ) diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index 29e121d045..903720517a 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -1,3 +1,5 @@ +import { type ReactNode } from 'react' + /* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -29,10 +31,10 @@ const Body = classed.tbody`` const Row = classed.tr`*:border-default last:*:border-b *:first:border-l *:last:border-r` -const Cell = ({ children }: Children) => { +const Cell = ({ children, className }: { children: ReactNode; className?: string }) => { return ( - -
{children}
+ +
{children}
) } @@ -78,6 +80,12 @@ const RemoveCell = ({ onClick, label }: { onClick: () => void; label: string }) ) +export const TruncateNameCell = ({ name }: { name: string }) => ( +
+
{name}
+
+) + type ClearAndAddButtonsProps = { addButtonCopy: string disabled: boolean @@ -153,7 +161,9 @@ export function MiniTable({ items.map((item, index) => ( {columns.map((column, colIndex) => ( - {column.cell(item, index)} + + {column.cell(item, index)} + ))} div { - @apply border-default flex h-9 items-center border border-y border-r-0 py-3 pr-6 pl-3; + @apply border-default flex h-9 items-center border border-y border-r-0 pr-4 pl-3; } /* first cell's div */ From 3c126af50d29441dc91b6b30b464c2b1b17af485 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 15 Apr 2026 17:00:16 +0100 Subject: [PATCH 2/6] Typefix --- app/ui/lib/MiniTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index 903720517a..926663363a 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -161,7 +161,7 @@ export function MiniTable({ items.map((item, index) => ( {columns.map((column, colIndex) => ( - + {column.cell(item, index)} ))} From 461fc516d7e64485f83a9ebcd072e38052ecf4e5 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Thu, 16 Apr 2026 13:05:34 +0100 Subject: [PATCH 3/6] Ue text width measurement for column widths --- .../form/fields/DisksTableField.tsx | 4 +- .../form/fields/NetworkInterfaceField.tsx | 6 +- app/components/form/fields/TlsCertsField.tsx | 2 +- app/forms/firewall-rules-common.tsx | 4 +- app/forms/instance-create.tsx | 4 +- app/forms/network-interface-edit.tsx | 2 +- app/ui/lib/MiniTable.tsx | 106 ++++++++++++++-- app/ui/lib/font-widths.gen.ts | 114 ++++++++++++++++++ app/ui/lib/text-width.ts | 26 ++++ package-lock.json | 40 ++++++ package.json | 3 + tools/gen-font-widths.ts | 107 ++++++++++++++++ 12 files changed, 395 insertions(+), 23 deletions(-) create mode 100644 app/ui/lib/font-widths.gen.ts create mode 100644 app/ui/lib/text-width.ts create mode 100644 tools/gen-font-widths.ts diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index 4be93dd5b5..73e4033947 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -16,7 +16,7 @@ import { CreateDiskSideModalForm } from '~/forms/disk-create' import type { InstanceCreateInput } from '~/forms/instance-create' import { sizeCellInner } from '~/table/columns/common' import { Button } from '~/ui/lib/Button' -import { MiniTable, TruncateNameCell } from '~/ui/lib/MiniTable' +import { MiniTable } from '~/ui/lib/MiniTable' export type DiskTableItem = | (DiskCreate & { action: 'create' }) @@ -51,7 +51,7 @@ export function DisksTableField({ columns={[ { header: 'Name', - cell: (item) => , + text: (item) => item.name, }, { header: 'Action', diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 0dbe3b6397..450a596458 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -116,9 +116,9 @@ export function NetworkInterfaceField({ ariaLabel="Network Interfaces" items={value.params} columns={[ - { header: 'Name', cell: (item) => item.name }, - { header: 'VPC', cell: (item) => item.vpcName }, - { header: 'Subnet', cell: (item) => item.subnetName }, + { header: 'Name', text: (item) => item.name }, + { header: 'VPC', text: (item) => item.vpcName }, + { header: 'Subnet', text: (item) => item.subnetName }, ]} rowKey={(item) => item.name} onRemoveItem={(item) => diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 824f986121..9d8c1726a1 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -50,7 +50,7 @@ export function TlsCertsField({ control }: { control: Control item.name }]} + columns={[{ header: 'Name', text: (item) => item.name }]} rowKey={(item) => item.name} onRemoveItem={(item) => onChange(items.filter((i) => i.name !== item.name))} removeLabel={(item) => `remove cert ${item.name}`} diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx index 56340d04f9..ef1ccdcd04 100644 --- a/app/forms/firewall-rules-common.tsx +++ b/app/forms/firewall-rules-common.tsx @@ -314,11 +314,11 @@ const targetAndHostTableColumns = [ }, { header: 'Value', - cell: (item: VpcFirewallRuleTarget | VpcFirewallRuleHostFilter) => item.value, + text: (item: VpcFirewallRuleTarget | VpcFirewallRuleHostFilter) => item.value, }, ] -const portTableColumns = [{ header: 'Port ranges', cell: (p: string) => p }] +const portTableColumns = [{ header: 'Port ranges', text: (p: string) => p }] const protocolTableColumns = [ { diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 33fc7ab8db..d46baa467c 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -1017,8 +1017,8 @@ const NetworkingSection = ({ ariaLabel="Floating IPs" items={attachedFloatingIps} columns={[ - { header: 'Name', cell: (item) => item.name }, - { header: 'IP', cell: (item) => item.ip }, + { header: 'Name', text: (item) => item.name }, + { header: 'IP', text: (item) => item.ip }, ]} rowKey={(item) => item.name} onRemoveItem={(item) => detachFloatingIp(item.name)} diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 8bd206ccd5..0302c8b317 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -164,7 +164,7 @@ export function EditNetworkInterfaceForm({ className="mb-4" ariaLabel="Transit IPs" items={transitIps} - columns={[{ header: 'Transit IPs', cell: (ip) => ip }]} + columns={[{ header: 'Transit IPs', text: (ip) => ip }]} rowKey={(ip) => ip} onRemoveItem={(ip) => { form.setValue( diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index 926663363a..2f42e350a3 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react' +import { type ReactNode, useMemo } from 'react' /* * This Source Code Form is subject to the terms of the Mozilla Public @@ -14,6 +14,7 @@ import { classed } from '~/util/classed' import { Button } from './Button' import { EmptyMessage } from './EmptyMessage' import { Table as BigTable } from './Table' +import { textWidth } from './text-width' type Children = { children: React.ReactNode } @@ -31,9 +32,17 @@ const Body = classed.tbody`` const Row = classed.tr`*:border-default last:*:border-b *:first:border-l *:last:border-r` -const Cell = ({ children, className }: { children: ReactNode; className?: string }) => { +const Cell = ({ + children, + className, + style, +}: { + children: ReactNode + className?: string + style?: React.CSSProperties +}) => { return ( - +
{children}
) @@ -80,9 +89,9 @@ const RemoveCell = ({ onClick, label }: { onClick: () => void; label: string })
) -export const TruncateNameCell = ({ name }: { name: string }) => ( +const TruncateCell = ({ text }: { text: string }) => (
-
{name}
+
{text}
) @@ -115,8 +124,14 @@ export const ClearAndAddButtons = ({ type Column = { header: string - cell: (item: T, index: number) => React.ReactNode -} +} & ( + | { cell: (item: T, index: number) => React.ReactNode } + | { + /** Columns with `text` auto-truncate and share remaining table width + * proportionally based on their measured text content. */ + text: (item: T) => string + } +) type MiniTableProps = { ariaLabel: string @@ -133,6 +148,62 @@ type MiniTableProps = { className?: string } +function isTextColumn( + col: Column +): col is { header: string; text: (item: T) => string } { + return 'text' in col +} + +/** + * For each text column, find the max text width across all items, then + * distribute remaining table width proportionally. Returns a per-column + * style object (undefined for fit-to-content columns). + */ +function useColumnWidths(columns: Column[], items: T[]) { + return useMemo(() => { + const hasTextCols = columns.some(isTextColumn) + if (!hasTextCols || items.length === 0) { + // Fall back to the old behavior: first column gets w-full + return columns.map((_, i) => (i === 0 ? 'w-full' : undefined)) + } + + // Measure max natural text width per text column + const maxWidths = columns.map((col) => { + if (!isTextColumn(col)) return 0 + let max = 0 + for (const item of items) { + const w = textWidth(col.text(item)) + if (w > max) max = w + } + return max + }) + + const textColCount = maxWidths.filter((w) => w > 0).length + const totalTextWidth = maxWidths.reduce((sum, w) => sum + w, 0) + if (totalTextWidth === 0 || textColCount === 0) { + return columns.map((_, i) => (i === 0 ? 'w-full' : undefined)) + } + + // How much wider the widest text column can be vs the narrowest. + // 1 = all equal, higher = more variation. + const maxWidthRatio = 5 / 2 + const equalShare = totalTextWidth / textColCount + const floor = equalShare + const ceiling = equalShare * maxWidthRatio + const clamped = maxWidths.map((w) => + w > 0 ? Math.min(Math.max(w, floor), ceiling) : 0 + ) + const clampedTotal = clamped.reduce((sum, w) => sum + w, 0) + + // Text columns share available space proportionally; others fit content + return columns.map((col, i) => { + if (!isTextColumn(col)) return undefined + const pct = (clamped[i] / clampedTotal) * 100 + return { width: `${pct.toFixed(1)}%` } as const + }) + }, [columns, items]) +} + /** If `emptyState` is left out, `MiniTable` renders null when `items` is empty. */ export function MiniTable({ ariaLabel, @@ -144,6 +215,8 @@ export function MiniTable({ emptyState, className, }: MiniTableProps) { + const colWidths = useColumnWidths(columns, items) + if (!emptyState && items.length === 0) return null return ( @@ -160,11 +233,20 @@ export function MiniTable({ {items.length ? ( items.map((item, index) => ( - {columns.map((column, colIndex) => ( - - {column.cell(item, index)} - - ))} + {columns.map((column, colIndex) => { + const w = colWidths[colIndex] + const className = typeof w === 'string' ? w : undefined + const style = typeof w === 'object' ? w : undefined + return ( + + {isTextColumn(column) ? ( + + ) : ( + column.cell(item, index) + )} + + ) + })} onRemoveItem(item)} diff --git a/app/ui/lib/font-widths.gen.ts b/app/ui/lib/font-widths.gen.ts new file mode 100644 index 0000000000..11fb1a3ed8 --- /dev/null +++ b/app/ui/lib/font-widths.gen.ts @@ -0,0 +1,114 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +// Generated by tools/gen-font-widths.ts — do not edit + +/** Per-character width as a fraction of font-size for SuisseIntl Regular */ +export const sansWidths: Record = { + ' ': 0.237, + '!': 0.245, + '"': 0.309, + '#': 0.666, + '$': 0.587, + '%': 0.998, + '&': 0.703, + "'": 0.156, + '(': 0.245, + ')': 0.245, + '*': 0.352, + '+': 0.678, + ',': 0.245, + '-': 0.405, + '.': 0.245, + '/': 0.377, + '0': 0.661, + '1': 0.368, + '2': 0.59, + '3': 0.587, + '4': 0.624, + '5': 0.607, + '6': 0.615, + '7': 0.513, + '8': 0.626, + '9': 0.61, + ':': 0.245, + ';': 0.245, + '<': 0.5, + '=': 0.678, + '>': 0.5, + '?': 0.499, + '@': 0.849, + 'A': 0.66, + 'B': 0.672, + 'C': 0.726, + 'D': 0.73, + 'E': 0.613, + 'F': 0.56, + 'G': 0.77, + 'H': 0.757, + 'I': 0.274, + 'J': 0.552, + 'K': 0.666, + 'L': 0.571, + 'M': 0.884, + 'N': 0.755, + 'O': 0.782, + 'P': 0.636, + 'Q': 0.782, + 'R': 0.679, + 'S': 0.641, + 'T': 0.574, + 'U': 0.731, + 'V': 0.638, + 'W': 0.922, + 'X': 0.656, + 'Y': 0.624, + 'Z': 0.633, + '[': 0.316, + '\\': 0.5, + ']': 0.316, + '^': 0.5, + '_': 0.458, + '`': 0.251, + 'a': 0.559, + 'b': 0.601, + 'c': 0.557, + 'd': 0.604, + 'e': 0.581, + 'f': 0.313, + 'g': 0.597, + 'h': 0.578, + 'i': 0.235, + 'j': 0.235, + 'k': 0.505, + 'l': 0.235, + 'm': 0.899, + 'n': 0.578, + 'o': 0.59, + 'p': 0.604, + 'q': 0.604, + 'r': 0.355, + 's': 0.525, + 't': 0.325, + 'u': 0.568, + 'v': 0.487, + 'w': 0.72, + 'x': 0.52, + 'y': 0.466, + 'z': 0.488, + '{': 0.29, + '|': 0.255, + '}': 0.29, + '~': 0.5, +} + +/** Fallback width for unmapped characters (average of all measured widths) */ +export const sansDefaultWidth = 0.5316 + +/** GT America Mono Regular — single width for all characters */ +export const monoWidth = 0.62 diff --git a/app/ui/lib/text-width.ts b/app/ui/lib/text-width.ts new file mode 100644 index 0000000000..342d9e54bd --- /dev/null +++ b/app/ui/lib/text-width.ts @@ -0,0 +1,26 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { monoWidth, sansDefaultWidth, sansWidths } from './font-widths.gen' + +/** + * Estimate the rendered width of `text` in our sans or mono font, returned + * as a unitless number proportional to actual pixel width. Multiply by + * font-size (in px) for an approximate pixel value. + * + * Uses pre-generated per-character advance-width ratios from the actual font + * files — no Canvas or DOM measurement needed. + */ +export function textWidth(text: string, font: 'sans' | 'mono' = 'sans'): number { + if (font === 'mono') return text.length * monoWidth + let width = 0 + for (const char of text) { + width += sansWidths[char] ?? sansDefaultWidth + } + return width +} diff --git a/package-lock.json b/package-lock.json index 9e9d088bc5..0380adf0f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "@types/lodash.throttle": "^4.1.9", "@types/md5": "^2.3.5", "@types/mousetrap": "^1.6.15", + "@types/opentype.js": "^1.3.9", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@types/react-is": "^19.2.0", @@ -79,6 +80,7 @@ "ip-num": "^1.5.1", "jsdom": "^25.0.1", "msw": "^2.7.5", + "opentype.js": "^1.3.4", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "oxlint-tsgolint": "^0.17.1", @@ -6200,6 +6202,13 @@ "undici-types": "~7.8.0" } }, + "node_modules/@types/opentype.js": { + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-1.3.9.tgz", + "integrity": "sha512-KOGywvDPncA4/tTWV5xKNhjpsoSSAHIx3mHOhL5l3XX+c6Xu2dQnHvGs7mRNQsQRte1EqmQ0cPQQ8Z14lkv+yw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", @@ -10183,6 +10192,23 @@ "license": "MIT", "peer": true }, + "node_modules/opentype.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", + "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "string.prototype.codepointat": "^0.2.1", + "tiny-inflate": "^1.0.3" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -11807,6 +11833,13 @@ "dev": true, "license": "MIT" }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -11939,6 +11972,13 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true, + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/package.json b/package.json index 6f138d5055..38cba7cd66 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "fmt:check": "oxfmt --check", "openapi-gen-ts": "openapi-gen-ts", "gen-api": "./tools/generate_api_client.sh", + "gen-font-widths": "tsx tools/gen-font-widths.ts", "postinstall": "patch-package", "prepare": "husky" }, @@ -87,6 +88,7 @@ "@types/lodash.throttle": "^4.1.9", "@types/md5": "^2.3.5", "@types/mousetrap": "^1.6.15", + "@types/opentype.js": "^1.3.9", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@types/react-is": "^19.2.0", @@ -103,6 +105,7 @@ "ip-num": "^1.5.1", "jsdom": "^25.0.1", "msw": "^2.7.5", + "opentype.js": "^1.3.4", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "oxlint-tsgolint": "^0.17.1", diff --git a/tools/gen-font-widths.ts b/tools/gen-font-widths.ts new file mode 100644 index 0000000000..9967760ee3 --- /dev/null +++ b/tools/gen-font-widths.ts @@ -0,0 +1,107 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/** + * Generates a TypeScript module with per-character width ratios for our + * sans (SuisseIntl) and mono (GT America Mono) fonts. Widths are stored as + * a fraction of em-size so callers can multiply by font-size to get pixel + * widths — no Canvas or DOM needed at runtime. + * + * Usage: npx tsx tools/gen-font-widths.ts + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import opentype from 'opentype.js' + +const FONTS_DIR = resolve(import.meta.dirname, '../app/ui/assets/fonts') +const OUT_PATH = resolve(import.meta.dirname, '../app/ui/lib/font-widths.gen.ts') + +// Printable ASCII range +const FIRST_CHAR = 32 // space +const LAST_CHAR = 126 // tilde + +function measureFont(fontPath: string): { + widths: Record + defaultWidth: number +} { + const buffer = readFileSync(fontPath) + const font = opentype.parse(buffer.buffer) + + const widths: Record = {} + let totalWidth = 0 + let count = 0 + + for (let code = FIRST_CHAR; code <= LAST_CHAR; code++) { + const char = String.fromCharCode(code) + const glyph = font.charToGlyph(char) + if (glyph && glyph.advanceWidth != null) { + const ratio = glyph.advanceWidth / font.unitsPerEm + widths[char] = ratio + totalWidth += ratio + count++ + } + } + + // Default width for unmapped characters — use the average + const defaultWidth = count > 0 ? totalWidth / count : 0.5 + + return { widths, defaultWidth } +} + +function formatNumber(n: number): string { + // 4 decimal places is plenty for proportional sizing + return n.toFixed(4).replace(/0+$/, '').replace(/\.$/, '.0') +} + +function escapeKey(char: string): string { + if (char === "'") return '"\'"' + if (char === '\\') return "'\\\\'" + return `'${char}'` +} + +const sans = measureFont(resolve(FONTS_DIR, 'SuisseIntl-Regular-WebS.woff')) +const mono = measureFont(resolve(FONTS_DIR, 'GT-America-Mono-Regular-OCC.woff')) + +// For mono, verify all widths are the same (that's what makes it mono) +const monoVals = Object.values(mono.widths) +const monoWidth = monoVals[0] + +let output = `/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +// Generated by tools/gen-font-widths.ts — do not edit + +/** Per-character width as a fraction of font-size for SuisseIntl Regular */ +export const sansWidths: Record = {\n` + +for (let code = FIRST_CHAR; code <= LAST_CHAR; code++) { + const char = String.fromCharCode(code) + const w = sans.widths[char] + if (w != null) { + output += ` ${escapeKey(char)}: ${formatNumber(w)},\n` + } +} + +output += `} + +/** Fallback width for unmapped characters (average of all measured widths) */ +export const sansDefaultWidth = ${formatNumber(sans.defaultWidth)} + +/** GT America Mono Regular — single width for all characters */ +export const monoWidth = ${formatNumber(monoWidth)} +` + +writeFileSync(OUT_PATH, output) +console.info(`Generated ${OUT_PATH}`) From 75140193ed233e97d8549646752800ae37794def Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Thu, 16 Apr 2026 14:23:59 +0100 Subject: [PATCH 4/6] Fmt --- app/ui/lib/font-widths.gen.ts | 108 +++++++++++++++++----------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/app/ui/lib/font-widths.gen.ts b/app/ui/lib/font-widths.gen.ts index 11fb1a3ed8..31f4daa8bc 100644 --- a/app/ui/lib/font-widths.gen.ts +++ b/app/ui/lib/font-widths.gen.ts @@ -14,7 +14,7 @@ export const sansWidths: Record = { '!': 0.245, '"': 0.309, '#': 0.666, - '$': 0.587, + $: 0.587, '%': 0.998, '&': 0.703, "'": 0.156, @@ -43,64 +43,64 @@ export const sansWidths: Record = { '>': 0.5, '?': 0.499, '@': 0.849, - 'A': 0.66, - 'B': 0.672, - 'C': 0.726, - 'D': 0.73, - 'E': 0.613, - 'F': 0.56, - 'G': 0.77, - 'H': 0.757, - 'I': 0.274, - 'J': 0.552, - 'K': 0.666, - 'L': 0.571, - 'M': 0.884, - 'N': 0.755, - 'O': 0.782, - 'P': 0.636, - 'Q': 0.782, - 'R': 0.679, - 'S': 0.641, - 'T': 0.574, - 'U': 0.731, - 'V': 0.638, - 'W': 0.922, - 'X': 0.656, - 'Y': 0.624, - 'Z': 0.633, + A: 0.66, + B: 0.672, + C: 0.726, + D: 0.73, + E: 0.613, + F: 0.56, + G: 0.77, + H: 0.757, + I: 0.274, + J: 0.552, + K: 0.666, + L: 0.571, + M: 0.884, + N: 0.755, + O: 0.782, + P: 0.636, + Q: 0.782, + R: 0.679, + S: 0.641, + T: 0.574, + U: 0.731, + V: 0.638, + W: 0.922, + X: 0.656, + Y: 0.624, + Z: 0.633, '[': 0.316, '\\': 0.5, ']': 0.316, '^': 0.5, - '_': 0.458, + _: 0.458, '`': 0.251, - 'a': 0.559, - 'b': 0.601, - 'c': 0.557, - 'd': 0.604, - 'e': 0.581, - 'f': 0.313, - 'g': 0.597, - 'h': 0.578, - 'i': 0.235, - 'j': 0.235, - 'k': 0.505, - 'l': 0.235, - 'm': 0.899, - 'n': 0.578, - 'o': 0.59, - 'p': 0.604, - 'q': 0.604, - 'r': 0.355, - 's': 0.525, - 't': 0.325, - 'u': 0.568, - 'v': 0.487, - 'w': 0.72, - 'x': 0.52, - 'y': 0.466, - 'z': 0.488, + a: 0.559, + b: 0.601, + c: 0.557, + d: 0.604, + e: 0.581, + f: 0.313, + g: 0.597, + h: 0.578, + i: 0.235, + j: 0.235, + k: 0.505, + l: 0.235, + m: 0.899, + n: 0.578, + o: 0.59, + p: 0.604, + q: 0.604, + r: 0.355, + s: 0.525, + t: 0.325, + u: 0.568, + v: 0.487, + w: 0.72, + x: 0.52, + y: 0.466, + z: 0.488, '{': 0.29, '|': 0.255, '}': 0.29, From 6885f2ab0ecf391a039112fd7fca8f66603c87f1 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Thu, 16 Apr 2026 15:27:43 +0100 Subject: [PATCH 5/6] Use `measureText` instead --- app/ui/lib/MiniTable.tsx | 14 +++-- app/ui/lib/font-widths.gen.ts | 114 ---------------------------------- app/ui/lib/text-width.ts | 38 ++++++++---- package-lock.json | 40 ------------ package.json | 3 - tools/gen-font-widths.ts | 107 ------------------------------- 6 files changed, 34 insertions(+), 282 deletions(-) delete mode 100644 app/ui/lib/font-widths.gen.ts delete mode 100644 tools/gen-font-widths.ts diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index 2f42e350a3..8cf68a422f 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -167,12 +167,15 @@ function useColumnWidths(columns: Column[], items: T[]) { return columns.map((_, i) => (i === 0 ? 'w-full' : undefined)) } - // Measure max natural text width per text column + // Measure max natural text width per text column. + // text-sans-md = 400 14px/1.125rem SuisseIntl, letter-spacing 0.03rem + const font = '400 14px SuisseIntl' + const letterSpacing = '0.03rem' const maxWidths = columns.map((col) => { if (!isTextColumn(col)) return 0 let max = 0 for (const item of items) { - const w = textWidth(col.text(item)) + const w = textWidth(col.text(item), font, letterSpacing) if (w > max) max = w } return max @@ -184,12 +187,13 @@ function useColumnWidths(columns: Column[], items: T[]) { return columns.map((_, i) => (i === 0 ? 'w-full' : undefined)) } - // How much wider the widest text column can be vs the narrowest. + // Max ratio between widest and narrowest text column. // 1 = all equal, higher = more variation. const maxWidthRatio = 5 / 2 const equalShare = totalTextWidth / textColCount - const floor = equalShare - const ceiling = equalShare * maxWidthRatio + const spread = Math.sqrt(maxWidthRatio) + const floor = equalShare / spread + const ceiling = equalShare * spread const clamped = maxWidths.map((w) => w > 0 ? Math.min(Math.max(w, floor), ceiling) : 0 ) diff --git a/app/ui/lib/font-widths.gen.ts b/app/ui/lib/font-widths.gen.ts deleted file mode 100644 index 31f4daa8bc..0000000000 --- a/app/ui/lib/font-widths.gen.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ - -// Generated by tools/gen-font-widths.ts — do not edit - -/** Per-character width as a fraction of font-size for SuisseIntl Regular */ -export const sansWidths: Record = { - ' ': 0.237, - '!': 0.245, - '"': 0.309, - '#': 0.666, - $: 0.587, - '%': 0.998, - '&': 0.703, - "'": 0.156, - '(': 0.245, - ')': 0.245, - '*': 0.352, - '+': 0.678, - ',': 0.245, - '-': 0.405, - '.': 0.245, - '/': 0.377, - '0': 0.661, - '1': 0.368, - '2': 0.59, - '3': 0.587, - '4': 0.624, - '5': 0.607, - '6': 0.615, - '7': 0.513, - '8': 0.626, - '9': 0.61, - ':': 0.245, - ';': 0.245, - '<': 0.5, - '=': 0.678, - '>': 0.5, - '?': 0.499, - '@': 0.849, - A: 0.66, - B: 0.672, - C: 0.726, - D: 0.73, - E: 0.613, - F: 0.56, - G: 0.77, - H: 0.757, - I: 0.274, - J: 0.552, - K: 0.666, - L: 0.571, - M: 0.884, - N: 0.755, - O: 0.782, - P: 0.636, - Q: 0.782, - R: 0.679, - S: 0.641, - T: 0.574, - U: 0.731, - V: 0.638, - W: 0.922, - X: 0.656, - Y: 0.624, - Z: 0.633, - '[': 0.316, - '\\': 0.5, - ']': 0.316, - '^': 0.5, - _: 0.458, - '`': 0.251, - a: 0.559, - b: 0.601, - c: 0.557, - d: 0.604, - e: 0.581, - f: 0.313, - g: 0.597, - h: 0.578, - i: 0.235, - j: 0.235, - k: 0.505, - l: 0.235, - m: 0.899, - n: 0.578, - o: 0.59, - p: 0.604, - q: 0.604, - r: 0.355, - s: 0.525, - t: 0.325, - u: 0.568, - v: 0.487, - w: 0.72, - x: 0.52, - y: 0.466, - z: 0.488, - '{': 0.29, - '|': 0.255, - '}': 0.29, - '~': 0.5, -} - -/** Fallback width for unmapped characters (average of all measured widths) */ -export const sansDefaultWidth = 0.5316 - -/** GT America Mono Regular — single width for all characters */ -export const monoWidth = 0.62 diff --git a/app/ui/lib/text-width.ts b/app/ui/lib/text-width.ts index 342d9e54bd..768ff557f4 100644 --- a/app/ui/lib/text-width.ts +++ b/app/ui/lib/text-width.ts @@ -6,21 +6,33 @@ * Copyright Oxide Computer Company */ -import { monoWidth, sansDefaultWidth, sansWidths } from './font-widths.gen' +let ctx: CanvasRenderingContext2D | null = null + +function getContext(): CanvasRenderingContext2D { + if (!ctx) { + const canvas = document.createElement('canvas') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- offscreen canvas always has 2d context + ctx = canvas.getContext('2d')! + } + return ctx +} + +const cache = new Map() /** - * Estimate the rendered width of `text` in our sans or mono font, returned - * as a unitless number proportional to actual pixel width. Multiply by - * font-size (in px) for an approximate pixel value. - * - * Uses pre-generated per-character advance-width ratios from the actual font - * files — no Canvas or DOM measurement needed. + * Measure the rendered pixel width of `text` using Canvas `measureText`. + * Accounts for font shaping, kerning, and letter-spacing. Reuses a single + * offscreen canvas context and caches results. */ -export function textWidth(text: string, font: 'sans' | 'mono' = 'sans'): number { - if (font === 'mono') return text.length * monoWidth - let width = 0 - for (const char of text) { - width += sansWidths[char] ?? sansDefaultWidth - } +export function textWidth(text: string, font: string, letterSpacing = '0px'): number { + const key = font + '\0' + letterSpacing + '\0' + text + const cached = cache.get(key) + if (cached != null) return cached + + const context = getContext() + context.font = font + context.letterSpacing = letterSpacing + const width = context.measureText(text).width + cache.set(key, width) return width } diff --git a/package-lock.json b/package-lock.json index 0380adf0f4..9e9d088bc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,6 @@ "@types/lodash.throttle": "^4.1.9", "@types/md5": "^2.3.5", "@types/mousetrap": "^1.6.15", - "@types/opentype.js": "^1.3.9", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@types/react-is": "^19.2.0", @@ -80,7 +79,6 @@ "ip-num": "^1.5.1", "jsdom": "^25.0.1", "msw": "^2.7.5", - "opentype.js": "^1.3.4", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "oxlint-tsgolint": "^0.17.1", @@ -6202,13 +6200,6 @@ "undici-types": "~7.8.0" } }, - "node_modules/@types/opentype.js": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-1.3.9.tgz", - "integrity": "sha512-KOGywvDPncA4/tTWV5xKNhjpsoSSAHIx3mHOhL5l3XX+c6Xu2dQnHvGs7mRNQsQRte1EqmQ0cPQQ8Z14lkv+yw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", @@ -10192,23 +10183,6 @@ "license": "MIT", "peer": true }, - "node_modules/opentype.js": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", - "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "string.prototype.codepointat": "^0.2.1", - "tiny-inflate": "^1.0.3" - }, - "bin": { - "ot": "bin/ot" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -11833,13 +11807,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string.prototype.codepointat": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", - "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", - "dev": true, - "license": "MIT" - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -11972,13 +11939,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tiny-inflate": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", - "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", - "dev": true, - "license": "MIT" - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/package.json b/package.json index 38cba7cd66..6f138d5055 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "fmt:check": "oxfmt --check", "openapi-gen-ts": "openapi-gen-ts", "gen-api": "./tools/generate_api_client.sh", - "gen-font-widths": "tsx tools/gen-font-widths.ts", "postinstall": "patch-package", "prepare": "husky" }, @@ -88,7 +87,6 @@ "@types/lodash.throttle": "^4.1.9", "@types/md5": "^2.3.5", "@types/mousetrap": "^1.6.15", - "@types/opentype.js": "^1.3.9", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@types/react-is": "^19.2.0", @@ -105,7 +103,6 @@ "ip-num": "^1.5.1", "jsdom": "^25.0.1", "msw": "^2.7.5", - "opentype.js": "^1.3.4", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "oxlint-tsgolint": "^0.17.1", diff --git a/tools/gen-font-widths.ts b/tools/gen-font-widths.ts deleted file mode 100644 index 9967760ee3..0000000000 --- a/tools/gen-font-widths.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ - -/** - * Generates a TypeScript module with per-character width ratios for our - * sans (SuisseIntl) and mono (GT America Mono) fonts. Widths are stored as - * a fraction of em-size so callers can multiply by font-size to get pixel - * widths — no Canvas or DOM needed at runtime. - * - * Usage: npx tsx tools/gen-font-widths.ts - */ - -import { readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' - -import opentype from 'opentype.js' - -const FONTS_DIR = resolve(import.meta.dirname, '../app/ui/assets/fonts') -const OUT_PATH = resolve(import.meta.dirname, '../app/ui/lib/font-widths.gen.ts') - -// Printable ASCII range -const FIRST_CHAR = 32 // space -const LAST_CHAR = 126 // tilde - -function measureFont(fontPath: string): { - widths: Record - defaultWidth: number -} { - const buffer = readFileSync(fontPath) - const font = opentype.parse(buffer.buffer) - - const widths: Record = {} - let totalWidth = 0 - let count = 0 - - for (let code = FIRST_CHAR; code <= LAST_CHAR; code++) { - const char = String.fromCharCode(code) - const glyph = font.charToGlyph(char) - if (glyph && glyph.advanceWidth != null) { - const ratio = glyph.advanceWidth / font.unitsPerEm - widths[char] = ratio - totalWidth += ratio - count++ - } - } - - // Default width for unmapped characters — use the average - const defaultWidth = count > 0 ? totalWidth / count : 0.5 - - return { widths, defaultWidth } -} - -function formatNumber(n: number): string { - // 4 decimal places is plenty for proportional sizing - return n.toFixed(4).replace(/0+$/, '').replace(/\.$/, '.0') -} - -function escapeKey(char: string): string { - if (char === "'") return '"\'"' - if (char === '\\') return "'\\\\'" - return `'${char}'` -} - -const sans = measureFont(resolve(FONTS_DIR, 'SuisseIntl-Regular-WebS.woff')) -const mono = measureFont(resolve(FONTS_DIR, 'GT-America-Mono-Regular-OCC.woff')) - -// For mono, verify all widths are the same (that's what makes it mono) -const monoVals = Object.values(mono.widths) -const monoWidth = monoVals[0] - -let output = `/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ - -// Generated by tools/gen-font-widths.ts — do not edit - -/** Per-character width as a fraction of font-size for SuisseIntl Regular */ -export const sansWidths: Record = {\n` - -for (let code = FIRST_CHAR; code <= LAST_CHAR; code++) { - const char = String.fromCharCode(code) - const w = sans.widths[char] - if (w != null) { - output += ` ${escapeKey(char)}: ${formatNumber(w)},\n` - } -} - -output += `} - -/** Fallback width for unmapped characters (average of all measured widths) */ -export const sansDefaultWidth = ${formatNumber(sans.defaultWidth)} - -/** GT America Mono Regular — single width for all characters */ -export const monoWidth = ${formatNumber(monoWidth)} -` - -writeFileSync(OUT_PATH, output) -console.info(`Generated ${OUT_PATH}`) From 148a9ac7661515cad8c5e4ff759da80d290cbcd9 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Thu, 16 Apr 2026 16:06:32 +0100 Subject: [PATCH 6/6] Conditionally render tooltip if truncated --- app/ui/lib/MiniTable.tsx | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index 8cf68a422f..56dd76aa8d 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useMemo } from 'react' +import { useRef, useState, type ReactNode, useMemo } from 'react' /* * This Source Code Form is subject to the terms of the Mozilla Public @@ -15,6 +15,7 @@ import { Button } from './Button' import { EmptyMessage } from './EmptyMessage' import { Table as BigTable } from './Table' import { textWidth } from './text-width' +import { Tooltip } from './Tooltip' type Children = { children: React.ReactNode } @@ -89,11 +90,35 @@ const RemoveCell = ({ onClick, label }: { onClick: () => void; label: string }) ) -const TruncateCell = ({ text }: { text: string }) => ( -
-
{text}
-
-) +const TruncateCell = ({ text }: { text: string }) => { + const ref = useRef(null) + const [isTruncated, setIsTruncated] = useState(false) + + const inner = ( +
{ + const el = ref.current + setIsTruncated(!!el && el.scrollWidth > el.clientWidth) + }} + > + {text} +
+ ) + + return ( +
+ {isTruncated ? ( + + {inner} + + ) : ( + inner + )} +
+ ) +} type ClearAndAddButtonsProps = { addButtonCopy: string