diff --git a/README.md b/README.md index 1649c11..7c06a25 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/package-lock.json b/package-lock.json index ea3b7fa..1cade65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2958,6 +2958,10 @@ ], "license": "MIT" }, + "node_modules/bb-plugin-color-swatches": { + "resolved": "plugins/color-swatches", + "link": true + }, "node_modules/bb-plugin-design-doctrine": { "resolved": "plugins/design-doctrine", "link": true @@ -5047,6 +5051,23 @@ "vitest": "^4.0.0" } }, + "plugins/color-swatches": { + "name": "bb-plugin-color-swatches", + "version": "0.1.0", + "devDependencies": { + "@get-bb/plugin-sdk": "file:../../tooling/vendor/get-bb-plugin-sdk-0.4.8.tgz", + "@testing-library/react": "^16.3.2", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "jsdom": "^29.0.1", + "typescript": "^5.7.0", + "vitest": "^4.0.0" + }, + "engines": { + "bb": ">=0.0.34", + "bbPluginSdk": "^0.4.1" + } + }, "plugins/design-doctrine": { "name": "bb-plugin-design-doctrine", "version": "0.3.0", diff --git a/plugins/color-swatches/README.md b/plugins/color-swatches/README.md new file mode 100644 index 0000000..4524c43 --- /dev/null +++ b/plugins/color-swatches/README.md @@ -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 +``` diff --git a/plugins/color-swatches/app.test.ts b/plugins/color-swatches/app.test.ts new file mode 100644 index 0000000..fe51272 --- /dev/null +++ b/plugins/color-swatches/app.test.ts @@ -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 = `
#fff
`; + + const app = await loadPluginApp(() => import("./app")); + const mounted = await mountPluginContentScripts(app, { pluginId: "color-swatches" }); + const line = document.querySelector(".sh__line")!; + const [prefix, value] = Array.from(line.querySelectorAll("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(); + }); +}); diff --git a/plugins/color-swatches/app.tsx b/plugins/color-swatches/app.tsx new file mode 100644 index 0000000..3f37d23 --- /dev/null +++ b/plugins/color-swatches/app.tsx @@ -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 (`#f4f4f4`) +// 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(); + 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 `` 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 | 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; + }, + }); +}); diff --git a/plugins/color-swatches/assets/color-chip.svg b/plugins/color-swatches/assets/color-chip.svg new file mode 100644 index 0000000..2222d05 --- /dev/null +++ b/plugins/color-swatches/assets/color-chip.svg @@ -0,0 +1 @@ + diff --git a/plugins/color-swatches/colors.test.ts b/plugins/color-swatches/colors.test.ts new file mode 100644 index 0000000..acd4332 --- /dev/null +++ b/plugins/color-swatches/colors.test.ts @@ -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"]); + }); +}); diff --git a/plugins/color-swatches/colors.ts b/plugins/color-swatches/colors.ts new file mode 100644 index 0000000..38e6f58 --- /dev/null +++ b/plugins/color-swatches/colors.ts @@ -0,0 +1,62 @@ +/** + * Finding color literals in a string. + * + * Deliberately permissive about *syntax* and strict about *boundaries*: the + * browser is the final judge of whether a match is a real color (the content + * script gates every hit through `CSS.supports`), so the job here is to find + * candidates without swallowing their neighbours — `#include`, `PR #1234567`, + * or a `my-rgb(...)` identifier must not read as color. + */ + +export interface ColorMatch { + /** Index of the first character of the literal. */ + start: number; + /** Index one past the last character. */ + end: number; + /** The literal exactly as it appears in the text. */ + value: string; +} + +/** Only these hex lengths are colors; 5 and 7 digits are something else. */ +const HEX_LENGTHS = new Set([3, 4, 6, 8]); + +/** + * Function forms, including one level of nesting so `rgb(var(--x) / 50%)` and + * `color-mix(in oklch, var(--a), var(--b))` survive. + */ +const FUNCTION_NAMES = [ + "rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color", "color-mix", +]; + +const PATTERN = new RegExp( + [ + "#[0-9a-fA-F]+", + `(?:${FUNCTION_NAMES.join("|")})\\(` + "[^()]*(?:\\([^()]*\\)[^()]*)*" + "\\)", + ].join("|"), + "gi", +); + +/** A literal may not begin in the middle of a word. */ +const isWordish = (char: string | undefined): boolean => + char !== undefined && /[\w#$@-]/.test(char); + +export function findColorMatches(text: string): ColorMatch[] { + const out: ColorMatch[] = []; + PATTERN.lastIndex = 0; + for (let m = PATTERN.exec(text); m !== null; m = PATTERN.exec(text)) { + const value = m[0]; + const start = m.index; + if (isWordish(text[start - 1])) continue; + + if (value.startsWith("#")) { + // `#f4f4f4;` is fine, `#f4f4f4f` is not a color — the run of hex digits + // has to be exactly one of the valid lengths on its own. + const digits = value.length - 1; + if (!HEX_LENGTHS.has(digits)) continue; + if (/[\w-]/.test(text[start + value.length] ?? "")) continue; + } + + out.push({ start, end: start + value.length, value }); + } + return out; +} diff --git a/plugins/color-swatches/dist/app.css b/plugins/color-swatches/dist/app.css new file mode 100644 index 0000000..a457ecd --- /dev/null +++ b/plugins/color-swatches/dist/app.css @@ -0,0 +1,240 @@ +/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, utilities; +@layer theme { + :root, :host { + --font-sans: var(--font-sans); + --font-serif: var(--font-serif); + --font-mono: var(--font-mono); + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); + --shadow: var(--shadow); + --shadow-lift: var(--shadow-lift); + } +} +@property --tw-animation-delay { + syntax: "*"; + inherits: false; + initial-value: 0s; +} +@property --tw-animation-direction { + syntax: "*"; + inherits: false; + initial-value: normal; +} +@property --tw-animation-duration { + syntax: "*"; + inherits: false; +} +@property --tw-animation-fill-mode { + syntax: "*"; + inherits: false; + initial-value: none; +} +@property --tw-animation-iteration-count { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-enter-blur { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-enter-opacity { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-enter-rotate { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-enter-scale { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-enter-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-enter-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-exit-blur { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-exit-opacity { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-exit-rotate { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-exit-scale { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-exit-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-exit-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@layer utilities { + @scope ([data-bb-plugin="color-swatches"], [data-bb-plugin-root]:not([data-bb-plugin])) { + .visible { + visibility: visible; + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .block { + display: block; + } + .inline { + display: inline; + } + .ring { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-animation-delay: 0s; + --tw-animation-direction: normal; + --tw-animation-duration: initial; + --tw-animation-fill-mode: none; + --tw-animation-iteration-count: 1; + --tw-enter-blur: 0; + --tw-enter-opacity: 1; + --tw-enter-rotate: 0; + --tw-enter-scale: 1; + --tw-enter-translate-x: 0; + --tw-enter-translate-y: 0; + --tw-exit-blur: 0; + --tw-exit-opacity: 1; + --tw-exit-rotate: 0; + --tw-exit-scale: 1; + --tw-exit-translate-x: 0; + --tw-exit-translate-y: 0; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + } + } +} diff --git a/plugins/color-swatches/dist/app.js b/plugins/color-swatches/dist/app.js new file mode 100644 index 0000000..32327e9 --- /dev/null +++ b/plugins/color-swatches/dist/app.js @@ -0,0 +1,186 @@ +// bb-plugin-runtime-shim:@get-bb/plugin-sdk/app +var runtime = globalThis.__bbPluginRuntime; +if (runtime == null || runtime.pluginSdkApp == null) { + throw new Error('Cannot load "@get-bb/plugin-sdk/app": this bundle must be loaded by the BB app, which provides the shared plugin runtime (globalThis.__bbPluginRuntime).'); +} +var mod = runtime.pluginSdkApp; +var { + Markdown, + ThreadChat, + definePluginApp, + experimental_NewThreadComposer, + experimental_useSidebarThreadActions, + experimental_useSidebarThreadPullRequest, + experimental_useSidebarThreadSplit, + experimental_useSidebarThreads, + useBbContext, + useBbNavigate, + useComposer, + useComposerView, + useRealtime, + useRealtimeConnectionState, + useRpc, + useSettings +} = mod; + +// colors.ts +var HEX_LENGTHS = /* @__PURE__ */ new Set([3, 4, 6, 8]); +var FUNCTION_NAMES = [ + "rgb", + "rgba", + "hsl", + "hsla", + "hwb", + "lab", + "lch", + "oklab", + "oklch", + "color", + "color-mix" +]; +var PATTERN = new RegExp( + [ + "#[0-9a-fA-F]+", + `(?:${FUNCTION_NAMES.join("|")})\\([^()]*(?:\\([^()]*\\)[^()]*)*\\)` + ].join("|"), + "gi" +); +var isWordish = (char) => char !== void 0 && /[\w#$@-]/.test(char); +function findColorMatches(text) { + const out = []; + PATTERN.lastIndex = 0; + for (let m = PATTERN.exec(text); m !== null; m = PATTERN.exec(text)) { + const value = m[0]; + const start = m.index; + if (isWordish(text[start - 1])) continue; + if (value.startsWith("#")) { + const digits = value.length - 1; + if (!HEX_LENGTHS.has(digits)) continue; + if (/[\w-]/.test(text[start + value.length] ?? "")) continue; + } + out.push({ start, end: start + value.length, value }); + } + return out; +} + +// app.tsx +var ATTR = "data-bb-color-swatch"; +var PROP = "--bb-color-swatch"; +var 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); +} +`; +var EXCLUDED = "[contenteditable], input, textarea"; +var isColor = (value) => typeof CSS !== "undefined" && typeof CSS.supports === "function" ? CSS.supports("color", value) : true; +function decorate(el, value) { + el.setAttribute(ATTR, ""); + el.style.setProperty(PROP, value); +} +function undecorate(el) { + el.removeAttribute(ATTR); + el.style?.removeProperty(PROP); +} +function decorateTokenizedLine(line) { + const starts = /* @__PURE__ */ new Map(); + let text = ""; + for (const node of Array.from(line.childNodes)) { + if (node.nodeType === Node.ELEMENT_NODE) starts.set(text.length, node); + 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); + } +} +function decorateWholeElement(el) { + 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, selector) { + const matches = Array.from(root.querySelectorAll?.(selector) ?? []); + return root instanceof Element && root.matches(selector) ? [root, ...matches] : matches; +} +function scan(root) { + 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); + } +} +var app_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); + let pending = null; + const flush = () => { + const roots = pending ?? /* @__PURE__ */ new Set(); + pending = null; + for (const root of roots) { + if (root.isConnected !== false) scan(root); + } + }; + const queue = (node) => { + if (!pending) { + pending = /* @__PURE__ */ 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 : target.parentElement; + const scope = host?.closest?.("pre, .sh__line, [class*='markdown'], main") ?? host; + if (scope) queue(scope); + } + }); + 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; + } + }); +}); +export { + app_default as default +}; diff --git a/plugins/color-swatches/dist/app.meta.json b/plugins/color-swatches/dist/app.meta.json new file mode 100644 index 0000000..c2cfedb --- /dev/null +++ b/plugins/color-swatches/dist/app.meta.json @@ -0,0 +1,11 @@ +{ + "sdkMajor": 0, + "sdkVersion": "0.4.8", + "artifactFormatVersion": 1, + "pluginId": "color-swatches", + "pluginVersion": "0.1.0", + "builtWith": { + "bbVersion": "0.39.0", + "pluginSdkVersion": "0.4.8" + } +} diff --git a/plugins/color-swatches/dist/server.js b/plugins/color-swatches/dist/server.js new file mode 100644 index 0000000..569fd5b --- /dev/null +++ b/plugins/color-swatches/dist/server.js @@ -0,0 +1,15 @@ +import { createRequire as __createRequire } from "node:module"; +import { dirname as __pathDirname } from "node:path"; +import { fileURLToPath as __fileURLToPath } from "node:url"; +const require = __createRequire(import.meta.url); +var __filename = __fileURLToPath(import.meta.url); +var __dirname = __pathDirname(__filename); + +// server.ts +async function plugin(bb) { + bb.log.info("color-swatches ready"); +} +export { + plugin as default +}; +//# sourceMappingURL=server.js.map diff --git a/plugins/color-swatches/dist/server.js.map b/plugins/color-swatches/dist/server.js.map new file mode 100644 index 0000000..46fc232 --- /dev/null +++ b/plugins/color-swatches/dist/server.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../server.ts"], + "sourcesContent": ["import type { BbPluginApi } from \"@get-bb/plugin-sdk\";\n\n/**\n * All of the behaviour lives in the frontend content script; a plugin still\n * needs a backend entry, so this one only announces itself.\n */\nexport default async function plugin(bb: BbPluginApi) {\n bb.log.info(\"color-swatches ready\");\n}\n"], + "mappings": ";;;;;;;;AAMA,eAAO,OAA8B,IAAiB;AACpD,KAAG,IAAI,KAAK,sBAAsB;AACpC;", + "names": [] +} diff --git a/plugins/color-swatches/dist/server.meta.json b/plugins/color-swatches/dist/server.meta.json new file mode 100644 index 0000000..c2cfedb --- /dev/null +++ b/plugins/color-swatches/dist/server.meta.json @@ -0,0 +1,11 @@ +{ + "sdkMajor": 0, + "sdkVersion": "0.4.8", + "artifactFormatVersion": 1, + "pluginId": "color-swatches", + "pluginVersion": "0.1.0", + "builtWith": { + "bbVersion": "0.39.0", + "pluginSdkVersion": "0.4.8" + } +} diff --git a/plugins/color-swatches/docs/screenshot.png b/plugins/color-swatches/docs/screenshot.png new file mode 100644 index 0000000..097b4c2 Binary files /dev/null and b/plugins/color-swatches/docs/screenshot.png differ diff --git a/plugins/color-swatches/package.json b/plugins/color-swatches/package.json new file mode 100644 index 0000000..d80cd8b --- /dev/null +++ b/plugins/color-swatches/package.json @@ -0,0 +1,31 @@ +{ + "name": "bb-plugin-color-swatches", + "version": "0.1.0", + "type": "module", + "private": true, + "files": ["assets", "dist", "docs", "README.md"], + "engines": { "bb": ">=0.0.34", "bbPluginSdk": "^0.4.1" }, + "bb": { + "name": "Color Swatches", + "description": "Renders an inline swatch beside every color literal in a thread — hex, rgb(), hsl() and oklch() — the way an editor decorates code.", + "branding": { "icon": "./assets/color-chip.svg" }, + "server": "./server.ts", + "app": "./app.tsx", + "skills": [] + }, + "scripts": { + "build": "node ../../tooling/build-plugin.mjs", + "check": "npm run typecheck && npm run build && npm test", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@get-bb/plugin-sdk": "file:../../tooling/vendor/get-bb-plugin-sdk-0.4.8.tgz", + "@testing-library/react": "^16.3.2", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "jsdom": "^29.0.1", + "typescript": "^5.7.0", + "vitest": "^4.0.0" + } +} diff --git a/plugins/color-swatches/server.ts b/plugins/color-swatches/server.ts new file mode 100644 index 0000000..64242c6 --- /dev/null +++ b/plugins/color-swatches/server.ts @@ -0,0 +1,9 @@ +import type { BbPluginApi } from "@get-bb/plugin-sdk"; + +/** + * All of the behaviour lives in the frontend content script; a plugin still + * needs a backend entry, so this one only announces itself. + */ +export default async function plugin(bb: BbPluginApi) { + bb.log.info("color-swatches ready"); +} diff --git a/plugins/color-swatches/tsconfig.json b/plugins/color-swatches/tsconfig.json new file mode 100644 index 0000000..65a2fbe --- /dev/null +++ b/plugins/color-swatches/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM"], + "types": ["node", "vitest/globals"], + "noEmit": true, + "skipLibCheck": true + }, + "include": ["server.ts", "app.tsx", "colors.ts", "colors.test.ts"] +}