@audiofool/moondog 0.1.0 → 0.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. package/src/surfaces/web/studio.mjs +0 -2288
@@ -1,4148 +0,0 @@
1
- function escapeHtml(value) {
2
- return String(value)
3
- .replaceAll("&", "&")
4
- .replaceAll("<", "&lt;")
5
- .replaceAll(">", "&gt;")
6
- .replaceAll('"', "&quot;")
7
- .replaceAll("'", "&#39;");
8
- }
9
-
10
- export function renderStudioPage({
11
- sessionToken,
12
- nonce,
13
- maximumArchiveBytes,
14
- maximumHistoryJsonBytes,
15
- demoOnly = false,
16
- archiveSession = false,
17
- archiveCount = archiveSession ? 1 : 0,
18
- captureMode = null,
19
- } = {}) {
20
- if (typeof sessionToken !== "string" || !/^[A-Za-z0-9_-]{32,}$/u.test(sessionToken)) {
21
- throw new TypeError("Studio session token is invalid.");
22
- }
23
- if (typeof nonce !== "string" || !/^[A-Za-z0-9_-]{16,}$/u.test(nonce)) {
24
- throw new TypeError("Studio CSP nonce is invalid.");
25
- }
26
- if (!Number.isSafeInteger(maximumArchiveBytes) || maximumArchiveBytes < 4) {
27
- throw new TypeError("Studio archive size limit is invalid.");
28
- }
29
- if (
30
- !Number.isSafeInteger(maximumHistoryJsonBytes) ||
31
- maximumHistoryJsonBytes < 1
32
- ) {
33
- throw new TypeError("Studio history JSON size limit is invalid.");
34
- }
35
- if (typeof demoOnly !== "boolean") {
36
- throw new TypeError("Studio demo-only mode is invalid.");
37
- }
38
- if (typeof archiveSession !== "boolean" || (demoOnly && archiveSession)) {
39
- throw new TypeError("Studio archive-session mode is invalid.");
40
- }
41
- if (
42
- !Number.isInteger(archiveCount) ||
43
- (archiveSession
44
- ? archiveCount < 1 || archiveCount > 2
45
- : archiveCount !== 0)
46
- ) {
47
- throw new TypeError("Studio archive count is invalid.");
48
- }
49
- if (
50
- !new Set([
51
- null,
52
- "hero",
53
- "time-machine",
54
- "listening-pulse",
55
- "listening-seasons",
56
- "historical-returns",
57
- "listening-patterns",
58
- "correction",
59
- ]).has(captureMode) ||
60
- (captureMode !== null && !demoOnly)
61
- ) {
62
- throw new TypeError("Studio capture mode is invalid.");
63
- }
64
- const maximumMegabytes = Math.floor(maximumArchiveBytes / (1024 * 1024));
65
- const maximumJsonMegabytes = Math.floor(
66
- maximumHistoryJsonBytes / (1024 * 1024),
67
- );
68
- const token = escapeHtml(sessionToken);
69
- const safeNonce = escapeHtml(nonce);
70
- const sessionMode = archiveSession
71
- ? "archive"
72
- : demoOnly
73
- ? "demo"
74
- : "persistent";
75
- const privateInMemory = archiveSession;
76
- const importHidden = demoOnly || archiveSession;
77
-
78
- return `<!doctype html>
79
- <html lang="en"${captureMode ? ` data-capture="${captureMode}"` : ""}>
80
- <head>
81
- <meta charset="utf-8">
82
- <meta name="viewport" content="width=device-width, initial-scale=1">
83
- <meta name="robots" content="noindex, nofollow, noarchive">
84
- <meta name="referrer" content="no-referrer">
85
- <meta name="color-scheme" content="light">
86
- <link rel="icon" href="/brand.png?session=${token}" type="image/png">
87
- <title>${demoOnly
88
- ? "Moondog Studio - Fictional Listening Time Machine"
89
- : privateInMemory
90
- ? "Moondog Studio - Session-only Spotify History"
91
- : "Moondog Studio - Private Listening History"}</title>
92
- <style nonce="${safeNonce}">
93
- :root {
94
- --paper: #eee8de;
95
- --paper-deep: #e2d8ca;
96
- --card: #f8f4ed;
97
- --ink: #1b1b19;
98
- --night: #1b1b19;
99
- --night-soft: #282825;
100
- --muted: #6d665d;
101
- --line: #c8bdaf;
102
- --line-strong: #94887a;
103
- --blue: #7897b3;
104
- --blue-soft: #d8e5ef;
105
- --green: #4f735f;
106
- --red: #b4523f;
107
- --shadow: 0 24px 70px rgba(45, 35, 26, 0.11);
108
- color: var(--ink);
109
- background: var(--paper);
110
- font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
111
- }
112
-
113
- * { box-sizing: border-box; }
114
-
115
- html { min-width: 320px; }
116
-
117
- html[data-capture] { scrollbar-width: none; }
118
-
119
- html[data-capture]::-webkit-scrollbar { display: none; }
120
-
121
- html[data-capture] *,
122
- html[data-capture] *::before,
123
- html[data-capture] *::after {
124
- scroll-behavior: auto !important;
125
- animation: none !important;
126
- transition: none !important;
127
- }
128
-
129
- html[data-capture="correction"] .topbar,
130
- html[data-capture="correction"] .hero,
131
- html[data-capture="correction"] .workspace,
132
- html[data-capture="correction"] .footnote { display: none !important; }
133
-
134
- html[data-capture="correction"] .shell {
135
- width: min(1180px, calc(100% - 40px));
136
- padding: 20px 0;
137
- }
138
-
139
- html[data-capture="correction"] .tuner {
140
- min-height: 800px;
141
- margin-top: 0;
142
- }
143
-
144
- html[data-capture="time-machine"] .topbar,
145
- html[data-capture="time-machine"] .hero,
146
- html[data-capture="time-machine"] .workspace,
147
- html[data-capture="time-machine"] .footnote,
148
- html[data-capture="time-machine"] .tuner-workbench,
149
- html[data-capture="time-machine"] .tuner-intro > .section-label,
150
- html[data-capture="time-machine"] .tuner-intro > h2,
151
- html[data-capture="time-machine"] .tuner-lede,
152
- html[data-capture="time-machine"] .demo-start-action,
153
- html[data-capture="time-machine"] .profile-metrics,
154
- html[data-capture="time-machine"] .identity-link-coverage,
155
- html[data-capture="time-machine"] .listening-pulse-preview,
156
- html[data-capture="time-machine"] .listening-seasons-preview,
157
- html[data-capture="time-machine"] .historical-returns-preview,
158
- html[data-capture="time-machine"] .continuity-preview,
159
- html[data-capture="time-machine"] .listening-patterns-preview,
160
- html[data-capture="time-machine"] .profile-artifact-links,
161
- html[data-capture="listening-patterns"] .topbar,
162
- html[data-capture="listening-patterns"] .hero,
163
- html[data-capture="listening-patterns"] .workspace,
164
- html[data-capture="listening-patterns"] .footnote,
165
- html[data-capture="listening-patterns"] .tuner-workbench,
166
- html[data-capture="listening-patterns"] .tuner-intro > .section-label,
167
- html[data-capture="listening-patterns"] .tuner-intro > h2,
168
- html[data-capture="listening-patterns"] .tuner-lede,
169
- html[data-capture="listening-patterns"] .demo-start-action,
170
- html[data-capture="listening-patterns"] .profile-metrics,
171
- html[data-capture="listening-patterns"] .identity-link-coverage,
172
- html[data-capture="listening-patterns"] .time-machine-preview,
173
- html[data-capture="listening-patterns"] .listening-pulse-preview,
174
- html[data-capture="listening-patterns"] .listening-seasons-preview,
175
- html[data-capture="listening-patterns"] .historical-returns-preview,
176
- html[data-capture="listening-patterns"] .continuity-preview,
177
- html[data-capture="listening-patterns"] .profile-artifact-links,
178
- html[data-capture="historical-returns"] .topbar,
179
- html[data-capture="historical-returns"] .hero,
180
- html[data-capture="historical-returns"] .workspace,
181
- html[data-capture="historical-returns"] .footnote,
182
- html[data-capture="historical-returns"] .tuner-workbench,
183
- html[data-capture="historical-returns"] .tuner-intro > .section-label,
184
- html[data-capture="historical-returns"] .tuner-intro > h2,
185
- html[data-capture="historical-returns"] .tuner-lede,
186
- html[data-capture="historical-returns"] .demo-start-action,
187
- html[data-capture="historical-returns"] .profile-metrics,
188
- html[data-capture="historical-returns"] .identity-link-coverage,
189
- html[data-capture="historical-returns"] .time-machine-preview,
190
- html[data-capture="historical-returns"] .listening-pulse-preview,
191
- html[data-capture="historical-returns"] .listening-seasons-preview,
192
- html[data-capture="historical-returns"] .continuity-preview:not(.historical-returns-preview),
193
- html[data-capture="historical-returns"] .listening-patterns-preview,
194
- html[data-capture="historical-returns"] .profile-artifact-links,
195
- html[data-capture="listening-seasons"] .topbar,
196
- html[data-capture="listening-seasons"] .hero,
197
- html[data-capture="listening-seasons"] .workspace,
198
- html[data-capture="listening-seasons"] .footnote,
199
- html[data-capture="listening-seasons"] .tuner-workbench,
200
- html[data-capture="listening-seasons"] .tuner-intro > .section-label,
201
- html[data-capture="listening-seasons"] .tuner-intro > h2,
202
- html[data-capture="listening-seasons"] .tuner-lede,
203
- html[data-capture="listening-seasons"] .demo-start-action,
204
- html[data-capture="listening-seasons"] .profile-metrics,
205
- html[data-capture="listening-seasons"] .identity-link-coverage,
206
- html[data-capture="listening-seasons"] .time-machine-preview,
207
- html[data-capture="listening-seasons"] .listening-pulse-preview,
208
- html[data-capture="listening-seasons"] .historical-returns-preview,
209
- html[data-capture="listening-seasons"] .continuity-preview,
210
- html[data-capture="listening-seasons"] .listening-patterns-preview,
211
- html[data-capture="listening-seasons"] .profile-artifact-links {
212
- display: none !important;
213
- }
214
-
215
- html[data-capture="listening-pulse"] .topbar,
216
- html[data-capture="listening-pulse"] .hero,
217
- html[data-capture="listening-pulse"] .workspace,
218
- html[data-capture="listening-pulse"] .footnote,
219
- html[data-capture="listening-pulse"] .tuner-workbench,
220
- html[data-capture="listening-pulse"] .tuner-intro > .section-label,
221
- html[data-capture="listening-pulse"] .tuner-intro > h2,
222
- html[data-capture="listening-pulse"] .tuner-lede,
223
- html[data-capture="listening-pulse"] .demo-start-action,
224
- html[data-capture="listening-pulse"] .profile-metrics,
225
- html[data-capture="listening-pulse"] .identity-link-coverage,
226
- html[data-capture="listening-pulse"] .time-machine-preview,
227
- html[data-capture="listening-pulse"] .listening-seasons-preview,
228
- html[data-capture="listening-pulse"] .continuity-preview,
229
- html[data-capture="listening-pulse"] .listening-patterns-preview,
230
- html[data-capture="listening-pulse"] .profile-artifact-links {
231
- display: none !important;
232
- }
233
-
234
- html[data-capture="time-machine"] .shell,
235
- html[data-capture="listening-pulse"] .shell,
236
- html[data-capture="listening-seasons"] .shell,
237
- html[data-capture="historical-returns"] .shell,
238
- html[data-capture="listening-patterns"] .shell {
239
- width: min(1180px, calc(100% - 40px));
240
- padding: 20px 0;
241
- }
242
-
243
- html[data-capture="time-machine"] .tuner,
244
- html[data-capture="listening-pulse"] .tuner,
245
- html[data-capture="listening-seasons"] .tuner,
246
- html[data-capture="historical-returns"] .tuner,
247
- html[data-capture="listening-patterns"] .tuner {
248
- display: block;
249
- min-height: 800px;
250
- margin-top: 0;
251
- border-color: #0e0e0d;
252
- background: var(--night);
253
- }
254
-
255
- html[data-capture="time-machine"] .tuner-intro,
256
- html[data-capture="listening-pulse"] .tuner-intro,
257
- html[data-capture="listening-seasons"] .tuner-intro,
258
- html[data-capture="historical-returns"] .tuner-intro,
259
- html[data-capture="listening-patterns"] .tuner-intro {
260
- min-height: 800px;
261
- padding: 54px 62px;
262
- }
263
-
264
- html[data-capture="time-machine"] .profile-state,
265
- html[data-capture="listening-pulse"] .profile-state,
266
- html[data-capture="listening-seasons"] .profile-state,
267
- html[data-capture="historical-returns"] .profile-state,
268
- html[data-capture="listening-patterns"] .profile-state {
269
- margin-top: 0;
270
- padding-top: 0;
271
- border-top: 0;
272
- }
273
-
274
- html[data-capture="time-machine"] .profile-state-head,
275
- html[data-capture="listening-pulse"] .profile-state-head,
276
- html[data-capture="listening-seasons"] .profile-state-head,
277
- html[data-capture="historical-returns"] .profile-state-head,
278
- html[data-capture="listening-patterns"] .profile-state-head {
279
- max-width: 700px;
280
- }
281
-
282
- html[data-capture="time-machine"] .time-machine-preview,
283
- html[data-capture="listening-pulse"] .listening-pulse-preview,
284
- html[data-capture="listening-seasons"] .listening-seasons-preview,
285
- html[data-capture="historical-returns"] .historical-returns-preview,
286
- html[data-capture="listening-patterns"] .listening-patterns-preview {
287
- margin-top: 40px;
288
- padding: 34px 38px;
289
- border-radius: 24px;
290
- background: rgba(166, 191, 213, 0.1);
291
- }
292
-
293
- html[data-capture="time-machine"] .time-machine-preview-head strong,
294
- html[data-capture="listening-pulse"] .time-machine-preview-head strong,
295
- html[data-capture="listening-seasons"] .time-machine-preview-head strong,
296
- html[data-capture="historical-returns"] .time-machine-preview-head strong,
297
- html[data-capture="listening-patterns"] .time-machine-preview-head strong {
298
- font-size: 32px;
299
- }
300
-
301
- html[data-capture="time-machine"] .time-machine-count,
302
- html[data-capture="listening-pulse"] .time-machine-count,
303
- html[data-capture="listening-seasons"] .time-machine-count,
304
- html[data-capture="historical-returns"] .time-machine-count,
305
- html[data-capture="listening-patterns"] .time-machine-count {
306
- font-size: 10px;
307
- }
308
-
309
- html[data-capture="time-machine"] .time-machine-coverage,
310
- html[data-capture="listening-pulse"] .listening-pulse-summary,
311
- html[data-capture="listening-seasons"] .listening-seasons-summary,
312
- html[data-capture="historical-returns"] .continuity-summary,
313
- html[data-capture="listening-patterns"] .listening-patterns-summary {
314
- max-width: 820px;
315
- margin-top: 18px;
316
- font-size: 13px;
317
- line-height: 1.6;
318
- }
319
-
320
- html[data-capture="time-machine"] .time-machine-coverage strong {
321
- font-size: 14px;
322
- }
323
-
324
- html[data-capture="listening-pulse"] .listening-pulse-preview {
325
- border-color: rgba(166, 191, 213, 0.5);
326
- background:
327
- radial-gradient(circle at 100% 0%, rgba(166, 191, 213, 0.16), transparent 22rem),
328
- rgba(166, 191, 213, 0.08);
329
- }
330
-
331
- html[data-capture="listening-pulse"] .studio-pulse-grid {
332
- gap: 7px;
333
- grid-template-columns: 42px repeat(12, minmax(0, 1fr));
334
- margin: 28px 0 24px;
335
- }
336
-
337
- html[data-capture="listening-pulse"] .studio-pulse-corner,
338
- html[data-capture="listening-pulse"] .studio-pulse-month,
339
- html[data-capture="listening-pulse"] .studio-pulse-year {
340
- font-size: 9px;
341
- }
342
-
343
- html[data-capture="listening-pulse"] .studio-pulse-cell {
344
- border-radius: 5px;
345
- }
346
-
347
- html[data-capture="listening-pulse"] .listening-pulse-boundary {
348
- max-width: 820px;
349
- margin: 0 0 22px;
350
- font-size: 11px;
351
- line-height: 1.6;
352
- }
353
-
354
- html[data-capture="listening-seasons"] .listening-seasons-preview {
355
- margin-top: 22px;
356
- padding: 24px 30px;
357
- border-color: rgba(196, 173, 136, 0.52);
358
- background:
359
- radial-gradient(circle at 0% 100%, rgba(166, 191, 213, 0.12), transparent 22rem),
360
- rgba(196, 173, 136, 0.09);
361
- }
362
-
363
- html[data-capture="listening-seasons"] .tuner-intro {
364
- padding: 28px 62px;
365
- }
366
-
367
- html[data-capture="listening-seasons"] .studio-seasons-grid {
368
- grid-template-columns: repeat(3, minmax(0, 1fr));
369
- gap: 10px;
370
- margin: 18px 0 16px;
371
- }
372
-
373
- html[data-capture="listening-seasons"] .studio-season {
374
- min-height: 180px;
375
- padding: 14px;
376
- border-radius: 15px;
377
- }
378
-
379
- html[data-capture="listening-seasons"] .studio-season-head strong {
380
- font-size: 10px;
381
- }
382
-
383
- html[data-capture="listening-seasons"] .studio-season-head span,
384
- html[data-capture="listening-seasons"] .studio-season-total span,
385
- html[data-capture="listening-seasons"] .studio-season small {
386
- font-size: 8px;
387
- }
388
-
389
- html[data-capture="listening-seasons"] .studio-season-total {
390
- margin-top: 11px;
391
- }
392
-
393
- html[data-capture="listening-seasons"] .studio-season-total strong {
394
- font-size: 22px;
395
- }
396
-
397
- html[data-capture="listening-seasons"] .studio-season-mix {
398
- height: 4px;
399
- margin-top: 10px;
400
- }
401
-
402
- html[data-capture="listening-seasons"] .studio-season-mix-label span,
403
- html[data-capture="listening-seasons"] .studio-season-anchor span {
404
- font-size: 7px;
405
- }
406
-
407
- html[data-capture="listening-seasons"] .studio-season-anchor {
408
- margin-top: 8px;
409
- padding-top: 6px;
410
- }
411
-
412
- html[data-capture="listening-seasons"] .studio-season-anchor strong {
413
- font-size: 11px;
414
- }
415
-
416
- html[data-capture="listening-seasons"] .listening-seasons-boundary {
417
- max-width: 900px;
418
- margin: 0 0 14px;
419
- font-size: 10px;
420
- line-height: 1.5;
421
- }
422
-
423
- html[data-capture="time-machine"] .time-machine-route {
424
- grid-template-columns: repeat(4, minmax(0, 1fr));
425
- gap: 1px;
426
- margin: 32px 0 30px;
427
- overflow: hidden;
428
- border: 1px solid rgba(245, 241, 233, 0.14);
429
- border-radius: 18px;
430
- background: rgba(245, 241, 233, 0.13);
431
- }
432
-
433
- html[data-capture="time-machine"] .time-machine-landmark {
434
- display: block;
435
- min-height: 176px;
436
- padding: 28px 24px;
437
- border-top: 0;
438
- background: rgba(27, 27, 25, 0.94);
439
- }
440
-
441
- html[data-capture="time-machine"] .time-machine-year {
442
- font-size: 14px;
443
- }
444
-
445
- html[data-capture="time-machine"] .time-machine-track {
446
- margin-top: 42px;
447
- }
448
-
449
- html[data-capture="time-machine"] .time-machine-track strong {
450
- font-size: 19px;
451
- }
452
-
453
- html[data-capture="time-machine"] .time-machine-track span {
454
- margin-top: 7px;
455
- font-size: 12px;
456
- }
457
-
458
- html[data-capture="historical-returns"] .historical-returns-preview {
459
- border-color: rgba(196, 173, 136, 0.42);
460
- background: rgba(196, 173, 136, 0.1);
461
- }
462
-
463
- html[data-capture="historical-returns"] .continuity-summary {
464
- margin-bottom: 20px;
465
- }
466
-
467
- html[data-capture="historical-returns"] .continuity-relationships {
468
- grid-template-columns: repeat(2, minmax(0, 1fr));
469
- gap: 0 32px;
470
- margin: 22px 0 26px;
471
- }
472
-
473
- html[data-capture="historical-returns"] .continuity-relationship {
474
- padding: 18px 0;
475
- }
476
-
477
- html[data-capture="historical-returns"] .historical-return-identity strong {
478
- font-size: 17px;
479
- }
480
-
481
- html[data-capture="historical-returns"] .historical-return-identity p {
482
- margin-top: 6px;
483
- font-size: 11px;
484
- }
485
-
486
- html[data-capture="historical-returns"] .continuity-relationship span {
487
- font-size: 10px;
488
- }
489
-
490
- html[data-capture="listening-patterns"] .listening-patterns-summary {
491
- margin-bottom: 22px;
492
- }
493
-
494
- html[data-capture="listening-patterns"] .listening-patterns-metrics {
495
- gap: 12px;
496
- margin-bottom: 24px;
497
- }
498
-
499
- html[data-capture="listening-patterns"] .listening-patterns-metric {
500
- padding: 18px;
501
- border-radius: 14px;
502
- }
503
-
504
- html[data-capture="listening-patterns"] .listening-patterns-metric strong {
505
- font-size: 34px;
506
- }
507
-
508
- html[data-capture="listening-patterns"] .listening-patterns-metric span {
509
- margin-top: 7px;
510
- font-size: 9px;
511
- }
512
-
513
- html[data-capture="listening-patterns"] .listening-pattern-release {
514
- padding: 15px 2px;
515
- }
516
-
517
- html[data-capture="listening-patterns"] .listening-pattern-release strong {
518
- font-size: 15px;
519
- }
520
-
521
- html[data-capture="listening-patterns"] .listening-pattern-release p,
522
- html[data-capture="listening-patterns"] .listening-pattern-release span {
523
- font-size: 11px;
524
- }
525
-
526
- body {
527
- min-height: 100vh;
528
- margin: 0;
529
- background:
530
- radial-gradient(circle at 8% -5%, rgba(255, 255, 255, 0.82), transparent 32rem),
531
- linear-gradient(180deg, var(--paper) 0%, #e7ddd0 100%);
532
- }
533
-
534
- button, input, textarea { font: inherit; }
535
-
536
- [hidden] { display: none !important; }
537
-
538
- button, .drop-zone, .result-link, .hero-preview-link, .profile-artifact-link { -webkit-tap-highlight-color: transparent; }
539
-
540
- .shell {
541
- width: min(1180px, calc(100% - 40px));
542
- margin: 0 auto;
543
- padding: 24px 0 56px;
544
- }
545
-
546
- .topbar {
547
- display: flex;
548
- min-width: 0;
549
- align-items: center;
550
- justify-content: space-between;
551
- gap: 24px;
552
- padding: 0 4px 18px;
553
- }
554
-
555
- .brand {
556
- display: flex;
557
- min-width: 0;
558
- align-items: center;
559
- gap: 12px;
560
- }
561
-
562
- .brand-mark {
563
- width: 42px;
564
- height: 42px;
565
- flex: 0 0 auto;
566
- overflow: hidden;
567
- border: 1px solid #aaa093;
568
- border-radius: 50%;
569
- background: #f7f3ec;
570
- }
571
-
572
- .brand-mark img {
573
- display: block;
574
- width: 100%;
575
- height: 100%;
576
- object-fit: cover;
577
- }
578
-
579
- .brand-name {
580
- margin: 0;
581
- font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
582
- font-size: 22px;
583
- font-weight: 650;
584
- letter-spacing: -0.02em;
585
- line-height: 1;
586
- }
587
-
588
- .brand-subtitle {
589
- margin: 4px 0 0;
590
- color: var(--muted);
591
- font-size: 10px;
592
- font-weight: 760;
593
- letter-spacing: 0.16em;
594
- text-transform: uppercase;
595
- }
596
-
597
- .local-badge {
598
- display: inline-flex;
599
- min-width: 0;
600
- align-items: center;
601
- gap: 8px;
602
- padding: 8px 12px;
603
- border: 1px solid var(--line);
604
- border-radius: 999px;
605
- color: #514b44;
606
- background: rgba(248, 244, 237, 0.72);
607
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
608
- font-size: 10px;
609
- font-weight: 700;
610
- letter-spacing: 0.08em;
611
- text-transform: uppercase;
612
- white-space: nowrap;
613
- }
614
-
615
- .local-badge::before {
616
- width: 7px;
617
- height: 7px;
618
- flex: 0 0 auto;
619
- border: 4px solid rgba(79, 115, 95, 0.17);
620
- border-radius: 50%;
621
- background: var(--green);
622
- content: "";
623
- }
624
-
625
- .hero {
626
- position: relative;
627
- isolation: isolate;
628
- display: grid;
629
- min-width: 0;
630
- grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.65fr);
631
- gap: clamp(32px, 6vw, 84px);
632
- overflow: hidden;
633
- padding: clamp(38px, 6vw, 72px);
634
- border: 1px solid #0e0e0d;
635
- border-radius: 32px 32px 0 0;
636
- color: #f5f1e9;
637
- background: var(--night);
638
- box-shadow: var(--shadow);
639
- }
640
-
641
- .hero::before {
642
- position: absolute;
643
- z-index: -1;
644
- width: 600px;
645
- aspect-ratio: 1;
646
- right: -220px;
647
- top: -250px;
648
- border: 1px solid rgba(245, 241, 233, 0.19);
649
- border-radius: 50%;
650
- box-shadow:
651
- 0 0 0 24px rgba(245, 241, 233, 0.028),
652
- 0 0 0 62px rgba(245, 241, 233, 0.022),
653
- 0 0 0 108px rgba(245, 241, 233, 0.017),
654
- 0 0 0 162px rgba(245, 241, 233, 0.012);
655
- content: "";
656
- }
657
-
658
- .eyebrow {
659
- display: flex;
660
- align-items: center;
661
- gap: 10px;
662
- margin: 0 0 32px;
663
- color: #d7d0c7;
664
- font-size: 10px;
665
- font-weight: 800;
666
- letter-spacing: 0.2em;
667
- text-transform: uppercase;
668
- }
669
-
670
- .eyebrow::before {
671
- width: 8px;
672
- height: 8px;
673
- border: 5px solid rgba(120, 151, 179, 0.24);
674
- border-radius: 50%;
675
- background: #a6bfd5;
676
- content: "";
677
- }
678
-
679
- h1, h2, .serif {
680
- font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
681
- font-weight: 650;
682
- letter-spacing: -0.04em;
683
- }
684
-
685
- h1 {
686
- max-width: 720px;
687
- margin: 0;
688
- font-size: clamp(52px, 7.2vw, 94px);
689
- line-height: 0.89;
690
- }
691
-
692
- .hero-copy {
693
- max-width: 650px;
694
- margin: 28px 0 0;
695
- color: #cdc7be;
696
- font-size: clamp(16px, 1.7vw, 20px);
697
- line-height: 1.55;
698
- }
699
-
700
- .hero-preview-link {
701
- appearance: none;
702
- display: inline-flex;
703
- align-items: center;
704
- gap: 10px;
705
- margin-top: 22px;
706
- padding: 0 0 5px;
707
- border: 0;
708
- border-bottom: 1px solid rgba(245, 241, 233, 0.38);
709
- color: #f5f1e9;
710
- background: transparent;
711
- font-size: 12px;
712
- font-weight: 760;
713
- line-height: 1.4;
714
- text-align: left;
715
- text-decoration: none;
716
- cursor: pointer;
717
- transition: border-color 150ms ease, color 150ms ease;
718
- }
719
-
720
- .hero-preview-link:hover { border-color: #a6bfd5; color: #dce9f4; }
721
-
722
- .hero-preview-link:focus-visible { outline: 3px solid rgba(166, 191, 213, 0.55); outline-offset: 5px; }
723
-
724
- .hero-preview-link:disabled { opacity: 0.48; cursor: wait; }
725
-
726
- .hero-preview-link.is-primary {
727
- min-height: 44px;
728
- padding: 0 17px;
729
- border: 1px solid #f5f1e9;
730
- border-radius: 999px;
731
- color: var(--night);
732
- background: #f5f1e9;
733
- }
734
-
735
- .hero-preview-link.is-primary:hover {
736
- border-color: #dce9f4;
737
- color: var(--night);
738
- background: #dce9f4;
739
- }
740
-
741
- .hero-preview-actions {
742
- display: flex;
743
- min-width: 0;
744
- flex-wrap: wrap;
745
- gap: 16px;
746
- align-items: center;
747
- margin-top: 22px;
748
- }
749
-
750
- .hero-preview-actions .hero-preview-link { margin-top: 0; }
751
-
752
- .hero-aside {
753
- align-self: end;
754
- border-top: 1px solid rgba(245, 241, 233, 0.25);
755
- }
756
-
757
- .hero-step {
758
- display: grid;
759
- grid-template-columns: 30px minmax(0, 1fr);
760
- gap: 14px;
761
- padding: 16px 0;
762
- border-bottom: 1px solid rgba(245, 241, 233, 0.15);
763
- }
764
-
765
- .hero-step span {
766
- color: #93aec5;
767
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
768
- font-size: 11px;
769
- }
770
-
771
- .hero-step p {
772
- margin: 0;
773
- color: #e7e1d8;
774
- font-size: 13px;
775
- line-height: 1.45;
776
- }
777
-
778
- .workspace {
779
- display: grid;
780
- min-width: 0;
781
- grid-template-columns: minmax(220px, 0.55fr) minmax(0, 1.45fr);
782
- border: 1px solid var(--line-strong);
783
- border-top: 0;
784
- border-radius: 0 0 32px 32px;
785
- background: var(--card);
786
- box-shadow: var(--shadow);
787
- }
788
-
789
- .privacy-rail {
790
- min-width: 0;
791
- padding: clamp(28px, 4vw, 48px);
792
- border-right: 1px solid var(--line);
793
- background: #e9e1d6;
794
- }
795
-
796
- .section-label {
797
- margin: 0 0 22px;
798
- color: var(--muted);
799
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
800
- font-size: 10px;
801
- font-weight: 800;
802
- letter-spacing: 0.17em;
803
- text-transform: uppercase;
804
- }
805
-
806
- .privacy-item {
807
- display: grid;
808
- grid-template-columns: 24px minmax(0, 1fr);
809
- gap: 12px;
810
- padding: 15px 0;
811
- border-top: 1px solid rgba(148, 136, 122, 0.45);
812
- }
813
-
814
- .privacy-item:last-of-type { border-bottom: 1px solid rgba(148, 136, 122, 0.45); }
815
-
816
- .privacy-icon {
817
- display: grid;
818
- width: 24px;
819
- height: 24px;
820
- place-items: center;
821
- border: 1px solid #8eaa99;
822
- border-radius: 50%;
823
- color: var(--green);
824
- font-size: 12px;
825
- font-weight: 900;
826
- }
827
-
828
- .privacy-item strong {
829
- display: block;
830
- margin: 1px 0 4px;
831
- font-size: 13px;
832
- }
833
-
834
- .privacy-item p {
835
- margin: 0;
836
- color: var(--muted);
837
- font-size: 12px;
838
- line-height: 1.45;
839
- }
840
-
841
- .importer {
842
- min-width: 0;
843
- padding: clamp(28px, 5vw, 58px);
844
- }
845
-
846
- .importer-header {
847
- display: flex;
848
- min-width: 0;
849
- align-items: end;
850
- justify-content: space-between;
851
- gap: 24px;
852
- margin-bottom: 24px;
853
- }
854
-
855
- .importer h2 {
856
- margin: 0;
857
- font-size: clamp(32px, 4.4vw, 54px);
858
- line-height: 0.98;
859
- }
860
-
861
- .limit {
862
- flex: 0 0 auto;
863
- color: var(--muted);
864
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
865
- font-size: 10px;
866
- letter-spacing: 0.08em;
867
- text-transform: uppercase;
868
- }
869
-
870
- .file-input {
871
- position: absolute;
872
- width: 1px;
873
- height: 1px;
874
- overflow: hidden;
875
- clip: rect(0 0 0 0);
876
- clip-path: inset(50%);
877
- white-space: nowrap;
878
- }
879
-
880
- .drop-zone {
881
- position: relative;
882
- display: grid;
883
- min-height: 258px;
884
- place-items: center;
885
- padding: 34px;
886
- overflow: hidden;
887
- border: 1.5px dashed var(--line-strong);
888
- border-radius: 24px;
889
- text-align: center;
890
- background:
891
- radial-gradient(circle at 90% 0%, rgba(120, 151, 179, 0.15), transparent 18rem),
892
- rgba(255, 255, 255, 0.24);
893
- cursor: pointer;
894
- transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
895
- }
896
-
897
- .drop-zone:hover, .drop-zone:focus-visible, .drop-zone.is-dragging {
898
- outline: none;
899
- border-color: var(--ink);
900
- background-color: rgba(216, 229, 239, 0.42);
901
- transform: translateY(-2px);
902
- }
903
-
904
- .drop-zone.is-ready {
905
- border-style: solid;
906
- border-color: #809d8b;
907
- background-color: rgba(79, 115, 95, 0.07);
908
- }
909
-
910
- .drop-orbit {
911
- display: grid;
912
- width: 74px;
913
- height: 74px;
914
- margin: 0 auto 22px;
915
- place-items: center;
916
- border: 1px solid var(--line-strong);
917
- border-radius: 50%;
918
- color: var(--ink);
919
- box-shadow: 0 0 0 9px rgba(148, 136, 122, 0.09), 0 0 0 19px rgba(148, 136, 122, 0.055);
920
- font-size: 26px;
921
- line-height: 1;
922
- }
923
-
924
- .drop-title {
925
- display: block;
926
- margin: 0;
927
- font-size: 18px;
928
- font-weight: 750;
929
- }
930
-
931
- .drop-copy {
932
- display: block;
933
- margin: 8px 0 0;
934
- color: var(--muted);
935
- font-size: 13px;
936
- line-height: 1.5;
937
- }
938
-
939
- .file-meta {
940
- display: none;
941
- min-width: 0;
942
- margin-top: 16px;
943
- padding: 14px 16px;
944
- align-items: center;
945
- justify-content: space-between;
946
- gap: 20px;
947
- border: 1px solid var(--line);
948
- border-radius: 14px;
949
- background: #f2ece3;
950
- }
951
-
952
- .file-meta.is-visible { display: flex; }
953
-
954
- .file-name {
955
- min-width: 0;
956
- overflow: hidden;
957
- font-size: 13px;
958
- font-weight: 720;
959
- text-overflow: ellipsis;
960
- white-space: nowrap;
961
- }
962
-
963
- .file-size {
964
- flex: 0 0 auto;
965
- color: var(--muted);
966
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
967
- font-size: 10px;
968
- }
969
-
970
- .action-row {
971
- display: flex;
972
- min-width: 0;
973
- align-items: center;
974
- gap: 18px;
975
- margin-top: 20px;
976
- }
977
-
978
- .primary-action {
979
- display: inline-flex;
980
- min-height: 50px;
981
- min-width: 218px;
982
- align-items: center;
983
- justify-content: space-between;
984
- gap: 18px;
985
- padding: 0 18px 0 20px;
986
- border: 1px solid var(--ink);
987
- border-radius: 999px;
988
- color: #f7f2e9;
989
- background: var(--ink);
990
- font-size: 13px;
991
- font-weight: 760;
992
- cursor: pointer;
993
- transition: opacity 150ms ease, transform 150ms ease;
994
- }
995
-
996
- .primary-action:hover:not(:disabled) { transform: translateY(-1px); }
997
-
998
- .primary-action:focus-visible { outline: 3px solid rgba(120, 151, 179, 0.5); outline-offset: 3px; }
999
-
1000
- .primary-action:disabled { opacity: 0.38; cursor: not-allowed; }
1001
-
1002
- .action-arrow {
1003
- display: grid;
1004
- width: 28px;
1005
- height: 28px;
1006
- place-items: center;
1007
- border-radius: 50%;
1008
- color: var(--ink);
1009
- background: #f7f2e9;
1010
- font-size: 16px;
1011
- }
1012
-
1013
- .status {
1014
- min-width: 0;
1015
- margin: 0;
1016
- color: var(--muted);
1017
- font-size: 12px;
1018
- line-height: 1.45;
1019
- }
1020
-
1021
- .status.is-error { color: var(--red); font-weight: 680; }
1022
-
1023
- .progress {
1024
- display: none;
1025
- height: 3px;
1026
- margin-top: 20px;
1027
- overflow: hidden;
1028
- border-radius: 999px;
1029
- background: #d7cec2;
1030
- }
1031
-
1032
- .progress.is-visible { display: block; }
1033
-
1034
- .progress-bar {
1035
- width: 0;
1036
- height: 100%;
1037
- border-radius: inherit;
1038
- background: var(--blue);
1039
- transition: width 180ms ease;
1040
- }
1041
-
1042
- .result {
1043
- display: none;
1044
- min-width: 0;
1045
- margin-top: 24px;
1046
- padding: clamp(22px, 4vw, 34px);
1047
- border: 1px solid #9eb3c5;
1048
- border-radius: 22px;
1049
- background: var(--blue-soft);
1050
- }
1051
-
1052
- .result.is-visible { display: block; }
1053
-
1054
- .result-kicker {
1055
- margin: 0 0 8px;
1056
- color: #506c84;
1057
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1058
- font-size: 10px;
1059
- font-weight: 800;
1060
- letter-spacing: 0.14em;
1061
- text-transform: uppercase;
1062
- }
1063
-
1064
- .result h3 {
1065
- margin: 0;
1066
- font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
1067
- font-size: clamp(28px, 4vw, 40px);
1068
- letter-spacing: -0.035em;
1069
- }
1070
-
1071
- .result-copy {
1072
- margin: 9px 0 0;
1073
- color: #516271;
1074
- font-size: 13px;
1075
- line-height: 1.5;
1076
- }
1077
-
1078
- .stats {
1079
- display: grid;
1080
- min-width: 0;
1081
- grid-template-columns: repeat(3, minmax(0, 1fr));
1082
- gap: 1px;
1083
- margin: 24px 0;
1084
- overflow: hidden;
1085
- border: 1px solid rgba(80, 108, 132, 0.25);
1086
- border-radius: 14px;
1087
- background: rgba(80, 108, 132, 0.22);
1088
- }
1089
-
1090
- .stat {
1091
- min-width: 0;
1092
- padding: 16px;
1093
- background: rgba(248, 244, 237, 0.72);
1094
- }
1095
-
1096
- .stat-value {
1097
- display: block;
1098
- overflow: hidden;
1099
- color: var(--ink);
1100
- font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
1101
- font-size: clamp(22px, 3vw, 32px);
1102
- font-weight: 650;
1103
- letter-spacing: -0.04em;
1104
- text-overflow: ellipsis;
1105
- white-space: nowrap;
1106
- }
1107
-
1108
- .stat-label {
1109
- display: block;
1110
- margin-top: 4px;
1111
- color: #607383;
1112
- font-size: 10px;
1113
- font-weight: 760;
1114
- letter-spacing: 0.08em;
1115
- text-transform: uppercase;
1116
- }
1117
-
1118
- .result-link {
1119
- display: inline-flex;
1120
- min-height: 44px;
1121
- align-items: center;
1122
- gap: 12px;
1123
- padding: 0 18px;
1124
- border: 1px solid var(--ink);
1125
- border-radius: 999px;
1126
- color: #f7f2e9;
1127
- background: var(--ink);
1128
- font-size: 12px;
1129
- font-weight: 760;
1130
- text-decoration: none;
1131
- }
1132
-
1133
- .result-links {
1134
- display: flex;
1135
- min-width: 0;
1136
- flex-wrap: wrap;
1137
- gap: 10px;
1138
- align-items: center;
1139
- }
1140
-
1141
- .result-link.is-secondary {
1142
- color: var(--ink);
1143
- background: transparent;
1144
- }
1145
-
1146
- .result-boundary {
1147
- margin: 12px 0 0;
1148
- color: #607383;
1149
- font-size: 10px;
1150
- line-height: 1.45;
1151
- }
1152
-
1153
- .tuner {
1154
- display: grid;
1155
- min-width: 0;
1156
- grid-template-columns: minmax(260px, 0.66fr) minmax(0, 1.34fr);
1157
- margin-top: 18px;
1158
- overflow: hidden;
1159
- border: 1px solid var(--line-strong);
1160
- border-radius: 32px;
1161
- background: var(--card);
1162
- box-shadow: var(--shadow);
1163
- }
1164
-
1165
- .tuner-intro {
1166
- position: relative;
1167
- isolation: isolate;
1168
- min-width: 0;
1169
- overflow: hidden;
1170
- padding: clamp(34px, 5vw, 58px);
1171
- color: #f5f1e9;
1172
- background: var(--night);
1173
- }
1174
-
1175
- .tuner-intro::after {
1176
- position: absolute;
1177
- z-index: -1;
1178
- width: 320px;
1179
- height: 320px;
1180
- right: -190px;
1181
- bottom: -150px;
1182
- border: 1px solid rgba(166, 191, 213, 0.24);
1183
- border-radius: 50%;
1184
- box-shadow:
1185
- 0 0 0 24px rgba(166, 191, 213, 0.045),
1186
- 0 0 0 60px rgba(166, 191, 213, 0.025);
1187
- content: "";
1188
- }
1189
-
1190
- .tuner-intro .section-label { color: #a6bfd5; }
1191
-
1192
- .tuner h2 {
1193
- max-width: 360px;
1194
- margin: 0;
1195
- font-size: clamp(38px, 5vw, 62px);
1196
- line-height: 0.94;
1197
- }
1198
-
1199
- .tuner-lede {
1200
- max-width: 430px;
1201
- margin: 22px 0 0;
1202
- color: #c9c3ba;
1203
- font-size: 14px;
1204
- line-height: 1.62;
1205
- }
1206
-
1207
- .profile-state {
1208
- margin-top: 34px;
1209
- padding-top: 22px;
1210
- border-top: 1px solid rgba(245, 241, 233, 0.2);
1211
- }
1212
-
1213
- .profile-state-head {
1214
- display: grid;
1215
- grid-template-columns: 12px minmax(0, 1fr);
1216
- gap: 12px;
1217
- align-items: start;
1218
- }
1219
-
1220
- .profile-state-dot {
1221
- width: 9px;
1222
- height: 9px;
1223
- margin-top: 5px;
1224
- border: 3px solid rgba(166, 191, 213, 0.22);
1225
- border-radius: 50%;
1226
- background: #a6bfd5;
1227
- box-sizing: content-box;
1228
- }
1229
-
1230
- .profile-state.is-ready .profile-state-dot {
1231
- border-color: rgba(130, 168, 146, 0.25);
1232
- background: #82a892;
1233
- }
1234
-
1235
- .profile-state.is-demo .profile-state-dot {
1236
- border-color: rgba(166, 191, 213, 0.3);
1237
- background: #a6bfd5;
1238
- }
1239
-
1240
- .profile-state.is-error .profile-state-dot {
1241
- border-color: rgba(180, 82, 63, 0.25);
1242
- background: #d87864;
1243
- }
1244
-
1245
- .profile-state strong {
1246
- display: block;
1247
- font-size: 14px;
1248
- line-height: 1.35;
1249
- }
1250
-
1251
- .profile-state p {
1252
- margin: 6px 0 0;
1253
- color: #a9a39a;
1254
- font-size: 11px;
1255
- line-height: 1.5;
1256
- }
1257
-
1258
- .demo-profile-badge {
1259
- display: inline-flex;
1260
- margin: 0 0 9px;
1261
- padding: 5px 8px;
1262
- border: 1px solid rgba(166, 191, 213, 0.34);
1263
- border-radius: 999px;
1264
- color: #d7e6f2;
1265
- background: rgba(166, 191, 213, 0.1);
1266
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1267
- font-size: 8px;
1268
- font-weight: 820;
1269
- letter-spacing: 0.13em;
1270
- line-height: 1;
1271
- text-transform: uppercase;
1272
- }
1273
-
1274
- .demo-start-action {
1275
- display: flex;
1276
- width: 100%;
1277
- min-width: 0;
1278
- align-items: center;
1279
- justify-content: space-between;
1280
- gap: 14px;
1281
- margin-top: 18px;
1282
- padding: 12px 14px;
1283
- border: 1px solid rgba(166, 191, 213, 0.32);
1284
- border-radius: 13px;
1285
- color: #f5f1e9;
1286
- background: rgba(166, 191, 213, 0.08);
1287
- font: inherit;
1288
- font-size: 11px;
1289
- font-weight: 760;
1290
- text-align: left;
1291
- cursor: pointer;
1292
- transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
1293
- }
1294
-
1295
- .demo-start-action:hover:not(:disabled) {
1296
- transform: translateY(-1px);
1297
- border-color: rgba(166, 191, 213, 0.68);
1298
- background: rgba(166, 191, 213, 0.14);
1299
- }
1300
-
1301
- .demo-start-action:focus-visible {
1302
- outline: 3px solid rgba(166, 191, 213, 0.38);
1303
- outline-offset: 3px;
1304
- }
1305
-
1306
- .demo-start-action:disabled { opacity: 0.45; cursor: wait; }
1307
-
1308
- .profile-tools {
1309
- display: flex;
1310
- flex-wrap: wrap;
1311
- gap: 8px;
1312
- margin-top: 14px;
1313
- }
1314
-
1315
- .profile-tools .demo-start-action {
1316
- width: auto;
1317
- flex: 1 1 110px;
1318
- margin-top: 0;
1319
- min-height: 44px;
1320
- }
1321
-
1322
- .track-correction-action {
1323
- display: block;
1324
- min-height: 44px;
1325
- margin-top: 2px;
1326
- padding: 8px 0;
1327
- border: 0;
1328
- color: #c8d9e8;
1329
- background: transparent;
1330
- font: inherit;
1331
- font-size: 10px;
1332
- font-weight: 700;
1333
- text-align: left;
1334
- text-decoration: underline;
1335
- text-underline-offset: 3px;
1336
- cursor: pointer;
1337
- }
1338
-
1339
- .track-correction-action:focus-visible {
1340
- outline: 2px solid var(--blue);
1341
- outline-offset: 3px;
1342
- }
1343
-
1344
- .track-correction-action:disabled { opacity: 0.45; cursor: wait; }
1345
- html[data-capture] .profile-tools,
1346
- html[data-capture] .track-correction-action { display: none; }
1347
- #correction-form { scroll-margin-top: 24px; }
1348
- #correction-return { margin-top: 16px; }
1349
-
1350
- .profile-metrics {
1351
- display: grid;
1352
- min-width: 0;
1353
- grid-template-columns: repeat(3, minmax(0, 1fr));
1354
- gap: 1px;
1355
- margin-top: 20px;
1356
- overflow: hidden;
1357
- border: 1px solid rgba(245, 241, 233, 0.13);
1358
- border-radius: 13px;
1359
- background: rgba(245, 241, 233, 0.12);
1360
- }
1361
-
1362
- .profile-metric {
1363
- min-width: 0;
1364
- padding: 12px;
1365
- background: rgba(245, 241, 233, 0.045);
1366
- }
1367
-
1368
- .profile-metric strong {
1369
- overflow: hidden;
1370
- color: #f5f1e9;
1371
- font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
1372
- font-size: 22px;
1373
- letter-spacing: -0.03em;
1374
- text-overflow: ellipsis;
1375
- white-space: nowrap;
1376
- }
1377
-
1378
- .profile-metric span {
1379
- display: block;
1380
- margin-top: 3px;
1381
- color: #918b82;
1382
- font-size: 8px;
1383
- font-weight: 760;
1384
- letter-spacing: 0.1em;
1385
- text-transform: uppercase;
1386
- }
1387
-
1388
- .profile-artifact-links {
1389
- display: flex;
1390
- min-width: 0;
1391
- flex-wrap: wrap;
1392
- gap: 14px;
1393
- margin-top: 18px;
1394
- }
1395
-
1396
- .profile-artifact-link {
1397
- display: inline-flex;
1398
- align-items: center;
1399
- gap: 8px;
1400
- padding-bottom: 4px;
1401
- border-bottom: 1px solid rgba(245, 241, 233, 0.3);
1402
- color: #f5f1e9;
1403
- font-size: 11px;
1404
- font-weight: 760;
1405
- text-decoration: none;
1406
- }
1407
-
1408
- .time-machine-preview {
1409
- min-width: 0;
1410
- margin-top: 20px;
1411
- padding: 16px;
1412
- border: 1px solid rgba(166, 191, 213, 0.32);
1413
- border-radius: 16px;
1414
- background: rgba(166, 191, 213, 0.08);
1415
- }
1416
-
1417
- .time-machine-preview-head {
1418
- display: flex;
1419
- min-width: 0;
1420
- align-items: baseline;
1421
- justify-content: space-between;
1422
- gap: 12px;
1423
- }
1424
-
1425
- .time-machine-preview-head strong {
1426
- color: #f5f1e9;
1427
- font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
1428
- font-size: 17px;
1429
- letter-spacing: -0.025em;
1430
- }
1431
-
1432
- .time-machine-count {
1433
- flex: 0 0 auto;
1434
- color: #a6bfd5;
1435
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1436
- font-size: 8px;
1437
- font-weight: 800;
1438
- letter-spacing: 0.1em;
1439
- text-transform: uppercase;
1440
- }
1441
-
1442
- .time-machine-coverage {
1443
- margin: 10px 0 0;
1444
- padding-left: 10px;
1445
- border-left: 2px solid rgba(166, 191, 213, 0.5);
1446
- color: #aaa49b;
1447
- font-size: 9px;
1448
- line-height: 1.55;
1449
- }
1450
-
1451
- .time-machine-coverage strong {
1452
- display: block;
1453
- color: #c8d9e8;
1454
- font-size: 10px;
1455
- font-weight: 760;
1456
- }
1457
-
1458
- .time-machine-coverage span {
1459
- display: block;
1460
- margin-top: 3px;
1461
- }
1462
-
1463
- .identity-link-coverage {
1464
- margin: 16px 0 0;
1465
- }
1466
-
1467
- .time-machine-route {
1468
- display: grid;
1469
- min-width: 0;
1470
- gap: 0;
1471
- margin: 12px 0 14px;
1472
- padding: 0;
1473
- list-style: none;
1474
- }
1475
-
1476
- .time-machine-landmark {
1477
- display: grid;
1478
- min-width: 0;
1479
- grid-template-columns: 38px minmax(0, 1fr);
1480
- gap: 10px;
1481
- padding: 9px 0;
1482
- border-top: 1px solid rgba(245, 241, 233, 0.12);
1483
- }
1484
-
1485
- .time-machine-year {
1486
- color: #a6bfd5;
1487
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1488
- font-size: 9px;
1489
- font-weight: 800;
1490
- letter-spacing: 0.04em;
1491
- }
1492
-
1493
- .time-machine-track {
1494
- min-width: 0;
1495
- }
1496
-
1497
- .time-machine-track strong {
1498
- overflow: hidden;
1499
- color: #f5f1e9;
1500
- font-size: 11px;
1501
- text-overflow: ellipsis;
1502
- white-space: nowrap;
1503
- }
1504
-
1505
- .time-machine-track span {
1506
- display: block;
1507
- overflow: hidden;
1508
- margin-top: 2px;
1509
- color: #918b82;
1510
- font-size: 9px;
1511
- text-overflow: ellipsis;
1512
- white-space: nowrap;
1513
- }
1514
-
1515
- .time-machine-action {
1516
- color: #dce9f4;
1517
- border-bottom-color: rgba(166, 191, 213, 0.58);
1518
- }
1519
-
1520
- .listening-pulse-preview {
1521
- min-width: 0;
1522
- margin-top: 14px;
1523
- padding: 16px;
1524
- border: 1px solid rgba(166, 191, 213, 0.36);
1525
- border-radius: 16px;
1526
- background:
1527
- radial-gradient(circle at 100% 0%, rgba(166, 191, 213, 0.12), transparent 16rem),
1528
- rgba(166, 191, 213, 0.06);
1529
- }
1530
-
1531
- .listening-pulse-summary,
1532
- .listening-pulse-boundary {
1533
- margin: 10px 0 12px;
1534
- color: #aaa49b;
1535
- font-size: 9px;
1536
- line-height: 1.55;
1537
- }
1538
-
1539
- .listening-pulse-boundary { margin: 10px 0 14px; }
1540
-
1541
- .studio-pulse-grid {
1542
- display: grid;
1543
- grid-template-columns: 31px repeat(12, minmax(0, 1fr));
1544
- gap: 4px;
1545
- min-width: 0;
1546
- align-items: center;
1547
- }
1548
-
1549
- .studio-pulse-corner,
1550
- .studio-pulse-month,
1551
- .studio-pulse-year {
1552
- color: #918b82;
1553
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1554
- font-size: 7px;
1555
- font-weight: 800;
1556
- letter-spacing: 0.04em;
1557
- }
1558
-
1559
- .studio-pulse-month { text-align: center; }
1560
-
1561
- .studio-pulse-cell {
1562
- display: block;
1563
- min-width: 0;
1564
- aspect-ratio: 1;
1565
- border: 1px solid rgba(166, 191, 213, 0.12);
1566
- border-radius: 3px;
1567
- background: rgba(166, 191, 213, 0.07);
1568
- }
1569
-
1570
- .studio-pulse-cell.level-1 { background: rgba(166, 191, 213, 0.26); }
1571
- .studio-pulse-cell.level-2 { background: rgba(166, 191, 213, 0.46); }
1572
- .studio-pulse-cell.level-3 { background: rgba(166, 191, 213, 0.68); }
1573
- .studio-pulse-cell.level-4 { background: #a6bfd5; }
1574
- .studio-pulse-cell.is-outside { border-color: transparent; background: transparent; }
1575
-
1576
- .listening-seasons-preview {
1577
- min-width: 0;
1578
- margin-top: 14px;
1579
- padding: 16px;
1580
- border: 1px solid rgba(196, 173, 136, 0.4);
1581
- border-radius: 16px;
1582
- background:
1583
- radial-gradient(circle at 0% 100%, rgba(166, 191, 213, 0.08), transparent 14rem),
1584
- rgba(196, 173, 136, 0.07);
1585
- }
1586
-
1587
- .listening-seasons-summary,
1588
- .listening-seasons-boundary {
1589
- margin: 10px 0 12px;
1590
- color: #aaa49b;
1591
- font-size: 9px;
1592
- line-height: 1.55;
1593
- }
1594
-
1595
- .studio-seasons-grid {
1596
- display: grid;
1597
- grid-template-columns: repeat(2, minmax(0, 1fr));
1598
- gap: 7px;
1599
- min-width: 0;
1600
- margin-bottom: 12px;
1601
- }
1602
-
1603
- .studio-season {
1604
- position: relative;
1605
- min-width: 0;
1606
- overflow: hidden;
1607
- padding: 11px;
1608
- border: 1px solid rgba(245, 241, 233, 0.13);
1609
- border-radius: 11px;
1610
- background: rgba(245, 241, 233, 0.035);
1611
- }
1612
-
1613
- .studio-season::before {
1614
- position: absolute;
1615
- inset: 0 auto 0 0;
1616
- width: 2px;
1617
- background: #a6bfd5;
1618
- content: "";
1619
- }
1620
-
1621
- .studio-season.quarter-2::before { background: #86a08c; }
1622
- .studio-season.quarter-3::before { background: #c29a6b; }
1623
- .studio-season.quarter-4::before { background: #9b819d; }
1624
- .studio-season.is-empty::before { opacity: 0.34; }
1625
-
1626
- .studio-season-head,
1627
- .studio-season-total,
1628
- .studio-season-mix-label {
1629
- display: flex;
1630
- min-width: 0;
1631
- align-items: baseline;
1632
- justify-content: space-between;
1633
- gap: 8px;
1634
- }
1635
-
1636
- .studio-season-head strong {
1637
- color: #f5f1e9;
1638
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1639
- font-size: 8px;
1640
- letter-spacing: 0.06em;
1641
- }
1642
-
1643
- .studio-season-head span,
1644
- .studio-season-total span,
1645
- .studio-season small {
1646
- color: #918b82;
1647
- font-size: 7px;
1648
- line-height: 1.4;
1649
- }
1650
-
1651
- .studio-season-total { margin-top: 10px; }
1652
- .studio-season-total strong {
1653
- min-width: 0;
1654
- overflow: hidden;
1655
- color: #f5f1e9;
1656
- font: 650 16px Iowan Old Style, Baskerville, serif;
1657
- text-overflow: ellipsis;
1658
- white-space: nowrap;
1659
- }
1660
- .studio-season-total span { flex: 0 1 auto; text-align: right; }
1661
-
1662
- .studio-season-mix {
1663
- height: 3px;
1664
- margin-top: 9px;
1665
- overflow: hidden;
1666
- border-radius: 99px;
1667
- background: rgba(196, 173, 136, 0.48);
1668
- }
1669
- .studio-season-mix span { display: block; height: 100%; background: #a6bfd5; }
1670
- .studio-season-mix-label { margin: 4px 0 0; }
1671
- .studio-season-mix-label span { color: #918b82; font-size: 6px; }
1672
-
1673
- .studio-season-anchor {
1674
- min-width: 0;
1675
- margin: 10px 0 0;
1676
- padding-top: 8px;
1677
- border-top: 1px solid rgba(245, 241, 233, 0.1);
1678
- }
1679
- .studio-season-anchor span {
1680
- display: block;
1681
- color: #918b82;
1682
- font-size: 6px;
1683
- font-weight: 800;
1684
- letter-spacing: 0.08em;
1685
- text-transform: uppercase;
1686
- }
1687
- .studio-season-anchor strong {
1688
- display: block;
1689
- overflow: hidden;
1690
- margin-top: 3px;
1691
- color: #f5f1e9;
1692
- font-size: 9px;
1693
- text-overflow: ellipsis;
1694
- white-space: nowrap;
1695
- }
1696
- .studio-season-anchor small {
1697
- display: block;
1698
- overflow: hidden;
1699
- margin-top: 2px;
1700
- text-overflow: ellipsis;
1701
- white-space: nowrap;
1702
- }
1703
-
1704
- .studio-season-empty {
1705
- margin: 18px 0 13px;
1706
- color: #918b82;
1707
- font-size: 8px;
1708
- line-height: 1.45;
1709
- }
1710
-
1711
- .listening-seasons-boundary { margin: 10px 0 14px; }
1712
-
1713
- .continuity-preview {
1714
- min-width: 0;
1715
- margin-top: 14px;
1716
- padding: 16px;
1717
- border: 1px solid rgba(196, 173, 136, 0.36);
1718
- border-radius: 16px;
1719
- background: rgba(196, 173, 136, 0.08);
1720
- }
1721
-
1722
- .continuity-summary {
1723
- margin: 10px 0 0;
1724
- color: #aaa49b;
1725
- font-size: 9px;
1726
- line-height: 1.55;
1727
- }
1728
-
1729
- .continuity-relationships {
1730
- display: grid;
1731
- min-width: 0;
1732
- gap: 0;
1733
- margin: 12px 0 14px;
1734
- padding: 0;
1735
- list-style: none;
1736
- }
1737
-
1738
- .continuity-relationship {
1739
- display: flex;
1740
- min-width: 0;
1741
- align-items: baseline;
1742
- justify-content: space-between;
1743
- gap: 12px;
1744
- padding: 8px 0;
1745
- border-top: 1px solid rgba(245, 241, 233, 0.12);
1746
- }
1747
-
1748
- .continuity-relationship strong {
1749
- min-width: 0;
1750
- overflow: hidden;
1751
- color: #f5f1e9;
1752
- font-size: 11px;
1753
- text-overflow: ellipsis;
1754
- white-space: nowrap;
1755
- }
1756
-
1757
- .continuity-relationship span {
1758
- flex: 0 0 auto;
1759
- color: #bfae90;
1760
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1761
- font-size: 8px;
1762
- font-weight: 800;
1763
- letter-spacing: 0.05em;
1764
- text-transform: uppercase;
1765
- }
1766
-
1767
- .historical-returns-preview .continuity-relationship {
1768
- align-items: center;
1769
- }
1770
-
1771
- .back-to-back-preview {
1772
- border-color: rgba(166, 191, 213, 0.36);
1773
- background: rgba(166, 191, 213, 0.08);
1774
- }
1775
-
1776
- .back-to-back-preview .continuity-relationship {
1777
- align-items: center;
1778
- }
1779
-
1780
- .historical-return-identity {
1781
- min-width: 0;
1782
- }
1783
-
1784
- .historical-return-identity strong {
1785
- display: block;
1786
- }
1787
-
1788
- .historical-return-identity p {
1789
- overflow: hidden;
1790
- margin: 3px 0 0;
1791
- color: #918b82;
1792
- font-size: 9px;
1793
- text-overflow: ellipsis;
1794
- white-space: nowrap;
1795
- }
1796
-
1797
- .listening-patterns-preview {
1798
- min-width: 0;
1799
- margin-top: 14px;
1800
- padding: 16px;
1801
- border: 1px solid rgba(166, 191, 213, 0.36);
1802
- border-radius: 16px;
1803
- background: rgba(166, 191, 213, 0.08);
1804
- }
1805
-
1806
- .listening-patterns-summary {
1807
- margin: 10px 0 12px;
1808
- color: #aaa49b;
1809
- font-size: 9px;
1810
- line-height: 1.55;
1811
- }
1812
-
1813
- .listening-patterns-metrics {
1814
- display: grid;
1815
- grid-template-columns: repeat(3, minmax(0, 1fr));
1816
- gap: 7px;
1817
- margin-bottom: 12px;
1818
- }
1819
-
1820
- .listening-patterns-metric {
1821
- min-width: 0;
1822
- padding: 10px 9px;
1823
- border: 1px solid rgba(166, 191, 213, 0.22);
1824
- border-radius: 10px;
1825
- background: rgba(245, 241, 233, 0.035);
1826
- }
1827
-
1828
- .listening-patterns-metric strong {
1829
- display: block;
1830
- overflow: hidden;
1831
- color: #f5f1e9;
1832
- font: 650 18px Iowan Old Style, Baskerville, serif;
1833
- text-overflow: ellipsis;
1834
- white-space: nowrap;
1835
- }
1836
-
1837
- .listening-patterns-metric span {
1838
- display: block;
1839
- margin-top: 4px;
1840
- color: #918b82;
1841
- font-size: 7px;
1842
- font-weight: 800;
1843
- letter-spacing: 0.07em;
1844
- text-transform: uppercase;
1845
- }
1846
-
1847
- .listening-pattern-releases {
1848
- display: grid;
1849
- min-width: 0;
1850
- gap: 0;
1851
- margin: 0 0 14px;
1852
- padding: 0;
1853
- list-style: none;
1854
- }
1855
-
1856
- .listening-pattern-release {
1857
- display: grid;
1858
- grid-template-columns: minmax(0, 1fr) auto;
1859
- gap: 12px;
1860
- min-width: 0;
1861
- align-items: baseline;
1862
- padding: 8px 0;
1863
- border-top: 1px solid rgba(245, 241, 233, 0.12);
1864
- }
1865
-
1866
- .listening-pattern-release > div { min-width: 0; }
1867
-
1868
- .listening-pattern-release strong,
1869
- .listening-pattern-release p {
1870
- overflow: hidden;
1871
- text-overflow: ellipsis;
1872
- white-space: nowrap;
1873
- }
1874
-
1875
- .listening-pattern-release strong { display: block; color: #f5f1e9; font-size: 10px; }
1876
- .listening-pattern-release p { margin: 2px 0 0; color: #918b82; font-size: 8px; }
1877
- .listening-pattern-release span { color: #a6bfd5; font: 800 8px ui-monospace, SFMono-Regular, Menlo, monospace; }
1878
-
1879
- .tuner-workbench {
1880
- min-width: 0;
1881
- padding: clamp(30px, 5vw, 58px);
1882
- }
1883
-
1884
- .correction-fieldset {
1885
- min-width: 0;
1886
- margin: 0;
1887
- padding: 0;
1888
- border: 0;
1889
- }
1890
-
1891
- .correction-fieldset:disabled { opacity: 0.5; }
1892
-
1893
- .control-block + .control-block { margin-top: 22px; }
1894
-
1895
- .control-label {
1896
- display: block;
1897
- margin: 0 0 9px;
1898
- color: var(--muted);
1899
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1900
- font-size: 9px;
1901
- font-weight: 800;
1902
- letter-spacing: 0.13em;
1903
- text-transform: uppercase;
1904
- }
1905
-
1906
- .choice-group {
1907
- display: grid;
1908
- min-width: 0;
1909
- grid-template-columns: repeat(2, minmax(0, 1fr));
1910
- gap: 6px;
1911
- padding: 5px;
1912
- border: 1px solid var(--line);
1913
- border-radius: 999px;
1914
- background: #ebe3d8;
1915
- }
1916
-
1917
- .choice-group input {
1918
- position: absolute;
1919
- opacity: 0;
1920
- pointer-events: none;
1921
- }
1922
-
1923
- .choice-group label {
1924
- display: flex;
1925
- min-width: 0;
1926
- min-height: 38px;
1927
- align-items: center;
1928
- justify-content: center;
1929
- gap: 8px;
1930
- padding: 8px 14px;
1931
- border-radius: 999px;
1932
- color: #625b53;
1933
- font-size: 12px;
1934
- font-weight: 720;
1935
- text-align: center;
1936
- cursor: pointer;
1937
- }
1938
-
1939
- .choice-group input:checked + label {
1940
- color: #f7f2e9;
1941
- background: var(--ink);
1942
- box-shadow: 0 4px 14px rgba(27, 27, 25, 0.14);
1943
- }
1944
-
1945
- .choice-group input:focus-visible + label {
1946
- outline: 3px solid rgba(120, 151, 179, 0.5);
1947
- outline-offset: 2px;
1948
- }
1949
-
1950
- .field-grid {
1951
- display: grid;
1952
- min-width: 0;
1953
- grid-template-columns: repeat(2, minmax(0, 1fr));
1954
- gap: 14px;
1955
- margin-top: 22px;
1956
- }
1957
-
1958
- .field { min-width: 0; }
1959
-
1960
- .text-input {
1961
- width: 100%;
1962
- min-width: 0;
1963
- min-height: 48px;
1964
- padding: 12px 14px;
1965
- border: 1px solid var(--line);
1966
- border-radius: 13px;
1967
- outline: none;
1968
- color: var(--ink);
1969
- background: #f3ede4;
1970
- font-size: 13px;
1971
- transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease;
1972
- }
1973
-
1974
- textarea.text-input {
1975
- min-height: 86px;
1976
- resize: vertical;
1977
- line-height: 1.5;
1978
- }
1979
-
1980
- .text-input:focus {
1981
- border-color: var(--blue);
1982
- background: #faf7f1;
1983
- box-shadow: 0 0 0 3px rgba(120, 151, 179, 0.16);
1984
- }
1985
-
1986
- .text-input::placeholder { color: #a29a90; }
1987
-
1988
- .field-hint {
1989
- margin: 7px 0 0;
1990
- color: #8a8278;
1991
- font-size: 10px;
1992
- line-height: 1.4;
1993
- }
1994
-
1995
- .correction-actions {
1996
- display: flex;
1997
- min-width: 0;
1998
- align-items: center;
1999
- gap: 16px;
2000
- margin-top: 22px;
2001
- }
2002
-
2003
- .correction-actions .primary-action { min-width: 198px; }
2004
-
2005
- .profile-status {
2006
- min-width: 0;
2007
- margin: 0;
2008
- color: var(--muted);
2009
- font-size: 11px;
2010
- line-height: 1.45;
2011
- }
2012
-
2013
- .profile-status.is-error { color: var(--red); font-weight: 680; }
2014
-
2015
- .active-corrections {
2016
- min-width: 0;
2017
- margin-top: 34px;
2018
- padding-top: 28px;
2019
- border-top: 1px solid var(--line);
2020
- }
2021
-
2022
- .active-corrections-head {
2023
- display: flex;
2024
- min-width: 0;
2025
- align-items: baseline;
2026
- justify-content: space-between;
2027
- gap: 18px;
2028
- margin-bottom: 14px;
2029
- }
2030
-
2031
- .active-corrections h3 {
2032
- margin: 0;
2033
- font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
2034
- font-size: 27px;
2035
- letter-spacing: -0.035em;
2036
- }
2037
-
2038
- .correction-count {
2039
- flex: 0 0 auto;
2040
- color: var(--muted);
2041
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2042
- font-size: 9px;
2043
- font-weight: 760;
2044
- letter-spacing: 0.1em;
2045
- text-transform: uppercase;
2046
- }
2047
-
2048
- .correction-empty {
2049
- margin: 0;
2050
- padding: 18px;
2051
- border: 1px dashed var(--line);
2052
- border-radius: 14px;
2053
- color: var(--muted);
2054
- font-size: 12px;
2055
- line-height: 1.5;
2056
- text-align: center;
2057
- }
2058
-
2059
- .correction-list {
2060
- display: grid;
2061
- min-width: 0;
2062
- gap: 9px;
2063
- }
2064
-
2065
- .correction-item {
2066
- display: grid;
2067
- min-width: 0;
2068
- grid-template-columns: minmax(0, 1fr) auto;
2069
- gap: 18px;
2070
- align-items: center;
2071
- padding: 15px 16px;
2072
- border: 1px solid var(--line);
2073
- border-radius: 15px;
2074
- background: #f1eae0;
2075
- }
2076
-
2077
- .correction-item-main { min-width: 0; }
2078
-
2079
- .correction-meta {
2080
- display: flex;
2081
- min-width: 0;
2082
- flex-wrap: wrap;
2083
- align-items: center;
2084
- gap: 7px;
2085
- margin-bottom: 6px;
2086
- }
2087
-
2088
- .correction-chip {
2089
- display: inline-flex;
2090
- align-items: center;
2091
- min-height: 20px;
2092
- padding: 3px 8px;
2093
- border-radius: 999px;
2094
- color: #315b45;
2095
- background: #d9e7dc;
2096
- font-size: 8px;
2097
- font-weight: 820;
2098
- letter-spacing: 0.1em;
2099
- text-transform: uppercase;
2100
- }
2101
-
2102
- .correction-chip.is-avoid {
2103
- color: #7b4338;
2104
- background: #ecd9d4;
2105
- }
2106
-
2107
- .correction-type, .correction-time {
2108
- color: #81786e;
2109
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2110
- font-size: 8px;
2111
- letter-spacing: 0.06em;
2112
- text-transform: uppercase;
2113
- }
2114
-
2115
- .correction-title {
2116
- display: block;
2117
- overflow-wrap: anywhere;
2118
- font-size: 14px;
2119
- line-height: 1.35;
2120
- }
2121
-
2122
- .correction-artist, .correction-note {
2123
- margin: 4px 0 0;
2124
- overflow-wrap: anywhere;
2125
- color: var(--muted);
2126
- font-size: 11px;
2127
- line-height: 1.45;
2128
- }
2129
-
2130
- .retract-action {
2131
- min-height: 34px;
2132
- padding: 0 12px;
2133
- border: 1px solid var(--line-strong);
2134
- border-radius: 999px;
2135
- color: #5c554d;
2136
- background: transparent;
2137
- font-size: 10px;
2138
- font-weight: 730;
2139
- cursor: pointer;
2140
- }
2141
-
2142
- .retract-action:hover:not(:disabled) { border-color: var(--ink); color: var(--ink); }
2143
-
2144
- .retract-action:focus-visible { outline: 3px solid rgba(120, 151, 179, 0.45); outline-offset: 2px; }
2145
-
2146
- .retract-action:disabled { opacity: 0.45; cursor: wait; }
2147
-
2148
- .footnote {
2149
- display: flex;
2150
- min-width: 0;
2151
- justify-content: space-between;
2152
- gap: 24px;
2153
- padding: 18px 5px 0;
2154
- color: var(--muted);
2155
- font-size: 10px;
2156
- line-height: 1.5;
2157
- }
2158
-
2159
- .footnote p { margin: 0; }
2160
-
2161
- @media (max-width: 820px) {
2162
- .shell { width: min(100% - 24px, 700px); padding-top: 14px; }
2163
- .local-badge { max-width: 48vw; overflow: hidden; text-overflow: ellipsis; }
2164
- .hero { grid-template-columns: minmax(0, 1fr); border-radius: 24px 24px 0 0; }
2165
- .hero-aside { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
2166
- .hero-step { grid-template-columns: 24px minmax(0, 1fr); padding-right: 10px; border-right: 1px solid rgba(245, 241, 233, 0.15); }
2167
- .hero-step:last-child { border-right: 0; }
2168
- .workspace { grid-template-columns: minmax(0, 1fr); border-radius: 0 0 24px 24px; }
2169
- .privacy-rail { border-right: 0; border-bottom: 1px solid var(--line); }
2170
- .privacy-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
2171
- .privacy-item { display: block; padding: 14px 0 0; border-bottom: 0 !important; }
2172
- .privacy-icon { margin-bottom: 10px; }
2173
- .tuner { grid-template-columns: minmax(0, 1fr); border-radius: 24px; }
2174
- .tuner-intro { padding-bottom: 42px; }
2175
- }
2176
-
2177
- @media (max-width: 560px) {
2178
- .shell { width: calc(100% - 16px); padding-bottom: 24px; }
2179
- .topbar { gap: 10px; padding: 0 2px 12px; }
2180
- .brand-mark { width: 36px; height: 36px; }
2181
- .brand-name { font-size: 19px; }
2182
- .brand-subtitle { display: none; }
2183
- .local-badge { max-width: 54vw; padding: 7px 9px; font-size: 9px; }
2184
- .hero { gap: 30px; padding: 34px 24px; }
2185
- h1 { font-size: clamp(48px, 16vw, 68px); }
2186
- .hero-copy { font-size: 16px; }
2187
- .hero-aside { display: block; }
2188
- .hero-step { grid-template-columns: 28px minmax(0, 1fr); padding-right: 0; border-right: 0; }
2189
- .privacy-rail, .importer { padding: 26px 22px; }
2190
- .privacy-list { display: block; }
2191
- .privacy-item { display: grid; padding: 14px 0; }
2192
- .importer-header { display: block; }
2193
- .limit { margin-top: 10px; }
2194
- .drop-zone { min-height: 230px; padding: 28px 20px; }
2195
- .file-meta { align-items: flex-start; }
2196
- .action-row { display: block; }
2197
- .primary-action { width: 100%; }
2198
- .status { margin-top: 12px; }
2199
- .stats { grid-template-columns: minmax(0, 1fr); }
2200
- .stat { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
2201
- .tuner-intro, .tuner-workbench { padding: 30px 22px; }
2202
- .field-grid { grid-template-columns: minmax(0, 1fr); }
2203
- .correction-actions { display: block; }
2204
- .correction-actions .primary-action { width: 100%; }
2205
- .profile-status { margin-top: 12px; }
2206
- .profile-metrics { grid-template-columns: minmax(0, 1fr); }
2207
- .profile-metric { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; }
2208
- .continuity-relationship { align-items: flex-start; flex-direction: column; gap: 4px; }
2209
- .continuity-relationship span { flex: 1 1 auto; }
2210
- .listening-patterns-metrics { grid-template-columns: minmax(0, 1fr); }
2211
- .studio-seasons-grid { grid-template-columns: minmax(0, 1fr); }
2212
- .listening-pattern-release { align-items: start; grid-template-columns: minmax(0, 1fr); gap: 4px; }
2213
- .profile-artifact-link { min-height: 44px; padding: 10px 0; box-sizing: border-box; }
2214
- .correction-item { grid-template-columns: minmax(0, 1fr); }
2215
- .retract-action { justify-self: start; }
2216
- .footnote { display: block; padding-inline: 3px; }
2217
- .footnote p + p { margin-top: 6px; }
2218
- }
2219
-
2220
- @media (prefers-reduced-motion: reduce) {
2221
- *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
2222
- }
2223
- </style>
2224
- </head>
2225
- <body>
2226
- <main class="shell" id="studio" data-session="${token}" data-maximum-bytes="${maximumArchiveBytes}" data-maximum-json-bytes="${maximumHistoryJsonBytes}" data-demo-only="${demoOnly}" data-session-mode="${sessionMode}" data-archive-count="${archiveCount}">
2227
- <header class="topbar">
2228
- <div class="brand">
2229
- <span class="brand-mark"><img src="/brand.png?session=${token}" alt=""></span>
2230
- <div>
2231
- <p class="brand-name">Moondog</p>
2232
- <p class="brand-subtitle">Listening intelligence studio</p>
2233
- </div>
2234
- </div>
2235
- <span class="local-badge">${demoOnly
2236
- ? "127.0.0.1 - fictional tour"
2237
- : privateInMemory
2238
- ? "127.0.0.1 - session-only"
2239
- : "127.0.0.1 - private session"}</span>
2240
- </header>
2241
-
2242
- <section class="hero" aria-labelledby="hero-title">
2243
- <div>
2244
- <p class="eyebrow">${demoOnly
2245
- ? "A zero-data product tour"
2246
- : privateInMemory
2247
- ? archiveCount === 1
2248
- ? "One ZIP, no lasting import"
2249
- : "Two ZIPs, one private session"
2250
- : "Meet your listening self"}</p>
2251
- <h1 id="hero-title">${demoOnly
2252
- ? "A music life,<br>made legible."
2253
- : privateInMemory
2254
- ? "Your whole history,<br>ready now."
2255
- : "Your history,<br>now useful."}</h1>
2256
- <p class="hero-copy">${demoOnly
2257
- ? "Explore a clearly fictional 52-play archive that Moondog passes through the production Spotify importer before rendering the local Time Machine, Tasteprint, and correction loop. No personal listening file is read or accepted in this tour."
2258
- : privateInMemory
2259
- ? archiveCount === 1
2260
- ? "Moondog read the explicitly supplied Spotify ZIP into this local process. Your original ZIP stays untouched, and no persistent profile, provider call, model, or cloud upload is involved."
2261
- : "Moondog reconciled the two explicitly supplied Spotify ZIPs inside this local process. Both originals stay untouched, and no persistent profile, provider call, model, or cloud upload is involved."
2262
- : "Turn Spotify or ListenBrainz history into one private Tasteprint and a Listening Time Machine across your years. Moondog reads it on this Mac and gives the agent evidence it can act on."}</p>
2263
- <div class="hero-preview-actions"${privateInMemory ? " hidden" : ""}>
2264
- <button class="hero-preview-link is-primary" id="hero-demo-action" type="button">
2265
- <span id="hero-demo-label">${demoOnly ? "Explore the real-import Time Machine" : "Try the real importer with fictional history"}</span>
2266
- <span aria-hidden="true">→</span>
2267
- </button>
2268
- <a class="hero-preview-link" href="/demo-tasteprint-card?session=${token}">Preview the fictional recap card <span aria-hidden="true">→</span></a>
2269
- </div>
2270
- </div>
2271
- <aside class="hero-aside" aria-label="${demoOnly
2272
- ? "Fictional tour sequence"
2273
- : privateInMemory
2274
- ? "Session-only sequence"
2275
- : "Import sequence"}">
2276
- ${demoOnly
2277
- ? `<div class="hero-step"><span>01</span><p>Run 52 fictional plays through the real importer.</p></div>
2278
- <div class="hero-step"><span>02</span><p>Follow four fictional year landmarks.</p></div>
2279
- <div class="hero-step"><span>03</span><p>Correct one reading and inspect its evidence.</p></div>`
2280
- : privateInMemory
2281
- ? `<div class="hero-step"><span>01</span><p>${archiveCount === 1 ? "Read the selected ZIP once" : "Reconcile both selected ZIPs"} in memory.</p></div>
2282
- <div class="hero-step"><span>02</span><p>Open the full Tasteprint and chronological route.</p></div>
2283
- <div class="hero-step"><span>03</span><p>Try corrections that vanish when Studio stops.</p></div>`
2284
- : `<div class="hero-step"><span>01</span><p>Choose a Spotify ZIP or ListenBrainz JSON.</p></div>
2285
- <div class="hero-step"><span>02</span><p>Build one private local music history.</p></div>
2286
- <div class="hero-step"><span>03</span><p>Open a chronological route through your years.</p></div>`}
2287
- </aside>
2288
- </section>
2289
-
2290
- <section class="workspace" aria-labelledby="import-title"${importHidden ? " hidden aria-hidden=\"true\"" : ""}>
2291
- <aside class="privacy-rail">
2292
- <p class="section-label">The privacy contract</p>
2293
- <div class="privacy-list">
2294
- <div class="privacy-item">
2295
- <span class="privacy-icon">1</span>
2296
- <div><strong>Stays on this Mac</strong><p>No cloud upload, provider call, or model.</p></div>
2297
- </div>
2298
- <div class="privacy-item">
2299
- <span class="privacy-icon">2</span>
2300
- <div><strong>Source is temporary</strong><p>The working copy is removed after import.</p></div>
2301
- </div>
2302
- <div class="privacy-item">
2303
- <span class="privacy-icon">3</span>
2304
- <div><strong>Only what is needed</strong><p>Spotify IDs and raw details never reach the Tasteprint.</p></div>
2305
- </div>
2306
- </div>
2307
- </aside>
2308
-
2309
- <div class="importer">
2310
- <div class="importer-header">
2311
- <div>
2312
- <p class="section-label">Listening history import</p>
2313
- <h2 id="import-title">Drop your history.</h2>
2314
- </div>
2315
- <span class="limit">ZIP ${maximumMegabytes} MB / JSON ${maximumJsonMegabytes} MB</span>
2316
- </div>
2317
-
2318
- <input class="file-input" id="archive" type="file" accept=".zip,.json,application/zip,application/json">
2319
- <label class="drop-zone" id="drop-zone" for="archive" tabindex="0">
2320
- <span>
2321
- <span class="drop-orbit" aria-hidden="true">↓</span>
2322
- <span class="drop-title" id="drop-title">Drag Spotify ZIP or ListenBrainz JSON here</span>
2323
- <span class="drop-copy" id="drop-copy">Account Data, Extended Streaming History, or saved ListenBrainz listens</span>
2324
- </span>
2325
- </label>
2326
-
2327
- <div class="file-meta" id="file-meta" aria-live="polite">
2328
- <span class="file-name" id="file-name"></span>
2329
- <span class="file-size" id="file-size"></span>
2330
- </div>
2331
-
2332
- <div class="action-row">
2333
- <button class="primary-action" id="import-action" type="button" disabled>
2334
- <span id="action-label">Import and build Tasteprint</span>
2335
- <span class="action-arrow" aria-hidden="true">→</span>
2336
- </button>
2337
- <p class="status" id="status" role="status" aria-live="polite">Nothing changes until you press import.</p>
2338
- </div>
2339
- <div class="progress" id="progress" aria-hidden="true"><div class="progress-bar" id="progress-bar"></div></div>
2340
-
2341
- <section class="result" id="result" aria-labelledby="result-title">
2342
- <p class="result-kicker" id="result-kicker">Private profile ready</p>
2343
- <h3 id="result-title">This is your listening arc.</h3>
2344
- <p class="result-copy" id="result-copy"></p>
2345
- <div class="stats" aria-label="Tasteprint coverage">
2346
- <div class="stat"><span class="stat-value" id="events">0</span><span class="stat-label">listens</span></div>
2347
- <div class="stat"><span class="stat-value" id="hours">0</span><span class="stat-label">hours</span></div>
2348
- <div class="stat"><span class="stat-value" id="tracks">0</span><span class="stat-label">tracks</span></div>
2349
- </div>
2350
- <div class="result-links">
2351
- <a class="result-link" id="result-link" href="#">Open Listening Time Machine <span aria-hidden="true">→</span></a>
2352
- <a class="result-link is-secondary" id="result-card-link" href="#">Open recap card <span aria-hidden="true">→</span></a>
2353
- <a class="result-link is-secondary" id="result-card-download-link" href="#" download>Download private HTML <span aria-hidden="true">↓</span></a>
2354
- </div>
2355
- <p class="result-boundary">The recap card is deliberately smaller, not automatically safe to share. Review every visible name, date, and aggregate first.</p>
2356
- </section>
2357
- </div>
2358
- </section>
2359
-
2360
- <section class="tuner" id="profile-editor" aria-labelledby="tuner-title">
2361
- <div class="tuner-intro">
2362
- <p class="section-label">Your word beats inference</p>
2363
- <h2 id="tuner-title">Correct the reading.</h2>
2364
- <p class="tuner-lede">Your history is evidence, not destiny. Tell Moondog what you actually like, or what to keep out. Your choice shapes the next Tasteprint without rewriting a single play.</p>
2365
-
2366
- <div class="profile-state" id="profile-state">
2367
- <div class="profile-state-head">
2368
- <span class="profile-state-dot" aria-hidden="true"></span>
2369
- <div>
2370
- <span class="demo-profile-badge" id="demo-profile-badge" hidden>Fictional real-import demo</span>
2371
- <strong id="profile-state-title">${demoOnly
2372
- ? "Loading the fictional listening profile"
2373
- : privateInMemory
2374
- ? "Reading the supplied Spotify history"
2375
- : "Checking this Mac for a private profile"}</strong>
2376
- <p id="profile-state-copy">${demoOnly
2377
- ? "A generated archive is imported in memory. No personal file or local profile is read."
2378
- : privateInMemory
2379
- ? "The full profile is being built in memory without creating persistent state."
2380
- : "This is a read-only local check."}</p>
2381
- </div>
2382
- </div>
2383
- <button class="demo-start-action" id="demo-start-action" type="button" hidden>
2384
- <span>Run the fictional archive through the real importer</span>
2385
- <span aria-hidden="true">→</span>
2386
- </button>
2387
- <div class="profile-metrics" id="profile-metrics" hidden>
2388
- <div class="profile-metric"><strong id="profile-events">0</strong><span>listens</span></div>
2389
- <div class="profile-metric"><strong id="profile-hours">0</strong><span>hours</span></div>
2390
- <div class="profile-metric"><strong id="profile-corrections">0</strong><span>direct signals</span></div>
2391
- </div>
2392
- <p class="time-machine-coverage identity-link-coverage" id="profile-identity-link-coverage" hidden>
2393
- <strong id="profile-identity-link-summary"></strong>
2394
- <span id="profile-identity-link-detail"></span>
2395
- </p>
2396
- <div class="profile-tools" id="profile-tools" hidden>
2397
- <button class="demo-start-action" id="profile-correct" type="button">Correct a reading</button>
2398
- <button class="demo-start-action" id="profile-refresh" type="button">Refresh profile</button>
2399
- </div>
2400
-
2401
- <section class="time-machine-preview" id="profile-time-machine" aria-labelledby="profile-time-machine-title" hidden>
2402
- <div class="time-machine-preview-head">
2403
- <strong id="profile-time-machine-title">Listening Time Machine</strong>
2404
- <span class="time-machine-count" id="profile-time-machine-count">0 landmarks</span>
2405
- </div>
2406
- <p class="time-machine-coverage" id="profile-time-machine-coverage" hidden>
2407
- <strong id="profile-time-machine-coverage-summary"></strong>
2408
- <span id="profile-time-machine-coverage-detail"></span>
2409
- </p>
2410
- <ol class="time-machine-route" id="profile-time-machine-route" aria-label="One song for each year"></ol>
2411
- <a class="profile-artifact-link time-machine-action" id="profile-time-machine-link" href="#">Open the chronological route <span aria-hidden="true">→</span></a>
2412
- </section>
2413
- <section class="listening-pulse-preview" id="profile-listening-pulse" aria-labelledby="profile-listening-pulse-title" hidden>
2414
- <div class="time-machine-preview-head">
2415
- <strong id="profile-listening-pulse-title">Listening Pulse</strong>
2416
- <span class="time-machine-count" id="profile-listening-pulse-count">0 active months</span>
2417
- </div>
2418
- <p class="listening-pulse-summary" id="profile-listening-pulse-summary"></p>
2419
- <div class="studio-pulse-grid" id="profile-listening-pulse-grid" role="grid" aria-label="Listening by month, in UTC"></div>
2420
- <p class="listening-pulse-boundary" id="profile-listening-pulse-boundary"></p>
2421
- <a class="profile-artifact-link time-machine-action" id="profile-listening-pulse-link" href="#">Open the full monthly view <span aria-hidden="true">→</span></a>
2422
- </section>
2423
- <section class="listening-seasons-preview" id="profile-listening-seasons" aria-labelledby="profile-listening-seasons-title" hidden>
2424
- <div class="time-machine-preview-head">
2425
- <strong id="profile-listening-seasons-title">Listening Seasons</strong>
2426
- <span class="time-machine-count" id="profile-listening-seasons-count">0 active seasons</span>
2427
- </div>
2428
- <p class="listening-seasons-summary" id="profile-listening-seasons-summary"></p>
2429
- <div class="studio-seasons-grid" id="profile-listening-seasons-grid" aria-label="Recent seasons, three months at a time, in UTC"></div>
2430
- <p class="listening-seasons-boundary" id="profile-listening-seasons-boundary"></p>
2431
- <a class="profile-artifact-link time-machine-action" id="profile-listening-seasons-link" href="#">Open the full seasonal view <span aria-hidden="true">→</span></a>
2432
- </section>
2433
- <section class="continuity-preview historical-returns-preview" id="profile-historical-returns" aria-labelledby="profile-historical-returns-title" hidden>
2434
- <div class="time-machine-preview-head">
2435
- <strong id="profile-historical-returns-title">Coming Back to Life</strong>
2436
- <span class="time-machine-count" id="profile-historical-returns-count">0 returns</span>
2437
- </div>
2438
- <p class="continuity-summary" id="profile-historical-returns-summary"></p>
2439
- <ol class="continuity-relationships" id="profile-historical-returns-tracks" aria-label="Songs that came back after a long gap"></ol>
2440
- <a class="profile-artifact-link time-machine-action" id="profile-historical-returns-link" href="#">Open the return story <span aria-hidden="true">→</span></a>
2441
- </section>
2442
- <section class="continuity-preview back-to-back-preview" id="profile-back-to-back" aria-labelledby="profile-back-to-back-title" hidden>
2443
- <div class="time-machine-preview-head">
2444
- <strong id="profile-back-to-back-title">Echoes</strong>
2445
- <span class="time-machine-count" id="profile-back-to-back-count">0 tracks</span>
2446
- </div>
2447
- <p class="continuity-summary" id="profile-back-to-back-summary"></p>
2448
- <ol class="continuity-relationships" id="profile-back-to-back-tracks" aria-label="Songs you played again right away"></ol>
2449
- <a class="profile-artifact-link time-machine-action" id="profile-back-to-back-link" href="#">Open the sequence evidence <span aria-hidden="true">→</span></a>
2450
- </section>
2451
- <section class="continuity-preview" id="profile-continuity" aria-labelledby="profile-continuity-title" hidden>
2452
- <div class="time-machine-preview-head">
2453
- <strong id="profile-continuity-title">What stayed. What changed.</strong>
2454
- <span class="time-machine-count" id="profile-continuity-count">0 across eras</span>
2455
- </div>
2456
- <p class="continuity-summary" id="profile-continuity-summary"></p>
2457
- <ol class="continuity-relationships" id="profile-continuity-relationships" aria-label="Artists who stayed across your years"></ol>
2458
- <a class="profile-artifact-link time-machine-action" id="profile-continuity-link" href="#">Open continuity and change <span aria-hidden="true">→</span></a>
2459
- </section>
2460
- <section class="listening-patterns-preview" id="profile-listening-patterns" aria-labelledby="profile-listening-patterns-title" hidden>
2461
- <div class="time-machine-preview-head">
2462
- <strong id="profile-listening-patterns-title">Inside a listening stretch</strong>
2463
- <span class="time-machine-count" id="profile-listening-patterns-count">Extended History</span>
2464
- </div>
2465
- <p class="listening-patterns-summary" id="profile-listening-patterns-summary"></p>
2466
- <div class="listening-patterns-metrics" id="profile-listening-patterns-metrics" hidden>
2467
- <div class="listening-patterns-metric"><strong id="profile-session-count">0</strong><span>approx sessions</span></div>
2468
- <div class="listening-patterns-metric"><strong id="profile-session-median">0</strong><span>median plays</span></div>
2469
- <div class="listening-patterns-metric"><strong id="profile-session-extended">0%</strong><span id="profile-session-extended-label">5+ play stretches</span></div>
2470
- </div>
2471
- <ol class="listening-pattern-releases" id="profile-listening-pattern-releases" aria-label="Records you went deep on"></ol>
2472
- <a class="profile-artifact-link time-machine-action" id="profile-listening-patterns-link" href="#">Open listening patterns <span aria-hidden="true">→</span></a>
2473
- </section>
2474
- <div class="profile-artifact-links">
2475
- <a class="profile-artifact-link" id="profile-card-link" href="#" hidden>Open recap card <span aria-hidden="true">→</span></a>
2476
- <a class="profile-artifact-link" id="profile-card-download-link" href="#" download hidden>Download private HTML <span aria-hidden="true">↓</span></a>
2477
- <a class="profile-artifact-link" id="profile-tasteprint-link" href="#" hidden>Open current Tasteprint <span aria-hidden="true">→</span></a>
2478
- </div>
2479
- </div>
2480
- </div>
2481
-
2482
- <div class="tuner-workbench">
2483
- <form id="correction-form" aria-label="Correct a listening preference">
2484
- <fieldset class="correction-fieldset" id="correction-fields" disabled>
2485
- <legend class="section-label">One choice</legend>
2486
-
2487
- <div class="control-block">
2488
- <span class="control-label">Correct an</span>
2489
- <div class="choice-group" role="radiogroup" aria-label="Correction target type">
2490
- <input id="entity-artist" name="entity-type" type="radio" value="artist" checked>
2491
- <label for="entity-artist">Artist</label>
2492
- <input id="entity-track" name="entity-type" type="radio" value="track">
2493
- <label for="entity-track">Track</label>
2494
- </div>
2495
- </div>
2496
-
2497
- <div class="field-grid">
2498
- <label class="field">
2499
- <span class="control-label" id="target-label">Artist name</span>
2500
- <input class="text-input" id="correction-label" name="label" type="text" maxlength="512" autocomplete="off" placeholder="Portishead" required>
2501
- </label>
2502
- <label class="field" id="artist-credit-field" hidden>
2503
- <span class="control-label">Track artist</span>
2504
- <input class="text-input" id="artist-credit" name="artist-credit" type="text" maxlength="512" autocomplete="off" placeholder="Portishead">
2505
- </label>
2506
- </div>
2507
-
2508
- <div class="control-block">
2509
- <span class="control-label">Current stance</span>
2510
- <div class="choice-group" role="radiogroup" aria-label="Correction stance">
2511
- <input id="stance-like" name="stance" type="radio" value="like" required checked>
2512
- <label for="stance-like">I like this</label>
2513
- <input id="stance-avoid" name="stance" type="radio" value="avoid">
2514
- <label for="stance-avoid">Avoid this</label>
2515
- </div>
2516
- </div>
2517
-
2518
- <label class="field control-block">
2519
- <span class="control-label" id="correction-note-label">Private note - optional</span>
2520
- <textarea class="text-input" id="correction-note" name="note" maxlength="2000" placeholder="Why this signal matters to you"></textarea>
2521
- <p class="field-hint" id="correction-note-hint">The note stays on this Mac and can appear in your Tasteprint. It is never sent to a model.</p>
2522
- </label>
2523
-
2524
- <div class="correction-actions">
2525
- <button class="primary-action" id="correction-action" type="submit">
2526
- <span id="correction-action-label">Apply to Tasteprint</span>
2527
- <span class="action-arrow" aria-hidden="true">→</span>
2528
- </button>
2529
- <p class="profile-status" id="profile-status" role="status" aria-live="polite">${demoOnly
2530
- ? "Running fictional history through the production importer."
2531
- : privateInMemory
2532
- ? archiveCount === 1
2533
- ? "Reading the supplied ZIP into this private session."
2534
- : "Reconciling both supplied ZIPs inside this private session."
2535
- : "Import listening history to begin."}</p>
2536
- </div>
2537
- <button class="retract-action" id="correction-return" type="button" hidden>Back to listening view</button>
2538
- </fieldset>
2539
- </form>
2540
-
2541
- <section class="active-corrections" aria-labelledby="active-corrections-title">
2542
- <div class="active-corrections-head">
2543
- <h3 id="active-corrections-title">Active corrections</h3>
2544
- <span class="correction-count" id="correction-count">0 active</span>
2545
- </div>
2546
- <p class="correction-empty" id="correction-empty">No choices yet. Add one to guide the reading. Your listening history stays as it is.</p>
2547
- <div class="correction-list" id="correction-list"></div>
2548
- </section>
2549
- </div>
2550
- </section>
2551
-
2552
- <footer class="footnote">
2553
- <p>${demoOnly
2554
- ? "This tour never reads or accepts personal listening history and writes no persistent profile."
2555
- : privateInMemory
2556
- ? `This private session keeps its profile and corrections only in memory. Stopping Studio discards them; ${archiveCount === 1 ? "the original ZIP remains" : "both original ZIPs remain"} untouched.`
2557
- : "Keep this terminal session running while Studio is open."}</p>
2558
- <p>Press Ctrl+C in the terminal to stop the local server.</p>
2559
- </footer>
2560
- </main>
2561
-
2562
- <script nonce="${safeNonce}">
2563
- (() => {
2564
- const studio = document.querySelector("#studio");
2565
- const input = document.querySelector("#archive");
2566
- const dropZone = document.querySelector("#drop-zone");
2567
- const dropTitle = document.querySelector("#drop-title");
2568
- const dropCopy = document.querySelector("#drop-copy");
2569
- const fileMeta = document.querySelector("#file-meta");
2570
- const fileName = document.querySelector("#file-name");
2571
- const fileSize = document.querySelector("#file-size");
2572
- const action = document.querySelector("#import-action");
2573
- const actionLabel = document.querySelector("#action-label");
2574
- const status = document.querySelector("#status");
2575
- const progress = document.querySelector("#progress");
2576
- const progressBar = document.querySelector("#progress-bar");
2577
- const result = document.querySelector("#result");
2578
- const resultLink = document.querySelector("#result-link");
2579
- const resultCardLink = document.querySelector("#result-card-link");
2580
- const resultCardDownloadLink = document.querySelector("#result-card-download-link");
2581
- const heroDemoAction = document.querySelector("#hero-demo-action");
2582
- const heroDemoLabel = document.querySelector("#hero-demo-label");
2583
- const profileState = document.querySelector("#profile-state");
2584
- const profileStateTitle = document.querySelector("#profile-state-title");
2585
- const profileStateCopy = document.querySelector("#profile-state-copy");
2586
- const demoProfileBadge = document.querySelector("#demo-profile-badge");
2587
- const demoStartAction = document.querySelector("#demo-start-action");
2588
- const profileMetrics = document.querySelector("#profile-metrics");
2589
- const profileEvents = document.querySelector("#profile-events");
2590
- const profileHours = document.querySelector("#profile-hours");
2591
- const profileCorrections = document.querySelector("#profile-corrections");
2592
- const profileIdentityLinkCoverage = document.querySelector("#profile-identity-link-coverage");
2593
- const profileIdentityLinkSummary = document.querySelector("#profile-identity-link-summary");
2594
- const profileIdentityLinkDetail = document.querySelector("#profile-identity-link-detail");
2595
- const profileTimeMachine = document.querySelector("#profile-time-machine");
2596
- const profileTimeMachineCount = document.querySelector("#profile-time-machine-count");
2597
- const profileTimeMachineCoverage = document.querySelector("#profile-time-machine-coverage");
2598
- const profileTimeMachineCoverageSummary = document.querySelector("#profile-time-machine-coverage-summary");
2599
- const profileTimeMachineCoverageDetail = document.querySelector("#profile-time-machine-coverage-detail");
2600
- const profileTimeMachineRoute = document.querySelector("#profile-time-machine-route");
2601
- const profileTimeMachineLink = document.querySelector("#profile-time-machine-link");
2602
- const profileListeningPulse = document.querySelector("#profile-listening-pulse");
2603
- const profileListeningPulseCount = document.querySelector("#profile-listening-pulse-count");
2604
- const profileListeningPulseSummary = document.querySelector("#profile-listening-pulse-summary");
2605
- const profileListeningPulseGrid = document.querySelector("#profile-listening-pulse-grid");
2606
- const profileListeningPulseBoundary = document.querySelector("#profile-listening-pulse-boundary");
2607
- const profileListeningPulseLink = document.querySelector("#profile-listening-pulse-link");
2608
- const profileListeningSeasons = document.querySelector("#profile-listening-seasons");
2609
- const profileListeningSeasonsCount = document.querySelector("#profile-listening-seasons-count");
2610
- const profileListeningSeasonsSummary = document.querySelector("#profile-listening-seasons-summary");
2611
- const profileListeningSeasonsGrid = document.querySelector("#profile-listening-seasons-grid");
2612
- const profileListeningSeasonsBoundary = document.querySelector("#profile-listening-seasons-boundary");
2613
- const profileListeningSeasonsLink = document.querySelector("#profile-listening-seasons-link");
2614
- const profileHistoricalReturns = document.querySelector("#profile-historical-returns");
2615
- const profileHistoricalReturnsCount = document.querySelector("#profile-historical-returns-count");
2616
- const profileHistoricalReturnsSummary = document.querySelector("#profile-historical-returns-summary");
2617
- const profileHistoricalReturnTracks = document.querySelector("#profile-historical-returns-tracks");
2618
- const profileHistoricalReturnsLink = document.querySelector("#profile-historical-returns-link");
2619
- const profileBackToBack = document.querySelector("#profile-back-to-back");
2620
- const profileBackToBackCount = document.querySelector("#profile-back-to-back-count");
2621
- const profileBackToBackSummary = document.querySelector("#profile-back-to-back-summary");
2622
- const profileBackToBackTracks = document.querySelector("#profile-back-to-back-tracks");
2623
- const profileBackToBackLink = document.querySelector("#profile-back-to-back-link");
2624
- const profileContinuity = document.querySelector("#profile-continuity");
2625
- const profileContinuityCount = document.querySelector("#profile-continuity-count");
2626
- const profileContinuitySummary = document.querySelector("#profile-continuity-summary");
2627
- const profileContinuityRelationships = document.querySelector("#profile-continuity-relationships");
2628
- const profileContinuityLink = document.querySelector("#profile-continuity-link");
2629
- const profileListeningPatterns = document.querySelector("#profile-listening-patterns");
2630
- const profileListeningPatternsCount = document.querySelector("#profile-listening-patterns-count");
2631
- const profileListeningPatternsSummary = document.querySelector("#profile-listening-patterns-summary");
2632
- const profileListeningPatternsMetrics = document.querySelector("#profile-listening-patterns-metrics");
2633
- const profileSessionCount = document.querySelector("#profile-session-count");
2634
- const profileSessionMedian = document.querySelector("#profile-session-median");
2635
- const profileSessionExtended = document.querySelector("#profile-session-extended");
2636
- const profileSessionExtendedLabel = document.querySelector("#profile-session-extended-label");
2637
- const profileListeningPatternReleases = document.querySelector("#profile-listening-pattern-releases");
2638
- const profileListeningPatternsLink = document.querySelector("#profile-listening-patterns-link");
2639
- const profileTasteprintLink = document.querySelector("#profile-tasteprint-link");
2640
- const profileCardLink = document.querySelector("#profile-card-link");
2641
- const profileCardDownloadLink = document.querySelector("#profile-card-download-link");
2642
- const correctionForm = document.querySelector("#correction-form");
2643
- const correctionReturn = document.querySelector("#correction-return");
2644
- const profileTools = document.querySelector("#profile-tools");
2645
- const profileCorrect = document.querySelector("#profile-correct");
2646
- const profileRefresh = document.querySelector("#profile-refresh");
2647
- const correctionFields = document.querySelector("#correction-fields");
2648
- const correctionLabel = document.querySelector("#correction-label");
2649
- const targetLabel = document.querySelector("#target-label");
2650
- const artistCreditField = document.querySelector("#artist-credit-field");
2651
- const artistCredit = document.querySelector("#artist-credit");
2652
- const correctionNote = document.querySelector("#correction-note");
2653
- const correctionNoteLabel = document.querySelector("#correction-note-label");
2654
- const correctionNoteHint = document.querySelector("#correction-note-hint");
2655
- const correctionActionLabel = document.querySelector("#correction-action-label");
2656
- const profileStatus = document.querySelector("#profile-status");
2657
- const correctionCount = document.querySelector("#correction-count");
2658
- const correctionEmpty = document.querySelector("#correction-empty");
2659
- const correctionList = document.querySelector("#correction-list");
2660
- const sessionToken = studio.dataset.session;
2661
- const maximumBytes = Number(studio.dataset.maximumBytes);
2662
- const maximumJsonBytes = Number(studio.dataset.maximumJsonBytes);
2663
- const studioMode = studio.dataset.sessionMode || "persistent";
2664
- const archiveMode = studioMode === "archive";
2665
- const archiveCount = Number(studio.dataset.archiveCount) || 0;
2666
- const portableCardUrl = (value) => {
2667
- const url = new URL(value, window.location.origin);
2668
- url.searchParams.set("download", "1");
2669
- return url.pathname + url.search;
2670
- };
2671
- let selectedFile = null;
2672
- let selectedFileKind = null;
2673
- let busy = false;
2674
- let profileReady = false;
2675
- let profileBusy = false;
2676
- let profileMode = "empty";
2677
- let correctionOrigin = null;
2678
-
2679
- const formatNumber = (value, maximumFractionDigits = 0) =>
2680
- new Intl.NumberFormat("en-US", { maximumFractionDigits }).format(Number(value) || 0);
2681
-
2682
- const formatCompactNumber = (value) =>
2683
- new Intl.NumberFormat("en-US", {
2684
- notation: "compact",
2685
- maximumFractionDigits: 1,
2686
- }).format(Number(value) || 0);
2687
-
2688
- const formatNaturalList = (items) => {
2689
- if (items.length === 0) return "";
2690
- if (items.length === 1) return items[0];
2691
- if (items.length === 2) return items[0] + " and " + items[1];
2692
- return items.slice(0, -1).join(", ") + ", and " + items.at(-1);
2693
- };
2694
-
2695
- const formatBytes = (value) => {
2696
- if (value < 1024 * 1024) return formatNumber(value / 1024, 1) + " KB";
2697
- return formatNumber(value / (1024 * 1024), 1) + " MB";
2698
- };
2699
-
2700
- const setStatus = (message, error = false) => {
2701
- status.textContent = message;
2702
- status.classList.toggle("is-error", error);
2703
- };
2704
-
2705
- const setProfileStatus = (message, error = false) => {
2706
- profileStatus.textContent = message;
2707
- profileStatus.classList.toggle("is-error", error);
2708
- };
2709
-
2710
- const syncAvailability = () => {
2711
- input.disabled = archiveMode || busy || profileBusy;
2712
- action.disabled = archiveMode || busy || profileBusy || !selectedFile;
2713
- heroDemoAction.disabled = busy || profileBusy;
2714
- demoStartAction.disabled = busy || profileBusy;
2715
- correctionFields.disabled = !profileReady || busy || profileBusy;
2716
- profileCorrect.disabled = !profileReady || busy || profileBusy;
2717
- profileRefresh.disabled = busy || profileBusy;
2718
- for (const button of document.querySelectorAll("[data-correction-shortcut]")) {
2719
- button.disabled = !profileReady || busy || profileBusy;
2720
- }
2721
- };
2722
-
2723
- const selectedEntityType = () =>
2724
- correctionForm.elements.namedItem("entity-type").value;
2725
-
2726
- const syncTargetFields = () => {
2727
- const track = selectedEntityType() === "track";
2728
- artistCreditField.hidden = !track;
2729
- artistCredit.required = track;
2730
- targetLabel.textContent = track ? "Track title" : "Artist name";
2731
- correctionLabel.placeholder = track ? "Roads" : "Portishead";
2732
- };
2733
-
2734
- const focusSection = (element) => {
2735
- element.scrollIntoView({ block: "start", behavior: "instant" });
2736
- element.focus({ preventScroll: true });
2737
- };
2738
-
2739
- const startCorrection = (track = null, origin = profileState) => {
2740
- if (!profileReady || busy || profileBusy) return;
2741
- correctionOrigin = origin;
2742
- correctionReturn.hidden = false;
2743
- if (track) {
2744
- correctionForm.elements.namedItem("entity-type").value = "track";
2745
- correctionLabel.value = track.title;
2746
- artistCredit.value = track.artist;
2747
- for (const radio of correctionForm.elements.namedItem("stance")) {
2748
- radio.checked = false;
2749
- }
2750
- correctionNote.value = "";
2751
- syncTargetFields();
2752
- setProfileStatus("Choose like or keep out for this track, then apply. Nothing has changed yet.");
2753
- }
2754
- correctionForm.scrollIntoView({ block: "start", behavior: "instant" });
2755
- (track ? document.querySelector("#stance-like") : correctionLabel).focus({ preventScroll: true });
2756
- };
2757
-
2758
- const trackCorrectionButton = (track, origin) => {
2759
- const button = document.createElement("button");
2760
- button.type = "button";
2761
- button.className = "track-correction-action";
2762
- button.dataset.correctionShortcut = "track";
2763
- button.textContent = "Correct this track";
2764
- button.setAttribute("aria-label", "Correct " + track.title + " by " + track.artist);
2765
- button.addEventListener("click", () => startCorrection(track, origin));
2766
- return button;
2767
- };
2768
-
2769
- profileCorrect.addEventListener("click", () => startCorrection());
2770
- correctionReturn.addEventListener("click", () => {
2771
- const target = correctionOrigin && !correctionOrigin.hidden
2772
- ? correctionOrigin : profileState;
2773
- target.tabIndex = -1;
2774
- focusSection(target);
2775
- });
2776
- profileRefresh.addEventListener("click", async () => {
2777
- if (busy || profileBusy) return;
2778
- profileBusy = true;
2779
- profileRefresh.textContent = "Refreshing...";
2780
- syncAvailability();
2781
- try {
2782
- await loadProfile();
2783
- } catch {
2784
- // loadProfile already renders the actionable local error.
2785
- } finally {
2786
- profileBusy = false;
2787
- profileRefresh.textContent = "Refresh profile";
2788
- syncAvailability();
2789
- }
2790
- });
2791
-
2792
- const formatDate = (value) => {
2793
- const date = new Date(value);
2794
- if (Number.isNaN(date.valueOf())) return "now";
2795
- return new Intl.DateTimeFormat("en-US", {
2796
- year: "numeric",
2797
- month: "short",
2798
- day: "numeric",
2799
- timeZone: "UTC",
2800
- }).format(date);
2801
- };
2802
-
2803
- const renderCorrections = (corrections) => {
2804
- const items = Array.isArray(corrections) ? corrections : [];
2805
- correctionCount.textContent = formatNumber(items.length) + " active";
2806
- correctionEmpty.hidden = items.length > 0;
2807
- correctionList.replaceChildren();
2808
- for (const correction of items) {
2809
- const item = document.createElement("article");
2810
- item.className = "correction-item";
2811
-
2812
- const main = document.createElement("div");
2813
- main.className = "correction-item-main";
2814
- const meta = document.createElement("div");
2815
- meta.className = "correction-meta";
2816
- const chip = document.createElement("span");
2817
- chip.className = "correction-chip" + (correction.stance === "avoid" ? " is-avoid" : "");
2818
- chip.textContent = correction.stance === "avoid" ? "Avoid" : "Like";
2819
- const type = document.createElement("span");
2820
- type.className = "correction-type";
2821
- type.textContent = correction.entity_type === "track" ? "Track" : "Artist";
2822
- const time = document.createElement("span");
2823
- time.className = "correction-time";
2824
- time.textContent = formatDate(correction.occurred_at);
2825
- meta.append(chip, type, time);
2826
-
2827
- const title = document.createElement("strong");
2828
- title.className = "correction-title";
2829
- title.textContent = correction.label;
2830
- main.append(meta, title);
2831
- if (correction.artist_credit) {
2832
- const artist = document.createElement("p");
2833
- artist.className = "correction-artist";
2834
- artist.textContent = "by " + correction.artist_credit;
2835
- main.append(artist);
2836
- }
2837
- if (correction.note) {
2838
- const note = document.createElement("p");
2839
- note.className = "correction-note";
2840
- note.textContent = correction.note;
2841
- main.append(note);
2842
- }
2843
-
2844
- const retract = document.createElement("button");
2845
- retract.className = "retract-action";
2846
- retract.type = "button";
2847
- retract.textContent = "Retract";
2848
- retract.addEventListener("click", () => retractCorrection(correction, retract));
2849
- item.append(main, retract);
2850
- correctionList.append(item);
2851
- }
2852
- };
2853
-
2854
- const renderIdentityLinkCoverage = (coverage) => {
2855
- const links = Number.isInteger(coverage && coverage.cross_format_track_links)
2856
- ? coverage.cross_format_track_links
2857
- : 0;
2858
- const events = Number.isInteger(coverage && coverage.cross_format_linked_events)
2859
- ? coverage.cross_format_linked_events
2860
- : 0;
2861
- const ambiguousTracks = Number.isInteger(
2862
- coverage && coverage.cross_format_ambiguous_tracks,
2863
- )
2864
- ? coverage.cross_format_ambiguous_tracks
2865
- : 0;
2866
- const ambiguousEvents = Number.isInteger(
2867
- coverage && coverage.cross_format_ambiguous_events,
2868
- )
2869
- ? coverage.cross_format_ambiguous_events
2870
- : 0;
2871
- const visible = links > 0 || ambiguousTracks > 0;
2872
- const details = [];
2873
- if (links > 0) {
2874
- details.push(
2875
- "Plays that appear in both exports line up exactly, so " +
2876
- formatNumber(events) + " " +
2877
- (events === 1 ? "play now counts" : "plays now count") + " toward the same songs.",
2878
- );
2879
- }
2880
- if (ambiguousTracks > 0) {
2881
- details.push(
2882
- formatNumber(ambiguousTracks) + " " +
2883
- (ambiguousTracks === 1 ? "track" : "tracks") +
2884
- " with " + formatNumber(ambiguousEvents) + " " +
2885
- (ambiguousEvents === 1 ? "play" : "plays") +
2886
- " could match more than one song, so they stay apart.",
2887
- );
2888
- } else if (links > 0) {
2889
- details.push("Anything unclear stays apart.");
2890
- }
2891
- if (visible) details.push("Your original history is unchanged.");
2892
- profileIdentityLinkCoverage.hidden = !visible;
2893
- profileIdentityLinkSummary.textContent = !visible
2894
- ? ""
2895
- : links > 0
2896
- ? formatNumber(links) + " provisional track " +
2897
- (links === 1 ? "identity" : "identities") +
2898
- " joined to resolved Spotify identities."
2899
- : formatNumber(ambiguousTracks) + " ambiguous provisional track " +
2900
- (ambiguousTracks === 1 ? "identity remains" : "identities remain") +
2901
- " separate.";
2902
- profileIdentityLinkDetail.textContent = details.join(" ");
2903
- };
2904
-
2905
- const renderTimeMachine = (timeMachine, tasteprintUrl) => {
2906
- const landmarks = Array.isArray(timeMachine && timeMachine.landmarks)
2907
- ? timeMachine.landmarks
2908
- : [];
2909
- const ready = timeMachine && timeMachine.ready === true && landmarks.length >= 2 && tasteprintUrl;
2910
- profileTimeMachineRoute.replaceChildren();
2911
- profileTimeMachineCoverage.hidden = true;
2912
- profileTimeMachineCoverageSummary.textContent = "";
2913
- profileTimeMachineCoverageDetail.textContent = "";
2914
- profileTimeMachine.hidden = !ready;
2915
- if (!ready) return false;
2916
- profileTimeMachineCount.textContent = formatNumber(timeMachine.landmark_count) + " landmarks";
2917
- const retainedYearCount = Number.isInteger(timeMachine.retained_year_count)
2918
- ? timeMachine.retained_year_count
2919
- : 0;
2920
- const representedYearCount = Number.isInteger(timeMachine.represented_year_count)
2921
- ? timeMachine.represented_year_count
2922
- : landmarks.length;
2923
- const unrepresentedYears = Array.isArray(timeMachine.unrepresented_years)
2924
- ? timeMachine.unrepresented_years.filter(Number.isInteger).map(String)
2925
- : [];
2926
- if (retainedYearCount > 0) {
2927
- const retainedNoun = retainedYearCount === 1 ? "year" : "years";
2928
- const coverageCopy = formatNumber(representedYearCount) + " of " +
2929
- formatNumber(retainedYearCount) + " " + retainedNoun + " have a song.";
2930
- const omittedCopy = unrepresentedYears.length === 0
2931
- ? "Every year has a song."
2932
- : unrepresentedYears.length === 1
2933
- ? unrepresentedYears[0] + " is still in your history, but no song stood out enough to pick."
2934
- : formatNaturalList(unrepresentedYears) + " are still in your history, but no song stood out enough to pick.";
2935
- const engagedPlays = Number(timeMachine.minimum_engaged_plays);
2936
- const listeningMinutes = Number(timeMachine.minimum_listening_minutes);
2937
- const thresholdCopy = engagedPlays >= 1 && listeningMinutes >= 1
2938
- ? " Each song comes from its own biggest year, with at least " +
2939
- formatNumber(engagedPlays) + " plays not skipped and " + formatNumber(listeningMinutes, 1) + " minutes, and never one you asked to keep out."
2940
- : " Each song comes from its own biggest year, and never one you asked to keep out.";
2941
- profileTimeMachineCoverageSummary.textContent = coverageCopy;
2942
- profileTimeMachineCoverageDetail.textContent = omittedCopy + thresholdCopy;
2943
- profileTimeMachineCoverage.hidden = false;
2944
- }
2945
- for (const landmark of landmarks.slice(0, 4)) {
2946
- const item = document.createElement("li");
2947
- item.className = "time-machine-landmark";
2948
- const year = document.createElement("span");
2949
- year.className = "time-machine-year";
2950
- year.textContent = String(landmark.year);
2951
- const track = document.createElement("span");
2952
- track.className = "time-machine-track";
2953
- const title = document.createElement("strong");
2954
- title.textContent = landmark.title;
2955
- const artist = document.createElement("span");
2956
- artist.textContent = landmark.artist;
2957
- track.append(title, artist);
2958
- track.append(trackCorrectionButton(landmark, profileTimeMachine));
2959
- item.append(year, track);
2960
- profileTimeMachineRoute.append(item);
2961
- }
2962
- profileTimeMachineLink.href = tasteprintUrl + "#time-machine";
2963
- return true;
2964
- };
2965
-
2966
- const renderListeningPulse = (pulse, tasteprintUrl) => {
2967
- const months = Array.isArray(pulse && pulse.months) ? pulse.months : [];
2968
- const ready = pulse && pulse.ready === true && months.length > 0 && tasteprintUrl;
2969
- profileListeningPulseGrid.replaceChildren();
2970
- profileListeningPulseSummary.textContent = "";
2971
- profileListeningPulseBoundary.textContent = "";
2972
- profileListeningPulse.hidden = !ready;
2973
- if (!ready) return false;
2974
-
2975
- const monthLabels = [
2976
- ["J", "January"], ["F", "February"], ["M", "March"],
2977
- ["A", "April"], ["M", "May"], ["J", "June"],
2978
- ["J", "July"], ["A", "August"], ["S", "September"],
2979
- ["O", "October"], ["N", "November"], ["D", "December"],
2980
- ];
2981
- const activeMonths = Number.isInteger(pulse.active_month_count)
2982
- ? pulse.active_month_count
2983
- : months.filter((month) => month.event_count > 0).length;
2984
- const previewActiveMonths = Number.isInteger(
2985
- pulse.preview_active_month_count,
2986
- )
2987
- ? pulse.preview_active_month_count
2988
- : months.filter((month) => month.event_count > 0).length;
2989
- const previewMonthCount = Number.isInteger(pulse.preview_month_count)
2990
- ? pulse.preview_month_count
2991
- : months.length;
2992
- const peakMinutes = Number.isFinite(pulse.peak_listening_minutes)
2993
- ? pulse.peak_listening_minutes
2994
- : Math.max(...months.map((month) => month.listening_minutes));
2995
- profileListeningPulseCount.textContent = formatNumber(activeMonths) +
2996
- (activeMonths === 1 ? " active month" : " active months");
2997
- profileListeningPulseSummary.textContent =
2998
- formatNumber(previewActiveMonths) + " of " +
2999
- formatNumber(previewMonthCount) +
3000
- " displayed UTC months contain eligible retained events. Color compares listening minutes only within this displayed window.";
3001
-
3002
- const corner = document.createElement("span");
3003
- corner.className = "studio-pulse-corner";
3004
- corner.textContent = "UTC";
3005
- corner.setAttribute("aria-hidden", "true");
3006
- profileListeningPulseGrid.append(corner);
3007
- for (const [short, long] of monthLabels) {
3008
- const label = document.createElement("span");
3009
- label.className = "studio-pulse-month";
3010
- label.textContent = short;
3011
- label.title = long;
3012
- label.setAttribute("aria-hidden", "true");
3013
- profileListeningPulseGrid.append(label);
3014
- }
3015
-
3016
- const byMonth = new Map(months.map((month) => [month.month, month]));
3017
- const firstYear = Number(months[0].month.slice(0, 4));
3018
- const lastYear = Number(months.at(-1).month.slice(0, 4));
3019
- for (let year = firstYear; year <= lastYear; year += 1) {
3020
- const yearLabel = document.createElement("span");
3021
- yearLabel.className = "studio-pulse-year";
3022
- yearLabel.textContent = String(year);
3023
- yearLabel.setAttribute("role", "rowheader");
3024
- profileListeningPulseGrid.append(yearLabel);
3025
- for (let monthIndex = 0; monthIndex < 12; monthIndex += 1) {
3026
- const key = String(year) + "-" + String(monthIndex + 1).padStart(2, "0");
3027
- const month = byMonth.get(key);
3028
- const cell = document.createElement("span");
3029
- cell.className = "studio-pulse-cell";
3030
- if (!month) {
3031
- cell.classList.add("is-outside");
3032
- cell.setAttribute("aria-hidden", "true");
3033
- profileListeningPulseGrid.append(cell);
3034
- continue;
3035
- }
3036
- const level = month.listening_minutes <= 0 || peakMinutes <= 0
3037
- ? 0
3038
- : Math.max(
3039
- 1,
3040
- Math.min(
3041
- 4,
3042
- Math.ceil(
3043
- Math.sqrt(month.listening_minutes / peakMinutes) * 4,
3044
- ),
3045
- ),
3046
- );
3047
- cell.classList.add("level-" + level);
3048
- const description = month.event_count > 0
3049
- ? monthLabels[monthIndex][1] + " " + year + ": " +
3050
- formatNumber(month.event_count) + " eligible events, " +
3051
- formatNumber(month.listening_minutes) + " listening minutes."
3052
- : monthLabels[monthIndex][1] + " " + year +
3053
- ": no retained eligible events.";
3054
- cell.title = description;
3055
- cell.setAttribute("role", "gridcell");
3056
- cell.setAttribute("aria-label", description);
3057
- profileListeningPulseGrid.append(cell);
3058
- }
3059
- }
3060
-
3061
- const previewOmitted = Number.isInteger(pulse.preview_omitted_month_count)
3062
- ? pulse.preview_omitted_month_count
3063
- : 0;
3064
- const projectionOmitted = Number.isInteger(
3065
- pulse.omitted_earlier_month_count,
3066
- )
3067
- ? pulse.omitted_earlier_month_count
3068
- : 0;
3069
- const boundedNotes = [];
3070
- if (previewOmitted > 0) {
3071
- boundedNotes.push(
3072
- "Showing the latest " + formatNumber(previewMonthCount) +
3073
- " months; " + formatNumber(previewOmitted) +
3074
- " earlier months are in the full Tasteprint.",
3075
- );
3076
- }
3077
- if (projectionOmitted > 0) {
3078
- boundedNotes.push(
3079
- formatNumber(projectionOmitted) +
3080
- " even earlier months go back further than Moondog keeps (240 months).",
3081
- );
3082
- }
3083
- boundedNotes.push(
3084
- "A blank cell means no history was kept for that month, not that you stopped listening.",
3085
- );
3086
- profileListeningPulseBoundary.textContent = boundedNotes.join(" ");
3087
- profileListeningPulseLink.href = tasteprintUrl + "#listening-pulse";
3088
- return true;
3089
- };
3090
-
3091
- const renderListeningSeasons = (value, tasteprintUrl) => {
3092
- const seasons = Array.isArray(value && value.seasons)
3093
- ? value.seasons
3094
- : [];
3095
- const ready = value && value.ready === true &&
3096
- seasons.some((season) => season.event_count > 0) && tasteprintUrl;
3097
- profileListeningSeasonsGrid.replaceChildren();
3098
- profileListeningSeasonsSummary.textContent = "";
3099
- profileListeningSeasonsBoundary.textContent = "";
3100
- profileListeningSeasons.hidden = !ready;
3101
- if (!ready) return false;
3102
-
3103
- const activeSeasonCount = Number.isInteger(value.active_season_count)
3104
- ? value.active_season_count
3105
- : seasons.filter((season) => season.event_count > 0).length;
3106
- const previewSeasonCount = Number.isInteger(value.preview_season_count)
3107
- ? value.preview_season_count
3108
- : seasons.length;
3109
- const previewActiveSeasonCount = Number.isInteger(
3110
- value.preview_active_season_count,
3111
- )
3112
- ? value.preview_active_season_count
3113
- : seasons.filter((season) => season.event_count > 0).length;
3114
- profileListeningSeasonsCount.textContent =
3115
- formatNumber(activeSeasonCount) +
3116
- (activeSeasonCount === 1 ? " active season" : " active seasons");
3117
- profileListeningSeasonsSummary.textContent =
3118
- "The latest " + formatNumber(previewSeasonCount) +
3119
- " fixed UTC calendar quarters are shown, including " +
3120
- formatNumber(previewActiveSeasonCount) +
3121
- " with eligible retained events.";
3122
-
3123
- const monthNames = [
3124
- "Jan", "Feb", "Mar", "Apr", "May", "Jun",
3125
- "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
3126
- ];
3127
- for (const season of seasons) {
3128
- const card = document.createElement("article");
3129
- card.className = "studio-season quarter-" + season.key.slice(6);
3130
- const head = document.createElement("div");
3131
- head.className = "studio-season-head";
3132
- const key = document.createElement("strong");
3133
- key.textContent = season.key.replace("-", " ");
3134
- const range = document.createElement("span");
3135
- const firstMonth = Number(season.start_month.slice(5, 7));
3136
- const lastMonth = Number(season.end_month.slice(5, 7));
3137
- range.textContent = monthNames[firstMonth - 1] === monthNames[lastMonth - 1]
3138
- ? monthNames[firstMonth - 1] + " UTC"
3139
- : monthNames[firstMonth - 1] + "-" +
3140
- monthNames[lastMonth - 1] + " UTC";
3141
- head.append(key, range);
3142
- card.append(head);
3143
-
3144
- if (season.event_count === 0) {
3145
- card.classList.add("is-empty");
3146
- const empty = document.createElement("p");
3147
- empty.className = "studio-season-empty";
3148
- empty.textContent = "Nothing in your history for these months.";
3149
- const retained = document.createElement("small");
3150
- retained.textContent = formatNumber(season.retained_month_count) +
3151
- " of 3 months in your history";
3152
- card.append(empty, retained);
3153
- profileListeningSeasonsGrid.append(card);
3154
- continue;
3155
- }
3156
-
3157
- const total = document.createElement("div");
3158
- total.className = "studio-season-total";
3159
- const time = document.createElement("strong");
3160
- time.textContent = season.listening_minutes < 120
3161
- ? formatNumber(season.listening_minutes) + " min"
3162
- : formatNumber(season.listening_minutes / 60, 1) + " h";
3163
- const events = document.createElement("span");
3164
- events.textContent = formatNumber(season.event_count) + " events / " +
3165
- formatNumber(season.distinct_tracks) + " tracks";
3166
- total.append(time, events);
3167
-
3168
- const mix = document.createElement("div");
3169
- mix.className = "studio-season-mix";
3170
- const firstObservedShare = season.distinct_tracks > 0
3171
- ? Math.max(
3172
- 0,
3173
- Math.min(
3174
- 100,
3175
- (season.first_observed_tracks / season.distinct_tracks) * 100,
3176
- ),
3177
- )
3178
- : 0;
3179
- const mixFill = document.createElement("span");
3180
- mixFill.style.width = firstObservedShare + "%";
3181
- mix.append(mixFill);
3182
- mix.setAttribute(
3183
- "aria-label",
3184
- formatNumber(season.first_observed_tracks) +
3185
- " tracks first observed in retained history and " +
3186
- formatNumber(season.returning_tracks) +
3187
- " seen in an earlier retained season",
3188
- );
3189
- const mixLabel = document.createElement("p");
3190
- mixLabel.className = "studio-season-mix-label";
3191
- const first = document.createElement("span");
3192
- first.textContent = formatNumber(season.first_observed_tracks) +
3193
- " first";
3194
- const returning = document.createElement("span");
3195
- returning.textContent = formatNumber(season.returning_tracks) +
3196
- " seen earlier";
3197
- mixLabel.append(first, returning);
3198
-
3199
- const leading = document.createElement("div");
3200
- leading.className = "studio-season-anchor";
3201
- const leadingLabel = document.createElement("span");
3202
- leadingLabel.textContent = "Leading artist";
3203
- const leadingName = document.createElement("strong");
3204
- leadingName.textContent = season.leading_artist;
3205
- leading.append(leadingLabel, leadingName);
3206
-
3207
- const signature = document.createElement("div");
3208
- signature.className = "studio-season-anchor";
3209
- const signatureLabel = document.createElement("span");
3210
- signatureLabel.textContent = "Signature track";
3211
- const signatureTitle = document.createElement("strong");
3212
- signatureTitle.textContent = season.signature_track.title;
3213
- const signatureArtist = document.createElement("small");
3214
- signatureArtist.textContent = season.signature_track.artist;
3215
- signature.append(
3216
- signatureLabel,
3217
- signatureTitle,
3218
- signatureArtist,
3219
- );
3220
- card.append(total, mix, mixLabel, leading, signature);
3221
- profileListeningSeasonsGrid.append(card);
3222
- }
3223
-
3224
- const previewOmitted = Number.isInteger(
3225
- value.preview_omitted_season_count,
3226
- )
3227
- ? value.preview_omitted_season_count
3228
- : 0;
3229
- const previewOmittedActive = Number.isInteger(
3230
- value.preview_omitted_active_season_count,
3231
- )
3232
- ? value.preview_omitted_active_season_count
3233
- : 0;
3234
- const projectionOmitted = Number.isInteger(
3235
- value.omitted_earlier_season_count,
3236
- )
3237
- ? value.omitted_earlier_season_count
3238
- : 0;
3239
- const notes = [];
3240
- if (previewOmitted > 0) {
3241
- notes.push(
3242
- formatNumber(previewOmitted) +
3243
- " earlier seasons, " +
3244
- formatNumber(previewOmittedActive) +
3245
- " of them with listening, are in the full Tasteprint.",
3246
- );
3247
- }
3248
- if (projectionOmitted > 0) {
3249
- notes.push(
3250
- formatNumber(projectionOmitted) +
3251
- " even earlier seasons go back further than Moondog keeps (80 seasons).",
3252
- );
3253
- }
3254
- notes.push(
3255
- "New means new to your history, which is not always when you found it. These windows say nothing about your mood or your life.",
3256
- );
3257
- profileListeningSeasonsBoundary.textContent = notes.join(" ");
3258
- profileListeningSeasonsLink.href = tasteprintUrl + "#listening-seasons";
3259
- return true;
3260
- };
3261
-
3262
- const renderHistoricalReturns = (historicalReturns, tasteprintUrl) => {
3263
- const tracks = Array.isArray(historicalReturns && historicalReturns.tracks)
3264
- ? historicalReturns.tracks
3265
- : [];
3266
- const ready = historicalReturns && historicalReturns.ready === true &&
3267
- tracks.length > 0 && tasteprintUrl;
3268
- profileHistoricalReturnTracks.replaceChildren();
3269
- profileHistoricalReturnsSummary.textContent = "";
3270
- profileHistoricalReturns.hidden = !ready;
3271
- if (!ready) return false;
3272
- const returnTrackCount = Number.isInteger(
3273
- historicalReturns.return_track_count,
3274
- )
3275
- ? historicalReturns.return_track_count
3276
- : tracks.length;
3277
- const minimumGapDays = Number.isInteger(
3278
- historicalReturns.minimum_gap_days,
3279
- )
3280
- ? historicalReturns.minimum_gap_days
3281
- : null;
3282
- profileHistoricalReturnsCount.textContent =
3283
- formatNumber(returnTrackCount) +
3284
- (returnTrackCount === 1 ? " song came back" : " songs came back");
3285
- profileHistoricalReturnsSummary.textContent = minimumGapDays
3286
- ? "Each song came back after at least " +
3287
- formatNumber(minimumGapDays) +
3288
- " days away. That is a pattern, not proof you missed it."
3289
- : "These songs came back after a long gap. That is a pattern, not proof you missed them.";
3290
- for (const track of tracks.slice(0, 4)) {
3291
- const item = document.createElement("li");
3292
- item.className = "continuity-relationship";
3293
- const identity = document.createElement("div");
3294
- identity.className = "historical-return-identity";
3295
- const title = document.createElement("strong");
3296
- title.textContent = track.title;
3297
- const artist = document.createElement("p");
3298
- artist.textContent = track.artist;
3299
- identity.append(title, artist);
3300
- identity.append(trackCorrectionButton(track, profileHistoricalReturns));
3301
- const gap = document.createElement("span");
3302
- gap.textContent = formatNumber(track.return_count) +
3303
- (track.return_count === 1 ? " return" : " returns") +
3304
- " · " + formatNumber(track.longest_gap_days) + "d max";
3305
- item.append(identity, gap);
3306
- profileHistoricalReturnTracks.append(item);
3307
- }
3308
- profileHistoricalReturnsLink.href = tasteprintUrl + "#tracks-that-stay";
3309
- return true;
3310
- };
3311
-
3312
- const renderBackToBack = (backToBack, tasteprintUrl) => {
3313
- const tracks = Array.isArray(backToBack && backToBack.tracks)
3314
- ? backToBack.tracks
3315
- : [];
3316
- const ready = backToBack && backToBack.ready === true &&
3317
- tracks.length > 0 && tasteprintUrl;
3318
- profileBackToBackTracks.replaceChildren();
3319
- profileBackToBackSummary.textContent = "";
3320
- profileBackToBack.hidden = !ready;
3321
- if (!ready) return false;
3322
- const trackCount = Number.isInteger(backToBack.track_count)
3323
- ? backToBack.track_count
3324
- : tracks.length;
3325
- profileBackToBackCount.textContent =
3326
- formatNumber(trackCount) + (trackCount === 1 ? " track" : " tracks");
3327
- const minimumPlays = Number.isInteger(
3328
- backToBack.minimum_consecutive_plays,
3329
- )
3330
- ? backToBack.minimum_consecutive_plays
3331
- : 2;
3332
- const minimumSeconds = Number.isInteger(
3333
- backToBack.minimum_played_seconds,
3334
- )
3335
- ? backToBack.minimum_played_seconds
3336
- : 30;
3337
- const maximumGap = Number.isInteger(backToBack.maximum_gap_minutes)
3338
- ? backToBack.maximum_gap_minutes
3339
- : 30;
3340
- profileBackToBackSummary.textContent =
3341
- "Each song was played at least " + formatNumber(minimumPlays) +
3342
- " times in a row, each play at least " +
3343
- formatNumber(minimumSeconds) + " seconds, not skipped, and within " +
3344
- formatNumber(maximumGap) +
3345
- " minutes of the last. It could be love, or repeat mode, or falling asleep.";
3346
- for (const track of tracks.slice(0, 4)) {
3347
- const item = document.createElement("li");
3348
- item.className = "continuity-relationship";
3349
- const identity = document.createElement("div");
3350
- identity.className = "historical-return-identity";
3351
- const title = document.createElement("strong");
3352
- title.textContent = track.title;
3353
- const artist = document.createElement("p");
3354
- artist.textContent = track.artist;
3355
- identity.append(title, artist);
3356
- const sequence = document.createElement("span");
3357
- identity.append(trackCorrectionButton(track, profileBackToBack));
3358
- sequence.textContent = formatNumber(track.maximum_consecutive_plays) +
3359
- "x longest · " + formatNumber(track.burst_count) +
3360
- (track.burst_count === 1 ? " sequence" : " sequences");
3361
- item.append(identity, sequence);
3362
- profileBackToBackTracks.append(item);
3363
- }
3364
- profileBackToBackLink.href = tasteprintUrl + "#back-to-back";
3365
- return true;
3366
- };
3367
-
3368
- const renderContinuity = (continuity, tasteprintUrl) => {
3369
- const relationships = Array.isArray(continuity && continuity.relationships)
3370
- ? continuity.relationships
3371
- : [];
3372
- const latest = continuity && continuity.latest_transition;
3373
- const ready = continuity && continuity.ready === true &&
3374
- (relationships.length > 0 || latest) && tasteprintUrl;
3375
- profileContinuityRelationships.replaceChildren();
3376
- profileContinuitySummary.textContent = "";
3377
- profileContinuity.hidden = !ready;
3378
- if (!ready) return false;
3379
- const relationshipCount = Number.isInteger(continuity.relationship_count)
3380
- ? continuity.relationship_count
3381
- : relationships.length;
3382
- profileContinuityCount.textContent =
3383
- formatNumber(relationshipCount) + " across eras";
3384
- if (
3385
- latest &&
3386
- Number.isInteger(latest.from_year) &&
3387
- Number.isInteger(latest.to_year) &&
3388
- Number.isFinite(latest.continuity_percent)
3389
- ) {
3390
- profileContinuitySummary.textContent =
3391
- formatNumber(latest.continuity_percent, 1) + "% of " + latest.to_year +
3392
- "'s top artists were also near the top in " + latest.from_year + ". " +
3393
- formatNumber(latest.new_artist_count) + " were new.";
3394
- } else {
3395
- profileContinuitySummary.textContent =
3396
- "These artists show up across your years and are still there in the latest one.";
3397
- }
3398
- for (const relationship of relationships.slice(0, 3)) {
3399
- const item = document.createElement("li");
3400
- item.className = "continuity-relationship";
3401
- const name = document.createElement("strong");
3402
- name.textContent = relationship.name;
3403
- const years = document.createElement("span");
3404
- years.textContent = formatNumber(relationship.active_years) +
3405
- " years · " + relationship.first_year + "-" + relationship.last_year;
3406
- item.append(name, years);
3407
- profileContinuityRelationships.append(item);
3408
- }
3409
- profileContinuityLink.href = tasteprintUrl + "#continuity";
3410
- return true;
3411
- };
3412
-
3413
- const renderListeningPatterns = (patterns, tasteprintUrl) => {
3414
- const releases = Array.isArray(patterns && patterns.releases)
3415
- ? patterns.releases
3416
- : [];
3417
- const session = patterns && patterns.session;
3418
- const ready = patterns && patterns.ready === true &&
3419
- (releases.length > 0 || session) && tasteprintUrl;
3420
- profileListeningPatternReleases.replaceChildren();
3421
- profileListeningPatternsSummary.textContent = "";
3422
- profileListeningPatternsMetrics.hidden = true;
3423
- profileListeningPatternReleases.hidden = true;
3424
- profileListeningPatterns.hidden = !ready;
3425
- if (!ready) return false;
3426
-
3427
- const releaseCount = Number.isInteger(patterns.release_count)
3428
- ? patterns.release_count
3429
- : releases.length;
3430
- profileListeningPatternsCount.textContent = releaseCount > 0
3431
- ? formatNumber(releaseCount) + " deep records"
3432
- : formatNumber(session.session_count) + " stretches";
3433
- if (session) {
3434
- profileListeningPatternsSummary.textContent =
3435
- "A gap longer than " + formatNumber(session.gap_minutes) +
3436
- " minutes begins a new approximate stretch. The median contains " +
3437
- formatNumber(session.median_plays, 1) + " plays and " +
3438
- formatNumber(session.median_listening_minutes, 1) +
3439
- " listening minutes.";
3440
- profileSessionCount.textContent = formatCompactNumber(session.session_count);
3441
- profileSessionCount.title = formatNumber(session.session_count) + " listening stretches";
3442
- profileSessionMedian.textContent = formatNumber(session.median_plays, 1);
3443
- profileSessionExtended.textContent =
3444
- formatNumber(session.extended_sequence_percent, 1) + "%";
3445
- profileSessionExtendedLabel.textContent =
3446
- formatNumber(session.extended_sequence_minimum_plays) + "+ play stretches";
3447
- profileListeningPatternsMetrics.hidden = false;
3448
- } else {
3449
- profileListeningPatternsSummary.textContent =
3450
- "You played several tracks from each of these records.";
3451
- }
3452
-
3453
- for (const release of releases.slice(0, 3)) {
3454
- const item = document.createElement("li");
3455
- item.className = "listening-pattern-release";
3456
- const identity = document.createElement("div");
3457
- const title = document.createElement("strong");
3458
- title.textContent = release.title;
3459
- const artist = document.createElement("p");
3460
- artist.textContent = release.artist_credit;
3461
- identity.append(title, artist);
3462
- const depth = document.createElement("span");
3463
- depth.textContent = formatNumber(release.distinct_tracks) +
3464
- " tracks · " + formatNumber(release.active_years) +
3465
- (release.active_years === 1 ? " year" : " years");
3466
- item.append(identity, depth);
3467
- profileListeningPatternReleases.append(item);
3468
- }
3469
- profileListeningPatternReleases.hidden = releases.length === 0;
3470
- profileListeningPatternsLink.href = tasteprintUrl + "#listening-patterns";
3471
- return true;
3472
- };
3473
-
3474
- const applyProfile = (payload, message) => {
3475
- profileTools.hidden = payload.state !== "ready";
3476
- profileReady = payload && payload.state === "ready";
3477
- profileMode = !profileReady
3478
- ? "empty"
3479
- : payload.profile_kind === "synthetic_demo"
3480
- ? "demo"
3481
- : payload.profile_kind === "private_session"
3482
- ? "session"
3483
- : "private";
3484
- profileState.classList.toggle("is-ready", profileReady);
3485
- profileState.classList.toggle("is-demo", profileMode === "demo");
3486
- profileState.classList.remove("is-error");
3487
- if (!profileReady) {
3488
- result.classList.remove("is-visible");
3489
- heroDemoAction.hidden = false;
3490
- heroDemoLabel.textContent = "Try the real importer with fictional history";
3491
- profileStateTitle.textContent = "No private profile yet";
3492
- profileStateCopy.textContent = "Import Spotify or ListenBrainz history above, or run a generated fictional archive through the real importer.";
3493
- demoProfileBadge.hidden = true;
3494
- demoStartAction.hidden = false;
3495
- profileMetrics.hidden = true;
3496
- renderIdentityLinkCoverage(null);
3497
- renderTimeMachine(null, null);
3498
- renderListeningPulse(null, null);
3499
- renderListeningSeasons(null, null);
3500
- renderHistoricalReturns(null, null);
3501
- renderBackToBack(null, null);
3502
- renderContinuity(null, null);
3503
- renderListeningPatterns(null, null);
3504
- profileTasteprintLink.hidden = true;
3505
- profileCardLink.hidden = true;
3506
- profileCardDownloadLink.hidden = true;
3507
- renderCorrections([]);
3508
- if (message) setProfileStatus(message);
3509
- syncAvailability();
3510
- return;
3511
- }
3512
-
3513
- const coverage = payload.coverage || {};
3514
- const corrections = Array.isArray(payload.corrections) ? payload.corrections : [];
3515
- const demoMode = profileMode === "demo";
3516
- const sessionOnlyMode = profileMode === "session";
3517
- const realImporterDemo = demoMode &&
3518
- payload.demo_proof &&
3519
- payload.demo_proof.production_importer === true;
3520
- const timeMachineReady = renderTimeMachine(
3521
- payload.time_machine,
3522
- payload.tasteprint_url,
3523
- );
3524
- const listeningPulseReady = renderListeningPulse(
3525
- payload.listening_pulse,
3526
- payload.tasteprint_url,
3527
- );
3528
- const listeningSeasonsReady = renderListeningSeasons(
3529
- payload.listening_seasons,
3530
- payload.tasteprint_url,
3531
- );
3532
- const historicalReturnsReady = renderHistoricalReturns(
3533
- payload.historical_returns,
3534
- payload.tasteprint_url,
3535
- );
3536
- const backToBackReady = renderBackToBack(
3537
- payload.back_to_back,
3538
- payload.tasteprint_url,
3539
- );
3540
- const continuityReady = renderContinuity(
3541
- payload.continuity,
3542
- payload.tasteprint_url,
3543
- );
3544
- const listeningPatternsReady = renderListeningPatterns(
3545
- payload.listening_patterns,
3546
- payload.tasteprint_url,
3547
- );
3548
- heroDemoAction.hidden = !demoMode;
3549
- heroDemoLabel.textContent = timeMachineReady
3550
- ? "Real-import Time Machine ready - jump to it"
3551
- : "Fictional real-import profile ready";
3552
- profileStateTitle.textContent = demoMode
3553
- ? "Fictional Listening Time Machine ready"
3554
- : sessionOnlyMode
3555
- ? timeMachineReady
3556
- ? "Your session-only Listening Time Machine is ready"
3557
- : listeningSeasonsReady
3558
- ? "Your session-only Listening Seasons are ready"
3559
- : listeningPulseReady
3560
- ? "Your session-only Listening Pulse is ready"
3561
- : backToBackReady
3562
- ? "Your session-only back-to-back story is ready"
3563
- : "Your session-only Tasteprint is ready"
3564
- : timeMachineReady
3565
- ? "Your Listening Time Machine is ready"
3566
- : listeningSeasonsReady
3567
- ? "Your Listening Seasons are ready"
3568
- : listeningPulseReady
3569
- ? "Your Listening Pulse is ready"
3570
- : backToBackReady
3571
- ? "Your back-to-back story is ready"
3572
- : "Private profile ready to tune";
3573
- profileStateCopy.textContent = demoMode
3574
- ? realImporterDemo
3575
- ? "52 fictional plays passed through the production importer. No personal history or persistent profile was used."
3576
- : "Every name and number is synthetic. Corrections live only in memory and disappear when Studio stops."
3577
- : sessionOnlyMode
3578
- ? archiveCount === 1
3579
- ? "The supplied ZIP is unchanged. This profile and every correction disappear when Studio stops."
3580
- : "Both supplied ZIPs are unchanged. Their reconciled profile and every correction disappear when Studio stops."
3581
- : "Your choices shape the reading. Your listening history stays exactly as it was.";
3582
- demoProfileBadge.textContent = sessionOnlyMode
3583
- ? "Session-only private profile"
3584
- : realImporterDemo
3585
- ? "Fictional real-import demo"
3586
- : "Fictional demo";
3587
- demoProfileBadge.hidden = !demoMode && !sessionOnlyMode;
3588
- demoStartAction.hidden = true;
3589
- correctionNoteLabel.textContent = demoMode
3590
- ? "Demo note - optional"
3591
- : sessionOnlyMode
3592
- ? "Session note - optional"
3593
- : "Private note - optional";
3594
- correctionNoteHint.textContent = demoMode
3595
- ? "The note lives only in this Studio process and disappears when it stops."
3596
- : sessionOnlyMode
3597
- ? "The note can appear in this session's Tasteprint and disappears when Studio stops."
3598
- : "The note stays local and can appear in your Tasteprint. It is not sent as model profile context.";
3599
- profileEvents.textContent = formatCompactNumber(coverage.effective_listening_events);
3600
- profileEvents.title = formatNumber(coverage.effective_listening_events) + " effective listens";
3601
- profileHours.textContent = Number(coverage.listening_hours) >= 1_000
3602
- ? formatCompactNumber(coverage.listening_hours)
3603
- : formatNumber(coverage.listening_hours, 1);
3604
- profileHours.title = formatNumber(coverage.listening_hours, 1) + " listening hours";
3605
- profileCorrections.textContent = formatNumber(payload.active_corrections);
3606
- profileMetrics.hidden = false;
3607
- renderIdentityLinkCoverage(coverage);
3608
- if (payload.tasteprint_url) {
3609
- const hasCorrections = corrections.length > 0;
3610
- profileTasteprintLink.href = payload.tasteprint_url +
3611
- (hasCorrections
3612
- ? "#listener-corrections"
3613
- : timeMachineReady
3614
- ? "#time-machine"
3615
- : listeningSeasonsReady
3616
- ? "#listening-seasons"
3617
- : listeningPulseReady
3618
- ? "#listening-pulse"
3619
- : backToBackReady
3620
- ? "#back-to-back"
3621
- : historicalReturnsReady
3622
- ? "#tracks-that-stay"
3623
- : continuityReady
3624
- ? "#continuity"
3625
- : listeningPatternsReady
3626
- ? "#listening-patterns"
3627
- : "");
3628
- profileTasteprintLink.firstChild.textContent = hasCorrections
3629
- ? "Review corrections in Tasteprint "
3630
- : demoMode
3631
- ? "Open fictional Tasteprint "
3632
- : sessionOnlyMode
3633
- ? "Open session Tasteprint "
3634
- : "Open current Tasteprint ";
3635
- profileTasteprintLink.hidden = false;
3636
- resultLink.href = payload.tasteprint_url +
3637
- (timeMachineReady
3638
- ? "#time-machine"
3639
- : listeningSeasonsReady
3640
- ? "#listening-seasons"
3641
- : listeningPulseReady
3642
- ? "#listening-pulse"
3643
- : backToBackReady
3644
- ? "#back-to-back"
3645
- : historicalReturnsReady
3646
- ? "#tracks-that-stay"
3647
- : continuityReady
3648
- ? "#continuity"
3649
- : listeningPatternsReady
3650
- ? "#listening-patterns"
3651
- : "");
3652
- resultLink.firstChild.textContent = timeMachineReady
3653
- ? "Open Listening Time Machine "
3654
- : listeningSeasonsReady
3655
- ? "Open Listening Seasons "
3656
- : listeningPulseReady
3657
- ? "Open Listening Pulse "
3658
- : backToBackReady
3659
- ? "Open played back to back "
3660
- : historicalReturnsReady
3661
- ? "Open music that came back "
3662
- : continuityReady
3663
- ? "Open continuity map "
3664
- : listeningPatternsReady
3665
- ? "Open listening patterns "
3666
- : "Open full Tasteprint ";
3667
- }
3668
- if (payload.tasteprint_card_url) {
3669
- profileCardLink.href = payload.tasteprint_card_url;
3670
- profileCardLink.firstChild.textContent = demoMode
3671
- ? "Open fictional recap card "
3672
- : sessionOnlyMode
3673
- ? "Open session recap card "
3674
- : "Open recap card ";
3675
- profileCardLink.hidden = false;
3676
- profileCardDownloadLink.href = portableCardUrl(payload.tasteprint_card_url);
3677
- profileCardDownloadLink.firstChild.textContent = demoMode
3678
- ? "Download fictional HTML "
3679
- : sessionOnlyMode
3680
- ? "Download session HTML "
3681
- : "Download private HTML ";
3682
- profileCardDownloadLink.hidden = false;
3683
- resultCardLink.href = payload.tasteprint_card_url;
3684
- resultCardDownloadLink.href = portableCardUrl(payload.tasteprint_card_url);
3685
- } else {
3686
- profileCardLink.hidden = true;
3687
- profileCardDownloadLink.hidden = true;
3688
- }
3689
- renderCorrections(corrections);
3690
- if (message) setProfileStatus(message);
3691
- syncAvailability();
3692
- };
3693
-
3694
- const showProfileError = (message) => {
3695
- profileReady = false;
3696
- profileMode = "empty";
3697
- profileState.classList.remove("is-ready");
3698
- profileState.classList.remove("is-demo");
3699
- profileState.classList.add("is-error");
3700
- profileStateTitle.textContent = "Private profile unavailable";
3701
- profileStateCopy.textContent = message;
3702
- heroDemoAction.hidden = true;
3703
- demoProfileBadge.hidden = true;
3704
- renderTimeMachine(null, null);
3705
- renderListeningPulse(null, null);
3706
- renderListeningSeasons(null, null);
3707
- demoStartAction.hidden = true;
3708
- profileMetrics.hidden = true;
3709
- renderIdentityLinkCoverage(null);
3710
- profileTasteprintLink.hidden = true;
3711
- profileCardLink.hidden = true;
3712
- profileCardDownloadLink.hidden = true;
3713
- renderCorrections([]);
3714
- setProfileStatus(message, true);
3715
- syncAvailability();
3716
- };
3717
-
3718
- async function profileRequest(pathname, options = {}) {
3719
- const response = await fetch(pathname, {
3720
- ...options,
3721
- headers: {
3722
- "X-Moondog-Session": sessionToken,
3723
- ...(options.body ? { "Content-Type": "application/json" } : {}),
3724
- },
3725
- });
3726
- const payload = await response.json().catch(() => ({}));
3727
- if (!response.ok) {
3728
- throw new Error(payload.message || "The local Studio update did not complete.");
3729
- }
3730
- return payload;
3731
- }
3732
-
3733
- async function loadProfile({ preserveStatus = false } = {}) {
3734
- try {
3735
- const payload = await profileRequest("/api/profile");
3736
- applyProfile(
3737
- payload,
3738
- preserveStatus
3739
- ? undefined
3740
- : payload.state === "ready"
3741
- ? payload.profile_kind === "synthetic_demo"
3742
- ? "Real importer complete. Try correcting Mara Vale or Midnight Lines."
3743
- : payload.profile_kind === "private_session"
3744
- ? archiveCount === 1
3745
- ? "Session ready. The original ZIP is untouched; corrections disappear when Studio stops."
3746
- : "Session ready. Both original ZIPs are untouched; corrections disappear when Studio stops."
3747
- : "Ready. Add a direct signal or retract one below."
3748
- : "Import a Spotify ZIP, ListenBrainz JSON, or try the fictional real-import profile.",
3749
- );
3750
- return payload;
3751
- } catch (error) {
3752
- showProfileError(error.message);
3753
- throw error;
3754
- }
3755
- }
3756
-
3757
- async function retractCorrection(correction, button) {
3758
- if (profileBusy || busy) return;
3759
- profileBusy = true;
3760
- button.disabled = true;
3761
- correctionActionLabel.textContent = "Updating Tasteprint";
3762
- setProfileStatus("Retracting " + correction.label + "...");
3763
- syncAvailability();
3764
- try {
3765
- const payload = await profileRequest(
3766
- profileMode === "demo"
3767
- ? "/api/demo/corrections/retract"
3768
- : profileMode === "session"
3769
- ? "/api/session/corrections/retract"
3770
- : "/api/corrections/retract",
3771
- {
3772
- method: "POST",
3773
- body: JSON.stringify({ correction_id: correction.correction_id }),
3774
- },
3775
- );
3776
- applyProfile(payload, "Retracted. The listening record is unchanged.");
3777
- } catch (error) {
3778
- await loadProfile({ preserveStatus: true }).catch(() => {});
3779
- setProfileStatus(error.message, true);
3780
- } finally {
3781
- profileBusy = false;
3782
- correctionActionLabel.textContent = "Apply to Tasteprint";
3783
- syncAvailability();
3784
- if (!button.isConnected && profileReady) {
3785
- (correctionReturn.hidden ? correctionLabel : correctionReturn).focus({ preventScroll: true });
3786
- }
3787
- }
3788
- }
3789
-
3790
- const resetProgress = () => {
3791
- progress.classList.remove("is-visible");
3792
- progress.setAttribute("aria-hidden", "true");
3793
- progressBar.style.width = "0%";
3794
- };
3795
-
3796
- const chooseFile = (file) => {
3797
- result.classList.remove("is-visible");
3798
- resetProgress();
3799
- selectedFile = null;
3800
- selectedFileKind = null;
3801
- fileMeta.classList.remove("is-visible");
3802
- dropZone.classList.remove("is-ready");
3803
- dropTitle.textContent = "Drag Spotify ZIP or ListenBrainz JSON here";
3804
- dropCopy.textContent = "Account Data, Extended Streaming History, or saved ListenBrainz listens";
3805
- if (!file) {
3806
- syncAvailability();
3807
- return;
3808
- }
3809
- const lowerName = file.name.toLocaleLowerCase("en-US");
3810
- const fileKind = lowerName.endsWith(".zip")
3811
- ? "spotify"
3812
- : lowerName.endsWith(".json")
3813
- ? "listenbrainz"
3814
- : null;
3815
- if (!fileKind) {
3816
- setStatus("Choose a Spotify .zip or ListenBrainz .json history file.", true);
3817
- syncAvailability();
3818
- return;
3819
- }
3820
- const minimumBytes = fileKind === "spotify" ? 4 : 1;
3821
- const maximumFileBytes = fileKind === "spotify"
3822
- ? maximumBytes
3823
- : maximumJsonBytes;
3824
- if (file.size < minimumBytes || file.size > maximumFileBytes) {
3825
- setStatus(
3826
- fileKind === "spotify"
3827
- ? "That ZIP is outside the supported size range."
3828
- : "That ListenBrainz JSON is outside the supported size range.",
3829
- true,
3830
- );
3831
- syncAvailability();
3832
- return;
3833
- }
3834
- selectedFile = file;
3835
- selectedFileKind = fileKind;
3836
- fileName.textContent = file.name;
3837
- fileSize.textContent = formatBytes(file.size);
3838
- fileMeta.classList.add("is-visible");
3839
- dropZone.classList.add("is-ready");
3840
- dropTitle.textContent = "Ready to read locally";
3841
- dropCopy.textContent = "Click here if you want to choose a different history file";
3842
- setStatus(
3843
- fileKind === "spotify"
3844
- ? "Ready. The Spotify ZIP has not been imported yet."
3845
- : "Ready. The ListenBrainz JSON has not been imported yet.",
3846
- );
3847
- syncAvailability();
3848
- };
3849
-
3850
- input.addEventListener("change", () => chooseFile(input.files && input.files[0]));
3851
- const scrollToProfileOutcome = () => {
3852
- const target = !profileTimeMachine.hidden
3853
- ? profileTimeMachine
3854
- : !profileListeningPulse.hidden
3855
- ? profileListeningPulse
3856
- : document.querySelector("#profile-editor");
3857
- target.scrollIntoView({
3858
- behavior: "smooth",
3859
- block:
3860
- profileTimeMachine.hidden && profileListeningPulse.hidden
3861
- ? "start"
3862
- : "center",
3863
- });
3864
- };
3865
-
3866
- async function startDemoProfile() {
3867
- if (profileBusy || busy) return;
3868
- if (profileReady) {
3869
- scrollToProfileOutcome();
3870
- return;
3871
- }
3872
- profileBusy = true;
3873
- setProfileStatus("Running 52 fictional plays through the production importer...");
3874
- syncAvailability();
3875
- try {
3876
- const payload = await profileRequest("/api/demo/start", {
3877
- method: "POST",
3878
- body: "{}",
3879
- });
3880
- applyProfile(
3881
- payload,
3882
- "Real importer complete. Follow the fictional route, then try correcting Mara Vale or Midnight Lines.",
3883
- );
3884
- scrollToProfileOutcome();
3885
- } catch (error) {
3886
- await loadProfile({ preserveStatus: true }).catch(() => {});
3887
- setProfileStatus(error.message, true);
3888
- } finally {
3889
- profileBusy = false;
3890
- syncAvailability();
3891
- }
3892
- }
3893
- heroDemoAction.addEventListener("click", startDemoProfile);
3894
- demoStartAction.addEventListener("click", startDemoProfile);
3895
- for (const radio of correctionForm.elements.namedItem("entity-type")) {
3896
- radio.addEventListener("change", syncTargetFields);
3897
- }
3898
- correctionForm.addEventListener("submit", async (event) => {
3899
- event.preventDefault();
3900
- if (!profileReady || profileBusy || busy || !correctionForm.reportValidity()) return;
3901
- const entityType = selectedEntityType();
3902
- const stance = correctionForm.elements.namedItem("stance").value;
3903
- const inputValue = {
3904
- entity_type: entityType,
3905
- label: correctionLabel.value.trim(),
3906
- stance,
3907
- ...(entityType === "track"
3908
- ? { artist_credit: artistCredit.value.trim() }
3909
- : {}),
3910
- ...(correctionNote.value.trim()
3911
- ? { note: correctionNote.value.trim() }
3912
- : {}),
3913
- };
3914
- profileBusy = true;
3915
- correctionActionLabel.textContent = "Updating Tasteprint";
3916
- setProfileStatus("Applying your direct signal locally...");
3917
- syncAvailability();
3918
- try {
3919
- const payload = await profileRequest(
3920
- profileMode === "demo"
3921
- ? "/api/demo/corrections"
3922
- : profileMode === "session"
3923
- ? "/api/session/corrections"
3924
- : "/api/corrections",
3925
- {
3926
- method: "POST",
3927
- body: JSON.stringify(inputValue),
3928
- },
3929
- );
3930
- applyProfile(
3931
- payload,
3932
- "Applied. Review the direct signal in the updated Tasteprint.",
3933
- );
3934
- correctionLabel.value = "";
3935
- artistCredit.value = "";
3936
- correctionNote.value = "";
3937
- correctionLabel.focus();
3938
- } catch (error) {
3939
- await loadProfile({ preserveStatus: true }).catch(() => {});
3940
- setProfileStatus(error.message, true);
3941
- } finally {
3942
- profileBusy = false;
3943
- correctionActionLabel.textContent = "Apply to Tasteprint";
3944
- syncAvailability();
3945
- }
3946
- });
3947
- dropZone.addEventListener("keydown", (event) => {
3948
- if (event.key === "Enter" || event.key === " ") {
3949
- event.preventDefault();
3950
- input.click();
3951
- }
3952
- });
3953
-
3954
- for (const eventName of ["dragenter", "dragover"]) {
3955
- dropZone.addEventListener(eventName, (event) => {
3956
- event.preventDefault();
3957
- if (!busy) dropZone.classList.add("is-dragging");
3958
- });
3959
- }
3960
- for (const eventName of ["dragleave", "drop"]) {
3961
- dropZone.addEventListener(eventName, (event) => {
3962
- event.preventDefault();
3963
- dropZone.classList.remove("is-dragging");
3964
- });
3965
- }
3966
- dropZone.addEventListener("drop", (event) => {
3967
- if (!busy) chooseFile(event.dataTransfer && event.dataTransfer.files[0]);
3968
- });
3969
-
3970
- action.addEventListener("click", () => {
3971
- if (!selectedFile || !selectedFileKind || busy || profileBusy) return;
3972
- busy = true;
3973
- actionLabel.textContent = "Importing locally";
3974
- result.classList.remove("is-visible");
3975
- progress.classList.add("is-visible");
3976
- progress.setAttribute("aria-hidden", "false");
3977
- progressBar.style.width = "4%";
3978
- const sourceLabel = selectedFileKind === "spotify"
3979
- ? "Spotify ZIP"
3980
- : "ListenBrainz JSON";
3981
- setStatus("Sending the " + sourceLabel + " to the local Moondog process...");
3982
- syncAvailability();
3983
-
3984
- const request = new XMLHttpRequest();
3985
- request.open("POST", "/api/import");
3986
- request.responseType = "json";
3987
- request.setRequestHeader(
3988
- "Content-Type",
3989
- selectedFileKind === "spotify" ? "application/zip" : "application/json",
3990
- );
3991
- request.setRequestHeader("X-Moondog-Session", sessionToken);
3992
- request.upload.addEventListener("progress", (event) => {
3993
- if (!event.lengthComputable) return;
3994
- const percent = Math.min(82, Math.max(4, Math.round((event.loaded / event.total) * 82)));
3995
- progressBar.style.width = percent + "%";
3996
- if (event.loaded === event.total) {
3997
- setStatus("Reading your history and building the cumulative profile...");
3998
- }
3999
- });
4000
- request.addEventListener("load", () => {
4001
- const payload = request.response || {};
4002
- if (request.status < 200 || request.status >= 300) {
4003
- setStatus(payload.message || "Moondog could not import that history file.", true);
4004
- progressBar.style.width = "0%";
4005
- return;
4006
- }
4007
- progressBar.style.width = "100%";
4008
- document.querySelector("#events").textContent = formatNumber(payload.coverage.effective_listening_events);
4009
- document.querySelector("#hours").textContent = formatNumber(payload.coverage.listening_hours, 1);
4010
- document.querySelector("#tracks").textContent = formatNumber(payload.coverage.distinct_tracks);
4011
- document.querySelector("#result-kicker").textContent = payload.source.already_imported
4012
- ? "Already part of your private profile"
4013
- : "Private profile updated";
4014
- const importedTimeMachine = payload.time_machine && payload.time_machine.ready === true;
4015
- const importedPulse = payload.listening_pulse && payload.listening_pulse.ready === true;
4016
- const importedSeasons = payload.listening_seasons && payload.listening_seasons.ready === true;
4017
- const importedBackToBack = payload.back_to_back && payload.back_to_back.ready === true;
4018
- const importedHistoricalReturns = payload.historical_returns && payload.historical_returns.ready === true;
4019
- const importedContinuity = payload.continuity && payload.continuity.ready === true;
4020
- const importedPatterns = payload.listening_patterns && payload.listening_patterns.ready === true;
4021
- document.querySelector("#result-title").textContent = importedTimeMachine
4022
- ? "Your Listening Time Machine is ready."
4023
- : importedSeasons
4024
- ? "Your Listening Seasons are ready."
4025
- : importedPulse
4026
- ? "Your Listening Pulse is ready."
4027
- : importedBackToBack
4028
- ? "Your back-to-back story is ready."
4029
- : importedHistoricalReturns
4030
- ? "Your return story is ready."
4031
- : importedContinuity
4032
- ? "Your continuity map is ready."
4033
- : importedPatterns
4034
- ? "Your listening patterns are ready."
4035
- : "This is your listening arc.";
4036
- const importSummary = payload.source.already_imported
4037
- ? "Moondog recognized this history file and kept the cumulative profile unchanged."
4038
- : formatNumber(payload.source.effective_event_delta) + " new effective listens were folded into your local music identity.";
4039
- const reconciliationSummary = !payload.source.already_imported && payload.source.superseded_events > 0
4040
- ? " " + formatNumber(payload.source.superseded_events) + " overlapping standard records were reconciled to richer Extended History evidence instead of counted twice."
4041
- : "";
4042
- const identityLinkSummary = payload.coverage.cross_format_track_links > 0
4043
- ? " " + formatNumber(payload.coverage.cross_format_track_links) +
4044
- " provisional track identities now aggregate with resolved Spotify identities through exact overlap evidence."
4045
- : "";
4046
- const historicalReturnSummary = importedHistoricalReturns
4047
- ? " " + formatNumber(payload.historical_returns.return_track_count) +
4048
- (payload.historical_returns.return_track_count === 1
4049
- ? " track reappeared"
4050
- : " tracks reappeared") +
4051
- " after a long gap, without treating the pattern as nostalgia or current preference."
4052
- : "";
4053
- const backToBackSummary = importedBackToBack
4054
- ? " " + formatNumber(payload.back_to_back.track_count) +
4055
- (payload.back_to_back.track_count === 1
4056
- ? " track appears"
4057
- : " tracks appear") +
4058
- " in bounded adjacent playback sequences, without treating them as proof of repeat mode, intention, or liking."
4059
- : "";
4060
- const pulseSummary = importedPulse
4061
- ? " " + formatNumber(payload.listening_pulse.active_month_count) +
4062
- " retained UTC months contain eligible activity in the bounded Listening Pulse."
4063
- : "";
4064
- const seasonSummary = importedSeasons
4065
- ? " " + formatNumber(payload.listening_seasons.active_season_count) +
4066
- " fixed UTC calendar quarters form the bounded Listening Seasons view."
4067
- : "";
4068
- const patternSummary = importedPatterns
4069
- ? " " + [
4070
- payload.listening_patterns.session
4071
- ? formatNumber(payload.listening_patterns.session.session_count) + " approximate listening stretches"
4072
- : "",
4073
- payload.listening_patterns.release_count > 0
4074
- ? formatNumber(payload.listening_patterns.release_count) + " multi-track records"
4075
- : "",
4076
- ].filter(Boolean).join(" and ") + " are ready for review."
4077
- : "";
4078
- document.querySelector("#result-copy").textContent = importedTimeMachine
4079
- ? formatNumber(payload.time_machine.landmark_count) + " chronological landmarks are ready across your retained listening years. " + importSummary + reconciliationSummary + identityLinkSummary + pulseSummary + seasonSummary + backToBackSummary + historicalReturnSummary + patternSummary
4080
- : importedSeasons
4081
- ? formatNumber(payload.listening_seasons.active_season_count) + " fixed UTC calendar quarters form your bounded Listening Seasons view. " + importSummary + reconciliationSummary + identityLinkSummary + pulseSummary + backToBackSummary + historicalReturnSummary + patternSummary
4082
- : importedPulse
4083
- ? formatNumber(payload.listening_pulse.active_month_count) + " retained UTC months form your bounded Listening Pulse. " + importSummary + reconciliationSummary + identityLinkSummary + seasonSummary + backToBackSummary + historicalReturnSummary + patternSummary
4084
- : importedBackToBack
4085
- ? formatNumber(payload.back_to_back.track_count) + " tracks appear in bounded adjacent playback sequences. " + importSummary + reconciliationSummary + identityLinkSummary + pulseSummary + seasonSummary + historicalReturnSummary + patternSummary
4086
- : importedHistoricalReturns
4087
- ? formatNumber(payload.historical_returns.return_track_count) + " tracks reappeared after long gaps in retained history. " + importSummary + reconciliationSummary + identityLinkSummary + pulseSummary + seasonSummary + backToBackSummary + patternSummary
4088
- : importedContinuity
4089
- ? formatNumber(payload.continuity.relationship_count) + " artists remain present across retained years, alongside year-to-year top-artist turnover. " + importSummary + reconciliationSummary + identityLinkSummary + pulseSummary + seasonSummary + backToBackSummary + historicalReturnSummary + patternSummary
4090
- : importSummary + reconciliationSummary + identityLinkSummary + pulseSummary + seasonSummary + backToBackSummary + historicalReturnSummary + patternSummary;
4091
- resultLink.href = payload.tasteprint_url +
4092
- (importedTimeMachine
4093
- ? "#time-machine"
4094
- : importedSeasons
4095
- ? "#listening-seasons"
4096
- : importedPulse
4097
- ? "#listening-pulse"
4098
- : importedBackToBack
4099
- ? "#back-to-back"
4100
- : importedHistoricalReturns
4101
- ? "#tracks-that-stay"
4102
- : importedContinuity
4103
- ? "#continuity"
4104
- : importedPatterns
4105
- ? "#listening-patterns"
4106
- : "");
4107
- resultLink.firstChild.textContent = importedTimeMachine
4108
- ? "Open Listening Time Machine "
4109
- : importedSeasons
4110
- ? "Open Listening Seasons "
4111
- : importedPulse
4112
- ? "Open Listening Pulse "
4113
- : importedBackToBack
4114
- ? "Open played back to back "
4115
- : importedHistoricalReturns
4116
- ? "Open music that came back "
4117
- : importedContinuity
4118
- ? "Open continuity map "
4119
- : importedPatterns
4120
- ? "Open listening patterns "
4121
- : "Open full Tasteprint ";
4122
- resultCardLink.href = payload.tasteprint_card_url;
4123
- resultCardDownloadLink.href = portableCardUrl(payload.tasteprint_card_url);
4124
- result.classList.add("is-visible");
4125
- applyProfile(payload, "Profile ready. Add a direct signal or retract one below.");
4126
- setStatus("Done. The temporary source copy has been removed.");
4127
- result.scrollIntoView({ behavior: "smooth", block: "nearest" });
4128
- });
4129
- request.addEventListener("error", () => {
4130
- setStatus("The local Studio connection was interrupted. You can try again.", true);
4131
- progressBar.style.width = "0%";
4132
- });
4133
- request.addEventListener("loadend", () => {
4134
- busy = false;
4135
- actionLabel.textContent = "Import and build Tasteprint";
4136
- syncAvailability();
4137
- });
4138
- request.send(selectedFile);
4139
- });
4140
-
4141
- syncTargetFields();
4142
- syncAvailability();
4143
- loadProfile().catch(() => {});
4144
- })();
4145
- </script>
4146
- </body>
4147
- </html>`;
4148
- }