Files
frames/skills/frames-animation/rules/context-sensitive-cursor.md
zooqueen 62bbc7766c skills: give each skill the directory its name declares
Every SKILL.md already declared the new name — name: frames, frames-cli,
frames-core, and so on — but the directories were never moved, so the tree
carried frames-* in content and hyperframes-* on disk. Everything that
resolves a skill by path missed: the core-skill list, the plugin marketplace
pin, and the docs the CLI reads at runtime. Eight tests failed on main because
the files they open do not exist.

The directories now match what the files say they are, and the guide the
claude-design contract reads takes the name that contract already used.
2026-08-13 18:17:24 -07:00

6.8 KiB
Raw Permalink Blame History

name, description, metadata
name description metadata
context-sensitive-cursor Cursor color and styling that adapt to the current text segment being typed — accent color on highlights, dim on placeholders, etc.
tags
cursor, color, context, typewriter, styling, segment

Context-Sensitive Cursor

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's SEQUENCE pattern.

How It Works

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.

Recipe

<!-- inside a standard scene clip (frames-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>
.terminal {
  font-family: {monoFont}; /* proportional fonts drift the cursor mid-segment */
  display: flex;
  align-items: baseline;
  white-space: pre; /* preserve trailing spaces — cursor sits at segment end */
}
.text {
  white-space: pre;
}
.cursor {
  display: inline-block; /* inline ignores width/height */
  width: {cursorWidth}px;
  height: {cursorHeight}px;
  background: {textColor}; /* default — overridden per segment in onUpdate */
  vertical-align: {cursorBaselineFix}px; /* small negative — anchor to baseline, not line-height */
}
// 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}" },
];

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

// 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,
);

Variations

  • 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:
// 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 — 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.

Values

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

  • 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.

See also

discrete-text-sequence (the underlying SEQUENCE pattern) · camera-cursor-tracking (camera follows the cursor) · press-release-spring (post-typing confirm press).