diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000..c845f4e --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,31 @@ +{ + "name": "@airship/protocol", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./tokens": { + "types": "./dist/tokens.d.ts", + "import": "./dist/tokens.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsup src/index.ts src/tokens.ts --format esm --dts --sourcemap --clean", + "clean": "rm -rf dist .turbo", + "dev": "tsup src/index.ts src/tokens.ts --format esm --dts --sourcemap --watch", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^4.0.0" + }, + "devDependencies": { + "vitest": "^4.1.10" + } +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 0000000..9c4bc9e --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,802 @@ +/** + * @airship/protocol — the shared contract between the daemon (`@airship/server` + + * `@airship/core`) and the browser overlay (`@airship/overlay`). + * + * Everything here is isomorphic: zod schemas are used by the server for runtime + * validation; the overlay imports the inferred *types* only (via `import type`) + * so zod is never pulled into the browser bundle. + */ +import { z } from "zod"; +import type { TokenScanResult } from "./tokens"; + +/** + * Token *types* re-exported for convenience. The category table, the value + * normalizer and everything else executable lives in `@airship/protocol/tokens` + * and must be imported from there directly — importing a value through this + * module would drag zod into the browser bundle, which is the one thing the + * split entry point exists to prevent. + */ +export type { + CssFramework, + DesignToken, + TokenCategory, + TokenKind, + TokenOrigin, + TokenRegistry, + TokenScanResult, +} from "./tokens"; + +// --------------------------------------------------------------------------- +// Agent backend + reasoning effort +// --------------------------------------------------------------------------- + +/** Which coding agent drives an edit. */ +export const AGENT_KINDS = ["claude", "codex", "opencode"] as const; +export const AgentKindSchema = z.enum(AGENT_KINDS); +export type AgentKind = z.infer; + +/** + * Reasoning effort, as the *union* of what the backends accept rather than the + * intersection: Claude has no `minimal` and Codex has no `max`, and + * intersecting would silently break existing `--effort max` invocations. Each + * provider clamps the one level it lacks (see `toClaudeEffort`/`toCodexEffort`). + * OpenCode exposes no reasoning-effort control at all and ignores the setting. + */ +export const EFFORT_LEVELS = [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; +export const EffortSchema = z.enum(EFFORT_LEVELS); +export type Effort = z.infer; + +// --------------------------------------------------------------------------- +// Element + source location +// --------------------------------------------------------------------------- + +export const SourceLocationSchema = z.object({ + column: z.number().int().optional(), + /** A few lines of surrounding code, resolved server-side for the agent. */ + context: z.string().optional(), + file: z.string(), + line: z.number().int().optional(), +}); +export type SourceLocation = z.infer; + +export const ElementContextSchema = z.object({ + classes: z.array(z.string()).default([]), + /** Framework component display name, when resolvable. */ + displayName: z.string().nullable().default(null), + /** CSS-ish selector hint for the picked node. */ + selector: z.string().optional(), + tagName: z.string(), + textPreview: z.string().default(""), +}); +export type ElementContext = z.infer; + +// --------------------------------------------------------------------------- +// Visual change set — the direct-manipulation edits accumulated in the design +// inspector. On "Apply" the overlay ships these deltas (not a code patch); the +// agent translates them into idiomatic source edits for the project's styling +// system. This is the key difference from a deterministic Tailwind writeback. +// --------------------------------------------------------------------------- + +/** + * Interaction states the inspector can force, so a user can inspect and edit + * styles that only apply on hover/focus/etc. + * + * A superset of the three states most editors expose. `:focus-visible` and + * `:disabled` are here because real design systems style them and there is no + * cost to discovering a rule we can already parse — the picker only offers the + * states some matched rule actually declares. + */ +export const PSEUDO_STATES = [ + ":hover", + ":focus", + ":focus-visible", + ":focus-within", + ":active", + ":disabled", +] as const; +export const PseudoStateSchema = z.enum(PSEUDO_STATES); +export type PseudoState = z.infer; + +/** + * The project's own name for a value, resolved by the overlay before the change + * is sent. This is what turns "padding-top: 16px" into "padding-top: --pk-space-4", + * and it is the difference between the agent guessing at the design system and + * being handed it. + */ +export const TokenRefSchema = z.object({ + /** The token's value, when it is only a near match — so the agent can judge. */ + actual: z.string().optional(), + /** True when the token's value equals the new value exactly. */ + exact: z.boolean(), + /** Where the token is defined, when it was found by the server-side scan. */ + file: z.string().optional(), + kind: z.enum(["css-var", "utility-class"]), + /** `--pk-space-4` or `.p-4`. */ + name: z.string(), + /** + * How we know. `reference` means the element names the token outright — a + * utility class it carries, a `var()` in its own style attribute, or a token + * the user just picked. `value` means only that the computed value happens to + * equal the token's, which is circumstantial: a hardcoded `16px` is + * indistinguishable from `var(--pk-space-md)` once the browser has resolved + * it. + * + * The inspector needs the difference because "detach" is only meaningful + * against a real reference. Offering it for a value match asked the user to + * unlink something they had never linked. + */ + via: z.enum(["reference", "value"]).optional(), +}); +export type TokenRef = z.infer; + +export const StyleChangeSchema = z.object({ + /** The value before the tweak (computed or inline). */ + from: z.string(), + /** + * The user explicitly detached this property from its token, so the agent must + * write a literal value rather than reaching for the scale. Without this an + * "unlink" is indistinguishable from "no token matched", and the agent would + * helpfully re-apply the token the user just rejected. + */ + hardcode: z.boolean().optional(), + /** CSS property name, kebab-case (e.g. "padding-top", "background-color"). */ + property: z.string(), + /** The value the user tweaked it to. */ + to: z.string(), + /** The project token matching `to`, when one does. */ + token: TokenRefSchema.optional(), +}); +export type StyleChange = z.infer; + +export const VisualEditTargetSchema = z.object({ + changes: z.array(StyleChangeSchema).min(1), + element: ElementContextSchema, + /** + * The selector these changes should be written to — a class the element + * carries, when the user widened the scope past this one instance. Absent + * means "this element", which is the default and was the only behaviour + * before scope targeting existed. + */ + scope: z.string().optional(), + source: SourceLocationSchema.nullable().default(null), + /** The interaction state being edited. Absent means the default state. */ + state: PseudoStateSchema.optional(), +}); +export type VisualEditTarget = z.infer; + +// --------------------------------------------------------------------------- +// Structural move — a direct-manipulation *reposition* in the DOM tree (drag to +// reorder among siblings or reparent into another container). Unlike a style +// delta this carries no CSS: it tells the agent to relocate the element's JSX to +// a new parent/position, preserving its props and children. +// --------------------------------------------------------------------------- + +export const MoveEditSchema = z.object({ + /** The sibling the element now renders immediately before. `null` ⇒ it was + * appended as the new parent's last child. */ + before: ElementContextSchema.nullable().default(null), + beforeSource: SourceLocationSchema.nullable().default(null), + /** The element being repositioned (context + source captured pre-move). */ + element: ElementContextSchema, + /** The container the element now lives in (null when unresolved). */ + newParent: ElementContextSchema.nullable().default(null), + newParentSource: SourceLocationSchema.nullable().default(null), + source: SourceLocationSchema.nullable().default(null), + /** 0-based element-child index within the new parent, for disambiguation. */ + toIndex: z.number().int().nonnegative().optional(), +}); +export type MoveEdit = z.infer; + +// --------------------------------------------------------------------------- +// Structural add/remove — deleting an element or duplicating it in place. Like +// a move this carries no CSS: it tells the agent to remove or clone the JSX. The +// overlay has already applied the change optimistically, so the agent is +// catching the source up to a DOM the user is already looking at. +// --------------------------------------------------------------------------- + +export const STRUCTURAL_OPS = ["delete", "duplicate"] as const; +export const StructuralOpSchema = z.enum(STRUCTURAL_OPS); +export type StructuralOp = z.infer; + +export const StructuralEditSchema = z.object({ + element: ElementContextSchema, + op: StructuralOpSchema, + source: SourceLocationSchema.nullable().default(null), +}); +export type StructuralEdit = z.infer; + +// --------------------------------------------------------------------------- +// HTML attributes — `alt`, `loading`, `autoplay`, `poster` and friends. +// +// Deliberately *not* folded into `visualChanges`. These are attributes on the +// element, not declarations in a stylesheet, and the agent edits them in a +// different place in the JSX. Describing `alt="…"` as a style change would make +// the prompt ask for something that does not exist. +// --------------------------------------------------------------------------- + +export const AttrChangeSchema = z.object({ + /** Attribute name as authored (`alt`, `loading`, `playsinline`). */ + attribute: z.string(), + /** Absent ⇒ the attribute was not present before. */ + from: z.string().nullable().default(null), + /** `null` ⇒ remove the attribute (a boolean attribute switched off). */ + to: z.string().nullable(), +}); +export type AttrChange = z.infer; + +export const AttrEditTargetSchema = z.object({ + changes: z.array(AttrChangeSchema).min(1), + element: ElementContextSchema, + source: SourceLocationSchema.nullable().default(null), +}); +export type AttrEditTarget = z.infer; + +// --------------------------------------------------------------------------- +// Text content — an in-place edit of a leaf text node. Deliberately the whole +// `textContent` rather than a diff: the agent needs to find the string in the +// source, and "the old text" is the only reliable way to locate it when the same +// component renders in several places. +// --------------------------------------------------------------------------- + +export const TextEditSchema = z.object({ + element: ElementContextSchema, + from: z.string(), + source: SourceLocationSchema.nullable().default(null), + to: z.string(), +}); +export type TextEditTarget = z.infer; + +// --------------------------------------------------------------------------- +// Image attachments (sent inline to Claude via the SDK's native image input) +// --------------------------------------------------------------------------- + +export const ImageInputSchema = z.object({ + /** Raw base64 (no `data:` prefix). */ + dataBase64: z.string(), + mediaType: z.string(), + /** Filename hint (display only). */ + name: z.string().optional(), +}); +export type ImageInput = z.infer; + +// --------------------------------------------------------------------------- +// Review comments — the user's feedback on an edit the agent already made. +// +// Deliberately carries the snippet alongside the line range. Line numbers refer +// to the file as the edit left it and drift the moment anything else touches it, +// so the text is what lets the agent re-find the code — the same reasoning +// `TextEditSchema` above relies on. +// --------------------------------------------------------------------------- + +export const CommentSchema = z.object({ + /** What the user actually said. */ + body: z.string(), + /** Repo-relative path, as it appears in the diff. */ + file: z.string(), + /** 1-based inclusive range in the post-edit file, when pinned to lines. */ + fromLine: z.number().int().optional(), + /** The job whose edit this comments on — lets the turn resume that session. */ + jobId: z.string().optional(), + /** The lines the comment points at, so the agent can re-locate by content. */ + snippet: z.string().default(""), + toLine: z.number().int().optional(), +}); +export type ReviewComment = z.infer; + +// --------------------------------------------------------------------------- +// Editors we know how to open a file in. +// --------------------------------------------------------------------------- + +export const EDITORS = ["vscode", "cursor", "windsurf", "zed"] as const; +export const EditorSchema = z.enum(EDITORS); +export type Editor = z.infer; + +// --------------------------------------------------------------------------- +// Jobs +// --------------------------------------------------------------------------- + +export const JOB_STATUSES = ["running", "done", "failed", "cancelled"] as const; +export const JobStatusSchema = z.enum(JOB_STATUSES); +export type JobStatus = z.infer; + +export const CreateJobRequestSchema = z + .object({ + /** Which backend to run this turn on. Absent means the daemon's launch + * default (`--agent`), which is what a client that predates the picker sends. */ + agent: AgentKindSchema.optional(), + /** Direct-manipulation HTML attribute edits (`alt`, `loading`, `autoplay`). */ + attrChanges: z.array(AttrEditTargetSchema).optional(), + /** Review feedback on the previous edit's diff. Like the delta arrays, + * these make `prompt` optional — a comments-only turn is a valid turn. */ + comments: z.array(CommentSchema).optional(), + element: ElementContextSchema.optional(), + /** Fork instead of continue — "try a different approach". */ + fork: z.boolean().optional(), + images: z.array(ImageInputSchema).optional(), + /** Direct-manipulation structural moves (drag-to-reposition in the tree). + * Like `visualChanges`, these make `prompt` optional. */ + moveChanges: z.array(MoveEditSchema).optional(), + /** Resume a previous job's agent session (multi-turn refinement). */ + parentJobId: z.string().optional(), + /** Free-text instruction. May be empty for a pure visual (deltas-only) edit, + * or an optional "note" that rides along with `visualChanges`. */ + prompt: z.string().default(""), + source: SourceLocationSchema.nullable().optional(), + /** Direct-manipulation deletes and duplicates. */ + structuralChanges: z.array(StructuralEditSchema).optional(), + /** In-place text edits. */ + textChanges: z.array(TextEditSchema).optional(), + /** Direct-manipulation style deltas from the design inspector. When present, + * `prompt` is optional and treated as an accompanying note. */ + visualChanges: z.array(VisualEditTargetSchema).optional(), + }) + .refine( + (r) => + r.prompt.trim().length > 0 || + (r.visualChanges?.length ?? 0) > 0 || + (r.moveChanges?.length ?? 0) > 0 || + (r.structuralChanges?.length ?? 0) > 0 || + (r.textChanges?.length ?? 0) > 0 || + (r.attrChanges?.length ?? 0) > 0 || + (r.comments?.length ?? 0) > 0, + { + message: + "either prompt, visualChanges, moveChanges, structuralChanges, textChanges, attrChanges, or comments is required", + } + ); +export type CreateJobRequest = z.infer; + +// --------------------------------------------------------------------------- +// Diffs (captured via Agent SDK hooks, rendered by the overlay) +// --------------------------------------------------------------------------- + +export const FileDiffSchema = z.object({ + additions: z.number().int(), + after: z.string().nullable().optional(), + before: z.string().nullable().optional(), + deletions: z.number().int(), + file: z.string(), + isDeleted: z.boolean(), + isNew: z.boolean(), + patch: z.string(), +}); +export type FileDiff = z.infer; + +// --------------------------------------------------------------------------- +// Structured output (the Agent SDK returns this typed object per edit) +// --------------------------------------------------------------------------- + +export const EditStructuredOutputSchema = z.object({ + filesChanged: z.array(z.string()), + followUps: z.array(z.string()), + summary: z.string(), +}); +export type EditStructuredOutput = z.infer; + +/** JSON Schema handed to the SDK `outputFormat` option (kept literal to avoid a + * zod→JSON-schema conversion dependency). Mirrors {@link EditStructuredOutputSchema}. */ +export const EDIT_OUTPUT_JSON_SCHEMA = { + additionalProperties: false, + properties: { + filesChanged: { + description: "Repo-relative paths of files that were edited.", + items: { type: "string" }, + type: "array", + }, + followUps: { + description: + "Up to 3 suggested follow-up edits the user might want next.", + items: { type: "string" }, + type: "array", + }, + summary: { + description: "One concise sentence describing what was changed.", + type: "string", + }, + }, + required: ["summary", "filesChanged", "followUps"], + type: "object", +} as const; + +// --------------------------------------------------------------------------- +// Todos (surfaced from Claude's TodoWrite tool for multi-step edits) +// --------------------------------------------------------------------------- + +export const TodoItemSchema = z.object({ + content: z.string(), + status: z.enum(["pending", "in_progress", "completed"]), +}); +export type TodoItem = z.infer; + +// --------------------------------------------------------------------------- +// Agent activity timeline — the ordered log of what the agent did during a job. +// Live it arrives as deltas over the socket; finished it is persisted verbatim +// into `JobDiffBundle.timeline`, so reopening a thread replays the identical +// sequence rather than an approximation of it. +// +// Deliberately plain interfaces, not zod: the overlay imports these as types +// only, which is what keeps zod out of the injected browser bundle. Server → +// client payloads are never re-validated, so there is nothing to validate with. +// --------------------------------------------------------------------------- + +export type ToolPhase = "pending" | "ok" | "error"; + +/** The one-line `⎿` text under a tool, plus its optional expanded body. Produced + * by `@airship/core` and already size-capped — a `Read` returns a whole file and + * a build returns hundreds of KB, none of which should cross the wire or land in + * `~/.airship/history`. */ +export interface ToolResultSummary { + /** Expanded detail (tail of stdout, grep hits, the patch). Pre-truncated. */ + detail?: string; + /** Lines dropped by truncation, for the "… +N lines" affordance. */ + droppedLines?: number; + /** "Read 214 lines" · "+12 −4" · "exit 1". Capped to ~120 chars. */ + text: string; + /** True when `detail` was clipped. */ + truncated?: boolean; +} + +interface TimelineItemBase { + /** Stable key. For tools this is the SDK `tool_use.id` — the join key with the + * `tool_result` block carried by the *following* user message. Synthetic for + * text/thinking blocks, which have no SDK id. */ + id: string; + /** ms since job start. Relative, so bundles stay diffable and clock-agnostic. */ + startedAt: number; +} + +export interface TimelineToolItem extends TimelineItemBase { + /** Whitelisted, stringified, capped subset of the tool input. Never the raw + * input — `Write.content` and `MultiEdit.edits` are unbounded. */ + args: Record; + endedAt?: number; + kind: "tool"; + /** Raw SDK tool name: "Read" | "Bash" | "MultiEdit" | "mcp__airship__…". */ + name: string; + /** SDK `parent_tool_use_id`, set for subagent-nested calls. Reserved. */ + parentToolUseId?: string | null; + phase: ToolPhase; + /** Absent while `phase === "pending"`. */ + result?: ToolResultSummary; + /** Mono header text: `Read(src/app.ts)`, `Bash(pnpm build)`. */ + title: string; +} + +export interface TimelineThinkingItem extends TimelineItemBase { + estimatedTokens?: number; + kind: "thinking"; + streaming?: boolean; + /** May be "" during the redacted-thinking phase, where the API streams only + * token estimates — render `estimatedTokens` instead of an empty box. */ + text: string; +} + +export interface TimelineTextItem extends TimelineItemBase { + kind: "text"; + streaming?: boolean; + /** Assistant prose, markdown. Rendered in sans — not part of the tool grammar. */ + text: string; +} + +export interface TimelineTodosItem extends TimelineItemBase { + kind: "todos"; + /** `id` is the TodoWrite `tool_use.id`, so a re-write patches in place rather + * than appending a second list. */ + todos: TodoItem[]; +} + +export type TimelineItem = + | TimelineToolItem + | TimelineThinkingItem + | TimelineTextItem + | TimelineTodosItem; + +/** + * A partial update to an already-appended item, addressed by `id`. The fields + * are a flat union across kinds; a consumer applies only what it understands. + * + * `textDelta` and `text` are both present on purpose: streaming appends via + * `textDelta`, and the final block commit sends `text` as an absolute replace. + * The deltas arrive *before* the assistant message that repeats the same prose, + * so an append-only model would double it. + */ +export interface TimelineItemPatch { + endedAt?: number; + estimatedTokens?: number; + phase?: ToolPhase; + result?: ToolResultSummary; + streaming?: boolean; + /** Replace `text` wholesale. Always wins over accumulated deltas. */ + text?: string; + /** Append to `text` (streaming). */ + textDelta?: string; + todos?: TodoItem[]; +} + +// --------------------------------------------------------------------------- +// Usage / cost +// --------------------------------------------------------------------------- + +export const UsageSchema = z.object({ + costUsd: z.number().optional(), + inputTokens: z.number().int().optional(), + outputTokens: z.number().int().optional(), +}); +export type Usage = z.infer; + +// --------------------------------------------------------------------------- +// History bundles +// --------------------------------------------------------------------------- + +export interface JobTargetSummary { + displayName: string | null; + source: SourceLocation | null; + tagName: string; +} + +export interface JobHistorySummary { + additions: number; + /** Which backend produced this job. Absent on bundles written before the + * Codex backend existed, which are all Claude by construction — so readers + * should treat `undefined` as `"claude"`. Load-bearing: `sessionId` means a + * `~/.claude` session for one agent and a `~/.codex` thread for the other, so + * resume must not cross backends. */ + agent?: AgentKind; + /** File-checkpoint id (first user-message uuid) for SDK `rewindFiles`. */ + checkpointId?: string; + completedAt?: number; + createdAt: number; + deletions: number; + error?: string; + filesChanged: number; + jobId: string; + parentJobId?: string; + promptPreview: string; + /** Agent session id — a Claude session or a Codex thread, per `agent`. Used + * to resume/fork the underlying SDK session. */ + sessionId?: string; + status: JobStatus; + target: JobTargetSummary; +} + +export interface JobDiffBundle extends JobHistorySummary { + diffs: FileDiff[]; + followUps?: string[]; + prompt: string; + summary?: string; + /** The full activity log, in order. Optional: bundles written before the + * timeline existed replay as an empty sequence and render as they always did. + * Stripped by `toSummary()` in the server's history module — it is the one + * heavy field on the bundle and must never ride along in a history listing. */ + timeline?: TimelineItem[]; + usage?: Usage; +} + +export interface JobSnapshot { + createdAt: number; + error?: string; + jobId: string; + prompt?: string; + status: JobStatus; + step?: string; +} + +// --------------------------------------------------------------------------- +// WebSocket protocol: server → client +// --------------------------------------------------------------------------- + +export type ServerEvent = + /** `defaultAgent` is the daemon's `--agent` setting, so the composer's picker + * can render the right backend on first paint instead of guessing. */ + | { type: "hello"; jobs: JobSnapshot[]; defaultAgent: AgentKind } + | { type: "job:created"; job: JobSnapshot } + /** Coarse one-line status ("Reading foo.tsx"). Self-overwriting by design — + * it drives the turn's live status pill, not the transcript body. */ + | { type: "job:step"; jobId: string; step: string } + /** @deprecated Superseded by `job:timeline` text items, which persist and + * replay. Still broadcast so an older overlay keeps streaming prose. */ + | { type: "job:text"; jobId: string; delta: string } + /** @deprecated Superseded by `job:timeline` todos items. */ + | { type: "job:todos"; jobId: string; todos: TodoItem[] } + /** A new item was appended to the job's activity timeline. */ + | { type: "job:timeline"; jobId: string; item: TimelineItem } + /** An already-appended timeline item changed (result arrived, prose streamed). + * A patch for an unknown id is ignorable — that is the mid-job-reconnect case, + * repaired wholesale by the `job:done` bundle. */ + | { + type: "job:timeline:patch"; + jobId: string; + id: string; + patch: TimelineItemPatch; + } + | { + type: "job:status"; + jobId: string; + status: JobStatus; + error?: string; + } + | { type: "job:done"; jobId: string; bundle: JobDiffBundle } + | { type: "history"; entries: JobHistorySummary[] } + | { type: "thread"; rootJobId: string; entries: JobDiffBundle[] } + /** The project's design tokens, scanned from the files on disk. Answers a + * `tokens` request; also pushed unprompted once the first scan completes. */ + | { type: "tokens:result"; scan: TokenScanResult } + /** The assembled instruction for a `prompt` request — the exact string the + * adapter would receive. Sent to the asking socket only: a broadcast would let + * one tab's composer overwrite another's preview. */ + | { type: "prompt:result"; text: string } + | { type: "undo:result"; jobId: string; ok: boolean; error?: string } + | { + type: "commit:result"; + ok: boolean; + sha?: string; + pushed?: boolean; + error?: string; + } + | { + type: "open:result"; + ok: boolean; + file: string; + editor?: string; + error?: string; + } + /** `stage` names where a multi-step PR flow stopped, so the toast can say + * "no origin remote" rather than "failed". */ + | { + type: "pr:result"; + ok: boolean; + url?: string; + stage?: "branch" | "commit" | "create" | "preflight" | "push"; + error?: string; + } + | { type: "error"; message: string }; + +// --------------------------------------------------------------------------- +// WebSocket protocol: client → server +// --------------------------------------------------------------------------- + +export const ClientMessageSchema = z.discriminatedUnion("type", [ + z.object({ request: CreateJobRequestSchema, type: z.literal("edit") }), + z.object({ jobId: z.string(), type: z.literal("cancel") }), + z.object({ jobId: z.string(), type: z.literal("undo") }), + z.object({ type: z.literal("history") }), + z.object({ rootJobId: z.string(), type: z.literal("thread") }), + /** Scan the project's CSS for design tokens. Read-only, so the server answers + * it off the edit chain. `refresh` busts the mtime cache after a token file + * changes under the daemon. */ + z.object({ + refresh: z.boolean().optional(), + type: z.literal("tokens"), + }), + /** + * Render the prompt this request would produce, without running it. Read-only, + * so the server answers it off the edit chain like `tokens`. + * + * It runs the same source backfill and token scan a real turn does, which is + * why this is a round trip at all: the file/line context and the design-scale + * legend are only knowable server-side, so a client-side re-derivation would + * show the user a string the agent never receives. + */ + z.object({ request: CreateJobRequestSchema, type: z.literal("prompt") }), + z.object({ + jobId: z.string(), + message: z.string().optional(), + push: z.boolean().optional(), + type: z.literal("commit"), + }), + /** Open a file in the user's editor. `file` is repo-relative and is resolved + * against the daemon's cwd — and validated to stay inside it. */ + z.object({ + column: z.number().int().optional(), + editor: EditorSchema.optional(), + file: z.string(), + line: z.number().int().optional(), + type: z.literal("open"), + }), + z.object({ + branch: z.string().optional(), + jobId: z.string(), + title: z.string().optional(), + type: z.literal("pr"), + }), +]); +export type ClientMessage = z.infer; + +/** + * How the overlay bundle should boot. The same IIFE is injected into all three + * surfaces and picks its role from this. + * + * - `shell` — the canvas: pan/zoom viewport, docks, chat, inspector. Owns the + * only control WebSocket. The user's app is *not* in this document. + * - `frame` — inside a frame iframe. Installs the realm-local agent the shell + * calls into and nothing else: no UI, no socket. + * - `inline` — the original single-document overlay, injected straight into the + * running app. A first-class surface, selected with `airship --mode inline` + * or the editor's own switcher. + */ +export type AirshipMode = "shell" | "frame" | "inline"; + +/** + * The two surfaces a user can actually choose between, in the words the CLI and + * the editor use for them. + * + * Deliberately not the same vocabulary as `AirshipMode`. `shell` describes what + * the document *is* to the proxy; `canvas` describes what the user sees, and is + * the word every doc, flag and button uses. `frame` is internal plumbing — it is + * a mode but never a surface, which is why this cannot just be a subset type. + * + * `mode` is also already taken inside the overlay for edit-vs-view + * (`data-__airship-mode="edit"`), so everything below the CLI boundary says + * "surface" to keep the two apart. + */ +export type AirshipSurface = "canvas" | "inline"; + +export function surfaceToMode(surface: AirshipSurface): AirshipMode { + return surface === "canvas" ? "shell" : "inline"; +} + +/** `frame` has no surface of its own; it reports the canvas that owns it. */ +export function modeToSurface(mode: AirshipMode): AirshipSurface { + return mode === "inline" ? "inline" : "canvas"; +} + +/** Query parameter the proxy branches on when serving HTML. */ +export const AIRSHIP_MODE_PARAM = "__airship"; + +/** + * Sticky surface preference, written by the editor's own switcher. + * + * A cookie and not localStorage because the choice has to be made by the proxy, + * before any script runs — it decides whether the response body is the canvas + * shell or the app itself. Scoped to the proxy origin, so it never travels to + * the app's real host. + */ +export const AIRSHIP_SURFACE_COOKIE = "__airship_surface"; + +/** Read the surface cookie out of a raw `Cookie:` header. */ +export function readSurfaceCookie( + header: string | undefined +): AirshipSurface | undefined { + if (!header) { + return; + } + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq < 1) { + continue; + } + if (part.slice(0, eq).trim() !== AIRSHIP_SURFACE_COOKIE) { + continue; + } + const value = part.slice(eq + 1).trim(); + if (value === "canvas" || value === "inline") { + return value; + } + } +} + +/** Prefix of the `window.name` a frame iframe is created with, plus its id. */ +export const AIRSHIP_FRAME_NAME = "__airship-frame:"; + +/** Global injected into the page before the overlay bundle loads. */ +export interface AirshipWindowConfig { + /** Which surface this document is. Absent ⇒ `inline` (older injections). */ + mode?: AirshipMode; + /** + * The app path this document belongs to, `__airship` stripped. + * + * The shell points its frames at it — requesting `/settings` serves the shell, + * which then loads `/settings?__airship=frame` into every frame. Inline gets + * it too, so its surface switcher can navigate to the clean path rather than + * reconstructing one from a URL that may still carry an explicit override. + */ + pathname?: string; + wsPath: string; +} diff --git a/packages/protocol/src/tokens.test.ts b/packages/protocol/src/tokens.test.ts new file mode 100644 index 0000000..ef0bc11 --- /dev/null +++ b/packages/protocol/src/tokens.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { categorizeToken, categoryForProperty } from "./tokens"; + +/* + * Which scale a custom property belongs to. + * + * This had no tests at all, and it is the one place in the codebase where a + * wrong answer becomes wrong CSS in somebody's source: a token in the wrong + * category is offered by the wrong picker, and picking it writes its value into + * a property it was never meant for. That is not hypothetical — a box-shadow + * classified as a font family reached the font picker, and `firstFamily` served + * up `0 8px 32px rgba(0` as a family name. + * + * Three tiers, best evidence first: the properties the token is used on, then + * its name, then its value. Each block below is one tier, and the last is the + * regression suite for the values that used to be misread. + */ + +const at = (name: string, value: string, usedOn?: string[]) => + categorizeToken({ name, usedOn, value }); + +describe("tier 1 — the properties a token is used on", () => { + it("beats both the name and the value", () => { + // Named like spacing, valued like a length, used as a radius. Usage wins. + expect(at("--space-2", "8px", ["border-radius"])).toBe("border-radius"); + }); + + it("takes the majority when a token is used on several", () => { + expect( + at("--x", "8px", ["padding-top", "padding-left", "border-radius"]) + ).toBe("spacing"); + }); + + it("abstains rather than guessing when no usage is in a known category", () => { + // `transition-timing-function` is in no category, so the vote is empty and + // the name and value tiers get their turn. This is why `--ease-*` has to be + // caught below rather than here. + expect(categoryForProperty("transition-timing-function")).toBeNull(); + expect( + at("--ease-panel", "ease-in-out", ["transition-timing-function"]) + ).toBe(null); + }); +}); + +describe("tier 2 — the name", () => { + it("reads an unprefixed name", () => { + expect(at("--shadow-lg", "0 4px 8px #0003")).toBe("box-shadow"); + expect(at("--radius-sm", "4px")).toBe("border-radius"); + expect(at("--font-sans", "Whatever")).toBe("font-family"); + }); + + /* + * The bug this suite exists for. Every pattern was anchored at `^--`, so a + * design system that namespaces its tokens got no name tier at all — and + * `--pk-elevation-floating` fell through to the value tier, where the only + * test it matched was "contains a comma". + */ + it("reads a namespaced name, which it used to ignore entirely", () => { + expect(at("--pk-elevation-floating", "0 8px 32px rgba(0,0,0,0.18)")).toBe( + "box-shadow" + ); + expect(at("--pk-radius-none", "0px")).toBe("border-radius"); + expect(at("--pk-radius-full", "9999px")).toBe("border-radius"); + expect(at("--brand-color-accent", "#0af")).toBe("colors"); + }); + + it("still only matches at a segment boundary", () => { + // Not "…-shadow", so the name says nothing and the value decides. + expect(at("--overshadowed", "12px")).toBe("spacing"); + }); +}); + +describe("tier 3 — the value", () => { + it("reads colours, weights and bare lengths", () => { + expect(at("--x", "#0af")).toBe("colors"); + expect(at("--x", "rgb(0 128 255)")).toBe("colors"); + expect(at("--x", "600")).toBe("font-weight"); + expect(at("--x", "12px")).toBe("spacing"); + }); + + it("reads a font stack", () => { + expect(at("--x", "Inter, system-ui, sans-serif")).toBe("font-family"); + expect(at("--x", '"Inter", "Inter Fallback", system-ui')).toBe( + "font-family" + ); + expect(at("--x", "JetBrains Mono, ui-monospace, monospace")).toBe( + "font-family" + ); + }); + + it("reads a shadow, which had no branch here at all", () => { + expect(at("--x", "0 8px 32px rgba(0,0,0,0.18)")).toBe("box-shadow"); + expect( + at("--x", "0 0 0 0.5px rgba(0,0,0,0.15), 0 6px 20px 4px #0003") + ).toBe("box-shadow"); + expect(at("--x", "inset 0 1px 2px #0002")).toBe("box-shadow"); + }); + + it("declines a lone bare word", () => { + /* + * `Inter` is a plausible family and so is `none`, and this tier only runs + * when usage and name have both declined to say. One unquoted word is not + * evidence; a real single-family token is reached by its name or its use. + */ + expect(at("--x", "Inter")).toBeNull(); + expect(at("--x", "none")).toBeNull(); + expect(at("--x", "auto")).toBeNull(); + }); + + it("keeps a lone family that is quoted or generic", () => { + expect(at("--x", '"Inter"')).toBe("font-family"); + expect(at("--x", "monospace")).toBe("font-family"); + expect(at("--x", "ui-monospace")).toBe("font-family"); + }); +}); + +describe("the values that used to become fonts", () => { + /* + * `if (v.includes(","))` was the whole font-family test. A comma is shared by + * shadows, easings, gradients, transforms and every multi-argument colour + * function in CSS, so all of them landed in the font picker. + */ + it("does not read an easing as a font", () => { + for (const easing of [ + "cubic-bezier(0.215, 0.61, 0.355, 1)", + "cubic-bezier(0.34, 1.56, 0.64, 1)", + ]) { + expect(at("--ease-x", easing)).not.toBe("font-family"); + // Nothing in the inspector can edit an easing, so `null` is the right + // answer — `categorizeToken`'s callers drop what they cannot place. + expect(at("--ease-x", easing)).toBeNull(); + } + }); + + it("does not read a shadow as a font", () => { + expect(at("--x", "0 8px 32px rgba(0,0,0,0.18)")).not.toBe("font-family"); + }); + + it("does not read a gradient or a transform as a font", () => { + expect(at("--x", "linear-gradient(90deg, #fff, #000)")).not.toBe( + "font-family" + ); + expect(at("--x", "translate(4px, 8px)")).not.toBe("font-family"); + }); + + it("does not read a breakpoint as a spacing step", () => { + // Named, so the name tier catches it before the bare-length rule can. + expect(at("--pk-layout-breakpoint-nav", "1240px")).toBe("sizing"); + }); +}); diff --git a/packages/protocol/src/tokens.ts b/packages/protocol/src/tokens.ts new file mode 100644 index 0000000..5b977db --- /dev/null +++ b/packages/protocol/src/tokens.ts @@ -0,0 +1,553 @@ +/** + * @airship/protocol/tokens — the design-token vocabulary, shared by the server + * scanner (`@airship/source/tokens`) and the overlay's registry/resolver. + * + * **This module must never import zod.** It is a separate entry point precisely + * so the overlay can import the category table and the normalizer as *values* + * without pulling `index.ts` — and therefore zod — into the injected browser + * bundle. Everything here is plain data and pure functions: no DOM, no Node. + * + * The wire-validated `TokenRef` lives in `index.ts` instead, because it rides + * inside `CreateJobRequest` and the server does validate that. + */ + +// --------------------------------------------------------------------------- +// Categories +// --------------------------------------------------------------------------- + +/** + * What kind of scale a token belongs to. A token only ever matches a value on a + * property in its own category — without this, `opacity: 1` matches a + * `--line-height-1` and the agent is told to write nonsense. + * + * There was a `layout` category here, over `display`, `flex-direction`, + * `align-items`, `justify-content`, `flex-wrap` and `position`. It is gone, + * because none of those is a *scale*. Nobody ships a `--display-3`, so the only + * things that ever landed in it were single-declaration component rules the + * scanner could not tell apart from a token — `.hamburger { display: flex }` — + * and the controls that edit those properties are segmented groups and selects, + * which have no way to show a binding. A category with no scale behind it and + * no control in front of it was a badge that could only ever mislead. + */ +export const TOKEN_CATEGORIES = [ + "spacing", + "sizing", + "colors", + "font-size", + "font-weight", + "line-height", + "letter-spacing", + "font-family", + "border-radius", + "border-width", + "box-shadow", + "opacity", +] as const; +export type TokenCategory = (typeof TOKEN_CATEGORIES)[number]; + +/** Physical + logical sides, the shape most box properties repeat. */ +const SIDES = ["top", "right", "bottom", "left"] as const; +const LOGICAL_SIDES = [ + "inline-start", + "inline-end", + "block-start", + "block-end", +] as const; + +function box(prefix: string, suffix = ""): string[] { + const tail = suffix ? `-${suffix}` : ""; + return [ + `${prefix}${tail}`, + ...SIDES.map((s) => `${prefix}-${s}${tail}`), + `${prefix}-inline${tail}`, + `${prefix}-block${tail}`, + ...LOGICAL_SIDES.map((s) => `${prefix}-${s}${tail}`), + ]; +} + +const CATEGORY_PROPERTIES: Record = { + "border-radius": [ + "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + "border-start-start-radius", + "border-start-end-radius", + "border-end-start-radius", + "border-end-end-radius", + ], + "border-width": [ + "border-width", + ...SIDES.map((s) => `border-${s}-width`), + ...LOGICAL_SIDES.map((s) => `border-${s}-width`), + "outline-width", + // Vector strokes share the border scale — the Vector section writes this. + "stroke-width", + ], + "box-shadow": ["box-shadow"], + colors: [ + "color", + "background-color", + "border-color", + ...SIDES.map((s) => `border-${s}-color`), + ...LOGICAL_SIDES.map((s) => `border-${s}-color`), + "outline-color", + "text-decoration-color", + "accent-color", + "caret-color", + "fill", + "stroke", + ], + "font-family": ["font-family"], + "font-size": ["font-size"], + "font-weight": ["font-weight"], + "letter-spacing": ["letter-spacing"], + "line-height": ["line-height"], + opacity: ["opacity"], + sizing: [ + "width", + "height", + "min-width", + "max-width", + "min-height", + "max-height", + "inline-size", + "block-size", + "min-inline-size", + "max-inline-size", + "min-block-size", + "max-block-size", + ], + spacing: [ + ...box("padding"), + ...box("margin"), + "gap", + "row-gap", + "column-gap", + ], +}; + +/** Reverse index, built once. */ +const PROPERTY_CATEGORY: ReadonlyMap = new Map( + TOKEN_CATEGORIES.flatMap((category) => + CATEGORY_PROPERTIES[category].map( + (property) => [property, category] as const + ) + ) +); + +/** The category a kebab-case CSS property draws its tokens from, if any. */ +export function categoryForProperty(property: string): TokenCategory | null { + return PROPERTY_CATEGORY.get(property) ?? null; +} + +/** Every property that draws on a given category. */ +export function propertiesForCategory( + category: TokenCategory +): readonly string[] { + return CATEGORY_PROPERTIES[category]; +} + +// --------------------------------------------------------------------------- +// Categorizing a custom property +// +// A utility class declares the property it affects, so its category is a lookup. +// A custom property declares nothing — `--brand: #0af` could be a colour, and +// `--step-2: 8px` could be spacing or sizing. Three tiers, best evidence first, +// shared by the static and runtime scanners so a token cannot land in one +// category on disk and another in the browser. +// --------------------------------------------------------------------------- + +/** + * Name-shaped hints, in priority order. Only consulted when usage says nothing. + * + * Every one of these used to be anchored at `^--`, which made the whole tier + * dead for any design system that namespaces. `--pk-elevation-floating` never + * matched the `elevation` rule written for it, fell through to the value tier, + * and was classified by the one thing its value had in common with a font stack: + * a comma. The sole exception was `-(spacing|space)-`, unanchored — and + * `--pk-space-4` was correspondingly the only `--pk-*` token that landed right. + * + * `SEG` generalises that exception: a segment boundary is the start of the name + * or a hyphen, so `--pk-elevation-` and `--elevation-` both match and `--x-not- + * elevation` still does not have to be special-cased. Prefixes are ordinary + * naming, not a special case to be tolerated grudgingly. + */ +const SEG = "(?:^--|-)"; +const NAME_PATTERNS: [RegExp, TokenCategory][] = [ + [ + new RegExp(`${SEG}(spacing|space|gap|pad|padding|margin|inset)\\b`, "i"), + "spacing", + ], + /* + * `breakpoint`, `screen`, `container` and `layout` are here because otherwise + * nothing claims them and the value tier files every one under spacing — so a + * padding field offered `1240px` as a step. They are widths a box is measured + * against, which is what sizing means. Spacing is tested first, so a + * `--container-padding` still reaches the right one. + */ + [ + new RegExp( + `${SEG}(size|width|height|measure|breakpoint|screen|container|layout)\\b`, + "i" + ), + "sizing", + ], + [ + new RegExp( + `${SEG}(color|colour|bg|background|foreground|fg|text-color|border-color|accent|muted|destructive|primary|secondary|surface|brand|success|warning|danger|error|info)\\b`, + "i" + ), + "colors", + ], + [ + new RegExp(`${SEG}(font-size|text)-(?:xs|sm|base|md|lg|xl|\\d)`, "i"), + "font-size", + ], + [new RegExp(`${SEG}(font-weight|weight)\\b`, "i"), "font-weight"], + [new RegExp(`${SEG}(leading|line-height)\\b`, "i"), "line-height"], + [new RegExp(`${SEG}(tracking|letter-spacing)\\b`, "i"), "letter-spacing"], + [ + new RegExp( + `${SEG}(font-family|font)-(?:sans|serif|mono|display|body|heading)`, + "i" + ), + "font-family", + ], + [ + new RegExp(`${SEG}(radius|rounded|border-radius|corner)\\b`, "i"), + "border-radius", + ], + [ + new RegExp(`${SEG}(border-width|border-w|stroke-width|stroke)\\b`, "i"), + "border-width", + ], + [new RegExp(`${SEG}(shadow|elevation)\\b`, "i"), "box-shadow"], + [new RegExp(`${SEG}(opacity|alpha)\\b`, "i"), "opacity"], + [new RegExp(`${SEG}(font|text)\\b`, "i"), "font-size"], +]; + +const HEX_OR_FUNC_COLOR = + /^(#|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|color\()/i; +const NUMERIC_LENGTH = /^-?[\d.]+(px|r?em|%|v[hw]|ch|ex)$/; +const THREE_DIGITS = /^\d{3}$/; +/** Whole-value now, not a prefix — so `ui-` has to spell out what it heads. */ +const GENERIC_FAMILY = + /^(ui-[a-z-]+|system-ui|-apple-system|blinkmacsystemfont|sans-serif|serif|monospace|cursive|fantasy|emoji|math)$/i; + +/** A quoted name, or a bare one: letters, digits, spaces, hyphens. Nothing else. */ +const FAMILY_NAME = /^(?:"[^"]*"|'[^']*'|[a-z][a-z0-9\s-]*)$/i; +const QUOTED = /^["']/; +/** The keyword no other kind of token value uses. */ +const INSET = /(^|\s)inset(\s|$)/i; +/** Whitespace-delimited numbers, with or without a unit — a bare `0` counts. */ +const SHADOW_LENGTHS = /(?:^|\s)-?[\d.]+(?:px|r?em|%|v[hw]|ch|ex)?(?=\s|$)/g; +/** Any length, with or without a unit — `0` counts, which is why `\b` is needed. */ +const LENGTH = /(^|[\s(])-?[\d.]+(px|r?em|%|v[hw]|ch|ex)?\b/i; + +/** + * Is this a font stack? + * + * It used to be `value.includes(",")`, which is how `0 8px 32px rgba(0,0,0,.18)` + * and `cubic-bezier(0.23, 1, 0.32, 1)` became font families — and, once there, + * how `0 8px 32px rgba(0` came to be offered in a font picker and written into + * somebody's stylesheet as a real `font-family`. A comma is the weakest possible + * evidence: it is punctuation shared by shadows, easings, gradients, transforms + * and every multi-argument colour function in CSS. + * + * Stated positively instead: every part has to look like a family name — which + * rejects anything carrying a length, a function call or a digit-led word. + * + * A lone bare word is not enough. `Inter` is a plausible family and so is + * `none`, `auto` and every other CSS keyword, and this tier only runs when + * usage and name have both already declined to say — so the safe reading of one + * unquoted word with no other evidence is "no idea", which drops the token. A + * real single-family token is reached by its name (`--font-sans`) or by the + * declaration that uses it; quoted or generic, it is unambiguous and kept. + */ +function isFontStack(value: string): boolean { + const parts = value.split(",").map((part) => part.trim()); + if (parts.some((part) => part === "")) { + return false; + } + const looksLikeFamily = (part: string): boolean => + GENERIC_FAMILY.test(part) || (FAMILY_NAME.test(part) && !LENGTH.test(part)); + const [only] = parts; + if (parts.length === 1) { + return GENERIC_FAMILY.test(only) || QUOTED.test(only); + } + return parts.every(looksLikeFamily); +} + +/** + * Is this a box-shadow? + * + * Two or more lengths in the first layer — offset-x and offset-y are required + * and everything else is optional — or the `inset` keyword, which nothing else + * in a token value uses. Deliberately checks only the first comma-separated + * layer: a stack of shadows is still a shadow, and the layers after the first + * add nothing to the question. + */ +function isShadow(value: string): boolean { + if (INSET.test(value)) { + return true; + } + const [first = ""] = value.split(","); + return (first.match(SHADOW_LENGTHS)?.length ?? 0) >= 2; +} + +/** + * Custom properties that are somebody's machinery rather than somebody's design + * decision. Offering `--tw-ring-offset-shadow` in a token picker would be like + * offering a minified variable name. + * + * `--ap-` is ours: the editor's own chrome palette, generated from + * `packages/editor-tokens/EDITOR.md`. It has no business being offered as the + * user's design system, and it was — the static scan walks up to the workspace + * root, found `packages/editor-tokens/dist/tokens.css`, and served 93 of the 144 + * colour tokens `apps/web` was shown out of a stylesheet that app never loads. + * Applying one wrote a `var()` the page could not resolve, which is what blanked + * backgrounds and left text looking unchanged. + * + * Excluding by prefix rather than by "does it resolve right now" is deliberate: + * this list can only ever be wrong about names we own, whereas a resolvability + * test would quietly drop a `--brand` that happens to live under `.dark` or + * inside a media query that is not currently matching. + */ +export const INTERNAL_TOKEN_PREFIXES = [ + "--ap-", + "--tw-", + "--chakra-", + "--mantine-", + "--radix-", + "--nextui-", + "--shiki-", +] as const; + +export function isInternalToken(name: string): boolean { + return INTERNAL_TOKEN_PREFIXES.some((p) => name.startsWith(p)); +} + +/** + * Values that are keywords rather than design decisions. + * + * Both scanners treat a one-declaration class rule as a utility token, which is + * right for `.text-brand { color: #6b4 }` and wrong for `.h-auto { height: + * auto }`. Tailwind emits dozens of the latter, and every one of them was + * landing in the registry as a token: the picker offered them, `matchByValue` + * linked controls to them, and the badge then offered to "detach from .h-auto". + * + * A token names a *value* on a scale. `auto` is not on a scale — it is the + * absence of one, and there is nothing for the agent to swap it for. + */ +const KEYWORD_VALUES: ReadonlySet = new Set([ + "auto", + "inherit", + "initial", + "unset", + "revert", + "revert-layer", + "none", + "normal", + "hidden", + "visible", + "transparent", +]); + +/** Is this value worth offering as a token? */ +export function isTokenizableValue(value: string): boolean { + const v = value.trim().toLowerCase(); + return v.length > 0 && !KEYWORD_VALUES.has(v); +} + +/** Majority vote across the properties the token is actually used on. */ +function categoryFromUsage(usedOn?: Iterable): TokenCategory | null { + if (!usedOn) { + return null; + } + const votes = new Map(); + for (const property of usedOn) { + const category = categoryForProperty(property); + if (category) { + votes.set(category, (votes.get(category) ?? 0) + 1); + } + } + let best: TokenCategory | null = null; + let bestCount = 0; + for (const [category, count] of votes) { + if (count > bestCount) { + best = category; + bestCount = count; + } + } + return best; +} + +function categoryFromName(name: string): TokenCategory | null { + for (const [pattern, category] of NAME_PATTERNS) { + if (pattern.test(name)) { + return category; + } + } + return null; +} + +function categoryFromValue(value: string): TokenCategory | null { + const v = value.trim(); + if (HEX_OR_FUNC_COLOR.test(v) || isChannelTriple(v)) { + return "colors"; + } + if (THREE_DIGITS.test(v)) { + return "font-weight"; + } + /* + * Before the font test, not after it. + * + * A shadow is the value most likely to be mistaken for a font stack — it is + * comma-separated and its parts are not lengths on their own — and there was + * no branch for it here at all. `0 8px 32px rgba(0,0,0,0.18)` could only ever + * reach `box-shadow` through usage or a name starting `--shadow`; miss both + * and it was guaranteed to land somewhere wrong. Note `HEX_OR_FUNC_COLOR` is + * anchored, so a shadow's trailing `rgba(` does not catch it above either. + */ + if (isShadow(v)) { + return "box-shadow"; + } + if (isFontStack(v)) { + return "font-family"; + } + if (NUMERIC_LENGTH.test(v)) { + // Deliberately spacing rather than sizing: a bare length in a design system + // is overwhelmingly a spacing step, and a wrong guess costs only a token + // offered on the wrong control. + return "spacing"; + } + return null; +} + +/** + * Which scale a custom property belongs to. `usedOn` is the set of CSS + * properties seen referencing it, and is by far the strongest signal — + * `color: var(--brand)` settles the question that no name or value heuristic + * can. Returns null when nothing identifies it, and the token is dropped rather + * than guessed into a category where it would mismatch real values. + */ +export function categorizeToken(input: { + name: string; + usedOn?: Iterable; + value: string; +}): TokenCategory | null { + return ( + categoryFromUsage(input.usedOn) ?? + categoryFromName(input.name) ?? + categoryFromValue(input.value) + ); +} + +// --------------------------------------------------------------------------- +// Value normalization +// --------------------------------------------------------------------------- + +const SPACE_RUN = /\s+/g; +const LEGACY_RGB = /^rgba?\(([^)]+)\)$/; +/** Tailwind v4 / UDS channel triples: `--brand: 255 229 202`. */ +const SPACE_RGB = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/; +/** Commas, slashes and whitespace all separate colour channels. */ +const CHANNEL_SEPARATOR = /[,/\s]+/; + +/** True when a bare value is three space-separated 0–255 channels. */ +export function isChannelTriple(value: string): boolean { + const m = SPACE_RGB.exec(value.trim()); + return ( + m !== null && [m[1], m[2], m[3]].every((n) => Number.parseInt(n, 10) <= 255) + ); +} + +/** + * Canonical form for value comparison. Both the server scanner and the browser + * registry key `byValue` through this, so a token found statically and the same + * token found at runtime collapse to one entry instead of two. + * + * Deliberately *not* a colour parser — it only has to be consistent, and + * anything needing real colour maths goes through `css-value.ts` in the overlay. + */ +export function normalizeTokenValue(value: string): string { + const trimmed = value.trim().toLowerCase().replace(SPACE_RUN, " "); + if (isChannelTriple(trimmed)) { + return `rgb(${trimmed.replace(SPACE_RUN, ", ")})`; + } + const legacy = LEGACY_RGB.exec(trimmed); + if (legacy) { + // `rgb(0,0,0)` and `rgb(0 0 0)` are the same colour; make them one key. + const parts = legacy[1].split(CHANNEL_SEPARATOR).filter(Boolean).join(", "); + return `rgb(${parts})`; + } + return trimmed; +} + +// --------------------------------------------------------------------------- +// The registry +// --------------------------------------------------------------------------- + +/** How a token is referenced in source. */ +export type TokenKind = "css-var" | "utility-class"; + +/** Where a token was found. Server-scanned tokens carry a real file. */ +export type TokenOrigin = "static" | "runtime"; + +export interface DesignToken { + /** + * The token this one is a straight alias of — its authored value was exactly + * `var(--other)`. + * + * Design systems routinely define a primitive scale and then re-export it + * under app-facing names (Tailwind v4's `@theme { --radius-md: + * var(--pk-radius-md) }` is the case in this repo's own example). Both names + * are real and both resolve to the same value, so without this the picker + * offers the user two identical `8px` entries and the prompt cannot say which + * name the codebase actually writes. + */ + aliasOf?: string; + category: TokenCategory; + /** Repo-relative file, when statically scanned. */ + file?: string; + kind: TokenKind; + /** 1-based, when statically scanned. */ + line?: number; + /** `--pk-space-4` for a custom property, `.p-4` for a utility class. */ + name: string; + origin: TokenOrigin; + /** + * For a utility class, which properties it declares and at what value. A + * custom property has the single synthetic key `""` holding its own value, + * so both kinds read through one code path. + */ + values: Record; +} + +export type CssFramework = "tailwind" | "custom" | "unknown"; + +/** What a single scan (static or runtime) produced. */ +export interface TokenScanResult { + framework: CssFramework; + tokens: DesignToken[]; +} + +/** The merged, indexed view the inspector and the prompt read from. */ +export interface TokenRegistry { + byCategory: Record; + byName: Record; + /** `"${property}:${normalizeTokenValue(value)}"` → tokens providing it. */ + byValue: Record; + framework: CssFramework; +} + +/** An empty registry — the "not scanned yet" value, so callers never null-check. */ +export function emptyRegistry(): TokenRegistry { + const byCategory = {} as Record; + for (const category of TOKEN_CATEGORIES) { + byCategory[category] = []; + } + return { byCategory, byName: {}, byValue: {}, framework: "unknown" }; +} diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 0000000..36aff4d --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "strictNullChecks": true + }, + "include": ["src"] +}