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 \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);