@mulmoclaude/spotify-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ import { FileOps, PluginRuntime } from 'gui-chat-protocol';
2
+ import { SpotifyClientError } from './client';
3
+ import { SpotifyProfile, SpotifyTokens } from './types';
4
+ export interface ProfileDeps {
5
+ runtime: PluginRuntime;
6
+ clientId: string;
7
+ tokens: SpotifyTokens;
8
+ now?: () => Date;
9
+ }
10
+ export declare function readProfile(files: FileOps): Promise<SpotifyProfile | null>;
11
+ export declare function writeProfile(files: FileOps, profile: SpotifyProfile): Promise<void>;
12
+ /** Get the cached profile if fresh; otherwise fetch + persist a
13
+ * new snapshot. On API failure with a stale cache we keep the
14
+ * stale value (better than locking the user out — a network blip
15
+ * shouldn't break playback).
16
+ *
17
+ * Account scoping: cache is invalidated by `clearProfileCache`
18
+ * whenever new tokens are written (i.e. after `oauthCallback`),
19
+ * so reconnecting with a different Spotify account starts with a
20
+ * fresh fetch and never serves the previous account's `product`
21
+ * (Codex review on PR #1171). */
22
+ export declare function getProfile(deps: ProfileDeps): Promise<{
23
+ ok: true;
24
+ profile: SpotifyProfile;
25
+ } | {
26
+ ok: false;
27
+ error: SpotifyClientError;
28
+ }>;
29
+ export declare function isPremium(profile: SpotifyProfile): boolean;
30
+ /** Test-only: clear the cache. Production callers should not need
31
+ * this — the TTL handles it. */
32
+ export declare function clearProfileCache(files: FileOps): Promise<void>;
@@ -0,0 +1,135 @@
1
+ import { z } from 'zod';
2
+ /** Single source of truth for `manageSpotify`'s `kind` discriminator.
3
+ * `definition.ts` derives the LLM-facing enum from `LLM_CALLABLE_KINDS`,
4
+ * the Zod union below uses these same literals — the previous setup
5
+ * duplicated the strings across both surfaces and risked drift
6
+ * (CodeRabbit review on PR #1166). */
7
+ export declare const SPOTIFY_KINDS: {
8
+ readonly connect: "connect";
9
+ readonly oauthCallback: "oauthCallback";
10
+ readonly status: "status";
11
+ readonly diagnose: "diagnose";
12
+ readonly configure: "configure";
13
+ readonly liked: "liked";
14
+ readonly playlists: "playlists";
15
+ readonly playlistTracks: "playlistTracks";
16
+ readonly recent: "recent";
17
+ readonly nowPlaying: "nowPlaying";
18
+ readonly search: "search";
19
+ readonly play: "play";
20
+ readonly pause: "pause";
21
+ readonly next: "next";
22
+ readonly previous: "previous";
23
+ readonly seek: "seek";
24
+ readonly setVolume: "setVolume";
25
+ readonly transferPlayback: "transferPlayback";
26
+ readonly getDevices: "getDevices";
27
+ };
28
+ /** Categories the `search` kind may include. Spotify's `/v1/search`
29
+ * accepts these four; centralising the list lets `definition.ts`
30
+ * reuse the same source for the JSON-schema enum (no drift between
31
+ * Zod and the LLM-facing schema). */
32
+ export declare const SEARCH_TYPES: readonly ["track", "artist", "album", "playlist"];
33
+ /** Kinds the LLM is allowed to invoke directly (= advertised in
34
+ * `TOOL_DEFINITION.parameters.kind.enum`). `configure` is omitted
35
+ * intentionally — it's a View-only action that writes the user's
36
+ * Client ID; exposing it to the LLM would invite the model to
37
+ * mutate user secrets. */
38
+ export declare const LLM_CALLABLE_KINDS: readonly ["connect", "oauthCallback", "status", "diagnose", "liked", "playlists", "playlistTracks", "recent", "nowPlaying", "search", "play", "pause", "next", "previous", "seek", "setVolume", "transferPlayback", "getDevices"];
39
+ /** Persisted at `runtime.files.config/tokens.json`. Per-machine
40
+ * secret — not synced via mulmoclaude's backup story. */
41
+ export declare const TokensSchema: z.ZodObject<{
42
+ accessToken: z.ZodString;
43
+ refreshToken: z.ZodString;
44
+ expiresAt: z.ZodString;
45
+ scopes: z.ZodArray<z.ZodString>;
46
+ }, z.core.$strip>;
47
+ /** Persisted at `runtime.files.config/client.json`. The user
48
+ * registers their own Spotify Developer Dashboard app and writes
49
+ * the Client ID here (PKCE flow doesn't need a secret). */
50
+ export declare const ClientConfigSchema: z.ZodObject<{
51
+ clientId: z.ZodString;
52
+ }, z.core.$strip>;
53
+ /** In-memory record kept between `connect` and `oauthCallback`. */
54
+ export declare const PendingAuthSchema: z.ZodObject<{
55
+ codeVerifier: z.ZodString;
56
+ redirectUri: z.ZodString;
57
+ createdAtMs: z.ZodNumber;
58
+ }, z.core.$strip>;
59
+ /** Dispatch argument shape — discriminated by `kind`. PR 1 covered
60
+ * only the OAuth-flavored kinds; PR 2 adds the listening-data
61
+ * kinds plus a View-only `configure` action.
62
+ *
63
+ * `configure` is excluded from `TOOL_DEFINITION.parameters.kind`
64
+ * enum because it's intended for the View's "Configure" form, not
65
+ * for the LLM. It still rides the same dispatch surface so the
66
+ * View doesn't need a separate endpoint. */
67
+ export declare const DispatchArgsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
68
+ kind: z.ZodLiteral<"connect">;
69
+ redirectUri: z.ZodString;
70
+ }, z.core.$strip>, z.ZodObject<{
71
+ kind: z.ZodLiteral<"oauthCallback">;
72
+ code: z.ZodOptional<z.ZodString>;
73
+ state: z.ZodOptional<z.ZodString>;
74
+ error: z.ZodOptional<z.ZodString>;
75
+ }, z.core.$strip>, z.ZodObject<{
76
+ kind: z.ZodLiteral<"status">;
77
+ }, z.core.$strip>, z.ZodObject<{
78
+ kind: z.ZodLiteral<"diagnose">;
79
+ }, z.core.$strip>, z.ZodObject<{
80
+ kind: z.ZodLiteral<"configure">;
81
+ clientId: z.ZodString;
82
+ }, z.core.$strip>, z.ZodObject<{
83
+ kind: z.ZodLiteral<"liked">;
84
+ limit: z.ZodOptional<z.ZodNumber>;
85
+ }, z.core.$strip>, z.ZodObject<{
86
+ kind: z.ZodLiteral<"playlists">;
87
+ }, z.core.$strip>, z.ZodObject<{
88
+ kind: z.ZodLiteral<"playlistTracks">;
89
+ playlistId: z.ZodString;
90
+ limit: z.ZodOptional<z.ZodNumber>;
91
+ }, z.core.$strip>, z.ZodObject<{
92
+ kind: z.ZodLiteral<"recent">;
93
+ limit: z.ZodOptional<z.ZodNumber>;
94
+ }, z.core.$strip>, z.ZodObject<{
95
+ kind: z.ZodLiteral<"nowPlaying">;
96
+ }, z.core.$strip>, z.ZodObject<{
97
+ kind: z.ZodLiteral<"search">;
98
+ query: z.ZodString;
99
+ types: z.ZodOptional<z.ZodArray<z.ZodEnum<{
100
+ track: "track";
101
+ artist: "artist";
102
+ album: "album";
103
+ playlist: "playlist";
104
+ }>>>;
105
+ limit: z.ZodOptional<z.ZodNumber>;
106
+ }, z.core.$strip>, z.ZodObject<{
107
+ kind: z.ZodLiteral<"play">;
108
+ deviceId: z.ZodOptional<z.ZodString>;
109
+ contextUri: z.ZodOptional<z.ZodString>;
110
+ trackUris: z.ZodOptional<z.ZodArray<z.ZodString>>;
111
+ }, z.core.$strip>, z.ZodObject<{
112
+ kind: z.ZodLiteral<"pause">;
113
+ deviceId: z.ZodOptional<z.ZodString>;
114
+ }, z.core.$strip>, z.ZodObject<{
115
+ kind: z.ZodLiteral<"next">;
116
+ deviceId: z.ZodOptional<z.ZodString>;
117
+ }, z.core.$strip>, z.ZodObject<{
118
+ kind: z.ZodLiteral<"previous">;
119
+ deviceId: z.ZodOptional<z.ZodString>;
120
+ }, z.core.$strip>, z.ZodObject<{
121
+ kind: z.ZodLiteral<"seek">;
122
+ positionMs: z.ZodNumber;
123
+ deviceId: z.ZodOptional<z.ZodString>;
124
+ }, z.core.$strip>, z.ZodObject<{
125
+ kind: z.ZodLiteral<"setVolume">;
126
+ volumePercent: z.ZodNumber;
127
+ deviceId: z.ZodOptional<z.ZodString>;
128
+ }, z.core.$strip>, z.ZodObject<{
129
+ kind: z.ZodLiteral<"transferPlayback">;
130
+ deviceId: z.ZodString;
131
+ play: z.ZodOptional<z.ZodBoolean>;
132
+ }, z.core.$strip>, z.ZodObject<{
133
+ kind: z.ZodLiteral<"getDevices">;
134
+ }, z.core.$strip>], "kind">;
135
+ export type DispatchArgs = z.infer<typeof DispatchArgsSchema>;
@@ -0,0 +1,19 @@
1
+ import { PluginRuntime } from 'gui-chat-protocol';
2
+ import { SpotifyClientError } from './client';
3
+ import { SearchResult, SpotifyTokens } from './types';
4
+ export type SearchType = "track" | "artist" | "album" | "playlist";
5
+ export interface SearchDeps {
6
+ runtime: PluginRuntime;
7
+ clientId: string;
8
+ tokens: SpotifyTokens;
9
+ now?: () => Date;
10
+ }
11
+ type Result<T> = {
12
+ ok: true;
13
+ data: T;
14
+ } | {
15
+ ok: false;
16
+ error: SpotifyClientError;
17
+ };
18
+ export declare function searchSpotify(deps: SearchDeps, query: string, types: readonly SearchType[] | undefined, limit: number | undefined): Promise<Result<SearchResult>>;
19
+ export {};
@@ -0,0 +1,20 @@
1
+ import { NormalisedAlbum, NormalisedArtist, NormalisedPlaylist, NormalisedTrack, SearchResult } from './types';
2
+ /** Build the LLM-facing message string for a search result. The
3
+ * plain text mirrors the View's grouped sections, one entity per
4
+ * line.
5
+ *
6
+ * `query` is user-influenced on the tool path — both the LLM and
7
+ * a manual View submission can put arbitrary strings in there.
8
+ * Embedding it raw lets a hostile query smuggle line breaks and
9
+ * control characters into the LLM's context window (a
10
+ * prompt-injection vector via tool output: `query: "x\n\nIgnore
11
+ * all previous instructions and …"`). Strip control chars and
12
+ * bound the length before interpolating (Codex review on PR
13
+ * #1168). */
14
+ export declare function summariseSearch(query: string, result: SearchResult): string;
15
+ export declare function sanitiseQueryForSummary(query: string): string;
16
+ export declare function formatSearchSection<T>(label: string, items: T[], formatter: (item: T, idx: number) => string): string;
17
+ export declare function formatTrackLine(track: NormalisedTrack, idx: number): string;
18
+ export declare function formatArtistLine(artist: NormalisedArtist, idx: number): string;
19
+ export declare function formatAlbumLine(album: NormalisedAlbum, idx: number): string;
20
+ export declare function formatPlaylistLine(playlist: NormalisedPlaylist, idx: number): string;
package/dist/style.css ADDED
@@ -0,0 +1,367 @@
1
+
2
+ .spotify-view[data-v-57a122f2] {
3
+ /* `h-full + flex column` so the content area can take the
4
+ * remaining vertical space and scroll, instead of overflowing
5
+ * into the host's chrome below. Same shape as todo-plugin's
6
+ * View. */
7
+ height: 100%;
8
+ display: flex;
9
+ flex-direction: column;
10
+ padding: 1rem;
11
+ font-family:
12
+ system-ui,
13
+ -apple-system,
14
+ sans-serif;
15
+ /* Critical: a flex child whose intrinsic content is taller than
16
+ * its allotted space won't shrink unless `min-height: 0`. Without
17
+ * this the scrollable content area's `overflow: auto` is ignored
18
+ * and the parent grows past the canvas. */
19
+ min-height: 0;
20
+ }
21
+ .spotify-connected[data-v-57a122f2] {
22
+ flex: 1;
23
+ display: flex;
24
+ flex-direction: column;
25
+ min-height: 0;
26
+ }
27
+ .spotify-content[data-v-57a122f2] {
28
+ flex: 1;
29
+ overflow-y: auto;
30
+ min-height: 0;
31
+ }
32
+ .spotify-header h2[data-v-57a122f2] {
33
+ font-size: 1.25rem;
34
+ font-weight: 600;
35
+ margin: 0 0 0.5rem;
36
+ }
37
+ .spotify-status[data-v-57a122f2] {
38
+ display: flex;
39
+ align-items: center;
40
+ gap: 0.5rem;
41
+ font-size: 0.875rem;
42
+ color: #6b7280;
43
+ margin-bottom: 1rem;
44
+ }
45
+ .spotify-connected-pill[data-v-57a122f2] {
46
+ background: #1ed760;
47
+ color: white;
48
+ padding: 0.125rem 0.5rem;
49
+ border-radius: 9999px;
50
+ font-size: 0.75rem;
51
+ font-weight: 500;
52
+ }
53
+ .spotify-reconnect[data-v-57a122f2] {
54
+ margin-left: auto;
55
+ background: none;
56
+ border: 1px solid #d1d5db;
57
+ border-radius: 0.25rem;
58
+ padding: 0.125rem 0.5rem;
59
+ cursor: pointer;
60
+ font-size: 0.75rem;
61
+ color: #374151;
62
+ }
63
+ .spotify-reconnect[data-v-57a122f2]:hover:not(:disabled) {
64
+ background: #f3f4f6;
65
+ }
66
+ .spotify-reconnect[data-v-57a122f2]:disabled {
67
+ opacity: 0.6;
68
+ cursor: not-allowed;
69
+ }
70
+ .spotify-expiry[data-v-57a122f2] {
71
+ font-size: 0.75rem;
72
+ color: #9ca3af;
73
+ }
74
+ .spotify-configure[data-v-57a122f2] {
75
+ border: 1px solid #e5e7eb;
76
+ border-radius: 0.5rem;
77
+ padding: 1rem;
78
+ margin-bottom: 1rem;
79
+ background: #fafafa;
80
+ }
81
+ .spotify-configure-help[data-v-57a122f2] {
82
+ margin: 0 0 0.75rem;
83
+ font-size: 0.875rem;
84
+ }
85
+ .spotify-configure-form[data-v-57a122f2] {
86
+ display: flex;
87
+ gap: 0.5rem;
88
+ }
89
+ .spotify-input[data-v-57a122f2] {
90
+ flex: 1;
91
+ padding: 0.5rem;
92
+ border: 1px solid #d1d5db;
93
+ border-radius: 0.375rem;
94
+ font-family: inherit;
95
+ }
96
+ .spotify-btn-primary[data-v-57a122f2] {
97
+ background: #1ed760;
98
+ color: white;
99
+ border: none;
100
+ padding: 0.5rem 1rem;
101
+ border-radius: 0.375rem;
102
+ font-weight: 500;
103
+ cursor: pointer;
104
+ }
105
+ .spotify-btn-primary[data-v-57a122f2]:disabled {
106
+ background: #d1d5db;
107
+ cursor: not-allowed;
108
+ }
109
+ .spotify-connect-section[data-v-57a122f2] {
110
+ display: flex;
111
+ justify-content: center;
112
+ padding: 2rem;
113
+ }
114
+ .spotify-tab-row[data-v-57a122f2] {
115
+ display: flex;
116
+ align-items: stretch;
117
+ border-bottom: 1px solid #e5e7eb;
118
+ margin-bottom: 1rem;
119
+ }
120
+ .spotify-tabs[data-v-57a122f2] {
121
+ display: flex;
122
+ gap: 0.25rem;
123
+ flex: 1;
124
+ }
125
+ .spotify-tab[data-v-57a122f2] {
126
+ background: none;
127
+ border: none;
128
+ padding: 0.5rem 1rem;
129
+ cursor: pointer;
130
+ font-family: inherit;
131
+ font-size: 0.875rem;
132
+ color: #6b7280;
133
+ border-bottom: 2px solid transparent;
134
+ }
135
+ .spotify-tab-active[data-v-57a122f2] {
136
+ color: #1ed760;
137
+ border-bottom-color: #1ed760;
138
+ font-weight: 500;
139
+ }
140
+ .spotify-refresh[data-v-57a122f2] {
141
+ background: none;
142
+ border: none;
143
+ color: #6b7280;
144
+ cursor: pointer;
145
+ font-size: 0.75rem;
146
+ padding: 0 0.5rem;
147
+ }
148
+ .spotify-list[data-v-57a122f2] {
149
+ list-style: none;
150
+ padding: 0;
151
+ margin: 0;
152
+ display: flex;
153
+ flex-direction: column;
154
+ gap: 0.25rem;
155
+ }
156
+ .spotify-track-row[data-v-57a122f2],
157
+ .spotify-playlist-row[data-v-57a122f2] {
158
+ border-radius: 0.375rem;
159
+ }
160
+ .spotify-track-row[data-v-57a122f2]:hover,
161
+ .spotify-playlist-row[data-v-57a122f2]:hover {
162
+ background: #f5f5f5;
163
+ }
164
+ .spotify-track-link[data-v-57a122f2] {
165
+ width: 100%;
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 0.75rem;
169
+ padding: 0.5rem;
170
+ background: none;
171
+ border: none;
172
+ cursor: pointer;
173
+ text-align: left;
174
+ font-family: inherit;
175
+ }
176
+ .spotify-cover[data-v-57a122f2] {
177
+ width: 2.5rem;
178
+ height: 2.5rem;
179
+ border-radius: 0.25rem;
180
+ object-fit: cover;
181
+ flex-shrink: 0;
182
+ }
183
+ .spotify-track-meta[data-v-57a122f2] {
184
+ flex: 1;
185
+ display: flex;
186
+ flex-direction: column;
187
+ gap: 0.125rem;
188
+ min-width: 0;
189
+ }
190
+ .spotify-track-name[data-v-57a122f2] {
191
+ font-weight: 500;
192
+ font-size: 0.875rem;
193
+ white-space: nowrap;
194
+ overflow: hidden;
195
+ text-overflow: ellipsis;
196
+ }
197
+ .spotify-track-artists[data-v-57a122f2] {
198
+ font-size: 0.75rem;
199
+ color: #6b7280;
200
+ white-space: nowrap;
201
+ overflow: hidden;
202
+ text-overflow: ellipsis;
203
+ }
204
+ .spotify-track-album[data-v-57a122f2] {
205
+ font-size: 0.75rem;
206
+ color: #9ca3af;
207
+ }
208
+ .spotify-track-duration[data-v-57a122f2] {
209
+ font-size: 0.75rem;
210
+ color: #9ca3af;
211
+ font-variant-numeric: tabular-nums;
212
+ }
213
+ .spotify-now-playing[data-v-57a122f2] {
214
+ border: 1px solid #e5e7eb;
215
+ border-radius: 0.5rem;
216
+ padding: 1rem;
217
+ }
218
+ .spotify-search-form[data-v-57a122f2] {
219
+ display: flex;
220
+ gap: 0.5rem;
221
+ margin-bottom: 1rem;
222
+ }
223
+ .spotify-search-results[data-v-57a122f2] {
224
+ display: flex;
225
+ flex-direction: column;
226
+ gap: 1rem;
227
+ }
228
+ .spotify-search-section h3[data-v-57a122f2] {
229
+ font-size: 0.875rem;
230
+ font-weight: 600;
231
+ color: #6b7280;
232
+ margin: 0 0 0.25rem;
233
+ padding: 0;
234
+ text-transform: uppercase;
235
+ letter-spacing: 0.05em;
236
+ }
237
+ .spotify-player[data-v-57a122f2],
238
+ .spotify-player-locked[data-v-57a122f2],
239
+ .spotify-devices[data-v-57a122f2] {
240
+ margin-top: 1rem;
241
+ padding: 0.75rem 1rem;
242
+ border: 1px solid #e5e7eb;
243
+ border-radius: 0.5rem;
244
+ }
245
+ .spotify-player-locked[data-v-57a122f2] {
246
+ background: #fafafa;
247
+ color: #6b7280;
248
+ }
249
+ .spotify-player h3[data-v-57a122f2],
250
+ .spotify-player-locked h3[data-v-57a122f2],
251
+ .spotify-devices h3[data-v-57a122f2] {
252
+ font-size: 0.875rem;
253
+ font-weight: 600;
254
+ margin: 0 0 0.5rem;
255
+ text-transform: uppercase;
256
+ letter-spacing: 0.05em;
257
+ color: #6b7280;
258
+ }
259
+ .spotify-player-buttons[data-v-57a122f2] {
260
+ display: flex;
261
+ gap: 0.5rem;
262
+ margin-bottom: 0.75rem;
263
+ }
264
+ .spotify-player-btn[data-v-57a122f2] {
265
+ flex: 1;
266
+ padding: 0.5rem;
267
+ background: #1ed760;
268
+ color: white;
269
+ border: none;
270
+ border-radius: 0.375rem;
271
+ font-size: 1rem;
272
+ cursor: pointer;
273
+ }
274
+ .spotify-player-btn[data-v-57a122f2]:disabled {
275
+ background: #d1d5db;
276
+ cursor: not-allowed;
277
+ }
278
+ .spotify-player-volume[data-v-57a122f2] {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 0.5rem;
282
+ font-size: 0.875rem;
283
+ }
284
+ .spotify-player-volume input[type="range"][data-v-57a122f2] {
285
+ flex: 1;
286
+ }
287
+ .spotify-device-row[data-v-57a122f2] {
288
+ display: flex;
289
+ align-items: center;
290
+ gap: 0.5rem;
291
+ padding: 0.375rem 0.5rem;
292
+ font-size: 0.875rem;
293
+ }
294
+ .spotify-device-name[data-v-57a122f2] {
295
+ flex: 1;
296
+ font-weight: 500;
297
+ }
298
+ .spotify-device-type[data-v-57a122f2] {
299
+ color: #6b7280;
300
+ font-size: 0.75rem;
301
+ }
302
+ .spotify-device-active[data-v-57a122f2] {
303
+ background: #1ed760;
304
+ color: white;
305
+ padding: 0 0.5rem;
306
+ border-radius: 9999px;
307
+ font-size: 0.75rem;
308
+ }
309
+ .spotify-device-transfer[data-v-57a122f2] {
310
+ background: none;
311
+ border: 1px solid #d1d5db;
312
+ border-radius: 0.25rem;
313
+ padding: 0.125rem 0.5rem;
314
+ cursor: pointer;
315
+ font-size: 0.75rem;
316
+ }
317
+ .spotify-now-cover[data-v-57a122f2] {
318
+ width: 4rem;
319
+ height: 4rem;
320
+ border-radius: 0.375rem;
321
+ }
322
+ .spotify-empty[data-v-57a122f2],
323
+ .spotify-loading[data-v-57a122f2] {
324
+ color: #6b7280;
325
+ font-size: 0.875rem;
326
+ text-align: center;
327
+ padding: 2rem;
328
+ }
329
+ .spotify-error[data-v-57a122f2] {
330
+ color: #dc2626;
331
+ font-size: 0.875rem;
332
+ padding: 0.5rem;
333
+ background: #fef2f2;
334
+ border-radius: 0.375rem;
335
+ }
336
+ .spotify-retry[data-v-57a122f2] {
337
+ background: none;
338
+ border: 1px solid currentColor;
339
+ border-radius: 0.25rem;
340
+ padding: 0.125rem 0.5rem;
341
+ margin-left: 0.5rem;
342
+ cursor: pointer;
343
+ color: inherit;
344
+ font-size: inherit;
345
+ }
346
+
347
+ .spotify-preview[data-v-53e3c895] {
348
+ display: inline-flex;
349
+ align-items: center;
350
+ gap: 0.5rem;
351
+ padding: 0.375rem 0.75rem;
352
+ border-radius: 9999px;
353
+ background: #f5f5f5;
354
+ font-size: 0.875rem;
355
+ }
356
+ .spotify-preview-icon[data-v-53e3c895] {
357
+ color: #1ed760;
358
+ font-weight: 600;
359
+ }
360
+ .spotify-preview-label[data-v-53e3c895] {
361
+ font-weight: 500;
362
+ }
363
+ .spotify-preview-summary[data-v-53e3c895] {
364
+ color: #6b7280;
365
+ font-size: 0.75rem;
366
+ }
367
+ /*$vite$:1*/
package/dist/time.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare const ONE_SECOND_MS = 1000;
2
+ export declare const ONE_MINUTE_MS: number;
@@ -0,0 +1,19 @@
1
+ import { FileOps } from 'gui-chat-protocol';
2
+ import { RefreshResponseFields, SpotifyClientConfig, SpotifyTokens } from './types';
3
+ /** Read persisted tokens. Returns null on absent / malformed (=
4
+ * caller treats as "not_connected" and walks the user back to the
5
+ * connect button). Throws only on the read I/O itself. */
6
+ export declare function readTokens(files: FileOps): Promise<SpotifyTokens | null>;
7
+ /** Write the full token record. */
8
+ export declare function writeTokens(files: FileOps, tokens: SpotifyTokens): Promise<void>;
9
+ /** Read the user-provided Client ID. Returns null when the file is
10
+ * absent / malformed (caller treats as "client_id_missing" and
11
+ * surfaces the setup guide). */
12
+ export declare function readClientConfig(files: FileOps): Promise<SpotifyClientConfig | null>;
13
+ /** Write the Client ID. The View's "Configure" form posts here
14
+ * via `runtime.dispatch({ kind: "configure", clientId })` (PR 2). */
15
+ export declare function writeClientConfig(files: FileOps, config: SpotifyClientConfig): Promise<void>;
16
+ /** Apply a refresh response to the persisted tokens, preserving the
17
+ * prior `refreshToken` when Spotify omits a fresh one (the common
18
+ * case). Pure — caller persists. */
19
+ export declare function mergeRefreshResponse(prior: SpotifyTokens, response: RefreshResponseFields, now?: Date): SpotifyTokens;