Open Form

/create-theme

Turns your design system, a screenshot or a brief into a reference theme for new forms.

/create-theme builds a theme folder under themes/<id>/: a reference that new forms take their look from. Reach for it with "make it look like our product", a brand or a mood. /create-form hands off to it when the direction you picked needs a theme the project doesn't have. For a tweak to a single form, just ask for the tweak — it goes in that form's own forms/<id>/theme/.

Make our forms look like the product. The design tokens are in apps/web.

Sources

Any mix of:

  • The repo's design system — the most common. The agent looks before it asks: Tailwind config or a v4 @theme block, CSS variables (shadcn's :root and .dark), theme objects and token files, next/font calls and @font-face, logo fills and theme_color, and the shape of the product's own inputs and buttons.
  • Images — screenshots, a mood board, a brand guide.
  • A description — "dark, neon, arcade", "calm clinic", "Swiss poster".
  • A starter to adapt — "editorial, but with our colors".

If the source isn't clear, it asks once. When sources disagree, it asks which one wins. From images or a description, it gives the theme one distinctive move — a display heading font, a textured brand panel, an unusual control shape, or a composition that isn't a centered card, such as a full-page poster or a document sheet — taken from the audience's world rather than a generated default, and keeps everything else quiet. width follows that composition instead of staying at the default by habit.

Token mapping

shadcn and Tailwind token names don't line up with Open Form's. The skill carries this table, traps included:

Product tokenOpen Form tokenNote
--backgroundbackgroundThe page behind the form
--cardsurfaceThe form card
--foregroundforeground
--muted-foregroundmutedshadcn --muted is a background, not this
--borderborderDecorative edges only
--inputcontrolshadcn --input is a border color; Open Form input is the field background
--primary / --primary-foregroundprimary / primaryForeground
--destructiveerror
--ring—Focus uses primary
—successThe brand's green, or one that passes 3:1 on surface
--radiusradiusrounded-full buttons become buttonRadius: '999px'

Colors can be pasted as they are: hex, hsl() and oklch() all work. Tokens avoid var() and color-mix(), which the contrast check can't read.

What it writes

The agent starts from the closest starter, under the new id:

npx open-form theme add classic --as acme

classic for product UI, editorial for premium serif, terminal for developer tools, brutalist for loud campaigns, conversational for one question per screen. --as renames the [data-of-theme] selectors and the name in THEME.md; file names and class prefixes stay the starter's, so theme add editorial --as acme gives themes/acme/editorial.css with .ed-… classes.

Then it goes only as deep as the look needs:

  1. Tokens — colors, fonts, radius, spacing, width and layout. Most product themes stop here; pill buttons are just buttonRadius.
  2. Tokens and CSS — a stylesheet on the runtime's own markup for what tokens can't express, such as button weight, pill choices, the header or the card border, scoped to the theme.
  3. Components — only where the look needs markup or behavior the runtime's doesn't have: a branded layout, a wrapper for an animated underline, keyboard shortcuts. Forms take such a piece only when they use it.

Started from classic, the result looks like this:

themes/acme/
├── index.ts      # tokens: colors (light and dark), fonts, radius, spacing, layout
├── acme.css      # depth 2: chrome details tokens can't express
├── fonts/        # optional: web fonts, e.g. from `open-form theme font`
└── THEME.md      # mood, palette and every token, typography, what's CSS and what's a component, signature ideas, copy tone, do / don't

A theme with its own components uses index.tsx. Every color gets a dark value too, or dark: false for a light-only brand; a dark-only brand sets colorScheme: 'dark' and puts its dark palette in colors. Controls and buttons stay at least 44px tall.

A custom Layout, or CSS that changes .of-root's padding, keeps the top-right corner clear for the language picker translated forms show, at every width: the agent reserves the room or moves the picker. See Layouts and chrome.

THEME.md is what Studio shows on the theme's page and what /create-form reads when it compares themes. See Themes and defineTheme.

Fonts

The product's own font setup (next/font, the geist package) isn't available to forms. The agent writes a system stack with the brand font first, and ends every stack with var(--of-cjk) (or var(--of-cjk-serif)) before the generic family, so Chinese and Japanese text gets fonts that match the form's locale. When the font is central to the brand, or you ask, it downloads it into the theme with open-form theme font:

npx open-form theme font acme Inter   # → themes/acme/fonts/

When theme font can't reach Google (fetch failed), the agent checks which font hosts answer. If the CSS and font files are reachable and only the catalog isn't, it installs the family by hand into the same files; otherwise it uses a system stack with the same feel and gives you the exact command to run later.

For Chinese and Japanese forms it picks the CJK type as deliberately as the Latin type, one family per language scoped with :lang(), and keeps Latin-only styling (uppercase labels, italic help, wide letter-spacing) off CJK text.

It never links fonts.googleapis.com: that would send every respondent's IP address to Google, a problem under the GDPR. See Web fonts.

Checking it

npx open-form theme check acme  # WCAG contrast, light and dark
npm run typecheck               # tsc --noEmit: the theme's code compiles

Accessibility wins over brand for text and controls. When a brand color fails, the agent keeps the hue, lowers the lightness until it passes, and tells you the original and the new value. See open-form theme.

Contrast numbers don't show whether it looks right, so with a browser tool the agent also opens the theme's page in Studio (/studio/themes/<id>), takes screenshots in light and dark, and compares them with the source. Once the theme is in a translated form, it also checks at phone and tablet width that the language picker in the corner covers nothing. Without a browser tool, it tells you it hasn't seen the theme.

Applying it

The new theme is a reference, so creating it changes no form, and no form ever imports it. A new form starts from its tokens:

npx open-form create waitlist --theme acme

Then the agent writes the rest of that form's look in its own theme/ folder, taking from the reference only the CSS and components the form uses. For a form that already exists, it sets the tokens in the form's theme/ and keeps what the form already has, looking at it first. "Our forms" or "everything" means doing that for each form you mean; there's no project-wide theme. It reports the theme's folder, where the tokens came from, any contrast adjustments, and which forms now use it.

The folder in themes/ is yours from then on: edit it by hand, or ask the agent. The theme's page in Studio is read-only, but its Change this theme box turns what you describe into a prompt to paste to the agent. Those changes reach forms you start from it afterwards, never existing ones. See Themes.

On this page