# Mirio > Mirio is inbound for sites that coding agents build: the building blocks and automations for demo request, contact, waitlist, onboarding and quote forms, whose every response is screened, read and routed. The form lives in the site's own code: a typed schema file and the `@trymirio/mirio` React components (Next.js, Vite, Astro). Mirio's hosted backend handles everything after submit: validation, spam protection, storage that stays coherent as the form changes, partial saves for multi-step forms, AI triage (a one-line summary, a category and a fit for each response), where each visitor came from, and delivery to email, Slack, signed webhooks or the site's own Postgres. The agent writes the form. Mirio handles everything after submit. All the docs in one Markdown file: https://trymirio.com/llms-full.txt Install: `npm install @trymirio/mirio`. CLI: `npx @trymirio/mirio ` (or `npx mirio ` after install). Everything is reachable without a browser except two one-time steps a human does: claiming the project (a Google, GitHub or email sign-in) and connecting Slack ("Add to Slack"). Pricing: free during the beta, with every feature, up to 1,000 responses a month (https://trymirio.com/docs/limits.md). Site: https://trymirio.com. Questions: https://trymirio.com/contact. ## Get started - [Quickstart (agent happy path)](https://api.trymirio.com/docs/index.md): `npx @trymirio/mirio init --agent ` (adds the framework plugin and CSS; agents name themselves with `--agent`) → write schema → render form → dev server registers it → test → claim → `mirio env vercel`. - [When to use Mirio](https://api.trymirio.com/docs/when-to-use.md): what it's for, when another tool fits better, and how it compares to form endpoints and hosted builders. - [Claiming and the sandbox](https://api.trymirio.com/docs/claiming.md): why unclaimed projects only accept localhost, how to go live. - [CLI](https://api.trymirio.com/docs/cli.md): `init`, `sync`, `env`, `push`, `test`, `status`, `check`, `claim`, `submissions`, `destinations`, `domains`, `review`, all with `--json`. - [Changelog](https://api.trymirio.com/docs/changelog.md): what changed in each `@trymirio/mirio` release; read it when you upgrade. ## Build forms - [Form schema](https://api.trymirio.com/docs/schema.md): `defineForm`, field types, constraints, pages, `renamedFrom`, settings. - [Conditional logic](https://api.trymirio.com/docs/conditional-logic.md): `showWhen` on fields and pages. - [Attribution and hidden fields](https://api.trymirio.com/docs/attribution.md): the page that led to the form, the landing page, the site the visit came from, campaign tags; `` in the root layout; hidden fields from URL parameters. - [Components overview](https://api.trymirio.com/docs/components.md): `
` auto-render, composing fields, multi-step, headless primitives. - [Styling](https://api.trymirio.com/docs/styling.md): five layers: inherit the site (shadcn mapping), dark mode, tokens and `theme`, `size` and `layout`, `classNames`; Tailwind vs compiled CSS. - [Theming](https://api.trymirio.com/docs/theming.md): every `theme` key, sizes, `layout`, `classNames` slots, with a live playground. ## Components (one page each: usage, props, customization) - [Form](https://api.trymirio.com/docs/components/form.md), [Field](https://api.trymirio.com/docs/components/field.md), [Input](https://api.trymirio.com/docs/components/input.md), [Textarea](https://api.trymirio.com/docs/components/textarea.md), [Select](https://api.trymirio.com/docs/components/select.md), [Choice](https://api.trymirio.com/docs/components/choice.md), [YesNo](https://api.trymirio.com/docs/components/yes-no.md), [Rating](https://api.trymirio.com/docs/components/rating.md), [DatePicker](https://api.trymirio.com/docs/components/date-picker.md), [Consent](https://api.trymirio.com/docs/components/consent.md), [Submit](https://api.trymirio.com/docs/components/submit.md), [Success](https://api.trymirio.com/docs/components/success.md), [MultiStep, Page and Progress](https://api.trymirio.com/docs/components/multi-step.md), [Label, Help and FieldError](https://api.trymirio.com/docs/components/label-help-error.md), [MirioProvider](https://api.trymirio.com/docs/components/provider.md), [Headless hooks](https://api.trymirio.com/docs/components/headless.md) ## After submit - [Spam and validation](https://api.trymirio.com/docs/spam.md): what the server checks, what gets flagged, Cloudflare Turnstile with your own widget (`npx mirio turnstile set `), what is never dropped. - [Enrichment](https://api.trymirio.com/docs/enrichment.md): email signals, duplicates, velocity, company domain. - [Triage (AI review)](https://api.trymirio.com/docs/ai-review.md): summary, category, fit against the form's ideal response (`npx mirio review --ideal "…"`), stated facts and a spam second opinion per response; corrections; privacy. - [Partial submissions](https://api.trymirio.com/docs/partials.md): how multi-step progress is saved and shown. - [Destinations](https://api.trymirio.com/docs/destinations.md): Slack, your own Postgres or Supabase database, email, webhooks; `mirio destinations add`; category and fit filters for email and Slack. - [Webhooks and email](https://api.trymirio.com/docs/webhooks.md): payload, signature verification, retries, replay. - [Agent-native forms](https://api.trymirio.com/docs/agents.md): the `agents` policy, `schema.json`, submitting as a declared agent. ## Reference - [HTTP API](https://api.trymirio.com/docs/api.md): submission endpoint, project API, read API, error codes. - [Usage](https://api.trymirio.com/docs/limits.md): free during the beta, one plan with every feature; what is counted, the monthly limits and rate limits. ## Rules of thumb for agents 1. Code is the source of truth. Form structure lives in `mirio/forms/.ts`. The dashboard never edits it. 2. Forms register themselves: `withMirio` (next.config) or `mirio()` (vite/astro config) registers a form when its file is saved in the dev server and runs `mirio sync` during builds. Without a dev server, `npx mirio push`. Then `npx mirio test ` to confirm a round trip. 3. After the human claims the project, run `npx mirio env vercel` (or `netlify`, `print`) so deploy builds register forms. Without the key a build fails only when visitors would be rejected. 4. Never put passwords, card numbers or government ids in a form. Mirio rejects those fields at push. 5. Share the claim link with the human. Going live takes one sign-in. 6. Settings the team changes without a deploy live in Mirio, not the file: `npx mirio destinations add slack --form ` (prints an "Add to Slack" link for the human; or `email` for members, `webhook`, `postgres`), `npx mirio domains add example.com`, `npx mirio review --ideal "…"`, `npx mirio turnstile set ` (Cloudflare Turnstile with the site's own widget). 7. Forms accept declared agent submissions by default (`settings.agents: 'accept'`, labeled as agent). Read a form's `schema.json` before submitting as an agent. 8. `` in the root layout is optional: ask the human before adding it. It records which page led to the form and where the visit started. --- # Mirio quickstart Mirio puts a form on a site that a coding agent builds, and handles everything after submit. This page is the whole happy path: about two minutes, and no human is needed until the claim at the end. There is no push step to remember: the framework plugin registers forms while you develop and when the site builds. > **Started in the Mirio dashboard?** If you created the form under **New form** in Mirio, open the form's page and click **Copy prompt**. Give it to your coding agent: it starts with `npx @trymirio/mirio init --link `, which connects this repo to that site (already claimed, no sandbox) and lists the form files to write. Then continue at step 2. ## 1. Set up the project ```bash npx @trymirio/mirio init --agent npm install @trymirio/mirio ``` Agents: name yourselves with `--agent` (for example `claude-code`, `cursor`, `codex`), so the site's owner can see which agent set it up. **In a monorepo**, run `init` in the app's folder (the one with `next.config`, `vite.config` or `astro.config`); `mirio.json` lives there and every `mirio` command runs from there. Run it at a monorepo root and it stops, names the app folder, and leaves a link code unused. `init` creates a sandbox project and sets up the repo: - `mirio.json` (public project id), the secret key in `.mirio/secret` (gitignored), `mirio/client.ts`, `mirio/forms/`. - **The framework plugin**: wraps `next.config.*` in `withMirio(...)`, or adds `mirio()` to `vite.config.*` / `astro.config.*`. It works alongside other config wrappers (`withSentryConfig`, `withMDX` and the like) in any order. If the config has a shape it can't edit safely, it prints the two lines to add. - **The CSS**: next to `@import "tailwindcss"` in your global stylesheet it adds `@trymirio/mirio/theme.css` and the `@source` line, plus `@trymirio/mirio/themes/shadcn.css` on shadcn/ui sites that use shadcn's color variables (it checks `components.json` and that your CSS defines them; a site with its own tokens keeps Mirio's defaults). Without Tailwind it tells you to import `@trymirio/mirio/styles.css`. - A short "Mirio forms" section in `AGENTS.md` or `CLAUDE.md`. It prints a claim link. Keep it; the human needs it in step 5. Sandbox projects accept submissions from localhost only. ## 2. Write the schema `mirio/forms/waitlist.ts`: ```ts import { defineForm } from '@trymirio/mirio/schema'; export const waitlist = defineForm({ slug: 'waitlist', name: 'Waitlist', pages: [ { fields: [ { key: 'email', type: 'email', label: 'Work email', autocomplete: 'email', required: true }, { key: 'name', type: 'short_text', label: 'Name', autocomplete: 'name' }, { key: 'role', type: 'dropdown', label: 'Role', options: ['Founder', 'Engineer', 'Designer', 'Other'] }, { key: 'consent', type: 'consent', label: 'Email me when there is news', required: true }, ], }, ], settings: { success: { type: 'message', title: 'You are on the list', message: 'We will email you when your spot opens.' }, }, }); ``` See [Form schema](https://api.trymirio.com/docs/schema.md) for every field type and constraint. Notifications, allowed domains and triage are set in Mirio, not here (`npx mirio destinations add email you@company.com --all-forms`, `mirio domains`, `mirio review`). ## 3. Render it ```tsx // components/waitlist-form.tsx 'use client'; import { Form } from '@trymirio/mirio'; import { mirio } from '@/mirio/client'; import { waitlist } from '@/mirio/forms/waitlist'; export function WaitlistForm() { return ; } ``` Then use `` in any page. In Next.js (App Router) the form has to live in a client component like this one (`'use client'` at the top): `mirio`, the client, can't be passed from a server component. Pass it as `client`, or wrap the app once in [MirioProvider](https://api.trymirio.com/docs/components/provider.md); without either, `` throws and says so. `` with no children renders every field, validation, the submit button and the success state; multi-page schemas render one step at a time. It inherits your site's font and follows its dark mode. Adjust it with `theme`, `size`, `layout` and `classNames` ([Theming](https://api.trymirio.com/docs/theming.md)) or compose it yourself ([Components](https://api.trymirio.com/docs/components.md)). **Optional, so ask the site owner first:** `` (from `@trymirio/mirio`), added once to the root layout, lets each response say which page on the site led to the form and where the visit started. It keeps a small record of the visit in the visitor's browser tab (`sessionStorage`). It takes no props, so a server layout can render it. See [Attribution and hidden fields](https://api.trymirio.com/docs/attribution.md). If the site sets a Content Security Policy, add the Mirio API to `connect-src` (`https://api.trymirio.com`, and `http://localhost:4000` against a local API). ## 4. Run the dev server and test ```bash npm run dev npx mirio test waitlist ``` While the dev server runs, saving a file in `mirio/forms/` registers that form (one line in the terminal: `[mirio] waitlist: v2 registered (development)`, or the validation issues). `mirio test` sends a sample submission end to end and reads it back. Submit the form on localhost yourself too; `npx mirio submissions waitlist` shows what landed. No dev server (a script, CI)? `npx mirio push` does the same registration by hand. ## 5. Claim, then give the host the key Tell the human the form works locally and share the claim link (`npx mirio claim` prints it again). They sign in with Google, GitHub or a code sent to their email, accept the terms and name the production domains. Then, once: ```bash npx mirio env vercel # or: npx mirio env netlify, npx mirio env print ``` This puts `MIRIO_SECRET_KEY` into the host's build environment (on stdin, never echoed). From then on every deploy registers its forms during the build, tagged production or preview from the host's environment; production builds set the questions the dashboard shows. Without the key a build still checks: it **fails** only if visitors would be rejected (the project is unclaimed, or a form was never registered) and **warns** when questions changed since the last registration (those responses are accepted and flagged). `npx mirio sync` runs the same step in any other CI. ## What happens after submit - The server validates every submission against the registered schema and normalizes values (trimmed, lowercased emails, E.164 phone numbers). - Spam is scored (honeypot, time to submit, disposable emails, link stuffing, optional Turnstile) and goes to a spam view. Nothing is discarded. - Submissions are stored by stable field id, so renaming a field with `renamedFrom` keeps its data together. - Completed submissions go to verified email addresses, Slack (connect a channel with "Add to Slack", or `mirio destinations add slack --form ` for a link a person approves), your database and signed webhooks. - Multi-step progress is saved at each step as a partial submission. Partials never trigger notifications. --- --- title: When to use Mirio description: What Mirio is for, when another tool fits better, and how it compares to form endpoints, hosted builders and inbound tools. --- # When to use Mirio Mirio is inbound for sites that a coding agent builds and maintains, starting with the demo request form: the building blocks and automations an agent needs so every request is read in seconds and the good ones answered in minutes. The form lives in the site's own code, rendered with the `@trymirio/mirio` React components; Mirio's backend handles everything after submit: validation, spam protection, versioned storage, partial responses, triage (summary, category, fit), and delivery to email, Slack, webhooks or your own database. ## Use Mirio when - An agent (Claude Code, Cursor, Codex, v0 and similar) builds or edits the site, and you want it to add a working form without a person setting up a backend. - You want every demo request read, sorted by fit and in Slack within seconds, without setting up sales operations first. - The form should look like the rest of the site: it inherits the site's fonts and shadcn theme, and every piece can be restyled in code. - You need what happens after submit to be reliable: server-side validation, spam filtering, retries, and data that stays consistent when the questions change. - You want responses in one place your team can act on (an inbox per form, sorted by triage), and optionally in your own database or Slack. - A multi-step form should save progress, so you can see where people drop off. ## Use something else when - **Your app already has a database and an API for this data.** Write the form against your own backend. - **You want a hosted form link with no site of your own.** A hosted builder such as Tally or Typeform fits better. - **You need a research survey** with panels, quotas and analysis. Use a survey tool. - **You run a large sales team with territories and Salesforce routing.** An inbound platform such as Default or Chili Piper fits better; Mirio's webhooks and database destination can still feed it. - **You need payments, file uploads or e-signatures in the form today.** Mirio doesn't do these yet. - **The site isn't built with React.** Plain HTML posting and Vue and Svelte components are on the roadmap. ## How it compares | | Mirio | Form endpoints (Formspree, Basin) | Hosted builders (Tally, Typeform) | Inbound platforms (Default, Chili Piper) | | --- | --- | --- | --- | --- | | Where the form lives | In your site's code | In your site's code | On the builder's page or an embed | Their form, or yours connected to their script | | Who builds it | Your agent, from a typed schema file | You write the HTML | You, in the builder's editor | Your team, in their form builder or your code | | Components | Styled and headless React components that match your site | None; bring your own markup | The builder's own design | Their form builder's, or bring your own | | Validation | Server-side against the registered schema | Basic | Built into the builder | In their form builder | | Questions change over time | Versioned; renamed fields keep their data | Fields are whatever gets posted | Managed in the editor | Managed in their app | | Multi-step progress saved | Yes | No | On some plans | Abandoned-form follow-up on some plans | | After submit | Spam filtering, triage, inbox, email, Slack, webhooks, your database | Email, webhooks, integrations | Results view, integrations | Enrichment, qualification, routing to reps, booking, CRM sync | | Agent setup | One command with no account; a person signs in once to go live | Sign up first, then paste an endpoint | Sign up, build in the editor | A sales-led contract, then workflows set up in their app | ## Start ```bash npx @trymirio/mirio init --agent ``` Then follow the [quickstart](https://api.trymirio.com/docs/index.md). --- # Claiming and the sandbox `mirio init` needs no account, so an agent can go from a prompt to a tested form on localhost without a human. The trade-off is that anonymous projects must not collect real people's data. Unclaimed projects therefore run in a sandbox. ## Sandbox rules - Submissions are accepted only from localhost origins (`localhost`, `127.0.0.1`, `*.localhost`, any port). A public origin gets HTTP 403 with code `project_unclaimed` and the claim link in the hint. - Requests made with the project's secret key (the CLI, server code) are accepted without an origin check, because they are the owner's own test traffic. - Sandbox data expires after 72 hours. Read it with `mirio submissions`. - No emails or webhooks are sent from a sandbox project. - `mirio status` and `mirio push` warn; `mirio check` fails a production build. ## Claiming The claim link looks like `https://trymirio.com/claim/`. A human opens it, signs in with Google, GitHub or a code sent to their email, picks or creates a workspace, accepts the terms, and enters the production domains (one per line; `example.com`, `https://app.example.com`, or `*.example.com`). The project becomes live and starts accepting submissions from those origins. The secret key does not change. Claiming gives every live form an identified owner who has accepted the terms, which matters under GDPR and for abuse response. ## Content Security Policy Sites with a CSP must list the API in `connect-src`: `https://api.trymirio.com` in production and your local API URL in development. Without it the browser refuses the request before it reaches Mirio. ## Connecting a site created in the dashboard A site can also start in the dashboard (sign up at [trymirio.com/signup](https://trymirio.com/signup)): **New form** creates the site (already yours, no claim step) and a draft form. The draft's page has **Copy prompt**: it holds a one-time setup command for the coding agent, ```bash npx @trymirio/mirio init --link mil_XXXXX-XXXXX-XXXXX-XXXXX ``` which writes `mirio.json` and `.mirio/secret` for that site and lists the drafts waiting for their files. The agent then writes `mirio/forms/.ts`; the first registration turns the draft into a live form and keeps the notifications and triage settings you already set up. Codes work once and expire after 24 hours; copy the prompt again for a new one. To move a site to another repo, open the site under **Sites** and use **Reconnect with a new key** (the old repo's key stops working). ## After claiming - Add more domains any time: `npx mirio domains add acme.com` (or the site's page under Sites in the dashboard); `acme.com` also covers `www.acme.com`, and the other way round. Domains belong to the site, not to a form. Localhost keeps working on a live site; those responses are stored as `test`. - The person who claims the site (or creates it in the dashboard) gets email notifications on every form, including forms added later, unless a form already has a destination. Add teammates to the workspace to notify them too (email goes to members only), or use Slack for shared channels. - `mirio status` shows `live` and `mirio check` passes. --- # CLI The CLI ships inside the `@trymirio/mirio` package. Run it with `npx @trymirio/mirio ` before the package is installed, or `npx mirio ` after. Every command accepts `--json` for machine-readable output on stdout. Errors say what went wrong and what to do next. Exit codes: 0 ok, 1 error, 2 `check` failed. Configuration: `mirio.json` at the repo root (`projectId`, `apiUrl`, `formsDir`), secret key in `.mirio/secret` (gitignored). `MIRIO_SECRET_KEY` and `MIRIO_API_URL` override both, which is what CI uses. ## `mirio init` Creates a sandbox project and sets up the repo. ```bash npx @trymirio/mirio init [--agent claude-code] [--name "Marketing site"] [--api-url https://api.trymirio.com] [--forms-dir mirio/forms] [--force] ``` Writes `mirio.json`, `.mirio/secret`, `mirio/client.ts`, `mirio/forms/`, adds `.mirio/` to `.gitignore`, and appends a short "Mirio forms" section to `AGENTS.md` and `CLAUDE.md` (whichever exist; creates `AGENTS.md` if neither). Prints the claim link. Refuses to overwrite an existing `mirio.json` without `--force`. Provisioning is rate limited per IP. In a monorepo, run it in the app's folder: at a monorepo root (no `package.json`, a workspace setup, or no React) with an app below it, it stops before doing anything (a link code stays unused) and names the app folders, Next.js and Astro apps first; `--force` sets up the current folder anyway. The install hint matches your package manager. `--agent ` tells Mirio which coding agent ran the command (agents should name themselves). Without it the CLI uses `MIRIO_AGENT`, then `AI_AGENT`, then `CLAUDECODE=1` (Claude Code); `init --link` sends it too. ### `mirio init --link ` Connects the repo to a site that was started in the Mirio dashboard (New form). The dashboard puts the command, with a one-time code, at the top of the prompt it gives you for the form; codes work once and expire after 24 hours. ```bash npx @trymirio/mirio init --link mil_XXXXX-XXXXX-XXXXX-XXXXX [--force] ``` It does everything `init` does (config, `.mirio/secret`, `mirio/client.ts`, the plugin, CSS and the AGENTS.md section) but uses the site created in the dashboard, which is already claimed, so there is no claim link. It prints `Connected to ` and the forms waiting for a file: ``` ✓ Connected to Acme marketing (prj_…) Forms waiting for a file: mirio/forms/waitlist.ts — Waitlist Template: waitlist mirio/forms/quote.ts — Quote request A short intake for people who want a custom quote: what they need built, rough budget… ``` Write each file with `defineForm` and the same slug; the dev server (or `mirio push`) registers it and the draft becomes the form. `--json` returns the project id and the drafts (never the key). An expired or used code asks you to copy a fresh prompt from the form's page; a site that is already connected to another repo returns the API's hint (copy `MIRIO_SECRET_KEY` from that repo, or create a reconnect code on the site's page under Sites). `--force` replaces an existing `mirio.json`. ### Setup edits Both forms of `init` also set up the site, unless you pass `--no-setup`: - **Framework plugin.** Next.js: wraps the default export of `next.config.(ts|mjs|js)` in `withMirio(...)` and adds the import (handles `export default nextConfig`, `export default { … }` and `module.exports = …`). Vite and React Router: adds `mirio()` to `plugins` in `vite.config.*`. Astro: adds `mirio()` to `vite.plugins` in `astro.config.*`. When the file has a shape it doesn't recognize, nothing is written and the two lines to add are printed. - **CSS.** With Tailwind v4, adds `@import "@trymirio/mirio/theme.css"` and the `@source` line after `@import "tailwindcss"` in the global stylesheet; on shadcn/ui sites (`components.json`, whose `tailwind.css` path is used) also `@import "@trymirio/mirio/themes/shadcn.css"`. With Tailwind v3 it adds `@trymirio/mirio/styles.css` (and `themes/shadcn-v3.css`) at the top. Without Tailwind it prints `import '@trymirio/mirio/styles.css'`. ## `mirio push [slug] [--env production|preview|development]` Loads every schema in the forms directory, validates it locally, and registers it. Prints the version number and the diff from the previous version. Warns while the project is unclaimed. With the framework plugin you rarely need it: the dev server registers a form when its file is saved, and builds run `sync`. The push is tagged with its source (see `sync`); from a laptop that is `development`, so it never changes the questions the dashboard shows for a live form. Pass `--env` to override. ``` ✓ waitlist v3 (new version) + role (dropdown) ~ name → full_name ⚠ Project is unclaimed. Public origins are rejected until it is claimed: https://trymirio.com/claim/… ``` ## `mirio test [slug]` Sends a sample submission built from the schema, with the secret key and a test flag, then reads it back. Test submissions are stored with status `test` and never count as responses or toward the response limit (triage still reviews them, at most 20 per form a day, and those reviews count toward the triage quota), and never trigger notifications. ## `mirio status` Project mode (sandbox or live), claim link, allowed origins, delivery destinations, drafts created in Mirio that are still "Waiting for a file: mirio/forms/.ts ()", and a table of forms with version, 7-day submissions, last submission, the agents policy, whether triage is on and whether an ideal response is set (read from Mirio). Warns when review is on without an ideal response (fit levels need one: `mirio review --ideal "…"`), prints one line per schema that still carries moved settings (`notifyEmails`, `allowedOrigins`, `review`), and flags forms whose local schema differs from what was registered. **Updates:** `status` and `sync` say when a newer `@trymirio/mirio` is on npm ("@trymirio/mirio 0.1.5 is out (you have 0.1.4). What changed: https://trymirio.com/docs/changelog · Upgrade: pnpm add @trymirio/mirio@latest"; `--json` has it as `update`). They ask the registry at most once a day (cached in `~/.cache/@trymirio/mirio`), give up after 1.5 seconds and never fail a command; the dev server's sync and CI builds don't check. `MIRIO_NO_UPDATE_CHECK=1` turns it off. See the [changelog](https://api.trymirio.com/docs/changelog.md). ## `mirio sync [--env production|preview|development]` The build step the framework plugin runs (`withMirio` during `next build`, `mirio()` during `vite build`); call it yourself in any other CI. - **With a key** (`MIRIO_SECRET_KEY`, or `.mirio/secret` locally): registers every form, tagged with its source. - **Without one**: checks the registered forms through the public schema endpoint. It **fails** (exit 2) only when visitors would be rejected: the project is unclaimed, or a form is not registered yet (a new form, or a draft created in Mirio whose file has not been registered). It **warns** when a form's questions changed since the registered version (those responses are accepted and flagged). An unclaimed project fails builds on a deploy host or in CI and only warns on a local build. If Mirio can't be reached, the build continues with a warning. The source comes from the host: Vercel (`VERCEL_ENV`, `VERCEL_GIT_COMMIT_REF`, `VERCEL_GIT_COMMIT_SHA`, `VERCEL_URL`), Netlify (`CONTEXT`, `BRANCH`, `COMMIT_REF`, `DEPLOY_URL`), Cloudflare Pages (`CF_PAGES_BRANCH`, `CF_PAGES_COMMIT_SHA`, `CF_PAGES_URL`; production on `MIRIO_PRODUCTION_BRANCH` or `main`), GitHub Actions (`GITHUB_REF_NAME`, `GITHUB_SHA`; same branch rule), Render (`RENDER`, `IS_PULL_REQUEST`, `RENDER_GIT_BRANCH`, `RENDER_GIT_COMMIT`), Railway (`RAILWAY_ENVIRONMENT_NAME`, `RAILWAY_GIT_BRANCH`, `RAILWAY_GIT_COMMIT_SHA`); GitLab, CircleCI, Buildkite, Jenkins, Bitbucket Pipelines, Azure Pipelines and Travis by their branch and pull/merge request variables (production on `MIRIO_PRODUCTION_BRANCH`, else the provider's default branch when it says, else `main`; preview otherwise); and any other build server that sets `CI` (production). `MIRIO_ENV` overrides. A build with no host signal and no `CI` is development, a local `next build` included, so building a branch on your laptop never changes production; if your build server doesn't set `CI`, set `MIRIO_ENV=production` (the build output says so). Production registrations set the questions the dashboard shows; preview and development ones never do. ``` mirio sync production (vercel, main@1a2b3c4) · 3 forms · registering ✓ demo-request v4 registered ✓ waitlist v2 unchanged ``` ## `mirio env vercel|netlify|print` Puts the project's secret key into the host so deploy builds can register forms. - `vercel`: `vercel env add MIRIO_SECRET_KEY production` and `… preview`, with the key on stdin. Needs the Vercel CLI, logged in and `vercel link`ed. - `netlify`: `netlify env:set MIRIO_SECRET_KEY … --context production` and `deploy-preview`. Needs the Netlify CLI, logged in and `netlify link`ed. - `print`: prints `MIRIO_SECRET_KEY=sk_…` to your terminal so you can paste it into any host's settings. Mirio never prints the key for `vercel` and `netlify`, and redacts it from the host CLI's output. When the host CLI is missing, not logged in, not linked, or the variable already exists, the error says what to run. ## `mirio check [--allow-sandbox]` A strict local check. Exits 2 when the project is unclaimed or any local schema differs from the registered one (or was never registered). Builds don't need it any more: the framework plugin runs `sync`, which fails only when visitors would be rejected. ## `mirio claim [--open] [--wait]` Prints the claim link. `--open` opens it in a browser. `--wait` polls until the project is live. ## `mirio submissions [slug] [--status all|complete|partial|spam|test] [--limit 20]` Reads submissions through the project API, with each response's kind (complete, partial, spam, test), when it came in and its first answers. For sandbox projects this is the agent's own test data and expires after 72 hours. ## `mirio destinations …` Wire delivery without the dashboard. Destinations belong to a form: pass `--form `, or `--all-forms` to create one per form. ```bash npx mirio destinations list npx mirio destinations add slack --form demo-request # "Add to Slack": prints a link for a person, waits for the channel npx mirio destinations add slack https://hooks.slack.com/services/… --all-forms npx mirio destinations add postgres "postgres://…pooler.supabase.com:6543/postgres" [--table demo_requests] --form demo-request npx mirio destinations add email founders@example.com --form demo-request # workspace members only npx mirio destinations add email sales@example.com --form demo-request --categories "Sales lead,Partnership" --fit high,medium npx mirio destinations add slack https://hooks.slack.com/services/… --form demo-request --fit high npx mirio destinations add slack https://hooks.slack.com/services/… --form demo-request --group "Urgency: This week" npx mirio destinations add webhook https://example.com/hooks/mirio --form demo-request # prints the signing secret once npx mirio destinations remove dst_… npx mirio destinations test dst_… ``` **Slack without a URL** prints a 30-minute link to "Add to Slack" for a person in the site's workspace (an agent can't click through Slack's install screen) and waits up to 10 minutes for the channel to appear; `--no-wait` prints the link and exits, and `--json` returns `{ connectUrl, expiresAt, destinations }` without waiting. Claimed sites only. Webhook URLs must be public `https://` addresses, and databases public hosts: Mirio refuses localhost, private ranges and internal names (see [Destinations](https://api.trymirio.com/docs/destinations.md)). Email goes only to workspace members (no verification step); for shared inboxes and aliases use Slack. Adding a non-member address fails with the API's hint. Connection strings are encrypted at rest and never printed back. See [Destinations](https://api.trymirio.com/docs/destinations.md). `--categories` (comma-separated, matching the form's review categories) and `--fit` (`high`, `medium`, `low`) filter what an **email or Slack** destination sends, using the [AI review](https://api.trymirio.com/docs/ai-review.md). Responses without a review (review off, or the review failed) are always sent. The flags are rejected for webhooks and Postgres, which receive every response with the review attached. `destinations list` shows each filter in plain words ("Sales lead or Partnership, high or medium fit"); `--json` includes the `filter` object. ## `mirio domains list|add |remove ` The site's production domains (allowed origins). Visitors from them can submit once the project is claimed; localhost always works. Accepts `example.com`, `www.example.com`, `*.example.com` or a full origin with a port. A domain and its `www.` form count as one site: adding either allows both (most sites redirect one to the other). API: `GET/POST/DELETE /v1/project/domains`. ```bash npx mirio domains add example.com npx mirio domains list npx mirio domains remove staging.example.com ``` ## `mirio review [--ideal "…"] [--summary "…"] [--categories "A,B"] [--group "Name: A, B"] [--remove-group "Name"] [--off|--on]` Triage for one form. With no flags it shows the settings; flags change them and keep the rest (so `--ideal` alone keeps the categories). The ideal response describes a great respondent in plain words and enables fit levels; `--summary` says what each summary should focus on (3 to 500 characters); categories are 2 to 12 labels; `--group "Urgency: This week, Later"` adds a group triage sorts into, or replaces the one with that name (repeatable, up to 5), and `--remove-group` drops one. API: `GET/PUT /v1/project/forms/:slug/review`. See [AI review](https://api.trymirio.com/docs/ai-review.md). ```bash npx mirio review demo-request --ideal "Product or research leads at 20–500 person software companies who want to start this quarter." ``` ## `mirio turnstile [set [--site-key ]|remove]` [Cloudflare Turnstile](https://api.trymirio.com/docs/spam.md#turnstile) with your own widget. `set` gives Mirio the widget's secret key so it can check visitors' tokens: the site key comes from `settings.turnstileSiteKey` in your schema files (`--site-key` when they use more than one), Mirio checks the secret key with Cloudflare, and it is stored encrypted and never printed back. With no action it lists the forms that set a site key and whether their tokens are checked; `remove` deletes the secret key. API: `GET/PUT/DELETE /v1/project/turnstile`. ```bash npx mirio turnstile set 0x4AAAAAAA… npx mirio turnstile ``` --- # Form schema Each form is a TypeScript file in `mirio/forms/.ts` that exports the result of `defineForm`. `defineForm` validates the schema at module load, so a bad schema fails fast in the site's own build and in `mirio push`. ```ts import { defineForm } from '@trymirio/mirio/schema'; export const contact = defineForm({ slug: 'contact', // lowercase letters, numbers, dashes; stable; used in the URL name: 'Contact', description: 'Shown in the dashboard only', pages: [ /* one or more pages */ ], settings: { /* optional */ }, }); ``` ## Pages A form is a list of pages. A classic form has one page; a multi-step form has many. Pages are the unit for partial saves and step completion. ```ts pages: [ { key: 'about', title: 'About you', description: 'Takes a minute', fields: [ /* … */ ] }, { key: 'project', title: 'Your project', fields: [ /* … */ ] }, ] ``` `key`, `title` and `description` are optional. Every page needs at least one field. ## Fields Every field has a stable `key` (unique across all pages), a `type`, and a `label`. Optional on every field: `help`, `placeholder`, `required`, `renamedFrom`, `defaultValue`. | Type | Value stored | Constraints | | --- | --- | --- | | `short_text` | string, trimmed | `minLength`, `maxLength` (default 500), `pattern` (regex source) | | `long_text` | string, trimmed | `minLength`, `maxLength` (default 10000), `pattern` | | `email` | string, lowercased | | | `phone` | E.164 string, e.g. `+15555550123` | `defaultCountry` (ISO-2, default `US`) for numbers typed without a country code | | `url` | normalized URL; `https://` added when no scheme | | | `number` | number | `min`, `max`, `integer` | | `single_choice` | one option value | `options` (required) | | `multiple_choice` | array of option values | `options` (required), `minSelected`, `maxSelected` | | `dropdown` | one option value | `options` (required) | | `yes_no` | boolean | `options` (optional, exactly two: the labels shown for yes and no) | | `rating` | integer `min`..`max` | `min` (default 1, may be 0), `max` (default 5, 2 to 10) | | `date` | `YYYY-MM-DD` | `min`, `max` as ISO dates | | `consent` | boolean; `required` means it must be true | | | `hidden` | string; not rendered | `defaultValue` | Also on every field: `autocomplete` (the HTML autocomplete token: `name`, `given-name`, `email`, `tel`, `organization`, `url`), `prefill: { query: 'utm_term' }` to take the initial value from a URL parameter (hidden fields default to their own key; when the form's page lacks it, the visit's landing page is used, see [hidden fields](https://api.trymirio.com/docs/attribution.md#hidden-fields)), and `showWhen` for [conditional logic](https://api.trymirio.com/docs/conditional-logic.md). There is no separate name field. Use `short_text` with `autocomplete: 'name'` (or `given-name` and `family-name` for two fields); browsers then autofill it, and `schedule` success links pick it up. `options` accept strings or `{ value, label }` objects: ```ts { key: 'plan', type: 'dropdown', label: 'Plan', options: ['Free', { value: 'pro', label: 'Pro ($29/mo)' }] } ``` ### Renaming a field Keys are what the site code and the API use, but submissions are stored by a stable field id. Declare the old key so the data stays together: ```ts { key: 'full_name', type: 'short_text', label: 'Full name', renamedFrom: ['name'] } ``` The submissions table shows one column, and the version diff reports a rename rather than a removal plus an addition. ### Fields Mirio refuses Keys or labels that look like passwords, card numbers, CVV codes or social security numbers are rejected by `defineForm` and by `push`. Mirio is not a place for secrets. Use a payment provider for cards. ## Settings The schema file holds what the visitor sees and what the form accepts: ```ts settings: { success: { type: 'message', title: 'Thanks', message: 'We will be in touch.' }, // or: success: { type: 'redirect', url: 'https://example.com/thanks', prefill: { ref: 'email' } }, // or: success: { type: 'schedule', url: 'https://calendly.com/versive/demo' }, // Calendly or Cal.com, name and email prefilled submitLabel: 'Request a demo', agents: 'accept', // 'accept' (default; declared agents are labeled) | 'block' agentNotes: 'What this form is for and what good answers look like', turnstileSiteKey: '0x4AAAAAAA…', // optional: your Cloudflare Turnstile widget; Mirio also needs its secret key (see Spam) } ``` `schedule` sends the visitor to a Calendly or Cal.com page with `name`, `email` and any `utm_*` parameters filled in, so the demo is booked in the same flow. The visitor's browser runs this code, so `success` from the file is what they see. Settings are applied at registration and are not part of the schema version. Changing a label, option or constraint is; every distinct schema becomes a new version with a visible diff. `agents` also accepts the old `'allow'` and `'flag'`, both meaning `'accept'`. ### What lives in Mirio instead Everything the team changes without a deploy lives in Mirio (the dashboard, the CLI or the API), not in the file: | Setting | Where | | --- | --- | | Allowed domains (per site) | `npx mirio domains add example.com`, dashboard: Sites → the site → Domains | | Notifications and integrations (per form) | `npx mirio destinations add email\|slack\|webhook\|postgres … --form ` or `--all-forms`; email goes to workspace members only. See [Destinations](https://api.trymirio.com/docs/destinations.md). | | Triage: ideal response, summary, categories, on/off | `npx mirio review --ideal "…"`. See [AI review](https://api.trymirio.com/docs/ai-review.md). | | Turnstile secret key (per site) | `npx mirio turnstile set `. See [Turnstile](https://api.trymirio.com/docs/spam.md#turnstile). | `settings.notifyEmails`, `settings.allowedOrigins` and `settings.review` still validate for one release. The first push imports `notifyEmails` (member addresses only) and `review` when Mirio has nothing set for that form; per-form `allowedOrigins` are dropped (domains belong to the site). `mirio status` and the dev server print one line per schema that still has them; remove them from the file. ## Versions `mirio push` computes a hash of the canonical schema (pages, fields and their properties, not settings). A new hash becomes a new version. Submissions record the version they were made against. If a form is submitted with a hash that was never pushed, the submission is accepted, validated against the latest pushed version, flagged `unregistered_schema`, and the owner gets one email. Run `mirio push` to clear the flag for future submissions. --- # Conditional logic Show a field or a whole page only when earlier answers match. Conditions live in the schema and are evaluated by the components and by the server, so a hidden field is never required and never stored. ```ts { key: 'role', type: 'dropdown', label: 'Role', options: ['Founder', 'Researcher', 'Other'], required: true }, { key: 'role_other', type: 'short_text', label: 'Which role?', required: true, showWhen: { field: 'role', op: 'eq', value: 'Other' } }, ``` Page-level: ```ts { title: 'Your team', showWhen: { field: 'role', op: 'in', value: ['Founder', 'Researcher'] }, fields: [ … ] } ``` Groups: ```ts showWhen: { all: [ { field: 'team_size', op: 'gte', value: 10 }, { any: [ { field: 'interest', op: 'contains', value: 'surveys' }, { field: 'timeline', op: 'neq', value: 'Just looking' } ] } ] } ``` Operators: `eq`, `neq`, `in`, `notIn`, `contains`, `notContains` (strings and multiple choice), `gt`, `gte`, `lt`, `lte` (numbers, dates), `empty`, `notEmpty`. Rules: - A condition may reference fields on the same page or earlier pages, never later ones. `defineForm` checks this. - A page with no visible fields is skipped; the progress indicator counts visible pages only. - Hidden values are dropped before a partial save or a submit, and the server drops them again. - Conditions are part of the schema version, so changing one creates a new version. Explicit jumps and calculated fields are not supported. If `showWhen` turns out not to be enough, [tell us](https://trymirio.com/contact). --- # Attribution and hidden fields Every response records where the person came from, so a form linked from twenty pages tells you which page sent them, and a campaign isn't lost when the visitor browses before filling it in: | | Stored as | Example | | --- | --- | --- | | The page with the form (origin and path, no query string) | `meta.pageUrl` | `https://acme.com/demo` | | The page on your site that led to the form, or the other site that linked straight to it | `meta.referrer` | `/pricing`, or `https://news.ycombinator.com` | | The first page of the visit, when it isn't the form's page | `meta.landingPage` | `/blog/launch` | | The other site the visit arrived from, when the form's page wasn't the first | `meta.entryReferrer` | `https://www.google.com` | | Campaign tags from the form's page, else from the landing page | `meta.utm` | `{ "source": "google", "medium": "cpc" }` | It shows on each response (Came from, Landing page, Visit came from), in Slack and email notifications ("From /pricing · via google / cpc · landed on /blog/launch"), in Insights (Where people come from, Pages that led here), in the CSV and API exports (`page_url`, `referrer`, `landing_page`, `entry_referrer`, `utm_*`), and in webhooks and database rows (`meta`). ## Record it on every page (optional) A page with a form records it by itself. To also know the pages a visitor saw *before* reaching the form (which page sent them, where the visit started), add `MirioAttribution` once to the root layout. It's optional: a choice for the site owner, since it runs on every page. It takes no props and renders nothing, so a Next.js server layout can render it: ```tsx // app/layout.tsx import { MirioAttribution } from '@trymirio/mirio'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` Outside React, call `startAttribution()` from `@trymirio/mirio/client` once per page load. Without either, responses still carry the form's own page, the site that linked straight to it and its campaign tags. ## Privacy - Other sites are recorded as their origin only (`https://www.google.com`), never their path or query, which can hold search terms or tokens. Pages on your site are recorded as paths, without query strings. Mirio's API trims whatever a client sends to this, including older versions that send the full `document.referrer`. - The visit record lives in the visitor's own `sessionStorage` for that tab, until the tab closes. It's sent only with a submission. No cookies, nothing shared across sites. - Turn it off with `createClient({ projectId, attribution: false })` in `mirio/client.ts`: responses then carry only what the form's page knows. - **Consent.** In the EU and UK, keeping data in a visitor's browser and sending it later can need consent under the ePrivacy rules, even without cookies, when it isn't strictly necessary for the form. The site owner decides. If your site asks for consent for analytics, treat attribution the same way: pass `attribution: false` until the visitor agrees, or leave it off. Mention Mirio in your privacy notice either way ([what Mirio stores](https://trymirio.com/legal/privacy#3-responses-we-store-for-customers)). ## Hidden fields A `hidden` field isn't shown; its value comes from the URL, like Typeform's hidden fields. Link to `/demo?plan=pro&partner=acme` and: ```ts fields: [ { key: 'plan', type: 'hidden', label: 'Plan' }, // ?plan=… { key: 'partner', type: 'hidden', label: 'Partner', prefill: { query: 'partner' } }, // any parameter name { key: 'source', type: 'hidden', label: 'Source', defaultValue: 'website' }, // a fixed value ] ``` A hidden field reads the parameter named after its key, or `prefill.query`. When the form's page doesn't have the parameter, it reads it from the landing page of the visit, so `acme.com/?plan=pro` → `/pricing` → `/demo` still fills `plan` (with `MirioAttribution` in the layout). `prefill.query` works on visible fields too, and `initialValues` on `` sets values from code (it wins over the URL). Hidden values are stored and delivered like any answer. --- # Components `@trymirio/mirio` has two layers. **Headless primitives** (`@trymirio/mirio/headless`) carry behavior and accessibility with no styling: form state, validation, conditional logic, page navigation, partial saves, focus management, labels and aria. **Styled components** (`@trymirio/mirio`) sit on top and look finished out of the box, authored in Tailwind and themed with `--mirio-*` CSS variables. Both are replaceable: restyle with the `theme`, `size`, `layout` and `classNames` props, variables or classes, or start from the primitives. ## Zero-markup form ```tsx import { Form } from '@trymirio/mirio'; import { mirio } from '@/mirio/client'; import { waitlist } from '@/mirio/forms/waitlist'; ``` With no children, `Form` renders every page from the schema: one page gives you the fields and a submit button; several pages give you `MultiStep` with a progress line, Back and Next. After submit it shows the `settings.success` message, or redirects. Wrap the app once in [MirioProvider](https://api.trymirio.com/docs/components/provider.md) to drop the `client` prop. ## Composing Children replace the automatic layout. `Field` renders any field by key (label, help, the right control, error); the controls, `Label`, `Help`, `FieldError`, `Submit`, `Page`, `Progress` and `MultiStep` can be arranged freely, mixed with your own JSX. ```tsx import { Form, Field, Submit } from '@trymirio/mirio';
``` Every styled component accepts `className` (merged with tailwind-merge, so your utilities win) and renders `data-mirio=""` for CSS. Hidden fields and pages (`showWhen`) render nothing. See [Styling](https://api.trymirio.com/docs/styling.md) for the three depths of customization. ## All components | Component | One line | | --- | --- | | [Form](https://api.trymirio.com/docs/components/form.md) | The whole form from a schema; auto-renders or takes children. | | [Field](https://api.trymirio.com/docs/components/field.md) | Label + help + the control for the field type + error, from one `name`. | | [Input](https://api.trymirio.com/docs/components/input.md) | Single-line control for `short_text`, `email`, `phone`, `url`, `number`. | | [Textarea](https://api.trymirio.com/docs/components/textarea.md) | Multi-line control for `long_text`. | | [Select](https://api.trymirio.com/docs/components/select.md) | Styled native select for `dropdown`. | | [Choice](https://api.trymirio.com/docs/components/choice.md) | Radio group (`single_choice`) or checkbox group (`multiple_choice`). | | [YesNo](https://api.trymirio.com/docs/components/yes-no.md) | Two segmented buttons with radio semantics for `yes_no`. | | [Rating](https://api.trymirio.com/docs/components/rating.md) | 1..max buttons with radio semantics and arrow keys for `rating`. | | [DatePicker](https://api.trymirio.com/docs/components/date-picker.md) | Styled native date input for `date`. | | [Consent](https://api.trymirio.com/docs/components/consent.md) | Checkbox with inline label for `consent`. | | [Submit](https://api.trymirio.com/docs/components/submit.md) | Submit button with the label from `settings.submitLabel` and a loading state. | | [Success](https://api.trymirio.com/docs/components/success.md) | The thank-you card from `settings.success`. | | [MultiStep](https://api.trymirio.com/docs/components/multi-step.md) | One page at a time with Progress, Back/Next and Submit; also covers `Page` and `Progress`. | | [Label, Help, FieldError](https://api.trymirio.com/docs/components/label-help-error.md) | The text pieces around a control, and the `requiredIndicator` option. | | [MirioProvider](https://api.trymirio.com/docs/components/provider.md) | Provide the client and display options once. | | [Headless](https://api.trymirio.com/docs/components/headless.md) | `useMirioForm`, `FormProvider`, `useForm`, `useField`, `usePage`, `useFormStatus`, `Honeypot`, `Turnstile`, with a reference table of everything the hook returns. | ## Behavior you get for free - Client validation from the schema, with the same validator the server runs; format errors on blur, "required" on submit; focus moves to the first invalid control. - Submit with an idempotency key and retries on network errors and 5xx; one logical submit never lands twice. - Page URL, referrer and UTM parameters captured; a honeypot and time-to-submit as spam signals; Turnstile when a site key is set (Mirio checks it once it has the widget's secret key); agents that declare themselves (`actor`) are labeled. - Partial saves when a visitor moves to the next step, never on keystrokes. - Prefill from `initialValues`, `prefill: { query }` and `defaultValue`; `autocomplete` attributes from the schema. - Conditional fields and pages ([Conditional logic](https://api.trymirio.com/docs/conditional-logic.md)), and `schedule` successes that land on a booking page with name and email filled in. --- # Styling The styled components are authored in Tailwind v4 and read `--mirio-*` CSS variables and two `data-mirio-*` attributes, so the Tailwind source and the compiled CSS look and behave the same. There are five layers, from "do nothing" to "own every class". Most sites stop at the first or second. The interactive [Theming](https://api.trymirio.com/docs/theming.md) page shows each knob live. ## Import the CSS (once) `mirio init` does this for you. By hand: **Tailwind v4.** Your Tailwind build generates the utilities the components use, so your own classes override theirs normally: ```css /* app/globals.css */ @import "tailwindcss"; @import "@trymirio/mirio/theme.css"; @import "@trymirio/mirio/themes/shadcn.css"; /* only on shadcn/ui sites */ @source "../node_modules/@trymirio/mirio/dist"; ``` **Anything else** (Tailwind v3, plain CSS, CSS modules). Import the compiled stylesheet. It is scoped under `[data-mirio-root]` (the `Form` root), so it does not touch the rest of the page and has no global reset. On a shadcn/ui site with Tailwind v3, add `@trymirio/mirio/themes/shadcn-v3.css` after it. ```ts import '@trymirio/mirio/styles.css'; ``` ## 1. Inherit the site The form uses your page's font (`--mirio-font: inherit`). On shadcn/ui sites, `themes/shadcn.css` maps every `--mirio-*` token to shadcn's variables (`--primary`, `--primary-foreground`, `--background`, `--foreground`, `--muted`, `--muted-foreground`, `--input`, `--ring`, `--destructive`, `--radius`), so the form matches your buttons and inputs and changes with your theme. `themes/shadcn-v3.css` does the same for the HSL-triplet style (`hsl(var(--primary))`). ## 2. Dark mode follows the site The built-in dark values apply when the form sits under a `.dark` or `[data-theme="dark"]` ancestor, which is what next-themes and most Tailwind setups toggle. To force it on one form, pass `theme={{ colorScheme: 'dark' }}` (`'light'` forces light, `'auto'` follows the visitor's OS). A form is never dark because of the OS alone unless you ask for `'auto'`. Override the dark values with your own rule: ```css .dark { --mirio-primary: #a5b4fc; --mirio-primary-fg: #1e1b4b; } ``` ## 3. Tokens and the `theme` prop Every color, the radius, font, sizes and spacing are CSS variables. Set them on `:root`, on `.dark`, or on any wrapper: ```css :root { --mirio-primary: #111827; --mirio-primary-fg: #ffffff; --mirio-bg: #ffffff; --mirio-fg: #111827; --mirio-muted: #f3f4f6; --mirio-muted-fg: #6b7280; --mirio-border: #e5e7eb; --mirio-ring: #2563eb; --mirio-error: #dc2626; --mirio-input-bg: #ffffff; --mirio-radius: 0.5rem; --mirio-font: inherit; --mirio-control-h: 2.5rem; --mirio-text: 15px; --mirio-border-width: 1px; --mirio-label-weight: 500; --mirio-space: 1.25rem; /* between fields */ --mirio-gap: 0.5rem; /* inside a field */ } ``` Or pass `theme` to one form (or to `MirioProvider` for all of them; the form's keys win). It becomes inline variables on the form root: ```tsx
``` ## 4. `size` and `layout` `size="sm" | "md" | "lg"` swaps one bundle of tokens (control height, text size, spacing) through `data-mirio-size`. `layout={{ columns: 2 }}` lays the auto-rendered fields (and `Page`) out in a grid; long text, choices, consent and keys listed in `layout.full` span every column, and everything is one column below 640px. ```tsx ``` ## 5. `classNames` slots, `className`, and owning the source `classNames` adds classes to every instance of a part: `root`, `form`, `page`, `pageHeader`, `field`, `label`, `help`, `input` (Input, Textarea, Select, DatePicker), `option` (choice rows), `segment` (YesNo), `rating`, `consent`, `error`, `submit`, `button` (Back, Next), `progress`, `success`. On `MirioProvider` they apply to every form (both are kept, the form's after the provider's). They are merged with tailwind-merge before each component's own `className`, so a conflicting utility on the component wins: ```tsx {/* composed: beats classNames.submit and the default */} ``` Plain CSS can target the `data-mirio` hooks (`[data-mirio="input"]`, `[data-mirio="submit"]`, …) and `[data-mirio-size]`, `[data-mirio-color-scheme]`. To change the markup itself, start from `@trymirio/mirio/headless` with your own elements ([Headless](https://api.trymirio.com/docs/components/headless.md)); the styled components are built on the same hooks. --- --- title: Theming description: Try theme, size, layout, colorScheme and classNames live, and copy the props. preview: theme-playground --- # Theming Every knob on this page writes props on `` (or `MirioProvider`); nothing here needs a stylesheet beyond the one import from [Styling](https://api.trymirio.com/docs/styling.md). Change the controls and copy the code. ## Preview ## Usage ```tsx import { Form } from '@trymirio/mirio'; ``` Set the same `theme`, `size` and `classNames` once for the whole site on `MirioProvider`; a form's own props win (theme per key, classNames per slot with both kept). ## Props ### `theme` | Key | Variable | Description | | --- | --- | --- | | `primary` | `--mirio-primary` | Submit button, selected rating, progress bar, checked boxes and radios. | | `primaryForeground` | `--mirio-primary-fg` | Text on primary, and the check or dot in a checked box. | | `background` | `--mirio-bg` (and `--mirio-input-bg` unless `inputBackground` is set) | Surfaces inside the form. | | `foreground` | `--mirio-fg` | Text. | | `muted` | `--mirio-muted` | Hover, segmented track, success card. | | `mutedForeground` | `--mirio-muted-fg` | Help text, placeholders, progress text. | | `border` | `--mirio-border` | Control and card borders, unchecked boxes. | | `ring` | `--mirio-ring` | Focus ring, checked choice border. | | `error` | `--mirio-error` | Error text, invalid borders, required mark. | | `inputBackground` | `--mirio-input-bg` | Control background, unchecked boxes. | | `radius` | `--mirio-radius` | `'none'` 0, `'sm'` 0.25rem, `'md'` 0.5rem, `'lg'` 0.75rem, `'full'` 9999px, or any CSS length. | | `font` | `--mirio-font` | Default `inherit`. | | `colorScheme` | `data-mirio-color-scheme` | `'dark'` forces the built-in dark values, `'light'` forces light, `'auto'` follows `prefers-color-scheme`. Unset: follow a `.dark` / `[data-theme="dark"]` ancestor. | Values are applied as inline variables on the form root, so they beat stylesheet values, including the dark ones. ### `size` | Size | `--mirio-control-h` | `--mirio-text` | `--mirio-space` | `--mirio-gap` | | --- | --- | --- | --- | --- | | `sm` | 2.25rem | 14px | 1rem | 0.5rem | | `md` (default) | 2.5rem | 15px | 1.25rem | 0.5rem | | `lg` | 3rem | 16px | 1.5rem | 0.625rem | Rendered as `data-mirio-size` on the root. `md` declares nothing, so your own `:root` values for these tokens keep working. ### `layout` (Form only) | Key | Type | Description | | --- | --- | --- | | `columns` | `1 \| 2 \| 3` | Grid for the auto-rendered fields and `Page`. One column below 640px. | | `full` | `string[]` | Field keys that span every column. `long_text`, `single_choice`, `multiple_choice` and `consent` always do; so does the page header. | ### `classNames` Slots: `root`, `form`, `page`, `pageHeader`, `field`, `label`, `help`, `input` (Input, Textarea, Select, DatePicker), `option` (each choice row), `segment` (each YesNo button), `rating` (each rating button), `consent`, `error` (FieldError), `submit`, `button` (Back and Next), `progress`, `success`. Merged with tailwind-merge in this order: the component's defaults, the provider's slot, the form's slot, then the component's own `className`. ## Variants and states ```tsx ``` ```tsx
{/* or theme={{ colorScheme: 'dark' }} on the form */}
``` ## Customization For anything the props don't cover, use the variables directly (`:root`, `.dark`, or a wrapper), the `data-mirio` hooks from plain CSS, or the [headless](https://api.trymirio.com/docs/components/headless.md) layer. See [Styling](https://api.trymirio.com/docs/styling.md) for the five layers. ## Accessibility Keep contrast when you change colors: `primaryForeground` on `primary` and `foreground` on `background` should reach 4.5:1. Focus rings use `ring`; don't set it to the background color. `colorScheme` also sets the CSS `color-scheme`, so native controls (date picker, scrollbars) match. ## Related [Styling](https://api.trymirio.com/docs/styling.md), [Form](https://api.trymirio.com/docs/components/form.md), [MirioProvider](https://api.trymirio.com/docs/components/provider.md), [Components](https://api.trymirio.com/docs/components.md) --- --- title: Form description: The whole form from a schema: fields, submit, inline errors and the success state, with zero markup. preview: form --- # Form `Form` takes a schema and a client and renders a finished form. With no children it renders every page itself; with children you compose the layout from the other components. ## Preview ## Usage ```tsx import { Form } from '@trymirio/mirio'; import { mirio } from '@/mirio/client'; import { waitlist } from '@/mirio/forms/waitlist'; export function Waitlist() { return ; } ``` One page renders the fields and a [Submit](https://api.trymirio.com/docs/components/submit.md) button. Several pages render [MultiStep](https://api.trymirio.com/docs/components/multi-step.md). After a successful submit the form is replaced by [Success](https://api.trymirio.com/docs/components/success.md), unless `settings.success` is a redirect or schedule, in which case the browser navigates. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `schema` | `FormSchema` | required | The schema from `defineForm`. | | `client` | `MirioClient` | nearest `MirioProvider` | From `createClient` (`mirio/client.ts`). Throws a descriptive error when neither is present. | | `onSuccess` | `(result: SubmissionResponse) => void` | — | Called with `{ id, status, success?, flags? }`. | | `onError` | `(error: MirioError) => void` | — | Called with `{ code, message, hint?, fields?, status }`. | | `initialValues` | `Record` | — | Programmatic prefill, keyed by field key. Wins over `prefill.query` and `defaultValue`. | | `children` | `ReactNode` | auto-render | Replaces the automatic layout. Use `Field`, `Submit`, `MultiStep` etc. inside. | | `showSuccess` | `boolean` | `true` | Swap the form for `Success` after submit. | | `requiredIndicator` | `ReactNode \| null` | nothing | What labels append to required fields. See [Label](https://api.trymirio.com/docs/components/label-help-error.md). | | `optionalIndicator` | `ReactNode \| null` | muted "Optional" | What labels append to optional fields; `null` for nothing. | | `theme` | `MirioTheme` | from `MirioProvider` | `primary`, `primaryForeground`, `background`, `foreground`, `muted`, `mutedForeground`, `border`, `ring`, `error`, `inputBackground`, `radius` (`'none' \| 'sm' \| 'md' \| 'lg' \| 'full'` or a CSS length), `font`, `colorScheme` (`'light' \| 'dark' \| 'auto'`). Inline `--mirio-*` variables on the root; merged over the provider's per key. See [Theming](https://api.trymirio.com/docs/theming.md). | | `size` | `'sm' \| 'md' \| 'lg'` | provider's, else `'md'` | Token bundle for control height, text size and spacing (`data-mirio-size`). | | `layout` | `{ columns?: 1 \| 2 \| 3; full?: string[] }` | one column | Grid for the auto-rendered fields and `Page`. Long text, choices, consent and `full` keys span all columns; one column below 640px. | | `classNames` | `Partial>` | — | Classes per part: `root`, `form`, `page`, `pageHeader`, `field`, `label`, `help`, `input`, `option`, `segment`, `rating`, `consent`, `error`, `submit`, `button`, `progress`, `success`. Merged after the provider's and before each component's own `className`. | | `className` | `string` | `flex flex-col gap-mirio` | Merged onto the inner `` (after `classNames.form`). | | `rootClassName` | `string` | `font-mirio text-mirio-fg` | Merged onto the outer `[data-mirio-root]` wrapper (after `classNames.root`). | | …`` attributes | | | Everything else (`id`, `aria-label`, …) is spread on the ``. `aria-label` defaults to `schema.name` unless you pass `aria-label` or `aria-labelledby`. | Rendered structure: ```html
…
…fields…

message

hint

``` Form-level errors (network, `rate_limited`, `project_unclaimed`, `agents_blocked`, …) render the server's `message` and `hint` in `data-mirio="form-error"`. Field-level server errors (`validation_failed` with `fields`) go to the matching [FieldError](https://api.trymirio.com/docs/components/label-help-error.md) instead and focus moves to the first one. ## Variants and states Composed layout: children replace the automatic rendering. ```tsx import { Form, Field, Submit } from '@trymirio/mirio';
``` Rethemed with the `theme` prop, same markup: ```tsx
``` Dark, following the site: under a `.dark` (or `[data-theme="dark"]`) ancestor the built-in dark values apply. `theme={{ colorScheme: 'dark' }}` forces them on one form. ```tsx
``` Two columns and a larger size: ```tsx ``` Status is exposed as `data-status` on the wrapper, so `[data-mirio-root][data-status="submitting"]` can style the whole form while a request is in flight. ## Customization - Variables the wrapper reads: `--mirio-font`, `--mirio-fg`, `--mirio-text`, `--mirio-space` (the gap between fields via `gap-mirio`), `--mirio-color-scheme`. Every child reads its own tokens; `theme`, `size` and `colorScheme` only change variables. See [Styling](https://api.trymirio.com/docs/styling.md) and [Theming](https://api.trymirio.com/docs/theming.md). - `className` is merged with tailwind-merge, so `className="gap-2"` replaces the default `gap-mirio`; `className="grid grid-cols-2"` replaces `flex flex-col`. - CSS hooks: `[data-mirio-root]` (scope), `[data-mirio="form"]`, `[data-mirio="form-error"]`, `[data-status="…"]`, `[data-slug="…"]`, `[data-mirio-size="…"]`, `[data-mirio-color-scheme="…"]`. - To change the structure itself, compose children (`Field`, `Submit`, your own JSX) or build on the [headless](https://api.trymirio.com/docs/components/headless.md) hooks. ## Accessibility - Renders a real `` with `novalidate`: validation messages are Mirio's (inline, below each control), not the browser's bubbles. `aria-label` is the form name unless you label it yourself. - Enter in a single-line input submits a single-page form, or advances to the next page in a multi-step form. - On a failed submit, focus moves to the first invalid control (jumping to its page in a multi-step form). On a page change, focus moves to the page heading, or the first field when the page has no title. - Form-level errors use `role="alert"`. - The honeypot is `aria-hidden`, `tabindex="-1"` and off-screen; assistive technology never meets it. ## Related [Field](https://api.trymirio.com/docs/components/field.md), [MultiStep](https://api.trymirio.com/docs/components/multi-step.md), [Success](https://api.trymirio.com/docs/components/success.md), [MirioProvider](https://api.trymirio.com/docs/components/provider.md), [Headless](https://api.trymirio.com/docs/components/headless.md), [Schema](https://api.trymirio.com/docs/schema.md), [Styling](https://api.trymirio.com/docs/styling.md), [Theming](https://api.trymirio.com/docs/theming.md) --- --- title: Field description: Label, help text, the right control for the field's type, and the inline error, from one `name`. preview: field --- # Field `Field` renders one schema field completely: [Label](https://api.trymirio.com/docs/components/label-help-error.md), [Help](https://api.trymirio.com/docs/components/label-help-error.md), the control chosen by `field.type`, and [FieldError](https://api.trymirio.com/docs/components/label-help-error.md). It is what `Form` uses for every field when you give it no children. ## Preview ## Usage ```tsx import { Form, Field, FieldControl, Submit } from '@trymirio/mirio'; {/* label + help + control + error */} {/* children replace the control only */} ``` ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | required | The field key. Throws if the key is not in the form's schema. | | `children` | `ReactNode` | the control for `field.type` | Replaces the control slot only; label, help and error still render. Put your own input here using `useField(name).inputProps`. | | `className` | `string` | `flex flex-col gap-1.5` | Merged onto the wrapper. | | …`
` attributes | | | Spread on the wrapper. | Rendered: `
` containing `[data-mirio="label"]`, `[data-mirio="help"]` (when the schema has `help`), the control, and `[data-mirio="error"]` (when invalid). `FieldControl` (`name` only) renders just the control. Which control a type gets: | Field type | Control | data-mirio | | --- | --- | --- | | `short_text`, `email`, `phone`, `url`, `number` | [Input](https://api.trymirio.com/docs/components/input.md) | `input` | | `long_text` | [Textarea](https://api.trymirio.com/docs/components/textarea.md) | `textarea` | | `dropdown` | [Select](https://api.trymirio.com/docs/components/select.md) | `select` | | `single_choice`, `multiple_choice` | [Choice](https://api.trymirio.com/docs/components/choice.md) | `choice` | | `yes_no` | [YesNo](https://api.trymirio.com/docs/components/yes-no.md) | `yesno` | | `rating` | [Rating](https://api.trymirio.com/docs/components/rating.md) | `rating` | | `date` | [DatePicker](https://api.trymirio.com/docs/components/date-picker.md) | `date` | | `consent` | [Consent](https://api.trymirio.com/docs/components/consent.md) (the row is its own label, so no `Label`; `Field` adds `Help` and `FieldError` under it) | `consent` | | `hidden` | `` with no wrapper, label or error (`className` does not apply) | `hidden` | Fields hidden by a `showWhen` condition render nothing at all (see [Conditional logic](https://api.trymirio.com/docs/conditional-logic.md)). ## Variants and states The preview above has a required field and a number field: submit empty to see the error state, which sets `aria-invalid` on the control and renders `FieldError` below it. Typing clears the error for that field. ## Customization - Spacing between label, control and error is the wrapper's `gap-1.5`; override with `className="gap-1"` (tailwind-merge drops the default). - Target a type with CSS: `[data-mirio="field"][data-type="long_text"] { … }`. - To change the structure (help below the control, label inside the control…), compose `Label`, `Help`, `FieldControl` and `FieldError` yourself, or build on `useField` from `@trymirio/mirio/headless`. ## Accessibility `Field` wires the pieces: the label's `htmlFor` targets the control (`aria-labelledby` for radio groups), help and error ids go into the control's `aria-describedby`, required fields get `aria-required="true"` (not the native `required`, so Mirio's messages show instead of the browser's), and invalid fields get `aria-invalid="true"`. ## Related [Form](https://api.trymirio.com/docs/components/form.md), [Label, Help, FieldError](https://api.trymirio.com/docs/components/label-help-error.md), [Headless: useField](https://api.trymirio.com/docs/components/headless.md), [Schema: field types](https://api.trymirio.com/docs/schema.md) --- --- title: Input description: The single-line text control for short_text, email, phone, url and number fields. preview: input --- # Input A styled `` bound to a field. `Field` renders it for `short_text`, `email`, `phone`, `url` and `number`; import it directly when you compose a field by hand. ## Preview ## Usage ```tsx // mirio/forms/newsletter.ts import { defineForm } from '@trymirio/mirio/schema'; export const newsletter = defineForm({ slug: 'newsletter', name: 'Newsletter', pages: [{ fields: [{ key: 'email', type: 'email', label: 'Work email', placeholder: 'you@company.com', autocomplete: 'email', required: true }] }], }); // Rendered by
automatically, or by hand: import { Form, Field, Input, Submit } from '@trymirio/mirio'; {/* label + help + + error */} ``` ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | required | The field key. | | `type` | `string` | by field type | Overrides the HTML type: `email`→`email`, `phone`→`tel`, `url`→`url`, `number`→`text` (with `inputMode="decimal"`, or `numeric` when `integer: true`), otherwise `text`. | | `className` | `string` | control classes | Merged onto the ``. | | …`` attributes | | | Everything except `name`, `value`, `onChange` (those come from the field). `placeholder` defaults to the schema's `placeholder`. | Renders ``. For a `hidden` field, `Input` renders `HiddenInput` (``) instead. `autocomplete` comes from the schema's `autocomplete`, else a default: `email` for email fields, `tel` for phone, `url` for url, and `name` / `given-name` / `family-name` / `organization` when a `short_text` key looks like one. Values are normalized on submit (trimmed, emails lower-cased, phones to E.164, URLs given `https://`), so the input itself never reformats what the visitor types. ## Variants and states ```tsx const fields = [ { key: 'name', type: 'short_text', label: 'Full name', help: 'As it appears on your badge.', autocomplete: 'name', required: true }, { key: 'website', type: 'url', label: 'Website', placeholder: 'acme.com', defaultValue: 'https://acme.com' }, { key: 'seats', type: 'number', label: 'Seats', integer: true, min: 1, max: 500, required: true }, ]; ``` - **Required**: the label gets the required indicator and the input `aria-required`. - **With help**: `help` renders below the label and is linked via `aria-describedby`. - **Prefilled**: `defaultValue`, `prefill: { query }` (from the URL) or `initialValues` on `Form`. - **Error**: after a failed submit, `aria-invalid="true"` turns the border red and `FieldError` appears. Format errors (bad email, bad URL) also show on blur; "required" waits for submit. - **Disabled**: pass `disabled` when composing; the control dims and ignores input. ## Customization - Reads `--mirio-bg`, `--mirio-fg`, `--mirio-border`, `--mirio-ring` (focus ring, 25 % alpha), `--mirio-error` (invalid border), `--mirio-muted-fg` (placeholder), `--mirio-radius`. - `className` overrides resolve by utility: `className="h-12 text-base"` replaces `min-h-10 text-[15px]`; `className="rounded-none"` replaces `rounded-mirio`. - CSS hook: `[data-mirio-root] [data-mirio="input"]`, plus `[aria-invalid="true"]` for the error state. - `classNames={{ input: '…' }}` on `Form` or `MirioProvider` styles Input, Textarea, Select and DatePicker together; they share one set of control classes. ## Accessibility Labelled by its `Label` through `htmlFor`; help and error text are in `aria-describedby`; `aria-required` and `aria-invalid` reflect state; `inputMode` gives phones the right keyboard for number fields. Enter submits (or advances a multi-step form). ## Related [Field](https://api.trymirio.com/docs/components/field.md), [Textarea](https://api.trymirio.com/docs/components/textarea.md), [Label, Help, FieldError](https://api.trymirio.com/docs/components/label-help-error.md), [Schema: field types](https://api.trymirio.com/docs/schema.md) --- --- title: Textarea description: The multi-line control for long_text fields. preview: textarea --- # Textarea A styled, vertically resizable `