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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# bb plugins

Nine bb plugins I use for product design work, kept together with the few build and repository tools they share. [![CI](https://github.com/brsbl/bb-plugins/actions/workflows/ci.yml/badge.svg)](https://github.com/brsbl/bb-plugins/actions/workflows/ci.yml)
Ten bb plugins I use for product design work, kept together with the few build and repository tools they share. [![CI](https://github.com/brsbl/bb-plugins/actions/workflows/ci.yml/badge.svg)](https://github.com/brsbl/bb-plugins/actions/workflows/ci.yml)

[bb](https://getbb.app) is an agentic IDE for running coding agents across projects, threads, and environments. Its plugins can add UI, commands, skills, and server capabilities; this repository is where I build and maintain mine.

Expand Down Expand Up @@ -92,6 +92,16 @@ A skeleton of the bb app in every configuration — sidebar, splits, panels, ove

Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/theme-preview --yes`

### Color Swatches

Renders an inline swatch beside every color literal in a thread — hex, `rgb()`, `hsl()`, `oklch()` and friends — the way an editor decorates code.

![Color Swatches in bb](plugins/color-swatches/docs/screenshot.png)

[Source](plugins/color-swatches) · [README](plugins/color-swatches/README.md)

Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/color-swatches --yes`

### Timeline Comments

Attaches durable discussion threads to selected timeline text. Users and agents can reply, edit, resolve or reopen comments, review them together, and add open feedback to the composer for follow-up.
Expand Down
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions plugins/color-swatches/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Color Swatches

Renders an inline swatch beside every color literal in a thread — hex, `rgb()`,
`hsl()`, `oklch()` and friends — the way an editor decorates code.

![Color Swatches in bb](docs/screenshot.png)

## Install

```sh
bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/color-swatches --yes
```

## Use

Nothing to configure. Once installed, any color literal an agent writes gets a
chip in front of it: inside fenced code blocks, inside diffs, and in the inline
`` `#070509` `` chips that appear in prose.

The chip shows alpha over a checkerboard and carries a faint ring, so
`#ffffff` and `#000000` stay visible on any theme.

Two deliberate limits:

- **Nothing is inserted into the page.** The chip is drawn in `::before` from a
custom property, so selecting and copying a line still yields exactly the
original text, and a streaming message can never end up with a stale wrapper
in it.
- **A chip only appears where it can be placed exactly.** bb highlights code one
token per element, so a literal reliably starts at a boundary; in an
unhighlighted block a literal can begin mid-run, and the plugin leaves it
alone rather than putting the chip in the wrong column.

The composer is never decorated.

## Develop

```sh
npm install
npm run check # typecheck, build, test
bb plugin install "path:$PWD" --yes
```
36 changes: 36 additions & 0 deletions plugins/color-swatches/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @vitest-environment jsdom

import { loadPluginApp, mountPluginContentScripts } from "@get-bb/plugin-sdk/testing/app";
import { afterEach, describe, expect, it, vi } from "vitest";

afterEach(() => {
document.body.replaceChildren();
vi.unstubAllGlobals();
});

describe("Color Swatches content script", () => {
it("redecorates a code line after its streamed text changes", async () => {
const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
});
document.body.innerHTML = `<pre><div class="sh__line"><span>#</span><span>fff</span></div></pre>`;

const app = await loadPluginApp(() => import("./app"));
const mounted = await mountPluginContentScripts(app, { pluginId: "color-swatches" });
const line = document.querySelector<HTMLElement>(".sh__line")!;
const [prefix, value] = Array.from(line.querySelectorAll<HTMLElement>("span"));
expect(prefix.getAttribute("data-bb-color-swatch")).toBe("");
expect(prefix.style.getPropertyValue("--bb-color-swatch")).toBe("#fff");

value.textContent = "000";
await Promise.resolve(); // deliver MutationObserver records
expect(frames).toHaveLength(1);
frames.shift()!(0);

expect(prefix.getAttribute("data-bb-color-swatch")).toBe("");
expect(prefix.style.getPropertyValue("--bb-color-swatch")).toBe("#000");
await mounted.lifecycle.dispose();
});
});
173 changes: 173 additions & 0 deletions plugins/color-swatches/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { definePluginApp } from "@get-bb/plugin-sdk/app";
import { findColorMatches } from "./colors";

// ---------------------------------------------------------------------------
// Why this decorates with CSS instead of inserting elements
//
// The timeline is React's DOM. Splitting its text nodes to wrap a literal is
// the usual way to do IDE swatches, and it is exactly what breaks while a
// message streams: React keeps the text node it created and writes the next
// chunk into whatever is left of it, so the wrapper survives with stale text
// beside it.
//
// So nothing is inserted. A decorated element gets one attribute and one custom
// property, and a stylesheet draws the chip in `::before`. Generated content is
// not part of the document, so React never sees it, selection never selects it,
// and copying a line still yields the original text.
//
// Placement relies on how bb highlights code: every token is its own span, so a
// literal always begins at a child boundary (`<span>#</span><span>f4f4f4</span>`)
// and the chip can sit on the token that starts the match.
// ---------------------------------------------------------------------------

const ATTR = "data-bb-color-swatch";
const PROP = "--bb-color-swatch";

const STYLESHEET = `
[${ATTR}] {
/* The chip inherits the line's font size, so it tracks code and prose. */
--bb-color-swatch-size: 0.78em;
}
[${ATTR}]::before {
content: "";
display: inline-block;
width: var(--bb-color-swatch-size);
height: var(--bb-color-swatch-size);
margin-inline-end: 0.34em;
vertical-align: -0.085em;
border-radius: 3px;
/* The color rides on top of a checkerboard so alpha reads as alpha, and an
* inset ring keeps white-on-white and black-on-black visible. */
background-image:
linear-gradient(var(${PROP}), var(${PROP})),
linear-gradient(45deg, rgba(128, 128, 128, 0.34) 25%, transparent 25%, transparent 75%, rgba(128, 128, 128, 0.34) 75%),
linear-gradient(45deg, rgba(128, 128, 128, 0.34) 25%, transparent 25%, transparent 75%, rgba(128, 128, 128, 0.34) 75%);
background-size: 100% 100%, 6px 6px, 6px 6px;
background-position: 0 0, 0 0, 3px 3px;
background-color: #fff;
box-shadow: inset 0 0 0 1px rgba(128, 128, 128, 0.45);
}
`;

/** Containers whose text is being edited — never decorate the composer. */
const EXCLUDED = "[contenteditable], input, textarea";

const isColor = (value: string): boolean =>
typeof CSS !== "undefined" && typeof CSS.supports === "function"
? CSS.supports("color", value)
: true;

function decorate(el: HTMLElement, value: string): void {
el.setAttribute(ATTR, "");
el.style.setProperty(PROP, value);
}

function undecorate(el: Element): void {
el.removeAttribute(ATTR);
(el as HTMLElement).style?.removeProperty(PROP);
}

/**
* Decorate the direct children of a highlighted code line. A match is only
* placed when it starts exactly at a child boundary; anything else would put
* the chip in the wrong column, and a wrong chip is worse than none.
*/
function decorateTokenizedLine(line: Element): void {
const starts = new Map<number, HTMLElement>();
let text = "";
for (const node of Array.from(line.childNodes)) {
if (node.nodeType === Node.ELEMENT_NODE) starts.set(text.length, node as HTMLElement);
text += node.textContent ?? "";
}
if (!text.includes("#") && !/[a-z]\(/i.test(text)) return;

for (const match of findColorMatches(text)) {
const target = starts.get(match.start);
if (target && isColor(match.value)) decorate(target, match.value);
}
}

/** An inline `<code>` chip whose whole content is one literal. */
function decorateWholeElement(el: HTMLElement): void {
const text = (el.textContent ?? "").trim();
if (!text || text.length > 64) return;
const matches = findColorMatches(text);
if (matches.length !== 1) return;
const [only] = matches;
if (only.start !== 0 || only.end !== text.length) return;
if (isColor(only.value)) decorate(el, only.value);
}

function matchesWithin(root: ParentNode, selector: string): Element[] {
const matches = Array.from(root.querySelectorAll?.(selector) ?? []);
return root instanceof Element && root.matches(selector) ? [root, ...matches] : matches;
}

function scan(root: ParentNode): void {
// Re-scanning a streamed line must not leave yesterday's chips behind.
for (const stale of Array.from(root.querySelectorAll?.(`[${ATTR}]`) ?? [])) undecorate(stale);

for (const line of matchesWithin(root, ".sh__line")) {
if (line.closest(EXCLUDED)) continue;
decorateTokenizedLine(line);
}
for (const code of matchesWithin(root, "code")) {
if (code.closest(EXCLUDED) || code.closest("pre")) continue;
decorateWholeElement(code as HTMLElement);
}
}

export default definePluginApp((app) => {
app.contentScripts.register({
id: "swatches",
mount({ signal }) {
const style = document.createElement("style");
style.dataset.bbColorSwatches = "";
style.textContent = STYLESHEET;
document.head.append(style);

// Streaming fires mutations constantly; collect subtrees and do one pass
// per frame rather than one pass per mutation record.
let pending: Set<ParentNode> | null = null;
const flush = () => {
const roots = pending ?? new Set();
pending = null;
for (const root of roots) {
if (root.isConnected !== false) scan(root);
}
};
const queue = (node: ParentNode) => {
if (!pending) {
pending = new Set();
requestAnimationFrame(flush);
}
pending.add(node);
};

const observer = new MutationObserver((records) => {
for (const record of records) {
const target = record.target;
const host =
target.nodeType === Node.ELEMENT_NODE
? (target as Element)
: target.parentElement;
// A code line is the smallest unit worth rescanning; above that, the
// message container catches whole re-renders.
const scope = host?.closest?.("pre, .sh__line, [class*='markdown'], main") ?? host;
if (scope) queue(scope as ParentNode);
}
});
observer.observe(document.body, { subtree: true, childList: true, characterData: true });

scan(document.body);

const dispose = () => {
observer.disconnect();
style.remove();
for (const el of Array.from(document.querySelectorAll(`[${ATTR}]`))) undecorate(el);
};
signal.addEventListener("abort", dispose, { once: true });
return dispose;
},
});
});
1 change: 1 addition & 0 deletions plugins/color-swatches/assets/color-chip.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions plugins/color-swatches/colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { findColorMatches } from "./colors";

const values = (text: string) => findColorMatches(text).map((m) => m.value);

describe("findColorMatches", () => {
it("accepts the four valid hex lengths", () => {
expect(values("#fff #ffff #f4f4f4 #f4f4f4ff")).toEqual(["#fff", "#ffff", "#f4f4f4", "#f4f4f4ff"]);
});

it("rejects hex runs that are not colors", () => {
expect(values("#12345 #1234567 #include #1234567890")).toEqual([]);
});

it("does not start a literal inside a word", () => {
expect(values("id#fff my-rgb(1 2 3) $#fff")).toEqual([]);
});

it("finds function forms, including one level of nesting", () => {
expect(values("rgba(255, 106, 31, 0.6) and oklch(0.7 0.1 200)")).toEqual([
"rgba(255, 106, 31, 0.6)",
"oklch(0.7 0.1 200)",
]);
expect(values("color-mix(in oklch, var(--a), var(--b))")).toEqual([
"color-mix(in oklch, var(--a), var(--b))",
]);
});

it("reports offsets that line up with the source text", () => {
const text = " --canvas: #f4f4f4;";
const [match] = findColorMatches(text);
expect(text.slice(match.start, match.end)).toBe("#f4f4f4");
});

it("finds a literal that is followed by punctuation", () => {
expect(values("--sidebar: #070509; /* true black */")).toEqual(["#070509"]);
});
});
Loading
Loading