Add Fit Check outfit-scoring demo

#1
by iamleonie - opened
README.md CHANGED
@@ -8,13 +8,13 @@ app_port: 7860
8
  suggested_hardware: l4x1
9
  models:
10
  - LiquidAI/d1-3B
11
- short_description: Ten camera, drawing and text demos of d1-3B
12
  pinned: false
13
  ---
14
 
15
  # System One Arcade
16
 
17
- Ten demos of `d1-3B`. Each one asks the model a few typed questions about a camera frame,
18
  a drawing or a message, and reads the answers from one forward pass.
19
 
20
  | demo | what it shows |
@@ -28,6 +28,7 @@ a drawing or a message, and reads the answers from one forward pass.
28
  | Red Light, Green Light | move on green, freeze on red |
29
  | Cowboy Duel | two players, first finger gun wins |
30
  | Live Triage | six answers about a support message as you type |
 
31
  | Ask Anything | your own yes/no questions about the camera image |
32
 
33
  Camera frames go to the model on this Space's GPU and are not stored.
 
8
  suggested_hardware: l4x1
9
  models:
10
  - LiquidAI/d1-3B
11
+ short_description: Eleven camera, drawing and text demos of d1-3B
12
  pinned: false
13
  ---
14
 
15
  # System One Arcade
16
 
17
+ Eleven demos of `d1-3B`. Each one asks the model a few typed questions about a camera frame,
18
  a drawing or a message, and reads the answers from one forward pass.
19
 
20
  | demo | what it shows |
 
28
  | Red Light, Green Light | move on green, freeze on red |
29
  | Cowboy Duel | two players, first finger gun wins |
30
  | Live Triage | six answers about a support message as you type |
31
+ | Fit Check | live outfit score with purikura-style doodles |
32
  | Ask Anything | your own yes/no questions about the camera image |
33
 
34
  Camera frames go to the model on this Space's GPU and are not stored.
game/web/app.js CHANGED
@@ -15,9 +15,10 @@ import draw from "./draw.js";
15
  import redlight from "./redlight.js";
16
  import duel from "./duel.js";
17
  import triage from "./triage.js";
 
18
  import ask from "./ask.js";
19
 
20
- const GAMES = [flappy, pong, reps, clean, hunt, draw, redlight, duel, triage, ask];
21
 
22
  const $ = (id) => document.getElementById(id);
23
  const video = $("cam");
 
15
  import redlight from "./redlight.js";
16
  import duel from "./duel.js";
17
  import triage from "./triage.js";
18
+ import fitcheck from "./fitcheck.js";
19
  import ask from "./ask.js";
20
 
21
+ const GAMES = [flappy, pong, reps, clean, hunt, draw, redlight, duel, triage, fitcheck, ask];
22
 
23
  const $ = (id) => document.getElementById(id);
24
  const video = $("cam");
game/web/assets/liquid-mark.svg ADDED
game/web/assets/puri-lace.svg ADDED
game/web/assets/puri-opening.svg ADDED
game/web/fitcheck.js ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Fit Check: one outfit-rating question fills the live meter.
2
+
3
+ import { el } from "./ui.js";
4
+
5
+ const OUTFIT = {
6
+ "text": "How successful is the visible outfit as a styled look to go out? Judge the whole combination, including shoes, accessories, and any piece that undermines it. Do not score the person's attractiveness, body, pose, or surroundings. Use only what is visible, without imagining hidden pieces or intent. Judge the assembled outfit, not the appeal of individual garments. Use the lowest level when there is no coherent styling idea visible or a conspicuous conflict makes the whole look unsuccessful. Comfortable clothes are not automatically low-scoring; the distinction is whether their shape, pairing, footwear, and details create a convincing look. Do not let one nice or formal item rescue a broken combination.\nBefore scoring above the lowest level, look for something that improves the whole outfit: considered proportions, a coherent silhouette, complementary pieces, or effective finishing. Being wearable, clean, or loosely color-coordinated is not enough. If no outfit-level styling is visible, or one conspicuous conflict cancels it, use the lowest level. Conversely, reward simple outfits when their shape and pieces genuinely work together.",
7
+ "levels": [
8
+ "0–20, lounge or active wear. anything you'd wear to a gym or at home on the sofa.",
9
+ "20–40, casual outfit.",
10
+ "40–60, outfit you can leave the house in Europe with (does not include lounge wear or active wear) pieces are worn but not styled. does not include lounge wear or gym wear. not dressed for comfort. not accessorized",
11
+ "60–80, coordinated outfit: Appealing and coherent, but less distinctive or less fully resolved as a whole. includes some intentional accessories, such as belts or jewelry. interesting combination or clothing pieces.",
12
+ "80–100, A distinctive, convincingly styled outfit with a clear visual point of view. Several visible choices—silhouette, proportion, layering, color or texture contrast, accessories, and footwear—reinforce the same aesthetic. Playful or unconventional combinations qualify when they feel composed rather than accidental. The result is memorable and well resolved in its own style; it need not be formal, expensive, minimal, or flawless. Combination of interesting patterns, accessories, color-coordinated pieces.dressier pieces of clothing including leather shoes-"
13
+ ]
14
+ };
15
+ const FRAME_SIDE = 512;
16
+
17
+ // One global, monotonic display transform; no image- or garment-specific rules.
18
+ const SCORE_STRETCH = Object.freeze({ rawLow: 21, shownLow: 5, rawHigh: 75, shownHigh: 90 });
19
+
20
+ function outfitScore(probs) {
21
+ const rawScore = 100 * probs.reduce((sum, probability, index) => sum + index * probability, 0) / (probs.length - 1);
22
+ const { rawLow, shownLow, rawHigh, shownHigh } = SCORE_STRETCH;
23
+ const shown = shownLow + (rawScore - rawLow) * (shownHigh - shownLow) / (rawHigh - rawLow);
24
+ return Math.max(0, Math.min(100, shown));
25
+ }
26
+
27
+ const SMOOTH = 0.18;
28
+ const RAIN_ENTER_AT = 35;
29
+ const RAIN_EXIT_AT = 48;
30
+ const RAIN_DWELL_MS = 3000;
31
+ const MOODS = [
32
+ { at: 40, name: "low", stickers: ["ぽつぽつ", "☂", "☁", "涙"] },
33
+ { at: 65, name: "mid", stickers: ["いいかも♡", "♪", "♡", "☆"] },
34
+ { at: 85, name: "cute", stickers: ["キラキラ☆", "かわいい♡", "きゅん", "✧", "☆"] },
35
+ { at: 101, name: "sparkle", stickers: ["ちょーかわいい♡", "キラキラMAX", "最高♡", "✦", "♡♡", "☆☆"] },
36
+ ];
37
+ const DOODLES = ["♡", "☆", "✧", "♡♡", "★", "♪", "✦", "キラッ"];
38
+ const FOIL_INKS = ["#ee55cb", "#7be7ff", "#c590ff", "#fff174", "#8bf0c5", "#ff9d75"];
39
+
40
+ let alive = false;
41
+
42
+ export default {
43
+ id: "fitcheck",
44
+ title: "Fit Check",
45
+ blurb: "Get a live outfit score with purikura-style doodles.",
46
+ tags: ["Outfits", "Camera", "Live score"],
47
+ camera: true,
48
+
49
+ start({ panel, view, stream }) {
50
+ alive = true;
51
+ document.documentElement.lang = "ja";
52
+ document.body.classList.add("fitcheck-page");
53
+ const stage = view.closest(".stage");
54
+ stage?.classList.add("fit-stage");
55
+ view.classList.add("fitcheck");
56
+ panel.classList.add("fit-panel");
57
+ view.append(el("div", { class: "fit-weather", "aria-hidden": "true" }));
58
+ view.append(el("img", { class: "puri-lace", src: "assets/puri-lace.svg", alt: "", "aria-hidden": "true" }));
59
+ view.append(el("div", { class: "purikura-decor", "aria-hidden": "true" },
60
+ el("span", { class: "puri-title" }, "今日のコーデ♡"),
61
+ el("span", { class: "puri-model" }, "decision model ♡"),
62
+ el("span", { class: "puri-date" }, new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "numeric", day: "numeric" }).format(new Date())),
63
+ el("img", { class: "puri-logo puri-logo-bottom", src: "assets/liquid-mark.svg", alt: "" }),
64
+ el("img", { class: "puri-logo puri-logo-top", src: "assets/liquid-mark.svg", alt: "" }),
65
+ el("img", { class: "puri-logo puri-logo-side", src: "assets/liquid-mark.svg", alt: "" }),
66
+ el("span", { class: "puri-frame-heart" }, "♥"),
67
+ el("span", { class: "puri-frame-star" }, "☆"),
68
+ el("span", { class: "puri-doodle heart-one" }, "♡"),
69
+ el("span", { class: "puri-doodle heart-two" }, "♡"),
70
+ el("span", { class: "puri-doodle heart-three" }, "♥"),
71
+ el("span", { class: "puri-doodle heart-four" }, "♡"),
72
+ el("span", { class: "puri-doodle heart-five" }, "♥"),
73
+ el("span", { class: "puri-doodle heart-six" }, "♡"),
74
+ el("span", { class: "puri-doodle heart-seven" }, "♡"),
75
+ el("span", { class: "puri-doodle heart-eight" }, "♥"),
76
+ el("span", { class: "puri-doodle star-one" }, "☆"),
77
+ el("span", { class: "puri-doodle star-two" }, "✧"),
78
+ el("span", { class: "puri-doodle star-three" }, "☆"),
79
+ el("span", { class: "puri-doodle star-four" }, "✦"),
80
+ el("span", { class: "puri-doodle star-five" }, "✧"),
81
+ el("span", { class: "puri-doodle star-six" }, "☆"),
82
+ el("span", { class: "puri-doodle star-seven" }, "✦"),
83
+ el("span", { class: "puri-doodle star-eight" }, "☆"),
84
+ el("span", { class: "puri-doodle spark-one" }, "✦"),
85
+ el("span", { class: "puri-doodle spark-two" }, "✧"),
86
+ el("span", { class: "puri-doodle spark-three" }, "✦"),
87
+ el("span", { class: "puri-doodle spark-four" }, "✧"),
88
+ el("span", { class: "puri-doodle spark-five" }, "✧"),
89
+ el("span", { class: "puri-doodle spark-six" }, "✦"),
90
+ el("span", { class: "puri-doodle spark-seven" }, "✧"),
91
+ el("span", { class: "puri-doodle spark-eight" }, "✦")));
92
+ const stickers = el("div", { class: "sticker-field", "aria-hidden": "true" });
93
+ view.append(stickers);
94
+
95
+ const fill = el("i");
96
+ const percent = el("div", { class: "fit-score", "aria-live": "off" }, "–");
97
+ const meter = el("div", { class: "fit-meter", role: "progressbar", "aria-label": "Outfit score", "aria-valuemin": "0", "aria-valuemax": "100", "aria-valuenow": "0" }, fill);
98
+ const privacy = el("p", { class: "note" },
99
+ "Camera frames are sent to the model for scoring and are not saved on this page.");
100
+ panel.append(
101
+ el("div", { class: "fit-panel-main" },
102
+ el("div", { class: "fit-score-row" },
103
+ el("div", { class: "fit-panel-heading" }, el("strong", {}, "♡ コーデのかわいさ"), el("small", {}, "How cute is your outfit today?")),
104
+ percent),
105
+ meter),
106
+ el("div", { class: "fit-panel-foot" },
107
+ el("p", { class: "fit-disclaimer" }, "A playful AI outfit rating, not a probability or a judgment of you."), privacy));
108
+
109
+ let shown = null;
110
+ let lastText = null;
111
+ let lastSticker = -Infinity;
112
+ let lowSince = null;
113
+ let rainActive = false;
114
+ stream.start({ questions: () => [OUTFIT], side: FRAME_SIDE, inflight: 2 }, (answer) => {
115
+ if (!alive) return;
116
+ const raw = outfitScore(answer.probs[0]);
117
+ shown = shown === null ? raw : shown + SMOOTH * (raw - shown);
118
+ const score = Math.round(shown);
119
+ const mood = MOODS.find((m) => score < m.at) ?? MOODS.at(-1);
120
+ const now = performance.now();
121
+ if (raw >= RAIN_EXIT_AT) {
122
+ if (rainActive) stickers.replaceChildren();
123
+ lowSince = null;
124
+ rainActive = false;
125
+ } else if (raw < RAIN_ENTER_AT) {
126
+ lowSince ??= now;
127
+ if (now - lowSince >= RAIN_DWELL_MS) rainActive = true;
128
+ } else if (!rainActive) {
129
+ lowSince = null;
130
+ }
131
+ const visualMood = mood.name === "low" && !rainActive ? null : mood;
132
+
133
+ fill.style.width = `${score}%`;
134
+ meter.setAttribute("aria-valuenow", String(score));
135
+ if (lastText === null || Math.abs(score - lastText) >= 1) {
136
+ lastText = score;
137
+ percent.textContent = `${score}%`;
138
+ }
139
+ view.dataset.mood = visualMood?.name ?? "";
140
+ panel.dataset.mood = visualMood?.name ?? "";
141
+ if (visualMood) maybeSticker(stickers, visualMood, score, lastSticker, (t) => { lastSticker = t; });
142
+ });
143
+ },
144
+
145
+ stop() {
146
+ alive = false;
147
+ document.documentElement.lang = "en";
148
+ document.body.classList.remove("fitcheck-page");
149
+ const view = document.getElementById("view");
150
+ const panel = document.getElementById("panel");
151
+ view?.classList.remove("fitcheck");
152
+ panel?.classList.remove("fit-panel");
153
+ view?.removeAttribute("data-mood");
154
+ panel?.removeAttribute("data-mood");
155
+ const stage = document.querySelector(".stage");
156
+ stage?.classList.remove("fit-stage");
157
+ },
158
+ };
159
+
160
+ function maybeSticker(layer, mood, score, last, setLast) {
161
+ const now = performance.now();
162
+ const every = score < 40 ? 680 : score < 65 ? 740 : score < 85 ? 900 : 700;
163
+ if (now - last < every) return;
164
+ setLast(now);
165
+ const decorated = score >= 65;
166
+ const pool = decorated && Math.random() < 0.82 ? DOODLES : mood.stickers;
167
+ const word = pool[Math.floor(Math.random() * pool.length)];
168
+ const kind = /^[♡☆✧★✦♪☂☁]+$/.test(word) ? "doodle" : "word";
169
+ const sticker = el("span", { class: `fit-sticker ${kind} ${mood.name}${decorated ? " edge" : ""}` }, word);
170
+ if (decorated) {
171
+ // Let the live rakugaki appear around the perimeter, leaving the person clear.
172
+ const edge = Math.floor(Math.random() * 4);
173
+ const x = edge === 0 ? 10 + Math.random() * 80 : edge === 1 ? 10 + Math.random() * 80 : edge === 2 ? 8 + Math.random() * 7 : 85 + Math.random() * 7;
174
+ const y = edge === 0 ? 11 + Math.random() * 8 : edge === 1 ? 81 + Math.random() * 8 : 21 + Math.random() * 58;
175
+ sticker.style.left = `${x}%`;
176
+ sticker.style.top = `${y}%`;
177
+ } else {
178
+ sticker.style.left = `${9 + Math.random() * 82}%`;
179
+ sticker.style.top = `${13 + Math.random() * 72}%`;
180
+ }
181
+ sticker.style.setProperty("--drift", `${-18 + Math.random() * 36}px`);
182
+ sticker.style.setProperty("--rise", `${24 + Math.random() * 34}px`);
183
+ sticker.style.setProperty("--spin", `${-18 + Math.random() * 36}deg`);
184
+ sticker.style.setProperty("--delay", `${Math.random() * 120}ms`);
185
+ if (decorated) sticker.style.setProperty("--sticker-ink", FOIL_INKS[Math.floor(Math.random() * FOIL_INKS.length)]);
186
+ layer.append(sticker);
187
+ const max = decorated ? 6 : 10;
188
+ while (layer.children.length > max) layer.firstElementChild.remove();
189
+ sticker.addEventListener("animationend", () => sticker.remove(), { once: true });
190
+ }
game/web/index.html CHANGED
@@ -4,11 +4,11 @@
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>System One Arcade</title>
7
- <meta name="description" content="Ten demos of d1-3B.">
8
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='10' fill='%23cd82f0'/%3E%3C/svg%3E">
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=JetBrains+Mono:wght@300..600&display=swap">
12
  <link rel="stylesheet" href="style.css">
13
  </head>
14
  <body>
@@ -28,7 +28,7 @@
28
  <div class="orb"></div>
29
  <div class="eyebrow">d1-3B</div>
30
  <h1>System One Arcade</h1>
31
- <p>Ten demos of a 3B vision-language model. Each one asks the model a few typed questions about a
32
  camera frame, a drawing or a message, and reads the answers from one forward pass.</p>
33
  <button class="btn" data-local>Run it locally</button>
34
  </div>
 
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>System One Arcade</title>
7
+ <meta name="description" content="Eleven demos of d1-3B.">
8
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='10' fill='%23cd82f0'/%3E%3C/svg%3E">
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=JetBrains+Mono:wght@300..600&family=Yomogi&display=swap">
12
  <link rel="stylesheet" href="style.css">
13
  </head>
14
  <body>
 
28
  <div class="orb"></div>
29
  <div class="eyebrow">d1-3B</div>
30
  <h1>System One Arcade</h1>
31
+ <p>Eleven demos of a 3B vision-language model. Each one asks the model a few typed questions about a
32
  camera frame, a drawing or a message, and reads the answers from one forward pass.</p>
33
  <button class="btn" data-local>Run it locally</button>
34
  </div>
game/web/style.css CHANGED
@@ -166,6 +166,311 @@ button { font: inherit; color: inherit; cursor: pointer; }
166
  .prob.top .name, .prob.top .value { color: var(--ink); }
167
  .prob.top .fill { background: linear-gradient(90deg, var(--violet), var(--purple)); }
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  /* game surfaces */
170
  .surface { display: block; width: 100%; height: auto; }
171
  .view .surface { max-height: calc(100vh - 124px); object-fit: contain; }
 
166
  .prob.top .name, .prob.top .value { color: var(--ink); }
167
  .prob.top .fill { background: linear-gradient(90deg, var(--violet), var(--purple)); }
168
 
169
+ /* Fit Check: a single purple purikura photo frame */
170
+ .stage.fit-stage {
171
+ width: 100%;
172
+ max-width: 1300px;
173
+ margin: 0 auto;
174
+ grid-template-columns: minmax(0, 1fr);
175
+ gap: 12px;
176
+ --puri: #8700f5;
177
+ --puri-dark: #3f0079;
178
+ --puri-mid: #6d00d8;
179
+ --puri-light: #ad62fe;
180
+ --puri-lilac: #c253f5;
181
+ --puri-pale: #f7f0ff;
182
+ }
183
+ .view.fitcheck {
184
+ isolation: isolate;
185
+ padding: 62px 54px 58px;
186
+ border: 0;
187
+ border-radius: 24px;
188
+ background:
189
+ radial-gradient(circle, #ffffffbd 0 1.5px, transparent 2.5px) 0 0 / 20px 20px,
190
+ radial-gradient(circle, #f8dcffb8 0 2px, transparent 3px) 9px 12px / 39px 39px,
191
+ repeating-linear-gradient(135deg, #af61de 0 15px, #c47fe9 15px 30px, #d89bed 30px 45px, #aa5bd9 45px 60px);
192
+ box-shadow: 0 14px 28px #3f00792e;
193
+ }
194
+ .view.fitcheck video {
195
+ position: relative;
196
+ display: block;
197
+ width: 100%;
198
+ aspect-ratio: 16 / 9;
199
+ max-height: none;
200
+ object-fit: contain;
201
+ background: #211132;
202
+ border: 0;
203
+ border-radius: 0;
204
+ box-shadow: none;
205
+ -webkit-mask: url("assets/puri-opening.svg") center / 100% 100% no-repeat;
206
+ mask: url("assets/puri-opening.svg") center / 100% 100% no-repeat;
207
+ }
208
+ .view.fitcheck .camera-hint {
209
+ z-index: 5;
210
+ inset: 35% 22%;
211
+ min-height: 74px;
212
+ padding: 16px;
213
+ border: 3px solid var(--puri-light);
214
+ border-radius: 6px;
215
+ background: #f7f0ffee;
216
+ color: var(--puri-dark);
217
+ font: 800 13px/1.6 "Hiragino Maru Gothic ProN", "Yu Gothic", "Meiryo", sans-serif;
218
+ box-shadow: 4px 5px 0 var(--puri);
219
+ }
220
+ .fit-weather, .purikura-decor, .sticker-field {
221
+ position: absolute;
222
+ inset: 0;
223
+ overflow: hidden;
224
+ pointer-events: none;
225
+ }
226
+ .fit-weather {
227
+ z-index: 1;
228
+ inset: 62px 54px 58px;
229
+ opacity: 0;
230
+ transition: opacity .3s ease;
231
+ -webkit-mask: url("assets/puri-opening.svg") center / 100% 100% no-repeat;
232
+ mask: url("assets/puri-opening.svg") center / 100% 100% no-repeat;
233
+ }
234
+ .fit-weather::before, .fit-weather::after { content: ""; position: absolute; inset: 0; }
235
+ .view.fitcheck[data-mood="low"] .fit-weather { opacity: .68; }
236
+ .view.fitcheck[data-mood="low"] .fit-weather::before {
237
+ z-index: 1;
238
+ background:
239
+ repeating-linear-gradient(105deg, transparent 0 27px, #bda7ed85 28px 29px, transparent 30px 67px),
240
+ repeating-linear-gradient(105deg, transparent 0 52px, #ffffff8a 53px 54px, transparent 55px 109px);
241
+ background-size: 90px 120px, 150px 160px;
242
+ animation: puri-rain .45s linear infinite;
243
+ }
244
+ .view.fitcheck[data-mood="low"] .fit-weather::after {
245
+ background: linear-gradient(#44345c1c, #38204e0e 45%, #35254e2b);
246
+ }
247
+ .view.fitcheck[data-mood="cute"] .fit-weather,
248
+ .view.fitcheck[data-mood="sparkle"] .fit-weather { opacity: 1; }
249
+ .view.fitcheck[data-mood="cute"] .fit-weather::before,
250
+ .view.fitcheck[data-mood="sparkle"] .fit-weather::before {
251
+ background:
252
+ radial-gradient(circle at 12% 23%, #f8c4e9 0 3px, transparent 4px),
253
+ radial-gradient(circle at 27% 72%, #b9eaff 0 2px, transparent 3px),
254
+ radial-gradient(circle at 56% 20%, #fff 0 4px, transparent 5px),
255
+ radial-gradient(circle at 78% 61%, #fff0b0 0 2px, transparent 3px),
256
+ radial-gradient(circle at 89% 31%, #d9bdff 0 3px, transparent 4px);
257
+ background-size: 280px 210px;
258
+ animation: puri-twinkle .7s steps(2, end) infinite;
259
+ }
260
+ .view.fitcheck[data-mood="sparkle"] .fit-weather::after {
261
+ background: linear-gradient(105deg, transparent 30%, #ffffff38 49%, transparent 60%);
262
+ background-size: 180px 100%;
263
+ animation: puri-sweep 1.4s linear infinite;
264
+ mix-blend-mode: screen;
265
+ }
266
+ .puri-lace {
267
+ position: absolute;
268
+ z-index: 2;
269
+ top: 62px;
270
+ left: 54px;
271
+ width: calc(100% - 108px);
272
+ height: calc(100% - 120px);
273
+ pointer-events: none;
274
+ }
275
+ .purikura-decor { z-index: 3; font-family: "Hiragino Maru Gothic ProN", "Yu Gothic", "Meiryo", sans-serif; }
276
+ .sticker-field { z-index: 4; }
277
+ .puri-title {
278
+ position: absolute;
279
+ top: -4px;
280
+ left: 72px;
281
+ transform: rotate(-5deg);
282
+ white-space: nowrap;
283
+ color: #f5c2ff;
284
+ font-size: clamp(48px, 5.7vw, 78px);
285
+ font-family: "Yomogi", "Klee One", "Hiragino Maru Gothic ProN", sans-serif;
286
+ font-weight: 900;
287
+ letter-spacing: .02em;
288
+ -webkit-text-stroke: 5px #7014b4;
289
+ paint-order: stroke fill;
290
+ text-shadow: -2px -2px 0 #fff, 3px 4px 0 #fff, 6px 7px 0 #54217e, 0 0 13px #fff;
291
+ }
292
+ .puri-logo {
293
+ position: absolute;
294
+ object-fit: contain;
295
+ filter: drop-shadow(0 0 3px #fff) drop-shadow(0 0 8px #f1b9ff) drop-shadow(2px 3px 0 #682595);
296
+ }
297
+ .puri-logo-bottom { left: 23px; bottom: 17px; width: 63px; height: 79px; transform: rotate(-10deg); }
298
+ .puri-logo-top { top: 19px; left: 64%; width: 50px; height: 68px; transform: rotate(12deg); }
299
+ .puri-logo-side { top: 69%; left: 25px; width: 50px; height: 67px; transform: rotate(-13deg); }
300
+ .puri-frame-heart, .puri-frame-star {
301
+ position: absolute;
302
+ line-height: 1;
303
+ font-family: Georgia, serif;
304
+ -webkit-text-stroke: 2px #fff;
305
+ paint-order: stroke fill;
306
+ text-shadow: 0 0 5px #fff, 0 0 11px #e9b2ff, 3px 3px 0 #7742a7;
307
+ }
308
+ .puri-frame-heart { top: 15px; right: 59px; color: #e6b8f9; font-size: 83px; transform: rotate(16deg); }
309
+ .puri-frame-star { bottom: 11px; left: 43%; color: #c5dafa; font-size: 81px; transform: rotate(-11deg); }
310
+ .puri-model {
311
+ position: absolute;
312
+ top: 83px;
313
+ left: 180px;
314
+ transform: rotate(-6deg);
315
+ white-space: nowrap;
316
+ color: #fff4ff;
317
+ font: 900 clamp(32px, 3.5vw, 48px)/1 "Yomogi", "Klee One", "Hiragino Maru Gothic ProN", sans-serif;
318
+ -webkit-text-stroke: 5px #b84ae7;
319
+ paint-order: stroke fill;
320
+ text-shadow: 3px 4px 0 #5a2389, 0 0 8px #fff, 0 0 18px #dc8eff;
321
+ }
322
+ .puri-date {
323
+ position: absolute;
324
+ right: 40px;
325
+ bottom: 12px;
326
+ transform: rotate(-5deg);
327
+ color: #45445b;
328
+ font: 900 clamp(22px, 2.9vw, 34px)/1 "Yomogi", "Klee One", "Hiragino Maru Gothic ProN", sans-serif;
329
+ letter-spacing: -.08em;
330
+ -webkit-text-stroke: 4px #fff;
331
+ paint-order: stroke fill;
332
+ text-shadow: 2px 2px 0 #d6b3fa;
333
+ }
334
+ .puri-doodle {
335
+ position: absolute;
336
+ line-height: 1;
337
+ font-weight: 900;
338
+ scale: 1.16;
339
+ -webkit-text-stroke: 3px white;
340
+ paint-order: stroke fill;
341
+ text-shadow: 0 0 5px white, 0 0 12px #ffc4fa, 2px 3px 0 #8e4bc5;
342
+ }
343
+ .puri-doodle { font-family: Georgia, serif; }
344
+ .heart-one { top: 21%; left: 25px; color: #f9b9dc; font-size: 79px; transform: rotate(-20deg); }
345
+ .heart-two { right: 24px; bottom: 14%; color: #bce8f7; font-size: 80px; transform: rotate(18deg); }
346
+ .heart-three { top: 26px; left: 56%; color: #ffd3ed; font-size: 55px; transform: rotate(12deg); }
347
+ .heart-four { top: 10%; right: 21px; color: #fbd6fa; font-size: 58px; transform: rotate(-17deg); }
348
+ .heart-five { top: 36%; left: 17px; color: #f1c2fc; font-size: 62px; transform: rotate(15deg); }
349
+ .heart-six { top: 40%; right: 15px; color: #d6bdfb; font-size: 64px; transform: rotate(-13deg); }
350
+ .heart-seven { bottom: 13px; left: 17%; color: #ffc6df; font-size: 57px; transform: rotate(15deg); }
351
+ .heart-eight { bottom: 18px; right: 18%; color: #d9c2ff; font-size: 54px; transform: rotate(-17deg); }
352
+ .star-one { bottom: 20px; left: 28%; color: #e8b9f7; font-size: 77px; transform: rotate(-14deg); }
353
+ .star-two { top: 27%; right: 22px; color: #fff2b0; font-size: 74px; transform: rotate(12deg); }
354
+ .star-three { right: 27%; bottom: 20px; color: #c9eaff; font-size: 72px; transform: rotate(9deg); }
355
+ .star-four { top: 12%; left: 24px; color: #c9eaff; font-size: 57px; transform: rotate(-9deg); }
356
+ .star-five { top: 60%; left: 19px; color: #c0e4ff; font-size: 59px; transform: rotate(13deg); }
357
+ .star-six { top: 61%; right: 16px; color: #f9c6f3; font-size: 62px; transform: rotate(-9deg); }
358
+ .star-seven { top: 13px; right: 23%; color: #fff0b5; font-size: 52px; transform: rotate(12deg); }
359
+ .star-eight { bottom: 16px; left: 62%; color: #e6c5ff; font-size: 54px; transform: rotate(-12deg); }
360
+ .spark-one { top: 48%; left: 30px; color: #d9d3ff; font-size: 56px; transform: rotate(15deg); }
361
+ .spark-two { right: 29px; bottom: 43%; color: #efc4fa; font-size: 60px; transform: rotate(-12deg); }
362
+ .spark-three { right: 25px; top: 72%; color: #fff0bb; font-size: 55px; transform: rotate(15deg); }
363
+ .spark-four { top: 25px; left: 46%; color: #fff2c9; font-size: 49px; transform: rotate(-10deg); }
364
+ .spark-five { top: 30%; left: 16px; color: #ffe7ac; font-size: 45px; transform: rotate(-12deg); }
365
+ .spark-six { bottom: 27%; left: 15px; color: #ffe7fc; font-size: 51px; transform: rotate(10deg); }
366
+ .spark-seven { top: 20%; right: 16px; color: #bbf1ff; font-size: 48px; transform: rotate(14deg); }
367
+ .spark-eight { bottom: 13px; right: 35%; color: #fff0b5; font-size: 48px; transform: rotate(-9deg); }
368
+ .view.fitcheck[data-mood="low"] .puri-doodle { color: #abb6d8; text-shadow: 0 0 5px #fff, 2px 3px 0 #7368a3; }
369
+ .fit-sticker {
370
+ position: absolute;
371
+ color: var(--sticker-ink, #c590ff);
372
+ font: 900 clamp(26px, 3.5vw, 46px)/1.2 "Yomogi", "Klee One", "Hiragino Maru Gothic ProN", sans-serif;
373
+ white-space: nowrap;
374
+ -webkit-text-stroke: 2.5px #271139;
375
+ paint-order: stroke fill;
376
+ text-shadow: 0 0 3px #fff, 0 0 11px currentColor, 0 0 22px currentColor;
377
+ animation: sticker-pop 1.35s cubic-bezier(.2, 1, .4, 1) var(--delay) forwards;
378
+ }
379
+ .fit-sticker.doodle {
380
+ font: 900 clamp(55px, 7vw, 105px)/1 Georgia, serif;
381
+ -webkit-text-stroke: 3px #271139;
382
+ }
383
+ .fit-sticker.edge { font-size: clamp(20px, 2.5vw, 34px); -webkit-text-stroke-width: 2px; }
384
+ .fit-sticker.edge.doodle { font-size: clamp(37px, 4.5vw, 67px); -webkit-text-stroke-width: 2px; }
385
+ .fit-sticker.low { color: #a6b5ff; }
386
+ .fit-sticker.sparkle { color: var(--sticker-ink, #ee55cb); }
387
+ @keyframes sticker-pop {
388
+ 0% { opacity: 0; transform: translate(-50%, -40%) scale(.55) rotate(var(--spin)); }
389
+ 13% { opacity: 1; transform: translate(-50%, -50%) scale(1.24) rotate(var(--spin)); }
390
+ 55% { opacity: 1; transform: translate(-50%, -55%) scale(1) rotate(var(--spin)); }
391
+ 100% { opacity: 0; transform: translate(calc(-50% + var(--drift)), calc(-50% - var(--rise))) scale(.76) rotate(var(--spin)); }
392
+ }
393
+ @keyframes puri-rain { from { background-position: 0 -120px, 0 -160px; } to { background-position: -90px 0, -150px 0; } }
394
+ @keyframes puri-twinkle { 0%, 100% { opacity: .3; filter: brightness(1); } 50% { opacity: 1; filter: brightness(1.8); } }
395
+ @keyframes puri-sweep { from { background-position: -180px 0; } to { background-position: 180px 0; } }
396
+ .fit-panel {
397
+ display: block;
398
+ width: 100%;
399
+ border: 4px solid var(--puri);
400
+ border-radius: 13px;
401
+ background: linear-gradient(120deg, #f7f7fa, #e7e4ed 31%, #f5eafd 56%, #e9f2f5 79%, #f7f7fa);
402
+ box-shadow: 0 8px 0 #bec0ca, 0 16px 24px #3f007926;
403
+ color: var(--puri-dark);
404
+ }
405
+ .fit-panel-main { padding: 14px 22px 10px; }
406
+ .fit-score-row { display: flex; align-items: center; gap: 20px; min-height: 75px; }
407
+ .fit-panel-heading { display: flex; flex-direction: column; flex: 1; gap: 5px; }
408
+ .fit-panel-heading strong { font: 900 clamp(21px, 2.4vw, 29px)/1.2 "Hiragino Maru Gothic ProN", "Yu Gothic", "Meiryo", sans-serif; }
409
+ .fit-panel-heading small { color: var(--puri-mid); font: 800 clamp(18px, 1.9vw, 23px)/1.25 var(--sans); letter-spacing: .01em; }
410
+ .fit-score { color: var(--puri); font: 900 clamp(49px, 6vw, 78px)/1 var(--sans); letter-spacing: -.07em; font-variant-numeric: tabular-nums; text-shadow: 3px 3px 0 #d8adff; }
411
+ .fit-meter { height: 25px; overflow: hidden; border: 3px solid var(--puri-mid); border-radius: 5px; background: white; box-shadow: inset 0 2px 4px #6d00d833; }
412
+ .fit-meter i { display: block; width: 0; height: 100%; background: repeating-linear-gradient(-45deg, #ffffff91 0 8px, transparent 8px 16px), linear-gradient(90deg, #aeb3c1, #b89ee1 24%, #add8ea 40%, #b9dcc9 56%, #e7d19d 73%, #d5a9cf 86%, #8700f5); transition: width .12s linear; }
413
+ .fit-panel-foot { display: flex; justify-content: space-between; gap: 20px; padding: 9px 22px; border-top: 1px dashed #c797fe; color: #71538d; }
414
+ .fit-panel-foot p { margin: 0; font-size: 11px; line-height: 1.5; }
415
+ .fit-panel-foot .note { max-width: 55%; color: inherit; font-size: 11px; }
416
+ .fit-panel[data-mood="low"] { border-color: #6c6196; background: #eeeaf7; box-shadow: 0 8px 0 #c7bedf, 0 16px 24px #28203d26; }
417
+ .fit-panel[data-mood="low"] .fit-score { color: #6c6196; text-shadow: 3px 3px 0 #d0cae1; }
418
+ .fit-panel[data-mood="low"] .fit-meter { border-color: #887daa; }
419
+ .fit-panel[data-mood="low"] .fit-meter i { background: repeating-linear-gradient(-45deg, #ffffff55 0 8px, transparent 8px 16px), linear-gradient(90deg, #b2a7d2, #6c6196); }
420
+ .view.fitcheck .caption[data-tone="badge"] { z-index: 6; inset: auto 30px 65px; padding: 10px; border: 3px solid white; border-radius: 6px; background: #5b368ce8; font: 800 17px/1.3 "Hiragino Maru Gothic ProN", "Yu Gothic", "Meiryo", sans-serif; }
421
+ @media (max-width: 700px) {
422
+ .view.fitcheck { padding: 48px 29px 42px; }
423
+ .fit-weather { inset: 48px 29px 42px; }
424
+ .puri-lace { top: 48px; left: 29px; width: calc(100% - 58px); height: calc(100% - 90px); }
425
+ .puri-title { left: 23px; top: -3px; font-size: clamp(34px, 8.5vw, 47px); -webkit-text-stroke-width: 3px; }
426
+ .puri-logo-bottom { left: 12px; bottom: 9px; width: 39px; height: 51px; }
427
+ .puri-logo-top { top: 65px; right: 9px; left: auto; width: 33px; height: 45px; }
428
+ .puri-logo-side { top: 68%; left: 8px; width: 33px; height: 45px; }
429
+ .puri-frame-heart { top: 51px; right: 13px; font-size: 50px; }
430
+ .puri-frame-star { bottom: 8px; font-size: 51px; }
431
+ .puri-doodle { font-size: 47px; }
432
+ .heart-three, .heart-eight, .star-three, .star-seven, .spark-four, .spark-five, .spark-seven { display: none; }
433
+ .spark-three { right: 8px; font-size: 40px; }
434
+ .heart-one { left: 10px; }
435
+ .star-one { left: 28%; }
436
+ .heart-two, .star-two { right: 10px; }
437
+ .spark-one, .spark-two { display: none; }
438
+ .heart-five { top: 34%; left: 8px; }
439
+ .heart-six { top: 42%; right: 8px; }
440
+ .heart-seven { bottom: 8px; left: 18%; }
441
+ .star-five { top: 57%; left: 8px; }
442
+ .star-six { top: 61%; right: 8px; }
443
+ .star-eight { bottom: 9px; left: 63%; }
444
+ .spark-six { bottom: 25%; left: 7px; }
445
+ .spark-eight { bottom: 9px; right: 37%; }
446
+ .puri-model { top: 44px; left: 49px; right: auto; font-size: clamp(23px, 5.4vw, 32px); writing-mode: horizontal-tb; -webkit-text-stroke-width: 3px; }
447
+ .puri-date { right: 20px; bottom: 9px; font-size: clamp(17px, 3.5vw, 23px); }
448
+ .fit-score-row { flex-wrap: wrap; gap: 6px 14px; }
449
+ .fit-panel-heading { min-width: 100%; }
450
+ .fit-panel-heading strong { font-size: 23px; }
451
+ .fit-panel-heading small { font-size: 20px; }
452
+ .fit-score { font-size: 52px; }
453
+ .fit-panel-foot { flex-direction: column; gap: 3px; }
454
+ .fit-panel-foot .note { max-width: none; }
455
+ }
456
+ @media (max-width: 480px) {
457
+ .fitcheck-page .play { padding: 14px 16px 28px; }
458
+ .fitcheck-page .topbar { gap: 8px; padding: 0 12px; }
459
+ .fitcheck-page .topbar .logo, .fitcheck-page .topbar .sep, .fitcheck-page .topbar .tag, .fitcheck-page .topbar .pill { display: none; }
460
+ .fitcheck-page .topbar .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
461
+ .view.fitcheck .camera-hint { inset: 32% 12%; font-size: 11px; }
462
+ .puri-title { font-size: clamp(34px, 9vw, 40px); }
463
+ .puri-model { font-size: clamp(23px, 6vw, 27px); }
464
+ .fit-panel-heading strong { font-size: 23px; }
465
+ .fit-panel-heading small { font-size: 21px; }
466
+ }
467
+ @media (prefers-reduced-motion: reduce) {
468
+ .fit-sticker, .view.fitcheck[data-mood="low"] .fit-weather::before,
469
+ .view.fitcheck[data-mood="cute"] .fit-weather::before,
470
+ .view.fitcheck[data-mood="sparkle"] .fit-weather::before,
471
+ .view.fitcheck[data-mood="sparkle"] .fit-weather::after { animation: none; }
472
+ }
473
+
474
  /* game surfaces */
475
  .surface { display: block; width: 100%; height: auto; }
476
  .view .surface { max-height: calc(100vh - 124px); object-fit: contain; }
game/web/thumbs.js CHANGED
@@ -118,6 +118,21 @@ export const THUMBS = {
118
  ${[0, 1, 2, 3, 4].map((i) => `<rect x="${196 + i * 18}" y="98" width="14" height="7" rx="2" fill="${i < 4 ? P : SOFT}"/>`).join("")}
119
  ${label(196, 130, "REFUND")}<text x="196" y="148" font-family="${MONO}" font-size="12" fill="${DEEP}">yes · 97%</text>`),
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  ask: frame("ask", `
122
  <g fill="none" stroke="${INK}" stroke-width="2.5" stroke-linecap="round">
123
  <path d="M26 46 V30 H42 M118 30 H134 V46 M134 154 V170 H118 M42 170 H26 V154"/>
 
118
  ${[0, 1, 2, 3, 4].map((i) => `<rect x="${196 + i * 18}" y="98" width="14" height="7" rx="2" fill="${i < 4 ? P : SOFT}"/>`).join("")}
119
  ${label(196, 130, "REFUND")}<text x="196" y="148" font-family="${MONO}" font-size="12" fill="${DEEP}">yes · 97%</text>`),
120
 
121
+ fitcheck: frame("fitcheck", `
122
+ <defs><linearGradient id="puri-foil"><stop stop-color="#aeb1bd"/><stop offset=".28" stop-color="#e3d6ef"/><stop offset=".53" stop-color="#a9d9e9"/><stop offset=".76" stop-color="#f2d8b5"/><stop offset="1" stop-color="#c1c4ce"/></linearGradient><pattern id="puri-check" width="16" height="16" patternUnits="userSpaceOnUse"><rect width="16" height="16" fill="#fff"/><rect width="8" height="8" fill="#c7c9d2"/><rect x="8" y="8" width="8" height="8" fill="#c7c9d2"/></pattern></defs>
123
+ <rect x="24" y="14" width="272" height="172" rx="10" fill="url(#puri-foil)" stroke="#8700f5" stroke-width="5"/>
124
+ <rect x="27" y="17" width="266" height="166" rx="8" fill="url(#puri-check)" opacity=".22"/>
125
+ <rect x="37" y="35" width="246" height="112" rx="3" fill="#f0e5ff" stroke="#fff" stroke-width="5"/>
126
+ <circle cx="160" cy="75" r="17" fill="#b793dc"/>
127
+ <path d="M111 140 Q122 91 160 93 Q198 91 209 140Z" fill="#ad62fe"/>
128
+ <text x="160" y="38" text-anchor="middle" font-family="sans-serif" font-size="17" font-weight="900" fill="#8700f5" stroke="#fff" stroke-width="3" paint-order="stroke">FIT CHECK ♡</text>
129
+ <text x="51" y="99" font-family="${SERIF}" font-size="42" fill="#8700f5" stroke="#fff" stroke-width="2" paint-order="stroke">☆</text>
130
+ <text x="264" y="89" text-anchor="end" font-family="${SERIF}" font-size="39" fill="#8700f5" stroke="#fff" stroke-width="2" paint-order="stroke">♡</text>
131
+ <text x="225" y="131" text-anchor="middle" font-family="Yomogi, sans-serif" font-size="17" font-weight="900" fill="#ff6bd6" stroke="#261139" stroke-width="3" paint-order="stroke">SPARKLE ☆</text>
132
+ <rect x="40" y="157" width="180" height="14" rx="3" fill="#fff" stroke="#6d00d8" stroke-width="2"/>
133
+ <rect x="42" y="159" width="155" height="10" rx="1" fill="#8700f5"/>
134
+ <text x="273" y="171" text-anchor="end" font-family="${MONO}" font-size="24" font-weight="900" fill="#8700f5">86%</text>`),
135
+
136
  ask: frame("ask", `
137
  <g fill="none" stroke="${INK}" stroke-width="2.5" stroke-linecap="round">
138
  <path d="M26 46 V30 H42 M118 30 H134 V46 M134 154 V170 H118 M42 170 H26 V154"/>