stevhliu HF Staff Claude Opus 5.5 commited on
Commit
54513db
·
1 Parent(s): 3a22edb

Fit the picker to the docs iframe

Browse files

The 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 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-[40px] font-medium leading-[44px] tracking-[-0.04em] text-stone">
47
  I want to run
48
  </span>
49
  <ModelInput onResolve={setModel} />
50
- <span className="text-[40px] font-medium leading-[44px] tracking-[-0.04em] text-stone">
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-[40px] font-medium leading-[44px] tracking-[-0.04em] text-stone">
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
- const W = 1180;
 
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
- if (!props.model) return <EmptyBar />;
51
- return <FilledBar {...props} model={props.model} />;
 
 
 
 
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: `${(anchor.x / W) * 100}%`,
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 { useCallback, useRef, useState } from "react";
4
  import type { Model } from "@/lib/data";
5
  import { elideMiddle } from "./elide";
6
 
7
- // The field is a fixed 420px with 14px of padding either side. It does not
8
- // grow with the name: a field that resized would shove the rest of the
9
- // sentence around on every keystroke.
10
- const TEXT_PX = 420 - 28;
11
- const TRACKING = "-1.6px"; // -0.04em at 40px
 
 
 
 
 
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 fontOf(el: HTMLInputElement | null): string | null {
18
- if (!el) return null;
19
  const s = getComputedStyle(el);
20
- return s.font || `${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
 
 
 
 
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 [font, setFont] = useState<string | null>(null);
43
- // A callback ref runs at commit, which is where the resolved font can be read.
44
- const attachInput = useCallback((el: HTMLInputElement | null) => {
45
- if (el) setFont(fontOf(el));
 
 
 
 
 
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 || !font ? text : elideMiddle(text, TEXT_PX, font, TRACKING);
 
 
91
  const isElided = elided !== text;
92
 
93
  return (
94
  <span className="relative inline-flex flex-col">
95
  <input
96
- ref={attachInput}
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-[40px] font-medium leading-[44px] tracking-[-0.04em] ring-raised outline-none placeholder:text-ash ${
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-[40px] font-medium leading-[44px] tracking-[-0.04em] text-obsidian"
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-[40px] font-medium leading-[44px] tracking-[-0.04em] ring-raised [&_svg]:text-graphite ${
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-[40px] font-medium leading-[44px] tracking-[-0.04em] text-obsidian"
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
  >