- commit
- 41facbc244f99fe4940c9bd596018e5c7a2f1baf
- parent
- 3ac3f9d138536aabe3625be7a6c2050b78b504aa
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 32 | +++++++++++++++++++++++++++++- |
| M | src/ | 22 | +++++++++++++++++++++ |
| M | src/ | 31 | +++++++++++++++++++++++++---- |
| A | tests/ | 29 | +++++++++++++++++++++++++++ |
@@ -50,6 +50,26 @@ function wrapText(ctx, value, maxWidth, size = 9, weight = 650) {
5050 return lines.length ? lines : [''];5151 }5252 53+function clampMotion(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }54+function easeOutCubic(value) {55+ const t = clampMotion(value);56+ return 1 - ((1 - t) ** 3);57+}58+function easeOutBack(value) {59+ const t = clampMotion(value);60+ const overshoot = 1.70158;61+ return 1 + (overshoot + 1) * ((t - 1) ** 3) + overshoot * ((t - 1) ** 2);62+}63+export function giftMotionFrame(sceneMotion = {}, reducedMotion = false) {64+ if (reducedMotion || sceneMotion.phase === 'settled') return { offsetY: 0, alpha: 1 };65+ if (sceneMotion.phase === 'exiting') {66+ const exit = clampMotion(sceneMotion.exitProgress);67+ return { offsetY: 16 * (exit ** 3), alpha: 1 - exit * 0.22 };68+ }69+ const entry = clampMotion(sceneMotion.entryProgress);70+ return { offsetY: -48 * (1 - easeOutBack(entry)), alpha: 0.78 + easeOutCubic(entry) * 0.22 };71+}72+5373 /** Canvas-only Gift controller. It never installs a pack itself. */5474 export function createGiftScene({ pack = null, collision = false, warnings: initialWarnings = [], reducedMotion = false, onAction, copyToClipboard } = {}) {5575 let preview = pack ? cloneData(pack) : null;@@ -122,6 +142,7 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
122142 if (!open || !preview || !ctx) return null;123143 const width = options.width || ctx.canvas?.width || 800;124144 const height = options.height || ctx.canvas?.height || 600;145+ const motion = giftMotionFrame(options.sceneMotion, options.reducedMotion);125146 const narrow = width < 760;126147 const coarsePointer = options.coarsePointer ?? globalThis.matchMedia?.('(pointer: coarse)').matches ?? false;127148 const touchLayout = narrow || coarsePointer;@@ -133,6 +154,12 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
133154 const displayWarningLines = detailsOpen ? noteLines.slice(0, 5).flatMap(line => wrapText(ctx, line, cardWidth - 68, 9, 650)) : [];134155 noteButtons = [];135156 ctx.save(); ctx.fillStyle = 'rgba(0,0,0,.78)'; ctx.fillRect(0, 0, width, height);157+ const motionContext = typeof ctx.translate === 'function';158+ if (motionContext) {159+ ctx.save();160+ ctx.globalAlpha *= motion.alpha;161+ ctx.translate(0, motion.offsetY);162+ }136163 ctx.fillStyle = COLORS.lip; rounded(ctx, left + 8, top + 8, cardWidth, cardHeight, 5); ctx.fill(); ctx.fillStyle = COLORS.board; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.stroke();137164 text(ctx, mode === 'collision' ? 'ALREADY HERE' : 'A PACK FOR YOU', left + 32, top + 38, { size: 12, weight: 800, color: COLORS.accent });138165 const titleLabel = fitLabel(ctx, title, cardWidth - 64, 34, 850);@@ -245,7 +272,10 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
245272 ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, hintY + offsetY, keyWidth, rowHeight, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, hintY + offsetY, keyWidth, rowHeight, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, hintY + rowHeight / 2 + offsetY, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, hintY + rowHeight / 2 + offsetY, { size: labelSize, weight: 700, color: COLORS.muted }); ctx.restore(); hintX += itemWidth + hintGap;246273 });247274 });248- return { x: left, y: top, width: cardWidth, height: cardHeight, narrow, touchLayout, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hintActions: hintButtons.map(button => ({ ...button })), noteActions: noteButtons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: hintBottom - rowHeight / 2, hintRows: hintRows.length, selected: selectedAction, now };275+ const layout = { x: left, y: top, width: cardWidth, height: cardHeight, narrow, touchLayout, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hintActions: hintButtons.map(button => ({ ...button })), noteActions: noteButtons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: hintBottom - rowHeight / 2, hintRows: hintRows.length, selected: selectedAction, now };276+ if (motionContext) ctx.restore();277+ ctx.restore();278+ return layout;249279 }250280 const api = { show, hide, setPack, choose, input, hit, draw, accessibilityText, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? cloneData(preview) : null; }, get collision() { return collides ? cloneData(collides) : null; }, get warnings() { return cloneData(warnings); }, get detailsOpen() { return detailsOpen; } };251281 return Object.freeze(api);@@ -43,6 +43,20 @@ function wrapText(ctx, value, width, size, weight = 700) {
4343 if (line) lines.push(line);4444 return lines.length ? lines : [''];4545 }46+function clampMotion(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }47+function easeOutCubic(value) {48+ const t = clampMotion(value);49+ return 1 - ((1 - t) ** 3);50+}51+export function workshopMotionFrame(sceneMotion = {}, reducedMotion = false) {52+ if (reducedMotion || sceneMotion.phase === 'settled') return { offsetY: 0, alpha: 1 };53+ if (sceneMotion.phase === 'exiting') {54+ const exit = clampMotion(sceneMotion.exitProgress);55+ return { offsetY: 16 * (exit ** 3), alpha: 1 - exit * 0.28 };56+ }57+ const entry = clampMotion(sceneMotion.entryProgress);58+ return { offsetY: -18 * (1 - easeOutCubic(entry)), alpha: 0.76 + easeOutCubic(entry) * 0.24 };59+}4660 function drawKeyHint(ctx, key, verb, right, y, colors, options = {}) {4761 ctx.font = `800 10px ${FONT}`;4862 const verbWidth = ctx.measureText(verb).width;@@ -246,7 +260,14 @@ export function createWorkshopScene(options = {}) {
246260 const compact = width < 600;247261 const coarsePointer = dimensions.coarsePointer ?? globalThis.matchMedia?.('(pointer: coarse)')?.matches ?? false;248262 const noKeycaps = width < 760 || coarsePointer;263+ const motion = workshopMotionFrame(dimensions.sceneMotion, dimensions.reducedMotion);249264 ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, width, height);265+ const motionContext = typeof ctx.save === 'function' && typeof ctx.translate === 'function';266+ if (motionContext) {267+ ctx.save();268+ ctx.globalAlpha *= motion.alpha;269+ ctx.translate(0, motion.offsetY);270+ }250271 const margin = compact ? 24 : 48;251272 drawText(ctx, 'WORKSHOP', margin, compact ? 30 : 48, 13, colors.muted, 'left', 800);252273 if (compact) {@@ -341,6 +362,7 @@ export function createWorkshopScene(options = {}) {
341362 hitLayout.confirm = { choices: confirm.choices.map((choice, index) => ({ x: buttonsLeft + index * (buttonWidth + gap), y: cardY + 118, width: buttonWidth, height: 38 })) };342363 confirm.choices.forEach((choice, index) => { const cell = hitLayout.confirm.choices[index]; roundRect(ctx, cell.x, cell.y, cell.width, cell.height, 3); ctx.fillStyle = confirm.choice === index ? colors.YOU : colors.board; ctx.fill(); ctx.strokeStyle = confirm.choice === index ? colors.YOU : colors.edge; ctx.stroke(); drawText(ctx, choice, cell.x + cell.width / 2, cell.y + cell.height / 2, 10, confirm.choice === index ? colors.ink : colors.fg, 'center', 850); });343364 }365+ if (motionContext) ctx.restore();344366 return view();345367 }346368 @@ -317,13 +317,36 @@ export function createStage(options = {}) {
317317 return;318318 }319319 if (scene === "gift" && gift) {320- gift.draw(ctx, visualNow(), { width: w, height: h, keycapPress, reducedMotion });320+ gift.draw(ctx, visualNow(), {321+ width: w,322+ height: h,323+ keycapPress,324+ reducedMotion,325+ sceneMotion: sceneMotionPhase({326+ now: visualNow(),327+ openedAt: sceneMotion.openedAt,328+ exitStartedAt: sceneMotion.scene === 'gift' ? sceneMotion.exitStartedAt : null,329+ reducedMotion,330+ }),331+ });321332 canvas.setAttribute("aria-label", gift.accessibilityText?.() || "Miau Is True Gift");322333 drawCopyConfirmation(ctx, w, h);323334 return;324335 }325336 if (scene === "workshop" && workshop) {326- workshop.draw(ctx, { width: w, height: h, keycapPress, now: visualNow(), reducedMotion });337+ workshop.draw(ctx, {338+ width: w,339+ height: h,340+ keycapPress,341+ now: visualNow(),342+ reducedMotion,343+ sceneMotion: sceneMotionPhase({344+ now: visualNow(),345+ openedAt: sceneMotion.openedAt,346+ exitStartedAt: sceneMotion.scene === 'workshop' ? sceneMotion.exitStartedAt : null,347+ reducedMotion,348+ }),349+ });327350 canvas.setAttribute("aria-label", workshop.accessibilityText?.() || "Miau Is True Workshop");328351 drawCopyConfirmation(ctx, w, h);329352 return;@@ -462,7 +485,7 @@ export function createStage(options = {}) {
462485 : id === "workshop" && workshop?.accessibilityText ? workshop.accessibilityText()463486 : `${spec.eyebrow || ""} ${titleEl.textContent}`.trim());464487 render();465- if (!reducedMotion && (id === "title" || id === "win" || id === "index" || id === "pause"))488+ if (!reducedMotion && (id === "title" || id === "win" || id === "index" || id === "pause" || id === "gift" || id === "workshop"))466489 visualFrame(animateVisuals);467490 if (id === 'win' || id === 'pause') {468491 transitionStart = 0;@@ -475,7 +498,7 @@ export function createStage(options = {}) {
475498 }476499 function show(id, data = {}) {477500 if (sceneMotion.phase === 'exiting') return api;478- const menuScene = scene === 'title' || scene === 'index';501+ const menuScene = scene === 'title' || scene === 'index' || scene === 'gift' || scene === 'workshop';479502 if (!data.instant && !reducedMotion && menuScene && id !== scene) {480503 const now = visualNow();481504 const generation = sceneMotion.generation + 1;@@ -0,0 +1,29 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { giftMotionFrame } from '../src/scenes/gift.js';4+import { workshopMotionFrame } from '../src/scenes/workshop.js';5+6+test('Gift card slams from above and falls 16px during exit', () => {7+ const start = giftMotionFrame({ phase: 'opening', entryProgress: 0 });8+ const landed = giftMotionFrame({ phase: 'opening', entryProgress: 1 });9+ const exit = giftMotionFrame({ phase: 'exiting', exitProgress: 1 });10+ assert.ok(start.offsetY < -40);11+ assert.ok(Math.abs(landed.offsetY) < 0.001);12+ assert.equal(exit.offsetY, 16);13+ assert.ok(exit.alpha < 1);14+});15+16+test('Workshop shell and list ease in, then drift down on exit', () => {17+ const start = workshopMotionFrame({ phase: 'opening', entryProgress: 0 });18+ const landed = workshopMotionFrame({ phase: 'opening', entryProgress: 1 });19+ const exit = workshopMotionFrame({ phase: 'exiting', exitProgress: 1 });20+ assert.equal(start.offsetY, -18);21+ assert.ok(Math.abs(landed.offsetY) < 0.001);22+ assert.equal(exit.offsetY, 16);23+ assert.ok(exit.alpha < 1);24+});25+26+test('reduced motion settles both menu scenes immediately', () => {27+ assert.deepEqual(giftMotionFrame({ phase: 'opening', entryProgress: 0 }, true), { offsetY: 0, alpha: 1 });28+ assert.deepEqual(workshopMotionFrame({ phase: 'exiting', exitProgress: 0.5 }, true), { offsetY: 0, alpha: 1 });29+});