mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
* fix(docs): load the player from latest, not a pinned minor The catalog pages pinned the player CDN URL to a minor line, and that pin sat one line behind after the last release. Every page kept rendering, on the older build, so nothing surfaced it: the only symptom was that a fix published to npm never appeared on the docs. The generator derived its pin from the player's package.json, which is correct only if every page is regenerated on the release that moves it. That is the step that did not happen, and it has to happen across 175 generated pages plus three hand-written files for the pin to be true. A version carried in step across 178 places will be stale, and stale here is silent. Ask for latest instead and there is nothing to carry. This costs the ability to hold the docs back from a bad player release. Paid deliberately: the pin did not buy that either, it only delayed the good releases too. A test asserts no pinned version comes back, and fails if it stops finding the references at all, so it cannot pass by matching nothing. * refactor(scripts): list tracked files instead of walking the tree The pin guard hand-rolled a recursive directory walk with its own skip list and size cap, which the audit flagged: helpers living in a test file earn no coverage, so their complexity lands straight on the CRAP score. git already knows which files to read, and ignores node_modules and build output for us, so one call replaces the walker and both findings go away.
1938 lines
75 KiB
React
1938 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>",
|
|
// `latest`, not a pinned line. A pin here is a number nothing reads back:
|
|
// the panel keeps working on whatever build the pin names, so a stale one
|
|
// is invisible until someone wonders why a shipped fix never arrived.
|
|
// `scripts/player-cdn-pin.test.ts` fails if a pin comes back.
|
|
'<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@latest/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, ''');",
|
|
" 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>
|
|
);
|
|
};
|