diff --git a/DESIGN.md b/DESIGN.md index 0f5d72cca2..8e8876131a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -208,10 +208,14 @@ Use an opaque background for swipe content. If the visible label is hidden, pres | [TransitionHorizontal](./packages/ui/src/components/animations/transition-horizontal.vue) | 500ms width and opacity transition | | [AnimatedContent](./packages/ui/src/components/animations/animated-content.vue) | 220ms enter, 160ms exit, and 6px inner blur transition | | Shared UnoCSS animations | Overlay 300ms, content 150ms, directional entrance 400ms, fade 200ms | +| [Blur fade](./packages/stage-ui/src/components/widgets/poppin-text/animators/blur-fade.ts) | Each grapheme fades and sharpens from 3px blur over 500ms. Starts are 40ms apart, at most 600ms in total | +| [RewriteText](./packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.vue) | Changed words blur out, then the new words blur in, with the blur fade timing | `AnimatedContent` owns motion, not surface styles. Its lifecycle owner supplies `data-state` and keeps the node mounted until the exit animation ends. -`AnimatedContent` and `BottomDrawer` explicitly handle reduced-motion preferences. Check other controls individually instead of assuming library-wide support. +Live voice text appears with the blur fade through `PoppinText`. A rewrite of spoken text, for example by the rephrase plugin, plays `RewriteText`, so only the changed words move. + +`AnimatedContent`, `BottomDrawer`, and the blur fade explicitly handle reduced-motion preferences. Check other controls individually instead of assuming library-wide support. Icons use Iconify. Existing components and stories use sets such as Solar and Phosphor. The repository does not require one exclusive icon set. diff --git a/apps/stage-tamagotchi/src/renderer/components/inlay/voice-inlay.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/inlay/voice-inlay.browser.test.ts index 4b2a49e1c4..5c3b46f086 100644 --- a/apps/stage-tamagotchi/src/renderer/components/inlay/voice-inlay.browser.test.ts +++ b/apps/stage-tamagotchi/src/renderer/components/inlay/voice-inlay.browser.test.ts @@ -141,6 +141,23 @@ describe('voice inlay', () => { await expect.poll(inlayView, { timeout: 3000 }).toBe('hidden') }) + it('plays the rewrite of auto-sent speech on the sent card before the card moves out', async () => { + const view = renderInlay() + publish({ drafts: [], input: { ...capturing, text: 'hello their', segments: [{ id: 'speech-0', text: 'hello their', final: true }] } }) + await expect.element(view.getByTestId('voice-draft-live')).toHaveTextContent('hello their') + + // Auto send: the speech settles into a draft that the host sends at once. + publish({ drafts: [{ ...draft, sending: true }], frontDraftId: 'draft' }) + await expect.poll(inlayView).toBe('sent') + const card = document.querySelector('[data-testid="voice-inlay"]')! + expect(card.classList.contains('inlay-send')).toBe(false) + + await expect.element(view.getByRole('status')).toHaveTextContent('Hello there.') + publish({ drafts: [] }) + await expect.poll(() => card.classList.contains('inlay-send'), { timeout: 3000 }).toBe(true) + await expect.poll(inlayView, { timeout: 3000 }).toBe('hidden') + }) + it('keeps new speech that starts during the send animation', async () => { renderInlay() publish({ drafts: [{ ...draft, sending: true }] }) diff --git a/apps/stage-tamagotchi/src/renderer/components/inlay/voice-inlay.vue b/apps/stage-tamagotchi/src/renderer/components/inlay/voice-inlay.vue index 4d513d5301..b457ec23cc 100644 --- a/apps/stage-tamagotchi/src/renderer/components/inlay/voice-inlay.vue +++ b/apps/stage-tamagotchi/src/renderer/components/inlay/voice-inlay.vue @@ -1,5 +1,6 @@ diff --git a/packages/stage-ui/src/components/widgets/poppin-text/animators/blur-fade.ts b/packages/stage-ui/src/components/widgets/poppin-text/animators/blur-fade.ts new file mode 100644 index 0000000000..c35f9125bd --- /dev/null +++ b/packages/stage-ui/src/components/widgets/poppin-text/animators/blur-fade.ts @@ -0,0 +1,97 @@ +import type { Animator } from '.' + +import { animate, cubicBezier, stagger } from 'animejs' + +/** + * The blurred stagger of AIRI text motion. The values match the slide transitions of the project talks. + * + * Each grapheme fades and sharpens over `durationMs`. Graphemes start `staggerMs` apart, and all starts fit in + * `maxSpreadMs`, so a long text does not take seconds to appear. + */ +export const BLUR_FADE = { + /** @default 500 */ + durationMs: 500, + /** @default 40 */ + staggerMs: 40, + /** @default 600 */ + maxSpreadMs: 600, + /** @default 3 */ + blurPx: 3, +} as const + +export type BlurFadeOptions = Partial> + +const ease = cubicBezier(0.22, 1, 0.36, 1) + +function prefersReducedMotion() { + return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches +} + +/** Sets the final state at once. An entered grapheme keeps no inline style, so it renders like plain text. */ +function settle(elements: HTMLElement[], direction: 'in' | 'out', blurPx: number) { + for (const element of elements) { + element.style.opacity = direction === 'in' ? '' : '0' + element.style.filter = direction === 'in' ? '' : `blur(${blurPx}px)` + } +} + +/** + * Fades graphemes in or out, one after another, with a blur. + * + * The start state is set before this function returns, so an entering grapheme never paints unblurred first. + * With reduced motion, the final state is set at once. + * + * Returns: + * - `finished`, which resolves when the last grapheme reaches its final state. + * - `stop`, which jumps every grapheme to its final state. + */ +export function playBlurFade(elements: HTMLElement[], direction: 'in' | 'out', options: BlurFadeOptions = {}) { + const { durationMs, staggerMs, maxSpreadMs, blurPx } = { ...BLUR_FADE, ...options } + if (elements.length === 0 || prefersReducedMotion()) { + settle(elements, direction, blurPx) + return { finished: Promise.resolve(), stop: () => {} } + } + + const visible = { opacity: 1, filter: 'blur(0px)' } + const hidden = { opacity: 0, filter: `blur(${blurPx}px)` } + const [from, to] = direction === 'in' ? [hidden, visible] : [visible, hidden] + for (const element of elements) { + element.style.opacity = String(from.opacity) + element.style.filter = from.filter + } + + const animation = animate(elements, { + opacity: [from.opacity, to.opacity], + filter: [from.filter, to.filter], + duration: durationMs, + ease, + delay: stagger(Math.min(staggerMs, maxSpreadMs / elements.length)), + }) + const finished = new Promise((resolve) => { + animation.then(() => { + settle(elements, direction, blurPx) + resolve() + }) + }) + + return { + finished, + stop: () => { + animation.complete() + settle(elements, direction, blurPx) + }, + } +} + +/** + * Creates a Poppin'Text animator that fades new graphemes in with a blurred stagger. + * + * @example + * + */ +export function createBlurFadeAnimator(options: BlurFadeOptions = {}): Animator { + return (elements) => { + const { stop } = playBlurFade(elements, 'in', options) + return stop + } +} diff --git a/packages/stage-ui/src/components/widgets/poppin-text/animators/index.ts b/packages/stage-ui/src/components/widgets/poppin-text/animators/index.ts index f74e82c2db..9db5508a75 100644 --- a/packages/stage-ui/src/components/widgets/poppin-text/animators/index.ts +++ b/packages/stage-ui/src/components/widgets/poppin-text/animators/index.ts @@ -8,6 +8,7 @@ export interface CreateAnimatorOptions { export type Animator = (elements: HTMLElement[]) => (() => void) +export * from './blur-fade' export * from './fade' export * from './float' export * from './popup' diff --git a/packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.browser.test.ts b/packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.browser.test.ts new file mode 100644 index 0000000000..900fb83382 --- /dev/null +++ b/packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.browser.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-vue' + +import RewriteText from './rewrite-text.vue' + +/** Short motion keeps the test fast. The stagger and the blur follow the same code path as the default motion. */ +const motion = { durationMs: 30, staggerMs: 5 } + +describe('rewriteText', () => { + it('animates only the changed words, at every place, then shows the new text as plain text', async () => { + const onFinished = vi.fn() + const view = render(RewriteText, { props: { from: 'um turn on the lite please', to: 'Turn on the light, please.', motion, onFinished } }) + const root = view.container.firstElementChild! + + // While old words leave, unchanged words stay whole and each removed grapheme is its own span. + const leaving = Array.from(root.children).map(span => span.textContent) + expect(leaving.join('')).toBe('um turn on the lite please') + expect(leaving).toContain(' on the ') + expect(leaving.filter(text => text?.length === 1).join('')).toBe('um turnlite') + + await vi.waitFor(() => expect(onFinished).toHaveBeenCalledOnce()) + expect(root.textContent).toBe('Turn on the light, please.') + expect(root.children).toHaveLength(1) + expect(root.querySelectorAll('[style*="blur"]')).toHaveLength(0) + }) + + it('finishes without animated graphemes when the text did not change', async () => { + const onFinished = vi.fn() + const view = render(RewriteText, { props: { from: 'Hello.', to: 'Hello.', motion, onFinished } }) + + await vi.waitFor(() => expect(onFinished).toHaveBeenCalledOnce()) + expect(view.container.textContent).toBe('Hello.') + expect(view.container.firstElementChild?.children).toHaveLength(1) + }) +}) diff --git a/packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.story.vue b/packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.story.vue new file mode 100644 index 0000000000..0d10cdc7b4 --- /dev/null +++ b/packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.story.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.vue b/packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.vue new file mode 100644 index 0000000000..4258c0a807 --- /dev/null +++ b/packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.vue @@ -0,0 +1,154 @@ + + +