chenguini 0.0.0-stage → 0.0.1

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/demos/draggable-cards/index.html +26 -0
  4. package/demos/draggable-cards/index.ts +282 -0
  5. package/demos/draggable-cards/swap-layout.ts +40 -0
  6. package/demos/gesture-flick-snap-target/index.html +63 -0
  7. package/demos/gesture-flick-snap-target/index.ts +262 -0
  8. package/demos/gesture-flick-snap-target/snap-layout.ts +44 -0
  9. package/demos/global-illumination-fast-approx/index.html +23 -0
  10. package/demos/global-illumination-fast-approx/index.ts +413 -0
  11. package/demos/global-illumination-pathtracer/index.html +56 -0
  12. package/demos/global-illumination-pathtracer/index.ts +638 -0
  13. package/demos/life-calendar/grid-sizing.ts +34 -0
  14. package/demos/life-calendar/index.html +36 -0
  15. package/demos/life-calendar/index.ts +235 -0
  16. package/demos/mobile-test-standalone/index.html +140 -0
  17. package/demos/mobile-test-standalone/index.ts +686 -0
  18. package/demos/nicer-hacker-news/data.ts +1790 -0
  19. package/demos/nicer-hacker-news/debug.js +39 -0
  20. package/demos/nicer-hacker-news/hn-comment-layout.ts +668 -0
  21. package/demos/nicer-hacker-news/hn-comment-parser.ts +228 -0
  22. package/demos/nicer-hacker-news/hn-thread-layout.ts +193 -0
  23. package/demos/nicer-hacker-news/index.html +101 -0
  24. package/demos/nicer-hacker-news/index.ts +845 -0
  25. package/demos/nicer-hacker-news/pages.html +160 -0
  26. package/demos/photo-gallery/data.ts +230 -0
  27. package/demos/photo-gallery/index.html +86 -0
  28. package/demos/photo-gallery/index.ts +705 -0
  29. package/demos/photo-gallery/layout.ts +367 -0
  30. package/demos/photo-gallery/prompt-layout.ts +127 -0
  31. package/demos/predictive-keyframes/index.html +13 -0
  32. package/demos/predictive-keyframes/index.ts +388 -0
  33. package/demos/scroll-anchor/anchor-layout.ts +136 -0
  34. package/demos/scroll-anchor/index.html +165 -0
  35. package/demos/scroll-anchor/index.ts +204 -0
  36. package/demos/scroll-anchor/scroll-anchor-react-bug.html +399 -0
  37. package/demos/spring-comparison/index.html +12 -0
  38. package/demos/spring-comparison/index.ts +239 -0
  39. package/docs/AddToHomeScreen.md +66 -0
  40. package/docs/Animation.md +142 -0
  41. package/docs/FrameScheduling.md +46 -0
  42. package/docs/Input.md +116 -0
  43. package/docs/Scrolling.md +138 -0
  44. package/docs/data-migration.md +116 -0
  45. package/docs/download-quirks-in-browser.md +6 -0
  46. package/docs/engineering.md +266 -0
  47. package/docs/file-system-api-quirks.md +3 -0
  48. package/docs/media.md +84 -0
  49. package/docs/router.md +376 -0
  50. package/docs/server-rendering-and-hot-reloading.md +113 -0
  51. package/docs/ui.md +334 -0
  52. package/docs/z-index-depth-management.md +99 -0
  53. package/package.json +25 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cheng Lou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # Chenguini
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **CH**eng's **ENG**ineering & **UI** guidelines for avoiding spaghetti code
4
+
5
+ See docs/ and demos/. Ignore docs/drafts for now
@@ -0,0 +1,26 @@
1
+ <!DOCTYPE html>
2
+ <head>
3
+ <meta charset="utf-8" />
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>Draggable Cards</title>
6
+ <style>
7
+ body {
8
+ font: 16px system-ui;
9
+ user-select: none; /* no text selection needed */
10
+ -webkit-user-select: none; /* no text selection needed */
11
+ background-color: #eee;
12
+ overflow: hidden; /* prevent accidentally dragging the viewport on iOS */
13
+ margin: 0;
14
+ }
15
+ .row {
16
+ position: absolute;
17
+ display: grid;
18
+ place-items: center;
19
+ overflow: hidden; /* height might be small during animation */
20
+ transition: box-shadow 0.25s ease-out, opacity 0.25s ease-out;
21
+ }
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <script src="./index.ts" type="module"></script>
26
+ </body>
@@ -0,0 +1,282 @@
1
+ import {center, rowSizeX, shouldSwapDown, shouldSwapUp, stackLayout, windowPaddingTop} from './swap-layout'
2
+
3
+ // === generic scheduler & its debugger
4
+ let scheduledRaf: number | null = null
5
+ function scheduleRender() {
6
+ if (scheduledRaf != null) return;
7
+ scheduledRaf = requestAnimationFrame(function renderAndMaybeScheduleAnotherRender(now) { // eye-grabbing name. No "(anonymous)" function in the debugger & profiler
8
+ scheduledRaf = null
9
+ if (render(now)) scheduleRender()
10
+ })
11
+ }
12
+
13
+ // === generic spring physics
14
+ // 4ms/step for the spring animation's step. Typically 4 steps for 60fps (16.6ms/frame) and 2 for 120fps (8.3ms/frame). Frame time delta varies, so not always true
15
+ // could use 8ms instead, but 120fps' 8.3ms/frame means the computation might not fit in the remaining 0.3ms, which means sometime the simulation step wouldn't even run once, giving the illusion of jank
16
+ const msPerAnimationStep = 4
17
+
18
+ type SpringConfig = {
19
+ pos: number
20
+ dest: number
21
+ v: number
22
+ k: number
23
+ b: number
24
+ }
25
+
26
+ function spring(pos: number, v = 0, k = 290, b = 24): SpringConfig {
27
+ return {pos, dest: pos, v, k, b} // k = stiffness, b = damping. Try https://chenglou.me/react-motion/demos/demo5-spring-parameters-chooser/
28
+ }
29
+ function springStep(config: SpringConfig) {
30
+ // https://blog.maximeheckel.com/posts/the-physics-behind-spring-animations/
31
+ // this seems inspired by https://github.com/chenglou/react-motion/blob/9e3ce95bacaa9a1b259f969870a21c727232cc68/src/stepper.js
32
+ const t = msPerAnimationStep / 1000 // convert to seconds for the physics equation
33
+ const {pos, dest, v, k, b} = config
34
+ // for animations, dest is actually spring at rest. Current position is the spring's stretched/compressed state
35
+ const Fspring = -k * (pos - dest) // Spring stiffness, in kg / s^2
36
+ const Fdamper = -b * v // Damping, in kg / s
37
+ const a = Fspring + Fdamper // a needs to be divided by mass, but we'll assume mass of 1. Adjust k and b to change spring curve instead
38
+ const newV = v + a * t
39
+ const newPos = pos + newV * t
40
+
41
+ config.pos = newPos; config.v = newV
42
+ }
43
+ function springGoToEnd(config: SpringConfig) {
44
+ config.pos = config.dest
45
+ config.v = 0
46
+ }
47
+
48
+ // === generic helpers
49
+ function hash(n: number) {
50
+ n = Math.imul((n >>> 16) ^ n, 0x21f0aaad);
51
+ n = Math.imul((n >>> 15) ^ n, 0xd35a2d97);
52
+ return (((n >>> 15) ^ n) >>> 0) / 0x100000000;
53
+ }
54
+
55
+ // === state
56
+ type RowData = {
57
+ id: string
58
+ sizeY: number
59
+ x: SpringConfig
60
+ y: SpringConfig
61
+ scale: SpringConfig
62
+ }
63
+
64
+ type DragInfo = {
65
+ id: string
66
+ deltaX: number
67
+ deltaY: number
68
+ }
69
+
70
+ type PointerSample = {x: number; y: number; time: number}
71
+
72
+ type State = {
73
+ animatedUntilTime: number | null
74
+ dragged: DragInfo | null
75
+ lastDragged: DragInfo | null
76
+ pointerState: 'down' | 'up' | 'firstDown'
77
+ pointer: PointerSample[]
78
+ events: {
79
+ mouseup: MouseEvent | null
80
+ touchend: TouchEvent | null
81
+ touchcancel: TouchEvent | null
82
+ mousemove: MouseEvent | null
83
+ touchmove: TouchEvent | null
84
+ pointerdown: PointerEvent | null
85
+ }
86
+ data: RowData[]
87
+ }
88
+
89
+ // cache lifetime: same as data
90
+ const domCache: HTMLDivElement[] = []
91
+ for (let i = 0; i < 5; i++) {
92
+ const node = document.createElement("div")
93
+ const sizeY = 30 + hash(i * 2) * 150 // [30, 180)
94
+ const rand = hash(i * 2 + 1) * 40 + 40 // Range: [40, 80]
95
+ // static properties: set once at creation
96
+ node.className = "row"
97
+ node.textContent = "Drag Me " + i
98
+ node.style.width = `${rowSizeX}px`
99
+ node.style.height = `${sizeY}px`
100
+ node.style.outline = `1px solid hsl(205, 100%, ${rand}%)` // blue hue
101
+ node.style.backgroundColor = `hsl(205, 100%, ${rand + 10}%)` // lighter blue hue
102
+ domCache.push(node)
103
+ document.body.appendChild(node)
104
+ }
105
+
106
+
107
+ const st: State = {
108
+ animatedUntilTime: null,
109
+ dragged: null,
110
+ lastDragged: null,
111
+ pointerState: 'up',
112
+ pointer: [{x: 0, y: 0, time: 0}], // circular buffer. Btw, on page load, there's no way to render a first cursor state =(
113
+ events: { mouseup: null, touchend: null, touchcancel: null, mousemove: null, touchmove: null, pointerdown: null },
114
+ data: [],
115
+ }
116
+ {
117
+ const windowSizeX = document.documentElement.clientWidth // excludes scroll bar & invariant under safari pinch zoom
118
+ for (let i = 0; i < 5; i++) {
119
+ const sizeY = 30 + hash(i * 2) * 150 // [30, 180)
120
+ st.data.push({
121
+ id: i + '', // gonna drag rows around so we can't refer to a row by index. Assign a stable id
122
+ sizeY,
123
+ x: spring(center(rowSizeX, windowSizeX)),
124
+ y: spring(0),
125
+ scale: spring(1),
126
+ })
127
+ }
128
+ }
129
+ function springForEach(f: (s: SpringConfig) => void) {
130
+ for (const d of st.data) {
131
+ f(d.x); f(d.y); f(d.scale) // no different than [a, b, c].forEach(f)
132
+ }
133
+ }
134
+
135
+ // === events
136
+ // See docs/Input.md.
137
+ window.addEventListener('resize', () => scheduleRender())
138
+ window.addEventListener("mouseup", (e) => {st.events.mouseup = e; scheduleRender()})
139
+ window.addEventListener("touchend", (e) => {st.events.touchend = e; scheduleRender()})
140
+ window.addEventListener("touchcancel", (e) => {st.events.touchcancel = e; scheduleRender()})
141
+ window.addEventListener("mousemove", (e) => {st.events.mousemove = e; scheduleRender()})
142
+ window.addEventListener("touchmove", (e) => {st.events.touchmove = e; scheduleRender()})
143
+ window.addEventListener('pointerdown', (e) => {st.events.pointerdown = e; scheduleRender()})
144
+
145
+ // === hit testing logic. Boxes' hit area should be static and not follow their current animated state usually (but we can do either). Use the dynamic area here for once
146
+ function hitTest(data: RowData[], pointer: PointerSample) {
147
+ for (const d of data) {
148
+ const {x, y, sizeY} = d
149
+ if (x.pos <= pointer.x && pointer.x < x.pos + rowSizeX && y.pos <= pointer.y && pointer.y < y.pos + sizeY) return d // pointer on this box
150
+ }
151
+ return null
152
+ }
153
+
154
+ function render(now: number) {
155
+ // === step 0: process events
156
+ // mouseup/touchend
157
+ if (st.events.mouseup != null || st.events.touchend != null || st.events.touchcancel != null) st.pointerState = 'up'
158
+ if (st.events.mouseup != null) st.pointer.push({x: st.events.mouseup.pageX, y: st.events.mouseup.pageY, time: st.events.mouseup.timeStamp})
159
+ if (st.events.touchcancel != null) {
160
+ const touch = st.events.touchcancel.changedTouches[0]
161
+ if (touch != null) st.pointer.push({x: touch.pageX, y: touch.pageY, time: st.events.touchcancel.timeStamp})
162
+ }
163
+ if (st.events.touchend != null) {
164
+ const touch = st.events.touchend.changedTouches[0]
165
+ if (touch != null) st.pointer.push({x: touch.pageX, y: touch.pageY, time: st.events.touchend.timeStamp})
166
+ }
167
+ // move
168
+ // when scrolling (which might schedule a render), a container's pointermove doesn't trigger, so the pointer's local coordinates are stale
169
+ // this means we should only use pointer's global coordinates, which is always right
170
+ if (st.events.mousemove != null) st.pointer.push({x: st.events.mousemove.pageX, y: st.events.mousemove.pageY, time: st.events.mousemove.timeStamp})
171
+ if (st.events.touchmove != null) st.pointer.push({x: st.events.touchmove.touches[0]!.pageX, y: st.events.touchmove.touches[0]!.pageY, time: st.events.touchmove.timeStamp})
172
+ // down
173
+ if (st.events.pointerdown != null) {
174
+ st.pointerState = 'firstDown'
175
+ st.pointer.push({x: st.events.pointerdown.pageX, y: st.events.pointerdown.pageY, time: st.events.pointerdown.timeStamp})
176
+ }
177
+
178
+ // === step 1: batched DOM reads (to avoid accidental DOM read & write interleaving)
179
+ const windowSizeX = document.documentElement.clientWidth // excludes scroll bar & invariant under safari pinch zoom
180
+ const pointerLast = st.pointer.at(-1)! // guaranteed non-null since pointer.length >= 1
181
+
182
+ // === step 2: handle inputs-related state change
183
+ let newDragged: DragInfo | null
184
+ if (st.pointerState === 'down') newDragged = st.dragged
185
+ else if (st.pointerState === 'up') {
186
+ if (st.dragged != null) {
187
+ const dragIdx = st.data.findIndex(d => d.id === st.dragged!.id)
188
+ let i = st.pointer.length - 1
189
+ while (i > 0 && pointerLast.time - st.pointer[i - 1]!.time <= 100) i--
190
+ const deltaTime = pointerLast.time - st.pointer[i]!.time
191
+ if (deltaTime > 0) {
192
+ const vx = (pointerLast.x - st.pointer[i]!.x) / deltaTime * 1000 // speed over ~1s
193
+ const vy = (pointerLast.y - st.pointer[i]!.y) / deltaTime * 1000
194
+ st.data[dragIdx]!.x.v += vx
195
+ st.data[dragIdx]!.y.v += vy
196
+ }
197
+ }
198
+ newDragged = null
199
+ } else {
200
+ const hit = hitTest(st.data, pointerLast)
201
+ if (hit != null) newDragged = {id: hit.id, deltaX: pointerLast.x - hit.x.pos, deltaY: pointerLast.y - hit.y.pos}
202
+ else newDragged = null
203
+ }
204
+
205
+ // === step 3: calculate new layout & cursor
206
+ if (newDragged != null) { // first, swap row based on cursor position if needed
207
+ let dragIdx = st.data.findIndex(d => d.id === newDragged.id) // guaranteed non-null
208
+ const d = st.data[dragIdx]!
209
+ const x = pointerLast.x - newDragged.deltaX
210
+ const y = pointerLast.y - newDragged.deltaY
211
+ d.x.pos = d.x.dest = x + (center(rowSizeX, windowSizeX) - x) / 1.5 // restrict horizontal drag a bit
212
+ d.y.pos = d.y.dest = y
213
+ d.scale.dest = 1.1
214
+ // dragging row upward? Swap it with previous row if cursor is above the first half previous row
215
+ while (dragIdx > 0 && shouldSwapUp(pointerLast.y, st.data[dragIdx - 1]!.y.dest, st.data[dragIdx - 1]!.sizeY)) {
216
+ [st.data[dragIdx], st.data[dragIdx - 1]] = [st.data[dragIdx - 1]!, st.data[dragIdx]!] // swap
217
+ dragIdx--
218
+ }
219
+ // dragging row downward? Swap it with next row if cursor is below the first half next row
220
+ while (dragIdx < st.data.length - 1 && shouldSwapDown(pointerLast.y, st.data[dragIdx + 1]!.y.dest, st.data[dragIdx + 1]!.sizeY)) {
221
+ [st.data[dragIdx], st.data[dragIdx + 1]] = [st.data[dragIdx + 1]!, st.data[dragIdx]!] // swap
222
+ dragIdx++
223
+ }
224
+ }
225
+ const rowTops = stackLayout(st.data.map(d => d.sizeY), windowPaddingTop)
226
+ for (let rowIndex = 0; rowIndex < st.data.length; rowIndex++) {
227
+ const d = st.data[rowIndex]!
228
+ const top = rowTops[rowIndex]!
229
+ if (newDragged != null && d.id === newDragged.id) { // already modified above
230
+ } else {
231
+ d.x.dest = center(rowSizeX, windowSizeX)
232
+ d.y.dest = top
233
+ d.scale.dest = 1
234
+ }
235
+ }
236
+ const cursor =
237
+ newDragged != null ? 'grabbing' // will be "grabbing" even if cursor isn't on the card! Try dragging to left/right extremes
238
+ : hitTest(st.data, pointerLast) != null ? 'grab'
239
+ : 'auto'
240
+
241
+ // === step 4: run animation
242
+ let newAnimatedUntilTime = st.animatedUntilTime ?? now
243
+ const steps = Math.min(300, Math.floor((now - newAnimatedUntilTime) / msPerAnimationStep)) // run x spring steps. Decouple physics simulation from framerate!
244
+ newAnimatedUntilTime += steps * msPerAnimationStep
245
+ let stillAnimating = false
246
+ springForEach(s => {
247
+ for (let i = 0; i < steps; i++) springStep(s)
248
+ if (Math.abs(s.v) < 0.01 && Math.abs(s.dest - s.pos) < 0.01) springGoToEnd(s) // close enough, done
249
+ else stillAnimating = true
250
+ })
251
+
252
+ // === step 5: render. Batch DOM writes (dynamic properties only)
253
+ for (let i = 0; i < st.data.length; i++) {
254
+ const d = st.data[i]!, style = domCache[Number(d.id)]!.style
255
+ style.transform = `translate3d(${d.x.pos}px,${d.y.pos}px,0) scale(${d.scale.pos})`
256
+ style.zIndex =
257
+ newDragged != null && d.id === newDragged.id ? String(st.data.length + 2)
258
+ : st.lastDragged != null && d.id === st.lastDragged.id ? String(st.data.length + 1) // last dragged and released row still needs to animate into place; keep it high
259
+ : String(i)
260
+ if (newDragged != null && d.id === newDragged.id) {
261
+ style.boxShadow = "rgba(0, 0, 0, 0.2) 0px 16px 32px 0px"
262
+ style.opacity = '0.7'
263
+ } else {
264
+ style.boxShadow = "rgba(0, 0, 0, 0.2) 0px 1px 2px 0px"
265
+ style.opacity = '1.0'
266
+ }
267
+ }
268
+ document.body.style.cursor = cursor
269
+
270
+ // === step 6: update state & prepare for next frame
271
+ if (st.pointerState === 'firstDown') st.pointerState = 'down'
272
+ if (st.dragged != null && newDragged == null) st.lastDragged = st.dragged
273
+ st.dragged = newDragged
274
+ st.animatedUntilTime = stillAnimating ? newAnimatedUntilTime : null // oxlint-disable-line no-unnecessary-condition
275
+ if (st.pointerState === 'up') st.pointer = [{x: 0, y: 0, time: 0}]
276
+ if (st.pointer.length > 20) st.pointer.shift() // keep last ~20
277
+ st.events.mouseup = st.events.touchend = st.events.touchcancel = st.events.mousemove = st.events.touchmove = st.events.pointerdown = null
278
+
279
+ return stillAnimating // oxlint-disable-line no-unnecessary-condition
280
+ }
281
+
282
+ scheduleRender()
@@ -0,0 +1,40 @@
1
+ // Pure swap and stack geometry for draggable-cards.
2
+ // The demo imports it.
3
+
4
+ export const rowSizeX = 320
5
+ export const windowPaddingTop = 50
6
+
7
+ /** @fit
8
+ * given containee: 0..<Infinity
9
+ * given container: 0..<Infinity
10
+ * return == (container - containee) / 2
11
+ */
12
+ export function center(containee: number, container: number) {
13
+ return (container - containee) / 2
14
+ }
15
+
16
+ export function shouldSwapUp(pointerY: number, neighborDestY: number, neighborSizeY: number) {
17
+ return pointerY < neighborDestY + neighborSizeY / 2
18
+ }
19
+
20
+ export function shouldSwapDown(pointerY: number, neighborDestY: number, neighborSizeY: number) {
21
+ return pointerY > neighborDestY + neighborSizeY / 2
22
+ }
23
+
24
+ /** @fit
25
+ * given rowSizes.length: int 0..100
26
+ * given rowSizes[]: 0..1000
27
+ * return.length == rowSizes.length
28
+ */
29
+ export function stackLayout(
30
+ rowSizes: number[],
31
+ paddingTop: number, // @fit 0..1000
32
+ ): number[] {
33
+ const result: number[] = []
34
+ let top = paddingTop
35
+ for (const rowSize of rowSizes) {
36
+ result.push(top)
37
+ top += rowSize
38
+ }
39
+ return result
40
+ }
@@ -0,0 +1,63 @@
1
+ <!DOCTYPE html>
2
+ <head>
3
+ <meta charset="utf-8" />
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>PiP Corner Snap (Two-Spring)</title>
6
+ <style>
7
+ body {
8
+ font: 14px system-ui;
9
+ user-select: none;
10
+ -webkit-user-select: none;
11
+ background-color: #1a1a2e;
12
+ overflow: hidden;
13
+ margin: 0;
14
+ height: 100vh;
15
+ }
16
+ .pip {
17
+ position: absolute;
18
+ width: 120px;
19
+ height: 80px;
20
+ border-radius: 12px;
21
+ background: #e94560;
22
+ display: grid;
23
+ place-items: center;
24
+ color: white;
25
+ font-weight: 600;
26
+ font-size: 13px;
27
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
28
+ }
29
+ .corner-marker {
30
+ position: absolute;
31
+ width: 16px;
32
+ height: 16px;
33
+ border-radius: 50%;
34
+ background: rgba(255,255,255,0.4);
35
+ transform: translate(-50%, -50%);
36
+ }
37
+ .rest-marker {
38
+ position: absolute;
39
+ width: 10px;
40
+ height: 10px;
41
+ border-radius: 50%;
42
+ background: rgba(100,200,255,0.85);
43
+ transform: translate(-50%, -50%);
44
+ pointer-events: none;
45
+ }
46
+ #info {
47
+ position: absolute;
48
+ bottom: 20px;
49
+ left: 20px;
50
+ color: rgba(255,255,255,0.5);
51
+ font-size: 12px;
52
+ line-height: 1.6;
53
+ }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <div id="info">
58
+ Two-spring corner snap. Drag and flick the card toward a corner.<br>
59
+ Decay rest = <span style="color:rgba(100,200,255,0.85)">blue dot</span> &middot;
60
+ Corners = <span style="color:rgba(255,255,255,0.5)">white dots</span>
61
+ </div>
62
+ <script src="./index.ts" type="module"></script>
63
+ </body>