diff --git a/.agents/skills/widget-schema/SKILL.md b/.agents/skills/widget-schema/SKILL.md new file mode 100644 index 0000000..0a3adda --- /dev/null +++ b/.agents/skills/widget-schema/SKILL.md @@ -0,0 +1,306 @@ +--- +name: widget-schema +description: Design, review, or refactor LLM-facing schemas for Observable Plot widget charts. Use this whenever the user asks to create a chart payload schema, simplify chart props, align a domain schema with a widget implementation, remove styling controls from chart configuration, or separate chart structure from renderer and Plot implementation details. Especially relevant for work in `packages/domain/src/Chart/`, `packages/widget-*/src/`, and prompt or resource docs that teach chart schemas. +--- + +# Widget Schema + +Design schemas for chart widgets that an LLM can use reliably. + +The job of the schema is to describe chart structure and data mapping. The job of the widget is to translate that structure into Observable Plot marks and apply the product's visual design defaults. + +## Use This Skill When + +Use this skill when the user asks to: + +- create a new chart widget payload schema +- simplify or refactor an existing chart schema +- review whether chart props are too implementation-specific +- align a domain schema with an Observable Plot widget implementation +- remove styling knobs from LLM-facing chart configuration +- make multiple chart schemas more consistent with each other + +This skill is especially relevant in `packages/domain/src/Chart/` and `packages/widget-*/src/`. + +## Core Model + +Keep these responsibilities separate: + +- Domain schema: chart intent, data mapping, structural variants +- Widget renderer: Plot translation, styling defaults, visual polish, color, opacity, line widths, curve choices, tooltip behavior +- Guidance/examples: teach the LLM the structural API, not renderer internals + +If a prop exists only because the current widget implementation happens to use a specific Plot option, it probably does not belong in the schema. + +## Design Principles + +### 1. Prefer chart semantics over Plot internals + +Use names that reflect what the user means, not what Plot happens to call the channel. + +Prefer: + +- `series` +- `variant` +- `stack` +- `direction` + +Avoid exposing Plot-specific implementation props unless they are truly the user concept: + +- `z` +- `x1` / `x2` +- `y1` / `y2` +- `fill` as a grouping proxy +- `stroke` as a grouping proxy + +Example: + +- Better schema prop: `series: "industry"` +- Widget translation: `z: series`, `fill: series`, `stroke: series` + +### 2. Keep schemas structural + +Good schema fields usually answer these questions: + +- Which field is x? +- Which field is y? +- Is there a grouping dimension? +- Which structural variant of the chart is this? +- Is sorting or interval regularization needed? + +These usually belong in the schema: + +- `x`, `y` +- `series` +- `variant` +- `direction` +- `interval` +- `sort` +- chart-specific structural fields such as `value`, `target`, `size`, `facet` + +These usually do not belong in the schema: + +- `fillOpacity` +- `strokeWidth` +- `strokeOpacity` +- `opacity` +- `curve` +- `symbol` +- `marker` +- color schemes +- tooltip toggles + +If the widget can choose a sensible default once and apply it consistently, keep that choice in the widget. + +### 3. Keep one widget focused on one chart concept + +Do not overload one schema to support several subtly different Plot constructors unless they are genuinely the same user-facing chart. + +If the widget is an area chart, optimize for the common `areaY` case. If band areas become a distinct product concept, prefer a separate schema or widget rather than a broad, mixed abstraction. + +### 4. Make defaults do the work + +LLM-facing schemas should be easy to use with minimal props. + +Good patterns: + +- default `x` to `"x"` +- default `y` to `"y"` +- default `variant` to the most useful presentation +- derive grouping/color behavior from `series` + +The fewer decisions the LLM has to make, the more consistent the outputs will be. + +### 5. Examples are part of the API + +Examples teach the model how to use the schema. + +Examples should emphasize: + +- data shape +- field mapping +- grouping/series +- structural variants + +Examples should not teach: + +- opacity tweaking +- stroke-width tweaking +- Plot implementation details + +## Review Workflow + +When asked to review or refactor a chart schema, follow this order: + +1. Read the domain schema. +2. Read the widget implementation that consumes it. +3. Check the relevant Observable Plot docs for the intended constructor. +4. Identify which props are structural and which are renderer styling. +5. Propose a smaller schema using semantic names. +6. If asked to implement, update: + - the domain schema + - the widget renderer + - LLM guidance, examples, and docs that teach the schema +7. Verify with the repo-required checks. + +Do not stop at only changing TypeScript types if examples or prompt resources still teach the old API. + +## Schema Rules + +### Field naming + +Prefer a shared vocabulary across chart types where possible: + +- `x` +- `y` +- `series` +- `variant` +- `direction` +- `interval` +- `sort` + +This consistency matters because the LLM learns from repetition across widgets. + +### Structural variants + +Use a small literal enum when the user is choosing between meaningful chart presentations. + +Example: + +```ts +export const AreaVariant = Schema.Literals(["area", "area-line"]); +``` + +This is better than leaking renderer implementation such as `showLine`, because it names the presentation rather than the drawing instruction. + +### Grouping and color + +If the user concept is “series”, expose `series`. + +Do not make the LLM decide between `z`, `fill`, and `stroke` unless the product actually needs those as separate concepts. + +The widget can translate: + +```ts +const series = marks?.series; + +Plot.areaY(data, { + x, + y, + ...(series ? { z: series, fill: series } : {}), +}); + +Plot.lineY(data, { + x, + y, + ...(series ? { z: series, stroke: series } : {}), +}); +``` + +### Styling defaults + +Put visual defaults in the widget, not the schema. + +Examples: + +```ts +fillOpacity: 0.3; +strokeWidth: 2; +tip: true; +scheme: "Category10"; +``` + +These are good renderer defaults because they make charts look right without asking the LLM to art-direct them. + +## Observable Plot Guidance + +Choose the schema shape to match the Plot constructor you actually want to support. + +Examples: + +- If the widget is built around `Plot.areaY`, expose the `x` and `y` shorthand model. +- If the widget is built around `Plot.lineY`, optimize for `x`, `y`, and optional `series`. +- Avoid exposing `x1` / `x2` / `y1` / `y2` unless the widget is intentionally modeling the lower-level constructor. + +The schema should reflect the supported abstraction, not every option Plot makes available. + +## Implementation Expectations + +When implementing a refactor with this skill: + +- keep the schema minimal +- preserve useful structural capabilities +- move style and polish into the widget +- update examples to the new API +- update prompt resources or guidance docs that teach the schema +- run the required verification commands for the repo + +If repo-wide checks fail because of unrelated pre-existing issues, state that clearly and do not treat them as caused by the schema refactor. + +## Output Format + +If the user asks for a review first, return: + +1. Findings about the current schema +2. A proposed simpler schema +3. The reasoning behind the separation of concerns +4. Any migration notes for the widget or docs + +If the user asks for implementation, make the code changes directly and then summarize: + +1. What changed in the schema +2. What moved into the widget +3. Which docs/examples were updated +4. Verification results + +## Examples + +### Good + +```ts +marks: { + x: "date", + y: "value", + series: "industry", + variant: "area-line", +} +``` + +Why it is good: + +- expresses data mapping clearly +- exposes the user concept of series +- exposes a structural presentation choice +- leaves styling to the widget + +### Bad + +```ts +marks: { + x: "date", + y: "value", + z: "industry", + fill: "industry", + fillOpacity: 0.2, + strokeWidth: 1, + curve: "basis", + tip: true, +} +``` + +Why it is bad: + +- mixes schema and styling concerns +- leaks Plot implementation details +- forces the LLM to make design decisions the widget should own +- teaches a noisier API than the product needs + +## Success Criteria + +The skill has done its job when: + +- the schema is easy for an LLM to use correctly +- the widget still has enough information to render the intended chart type +- the product owns visual consistency in the renderer +- examples and docs reinforce the simplified API +- similar chart widgets start converging on the same vocabulary diff --git a/apps/server-mcp/src/prompt/bar-chart-one-shot.ts b/apps/server-mcp/src/prompt/bar-chart-one-shot.ts index 7973a63..a38eb78 100644 --- a/apps/server-mcp/src/prompt/bar-chart-one-shot.ts +++ b/apps/server-mcp/src/prompt/bar-chart-one-shot.ts @@ -17,7 +17,7 @@ export const barChartOneShot = McpServer.prompt({ | - Vertical: x=category, y=value. | - Horizontal: x=value, y=category. |- Use layout for sizing, margins, title/subtitle/caption, and grid. - |- If you need groups/series, use marks.fill (or marks.z) with a series field. + |- If you need stacked bars, use marks.series with a categorical series field. | |Return only the JSON payload that matches BarChartWidgetPayload. |## Best practices (short) @@ -25,7 +25,7 @@ export const barChartOneShot = McpServer.prompt({ |- Include a clear title and a brief caption for context/source. |- Use direction: "horizontal" for long category labels. |- Keep margins generous when labels are rotated or long. - |- Use marks.fill only when multiple series are truly needed. + |- Use marks.series only when multiple series are truly needed. | |Example payload |{ @@ -46,8 +46,7 @@ export const barChartOneShot = McpServer.prompt({ | }, | "marks": { | "x": "month", - | "y": "sales", - | "tip": true + | "y": "sales" | } |} | diff --git a/bun.lock b/bun.lock index 5c54481..8be800f 100644 --- a/bun.lock +++ b/bun.lock @@ -150,6 +150,22 @@ "@types/node": "^25.5.0", }, }, + "packages/ui": { + "name": "@repo/ui", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4", + }, + "devDependencies": { + "@repo/config-typescript": "workspace:*", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + }, + }, "packages/widget-bar-chart": { "name": "@repo/widget-bar-chart", "version": "0.0.0", @@ -164,6 +180,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -188,6 +205,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -212,6 +230,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -590,6 +609,8 @@ "@repo/presence": ["@repo/presence@workspace:packages/presence"], + "@repo/ui": ["@repo/ui@workspace:packages/ui"], + "@repo/widget-bar-chart": ["@repo/widget-bar-chart@workspace:packages/widget-bar-chart"], "@repo/widget-line-chart": ["@repo/widget-line-chart@workspace:packages/widget-line-chart"], diff --git a/packages/domain/src/Chart.ts b/packages/domain/src/Chart.ts index 4ef4403..6503820 100644 --- a/packages/domain/src/Chart.ts +++ b/packages/domain/src/Chart.ts @@ -1,502 +1,4 @@ -import { Schema, Struct } from "effect"; - -const ChannelString = Schema.String.annotate({ plotChannel: "field" }); -const ChannelNumber = Schema.Number.annotate({ plotChannel: "value" }); -const Channel = Schema.Union([ChannelString, ChannelNumber]); -const RangeInterval = Schema.Union([ - Schema.Number, - Schema.Literals([ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "half", - "year", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", - ]), -]); - -const IntervalValue = RangeInterval.annotate({ plotChannel: "interval" }); -const AxisTickValue = Schema.Union([Schema.Number, Schema.String, Schema.Date]); -const AxisTicks = Schema.Union([ - Schema.Number, - Schema.String, - Schema.Array(AxisTickValue), -]); -const ScaleDomain = Schema.Array(AxisTickValue); -const ScaleType = Schema.Union([ - Schema.Literal("linear").annotate({ - description: - "Default for quantitative values (counts, measurements). Avoid for years if you want calendar ticks.", - }), - Schema.Literal("log").annotate({ - description: "Use for values spanning orders of magnitude (must be > 0).", - }), - Schema.Literal("symlog").annotate({ - description: "Use for wide-range values that cross zero.", - }), - Schema.Literal("pow").annotate({ - description: "Use for power transforms when you need a custom exponent.", - }), - Schema.Literal("sqrt").annotate({ - description: - "Use for square-root scaling (often for size/radius encodings).", - }), - Schema.Literal("utc").annotate({ - description: - "Use for dates or years so ticks follow the calendar in UTC (recommended).", - }), - Schema.Literal("time").annotate({ - description: - "Use for dates or years when you want local time instead of UTC.", - }), - Schema.Literal("point").annotate({ - description: "Use for ordinal categories shown as discrete points.", - }), - Schema.Literal("band").annotate({ - description: "Use for ordinal categories with width (bars, rects).", - }), - Schema.Literal("ordinal").annotate({ - description: "Use for ordered categories (strings, booleans).", - }), - Schema.Literal("categorical").annotate({ - description: "Use for unordered categories; mainly for color scales.", - }), - Schema.Literal("identity").annotate({ - description: "Use to bypass scaling and interpret values directly.", - }), -]); - -const LineCurve = Schema.Literals([ - "basis", - "basis-open", - "basis-closed", - "bump-x", - "bump-y", - "bundle", - "cardinal", - "cardinal-open", - "cardinal-closed", - "catmull-rom", - "catmull-rom-open", - "catmull-rom-closed", - "linear", - "linear-closed", - "monotone-x", - "monotone-y", - "natural", - "step", - "step-after", - "step-before", - "auto", -]); - -const LineMarker = Schema.Literals([ - "none", - "arrow", - "arrow-reverse", - "dot", - "circle", - "circle-stroke", - "tick", - "tick-x", - "tick-y", -]); - -const DotSymbol = Schema.Literals([ - "circle", - "cross", - "diamond", - "square", - "star", - "triangle", - "wye", - "plus", - "times", - "triangle2", - "asterisk", - "square2", - "diamond2", - "hexagon", -]); - -export const BarDatumValue = Schema.Union([ - Schema.String, - Schema.Number, - Schema.NumberFromString, - Schema.Boolean, - Schema.Date, - Schema.Null, -]); - -export const LineDatumValue = Schema.Union([ - Schema.String, - Schema.Number, - Schema.NumberFromString, - Schema.Boolean, - Schema.Date, - Schema.Null, -]); - -export const DotDatumValue = Schema.Union([ - Schema.String, - Schema.Number, - Schema.NumberFromString, - Schema.Boolean, - Schema.Date, - Schema.Null, -]); - -export const BarDatum = Schema.Record(Schema.String, BarDatumValue).annotate({ - description: - "Single bar data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'category' (string-ish) and 'value' (number-ish). If you use different keys, set marks.x and marks.y to those key names. For temporal axes, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", - examples: [ - { category: "Q1", value: 120 }, - { product: "Alpha", sales: 48, region: "EU" }, - ], -}); - -export const BarDatumDefaults = Schema.Struct({ - category: Schema.String, - value: Schema.Union([Schema.Number, Schema.NumberFromString]), -}).mapFields(Struct.map(Schema.optionalKey)); - -export const LineDatum = Schema.Record(Schema.String, LineDatumValue).annotate({ - description: - "Single line data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'x' (temporal/quantitative) and 'y' (quantitative). If you use different keys, set marks.x and marks.y to those key names. For time series, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", - examples: [ - { x: "2024-01-01", y: 120 }, - { date: "2024-01-01", value: 48, series: "Alpha" }, - ], -}); - -export const LineDatumDefaults = Schema.Struct({ - x: Schema.Union([Schema.String, Schema.Number, Schema.Date]), - y: Schema.Union([Schema.Number, Schema.NumberFromString]), -}).mapFields(Struct.map(Schema.optionalKey)); - -export const DotDatum = Schema.Record(Schema.String, DotDatumValue).annotate({ - description: - "Single dot data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'x' (quantitative/temporal) and 'y' (quantitative). If you use different keys, set marks.x and marks.y to those key names. For time series, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", - examples: [ - { x: 10, y: 22 }, - { date: "2024-01-01", value: 48, category: "Alpha" }, - ], -}); - -export const DotDatumDefaults = Schema.Struct({ - x: Schema.Union([Schema.String, Schema.Number, Schema.Date]), - y: Schema.Union([Schema.Number, Schema.NumberFromString]), -}).mapFields(Struct.map(Schema.optionalKey)); - -const NumberGreaterThanZero = Schema.Union([ - Schema.Number, - Schema.NumberFromString, -]).check(Schema.isGreaterThan(0)); - -export const PlotLayoutProps = Schema.Struct({ - title: Schema.String, - subtitle: Schema.String, - caption: Schema.String, - - aspectRatio: NumberGreaterThanZero, - marginTop: NumberGreaterThanZero, - marginRight: NumberGreaterThanZero, - marginBottom: NumberGreaterThanZero, - marginLeft: NumberGreaterThanZero, - margin: NumberGreaterThanZero, - width: NumberGreaterThanZero, - height: NumberGreaterThanZero, - - inset: NumberGreaterThanZero, - grid: Schema.Boolean, - - x: Schema.Struct({ - type: ScaleType, - domain: ScaleDomain, - ticks: AxisTicks, - tickFormat: Schema.Union([Schema.String, Schema.Null]), - // tickRotate: Schema.Number, - label: Schema.Union([Schema.String, Schema.Null]), - interval: IntervalValue, - // grid: Schema.Boolean, - }).mapFields(Struct.map(Schema.optionalKey)), - y: Schema.Struct({ - type: ScaleType, - domain: ScaleDomain, - ticks: AxisTicks, - tickFormat: Schema.Union([Schema.String, Schema.Null]), - // tickRotate: Schema.Number, - label: Schema.Union([Schema.String, Schema.Null]), - interval: IntervalValue, - // grid: Schema.Boolean, - }).mapFields(Struct.map(Schema.optionalKey)), - - color: Schema.Struct({ - legend: Schema.Boolean, - }), -}).mapFields(Struct.map(Schema.optionalKey)); - -PlotLayoutProps.annotate({ - description: - "Plot layout options. For year values (even if provided as whole numbers), set layout.x.type to 'utc' or 'time' and use a time tick format such as '%Y' with ticks or interval set to 'year'.", - examples: [ - { - x: { - type: "utc", - ticks: "year", - tickFormat: "%Y", - interval: "year", - }, - }, - ], -}); - -export const BarMarkProps = Schema.Struct({ - x: Channel.annotate({ - description: - "Primary position channel. For vertical bars, maps to the category/ordinal axis; for horizontal bars, maps to the value axis.", - }), - // x1: Channel, - // x2: Channel, - y: Channel.annotate({ - description: - "Primary value channel. For vertical bars, maps to the value axis; for horizontal bars, maps to the category/ordinal axis.", - }), - // y1: Channel, - // y2: Channel, - z: Channel.annotate({ - description: - "Series/channel for grouping within each stack. Use with fill for stacked bars when multiple series share the same x/y.", - }), - interval: IntervalValue, - - inset: NumberGreaterThanZero, - - fill: Channel.annotate({ - description: - "Series/color channel. With only x and y available, setting fill (or z) makes Plot apply the stack transform, producing stacked bars. Vertical bars stack by x; horizontal bars stack by y.", - }), - // fillOpacity: Channel, - stroke: Channel.annotate({ - description: "Stroke color channel. Maps to the outline color of the bars.", - }), - // strokeWidth: Channel, - // strokeOpacity: Channel, - // opacity: Channel, - - tip: Schema.Boolean.annotate({ - description: "If true, show a tooltip with all data fields on hover.", - }), -}).mapFields(Struct.map(Schema.optionalKey)); - -export const LineMarkProps = Schema.Struct({ - x: Channel.annotate({ - description: - "Horizontal position channel. For line charts, typically temporal or quantitative.", - }), - y: Channel.annotate({ - description: - "Vertical position channel. For line charts, typically quantitative.", - }), - z: Channel.annotate({ - description: - "Series/channel for grouping multiple lines. Typically a categorical value such as a series name.", - }), - - stroke: Channel.annotate({ - description: - "Stroke color channel. If specified, consider also setting z to avoid segmenting on color changes.", - }), - strokeWidth: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: - "Stroke width in pixels, or a channel name for per-point width.", - }), - strokeOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: - "Stroke opacity (0-1), or a channel name for per-point opacity.", - }), - - curve: LineCurve.annotate({ - description: - "Curve interpolation name (e.g. linear, step, basis, catmull-rom).", - }), - marker: Schema.Union([Schema.Boolean, LineMarker]).annotate({ - description: - "Marker to draw at each point (true for default, or marker name).", - }), - sort: Schema.Union([ - Schema.String, - Schema.Struct({ - channel: Schema.String, - order: Schema.Literals(["ascending", "descending"]), - }), - ]).annotate({ - description: - "Sort option to order points or series. Use a channel name or { channel, order }.", - }), - - tip: Schema.Boolean.annotate({ - description: "If true, show a tooltip with all data fields on hover.", - }), -}).mapFields(Struct.map(Schema.optionalKey)); - -export const DotMarkProps = Schema.Struct({ - x: Channel.annotate({ - description: - "Horizontal position channel. For scatterplots, typically quantitative or temporal.", - }), - y: Channel.annotate({ - description: - "Vertical position channel. For scatterplots, typically quantitative.", - }), - - r: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Dot radius in pixels, or a channel name for per-point size.", - }), - symbol: Schema.Union([Channel, DotSymbol]).annotate({ - description: - "Symbol name or channel. Use a categorical field to map different symbols.", - }), - rotate: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Rotation angle in degrees clockwise, or a channel name.", - }), - - fill: Channel.annotate({ - description: "Fill color channel. Maps to dot fill color.", - }), - fillOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Fill opacity (0-1), or a channel name.", - }), - stroke: Channel.annotate({ - description: "Stroke color channel. Maps to dot outline color.", - }), - strokeWidth: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Stroke width in pixels, or a channel name.", - }), - strokeOpacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Stroke opacity (0-1), or a channel name.", - }), - opacity: Schema.Union([Channel, Schema.NumberFromString]).annotate({ - description: "Overall opacity (0-1), or a channel name.", - }), - - sort: Schema.Union([ - Schema.String, - Schema.Null, - Schema.Struct({ - channel: Schema.String, - order: Schema.Literals(["ascending", "descending"]), - }), - ]).annotate({ - description: - "Sort option to order dots. Use a channel name, { channel, order }, or null for input order.", - }), - - tip: Schema.Boolean.annotate({ - description: "If true, show a tooltip with all data fields on hover.", - }), -}).mapFields(Struct.map(Schema.optionalKey)); - -export const AxisMarkProps = Schema.Struct({ - x: Channel, - y: Channel, - - anchor: Schema.Literals(["top", "right", "bottom", "left"]), - ticks: AxisTicks, - // tickSpacing: Schema.Number, - // interval: IntervalValue, - // tickSize: Schema.Number, - tickFormat: Schema.Union([Schema.String, Schema.Unknown, Schema.Null]), - tickRotate: Schema.Number, - label: Schema.Union([Schema.String, Schema.Null]), - labelAnchor: Schema.Literals(["top", "right", "bottom", "left", "center"]), - // labelOffset: Schema.Number, -}).mapFields(Struct.map(Schema.optionalKey)); - -export const BarChartWidgetPayload = Schema.Struct({ - data: Schema.Array(BarDatum).annotate({ - description: - "Array of bar data objects. Each object must expose the keys used in marks.x and marks.y.", - }), - direction: Schema.Literals(["horizontal", "vertical"]) - .annotate({ - description: - "Orientation of bars. Vertical means x=category and y=value; horizontal means x=value and y=category.", - }) - .pipe(Schema.optionalKey), - layout: PlotLayoutProps.annotate({ - description: - "Plot layout options. All fields optional; omit to use defaults.", - }).pipe(Schema.optionalKey), - marks: BarMarkProps.annotate({ - description: - "Mark channel mapping. Use marks.x and marks.y to select data keys that match your data objects. For vertical bars, set x=category and y=value. For horizontal bars, set x=value and y=category.", - }), -}).annotate({ - description: - "Payload for render_bar_chart_widget. Align marks.x/marks.y with the data keys for the chosen orientation.", - examples: [ - { - data: [ - { category: "Q1", value: 120 }, - { category: "Q2", value: 98 }, - ], - direction: "vertical", - marks: { x: "category", y: "value" }, - }, - { - data: [ - { breed: "Golden Retriever", popularity: 9 }, - { breed: "Labrador Retriever", popularity: 8.5 }, - ], - direction: "horizontal", - marks: { x: "popularity", y: "breed" }, - }, - ], -}); - -export const LineChartWidgetPayload = Schema.Struct({ - data: Schema.Array(LineDatum).annotate({ - description: - "Array of line data objects. Each object must expose the keys used in marks.x and marks.y.", - }), - layout: PlotLayoutProps.annotate({ - description: - "Plot layout options. All fields optional; omit to use defaults.", - }).pipe(Schema.optionalKey), - marks: LineMarkProps.annotate({ - description: - "Line mark channel mapping. Use marks.x and marks.y to select data keys. Defaults are x='x' and y='y' if omitted.", - }).pipe(Schema.optionalKey), -}).annotate({ - description: - "Payload for render_line_chart_widget. LLM guidance: choose marks.x and marks.y to match keys in data objects.", -}); - -export const ScatterplotWidgetPayload = Schema.Struct({ - data: Schema.Array(DotDatum).annotate({ - description: - "Array of dot data objects. Each object must expose the keys used in marks.x and marks.y.", - }), - layout: PlotLayoutProps.annotate({ - description: - "Plot layout options. All fields optional; omit to use defaults.", - }).pipe(Schema.optionalKey), - marks: DotMarkProps.annotate({ - description: - "Dot mark channel mapping. Use marks.x and marks.y to select data keys. Defaults are x='x' and y='y' if omitted.", - }), -}).annotate({ - description: - "Payload for render_scatterplot_widget. LLM guidance: choose marks.x and marks.y to match keys in data objects.", -}); +export * from "./Chart/BarChart"; +export * from "./Chart/LineChart"; +export * from "./Chart/Scatterplot"; +export * from "./Chart/shared"; diff --git a/packages/domain/src/Chart/BarChart.ts b/packages/domain/src/Chart/BarChart.ts new file mode 100644 index 0000000..36c22c8 --- /dev/null +++ b/packages/domain/src/Chart/BarChart.ts @@ -0,0 +1,95 @@ +import { Schema, Struct } from "effect"; +import { Channel, IntervalValue, PlotLayoutProps } from "./shared"; + +export const BarDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const BarDatum = Schema.Record(Schema.String, BarDatumValue).annotate({ + description: + "Single bar data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'category' (string-ish) and 'value' (number-ish). If you use different keys, set marks.x and marks.y to those key names. For temporal axes, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", + examples: [ + { category: "Q1", value: 120 }, + { product: "Alpha", sales: 48, region: "EU" }, + ], +}); + +export const BarDatumDefaults = Schema.Struct({ + category: Schema.String, + value: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const BarMarkProps = Schema.Struct({ + x: Channel.annotate({ + description: + "Field for the category or value axis. Defaults to 'category' for vertical bars and 'value' for horizontal bars.", + }), + y: Channel.annotate({ + description: + "Field for the value or category axis. Defaults to 'value' for vertical bars and 'category' for horizontal bars.", + }), + series: Channel.annotate({ + description: + "Optional categorical field for stacked bars or grouped color encoding.", + }), + interval: IntervalValue, + sort: Schema.Union([ + Schema.String, + Schema.Struct({ + channel: Schema.String, + order: Schema.Literals(["ascending", "descending"]), + }), + ]).annotate({ + description: + "Optional sort for category ordering. Use a channel name or { channel, order }.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const BarChartWidgetPayload = Schema.Struct({ + data: Schema.Array(BarDatum).annotate({ + description: + "Array of bar data objects. Each object must expose the keys used in marks.x and marks.y.", + }), + direction: Schema.Literals(["horizontal", "vertical"]) + .annotate({ + description: + "Orientation of bars. Vertical means x=category and y=value; horizontal means x=value and y=category.", + }) + .pipe(Schema.optionalKey), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: BarMarkProps.annotate({ + description: + "Bar mark mapping. Use marks.x and marks.y to select data keys. Use marks.series for stacked bars. Defaults are based on direction if omitted.", + }).pipe(Schema.optionalKey), +}).annotate({ + description: + "Payload for render_bar_chart_widget. Use for category comparison, ranking, and simple part-to-whole bars.", + examples: [ + { + data: [ + { category: "Q1", value: 120 }, + { category: "Q2", value: 98 }, + ], + direction: "vertical", + marks: { x: "category", y: "value" }, + }, + { + data: [ + { quarter: "Q1", region: "EU", value: 40 }, + { quarter: "Q1", region: "NA", value: 80 }, + { quarter: "Q2", region: "EU", value: 30 }, + { quarter: "Q2", region: "NA", value: 68 }, + ], + direction: "vertical", + marks: { x: "quarter", y: "value", series: "region" }, + }, + ], +}); diff --git a/packages/domain/src/Chart/LineChart.ts b/packages/domain/src/Chart/LineChart.ts new file mode 100644 index 0000000..9aef3cd --- /dev/null +++ b/packages/domain/src/Chart/LineChart.ts @@ -0,0 +1,84 @@ +import { Schema, Struct } from "effect"; +import { Channel, PlotLayoutProps } from "./shared"; + +export const LineDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const LineDatum = Schema.Record(Schema.String, LineDatumValue).annotate({ + description: + "Single line data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'x' (temporal/quantitative) and 'y' (quantitative). If you use different keys, set marks.x and marks.y to those key names. For time series, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", + examples: [ + { x: "2024-01-01", y: 120 }, + { date: "2024-01-01", value: 48, series: "Alpha" }, + ], +}); + +export const LineDatumDefaults = Schema.Struct({ + x: Schema.Union([Schema.String, Schema.Number, Schema.Date]), + y: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const LineMarkProps = Schema.Struct({ + x: Channel.annotate({ + description: "Horizontal position field. Defaults to 'x' when omitted.", + }), + y: Channel.annotate({ + description: "Vertical value field. Defaults to 'y' when omitted.", + }), + series: Channel.annotate({ + description: + "Optional categorical field for multiple series. The renderer uses this for grouping and color.", + }), + sort: Schema.Union([ + Schema.String, + Schema.Struct({ + channel: Schema.String, + order: Schema.Literals(["ascending", "descending"]), + }), + ]).annotate({ + description: + "Sort option to order points or series. Use a channel name or { channel, order }.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const LineChartWidgetPayload = Schema.Struct({ + data: Schema.Array(LineDatum).annotate({ + description: + "Array of line data objects. Each object must expose the keys used in marks.x and marks.y.", + }), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: LineMarkProps.annotate({ + description: + "Line mark mapping. Use marks.x and marks.y to select data keys. Use marks.series for multiple lines. Defaults are x='x' and y='y' if omitted.", + }).pipe(Schema.optionalKey), +}).annotate({ + description: + "Payload for render_line_chart_widget. Use for trends and continuous comparisons across time or another ordered dimension.", + examples: [ + { + data: [ + { date: "2024-01-01", value: 120 }, + { date: "2024-02-01", value: 98 }, + ], + marks: { x: "date", y: "value" }, + }, + { + data: [ + { date: "2024-01-01", value: 120, series: "Alpha" }, + { date: "2024-02-01", value: 98, series: "Alpha" }, + { date: "2024-01-01", value: 80, series: "Beta" }, + { date: "2024-02-01", value: 110, series: "Beta" }, + ], + marks: { x: "date", y: "value", series: "series" }, + }, + ], +}); diff --git a/packages/domain/src/Chart/Scatterplot.ts b/packages/domain/src/Chart/Scatterplot.ts new file mode 100644 index 0000000..fa9cf75 --- /dev/null +++ b/packages/domain/src/Chart/Scatterplot.ts @@ -0,0 +1,86 @@ +import { Schema, Struct } from "effect"; +import { Channel, PlotLayoutProps } from "./shared"; + +export const DotDatumValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.NumberFromString, + Schema.Boolean, + Schema.Date, + Schema.Null, +]); + +export const DotDatum = Schema.Record(Schema.String, DotDatumValue).annotate({ + description: + "Single dot data object. Keys are column names used by marks.x and marks.y. Default channels expect keys 'x' (quantitative/temporal) and 'y' (quantitative). If you use different keys, set marks.x and marks.y to those key names. For time series, prefer Date objects or epoch milliseconds; if using date strings, set layout.x.type to 'utc' or 'time'.", + examples: [ + { x: 10, y: 22 }, + { date: "2024-01-01", value: 48, category: "Alpha" }, + ], +}); + +export const DotDatumDefaults = Schema.Struct({ + x: Schema.Union([Schema.String, Schema.Number, Schema.Date]), + y: Schema.Union([Schema.Number, Schema.NumberFromString]), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const DotMarkProps = Schema.Struct({ + x: Channel.annotate({ + description: "Horizontal position field. Defaults to 'x' when omitted.", + }), + y: Channel.annotate({ + description: "Vertical position field. Defaults to 'y' when omitted.", + }), + series: Channel.annotate({ + description: "Optional categorical field for grouped color encoding.", + }), + size: Schema.Union([Channel, Schema.NumberFromString]).annotate({ + description: + "Optional quantitative field for dot size, or a constant radius in pixels.", + }), + sort: Schema.Union([ + Schema.String, + Schema.Null, + Schema.Struct({ + channel: Schema.String, + order: Schema.Literals(["ascending", "descending"]), + }), + ]).annotate({ + description: + "Sort option to order dots. Use a channel name, { channel, order }, or null for input order.", + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +export const ScatterplotWidgetPayload = Schema.Struct({ + data: Schema.Array(DotDatum).annotate({ + description: + "Array of dot data objects. Each object must expose the keys used in marks.x and marks.y.", + }), + layout: PlotLayoutProps.annotate({ + description: + "Plot layout options. All fields optional; omit to use defaults.", + }).pipe(Schema.optionalKey), + marks: DotMarkProps.annotate({ + description: + "Scatterplot mapping. Use marks.x and marks.y to select data keys. Use marks.series for grouped color and marks.size for bubble charts. Defaults are x='x' and y='y' if omitted.", + }).pipe(Schema.optionalKey), +}).annotate({ + description: + "Payload for render_scatterplot_widget. Use for correlation, clustering, and bubble-chart style comparisons.", + examples: [ + { + data: [ + { horsepower: 130, mpg: 24 }, + { horsepower: 165, mpg: 18 }, + ], + marks: { x: "mpg", y: "horsepower" }, + }, + { + data: [ + { x: 10, y: 22, category: "A", volume: 100 }, + { x: 15, y: 18, category: "B", volume: 160 }, + ], + marks: { x: "x", y: "y", series: "category", size: "volume" }, + }, + ], +}); diff --git a/packages/domain/src/Chart/shared.ts b/packages/domain/src/Chart/shared.ts new file mode 100644 index 0000000..50488d4 --- /dev/null +++ b/packages/domain/src/Chart/shared.ts @@ -0,0 +1,207 @@ +import { Schema, Struct } from "effect"; + +export const ChannelString = Schema.String.annotate({ plotChannel: "field" }); +export const ChannelNumber = Schema.Number.annotate({ plotChannel: "value" }); +export const Channel = Schema.Union([ChannelString, ChannelNumber]); +export const RangeInterval = Schema.Union([ + Schema.Number, + Schema.Literals([ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "half", + "year", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ]), +]); + +export const IntervalValue = RangeInterval.annotate({ + plotChannel: "interval", +}); +export const AxisTickValue = Schema.Union([ + Schema.Number, + Schema.String, + Schema.Date, +]); +export const AxisTicks = Schema.Union([ + Schema.Number, + Schema.String, + Schema.Array(AxisTickValue), +]); +export const ScaleDomain = Schema.Array(AxisTickValue); +export const ScaleType = Schema.Union([ + Schema.Literal("linear").annotate({ + description: + "Default for quantitative values (counts, measurements). Avoid for years if you want calendar ticks.", + }), + Schema.Literal("log").annotate({ + description: "Use for values spanning orders of magnitude (must be > 0).", + }), + Schema.Literal("symlog").annotate({ + description: "Use for wide-range values that cross zero.", + }), + Schema.Literal("pow").annotate({ + description: "Use for power transforms when you need a custom exponent.", + }), + Schema.Literal("sqrt").annotate({ + description: + "Use for square-root scaling (often for size/radius encodings).", + }), + Schema.Literal("utc").annotate({ + description: + "Use for dates or years so ticks follow the calendar in UTC (recommended).", + }), + Schema.Literal("time").annotate({ + description: + "Use for dates or years when you want local time instead of UTC.", + }), + Schema.Literal("point").annotate({ + description: "Use for ordinal categories shown as discrete points.", + }), + Schema.Literal("band").annotate({ + description: "Use for ordinal categories with width (bars, rects).", + }), + Schema.Literal("ordinal").annotate({ + description: "Use for ordered categories (strings, booleans).", + }), + Schema.Literal("categorical").annotate({ + description: "Use for unordered categories; mainly for color scales.", + }), + Schema.Literal("identity").annotate({ + description: "Use to bypass scaling and interpret values directly.", + }), +]); + +export const LineCurve = Schema.Literals([ + "basis", + "basis-open", + "basis-closed", + "bump-x", + "bump-y", + "bundle", + "cardinal", + "cardinal-open", + "cardinal-closed", + "catmull-rom", + "catmull-rom-open", + "catmull-rom-closed", + "linear", + "linear-closed", + "monotone-x", + "monotone-y", + "natural", + "step", + "step-after", + "step-before", + "auto", +]); + +export const LineMarker = Schema.Literals([ + "none", + "arrow", + "arrow-reverse", + "dot", + "circle", + "circle-stroke", + "tick", + "tick-x", + "tick-y", +]); + +export const DotSymbol = Schema.Literals([ + "circle", + "cross", + "diamond", + "square", + "star", + "triangle", + "wye", + "plus", + "times", + "triangle2", + "asterisk", + "square2", + "diamond2", + "hexagon", +]); + +export const NumberGreaterThanZero = Schema.Union([ + Schema.Number, + Schema.NumberFromString, +]).check(Schema.isGreaterThan(0)); + +export const PlotLayoutProps = Schema.Struct({ + title: Schema.String, + subtitle: Schema.String, + caption: Schema.String, + + aspectRatio: NumberGreaterThanZero, + marginTop: NumberGreaterThanZero, + marginRight: NumberGreaterThanZero, + marginBottom: NumberGreaterThanZero, + marginLeft: NumberGreaterThanZero, + margin: NumberGreaterThanZero, + width: NumberGreaterThanZero, + height: NumberGreaterThanZero, + + inset: NumberGreaterThanZero, + grid: Schema.Boolean, + + x: Schema.Struct({ + type: ScaleType, + domain: ScaleDomain, + ticks: AxisTicks, + tickFormat: Schema.Union([Schema.String, Schema.Null]), + label: Schema.Union([Schema.String, Schema.Null]), + interval: IntervalValue, + }).mapFields(Struct.map(Schema.optionalKey)), + y: Schema.Struct({ + type: ScaleType, + domain: ScaleDomain, + ticks: AxisTicks, + tickFormat: Schema.Union([Schema.String, Schema.Null]), + label: Schema.Union([Schema.String, Schema.Null]), + interval: IntervalValue, + }).mapFields(Struct.map(Schema.optionalKey)), + + color: Schema.Struct({ + legend: Schema.Boolean, + }), +}).mapFields(Struct.map(Schema.optionalKey)); + +PlotLayoutProps.annotate({ + description: + "Plot layout options. For year values (even if provided as whole numbers), set layout.x.type to 'utc' or 'time' and use a time tick format such as '%Y' with ticks or interval set to 'year'.", + examples: [ + { + x: { + type: "utc", + ticks: "year", + tickFormat: "%Y", + interval: "year", + }, + }, + ], +}); + +export const AxisMarkProps = Schema.Struct({ + x: Channel, + y: Channel, + + anchor: Schema.Literals(["top", "right", "bottom", "left"]), + ticks: AxisTicks, + tickFormat: Schema.Union([Schema.String, Schema.Unknown, Schema.Null]), + tickRotate: Schema.Number, + label: Schema.Union([Schema.String, Schema.Null]), + labelAnchor: Schema.Literals(["top", "right", "bottom", "left", "center"]), +}).mapFields(Struct.map(Schema.optionalKey)); diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000..2d36f2e --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1 @@ +# @repo/ui \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..c2d19cd --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "@repo/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "type-check": "tsc --noEmit", + "clean": "git clean -xdf .cache .turbo dist node_modules tsconfig.tsbuildinfo" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "@observablehq/plot": "^0.6.17", + "effect": "4.0.0-beta.43", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@repo/config-typescript": "workspace:*", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3" + } +} diff --git a/packages/ui/src/PlotFigure.tsx b/packages/ui/src/PlotFigure.tsx new file mode 100644 index 0000000..12ac964 --- /dev/null +++ b/packages/ui/src/PlotFigure.tsx @@ -0,0 +1,37 @@ +import * as Plot from "@observablehq/plot"; +import { type ComponentPropsWithoutRef, type RefObject, useRef } from "react"; + +import { usePlotRenderer } from "./usePlotRenderer"; + +type PlotOptions = Parameters[0]; + +export type PlotFigureProps = Omit< + ComponentPropsWithoutRef<"div">, + "children" +> & { + options: PlotOptions; + dependencies?: ReadonlyArray; + containerRef?: RefObject; +}; + +export const PlotFigure = ({ + options, + dependencies, + containerRef, + ...props +}: PlotFigureProps) => { + const internalRef = useRef(null); + const plotContainerRef = containerRef ?? internalRef; + + usePlotRenderer({ + containerRef: plotContainerRef, + dependencies: dependencies ?? [options], + renderPlot: () => + Plot.plot({ + style: "--plot-background: var(--color-background-primary);", + ...options, + }), + }); + + return
; +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..e9ea3a3 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,11 @@ +export { PlotFigure, type PlotFigureProps } from "./PlotFigure"; +export { usePlotRenderer } from "./usePlotRenderer"; +export { useWidgetPayload } from "./useWidgetPayload"; +export { useWidgetResize } from "./useWidgetResize"; + +export { + type HostApp, + mountWidgetApp, + WidgetFrame, + WidgetStatus, +} from "./widget"; diff --git a/packages/ui/src/usePlotRenderer.tsx b/packages/ui/src/usePlotRenderer.tsx new file mode 100644 index 0000000..70ec014 --- /dev/null +++ b/packages/ui/src/usePlotRenderer.tsx @@ -0,0 +1,30 @@ +import { type RefObject, useEffect } from "react"; + +type PlotRenderContext = { + width?: number; +}; + +type UsePlotRendererOptions = { + containerRef: RefObject; + width?: number; + renderPlot: (context: PlotRenderContext) => Element; + dependencies: ReadonlyArray; +}; + +export const usePlotRenderer = ({ + containerRef, + width, + renderPlot, + dependencies, +}: UsePlotRendererOptions) => { + useEffect(() => { + if (!containerRef.current) { + return undefined; + } + + const plot = renderPlot(width ? { width } : {}); + containerRef.current.replaceChildren(plot); + + return () => plot.remove(); + }, [containerRef, width, renderPlot, ...dependencies]); +}; diff --git a/packages/ui/src/useWidgetPayload.tsx b/packages/ui/src/useWidgetPayload.tsx new file mode 100644 index 0000000..4a3142c --- /dev/null +++ b/packages/ui/src/useWidgetPayload.tsx @@ -0,0 +1,100 @@ +import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; +import { Option, Schema } from "effect"; +import { useRef, useState } from "react"; +import type { HostApp } from "./widget"; + +type ToolResultContentItem = NonNullable[number]; + +type ToolResultParams = Parameters< + NonNullable["app"]>["ontoolresult"]> +>[0]; + +type AppInfo = { + name: string; + version: string; +}; + +type UseWidgetPayloadOptions = { + appInfo: AppInfo; + capabilities?: Record; +}; + +type UseWidgetPayloadResult = { + app: HostApp | null; + error: Error | null; + isConnected: boolean; + payload: TPayload | null; +}; + +const decodeToolResult = >( + schema: TSchema, + params: ToolResultParams, +): Schema.Schema.Type | null => { + if (params.isError) { + return null; + } + + const structuredPayload = Schema.decodeUnknownOption(schema)( + params.structuredContent, + ); + if (Option.isSome(structuredPayload)) { + return structuredPayload.value as Schema.Schema.Type; + } + + const text = params.content?.find( + (item: ToolResultContentItem) => item.type === "text", + )?.text; + if (!text) { + return null; + } + + try { + return Schema.decodeUnknownSync(schema)( + JSON.parse(text), + ) as Schema.Schema.Type; + } catch { + return null; + } +}; + +export const useWidgetPayload = < + TSchema extends Schema.Decoder, +>( + schema: TSchema, + options: UseWidgetPayloadOptions, +): UseWidgetPayloadResult> => { + const [payload, setPayload] = useState | null>( + null, + ); + const lastPayloadRef = useRef(null); + + const { app, isConnected, error } = useApp({ + appInfo: options.appInfo, + capabilities: options.capabilities ?? {}, + onAppCreated: (nextApp: HostApp) => { + nextApp.ontoolresult = (params: ToolResultParams) => { + const nextPayload = decodeToolResult(schema, params); + if (!nextPayload) { + return; + } + + const serialized = JSON.stringify(nextPayload); + if (lastPayloadRef.current === serialized) { + return; + } + + lastPayloadRef.current = serialized; + setPayload(nextPayload); + }; + }, + }); + + useHostStyles(app, app?.getHostContext()); + + return { + app, + error, + isConnected, + payload, + }; +}; diff --git a/packages/ui/src/useWidgetResize.tsx b/packages/ui/src/useWidgetResize.tsx new file mode 100644 index 0000000..d5a1389 --- /dev/null +++ b/packages/ui/src/useWidgetResize.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from "react"; + +type SizeChangedSender = { + sendSizeChanged: (params: { width?: number; height?: number }) => unknown; +}; + +export const useWidgetResize = (app?: SizeChangedSender | null) => { + const frameRef = useRef(null); + const [measuredWidth, setMeasuredWidth] = useState(null); + const lastSizeRef = useRef<{ width: number; height: number } | null>(null); + const scheduledRef = useRef(false); + + useEffect(() => { + if (!frameRef.current) { + return undefined; + } + + const element = frameRef.current; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + + const nextWidth = Math.floor(entry.contentRect.width); + if (Number.isFinite(nextWidth) && nextWidth > 0) { + setMeasuredWidth(nextWidth); + } + + if (!app) { + return; + } + + if (scheduledRef.current) { + return; + } + + scheduledRef.current = true; + requestAnimationFrame(() => { + scheduledRef.current = false; + + const width = Math.ceil(entry.contentRect.width); + const height = Math.ceil(entry.contentRect.height); + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return; + } + + const lastSize = lastSizeRef.current; + if (lastSize?.width === width && lastSize?.height === height) { + return; + } + + lastSizeRef.current = { width, height }; + void app.sendSizeChanged({ width, height }); + }); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, [app]); + + return { + frameRef, + measuredWidth, + }; +}; diff --git a/packages/ui/src/widget.tsx b/packages/ui/src/widget.tsx new file mode 100644 index 0000000..ca2348a --- /dev/null +++ b/packages/ui/src/widget.tsx @@ -0,0 +1,44 @@ +import type { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { ReactElement, ReactNode, RefObject } from "react"; +import { createRoot } from "react-dom/client"; + +export type HostApp = NonNullable["app"]>; + +type WidgetFrameProps = { + children?: ReactNode; + frameRef?: RefObject; +}; + +type WidgetStatusProps = { + message: string; +}; + +export const mountWidgetApp = (element: ReactElement) => { + const rootElement = document.getElementById("root"); + + if (!rootElement) { + throw new Error("Widget root element not found"); + } + + createRoot(rootElement).render(element); +}; + +export const WidgetFrame = ({ children, frameRef }: WidgetFrameProps) => { + return ( +
+ {children} +
+ ); +}; + +export const WidgetStatus = ({ message }: WidgetStatusProps) => { + return
{message}
; +}; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..27c182a --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/widget-bar-chart/package.json b/packages/widget-bar-chart/package.json index 93ee8ae..f49ac96 100644 --- a/packages/widget-bar-chart/package.json +++ b/packages/widget-bar-chart/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/packages/widget-bar-chart/src/app.tsx b/packages/widget-bar-chart/src/app.tsx index d4addf3..09b71a1 100644 --- a/packages/widget-bar-chart/src/app.tsx +++ b/packages/widget-bar-chart/src/app.tsx @@ -1,10 +1,4 @@ -import { createRoot } from "react-dom/client"; +import { mountWidgetApp } from "@repo/ui"; import { WidgetApp } from "./bar-chart"; -const rootElement = document.getElementById("root"); - -if (!rootElement) { - throw new Error("Widget root element not found"); -} - -createRoot(rootElement).render(); +mountWidgetApp(); diff --git a/packages/widget-bar-chart/src/bar-chart.tsx b/packages/widget-bar-chart/src/bar-chart.tsx index 89611f4..e2cc806 100644 --- a/packages/widget-bar-chart/src/bar-chart.tsx +++ b/packages/widget-bar-chart/src/bar-chart.tsx @@ -1,41 +1,13 @@ -import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; import * as Plot from "@observablehq/plot"; import { BarChartWidgetPayload } from "@repo/domain/Chart"; -import { Option, Schema } from "effect"; -import { useEffect, useRef, useState } from "react"; - -const extractPayloadFromToolResult = ( - params: Parameters< - NonNullable["app"]>["ontoolresult"]> - >[0], -) => { - if (params.isError) { - return null; - } - - const structured = params.structuredContent; - const structuredPayload = Schema.decodeUnknownOption(BarChartWidgetPayload)( - structured, - ); - if (Option.isSome(structuredPayload)) { - return structuredPayload.value; - } - - const text = params.content?.find((item) => item.type === "text")?.text; - if (text) { - try { - return Schema.decodeUnknownSync(BarChartWidgetPayload)(JSON.parse(text)); - } catch { - return null; - } - } - - return null; -}; - -type SizeChangedSender = { - sendSizeChanged: (params: { width?: number; height?: number }) => unknown; -}; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; const BarChart = ({ data, @@ -43,146 +15,67 @@ const BarChart = ({ layout, marks, app, -}: typeof BarChartWidgetPayload.Type & { app?: SizeChangedSender | null }) => { - const wrapperRef = useRef(null); - const containerRef = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(null); - const lastSizeRef = useRef<{ width: number; height: number } | null>(null); - const scheduledRef = useRef(false); - - useEffect(() => { - if (!containerRef.current) { - return undefined; - } - - const layoutOptions = layout ?? {}; - const { width: layoutWidth, ...restLayoutOptions } = layoutOptions; - const width = layoutWidth ?? measuredWidth ?? undefined; - const isHorizontal = direction === "horizontal"; - const barOptions = { - ...marks, - x: marks?.x ?? (isHorizontal ? "value" : "category"), - y: marks?.y ?? (isHorizontal ? "category" : "value"), - }; - - const plot = Plot.plot({ - style: "--plot-background: var(--color-background-primary);", - ...restLayoutOptions, - ...(width ? { width } : {}), - color: { - ...(layoutOptions.color ? layoutOptions.color : {}), - scheme: "Category10", - }, - marks: [ - isHorizontal ? Plot.ruleX([0]) : Plot.ruleY([0]), - isHorizontal - ? Plot.barX(data, barOptions) - : Plot.barY(data, barOptions), - ].filter(Boolean), - }); - - containerRef.current.replaceChildren(plot); - return () => plot.remove(); - }, [data, layout, marks, measuredWidth, direction]); - - useEffect(() => { - if (!wrapperRef.current) { - return undefined; - } - - const element = wrapperRef.current; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) { - return; - } - const nextWidth = Math.floor(entry.contentRect.width); - if (Number.isFinite(nextWidth) && nextWidth > 0) { - setMeasuredWidth(nextWidth); - } - - if (!app) { - return; - } - - if (scheduledRef.current) { - return; - } - - scheduledRef.current = true; - requestAnimationFrame(() => { - scheduledRef.current = false; - const width = Math.ceil(entry.contentRect.width); - const height = Math.ceil(entry.contentRect.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return; - } - const lastSize = lastSizeRef.current; - if (lastSize?.width === width && lastSize?.height === height) { - return; - } - lastSizeRef.current = { width, height }; - void app.sendSizeChanged({ width, height }); - }); - }); - - observer.observe(element); - return () => observer.disconnect(); - }, [app]); +}: typeof BarChartWidgetPayload.Type & { app?: HostApp | null }) => { + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const isHorizontal = direction === "horizontal"; + const series = marks?.series; + const barOptions = { + x: marks?.x ?? (isHorizontal ? "value" : "category"), + y: marks?.y ?? (isHorizontal ? "category" : "value"), + ...(series ? { z: series, fill: series } : {}), + ...(marks?.interval ? { interval: marks.interval } : {}), + ...(marks?.sort ? { sort: marks.sort } : {}), + inset: 0.5, + tip: true, + }; return ( -
-
-
+ + + ); }; export const WidgetApp = () => { - const [payload, setPayload] = useState< - typeof BarChartWidgetPayload.Type | null - >(null); - const lastPayloadRef = useRef(null); - const updatePayload = (next: typeof BarChartWidgetPayload.Type) => { - const serialized = JSON.stringify(next); - if (lastPayloadRef.current === serialized) { - return; - } - lastPayloadRef.current = serialized; - setPayload(next); - }; - const { app, isConnected, error } = useApp({ - appInfo: { name: "widget-bar-chart", version: "0.1.0" }, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = (params) => { - const parsed = extractPayloadFromToolResult(params); - if (parsed) { - updatePayload(parsed); - } - }; + const { app, isConnected, error, payload } = useWidgetPayload( + BarChartWidgetPayload, + { + appInfo: { name: "widget-bar-chart", version: "0.1.0" }, + capabilities: {}, }, - }); - - useHostStyles(app, app?.getHostContext()); + ); if (error) { - return
Error: {error.message}
; + return ; } if (!isConnected) { - return
Connecting…
; + return ; } if (!payload) { - return
Waiting for data…
; + return ; } return ; diff --git a/packages/widget-line-chart/package.json b/packages/widget-line-chart/package.json index 99b0bb7..eb0eabd 100644 --- a/packages/widget-line-chart/package.json +++ b/packages/widget-line-chart/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/packages/widget-line-chart/src/app.tsx b/packages/widget-line-chart/src/app.tsx index 04683d5..39341ec 100644 --- a/packages/widget-line-chart/src/app.tsx +++ b/packages/widget-line-chart/src/app.tsx @@ -1,10 +1,4 @@ -import { createRoot } from "react-dom/client"; +import { mountWidgetApp } from "@repo/ui"; import { WidgetApp } from "./line-chart"; -const rootElement = document.getElementById("root"); - -if (!rootElement) { - throw new Error("Widget root element not found"); -} - -createRoot(rootElement).render(); +mountWidgetApp(); diff --git a/packages/widget-line-chart/src/line-chart.tsx b/packages/widget-line-chart/src/line-chart.tsx index 19bddc3..f9ad904 100644 --- a/packages/widget-line-chart/src/line-chart.tsx +++ b/packages/widget-line-chart/src/line-chart.tsx @@ -1,183 +1,74 @@ -import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; import * as Plot from "@observablehq/plot"; import { LineChartWidgetPayload } from "@repo/domain/Chart"; -import { Option, Schema } from "effect"; -import { useEffect, useRef, useState } from "react"; - -const extractPayloadFromToolResult = ( - params: Parameters< - NonNullable["app"]>["ontoolresult"]> - >[0], -) => { - if (params.isError) { - return null; - } - - const structured = params.structuredContent; - const structuredPayload = Schema.decodeUnknownOption(LineChartWidgetPayload)( - structured, - ); - if (Option.isSome(structuredPayload)) { - return structuredPayload.value; - } - - const text = params.content?.find((item) => item.type === "text")?.text; - if (text) { - try { - return Schema.decodeUnknownSync(LineChartWidgetPayload)(JSON.parse(text)); - } catch { - return null; - } - } - - return null; -}; - -type SizeChangedSender = { - sendSizeChanged: (params: { width?: number; height?: number }) => unknown; -}; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; const LineChart = ({ data, layout, marks, app, -}: typeof LineChartWidgetPayload.Type & { app?: SizeChangedSender | null }) => { - const wrapperRef = useRef(null); - const containerRef = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(null); - const lastSizeRef = useRef<{ width: number; height: number } | null>(null); - const scheduledRef = useRef(false); - - useEffect(() => { - if (!containerRef.current) { - return undefined; - } - - const layoutOptions = layout ?? {}; - const { width: layoutWidth, ...restLayoutOptions } = layoutOptions; - const width = layoutWidth ?? measuredWidth ?? undefined; - - const plot = Plot.plot({ - style: "--plot-background: var(--color-background-primary);", - ...restLayoutOptions, - ...(width ? { width } : {}), - color: { - ...(layoutOptions.color ? layoutOptions.color : {}), - scheme: "Category10", - }, - marks: [ - Plot.ruleY([0]), - Plot.line(data, { - ...marks, - x: marks?.x ?? "x", - y: marks?.y ?? "y", - }), - ], - }); - - containerRef.current.replaceChildren(plot); - return () => plot.remove(); - }, [data, layout, marks, measuredWidth]); - - useEffect(() => { - if (!wrapperRef.current) { - return undefined; - } - - const element = wrapperRef.current; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) { - return; - } - const nextWidth = Math.floor(entry.contentRect.width); - if (Number.isFinite(nextWidth) && nextWidth > 0) { - setMeasuredWidth(nextWidth); - } - - if (!app) { - return; - } - - if (scheduledRef.current) { - return; - } - - scheduledRef.current = true; - requestAnimationFrame(() => { - scheduledRef.current = false; - const width = Math.ceil(entry.contentRect.width); - const height = Math.ceil(entry.contentRect.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return; - } - const lastSize = lastSizeRef.current; - if (lastSize?.width === width && lastSize?.height === height) { - return; - } - lastSizeRef.current = { width, height }; - void app.sendSizeChanged({ width, height }); - }); - }); - - observer.observe(element); - return () => observer.disconnect(); - }, [app]); +}: typeof LineChartWidgetPayload.Type & { app?: HostApp | null }) => { + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const x = marks?.x ?? "x"; + const y = marks?.y ?? "y"; + const series = marks?.series; + const lineOptions = { + x, + y, + ...(series ? { z: series, stroke: series } : {}), + ...(marks?.sort ? { sort: marks.sort } : {}), + strokeWidth: 2, + tip: true, + }; return ( -
-
-
+ + + ); }; export const WidgetApp = () => { - const [payload, setPayload] = useState< - typeof LineChartWidgetPayload.Type | null - >(null); - const lastPayloadRef = useRef(null); - const updatePayload = (next: typeof LineChartWidgetPayload.Type) => { - const serialized = JSON.stringify(next); - if (lastPayloadRef.current === serialized) { - return; - } - lastPayloadRef.current = serialized; - setPayload(next); - }; - const { app, isConnected, error } = useApp({ - appInfo: { name: "widget-line-chart", version: "0.1.0" }, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = (params) => { - const parsed = extractPayloadFromToolResult(params); - if (parsed) { - updatePayload(parsed); - } - }; + const { app, isConnected, error, payload } = useWidgetPayload( + LineChartWidgetPayload, + { + appInfo: { name: "widget-line-chart", version: "0.1.0" }, + capabilities: {}, }, - }); - - useHostStyles(app, app?.getHostContext()); + ); if (error) { - return
Error: {error.message}
; + return ; } if (!isConnected) { - return
Connecting…
; + return ; } if (!payload) { - return
Waiting for data…
; + return ; } return ; diff --git a/packages/widget-scatterplot/package.json b/packages/widget-scatterplot/package.json index 0e87c9f..23f15af 100644 --- a/packages/widget-scatterplot/package.json +++ b/packages/widget-scatterplot/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@repo/config-typescript": "workspace:*", "@repo/domain": "workspace:*", + "@repo/ui": "workspace:*", "@types/d3": "^7.4.3", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/packages/widget-scatterplot/src/app.tsx b/packages/widget-scatterplot/src/app.tsx index a148c42..e588b71 100644 --- a/packages/widget-scatterplot/src/app.tsx +++ b/packages/widget-scatterplot/src/app.tsx @@ -1,10 +1,4 @@ -import { createRoot } from "react-dom/client"; +import { mountWidgetApp } from "@repo/ui"; import { WidgetApp } from "./scatterplot"; -const rootElement = document.getElementById("root"); - -if (!rootElement) { - throw new Error("Widget root element not found"); -} - -createRoot(rootElement).render(); +mountWidgetApp(); diff --git a/packages/widget-scatterplot/src/scatterplot.tsx b/packages/widget-scatterplot/src/scatterplot.tsx index 4afdd38..e7b5cf2 100644 --- a/packages/widget-scatterplot/src/scatterplot.tsx +++ b/packages/widget-scatterplot/src/scatterplot.tsx @@ -1,43 +1,13 @@ -import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; import * as Plot from "@observablehq/plot"; import { ScatterplotWidgetPayload } from "@repo/domain/Chart"; -import { Option, Schema } from "effect"; -import { useEffect, useRef, useState } from "react"; - -const extractPayloadFromToolResult = ( - params: Parameters< - NonNullable["app"]>["ontoolresult"]> - >[0], -) => { - if (params.isError) { - return null; - } - - const structured = params.structuredContent; - const structuredPayload = Schema.decodeUnknownOption( - ScatterplotWidgetPayload, - )(structured); - if (Option.isSome(structuredPayload)) { - return structuredPayload.value; - } - - const text = params.content?.find((item) => item.type === "text")?.text; - if (text) { - try { - return Schema.decodeUnknownSync(ScatterplotWidgetPayload)( - JSON.parse(text), - ); - } catch { - return null; - } - } - - return null; -}; - -type SizeChangedSender = { - sendSizeChanged: (params: { width?: number; height?: number }) => unknown; -}; +import { + type HostApp, + PlotFigure, + useWidgetPayload, + useWidgetResize, + WidgetFrame, + WidgetStatus, +} from "@repo/ui"; const Scatterplot = ({ data, @@ -45,144 +15,64 @@ const Scatterplot = ({ marks, app, }: typeof ScatterplotWidgetPayload.Type & { - app?: SizeChangedSender | null; + app?: HostApp | null; }) => { - const wrapperRef = useRef(null); - const containerRef = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(null); - const lastSizeRef = useRef<{ width: number; height: number } | null>(null); - const scheduledRef = useRef(false); - - useEffect(() => { - if (!containerRef.current) { - return undefined; - } - - const layoutOptions = layout ?? {}; - const { width: layoutWidth, ...restLayoutOptions } = layoutOptions; - const width = layoutWidth ?? measuredWidth ?? undefined; - - const plot = Plot.plot({ - style: "--plot-background: var(--color-background-primary);", - ...restLayoutOptions, - ...(width ? { width } : {}), - color: { - ...(layoutOptions.color ? layoutOptions.color : {}), - scheme: "Category10", - }, - marks: [ - Plot.ruleX([0]), - Plot.ruleY([0]), - Plot.dot(data, { - ...marks, - x: marks?.x ?? "x", - y: marks?.y ?? "y", - }), - ], - }); - - containerRef.current.replaceChildren(plot); - return () => plot.remove(); - }, [data, layout, marks, measuredWidth]); - - useEffect(() => { - if (!wrapperRef.current) { - return undefined; - } - - const element = wrapperRef.current; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) { - return; - } - const nextWidth = Math.floor(entry.contentRect.width); - if (Number.isFinite(nextWidth) && nextWidth > 0) { - setMeasuredWidth(nextWidth); - } - - if (!app) { - return; - } - - if (scheduledRef.current) { - return; - } - - scheduledRef.current = true; - requestAnimationFrame(() => { - scheduledRef.current = false; - const width = Math.ceil(entry.contentRect.width); - const height = Math.ceil(entry.contentRect.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return; - } - const lastSize = lastSizeRef.current; - if (lastSize?.width === width && lastSize?.height === height) { - return; - } - lastSizeRef.current = { width, height }; - void app.sendSizeChanged({ width, height }); - }); - }); - - observer.observe(element); - return () => observer.disconnect(); - }, [app]); + const { frameRef, measuredWidth } = useWidgetResize(app); + + const width = layout?.width ?? measuredWidth ?? undefined; + const layoutOptions = layout ?? {}; + const { width: _layoutWidth, ...restLayoutOptions } = layoutOptions; + const x = marks?.x ?? "x"; + const y = marks?.y ?? "y"; + const series = marks?.series; + const dotOptions = { + x, + y, + ...(series ? { fill: series, stroke: series } : {}), + ...(marks?.size ? { r: marks.size } : {}), + ...(marks?.sort !== undefined ? { sort: marks.sort } : {}), + strokeWidth: 1.5, + fillOpacity: 0.8, + tip: true, + }; return ( -
-
-
+ + + ); }; export const WidgetApp = () => { - const [payload, setPayload] = useState< - typeof ScatterplotWidgetPayload.Type | null - >(null); - const lastPayloadRef = useRef(null); - const updatePayload = (next: typeof ScatterplotWidgetPayload.Type) => { - const serialized = JSON.stringify(next); - if (lastPayloadRef.current === serialized) { - return; - } - lastPayloadRef.current = serialized; - setPayload(next); - }; - const { app, isConnected, error } = useApp({ - appInfo: { name: "widget-scatterplot", version: "0.1.0" }, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = (params) => { - const parsed = extractPayloadFromToolResult(params); - if (parsed) { - updatePayload(parsed); - } - }; + const { app, isConnected, error, payload } = useWidgetPayload( + ScatterplotWidgetPayload, + { + appInfo: { name: "widget-scatterplot", version: "0.1.0" }, + capabilities: {}, }, - }); - - useHostStyles(app, app?.getHostContext()); + ); if (error) { - return
Error: {error.message}
; + return ; } if (!isConnected) { - return
Connecting…
; + return ; } if (!payload) { - return
Waiting for data…
; + return ; } return ; diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..9f67b5f --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "simple": { + "source": "roin-orca/skills", + "sourceType": "github", + "computedHash": "cfde9dde00c9b1f8934e0312440a5ae2ed76dbd81e49ba7433e0e63ea6551d13" + } + } +}