feat(catalog): click-to-annotate captures with GitHub issue handoff (#42183)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { feedbackIssueUrl } from "../src/feedback"
|
||||
import { annotationUrl, readAnnotations } from "../src/annotations"
|
||||
|
||||
describe("catalog feedback", () => {
|
||||
test("opens a prefilled issue for an exact capture", () => {
|
||||
@@ -18,4 +19,39 @@ describe("catalog feedback", () => {
|
||||
expect(url.searchParams.get("body")).toContain("`skill-picker`")
|
||||
expect(url.searchParams.get("body")).toContain("screen=skill-picker&set=opencode")
|
||||
})
|
||||
|
||||
test("round-trips a capture annotation document through the URL fragment", () => {
|
||||
const document = {
|
||||
version: 1 as const,
|
||||
identifier: "skill-picker",
|
||||
variant: "opencode",
|
||||
annotations: [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }],
|
||||
}
|
||||
const url = new URL(annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document))
|
||||
|
||||
expect(url.hash).toStartWith("#annotations=")
|
||||
expect(readAnnotations(url, "skill-picker", "opencode")).toEqual(document.annotations)
|
||||
expect(readAnnotations(url, "other-screen", "opencode")).toEqual([])
|
||||
})
|
||||
|
||||
test("includes human and machine-readable annotations in the issue", () => {
|
||||
const annotations = [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }]
|
||||
const document = { version: 1 as const, identifier: "skill-picker", variant: "opencode", annotations }
|
||||
const url = new URL(
|
||||
feedbackIssueUrl({
|
||||
title: "Skill picker",
|
||||
identifier: "skill-picker",
|
||||
deepLink: annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document),
|
||||
variant: "opencode",
|
||||
annotations,
|
||||
document,
|
||||
}),
|
||||
)
|
||||
const body = url.searchParams.get("body") ?? ""
|
||||
|
||||
expect(body).toContain("## 1. Row 5, column 13")
|
||||
expect(body).toContain("This label needs more contrast.")
|
||||
expect(body).toContain("<summary>Annotation data</summary>")
|
||||
expect(body).toContain('"row": 4')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -334,6 +334,7 @@ export function App({ catalog }: AppProps) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (ui.viewerOpen) return
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
@@ -349,18 +350,22 @@ export function App({ catalog }: AppProps) {
|
||||
states: ui.facets.state,
|
||||
}),
|
||||
)
|
||||
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements])
|
||||
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements, ui.viewerOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.viewerOpen || !selectedScreen) return
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
const url = new URL(
|
||||
catalogDeepLink(selectedScreen.id, {
|
||||
flowId: ui.mode === "flows" ? activeFlow?.id : undefined,
|
||||
variantId: activeVariant.id,
|
||||
}),
|
||||
)
|
||||
if (window.location.hash.startsWith("#annotations=")) url.hash = window.location.hash
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
url,
|
||||
)
|
||||
}, [activeVariant.id, activeFlow?.id, selectedScreen, ui.mode, ui.viewerOpen])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -483,6 +488,7 @@ export function App({ catalog }: AppProps) {
|
||||
</main>
|
||||
{ui.viewerOpen && selectedScreen ? (
|
||||
<Viewer
|
||||
key={`${selectedScreen.id}:${activeVariant.id}`}
|
||||
screen={selectedScreen}
|
||||
identifier={
|
||||
ui.mode === "flows" && activeFlow?.replayable ? `${activeFlow.id}/${selectedScreen.id}` : selectedScreen.id
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface Annotation {
|
||||
readonly id: string
|
||||
readonly row: number
|
||||
readonly column: number
|
||||
readonly note: string
|
||||
}
|
||||
|
||||
export interface AnnotationDocument {
|
||||
readonly version: 1
|
||||
readonly identifier: string
|
||||
readonly variant: string
|
||||
readonly annotations: ReadonlyArray<Annotation>
|
||||
}
|
||||
|
||||
const FragmentKey = "annotations"
|
||||
const MaxAnnotations = 24
|
||||
const MaxNoteLength = 2_000
|
||||
|
||||
export function annotationUrl(deepLink: string, document: AnnotationDocument) {
|
||||
const url = new URL(deepLink)
|
||||
url.hash = `${FragmentKey}=${encode(document)}`
|
||||
return url.href
|
||||
}
|
||||
|
||||
export function readAnnotations(url: URL, identifier: string, variant: string): ReadonlyArray<Annotation> {
|
||||
const params = new URLSearchParams(url.hash.slice(1))
|
||||
const encoded = params.get(FragmentKey)
|
||||
if (!encoded) return []
|
||||
const value = decode(encoded)
|
||||
if (!isDocument(value) || value.identifier !== identifier || value.variant !== variant) return []
|
||||
return value.annotations
|
||||
}
|
||||
|
||||
export function readAnnotationDraft(value: string): ReadonlyArray<Annotation> {
|
||||
try {
|
||||
const annotations: unknown = JSON.parse(value)
|
||||
return isAnnotations(annotations) ? annotations : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function encode(value: AnnotationDocument) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(value))
|
||||
return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""))
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function decode(value: string): unknown {
|
||||
try {
|
||||
const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/"))
|
||||
return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isDocument(value: unknown): value is AnnotationDocument {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const document = value as Partial<AnnotationDocument>
|
||||
if (document.version !== 1 || typeof document.identifier !== "string" || typeof document.variant !== "string")
|
||||
return false
|
||||
return isAnnotations(document.annotations)
|
||||
}
|
||||
|
||||
function isAnnotations(value: unknown): value is ReadonlyArray<Annotation> {
|
||||
if (!Array.isArray(value) || value.length > MaxAnnotations) return false
|
||||
return value.every(
|
||||
(annotation) =>
|
||||
annotation &&
|
||||
typeof annotation === "object" &&
|
||||
typeof annotation.id === "string" &&
|
||||
Number.isInteger(annotation.row) &&
|
||||
annotation.row >= 0 &&
|
||||
Number.isInteger(annotation.column) &&
|
||||
annotation.column >= 0 &&
|
||||
typeof annotation.note === "string" &&
|
||||
annotation.note.length <= MaxNoteLength,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { Annotation } from "../annotations"
|
||||
|
||||
interface AnnotationEditorProps {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly annotations: ReadonlyArray<Annotation>
|
||||
readonly onAdd: (row: number, column: number, note: string) => void
|
||||
readonly onChange: (id: string, note: string) => void
|
||||
readonly onDelete: (id: string) => void
|
||||
readonly issueLink: string
|
||||
readonly onDone: () => void
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
readonly id?: string
|
||||
readonly row: number
|
||||
readonly column: number
|
||||
readonly note: string
|
||||
}
|
||||
|
||||
export function AnnotationEditor(props: AnnotationEditorProps) {
|
||||
const [draft, setDraft] = useState<Draft>()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const complete = props.annotations.filter((annotation) => annotation.note.trim() !== "")
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus()
|
||||
textareaRef.current?.setSelectionRange(draft.note.length, draft.note.length)
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [draft?.id, draft?.row, draft?.column])
|
||||
|
||||
const save = () => {
|
||||
if (!draft?.note.trim()) return
|
||||
if (draft.id) props.onChange(draft.id, draft.note.trim())
|
||||
else props.onAdd(draft.row, draft.column, draft.note.trim())
|
||||
setDraft(undefined)
|
||||
}
|
||||
|
||||
const edit = (annotation: Annotation) =>
|
||||
setDraft({ id: annotation.id, row: annotation.row, column: annotation.column, note: annotation.note })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="annotation-layer"
|
||||
aria-label="Click the terminal to add an annotation"
|
||||
onPointerDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
const column = Math.min(props.cols - 1, Math.max(0, Math.floor(((event.clientX - bounds.left) / bounds.width) * props.cols)))
|
||||
const row = Math.min(props.rows - 1, Math.max(0, Math.floor(((event.clientY - bounds.top) / bounds.height) * props.rows)))
|
||||
setDraft({ row, column, note: "" })
|
||||
}}
|
||||
>
|
||||
{props.annotations.map((annotation, index) => (
|
||||
<button
|
||||
key={annotation.id}
|
||||
type="button"
|
||||
className={`annotation-pin${annotation.id === draft?.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${((annotation.column + 0.5) / props.cols) * 100}%`,
|
||||
top: `${((annotation.row + 0.5) / props.rows) * 100}%`,
|
||||
}}
|
||||
aria-label={`Edit annotation ${index + 1}, row ${annotation.row + 1}, column ${annotation.column + 1}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => edit(annotation)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
))}
|
||||
{draft ? (
|
||||
<div
|
||||
className={`annotation-composer${draft.row > props.rows / 2 ? " above" : ""}`}
|
||||
style={{
|
||||
left: `clamp(9rem, ${((draft.column + 0.5) / props.cols) * 100}%, calc(100% - 9rem))`,
|
||||
top: `${((draft.row + 0.5) / props.rows) * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header>
|
||||
<span>{draft.id ? "Edit annotation" : "New annotation"}</span>
|
||||
<small>R{draft.row + 1} · C{draft.column + 1}</small>
|
||||
</header>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="annotation-note"
|
||||
aria-label="Annotation note"
|
||||
value={draft.note}
|
||||
maxLength={2_000}
|
||||
rows={2}
|
||||
placeholder="What should change?"
|
||||
onChange={(event) => setDraft({ ...draft, note: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
save()
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setDraft(undefined)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<footer>
|
||||
{draft.id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="annotation-composer-delete"
|
||||
onClick={() => {
|
||||
if (draft.id) props.onDelete(draft.id)
|
||||
setDraft(undefined)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
) : <span />}
|
||||
<button type="button" onClick={() => setDraft(undefined)}>Cancel</button>
|
||||
<button type="button" className="annotation-composer-save" disabled={!draft.note.trim()} onClick={save}>
|
||||
{draft.id ? "Save" : "Add"}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
<aside className="annotation-panel" aria-label="Capture annotations">
|
||||
<header>
|
||||
<div>
|
||||
<strong>Annotations</strong>
|
||||
<span>{props.annotations.length === 0 ? "Click anywhere on the terminal" : `${props.annotations.length} placed`}</span>
|
||||
</div>
|
||||
<button type="button" onClick={props.onDone}>Done</button>
|
||||
</header>
|
||||
<div className="annotation-list">
|
||||
{props.annotations.map((annotation, index) => (
|
||||
<button key={annotation.id} type="button" className="annotation-list-row" onClick={() => edit(annotation)}>
|
||||
<span className="annotation-list-pin">{index + 1}</span>
|
||||
<span>
|
||||
<small>Row {annotation.row + 1} · Column {annotation.column + 1}</small>
|
||||
<strong>{annotation.note}</strong>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<footer>
|
||||
<a
|
||||
className="annotation-issue"
|
||||
href={complete.length === 0 ? undefined : props.issueLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-disabled={complete.length === 0}
|
||||
>
|
||||
Open GitHub issue
|
||||
</a>
|
||||
<span>{complete.length === 0 ? "Add a note to continue" : `${complete.length} note${complete.length === 1 ? "" : "s"} will be included`}</span>
|
||||
</footer>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,8 +8,7 @@ interface CaptureSetSwitcherProps {
|
||||
|
||||
export function CaptureSetSwitcher({ sets, active, onSelect }: CaptureSetSwitcherProps) {
|
||||
return (
|
||||
<label className="variant-switcher" title={active.label}>
|
||||
<span className="sr-only">Theme</span>
|
||||
<label className="variant-switcher" title="Switch theme">
|
||||
<select aria-label="Select theme" value={active.id} onChange={(event) => onSelect(event.target.value)}>
|
||||
{sets.map((set) => (
|
||||
<option key={set.id} value={set.id}>
|
||||
@@ -17,8 +16,14 @@ export function CaptureSetSwitcher({ sets, active, onSelect }: CaptureSetSwitche
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="variant-hint" aria-hidden="true">
|
||||
Theme
|
||||
</span>
|
||||
<span className="variant-name" aria-hidden="true">
|
||||
{active.label}
|
||||
</span>
|
||||
<span className="variant-chevron" aria-hidden="true">
|
||||
↓
|
||||
▾
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useEffectEvent, useRef } from "react"
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react"
|
||||
import type { Facet, Filter, Screen, Taxonomy, TaxonomyGroup, Variant } from "../catalog"
|
||||
import { facetValues, frameFor, label, taxonomyLabel } from "../catalog"
|
||||
import { TerminalFrame } from "./TerminalFrame"
|
||||
@@ -6,6 +6,8 @@ import { CaptureSetSwitcher } from "./CaptureSetSwitcher"
|
||||
import { CaptureContextMenu } from "./CaptureContextMenu"
|
||||
import { feedbackIssueUrl } from "../feedback"
|
||||
import { CaptureActionsMenu } from "./CaptureActionsMenu"
|
||||
import { annotationUrl, readAnnotationDraft, readAnnotations, type Annotation, type AnnotationDocument } from "../annotations"
|
||||
import { AnnotationEditor } from "./AnnotationEditor"
|
||||
|
||||
interface ViewerProps {
|
||||
readonly screen: Screen
|
||||
@@ -50,17 +52,64 @@ export function Viewer({
|
||||
const frame = frameFor(screen, variant.id)
|
||||
if (!frame) throw new Error(`Capture ${screen.id} is unavailable in set ${variant.id}`)
|
||||
const issueLink = feedbackIssueUrl({ title: screen.title, identifier, deepLink, variant: variant.id })
|
||||
const storageKey = `catalog-annotations:${identifier}:${variant.id}`
|
||||
const [annotating, setAnnotating] = useState(() => window.location.hash.startsWith("#annotations="))
|
||||
const [annotations, setAnnotations] = useState<ReadonlyArray<Annotation>>(() => {
|
||||
const linked = readAnnotations(new URL(window.location.href), identifier, variant.id)
|
||||
if (linked.length > 0) return linked.filter((annotation) => annotation.row < frame.rows && annotation.column < frame.cols)
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (!stored) return []
|
||||
return readAnnotationDraft(stored).filter(
|
||||
(annotation) => annotation.row < frame.rows && annotation.column < frame.cols,
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const document: AnnotationDocument = { version: 1, identifier, variant: variant.id, annotations }
|
||||
const annotatedLink = annotationUrl(deepLink, document)
|
||||
const completeAnnotations = annotations.filter((annotation) => annotation.note.trim() !== "")
|
||||
const issueDocument = { ...document, annotations: completeAnnotations }
|
||||
const annotationIssueLink = feedbackIssueUrl({
|
||||
title: screen.title,
|
||||
identifier,
|
||||
deepLink: annotationUrl(deepLink, issueDocument),
|
||||
variant: variant.id,
|
||||
annotations: completeAnnotations,
|
||||
document: issueDocument,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey, JSON.stringify(annotations))
|
||||
if (annotating) window.history.replaceState(null, "", annotations.length > 0 ? annotatedLink : deepLink)
|
||||
}, [annotatedLink, annotating, annotations, deepLink, storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
dialogRef.current?.showModal()
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
|
||||
const editing =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
(event.target instanceof HTMLElement && event.target.isContentEditable)
|
||||
if (editing && event.key !== "Escape") return
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
if (annotating) {
|
||||
setAnnotating(false)
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (event.key.toLowerCase() === "a" && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
event.preventDefault()
|
||||
setAnnotating((value) => !value)
|
||||
return
|
||||
}
|
||||
if (annotating) return
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
onNavigate(event.key === "ArrowLeft" ? -1 : 1)
|
||||
@@ -107,6 +156,15 @@ export function Viewer({
|
||||
</button>
|
||||
</span>
|
||||
<div className="viewer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`viewer-button${annotating ? " active" : ""}`}
|
||||
onClick={() => setAnnotating((value) => !value)}
|
||||
title="Toggle annotation mode (A)"
|
||||
>
|
||||
Annotate
|
||||
{annotations.length > 0 ? <span className="viewer-button-count">{annotations.length}</span> : undefined}
|
||||
</button>
|
||||
<CaptureActionsMenu identifier={identifier} deepLink={deepLink} issueLink={issueLink} />
|
||||
<CaptureSetSwitcher sets={variants} active={variant} onSelect={onVariantSelect} />
|
||||
</div>
|
||||
@@ -117,6 +175,26 @@ export function Viewer({
|
||||
<CaptureContextMenu identifier={identifier} deepLink={deepLink} issueLink={issueLink}>
|
||||
<div className="viewer-image-wrap">
|
||||
<TerminalFrame frame={frame} label={`${screen.title}, ${variant.label}`} />
|
||||
{annotating ? (
|
||||
<AnnotationEditor
|
||||
cols={frame.cols}
|
||||
rows={frame.rows}
|
||||
annotations={annotations}
|
||||
onAdd={(row, column, note) => {
|
||||
if (annotations.length >= 24) return
|
||||
const annotation = { id: crypto.randomUUID(), row, column, note }
|
||||
setAnnotations([...annotations, annotation])
|
||||
}}
|
||||
onChange={(id, note) =>
|
||||
setAnnotations(annotations.map((annotation) => annotation.id === id ? { ...annotation, note } : annotation))
|
||||
}
|
||||
onDelete={(id) => setAnnotations(annotations.filter((annotation) => annotation.id !== id))}
|
||||
onDone={() => {
|
||||
setAnnotating(false)
|
||||
}}
|
||||
issueLink={annotationIssueLink}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
</CaptureContextMenu>
|
||||
<figcaption className="viewer-caption">
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Annotation, AnnotationDocument } from "./annotations"
|
||||
|
||||
interface FeedbackIssue {
|
||||
readonly title: string
|
||||
readonly identifier: string
|
||||
readonly deepLink: string
|
||||
readonly variant: string
|
||||
readonly annotations?: ReadonlyArray<Annotation>
|
||||
readonly document?: AnnotationDocument
|
||||
}
|
||||
|
||||
export function feedbackIssueUrl(issue: FeedbackIssue) {
|
||||
@@ -12,15 +16,32 @@ export function feedbackIssueUrl(issue: FeedbackIssue) {
|
||||
url.searchParams.set(
|
||||
"body",
|
||||
[
|
||||
"## Feedback",
|
||||
"",
|
||||
"<!-- What looks wrong, confusing, or could be improved? -->",
|
||||
...(issue.annotations?.length
|
||||
? issue.annotations.flatMap((annotation, index) => [
|
||||
`## ${index + 1}. Row ${annotation.row + 1}, column ${annotation.column + 1}`,
|
||||
"",
|
||||
annotation.note.trim(),
|
||||
"",
|
||||
])
|
||||
: ["## Feedback", "", "<!-- What looks wrong, confusing, or could be improved? -->", ""]),
|
||||
"",
|
||||
"## Catalog state",
|
||||
"",
|
||||
`- Screen: \`${issue.identifier}\``,
|
||||
`- Theme: \`${issue.variant}\``,
|
||||
`- Link: ${issue.deepLink}`,
|
||||
...(issue.document
|
||||
? [
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>Annotation data</summary>",
|
||||
"",
|
||||
"```json",
|
||||
JSON.stringify(issue.document, null, 2),
|
||||
"```",
|
||||
"</details>",
|
||||
]
|
||||
: []),
|
||||
].join("\n"),
|
||||
)
|
||||
return url.href
|
||||
|
||||
@@ -114,7 +114,7 @@ a {
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 1px solid var(--kit-accent);
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@@ -222,12 +222,17 @@ kbd {
|
||||
}
|
||||
|
||||
.catalog-tabs button:focus-visible,
|
||||
.viewer-button:focus-visible,
|
||||
.command-trigger:focus-visible {
|
||||
outline: 1px solid var(--catalog-accent);
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.viewer-button:focus-visible {
|
||||
outline: 0;
|
||||
background: var(--kit-bg-hover);
|
||||
color: var(--kit-fg-strong);
|
||||
}
|
||||
|
||||
.catalog-tools {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
@@ -692,20 +697,35 @@ kbd {
|
||||
}
|
||||
|
||||
.variant-switcher select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
opacity: 0;
|
||||
outline: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.variant-switcher .variant-chevron {
|
||||
.variant-switcher:has(select:focus-visible) {
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.variant-switcher .variant-hint {
|
||||
color: var(--kit-fg-faint);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.variant-switcher .variant-name {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.variant-switcher .variant-chevron {
|
||||
margin-left: -0.15rem;
|
||||
color: var(--kit-fg-faint);
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.capture-open:hover .capture-frame,
|
||||
@@ -718,7 +738,7 @@ kbd {
|
||||
}
|
||||
|
||||
.capture-open:focus-visible .capture-frame {
|
||||
outline: 1px solid var(--catalog-accent);
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline-offset: 0.3rem;
|
||||
}
|
||||
|
||||
@@ -746,6 +766,11 @@ kbd {
|
||||
letter-spacing: 0.08em;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.capture-actions > summary:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.capture-actions > summary::-webkit-details-marker {
|
||||
@@ -1071,7 +1096,7 @@ kbd {
|
||||
}
|
||||
|
||||
.flow-open:focus-visible .flow-frame {
|
||||
outline: 1px solid var(--catalog-accent);
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline-offset: 0.3rem;
|
||||
}
|
||||
|
||||
@@ -1160,7 +1185,7 @@ kbd {
|
||||
|
||||
.viewer-header > .viewer-button:first-child {
|
||||
justify-self: start;
|
||||
border-right: 1px solid var(--kit-line);
|
||||
padding-inline: 1.1rem;
|
||||
}
|
||||
|
||||
.viewer-position {
|
||||
@@ -1175,11 +1200,12 @@ kbd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.viewer-actions .capture-actions {
|
||||
align-self: center;
|
||||
margin-inline: 0.4rem;
|
||||
}
|
||||
|
||||
.viewer-button {
|
||||
@@ -1188,8 +1214,36 @@ kbd {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.viewer-actions .variant-switcher {
|
||||
border-left: 0;
|
||||
min-height: 1.9rem;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.viewer-button kbd {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--kit-fg-faint);
|
||||
}
|
||||
|
||||
.viewer-button-count {
|
||||
display: inline-grid;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0 0.28rem;
|
||||
border-radius: 999px;
|
||||
place-items: center;
|
||||
background: var(--catalog-mark);
|
||||
color: var(--catalog-mark-ink);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.viewer-actions .viewer-button {
|
||||
border-left: 1px solid var(--kit-line);
|
||||
align-self: center;
|
||||
min-height: 1.9rem;
|
||||
}
|
||||
|
||||
.viewer-body {
|
||||
@@ -1235,6 +1289,257 @@ kbd {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.annotation-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.annotation-pin,
|
||||
.annotation-list-pin {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid #17120a;
|
||||
border-radius: 999px;
|
||||
background: var(--catalog-mark);
|
||||
color: var(--catalog-mark-ink);
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.annotation-pin {
|
||||
position: absolute;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.annotation-pin:hover,
|
||||
.annotation-pin:focus-visible,
|
||||
.annotation-pin.selected {
|
||||
outline: 2px solid #fff2d8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.annotation-composer {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: grid;
|
||||
width: 18rem;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
transform: translate(-50%, 1.2rem);
|
||||
border-radius: 0.85rem;
|
||||
background: #1a1a1a;
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 55%), 0 0 0 1px rgb(255 255 255 / 9%);
|
||||
cursor: default;
|
||||
animation: annotation-composer-in 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.annotation-composer.above {
|
||||
transform: translate(-50%, calc(-100% - 1.2rem));
|
||||
}
|
||||
|
||||
.annotation-composer header,
|
||||
.annotation-composer footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.annotation-composer header {
|
||||
justify-content: space-between;
|
||||
color: var(--kit-fg-faint);
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.annotation-composer header small {
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.annotation-composer textarea {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
border: 1px solid rgb(255 255 255 / 14%);
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: var(--kit-fg-strong);
|
||||
font-family: var(--kit-sans);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.annotation-composer textarea:focus {
|
||||
border-color: var(--catalog-mark);
|
||||
}
|
||||
|
||||
.annotation-composer footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.annotation-composer footer button {
|
||||
min-height: 1.8rem;
|
||||
padding: 0 0.65rem;
|
||||
border-radius: 0.45rem;
|
||||
color: var(--kit-fg-muted);
|
||||
font-family: var(--kit-sans);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.annotation-composer footer > :first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.annotation-composer .annotation-composer-delete {
|
||||
color: #ff8585;
|
||||
}
|
||||
|
||||
.annotation-composer .annotation-composer-save {
|
||||
background: var(--catalog-mark);
|
||||
color: var(--catalog-mark-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.annotation-composer .annotation-composer-save:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@keyframes annotation-composer-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
scale: 0.96;
|
||||
}
|
||||
}
|
||||
|
||||
.annotation-panel {
|
||||
position: fixed;
|
||||
z-index: 4;
|
||||
top: 3rem;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: grid;
|
||||
width: min(22rem, 34vw);
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
border-left: 1px solid var(--kit-line-strong);
|
||||
background: #0b0b0b;
|
||||
box-shadow: -24px 0 64px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.annotation-panel > header,
|
||||
.annotation-panel > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.annotation-panel > header {
|
||||
border-bottom: 1px solid var(--kit-line);
|
||||
}
|
||||
|
||||
.annotation-panel > header div {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.annotation-panel strong,
|
||||
.annotation-panel > header button,
|
||||
.annotation-panel > footer button {
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.annotation-panel > header span,
|
||||
.annotation-panel > footer span,
|
||||
.annotation-list section > div > span {
|
||||
color: var(--kit-fg-faint);
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.annotation-panel > header button {
|
||||
padding: 0.4rem 0.55rem;
|
||||
color: var(--kit-fg-muted);
|
||||
}
|
||||
|
||||
.annotation-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.annotation-list-row {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 1.65rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-bottom: 1px solid var(--kit-line);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.annotation-list-pin {
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
}
|
||||
|
||||
.annotation-list-row > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.annotation-list-row small {
|
||||
color: var(--kit-fg-faint);
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.annotation-list-row strong {
|
||||
overflow: hidden;
|
||||
color: var(--kit-fg-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 450;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.annotation-list-row:hover,
|
||||
.annotation-list-row:focus-visible {
|
||||
background: rgb(255 255 255 / 3%);
|
||||
}
|
||||
|
||||
.annotation-panel > footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
border-top: 1px solid var(--kit-line);
|
||||
}
|
||||
|
||||
.annotation-issue {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 2.3rem;
|
||||
padding: 0 0.85rem;
|
||||
background: var(--catalog-mark);
|
||||
color: var(--catalog-mark-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.annotation-issue[aria-disabled="true"] {
|
||||
background: var(--kit-bg-hover);
|
||||
color: var(--kit-fg-faint);
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-variant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1494,6 +1799,25 @@ kbd {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.annotation-panel {
|
||||
top: auto;
|
||||
width: 100%;
|
||||
height: min(48dvh, 25rem);
|
||||
border-top: 1px solid var(--kit-line-strong);
|
||||
border-left: 0;
|
||||
box-shadow: 0 -24px 64px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.annotation-pin {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.annotation-composer textarea {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--catalog-header-height: 10.5rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user