feat(dashboard): a platform shelf leads with its most used, as cards

A shelf was one grid of equal cards, comparisons and single tools mixed. Long
job descriptions stretched whole rows, the Auto-route pill repeated on most
cards, and nothing told the eye where to land.

- Everything on a shelf is ranked by 30-day observed calls, whether one
  provider serves it or several. The six most used are cards of one height:
  a two-line title, who serves it with the price on the right, and the
  provider count with auto-route as quiet text under it.
- The rest is one list (tool, provider, price) on DataTable: a compared job
  shows its logo stack and opens its comparison, a single tool opens the
  drawer. Account and setup shows its first six until asked for the rest.
- With no counts yet (a fresh server) the cards read Featured, not Most used.
- A compared job's card shows its short title (capability_titles) and the
  full description on hover.
- The old shelf-only rules (the Auto-route pill and the card footer) are gone.
This commit is contained in:
SToneX
2026-09-30 16:13:18 +08:00
parent 2f1dd86ed3
commit 7fb3d51f10
7 changed files with 88 additions and 38 deletions
+5 -2
View File
@@ -85,8 +85,11 @@ this page keeps what no single file shows. The look follows the root `design.md`
## Platform shelf, comparison, drawer
- A shelf lists comparisons and single tools most used first (30-day observed calls). A comparison
leads with Auto-route when a routed tool exists; its counts are shown in bands, never exact.
- A shelf ranks everything by 30-day observed calls, whether one provider serves it or several: its
six most used are cards of one height, the rest one list, and account plumbing shows its first six.
With no counts yet the cards read Featured, not Most used. A compared job's card carries its short
title (`capability_titles`, [catalog](../architecture/catalog.md)), the full description on hover.
- A comparison leads with Auto-route when a routed tool exists; its counts are shown in bands, never exact.
- The tool drawer is not modal; its primary button is the agent's next step (`drawerNext`). Finding
tools for a job is [search-experiment](../architecture/search-experiment.md).
- The `catalog-v2` experiment shows the old ledger (`LegacyPlatformPage.vue`) to its control arm; it
+1 -1
View File
@@ -131,7 +131,7 @@ test("a tool drawer's primary button is the next step to an agent using the tool
await page.getByRole('table').getByRole('row').filter({ hasText: 'Ocean' }).first().click()
await expect(primary()).toHaveText(/^Add your .+ key$/) // only your own key can call it
await page.goto('/catalog/google-analytics')
await page.locator('.pl-tool').first().click()
await page.locator('button.pl-job').first().click()
await expect(primary()).toHaveText(/^Connect /) // an account to connect first
await expect(drawer.locator('.td-stats')).toContainText('your account')
})
+44 -17
View File
@@ -6,12 +6,24 @@ import CatalogSearch from '../components/CatalogSearch.vue'
import ProviderLogo from '../components/ProviderLogo.vue'
import { DataTable } from '../components/ui/table'
const LIST = [
{ key: 'tool', header: 'Tool', wrap: true, mobile: 'primary' },
{ key: 'provider', header: 'Provider' },
{ key: 'price', header: 'Price', align: 'right' },
]
// A platform shelf, read at two levels: the shelf (the capabilities several providers serve, then
// every other tool) and one comparison (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, DataTable, ProviderLogo },
setup: useDashboard,
data: () => ({ listColumns: LIST }),
methods: {
shelfKey(t) { return t.job ? 'job:'+t.key : t.id },
// A job several providers do opens its comparison; a one-provider tool opens in the drawer.
openShelfItem(t) { t.job ? this.openComparison(t.slug) : this.openTool(t.id) },
},
}
</script>
@@ -32,32 +44,47 @@ export default {
<FindAnswer v-if="findActive && find.scope===platSlug" class="pl-find" />
<!-- The shelf: comparisons and single-provider tools in one list, most used first; then the
account and setup plumbing, the same cards in a quieter weight. -->
<section v-for="sec in [{key:'tools', label:'Tools', items:platShelf},
{key:'setup', label:'Account and setup', items:platPlumbing, quiet:true}].filter(s=>s.items.length)"
:key="sec.key" class="pl-sec">
<h2 class="pl-h" :class="{'pl-h-quiet':sec.quiet}"><span>{{sec.label}}</span><i v-if="!sec.quiet"></i><em>{{sec.items.length}}</em></h2>
<!-- The shelf, by use: the most used items as cards of one height (a short title, who serves it,
the price), then the rest, and the account plumbing, as lists. A job several providers do
opens its comparison; a one-provider tool opens in the drawer. -->
<section v-if="platTop.length" class="pl-sec">
<h2 class="pl-h"><span>{{platTopMeasured ? 'Most used' : 'Featured'}}</span><i></i></h2>
<div class="pl-grid pl-grid-t">
<template v-for="t in sec.items" :key="t.job ? 'job:'+t.key : t.id">
<a v-if="t.job" class="pl-card pl-cmp" :href="platUrl(platSlug, t.slug)" @click.prevent="openComparison(t.slug)">
<template v-for="t in platTop" :key="t.job ? 'job:'+t.key : t.id">
<a v-if="t.job" class="pl-card pl-cmp pl-job" :href="platUrl(platSlug, t.slug)" :title="t.hint" @click.prevent="openComparison(t.slug)">
<b>{{t.title}}</b>
<span class="pl-cmp-f">
<span class="pl-cmp-r">
<span class="pl-stack" aria-hidden="true"><ProviderLogo v-for="s in t.logos" :key="s" :service="s" /></span>
<span v-if="t.routed" class="pl-auto" title="One call: treg picks the provider for you">Auto-route</span>
</span>
<span class="pl-meta">{{t.meta}}</span>
<span class="pl-job-f">
<span class="pl-stack" aria-hidden="true"><ProviderLogo v-for="s in t.logos" :key="s" :service="s" /></span>
<span v-if="t.range" class="pl-job-p">{{t.range}}</span>
</span>
<span class="pl-meta">{{t.provN}} providers<template v-if="t.routed"> · <span class="pl-job-auto" title="One call: treg picks the provider for you">auto-route</span></template></span>
</a>
<button v-else class="pl-card pl-tool" :class="{on:drawerTool===t.id, quiet:sec.quiet}" @click="openTool(t.id)">
<ProviderLogo :service="t.e.provider" large />
<span class="pl-tool-b"><b>{{t.title}}</b><span class="pl-meta">{{t.e.provider_display||t.e.provider}} · {{toolPrice(t.e)}}</span></span>
<button v-else class="pl-card pl-job" :class="{on:drawerTool===t.id}" :title="t.e.summary||t.title" @click="openTool(t.id)">
<b>{{t.title}}</b>
<span class="pl-job-f">
<span class="pl-stack" aria-hidden="true"><ProviderLogo :service="t.e.provider" /></span>
<span class="pl-job-p">{{toolPrice(t.e)}}</span>
</span>
<span class="pl-meta">{{t.e.provider_display||t.e.provider}}</span>
</button>
</template>
</div>
</section>
<section v-for="sec in platSections" :key="sec.key" class="pl-sec">
<h2 class="pl-h" :class="{'pl-h-quiet':sec.quiet}"><span>{{sec.label}}</span><i></i><em>{{sec.items.length}}</em></h2>
<DataTable :class="{'pl-list-quiet':sec.quiet}" :columns="listColumns" :rows="sec.shown" :row-key="shelfKey" :selected="drawerTool"
interactive surface @row-click="openShelfItem">
<template #cell-tool="{ row: t }"><b :title="t.job ? t.hint : (t.e.summary||t.title)">{{t.title}}</b></template>
<template #cell-provider="{ row: t }">
<span v-if="t.job" class="pl-by"><span class="pl-stack" aria-hidden="true"><ProviderLogo v-for="s in t.logosFew" :key="s" :service="s" /></span>{{t.provN}} providers<template v-if="t.routed"> · <span class="pl-job-auto">auto-route</span></template></span>
<span v-else class="pl-by"><ProviderLogo :service="t.e.provider" />{{t.e.provider_display||t.e.provider}}</span>
</template>
<template #cell-price="{ row: t }">{{t.job ? t.range : toolPrice(t.e)}}</template>
</DataTable>
<button v-if="sec.hidden" class="pl-link pl-more-rows" @click="platSetupOpen=true">Show all {{sec.hidden}}</button>
</section>
<p v-if="platFilterQ && !platShelf.length && !platPlumbing.length && !findActive && !findSoon" class="pl-empty">
Nothing in {{platLabel}} matches “{{platQ.trim()}}”. <button class="pl-link" @click="platQ=''">Clear the search</button></p>
+1
View File
@@ -70,6 +70,7 @@ openPlatform(slug, fromPop, cap){ this.resetConfirms();
// Moving between a shelf and one of its comparisons keeps the loaded shelf: the page is the same
// payload read another way, so only the address and the view state change.
const same = this.view==='platform' && this.platSlug===slug && (this.platData || this.platLoading);
if(this.platSlug!==slug) this.platSetupOpen=false;
this.detail=null; this.platSlug=slug; this.view='platform'; this.platCap=cap||null; this.drawerTool=null;
this.epTab={}; this.platCopied='';
if(!same){ this.platEx={}; this.platQ=''; this.epInfo={};
+23 -8
View File
@@ -3,6 +3,8 @@ import { isJobQuery } from './find.js'
// The shelf's order: the last 30 days' calls, most first; then the jobs more providers do; then by
// title, so an unused shelf still reads in a stable order.
const byTitle = new Intl.Collator()
// How many of a shelf's most used items lead as cards: two rows at the widest layout.
const PLAT_TOP = 6
const byUse = (a, b) => (b.usage - a.usage) || (b.provN - a.provN) || byTitle.compare(a.title, b.title)
// What each comparison column means, on hover or focus of its heading (comparisonColumns).
@@ -132,9 +134,10 @@ platProviders(){ // providers with endpoints here, in catalog order
for(const sec of (this.platData.domains||[])) for(const r of (sec.rows||[])){
const eps=r.endpoints||[]; if(!eps.length) continue;
out.push({...r, domain:sec.domain, endpoints:eps,
// The server picks `name` over `summary` where a curated name exists; the clip guards the
// rows where one does not yet, whose summary is documentation prose.
title:this.clip(r.description, 90),
// A compared job may carry a short `title` for people; its `description` is also written for
// agents and stays whole (the card's hover). The server picks `name` over `summary` where a
// curated name exists; the clip guards the rows where one does not yet.
title:this.clip(r.title||r.description, 90),
mgmt: eps.every(e=>e.kind==='account'||e.kind==='utility'),
hay: (r.description+' '+r.domain+' '+(r.capability||'')+' '+eps.map(e=>
e.provider+' '+(e.provider_display||'')+' '+(e.name||'')+' '+e.path+' '+e.summary).join(' ')).toLowerCase()});
@@ -149,7 +152,7 @@ platProviders(){ // providers with endpoints here, in catalog order
return this.platRowsAll.filter(r=>r.compare).map(r=>{
const direct=r.endpoints.filter(e=>e.kind!=='routed'), provs=[...new Set(direct.map(e=>e.provider))];
const range=this.priceRange(direct);
return {job:true, key:r.capability, slug:r.compare, row:r, title:r.description, hay:r.hay, logos:provs.slice(0,5),
return {job:true, key:r.capability, slug:r.compare, row:r, title:r.title, hint:r.description, hay:r.hay, logos:provs.slice(0,5), logosFew:provs.slice(0,3), range,
routed:r.endpoints.find(e=>e.kind==='routed')||null, provN:provs.length,
// An auto-routed call is also recorded on every provider it tries, so the providers'
// own calls already count it; adding the routed row too would count it twice.
@@ -171,12 +174,24 @@ platProviders(){ // providers with endpoints here, in catalog order
out.push({id:e.id, title, e, mgmt:r.mgmt, usage:this.callsOf(e), provN:1,
hay:(title+' '+e.provider+' '+(e.provider_display||'')+' '+(e.summary||'')+' '+e.id).toLowerCase()}); } }
return out.sort(byUse); },
// The shelf itself: comparisons and single-provider tools in ONE list, most used first. A visitor
// comes for a job, not for whether one provider or several serve it; a comparison still reads as
// one (its logo stack, and Auto-route where treg can pick).
// Sorted once per shelf; the search box only filters it.
// The shelf itself, most used first, whether one provider serves an item or several: the head of it
// as cards, the rest as a list. One grid of equal cards gave the eye nowhere to land; ranking a
// comparison above a busier single tool put the eye on the wrong thing. Sorted once per shelf;
// the search box only filters it.
platShelfIndex(){ return [...this.platComparisons, ...this.platToolIndex.filter(t=>!t.mgmt)].sort(byUse); },
platShelf(){ const q=this.platFilterQ; return q ? this.platShelfIndex.filter(t=>t.hay.includes(q)) : this.platShelfIndex; },
platTop(){ return this.platShelf.slice(0, PLAT_TOP); },
platRest(){ return this.platShelf.slice(PLAT_TOP); },
// Only a measured count earns "Most used"; with none (a fresh server), the order is the tie-break's.
platTopMeasured(){ return this.platTop.some(t=>t.usage>0); },
// The lists under the cards. Plumbing is looked up, not browsed: its head until asked for the rest,
// or all of it while a search is narrowing it.
platSections(){
const setup=this.platPlumbing, fold=!this.platSetupOpen && !this.platFilterQ && setup.length>PLAT_TOP;
return [
{key:'tools', label:this.platTop.length ? 'More tools' : 'Tools', items:this.platRest, shown:this.platRest, hidden:0},
{key:'setup', label:'Account and setup', quiet:true, items:setup, shown:fold ? setup.slice(0, PLAT_TOP) : setup, hidden:fold ? setup.length : 0},
].filter(s=>s.items.length); },
platPlumbing(){ const q=this.platFilterQ; return this.platToolIndex.filter(t=>t.mgmt && (!q || t.hay.includes(q))); },
platEpById(){ const m={}; for(const r of this.platRowsAll) for(const e of r.endpoints) m[e.id]=e; return m; },
// The drawer reads from the list of the view it was opened on.
+1
View File
@@ -80,6 +80,7 @@ export default function data(){
// Find tools for a job (state/find.js): phase idle | recall | reading | done | error
find:{...FIND_EMPTY}, findCopied:'', findSoon:false,
platSlug:null, platData:null, platErr:'', platLoading:false,
platSetupOpen:false, // a platform shelf's account-and-setup list has been expanded
platShelfOpen:{}, // category → its featured shelf has been expanded to the full tile list
platQ:'', // the shelf's search box
epTab:{}, // endpoint id → which pane of its detail is showing ('req' | 'res' | 'rev')
+13 -10
View File
@@ -893,19 +893,12 @@ main.flush,.redesign main.flush{padding:0;max-width:none}
.pl-cmp b{font-size:15.5px;font-weight:500;line-height:1.4}
/* A comparison's foot is two fixed lines, logos (and Auto-route) over the provider count and price,
so no card wraps them differently from its neighbour. */
.pl-cmp-f{display:flex;flex-direction:column;gap:8px;margin-top:auto}
.pl-cmp-r{display:flex;align-items:center;justify-content:space-between;gap:10px}
.pl-cmp.sm{gap:6px;padding:15px 18px}
.pl-cmp.sm b{font-size:14px}
.pl-meta{font-family:var(--mono);font-size:11.5px;color:var(--muted2);font-variant-numeric:tabular-nums}
/* Auto-route's look, the tag and the comparison page's card alike: a green light and a dot field. */
.pl-auto,.pl-autocard{--pl-glow:#5b8a5a38;--pl-dot:#ffffff1c}
[data-theme="light"] .pl-auto,[data-theme="light"] .pl-autocard{--pl-glow:#dce8d5;--pl-dot:#1a1a1a1a}
.pl-auto{--pl-auto-ink:#b9d6b4;flex:0 0 auto;padding:3px 9px;
border-radius:999px;background:radial-gradient(var(--pl-dot) .6px,transparent .9px) 0 0/4px 4px,var(--pl-glow);
box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pl-auto-ink) 22%,transparent);color:var(--pl-auto-ink);
font-size:11px;font-weight:500;letter-spacing:.01em}
[data-theme="light"] .pl-auto{--pl-auto-ink:#2f5a2e}
/* Auto-route's look on the comparison page's card: a green light and a dot field. */
.pl-autocard{--pl-glow:#5b8a5a38;--pl-dot:#ffffff1c}
[data-theme="light"] .pl-autocard{--pl-glow:#dce8d5;--pl-dot:#1a1a1a1a}
.pl-logo{display:inline-grid;place-items:center;width:24px;height:24px;flex:0 0 24px;border-radius:7px;background:#fbfaf7;
box-shadow:0 0 0 1px rgba(0,0,0,.06);overflow:hidden}
.pl-logo img{width:15px;height:15px;object-fit:contain}
@@ -1035,6 +1028,7 @@ main.flush,.redesign main.flush{padding:0;max-width:none}
.pl-meta.pl-push{font-family:var(--sans);font-size:13px}
.pl-sub{display:block;margin-top:2px;font-size:11.5px;color:var(--muted2)}
.pl-by{display:inline-flex;flex-wrap:wrap;align-items:center;gap:4px 8px;max-width:100%;color:var(--muted)}
.pl-more-rows{margin-top:14px;font-size:13px}
.ui-td .cn-links{flex-wrap:nowrap;justify-content:flex-end;font-family:var(--sans);white-space:nowrap}
.own-card .cn-id a{color:inherit;text-decoration:none}
.own-card .cn-id a:hover{text-decoration:underline;text-underline-offset:3px}
@@ -1085,6 +1079,15 @@ main.flush,.redesign main.flush{padding:0;max-width:none}
.act-stat span{font-size:12.5px;color:var(--muted)}
.act-pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(360px,100%),1fr));gap:0 24px}
/* A platform shelf: its most used as cards of one height (a long title never stretches a row), the
rest as a list. */
.pl-job{gap:10px;padding:18px 20px}
.pl-job b{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:2.8em;
font-size:15px;font-weight:500;line-height:1.4}
.pl-job-f{display:flex;align-items:center;gap:10px;margin-top:4px;min-width:0}
.pl-job-auto{color:var(--green)}
.pl-job-p{margin-left:auto;flex:none;font-family:var(--mono);font-size:12.5px;color:var(--ink);font-variant-numeric:tabular-nums}
.pl-list-quiet .ui-td b{font-weight:400;color:var(--muted)}
.pv-hero{padding-bottom:36px}
.pv-id{display:flex;align-items:center;gap:12px}
.pv-acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}