Safe UGC UI is a pnpm workspace for describing, validating, and rendering untrusted UI cards. It combines a JSON card format, Zod-based types, JSON Schema generation, a security-focused validator, and a React renderer that keeps user-provided UI inside a constrained container.
- Phase 2 plus the
v1.0interactive container milestone are implemented. - Published packages are currently
3.0.0:@safe-ugc-ui/types,@safe-ugc-ui/schema,@safe-ugc-ui/validator,@safe-ugc-ui/react. - The
v1.1safe visual/layout pack is implemented onmain. - The style system includes font family tokens, text shadow, repeating linear gradients,
aspectRatio,backdropBlur, structuredclipPath, and node-levelresponsive.medium/responsive.compactoverrides. - Nodes support
$ifconditional rendering, structuralSwitchbranching, andButton/Togglesupportdisabled. - Text authoring supports structured
$template,Text.spans, andText.maxLines/truncate. - Cards support top-level
fragmentsand$usereferences for non-recursive subtree reuse. AccordionandTabsare implemented as renderer-owned interactive containers.packages/demois a private playground app used for local development.
| Package | Purpose |
|---|---|
@safe-ugc-ui/types |
Zod schemas, inferred TypeScript types, constants, and shared ref-path helpers |
@safe-ugc-ui/schema |
JSON Schema generation and the built ugc-card.schema.json artifact |
@safe-ugc-ui/validator |
Structural, style, security, limit validation, and safe card loading |
@safe-ugc-ui/react |
UGCRenderer, UGCContainer, renderer internals, asset/style helpers |
@safe-ugc-ui/demo |
Vite-based playground for editing card JSON and previewing output |
- Semver-supported public entrypoints are the package root exports and
@safe-ugc-ui/schema/ugc-card.schema.json. @safe-ugc-ui/types/internal/*stays exported for workspace coordination and advanced tooling, but it is not covered by semver stability promises.- External consumers should avoid
@safe-ugc-ui/types/internal/*unless they are prepared to pin exact versions and absorb internal refactors.
types ─────┬── schema
├── validator
└── react ──── validator
│
demo ────┬── react
└── validator
Install only the package you need:
pnpm add @safe-ugc-ui/react
pnpm add @safe-ugc-ui/validator
pnpm add @safe-ugc-ui/schema
pnpm add @safe-ugc-ui/types@safe-ugc-ui/react already depends on @safe-ugc-ui/types and @safe-ugc-ui/validator.
pnpm build— build all workspace packagespnpm test— run Vitest in workspace modepnpm test:clean-checkout— verify workspace tests plus demo typecheck and build from a clean workspace without prebuilt packagedistoutputspnpm test:contracts— run the targeted contract-regression gate for host-facing validator/renderer boundaries, including a clean-workspace canarypnpm test:contracts:packages— run only the validator/react contract-regression suites without the workspace canarypnpm test:run— run the full workspace test suite oncepnpm test:coverage— run the workspace suite with coveragepnpm release:pack-check— verify that each publishable tarball contains the expected build outputs and exported entrypointspnpm release:check— run the shared pre-tag release baseline: format check, contract gate, clean-checkout gate, build, tarball/export verification, typecheck, audit, and coveragepnpm clean— remove packagedistdirectoriespnpm format— format the workspace with Prettierpnpm format:check— check whether the workspace is Prettier-formatted
The main CI workflow and the tag-based publish workflow both use pnpm release:check as the shared
release baseline. CI runs that baseline on Node 20.19.0; publish.yml intentionally uses Node
24 because npm trusted publishing requires a newer runner runtime.
Use loadCardRaw() when the input is still a JSON string so the validator can reject oversized
payloads before parsing, run the full validation pipeline, and return a typed card only on success:
import { loadCardRaw } from '@safe-ugc-ui/validator';
const rawCard = `{
"meta": { "name": "hello", "version": "1.0.0" },
"state": { "greeting": "Hello, World!" },
"views": {
"Main": {
"type": "Text",
"content": { "$ref": "$greeting" }
}
}
}`;
const result = loadCardRaw(rawCard);
if (!result.valid) {
console.error(result.errors);
} else {
console.log(result.card.views.Main);
}If the card is already parsed, use loadCard() instead.
Use validateRaw() or validate() when you only need diagnostics and do not need the typed
UGCCard returned.
For most validator errors, ValidationError.path points to the exact failing field. For some
structural SCHEMA_ERRORs that come from nested Zod unions, path points to the nearest stable
ancestor and message includes up to three deeper child locations.
Prefer validating at host ingest time with loadCardRaw() or loadCard(). UGCRenderer still
validates before rendering and revalidates the effective merged runtime state, so the render
boundary stays defensive even when the host passes a parsed card object.
import { UGCRenderer } from '@safe-ugc-ui/react';
import { loadCardRaw } from '@safe-ugc-ui/validator';
export function CardPreview({ rawCard }: { rawCard: string }) {
const result = loadCardRaw(rawCard);
if (!result.valid) {
console.error(result.errors);
return null;
}
return (
<UGCRenderer
card={result.card}
assets={{
'@assets/avatar.png': 'https://cdn.example.com/avatar.png',
}}
onError={(errors) => {
console.error(errors);
}}
/>
);
}Key renderer props:
viewNameto render a specific named view; invalid names emitRUNTIME_VIEW_NOT_FOUNDthroughonErrorand render nothingassetsto resolve@assets/...references to host-controlled URLs; the host owns final URL provenance and any origin allowlist policystateto override or extendcard.state; the merged state is revalidated before renderingcontainerStyleto style the outer isolation container without replacing protected isolation propertieshostOverflowto override only theoverflowkey among the protected isolation propertiesiconResolverto map icon names to React nodes; if omitted,Iconnodes soft-skip and emitRUNTIME_ICON_RESOLVER_MISSINGthroughonErroronActionto receive Button and Toggle action eventsonErrorreceives structuredRendererError[]diagnostics for validation failures and runtime renderer issues
For editor integration or external structural validation:
import { generateCardSchema } from '@safe-ugc-ui/schema';
const schema = generateCardSchema();The build also emits a static file at packages/schema/dist/ugc-card.schema.json, published as:
@safe-ugc-ui/schema/ugc-card.schema.json
A card is a JSON object with these main areas:
meta: card identity and version metadataassets: named asset references that must use@assets/...; the host provides the final file or URL mappingstate: precomputed values referenced via{ "$ref": "$path.to.value" }styles: named style presets for$stylereusefragments: reusable node subtrees referenced via$useviews: one or more renderable trees
Currently implemented node types:
Box,Row,Column,Text,ImageStack,Grid,Spacer,Divider,IconProgressBar,Avatar,Badge,Chip,Button,Toggle,Accordion,TabsSwitch(structural branch selector)
Supported card-level features:
$refstate binding- node-level
$ifconditional rendering - structural
Switchbranch selection with static cases for...inloopsfragmentsplus$usesubtree reuse- reusable
stylesplus$stylereferences - node-level
responsive.mediumoverrides for container widths up to768px - node-level
responsive.compactoverrides for container widths up to480px hoverStyle- structured
transition - directional
borderRadius objectFit,objectPosition,aspectRatio,backdropBlur, and structuredclipPathButton/Toggledisabled stateAccordionandTabslocal interactive state with hidden-content budgeting
For full details, see:
JSON Schema is structural only. Actual safety checks live in @safe-ugc-ui/validator.
Recommended host boundary:
- call
loadCardRaw()for untrusted raw JSON at import/ingest time - use
loadCard()only when the host has already parsed the payload - treat
validateRaw()andvalidate()as lower-level diagnostics APIs - treat low-level renderer internals such as
renderTree()as advanced APIs that assume prior validation - treat card-authored
stateand any host-provided runtimestateoverrides as untrusted inputs - treat final
assetsmap values as host-controlled inputs; card authors may reference@assets/..., but hosts decide the actual file/URL provenance and any origin restrictions - let
UGCRendererrevalidate the effective merged runtime state before rendering - if the host sets
hostOverflowto any value other thanhidden, the host must provide an outer wrapper (e.g.overflow-x: auto); otherwise overflow can leak to the page viewport
The validation pipeline enforces:
- external URL blocking for user-controlled asset fields
- style objects as a closed DSL, so unknown style keys are rejected instead of ignored
- path traversal checks for
@assets/... - CSS function restrictions such as
url(),var(),calc(),expression() - layout isolation rules like forbidding
position: fixedandposition: sticky - runtime-oriented limits for card size, node count, loop count, renderable text output, and effective style output using the merged state
- prototype-pollution protection in
$refpaths
UGCContainer adds renderer-side isolation with overflow: hidden, isolation: isolate,
contain: content, and position: relative, and containerStyle cannot override those keys. Hosts can override only the overflow key, and only via the hostOverflow prop.
- Node.js
>= 20.19.0 - pnpm
>= 9
pnpm install
pnpm build
pnpm test
pnpm test:run
pnpm test:coverage
pnpm release:pack-check
pnpm release:check
pnpm clean
pnpm --filter @safe-ugc-ui/schema build
pnpm --filter @safe-ugc-ui/demo devpackages/
types/ Zod schemas, inferred TS types, constants
schema/ JSON Schema generation and static schema artifact
validator/ Validation pipeline and diagnostic result types
react/ React renderer, components, asset/style/state helpers
demo/ Vite playground
Tests live alongside source as *.test.ts or *.test.tsx.
- Update
README.md,AGENTS.md, andCLAUDE.mdtogether when package versions, public APIs, commands, or workflow expectations change. - Releases are published by GitHub Actions via npm trusted publishing from
v*tags after a local clean-checkoutpnpm release:checkrehearsal passes. publish.ymlintentionally runs the release baseline on Node24even though local development and CI target Node20.19.0; this exception exists to satisfy npm trusted publishing requirements.- The actual publish step runs inside
publish.ymlviapnpm -r publish --access public --no-git-checks, not as a normal local maintainer command. pnpm release:pack-checkverifies packed tarballs before publish so exported entrypoints and generated artifacts are checked before npm sees them.- Treat
safe-ugc-ui-card-spec.mdas the source of truth for current card behavior. - Treat
safe-ugc-ui-spec-v0.3.mdas design history, not the current implementation contract.