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