Skip to content

Commit 786da4f

Browse files
committed
feat(widgets): spec-driven inputs, practitioner theme, one-tag widgets.js
Turn the components into copy-paste widgets for non-technical site owners. Everything derives from the spec, the manifest, and the bindings, so a new endpoint or component flows through with zero widget code. Input system v2: a param-shape registry maps every spec param to an input kind (sign tile picker with glyphs, filterable select above 12 options, boolean toggle, date, time, number, text). Required fields render visible, optional fields collapse under one Advanced disclosure, and a form whose only visible required field is an enum submits on selection. Adds lang, submit-label and attribution attributes, person-group cards, visible validation, and a sticky picker with an Edit affordance. Perf: per-operation schema slices at dist/schemas replace the 1.5 MB runtime spec fetch, keeping the full-spec fetch as a fallback. Security: location-search now fails closed on a secret key like every other component, via a shared key guard. Theme: new --roxy-font-display token (defaults to sans) plus dist/styles/themes/practitioner.css, a rosewater light and dark preset. The Customise dialog gains a Presets dropdown. widgets.js: the hardcoded 16-entry endpoint map is gone. The map is generated at build from bindings x manifest x spec (51 slugs). When every required param is present as data attributes it fetches immediately, otherwise it renders form mode. Guarded against double-defining elements when the full bundle is also on the page. Demo: a fourth Embed tab per card, derived from the bindings mirror with zero per-card authoring. CI: a bundle-size gate (150 KB gzip full, 30 KB gzip per component, 8 KB raw widgets.js) wired into ci.yml and lefthook, plus a taxonomy gate asserting every spec param resolves to a known input kind. Spec reconciled with live, picking up the human-design nodeType toggle. All changes are additive. Controlled mode, tag names, token names and event names are unchanged, so the WordPress plugin contract and every data-prop consumer stay unaffected.
1 parent f32b646 commit 786da4f

150 files changed

Lines changed: 6644 additions & 537 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ jobs:
2929
- run: bun run typecheck
3030
- run: bun run build
3131
- run: bun run test
32+
- name: Bundle-size budget (gzip full + per-component, raw widgets.js)
33+
run: bun run size:check
3234

3335
# packages/ui-{react,vue}/src is GENERATED from packages/ui. A diff here
3436
# means generated output was hand-edited; the next build would revert it.

AGENTS.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ For a static chart with no picker, fetch in a Server Component and pass `data` t
362362

363363
### Pattern 3: schema-driven form
364364

365-
`<roxy-endpoint-form>` reads the OpenAPI spec and renders the inputs for any endpoint. On `roxy-submit`, POST the validated values to your own route, which calls the SDK with the secret key, then set the returned data on the target component.
365+
`<roxy-endpoint-form>` reads the OpenAPI spec and renders the inputs for any endpoint. On `roxy-submit`, POST the validated values to your own route, which calls the SDK with the secret key, then set the returned data on the target component. For an endpoint that needs coordinates, add `publishable-key="pk_live_..."` so the built-in city search can geocode.
366366

367367
```html
368368
<roxy-endpoint-form
@@ -393,7 +393,7 @@ When you do not want a backend at all, mint a **publishable key** (`pk_live_*` /
393393
></roxy-natal-chart>
394394
```
395395

396-
That single element shows a schema-driven form (city search included for endpoints that need coordinates), fetches on submit, shows a loading then error-or-result state, and re-shows the form so the user can try another query. `method` defaults to `POST`; set `method="GET"` for GET endpoints. Set `data-endpoint` to the spec path without the leading slash (`dreams/symbols/{id}`, `astrology/horoscope/{sign}/daily`).
396+
That single element renders a schema-driven form (a zodiac/enum tile picker, a boolean toggle, native date and time inputs, and a city search for endpoints that need coordinates), fetches on submit, and shows a loading then error-or-result state. Optional fields collapse under one Advanced disclosure, and a form whose only required field is an enum submits on selection (tap a sign, get a reading, no button). The result keeps a re-query affordance: a single-enum picker stays above the result and refetches when the selection changes, any other form gets a compact Edit query control that restores it with the previous values. `method` defaults to `POST`; set `method="GET"` for GET endpoints. Set `data-endpoint` to the spec path without the leading slash (`dreams/symbols/{id}`, `astrology/horoscope/{sign}/daily`).
397397

398398
**Key handling is the contract. The component enforces it, not you:**
399399

@@ -403,6 +403,12 @@ That single element shows a schema-driven form (city search included for endpoin
403403

404404
In React, the same props are typed: `<RoxyNatalChart endpoint="astrology/natal-chart" publishableKey={process.env.NEXT_PUBLIC_ROXY_PK} />`.
405405

406+
**Three optional attributes on the self-fetch element.** `lang` sets the response language (`en`, `tr`, `de`, `es`, `hi`, `pt`, `fr`, `ru`): put it on the element (`<roxy-horoscope-card lang="de" ...>`) and the form routes it to the `?lang=` query on submit, so visitors never see a language field. `submit-label` overrides the derived button label. `attribution` renders a small "Spiritual data by RoxyAPI" credit under the result: off by default, and the one-tag script below turns it on unless you set `data-attribution="off"`. None of these apply in controlled mode.
407+
408+
**One tag, no element wiring.** For the simplest embed, load `dist/cdn/widgets.js` and drop a `<div data-roxy-widget="{slug}" data-publishable-key="pk_live_...">`. The script mounts the matching component from a generated slug map: with every path parameter supplied as a `data-*` attribute (`data-sign`, `data-id`) it fetches on mount, otherwise it renders the same input form. A second `data-*` attribute picks a variant (`data-period`, `data-mode`, `data-type`, `data-spread`).
409+
410+
**Theme every widget in one link.** Add `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/styles/themes/practitioner.css">` for a warm rosewater serif look, or set the `--roxy-font-display` token alone to swap result headings to a display face (it defaults to the body font, so nothing changes until you set it). See [THEMING.md](packages/ui/THEMING.md).
411+
406412
### Pattern 5: MCP tool-call response
407413

408414
A remote MCP server at `roxyapi.com/mcp/{domain}` exposes each RoxyAPI endpoint as an MCP tool. The JSON returned by the tool call has the same shape as the SDK response. Pass it straight into the matching component.
@@ -490,7 +496,7 @@ const { data } = await roxy.humanDesign.generateBodygraph({
490496
});
491497
```
492498

493-
`lang` is a query parameter even on a POST endpoint. Put it in `query`, never in `body`; a `lang` key in the body is ignored and you get English back. In the self-fetch pattern the component reads the parameter from the spec and sends it correctly, so the generated form simply offers the language as a field.
499+
`lang` is a query parameter even on a POST endpoint. Put it in `query`, never in `body`; a `lang` key in the body is ignored and you get English back. In the self-fetch pattern you do not fill a language field: set `lang` as an attribute on the element (`<roxy-horoscope-card lang="de" ...>`) and the form routes it to the `?lang=` query on submit.
494500

495501
The structural labels the components draw (section headings such as Reading or Centers, and column headers) are English.
496502

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,8 @@ Two ways to feed a component, and the key rule for each:
602602

603603
Set `ROXY_API_KEY` to your secret key in your server env for the server-side SDK examples on this page. For self-fetch embedding with no backend, use a publishable key (see the fully client-side pattern in [`AGENTS.md`](AGENTS.md)).
604604

605+
The self-fetch form renders spec-driven inputs (a zodiac tile picker, a boolean toggle, native date and time, a city search), collapses optional fields under one Advanced disclosure, and reads a `lang` attribute for localized responses. For the simplest embed, load `dist/cdn/widgets.js` and drop one `<div data-roxy-widget="{slug}" data-publishable-key="pk_live_...">`: with the required attributes present it fetches on mount, otherwise it renders the form. A single `<link>` to the [practitioner theme preset](https://cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/styles/themes/practitioner.css) restyles every widget on the page.
606+
605607
## Distribution
606608

607609
| Surface | URL |
@@ -611,7 +613,8 @@ Set `ROXY_API_KEY` to your secret key in your server env for the server-side SDK
611613
| npm `@roxyapi/ui-vue` | `npmjs.com/package/@roxyapi/ui-vue` |
612614
| jsDelivr CDN (full bundle) | `cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/cdn/roxy-ui.js` |
613615
| jsDelivr CDN (per component) | `cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/cdn/components/{name}.js` |
614-
| Widgets auto-mount (with browser keys, coming soon) | `cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/cdn/widgets.js` |
616+
| Widgets auto-mount (one tag, browser keys) | `cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/cdn/widgets.js` |
617+
| Practitioner theme preset (one link, warm rosewater) | `cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/styles/themes/practitioner.css` |
615618
| shadcn registry | `npx shadcn@latest add https://cdn.jsdelivr.net/gh/RoxyAPI/ui@latest/registry/{name}.json` |
616619
| Components catalog (JSON: every component, domain, and endpoint) | `cdn.jsdelivr.net/npm/@roxyapi/ui@latest/components-catalog.json` |
617620

@@ -771,7 +774,7 @@ Persist the choice in `localStorage` from your own code; the components do not o
771774
<details>
772775
<summary><strong>How big is each component? What is the bundle cost?</strong></summary>
773776

774-
Per-component bundles run 9-21 KB gzipped, capped at 30 KB by CI. The full bundle (every component, helpers, base styles, and the inlined design tokens) stays well under the 150 KB CI cap, around 85 KB gzipped today. The React and Vue packages load the runtime on mount, so a route that renders one chart pays for one component, not the whole catalog. Pin a concrete version in production for byte-stable cache hits.
777+
Per-component bundles run 13-26 KB gzipped, capped at 30 KB by CI. The full bundle (every component, helpers, base styles, and the inlined design tokens) stays well under the 150 KB CI cap, around 108 KB gzipped today. The React and Vue packages load the runtime on mount, so a route that renders one chart pays for one component, not the whole catalog. Pin a concrete version in production for byte-stable cache hits.
775778
</details>
776779

777780
<details>
@@ -895,11 +898,11 @@ For CSP, allow `script-src https://cdn.jsdelivr.net` if loading the bundle from
895898
Semver. Pre-1.0, minor bumps may include breaking changes (we will note them in the changelog). Patch bumps are always backwards-compatible. Pin a concrete version in production code:
896899

897900
```bash
898-
npm install @roxyapi/ui@0.8.x
901+
npm install @roxyapi/ui@0.15.x
899902
```
900903

901904
```html
902-
<script src="https://cdn.jsdelivr.net/npm/@roxyapi/ui@0.8.0/dist/cdn/roxy-ui.js"></script>
905+
<script src="https://cdn.jsdelivr.net/npm/@roxyapi/ui@0.15.0/dist/cdn/roxy-ui.js"></script>
903906
```
904907

905908
The `@latest` URL on this page is for paste-friendly marketing; production code should pin.

apps/docs/components-manifest.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,74 @@ const PERSON2 = {
2525

2626
const REGISTRY_BASE = 'https://cdn.jsdelivr.net/gh/RoxyAPI/ui@main/registry';
2727
const UI_CDN = 'https://cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/cdn';
28+
const PRACTITIONER_THEME_URL =
29+
'https://cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/styles/themes/practitioner.css';
2830

2931
const MANIFEST_BY_TAG = Object.fromEntries(
3032
(window.ROXY_COMPONENTS || []).map((c) => [c.tag, c]),
3133
);
3234

35+
// Generated endpoint map (window.ROXY_ENDPOINT_BINDINGS, mirrored by
36+
// scripts/sync-manifest.ts). The Embed tab derives from it, so a binding change
37+
// flows through with no per-card edit here.
38+
const ENDPOINT_BINDINGS = window.ROXY_ENDPOINT_BINDINGS || {};
39+
3340
function lookup(tag) {
3441
const meta = MANIFEST_BY_TAG[tag];
3542
if (!meta) throw new Error(`Unknown tag in demo manifest: ${tag}`);
3643
return meta;
3744
}
3845

46+
/**
47+
* Derive the Embed-tab snippets for an endpoint-bound component from its tag,
48+
* slug, and the generated endpoint bindings. The tag's FIRST binding (the
49+
* widgets-map default; bindings are path-sorted) drives both the script-mode
50+
* element and the one-tag widgets.js div, so there is zero per-card authoring.
51+
* Returns null for a component with no binding (the three helpers), and the demo
52+
* hides the tab for those.
53+
*/
54+
function embedSnippet(tag, slug) {
55+
const bindings = ENDPOINT_BINDINGS[tag];
56+
if (!bindings || !bindings.length) return null;
57+
const def = bindings[0];
58+
const endpoint = def.path.replace(/^\//, '');
59+
// POST is the element default, so only a GET binding needs an explicit method.
60+
const methodAttr = def.method === 'POST' ? '' : ` method="${def.method}"`;
61+
// The default variant's selector attribute (period/mode/type/spread/detail),
62+
// so the script element renders the same view the one-tag default resolves to.
63+
const configAttr = def.attrs
64+
? Object.entries(def.attrs)
65+
.map(([k, v]) => ` ${k}="${v}"`)
66+
.join('')
67+
: '';
68+
69+
const script = `<!-- Optional: warm practitioner theme (drop this line for the default look) -->
70+
<!-- <link rel="stylesheet" href="${PRACTITIONER_THEME_URL}"> -->
71+
<script src="${UI_CDN}/roxy-ui.js" defer></script>
72+
<${tag}${configAttr} data-endpoint="${endpoint}"${methodAttr} publishable-key="pk_live_..." lang="en"></${tag}>`;
73+
74+
const oneTag = `<script src="${UI_CDN}/widgets.js" defer></script>
75+
<div data-roxy-widget="${slug}" data-publishable-key="pk_live_..."></div>`;
76+
77+
// The selector attribute and its non-default values, surfaced on the hint line
78+
// so one data-* attribute on the one-tag div switches variant.
79+
const selector = def.attrs ? Object.keys(def.attrs)[0] : undefined;
80+
const otherValues = selector
81+
? bindings
82+
.slice(1)
83+
.map((b) => b.attrs && b.attrs[selector])
84+
.filter(Boolean)
85+
: [];
86+
const variantHint =
87+
selector && otherValues.length
88+
? ` Switch variant with data-${selector} (${otherValues.map((v) => `"${v}"`).join(', ')}) on the one-tag div.`
89+
: '';
90+
91+
const hint = `Mint a publishable key at roxyapi.com/account, register the origins you embed on, and replace the pk_live_ placeholder. Works on any site that allows script tags.${variantHint}`;
92+
93+
return { script, oneTag, hint };
94+
}
95+
3996
function serverRender(tag, body) {
4097
// body is indented one level for the shadcn RSC block; dedent it to sit at
4198
// the top level of this standalone server snippet.
@@ -84,6 +141,8 @@ function entry({ id, tag, seoLine, heading, topic, attrs = '', sdkCall, code, sh
84141
attrs,
85142
code: code ?? serverRender(tag, sdkCall),
86143
shadcn: shadcnOverride ?? shadcn(tag, sdkCall),
144+
// null for the three unbound helpers, so page.js hides the Embed tab.
145+
embed: embedSnippet(tag, meta.slug),
87146
};
88147
}
89148

apps/docs/index.html

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,27 @@
340340
}
341341
.copy-btn:hover { color: var(--roxy-fg); border-color: var(--roxy-fg); }
342342

343+
/* The card is a flex column and the grid cell is minmax(0, 1fr), but a
344+
* flex/grid item defaults to min-width: auto, so a long jsDelivr line in a
345+
* <pre> sets the card min-content wide and the pre never scrolls: it drags
346+
* the whole card past its cell and gets clipped. min-width: 0 lets the
347+
* panel shrink so the pre's own overflow-x scrollbar takes over. */
348+
[role="tabpanel"] { min-width: 0; }
349+
.code-block, .code-block pre { min-width: 0; }
350+
351+
.embed-panel .embed-lead {
352+
margin: 0;
353+
font-size: 0.8125rem;
354+
font-weight: 500;
355+
color: var(--roxy-fg);
356+
}
357+
.embed-panel .embed-hint {
358+
margin: 0;
359+
font-size: 0.78125rem;
360+
line-height: 1.5;
361+
color: var(--roxy-muted);
362+
}
363+
343364
.quickstart {
344365
background: color-mix(in srgb, var(--roxy-border) 14%, transparent);
345366
border: 1px solid var(--roxy-border);
@@ -592,7 +613,7 @@
592613
"name": "How do I theme RoxyAPI UI components?",
593614
"acceptedAnswer": {
594615
"@type": "Answer",
595-
"text": "Override CSS custom properties on the root or per element. About 30 design tokens cover color, typography, spacing, radius, shadow, and motion. No Tailwind required, no class-name overrides, no rebuild step. Light and dark defaults are built in. The Customize Colors panel on this page generates the override CSS for you."
616+
"text": "Override CSS custom properties on the root or per element. About 47 design tokens cover color, typography, spacing, radius, shadow, and motion. No Tailwind required, no class-name overrides, no rebuild step. Light and dark defaults are built in. The Customize Colors panel on this page generates the override CSS for you."
596617
}
597618
}
598619
]
@@ -774,7 +795,7 @@ <h2 id="faq-title">Frequently asked</h2>
774795
</details>
775796
<details>
776797
<summary>How do I theme components?</summary>
777-
<p>Override CSS custom properties on the root or per element. About 30 design tokens cover color, typography, spacing, radius, shadow, and motion. The Customize Colors panel above generates the override CSS for you.</p>
798+
<p>Override CSS custom properties on the root or per element. About 47 design tokens cover color, typography, spacing, radius, shadow, and motion. The Customize Colors panel above generates the override CSS for you.</p>
778799
</details>
779800
</section>
780801

@@ -787,6 +808,13 @@ <h2 id="customize-title">Customize colors</h2>
787808
<p style="color: var(--roxy-muted); font-size: 0.875rem; margin: 0 0 0.875rem;">
788809
Override CSS custom properties on <code>:root</code> for light, on <code>:root[data-theme="dark"]</code> for dark. Edits below preview live.
789810
</p>
811+
<div class="preset-row" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.875rem;">
812+
<label for="preset-select" style="font-size: 0.875rem; color: var(--roxy-muted);">Preset</label>
813+
<select id="preset-select" style="padding: 0.35rem 0.5rem; border: 1px solid var(--roxy-border); border-radius: 6px; background: var(--roxy-bg); color: var(--roxy-fg); font: inherit;">
814+
<option value="default">Default</option>
815+
<option value="practitioner">Practitioner</option>
816+
</select>
817+
</div>
790818
<div class="mode-tabs" role="tablist" aria-label="Theme mode">
791819
<button id="mode-light" type="button" role="tab" aria-selected="true" data-mode="light">Light</button>
792820
<button id="mode-dark" type="button" role="tab" aria-selected="false" data-mode="dark">Dark</button>
@@ -796,6 +824,13 @@ <h2 id="customize-title">Customize colors</h2>
796824
<button type="button" class="copy-btn" data-copy-target="customize-snippet">Copy</button>
797825
<pre><code id="customize-snippet"></code></pre>
798826
</div>
827+
<div id="preset-link-alt" hidden style="margin-top: 0.75rem;">
828+
<p style="color: var(--roxy-muted); font-size: 0.8125rem; margin: 0 0 0.375rem;">Or link the preset stylesheet, no snippet to paste:</p>
829+
<div class="code-block">
830+
<button type="button" class="copy-btn" data-copy-target="preset-link-snippet">Copy</button>
831+
<pre><code id="preset-link-snippet"></code></pre>
832+
</div>
833+
</div>
799834
</section>
800835
<footer>
801836
<button type="button" id="customize-reset" class="btn btn--xs btn--ghost">Reset</button>

0 commit comments

Comments
 (0)