From 9346bad8702cd2488ee77ae9ae712d855bb620d1 Mon Sep 17 00:00:00 2001 From: SToneX Date: Mon, 28 Sep 2026 18:45:19 +0800 Subject: [PATCH] refactor(dashboard): shadcn-shaped table primitives and a layout check Tables kept shipping the same bugs: text painted over its neighbour, rows whose columns did not line up, words split across lines. Each was a hand-made table or a per-row CSS grid fighting the sheet's global table rules. - components/ui/table: Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption and TableEmpty, shaped after shadcn/ui, and DataTable on top: column definitions (width, alignment, truncate, wrap, sort, a heading tip, phone layout) with a slot per cell; the page owns rows and sort. - The global table/th/td rules skip .ui-table via :where(), at the same specificity, so existing tables are unchanged and new ones start clean. - The job comparison and the search answer move onto DataTable. The answer's columns now line up across rows, and "Show more" is gone. - e2e/layout.spec.ts renders the main pages at desktop and phone width and fails on text over text, text past its row, or a page scrolling sideways, naming the element responsible. It found two bugs, fixed here: an input tag spilling into the price on a phone, and the Activity key picker pushing the page off screen. Fragments: interface/dashboard.md. --- .agents/skills/tools-registry-context/MAP.md | 4 +- docs/context/interface/dashboard.md | 14 ++ frontend/README.md | 14 ++ frontend/e2e/dashboard.spec.ts | 6 +- frontend/e2e/helpers.ts | 65 +++++++++ frontend/e2e/layout.spec.ts | 77 +++++++++++ frontend/e2e/mobile.spec.ts | 34 +---- frontend/src/components/FindAnswer.vue | 103 +++++++------- .../src/components/ui/table/DataTable.vue | 86 ++++++++++++ frontend/src/components/ui/table/Table.vue | 17 +++ .../src/components/ui/table/TableBody.vue | 1 + .../src/components/ui/table/TableCaption.vue | 1 + .../src/components/ui/table/TableCell.vue | 7 + .../src/components/ui/table/TableEmpty.vue | 8 ++ .../src/components/ui/table/TableFooter.vue | 1 + .../src/components/ui/table/TableHead.vue | 7 + .../src/components/ui/table/TableHeader.vue | 1 + frontend/src/components/ui/table/TableRow.vue | 8 ++ frontend/src/components/ui/table/index.ts | 11 ++ frontend/src/components/ui/table/types.ts | 26 ++++ frontend/src/pages/PlatformPage.vue | 40 ++---- frontend/src/state/catalog.js | 3 +- frontend/src/state/catalogComputed.js | 12 +- frontend/src/state/data.js | 2 +- frontend/src/styles/base.css | 126 ++++++++++-------- 25 files changed, 499 insertions(+), 175 deletions(-) create mode 100644 frontend/e2e/layout.spec.ts create mode 100644 frontend/src/components/ui/table/DataTable.vue create mode 100644 frontend/src/components/ui/table/Table.vue create mode 100644 frontend/src/components/ui/table/TableBody.vue create mode 100644 frontend/src/components/ui/table/TableCaption.vue create mode 100644 frontend/src/components/ui/table/TableCell.vue create mode 100644 frontend/src/components/ui/table/TableEmpty.vue create mode 100644 frontend/src/components/ui/table/TableFooter.vue create mode 100644 frontend/src/components/ui/table/TableHead.vue create mode 100644 frontend/src/components/ui/table/TableHeader.vue create mode 100644 frontend/src/components/ui/table/TableRow.vue create mode 100644 frontend/src/components/ui/table/index.ts create mode 100644 frontend/src/components/ui/table/types.ts diff --git a/.agents/skills/tools-registry-context/MAP.md b/.agents/skills/tools-registry-context/MAP.md index f7f35d07..b5854c8b 100644 --- a/.agents/skills/tools-registry-context/MAP.md +++ b/.agents/skills/tools-registry-context/MAP.md @@ -29,6 +29,7 @@ Regenerate via `scripts/build-map.py`. | `frontend/README.md` | interface/dashboard.md | | `frontend/e2e/experiments.spec.ts` | interface/dashboard.md | | `frontend/e2e/landing.spec.ts` | interface/seo.md | +| `frontend/e2e/layout.spec.ts` | interface/dashboard.md | | `frontend/index.html` | interface/dashboard.md, interface/seo.md | | `frontend/package.json` | interface/dashboard.md | | `frontend/scripts/copy-runtime.mjs` | interface/dashboard.md | @@ -48,6 +49,7 @@ Regenerate via `scripts/build-map.py`. | `frontend/src/components/SignInDialog.vue` | interface/dashboard.md | | `frontend/src/components/SignedOutPage.vue` | interface/dashboard.md, interface/landing-sandbox.md | | `frontend/src/components/ToolDrawer.vue` | interface/dashboard.md | +| `frontend/src/components/ui/table/DataTable.vue` | interface/dashboard.md | | `frontend/src/dialogs/AcceptInvitesDialog.vue` | interface/dashboard.md | | `frontend/src/dialogs/AgentGuideDialog.vue` | interface/dashboard.md | | `frontend/src/dialogs/CallDetailsDialog.vue` | interface/dashboard.md | @@ -639,7 +641,7 @@ Regenerate via `scripts/build-map.py`. | `interface/api.md` | `media.py`, `sitetrack.js`, `api.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `caller_metadata.py`, `client_identity.py`, `auth.py`, `provider_resources.py`, `access.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `relay.py`, `connect.py`, `__init__.py`, `referrals.py`, `signup.py`, `__init__.py`, `admin.py`, `auth.py`, `auth_helpers.py`, `billing.py`, `call.py`, `catalog.py`, `connections.py`, `onboard.py`, `orgs.py`, `provider_resources.py`, `api_keys.py`, `resources.py`, `referrals.py`, `signup_cookies.py`, `web.py`, `access.py`, `api_keys.py`, `teams.py`, `access.py`, `budgets.py`, `publicdemo.py`, `usage.py`, `mcp_oauth.py`, `session.py`, `timeutil.py`, `store.py`, `email.py`, `runner.py`, `ratestore.py` | | `interface/catalog-review-proposal.md` | _(no source files — narrative/reference)_ | | `interface/cli.md` | `cli.py`, `test_released_cli_compat.py`, `test_cli_key_compatibility.py`, `auth_helpers.py`, `cli_analytics.py`, `convert.py`, `agents.py`, `api_keys.py`, `test_api_keys.py` | -| `interface/dashboard.md` | `sitetrack.js`, `README.md`, `index.html`, `package.json`, `vite.config.ts`, `TeamResourcesPage.vue`, `FishVoiceDialog.vue`, `resources.js`, `resourcesComputed.js`, `App.vue`, `views.ts`, `api.ts`, `DashboardNavigation.vue`, `PublicNavigation.vue`, `SignInDialog.vue`, `SignedOutPage.vue`, `AcceptInvitesDialog.vue`, `AgentGuideDialog.vue`, `CallDetailsDialog.vue`, `ConnectTokenDialog.vue`, `ConnectionMethodDialog.vue`, `CopyToolDialog.vue`, `dialog.ts`, `EditToolDialog.vue`, `ExtraCredentialDialog.vue`, `ImportSkillDialog.vue`, `RecipeDialog.vue`, `RequestToolDialog.vue`, `ResourcePickerDialog.vue`, `RunToolDialog.vue`, `ShareDialog.vue`, `TopUpDialog.vue`, `TryEndpointDialog.vue`, `WelcomeDialog.vue`, `main.ts`, `ActivityPage.vue`, `AdminPage.vue`, `CatalogPage.vue`, `DetailPage.vue`, `GettingStartedPage.vue`, `getting-started-art.ts`, `experiments.spec.ts`, `AgentCover.vue`, `HelpPage.vue`, `PlatformPage.vue`, `ProviderPage.vue`, `ToolDrawer.vue`, `CatalogSearch.vue`, `ReferralsPage.vue`, `SecretsPage.vue`, `TeamPage.vue`, `ToolsPage.vue`, `activity.js`, `admin.js`, `agents.js`, `agentsComputed.js`, `analytics.js`, `billing.js`, `billingComputed.js`, `boot.js`, `catalog.js`, `catalogComputed.js`, `find.js`, `findComputed.js`, `pile.ts`, `FindAnswer.vue`, `SearchPage.vue`, `LandingNavigation.vue`, `connections.js`, `constants.js`, `context.ts`, `controller.js`, `data.js`, `details.js`, `detailsComputed.js`, `format.js`, `governance.js`, `help.js`, `keys.js`, `lifecycle.js`, `navigation.js`, `onboarding.js`, `onboardingComputed.js`, `projects.js`, `referrals.js`, `secrets.js`, `session.js`, `sessionComputed.js`, `sharing.js`, `skills.js`, `snippets.js`, `storage.js`, `team.js`, `tickets.js`, `tools.js`, `tryTool.js`, `base.css`, `index.ts`, `data.ts`, `AgentPicker.vue`, `SetupInstructions.vue`, `TryItOut.vue`, `vite.agent-setup.config.ts`, `dashboard.css`, `SOURCES.md`, `README.md`, `copy-runtime.mjs`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py`, `api_keys.py`, `test_api_keys.py` | +| `interface/dashboard.md` | `sitetrack.js`, `README.md`, `index.html`, `package.json`, `vite.config.ts`, `TeamResourcesPage.vue`, `FishVoiceDialog.vue`, `resources.js`, `resourcesComputed.js`, `App.vue`, `views.ts`, `api.ts`, `DashboardNavigation.vue`, `PublicNavigation.vue`, `SignInDialog.vue`, `SignedOutPage.vue`, `AcceptInvitesDialog.vue`, `AgentGuideDialog.vue`, `CallDetailsDialog.vue`, `ConnectTokenDialog.vue`, `ConnectionMethodDialog.vue`, `CopyToolDialog.vue`, `dialog.ts`, `EditToolDialog.vue`, `ExtraCredentialDialog.vue`, `ImportSkillDialog.vue`, `RecipeDialog.vue`, `RequestToolDialog.vue`, `ResourcePickerDialog.vue`, `RunToolDialog.vue`, `ShareDialog.vue`, `TopUpDialog.vue`, `TryEndpointDialog.vue`, `WelcomeDialog.vue`, `main.ts`, `ActivityPage.vue`, `AdminPage.vue`, `CatalogPage.vue`, `DetailPage.vue`, `GettingStartedPage.vue`, `getting-started-art.ts`, `experiments.spec.ts`, `AgentCover.vue`, `HelpPage.vue`, `PlatformPage.vue`, `ProviderPage.vue`, `ToolDrawer.vue`, `CatalogSearch.vue`, `DataTable.vue`, `FindAnswer.vue`, `layout.spec.ts`, `ReferralsPage.vue`, `SecretsPage.vue`, `TeamPage.vue`, `ToolsPage.vue`, `activity.js`, `admin.js`, `agents.js`, `agentsComputed.js`, `analytics.js`, `billing.js`, `billingComputed.js`, `boot.js`, `catalog.js`, `catalogComputed.js`, `find.js`, `findComputed.js`, `pile.ts`, `FindAnswer.vue`, `SearchPage.vue`, `LandingNavigation.vue`, `connections.js`, `constants.js`, `context.ts`, `controller.js`, `data.js`, `details.js`, `detailsComputed.js`, `format.js`, `governance.js`, `help.js`, `keys.js`, `lifecycle.js`, `navigation.js`, `onboarding.js`, `onboardingComputed.js`, `projects.js`, `referrals.js`, `secrets.js`, `session.js`, `sessionComputed.js`, `sharing.js`, `skills.js`, `snippets.js`, `storage.js`, `team.js`, `tickets.js`, `tools.js`, `tryTool.js`, `base.css`, `index.ts`, `data.ts`, `AgentPicker.vue`, `SetupInstructions.vue`, `TryItOut.vue`, `vite.agent-setup.config.ts`, `dashboard.css`, `SOURCES.md`, `README.md`, `copy-runtime.mjs`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py`, `api_keys.py`, `test_api_keys.py` | | `interface/enrich-arena.md` | `arena.py`, `arena.py`, `arena.py`, `models.py`, `0027_enrich_arena.py`, `teams.py`, `auth.py`, `bootstrap.py`, `enrich-arena.html`, `arena.js`, `bench.js`, `arena.css`, `index.ts`, `arena_verification_insights.py`, `0029_arena_verification_snapshot.py`, `import_arena_verification.py`, `test_arena_verification_insights.py`, `arena_insights.py`, `arena_insights.py`, `0028_arena_insights.py`, `test_arena_insights.py`, `apollo.svg`, `branddev.svg`, `companyenrich.svg`, `findymail.svg`, `hunter.svg`, `icypeas.svg`, `leadmagic.svg`, `leadsforge.svg`, `lusha.svg`, `pdl.svg`, `predictleads.svg`, `thecompaniesapi.svg`, `tomba.svg`, `sitetrack.js`, `test_enrich_arena.py` | | `interface/env-import.md` | `providers.py`, `skills.py` | | `interface/landing-sandbox.md` | `sandbox.py`, `sandbox_identity.py`, `pubfeed.py`, `sandbox.py`, `__init__.py`, `sandbox.py`, `api.py`, `onboard.py`, `web.py`, `boot.js`, `SignedOutPage.vue`, `install.sh` | diff --git a/docs/context/interface/dashboard.md b/docs/context/interface/dashboard.md index 350592b7..320ab7cd 100644 --- a/docs/context/interface/dashboard.md +++ b/docs/context/interface/dashboard.md @@ -50,6 +50,9 @@ sources: - frontend/src/pages/ProviderPage.vue - frontend/src/components/ToolDrawer.vue - frontend/src/components/CatalogSearch.vue + - frontend/src/components/ui/table/DataTable.vue + - frontend/src/components/FindAnswer.vue + - frontend/e2e/layout.spec.ts - frontend/src/pages/ReferralsPage.vue - frontend/src/pages/SecretsPage.vue - frontend/src/pages/TeamPage.vue @@ -908,6 +911,17 @@ fallback and a bare provider name all stay on that shelf; a sentence does not fi (no row contains a sentence), the answer appears above them instead. An answer row opens the job page when it is one of the shelf's jobs, the drawer otherwise. +**Tables** are built from `components/ui/table`, shaped after shadcn/ui's Table: native elements, one +class each (`.ui-table`, `.ui-tr`, `.ui-th`, `.ui-td`), and `DataTable` on top, which takes column +definitions and a slot per cell while the page keeps the rows and the sort. The job comparison and +the search answer use it. Two variants: `lined` (a hairline per row, shadcn's default) and `plain` +(rows separated by air, the landing look), optionally on a `surface` card. Cells stay on one line +unless a column says `wrap`; a `truncate` column ends in an ellipsis with its full text on hover; a +phone turns each row into a card (`mobile`: primary, field, wide, hide). The sheet's global table rules +are scoped with `:where(table:not(.ui-table))`, zero specificity, so older tables look as they did and +new ones start clean. `e2e/layout.spec.ts` renders the main pages at two widths and fails on any text +painted over other text or past its row, and names what makes a page scroll sideways. + **A provider's page** (`/app/marketplace/`) lists every tool it serves by platform (`GET /catalog/providers/`), each a card opening the same drawer; a tool that is one of several providers doing a job links to that job's comparison ("compare with 24 others"). diff --git a/frontend/README.md b/frontend/README.md index 683fe6ea..fa830de4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -47,6 +47,20 @@ uv build Test behavior, not template source strings, CSS class names or component arrangement. Keep transport unit tests and HTTP rollout/packaging checks; use browser tests for user interactions. +## Tables + +Every new table is built from `src/components/ui/table`: the shadcn-shaped primitives (`Table`, +`TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`, …) and `DataTable`, which takes +column definitions (width, alignment, truncation, wrapping, sort, a heading tip, how the column shows +on a phone) and a `cell-` slot per column. The page owns the rows and the sort. Do not lay out +rows as one CSS grid per row: each row sizes its own columns, so columns stop lining up and a long +value paints over its neighbour. A real table grows the column instead. The sheet's global +`table`/`th`/`td` rules skip `.ui-table`; the older tables still rely on them until they move over. + +`e2e/layout.spec.ts` renders the main pages at desktop and phone width and fails on text painted over +other text, text past its row, or a page that scrolls sideways (`textCollisions`, `sidewaysCulprits` +in `e2e/helpers.ts`). Add a page with a table or cards to it. + Browser tests run against the **built** dashboard (`TREG_FRONTEND_DEV=false`), not the source: run `bash scripts/build-dashboard.sh` after every frontend change, or the browser tests pass or fail on the previous build. A regression test only counts once it has failed against a build without the fix. diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index e25578ce..718a5015 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -41,10 +41,10 @@ test('public catalog and shared deep links remain available without a session', // A job is its own URL: it survives a reload, and the breadcrumb leads back to the shelf. await page.locator('.pl-job').first().click() await expect(page).toHaveURL(/\/catalog\/google\/[^/]+$/) - await expect(page.locator('.pl-cmp tbody tr').first()).toBeVisible() + await expect(page.getByRole('table').getByRole('row').nth(1)).toBeVisible() await page.reload() - await expect(page.locator('.pl-cmp tbody tr').first()).toBeVisible() - await page.locator('.pl-cmp tbody tr').first().click() + await expect(page.getByRole('table').getByRole('row').nth(1)).toBeVisible() + await page.getByRole('table').getByRole('row').nth(1).click() await expect(page.getByRole('complementary', { name: 'Tool details' })).toBeVisible() await page.keyboard.press('Escape') await expect(page.getByRole('complementary', { name: 'Tool details' })).toHaveCount(0) diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index cfead549..d8f897b9 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -45,3 +45,68 @@ export async function openTopUp(page: Page) { export const hubTool = tool export const hubRun = run + +/** The page never scrolls sideways: wide tables and code scroll inside themselves, the rest wraps. */ +export const fitsWidth = (page: Page) => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth) + +/** What makes the page wider than the window: elements past the right edge that no ancestor clips. */ +export async function sidewaysCulprits(page: Page): Promise { + return page.evaluate(() => { + const W = innerWidth, out: string[] = [] + for (const el of document.querySelectorAll('body *')) { + const r = el.getBoundingClientRect() + if (r.right <= W + 1 || !el.getClientRects().length) continue + let clipped = false + for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) + if (getComputedStyle(p).overflowX !== 'visible') { clipped = true; break } + if (!clipped) out.push(`<${el.tagName.toLowerCase()} class="${el.className}"> ends at ${Math.round(r.right)}px`) + } + return out.slice(0, 8) + }) +} + +/** + * Text that paints over other text, or past the edge of its row, inside every element `rows` matches. + * Each text run is measured where it is actually painted (a Range, not its element's box, so text that + * refuses to wrap and spills out of a narrow cell is caught), clipped by any ancestor that clips it, + * so an ellipsis-truncated line counts only as far as it shows. + */ +export async function textCollisions(page: Page, rows: string): Promise { + return page.evaluate((selector) => { + const clip = (r: DOMRect, node: Node, row: Element) => { + let { left, right, top, bottom } = r + for (let p = node.parentElement; p && p !== row; p = p.parentElement) { + const cs = getComputedStyle(p) + if (cs.overflowX === 'visible' && cs.overflowY === 'visible') continue + const c = p.getBoundingClientRect() + left = Math.max(left, c.left); right = Math.min(right, c.right); top = Math.max(top, c.top); bottom = Math.min(bottom, c.bottom) + } + return { left, right, top, bottom } + } + const found: string[] = [] + for (const row of document.querySelectorAll(selector)) { + if (!row.getClientRects().length) continue + const edge = row.getBoundingClientRect() + const runs: { node: Node, text: string, box: { left: number, right: number, top: number, bottom: number } }[] = [] + const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT) + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node.textContent!.trim() + if (!text || !node.parentElement?.getClientRects().length) continue + const range = document.createRange(); range.selectNodeContents(node) + for (const r of range.getClientRects()) { + const box = clip(r, node, row) + if (box.right - box.left < 1 || box.bottom - box.top < 1) continue + runs.push({ node, text: text.slice(0, 48), box }) + if (box.right > edge.right + 1 || box.left < edge.left - 1) found.push(`past its row: ${text.slice(0, 48)}`) + } + } + for (let i = 0; i < runs.length; i++) for (let j = i + 1; j < runs.length; j++) { + const a = runs[i]!, b = runs[j]! + if (a.node === b.node) continue + if (a.box.right - 1 > b.box.left && b.box.right - 1 > a.box.left && a.box.bottom - 1 > b.box.top && b.box.bottom - 1 > a.box.top) + found.push(`${a.text} / ${b.text}`) + } + } + return [...new Set(found)] + }, rows) +} diff --git a/frontend/e2e/layout.spec.ts b/frontend/e2e/layout.spec.ts new file mode 100644 index 00000000..6ac92989 --- /dev/null +++ b/frontend/e2e/layout.spec.ts @@ -0,0 +1,77 @@ +import { expect, test, type Page } from '@playwright/test' +import { fitsWidth, sidewaysCulprits, signIn, textCollisions } from './helpers' + +// Every main page, at desktop and phone width: the page never scrolls sideways, and no text in a +// row, card or cell paints over other text or past its edge. This is the class of bug every table +// here kept shipping (a fixed column and a price that would not wrap), caught by rendering, not by +// reading CSS. +const ROWS = 'tr, .pl-card, .cat-find-suggest, .pl-autocard, .td-stats > div, .td-params > div' + +async function expectClean(page: Page, where: string) { + if (!(await fitsWidth(page))) expect(await sidewaysCulprits(page), `${where}: scrolls sideways`).toEqual([]) + expect(await textCollisions(page, ROWS), where).toEqual([]) +} + +test('the collision check sees text painted over text, and past its row', async ({ page }) => { + // The checker's own test: a fixed grid column too narrow for a price that will not wrap, the shape + // of the bug a table cannot have (its column grows to the content) but a hand-made grid row can. + await page.setContent(`
+ $0.025/started 10 emailsfit
+
a line far wider than its row
+
clipped, so it only counts as far as it shows
`) + const found = await textCollisions(page, '.row') + expect(found).toContain('$0.025/started 10 emails / fit') + expect(found).toContain('past its row: a line far wider than its row') + expect(found.some(f => f.includes('clipped'))).toBe(false) +}) + +for (const [width, height] of [[1440, 1000], [390, 844]] as const) { + test.describe(`at ${width}px`, () => { + test.use({ viewport: { width, height } }) + + test('the public catalog, a shelf, a job and a tool lay out cleanly', async ({ page }) => { + await page.goto('/catalog') + await expect(page.locator('.cat-find')).toBeVisible() + await expectClean(page, '/catalog') + for (const slug of ['companies', 'people']) { + await page.goto('/catalog/' + slug) + await expect(page.locator('.pl-job').first()).toBeVisible() + await expectClean(page, '/catalog/' + slug) + } + await page.locator('.pl-job').first().click() + await expect(page.locator('.ui-data-table .ui-tbody .ui-tr').first()).toBeVisible() + await expectClean(page, 'a job page') + await page.locator('.ui-data-table .ui-tbody .ui-tr').first().click() + await expect(page.getByRole('complementary', { name: 'Tool details' })).toBeVisible() + await expectClean(page, 'the tool drawer') + }) + + test('a search answer keeps a long price in its column', async ({ page }) => { + // The browser-test server has no judge key; answer /catalog/find with real catalog rows, one of + // them priced "per started 10 emails", the rate that once painted over the fit bar. + const detail = await (await page.request.get('/catalog/endpoints/hunter.companies.emails')).json() + const ep = detail.endpoint + const rows = [ep, ...detail.siblings.slice(0, 3)].map((e: any, i: number) => ({ + id: e.id, name: e.name || e.summary, provider: e.provider, provider_display: e.provider_display, + platform: e.platform || 'people', platform_label: 'People & contact data', capability: e.capability, + capability_description: e.name, cost: e.cost, p: 0.9 - i * 0.1 })) + await page.route('**/catalog/find?**', route => route.fulfill({ status: 200, contentType: 'application/x-ndjson', + body: JSON.stringify({ event: 'candidates', candidates: [] }) + '\n' + + JSON.stringify({ event: 'judged', verdict: 'strong', named: '', read: 4, high: 0.8, rows }) + '\n' })) + await page.goto('/catalog') + await page.locator('.cat-find input').fill('list every email address at a company') + await page.keyboard.press('Enter') + await expect(page.locator('.fa .ui-tbody .ui-tr').first()).toBeVisible() + await expectClean(page, 'a search answer') + }) + + test('the signed-in pages with tables lay out cleanly', async ({ page }) => { + await signIn(page, `layout-${width}`) + for (const hash of ['activity', 'orgs', 'tools', 'connections']) { + await page.goto('/app#' + hash) + await page.waitForLoadState('networkidle') + await expectClean(page, '/app#' + hash) + } + }) + }) +} diff --git a/frontend/e2e/mobile.spec.ts b/frontend/e2e/mobile.spec.ts index 7484f2a8..7f0eecbf 100644 --- a/frontend/e2e/mobile.spec.ts +++ b/frontend/e2e/mobile.spec.ts @@ -1,12 +1,10 @@ import { expect, test, type Page } from '@playwright/test' -import { billingOn, hubOn, hubTool, openTopUp, signIn } from './helpers' +import { billingOn, fitsWidth, hubOn, hubTool, openTopUp, signIn, textCollisions } from './helpers' // At phone width the page itself never scrolls sideways: wide tables and code scroll inside // themselves, and everything else wraps. test.use({ viewport: { width: 390, height: 844 } }) -const fitsWidth = (page: Page) => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth) - test('the top-up dialog shows every amount on a phone', async ({ page }) => { await billingOn(page) await signIn(page, 'phone-topup') @@ -38,32 +36,6 @@ test('a job comparison keeps provider prices clear of each other on a phone', as await page.locator('.pl-job').first().click() await expect(page.getByRole('table').first()).toBeVisible() expect(await fitsWidth(page)).toBe(true) - // No two pieces of text in one comparison row paint over each other. Text an ancestor clips (a route - // cut short with an ellipsis) counts only where it is painted. - const overlaps = await page.evaluate(() => { - const painted = (el: HTMLElement, row: Element) => { - const r = el.getBoundingClientRect() - let { left, right, top, bottom } = r - for (let p = el.parentElement; p && p !== row; p = p.parentElement) { - if (getComputedStyle(p).overflowX === 'visible') continue - const c = p.getBoundingClientRect() - left = Math.max(left, c.left); right = Math.min(right, c.right); top = Math.max(top, c.top); bottom = Math.min(bottom, c.bottom) - } - return { left, right, top, bottom } - } - const found: string[] = [] - for (const row of document.querySelectorAll('tr')) { - const texts = [...row.querySelectorAll('*')].filter(el => - [...el.childNodes].some(n => n.nodeType === Node.TEXT_NODE && n.textContent!.trim()) && el.getClientRects().length) - const boxes = texts.map(el => ({ el, r: painted(el, row) })).filter(b => b.r.right > b.r.left && b.r.bottom > b.r.top) - for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) { - const a = boxes[i]!, b = boxes[j]! - if (a.el.contains(b.el) || b.el.contains(a.el)) continue - if (a.r.right - 1 > b.r.left && b.r.right - 1 > a.r.left && a.r.bottom - 1 > b.r.top && b.r.bottom - 1 > a.r.top) - found.push(`${a.el.textContent!.trim()} / ${b.el.textContent!.trim()}`) - } - } - return found - }) - expect(overlaps).toEqual([]) + // No two pieces of text in one comparison row paint over each other (helpers.textCollisions). + expect(await textCollisions(page, 'tr')).toEqual([]) }) diff --git a/frontend/src/components/FindAnswer.vue b/frontend/src/components/FindAnswer.vue index 357a638a..7124cc6b 100644 --- a/frontend/src/components/FindAnswer.vue +++ b/frontend/src/components/FindAnswer.vue @@ -1,25 +1,29 @@ @@ -76,41 +76,32 @@ export default { .fa-link{border:0;background:none;padding:0;font:inherit;font-size:13px;color:var(--ink);text-decoration:underline;text-underline-offset:3px; text-decoration-color:var(--line2,var(--line));cursor:pointer} .fa-link:hover{text-decoration-color:currentColor} -.fa-list{list-style:none;margin:0;padding:0;border-top:1px solid var(--line)} -.fa-row{position:relative;border-bottom:1px solid var(--line);animation:fa-in .3s both} -.fa-row:nth-child(2){animation-delay:30ms}.fa-row:nth-child(3){animation-delay:60ms}.fa-row:nth-child(4){animation-delay:90ms} -.fa-row:nth-child(5){animation-delay:120ms}.fa-row:nth-child(n+6){animation-delay:150ms} +/* The layout is the table's (components/ui/table); these style what sits in its cells. */ +:deep(.ui-tbody .ui-tr){animation:fa-in .3s both} @keyframes fa-in{from{opacity:0}} -.fa-main{width:100%;display:grid;grid-template-columns:36px minmax(0,1fr) 150px 180px 56px;align-items:center;gap:16px; - padding:12px 140px 12px 8px;border:0;background:none;text-align:left;color:var(--ink);cursor:pointer;border-radius:10px;font:inherit} -.fa-main:hover{background:var(--hover,rgba(0,0,0,.035))} -.fa-logo{width:36px;height:36px;border-radius:10px;background:#fff;border:1px solid var(--line);display:grid;place-items:center;color:#fff;font-weight:600;font-size:14px} +.fa-main{display:flex;align-items:center;gap:14px;min-width:0} +.fa-logo{flex:0 0 36px;width:36px;height:36px;border-radius:10px;background:#fff;border:1px solid var(--line);display:grid;place-items:center;color:#fff;font-weight:600;font-size:14px} .fa-logo img{width:21px;height:21px;object-fit:contain} .fa-what{display:flex;flex-direction:column;gap:1px;min-width:0} -.fa-what b{font-weight:500;font-size:14.5px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fa-what b{font-weight:500;font-size:14.5px;line-height:1.3} .fa-what small{font-size:12.5px;color:var(--muted)} .fa-provs{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted);white-space:nowrap} .fa-stack{display:flex;padding-left:6px} .fa-stack img{width:20px;height:20px;margin-left:-6px;border-radius:6px;background:#fff;border:1.5px solid var(--bg);object-fit:contain;padding:1px} -/* A rate can be a phrase ("$0.025/started 10 emails"): one width for every row so the columns line up, wide enough for that, and past it an ellipsis, never over the fit bar. */ -.fa-price{overflow:hidden;text-overflow:ellipsis;font-family:var(--mono);font-size:12px;color:var(--muted);text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums} -.fa-fit{height:4px;border-radius:2px;background:var(--line);overflow:hidden} +/* A rate can be a phrase ("$0.025/started 10 emails"): the price column is 180px on every row, and + anything longer ends in an ellipsis with the whole rate on hover (the DataTable's `truncate`). */ +.fa-price{font-family:var(--mono);font-size:12px;color:var(--muted)} +.fa-fit{display:block;width:56px;margin-left:auto;height:4px;border-radius:2px;background:var(--line);overflow:hidden} .fa-fit i{display:block;height:100%;background:var(--ink);border-radius:2px} -.fa-row.weak .fa-what b,.fa-row.weak .fa-logo{opacity:.62} -.fa-row.weak .fa-fit i{background:var(--muted)} -.fa-copy{position:absolute;right:8px;top:50%;transform:translateY(-50%);border:1px solid var(--line2,var(--line));background:var(--surface,var(--panel)); +.weak .fa-what b,.weak .fa-logo{opacity:.62} +.weak .fa-fit i{background:var(--muted)} +.fa-copy{border:1px solid var(--line2,var(--line));background:var(--surface,var(--panel));white-space:nowrap; color:var(--ink);border-radius:8px;padding:5px 11px;font:inherit;font-size:12.5px;cursor:pointer;opacity:0;transition:opacity .15s} -.fa-row:hover .fa-copy,.fa-copy:focus-visible{opacity:1} -.fa-more{margin-top:10px} +.ui-tr:hover .fa-copy,.fa-copy:focus-visible{opacity:1} .fa-skel{display:grid;grid-template-columns:36px minmax(0,1fr) 150px;gap:16px;align-items:center;padding:12px 8px;border-bottom:1px solid var(--line)} .fa-skel span{height:12px;border-radius:6px;background:linear-gradient(90deg,var(--line),var(--hover,var(--panel2)),var(--line));background-size:200% 100%;animation:fa-sh 1.2s linear infinite} .fa-skel span:first-child{height:36px;border-radius:10px} @keyframes fa-sh{to{background-position:-200% 0}} -@media (max-width:760px){ - .fa-main{grid-template-columns:36px minmax(0,1fr) auto;padding-right:8px} - .fa-provs,.fa-fit{display:none} - .fa-copy{display:none} -} @media (hover:none){.fa-copy{opacity:1}} -@media (prefers-reduced-motion:reduce){.fa-row,.fa-skel span{animation:none}} +@media (prefers-reduced-motion:reduce){:deep(.ui-tbody .ui-tr),.fa-skel span{animation:none}} diff --git a/frontend/src/components/ui/table/DataTable.vue b/frontend/src/components/ui/table/DataTable.vue new file mode 100644 index 00000000..0e00a336 --- /dev/null +++ b/frontend/src/components/ui/table/DataTable.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/src/components/ui/table/Table.vue b/frontend/src/components/ui/table/Table.vue new file mode 100644 index 00000000..1b565bc6 --- /dev/null +++ b/frontend/src/components/ui/table/Table.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/table/TableBody.vue b/frontend/src/components/ui/table/TableBody.vue new file mode 100644 index 00000000..6440d08c --- /dev/null +++ b/frontend/src/components/ui/table/TableBody.vue @@ -0,0 +1 @@ + diff --git a/frontend/src/components/ui/table/TableCaption.vue b/frontend/src/components/ui/table/TableCaption.vue new file mode 100644 index 00000000..8c0d1a82 --- /dev/null +++ b/frontend/src/components/ui/table/TableCaption.vue @@ -0,0 +1 @@ + diff --git a/frontend/src/components/ui/table/TableCell.vue b/frontend/src/components/ui/table/TableCell.vue new file mode 100644 index 00000000..4330f444 --- /dev/null +++ b/frontend/src/components/ui/table/TableCell.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/components/ui/table/TableEmpty.vue b/frontend/src/components/ui/table/TableEmpty.vue new file mode 100644 index 00000000..419c41bb --- /dev/null +++ b/frontend/src/components/ui/table/TableEmpty.vue @@ -0,0 +1,8 @@ + + + diff --git a/frontend/src/components/ui/table/TableFooter.vue b/frontend/src/components/ui/table/TableFooter.vue new file mode 100644 index 00000000..53263d5f --- /dev/null +++ b/frontend/src/components/ui/table/TableFooter.vue @@ -0,0 +1 @@ + diff --git a/frontend/src/components/ui/table/TableHead.vue b/frontend/src/components/ui/table/TableHead.vue new file mode 100644 index 00000000..31e2e645 --- /dev/null +++ b/frontend/src/components/ui/table/TableHead.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/components/ui/table/TableHeader.vue b/frontend/src/components/ui/table/TableHeader.vue new file mode 100644 index 00000000..d4806bfd --- /dev/null +++ b/frontend/src/components/ui/table/TableHeader.vue @@ -0,0 +1 @@ + diff --git a/frontend/src/components/ui/table/TableRow.vue b/frontend/src/components/ui/table/TableRow.vue new file mode 100644 index 00000000..e8e7a207 --- /dev/null +++ b/frontend/src/components/ui/table/TableRow.vue @@ -0,0 +1,8 @@ + + + diff --git a/frontend/src/components/ui/table/index.ts b/frontend/src/components/ui/table/index.ts new file mode 100644 index 00000000..92644635 --- /dev/null +++ b/frontend/src/components/ui/table/index.ts @@ -0,0 +1,11 @@ +export { default as Table } from './Table.vue' +export { default as TableHeader } from './TableHeader.vue' +export { default as TableBody } from './TableBody.vue' +export { default as TableFooter } from './TableFooter.vue' +export { default as TableRow } from './TableRow.vue' +export { default as TableHead } from './TableHead.vue' +export { default as TableCell } from './TableCell.vue' +export { default as TableCaption } from './TableCaption.vue' +export { default as TableEmpty } from './TableEmpty.vue' +export { default as DataTable } from './DataTable.vue' +export type { Column, Sort } from './types' diff --git a/frontend/src/components/ui/table/types.ts b/frontend/src/components/ui/table/types.ts new file mode 100644 index 00000000..78a1069d --- /dev/null +++ b/frontend/src/components/ui/table/types.ts @@ -0,0 +1,26 @@ +// A DataTable column: what it shows, how wide it may get, how it behaves on a phone. +export interface Column { + key: string + header: string + /** One sentence on what the number means, shown on hover or focus of the heading. */ + tip?: string + align?: 'left' | 'right' | 'center' + /** A fixed width ("180px", "30%"). Omitted: the column sizes to its content. */ + width?: string + /** Never narrower than this, even when the table is squeezed. */ + minWidth?: string + /** One line: longer text ends in an ellipsis, with the whole of it on hover. Needs `width`. */ + truncate?: boolean + /** Cells stay on one line (shadcn's default); a column of prose or tags opts into wrapping. */ + wrap?: boolean + sortable?: boolean + /** The direction a first click sorts in; numbers people want high first say "desc". */ + sortFirst?: 'asc' | 'desc' + /** The cell's text when no `cell-` slot is given, and the full text behind a truncation. */ + value?: (row: Row) => string | number | null | undefined + /** Below 760px each row becomes a card: `primary` spans it, `field` is labelled (two to a line), + * `wide` is labelled and spans it, `hide` drops out. */ + mobile?: 'primary' | 'field' | 'wide' | 'hide' +} + +export interface Sort { key: string, dir: 'asc' | 'desc' } diff --git a/frontend/src/pages/PlatformPage.vue b/frontend/src/pages/PlatformPage.vue index 19490569..6bae3e4c 100644 --- a/frontend/src/pages/PlatformPage.vue +++ b/frontend/src/pages/PlatformPage.vue @@ -3,12 +3,13 @@ import { useDashboard } from '../state/context' import ToolDrawer from '../components/ToolDrawer.vue' import FindAnswer from '../components/FindAnswer.vue' import CatalogSearch from '../components/CatalogSearch.vue' +import { DataTable } from '../components/ui/table' // A platform shelf, read at two levels: the shelf (the jobs several providers do, then every other // tool) and one job (let treg pick, or compare the providers). A tool opens in a drawer over either, // so a comparison never loses its table. The look is the landing page's: surfaces, not boxes. export default { - components: { ToolDrawer, FindAnswer, CatalogSearch }, + components: { ToolDrawer, FindAnswer, CatalogSearch, DataTable }, setup: useDashboard, } @@ -112,31 +113,18 @@ export default {

{{platJobMeta.routed ? 'Or choose a provider' : 'Choose a provider'}}{{platJobChoices.length}}

-
- - - - - - - - - - - - - - - -
ProviderTakes -
- - {{r.e.provider_display||r.e.provider}}unverified - {{clip(r.e.name||r.e.summary, 52)}} -
{{t}}—{{r.e.platform_eligible===false ? 'your key only' : costShort(r.e.cost)}}——
-
+ + + + + + +

Works share of the last 30 days' calls that did not end in a provider error, past 20 calls. Useful what teams' agents said after using the result, one vote per team, past 5 teams; compare it within this table only.

diff --git a/frontend/src/state/catalog.js b/frontend/src/state/catalog.js index ea0db38a..21475869 100644 --- a/frontend/src/state/catalog.js +++ b/frontend/src/state/catalog.js @@ -56,7 +56,7 @@ openPlatform(slug, fromPop, job){ this.resetConfirms(); this.epTab={}; this.platCopied=''; if(!same){ this.platEx={}; this.platClearFilters(); this.epInfo={}; if(this.find.scope) this.findExit(); } // a shelf's answer belongs to that shelf - this.platJobSort='price'; + this.platJobSort={key:'price', dir:'asc'}; // A public visitor stays on the indexable /catalog/ URL; a signed-in one keeps the // in-app hash route. Same view either way — only the address bar differs. if(!fromPop) history.pushState({platform:slug}, '', this.platUrl(slug, job)); @@ -111,7 +111,6 @@ closeTool(){ this.drawerTool=null; }, stepTool(d){ const ids=this.drawerIds; const i=ids.indexOf(this.drawerTool); if(i<0) return; const next=ids[Math.min(ids.length-1, Math.max(0, i+d))]; if(next && next!==this.drawerTool) this.openTool(next); }, -platSortBy(k){ this.platJobSort=k; }, drawerKeys(ev){ if(!this.drawerTool || this.epTry || ev.metaKey || ev.ctrlKey || ev.altKey) return; const t=ev.target; if(t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable)) return; diff --git a/frontend/src/state/catalogComputed.js b/frontend/src/state/catalogComputed.js index 883de324..d8bf042c 100644 --- a/frontend/src/state/catalogComputed.js +++ b/frontend/src/state/catalogComputed.js @@ -175,11 +175,19 @@ platJobChoices(){ usd:e.platform_eligible===false ? null : this.costUsd(e.cost), works:o && o.decided>=20 && o.ok_rate!=null ? {pct:Math.round(o.ok_rate*100), n:o.decided} : null, useful:e.reviews ? {pct:Math.round(e.reviews.share.useful*100), n:e.reviews.teams} : null}; }); - const k=this.platJobSort, val=r=>k==='price' ? r.usd : k==='works' ? (r.works&&r.works.pct) : (r.useful&&r.useful.pct); - const dir=k==='price' ? 1 : -1; + const k=this.platJobSort.key, val=r=>k==='price' ? r.usd : k==='works' ? (r.works&&r.works.pct) : (r.useful&&r.useful.pct); + const dir=this.platJobSort.dir==='asc' ? 1 : -1; // unmeasured rows stay last either way return rows.sort((a,b)=>{ const x=val(a), y=val(b); if(x==null && y==null) return (a.e.provider_display||a.e.provider).localeCompare(b.e.provider_display||b.e.provider); if(x==null) return 1; if(y==null) return -1; return (x-y)*dir || (b.e.verified?1:0)-(a.e.verified?1:0); }); }, +// The comparison's columns (components/ui/table DataTable). Price sorts cheapest first, the two + // measured columns best first; every heading explains its number (`colTips`). + jobColumns(){ const t=this.colTips; return [ + {key:'provider', header:'Provider', mobile:'primary', minWidth:'240px', wrap:true}, + {key:'takes', header:'Takes', tip:t.takes, minWidth:'150px', wrap:true, mobile:'wide'}, + {key:'price', header:'Price', tip:t.price, align:'right', sortable:true, sortFirst:'asc'}, + {key:'works', header:'Works', tip:t.works, align:'right', sortable:true, sortFirst:'desc'}, + {key:'useful', header:'Useful', tip:t.useful, align:'right', sortable:true, sortFirst:'desc'}]; }, platOtherJobs(){ return this.platJobIndex.filter(j=>!this.platJobRow || j.key!==this.platJobRow.capability).slice(0,6); }, drawerIds(){ if(this.view==='provider') return [...this.mkToolShelves.flatMap(p=>p.tools), ...this.mkToolShelves.flatMap(p=>p.plumbing)].map(t=>t.id); diff --git a/frontend/src/state/data.js b/frontend/src/state/data.js index b432a2a7..28bef5a3 100644 --- a/frontend/src/state/data.js +++ b/frontend/src/state/data.js @@ -90,7 +90,7 @@ export default function data(){ drawerTool:null, // endpoint id open in the tool drawer epInfo:{}, // endpoint id → {loading, data} from /catalog/endpoints/ platJobLead:null, // the endpoint whose detail carries the open job's siblings and plan - platJobSort:'price', // the job table's order: 'price' | 'works' | 'useful' + platJobSort:{key:'price', dir:'asc'}, // the comparison's order (DataTable v-model:sort) // What each comparison column means, on hover or focus of its heading. colTips:{ takes:'What you can send it. Each tag is one input it accepts; — means the catalog has not mapped it yet.', diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 609e3936..add6bdc0 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -20,7 +20,8 @@ .btn.sm{padding:4px 9px;font-size:12px} .btn:disabled{opacity:.45;cursor:not-allowed} .btn.ico{width:34px;min-width:34px;padding:0;flex:0 0 auto} .btn.sm.ico{width:30px;min-width:30px;min-height:30px} .btn.danger{border-color:var(--red);color:var(--red)} .btn.danger:hover{background:color-mix(in srgb,var(--red) 15%,transparent);border-color:var(--red)} - .msel{background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:3px 7px;font-family:var(--mono);font-size:12px} + /* max-width: an option's text (an API key's owner email) sets a select's width, and a long one pushed the page past a phone's edge. */ + .msel{max-width:100%;text-overflow:ellipsis;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:3px 7px;font-family:var(--mono);font-size:12px} .frow{display:flex;align-items:center;gap:10px;margin:8px 0} .frow label{width:78px;font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;flex:none} .frow input,.frow select{flex:1;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:var(--rb);padding:7px 10px;font-family:var(--mono);font-size:12.5px} .frow input:disabled{opacity:.6} @@ -284,9 +285,9 @@ .card .host{font-size:11.5px;color:var(--muted);margin:4px 0 10px;word-break:break-all} .card .foot{display:flex;gap:8px;margin-top:13px} .kv{display:flex;gap:18px;flex-wrap:wrap;margin:9px 0} .kv div{font-size:13px} .kv b{display:block;color:var(--muted);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.06em} - table{width:100%;border-collapse:collapse;font-size:12.5px;background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden} - th,td{text-align:left;padding:10px 13px;border-bottom:1px solid var(--line);overflow-wrap:anywhere} th{color:var(--muted);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.06em;background:var(--panel2)} - tr:last-child td{border-bottom:0} + :where(table:not(.ui-table)){width:100%;border-collapse:collapse;font-size:12.5px;background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden} + :where(table:not(.ui-table)) :is(th,td){text-align:left;padding:10px 13px;border-bottom:1px solid var(--line);overflow-wrap:anywhere} :where(table:not(.ui-table)) th{color:var(--muted);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.06em;background:var(--panel2)} + :where(table:not(.ui-table)) tr:last-child td{border-bottom:0} .statgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:18px} .stat{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px} .stat .n{font-size:26px;font-weight:700;color:var(--accent)} .stat .l{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.06em} .tabs{display:flex;gap:4px;border-bottom:1px solid var(--line);margin-bottom:14px} .tabs button{background:none;border:0;color:var(--muted);padding:9px 13px;border-bottom:2px solid transparent;font-family:var(--mono)} .tabs button.active{color:var(--accent);border-bottom-color:var(--accent);font-weight:600} @@ -451,7 +452,7 @@ rather than push the page sideways. Tables and pre blocks keep their own scrollers. */ main code{overflow-wrap:anywhere} .tut-head{flex-wrap:wrap} .tut-head>div:first-child{flex:1 1 100%} .tut-actions{flex:1 1 auto;justify-content:flex-start;margin-left:0} - table{display:block;overflow-x:auto;white-space:nowrap}td,th{word-break:break-word} + :where(table:not(.ui-table)){display:block;overflow-x:auto;white-space:nowrap}:where(table:not(.ui-table)) :is(td,th){word-break:break-word} /* header wraps instead of forcing horizontal page scroll: the search drops to its own full-width row and the flex spacer is removed so brand/org/theme/avatar/sign-out fit the viewport. */ .top{flex-wrap:wrap;gap:8px;padding:10px 12px}.top .spacer{display:none}.search{flex:1 1 100%;max-width:none;order:9}.orgsel{min-width:0} @@ -555,8 +556,8 @@ h1{font-family:var(--display);font-weight:400;font-size:23px;letter-spacing:0} .seg{border-radius:999px} .seg button{border-radius:999px} .seg button.on{background:linear-gradient(180deg,#fdfcf7,#eae7de);color:#1c1b19} .seg button.on span{color:rgba(28,27,25,.55)} -th{background:rgba(0,0,0,.25)} -.ttable-wrap,table{box-shadow:rgba(255,255,255,.07) 0 1px 0 inset} +:where(table:not(.ui-table)) th{background:rgba(0,0,0,.25)} +.ttable-wrap,:where(table:not(.ui-table)){box-shadow:rgba(255,255,255,.07) 0 1px 0 inset} .stat{background:linear-gradient(180deg,#201f1d,#181715); box-shadow:rgba(255,255,255,.07) 0 1px 0 inset, rgba(0,0,0,.35) 0 10px 24px -14px} .card{background:linear-gradient(180deg,#1e1d1b 0%,#171614 100%); @@ -680,11 +681,11 @@ input[type=checkbox],input[type=radio]{accent-color:var(--ink)} .stat{background:var(--surface);border-color:transparent;box-shadow:var(--shadow-sm)} .card{background:var(--surface);border-color:transparent;box-shadow:var(--shadow-sm)} .modal,.drawer{background:var(--surface);box-shadow:var(--shadow-lg)} -.ttable-wrap,table{box-shadow:none} +.ttable-wrap,:where(table:not(.ui-table)){box-shadow:none} .ttable-wrap{background:var(--surface);border-color:var(--line)} -table{background:var(--surface)} +:where(table:not(.ui-table)){background:var(--surface)} /* rows.gg's column heads are label text over a rule, not a filled bar. */ -th{background:none;color:var(--muted2);font-weight:450;border-bottom:1px solid var(--line2)} +:where(table:not(.ui-table)) th{background:none;color:var(--muted2);font-weight:450;border-bottom:1px solid var(--line2)} .frow input,.frow select,.field input,.field select,.field textarea,.search,.msel,.bindinput,.si-input{ background:var(--surface);box-shadow:none;border:1px solid var(--line2);color:var(--ink)} .frow input::placeholder,.field input::placeholder,.field textarea::placeholder,.search input::placeholder{color:var(--muted2)} @@ -718,7 +719,7 @@ h1{font-size:22px;letter-spacing:-.44px} h2{font-size:17px} h3{font-size:15px} .sub{font-size:12.5px;color:var(--muted);max-width:74ch} /* 450 "book" is a real weight in this design — it is why secondary labels sit between regular and medium instead of going grey-and-bold. */ -.side .grp,th,.kv b,.tgh,.stat .l{font-weight:450;text-transform:none;letter-spacing:.1px} +.side .grp,:where(table:not(.ui-table)) th,.kv b,.tgh,.stat .l{font-weight:450;text-transform:none;letter-spacing:.1px} /* ---- 3.3 controls: 10px, not pills ---- Chips and badges stay round; only the BUTTONS square off. */ @@ -1030,34 +1031,13 @@ main.flush,.redesign main.flush{padding:0;max-width:none} .pl-btn.inv{background:var(--inverse-ink);color:var(--inverse)} .pl-link.inv{color:var(--inverse-ink);text-decoration-color:color-mix(in srgb,var(--inverse-ink) 35%,transparent)} -/* The comparison: one surface, a real header, rows separated by air and a hover, not by rules. */ -.pl-table{border-radius:18px;background:var(--surface);box-shadow:var(--shadow-md);overflow-x:auto;padding:6px 8px} -.pl-cmp{width:100%;border:0;border-collapse:separate;border-spacing:0;background:none;box-shadow:none;margin:0} -.pl-cmp tr,.pl-cmp th,.pl-cmp td{border:0;background:none;overflow-wrap:normal} -.pl-cmp th{padding:14px 14px 12px;text-align:left;font-family:var(--mono);font-size:10.5px;font-weight:500;letter-spacing:.12em; - text-transform:uppercase;color:var(--muted2);white-space:nowrap} -.pl-cmp th.c-price,.pl-cmp th.c-works,.pl-cmp th.c-useful{text-align:right} -.pl-sort{background:none;border:0;padding:0;font:inherit;letter-spacing:inherit;text-transform:inherit;color:inherit;cursor:pointer} -.pl-sort:hover,.pl-sort.on{color:var(--ink)} -.pl-sort.on::after{content:'↓';margin-left:5px} -.pl-cmp td{padding:13px 14px;vertical-align:middle} -.pl-cmp tbody tr{cursor:pointer} -.pl-cmp tbody td:first-child{border-radius:12px 0 0 12px} -.pl-cmp tbody td:last-child{border-radius:0 12px 12px 0} -.pl-cmp tbody tr:hover td{background:var(--hover)} -.pl-cmp tbody tr.on td{background:color-mix(in srgb,var(--ink) 6%,transparent)} -.pl-cmp tbody tr:focus-visible{outline:none} -.pl-cmp tbody tr:focus-visible td{box-shadow:inset 0 1.5px 0 var(--ink),inset 0 -1.5px 0 var(--ink)} -.c-prov{min-width:250px} .c-prov-i{display:flex;align-items:center;gap:12px} .c-name{display:flex;flex-direction:column;gap:2px;min-width:0} .c-name b{font-size:14.5px;font-weight:500} .c-sub{font-size:12.5px;color:var(--muted)} .c-unv{margin-left:8px;font-size:11px;font-weight:400;color:var(--muted2)} -.c-takes{min-width:150px} -.pl-tag{display:inline-block;margin:2px 4px 2px 0;padding:2px 8px;border-radius:6px;background:var(--panel2);font-size:12px;color:var(--ink);white-space:nowrap} -.c-price,.c-works,.c-useful{text-align:right;white-space:nowrap} -.c-price{font-family:var(--mono);font-size:12.5px;color:var(--ink);font-variant-numeric:tabular-nums} +.pl-tag{display:inline-block;max-width:100%;margin:2px 4px 2px 0;padding:2px 8px;border-radius:6px;background:var(--panel2);font-size:12px;color:var(--ink)} +.c-price{font-family:var(--mono);font-size:12.5px;color:var(--ink);white-space:nowrap} .c-v{display:block;font-family:var(--mono);font-size:14px;font-weight:500;font-variant-numeric:tabular-nums} .c-v.low{color:var(--red)} .c-n{display:block;margin-top:1px;font-size:11px;color:var(--muted2)} @@ -1128,29 +1108,69 @@ a.td-prov:hover{color:var(--ink);text-decoration:underline;text-underline-offset .pl-autocard{grid-template-columns:1fr;gap:22px;padding:24px 20px} .pl-autocard h2{font-size:22px} .pl-auto-f{gap:18px} - .pl-table{padding:4px} - .pl-cmp thead{display:none} - .pl-cmp,.pl-cmp tbody{display:block} - .pl-cmp tr{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px 16px;padding:14px 12px;border-radius:12px} - .pl-cmp td{padding:0;background:none!important;border-radius:0!important} - .pl-cmp tbody tr:hover,.pl-cmp tbody tr.on{background:var(--hover)} - .c-prov,.c-takes{grid-column:1/-1;min-width:0} - .c-price{text-align:left;align-self:center} .td-b{padding:0 18px 28px} .td-h{padding-left:18px} } -/* Column help: a small card under the heading on hover or focus, not the browser's slow title. */ -.pl-tip{position:relative;cursor:help;text-decoration:underline dotted color-mix(in srgb,var(--muted2) 70%,transparent);text-underline-offset:4px} -/* ::before, because a sorted heading already uses ::after for its arrow. */ -.pl-tip::before{content:attr(data-tip);position:absolute;top:calc(100% + 10px);right:0;z-index:5;width:260px;padding:10px 12px; - border-radius:10px;background:var(--inverse);color:var(--inverse-ink);font-family:var(--sans);font-size:12px;font-weight:400; - line-height:1.5;letter-spacing:0;text-transform:none;text-align:left;white-space:normal;box-shadow:var(--shadow-lg); - opacity:0;transform:translateY(-3px);pointer-events:none;transition:opacity .15s var(--ease),transform .15s var(--ease)} -.c-takes .pl-tip::before{left:0;right:auto} -.pl-tip:hover::before,.pl-tip:focus-visible::before{opacity:1;transform:none;transition-delay:.12s} -.pl-table{overflow:visible} -@media(max-width:760px){ .pl-note{display:block} .pl-table{overflow-x:auto} } +@media(max-width:760px){ .pl-note{display:block} } .pl-hero .cat-search{margin-top:28px} .pl-hero .cat-find{margin-bottom:0} .pl-hero .cat-find-suggest{margin:10px 0 0} .pl-find{margin:0 0 48px} +/* ---- ui/table: the shadcn-shaped table primitives (components/ui/table) ---- + Every new table is built from these. The global table/th/td rules above skip .ui-table, so these + start from nothing: no borders, no filled header bar, no word-breaking. Numbers are tabular, + headings are the landing's mono eyebrow, and a cell stays on one line unless it says otherwise. */ +.ui-table-wrap{position:relative;width:100%;overflow-x:auto} +.ui-table-surface{border-radius:18px;background:var(--surface);box-shadow:var(--shadow-md);padding:6px 8px} +.ui-table{width:100%;border-collapse:separate;border-spacing:0;font-size:13.5px;color:var(--ink);font-variant-numeric:tabular-nums} +.ui-th{height:44px;padding:0 14px;text-align:left;vertical-align:middle;white-space:nowrap;font-family:var(--mono); + font-size:10.5px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--muted2)} +.ui-td{padding:13px 14px;vertical-align:middle;white-space:nowrap} +.ui-td[data-wrap]{white-space:normal} +.ui-right{text-align:right} .ui-center{text-align:center} +.ui-caption{caption-side:bottom;margin-top:12px;font-size:12px;color:var(--muted2);text-align:left} +.ui-empty{padding:28px 14px;text-align:center;color:var(--muted)} +/* lined: shadcn's hairline under each row. plain: rows separated by air, a rounded hover. */ +.ui-table-lined .ui-thead .ui-th{border-bottom:1px solid var(--line2)} +.ui-table-lined .ui-tbody .ui-tr:not(:last-child) .ui-td{border-bottom:1px solid var(--line)} +.ui-table-plain .ui-tbody .ui-td:first-child{border-radius:12px 0 0 12px} +.ui-table-plain .ui-tbody .ui-td:last-child{border-radius:0 12px 12px 0} +.ui-tr-interactive{cursor:pointer} +.ui-tr-interactive:hover .ui-td{background:var(--hover)} +.ui-tr[data-state="selected"] .ui-td{background:color-mix(in srgb,var(--ink) 6%,transparent)} +.ui-tr-interactive:focus-visible{outline:none} +.ui-tr-interactive:focus-visible .ui-td{box-shadow:inset 0 1.5px 0 var(--ink),inset 0 -1.5px 0 var(--ink)} +.ui-trunc{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +/* A sortable heading is a button; the active one carries its direction. */ +.ui-sort{background:none;border:0;padding:0;font:inherit;letter-spacing:inherit;text-transform:inherit;color:inherit;cursor:pointer;white-space:nowrap} +.ui-sort:hover,.ui-sort.on{color:var(--ink)} +.ui-sort[data-dir="asc"]::after{content:'↑';margin-left:5px} +.ui-sort[data-dir="desc"]::after{content:'↓';margin-left:5px} +/* Heading help: a card under the heading on hover or focus. ::before, because ::after is the sort arrow. */ +.ui-tip,.ui-sort[data-tip]{position:relative;cursor:help;text-decoration:underline dotted color-mix(in srgb,var(--muted2) 70%,transparent);text-underline-offset:4px} +.ui-sort[data-tip]{cursor:pointer} +.ui-tip::before,.ui-sort[data-tip]::before{content:attr(data-tip);position:absolute;top:calc(100% + 10px);left:0;z-index:5;width:260px;padding:10px 12px; + border-radius:10px;background:var(--inverse);color:var(--inverse-ink);font-family:var(--sans);font-size:12px;font-weight:400; + line-height:1.5;letter-spacing:0;text-transform:none;text-align:left;white-space:normal;box-shadow:var(--shadow-lg); + opacity:0;transform:translateY(-3px);pointer-events:none;transition:opacity .15s var(--ease),transform .15s var(--ease)} +.ui-right .ui-tip::before,.ui-right .ui-sort[data-tip]::before{left:auto;right:0} +.ui-tip:hover::before,.ui-tip:focus-visible::before,.ui-sort[data-tip]:hover::before,.ui-sort[data-tip]:focus-visible::before{opacity:1;transform:none;transition-delay:.12s} +.ui-data-table{overflow:visible} +.ui-table-wrap:has(.ui-tip,.ui-sort[data-tip]){overflow:visible} +/* Phone width: a row is a card. `primary` spans it; a `field` names itself; `hide` drops out. */ +@media(max-width:760px){ + .ui-table-wrap{overflow:visible} + .ui-data-table,.ui-data-table .ui-tbody{display:block} + .ui-data-table .ui-thead,.ui-data-table colgroup{display:none} + .ui-data-table .ui-tbody .ui-tr{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px;padding:14px 12px;border-radius:12px} + .ui-table-lined.ui-data-table .ui-tbody .ui-tr:not(:last-child){border-bottom:1px solid var(--line)} + .ui-data-table .ui-td{display:block;padding:0;border:0!important;border-radius:0!important;background:none!important;text-align:left;width:auto!important;max-width:none!important} + .ui-data-table .ui-tr-interactive:hover{background:var(--hover)} + .ui-data-table .ui-tr-interactive:focus-visible{outline:1.5px solid var(--ink);outline-offset:-1.5px;box-shadow:none} + .ui-data-table .ui-td{box-shadow:none!important} + .ui-data-table .ui-td[data-mobile="primary"],.ui-data-table .ui-td[data-mobile="wide"]{grid-column:1/-1} + .ui-data-table .ui-td[data-mobile="hide"]{display:none} + .ui-data-table .ui-td[data-mobile="field"]::before,.ui-data-table .ui-td[data-mobile="wide"]::before{content:attr(data-label);display:block;margin-bottom:2px;font-family:var(--mono); + font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted2)} + .ui-data-table .ui-trunc{max-width:none!important} +}