): string {
+ const parts: string[] = [];
+ for (const [k, v] of Object.entries(data)) {
+ if (v === null || v === undefined || v === "") continue;
+ const s = typeof v === "object" ? JSON.stringify(v) : String(v);
+ if (s.length > 80) continue;
+ parts.push(`${k}: ${s}`);
+ if (parts.length >= 6) break;
+ }
+ return parts.join(" · ");
+}
diff --git a/packages/web/src/components/ActionList.tsx b/packages/web/src/components/ActionList.tsx
index 210d4f1..bbbd469 100644
--- a/packages/web/src/components/ActionList.tsx
+++ b/packages/web/src/components/ActionList.tsx
@@ -4,6 +4,8 @@ import type { ActionListRuntimeProps } from "@modernrelay/notebook-core";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
+import { useAnnotation } from "../annotation-context.js";
+import { AnnotationMarker } from "./AnnotationMarker.js";
interface ComponentCtx {
props: P;
@@ -51,6 +53,7 @@ export function ActionList({
props: p,
}: ComponentCtx): React.ReactElement {
const actions = useActions();
+ const annot = useAnnotation();
const statusMap =
useStateValue>(p.status_state ?? "/__never__") ?? {};
@@ -62,6 +65,8 @@ export function ActionList({
{p.rows.map((row, idx) => {
const id = String(row[p.id_column] ?? "");
+ const akey = id || `#${idx}`;
+ const n = annot.numberOf(akey);
const title = String(row[p.title_column] ?? "");
const body = p.body_column ? String(row[p.body_column] ?? "") : "";
const meta =
@@ -98,8 +103,20 @@ export function ActionList({
saving ? "border-info/40 bg-info/4" : "border-border bg-card",
)}
>
-
+
+ annot.annotate(
+ { key: akey, headline: title, data: row },
+ e,
+ ),
+ }
+ : {})}
+ >
+ {n !== null &&
}
{title}
{status && (
@@ -138,7 +155,7 @@ export function ActionList({
? "outline"
: (BUTTON_VARIANT[act.variant ?? "default"] ?? "outline")
}
- disabled={saving}
+ disabled={saving || annot.active}
aria-pressed={isCurrent}
onClick={() => fireAction(actions, act, row, id, p.runtime?.cell_id)}
>
diff --git a/packages/web/src/components/AnnotationMarker.tsx b/packages/web/src/components/AnnotationMarker.tsx
new file mode 100644
index 0000000..75c0f1a
--- /dev/null
+++ b/packages/web/src/components/AnnotationMarker.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { A } from "./annotation-style.js";
+
+/**
+ * A numbered circular pin (agentation's annotation marker) — 22px blue circle
+ * with the annotation's 1-based order in white.
+ */
+export function AnnotationMarker({ n }: { n: number }): React.ReactElement {
+ return (
+
+ {n}
+
+ );
+}
diff --git a/packages/web/src/components/AnnotationPopup.tsx b/packages/web/src/components/AnnotationPopup.tsx
new file mode 100644
index 0000000..a3cff58
--- /dev/null
+++ b/packages/web/src/components/AnnotationPopup.tsx
@@ -0,0 +1,152 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Trash2Icon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { AnnotationIntent } from "../annotations-store.js";
+import type { PendingAnnotation } from "../annotation-context.js";
+import { A } from "./annotation-style.js";
+
+const INTENTS: AnnotationIntent[] = ["fix", "change", "question", "approve"];
+
+/**
+ * Anchored note popup — a faithful port of agentation's annotation popup
+ * (#1a1a1a card, 16px radius, soft drop + 1px inner ring, single-line header,
+ * rows=2 textarea, accent-focus, delete pinned left, pill actions). Centered
+ * under the clicked entity. Parent keys this by entity id so state resets.
+ */
+export function AnnotationPopup({
+ pending,
+ onSave,
+ onRemove,
+ onClose,
+}: {
+ pending: PendingAnnotation;
+ onSave: (note: string, intent?: AnnotationIntent) => void;
+ onRemove: (id: string) => void;
+ onClose: () => void;
+}): React.ReactElement {
+ const [note, setNote] = useState(pending.existing?.note ?? "");
+ const [intent, setIntent] = useState(
+ pending.existing?.intent,
+ );
+ const [focused, setFocused] = useState(false);
+ const ref = useRef(null);
+ useEffect(() => {
+ ref.current?.focus();
+ }, []);
+
+ const save = (): void => onSave(note.trim(), intent);
+
+ // Centered under the entity (agentation anchors with translateX(-50%)).
+ const center = pending.rect.left + pending.rect.width / 2;
+ const left = Math.min(Math.max(center, 148), Math.max(148, window.innerWidth - 148));
+ const top = Math.min(pending.rect.bottom + 8, Math.max(8, window.innerHeight - 280));
+
+ return (
+ <>
+
+ e.stopPropagation()}
+ >
+ {/* Single-line header — the entity (agentation's `.element`). */}
+
+ {pending.draft.key}
+ {pending.draft.headline && pending.draft.headline !== pending.draft.key && (
+ {pending.draft.headline}
+ )}
+
+
+
+ >
+ );
+}
diff --git a/packages/web/src/components/AnnotationToolbar.tsx b/packages/web/src/components/AnnotationToolbar.tsx
new file mode 100644
index 0000000..33cd045
--- /dev/null
+++ b/packages/web/src/components/AnnotationToolbar.tsx
@@ -0,0 +1,225 @@
+import React, { useState } from "react";
+import {
+ CheckIcon,
+ ClipboardIcon,
+ ListIcon,
+ PencilIcon,
+ Trash2Icon,
+ XIcon,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { Annotation, AnnotationSession } from "../annotations-store.js";
+import { A } from "./annotation-style.js";
+
+/**
+ * Floating bottom-right control — a faithful port of agentation's toolbar:
+ * a collapsed 44px circle (annotate toggle) that expands to a pill of 34px
+ * circular control buttons with a count badge. An optional list panel opens
+ * above it (its "settings panel" position).
+ */
+export function AnnotationToolbar({
+ active,
+ onToggleActive,
+ session,
+ onInstruction,
+ onRemove,
+ onClear,
+ onCopy,
+}: {
+ active: boolean;
+ onToggleActive: (next: boolean) => void;
+ session: AnnotationSession;
+ onInstruction: (s: string) => void;
+ onRemove: (id: string) => void;
+ onClear: () => void;
+ onCopy: () => Promise;
+}): React.ReactElement {
+ const count = session.items.length;
+ const expanded = active || count > 0;
+ const [showList, setShowList] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const copy = (): void => {
+ void onCopy().then((ok) => {
+ if (!ok) return;
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ });
+ };
+
+ // Collapsed FAB: a 44px circle that turns annotate mode on.
+ if (!expanded) {
+ return (
+
+ );
+ }
+
+ // Numbered list grouped by cell (1-based global order).
+ const numberById = new Map();
+ session.items.forEach((a, i) => numberById.set(a.id, i + 1));
+ const groups: Array<[string, Annotation[]]> = [];
+ const byCell = new Map();
+ for (const a of session.items) {
+ let arr = byCell.get(a.cellId);
+ if (!arr) {
+ arr = [];
+ byCell.set(a.cellId, arr);
+ groups.push([a.cellId, arr]);
+ }
+ arr.push(a);
+ }
+
+ return (
+
+ {showList && (
+
+ )}
+
+
+ {count > 0 && (
+
+ {count}
+
+ )}
+
onToggleActive(!active)} title="Annotate">
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
setShowList((s) => !s)} title="Annotations">
+
+
+
+
+
+
+
+ );
+}
+
+/** A 34px circular toolbar control button (agentation's `.controlButton`). */
+function ControlButton({
+ children,
+ onClick,
+ title,
+ active = false,
+ danger = false,
+ disabled = false,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+ title: string;
+ active?: boolean;
+ danger?: boolean;
+ disabled?: boolean;
+}): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/Card.tsx b/packages/web/src/components/Card.tsx
index c0eae65..c03caf7 100644
--- a/packages/web/src/components/Card.tsx
+++ b/packages/web/src/components/Card.tsx
@@ -2,6 +2,9 @@ import React from "react";
import type { CardRuntimeProps } from "@modernrelay/notebook-core";
import { Badge } from "@/components/ui/badge";
import { CopyButton } from "@/components/ui/copy-button";
+import { cn } from "@/lib/utils";
+import { useAnnotation } from "../annotation-context.js";
+import { AnnotationMarker } from "./AnnotationMarker.js";
interface ComponentCtx {
props: P;
@@ -15,6 +18,7 @@ function fmt(v: unknown): string {
export function Card({
props: p,
}: ComponentCtx): React.ReactElement {
+ const annot = useAnnotation();
const row = p.rows[0];
if (!row) {
return (
@@ -34,11 +38,28 @@ export function Card({
badge: false,
}));
const title = p.title_column ? fmt(row[p.title_column]) : "";
+ const akey =
+ title || (fields[0] ? fmt(row[fields[0].key]) : "") || "card";
+ const n = annot.numberOf(akey);
// Frameless: the cell card is the only frame (no inner border/padding).
return (
-
+
+ annot.annotate(
+ { key: akey, headline: title || akey, data: row },
+ e,
+ ),
+ }
+ : {})}
+ >
{title && (
-
{title}
+
+ {n !== null && }
+ {title}
+
)}
{fields.map((f) => {
diff --git a/packages/web/src/components/Subgraph.tsx b/packages/web/src/components/Subgraph.tsx
index b7632e1..bcdb7ec 100644
--- a/packages/web/src/components/Subgraph.tsx
+++ b/packages/web/src/components/Subgraph.tsx
@@ -1,5 +1,8 @@
import React from "react";
import type { SubgraphRuntimeProps } from "@modernrelay/notebook-core";
+import { cn } from "@/lib/utils";
+import { useAnnotation } from "../annotation-context.js";
+import { AnnotationMarker } from "./AnnotationMarker.js";
interface ComponentCtx {
props: P;
@@ -15,6 +18,7 @@ export function Subgraph({
props: p,
}: ComponentCtx): React.ReactElement {
const { center, depth, rows } = p;
+ const annot = useAnnotation();
if (rows.length === 0) {
return (
(no neighborhood)
@@ -42,29 +46,55 @@ export function Subgraph({
{center.type} · depth {depth}
- {[...groups.values()].map((g) => (
-
-
- ● {g.centerLabel || g.centerId}
-
-
- {g.edges.map((e, idx) => {
- if (e.predicate === null && e.neighbor === null) return null;
- return (
- -
-
- ─{e.predicate ?? "?"}─▶
-
- {e.neighbor ?? "?"}
-
- );
- })}
-
-
- ))}
+ {[...groups.values()].map((g) => {
+ const n = annot.numberOf(g.centerId);
+ return (
+
+
+ annot.annotate(
+ {
+ key: g.centerId,
+ headline: g.centerLabel || g.centerId,
+ data: {
+ [center.id_column]: g.centerId,
+ [center.label_column]: g.centerLabel,
+ type: center.type,
+ },
+ },
+ e,
+ ),
+ }
+ : {})}
+ >
+ {n !== null ? : ●}
+ {g.centerLabel || g.centerId}
+
+
+ {g.edges.map((e, idx) => {
+ if (e.predicate === null && e.neighbor === null) return null;
+ return (
+ -
+
+ ─{e.predicate ?? "?"}─▶
+
+ {e.neighbor ?? "?"}
+
+ );
+ })}
+
+
+ );
+ })}
);
}
diff --git a/packages/web/src/components/Table.tsx b/packages/web/src/components/Table.tsx
index 58d3884..f43b863 100644
--- a/packages/web/src/components/Table.tsx
+++ b/packages/web/src/components/Table.tsx
@@ -12,6 +12,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { CopyButton } from "@/components/ui/copy-button";
import { cn } from "@/lib/utils";
+import { useAnnotation } from "../annotation-context.js";
+import { AnnotationMarker } from "./AnnotationMarker.js";
interface ComponentCtx
{
props: P;
@@ -52,6 +54,7 @@ export function Table({
}: ComponentCtx): React.ReactElement {
const { columns, rows, dense, select_state, select_column } = p;
const actions = useActions();
+ const annot = useAnnotation();
// Read the current selection so we can highlight the active row.
const selected = useStateValue(select_state ?? "/__never__");
@@ -59,11 +62,28 @@ export function Table({
return (no rows)
;
}
- // Rows are clickable only when the author opts in with both props.
+ // Rows are clickable for selection only when the author opts in with both
+ // props; in annotate mode every row is clickable to attach a note.
const selectable = Boolean(select_state && select_column);
const rowValue = (row: Record): string =>
select_column ? String(row[select_column] ?? "") : "";
+ // Annotation identity/headline: prefer the select column, else the first
+ // column, for the key; a title-ish column (or first) for the headline.
+ const firstCol = columns[0]?.key;
+ const titleCol =
+ columns.find((c) => /title|name|label/i.test(c.key))?.key ?? firstCol;
+ const annotKey = (row: Record, idx: number): string => {
+ const v = select_column
+ ? String(row[select_column] ?? "")
+ : firstCol
+ ? String(row[firstCol] ?? "")
+ : "";
+ return v || `#${idx}`;
+ };
+ const headlineOf = (row: Record): string =>
+ titleCol ? String(row[titleCol] ?? "") : "";
+
// Numeric columns right-align (with tabular figures); authors can override.
const alignByKey: Record = {};
for (const col of columns) {
@@ -88,24 +108,37 @@ export function Table({
{rows.map((row, idx) => {
const isSelected = selectable && rowValue(row) === selected;
+ const key = annotKey(row, idx);
+ const n = annot.numberOf(key);
+ const clickable = annot.active || selectable;
return (
- actions.execute({
- action: "setState",
- params: { statePath: select_state, value: rowValue(row) },
- }),
+ onClick: (e: React.MouseEvent) =>
+ annot.active
+ ? annot.annotate(
+ { key, headline: headlineOf(row), data: row },
+ e,
+ )
+ : actions.execute({
+ action: "setState",
+ params: {
+ statePath: select_state,
+ value: rowValue(row),
+ },
+ }),
}
: {})}
>
- {columns.map((col) => {
+ {columns.map((col, i) => {
const value = formatCell(row[col.key], col.format);
const content = col.wrap ? (
{value}
@@ -116,17 +149,17 @@ export function Table({
return (
+ {i === 0 && n !== null && (
+
+
+
+ )}
{col.badge ? (
value ? (
{value}
diff --git a/packages/web/src/components/annotation-style.ts b/packages/web/src/components/annotation-style.ts
new file mode 100644
index 0000000..410af31
--- /dev/null
+++ b/packages/web/src/components/annotation-style.ts
@@ -0,0 +1,21 @@
+/**
+ * agentation's visual language — a dark floating chrome that is its own surface
+ * regardless of the page theme (so it reads identically over a light or dark
+ * notebook). Hex/shadow values lifted from agentation's SCSS modules
+ * (annotation-popup-css, page-toolbar-css, annotation-marker).
+ */
+export const A = {
+ blue: "#3b82f6",
+ /** Popup/submit accent (agentation's default `accentColor`). */
+ accent: "#3c82f7",
+ green: "#22c55e",
+ red: "#ef4444",
+ surface: "#1a1a1a",
+ font: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
+ /** Popup/tooltip shadow — soft drop + 1px inner ring. */
+ shadow: "0 4px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.08)",
+ /** Toolbar/panel shadow. */
+ barShadow: "0 2px 8px rgba(0, 0, 0, 0.2), 0 4px 16px rgba(0, 0, 0, 0.1)",
+ /** Numbered marker shadow. */
+ markerShadow: "0 2px 6px rgba(0, 0, 0, 0.2), inset 0 0 0 1px rgba(0, 0, 0, 0.04)",
+} as const;