Skip to content

Commit 8bc2e78

Browse files
committed
fix: a portal a phone can navigate, and a usage panel that says something when the cache is old
**Navigation existed on mobile only as things that were hidden.** Two rails were resolved below 768px by `display: none` and nothing else. `.adm-side` carries the machine list, the assistants, all fourteen board sections and the Leaderboard's own pages — twenty-two rows, gone, with no replacement, so a phone got a board it could scroll and could not navigate. `.nav__links` did the same to Board, Privacy, Agents, Compare and Pricing on the marketing page. Both fold into panels now rather than vanishing. The section rail becomes a drawer over the content, the pattern `.bv-rail` already uses, opened by the topbar hamburger — a button that until now toggled the root rail between icons and labels, a distinction mobile does not have because that rail is always icons there. lib/mobileNav.js keeps the two meanings apart by breakpoint so neither leaks into the other, and closes the drawer on a pick, the scrim, Escape, or a widen back to desktop. **A grid track nobody was placed into.** `.mkt-page` set `max-width: none` to opt out of the 1180px reading width, but `.adm-page--wide` declares the same specificity later in the file and won outright — so the Integrations grid, the one layout here that genuinely wants the whole window, was capped and left-aligned with a dead band beside it. Now an explicit `.adm-page--full`, which Demand takes too. The mobile nav bar had the mirror of that bug: four tracks for four children, but the absolutely-positioned links panel takes no part in auto-placement, so the call to action landed in the `1fr` and stretched into a banner swallowing the bar. **Usage windows could not tell you whether anything was moving.** The percentages come from a cache only Claude Code refreshes, so after an hour the panel is three stale numbers and a caption, with the remedy hidden in a title attribute no phone can show. The remedy is inline now, and beside it a figure that cannot go stale: tokens over the trailing seven days, counted here from transcripts already on disk, moving every cycle whatever the cache is doing. Integrations was also two things — a root page and a board panel. Keeps the page, drops the panel.
1 parent 202e67a commit 8bc2e78

9 files changed

Lines changed: 306 additions & 41 deletions

File tree

site/src/components/BoardView.jsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { AddMachineModal, MachinesPanel, UpgradeModal } from './board/enroll'
88
import { ExtensionsCard, governanceBadges, McpCard, PermissionsCard, ToolCallsCard } from './board/governance'
99
import Leaderboard from './board/leaderboard'
1010
import {
11-
ActivityCard, Card, DriversCard, EndedFeed, HostsFeed, HoursCard, IntegrationsCard, LiveCard,
11+
ActivityCard, Card, DriversCard, EndedFeed, HostsFeed, HoursCard, LiveCard,
1212
ModelsTable, Offboard, ProjectsFeed, PromptsFeed, RateCard, SessionsTable, SpendCard, Tiles,
1313
TokensCard, UsageWindows,
1414
} from './board/panels'
@@ -138,7 +138,6 @@ export default function BoardView({
138138
const toolId = (tool && tool.id) || 'claude-code'
139139
const gov = m.governance || NO_FACTS
140140
const codex = m.codex || NO_FACTS
141-
const intSummary = m.integrationSummary || NO_FACTS
142141
const endings = data?.endings || NO_ROWS
143142
const hosts = data?.hosts || NO_ROWS
144143
const machines = data?.machines || NO_ROWS
@@ -193,13 +192,6 @@ export default function BoardView({
193192
{ id: 'p-permissions', label: 'Permissions', icon: 'shield', badge: govB.permissions.text },
194193
{ id: 'p-extensions', label: 'Extensions', icon: 'blocks', badge: govB.extensions.text },
195194
{ id: 'p-machines', label: 'Machines', icon: 'machines', badge: up + '/' + hosts.length, tone: hosts.length && up < hosts.length ? 'bad' : null },
196-
{
197-
id: 'p-integrations',
198-
label: 'Integrations',
199-
icon: 'plug',
200-
badge: (intSummary.reading || 0) + '/' + (intSummary.known || 0),
201-
tone: (intSummary.needsSetup || 0) > 0 ? 'warn' : null,
202-
},
203195
...(toolId === 'claude-code'
204196
? [{ id: 'p-prompts', label: 'Prompts', icon: 'prompts', badge: String((m.prompts || []).length) }]
205197
: []),
@@ -240,7 +232,7 @@ export default function BoardView({
240232
...shared,
241233
{ id: 'p-recently-finished', label: 'Recently finished', icon: 'ended', badge: endedHour ? String(endedHour) : null },
242234
]
243-
}, [cur, offboard, hosts, processes, endings, toolId, gov, codex, usage, intSummary, m.projects, m.prompts, m.limits, claude.daily, claude.models, myRank, profiles.length, embedded])
235+
}, [cur, offboard, hosts, processes, endings, toolId, gov, codex, usage, m.projects, m.prompts, m.limits, claude.daily, claude.models, myRank, profiles.length, embedded])
244236

245237
/* ── report state to parent when embedded ──
246238
Dependencies, not a bare effect: the shell turns this into state, so
@@ -326,9 +318,6 @@ export default function BoardView({
326318
<MachinesPanel machines={machines} cloud={cloud} onAdd={() => setAddOpen(true)} />
327319
</div>
328320
</Section>
329-
<Section id="p-integrations">
330-
<IntegrationsCard integrations={m.integrations} summary={m.integrationSummary} />
331-
</Section>
332321
{toolId === 'claude-code' && <Section id="p-prompts"><PromptsFeed prompts={m.prompts} /></Section>}
333322
</>
334323
)
@@ -361,7 +350,7 @@ export default function BoardView({
361350
</Section>
362351
{leaderboardSection}
363352
<Section id="p-usage-windows" cols={2}>
364-
<UsageWindows lim={m.limits} />
353+
<UsageWindows lim={m.limits} usage={usage} />
365354
<HoursCard hours={claude.hours} />
366355
</Section>
367356
<Section id="p-activity" cols={2}>

site/src/components/Nav.jsx

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
1-
import { useEffect, useRef } from 'react'
1+
import { useEffect, useRef, useState } from 'react'
2+
3+
const LINKS = [
4+
['#board', 'Board'],
5+
['#boundary', 'Privacy'],
6+
['#integrations', 'Agents'],
7+
['#compare', 'Compare'],
8+
['#pricing', 'Pricing'],
9+
]
210

311
export default function Nav({ cta, onPortal }) {
412
const navRef = useRef(null)
13+
/* Below 768px the links do not fit beside the brand and the call to action,
14+
and the stylesheet used to resolve that by hiding them outright — which
15+
left a phone with a header of two buttons and no way to reach Board,
16+
Privacy, Agents, Compare or Pricing at all. They fold into a panel under
17+
the bar instead. */
18+
const [open, setOpen] = useState(false)
519

620
useEffect(() => {
721
const onScroll = () => {
@@ -12,19 +26,48 @@ export default function Nav({ cta, onPortal }) {
1226
return () => window.removeEventListener('scroll', onScroll)
1327
}, [])
1428

29+
/* Anything that moves the page closes the panel: a link scrolls to a section
30+
the panel is covering, Escape is the reflex, and a tap outside is what a
31+
panel over content is expected to answer to. */
32+
useEffect(() => {
33+
if (!open) return
34+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
35+
const onClick = (e) => { if (!navRef.current?.contains(e.target)) setOpen(false) }
36+
document.addEventListener('keydown', onKey)
37+
document.addEventListener('click', onClick)
38+
return () => {
39+
document.removeEventListener('keydown', onKey)
40+
document.removeEventListener('click', onClick)
41+
}
42+
}, [open])
43+
1544
return (
16-
<header className="nav" ref={navRef}>
45+
<header className={'nav' + (open ? ' nav--open' : '')} ref={navRef}>
1746
<div className="nav__inner">
47+
<button
48+
type="button"
49+
className="nav__menubtn"
50+
aria-label={open ? 'Close menu' : 'Open menu'}
51+
aria-expanded={open}
52+
aria-controls="nav-links"
53+
onClick={() => setOpen(o => !o)}
54+
>
55+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
56+
{open ? (
57+
<path d="M4 4l10 10M14 4L4 14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
58+
) : (
59+
<path d="M2.5 5h13M2.5 9h13M2.5 13h13" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
60+
)}
61+
</svg>
62+
</button>
1863
<a className="nav__brand" href="#top" aria-label="TokenHUD — Token Heads-Up Display">
1964
<span className="nav__brand-dot" aria-hidden="true" />
2065
<span>Token<b>HUD</b></span>
2166
</a>
22-
<nav className="nav__links" aria-label="Primary">
23-
<a href="#board">Board</a>
24-
<a href="#boundary">Privacy</a>
25-
<a href="#integrations">Agents</a>
26-
<a href="#compare">Compare</a>
27-
<a href="#pricing">Pricing</a>
67+
<nav className="nav__links" id="nav-links" aria-label="Primary">
68+
{LINKS.map(([href, label]) => (
69+
<a key={href} href={href} onClick={() => setOpen(false)}>{label}</a>
70+
))}
2871
</nav>
2972
<button className="nav__cta" onClick={onPortal}>
3073
<span>{cta}</span>

site/src/components/Portal.jsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import SectionRail from './admin/SectionRail'
1313
import { useNow } from './board/util'
1414
import BoardView from './BoardView'
1515
import AuthCard from './portal/AuthCard'
16+
import { useMobileNav } from '../lib/mobileNav'
1617

1718
/* The portal: sign in, register machines, watch the board.
1819
*
@@ -230,6 +231,10 @@ export default function Portal({ onClose, user, onUser, onSelfHost }) {
230231
const toggleRoot = useCallback(() => {
231232
setRootMini(c => { save(SK_ROOTNAV, c ? '' : '1'); return !c })
232233
}, [])
234+
235+
/* The hamburger means "mini the root rail" on a desktop and "open the
236+
section rail" on a phone; see lib/mobileNav.js. */
237+
const { navOpen, onHamburger, closeNav } = useMobileNav({ setCollapsed, toggleRoot })
233238
const goto = useCallback(key => {
234239
if (key !== 'monitoring') setPendingTool(null)
235240
setSection(key); save(SK_SECTION, key)
@@ -294,7 +299,7 @@ export default function Portal({ onClose, user, onUser, onSelfHost }) {
294299
return (
295300
<div className="dashboard-frame adm">
296301
<AdminTopbar
297-
onCollapse={toggleRoot}
302+
onCollapse={onHamburger}
298303
onClose={onClose}
299304
onSignOut={handleSignOut}
300305
streaming={synced}
@@ -308,7 +313,12 @@ export default function Portal({ onClose, user, onUser, onSelfHost }) {
308313
<div className={'adm-shell'
309314
+ (rootMini ? ' adm-shell--rootmini' : '')
310315
+ (collapsed ? ' adm-shell--submini' : '')
311-
+ (hasSubNav ? '' : ' adm-shell--nosub')}>
316+
+ (hasSubNav ? '' : ' adm-shell--nosub')
317+
+ (navOpen ? ' adm-shell--navopen' : '')}>
318+
319+
{navOpen && (
320+
<button className="adm-scrim" onClick={closeNav} aria-label="Close navigation" />
321+
)}
312322

313323
<RootRail
314324
section={section} onSection={goto}

site/src/components/SelfHost.jsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import SectionRail from './admin/SectionRail'
99
import Settings from './admin/Settings'
1010
import { ShareModal } from './board/share'
1111
import BoardView from './BoardView'
12+
import { useMobileNav } from '../lib/mobileNav'
1213

1314
/* The self-host admin portal.
1415
*
@@ -519,6 +520,10 @@ export default function SelfHost({ onClose }) {
519520
const toggleRoot = useCallback(() => {
520521
setRootMini(c => { save(SK_ROOTNAV, c ? '' : '1'); return !c })
521522
}, [])
523+
524+
/* The hamburger means "mini the root rail" on a desktop and "open the
525+
section rail" on a phone; see lib/mobileNav.js. */
526+
const { navOpen, onHamburger, closeNav } = useMobileNav({ setCollapsed, toggleRoot })
522527
const goto = useCallback(key => {
523528
if (key !== 'monitoring') setPendingTool(null)
524529
setSection(key); save(SK_SECTION, key)
@@ -604,7 +609,7 @@ export default function SelfHost({ onClose }) {
604609
return (
605610
<div className="dashboard-frame adm">
606611
<AdminTopbar
607-
onCollapse={toggleRoot}
612+
onCollapse={onHamburger}
608613
serverUrl={serverUrl.current} serverOk
609614
onClose={onClose}
610615
theme={theme} onTheme={toggleTheme}
@@ -616,7 +621,12 @@ export default function SelfHost({ onClose }) {
616621
<div className={'adm-shell'
617622
+ (rootMini ? ' adm-shell--rootmini' : '')
618623
+ (collapsed ? ' adm-shell--submini' : '')
619-
+ (hasSubNav ? '' : ' adm-shell--nosub')}>
624+
+ (hasSubNav ? '' : ' adm-shell--nosub')
625+
+ (navOpen ? ' adm-shell--navopen' : '')}>
626+
627+
{navOpen && (
628+
<button className="adm-scrim" onClick={closeNav} aria-label="Close navigation" />
629+
)}
620630

621631
<RootRail
622632
section={section} onSection={goto}

site/src/components/admin/IntegrationsPage.jsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ export default function IntegrationsPage({ snapshots, onNavigate }) {
308308
if (selected) {
309309
const fresh = rows.find(r => r.id === selected.id) || selected
310310
return (
311-
<div className="adm-page adm-page--wide mkt-page">
311+
<div className="adm-page adm-page--wide adm-page--full mkt-page">
312312
<DetailView row={fresh} multi={multi} onBack={() => setSelected(null)} />
313313
</div>
314314
)
@@ -326,7 +326,7 @@ export default function IntegrationsPage({ snapshots, onNavigate }) {
326326

327327
if (!rows.length) {
328328
return (
329-
<div className="adm-page adm-page--wide mkt-page">
329+
<div className="adm-page adm-page--wide adm-page--full mkt-page">
330330
<div className="mkt-empty">
331331
<div className="mkt-empty-icon">
332332
<svg width="48" height="48" viewBox="0 0 48 48" fill="none"><rect x="4" y="8" width="40" height="32" rx="4" stroke="currentColor" strokeWidth="2"/><path d="M4 16h40M16 16v24M32 16v24" stroke="currentColor" strokeWidth="1.5" strokeDasharray="3 3"/><circle cx="24" cy="30" r="4" stroke="currentColor" strokeWidth="1.5"/></svg>
@@ -339,7 +339,7 @@ export default function IntegrationsPage({ snapshots, onNavigate }) {
339339
}
340340

341341
return (
342-
<div className="adm-page adm-page--wide mkt-page">
342+
<div className="adm-page adm-page--wide adm-page--full mkt-page">
343343
{/* header strip */}
344344
<div className="mkt-header">
345345
<div className="mkt-header-left">

site/src/components/admin/LeaderboardPage.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ export default function LeaderboardPage({
5757

5858
return (
5959
<TipProvider>
60-
<div className="adm-page adm-page--wide">
60+
{/* Demand is charts and stat tiles across the full width; the other three
61+
are tables and headline copy, which read better against the 1180px
62+
column. So the opt-out is per page rather than on the wrapper. */}
63+
<div className={'adm-page adm-page--wide' + (page === 'demand' ? ' adm-page--full' : '')}>
6164
{page === 'standings' && <Standings board={board} meId={meId} right={shareBtn} />}
6265
{page === 'live' && <Live board={board} />}
6366
{page === 'models' && <Models board={board} />}

site/src/components/board/panels.jsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,25 @@ function resetCell(row, now) { /* eslint-disable-line no-unused-vars -- now fo
9595
return `Resets in ${dur(secs)}`
9696
}
9797

98-
export function UsageWindows({ lim }) {
98+
/* Tokens over the trailing seven days, from this board's own transcript index.
99+
*
100+
* The percentages above it come out of Claude Code's cache and are only as
101+
* fresh as the last time Claude Code talked to the usage endpoint. This is
102+
* counted here, from files already on disk, so it moves every cycle whatever
103+
* the cache is doing — which is the difference between a panel that has gone
104+
* quiet and a panel that says nothing at all. */
105+
function tokensLast7(usage, now) {
106+
const rows = (usage && usage.byDay) || []
107+
if (!rows.length) return null
108+
// `now` comes from the panel's ticking clock rather than Date.now(), so the
109+
// window rolls with the same tick that ages the cache above it.
110+
const cutoff = new Date(now - 7 * 86400_000).toISOString().slice(0, 10)
111+
let total = 0
112+
for (const r of rows) if (r && r.date && r.date >= cutoff) total += Number(r.tokens) || 0
113+
return total || null
114+
}
115+
116+
export function UsageWindows({ lim, usage }) {
99117
const now = useNow(1000)
100118

101119
if (!lim || !lim.available) {
@@ -114,6 +132,7 @@ export function UsageWindows({ lim }) {
114132

115133
const age = lim.ageSeconds
116134
const stale = age != null && age > (lim.staleAfterSeconds || 3600)
135+
const live7 = tokensLast7(usage, now)
117136
const rows = (lim.windows || []).slice()
118137
const when = lim.fetchedAt ? clock(lim.fetchedAt) : 'unknown'
119138
const agoTxt = age == null ? '' : ` · ${dur(age)} ago`
@@ -157,6 +176,29 @@ export function UsageWindows({ lim }) {
157176
})}
158177
</tbody>
159178
</table>
179+
180+
{/* The figure that cannot go stale, whatever the cache is doing. Kept
181+
under the table rather than in it: it answers a different question —
182+
not "how much of the plan is gone" but "is anything still moving". */}
183+
{live7 != null && (
184+
<p className="bv-note" style={{ marginTop: 12 }}>
185+
<b className="tnum">{compact(live7)}</b> tokens in the last 7 days —
186+
counted here from your own transcripts, current as of this reading.
187+
</p>
188+
)}
189+
190+
{/* The remedy belongs where the problem is stated. It used to live only
191+
in the stale caption's title attribute, which nobody hovers and a
192+
phone cannot show at all. */}
193+
{stale && (
194+
<div className="bv-warnbar" style={{ marginTop: 12 }}>
195+
These percentages are {dur(age)} old. TokenHUD never asks Anthropic for
196+
them — it reads the cache Claude Code writes, and Claude Code only
197+
refreshes it when it talks to the usage endpoint. Run <b>/usage</b> in
198+
Claude Code and the next reading will pick it up. The reset countdowns
199+
above are absolute and stay exact meanwhile.
200+
</div>
201+
)}
160202
</Card>
161203
)
162204
}

site/src/lib/mobileNav.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useCallback, useEffect, useState } from 'react'
2+
3+
/* The section rail, on a phone.
4+
*
5+
* The admin shell is three columns — root rail, section rail, content — and
6+
* below 768px there is only room for two. The stylesheet used to resolve that
7+
* by hiding the section rail outright, which is the one rail that carries the
8+
* machine list, the assistants, every board section and the Leaderboard's own
9+
* pages. The result was a board a phone could scroll and could not navigate.
10+
*
11+
* So on mobile it becomes a drawer over the content, the same pattern
12+
* `.bv-rail` already uses on the self-host board, opened by the topbar's
13+
* hamburger — a button that until now toggled the root rail between icons and
14+
* labels, which is a distinction mobile does not have because the root rail is
15+
* always icons there. Same button, the meaning it should have had. */
16+
17+
export const MOBILE_NAV = '(max-width: 768px)'
18+
19+
const isMobile = () => window.matchMedia(MOBILE_NAV).matches
20+
21+
export function useMobileNav({ setCollapsed, toggleRoot }) {
22+
const [navOpen, setNavOpen] = useState(false)
23+
const closeNav = useCallback(() => setNavOpen(false), [])
24+
25+
const onHamburger = useCallback(() => {
26+
if (!isMobile()) return toggleRoot()
27+
setNavOpen(open => {
28+
// A drawer 300px wide showing nothing but icon stubs is a drawer for
29+
// nothing, so opening it also un-minis the rail. Deliberately not saved:
30+
// this is a mobile session making itself usable, not a person changing
31+
// the preference their desktop reads back.
32+
if (!open) setCollapsed(false)
33+
return !open
34+
})
35+
}, [setCollapsed, toggleRoot])
36+
37+
/* Every row in the rail navigates somewhere, so once one is picked the
38+
drawer has done its job. Listened for here rather than threaded through
39+
two rail components that have four different callbacks between them. */
40+
useEffect(() => {
41+
if (!navOpen) return
42+
const onClick = (e) => { if (e.target.closest?.('.adm-side .adm-item')) setNavOpen(false) }
43+
const onKey = (e) => { if (e.key === 'Escape') setNavOpen(false) }
44+
document.addEventListener('click', onClick)
45+
document.addEventListener('keydown', onKey)
46+
return () => {
47+
document.removeEventListener('click', onClick)
48+
document.removeEventListener('keydown', onKey)
49+
}
50+
}, [navOpen])
51+
52+
/* Widening back to desktop leaves the drawer state set but invisible, and
53+
the next hamburger press would then appear to do nothing. */
54+
useEffect(() => {
55+
const mq = window.matchMedia(MOBILE_NAV)
56+
const onChange = () => { if (!mq.matches) setNavOpen(false) }
57+
mq.addEventListener('change', onChange)
58+
return () => mq.removeEventListener('change', onChange)
59+
}, [])
60+
61+
return { navOpen, onHamburger, closeNav }
62+
}

0 commit comments

Comments
 (0)