From 5560ae70a27dc0e7305d97d4d33eec1e9175305d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 1 Oct 2026 17:03:19 +0800 Subject: [PATCH 1/2] feat(ui): two text tones in the record tables; the account name column hugs its names Text tones (owner option A "two tones", reviewed in artifacts/tmp/ui-polish/text): - tokens.css: the light label grey joins the muted grey (#66666e); data text role tokens: primary and labelled values the ink, secondary / reference / label one grey (#1f1f23 / #66666e, dark #e8e8ec / #a3a3ac), a dark selected row lifts its grey to #b8b8c0. - data.css 12: in the cells, headers and toolbars of the usage records, the error lists, the ops error dialog, accounts, API keys, users and the distribution tables, the theme's text tokens and upstream's grey classes resolve to those roles; labelled values (one + between DataTable's static hooks), row actions and line labels; the dark selected-row lift (style query, Firefox @supports twin). Distribution figures and page numbers read their roles directly; distribution figures are Geist Mono. - page files: usage token figures in the ink, Mono 13 / 400; A $ a 12px reference; latency Mono 12; IP 12px; reasoning effort secondary; endpoint path labelled; error message primary, platform and category secondary; accounts e-mail secondary, classification / route values labelled, priority figures primary, filter pills drawn as controls; keys and users exceptions; ops dialog time. Account name column: - softWrap.ts: a word with figures inside it (cindy-global-<18>-, eucadc5ca109engnmw@) is never broken; nameLinePieces / nameWidths model the cell's line breaks (validated against Chromium on all 863 names: one line at the one-line width, at most two lines at the two-line width, e-mails only before the @, no code split, no dot break). - AccountsView measures the page's names and e-mails on a canvas (from the page's rows, so a virtualised list keeps one width; re-measured when web fonts load) and binds the one-line, two-line and widest-piece widths as custom properties (theme only). - tables.css: the column takes the one-line width where the table has room beside the other columns (1920), else the two-line width (1440); it no longer takes a share of the spare width. A floor at the widest piece (10rem cap, 7rem below 1400px) lets a squeezed table break an over-long URL or code instead of scrolling sideways. Also: the scheduling-weight figures wrap at their "/" below 1400px, the usage cell's hover tips open leftwards, a note's long token may wrap. Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/console/data.css | 161 +++++++++++++++++- .../src/styles/console/pages/accounts.css | 38 ++++- .../styles/console/pages/dashboard-usage.css | 98 ++++++++++- .../src/styles/console/pages/ops-channels.css | 7 +- frontend/src/styles/console/pages/user.css | 25 ++- .../pages/users-groups-proxies-keys.css | 10 ++ frontend/src/styles/console/tables.css | 55 +++++- frontend/src/styles/console/tokens.css | 25 ++- frontend/src/utils/__tests__/softWrap.spec.ts | 44 +++++ frontend/src/utils/softWrap.ts | 113 ++++++++++-- frontend/src/views/admin/AccountsView.vue | 61 ++++++- 11 files changed, 591 insertions(+), 46 deletions(-) create mode 100644 frontend/src/utils/__tests__/softWrap.spec.ts diff --git a/frontend/src/styles/console/data.css b/frontend/src/styles/console/data.css index 00f4141f50e8..33e163f256b3 100644 --- a/frontend/src/styles/console/data.css +++ b/frontend/src/styles/console/data.css @@ -730,18 +730,21 @@ html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text color: rgb(var(--ui-label)); } -/* figures: requests and tokens muted, the actual spend ink, the account cost and the standard price muted (the - title attributes still name them) */ +/* figures (text roles, section 12): requests and tokens are the row's secondary figures, the actual spend its primary + figure, the account cost and the standard price reference values (the title attributes still name them); all of + them Geist Mono, tabular, like the token column (they were Geist next to Mono token counts) */ html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"] { - color: rgb(var(--ui-muted)); + color: rgb(var(--ui-data-secondary)); + font-family: var(--ui-font-mono); + font-variant-numeric: tabular-nums; } -html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"]:is([class~="text-green-600"]) { - color: rgb(var(--ui-ink)); +html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"]:is([class~="text-orange-500"], [class~="text-gray-400"]) { + color: rgb(var(--ui-data-reference)); } -html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"]:nth-child(3) { - font-family: var(--ui-font-mono); +html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"]:is([class~="text-green-600"]) { + color: rgb(var(--ui-data-primary)); } /* the colour key: an 8px square with 2px corners (REF §8.14 legend and usage rows) in the slice's colour; slices @@ -862,7 +865,8 @@ html.flat-theme [data-ui="pagination"] nav[aria-label="Pagination"] > button { border: 0; border-radius: var(--ui-radius-md); background-color: transparent; - color: rgb(var(--ui-muted)); + /* page numbers are figures: the secondary role (section 12; the sentence around the counts is the label) */ + color: rgb(var(--ui-data-secondary)); font-family: var(--ui-font-mono); font-size: 12px; font-weight: 500; @@ -1893,3 +1897,144 @@ html.flat-theme :where(div:is([class~="bg-primary-50"], [class~="bg-primary-50/5 color: rgb(var(--ui-muted)); } /* END S callouts */ + +/* ================================================================== 12 · text roles in the record tables + + Owner decision 2026-10-01, text option A "two tones" (artifacts/tmp/ui-polish/text/PREVIEW.md, where it was reviewed + and verified against a role table): in the cells and headers of 使用记录 (管理 and 用户, records and error requests), + 运维监控's error dialog, 账号管理, API 密钥, 用户管理 and the distribution tables, and in the accounts toolbar, the + pagination, the records tabs and the IP-region bar, a value is the ink and everything else one grey (tokens.css, data + text roles); before, a row held up to eight greys a step apart. Status, platform / identity, accent and token-glyph + colours stay; every other table keeps its colours. + primary names, e-mails, model, key / account names, token figures, the billed cost, balances, amounts, + priority, the error message; toolbar controls (buttons, selects, filter pills, page size) + labelled a value right after its inline label (管理分类 / 请求路由 / 最近 / 创建 / 过期 / 上游声明倍率 values, the + endpoint path after 入站 / 上游) + secondary unlabelled metadata: times, IPs, User-Agent, ids (#1), the model chain (↳), chips' text, status + words, platform, error category, reasoning effort, the masked key, an account's e-mail, page numbers + reference the account's billed cost (A $), the distribution tables' account cost and standard price + label headers, inline labels, 入站 / 上游, 首字 / 总耗时, units, placeholders (-), counts, the pagination + sentence, idle tabs, empty-value prompts, row-action icons + Inside those boxes the theme's text tokens and upstream's grey text classes resolve to the roles (custom properties + only, declared on each cell so a selected row can lift the tones it holds); an element whose class names another + role is named below or in its page file (accounts.css, dashboard-usage.css, user.css, + users-groups-proxies-keys.css), and the rules that paint a figure, an e-mail, a time or a page number read the role + directly (sections 3 and 4, the page files, ops-channels.css). Static hooks only (data-table, data-column, data-line, data-ui, upstream's static classes); one + joins two + static DataTable hooks (a labelled value). An empty-state row (colspan) is left alone. */ + +/* 12.1 the scope: each cell of the tables above, and the toolbars */ +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan])), +html.flat-theme :where([data-ui="accounts-toolbar"], [data-ui="pagination"], [data-ui~="usage-records"] > div:first-child, [data-ui="user-usage"] > div[class~="border-b"], [data-ui~="records-card"] > div[class~="justify-end"][class~="border-b"]) { + --ui-ink: var(--ui-data-primary); + --ui-ink-soft: var(--ui-data-secondary); + --ui-muted: var(--ui-data-label); + --ui-faint: var(--ui-data-label); + --ui-label: var(--ui-data-label); + --ui-table-head: var(--ui-data-label); + --ui-badge-muted: var(--ui-data-secondary); +} + +/* a cell's own text (TablePageLayout's scoped td colour, text-gray-700 / dark:text-gray-300: the users id, the accounts + usage notes) is a secondary value */ +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan])) { + --theme-text-gray-700: var(--ui-data-secondary); +} + +html.flat-theme.dark :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan])) { + --theme-text-gray-300: var(--ui-data-secondary); +} + +/* headers: the label; a sortable label under the pointer takes the primary tone (it was the in-between grey) */ +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > thead > tr > th) { + --ui-ink: var(--ui-data-primary); + --ui-ink-soft: var(--ui-data-primary); + --ui-muted: var(--ui-data-label); + --ui-faint: var(--ui-data-label); + --ui-label: var(--ui-data-label); + --ui-table-head: var(--ui-data-label); +} + +/* 12.2 upstream's grey text classes -> roles: the light class names the template's intent (900 primary, 500-800 + secondary, 400 label); dark mode reads the same role whatever dark: shade the template pairs with it */ +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan]), [data-ui="accounts-toolbar"], [data-ui="pagination"], [data-ui~="usage-records"] > div:first-child, [data-ui="user-usage"] > div[class~="border-b"], [data-ui~="records-card"] > div[class~="justify-end"][class~="border-b"]) :not(td, th):is([class~="text-gray-900"], [class~="text-gray-950"]) { + --theme-text-gray-100: var(--ui-data-primary); --theme-text-gray-200: var(--ui-data-primary); --theme-text-gray-300: var(--ui-data-primary); + --theme-text-gray-400: var(--ui-data-primary); --theme-text-gray-500: var(--ui-data-primary); --theme-text-gray-600: var(--ui-data-primary); + --theme-text-gray-700: var(--ui-data-primary); --theme-text-gray-800: var(--ui-data-primary); --theme-text-gray-900: var(--ui-data-primary); + --theme-text-gray-950: var(--ui-data-primary); --theme-text-dark-100: var(--ui-data-primary); --theme-text-dark-200: var(--ui-data-primary); + --theme-text-dark-300: var(--ui-data-primary); --theme-text-dark-400: var(--ui-data-primary); --theme-text-dark-500: var(--ui-data-primary); + --theme-text-dark-600: var(--ui-data-primary); --ui-ink-soft: var(--ui-data-primary); +} + +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan]), [data-ui="accounts-toolbar"], [data-ui="pagination"], [data-ui~="usage-records"] > div:first-child, [data-ui="user-usage"] > div[class~="border-b"], [data-ui~="records-card"] > div[class~="justify-end"][class~="border-b"]) :not(td, th):is([class~="text-gray-800"], [class~="text-gray-700"], [class~="text-gray-600"], [class~="text-gray-500"], [class~="text-slate-500"], [class~="text-slate-600"]) { + --theme-text-gray-100: var(--ui-data-secondary); --theme-text-gray-200: var(--ui-data-secondary); --theme-text-gray-300: var(--ui-data-secondary); + --theme-text-gray-400: var(--ui-data-secondary); --theme-text-gray-500: var(--ui-data-secondary); --theme-text-gray-600: var(--ui-data-secondary); + --theme-text-gray-700: var(--ui-data-secondary); --theme-text-gray-800: var(--ui-data-secondary); --theme-text-gray-900: var(--ui-data-secondary); + --theme-text-gray-950: var(--ui-data-secondary); --theme-text-dark-100: var(--ui-data-secondary); --theme-text-dark-200: var(--ui-data-secondary); + --theme-text-dark-300: var(--ui-data-secondary); --theme-text-dark-400: var(--ui-data-secondary); --theme-text-dark-500: var(--ui-data-secondary); + --theme-text-dark-600: var(--ui-data-secondary); --ui-ink-soft: var(--ui-data-secondary); +} + +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan]), [data-ui="accounts-toolbar"], [data-ui="pagination"], [data-ui~="usage-records"] > div:first-child, [data-ui="user-usage"] > div[class~="border-b"], [data-ui~="records-card"] > div[class~="justify-end"][class~="border-b"]) :not(td, th):is([class~="text-gray-400"], [class~="text-slate-400"]) { + --theme-text-gray-100: var(--ui-data-label); --theme-text-gray-200: var(--ui-data-label); --theme-text-gray-300: var(--ui-data-label); + --theme-text-gray-400: var(--ui-data-label); --theme-text-gray-500: var(--ui-data-label); --theme-text-gray-600: var(--ui-data-label); + --theme-text-gray-700: var(--ui-data-label); --theme-text-gray-800: var(--ui-data-label); --theme-text-gray-900: var(--ui-data-label); + --theme-text-gray-950: var(--ui-data-label); --theme-text-dark-100: var(--ui-data-label); --theme-text-dark-200: var(--ui-data-label); + --theme-text-dark-300: var(--ui-data-label); --theme-text-dark-400: var(--ui-data-label); --theme-text-dark-500: var(--ui-data-label); + --theme-text-dark-600: var(--ui-data-label); --ui-ink-soft: var(--ui-data-label); +} + +/* 12.3 elements whose role differs from their class (the tables' own ones are in the page files) */ +/* a value right after its own inline label (DataTable's stacked lines: 最近 / 创建 / 过期, a scheduling group ...) is a + labelled value, one step above its label; primary values and placeholders keep their roles (the one sibling + combinator, keyed on DataTable's static data-ui hooks) */ +html.flat-theme :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) [data-ui="cell-line-label"] + [data-ui="cell-line-value"] :is([class~="text-gray-800"], [class~="text-gray-700"], [class~="text-gray-600"], [class~="text-gray-500"], [class~="text-slate-500"], [class~="text-slate-600"]) { + --theme-text-gray-100: var(--ui-data-labelled); --theme-text-gray-200: var(--ui-data-labelled); --theme-text-gray-300: var(--ui-data-labelled); + --theme-text-gray-400: var(--ui-data-labelled); --theme-text-gray-500: var(--ui-data-labelled); --theme-text-gray-600: var(--ui-data-labelled); + --theme-text-gray-700: var(--ui-data-labelled); --theme-text-gray-800: var(--ui-data-labelled); --theme-text-gray-900: var(--ui-data-labelled); + --theme-text-gray-950: var(--ui-data-labelled); --theme-text-dark-100: var(--ui-data-labelled); --theme-text-dark-200: var(--ui-data-labelled); + --theme-text-dark-300: var(--ui-data-labelled); --theme-text-dark-400: var(--ui-data-labelled); --theme-text-dark-500: var(--ui-data-labelled); + --theme-text-dark-600: var(--ui-data-labelled); --ui-ink-soft: var(--ui-data-labelled); +} + +/* row actions are icon buttons with a caption: they keep the label tone whatever grey class they carry */ +html.flat-theme :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) td[data-column="actions"] :is([class~="text-gray-400"], [class~="text-gray-500"], [class~="text-gray-600"], [class~="text-gray-700"]) { + --theme-text-gray-100: var(--ui-data-label); --theme-text-gray-200: var(--ui-data-label); --theme-text-gray-300: var(--ui-data-label); + --theme-text-gray-400: var(--ui-data-label); --theme-text-gray-500: var(--ui-data-label); --theme-text-gray-600: var(--ui-data-label); + --theme-text-gray-700: var(--ui-data-label); --theme-text-gray-800: var(--ui-data-label); --theme-text-gray-900: var(--ui-data-label); + --theme-text-gray-950: var(--ui-data-label); --theme-text-dark-100: var(--ui-data-label); --theme-text-dark-200: var(--ui-data-label); + --theme-text-dark-300: var(--ui-data-label); --theme-text-dark-400: var(--ui-data-label); --theme-text-dark-500: var(--ui-data-label); + --theme-text-dark-600: var(--ui-data-label); --ui-ink-soft: var(--ui-data-label); +} + +/* stacked-cell line labels stay labels in a selected row too (section 1 draws them there with --ui-ink-soft) */ +html.flat-theme :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) [data-ui="cell-line-label"] { + --ui-ink-soft: var(--ui-data-label); +} + +/* 12.4 a selected row (dark): on the selected fill (#39433f, #404946 under the pointer) the grey #a3a3ac is 4.10:1 / + 3.71, so the row lifts its secondary, reference and label tones to --ui-data-picked (#b8b8c0, 5.20 / 4.72), as + section 1 lifts upstream's greys there. DataTable's own selection is a class on the row; a page's own select column + publishes --ui-row-picked (section 1, dark only), read here by a style query on the cells; Firefox (no style + queries) keeps the relational form in an @supports (-moz-appearance: none) block, which Chromium never loads, as + section 1 does. */ +html.flat-theme.dark :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"])) > table > tbody.table-body > tr[class~="bg-primary-50/40"] > td { + --ui-data-secondary: var(--ui-data-picked); + --ui-data-reference: var(--ui-data-picked); + --ui-data-label: var(--ui-data-picked); +} + +@container style(--ui-row-picked: 1) { + html.flat-theme.dark :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"])) > table > tbody.table-body > tr[data-row-id] > td { + --ui-data-secondary: var(--ui-data-picked); + --ui-data-reference: var(--ui-data-picked); + --ui-data-label: var(--ui-data-picked); + } +} + +@supports (-moz-appearance: none) { + html.flat-theme.dark :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"])) > table > tbody.table-body > tr:has(> td[data-column="select"] input[type="checkbox"]:checked) > td { + --ui-data-secondary: var(--ui-data-picked); + --ui-data-reference: var(--ui-data-picked); + --ui-data-label: var(--ui-data-picked); + } +} diff --git a/frontend/src/styles/console/pages/accounts.css b/frontend/src/styles/console/pages/accounts.css index 90514a177ab3..8cac19876c2b 100644 --- a/frontend/src/styles/console/pages/accounts.css +++ b/frontend/src/styles/console/pages/accounts.css @@ -102,6 +102,17 @@ html.flat-theme [data-ui="accounts-toolbar"] > [data-ui="account-filters"] { gap: 6px; } +/* The filter pills are controls (text roles, data.css 12: the toolbar's grey is the placeholder's): drawn like the + buttons and selects beside them, in the ink; under the pointer a pill takes the focus line, as the labelled field + pills of 使用记录 do (an active pill keeps its accent edge: this weighs less). */ +html.flat-theme [data-ui="accounts-toolbar"] [data-ui~="filter-pill"] { + --ui-ink-soft: var(--ui-data-primary); +} + +html.flat-theme :where([data-ui="accounts-toolbar"]) [data-ui~="filter-pill"]:hover { + border-color: rgb(var(--ui-line-focus)); +} + /* The route-group select stands among 30px pills: the recipe's house select at the pill height. */ html.flat-theme [data-ui="account-filters"] select.input { height: var(--ui-control-h); @@ -444,7 +455,8 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="name"] > d min-width: 3em; font-size: 12px; line-height: 18px; - color: rgb(var(--ui-muted)); + /* the e-mail under the name is a secondary value (data.css 12) */ + color: rgb(var(--ui-data-secondary)); } /* Integration review: a name that links to the account's homepage sits in HelpTooltip's root, which keeps its own @@ -647,6 +659,30 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="actions"] } } +/* Text roles (data.css 12) of the accounts table whose template class names another role: + - 管理分类 and 请求路由 are inline labels (template spans); the folder and the route summary are their values; + - priority, scheduling weight and the account rate are figures (primary); 「未探测」 is the labelled value of + 上游声明倍率 (its class says label). */ +html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="taxonomy_route"] span[class~="font-medium"][class~="text-gray-700"], +html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="taxonomy_route"] div[class~="truncate"][class~="text-gray-500"], +html.flat-theme .table-wrapper[data-table="accounts"] [data-ui="cell-line"][data-line="upstream_billing_rate"] [class~="font-medium"][class~="text-gray-400"] { + --theme-text-gray-100: var(--ui-data-labelled); --theme-text-gray-200: var(--ui-data-labelled); --theme-text-gray-300: var(--ui-data-labelled); + --theme-text-gray-400: var(--ui-data-labelled); --theme-text-gray-500: var(--ui-data-labelled); --theme-text-gray-600: var(--ui-data-labelled); + --theme-text-gray-700: var(--ui-data-labelled); --theme-text-gray-800: var(--ui-data-labelled); --theme-text-gray-900: var(--ui-data-labelled); + --theme-text-gray-950: var(--ui-data-labelled); --theme-text-dark-100: var(--ui-data-labelled); --theme-text-dark-200: var(--ui-data-labelled); + --theme-text-dark-300: var(--ui-data-labelled); --theme-text-dark-400: var(--ui-data-labelled); --theme-text-dark-500: var(--ui-data-labelled); + --theme-text-dark-600: var(--ui-data-labelled); --ui-ink-soft: var(--ui-data-labelled); +} + +html.flat-theme .table-wrapper[data-table="accounts"] [data-ui="cell-line"]:is([data-line="priority"], [data-line="scheduler_score"], [data-line="rate_multiplier"]) > [data-ui="cell-line-value"] [class~="text-gray-700"] { + --theme-text-gray-100: var(--ui-data-primary); --theme-text-gray-200: var(--ui-data-primary); --theme-text-gray-300: var(--ui-data-primary); + --theme-text-gray-400: var(--ui-data-primary); --theme-text-gray-500: var(--ui-data-primary); --theme-text-gray-600: var(--ui-data-primary); + --theme-text-gray-700: var(--ui-data-primary); --theme-text-gray-800: var(--ui-data-primary); --theme-text-gray-900: var(--ui-data-primary); + --theme-text-gray-950: var(--ui-data-primary); --theme-text-dark-100: var(--ui-data-primary); --theme-text-dark-200: var(--ui-data-primary); + --theme-text-dark-300: var(--ui-data-primary); --theme-text-dark-400: var(--ui-data-primary); --theme-text-dark-500: var(--ui-data-primary); + --theme-text-dark-600: var(--ui-data-primary); --ui-ink-soft: var(--ui-data-primary); +} + /* ================================================================== 6 · card grid and compact list */ /* Shared parts of the two other views: the 14px checkbox with its 32px hit area (as in the table), the account id diff --git a/frontend/src/styles/console/pages/dashboard-usage.css b/frontend/src/styles/console/pages/dashboard-usage.css index 40c2eba962b1..ec41f00f5df1 100644 --- a/frontend/src/styles/console/pages/dashboard-usage.css +++ b/frontend/src/styles/console/pages/dashboard-usage.css @@ -416,12 +416,13 @@ html.flat-theme :is(.table-wrapper[data-table="usage"], [data-ui="admin-usage"] color: rgb(var(--ui-ink-soft)); } -/* Hierarchy by colour and weight instead of more black (palette C): the requested model and the billed cost are the - row's ink (500); every token figure (input, output, cache, image) ink-soft; the key, account and reasoning names - ink-soft 400; the endpoint's 「入站 / 上游」 labels 400; times and IPs tabular figures. The token glyphs get upstream's - hues back as identity-safe text colours (input emerald, output violet, cache read sky, cache write amber, image - fuchsia / pink, indigo), so the figures stay neutral and the colour says which figure is which; the account's billed - cost stays muted. The key, account, reasoning and endpoint rules also name their lines of a stacked cell +/* Hierarchy by colour and weight instead of more black (palette C, then the text roles of data.css 12): the requested + model and the billed cost are the row's ink (500); every token figure (input, output, cache, image) the ink at 400; + the key and account names 400 (the primary role), the reasoning effort the secondary one; the endpoint's + 「入站 / 上游」 labels 400; times and IPs tabular figures. The token glyphs get upstream's hues back as identity-safe + text colours (input emerald, output violet, cache read sky, cache write amber, image fuchsia / pink, indigo), so the + figures stay neutral and the colour says which figure is which; the account's billed cost is a reference value (the + grey). The key, account, reasoning and endpoint rules also name their lines of a stacked cell ([data-ui="cell-line"][data-line], the table-width track's stacked layout), as that track does for the rules it carries over. */ html.flat-theme .table-wrapper[data-table="usage"] td:is([data-column="api_key"], [data-column="account"], [data-column="reasoning_effort"]) span[class~="text-gray-900"], @@ -430,9 +431,15 @@ html.flat-theme .table-wrapper[data-table="usage"] [data-ui="cell-line"]:is([dat font-weight: 400; } +/* (text roles, data.css 12: every token figure is a primary figure, in the ink like the billed cost, Geist Mono 13 / 400 + tabular; the billed cost keeps 500, the one weighted figure of the row. They were ink-soft Mono 13 / 500.) */ html.flat-theme .table-wrapper[data-table="usage"] td[data-column="tokens"] span[class~="font-medium"][class~="text-gray-900"], html.flat-theme .table-wrapper[data-table="usage"] td[data-column="tokens"] :is([class~="text-sky-600"], [class~="text-amber-600"], [class~="text-fuchsia-600"], [class~="text-pink-600"]) { - color: rgb(var(--ui-ink-soft)); + color: rgb(var(--ui-data-primary)); + font-family: var(--ui-font-mono); + font-size: 13px; + font-weight: 400; + font-variant-numeric: tabular-nums; } html.flat-theme .table-wrapper[data-table="usage"] td[data-column="cost"] span[class~="text-green-600"] { @@ -476,8 +483,83 @@ html.flat-theme .table-wrapper[data-table="usage"] td[data-column="tokens"] svg[ color: rgb(var(--ui-id-indigo)); } +/* the account's billed cost (A $) is a reference value next to the billed cost: the grey, a secondary figure one pixel + under the row's 13px figures (it was 11px) */ html.flat-theme .table-wrapper[data-table="usage"] td[data-column="cost"] div[class~="text-orange-500"] { - color: rgb(var(--ui-muted)); + color: rgb(var(--ui-data-reference)); + font-size: 12px; + line-height: 18px; +} + +/* Text roles (data.css 12) of the records and error lists whose class names another role: + - reasoning effort qualifies the model: secondary (its class says primary; dark: its dark:text-white reads + --theme-on-fill, the ink inside tables, so that is moved too, on these text leaves only); + - 入站 / 上游: the endpoint path is the labelled value, 「入站:」 / 「上游:」 its label (their classes say secondary); + - error lists: the message is the row's content (primary); platform and category are secondary. + The secondary figures (latency, IP) are Geist Mono 12, a pixel under the primary 13px figures (latency was Geist + sans 12 / 500, the IP Mono 13). */ +html.flat-theme .table-wrapper[data-table="usage"] :is(td[data-column="reasoning_effort"], [data-ui="cell-line"][data-line="reasoning_effort"]) :is(span, div)[class~="text-gray-900"] { + --theme-text-gray-100: var(--ui-data-secondary); --theme-text-gray-200: var(--ui-data-secondary); --theme-text-gray-300: var(--ui-data-secondary); + --theme-text-gray-400: var(--ui-data-secondary); --theme-text-gray-500: var(--ui-data-secondary); --theme-text-gray-600: var(--ui-data-secondary); + --theme-text-gray-700: var(--ui-data-secondary); --theme-text-gray-800: var(--ui-data-secondary); --theme-text-gray-900: var(--ui-data-secondary); + --theme-text-gray-950: var(--ui-data-secondary); --theme-text-dark-100: var(--ui-data-secondary); --theme-text-dark-200: var(--ui-data-secondary); + --theme-text-dark-300: var(--ui-data-secondary); --theme-text-dark-400: var(--ui-data-secondary); --theme-text-dark-500: var(--ui-data-secondary); + --theme-text-dark-600: var(--ui-data-secondary); --ui-ink-soft: var(--ui-data-secondary); +} + +html.flat-theme.dark .table-wrapper[data-table="usage"] :is(td[data-column="reasoning_effort"], [data-ui="cell-line"][data-line="reasoning_effort"]) :is(span, div)[class~="text-gray-900"] { + --theme-on-fill: var(--ui-data-secondary); +} + +html.flat-theme :is(.table-wrapper[data-table="usage"], :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"])) :is(td[data-column="endpoint"], [data-ui="cell-line"][data-line="endpoint"]) div[class~="break-all"] { + --theme-text-gray-100: var(--ui-data-labelled); --theme-text-gray-200: var(--ui-data-labelled); --theme-text-gray-300: var(--ui-data-labelled); + --theme-text-gray-400: var(--ui-data-labelled); --theme-text-gray-500: var(--ui-data-labelled); --theme-text-gray-600: var(--ui-data-labelled); + --theme-text-gray-700: var(--ui-data-labelled); --theme-text-gray-800: var(--ui-data-labelled); --theme-text-gray-900: var(--ui-data-labelled); + --theme-text-gray-950: var(--ui-data-labelled); --theme-text-dark-100: var(--ui-data-labelled); --theme-text-dark-200: var(--ui-data-labelled); + --theme-text-dark-300: var(--ui-data-labelled); --theme-text-dark-400: var(--ui-data-labelled); --theme-text-dark-500: var(--ui-data-labelled); + --theme-text-dark-600: var(--ui-data-labelled); --ui-ink-soft: var(--ui-data-labelled); +} + +html.flat-theme :is(.table-wrapper[data-table="usage"], :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"])) :is(td[data-column="endpoint"], [data-ui="cell-line"][data-line="endpoint"]) div[class~="break-all"] > span[class~="font-medium"] { + --theme-text-gray-100: var(--ui-data-label); --theme-text-gray-200: var(--ui-data-label); --theme-text-gray-300: var(--ui-data-label); + --theme-text-gray-400: var(--ui-data-label); --theme-text-gray-500: var(--ui-data-label); --theme-text-gray-600: var(--ui-data-label); + --theme-text-gray-700: var(--ui-data-label); --theme-text-gray-800: var(--ui-data-label); --theme-text-gray-900: var(--ui-data-label); + --theme-text-gray-950: var(--ui-data-label); --theme-text-dark-100: var(--ui-data-label); --theme-text-dark-200: var(--ui-data-label); + --theme-text-dark-300: var(--ui-data-label); --theme-text-dark-400: var(--ui-data-label); --theme-text-dark-500: var(--ui-data-label); + --theme-text-dark-600: var(--ui-data-label); --ui-ink-soft: var(--ui-data-label); +} + +html.flat-theme :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) td[data-column="message"] > span[class~="text-gray-600"] { + --theme-text-gray-100: var(--ui-data-primary); --theme-text-gray-200: var(--ui-data-primary); --theme-text-gray-300: var(--ui-data-primary); + --theme-text-gray-400: var(--ui-data-primary); --theme-text-gray-500: var(--ui-data-primary); --theme-text-gray-600: var(--ui-data-primary); + --theme-text-gray-700: var(--ui-data-primary); --theme-text-gray-800: var(--ui-data-primary); --theme-text-gray-900: var(--ui-data-primary); + --theme-text-gray-950: var(--ui-data-primary); --theme-text-dark-100: var(--ui-data-primary); --theme-text-dark-200: var(--ui-data-primary); + --theme-text-dark-300: var(--ui-data-primary); --theme-text-dark-400: var(--ui-data-primary); --theme-text-dark-500: var(--ui-data-primary); + --theme-text-dark-600: var(--ui-data-primary); --ui-ink-soft: var(--ui-data-primary); +} + +html.flat-theme :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) :is(td[data-column="platform"], td[data-column="category"]) > span[class~="text-gray-900"], +html.flat-theme :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) [data-ui="cell-line"]:is([data-line="platform"], [data-line="category"]) > [data-ui="cell-line-value"] > span[class~="text-gray-900"] { + --theme-text-gray-100: var(--ui-data-secondary); --theme-text-gray-200: var(--ui-data-secondary); --theme-text-gray-300: var(--ui-data-secondary); + --theme-text-gray-400: var(--ui-data-secondary); --theme-text-gray-500: var(--ui-data-secondary); --theme-text-gray-600: var(--ui-data-secondary); + --theme-text-gray-700: var(--ui-data-secondary); --theme-text-gray-800: var(--ui-data-secondary); --theme-text-gray-900: var(--ui-data-secondary); + --theme-text-gray-950: var(--ui-data-secondary); --theme-text-dark-100: var(--ui-data-secondary); --theme-text-dark-200: var(--ui-data-secondary); + --theme-text-dark-300: var(--ui-data-secondary); --theme-text-dark-400: var(--ui-data-secondary); --theme-text-dark-500: var(--ui-data-secondary); + --theme-text-dark-600: var(--ui-data-secondary); --ui-ink-soft: var(--ui-data-secondary); +} + +html.flat-theme.dark :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) :is(td[data-column="platform"], td[data-column="category"]) > span[class~="text-gray-900"], +html.flat-theme.dark :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) [data-ui="cell-line"]:is([data-line="platform"], [data-line="category"]) > [data-ui="cell-line-value"] > span[class~="text-gray-900"] { + --theme-on-fill: var(--ui-data-secondary); +} + +html.flat-theme .table-wrapper[data-table="usage"] td[data-column="latency"] span[class~="tabular-nums"] { + font-family: var(--ui-font-mono); + font-size: 12px; +} + +html.flat-theme :is(.table-wrapper[data-table="usage"], :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"])) td:is([data-column="ip_address"], [data-column="client_ip"]) span[class~="font-mono"] { + font-size: 12px; } /* The token and cost breakdown cards (UsageTable's two portals; the frame is overlays.css 2.3): the billed amounts and diff --git a/frontend/src/styles/console/pages/ops-channels.css b/frontend/src/styles/console/pages/ops-channels.css index c281d53086d8..a0eab0901655 100644 --- a/frontend/src/styles/console/pages/ops-channels.css +++ b/frontend/src/styles/console/pages/ops-channels.css @@ -1698,13 +1698,14 @@ html.flat-theme [data-ui~="ops-error-list"] > div:last-child > div[class~="mb-2" color: rgb(var(--ui-label)); } -/* the list's cells: time Mono; the type and group chips are categories (data.css' category-chip hook, added to - OpsErrorLogTable.vue: the reference's neutral chip); the detail action a 28px icon button */ +/* the list's cells: time Mono, a secondary value (data.css 12, as in the other lists); the type and group chips are + categories (data.css' category-chip hook, added to OpsErrorLogTable.vue: the reference's neutral chip); the detail + action a 28px icon button */ html.flat-theme [data-ui~="ops-error-list"] .table-wrapper td[data-column="created_at"] > span { font-family: var(--ui-font-mono); font-size: 12px; font-variant-numeric: tabular-nums; - color: rgb(var(--ui-muted)); + color: rgb(var(--ui-data-secondary)); } html.flat-theme [data-ui~="ops-error-list"] .table-wrapper td[data-column="actions"] > button { diff --git a/frontend/src/styles/console/pages/user.css b/frontend/src/styles/console/pages/user.css index f668173f921a..9e87dc9f2829 100644 --- a/frontend/src/styles/console/pages/user.css +++ b/frontend/src/styles/console/pages/user.css @@ -127,13 +127,32 @@ html.flat-theme .table-wrapper[data-table="keys"] td[data-column="usage"] span[c font-variant-numeric: tabular-nums; } -/* the labels (今日 / 近30天) in ink-soft, the reference's colour for a row's secondary figures: muted sits at 4.5:1 - on a hovered row, too close to the floor for a 12px label */ +/* the labels (今日 / 近30天): the label role of the table's text (data.css 12; #66666e is 4.95:1 on a hovered row) */ html.flat-theme .table-wrapper[data-table="keys"] td[data-column="usage"] span[class~="text-gray-500"] { - color: rgb(var(--ui-ink-soft)); + color: rgb(var(--ui-data-label)); font-size: 12px; } +/* text roles (data.css 12) whose class names another role: 「选择分组」 is an empty-value prompt (a label); 「永久有效」 is + a value (secondary, its class says label) */ +html.flat-theme .table-wrapper[data-table="keys"] td[data-column="group"] [class~="text-gray-500"] { + --theme-text-gray-100: var(--ui-data-label); --theme-text-gray-200: var(--ui-data-label); --theme-text-gray-300: var(--ui-data-label); + --theme-text-gray-400: var(--ui-data-label); --theme-text-gray-500: var(--ui-data-label); --theme-text-gray-600: var(--ui-data-label); + --theme-text-gray-700: var(--ui-data-label); --theme-text-gray-800: var(--ui-data-label); --theme-text-gray-900: var(--ui-data-label); + --theme-text-gray-950: var(--ui-data-label); --theme-text-dark-100: var(--ui-data-label); --theme-text-dark-200: var(--ui-data-label); + --theme-text-dark-300: var(--ui-data-label); --theme-text-dark-400: var(--ui-data-label); --theme-text-dark-500: var(--ui-data-label); + --theme-text-dark-600: var(--ui-data-label); --ui-ink-soft: var(--ui-data-label); +} + +html.flat-theme .table-wrapper[data-table="keys"] td[data-column="expires_at"] > span[class~="text-gray-400"] { + --theme-text-gray-100: var(--ui-data-secondary); --theme-text-gray-200: var(--ui-data-secondary); --theme-text-gray-300: var(--ui-data-secondary); + --theme-text-gray-400: var(--ui-data-secondary); --theme-text-gray-500: var(--ui-data-secondary); --theme-text-gray-600: var(--ui-data-secondary); + --theme-text-gray-700: var(--ui-data-secondary); --theme-text-gray-800: var(--ui-data-secondary); --theme-text-gray-900: var(--ui-data-secondary); + --theme-text-gray-950: var(--ui-data-secondary); --theme-text-dark-100: var(--ui-data-secondary); --theme-text-dark-200: var(--ui-data-secondary); + --theme-text-dark-300: var(--ui-data-secondary); --theme-text-dark-400: var(--ui-data-secondary); --theme-text-dark-500: var(--ui-data-secondary); + --theme-text-dark-600: var(--ui-data-secondary); --ui-ink-soft: var(--ui-data-secondary); +} + /* the live-concurrency count: the reference's cell chip (4px, Mono), a running count keeps its success tint */ html.flat-theme .table-wrapper[data-table="keys"] td[data-column="current_concurrency"] > span { min-width: 28px; diff --git a/frontend/src/styles/console/pages/users-groups-proxies-keys.css b/frontend/src/styles/console/pages/users-groups-proxies-keys.css index ff6422f1d681..146748f9acfa 100644 --- a/frontend/src/styles/console/pages/users-groups-proxies-keys.css +++ b/frontend/src/styles/console/pages/users-groups-proxies-keys.css @@ -296,6 +296,16 @@ html.flat-theme :is([data-ui~="redeem-page"], [data-ui~="promo-page"]) td[data-c /* ================================================================== 4 · users table */ +/* the username is a name: the primary role of the table's text (data.css 12; its class says secondary) */ +html.flat-theme .table-wrapper[data-table="users"] td[data-column="username"] > span[class~="text-gray-700"] { + --theme-text-gray-100: var(--ui-data-primary); --theme-text-gray-200: var(--ui-data-primary); --theme-text-gray-300: var(--ui-data-primary); + --theme-text-gray-400: var(--ui-data-primary); --theme-text-gray-500: var(--ui-data-primary); --theme-text-gray-600: var(--ui-data-primary); + --theme-text-gray-700: var(--ui-data-primary); --theme-text-gray-800: var(--ui-data-primary); --theme-text-gray-900: var(--ui-data-primary); + --theme-text-gray-950: var(--ui-data-primary); --theme-text-dark-100: var(--ui-data-primary); --theme-text-dark-200: var(--ui-data-primary); + --theme-text-dark-300: var(--ui-data-primary); --theme-text-dark-400: var(--ui-data-primary); --theme-text-dark-500: var(--ui-data-primary); + --theme-text-dark-600: var(--ui-data-primary); --ui-ink-soft: var(--ui-data-primary); +} + /* Balance: the amount is a link to the balance history (the dashed underline in the control line, ink on hover); 「充值」 under it (B1.8) is the reference's link, the accent text at 12px, underlined on hover (upstream's hover fill goes). */ html.flat-theme [data-ui~="users-page"] td[data-column="balance"] div[class~="group"] > button { diff --git a/frontend/src/styles/console/tables.css b/frontend/src/styles/console/tables.css index 0d22fe13ed37..bc9ae66a3bad 100644 --- a/frontend/src/styles/console/tables.css +++ b/frontend/src/styles/console/tables.css @@ -217,12 +217,30 @@ html.flat-theme .table-wrapper[data-table="accounts"] > table > tbody.table-body margin-top: 1px; } -/* ---- 名称 / 邮箱 / #ID (/ 备注): the name (600) over its e-mail, both complete; the template puts before the @ - and inside a long local part (NightingaleGiovanna7598 -> Nightingale · Giovanna · 7598), so an address breaks at a - word, never inside one */ +/* ---- 名称 / 邮箱 / #ID (/ 备注): the name (500) over its e-mail, both complete; the template puts before the @ + and at the joins of a word of letters (NightingaleGiovanna7598 -> Nightingale · Giovanna · 7598), never inside a code + (the 18 characters of cindy-global-am332d23b406hthnne-…), so an address or a name breaks at a word. + The column hugs its names (owner 2026-10-01, 「账号名称空白太多」). AccountsView measures the page's names and e-mails + in their own fonts (softWrap.ts nameWidths): --account-name-one, the widest of them on one line; --account-name-two, + the narrowest width at which each takes at most two lines, broken at a natural join (an e-mail before its @ only); + --account-name-piece, the widest piece that cannot break. Where the table has room for the one-line width beside the + other columns at about their own one-line widths (the table less 60rem) and it is at most 30 % of the table (one + outlier, a URL with nine 「(Copy)」, does not set a 600px column), the column takes it (1920: the cindy names on one + line); else it takes the two-line width (1440), not a width in between, which would wrap the long names and still + leave blank beside the short ones. The width is the column's own: the rest of the table goes to the other columns + in proportion (the browser's auto layout). Where even the two-line width does not fit (all 21 columns), the browser + narrows the column down to its widest piece, at most 10rem (every code and e-mail part is narrower, 148px at most: + only a longer piece, a URL without a space, may then break inside, so the table never scrolls sideways), and a long + name takes a third line. Unmeasured (no canvas), the column is the browser's, from 9rem. (The clamp is a step: its + middle term is far below the two-line width unless the room holds the one-line width, then it is at least that.) */ +html.flat-theme .table-wrapper[data-table="accounts"] { + --account-name-col: calc(clamp(var(--account-name-two), (min(100cqw - 60rem, 30cqw) - var(--account-name-one)) * 100000 + var(--account-name-one), var(--account-name-one)) + 2 * var(--ui-table-cell-x)); + --account-name-floor: calc(min(var(--account-name-piece), 10rem) + 2 * var(--ui-table-cell-x)); +} + html.flat-theme .table-wrapper[data-table="accounts"] :is(th, td)[data-column="name"] { - width: auto; - min-width: 9rem; + width: var(--account-name-col, auto); + min-width: var(--account-name-floor, 9rem); max-width: none; } @@ -241,7 +259,8 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="name"] > d overflow: visible; white-space: normal; text-overflow: clip; - overflow-wrap: break-word; + /* (the column's floor keeps every piece up to 10rem whole; only a longer one, a URL, may break inside) */ + overflow-wrap: anywhere; } /* a name that links to its site keeps its dotted underline on every line it wraps to */ @@ -263,6 +282,8 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="name"] div overflow: visible; white-space: normal; text-overflow: clip; + /* a note's long token (exported=2026-…) does not hold the column wider than its names need */ + overflow-wrap: anywhere; } /* ---- 平台/类型 / 容量: the identity chips (the joined platform | type chip on one line when it fits), the compact-mode @@ -414,6 +435,13 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="usage"] sp text-overflow: clip; } +/* the cell's hover tips (Gemini quota policy, ineligible warning: 20rem, invisible until hovered) open leftwards from + their icon; opening rightwards, the hidden box widened the table's scroll area (1280: 17px of sideways scrolling) */ +html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="usage"] [class~="group"] > span[class~="absolute"][class~="top-full"][class~="w-80"] { + left: auto; + right: 0; +} + html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="usage"] div[data-line="today_stats"] { flex-direction: column; align-items: flex-start; @@ -501,10 +529,23 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="actions"] } html.flat-theme .table-wrapper[data-table="accounts"] th[data-column="platform_type"] { min-width: 6rem; } - html.flat-theme .table-wrapper[data-table="accounts"] :is(th, td)[data-column="name"] { min-width: 8rem; } + /* the name column's floor is today's 8rem (a 7rem piece and the insets): with all 21 columns at 1280 a page whose other + cells cannot narrow (a temporarily unschedulable account, OAuth plan chips) leaves the name less than its widest + code, which then breaks inside rather than the table scrolling sideways */ + html.flat-theme .table-wrapper[data-table="accounts"] { + --account-name-floor: calc(min(var(--account-name-piece), 7rem) + 2 * var(--ui-table-cell-x)); + } + + html.flat-theme .table-wrapper[data-table="accounts"] :is(th, td)[data-column="name"] { min-width: var(--account-name-floor, 8rem); } html.flat-theme .table-wrapper[data-table="accounts"] th[data-column="taxonomy_route"] { min-width: 7rem; } html.flat-theme .table-wrapper[data-table="accounts"] th[data-column="usage"] { min-width: 9.5rem; } html.flat-theme .table-wrapper[data-table="accounts"] th[data-column="last_used_at"] { min-width: 7rem; } + + /* all 21 columns: 调度权值's group / score / limit figures (「commandcode / 3.75 / +∞」) wrap at their 「/」 where the + column is short of room (the line pushed the table sideways at 1280) */ + html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="priority"] div[data-line="scheduler_score"] > div[data-ui="cell-line-value"] > div > div { + flex-wrap: wrap; + } } /* ================================================================== 3 · lines whose layout differs from their column's diff --git a/frontend/src/styles/console/tokens.css b/frontend/src/styles/console/tokens.css index b2f83dff17d7..401ab5fff46d 100644 --- a/frontend/src/styles/console/tokens.css +++ b/frontend/src/styles/console/tokens.css @@ -92,12 +92,24 @@ /* Text (zinc). ink #1f1f23 (16.4:1 on white; #0a0a0a read black and heavy in dense rows); ink-soft #52525b (values, filter pills, tooltip descriptions, secondary figures); muted and faint (placeholders, captions) #66666e: these hold >= 4.5:1 on every neutral fill (surface, canvas, the grouped fill, hover). --ui-label, the - mono label's grey, #6e6e76 holds it on the fills labels sit on (surface 5.05, canvas 4.84, grouped 4.60). */ + mono label's grey, is the muted grey too (owner 2026-10-01, text option A: the KPI, field and section labels + and the table headers are one grey; the former #6e6e76 was 4.40:1 on the row hover, #66666e is 4.95). */ --ui-ink: 31 31 35; --ui-ink-soft: 82 82 91; --ui-muted: 102 102 110; --ui-faint: 102 102 110; - --ui-label: 110 110 118; + --ui-label: 102 102 110; + /* Data text roles (owner 2026-10-01, text option A "two tones": artifacts/tmp/ui-polish/text/PREVIEW.md), read by + data.css 12 inside the cells, headers and toolbars of the record tables: a value is the ink, everything else one + grey, both existing tones (no grey in between). primary = names, figures, the billed cost, controls; labelled = + a value after its inline label; secondary = unlabelled metadata (times, IPs, ids, chips, status words); + reference = the account's billed cost, a distribution's account cost and standard price; label = headers, + inline labels, units, placeholders, counts. */ + --ui-data-primary: 31 31 35; + --ui-data-labelled: 31 31 35; + --ui-data-secondary: 102 102 110; + --ui-data-reference: 102 102 110; + --ui-data-label: 102 102 110; /* Accent (the green #168a49 carries white at 4.41:1). The fill is #168848 (white 4.52:1, REF-CONSOLE §13); accent text and links are #147c42, the nearest at >= 4.5:1 on every neutral fill and on the soft fill. */ --ui-primary: 22 136 72; @@ -213,6 +225,15 @@ /* faint and the label hold >= 4.5:1 on the canvas, the surface, the grouped fill and hover */ --ui-faint: 160 160 169; --ui-label: 163 163 172; + /* data text roles (see light): the ink and the label grey #a3a3ac, the table headers' grey (6.41:1 on the surface, + 5.32 on the row hover). On the selected fill (#39433f, 4.10:1) a row lifts its grey to the soft ink #b8b8c0 + (--ui-data-picked: 5.20:1, 4.72 under the pointer; data.css 12). */ + --ui-data-primary: 232 232 236; + --ui-data-labelled: 232 232 236; + --ui-data-secondary: 163 163 172; + --ui-data-reference: 163 163 172; + --ui-data-label: 163 163 172; + --ui-data-picked: 184 184 192; /* the mint accent with ink on it; soft = mint at 16 % over the surface */ --ui-primary: 182 247 200; --ui-primary-hover: 166 225 183; diff --git a/frontend/src/utils/__tests__/softWrap.spec.ts b/frontend/src/utils/__tests__/softWrap.spec.ts new file mode 100644 index 000000000000..c730ca1f9349 --- /dev/null +++ b/frontend/src/utils/__tests__/softWrap.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { nameLinePieces, nameWidths, nameWrapParts } from '../softWrap' + +// one unit per character: enough to tell which split the widths come from +const chars = (text: string) => text.length + +describe('nameWrapParts', () => { + it('breaks an e-mail at its words and before the @', () => { + expect(nameWrapParts('NightingaleGiovanna7598@hotmail.com')).toEqual(['Nightingale', 'Giovanna', '7598', '@hotmail.com']) + expect(nameWrapParts('Aetin59137@outlook.com')).toEqual(['Aetin', '59137', '@outlook.com']) + }) + + it('keeps a word with figures inside it whole', () => { + expect(nameWrapParts('eucadc5ca109engnmw@outlook.com')).toEqual(['eucadc5ca109engnmw', '@outlook.com']) + expect(nameWrapParts('cindy-global-am332d23b406hthnne-dylancooper2840-2dc78b3c')).toEqual([ + 'cindy-global-am332d23b406hthnne-dylancooper', + '2840-2dc78b3c' + ]) + expect(nameWrapParts('')).toEqual([]) + }) +}) + +describe('nameWidths', () => { + it('lays a cindy name out at its hyphens, never inside its code', () => { + const name = 'cindy-global-am332d23b406hthnne-dylancooper2840-2dc78b3c' + expect(nameLinePieces(name)).toEqual(['cindy-', 'global-', 'am332d23b406hthnne-', 'dylancooper', '2840-', '2dc78b3c']) + expect(nameWidths(name, chars)).toEqual({ one: name.length, two: 'cindy-global-am332d23b406hthnne-'.length, piece: 19 }) + }) + + it('breaks an e-mail before its @ only', () => { + const mail = 'NightingaleGiovanna7598@hotmail.com' + expect(nameWidths(mail, chars)).toEqual({ one: mail.length, two: 'NightingaleGiovanna7598'.length, piece: 12 }) + }) + + it('breaks after a hyphen, not inside a range of figures', () => { + expect(nameLinePieces('claude-3-5-sonnet')).toEqual(['claude-', '3-5-', 'sonnet']) + expect(nameLinePieces('ab-1-2')).toEqual(['ab-', '1-2']) + }) + + it('breaks after a space, the space hanging at the line end', () => { + // 「openai_28 」 | 「(Copy)」: the first line is 9 wide, its space not counted + expect(nameWidths('openai_28 (Copy)', chars)).toEqual({ one: 16, two: 9, piece: 9 }) + }) +}) diff --git a/frontend/src/utils/softWrap.ts b/frontend/src/utils/softWrap.ts index 7dc2508b99c0..f900f17c0c95 100644 --- a/frontend/src/utils/softWrap.ts +++ b/frontend/src/utils/softWrap.ts @@ -4,35 +4,124 @@ const isLower = (c: string | undefined) => c !== undefined && c >= 'a' && c <= 'z' const isUpper = (c: string | undefined) => c !== undefined && c >= 'A' && c <= 'Z' const isDigit = (c: string | undefined) => c !== undefined && c >= '0' && c <= '9' +const isLetter = (c: string | undefined) => isLower(c) || isUpper(c) +const isWordChar = (c: string | undefined) => isLetter(c) || isDigit(c) +const isCJK = (c: string | undefined) => c !== undefined && /[\u3400-\u9fff\uf900-\ufaff]/.test(c) /** - * An account name or e-mail breaks at a word, never inside one: before the @, where a lower-case letter meets a - * capital (NightingaleGiovanna -> Nightingale | Giovanna) and where a letter meets a run of three or more digits - * (Giovanna7598 -> Giovanna | 7598). + * An account name or e-mail breaks at a word, never inside one: before the @, and inside a word of letters (with + * figures only at its end) where a lower-case letter meets a capital (NightingaleGiovanna -> Nightingale | Giovanna) + * and before its final run of three or more figures (Giovanna7598 -> Giovanna | 7598). A word with figures inside it is + * a code and stays whole (the 18 characters of cindy-global-am332d23b406hthnne-…, eucadc5ca109engnmw@outlook.com). */ export function nameWrapParts(value: unknown): string[] { const text = value == null ? '' : String(value) if (!text) return [] + const cuts: number[] = [] + for (let start = 0; start < text.length; ) { + if (!isWordChar(text[start])) { + if (text[start] === '@' && start > 0) cuts.push(start) + start++ + continue + } + let end = start + while (end < text.length && isWordChar(text[end])) end++ + let figures = end + while (figures > start && isDigit(text[figures - 1])) figures-- + let letters = figures > start + for (let i = start; letters && i < figures; i++) letters = isLetter(text[i]) + if (letters) { + for (let i = start + 1; i < figures; i++) if (isLower(text[i - 1]) && isUpper(text[i])) cuts.push(i) + if (end - figures >= 3) cuts.push(figures) + } + start = end + } const parts: string[] = [] - let start = 0 + let from = 0 + for (const at of cuts) { + parts.push(text.slice(from, at)) + from = at + } + parts.push(text.slice(from)) + return parts +} + +/** + * Whether the browser may break after the hyphen before text[i]: before a letter, a CJK character or a figure, but not + * inside a range of figures (the 1-2 of ab-1-2, the 3-5 of claude-3-5). A subset of what Chromium does (it also breaks + * before a bracket, say), so a width worked out from it never needs a break the browser does not make. + */ +function breaksAfterHyphen(text: string, i: number): boolean { + const ch = text[i] + if (isLetter(ch) || isCJK(ch)) return true + if (!isDigit(ch)) return false + let k = i - 2 + while (k >= 0 && isDigit(text[k])) k-- + return !(k < i - 2 && k >= 0 && text[k] === '-') +} + +/** + * The pieces a name or e-mail is laid out in where its cell is narrow: the parts of nameWrapParts (the template's + * ), cut again where the browser itself breaks: after a space, after a hyphen (breaksAfterHyphen) and between + * two CJK characters. Joined back, the pieces are the text. + */ +export function nameLinePieces(value: unknown): string[] { + const parts = nameWrapParts(value) + const text = parts.join('') + const cuts = new Set() + let at = 0 + for (const part of parts.slice(0, -1)) cuts.add((at += part.length)) for (let i = 1; i < text.length; i++) { const prev = text[i - 1] const ch = text[i] - const digitRun = isDigit(ch) && isDigit(text[i + 1]) && isDigit(text[i + 2]) - if (ch === '@' || (isLower(prev) && isUpper(ch)) || ((isLower(prev) || isUpper(prev)) && digitRun)) { - parts.push(text.slice(start, i)) - start = i + if ((prev === ' ' && ch !== ' ') || (prev === '-' && breaksAfterHyphen(text, i)) || (isCJK(prev) && isCJK(ch))) cuts.add(i) + } + const pieces: string[] = [] + let from = 0 + for (const cut of [...cuts].sort((a, b) => a - b)) { + pieces.push(text.slice(from, cut)) + from = cut + } + if (text) pieces.push(text.slice(from)) + return pieces +} + +export interface NameWidths { + /** the value on one line */ + one: number + /** the narrowest width at which it takes at most two lines, broken at a piece join (an e-mail before its @ only) */ + two: number + /** its widest piece: the narrowest width it can take without breaking inside a word */ + piece: number +} + +const EMAIL = /^[^\s@]+@[^\s@]+$/ + +/** The widths of a name or e-mail laid out in pieces (nameLinePieces), with `measure` giving a text's width. */ +export function nameWidths(value: unknown, measure: (text: string) => number): NameWidths { + const text = value == null ? '' : String(value) + if (!text) return { one: 0, two: 0, piece: 0 } + const one = measure(text) + const pieces = nameLinePieces(text) + let piece = 0 + for (const p of pieces) piece = Math.max(piece, measure(p.trimEnd())) + let two = one + if (EMAIL.test(text)) { + const at = text.indexOf('@') + two = Math.min(one, Math.max(measure(text.slice(0, at)), measure(text.slice(at)))) + } else { + let first = '' + for (let i = 0; i < pieces.length - 1; i++) { + first += pieces[i] + two = Math.min(two, Math.max(measure(first.trimEnd()), measure(pieces.slice(i + 1).join('')))) } } - parts.push(text.slice(start)) - return parts + return { one, two: Math.max(two, piece), piece } } /** The longest model-id part drawn whole; a usual id (claude-sonnet-4-5-20250929) is far shorter. */ const MODEL_PART_WHOLE_MAX = 32 -const isLetter = (c: string | undefined) => isLower(c) || isUpper(c) - /** * A model id breaks only after a slash (anthropic/ | claude-opus-4-5); each part is drawn whole. An outlier part longer * than 32 characters may also break after a hyphen or underscore that a letter follows, so a version or a date stays diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 47643a172c23..9befd26ca73f 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -266,7 +266,7 @@ @select-all-results="handleSelectAllResults" @toggle-schedulable="handleBulkToggleSchedulable" /> -
+
flatThemeActive.value ? nameWrapParts(value) : [value == null ? '' : String(value)] +// Console theme: the name column hugs its names (styles/console/tables.css, 名称). The page's account names (13px / 500) +// and e-mails (12px / 400, accounts.css) are measured in the cells' font on a canvas: the widest of them on one line, +// the narrowest width at which each takes at most two lines (an e-mail breaks before its @ only, a code such as the 18 +// characters of a cindy name never) and the widest piece that cannot break. tables.css gives the column the one-line +// width where the table has room for it, else the two-line width. Measured from the page's rows, not the rendered +// ones, so a virtualised list keeps one width while it scrolls; the web fonts arriving re-measure. +const nameFontEpoch = ref(0) +const nameWidthCache = new Map() +let nameCanvas: CanvasRenderingContext2D | null | undefined +const remeasureAccountNames = () => { + nameWidthCache.clear() + nameFontEpoch.value++ +} + +const measureAccountName = (ctx: CanvasRenderingContext2D, font: string, text: string): NameWidths => { + const key = `${font}\n${text}` + let widths = nameWidthCache.get(key) + if (!widths) { + ctx.font = font + widths = nameWidths(text, (part) => ctx.measureText(part).width) + nameWidthCache.set(key, widths) + } + return widths +} + +const accountNameColumnStyle = computed | undefined>(() => { + void nameFontEpoch.value + if (!flatThemeActive.value || viewMode.value !== 'table' || typeof document === 'undefined') return undefined + if (nameCanvas === undefined) nameCanvas = document.createElement('canvas').getContext('2d') + const family = getComputedStyle(document.documentElement).getPropertyValue('--ui-font').replace(/\s+/g, ' ').trim() + if (!nameCanvas || !family) return undefined + let one = 0 + let two = 0 + let piece = 0 + for (const row of accounts.value) { + const lines: Array<[string, unknown]> = [[`500 13px ${family}`, row.name], [`400 12px ${family}`, accountDisplayEmail(row)]] + for (const [font, text] of lines) { + if (!text) continue + const widths = measureAccountName(nameCanvas, font, String(text)) + one = Math.max(one, widths.one) + two = Math.max(two, widths.two) + piece = Math.max(piece, widths.piece) + } + } + if (!one) return undefined + // a pixel over the canvas width, so layout rounding never wraps the widest line + const px = (width: number) => `${Math.ceil(width) + 1}px` + return { '--account-name-one': px(one), '--account-name-two': px(two), '--account-name-piece': px(piece) } +}) + const accountRouteSummary = (account: Account) => { const groups = accountGroupsForRow(account).map(group => group.name).filter(Boolean) const proxy = account.proxy?.name || t('admin.accounts.directConnection') @@ -3006,6 +3056,10 @@ const handleClickOutside = (event: MouseEvent) => { onMounted(async () => { if (route.query.operations === 'history') void accountJobsStore.openDrawer() + if (typeof document !== 'undefined' && document.fonts) { + document.fonts.addEventListener('loadingdone', remeasureAccountNames) + void document.fonts.ready.then(remeasureAccountNames) + } if (typeof window !== 'undefined') { desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery) isDesktopViewport.value = desktopViewportMediaQuery.matches @@ -3049,6 +3103,9 @@ onMounted(async () => { onUnmounted(() => { upstreamBillingRateAbortController?.abort() + if (typeof document !== 'undefined' && document.fonts) { + document.fonts.removeEventListener('loadingdone', remeasureAccountNames) + } if (usageBatchFlushTimer !== null) { clearTimeout(usageBatchFlushTimer) usageBatchFlushTimer = null From 218da6624a04059d12f22147923d1b10a3acc1ce Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 1 Oct 2026 19:11:07 +0800 Subject: [PATCH 2/2] fix(ui): the account name column follows the page's usual names at every width; one selected-row rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account name column (owner 2026-10-01, 「账号名称空白太多也没优化」; independent verification of 5560ae70a: page 1 had grown wider, one long name still set the column, 1536-1680 and a collapsed sidebar switched to a wider column): - softWrap.ts: nameColumnWidth gives each page the width at which its names, e-mails and #ids leave the least blank beside them, an extra line counting as 40px of blank (NAME_LINE_COST), between the widest piece (codes and host names stay whole; a token over pieceCap breaks inside instead) and the widest two-line width. nameRuns / nameLines model the cell's lines (on all 863 rows at 1440: 0 line-count differences from Chromium, every page's column = the model's width + 1px). A name or e-mail wider than the column takes another line at its word joins (Nightingale | Giovanna7598 | @hotmail.com) instead of widening the column for the whole page: an e-mail is no longer held to breaking before its @ only. nameWrapParts also breaks after a URL's slashes (a ; Chromium does not break there), never at a host name's dots. nameWidths (one / two / piece widths) is replaced. - AccountsView binds --account-name-w and --account-name-piece from it (names 500 13px, e-mails 400 12px, the #id in Geist Mono 12 while its column shows; pieceCap 16rem). - tables.css: the column is that width at every screen width; the one-line / two-line step on the table width (min(100cqw - 60rem, 30cqw)) is gone. Below 1400px the 7rem floor stays (page 41 with all 21 columns at 1280: the other columns are at their floors there, so a higher floor scrolls sideways). Selected rows (dark): section 1's existing row rule (the subject-position :has() on the page's own select box) also sets the three data roles of 12.4, which the cells inherit; 12.4's style query and its Firefox @supports :has() twin are removed (no new relational selector; the same in every browser). Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/console/data.css | 30 ++-- frontend/src/styles/console/tables.css | 36 ++--- frontend/src/utils/__tests__/softWrap.spec.ts | 72 ++++++++-- frontend/src/utils/softWrap.ts | 128 ++++++++++++++---- frontend/src/views/admin/AccountsView.vue | 72 +++++----- 5 files changed, 225 insertions(+), 113 deletions(-) diff --git a/frontend/src/styles/console/data.css b/frontend/src/styles/console/data.css index 33e163f256b3..783a24a320dc 100644 --- a/frontend/src/styles/console/data.css +++ b/frontend/src/styles/console/data.css @@ -347,9 +347,14 @@ html.flat-theme .table-wrapper .table-body > tr:where([data-row-id]):is([class~= DataTable's own selection is the row's class; a page's own select column makes the row publish --ui-row-picked (dark theme only, so a light-theme selection never restyles the row's cells), which its cells read through a style query (Chromium 111+, Safari 18+). Firefox has no style queries: it keeps the pre-pass selector in the - @supports (-moz-appearance: none) blocks, which Chromium never loads. Same weights (0,8,4) as the pre-pass rules. */ + @supports (-moz-appearance: none) blocks, which Chromium never loads. Same weights (0,8,4) as the pre-pass rules. + The same row also lifts the data text roles of section 12 (12.4) for its cells, which inherit them: a custom + property needs no style query, so this works in every browser without another relational selector. */ html.flat-theme.dark .table-wrapper .table-body > tr:where([data-row-id]):has(> td[data-column="select"] input[type="checkbox"]:checked) { --ui-row-picked: 1; + --ui-data-secondary: var(--ui-data-picked); + --ui-data-reference: var(--ui-data-picked); + --ui-data-label: var(--ui-data-picked); } html.flat-theme.dark .table-wrapper .table-body > tr[class~="bg-primary-50/40"][class][class]:is(tr):is(tr) :is([class~="text-gray-400"], [class~="text-gray-500"], [class~="dark:text-dark-400"], [class~="dark:text-dark-500"], [data-ui="cell-line-label"]) { @@ -2013,28 +2018,11 @@ html.flat-theme :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list /* 12.4 a selected row (dark): on the selected fill (#39433f, #404946 under the pointer) the grey #a3a3ac is 4.10:1 / 3.71, so the row lifts its secondary, reference and label tones to --ui-data-picked (#b8b8c0, 5.20 / 4.72), as - section 1 lifts upstream's greys there. DataTable's own selection is a class on the row; a page's own select column - publishes --ui-row-picked (section 1, dark only), read here by a style query on the cells; Firefox (no style - queries) keeps the relational form in an @supports (-moz-appearance: none) block, which Chromium never loads, as - section 1 does. */ + section 1 lifts upstream's greys there. DataTable's own selection is a class on the row (below); a page's own + select column is the checked box section 1's row rule already tests, and that rule sets the three roles on the row, + whose cells inherit them (no style query, no further relational selector, the same in Firefox). */ html.flat-theme.dark :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"])) > table > tbody.table-body > tr[class~="bg-primary-50/40"] > td { --ui-data-secondary: var(--ui-data-picked); --ui-data-reference: var(--ui-data-picked); --ui-data-label: var(--ui-data-picked); } - -@container style(--ui-row-picked: 1) { - html.flat-theme.dark :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"])) > table > tbody.table-body > tr[data-row-id] > td { - --ui-data-secondary: var(--ui-data-picked); - --ui-data-reference: var(--ui-data-picked); - --ui-data-label: var(--ui-data-picked); - } -} - -@supports (-moz-appearance: none) { - html.flat-theme.dark :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"])) > table > tbody.table-body > tr:has(> td[data-column="select"] input[type="checkbox"]:checked) > td { - --ui-data-secondary: var(--ui-data-picked); - --ui-data-reference: var(--ui-data-picked); - --ui-data-label: var(--ui-data-picked); - } -} diff --git a/frontend/src/styles/console/tables.css b/frontend/src/styles/console/tables.css index bc9ae66a3bad..2f0b3f8090e4 100644 --- a/frontend/src/styles/console/tables.css +++ b/frontend/src/styles/console/tables.css @@ -217,24 +217,23 @@ html.flat-theme .table-wrapper[data-table="accounts"] > table > tbody.table-body margin-top: 1px; } -/* ---- 名称 / 邮箱 / #ID (/ 备注): the name (500) over its e-mail, both complete; the template puts before the @ - and at the joins of a word of letters (NightingaleGiovanna7598 -> Nightingale · Giovanna · 7598), never inside a code - (the 18 characters of cindy-global-am332d23b406hthnne-…), so an address or a name breaks at a word. - The column hugs its names (owner 2026-10-01, 「账号名称空白太多」). AccountsView measures the page's names and e-mails - in their own fonts (softWrap.ts nameWidths): --account-name-one, the widest of them on one line; --account-name-two, - the narrowest width at which each takes at most two lines, broken at a natural join (an e-mail before its @ only); - --account-name-piece, the widest piece that cannot break. Where the table has room for the one-line width beside the - other columns at about their own one-line widths (the table less 60rem) and it is at most 30 % of the table (one - outlier, a URL with nine 「(Copy)」, does not set a 600px column), the column takes it (1920: the cindy names on one - line); else it takes the two-line width (1440), not a width in between, which would wrap the long names and still - leave blank beside the short ones. The width is the column's own: the rest of the table goes to the other columns - in proportion (the browser's auto layout). Where even the two-line width does not fit (all 21 columns), the browser - narrows the column down to its widest piece, at most 10rem (every code and e-mail part is narrower, 148px at most: - only a longer piece, a URL without a space, may then break inside, so the table never scrolls sideways), and a long - name takes a third line. Unmeasured (no canvas), the column is the browser's, from 9rem. (The clamp is a step: its - middle term is far below the two-line width unless the room holds the one-line width, then it is at least that.) */ +/* ---- 名称 / 邮箱 / #ID (/ 备注): the name (500) over its e-mail, both complete; the template puts before the @, + after a URL's slashes and at the joins of a word of letters (NightingaleGiovanna7598 -> Nightingale · Giovanna · + 7598), never inside a code (the 18 characters of cindy-global-am332d23b406hthnne-…) or a host name, so an address or + a name breaks at a word. + The column follows the page's names (owner 2026-10-01, 「名称这里你留这么大的空白干啥」「账号名称空白太多」). + AccountsView measures the page's names, e-mails and #ids in their own fonts and binds --account-name-w, the width at + which they leave the least blank, an extra line counting as 40px of blank (softWrap.ts nameColumnWidth): a page of + e-mails takes their local part's width, a page of cindy names their two-line width, and one longer name or e-mail + takes a third line at its word joins instead of widening the column for the whole page; never narrower than the + widest piece, --account-name-piece. The width depends on the page's names only, so it is the same at every screen + width (1440, 1920, the sidebar collapsed): the rest of the table goes to the other columns in proportion (the + browser's auto layout). Where even that width does not fit (all 21 columns), the browser narrows the column down to + its widest piece, at most 10rem (every code and e-mail part is narrower: only a longer piece, a host name without a + break, may then break inside, so the table never scrolls sideways), and a long name takes another line. Unmeasured + (no canvas), the column is the browser's, from 9rem. */ html.flat-theme .table-wrapper[data-table="accounts"] { - --account-name-col: calc(clamp(var(--account-name-two), (min(100cqw - 60rem, 30cqw) - var(--account-name-one)) * 100000 + var(--account-name-one), var(--account-name-one)) + 2 * var(--ui-table-cell-x)); + --account-name-col: calc(var(--account-name-w) + 2 * var(--ui-table-cell-x)); --account-name-floor: calc(min(var(--account-name-piece), 10rem) + 2 * var(--ui-table-cell-x)); } @@ -531,7 +530,8 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="actions"] html.flat-theme .table-wrapper[data-table="accounts"] th[data-column="platform_type"] { min-width: 6rem; } /* the name column's floor is today's 8rem (a 7rem piece and the insets): with all 21 columns at 1280 a page whose other cells cannot narrow (a temporarily unschedulable account, OAuth plan chips) leaves the name less than its widest - code, which then breaks inside rather than the table scrolling sideways */ + code, which then breaks inside rather than the table scrolling sideways (page 41: the other columns are at their + floors, so every pixel more of this floor was a pixel of sideways scrolling: 8rem 5px, 9rem 21px) */ html.flat-theme .table-wrapper[data-table="accounts"] { --account-name-floor: calc(min(var(--account-name-piece), 7rem) + 2 * var(--ui-table-cell-x)); } diff --git a/frontend/src/utils/__tests__/softWrap.spec.ts b/frontend/src/utils/__tests__/softWrap.spec.ts index c730ca1f9349..8b86f5193477 100644 --- a/frontend/src/utils/__tests__/softWrap.spec.ts +++ b/frontend/src/utils/__tests__/softWrap.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' -import { nameLinePieces, nameWidths, nameWrapParts } from '../softWrap' +import { nameColumnWidth, nameLinePieces, nameLines, nameRuns, nameWrapParts, type NameCell } from '../softWrap' // one unit per character: enough to tell which split the widths come from const chars = (text: string) => text.length +const cell = (text: string): NameCell => ({ texts: [nameRuns(text, chars)] }) describe('nameWrapParts', () => { it('breaks an e-mail at its words and before the @', () => { @@ -18,18 +19,17 @@ describe('nameWrapParts', () => { ]) expect(nameWrapParts('')).toEqual([]) }) -}) -describe('nameWidths', () => { - it('lays a cindy name out at its hyphens, never inside its code', () => { - const name = 'cindy-global-am332d23b406hthnne-dylancooper2840-2dc78b3c' - expect(nameLinePieces(name)).toEqual(['cindy-', 'global-', 'am332d23b406hthnne-', 'dylancooper', '2840-', '2dc78b3c']) - expect(nameWidths(name, chars)).toEqual({ one: name.length, two: 'cindy-global-am332d23b406hthnne-'.length, piece: 19 }) + it('breaks a URL after its slashes, never at a dot of its host', () => { + expect(nameWrapParts('claude-https://api2.aigcbest.top/v1')).toEqual(['claude-https://', 'api2.aigcbest.top/', 'v1']) }) +}) - it('breaks an e-mail before its @ only', () => { - const mail = 'NightingaleGiovanna7598@hotmail.com' - expect(nameWidths(mail, chars)).toEqual({ one: mail.length, two: 'NightingaleGiovanna7598'.length, piece: 12 }) +describe('nameLinePieces', () => { + it('lays a cindy name out at its hyphens, never inside its code', () => { + expect(nameLinePieces('cindy-global-am332d23b406hthnne-dylancooper2840-2dc78b3c')).toEqual([ + 'cindy-', 'global-', 'am332d23b406hthnne-', 'dylancooper', '2840-', '2dc78b3c' + ]) }) it('breaks after a hyphen, not inside a range of figures', () => { @@ -37,8 +37,56 @@ describe('nameWidths', () => { expect(nameLinePieces('ab-1-2')).toEqual(['ab-', '1-2']) }) - it('breaks after a space, the space hanging at the line end', () => { + it('cuts a URL after its slashes and its hyphens', () => { + expect(nameLinePieces('claude-https://api2.aigcbest.top/v1')).toEqual(['claude-', 'https://', 'api2.aigcbest.top/', 'v1']) + }) +}) + +describe('nameLines', () => { + it('ends each line at the last join that fits', () => { + const mail = nameRuns('NightingaleGiovanna7598@hotmail.com', chars) + expect(nameLines(mail, 35)).toEqual({ lines: 1, widest: 35 }) + expect(nameLines(mail, 23)).toEqual({ lines: 2, widest: 23 }) + // NightingaleGiovanna | 7598@hotmail.com: an e-mail wider than the column breaks at its word joins too + expect(nameLines(mail, 19)).toEqual({ lines: 2, widest: 19 }) + // Nightingale | Giovanna7598 | @hotmail.com + expect(nameLines(mail, 12)).toEqual({ lines: 3, widest: 12 }) + }) + + it('hangs a space at the line end', () => { // 「openai_28 」 | 「(Copy)」: the first line is 9 wide, its space not counted - expect(nameWidths('openai_28 (Copy)', chars)).toEqual({ one: 16, two: 9, piece: 9 }) + expect(nameLines(nameRuns('openai_28 (Copy)', chars), 10)).toEqual({ lines: 2, widest: 9 }) + }) +}) + +describe('nameColumnWidth', () => { + it('takes the width of the page\'s usual e-mails; a longer one takes a third line', () => { + const page = [...Array.from({ length: 10 }, () => cell('Abcde12345@outlook.com')), cell('NightingaleGiovanna7598@hotmail.com')] + // every Abcde12345 | @outlook.com fills 12; the long one is Nightingale | Giovanna7598 | @hotmail.com, not a + // 19-wide column for the whole page + expect(nameColumnWidth(page)).toEqual({ width: 12, piece: 12 }) + expect(nameLines(page[10].texts[0], 12).lines).toBe(3) + }) + + it('never splits a code, so its width is the floor', () => { + const page = [...Array.from({ length: 5 }, () => cell('cun_001')), cell('eucadc5ca109engnmw@outlook.com')] + expect(nameColumnWidth(page)).toEqual({ width: 18, piece: 18 }) + }) + + it('keeps names on two lines where a narrower column would cost each a line', () => { + const page = Array.from({ length: 3 }, () => cell('cindy-global-am332d23b406hthnne-dylancooper2840-2dc78b3c')) + // cindy-global-am332d23b406hthnne- | dylancooper2840-2dc78b3c + expect(nameColumnWidth(page)).toEqual({ width: 32, piece: 19 }) + }) + + it('counts the #id and is empty without names', () => { + expect(nameColumnWidth([{ texts: [nameRuns('kimi', chars)], fixed: 6 }])).toEqual({ width: 6, piece: 4 }) + expect(nameColumnWidth([])).toEqual({ width: 0, piece: 0 }) + }) + + it('lets a token wider than the cap break inside rather than set the column', () => { + const page = [...Array.from({ length: 5 }, () => cell('cun_0001')), cell('x'.repeat(40))] + expect(nameColumnWidth(page)).toEqual({ width: 40, piece: 40 }) + expect(nameColumnWidth(page, { pieceCap: 16 })).toEqual({ width: 16, piece: 40 }) }) }) diff --git a/frontend/src/utils/softWrap.ts b/frontend/src/utils/softWrap.ts index f900f17c0c95..b757559805b2 100644 --- a/frontend/src/utils/softWrap.ts +++ b/frontend/src/utils/softWrap.ts @@ -9,10 +9,12 @@ const isWordChar = (c: string | undefined) => isLetter(c) || isDigit(c) const isCJK = (c: string | undefined) => c !== undefined && /[\u3400-\u9fff\uf900-\ufaff]/.test(c) /** - * An account name or e-mail breaks at a word, never inside one: before the @, and inside a word of letters (with - * figures only at its end) where a lower-case letter meets a capital (NightingaleGiovanna -> Nightingale | Giovanna) - * and before its final run of three or more figures (Giovanna7598 -> Giovanna | 7598). A word with figures inside it is - * a code and stays whole (the 18 characters of cindy-global-am332d23b406hthnne-…, eucadc5ca109engnmw@outlook.com). + * An account name or e-mail breaks at a word, never inside one: before the @, after a slash that a letter follows (a + * URL's https:// | api2.aigcbest.top/ | v1; Chromium does not break there by itself), and inside a word of letters + * (with figures only at its end) where a lower-case letter meets a capital (NightingaleGiovanna -> Nightingale | + * Giovanna) and before its final run of three or more figures (Giovanna7598 -> Giovanna | 7598). A word with figures + * inside it is a code and stays whole (the 18 characters of cindy-global-am332d23b406hthnne-…, + * eucadc5ca109engnmw@outlook.com), and so does a host name (no break at its dots). */ export function nameWrapParts(value: unknown): string[] { const text = value == null ? '' : String(value) @@ -21,6 +23,7 @@ export function nameWrapParts(value: unknown): string[] { for (let start = 0; start < text.length; ) { if (!isWordChar(text[start])) { if (text[start] === '@' && start > 0) cuts.push(start) + if (text[start] === '/' && isLetter(text[start + 1])) cuts.push(start + 1) start++ continue } @@ -86,37 +89,104 @@ export function nameLinePieces(value: unknown): string[] { return pieces } -export interface NameWidths { - /** the value on one line */ - one: number - /** the narrowest width at which it takes at most two lines, broken at a piece join (an e-mail before its @ only) */ - two: number - /** its widest piece: the narrowest width it can take without breaking inside a word */ - piece: number +/** A name or e-mail measured for line breaking: its pieces (nameLinePieces) and the width of every run of them. */ +export interface NameRuns { + pieces: string[] + /** run[a][b]: pieces a .. b-1 on one line (a space at its end hangs outside the line, as in the browser) */ + run: number[][] } -const EMAIL = /^[^\s@]+@[^\s@]+$/ +/** Measures a name or e-mail's pieces and their runs, with `measure` giving a text's width in the cell's font. */ +export function nameRuns(value: unknown, measure: (text: string) => number): NameRuns { + const pieces = nameLinePieces(value) + const run = pieces.map((_, a) => { + const widths = new Array(pieces.length + 1).fill(0) + let line = '' + for (let b = a + 1; b <= pieces.length; b++) widths[b] = measure((line += pieces[b - 1]).trimEnd()) + return widths + }) + return { pieces, run } +} -/** The widths of a name or e-mail laid out in pieces (nameLinePieces), with `measure` giving a text's width. */ -export function nameWidths(value: unknown, measure: (text: string) => number): NameWidths { - const text = value == null ? '' : String(value) - if (!text) return { one: 0, two: 0, piece: 0 } - const one = measure(text) - const pieces = nameLinePieces(text) +/** + * The lines a measured name or e-mail takes at a width, as the browser lays it out (each line ends at the last join + * that still fits), and the widest of them. A piece wider than the width (the column's floor) counts as filling it. + */ +export function nameLines(text: NameRuns, width: number): { lines: number; widest: number } { + const count = text.pieces.length + let lines = 0 + let widest = 0 + for (let a = 0; a < count; ) { + let b = a + 1 + while (b < count && text.run[a][b + 1] <= width) b++ + lines++ + widest = Math.max(widest, Math.min(text.run[a][b], width)) + a = b + } + return { lines, widest } +} + +/** A row's name cell: its name and e-mail measured, and the width of its lines that do not wrap (the #id). */ +export interface NameCell { + texts: NameRuns[] + fixed?: number +} + +/** What an extra line of a name costs, in pixels of blank beside the names (see nameColumnWidth). */ +export const NAME_LINE_COST = 40 + +/** + * The account name column's width for a page of rows (owner 2026-10-01, 「名称这里你留这么大的空白干啥」): the width at + * which the page's names and e-mails leave the least blank beside them, every extra line a name or e-mail takes + * counting as `lineCost` pixels of blank. So the column follows the page's usual names: a page of e-mails takes their + * local part's width (Aelmp67526 | @outlook.com), a page of cindy names their two-line width, and one longer name or + * e-mail takes a third line at its word joins (Nightingale | Giovanna7598 | @hotmail.com) instead of widening the + * column for the whole page. Never narrower than the widest piece (a code, a host name, the #id: nothing breaks inside + * a word; a token wider than `pieceCap` breaks inside rather than set the column) and never wider than the narrowest + * width at which each name and e-mail takes at most two lines (the column does not widen to put names on one line). + * The same at every screen width: the table's spare width goes to the other columns. Returns whole pixels (0 for no + * names) and the widest piece. + */ +export function nameColumnWidth( + cells: NameCell[], + { lineCost = NAME_LINE_COST, pieceCap = Infinity }: { lineCost?: number; pieceCap?: number } = {} +): { width: number; piece: number } { let piece = 0 - for (const p of pieces) piece = Math.max(piece, measure(p.trimEnd())) - let two = one - if (EMAIL.test(text)) { - const at = text.indexOf('@') - two = Math.min(one, Math.max(measure(text.slice(0, at)), measure(text.slice(at)))) - } else { - let first = '' - for (let i = 0; i < pieces.length - 1; i++) { - first += pieces[i] - two = Math.min(two, Math.max(measure(first.trimEnd()), measure(pieces.slice(i + 1).join('')))) + let fixed = 0 + let two = 0 + for (const cell of cells) { + fixed = Math.max(fixed, cell.fixed ?? 0) + for (const text of cell.texts) { + const count = text.pieces.length + let textPiece = 0 + for (let a = 0; a < count; a++) textPiece = Math.max(textPiece, text.run[a][a + 1]) + let textTwo = count ? text.run[0][count] : 0 + for (let s = 1; s < count; s++) textTwo = Math.min(textTwo, Math.max(text.run[0][s], text.run[s][count])) + piece = Math.max(piece, textPiece) + two = Math.max(two, textTwo, textPiece) + } + } + const from = Math.ceil(Math.max(Math.min(piece, pieceCap), fixed)) + const to = Math.max(from, Math.ceil(two)) + let width = from + let least = Infinity + for (let w = from; w <= to; w++) { + let cost = 0 + for (const cell of cells) { + let ink = cell.fixed ?? 0 + for (const text of cell.texts) { + const laid = nameLines(text, w) + ink = Math.max(ink, laid.widest) + cost += lineCost * laid.lines + } + cost += w - ink + } + if (cost < least) { + least = cost + width = w } } - return { one, two: Math.max(two, piece), piece } + return { width: two > 0 ? width : 0, piece: Math.ceil(piece) } } /** The longest model-id part drawn whole; a usual id (claude-sonnet-4-5-20250929) is far shorter. */ diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 9befd26ca73f..d4585aa5fbc7 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -723,7 +723,7 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue' import Toggle from '@/components/common/Toggle.vue' import { CreateAccountModal, EditAccountModal, BulkEditAccountModal, SyncFromCrsModal, TempUnschedStatusModal } from '@/components/account' -import { nameWidths, nameWrapParts, type NameWidths } from '@/utils/softWrap' +import { nameColumnWidth, nameRuns, nameWrapParts, type NameCell, type NameRuns } from '@/utils/softWrap' import AccountTableActions from '@/components/admin/account/AccountTableActions.vue' import AccountBulkActionsBar from '@/components/admin/account/AccountBulkActionsBar.vue' // Console theme (ui-el D6): a two-row toolbar with the classification menu and a bulk menu in it; the bulk bar @@ -2145,54 +2145,60 @@ function accountDisplayEmail(row: any): string { const nameParts = (value: unknown): string[] => flatThemeActive.value ? nameWrapParts(value) : [value == null ? '' : String(value)] -// Console theme: the name column hugs its names (styles/console/tables.css, 名称). The page's account names (13px / 500) -// and e-mails (12px / 400, accounts.css) are measured in the cells' font on a canvas: the widest of them on one line, -// the narrowest width at which each takes at most two lines (an e-mail breaks before its @ only, a code such as the 18 -// characters of a cindy name never) and the widest piece that cannot break. tables.css gives the column the one-line -// width where the table has room for it, else the two-line width. Measured from the page's rows, not the rendered -// ones, so a virtualised list keeps one width while it scrolls; the web fonts arriving re-measure. +// Console theme: the name column follows the page's names (styles/console/tables.css, 名称; softWrap.ts +// nameColumnWidth). The page's account names (13px / 500), e-mails (12px / 400, accounts.css) and #ids (Geist Mono 12) +// are measured in the cells' fonts on a canvas, piece by piece, and the column takes the width at which they leave the +// least blank, an extra line counting as 40px of it: one long name or e-mail takes a third line at its word joins +// instead of widening the column for the whole page. Measured from the page's rows, not the rendered ones, so a +// virtualised list keeps one width while it scrolls; the web fonts arriving re-measure. const nameFontEpoch = ref(0) -const nameWidthCache = new Map() +const nameRunsCache = new Map() let nameCanvas: CanvasRenderingContext2D | null | undefined const remeasureAccountNames = () => { - nameWidthCache.clear() + nameRunsCache.clear() nameFontEpoch.value++ } -const measureAccountName = (ctx: CanvasRenderingContext2D, font: string, text: string): NameWidths => { +const measureAccountName = (ctx: CanvasRenderingContext2D, font: string, text: string): NameRuns => { const key = `${font}\n${text}` - let widths = nameWidthCache.get(key) - if (!widths) { + let runs = nameRunsCache.get(key) + if (!runs) { ctx.font = font - widths = nameWidths(text, (part) => ctx.measureText(part).width) - nameWidthCache.set(key, widths) + runs = nameRuns(text, (part) => ctx.measureText(part).width) + nameRunsCache.set(key, runs) } - return widths + return runs } const accountNameColumnStyle = computed | undefined>(() => { void nameFontEpoch.value if (!flatThemeActive.value || viewMode.value !== 'table' || typeof document === 'undefined') return undefined if (nameCanvas === undefined) nameCanvas = document.createElement('canvas').getContext('2d') - const family = getComputedStyle(document.documentElement).getPropertyValue('--ui-font').replace(/\s+/g, ' ').trim() - if (!nameCanvas || !family) return undefined - let one = 0 - let two = 0 - let piece = 0 - for (const row of accounts.value) { - const lines: Array<[string, unknown]> = [[`500 13px ${family}`, row.name], [`400 12px ${family}`, accountDisplayEmail(row)]] - for (const [font, text] of lines) { - if (!text) continue - const widths = measureAccountName(nameCanvas, font, String(text)) - one = Math.max(one, widths.one) - two = Math.max(two, widths.two) - piece = Math.max(piece, widths.piece) + const rootStyle = getComputedStyle(document.documentElement) + const fontFamily = (name: string) => rootStyle.getPropertyValue(name).replace(/\s+/g, ' ').trim() + const family = fontFamily('--ui-font') + const mono = fontFamily('--ui-font-mono') + const ctx = nameCanvas + if (!ctx || !family) return undefined + const showId = !hiddenColumns.has('id') && !!mono + const cells: NameCell[] = accounts.value.map((row) => { + const texts: NameRuns[] = [] + if (row.name) texts.push(measureAccountName(ctx, `500 13px ${family}`, String(row.name))) + const email = accountDisplayEmail(row) + if (email) texts.push(measureAccountName(ctx, `400 12px ${family}`, email)) + let fixed = 0 + if (showId) { + ctx.font = `400 12px ${mono}` + fixed = ctx.measureText(`#${row.id}`).width } - } - if (!one) return undefined - // a pixel over the canvas width, so layout rounding never wraps the widest line - const px = (width: number) => `${Math.ceil(width) + 1}px` - return { '--account-name-one': px(one), '--account-name-two': px(two), '--account-name-piece': px(piece) } + return { texts, fixed } + }) + // a token wider than 16rem breaks inside rather than set the column (the widest piece of the 863 names is a 205px + // host name, integrations.emergentagent.com/) + const { width, piece } = nameColumnWidth(cells, { pieceCap: 256 }) + if (!width) return undefined + // a pixel over the canvas widths, so layout rounding never wraps a line that fits + return { '--account-name-w': `${width + 1}px`, '--account-name-piece': `${piece + 1}px` } }) const accountRouteSummary = (account: Account) => {