diff --git a/.github/social-preview.png b/.github/social-preview.png
new file mode 100644
index 0000000..1bb9bdf
Binary files /dev/null and b/.github/social-preview.png differ
diff --git a/README.md b/README.md
index 90bd90d..c05f24c 100644
--- a/README.md
+++ b/README.md
@@ -1,35 +1,77 @@
-# Relay UI
+
+
+
-Relay UI holds the Ink components that Relay's terminal tools share, so each tool draws tables, spinners, Markdown, and colors the same way instead of keeping its own copy.
+Relay UI holds the terminal components that Relay's tools share: themes, spinners, tables, and Markdown. Each tool draws them the same way instead of keeping its own copy, and each component renders the same in a live terminal as in a single printed frame.
-## See the components
+The components are ordinary Ink components. Ink and React are peer dependencies, so they render with the application's own copy of each.
+
+## Try the demo
```sh
npm install
npm run demo
```
-The gallery shows every component with sample data. Press `q` to quit. To print it once without opening the interactive view:
+The gallery shows every component with sample data. Press `q` to quit. To print the gallery once, or to see it in the grey theme:
```sh
npx tsx src/cli.tsx --snapshot
+npx tsx src/cli.tsx --theme mono
```
-## Use it in a tool
+## Use the components
```sh
npm install @relay-cli/ui ink react
```
-Ink and React are peer dependencies, so the components render with your tool's own copy of each.
+```tsx
+import {render} from 'ink';
+import {Markdown, Spinner, Table, ThemeProvider, resolveTheme} from '@relay-cli/ui';
+
+const columns = [
+ {key: 'method', header: 'Method'},
+ {key: 'path', header: 'Path', truncate: 'middle' as const},
+ {key: 'status', header: 'Status', align: 'right' as const},
+];
+
+render(
+
+
+
+
+ ,
+);
+```
+
+## Components
+
+| Component | What it draws |
+| --------------- | -------------------------------------------------------------------------------------------------------------- |
+| `ThemeProvider` | Sets the colors every component below it uses. `relayTheme` and `monoTheme` are included. |
+| `Spinner` | An animated spinner with a label that turns into ✓ or ✗ when the work finishes. |
+| `Table` | Aligned columns that shrink to the terminal width, with middle truncation, a selected row, and scrolling. |
+| `Markdown` | Headings, lists, quotes, code, pipe tables, and inline formatting, including replies that are still streaming. |
-## Develop
+## Documentation
+
+- [Package API](./docs/api.md)
+- [Artwork](./docs/assets/ARTWORK.md)
+- [Contributing](./CONTRIBUTING.md)
+
+## Scope
+
+Relay UI holds components that more than one Relay tool needs. Layout, focus, and keybindings belong to [Relay Shell](https://github.com/relay-cli/relay-shell); conversation and server-output interfaces stay in [Relay Chat](https://github.com/relay-cli/relay-chat) and [Relay Cards](https://github.com/relay-cli/relay-cards).
+
+## Development
```sh
+npm install
npm run check
```
-`check` runs Prettier, ESLint, the TypeScript compiler, the Vitest suite, and the build. See [CONTRIBUTING.md](CONTRIBUTING.md).
+`npm run check` verifies formatting, linting, strict types, tests, and the distributable package.
## License
diff --git a/docs/assets/ARTWORK.md b/docs/assets/ARTWORK.md
new file mode 100644
index 0000000..5b828ea
--- /dev/null
+++ b/docs/assets/ARTWORK.md
@@ -0,0 +1,21 @@
+# Relay UI artwork
+
+The banner and social preview are drawn by `draw-artwork.py` from the official Relay mark (`relay-avatar.png` in the Relay organization profile). They use the shared Relay palette, the Cascadia Mono typeface for interface text, and restrained terminal borders, without decorative code or robot imagery.
+
+## Banner
+
+`banner.png` uses a 3:1 composition. The left side contains the Relay mark, the product name, and a short description. The right side draws the real components as terminal panels: a table of HTTP requests with colored status codes and a selected row, spinners in their running, done, and failed states, and rendered Markdown with a heading, a numbered list, inline code, and a quote.
+
+## Social preview
+
+`.github/social-preview.png` recomposes the same panels for a 2:1 thumbnail. The description is removed, the mark sits beside the name, and the interface text is enlarged so it stays readable in repository cards.
+
+## Redrawing
+
+The script needs Python with Pillow and NumPy, and reads fonts from `C:/Windows/Fonts` (Cascadia Mono, Bahnschrift, Segoe UI Symbol):
+
+```sh
+python docs/assets/draw-artwork.py path/to/relay-avatar.png docs/assets
+```
+
+It writes `banner.png` and `social-preview.png`; copy the social preview to `.github/`. Upload it under the repository's Settings → Social preview as well, since GitHub does not read it from the repository.
diff --git a/docs/assets/banner.png b/docs/assets/banner.png
new file mode 100644
index 0000000..3a609c6
Binary files /dev/null and b/docs/assets/banner.png differ
diff --git a/docs/assets/draw-artwork.py b/docs/assets/draw-artwork.py
new file mode 100644
index 0000000..b3676f1
--- /dev/null
+++ b/docs/assets/draw-artwork.py
@@ -0,0 +1,204 @@
+"""Draw relay-ui's README banner and social preview with the official Relay mark."""
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image, ImageDraw, ImageFilter, ImageFont
+
+AVATAR, OUT = Path(sys.argv[1]), Path(sys.argv[2])
+OUT.mkdir(parents=True, exist_ok=True)
+SS = 2 # supersampling
+
+BG = (8, 11, 15)
+SURFACE = (17, 24, 32)
+RAISED = (32, 42, 52)
+RULE = (52, 65, 77)
+TEXT = (232, 237, 242)
+MUTED = (132, 144, 156)
+ACCENT = (255, 138, 61)
+FOCUS = (72, 168, 255)
+SUCCESS = (79, 209, 139)
+WARNING = (240, 200, 75)
+DANGER = (255, 77, 90)
+
+FONTS = Path('C:/Windows/Fonts')
+
+
+def mono(size, weight='Regular'):
+ font = ImageFont.truetype(str(FONTS / 'CascadiaMono.ttf'), size * SS)
+ font.set_variation_by_name(weight)
+ return font
+
+
+def title_font(size):
+ font = ImageFont.truetype(str(FONTS / 'bahnschrift.ttf'), size * SS)
+ font.set_variation_by_name('Bold')
+ return font
+
+
+def symbol(size):
+ return ImageFont.truetype(str(FONTS / 'seguisym.ttf'), size * SS)
+
+
+def extract_mark():
+ """Lift the mark off the avatar's white background with clean, unfringed edges."""
+ source = Image.open(AVATAR).convert('RGBA')
+ flat = Image.new('RGBA', source.size, (255, 255, 255, 255))
+ flat.alpha_composite(source)
+ pixels = np.asarray(flat.convert('RGB')).astype(float)
+ white = np.array([255.0, 255.0, 255.0])
+ palette = np.array([[72, 165, 255], [255, 139, 58], [22, 27, 34]], dtype=float)
+ distances = np.stack([np.linalg.norm(pixels - c, axis=2) for c in palette], axis=2)
+ nearest = distances.argmin(axis=2)
+ target = palette[nearest]
+ alpha = np.linalg.norm(pixels - white, axis=2) / np.linalg.norm(target - white, axis=2)
+ alpha = np.clip((alpha - 0.08) / 0.84, 0, 1)
+ rgba = np.dstack([target, alpha * 255]).astype(np.uint8)
+ mark = Image.fromarray(rgba, 'RGBA')
+ return mark.crop(mark.getbbox())
+
+
+MARK = extract_mark()
+
+
+def background(width, height):
+ image = Image.new('RGB', (width * SS, height * SS), BG)
+ glow = Image.new('L', image.size, 0)
+ ImageDraw.Draw(glow).ellipse(
+ (int(width * 0.45) * SS, -height * SS // 2, int(width * 1.3) * SS, height * SS),
+ fill=26,
+ )
+ glow = glow.filter(ImageFilter.GaussianBlur(160 * SS))
+ image.paste(Image.new('RGB', image.size, (28, 38, 50)), mask=glow)
+ return image
+
+
+def s(value):
+ return int(value * SS)
+
+
+def panel(draw, box, bar, label, size):
+ x0, y0, x1, y1 = box
+ draw.rounded_rectangle((s(x0), s(y0), s(x1), s(y1)), radius=s(12), fill=SURFACE,
+ outline=RULE, width=s(2))
+ draw.rounded_rectangle((s(x0), s(y0), s(x0 + 9), s(y1)), radius=s(5), fill=bar)
+ draw.rectangle((s(x0 + 5), s(y0 + 1), s(x0 + 9), s(y1 - 1)), fill=bar)
+ draw.text((s(x0 + 34), s(y0 + 22)), label, font=mono(size, 'Bold'), fill=bar)
+ return x0 + 34, y0 + 22 + size * 1.55
+
+
+def text_run(draw, x, y, parts, size, weight='Regular'):
+ """Draw [(text, color, weight?)] left to right and return the end x."""
+ for part in parts:
+ value, color = part[0], part[1]
+ font = mono(size, part[2] if len(part) > 2 else weight)
+ draw.text((s(x), s(y)), value, font=font, fill=color)
+ x += draw.textlength(value, font=font) / SS
+ return x
+
+
+def table_panel(draw, box, size):
+ x, y = panel(draw, box, FOCUS, 'TABLE', size)
+ line = size * 1.38
+ cell = draw.textlength('M', font=mono(size)) / SS
+ total = int((box[2] - x - 34) / cell)
+ widths = {'Method': 6, 'Path': total - 6 - 6 - 8 - 6, 'Status': 6, 'Time': 8}
+ cols = [('Method', 0, 'l'), ('Path', 8, 'l'), ('Status', total - 16, 'r'),
+ ('Time', total - 8, 'r')]
+
+ def row(values, colors, weight='Regular'):
+ for (name, start, align), value, color in zip(cols, values, colors):
+ if len(value) > widths[name]: # cut in the middle, as Table does for paths
+ keep = widths[name] - 1
+ value = value[: (keep + 1) // 2] + '…' + value[len(value) - keep // 2 :]
+ pad = widths[name] - len(value) if align == 'r' else 0
+ text_run(draw, x + (start + pad) * cell, row.y, [(value, color, weight)], size)
+ row.y += line
+
+ row.y = y
+ row([c[0] for c in cols], [MUTED] * 4, 'Bold')
+ draw.line((s(x), s(row.y - line * 0.2), s(x + total * cell), s(row.y - line * 0.2)),
+ fill=RULE, width=s(2))
+ row.y += line * 0.15
+ data = [
+ ('GET', '/health', '200', '3.2 ms', SUCCESS),
+ ('POST', '/api/orders/2026/10/21/items', '201', '48.9 ms', SUCCESS),
+ ('GET', '/api/orders/unknown', '404', '1.1 ms', WARNING),
+ ('PUT', '/api/settings', '500', '212.4 ms', DANGER),
+ ]
+ for index, (method, path, status, time, color) in enumerate(data):
+ if index == 1:
+ draw.rectangle((s(x - 10), s(row.y - line * 0.12), s(x + total * cell + 10),
+ s(row.y + line * 0.86)), fill=RAISED)
+ row([method, path, status, time], [FOCUS] * 4, 'Bold')
+ else:
+ row([method, path, status, time], [TEXT, TEXT, color, TEXT])
+
+
+def spinner_panel(draw, box, size):
+ x, y = panel(draw, box, ACCENT, 'SPINNER', size)
+ line = size * 1.78
+ sym = symbol(size)
+ rows = [('⠹', ACCENT, 'Starting server', TEXT), ('✓', SUCCESS, 'Build finished', MUTED),
+ ('✗', DANGER, 'Tests failed', MUTED)]
+ for glyph, glyph_color, label, label_color in rows:
+ draw.text((s(x), s(y)), glyph, font=sym if glyph == '✗' else mono(size, 'Bold'),
+ fill=glyph_color)
+ text_run(draw, x + size * 1.1, y, [(label, label_color)], size)
+ y += line
+
+
+def markdown_panel(draw, box, size):
+ x, y = panel(draw, box, SUCCESS, 'MARKDOWN', size)
+ line = size * 1.78
+ text_run(draw, x, y, [('Deploy checklist', ACCENT, 'Bold')], size)
+ y += line * 1.1
+ text_run(draw, x, y, [('1. ', MUTED), ('Run ', TEXT), ('npm test', ACCENT)], size)
+ y += line
+ text_run(draw, x, y, [('2. ', MUTED), ('Tag ', TEXT), ('main', TEXT, 'Bold')], size)
+ y += line
+ draw.line((s(x + 4), s(y + 2), s(x + 4), s(y + size * 1.2)), fill=RULE, width=s(3))
+ text_run(draw, x + 18, y, [('Ship it.', MUTED)], size)
+
+
+def place_mark(image, x, y, height):
+ mark = MARK.resize((s(MARK.width * height / MARK.height), s(height)), Image.LANCZOS)
+ image.paste(mark, (s(x), s(y)), mark)
+ return MARK.width * height / MARK.height
+
+
+def interface(draw, left, top, right, bottom, size):
+ gap = 22
+ split = top + (bottom - top) * 0.5
+ table_panel(draw, (left, top, right, split - gap / 2), size)
+ middle = left + (right - left) * 0.5
+ spinner_panel(draw, (left, split + gap / 2, middle - gap / 2, bottom), size)
+ markdown_panel(draw, (middle + gap / 2, split + gap / 2, right, bottom), size)
+
+
+def banner():
+ width, height = 2172, 724
+ image = background(width, height)
+ draw = ImageDraw.Draw(image)
+ place_mark(image, 128, 178, 236)
+ draw.text((s(58), s(440)), 'RELAY UI', font=title_font(142), fill=TEXT)
+ draw.text((s(62), s(600)), 'Shared components for terminal tools.', font=mono(27),
+ fill=(196, 204, 212))
+ draw.line((s(940), s(74), s(940), s(650)), fill=(36, 46, 56), width=s(2))
+ interface(draw, 1004, 94, 2112, 628, 25)
+ image.resize((width, height), Image.LANCZOS).save(OUT / 'banner.png', optimize=True)
+
+
+def social():
+ width, height = 1774, 887
+ image = background(width, height)
+ draw = ImageDraw.Draw(image)
+ mark_width = place_mark(image, 60, 346, 184)
+ draw.text((s(64 + mark_width + 36), s(372)), 'RELAY UI', font=title_font(116), fill=TEXT)
+ draw.line((s(836), s(150), s(836), s(737)), fill=(36, 46, 56), width=s(2))
+ interface(draw, 884, 150, 1722, 737, 27)
+ image.resize((width, height), Image.LANCZOS).save(OUT / 'social-preview.png', optimize=True)
+
+
+banner()
+social()