feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -0,0 +1,78 @@
|
||||
# Design Studio
|
||||
|
||||
A local editor for `@wrnexus/design-system`. The authoring package adds an authoring surface; applications continue to use the existing theme adapter and components. Neither Studio code nor AI credentials are needed by an application.
|
||||
|
||||
## Start
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
bun run design:studio
|
||||
bun run design:studio --app-root path/to/existing/application
|
||||
```
|
||||
|
||||
Open the printed loopback URL (default port 4320; override `DESIGN_STUDIO_PORT`). Without `--app-root`, the Studio supports editing, previews and portable exports. The second form enables local revision bundles inside the specified application's `.wrnexus/design-studio` directory. It does not overwrite application configuration, publish or deploy.
|
||||
|
||||
## Authoring workflow
|
||||
|
||||
1. Create a blank design, select one of the three complete presets, duplicate the current design, or inspect/import local JSON.
|
||||
2. Edit named foundations in the base, mode or density layer. Token references use `@token.name`; legacy suffixes bind values to existing `--wrn-*` variables. Save a token explicitly or commit by leaving its input. Search includes names, types and bindings. Resolved values identify their actual source, including mode overrides.
|
||||
3. Inspect any of the 104 contracts and its ownership limits. Edit allowed default part properties, or use the component recipe JSON editor for variants, compound conditions and states. The canonical source editor supports every engine-supported field.
|
||||
4. Compare the current draft with a preset or the last applied design. Preview a composed page or seven gallery pages. Choose mode, density, viewport width and LTR/RTL. Frame widths scroll independently of the authoring workspace.
|
||||
5. Review before/after values and affected paths. Export the validated design or apply a local revision bundle. Rollback changes the application's current pointer and preserves the working draft.
|
||||
|
||||
Raw canonical edits autosave after a short pause. Invalid JSON, invalid aliases and unsupported recipes retain the last valid preview. Undo/redo and local history preserve invalid revisions as well as valid ones. History keeps up to 25 revisions. Storage corruption/quota failures show a message and preserve in-memory work; export the raw draft if autosave is unavailable. Local storage is not collaborative storage or a backup. Multiple browser tabs do not merge drafts.
|
||||
|
||||
## Imports and exports
|
||||
|
||||
Canonical JSON is the lossless format: all tokens, modes, densities, recipes and supported metadata round-trip. Documents requiring an external inheritance registry must be resolved with that registry before importing; the Studio reports missing parents rather than inventing them.
|
||||
|
||||
Legacy import accepts JSON shaped like `{ "default": "light", "themes": { "light": { "color-primary": "#112233" } } }`. Known semantic bindings are typed using the engine preset vocabulary. Unsupported fields and unknown suffixes are listed. This importer never evaluates a JavaScript/TypeScript configuration or guesses a dynamic palette.
|
||||
|
||||
The exchange subset accepts grouped or flat scalar tokens with `$type` and `$value`, group type inheritance, `$description`, and `{token.path}` aliases. Supported types are color strings, dimension strings, numbers, font-family strings, font weights and duration strings. Structured color objects, composite typography/shadows/dimensions, extensions and assets are unsupported. Tokens without WrNexus bindings are reported as ambiguous; map their legacy suffix in Foundations before expecting component changes. This is not a full DTCG implementation. Review the preservation/ambiguity/unsupported lists before accepting an import.
|
||||
|
||||
Exports include canonical JSON, standalone compiled CSS, theme configuration, resolved flat exchange tokens and a compatibility report. Exchange exports intentionally flatten modes/aliases to the selected resolved values and do not contain component recipes. Canonical JSON remains the editable source of truth. Invalid drafts can only be exported as raw drafts.
|
||||
|
||||
## Application revisions
|
||||
|
||||
Apply validates the full document and selected mode/density again on the server. The server only writes to the root configured at startup; the browser cannot select arbitrary filesystem paths. Each immutable revision includes `system.json`, `system.css`, `theme.json`, `tokens.json`, `report.json` and integration guidance. `CURRENT.json` selects the active revision. Applying from a stale base revision fails with `WRN-STUDIO-CONFLICT`; refresh Review to inspect the new target state. Rollback swaps the pointer with the previous revision, including returning a first apply to `none`.
|
||||
|
||||
To integrate a reviewed revision into an existing app, import its generated `theme.json` and assign it to the existing config's `theme` field, retaining any explicit app overrides:
|
||||
|
||||
```ts
|
||||
import theme from "./.wrnexus/design-studio/revisions/<revision>/theme.json";
|
||||
// In your existing WrNexus configuration:
|
||||
// theme: { ...theme, themes: existingThemeOverrides }
|
||||
```
|
||||
|
||||
The generated theme configuration uses the existing `designSystem` adapter, preserving instance props and explicit application token overrides. Alternatively use `system.css` as a standalone stylesheet. Do not load both paths unless you deliberately manage precedence. Existing app configuration is never automatically rewritten. Applications pin the imported revision; changing the Studio pointer alone does not rebuild or deploy them. `/application-preview` renders the selected on-disk revision through the normal theme adapter for local verification.
|
||||
|
||||
## Isolation and limits
|
||||
|
||||
The shell uses a separate browser-safe model entry. Filesystem/compiler/SSR code exists only under the server entry. The server binds loopback, rejects non-local hosts/cross-origin requests and bounds JSON inputs. Preview documents contain only trusted shipped component markup and validated styling. Frames permit scripts for component behavior, omit same-origin privileges, block network/forms/fonts, and retain provider/native/content ownership boundaries. The preview cache retains 24 generated documents; Refresh regenerates an expired one.
|
||||
|
||||
The component gallery is a baseline of default states, not full provider or accessibility certification. The composed preview exercises form controls, InputNumber and dialogs. Existing DatePicker calendar interaction emitted a client expression diagnostic during browser exploration; it remains available in the default-state gallery and its component source/API is unchanged. External provider rendering, all populated interaction states, extra browsers, collaborative storage, agent mutations, package publication and deployment remain separate scopes.
|
||||
|
||||
Run `bun test packages/design-studio/test` for model, import/export, application and preview checks. See the Phase 4 implementation report in `docs/plans` for broader regression and browser verification.
|
||||
|
||||
## Guided generation and locked decisions
|
||||
|
||||
Open **Guided design** for a progressive brief, three previewable alternatives, foundation mixing and word-based edits. Proposals are staged until reviewed and accepted into normal draft history. The default is local rules, with a documented phrase vocabulary; unsupported requests leave the draft unchanged. Local prose fields are context, not interpreted instructions. Canonical exports continue to use the existing engine schema.
|
||||
|
||||
Use foundation-group locks or JSON pointers such as `/tokens/shape.control` or `/recipes/Button`. Locks protect raw decisions and referenced effective token values across modes/densities, including aliases. Lock-violating raw edits remain recoverable; unlock explicitly before accepting them. Lock preferences persist independently of undo/redo. Generated proposals bind to the source and locks and become stale when either changes.
|
||||
|
||||
For optional LLM interpretation, configure `ANTHROPIC_API_KEY` on the server and run `bun run design:studio --ai-model <explicit-provider-model-id>`. Or inject an existing AI provider into `createStudioHandler({ generationProvider, generationModel })`. The browser receives provider/model metadata, never credentials. Only a generation action sends the brief, design and capabilities; ordinary editing/previews require no provider.
|
||||
|
||||
Provider proposals are bounded data-only patches, validated against the engine and contracts, with at most one repair and a 20-second deadline. Failures/cancellation preserve the draft and previous candidates. The brief, alternatives and provenance autosave separately and can be exported as a generation report. See the [Phase 5 report](../../docs/plans/2026-10-19-design-system-phase-5-implementation.md) for exact command semantics, fidelity, budgets, recovery and live-provider verification limits.
|
||||
|
||||
## Agent operations (Phase 6)
|
||||
|
||||
The existing MCP server now has opt-in design tools for recoverable drafts, locks, token patches, page specifications, context, conformance, preview, diff, export, apply and reversion. Start the local workspace server with `bun packages/mcp/src/stdio.ts --root=APPLICATION_ROOT --design-write`; `--design` exposes read operations only. Existing default tools and APIs are unchanged.
|
||||
|
||||
See the [Phase 6 operation and recovery guide](../../docs/plans/2026-10-19-design-system-phase-6-implementation.md) for revision requirements, tool schemas, examples and limits. Explicit server subpaths are `./agents`, `./agent-context`, `./agent-pages` and `./conformance`; filesystem/compiler functionality stays outside the browser root export.
|
||||
|
||||
Run `bun run scripts/evaluate-design-agents.ts` for the six-page/twelve-preview deterministic evaluation. No model credentials are needed. Design bundles and exported `.wrn` pages require explicit app integration. No routes or deployments are modified. Standalone staging and installed-consumer verification are available; see [release and upgrade guidance](RELEASE.md).
|
||||
|
||||
## Installed packages and upgrades
|
||||
|
||||
The staged package ships an installed `wrnexus-design-studio` binary, a prebuilt browser client and explicit server/agent subpaths. MCP consumes Studio as an optional peer. See [RELEASE.md](RELEASE.md) for installed usage, verified shared design bundles, upgrade planning, migrations, recovery and the release gate. The new package/upgrade APIs are programmatic; existing Studio authoring controls and APIs remain available.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Design release and upgrade guide
|
||||
|
||||
These packages support Bun 1.3 or newer. The design engine and Studio root/upgrade APIs are browser safe. Studio's server, CLI and agent service are local authoring tools; an application only needs the engine, theme adapter and UI components.
|
||||
|
||||
## Installed use
|
||||
|
||||
Install `@wrnexus/design-system` for the data engine. Add `@wrnexus/design-studio` for authoring; the installed `wrnexus-design-studio --app-root EXISTING_APP` binary binds to loopback port 4320 (`DESIGN_STUDIO_PORT` overrides it). Without `--app-root`, only exports are enabled. No AI key is needed. Existing `createStudioHandler` programmatic integration remains available from `@wrnexus/design-studio/server`.
|
||||
|
||||
Install `@wrnexus/mcp` for the existing eight framework tools. Studio is an **optional peer**, so a normal MCP-only installation does not install or load Studio. Install compatible Studio explicitly and use `wrnexus-mcp --root=EXISTING_APP --design` for inspection or `--design-write` for draft and bundle mutations. Missing Studio produces `WRN-MCP-DESIGN-INSTALL` when a design operation is invoked. The default server still exposes eight tools.
|
||||
|
||||
Studio packages ship compiled JS/declarations, component metadata through declared dependencies, and a prebuilt browser client. Published packages do not require a sibling source checkout. Application configuration and routes are never rewritten implicitly.
|
||||
|
||||
## Share a pinned design
|
||||
|
||||
```ts
|
||||
import { createDesignPackage, readDesignPackage } from "@wrnexus/design-system/package";
|
||||
|
||||
// Run after validating/reviewing your self-contained canonical design:
|
||||
const artifact = await createDesignPackage(system, "Reviewed brand release");
|
||||
const source = JSON.stringify(artifact);
|
||||
// Store this JSON with your application or distribute it in your own versioned package.
|
||||
const verified = await readDesignPackage(source);
|
||||
const theme = { designSystem: { system: verified.package.system } };
|
||||
```
|
||||
|
||||
Assign that theme through the existing app configuration. Alternatively use `compileDesignSystem` to generate standalone CSS; avoid loading both integrations. Explicit app tokens and instance props retain precedence.
|
||||
|
||||
The envelope has format version 1, contract version 1, the canonical schema-v1 design and a deterministic SHA-256 content digest. Object key order does not change the pin. The digest detects changed content; it is not a publisher signature. Use a trusted distribution source and pin the artifact together with engine/UI package versions and the app lockfile. Version-1 contracts do not replace visual regression review after dependency updates.
|
||||
|
||||
Bundles are self-contained: unresolved external inheritance is rejected. Resolve and flatten your inherited specification before sharing it. Names and types remain canonical; there is no automatic token renaming. Package source is capped at 2 MB and release notes at 2,000 characters.
|
||||
|
||||
## Review an upgrade
|
||||
|
||||
```ts
|
||||
import { planDesignUpgrade, applyDesignUpgrade } from "@wrnexus/design-studio/upgrades";
|
||||
|
||||
const plan = await planDesignUpgrade(session, incomingJson);
|
||||
// Review plan.changes, diagnostics, migrations and the matching page previews.
|
||||
if (plan.canApply) await applyDesignUpgrade(session, incomingJson, plan.baseRevision);
|
||||
// Existing session.undo()/redo() and autosave still apply.
|
||||
```
|
||||
|
||||
Planning does not mutate the session. Adoption rereads and verifies the package, checks system identity and the exact current design/lock revision, then uses the existing history mechanism. Invalid source must be resolved first. Locked decisions and their effective alias/recipe dependencies remain protected. Another design id requires an explicit import. Version numbers do not trigger automatic downloads or updates: a supported version with the same id may be adopted explicitly after review, including a deliberate rollback.
|
||||
|
||||
MCP provides `wrnexus_design_package` to export a verified package, `wrnexus_design_upgrade_plan` to review incoming `source`, and `wrnexus_design_upgrade` with `source` plus the latest `baseRevision` to adopt it. Existing draft/application revisions remain separate. Application changes still require `baseApplicationRevision`; exporting or upgrading a draft never deploys it.
|
||||
|
||||
## Migration and recovery
|
||||
|
||||
- Existing canonical schema-v1 JSON can be wrapped through `readDesignPackage`; it reports `canonical-v1-to-package-v1`. Existing canonical/legacy/token imports continue to use the existing explicit import APIs.
|
||||
- Old Studio browser drafts and agent state without a storage version remain readable. Successful subsequent saves add storage version 1, retaining source history, invalid raw edits, locks and saved pages.
|
||||
- Unknown package formats or contract versions fail closed. Unknown/faulty browser storage is kept byte-for-byte: `session.recoverySource` exposes the saved bytes and automatic writes stay blocked until storage is deliberately reset after backup. Keep the original browser storage key `wrnexus.design-studio.v1`; do not clear it before exporting recovery data. If you are using the UI, back up that local-storage value before resetting it; new package/upgrade APIs are programmatic, not additional Studio controls.
|
||||
- Unknown agent-state versions also fail closed; `STATE.json` is not rewritten. Back up `.wrnexus/design-studio` before changing authoring versions. Older software cannot promise to read newer formats; retain a supported tool version alongside the backup.
|
||||
- Agent state lives under the configured app's `.wrnexus/design-studio/agent`; browser local storage remains separate. Application revisions are immutable bundles selected by `CURRENT.json`. Existing application pointers and bundles need no migration.
|
||||
- On a crash-held `LOCK` or `APPLY.lock`, stop all writers and back up the managed directory before removing the empty lock directory. Locks are never stolen automatically. Do not edit saved state with active writers.
|
||||
|
||||
Upgrading Studio does not apply a theme, alter app configuration or rewrite route files. Exported pages and generated theme bundles still require explicit application integration. Preview execution remains limited to trusted shipped components; submitted arbitrary source is only statically checked.
|
||||
|
||||
## Local release workflow (maintainers)
|
||||
|
||||
From the framework repository:
|
||||
|
||||
```sh
|
||||
bun run design:release:plan
|
||||
bun run design:release:stage
|
||||
bun run design:release:check
|
||||
# Full repository production checks followed by installed-consumer checks:
|
||||
bun run design:release:gate
|
||||
```
|
||||
|
||||
Staging builds the complete declared local dependency closure under `.publish-design`, preserving the standard `.publish` staging area. It validates assets/declarations, rewrites workspace ranges and preserves optional-peer metadata. Checks create real npm tarballs and install them into temporary consumers with lifecycle scripts disabled. They exercise two independent apps, shared pins, upgrades/recovery, browser-safe exports, prebuilt Studio assets and CLI, real component previews, installed types, MCP stdio and a separate MCP-only installation.
|
||||
|
||||
These commands never authenticate, bump source versions, publish or deploy. Output reports and tarballs are under `output/design-system-phase-7`. A successful packaging report is **not** a publication approval or a passing repository-wide production gate. Before publishing, confirm unused package versions against the target registry, run the existing version/release process intentionally, restage, rerun all production and consumer gates, and review the package integrity manifest. The default staged access remains restricted. Do not automatically switch package access or publish dependencies to a public registry.
|
||||
|
||||
## Hosted export-only demo
|
||||
|
||||
`createStudioHandler({ hosted: { basePath: "/design-studio" } })` enables an
|
||||
explicit same-origin hosted demo at that path. Forward the complete path and
|
||||
Host through your reverse proxy. Hosted mode rejects applicationRoot and paid
|
||||
provider configuration: it generates local alternatives and ephemeral previews,
|
||||
with browser-local recoverable drafts and exports, without writing server files.
|
||||
The default local handler and CLI remain loopback-only with their existing APIs.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@wrnexus/design-studio",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"description": "Local design system authoring, guided proposals and review.",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./agents": "./src/agent-server.ts",
|
||||
"./conformance": "./src/conformance.ts",
|
||||
"./agent-context": "./src/agent-context.ts",
|
||||
"./agent-pages": "./src/agent-pages.ts",
|
||||
"./upgrades": "./src/upgrades.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/ai": "workspace:*",
|
||||
"@wrnexus/design-system": "workspace:*",
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/ssr": "workspace:*",
|
||||
"@wrnexus/styles": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"bin": {
|
||||
"wrnexus-design-studio": "src/cli.ts"
|
||||
},
|
||||
"files": [
|
||||
"assets",
|
||||
"RELEASE.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
COMPONENT_CONTRACTS,
|
||||
resolveDesignSystem,
|
||||
validateDesignSystem,
|
||||
type DesignSystem,
|
||||
type ResolveOptions,
|
||||
} from "@wrnexus/design-system";
|
||||
import reference from "@wrnexus/ui/component-reference.json";
|
||||
export const AGENT_CONTEXT_VERSION = 1;
|
||||
export function agentGenerationContext(
|
||||
system: DesignSystem,
|
||||
options: ResolveOptions & { components?: string[]; locks?: string[] } = {},
|
||||
) {
|
||||
const diagnostics = validateDesignSystem(system, options);
|
||||
if (diagnostics.length) throw Error("WRN-DESIGN-CONTEXT: source system must validate.");
|
||||
const names = options.components ?? [
|
||||
"Button",
|
||||
"Input",
|
||||
"Textarea",
|
||||
"Select",
|
||||
"Card",
|
||||
"Alert",
|
||||
"PageHeader",
|
||||
"Container",
|
||||
];
|
||||
if (
|
||||
!Array.isArray(names) ||
|
||||
names.length > 12 ||
|
||||
names.some((name) => !Object.hasOwn(COMPONENT_CONTRACTS, name))
|
||||
)
|
||||
throw Error("WRN-DESIGN-CONTEXT: select up to 12 known components.");
|
||||
const resolved = resolveDesignSystem(system, options);
|
||||
return {
|
||||
contextVersion: AGENT_CONTEXT_VERSION,
|
||||
system: {
|
||||
id: system.id,
|
||||
version: system.version,
|
||||
mode: resolved.mode,
|
||||
density: resolved.density,
|
||||
},
|
||||
locks: options.locks ?? [],
|
||||
tokens: resolved.tokens,
|
||||
components: Object.fromEntries(
|
||||
names.map((name) => {
|
||||
const contract = COMPONENT_CONTRACTS[name as keyof typeof COMPONENT_CONTRACTS];
|
||||
const meta = reference.components.find((c) => c.name === name)!;
|
||||
return [
|
||||
name,
|
||||
{
|
||||
contractVersion: contract.contractVersion,
|
||||
axes: contract.axes,
|
||||
parts: contract.parts,
|
||||
props: meta.props,
|
||||
outputs: meta.outputs,
|
||||
slots: contract.slots,
|
||||
constraints: contract.constraints,
|
||||
support: contract.support,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
composition: {
|
||||
container: "Container",
|
||||
spacingToken: "space.panel",
|
||||
legacySpacing: "--wrn-space-panel",
|
||||
rules: [
|
||||
"Use declared component props and variants. Preserve instance APIs and explicit choices.",
|
||||
"Use semantic token references for styling. Do not invent variants or provider capabilities.",
|
||||
"Custom/ejected application components need separate contracts.",
|
||||
"Page-spec previews contain trusted shipped components only; arbitrary source is analyzed, never executed.",
|
||||
],
|
||||
},
|
||||
examples: [
|
||||
'<Button label={"Continue"} variant={"default"} />',
|
||||
"color: var(--wrn-color-text); gap: var(--wrn-space-panel);",
|
||||
],
|
||||
limitations: [
|
||||
"Static conformance does not certify accessibility, contrast, dynamic expressions or visual quality.",
|
||||
"Native/provider/content-owned regions retain their contract limits.",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import reference from "@wrnexus/ui/component-reference.json";
|
||||
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
|
||||
import { checkDesignConformance } from "./conformance.ts";
|
||||
export interface AgentPage {
|
||||
id: string;
|
||||
title: string;
|
||||
components: Array<{ component: string; props?: Record<string, unknown> }>;
|
||||
}
|
||||
function jsonLiteral(value: unknown, depth = 0): boolean {
|
||||
if (depth > 16) return false;
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value))
|
||||
return value.length <= 1000 && value.every((item) => jsonLiteral(item, depth + 1));
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
|
||||
Object.entries(value).every(
|
||||
([key, item]) =>
|
||||
!["__proto__", "constructor", "prototype"].includes(key) && jsonLiteral(item, depth + 1),
|
||||
)
|
||||
);
|
||||
}
|
||||
export function validateAgentPage(input: unknown): asserts input is AgentPage {
|
||||
if (!input || typeof input !== "object") throw Error("WRN-DESIGN-PAGE: expected a page object.");
|
||||
const page = input as AgentPage;
|
||||
if (
|
||||
typeof page.id !== "string" ||
|
||||
!/^[a-z][a-z0-9-]{0,63}$/.test(page.id) ||
|
||||
typeof page.title !== "string" ||
|
||||
!page.title.trim() ||
|
||||
page.title.length > 200 ||
|
||||
!Array.isArray(page.components) ||
|
||||
page.components.length < 1 ||
|
||||
page.components.length > 12
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: valid id, title and 1–12 components required.");
|
||||
for (const entry of page.components) {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry.component !== "string" ||
|
||||
!Object.hasOwn(COMPONENT_CONTRACTS, entry.component)
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: unknown contracted component.");
|
||||
if (
|
||||
entry.props !== undefined &&
|
||||
(!entry.props || typeof entry.props !== "object" || Array.isArray(entry.props))
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: props must be an object.");
|
||||
const props = reference.components.find((c) => c.name === entry.component)!.props;
|
||||
for (const [key, value] of Object.entries(entry.props ?? {})) {
|
||||
const definition = props.find((p) => p.name === key);
|
||||
if (
|
||||
!definition ||
|
||||
["attrs", "class", "controlClass", "style", "html"].includes(key) ||
|
||||
!/^[A-Za-z]\w*$/.test(key)
|
||||
)
|
||||
throw Error(`WRN-DESIGN-PAGE: unsupported prop '${key}'.`);
|
||||
if (!jsonLiteral(value))
|
||||
throw Error("WRN-DESIGN-PAGE: props require bounded plain JSON literals.");
|
||||
const serialized = JSON.stringify(value);
|
||||
if (
|
||||
serialized === undefined ||
|
||||
typeof value === "function" ||
|
||||
(typeof value === "number" && !Number.isFinite(value))
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: props require finite JSON literals.");
|
||||
if (
|
||||
serialized.length > 10000 ||
|
||||
/(?:javascript|vbscript)\s*:|data\s*:\s*text\/html/i.test(JSON.stringify(value))
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: unsafe or oversized prop value.");
|
||||
if (
|
||||
(definition.type === "string" && typeof value !== "string") ||
|
||||
(definition.type === "boolean" && typeof value !== "boolean") ||
|
||||
(definition.type === "number" && (typeof value !== "number" || !Number.isFinite(value)))
|
||||
)
|
||||
throw Error(`WRN-DESIGN-PAGE: invalid type for ${key}.`);
|
||||
const axis = (
|
||||
COMPONENT_CONTRACTS[entry.component as keyof typeof COMPONENT_CONTRACTS].axes as Record<
|
||||
string,
|
||||
{ values: readonly string[] }
|
||||
>
|
||||
)[key];
|
||||
if (axis && !axis.values.includes(String(value)))
|
||||
throw Error(`WRN-DESIGN-PAGE: unsupported ${key} value.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const literal = (value: unknown) =>
|
||||
JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
|
||||
const text = (value: string) =>
|
||||
value.replace(
|
||||
/[&<>"'{}]/g,
|
||||
(c) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"{": "{",
|
||||
"}": "}",
|
||||
})[c]!,
|
||||
);
|
||||
export function agentPageSource(page: AgentPage) {
|
||||
validateAgentPage(page);
|
||||
return `page AgentPage {\n seo { title = ${literal(page.title)} }\n view {\n <main class="wrn-agent-page"><h1>${text(page.title)}</h1>\n${page.components
|
||||
.map(
|
||||
(entry) =>
|
||||
`<${entry.component} ${Object.entries(entry.props ?? {})
|
||||
.map(([key, value]) => `${key}={${literal(value)}}`)
|
||||
.join(" ")} />`,
|
||||
)
|
||||
.join(
|
||||
"\n",
|
||||
)}\n </main>\n }\n style { .wrn-agent-page { display: grid; padding: var(--wrn-space-panel); gap: var(--wrn-space-panel); color: var(--wrn-color-text); background: var(--wrn-color-bg); font-family: var(--wrn-font-sans); } }\n}\n`;
|
||||
}
|
||||
export function checkAgentPage(input: unknown) {
|
||||
try {
|
||||
validateAgentPage(input);
|
||||
const source = agentPageSource(input);
|
||||
return { ...checkDesignConformance(source), source };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
source: undefined,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "WRN-DESIGN-PAGE",
|
||||
path: "$",
|
||||
message: (error as Error).message,
|
||||
severity: "error" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { createDesignPackage } from "@wrnexus/design-system/package";
|
||||
import { planDesignUpgrade, applyDesignUpgrade } from "./upgrades.ts";
|
||||
import {
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { StudioApplication } from "./application.ts";
|
||||
import { StudioSession, exportDesign, reviewDesign } from "./session.ts";
|
||||
import { writeDecision } from "./decisions.ts";
|
||||
import { agentGenerationContext } from "./agent-context.ts";
|
||||
import { checkDesignConformance } from "./conformance.ts";
|
||||
import { agentPageSource, checkAgentPage, type AgentPage } from "./agent-pages.ts";
|
||||
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
|
||||
import { previewAgentPage } from "./preview.ts";
|
||||
|
||||
interface State {
|
||||
storageVersion?: 1;
|
||||
revision: string;
|
||||
session: string;
|
||||
pages: Record<string, AgentPage>;
|
||||
previous?: { session: string; pages: Record<string, AgentPage> };
|
||||
}
|
||||
/** Local, revision-checked agent workspace. Never writes application routes or configuration. */
|
||||
export class DesignAgentService {
|
||||
private application: StudioApplication;
|
||||
private directory: string;
|
||||
constructor(
|
||||
root: string,
|
||||
private writable = false,
|
||||
) {
|
||||
this.application = new StudioApplication(root);
|
||||
this.directory = join(this.application.directory, "agent");
|
||||
}
|
||||
private check(path: string, file = false) {
|
||||
if (
|
||||
existsSync(path) &&
|
||||
(lstatSync(path).isSymbolicLink() ||
|
||||
!(file ? lstatSync(path).isFile() : lstatSync(path).isDirectory()))
|
||||
)
|
||||
throw Error("WRN-DESIGN-PATH: managed storage must not contain links.");
|
||||
}
|
||||
private load() {
|
||||
// Re-check every ancestor through the application boundary on every operation.
|
||||
this.application.state();
|
||||
this.check(this.directory);
|
||||
const file = join(this.directory, "STATE.json");
|
||||
this.check(file, true);
|
||||
let serialized = "";
|
||||
const session = new StudioSession(this.application.system(), {
|
||||
getItem: () => serialized || null,
|
||||
setItem: (_key, value) => {
|
||||
serialized = value;
|
||||
},
|
||||
});
|
||||
let state: State = { revision: "none", session: "", pages: {} };
|
||||
if (existsSync(file)) {
|
||||
const raw = readFileSync(file, "utf8");
|
||||
if (raw.length > 12_000_000) throw Error("WRN-DESIGN-LIMIT: workspace too large.");
|
||||
state = JSON.parse(raw);
|
||||
if (state.storageVersion !== undefined && state.storageVersion !== 1)
|
||||
throw Error(
|
||||
"WRN-DESIGN-RECOVERY: unsupported agent state version; preserve original bytes.",
|
||||
);
|
||||
serialized = state.session;
|
||||
if (!session.restore())
|
||||
throw Error("WRN-DESIGN-RECOVERY: saved draft could not be restored.");
|
||||
for (const page of Object.values(state.pages))
|
||||
if (!checkAgentPage(page).ok) throw Error("WRN-DESIGN-RECOVERY: invalid saved page.");
|
||||
}
|
||||
session.persist();
|
||||
state.session = serialized;
|
||||
return {
|
||||
state,
|
||||
session,
|
||||
serialize: () => {
|
||||
session.persist();
|
||||
return serialized;
|
||||
},
|
||||
};
|
||||
}
|
||||
async call(operation: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
||||
if (JSON.stringify(args).length > 2_000_000)
|
||||
throw Error("WRN-DESIGN-LIMIT: request exceeds 2 MB.");
|
||||
const writes = ["create", "propose", "locks", "page", "apply", "revert", "upgrade"];
|
||||
const mutation = writes.includes(operation);
|
||||
if (mutation && !this.writable)
|
||||
throw Error("WRN-DESIGN-READONLY: enable designWrite at server startup.");
|
||||
let locked = false;
|
||||
const lock = join(this.directory, "LOCK");
|
||||
if (mutation) {
|
||||
this.application.state();
|
||||
this.check(this.directory);
|
||||
mkdirSync(this.directory, { recursive: true });
|
||||
try {
|
||||
mkdirSync(lock);
|
||||
locked = true;
|
||||
} catch {
|
||||
throw Error(
|
||||
"WRN-DESIGN-BUSY: workspace is locked; retry after the current writer completes.",
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { state, session, serialize } = this.load();
|
||||
if (mutation && args.baseRevision !== state.revision)
|
||||
throw Error("WRN-DESIGN-CONFLICT: draft changed; inspect and review again.");
|
||||
const before = { session: state.session, pages: structuredClone(state.pages) };
|
||||
const options = {
|
||||
mode: typeof args.mode === "string" ? args.mode : undefined,
|
||||
density: typeof args.density === "string" ? args.density : undefined,
|
||||
};
|
||||
let result: unknown;
|
||||
switch (operation) {
|
||||
case "inspect":
|
||||
return {
|
||||
revision: state.revision,
|
||||
application: this.application.state(),
|
||||
source: session.source,
|
||||
valid: session.valid,
|
||||
locks: session.locks,
|
||||
diagnostics: session.diagnostics,
|
||||
pages: state.pages,
|
||||
history: session.history.map((r) => r.label),
|
||||
cursor: session.cursor,
|
||||
};
|
||||
case "capabilities":
|
||||
return {
|
||||
contextVersion: 1,
|
||||
writable: this.writable,
|
||||
components: Object.keys(COMPONENT_CONTRACTS),
|
||||
operations: [
|
||||
"package",
|
||||
"upgrade_plan",
|
||||
"upgrade",
|
||||
"inspect",
|
||||
"capabilities",
|
||||
"context",
|
||||
"create",
|
||||
"propose",
|
||||
"locks",
|
||||
"validate",
|
||||
"preview",
|
||||
"diff",
|
||||
"apply",
|
||||
"revert",
|
||||
"export",
|
||||
"conformance",
|
||||
"page",
|
||||
],
|
||||
limits: { pageComponents: 12, pages: 12, patches: 48 },
|
||||
boundaries: [
|
||||
"Local managed draft only. Explicit revision required for mutations.",
|
||||
"Application bundles require explicit integration; routes and deployments are never modified.",
|
||||
"Static analysis is heuristic; arbitrary submitted code is never executed.",
|
||||
],
|
||||
};
|
||||
case "context":
|
||||
return {
|
||||
revision: state.revision,
|
||||
...agentGenerationContext(session.valid, {
|
||||
...options,
|
||||
components: args.components as string[] | undefined,
|
||||
locks: session.locks,
|
||||
}),
|
||||
};
|
||||
case "package":
|
||||
return {
|
||||
revision: state.revision,
|
||||
diagnostics: session.diagnostics,
|
||||
package: await createDesignPackage(session.valid, args.notes as string | undefined),
|
||||
};
|
||||
case "upgrade_plan":
|
||||
return {
|
||||
revision: state.revision,
|
||||
...(await planDesignUpgrade(session, args.source as string)),
|
||||
};
|
||||
case "upgrade": {
|
||||
const plan = await planDesignUpgrade(session, args.source as string);
|
||||
result = await applyDesignUpgrade(session, args.source as string, plan.baseRevision);
|
||||
break;
|
||||
}
|
||||
case "create":
|
||||
if (args.source !== undefined) {
|
||||
if (typeof args.source !== "string")
|
||||
throw Error("WRN-DESIGN-INPUT: source must be a string.");
|
||||
session.edit(args.source, "Agent draft");
|
||||
}
|
||||
break;
|
||||
case "propose":
|
||||
if (typeof args.source === "string") session.edit(args.source, "Agent source proposal");
|
||||
else {
|
||||
if (!Array.isArray(args.patches) || args.patches.length > 48)
|
||||
throw Error("WRN-DESIGN-INPUT: provide source or up to 48 patches.");
|
||||
session.mutate((system) => {
|
||||
for (const patch of args.patches as Array<{ path: string; value: unknown }>)
|
||||
writeDecision(system, patch.path, patch.value);
|
||||
}, "Agent token proposal");
|
||||
}
|
||||
break;
|
||||
case "locks":
|
||||
session.setLocks(args.locks as string[]);
|
||||
break;
|
||||
case "page": {
|
||||
const checked = checkAgentPage(args.page);
|
||||
if (!checked.ok) return { revision: state.revision, ...checked };
|
||||
const page = args.page as AgentPage;
|
||||
if (!Object.hasOwn(state.pages, page.id) && Object.keys(state.pages).length >= 12)
|
||||
throw Error("WRN-DESIGN-LIMIT: at most 12 pages.");
|
||||
state.pages[page.id] = structuredClone(page);
|
||||
result = checked;
|
||||
break;
|
||||
}
|
||||
case "validate":
|
||||
return {
|
||||
revision: state.revision,
|
||||
ok: !session.diagnostics.length,
|
||||
diagnostics: session.diagnostics,
|
||||
pages: Object.values(state.pages).map((page) => ({
|
||||
id: page.id,
|
||||
...checkAgentPage(page),
|
||||
})),
|
||||
};
|
||||
case "conformance":
|
||||
return checkDesignConformance(args.source as string, args.exceptions as never);
|
||||
case "diff":
|
||||
return {
|
||||
revision: state.revision,
|
||||
changes: reviewDesign(session.applied, session.valid),
|
||||
pages: Object.keys(state.pages),
|
||||
diagnostics: session.diagnostics,
|
||||
};
|
||||
case "preview": {
|
||||
const page = state.pages[String(args.pageId)];
|
||||
if (!page) throw Error("WRN-DESIGN-PAGE: select a saved page.");
|
||||
return {
|
||||
revision: state.revision,
|
||||
diagnostics: session.diagnostics,
|
||||
html: await previewAgentPage(session.valid, page, options),
|
||||
};
|
||||
}
|
||||
case "export":
|
||||
return {
|
||||
revision: state.revision,
|
||||
source: session.source,
|
||||
locks: session.locks,
|
||||
diagnostics: session.diagnostics,
|
||||
artifacts: exportDesign(session.valid, options),
|
||||
pages: Object.fromEntries(
|
||||
Object.values(state.pages).map((page) => [page.id + ".wrn", agentPageSource(page)]),
|
||||
),
|
||||
};
|
||||
case "apply":
|
||||
if (session.diagnostics.length)
|
||||
throw Error("WRN-DESIGN-INVALID: resolve draft diagnostics before applying.");
|
||||
result = this.application.apply(
|
||||
session.valid,
|
||||
String(args.baseApplicationRevision),
|
||||
options,
|
||||
);
|
||||
session.markApplied();
|
||||
break;
|
||||
case "revert":
|
||||
if (args.target === "application") {
|
||||
result = this.application.rollback(String(args.baseApplicationRevision));
|
||||
} else if (args.target === "checkpoint") {
|
||||
if (!state.previous) throw Error("WRN-DESIGN-RECOVERY: no checkpoint.");
|
||||
state.session = state.previous.session;
|
||||
state.pages = state.previous.pages;
|
||||
} else {
|
||||
session.undo();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw Error("WRN-DESIGN-OPERATION: unknown operation.");
|
||||
}
|
||||
if (!(operation === "revert" && args.target === "checkpoint")) state.session = serialize();
|
||||
state.storageVersion = 1;
|
||||
state.previous = before;
|
||||
state.revision = randomUUID();
|
||||
const raw = JSON.stringify(state);
|
||||
if (raw.length > 12_000_000)
|
||||
throw Error("WRN-DESIGN-LIMIT: export or shorten draft history.");
|
||||
const temp = join(this.directory, randomUUID() + ".tmp");
|
||||
writeFileSync(temp, raw, { flag: "wx" });
|
||||
renameSync(temp, join(this.directory, "STATE.json"));
|
||||
const saved = this.load().session;
|
||||
return {
|
||||
revision: state.revision,
|
||||
diagnostics: saved.diagnostics,
|
||||
affectedPaths:
|
||||
operation === "page"
|
||||
? ["pages." + (args.page as AgentPage).id]
|
||||
: operation === "locks"
|
||||
? ["locks"]
|
||||
: operation === "apply" || (operation === "revert" && args.target === "application")
|
||||
? ["application.CURRENT"]
|
||||
: reviewDesign(JSON.parse(before.session).valid, saved.valid)
|
||||
.map((change) => change.path)
|
||||
.concat(
|
||||
saved.source !==
|
||||
JSON.parse(before.session).history[JSON.parse(before.session).cursor].source
|
||||
? ["draft.source"]
|
||||
: [],
|
||||
),
|
||||
result,
|
||||
};
|
||||
} finally {
|
||||
if (locked) rmdirSync(lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
} from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { exportDesign } from "./session.ts";
|
||||
import type { DesignSystem, ResolveOptions } from "@wrnexus/design-system";
|
||||
|
||||
interface Pointer {
|
||||
revision: string;
|
||||
previous?: string;
|
||||
}
|
||||
/** Immutable revision bundles with one atomic pointer. No caller-supplied path or app configuration edits. */
|
||||
export class StudioApplication {
|
||||
readonly directory: string;
|
||||
constructor(root: string) {
|
||||
if (!existsSync(root)) throw Error("Application root must already exist.");
|
||||
this.directory = join(realpathSync(resolve(root)), ".wrnexus", "design-studio");
|
||||
this.checkDirectories();
|
||||
}
|
||||
private checkDirectories() {
|
||||
for (const directory of [
|
||||
resolve(this.directory, ".."),
|
||||
this.directory,
|
||||
join(this.directory, "revisions"),
|
||||
]) {
|
||||
if (
|
||||
existsSync(directory) &&
|
||||
(!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink())
|
||||
)
|
||||
throw Error("Studio directory must be a real directory, not a link.");
|
||||
}
|
||||
const pointer = join(this.directory, "CURRENT.json");
|
||||
if (
|
||||
existsSync(pointer) &&
|
||||
(!lstatSync(pointer).isFile() || lstatSync(pointer).isSymbolicLink())
|
||||
)
|
||||
throw Error("Invalid application pointer.");
|
||||
}
|
||||
state(): Pointer {
|
||||
this.checkDirectories();
|
||||
const path = join(this.directory, "CURRENT.json");
|
||||
if (!existsSync(path)) return { revision: "none" };
|
||||
const pointer = JSON.parse(readFileSync(path, "utf8")) as Pointer;
|
||||
if (
|
||||
!/^(?:[a-f0-9]{64}|none)$/.test(pointer.revision) ||
|
||||
(pointer.previous && !/^(?:[a-f0-9]{64}|none)$/.test(pointer.previous))
|
||||
)
|
||||
throw Error("Invalid application revision.");
|
||||
return pointer;
|
||||
}
|
||||
system(): DesignSystem | undefined {
|
||||
const current = this.state();
|
||||
if (current.revision === "none") return undefined;
|
||||
const target = join(this.directory, "revisions", current.revision);
|
||||
const file = join(target, "system.json");
|
||||
if (
|
||||
!lstatSync(target).isDirectory() ||
|
||||
lstatSync(target).isSymbolicLink() ||
|
||||
!lstatSync(file).isFile() ||
|
||||
lstatSync(file).isSymbolicLink()
|
||||
)
|
||||
throw Error("Invalid revision bundle.");
|
||||
return JSON.parse(readFileSync(file, "utf8")) as DesignSystem;
|
||||
}
|
||||
selection(): ResolveOptions {
|
||||
const current = this.state();
|
||||
if (current.revision === "none") return {};
|
||||
// Validate the revision directory before reading another artifact inside it.
|
||||
this.system();
|
||||
const file = join(this.directory, "revisions", current.revision, "theme.json");
|
||||
if (!lstatSync(file).isFile() || lstatSync(file).isSymbolicLink())
|
||||
throw Error("Invalid theme artifact.");
|
||||
const theme = JSON.parse(readFileSync(file, "utf8"));
|
||||
return { mode: theme.designSystem?.mode, density: theme.designSystem?.density };
|
||||
}
|
||||
private point(pointer: Pointer) {
|
||||
const path = join(this.directory, `CURRENT-${randomUUID()}.tmp`);
|
||||
writeFileSync(path, JSON.stringify(pointer, null, 2));
|
||||
renameSync(path, join(this.directory, "CURRENT.json"));
|
||||
}
|
||||
private exclusive<T>(run: () => T): T {
|
||||
this.checkDirectories();
|
||||
mkdirSync(this.directory, { recursive: true });
|
||||
const lock = join(this.directory, "APPLY.lock");
|
||||
try {
|
||||
mkdirSync(lock);
|
||||
} catch {
|
||||
throw Error("WRN-STUDIO-BUSY: another application writer is active.");
|
||||
}
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
rmdirSync(lock);
|
||||
}
|
||||
}
|
||||
apply(system: DesignSystem, base: string, options: ResolveOptions = {}) {
|
||||
return this.exclusive(() => this.applyUnlocked(system, base, options));
|
||||
}
|
||||
private applyUnlocked(system: DesignSystem, base: string, options: ResolveOptions) {
|
||||
const artifacts = exportDesign(system, options);
|
||||
const current = this.state();
|
||||
if (base !== current.revision)
|
||||
throw Error("WRN-STUDIO-CONFLICT: application changed; refresh review before applying.");
|
||||
const revision = createHash("sha256").update(JSON.stringify({ system, options })).digest("hex");
|
||||
mkdirSync(join(this.directory, "revisions"), { recursive: true });
|
||||
this.checkDirectories();
|
||||
const target = join(this.directory, "revisions", revision);
|
||||
if (
|
||||
existsSync(target) &&
|
||||
(!lstatSync(target).isDirectory() || lstatSync(target).isSymbolicLink())
|
||||
)
|
||||
throw Error("Invalid revision directory.");
|
||||
if (!existsSync(target)) {
|
||||
const staging = join(this.directory, "revisions", `draft-${randomUUID()}`);
|
||||
mkdirSync(staging);
|
||||
for (const [filename, body] of Object.entries({
|
||||
"system.json": artifacts.canonical,
|
||||
"system.css": artifacts.css,
|
||||
"theme.json": artifacts.theme,
|
||||
"tokens.json": artifacts.tokens,
|
||||
"report.json": artifacts.report,
|
||||
}))
|
||||
writeFileSync(join(staging, filename), body);
|
||||
writeFileSync(
|
||||
join(staging, "README.md"),
|
||||
`# ${system.name}\n\nImport theme.json and assign it to your WrNexus app configuration's theme field. Preserve explicit application overrides. This bundle does not edit app configuration or deploy. Alternatively use system.css as the standalone stylesheet; do not load both integrations.\n`,
|
||||
);
|
||||
renameSync(staging, target);
|
||||
}
|
||||
if (revision !== current.revision) this.point({ revision, previous: current.revision });
|
||||
return { ...this.state(), directory: target };
|
||||
}
|
||||
rollback(base: string) {
|
||||
return this.exclusive(() => this.rollbackUnlocked(base));
|
||||
}
|
||||
private rollbackUnlocked(base: string) {
|
||||
const current = this.state();
|
||||
if (base !== current.revision)
|
||||
throw Error("WRN-STUDIO-CONFLICT: application changed; refresh review.");
|
||||
if (!current.previous) throw Error("No previous application revision.");
|
||||
this.point({ revision: current.previous, previous: current.revision });
|
||||
return this.state();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createStudioHandler } from "./server.ts";
|
||||
export async function runDesignStudio() {
|
||||
const index = process.argv.indexOf("--app-root");
|
||||
if (index >= 0 && !process.argv[index + 1])
|
||||
throw Error("--app-root requires an existing application directory.");
|
||||
const fetch = await createStudioHandler({
|
||||
applicationRoot: index >= 0 ? process.argv[index + 1] : undefined,
|
||||
...(await (async () => {
|
||||
const position = process.argv.indexOf("--ai-model");
|
||||
if (position < 0) return {};
|
||||
const model = process.argv[position + 1];
|
||||
if (!model || model.startsWith("--"))
|
||||
throw Error("--ai-model requires an explicit provider model ID.");
|
||||
if (!process.env.ANTHROPIC_API_KEY)
|
||||
throw Error("ANTHROPIC_API_KEY is required when --ai-model is enabled.");
|
||||
const { anthropicProvider } = await import("@wrnexus/ai");
|
||||
return { generationProvider: anthropicProvider({ model }), generationModel: model };
|
||||
})()),
|
||||
});
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(process.env.DESIGN_STUDIO_PORT ?? 4320),
|
||||
fetch,
|
||||
});
|
||||
console.log(
|
||||
`Design Studio: ${server.url} (${index >= 0 ? "local application enabled" : "export-only; pass --app-root to enable local apply"})`,
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
if (import.meta.main) await runDesignStudio();
|
||||
@@ -0,0 +1,674 @@
|
||||
import { setupGuidance } from "./guidance.ts";
|
||||
import { StudioSession, importDesign, exportDesign, reviewDesign } from "./session.ts";
|
||||
import {
|
||||
COMPONENT_CONTRACTS,
|
||||
CORE_DESIGN_SYSTEMS,
|
||||
TOKEN_TYPES,
|
||||
type DesignSystem,
|
||||
type ComponentDesignContract,
|
||||
type DesignToken,
|
||||
} from "@wrnexus/design-system";
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const value = (id: string) => $<HTMLInputElement>(id).value;
|
||||
const escape = (text: unknown) =>
|
||||
String(text).replace(
|
||||
/[&<>"']/g,
|
||||
(char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!,
|
||||
);
|
||||
const option = (name: string, label = name) =>
|
||||
`<option value="${escape(name)}">${escape(label)}</option>`;
|
||||
const session = new StudioSession(undefined, localStorage);
|
||||
session.restore();
|
||||
let importCandidate: DesignSystem | undefined;
|
||||
let application: { enabled?: boolean; revision: string; previous?: string; directory?: string } = {
|
||||
revision: "none",
|
||||
};
|
||||
let page = 0;
|
||||
let previewGeneration = 0;
|
||||
let activeTab = "foundations";
|
||||
let noticeTimer: ReturnType<typeof setTimeout>;
|
||||
function message(text: string) {
|
||||
$("message").textContent = text;
|
||||
clearTimeout(noticeTimer);
|
||||
noticeTimer = setTimeout(() => {
|
||||
$("message").textContent = "";
|
||||
}, 10000);
|
||||
}
|
||||
async function request(path: string, body?: unknown) {
|
||||
const response = await fetch(
|
||||
new URL("." + path, document.baseURI),
|
||||
body === undefined
|
||||
? undefined
|
||||
: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw Error(result.error ?? "Request failed.");
|
||||
return result;
|
||||
}
|
||||
function on(id: string, action: () => unknown | Promise<unknown>, event = "click") {
|
||||
$(id).addEventListener(event, () => {
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
flushSource();
|
||||
return action();
|
||||
})
|
||||
.catch((error) => message(error.message));
|
||||
});
|
||||
}
|
||||
function flushSource() {
|
||||
if (activeTab === "source" && value("source-text") !== session.source) {
|
||||
clearTimeout(sourceTimer);
|
||||
session.edit(value("source-text"));
|
||||
render();
|
||||
}
|
||||
}
|
||||
function download(body: string, filename: string, type = "application/json") {
|
||||
const url = URL.createObjectURL(new Blob([body], { type }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
function selectOptions(id: string, entries: string[], empty?: string) {
|
||||
const select = $<HTMLSelectElement>(id);
|
||||
const previous = select.value;
|
||||
select.innerHTML =
|
||||
(empty !== undefined ? option("", empty) : "") + entries.map((name) => option(name)).join("");
|
||||
if ([...select.options].some((o) => o.value === previous)) select.value = previous;
|
||||
}
|
||||
function selection() {
|
||||
return {
|
||||
mode: value("preview-mode") || undefined,
|
||||
density: value("preview-density") || undefined,
|
||||
};
|
||||
}
|
||||
function tab(name: string) {
|
||||
activeTab = name;
|
||||
for (const id of ["guided", "foundations", "components", "source", "history", "review"])
|
||||
$(id === "review" ? "review-panel" : id).hidden = id !== name;
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>("[data-tab]")
|
||||
.forEach((button) =>
|
||||
button.setAttribute("aria-current", button.dataset.tab === name ? "page" : "false"),
|
||||
);
|
||||
$("editor-title").textContent =
|
||||
{
|
||||
guided: "Guided design",
|
||||
foundations: "Foundations",
|
||||
components: "Components",
|
||||
source: "Source & import",
|
||||
history: "History",
|
||||
review: "Review & apply",
|
||||
}[name] ?? name;
|
||||
}
|
||||
function renderTokens() {
|
||||
const layer = value("token-layer");
|
||||
const [kind, name] = layer.split(":");
|
||||
const override =
|
||||
kind === "mode"
|
||||
? session.valid.modes?.[name!]
|
||||
: kind === "density"
|
||||
? session.valid.densities?.[name!]
|
||||
: undefined;
|
||||
const tokens = { ...session.valid.tokens, ...override };
|
||||
const selected = selection();
|
||||
const resolved = session.resolved({
|
||||
...selected,
|
||||
...(kind === "mode" ? { mode: name } : kind === "density" ? { density: name } : {}),
|
||||
});
|
||||
const search = value("token-search").toLowerCase();
|
||||
const filtered = Object.entries(tokens).filter(([key, token]) =>
|
||||
`${key} ${token.type} ${token.legacy ?? ""}`.toLowerCase().includes(search),
|
||||
);
|
||||
$("tokens").innerHTML =
|
||||
filtered
|
||||
.map(([key, token]) => {
|
||||
const actual = resolved.tokens[key];
|
||||
const raw = typeof token.value === "object" ? "@" + token.value.ref : token.value;
|
||||
return `<div class="token"><label><span class="token-name">${escape(key)}</span><input data-token="${escape(key)}" value="${escape(raw)}" ${session.diagnostics.length ? "disabled" : ""}></label><small>${escape(token.type)} · ${escape(actual?.value ?? "unresolved")}<br>Source: ${escape(actual?.source ?? "")}${actual?.reference ? ` → ${escape(actual.reference)}` : ""}</small><label>Legacy token suffix<input data-binding="${escape(key)}" placeholder="e.g. radius-sm" value="${escape(token.legacy ?? "")}" ${session.diagnostics.length ? "disabled" : ""}></label><button data-save-token="${escape(key)}" ${session.diagnostics.length ? "disabled" : ""}>Save token</button><button data-delete-token="${escape(key)}" ${session.diagnostics.length ? "disabled" : ""}>Remove ${kind === "base" ? "token" : "override"}</button></div>`;
|
||||
})
|
||||
.join("") || "<p>No tokens match this search.</p>";
|
||||
}
|
||||
function tokenMap(system: DesignSystem) {
|
||||
const [kind, name] = value("token-layer").split(":");
|
||||
if (kind === "mode") return (system.modes![name!] ??= {});
|
||||
if (kind === "density") return (system.densities![name!] ??= {});
|
||||
return system.tokens;
|
||||
}
|
||||
function recipeContract(): ComponentDesignContract {
|
||||
return COMPONENT_CONTRACTS[
|
||||
value("component") as keyof typeof COMPONENT_CONTRACTS
|
||||
] as unknown as ComponentDesignContract;
|
||||
}
|
||||
function renderContract() {
|
||||
const contract = recipeContract();
|
||||
if (!contract) {
|
||||
$("contract").textContent = "No components match this search.";
|
||||
selectOptions("part", []);
|
||||
selectOptions("property", []);
|
||||
$<HTMLTextAreaElement>("recipe-source").value = "";
|
||||
for (const id of ["set-recipe", "remove-recipe", "save-recipe"])
|
||||
$(id).setAttribute("disabled", "");
|
||||
return;
|
||||
}
|
||||
for (const id of ["remove-recipe", "save-recipe"])
|
||||
$(id).toggleAttribute("disabled", !!session.diagnostics.length);
|
||||
$("contract").innerHTML =
|
||||
`<p><strong>${escape(value("component"))}</strong> · Contract version ${contract.contractVersion}</p><p>Axes: ${escape(
|
||||
Object.entries(contract.axes)
|
||||
.map(([name, axis]) => `${name}: ${axis.values.join(", ")}`)
|
||||
.join("; ") || "none",
|
||||
)}</p><p>${escape(contract.constraints.join(" "))}</p><details><summary>Ownership and limits</summary><pre>${escape(JSON.stringify(contract.regions ?? {}, null, 2))}</pre></details>`;
|
||||
selectOptions("part", Object.keys(contract.parts));
|
||||
renderProperties();
|
||||
$("recipe-source").textContent = JSON.stringify(
|
||||
session.valid.recipes?.[value("component")] ?? { contractVersion: 1, base: {} },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
$<HTMLTextAreaElement>("recipe-source").value = JSON.stringify(
|
||||
session.valid.recipes?.[value("component")] ?? { contractVersion: 1, base: {} },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
function renderProperties() {
|
||||
const contract = recipeContract();
|
||||
const part = contract?.parts[value("part")];
|
||||
selectOptions("property", part ? [...part.properties] : []);
|
||||
$("set-recipe").toggleAttribute(
|
||||
"disabled",
|
||||
!part?.properties.length || !!session.diagnostics.length,
|
||||
);
|
||||
const raw =
|
||||
session.valid.recipes?.[value("component")]?.base?.[value("part")]?.[value("property")];
|
||||
$<HTMLInputElement>("recipe-value").value =
|
||||
typeof raw === "object" ? "@" + raw.ref : String(raw ?? "");
|
||||
}
|
||||
function render() {
|
||||
$<HTMLSelectElement>("preset").value = Object.hasOwn(CORE_DESIGN_SYSTEMS, session.valid.id)
|
||||
? session.valid.id
|
||||
: "";
|
||||
$("system-name").setAttribute("value", session.valid.name);
|
||||
$<HTMLInputElement>("system-name").value = session.valid.name;
|
||||
$<HTMLInputElement>("system-id").value = session.valid.id;
|
||||
if (document.activeElement !== $("source-text"))
|
||||
$<HTMLTextAreaElement>("source-text").value = session.source;
|
||||
$("validation-state").textContent = session.diagnostics.length
|
||||
? "Draft has errors"
|
||||
: "Valid draft";
|
||||
$("diagnostics").textContent = session.diagnostics
|
||||
.map((d) => `${d.code} · ${d.path}: ${d.message}`)
|
||||
.join("\n");
|
||||
$("save-state").textContent = session.storageError || "Draft saved locally";
|
||||
$("undo").toggleAttribute("disabled", session.cursor === 0);
|
||||
$("redo").toggleAttribute("disabled", session.cursor === session.history.length - 1);
|
||||
selectOptions("preview-mode", Object.keys(session.valid.modes ?? {}), "Default");
|
||||
selectOptions("preview-density", Object.keys(session.valid.densities ?? {}), "Default");
|
||||
selectOptions("token-layer", [
|
||||
"base",
|
||||
...Object.keys(session.valid.modes ?? {}).map((n) => "mode:" + n),
|
||||
...Object.keys(session.valid.densities ?? {}).map((n) => "density:" + n),
|
||||
]);
|
||||
renderTokens();
|
||||
renderContract();
|
||||
$("revisions").innerHTML = session.history
|
||||
.map(
|
||||
(revision, i) =>
|
||||
`<li><button data-revision="${i}" ${i === session.cursor ? 'aria-current="step"' : ""}>${i + 1}. ${escape(revision.label)}${i === session.cursor ? " · current" : ""}</button></li>`,
|
||||
)
|
||||
.join("");
|
||||
const changes = session.changes();
|
||||
const details = reviewDesign(session.applied, session.valid);
|
||||
$("changes").textContent = changes.length
|
||||
? `${changes.length} changed paths\n${changes.some((path) => path.startsWith("$.tokens") || path.startsWith("$.modes") || path.startsWith("$.densities")) ? "Foundation changes may affect all 104 contracted components." : "Affected components: " + [...new Set(changes.filter((path) => path.startsWith("$.recipes.")).map((path) => path.split(".")[2]))].join(", ")}\n\n` +
|
||||
details
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.path}\nBefore: ${JSON.stringify(entry.before) ?? "(absent)"}\nAfter: ${JSON.stringify(entry.after) ?? "(removed)"}`,
|
||||
)
|
||||
.join("\n\n")
|
||||
: "No changes from the last applied/opened design.";
|
||||
$("apply").toggleAttribute("disabled", !application.enabled || !!session.diagnostics.length);
|
||||
$("rollback").toggleAttribute("disabled", !application.previous);
|
||||
$("target").textContent = application.enabled
|
||||
? `Local folder: ${application.directory}\nCurrent revision: ${application.revision}`
|
||||
: "Export-only workspace. Enable local apply by starting the Studio with --app-root <existing application folder>.";
|
||||
tab(activeTab);
|
||||
guidance?.render();
|
||||
}
|
||||
async function previews() {
|
||||
const generation = ++previewGeneration;
|
||||
const settings = {
|
||||
...selection(),
|
||||
direction: value("preview-direction"),
|
||||
content: value("preview-content"),
|
||||
page,
|
||||
};
|
||||
const comparison = value("compare");
|
||||
$("comparison").hidden = !comparison;
|
||||
const systems = [
|
||||
session.valid,
|
||||
...(comparison
|
||||
? [
|
||||
comparison === "applied"
|
||||
? session.applied
|
||||
: CORE_DESIGN_SYSTEMS[comparison as keyof typeof CORE_DESIGN_SYSTEMS],
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const result = await Promise.all(
|
||||
systems.map((system) =>
|
||||
request("/api/preview", {
|
||||
system,
|
||||
...settings,
|
||||
...(system !== session.valid
|
||||
? {
|
||||
mode: Object.hasOwn(system!.modes ?? {}, settings.mode ?? "")
|
||||
? settings.mode
|
||||
: undefined,
|
||||
density: Object.hasOwn(system!.densities ?? {}, settings.density ?? "")
|
||||
? settings.density
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (generation !== previewGeneration) return;
|
||||
$<HTMLIFrameElement>("preview-frame").src = result[0].url;
|
||||
if (comparison) $<HTMLIFrameElement>("compare-frame").src = result[1].url;
|
||||
for (const id of ["preview-frame", "compare-frame"]) $(id).style.width = value("preview-width");
|
||||
$("preview-label").textContent = session.diagnostics.length
|
||||
? "Last valid draft · source has errors"
|
||||
: "Current draft";
|
||||
}
|
||||
async function changed() {
|
||||
render();
|
||||
await previews();
|
||||
}
|
||||
function raw(input: string): string | number | { ref: string } {
|
||||
if (input.startsWith("@")) return { ref: input.slice(1) };
|
||||
return input;
|
||||
}
|
||||
function tokenValue(type: DesignToken["type"], input: string) {
|
||||
return !input.startsWith("@") && input.trim() && ["number", "fontWeight"].includes(type)
|
||||
? Number(input)
|
||||
: raw(input);
|
||||
}
|
||||
async function applicationState() {
|
||||
const state = await request("/api/application");
|
||||
application = state;
|
||||
if (state.system) {
|
||||
session.applied = structuredClone(state.system);
|
||||
session.persist();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-tab]").forEach((button) =>
|
||||
button.addEventListener("click", () => {
|
||||
flushSource();
|
||||
tab(button.dataset.tab!);
|
||||
if (button.dataset.tab === "review") applicationState().catch((e) => message(e.message));
|
||||
}),
|
||||
);
|
||||
on("undo", async () => {
|
||||
session.undo();
|
||||
await changed();
|
||||
});
|
||||
on("redo", async () => {
|
||||
session.redo();
|
||||
await changed();
|
||||
});
|
||||
on("new", async () => {
|
||||
session.replace(
|
||||
{ schemaVersion: 1, id: "my-design", name: "My design", version: "1.0.0", tokens: {} },
|
||||
"Created new system",
|
||||
);
|
||||
await changed();
|
||||
});
|
||||
on(
|
||||
"preset",
|
||||
async () => {
|
||||
if (!value("preset")) return;
|
||||
session.replace(
|
||||
CORE_DESIGN_SYSTEMS[value("preset") as keyof typeof CORE_DESIGN_SYSTEMS],
|
||||
"Selected " + value("preset"),
|
||||
);
|
||||
await changed();
|
||||
},
|
||||
"change",
|
||||
);
|
||||
on("duplicate", async () => {
|
||||
const system = structuredClone(session.valid);
|
||||
system.id += "-copy";
|
||||
system.name += " copy";
|
||||
session.replace(system, "Duplicated system");
|
||||
await changed();
|
||||
});
|
||||
for (const [id, field] of [
|
||||
["system-name", "name"],
|
||||
["system-id", "id"],
|
||||
] as const)
|
||||
on(
|
||||
id,
|
||||
async () => {
|
||||
const next = value(id);
|
||||
session.mutate((s) => {
|
||||
s[field] = next;
|
||||
}, "Changed " + field);
|
||||
await changed();
|
||||
},
|
||||
"change",
|
||||
);
|
||||
on("token-search", renderTokens, "input");
|
||||
on("token-layer", renderTokens, "change");
|
||||
$("tokens").addEventListener("change", (event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
try {
|
||||
session.mutate(
|
||||
(system) => {
|
||||
const map = tokenMap(system);
|
||||
const name = input.dataset.token ?? input.dataset.binding!;
|
||||
const token = map[name] ?? structuredClone(system.tokens[name]!);
|
||||
if (!token) throw Error("Token not found.");
|
||||
map[name] = token;
|
||||
if (input.dataset.token) token.value = tokenValue(token.type, input.value);
|
||||
else if (input.value) token.legacy = input.value;
|
||||
else delete token.legacy;
|
||||
},
|
||||
"Edited token " + (input.dataset.token ?? input.dataset.binding),
|
||||
);
|
||||
changed().catch((e) => message(e.message));
|
||||
} catch (error) {
|
||||
message((error as Error).message);
|
||||
}
|
||||
});
|
||||
$("tokens").addEventListener("click", (event) => {
|
||||
const save = (event.target as HTMLElement).dataset.saveToken;
|
||||
if (save) {
|
||||
const parent = (event.target as HTMLElement).closest(".token")!;
|
||||
const input = parent.querySelector<HTMLInputElement>("[data-token]")!;
|
||||
const binding = parent.querySelector<HTMLInputElement>("[data-binding]")!;
|
||||
const inputValue = input.value,
|
||||
bindingValue = binding.value;
|
||||
try {
|
||||
session.mutate((system) => {
|
||||
const map = tokenMap(system);
|
||||
const token = map[save] ?? structuredClone(system.tokens[save]!);
|
||||
token.value = tokenValue(token.type, inputValue);
|
||||
if (bindingValue) token.legacy = bindingValue;
|
||||
else delete token.legacy;
|
||||
map[save] = token;
|
||||
}, "Edited token " + save);
|
||||
changed().catch((e) => message(e.message));
|
||||
} catch (error) {
|
||||
message((error as Error).message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const name = (event.target as HTMLElement).dataset.deleteToken;
|
||||
if (!name) return;
|
||||
try {
|
||||
session.mutate((s) => {
|
||||
delete tokenMap(s)[name];
|
||||
}, "Removed " + name);
|
||||
changed().catch((e) => message(e.message));
|
||||
} catch (error) {
|
||||
message((error as Error).message);
|
||||
}
|
||||
});
|
||||
$<HTMLSelectElement>("add-token-type").innerHTML = TOKEN_TYPES.map((t) => option(t)).join("");
|
||||
on("add-token", async () => {
|
||||
session.mutate((s) => {
|
||||
const name = value("add-token-name");
|
||||
if (
|
||||
!/^[A-Za-z][\w.-]*$/.test(name) ||
|
||||
name.split(/[.-]/).some((p) => ["__proto__", "constructor", "prototype"].includes(p))
|
||||
)
|
||||
throw Error("Choose a valid token name.");
|
||||
const map = tokenMap(s);
|
||||
if (Object.hasOwn(map, name)) throw Error("Token already exists.");
|
||||
map[name] = {
|
||||
type: value("add-token-type") as DesignToken["type"],
|
||||
value: tokenValue(value("add-token-type") as DesignToken["type"], value("add-token-value")),
|
||||
};
|
||||
}, "Added token");
|
||||
await changed();
|
||||
});
|
||||
for (const kind of ["mode", "density"] as const)
|
||||
on("add-" + kind, async () => {
|
||||
session.mutate((s) => {
|
||||
const name = value("add-layer-name");
|
||||
if (
|
||||
!/^[A-Za-z][\w-]*$/.test(name) ||
|
||||
["constructor", "prototype", "__proto__"].includes(name)
|
||||
)
|
||||
throw Error("Choose a valid layer name.");
|
||||
const layers = kind === "mode" ? (s.modes ??= {}) : (s.densities ??= {});
|
||||
if (Object.hasOwn(layers, name)) throw Error("Layer already exists.");
|
||||
layers[name] = {};
|
||||
}, "Added " + kind);
|
||||
await changed();
|
||||
});
|
||||
on(
|
||||
"component-search",
|
||||
() => {
|
||||
const filter = value("component-search").toLowerCase();
|
||||
selectOptions(
|
||||
"component",
|
||||
Object.keys(COMPONENT_CONTRACTS).filter((n) => n.toLowerCase().includes(filter)),
|
||||
);
|
||||
renderContract();
|
||||
},
|
||||
"input",
|
||||
);
|
||||
selectOptions("component", Object.keys(COMPONENT_CONTRACTS));
|
||||
on("component", renderContract, "change");
|
||||
on("part", renderProperties, "change");
|
||||
on("property", renderProperties, "change");
|
||||
on("set-recipe", async () => {
|
||||
session.mutate((s) => {
|
||||
const recipe = ((s.recipes ??= {})[value("component")] ??= { contractVersion: 1 });
|
||||
const part = ((recipe.base ??= {})[value("part")] ??= {});
|
||||
part[value("property")] = raw(value("recipe-value"));
|
||||
}, "Changed component default");
|
||||
await changed();
|
||||
});
|
||||
on("remove-recipe", async () => {
|
||||
session.mutate((s) => {
|
||||
const part = s.recipes?.[value("component")]?.base?.[value("part")];
|
||||
if (part) delete part[value("property")];
|
||||
}, "Removed component default");
|
||||
await changed();
|
||||
});
|
||||
on("save-recipe", async () => {
|
||||
const recipe = JSON.parse(value("recipe-source"));
|
||||
session.mutate((s) => {
|
||||
(s.recipes ??= {})[value("component")] = recipe;
|
||||
}, "Edited component recipe");
|
||||
await changed();
|
||||
});
|
||||
on("save-source", async () => {
|
||||
session.edit(value("source-text"));
|
||||
await changed();
|
||||
});
|
||||
let sourceTimer: ReturnType<typeof setTimeout>;
|
||||
$("source-text").addEventListener("input", () => {
|
||||
clearTimeout(sourceTimer);
|
||||
sourceTimer = setTimeout(() => {
|
||||
try {
|
||||
session.edit(value("source-text"));
|
||||
changed().catch((e) => message(e.message));
|
||||
} catch (e) {
|
||||
message((e as Error).message);
|
||||
}
|
||||
}, 600);
|
||||
});
|
||||
on(
|
||||
"import-file",
|
||||
async () => {
|
||||
const file = $<HTMLInputElement>("import-file").files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 2_000_000) throw Error("File exceeds 2 MB.");
|
||||
$<HTMLTextAreaElement>("import-text").value = await file.text();
|
||||
importCandidate = undefined;
|
||||
$("accept-import").setAttribute("disabled", "");
|
||||
},
|
||||
"change",
|
||||
);
|
||||
on("inspect-import", () => {
|
||||
const report = importDesign(
|
||||
value("import-text"),
|
||||
value("import-format") as "canonical" | "legacy" | "tokens",
|
||||
);
|
||||
importCandidate = report.system;
|
||||
$("import-report").textContent = JSON.stringify(
|
||||
{
|
||||
...report,
|
||||
system: report.system ? { id: report.system.id, name: report.system.name } : undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
$("accept-import").toggleAttribute("disabled", !importCandidate);
|
||||
});
|
||||
for (const id of ["import-text", "import-format"])
|
||||
on(
|
||||
id,
|
||||
() => {
|
||||
importCandidate = undefined;
|
||||
$("accept-import").setAttribute("disabled", "");
|
||||
},
|
||||
id === "import-text" ? "input" : "change",
|
||||
);
|
||||
on("accept-import", async () => {
|
||||
if (!importCandidate) throw Error("Inspect the current import first.");
|
||||
session.replace(importCandidate, "Imported design");
|
||||
await changed();
|
||||
message("Imported design. Review mapping warnings before applying.");
|
||||
});
|
||||
on("download-draft", () => download(value("source-text"), "design-draft.json"));
|
||||
on("export", () => {
|
||||
if (session.diagnostics.length)
|
||||
throw Error("Resolve errors or export the raw draft from Source & import.");
|
||||
download(exportDesign(session.valid, selection()).canonical, session.valid.id + ".json");
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-export]").forEach((button) =>
|
||||
button.addEventListener("click", () => {
|
||||
try {
|
||||
if (session.diagnostics.length) throw Error("Resolve draft errors before exporting.");
|
||||
const kind = button.dataset.export as keyof ReturnType<typeof exportDesign>;
|
||||
download(
|
||||
exportDesign(session.valid, selection())[kind],
|
||||
`${session.valid.id}-${kind}.${kind === "css" ? "css" : "json"}`,
|
||||
kind === "css" ? "text/css" : "application/json",
|
||||
);
|
||||
} catch (e) {
|
||||
message((e as Error).message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
on("review", async () => {
|
||||
tab("review");
|
||||
await applicationState();
|
||||
});
|
||||
on("apply", async () => {
|
||||
if (session.diagnostics.length) throw Error("Resolve draft errors before applying.");
|
||||
const submitted = structuredClone(session.valid);
|
||||
const result = await request("/api/apply", {
|
||||
system: submitted,
|
||||
base: application.revision,
|
||||
...selection(),
|
||||
});
|
||||
session.applied = submitted;
|
||||
session.persist();
|
||||
const { directory: _bundleDirectory, ...revisionState } = result;
|
||||
application = { ...application, ...revisionState };
|
||||
$("integration").textContent =
|
||||
`Applied to ${result.directory}\n\nImport theme from ${result.directory.replaceAll("\\", "/")}/theme.json\nand set theme in your existing WrNexus app configuration.\n\nCurrent pointer: ${application.directory}/CURRENT.json\nNo application configuration was overwritten.`;
|
||||
render();
|
||||
message("Applied validated design to the local folder.");
|
||||
});
|
||||
on("rollback", async () => {
|
||||
await request("/api/rollback", { base: application.revision });
|
||||
await applicationState();
|
||||
message("Application pointer restored to its previous revision. Your draft is preserved.");
|
||||
});
|
||||
$("revisions").addEventListener("click", (event) => {
|
||||
const index = (event.target as HTMLElement).dataset.revision;
|
||||
if (index === undefined) return;
|
||||
while (session.cursor > Number(index)) session.undo();
|
||||
while (session.cursor < Number(index)) session.redo();
|
||||
changed().catch((e) => message(e.message));
|
||||
});
|
||||
for (const id of [
|
||||
"preview-mode",
|
||||
"preview-density",
|
||||
"preview-width",
|
||||
"preview-direction",
|
||||
"preview-content",
|
||||
"compare",
|
||||
])
|
||||
on(
|
||||
id,
|
||||
async () => {
|
||||
renderTokens();
|
||||
await previews();
|
||||
},
|
||||
"change",
|
||||
);
|
||||
// Gallery pagination belongs to the Studio so isolated frames never navigate the workspace.
|
||||
const pagination = document.createElement("div");
|
||||
pagination.className = "row";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = String(i + 1);
|
||||
button.setAttribute("aria-label", `Gallery page ${i + 1}`);
|
||||
button.addEventListener("click", () => {
|
||||
page = i;
|
||||
$<HTMLSelectElement>("preview-content").value = "catalog";
|
||||
previews().catch((e) => message(e.message));
|
||||
});
|
||||
pagination.append(button);
|
||||
}
|
||||
$("preview-content").closest(".preview-controls")!.after(pagination);
|
||||
on("refresh-preview", previews);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key.toLowerCase() === "z" &&
|
||||
!["INPUT", "TEXTAREA"].includes((event.target as HTMLElement).tagName)
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) session.redo();
|
||||
else session.undo();
|
||||
changed().catch((e) => message(e.message));
|
||||
}
|
||||
});
|
||||
window.addEventListener("beforeunload", () => {
|
||||
clearTimeout(sourceTimer);
|
||||
if (activeTab === "source" && value("source-text") !== session.source) {
|
||||
try {
|
||||
session.edit(value("source-text"));
|
||||
} catch {
|
||||
/* Existing local draft remains recoverable. */
|
||||
}
|
||||
} else session.persist();
|
||||
});
|
||||
const guidance = setupGuidance(session, {
|
||||
message,
|
||||
changed,
|
||||
preview: async (system) => (await request("/api/preview", { system, content: "composed" })).url,
|
||||
});
|
||||
render();
|
||||
applicationState()
|
||||
.then(previews)
|
||||
.catch((e) => message(e.message));
|
||||
@@ -0,0 +1,214 @@
|
||||
import { parse } from "@wrnexus/compiler";
|
||||
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
|
||||
import reference from "@wrnexus/ui/component-reference.json";
|
||||
|
||||
export interface ConformanceDiagnostic {
|
||||
code: string;
|
||||
path: string;
|
||||
message: string;
|
||||
severity: "error" | "warning";
|
||||
exception?: string;
|
||||
}
|
||||
export interface ConformanceException {
|
||||
code: string;
|
||||
path: string;
|
||||
reason: string;
|
||||
}
|
||||
export function checkDesignConformance(source: string, exceptions: ConformanceException[] = []) {
|
||||
const diagnostics: ConformanceDiagnostic[] = [];
|
||||
const add = (
|
||||
code: string,
|
||||
path: string,
|
||||
message: string,
|
||||
severity: "error" | "warning" = "error",
|
||||
) => diagnostics.push({ code, path, message, severity });
|
||||
if (typeof source !== "string" || source.length > 128000)
|
||||
return {
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "WRN-CONFORMANCE-LIMIT",
|
||||
path: "$",
|
||||
message: "Source must be at most 128,000 characters.",
|
||||
severity: "error" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (
|
||||
!Array.isArray(exceptions) ||
|
||||
exceptions.length > 64 ||
|
||||
exceptions.some(
|
||||
(e) =>
|
||||
!e ||
|
||||
typeof e.code !== "string" ||
|
||||
typeof e.path !== "string" ||
|
||||
typeof e.reason !== "string" ||
|
||||
!e.reason.trim() ||
|
||||
e.reason.length > 500,
|
||||
)
|
||||
)
|
||||
throw Error(
|
||||
"WRN-CONFORMANCE-EXCEPTION: provide up to 64 exact code/path exceptions with reasons.",
|
||||
);
|
||||
let ast: ReturnType<typeof parse>;
|
||||
try {
|
||||
ast = parse(source);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "WRN-CONFORMANCE-PARSE",
|
||||
path: "$",
|
||||
message: (error as Error).message,
|
||||
severity: "error" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
let count = 0;
|
||||
function css(text: string, path: string) {
|
||||
const withoutVars = text.replace(/var\(\s*--wrn-[\w-]+(?:\s*,[^)]*)?\)/g, "TOKEN");
|
||||
if (/#[a-f\d]{3,8}\b|\b(?:rgb|hsl)a?\(/i.test(withoutVars))
|
||||
add(
|
||||
"WRN-CONFORMANCE-COLOR",
|
||||
path,
|
||||
"Literal color bypasses the system. Prefer a semantic --wrn-* token.",
|
||||
"warning",
|
||||
);
|
||||
if (
|
||||
/(?:font-family|font-size|border-radius|box-shadow|padding(?:-\w+)?|margin(?:-\w+)?|gap)\s*:[^;}]*?(?:\d+(?:\.\d+)?(?:px|rem|em)|["'][A-Za-z])/i.test(
|
||||
withoutVars,
|
||||
)
|
||||
)
|
||||
add(
|
||||
"WRN-CONFORMANCE-FOUNDATION",
|
||||
path,
|
||||
"Literal typography, spacing, shape or elevation bypasses a foundation token.",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
function walk(value: unknown, path: string) {
|
||||
if (++count > 20000) throw Error("AST traversal limit exceeded.");
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((node, index) => walk(node, `${path}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
const node = value as {
|
||||
type?: string;
|
||||
tag?: string;
|
||||
attrs?: Array<{ name: string; value: string; event?: boolean }>;
|
||||
};
|
||||
if (node.type === "element" && node.tag) {
|
||||
const mount = node.attrs?.find((a) => a.name === "data-component");
|
||||
const name = mount?.value ?? (/^[A-Z]/.test(node.tag) ? node.tag : undefined);
|
||||
if (name) {
|
||||
if (name.includes("{"))
|
||||
add(
|
||||
"WRN-CONFORMANCE-DYNAMIC",
|
||||
path,
|
||||
"Dynamic component mount cannot be verified.",
|
||||
"warning",
|
||||
);
|
||||
else if (!Object.hasOwn(COMPONENT_CONTRACTS, name))
|
||||
add(
|
||||
"WRN-CONFORMANCE-COMPONENT",
|
||||
path,
|
||||
`No contract for '${name}'. Custom components require an explicit reviewed exception.`,
|
||||
"warning",
|
||||
);
|
||||
else {
|
||||
const contract = COMPONENT_CONTRACTS[name as keyof typeof COMPONENT_CONTRACTS];
|
||||
const meta = reference.components.find((c) => c.name === name)!;
|
||||
for (const attr of node.attrs ?? []) {
|
||||
if (attr.event) {
|
||||
if (!meta.outputs.some((output) => output.name === attr.name))
|
||||
add(
|
||||
"WRN-CONFORMANCE-EVENT",
|
||||
path + "." + attr.name,
|
||||
`Unknown ${name} event '${attr.name}'.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
["data-component", "slot"].includes(attr.name) ||
|
||||
attr.name.startsWith("aria-") ||
|
||||
attr.name.startsWith("data-") ||
|
||||
attr.name.startsWith("@")
|
||||
)
|
||||
continue;
|
||||
const prop = meta.props.find((p) => p.name === attr.name);
|
||||
if (!prop && !["class", "attrs"].includes(attr.name))
|
||||
add(
|
||||
"WRN-CONFORMANCE-PROP",
|
||||
path + "." + attr.name,
|
||||
`Unknown ${name} prop '${attr.name}'.`,
|
||||
);
|
||||
const axis = (contract.axes as Record<string, { values: readonly string[] }>)[
|
||||
attr.name
|
||||
];
|
||||
if (axis) {
|
||||
let literal = attr.value;
|
||||
if (literal.startsWith("{")) {
|
||||
try {
|
||||
literal = String(JSON.parse(literal.slice(1, -1)));
|
||||
} catch {
|
||||
add(
|
||||
"WRN-CONFORMANCE-DYNAMIC",
|
||||
path + "." + attr.name,
|
||||
"Dynamic axis requires runtime validation.",
|
||||
"warning",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!axis.values.includes(literal))
|
||||
add(
|
||||
"WRN-CONFORMANCE-AXIS",
|
||||
path + "." + attr.name,
|
||||
`Unsupported ${name}.${attr.name} '${literal}'. Allowed: ${axis.values.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const attr of node.attrs ?? []) {
|
||||
if (attr.name === "style") css(attr.value, path + ".style");
|
||||
if (
|
||||
attr.name === "class" &&
|
||||
/(?:^|\s)(?:bg|text|rounded|shadow|p[xytrbl]?|m[xytrbl]?|gap)-(?:\[|\d|(?:red|blue|green|gray)-)/.test(
|
||||
attr.value,
|
||||
)
|
||||
)
|
||||
add(
|
||||
"WRN-CONFORMANCE-UTILITY",
|
||||
path + ".class",
|
||||
"Utility styling may bypass tokens; use a token-backed class or document the exception.",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(value))
|
||||
if (key !== "attrs") walk(child, `${path}.${key}`);
|
||||
}
|
||||
try {
|
||||
walk(ast.view, "$.view");
|
||||
ast.styles?.forEach((style, index) => css(style, `$.styles[${index}]`));
|
||||
} catch (error) {
|
||||
add("WRN-CONFORMANCE-LIMIT", "$", (error as Error).message);
|
||||
}
|
||||
for (const exception of exceptions) {
|
||||
const item = diagnostics.find(
|
||||
(d) => d.code === exception.code && d.path === exception.path && d.severity === "warning",
|
||||
);
|
||||
if (item) item.exception = exception.reason;
|
||||
else
|
||||
add(
|
||||
"WRN-CONFORMANCE-EXCEPTION",
|
||||
exception.path,
|
||||
"Exception does not match a warning; errors cannot be waived.",
|
||||
);
|
||||
}
|
||||
return { ok: !diagnostics.some((d) => !d.exception), diagnostics };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
resolveDesignSystem,
|
||||
validateDesignSystem,
|
||||
type DesignSystem,
|
||||
type DesignDiagnostic,
|
||||
} from "@wrnexus/design-system";
|
||||
|
||||
export type DecisionLock = string;
|
||||
const forbidden = new Set(["__proto__", "prototype", "constructor"]);
|
||||
export function decisionPath(path: string): string[] {
|
||||
if (typeof path !== "string" || path.length > 512 || !path.startsWith("/"))
|
||||
throw Error("WRN-STUDIO-LOCK-PATH: use a JSON pointer.");
|
||||
const parts = path
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.map((p) => {
|
||||
if (/~(?![01])/u.test(p)) throw Error("Invalid pointer escape.");
|
||||
return p.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
});
|
||||
if (
|
||||
!["tokens", "modes", "densities", "recipes", "name", "id", "version", "defaultMode"].includes(
|
||||
parts[0]!,
|
||||
) ||
|
||||
parts.some((p) => !p || forbidden.has(p)) ||
|
||||
parts.length > 8
|
||||
)
|
||||
throw Error("WRN-STUDIO-LOCK-PATH: unsupported decision path.");
|
||||
return parts;
|
||||
}
|
||||
export const pointerPart = (value: string) => value.replace(/~/g, "~0").replace(/\//g, "~1");
|
||||
export function readDecision(value: unknown, path: string): unknown {
|
||||
let current = value;
|
||||
for (const part of decisionPath(path)) {
|
||||
if (!current || typeof current !== "object" || !Object.hasOwn(current, part)) return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
export function writeDecision(system: DesignSystem, path: string, value: unknown) {
|
||||
const parts = decisionPath(path);
|
||||
let target = system as unknown as Record<string, unknown>;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!Object.hasOwn(target, part)) target[part] = {};
|
||||
if (!target[part] || typeof target[part] !== "object" || Array.isArray(target[part]))
|
||||
throw Error("WRN-STUDIO-PATCH: parent must be an object.");
|
||||
target = target[part] as Record<string, unknown>;
|
||||
}
|
||||
const last = parts.at(-1)!;
|
||||
if (value === undefined) delete target[last];
|
||||
else target[last] = structuredClone(value);
|
||||
}
|
||||
export function validateLocks(locks: unknown): asserts locks is DecisionLock[] {
|
||||
if (
|
||||
!Array.isArray(locks) ||
|
||||
locks.length > 128 ||
|
||||
locks.some((p) => typeof p !== "string") ||
|
||||
new Set(locks).size !== locks.length
|
||||
)
|
||||
throw Error("WRN-STUDIO-LOCKS: provide up to 128 unique decision paths.");
|
||||
for (const path of locks) decisionPath(path);
|
||||
}
|
||||
function references(value: unknown, result = new Set<string>()) {
|
||||
if (value && typeof value === "object") {
|
||||
if ("ref" in value && typeof value.ref === "string") result.add(value.ref);
|
||||
else for (const child of Object.values(value)) references(child, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function tokenNames(system: DesignSystem, path: string) {
|
||||
const [kind, layer, name] = decisionPath(path);
|
||||
const names = new Set<string>();
|
||||
if (kind === "tokens")
|
||||
for (const token of Object.keys(system.tokens)) if (!layer || token === layer) names.add(token);
|
||||
if (kind === "modes" || kind === "densities") {
|
||||
const layers = system[kind] ?? {};
|
||||
for (const [key, tokens] of Object.entries(layers))
|
||||
if (!layer || key === layer)
|
||||
for (const token of Object.keys(tokens)) if (!name || name === token) names.add(token);
|
||||
}
|
||||
if (kind === "recipes") references(readDecision(system, path), names);
|
||||
return names;
|
||||
}
|
||||
function configurations(a: DesignSystem, b: DesignSystem) {
|
||||
const modes = [
|
||||
undefined,
|
||||
...new Set([...Object.keys(a.modes ?? {}), ...Object.keys(b.modes ?? {})]),
|
||||
];
|
||||
const densities = [
|
||||
undefined,
|
||||
...new Set([...Object.keys(a.densities ?? {}), ...Object.keys(b.densities ?? {})]),
|
||||
];
|
||||
if (modes.length * densities.length > 128)
|
||||
throw Error("WRN-STUDIO-LOCK-LIMIT: too many lock configurations.");
|
||||
return modes.flatMap((mode) => densities.map((density) => ({ mode, density })));
|
||||
}
|
||||
/** Locks protect raw decisions and effective referenced values, including aliases and modes. */
|
||||
export function checkDecisionLocks(
|
||||
before: DesignSystem,
|
||||
after: DesignSystem,
|
||||
locks: DecisionLock[],
|
||||
): DesignDiagnostic[] {
|
||||
validateLocks(locks);
|
||||
if (!locks.length) return [];
|
||||
const diagnostics: DesignDiagnostic[] = [];
|
||||
const fail = (path: string, message: string) =>
|
||||
diagnostics.push({ code: "WRN-STUDIO-LOCK", path, message });
|
||||
const configurationsList = configurations(before, after);
|
||||
const resolve = (system: DesignSystem, mode?: string, density?: string) =>
|
||||
resolveDesignSystem(system, {
|
||||
mode: mode && Object.hasOwn(system.modes ?? {}, mode) ? mode : undefined,
|
||||
density: density && Object.hasOwn(system.densities ?? {}, density) ? density : undefined,
|
||||
}).tokens;
|
||||
const pairs = configurationsList.map(({ mode, density }) => ({
|
||||
mode,
|
||||
density,
|
||||
before: resolve(before, mode, density),
|
||||
after: resolve(after, mode, density),
|
||||
}));
|
||||
for (const path of locks) {
|
||||
if (JSON.stringify(readDecision(before, path)) !== JSON.stringify(readDecision(after, path))) {
|
||||
fail(path, "This decision is locked. Unlock it before changing or removing it.");
|
||||
continue;
|
||||
}
|
||||
const names = tokenNames(before, path);
|
||||
let changed = false;
|
||||
for (const pair of pairs) {
|
||||
const parts = decisionPath(path);
|
||||
if (parts[0] === "modes" && parts[1] && pair.mode !== parts[1]) continue;
|
||||
if (parts[0] === "densities" && parts[1] && pair.density !== parts[1]) continue;
|
||||
for (const name of names)
|
||||
if (
|
||||
JSON.stringify({ type: pair.before[name]?.type, value: pair.before[name]?.value }) !==
|
||||
JSON.stringify({ type: pair.after[name]?.type, value: pair.after[name]?.value })
|
||||
) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (changed) break;
|
||||
}
|
||||
if (changed)
|
||||
fail(path, "A referenced token or mode changes the effective value of this locked decision.");
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
/** Local directions copy lock dependencies; arbitrary provider patches must pass checks unchanged. */
|
||||
export function preserveDecisionLocks(
|
||||
before: DesignSystem,
|
||||
after: DesignSystem,
|
||||
locks: DecisionLock[],
|
||||
) {
|
||||
validateLocks(locks);
|
||||
const next = structuredClone(after);
|
||||
const names = new Set<string>();
|
||||
for (const path of locks) {
|
||||
writeDecision(next, path, readDecision(before, path));
|
||||
for (const name of tokenNames(before, path)) names.add(name);
|
||||
}
|
||||
// The default selection is part of an effective token/recipe decision.
|
||||
if (names.size) next.defaultMode = before.defaultMode;
|
||||
for (const name of names) {
|
||||
for (const token of [
|
||||
before.tokens[name],
|
||||
...Object.values(before.modes ?? {}).map((t) => t[name]),
|
||||
...Object.values(before.densities ?? {}).map((t) => t[name]),
|
||||
])
|
||||
for (const ref of references(token)) names.add(ref);
|
||||
writeDecision(next, "/tokens/" + pointerPart(name), before.tokens[name]);
|
||||
for (const kind of ["modes", "densities"] as const)
|
||||
for (const layer of new Set([
|
||||
...Object.keys(before[kind] ?? {}),
|
||||
...Object.keys(next[kind] ?? {}),
|
||||
]))
|
||||
writeDecision(
|
||||
next,
|
||||
`/${kind}/${pointerPart(layer)}/${pointerPart(name)}`,
|
||||
before[kind]?.[layer]?.[name],
|
||||
);
|
||||
}
|
||||
if (validateDesignSystem(next).length)
|
||||
throw Error("WRN-STUDIO-LOCK: locked decisions cannot be combined with this direction.");
|
||||
const failures = checkDecisionLocks(before, next, locks);
|
||||
if (failures.length) throw Error(failures.map((d) => d.message).join(" "));
|
||||
return next;
|
||||
}
|
||||
export async function designRevision(system: DesignSystem, locks: DecisionLock[] = []) {
|
||||
validateLocks(locks);
|
||||
const bytes = new TextEncoder().encode(JSON.stringify({ system, locks: [...locks].sort() }));
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import {
|
||||
CORE_DESIGN_SYSTEMS,
|
||||
COMPONENT_CONTRACTS,
|
||||
validateDesignSystem,
|
||||
type DesignSystem,
|
||||
type DesignDiagnostic,
|
||||
type TokenMap,
|
||||
} from "@wrnexus/design-system";
|
||||
import {
|
||||
checkDecisionLocks,
|
||||
preserveDecisionLocks,
|
||||
designRevision,
|
||||
validateLocks,
|
||||
writeDecision,
|
||||
decisionPath,
|
||||
type DecisionLock,
|
||||
} from "./decisions.ts";
|
||||
import { diffDesign } from "./session.ts";
|
||||
|
||||
export interface DesignBrief {
|
||||
product: string;
|
||||
audience: string;
|
||||
tone: "restrained" | "warm" | "expressive";
|
||||
geometry: "square" | "rounded" | "angular";
|
||||
density: "compact" | "comfortable" | "spacious";
|
||||
surface: "flat" | "soft" | "hard";
|
||||
brandColor?: string;
|
||||
references?: string;
|
||||
constraints?: string;
|
||||
}
|
||||
export interface DesignProposal {
|
||||
id: string;
|
||||
name: string;
|
||||
rationale: string;
|
||||
system: DesignSystem;
|
||||
changedPaths: string[];
|
||||
}
|
||||
export interface GenerationRequest {
|
||||
system: DesignSystem;
|
||||
locks: DecisionLock[];
|
||||
brief: DesignBrief;
|
||||
baseRevision: string;
|
||||
instruction?: string;
|
||||
kind: "directions" | "edit";
|
||||
}
|
||||
export interface GenerationBatch {
|
||||
brief: DesignBrief;
|
||||
locks: DecisionLock[];
|
||||
instruction?: string;
|
||||
baseRevision: string;
|
||||
alternatives: DesignProposal[];
|
||||
provenance: {
|
||||
provider: string;
|
||||
model?: string;
|
||||
templateVersion: 1;
|
||||
attempts: number;
|
||||
maxOutputTokens: number;
|
||||
createdAt: string;
|
||||
};
|
||||
diagnostics: DesignDiagnostic[];
|
||||
}
|
||||
export interface GenerationProvider {
|
||||
name: string;
|
||||
generate(
|
||||
prompt: string,
|
||||
options?: { system?: string; model?: string; maxTokens?: number; signal?: AbortSignal },
|
||||
): Promise<{ value: string; provider: string; model?: string }>;
|
||||
}
|
||||
export const DEFAULT_DESIGN_BRIEF: DesignBrief = {
|
||||
product: "",
|
||||
audience: "",
|
||||
tone: "restrained",
|
||||
geometry: "square",
|
||||
density: "comfortable",
|
||||
surface: "flat",
|
||||
};
|
||||
const diagnostic = (code: string, message: string, path = "$"): DesignDiagnostic => ({
|
||||
code,
|
||||
path,
|
||||
message,
|
||||
});
|
||||
export function validateGenerationRequest(input: unknown): asserts input is GenerationRequest {
|
||||
if (!input || typeof input !== "object")
|
||||
throw Error("WRN-STUDIO-GENERATION: request must be an object.");
|
||||
const r = input as GenerationRequest;
|
||||
if (validateDesignSystem(r.system).length)
|
||||
throw Error("WRN-STUDIO-GENERATION: source design must validate.");
|
||||
validateLocks(r.locks);
|
||||
if (
|
||||
!["directions", "edit"].includes(r.kind) ||
|
||||
typeof r.baseRevision !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(r.baseRevision)
|
||||
)
|
||||
throw Error("WRN-STUDIO-GENERATION: invalid kind or revision.");
|
||||
const b = r.brief;
|
||||
if (
|
||||
b &&
|
||||
typeof b === "object" &&
|
||||
Object.keys(b).some(
|
||||
(name) =>
|
||||
![
|
||||
"product",
|
||||
"audience",
|
||||
"tone",
|
||||
"geometry",
|
||||
"density",
|
||||
"surface",
|
||||
"brandColor",
|
||||
"references",
|
||||
"constraints",
|
||||
].includes(name),
|
||||
)
|
||||
)
|
||||
throw Error("WRN-STUDIO-BRIEF: unknown brief field.");
|
||||
if (
|
||||
!b ||
|
||||
typeof b !== "object" ||
|
||||
!["restrained", "warm", "expressive"].includes(b.tone) ||
|
||||
!["square", "rounded", "angular"].includes(b.geometry) ||
|
||||
!["compact", "comfortable", "spacious"].includes(b.density) ||
|
||||
!["flat", "soft", "hard"].includes(b.surface)
|
||||
)
|
||||
throw Error("WRN-STUDIO-BRIEF: choose valid design options.");
|
||||
for (const name of ["product", "audience", "references", "constraints"] as const)
|
||||
if (b[name] !== undefined && (typeof b[name] !== "string" || b[name]!.length > 2000))
|
||||
throw Error("WRN-STUDIO-BRIEF: text fields are limited to 2000 characters.");
|
||||
if (typeof b.product !== "string" || typeof b.audience !== "string")
|
||||
throw Error("WRN-STUDIO-BRIEF: product and audience must be text.");
|
||||
if (b.brandColor && !/^#[a-f0-9]{6}$/i.test(b.brandColor))
|
||||
throw Error("WRN-STUDIO-BRIEF: brand color must be #RRGGBB.");
|
||||
if (
|
||||
r.instruction !== undefined &&
|
||||
(typeof r.instruction !== "string" || r.instruction.length > 2000)
|
||||
)
|
||||
throw Error("WRN-STUDIO-INSTRUCTION: enter up to 2000 characters.");
|
||||
if (
|
||||
r.kind === "edit" &&
|
||||
(typeof r.instruction !== "string" || !r.instruction.trim() || r.instruction.length > 2000)
|
||||
)
|
||||
throw Error("WRN-STUDIO-INSTRUCTION: enter up to 2000 characters.");
|
||||
}
|
||||
function color(system: DesignSystem, hex: string) {
|
||||
const rgb = hex
|
||||
.slice(1)
|
||||
.match(/../g)!
|
||||
.map((n) => parseInt(n, 16));
|
||||
const luminance = rgb
|
||||
.map((n) => {
|
||||
const c = n / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce((s, c, i) => s + c * [0.2126, 0.7152, 0.0722][i]!, 0);
|
||||
const foreground = (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#ffffff";
|
||||
const shade = (factor: number) =>
|
||||
"#" +
|
||||
rgb
|
||||
.map((n) =>
|
||||
Math.round(n * factor)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("");
|
||||
for (const tokens of [system.tokens, ...Object.values(system.modes ?? {})])
|
||||
for (const [name, value] of Object.entries({
|
||||
"color.primary": hex,
|
||||
"color.primary-hover": shade(0.88),
|
||||
"color.primary-active": shade(0.75),
|
||||
"color.primary-contrast": foreground,
|
||||
})) {
|
||||
if (tokens[name]) tokens[name] = { ...tokens[name]!, value };
|
||||
}
|
||||
}
|
||||
function foundation(system: DesignSystem, name: string, value: string) {
|
||||
if (system.tokens[name]) system.tokens[name] = { ...system.tokens[name]!, value };
|
||||
}
|
||||
function applyGeometry(system: DesignSystem, geometry: DesignBrief["geometry"]) {
|
||||
foundation(system, "shape.control", geometry === "rounded" ? "16px" : "0");
|
||||
foundation(system, "shape.panel", geometry === "rounded" ? "24px" : "0");
|
||||
foundation(system, "shape.dialog", geometry === "rounded" ? "32px" : "0");
|
||||
foundation(system, "shape.base", geometry === "rounded" ? "24px" : "0");
|
||||
// Independent rotation preserves interaction transforms, matching the existing Angular contract.
|
||||
for (const [name, part, angle] of [
|
||||
["Button", "control", "-2deg"],
|
||||
["Card", "panel", "1deg"],
|
||||
] as const) {
|
||||
const recipe = system.recipes?.[name];
|
||||
if (!recipe) continue;
|
||||
const styles = ((recipe.base ??= {})[part] ??= {});
|
||||
styles.rotate = geometry === "angular" ? angle : "0deg";
|
||||
}
|
||||
}
|
||||
function applyDensity(system: DesignSystem, density: DesignBrief["density"]) {
|
||||
const compact = density === "compact",
|
||||
spacious = density === "spacious";
|
||||
foundation(system, "space.control", compact ? "0.625rem" : spacious ? "1.25rem" : "0.875rem");
|
||||
foundation(system, "space.panel", compact ? "0.75rem" : spacious ? "1.75rem" : "1.25rem");
|
||||
foundation(system, "space.page", compact ? "1rem" : spacious ? "2rem" : "1.5rem");
|
||||
foundation(system, "control.height", compact ? "2.25rem" : spacious ? "3rem" : "2.5rem");
|
||||
}
|
||||
function applySurface(system: DesignSystem, surface: DesignBrief["surface"]) {
|
||||
foundation(
|
||||
system,
|
||||
"elevation.panel",
|
||||
surface === "flat"
|
||||
? "none"
|
||||
: surface === "soft"
|
||||
? "0 8px 24px rgba(0, 0, 0, 0.12)"
|
||||
: "4px 4px 0 #000000",
|
||||
);
|
||||
}
|
||||
function mapLayers(
|
||||
base: Record<string, TokenMap> | undefined,
|
||||
extra: Record<string, TokenMap> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
[...new Set([...Object.keys(base ?? {}), ...Object.keys(extra ?? {})])].map((name) => [
|
||||
name,
|
||||
{ ...base?.[name], ...extra?.[name] },
|
||||
]),
|
||||
);
|
||||
}
|
||||
function direction(
|
||||
request: GenerationRequest,
|
||||
preset: keyof typeof CORE_DESIGN_SYSTEMS,
|
||||
index: number,
|
||||
) {
|
||||
const template = CORE_DESIGN_SYSTEMS[preset];
|
||||
const system: DesignSystem = {
|
||||
...structuredClone(request.system),
|
||||
tokens: { ...structuredClone(request.system.tokens), ...structuredClone(template.tokens) },
|
||||
modes: mapLayers(request.system.modes, template.modes),
|
||||
recipes: { ...structuredClone(request.system.recipes), ...structuredClone(template.recipes) },
|
||||
};
|
||||
const desired =
|
||||
index === 0
|
||||
? request.brief.geometry
|
||||
: preset === "soft"
|
||||
? "rounded"
|
||||
: preset === "angular"
|
||||
? "angular"
|
||||
: "square";
|
||||
applyGeometry(system, desired);
|
||||
applyDensity(
|
||||
system,
|
||||
index === 0 ? request.brief.density : preset === "soft" ? "spacious" : "compact",
|
||||
);
|
||||
applySurface(
|
||||
system,
|
||||
index === 0
|
||||
? request.brief.surface
|
||||
: preset === "angular"
|
||||
? "hard"
|
||||
: preset === "soft"
|
||||
? "soft"
|
||||
: "flat",
|
||||
);
|
||||
if (request.brief.brandColor) color(system, request.brief.brandColor);
|
||||
return preserveDecisionLocks(request.system, system, request.locks);
|
||||
}
|
||||
function localEdit(request: GenerationRequest) {
|
||||
const text = request.instruction!.trim().toLowerCase();
|
||||
const system = structuredClone(request.system);
|
||||
const clauses = text.split(/\s*(?:;|,|\band\b)\s*/).filter(Boolean);
|
||||
if (clauses.length > 8) throw Error("Use at most eight local edit clauses.");
|
||||
for (const clause of clauses) {
|
||||
if (
|
||||
/^(?:make (?:the )?(?:buttons?|controls?|corners?) |use |set (?:the )?geometry (?:to )?)?(?:square|sharp)(?: corners?| buttons?| controls?)?$/.test(
|
||||
clause,
|
||||
)
|
||||
)
|
||||
applyGeometry(system, "square");
|
||||
else if (
|
||||
/^(?:make (?:the )?(?:buttons?|controls?|corners?) |use |set (?:the )?geometry (?:to )?)?(?:rounded|round|rounder)(?: corners?| buttons?| controls?)?$/.test(
|
||||
clause,
|
||||
)
|
||||
)
|
||||
applyGeometry(system, "rounded");
|
||||
else if (/^(?:tilt (?:the )?buttons?|use angular(?: buttons?| geometry)?)$/.test(clause))
|
||||
applyGeometry(system, "angular");
|
||||
else if (
|
||||
/^(?:(?:make (?:it |the layout )?|use )(?:more )?)?(compact|comfortable|spacious)(?: spacing| density| layout)?$/.test(
|
||||
clause,
|
||||
)
|
||||
)
|
||||
applyDensity(
|
||||
system,
|
||||
clause.includes("compact")
|
||||
? "compact"
|
||||
: clause.includes("spacious")
|
||||
? "spacious"
|
||||
: "comfortable",
|
||||
);
|
||||
else if (/^(?:remove shadows|no shadows|use flat surfaces)$/.test(clause))
|
||||
applySurface(system, "flat");
|
||||
else if (/^(?:use soft shadows|add soft shadows)$/.test(clause)) applySurface(system, "soft");
|
||||
else if (/^(?:use hard shadows|add hard shadows)$/.test(clause)) applySurface(system, "hard");
|
||||
else if (
|
||||
/^(?:use |set (?:the )?(?:brand|primary)(?: color)? (?:to )?)(?:brand |primary |color )?#[a-f0-9]{6}$/.test(
|
||||
clause,
|
||||
)
|
||||
)
|
||||
color(system, clause.match(/#[a-f0-9]{6}/)![0]);
|
||||
else if (/^(?:use )?(serif|sans-serif|monospace)(?: typography| fonts?)?$/.test(clause)) {
|
||||
const font = clause.includes("sans-serif")
|
||||
? "Helvetica, Arial, sans-serif"
|
||||
: clause.includes("monospace")
|
||||
? '"Courier New", monospace'
|
||||
: "Georgia, serif";
|
||||
foundation(system, "type.body", font);
|
||||
foundation(system, "type.heading", font);
|
||||
} else
|
||||
throw Error(
|
||||
`WRN-STUDIO-LOCAL-EDIT: unsupported clause '${clause}'. Use square/rounded controls, compact/spacious spacing, flat/soft/hard shadows, serif/sans-serif/monospace typography, or set primary color to #RRGGBB. Broader requests require a configured provider.`,
|
||||
);
|
||||
}
|
||||
const failures = checkDecisionLocks(request.system, system, request.locks);
|
||||
if (failures.length) throw Error("WRN-STUDIO-LOCK: " + failures.map((d) => d.path).join(", "));
|
||||
return system;
|
||||
}
|
||||
function proposal(
|
||||
request: GenerationRequest,
|
||||
system: DesignSystem,
|
||||
index: number,
|
||||
name: string,
|
||||
rationale: string,
|
||||
): DesignProposal {
|
||||
const errors = validateDesignSystem(system);
|
||||
if (errors.length) throw Error(errors.map((d) => `${d.code} ${d.path}: ${d.message}`).join("\n"));
|
||||
const locks = checkDecisionLocks(request.system, system, request.locks);
|
||||
if (locks.length) throw Error("WRN-STUDIO-LOCK: " + locks.map((d) => d.path).join(", "));
|
||||
return {
|
||||
id: "direction-" + (index + 1),
|
||||
name,
|
||||
rationale,
|
||||
system,
|
||||
changedPaths: diffDesign(request.system, system),
|
||||
};
|
||||
}
|
||||
export function applyDesignPatch(system: DesignSystem, patch: unknown): DesignSystem {
|
||||
if (!Array.isArray(patch) || patch.length > 48)
|
||||
throw Error("WRN-STUDIO-PATCH: at most 48 operations per alternative.");
|
||||
const next = structuredClone(system);
|
||||
for (const op of patch) {
|
||||
if (
|
||||
!op ||
|
||||
typeof op !== "object" ||
|
||||
!["set", "remove"].includes(op.op) ||
|
||||
typeof op.path !== "string"
|
||||
)
|
||||
throw Error("WRN-STUDIO-PATCH: expected set/remove path operations.");
|
||||
decisionPath(op.path);
|
||||
if (op.op === "set" && !Object.hasOwn(op, "value"))
|
||||
throw Error("WRN-STUDIO-PATCH: set requires value.");
|
||||
writeDecision(next, op.path, op.op === "remove" ? undefined : op.value);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
export async function generateDesigns(
|
||||
request: GenerationRequest,
|
||||
options: {
|
||||
provider?: GenerationProvider;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
maxOutputTokens?: number;
|
||||
timeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<GenerationBatch> {
|
||||
validateGenerationRequest(request);
|
||||
if (request.baseRevision !== (await designRevision(request.system, request.locks)))
|
||||
throw Error("WRN-STUDIO-CONFLICT: source revision does not match.");
|
||||
const budget = options.maxOutputTokens ?? 4096;
|
||||
if (!Number.isInteger(budget) || budget < 256 || budget > 8192)
|
||||
throw Error("WRN-STUDIO-BUDGET: output budget must be 256–8192 tokens.");
|
||||
const timeout = options.timeoutMs ?? 20000;
|
||||
if (!Number.isInteger(timeout) || timeout < 10 || timeout > 60000)
|
||||
throw Error("WRN-STUDIO-BUDGET: timeout must be 10–60000 ms.");
|
||||
const signal = AbortSignal.any([
|
||||
...(options.signal ? [options.signal] : []),
|
||||
AbortSignal.timeout(timeout),
|
||||
]);
|
||||
signal.throwIfAborted();
|
||||
const batch: GenerationBatch = {
|
||||
brief: structuredClone(request.brief),
|
||||
locks: [...request.locks],
|
||||
instruction: request.instruction,
|
||||
baseRevision: request.baseRevision,
|
||||
alternatives: [],
|
||||
diagnostics: [],
|
||||
provenance: {
|
||||
provider: options.provider?.name ?? "local-rules",
|
||||
model: options.model,
|
||||
templateVersion: 1,
|
||||
attempts: 0,
|
||||
maxOutputTokens: options.provider ? budget : 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
if (!options.provider) {
|
||||
batch.provenance.attempts = 1;
|
||||
try {
|
||||
if (request.kind === "edit")
|
||||
batch.alternatives = [
|
||||
proposal(
|
||||
request,
|
||||
localEdit(request),
|
||||
0,
|
||||
"Requested edit",
|
||||
"Local rule-based edit. Review the exact changed paths before accepting.",
|
||||
),
|
||||
];
|
||||
else {
|
||||
const first =
|
||||
request.brief.geometry === "rounded"
|
||||
? "soft"
|
||||
: request.brief.geometry === "angular"
|
||||
? "angular"
|
||||
: request.brief.tone === "warm"
|
||||
? "soft"
|
||||
: request.brief.tone === "expressive"
|
||||
? "angular"
|
||||
: "square";
|
||||
const order = [
|
||||
first,
|
||||
...(["square", "soft", "angular"] as const).filter((name) => name !== first),
|
||||
] as Array<keyof typeof CORE_DESIGN_SYSTEMS>;
|
||||
batch.alternatives = order.map((name, index) =>
|
||||
proposal(
|
||||
request,
|
||||
direction(request, name, index),
|
||||
index,
|
||||
index === 0 ? "Brief direction" : CORE_DESIGN_SYSTEMS[name].name,
|
||||
`${index === 0 ? "Uses your geometry, spacing and surface choices." : "An alternative foundation direction."} ${request.locks.length} locked decisions preserved. Product, audience and reference prose are recorded as context; local rules do not interpret free-form prose.`,
|
||||
),
|
||||
);
|
||||
if (new Set(batch.alternatives.map((p) => JSON.stringify(p.system))).size !== 3)
|
||||
throw Error(
|
||||
"Locked decisions or brief choices collapse the directions. Unlock a decision or refine the brief to obtain three distinct alternatives.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
batch.alternatives = [];
|
||||
batch.diagnostics = [diagnostic("WRN-STUDIO-GENERATION", (error as Error).message)];
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
return batch;
|
||||
}
|
||||
const propertyProfiles: string[][] = [];
|
||||
const profile = (properties: readonly string[]) => {
|
||||
const key = JSON.stringify(properties);
|
||||
const index = propertyProfiles.findIndex((p) => JSON.stringify(p) === key);
|
||||
if (index >= 0) return index;
|
||||
propertyProfiles.push([...properties]);
|
||||
return propertyProfiles.length - 1;
|
||||
};
|
||||
const capabilities = Object.fromEntries(
|
||||
Object.entries(COMPONENT_CONTRACTS).map(([name, c]) => [
|
||||
name,
|
||||
{
|
||||
parts: Object.fromEntries(
|
||||
Object.entries(c.parts).map(([part, p]) => [part, profile(p.properties)]),
|
||||
),
|
||||
axes: c.axes,
|
||||
states: Object.fromEntries(
|
||||
Object.entries(c.parts)
|
||||
.filter(([, p]) => Object.keys(p.states).length)
|
||||
.map(([part, p]) => [part, Object.keys(p.states)]),
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const context = {
|
||||
propertyProfiles,
|
||||
templateVersion: 1,
|
||||
kind: request.kind,
|
||||
brief: request.brief,
|
||||
instruction: request.instruction,
|
||||
locks: request.locks,
|
||||
system: request.system,
|
||||
capabilities,
|
||||
};
|
||||
const rules =
|
||||
'Return JSON only: {"alternatives":[{"name":"...","rationale":"...","patch":[{"op":"set","path":"/tokens/shape.control/value","value":"0"}]}]}. Return exactly three DISTINCT alternatives for directions, one for edit. Capability part values index propertyProfiles; states list accepted state names. Paths are JSON pointers, escape dots as literal characters (no escape needed). Only set/remove existing schema decisions: tokens, modes, densities, recipes, id/name/version/defaultMode. Never change locked raw or effective values, including token aliases. Use only declared component parts/properties/axes. No tools, code execution, remote assets or new schema fields. Do not embed secrets. A set operation has a value. Preserve source identity unless explicitly requested. Each alternative has at most 48 operations. Respect the brief and explain its tradeoffs.';
|
||||
let repair = "";
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
signal.throwIfAborted();
|
||||
batch.provenance.attempts = attempt + 1;
|
||||
const prompt = JSON.stringify(context) + repair;
|
||||
if (prompt.length > 180000)
|
||||
throw Error("WRN-STUDIO-BUDGET: generation context exceeds 180,000 characters.");
|
||||
let stop: () => void = () => {};
|
||||
let received = false;
|
||||
try {
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
const listener = () =>
|
||||
reject(new DOMException("Generation cancelled or timed out.", "AbortError"));
|
||||
signal.addEventListener("abort", listener, { once: true });
|
||||
stop = () => signal.removeEventListener("abort", listener);
|
||||
if (signal.aborted) listener();
|
||||
});
|
||||
const output = await Promise.race([
|
||||
options.provider.generate(prompt, {
|
||||
system: rules,
|
||||
model: options.model,
|
||||
maxTokens: budget,
|
||||
signal,
|
||||
}),
|
||||
aborted,
|
||||
]);
|
||||
received = true;
|
||||
signal.throwIfAborted();
|
||||
if (typeof output.value !== "string" || output.value.length > 100000)
|
||||
throw Error("Provider output exceeds 100,000 characters.");
|
||||
const data = JSON.parse(output.value.replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, ""));
|
||||
const count = request.kind === "directions" ? 3 : 1;
|
||||
if (!Array.isArray(data.alternatives) || data.alternatives.length !== count)
|
||||
throw Error(`Expected ${count} alternatives.`);
|
||||
const alternatives = data.alternatives.map((item: unknown, index: number) => {
|
||||
if (!item || typeof item !== "object") throw Error("Invalid alternative.");
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (
|
||||
typeof entry.name !== "string" ||
|
||||
entry.name.length > 120 ||
|
||||
typeof entry.rationale !== "string" ||
|
||||
entry.rationale.length > 2000
|
||||
)
|
||||
throw Error("Alternative name/rationale is invalid.");
|
||||
return proposal(
|
||||
request,
|
||||
applyDesignPatch(request.system, entry.patch),
|
||||
index,
|
||||
entry.name,
|
||||
entry.rationale,
|
||||
);
|
||||
});
|
||||
if (new Set(alternatives.map((p: DesignProposal) => JSON.stringify(p.system))).size !== count)
|
||||
throw Error("Alternatives must be distinct.");
|
||||
batch.alternatives = alternatives;
|
||||
batch.diagnostics = [];
|
||||
batch.provenance.model = output.model ?? options.model;
|
||||
return batch;
|
||||
} catch (error) {
|
||||
if (signal.aborted)
|
||||
throw new DOMException("Generation cancelled or timed out.", "AbortError");
|
||||
if (!received) {
|
||||
batch.diagnostics = [
|
||||
diagnostic(
|
||||
"WRN-STUDIO-PROVIDER",
|
||||
"Provider request failed. Your draft is unchanged; check server-side provider configuration.",
|
||||
),
|
||||
];
|
||||
return batch;
|
||||
}
|
||||
const message = (error as Error).message;
|
||||
batch.diagnostics = [diagnostic("WRN-STUDIO-PROPOSAL", message)];
|
||||
repair =
|
||||
"\nThe previous attempt was invalid. Repair these diagnostics without changing locks: " +
|
||||
message;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const guidanceHtml = `<section id="guided" class="editor" hidden>
|
||||
<p>Create three directions from a brief, then preview and review each before changing your draft.</p><p id="generation-provider" class="help">Local rules · no network generation</p>
|
||||
<div class="guide-step" data-step="0"><h3>1. Product and people</h3><label>What are you building?<input id="brief-product" maxlength="2000" placeholder="For example, a booking website"></label><label>Who will use it?<input id="brief-audience" maxlength="2000" placeholder="For example, people booking on their phone"></label></div>
|
||||
<div class="guide-step" data-step="1" hidden><h3>2. Visual decisions</h3><label>Visual tone<select id="brief-tone"><option value="restrained">Restrained</option><option value="warm">Warm</option><option value="expressive">Expressive</option></select></label><label>Geometry<select id="brief-geometry"><option value="square">Square</option><option value="rounded">Rounded</option><option value="angular">Angular / tilted</option></select></label><div class="geometry-choices"><button type="button" data-geometry="square"><span class="geometry-square">Button</span>Square</button><button type="button" data-geometry="rounded"><span class="geometry-rounded">Button</span>Rounded</button><button type="button" data-geometry="angular"><span class="geometry-angular">Button</span>Angular</button></div><div class="row"><label>Density<select id="brief-density"><option value="compact">Compact</option><option value="comfortable">Comfortable</option><option value="spacious">Spacious</option></select></label><label>Surfaces<select id="brief-surface"><option value="flat">Flat / outlined</option><option value="soft">Soft shadows</option><option value="hard">Hard shadows</option></select></label></div></div>
|
||||
<div class="guide-step" data-step="2" hidden><h3>3. Brand and constraints</h3><label>Brand color, if fixed<input id="brief-brandColor" placeholder="#002FA7" maxlength="7"></label><label>Reference notes<textarea id="brief-references" rows="3" maxlength="2000" placeholder="Describe references; URLs are recorded, not fetched."></textarea></label><label>Constraints<textarea id="brief-constraints" rows="3" maxlength="2000" placeholder="Describe requirements. Use decision locks below to enforce exact values."></textarea></label><p class="help">Local rules use the selected visual choices and brand color. Product, audience and prose are saved as context. A configured LLM can interpret the prose; exact decisions are enforced by locks.</p></div>
|
||||
<div class="row"><button id="brief-back">Back</button><button id="brief-next">Next</button><button id="generate-directions" class="primary">Generate three directions</button><button id="cancel-generation" disabled>Cancel</button></div><p id="generation-state" role="status" aria-live="polite"></p>
|
||||
<details open><summary>Decision locks</summary><p class="help">Locks protect manual edits, imports, generation and token aliases across modes. Unlock a decision before changing it. Locks override conflicting brief choices. Lock changes are explicit preferences and are independent of undo/redo.</p><div class="row"><button data-lock-group="colors">Lock colors</button><button data-lock-group="geometry">Lock geometry</button><button data-lock-group="typography">Lock typography</button><button data-lock-group="spacing">Lock spacing</button><button data-lock-group="elevation">Lock elevation</button></div><label>Specific decision (JSON pointer)<input id="decision-path" placeholder="/tokens/shape.control or /recipes/Button"></label><button id="lock-decision">Lock decision</button><ul id="decision-locks"></ul></details>
|
||||
<details open><summary>Edit with words</summary><label>Requested change<textarea id="edit-instruction" rows="3" maxlength="2000" placeholder="Make buttons square and use compact spacing"></textarea></label><p class="help">Local examples: rounded controls; use spacious spacing; remove shadows; use serif typography; set primary color to #002FA7. Unrecognized clauses are reported, not guessed.</p><button id="generate-edit">Propose edit</button></details>
|
||||
<h3>Alternatives</h3><p class="help">Every alternative uses the same composed preview content. Choosing one creates a normal recoverable draft revision; applying to an application still uses Review & apply.</p><div id="design-alternatives"></div>
|
||||
<details id="combine-panel" hidden><summary>Combine foundation decisions</summary><p class="help">Mix named color, shape, type, spacing and elevation groups. The combined proposal is validated again before review.</p><div id="combine-choices"></div><button id="combine-directions">Propose combination</button></details><pre id="proposal-diff"></pre><button id="accept-proposal" disabled>Use reviewed proposal</button><button id="export-generation" disabled>Export generation report</button>
|
||||
</section>`;
|
||||
export const guidanceCss = `.geometry-choices{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.geometry-choices button{display:grid;gap:14px;padding:16px 8px;font-size:12px}.geometry-choices span{display:block;padding:8px;border:1px solid #171719;background:#f7f7f8}.geometry-rounded{border-radius:18px}.geometry-angular{rotate:-3deg;box-shadow:3px 3px 0 #171719}.geometry-choices [aria-pressed=true]{border-color:#002fa7;outline:1px solid #002fa7}.alternative{border-top:1px solid #d9d9de;padding:18px 0}.alternative h4{margin:0 0 8px;font-size:16px;font-weight:500}.alternative p{font-size:12px}.alternative iframe{height:300px;margin:12px 0}#decision-locks{max-height:180px;overflow:auto;padding-left:20px;font-size:12px;overflow-wrap:anywhere}#decision-locks li{margin-bottom:8px}#decision-locks button{font-size:11px;margin-left:8px;padding:4px 8px}#generation-state{white-space:pre-wrap;font-size:12px;color:#002fa7}`;
|
||||
@@ -0,0 +1,444 @@
|
||||
import {
|
||||
DEFAULT_DESIGN_BRIEF,
|
||||
type DesignBrief,
|
||||
type GenerationBatch,
|
||||
type DesignProposal,
|
||||
} from "./generation.ts";
|
||||
import {
|
||||
designRevision,
|
||||
checkDecisionLocks,
|
||||
pointerPart,
|
||||
readDecision,
|
||||
writeDecision,
|
||||
validateLocks,
|
||||
} from "./decisions.ts";
|
||||
import { StudioSession, reviewDesign } from "./session.ts";
|
||||
import { validateDesignSystem, type DesignSystem } from "@wrnexus/design-system";
|
||||
|
||||
const groups = ["colors", "geometry", "typography", "spacing", "elevation"] as const;
|
||||
export type FoundationGroup = (typeof groups)[number];
|
||||
export function foundationDecisions(system: DesignSystem, group: FoundationGroup) {
|
||||
const names = Object.keys(system.tokens).filter((name) =>
|
||||
group === "colors"
|
||||
? name.startsWith("color.")
|
||||
: group === "geometry"
|
||||
? name.startsWith("shape.")
|
||||
: group === "typography"
|
||||
? name.startsWith("type.")
|
||||
: group === "spacing"
|
||||
? name.startsWith("space.") || name === "control.height"
|
||||
: name.startsWith("elevation."),
|
||||
);
|
||||
const paths = names.map((name) => "/tokens/" + pointerPart(name));
|
||||
if (group === "geometry")
|
||||
paths.push("/recipes/Button/base/control/rotate", "/recipes/Card/base/panel/rotate");
|
||||
return paths;
|
||||
}
|
||||
export async function combineDesignProposals(
|
||||
system: DesignSystem,
|
||||
batch: GenerationBatch,
|
||||
choices: Partial<Record<FoundationGroup, string>>,
|
||||
locks: string[],
|
||||
) {
|
||||
if (batch.baseRevision !== (await designRevision(system, locks)))
|
||||
throw Error("WRN-STUDIO-CONFLICT: the source or locks changed. Generate again.");
|
||||
const next = structuredClone(system);
|
||||
for (const group of groups) {
|
||||
const id = choices[group];
|
||||
if (!id) continue;
|
||||
const alternative = batch.alternatives.find((p) => p.id === id);
|
||||
if (!alternative) throw Error("Unknown alternative.");
|
||||
for (const path of foundationDecisions(alternative.system, group)) {
|
||||
writeDecision(next, path, readDecision(alternative.system, path));
|
||||
if (path.startsWith("/tokens/")) {
|
||||
const name = path.slice("/tokens/".length);
|
||||
for (const kind of ["modes", "densities"] as const)
|
||||
for (const layer of new Set([
|
||||
...Object.keys(next[kind] ?? {}),
|
||||
...Object.keys(alternative.system[kind] ?? {}),
|
||||
]))
|
||||
writeDecision(
|
||||
next,
|
||||
`/${kind}/${pointerPart(layer)}/${name}`,
|
||||
readDecision(alternative.system, `/${kind}/${pointerPart(layer)}/${name}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const diagnostics = [...validateDesignSystem(next), ...checkDecisionLocks(system, next, locks)];
|
||||
if (diagnostics.length)
|
||||
throw Error(diagnostics.map((d) => `${d.code}: ${d.path} ${d.message}`).join("\n"));
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Browser controller; proposals live separately from committed canonical draft history. */
|
||||
export function setupGuidance(
|
||||
session: StudioSession,
|
||||
hooks: {
|
||||
message: (text: string) => void;
|
||||
changed: () => Promise<void>;
|
||||
preview: (system: DesignSystem) => Promise<string>;
|
||||
},
|
||||
) {
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const escape = (s: unknown) =>
|
||||
String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
||||
);
|
||||
const value = (id: string) => $(id) as HTMLInputElement;
|
||||
let brief: DesignBrief = structuredClone(DEFAULT_DESIGN_BRIEF),
|
||||
batch: GenerationBatch | undefined,
|
||||
selected: DesignProposal | undefined,
|
||||
step = 0;
|
||||
let controller: AbortController | undefined,
|
||||
epoch = 0,
|
||||
rendered: GenerationBatch | undefined,
|
||||
status = "Ready to generate proposals. Your current draft is unchanged.";
|
||||
const key = "wrnexus.design-studio.guidance.v1";
|
||||
function save() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ brief, batch, instruction: value("edit-instruction").value }),
|
||||
);
|
||||
} catch {
|
||||
hooks.message(
|
||||
"Guided workspace autosave is full. Export its report; the canonical draft is preserved separately.",
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
if (stored.length > 2_000_000) throw Error("Guidance history too large.");
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.brief && typeof parsed.brief === "object") brief = { ...brief, ...parsed.brief };
|
||||
if (
|
||||
parsed.batch &&
|
||||
parsed.batch.provenance &&
|
||||
typeof parsed.batch.provenance.provider === "string" &&
|
||||
/^[a-f0-9]{64}$/.test(parsed.batch.baseRevision) &&
|
||||
Array.isArray(parsed.batch.alternatives) &&
|
||||
parsed.batch.alternatives.length <= 3 &&
|
||||
parsed.batch.alternatives.every(
|
||||
(p: DesignProposal) =>
|
||||
p &&
|
||||
typeof p.id === "string" &&
|
||||
typeof p.name === "string" &&
|
||||
typeof p.rationale === "string" &&
|
||||
Array.isArray(p.changedPaths) &&
|
||||
p.changedPaths.every((path) => typeof path === "string") &&
|
||||
!validateDesignSystem(p.system).length,
|
||||
)
|
||||
)
|
||||
batch = parsed.batch;
|
||||
if (typeof parsed.instruction === "string")
|
||||
value("edit-instruction").value = parsed.instruction.slice(0, 2000);
|
||||
}
|
||||
} catch {
|
||||
status = "Guided workspace could not be restored. The canonical draft is preserved.";
|
||||
}
|
||||
for (const name of Object.keys(DEFAULT_DESIGN_BRIEF).concat([
|
||||
"brandColor",
|
||||
"references",
|
||||
"constraints",
|
||||
]) as Array<keyof DesignBrief>) {
|
||||
const input = value("brief-" + name);
|
||||
if (input) input.value = brief[name] ?? "";
|
||||
}
|
||||
function readBrief() {
|
||||
const next = {} as Record<string, string>;
|
||||
for (const name of [
|
||||
"product",
|
||||
"audience",
|
||||
"tone",
|
||||
"geometry",
|
||||
"density",
|
||||
"surface",
|
||||
"brandColor",
|
||||
"references",
|
||||
"constraints",
|
||||
])
|
||||
next[name] = value("brief-" + name).value;
|
||||
brief = next as unknown as DesignBrief;
|
||||
save();
|
||||
return brief;
|
||||
}
|
||||
function event(id: string, action: () => void | Promise<void>) {
|
||||
$(id).addEventListener("click", () =>
|
||||
Promise.resolve()
|
||||
.then(action)
|
||||
.catch((e) => {
|
||||
status = e.message;
|
||||
render();
|
||||
hooks.message(e.message);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async function fresh() {
|
||||
if (
|
||||
!batch ||
|
||||
session.diagnostics.length ||
|
||||
JSON.stringify(readBrief()) !== JSON.stringify(batch.brief) ||
|
||||
batch.baseRevision !== (await designRevision(session.valid, session.locks))
|
||||
)
|
||||
throw Error(
|
||||
"WRN-STUDIO-CONFLICT: source, brief or locks changed. Resolve draft errors and generate again.",
|
||||
);
|
||||
}
|
||||
function review(proposal: DesignProposal) {
|
||||
selected = proposal;
|
||||
$("proposal-diff").textContent =
|
||||
reviewDesign(session.valid, proposal.system)
|
||||
.map(
|
||||
(d) =>
|
||||
`${d.path}\nBefore: ${JSON.stringify(d.before) ?? "(absent)"}\nAfter: ${JSON.stringify(d.after) ?? "(removed)"}`,
|
||||
)
|
||||
.join("\n\n") || "No changes.";
|
||||
$("accept-proposal").removeAttribute("disabled");
|
||||
}
|
||||
function render() {
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-lock-group]").forEach((button) => {
|
||||
const group = button.dataset.lockGroup as FoundationGroup;
|
||||
const paths = foundationDecisions(session.valid, group);
|
||||
const locked = paths.length > 0 && paths.every((path) => session.locks.includes(path));
|
||||
button.textContent = (locked ? "Unlock " : "Lock ") + group;
|
||||
button.setAttribute("aria-pressed", String(locked));
|
||||
});
|
||||
$("generation-state").textContent = status;
|
||||
$("brief-back").toggleAttribute("disabled", step === 0);
|
||||
$("brief-next").toggleAttribute("disabled", step === 2);
|
||||
document.querySelectorAll<HTMLElement>(".guide-step").forEach((el) => {
|
||||
el.hidden = Number(el.dataset.step) !== step;
|
||||
});
|
||||
for (const id of ["generate-directions", "generate-edit"])
|
||||
$(id).toggleAttribute("disabled", !!controller || !!session.diagnostics.length);
|
||||
$("cancel-generation").toggleAttribute("disabled", !controller);
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>("[data-geometry]")
|
||||
.forEach((b) =>
|
||||
b.setAttribute(
|
||||
"aria-pressed",
|
||||
String(b.dataset.geometry === value("brief-geometry").value),
|
||||
),
|
||||
);
|
||||
$("decision-locks").innerHTML =
|
||||
session.locks
|
||||
.map(
|
||||
(path) => `<li>${escape(path)}<button data-unlock="${escape(path)}">Unlock</button></li>`,
|
||||
)
|
||||
.join("") || "<li>No decisions locked.</li>";
|
||||
$("export-generation").toggleAttribute("disabled", !batch);
|
||||
if (!batch || rendered === batch) return;
|
||||
rendered = batch;
|
||||
$("design-alternatives").innerHTML = batch.alternatives
|
||||
.map(
|
||||
(p, i) =>
|
||||
`<article class="alternative"><h4>${escape(p.name)}</h4><p>${escape(p.rationale)}</p><p>${p.changedPaths.length} changed paths</p><div class="frame-scroll"><iframe title="Alternative ${i + 1} preview" id="alternative-${i}" sandbox="allow-scripts"></iframe></div><button data-review-proposal="${escape(p.id)}">Review ${escape(p.name)}</button></article>`,
|
||||
)
|
||||
.join("");
|
||||
$("combine-panel").hidden = batch.alternatives.length < 2;
|
||||
$("combine-choices").innerHTML = groups
|
||||
.map(
|
||||
(group) =>
|
||||
`<label>${group}<select data-combine="${group}"><option value="">Current draft</option>${batch!.alternatives.map((p) => `<option value="${escape(p.id)}">${escape(p.name)}</option>`).join("")}</select></label>`,
|
||||
)
|
||||
.join("");
|
||||
const displayed = batch;
|
||||
batch.alternatives.forEach((p, i) => {
|
||||
hooks
|
||||
.preview(p.system)
|
||||
.then((url) => {
|
||||
if (batch === displayed) $<HTMLIFrameElement>("alternative-" + i).src = url;
|
||||
})
|
||||
.catch(() => {
|
||||
status = "An alternative preview failed. Refresh generation before accepting.";
|
||||
$("generation-state").textContent = status;
|
||||
});
|
||||
});
|
||||
}
|
||||
async function generate(kind: "directions" | "edit") {
|
||||
if (session.diagnostics.length) throw Error("Resolve the source draft before generating.");
|
||||
readBrief();
|
||||
if (kind === "directions" && (!brief.product.trim() || !brief.audience.trim())) {
|
||||
step = 0;
|
||||
throw Error("Describe your product and audience first.");
|
||||
}
|
||||
const current = ++epoch;
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const system = structuredClone(session.valid),
|
||||
locks = [...session.locks];
|
||||
const requestedBrief = structuredClone(brief);
|
||||
const baseRevision = await designRevision(system, locks);
|
||||
selected = undefined;
|
||||
$("accept-proposal").setAttribute("disabled", "");
|
||||
status = "Generating proposals. Your draft is unchanged.";
|
||||
render();
|
||||
try {
|
||||
const response = await fetch(new URL("./api/generate", document.baseURI), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
system,
|
||||
locks,
|
||||
brief: requestedBrief,
|
||||
baseRevision,
|
||||
kind,
|
||||
instruction: kind === "edit" ? value("edit-instruction").value : undefined,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw Error(result.error ?? "Generation failed.");
|
||||
if (current !== epoch || signal.aborted) return;
|
||||
if (result.diagnostics.length) {
|
||||
status = result.diagnostics.map((d: { message: string }) => d.message).join("\n");
|
||||
return;
|
||||
}
|
||||
batch = result;
|
||||
status = `${batch!.alternatives.length} proposals ready. Review one before using it. ${batch!.provenance.provider}; ${batch!.provenance.attempts} attempt(s).`;
|
||||
if (
|
||||
batch!.baseRevision !== (await designRevision(session.valid, session.locks)) ||
|
||||
JSON.stringify(brief) !== JSON.stringify(requestedBrief)
|
||||
)
|
||||
status =
|
||||
"Proposals arrived after the draft or locks changed. They are preserved for inspection; regenerate before accepting.";
|
||||
save();
|
||||
} catch (error) {
|
||||
if (current === epoch)
|
||||
status = signal.aborted
|
||||
? "Generation cancelled. Your draft and prior alternatives are preserved."
|
||||
: `Generation failed. Your draft is unchanged. ${(error as Error).message}`;
|
||||
} finally {
|
||||
if (current === epoch) {
|
||||
controller = undefined;
|
||||
render();
|
||||
}
|
||||
}
|
||||
}
|
||||
event("brief-back", () => {
|
||||
step = Math.max(0, step - 1);
|
||||
readBrief();
|
||||
render();
|
||||
});
|
||||
event("brief-next", () => {
|
||||
step = Math.min(2, step + 1);
|
||||
readBrief();
|
||||
render();
|
||||
});
|
||||
event("generate-directions", () => generate("directions"));
|
||||
event("generate-edit", () => generate("edit"));
|
||||
event("cancel-generation", () => {
|
||||
epoch++;
|
||||
controller?.abort();
|
||||
controller = undefined;
|
||||
status = "Generation cancelled. Your draft and prior alternatives are preserved.";
|
||||
render();
|
||||
});
|
||||
$("guided").addEventListener("input", () => {
|
||||
readBrief();
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-geometry]").forEach((b) =>
|
||||
b.addEventListener("click", () => {
|
||||
value("brief-geometry").value = b.dataset.geometry!;
|
||||
readBrief();
|
||||
render();
|
||||
}),
|
||||
);
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-lock-group]").forEach((b) =>
|
||||
b.addEventListener("click", () => {
|
||||
try {
|
||||
const paths = foundationDecisions(session.valid, b.dataset.lockGroup as FoundationGroup);
|
||||
const all = paths.every((p) => session.locks.includes(p));
|
||||
session.setLocks(
|
||||
all
|
||||
? session.locks.filter((p) => !paths.includes(p))
|
||||
: [...new Set([...session.locks, ...paths])],
|
||||
);
|
||||
hooks.changed().catch((e) => hooks.message(e.message));
|
||||
} catch (e) {
|
||||
hooks.message((e as Error).message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
event("lock-decision", async () => {
|
||||
const path = value("decision-path").value;
|
||||
validateLocks([...new Set([...session.locks, path])]);
|
||||
session.setLocks([...new Set([...session.locks, path])]);
|
||||
await hooks.changed();
|
||||
});
|
||||
$("decision-locks").addEventListener("click", (e) => {
|
||||
const path = (e.target as HTMLElement).dataset.unlock;
|
||||
if (path) {
|
||||
session.setLocks(session.locks.filter((p) => p !== path));
|
||||
hooks.changed().catch((e) => hooks.message(e.message));
|
||||
}
|
||||
});
|
||||
$("design-alternatives").addEventListener("click", (e) => {
|
||||
const id = (e.target as HTMLElement).dataset.reviewProposal;
|
||||
if (!id) return;
|
||||
fresh()
|
||||
.then(() => {
|
||||
const p = batch!.alternatives.find((p) => p.id === id);
|
||||
if (p) review(p);
|
||||
})
|
||||
.catch((e) => hooks.message(e.message));
|
||||
});
|
||||
event("combine-directions", async () => {
|
||||
await fresh();
|
||||
const choices = Object.fromEntries(
|
||||
[...document.querySelectorAll<HTMLSelectElement>("[data-combine]")].map((s) => [
|
||||
s.dataset.combine!,
|
||||
s.value,
|
||||
]),
|
||||
);
|
||||
const system = await combineDesignProposals(session.valid, batch!, choices, session.locks);
|
||||
review({
|
||||
id: "combined",
|
||||
name: "Combined foundations",
|
||||
rationale: "Selected foundation groups, revalidated together.",
|
||||
system,
|
||||
changedPaths: reviewDesign(session.valid, system).map((d) => d.path),
|
||||
});
|
||||
});
|
||||
event("accept-proposal", async () => {
|
||||
await fresh();
|
||||
if (!selected) throw Error("Review a proposal first.");
|
||||
const failures = [
|
||||
...validateDesignSystem(selected.system),
|
||||
...checkDecisionLocks(session.valid, selected.system, session.locks),
|
||||
];
|
||||
if (failures.length) throw Error(failures.map((d) => d.message).join("\n"));
|
||||
session.replace(selected.system, "Accepted " + selected.name);
|
||||
status = "Proposal saved as a draft revision. Use Review & apply for the application.";
|
||||
selected = undefined;
|
||||
$("accept-proposal").setAttribute("disabled", "");
|
||||
await hooks.changed();
|
||||
});
|
||||
event("export-generation", () => {
|
||||
if (!batch) return;
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify({ brief, locks: session.locks, batch }, null, 2)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "design-generation-report.json";
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
});
|
||||
fetch(new URL("./api/generation", document.baseURI))
|
||||
.then((r) => r.json())
|
||||
.then((config) => {
|
||||
$("generation-provider").textContent =
|
||||
config.provider === "local-rules"
|
||||
? "Local rules · no network generation"
|
||||
: `${config.provider} / ${config.model ?? "configured model"} · Sends the current design, brief and contract capabilities to this configured provider only when you generate. At most two attempts, 4096 output tokens per attempt, 20-second deadline.`;
|
||||
})
|
||||
.catch(() => {});
|
||||
render();
|
||||
return { render };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { StudioSession, importDesign, exportDesign, diffDesign, reviewDesign } from "./session.ts";
|
||||
export type { ImportReport, StudioRevision, StudioStorage } from "./session.ts";
|
||||
export { checkDecisionLocks, designRevision, pointerPart } from "./decisions.ts";
|
||||
export type { DecisionLock } from "./decisions.ts";
|
||||
export { generateDesigns, applyDesignPatch, DEFAULT_DESIGN_BRIEF } from "./generation.ts";
|
||||
export type {
|
||||
DesignBrief,
|
||||
DesignProposal,
|
||||
GenerationRequest,
|
||||
GenerationBatch,
|
||||
GenerationProvider,
|
||||
} from "./generation.ts";
|
||||
export { combineDesignProposals, foundationDecisions } from "./guidance.ts";
|
||||
export type { FoundationGroup } from "./guidance.ts";
|
||||
@@ -0,0 +1,175 @@
|
||||
import { validateAgentPage } from "./agent-pages.ts";
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { compileWrnFile } from "@wrnexus/compiler";
|
||||
import { runWithRequestContext } from "@wrnexus/core";
|
||||
import { REACTIVE_RUNTIME } from "@wrnexus/csr";
|
||||
import { renderDocument } from "@wrnexus/ssr";
|
||||
import { uiCss, uiComponentsDir } from "@wrnexus/ui/registry";
|
||||
import { resolveThemeConfig, renderThemeCss } from "@wrnexus/styles/theme";
|
||||
import {
|
||||
COMPONENT_CONTRACTS,
|
||||
type DesignSystem,
|
||||
type ResolveOptions,
|
||||
} from "@wrnexus/design-system";
|
||||
|
||||
const modules = new Map<string, Promise<{ render: (props: Record<string, unknown>) => string }>>();
|
||||
const escape = (text: string) =>
|
||||
text.replace(
|
||||
/[&<>"']/g,
|
||||
(char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!,
|
||||
);
|
||||
async function component(name: string, props: Record<string, unknown> = {}) {
|
||||
if (!Object.hasOwn(COMPONENT_CONTRACTS, name)) throw Error("Unknown component.");
|
||||
if (!modules.has(name)) {
|
||||
const sourcePath = resolve(
|
||||
uiComponentsDir(),
|
||||
name === "ComboBox" ? "Combobox.wrn" : `${name}.wrn`,
|
||||
);
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const directory = join(tmpdir(), "wrnexus-design-studio");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const target = join(
|
||||
directory,
|
||||
`${name}-${createHash("sha256")
|
||||
.update(import.meta.url + source)
|
||||
.digest("hex")
|
||||
.slice(0, 16)}.ts`,
|
||||
);
|
||||
const core = fileURLToPath(import.meta.resolve("@wrnexus/core"));
|
||||
writeFileSync(
|
||||
target,
|
||||
compileWrnFile(source, target).replace(
|
||||
/from (["'])@wrnexus\/core\1/g,
|
||||
`from ${JSON.stringify(pathToFileURL(core).href)}`,
|
||||
),
|
||||
);
|
||||
modules.set(name, import(pathToFileURL(target).href));
|
||||
}
|
||||
return String((await modules.get(name)!).render(props));
|
||||
}
|
||||
|
||||
export async function previewDocument(
|
||||
system: DesignSystem,
|
||||
options: ResolveOptions & { content?: string; page?: number; direction?: string } = {},
|
||||
) {
|
||||
const theme = resolveThemeConfig({
|
||||
designSystem: { system, mode: options.mode, density: options.density },
|
||||
default: options.mode ?? system.defaultMode,
|
||||
});
|
||||
const mode = options.mode ?? system.defaultMode ?? "light";
|
||||
return runWithRequestContext({} as never, async () => {
|
||||
let body: string;
|
||||
if (options.content === "catalog") {
|
||||
const page = Math.max(0, Math.min(6, options.page ?? 0));
|
||||
const names = Object.keys(COMPONENT_CONTRACTS).slice(page * 16, (page + 1) * 16);
|
||||
const cards = [];
|
||||
for (const name of names)
|
||||
cards.push(
|
||||
`<section class="sample"><h2>${escape(name)}</h2>${await component(name)}</section>`,
|
||||
);
|
||||
body = `<h1>Component gallery</h1><p>Page ${page + 1} of 7 · Default states</p><div class="gallery">${cards.join("")}</div>`;
|
||||
} else {
|
||||
const nav = await component("Nav", {
|
||||
items: [
|
||||
{ label: "Profile", href: "#profile" },
|
||||
{ label: "Preferences", href: "#preferences" },
|
||||
],
|
||||
});
|
||||
const heading = await component("PageHeader", {
|
||||
title: "Design system preview",
|
||||
description: "The same components and content, styled by your draft.",
|
||||
});
|
||||
const card = await component("Card", {
|
||||
title: "Profile",
|
||||
description: "Sample form. Changes stay inside this preview.",
|
||||
});
|
||||
const input = await component("Input", {
|
||||
id: "display-name",
|
||||
label: "Display name",
|
||||
placeholder: "Your name",
|
||||
});
|
||||
const select = await component("Select", {
|
||||
id: "language",
|
||||
label: "Language",
|
||||
value: "en",
|
||||
options: [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Hindi", value: "hi" },
|
||||
],
|
||||
});
|
||||
const textarea = await component("Textarea", {
|
||||
id: "bio",
|
||||
label: "About you",
|
||||
placeholder: "Add a short introduction",
|
||||
});
|
||||
const toggle = await component("Switch", { id: "updates", label: "Email updates" });
|
||||
const checkbox = await component("Checkbox", {
|
||||
id: "remember",
|
||||
label: "Remember preferences",
|
||||
});
|
||||
const button = await component("Button", { label: "Save changes" });
|
||||
const disabled = await component("Button", { label: "Unavailable", disabled: true });
|
||||
const loading = await component("Button", { label: "Loading…", loading: true });
|
||||
const alert = await component("Alert", {
|
||||
title: "Preview only",
|
||||
description: "This form does not send data.",
|
||||
color: "info",
|
||||
dismissible: true,
|
||||
});
|
||||
const modal = await component("Modal", {
|
||||
title: "Review changes",
|
||||
description: "Check your preferences before continuing.",
|
||||
triggerLabel: "Open dialog",
|
||||
confirmLabel: "Continue",
|
||||
});
|
||||
const radio = await component("Radio", {
|
||||
label: "Comfortable spacing",
|
||||
id: "density-comfortable",
|
||||
checked: true,
|
||||
});
|
||||
const number = await component("InputNumber", {
|
||||
label: "Quantity",
|
||||
id: "quantity",
|
||||
value: 2,
|
||||
});
|
||||
body = `${nav}${heading}<div class="layout"><section id="profile">${card}<form>${input}${select}${textarea}<div id="preferences">${toggle}${checkbox}</div><div class="actions">${button}${disabled}${loading}</div></form></section><aside>${alert}<h2>More components</h2>${radio}${number}<div class="actions">${modal}</div></aside></div>`;
|
||||
}
|
||||
const runtime = REACTIVE_RUNTIME;
|
||||
return renderDocument({
|
||||
meta: { title: system.name + " preview" },
|
||||
body,
|
||||
htmlAttrs: ` dir="${options.direction === "rtl" ? "rtl" : "ltr"}" data-theme="${escape(mode)}"`,
|
||||
extraHead: `<style>${uiCss()}${renderThemeCss(theme)}*{box-sizing:border-box}body{padding:24px;margin:0;font-family:var(--wrn-font-sans);color:var(--wrn-color-text);background:var(--wrn-color-bg);line-height:1.5}h1{font-size:28px}h2{font-size:18px}.layout{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:24px}.layout>*,form{min-width:0;display:grid;gap:16px;align-content:start}.actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}.sample{min-width:0;overflow:auto;border:1px solid var(--wrn-color-border);padding:16px}@media(max-width:650px){.layout{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}</style>`,
|
||||
componentRecipeCss: theme.recipeCss,
|
||||
extraBody: `<script>${runtime.replace(/<\/script/gi, "<\\/script")}\ndocument.addEventListener('submit',e=>e.preventDefault());</script>`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Safe page-spec preview. Only shipped components execute; submitted page source never executes. */
|
||||
export async function previewAgentPage(
|
||||
system: DesignSystem,
|
||||
page: import("./agent-pages.ts").AgentPage,
|
||||
options: ResolveOptions = {},
|
||||
) {
|
||||
validateAgentPage(page);
|
||||
const theme = resolveThemeConfig({
|
||||
designSystem: { system, ...options },
|
||||
default: options.mode ?? system.defaultMode,
|
||||
});
|
||||
return runWithRequestContext({} as never, async () => {
|
||||
const body = [];
|
||||
for (const entry of page.components) body.push(await component(entry.component, entry.props));
|
||||
return renderDocument({
|
||||
meta: { title: page.title },
|
||||
body: `<main class="wrn-agent-page"><h1>${escape(page.title)}</h1>${body.join("")}</main>`,
|
||||
htmlAttrs: ` data-theme="${escape(options.mode ?? system.defaultMode ?? "light")}"`,
|
||||
extraHead: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; form-action 'none'; base-uri 'none'"><style>${uiCss()}${renderThemeCss(theme)}*{box-sizing:border-box}body{margin:0}.wrn-agent-page{display:grid;min-width:0;padding:var(--wrn-space-panel);gap:var(--wrn-space-panel);color:var(--wrn-color-text);background:var(--wrn-color-bg);font-family:var(--wrn-font-sans)}</style>`,
|
||||
componentRecipeCss: theme.recipeCss,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { StudioApplication } from "./application.ts";
|
||||
import { studioHtml, studioCss } from "./shell.ts";
|
||||
import { previewDocument } from "./preview.ts";
|
||||
import { validateDesignSystem, type DesignSystem } from "@wrnexus/design-system";
|
||||
import { generateDesigns, type GenerationProvider } from "./generation.ts";
|
||||
|
||||
/** Explicit local server subpath. The root entry remains browser safe. */
|
||||
export async function createStudioHandler(
|
||||
options: {
|
||||
applicationRoot?: string;
|
||||
generationProvider?: GenerationProvider;
|
||||
generationModel?: string;
|
||||
/** Public demos can generate previews and exports, never write application files. */
|
||||
hosted?: { basePath: string };
|
||||
} = {},
|
||||
) {
|
||||
const basePath = options.hosted?.basePath ?? "";
|
||||
if (basePath && !/^\/[a-zA-Z0-9/_-]+$/.test(basePath))
|
||||
throw Error("Hosted basePath must be an absolute path without a trailing slash.");
|
||||
if (basePath.endsWith("/")) throw Error("Hosted basePath must not end with a slash.");
|
||||
if (options.hosted && (options.applicationRoot || options.generationProvider))
|
||||
throw Error("Hosted Studio supports export-only local generation.");
|
||||
const app = options.applicationRoot ? new StudioApplication(options.applicationRoot) : undefined;
|
||||
const asset = fileURLToPath(new URL("../assets/studio-client.js", import.meta.url));
|
||||
let client: string;
|
||||
if (existsSync(asset)) client = readFileSync(asset, "utf8");
|
||||
else {
|
||||
const build = await Bun.build({
|
||||
entrypoints: [fileURLToPath(new URL("./client.ts", import.meta.url))],
|
||||
target: "browser",
|
||||
minify: true,
|
||||
});
|
||||
if (!build.success) throw Error(build.logs.join("\n"));
|
||||
client = await build.outputs[0]!.text();
|
||||
}
|
||||
const previews = new Map<string, string>();
|
||||
let generating = 0;
|
||||
const headers = {
|
||||
"cache-control": "no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
"content-security-policy":
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; frame-src 'self' about:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'none'",
|
||||
};
|
||||
const response = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
});
|
||||
const previewResponse = (html: string) =>
|
||||
new Response(html, {
|
||||
headers: {
|
||||
...headers,
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"content-security-policy":
|
||||
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'",
|
||||
},
|
||||
});
|
||||
return async (request: Request): Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
// Hosted handlers are served behind the deployment's loopback reverse proxy.
|
||||
const protocol = request.headers.get("x-forwarded-proto");
|
||||
if (options.hosted && (protocol === "https" || protocol === "http"))
|
||||
url.protocol = protocol + ":";
|
||||
if (
|
||||
(!options.hosted && !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) ||
|
||||
(request.headers.get("origin") && request.headers.get("origin") !== url.origin)
|
||||
)
|
||||
return response({ error: "Local same-origin requests only." }, 403);
|
||||
if (basePath) {
|
||||
if (!url.pathname.startsWith(basePath + "/")) return response({ error: "Not found" }, 404);
|
||||
url.pathname = url.pathname.slice(basePath.length);
|
||||
}
|
||||
try {
|
||||
if (request.method === "GET") {
|
||||
if (url.pathname === "/api/generation")
|
||||
return response({
|
||||
provider: options.generationProvider?.name ?? "local-rules",
|
||||
model: options.generationModel,
|
||||
templateVersion: 1,
|
||||
maxAttempts: 2,
|
||||
maxOutputTokens: 4096,
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
if (url.pathname === "/application-preview") {
|
||||
const system = app?.system();
|
||||
return system
|
||||
? previewResponse(await previewDocument(system, app!.selection()))
|
||||
: response({ error: "No applied design. Apply a design first." }, 404);
|
||||
}
|
||||
if (url.pathname.startsWith("/preview/")) {
|
||||
const html = previews.get(url.pathname);
|
||||
return html
|
||||
? previewResponse(html)
|
||||
: response({ error: "Preview expired. Refresh the preview." }, 404);
|
||||
}
|
||||
if (url.pathname === "/")
|
||||
return new Response(
|
||||
basePath
|
||||
? studioHtml
|
||||
.replaceAll('href="/', 'href="./')
|
||||
.replaceAll('src="/', 'src="./')
|
||||
.replace("<head>", `<head><base href="${basePath}/">`)
|
||||
: studioHtml,
|
||||
{
|
||||
headers: { ...headers, "content-type": "text/html; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
if (url.pathname === "/studio.css")
|
||||
return new Response(studioCss, { headers: { ...headers, "content-type": "text/css" } });
|
||||
if (url.pathname === "/studio.js")
|
||||
return new Response(client, {
|
||||
headers: { ...headers, "content-type": "text/javascript" },
|
||||
});
|
||||
if (url.pathname === "/api/application")
|
||||
return response({
|
||||
enabled: !!app,
|
||||
directory: app?.directory,
|
||||
...(app?.state() ?? { revision: "none" }),
|
||||
system: app?.system(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
request.method !== "POST" ||
|
||||
!["/api/preview", "/api/apply", "/api/rollback", "/api/generate"].includes(url.pathname)
|
||||
)
|
||||
return response({ error: "Not found" }, 404);
|
||||
if (!(request.headers.get("content-type") ?? "").startsWith("application/json"))
|
||||
return response({ error: "JSON required." }, 415);
|
||||
if (Number(request.headers.get("content-length")) > 2_000_000)
|
||||
return response({ error: "Request too large." }, 413);
|
||||
let text = "";
|
||||
const reader = request.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 2_000_000) {
|
||||
await reader.cancel();
|
||||
return response({ error: "Request too large." }, 413);
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
}
|
||||
const input = JSON.parse(text);
|
||||
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||
return response({ error: "JSON object required." }, 400);
|
||||
if (url.pathname === "/api/generate") {
|
||||
if (generating >= 2)
|
||||
return response(
|
||||
{ error: "Generation is busy. Try again after the current requests finish." },
|
||||
429,
|
||||
);
|
||||
generating++;
|
||||
try {
|
||||
return response(
|
||||
await generateDesigns(input, {
|
||||
provider: options.generationProvider,
|
||||
model: options.generationModel,
|
||||
signal: request.signal,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
generating--;
|
||||
}
|
||||
}
|
||||
if (url.pathname === "/api/rollback") {
|
||||
if (!app) return response({ error: "Local application is not configured." }, 409);
|
||||
return response(app.rollback(input.base));
|
||||
}
|
||||
const diagnostics = validateDesignSystem(input.system);
|
||||
if (diagnostics.length)
|
||||
return response({ error: "Invalid design system.", diagnostics }, 422);
|
||||
const selection = {
|
||||
mode: typeof input.mode === "string" ? input.mode : undefined,
|
||||
density: typeof input.density === "string" ? input.density : undefined,
|
||||
};
|
||||
if (url.pathname === "/api/preview") {
|
||||
const html = await previewDocument(input.system as DesignSystem, {
|
||||
...selection,
|
||||
content: input.content === "catalog" ? "catalog" : "composed",
|
||||
page: Number.isInteger(input.page) ? input.page : 0,
|
||||
direction: input.direction,
|
||||
});
|
||||
const path = "/preview/" + randomUUID();
|
||||
previews.set(path, html);
|
||||
while (previews.size > 24) previews.delete(previews.keys().next().value!);
|
||||
return response({ url: basePath + path });
|
||||
}
|
||||
if (!app)
|
||||
return response(
|
||||
{ error: "Local application is not configured. Export or restart with --app-root." },
|
||||
409,
|
||||
);
|
||||
return response(app.apply(input.system as DesignSystem, input.base, selection));
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
return response({ error: message }, message.includes("WRN-STUDIO-CONFLICT") ? 409 : 400);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import {
|
||||
CORE_DESIGN_SYSTEMS,
|
||||
COMPONENT_CONTRACTS,
|
||||
compileDesignSystem,
|
||||
resolveDesignSystem,
|
||||
validateDesignSystem,
|
||||
type DesignSystem,
|
||||
type DesignDiagnostic,
|
||||
type ResolveOptions,
|
||||
type DesignToken,
|
||||
} from "@wrnexus/design-system";
|
||||
import { checkDecisionLocks, validateLocks, type DecisionLock } from "./decisions.ts";
|
||||
|
||||
export interface StudioRevision {
|
||||
source: string;
|
||||
label: string;
|
||||
}
|
||||
export interface StudioStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
export interface ImportReport {
|
||||
system?: DesignSystem;
|
||||
preserved: string[];
|
||||
unsupported: string[];
|
||||
ambiguous: string[];
|
||||
diagnostics: DesignDiagnostic[];
|
||||
}
|
||||
const limit = 2_000_000;
|
||||
const key = "wrnexus.design-studio.v1";
|
||||
const json = (value: unknown) => JSON.stringify(value, null, 2);
|
||||
const clone = <T>(value: T): T => structuredClone(value);
|
||||
const diagnostic = (code: string, message: string): DesignDiagnostic => ({
|
||||
code,
|
||||
path: "$",
|
||||
message,
|
||||
});
|
||||
function parse(source: string): unknown {
|
||||
if (source.length > limit) throw Error("Document exceeds the 2 MB import limit.");
|
||||
return JSON.parse(source);
|
||||
}
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value);
|
||||
const safeName = (name: string) =>
|
||||
/^[A-Za-z][\w.-]*$/.test(name) &&
|
||||
!name.split(/[.-]/).some((part) => ["__proto__", "constructor", "prototype"].includes(part));
|
||||
|
||||
/** Deliberate subset: canonical JSON, literal legacy themes and grouped exchange tokens. No code/assets execute. */
|
||||
export function importDesign(
|
||||
source: string,
|
||||
format: "canonical" | "legacy" | "tokens" = "canonical",
|
||||
): ImportReport {
|
||||
const report: ImportReport = { preserved: [], unsupported: [], ambiguous: [], diagnostics: [] };
|
||||
try {
|
||||
const input = parse(source);
|
||||
if (format === "canonical") {
|
||||
report.diagnostics = validateDesignSystem(input);
|
||||
if (!report.diagnostics.length) {
|
||||
report.system = clone(input as DesignSystem);
|
||||
report.preserved.push("Complete canonical document");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
if (!record(input)) throw Error("Import must be a JSON object.");
|
||||
const system: DesignSystem = {
|
||||
schemaVersion: 1,
|
||||
id: "imported",
|
||||
name: "Imported design",
|
||||
version: "1.0.0",
|
||||
tokens: {},
|
||||
};
|
||||
if (format === "legacy") {
|
||||
const baseline = CORE_DESIGN_SYSTEMS.square;
|
||||
const bindings = new Map(
|
||||
Object.entries(baseline.tokens)
|
||||
.filter(([, t]) => t.legacy)
|
||||
.map(([name, t]) => [t.legacy!, { name, type: t.type }]),
|
||||
);
|
||||
if (!record(input.themes))
|
||||
throw Error("Legacy JSON requires a themes object with literal token values.");
|
||||
system.modes = {};
|
||||
for (const [mode, values] of Object.entries(input.themes)) {
|
||||
if (!safeName(mode)) throw Error("Invalid legacy mode name.");
|
||||
if (!record(values)) {
|
||||
report.unsupported.push(`themes.${mode}`);
|
||||
continue;
|
||||
}
|
||||
const tokens: Record<string, DesignToken> = {};
|
||||
for (const [suffix, value] of Object.entries(values)) {
|
||||
const binding = bindings.get(suffix);
|
||||
if (!binding || (typeof value !== "string" && typeof value !== "number")) {
|
||||
report.unsupported.push(`themes.${mode}.${suffix}`);
|
||||
continue;
|
||||
}
|
||||
tokens[binding.name] = { type: binding.type, value, legacy: suffix };
|
||||
report.preserved.push(`themes.${mode}.${suffix}`);
|
||||
}
|
||||
system.modes[mode] = tokens;
|
||||
}
|
||||
system.defaultMode =
|
||||
typeof input.default === "string" ? input.default : Object.keys(system.modes)[0];
|
||||
for (const field of Object.keys(input))
|
||||
if (!["themes", "default"].includes(field)) report.unsupported.push(field);
|
||||
} else {
|
||||
const types: Record<string, DesignToken["type"]> = {
|
||||
color: "color",
|
||||
dimension: "dimension",
|
||||
number: "number",
|
||||
fontFamily: "fontFamily",
|
||||
fontWeight: "fontWeight",
|
||||
duration: "duration",
|
||||
};
|
||||
const walk = (
|
||||
group: Record<string, unknown>,
|
||||
prefix: string,
|
||||
inheritedType?: string,
|
||||
depth = 0,
|
||||
) => {
|
||||
if (depth > 32) throw Error("Exchange token nesting exceeds 32 levels.");
|
||||
for (const [name, value] of Object.entries(group)) {
|
||||
if (name.startsWith("$")) {
|
||||
if (!["$type", "$description"].includes(name))
|
||||
report.unsupported.push(prefix ? `${prefix}.${name}` : name);
|
||||
continue;
|
||||
}
|
||||
const path = prefix ? `${prefix}.${name}` : name;
|
||||
if (!safeName(path)) throw Error("Invalid exchange token path.");
|
||||
if (!record(value)) {
|
||||
report.unsupported.push(path);
|
||||
continue;
|
||||
}
|
||||
const type = typeof value.$type === "string" ? value.$type : inheritedType;
|
||||
if ("$value" in value) {
|
||||
const raw = value.$value;
|
||||
if (!type || !types[type] || (typeof raw !== "string" && typeof raw !== "number")) {
|
||||
report.unsupported.push(path);
|
||||
continue;
|
||||
}
|
||||
const alias = typeof raw === "string" && /^\{[\w.-]+\}$/.test(raw);
|
||||
system.tokens[path] = {
|
||||
type: types[type]!,
|
||||
value: alias ? { ref: (raw as string).slice(1, -1) } : (raw as string | number),
|
||||
};
|
||||
report.preserved.push(path);
|
||||
report.ambiguous.push(
|
||||
`${path}: no WrNexus semantic binding; map a legacy suffix in Foundations before applying.`,
|
||||
);
|
||||
for (const field of Object.keys(value))
|
||||
if (!["$type", "$value", "$description"].includes(field))
|
||||
report.unsupported.push(`${path}.${field}`);
|
||||
if (typeof value.$description === "string")
|
||||
system.tokens[path]!.description = value.$description;
|
||||
} else walk(value, path, type, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(input, "", typeof input.$type === "string" ? input.$type : undefined);
|
||||
}
|
||||
if (!report.preserved.length) throw Error("No supported tokens found.");
|
||||
report.diagnostics = validateDesignSystem(system);
|
||||
if (!report.diagnostics.length) report.system = system;
|
||||
} catch (error) {
|
||||
report.diagnostics = [diagnostic("WRN-STUDIO-IMPORT", (error as Error).message)];
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
export function reviewDesign(
|
||||
before: unknown,
|
||||
after: unknown,
|
||||
path = "$",
|
||||
): Array<{ path: string; before: unknown; after: unknown }> {
|
||||
if (JSON.stringify(before) === JSON.stringify(after)) return [];
|
||||
if (!record(before) || !record(after))
|
||||
return [{ path, before: clone(before), after: clone(after) }];
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
||||
.sort()
|
||||
.flatMap((name) => reviewDesign(before[name], after[name], `${path}.${name}`));
|
||||
}
|
||||
export function diffDesign(before: unknown, after: unknown, path = "$"): string[] {
|
||||
return reviewDesign(before, after, path).map((entry) => entry.path);
|
||||
}
|
||||
|
||||
export function exportDesign(system: DesignSystem, options: ResolveOptions = {}) {
|
||||
const diagnostics = validateDesignSystem(system, options);
|
||||
if (diagnostics.length) throw Error(diagnostics.map((d) => `${d.path}: ${d.message}`).join("\n"));
|
||||
const compiled = compileDesignSystem(system, options);
|
||||
const theme = {
|
||||
default: options.mode ?? system.defaultMode,
|
||||
designSystem: { system, ...options },
|
||||
};
|
||||
const exchange = Object.fromEntries(
|
||||
Object.entries(compiled.resolved.tokens).map(([name, token]) => [
|
||||
name,
|
||||
{ $type: token.type, $value: token.value },
|
||||
]),
|
||||
);
|
||||
return {
|
||||
canonical: json(system),
|
||||
css: compiled.css,
|
||||
theme: json(theme),
|
||||
tokens: json(exchange),
|
||||
report: json({
|
||||
system: system.id,
|
||||
diagnostics,
|
||||
components: Object.keys(COMPONENT_CONTRACTS).length,
|
||||
recipes: Object.keys(compiled.resolved.recipes),
|
||||
limitations:
|
||||
"Only declared parts are styled. Native, provider and content-owned regions retain their documented boundaries.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Browser-safe state machine. Raw partial edits never replace the last valid preview. */
|
||||
export class StudioSession {
|
||||
locks: DecisionLock[] = [];
|
||||
source: string;
|
||||
valid: DesignSystem;
|
||||
diagnostics: DesignDiagnostic[] = [];
|
||||
history: StudioRevision[];
|
||||
cursor = 0;
|
||||
applied: DesignSystem;
|
||||
storageError = "";
|
||||
private savedRecovery?: string;
|
||||
/** Untouched saved bytes when a future or damaged draft cannot be restored. */
|
||||
get recoverySource(): string | undefined {
|
||||
return this.savedRecovery;
|
||||
}
|
||||
constructor(
|
||||
system: DesignSystem = CORE_DESIGN_SYSTEMS.square,
|
||||
private storage?: StudioStorage,
|
||||
) {
|
||||
if (validateDesignSystem(system).length) throw Error("Initial system must validate.");
|
||||
this.valid = clone(system);
|
||||
this.applied = clone(system);
|
||||
this.source = json(system);
|
||||
this.history = [{ source: this.source, label: "Opened system" }];
|
||||
}
|
||||
restore(): boolean {
|
||||
let saved: string | null | undefined;
|
||||
try {
|
||||
saved = this.storage?.getItem(key);
|
||||
if (!saved) return false;
|
||||
if (saved.length > 8_000_000) throw Error("Saved history exceeds 8 MB.");
|
||||
const input = JSON.parse(saved);
|
||||
if (input?.storageVersion !== undefined && input.storageVersion !== 1)
|
||||
throw Error("Unsupported saved draft version; retain the original bytes.");
|
||||
if (
|
||||
!record(input) ||
|
||||
!Array.isArray(input.history) ||
|
||||
input.history.length < 1 ||
|
||||
input.history.length > 25 ||
|
||||
!Number.isInteger(input.cursor)
|
||||
)
|
||||
throw Error("Invalid local history.");
|
||||
const history = input.history as StudioRevision[];
|
||||
if (
|
||||
history.some((r) => !r || typeof r.source !== "string" || typeof r.label !== "string") ||
|
||||
(input.cursor as number) < 0 ||
|
||||
(input.cursor as number) >= history.length
|
||||
)
|
||||
throw Error("Invalid local revisions.");
|
||||
const valid = importDesign(json(input.valid));
|
||||
const applied = importDesign(json(input.applied));
|
||||
if (!valid.system || !applied.system)
|
||||
throw Error("Saved valid system could not be restored.");
|
||||
validateLocks(input.locks ?? []);
|
||||
this.locks = structuredClone((input.locks ?? []) as DecisionLock[]);
|
||||
this.valid = valid.system;
|
||||
this.applied = applied.system;
|
||||
this.history = clone(history);
|
||||
this.cursor = input.cursor as number;
|
||||
this.source = history[this.cursor]!.source;
|
||||
this.check();
|
||||
this.savedRecovery = undefined;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.savedRecovery = saved ?? undefined;
|
||||
this.storageError = `Local draft could not be restored: ${(error as Error).message}`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
private check() {
|
||||
const report = importDesign(this.source);
|
||||
this.diagnostics = report.diagnostics;
|
||||
if (report.system) {
|
||||
try {
|
||||
this.diagnostics = checkDecisionLocks(this.valid, report.system, this.locks);
|
||||
} catch (error) {
|
||||
this.diagnostics = [diagnostic("WRN-STUDIO-LOCK", (error as Error).message)];
|
||||
}
|
||||
if (!this.diagnostics.length) this.valid = report.system;
|
||||
}
|
||||
}
|
||||
persist() {
|
||||
if (this.savedRecovery !== undefined) {
|
||||
this.storageError =
|
||||
"Autosave unavailable. Saved recovery data is protected. Export recoverySource before resetting local storage.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.storage?.setItem(
|
||||
key,
|
||||
json({
|
||||
storageVersion: 1,
|
||||
history: this.history,
|
||||
cursor: this.cursor,
|
||||
valid: this.valid,
|
||||
applied: this.applied,
|
||||
locks: this.locks,
|
||||
}),
|
||||
);
|
||||
this.storageError = "";
|
||||
} catch {
|
||||
this.storageError = "Autosave unavailable or full. Export your draft to keep it.";
|
||||
}
|
||||
}
|
||||
edit(source: string, label = "Edited source") {
|
||||
if (source.length > limit) throw Error("Draft exceeds 2 MB.");
|
||||
if (source === this.source) return;
|
||||
this.source = source;
|
||||
this.history = this.history.slice(0, this.cursor + 1);
|
||||
this.history.push({ source, label });
|
||||
this.history = this.history.slice(-25);
|
||||
this.cursor = this.history.length - 1;
|
||||
this.check();
|
||||
this.persist();
|
||||
}
|
||||
replace(system: DesignSystem, label: string) {
|
||||
this.edit(json(system), label);
|
||||
}
|
||||
setLocks(locks: DecisionLock[]) {
|
||||
validateLocks(locks);
|
||||
this.locks = structuredClone(locks);
|
||||
this.check();
|
||||
this.persist();
|
||||
}
|
||||
undo() {
|
||||
if (this.cursor > 0) {
|
||||
this.source = this.history[--this.cursor]!.source;
|
||||
this.check();
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
redo() {
|
||||
if (this.cursor < this.history.length - 1) {
|
||||
this.source = this.history[++this.cursor]!.source;
|
||||
this.check();
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
mutate(change: (system: DesignSystem) => void, label: string) {
|
||||
if (this.diagnostics.length)
|
||||
throw Error("Resolve source errors before using structured editors.");
|
||||
const next = clone(this.valid);
|
||||
change(next);
|
||||
this.replace(next, label);
|
||||
}
|
||||
resolved(options: ResolveOptions = {}) {
|
||||
return resolveDesignSystem(this.valid, options);
|
||||
}
|
||||
changes() {
|
||||
return diffDesign(this.applied, this.valid);
|
||||
}
|
||||
markApplied() {
|
||||
if (this.diagnostics.length) throw Error("Resolve errors before applying.");
|
||||
this.applied = clone(this.valid);
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { guidanceHtml, guidanceCss } from "./guidance-shell.ts";
|
||||
export const studioHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WrNexus Design Studio</title><link rel="stylesheet" href="/studio.css"><script type="module" src="/studio.js"></script></head><body>
|
||||
<header><a class="brand" href="/">WrNexus <strong>Design Studio</strong></a><span id="save-state" role="status">Local workspace</span><button id="undo">Undo</button><button id="redo">Redo</button><button id="export">Export JSON</button><button id="review" class="primary">Review changes</button></header>
|
||||
<main><aside><p class="eyebrow">Workspace</p><h1>Your system.<br>Your components.</h1><p class="intro">Edit one design system and preview it on the existing component library.</p><label>Start from a preset<select id="preset"><option value="">Choose a preset</option><option value="square">Square / Industrial</option><option value="soft">Soft / Organic</option><option value="angular">Angular / Lo-Fi</option></select></label><div class="row"><button id="new">Create new</button><button id="duplicate">Duplicate</button></div><label>System name<input id="system-name"></label><label>System ID<input id="system-id"></label>
|
||||
<nav aria-label="Editors"><button data-tab="guided">Guided design</button><button data-tab="foundations" aria-current="page">Foundations</button><button data-tab="components">Components</button><button data-tab="source">Source & import</button><button data-tab="history">History</button><button data-tab="review">Review & apply</button></nav><p class="footnote">Local drafts stay in this browser. Export a file to keep a portable copy.</p></aside>
|
||||
<section class="workspace"><div class="workspace-title"><div><p class="eyebrow">Live authoring</p><h2 id="editor-title">Foundations</h2></div><span id="validation-state" class="badge">Checking</span></div><div id="diagnostics" role="status" aria-live="polite"></div>
|
||||
${guidanceHtml}
|
||||
<section id="foundations" class="editor"><div class="row"><label>Search tokens<input id="token-search" type="search" placeholder="Color, shape, typography…"></label><label>Edit layer<select id="token-layer"></select></label></div><p class="help">Aliases use @token.name. The resolved value and its source appear below each field.</p><div id="tokens" class="token-grid"></div><details><summary>Add a token, mode or density</summary><div class="row"><label>Token name<input id="add-token-name" placeholder="space.section"></label><label>Type<select id="add-token-type"></select></label><label>Value<input id="add-token-value" placeholder="24px"></label><button id="add-token">Add token</button></div><div class="row"><label>New layer name<input id="add-layer-name" placeholder="compact"></label><button id="add-mode">Add mode</button><button id="add-density">Add density</button></div></details></section>
|
||||
<section id="components" class="editor" hidden><div class="row"><label>Find a component<input type="search" id="component-search" placeholder="Search 104 components"></label><label>Component<select id="component"></select></label></div><div id="contract"></div><div class="row"><label>Part<select id="part"></select></label><label>Property<select id="property"></select></label></div><div class="row"><label>Recipe value<input id="recipe-value" placeholder="@shape.control or 8px"></label><button id="set-recipe">Set default</button><button id="remove-recipe">Remove default</button></div><label>Complete component recipe JSON<textarea id="recipe-source" rows="12" spellcheck="false"></textarea></label><button id="save-recipe">Save component recipe</button><p class="help">Recipe JSON supports the contract’s variants, compound conditions and states. All edits pass through engine validation.</p></section>
|
||||
<section id="source" class="editor" hidden><label>Canonical design system JSON<textarea id="source-text" rows="22" spellcheck="false"></textarea></label><button id="save-source">Update draft</button><button id="download-draft">Export raw draft</button><details open><summary>Import a design</summary><label>Format<select id="import-format"><option value="canonical">Canonical design system JSON</option><option value="legacy">WrNexus theme JSON</option><option value="tokens">Exchange tokens: literal scalar / alias subset</option></select></label><label>Choose a local JSON file<input id="import-file" type="file" accept=".json,application/json"></label><label>Or paste JSON<textarea id="import-text" rows="7" spellcheck="false"></textarea></label><button id="inspect-import">Inspect import</button><pre id="import-report"></pre><button id="accept-import" disabled>Use imported design</button></details></section>
|
||||
<section id="history" class="editor" hidden><p>Up to 25 local revisions, including incomplete source edits. Undo and redo also restore invalid drafts.</p><ol id="revisions"></ol></section>
|
||||
<section id="review-panel" class="editor" hidden><h3>Review before applying</h3><p id="target"></p><p>Apply writes a validated system, compiled CSS and theme configuration to the configured local application folder. Existing application configuration is preserved. Import the generated theme configuration to use it.</p><pre id="changes"></pre><div class="row"><button id="apply" class="primary">Apply to local folder</button><button id="rollback">Rollback last apply</button></div><h3>Portable exports</h3><div class="row"><button data-export="canonical">Canonical JSON</button><button data-export="css">Compiled CSS</button><button data-export="theme">Theme config</button><button data-export="tokens">Exchange tokens</button><button data-export="report">Compatibility report</button></div><p class="help">CSS and theme configuration are alternative integration paths. Publication and deployment are separate.</p><pre id="integration"></pre></section>
|
||||
</section><section class="preview"><div class="preview-heading"><div><p class="eyebrow">Same content, different decisions</p><h2>Preview</h2></div><button id="refresh-preview">Refresh</button></div><div class="preview-controls"><label>Mode<select id="preview-mode"></select></label><label>Density<select id="preview-density"></select></label><label>Width<select id="preview-width"><option value="100%">Responsive</option><option value="390px">390 px</option><option value="768px">768 px</option><option value="1280px">1280 px</option></select></label><label>Direction<select id="preview-direction"><option value="ltr">LTR</option><option value="rtl">RTL</option></select></label><label>Content<select id="preview-content"><option value="composed">Composed page</option><option value="catalog">Component gallery</option></select></label><label>Compare<select id="compare"><option value="">No comparison</option><option value="applied">Last applied</option><option value="square">Square</option><option value="soft">Soft</option><option value="angular">Angular</option></select></label></div><p class="help">Previews use isolated frames. Invalid drafts keep the last valid design. Gallery shows default component states; provider regions retain their own styling.</p><div class="frames"><div><p id="preview-label">Current draft</p><div class="frame-scroll"><iframe id="preview-frame" title="Current design preview" sandbox="allow-scripts"></iframe></div></div><div id="comparison" hidden><p>Comparison</p><div class="frame-scroll"><iframe id="compare-frame" title="Comparison design preview" sandbox="allow-scripts"></iframe></div></div></div></section></main><div id="message" role="status" aria-live="polite"></div></body></html>`;
|
||||
|
||||
export const studioCss = `${guidanceCss}:root{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;color:#171719;background:#fff;line-height:1.45;font-size:14px;--accent:#002fa7}*{box-sizing:border-box}body{margin:0}header{display:flex;align-items:center;gap:10px;min-height:72px;padding:16px 24px;border-bottom:1px solid #d9d9de;position:sticky;top:0;background:white;z-index:2}.brand{text-decoration:none;color:inherit;font-size:17px;letter-spacing:-.5px;margin-right:auto}.brand strong{margin-left:8px}#save-state{font-size:12px;color:#555;max-width:230px}button,input,select,textarea{font:inherit}button{padding:9px 12px;border:1px solid #c8c8d0;background:#fff;color:#171719;cursor:pointer;border-radius:0}button:hover{border-color:var(--accent);color:var(--accent)}button.primary{background:var(--accent);color:white;border-color:var(--accent)}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}main{display:grid;grid-template-columns:235px minmax(340px,.9fr) minmax(420px,1.1fr);min-height:calc(100vh - 72px)}aside{border-right:1px solid #d9d9de;padding:28px 20px}.eyebrow{font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:#666;margin:0 0 12px}h1{font-size:31px;line-height:1.08;letter-spacing:-1.4px;font-weight:500;margin:0 0 16px}h2{font-size:23px;line-height:1.2;letter-spacing:-.7px;font-weight:500;margin:0}h3{font-weight:500}.intro,.help,.footnote{color:#63636d;font-size:12px}.intro{margin-bottom:28px;max-width:28em}.footnote{margin-top:32px}label{display:block;font-size:12px;margin:12px 0;color:#3d3d45}input,select,textarea{display:block;width:100%;margin-top:6px;padding:9px;border:1px solid #c8c8d0;border-radius:0;background:white;color:#171719;min-width:0}textarea{font-family:Consolas,monospace;font-size:12px;tab-size:2;resize:vertical}nav{display:grid;margin:28px -20px 0}nav button{text-align:left;border:0;border-top:1px solid #e2e2e7;padding:15px 20px}nav button[aria-current=page]{border-left:3px solid var(--accent);padding-left:17px;color:var(--accent);background:#f7f7f8}.workspace{min-width:0;padding:28px 24px;border-right:1px solid #d9d9de}.workspace-title,.preview-heading{display:flex;justify-content:space-between;gap:12px;align-items:start;margin-bottom:24px}.badge{font-size:11px;border:1px solid #c8c8d0;padding:5px 8px;white-space:nowrap}.row{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.row>label{flex:1;min-width:100px}.row>button{margin-bottom:12px}.token-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0 18px}.token{border-top:1px solid #e2e2e7;padding:10px 0;min-width:0}.token label{margin:0}.token small{font-size:11px;display:block;overflow-wrap:anywhere;color:#686870;margin-top:7px}.token .token-name{font-weight:600;font-size:12px}.token-actions{display:flex;gap:8px}.token-actions input{flex:1}.swatch{width:18px;height:18px;border:1px solid #999;display:inline-block;vertical-align:middle;margin-right:6px}.preview{min-width:0;padding:28px 24px;background:#f7f7f8;align-self:start;position:sticky;top:72px;max-height:calc(100vh - 72px);overflow:auto}.preview-controls{display:flex;flex-wrap:wrap;gap:10px}.preview-controls label{flex:1;min-width:100px;margin-top:0}.frames{display:grid;gap:16px}.frames:has(#comparison:not([hidden])){grid-template-columns:1fr 1fr}.frames>div{min-width:0}.frame-scroll{overflow:auto;background:white;border:1px solid #d9d9de}iframe{border:0;width:100%;height:680px;display:block;background:white}.frames p{font-size:12px}details{border-top:1px solid #d9d9de;padding-top:16px;margin-top:24px}summary{cursor:pointer}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;background:#f7f7f8;padding:12px;max-height:350px;overflow:auto}#diagnostics:not(:empty){border-left:3px solid #b72032;padding:8px 12px;margin-bottom:20px;color:#8c1526;font-size:12px}#message:not(:empty){position:fixed;bottom:16px;left:24px;max-width:min(650px,calc(100vw - 48px));padding:14px 20px;background:#171719;color:white;z-index:4;white-space:pre-wrap}#contract{font-size:12px}.editor[hidden],[hidden]{display:none!important}#revisions li{margin-bottom:10px}#revisions button{text-align:left;width:100%}@media(min-width:1600px){main{grid-template-columns:250px minmax(470px,.8fr) minmax(600px,1.2fr)}}@media(max-width:1150px){main{grid-template-columns:210px minmax(0,1fr)}.preview{grid-column:2;position:static;max-height:none}.workspace{border-right:0}aside{grid-row:span 2}}@media(max-width:650px){header{flex-wrap:wrap;padding:12px}.brand{width:100%}#save-state{margin-right:auto}main{display:block}aside{border-right:0;padding:24px}h1 br{display:none}nav{display:flex;overflow:auto;margin:24px -24px 0}nav button{white-space:nowrap;padding:12px}.workspace,.preview{padding:24px}aside .intro,aside .footnote{display:none}.frames:has(#comparison:not([hidden])){grid-template-columns:1fr}.token-
Line truncated
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readDesignPackage } from "@wrnexus/design-system/package";
|
||||
import { checkDecisionLocks, designRevision } from "./decisions.ts";
|
||||
import { reviewDesign, type StudioSession } from "./session.ts";
|
||||
/** Review-only upgrade planning. Adoption always revalidates bytes, draft revision and locks. */
|
||||
export async function planDesignUpgrade(session: StudioSession, source: string) {
|
||||
if (session.diagnostics.length)
|
||||
throw Error("WRN-STUDIO-UPGRADE: resolve source errors before planning an upgrade.");
|
||||
const parsed = await readDesignPackage(source);
|
||||
const next = parsed.package.system;
|
||||
if (next.id !== session.valid.id)
|
||||
throw Error(
|
||||
"WRN-STUDIO-UPGRADE: system identity changed; import a different design explicitly.",
|
||||
);
|
||||
const diagnostics = checkDecisionLocks(session.valid, next, session.locks);
|
||||
return {
|
||||
baseRevision: await designRevision(session.valid, session.locks),
|
||||
package: parsed.package,
|
||||
migrations: parsed.migrations,
|
||||
changes: reviewDesign(session.valid, next),
|
||||
diagnostics,
|
||||
canApply: !diagnostics.length,
|
||||
};
|
||||
}
|
||||
export async function applyDesignUpgrade(
|
||||
session: StudioSession,
|
||||
source: string,
|
||||
baseRevision: string,
|
||||
) {
|
||||
const plan = await planDesignUpgrade(session, source);
|
||||
if (plan.baseRevision !== baseRevision)
|
||||
throw Error("WRN-STUDIO-CONFLICT: draft or locks changed; review the upgrade again.");
|
||||
if (!plan.canApply) throw Error("WRN-STUDIO-UPGRADE-LOCK: upgrade changes locked decisions.");
|
||||
session.replace(plan.package.system, "Adopted design package " + plan.package.system.version);
|
||||
return plan;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { test, expect, afterEach } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createFrameworkMcpServer } from "../../mcp/src/index.ts";
|
||||
import { DesignAgentService } from "../src/agent-server.ts";
|
||||
import { agentGenerationContext } from "../src/agent-context.ts";
|
||||
import { checkDesignConformance } from "../src/conformance.ts";
|
||||
import { agentPageSource, checkAgentPage, type AgentPage } from "../src/agent-pages.ts";
|
||||
import { CORE_DESIGN_SYSTEMS } from "../../design-system/src/index.ts";
|
||||
import { compileWrnFile } from "../../compiler/src/index.ts";
|
||||
const roots: string[] = [];
|
||||
function root() {
|
||||
const path = mkdtempSync(join(tmpdir(), "wrn-phase6-"));
|
||||
roots.push(path);
|
||||
writeFileSync(join(path, "package.json"), "{}");
|
||||
return path;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true });
|
||||
});
|
||||
const page = (id: string): AgentPage => ({
|
||||
id,
|
||||
title: id === "account" ? "Account settings" : "Contact support",
|
||||
components: [
|
||||
{ component: "PageHeader", props: { title: "Your workspace" } },
|
||||
{ component: "Input", props: { id: id + "-name", label: "Name" } },
|
||||
{ component: "Button", props: { label: "Continue", variant: "default" } },
|
||||
],
|
||||
});
|
||||
test("MCP defaults stay unchanged; explicit design opt-in exposes read tools and separate write opt-in", async () => {
|
||||
const path = root();
|
||||
expect(createFrameworkMcpServer(path).tools()).toHaveLength(8);
|
||||
const readonly = createFrameworkMcpServer(path, { design: true });
|
||||
expect(readonly.tools().some((t) => t.name === "wrnexus_design_context")).toBe(true);
|
||||
expect(readonly.tools().some((t) => t.name === "wrnexus_design_apply")).toBe(false);
|
||||
expect(
|
||||
((await readonly.call("wrnexus_design_context")) as any).components.Button.parts,
|
||||
).toBeDefined();
|
||||
expect(existsSync(join(path, ".wrnexus"))).toBe(false);
|
||||
const response = await readonly.handle({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "wrnexus_design_apply", arguments: { baseRevision: "none" } },
|
||||
});
|
||||
expect((response as any).result.isError).toBe(true);
|
||||
});
|
||||
test("recover invalid edits, stale conflicts, locks, undo and checkpoints across service instances", async () => {
|
||||
const path = root();
|
||||
let service = new DesignAgentService(path, true);
|
||||
let draft: any = await service.call("create", { baseRevision: "none" });
|
||||
const first = draft.revision;
|
||||
draft = await service.call("locks", { baseRevision: first, locks: ["/tokens/color.primary"] });
|
||||
await expect(service.call("create", { baseRevision: first })).rejects.toThrow("CONFLICT");
|
||||
draft = await service.call("propose", { baseRevision: draft.revision, source: "{unfinished" });
|
||||
expect(draft.diagnostics.length).toBeGreaterThan(0);
|
||||
service = new DesignAgentService(path, true);
|
||||
let state: any = await service.call("inspect");
|
||||
expect(state.source).toBe("{unfinished");
|
||||
expect(state.valid.id).toBe("square");
|
||||
await expect(
|
||||
service.call("apply", { baseRevision: state.revision, baseApplicationRevision: "none" }),
|
||||
).rejects.toThrow("INVALID");
|
||||
draft = await service.call("revert", { baseRevision: state.revision });
|
||||
state = await service.call("inspect");
|
||||
expect(state.diagnostics).toEqual([]);
|
||||
const locked = structuredClone(state.valid);
|
||||
locked.tokens["color.primary"].value = "#abcdef";
|
||||
draft = await service.call("propose", {
|
||||
baseRevision: draft.revision,
|
||||
source: JSON.stringify(locked),
|
||||
});
|
||||
expect(draft.diagnostics.length).toBeGreaterThan(0);
|
||||
await service.call("revert", { baseRevision: draft.revision, target: "checkpoint" });
|
||||
state = await service.call("inspect");
|
||||
expect(state.diagnostics).toEqual([]);
|
||||
expect(state.locks).toEqual(["/tokens/color.primary"]);
|
||||
});
|
||||
test("agents create two compilable consistent pages across all three designs and both modes", async () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
const service = new DesignAgentService(root(), true);
|
||||
let draft: any = await service.call("create", {
|
||||
baseRevision: "none",
|
||||
source: JSON.stringify(system),
|
||||
});
|
||||
for (const id of ["account", "support"]) {
|
||||
draft = await service.call("page", { baseRevision: draft.revision, page: page(id) });
|
||||
expect(draft.result.ok).toBe(true);
|
||||
const source = agentPageSource(page(id));
|
||||
expect(checkDesignConformance(source).ok).toBe(true);
|
||||
expect(compileWrnFile(source)).toBeTruthy();
|
||||
}
|
||||
for (const mode of ["light", "dark"])
|
||||
for (const id of ["account", "support"]) {
|
||||
const preview: any = await service.call("preview", { pageId: id, mode });
|
||||
expect(preview.html).toContain("Content-Security-Policy");
|
||||
expect(preview.html).toContain('data-wrn-component="Button"');
|
||||
expect(preview.html).toContain('data-theme="' + mode + '"');
|
||||
expect(preview.html).toContain("--wrn-radius");
|
||||
}
|
||||
const exported: any = await service.call("export");
|
||||
expect(Object.keys(exported.pages)).toEqual(["account.wrn", "support.wrn"]);
|
||||
expect(((await service.call("validate")) as any).ok).toBe(true);
|
||||
}
|
||||
});
|
||||
test("conformance rejects invented axes and props; only exact warnings can be waived", () => {
|
||||
const source =
|
||||
'page Test { view { <div data-component="Button" variant="invented" nonsense="x"></div> } style { main { color: #fff; padding: 17px; } } }';
|
||||
const report = checkDesignConformance(source);
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.diagnostics.some((d) => d.code === "WRN-CONFORMANCE-AXIS")).toBe(true);
|
||||
expect(report.diagnostics.some((d) => d.code === "WRN-CONFORMANCE-PROP")).toBe(true);
|
||||
const warning = "page Test { view { <main>Test</main> } style { main { color: #fff; } } }";
|
||||
const d = checkDesignConformance(warning).diagnostics[0]!;
|
||||
expect(
|
||||
checkDesignConformance(warning, [
|
||||
{ code: d.code, path: d.path, reason: "Reviewed brand illustration" },
|
||||
]).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkDesignConformance(source, [
|
||||
{ code: "WRN-CONFORMANCE-AXIS", path: "$.view[0].variant", reason: "No" },
|
||||
]).ok,
|
||||
).toBe(false);
|
||||
expect(checkDesignConformance("broken").ok).toBe(false);
|
||||
expect(
|
||||
checkAgentPage({
|
||||
...page("account"),
|
||||
components: [{ component: "Button", props: { variant: "invented" } }],
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
checkAgentPage({
|
||||
...page("account"),
|
||||
components: [{ component: "Button", props: { label: "javascript:alert(1)" } }],
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
test("application revision checks, exports and reversal preserve existing app configuration", async () => {
|
||||
const path = root();
|
||||
writeFileSync(join(path, "wrn.config.ts"), "existing config");
|
||||
const service = new DesignAgentService(path, true);
|
||||
let draft: any = await service.call("create", { baseRevision: "none" });
|
||||
await expect(
|
||||
service.call("apply", { baseRevision: draft.revision, baseApplicationRevision: "stale" }),
|
||||
).rejects.toThrow("CONFLICT");
|
||||
draft = await service.call("apply", {
|
||||
baseRevision: draft.revision,
|
||||
baseApplicationRevision: "none",
|
||||
});
|
||||
expect(draft.result.revision).not.toBe("none");
|
||||
expect(readFileSync(join(path, "wrn.config.ts"), "utf8")).toBe("existing config");
|
||||
const reverted: any = await service.call("revert", {
|
||||
baseRevision: draft.revision,
|
||||
target: "application",
|
||||
baseApplicationRevision: draft.result.revision,
|
||||
});
|
||||
expect(reverted.result.revision).toBe("none");
|
||||
mkdirSync(join(path, ".wrnexus", "design-studio", "agent", "LOCK"));
|
||||
await expect(service.call("create", { baseRevision: reverted.revision })).rejects.toThrow("BUSY");
|
||||
});
|
||||
test("agent context carries resolved tokens and real contract boundaries, with bounded selection", () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
const context = agentGenerationContext(system, { components: ["Button", "Input"] });
|
||||
expect(Object.keys(context.components)).toEqual(["Button", "Input"]);
|
||||
expect(context.tokens["color.primary"]).toBeDefined();
|
||||
expect((context.components.Button.axes as any).variant.values).toContain("default");
|
||||
}
|
||||
expect(() =>
|
||||
agentGenerationContext(CORE_DESIGN_SYSTEMS.square, { components: ["Invented"] }),
|
||||
).toThrow("CONTEXT");
|
||||
});
|
||||
test("stdio design protocol advertises opt-in tools and returns structured context without model credentials", async () => {
|
||||
const child = Bun.spawn(["bun", "packages/mcp/src/stdio.ts", "--root=" + root(), "--design"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
child.stdin.write(
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }) +
|
||||
"\n" +
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name: "wrnexus_design_context", arguments: { components: ["Button"] } },
|
||||
}) +
|
||||
"\n",
|
||||
);
|
||||
child.stdin.end();
|
||||
const lines = (await new Response(child.stdout).text())
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(await child.exited).toBe(0);
|
||||
expect(lines[0].result.tools.some((t: any) => t.name === "wrnexus_design_context")).toBe(true);
|
||||
expect(lines[1].result.structuredContent.value.components.Button).toBeDefined();
|
||||
});
|
||||
|
||||
test("exported pages preserve literal string, boolean and structured props in compiled mount markup", async () => {
|
||||
const source = agentPageSource({
|
||||
id: "literal-props",
|
||||
title: "Literal props",
|
||||
components: [
|
||||
{ component: "Button", props: { label: "Continue", disabled: false } },
|
||||
{ component: "Select", props: { options: [{ label: "One", value: "one" }] } },
|
||||
],
|
||||
});
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
|
||||
compileWrnFile(source).replace(
|
||||
/from (["'])@wrnexus\/core\1/g,
|
||||
`from ${JSON.stringify(new URL("../../core/src/index.ts", import.meta.url).href)}`,
|
||||
),
|
||||
);
|
||||
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
|
||||
const html = await mod.default({});
|
||||
expect(html).toContain('label="Continue"');
|
||||
expect(html).toContain('disabled="false"');
|
||||
expect(html).toContain(
|
||||
'options="[{"label":"One","value":"one"}]"',
|
||||
);
|
||||
});
|
||||
import { createDesignPackage } from "@wrnexus/design-system/package";
|
||||
test("MCP package upgrades preserve history and reject future agent storage without overwriting it", async () => {
|
||||
const path = root();
|
||||
const server = createFrameworkMcpServer(path, { design: true, designWrite: true });
|
||||
let draft: any = await server.call("wrnexus_design_create", { baseRevision: "none" });
|
||||
const next = structuredClone(CORE_DESIGN_SYSTEMS.square);
|
||||
next.version = "1.2.0";
|
||||
const source = JSON.stringify(await createDesignPackage(next));
|
||||
expect(((await server.call("wrnexus_design_upgrade_plan", { source })) as any).canApply).toBe(
|
||||
true,
|
||||
);
|
||||
draft = await server.call("wrnexus_design_upgrade", { baseRevision: draft.revision, source });
|
||||
expect(((await server.call("wrnexus_design_inspect")) as any).valid.version).toBe("1.2.0");
|
||||
await expect(
|
||||
server.call("wrnexus_design_upgrade", { baseRevision: "none", source }),
|
||||
).rejects.toThrow("CONFLICT");
|
||||
await server.call("wrnexus_design_revert", { baseRevision: draft.revision });
|
||||
expect(((await server.call("wrnexus_design_inspect")) as any).valid.version).toBe("1.0.0");
|
||||
const file = join(path, ".wrnexus", "design-studio", "agent", "STATE.json");
|
||||
const state = JSON.parse(readFileSync(file, "utf8"));
|
||||
const future = JSON.stringify({ ...state, storageVersion: 2 });
|
||||
writeFileSync(file, future);
|
||||
await expect(
|
||||
server.call("wrnexus_design_create", { baseRevision: state.revision }),
|
||||
).rejects.toThrow("RECOVERY");
|
||||
expect(readFileSync(file, "utf8")).toBe(future);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { CORE_DESIGN_SYSTEMS, validateDesignSystem } from "../../design-system/src/index.ts";
|
||||
import {
|
||||
generateDesigns,
|
||||
DEFAULT_DESIGN_BRIEF,
|
||||
type GenerationRequest,
|
||||
type GenerationProvider,
|
||||
} from "../src/generation.ts";
|
||||
import { designRevision, checkDecisionLocks, preserveDecisionLocks } from "../src/decisions.ts";
|
||||
import { combineDesignProposals } from "../src/guidance.ts";
|
||||
import { StudioSession } from "../src/session.ts";
|
||||
|
||||
const source = () => structuredClone(CORE_DESIGN_SYSTEMS.square);
|
||||
async function request(
|
||||
kind: "directions" | "edit" = "directions",
|
||||
locks: string[] = [],
|
||||
system = source(),
|
||||
): Promise<GenerationRequest> {
|
||||
return {
|
||||
system,
|
||||
locks,
|
||||
brief: { ...DEFAULT_DESIGN_BRIEF, product: "Booking site", audience: "Mobile customers" },
|
||||
baseRevision: await designRevision(system, locks),
|
||||
kind,
|
||||
instruction: kind === "edit" ? "make buttons rounded and use spacious spacing" : undefined,
|
||||
};
|
||||
}
|
||||
const patched = (changes: unknown[]) =>
|
||||
JSON.stringify({
|
||||
alternatives: [{ name: "Edited", rationale: "Requested change", patch: changes }],
|
||||
});
|
||||
const set = (path: string, value: unknown) => ({ op: "set", path, value });
|
||||
|
||||
test("local guided generation produces three distinct validated directions without changing source", async () => {
|
||||
const input = await request();
|
||||
const original = JSON.stringify(input.system);
|
||||
const result = await generateDesigns(input);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.alternatives).toHaveLength(3);
|
||||
expect(new Set(result.alternatives.map((p) => JSON.stringify(p.system))).size).toBe(3);
|
||||
for (const proposal of result.alternatives) {
|
||||
expect(validateDesignSystem(proposal.system)).toEqual([]);
|
||||
expect(Object.keys(proposal.system.recipes!)).toHaveLength(104);
|
||||
expect(proposal.system.id).toBe(input.system.id);
|
||||
}
|
||||
expect(JSON.stringify(input.system)).toBe(original);
|
||||
expect(result.provenance.provider).toBe("local-rules");
|
||||
expect(result.provenance.maxOutputTokens).toBe(0);
|
||||
});
|
||||
|
||||
test("guided geometry, density, surface and brand choices affect the brief direction", async () => {
|
||||
const input = await request();
|
||||
input.brief = {
|
||||
...input.brief,
|
||||
geometry: "rounded",
|
||||
density: "spacious",
|
||||
surface: "soft",
|
||||
brandColor: "#112233",
|
||||
};
|
||||
const result = await generateDesigns(input);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
const system = result.alternatives[0]!.system;
|
||||
expect(system.tokens["shape.control"]!.value).toBe("16px");
|
||||
expect(system.tokens["space.control"]!.value).toBe("1.25rem");
|
||||
expect(system.tokens["elevation.panel"]!.value).toContain("24px");
|
||||
expect(system.modes!.light!["color.primary"]!.value).toBe("#112233");
|
||||
expect(result.brief).toEqual(input.brief);
|
||||
});
|
||||
|
||||
test("direct locks, mode overrides and alias changes cannot alter a locked token", () => {
|
||||
const base = source();
|
||||
base.tokens["shape.alias"] = { type: "dimension", value: { ref: "shape.control" } };
|
||||
let next = structuredClone(base);
|
||||
next.tokens["shape.control"]!.value = "8px";
|
||||
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).not.toEqual([]);
|
||||
next = structuredClone(base);
|
||||
next.modes!.light!["shape.control"] = { type: "dimension", value: "8px" };
|
||||
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).not.toEqual([]);
|
||||
next = structuredClone(base);
|
||||
next.tokens["space.page"]!.value = "5rem";
|
||||
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).toEqual([]);
|
||||
});
|
||||
|
||||
test("component recipe locks protect referenced token values and absent decisions", () => {
|
||||
const base = source(),
|
||||
next = structuredClone(base);
|
||||
next.tokens["shape.control"]!.value = "8px";
|
||||
expect(checkDecisionLocks(base, next, ["/recipes/Button"])).not.toEqual([]);
|
||||
const added = structuredClone(base);
|
||||
added.recipes!.Button!.base!.control!.rotate = "2deg";
|
||||
expect(checkDecisionLocks(base, added, ["/recipes/Button/base/control/rotate"])).not.toEqual([]);
|
||||
});
|
||||
|
||||
test("directions preserve locks, including recipe token dependencies", async () => {
|
||||
const input = await request("directions", ["/recipes/Button"]);
|
||||
const result = await generateDesigns(input);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
for (const p of result.alternatives)
|
||||
expect(checkDecisionLocks(input.system, p.system, input.locks)).toEqual([]);
|
||||
const next = preserveDecisionLocks(
|
||||
input.system,
|
||||
structuredClone(CORE_DESIGN_SYSTEMS.soft),
|
||||
input.locks,
|
||||
);
|
||||
expect(checkDecisionLocks(input.system, next, input.locks)).toEqual([]);
|
||||
});
|
||||
|
||||
test("manual edits, undo, reload and unlock keep lock-violating drafts recoverable", () => {
|
||||
const memory = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => memory.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
memory.set(k, v);
|
||||
},
|
||||
};
|
||||
const session = new StudioSession(undefined, storage);
|
||||
session.setLocks(["/tokens/shape.control"]);
|
||||
session.mutate((s) => {
|
||||
s.tokens["shape.control"]!.value = "9px";
|
||||
}, "Locked edit");
|
||||
expect(session.valid.tokens["shape.control"]!.value).toBe("0");
|
||||
expect(session.diagnostics[0]!.code).toBe("WRN-STUDIO-LOCK");
|
||||
expect(session.source).toContain("9px");
|
||||
const restored = new StudioSession(undefined, storage);
|
||||
expect(restored.restore()).toBe(true);
|
||||
expect(restored.locks).toEqual(session.locks);
|
||||
expect(restored.diagnostics[0]!.code).toBe("WRN-STUDIO-LOCK");
|
||||
restored.setLocks([]);
|
||||
expect(restored.valid.tokens["shape.control"]!.value).toBe("9px");
|
||||
restored.undo();
|
||||
expect(restored.valid.tokens["shape.control"]!.value).toBe("0");
|
||||
});
|
||||
|
||||
test("word-based edits validate whole requests and reject unknown or locked changes", async () => {
|
||||
const result = await generateDesigns(await request("edit"));
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.alternatives[0]!.system.tokens["shape.control"]!.value).toBe("16px");
|
||||
const locked = await generateDesigns(await request("edit", ["/tokens/shape.control"]));
|
||||
expect(locked.alternatives).toEqual([]);
|
||||
expect(locked.diagnostics[0]!.message).toContain("LOCK");
|
||||
const input = await request("edit");
|
||||
input.instruction = "make buttons rounded and invent a new calendar";
|
||||
const failed = await generateDesigns(input);
|
||||
expect(failed.alternatives).toEqual([]);
|
||||
expect(input.system.tokens["shape.control"]!.value).toBe("0");
|
||||
input.instruction = "set primary color to #aabbcc and remove shadows and use serif typography";
|
||||
expect((await generateDesigns(input)).diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
test("stale generation base and stale combinations fail without applying", async () => {
|
||||
const input = await request();
|
||||
input.baseRevision = "a".repeat(64);
|
||||
expect(generateDesigns(input)).rejects.toThrow("WRN-STUDIO-CONFLICT");
|
||||
const good = await request();
|
||||
const batch = await generateDesigns(good);
|
||||
const next = structuredClone(good.system);
|
||||
next.name = "Changed";
|
||||
await expect(combineDesignProposals(next, batch, { colors: "direction-2" }, [])).rejects.toThrow(
|
||||
"CONFLICT",
|
||||
);
|
||||
});
|
||||
|
||||
test("foundation decisions combine and revalidate before entering a draft", async () => {
|
||||
const input = await request();
|
||||
const batch = await generateDesigns(input);
|
||||
const next = await combineDesignProposals(
|
||||
input.system,
|
||||
batch,
|
||||
{ geometry: "direction-2", typography: "direction-3" },
|
||||
[],
|
||||
);
|
||||
expect(validateDesignSystem(next)).toEqual([]);
|
||||
expect(next.tokens["shape.control"]).toEqual(
|
||||
batch.alternatives[1]!.system.tokens["shape.control"],
|
||||
);
|
||||
expect(next.tokens["type.body"]).toEqual(batch.alternatives[2]!.system.tokens["type.body"]);
|
||||
});
|
||||
|
||||
test("provider adapter uses bounded patches, compact capabilities and one repair", async () => {
|
||||
let calls = 0;
|
||||
let context = "";
|
||||
const provider: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate(prompt, options) {
|
||||
calls++;
|
||||
context = prompt;
|
||||
expect(options!.maxTokens).toBe(512);
|
||||
expect(options!.signal).toBeDefined();
|
||||
return {
|
||||
provider: "fixture",
|
||||
model: "test-model",
|
||||
value: calls === 1 ? "not json" : patched([set("/tokens/shape.control/value", "11px")]),
|
||||
};
|
||||
},
|
||||
};
|
||||
const result = await generateDesigns(await request("edit"), { provider, maxOutputTokens: 512 });
|
||||
expect(calls).toBe(2);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.provenance.model).toBe("test-model");
|
||||
expect(result.provenance.attempts).toBe(2);
|
||||
expect(context).toContain("capabilities");
|
||||
expect(context).toContain("Repair these diagnostics");
|
||||
});
|
||||
|
||||
test("provider invalid parts and lock violations cannot survive bounded repair", async () => {
|
||||
let calls = 0;
|
||||
const provider: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate() {
|
||||
calls++;
|
||||
return { provider: "fixture", value: patched([set("/tokens/shape.control/value", "12px")]) };
|
||||
},
|
||||
};
|
||||
const result = await generateDesigns(await request("edit", ["/tokens/shape.control"]), {
|
||||
provider,
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
expect(result.alternatives).toEqual([]);
|
||||
expect(result.diagnostics[0]!.code).toBe("WRN-STUDIO-PROPOSAL");
|
||||
const invalid: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate() {
|
||||
return {
|
||||
provider: "fixture",
|
||||
value: patched([set("/recipes/Button/base/control/position", "fixed")]),
|
||||
};
|
||||
},
|
||||
};
|
||||
expect(
|
||||
(await generateDesigns(await request("edit"), { provider: invalid })).alternatives,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("prototype paths, excessive operations and execution strings are rejected", async () => {
|
||||
for (const patch of [
|
||||
[set("/tokens/__proto__/value", "x")],
|
||||
Array.from({ length: 49 }, () => set("/name", "x")),
|
||||
[set("/tokens/shape.control/value", "0;}body{display:none")],
|
||||
]) {
|
||||
const provider: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate() {
|
||||
return { provider: "fixture", value: patched(patch) };
|
||||
},
|
||||
};
|
||||
expect((await generateDesigns(await request("edit"), { provider })).alternatives).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("cancellation and deadline stop even a provider that ignores its signal", async () => {
|
||||
const provider: GenerationProvider = { name: "hung", generate: () => new Promise(() => {}) };
|
||||
const controller = new AbortController();
|
||||
const input = await request("edit");
|
||||
const pending = generateDesigns(input, { provider, signal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toThrow();
|
||||
await expect(generateDesigns(input, { provider, timeoutMs: 15 })).rejects.toThrow("cancelled");
|
||||
expect(input.system.tokens["shape.control"]!.value).toBe("0");
|
||||
});
|
||||
|
||||
test("transport failure is not retried or returned with secret-bearing messages", async () => {
|
||||
let count = 0;
|
||||
const provider: GenerationProvider = {
|
||||
name: "fixture",
|
||||
async generate() {
|
||||
count++;
|
||||
throw Error("secret-key-in-provider-error");
|
||||
},
|
||||
};
|
||||
const result = await generateDesigns(await request("edit"), { provider });
|
||||
expect(count).toBe(1);
|
||||
expect(JSON.stringify(result)).not.toContain("secret-key-in-provider-error");
|
||||
expect(result.diagnostics[0]!.code).toBe("WRN-STUDIO-PROVIDER");
|
||||
});
|
||||
|
||||
test("untrusted brief fields and generation budgets are bounded", async () => {
|
||||
const input = await request();
|
||||
input.brief.product = "x".repeat(2001);
|
||||
await expect(generateDesigns(input)).rejects.toThrow("2000");
|
||||
await expect(generateDesigns(await request(), { maxOutputTokens: 9000 })).rejects.toThrow(
|
||||
"BUDGET",
|
||||
);
|
||||
const altered = await request();
|
||||
altered.brief.brandColor = "red;}body{}";
|
||||
await expect(generateDesigns(altered)).rejects.toThrow("BRIEF");
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createStudioHandler } from "../src/server.ts";
|
||||
import { StudioApplication } from "../src/application.ts";
|
||||
import { CORE_DESIGN_SYSTEMS } from "../../design-system/src/index.ts";
|
||||
import { resolveThemeConfig } from "../../styles/src/theme.ts";
|
||||
import { designRevision } from "../src/decisions.ts";
|
||||
import { DEFAULT_DESIGN_BRIEF } from "../src/generation.ts";
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrn-studio-test-"));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
const handler = await createStudioHandler({ applicationRoot: root });
|
||||
const send = (path: string, body?: unknown, origin?: string) =>
|
||||
handler(
|
||||
new Request(
|
||||
"http://127.0.0.1:4320" + path,
|
||||
body === undefined
|
||||
? undefined
|
||||
: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...(origin ? { origin } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test("hosted Studio keeps its path, same-origin checks and export-only boundary", async () => {
|
||||
const hosted = await createStudioHandler({ hosted: { basePath: "/design-studio" } });
|
||||
const origin = "https://components.example";
|
||||
const html = await hosted(new Request(origin + "/design-studio/"));
|
||||
expect(await html.text()).toContain('<base href="/design-studio/">');
|
||||
expect((await hosted(new Request(origin + "/api/application"))).status).toBe(404);
|
||||
const state = await hosted(new Request(origin + "/design-studio/api/application"));
|
||||
expect((await state.json()).enabled).toBe(false);
|
||||
const proxied = await hosted(
|
||||
new Request("http://components.example/design-studio/api/application", {
|
||||
headers: { origin, "x-forwarded-proto": "https" },
|
||||
}),
|
||||
);
|
||||
expect(proxied.status).toBe(200);
|
||||
const request = (path: string, body: unknown, from = origin) =>
|
||||
hosted(
|
||||
new Request(origin + "/design-studio" + path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: from },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(await request("/api/apply", { system: CORE_DESIGN_SYSTEMS.soft, base: "none" })).status,
|
||||
).toBe(409);
|
||||
expect(
|
||||
(await request("/api/preview", { system: CORE_DESIGN_SYSTEMS.soft }, "https://other.example"))
|
||||
.status,
|
||||
).toBe(403);
|
||||
const preview = await request("/api/preview", { system: CORE_DESIGN_SYSTEMS.soft });
|
||||
const { url } = await preview.json();
|
||||
expect(url).toStartWith("/design-studio/preview/");
|
||||
expect((await hosted(new Request(origin + url))).status).toBe(200);
|
||||
await expect(
|
||||
createStudioHandler({ applicationRoot: root, hosted: { basePath: "/design-studio" } }),
|
||||
).rejects.toThrow("export-only");
|
||||
});
|
||||
|
||||
test("generation endpoint exposes only configured metadata and stages validated proposals", async () => {
|
||||
const meta = await (await send("/api/generation")).json();
|
||||
expect(meta.provider).toBe("local-rules");
|
||||
expect(JSON.stringify(meta)).not.toContain("apiKey");
|
||||
const system = structuredClone(CORE_DESIGN_SYSTEMS.soft),
|
||||
locks = ["/tokens/shape.control"];
|
||||
const input = {
|
||||
kind: "directions",
|
||||
brief: { ...DEFAULT_DESIGN_BRIEF, product: "A website", audience: "Customers" },
|
||||
system,
|
||||
locks,
|
||||
baseRevision: await designRevision(system, locks),
|
||||
};
|
||||
const response = await send("/api/generate", input);
|
||||
expect(response.status).toBe(200);
|
||||
const batch = await response.json();
|
||||
expect(batch.alternatives).toHaveLength(3);
|
||||
expect(batch.provenance.provider).toBe("local-rules");
|
||||
expect((await send("/api/generate", { ...input, baseRevision: "a".repeat(64) })).status).toBe(
|
||||
409,
|
||||
);
|
||||
expect((await send("/api/generate", input, "https://example.org")).status).toBe(403);
|
||||
});
|
||||
|
||||
test("local application supports immutable bundles, conflicts, rollback and existing config preservation", () => {
|
||||
const app = new StudioApplication(root);
|
||||
writeFileSync(join(root, "wrn.config.ts"), "export default { existing: true };");
|
||||
const first = app.apply(CORE_DESIGN_SYSTEMS.square, "none", { mode: "light" });
|
||||
expect(existsSync(join(first.directory, "system.css"))).toBe(true);
|
||||
const theme = JSON.parse(readFileSync(join(first.directory, "theme.json"), "utf8"));
|
||||
expect(resolveThemeConfig(theme).recipeCss).toContain("data-wrn-owner");
|
||||
expect(() => app.apply(CORE_DESIGN_SYSTEMS.soft, "none")).toThrow("WRN-STUDIO-CONFLICT");
|
||||
const second = app.apply(CORE_DESIGN_SYSTEMS.soft, first.revision);
|
||||
expect(app.rollback(second.revision).revision).toBe(first.revision);
|
||||
expect(app.system()!.id).toBe("square");
|
||||
expect(readFileSync(join(root, "wrn.config.ts"), "utf8")).toContain("existing: true");
|
||||
const fresh = mkdtempSync(join(root, "first-"));
|
||||
const initial = new StudioApplication(fresh);
|
||||
const applied = initial.apply(CORE_DESIGN_SYSTEMS.soft, "none");
|
||||
expect(initial.rollback(applied.revision).revision).toBe("none");
|
||||
expect(initial.system()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("server serves a browser bundle, rejects cross-origin, invalid, oversized and unconfigured writes", async () => {
|
||||
expect((await send("/")).status).toBe(200);
|
||||
const bundle = await send("/studio.js");
|
||||
expect(bundle.status).toBe(200);
|
||||
expect(await bundle.text()).not.toContain("node:fs");
|
||||
expect(
|
||||
(
|
||||
await send(
|
||||
"/api/apply",
|
||||
{ system: CORE_DESIGN_SYSTEMS.soft, base: "none" },
|
||||
"https://other.example",
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect((await handler(new Request("http://evil.example/api/application"))).status).toBe(403);
|
||||
expect((await send("/api/apply", { system: {}, base: "none" })).status).toBe(422);
|
||||
expect((await send("/api/apply", { data: "x".repeat(2_000_001) })).status).toBe(413);
|
||||
const readOnly = await createStudioHandler();
|
||||
expect(
|
||||
(
|
||||
await readOnly(
|
||||
new Request("http://127.0.0.1/api/apply", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ system: CORE_DESIGN_SYSTEMS.soft, base: "none" }),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(409);
|
||||
});
|
||||
|
||||
test("all presets generate isolated real component previews with light/dark and responsive/RTL controls", async () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS))
|
||||
for (const mode of ["light", "dark"]) {
|
||||
const result = await send("/api/preview", { system, mode, direction: "rtl" });
|
||||
expect(result.status).toBe(200);
|
||||
const { url } = await result.json();
|
||||
const preview = await send(url);
|
||||
const html = await preview.text();
|
||||
expect(html).toContain(`data-theme="${mode}"`);
|
||||
expect(html).toContain('dir="rtl"');
|
||||
expect(html).toContain('data-wrn-component="Radio"');
|
||||
expect(html).toContain('data-wrn-component="InputNumber"');
|
||||
expect(html).toContain("data-scope");
|
||||
expect(html).toContain("data-wrn-controls");
|
||||
expect(html).not.toContain("failed to load component controllers");
|
||||
expect(preview.headers.get("content-security-policy")).toContain("connect-src 'none'");
|
||||
}
|
||||
});
|
||||
|
||||
test("catalog previews cover all 104 component defaults and unknown routes do not expose files", async () => {
|
||||
let count = 0;
|
||||
for (let page = 0; page < 7; page++) {
|
||||
const result = await send("/api/preview", {
|
||||
system: CORE_DESIGN_SYSTEMS.soft,
|
||||
content: "catalog",
|
||||
page,
|
||||
});
|
||||
const { url } = await result.json();
|
||||
const html = await (await send(url)).text();
|
||||
count += (html.match(/data-wrn-component=/g) ?? []).length;
|
||||
}
|
||||
expect(count).toBe(104);
|
||||
expect((await send("/../../package.json")).status).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { StudioSession, importDesign, exportDesign, diffDesign } from "../src/index.ts";
|
||||
import { CORE_DESIGN_SYSTEMS, validateDesignSystem } from "../../design-system/src/index.ts";
|
||||
|
||||
test("invalid edits preserve a valid preview, raw input, undo/redo and reload", () => {
|
||||
const data = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => data.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
data.set(k, v);
|
||||
},
|
||||
};
|
||||
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft, storage);
|
||||
session.mutate((s) => {
|
||||
s.tokens["shape.control"]!.value = "13px";
|
||||
}, "Changed radius");
|
||||
session.edit('{"tokens":', "Incomplete JSON");
|
||||
expect(session.diagnostics[0]!.code).toBe("WRN-STUDIO-IMPORT");
|
||||
expect(session.valid.tokens["shape.control"]!.value).toBe("13px");
|
||||
session.undo();
|
||||
expect(session.diagnostics).toEqual([]);
|
||||
session.redo();
|
||||
expect(session.source).toBe('{"tokens":');
|
||||
const restored = new StudioSession(undefined, storage);
|
||||
expect(restored.restore()).toBe(true);
|
||||
expect(restored.source).toBe(session.source);
|
||||
expect(restored.valid.tokens["shape.control"]!.value).toBe("13px");
|
||||
expect(() => restored.markApplied()).toThrow();
|
||||
expect(() => restored.mutate(() => {}, "x")).toThrow();
|
||||
});
|
||||
|
||||
test("history stays bounded, branches after undo, and realistic full-preset history reloads", () => {
|
||||
const data = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => data.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
data.set(k, v);
|
||||
},
|
||||
};
|
||||
const session = new StudioSession(undefined, storage);
|
||||
for (let i = 0; i < 30; i++)
|
||||
session.mutate((s) => {
|
||||
s.name = "Revision " + i;
|
||||
}, "Name");
|
||||
expect(session.history).toHaveLength(25);
|
||||
const restored = new StudioSession(undefined, storage);
|
||||
expect(restored.restore()).toBe(true);
|
||||
restored.undo();
|
||||
restored.mutate((s) => {
|
||||
s.name = "Branch";
|
||||
}, "Branched");
|
||||
restored.redo();
|
||||
expect(restored.valid.name).toBe("Branch");
|
||||
expect(restored.history.at(-1)!.label).toBe("Branched");
|
||||
});
|
||||
|
||||
test("corrupted storage and quota failures do not destroy in-memory drafts", () => {
|
||||
const broken = new StudioSession(undefined, {
|
||||
getItem: () => "{",
|
||||
setItem: () => {
|
||||
throw Error("quota");
|
||||
},
|
||||
});
|
||||
expect(broken.restore()).toBe(false);
|
||||
expect(broken.storageError).toContain("could not be restored");
|
||||
broken.mutate((s) => {
|
||||
s.name = "Kept";
|
||||
}, "Edit");
|
||||
expect(broken.valid.name).toBe("Kept");
|
||||
expect(broken.storageError).toContain("Autosave unavailable");
|
||||
});
|
||||
|
||||
test("canonical import/export round trips all recipes, modes, aliases and metadata", () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
const artifact = exportDesign(system);
|
||||
const report = importDesign(artifact.canonical);
|
||||
expect(report.diagnostics).toEqual([]);
|
||||
expect(report.system).toEqual(system);
|
||||
expect(artifact.css).toContain("data-wrn-owner");
|
||||
expect(JSON.parse(artifact.theme).designSystem.system).toEqual(system);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy import reports unsupported fields and preserves literal semantic bindings", () => {
|
||||
const report = importDesign(
|
||||
JSON.stringify({
|
||||
default: "light",
|
||||
palette: "violet",
|
||||
themes: { light: { "color-primary": "#112233", unknown: "4px" } },
|
||||
}),
|
||||
"legacy",
|
||||
);
|
||||
expect(report.diagnostics).toEqual([]);
|
||||
expect(report.unsupported).toContain("palette");
|
||||
expect(report.unsupported).toContain("themes.light.unknown");
|
||||
expect(report.system!.modes!.light!["color.primary"]).toEqual({
|
||||
type: "color",
|
||||
value: "#112233",
|
||||
legacy: "color-primary",
|
||||
});
|
||||
});
|
||||
|
||||
test("exchange subset preserves aliases and reports ambiguous semantic mapping and unsupported composites", () => {
|
||||
const report = importDesign(
|
||||
JSON.stringify({
|
||||
brand: {
|
||||
$type: "color",
|
||||
base: { $value: "#112233" },
|
||||
action: { $value: "{brand.base}" },
|
||||
complex: { $value: { colorSpace: "srgb", components: [0, 0, 0] } },
|
||||
},
|
||||
$extensions: { x: 1 },
|
||||
}),
|
||||
"tokens",
|
||||
);
|
||||
expect(report.diagnostics).toEqual([]);
|
||||
expect(report.system!.tokens["brand.action"]!.value).toEqual({ ref: "brand.base" });
|
||||
expect(report.unsupported).toContain("brand.complex");
|
||||
expect(report.unsupported).toContain("$extensions");
|
||||
expect(report.ambiguous).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("imports reject oversized, cyclic, executable, polluted and missing-reference documents", () => {
|
||||
expect(importDesign(" ".repeat(2_000_001)).diagnostics).not.toEqual([]);
|
||||
expect(
|
||||
importDesign('{"__proto__":{"$type":"color","$value":"#000"}}', "tokens").diagnostics,
|
||||
).not.toEqual([]);
|
||||
for (const tokens of [
|
||||
{ a: { type: "color", value: { ref: "missing" } } },
|
||||
{ a: { type: "color", value: { ref: "b" } }, b: { type: "color", value: { ref: "a" } } },
|
||||
{ a: { type: "color", value: "red;}body{display:none" } },
|
||||
]) {
|
||||
const system = { schemaVersion: 1, id: "bad", name: "Bad", version: "1", tokens };
|
||||
expect(importDesign(JSON.stringify(system)).diagnostics).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("review diffs identify affected paths and applied snapshots are detached", () => {
|
||||
const session = new StudioSession();
|
||||
session.mutate((s) => {
|
||||
s.tokens["shape.control"]!.value = "6px";
|
||||
}, "Radius");
|
||||
expect(session.changes()).toContain("$.tokens.shape.control.value");
|
||||
session.markApplied();
|
||||
expect(session.changes()).toEqual([]);
|
||||
session.mutate((s) => {
|
||||
s.name = "Other";
|
||||
}, "Name");
|
||||
expect(session.applied.name).not.toBe("Other");
|
||||
expect(diffDesign({ a: 1 }, { a: 2, b: 3 })).toEqual(["$.a", "$.b"]);
|
||||
expect(validateDesignSystem(session.valid)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
|
||||
import { createDesignPackage, readDesignPackage } from "@wrnexus/design-system/package";
|
||||
import { StudioSession } from "../src/session.ts";
|
||||
import { planDesignUpgrade, applyDesignUpgrade } from "../src/upgrades.ts";
|
||||
|
||||
test("shared packages have deterministic pins independent of object key order and reject changed or future content", async () => {
|
||||
const system = CORE_DESIGN_SYSTEMS.square;
|
||||
const first = await createDesignPackage(system);
|
||||
const reordered = {
|
||||
...system,
|
||||
tokens: Object.fromEntries(Object.entries(system.tokens).reverse()),
|
||||
};
|
||||
expect((await createDesignPackage(reordered)).integrity.digest).toBe(first.integrity.digest);
|
||||
expect((await readDesignPackage(JSON.stringify(first))).package).toEqual(first);
|
||||
const changed = structuredClone(first);
|
||||
changed.system.tokens["color.primary"]!.value = "#abcdef";
|
||||
await expect(readDesignPackage(JSON.stringify(changed))).rejects.toThrow("INTEGRITY");
|
||||
await expect(readDesignPackage(JSON.stringify({ ...first, formatVersion: 2 }))).rejects.toThrow(
|
||||
"VERSION",
|
||||
);
|
||||
await expect(createDesignPackage({ ...system, extends: ["company"] })).rejects.toThrow();
|
||||
expect((await readDesignPackage(JSON.stringify(system))).migrations).toEqual([
|
||||
"canonical-v1-to-package-v1",
|
||||
]);
|
||||
});
|
||||
test("upgrade review and explicit adoption respect identity, pins, locks, stale revisions and recoverable history", async () => {
|
||||
const session = new StudioSession();
|
||||
const next = structuredClone(session.valid);
|
||||
next.version = "1.1.0";
|
||||
next.tokens["color.primary"]!.value = "#abcdef";
|
||||
const source = JSON.stringify(await createDesignPackage(next));
|
||||
const plan = await planDesignUpgrade(session, source);
|
||||
expect(plan.canApply).toBe(true);
|
||||
expect(session.valid.version).toBe("1.0.0");
|
||||
session.setLocks(["/tokens/color.primary"]);
|
||||
expect((await planDesignUpgrade(session, source)).canApply).toBe(false);
|
||||
await expect(applyDesignUpgrade(session, source, plan.baseRevision)).rejects.toThrow("CONFLICT");
|
||||
const locked = await planDesignUpgrade(session, source);
|
||||
await expect(applyDesignUpgrade(session, source, locked.baseRevision)).rejects.toThrow("LOCK");
|
||||
session.setLocks([]);
|
||||
const fresh = await planDesignUpgrade(session, source);
|
||||
await applyDesignUpgrade(session, source, fresh.baseRevision);
|
||||
expect(session.valid.version).toBe("1.1.0");
|
||||
session.undo();
|
||||
expect(session.valid.version).toBe("1.0.0");
|
||||
session.redo();
|
||||
expect(session.valid.version).toBe("1.1.0");
|
||||
await expect(
|
||||
planDesignUpgrade(
|
||||
session,
|
||||
JSON.stringify(await createDesignPackage({ ...next, id: "different" })),
|
||||
),
|
||||
).rejects.toThrow("identity");
|
||||
session.edit("{unfinished");
|
||||
await expect(planDesignUpgrade(session, source)).rejects.toThrow("source errors");
|
||||
});
|
||||
test("legacy unversioned drafts migrate losslessly; future drafts remain untouched by autosave", () => {
|
||||
let saved = "";
|
||||
const storage = {
|
||||
getItem: () => saved || null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
saved = value;
|
||||
},
|
||||
};
|
||||
const old = new StudioSession(undefined, storage);
|
||||
old.edit("{unfinished");
|
||||
const legacy = JSON.parse(saved);
|
||||
delete legacy.storageVersion;
|
||||
saved = JSON.stringify(legacy);
|
||||
const restored = new StudioSession(undefined, storage);
|
||||
expect(restored.restore()).toBe(true);
|
||||
expect(restored.source).toBe("{unfinished");
|
||||
restored.undo();
|
||||
expect(JSON.parse(saved).storageVersion).toBe(1);
|
||||
const future = JSON.stringify({ ...legacy, storageVersion: 99 });
|
||||
saved = future;
|
||||
const older = new StudioSession(undefined, storage);
|
||||
expect(older.restore()).toBe(false);
|
||||
expect(older.recoverySource).toBe(future);
|
||||
older.edit("{another edit");
|
||||
older.persist();
|
||||
expect(saved).toBe(future);
|
||||
expect(older.storageError).toContain("protected");
|
||||
});
|
||||
Reference in new issue
Block a user