diff --git a/skills/roam-global-tailwind/SKILL.md b/skills/roam-global-tailwind/SKILL.md new file mode 100644 index 000000000..b0bb3831f --- /dev/null +++ b/skills/roam-global-tailwind/SKILL.md @@ -0,0 +1,37 @@ +--- +name: roam-global-tailwind +description: Use Roam Research's globally loaded Tailwind CSS safely in RoamJS extensions. Trigger when Codex writes, reviews, or refactors Roam extension UI that relies on Roam's global Tailwind utility classes; checks whether utilities, responsive or state variants, arbitrary syntax, newer Tailwind names, colors, or important modifiers are available; or decides whether styling must instead live in extension-owned CSS or a separately compiled Tailwind bundle. +--- + +# Roam global Tailwind + +Distinguish Roam's global stylesheet from CSS compiled by the current project. Apply this skill only to classes that rely on Roam's globally loaded `tailwind.min.css`; inspect the repository first when that scope is unclear. + +## Choose the styling source + +1. Use the project's own Tailwind configuration and generated CSS when the project compiles the class itself. +2. Otherwise, treat the class as dependent on Roam's global stylesheet. +3. Read [references/compatibility.md](references/compatibility.md) before selecting or reviewing global classes. +4. Run `node scripts/check-classes.mjs ` for any class not explicitly confirmed in the reference, and whenever current live behavior matters. + +Do not infer that a whole variant family is available from one confirmed class. Check complete class names because Roam's generated variants are utility-specific. + +## Handle unavailable classes + +- Prefer a confirmed Roam-global utility with equivalent behavior. +- Add extension-scoped CSS when no confirmed global utility exists. +- Compile the project's own Tailwind CSS only when the repository already owns or intentionally adopts that build path. +- Do not use `!utility` syntax against Roam's global stylesheet. Use narrowly scoped extension CSS with `!important` only when the cascade requires it. + +## Respond to stylesheet drift + +The checker compares the live file with the verified snapshot hash. If it reports drift: + +1. Trust each checker's per-class result for that fetched file. +2. Treat the static compatibility reference and version fingerprint as historical, not current. +3. Avoid broad claims about newly available utility families. +4. Report the new hash and recommend rerunning the full historical comparison before updating the reference. + +## Verify UI work + +After changing user-facing styling, run the project's tests and build, then inspect the rendered UI in Roam. Confirm the target class appears in the DOM and the expected computed style wins the cascade. diff --git a/skills/roam-global-tailwind/agents/openai.yaml b/skills/roam-global-tailwind/agents/openai.yaml new file mode 100644 index 000000000..f74c66dad --- /dev/null +++ b/skills/roam-global-tailwind/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Roam Global Tailwind" + short_description: "Use Roam-safe global Tailwind utilities" + default_prompt: "Use $roam-global-tailwind to verify that these Tailwind classes work in Roam." diff --git a/skills/roam-global-tailwind/references/compatibility.md b/skills/roam-global-tailwind/references/compatibility.md new file mode 100644 index 000000000..44f974ba2 --- /dev/null +++ b/skills/roam-global-tailwind/references/compatibility.md @@ -0,0 +1,57 @@ +# Roam global Tailwind compatibility + +## Scope and confidence + +This reference covers only `https://roamresearch.com/assets/css/tailwind.min.css`, not Tailwind CSS compiled by an extension. + +Verified snapshot: + +- Fetched: 2026-08-17 +- Bytes after transfer decoding: 3,200,288 +- SHA-256: `d2b34eb3b8e0519fbcf48718110ccdd35676b9ce9bf1be8594beba7eab3464ba` +- Decoded class selectors: 38,868 +- Compatibility: Tailwind CSS 2.1.x with very high confidence +- Exact patch: indeterminate; stable 2.1.0 through 2.1.4 generated byte-identical candidate CSS and exact class-selector sets + +Boundary comparisons found 857 selector differences against 2.0.4, 70 classes present in 2.2.0 but absent from Roam, and 151 present in 2.2.19 but absent from Roam. Treat `2.1.x` as a compatibility fingerprint, not proof of Roam's original package metadata. + +## Confirmed utility examples + +- Layout and position: `block`, `hidden`, `relative`, `absolute`, `sticky`, `flex`, `inline-flex`, `grid`, `grid-cols-12`, `gap-4`. +- Spacing and size: `p-4`, `mx-auto`, `space-x-4`, `space-x-reverse`, `w-full`, `h-screen`, `min-w-0`, `max-w-prose`. +- Typography: `text-sm`, `text-blue-500`, `font-semibold`, `leading-tight`, `truncate`. +- Visual: `bg-blue-500`, `border`, `rounded-md`, `shadow-lg`, `opacity-50`, `ring-offset-2`. +- Effects and motion: `transform`, `scale-95`, `transition`, `duration-200`, `animate-spin`, `filter`, `blur-sm`, `backdrop-blur-sm`. +- Tailwind 2 flex sizing names: `flex-grow`, `flex-grow-0`, `flex-shrink`, `flex-shrink-0`. + +## Confirmed variant examples + +The only generated variant prefixes found were: + +- Responsive: `sm:`, `md:`, `lg:`, `xl:`, `2xl:`. +- State and group: `hover:`, `focus:`, `focus-within:`, `group-hover:`. + +Confirmed complete classes include `sm:flex`, `2xl:grid`, `hover:bg-blue-500`, `focus:ring-2`, `focus-within:ring-2`, and `group-hover:opacity-100`. + +Variant generation is utility-specific. Check the complete proposed class with the bundled script. + +## Confirmed unavailable examples + +- Important modifiers: `!p-4`, `!bg-blue-500`, `sm:!p-4`. The snapshot contains zero `!important` declarations and zero class names containing `!`. +- Arbitrary or JIT syntax: `p-[13px]`, `bg-[#123456]`. +- Stacked or content variants: `hover:focus:bg-blue-500`, `before:content-[x]`. +- Newer variants: `peer-checked:block`, `dark:bg-gray-900`, `focus-visible:ring-2`, `motion-safe:transition`, `disabled:opacity-50`. +- Newer utilities: `aspect-square`, `basis-1/2`, `grow`, `shrink`, `columns-2`. +- Newer palette names: `bg-slate-500`, `text-emerald-500`; do not assume `zinc`, `neutral`, `stone`, `amber`, `sky`, or `rose`. + +## Important behavior + +Roam's global snapshot was not generated with Tailwind's `important` mode. A class such as `!p-4` has no matching rule. Standard CSS still supports `!important` in extension-owned styles: + +```css +.my-extension-control { + padding: 1rem !important; +} +``` + +Scope such overrides to the extension and use them only when specificity or host styles require them. diff --git a/skills/roam-global-tailwind/scripts/check-classes.mjs b/skills/roam-global-tailwind/scripts/check-classes.mjs new file mode 100644 index 000000000..ece6468d6 --- /dev/null +++ b/skills/roam-global-tailwind/scripts/check-classes.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const DEFAULT_URL = "https://roamresearch.com/assets/css/tailwind.min.css"; +const VERIFIED_SHA256 = + "d2b34eb3b8e0519fbcf48718110ccdd35676b9ce9bf1be8594beba7eab3464ba"; + +const parseArguments = (arguments_) => { + const options = { classNames: [], file: null, json: false, url: DEFAULT_URL }; + + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--json") { + options.json = true; + continue; + } + if (argument === "--file") { + const file = arguments_[index + 1]; + if (!file || file.startsWith("--")) { + throw new Error("--file requires a path"); + } + options.file = file; + index += 1; + continue; + } + if (argument === "--url") { + const url = arguments_[index + 1]; + if (!url || url.startsWith("--")) { + throw new Error("--url requires a URL"); + } + options.url = url; + index += 1; + continue; + } + options.classNames.push(argument); + } + + if (!options.classNames.length) { + throw new Error( + "Usage: check-classes.mjs [--json] [--file CSS_PATH | --url URL] CLASS...", + ); + } + return options; +}; + +const readCss = async ({ file, url }) => { + if (file) { + const resolvedPath = path.resolve(file); + return { bytes: await fs.readFile(resolvedPath), source: resolvedPath }; + } + + const response = await fetch(url, { + headers: { "cache-control": "no-cache", pragma: "no-cache" }, + }); + if (!response.ok) { + throw new Error( + `Download failed: ${response.status} ${response.statusText}`, + ); + } + return { + bytes: Buffer.from(await response.arrayBuffer()), + source: response.url, + }; +}; + +const isIdentifierCharacter = (character) => /[A-Za-z0-9_-]/.test(character); + +const decodeClassIdentifier = (css, startIndex) => { + let className = ""; + let index = startIndex; + + while (index < css.length) { + const character = css[index]; + if (isIdentifierCharacter(character)) { + className += character; + index += 1; + continue; + } + if (character !== "\\") break; + + index += 1; + const hexadecimalMatch = css.slice(index).match(/^[0-9A-Fa-f]{1,6}/); + if (hexadecimalMatch) { + className += String.fromCodePoint( + Number.parseInt(hexadecimalMatch[0], 16), + ); + index += hexadecimalMatch[0].length; + if (/\s/.test(css[index] ?? "")) index += 1; + continue; + } + if (index < css.length) { + className += css[index]; + index += 1; + } + } + + return { className, endIndex: index }; +}; + +const extractClassNames = (css) => { + const classNames = new Set(); + for (let index = 0; index < css.length; index += 1) { + if (css[index] !== ".") continue; + const nextCharacter = css[index + 1] ?? ""; + if (nextCharacter !== "\\" && !isIdentifierCharacter(nextCharacter)) + continue; + const decoded = decodeClassIdentifier(css, index + 1); + if (decoded.className) classNames.add(decoded.className); + index = decoded.endIndex - 1; + } + return classNames; +}; + +const main = async () => { + const options = parseArguments(process.argv.slice(2)); + const { bytes, source } = await readCss(options); + const sha256 = crypto.createHash("sha256").update(bytes).digest("hex"); + const classNames = extractClassNames(bytes.toString("utf8")); + const checks = Object.fromEntries( + options.classNames.map((className) => [ + className, + classNames.has(className), + ]), + ); + const result = { + source, + bytes: bytes.length, + sha256, + matchesVerifiedSnapshot: sha256 === VERIFIED_SHA256, + checks, + }; + + if (options.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + process.stdout.write(`Source: ${source}\n`); + process.stdout.write(`SHA-256: ${sha256}\n`); + process.stdout.write( + `Verified snapshot: ${result.matchesVerifiedSnapshot ? "match" : "DRIFT DETECTED"}\n`, + ); + for (const [className, available] of Object.entries(checks)) { + process.stdout.write(`${available ? "YES" : "NO "} ${className}\n`); + } + } + + if (Object.values(checks).some((available) => !available)) { + process.exitCode = 1; + } +}; + +main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 2; +}); diff --git a/skills/roam-global-tailwind/scripts/check-classes.test.mjs b/skills/roam-global-tailwind/scripts/check-classes.test.mjs new file mode 100644 index 000000000..90672e243 --- /dev/null +++ b/skills/roam-global-tailwind/scripts/check-classes.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_PATH = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "check-classes.mjs", +); + +const runChecker = (arguments_) => + spawnSync(process.execPath, [SCRIPT_PATH, ...arguments_], { + encoding: "utf8", + }); + +test("decodes escaped Tailwind class selectors", () => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "roam-global-tailwind-"), + ); + const cssPath = path.join(temporaryDirectory, "tailwind.css"); + fs.writeFileSync( + cssPath, + ".flex{display:flex}.\\32xl\\:grid{display:grid}.hover\\:bg-blue-500:hover{color:#00f}.w-1\\/2{width:50%}.\\!p-4{padding:1rem}", + ); + + try { + const result = runChecker([ + "--json", + "--file", + cssPath, + "flex", + "2xl:grid", + "hover:bg-blue-500", + "w-1/2", + "!p-4", + ]); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout).checks, { + flex: true, + "2xl:grid": true, + "hover:bg-blue-500": true, + "w-1/2": true, + "!p-4": true, + }); + } finally { + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); + } +}); + +test("returns one when a requested class is absent", () => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "roam-global-tailwind-"), + ); + const cssPath = path.join(temporaryDirectory, "tailwind.css"); + fs.writeFileSync(cssPath, ".flex{display:flex}"); + + try { + const result = runChecker(["--json", "--file", cssPath, "aspect-square"]); + assert.equal(result.status, 1, result.stderr); + assert.equal(JSON.parse(result.stdout).checks["aspect-square"], false); + } finally { + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); + } +}); + +test("rejects an incomplete file option", () => { + const result = runChecker(["--file"]); + assert.equal(result.status, 2); + assert.match(result.stderr, /--file requires a path/); +});