animate-my-app 0.0.0-stage → 1.1.0
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.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/bin/install.js +41 -0
- package/package.json +31 -4
- package/skills/animate-my-app/SKILL.md +119 -0
- package/skills/animate-my-app/assets/template/__APP__/App.swift +38 -0
- package/skills/animate-my-app/assets/template/__APP__/Assets.xcassets/Contents.json +6 -0
- package/skills/animate-my-app/assets/template/__APP__/ContentView.swift +123 -0
- package/skills/animate-my-app/assets/template/__APP__/SceneBridge.swift +88 -0
- package/skills/animate-my-app/assets/template/__APP__/Web/content.js +53 -0
- package/skills/animate-my-app/assets/template/__APP__/Web/scene.html +28 -0
- package/skills/animate-my-app/assets/template/__APP__/Web/scene.js +133 -0
- package/skills/animate-my-app/assets/template/__APP__/Web/three.min.js +7 -0
- package/skills/animate-my-app/assets/template/__APP__.xcodeproj/project.pbxproj +164 -0
- package/skills/animate-my-app/assets/template/__APP__.xcodeproj/xcshareddata/xcschemes/__APP__.xcscheme +46 -0
- package/skills/animate-my-app/references/craft.md +191 -0
- package/skills/animate-my-app/references/gotchas.md +446 -0
- package/skills/animate-my-app/references/motion-physics.md +260 -0
- package/skills/animate-my-app/references/photo-layers.md +84 -0
- package/skills/animate-my-app/references/procedural-3d.md +252 -0
- package/skills/animate-my-app/references/research-and-tools.md +126 -0
- package/skills/animate-my-app/references/stack.md +68 -0
- package/skills/animate-my-app/references/testing.md +162 -0
- package/skills/animate-my-app/scripts/frame_video.swift +190 -0
- package/skills/animate-my-app/scripts/frames.swift +27 -0
- package/skills/animate-my-app/scripts/hitch.swift +28 -0
- package/skills/animate-my-app/scripts/new_app.sh +24 -0
- package/skills/animate-my-app/scripts/sf_symbol_bitmap.swift +18 -0
- package/skills/animate-my-app/scripts/sf_symbol_codepoints.py +30 -0
- package/skills/animate-my-app/scripts/side_by_side.swift +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 @oiharshit
|
|
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,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# animate-my-app
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Claude Code skill that turns an app design into a **native, animated iOS app**, the kind of motion people post and ask "how did you make this?".
|
|
4
|
+
|
|
5
|
+
Drop in a Figma link or screenshots and ask Claude to animate it. The skill makes Claude:
|
|
6
|
+
|
|
7
|
+
- build a real SwiftUI app: layout, text, buttons, haptics and safe areas stay native;
|
|
8
|
+
- render hero parts (logos, 3D objects, clay characters, paper, chrome, painted backgrounds) in a **three.js** layer, built procedurally rather than from cut-up images;
|
|
9
|
+
- move things with **real physics**: damped springs, gravity, squash and stretch that keeps volume, contact shadows, and staging that waits for things to land;
|
|
10
|
+
- load every texture and compile every shader **before** the clock starts, so nothing loads mid-animation;
|
|
11
|
+
- **test what it builds**: Simulator recordings checked frame by frame, a frame-time probe (target: 0 frames over 25 ms), and an optional self-test on your iPhone;
|
|
12
|
+
- ask you about the motion before guessing.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
**Claude Code plugin (recommended)**
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
claude plugin marketplace add contactharshitdesign-design/animate-my-app
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
claude plugin install animate-my-app@animate-my-app
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or inside Claude Code: `/plugin marketplace add contactharshitdesign-design/animate-my-app`, then `/plugin install animate-my-app@animate-my-app`.
|
|
27
|
+
|
|
28
|
+
**npx (copies the skill into `~/.claude/skills`)**
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx animate-my-app
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Add `--project` to install it into the current project's `.claude/skills` instead, and `--force` to replace an older copy (it's backed up first).
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
Ask Claude Code for any app motion, for example:
|
|
39
|
+
|
|
40
|
+
- "Animate this onboarding from Figma: <link>"
|
|
41
|
+
- "Make the splash logo drop in and squash like clay"
|
|
42
|
+
- "The paper should fall onto page 2 with real physics"
|
|
43
|
+
- "Turn this seat map into 3D and fly into the seat I tap"
|
|
44
|
+
|
|
45
|
+
The skill scaffolds an app with `scripts/new_app.sh`, builds it, runs it in the Simulator and reports measured results.
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- macOS with Xcode and an iOS Simulator runtime
|
|
50
|
+
- Claude Code
|
|
51
|
+
- Optional: an Apple developer team ID to run on your own iPhone; the Figma MCP server for Figma links
|
|
52
|
+
|
|
53
|
+
## What's inside
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
skills/animate-my-app/
|
|
57
|
+
├── SKILL.md the workflow and the rules
|
|
58
|
+
├── references/ craft guide, stack, motion physics, procedural 3D, testing, research and tools, lessons learned
|
|
59
|
+
├── scripts/ new_app.sh scaffold, frame extraction, hitch probe, framed video, SF Symbol export
|
|
60
|
+
└── assets/template/ a working SwiftUI + three.js app the scaffold copies
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Author
|
|
64
|
+
|
|
65
|
+
Made by [@oiharshit](https://x.com/oiharshit).
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
[MIT](LICENSE)
|
package/bin/install.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx animate-my-app → installs the skill for you (~/.claude/skills/animate-my-app)
|
|
3
|
+
// npx animate-my-app --project → installs it into this project (./.claude/skills/animate-my-app)
|
|
4
|
+
// add --force to replace an existing copy (the old one is kept as a timestamped backup)
|
|
5
|
+
'use strict';
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
12
|
+
console.log('Usage: npx animate-my-app [--project] [--force]');
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
const project = args.includes('--project');
|
|
16
|
+
const force = args.includes('--force');
|
|
17
|
+
const src = path.join(__dirname, '..', 'skills', 'animate-my-app');
|
|
18
|
+
const root = project ? path.join(process.cwd(), '.claude', 'skills') : path.join(os.homedir(), '.claude', 'skills');
|
|
19
|
+
const dest = path.join(root, 'animate-my-app');
|
|
20
|
+
|
|
21
|
+
if (!fs.existsSync(path.join(src, 'SKILL.md'))) { console.error('animate-my-app: the packaged skill is missing.'); process.exit(1); }
|
|
22
|
+
fs.mkdirSync(root, { recursive: true });
|
|
23
|
+
if (fs.existsSync(dest)) {
|
|
24
|
+
if (!force) {
|
|
25
|
+
console.log(`animate-my-app is already installed at ${dest}\nRun again with --force to replace it (your current copy is backed up).`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
// backups live outside the skills folder, or Claude would load them as a second copy of the skill
|
|
29
|
+
const backups = path.join(os.homedir(), '.claude', 'skill-backups');
|
|
30
|
+
fs.mkdirSync(backups, { recursive: true });
|
|
31
|
+
const backup = path.join(backups, `animate-my-app-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
|
32
|
+
fs.renameSync(dest, backup);
|
|
33
|
+
console.log(`Backed up the old copy to ${backup}`);
|
|
34
|
+
}
|
|
35
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
36
|
+
for (const f of ['new_app.sh']) { // keep scripts executable
|
|
37
|
+
const p = path.join(dest, 'scripts', f);
|
|
38
|
+
if (fs.existsSync(p)) fs.chmodSync(p, 0o755);
|
|
39
|
+
}
|
|
40
|
+
console.log(`Installed animate-my-app → ${dest}`);
|
|
41
|
+
console.log('Restart Claude Code (or run /reload-skills), then ask it to animate your app design.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "animate-my-app",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "A Claude Code skill: turn an app design into a native, animated iOS app (SwiftUI + three.js), tested on device. Run `npx animate-my-app` to install.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"animate-my-app": "bin/install.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"skills/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"claude",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"skill",
|
|
18
|
+
"animation",
|
|
19
|
+
"ios",
|
|
20
|
+
"swiftui",
|
|
21
|
+
"threejs"
|
|
22
|
+
],
|
|
23
|
+
"author": "@oiharshit",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=16"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/contactharshitdesign-design/animate-my-app.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/contactharshitdesign-design/animate-my-app#readme"
|
|
33
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: animate-my-app
|
|
3
|
+
description: >
|
|
4
|
+
Turn an app design (Figma frames, screenshots, a reference video or an existing app) into a real, native,
|
|
5
|
+
animated iOS app that runs in the Simulator and on iPhone: SwiftUI for layout, text and buttons, plus a
|
|
6
|
+
transparent three.js/WebGL layer for hero motion (clay or chrome 3D objects, logos that assemble, shader and
|
|
7
|
+
paint backgrounds, page curls, particles, physics, gesture-driven 3D). Use it for any request to animate an
|
|
8
|
+
app design or screen; build or fix an onboarding, splash, intro, screen transition, logo or hero animation;
|
|
9
|
+
recreate motion from a reference (Dribbble, Pinterest, a video); tune how something moves (springs, squash and
|
|
10
|
+
stretch, gestures, swipes, card stacks, pull-to-refresh); or add three.js, shaders or physics to a native iOS
|
|
11
|
+
app. Ships a scaffold script, motion and procedural-3D recipes, measurement scripts for frame times and
|
|
12
|
+
motion, and a list of WKWebView, three.js, SwiftUI and Simulator gotchas.
|
|
13
|
+
license: MIT
|
|
14
|
+
metadata:
|
|
15
|
+
author: "@oiharshit"
|
|
16
|
+
version: "1.1.0"
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Animate My App
|
|
20
|
+
|
|
21
|
+
Take a designed app screen (usually from Figma) and ship it as a native iOS app whose hero moments move like a
|
|
22
|
+
Rive- or three.js-level piece: physical, staged, measured. Speed comes from **reusing this pipeline** (scaffold,
|
|
23
|
+
engine, test scripts, recipes), not re-deriving it for every app.
|
|
24
|
+
|
|
25
|
+
## 0. Before anything: read the craft guide
|
|
26
|
+
|
|
27
|
+
Read `references/craft.md` at the start of every build. It sets the bar for motion, interaction, accessibility,
|
|
28
|
+
visual polish, typography, colour and Figma-to-code fidelity, and ends with the review checklist to run before
|
|
29
|
+
you report. This skill is self-contained: everything it needs is in this folder.
|
|
30
|
+
|
|
31
|
+
## 1. Intake (fast), then ASK
|
|
32
|
+
|
|
33
|
+
1. Pull every frame that was linked: Figma MCP `get_screenshot` + `get_design_context` + `get_metadata`
|
|
34
|
+
(+ `download_assets` for raster art, fonts, exact colours and sizes). Details and fallbacks:
|
|
35
|
+
`references/research-and-tools.md`.
|
|
36
|
+
2. Find references when the motion idea is open: Mobbin (MCP or website), Pinterest, Dribbble, a supplied video.
|
|
37
|
+
Generated assets or motion references can come from an image/video generation service if one is connected.
|
|
38
|
+
3. **Ask** (2–4 pointed questions) whenever the motion intent is not explicit: sequence and staging, feel (bouncy
|
|
39
|
+
clay / crisp / calm), total length, what is procedural vs image, sound or none, the hand-off between screens.
|
|
40
|
+
When motion is described in words, restate it as physics and confirm ("squeeze → stretch and move on the same
|
|
41
|
+
frame, tallest early in the climb?"). Ask again mid-build whenever a choice changes the result. Never guess
|
|
42
|
+
on intent that matters.
|
|
43
|
+
|
|
44
|
+
## 2. Build: native app + three.js layer
|
|
45
|
+
|
|
46
|
+
Scaffold in one command (working app, replay protocol, preload/warm-start engine, demo content):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
scripts/new_app.sh <AppName> <parent-dir> [bundle.id] # default bundle id: com.example.<appname>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Architecture, bridge and engine API: `references/stack.md`. In short:
|
|
53
|
+
- SwiftUI owns layout, text, buttons, haptics, safe areas. A transparent full-screen `WKWebView` runs three.js
|
|
54
|
+
for hero parts, served over a custom scheme (WebGL refuses `file://` textures). Created eagerly at launch.
|
|
55
|
+
- **One timeline** (JS) is the source of truth; it fires cues (`headline`, `button`, …) that SwiftUI animates.
|
|
56
|
+
- Design canvas = the Figma frame in points (iPhone 17 Pro 402×874); ortho camera in design points.
|
|
57
|
+
- Pick the right tool per part: three.js for 3D/clay/shaders/procedural paint; SwiftUI springs,
|
|
58
|
+
`matchedGeometryEffect`, `PhaseAnimator`/`KeyframeAnimator` for UI; Rive/Lottie only when such assets are
|
|
59
|
+
supplied or the part is pure 2D vector character animation.
|
|
60
|
+
|
|
61
|
+
**Build hero parts procedurally, not from cut-up images.** Image cut-outs crack, fringe and flicker. Recipes for
|
|
62
|
+
clay letters (SDF + surface nets + clay shader), chrome and metal, self-painting brush backgrounds, paper, and
|
|
63
|
+
splitting illustrations into clean animatable pieces: `references/procedural-3d.md`. Photographed people with
|
|
64
|
+
swappable pieces: `references/photo-layers.md`.
|
|
65
|
+
|
|
66
|
+
## 3. Motion rules
|
|
67
|
+
|
|
68
|
+
Full formulas and constants: `references/motion-physics.md`; timing, easing, gestures and polish:
|
|
69
|
+
`references/craft.md`.
|
|
70
|
+
- Real physics: damped springs (damping ratio + response), gravity drops, squash and stretch that **preserves
|
|
71
|
+
volume**, contact shadows that react to height.
|
|
72
|
+
- Anticipation without dwell: squeeze, then stretch **and** move on the same frame; peak stretch early in the
|
|
73
|
+
travel; jelly follow-through; overshoot then a braking squash. Stretch must be clearly visible.
|
|
74
|
+
- **Staging is gated on arrival**: the next phase starts only after the previous element has truly landed
|
|
75
|
+
(compute the arrival time, don't guess a delay).
|
|
76
|
+
- Entrances never from `scale(0)`; press feedback 0.97 + light haptic; always a reduced-motion path.
|
|
77
|
+
- Buttons and text match Figma exactly (measure size, radius, colour, inner glows) and sit per Apple HIG
|
|
78
|
+
(primary button 16 pt above the bottom safe area; keep the designed spacing to the headline).
|
|
79
|
+
|
|
80
|
+
## 4. Hard rules (defaults)
|
|
81
|
+
|
|
82
|
+
- **No clipping** unless asked or the Figma frame has "Clip content" on. Pad every bounded surface (planes,
|
|
83
|
+
textures, canvases, frames) so brush ends, shadows, glows and overshoot fit. Check every edge of rendered frames
|
|
84
|
+
for straight cuts before delivering. If source art itself is cut, repair it.
|
|
85
|
+
- **No mid-animation loading:** decode + GPU-upload every texture and pre-warm every shader *before* the clock
|
|
86
|
+
starts; warm frames before the first move. Measure; target 0 frames > 25 ms on replay.
|
|
87
|
+
- **Replay protocol:** every app listens for the Darwin notification `com.animatemyapp.replay` and replays its
|
|
88
|
+
intro in place (fade to white → reset → warm → start), plus tap-to-replay outside buttons. Never relaunch to replay.
|
|
89
|
+
- Real native app in the Simulator (and on iPhone), never a localhost/web demo. Status bar 9:41 for anything shown.
|
|
90
|
+
- **Concentric corners:** outer radius = inner radius + padding for every nested rounded shape (thumbnail in a
|
|
91
|
+
card, highlight in a menu, chip in a composer, outline around media). Derive radii from the innermost element
|
|
92
|
+
out; break it only when truly necessary (full-bleed media clipped by its card simply inherits the card radius).
|
|
93
|
+
- No sound unless asked.
|
|
94
|
+
- **Test everything** yourself before reporting (section 5). Report measured numbers, not impressions.
|
|
95
|
+
|
|
96
|
+
## 5. Verify
|
|
97
|
+
|
|
98
|
+
`references/testing.md` has the scripts and checks. Minimum every iteration:
|
|
99
|
+
1. Build (iOS Simulator build tool if available, else `xcodebuild`), install, launch on a simulator you booted
|
|
100
|
+
(pass its UDID to every `simctl` call).
|
|
101
|
+
2. Record (`xcrun simctl io <UDID> recordVideo`), extract frames (`scripts/frames.swift`), and check the
|
|
102
|
+
sequence numerically: timing order, stretch amounts, overshoot, arrival before next phase, no cuts at edges.
|
|
103
|
+
3. Frame-time probe in-app for stalls (snippet in testing.md); replay via the notification and confirm 0 stalls.
|
|
104
|
+
Simulator WebGL is software-rendered and noisy under load: judge performance on a real iPhone when one is available.
|
|
105
|
+
4. Look at contact sheets yourself; compare against the Figma screenshot.
|
|
106
|
+
|
|
107
|
+
## 6. Deliver
|
|
108
|
+
|
|
109
|
+
- **Recording:** override the status bar to 9:41, warm-launch once, then trigger the in-place replay
|
|
110
|
+
(`xcrun simctl spawn <UDID> notifyutil -p com.animatemyapp.replay`) while recording with
|
|
111
|
+
`xcrun simctl io <UDID> recordVideo`, or with your own screen recorder. Don't record unless asked.
|
|
112
|
+
- **Framed video** when asked: `scripts/frame_video.swift` (iPhone frame on white, ≥ 120 px margins, 60 fps).
|
|
113
|
+
- **Side-by-side with a reference:** `scripts/side_by_side.swift`.
|
|
114
|
+
- Gotchas that cost time before: `references/gotchas.md`. Read it once per build.
|
|
115
|
+
|
|
116
|
+
## 7. Keep this skill growing
|
|
117
|
+
|
|
118
|
+
After each app, add what was learned (new recipe, new gotcha, a measured constant) to the matching reference
|
|
119
|
+
file, written as general guidance, so the next build is faster.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import CoreText
|
|
3
|
+
|
|
4
|
+
@main
|
|
5
|
+
struct __APP__App: App {
|
|
6
|
+
init() {
|
|
7
|
+
Self.registerFonts()
|
|
8
|
+
_ = SceneBridge.shared // start the scene's web process now, in parallel with launch
|
|
9
|
+
Self.listenForReplay()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// Registers every font file bundled with the app (drop .ttf/.otf anywhere in the app folder).
|
|
13
|
+
/// Use the font's PostScript name in `.font(.custom(...))`.
|
|
14
|
+
static func registerFonts() {
|
|
15
|
+
for ext in ["ttf", "otf", "ttc"] {
|
|
16
|
+
for url in Bundle.main.urls(forResourcesWithExtension: ext, subdirectory: nil) ?? [] {
|
|
17
|
+
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// `xcrun simctl spawn booted notifyutil -p com.animatemyapp.replay` (or any tool posting that Darwin notification) replays the intro in place.
|
|
23
|
+
static func listenForReplay() {
|
|
24
|
+
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), nil, { _, _, _, _, _ in
|
|
25
|
+
DispatchQueue.main.async { NotificationCenter.default.post(name: .replayRequested, object: nil) }
|
|
26
|
+
}, "com.animatemyapp.replay" as CFString, nil, .deliverImmediately)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var body: some Scene {
|
|
30
|
+
WindowGroup {
|
|
31
|
+
ContentView()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
extension Notification.Name {
|
|
37
|
+
static let replayRequested = Notification.Name("com.animatemyapp.replay")
|
|
38
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// Laid out on the Figma canvas in points and scaled to the device. The three.js timeline is the single
|
|
4
|
+
/// source of truth: it sends cues ("headline", "button", …) and SwiftUI animates the native UI on them.
|
|
5
|
+
struct ContentView: View {
|
|
6
|
+
|
|
7
|
+
// Design canvas = the Figma frame (iPhone 17 Pro logical size).
|
|
8
|
+
private let CW: CGFloat = 402
|
|
9
|
+
private let CH: CGFloat = 874
|
|
10
|
+
|
|
11
|
+
private let bridge = SceneBridge.shared
|
|
12
|
+
@State private var headIn = false
|
|
13
|
+
@State private var btnIn = false
|
|
14
|
+
@State private var veil = false // content fades to white while a replay resets behind it
|
|
15
|
+
@State private var replaying = false
|
|
16
|
+
|
|
17
|
+
private let brand = Color(red: 8 / 255, green: 118 / 255, blue: 255 / 255)
|
|
18
|
+
private let ink = Color(red: 26 / 255, green: 26 / 255, blue: 26 / 255)
|
|
19
|
+
|
|
20
|
+
/// Primary action sits in the thumb zone: 16pt above the bottom safe area (Apple HIG), never in the
|
|
21
|
+
/// home-indicator area. Keep the designed spacing between headline and button.
|
|
22
|
+
private let buttonGapAboveSafeArea: CGFloat = 16
|
|
23
|
+
|
|
24
|
+
private var bottomSafeInset: CGFloat {
|
|
25
|
+
UIApplication.shared.connectedScenes
|
|
26
|
+
.compactMap { $0 as? UIWindowScene }
|
|
27
|
+
.flatMap { $0.windows }
|
|
28
|
+
.first { $0.isKeyWindow }?.safeAreaInsets.bottom ?? 34
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var body: some View {
|
|
32
|
+
GeometryReader { geo in
|
|
33
|
+
let s = min(geo.size.width / CW, geo.size.height / CH)
|
|
34
|
+
ZStack {
|
|
35
|
+
Color.white
|
|
36
|
+
|
|
37
|
+
SceneLayer(bridge: bridge)
|
|
38
|
+
.frame(width: CW, height: CH)
|
|
39
|
+
.allowsHitTesting(false)
|
|
40
|
+
.opacity(veil ? 0 : 1)
|
|
41
|
+
|
|
42
|
+
// tap anywhere (except the button) to replay; the web view would otherwise swallow touches
|
|
43
|
+
Color.clear
|
|
44
|
+
.contentShape(Rectangle())
|
|
45
|
+
.onTapGesture { replay() }
|
|
46
|
+
|
|
47
|
+
VStack(spacing: 32) {
|
|
48
|
+
Text("Replace with the design's headline")
|
|
49
|
+
.font(.system(size: 34, weight: .bold))
|
|
50
|
+
.multilineTextAlignment(.center)
|
|
51
|
+
.foregroundColor(ink)
|
|
52
|
+
.frame(width: 360)
|
|
53
|
+
.allowsHitTesting(false)
|
|
54
|
+
.opacity(headIn ? 1 : 0)
|
|
55
|
+
.offset(y: headIn ? 0 : 26)
|
|
56
|
+
|
|
57
|
+
Button {
|
|
58
|
+
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
59
|
+
} label: {
|
|
60
|
+
Text("Get started")
|
|
61
|
+
.font(.system(size: 16, weight: .bold))
|
|
62
|
+
.foregroundColor(.white)
|
|
63
|
+
.frame(width: 354, height: 58)
|
|
64
|
+
.background(Capsule().fill(brand))
|
|
65
|
+
}
|
|
66
|
+
.buttonStyle(PressScaleStyle())
|
|
67
|
+
.allowsHitTesting(btnIn)
|
|
68
|
+
.opacity(btnIn ? 1 : 0)
|
|
69
|
+
.offset(y: btnIn ? 0 : 26)
|
|
70
|
+
.scaleEffect(btnIn ? 1 : 0.9)
|
|
71
|
+
}
|
|
72
|
+
.padding(.bottom, bottomSafeInset / s + buttonGapAboveSafeArea)
|
|
73
|
+
.frame(width: CW, height: CH, alignment: .bottom)
|
|
74
|
+
.opacity(veil ? 0 : 1)
|
|
75
|
+
}
|
|
76
|
+
.frame(width: CW, height: CH)
|
|
77
|
+
.scaleEffect(s)
|
|
78
|
+
.position(x: geo.size.width / 2, y: geo.size.height / 2)
|
|
79
|
+
}
|
|
80
|
+
.ignoresSafeArea()
|
|
81
|
+
.background(Color.white.ignoresSafeArea())
|
|
82
|
+
.onReceive(NotificationCenter.default.publisher(for: .replayRequested)) { _ in replay() }
|
|
83
|
+
.onAppear {
|
|
84
|
+
bridge.onEvent = { event in
|
|
85
|
+
switch event {
|
|
86
|
+
case "headline": withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { headIn = true }
|
|
87
|
+
case "button": withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { btnIn = true }
|
|
88
|
+
case "reset" where replaying:
|
|
89
|
+
// the scene is blank again; lift the veil a beat later so no old frame can show
|
|
90
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
91
|
+
withTransaction(Transaction(animation: nil)) { veil = false }
|
|
92
|
+
replaying = false
|
|
93
|
+
}
|
|
94
|
+
default: break
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Restart the intro from its first frame: fade to white, reset behind the veil, start warm.
|
|
101
|
+
private func replay() {
|
|
102
|
+
guard !replaying else { return }
|
|
103
|
+
replaying = true
|
|
104
|
+
withAnimation(.easeOut(duration: 0.18)) { veil = true }
|
|
105
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
106
|
+
withTransaction(Transaction(animation: nil)) {
|
|
107
|
+
headIn = false
|
|
108
|
+
btnIn = false
|
|
109
|
+
}
|
|
110
|
+
bridge.replay()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Pressables acknowledge the touch immediately: a quick 0.97 scale-down, released with a soft spring.
|
|
116
|
+
private struct PressScaleStyle: ButtonStyle {
|
|
117
|
+
func makeBody(configuration: Configuration) -> some View {
|
|
118
|
+
configuration.label
|
|
119
|
+
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
|
120
|
+
.animation(configuration.isPressed ? .easeOut(duration: 0.1) : .spring(response: 0.3, dampingFraction: 0.6),
|
|
121
|
+
value: configuration.isPressed)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// Serves bundled files over `appbundle://` so every file (scripts, textures) is same-origin —
|
|
5
|
+
/// WebGL refuses textures loaded from `file://` URLs.
|
|
6
|
+
final class BundleSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
7
|
+
static let scheme = "appbundle"
|
|
8
|
+
|
|
9
|
+
func webView(_ webView: WKWebView, start task: WKURLSchemeTask) {
|
|
10
|
+
guard let url = task.request.url,
|
|
11
|
+
let file = Bundle.main.url(forResource: url.lastPathComponent, withExtension: nil),
|
|
12
|
+
let data = try? Data(contentsOf: file) else {
|
|
13
|
+
task.didFailWithError(URLError(.fileDoesNotExist))
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
let mime: String
|
|
17
|
+
switch file.pathExtension.lowercased() {
|
|
18
|
+
case "html": mime = "text/html; charset=utf-8"
|
|
19
|
+
case "js": mime = "text/javascript; charset=utf-8"
|
|
20
|
+
case "json": mime = "application/json"
|
|
21
|
+
case "png": mime = "image/png"
|
|
22
|
+
case "jpg", "jpeg": mime = "image/jpeg"
|
|
23
|
+
case "webp": mime = "image/webp"
|
|
24
|
+
case "glb": mime = "model/gltf-binary"
|
|
25
|
+
default: mime = "application/octet-stream"
|
|
26
|
+
}
|
|
27
|
+
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
|
|
28
|
+
headerFields: ["Content-Type": mime, "Content-Length": "\(data.count)",
|
|
29
|
+
"Cache-Control": "no-store"])! // never a stale script after a reinstall
|
|
30
|
+
task.didReceive(response)
|
|
31
|
+
task.didReceive(data)
|
|
32
|
+
task.didFinish()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Owns the WKWebView that renders the three.js scene and bridges its timeline cues to SwiftUI.
|
|
39
|
+
/// Created eagerly at app launch so the web process spins up in parallel with the app.
|
|
40
|
+
final class SceneBridge: NSObject, WKScriptMessageHandler {
|
|
41
|
+
static let shared = SceneBridge()
|
|
42
|
+
|
|
43
|
+
let webView: WKWebView
|
|
44
|
+
var onEvent: ((String) -> Void)?
|
|
45
|
+
|
|
46
|
+
private override init() {
|
|
47
|
+
let config = WKWebViewConfiguration()
|
|
48
|
+
config.websiteDataStore = .nonPersistent() // no disk cache between launches
|
|
49
|
+
config.setURLSchemeHandler(BundleSchemeHandler(), forURLScheme: BundleSchemeHandler.scheme)
|
|
50
|
+
webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 402, height: 874), configuration: config)
|
|
51
|
+
super.init()
|
|
52
|
+
config.userContentController.add(self, name: "scene")
|
|
53
|
+
|
|
54
|
+
webView.isOpaque = false
|
|
55
|
+
webView.backgroundColor = .clear
|
|
56
|
+
webView.scrollView.backgroundColor = .clear
|
|
57
|
+
webView.scrollView.isScrollEnabled = false
|
|
58
|
+
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
|
59
|
+
webView.isUserInteractionEnabled = false
|
|
60
|
+
#if DEBUG
|
|
61
|
+
if #available(iOS 16.4, *) { webView.isInspectable = true }
|
|
62
|
+
#endif
|
|
63
|
+
|
|
64
|
+
// look-dev / test flags: SIMCTL_CHILD_SCENE_QUERY="perf&debug" xcrun simctl launch booted <bundle id>
|
|
65
|
+
let query = ProcessInfo.processInfo.environment["SCENE_QUERY"].map { "?\($0)" } ?? ""
|
|
66
|
+
if let url = URL(string: "\(BundleSchemeHandler.scheme)://app/scene.html\(query)") {
|
|
67
|
+
webView.load(URLRequest(url: url))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
72
|
+
guard let event = message.body as? String else { return }
|
|
73
|
+
if event.hasPrefix("perf:") { NSLog("[scene] %@", event); return } // frame-time probe (?perf)
|
|
74
|
+
onEvent?(event)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func replay() {
|
|
78
|
+
webView.evaluateJavaScript("window.scenePlay && window.scenePlay()")
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Transparent, non-interactive layer that shows the three.js scene above the native background.
|
|
83
|
+
struct SceneLayer: UIViewRepresentable {
|
|
84
|
+
let bridge: SceneBridge
|
|
85
|
+
|
|
86
|
+
func makeUIView(context: Context) -> WKWebView { bridge.webView }
|
|
87
|
+
func updateUIView(_ webView: WKWebView, context: Context) {}
|
|
88
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Demo part — replace with the design's hero animation. Shows the patterns every part follows:
|
|
2
|
+
// state is a pure function of t (so replays are identical), physics via springs, volume-preserving squash,
|
|
3
|
+
// a contact shadow that reacts to height, and cues that bring in the native UI.
|
|
4
|
+
(function () {
|
|
5
|
+
'use strict';
|
|
6
|
+
const S = window.SCENE;
|
|
7
|
+
if (!S) return;
|
|
8
|
+
const { THREE, scene, toWorld, spring, k, REDUCED } = S;
|
|
9
|
+
|
|
10
|
+
// a soft clay pill (replace with procedural geometry built for the design)
|
|
11
|
+
const body = new THREE.Mesh(
|
|
12
|
+
new THREE.CapsuleGeometry(34 * k, 60 * k, 12, 32),
|
|
13
|
+
new THREE.MeshPhysicalMaterial({ color: 0xF6BE2C, roughness: 0.55, sheen: 0.35, sheenColor: 0xffffff }));
|
|
14
|
+
body.rotation.z = Math.PI / 2;
|
|
15
|
+
const pivot = new THREE.Group(); // pivot at the object's base, so squash stays grounded
|
|
16
|
+
body.position.y = 34 * k;
|
|
17
|
+
pivot.add(body);
|
|
18
|
+
scene.add(pivot);
|
|
19
|
+
|
|
20
|
+
// contact shadow: radial-gradient blob on the ground
|
|
21
|
+
const c = document.createElement('canvas'); c.width = c.height = 128;
|
|
22
|
+
const g = c.getContext('2d'), gr = g.createRadialGradient(64, 64, 0, 64, 64, 64);
|
|
23
|
+
gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(0.45, 'rgba(255,255,255,0.4)'); gr.addColorStop(1, 'rgba(255,255,255,0)');
|
|
24
|
+
g.fillStyle = gr; g.fillRect(0, 0, 128, 128);
|
|
25
|
+
const shadow = new THREE.Mesh(new THREE.PlaneGeometry(1, 1),
|
|
26
|
+
new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(c), color: 0x2a1e14, transparent: true, depthWrite: false, opacity: 0 }));
|
|
27
|
+
shadow.renderOrder = -1;
|
|
28
|
+
scene.add(shadow);
|
|
29
|
+
|
|
30
|
+
const GROUND = toWorld(201, 470), T0 = 80, FALL = 0.32, DROP = 260 * k;
|
|
31
|
+
S.track((t) => {
|
|
32
|
+
const s = (t - T0) / 1000;
|
|
33
|
+
if (s < 0) { pivot.visible = shadow.visible = false; return; }
|
|
34
|
+
pivot.visible = shadow.visible = true;
|
|
35
|
+
let y = 0, sy = 1;
|
|
36
|
+
if (REDUCED) { y = 0; }
|
|
37
|
+
else if (s < FALL) { const u = s / FALL; y = DROP * (1 - u * u); sy = 1 + 0.18 * u; } // gravity, stretched by speed
|
|
38
|
+
else { const tl = s - FALL; y = spring(0, 260 * k, 0.5, 0.32, tl); sy = 1 + spring(-0.22, -8, 0.42, 0.3, tl); } // squash + hop
|
|
39
|
+
const sx = 1 / Math.sqrt(Math.max(sy, 0.3)); // keep the clay's volume
|
|
40
|
+
pivot.position.set(GROUND.x, GROUND.y + Math.max(0, y), 0);
|
|
41
|
+
pivot.scale.set(sx, sy, sx);
|
|
42
|
+
const h = Math.max(0, y) / (100 * k);
|
|
43
|
+
shadow.position.set(GROUND.x, GROUND.y - 2 * k, -10);
|
|
44
|
+
shadow.scale.set(150 * k * sx * (1 + 0.4 * h), 34 * k * (1 + 0.3 * h), 1);
|
|
45
|
+
shadow.material.opacity = 0.3 * Math.exp(-1.2 * h) * Math.min(1, s / 0.1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// native UI comes in after the hero has landed (staging gated on arrival)
|
|
49
|
+
S.cue(T0 + FALL * 1000 + 450, 'headline');
|
|
50
|
+
S.cue(T0 + FALL * 1000 + 600, 'button');
|
|
51
|
+
|
|
52
|
+
// Textures for real parts: const tex = S.loadTexture('my-art.png'); (on the GPU before the clock starts)
|
|
53
|
+
})();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
|
6
|
+
<style>
|
|
7
|
+
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
|
|
8
|
+
canvas { display: block; }
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<script>
|
|
13
|
+
// ?debug shows script errors on screen (look-dev in the simulator's Safari inspector also works)
|
|
14
|
+
if (location.search.indexOf('debug') !== -1) {
|
|
15
|
+
window.onerror = function (m, s, l, c, e) {
|
|
16
|
+
var p = document.createElement('pre');
|
|
17
|
+
p.style.cssText = 'position:fixed;top:60px;left:8px;right:8px;font:11px monospace;color:#c00;white-space:pre-wrap;z-index:9';
|
|
18
|
+
p.textContent = m + ' @' + l + ':' + c + '\n' + (e && e.stack || '');
|
|
19
|
+
document.body.appendChild(p);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
<script src="three.min.js"></script>
|
|
24
|
+
<!-- engine first, then one script per animated part -->
|
|
25
|
+
<script src="scene.js"></script>
|
|
26
|
+
<script src="content.js"></script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|