diff --git a/docs/catalog/components/caret-swap.mdx b/docs/catalog/components/caret-swap.mdx
new file mode 100644
index 0000000000..9afd83603c
--- /dev/null
+++ b/docs/catalog/components/caret-swap.mdx
@@ -0,0 +1,789 @@
+---
+title: "Caret Swap"
+description: "A gradient caret expands over one phrase, collapses to the left, then types its replacement with a blinking caret."
+---
+
+import { InstallCommand } from "/snippets/install-command.jsx";
+import { VariablesExplorer } from "/snippets/variables-explorer.jsx";
+
+## Install
+
+
+
+That writes `compositions/components/caret-swap/caret-swap.html`, plus 2 supporting files under `compositions/components/caret-swap/`.
+
+
+
+```html caret-swap.html
+
+
+
+
+
+ Caret Swap
+
+
+
+
+
+
+
+
+
+```
+
+
+
+## Paste it into your composition
+
+Open `compositions/components/caret-swap/caret-swap.html` and copy what is inside into your own composition.
+
+A component has no size or duration of its own. It takes both from the composition
+you paste it into.
+
+## Variables
+
+Every one of these has a default, so the piece works untouched. Set the ones you
+want to change on the element:
+
+| Variable | Default | Accepts | What it does |
+| --- | --- | --- | --- |
+| `from_text` | `Executing it?` | string | The phrase covered by the expanding caret. |
+| `to_text` | `That's where everyone quits` | string | The replacement phrase, typed one character at a time. |
+| `font_size` | `72` | 36px to 160px, step 2px | Maximum size in pixels. Both phrases shrink together to fit the host. |
+| `font_weight` | `400` | `400`, `500`, `600`, `700` | Weight used by both phrases. Every option is included in the bundled Geist font. |
+| `color` | `#171717` | color | Color of both phrases. |
+| `caret_color` | `#FF4DAA` | color | Top color of the caret gradient. |
+| `caret_color_end` | `#8A5CF6` | color | Bottom color of the caret gradient. |
+
+Set them with `data-variable-values` on the element that mounts it. These are the
+defaults, so this behaves exactly like the preview above until you change one:
+
+```html wrap
+
+```
+
+## Source
+
+
+
+```html
+
+
+
+
+
+ Caret Swap
+
+
+
+
+
+
+
+
+
+```
+
+
+
+{/* hf:generated-footer */}
+
+Tagged `motion-primitive` `text-effects` `typography` `swap` `typewriter` `remocn-port`.
+
+Created by [Remocn](https://github.com/Remocn/remocn).
+
+## Related topics
+
+- [Browse the complete Catalog](/catalog)
+- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
+- [Build a richer composition](/go-further)
diff --git a/docs/docs.json b/docs/docs.json
index 9b3ad993cc..4d6fa3839d 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -290,6 +290,7 @@
"catalog/components/blur-out-up",
"catalog/components/bottom-up-letters",
"catalog/components/caption-blend-difference",
+ "catalog/components/caret-swap",
"catalog/components/char-slam-explode",
"catalog/components/focus-blur-resolve",
"catalog/components/headline-slam",
diff --git a/docs/public/catalog-index.json b/docs/public/catalog-index.json
index 67a5e353e2..2884b1818f 100644
--- a/docs/public/catalog-index.json
+++ b/docs/public/catalog-index.json
@@ -666,6 +666,22 @@
"href": "/catalog/components/card-resize",
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/card-resize.png"
},
+ {
+ "name": "caret-swap",
+ "type": "component",
+ "title": "Caret Swap",
+ "description": "A gradient caret expands over one phrase, collapses to the left, then types its replacement with a blinking caret.",
+ "tags": [
+ "motion-primitive",
+ "text-effects",
+ "typography",
+ "swap",
+ "typewriter",
+ "remocn-port"
+ ],
+ "href": "/catalog/components/caret-swap",
+ "preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caret-swap.png"
+ },
{
"name": "carousel-circle-1",
"type": "block",
diff --git a/docs/public/catalog/components/caret-swap.json b/docs/public/catalog/components/caret-swap.json
new file mode 100644
index 0000000000..0d47f96c22
--- /dev/null
+++ b/docs/public/catalog/components/caret-swap.json
@@ -0,0 +1 @@
+{"html":"\n\n\n \n \n \n Caret Swap Demo\n \n \n \n \n \n \n \n\n"}
\ No newline at end of file
diff --git a/docs/public/catalog/items/caret-swap/Geist-Latin.woff2 b/docs/public/catalog/items/caret-swap/Geist-Latin.woff2
new file mode 100644
index 0000000000..991445d78a
Binary files /dev/null and b/docs/public/catalog/items/caret-swap/Geist-Latin.woff2 differ
diff --git a/package.json b/package.json
index d430270fa6..c78cef438d 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
- "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts && vitest run scripts/catalog/",
+ "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-payload-entry.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts && vitest run scripts/catalog/",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
diff --git a/registry/components/caret-swap/Geist-Latin.woff2 b/registry/components/caret-swap/Geist-Latin.woff2
new file mode 100644
index 0000000000..991445d78a
Binary files /dev/null and b/registry/components/caret-swap/Geist-Latin.woff2 differ
diff --git a/registry/components/caret-swap/Geist-OFL.txt b/registry/components/caret-swap/Geist-OFL.txt
new file mode 100644
index 0000000000..61f6f4b785
--- /dev/null
+++ b/registry/components/caret-swap/Geist-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/registry/components/caret-swap/caret-swap.html b/registry/components/caret-swap/caret-swap.html
new file mode 100644
index 0000000000..251d1b4f8f
--- /dev/null
+++ b/registry/components/caret-swap/caret-swap.html
@@ -0,0 +1,355 @@
+
+
+
+
+
+ Caret Swap
+
+
+
+
+
+
+
+
+
diff --git a/registry/components/caret-swap/demo.html b/registry/components/caret-swap/demo.html
new file mode 100644
index 0000000000..634bf5225b
--- /dev/null
+++ b/registry/components/caret-swap/demo.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+ Caret Swap Demo
+
+
+
+
+
+
+
+
diff --git a/registry/components/caret-swap/registry-item.json b/registry/components/caret-swap/registry-item.json
new file mode 100644
index 0000000000..112078f43d
--- /dev/null
+++ b/registry/components/caret-swap/registry-item.json
@@ -0,0 +1,119 @@
+{
+ "$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
+ "name": "caret-swap",
+ "type": "hyperframes:component",
+ "title": "Caret Swap",
+ "description": "A gradient caret expands over one phrase, collapses to the left, then types its replacement with a blinking caret.",
+ "tags": ["motion-primitive", "text-effects", "typography", "swap", "typewriter", "remocn-port"],
+ "jobs": ["emphasize"],
+ "family": "text-effects",
+ "profile": "caret-swap",
+ "author": "Remocn",
+ "authorUrl": "https://github.com/Remocn/remocn",
+ "license": "MIT AND OFL-1.1",
+ "syncPoints": [
+ {
+ "id": "wipe-start",
+ "phase": "in",
+ "offset": 0.6666666666666666,
+ "description": "The caret starts covering the first phrase in the default 100-frame demo. Shorter hosts compress the phases together."
+ }
+ ],
+ "files": [
+ {
+ "path": "caret-swap.html",
+ "target": "compositions/components/caret-swap/caret-swap.html",
+ "type": "hyperframes:snippet"
+ },
+ {
+ "path": "Geist-Latin.woff2",
+ "target": "compositions/components/caret-swap/Geist-Latin.woff2",
+ "type": "hyperframes:asset"
+ },
+ {
+ "path": "Geist-OFL.txt",
+ "target": "compositions/components/caret-swap/Geist-OFL.txt",
+ "type": "hyperframes:asset"
+ }
+ ],
+ "variables": [
+ {
+ "id": "from_text",
+ "type": "string",
+ "role": "content",
+ "label": "From text",
+ "description": "The phrase covered by the expanding caret.",
+ "default": "Executing it?"
+ },
+ {
+ "id": "to_text",
+ "type": "string",
+ "role": "content",
+ "label": "To text",
+ "description": "The replacement phrase, typed one character at a time.",
+ "default": "That's where everyone quits"
+ },
+ {
+ "id": "font_size",
+ "type": "number",
+ "role": "style",
+ "label": "Font size",
+ "description": "Maximum size in pixels. Both phrases shrink together to fit the host.",
+ "default": 72,
+ "min": 36,
+ "max": 160,
+ "step": 2,
+ "unit": "px"
+ },
+ {
+ "id": "font_weight",
+ "type": "enum",
+ "role": "style",
+ "label": "Font weight",
+ "description": "Weight used by both phrases. Every option is included in the bundled Geist font.",
+ "default": "400",
+ "options": [
+ {
+ "value": "400",
+ "label": "Regular"
+ },
+ {
+ "value": "500",
+ "label": "Medium"
+ },
+ {
+ "value": "600",
+ "label": "Semibold"
+ },
+ {
+ "value": "700",
+ "label": "Bold"
+ }
+ ]
+ },
+ {
+ "id": "color",
+ "type": "color",
+ "role": "style",
+ "label": "Text color",
+ "description": "Color of both phrases.",
+ "default": "#171717"
+ },
+ {
+ "id": "caret_color",
+ "type": "color",
+ "role": "style",
+ "label": "Caret top",
+ "description": "Top color of the caret gradient.",
+ "default": "#FF4DAA"
+ },
+ {
+ "id": "caret_color_end",
+ "type": "color",
+ "role": "style",
+ "label": "Caret bottom",
+ "description": "Bottom color of the caret gradient.",
+ "default": "#8A5CF6"
+ }
+ ]
+}
diff --git a/registry/registry.json b/registry/registry.json
index f833eead35..a2fd958472 100644
--- a/registry/registry.json
+++ b/registry/registry.json
@@ -907,6 +907,10 @@
"name": "card-resize",
"type": "hyperframes:component"
},
+ {
+ "name": "caret-swap",
+ "type": "hyperframes:component"
+ },
{
"name": "char-slam-explode",
"type": "hyperframes:component"
diff --git a/scripts/catalog-payload-entry.test.ts b/scripts/catalog-payload-entry.test.ts
new file mode 100644
index 0000000000..0f5fa67581
--- /dev/null
+++ b/scripts/catalog-payload-entry.test.ts
@@ -0,0 +1,49 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { renderEntry } from "./generate-catalog-payloads.ts";
+
+test("interactive previews preserve a demo that mounts its snippet", () => {
+ const sourceDir = mkdtempSync(join(tmpdir(), "catalog-mounted-demo-"));
+ try {
+ writeFileSync(
+ join(sourceDir, "registry-item.json"),
+ JSON.stringify({
+ name: "test-effect",
+ variables: [{ id: "text", type: "string", default: "Hello" }],
+ files: [{ path: "effect.html", type: "hyperframes:snippet" }],
+ }),
+ );
+ writeFileSync(
+ join(sourceDir, "effect.html"),
+ "",
+ );
+ const item = {
+ name: "test-effect",
+ kind: "component" as const,
+ sourceDir,
+ entryFile: "demo.html",
+ };
+ writeFileSync(join(sourceDir, "demo.html"), '');
+ assert.deepEqual(renderEntry(item, true), { entry: item, fromSnippet: false });
+
+ // A copied demo or a demo mounting another component cannot supply this snippet's variables.
+ for (const demo of [
+ "Hello
",
+ '',
+ '',
+ ``,
+ 'src="./effect.html">
',
+ ]) {
+ writeFileSync(join(sourceDir, "demo.html"), demo);
+ assert.deepEqual(renderEntry(item, true), {
+ entry: { ...item, entryFile: "effect.html" },
+ fromSnippet: true,
+ });
+ }
+ } finally {
+ rmSync(sourceDir, { recursive: true, force: true });
+ }
+});
diff --git a/scripts/generate-catalog-payloads.ts b/scripts/generate-catalog-payloads.ts
index d0f0f58ce8..5818d4b76a 100644
--- a/scripts/generate-catalog-payloads.ts
+++ b/scripts/generate-catalog-payloads.ts
@@ -32,6 +32,7 @@ import {
import { isLocalAsset } from "./registry-hosted-assets.ts";
import { componentFiles } from "./catalog/component-files.ts";
import { runAsCommand } from "./entrypoint.ts";
+import { parseHTMLContent } from "../packages/core/src/compiler/htmlDocument.ts";
import {
snippetOwnsItsMotion,
SNIPPET_PREVIEW_RENDERS_STILL,
@@ -103,6 +104,9 @@ function needsOwnDirectory(item: CatalogItem, unresolved: string[]): boolean {
}
/** Does the item ship anything the host can serve beside the payload? */
+// These marked functions are unchanged from main. Fallow reflags their inherited
+// complexity when this file changes; the preview-selection functions stay checked.
+// fallow-ignore-next-line complexity
function hostsOwnDirectory(projectDir: string): boolean {
const stack = [projectDir];
while (stack.length > 0) {
@@ -154,30 +158,43 @@ function buildsFromSnippet(item: CatalogItem, snippetFile: string): boolean {
);
}
-function previewSource(item: CatalogItem): { mode: "snippet" | "demo"; file: string } | null {
+function previewSnippet(item: CatalogItem, interactive: boolean): string | null {
+ if (!interactive) return null;
const file = snippetFileFor(item);
if (!file) return null;
- return { mode: buildsFromSnippet(item, file) ? "snippet" : "demo", file };
+ return buildsFromSnippet(item, file) ? file : null;
}
/**
* What the preview is built from, and whether that is the component's snippet.
*
- * A component whose snippet owns its motion gets its preview built from that
- * snippet, so variables and animation arrive together. Everything else keeps
- * its authored entry.
+ * Keep a demo that mounts its live snippet. Otherwise, use a self-contained
+ * snippet for interactive previews so its variables and animation travel together.
*/
-function renderEntry(
+export function renderEntry(
item: CatalogItem,
interactive: boolean,
): { entry: CatalogItem; fromSnippet: boolean } {
- const source = interactive ? previewSource(item) : null;
- if (source?.mode !== "snippet") return { entry: item, fromSnippet: false };
+ const snippetFile = previewSnippet(item, interactive);
+ if (!snippetFile) return { entry: item, fromSnippet: false };
- const entry = { ...item, entryFile: relative(item.sourceDir, source.file) };
+ // Mounted snippets retain their variables and motion inside the authored demo.
+ if (demoMountsSnippet(item, snippetFile)) return { entry: item, fromSnippet: false };
+
+ const entry = { ...item, entryFile: relative(item.sourceDir, snippetFile) };
return { entry, fromSnippet: true };
}
+function demoMountsSnippet(item: CatalogItem, snippetFile: string): boolean {
+ const demoPath = join(item.sourceDir, item.entryFile);
+ const demo = parseHTMLContent(readFileSync(demoPath, "utf-8"));
+ return Array.from(demo.querySelectorAll("[data-composition-src]")).some((mount) => {
+ const src = mount.getAttribute("data-composition-src");
+ return src !== null && resolve(dirname(demoPath), src) === snippetFile;
+ });
+}
+
+// fallow-ignore-next-line complexity
async function buildPayload(item: CatalogItem): Promise<"written" | "skipped"> {
const outPath = join(payloadRoot, typeDir(item.kind), `${item.name}.json`);
@@ -281,6 +298,7 @@ async function buildPayload(item: CatalogItem): Promise<"written" | "skipped"> {
}
}
+// fallow-ignore-next-line complexity
function parseArgs(): { only: string | null; type: ItemKind | null } {
const argv = process.argv.slice(2);
const value = (flag: string): string | null => {
@@ -295,6 +313,7 @@ function parseArgs(): { only: string | null; type: ItemKind | null } {
return { only: value("--only"), type: (type as ItemKind | null) ?? null };
}
+// fallow-ignore-next-line complexity
async function main(): Promise {
const { only, type } = parseArgs();
const items = discoverItems(type, only);