diff --git a/frontend/src/app/layout/app-header.css b/frontend/src/app/layout/app-header.css new file mode 100644 index 00000000..0c619308 --- /dev/null +++ b/frontend/src/app/layout/app-header.css @@ -0,0 +1,87 @@ +@keyframes app-header-glyph-wave { + 0%, + 100% { + transform: translate3d(0, 0, 0) rotate(0deg); + } + 30% { + transform: translate3d(-0.5px, -4px, 0) rotate(-2deg); + } + 58% { + transform: translate3d(0.6px, 1px, 0) rotate(1.25deg); + } + 78% { + transform: translate3d(-0.2px, -0.75px, 0) rotate(-0.4deg); + } +} + +.app-header-text-wave .app-header-wave-glyph { + transform-origin: center 80%; + animation: app-header-glyph-wave 380ms cubic-bezier(0.2, 0.75, 0.25, 1) both; +} + +/* + * 生成中那个小机器人。方脸方眼是照产品本体的像素语汇画的——圆脸圆眼是通用 + * emoji 语汇,跟"做像素角色资产"这件事没关系,排在中文字旁边像贴上去的。 + * + * 动的只有瞳孔,脸不动:脸跟着晃就成了摇头,那是"不同意"不是"在张望"。 + * 视线和眨眼分成两条动画,周期故意不整除(3.6s / 4.3s),合起来的节奏不重复。 + * 每次移动占约 8% 周期(≈290ms)滑过去,到位后停住十几个百分点再走下一步。 + */ +@keyframes app-header-bot-gaze { + 0%, + 22% { + transform: translate(0, 0); + } + /* 瞟左边 */ + 30%, + 44% { + transform: translate(-2px, 0); + } + /* 扫到右边 */ + 52%, + 64% { + transform: translate(2px, 0); + } + /* 抬头想一下 */ + 72%, + 84% { + transform: translate(0, -2px); + } + 92%, + 100% { + transform: translate(0, 0); + } +} + +@keyframes app-header-bot-blink { + 0%, + 92% { + transform: scaleY(1); + } + 94.5% { + transform: scaleY(0.15); + } + 97%, + 100% { + transform: scaleY(1); + } +} + +.app-header-bot-gaze { + animation: app-header-bot-gaze 3600ms cubic-bezier(0.5, 0, 0.15, 1) infinite; +} + +.app-header-bot-blink { + /* SVG 默认绕用户坐标原点缩放,不套 fill-box 的话眨眼会把眼睛甩出脸外。 */ + transform-box: fill-box; + transform-origin: center; + animation: app-header-bot-blink 4300ms cubic-bezier(0.4, 0, 0.55, 1) infinite; +} + +@media (prefers-reduced-motion: reduce) { + .app-header-text-wave .app-header-wave-glyph, + .app-header-bot-gaze, + .app-header-bot-blink { + animation: none; + } +} diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index dfe556d2..0c587027 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -14,6 +14,7 @@ import { useProductPopoverMotion, } from '@/shared/ui' import { PageBackButton } from './page-back-button' +import './app-header.css' interface ProductNavigationItem { motionKey: string diff --git a/frontend/src/app/layout/page-back-button.css b/frontend/src/app/layout/page-back-button.css new file mode 100644 index 00000000..4fd24ad0 --- /dev/null +++ b/frontend/src/app/layout/page-back-button.css @@ -0,0 +1,24 @@ +@keyframes app-header-back-strip { + from { + transform: translate3d(0, 0, 0); + } + to { + transform: translate3d(-26px, 0, 0); + } +} + +.app-header-back-strip { + transform: translate3d(0, 0, 0); + will-change: transform; +} + +.app-header-back-in-flight .app-header-back-strip { + animation: app-header-back-strip 220ms cubic-bezier(0.65, 0, 0.35, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .app-header-back-in-flight .app-header-back-strip { + animation-duration: 0.01ms; + animation-delay: 0ms; + } +} diff --git a/frontend/src/app/layout/page-back-button.tsx b/frontend/src/app/layout/page-back-button.tsx index b040b3e0..8e15e795 100644 --- a/frontend/src/app/layout/page-back-button.tsx +++ b/frontend/src/app/layout/page-back-button.tsx @@ -2,6 +2,8 @@ import { ArrowLeft } from '@phosphor-icons/react' import { useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router' +import './page-back-button.css' + const backTransitionDurationMs = 230 function fallbackPath(pathname: string): string { diff --git a/frontend/src/app/route-motion.css b/frontend/src/app/route-motion.css new file mode 100644 index 00000000..57541f08 --- /dev/null +++ b/frontend/src/app/route-motion.css @@ -0,0 +1,78 @@ +@keyframes route-page-enter-forward { + from { + transform: translate3d(14px, 0, 0); + } + + 68% { + transform: translate3d(1px, 0, 0); + } + + to { + transform: translate3d(0, 0, 0); + } +} + +@keyframes route-page-enter-backward { + from { + transform: translate3d(-14px, 0, 0); + } + + 68% { + transform: translate3d(-1px, 0, 0); + } + + to { + transform: translate3d(0, 0, 0); + } +} + +@keyframes route-page-enter-lateral { + from { + transform: translate3d(8px, 0, 0); + } + + to { + transform: translate3d(0, 0, 0); + } +} + +.route-motion-surface[data-motion-active='true'] + :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)) { + backface-visibility: hidden; + --route-motion-lag: 0ms; +} + +.route-motion-surface[data-motion-active='true'] + :where(main, section[aria-label], [role='region']) { + --route-motion-lag: 18ms; +} + +.route-motion-surface[data-motion-active='true'] :where(aside, footer) { + --route-motion-lag: 34ms; +} + +.route-motion-surface[data-motion-active='true'][data-motion-direction='forward'] + :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)) { + animation: route-page-enter-forward 222ms cubic-bezier(0.16, 1, 0.3, 1) both; + animation-delay: var(--route-motion-lag); +} + +.route-motion-surface[data-motion-active='true'][data-motion-direction='backward'] + :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)) { + animation: route-page-enter-backward 222ms cubic-bezier(0.16, 1, 0.3, 1) both; + animation-delay: var(--route-motion-lag); +} + +.route-motion-surface[data-motion-active='true'][data-motion-direction='lateral'] + :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)) { + animation: route-page-enter-lateral 222ms cubic-bezier(0.16, 1, 0.3, 1) both; + animation-delay: var(--route-motion-lag); +} + +@media (prefers-reduced-motion: reduce) { + .route-motion-surface, + .route-motion-surface + :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)) { + animation: none !important; + } +} diff --git a/frontend/src/app/route-motion.tsx b/frontend/src/app/route-motion.tsx index f305997d..54c6f086 100644 --- a/frontend/src/app/route-motion.tsx +++ b/frontend/src/app/route-motion.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, type ReactNode } from 'react' import { useLocation } from 'react-router' import { getRouteMotionDirection } from './route-motion-model' +import './route-motion.css' function motionPathname(pathname: string): string { return /^\/projects\/[^/]+$/.test(pathname) ? `${pathname}/assets` : pathname diff --git a/frontend/src/index.css b/frontend/src/index.css index 0173d694..b92f8a3d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -7,179 +7,6 @@ body, height: 100%; } -@keyframes app-header-glyph-wave { - 0%, - 100% { - transform: translate3d(0, 0, 0) rotate(0deg); - } - 30% { - transform: translate3d(-0.5px, -4px, 0) rotate(-2deg); - } - 58% { - transform: translate3d(0.6px, 1px, 0) rotate(1.25deg); - } - 78% { - transform: translate3d(-0.2px, -0.75px, 0) rotate(-0.4deg); - } -} - -.app-header-text-wave .app-header-wave-glyph { - transform-origin: center 80%; - animation: app-header-glyph-wave 380ms cubic-bezier(0.2, 0.75, 0.25, 1) both; -} - -/* - * 生成中那个小机器人。方脸方眼是照产品本体的像素语汇画的——圆脸圆眼是通用 - * emoji 语汇,跟"做像素角色资产"这件事没关系,排在中文字旁边像贴上去的。 - * - * 动的只有瞳孔,脸不动:脸跟着晃就成了摇头,那是"不同意"不是"在张望"。 - * 视线和眨眼分成两条动画,周期故意不整除(3.6s / 4.3s),合起来的节奏不重复。 - * 每次移动占约 8% 周期(≈290ms)滑过去,到位后停住十几个百分点再走下一步。 - */ -@keyframes app-header-bot-gaze { - 0%, - 22% { - transform: translate(0, 0); - } - /* 瞟左边 */ - 30%, - 44% { - transform: translate(-2px, 0); - } - /* 扫到右边 */ - 52%, - 64% { - transform: translate(2px, 0); - } - /* 抬头想一下 */ - 72%, - 84% { - transform: translate(0, -2px); - } - 92%, - 100% { - transform: translate(0, 0); - } -} - -@keyframes app-header-bot-blink { - 0%, - 92% { - transform: scaleY(1); - } - 94.5% { - transform: scaleY(0.15); - } - 97%, - 100% { - transform: scaleY(1); - } -} - -.app-header-bot-gaze { - animation: app-header-bot-gaze 3600ms cubic-bezier(0.5, 0, 0.15, 1) infinite; -} - -.app-header-bot-blink { - /* SVG 默认绕用户坐标原点缩放,不套 fill-box 的话眨眼会把眼睛甩出脸外。 */ - transform-box: fill-box; - transform-origin: center; - animation: app-header-bot-blink 4300ms cubic-bezier(0.4, 0, 0.55, 1) infinite; -} - -@keyframes app-header-back-strip { - from { - transform: translate3d(0, 0, 0); - } - to { - transform: translate3d(-26px, 0, 0); - } -} - -.app-header-back-strip { - transform: translate3d(0, 0, 0); - will-change: transform; -} - -.app-header-back-in-flight .app-header-back-strip { - animation: app-header-back-strip 220ms cubic-bezier(0.65, 0, 0.35, 1) both; -} - -@keyframes product-popover-in { - 0% { - opacity: 0; - transform: translate3d(0, var(--product-popover-shift-y, -8px), 0) scale(0.82); - } - 56% { - opacity: 1; - transform: translate3d(0, calc(var(--product-popover-shift-y, -8px) * -0.1875), 0) scale(1.025); - } - 78% { - transform: translate3d(0, calc(var(--product-popover-shift-y, -8px) * 0.0625), 0) scale(0.996); - } - 100% { - opacity: 1; - transform: translate3d(0, 0, 0) scale(1); - } -} - -.product-popover-in, -.product-popover-out { - backface-visibility: hidden; - will-change: opacity, transform; -} - -.product-popover-in { - animation: product-popover-in 220ms cubic-bezier(0.22, 0.8, 0.25, 1) both; -} - -@keyframes product-popover-out { - 0% { - opacity: 1; - transform: translate3d(0, 0, 0) scale(1); - } - 100% { - opacity: 0; - transform: translate3d(0, var(--product-popover-shift-y, -8px), 0) scale(0.82); - } -} - -.product-popover-out { - animation: product-popover-out 110ms cubic-bezier(0.55, 0, 1, 0.45) both; -} - -.product-popover-closed { - pointer-events: none; - visibility: hidden; - opacity: 0; - transform: translate3d(0, var(--product-popover-shift-y, -8px), 0) scale(0.82); -} - -[data-popover-placement='top'] { - --product-popover-shift-y: 8px; -} - -@media (prefers-reduced-motion: reduce) { - .app-header-text-wave .app-header-wave-glyph, - .app-header-bot-gaze, - .app-header-bot-blink { - animation: none; - } - - .app-header-back-in-flight .app-header-back-strip { - animation-duration: 0.01ms; - animation-delay: 0ms; - } - - .product-popover-in { - animation: none; - } - - .product-popover-out { - animation: product-popover-out 0.01ms both; - } -} - /* * 宣传页设计令牌。 * @@ -313,125 +140,6 @@ body, } } -.project-preview-loading, -.project-preview-message { - position: relative; - display: grid; - height: 100%; - overflow: hidden; - place-items: center; - background: linear-gradient( - 145deg, - var(--color-app-surface-muted), - var(--color-app-surface-strong) - ); -} - -.project-preview-message-grid { - position: absolute; - inset: 0; - background-image: - linear-gradient(to right, rgb(143 160 146 / 18%) 1px, transparent 1px), - linear-gradient(to bottom, rgb(143 160 146 / 18%) 1px, transparent 1px); - background-position: center; - background-size: 24px 24px; - mask-image: radial-gradient(circle at center, #000 20%, transparent 76%); -} - -.project-preview-image { - opacity: 0.35; - filter: blur(12px); - transition: - opacity 320ms cubic-bezier(0.16, 1, 0.3, 1), - filter 420ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.project-preview-image-ready { - opacity: 1; - filter: blur(0); -} - -.project-preview-message span { - position: relative; - border: 1px solid rgb(143 160 146 / 24%); - background: rgb(247 248 244 / 78%); - padding: 0.3rem 0.55rem; - color: var(--color-app-faint); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.625rem; - letter-spacing: 0.06em; - backdrop-filter: blur(6px); -} - -.project-preview-message-error { - background: - radial-gradient(circle at 28% 16%, rgb(255 248 243 / 74%), transparent 38%), - linear-gradient(145deg, var(--color-app-surface-muted), rgb(214 193 182 / 40%)); -} - -.project-preview-message-error span { - border-color: var(--color-app-danger-line); - color: var(--color-app-danger-muted); -} - -@keyframes projects-dialog-backdrop { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes projects-dialog-panel { - from { - opacity: 0; - transform: translateY(8px) scale(0.98); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes route-page-enter-forward { - from { - transform: translate3d(14px, 0, 0); - } - - 68% { - transform: translate3d(1px, 0, 0); - } - - to { - transform: translate3d(0, 0, 0); - } -} - -@keyframes route-page-enter-backward { - from { - transform: translate3d(-14px, 0, 0); - } - - 68% { - transform: translate3d(-1px, 0, 0); - } - - to { - transform: translate3d(0, 0, 0); - } -} - -@keyframes route-page-enter-lateral { - from { - transform: translate3d(8px, 0, 0); - } - - to { - transform: translate3d(0, 0, 0); - } -} - .projects-intro { animation: projects-intro 420ms cubic-bezier(0.22, 1, 0.36, 1) both; } @@ -441,47 +149,6 @@ body, animation-delay: calc(70ms + var(--project-card-order, 0) * 55ms); } -.projects-dialog-backdrop { - animation: projects-dialog-backdrop 180ms ease-out both; -} - -.projects-dialog-panel { - animation: projects-dialog-panel 260ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -.route-motion-surface[data-motion-active='true'] - :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)) { - backface-visibility: hidden; - --route-motion-lag: 0ms; -} - -.route-motion-surface[data-motion-active='true'] - :where(main, section[aria-label], [role='region']) { - --route-motion-lag: 18ms; -} - -.route-motion-surface[data-motion-active='true'] :where(aside, footer) { - --route-motion-lag: 34ms; -} - -.route-motion-surface[data-motion-active='true'][data-motion-direction='forward'] - :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)) { - animation: route-page-enter-forward 222ms cubic-bezier(0.16, 1, 0.3, 1) both; - animation-delay: var(--route-motion-lag); -} - -.route-motion-surface[data-motion-active='true'][data-motion-direction='backward'] - :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)) { - animation: route-page-enter-backward 222ms cubic-bezier(0.16, 1, 0.3, 1) both; - animation-delay: var(--route-motion-lag); -} - -.route-motion-surface[data-motion-active='true'][data-motion-direction='lateral'] - :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)) { - animation: route-page-enter-lateral 222ms cubic-bezier(0.16, 1, 0.3, 1) both; - animation-delay: var(--route-motion-lag); -} - .action-reveal { animation: action-reveal 520ms cubic-bezier(0.2, 0.9, 0.25, 1) both; } @@ -489,184 +156,7 @@ body, @media (prefers-reduced-motion: reduce) { .projects-intro, .projects-card-enter, - .projects-dialog-backdrop, - .projects-dialog-panel, - .route-motion-surface, - .route-motion-surface - :where(header, main, aside, footer, section[aria-label], [role='region'], h1:not(header *)), - .action-reveal, - .project-pixel-mark i { + .action-reveal { animation: none !important; } - - .project-preview-image { - transition: none; - } -} - -.playtest-pixel-canvas { - position: relative; - height: 168px; - margin-top: 14px; - overflow: hidden; - mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 94%, transparent 100%); -} - -.playtest-pixel-canvas canvas { - display: block; - width: 100%; - height: 100%; -} - -@media (min-width: 768px) { - .playtest-pixel-canvas { - position: absolute; - inset: 0 -10px 0 42%; - height: auto; - margin-top: 0; - mask-image: linear-gradient(90deg, transparent 0%, #000 22%, #000 96%, transparent 100%); - } -} - -@keyframes project-pixel-field { - 0%, - 58%, - 100% { - opacity: 0.5; - transform: scale(0.84); - } - - 16% { - opacity: 0.96; - transform: scale(1.08); - } -} - -@keyframes project-pixel-frame { - 0%, - 100% { - opacity: 0.72; - transform: scale(0.86); - } - - 38% { - opacity: 1; - transform: scale(1.08); - } -} - -@keyframes project-pixel-handle { - 0%, - 24%, - 100% { - transform: scale(0.82); - } - - 42%, - 58% { - transform: scale(1.18); - } -} - -@keyframes project-pixel-cursor { - 0%, - 14%, - 100% { - opacity: 0.28; - transform: translate(-5px, -5px) scale(0.86); - } - - 38% { - opacity: 1; - transform: translate(0, 0) scale(1); - } -} - -/* - 创建页左侧的装饰点阵。289 个格子共用四组动画,靠 animation-delay 错峰: - pixel-ring-N 是格子到中心的距离环,管底噪;pixel-phase-N 是 (x+y)%4,管实心笔画。 - 两组延迟都是负值,页面一进来就处在动画中段,不会先静止再启动。 -*/ -.project-pixel-mark { - display: grid; - grid-template-columns: repeat(17, 5px); - grid-template-rows: repeat(17, 5px); - gap: 8px; - place-content: center; - width: 248px; - height: 248px; -} - -.project-pixel-mark i { - width: 5px; - height: 5px; - border-radius: 1px; - background: var(--color-app-line); - opacity: 0.62; - box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-app-surface-raised) 18%, transparent); - animation: project-pixel-field 4.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; -} - -.project-pixel-mark .pixel-ring-1 { - animation-delay: -0.14s; -} - -.project-pixel-mark .pixel-ring-2 { - animation-delay: -0.28s; -} - -.project-pixel-mark .pixel-ring-3 { - animation-delay: -0.42s; -} - -.project-pixel-mark .pixel-ring-4 { - animation-delay: -0.56s; -} - -.project-pixel-mark .pixel-ring-5 { - animation-delay: -0.7s; -} - -.project-pixel-mark .pixel-ring-6 { - animation-delay: -0.84s; -} - -.project-pixel-mark .pixel-ring-7 { - animation-delay: -0.98s; -} - -.project-pixel-mark .pixel-ring-8 { - animation-delay: -1.12s; -} - -.project-pixel-mark i.is-active { - background: var(--color-app-ink-soft); - opacity: 0.9; - box-shadow: none; - animation: project-pixel-frame 3.4s ease-in-out infinite; -} - -.project-pixel-mark i.is-active.pixel-phase-1 { - animation-delay: -0.18s; -} - -.project-pixel-mark i.is-active.pixel-phase-2 { - animation-delay: -0.36s; -} - -.project-pixel-mark i.is-active.pixel-phase-3 { - animation-delay: -0.54s; -} - -.project-pixel-mark i.is-handle { - background: var(--color-app-ink); - opacity: 1; - box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-app-ink) 9%, transparent); - animation: project-pixel-handle 3.8s ease-in-out infinite; -} - -.project-pixel-mark i.is-cursor { - background: var(--color-app-accent); - opacity: 1; - animation: project-pixel-cursor 2.8s cubic-bezier(0.22, 1, 0.36, 1) infinite; } diff --git a/frontend/src/pages/playtest/pixel-stage.css b/frontend/src/pages/playtest/pixel-stage.css new file mode 100644 index 00000000..d6ad10ff --- /dev/null +++ b/frontend/src/pages/playtest/pixel-stage.css @@ -0,0 +1,23 @@ +.playtest-pixel-canvas { + position: relative; + height: 168px; + margin-top: 14px; + overflow: hidden; + mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 94%, transparent 100%); +} + +.playtest-pixel-canvas canvas { + display: block; + width: 100%; + height: 100%; +} + +@media (min-width: 768px) { + .playtest-pixel-canvas { + position: absolute; + inset: 0 -10px 0 42%; + height: auto; + margin-top: 0; + mask-image: linear-gradient(90deg, transparent 0%, #000 22%, #000 96%, transparent 100%); + } +} diff --git a/frontend/src/pages/playtest/pixel-stage.tsx b/frontend/src/pages/playtest/pixel-stage.tsx index 6a632358..7bde36a6 100644 --- a/frontend/src/pages/playtest/pixel-stage.tsx +++ b/frontend/src/pages/playtest/pixel-stage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import { getPlaytestSceneState, modulo } from './pixel-stage-model' +import './pixel-stage.css' const WORLD_WIDTH = 144 const WORLD_HEIGHT = 56 diff --git a/frontend/src/pages/project-create/pixel-mark.css b/frontend/src/pages/project-create/pixel-mark.css new file mode 100644 index 00000000..822a84db --- /dev/null +++ b/frontend/src/pages/project-create/pixel-mark.css @@ -0,0 +1,148 @@ +@keyframes project-pixel-field { + 0%, + 58%, + 100% { + opacity: 0.5; + transform: scale(0.84); + } + + 16% { + opacity: 0.96; + transform: scale(1.08); + } +} + +@keyframes project-pixel-frame { + 0%, + 100% { + opacity: 0.72; + transform: scale(0.86); + } + + 38% { + opacity: 1; + transform: scale(1.08); + } +} + +@keyframes project-pixel-handle { + 0%, + 24%, + 100% { + transform: scale(0.82); + } + + 42%, + 58% { + transform: scale(1.18); + } +} + +@keyframes project-pixel-cursor { + 0%, + 14%, + 100% { + opacity: 0.28; + transform: translate(-5px, -5px) scale(0.86); + } + + 38% { + opacity: 1; + transform: translate(0, 0) scale(1); + } +} + +/* + 创建页左侧的装饰点阵。289 个格子共用四组动画,靠 animation-delay 错峰: + pixel-ring-N 是格子到中心的距离环,管底噪;pixel-phase-N 是 (x+y)%4,管实心笔画。 + 两组延迟都是负值,页面一进来就处在动画中段,不会先静止再启动。 +*/ +.project-pixel-mark { + display: grid; + grid-template-columns: repeat(17, 5px); + grid-template-rows: repeat(17, 5px); + gap: 8px; + place-content: center; + width: 248px; + height: 248px; +} + +.project-pixel-mark i { + width: 5px; + height: 5px; + border-radius: 1px; + background: var(--color-app-line); + opacity: 0.62; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-app-surface-raised) 18%, transparent); + animation: project-pixel-field 4.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.project-pixel-mark .pixel-ring-1 { + animation-delay: -0.14s; +} + +.project-pixel-mark .pixel-ring-2 { + animation-delay: -0.28s; +} + +.project-pixel-mark .pixel-ring-3 { + animation-delay: -0.42s; +} + +.project-pixel-mark .pixel-ring-4 { + animation-delay: -0.56s; +} + +.project-pixel-mark .pixel-ring-5 { + animation-delay: -0.7s; +} + +.project-pixel-mark .pixel-ring-6 { + animation-delay: -0.84s; +} + +.project-pixel-mark .pixel-ring-7 { + animation-delay: -0.98s; +} + +.project-pixel-mark .pixel-ring-8 { + animation-delay: -1.12s; +} + +.project-pixel-mark i.is-active { + background: var(--color-app-ink-soft); + opacity: 0.9; + box-shadow: none; + animation: project-pixel-frame 3.4s ease-in-out infinite; +} + +.project-pixel-mark i.is-active.pixel-phase-1 { + animation-delay: -0.18s; +} + +.project-pixel-mark i.is-active.pixel-phase-2 { + animation-delay: -0.36s; +} + +.project-pixel-mark i.is-active.pixel-phase-3 { + animation-delay: -0.54s; +} + +.project-pixel-mark i.is-handle { + background: var(--color-app-ink); + opacity: 1; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-app-ink) 9%, transparent); + animation: project-pixel-handle 3.8s ease-in-out infinite; +} + +.project-pixel-mark i.is-cursor { + background: var(--color-app-accent); + opacity: 1; + animation: project-pixel-cursor 2.8s cubic-bezier(0.22, 1, 0.36, 1) infinite; +} + +@media (prefers-reduced-motion: reduce) { + .project-pixel-mark i { + animation: none !important; + } +} diff --git a/frontend/src/pages/project-create/pixel-mark.tsx b/frontend/src/pages/project-create/pixel-mark.tsx index f751e614..aa5a391b 100644 --- a/frontend/src/pages/project-create/pixel-mark.tsx +++ b/frontend/src/pages/project-create/pixel-mark.tsx @@ -1,3 +1,5 @@ +import './pixel-mark.css' + const GRID_SIZE = 17 const CENTER = (GRID_SIZE - 1) / 2 diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx index b479024f..380f6507 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -14,6 +14,7 @@ import { } from '@/entities' import type { Paged } from '@/shared/pagination' import { AssetThumbnailImage, Pagination, PixelMatrix } from '@/shared/ui' +import './projects.css' const PROJECT_PAGE_SIZE = 12 diff --git a/frontend/src/pages/projects/projects.css b/frontend/src/pages/projects/projects.css new file mode 100644 index 00000000..1415c505 --- /dev/null +++ b/frontend/src/pages/projects/projects.css @@ -0,0 +1,113 @@ +.project-preview-loading, +.project-preview-message { + position: relative; + display: grid; + height: 100%; + overflow: hidden; + place-items: center; + background: linear-gradient( + 145deg, + var(--color-app-surface-muted), + var(--color-app-surface-strong) + ); +} + +.project-preview-message-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgb(143 160 146 / 18%) 1px, transparent 1px), + linear-gradient(to bottom, rgb(143 160 146 / 18%) 1px, transparent 1px); + background-position: center; + background-size: 24px 24px; + mask-image: radial-gradient(circle at center, #000 20%, transparent 76%); +} + +.project-preview-image { + opacity: 0.35; + filter: blur(12px); + transition: + opacity 320ms cubic-bezier(0.16, 1, 0.3, 1), + filter 420ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.project-preview-image-ready { + opacity: 1; + filter: blur(0); +} + +.project-preview-message span { + position: relative; + border: 1px solid rgb(143 160 146 / 24%); + background: rgb(247 248 244 / 78%); + padding: 0.3rem 0.55rem; + color: var(--color-app-faint); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.625rem; + letter-spacing: 0.06em; + backdrop-filter: blur(6px); +} + +.project-preview-message-error { + background: + radial-gradient(circle at 28% 16%, rgb(255 248 243 / 74%), transparent 38%), + linear-gradient(145deg, var(--color-app-surface-muted), rgb(214 193 182 / 40%)); +} + +.project-preview-message-error span { + border-color: var(--color-app-danger-line); + color: var(--color-app-danger-muted); +} + +@keyframes projects-dialog-backdrop { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes projects-dialog-panel { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.projects-dialog-backdrop { + animation: projects-dialog-backdrop 180ms ease-out both; +} + +.projects-dialog-panel { + animation: projects-dialog-panel 260ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@media (prefers-color-scheme: dark) { + .project-preview-message span { + background: rgb(36 36 36 / 88%); + } + + .project-preview-message-error { + background: linear-gradient( + 145deg, + var(--color-app-surface-muted), + var(--color-app-danger-soft) + ); + } +} + +@media (prefers-reduced-motion: reduce) { + .projects-dialog-backdrop, + .projects-dialog-panel { + animation: none !important; + } + + .project-preview-image { + transition: none; + } +} diff --git a/frontend/src/shared/ui/product-control.css b/frontend/src/shared/ui/product-control.css new file mode 100644 index 00000000..c89d765c --- /dev/null +++ b/frontend/src/shared/ui/product-control.css @@ -0,0 +1,63 @@ +@keyframes product-popover-in { + 0% { + opacity: 0; + transform: translate3d(0, var(--product-popover-shift-y, -8px), 0) scale(0.82); + } + 56% { + opacity: 1; + transform: translate3d(0, calc(var(--product-popover-shift-y, -8px) * -0.1875), 0) scale(1.025); + } + 78% { + transform: translate3d(0, calc(var(--product-popover-shift-y, -8px) * 0.0625), 0) scale(0.996); + } + 100% { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } +} + +.product-popover-in, +.product-popover-out { + backface-visibility: hidden; + will-change: opacity, transform; +} + +.product-popover-in { + animation: product-popover-in 220ms cubic-bezier(0.22, 0.8, 0.25, 1) both; +} + +@keyframes product-popover-out { + 0% { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } + 100% { + opacity: 0; + transform: translate3d(0, var(--product-popover-shift-y, -8px), 0) scale(0.82); + } +} + +.product-popover-out { + animation: product-popover-out 110ms cubic-bezier(0.55, 0, 1, 0.45) both; +} + +.product-popover-closed { + pointer-events: none; + visibility: hidden; + opacity: 0; + transform: translate3d(0, var(--product-popover-shift-y, -8px), 0) scale(0.82); +} + +[data-popover-placement='top'] { + --product-popover-shift-y: 8px; +} + +@media (prefers-reduced-motion: reduce) { + .product-popover-in { + animation: none; + } + + .product-popover-out { + animation: product-popover-out 0.01ms both; + } +} diff --git a/frontend/src/shared/ui/product-control.ts b/frontend/src/shared/ui/product-control.ts index 86447a75..82792a06 100644 --- a/frontend/src/shared/ui/product-control.ts +++ b/frontend/src/shared/ui/product-control.ts @@ -1,3 +1,5 @@ +import './product-control.css' + export type ProductControlVariant = 'accent' | 'chrome' | 'primary' | 'secondary' const productControlBaseClass = diff --git a/frontend/src/system-theme.css b/frontend/src/system-theme.css index c90c327c..c28acad5 100644 --- a/frontend/src/system-theme.css +++ b/frontend/src/system-theme.css @@ -53,16 +53,4 @@ --shadow-app-float: 0 16px 40px rgb(0 0 0 / 24%); --shadow-app-pulse: 0 0 0 16px rgb(215 215 215 / 5%); } - - .project-preview-message span { - background: rgb(36 36 36 / 88%); - } - - .project-preview-message-error { - background: linear-gradient( - 145deg, - var(--color-app-surface-muted), - var(--color-app-danger-soft) - ); - } }