Files
heygen-com__hyperframes/docs/snippets/variables-explorer.jsx
Miguel Ángel 1ae2067b8d feat(catalog): put the variables panel back, on payloads (#3199)
* feat(catalog): put the variables panel back, on payloads

The panel drove its preview by loading an .html from docs/public, a type the
host does not publish, so it showed an empty frame in production and was
parked when the catalog was re-landed.

It now mounts the same JSON payload the plain player uses and re-mounts it as
values change, injecting them as window.__hfVariables into the composition head
before any of its scripts run, which is where the runtime reads overrides from.
Doing it in the markup rather than after load is what stops the composition
initialising with the wrong values first.

172 items with variables get the panel back; the playhead carries across a
change so a tweak mid-shot does not jump back to frame zero.

* fix(docs): drop the unused url form and the needless escapes

* fix(docs): the panel cannot reference a binding beside the export

* feat(catalog): make importing an SVG the obvious move

A reader arrives at this control with a shape, not with path data, and the
panel asked for the coordinates first. Import is now the primary action in a
drop target you can see is a drop target, and the raw path sits behind a
disclosure for anyone who wants it.

* feat(cli): let a fruitless catalog search report the gap

An agent that searches by meaning and finds nothing worth installing knows
something we do not: the name of a move the catalog is missing. There was no
way to tell us, so that knowledge was lost at the end of every run.

hyperframes feedback --search-miss "<query>" --wanted "<the move>" records it.
It carries no rating, so it never lands in the rating metric, and it is a
separate deliberate command rather than something catalog --query does on its
own: plain search still sends nothing, which is what the CLI promises.

--rating stops being required at the arg level, since a miss has no rating to
give. The check moved into the run body, where an absent one is now handled
rather than crashing on undefined.

* feat(cli): carry tuned variable values into the install snippet

Someone who tunes a block on its catalog page had no way to keep those values:
the install command was the same one everybody gets, and the tuning stayed on
the page.

hyperframes add <item> --vars '<json>' now prints a mount element carrying
data-variable-values, so the values land where the block is used.

They ride on the host rather than being written into the installed file. That
keeps the composition on disk byte-identical to the registry's, so a later
reinstall can still tell an edit from an update, and it lets two mounts of the
same block carry different values.

* fix(catalog): serve the item's own directory so runtime paths resolve

Some compositions assemble their asset URLs at run time —
"compositions/components/" + texture + ".png" for the texture masks, a font the
compiler pulled into _remote_media — and no scan of the markup can see a string
that does not exist until a script concatenates it. Those items either rendered
black or were dropped to a video that had never been uploaded.

Each item that needs it now has its prepared directory published, and its
payload carries a <base> pointing at it, so any relative path the composition
invents resolves. caption-texture renders its masks again, and
variable-font-flex has a preview at all for the first time: its MP4 and poster
are both 403.

Both layouts are published, because which one a composition asks for differs
per item, and a directory only earns that if it is under 2 MB. The 12 MB
texture sheet keeps the recorded video it already had.

* fix(catalog): let the variables panel actually drive the composition

Every control on the panel was inert. The values reached the composition and
nothing repainted, because the payload had already been compiled: compiling
inlines a mounted component and resolves its variables into the markup and CSS,
so by the time a reader turns a knob there is nothing left to change.

An item that declares variables now ships uncompiled, keeping the mount the
runtime loads at run time, which is the only state where data-variable-values
still means anything. The component travels inline as a data URI rather than a
sibling file, because .html is the one type the docs host will not publish. The
demo's own pinned values come off, so the reader's choices reach the mount
instead of losing to the values the demo picked to show itself off.

Measured on the rendered frame rather than the DOM: green rgb(98,207,144),
blue rgb(6,6,199), violet rgb(177,147,230), and back to green.

docs/public/catalog drops from 48 MB to 35 MB along the way, since an
uncompiled payload carries far less than an inlined one.

* feat(catalog): keep variable changes in the url

A reader who tuned a piece lost it on reload, and had nothing to send anyone.
The values now live in the query string, scoped by composition id so two links
never read each other,and only the ones that differ from the defaults are
written, so changing one knob gives a short URL rather than every variable
spelled out.

replaceState rather than pushState: dragging a slider should not leave a trail
of history entries. An unreadable value is ignored rather than thrown, so a
truncated or hand-edited link opens the piece at its defaults.

* fix(catalog): only rewrite the url when a value actually changed

* refactor(catalog): memoise the declared defaults on their content

* feat(catalog): offer an install command carrying the tuned values

The Install block is generated before anyone touches a knob, so it can only
ever print the plain command. Someone who spent a minute tuning a piece copied
it and got the defaults back.

The panel now carries its own command in the Snippet tab, with --vars holding
exactly the values that differ. An untouched piece still offers the same short
command, so nothing gets noisier for the common case.

* fix(catalog): a piece with nothing to render is a skip, not a failure

caption-blend-difference is a stylesheet and a paragraph of prose — a class you
add to your own captions, with no standalone scene to show. The generator
treated that as a build failure, so every run ended by reporting something
broken when nothing was.

It now reports the shape it is and keeps its recorded video, which is the only
honest preview such an item has. A genuine render failure still throws.

* fix(catalog): restore variables from the url on a cold load

A shared link opened at the defaults. The first render happens on the server,
where there is no window to read the query string from, and React then hydrates
against that markup and never revisits it — so the values only appeared once you
touched a control.

The URL is read again after mount, which is the first moment it exists. The
value is also escaped once now rather than twice: URLSearchParams already
decodes on the way out, and decoding a second time turned an SVG path full of
percent-escapes into something that no longer parsed, besides doubling the
length of every link.

* fix(catalog): mount the preview with the values a link carried

The frame was built from the declared defaults and the shared values were
posted to it afterwards, which is too late for anything the composition reads
once at init: a path arrived after the mark had already been drawn from the
default one, so a link looked right in the panel and wrong on screen.

* feat(catalog): the install command follows the values you tuned

Copying the Install line gave the plain command back, because that block is
generated before anyone touches a knob and had no way to know what changed. The
tuned command only existed in the panel Snippet tab, which is not where anyone
looks for it.

The line now reads the same query string the panel writes, so the two agree
without either component knowing the other exists, and a shared link carries the
right command too. replaceState fires no event, so the panel announces its own
writes.

* fix(catalog): send a text variable to the preview once it is finished

Every other control in the explorer reports a whole value on every event: a
slider at any position is a position, a swatch is a colour. A text field is
not. Typing v3 into a badge posted v first, so the preview remounted and
rendered a composition built from half a word.

The post now waits while a text field has focus and goes out when the edit is
committed, with Enter or by clicking away. The field itself is unchanged and
still tracks every keystroke.

---------

Co-authored-by: Miguel Angel Simon Sierra <miguelangelsi07@gmail.com>
2026-08-10 22:40:01 -04:00

1934 lines
75 KiB
React

/**
* The interactive variables explorer on a catalog page.
*
* Replaces the static Variables table and the paste-ready snippet that used to
* sit under it. A table tells a reader that `glow` accepts `none | standard |
* strong`; it cannot tell them what `strong` looks like. This can, so the two
* halves are one control: change it here, watch the preview above change.
*
* How a value reaches the running composition
* -------------------------------------------
* A composition reads its variables once, at init, before any of this exists.
* So there is no "apply" path into a live one — the value has to be present
* before the composition boots. The preview wrapper (generated by
* scripts/generate-catalog-pages.ts alongside this) listens for the message
* posted below and reloads only the inner composition frame with the values in
* its query string, carrying the playhead across. The page, the wrapper and
* this panel never blink; the reload is one nested iframe deep.
*
* Which is why the post is debounced. A dragged range input fires an event per
* pixel, and an unthrottled reload per pixel is a reload storm that never
* settles. The control itself is never debounced — it tracks the pointer
* exactly, because a control that lags its own input feels broken.
*
* Why the colours are not utility classes
* ---------------------------------------
* Mintlify serves a prebuilt Tailwind, so a `dark:` utility only resolves if
* some other page already used it. `dark:bg-zinc-700` and `dark:text-zinc-100`
* both silently no-op here, which renders white text on a white pill. Layout
* utilities are safe and used throughout; every colour comes from the two token
* blocks below, where this file owns both the values and the cascade order.
*
* The three tabs, and why two of them are highlighted differently
* ----------------------------------------------------------------
* Preview is the running composition. Code is its source. Snippet is the mount
* element carrying whatever the reader has just dialled in — the one thing on
* the page they are meant to copy, and the reason the panel below exists.
*
* Mintlify highlights a fenced block at build time with shiki. The source is
* static, so it goes through a real fence: the generator writes it as ```html
* children of this component and it arrives already coloured, indistinguishable
* from every other code block on the site, with no colour table here involved.
*
* The snippet cannot. It changes on every keystroke and every drag, and there
* is no build step at that point. So it is assembled token by token into the
* markup a fence would have produced and handed to <CodeBlock>, which supplies
* the same container, filename header and copy button. SHIKI below is that
* theme's palette, copied verbatim from a rendered fence on this same site.
*
* The one control that is not just a control
* ------------------------------------------
* A variable holding raw SVG path data cannot be authored in a text field, so
* that field also takes a file. The exports at the foot of this file are what
* turn a dropped or picked SVG into path data in the composition's own
* coordinate space; the panel writes the result through `onChange` like any
* other edit, so it reaches the preview by the same debounced post as the rest.
*/
export const VariablesExplorer = ({
previewSrc,
compositionId,
compositionSrc,
variables,
children,
}) => {
/**
* github-light-default / dark-plus, the pair Mintlify renders fences with.
* The light value is the `color`, the dark one the `--shiki-dark` custom
* property the site's own stylesheet swaps in — shiki's own two-value scheme.
*
* Inside the component, not beside it: MDX only carries a snippet's exported
* bindings into the page, so a bare module-level `const` is undefined by the
* time this renders.
*/
const SHIKI = {
punct: { color: "rgb(31, 35, 40)", "--shiki-dark": "#808080" },
tag: { color: "rgb(17, 99, 41)", "--shiki-dark": "#569CD6" },
attr: { color: "rgb(5, 80, 174)", "--shiki-dark": "#9CDCFE" },
equals: { color: "rgb(31, 35, 40)", "--shiki-dark": "#D4D4D4" },
value: { color: "rgb(10, 48, 105)", "--shiki-dark": "#CE9178" },
};
const CSS = `
.hf-ve {
--ve-fg: #18181b;
--ve-muted: #71717a;
--ve-line: #e4e4e7;
--ve-surface: #ffffff;
--ve-sunken: #fafafa;
--ve-hover: #f4f4f5;
--ve-on-bg: #18181b;
--ve-on-fg: #ffffff;
--ve-ring: rgba(24, 24, 27, 0.14);
--ve-danger: #b42318;
}
:where(html.dark) .hf-ve {
--ve-fg: #f4f4f5;
--ve-muted: #a1a1aa;
--ve-line: #27272a;
--ve-surface: #18181b;
--ve-sunken: #131316;
--ve-hover: #27272a;
--ve-on-bg: #f4f4f5;
--ve-on-fg: #18181b;
--ve-ring: rgba(244, 244, 245, 0.2);
--ve-danger: #ff9d95;
}
.hf-ve-tabs {
display: inline-flex;
gap: 2px;
padding: 3px;
border: 1px solid var(--ve-line);
border-radius: 9999px;
background: var(--ve-sunken);
}
.hf-ve-tab {
padding: 4px 12px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
color: var(--ve-muted);
background: transparent;
}
.hf-ve-tab[data-on="true"] {
color: var(--ve-fg);
background: var(--ve-surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.hf-ve-tab:hover:not([data-on="true"]) { color: var(--ve-fg); }
/* Not a grid. A grid row is as tall as its tallest cell, so a five-line snippet
sat in the preview's 16:9 box with 300px of dead area under it. The inactive
pane is taken out of flow instead — and stretches left/right rather than to
inset 0, so the iframe keeps its own height. An iframe resized on every tab
switch reflows the composition running inside it. */
.hf-ve-frame { position: relative; }
.hf-ve-cell { min-width: 0; }
.hf-ve-cell[data-on="false"] {
position: absolute;
top: 0;
left: 0;
right: 0;
visibility: hidden;
pointer-events: none;
}
.hf-ve-preview {
overflow: hidden;
border: 1px solid var(--ve-line);
border-radius: 12px;
}
/* CodeBlock carries the page margins (mt-5 mb-8) that separate it from prose,
which is dead space inside a tab. Element plus two classes out-specifies a
Tailwind utility without !important. */
.hf-ve-cell > div.code-block { margin: 0; }
/* The snippet wraps; the source does not.
A value the reader has to read in full should not hide half of itself off the
right edge, so the snippet pane wraps — what \`\`\`html wrap does for a fence.
The width reset is the half that matters: the block's own <code> is sized to
max-content, and content that never meets an edge never wraps.
Source is left to scroll sideways like every other code block on the site.
Wrapping it breaks its indentation, and a comment paragraph re-flowed to a
narrow column reads worse than one the reader can scroll. */
.hf-ve-snippet .shiki,
.hf-ve-snippet .shiki code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* A composition source runs to several hundred lines, and an un-capped tab
pushes the Customize panel off the screen. The cap goes on the scroll box
rather than the block, so the filename and its copy button stay put; and it
is a max-height, so a short source still hugs its own content and the dead
area under it stays gone. */
.hf-ve-cell .code-block pre {
max-height: 460px;
overflow: auto;
}
/* Four classes deep because the rule being answered is three
(\`html:not(.dark) .codeblock-light pre.shiki code\`), and a shorter selector
silently loses to it. */
.hf-ve .hf-ve-snippet .code-block pre.shiki code {
width: auto;
min-width: 0;
}
.hf-ve-panel {
margin-top: 12px;
border: 1px solid var(--ve-line);
border-radius: 12px;
}
.hf-ve-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid var(--ve-line);
}
.hf-ve-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ve-muted);
}
.hf-ve-grid {
display: grid;
gap: 16px 28px;
padding: 16px;
}
@media (min-width: 640px) {
.hf-ve-grid { grid-template-columns: 1fr 1fr; }
}
.hf-ve-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.hf-ve-label { font-size: 14px; font-weight: 500; color: var(--ve-fg); }
.hf-ve-value {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ve-muted);
}
.hf-ve-desc {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--ve-muted);
}
.hf-ve-btn {
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
color: var(--ve-muted);
background: transparent;
}
.hf-ve-btn:hover:not(:disabled) { color: var(--ve-fg); background: var(--ve-hover); }
.hf-ve-btn:disabled { opacity: 0.4; cursor: default; }
.hf-ve-field {
width: 100%;
height: 36px;
padding: 0 12px;
border: 1px solid var(--ve-line);
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
color: var(--ve-fg);
background: var(--ve-surface);
}
.hf-ve-field::placeholder { color: var(--ve-muted); }
.hf-ve-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
/* Focus, once, for every control here. A ring outside the border rather than a
border colour alone: the border already carries the resting state, so
recolouring it is a change a reader can miss. Nothing shifts, because the
ring is a shadow. :focus-visible, so a pointer click does not light it up. */
.hf-ve-field:focus-visible,
.hf-ve-seg-btn:focus-visible,
.hf-ve-tab:focus-visible,
.hf-ve-btn:focus-visible,
.hf-ve-switch:focus-visible,
.hf-ve-swatch:focus-visible {
outline: none;
border-color: var(--ve-on-bg);
box-shadow: 0 0 0 3px var(--ve-ring);
}
/* A range is a track, and ringing the track rings a pill the width of the
panel. The thumb is the part that has focus, so the thumb is what says so. */
.hf-ve-range:focus-visible { outline: none; }
.hf-ve-range:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 4px var(--ve-ring); }
.hf-ve-range:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 4px var(--ve-ring); }
/* Safari still fires :focus for a click on a button, so the pair is kept. */
.hf-ve-field:focus { outline: none; border-color: var(--ve-on-bg); }
/* The enum branch above sends anything past four options here. No shipped item
does today (every enum in the registry has two to four), so this is styled to
the point of not looking foreign and no further — the chevron is one neutral
grey rather than a per-theme pair, because a data URI cannot read a token. */
.hf-ve-select {
appearance: none;
-webkit-appearance: none;
padding-right: 34px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%2389898f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
cursor: pointer;
}
.hf-ve-seg {
display: flex;
padding: 2px;
border: 1px solid var(--ve-line);
border-radius: 8px;
}
.hf-ve-seg-btn {
flex: 1;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: var(--ve-muted);
background: transparent;
}
.hf-ve-seg-btn:hover:not([data-on="true"]) { color: var(--ve-fg); background: var(--ve-hover); }
.hf-ve-seg-btn[data-on="true"] { color: var(--ve-on-fg); background: var(--ve-on-bg); }
/* A range, rebuilt. \`accent-color\` alone leaves the platform's hairline track,
which reads as an unstyled browser part next to everything else here. Each
engine names its parts differently and shares none of them, so the same
track and thumb are written twice; a selector either engine cannot parse
drops the whole rule, which is why they are never grouped. */
.hf-ve-range {
width: 100%;
height: 20px;
appearance: none;
-webkit-appearance: none;
border: 0;
border-radius: 9999px;
background: transparent;
cursor: pointer;
}
/* --ve-fill is set per render: painting progress on a native track means a
two-stop gradient, and only the component knows where the value sits. */
.hf-ve-range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 9999px;
background: linear-gradient(
to right,
var(--ve-on-bg) var(--ve-fill, 0%),
var(--ve-line) var(--ve-fill, 0%)
);
}
.hf-ve-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
margin-top: -5px;
border: 2px solid var(--ve-on-bg);
border-radius: 9999px;
background: var(--ve-surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14);
}
.hf-ve-range::-moz-range-track { height: 6px; border-radius: 9999px; background: var(--ve-line); }
.hf-ve-range::-moz-range-progress { height: 6px; border-radius: 9999px; background: var(--ve-on-bg); }
.hf-ve-range::-moz-range-thumb {
width: 16px;
height: 16px;
border: 2px solid var(--ve-on-bg);
border-radius: 9999px;
background: var(--ve-surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14);
}
/* Hover reads on the part being aimed at rather than on the whole strip: a
track that darkens under the pointer says "click me and the thumb comes
here", which is what a native range actually does. */
.hf-ve-range:hover::-webkit-slider-thumb { border-color: var(--ve-fg); }
.hf-ve-range:hover::-moz-range-thumb { border-color: var(--ve-fg); }
/* The number control, and the three things it used to leave unsaid.
*
* It could not say where the range ends, so a reader dragging \`stroke_width\`
* had no idea whether 12 was nearly nothing or nearly everything: the ends now
* carry min and max.
*
* It could not say where the author left the knob, which is the one reference
* point a panel built around deviating from the author's choice needs: a mark
* on the track is the default, and a double click puts the value back on it.
*
* And it printed the value in the label row, a fixed distance from a thumb that
* moves, so reading a drag meant looking in two places at once. The value rides
* the thumb instead.
*
* Still a real <input type="range">. Every custom slider on the web reimplements
* keyboard stepping, touch, and the screen-reader contract, and most of them do
* one of the three badly; none of what is added here needed the element
* replaced. Nothing moves that a finger is not moving: the value tracks the
* pointer because it is the pointer's own readout, and the only transition is
* the colour one every other control here shares. */
.hf-ve-slider {
display: grid;
gap: 1px;
}
.hf-ve-ruler {
position: relative;
height: 17px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
font-variant-numeric: tabular-nums;
line-height: 17px;
color: var(--ve-muted);
}
.hf-ve-bound {
position: absolute;
top: 0;
}
.hf-ve-bound[data-end="min"] { left: 0; }
.hf-ve-bound[data-end="max"] { right: 0; }
/* An end steps aside rather than being overprinted by the value arriving on
top of it. Visibility, not opacity: there is no fade here, the label is
either the thing being read or it is out of the way. */
.hf-ve-ruler[data-near="min"] .hf-ve-bound[data-end="min"],
.hf-ve-ruler[data-near="max"] .hf-ve-bound[data-end="max"] {
visibility: hidden;
}
/* translateX is centring, not motion: the readout is as wide as its own digits
and has to hang half of that either side of the thumb. */
.hf-ve-readout {
position: absolute;
top: 0;
left: calc(var(--ve-fill, 0%) + var(--ve-fill-nudge, 0px));
transform: translateX(-50%);
color: var(--ve-fg);
font-weight: 600;
white-space: nowrap;
}
.hf-ve-track {
position: relative;
display: block;
}
.hf-ve-range { display: block; }
.hf-ve-default {
position: absolute;
top: 5px;
left: calc(var(--ve-default, 50%) + var(--ve-default-nudge, 0px));
width: 2px;
height: 10px;
margin-left: -1px;
border-radius: 1px;
background: var(--ve-muted);
pointer-events: none;
}
/* A switch, for the boolean type. It used to fall through to the text input at
the end of control(), which asked the reader to type the word "true". */
.hf-ve-switch {
display: inline-flex;
align-items: center;
width: 40px;
height: 24px;
padding: 2px;
border: 1px solid var(--ve-line);
border-radius: 9999px;
background: var(--ve-sunken);
cursor: pointer;
}
.hf-ve-switch[data-on="true"] { border-color: var(--ve-on-bg); background: var(--ve-on-bg); }
.hf-ve-switch-dot {
width: 18px;
height: 18px;
border-radius: 9999px;
background: var(--ve-surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}
.hf-ve-switch[data-on="true"] .hf-ve-switch-dot {
background: var(--ve-on-fg);
transform: translateX(16px);
}
/* The SVG import control: the same text field, with a way to fill it.
*
* The whole block is the drop target, not just the field, so a file let go over
* the button lands too. It says so by taking the same border colour a focused
* field takes, which is the one feedback channel this panel has left.
*
* A bare <input type="file"> is unlabelled, unstyleable and reads as "No file
* chosen" next to controls that carry their own value, so the real input is
* taken out of the layout and the tab order and a button in front of it is what
* a reader sees and what a keyboard reaches. Hidden with size and opacity
* rather than display:none, because an input that is not rendered at all is one
* some browsers decline to open a picker for. */
.hf-ve-drop {
display: grid;
gap: 8px;
}
.hf-ve-drop[data-over="true"] .hf-ve-dropzone {
border-color: var(--ve-on-bg);
border-style: solid;
}
/* The import is the action almost everyone wants: a reader arrives with a
shape, not with path data. A dashed target reads as "put a file here" on
sight, where a button beneath a field of coordinates read as an afterthought
to the coordinates. */
.hf-ve-dropzone {
display: grid;
justify-items: center;
gap: 6px;
padding: 18px 12px;
border: 1px dashed var(--ve-line);
border-radius: 10px;
background: var(--ve-surface);
text-align: center;
}
.hf-ve-dropzone .hf-ve-btn {
padding: 7px 16px;
font-size: 13px;
color: var(--ve-fg);
border-color: var(--ve-line);
background: var(--ve-bg);
}
.hf-ve-dropzone .hf-ve-btn:hover:not(:disabled) { background: var(--ve-hover); }
/* Path data stays reachable, but a reader has to ask for it. A native details
element rather than our own toggle, so it opens with the keyboard and is
announced as expandable without any wiring. */
.hf-ve-advanced > summary {
font-size: 12px;
color: var(--ve-muted);
cursor: pointer;
list-style: none;
padding: 2px 0;
}
.hf-ve-advanced > summary::-webkit-details-marker { display: none; }
.hf-ve-advanced > summary::before { content: "▸ "; }
.hf-ve-advanced[open] > summary::before { content: "▾ "; }
.hf-ve-advanced > summary:hover { color: var(--ve-fg); }
.hf-ve-advanced .hf-ve-field { margin-top: 6px; }
.hf-ve-file {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.hf-ve-note {
margin: 0;
min-width: 0;
font-size: 12px;
line-height: 1.4;
color: var(--ve-muted);
}
.hf-ve-note[data-tone="error"] { color: var(--ve-danger); }
.hf-ve-note[data-tone="ok"] { color: var(--ve-fg); }
.hf-ve-swatch {
width: 40px;
height: 32px;
flex-shrink: 0;
padding: 2px;
border: 1px solid var(--ve-line);
border-radius: 8px;
background: var(--ve-surface);
cursor: pointer;
}
/* Nothing here moves.
*
* A press-down scale on every button and field, a scale-in on the tab panes, a
* springing switch knob and a growing slider thumb were all flourish: the
* control had already told you what it did by changing colour, and the motion
* was a second, slower answer to a question already settled. What is left is
* one colour transition, short enough to read as immediate and long enough not
* to flicker. A control here may change colour; it does not move.
*
* Which is also why there is no prefers-reduced-motion block any more. There is
* no motion left to reduce. */
.hf-ve-tint {
transition:
background-color 100ms ease,
border-color 100ms ease,
color 100ms ease;
}
`;
// >>> svg-import geometry
//
// Inside the component, and marked, for two reasons that pull the same way.
// Mintlify carries each exported binding into the page on its own, so a
// second `export const` beside this one is not in scope here: referencing it
// throws `is not defined` and takes the explorer down. And the geometry is
// the half of this feature that fails silently, so it has to be testable
// without a browser. `scripts/variables-explorer.test.ts` reads the source
// between these two markers and evaluates it, which is why they are here and
// why the names below are part of the contract.
/**
* Two of the registry's 663 declared variables hold raw SVG path data, and
* nobody authors `M 92 328 C 178 142 292 138 366 276 ...` by hand. Everything
* below is one control: pick or drop an SVG, and its geometry arrives in the
* field as path data the composition already knows how to draw.
*
* Why the default value decides, and not the variable's name
* ----------------------------------------------------------
* Half a dozen registry variables are called `path` and are enums of preset
* motion shapes (`sweep`, `bulb`). Keying the control off the name would put a
* file picker on all of them. The declared default is the honest signal: a
* value that starts with a moveto is path data and nothing else is, so the
* control lands on exactly `svg-stroke-trace.path` and
* `offset-path-traveler.path` today, and on any future path primitive without
* a list to maintain.
*
* Why the geometry is refitted rather than lifted
* -----------------------------------------------
* The `d` attribute of an imported file is written in that file's own viewBox.
* `svg-stroke-trace` draws in `0 0 1024 520`; a 24-unit icon lifted verbatim
* into it is a speck in the top-left corner, and a 2000-unit poster is clipped
* to a fragment. Either reads as a broken feature rather than a faithful one.
*
* So the import is scaled to fit, aspect preserved, centred. The box it is
* fitted into is the bounding box of the variable's own declared default, which
* is the one piece of authored geometry both this panel and the composition
* agree on. It needs no per-composition table and it keeps each primitive's
* intent: the stroke trace's default sits inset in its viewBox and an import
* lands inset the same way, while the traveler's default deliberately starts
* and ends off-frame, so an imported route enters and leaves frame too.
*
* There is no "keep original coordinates" escape hatch. A control that always
* produces something visible beats one that is faithful and blank; if the
* original numbers are wanted they can still be pasted into the same field,
* which is untouched and still the primary input.
*/
const isSvgPathData = (value) => typeof value === "string" && /^\s*[Mm]\s*-?[\d.]/.test(value);
/**
* Path data as a list of `{ code, args }`, commands and numbers separated.
*
* Hand-written because the browser exposes no path parser: `getPathData()` was
* never shipped and the `SVGPathSeg` API that preceded it was removed. What is
* being parsed is small and fully specified, and the two places the grammar
* bites are both handled here: arc flags may be written as bare adjacent digits
* (`a1 1 0 011 1`), and a command's arguments may repeat without repeating the
* letter, where a second `M` pair is a lineto rather than another moveto.
*
* Throws on anything it cannot read, which is what turns a corrupt file into a
* message in the panel instead of an invalid `d` in the preview.
*/
const parsePathData = (d) => {
const source = String(d);
const arity = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
const commands = [];
let at = 0;
let code = "";
const separator = () => {
while (at < source.length && /[\s,]/.test(source[at])) at += 1;
};
const digits = () => {
while (at < source.length && source[at] >= "0" && source[at] <= "9") at += 1;
};
const number = () => {
separator();
const start = at;
if (source[at] === "+" || source[at] === "-") at += 1;
digits();
if (source[at] === ".") {
at += 1;
digits();
}
if (source[at] === "e" || source[at] === "E") {
at += 1;
if (source[at] === "+" || source[at] === "-") at += 1;
digits();
}
const text = source.slice(start, at);
const value = Number(text);
if (text === "" || !Number.isFinite(value)) {
throw new Error(`expected a number at character ${start + 1}`);
}
return value;
};
// A flag is one character, and the grammar allows it to touch the next one.
const flag = () => {
separator();
const character = source[at];
if (character !== "0" && character !== "1") {
throw new Error(`expected an arc flag at character ${at + 1}`);
}
at += 1;
return Number(character);
};
separator();
while (at < source.length) {
const character = source[at];
if (/[a-zA-Z]/.test(character)) {
// `arity[...] === undefined` rather than the `in` operator: Mintlify's
// snippet compiler walks this AST and has no handler for an `in`
// expression, and the throw it raises takes the whole page down.
if (arity[character.toUpperCase()] === undefined) {
throw new Error(`unknown command "${character}"`);
}
code = character;
at += 1;
} else if (code === "") {
throw new Error("path data must open with a command");
} else if (code === "M" || code === "m") {
// An implicit repeat of a moveto is a lineto, per the grammar.
code = code === "M" ? "L" : "l";
} else if (code === "Z" || code === "z") {
throw new Error(`expected a command at character ${at + 1}`);
}
const letter = code.toUpperCase();
const args = [];
if (letter === "A") {
args.push(number(), number(), number(), flag(), flag(), number(), number());
} else {
for (let taken = 0; taken < arity[letter]; taken += 1) args.push(number());
}
commands.push({ code, args });
separator();
}
if (commands.length === 0) throw new Error("path data is empty");
return commands;
};
/**
* The same path, as absolute `M` / `L` / `C` / `Q` / `Z` and nothing else.
*
* The point of the reduction is that every remaining argument list is a run of
* x/y pairs, so applying a matrix to any of them is the same three lines. The
* commands that are not pairs are the ones removed: `H` and `V` carry one
* coordinate, `A` carries radii and flags that a rotation or a mirror would
* silently invalidate, and `S` and `T` carry an implicit control point that is
* a reflection of the previous command's. Reflection is affine-covariant, so
* `S` would survive a transform on its own; it is resolved here anyway because
* an `A` in front of it becomes a cubic, and the reflection would then be taken
* from a control point the author never wrote.
*/
const normalisePathData = (commands) => {
const out = [];
let x = 0;
let y = 0;
let startX = 0;
let startY = 0;
let cubicControl = null;
let quadraticControl = null;
for (const { code, args } of commands) {
const letter = code.toUpperCase();
const relative = code !== letter;
const dx = relative ? x : 0;
const dy = relative ? y : 0;
const pairs = (values) => {
const mapped = [];
for (let index = 0; index + 1 < values.length; index += 2) {
mapped.push(values[index] + dx, values[index + 1] + dy);
}
return mapped;
};
let nextCubic = null;
let nextQuadratic = null;
if (letter === "M") {
const [px, py] = pairs(args);
out.push({ code: "M", args: [px, py] });
x = px;
y = py;
startX = px;
startY = py;
} else if (letter === "L") {
const [px, py] = pairs(args);
out.push({ code: "L", args: [px, py] });
x = px;
y = py;
} else if (letter === "H") {
x = args[0] + dx;
out.push({ code: "L", args: [x, y] });
} else if (letter === "V") {
y = args[0] + dy;
out.push({ code: "L", args: [x, y] });
} else if (letter === "C") {
const points = pairs(args);
out.push({ code: "C", args: points });
nextCubic = [points[2], points[3]];
x = points[4];
y = points[5];
} else if (letter === "S") {
const points = pairs(args);
const first = cubicControl ? [2 * x - cubicControl[0], 2 * y - cubicControl[1]] : [x, y];
out.push({ code: "C", args: [...first, ...points] });
nextCubic = [points[0], points[1]];
x = points[2];
y = points[3];
} else if (letter === "Q") {
const points = pairs(args);
out.push({ code: "Q", args: points });
nextQuadratic = [points[0], points[1]];
x = points[2];
y = points[3];
} else if (letter === "T") {
const points = pairs(args);
const control = quadraticControl
? [2 * x - quadraticControl[0], 2 * y - quadraticControl[1]]
: [x, y];
out.push({ code: "Q", args: [...control, ...points] });
nextQuadratic = control;
x = points[0];
y = points[1];
} else if (letter === "A") {
const endX = args[5] + dx;
const endY = args[6] + dy;
out.push(...arcToCubics(x, y, args[0], args[1], args[2], args[3], args[4], endX, endY));
x = endX;
y = endY;
} else if (letter === "Z") {
out.push({ code: "Z", args: [] });
x = startX;
y = startY;
}
cubicControl = nextCubic;
quadraticControl = nextQuadratic;
}
return out;
};
/**
* One elliptical arc as up to four cubics.
*
* The endpoint-to-centre conversion of the SVG implementation notes, then a
* quarter turn at a time, because a cubic tracks a circular arc well below 90
* degrees and visibly badly above it. The last segment's endpoint is restored
* to the authored one so a closed shape still closes exactly.
*/
const arcToCubics = (x1, y1, rx, ry, rotation, largeArc, sweep, x2, y2) => {
// Both degenerate cases are named by the spec: a zero radius draws a line,
// and coincident endpoints draw nothing at all.
if (x1 === x2 && y1 === y2) return [];
let radiusX = Math.abs(rx);
let radiusY = Math.abs(ry);
if (radiusX === 0 || radiusY === 0) return [{ code: "L", args: [x2, y2] }];
const phi = (rotation * Math.PI) / 180;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const midX = (x1 - x2) / 2;
const midY = (y1 - y2) / 2;
const primeX = cosPhi * midX + sinPhi * midY;
const primeY = -sinPhi * midX + cosPhi * midY;
// Radii too small to span the endpoints are scaled up until they just fit.
const oversize =
(primeX * primeX) / (radiusX * radiusX) + (primeY * primeY) / (radiusY * radiusY);
if (oversize > 1) {
const grow = Math.sqrt(oversize);
radiusX *= grow;
radiusY *= grow;
}
const denominator =
radiusX * radiusX * primeY * primeY + radiusY * radiusY * primeX * primeX;
const numerator =
radiusX * radiusX * radiusY * radiusY -
radiusX * radiusX * primeY * primeY -
radiusY * radiusY * primeX * primeX;
const factor =
(largeArc === sweep ? -1 : 1) * Math.sqrt(Math.max(0, numerator) / denominator);
const centrePrimeX = (factor * radiusX * primeY) / radiusY;
const centrePrimeY = (-factor * radiusY * primeX) / radiusX;
const centreX = cosPhi * centrePrimeX - sinPhi * centrePrimeY + (x1 + x2) / 2;
const centreY = sinPhi * centrePrimeX + cosPhi * centrePrimeY + (y1 + y2) / 2;
const angle = (ux, uy, vx, vy) => {
const length = Math.hypot(ux, uy) * Math.hypot(vx, vy);
const cosine = length === 0 ? 1 : Math.min(1, Math.max(-1, (ux * vx + uy * vy) / length));
return (ux * vy - uy * vx < 0 ? -1 : 1) * Math.acos(cosine);
};
const fromX = (primeX - centrePrimeX) / radiusX;
const fromY = (primeY - centrePrimeY) / radiusY;
const toX = (-primeX - centrePrimeX) / radiusX;
const toY = (-primeY - centrePrimeY) / radiusY;
const start = angle(1, 0, fromX, fromY);
let sweptAngle = angle(fromX, fromY, toX, toY);
if (!sweep && sweptAngle > 0) sweptAngle -= 2 * Math.PI;
if (sweep && sweptAngle < 0) sweptAngle += 2 * Math.PI;
const steps = Math.max(1, Math.ceil(Math.abs(sweptAngle) / (Math.PI / 2)));
const step = sweptAngle / steps;
const handle = (4 / 3) * Math.tan(step / 4);
const at = (t) => [
centreX + radiusX * Math.cos(t) * cosPhi - radiusY * Math.sin(t) * sinPhi,
centreY + radiusX * Math.cos(t) * sinPhi + radiusY * Math.sin(t) * cosPhi,
];
const slope = (t) => [
-radiusX * Math.sin(t) * cosPhi - radiusY * Math.cos(t) * sinPhi,
-radiusX * Math.sin(t) * sinPhi + radiusY * Math.cos(t) * cosPhi,
];
const out = [];
for (let index = 0; index < steps; index += 1) {
const from = start + index * step;
const to = from + step;
const [ax, ay] = at(from);
const [bx, by] = at(to);
const [aSlopeX, aSlopeY] = slope(from);
const [bSlopeX, bSlopeY] = slope(to);
out.push({
code: "C",
args: [
ax + handle * aSlopeX,
ay + handle * aSlopeY,
bx - handle * bSlopeX,
by - handle * bSlopeY,
bx,
by,
],
});
}
const last = out[out.length - 1];
last.args[4] = x2;
last.args[5] = y2;
return out;
};
/**
* Every point through a matrix. Only correct on the output of
* `normalisePathData`, where every argument list is a run of x/y pairs.
*/
const transformPathData = (segments, matrix) =>
segments.map(({ code, args }) => {
const moved = [];
for (let index = 0; index + 1 < args.length; index += 2) {
const x = args[index];
const y = args[index + 1];
moved.push(matrix.a * x + matrix.c * y + matrix.e, matrix.b * x + matrix.d * y + matrix.f);
}
return { code, args: moved };
});
/**
* Scale to fit, aspect preserved, centred: `source` placed inside `target`.
*
* A dimension the source does not have is not a constraint on it, so a flat
* horizontal route is sized by its width alone rather than collapsing to a
* scale of zero.
*/
const fitMatrix = (source, target) => {
// A dimension the source does not have is not a constraint on it: a flat
// horizontal route divides by zero, which is Infinity and loses the `min`
// to the dimension that is real. Guarding that explicitly was a branch that
// could not be told apart from this one, so it is not here.
const chosen = Math.min(target.width / source.width, target.height / source.height);
const scale = Number.isFinite(chosen) && chosen > 0 ? chosen : 1;
return {
a: scale,
b: 0,
c: 0,
d: scale,
e: target.x + target.width / 2 - (source.x + source.width / 2) * scale,
f: target.y + target.height / 2 - (source.y + source.height / 2) * scale,
};
};
/** Back to a `d` string. Two decimals is a hundredth of a viewBox unit. */
const printPathData = (segments) =>
segments
.map(({ code, args }) => {
if (args.length === 0) return code;
const numbers = args.map((value) => {
const rounded = Math.round(value * 100) / 100;
return String(Object.is(rounded, -0) ? 0 : rounded);
});
return `${code} ${numbers.join(" ")}`;
})
.join(" ");
/**
* The five shapes that are not a `<path>`, as path data.
*
* Rejecting them was the alternative, and it would reject most real files: a
* logo exported from a drawing tool is routinely a `<rect>` and two `<circle>`
* elements. Each conversion is the geometry the SVG shape module defines, and
* the curved ones are written with arcs so that `normalisePathData` reduces
* them by the same route an authored arc takes.
*
* Takes attributes rather than an element so it can be tested without a DOM.
* Returns null for a shape with no area to draw, which the caller counts as one
* more element it could not use.
*/
const shapePathData = (tag, attrs) => {
const number = (name, fallback = 0) => {
const value = parseFloat(attrs[name]);
return Number.isFinite(value) ? value : fallback;
};
if (tag === "path") {
const d = typeof attrs.d === "string" ? attrs.d.trim() : "";
return d === "" ? null : d;
}
if (tag === "rect") {
const width = number("width");
const height = number("height");
if (!(width > 0) || !(height > 0)) return null;
const x = number("x");
const y = number("y");
// Either radius alone defines both, which is what the shape module says and
// what a rounded rect exported with only `rx` relies on.
const declaredX = parseFloat(attrs.rx);
const declaredY = parseFloat(attrs.ry);
const rawX = Number.isFinite(declaredX) ? declaredX : declaredY;
const rawY = Number.isFinite(declaredY) ? declaredY : declaredX;
const rx = Math.min(Math.max(Number.isFinite(rawX) ? rawX : 0, 0), width / 2);
const ry = Math.min(Math.max(Number.isFinite(rawY) ? rawY : 0, 0), height / 2);
if (rx === 0 || ry === 0) {
return `M ${x} ${y} H ${x + width} V ${y + height} H ${x} Z`;
}
return [
`M ${x + rx} ${y}`,
`H ${x + width - rx}`,
`A ${rx} ${ry} 0 0 1 ${x + width} ${y + ry}`,
`V ${y + height - ry}`,
`A ${rx} ${ry} 0 0 1 ${x + width - rx} ${y + height}`,
`H ${x + rx}`,
`A ${rx} ${ry} 0 0 1 ${x} ${y + height - ry}`,
`V ${y + ry}`,
`A ${rx} ${ry} 0 0 1 ${x + rx} ${y}`,
"Z",
].join(" ");
}
if (tag === "circle" || tag === "ellipse") {
const rx = tag === "circle" ? number("r") : number("rx");
const ry = tag === "circle" ? number("r") : number("ry");
if (!(rx > 0) || !(ry > 0)) return null;
const cx = number("cx");
const cy = number("cy");
// Two half turns: one arc cannot describe a full ellipse, because its start
// and end points would coincide and the spec draws nothing.
return [
`M ${cx - rx} ${cy}`,
`A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy}`,
`A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy}`,
"Z",
].join(" ");
}
if (tag === "line") {
const x1 = number("x1");
const y1 = number("y1");
const x2 = number("x2");
const y2 = number("y2");
if (x1 === x2 && y1 === y2) return null;
return `M ${x1} ${y1} L ${x2} ${y2}`;
}
if (tag === "polyline" || tag === "polygon") {
const values = String(attrs.points ?? "")
.trim()
.split(/[\s,]+/)
.map(Number)
.filter((value) => Number.isFinite(value));
if (values.length < 4) return null;
const steps = [`M ${values[0]} ${values[1]}`];
for (let index = 2; index + 1 < values.length; index += 2) {
steps.push(`L ${values[index]} ${values[index + 1]}`);
}
if (tag === "polygon") steps.push("Z");
return steps.join(" ");
}
return null;
};
/**
* An SVG file's text, in: path data fitted to `target`, out.
*
* The measuring is done by the browser rather than by more arithmetic here.
* `getScreenCTM` resolves the whole `transform` chain of an element, including
* every group above it and the root's own viewBox, which is the difference
* between a logo that sits where its author put it and one whose halves fly
* apart. It is read relative to an empty `<g>` appended to the root, so the
* result is the file's own user space rather than screen pixels, and whatever
* size the offscreen host happens to have cancels out.
*
* Both of those need layout, which is why the file is parked in the document
* instead of measured in the detached tree `DOMParser` returns. It is removed
* again in a `finally`, so a throw does not leave it there.
*
* Every failure is a throw carrying the sentence the panel shows. Importing
* nothing quietly is the one outcome that is not allowed: it looks identical to
* a control that does not work.
*/
const svgToPathData = (svgText, targetPathData, doc = document) => {
const NS = "http://www.w3.org/2000/svg";
const parsed = new DOMParser().parseFromString(String(svgText), "image/svg+xml");
if (
parsed.getElementsByTagName("parsererror").length > 0 ||
!parsed.documentElement ||
parsed.documentElement.localName !== "svg"
) {
throw new Error("That file is not an SVG, or its markup is malformed.");
}
const host = doc.createElement("div");
host.setAttribute("aria-hidden", "true");
host.style.cssText =
"position:fixed;left:-99999px;top:0;width:600px;height:600px;overflow:hidden;";
const svg = doc.importNode(parsed.documentElement, true);
host.appendChild(svg);
doc.body.appendChild(host);
try {
const reference = doc.createElementNS(NS, "g");
svg.appendChild(reference);
const rootMatrix = reference.getScreenCTM();
// Anything inside these is a definition, not a drawing. Matched by
// localName rather than a selector because a document that came from
// `image/svg+xml` matches `clipPath` case-sensitively and an HTML one
// does not.
const defining = ["defs", "clipPath", "mask", "symbol", "marker", "pattern"];
const isDefinition = (element) => {
for (let node = element.parentNode; node && node !== svg; node = node.parentNode) {
if (defining.includes(node.localName)) return true;
}
return false;
};
const shapes = [...svg.querySelectorAll("path,rect,circle,ellipse,line,polyline,polygon")];
const segments = [];
let shapesUsed = 0;
let firstProblem = null;
for (const element of shapes) {
if (isDefinition(element)) continue;
if (doc.defaultView.getComputedStyle(element).display === "none") continue;
const attrs = {};
for (const attribute of element.attributes) attrs[attribute.localName] = attribute.value;
const d = shapePathData(element.localName, attrs);
if (d === null) continue;
let own;
try {
own = normalisePathData(parsePathData(d));
} catch (error) {
firstProblem = firstProblem ?? error.message;
continue;
}
const matrix = element.getScreenCTM();
segments.push(
...(rootMatrix && matrix
? transformPathData(own, rootMatrix.inverse().multiply(matrix))
: own),
);
shapesUsed += 1;
}
if (segments.length === 0) {
if (firstProblem) throw new Error(`This SVG has unreadable path data: ${firstProblem}.`);
const untraceable = ["text", "image", "use"].find(
(tag) => svg.getElementsByTagName(tag).length > 0,
);
throw new Error(
untraceable
? `This SVG draws with <${untraceable}>, which has no outline to trace. Convert it to paths and try again.`
: "This SVG has no shapes to import.",
);
}
// Subpaths in document order, one after another. A stroke trace draws them
// in that order and the moveto between two of them is a gap rather than a
// line, so nothing is joined that the file did not join.
const probe = doc.createElementNS(NS, "path");
svg.appendChild(probe);
probe.setAttribute("d", printPathData(segments));
const source = probe.getBBox();
if (!(source.width > 0) || !(source.height > 0)) {
if (!(source.width > 0) && !(source.height > 0)) {
throw new Error("This SVG's shapes have no size.");
}
}
probe.setAttribute("d", String(targetPathData));
const target = probe.getBBox();
return {
d: printPathData(transformPathData(segments, fitMatrix(source, target))),
shapes: shapesUsed,
};
} finally {
host.remove();
}
};
// <<< svg-import geometry
/**
* How finely a number was written, as a step: 0.06 gives 0.01, 100 gives 1.
*
* Only for the number variables that declare no step of their own. A default
* is the one sample of the variable's scale that exists, and the precision it
* was written to is the author saying what a meaningful change to it is.
*/
const granularity = (value) => {
const decimals = String(value).split(".")[1];
return decimals ? Number(`1e-${decimals.length}`) : 1;
};
/**
* The value box on the right of a label. Empty where the control already
* shows the value in full — a text input repeating itself is noise.
*/
const readout = (variable, value) => {
// Numbers used to print here. They now ride their own thumb, or sit in a
// number field, and a second copy a fixed distance away was the thing that
// made a reader look in two places at once.
if (variable.type === "number") return "";
if (variable.type === "color") return String(value);
if (variable.type === "enum") {
const hit = (variable.options ?? []).find((o) => o.value === value);
return hit ? (hit.label ?? hit.value) : String(value);
}
return "";
};
/**
* The control for one variable.
*
* A plain function called as `{control(...)}`, not a component. Mintlify
* compiles a snippet as MDX, where a capitalised tag has to be one the *page*
* provides — `<CodeBlock />` above is, `<Control />` would not be, and a tag
* that resolves to nothing takes the whole explorer down with it.
*
* Which is also why the SVG import's message is state passed in rather than
* state of its own: `useState` here would be a hook called outside a
* component, and the panel keeps one note per variable instead.
*/
const control = (variable, value, onChange, note, onNote, onTyping) => {
const options = variable.options ?? [];
// Up to four options fit a segmented row at docs width. Past that the
// segments shrink out of readability and a select is the honest control.
if (variable.type === "enum" && options.length > 0 && options.length <= 4) {
return (
<div className="hf-ve-seg">
{options.map((o) => (
<button
key={o.value}
type="button"
data-on={value === o.value}
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className="hf-ve-seg-btn hf-ve-tint"
>
{o.label ?? o.value}
</button>
))}
</div>
);
}
if (variable.type === "enum") {
return (
<select
className="hf-ve-field hf-ve-select hf-ve-tint"
value={value}
aria-label={variable.label ?? variable.id}
onChange={(e) => onChange(e.target.value)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label ?? o.value}
</option>
))}
</select>
);
}
if (variable.type === "number") {
const min = Number(variable.min);
const max = Number(variable.max);
const unit = variable.unit ?? "";
const declaredStep = Number(variable.step);
const step = declaredStep > 0 ? declaredStep : 1;
const label = variable.label ?? variable.id;
// 16 of the registry's 112 number variables declare no bounds, and a
// range input with no min/max is 0 to 100 by definition. That is not a
// mispainted slider, it is a false one: `cursor-glyph-trail.fade`
// defaults to 0.8, and on a 0-to-100 integer scale a reader who touches
// it can never get 0.8 back. `count-up.end` defaults to 100 and sits
// pinned at a maximum that is not its maximum.
//
// So an unbounded variable gets the control that can say what it is: a
// number field, where the authored value is expressible and no invented
// range is implied. Guessing bounds from the default was the other
// option, and `count-up.end` is exactly the case that shows why not,
// since 100 is as plausibly a tenth of the range as all of it. Declaring
// min and max on the registry item promotes it to a slider with no code
// change here, which is the fix that belongs upstream.
if (!(Number.isFinite(min) && Number.isFinite(max) && max > min)) {
return (
<input
type="number"
className="hf-ve-field hf-ve-mono hf-ve-tint"
value={value}
min={Number.isFinite(min) ? min : undefined}
max={Number.isFinite(max) ? max : undefined}
// How finely the author wrote the default is how finely it steps.
// The alternatives are both wrong here: step 1 rejects the 0.06 and
// 0.45 defaults several of these ship with, and "any" accepts them
// but makes the spinner and the arrow keys move by a whole 1, which
// takes `fade` from 0.8 to 1.8 in one press.
step={declaredStep > 0 ? declaredStep : granularity(variable.default)}
aria-label={label}
// ponytail: a value that does not parse is dropped rather than
// written, so the preview never reloads on a half-typed number.
// The cost is that the field cannot be emptied; every partial
// number that matters ("0", "0.", "0.4") parses, so it is typeable.
onChange={(e) => {
const next = Number(e.target.value);
if (e.target.value !== "" && Number.isFinite(next)) onChange(next);
}}
/>
);
}
const at = (n) => Math.min(1, Math.max(0, (Number(n) - min) / (max - min)));
const now = at(value);
const authored = at(variable.default);
// The thumb centre is inset by half its own width at each end, so a
// percentage alone drifts up to 8px away from it. Both the readout and
// the default mark are placed with the same correction, which is what
// keeps them agreeing with the thumb rather than nearly agreeing.
const place = (fraction) => ({
offset: `${fraction * 100}%`,
nudge: `${(0.5 - fraction) * 16}px`,
});
const value_ = place(now);
const default_ = place(authored);
// Shift is read once, when the gesture starts, and written straight to
// the DOM. React re-renders during a drag do not undo it, because the
// `step` prop itself never changes and React only writes attributes it
// sees change. A `useState` here would mean a global key listener and a
// re-render of the whole panel on every shift press.
const grain = (event) => {
event.currentTarget.step = event.shiftKey ? step / 10 : step;
};
return (
<div
className="hf-ve-slider"
style={{
"--ve-fill": value_.offset,
"--ve-fill-nudge": value_.nudge,
"--ve-default": default_.offset,
"--ve-default-nudge": default_.nudge,
}}
>
{/* The bounds, and the live value between them.
The value used to sit in the label row, a fixed distance from a
thumb that moves; here it rides the thumb, so reading it costs no
eye movement mid-drag. The ends carry min and max, which the panel
never showed at all: a track alone cannot say whether 12 is nearly
nothing or nearly everything. Each end hides itself when the value
arrives on top of it, so the two never overprint. */}
<div
className="hf-ve-ruler"
data-near={now < 0.14 ? "min" : now > 0.86 ? "max" : ""}
aria-hidden="true"
>
<span className="hf-ve-bound" data-end="min">
{min}
</span>
<span className="hf-ve-bound" data-end="max">
{max}
</span>
<span className="hf-ve-readout">
{value}
{unit}
</span>
</div>
<span className="hf-ve-track">
<input
type="range"
className="hf-ve-range"
min={min}
max={max}
step={step}
value={value}
aria-label={label}
// The unit is on screen but not in the accessible name, and a
// bare "12" is not what this control is worth saying.
aria-valuetext={`${value}${unit}`}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={grain}
onKeyDown={grain}
// Back to the author's value, the gesture every audio and motion
// tool uses for it. Reset above does the same for the whole
// panel; this is the one knob you have just pushed too far.
onDoubleClick={() => onChange(variable.default)}
/>
{/* Where the author left it. The panel is an invitation to deviate
from that, and without the mark there is nothing to deviate
from. Absent while the thumb is still on top of it, because a
mark under the thumb marks nothing and reads as a seam in it.
Four percent of the track is about one thumb at every width
this panel is laid out at. */}
{Math.abs(now - authored) > 0.04 && (
<span className="hf-ve-default" aria-hidden="true" />
)}
</span>
</div>
);
}
if (variable.type === "boolean") {
// A real switch. Without this branch a boolean fell through to the text
// input at the end of this function, which asked the reader to type the
// word "true" — and typed anything else silently became a string.
const on = value === true || value === "true";
return (
<button
type="button"
role="switch"
aria-checked={on}
aria-label={variable.label ?? variable.id}
data-on={on}
onClick={() => onChange(!on)}
className="hf-ve-switch"
>
<span className="hf-ve-switch-dot" />
</button>
);
}
if (variable.type === "color") {
return (
<div className="flex items-center gap-2">
<input
type="color"
className="hf-ve-swatch hf-ve-tint"
value={value}
aria-label={variable.label ?? variable.id}
onChange={(e) => onChange(e.target.value)}
/>
<input
type="text"
className="hf-ve-field hf-ve-mono hf-ve-tint"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => onTyping(variable.id)}
onBlur={() => onTyping(null)}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
</div>
);
}
// A string variable whose default is path data. See `isSvgPathData` at the
// foot of this file for why the default decides and the name does not.
if (isSvgPathData(variable.default)) {
const fileId = `hf-ve-file-${variable.id}`;
const noteId = `hf-ve-note-${variable.id}`;
const receive = (file) => {
if (!file) return;
file
.text()
.then((text) => {
const { d, shapes } = svgToPathData(text, variable.default);
onChange(d);
onNote({
tone: "ok",
message: `${file.name}: ${shapes} shape${shapes === 1 ? "" : "s"} scaled to fit.`,
});
})
.catch((error) => onNote({ tone: "error", message: error.message }));
};
return (
<div
className="hf-ve-drop"
onDragOver={(event) => {
event.preventDefault();
event.currentTarget.dataset.over = "true";
}}
onDragLeave={(event) => {
event.currentTarget.dataset.over = "false";
}}
onDrop={(event) => {
event.preventDefault();
event.currentTarget.dataset.over = "false";
receive(event.dataTransfer.files[0]);
}}
>
{/* The button is the trigger, so the picker is reachable by tab and
Enter and carries the focus ring every other control here has.
Dropping a file does the same thing and is never the only way. */}
<div className="hf-ve-dropzone">
<button
type="button"
className="hf-ve-btn hf-ve-tint"
onClick={() => document.getElementById(fileId).click()}
>
Import SVG
</button>
<input
id={fileId}
type="file"
accept=".svg,image/svg+xml"
className="hf-ve-file"
tabIndex={-1}
aria-hidden="true"
onChange={(event) => {
receive(event.target.files[0]);
// Cleared so picking the same file twice is two events.
event.target.value = "";
}}
/>
{/* Always present, so the region is one a screen reader is
already watching when a message arrives. */}
<p id={noteId} role="status" className="hf-ve-note" data-tone={note ? note.tone : ""}>
{note ? note.message : "Or drop one here. Scaled to fit and centred."}
</p>
</div>
<details className="hf-ve-advanced">
<summary>Path data</summary>
<input
type="text"
className="hf-ve-field hf-ve-mono hf-ve-tint"
value={value}
aria-label={variable.label ?? variable.id}
onChange={(e) => onChange(e.target.value)}
onFocus={() => onTyping(variable.id)}
onBlur={() => onTyping(null)}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
</details>
</div>
);
}
return (
<input
type="text"
className="hf-ve-field hf-ve-tint"
value={value}
aria-label={variable.label ?? variable.id}
onChange={(e) => onChange(e.target.value)}
onFocus={() => onTyping(variable.id)}
onBlur={() => onTyping(null)}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
);
};
// Keyed on the declarations' content, not their identity: MDX hands this
// component a fresh `variables` array on every render, so memoising on the
// array itself would rebuild the defaults every time and defeat the point.
const variablesKey = JSON.stringify(variables);
const defaults = useMemo(() => {
const built = {};
for (const v of variables) if (v.default !== undefined) built[v.id] = v.default;
return built;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variablesKey]);
/**
* Values survive a reload by living in the query string.
*
* Scoped by composition id, so two links to different items never read each
* other's settings, and only the values that differ from the defaults are
* written — a reader who changed one knob gets a short, obvious URL rather
* than every variable spelled out.
*
* Anything unreadable is ignored rather than thrown: a truncated or
* hand-edited URL should open the piece at its defaults, not break the page.
*/
const urlKey = `vars-${compositionId}`;
const readFromUrl = () => {
if (typeof window === "undefined") return {};
try {
const raw = new URLSearchParams(window.location.search).get(urlKey);
if (!raw) return {};
// `URLSearchParams` has already decoded this once. Decoding again turned
// a path full of percent-escapes into something that no longer parsed,
// and doubled the length of every link.
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
// Only ids this item declares; a stale link must not inject stray keys.
const declared = new Set(variables.map((v) => v.id));
return Object.fromEntries(Object.entries(parsed).filter(([id]) => declared.has(id)));
} catch {
return {};
}
};
const [values, setValues] = useState(() => ({ ...defaults, ...readFromUrl() }));
/**
* Read the URL again once the component is actually in a browser.
*
* The first render happens on the server, where there is no `window`, so the
* state above can only be the declared defaults. React then hydrates against
* that markup and never revisits it — which is why a shared link opened at
* its defaults and only looked right if you touched a control. Applying the
* values after mount is what makes a reload land where it left off.
*
* Runs once. Later edits own the state from then on, and the effect below
* keeps the URL in step with them.
*/
useEffect(() => {
const fromUrl = readFromUrl();
if (Object.keys(fromUrl).length > 0) setValues((current) => ({ ...current, ...fromUrl }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// `replaceState` rather than `pushState`: dragging a slider should not stack
// up history entries the back button has to walk through.
useEffect(() => {
if (typeof window === "undefined") return;
const changed = Object.fromEntries(
Object.entries(values).filter(([id, value]) => JSON.stringify(value) !== JSON.stringify(defaults[id])),
);
const url = new URL(window.location.href);
if (Object.keys(changed).length === 0) url.searchParams.delete(urlKey);
else url.searchParams.set(urlKey, JSON.stringify(changed));
// `defaults` is rebuilt every render, so this effect runs every render too.
// Comparing first keeps it to an actual change rather than touching the
// history API on each one.
const next = url.toString();
if (next !== window.location.href) {
window.history.replaceState(null, "", next);
// `replaceState` fires no event, and the install command on the same page
// needs to follow these values.
window.dispatchEvent(new CustomEvent("hf-vars-changed"));
}
}, [values, defaults, urlKey]);
// What the SVG import last had to say, per variable. Held here because
// `control` is a function rather than a component and cannot hold it itself.
const [notes, setNotes] = useState({});
/**
* The id of the text field being typed into, if any.
*
* Every other control reports a whole value on every event: a slider at any
* position is a position, a swatch is a colour. A text field is not. While
* someone types `v3`, `v` is a prefix, and the preview remounted on it and
* showed them a composition built from half a word.
*
* So the post below waits while a text field has focus, and goes out when the
* edit is committed — Enter, or clicking away. The field itself never lags;
* only the mount waits, and it never sees a state nobody asked for.
*/
const [typing, setTyping] = useState(null);
/** What was last posted, so ending an edit that changed nothing is free. */
const posted = useRef(null);
const [tab, setTab] = useState("preview");
const frame = useRef(null);
// The frame is a document we write, not a file we fetch.
//
// The preview used to be an `.html` sitting in `docs/public`, which the docs
// host does not publish — it 404'd in production and showed an empty panel.
// The composition now arrives as JSON and is mounted here, so the only thing
// that has to survive the deploy is the payload, which is a servable type.
//
// Values are injected as `window.__hfVariables` into the composition's own
// head before any of its scripts run. That is where the runtime reads render
// overrides from, and doing it in the markup rather than after load is what
// guarantees the composition never initialises with the wrong values first.
const bootstrap = [
"<!doctype html><html><head><meta charset='utf-8'>",
"<style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}",
"hyperframes-player{display:block;width:100%;height:100%}</style>",
'<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"></' +
"script>",
"</head><body><script>",
"(function(){",
` var PAYLOAD = ${JSON.stringify(previewSrc)};`,
// The frame mounts with whatever a shared link asked for, not the bare
// defaults. Posting the values afterwards is too late for anything the
// composition reads once at init — a path arrives, the mark is already
// drawn from the default one, and only a later edit corrects it.
` var INITIAL = ${JSON.stringify({ ...defaults, ...readFromUrl() })};`,
" var html = null, player = null, poll = null;",
// Two ways in, because a composition can be either shape. A top-level one
// reads overrides off `window.__hfVariables`; one mounted through
// `data-composition-src` is fed from its host's `data-variable-values`,
// which the loader reads before the sub-composition runs — so that has to
// be in the markup, not assigned afterwards.
" function withValues(source, values) {",
" var json = JSON.stringify(values);",
" var attr = json.replace(/'/g, '&#39;');",
" var out = source.replace(/\\sdata-variable-values=(?:\"[^\"]*\"|'[^']*')/gi, '');",
" out = out.replace(/(data-composition-src=)/gi, \"data-variable-values='\" + attr + \"' $1\");",
" var tag = '<' + 'script>window.__hfVariables=' + json + ';<' + '/script>';",
" return /<head[^>]*>/i.test(out)",
" ? out.replace(/<head([^>]*)>/i, '<head$1>' + tag)",
" : tag + out;",
" }",
// The composition reads its variables once, at init, so a new value can
// only arrive by mounting it again. The playhead is carried across so a
// change mid-shot does not throw the reader back to frame zero.
" function arm(resumeAt) {",
" clearInterval(poll);",
" var last = -1, tries = 0, seeked = false;",
" poll = setInterval(function () {",
" if (player.ready) {",
" if (!seeked) { seeked = true; if (resumeAt > 0) player.seek(resumeAt); }",
" player.play();",
" }",
" if (seeked && player.currentTime > 0 && player.currentTime !== last) {",
" clearInterval(poll); return;",
" }",
" last = player.currentTime;",
" if (++tries > 150) clearInterval(poll);",
" }, 100);",
" }",
" function mount(values, resumeAt) {",
" if (html === null) return;",
" player.setAttribute('srcdoc', withValues(html, values));",
" arm(resumeAt || 0);",
" }",
" player = document.createElement('hyperframes-player');",
" player.setAttribute('controls', ''); player.setAttribute('muted', '');",
" document.body.appendChild(player);",
" player.addEventListener('ended', function () { player.seek(0); player.play(); });",
" fetch(PAYLOAD).then(function (r) { return r.json(); }).then(function (d) {",
" html = d.html; mount(INITIAL, 0);",
" }).catch(function (e) {",
" document.body.innerHTML = '<pre style=\"color:#f66;font:12px monospace;padding:12px\">preview unavailable: ' + e + '</pre>';",
" });",
" addEventListener('message', function (event) {",
" var values = event.data && event.data.hfVariables;",
" if (!values) return;",
" mount(values, player.currentTime || 0);",
" });",
"})();",
"</" + "script></body></html>",
].join("");
useEffect(() => {
if (typing !== null) return;
const payload = JSON.stringify(values);
// Focusing a field and leaving it alone still ends an edit, and remounting
// on that would restart the composition for nothing.
if (payload === posted.current) return;
const timer = setTimeout(() => {
const target = frame.current && frame.current.contentWindow;
if (!target) return;
posted.current = payload;
target.postMessage({ hfVariables: values }, window.location.origin);
}, 150);
return () => clearTimeout(timer);
}, [values, typing]);
// The mount element, as coloured tokens.
//
// The values are indented rather than printed on one line: a reader looking
// at 200 characters of `{"a":1,"b":2,...}` reads it as minified output rather
// than as something they are meant to edit. Two extra spaces per line keep
// the object under its own attribute. Newlines inside a quoted HTML attribute
// are legal and JSON.parse ignores them, so this still pastes and runs.
const printed = JSON.stringify(values, null, 2).split("\n").join("\n ");
const attributes = [
["data-composition-id", `"${compositionId}"`],
["data-composition-src", `"${compositionSrc}"`],
["data-variable-values", `'${printed}'`],
];
// One entry per rendered line, each a list of [style, text] tokens — the
// shape a shiki fence emits. Built rather than written out because an
// indented value spans several lines and each one needs its own `line` span.
const snippetLines = [[[SHIKI.punct, "<"], [SHIKI.tag, "div"]]];
for (const [name, literal] of attributes) {
const [head, ...rest] = literal.split("\n");
snippetLines.push([[SHIKI.attr, ` ${name}`], [SHIKI.equals, "="], [SHIKI.value, head]]);
for (const line of rest) snippetLines.push([[SHIKI.value, line]]);
}
snippetLines.push([[SHIKI.punct, "></"], [SHIKI.tag, "div"], [SHIKI.punct, ">"]]);
const dirty = variables.some((v) => values[v.id] !== defaults[v.id]);
// Only the values that differ, so an untouched piece offers the same short
// command the Install block does, and a tuned one carries exactly what
// changed rather than every variable restated.
const installCommand = (() => {
const base = `npx hyperframes add ${compositionId}`;
if (!dirty) return base;
const changed = {};
for (const v of variables) {
if (JSON.stringify(values[v.id]) !== JSON.stringify(defaults[v.id])) changed[v.id] = values[v.id];
}
return `${base} --vars '${JSON.stringify(changed)}'`;
})();
// "Code" is the composition's own source, handed in as a fenced block by the
// generator and highlighted by shiki at build time — the source never changes
// as a knob moves, so it needs none of the hand-colouring the snippet does.
// A source with a fence in it cannot be passed this way, and then there is
// simply no Code tab rather than an empty one.
const TABS = [
["preview", "Preview"],
...(children ? [["code", "Code"]] : []),
["snippet", "Snippet"],
];
return (
<div className="hf-ve not-prose my-4">
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<div className="mb-3 flex items-center gap-3">
<div className="hf-ve-tabs">
{TABS.map(([id, label]) => (
<button
key={id}
type="button"
data-on={tab === id}
aria-pressed={tab === id}
onClick={() => setTab(id)}
className="hf-ve-tab hf-ve-tint"
>
{label}
</button>
))}
</div>
</div>
{/* One grid cell, two panes. Switching tabs must not unmount the iframe —
that reloads the composition and throws it back to frame zero. */}
<div className="hf-ve-frame">
<div className="hf-ve-cell" data-on={tab === "preview"}>
<iframe
ref={frame}
srcDoc={bootstrap}
className="hf-ve-preview block aspect-video w-full"
title={`${compositionId} preview`}
/>
</div>
{children && (
<div className="hf-ve-cell" data-on={tab === "code"}>
{children}
</div>
)}
<div className="hf-ve-cell hf-ve-snippet" data-on={tab === "snippet"}>
{/* The Install block further down the page is generated before anyone
touches a knob, so it can only ever offer the plain command. This
one is the panel's, and it carries what the reader actually chose:
copying it installs the piece already tuned. */}
<CodeBlock filename="Terminal">
<pre
className="shiki shiki-themes github-light-default dark-plus"
style={{
backgroundColor: "rgb(255, 255, 255)",
"--shiki-dark-bg": "#0B0C0E",
color: "rgb(31, 35, 40)",
"--shiki-dark": "#D4D4D4",
}}
>
<code>
<span className="line">
<span style={SHIKI.value}>{installCommand}</span>
{"\n"}
</span>
</code>
</pre>
</CodeBlock>
{/* Each line carries its own trailing newline rather than sitting
next to a bare one: MDX resolves a dotted JSX tag through the
page, so `<React.Fragment>` throws where a keyed element does
not. The text content is identical either way. */}
<CodeBlock filename="index.html">
<pre
className="shiki shiki-themes github-light-default dark-plus"
style={{
backgroundColor: "rgb(255, 255, 255)",
"--shiki-dark-bg": "#0B0C0E",
color: "rgb(31, 35, 40)",
"--shiki-dark": "#D4D4D4",
}}
>
<code>
{snippetLines.map((tokens, line) => (
<span key={line} className="line">
{tokens.map(([style, text], token) => (
<span key={token} style={style}>
{text}
</span>
))}
{"\n"}
</span>
))}
</code>
</pre>
</CodeBlock>
</div>
</div>
<div className="hf-ve-panel">
<div className="hf-ve-head">
<span className="hf-ve-title">Customize</span>
<button
type="button"
onClick={() => {
setValues(defaults);
setNotes({});
}}
disabled={!dirty}
className="hf-ve-btn hf-ve-tint"
>
Reset
</button>
</div>
<div className="hf-ve-grid">
{variables.map((v) => (
<div key={v.id}>
<div className="hf-ve-row">
<label className="hf-ve-label">{v.label ?? v.id}</label>
<span className="hf-ve-value">{readout(v, values[v.id])}</span>
</div>
{control(
v,
values[v.id],
(next) => setValues((prev) => ({ ...prev, [v.id]: next })),
notes[v.id],
(note) => setNotes((prev) => ({ ...prev, [v.id]: note })),
setTyping,
)}
{v.description && <p className="hf-ve-desc">{v.description}</p>}
</div>
))}
</div>
</div>
</div>
);
};