apptify 0.8.0 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/apptify.css +1 -1
  2. package/dist/apptify.js +1503 -1721
  3. package/dist/components/button.d.ts +1 -1
  4. package/dist/components/foundation.d.ts +2 -4
  5. package/dist/components/link.d.ts +1 -1
  6. package/dist/core/image-gallery-motion.d.ts +1 -1
  7. package/dist/core/layers.d.ts +0 -1
  8. package/dist/index.d.ts +2 -46
  9. package/docs/COMPONENTS.md +7 -4
  10. package/package.json +1 -1
  11. package/templates/playground/README.md +1 -1
  12. package/templates/playground/package.json +1 -1
  13. package/templates/playground/source-snapshot.json +80 -40
  14. package/templates/playground/src/ComponentDemo.vue +2 -5
  15. package/templates/playground/src/catalog.ts +0 -1
  16. package/templates/playground/src/components/ErrorPagePreviews.vue +86 -0
  17. package/templates/playground/src/components/bad-request-paper-scene.vue +280 -0
  18. package/templates/playground/src/components/bad-request-paper-story.ts +169 -0
  19. package/templates/playground/src/components/gateway-paper-scene.vue +336 -0
  20. package/templates/playground/src/components/gateway-paper-story.ts +197 -0
  21. package/templates/playground/src/components/hamster-429-scene.vue +1045 -0
  22. package/templates/playground/src/components/server-fire-paper-scene.vue +249 -0
  23. package/templates/playground/src/components/server-fire-paper-story.ts +272 -0
  24. package/templates/playground/src/components/unavailable-paper-scene.vue +488 -0
  25. package/templates/playground/src/editor/component-metadata.ts +0 -1
  26. package/templates/playground/src/editor/handbooks/apple-dialog.ts +1 -1
  27. package/templates/playground/src/editor/playground-examples.ts +0 -1
  28. package/templates/playground/src/error-pages.ts +9 -0
  29. package/templates/playground/src/style.css +0 -2
  30. package/templates/playground/src/views/400.vue +74 -0
  31. package/templates/playground/src/views/403.vue +5 -2
  32. package/templates/playground/src/views/429.vue +74 -0
  33. package/templates/playground/src/views/500.vue +67 -0
  34. package/templates/playground/src/views/502.vue +68 -0
  35. package/templates/playground/src/views/503.vue +79 -0
  36. package/templates/playground/src/views/[...all].vue +5 -2
  37. package/templates/playground/src/views/components.vue +8 -8
  38. package/tooling/prepare-playground.mjs +2 -2
  39. package/dist/components/scrollbar.d.ts +0 -88
  40. package/dist/core/scrollbar.d.ts +0 -14
  41. package/templates/playground/src/editor/handbooks/apple-scroll-bar.ts +0 -15
@@ -0,0 +1,86 @@
1
+ <template>
2
+ <section ref="section" aria-labelledby="error-pages-heading">
3
+ <header class="feed-group-heading"><h2 id="error-pages-heading">错误页面</h2></header>
4
+ <div class="error-previews__grid">
5
+ <apple-card v-for="(page, code) in errorPages" :key="code" class="error-preview">
6
+ <template #title>
7
+ <h3>
8
+ <router-link :to="`/${code}`" class="error-preview__link">
9
+ {{ code }} · {{ page.title }}
10
+ <ArrowUpRight :size="16" aria-hidden="true" />
11
+ </router-link>
12
+ </h3>
13
+ </template>
14
+ <p class="error-preview__copy">{{ page.copy[0] }}<br />{{ page.copy[1] }}</p>
15
+ <div class="error-preview__scene" :data-code="code">
16
+ <component v-if="visibleScenes.has(String(code))" :is="scenes[code]" v-bind="sceneProps(code)" />
17
+ </div>
18
+ </apple-card>
19
+ </div>
20
+ </section>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from 'vue'
25
+ import { ArrowUpRight } from 'lucide-vue-next'
26
+ import { useApple } from 'apptify'
27
+ import { resolveMotion } from 'apptify'
28
+ import { errorPages } from '../error-pages'
29
+
30
+ const scenes = {
31
+ 400: defineAsyncComponent(() => import('./bad-request-paper-scene.vue')),
32
+ 403: defineAsyncComponent(() => import('./forbidden-paper-scene.vue')),
33
+ 404: defineAsyncComponent(() => import('./not-found-paper-scene.vue')),
34
+ 429: defineAsyncComponent(() => import('./hamster-429-scene.vue')),
35
+ 500: defineAsyncComponent(() => import('./server-fire-paper-scene.vue')),
36
+ 502: defineAsyncComponent(() => import('./gateway-paper-scene.vue')),
37
+ 503: defineAsyncComponent(() => import('./unavailable-paper-scene.vue')),
38
+ }
39
+
40
+ const apple = useApple()
41
+ const preferences = computed(() => ({
42
+ theme: apple.theme.value.current.scheme,
43
+ reducedMotion: resolveMotion('inherit', apple.motion.value.mode, apple.motion.value.reduced) !== 'full',
44
+ }))
45
+ function sceneProps(code: keyof typeof errorPages) {
46
+ return code === 400 || code === 429 || code === 503 ? preferences.value : {}
47
+ }
48
+
49
+ const section = ref<HTMLElement>()
50
+ const visibleScenes = ref(new Set<string>())
51
+ let observer: IntersectionObserver | undefined
52
+ onMounted(() => {
53
+ if (typeof IntersectionObserver === 'undefined') {
54
+ visibleScenes.value = new Set(Object.keys(errorPages))
55
+ return
56
+ }
57
+ observer = new IntersectionObserver(entries => {
58
+ for (const { target, isIntersecting } of entries) {
59
+ if (!isIntersecting) continue
60
+ visibleScenes.value.add(target.getAttribute('data-code')!)
61
+ observer?.unobserve(target)
62
+ }
63
+ }, { rootMargin: '300px 0px' })
64
+ section.value?.querySelectorAll('[data-code]').forEach(scene => observer?.observe(scene))
65
+ })
66
+ onBeforeUnmount(() => observer?.disconnect())
67
+ </script>
68
+
69
+ <style scoped>
70
+ .error-previews__grid {
71
+ display: grid;
72
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
73
+ gap: 20px;
74
+ }
75
+ .error-preview h3 { width: 100%; margin: 0; font-size: 16px; font-weight: 600; }
76
+ .error-preview__link { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: inherit; text-decoration: none; }
77
+ .error-preview__link svg { flex-shrink: 0; color: var(--apple-secondary); }
78
+ .error-preview__link:hover { color: var(--apple-accent); }
79
+ .error-preview__link::after { position: absolute; inset: 0; content: ''; }
80
+ .error-preview__link:focus-visible::after { outline: 2px solid var(--apple-accent); outline-offset: -4px; border-radius: 18px; }
81
+ .error-preview__copy { margin: 8px 0 12px; color: var(--apple-secondary); font-size: 13px; line-height: 1.7; }
82
+ .error-preview__scene { display: grid; padding-bottom: 18px; }
83
+ .error-preview__scene::before { grid-area: 1 / 1; aspect-ratio: 3 / 2; content: ''; }
84
+ .error-preview__scene :deep(figure) { grid-area: 1 / 1; align-self: start; position: relative; z-index: 1; pointer-events: none; }
85
+ .error-preview__scene :deep(button) { pointer-events: auto; }
86
+ </style>
@@ -0,0 +1,280 @@
1
+ <template>
2
+ <figure class="bad-request-scene" :data-theme="theme">
3
+ <div class="bad-request-scene__window">
4
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 720" role="img" :aria-labelledby="`${uid}-title ${uid}-desc`" class="bad-request-scene__art" ref="art">
5
+ <title :id="`${uid}-title`">一个不合规格的包裹,与一只困惑的猫</title>
6
+ <desc :id="`${uid}-desc`">镜头近距离跟随一个歪歪扭扭的红色包裹。包裹随传送带停下后,镜头只拉远一次,露出普通纸箱和一只头顶问号的猫。猫一直看着包裹,不时歪头、眨眼、抬爪挠头。</desc>
7
+ <defs>
8
+ <linearGradient :id="`${uid}-wall`" x1="0" y1="0" x2="0.2" y2="1"><stop stop-color="var(--br-wall-top)"/><stop offset="1" stop-color="var(--br-wall)"/></linearGradient>
9
+ <linearGradient :id="`${uid}-glass`" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#e9e5c8"/><stop offset="1" stop-color="#bbc9ac"/></linearGradient>
10
+ <filter :id="`${uid}-soft`" x="-30%" y="-30%" width="170%" height="180%"><feDropShadow dx="1" dy="3" stdDeviation="1.5" flood-color="#514633" flood-opacity=".16"/></filter>
11
+ <pattern :id="`${uid}-grain`" width="53" height="47" patternUnits="userSpaceOnUse"><path d="m4 8 1-.4m27 11 .8-.3m13 15 1-.3m-25 5 .6-.3" stroke="#685940" stroke-opacity=".12" stroke-width=".8"/><path d="m13 17 1.8-.6m22-9 1-.4m-14 29 1.4-.5" stroke="#fff9e9" stroke-opacity=".3" stroke-width=".8"/></pattern>
12
+ <clipPath :id="`${uid}-frame`"><path d="M38 80Q38 40 80 36L1111 22Q1155 22 1162 71L1183 631Q1185 670 1139 682L87 704Q35 702 34 657Z"/></clipPath>
13
+ <clipPath :id="`${uid}-window`"><path d="m139 144 204-7 2 153-206 7Z"/></clipPath>
14
+ <clipPath :id="`${uid}-belt`"><path d="M-1000 405H687a24 24 0 0 1 0 48H-1000Z"/></clipPath>
15
+ <g :id="`${uid}-box`">
16
+ <path d="M-44-69H25V0H-44Z" fill="#c6a06f"/>
17
+ <path d="m25-69 21-16v69L25 0Z" fill="#a78056"/>
18
+ <path d="m-44-69 21-16h69L25-69Z" fill="#e1bf8b"/>
19
+ <path d="M-16-69H-2V0H-16Z" fill="#e8d5ae"/>
20
+ <path d="m-16-69 21-16h14L-2-69Z" fill="#f1dfb9"/>
21
+ <path d="m-42-67 65 0m4 0 16-13" stroke="#f1d3a1" stroke-width="1.2" fill="none"/>
22
+ <path d="M5-49h15v22H5Z" fill="#f5e9cd"/>
23
+ <path d="M8-43h8m-8 4h6m-6 6h1m2 0h1m2 0h2" fill="none" stroke="#948776" stroke-width="1.2"/>
24
+ <path d="m-35-11 0-10m-3 3 3-3 3 3m6 7v-10m-3 3 3-3 3 3" fill="none" stroke="#8b6d4f" stroke-width="1.5" stroke-linejoin="round"/>
25
+ <path d="m-43-1 67 0m3-1 17-14" fill="none" stroke="#8f704c" stroke-opacity=".5" stroke-width="1"/>
26
+ </g>
27
+ <g :id="`${uid}-leaf`"><path d="M0 0C-21-5-31-22-22-43-1-37 9-17 0 0Z" fill="currentColor"/><path d="M0 0-18-34" fill="none" stroke="#d4d9b8" stroke-width="1.2" stroke-opacity=".6"/></g>
28
+ </defs>
29
+ <path d="M47 92Q46 49 88 47L1120 33Q1167 35 1175 84L1194 642Q1196 686 1146 694L94 715Q44 713 44 665Z" fill="var(--br-shadow)"/>
30
+ <g :clip-path="`url(#${uid}-frame)`">
31
+ <path d="M0 0H1200V720H0Z" :fill="`url(#${uid}-wall)`"/>
32
+ <g data-part="camera">
33
+ <path d="M-1800-1000H2800V1400H-1800Z" :fill="`url(#${uid}-wall)`"/>
34
+ <path d="M-1800 536 2800 496V1500H-1800Z" fill="var(--br-floor)"/>
35
+ <path d="M-1800 549 2800 509" stroke="var(--br-baseboard)" stroke-width="8"/>
36
+ <path d="M-100 612 1330 592m-1240 67 1260-13M219 534 75 800m454-269-22 269m273-272 134 272m100-274 269 268" fill="none" stroke="var(--br-floor-line)" stroke-width="1.5" opacity=".45"/>
37
+ <path d="m24 58 1120-19 12 39L21 100Z" fill="var(--br-timber)"/>
38
+ <path d="m24 58 1120-19 5 11L23 72Z" fill="var(--br-timber-light)"/>
39
+ <path d="m1093 78 22-1 14 431-22 2Z" fill="var(--br-timber)"/>
40
+ <path d="m1093 78 7 0 14 432-7 1Z" fill="var(--br-timber-light)"/>
41
+ <path d="m1101 103-107 95 8 10 105-88Z" fill="var(--br-timber)"/>
42
+ <g :filter="`url(#${uid}-soft)`">
43
+ <path d="m122 128 235-9 4 190-238 9Z" fill="#b6a58b"/>
44
+ <path d="m130 136 220-8 3 172-222 9Z" fill="#879382"/>
45
+ <g :clip-path="`url(#${uid}-window)`">
46
+ <path d="M130 128H360V317H130Z" :fill="`url(#${uid}-glass)`"/>
47
+ <circle cx="294" cy="177" r="24" fill="#f7edca"/>
48
+ <path d="M118 252 178 211 223 242 282 204 366 255V315H118Z" fill="#bdc6a6"/>
49
+ <path d="m178 211 45 31 59-38-26 47-65 4Z" fill="#d0d3b1"/>
50
+ <path d="M121 282 177 255 236 272 311 246 366 275V320H121Z" fill="#9fb394"/>
51
+ <path d="M125 305Q222 273 358 292V326H125Z" fill="#b9c399"/>
52
+ <path d="m164 300 3-49m0 25-16-17m16 3 13-17" fill="none" stroke="#809575" stroke-width="4"/>
53
+ <path d="m141 257 4-21 19-13 20 7 12 18-9 20-23 3Z" fill="#8fa47f"/>
54
+ <path d="m145 236 19-13 20 7 7 11-24 11-26 5Z" fill="#afbb91"/>
55
+ </g>
56
+ <path d="m243 141 1 153m-104-84 203-7" fill="none" stroke="#dfcfad" stroke-width="8"/>
57
+ <path d="m245 141 1 153m-105-83 202-7" fill="none" stroke="#9e9f85" stroke-width="1.5"/>
58
+ <path d="m116 310 248-9 9 12-257 10Z" fill="#d5bd97"/>
59
+ <path d="m116 323 257-10-1 7-257 10Z" fill="#ad9270"/>
60
+ </g>
61
+ <path d="m141 327 227-8 100 207-226 5Z" fill="#fff0c8" fill-opacity=".13"/>
62
+ <g :filter="`url(#${uid}-soft)`">
63
+ <path d="m447 199 233-5 15 12-247 7Z" fill="#c5ad89"/>
64
+ <path d="m448 213 247-7-1 9-246 7Z" fill="#a58c6b"/>
65
+ <path d="m468 222 9-1 0 22-9-6m190-20 9 0 0 22-9-6" fill="#af9879"/>
66
+ <use :href="`#${uid}-box`" transform="translate(499 199) scale(.68)"/>
67
+ <use :href="`#${uid}-box`" transform="translate(576 198) scale(.68)"/>
68
+ <use :href="`#${uid}-box`" transform="translate(650 196) scale(.68)"/>
69
+ </g>
70
+ <g transform="translate(1019 174) rotate(4)" :filter="`url(#${uid}-soft)`">
71
+ <path d="M-33-44 35-43 36 46-34 48Z" fill="#b49c79"/>
72
+ <path d="M-27-37 28-36 29 39-28 41Z" fill="#ede1c4"/>
73
+ <path d="M-12-46h27v11h-27Z" fill="#81917e"/>
74
+ <path d="M-14-20h30m-30 13h24m-24 13h30m-30 13h19" fill="none" stroke="#bab397" stroke-width="2"/>
75
+ <path d="m-21-22 3 4 5-7m-8 17 3 4 5-7m-8 17 3 4 5-7" fill="none" stroke="#97a17f" stroke-width="1.6"/>
76
+ </g>
77
+ <g transform="translate(1130 510)" :filter="`url(#${uid}-soft)`">
78
+ <path d="M0 0-8-78m6 45-23-28m22 17 19-33" fill="none" stroke="#7e8d6c" stroke-width="3"/>
79
+ <g color="#8fa178"><use :href="`#${uid}-leaf`" transform="translate(-6 -41) rotate(-36) scale(.8)"/><use :href="`#${uid}-leaf`" transform="translate(-9 -69) rotate(15) scale(.8)"/></g>
80
+ <g color="#a9b18a"><use :href="`#${uid}-leaf`" transform="translate(-2 -29) rotate(48) scale(.75)"/><use :href="`#${uid}-leaf`" transform="translate(6 -56) rotate(76) scale(.7)"/></g>
81
+ <path d="M-28-3 23-5 17 38-20 40Z" fill="#b58c6d"/>
82
+ <path d="M-28-3-18-4-11 39-20 40Z" fill="#cfab87"/>
83
+ <path d="M-31-10 26-12 25 0-30 2Z" fill="#d0ad88"/>
84
+ </g>
85
+ <!-- The belt and every parcel travel the same distance, then stop together. -->
86
+ <path d="m76 603 600-5 47 16-679 3Z" fill="#596e59" fill-opacity=".13"/>
87
+ <path d="M137 450h25L148 604H123Zm455 0h24l26 152h-26Z" fill="#748975"/>
88
+ <path d="M137 450h8l-13 154h-9Zm455 0h8l26 152h-10Z" fill="#a1ad8f"/>
89
+ <path d="M153 518h457v13H152Z" fill="#a7b197"/>
90
+ <path d="m159 466 452 128m-8-128L145 593" fill="none" stroke="#94a58a" stroke-width="7"/>
91
+ <path d="M115 603h40v8h-40Zm495-2h41v8h-41Z" fill="#607460"/>
92
+ <path d="M-1000 405H687a25 25 0 0 1 0 50H-1000Z" fill="#485f57"/>
93
+ <g :clip-path="`url(#${uid}-belt)`"><g data-part="belt-ribs" fill="none" stroke="#8e9e88" stroke-width="2.1"><path d="M-1024 408v6m0 30v6"/><path d="M-1000 408v6m0 30v6"/><path d="M-976 408v6m0 30v6"/><path d="M-952 408v6m0 30v6"/><path d="M-928 408v6m0 30v6"/><path d="M-904 408v6m0 30v6"/><path d="M-880 408v6m0 30v6"/><path d="M-856 408v6m0 30v6"/><path d="M-832 408v6m0 30v6"/><path d="M-808 408v6m0 30v6"/><path d="M-784 408v6m0 30v6"/><path d="M-760 408v6m0 30v6"/><path d="M-736 408v6m0 30v6"/><path d="M-712 408v6m0 30v6"/><path d="M-688 408v6m0 30v6"/><path d="M-664 408v6m0 30v6"/><path d="M-640 408v6m0 30v6"/><path d="M-616 408v6m0 30v6"/><path d="M-592 408v6m0 30v6"/><path d="M-568 408v6m0 30v6"/><path d="M-544 408v6m0 30v6"/><path d="M-520 408v6m0 30v6"/><path d="M-496 408v6m0 30v6"/><path d="M-472 408v6m0 30v6"/><path d="M-448 408v6m0 30v6"/><path d="M-424 408v6m0 30v6"/><path d="M-400 408v6m0 30v6"/><path d="M-376 408v6m0 30v6"/><path d="M-352 408v6m0 30v6"/><path d="M-328 408v6m0 30v6"/><path d="M-304 408v6m0 30v6"/><path d="M-280 408v6m0 30v6"/><path d="M-256 408v6m0 30v6"/><path d="M-232 408v6m0 30v6"/><path d="M-208 408v6m0 30v6"/><path d="M-184 408v6m0 30v6"/><path d="M-160 408v6m0 30v6"/><path d="M-136 408v6m0 30v6"/><path d="M-112 408v6m0 30v6"/><path d="M-88 408v6m0 30v6"/><path d="M-64 408v6m0 30v6"/><path d="M-40 408v6m0 30v6"/><path d="M-16 408v6m0 30v6"/><path d="M8 408v6m0 30v6"/><path d="M32 408v6m0 30v6"/><path d="M56 408v6m0 30v6"/><path d="M80 408v6m0 30v6"/><path d="M104 408v6m0 30v6"/><path d="M128 408v6m0 30v6"/><path d="M152 408v6m0 30v6"/><path d="M176 408v6m0 30v6"/><path d="M200 408v6m0 30v6"/><path d="M224 408v6m0 30v6"/><path d="M248 408v6m0 30v6"/><path d="M272 408v6m0 30v6"/><path d="M296 408v6m0 30v6"/><path d="M320 408v6m0 30v6"/><path d="M344 408v6m0 30v6"/><path d="M368 408v6m0 30v6"/><path d="M392 408v6m0 30v6"/><path d="M416 408v6m0 30v6"/><path d="M440 408v6m0 30v6"/><path d="M464 408v6m0 30v6"/><path d="M488 408v6m0 30v6"/><path d="M512 408v6m0 30v6"/><path d="M536 408v6m0 30v6"/><path d="M560 408v6m0 30v6"/><path d="M584 408v6m0 30v6"/><path d="M608 408v6m0 30v6"/><path d="M632 408v6m0 30v6"/><path d="M656 408v6m0 30v6"/><path d="M680 408v6m0 30v6"/><path d="M704 408v6m0 30v6"/><path d="M728 408v6m0 30v6"/></g></g>
94
+ <path d="M-1000 455H688l21-13 2 25-23 16H-1000Z" fill="#849b81"/>
95
+ <path d="M-1000 455H688l21-13-8 15-15 9H-1000Z" fill="#bac4a6"/>
96
+ <path d="M-1000 480H686" stroke="#577361" stroke-width="3"/>
97
+ <g fill="#d1d5b9"><circle cx="85" cy="469" r="3"/><circle cx="288" cy="469" r="3"/><circle cx="490" cy="469" r="3"/><circle cx="657" cy="469" r="3"/></g>
98
+ <g transform="translate(686 430)"><circle r="21" fill="#b4bca1"/><circle r="14" fill="#7e917c"/><g data-part="roller"><path d="M-10 0H10M0-10V10" stroke="#d4d8bc" stroke-width="3"/><circle r="4" fill="#586f5d"/></g></g>
99
+ <g data-part="normal-parcels" transform="translate(522 0)" :filter="`url(#${uid}-soft)`">
100
+ <use :href="`#${uid}-box`" transform="translate(-122 405) scale(1.04)"/>
101
+ <use :href="`#${uid}-box`" transform="translate(-295 405) scale(1.04)"/>
102
+ <use :href="`#${uid}-box`" transform="translate(-465 405) scale(1.04)"/>
103
+ </g>
104
+ <!-- One continuous silhouette: deliberately unlike all the regular cartons. -->
105
+ <g data-part="parcel" transform="translate(592 405)">
106
+ <ellipse cy="1" rx="82" ry="5" fill="#485f57" fill-opacity=".27"/>
107
+ <g data-part="parcel-pitch" :filter="`url(#${uid}-soft)`">
108
+ <path d="m-69-111 50-31 68 8 36 31-10 57 8 31L22 1-56-5-86-54Z" fill="#be695b"/>
109
+ <path d="m-69-111 50-31 68 8 36 31-73 24Z" fill="#e7a18a"/>
110
+ <path d="m-69-111 81 32 10 80-78-6-30-49Z" fill="#d98770"/>
111
+ <path d="m12-79 73-24-10 57 8 31L22 1Z" fill="#a65750"/>
112
+ <path d="m-69-111 18-24 29 27-8 20Z" fill="#efb293"/>
113
+ <path d="m-19-142 27-24 39 42-35 45-18-38Z" fill="#d58a79"/>
114
+ <path d="m8-166 39 42-17-7-16-25Z" fill="#efb39a"/>
115
+ <path d="m12-79 35-45 18-2 20 23Z" fill="#e8aa90"/>
116
+ <path d="m-76-91 25-7 72 78-6 17-18-9Z" fill="#8d9b8b"/>
117
+ <path d="m-76-91 25-7 6 7-27 8Z" fill="#b8c6ad"/>
118
+ <path d="m-25-135 18-6 14 46 54 67-15 7-57-69Z" fill="#b6bfa0"/>
119
+ <path d="m7-95 54 67-15 7-24-36Z" fill="#8b9e89"/>
120
+ <path d="m60-27 27-8 14 17-25 5-5-8-16 3Z" fill="#a1b095"/>
121
+ <path d="m-83-55 18-7-1 14-18 7Z" fill="#b97060"/>
122
+ <path d="m-40-65 29-6 12 17-8 26-29-2Z" fill="#eeccae"/>
123
+ <path d="m-40-65 29-6 5 9-13 0-4 12-11 0Z" fill="#f7e2c2"/>
124
+ <g transform="translate(38 -64) rotate(21)">
125
+ <path d="M-19-17 20-17 20 17-18 18-24 8Z" fill="#f4e1bd"/>
126
+ <path d="m-17-12 34 0m-32 6h27" fill="none" stroke="#b29b7a" stroke-width="1.3"/>
127
+ <path d="M-15 5v8m4-8v8m3-8v8m5-8v8m3-8v8m5-8v8m3-8v8" stroke="#8e7962" stroke-width="1.4"/>
128
+ </g>
129
+ <path d="m-55-90 18 8m-35 29 13 19 17 4m71-83 14-16m10 75 13-5" fill="none" stroke="#f2bda0" stroke-width="1.5" stroke-opacity=".65"/>
130
+ <path d="m-45-19 8-7 7 9 7-5m-3-101 9 16m43 83 15-8" fill="none" stroke="#9a584e" stroke-width="1.4" stroke-linecap="round"/>
131
+ </g>
132
+ </g>
133
+ <g transform="translate(932 592)" data-part="cat-root">
134
+ <path d="M-130 10Q-109-9-13-10 83-14 133 6L148 21Q99 41-4 42-106 45-143 25Z" fill="#9aa585"/>
135
+ <path d="M-126 14Q-42-5 113 11M-129 23Q-26 7 128 20" fill="none" stroke="#c6c5a2" stroke-width="2" opacity=".65"/>
136
+ <ellipse cx="0" cy="12" rx="95" ry="14" fill="#514c37" fill-opacity=".16"/>
137
+ <g data-part="tail">
138
+ <path d="M59-17C105-4 157-11 160-49 165-77 143-97 127-84" fill="none" stroke="#ba875d" stroke-width="27" stroke-linecap="round"/>
139
+ <path d="M65-24C105-12 146-17 149-49 153-67 141-79 132-75" fill="none" stroke="#d9ad7b" stroke-width="10" stroke-linecap="round"/>
140
+ <path d="m124-13-4-15m32-8-18-4m19-23-16 7" fill="none" stroke="#a06e4e" stroke-width="6" stroke-opacity=".6"/>
141
+ </g>
142
+ <g data-part="body">
143
+ <path d="M-48-188Q-78-171-79-128L-85-72Q-92-20-60 4 6 24 64 2 87-15 81-65L76-127Q73-169 45-188Z" fill="#d5a273"/>
144
+ <path d="M-48-188Q-78-171-79-128L-85-72Q-92-20-60 4L-36 10Q-70-23-57-87L-49-154Z" fill="#bc875e"/>
145
+ <path d="M-18-175Q10-183 31-161L43-107Q56-47 31-19-3-4-33-21-54-42-43-96L-36-151Z" fill="#ead6b2"/>
146
+ <path d="m-54-180 25 10-15 54 6 85-27 25-13-17-6-48 4-65Z" fill="#8c9e83"/>
147
+ <path d="m34-176 22 4 20 42 4 60-6 49-22 18-18-28 8-66Z" fill="#728b74"/>
148
+ <path d="m-42-119 84 1 17 81-18 23-64-2-23-24Z" fill="#8fa184"/>
149
+ <path d="m-40-113 80 1m-69 83 56 1" fill="none" stroke="#c4ccb0" stroke-width="1.4" stroke-opacity=".7"/>
150
+ <path d="m-22-85 44 1-2 29-18 8-21-7Z" fill="#7e9277"/>
151
+ <path d="m-19-82 38 1m-30 29 11 4 14-7" fill="none" stroke="#b5c1a0" stroke-width="1.1"/>
152
+ <path d="m-52-175 9 7-13 44m96-46 9 8 10 37" fill="none" stroke="#bbc7a6" stroke-width="3"/>
153
+ <circle cx="-51" cy="-127" r="3" fill="#e1d9b1"/><circle cx="51" cy="-126" r="3" fill="#e1d9b1"/>
154
+ <path d="M-64-145Q-84-130-86-100L-88-76Q-84-62-72-69L-55-99-47-131Z" fill="#d9ac7d"/>
155
+ <path d="m-79-90 12 4m-15 5 11 5" fill="none" stroke="#ab7956" stroke-width="1.5" stroke-linecap="round"/>
156
+ </g>
157
+ <path d="M-62-17Q-88-10-90 2-91 12-74 16L-37 15Q-21 12-25 0L-34-14Z" fill="#d8aa7b"/>
158
+ <path d="M35-15Q18-2 25 10L43 17 79 16Q98 13 94 4 85-9 66-15Z" fill="#d2a172"/>
159
+ <path d="m-75 7 0 5m13-6 0 6m13-5 0 5m50-4 0 5m13-6 0 6m13-5 0 5" fill="none" stroke="#a77652" stroke-width="1.6" stroke-linecap="round"/>
160
+ <g data-part="head" :filter="`url(#${uid}-soft)`">
161
+ <path d="M-77-266-87-333Q-87-346-77-340L-30-301 36-301 78-341Q87-348 88-333L83-263Z" fill="#ce9b6e"/>
162
+ <path d="m-77-328 34 31-27 19Z" fill="#d7a394"/>
163
+ <path d="m79-328-31 31 27 19Z" fill="#d2a092"/>
164
+ <path d="m-76-328 24 24-17 12Z" fill="#eac3ab"/>
165
+ <path d="m79-328-22 24 15 12Z" fill="#e4b99f"/>
166
+ <path d="M-71-288Q-48-306-6-304 40-309 70-287L88-263Q103-236 91-211L97-201 84-198 91-189 76-188Q45-157 3-160-44-160-73-186L-88-187-82-198-95-201-88-211Q-105-240-88-264Z" fill="#dbac7c"/>
167
+ <path d="M-89-245Q-98-224-87-209L-95-201-82-198-88-187-73-186Q-44-160 3-160L-11-166Q-64-174-76-213Z" fill="#d4a273"/>
168
+ <path d="M-87-224Q-58-219-38-226-13-231 2-221 16-232 44-224 62-214 86-226L91-204 84-196 89-188 74-189Q47-158 3-160-46-160-76-188L-87-188-82-198-95-201Z" fill="#ead0aa"/>
169
+ <path d="M-86-263Q-66-287-40-288-63-266-68-237L-80-226-91-228Z" fill="#e7bc8e"/>
170
+ <path d="m-25-300 12 0-1 26-7-4Zm21-3 13 1-5 31-6-4Zm24 1 11 5-8 24-6-7Z" fill="#b4825a"/>
171
+ <path d="m-91-242 19 5-1 8-22-4Zm2 19 17 4-1 7-17-4Zm167-20 18-7 2 9-19 6Zm1 16 18-6-2 9-16 4Z" fill="#bc885d"/>
172
+ <path d="m-56-263q13-9 27-5m46-2q12-5 26 3" fill="none" stroke="#936c4f" stroke-width="3.2" stroke-linecap="round"/>
173
+ <g transform="translate(-37 -241)"><g data-part="eye-left"><ellipse rx="13.5" ry="14.8" fill="#f2e1be"/><g data-part="pupil-left"><ellipse cx="-3.2" cy="2.6" rx="6.6" ry="10.3" fill="#464435"/><ellipse cx="-5" cy="-.5" rx="2" ry="2.5" fill="#fff6df"/></g></g></g>
174
+ <g transform="translate(34 -242)"><g data-part="eye-right"><ellipse rx="12.5" ry="14" fill="#f2e1be"/><g data-part="pupil-right"><ellipse cx="-3.4" cy="3" rx="6.4" ry="9.5" fill="#464435"/><ellipse cx="-5.2" cy="-.1" rx="1.9" ry="2.3" fill="#fff6df"/></g></g></g>
175
+ <path d="M-10-215Q-1-220 8-215L0-206Z" fill="#ab7b68"/>
176
+ <path d="M0-206v7m-7 5q7-3 14-1" fill="none" stroke="#8d7059" stroke-width="1.9" stroke-linecap="round"/>
177
+ <g fill="#ae9575"><circle cx="-27" cy="-210" r="1.3"/><circle cx="-19" cy="-206" r="1.1"/><circle cx="24" cy="-211" r="1.3"/><circle cx="17" cy="-207" r="1.1"/></g>
178
+ <path d="m-51-212-48-7m49 14-52 2m53 4-42 13m139-24 48-9m-48 15 52 0m-51 6 42 12" fill="none" stroke="#f6e6c8" stroke-width="1.7" stroke-linecap="round"/>
179
+ </g>
180
+ <!-- The entire arm is redrawn to its paw; it never detaches from the shoulder. -->
181
+ <path data-part="arm-outline" d="M60-153Q93-137 92-107T76-68" fill="none" stroke="#b9855c" stroke-width="26" stroke-linecap="round"/>
182
+ <path data-part="arm-fill" d="M60-153Q93-137 92-107T76-68" fill="none" stroke="#dcae7d" stroke-width="21" stroke-linecap="round"/>
183
+ <g data-part="paw" transform="translate(76 -68)">
184
+ <path d="M-13-10Q-15-19-7-20L1-18Q8-22 13-16L16-3Q17 12 4 15-10 16-14 7Z" fill="#dfb484"/>
185
+ <path d="m-9-9 0 8m7-11 0 8m7-9 0 7" fill="none" stroke="#ad805b" stroke-width="1.5" stroke-linecap="round"/>
186
+ <path d="M-8 7q8 5 16-1" fill="none" stroke="#f0cf9e" stroke-width="1.4"/>
187
+ </g>
188
+ <g data-part="scratch-marks" visibility="hidden" fill="none" stroke="#aa9476" stroke-width="2" stroke-linecap="round"><path d="m0 0 5-5m2 13 8-3"/></g>
189
+ </g>
190
+ <g transform="translate(940 182)">
191
+ <g data-part="question" :filter="`url(#${uid}-soft)`">
192
+ <path d="M-16-8Q-16-28 3-29 22-29 23-14 24-2 11 5Q3 10 3 20" fill="none" stroke="#89997b" stroke-width="10" stroke-linecap="round"/>
193
+ <path d="M-17-10Q-17-28 2-30 19-30 21-17" fill="none" stroke="#bcc5a0" stroke-width="2.4" stroke-linecap="round"/>
194
+ <circle cx="3" cy="36" r="5.7" fill="#89997b"/>
195
+ <circle cx="1.5" cy="34.8" r="1.5" fill="#c4ccaa"/>
196
+ </g>
197
+ </g>
198
+ <path d="m757 627 62 1m-550 16 48-1m-123-15 34 0m744 11 51-1" fill="none" stroke="var(--br-floor-line)" stroke-width="1.6" opacity=".45"/>
199
+ </g>
200
+ <path d="M0 0H1200V720H0Z" :fill="`url(#${uid}-grain)`" pointer-events="none"/>
201
+ </g>
202
+ <path d="M42 83 40 645Q40 682 85 681" fill="none" stroke="#fff8e0" stroke-width="2" stroke-opacity=".48"/>
203
+ </svg>
204
+
205
+ </div>
206
+ <div v-if="canAnimate" class="bad-request-scene__controls">
207
+ <button type="button" :aria-label="paused ? '继续动画' : '暂停动画'" :title="paused ? '继续动画' : '暂停动画'" @click="togglePause">
208
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path v-if="paused" d="m5 3 8 5-8 5Z"/><path v-else d="M5 3v10m6-10v10"/></svg>
209
+ </button>
210
+ <button type="button" aria-label="重播动画" title="重播动画" @click="replay">
211
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6a5.4 5.4 0 1 1-.1 4M3 2v4h4"/></svg>
212
+ </button>
213
+ </div>
214
+ </figure>
215
+ </template>
216
+
217
+ <script setup lang="ts">
218
+ import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, useId, watch } from 'vue'
219
+ import { createBadRequestStory, type BadRequestStory } from './bad-request-paper-story'
220
+
221
+ const props = withDefaults(defineProps<{ theme?: 'auto' | 'light' | 'dark'; reducedMotion?: boolean }>(), {
222
+ theme: 'auto', reducedMotion: false,
223
+ })
224
+ const uid = `bad-request-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`
225
+ const art = ref<SVGSVGElement>()
226
+ const paused = ref(false)
227
+ const systemReduced = ref(false)
228
+ const canAnimate = computed(() => !props.reducedMotion && !systemReduced.value)
229
+ let story: BadRequestStory | undefined
230
+ let media: MediaQueryList | undefined
231
+ const updateSystemMotion = () => { systemReduced.value = media?.matches ?? false }
232
+ function pause() { paused.value = true; story?.pause() }
233
+ function play() { paused.value = false; story?.play() }
234
+ function togglePause() { if (paused.value) play(); else pause() }
235
+ function replay() { story?.restart(); play() }
236
+ watch(canAnimate, allowed => story?.setReducedMotion(!allowed))
237
+ onMounted(() => {
238
+ media = window.matchMedia('(prefers-reduced-motion: reduce)')
239
+ updateSystemMotion()
240
+ media.addEventListener('change', updateSystemMotion)
241
+ if (art.value) story = createBadRequestStory(art.value, !canAnimate.value)
242
+ })
243
+ onActivated(() => story?.setActive(true))
244
+ onDeactivated(() => story?.setActive(false))
245
+ onBeforeUnmount(() => { media?.removeEventListener('change', updateSystemMotion); story?.dispose() })
246
+ defineExpose({ replay, pause, play })
247
+ </script>
248
+
249
+ <style scoped>
250
+ .bad-request-scene {
251
+ --br-wall-top:#f2e9d8; --br-wall:#e5dfc9; --br-floor:#c5c5a4;
252
+ --br-baseboard:#b4b697; --br-floor-line:#97a185; --br-shadow:#d5ccba;
253
+ --br-timber:#bba781; --br-timber-light:#d3bd97;
254
+ position:relative; min-width:0; margin:0;
255
+ }
256
+ .bad-request-scene[data-theme="dark"] {
257
+ --br-wall-top:#293b39; --br-wall:#3a4c43; --br-floor:#718062;
258
+ --br-baseboard:#5d7056; --br-floor-line:#a3ac87; --br-shadow:#172724;
259
+ --br-timber:#827b5e; --br-timber-light:#aaa382;
260
+ }
261
+ @media(prefers-color-scheme:dark) {
262
+ .bad-request-scene[data-theme="auto"] {
263
+ --br-wall-top:#293b39; --br-wall:#3a4c43; --br-floor:#718062;
264
+ --br-baseboard:#5d7056; --br-floor-line:#a3ac87; --br-shadow:#172724;
265
+ --br-timber:#827b5e; --br-timber-light:#aaa382;
266
+ }
267
+ }
268
+ .bad-request-scene__window { padding:4px; overflow:hidden; }
269
+ .bad-request-scene__art { display:block; width:100%; height:auto; overflow:visible; isolation:isolate; }
270
+ .bad-request-scene__controls { position:absolute; right:1.5%; bottom:-18px; display:flex; gap:3px; }
271
+ .bad-request-scene__controls button {
272
+ display:grid; place-items:center; width:30px; height:30px; padding:0;
273
+ border:0; border-radius:50%; background:transparent;
274
+ color:var(--apple-secondary,#787a68); cursor:pointer;
275
+ }
276
+ .bad-request-scene__controls button:hover { background:var(--apple-hover,rgba(108,117,92,.08)); }
277
+ .bad-request-scene__controls button:focus-visible { outline:2px solid var(--apple-accent,#72856f); outline-offset:2px; }
278
+ .bad-request-scene__controls svg { width:13px; height:13px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; }
279
+ @media(max-width:880px) { .bad-request-scene__window { margin-inline:-12px; } .bad-request-scene__controls { right:-1%; bottom:-16px; } }
280
+ </style>
@@ -0,0 +1,169 @@
1
+ /** Deterministic one-shot arrival and reveal, followed only by the cat's idle loop. */
2
+ export const BAD_REQUEST_TIMING = Object.freeze({ arrival: 3.8, reveal: 4.25, idle: 6.6, cycle: 6.2 })
3
+
4
+ type Point = { x: number; y: number }
5
+ export interface BadRequestFrame {
6
+ phase: 'follow' | 'stopped' | 'reveal' | 'confused'
7
+ distance: number
8
+ packetX: number
9
+ packetAngle: number
10
+ camera: { scale: number; x: number; y: number }
11
+ headAngle: number
12
+ paw: Point
13
+ elbow: Point
14
+ pawAngle: number
15
+ scratching: boolean
16
+ blink: number
17
+ tailAngle: number
18
+ questionY: number
19
+ questionAngle: number
20
+ }
21
+ export interface BadRequestStory {
22
+ play(): void
23
+ pause(): void
24
+ restart(): void
25
+ seek(seconds: number): void
26
+ setReducedMotion(reduced: boolean): void
27
+ setActive(active: boolean): void
28
+ dispose(): void
29
+ readonly time: number
30
+ }
31
+
32
+ const clamp = (v: number) => Math.max(0, Math.min(1, v))
33
+ const mix = (a: number, b: number, p: number) => a + (b - a) * p
34
+ const smooth = (v: number) => { const p = clamp(v); return p * p * (3 - 2 * p) }
35
+ const smoother = (v: number) => { const p = clamp(v); return p * p * p * (p * (p * 6 - 15) + 10) }
36
+ const pointMix = (a: Point, b: Point, p: number): Point => ({ x: mix(a.x, b.x, p), y: mix(a.y, b.y, p) })
37
+ function rotate(p: Point, angle: number, pivot: Point): Point {
38
+ const a = angle * Math.PI / 180, c = Math.cos(a), s = Math.sin(a)
39
+ return { x: pivot.x + (p.x - pivot.x) * c - (p.y - pivot.y) * s,
40
+ y: pivot.y + (p.x - pivot.x) * s + (p.y - pivot.y) * c }
41
+ }
42
+ function travelled(t: number): number {
43
+ const end = BAD_REQUEST_TIMING.arrival, acceleration = .4, braking = .65, brakeAt = end - braking
44
+ t = Math.max(0, Math.min(end, t))
45
+ const area = t < acceleration ? t * t / (2 * acceleration)
46
+ : t < brakeAt ? t - acceleration / 2
47
+ : t - acceleration / 2 - (t - brakeAt) ** 2 / (2 * braking)
48
+ return 522 * area / (end - (acceleration + braking) / 2)
49
+ }
50
+
51
+ /** Pure sampling is shared by playback and the included regression tests. No DOM required. */
52
+ export function sampleBadRequest(seconds: number, reduced = false): BadRequestFrame {
53
+ const { arrival, reveal, idle, cycle } = BAD_REQUEST_TIMING
54
+ const t = reduced ? idle : Math.max(0, Number.isFinite(seconds) ? seconds : 0)
55
+ const distance = travelled(t), packetX = 70 + distance
56
+ const zoom = smoother((t - reveal) / (idle - reveal))
57
+ const z = 2.65
58
+ const camera = { scale: mix(z, 1, zoom), x: mix(600 - z * packetX, 0, zoom), y: mix(367 - z * 323, 0, zoom) }
59
+ const settled = Math.max(0, t - idle), p = settled % cycle
60
+ let headAngle = p < .75 ? mix(-4, -9, smooth(p / .75))
61
+ : p < 3.35 ? -9 : p < 4.25 ? mix(-9, 5, smooth((p - 3.35) / .9))
62
+ : p < 5 ? 5 : mix(5, -4, smooth((p - 5) / (cycle - 5)))
63
+ if (t < idle) headAngle = -4
64
+ if (reduced) headAngle = -7
65
+ const lift = reduced || t < idle ? 0 : smooth((p - .85) / .65) * (1 - smooth((p - 2.8) / .7))
66
+ const rubEnvelope = smooth((p - 1.5) / .12) * (1 - smooth((p - 2.65) / .15))
67
+ const rub = reduced ? 0 : Math.sin((p - 1.5) * Math.PI * 2 * 3.4) * 4.2 * rubEnvelope
68
+ // Contact is calculated in the tilted head's coordinates, so the paw cannot float away.
69
+ const contact = rotate({ x: 91 + rub * .18, y: -278 + rub }, headAngle, { x: 0, y: -190 })
70
+ const paw = pointMix({ x: 76, y: -68 }, contact, lift)
71
+ const elbow = pointMix({ x: 100, y: -108 }, { x: 122, y: -211 }, lift)
72
+ const blinkAt = t % 4.9
73
+ const blink = !reduced && blinkAt > 3.55 && blinkAt < 3.77
74
+ ? 1 - .94 * Math.sin((blinkAt - 3.55) / .22 * Math.PI) ** 2 : 1
75
+ // One small inertial settle at arrival; the parcel stays still for the rest of the visit.
76
+ const afterStop = t - arrival
77
+ const packetAngle = afterStop > 0 && afterStop < .38
78
+ ? 1.8 * Math.sin(afterStop / .38 * Math.PI * 2) * (1 - afterStop / .38) ** 2 : 0
79
+ return {
80
+ phase: t < arrival ? 'follow' : t < reveal ? 'stopped' : t < idle ? 'reveal' : 'confused',
81
+ distance, packetX, packetAngle, camera, headAngle, paw, elbow,
82
+ pawAngle: lift * (headAngle - 12), scratching: lift > .98 && rubEnvelope > .1 && rub > 0,
83
+ blink, tailAngle: reduced ? 0 : 3 * Math.sin(t * .95),
84
+ questionY: reduced ? 0 : 3 * Math.sin(settled * 1.7),
85
+ questionAngle: reduced ? -3 : -3 + 3 * Math.sin(settled * 1.1),
86
+ }
87
+ }
88
+
89
+ export function createBadRequestStory(svg: SVGSVGElement, initiallyReduced = false): BadRequestStory {
90
+ const doc = svg.ownerDocument, win = doc.defaultView
91
+ if (!win) throw new Error('The bad request scene must be mounted in a browser document.')
92
+ function part(name: string): SVGElement {
93
+ const node = svg.querySelector<SVGElement>(`[data-part="${name}"]`)
94
+ if (!node) throw new Error(`Bad request scene is missing ${name}.`)
95
+ return node
96
+ }
97
+ const camera = part('camera'), packet = part('parcel'), pitch = part('parcel-pitch')
98
+ const normal = part('normal-parcels'), ribs = part('belt-ribs'), roller = part('roller')
99
+ const head = part('head'), paw = part('paw'), outline = part('arm-outline'), fill = part('arm-fill')
100
+ const leftEye = part('eye-left'), rightEye = part('eye-right'), tail = part('tail')
101
+ const question = part('question'), marks = part('scratch-marks')
102
+ const n = (v: number) => Math.round(v * 1000) / 1000
103
+ const transform = (el: SVGElement, value: string) => el.setAttribute('transform', value)
104
+ let elapsed = initiallyReduced ? BAD_REQUEST_TIMING.idle : 0
105
+ let reduced = initiallyReduced, paused = false, active = true, visible = true, disposed = false
106
+ let raf = 0, lastTick: number | undefined, lastPaint = -Infinity
107
+ let phase = ''
108
+
109
+ function paint() {
110
+ const f = sampleBadRequest(elapsed, reduced), c = f.camera
111
+ transform(camera, `matrix(${n(c.scale)} 0 0 ${n(c.scale)} ${n(c.x)} ${n(c.y)})`)
112
+ transform(packet, `translate(${n(f.packetX)} 405)`)
113
+ transform(pitch, `rotate(${n(f.packetAngle)} 0 0)`)
114
+ transform(normal, `translate(${n(f.distance)} 0)`)
115
+ transform(ribs, `translate(${n(f.distance % 24)} 0)`)
116
+ transform(roller, `rotate(${n(f.distance / 21 * 180 / Math.PI)})`)
117
+ transform(head, `rotate(${n(f.headAngle)} 0 -190)`)
118
+ transform(tail, `rotate(${n(f.tailAngle)} 64 -22)`)
119
+ transform(question, `translate(0 ${n(f.questionY)}) rotate(${n(f.questionAngle)} 3 12)`)
120
+ transform(leftEye, `scale(1 ${n(f.blink)})`)
121
+ transform(rightEye, `scale(1 ${n(f.blink)})`)
122
+ const e = f.elbow, p = f.paw
123
+ const d = `M60 -153 Q${n((60 + e.x) / 2 + 10)} ${n((-153 + e.y) / 2)} ${n(e.x)} ${n(e.y)} Q${n((e.x + p.x) / 2 + 7)} ${n((e.y + p.y) / 2)} ${n(p.x)} ${n(p.y)}`
124
+ outline.setAttribute('d', d); fill.setAttribute('d', d)
125
+ transform(paw, `translate(${n(p.x)} ${n(p.y)}) rotate(${n(f.pawAngle)})`)
126
+ // Marks live in cat-local space, just like the arm.
127
+ transform(marks, `translate(${n(p.x + 23)} ${n(p.y - 13)})`)
128
+ marks.setAttribute('visibility', f.scratching ? 'visible' : 'hidden')
129
+ if (phase !== f.phase) { phase = f.phase; svg.setAttribute('data-phase', phase) }
130
+ }
131
+ function running() { return !disposed && !paused && !reduced && active && visible && !doc.hidden }
132
+ function stop() { if (raf) win!.cancelAnimationFrame(raf); raf = 0; lastTick = undefined; lastPaint = -Infinity }
133
+ function tick(now: number) {
134
+ raf = 0
135
+ if (!running()) { lastTick = undefined; return }
136
+ if (lastTick !== undefined) elapsed += Math.min(.1, Math.max(0, (now - lastTick) / 1000))
137
+ lastTick = now
138
+ if (now - lastPaint >= 1000 / 30 - .5) { paint(); lastPaint = now }
139
+ raf = win!.requestAnimationFrame(tick)
140
+ }
141
+ function sync() {
142
+ if (running()) { if (!raf) raf = win!.requestAnimationFrame(tick) }
143
+ else stop()
144
+ }
145
+ const visibilityChanged = () => sync()
146
+ doc.addEventListener('visibilitychange', visibilityChanged)
147
+ const observer = typeof IntersectionObserver === 'undefined' ? undefined : new IntersectionObserver(entries => {
148
+ visible = entries.some(entry => entry.isIntersecting)
149
+ sync()
150
+ }, { threshold: 0 })
151
+ observer?.observe(svg)
152
+ paint(); sync()
153
+ return {
154
+ get time() { return elapsed },
155
+ play() { if (!disposed) { paused = false; sync() } },
156
+ pause() { paused = true; sync() },
157
+ restart() { if (disposed) return; elapsed = reduced ? BAD_REQUEST_TIMING.idle : 0; lastTick = undefined; paint(); sync() },
158
+ seek(seconds) { if (disposed) return; elapsed = Math.max(0, Number.isFinite(seconds) ? seconds : 0); lastTick = undefined; paint() },
159
+ setReducedMotion(value) {
160
+ if (disposed || reduced === value) return
161
+ reduced = value
162
+ // Returning from a static accessibility pose never replays the camera entrance.
163
+ if (value) elapsed = Math.max(elapsed, BAD_REQUEST_TIMING.idle)
164
+ paint(); sync()
165
+ },
166
+ setActive(value) { active = value; sync() },
167
+ dispose() { if (disposed) return; disposed = true; stop(); observer?.disconnect(); doc.removeEventListener('visibilitychange', visibilityChanged) },
168
+ }
169
+ }