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.
This commit is contained in:
SToneX
2026-09-28 22:01:15 +08:00
parent 2ef37b7636
commit 9346bad870
25 changed files with 499 additions and 175 deletions
+3 -1
View File
@@ -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` |
+14
View File
@@ -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/<service>`) lists every tool it serves by platform
(`GET /catalog/providers/<service>`), 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").
+14
View File
@@ -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-<key>` 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.
+3 -3
View File
@@ -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)
+65
View File
@@ -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<string[]> {
return page.evaluate(() => {
const W = innerWidth, out: string[] = []
for (const el of document.querySelectorAll<HTMLElement>('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<string[]> {
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)
}
+77
View File
@@ -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(`<div class="row" style="display:grid;grid-template-columns:120px 60px;width:180px;font:12px monospace">
<span style="white-space:nowrap">$0.025/started 10 emails</span><span>fit</span></div>
<div class="row" style="width:100px"><span style="white-space:nowrap">a line far wider than its row</span></div>
<div class="row" style="width:100px"><span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">clipped, so it only counts as far as it shows</span></div>`)
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)
}
})
})
}
+3 -31
View File
@@ -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<HTMLElement>('*')].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([])
})
+47 -56
View File
@@ -1,25 +1,29 @@
<script>
import { useDashboard } from '../state/context'
import { DataTable } from './ui/table'
const SHOWN = 8 // rows before "Show more"
// The Catalog page's answer to a described job (state/find.js): one quiet list, one row per job.
// Strong fits first at full weight, weaker ones after them in a lighter tone. No labels for the
// buckets: the order and the fit bar already say it. Clearing the search box is how you leave.
// The answer to a described job (state/find.js): one row per job, best fit first, weaker fits in a
// lighter tone. A table (components/ui/table), so every row's columns line up whatever a price says.
// Clearing the search box is how you leave.
export default {
components: { DataTable },
setup: useDashboard,
data(){ return { all:false } },
computed: {
shown(){ return this.all ? this.findGroups : this.findGroups.slice(0, SHOWN); },
nothing(){ return this.find.verdict==='none' || (this.find.verdict==='keyword' && !this.findGroups.length); },
columns(){ return [
{key:'what', header:'Tool', mobile:'primary', wrap:true},
{key:'provs', header:'Providers', mobile:'hide'},
{key:'price', header:'Price', align:'right', width:'180px', truncate:true, value:g=>this.findPrice(g)},
{key:'fit', header:'Fit', align:'right', width:'76px', mobile:'hide',
tip:'How well the relevance judge thinks this does what you described.'},
{key:'copy', header:'', align:'right', width:'132px', mobile:'hide'}]; },
},
watch: { 'find.q'(){ this.all=false; } },
}
</script>
<template>
<section class="fa" aria-live="polite" :aria-busy="findBusy">
<div v-if="findBusy" class="fa-list" aria-label="Finding tools">
<div v-if="findBusy" aria-label="Finding tools">
<div v-for="i in 3" :key="i" class="fa-skel"><span></span><span></span><span></span></div>
</div>
@@ -39,30 +43,26 @@ export default {
<p v-if="find.verdict==='closest'" class="fa-sub">Nothing fits closely. These come nearest.
<button class="fa-link" type="button" @click="findRequestTool()">Request a better tool</button></p>
<ul class="fa-list">
<li v-for="(g, i) in shown" :key="g.key" class="fa-row" :class="{weak:findWeak(g)}">
<button class="fa-main" type="button" @click="findOpen(g, i+1)">
<span class="fa-logo" :class="{gen:platLogoBad[g.platform]}"
:style="platLogoBad[g.platform] ? {background:platTileBg(g.platform)} : null">
<img v-if="!platLogoBad[g.platform]" :src="'/logos/platforms/'+g.platform+'.svg'" alt="" @error="platLogoBad[g.platform]=true">
<span v-else>{{platInitial({label:g.platform_label, slug:g.platform})}}</span>
</span>
<span class="fa-what"><b>{{g.label}}</b><small v-if="!find.scope">{{platShort(g.platform_label)}}</small></span>
<span class="fa-provs" :title="g.rows.map(r=>r.provider_display||r.provider).join(', ')">
<span class="fa-stack"><img v-for="p in findProviders(g).slice(0,3)" :key="p" :src="'/logos/'+p+'.svg'" alt=""
@error="$event.target.style.visibility='hidden'"></span>
{{findProviders(g).length}} provider{{findProviders(g).length===1?'':'s'}}
</span>
<span class="fa-price" :title="findPrice(g)">{{findPrice(g)}}</span>
<span v-if="g.p!=null" class="fa-fit" :title="'Fit for this job: '+Math.round(g.p*100)+'%'">
<i :style="{width:Math.round(g.p*100)+'%'}"></i></span>
</button>
<button class="fa-copy" type="button" @click="findCopy([g], g.key)">
{{findCopied===g.key ? 'Copied' : 'Copy for agent'}}</button>
</li>
</ul>
<button v-if="findGroups.length>shown.length" class="fa-link fa-more" type="button" @click="all=true">
Show {{findGroups.length-shown.length}} more</button>
<DataTable :columns="columns" :rows="findGroups" :row-key="g => g.key" interactive
:row-class="g => ({ weak: findWeak(g) })" @row-click="g => findOpen(g, findGroups.indexOf(g)+1)">
<template #cell-what="{ row: g }"><span class="fa-main">
<span class="fa-logo" :class="{gen:platLogoBad[g.platform]}"
:style="platLogoBad[g.platform] ? {background:platTileBg(g.platform)} : null">
<img v-if="!platLogoBad[g.platform]" :src="'/logos/platforms/'+g.platform+'.svg'" alt="" @error="platLogoBad[g.platform]=true">
<span v-else>{{platInitial({label:g.platform_label, slug:g.platform})}}</span>
</span>
<span class="fa-what"><b>{{g.label}}</b><small v-if="!find.scope">{{platShort(g.platform_label)}}</small></span>
</span></template>
<template #cell-provs="{ row: g }"><span class="fa-provs" :title="g.rows.map(r=>r.provider_display||r.provider).join(', ')">
<span class="fa-stack"><img v-for="p in findProviders(g).slice(0,3)" :key="p" :src="'/logos/'+p+'.svg'" alt=""
@error="$event.target.style.visibility='hidden'"></span>
{{findProviders(g).length}} provider{{findProviders(g).length===1?'':'s'}}</span></template>
<template #cell-price="{ value }"><span class="fa-price">{{value}}</span></template>
<template #cell-fit="{ row: g }"><span v-if="g.p!=null" class="fa-fit" :title="'Fit for this job: '+Math.round(g.p*100)+'%'">
<i :style="{width:Math.round(g.p*100)+'%'}"></i></span></template>
<template #cell-copy="{ row: g }"><button class="fa-copy" type="button" @click.stop="findCopy([g], g.key)" @keydown.enter.stop>
{{findCopied===g.key ? 'Copied' : 'Copy for agent'}}</button></template>
</DataTable>
</template>
</section>
</template>
@@ -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}}
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts" generic="Row">
// A table described by its columns, on the shadcn primitives: shadcn's "Data Table" pattern without
// TanStack. The page owns the rows and the sort (v-model:sort), exactly as a TanStack table's state
// lives in the page; this component owns the layout rules every table here kept getting wrong:
// - widths are per column, so every row lines up (a row per grid could not);
// - a truncated cell ends in an ellipsis and carries its full text, never paints over its neighbour;
// - a heading can explain itself (`tip`), and a sortable one says which way it sorts;
// - below 760px a row becomes a card (`mobile` per column) instead of a table to scroll sideways.
// Cells render `cell-<key>` slots when given, else the column's `value`.
import { computed } from 'vue'
import Table from './Table.vue'
import TableHeader from './TableHeader.vue'
import TableBody from './TableBody.vue'
import TableRow from './TableRow.vue'
import TableHead from './TableHead.vue'
import TableCell from './TableCell.vue'
import TableEmpty from './TableEmpty.vue'
import type { Column, Sort } from './types'
const props = withDefaults(defineProps<{
columns: Column<Row>[]
rows: Row[]
rowKey: (row: Row) => string
sort?: Sort | null
selected?: string | null
/** Rows answer a click (and Enter): the page decides what that opens. */
interactive?: boolean
rowClass?: (row: Row) => string | Record<string, boolean> | undefined
variant?: 'lined' | 'plain'
surface?: boolean
empty?: string
}>(), { sort: null, selected: null, interactive: false, variant: 'lined', surface: false, empty: 'Nothing to show.' })
const emit = defineEmits<{ 'update:sort': [Sort], 'row-click': [Row] }>()
function sortBy(col: Column<Row>) {
const first = col.sortFirst || 'asc'
const dir = props.sort && props.sort.key === col.key ? (props.sort.dir === 'asc' ? 'desc' : 'asc') : first
emit('update:sort', { key: col.key, dir })
}
function ariaSort(col: Column<Row>) {
if (!col.sortable) return undefined
if (!props.sort || props.sort.key !== col.key) return 'none'
return props.sort.dir === 'asc' ? 'ascending' : 'descending'
}
const widths = computed(() => props.columns.map(c => ({
...(c.width ? { width: c.width, maxWidth: c.width } : {}),
...(c.minWidth ? { minWidth: c.minWidth } : {}),
})))
function text(col: Column<Row>, row: Row) {
const v = col.value ? col.value(row) : (row as Record<string, unknown>)[col.key]
return v == null ? '' : String(v)
}
</script>
<template>
<Table :variant="variant" :surface="surface" class="ui-data-table">
<colgroup><col v-for="(c, i) in columns" :key="c.key" :style="widths[i]"></colgroup>
<TableHeader>
<TableRow>
<TableHead v-for="c in columns" :key="c.key" :align="c.align" :aria-sort="ariaSort(c)">
<slot :name="'head-' + c.key" :column="c">
<button v-if="c.sortable" type="button" class="ui-sort" :class="{ on: sort && sort.key === c.key }"
:data-tip="c.tip" :data-dir="sort && sort.key === c.key ? sort.dir : undefined" @click="sortBy(c)">{{ c.header }}</button>
<span v-else :class="{ 'ui-tip': c.tip }" :data-tip="c.tip" :tabindex="c.tip ? 0 : undefined">{{ c.header }}</span>
</slot>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="row in rows" :key="rowKey(row)" :interactive="interactive" :selected="selected === rowKey(row)"
:class="rowClass && rowClass(row)" :tabindex="interactive ? 0 : undefined"
@click="interactive && emit('row-click', row)" @keydown.enter="interactive && emit('row-click', row)">
<TableCell v-for="(c, i) in columns" :key="c.key" :align="c.align" :style="widths[i]"
:data-label="c.header" :data-mobile="c.mobile || 'field'" :data-wrap="c.wrap ? '' : undefined">
<span v-if="c.truncate" class="ui-trunc" :style="c.width ? { maxWidth: c.width } : undefined" :title="text(c, row)"><slot :name="'cell-' + c.key" :row="row" :value="text(c, row)">{{ text(c, row) }}</slot></span>
<slot v-else :name="'cell-' + c.key" :row="row" :value="text(c, row)">{{ text(c, row) }}</slot>
</TableCell>
</TableRow>
<TableEmpty v-if="!rows.length" :colspan="columns.length"><slot name="empty">{{ empty }}</slot></TableEmpty>
</TableBody>
</Table>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
// The table primitives follow shadcn/ui's Table one to one (Table, TableHeader, TableBody,
// TableFooter, TableRow, TableHead, TableCell, TableCaption): native elements, one class each, the
// look in styles/base.css under "ui/table". The global `table`/`th`/`td` rules skip `.ui-table`, so
// a table built from these starts clean instead of undoing the sheet's defaults.
//
// `variant`: `lined` (hairline between rows, shadcn's default) or `plain` (rows separated by air,
// hover and selection only; the landing look). `surface` lifts it onto a card.
defineOptions({ inheritAttrs: false })
withDefaults(defineProps<{ variant?: 'lined' | 'plain', surface?: boolean }>(), { variant: 'lined', surface: false })
</script>
<template>
<div class="ui-table-wrap" :class="{ 'ui-table-surface': surface }">
<table v-bind="$attrs" class="ui-table" :class="'ui-table-' + variant"><slot /></table>
</div>
</template>
@@ -0,0 +1 @@
<template><tbody class="ui-tbody"><slot /></tbody></template>
@@ -0,0 +1 @@
<template><caption class="ui-caption"><slot /></caption></template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
defineProps<{ align?: 'left' | 'right' | 'center' }>()
</script>
<template>
<td class="ui-td" :class="align && align !== 'left' ? 'ui-' + align : undefined"><slot /></td>
</template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
// shadcn's TableEmpty: one row spanning the table, for "nothing to show".
defineProps<{ colspan: number }>()
</script>
<template>
<tr class="ui-tr ui-tr-empty"><td class="ui-td ui-empty" :colspan="colspan"><slot /></td></tr>
</template>
@@ -0,0 +1 @@
<template><tfoot class="ui-tfoot"><slot /></tfoot></template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
defineProps<{ align?: 'left' | 'right' | 'center' }>()
</script>
<template>
<th class="ui-th" :class="align && align !== 'left' ? 'ui-' + align : undefined"><slot /></th>
</template>
@@ -0,0 +1 @@
<template><thead class="ui-thead"><slot /></thead></template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
// `selected` is shadcn's data-state="selected"; `interactive` gives the row a pointer and a hover.
defineProps<{ selected?: boolean, interactive?: boolean }>()
</script>
<template>
<tr class="ui-tr" :class="{ 'ui-tr-interactive': interactive }" :data-state="selected ? 'selected' : undefined"><slot /></tr>
</template>
+11
View File
@@ -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'
+26
View File
@@ -0,0 +1,26 @@
// A DataTable column: what it shows, how wide it may get, how it behaves on a phone.
export interface Column<Row = any> {
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-<key>` 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' }
+14 -26
View File
@@ -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,
}
</script>
@@ -112,31 +113,18 @@ export default {
<section class="pl-sec">
<h2 class="pl-h"><span>{{platJobMeta.routed ? 'Or choose a provider' : 'Choose a provider'}}</span><i></i><em>{{platJobChoices.length}}</em></h2>
<div class="pl-table">
<table class="pl-cmp">
<thead><tr>
<th class="c-prov">Provider</th>
<th class="c-takes"><span class="pl-tip" tabindex="0" :data-tip="colTips.takes">Takes</span></th>
<th v-for="c in [['price','Price'],['works','Works'],['useful','Useful']]" :key="c[0]" :class="'c-'+c[0]"
:aria-sort="platJobSort===c[0] ? (c[0]==='price' ? 'ascending' : 'descending') : 'none'">
<button class="pl-sort pl-tip" :class="{on:platJobSort===c[0]}" :data-tip="colTips[c[0]]" @click="platSortBy(c[0])">{{c[1]}}</button></th>
</tr></thead>
<tbody>
<tr v-for="r in platJobChoices" :key="r.id" :class="{on:drawerTool===r.id}" tabindex="0"
@click="openTool(r.id)" @keydown.enter="openTool(r.id)">
<td class="c-prov"><div class="c-prov-i">
<span class="pl-logo lg"><img :src="'/logos/'+r.e.provider+'.svg'" alt="" aria-hidden="true" @error="$event.target.style.visibility='hidden'"></span>
<span class="c-name"><b>{{r.e.provider_display||r.e.provider}}<span v-if="!r.e.verified" class="c-unv" title="Documented, but treg has not called it with a live key yet">unverified</span></b>
<span v-if="r.twin" class="c-sub">{{clip(r.e.name||r.e.summary, 52)}}</span></span>
</div></td>
<td class="c-takes"><span v-for="t in r.takes" :key="t" class="pl-tag">{{t}}</span><span v-if="!r.takes.length" class="c-none">—</span></td>
<td class="c-price">{{r.e.platform_eligible===false ? 'your key only' : costShort(r.e.cost)}}</td>
<td class="c-works" :title="worksTitle(r)"><template v-if="r.works"><span class="c-v">{{r.works.pct}}%</span><span class="c-n">{{approxCalls(r.works.n)}}</span></template><span v-else class="c-none">—</span></td>
<td class="c-useful" :title="usefulTitle(r)"><template v-if="r.useful"><span class="c-v" :class="{low:r.useful.pct<50}">{{r.useful.pct}}%</span><span class="c-n">{{approxTeams(r.useful.n)}}</span></template><span v-else class="c-none">—</span></td>
</tr>
</tbody>
</table>
</div>
<DataTable :columns="jobColumns" :rows="platJobChoices" :row-key="r => r.id" v-model:sort="platJobSort"
:selected="drawerTool" interactive variant="plain" surface @row-click="r => openTool(r.id)">
<template #cell-provider="{ row: r }"><div class="c-prov-i">
<span class="pl-logo lg"><img :src="'/logos/'+r.e.provider+'.svg'" alt="" aria-hidden="true" @error="$event.target.style.visibility='hidden'"></span>
<span class="c-name"><b>{{r.e.provider_display||r.e.provider}}<span v-if="!r.e.verified" class="c-unv" title="Documented, but treg has not called it with a live key yet">unverified</span></b>
<span v-if="r.twin" class="c-sub">{{clip(r.e.name||r.e.summary, 52)}}</span></span>
</div></template>
<template #cell-takes="{ row: r }"><span v-for="t in r.takes" :key="t" class="pl-tag">{{t}}</span><span v-if="!r.takes.length" class="c-none">—</span></template>
<template #cell-price="{ row: r }"><span class="c-price">{{r.e.platform_eligible===false ? 'your key only' : costShort(r.e.cost)}}</span></template>
<template #cell-works="{ row: r }"><span :title="worksTitle(r)"><template v-if="r.works"><span class="c-v">{{r.works.pct}}%</span><span class="c-n">{{approxCalls(r.works.n)}}</span></template><span v-else class="c-none">—</span></span></template>
<template #cell-useful="{ row: r }"><span :title="usefulTitle(r)"><template v-if="r.useful"><span class="c-v" :class="{low:r.useful.pct<50}">{{r.useful.pct}}%</span><span class="c-n">{{approxTeams(r.useful.n)}}</span></template><span v-else class="c-none">—</span></span></template>
</DataTable>
<p class="pl-note"><b>Works</b> share of the last 30 days' calls that did not end in a provider error, past 20 calls.
<b>Useful</b> what teams' agents said after using the result, one vote per team, past 5 teams; compare it within this table only.</p>
</section>
+1 -2
View File
@@ -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/<slug> 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;
+10 -2
View File
@@ -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);
+1 -1
View File
@@ -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/<id>
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.',
+73 -53
View File
@@ -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}
}