From 0776d9a8a428132d125d1b70a9edb2fc9a65324c Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Wed, 27 May 2026 14:01:42 -0700 Subject: [PATCH 1/5] Add AI telemetry/provenance and prerender SEO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce M1 AI surfaces and improve docs prerendering: - Add conversation-arc telemetry (src/components/ai/conversationArc.ts) — opt-in module-scoped ring buffer store with enable/disable, record/flush/getEvents, subscribe/unsubscribe, capacity handling and tests (conversationArc.test.ts). - Add annotation provenance/lifecycle type surface and helper (src/components/ai/annotationProvenance.ts) with accompanying tests to validate non-mutating withProvenance and documented shapes. - Add variant-discovery stubs and tests (src/components/ai/variantDiscovery.ts + variantDiscovery.test.ts) for future proposers/evaluators. - Enhance prerender tooling (scripts/prerender.mjs and its types) to support per-route ROUTE_META, blog OG card copying, blog-aware generatePage signature, meta injection anchored at (works with minified shells), sitemap.xml and robots.txt generation, and related tests updates for prerender behavior. - Update documentation (CLAUDE.md) to describe the new AI APIs: conversation arc, annotation provenance/lifecycle, and variant discovery. These changes add type-safe surfaces and test coverage for AI telemetry and provenance (M1), and improve SEO/static build correctness for blog and section pages. --- CLAUDE.md | 44 +++ scripts/prerender.d.mts | 18 +- scripts/prerender.mjs | 292 +++++++++++++++--- .../scenarios/docs-prerender.test.ts | 61 ++++ .../ai/annotationProvenance.test.ts | 106 +++++++ src/components/ai/annotationProvenance.ts | 139 +++++++++ src/components/ai/conversationArc.test.ts | 210 +++++++++++++ src/components/ai/conversationArc.ts | 289 +++++++++++++++++ src/components/ai/variantDiscovery.test.ts | 129 ++++++++ src/components/ai/variantDiscovery.ts | 235 ++++++++++++++ src/components/semiotic-ai.ts | 59 ++++ src/components/semiotic.ts | 13 + 12 files changed, 1550 insertions(+), 45 deletions(-) create mode 100644 src/components/ai/annotationProvenance.test.ts create mode 100644 src/components/ai/annotationProvenance.ts create mode 100644 src/components/ai/conversationArc.test.ts create mode 100644 src/components/ai/conversationArc.ts create mode 100644 src/components/ai/variantDiscovery.test.ts create mode 100644 src/components/ai/variantDiscovery.ts diff --git a/CLAUDE.md b/CLAUDE.md index b2ecabe7..2975ca16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,50 @@ function SuggestedChart({ data, intent }) { } ``` +### Conversation-arc telemetry (`semiotic/ai`) +Opt-in event store that records the arc of an AI-assisted session: `suggestion-shown → suggestion-chosen → audience-set → chart-rendered → chart-edited → chart-replaced → chart-exported | chart-abandoned`. Module-scoped, no React provider needed. Default surface is a no-op — call `enableConversationArc()` to start recording. +- **`enableConversationArc({ capacity?, sessionId? })`** → enables recording. Bounded ring buffer (default 1000 events). Safe to call multiple times; reuses the existing session unless `sessionId` is overridden. +- **`disableConversationArc()`** → stops recording without dropping buffered events. +- **`getConversationArcStore()`** → returns `{ enabled, sessionId, capacity, record(input), flush(), getEvents(), subscribe(listener), clear(), reset() }`. Methods are safe to call when disabled (no-op). +- **Events**: `ConversationArcEvent` discriminated union with `type`, `timestamp`, `sessionId`, optional `arcId` + `meta`. Each variant carries its own payload (e.g. `SuggestionShownEvent` has `components`, `intent`, `topScore`, `audience`). + +```ts +import { enableConversationArc, getConversationArcStore } from "semiotic/ai" + +enableConversationArc() +const store = getConversationArcStore() +const unsub = store.subscribe((event) => console.log(event.type, event)) +store.record({ type: "suggestion-shown", components: ["LineChart"], intent: "trend" }) +``` + +### Annotation provenance + lifecycle (`semiotic/ai`, types also re-exported from `semiotic`) +Type surface for "where did this annotation come from?" and "is it stale?" Optional blocks attached to any annotation — existing arrays keep working unchanged. +- **`provenance`**: `{ author?, source?, confidence?, created_at?, stable_id? }`. `source` is an open string union (`"user" | "ai" | "agent" | "import" | "computed" | "system" | (string & {})`). +- **`lifecycle`**: `{ freshness?, ttl_hint?, anchor? }`. `freshness` is `"fresh" | "aging" | "stale" | "expired"`. `anchor` is `"fixed" | "latest" | "sticky" | "semantic"`. `ttl_hint` accepts an ISO 8601 duration string (`"P30D"`) or milliseconds. +- **`withProvenance(annotation, { provenance?, lifecycle? })`** → returns a new annotation with the blocks attached. Pure, SSR-safe. +- **`Annotated`** type alias: `T & { provenance?, lifecycle? }`. Use for explicit typing. +- Type surface only at this stage. Freshness computation, default visual treatment, and stable-id anchor resolution land later. + +```ts +import { withProvenance } from "semiotic/ai" + +const ann = withProvenance( + { type: "y-threshold", value: 100, label: "SLA breach" }, + { + provenance: { author: "alice", source: "user", created_at: "2026-05-20T14:00:00Z" }, + lifecycle: { ttl_hint: "P30D", anchor: "semantic" }, + }, +) +``` + +### Variant discovery (`semiotic/ai`) +Interface for proposing and scoring chart variants beyond the hand-curated `capability.variants`. Heuristic and model-based proposers plug in through `registerVariantDiscovery`. M1 ships the type surface + stub implementations; behavior arrives in subsequent milestones. +- **`VariantProposal`**: `{ id, base_component, intent_deltas?, rubric_deltas?, buildProps?, rationale?, source: "manual" | "heuristic" | "model", variant_key?, tags? }`. +- **`VariantScore`**: `{ proposal_id, fit (0–5), novelty (0–1), risk (0–1), reasons }`. `fit` mixes with `suggestCharts` composite scores in unified rankings. +- **`proposeVariant(component, capability, context)`** → `VariantProposal[]`. M1 stub returns `[]`. +- **`evaluateVariantProposal(proposal, profile, audience?)`** → `VariantScore`. M1 stub returns a neutral baseline with a reason pointing back at the design doc. +- **`registerVariantDiscovery(fn)`** → registers an external proposer, returns an unregister callback. Pair with `getRegisteredVariantDiscovery()` / `clearVariantDiscovery()` for inspection and teardown. +- Full design + sequencing in `docs/strategy/variant-discovery.md`. ## AI Behavior Contracts diff --git a/scripts/prerender.d.mts b/scripts/prerender.d.mts index 88924bee..0097aae1 100644 --- a/scripts/prerender.d.mts +++ b/scripts/prerender.d.mts @@ -2,6 +2,22 @@ export function extractRoutesFromSource(source: string): string[] export function copyDocsApiAssets(publicApiDir?: string, buildDir?: string): string[] -export function generatePage(shellHtml: string, routePath: string): string +export function copyBlogOgCards(publicOgDir?: string, buildDir?: string): string[] + +export interface BlogEntryMeta { + slug: string + title: string + subtitle?: string + excerpt?: string + author: string + date: string + tags?: string[] +} + +export function generatePage( + shellHtml: string, + routePath: string, + blogMeta?: BlogEntryMeta | null +): string export function prerender(): void diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 2d7233be..c78f9f7e 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -21,7 +21,117 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const BUILD_DIR = resolve(__dirname, "../docs/build") const APP_SRC = resolve(__dirname, "../docs/src/App.js") const PUBLIC_API_DIR = resolve(__dirname, "../docs/public/api") +const PUBLIC_BLOG_OG_DIR = resolve(__dirname, "../docs/public/blog/og") const SITE_URL = "https://semiotic3.nteract.io" +const DEFAULT_OG_IMAGE = `${SITE_URL}/assets/img/semiotic-social.png` + +// Per-route SEO metadata. Keys are route paths exactly as extracted from +// App.js (no leading slash, "" for the landing page). Routes not listed +// here inherit the shell's generic description/og tags. Listing top-level +// sections meaningfully helps indexing — every chart page should not ship +// the same description as the landing page. +const ROUTE_META = { + "": { + title: "Semiotic — Data Visualization for React", + description: + "Semiotic is a React data visualization framework. Build interactive charts, network diagrams, geo maps, and streaming visualizations from simple, composable components.", + }, + "getting-started": { + title: "Getting Started — Semiotic", + description: + "Install Semiotic and ship your first chart. Sub-path imports, the HOC chart catalog, and the streaming Frame escape hatch.", + }, + charts: { + title: "Charts — Semiotic", + description: + "The Semiotic chart catalog: 45+ HOC charts spanning XY, ordinal, network, geo, hierarchy, and realtime families. Browse by family with live demos and copy-paste examples.", + }, + features: { + title: "Features — Semiotic", + description: + "Semiotic features: streaming push API, animated transitions, accessibility, annotations, coordinated views, themed CSS variables, SSR, and AI-facing tooling.", + }, + theming: { + title: "Theming — Semiotic", + description: + "Theme Semiotic with named presets (tufte, dark, bi-tool, journalist, …), CSS custom properties, and a scoped cascade override that flows through every chart.", + }, + "theming/styling": { + title: "Styling Primitives — Semiotic Theming", + description: + "Top-level color, stroke, strokeWidth, opacity, and gradient props on every Semiotic HOC. Precedence cascade, CSS variable overrides, and per-datum style functions.", + }, + "theming/theme-provider": { + title: "ThemeProvider — Semiotic Theming", + description: + "Wrap charts in ThemeProvider to apply a named preset or custom theme object. Categorical palettes, semantic status roles, fonts, and CSS-variable emission.", + }, + "theming/semantic-colors": { + title: "Semantic Colors — Semiotic Theming", + description: + "Use --semiotic-success, --semiotic-danger, --semiotic-warning, --semiotic-info and other semantic role tokens to drive status-aware visualizations.", + }, + "theming/theme-explorer": { + title: "Theme Explorer — Semiotic", + description: + "Preview every Semiotic theme preset across the chart catalog. Swap themes live, compare palettes, export tokens.", + }, + playground: { + title: "Playground — Semiotic", + description: + "Edit Semiotic chart props live in the browser. Round-trip JSON config, deep-link via URL, and copy generated JSX for any chart in the catalog.", + }, + blog: { + title: "Blog — Semiotic", + description: + "Release notes, chart explainers, and case studies from the Semiotic data visualization library.", + }, + api: { + title: "API Reference — Semiotic", + description: + "Generated TypeDoc API surface for Semiotic: every component, hook, frame, and helper exported from the library and its sub-path entry points.", + }, + "api/charts": { + title: "Chart Components API — Semiotic", + description: + "API reference for every Semiotic HOC chart: props, accessors, frameProps, and streaming ref methods.", + }, + "api/typedoc": { + title: "TypeDoc — Semiotic API Reference", + description: + "Full TypeDoc index for Semiotic — modules, classes, interfaces, types, and re-exports.", + }, + cookbook: { + title: "Cookbook — Semiotic", + description: + "Recipes for non-catalog charts: marginal graphics, slope graphs, marimekko, ridgelines, swarm plots, isotype charts, custom timelines, and more.", + }, + recipes: { + title: "Recipes — Semiotic", + description: + "Composed Semiotic patterns — KPI cards with sparklines, network explorers, streaming migration maps, benchmark dashboards, and other reusable dashboard primitives.", + }, + migration: { + title: "Migration Guide — Semiotic", + description: + "Upgrade to Semiotic 3.x. Removed APIs, replacement HOC charts, sub-path imports, and the new streaming-first runtime.", + }, + "frames/xy-frame": { + title: "XYFrame — Semiotic", + description: + "XYFrame is the low-level Cartesian rendering frame underlying every XY HOC chart. Use it when you need control the HOC abstractions don't expose.", + }, + "frames/ordinal-frame": { + title: "OrdinalFrame — Semiotic", + description: + "OrdinalFrame is the low-level rendering frame for category-by-value charts. Underlies BarChart, PieChart, BoxPlot, Histogram, and the rest of the ordinal family.", + }, + "frames/network-frame": { + title: "NetworkFrame — Semiotic", + description: + "NetworkFrame is the low-level rendering frame for graph, hierarchy, and sankey-style visualizations. Underlies ForceDirectedGraph, SankeyDiagram, Treemap, and others.", + }, +} // ── Extract routes from App.js ────────────────────────────────────────── @@ -126,6 +236,26 @@ export function copyDocsApiAssets(publicApiDir = PUBLIC_API_DIR, buildDir = BUIL return copied } +// Copy the rendered blog OG cards (docs/public/blog/og/*.png) into the +// static build. Parcel only bundles files referenced by the HTML/JS at +// build time, so these per-entry images otherwise never make it into +// dist — and the og:image meta tags would 404. +export function copyBlogOgCards(publicOgDir = PUBLIC_BLOG_OG_DIR, buildDir = BUILD_DIR) { + if (!existsSync(publicOgDir)) return [] + + const outDir = resolve(buildDir, "blog", "og") + mkdirSync(outDir, { recursive: true }) + + const copied = [] + for (const fileName of readdirSync(publicOgDir).sort()) { + if (!fileName.endsWith(".png")) continue + copyFileSync(resolve(publicOgDir, fileName), resolve(outDir, fileName)) + copied.push(`blog/og/${fileName}`) + } + + return copied +} + // ── Blog metadata loader ─────────────────────────────────────────────── // // Reads docs/src/blog/entries-meta.js for the slug list + per-entry @@ -147,18 +277,29 @@ async function loadBlogEntries() { // ── Generate pre-rendered HTML for a route ────────────────────────────── +const escHtml = (s) => + String(s).replace(/&/g, "&").replace(/"/g, """).replace(//g, ">") + export function generatePage(shellHtml, routePath, blogMeta = null) { - const title = routePath + const slugTitle = routePath .split("/") .filter(Boolean) .map(s => s.split("-").map(w => w[0].toUpperCase() + w.slice(1)).join(" ")) .join(" \u2014 ") - // For blog entry routes, prefer the entry's own title + subtitle - // over the slug-derived heuristic. + // Three sources of page meta (in priority order): + // 1. blogMeta \u2014 passed by main() for blog/:slug routes + // 2. ROUTE_META[routePath] \u2014 hand-curated per-section copy + // 3. slug-cased fallback title, shell-inherited description + const routeMeta = !blogMeta && Object.prototype.hasOwnProperty.call(ROUTE_META, routePath) + ? ROUTE_META[routePath] + : null + const fullTitle = blogMeta?.title ? `${blogMeta.title} \u2014 Semiotic Blog` - : (title ? `${title} \u2014 Semiotic` : "Semiotic \u2014 Data Visualization for React") + : routeMeta?.title + ? routeMeta.title + : (slugTitle ? `${slugTitle} \u2014 Semiotic` : "Semiotic \u2014 Data Visualization for React") const navHtml = `