Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Fit the picker to the docs iframe
Browse filesThe chart drew into a fixed 1180-wide viewBox, so in the ~760px docs
iframe its labels shrank to about 6px. It now measures its container and
draws at real pixels. Below lg the sentence uses 26px type and tighter
padding, and the hover card is clamped inside the chart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- app/page.tsx +6 -6
- components/MemoryBar.tsx +50 -10
- components/ModelInput.tsx +35 -19
- components/SentenceCombobox.tsx +1 -1
- components/SentenceSelect.tsx +2 -2
app/page.tsx
CHANGED
|
@@ -41,13 +41,13 @@ export default function CalculatorPage() {
|
|
| 41 |
: [];
|
| 42 |
|
| 43 |
return (
|
| 44 |
-
<main className="mx-auto w-full max-w-[1280px] px-10 pb-24 pt-20">
|
| 45 |
-
<div className="flex flex-wrap items-center gap-2.5">
|
| 46 |
-
<span className="text-[
|
| 47 |
I want to run
|
| 48 |
</span>
|
| 49 |
<ModelInput onResolve={setModel} />
|
| 50 |
-
<span className="text-[
|
| 51 |
on
|
| 52 |
</span>
|
| 53 |
<SentenceCombobox
|
|
@@ -58,7 +58,7 @@ export default function CalculatorPage() {
|
|
| 58 |
options={GPUS.map((g) => ({ id: g.id, label: g.label }))}
|
| 59 |
onChange={setGpuId}
|
| 60 |
/>
|
| 61 |
-
<span className="text-[
|
| 62 |
quantized
|
| 63 |
</span>
|
| 64 |
<SentenceSelect
|
|
@@ -69,7 +69,7 @@ export default function CalculatorPage() {
|
|
| 69 |
/>
|
| 70 |
</div>
|
| 71 |
|
| 72 |
-
<div className="pt-11">
|
| 73 |
<MemoryBar
|
| 74 |
model={model}
|
| 75 |
gpu={gpu}
|
|
|
|
| 41 |
: [];
|
| 42 |
|
| 43 |
return (
|
| 44 |
+
<main className="mx-auto w-full max-w-[1280px] px-4 pb-6 pt-5 lg:px-10 lg:pb-24 lg:pt-20">
|
| 45 |
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 lg:gap-2.5">
|
| 46 |
+
<span className="text-[26px] font-medium leading-[30px] lg:text-[40px] lg:leading-[44px] tracking-[-0.04em] text-stone">
|
| 47 |
I want to run
|
| 48 |
</span>
|
| 49 |
<ModelInput onResolve={setModel} />
|
| 50 |
+
<span className="text-[26px] font-medium leading-[30px] lg:text-[40px] lg:leading-[44px] tracking-[-0.04em] text-stone">
|
| 51 |
on
|
| 52 |
</span>
|
| 53 |
<SentenceCombobox
|
|
|
|
| 58 |
options={GPUS.map((g) => ({ id: g.id, label: g.label }))}
|
| 59 |
onChange={setGpuId}
|
| 60 |
/>
|
| 61 |
+
<span className="text-[26px] font-medium leading-[30px] lg:text-[40px] lg:leading-[44px] tracking-[-0.04em] text-stone">
|
| 62 |
quantized
|
| 63 |
</span>
|
| 64 |
<SentenceSelect
|
|
|
|
| 69 |
/>
|
| 70 |
</div>
|
| 71 |
|
| 72 |
+
<div className="pt-7 lg:pt-11">
|
| 73 |
<MemoryBar
|
| 74 |
model={model}
|
| 75 |
gpu={gpu}
|
components/MemoryBar.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useCallback, useEffect, useRef } from "react";
|
| 4 |
|
| 5 |
import {
|
| 6 |
Model,
|
|
@@ -12,11 +12,28 @@ import {
|
|
| 12 |
weightsGB,
|
| 13 |
} from "@/lib/data";
|
| 14 |
|
| 15 |
-
|
|
|
|
| 16 |
const BAR_H = 55;
|
| 17 |
const R = 5.5;
|
| 18 |
/** Pointer band around each tick, in viewBox units. */
|
| 19 |
const HIT_W = 44;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
interface Props {
|
| 22 |
/** null until a model resolves — the bar renders empty, but to scale */
|
|
@@ -46,16 +63,42 @@ function niceCeil(v: number): number {
|
|
| 46 |
return Math.ceil(v / step) * step;
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
export default function MemoryBar(props: Props) {
|
| 50 |
-
|
| 51 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
/**
|
| 55 |
* The card, waiting. Just the empty track, holding the same space the filled
|
| 56 |
* bar will take, so nothing moves when a model arrives.
|
| 57 |
*/
|
| 58 |
-
function EmptyBar() {
|
| 59 |
const barTop = 84;
|
| 60 |
const barBottom = barTop + BAR_H;
|
| 61 |
|
|
@@ -68,7 +111,7 @@ function EmptyBar() {
|
|
| 68 |
);
|
| 69 |
}
|
| 70 |
|
| 71 |
-
function FilledBar({ model, gpu, contextTokens, selectedBits, onHoverBits, hoverBits, hoverCard }: Props & { model: Model }) {
|
| 72 |
const overhead = overheadGB(model, contextTokens);
|
| 73 |
const vram = gpu.vram;
|
| 74 |
|
|
@@ -146,8 +189,6 @@ function FilledBar({ model, gpu, contextTokens, selectedBits, onHoverBits, hover
|
|
| 146 |
useEffect(() => () => clearTimeout(closeTimer.current), []);
|
| 147 |
|
| 148 |
const anchor = ticks.find((t) => t.bits === hoverBits);
|
| 149 |
-
// Past the midpoint the card would run off, so hang it from its right edge.
|
| 150 |
-
const flips = anchor ? anchor.x > W * 0.55 : false;
|
| 151 |
|
| 152 |
return (
|
| 153 |
<div className="relative">
|
|
@@ -299,10 +340,9 @@ function FilledBar({ model, gpu, contextTokens, selectedBits, onHoverBits, hover
|
|
| 299 |
onMouseEnter={cancelClose}
|
| 300 |
onMouseLeave={scheduleClose}
|
| 301 |
style={{
|
| 302 |
-
left:
|
| 303 |
// Below the whole chart, so it never covers the KV cache caption.
|
| 304 |
top: "calc(100% + 6px)",
|
| 305 |
-
transform: flips ? "translateX(-100%)" : undefined,
|
| 306 |
}}
|
| 307 |
>
|
| 308 |
{hoverCard}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
| 4 |
|
| 5 |
import {
|
| 6 |
Model,
|
|
|
|
| 12 |
weightsGB,
|
| 13 |
} from "@/lib/data";
|
| 14 |
|
| 15 |
+
/** Width before the container has been measured. */
|
| 16 |
+
const DEFAULT_W = 1180;
|
| 17 |
const BAR_H = 55;
|
| 18 |
const R = 5.5;
|
| 19 |
/** Pointer band around each tick, in viewBox units. */
|
| 20 |
const HIT_W = 44;
|
| 21 |
+
/** MethodReceipt's fixed width. */
|
| 22 |
+
const CARD_W = 466;
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Where the hover card's left edge goes, in px from the chart's left edge, so
|
| 26 |
+
* that it reads as hanging from the tick at `anchorX` without running off a
|
| 27 |
+
* chart `width` px wide. The chart is as narrow as the docs iframe (~730px),
|
| 28 |
+
* so a card can fail to fit on either side of a tick in the middle.
|
| 29 |
+
*/
|
| 30 |
+
function cardLeft(anchorX: number, width: number, cardW: number): number {
|
| 31 |
+
// Hang from the tick: rightward if the card fits, else leftward.
|
| 32 |
+
const hung = anchorX + cardW <= width ? anchorX : anchorX - cardW;
|
| 33 |
+
// Neither side fits, so slide it into frame. When the chart is narrower than
|
| 34 |
+
// the card, the left edge wins: overflow to the right can still be scrolled.
|
| 35 |
+
return Math.max(0, Math.min(hung, width - cardW));
|
| 36 |
+
}
|
| 37 |
|
| 38 |
interface Props {
|
| 39 |
/** null until a model resolves — the bar renders empty, but to scale */
|
|
|
|
| 63 |
return Math.ceil(v / step) * step;
|
| 64 |
}
|
| 65 |
|
| 66 |
+
/**
|
| 67 |
+
* The chart draws in real pixels: the viewBox is as wide as the box it sits
|
| 68 |
+
* in. A fixed viewBox would scale the 11px captions down with the page, and in
|
| 69 |
+
* the docs iframe they shrink to about 6px.
|
| 70 |
+
*/
|
| 71 |
+
function useWidth() {
|
| 72 |
+
const ref = useRef<HTMLDivElement>(null);
|
| 73 |
+
const [width, setWidth] = useState(DEFAULT_W);
|
| 74 |
+
// Layout effect, so the first paint is already at the measured width.
|
| 75 |
+
useLayoutEffect(() => {
|
| 76 |
+
const el = ref.current;
|
| 77 |
+
if (!el) return;
|
| 78 |
+
setWidth(Math.max(1, Math.round(el.clientWidth)));
|
| 79 |
+
const ro = new ResizeObserver(([entry]) => {
|
| 80 |
+
setWidth(Math.max(1, Math.round(entry.contentRect.width)));
|
| 81 |
+
});
|
| 82 |
+
ro.observe(el);
|
| 83 |
+
return () => ro.disconnect();
|
| 84 |
+
}, []);
|
| 85 |
+
return [ref, width] as const;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
export default function MemoryBar(props: Props) {
|
| 89 |
+
const [ref, W] = useWidth();
|
| 90 |
+
return (
|
| 91 |
+
<div ref={ref}>
|
| 92 |
+
{props.model ? <FilledBar {...props} model={props.model} W={W} /> : <EmptyBar W={W} />}
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
}
|
| 96 |
|
| 97 |
/**
|
| 98 |
* The card, waiting. Just the empty track, holding the same space the filled
|
| 99 |
* bar will take, so nothing moves when a model arrives.
|
| 100 |
*/
|
| 101 |
+
function EmptyBar({ W }: { W: number }) {
|
| 102 |
const barTop = 84;
|
| 103 |
const barBottom = barTop + BAR_H;
|
| 104 |
|
|
|
|
| 111 |
);
|
| 112 |
}
|
| 113 |
|
| 114 |
+
function FilledBar({ model, gpu, contextTokens, selectedBits, onHoverBits, hoverBits, hoverCard, W }: Props & { model: Model; W: number }) {
|
| 115 |
const overhead = overheadGB(model, contextTokens);
|
| 116 |
const vram = gpu.vram;
|
| 117 |
|
|
|
|
| 189 |
useEffect(() => () => clearTimeout(closeTimer.current), []);
|
| 190 |
|
| 191 |
const anchor = ticks.find((t) => t.bits === hoverBits);
|
|
|
|
|
|
|
| 192 |
|
| 193 |
return (
|
| 194 |
<div className="relative">
|
|
|
|
| 340 |
onMouseEnter={cancelClose}
|
| 341 |
onMouseLeave={scheduleClose}
|
| 342 |
style={{
|
| 343 |
+
left: cardLeft(anchor.x, W, CARD_W),
|
| 344 |
// Below the whole chart, so it never covers the KV cache caption.
|
| 345 |
top: "calc(100% + 6px)",
|
|
|
|
| 346 |
}}
|
| 347 |
>
|
| 348 |
{hoverCard}
|
components/ModelInput.tsx
CHANGED
|
@@ -1,23 +1,32 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
import type { Model } from "@/lib/data";
|
| 5 |
import { elideMiddle } from "./elide";
|
| 6 |
|
| 7 |
-
// The field
|
| 8 |
-
//
|
| 9 |
-
//
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
/**
|
| 14 |
* The canvas cannot resolve `var(--font-geist-sans)`, so take the font the
|
| 15 |
-
* browser actually resolved on the field itself.
|
|
|
|
| 16 |
*/
|
| 17 |
-
function
|
| 18 |
-
if (!el) return null;
|
| 19 |
const s = getComputedStyle(el);
|
| 20 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
interface Props {
|
|
@@ -39,10 +48,15 @@ export default function ModelInput({ onResolve }: Props) {
|
|
| 39 |
const [text, setText] = useState("");
|
| 40 |
const [status, setStatus] = useState<Status>({ kind: "empty" });
|
| 41 |
const [focused, setFocused] = useState(false);
|
| 42 |
-
const [
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}, []);
|
| 47 |
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
| 48 |
// Only the newest lookup may write state; earlier ones lose the race.
|
|
@@ -87,13 +101,15 @@ export default function ModelInput({ onResolve }: Props) {
|
|
| 87 |
// middle elided so the tail — the part that names the checkpoint — stays
|
| 88 |
// visible. The input keeps the real value throughout.
|
| 89 |
const elided =
|
| 90 |
-
focused || !
|
|
|
|
|
|
|
| 91 |
const isElided = elided !== text;
|
| 92 |
|
| 93 |
return (
|
| 94 |
<span className="relative inline-flex flex-col">
|
| 95 |
<input
|
| 96 |
-
ref={
|
| 97 |
aria-label="Model"
|
| 98 |
value={text}
|
| 99 |
onChange={(e) => handleChange(e.target.value)}
|
|
@@ -104,20 +120,20 @@ export default function ModelInput({ onResolve }: Props) {
|
|
| 104 |
autoCapitalize="off"
|
| 105 |
autoComplete="off"
|
| 106 |
title={isElided ? text : undefined}
|
| 107 |
-
className={`h-[52px] w-[420px] rounded-md bg-paper px-3.5 text-[
|
| 108 |
isElided ? "text-transparent" : "text-obsidian"
|
| 109 |
}`}
|
| 110 |
/>
|
| 111 |
{isElided && (
|
| 112 |
<span
|
| 113 |
aria-hidden
|
| 114 |
-
className="pointer-events-none absolute inset-y-0 left-3.5 flex items-center text-[
|
| 115 |
>
|
| 116 |
{elided}
|
| 117 |
</span>
|
| 118 |
)}
|
| 119 |
<span
|
| 120 |
-
className="label absolute top-[56px] left-1 whitespace-nowrap text-graphite"
|
| 121 |
role={status.kind === "error" ? "alert" : undefined}
|
| 122 |
>
|
| 123 |
{status.kind === "loading" && "Looking up"}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useEffect, useRef, useState } from "react";
|
| 4 |
import type { Model } from "@/lib/data";
|
| 5 |
import { elideMiddle } from "./elide";
|
| 6 |
|
| 7 |
+
// The field has a fixed width per breakpoint. It does not grow with the name:
|
| 8 |
+
// a field that resized would shove the rest of the sentence around on every
|
| 9 |
+
// keystroke.
|
| 10 |
+
|
| 11 |
+
interface Metrics {
|
| 12 |
+
font: string;
|
| 13 |
+
/** room for text inside the padding */
|
| 14 |
+
textPx: number;
|
| 15 |
+
tracking: string;
|
| 16 |
+
}
|
| 17 |
|
| 18 |
/**
|
| 19 |
* The canvas cannot resolve `var(--font-geist-sans)`, so take the font the
|
| 20 |
+
* browser actually resolved on the field itself. Size, width and tracking all
|
| 21 |
+
* change at the breakpoint, so they are read here too, not hardcoded.
|
| 22 |
*/
|
| 23 |
+
function metricsOf(el: HTMLInputElement): Metrics {
|
|
|
|
| 24 |
const s = getComputedStyle(el);
|
| 25 |
+
return {
|
| 26 |
+
font: s.font || `${s.fontWeight} ${s.fontSize} ${s.fontFamily}`,
|
| 27 |
+
textPx: el.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight),
|
| 28 |
+
tracking: s.letterSpacing,
|
| 29 |
+
};
|
| 30 |
}
|
| 31 |
|
| 32 |
interface Props {
|
|
|
|
| 48 |
const [text, setText] = useState("");
|
| 49 |
const [status, setStatus] = useState<Status>({ kind: "empty" });
|
| 50 |
const [focused, setFocused] = useState(false);
|
| 51 |
+
const [metrics, setMetrics] = useState<Metrics | null>(null);
|
| 52 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 53 |
+
// Re-read whenever the field resizes, which is when the breakpoint flips.
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
const el = inputRef.current;
|
| 56 |
+
if (!el) return;
|
| 57 |
+
const ro = new ResizeObserver(() => setMetrics(metricsOf(el)));
|
| 58 |
+
ro.observe(el);
|
| 59 |
+
return () => ro.disconnect();
|
| 60 |
}, []);
|
| 61 |
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
| 62 |
// Only the newest lookup may write state; earlier ones lose the race.
|
|
|
|
| 101 |
// middle elided so the tail — the part that names the checkpoint — stays
|
| 102 |
// visible. The input keeps the real value throughout.
|
| 103 |
const elided =
|
| 104 |
+
focused || !metrics
|
| 105 |
+
? text
|
| 106 |
+
: elideMiddle(text, metrics.textPx, metrics.font, metrics.tracking);
|
| 107 |
const isElided = elided !== text;
|
| 108 |
|
| 109 |
return (
|
| 110 |
<span className="relative inline-flex flex-col">
|
| 111 |
<input
|
| 112 |
+
ref={inputRef}
|
| 113 |
aria-label="Model"
|
| 114 |
value={text}
|
| 115 |
onChange={(e) => handleChange(e.target.value)}
|
|
|
|
| 120 |
autoCapitalize="off"
|
| 121 |
autoComplete="off"
|
| 122 |
title={isElided ? text : undefined}
|
| 123 |
+
className={`h-[38px] lg:h-[52px] w-[280px] lg:w-[420px] rounded-md bg-paper px-2.5 lg:px-3.5 text-[26px] font-medium leading-[30px] lg:text-[40px] lg:leading-[44px] tracking-[-0.04em] ring-raised outline-none placeholder:text-ash ${
|
| 124 |
isElided ? "text-transparent" : "text-obsidian"
|
| 125 |
}`}
|
| 126 |
/>
|
| 127 |
{isElided && (
|
| 128 |
<span
|
| 129 |
aria-hidden
|
| 130 |
+
className="pointer-events-none absolute inset-y-0 left-2.5 lg:left-3.5 flex items-center text-[26px] font-medium leading-[30px] lg:text-[40px] lg:leading-[44px] tracking-[-0.04em] text-obsidian"
|
| 131 |
>
|
| 132 |
{elided}
|
| 133 |
</span>
|
| 134 |
)}
|
| 135 |
<span
|
| 136 |
+
className="label absolute top-[42px] lg:top-[56px] left-1 whitespace-nowrap text-graphite"
|
| 137 |
role={status.kind === "error" ? "alert" : undefined}
|
| 138 |
>
|
| 139 |
{status.kind === "loading" && "Looking up"}
|
components/SentenceCombobox.tsx
CHANGED
|
@@ -51,7 +51,7 @@ export default function SentenceCombobox({
|
|
| 51 |
>
|
| 52 |
<ComboboxTrigger
|
| 53 |
aria-label={ariaLabel}
|
| 54 |
-
className={`inline-flex h-[52px] items-center gap-2.5 rounded-md bg-paper px-3.5 text-[
|
| 55 |
muted ? "text-ash" : "text-obsidian"
|
| 56 |
}`}
|
| 57 |
>
|
|
|
|
| 51 |
>
|
| 52 |
<ComboboxTrigger
|
| 53 |
aria-label={ariaLabel}
|
| 54 |
+
className={`inline-flex h-[38px] lg:h-[52px] items-center gap-2.5 rounded-md bg-paper px-2.5 lg:px-3.5 text-[26px] font-medium leading-[30px] lg:text-[40px] lg:leading-[44px] tracking-[-0.04em] ring-raised [&_svg]:text-graphite ${
|
| 55 |
muted ? "text-ash" : "text-obsidian"
|
| 56 |
}`}
|
| 57 |
>
|
components/SentenceSelect.tsx
CHANGED
|
@@ -32,13 +32,13 @@ export default function SentenceSelect({
|
|
| 32 |
return (
|
| 33 |
<span
|
| 34 |
className={`relative inline-flex items-center gap-2.5 rounded-md bg-paper ring-raised ${
|
| 35 |
-
display ? "h-[52px] px-3.5" : "h-9 px-3"
|
| 36 |
}`}
|
| 37 |
>
|
| 38 |
<span
|
| 39 |
className={
|
| 40 |
display
|
| 41 |
-
? "text-[
|
| 42 |
: "font-mono text-[13px] text-obsidian"
|
| 43 |
}
|
| 44 |
>
|
|
|
|
| 32 |
return (
|
| 33 |
<span
|
| 34 |
className={`relative inline-flex items-center gap-2.5 rounded-md bg-paper ring-raised ${
|
| 35 |
+
display ? "h-[38px] px-2.5 lg:h-[52px] lg:px-3.5" : "h-9 px-3"
|
| 36 |
}`}
|
| 37 |
>
|
| 38 |
<span
|
| 39 |
className={
|
| 40 |
display
|
| 41 |
+
? "text-[26px] font-medium leading-[30px] lg:text-[40px] lg:leading-[44px] tracking-[-0.04em] text-obsidian"
|
| 42 |
: "font-mono text-[13px] text-obsidian"
|
| 43 |
}
|
| 44 |
>
|