diff --git a/NOTICE b/NOTICE
index 51a20b9..f6c98a2 100644
--- a/NOTICE
+++ b/NOTICE
@@ -2,7 +2,9 @@ Polyxd
Copyright 2026 the Polyxd authors
This product includes software developed at Polyxd, licensed under the Apache
-License, Version 2.0 (see LICENSE).
+License, Version 2.0 (see LICENSE), except Polyxd Studio (apps/studio), which is
+licensed under the Functional Source License, Version 1.1, ALv2 Future License
+(see apps/studio/LICENSE).
It also includes material from the projects below. Each is used under its own
licence, and each licence is included in full beside the material it covers.
diff --git a/README.md b/README.md
index b4a9d06..c616ff2 100644
--- a/README.md
+++ b/README.md
@@ -216,6 +216,6 @@ Before a pull request: `npm run test:all` and `npm run check:licences`.
## Licence
-Code is [Apache-2.0](LICENSE); the spec and documentation are [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/). [NOTICE](NOTICE) lists every vendored source and its licence.
+Code is [Apache-2.0](LICENSE), except Studio (`apps/studio`), which is [FSL-1.1-ALv2](apps/studio/LICENSE): free to run for your own team or company, not as a competing hosted service, and Apache-2.0 two years after each release; the spec and documentation are [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/). [NOTICE](NOTICE) lists every vendored source and its licence.
The design-system packs are Polyxd's work, reading each system's published tokens. Polyxd is not affiliated with, endorsed by or sponsored by any of those projects or their owners, and each name is the trademark of its owner.
diff --git a/apps/site/content/docs/roadmap.md b/apps/site/content/docs/roadmap.md
index 1c66e7d..bb266d6 100644
--- a/apps/site/content/docs/roadmap.md
+++ b/apps/site/content/docs/roadmap.md
@@ -57,4 +57,4 @@ The runtime points at any generator: Claude, GPT or Gemini through their APIs, o
## Open core
-The spec, design-system packs, React renderer, runtime, MCP server, verifier and benchmark are meant to be free and open: code under Apache-2.0, and the spec and docs under CC-BY-4.0. [Studio](/docs/studio) exists for teams (hosted at studio.polyxd.com, free for one workspace, and open source to run yourself): design systems, what generated screens may use, rules, whole Design Directions (edited, versioned and fetched by key), authored screens and delivery, and Insights: how each screen does in a team's product, counted from the semantic events the product sends. Reviewing generated screens there is planned. Whether a paid tier comes later is not decided. The intent is that anything that runs inside someone else's product stays free, with no usage metering.
+The spec, design-system packs, React renderer, runtime, MCP server, verifier and benchmark are meant to be free and open: code under Apache-2.0, and the spec and docs under CC-BY-4.0. [Studio](/docs/studio) exists for teams (hosted at studio.polyxd.com, free for one workspace, and source-available under the Functional Source License to run yourself): design systems, what generated screens may use, rules, whole Design Directions (edited, versioned and fetched by key), authored screens and delivery, and Insights: how each screen does in a team's product, counted from the semantic events the product sends. Reviewing generated screens there is planned. Paid plans for bigger teams are planned and will be published before they start; one workspace stays free. The intent is that anything that runs inside someone else's product stays free, with no usage metering.
diff --git a/apps/site/content/docs/studio.md b/apps/site/content/docs/studio.md
index f383b6c..13d0b4e 100644
--- a/apps/site/content/docs/studio.md
+++ b/apps/site/content/docs/studio.md
@@ -7,7 +7,7 @@ order: 23
# Studio
-[Studio](https://studio.polyxd.com) is the team's side of Polyxd: open source (Apache-2.0, in `apps/studio`), running on Cloudflare Workers with D1 and R2, and the same code whether you use the hosted one or your own. The hosted Studio is free for one workspace; a small fee may later cover its storage.
+[Studio](https://studio.polyxd.com) is the team's side of Polyxd: source-available (the [Functional Source License](https://fsl.software), in `apps/studio`: free to run for your own team or company, not as a competing hosted service, and each version becomes Apache-2.0 after two years), running on Cloudflare Workers with D1 and R2, and the same code whether you use the hosted one or your own. The hosted Studio has a Free plan and paid ones ([Plans](#plans)); a Studio you run yourself has no plans and no limits.
## Your design system
@@ -97,7 +97,28 @@ An ingest key is publishable, like the key a web analytics tool puts in a page.
## Team
-Workspaces, invites with roles (design-system, designer, product, engineer, viewer), sign-in through [better-auth](https://www.better-auth.com) (email and password with verification, Google when configured), API keys, and ingest keys for Insights.
+Workspaces, invites with roles (design-system, designer, product, engineer, viewer), sign-in through [better-auth](https://www.better-auth.com) (email and password with verification, Google when configured), API keys, and ingest keys for Insights. Owners can take someone out of a workspace, and an invite can be withdrawn before it is used.
+
+## Plans
+
+Plans apply to the hosted Studio only. You pay per editor: a viewer is always free, and every other role (owner, design-system, designer, product, engineer) is an editor.
+
+| | Free | Pro | Team | Enterprise |
+|---|---|---|---|---|
+| Price | $0 | $8 a month, or $80 a year | $12 per editor a month, or $120 a year | talk to us |
+| Workspaces you own | 1 | 3 | unlimited | unlimited |
+| Editors | 2 | 1 | unlimited | unlimited |
+| Design systems | 1 | unlimited | unlimited | unlimited |
+| Directions | 1 | unlimited | unlimited | unlimited |
+| Published screens | 10 | unlimited | unlimited | unlimited |
+| Fetches by key a month (screens, Directions, tokens) | 10,000 | 250,000 | 1,000,000 | 10 million or more |
+| Version history | last 10 | all | all | all |
+
+- **Reaching a limit** stops only the new thing: another design system, another published screen, another editor. Studio says which plan has room. Everything you already have keeps working.
+- **Going over your fetches never breaks your product.** Studio warns you at 80% and 100% on the Billing page. If a workspace stays over for 7 days, editing pauses until it upgrades or the month turns; products still get their screens, Directions and tokens.
+- **Billing** is under Workspace → Billing: your plan, your seats, what you've used this month, and the buttons to upgrade or manage your subscription (owners only; payment is through Stripe). On Team, adding or removing an editor changes your seats, prorated.
+- **The founding offer**: the first 100 paying workspaces pay half, for as long as they stay subscribed.
+- **You can always leave**: every design system, screen and Direction exports as JSON on every plan.
## Run it yourself
@@ -107,4 +128,6 @@ npm run db:migrate -w @polyxd/studio # local D1
npm run dev -w @polyxd/studio # http://localhost:8789
```
-For production: a D1 database, an R2 bucket, `SECRETS_KEY` and `AUTH_SECRET` secrets, `RESEND_API_KEY` for email, and `npm run deploy -w @polyxd/studio`. The README in `apps/studio` has the exact steps.
+For production: a D1 database, an R2 bucket, `SECRETS_KEY` and `AUTH_SECRET` secrets, `RESEND_API_KEY` for email, and `npm run deploy -w @polyxd/studio`. The README in `apps/studio` has the exact steps. Your own Studio has no plans and no limits: as many editors, design systems, Directions, screens and fetches as you like, and no Billing page.
+
+The [licence](https://github.com/visualfart/polyxd/blob/main/apps/studio/LICENSE) lets you run Studio for your own team or company, change it, and share your changes. It does not let you offer Studio, or something substantially like it, as a service to others. Each version becomes Apache-2.0 two years after its release.
diff --git a/apps/site/content/legal/terms.md b/apps/site/content/legal/terms.md
index 0e8c917..7c6904a 100644
--- a/apps/site/content/legal/terms.md
+++ b/apps/site/content/legal/terms.md
@@ -20,7 +20,7 @@ Our [privacy policy](/privacy/) explains what we do with personal data.
## Open-source software
-Polyxd's code is open source under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). The specification and the documentation are licensed under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/). Those licences, not these terms, govern your use of the code, the specification and the docs, wherever you get them. You can run all of it yourself.
+Polyxd's code is open source under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0), except Polyxd Studio, whose source is available under the [Functional Source License 1.1](https://fsl.software) (you may run it for your own team or company, but not offer it as a competing service; each version becomes Apache 2.0 two years after its release). The specification and the documentation are licensed under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/). Those licences, not these terms, govern your use of the code, the specification and the docs, wherever you get them. You can run all of it yourself.
These terms apply to the services we host for you.
diff --git a/apps/site/src/studio.html b/apps/site/src/studio.html
index 1276043..afcedf2 100644
--- a/apps/site/src/studio.html
+++ b/apps/site/src/studio.html
@@ -246,7 +246,7 @@
FreeFor one workspace. Open source to run yourself.
+
FreeFor one workspace. Source-available to run yourself.
diff --git a/apps/studio/LICENSE b/apps/studio/LICENSE
new file mode 100644
index 0000000..6a7d6d9
--- /dev/null
+++ b/apps/studio/LICENSE
@@ -0,0 +1,105 @@
+# Functional Source License, Version 1.1, ALv2 Future License
+
+## Abbreviation
+
+FSL-1.1-ALv2
+
+## Notice
+
+Copyright 2026 Neelank Sachan
+
+## Terms and Conditions
+
+### Licensor ("We")
+
+The party offering the Software under these Terms and Conditions.
+
+### The Software
+
+The "Software" is each version of the software that we make available under
+these Terms and Conditions, as indicated by our inclusion of these Terms and
+Conditions with the Software.
+
+### License Grant
+
+Subject to your compliance with this License Grant and the Patents,
+Redistribution and Trademark clauses below, we hereby grant you the right to
+use, copy, modify, create derivative works, publicly perform, publicly display
+and redistribute the Software for any Permitted Purpose identified below.
+
+### Permitted Purpose
+
+A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
+means making the Software available to others in a commercial product or
+service that:
+
+1. substitutes for the Software;
+
+2. substitutes for any other product or service we offer using the Software
+ that exists as of the date we make the Software available; or
+
+3. offers the same or substantially similar functionality as the Software.
+
+Permitted Purposes specifically include using the Software:
+
+1. for your internal use and access;
+
+2. for non-commercial education;
+
+3. for non-commercial research; and
+
+4. in connection with professional services that you provide to a licensee
+ using the Software in accordance with these Terms and Conditions.
+
+### Patents
+
+To the extent your use for a Permitted Purpose would necessarily infringe our
+patents, the license grant above includes a license under our patents. If you
+make a claim against any party that the Software infringes or contributes to
+the infringement of any patent, then your patent license to the Software ends
+immediately.
+
+### Redistribution
+
+The Terms and Conditions apply to all copies, modifications and derivatives of
+the Software.
+
+If you redistribute any copies, modifications or derivatives of the Software,
+you must include a copy of or a link to these Terms and Conditions and not
+remove any copyright notices provided in or with the Software.
+
+### Disclaimer
+
+THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
+PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
+
+IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
+SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
+EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
+
+### Trademarks
+
+Except for displaying the License Details and identifying us as the origin of
+the Software, you have no right under these Terms and Conditions to use our
+trademarks, trade names, service marks or product names.
+
+## Grant of Future License
+
+We hereby irrevocably grant you an additional license to use the Software under
+the Apache License, Version 2.0 that is effective on the second anniversary of
+the date we make the Software available. On or after that date, you may use the
+Software under the Apache License, Version 2.0, in which case the following
+will apply:
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License.
+
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
diff --git a/apps/studio/README.md b/apps/studio/README.md
index 2385067..fdb9e95 100644
--- a/apps/studio/README.md
+++ b/apps/studio/README.md
@@ -1,13 +1,14 @@
# Polyxd Studio
-Where a design-system team decides what generated screens may look like, and reviews what they actually look like. Apache-2.0, like the rest of Polyxd.
+Where a design-system team decides what generated screens may look like, and reviews what they actually look like. Source-available under the [Functional Source License](LICENSE) (FSL-1.1-ALv2), unlike the rest of Polyxd, which is Apache-2.0: run it for your own team or company, but not as a competing hosted service. Each version becomes Apache-2.0 two years after its release.
-Run it yourself on your own Cloudflare account, or use the hosted one at studio.polyxd.com (same code; a small fee may cover its storage later).
+Run it yourself on your own Cloudflare account, free and with no limits, or use the hosted one at studio.polyxd.com (same code), which has a Free plan and paid ones ([plans and billing](#plans-and-billing-the-hosted-studio)).
## What works
- A landing page at `/` for anyone signed out (and at `/welcome` for anyone signed in): what Studio does, in the site's voice, with the spec's send-money example drawn live by `@polyxd/react` and cycled through three design systems, product images captured from Studio itself (`public/landing/`), and the sign-in split beside it (`/signin`, `?mode=signup` or `?mode=forgot` open that form).
-- Sign up and sign in through [better-auth](https://www.better-auth.com), open source and running inside the Worker: email and password with verification, password reset, Google when a client is configured; two-step verification and SAML/OIDC single sign-on are its plugins, to add when a customer needs them. Workspaces, invites with roles, API keys.
+- Sign up and sign in through [better-auth](https://www.better-auth.com), open source and running inside the Worker: email and password with verification, password reset, Google when a client is configured; two-step verification and SAML/OIDC single sign-on are its plugins, to add when a customer needs them. Workspaces, invites with roles (withdrawn from the Team page), owners taking people out, API keys.
+- On the hosted Studio only, plans and billing: a Billing page (plan, seats, usage, upgrade and manage), limits per plan, fetch metering, Stripe ([below](#plans-and-billing-the-hosted-studio)).
- Import a design system as it is, from an npm package (public, or private through a read-only registry token kept encrypted, an uploaded `npm pack` tarball, or `polyxd studio push` from inside your network), a Tokens Studio file, a W3C DTCG file, or CSS custom properties.
- Or **start from a template**: one of the twelve original template packs (`packages/ds-{mono,civic,sketch,wireframe,editorial,pastel,health,finance,glass,terminal,brutalist,neon}`, bundled into the Worker as JSON), or a blank one (Mono's structure with a grey ramp). Each is shown with one line of character and a strip of swatches from its own tokens. It becomes a design system of the workspace with its tokens copied, scanned, and every role mapped to the pack's semantic token of the same name (exact matches accepted), ready to tune and publish.
- A scan of what was found: tokens by tier and type, modes, broken and circular references, deprecated tokens.
@@ -117,7 +118,35 @@ The landing's product images are captured from a running Studio: against a local
3. Secrets, with `npx wrangler secret put --env production`: `AUTH_SECRET` (a long random string; signs sessions), `SECRETS_KEY` (another; encrypts registry tokens), `RESEND_API_KEY` (verification, reset and invite emails; without it, links are logged), and optionally `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` for "Continue with Google" (redirect URI: `/api/auth/callback/google`). Analytics are off unless you also set `POSTHOG_KEY` (and `POSTHOG_HOST` for PostHog's EU cloud); `docs/analytics.md` says what they send.
4. `npm run deploy -w @polyxd/studio`.
-The hosted one at studio.polyxd.com is this same configuration.
+The hosted one at studio.polyxd.com is this same configuration, plus billing (below). Leave `BILLING` unset and your Studio has no plans, no limits and no Billing page: every workspace can have as many editors, design systems, Directions, published screens and fetches as you like, and no request to Stripe or Analytics Engine is ever made.
+
+## Plans and billing (the hosted Studio)
+
+Only a Studio whose `BILLING` is `on` has plans. The limits live in one table, `LIMITS` in `src/worker/plans.ts`, from [the pricing decision](../../docs/decisions/0004-pricing-and-licensing.md):
+
+| | Free | Pro | Team | Enterprise |
+|---|---|---|---|---|
+| Workspaces a person owns | 1 | 3 | unlimited | unlimited |
+| Editors (every role but `viewer`; viewers are free) | 2 | 1 | unlimited | unlimited |
+| Design systems (imported or from a template) | 1 | unlimited | unlimited | unlimited |
+| Directions | 1 | unlimited | unlimited | unlimited |
+| Published screens | 10 | unlimited | unlimited | unlimited |
+| Fetches by API key a month | 10,000 | 250,000 | 1,000,000 | 10,000,000 |
+| Versions kept per screen, Direction or design system | last 10 | all | all | all |
+
+- **Hard limits** only stop something new: creating a workspace, inviting or accepting an invite as an editor (an open editor invite holds a seat; withdraw it on the Team page to free it), importing or starting a design system, creating a Direction, publishing a screen that isn't published yet. The answer is a 402 `{ error, code: "plan_limit", limit, plan, current, max }`, and the app shows an upgrade dialog. On Free, saving a version prunes all but the last 10 (the published or live one is always kept).
+- **Fetches** are soft. A product's `GET` by API key of a published screen, a Direction or a design system's export writes one data point to Workers Analytics Engine (`FETCHES`, dataset `polyxd_studio_fetches`), never a D1 row. The hourly cron (`triggers` in `wrangler.jsonc`) reads this month's and last month's totals back through the Analytics Engine SQL API into `usage`, and sets `over_quota_since` on a workspace over its plan's fetches (or clears it). Going over never blocks a fetch. Seven days over, every change in the workspace answers 402 `{ code: "over_quota" }` until it upgrades or the month turns; reads, fetches and billing keep working. The Billing page warns at 80% and 100%; email warnings are still to do.
+- **Stripe**, through its REST API (`src/worker/billing.ts`): `POST /api/w//billing/checkout` with `{ plan: "pro" | "team", interval: "month" | "year" }` opens Checkout (Team's quantity is the workspace's editors; the founding coupon is applied while it lasts, then the full price), `POST …/billing/portal` opens the Customer Portal, and only owners may call either. `POST /api/billing/webhook` checks the `Stripe-Signature` with Web Crypto and is the only thing that changes a workspace's plan: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted` (back to Free) and `invoice.payment_failed` (shown as past due while Stripe retries). A Team subscription's quantity follows editors joining and leaving, prorated. Enterprise is set by hand: `UPDATE workspaces SET plan = 'enterprise' WHERE slug = '…'`.
+
+To turn it on:
+
+1. In Stripe (test mode first): a product **Pro** with prices of $8 a month and $80 a year, a product **Team** with per-unit prices of $12 a month and $120 a year, and a coupon of 50% off, duration forever, max redemptions 100. In the Customer Portal settings, allow switching between those prices, changing Team's quantity and cancelling.
+2. A webhook endpoint at `https:///api/billing/webhook` for the five events above.
+3. An Analytics Engine API token: a Cloudflare API token with *Account Analytics: Read*.
+4. Secrets, with `npx wrangler secret put --env production`: `BILLING` (`on`), `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_PRO_MONTH`, `STRIPE_PRICE_PRO_YEAR`, `STRIPE_PRICE_TEAM_MONTH`, `STRIPE_PRICE_TEAM_YEAR`, `STRIPE_COUPON_FOUNDING` (optional), `CF_ACCOUNT_ID` and `CF_ANALYTICS_TOKEN`.
+5. `npm run db:migrate:remote -w @polyxd/studio` for `migrations/0007_plans.sql`, then deploy.
+
+Locally, `npx wrangler dev --var BILLING:on` shows the limits and the Billing page without Stripe; `test/billing.worker.test.ts` runs both kinds of Studio with Stripe and Analytics Engine stood in for.
## Layout
@@ -130,7 +159,7 @@ The hosted one at studio.polyxd.com is this same configuration.
| `src/templates/` | The twelve template packs bundled as JSON (`packs.ts`), their extras stylesheets as text (`extras.ts`), and each as a graph with a swatch summary |
| `src/tokens/` | Editing: a pack as a graph, edits applied with aliases re-checked, the OKLCH ramp and rebrand, values as CSS and as one line |
| `src/export/` | The six export formats |
-| `src/worker/` | The API on Workers: auth, workspaces, design systems, templates, editing, export, components, rules, screens, Directions, and Insights' ingest endpoint (`insights.ts`) |
+| `src/worker/` | The API on Workers: auth, workspaces, design systems, templates, editing, export, components, rules, screens, Directions, Insights' ingest endpoint (`insights.ts`); `plans.ts` the plan table, limits, fetch metering and the rollup, `billing.ts` Stripe |
| `src/screens/` | Screens, shared by the Worker and the app: the schema read directly (the editor's shapes, and problems placed at the prop they are about), the checker with the spec's shell rules, the tree edits |
| `src/insights/` | Insights, shared by the Worker and the app: the event check and the counting (`events.ts`), and the report the page reads (`report.ts`) |
| `src/direction/` | Directions, shared by the Worker and the app: what a version stores and what a product gets (`model.ts`), the schema check in plain words (`schema.ts`), the field-by-field diff (`diff.ts`), the voice sample (`voice.ts`), and every setting's words (`labels.ts`) |
diff --git a/apps/studio/migrations/0007_plans.sql b/apps/studio/migrations/0007_plans.sql
new file mode 100644
index 0000000..5be0b9b
--- /dev/null
+++ b/apps/studio/migrations/0007_plans.sql
@@ -0,0 +1,23 @@
+-- Plans and billing (docs/decisions/0004-pricing-and-licensing.md). A workspace is on a plan;
+-- Stripe says which, through the webhook. Only the hosted Studio (BILLING=on) reads any of this:
+-- a self-hosted one has no limits, and every workspace stays 'free' without it mattering.
+ALTER TABLE workspaces ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'; -- free | pro | team | enterprise
+ALTER TABLE workspaces ADD COLUMN plan_status TEXT; -- Stripe's: active | trialing | past_due | canceled | …
+ALTER TABLE workspaces ADD COLUMN billing_interval TEXT; -- month | year
+ALTER TABLE workspaces ADD COLUMN seats INTEGER; -- editor seats billed (Team)
+ALTER TABLE workspaces ADD COLUMN stripe_customer_id TEXT;
+ALTER TABLE workspaces ADD COLUMN stripe_subscription_id TEXT;
+ALTER TABLE workspaces ADD COLUMN period_end TEXT; -- when the paid period renews or ends
+ALTER TABLE workspaces ADD COLUMN over_quota_since TEXT; -- first rollup that found fetches over the plan's
+CREATE INDEX workspaces_stripe_customer ON workspaces(stripe_customer_id);
+
+-- Monthly totals, rolled up by the scheduled handler from the fetch counter (Workers Analytics
+-- Engine), so no request writes to D1 to be counted. period is 'YYYY-MM' in UTC.
+CREATE TABLE usage (
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ metric TEXT NOT NULL, -- fetches
+ period TEXT NOT NULL,
+ count INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (workspace_id, metric, period)
+);
diff --git a/apps/studio/package.json b/apps/studio/package.json
index c55b43d..b35ec15 100644
--- a/apps/studio/package.json
+++ b/apps/studio/package.json
@@ -2,6 +2,7 @@
"name": "@polyxd/studio",
"version": "0.0.1",
"private": true,
+ "license": "FSL-1.1-ALv2",
"description": "Polyxd Studio: where a design-system team decides what generated screens may look like, and reviews what they actually look like",
"type": "module",
"scripts": {
diff --git a/apps/studio/src/app/App.tsx b/apps/studio/src/app/App.tsx
index ea7452b..b90e403 100644
--- a/apps/studio/src/app/App.tsx
+++ b/apps/studio/src/app/App.tsx
@@ -1,6 +1,6 @@
import { createContext, lazy, Suspense, useContext, useEffect, useState, type ReactNode } from "react";
import { Link, Navigate, NavLink, Route, Routes, useNavigate, useParams } from "react-router-dom";
-import { api, type Me } from "./api.ts";
+import { api, PLAN_LIMIT_EVENT, type Me, type PlanLimitError } from "./api.ts";
import { startAnalytics, stopAnalytics } from "./analytics.ts";
import { SignIn } from "./pages/SignIn.tsx";
import { Workspaces } from "./pages/Workspaces.tsx";
@@ -14,6 +14,7 @@ import { Components } from "./pages/Components.tsx";
import { Rules } from "./pages/Rules.tsx";
import { Team } from "./pages/Team.tsx";
import { Invite } from "./pages/Invite.tsx";
+import { Billing } from "./pages/Billing.tsx";
import { Mark, StudioLockup } from "./mark.tsx";
// The screen pages carry the renderer, the schema and the spec's examples; they load when opened.
const Screens = lazy(() => import("./pages/Screens.tsx").then((m) => ({ default: m.Screens })));
@@ -81,7 +82,7 @@ const NAV: { group?: string; items: { to: string; label: string }[] }[] = [
{ group: "Foundations", items: [{ to: "design-systems", label: "Design systems" }, { to: "components", label: "Components" }] },
{ group: "Direction", items: [{ to: "directions", label: "Directions" }, { to: "rules", label: "Rules" }] },
{ group: "Product", items: [{ to: "screens", label: "Screens" }, { to: "insights", label: "Insights" }] },
- { group: "Workspace", items: [{ to: "team", label: "Team" }] },
+ { group: "Workspace", items: [{ to: "team", label: "Team" }, { to: "billing", label: "Billing" }] },
];
function Shell() {
@@ -106,7 +107,8 @@ function Shell() {
{NAV.map((g, i) => (
{g.group &&
{g.group}
}
- {g.items.map((it) => (
+ {/* Billing only where there are plans: a self-hosted Studio has none. */}
+ {g.items.filter((it) => it.to !== "billing" || me.billing).map((it) => (
{it.label}
@@ -145,13 +147,42 @@ function Shell() {
} />
} />
} />
+ } />
That page isn't here
The link may be from another workspace.
} />
+
);
}
+/** Shown on any 402 from the Worker: what ran out, and the way to more. Viewers are always free. */
+function UpgradeDialog({ slug }: { slug: string }) {
+ const [hit, setHit] = useState(null);
+ const navigate = useNavigate();
+ useEffect(() => {
+ const on = (e: Event) => setHit((e as CustomEvent).detail);
+ window.addEventListener(PLAN_LIMIT_EVENT, on);
+ return () => window.removeEventListener(PLAN_LIMIT_EVENT, on);
+ }, []);
+ if (!hit) return null;
+ const locked = hit.code === "over_quota";
+ return (
+ <>
+
setHit(null)} />
+
+
{locked ? "Editing is paused" : "That's your plan's limit"}
+
{hit.error}
+ {hit.limit === "editors" &&
Viewers are always free: invite people as viewers, or make someone a viewer, to stay on this plan.
}
+
+
+
+
+
+ >
+ );
+}
+
export type Ws = Me["workspaces"][number];
export function Page({ crumbs, title, lede, actions, children, meta }: { crumbs: string[]; title: string; lede?: string; actions?: ReactNode; meta?: ReactNode; children: ReactNode }) {
diff --git a/apps/studio/src/app/api.ts b/apps/studio/src/app/api.ts
index 33f54cf..2a4bf7a 100644
--- a/apps/studio/src/app/api.ts
+++ b/apps/studio/src/app/api.ts
@@ -20,16 +20,54 @@ export async function api(method: string, path: string, body?: unkn
const r = await fetch(path, init);
const text = await r.text();
const data = text ? JSON.parse(text) : {};
+ // A plan's limit, wherever it was hit: the shell shows the upgrade dialog (App.tsx), and the caller still gets the error.
+ if (r.status === 402) window.dispatchEvent(new CustomEvent(PLAN_LIMIT_EVENT, { detail: { error: data.error ?? "Your plan's limit", ...data } }));
if (!r.ok) throw new ApiError(r.status, data.error ?? `${r.status} ${r.statusText}`, data);
return data as T;
}
+export const PLAN_LIMIT_EVENT = "studio:plan-limit";
+/** The Worker's 402: what ran out, on which plan (src/worker/plans.ts). */
+export interface PlanLimitError {
+ error: string;
+ code?: "plan_limit" | "over_quota";
+ limit?: string;
+ plan?: string;
+ current?: number;
+ max?: number | null;
+}
+
export interface Me {
user: { id: string; email: string; name: string } | null;
- workspaces: { id: string; slug: string; name: string; role: string }[];
+ workspaces: { id: string; slug: string; name: string; role: string; plan?: string }[];
signIn?: { google: boolean; emailVerification: boolean };
/** Present only when the Worker has a PostHog key: the project's public key and PostHog's app for its region. */
analytics?: { key: string; ui: string };
+ /** Whether this Studio has plans (the hosted one). A self-hosted Studio has no limits and no Billing page. */
+ billing?: boolean;
+}
+
+type Limit = number | null;
+export interface Billing {
+ enabled: true;
+ plan: "free" | "pro" | "team" | "enterprise";
+ status: string | null;
+ interval: "month" | "year" | null;
+ seats: number | null;
+ periodEnd: string | null;
+ subscribed: boolean;
+ customer: boolean;
+ limits: { workspaces: Limit; editors: Limit; designSystems: Limit; directions: Limit; publishedScreens: Limit; fetches: Limit; history: Limit; privateMcp: boolean; approvals: boolean; sharedLibraries: boolean };
+ usage: { editors: number; pendingEditors: number; viewers: number; designSystems: number; directions: number; publishedScreens: number; fetches: number };
+ period: string;
+ fetchesPercent: number | null;
+ overQuotaSince: string | null;
+ lockedFrom: string | null;
+ locked: boolean;
+ prices: { pro: { month: number; year: number }; team: { month: number; year: number } };
+ founding: boolean;
+ checkout: boolean;
+ canManage: boolean;
}
export interface Scan {
diff --git a/apps/studio/src/app/pages/Billing.tsx b/apps/studio/src/app/pages/Billing.tsx
new file mode 100644
index 0000000..6adf63f
--- /dev/null
+++ b/apps/studio/src/app/pages/Billing.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+import { api, type Billing as BillingInfo } from "../api.ts";
+import { Page, useSession, type Ws } from "../App.tsx";
+
+type Paid = "pro" | "team";
+const NAMES = { free: "Free", pro: "Pro", team: "Team", enterprise: "Enterprise" } as const;
+const date = (iso: string) => new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
+const count = (n: number | null, word: string) => (n === null ? `Unlimited ${word}s` : `${n.toLocaleString()} ${word}${n === 1 ? "" : "s"}`);
+
+/** What each plan gives, in the words of the plan table (docs/decisions/0004). */
+const FEATURES: Record<"free" | Paid, string[]> = {
+ free: ["2 editors", "1 design system and 1 Direction", "10 published screens", "10,000 fetches a month", "The last 10 versions of everything"],
+ pro: ["1 editor, up to 3 workspaces", "Unlimited design systems, Directions and screens", "250,000 fetches a month", "Full version history", "Private packs and screens through the hosted MCP"],
+ team: ["Unlimited editors and workspaces", "Everything in Pro", "1,000,000 fetches a month", "Approval before publish", "Shared libraries across workspaces"],
+};
+
+/** One line of usage: what, how much of how much, and a bar that turns amber at 80% and red once over. */
+function Usage({ label, used, max, note }: { label: string; used: number; max: number | null; note?: string }) {
+ const share = max ? used / max : 0;
+ return (
+
Thanks, the payment went through.Your plan changes here as soon as Stripe confirms it, usually within a minute.
}
+ {b.locked ? (
+
Editing is paused{ws.name} has been over its monthly fetches since {date(b.overQuotaSince!)}. Products still get their screens, Directions and tokens. Upgrade to edit again.
+ ) : b.overQuotaSince ? (
+
Over this month's fetchesNothing stops working. If {ws.name} is still over on {date(b.lockedFrom!)}, editing pauses until it upgrades; fetches carry on.
{b.fetchesPercent}% of this month's fetches usedGoing over never breaks a product. After 7 days over, editing pauses until the workspace upgrades.
+ ) : null}
+ {b.status === "past_due" &&
The last payment didn't go throughStripe will try again. {b.canManage ? "Check the card in Manage billing." : "Ask an owner to check the card."}
}
+
+
+
This month
Viewers are always free; everyone else is an editor.
+
+
+
+
+
+
+
+
+
+ {b.plan !== "enterprise" && (
+
+
+
Plans
{b.founding &&
Founding offer: the first 100 paying workspaces pay half, for as long as they stay.
}
+
+
+
+
+
+
+ {(["free", "pro", "team"] as const).map((plan) => {
+ const current = b.plan === plan;
+ return (
+
+
+
{NAMES[plan]}
+
{plan === "free" ? "$0" : `$${price(plan)}`}{plan === "free" ? "for trying it and side projects" : `${plan === "team" ? "per editor, " : ""}a ${interval}${plan === "team" && seats > 1 ? ` · $${(price(plan) * seats).toLocaleString()} for your ${seats} editors` : ""}`}
+
+
{FEATURES[plan].map((f) =>
{f}
)}
+
+ {current ? (
+ Your plan
+ ) : plan === "free" ? (
+ b.canManage && b.customer ? : null
+ ) : !b.canManage ? (
+ Only an owner can change the plan.
+ ) : b.subscribed ? (
+
+ ) : plan === "pro" && u.editors > 1 ? (
+ Pro is for one editor; {ws.name} has {u.editors}. Team fits.
+ ) : !b.checkout ? (
+ Paying isn't set up on this Studio yet.
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+
Enterprise: 10 million fetches or more, single sign-on, an audit log and an SLA, on a contract. Write to us from polyxd.com. Everything can be exported as JSON on every plan.
Studio is Apache-2.0, in apps/studio of the Polyxd repository, running on Cloudflare Workers with D1 and R2. Run it on your own account, or use the hosted one at studio.polyxd.com. Same code either way.
+
Run it yourself, or hosted
+
Studio's source is in apps/studio of the Polyxd repository, under the Functional Source License: run it free for your own team or company on Cloudflare Workers with D1 and R2, or use the hosted one at studio.polyxd.com. Same code either way.
@@ -131,7 +131,7 @@ export function Landing() {
Hosted
-
Free for one workspace. A small fee may cover its storage later.
+
Free for one workspace. Paid plans for bigger teams are coming.