diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index 48a2600..c57495d 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -291,7 +291,7 @@ color: var(--ink); } -/* "{ } JSON" link inline in the subtitle metadata line. It uses the blue link +/* "JSON" link inline in the subtitle metadata line. It uses the blue link color and a persistent underline (on the word, not the icon) so it clearly reads as a clickable link rather than just emphasized text. */ .viewer-json-link { @@ -314,6 +314,27 @@ color: var(--accent-strong); } +.viewer-json-download { + display: inline-flex; + align-items: center; + vertical-align: middle; + margin-left: 0.35rem; + padding: 0.15rem; + border-radius: 0.3rem; + color: var(--accent); + transition: color 0.2s ease, background 0.2s ease; +} + +.viewer-json-download svg { + width: 14px; + height: 14px; +} + +.viewer-json-download:hover { + color: var(--accent-strong); + background: var(--accent-soft); +} + .viewer-subtitle { margin: 0.25rem 0 0; color: #475569; @@ -566,6 +587,107 @@ background: var(--accent-soft); } +.viewer-snippet { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +/* Looks like the JSON link, with a chevron to say it opens something. */ +.viewer-snippet-toggle { + display: inline-flex; + align-items: center; + gap: 0.15rem; + padding: 0; + border: none; + background: none; + font: inherit; + font-weight: 600; + color: var(--accent); + cursor: pointer; +} + +.viewer-snippet-toggle:hover { + color: var(--accent-strong); +} + +.viewer-snippet-popover { + position: absolute; + top: calc(100% + 0.4rem); + left: 0; + z-index: 10; + display: flex; + flex-direction: column; + min-width: 22rem; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +/* Language label on the left, copy action on the right, as in docs code blocks. */ +.viewer-snippet-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.3rem 0.4rem 0.3rem 0.8rem; + border-bottom: 1px solid var(--line); + background: var(--bg); +} + +.viewer-snippet-language { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +.viewer-snippet-copy { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.45rem; + border: none; + border-radius: 0.3rem; + background: none; + font: inherit; + font-size: 0.78rem; + color: var(--ink-2); + cursor: pointer; +} + +.viewer-snippet-copy:hover { + color: var(--accent-strong); + background: var(--accent-soft); +} + +.viewer-snippet-code { + display: block; + padding: 0.8rem 1rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 0.82rem; + line-height: 1.5; + color: var(--ink-2); + background: #f8fafc; + white-space: pre; +} + +/* Syntax highlighting kept monochrome: the chrome carries no hue (see :root). */ +.viewer-snippet-token--keyword { + font-weight: 700; + color: var(--accent-strong); +} + +.viewer-snippet-token--function { + color: var(--ink); +} + +.viewer-snippet-token--string { + color: var(--muted); +} + .viewer-canvas-placeholder { height: 360px; border-radius: 1rem; diff --git a/apps/probe-viewer/src/components/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx index c43dbe1..e91aec9 100644 --- a/apps/probe-viewer/src/components/ProbeViewer.tsx +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -10,6 +10,7 @@ import { ProbeCanvas } from "./ProbeCanvas"; import { DoubleSidedProbeCanvas } from "./DoubleSidedProbeCanvas"; import { LocalProbePanel } from "./LocalProbePanel"; import { ProbeOverview } from "./ProbeOverview"; +import { PythonSnippet } from "./PythonSnippet"; const CANVAS_PADDING = 40; // How far past "the smallest contact exactly fills the viewport" the zoom cap @@ -131,14 +132,6 @@ const DownloadIcon = ( ); -// Curly-braces glyph, the de-facto standard symbol for JSON/code. -const JsonIcon = ( - -); - // Leading icons for the view-toggle chips: an eye (show/hide contact IDs), an // I-beam matching the on-canvas scale bar, and a minimap frame for the overview. const EyeIcon = ( @@ -391,9 +384,20 @@ export function ProbeViewer() { rel="noreferrer" title="View this probe's JSON on GitHub" > - {JsonIcon} JSON + + {DownloadIcon} + + {" "} + ยท{" "} + )}

diff --git a/apps/probe-viewer/src/components/PythonSnippet.tsx b/apps/probe-viewer/src/components/PythonSnippet.tsx new file mode 100644 index 0000000..59b9b63 --- /dev/null +++ b/apps/probe-viewer/src/components/PythonSnippet.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +const ChevronDownIcon = ( + +); + +const CopyIcon = ( + +); + +const CheckIcon = ( + +); + +interface PythonSnippetProps { + manufacturer: string; + model: string; +} + +// "Python" toggle for the probe subtitle. It opens a popover with the +// probeinterface code that loads this probe from the library. +export function PythonSnippet({ manufacturer, model }: PythonSnippetProps) { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const ref = useRef(null); + + // The snippet as (token kind, text) pairs, so the highlighted markup and the + // copied text come from the same source. + const tokens: [string | null, string][] = [ + ["keyword", "from"], + [null, " probeinterface "], + ["keyword", "import"], + [null, " get_probe\n\nprobe = "], + ["function", "get_probe"], + [null, "("], + ["string", `"${manufacturer}"`], + [null, ", "], + ["string", `"${model}"`], + [null, ")"], + ]; + const code = tokens.map(([, text]) => text).join(""); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(code).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [code]); + + // Close on a click outside the popover or on Escape. + useEffect(() => { + if (!open) return; + const handleMouseDown = (event: MouseEvent) => { + if (!ref.current?.contains(event.target as Node)) setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", handleMouseDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + return ( + + + {open && ( + + + Python + + + + {tokens.map(([kind, text], index) => ( + + {text} + + ))} + + + )} + + ); +}