diff --git a/.clinerules b/.clinerules index b2ecabe7..9cc3c67a 100644 --- a/.clinerules +++ b/.clinerules @@ -325,6 +325,49 @@ 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?, createdAt?, stableId? }`. `source` is an open string union (`"user" | "ai" | "agent" | "import" | "computed" | "system" | (string & {})`). +- **`lifecycle`**: `{ freshness?, ttlHint?, anchor? }`. `freshness` is `"fresh" | "aging" | "stale" | "expired"`. `anchor` is `"fixed" | "latest" | "sticky" | "semantic"`. `ttlHint` 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", createdAt: "2026-05-20T14:00:00Z" }, + lifecycle: { ttlHint: "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, baseComponent, intentDeltas?, rubricDeltas?, buildProps?, rationale?, source: "manual" | "heuristic" | "model", variantKey?, tags? }`. +- **`VariantScore`**: `{ proposalId, 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. `proposeVariant` dispatches through every registered function and deduplicates by `proposal.id`. Returns an unregister callback. Pair with `getRegisteredVariantDiscovery()` / `clearVariantDiscovery()` for inspection and teardown. ## AI Behavior Contracts diff --git a/.cursorrules b/.cursorrules index b2ecabe7..9cc3c67a 100644 --- a/.cursorrules +++ b/.cursorrules @@ -325,6 +325,49 @@ 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?, createdAt?, stableId? }`. `source` is an open string union (`"user" | "ai" | "agent" | "import" | "computed" | "system" | (string & {})`). +- **`lifecycle`**: `{ freshness?, ttlHint?, anchor? }`. `freshness` is `"fresh" | "aging" | "stale" | "expired"`. `anchor` is `"fixed" | "latest" | "sticky" | "semantic"`. `ttlHint` 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", createdAt: "2026-05-20T14:00:00Z" }, + lifecycle: { ttlHint: "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, baseComponent, intentDeltas?, rubricDeltas?, buildProps?, rationale?, source: "manual" | "heuristic" | "model", variantKey?, tags? }`. +- **`VariantScore`**: `{ proposalId, 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. `proposeVariant` dispatches through every registered function and deduplicates by `proposal.id`. Returns an unregister callback. Pair with `getRegisteredVariantDiscovery()` / `clearVariantDiscovery()` for inspection and teardown. ## AI Behavior Contracts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b2ecabe7..9cc3c67a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -325,6 +325,49 @@ 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?, createdAt?, stableId? }`. `source` is an open string union (`"user" | "ai" | "agent" | "import" | "computed" | "system" | (string & {})`). +- **`lifecycle`**: `{ freshness?, ttlHint?, anchor? }`. `freshness` is `"fresh" | "aging" | "stale" | "expired"`. `anchor` is `"fixed" | "latest" | "sticky" | "semantic"`. `ttlHint` 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", createdAt: "2026-05-20T14:00:00Z" }, + lifecycle: { ttlHint: "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, baseComponent, intentDeltas?, rubricDeltas?, buildProps?, rationale?, source: "manual" | "heuristic" | "model", variantKey?, tags? }`. +- **`VariantScore`**: `{ proposalId, 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. `proposeVariant` dispatches through every registered function and deduplicates by `proposal.id`. Returns an unregister callback. Pair with `getRegisteredVariantDiscovery()` / `clearVariantDiscovery()` for inspection and teardown. ## AI Behavior Contracts diff --git a/.windsurfrules b/.windsurfrules index b2ecabe7..9cc3c67a 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -325,6 +325,49 @@ 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?, createdAt?, stableId? }`. `source` is an open string union (`"user" | "ai" | "agent" | "import" | "computed" | "system" | (string & {})`). +- **`lifecycle`**: `{ freshness?, ttlHint?, anchor? }`. `freshness` is `"fresh" | "aging" | "stale" | "expired"`. `anchor` is `"fixed" | "latest" | "sticky" | "semantic"`. `ttlHint` 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", createdAt: "2026-05-20T14:00:00Z" }, + lifecycle: { ttlHint: "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, baseComponent, intentDeltas?, rubricDeltas?, buildProps?, rationale?, source: "manual" | "heuristic" | "model", variantKey?, tags? }`. +- **`VariantScore`**: `{ proposalId, 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. `proposeVariant` dispatches through every registered function and deduplicates by `proposal.id`. Returns an unregister callback. Pair with `getRegisteredVariantDiscovery()` / `clearVariantDiscovery()` for inspection and teardown. ## AI Behavior Contracts diff --git a/CLAUDE.md b/CLAUDE.md index b2ecabe7..9cc3c67a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,49 @@ 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?, createdAt?, stableId? }`. `source` is an open string union (`"user" | "ai" | "agent" | "import" | "computed" | "system" | (string & {})`). +- **`lifecycle`**: `{ freshness?, ttlHint?, anchor? }`. `freshness` is `"fresh" | "aging" | "stale" | "expired"`. `anchor` is `"fixed" | "latest" | "sticky" | "semantic"`. `ttlHint` 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", createdAt: "2026-05-20T14:00:00Z" }, + lifecycle: { ttlHint: "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, baseComponent, intentDeltas?, rubricDeltas?, buildProps?, rationale?, source: "manual" | "heuristic" | "model", variantKey?, tags? }`. +- **`VariantScore`**: `{ proposalId, 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. `proposeVariant` dispatches through every registered function and deduplicates by `proposal.id`. Returns an unregister callback. Pair with `getRegisteredVariantDiscovery()` / `clearVariantDiscovery()` for inspection and teardown. ## AI Behavior Contracts diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index b2ecabe7..9cc3c67a 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -325,6 +325,49 @@ 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?, createdAt?, stableId? }`. `source` is an open string union (`"user" | "ai" | "agent" | "import" | "computed" | "system" | (string & {})`). +- **`lifecycle`**: `{ freshness?, ttlHint?, anchor? }`. `freshness` is `"fresh" | "aging" | "stale" | "expired"`. `anchor` is `"fixed" | "latest" | "sticky" | "semantic"`. `ttlHint` 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", createdAt: "2026-05-20T14:00:00Z" }, + lifecycle: { ttlHint: "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, baseComponent, intentDeltas?, rubricDeltas?, buildProps?, rationale?, source: "manual" | "heuristic" | "model", variantKey?, tags? }`. +- **`VariantScore`**: `{ proposalId, 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. `proposeVariant` dispatches through every registered function and deduplicates by `proposal.id`. Returns an unregister callback. Pair with `getRegisteredVariantDiscovery()` / `clearVariantDiscovery()` for inspection and teardown. ## AI Behavior Contracts diff --git a/docs/src/App.js b/docs/src/App.js index 5a7ef6e3..d6acc6f9 100644 --- a/docs/src/App.js +++ b/docs/src/App.js @@ -96,6 +96,7 @@ import CustomChartsPage from "./pages/features/CustomChartsPage" import CapabilitiesPage from "./pages/features/CapabilitiesPage" import InterrogationPage from "./pages/features/InterrogationPage" import SuggestionsPage from "./pages/features/SuggestionsPage" +import ConversationArcPage from "./pages/features/ConversationArcPage" // New cookbook pages import HomerunMapPage from "./pages/cookbook/HomerunMapPage" @@ -399,6 +400,7 @@ export default function DocsApp() { } /> } /> } /> + } /> } /> } /> diff --git a/docs/src/blog/entries-meta.js b/docs/src/blog/entries-meta.js index 9e33d2f8..7c2a888d 100644 --- a/docs/src/blog/entries-meta.js +++ b/docs/src/blog/entries-meta.js @@ -30,6 +30,18 @@ export const allBlogEntriesMeta = [ excerpt: "3.6.0 turns Semiotic's observation hooks, native annotations, and streaming runtime into an explicit AI-facing surface. Charts declare what they're for; datasets get profiled and ranked; audiences get calibrated; conversations anchor back to the chart instead of stopping at a chat bubble. Three case-study posts published alongside the release walk through what the new shape makes possible.", }, + { + slug: "talk-track-intelligence", + title: "The arc, the annotation, and the variant", + subtitle: + "Three composable AI surfaces shipping together in 3.5.x: conversation-arc telemetry, annotation provenance + lifecycle, and a variant discovery plug point. Two are runnable inline.", + author: "Elijah Meeks", + date: "2026-05-27", + tags: ["case-study", "ai", "roadmap"], + excerpt: + "AI-assisted chart authoring is a session, not a single call. Semiotic 3.5.x lands the spine for treating that session as a first-class thing — an event vocabulary for the arc itself, provenance + lifecycle on every annotation, and an extension surface for variant proposers. Interactive demos for the first two.", + draft: true, + }, { slug: "live-conversational-dashboard", title: "Live conversational dashboards", diff --git a/docs/src/blog/entries.js b/docs/src/blog/entries.js index 548ea919..6acdb1b0 100644 --- a/docs/src/blog/entries.js +++ b/docs/src/blog/entries.js @@ -52,6 +52,7 @@ import MultimodalResponse from "./entries/multimodal-response.js" import AnchoredConversations from "./entries/anchored-conversations.js" import LiveDashboard from "./entries/live-conversational-dashboard.js" import Release360 from "./entries/release-3-6-0.js" +import TalkTrackIntelligence from "./entries/talk-track-intelligence.js" /** * Every entry, drafts included. Consumers that need the full list (direct @@ -60,6 +61,7 @@ import Release360 from "./entries/release-3-6-0.js" */ export const allBlogEntries = [ Release360, + TalkTrackIntelligence, LiveDashboard, AnchoredConversations, MultimodalResponse, diff --git a/docs/src/blog/entries/talk-track-intelligence.js b/docs/src/blog/entries/talk-track-intelligence.js new file mode 100644 index 00000000..be20de06 --- /dev/null +++ b/docs/src/blog/entries/talk-track-intelligence.js @@ -0,0 +1,565 @@ +/* eslint-disable react/no-unescaped-entities */ +import React, { useEffect, useMemo, useRef, useState } from "react" +import { Link } from "react-router-dom" +import { CategoryColorProvider, DotPlot, LineChart } from "semiotic" +import { + disableConversationArc, + enableConversationArc, + getConversationArcStore, + withProvenance, +} from "semiotic/ai" + +// Entry metadata defined up here (not inline on the default export) so +// `scripts/check-blog-entry-sync.mjs` — which reads files as raw source +// and matches the FIRST `title:`/`author:`/etc. literal — sees the +// canonical strings before any provenance.author or note.title that +// appears later in the demo data. +const META = { + slug: "talk-track-intelligence", + title: "The arc, the annotation, and the variant", + subtitle: + "Three composable AI surfaces shipping together in 3.5.x: conversation-arc telemetry, annotation provenance + lifecycle, and a variant discovery plug point. Two are runnable inline.", + author: "Elijah Meeks", + date: "2026-05-27", + tags: ["case-study", "ai", "roadmap"], + excerpt: + "AI-assisted chart authoring is a session, not a single call. Semiotic 3.5.x lands the spine for treating that session as a first-class thing — an event vocabulary for the arc itself, provenance + lifecycle on every annotation, and an extension surface for variant proposers. Interactive demos for the first two.", +} + +// ─── Shared layout chrome (mirrors other blog entries) ──────────────────── +const card = { + background: "var(--surface-1)", + borderRadius: 10, + padding: 18, + border: "1px solid var(--surface-3)", + margin: "20px 0", +} + +const inlineCode = { + fontFamily: "var(--semiotic-font-family-mono, ui-monospace, monospace)", + fontSize: "0.9em", +} + +const tag = (color) => ({ + display: "inline-block", + background: color, + color: "white", + fontSize: 11, + fontWeight: 600, + padding: "2px 8px", + borderRadius: 999, + marginRight: 6, +}) + +// ─── Conversation-arc live demo (driven by the actual store) ────────────── + +const PRESET_EVENTS = [ + { label: "Show suggestions", payload: { type: "suggestion-shown", components: ["LineChart", "AreaChart"], intent: "trend" } }, + { label: "Pick LineChart", payload: { type: "suggestion-chosen", component: "LineChart", rank: 1, source: "user" } }, + { label: "Switch audience", payload: { type: "audience-set", audience: "executive", previous: "analyst" } }, + { label: "Render", payload: { type: "chart-rendered", component: "LineChart", chartId: "arc-demo" } }, + { label: "Edit props", payload: { type: "chart-edited", component: "LineChart", changedProps: ["lineWidth"] } }, + { label: "Replace via repair", payload: { type: "chart-replaced", from: "LineChart", to: "AreaChart", reason: "repair" } }, + { label: "Export JSX", payload: { type: "chart-exported", component: "AreaChart", format: "jsx" } }, + { label: "Abandon", payload: { type: "chart-abandoned", reason: "user-walked-away" } }, +] + +const TYPE_COLOR = { + "suggestion-shown": "#3a8eff", + "suggestion-chosen": "#3a8eff", + "audience-set": "#d49a00", + "chart-rendered": "#2d8a4a", + "chart-edited": "#2d8a4a", + "chart-replaced": "#d49a00", + "chart-exported": "#6a52d9", + "chart-abandoned": "#c43d3d", +} + +const timeFormat = (ms) => { + const d = new Date(ms) + return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }) +} + +function ConversationArcLiveDemo() { + const store = useMemo(() => getConversationArcStore(), []) + const chartRef = useRef(null) + const dotIdRef = useRef(0) + const [enabled, setEnabled] = useState(store.enabled) + const [events, setEvents] = useState(() => store.getEvents()) + + useEffect(() => { + return store.subscribe((event) => { + setEvents(store.getEvents()) + chartRef.current?.push({ + id: ++dotIdRef.current, + type: event.type, + time: event.timestamp, + }) + }) + }, [store]) + + useEffect(() => () => { + // Don't `reset()` — that would wipe listeners other parts of the + // app might have set up. Just stop recording and drop the buffer. + disableConversationArc() + store.clear() + }, [store]) + + const toggle = () => { + if (enabled) { + disableConversationArc() + } else { + enableConversationArc({ capacity: 50, sessionId: "blog-demo" }) + } + setEnabled(getConversationArcStore().enabled) + } + + return ( +
+
+ + + {events.length} events buffered + +
+ +
+ {PRESET_EVENTS.map((p) => ( + + ))} +
+ + + + {enabled ? "Click an event button — dots arrive via push API." : "Enable recording, then click buttons."} +
+ } + /> + + +
+ {events.length === 0 ? ( + + {enabled ? "Click an event button above." : "Recording is off."} + + ) : ( + events.slice().reverse().map((e, i) => ( +
+ {e.type} + + {JSON.stringify(Object.fromEntries(Object.entries(e).filter(([k]) => !["type", "timestamp", "sessionId"].includes(k))))} + +
+ )) + )} +
+ + ) +} + +// ─── Annotation provenance freshness scrubber ───────────────────────────── + +// Hand-tuned spikes so annotations sit on visible peaks rather than +// getting lost in noise. +const STALE_DEMO_DATA = [ + { month: 1, value: 280 }, + { month: 2, value: 310 }, + { month: 3, value: 420 }, // alice's spike + { month: 4, value: 350 }, + { month: 5, value: 360 }, + { month: 6, value: 370 }, + { month: 7, value: 510 }, // AI's anomaly + { month: 8, value: 390 }, + { month: 9, value: 400 }, + { month: 10, value: 420 }, + { month: 11, value: 450 }, + { month: 12, value: 470 }, +] + +// Field names match the chart's accessors (`month`/`value`). That's how +// the annotation layer resolves data → screen coordinates. +const RAW_ANNOTATIONS = [ + withProvenance( + { + type: "callout", + id: "alice-spike", + month: 3, + value: 420, + label: "Hand-placed spike", + note: "Marked when the product launched.", + dx: 50, + dy: -45, + }, + { + provenance: { author: "alice", source: "user", createdAt: "2026-02-15T12:00:00Z" }, + lifecycle: { ttlHint: "P30D", anchor: "semantic" }, + } + ), + withProvenance( + { + type: "callout", + id: "ai-anomaly", + month: 7, + value: 510, + label: "AI anomaly tag", + note: "Flagged by model-v3 (confidence 0.62).", + dx: -55, + dy: -45, + }, + { + provenance: { author: "model-v3", source: "ai", confidence: 0.62, createdAt: "2026-03-10T09:00:00Z" }, + lifecycle: { ttlHint: "P14D", anchor: "fixed" }, + } + ), +] + +const FRESHNESS_BASE_COLOR = { user: "#3a8eff", ai: "#d49a00" } +const FRESHNESS_COLOR = { + fresh: (base) => base, + aging: () => "#8a96a3", + stale: () => "#b0b0b0", +} +const FRESHNESS_SUFFIX = { fresh: "", aging: " · aging", stale: " · stale" } +const FRESHNESS_BADGE = { + fresh: "#2d8a4a", + aging: "#d49a00", + stale: "#a0a0a0", + expired: "#c43d3d", +} + +function parseIsoDuration(s) { + const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?)?$/.exec(s) + if (!m) return 0 + return (parseInt(m[1] || "0", 10) * 24 + parseInt(m[2] || "0", 10)) * 60 * 60 * 1000 +} + +function previewFreshness(ann, nowMs) { + const created = ann?.provenance?.createdAt ? Date.parse(ann.provenance.createdAt) : null + const ttl = ann?.lifecycle?.ttlHint + if (created == null || ttl == null) return "fresh" + const ms = typeof ttl === "number" ? ttl : parseIsoDuration(ttl) + const age = nowMs - created + if (age < ms) return "fresh" + if (age < ms * 1.5) return "aging" + if (age < ms * 3) return "stale" + return "expired" +} + +function FreshnessLiveDemo() { + const [nowIso, setNowIso] = useState("2026-03-10T00:00:00Z") + const nowMs = Date.parse(nowIso) + + const states = RAW_ANNOTATIONS.map((a) => ({ + raw: a, + freshness: previewFreshness(a, nowMs), + })) + + // Expired annotations drop out of the array — mirrors M2's default + // of hiding them unless `showExpiredAnnotations` is on. + const visible = states + .filter((s) => s.freshness !== "expired") + .map(({ raw, freshness }) => { + const base = FRESHNESS_BASE_COLOR[raw.provenance.source] ?? "#5a5a5a" + return { + ...raw, + label: raw.label + FRESHNESS_SUFFIX[freshness], + color: FRESHNESS_COLOR[freshness](base), + lifecycle: { ...raw.lifecycle, freshness }, + } + }) + + return ( +
+ +
+ + setNowIso(new Date(parseInt(e.target.value, 10)).toISOString())} + style={{ width: "100%" }} + /> +
+ {states.map(({ raw, freshness }) => { + const ageDays = Math.max( + 0, + Math.floor((nowMs - Date.parse(raw.provenance.createdAt)) / (24 * 60 * 60 * 1000)) + ) + return ( +
+
+ {raw.label} + {freshness} +
+
+ by {raw.provenance.author} + {" · "}{ageDays} day{ageDays === 1 ? "" : "s"} old + {" · "}TTL {raw.lifecycle.ttlHint} +
+
+ ) + })} +
+
+
+ ) +} + +// ─── Body ───────────────────────────────────────────────────────────────── + +function Body() { + return ( +
+

+ draft + 3.5.x +

+ +

+ AI-assisted chart authoring is a session. A user sees a ranked + list, picks one, adjusts the audience, renders, edits, replaces, + exports — or abandons. None of those moves are first-class in any + visualization library we know of. They live in chat transcripts + and analytics events, separated from the chart that occasioned + them. +

+ +

+ Semiotic 3.5.x lands the spine for treating that session as a + thing the library knows about. Three composable surfaces, each + shipping as a type contract today, with runtime helpers + sequenced through the rest of the year. This post walks through + what each one is, with two of them runnable inline below. +

+ +

The arc itself

+ +

+ The first surface is a module-scoped event store with eight + variants in a discriminated union: +

+ +
{`type ConversationArcEvent =
+  | { type: "suggestion-shown", components, intent?, topScore?, audience? }
+  | { type: "suggestion-chosen", component, rank?, source? }
+  | { type: "audience-set", audience, previous? }
+  | { type: "chart-rendered", component, chartId? }
+  | { type: "chart-edited", component, chartId?, changedProps? }
+  | { type: "chart-replaced", from, to, reason? }
+  | { type: "chart-exported", component, format }
+  | { type: "chart-abandoned", component?, reason? }`}
+ +

+ Default surface is a no-op. enableConversationArc(){" "} + flips the store on and starts buffering. The default capacity is + 1000; new events evict oldest. Subscribers see every event as it + lands. There are no network sinks yet — those plug in through{" "} + subscribe() for now, with first-party{" "} + LocalStorageSink,{" "} + IndexedDBSink, and{" "} + WebhookSink in the next milestone. +

+ +

+ Enable it below and click the event buttons. The log is wired to + a real subscriber on the real store: +

+ + + +

+ Why bother? Because the data nobody else is collecting is the arc + itself: I saw five charts, picked the second, swapped the + audience to "executive," edited two props, exported as JSX, and + came back the next day to edit it again. That's a sequence, + not a single event. Capturing it is what makes a serious + recommender feedback loop possible — not just "did the user click + on the suggestion" but "did they keep it after using it." +

+ +

Anchored notes that know how old they are

+ +

+ The second surface is two optional blocks on any annotation:{" "} + provenance (author, source, + confidence, createdAt, stableId) and{" "} + lifecycle (freshness, ttlHint, + anchor). Both are additive — existing{" "} + annotations arrays keep working. +

+ +

+ The lifecycle answer is the Q&A backstop: when the data refreshes + and the annotation's reference point shifts, what happens? The + anchor modes spell out the four reasonable answers:{" "} + fixed keeps the recorded + coordinate, latest tracks the + most recent point, sticky rides + along until removed, and semantic{" "} + re-resolves through stableId when + new data arrives. +

+ +

+ Freshness handles the orthogonal problem: a stale note shouldn't + look as authoritative as a fresh one. The chart below has two + annotations with different TTLs. Drag "now" forward and watch + them fade through fresh → aging → stale → expired: +

+ + + +

+ The styling above is page-local — the M1 surface is type-only, + and the shipping computeAnnotationFreshness{" "} + helper plus the default visual treatment land next. +

+ +

Variants the library didn't think of

+ +

+ The third surface is a registration plug point for variant + proposers. Today, every chart variant in Semiotic was hand-curated + in a Foo.capability.ts file. That + scales to a few dozen variants. It doesn't scale to "given this + specific data shape and this specific audience, propose a + configuration nobody wrote down." +

+ +
{`import { registerVariantDiscovery } from "semiotic/ai"
+
+registerVariantDiscovery((component, capability, context) => {
+  if (component !== "BoxPlot") return []
+  if (!context.profile.fields[context.profile.primary.y ?? ""]?.bimodal) return []
+  return [
+    {
+      id: "RidgelinePlot:bimodal",
+      baseComponent: "RidgelinePlot",
+      intentDeltas: { distribution: 1 },
+      buildProps: () => ({ bins: 40, amplitude: 1.8 }),
+      rationale: "Distribution is bimodal — Ridgeline reveals the second mode.",
+      source: "model",
+    },
+  ]
+})`}
+ +

+ A proposal mixes into the same ranked list{" "} + suggestCharts produces, scored + against the same rubric. The discovery model can be a heuristic, + an LLM call, a future research artifact — the API doesn't care. + The scoring side (fit,{" "} + novelty,{" "} + risk) lands in M3. +

+ +

Why these three together

+ +

+ The arc records what happened. The annotations preserve what the + user said about it. Variant discovery keeps the system honest + about what it doesn't yet know — and where the learning slots + in. Together they make a complete AI-assisted authoring session + something the library has a vocabulary for, not just something + the chat transcript happens to mention. +

+ +

+ For the full type contract, see{" "} + /intelligence/conversation-arc{" "} + in the docs. For the milestone sequencing through October, the + roadmap and the variant-discovery design doc track each + surface's M1 → M4 path. +

+
+ ) +} + +export default { + ...META, + draft: true, + component: Body, +} diff --git a/docs/src/components/navData.js b/docs/src/components/navData.js index 5256c03c..60d8c0e9 100644 --- a/docs/src/components/navData.js +++ b/docs/src/components/navData.js @@ -129,6 +129,7 @@ const navData = [ { title: "Capability Matrix", path: "/intelligence/capabilities" }, { title: "Chart Suggestions", path: "/intelligence/suggestions" }, { title: "Interrogation", path: "/intelligence/interrogation" }, + { title: "Conversation Arc", path: "/intelligence/conversation-arc" }, { title: "Serialization", path: "/intelligence/serialization" }, { title: "Vega-Lite Translator", path: "/intelligence/vega-lite" } ] diff --git a/docs/src/pages/features/ConversationArcPage.js b/docs/src/pages/features/ConversationArcPage.js new file mode 100644 index 00000000..f6e6a278 --- /dev/null +++ b/docs/src/pages/features/ConversationArcPage.js @@ -0,0 +1,768 @@ +import React, { useEffect, useMemo, useRef, useState } from "react" +import { + disableConversationArc, + enableConversationArc, + getConversationArcStore, + withProvenance, +} from "semiotic/ai" +import { CategoryColorProvider, DotPlot, LineChart } from "semiotic" +import PageLayout from "../../components/PageLayout" +import CodeBlock from "../../components/CodeBlock" + +// ── Live event log driven by the actual ConversationArcStore ───────── + +const EVENT_PRESETS = [ + { + type: "suggestion-shown", + label: "Show suggestions", + payload: () => ({ + type: "suggestion-shown", + intent: "trend", + components: ["LineChart", "AreaChart", "Scatterplot"], + topScore: 4.6, + }), + }, + { + type: "suggestion-chosen", + label: "Pick LineChart", + payload: () => ({ + type: "suggestion-chosen", + component: "LineChart", + rank: 1, + source: "user", + }), + }, + { + type: "audience-set", + label: "Switch audience", + payload: () => ({ + type: "audience-set", + audience: "executive", + previous: "analyst", + }), + }, + { + type: "chart-rendered", + label: "Render chart", + payload: () => ({ + type: "chart-rendered", + component: "LineChart", + chartId: "demo-1", + }), + }, + { + type: "chart-edited", + label: "Edit props", + payload: () => ({ + type: "chart-edited", + component: "LineChart", + chartId: "demo-1", + changedProps: ["lineWidth", "colorScheme"], + }), + }, + { + type: "chart-replaced", + label: "Replace via repair", + payload: () => ({ + type: "chart-replaced", + from: "LineChart", + to: "StackedAreaChart", + reason: "repair", + }), + }, + { + type: "chart-exported", + label: "Export JSX", + payload: () => ({ + type: "chart-exported", + component: "StackedAreaChart", + format: "jsx", + }), + }, + { + type: "chart-abandoned", + label: "Abandon", + payload: () => ({ + type: "chart-abandoned", + component: "StackedAreaChart", + reason: "user-walked-away", + }), + }, +] + +const TYPE_COLORS = { + "suggestion-shown": "var(--semiotic-info, #3a8eff)", + "suggestion-chosen": "var(--semiotic-info, #3a8eff)", + "audience-set": "var(--semiotic-warning, #d49a00)", + "chart-rendered": "var(--semiotic-success, #2d8a4a)", + "chart-edited": "var(--semiotic-success, #2d8a4a)", + "chart-replaced": "var(--semiotic-warning, #d49a00)", + "chart-exported": "var(--semiotic-secondary, #6a52d9)", + "chart-abandoned": "var(--semiotic-danger, #c43d3d)", +} + +// Same palette as the button borders, but using the hex fallbacks +// directly so the CategoryColorProvider hands canvas-renderable strings +// to the DotPlot instead of unresolved `var(...)` references. +const TYPE_COLORS_HEX = { + "suggestion-shown": "#3a8eff", + "suggestion-chosen": "#3a8eff", + "audience-set": "#d49a00", + "chart-rendered": "#2d8a4a", + "chart-edited": "#2d8a4a", + "chart-replaced": "#d49a00", + "chart-exported": "#6a52d9", + "chart-abandoned": "#c43d3d", +} + +// Categories on the y-axis appear in the order the first event of +// each type arrives. That's `sort: "auto"`'s streaming behavior on +// DotPlot — honest with the "watch the arc unfold" framing. +const timeFormat = (ms) => { + const d = new Date(ms) + return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }) +} + +function ArcDemo() { + const store = useMemo(() => getConversationArcStore(), []) + const chartRef = useRef(null) + const dotIdRef = useRef(0) + const [enabled, setEnabled] = useState(store.enabled) + const [events, setEvents] = useState(() => store.getEvents()) + const [sessionId, setSessionId] = useState(store.sessionId) + + useEffect(() => { + const unsubscribe = store.subscribe((event) => { + setEvents(store.getEvents()) + setSessionId(store.sessionId) + // Mirror the same event into the DotPlot via its push API. + // Stable ID per dot so the chart can update/remove individuals + // later if we ever want to. + chartRef.current?.push({ + id: ++dotIdRef.current, + type: event.type, + time: event.timestamp, + }) + }) + return unsubscribe + }, [store]) + + // Clean up on unmount so navigating away doesn't leave recording on + // for other consumers. Intentionally not `reset()` — that would wipe + // listeners other parts of the app sharing the same store may have + // attached. + useEffect(() => () => { + disableConversationArc() + store.clear() + }, [store]) + + const toggle = () => { + if (enabled) { + disableConversationArc() + } else { + enableConversationArc({ capacity: 50, sessionId: "docs-demo" }) + } + setEnabled(getConversationArcStore().enabled) + setSessionId(getConversationArcStore().sessionId) + } + + const fire = (preset) => { + store.record(preset.payload()) + } + + const clear = () => { + store.clear() + chartRef.current?.clear() + dotIdRef.current = 0 + setEvents([]) + } + + return ( +
+
+ + + session: {sessionId || "—"} + + + {events.length} event{events.length === 1 ? "" : "s"} + + +
+ +
+ {EVENT_PRESETS.map((preset) => ( + + ))} +
+ + + + {enabled + ? "Click an event button above — dots will arrive via the DotPlot push API." + : "Enable recording, then click an event button to drop dots onto the chart."} +
+ } + /> + + +
+ {events.length === 0 && ( +
+ {enabled + ? "No events yet. Click a button above to record one." + : "Recording is off. Click ‘Enable recording’ to start a session."} +
+ )} + {events.slice().reverse().map((event, i) => ( +
+ + {new Date(event.timestamp).toLocaleTimeString()} + + + {event.type} + + + {JSON.stringify( + Object.fromEntries( + Object.entries(event).filter( + ([k]) => k !== "type" && k !== "timestamp" && k !== "sessionId" + ) + ) + )} + +
+ ))} +
+ + ) +} + +// ── Annotation provenance demo: freshness scrubber ─────────────────── + +// Two callouts pointing at real data spikes. Annotation fields use the +// chart's accessor names (`month`, `value`) — that's how the annotation +// system resolves screen coordinates from data. +const ANNOTATIONS_RAW = [ + withProvenance( + { + type: "callout", + id: "alice-spike", + month: 3, + value: 420, + label: "Hand-placed spike", + note: "Marked when the product launched.", + dx: 50, + dy: -45, + }, + { + provenance: { + author: "alice", + source: "user", + createdAt: "2026-02-15T12:00:00Z", + }, + lifecycle: { ttlHint: "P30D", anchor: "semantic" }, + } + ), + withProvenance( + { + type: "callout", + id: "ai-anomaly", + month: 7, + value: 510, + label: "AI anomaly tag", + note: "Flagged by model-v3 (confidence 0.62).", + dx: -55, + dy: -45, + }, + { + provenance: { + author: "model-v3", + source: "ai", + confidence: 0.62, + createdAt: "2026-03-10T09:00:00Z", + }, + lifecycle: { ttlHint: "P14D", anchor: "fixed" }, + } + ), +] + +// Stand-in for the M2 `computeAnnotationFreshness` helper. The page-level +// scrubber re-runs this on each "now" change so readers see annotations +// drift through fresh → aging → stale → expired. +function computeFreshnessPreview(ann, nowMs) { + const created = ann?.provenance?.createdAt ? Date.parse(ann.provenance.createdAt) : null + const ttl = ann?.lifecycle?.ttlHint + if (created == null || ttl == null) return "fresh" + const ms = typeof ttl === "number" ? ttl : parseIsoDuration(ttl) + const age = nowMs - created + if (age < ms) return "fresh" + if (age < ms * 1.5) return "aging" + if (age < ms * 3) return "stale" + return "expired" +} + +function parseIsoDuration(s) { + const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?)?$/.exec(s) + if (!m) return 0 + const days = parseInt(m[1] || "0", 10) + const hours = parseInt(m[2] || "0", 10) + return ((days * 24 + hours) * 60 * 60 * 1000) +} + +// Per-source brand color. Freshness shifts THIS color toward gray. +// The annotation renderer reads `color` for both text fill + connector +// stroke, so this single field drives the whole visual treatment. +const SOURCE_BASE_COLOR = { + user: "#3a8eff", + ai: "#d49a00", +} + +const COLOR_BY_FRESHNESS = { + fresh: (base) => base, + aging: () => "#8a96a3", + stale: () => "#b0b0b0", +} + +const LABEL_SUFFIX = { + fresh: "", + aging: " · aging", + stale: " · stale", +} + +const FRESHNESS_BADGE_COLOR = { + fresh: "#2d8a4a", + aging: "#d49a00", + stale: "#a0a0a0", + expired: "#c43d3d", +} + +// Hand-tuned data so the spikes the annotations point at are clearly +// visible peaks rather than getting lost in a noisy sine wave. +const SAMPLE_DATA = [ + { month: 1, value: 280 }, + { month: 2, value: 310 }, + { month: 3, value: 420 }, // alice's spike + { month: 4, value: 350 }, + { month: 5, value: 360 }, + { month: 6, value: 370 }, + { month: 7, value: 510 }, // AI's anomaly + { month: 8, value: 390 }, + { month: 9, value: 400 }, + { month: 10, value: 420 }, + { month: 11, value: 450 }, + { month: 12, value: 470 }, +] + +const SLIDER_MIN = Date.parse("2026-02-15T00:00:00Z") +const SLIDER_MAX = Date.parse("2026-08-15T00:00:00Z") + +function FreshnessDemo() { + const [nowIso, setNowIso] = useState("2026-03-10T00:00:00Z") + const nowMs = Date.parse(nowIso) + + // Compute freshness state for each raw annotation. Expired ones drop + // out of the chart-bound array entirely — mirrors M2's default of + // hiding expired notes unless `showExpiredAnnotations` is on. + const states = ANNOTATIONS_RAW.map((a) => ({ + raw: a, + freshness: computeFreshnessPreview(a, nowMs), + })) + + const visibleAnnotations = states + .filter((s) => s.freshness !== "expired") + .map(({ raw, freshness }) => { + const base = SOURCE_BASE_COLOR[raw.provenance.source] ?? "#5a5a5a" + return { + ...raw, + label: raw.label + LABEL_SUFFIX[freshness], + color: COLOR_BY_FRESHNESS[freshness](base), + lifecycle: { ...raw.lifecycle, freshness }, + } + }) + + return ( +
+ + +
+ + setNowIso(new Date(parseInt(e.target.value, 10)).toISOString())} + style={{ width: "100%" }} + /> +
+ +
+ {states.map(({ raw, freshness }) => { + const created = Date.parse(raw.provenance.createdAt) + const ageDays = Math.max(0, Math.floor((nowMs - created) / (24 * 60 * 60 * 1000))) + return ( +
+
+ {raw.label} + {freshness} +
+
+ by {raw.provenance.author} + {" · "}{ageDays} day{ageDays === 1 ? "" : "s"} old + {" · "}TTL {raw.lifecycle.ttlHint} +
+ {freshness === "expired" && ( +
+ hidden from chart — past 3× TTL +
+ )} +
+ ) + })} +
+ +

+ Drag the slider forward in time. The annotations' colors and + labels shift through fresh → aging → stale, then + disappear once they hit expired. The freshness + calculation here is a page-local stand-in — the shipping + computeAnnotationFreshness helper and default + visual treatment land in M2. The annotation data itself uses + the M1 provenance + lifecycle blocks verbatim. +

+
+ ) +} + +// ── Page ──────────────────────────────────────────────────────────── + +export default function ConversationArcPage() { + return ( + +

+ AI-assisted chart authoring is a session, not a one-shot call. Users + see suggestions, pick one, refine the audience, render, edit, replace, + export — or abandon. Semiotic gives that arc structure with three + composable surfaces that ship together in 3.5.x: +

+
    +
  • Conversation-arc telemetry — an opt-in event store recording the arc itself.
  • +
  • Annotation provenance + lifecycle — every annotation can carry origin, confidence, and freshness.
  • +
  • Variant discovery — an interface for proposing chart variants outside the hand-curated capability registry.
  • +
+

+ These are the talk-readiness M1 deliverables in the roadmap. M2–M4 + will fill in heuristic implementations and runtime helpers. The + type surface lands today. +

+ +

Conversation-arc telemetry

+

+ enableConversationArc() turns on a module-scoped ring + buffer that records the arc of an AI-assisted session. Default + surface is a no-op so the import is zero-cost when telemetry is off. +

+ +

Interactive demo

+

+ Enable recording, fire some events, and watch the live store. Each + click below calls store.record(...). The event log is + driven by a real subscriber on the real store — not a re-render of + local state. +

+ + +

Wiring

+ +{`import { + enableConversationArc, + disableConversationArc, + getConversationArcStore, +} from "semiotic/ai" + +enableConversationArc({ capacity: 1000, sessionId: "session-abc" }) + +const store = getConversationArcStore() +const unsubscribe = store.subscribe((event) => { + // Send to your sink — analytics, IndexedDB, replay file. + console.log(event.type, event) +}) + +store.record({ + type: "suggestion-shown", + components: ["LineChart", "AreaChart"], + intent: "trend", +}) +store.record({ + type: "suggestion-chosen", + component: "LineChart", + rank: 1, + source: "user", +}) + +// Talk-time: flush the buffer to a recorded fixture for replay. +const allEvents = store.flush()`} + + +

Event vocabulary

+

+ Eight variants in a discriminated union: suggestion-shown,{" "} + suggestion-chosen, audience-set,{" "} + chart-rendered, chart-edited,{" "} + chart-replaced, chart-exported,{" "} + chart-abandoned. Each carries the fields a downstream + analytics or replay system would actually consume (component name, + rank, format, reason). The arcId field threads multiple + events into a single named arc when you need it. +

+ +

Annotation provenance + lifecycle

+

+ Anchored conversations stay defensible when every annotation knows + where it came from and when it should be considered stale. The + M1 surface attaches two optional blocks to any annotation:{" "} + provenance ({" "} + author, source, confidence,{" "} + createdAt, stableId) and{" "} + lifecycle (freshness, ttlHint,{" "} + anchor). +

+ +

Lifecycle scrubber

+

+ The chart below carries two annotations — one from a user with a + 30-day TTL, one from an AI with a 14-day TTL and lower confidence. + Drag the slider to advance "now" and watch them drift through{" "} + fresh → aging → stale → expired: +

+ + +

Attaching provenance

+ +{`import { withProvenance } from "semiotic/ai" + +const ann = withProvenance( + { type: "y-threshold", value: 100, label: "SLA breach" }, + { + provenance: { + author: "alice", + source: "user", + createdAt: "2026-05-20T14:00:00Z", + stableId: "annot-sla-2026q2", + }, + lifecycle: { ttlHint: "P30D", anchor: "semantic" }, + }, +)`} + + +

+ The anchor mode matters when data refreshes. "fixed"{" "} + keeps the recorded coordinate verbatim; "latest"{" "} + re-pins to the most recent data point; "sticky"{" "} + rides forward until removed (the existing streaming behavior);{" "} + "semantic" re-resolves via stableId when + new data arrives — that's the M3 anchor-resolution algorithm. +

+ +

Variant discovery

+

+ Hand-curated capability.variants are bounded by what + humans wrote. Variant discovery is the API surface for proposing + configurations the registry doesn't include — from heuristic + walkers, LLM agents, or future ML models — and scoring them with + the same rubric the built-in suggester uses. +

+ + +{`import { + proposeVariant, + evaluateVariantProposal, + registerVariantDiscovery, + type VariantProposal, +} from "semiotic/ai" + +// A bespoke discovery function: propose a streamgraph when the user +// chose a multi-series area but their audience profile rewards trend +// over part-to-whole. +registerVariantDiscovery((component, capability, context) => { + if (component !== "StackedAreaChart") return [] + if (context.audience?.targets?.["trend"] === undefined) return [] + return [ + { + id: "StackedAreaChart:streamgraph", + baseComponent: "StackedAreaChart", + intentDeltas: { trend: 1, "part-to-whole": -1 }, + buildProps: (profile) => ({ + baseline: "wiggle", + stackOrder: "insideOut", + }), + rationale: "Streamgraph reveals the trend better for this audience.", + source: "heuristic", + }, + ] +}) + +const proposals = proposeVariant("StackedAreaChart", capability, ctx) +const scores = proposals.map((p) => evaluateVariantProposal(p, profile))`} + + +

+ At M1, proposeVariant and{" "} + evaluateVariantProposal are stubs — they return empty + proposals and a neutral baseline score. The point is the contract:{" "} + VariantProposal and VariantScore are + stable shapes consumers can wire end-to-end today. Heuristic + proposal lands in M2; scoring + the MCP{" "} + proposeChartVariants tool land in M3. +

+ +

Why these three together

+

+ The arc records what happened. The annotations preserve what the + user said about it. Variant discovery keeps the system honest about + what it doesn't yet know — and where the learning slots in. +

+
+ ) +} diff --git a/docs/src/pages/features/InterrogationPage.js b/docs/src/pages/features/InterrogationPage.js index 5a27d232..88ec8ad1 100644 --- a/docs/src/pages/features/InterrogationPage.js +++ b/docs/src/pages/features/InterrogationPage.js @@ -164,7 +164,7 @@ export default function InterrogationPage() { { label: "Interrogation", path: "/intelligence/interrogation" }, ]} prevPage={{ title: "Chart Suggestions", path: "/intelligence/suggestions" }} - nextPage={{ title: "Serialization", path: "/intelligence/serialization" }} + nextPage={{ title: "Conversation Arc", path: "/intelligence/conversation-arc" }} >

Semiotic ships a headless hook, useChartInterrogation, that lets users diff --git a/docs/src/pages/features/SerializationPage.js b/docs/src/pages/features/SerializationPage.js index 7a7092d2..f35946dd 100644 --- a/docs/src/pages/features/SerializationPage.js +++ b/docs/src/pages/features/SerializationPage.js @@ -311,7 +311,7 @@ export default function SerializationPage() { { label: "Intelligence", path: "/intelligence" }, { label: "Serialization", path: "/intelligence/serialization" }, ]} - prevPage={{ title: "Interrogation", path: "/intelligence/interrogation" }} + prevPage={{ title: "Conversation Arc", path: "/intelligence/conversation-arc" }} nextPage={{ title: "Vega-Lite Translator", path: "/intelligence/vega-lite" }} >

diff --git a/etc/api-surface/semiotic-ai.api.md b/etc/api-surface/semiotic-ai.api.md index d79b071c..837eaacd 100644 --- a/etc/api-surface/semiotic-ai.api.md +++ b/etc/api-surface/semiotic-ai.api.md @@ -101,12 +101,16 @@ function TreeDiagram function Treemap function ViolinPlot function applyAudienceBias +function clearVariantDiscovery function configToJSX function copyConfig function deserializeSelections function diagnoseConfig function diffProfile +function disableConversationArc function effectiveFamiliarity +function enableConversationArc +function evaluateVariantProposal function explainCapabilityFit function exportChart function fromConfig @@ -114,14 +118,18 @@ function fromURL function fromVegaLite function getCapabilities function getCapability +function getConversationArcStore function getIntent +function getRegisteredVariantDiscovery function getStreamCapabilities function inferIntent function listIntents function profileData +function proposeVariant function registerChartCapability function registerIntent function registerStreamChartCapability +function registerVariantDiscovery function repairChartConfig function runQualityScorecard function scoreChart @@ -147,25 +155,35 @@ function useLinkedHover function useSelection function useTheme function validateProps +function withProvenance +interface AnnotationLifecycle +interface AnnotationProvenance interface AnomalyConfig interface AudienceBiasResult interface AudienceProfile +interface AudienceSetEvent interface AudienceTarget interface BrushEndObservation interface BrushObservation interface CategoricalFieldSummary interface CategoryColorProviderProps +interface ChartAbandonedEvent interface ChartCapability interface ChartConfig interface ChartContainerHandle interface ChartContainerProps interface ChartDataProfile +interface ChartEditedEvent +interface ChartExportedEvent interface ChartGridProps +interface ChartRenderedEvent +interface ChartReplacedEvent interface ChartRubric interface ChartVariant interface ClickEndObservation interface ClickObservation interface ContextLayoutProps +interface ConversationArcStore interface DashboardPanel interface DashboardSuggestion interface DataSummary @@ -173,6 +191,7 @@ interface DateFieldSummary interface DetailsPanelProps interface Diagnosis interface DiagnosisResult +interface EnableConversationArcOptions interface ExplainCapabilityFitResult interface FieldCandidate interface FieldTypeChange @@ -211,6 +230,8 @@ interface SuggestDashboardOptions interface SuggestStreamChartsOptions interface SuggestStretchChartsOptions interface Suggestion +interface SuggestionChosenEvent +interface SuggestionShownEvent interface SummarizeOptions interface ToConfigOptions interface UnknownFieldSummary @@ -222,14 +243,26 @@ interface UseChartObserverResult interface UseChartSuggestionsOptions interface UseChartSuggestionsResult interface ValidationResult +interface VariantDiscoveryContext +interface VariantProposal +interface VariantScore interface VegaLiteEncoding interface VegaLiteSpec +type Annotated +type AnnotationAnchor +type AnnotationFreshness +type AnnotationSource type BuiltInIntentId type CategoryColorMap type ChartFamily type ChartImportPath type ChartObservation +type ConversationArcEvent +type ConversationArcEventInput +type ConversationArcEventType +type ConversationArcListener type CopyFormat +type EvaluateVariantProposalFn type FieldKind type FieldSummary type FieldType @@ -239,9 +272,12 @@ type IntentScorer type InterrogationQuery type OnObservationCallback type PrimaryRole +type ProposeVariantFn type RepairResult type SerializedFieldSelection type SerializedSelections type StreamFieldKind type StreamIntentScorer +type VariantProposalSource +type VariantRejectionReason ``` diff --git a/etc/api-surface/semiotic.api.md b/etc/api-surface/semiotic.api.md index 95427b64..99609c75 100644 --- a/etc/api-surface/semiotic.api.md +++ b/etc/api-surface/semiotic.api.md @@ -99,6 +99,8 @@ function useLinkedHover function useSelection function useTheme interface AnnotationContext +interface AnnotationLifecycle +interface AnnotationProvenance interface AreaChartProps interface AxisConfig interface BandConfig @@ -204,7 +206,11 @@ interface ViolinPlotProps interface WaterfallStyle interface XYFrameAxisConfig type Accessor +type Annotated +type AnnotationAnchor type AnnotationAnchorMode +type AnnotationFreshness +type AnnotationSource type ArrowOfTime type CanvasRendererFn type CategoryColorMap diff --git a/scripts/check-docs-routes.mjs b/scripts/check-docs-routes.mjs index 392ee565..04ec2e1b 100644 --- a/scripts/check-docs-routes.mjs +++ b/scripts/check-docs-routes.mjs @@ -23,17 +23,20 @@ export const REQUIRED_DOCS_ROUTES = [ }, { routePath: "api", - title: "Api \u2014 Semiotic", + // ROUTE_META in scripts/prerender.mjs supplies curated section + // titles for top-level routes; expected strings here track those + // values rather than the slug-case fallback. + title: "API Reference \u2014 Semiotic", canonicalUrl: `${SITE_URL}/api`, }, { routePath: "api/charts", - title: "Api \u2014 Charts \u2014 Semiotic", + title: "Chart Components API \u2014 Semiotic", canonicalUrl: `${SITE_URL}/api/charts`, }, { routePath: "api/typedoc", - title: "Api \u2014 Typedoc \u2014 Semiotic", + title: "TypeDoc \u2014 Semiotic API Reference", canonicalUrl: `${SITE_URL}/api/typedoc`, }, ] diff --git a/scripts/prerender.d.mts b/scripts/prerender.d.mts index 88924bee..2441e9cd 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 function prerender(): void +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(): Promise 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 = `