getfilepress 0.1.2 → 0.1.3

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.
@@ -8,7 +8,13 @@
8
8
  };
9
9
 
10
10
  type Health = {
11
- ollama: { available: boolean; hint: string; model: string; host: string };
11
+ ollama: {
12
+ available: boolean;
13
+ hint: string;
14
+ model: string;
15
+ host: string;
16
+ models: string[];
17
+ };
12
18
  active: { versionId: string; activatedAt: string } | null;
13
19
  versions: VersionRow[];
14
20
  brief: {
@@ -22,12 +28,44 @@
22
28
  };
23
29
  };
24
30
 
31
+ type DiscoveredServer = {
32
+ label: string;
33
+ endpoint: string;
34
+ source: string;
35
+ self: boolean;
36
+ models: string[];
37
+ };
38
+
39
+ type TabId = 'refine' | 'look' | 'images' | 'inspire' | 'config' | 'history';
40
+
41
+ const TABS: Array<{ id: TabId; label: string; hint: string }> = [
42
+ { id: 'refine', label: 'Refine', hint: 'Ask Ollama' },
43
+ { id: 'look', label: 'Look', hint: 'Quick steers' },
44
+ { id: 'images', label: 'Images', hint: 'Stock / upload' },
45
+ { id: 'inspire', label: 'Inspire', hint: 'From URLs' },
46
+ { id: 'config', label: 'Config', hint: 'Lede / logo' },
47
+ { id: 'history', label: 'History', hint: 'Versions' }
48
+ ];
49
+
25
50
  let open = $state(false);
51
+ let tab = $state<TabId>('refine');
26
52
  let loading = $state(false);
27
53
  let error = $state('');
28
54
  let health = $state<Health | null>(null);
29
55
  let stockQuery = $state('abstract dark texture');
30
56
  let accent = $state('#1e4d6b');
57
+ let inspireUrls = $state('https://www.catalystforge.com\n');
58
+ let useLlm = $state(true);
59
+ let selectedModel = $state('');
60
+ let selectedHost = $state('');
61
+ let discovered = $state<DiscoveredServer[]>([]);
62
+ let includeLan = $state(false);
63
+ let scanning = $state(false);
64
+ let scanNote = $state('');
65
+ let refinePrompt = $state('');
66
+ let cfgLede = $state('');
67
+ let cfgTagline = $state('');
68
+ let cfgLogo = $state('');
31
69
 
32
70
  async function api(path: string, init?: RequestInit) {
33
71
  const res = await fetch(`/__filepress/genie${path}`, {
@@ -48,6 +86,11 @@
48
86
  try {
49
87
  health = await api('/health');
50
88
  if (health?.brief?.tokens?.accent) accent = health.brief.tokens.accent;
89
+ if (health?.ollama?.host && !selectedHost) selectedHost = health.ollama.host;
90
+ if (health?.ollama?.model && !selectedModel) selectedModel = health.ollama.model;
91
+ else if (health?.ollama?.models?.length && !selectedModel) {
92
+ selectedModel = health.ollama.models[0];
93
+ }
51
94
  } catch (e) {
52
95
  error = e instanceof Error ? e.message : String(e);
53
96
  } finally {
@@ -57,6 +100,7 @@
57
100
 
58
101
  async function openPanel() {
59
102
  open = true;
103
+ tab = 'refine';
60
104
  await refresh();
61
105
  }
62
106
 
@@ -66,7 +110,6 @@
66
110
  try {
67
111
  await api('/steer', { method: 'POST', body: JSON.stringify({ brief, label }) });
68
112
  await refresh();
69
- // Theme alias is a real file — full reload picks up CSS reliably.
70
113
  location.reload();
71
114
  } catch (e) {
72
115
  error = e instanceof Error ? e.message : String(e);
@@ -101,7 +144,7 @@
101
144
  }
102
145
  }
103
146
 
104
- async function onUpload(ev: Event, role: 'hero' | 'background') {
147
+ async function onUpload(ev: Event, role: 'hero' | 'background' | 'logo') {
105
148
  const input = ev.currentTarget as HTMLInputElement;
106
149
  const file = input.files?.[0];
107
150
  if (!file) return;
@@ -130,6 +173,144 @@
130
173
  input.value = '';
131
174
  }
132
175
  }
176
+
177
+ function normalizeHost(host: string) {
178
+ return host.trim().replace(/\/+$/, '').toLowerCase();
179
+ }
180
+
181
+ function serverOptions(): DiscoveredServer[] {
182
+ const map = new Map<string, DiscoveredServer>();
183
+ if (health?.ollama?.host) {
184
+ map.set(normalizeHost(health.ollama.host), {
185
+ label: `OLLAMA_HOST (${health.ollama.host})`,
186
+ endpoint: health.ollama.host.replace(/\/+$/, ''),
187
+ source: 'env',
188
+ self: true,
189
+ models: health.ollama.models ?? []
190
+ });
191
+ }
192
+ for (const s of discovered) {
193
+ map.set(normalizeHost(s.endpoint), s);
194
+ }
195
+ return [...map.values()];
196
+ }
197
+
198
+ function modelsForHost(host: string): string[] {
199
+ const hit = serverOptions().find((s) => normalizeHost(s.endpoint) === normalizeHost(host));
200
+ return hit?.models ?? health?.ollama?.models ?? [];
201
+ }
202
+
203
+ function pickModel(models: string[], preferred: string) {
204
+ if (preferred && models.includes(preferred)) return preferred;
205
+ return models[0] || preferred || '';
206
+ }
207
+
208
+ function onHostChange() {
209
+ selectedModel = pickModel(modelsForHost(selectedHost), selectedModel || health?.ollama?.model || '');
210
+ }
211
+
212
+ function hostIsReady() {
213
+ if (modelsForHost(selectedHost).length) return true;
214
+ if (!health) return false;
215
+ return (
216
+ health.ollama.available &&
217
+ normalizeHost(selectedHost || health.ollama.host) === normalizeHost(health.ollama.host)
218
+ );
219
+ }
220
+
221
+ async function runScan() {
222
+ scanning = true;
223
+ error = '';
224
+ scanNote = includeLan ? 'Scanning LAN + known hosts…' : 'Scanning known hosts / Tailscale…';
225
+ try {
226
+ const data = await api('/scan', {
227
+ method: 'POST',
228
+ body: JSON.stringify({ lan: includeLan })
229
+ });
230
+ discovered = Array.isArray(data.servers) ? data.servers : [];
231
+ if (data.error) {
232
+ scanNote = data.error;
233
+ } else if (!discovered.length) {
234
+ scanNote =
235
+ 'No Ollama servers found. Add hosts in ~/.ollanet/config.json, set OLLANET_HOSTS, or enable LAN.';
236
+ } else {
237
+ const sources = Array.isArray(data.sources) ? data.sources.join(', ') : 'scan';
238
+ scanNote = `Found ${discovered.length} server${discovered.length === 1 ? '' : 's'} (${sources}).`;
239
+ }
240
+ const options = serverOptions();
241
+ if (!options.some((s) => normalizeHost(s.endpoint) === normalizeHost(selectedHost))) {
242
+ selectedHost = options[0]?.endpoint || selectedHost;
243
+ }
244
+ onHostChange();
245
+ } catch (e) {
246
+ error = e instanceof Error ? e.message : String(e);
247
+ scanNote = '';
248
+ } finally {
249
+ scanning = false;
250
+ }
251
+ }
252
+
253
+ async function runInspire() {
254
+ loading = true;
255
+ error = '';
256
+ try {
257
+ await api('/inspire', {
258
+ method: 'POST',
259
+ body: JSON.stringify({
260
+ urls: inspireUrls
261
+ .split(/\n+/)
262
+ .map((s) => s.trim())
263
+ .filter(Boolean),
264
+ useLlm,
265
+ model: selectedModel || undefined,
266
+ host: selectedHost || undefined
267
+ })
268
+ });
269
+ location.reload();
270
+ } catch (e) {
271
+ error = e instanceof Error ? e.message : String(e);
272
+ loading = false;
273
+ }
274
+ }
275
+
276
+ async function runRefine() {
277
+ loading = true;
278
+ error = '';
279
+ try {
280
+ await api('/refine', {
281
+ method: 'POST',
282
+ body: JSON.stringify({
283
+ prompt: refinePrompt,
284
+ model: selectedModel || undefined,
285
+ host: selectedHost || undefined
286
+ })
287
+ });
288
+ location.reload();
289
+ } catch (e) {
290
+ error = e instanceof Error ? e.message : String(e);
291
+ loading = false;
292
+ }
293
+ }
294
+
295
+ async function runConfig() {
296
+ loading = true;
297
+ error = '';
298
+ try {
299
+ const patch: Record<string, string | null> = {};
300
+ if (cfgLede.trim()) patch.lede = cfgLede.trim();
301
+ if (cfgTagline.trim()) patch.tagline = cfgTagline.trim();
302
+ if (cfgLogo.trim()) patch.logo = cfgLogo.trim();
303
+ if (!Object.keys(patch).length) throw new Error('Fill at least one config field');
304
+ await api('/config', {
305
+ method: 'POST',
306
+ body: JSON.stringify({ patch })
307
+ });
308
+ location.reload();
309
+ } catch (e) {
310
+ error = e instanceof Error ? e.message : String(e);
311
+ loading = false;
312
+ }
313
+ }
133
314
  </script>
134
315
 
135
316
  {#if !open}
@@ -139,7 +320,10 @@
139
320
  {:else}
140
321
  <aside class="genie-panel" aria-label="Genie Mode">
141
322
  <header class="genie-head">
142
- <strong>Genie</strong>
323
+ <div>
324
+ <strong>Genie</strong>
325
+ <p class="genie-tagline">Try a look → activate → commit baked files</p>
326
+ </div>
143
327
  <button type="button" class="genie-x" onclick={() => (open = false)}>Close</button>
144
328
  </header>
145
329
 
@@ -148,132 +332,308 @@
148
332
  {/if}
149
333
 
150
334
  {#if health}
151
- <section class="genie-sec">
152
- <h3>Status</h3>
153
- <p class="genie-muted">
154
- Ollama: {health.ollama.available ? 'up' : 'down'} · {health.ollama.model}
155
- </p>
156
- <p class="genie-hint">{health.ollama.hint}</p>
157
- </section>
158
-
159
- <section class="genie-sec">
160
- <h3>Steers</h3>
161
- <label class="genie-row">
162
- Accent
163
- <input type="color" bind:value={accent} disabled={loading} />
164
- <button
165
- type="button"
166
- disabled={loading}
167
- onclick={() =>
168
- steer(
169
- { tokens: { accent, accentStrong: accent } },
170
- `Accent ${accent}`
171
- )}
172
- >
173
- Apply
174
- </button>
175
- </label>
176
- <div class="genie-chips">
177
- <button
178
- type="button"
179
- disabled={loading}
180
- onclick={() =>
181
- steer(
182
- { paletteMode: 'dark', hero: 'bold', atmosphere: 'noise', elevatedCards: true, navStyle: 'uppercase-tracked', density: 'balanced' },
183
- 'Dark punchy'
184
- )}
185
- >
186
- Dark punchy
187
- </button>
188
- <button
189
- type="button"
190
- disabled={loading}
191
- onclick={() =>
192
- steer(
193
- {
194
- paletteMode: 'light',
195
- hero: 'editorial',
196
- atmosphere: 'none',
197
- elevatedCards: false,
198
- navStyle: 'soft',
199
- density: 'sparse'
200
- },
201
- 'Light editorial'
202
- )}
203
- >
204
- Light editorial
205
- </button>
206
- <button
207
- type="button"
208
- disabled={loading}
209
- onclick={() => steer({ density: 'dense' }, 'Denser')}
210
- >
211
- Denser
212
- </button>
213
- <button
214
- type="button"
215
- disabled={loading}
216
- onclick={() => steer({ hero: 'bold' }, 'Bold hero')}
217
- >
218
- Bold hero
219
- </button>
220
- </div>
221
- </section>
222
-
223
- <section class="genie-sec">
224
- <h3>Background (Openverse)</h3>
225
- <label class="genie-row">
226
- Query
227
- <input type="text" bind:value={stockQuery} disabled={loading} />
228
- <button type="button" disabled={loading} onclick={applyStock}>Fetch</button>
229
- </label>
230
- </section>
231
-
232
- <section class="genie-sec">
233
- <h3>Upload</h3>
234
- <label class="genie-row">
235
- Hero background
236
- <input
237
- type="file"
238
- accept="image/jpeg,image/png,image/webp,image/gif"
239
- disabled={loading}
240
- onchange={(e) => onUpload(e, 'hero')}
241
- />
242
- </label>
243
- <label class="genie-row">
244
- Page background
245
- <input
246
- type="file"
247
- accept="image/jpeg,image/png,image/webp,image/gif"
248
- disabled={loading}
249
- onchange={(e) => onUpload(e, 'background')}
250
- />
251
- </label>
252
- </section>
253
-
254
- <section class="genie-sec">
255
- <h3>Versions</h3>
256
- <p class="genie-muted">
257
- Active: {health.active?.versionId ?? '(none)'}
258
- </p>
259
- <ul class="genie-versions">
260
- {#each health.versions as v (v.id)}
261
- <li class:active={health.active?.versionId === v.id}>
335
+ <div class="genie-shell">
336
+ <nav class="genie-tabs" aria-label="Genie sections">
337
+ {#each TABS as t (t.id)}
338
+ <button
339
+ type="button"
340
+ class="genie-tab"
341
+ class:active={tab === t.id}
342
+ onclick={() => (tab = t.id)}
343
+ title={t.hint}
344
+ >
345
+ <span class="genie-tab-label">{t.label}</span>
346
+ <span class="genie-tab-hint">{t.hint}</span>
347
+ </button>
348
+ {/each}
349
+ </nav>
350
+
351
+ <div class="genie-body">
352
+ {#if tab === 'refine'}
353
+ <section class="genie-sec">
354
+ <h3>Ollama refine</h3>
355
+ <p class="genie-howto">
356
+ Describe the look you want in plain language. Genie asks Ollama — local or another
357
+ server you scan on the network — for a design brief, writes a new version, and
358
+ activates it (page reloads). Undo anytime from <strong>History</strong>.
359
+ </p>
360
+
361
+ <div class="genie-status-pill" class:up={hostIsReady()}>
362
+ {hostIsReady() ? 'Ollama up' : 'Ollama down'}
363
+ {#if selectedHost}
364
+ · {selectedHost.replace(/^https?:\/\//, '')}
365
+ {/if}
366
+ {#if hostIsReady() && selectedModel}
367
+ · {selectedModel}
368
+ {/if}
369
+ </div>
370
+
371
+ <label class="genie-row">
372
+ Server
373
+ <select bind:value={selectedHost} disabled={loading || scanning} onchange={onHostChange}>
374
+ {#each serverOptions() as s (s.endpoint)}
375
+ <option value={s.endpoint}>{s.label}</option>
376
+ {/each}
377
+ </select>
378
+ </label>
379
+ <div class="genie-scan-row">
380
+ <label class="genie-check genie-check-inline">
381
+ <input type="checkbox" bind:checked={includeLan} disabled={loading || scanning} />
382
+ Include LAN
383
+ </label>
384
+ <button type="button" disabled={loading || scanning} onclick={runScan}>
385
+ {scanning ? 'Scanning…' : 'Scan network'}
386
+ </button>
387
+ </div>
388
+ {#if scanNote}
389
+ <p class="genie-muted">{scanNote}</p>
390
+ {/if}
391
+
392
+ {#if hostIsReady() && modelsForHost(selectedHost).length}
393
+ <label class="genie-row">
394
+ Model
395
+ <select bind:value={selectedModel} disabled={loading || scanning}>
396
+ {#each modelsForHost(selectedHost) as m (m)}
397
+ <option value={m}>{m}</option>
398
+ {/each}
399
+ </select>
400
+ </label>
401
+ {:else if !hostIsReady()}
402
+ <p class="genie-hint">{health.ollama.hint}</p>
403
+ <p class="genie-muted">
404
+ Steers, images, and inspire still work without Ollama — use the other tabs. Or scan
405
+ for a server on Tailscale / LAN.
406
+ </p>
407
+ {:else}
408
+ <p class="genie-hint">{health.ollama.hint}</p>
409
+ {/if}
410
+
411
+ <label class="genie-row">
412
+ Direction
413
+ <textarea
414
+ rows="3"
415
+ bind:value={refinePrompt}
416
+ placeholder="warmer gold accents, denser nav, softer hero, less noise"
417
+ disabled={loading || !hostIsReady()}
418
+ ></textarea>
419
+ </label>
262
420
  <button
263
421
  type="button"
264
- disabled={loading || health.active?.versionId === v.id}
265
- onclick={() => activate(v.id)}
422
+ class="genie-primary"
423
+ disabled={loading || scanning || !hostIsReady() || !refinePrompt.trim()}
424
+ onclick={runRefine}
266
425
  >
267
- {v.label}
426
+ {loading ? 'Working…' : 'Refine & activate'}
268
427
  </button>
269
- <span class="genie-muted">{v.id === 'baseline' ? 'baseline' : v.createdAt.slice(11, 19)}</span>
270
- </li>
271
- {/each}
272
- </ul>
273
- <button type="button" class="genie-linkish" disabled={loading} onclick={refresh}>
274
- Refresh list
275
- </button>
276
- </section>
428
+ <p class="genie-muted">
429
+ Tip: set <code>FILEPRESS_OLLAMA_MODEL</code> / <code>OLLAMA_HOST</code> for defaults;
430
+ <a href="https://ollanet.dev" target="_blank" rel="noreferrer">ollanet</a> finds other
431
+ boxes. Finetuna can tune a named variant.
432
+ </p>
433
+ </section>
434
+ {:else if tab === 'look'}
435
+ <section class="genie-sec">
436
+ <h3>Quick steers</h3>
437
+ <p class="genie-howto">
438
+ Instant, no LLM. Each chip or accent change creates a version and reloads.
439
+ </p>
440
+ <label class="genie-row">
441
+ Accent
442
+ <input type="color" bind:value={accent} disabled={loading} />
443
+ <button
444
+ type="button"
445
+ disabled={loading}
446
+ onclick={() =>
447
+ steer(
448
+ { tokens: { accent, accentStrong: accent } },
449
+ `Accent ${accent}`
450
+ )}
451
+ >
452
+ Apply accent
453
+ </button>
454
+ </label>
455
+ <div class="genie-chips">
456
+ <button
457
+ type="button"
458
+ disabled={loading}
459
+ onclick={() =>
460
+ steer(
461
+ {
462
+ paletteMode: 'dark',
463
+ hero: 'bold',
464
+ atmosphere: 'noise',
465
+ elevatedCards: true,
466
+ navStyle: 'uppercase-tracked',
467
+ density: 'balanced'
468
+ },
469
+ 'Dark punchy'
470
+ )}
471
+ >
472
+ Dark punchy
473
+ </button>
474
+ <button
475
+ type="button"
476
+ disabled={loading}
477
+ onclick={() =>
478
+ steer(
479
+ {
480
+ paletteMode: 'light',
481
+ hero: 'editorial',
482
+ atmosphere: 'none',
483
+ elevatedCards: false,
484
+ navStyle: 'soft',
485
+ density: 'sparse'
486
+ },
487
+ 'Light editorial'
488
+ )}
489
+ >
490
+ Light editorial
491
+ </button>
492
+ <button
493
+ type="button"
494
+ disabled={loading}
495
+ onclick={() => steer({ density: 'dense' }, 'Denser')}
496
+ >
497
+ Denser
498
+ </button>
499
+ <button
500
+ type="button"
501
+ disabled={loading}
502
+ onclick={() => steer({ hero: 'bold' }, 'Bold hero')}
503
+ >
504
+ Bold hero
505
+ </button>
506
+ </div>
507
+ </section>
508
+ {:else if tab === 'images'}
509
+ <section class="genie-sec">
510
+ <h3>Images</h3>
511
+ <p class="genie-howto">
512
+ Pull a CC stock background from Openverse, or upload a local file. Logo upload
513
+ also sets <code>logo</code> in config.
514
+ </p>
515
+ <label class="genie-row">
516
+ Openverse query
517
+ <input type="text" bind:value={stockQuery} disabled={loading} />
518
+ <button type="button" disabled={loading} onclick={applyStock}>
519
+ Fetch background
520
+ </button>
521
+ </label>
522
+ <label class="genie-row">
523
+ Hero background
524
+ <input
525
+ type="file"
526
+ accept="image/jpeg,image/png,image/webp,image/gif"
527
+ disabled={loading}
528
+ onchange={(e) => onUpload(e, 'hero')}
529
+ />
530
+ </label>
531
+ <label class="genie-row">
532
+ Page background
533
+ <input
534
+ type="file"
535
+ accept="image/jpeg,image/png,image/webp,image/gif"
536
+ disabled={loading}
537
+ onchange={(e) => onUpload(e, 'background')}
538
+ />
539
+ </label>
540
+ <label class="genie-row">
541
+ Logo
542
+ <input
543
+ type="file"
544
+ accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml"
545
+ disabled={loading}
546
+ onchange={(e) => onUpload(e, 'logo')}
547
+ />
548
+ </label>
549
+ </section>
550
+ {:else if tab === 'inspire'}
551
+ <section class="genie-sec">
552
+ <h3>Inspire from URLs</h3>
553
+ <p class="genie-howto">
554
+ Paste 1–3 public site URLs. Genie crawls them and blends a look. Optionally refine
555
+ with the model selected under <strong>Refine</strong>.
556
+ </p>
557
+ <label class="genie-row">
558
+ URLs (one per line)
559
+ <textarea rows="4" bind:value={inspireUrls} disabled={loading}></textarea>
560
+ </label>
561
+ <label class="genie-check">
562
+ <input type="checkbox" bind:checked={useLlm} disabled={loading} />
563
+ Refine with Ollama when available
564
+ {#if selectedModel}
565
+ <span class="genie-muted">({selectedModel})</span>
566
+ {/if}
567
+ {#if selectedHost}
568
+ <span class="genie-muted">{selectedHost.replace(/^https?:\/\//, '')}</span>
569
+ {/if}
570
+ </label>
571
+ <button type="button" class="genie-primary" disabled={loading} onclick={runInspire}>
572
+ Crawl &amp; apply
573
+ </button>
574
+ </section>
575
+ {:else if tab === 'config'}
576
+ <section class="genie-sec">
577
+ <h3>Site chrome</h3>
578
+ <p class="genie-howto">
579
+ Patches <code>filepress.config.ts</code> on activate (lede, tagline, logo path).
580
+ Leave a field blank to skip it.
581
+ </p>
582
+ <label class="genie-row">
583
+ Lede
584
+ <input type="text" bind:value={cfgLede} disabled={loading} />
585
+ </label>
586
+ <label class="genie-row">
587
+ Tagline
588
+ <input type="text" bind:value={cfgTagline} disabled={loading} />
589
+ </label>
590
+ <label class="genie-row">
591
+ Logo path
592
+ <input
593
+ type="text"
594
+ bind:value={cfgLogo}
595
+ placeholder="/images/logo.svg"
596
+ disabled={loading}
597
+ />
598
+ </label>
599
+ <button type="button" class="genie-primary" disabled={loading} onclick={runConfig}>
600
+ Apply config
601
+ </button>
602
+ </section>
603
+ {:else}
604
+ <section class="genie-sec">
605
+ <h3>Versions</h3>
606
+ <p class="genie-howto">
607
+ Every Genie action saves a snapshot under <code>.filepress-genie/</code> (gitignored).
608
+ Activate to bake into the working tree; commit when you like it. <code>baseline</code> is
609
+ the pre-Genie look.
610
+ </p>
611
+ <p class="genie-muted">
612
+ Active: {health.active?.versionId ?? '(none)'}
613
+ </p>
614
+ <ul class="genie-versions">
615
+ {#each health.versions as v (v.id)}
616
+ <li class:active={health.active?.versionId === v.id}>
617
+ <button
618
+ type="button"
619
+ disabled={loading || health.active?.versionId === v.id}
620
+ onclick={() => activate(v.id)}
621
+ >
622
+ {v.label}
623
+ </button>
624
+ <span class="genie-muted"
625
+ >{v.id === 'baseline' ? 'baseline' : v.createdAt.slice(11, 19)}</span
626
+ >
627
+ </li>
628
+ {/each}
629
+ </ul>
630
+ <button type="button" class="genie-linkish" disabled={loading} onclick={refresh}>
631
+ Refresh list
632
+ </button>
633
+ </section>
634
+ {/if}
635
+ </div>
636
+ </div>
277
637
  {:else if loading}
278
638
  <p class="genie-muted">Loading…</p>
279
639
  {/if}
@@ -304,21 +664,33 @@
304
664
  right: 0;
305
665
  bottom: 0;
306
666
  z-index: 100000;
307
- width: min(22rem, 100vw);
308
- overflow: auto;
667
+ width: min(28rem, 100vw);
668
+ display: flex;
669
+ flex-direction: column;
309
670
  background: color-mix(in srgb, var(--bg, #12121a) 94%, #000);
310
671
  color: var(--ink, #eee);
311
672
  border-left: 1px solid var(--rule, #333);
312
- padding: 1rem 1rem 2rem;
673
+ padding: 0.85rem 0.75rem 1rem;
313
674
  font: 0.9rem/1.45 var(--font-sans, system-ui, sans-serif);
314
675
  box-shadow: -12px 0 40px color-mix(in srgb, #000 40%, transparent);
315
676
  }
316
677
 
317
678
  .genie-head {
318
679
  display: flex;
319
- align-items: center;
680
+ align-items: flex-start;
320
681
  justify-content: space-between;
321
- margin-bottom: 1rem;
682
+ gap: 0.75rem;
683
+ padding: 0 0.25rem 0.75rem;
684
+ border-bottom: 1px solid var(--rule, #333);
685
+ margin-bottom: 0.75rem;
686
+ flex-shrink: 0;
687
+ }
688
+
689
+ .genie-tagline {
690
+ margin: 0.2rem 0 0;
691
+ font-size: 0.75rem;
692
+ color: var(--ink-soft, #999);
693
+ font-weight: 400;
322
694
  }
323
695
 
324
696
  .genie-x,
@@ -326,32 +698,111 @@
326
698
  cursor: pointer;
327
699
  }
328
700
 
329
- .genie-sec {
330
- margin-bottom: 1.25rem;
331
- padding-bottom: 1rem;
332
- border-bottom: 1px solid var(--rule, #333);
701
+ .genie-shell {
702
+ display: grid;
703
+ grid-template-columns: 5.75rem 1fr;
704
+ gap: 0.65rem;
705
+ min-height: 0;
706
+ flex: 1;
707
+ overflow: hidden;
333
708
  }
334
709
 
335
- .genie-sec h3 {
336
- margin: 0 0 0.5rem;
710
+ .genie-tabs {
711
+ display: flex;
712
+ flex-direction: column;
713
+ gap: 0.25rem;
714
+ overflow: auto;
715
+ padding-right: 0.15rem;
716
+ }
717
+
718
+ .genie-tab {
719
+ display: grid;
720
+ gap: 0.1rem;
721
+ text-align: left;
722
+ border: 1px solid transparent;
723
+ background: transparent;
724
+ color: var(--ink-soft, #999);
725
+ border-radius: 8px;
726
+ padding: 0.45rem 0.4rem;
337
727
  font-size: 0.72rem;
338
- letter-spacing: 0.1em;
728
+ }
729
+
730
+ .genie-tab-label {
731
+ font-weight: 700;
732
+ letter-spacing: 0.04em;
733
+ text-transform: uppercase;
734
+ color: inherit;
735
+ }
736
+
737
+ .genie-tab-hint {
738
+ font-size: 0.65rem;
739
+ opacity: 0.85;
740
+ line-height: 1.2;
741
+ }
742
+
743
+ .genie-tab:hover {
744
+ border-color: var(--rule, #444);
745
+ color: var(--ink, #eee);
746
+ }
747
+
748
+ .genie-tab.active {
749
+ border-color: color-mix(in srgb, var(--accent, #f0c040) 55%, var(--rule, #444));
750
+ background: color-mix(in srgb, var(--accent, #f0c040) 12%, transparent);
751
+ color: var(--accent, #f0c040);
752
+ }
753
+
754
+ .genie-body {
755
+ overflow: auto;
756
+ padding: 0 0.15rem 1rem 0.35rem;
757
+ border-left: 1px solid var(--rule, #333);
758
+ min-width: 0;
759
+ }
760
+
761
+ .genie-sec h3 {
762
+ margin: 0 0 0.45rem;
763
+ font-size: 0.78rem;
764
+ letter-spacing: 0.08em;
339
765
  text-transform: uppercase;
340
766
  color: var(--ink-soft, #999);
341
767
  font-weight: 600;
342
768
  }
343
769
 
770
+ .genie-howto {
771
+ font-size: 0.8rem;
772
+ color: var(--ink-soft, #bbb);
773
+ line-height: 1.45;
774
+ margin: 0 0 0.85rem;
775
+ }
776
+
777
+ .genie-status-pill {
778
+ display: inline-block;
779
+ font-size: 0.72rem;
780
+ font-weight: 600;
781
+ letter-spacing: 0.04em;
782
+ text-transform: uppercase;
783
+ padding: 0.25rem 0.55rem;
784
+ border-radius: 999px;
785
+ border: 1px solid var(--rule, #444);
786
+ color: var(--ink-soft, #999);
787
+ margin-bottom: 0.75rem;
788
+ }
789
+
790
+ .genie-status-pill.up {
791
+ border-color: color-mix(in srgb, #3a8 50%, var(--rule));
792
+ color: #7dca9a;
793
+ }
794
+
344
795
  .genie-muted {
345
796
  color: var(--ink-soft, #999);
346
797
  font-size: 0.82rem;
347
- margin: 0.25rem 0;
798
+ margin: 0.35rem 0;
348
799
  }
349
800
 
350
801
  .genie-hint {
351
802
  font-size: 0.78rem;
352
803
  color: var(--ink-soft, #aaa);
353
804
  line-height: 1.4;
354
- margin: 0.4rem 0 0;
805
+ margin: 0.4rem 0 0.75rem;
355
806
  word-break: break-word;
356
807
  }
357
808
 
@@ -361,6 +812,8 @@
361
812
  padding: 0.5rem 0.65rem;
362
813
  border-radius: 6px;
363
814
  font-size: 0.82rem;
815
+ margin: 0 0.25rem 0.65rem;
816
+ flex-shrink: 0;
364
817
  }
365
818
 
366
819
  .genie-row {
@@ -371,8 +824,32 @@
371
824
  }
372
825
 
373
826
  .genie-row input[type='text'],
374
- .genie-row input[type='file'] {
827
+ .genie-row input[type='file'],
828
+ .genie-row textarea,
829
+ .genie-row select {
375
830
  width: 100%;
831
+ box-sizing: border-box;
832
+ }
833
+
834
+ .genie-check {
835
+ display: flex;
836
+ flex-wrap: wrap;
837
+ align-items: center;
838
+ gap: 0.45rem;
839
+ font-size: 0.8rem;
840
+ margin-bottom: 0.65rem;
841
+ }
842
+
843
+ .genie-scan-row {
844
+ display: flex;
845
+ flex-wrap: wrap;
846
+ align-items: center;
847
+ gap: 0.5rem;
848
+ margin-bottom: 0.65rem;
849
+ }
850
+
851
+ .genie-check-inline {
852
+ margin-bottom: 0;
376
853
  }
377
854
 
378
855
  .genie-chips {
@@ -383,21 +860,39 @@
383
860
 
384
861
  .genie-chips button,
385
862
  .genie-row button,
386
- .genie-versions button {
863
+ .genie-versions button,
864
+ .genie-primary {
387
865
  border: 1px solid var(--rule, #444);
388
866
  background: var(--surface, #1c1c22);
389
867
  color: var(--ink, #eee);
390
868
  border-radius: 6px;
391
- padding: 0.35rem 0.55rem;
869
+ padding: 0.4rem 0.65rem;
392
870
  font-size: 0.78rem;
393
871
  }
394
872
 
873
+ .genie-primary {
874
+ width: 100%;
875
+ margin-top: 0.25rem;
876
+ border-color: color-mix(in srgb, var(--accent, #f0c040) 50%, var(--rule));
877
+ background: color-mix(in srgb, var(--accent, #f0c040) 16%, var(--surface, #1c1c22));
878
+ color: var(--accent, #f0c040);
879
+ font-weight: 700;
880
+ }
881
+
395
882
  .genie-chips button:hover,
396
883
  .genie-row button:hover,
397
- .genie-versions button:hover {
884
+ .genie-versions button:hover,
885
+ .genie-primary:hover:not(:disabled) {
398
886
  border-color: var(--accent, #f0c040);
399
887
  }
400
888
 
889
+ .genie-primary:disabled,
890
+ .genie-chips button:disabled,
891
+ .genie-row button:disabled {
892
+ opacity: 0.45;
893
+ cursor: not-allowed;
894
+ }
895
+
401
896
  .genie-versions {
402
897
  list-style: none;
403
898
  margin: 0.5rem 0;
@@ -426,4 +921,37 @@
426
921
  font-size: 0.8rem;
427
922
  text-decoration: underline;
428
923
  }
924
+
925
+ .genie-howto code,
926
+ .genie-muted code {
927
+ font-size: 0.85em;
928
+ }
929
+
930
+ @media (max-width: 28rem) {
931
+ .genie-shell {
932
+ grid-template-columns: 1fr;
933
+ }
934
+
935
+ .genie-tabs {
936
+ flex-direction: row;
937
+ flex-wrap: wrap;
938
+ border-bottom: 1px solid var(--rule, #333);
939
+ padding-bottom: 0.5rem;
940
+ margin-bottom: 0.25rem;
941
+ }
942
+
943
+ .genie-tab {
944
+ flex: 1 1 auto;
945
+ min-width: 4.5rem;
946
+ }
947
+
948
+ .genie-tab-hint {
949
+ display: none;
950
+ }
951
+
952
+ .genie-body {
953
+ border-left: none;
954
+ padding-left: 0;
955
+ }
956
+ }
429
957
  </style>