diff --git a/motion/README.md b/motion/README.md index bcae7496..d823366b 100644 --- a/motion/README.md +++ b/motion/README.md @@ -34,4 +34,23 @@ This composition is the library's reference point — match its restraint, don't - **Type** — **Share Tech Mono** (a single static weight, 400 Regular — no bold/italic exists for this family) for the one big headline moment, paired with **Inter** as the clean workhorse body face for everything else. Both are vendored under `public/fonts/*.woff2` and loaded via `@font-face` in `theme.css` rather than fetched from a CDN — rendering never depends on network access or on whatever fonts happen to be installed on the render host. At least two weights of the body face, so hierarchy comes from more than just size. - **Motion** — entrances are CSS keyframe animations with a restrained easing (`cubic-bezier(0.22, 1, 0.36, 1)` — smooth settle, no overshoot, approximating Remotion's `spring({damping:17, mass:0.7, stiffness:140})` without the bounce), staggered across elements via `animation-delay` rather than all firing on frame 0. Keep one continuous ambient motion (here: the scanning accent line) so the frame is never fully static once entrances land. Every animation should earn its place — hierarchy, or a beat of pacing, not motion for its own sake. - **Layout** — anchor content asymmetrically (this clip sits in the lower two-thirds, left-aligned); avoid a perfectly centered card, which reads as a generic template rather than a designed frame. -- **AI tells to avoid** — no default AI-purple gradient wash, no centered-everything, no emoji as design elements, no one-font-one-size, no em dash in on-screen copy (voiceover script, highlight bullets, kicker text) or filler verbs ("Elevate", "Seamless", "Unleash", "Next-Gen"). \ No newline at end of file +- **AI tells to avoid** — no default AI-purple gradient wash, no centered-everything, no emoji as design elements, no one-font-one-size, no em dash in on-screen copy (voiceover script, highlight bullets, kicker text) or filler verbs ("Elevate", "Seamless", "Unleash", "Next-Gen"). + +## Panel-demo kit (`kit/`) + +`kit/` is a second register alongside the release-announcement's text-card +style: reusable `pk-`-namespaced CSS/HTML that recreates the control panel's +look (dark chrome, task cards, status pills, toasts, a typing reveal, a +cursor) so a composition can simulate the product actually being used, +instead of announcing it over a headline. Use the **text-card register** +(release-announcement's pattern) for version/feature announcements with no +product visuals; use the **demo register** (`kit/`) whenever the story is +"watch this happen in the app" — a task moving through the panel, a feature +being triggered, an agent doing something visible. + +`compositions/panel-demo/` is the reference composition: a task title types +into an intake field, a card materializes in a column, a cursor clicks it +done, a toast confirms, out on "roboco.tech". Start a new demo-register +composition from its structure and `kit.css`'s classes rather than +reinventing the panel's chrome per clip. See `kit/README.md` for the full +piece-by-piece reference. \ No newline at end of file diff --git a/motion/compositions/panel-demo/panel-demo.test.js b/motion/compositions/panel-demo/panel-demo.test.js new file mode 100644 index 00000000..b11516ed --- /dev/null +++ b/motion/compositions/panel-demo/panel-demo.test.js @@ -0,0 +1,88 @@ +import { readFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +// HTML-structure smoke test for the panel-demo composition — mirrors +// release-announcement.test.js. We parse the HTML as text and assert +// structural invariants: dimensions, props.js wiring, the kit stylesheet +// link, timed clips, the kit's pill/toast/typing/cursor pieces actually +// being used, and the offline-render constraint (no CDN, no network URLs). + +const here = dirname(fileURLToPath(import.meta.url)); +const dir = here; +const read = (name) => readFileSync(join(dir, name), "utf8"); + +const orientations = [ + { file: "vertical.html", height: "1920" }, + { file: "square.html", height: "1080" }, +]; + +describe("panel-demo composition", () => { + it("ships props.js (default preview values, sidecar overwrites at render)", () => { + expect(existsSync(join(dir, "props.js"))).toBe(true); + const src = read("props.js"); + expect(src).toContain("window.__PROPS__"); + expect(src).toContain("window.__ORIENTATION__"); + expect(src).toContain("taskTitle"); + expect(src).toContain("toastTitle"); + expect(src).toContain("toastBody"); + }); + + it.each(orientations)("$file parses with correct dimensions and wiring", ({ file, height }) => { + const html = read(file); + + // HyperFrames render params. + expect(html).toContain('data-width="1080"'); + expect(html).toContain(`data-height="${height}"`); + expect(html).toMatch(/data-duration="12"/); + expect(html).toMatch(/data-fps="30"/); + + // props.js loaded before the kit helper and before any inline script + // reads window.__PROPS__ / window.PanelKit. + const propsIdx = html.indexOf(''); + const kitJsIdx = html.indexOf(''); + const inlineIdx = html.indexOf("window.__timelines"); + expect(propsIdx).toBeGreaterThan(-1); + expect(kitJsIdx).toBeGreaterThan(propsIdx); + expect(inlineIdx).toBeGreaterThan(kitJsIdx); + + // Kit stylesheet linked (this composition has no theme.css of its own). + expect(html).toContain('href="../../kit/kit.css"'); + + // At least one timed clip element. + expect(html).toMatch(/class="[^"]*clip[^"]*"/); + + // Panel frame chrome. + expect(html).toContain("pk-frame"); + expect(html).toContain("pk-frame__sidebar"); + expect(html).toContain("pk-frame__topbar"); + + // Typing reveal: container + caret present, wired to the kit helper. + expect(html).toContain('id="typeTitle"'); + expect(html).toContain("pk-caret"); + expect(html).toContain("PanelKit.typeText"); + + // Task card + the progress -> completed pill swap. + expect(html).toContain("pk-card"); + expect(html).toContain("pk-pill--progress"); + expect(html).toContain("pk-pill--completed"); + + // Cursor glide + click. + expect(html).toContain("pk-cursor"); + expect(html).toContain("pk-cursor__ring"); + + // Toast. + expect(html).toContain("pk-toast"); + expect(html).toContain('id="toastTitle"'); + expect(html).toContain('id="toastBody"'); + + // Outro headline moment. + expect(html).toContain("pk-outro"); + expect(html).toContain("roboco.tech"); + + // No CDN / network fetch of any kind — the render is offline. + expect(html).not.toMatch(/]+src="https?:\/\//); + expect(html).not.toMatch(/href="https?:\/\//); + }); +}); diff --git a/motion/compositions/panel-demo/props.js b/motion/compositions/panel-demo/props.js new file mode 100644 index 00000000..a91bcd41 --- /dev/null +++ b/motion/compositions/panel-demo/props.js @@ -0,0 +1,14 @@ +// Default sample props for local preview (npx hyperframes preview). The +// video-renderer sidecar OVERWRITES this file at render time with the real +// per-request values + window.__ORIENTATION__. Shared by both vertical.html +// and square.html — orientation is baked into which HTML file is rendered +// (see motion/README.md), so there is no runtime branch here. +window.__PROPS__ = { + taskTitle: "Ship the panel-demo kit", + agentName: "fe-dev-1", + teamLabel: "Frontend", + priorityLabel: "P1 - High", + toastTitle: "Task completed", + toastBody: "fe-dev-1 merged the panel-demo kit PR.", +}; +window.__ORIENTATION__ = "vertical"; diff --git a/motion/compositions/panel-demo/square.html b/motion/compositions/panel-demo/square.html new file mode 100644 index 00000000..959c2fc2 --- /dev/null +++ b/motion/compositions/panel-demo/square.html @@ -0,0 +1,151 @@ + + + + + Panel Demo — square (1080×1080) + + + + + + +
+
+ + +
+ +
+ + Live +
+
+ +
+ +
+
New task
+
Ship the panel-demo kit
+
+ + +
+
Tasks
+
+
Ship the panel-demo kit
+
+ Frontend + P1 - High + @fe-dev-1 +
+
+ in progress + completed +
+
+
+ + +
+
+
+
+ + +
+
+
+
Task completed
+
fe-dev-1 merged the panel-demo kit PR.
+
+
+ + +
roboco.tech
+
+
+
+ + + + diff --git a/motion/compositions/panel-demo/vertical.html b/motion/compositions/panel-demo/vertical.html new file mode 100644 index 00000000..ca78401c --- /dev/null +++ b/motion/compositions/panel-demo/vertical.html @@ -0,0 +1,150 @@ + + + + + Panel Demo — vertical (1080×1920) + + + + + + +
+
+ + +
+ +
+ + Live +
+
+ +
+ +
+
New task
+
Ship the panel-demo kit
+
+ + +
+
Tasks
+
+
Ship the panel-demo kit
+
+ Frontend + P1 - High + @fe-dev-1 +
+
+ in progress + completed +
+
+
+ + +
+
+
+
+ + +
+
+
+
Task completed
+
fe-dev-1 merged the panel-demo kit PR.
+
+
+ + +
roboco.tech
+
+
+
+ + + + diff --git a/motion/kit/README.md b/motion/kit/README.md new file mode 100644 index 00000000..9a4662a2 --- /dev/null +++ b/motion/kit/README.md @@ -0,0 +1,67 @@ +# kit + +Reusable plain-CSS/HTML building blocks that recreate the RoboCo control +panel's look, for compositions that want to simulate the product rather +than run a text card. Hand-rolled (no React, no Tailwind, no bundler) — this +is a recreation of the panel's design language, not a port of its +components. Load `kit.css` (and `kit.js` if you use the typing helper); no +composition-level `theme.css` is needed, kit.css owns the reset + fonts. + +```html + + + +``` + +Every class is namespaced `pk-`. See `compositions/panel-demo/` for a full +worked example (task types in, a card appears, a cursor clicks it done, a +toast confirms it). + +## Pieces + +- **`pk-frame`** — the app chrome: a slim sidebar (`pk-frame__sidebar`, + `pk-frame__navitem[--active]`) and a top bar (`pk-frame__topbar`, + `pk-frame__search`, `pk-frame__status` for the pulsing "Live" dot) around + a `pk-frame__content` area. Fixed sidebar/topbar sizing works at both + 1080x1920 and 1080x1080 — only the content area's height changes. +- **`pk-column`** / **`pk-card`** — a kanban-ish list container + (`pk-column__header` + children) hosting task cards. A card is + `pk-card__title`, `pk-card__meta` (chips + `pk-card__assignee`), and + `pk-card__status` (a pill). +- **`pk-chip`** — a small team/priority tag: `Frontend`. Variants: `--purple`, `--blue`, + `--green`, `--orange`, `--pink`, `--gray`. +- **`pk-pill`** — a status pill: `in progress`. Variants: `--progress`, + `--review`, `--approved`, `--completed`. To swap one pill for another + (e.g. progress -> completed), stack two pills at the same spot inside a + `position: relative` `pk-card__status` and give the outgoing one + `pk-pill--swap-out` (fades in then out) and the incoming one + `pk-pill--swap-in` (fades in and stays), each with its own + `--pk-pill-enter-delay` / `--pk-pill-exit-delay` (composition-absolute + seconds — the same convention `data-start` uses). +- **`pk-toast`** — a notification card that slides in from a corner: + `pk-toast__icon` + `pk-toast__body` (`pk-toast__title`, + `pk-toast__text`). Position via `--pk-toast-bottom` (right-anchored). +- **`pk-type`** / **`pk-caret`** — character-by-character typing reveal. + Write the target element's final text directly in the HTML, then call + `window.PanelKit.typeText(el, { delay, stagger })` (from `kit.js`) once, + synchronously, before rendering starts — it splits the text into + `pk-type__char` spans with staggered `animation-delay`s (works with any + font, no per-character width math). `delay` is the composition-absolute + second the first character appears; `stagger` defaults to 0.045s/char. + Add a `` next to it for the blinking caret. +- **`pk-cursor`** — a small CSS-shape cursor (`pk-cursor__glyph`) that + glides along a straight path and pulses (`pk-cursor__ring`) on arrival. + Position via `--pk-cursor-x0/y0/x1/y1` (content-box-relative pixels) and + timing via `--pk-cursor-delay` (glide start) / `--pk-click-delay` (pulse + start), all composition-absolute seconds. + +## Offline constraints (same as every composition) + +No CDN, no npm runtime deps, no bundler — plain files only, loaded via +relative paths (`../../kit/...` from a composition two levels down). Fonts +are the vendored `motion/public/fonts/*.woff2`, loaded via `@font-face` in +`kit.css`. Motion is CSS keyframes timed with `animation-delay` (seconds +from frame 0 — no `requestAnimationFrame`, no `Date.now()`), so a render is +frame-deterministic. diff --git a/motion/kit/kit.css b/motion/kit/kit.css new file mode 100644 index 00000000..7e1ef708 --- /dev/null +++ b/motion/kit/kit.css @@ -0,0 +1,498 @@ +/* Panel kit — reusable building blocks that recreate the RoboCo control + * panel's look for HyperFrames compositions. This is a hand-rolled + * recreation (plain CSS/HTML), not a screenshot or a port of the panel's + * React/Tailwind components. Every class is namespaced `pk-` (panel kit). + * + * Tokens are lifted from panel/src/app/globals.css's dark theme (the panel + * always renders dark in product screenshots) and from the status-pill / + * type-chip color maps in panel/src/components/tasks/*-badge.tsx. See + * motion/kit/README.md for usage and motion/README.md for the design bar + * this kit still has to answer to (variance ~6, motion ~6, density ~4). + */ + +@font-face { + font-family: "Share Tech Mono"; + src: url("../public/fonts/ShareTechMono-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: block; +} + +@font-face { + font-family: "Inter"; + src: url("../public/fonts/Inter-Variable.woff2") format("woff2-variations"); + font-weight: 400 600; + font-style: normal; + font-display: block; +} + +:root { + /* Surfaces — panel dark theme is achromatic (oklch chroma 0), not the + * warm-tinted ink used by release-announcement's theme.css. */ + --pk-bg: #0a0a0a; /* --background (dark) oklch(0.145 0 0) */ + --pk-surface: #1a1a1a; /* --card (dark) oklch(0.205 0 0) */ + --pk-surface-raised: #262626; /* --secondary/--muted (dark) oklch(0.269 0 0) */ + --pk-border: rgba(255, 255, 255, 0.10); /* --border (dark), exact */ + --pk-border-strong: rgba(255, 255, 255, 0.16); + + /* Text */ + --pk-text: #fafafa; /* --foreground (dark) oklch(0.985 0 0) */ + --pk-text-muted: #a3a3a3; /* --muted-foreground (dark) oklch(0.708 0 0) */ + --pk-text-faint: #737373; + + /* The one active-state inversion the panel uses (selected sidebar item): + * near-white bg, near-black text. */ + --pk-active-bg: #e5e5e5; /* --primary (dark) oklch(0.922 0 0) */ + --pk-active-fg: #1a1a1a; /* --primary-foreground (dark) oklch(0.205 0 0) */ + + /* One accent, used sparingly (cursor, caret, live dot) — shared with + * release-announcement's theme so multiple compositions still read as one + * library rather than each clip inventing its own brand color. */ + --pk-accent: #ff5a1f; + + /* Status pill palette — solid bg + white text, lifted from + * task-status-badge.tsx's Tailwind classes. */ + --pk-pill-progress: #2563eb; /* bg-blue-600 (IN_PROGRESS) */ + --pk-pill-review: #7c3aed; /* violet-600, echoes the violet PR chip */ + --pk-pill-approved: #d97706; /* bg-amber-600 (AWAITING_CEO_APPROVAL) */ + --pk-pill-completed: #16a34a; /* green-600 (COMPLETED family) */ + + --pk-radius: 16px; + --pk-radius-sm: 10px; + --pk-ease: cubic-bezier(0.22, 1, 0.36, 1); + + --pk-font-display: "Share Tech Mono"; + --pk-font-body: "Inter"; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + width: 100%; + height: 100%; + background: var(--pk-bg); + color: var(--pk-text); + font-family: var(--pk-font-body); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +#stage { + position: relative; + width: 1080px; + height: 100%; + overflow: hidden; + background: var(--pk-bg); +} + +/* ---------------------------------------------------------------------- * + * pk-frame — panel chrome: a slim sidebar rail + top header bar around a + * content area. A stylized crop of the app (not a full screenshot), so the + * same fixed sidebar/topbar sizing works at both 1080x1920 and 1080x1080 — + * only the content area's available height changes with the stage. + * ---------------------------------------------------------------------- */ +.pk-frame { + position: absolute; + inset: 0; + background: var(--pk-bg); + opacity: 0; + animation: pk-enter-fade 0.6s var(--pk-ease) forwards; +} + +.pk-frame__sidebar { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 168px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding-top: 32px; + background: var(--pk-surface); + border-right: 1px solid var(--pk-border); +} + +.pk-frame__logo { + width: 40px; + height: 40px; + border-radius: var(--pk-radius-sm); + background: var(--pk-accent); + margin-bottom: 10px; +} + +.pk-frame__navitem { + width: 44px; + height: 44px; + border-radius: var(--pk-radius-sm); + display: flex; + align-items: center; + justify-content: center; +} +.pk-frame__navitem::before { + content: ""; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--pk-text-muted); +} +.pk-frame__navitem--active { + background: var(--pk-active-bg); +} +.pk-frame__navitem--active::before { + background: var(--pk-active-fg); +} + +.pk-frame__topbar { + position: absolute; + left: 168px; + right: 0; + top: 0; + height: 96px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 40px; + border-bottom: 1px solid var(--pk-border); +} + +.pk-frame__search { + display: flex; + align-items: center; + width: 360px; + height: 48px; + padding: 0 20px; + border-radius: var(--pk-radius-sm); + border: 1px solid var(--pk-border); + background: var(--pk-surface); + color: var(--pk-text-faint); + font-size: 22px; +} + +.pk-frame__status { + display: flex; + align-items: center; + gap: 10px; + color: var(--pk-text-muted); + font-size: 20px; +} +.pk-frame__statusdot { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--pk-accent); + box-shadow: 0 0 12px var(--pk-accent); + animation: pk-pulse 2s ease-in-out infinite; +} + +.pk-frame__content { + position: absolute; + left: 168px; + right: 0; + top: 96px; + bottom: 0; + overflow: hidden; +} + +/* ---------------------------------------------------------------------- * + * pk-intake — a text-input style surface for the typing reveal. + * ---------------------------------------------------------------------- */ +.pk-intake { + position: absolute; + left: 56px; + right: 56px; + top: var(--pk-intake-top, 40px); + opacity: 0; + transform: translateY(14px); + animation: pk-enter-rise 0.6s var(--pk-ease) forwards; +} +.pk-intake__label { + font-size: 20px; + font-weight: 500; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--pk-text-faint); + margin-bottom: 12px; +} +.pk-intake__field { + display: flex; + align-items: center; + height: 72px; + padding: 0 24px; + border-radius: var(--pk-radius-sm); + border: 1px solid var(--pk-border-strong); + background: var(--pk-surface); + font-family: var(--pk-font-body); + font-size: 34px; + font-weight: 500; + color: var(--pk-text); + white-space: pre; + overflow: hidden; +} + +/* ---------------------------------------------------------------------- * + * pk-type — character-by-character typing reveal. kit.js splits the target + * element's text into one per character with a + * staggered animation-delay; this stylesheet only owns the reveal keyframe + * and the blinking caret, so it works with any font (no width bookkeeping). + * ---------------------------------------------------------------------- */ +.pk-type__char { + display: inline; + opacity: 0; + animation: pk-type-reveal 0.12s steps(1, end) forwards; +} +@keyframes pk-type-reveal { + to { opacity: 1; } +} + +.pk-caret { + display: inline-block; + width: 4px; + height: 0.9em; + margin-left: 2px; + vertical-align: -0.1em; + background: var(--pk-accent); + animation: pk-caret-blink 1s steps(1, end) infinite; +} +@keyframes pk-caret-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +/* ---------------------------------------------------------------------- * + * pk-column — a kanban-ish list container to host cards. + * ---------------------------------------------------------------------- */ +.pk-column { + position: absolute; + left: 56px; + width: 560px; + top: var(--pk-column-top, 220px); +} +.pk-column__header { + font-size: 22px; + font-weight: 600; + letter-spacing: 0.5px; + color: var(--pk-text-muted); + margin-bottom: 16px; +} + +/* ---------------------------------------------------------------------- * + * pk-card — task card: title, meta row (team/priority chips), status row. + * ---------------------------------------------------------------------- */ +.pk-card { + border-radius: var(--pk-radius); + border: 1px solid var(--pk-border); + background: var(--pk-surface); + padding: 32px 36px; + opacity: 0; + transform: translateY(20px) scale(0.97); + animation: pk-enter-rise-scale 0.5s var(--pk-ease) forwards; +} +.pk-card__title { + font-size: 34px; + font-weight: 600; + line-height: 1.25; + color: var(--pk-text); +} +.pk-card__meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} +.pk-card__assignee { + font-size: 20px; + color: var(--pk-text-muted); +} +.pk-card__status { + position: relative; + height: 44px; + margin-top: 20px; +} +.pk-card__status .pk-pill { + position: absolute; + left: 0; + top: 0; +} + +/* ---------------------------------------------------------------------- * + * pk-chip — small team/priority tag: soft bg + colored text, lifted from + * task-type-badge.tsx's dark-mode pairs. + * ---------------------------------------------------------------------- */ +.pk-chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 8px 18px; + font-size: 20px; + font-weight: 500; +} +.pk-chip--purple { background: #581c87; color: #d8b4fe; } +.pk-chip--blue { background: #1e3a8a; color: #93c5fd; } +.pk-chip--green { background: #14532d; color: #86efac; } +.pk-chip--orange { background: #7c2d12; color: #fdba74; } +.pk-chip--pink { background: #831843; color: #f9a8d4; } +.pk-chip--gray { background: #27272a; color: #d4d4d8; } + +/* ---------------------------------------------------------------------- * + * pk-pill — status pill: solid bg + white text, fully rounded (matches the + * panel's shared Badge component, which is rounded-full for every variant). + * Text mirrors the panel's own rendering of a status enum: lowercase, spaces + * instead of underscores (see TaskStatusBadge: status.replace(/_/g," ")). + * ---------------------------------------------------------------------- */ +.pk-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 10px 24px; + font-size: 22px; + font-weight: 500; + color: #fff; + white-space: nowrap; +} +.pk-pill--progress { background: var(--pk-pill-progress); } +.pk-pill--review { background: var(--pk-pill-review); } +.pk-pill--approved { background: var(--pk-pill-approved); } +.pk-pill--completed { background: var(--pk-pill-completed); } + +/* An enter+exit pair for a pill being swapped out (e.g. progress -> another + * status). Apply alongside a variant class; delays are set per-instance via + * --pk-pill-enter-delay / --pk-pill-exit-delay (composition-absolute + * seconds, same convention as every other animation-delay in this house). */ +.pk-pill--swap-out { + opacity: 0; + transform: translateY(6px); + animation: + pk-enter-rise 0.4s var(--pk-ease) var(--pk-pill-enter-delay, 0s) forwards, + pk-fade-out 0.4s var(--pk-ease) var(--pk-pill-exit-delay, 1s) forwards; +} +.pk-pill--swap-in { + opacity: 0; + transform: translateY(6px); + animation: pk-enter-rise 0.4s var(--pk-ease) var(--pk-pill-enter-delay, 0s) forwards; +} +@keyframes pk-fade-out { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-6px); } +} + +/* ---------------------------------------------------------------------- * + * pk-cursor — a small cursor sprite moved along a keyframed path (a pure CSS + * shape, no image asset), with a click-pulse ring. Start/end coordinates are + * content-box-relative pixels supplied per composition/orientation via + * --pk-cursor-x0/y0/x1/y1; the click delay via --pk-click-delay. + * ---------------------------------------------------------------------- */ +.pk-cursor { + position: absolute; + left: 0; + top: 0; + width: 34px; + height: 34px; + transform: translate(var(--pk-cursor-x0, 0px), var(--pk-cursor-y0, 0px)); + animation: pk-cursor-glide 1.2s var(--pk-ease) var(--pk-cursor-delay, 0s) forwards; +} +@keyframes pk-cursor-glide { + from { transform: translate(var(--pk-cursor-x0, 0px), var(--pk-cursor-y0, 0px)); } + to { transform: translate(var(--pk-cursor-x1, 0px), var(--pk-cursor-y1, 0px)); } +} +.pk-cursor__glyph { + width: 34px; + height: 34px; + background: var(--pk-text); + clip-path: polygon(0 0, 0 70%, 22% 55%, 38% 88%, 52% 82%, 36% 50%, 62% 50%); + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45)); +} +.pk-cursor__ring { + position: absolute; + left: -14px; + top: -14px; + width: 62px; + height: 62px; + border-radius: 50%; + border: 3px solid var(--pk-accent); + opacity: 0; + animation: pk-cursor-click 0.6s var(--pk-ease) var(--pk-click-delay, 0s) forwards; +} +@keyframes pk-cursor-click { + 0% { opacity: 0.9; transform: scale(0.4); } + 60% { opacity: 0.55; } + 100% { opacity: 0; transform: scale(1.6); } +} + +/* ---------------------------------------------------------------------- * + * pk-toast — notification toast, slides in from a corner (right-anchored; + * --pk-toast-bottom sets the vertical offset per orientation). + * ---------------------------------------------------------------------- */ +.pk-toast { + position: absolute; + right: 56px; + bottom: var(--pk-toast-bottom, 120px); + width: 560px; + display: flex; + align-items: flex-start; + gap: 16px; + padding: 24px 28px; + border-radius: var(--pk-radius); + border: 1px solid var(--pk-border); + background: var(--pk-surface-raised); + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.5); + opacity: 0; + transform: translateX(40px); + animation: pk-enter-slide 0.6s var(--pk-ease) forwards; +} +.pk-toast__icon { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--pk-pill-completed); +} +.pk-toast__title { + font-size: 26px; + font-weight: 600; + color: var(--pk-text); + margin-bottom: 6px; +} +.pk-toast__text { + font-size: 20px; + line-height: 1.3; + color: var(--pk-text-muted); +} + +/* ---------------------------------------------------------------------- * + * pk-outro — a headline moment set in the display face, matching the + * release-announcement composition's outro treatment. + * ---------------------------------------------------------------------- */ +.pk-outro { + position: absolute; + left: 56px; + bottom: var(--pk-outro-bottom, 56px); + font-family: var(--pk-font-display); + font-size: 56px; + color: var(--pk-text); + opacity: 0; + transform: translateY(14px); + animation: pk-enter-rise 0.8s var(--pk-ease) forwards; +} + +/* ---------------------------------------------------------------------- * + * Shared entrance keyframes. + * ---------------------------------------------------------------------- */ +@keyframes pk-enter-fade { + to { opacity: 1; } +} +@keyframes pk-enter-rise { + to { opacity: 1; transform: translateY(0); } +} +@keyframes pk-enter-rise-scale { + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes pk-enter-slide { + to { opacity: 1; transform: translateX(0); } +} +@keyframes pk-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} diff --git a/motion/kit/kit.js b/motion/kit/kit.js new file mode 100644 index 00000000..242d9653 --- /dev/null +++ b/motion/kit/kit.js @@ -0,0 +1,36 @@ +// Panel kit helpers — tiny, framework-free, run synchronously at load +// (no requestAnimationFrame, no timers). Loaded after props.js and before +// any inline script that reads window.PanelKit, same ordering convention as +// props.js itself (see motion/README.md). +(function () { + "use strict"; + + var DEFAULT_STAGGER = 0.045; // seconds between characters + + // Splits `el`'s current text into one per + // character (whitespace preserved via CSS `white-space: pre` on the + // container) and gives each an absolute animation-delay so the reveal + // cascades left-to-right. `delay` is the composition-absolute second the + // first character should appear — the same convention every other + // animation-delay in this house uses (seconds from frame 0, not relative + // to the element). Deterministic: runs once, before any animation fires. + function typeText(el, options) { + if (!el) return; + var opts = options || {}; + var delay = typeof opts.delay === "number" ? opts.delay : 0; + var stagger = typeof opts.stagger === "number" ? opts.stagger : DEFAULT_STAGGER; + var text = el.textContent || ""; + + el.textContent = ""; + el.classList.add("pk-type"); + for (var i = 0; i < text.length; i++) { + var span = document.createElement("span"); + span.className = "pk-type__char"; + span.textContent = text[i]; + span.style.animationDelay = (delay + i * stagger).toFixed(3) + "s"; + el.appendChild(span); + } + } + + window.PanelKit = { typeText: typeText }; +})();