mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
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:
@@ -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` |
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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' }
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user