feat(protocol): define the shared wire types
The one package everything else depends on and that depends on nothing. Overlay, server and core all speak these types, so the browser bundle and the node side cannot drift apart without a typecheck failure.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<typeof AgentKindSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof EffortSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof SourceLocationSchema>;
|
||||
|
||||
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<typeof ElementContextSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof PseudoStateSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof TokenRefSchema>;
|
||||
|
||||
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<typeof StyleChangeSchema>;
|
||||
|
||||
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<typeof VisualEditTargetSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof MoveEditSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof StructuralOpSchema>;
|
||||
|
||||
export const StructuralEditSchema = z.object({
|
||||
element: ElementContextSchema,
|
||||
op: StructuralOpSchema,
|
||||
source: SourceLocationSchema.nullable().default(null),
|
||||
});
|
||||
export type StructuralEdit = z.infer<typeof StructuralEditSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof AttrChangeSchema>;
|
||||
|
||||
export const AttrEditTargetSchema = z.object({
|
||||
changes: z.array(AttrChangeSchema).min(1),
|
||||
element: ElementContextSchema,
|
||||
source: SourceLocationSchema.nullable().default(null),
|
||||
});
|
||||
export type AttrEditTarget = z.infer<typeof AttrEditTargetSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof TextEditSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof ImageInputSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof CommentSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof EditorSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jobs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const JOB_STATUSES = ["running", "done", "failed", "cancelled"] as const;
|
||||
export const JobStatusSchema = z.enum(JOB_STATUSES);
|
||||
export type JobStatus = z.infer<typeof JobStatusSchema>;
|
||||
|
||||
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<typeof CreateJobRequestSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof FileDiffSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof EditStructuredOutputSchema>;
|
||||
|
||||
/** 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<typeof TodoItemSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, string>;
|
||||
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<typeof UsageSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof ClientMessageSchema>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<TokenCategory, readonly string[]> = {
|
||||
"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<string, TokenCategory> = 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<string> = 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<string>): TokenCategory | null {
|
||||
if (!usedOn) {
|
||||
return null;
|
||||
}
|
||||
const votes = new Map<TokenCategory, number>();
|
||||
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<string>;
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
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<TokenCategory, DesignToken[]>;
|
||||
byName: Record<string, DesignToken>;
|
||||
/** `"${property}:${normalizeTokenValue(value)}"` → tokens providing it. */
|
||||
byValue: Record<string, DesignToken[]>;
|
||||
framework: CssFramework;
|
||||
}
|
||||
|
||||
/** An empty registry — the "not scanned yet" value, so callers never null-check. */
|
||||
export function emptyRegistry(): TokenRegistry {
|
||||
const byCategory = {} as Record<TokenCategory, DesignToken[]>;
|
||||
for (const category of TOKEN_CATEGORIES) {
|
||||
byCategory[category] = [];
|
||||
}
|
||||
return { byCategory, byName: {}, byValue: {}, framework: "unknown" };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user