mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(skills): c2v mining pass — 7 new blueprints, 10 new rules, compacted recipe corpus (#2680)
* feat(skills): c2v mining pass over animation blueprints and rules Compacts ~45 existing animation rules/blueprints into tighter recipe form (net -3.4k lines) and adds 17 mined from the c2v corpus: - 7 blueprints: agent-progress-theater, camera-journey, fixed-anchor-cycle, panel-edit-live-sync, prompt-type-submit-generate, transcript-scroll-artifact-reveal, zoom-out-workspace-reveal - 10 rules: 3d-camera-flight, anchored-layout-expand, chart-scrub-readout, chromatic-glitch, control-target-sync, cursor-drag, gradient-text-sweep, multi-cursor-choreography, particle-burst, theme-crossfade-morph Both indexes updated. * feat(skills): sync product-launch script bank with mined blueprint roles The role->blueprint script bank in product-launch-video/story-design.md is kept 1:1 with blueprints-index role declarations, which the c2v mining pass expanded. Adds the 25 missing entries (script-shape descriptor + example lines + pattern): 13 for the 7 new blueprints, 12 for role widenings on 7 existing ones (cursor-ui-demo, dataviz-countup, titlecard-reveal, et al.), and states the 1:1 sync contract in the bank's intro. * docs(skills): cover constellation-hub scatter-drift variant in the script bank Review follow-up on #2680: the SOCIAL_PROOF constellation-hub entry patterned only the orbit shape; the c2v pass added a scatter-drift end-card variant with the opposite geometry (no hub, no ring). Adds an example line and extends the pattern so a scatter-drift beat's VO isn't steered toward the orbit shape.
This commit is contained in:
@@ -7,251 +7,134 @@ metadata:
|
||||
|
||||
# Context-Sensitive Cursor
|
||||
|
||||
In a typewriter sequence, the cursor's color (and optionally height/blink rate) matches the **active text segment**. If the typewriter is currently typing a brand name, the cursor is the brand accent color; on a placeholder, it dims to gray. Enhances visual cohesion vs a single fixed cursor color across all text states.
|
||||
In a typewriter sequence, the cursor's color (and optionally height / blink behavior) matches the **active text segment** — brand accent while typing the brand name, dim on placeholders, success color on the completion mark. The eye lands on the keyword being typed because the cursor shifts with it; a fixed single-color cursor is visual noise by comparison. Layers on top of [discrete-text-sequence](discrete-text-sequence.md)'s SEQUENCE pattern.
|
||||
|
||||
## How It Works
|
||||
|
||||
The text is authored as a SEQUENCE of `{text, t, segment}` entries where `segment` is a string identifier ('main' / 'highlight' / 'brand' / 'success'). The driver tween's onUpdate determines the current segment based on `time`, then sets the cursor's CSS color (and optionally other props) to match that segment's palette.
|
||||
The text is authored as a SEQUENCE of `{ t, text, segment, color }` entries; a linear driver's `onUpdate` reverse-searches for the current entry and writes both the visible text and the cursor's `background` (the cursor is a colored block, so `background`, NOT `color`). A second linear tween sweeps a phase `p` through `2π × BLINK_CYCLES_PER_SCENE` and gates cursor opacity on `sin(p) > 0` — a deterministic square-wave blink on the timeline.
|
||||
|
||||
## HTML
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="cursor-scene"
|
||||
data-composition-id="cursor-scene"
|
||||
data-start="0"
|
||||
data-duration="{DURATION}"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="terminal">
|
||||
<div class="prompt">$</div>
|
||||
<div class="text-wrap">
|
||||
<span class="text" id="text"></span><span class="cursor" id="cursor">_</span>
|
||||
</div>
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="terminal">
|
||||
<div class="prompt">$</div>
|
||||
<div class="text-wrap">
|
||||
<span class="text" id="text"></span><span class="cursor" id="cursor">_</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
Placeholders: `{monoFont}` is the project's monospace stack (proportional fonts cause cursor drift mid-segment); `{bgColor}` is the dark backdrop; `{textColor}` is the readable foreground; `{promptColor}` is the segment-default color for the leading prompt glyph.
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
font-family: {monoFont};
|
||||
}
|
||||
.terminal {
|
||||
font-family: {monoFont}; /* proportional fonts drift the cursor mid-segment */
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 24px;
|
||||
font-size: 72px;
|
||||
font-weight: 800;
|
||||
color: {textColor};
|
||||
white-space: pre;
|
||||
}
|
||||
.prompt {
|
||||
color: {promptColor};
|
||||
}
|
||||
.text-wrap {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
min-width: 1200px;
|
||||
white-space: pre; /* preserve trailing spaces — cursor sits at segment end */
|
||||
}
|
||||
.text {
|
||||
color: {textColor};
|
||||
white-space: pre;
|
||||
}
|
||||
/* Cursor highlights based on active segment via per-frame background swap */
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
display: inline-block; /* inline ignores width/height */
|
||||
width: {cursorWidth}px;
|
||||
height: {cursorHeight}px;
|
||||
background: {textColor}; /* default — overridden per segment in onUpdate */
|
||||
margin-left: {cursorGap}px;
|
||||
vertical-align: {cursorBaselineFix}px;
|
||||
vertical-align: {cursorBaselineFix}px; /* small negative — anchor to baseline, not line-height */
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
```js
|
||||
// Adjacent entries usually share a text prefix but may differ in `segment` —
|
||||
// that's what shifts the cursor color mid-line.
|
||||
const SEQUENCE = [
|
||||
{ t: 0, text: "", segment: "main", color: "{mainColor}" },
|
||||
{ t: T_LEADIN_END, text: "{leadInChunk}", segment: "main", color: "{mainColor}" },
|
||||
{ t: T_BRAND_IN, text: "{leadInBrandPrefix}", segment: "brand", color: "{brandColor}" },
|
||||
{ t: T_BRAND_OUT, text: "{leadInBrandFull}", segment: "main", color: "{mainColor}" },
|
||||
{ t: T_CMD_IN, text: "{leadInCmdPrefix}", segment: "cmd", color: "{cmdColor}" },
|
||||
{ t: T_SUCCESS, text: "{leadInDone}", segment: "success", color: "{successColor}" },
|
||||
];
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Sequence with per-entry segment label.
|
||||
// Each entry: { t: absoluteSeconds, text: cumulative visible string, segment: paletteKey, color: hex }.
|
||||
// As the driver crosses each `t`, the cursor color swaps to that segment's accent —
|
||||
// viewer's eye locks onto the keyword being typed.
|
||||
// Shape: a monotonic timeline of N entries where adjacent entries usually share text-prefix
|
||||
// but may differ in `segment` (which is what makes the cursor color shift mid-line).
|
||||
const SEQUENCE = [
|
||||
{ t: 0, text: "", segment: "main", color: "{mainColor}" },
|
||||
{ t: T_LEADIN_END, text: "{leadInChunk}", segment: "main", color: "{mainColor}" },
|
||||
{ t: T_BRAND_IN, text: "{leadInBrandPrefix}", segment: "brand", color: "{brandColor}" }, // brand segment starts
|
||||
{ t: T_BRAND_OUT, text: "{leadInBrandFull}", segment: "main", color: "{mainColor}" }, // back to main
|
||||
{ t: T_CMD_IN, text: "{leadInCmdPrefix}", segment: "cmd", color: "{cmdColor}" }, // command segment
|
||||
{ t: T_BRAND_2, text: "{leadInCmdBrand}", segment: "brand", color: "{brandColor}" }, // brand again (e.g. filename)
|
||||
{ t: T_SUCCESS, text: "{leadInDone}", segment: "success", color: "{successColor}" }, // completion mark
|
||||
];
|
||||
|
||||
function entryAt(time) {
|
||||
for (let i = SEQUENCE.length - 1; i >= 0; i--) {
|
||||
if (time >= SEQUENCE[i].t) return SEQUENCE[i];
|
||||
}
|
||||
return SEQUENCE[0];
|
||||
function entryAt(time) {
|
||||
for (let i = SEQUENCE.length - 1; i >= 0; i--) {
|
||||
if (time >= SEQUENCE[i].t) return SEQUENCE[i];
|
||||
}
|
||||
return SEQUENCE[0];
|
||||
}
|
||||
|
||||
const textEl = document.getElementById("text");
|
||||
const cursorEl = document.getElementById("cursor");
|
||||
const textEl = document.getElementById("text");
|
||||
const cursorEl = document.getElementById("cursor");
|
||||
|
||||
// Discrete state driver — writes text + cursor color
|
||||
const driver = { t: 0 };
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
t: DURATION,
|
||||
duration: DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const entry = entryAt(driver.t);
|
||||
textEl.textContent = entry.text;
|
||||
cursorEl.style.background = entry.color;
|
||||
},
|
||||
const driver = { t: 0 };
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
t: DURATION,
|
||||
duration: DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const entry = entryAt(driver.t);
|
||||
textEl.textContent = entry.text;
|
||||
cursorEl.style.background = entry.color;
|
||||
},
|
||||
0,
|
||||
);
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Deterministic blink via sin (NOT CSS animation).
|
||||
// Phase sweep = (2π) × BLINK_CYCLES_PER_SCENE drives a square wave at sign(sin(p)).
|
||||
const blink = { p: 0 };
|
||||
tl.to(
|
||||
blink,
|
||||
{
|
||||
p: Math.PI * 2 * BLINK_CYCLES_PER_SCENE,
|
||||
duration: DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
cursorEl.style.opacity = Math.sin(blink.p) > 0 ? "1" : "0";
|
||||
},
|
||||
// Deterministic square-wave blink
|
||||
const blink = { p: 0 };
|
||||
tl.to(
|
||||
blink,
|
||||
{
|
||||
p: Math.PI * 2 * BLINK_CYCLES_PER_SCENE,
|
||||
duration: DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
cursorEl.style.opacity = Math.sin(blink.p) > 0 ? "1" : "0";
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
window.__timelines["cursor-scene"] = tl;
|
||||
</script>
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Non-blinking during active typing
|
||||
|
||||
When letters are being added (driver moved forward in the last `TYPING_GRACE` seconds), suppress blink — cursor stays solid. When no typing activity (`driver.t - lastChangeTime > TYPING_GRACE`), resume blink.
|
||||
- **Non-blinking during active typing** — suppress blink while letters are appearing (solid cursor), resume on idle. This MUST be a pure function of the driver's time: tracking a mutable `lastChangeTime` in `onUpdate` is not reverse-seek-safe (scrubbing backwards leaves the stale forward-pass value behind and the cursor blinks — or holds solid — at the wrong frames). Bake the change times from the SEQUENCE instead — every entry whose `text` differs from its predecessor is a typing event:
|
||||
|
||||
```js
|
||||
let lastChangeTime = 0,
|
||||
lastText = "";
|
||||
// In onUpdate:
|
||||
if (entry.text !== lastText) {
|
||||
lastChangeTime = driver.t;
|
||||
lastText = entry.text;
|
||||
}
|
||||
const isTyping = driver.t - lastChangeTime < TYPING_GRACE;
|
||||
// Baked once at build time — no runtime state.
|
||||
const CHANGE_TIMES = SEQUENCE.filter((e, i) => i > 0 && e.text !== SEQUENCE[i - 1].text).map(
|
||||
(e) => e.t,
|
||||
);
|
||||
// In onUpdate — identical result at any seek, either direction:
|
||||
const isTyping = CHANGE_TIMES.some((t) => t <= driver.t && driver.t - t < TYPING_GRACE);
|
||||
cursorEl.style.opacity = isTyping ? "1" : Math.sin(blink.p) > 0 ? "1" : "0";
|
||||
```
|
||||
|
||||
### Cursor HEIGHT shifts on segment
|
||||
- **Cursor HEIGHT shifts on segment** — larger cursor on the brand segment: `cursorEl.style.height = entry.segment === "brand" ? cursorHeightEmphasis : cursorHeight` (1.1–1.25×; more reads as glitch).
|
||||
- **Contrast reversal** — a dark-text-on-light segment needs a dark cursor too; keep `entry.color` as the single source of truth and read from it.
|
||||
|
||||
Larger cursor on brand segment for emphasis (`cursorHeightEmphasis > cursorHeight`):
|
||||
## Values
|
||||
|
||||
```js
|
||||
cursorEl.style.height =
|
||||
entry.segment === "brand" ? `${cursorHeightEmphasis}px` : `${cursorHeight}px`;
|
||||
```
|
||||
|
||||
### Cursor reverses contrast on dark text
|
||||
|
||||
If a segment is rendered DARK text on light bg, cursor should swap to dark too. Manage via `entry.color` as the SOURCE OF TRUTH and read from there.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Cursor color shifts make brand moments POP** — eye lands on the brand name because the cursor color shifts to brand accent. Without it, cursor is visual noise.
|
||||
- **`background` property on the cursor div** — NOT `color` (cursor is a colored block, not a glyph)
|
||||
- **Deterministic blink via sin** — never CSS `@keyframes blink`. HF seek will desync.
|
||||
- **Cursor `display: inline-block`** — `display: inline` ignores width/height.
|
||||
- **`vertical-align: -8px`** (or similar) — visually anchor cursor to text baseline, not full line-height.
|
||||
- **`white-space: pre`** on text and parent — preserve trailing spaces so cursor sits at end of segment, not after collapsed space.
|
||||
- **Color palette aligned with brand system** — 3-4 colors max for segments (main / brand / cmd / success). More and the segmentation reads as random.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **DURATION** — total scene length in seconds
|
||||
- Range: 4-8 s for a single typed line; longer if the line is long
|
||||
- Effects: too short truncates the typing; too long leaves a dead tail after the success state
|
||||
- Constraints: must be `≥ SEQUENCE[last].t + (closing dwell)`
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **SEQUENCE entry `t` values** — absolute seconds where each new visible text + segment kicks in
|
||||
- Range: monotonically increasing; spacing 0.2-0.5 s between micro-additions (per-word or per-token), longer between segment swaps
|
||||
- Effects: too-tight spacing collapses the typing feel into a slideshow; too-loose drags
|
||||
- Constraints: ordered ascending; entries do not need uniform spacing — slow down on highlights
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **Segment palette: mainColor / brandColor / cmdColor / successColor** — the cursor-fill swatches
|
||||
- Range: 3-4 discrete colors max; each should be distinguishable at small cursor width
|
||||
- Effects: too many segments and the swaps read as random; too few and the brand moment loses pop
|
||||
- Constraints: `brandColor` and `successColor` may be similar in hue but should differ in saturation/luminance so a brand→success transition is visible
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **cursorWidth / cursorHeight / cursorGap / cursorBaselineFix** — cursor block geometry
|
||||
- Range: cursorWidth 8-24 px; cursorHeight ≈ 0.85-1.0 × fontSize; cursorGap 4-12 px; cursorBaselineFix small negative number to drop below the baseline
|
||||
- Effects: too-thin cursor disappears in render compression; too-tall cursor visually outranks the text
|
||||
- Constraints: must use `display: inline-block` (a `width` on `display: inline` is ignored)
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **cursorHeightEmphasis** (Variations) — height when the active segment is the brand
|
||||
- Range: 1.1-1.25 × `cursorHeight`
|
||||
- Effects: subtle bump reads as emphasis; large bump reads as glitch
|
||||
- Constraints: `cursorHeightEmphasis > cursorHeight`
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **BLINK_CYCLES_PER_SCENE** — how many full blink cycles span `DURATION`
|
||||
- Range: choose so the period `DURATION / BLINK_CYCLES_PER_SCENE` ≈ 0.6-1.2 s; e.g. an 8-second scene with ~1 s period uses BLINK_CYCLES_PER_SCENE = 8
|
||||
- Effects: short period (many cycles) reads as glitchy / agitated; long period reads as terminal-idle
|
||||
- Constraints: must be a whole number when `DURATION` is fixed — the sin sweep ends mid-cycle otherwise and the cursor pops on the last frame
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **TYPING_GRACE** (Variations) — seconds after a text change during which blink is suppressed
|
||||
- Range: 0.15-0.3 s
|
||||
- Effects: low end still blinks while letters are still appearing; high end keeps cursor solid through long holds
|
||||
- Constraints: must be smaller than the shortest dwell between two adjacent SEQUENCE entries — otherwise the cursor never blinks
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
| token | range | notes |
|
||||
| ---------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| DURATION | 4–8s per typed line | `≥ SEQUENCE[last].t + closing dwell` |
|
||||
| entry `t` spacing | 0.2–0.5s micro-additions | ascending, non-uniform — slow down on highlights |
|
||||
| segment palette | 3–4 colors max | more reads as random; brand vs success should differ in saturation/luminance |
|
||||
| cursorWidth / Height | 8–24px / 0.85–1.0× fontSize | too thin vanishes in render compression; too tall outranks the text |
|
||||
| cursorBaselineFix | small negative px | drop the block to the text baseline |
|
||||
| BLINK_CYCLES_PER_SCENE | period ≈ 0.6–1.2s | **whole number** — otherwise the sin sweep ends mid-cycle and the cursor pops on the last frame |
|
||||
| TYPING_GRACE | 0.15–0.3s | **< shortest dwell between adjacent entries** — otherwise the cursor never blinks |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS animation** on cursor — must be timeline-driven (blink + color)
|
||||
- **Cursor `display: inline-block`** — required for width/height
|
||||
- **`white-space: pre`** on text container and text — preserve trailing space
|
||||
- **Monospace font** — proportional fonts cause cursor to drift mid-segment
|
||||
- **Cursor color goes on `background`** — it's a colored block, not a glyph.
|
||||
- **Blink is timeline-driven sin, pure of any mutable tracker** — the typing-grace variation shows the seek-safe form.
|
||||
- **`white-space: pre` on text and container** — collapsed trailing spaces park the cursor in the wrong column.
|
||||
- **Monospace font + `display: inline-block` cursor** — proportional faces drift the cursor mid-segment; inline ignores the block geometry.
|
||||
- **BLINK_CYCLES_PER_SCENE is a whole number** for the fixed DURATION.
|
||||
|
||||
## Combinations
|
||||
## See also
|
||||
|
||||
- [discrete-text-sequence.md](discrete-text-sequence.md) — uses the same SEQUENCE array pattern; this rule adds the cursor styling layer
|
||||
- [camera-cursor-tracking.md](camera-cursor-tracking.md) — camera tracks the cursor across the typing
|
||||
- [press-release-spring.md](press-release-spring.md) — after typing completes, a button press confirms the command
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — onUpdate driving cursor color + sin blink
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
`discrete-text-sequence` (the underlying SEQUENCE pattern) · `camera-cursor-tracking` (camera follows the cursor) · `press-release-spring` (post-typing confirm press).
|
||||
|
||||
Reference in New Issue
Block a user