Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,23 @@ tlon settings deauthorize-ship ~ship # Remove from auth

- Activity: max 25 items
- Messages: max 50 items

## A2UI Blobs

Post interactive UI components (charts, tables, decision cards, buttons) to Tlon channels as structured blobs. The Tlon client renders these as native Ochre-styled cards inline in chat.

**Trigger phrases:** "post a chart", "send a decision card", "create an interactive blob", "post a table/graph/visualization to Tlon"

```bash
tlon posts blob <nest> --blob '<json>' [--caption "text"]
tlon posts blob <nest> --blob-file ./blob.json [--caption "text"]
```

**Full schema and design rules:** See `references/a2ui-components.md` and `references/a2ui-design-rules.md`.

**Quick example — bar chart:**
```bash
tlon posts blob chat/~host/channel-name \
--caption "Weekly PRs" \
--blob '[{"type":"a2ui","version":1,"root":"root","title":"Weekly PRs","icon":"📊","components":[{"id":"root","component":{"Column":{"children":["chart"],"gap":"xs"}}},{"id":"chart","component":{"Chart":{"chartType":"bar","series":[{"label":"Alice","values":[5,8,3],"color":"#4E91F5"},{"label":"Bob","values":[2,4,7],"color":"#3FB950"}],"xLabels":["W1","W2","W3"],"yLabel":"PRs","height":180}}}]}]'
```
186 changes: 186 additions & 0 deletions references/a2ui-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# A2UI Component Reference

A2UI blobs are structured JSON arrays that Tlon renders as native interactive cards. The blob field is a JSON string.

## Blob Envelope

Every A2UI blob is a **JSON array** containing one object:

```json
[{
"type": "a2ui",
"version": 1,
"root": "root",
"title": "Card Title",
"icon": "📊",
"components": [
{"id": "root", "component": { "Column": { "children": ["child1", "child2"], "gap": "sm" } }},
{"id": "child1", "component": { "Text": { "text": "Hello", "size": "sm" } }}
]
}]
```

**Required fields:**
- `type`: always `"a2ui"`
- `version`: always `1`
- `root`: id of the root component (usually `"root"`)
- `components`: flat array of `{id, component}` objects

**Optional fields:**
- `title`: shown in card header
- `icon`: emoji icon in header

---

## Component Types

Each component object has exactly one key (the type name) with a props object.

### Column
Vertical stack.
```json
{"Column": {"children": ["id1", "id2"], "gap": "xs"}}
```
- `children`: array of component ids
- `gap`: `"xs"` | `"sm"` | `"md"` | `"lg"`

### Row
Horizontal stack.
```json
{"Row": {"children": ["id1", "id2"], "gap": "xs", "justify": "between"}}
```
- `justify`: `"start"` | `"end"` | `"center"` | `"between"`

### Text
```json
{"Text": {"text": "Hello world", "size": "sm", "color": "$secondaryText"}}
```
- `size`: `"xs"` | `"sm"` | `"md"` | `"lg"` | `"xl"`
- `color`: Ochre token like `"$primaryText"`, `"$secondaryText"`, `"$tertiaryText"`

### Chart
```json
{"Chart": {
"chartType": "bar",
"series": [
{"label": "Alice", "values": [5, 8, 3], "color": "#4E91F5"}
],
"xLabels": ["W1", "W2", "W3"],
"yLabel": "PRs",
"height": 180
}}
```
- `chartType`: `"bar"` | `"line"` | `"area"` | `"pie"` | `"sparkline"`
- **Bar/Line/Area**: `series[].values` length MUST match `xLabels` length
- **Pie**: each series = one slice. `values:[N]` (single number). Do NOT use `xLabels` for pie.
- `height`: 120–300 recommended

### Table
```json
{"Table": {
"columns": ["Person", "W1", "W2"],
"rows": [
["Alice", 5, 8],
["Bob", 2, 4]
],
"style": "rich"
}}
```
- `style`: `"simple"` | `"rich"` (rich shows proportional mini-bars)

### Badge
```json
{"Badge": {"text": "shipped", "variant": "success"}}
```
- `variant`: `"default"` | `"primary"` | `"success"`

### Button
```json
{"Button": {"label": "Approve", "action": "approve", "variant": "primary"}}
```
- `variant`: `"primary"` | `"secondary"`
- `action`: string sent to %a2ui agent on tap

### Divider
```json
{"Divider": {}}
```

### Spacer
```json
{"Spacer": {"size": "sm"}}
```
- `size`: `"sm"` | `"md"` | `"lg"`

---

## Chart Type Rules (Critical)

### Pie Chart — CORRECT
Each series = one slice. One value per series.
```json
"series": [
{"label": "Alice", "values": [36], "color": "#4E91F5"},
{"label": "Bob", "values": [24], "color": "#3FB950"}
]
```
**No `xLabels` for pie charts.**

### Pie Chart — WRONG (do not do this)
```json
"series": [{"label": "All", "values": [36, 24, 18]}],
"xLabels": ["Alice", "Bob", "Carol"]
```

### Bar/Line/Area Chart — CORRECT
All series must have same number of values as `xLabels`.
```json
"series": [
{"label": "Alice", "values": [5, 8, 3]},
{"label": "Bob", "values": [2, 4, 7]}
],
"xLabels": ["W1", "W2", "W3"]
```

---

## Full Working Examples

### Decision Card
```json
[{"type":"a2ui","version":1,"root":"root","title":"Deploy to Production?","icon":"🚀","components":[
{"id":"root","component":{"Column":{"children":["body","divider","btns"],"gap":"sm"}}},
{"id":"body","component":{"Text":{"text":"v2.4.1 is ready. 12 PRs merged, all tests passing.","size":"sm","color":"$secondaryText"}}},
{"id":"divider","component":{"Divider":{}}},
{"id":"btns","component":{"Row":{"children":["yes","no"],"gap":"xs"}}},
{"id":"yes","component":{"Button":{"label":"Deploy","action":"deploy","variant":"primary"}}},
{"id":"no","component":{"Button":{"label":"Hold","action":"hold","variant":"secondary"}}}
]}]
```

### Bar Chart with Table
```json
[{"type":"a2ui","version":1,"root":"root","title":"Weekly PRs","icon":"📊","components":[
{"id":"root","component":{"Column":{"children":["chart","divider","table"],"gap":"xs"}}},
{"id":"chart","component":{"Chart":{"chartType":"bar","series":[
{"label":"Alice","values":[5,8,3],"color":"#4E91F5"},
{"label":"Bob","values":[2,4,7],"color":"#3FB950"}
],"xLabels":["W1","W2","W3"],"yLabel":"PRs","height":180}}},
{"id":"divider","component":{"Divider":{}}},
{"id":"table","component":{"Table":{"columns":["Person","W1","W2","W3"],"rows":[["Alice",5,8,3],["Bob",2,4,7]],"style":"rich"}}}
]}]
```

### Pie Chart
```json
[{"type":"a2ui","version":1,"root":"root","title":"Commits by Person","icon":"🥧","components":[
{"id":"root","component":{"Column":{"children":["chart","badges"],"gap":"xs"}}},
{"id":"chart","component":{"Chart":{"chartType":"pie","series":[
{"label":"Alice","values":[36],"color":"#4E91F5"},
{"label":"Bob","values":[24],"color":"#3FB950"},
{"label":"Carol","values":[18],"color":"#E3B341"}
],"height":200}}},
{"id":"badges","component":{"Row":{"children":["b1"],"gap":"xs"}}},
{"id":"b1","component":{"Badge":{"text":"78 total","variant":"primary"}}}
]}]
```
71 changes: 71 additions & 0 deletions references/a2ui-design-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# A2UI Design Rules

Follow these rules exactly when generating A2UI blobs for Tlon.

## Font Rules

**NEVER reference fonts in blob JSON.** The renderer handles fonts using the Ochre system font stack. Never add `fontFamily` props to blob JSON components.

## Spacing Tokens

Ochre spacing tokens:
| Token | Value |
|-------|-------|
| `$2xs` | 2px |
| `$xs` | 4px |
| `$s` | 6px |
| `$m` | 8px |
| `$l` | 12px |
| `$xl` | 16px |
| `$2xl` | 24px |
| `$3xl` | 32px |

In blob JSON use shorthand strings: `"xs"`, `"sm"`, `"md"`, `"lg"`.

## Color Rules

### Buttons
- **Primary**: `#4E91F5` solid blue, white text
- **Secondary**: system background with border

### Badges
- **default**: neutral gray
- **primary**: `#143A5E` bg + `#4E91F5` text
- **success**: `#1B3D2A` bg + `#3FB950` text

### Charts — recommended color palette (use in order)
```
#4E91F5 blue
#3FB950 green
#E3B341 yellow
#E96A6A red
#A78BFA purple
#F09860 orange
```

## Layout Rules

- Section headers: `size: "xs"`, `color: "$tertiaryText"`
- Add Dividers between major sections
- Chart height: 180–220 recommended inside cards (max 300)
- Use `paddingVertical` for symmetry, never only `paddingBottom`

## Chart Rules

| Chart type | Series format | xLabels |
|------------|--------------|---------|
| bar | `values: [N, N, N]` — one per x-label | required |
| line | `values: [N, N, N]` — one per x-label | required |
| area | `values: [N, N, N]` — one per x-label | required |
| pie | `values: [N]` — one total per series | NOT used |
| sparkline | single series | optional |

**PIE RULE:** Each slice is a separate series with `values:[singleNumber]`. Never multiple values in one series for pie.

## Common Mistakes

1. Pie using xLabels instead of per-series labels
2. Bar/line series length not matching xLabels count
3. Blob not wrapped in array `[{...}]`
4. Missing `version: 1`
5. Using pixel numbers for gap instead of token strings
59 changes: 58 additions & 1 deletion scripts/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import * as fs from "fs";
import { addReaction, deletePost, editPost, getChannelPosts, getCurrentUserId, removeReaction } from "@tloncorp/api";
import { addReaction, deletePost, editPost, getChannelPosts, getCurrentUserId, removeReaction, sendPost } from "@tloncorp/api";
import type { Post } from "@tloncorp/api";
import { ensureClient } from "./api-client";
import { markdownToStory, type Story } from "./story";
Expand Down Expand Up @@ -291,6 +291,60 @@ async function main() {
break;
}

case "blob": {
const nestArg = args[1];
if (!nestArg) {
console.error("Usage: posts.ts blob <nest> --blob '<json>' | --blob-file <path> [--caption <text>]");
process.exit(1);
}

const blobIdx = args.indexOf("--blob");
const blobFileIdx = args.indexOf("--blob-file");
const captionIdx = args.indexOf("--caption");

let blobJson: string | undefined;
if (blobIdx !== -1) {
blobJson = args[blobIdx + 1];
} else if (blobFileIdx !== -1) {
const filePath = args[blobFileIdx + 1];
if (!filePath) {
console.error("--blob-file requires a path argument");
process.exit(1);
}
blobJson = fs.readFileSync(filePath, "utf-8").trim();
}

if (!blobJson) {
console.error("Either --blob '<json>' or --blob-file <path> is required");
process.exit(1);
}

let blobData: unknown[];
try {
blobData = JSON.parse(blobJson);
if (!Array.isArray(blobData)) throw new Error("Blob must be a JSON array");
} catch (e: any) {
console.error(`Invalid blob JSON: ${e.message}`);
process.exit(1);
}

const caption = captionIdx !== -1 ? args[captionIdx + 1] : undefined;
const our = getCurrentUserId();
const sentAt = Date.now();
const content = caption ? [{ inline: [caption] }] : [{ inline: [""] }];

await sendPost({
channelId: nestArg,
authorId: our,
sentAt,
content,
blob: JSON.stringify(blobData),
});

console.log("✓ Blob posted");
break;
}

case "send":
console.error("error: Channel post send is handled by the Tlon channel plugin.");
console.error("Use the channel message tool with channel=tlon instead.");
Expand All @@ -313,6 +367,9 @@ Commands:
unreact <channel> <post-id> Remove your reaction from a post
edit <channel> <post-id> <message> Edit a post [--title <t>] [--image <url>] [--content <json>]
delete <channel> <post-id> Delete a post
blob <nest> --blob '<json>' Post an A2UI blob
[--blob-file <path>] Post blob from JSON file
[--caption <text>] Optional caption text

Edit options:
--title <title> Set/update notebook post title
Expand Down