diff --git a/src/components/labs/Lab1ConfidenceChat.test.tsx b/src/components/labs/Lab1ConfidenceChat.test.tsx index 7a967d3..ff25962 100644 --- a/src/components/labs/Lab1ConfidenceChat.test.tsx +++ b/src/components/labs/Lab1ConfidenceChat.test.tsx @@ -49,7 +49,12 @@ describe("Lab1ConfidenceChat", () => { screen.getByRole("button", { name: "Generate Output" }).closest("form")!, ); - expect(await screen.findByLabelText("often 40.0%")).toBeInTheDocument(); + const token = await screen.findByLabelText("often 40.0%"); + expect(token).toBeInTheDocument(); + + fireEvent.mouseEnter(token); + + expect(screen.getByRole("tooltip")).toBeInTheDocument(); expect(screen.getByText("14.0%:")).toBeInTheDocument(); expect(screen.getByText("commonly")).toBeInTheDocument(); expect(screen.getByText("batiai/gemma4-e2b:q4")).toBeInTheDocument(); diff --git a/src/components/labs/Lab1ConfidenceChat.tsx b/src/components/labs/Lab1ConfidenceChat.tsx index b1b145a..3b9cd19 100644 --- a/src/components/labs/Lab1ConfidenceChat.tsx +++ b/src/components/labs/Lab1ConfidenceChat.tsx @@ -1,6 +1,12 @@ "use client"; -import { FormEvent, useState } from "react"; +import { + type CSSProperties, + type FocusEvent, + FormEvent, + type MouseEvent, + useState, +} from "react"; import { formatProbabilityPercent, @@ -23,12 +29,28 @@ type AssistantTurn = Lab1ConfidenceResponse & { type ChatTurn = AssistantTurn | UserTurn; +type TooltipPlacement = "above" | "below"; + +type ActiveTooltip = { + left: number; + placement: TooltipPlacement; + token: Lab1ResponseToken; + tokenId: string; + top: number; +}; + const starterPrompts = [ "The quick brown fox", "Write one sentence explaining what a firewall does.", "List three words that describe a phishing email.", ] as const; +const CONFIDENCE_TOOLTIP_ID = "lab1-confidence-tooltip"; +const TOOLTIP_ESTIMATED_HEIGHT = 180; +const TOOLTIP_ESTIMATED_WIDTH = 260; +const TOOLTIP_VIEWPORT_PADDING = 16; +const TOOLTIP_OFFSET = 10; + function buildTurnId() { return `lab1-turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } @@ -37,14 +59,56 @@ function toConversation(messages: ChatTurn[]) { return messages.map(({ content, role }) => ({ content, role })); } -function renderTooltip(token: Lab1ResponseToken) { +function getTooltipPosition(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + const viewportWidth = + window.innerWidth || document.documentElement.clientWidth; + const viewportHeight = + window.innerHeight || document.documentElement.clientHeight; + const halfTooltipWidth = TOOLTIP_ESTIMATED_WIDTH / 2; + const minLeft = TOOLTIP_VIEWPORT_PADDING + halfTooltipWidth; + const maxLeft = viewportWidth - TOOLTIP_VIEWPORT_PADDING - halfTooltipWidth; + const centeredLeft = rect.left + rect.width / 2; + const left = + maxLeft > minLeft + ? Math.min(Math.max(centeredLeft, minLeft), maxLeft) + : viewportWidth / 2; + const belowTop = rect.bottom + TOOLTIP_OFFSET; + const hasRoomBelow = belowTop + TOOLTIP_ESTIMATED_HEIGHT <= viewportHeight; + const hasRoomAbove = rect.top - TOOLTIP_OFFSET - TOOLTIP_ESTIMATED_HEIGHT > 0; + + if (!hasRoomBelow && hasRoomAbove) { + return { + left, + placement: "above" as const, + top: rect.top - TOOLTIP_OFFSET, + }; + } + + return { + left, + placement: "below" as const, + top: belowTop, + }; +} + +function renderTooltip( + token: Lab1ResponseToken, + placement: TooltipPlacement, + style?: CSSProperties, +) { return ( - + {formatProbabilityPercent(token.probability)} {token.topAlternatives.length > 0 ? ( - {token.topAlternatives.map((candidate) => ( - + {token.topAlternatives.map((candidate, index) => ( + {formatProbabilityPercent(candidate.probability)}:{" "} {candidate.token} @@ -64,6 +128,27 @@ export function Lab1ConfidenceChat() { const [messages, setMessages] = useState([]); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [activeTooltip, setActiveTooltip] = useState( + null, + ); + + function showTooltip( + tokenId: string, + token: Lab1ResponseToken, + element: HTMLElement, + ) { + setActiveTooltip({ + token, + tokenId, + ...getTooltipPosition(element), + }); + } + + function hideTooltip(tokenId: string) { + setActiveTooltip((currentTooltip) => + currentTooltip?.tokenId === tokenId ? null : currentTooltip, + ); + } async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -183,21 +268,40 @@ export function Lab1ConfidenceChat() {
- {message.tokens.map((token, index) => ( - - {token.token} - {renderTooltip(token)} - - ))} + {message.tokens.map((token, index) => { + const tokenId = `${message.id}-${index}-${token.token}`; + const isTooltipActive = activeTooltip?.tokenId === tokenId; + + return ( + hideTooltip(tokenId)} + onFocus={( + event: FocusEvent, + ) => showTooltip(tokenId, token, event.currentTarget)} + onMouseEnter={( + event: MouseEvent< + HTMLSpanElement, + globalThis.MouseEvent + >, + ) => showTooltip(tokenId, token, event.currentTarget)} + onMouseLeave={() => hideTooltip(tokenId)} + role="listitem" + tabIndex={0} + > + {token.token} + + ); + })}
{message.error ? ( @@ -239,6 +343,13 @@ export function Lab1ConfidenceChat() { {error ?

{error}

: null} + + {activeTooltip + ? renderTooltip(activeTooltip.token, activeTooltip.placement, { + left: activeTooltip.left, + top: activeTooltip.top, + }) + : null} ); } diff --git a/src/components/labs/LabContent.test.tsx b/src/components/labs/LabContent.test.tsx index 08e610d..d842133 100644 --- a/src/components/labs/LabContent.test.tsx +++ b/src/components/labs/LabContent.test.tsx @@ -165,9 +165,44 @@ describe("LabContent", () => { name: "WhiteRabbitNeo-V3-7B on Hugging Face", }), ).toHaveClass("lab-open-pill"); - expect( - screen.getByRole("link", { name: "Ollama registry" }), - ).toHaveClass("lab-open-pill"); + expect(screen.getByRole("link", { name: "Ollama registry" })).toHaveClass( + "lab-open-pill", + ); + }); + + it("renders Lab 7 dataset and download targets as polished buttons", async () => { + mockRuntimeConfig(); + + const lab = getLabDocument("lab-7-dataset-generation-and-fine-tuning"); + expect(lab).not.toBeNull(); + + render( + , + ); + + const gsm8kLink = await screen.findByRole("link", { + name: "GSM8K dataset", + }); + expect(gsm8kLink).toHaveAttribute( + "href", + "https://huggingface.co/datasets/openai/gsm8k", + ); + expect(gsm8kLink).toHaveClass("lab-open-pill"); + + const kilnLinks = screen.getAllByRole("link", { + name: "Download Kiln AI", + }); + expect(kilnLinks).toHaveLength(2); + for (const kilnLink of kilnLinks) { + expect(kilnLink).toHaveAttribute( + "href", + "https://github.com/Kiln-AI/Kiln/releases/tag/v0.18.1", + ); + expect(kilnLink).toHaveClass("lab-download-pill"); + } }); it("keeps rendered service URL links after opening an image zoom modal", async () => { diff --git a/src/styles/globals.css b/src/styles/globals.css index c4e2d31..e871199 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -914,17 +914,22 @@ ol { } .lab-content ul.concept-pill-list > li { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.55rem; + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + align-items: baseline; + column-gap: 0.7rem; + row-gap: 0.28rem; margin: 0; - padding: 0.48rem 0.78rem; + padding: 0.72rem 1rem; border: 1px solid #d5e2ee; border-radius: 999px; background: linear-gradient(180deg, #f9fcff, #f4f9fe); } +.lab-content ul.concept-pill-list > li > span:not(.concept-pill-label) { + line-height: 1.45; +} + .lab-content .concept-pill-label { display: inline; color: #0f4f76; @@ -1899,7 +1904,10 @@ ol { } .lab-content ul.concept-pill-list > li { + grid-template-columns: 1fr; + row-gap: 0.3rem; border-radius: 16px; + padding: 0.72rem 0.9rem; } .quantization-explorer__controls, @@ -2052,7 +2060,8 @@ ol { } .lab-content a.lab-service-pill, -.lab-content a.lab-open-pill { +.lab-content a.lab-open-pill, +.lab-content a.lab-download-pill { display: inline-flex; align-items: center; gap: 0.45rem; @@ -2074,7 +2083,8 @@ ol { } .lab-content a.lab-service-pill::before, -.lab-content a.lab-open-pill::before { +.lab-content a.lab-open-pill::before, +.lab-content a.lab-download-pill::before { content: "Open"; display: inline-flex; align-items: center; @@ -2088,8 +2098,13 @@ ol { text-transform: uppercase; } +.lab-content a.lab-download-pill::before { + content: "Download"; +} + .lab-content a.lab-service-pill:hover, -.lab-content a.lab-open-pill:hover { +.lab-content a.lab-open-pill:hover, +.lab-content a.lab-download-pill:hover { transform: translateY(-1px); box-shadow: 0 12px 28px -22px rgba(15, 92, 139, 0.85); } @@ -2185,14 +2200,21 @@ ol { .lab1-confidence__token { position: relative; border-radius: 0.42rem; + cursor: help; padding: 0.12rem 0.08rem; transition: filter 120ms ease; } -.lab1-confidence__token:hover { +.lab1-confidence__token:hover, +.lab1-confidence__token[aria-describedby="lab1-confidence-tooltip"] { filter: saturate(1.05); } +.lab1-confidence__token:focus-visible { + outline: 2px solid rgba(15, 92, 139, 0.35); + outline-offset: 2px; +} + .lab1-confidence__token--very-high { background: rgba(88, 185, 102, 0.3); } @@ -2214,25 +2236,24 @@ ol { } .lab1-confidence__tooltip { - position: absolute; - left: 0; - top: calc(100% + 0.45rem); - z-index: 5; - display: none; + position: fixed; + z-index: 50; + display: block; min-width: 180px; - max-width: 260px; + max-width: min(260px, calc(100vw - 2rem)); border: 1px solid #d7e2ee; border-radius: 0.85rem; background: rgba(255, 255, 255, 0.98); box-shadow: 0 18px 38px -26px rgba(17, 44, 73, 0.7); color: #24384c; padding: 0.7rem 0.8rem; + pointer-events: none; + transform: translateX(-50%); white-space: normal; } -.lab1-confidence__token:hover .lab1-confidence__tooltip, -.lab1-confidence__token:focus-visible .lab1-confidence__tooltip { - display: block; +.lab1-confidence__tooltip--above { + transform: translate(-50%, -100%); } .lab1-confidence__tooltip strong {