seachen commited on
Commit
9fdeb41
·
verified ·
1 Parent(s): 3e3fa48

Playable frontend: FastAPI + canvas shop UI

Browse files
Files changed (3) hide show
  1. web/play/app.js +50 -20
  2. web/play/index.html +91 -53
  3. web/play/styles.css +646 -315
web/play/app.js CHANGED
@@ -30,12 +30,29 @@ createApp({
30
  const stockSlot = ref("");
31
  const toasts = ref([]);
32
  const floaters = ref([]);
 
 
 
33
  let autoTimer = null;
34
  let autoInFlight = false;
35
  let toastSeq = 0;
36
  let floatSeq = 0;
37
  const imgCache = new Map();
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  const reversedLog = computed(() => [...(view.value?.log || [])].reverse());
40
  const catalogOptions = computed(() => view.value?.catalog || []);
41
  const stockOptions = computed(() => {
@@ -304,18 +321,21 @@ createApp({
304
 
305
  function drawBubble(ctx, x, y, text) {
306
  const t = String(text).slice(0, 18);
307
- ctx.font = "12px 'PingFang SC','Microsoft YaHei',sans-serif";
 
 
 
308
  const tw = ctx.measureText(t).width;
309
  const bw = tw + 16;
310
  const bh = 24;
311
  const bx = x - bw / 2;
312
  const by = y - bh - 10;
313
- ctx.fillStyle = "rgba(255,253,249,0.96)";
314
- ctx.strokeStyle = "rgba(196,137,102,0.75)";
315
  roundRect(ctx, bx, by, bw, bh, 10);
316
  ctx.fill();
317
  ctx.stroke();
318
- ctx.fillStyle = "#3a2f2a";
319
  ctx.textAlign = "center";
320
  ctx.fillText(t, x, by + 16);
321
  ctx.textAlign = "left";
@@ -327,8 +347,17 @@ createApp({
327
  const ctx = el.getContext("2d");
328
  const w = el.width;
329
  const h = el.height;
 
 
 
 
 
 
 
 
 
330
  ctx.clearRect(0, 0, w, h);
331
- ctx.fillStyle = "rgba(250,244,236,0.9)";
332
  ctx.fillRect(0, 0, w, h);
333
 
334
  const v = view.value;
@@ -342,12 +371,10 @@ createApp({
342
  const px = x * CELL;
343
  const py = y * CELL;
344
  const t = tags[`${x},${y}`] || [];
345
- if (t.includes("wall") && t.includes("window")) ctx.fillStyle = "rgba(170,205,225,0.55)";
346
- else if (t.includes("wall")) ctx.fillStyle = "rgba(90,70,60,0.32)";
347
- else if (t.includes("is_entrance")) ctx.fillStyle = "rgba(196,137,102,0.35)";
348
- else
349
- ctx.fillStyle =
350
- (x + y) % 2 === 0 ? "rgba(255,255,255,0.28)" : "rgba(230,210,190,0.22)";
351
  ctx.fillRect(px, py, CELL, CELL);
352
  }
353
  }
@@ -367,8 +394,8 @@ createApp({
367
  roundRect(ctx, px + 6, py + 6, CELL - 12, CELL - 12, 6);
368
  ctx.fill();
369
  }
370
- ctx.fillStyle = "rgba(40,30,25,0.85)";
371
- ctx.font = "11px 'PingFang SC','Microsoft YaHei',sans-serif";
372
  ctx.fillText(String(f.name || "").slice(0, 6), px + 4, py + CELL - 5);
373
  }
374
 
@@ -396,14 +423,14 @@ createApp({
396
  ctx.drawImage(im, px - 12, py + 2, 24, 24);
397
  ctx.restore();
398
  }
399
- ctx.fillStyle = "#3a2f2a";
400
- ctx.font = "bold 11px sans-serif";
401
  ctx.fillText("×" + d.qty, px + 12, py + 18);
402
  }
403
 
404
  for (const [name, xy] of Object.entries(v.grid.markers || {})) {
405
- ctx.fillStyle = "rgba(80,60,50,0.4)";
406
- ctx.font = "10px 'PingFang SC','Microsoft YaHei',sans-serif";
407
  ctx.fillText(markersZh[name] || name, xy[0] * CELL + 3, xy[1] * CELL + 12);
408
  }
409
 
@@ -434,11 +461,11 @@ createApp({
434
  ctx.drawImage(portrait, px - 15, py - 15, 30, 30);
435
  ctx.restore();
436
  }
437
- ctx.fillStyle = "#2a2018";
438
- ctx.font = "bold 12px 'PingFang SC','Microsoft YaHei',sans-serif";
439
  ctx.textAlign = "center";
440
  ctx.fillText(a.name || "", px, py + 32);
441
- ctx.font = "11px 'PingFang SC','Microsoft YaHei',sans-serif";
442
  ctx.fillStyle = col;
443
  ctx.fillText(a.state_zh || "", px, py + 46);
444
  ctx.textAlign = "left";
@@ -449,6 +476,7 @@ createApp({
449
  watch(view, () => nextTick(draw), { deep: true });
450
 
451
  onMounted(async () => {
 
452
  await newGame();
453
  setInterval(() => {
454
  if (view.value) draw();
@@ -472,6 +500,8 @@ createApp({
472
  bgStyle,
473
  toasts,
474
  floaters,
 
 
475
  isHere,
476
  newGame,
477
  openShop,
 
30
  const stockSlot = ref("");
31
  const toasts = ref([]);
32
  const floaters = ref([]);
33
+ const flavor = ref(
34
+ localStorage.getItem("town-shop-flavor") === "latte" ? "latte" : "mocha"
35
+ );
36
  let autoTimer = null;
37
  let autoInFlight = false;
38
  let toastSeq = 0;
39
  let floatSeq = 0;
40
  const imgCache = new Map();
41
 
42
+ function setFlavor(name) {
43
+ flavor.value = name === "latte" ? "latte" : "mocha";
44
+ document.documentElement.setAttribute("data-flavor", flavor.value);
45
+ localStorage.setItem("town-shop-flavor", flavor.value);
46
+ const meta = document.querySelector('meta[name="theme-color"]');
47
+ if (meta) meta.setAttribute("content", flavor.value === "latte" ? "#eff1f5" : "#1e1e2e");
48
+ nextTick(draw);
49
+ }
50
+
51
+ function ctp(name, fallback) {
52
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
53
+ return v || fallback;
54
+ }
55
+
56
  const reversedLog = computed(() => [...(view.value?.log || [])].reverse());
57
  const catalogOptions = computed(() => view.value?.catalog || []);
58
  const stockOptions = computed(() => {
 
321
 
322
  function drawBubble(ctx, x, y, text) {
323
  const t = String(text).slice(0, 18);
324
+ const peach = ctp("--ctp-peach", "#fab387");
325
+ const base = ctp("--ctp-mantle", "#181825");
326
+ const ink = ctp("--ctp-text", "#cdd6f4");
327
+ ctx.font = "12px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif";
328
  const tw = ctx.measureText(t).width;
329
  const bw = tw + 16;
330
  const bh = 24;
331
  const bx = x - bw / 2;
332
  const by = y - bh - 10;
333
+ ctx.fillStyle = base;
334
+ ctx.strokeStyle = peach;
335
  roundRect(ctx, bx, by, bw, bh, 10);
336
  ctx.fill();
337
  ctx.stroke();
338
+ ctx.fillStyle = ink;
339
  ctx.textAlign = "center";
340
  ctx.fillText(t, x, by + 16);
341
  ctx.textAlign = "left";
 
347
  const ctx = el.getContext("2d");
348
  const w = el.width;
349
  const h = el.height;
350
+ const base = ctp("--ctp-base", "#1e1e2e");
351
+ const surface0 = ctp("--ctp-surface0", "#313244");
352
+ const surface1 = ctp("--ctp-surface1", "#45475a");
353
+ const text = ctp("--ctp-text", "#cdd6f4");
354
+ const subtext = ctp("--ctp-subtext0", "#a6adc8");
355
+ const peach = ctp("--ctp-peach", "#fab387");
356
+ const sky = ctp("--ctp-sky", "#89dceb");
357
+ const crust = ctp("--ctp-crust", "#11111b");
358
+
359
  ctx.clearRect(0, 0, w, h);
360
+ ctx.fillStyle = base;
361
  ctx.fillRect(0, 0, w, h);
362
 
363
  const v = view.value;
 
371
  const px = x * CELL;
372
  const py = y * CELL;
373
  const t = tags[`${x},${y}`] || [];
374
+ if (t.includes("wall") && t.includes("window")) ctx.fillStyle = sky + "66";
375
+ else if (t.includes("wall")) ctx.fillStyle = surface1 + "cc";
376
+ else if (t.includes("is_entrance")) ctx.fillStyle = peach + "55";
377
+ else ctx.fillStyle = (x + y) % 2 === 0 ? surface0 + "aa" : surface1 + "66";
 
 
378
  ctx.fillRect(px, py, CELL, CELL);
379
  }
380
  }
 
394
  roundRect(ctx, px + 6, py + 6, CELL - 12, CELL - 12, 6);
395
  ctx.fill();
396
  }
397
+ ctx.fillStyle = text;
398
+ ctx.font = "11px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif";
399
  ctx.fillText(String(f.name || "").slice(0, 6), px + 4, py + CELL - 5);
400
  }
401
 
 
423
  ctx.drawImage(im, px - 12, py + 2, 24, 24);
424
  ctx.restore();
425
  }
426
+ ctx.fillStyle = text;
427
+ ctx.font = "bold 11px 'JetBrains Mono',sans-serif";
428
  ctx.fillText("×" + d.qty, px + 12, py + 18);
429
  }
430
 
431
  for (const [name, xy] of Object.entries(v.grid.markers || {})) {
432
+ ctx.fillStyle = subtext;
433
+ ctx.font = "10px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif";
434
  ctx.fillText(markersZh[name] || name, xy[0] * CELL + 3, xy[1] * CELL + 12);
435
  }
436
 
 
461
  ctx.drawImage(portrait, px - 15, py - 15, 30, 30);
462
  ctx.restore();
463
  }
464
+ ctx.fillStyle = text;
465
+ ctx.font = "bold 12px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif";
466
  ctx.textAlign = "center";
467
  ctx.fillText(a.name || "", px, py + 32);
468
+ ctx.font = "11px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif";
469
  ctx.fillStyle = col;
470
  ctx.fillText(a.state_zh || "", px, py + 46);
471
  ctx.textAlign = "left";
 
476
  watch(view, () => nextTick(draw), { deep: true });
477
 
478
  onMounted(async () => {
479
+ setFlavor(flavor.value);
480
  await newGame();
481
  setInterval(() => {
482
  if (view.value) draw();
 
500
  bgStyle,
501
  toasts,
502
  floaters,
503
+ flavor,
504
+ setFlavor,
505
  isHere,
506
  newGame,
507
  openShop,
web/play/index.html CHANGED
@@ -1,56 +1,76 @@
1
  <!doctype html>
2
- <html lang="zh-CN">
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
 
 
6
  <title>小镇角落小店</title>
 
 
 
7
  <link rel="stylesheet" href="/play/styles.css" />
8
  <script src="https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js"></script>
9
  </head>
10
  <body>
11
  <div id="app" v-cloak>
12
- <header class="topbar">
 
 
13
  <div class="brand">
14
- <div class="logo">🌷</div>
15
  <div>
16
  <h1>小镇角落小店</h1>
17
  <p class="guide">{{ view?.guide || '正在布置小店…' }}</p>
18
  </div>
19
  </div>
20
- <div class="phase-track" v-if="view">
 
21
  <span
22
  v-for="p in view.phases"
23
  :key="p.id"
24
  class="phase-pill"
25
  :class="{ on: p.active }"
 
26
  >{{ p.label }}</span>
27
- </div>
 
28
  <div class="top-meta" v-if="view">
29
  <strong>{{ view.meta.day_label }}</strong>
30
  <span>{{ view.meta.phase_zh }}</span>
31
- <span class="money-pill">¥ {{ view.wallet }}</span>
 
 
 
 
 
 
 
 
 
 
 
 
32
  </div>
33
  </header>
34
 
35
- <main class="shell" v-if="view">
36
- <section class="stage-card" :class="'light-' + view.atmosphere.light">
37
- <div class="stage-bg" :style="bgStyle"></div>
38
- <canvas ref="canvas" width="576" height="480"></canvas>
39
 
40
- <!-- 浮动成交提示 -->
41
- <div class="floaters">
42
  <div v-for="f in floaters" :key="f.id" class="floater" :class="f.kind">{{ f.text }}</div>
43
  </div>
44
 
45
  <div class="mood-bar">
46
- <span>💭</span>
47
  <span>{{ view.atmosphere.mood_line || '店里还很安静。' }}</span>
48
  <span class="live" v-if="view.agents.length">店内 {{ view.agents.length }} 人</span>
49
  <span class="live dim" v-else-if="view.ui.can_tick">等待客人中…</span>
50
  </div>
51
 
52
- <!-- 今日来客(离开也保留,避免「看不见人」) -->
53
- <div class="guest-rail">
54
  <div class="rail-title">今日来客</div>
55
  <div class="rail-body" v-if="(view.guests_today || []).length">
56
  <div
@@ -59,8 +79,8 @@
59
  :key="g.agent_id"
60
  :class="{ left: g.left, here: isHere(g.agent_id) }"
61
  >
62
- <img v-if="g.portrait" :src="'/' + g.portrait" :alt="g.name" />
63
- <div class="avatar-fallback" v-else>{{ (g.name || '?')[0] }}</div>
64
  <div>
65
  <b>{{ g.name }}</b>
66
  <small>{{ g.kind_zh }} · {{ g.left ? '已离开' : g.state_zh }}</small>
@@ -73,16 +93,15 @@
73
  </div>
74
  </section>
75
 
76
- <aside class="hud">
77
- <!-- 主操作:按阶段只突出一个 -->
78
  <div class="card hero-actions">
79
  <template v-if="view.meta.phase === 'PREP'">
80
  <h2>营业前 · 开张准备</h2>
81
  <p class="hint ok">现在可以进货、上架、调灯光。</p>
82
- <button class="primary big" @click="openShop" :disabled="busy">🚪 开始营业</button>
83
  <div class="btn-grid">
84
- <button @click="quickPrep" :disabled="busy">一键备货</button>
85
- <button class="ghost" @click="newGame" :disabled="busy">新开一局</button>
86
  </div>
87
  </template>
88
 
@@ -93,8 +112,8 @@
93
  <div><em>成交</em><b>{{ view.stats.sales }}</b></div>
94
  <div><em>营收</em><b>¥{{ view.stats.revenue }}</b></div>
95
  </div>
96
- <button class="primary big" @click="sleep" :disabled="busy">🌙 入睡 · 下一天</button>
97
- <button class="ghost" @click="newGame" :disabled="busy">新开一局</button>
98
  </template>
99
 
100
  <template v-else>
@@ -106,7 +125,7 @@
106
  </div>
107
  <div class="btn-grid">
108
  <button type="button" class="stable" @click="togglePause">
109
- {{ view.meta.paused ? '继续' : '暂停' }}
110
  </button>
111
  <button type="button" class="stable" @click="tickOnce" :disabled="view.meta.paused">
112
  推进一步
@@ -115,8 +134,8 @@
115
  <button type="button" class="ghost stable" @click="newGame">新开一局</button>
116
  </div>
117
  <div class="speed-row">
118
- <span>自动流逝</span>
119
- <select v-model.number="speed" @change="setupAuto">
120
  <option :value="0">关闭</option>
121
  <option :value="350">正常</option>
122
  <option :value="180">两倍速</option>
@@ -131,31 +150,31 @@
131
  <h2>进货与上架</h2>
132
  <p class="hint" :class="{ ok: view.ui.can_restock }">{{ view.ui.restock_hint }}</p>
133
  <div class="form-row">
134
- <select v-model="restockId" :disabled="!view.ui.can_restock">
135
  <option v-for="p in catalogOptions" :key="'r'+p.product_id" :value="p.product_id">
136
  {{ p.name }}(¥{{ p.price }})
137
  </option>
138
  </select>
139
- <button @click="restock" :disabled="busy || !view.ui.can_restock">进货 +1</button>
140
  </div>
141
  <p class="hint" :class="{ ok: view.ui.can_stock_display }">{{ view.ui.stock_hint }}</p>
142
  <div class="form-row triple" v-if="view.ui.can_stock_display">
143
- <select v-model="stockPid">
144
  <option v-for="p in stockOptions" :key="'s'+p.product_id" :value="p.product_id">
145
  {{ p.name }} ×{{ p.qty ?? '—' }}
146
  </option>
147
  </select>
148
- <select v-model="stockSlot">
149
  <option v-for="d in view.displays" :key="d.slot" :value="d.slot">
150
  {{ d.name }} / {{ d.slot }}
151
  </option>
152
  </select>
153
- <button @click="stock" :disabled="busy">上架</button>
154
  </div>
155
- <div class="btn-grid three light-row">
156
- <button :class="{ active: view.atmosphere.light==='warm' }" @click="setLight('warm')">暖光</button>
157
- <button :class="{ active: view.atmosphere.light==='cool' }" @click="setLight('cool')">冷光</button>
158
- <button :class="{ active: view.atmosphere.light==='off' }" @click="setLight('off')">关灯</button>
159
  </div>
160
  </div>
161
 
@@ -163,31 +182,32 @@
163
  <h2>氛围</h2>
164
  <div class="atm" v-for="d in view.atmosphere.dims" :key="d.key">
165
  <span>{{ d.label }}</span>
166
- <div class="bar"><i :style="{ width: d.pct + '%' }"></i></div>
 
 
167
  <span class="pct">{{ d.pct }}</span>
168
  </div>
169
  </div>
170
 
171
  <div class="card">
172
  <h2>店内见闻</h2>
173
- <ul class="log">
174
  <li v-for="(line, i) in reversedLog" :key="i">{{ line }}</li>
175
  </ul>
176
  </div>
177
  </aside>
178
  </main>
179
 
180
- <div class="loading" v-else>
181
- <div class="spinner"></div>
182
  <p>{{ error || '正在布置小店…' }}</p>
183
- <button v-if="error" class="primary" @click="newGame">重试</button>
184
  </div>
185
 
186
- <!-- 夜记 -->
187
- <div class="modal" v-if="showNight && view?.night" @click.self="showNight=false">
188
  <div class="modal-card">
189
  <div class="modal-kicker">夜记</div>
190
- <h2>{{ view.meta.day_label }} · 打烊了</h2>
191
  <p class="lead">{{ view.night.mood }}</p>
192
  <p class="diary">「{{ view.night.diary }}」</p>
193
  <div class="night-stats">
@@ -196,31 +216,49 @@
196
  <div><em>营收</em><b>¥{{ view.night.revenue }}</b></div>
197
  </div>
198
  <p class="vibe" v-if="view.night.top_dims?.length">今日气息:{{ view.night.top_dims.join(' · ') }}</p>
199
- <button class="primary wide" @click="showNight=false">收下这份夜晚</button>
200
  </div>
201
  </div>
202
 
203
- <!-- 首次引导 -->
204
- <div class="modal" v-if="showHelp" @click.self="showHelp=false">
205
  <div class="modal-card">
206
- <div class="modal-kicker">怎么玩</div>
207
- <h2>三步经营你的花店</h2>
208
  <ol class="help-list">
209
  <li><b>营业前</b>:进货、上架,然后点「开始营业」</li>
210
  <li><b>营业中</b>:自动流逝,看客人进店、闲逛、结账</li>
211
  <li><b>夜晚</b>:读夜记,点「入睡」进入下一天</li>
212
  </ol>
213
- <p class="hint ok">演示已加速:不会空等很久,客人会更常出现。</p>
214
- <button class="primary wide" @click="showHelp=false">我知道了,开始</button>
215
  </div>
216
  </div>
217
 
218
- <div class="toast-stack">
219
  <div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind">{{ t.text }}</div>
220
  </div>
221
 
222
- <footer class="foot">毕业设计演示 · Vue 3 前端 + Python 模拟</footer>
 
 
 
223
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  <script src="/play/app.js"></script>
225
  </body>
226
  </html>
 
1
  <!doctype html>
2
+ <html lang="zh-CN" data-flavor="mocha">
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <meta name="theme-color" content="#1e1e2e" />
8
  <title>小镇角落小店</title>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
12
  <link rel="stylesheet" href="/play/styles.css" />
13
  <script src="https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js"></script>
14
  </head>
15
  <body>
16
  <div id="app" v-cloak>
17
+ <a class="skip-link" href="#main">跳到主内容</a>
18
+
19
+ <header class="topbar" role="banner">
20
  <div class="brand">
21
+ <div class="logo" aria-hidden="true">🌷</div>
22
  <div>
23
  <h1>小镇角落小店</h1>
24
  <p class="guide">{{ view?.guide || '正在布置小店…' }}</p>
25
  </div>
26
  </div>
27
+
28
+ <nav class="phase-track" v-if="view" aria-label="营业时辰">
29
  <span
30
  v-for="p in view.phases"
31
  :key="p.id"
32
  class="phase-pill"
33
  :class="{ on: p.active }"
34
+ :aria-current="p.active ? 'step' : null"
35
  >{{ p.label }}</span>
36
+ </nav>
37
+
38
  <div class="top-meta" v-if="view">
39
  <strong>{{ view.meta.day_label }}</strong>
40
  <span>{{ view.meta.phase_zh }}</span>
41
+ <span class="money-pill" aria-label="店内现金">¥ {{ view.wallet }}</span>
42
+ <div class="flavor-switch" role="group" aria-label="Catppuccin 主题">
43
+ <button
44
+ type="button"
45
+ :aria-pressed="flavor === 'mocha'"
46
+ @click="setFlavor('mocha')"
47
+ >Mocha</button>
48
+ <button
49
+ type="button"
50
+ :aria-pressed="flavor === 'latte'"
51
+ @click="setFlavor('latte')"
52
+ >Latte</button>
53
+ </div>
54
  </div>
55
  </header>
56
 
57
+ <main id="main" class="shell" v-if="view">
58
+ <section class="stage-card" :class="'light-' + view.atmosphere.light" aria-label="店铺舞台">
59
+ <div class="stage-bg" :style="bgStyle" aria-hidden="true"></div>
60
+ <canvas ref="canvas" width="576" height="480" role="img" aria-label="店铺俯视图"></canvas>
61
 
62
+ <div class="floaters" aria-live="polite">
 
63
  <div v-for="f in floaters" :key="f.id" class="floater" :class="f.kind">{{ f.text }}</div>
64
  </div>
65
 
66
  <div class="mood-bar">
67
+ <span aria-hidden="true">💭</span>
68
  <span>{{ view.atmosphere.mood_line || '店里还很安静。' }}</span>
69
  <span class="live" v-if="view.agents.length">店内 {{ view.agents.length }} 人</span>
70
  <span class="live dim" v-else-if="view.ui.can_tick">等待客人中…</span>
71
  </div>
72
 
73
+ <div class="guest-rail" aria-label="今日来客">
 
74
  <div class="rail-title">今日来客</div>
75
  <div class="rail-body" v-if="(view.guests_today || []).length">
76
  <div
 
79
  :key="g.agent_id"
80
  :class="{ left: g.left, here: isHere(g.agent_id) }"
81
  >
82
+ <img v-if="g.portrait" :src="'/' + g.portrait" :alt="g.name + ' 肖像'" />
83
+ <div class="avatar-fallback" v-else aria-hidden="true">{{ (g.name || '?')[0] }}</div>
84
  <div>
85
  <b>{{ g.name }}</b>
86
  <small>{{ g.kind_zh }} · {{ g.left ? '已离开' : g.state_zh }}</small>
 
93
  </div>
94
  </section>
95
 
96
+ <aside class="hud" aria-label="经营面板">
 
97
  <div class="card hero-actions">
98
  <template v-if="view.meta.phase === 'PREP'">
99
  <h2>营业前 · 开张准备</h2>
100
  <p class="hint ok">现在可以进货、上架、调灯光。</p>
101
+ <button class="primary big" @click="openShop" :disabled="busy">开始营业</button>
102
  <div class="btn-grid">
103
+ <button type="button" @click="quickPrep" :disabled="busy">一键备货</button>
104
+ <button type="button" class="ghost" @click="newGame" :disabled="busy">新开一局</button>
105
  </div>
106
  </template>
107
 
 
112
  <div><em>成交</em><b>{{ view.stats.sales }}</b></div>
113
  <div><em>营收</em><b>¥{{ view.stats.revenue }}</b></div>
114
  </div>
115
+ <button class="primary big" @click="sleep" :disabled="busy">入睡 · 下一天</button>
116
+ <button type="button" class="ghost" @click="newGame" :disabled="busy">新开一局</button>
117
  </template>
118
 
119
  <template v-else>
 
125
  </div>
126
  <div class="btn-grid">
127
  <button type="button" class="stable" @click="togglePause">
128
+ {{ view.meta.paused ? '继续' : '暂停' }}
129
  </button>
130
  <button type="button" class="stable" @click="tickOnce" :disabled="view.meta.paused">
131
  推进一步
 
134
  <button type="button" class="ghost stable" @click="newGame">新开一局</button>
135
  </div>
136
  <div class="speed-row">
137
+ <label for="speed">自动流逝</label>
138
+ <select id="speed" v-model.number="speed" @change="setupAuto">
139
  <option :value="0">关闭</option>
140
  <option :value="350">正常</option>
141
  <option :value="180">两倍速</option>
 
150
  <h2>进货与上架</h2>
151
  <p class="hint" :class="{ ok: view.ui.can_restock }">{{ view.ui.restock_hint }}</p>
152
  <div class="form-row">
153
+ <select v-model="restockId" :disabled="!view.ui.can_restock" aria-label="进货商品">
154
  <option v-for="p in catalogOptions" :key="'r'+p.product_id" :value="p.product_id">
155
  {{ p.name }}(¥{{ p.price }})
156
  </option>
157
  </select>
158
+ <button type="button" @click="restock" :disabled="busy || !view.ui.can_restock">进货 +1</button>
159
  </div>
160
  <p class="hint" :class="{ ok: view.ui.can_stock_display }">{{ view.ui.stock_hint }}</p>
161
  <div class="form-row triple" v-if="view.ui.can_stock_display">
162
+ <select v-model="stockPid" aria-label="上架商品">
163
  <option v-for="p in stockOptions" :key="'s'+p.product_id" :value="p.product_id">
164
  {{ p.name }} ×{{ p.qty ?? '—' }}
165
  </option>
166
  </select>
167
+ <select v-model="stockSlot" aria-label="货架位">
168
  <option v-for="d in view.displays" :key="d.slot" :value="d.slot">
169
  {{ d.name }} / {{ d.slot }}
170
  </option>
171
  </select>
172
+ <button type="button" @click="stock" :disabled="busy">上架</button>
173
  </div>
174
+ <div class="btn-grid three light-row" role="group" aria-label="灯光">
175
+ <button type="button" :class="{ active: view.atmosphere.light==='warm' }" @click="setLight('warm')">暖光</button>
176
+ <button type="button" :class="{ active: view.atmosphere.light==='cool' }" @click="setLight('cool')">冷光</button>
177
+ <button type="button" :class="{ active: view.atmosphere.light==='off' }" @click="setLight('off')">关灯</button>
178
  </div>
179
  </div>
180
 
 
182
  <h2>氛围</h2>
183
  <div class="atm" v-for="d in view.atmosphere.dims" :key="d.key">
184
  <span>{{ d.label }}</span>
185
+ <div class="bar" role="progressbar" :aria-valuenow="d.pct" aria-valuemin="0" aria-valuemax="100" :aria-label="d.label">
186
+ <i :style="{ width: d.pct + '%' }"></i>
187
+ </div>
188
  <span class="pct">{{ d.pct }}</span>
189
  </div>
190
  </div>
191
 
192
  <div class="card">
193
  <h2>店内见闻</h2>
194
+ <ul class="log" aria-live="polite">
195
  <li v-for="(line, i) in reversedLog" :key="i">{{ line }}</li>
196
  </ul>
197
  </div>
198
  </aside>
199
  </main>
200
 
201
+ <div class="loading" v-else role="status">
202
+ <div class="spinner" aria-hidden="true"></div>
203
  <p>{{ error || '正在布置小店…' }}</p>
204
+ <button v-if="error" type="button" class="primary" @click="newGame">重试</button>
205
  </div>
206
 
207
+ <div class="modal" v-if="showNight && view?.night" @click.self="showNight=false" role="dialog" aria-modal="true" aria-labelledby="night-title">
 
208
  <div class="modal-card">
209
  <div class="modal-kicker">夜记</div>
210
+ <h2 id="night-title">{{ view.meta.day_label }} · 打烊了</h2>
211
  <p class="lead">{{ view.night.mood }}</p>
212
  <p class="diary">「{{ view.night.diary }}」</p>
213
  <div class="night-stats">
 
216
  <div><em>营收</em><b>¥{{ view.night.revenue }}</b></div>
217
  </div>
218
  <p class="vibe" v-if="view.night.top_dims?.length">今日气息:{{ view.night.top_dims.join(' · ') }}</p>
219
+ <button type="button" class="primary wide" @click="showNight=false">收下这份夜晚</button>
220
  </div>
221
  </div>
222
 
223
+ <div class="modal" v-if="showHelp" @click.self="showHelp=false" role="dialog" aria-modal="true" aria-labelledby="help-title">
 
224
  <div class="modal-card">
225
+ <div class="modal-kicker">怎么玩 · Catppuccin</div>
226
+ <h2 id="help-title">三步经营你的花店</h2>
227
  <ol class="help-list">
228
  <li><b>营业前</b>:进货、上架,然后点「开始营业」</li>
229
  <li><b>营业中</b>:自动流逝,看客人进店、闲逛、结账</li>
230
  <li><b>夜晚</b>:读夜记,点「入睡」进入下一天</li>
231
  </ol>
232
+ <p class="hint ok">界面使用 Catppuccin 配色;右上角可在 Mocha / Latte 间切换。</p>
233
+ <button type="button" class="primary wide" @click="showHelp=false">我知道了,开始</button>
234
  </div>
235
  </div>
236
 
237
+ <div class="toast-stack" aria-live="polite" aria-relevant="additions">
238
  <div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind">{{ t.text }}</div>
239
  </div>
240
 
241
+ <footer class="foot">
242
+ 毕业设计演示 · Vue 3 ·
243
+ <span>Catppuccin {{ flavor === 'mocha' ? 'Mocha' : 'Latte' }}</span>
244
+ </footer>
245
  </div>
246
+ <style>
247
+ .skip-link {
248
+ position: absolute;
249
+ left: -9999px;
250
+ top: 0;
251
+ z-index: 100;
252
+ padding: 0.5rem 0.75rem;
253
+ background: var(--ctp-mauve, #cba6f7);
254
+ color: var(--ctp-crust, #11111b);
255
+ border-radius: 0 0 8px 0;
256
+ font-weight: 700;
257
+ }
258
+ .skip-link:focus {
259
+ left: 0;
260
+ }
261
+ </style>
262
  <script src="/play/app.js"></script>
263
  </body>
264
  </html>
web/play/styles.css CHANGED
@@ -1,290 +1,484 @@
1
- :root {
2
- --bg: #f3ebe2;
3
- --ink: #3a2f2a;
4
- --muted: #6d5c53;
5
- --card: #fffdf9;
6
- --line: #e4d5c6;
7
- --accent: #c48966;
8
- --accent2: #a36a4c;
9
- --green: #5d8f6c;
10
- --shadow: 0 14px 36px rgba(58, 40, 28, 0.08);
11
- --radius: 18px;
12
- font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  "Noto Sans SC", system-ui, sans-serif;
 
 
14
  }
15
 
16
- * { box-sizing: border-box; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  html, body {
18
  margin: 0;
19
  min-height: 100%;
20
- color: var(--ink);
 
 
 
21
  background:
22
- radial-gradient(circle at 12% -10%, #f8e6d2 0%, transparent 42%),
23
- radial-gradient(circle at 90% 0%, #e7f0e5 0%, transparent 34%),
 
24
  var(--bg);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  }
26
- [v-cloak] { display: none; }
27
 
28
  #app {
29
- max-width: 1220px;
30
  margin: 0 auto;
31
- padding: 1rem 1rem 2rem;
32
  }
33
 
 
34
  .topbar {
35
- display: flex;
36
- flex-wrap: wrap;
37
- gap: .8rem 1rem;
38
  align-items: center;
39
- justify-content: space-between;
40
- margin-bottom: 1rem;
 
 
 
 
 
41
  }
 
42
  .brand {
43
  display: flex;
44
- gap: .7rem;
45
  align-items: center;
 
46
  }
 
47
  .logo {
48
  width: 48px;
49
  height: 48px;
50
- border-radius: 14px;
51
- background: linear-gradient(145deg, #f0d2b8, #d8a07d);
 
52
  display: grid;
53
  place-items: center;
54
- font-size: 1.4rem;
55
- box-shadow: var(--shadow);
56
  }
 
57
  .brand h1 {
58
  margin: 0;
59
- font-size: 1.35rem;
60
- letter-spacing: .02em;
 
 
 
 
 
61
  }
 
 
62
  .brand p {
63
- margin: .15rem 0 0;
64
- color: var(--muted);
65
- font-size: .88rem;
 
 
 
 
 
66
  }
67
 
68
  .phase-track {
69
  display: flex;
70
  flex-wrap: wrap;
71
- gap: .35rem;
 
72
  }
 
73
  .phase-pill {
74
- font-size: .75rem;
75
- padding: .3rem .65rem;
76
- border-radius: 999px;
77
- border: 1px solid var(--line);
78
- background: #fff;
79
- color: var(--muted);
 
 
 
80
  }
 
81
  .phase-pill.on {
82
- color: #fff;
83
  border-color: transparent;
84
- background: linear-gradient(135deg, #d8a78a, var(--accent));
85
- box-shadow: 0 4px 12px rgba(196, 137, 102, .35);
86
  }
 
87
  .top-meta {
88
  display: flex;
89
  flex-wrap: wrap;
90
- gap: .45rem .7rem;
91
  align-items: center;
92
- color: var(--muted);
93
- font-size: .9rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  }
95
- .top-meta strong { color: var(--ink); }
96
- .tag-pause {
97
- background: #fff4df;
98
- color: #9a6a18;
99
- border-radius: 999px;
100
- padding: .15rem .55rem;
101
- font-size: .78rem;
102
  }
103
 
 
104
  .shell {
105
  display: grid;
106
  grid-template-columns: minmax(0, 1fr) 340px;
107
- gap: 1rem;
108
  align-items: start;
109
  }
110
 
 
111
  .stage-card {
112
  position: relative;
113
- background: var(--card);
114
- border: 1px solid var(--line);
115
- border-radius: var(--radius);
116
  overflow: hidden;
117
  box-shadow: var(--shadow);
118
  }
 
119
  .stage-bg {
120
  position: absolute;
121
  inset: 0;
122
  background-size: cover;
123
  background-position: center;
124
- opacity: .32;
125
  pointer-events: none;
 
126
  }
127
- .stage-card.light-warm .stage-bg { filter: sepia(.18) saturate(1.12); }
128
- .stage-card.light-cool .stage-bg { filter: hue-rotate(18deg) brightness(1.05) saturate(.92); }
129
- .stage-card.light-off .stage-bg { filter: brightness(.42) saturate(.55); }
 
 
130
  canvas {
131
  position: relative;
132
  display: block;
133
  width: 100%;
134
  height: auto;
 
135
  }
 
136
  .mood-bar {
137
  position: relative;
138
  display: flex;
139
- gap: .5rem;
140
- align-items: center;
141
- padding: .7rem 1rem;
142
- border-top: 1px solid var(--line);
143
- background: rgba(255, 253, 249, .92);
144
- color: var(--muted);
145
- }
146
- .agent-strip {
147
- display: flex;
148
- gap: .55rem;
149
- overflow-x: auto;
150
- padding: .65rem .85rem .85rem;
151
- border-top: 1px solid var(--line);
152
- background: #fbf6f0;
153
- }
154
- .agent-strip.empty {
155
- color: var(--muted);
156
- font-size: .9rem;
157
- }
158
- .agent-chip {
159
- display: flex;
160
- gap: .45rem;
161
  align-items: center;
162
- min-width: 160px;
163
- background: #fff;
164
- border: 1px solid var(--line);
165
- border-radius: 12px;
166
- padding: .4rem .55rem;
167
  }
168
- .agent-chip img {
169
- width: 36px;
170
- height: 36px;
171
- border-radius: 50%;
172
- object-fit: cover;
173
- }
174
- .agent-chip b { display: block; font-size: .88rem; }
175
- .agent-chip small { color: var(--muted); font-size: .74rem; }
176
 
177
- .hud { display: flex; flex-direction: column; gap: .75rem; }
178
- .card {
179
- background: var(--card);
180
- border: 1px solid var(--line);
181
- border-radius: 16px;
182
- padding: .9rem 1rem;
183
- box-shadow: var(--shadow);
184
- }
185
- .card h2 {
186
- margin: 0 0 .65rem;
187
- font-size: .82rem;
188
- letter-spacing: .08em;
189
- color: var(--muted);
190
- font-weight: 700;
191
- }
192
- .money-pill {
193
- background: #f3e2d2;
194
- color: var(--accent2);
195
- font-weight: 700;
196
- padding: .2rem .55rem;
197
- border-radius: 999px;
198
- }
199
- .guide { max-width: 28rem; }
200
- button.big {
201
- width: 100%;
202
- padding: .85rem 1rem;
203
- font-size: 1.05rem;
204
- margin: .35rem 0 .55rem;
205
- }
206
- .hero-actions .hint { margin-bottom: .55rem; }
207
- .form-row.triple {
208
- grid-template-columns: 1fr 1fr auto;
209
- }
210
- .light-row { margin-top: .55rem; }
211
  .live {
212
  margin-left: auto;
213
- font-size: .8rem;
214
- color: var(--accent2);
215
- font-weight: 650;
 
 
 
216
  }
217
- .live.dim { color: var(--muted); font-weight: 500; }
218
 
 
 
 
 
 
 
 
219
  .guest-rail {
220
- border-top: 1px solid var(--line);
221
- background: #fbf6f0;
222
- padding: .55rem .75rem .7rem;
223
  }
 
224
  .rail-title {
225
- font-size: .75rem;
226
- letter-spacing: .08em;
227
- color: var(--muted);
 
228
  font-weight: 700;
229
- margin-bottom: .4rem;
230
  }
 
231
  .rail-body {
232
  display: flex;
233
- gap: .5rem;
234
  overflow-x: auto;
235
- padding-bottom: .15rem;
 
 
236
  }
 
237
  .guest-card {
238
  display: flex;
239
- gap: .4rem;
240
  align-items: center;
241
- min-width: 148px;
242
- background: #fff;
243
- border: 1px solid var(--line);
244
- border-radius: 12px;
245
- padding: .4rem .5rem;
246
- }
247
- .guest-card.here { border-color: var(--accent); box-shadow: 0 0 0 2px #f0d8c6; }
248
- .guest-card.left { opacity: .55; }
249
- .guest-card img, .avatar-fallback {
 
 
 
 
 
 
 
 
250
  width: 36px;
251
  height: 36px;
252
  border-radius: 50%;
253
  object-fit: cover;
 
254
  }
 
255
  .avatar-fallback {
256
  display: grid;
257
  place-items: center;
258
- background: #e8d5c4;
 
259
  font-weight: 700;
 
 
 
 
 
 
 
260
  }
261
- .guest-card b { display: block; font-size: .86rem; }
262
- .guest-card small { color: var(--muted); font-size: .72rem; }
263
- .rail-empty { color: var(--muted); font-size: .88rem; }
264
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  .floaters {
266
  pointer-events: none;
267
  position: absolute;
268
  left: 50%;
269
- top: 18%;
270
  transform: translateX(-50%);
271
  z-index: 5;
272
  display: flex;
273
  flex-direction: column;
274
- gap: .35rem;
275
  align-items: center;
276
  }
 
277
  .floater {
278
- background: rgba(255,253,249,.95);
279
- border: 1px solid var(--line);
280
- border-radius: 999px;
281
- padding: .35rem .8rem;
282
  font-weight: 700;
 
283
  animation: rise 1.5s ease-out forwards;
284
- box-shadow: var(--shadow);
 
285
  }
286
- .floater.sale { color: var(--green); border-color: #b7d4be; }
287
- .floater.enter { color: var(--accent2); }
 
 
 
 
 
 
 
 
 
288
  @keyframes rise {
289
  from { opacity: 0; transform: translateY(12px); }
290
  20% { opacity: 1; }
@@ -293,279 +487,416 @@ button.big {
293
 
294
  .toast-stack {
295
  position: fixed;
296
- right: 1rem;
297
- bottom: 1rem;
298
  z-index: 40;
299
  display: flex;
300
  flex-direction: column;
301
- gap: .4rem;
302
  max-width: min(320px, 92vw);
303
  }
 
304
  .toast {
305
- background: #fffdf9;
306
- border: 1px solid var(--line);
307
- border-radius: 12px;
308
- padding: .65rem .8rem;
 
309
  box-shadow: var(--shadow);
310
- animation: slidein .25s ease-out;
311
- font-size: .9rem;
 
312
  }
313
- .toast.sale { border-color: #b7d4be; }
314
- .toast.enter { border-color: #e0b08c; }
315
- .toast.err { border-color: #e0a0a0; color: #8a3030; }
 
 
 
316
  @keyframes slidein {
317
  from { opacity: 0; transform: translateY(8px); }
318
  to { opacity: 1; transform: none; }
319
  }
320
- .help-list {
321
- margin: .4rem 0 1rem;
322
- padding-left: 1.2rem;
323
- color: var(--muted);
324
- line-height: 1.7;
325
- }
326
- .help-list b { color: var(--ink); }
327
 
328
- .money-row {
 
329
  display: flex;
330
- justify-content: space-between;
331
- align-items: baseline;
332
- margin-bottom: .55rem;
333
  }
334
- .money-row span { color: var(--muted); }
335
- .money-row strong {
336
- font-size: 1.85rem;
337
- letter-spacing: -.02em;
338
- color: var(--accent2);
 
 
 
 
 
 
 
 
 
 
 
339
  }
 
340
  .stat-grid {
341
  display: grid;
342
  grid-template-columns: repeat(3, 1fr);
343
- gap: .4rem;
 
344
  }
 
345
  .stat-grid div {
346
- background: #f7f0e8;
347
- border-radius: 10px;
348
- padding: .45rem .4rem;
 
349
  text-align: center;
350
  }
 
351
  .stat-grid em {
352
  display: block;
353
  font-style: normal;
354
- color: var(--muted);
355
- font-size: .72rem;
 
356
  }
357
- .stat-grid b { font-size: 1rem; }
358
 
 
 
 
 
 
 
 
359
  .atm {
360
  display: grid;
361
  grid-template-columns: 48px 1fr 34px;
362
- gap: .4rem;
363
  align-items: center;
364
- font-size: .8rem;
365
- margin-bottom: .35rem;
 
366
  }
 
367
  .bar {
368
  height: 8px;
369
- background: #efe4d8;
370
- border-radius: 99px;
371
  overflow: hidden;
 
372
  }
 
373
  .bar > i {
374
  display: block;
375
  height: 100%;
376
- background: linear-gradient(90deg, #e2b28f, var(--accent2));
377
- border-radius: 99px;
 
 
 
 
 
 
 
 
378
  }
379
- .pct { color: var(--muted); text-align: right; }
380
 
 
381
  .btn-grid {
382
  display: grid;
383
  grid-template-columns: 1fr 1fr;
384
- gap: .45rem;
385
  }
 
386
  .btn-grid.three { grid-template-columns: repeat(3, 1fr); }
387
- button, select {
 
 
388
  font: inherit;
389
- border-radius: 999px;
390
- border: 1px solid var(--line);
391
- background: #fff;
392
- color: var(--ink);
393
- padding: .48rem .7rem;
394
  cursor: pointer;
 
 
 
 
 
 
395
  }
396
- button:hover:not(:disabled) { box-shadow: 0 5px 14px rgba(0,0,0,.08); }
397
- button:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
398
- /* 营业中主按钮:不跟自动 tick 的 busy 绑在一起,避免视觉狂闪 */
399
- button.stable {
400
- transition: none;
401
  }
402
- button.stable:active {
403
- transform: none;
 
 
404
  }
 
405
  button.primary {
406
- background: linear-gradient(135deg, #d8a78a, var(--accent));
407
  border-color: transparent;
408
- color: #fff;
409
- font-weight: 650;
 
410
  }
411
- button.ghost { background: #f6efe7; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  button.active {
413
- border-color: var(--accent);
414
- background: #f8e8db;
415
- color: var(--accent2);
416
  font-weight: 650;
417
  }
418
- button.wide { width: 100%; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  .speed-row {
420
  display: flex;
421
  align-items: center;
422
  justify-content: space-between;
423
- gap: .6rem;
424
- margin-top: .65rem;
425
- color: var(--muted);
426
- font-size: .88rem;
427
- }
428
- .hint {
429
- margin: 0 0 .45rem;
430
- font-size: .78rem;
431
- color: #9a6a18;
432
- background: #fff6e8;
433
- border-radius: 8px;
434
- padding: .35rem .55rem;
435
- line-height: 1.4;
436
- }
437
- .hint.ok {
438
- color: var(--green);
439
- background: #eef6f0;
440
  }
 
 
 
441
  .form-row {
442
  display: grid;
443
  grid-template-columns: 1fr auto;
444
- gap: .4rem;
445
- margin-bottom: .45rem;
446
  }
447
- .form-row:has(select + select) {
 
448
  grid-template-columns: 1fr 1fr auto;
449
  }
450
- select { min-width: 0; width: 100%; }
451
 
452
- .shelf-list {
453
- display: flex;
454
- flex-direction: column;
455
- gap: .4rem;
456
- margin-top: .35rem;
457
- max-height: 150px;
458
- overflow: auto;
 
 
459
  }
460
- .shelf {
461
- display: flex;
462
- gap: .5rem;
463
- align-items: center;
464
- background: #f8f1e9;
465
- border-radius: 10px;
466
- padding: .35rem .45rem;
467
- }
468
- .shelf img {
469
- width: 34px;
470
- height: 34px;
471
- border-radius: 8px;
472
- object-fit: cover;
473
  }
474
- .shelf b { display: block; font-size: .84rem; }
475
- .shelf small { color: var(--muted); font-size: .74rem; }
476
 
 
 
 
 
 
477
  .log {
478
  list-style: none;
479
  margin: 0;
480
  padding: 0;
481
  max-height: 180px;
482
  overflow: auto;
483
- font-size: .84rem;
484
- color: var(--muted);
 
 
485
  }
 
486
  .log li {
487
- padding: .35rem 0;
488
- border-bottom: 1px dashed #eadfdf;
489
  }
490
- .log li:first-child { color: var(--ink); font-weight: 550; }
491
 
 
 
 
 
 
 
492
  .loading {
493
  min-height: 50vh;
494
  display: grid;
495
  place-items: center;
496
- gap: .6rem;
497
- color: var(--muted);
 
498
  }
 
499
  .spinner {
500
- width: 36px;
501
- height: 36px;
502
  border-radius: 50%;
503
- border: 3px solid #e8d8c8;
504
- border-top-color: var(--accent);
505
- animation: spin .8s linear infinite;
506
  }
 
507
  @keyframes spin { to { transform: rotate(360deg); } }
508
 
 
509
  .modal {
510
  position: fixed;
511
  inset: 0;
512
- background: rgba(42, 30, 24, .48);
513
  display: grid;
514
  place-items: center;
515
- padding: 1rem;
516
  z-index: 30;
517
- backdrop-filter: blur(3px);
518
  }
 
519
  .modal-card {
520
  width: min(440px, 100%);
521
- background: var(--card);
522
- border-radius: 20px;
523
- border: 1px solid var(--line);
524
- padding: 1.25rem 1.35rem 1.2rem;
525
- box-shadow: 0 24px 60px rgba(0,0,0,.2);
526
  }
 
527
  .modal-kicker {
528
- color: var(--accent2);
529
- font-size: .78rem;
530
- letter-spacing: .12em;
 
531
  font-weight: 700;
532
  }
533
- .modal-card h2 { margin: .25rem 0 .6rem; }
534
- .modal-card .lead { color: var(--ink); margin: 0 0 .5rem; }
 
 
 
 
 
 
 
 
 
 
 
 
535
  .modal-card .diary {
536
- color: var(--muted);
537
  font-style: italic;
538
  margin: 0 0 1rem;
539
  }
 
540
  .night-stats {
541
  display: grid;
542
  grid-template-columns: repeat(3, 1fr);
543
- gap: .45rem;
544
- margin-bottom: .8rem;
545
  }
 
546
  .night-stats div {
547
- background: #f6eee5;
548
- border-radius: 12px;
 
549
  text-align: center;
550
- padding: .55rem .3rem;
551
  }
 
552
  .night-stats em {
553
  display: block;
554
  font-style: normal;
555
- color: var(--muted);
556
- font-size: .75rem;
557
  }
558
- .vibe { color: var(--muted); font-size: .9rem; }
559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  .foot {
561
- margin-top: 1.2rem;
562
  text-align: center;
563
- color: var(--muted);
564
- font-size: .82rem;
 
 
 
 
 
565
  }
566
- .foot a { color: var(--accent2); }
567
 
568
- @media (max-width: 920px) {
 
 
 
 
 
 
 
 
569
  .shell { grid-template-columns: 1fr; }
570
- .form-row:has(select + select) { grid-template-columns: 1fr; }
 
 
 
 
 
 
 
 
571
  }
 
1
+ /* =========================================================
2
+ 小镇角落小店 · Catppuccin Userstyles + UX tokens
3
+ Flavors: Mocha (default, cozy dark) / Latte (light)
4
+ Spacing: 4/8/12/16/24/32 · Radius: 8/12/16/999
5
+ ========================================================= */
6
+
7
+ /* --- Catppuccin Mocha (default) --- */
8
+ :root,
9
+ [data-flavor="mocha"] {
10
+ --ctp-rosewater: #f5e0dc;
11
+ --ctp-flamingo: #f2cdcd;
12
+ --ctp-pink: #f5c2e7;
13
+ --ctp-mauve: #cba6f7;
14
+ --ctp-red: #f38ba8;
15
+ --ctp-maroon: #eba0ac;
16
+ --ctp-peach: #fab387;
17
+ --ctp-yellow: #f9e2af;
18
+ --ctp-green: #a6e3a1;
19
+ --ctp-teal: #94e2d5;
20
+ --ctp-sky: #89dceb;
21
+ --ctp-sapphire: #74c7ec;
22
+ --ctp-blue: #89b4fa;
23
+ --ctp-lavender: #b4befe;
24
+ --ctp-text: #cdd6f4;
25
+ --ctp-subtext1: #bac2de;
26
+ --ctp-subtext0: #a6adc8;
27
+ --ctp-overlay2: #9399b2;
28
+ --ctp-overlay1: #7f849c;
29
+ --ctp-overlay0: #6c7086;
30
+ --ctp-surface2: #585b70;
31
+ --ctp-surface1: #45475a;
32
+ --ctp-surface0: #313244;
33
+ --ctp-base: #1e1e2e;
34
+ --ctp-mantle: #181825;
35
+ --ctp-crust: #11111b;
36
+
37
+ --bg: var(--ctp-base);
38
+ --bg-deep: var(--ctp-crust);
39
+ --bg-elevated: var(--ctp-mantle);
40
+ --surface: var(--ctp-surface0);
41
+ --surface-2: var(--ctp-surface1);
42
+ --border: color-mix(in srgb, var(--ctp-overlay0) 45%, transparent);
43
+ --border-strong: var(--ctp-surface2);
44
+ --text: var(--ctp-text);
45
+ --text-muted: var(--ctp-subtext0);
46
+ --text-faint: var(--ctp-overlay1);
47
+ --accent: var(--ctp-peach);
48
+ --accent-2: var(--ctp-pink);
49
+ --accent-soft: color-mix(in srgb, var(--ctp-peach) 18%, var(--ctp-surface0));
50
+ --on-accent: #11111b;
51
+ --ok: var(--ctp-green);
52
+ --warn: var(--ctp-yellow);
53
+ --danger: var(--ctp-red);
54
+ --info: var(--ctp-blue);
55
+ --focus: var(--ctp-lavender);
56
+ --shadow: 0 12px 40px color-mix(in srgb, var(--ctp-crust) 65%, transparent);
57
+ --shadow-sm: 0 4px 14px color-mix(in srgb, var(--ctp-crust) 45%, transparent);
58
+ --glow: 0 0 0 3px color-mix(in srgb, var(--ctp-peach) 35%, transparent);
59
+ --radius-sm: 8px;
60
+ --radius: 14px;
61
+ --radius-lg: 18px;
62
+ --radius-pill: 999px;
63
+ --space-1: 4px;
64
+ --space-2: 8px;
65
+ --space-3: 12px;
66
+ --space-4: 16px;
67
+ --space-5: 24px;
68
+ --space-6: 32px;
69
+ --font: "JetBrains Mono", "SF Mono", "Cascadia Code", "PingFang SC",
70
+ "Microsoft YaHei", "Noto Sans SC", ui-sans-serif, system-ui, sans-serif;
71
+ --font-ui: "Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
72
  "Noto Sans SC", system-ui, sans-serif;
73
+ --stage-floor-a: color-mix(in srgb, var(--ctp-surface0) 80%, var(--ctp-base));
74
+ --stage-floor-b: color-mix(in srgb, var(--ctp-surface1) 55%, var(--ctp-base));
75
  }
76
 
77
+ /* --- Catppuccin Latte (light) --- */
78
+ [data-flavor="latte"] {
79
+ --ctp-rosewater: #dc8a78;
80
+ --ctp-flamingo: #dd7878;
81
+ --ctp-pink: #ea76cb;
82
+ --ctp-mauve: #8839ef;
83
+ --ctp-red: #d20f39;
84
+ --ctp-maroon: #e64553;
85
+ --ctp-peach: #fe640b;
86
+ --ctp-yellow: #df8e1d;
87
+ --ctp-green: #40a02b;
88
+ --ctp-teal: #179299;
89
+ --ctp-sky: #04a5e5;
90
+ --ctp-sapphire: #209fb5;
91
+ --ctp-blue: #1e66f5;
92
+ --ctp-lavender: #7287fd;
93
+ --ctp-text: #4c4f69;
94
+ --ctp-subtext1: #5c5f77;
95
+ --ctp-subtext0: #6c6f85;
96
+ --ctp-overlay2: #7c7f93;
97
+ --ctp-overlay1: #8c8fa1;
98
+ --ctp-overlay0: #9ca0b0;
99
+ --ctp-surface2: #acb0be;
100
+ --ctp-surface1: #bcc0cc;
101
+ --ctp-surface0: #ccd0da;
102
+ --ctp-base: #eff1f5;
103
+ --ctp-mantle: #e6e9ef;
104
+ --ctp-crust: #dce0e8;
105
+
106
+ --bg: var(--ctp-base);
107
+ --bg-deep: var(--ctp-crust);
108
+ --bg-elevated: #ffffff;
109
+ --surface: #ffffff;
110
+ --surface-2: var(--ctp-mantle);
111
+ --border: color-mix(in srgb, var(--ctp-overlay0) 55%, transparent);
112
+ --border-strong: var(--ctp-surface1);
113
+ --text: var(--ctp-text);
114
+ --text-muted: var(--ctp-subtext0);
115
+ --text-faint: var(--ctp-overlay1);
116
+ --accent: var(--ctp-peach);
117
+ --accent-2: var(--ctp-mauve);
118
+ --accent-soft: color-mix(in srgb, var(--ctp-peach) 14%, #fff);
119
+ --on-accent: #ffffff;
120
+ --ok: var(--ctp-green);
121
+ --warn: var(--ctp-yellow);
122
+ --danger: var(--ctp-red);
123
+ --info: var(--ctp-blue);
124
+ --focus: var(--ctp-lavender);
125
+ --shadow: 0 12px 32px color-mix(in srgb, var(--ctp-overlay0) 28%, transparent);
126
+ --shadow-sm: 0 4px 12px color-mix(in srgb, var(--ctp-overlay0) 18%, transparent);
127
+ --glow: 0 0 0 3px color-mix(in srgb, var(--ctp-mauve) 30%, transparent);
128
+ --stage-floor-a: #f8f9fc;
129
+ --stage-floor-b: #eef0f6;
130
+ }
131
+
132
+ /* --- Reset / base --- */
133
+ *,
134
+ *::before,
135
+ *::after { box-sizing: border-box; }
136
+
137
  html, body {
138
  margin: 0;
139
  min-height: 100%;
140
+ color: var(--text);
141
+ font-family: var(--font-ui);
142
+ font-size: 15px;
143
+ line-height: 1.5;
144
  background:
145
+ radial-gradient(ellipse 80% 50% at 10% -10%, color-mix(in srgb, var(--ctp-mauve) 18%, transparent), transparent 55%),
146
+ radial-gradient(ellipse 60% 40% at 90% 0%, color-mix(in srgb, var(--ctp-peach) 14%, transparent), transparent 50%),
147
+ radial-gradient(ellipse 50% 30% at 50% 100%, color-mix(in srgb, var(--ctp-pink) 10%, transparent), transparent 45%),
148
  var(--bg);
149
+ background-attachment: fixed;
150
+ -webkit-font-smoothing: antialiased;
151
+ }
152
+
153
+ [v-cloak] { display: none !important; }
154
+
155
+ :focus-visible {
156
+ outline: 2px solid var(--focus);
157
+ outline-offset: 2px;
158
+ }
159
+
160
+ ::selection {
161
+ background: color-mix(in srgb, var(--ctp-mauve) 40%, transparent);
162
+ color: var(--text);
163
  }
 
164
 
165
  #app {
166
+ max-width: 1240px;
167
  margin: 0 auto;
168
+ padding: var(--space-4) var(--space-4) var(--space-6);
169
  }
170
 
171
+ /* --- Top bar --- */
172
  .topbar {
173
+ display: grid;
174
+ grid-template-columns: minmax(200px, 1.2fr) minmax(0, 1.4fr) auto;
175
+ gap: var(--space-3) var(--space-4);
176
  align-items: center;
177
+ margin-bottom: var(--space-5);
178
+ padding: var(--space-3) var(--space-4);
179
+ background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
180
+ border: 1px solid var(--border);
181
+ border-radius: var(--radius-lg);
182
+ box-shadow: var(--shadow-sm);
183
+ backdrop-filter: blur(12px);
184
  }
185
+
186
  .brand {
187
  display: flex;
188
+ gap: var(--space-3);
189
  align-items: center;
190
+ min-width: 0;
191
  }
192
+
193
  .logo {
194
  width: 48px;
195
  height: 48px;
196
+ flex-shrink: 0;
197
+ border-radius: var(--radius);
198
+ background: linear-gradient(145deg, var(--ctp-peach), var(--ctp-pink));
199
  display: grid;
200
  place-items: center;
201
+ font-size: 1.35rem;
202
+ box-shadow: var(--shadow-sm), inset 0 1px 0 color-mix(in srgb, #fff 25%, transparent);
203
  }
204
+
205
  .brand h1 {
206
  margin: 0;
207
+ font-size: 1.2rem;
208
+ font-weight: 700;
209
+ letter-spacing: 0.01em;
210
+ color: var(--text);
211
+ white-space: nowrap;
212
+ overflow: hidden;
213
+ text-overflow: ellipsis;
214
  }
215
+
216
+ .brand .guide,
217
  .brand p {
218
+ margin: 2px 0 0;
219
+ color: var(--text-muted);
220
+ font-size: 0.82rem;
221
+ line-height: 1.35;
222
+ display: -webkit-box;
223
+ -webkit-line-clamp: 2;
224
+ -webkit-box-orient: vertical;
225
+ overflow: hidden;
226
  }
227
 
228
  .phase-track {
229
  display: flex;
230
  flex-wrap: wrap;
231
+ gap: 6px;
232
+ justify-content: center;
233
  }
234
+
235
  .phase-pill {
236
+ font-size: 0.72rem;
237
+ font-weight: 600;
238
+ letter-spacing: 0.04em;
239
+ padding: 0.28rem 0.65rem;
240
+ border-radius: var(--radius-pill);
241
+ border: 1px solid var(--border);
242
+ background: var(--surface);
243
+ color: var(--text-faint);
244
+ transition: color 0.15s, background 0.15s, border-color 0.15s, box-shadow 0.15s;
245
  }
246
+
247
  .phase-pill.on {
248
+ color: var(--ctp-crust);
249
  border-color: transparent;
250
+ background: linear-gradient(135deg, var(--ctp-peach), var(--ctp-pink));
251
+ box-shadow: 0 4px 14px color-mix(in srgb, var(--ctp-peach) 35%, transparent);
252
  }
253
+
254
  .top-meta {
255
  display: flex;
256
  flex-wrap: wrap;
257
+ gap: 0.4rem 0.65rem;
258
  align-items: center;
259
+ justify-content: flex-end;
260
+ color: var(--text-muted);
261
+ font-size: 0.88rem;
262
+ }
263
+
264
+ .top-meta strong { color: var(--text); font-weight: 700; }
265
+
266
+ .money-pill {
267
+ background: var(--accent-soft);
268
+ color: var(--accent);
269
+ font-weight: 700;
270
+ font-variant-numeric: tabular-nums;
271
+ padding: 0.22rem 0.65rem;
272
+ border-radius: var(--radius-pill);
273
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
274
+ }
275
+
276
+ .flavor-switch {
277
+ display: inline-flex;
278
+ border: 1px solid var(--border);
279
+ border-radius: var(--radius-pill);
280
+ overflow: hidden;
281
+ background: var(--surface);
282
+ }
283
+
284
+ .flavor-switch button {
285
+ border: 0;
286
+ border-radius: 0;
287
+ background: transparent;
288
+ color: var(--text-muted);
289
+ padding: 0.28rem 0.7rem;
290
+ font-size: 0.75rem;
291
+ font-weight: 600;
292
  }
293
+
294
+ .flavor-switch button[aria-pressed="true"] {
295
+ background: color-mix(in srgb, var(--ctp-mauve) 28%, var(--surface));
296
+ color: var(--text);
 
 
 
297
  }
298
 
299
+ /* --- Layout --- */
300
  .shell {
301
  display: grid;
302
  grid-template-columns: minmax(0, 1fr) 340px;
303
+ gap: var(--space-4);
304
  align-items: start;
305
  }
306
 
307
+ /* --- Stage --- */
308
  .stage-card {
309
  position: relative;
310
+ background: var(--bg-elevated);
311
+ border: 1px solid var(--border);
312
+ border-radius: var(--radius-lg);
313
  overflow: hidden;
314
  box-shadow: var(--shadow);
315
  }
316
+
317
  .stage-bg {
318
  position: absolute;
319
  inset: 0;
320
  background-size: cover;
321
  background-position: center;
322
+ opacity: 0.22;
323
  pointer-events: none;
324
+ filter: saturate(1.05);
325
  }
326
+
327
+ .stage-card.light-warm .stage-bg { filter: sepia(0.2) saturate(1.15); opacity: 0.28; }
328
+ .stage-card.light-cool .stage-bg { filter: hue-rotate(20deg) brightness(1.05); opacity: 0.26; }
329
+ .stage-card.light-off .stage-bg { filter: brightness(0.4) saturate(0.5); opacity: 0.18; }
330
+
331
  canvas {
332
  position: relative;
333
  display: block;
334
  width: 100%;
335
  height: auto;
336
+ background: linear-gradient(180deg, var(--stage-floor-a), var(--stage-floor-b));
337
  }
338
+
339
  .mood-bar {
340
  position: relative;
341
  display: flex;
342
+ gap: var(--space-2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  align-items: center;
344
+ padding: 0.7rem 1rem;
345
+ border-top: 1px solid var(--border);
346
+ background: color-mix(in srgb, var(--bg-elevated) 92%, var(--surface));
347
+ color: var(--text-muted);
348
+ font-size: 0.92rem;
349
  }
 
 
 
 
 
 
 
 
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  .live {
352
  margin-left: auto;
353
+ font-size: 0.78rem;
354
+ font-weight: 700;
355
+ color: var(--ctp-teal);
356
+ background: color-mix(in srgb, var(--ctp-teal) 14%, transparent);
357
+ padding: 0.15rem 0.55rem;
358
+ border-radius: var(--radius-pill);
359
  }
 
360
 
361
+ .live.dim {
362
+ color: var(--text-faint);
363
+ background: var(--surface);
364
+ font-weight: 500;
365
+ }
366
+
367
+ /* Guest rail */
368
  .guest-rail {
369
+ border-top: 1px solid var(--border);
370
+ background: var(--bg-deep);
371
+ padding: 0.65rem 0.85rem 0.8rem;
372
  }
373
+
374
  .rail-title {
375
+ font-size: 0.72rem;
376
+ letter-spacing: 0.1em;
377
+ text-transform: uppercase;
378
+ color: var(--text-faint);
379
  font-weight: 700;
380
+ margin-bottom: 0.45rem;
381
  }
382
+
383
  .rail-body {
384
  display: flex;
385
+ gap: 0.5rem;
386
  overflow-x: auto;
387
+ padding-bottom: 2px;
388
+ scrollbar-width: thin;
389
+ scrollbar-color: var(--surface-2) transparent;
390
  }
391
+
392
  .guest-card {
393
  display: flex;
394
+ gap: 0.45rem;
395
  align-items: center;
396
+ min-width: 152px;
397
+ background: var(--surface);
398
+ border: 1px solid var(--border);
399
+ border-radius: var(--radius);
400
+ padding: 0.4rem 0.55rem;
401
+ transition: border-color 0.15s, box-shadow 0.15s, opacity 0.15s;
402
+ }
403
+
404
+ .guest-card.here {
405
+ border-color: color-mix(in srgb, var(--ctp-peach) 55%, var(--border));
406
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ctp-peach) 22%, transparent);
407
+ }
408
+
409
+ .guest-card.left { opacity: 0.5; }
410
+
411
+ .guest-card img,
412
+ .avatar-fallback {
413
  width: 36px;
414
  height: 36px;
415
  border-radius: 50%;
416
  object-fit: cover;
417
+ flex-shrink: 0;
418
  }
419
+
420
  .avatar-fallback {
421
  display: grid;
422
  place-items: center;
423
+ background: linear-gradient(145deg, var(--ctp-mauve), var(--ctp-blue));
424
+ color: var(--ctp-crust);
425
  font-weight: 700;
426
+ font-size: 0.9rem;
427
+ }
428
+
429
+ .guest-card b {
430
+ display: block;
431
+ font-size: 0.86rem;
432
+ color: var(--text);
433
  }
 
 
 
434
 
435
+ .guest-card small {
436
+ color: var(--text-muted);
437
+ font-size: 0.72rem;
438
+ }
439
+
440
+ .rail-empty {
441
+ color: var(--text-faint);
442
+ font-size: 0.88rem;
443
+ padding: 0.35rem 0;
444
+ }
445
+
446
+ /* Floaters / toasts */
447
  .floaters {
448
  pointer-events: none;
449
  position: absolute;
450
  left: 50%;
451
+ top: 16%;
452
  transform: translateX(-50%);
453
  z-index: 5;
454
  display: flex;
455
  flex-direction: column;
456
+ gap: 0.35rem;
457
  align-items: center;
458
  }
459
+
460
  .floater {
461
+ background: color-mix(in srgb, var(--bg-elevated) 94%, transparent);
462
+ border: 1px solid var(--border);
463
+ border-radius: var(--radius-pill);
464
+ padding: 0.35rem 0.85rem;
465
  font-weight: 700;
466
+ color: var(--text);
467
  animation: rise 1.5s ease-out forwards;
468
+ box-shadow: var(--shadow-sm);
469
+ backdrop-filter: blur(8px);
470
  }
471
+
472
+ .floater.sale {
473
+ color: var(--ok);
474
+ border-color: color-mix(in srgb, var(--ok) 45%, var(--border));
475
+ }
476
+
477
+ .floater.enter {
478
+ color: var(--ctp-peach);
479
+ border-color: color-mix(in srgb, var(--ctp-peach) 40%, var(--border));
480
+ }
481
+
482
  @keyframes rise {
483
  from { opacity: 0; transform: translateY(12px); }
484
  20% { opacity: 1; }
 
487
 
488
  .toast-stack {
489
  position: fixed;
490
+ right: var(--space-4);
491
+ bottom: var(--space-4);
492
  z-index: 40;
493
  display: flex;
494
  flex-direction: column;
495
+ gap: 0.4rem;
496
  max-width: min(320px, 92vw);
497
  }
498
+
499
  .toast {
500
+ background: var(--bg-elevated);
501
+ border: 1px solid var(--border);
502
+ border-left: 3px solid var(--ctp-lavender);
503
+ border-radius: var(--radius);
504
+ padding: 0.7rem 0.85rem;
505
  box-shadow: var(--shadow);
506
+ animation: slidein 0.22s ease-out;
507
+ font-size: 0.9rem;
508
+ color: var(--text);
509
  }
510
+
511
+ .toast.sale { border-left-color: var(--ok); }
512
+ .toast.enter { border-left-color: var(--ctp-peach); }
513
+ .toast.err { border-left-color: var(--danger); color: var(--danger); }
514
+ .toast.phase { border-left-color: var(--ctp-mauve); }
515
+
516
  @keyframes slidein {
517
  from { opacity: 0; transform: translateY(8px); }
518
  to { opacity: 1; transform: none; }
519
  }
 
 
 
 
 
 
 
520
 
521
+ /* --- HUD cards --- */
522
+ .hud {
523
  display: flex;
524
+ flex-direction: column;
525
+ gap: var(--space-3);
 
526
  }
527
+
528
+ .card {
529
+ background: var(--bg-elevated);
530
+ border: 1px solid var(--border);
531
+ border-radius: var(--radius-lg);
532
+ padding: var(--space-4);
533
+ box-shadow: var(--shadow-sm);
534
+ }
535
+
536
+ .card h2 {
537
+ margin: 0 0 var(--space-3);
538
+ font-size: 0.72rem;
539
+ letter-spacing: 0.12em;
540
+ text-transform: uppercase;
541
+ color: var(--text-faint);
542
+ font-weight: 700;
543
  }
544
+
545
  .stat-grid {
546
  display: grid;
547
  grid-template-columns: repeat(3, 1fr);
548
+ gap: var(--space-2);
549
+ margin-bottom: var(--space-3);
550
  }
551
+
552
  .stat-grid div {
553
+ background: var(--surface);
554
+ border: 1px solid var(--border);
555
+ border-radius: var(--radius-sm);
556
+ padding: 0.5rem 0.35rem;
557
  text-align: center;
558
  }
559
+
560
  .stat-grid em {
561
  display: block;
562
  font-style: normal;
563
+ color: var(--text-faint);
564
+ font-size: 0.7rem;
565
+ margin-bottom: 2px;
566
  }
 
567
 
568
+ .stat-grid b {
569
+ font-size: 1.05rem;
570
+ font-variant-numeric: tabular-nums;
571
+ color: var(--text);
572
+ }
573
+
574
+ /* Atmosphere bars */
575
  .atm {
576
  display: grid;
577
  grid-template-columns: 48px 1fr 34px;
578
+ gap: 0.4rem;
579
  align-items: center;
580
+ font-size: 0.8rem;
581
+ margin-bottom: 0.4rem;
582
+ color: var(--text-muted);
583
  }
584
+
585
  .bar {
586
  height: 8px;
587
+ background: var(--surface);
588
+ border-radius: var(--radius-pill);
589
  overflow: hidden;
590
+ border: 1px solid var(--border);
591
  }
592
+
593
  .bar > i {
594
  display: block;
595
  height: 100%;
596
+ background: linear-gradient(90deg, var(--ctp-mauve), var(--ctp-peach));
597
+ border-radius: var(--radius-pill);
598
+ transition: width 0.35s ease;
599
+ }
600
+
601
+ .pct {
602
+ color: var(--text-faint);
603
+ text-align: right;
604
+ font-variant-numeric: tabular-nums;
605
+ font-size: 0.75rem;
606
  }
 
607
 
608
+ /* Buttons & forms */
609
  .btn-grid {
610
  display: grid;
611
  grid-template-columns: 1fr 1fr;
612
+ gap: 0.45rem;
613
  }
614
+
615
  .btn-grid.three { grid-template-columns: repeat(3, 1fr); }
616
+
617
+ button,
618
+ select {
619
  font: inherit;
620
+ border-radius: var(--radius-pill);
621
+ border: 1px solid var(--border);
622
+ background: var(--surface);
623
+ color: var(--text);
624
+ padding: 0.5rem 0.85rem;
625
  cursor: pointer;
626
+ transition: background 0.12s, border-color 0.12s, box-shadow 0.12s, opacity 0.12s;
627
+ }
628
+
629
+ button:hover:not(:disabled) {
630
+ border-color: var(--border-strong);
631
+ background: var(--surface-2);
632
  }
633
+
634
+ button:active:not(:disabled) {
635
+ transform: translateY(1px);
 
 
636
  }
637
+
638
+ button:disabled {
639
+ opacity: 0.42;
640
+ cursor: not-allowed;
641
  }
642
+
643
  button.primary {
644
+ background: linear-gradient(135deg, var(--ctp-peach), var(--ctp-pink));
645
  border-color: transparent;
646
+ color: var(--on-accent);
647
+ font-weight: 700;
648
+ box-shadow: 0 6px 18px color-mix(in srgb, var(--ctp-peach) 30%, transparent);
649
  }
650
+
651
+ button.primary:hover:not(:disabled) {
652
+ filter: brightness(1.05);
653
+ background: linear-gradient(135deg, var(--ctp-peach), var(--ctp-pink));
654
+ }
655
+
656
+ button.ghost {
657
+ background: transparent;
658
+ border-color: var(--border);
659
+ color: var(--text-muted);
660
+ }
661
+
662
+ button.ghost:hover:not(:disabled) {
663
+ background: var(--surface);
664
+ color: var(--text);
665
+ }
666
+
667
  button.active {
668
+ border-color: color-mix(in srgb, var(--ctp-mauve) 55%, var(--border));
669
+ background: color-mix(in srgb, var(--ctp-mauve) 22%, var(--surface));
670
+ color: var(--ctp-lavender);
671
  font-weight: 650;
672
  }
673
+
674
+ button.big {
675
+ width: 100%;
676
+ padding: 0.9rem 1rem;
677
+ font-size: 1.05rem;
678
+ margin: 0.2rem 0 0.55rem;
679
+ border-radius: var(--radius);
680
+ }
681
+
682
+ button.wide { width: 100%; border-radius: var(--radius); }
683
+ button.stable { transition: background 0.12s, border-color 0.12s; }
684
+ button.stable:active { transform: none; }
685
+
686
+ select {
687
+ min-width: 0;
688
+ width: 100%;
689
+ appearance: none;
690
+ background-image: linear-gradient(45deg, transparent 50%, var(--text-faint) 50%),
691
+ linear-gradient(135deg, var(--text-faint) 50%, transparent 50%);
692
+ background-position: calc(100% - 16px) calc(50% - 2px), calc(100% - 11px) calc(50% - 2px);
693
+ background-size: 5px 5px, 5px 5px;
694
+ background-repeat: no-repeat;
695
+ padding-right: 1.6rem;
696
+ }
697
+
698
  .speed-row {
699
  display: flex;
700
  align-items: center;
701
  justify-content: space-between;
702
+ gap: 0.6rem;
703
+ margin-top: 0.65rem;
704
+ color: var(--text-muted);
705
+ font-size: 0.88rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  }
707
+
708
+ .speed-row select { width: auto; min-width: 7.5rem; }
709
+
710
  .form-row {
711
  display: grid;
712
  grid-template-columns: 1fr auto;
713
+ gap: 0.4rem;
714
+ margin-bottom: 0.45rem;
715
  }
716
+
717
+ .form-row.triple {
718
  grid-template-columns: 1fr 1fr auto;
719
  }
 
720
 
721
+ .hint {
722
+ margin: 0 0 0.5rem;
723
+ font-size: 0.78rem;
724
+ color: var(--warn);
725
+ background: color-mix(in srgb, var(--warn) 12%, var(--surface));
726
+ border: 1px solid color-mix(in srgb, var(--warn) 28%, var(--border));
727
+ border-radius: var(--radius-sm);
728
+ padding: 0.4rem 0.6rem;
729
+ line-height: 1.4;
730
  }
731
+
732
+ .hint.ok {
733
+ color: var(--ok);
734
+ background: color-mix(in srgb, var(--ok) 12%, var(--surface));
735
+ border-color: color-mix(in srgb, var(--ok) 28%, var(--border));
 
 
 
 
 
 
 
 
736
  }
 
 
737
 
738
+ .light-row { margin-top: 0.55rem; }
739
+
740
+ .hero-actions .hint { margin-bottom: 0.55rem; }
741
+
742
+ /* Log */
743
  .log {
744
  list-style: none;
745
  margin: 0;
746
  padding: 0;
747
  max-height: 180px;
748
  overflow: auto;
749
+ font-size: 0.84rem;
750
+ color: var(--text-muted);
751
+ scrollbar-width: thin;
752
+ scrollbar-color: var(--surface-2) transparent;
753
  }
754
+
755
  .log li {
756
+ padding: 0.4rem 0;
757
+ border-bottom: 1px dashed color-mix(in srgb, var(--border) 80%, transparent);
758
  }
 
759
 
760
+ .log li:first-child {
761
+ color: var(--text);
762
+ font-weight: 600;
763
+ }
764
+
765
+ /* Loading */
766
  .loading {
767
  min-height: 50vh;
768
  display: grid;
769
  place-items: center;
770
+ gap: 0.7rem;
771
+ color: var(--text-muted);
772
+ text-align: center;
773
  }
774
+
775
  .spinner {
776
+ width: 40px;
777
+ height: 40px;
778
  border-radius: 50%;
779
+ border: 3px solid var(--surface);
780
+ border-top-color: var(--ctp-peach);
781
+ animation: spin 0.75s linear infinite;
782
  }
783
+
784
  @keyframes spin { to { transform: rotate(360deg); } }
785
 
786
+ /* Modal */
787
  .modal {
788
  position: fixed;
789
  inset: 0;
790
+ background: color-mix(in srgb, var(--ctp-crust) 62%, transparent);
791
  display: grid;
792
  place-items: center;
793
+ padding: var(--space-4);
794
  z-index: 30;
795
+ backdrop-filter: blur(6px);
796
  }
797
+
798
  .modal-card {
799
  width: min(440px, 100%);
800
+ background: var(--bg-elevated);
801
+ border-radius: var(--radius-lg);
802
+ border: 1px solid var(--border);
803
+ padding: 1.35rem 1.4rem 1.25rem;
804
+ box-shadow: var(--shadow);
805
  }
806
+
807
  .modal-kicker {
808
+ color: var(--ctp-mauve);
809
+ font-size: 0.75rem;
810
+ letter-spacing: 0.14em;
811
+ text-transform: uppercase;
812
  font-weight: 700;
813
  }
814
+
815
+ .modal-card h2 {
816
+ margin: 0.3rem 0 0.65rem;
817
+ color: var(--text);
818
+ text-transform: none;
819
+ letter-spacing: 0;
820
+ font-size: 1.25rem;
821
+ }
822
+
823
+ .modal-card .lead {
824
+ color: var(--text);
825
+ margin: 0 0 0.5rem;
826
+ }
827
+
828
  .modal-card .diary {
829
+ color: var(--text-muted);
830
  font-style: italic;
831
  margin: 0 0 1rem;
832
  }
833
+
834
  .night-stats {
835
  display: grid;
836
  grid-template-columns: repeat(3, 1fr);
837
+ gap: 0.45rem;
838
+ margin-bottom: 0.85rem;
839
  }
840
+
841
  .night-stats div {
842
+ background: var(--surface);
843
+ border: 1px solid var(--border);
844
+ border-radius: var(--radius);
845
  text-align: center;
846
+ padding: 0.55rem 0.3rem;
847
  }
848
+
849
  .night-stats em {
850
  display: block;
851
  font-style: normal;
852
+ color: var(--text-faint);
853
+ font-size: 0.72rem;
854
  }
 
855
 
856
+ .vibe {
857
+ color: var(--text-muted);
858
+ font-size: 0.9rem;
859
+ }
860
+
861
+ .help-list {
862
+ margin: 0.4rem 0 1rem;
863
+ padding-left: 1.2rem;
864
+ color: var(--text-muted);
865
+ line-height: 1.75;
866
+ }
867
+
868
+ .help-list b { color: var(--text); }
869
+
870
+ /* Footer */
871
  .foot {
872
+ margin-top: var(--space-5);
873
  text-align: center;
874
+ color: var(--text-faint);
875
+ font-size: 0.8rem;
876
+ }
877
+
878
+ .foot a {
879
+ color: var(--ctp-lavender);
880
+ text-decoration: none;
881
  }
 
882
 
883
+ .foot a:hover { text-decoration: underline; }
884
+
885
+ /* Responsive */
886
+ @media (max-width: 960px) {
887
+ .topbar {
888
+ grid-template-columns: 1fr;
889
+ }
890
+ .top-meta { justify-content: flex-start; }
891
+ .phase-track { justify-content: flex-start; }
892
  .shell { grid-template-columns: 1fr; }
893
+ .form-row.triple { grid-template-columns: 1fr; }
894
+ }
895
+
896
+ @media (prefers-reduced-motion: reduce) {
897
+ *, *::before, *::after {
898
+ animation-duration: 0.01ms !important;
899
+ animation-iteration-count: 1 !important;
900
+ transition-duration: 0.01ms !important;
901
+ }
902
  }