feat(client): split the insights panel into 2:3 columns

This commit is contained in:
bowenliang123 committed 2026-09-16 17:30:03 +08:00
1 parent 8de11e2c32
commit 2500ddb03c
2 files changed
+120 -108

No files matched your search

+89 -87
View File
@@ -6,12 +6,12 @@
* projection values), so the panel draws every session's insight without
* opening one log.
*
* Top to bottom: the range picker (7d / 30d / all) scopes the insight band —
* the KPI 2×2 block and the activity heatmap share one folding row — above
* the session grid; the activity heatmap keeps its own fixed 8-week window
* and PINs the grid to a picked day (the panel's drill-down gesture). A
* session card click jumps to that session through the harness's own
* `sessions.open` and closes the panel.
* The body is a 2:3 column pair: the insight column (the KPI 2×2 block over
* the activity heatmap) beside the session column (search, group chips, and
* the card grid); the heatmap keeps its own fixed 8-week window and PINs the
* list to a picked day (the panel's drill-down gesture). A session card
* click jumps to that session through the harness's own `sessions.open` and
* closes the panel.
*/
import { useEffect, useMemo, useState, useSyncExternalStore, type ReactElement } from 'react'
@@ -134,8 +134,8 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
{rows === null ? (
<div className="lc-empty">{t('ov.unavailable')}</div>
) : (
<>
<div className="lc-ov-mid">
<div className="lc-ov-body">
<div className="lc-ov-left">
<div className="lc-ov-kpis">
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('ov.kpi.sessions')}</span>
@@ -167,90 +167,92 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
</div>
</div>
<div className="lc-ov-list-head">
<span className="lc-ov-list-title">{t('ov.list.title')}</span>
<span className="lc-ov-list-count">{visible.length}</span>
{day !== null && (
<button type="button" className="lc-ov-day-chip" title={t('ov.list.dayClear')} onClick={() => { setDay(null) }}>
{t('ov.list.dayFilter', { day })} ×
</button>
)}
<input
className="lc-ov-search"
type="search"
value={query}
placeholder={t('ov.list.search')}
aria-label={t('ov.list.search')}
onChange={(ev) => { setQuery(ev.target.value) }}
/>
<div className="lc-gran" role="group" aria-label={t('ov.list.sortLabel')}>
{SORTS.map(s => (
<button
key={s}
type="button"
className={'lc-gran-btn' + (sort === s ? ' lc-gran-on' : '')}
onClick={() => { setSort(s) }}
>{t('ov.list.sort.' + s)}</button>
))}
</div>
</div>
{chips.length > 0 && (
<div className="lc-ov-groups" role="group" aria-label={t('ov.group.label')}>
<button
type="button"
className={'lc-ov-chip' + (group === null ? ' lc-ov-chip-on' : '')}
onClick={() => { setGroup(null) }}
>{t('ov.range.all')}<span className="lc-ov-chip-n">{scoped.length}</span></button>
{chips.map(c => (
<button
key={c.key}
type="button"
className={'lc-ov-chip' + (group === c.key ? ' lc-ov-chip-on' : '')}
onClick={() => { setGroup(group === c.key ? null : c.key) }}
>{c.key === UNGROUPED_KEY ? t('ov.group.ungrouped') : c.key}<span className="lc-ov-chip-n">{c.count}</span></button>
))}
</div>
)}
{visible.length === 0 ? (
<div className="lc-empty">{t(allRows.length === 0 ? 'ov.list.empty' : 'ov.list.noMatch')}</div>
) : (
<>
<div className="lc-ov-grid">
{paged.items.map(row => (
<OverviewCard
key={row.id}
row={row}
{...(groups?.[row.id] !== undefined ? { group: groups[row.id] } : {})}
costLabel={cardCostOf(row, prices, currency)}
now={now}
onOpen={openOne}
/>
<div className="lc-ov-right">
<div className="lc-ov-list-head">
<span className="lc-ov-list-title">{t('ov.list.title')}</span>
<span className="lc-ov-list-count">{visible.length}</span>
{day !== null && (
<button type="button" className="lc-ov-day-chip" title={t('ov.list.dayClear')} onClick={() => { setDay(null) }}>
{t('ov.list.dayFilter', { day })} ×
</button>
)}
<input
className="lc-ov-search"
type="search"
value={query}
placeholder={t('ov.list.search')}
aria-label={t('ov.list.search')}
onChange={(ev) => { setQuery(ev.target.value) }}
/>
<div className="lc-gran" role="group" aria-label={t('ov.list.sortLabel')}>
{SORTS.map(s => (
<button
key={s}
type="button"
className={'lc-gran-btn' + (sort === s ? ' lc-gran-on' : '')}
onClick={() => { setSort(s) }}
>{t('ov.list.sort.' + s)}</button>
))}
</div>
{paged.count > 1 && (
<div className="lc-ov-pager" role="navigation" aria-label={t('ov.list.pager')}>
</div>
{chips.length > 0 && (
<div className="lc-ov-groups" role="group" aria-label={t('ov.group.label')}>
<button
type="button"
className={'lc-ov-chip' + (group === null ? ' lc-ov-chip-on' : '')}
onClick={() => { setGroup(null) }}
>{t('ov.range.all')}<span className="lc-ov-chip-n">{scoped.length}</span></button>
{chips.map(c => (
<button
key={c.key}
type="button"
className="lc-ov-pager-btn"
disabled={paged.index === 0}
aria-label={t('ov.list.prev')}
onClick={() => { setPage(paged.index - 1) }}
>‹</button>
<span className="lc-ov-pager-n">{t('ov.list.page', { n: paged.index + 1, total: paged.count })}</span>
<button
type="button"
className="lc-ov-pager-btn"
disabled={paged.index === paged.count - 1}
aria-label={t('ov.list.next')}
onClick={() => { setPage(paged.index + 1) }}
>›</button>
className={'lc-ov-chip' + (group === c.key ? ' lc-ov-chip-on' : '')}
onClick={() => { setGroup(group === c.key ? null : c.key) }}
>{c.key === UNGROUPED_KEY ? t('ov.group.ungrouped') : c.key}<span className="lc-ov-chip-n">{c.count}</span></button>
))}
</div>
)}
{visible.length === 0 ? (
<div className="lc-empty">{t(allRows.length === 0 ? 'ov.list.empty' : 'ov.list.noMatch')}</div>
) : (
<>
<div className="lc-ov-grid">
{paged.items.map(row => (
<OverviewCard
key={row.id}
row={row}
{...(groups?.[row.id] !== undefined ? { group: groups[row.id] } : {})}
costLabel={cardCostOf(row, prices, currency)}
now={now}
onOpen={openOne}
/>
))}
</div>
)}
</>
)}
</>
{paged.count > 1 && (
<div className="lc-ov-pager" role="navigation" aria-label={t('ov.list.pager')}>
<button
type="button"
className="lc-ov-pager-btn"
disabled={paged.index === 0}
aria-label={t('ov.list.prev')}
onClick={() => { setPage(paged.index - 1) }}
>‹</button>
<span className="lc-ov-pager-n">{t('ov.list.page', { n: paged.index + 1, total: paged.count })}</span>
<button
type="button"
className="lc-ov-pager-btn"
disabled={paged.index === paged.count - 1}
aria-label={t('ov.list.next')}
onClick={() => { setPage(paged.index + 1) }}
>›</button>
</div>
)}
</>
)}
</div>
</div>
)}
</div>
</div>
+31 -21
View File
@@ -1,12 +1,13 @@
/*
* The Context Dashboard: the sidebar-foot entry button (stacked above
* The Context Insights panel: the sidebar-foot entry button (stacked above
* Settings by the harness's own foot layout) and the frame-wide overlay
* panel it opens — KPI band, activity heatmap, and the session card grid.
* The overlay card is the `lc-ov` query container, so every fold (chart
* stack, grid columns) keys off the card's own width. Inline-size
* containment is safe here: the card carries no fullscreen overlay of its
* own (heat tooltips are native), so the fixed-containing-block trap cannot
* catch anything.
* panel it opens — a 2:3 body of insight column (KPI 2×2 block over the
* activity heatmap) beside the session column (search, chips, card grid).
* The overlay card is the `lc-ov` query container, so the column fold keys
* off the card's own width. Inline-size containment is safe here: the card
* carries no fullscreen overlay of its own, and the heat cells' Tooltip
* bubbles position correctly against the viewport despite the card's layout
* containment (checked in the browser).
*/
/* ---- the sidebar-foot entry ---- */
@@ -24,26 +25,35 @@
/* ---- the overlay chrome ---- */
.lc-ov-backdrop { position: fixed; inset: 0; z-index: 200; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.45)); display: flex; align-items: center; justify-content: center; }
.lc-ov-card { width: min(1120px, calc(100vw - 32px)); max-height: min(86vh, 900px); overflow-y: auto; box-sizing: border-box; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); padding: 16px 18px 18px; color: var(--dsw-alias-label-primary); font-size: 13px; container: lc-ov / inline-size; }
.lc-ov-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
/* The card is a flex column capped at its max-height: the body fills the
rest and each column scrolls its own overflow, so browsing sessions never
scrolls the insight column out of view. Hidden (not scrollable) here —
the scrollers live on the columns, and on narrow widths on the body. */
.lc-ov-card { display: flex; flex-direction: column; width: min(1120px, calc(100vw - 32px)); max-height: min(86vh, 900px); overflow: hidden; box-sizing: border-box; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); padding: 16px 18px 18px; color: var(--dsw-alias-label-primary); font-size: 13px; container: lc-ov / inline-size; }
.lc-ov-head { flex: none; display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.lc-ov-head-icon { flex: none; }
.lc-ov-title { font-weight: 600; font-size: 15px; white-space: nowrap; }
.lc-ov-head .lc-modal-close { margin-left: 0; flex: none; }
/* ---- the mid band: the KPI 2×2 block and the activity heat share one row; flex-wrap folds the heat onto its own row under width pressure ---- */
.lc-ov-mid { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
/* border-box keeps the flex bases true outer widths: under the harness's
content-box default the padding quietly adds ~30px per card and the row
wraps at full width. */
.lc-ov-mid > .lc-card { margin-bottom: 0; min-width: 0; box-sizing: border-box; }
.lc-ov-kpis { flex: 1 1 320px; min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, 1fr); gap: 10px; }
/* ---- the body: a 2:3 column pair — the insight column (KPI 2×2 over the
heat) beside the session column; under width pressure it folds to one
full-width column and the body itself becomes the scroller. ---- */
.lc-ov-body { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 10px; }
.lc-ov-left { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
.lc-ov-right { min-height: 0; overflow-y: auto; }
@container lc-ov (max-width: 760px) {
.lc-ov-body { grid-template-columns: 1fr; overflow-y: auto; }
.lc-ov-left, .lc-ov-right { overflow: visible; }
}
/* border-box keeps the card tracks true outer widths: under the harness's
content-box default the padding quietly adds ~30px per card. */
.lc-ov-left > .lc-card { margin-bottom: 0; min-width: 0; box-sizing: border-box; }
.lc-ov-kpis { flex: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, 1fr); gap: 10px; }
.lc-ov-kpi { align-items: center; padding: 10px 8px; }
.lc-ov-kpi .lc-stat-value { font-size: 20px; }
.lc-ov-heat-card { flex: 0 1 252px; display: flex; flex-direction: column; }
/* Stacked on its own wrapped row (narrow panes), the heat card stretches again — the grid just centers wider. */
@container lc-ov (max-width: 680px) {
.lc-ov-heat-card { flex: 1 1 252px; }
}
/* The heat card absorbs the insight column's leftover height (the grid
centers inside it) but never shrinks below its own grid. */
.lc-ov-heat-card { flex: 1 0 auto; display: flex; flex-direction: column; }
/*
* The activity heatmap: fixed-size square cells (8 week columns ≈ a 200px