mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 04:07:03 +00:00
* fix(docs): restore the sidebar gutter on catalog detail pages * fix(docs): align the catalog tab row and panes with the page gutter
2277 lines
88 KiB
React
2277 lines
88 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.
|
||
*/
|
||
|
||
/** A named page region (`code`, `install`, `docs`) that CatalogDetail shows under the matching tab. */
|
||
export const CatalogSlot = ({ slot, children }) => (
|
||
<div data-slot={slot} className="prose prose-gray dark:prose-invert">
|
||
{children}
|
||
</div>
|
||
);
|
||
|
||
export const CatalogDetail = ({
|
||
previewSrc,
|
||
compositionId,
|
||
compositionSrc,
|
||
variables = [],
|
||
title,
|
||
description,
|
||
meta = {},
|
||
about,
|
||
attribution,
|
||
rawUrl,
|
||
video,
|
||
poster,
|
||
webgpu,
|
||
needsFlag,
|
||
hasCode,
|
||
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-hover);
|
||
}
|
||
.hf-ve-tab:focus:not(:focus-visible) { outline: none; box-shadow: none; }
|
||
.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;
|
||
}
|
||
|
||
.hf-ve-head-title { margin: 8px 0 28px; }
|
||
.hf-ve-head-title h1 { margin: 0; font-size: 44px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; color: var(--ve-fg); }
|
||
.hf-ve-head-title p { margin: 12px 0 0; font-size: 18px; line-height: 1.5; color: var(--ve-muted); max-width: 70ch; }
|
||
@media (max-width: 640px) { .hf-ve-head-title h1 { font-size: 32px; } }
|
||
/* Item page anatomy: bar, stage beside Tune, tabs, tab body. */
|
||
.hf-ve-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px 20px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.hf-ve-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 6px 18px;
|
||
font-size: 14px;
|
||
color: var(--ve-muted);
|
||
}
|
||
.hf-ve-meta b { color: var(--ve-fg); font-weight: 600; }
|
||
.hf-ve-badge {
|
||
padding: 2px 10px;
|
||
border-radius: 9999px;
|
||
font-size: 12px;
|
||
background: var(--ve-hover);
|
||
}
|
||
.hf-ve-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.hf-ve-action {
|
||
display: inline-grid;
|
||
align-items: center;
|
||
justify-items: center;
|
||
padding: 8px 14px;
|
||
border-radius: 10px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--ve-fg);
|
||
background: var(--ve-hover);
|
||
text-decoration: none;
|
||
border: 0;
|
||
cursor: pointer;
|
||
}
|
||
.hf-ve-action-label { grid-area: 1 / 1; }
|
||
.hf-ve-action-label[data-shown="false"] { visibility: hidden; }
|
||
.hf-ve-action:hover:not(:disabled) { background: var(--ve-line); }
|
||
.hf-ve-action:disabled { opacity: 0.4; cursor: default; }
|
||
.hf-ve-tune-foot .hf-ve-action { justify-content: center; white-space: nowrap; }
|
||
.hf-ve-action[data-primary="true"] { color: var(--ve-on-fg); background: var(--ve-on-bg); }
|
||
.hf-ve-action[data-primary="true"]:hover:not(:disabled) { background: var(--ve-on-bg); opacity: 0.88; }
|
||
.hf-ve-main { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
|
||
@media (min-width: 1024px) {
|
||
.hf-ve-main[data-tune="true"] { grid-template-columns: minmax(0, 1fr) 340px; }
|
||
}
|
||
.hf-ve-stage {
|
||
overflow: hidden;
|
||
border: 1px solid var(--ve-line);
|
||
border-radius: 14px;
|
||
background: var(--ve-surface);
|
||
}
|
||
.hf-ve-stage .hf-ve-preview { border: 0; border-radius: 0; }
|
||
.hf-ve-caption { padding: 10px 16px; font-size: 13px; color: var(--ve-muted); }
|
||
.hf-ve-caption span + span { margin-left: 16px; }
|
||
.hf-ve-tune {
|
||
position: relative;
|
||
min-height: 320px;
|
||
border: 1px solid var(--ve-line);
|
||
border-radius: 14px;
|
||
background: var(--ve-surface);
|
||
}
|
||
.hf-ve-tune-inner { display: flex; flex-direction: column; max-height: 520px; }
|
||
@media (min-width: 1024px) {
|
||
.hf-ve-tune-inner { position: absolute; inset: 0; max-height: none; }
|
||
}
|
||
.hf-ve-tune-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--ve-line);
|
||
font-weight: 600;
|
||
}
|
||
.hf-ve-tune-head small { font-weight: 400; font-size: 13px; color: var(--ve-muted); }
|
||
.hf-ve-tune-list {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
overscroll-behavior: contain;
|
||
display: grid;
|
||
gap: 16px;
|
||
padding: 16px;
|
||
align-content: start;
|
||
mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 12px), transparent 100%);
|
||
}
|
||
.hf-ve-tune-foot {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||
gap: 8px;
|
||
padding: 16px;
|
||
border-top: 1px solid var(--ve-line);
|
||
}
|
||
/* No left inset on the tab row or the panes under it: the page gutter is the
|
||
* page's job, and a second 16px here put everything below the stage on a
|
||
* different left edge from the title, install block and stage above it. */
|
||
.hf-ve-tabs-row { margin: 20px 0 0; }
|
||
.hf-ve-tabs-row .hf-ve-tab { padding: 6px 16px; font-size: 14px; }
|
||
.hf-ve-tabs-row .hf-ve-tab small { margin-left: 6px; font-weight: 400; opacity: 0.7; }
|
||
.hf-ve-body { padding: 2rem 0 0; }
|
||
.hf-ve-install { margin: 0 0 28px; }
|
||
.hf-ve-install-title { margin: 0 0 12px; font-size: 24px; line-height: 1.3; font-weight: 600; letter-spacing: -0.01em; color: var(--ve-fg); }
|
||
.hf-ve-about h3 { margin: 0 0 8px; font-size: 16px; font-weight: 600; }
|
||
.hf-ve-about p { margin: 0 0 12px; line-height: 1.6; max-width: 72ch; }
|
||
.hf-ve-about .hf-ve-attr { font-size: 14px; color: var(--ve-muted); }
|
||
.hf-ve-about .hf-ve-attr a { color: var(--ve-fg); text-decoration: underline; }
|
||
.hf-ve-body-pane[hidden] { display: none; }
|
||
.hf-ve-slots > [data-slot] { display: none; }
|
||
.hf-ve-slots[data-tab="code"] > [data-slot="code"],
|
||
.hf-ve-slots[data-tab="docs"] > [data-slot="docs"] { display: block; }
|
||
.hf-ve-body-pane .code-block pre,
|
||
.hf-ve-slots [data-slot="code"] pre { max-height: 560px; overflow: auto; }
|
||
@media (max-width: 640px) {
|
||
.hf-ve-actions { width: 100%; }
|
||
.hf-ve-action { flex: 1 1 calc(50% - 8px); justify-content: center; }
|
||
}
|
||
|
||
`;
|
||
|
||
// >>> 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 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]);
|
||
|
||
const hasTune = variables.length > 0;
|
||
const [tab, setTab] = useState("preview");
|
||
const lines = meta.codeLines;
|
||
const TABS = [
|
||
["preview", "Preview"],
|
||
...(hasCode ? [["code", "Code", lines ? `${lines} ln` : ""]] : []),
|
||
["install", "Snippet"],
|
||
["docs", "Docs"],
|
||
];
|
||
|
||
const changedValues = () => {
|
||
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 changed;
|
||
};
|
||
const renderCommand = (() => {
|
||
const changed = changedValues();
|
||
const vars = Object.keys(changed).length ? ` --variables '${JSON.stringify(changed).replace(/'/g, "'\\''")}'` : "";
|
||
return `# Run from the installed project's root.\nnpx hyperframes render --composition '${compositionSrc}'${vars}`;
|
||
})();
|
||
const agentRequest = (() => {
|
||
const changed = changedValues();
|
||
const base = `Install the HyperFrames catalog item "${compositionId}" (${title || compositionId}) into my project with \`npx hyperframes add ${compositionId}\`, mount it at the point of my composition where it should play, and verify with \`npx hyperframes check\`.`;
|
||
const set = Object.entries(changed).map(([id, value]) => `${id} = ${JSON.stringify(value)}`);
|
||
return set.length ? `${base} Set ${set.join(", ")}; keep the other variables at their defaults.` : `${base} Keep the default variables unless I say otherwise.`;
|
||
})();
|
||
|
||
const [copiedKey, setCopiedKey] = useState("");
|
||
const copy = async (key, text) => {
|
||
let ok = false;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
ok = true;
|
||
} catch {
|
||
const area = document.createElement("textarea");
|
||
area.value = text;
|
||
area.setAttribute("readonly", "");
|
||
area.style.cssText = "position:fixed;opacity:0";
|
||
document.body.appendChild(area);
|
||
area.select();
|
||
ok = document.execCommand("copy");
|
||
area.remove();
|
||
}
|
||
if (!ok) return;
|
||
setCopiedKey(key);
|
||
setTimeout(() => setCopiedKey((current) => (current === key ? "" : current)), 1400);
|
||
};
|
||
const CopyAction = ({ id, label, text, primary }) => {
|
||
const shown = copiedKey === id ? "Copied" : label;
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="hf-ve-action"
|
||
data-primary={primary ? "true" : "false"}
|
||
onClick={() => copy(id, typeof text === "function" ? text() : text)}
|
||
>
|
||
{[label, "Copied"].map((name) => (
|
||
<span key={name} className="hf-ve-action-label" data-shown={String(name === shown)}>
|
||
{name}
|
||
</span>
|
||
))}
|
||
</button>
|
||
);
|
||
};
|
||
const wiring = snippetLines.map((tokens) => tokens.map(([, text]) => text).join("")).join("\n");
|
||
const mountText = `${wiring}\n`;
|
||
|
||
const flagNotice = needsFlag ? (
|
||
<div className="flex aspect-video w-full items-center justify-center text-sm text-zinc-500">
|
||
Needs <code>chrome://flags/#{needsFlag}</code> to render live
|
||
</div>
|
||
) : null;
|
||
const player = (
|
||
<iframe
|
||
ref={frame}
|
||
srcDoc={bootstrap}
|
||
className="hf-ve-preview block aspect-video w-full"
|
||
title={`${compositionId} preview`}
|
||
/>
|
||
);
|
||
const recordedRef = useRef(null);
|
||
// Lazy initializer so the first render already knows the preference; pause,
|
||
// removeAttribute and load are what stop a clip that was already playing.
|
||
const [reduced, setReduced] = useState(
|
||
() => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||
);
|
||
useEffect(() => {
|
||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||
const onChange = (event) => setReduced(event.matches);
|
||
query.addEventListener("change", onChange);
|
||
return () => query.removeEventListener("change", onChange);
|
||
}, []);
|
||
useEffect(() => {
|
||
const clip = recordedRef.current;
|
||
if (!reduced || !clip) return;
|
||
clip.pause();
|
||
clip.removeAttribute("src");
|
||
clip.load();
|
||
}, [reduced]);
|
||
const recorded = video ? (
|
||
<video
|
||
ref={recordedRef}
|
||
className="block aspect-video w-full object-cover"
|
||
src={reduced ? undefined : video}
|
||
poster={poster}
|
||
autoPlay={!reduced}
|
||
muted
|
||
loop={!reduced}
|
||
playsInline
|
||
/>
|
||
) : null;
|
||
// null until the adapter request settles, so the player never mounts on a browser that cannot run it.
|
||
const [hasAdapter, setHasAdapter] = useState(null);
|
||
// Mintlify evaluates only a snippet's exports, so this helper lives inside the component.
|
||
// BEGIN hasWebgpuAdapter: resolves false, never rejects (no gpu, null adapter, sync or async throw, no answer in time).
|
||
const hasWebgpuAdapter = (gpu, timeoutMs) => {
|
||
const probe = new Promise((resolve) => resolve(gpu?.requestAdapter())).then(
|
||
(adapter) => Boolean(adapter),
|
||
() => false,
|
||
);
|
||
const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs, false));
|
||
return Promise.race([probe, timeout]);
|
||
};
|
||
// END hasWebgpuAdapter
|
||
useEffect(() => {
|
||
if (webgpu) hasWebgpuAdapter(navigator.gpu, 3000).then(setHasAdapter);
|
||
}, [webgpu]);
|
||
const adapterMissing = webgpu && hasAdapter === false;
|
||
// Edits reach a mounted player only, so the panel waits for the probe and goes when the clip stands in.
|
||
const tunePanel = hasTune && !(webgpu && hasAdapter !== true);
|
||
let webgpuStage = player;
|
||
if (webgpu && hasAdapter === null) webgpuStage = <div className="aspect-video w-full" />;
|
||
if (adapterMissing) webgpuStage = recorded;
|
||
const stageNode = webgpu ? webgpuStage : recorded ?? flagNotice ?? player;
|
||
|
||
let caption = "Live composition · HyperFrames Player";
|
||
if (adapterMissing) caption = "Recorded preview · live playback needs WebGPU, which this browser does not offer";
|
||
else if (!webgpu && (video || needsFlag)) caption = "Recorded preview";
|
||
|
||
// The install command belongs above the preview, so its slot is lifted out of the tab body.
|
||
// Mintlify hands each MDX child over wrapped in a component boundary, so the slot name is found by descending.
|
||
const slotOf = (node) => {
|
||
for (let cur = node; React.isValidElement(cur); cur = React.Children.toArray(cur.props.children)[0]) {
|
||
if (cur.props.slot) return cur.props.slot;
|
||
}
|
||
return null;
|
||
};
|
||
const allSlots = React.Children.toArray(children);
|
||
const installSlot = allSlots.find((child) => slotOf(child) === "install") ?? null;
|
||
const otherSlots = allSlots.filter((child) => child !== installSlot);
|
||
|
||
const seconds = meta.duration ? `${meta.duration} s` : null;
|
||
const size = meta.width && meta.height ? `${meta.width}×${meta.height}` : null;
|
||
|
||
return (
|
||
<div className="hf-ve my-4">
|
||
<style dangerouslySetInnerHTML={{ __html: CSS }} />
|
||
|
||
<div className="not-prose">
|
||
<header className="hf-ve-head-title">
|
||
<h1>{title}</h1>
|
||
{description && <p>{description}</p>}
|
||
</header>
|
||
</div>
|
||
|
||
{installSlot && (
|
||
<div className="hf-ve-install">
|
||
<h2 className="hf-ve-install-title">Install</h2>
|
||
{installSlot}
|
||
</div>
|
||
)}
|
||
|
||
<div className="not-prose">
|
||
|
||
<div className="hf-ve-bar">
|
||
<div className="hf-ve-meta">
|
||
{seconds && (
|
||
<span>
|
||
<b>{seconds}</b> duration
|
||
</span>
|
||
)}
|
||
{size && <b>{size}</b>}
|
||
{hasTune && (
|
||
<span>
|
||
<b>{variables.length}</b> {variables.length === 1 ? "variable" : "variables"}
|
||
</span>
|
||
)}
|
||
{meta.category && <b>{meta.category}</b>}
|
||
{meta.badge && <span className="hf-ve-badge">{meta.badge}</span>}
|
||
</div>
|
||
<div className="hf-ve-actions">
|
||
<CopyAction id="agent" label="Copy agent request" text={agentRequest} primary />
|
||
<CopyAction id="wiring" label="Copy wiring" text={mountText} />
|
||
<CopyAction id="link" label="Copy link" text={() => window.location.href} />
|
||
{rawUrl && (
|
||
<a className="hf-ve-action" href={rawUrl} target="_blank" rel="noopener noreferrer">
|
||
Raw
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hf-ve-main" data-tune={tunePanel ? "true" : "false"}>
|
||
<div className="hf-ve-stage">
|
||
{stageNode}
|
||
<div className="hf-ve-caption">
|
||
{(seconds || size) && <span>{[seconds, size && `${size} preview`].filter(Boolean).join(" · ")}</span>}
|
||
<span>{caption}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{tunePanel && (
|
||
<aside className="hf-ve-tune" aria-label="Tune">
|
||
<div className="hf-ve-tune-inner">
|
||
<div className="hf-ve-tune-head">
|
||
Tune <small>{variables.length} {variables.length === 1 ? "variable" : "variables"}</small>
|
||
</div>
|
||
<div className="hf-ve-tune-list">
|
||
{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 className="hf-ve-tune-foot">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setValues(defaults);
|
||
setNotes({});
|
||
}}
|
||
disabled={!dirty}
|
||
className="hf-ve-action"
|
||
>
|
||
Reset
|
||
</button>
|
||
<CopyAction
|
||
id="json"
|
||
label="Copy JSON"
|
||
text={() => JSON.stringify(dirty ? changedValues() : defaults, null, 2)}
|
||
/>
|
||
<CopyAction id="render" label="Copy render cmd" text={renderCommand} />
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
)}
|
||
</div>
|
||
|
||
<div className="hf-ve-tabs hf-ve-tabs-row" role="tablist">
|
||
{TABS.map(([id, label, note]) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
role="tab"
|
||
data-on={tab === id}
|
||
aria-selected={tab === id}
|
||
onClick={() => setTab(id)}
|
||
className="hf-ve-tab hf-ve-tint"
|
||
>
|
||
{label}
|
||
{note ? <small>{note}</small> : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hf-ve-body">
|
||
{/* Slots are shown by CSS on the tab attribute, not sorted here: MDX's child
|
||
shape is not worth introspecting. Frame-mode pages have no prose wrapper,
|
||
so the slots carry their own. */}
|
||
<div className="hf-ve-slots" data-tab={tab}>
|
||
{otherSlots}
|
||
</div>
|
||
<div className="hf-ve-body-pane hf-ve-about not-prose" hidden={tab !== "preview"}>
|
||
<h3>About</h3>
|
||
{about && <p>{about}</p>}
|
||
{attribution && (
|
||
<p className="hf-ve-attr">
|
||
{attribution.author ? (
|
||
<>
|
||
Created by{" "}
|
||
{attribution.authorUrl ? (
|
||
<a href={attribution.authorUrl} target="_blank" rel="noopener noreferrer">
|
||
{attribution.author}
|
||
</a>
|
||
) : (
|
||
attribution.author
|
||
)}{" "}
|
||
·{" "}
|
||
</>
|
||
) : null}
|
||
Registry item{" "}
|
||
<a
|
||
href={`https://github.com/heygen-com/hyperframes/tree/main/${attribution.path}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
heygen-com/hyperframes
|
||
</a>{" "}
|
||
· <code>{attribution.path}</code>
|
||
{attribution.tags.length > 0 ? ` · ${attribution.tags.join(", ")}` : ""}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="hf-ve-body-pane hf-ve-snippet not-prose" hidden={tab !== "install"}>
|
||
{/* The mount element carrying what the reader dialled in, assembled token by token (no build step to highlight it). */}
|
||
<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>
|
||
);
|
||
};
|