openclacky 1.4.1 → 1.5.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/lib/clacky/agent/llm_caller.rb +3 -3
  4. data/lib/clacky/agent/session_serializer.rb +39 -5
  5. data/lib/clacky/brand_config.rb +49 -7
  6. data/lib/clacky/client.rb +22 -16
  7. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +135 -84
  8. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -4
  9. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +522 -0
  10. data/lib/clacky/extension/api_extension.rb +5 -6
  11. data/lib/clacky/extension/loader.rb +16 -2
  12. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  13. data/lib/clacky/mcp/http_transport.rb +1 -1
  14. data/lib/clacky/providers.rb +109 -1
  15. data/lib/clacky/server/http_server.rb +112 -20
  16. data/lib/clacky/server/session_registry.rb +9 -0
  17. data/lib/clacky/utils/model_pricing.rb +36 -0
  18. data/lib/clacky/version.rb +1 -1
  19. data/lib/clacky/web/app.css +321 -32
  20. data/lib/clacky/web/app.js +29 -6
  21. data/lib/clacky/web/components/onboard.js +7 -3
  22. data/lib/clacky/web/features/extensions/store.js +54 -2
  23. data/lib/clacky/web/features/extensions/view.js +40 -5
  24. data/lib/clacky/web/features/new-session/store.js +8 -4
  25. data/lib/clacky/web/features/version/view.js +5 -1
  26. data/lib/clacky/web/i18n.js +52 -18
  27. data/lib/clacky/web/index.html +51 -5
  28. data/lib/clacky/web/sessions.js +40 -11
  29. data/lib/clacky/web/settings.js +22 -3
  30. data/lib/clacky/web/theme.js +27 -58
  31. metadata +2 -5
  32. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +0 -71
  33. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +0 -73
  34. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +0 -65
  35. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +0 -106
@@ -152,6 +152,163 @@
152
152
  --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.48), 0 4px 8px rgba(0, 0, 0, 0.32);
153
153
  }
154
154
 
155
+ /* ── Dim theme (mid-dark, softer than pure dark) ─────────────────────────
156
+ Design stance: desaturated dark grey, less eye strain than near-black.
157
+ Accent is the same run-time variable — not touched here. */
158
+ [data-theme="dim"] {
159
+ --color-bg-primary: #21262d; /* sidebar / shell — deep slate */
160
+ --color-bg-secondary: #2b3038; /* main content — slightly lighter */
161
+ --color-bg-tertiary: #2b3038;
162
+ --color-bg-hover: #333b44;
163
+ --color-bg-input: #2b3038;
164
+ --color-bg-card: #2b3038;
165
+ --color-bg-overlay: rgba(0, 0, 0, 0.52);
166
+
167
+ --color-border-primary: #363d47;
168
+ --color-border-secondary: #2e353f;
169
+ --color-border-strong: #4a5260;
170
+
171
+ --color-text-primary: #cdd9e5;
172
+ --color-text-secondary: #8b98a8;
173
+ --color-text-tertiary: #5d6875;
174
+ --color-text-muted: #4a535e;
175
+ --color-text-inverse: #ffffff;
176
+ --live: #3ed47e;
177
+
178
+ --color-accent-primary: #818cf8;
179
+ --color-accent-hover: #6366f1;
180
+ --color-accent-soft: color-mix(in srgb, var(--color-accent-primary) 14%, transparent);
181
+
182
+ --color-success: #34d399;
183
+ --color-success-bg: #0d2a22;
184
+ --color-success-border: #1a4033;
185
+
186
+ --color-error: #f87171;
187
+ --color-error-bg: #2d1818;
188
+ --color-error-border: #4d2020;
189
+
190
+ --color-warning: #fbbf24;
191
+ --color-warning-bg: #2d1f00;
192
+ --color-warning-border: #4d3200;
193
+
194
+ --color-info: #38bdf8;
195
+ --color-secondary: #34d399;
196
+ --color-accent-alt: #fbbf24;
197
+
198
+ --color-button-primary: var(--color-accent-primary);
199
+ --color-button-primary-hover: var(--color-accent-hover);
200
+ --color-button-primary-text: #ffffff;
201
+
202
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.28);
203
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.30), 0 1px 2px rgba(0, 0, 0, 0.22);
204
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.20);
205
+ --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.44), 0 4px 8px rgba(0, 0, 0, 0.28);
206
+ }
207
+
208
+ /* ── Warm theme (warm off-white, sepia feel) ─────────────────────────────
209
+ Design stance: warm paper tones, easier on eyes than cold white. */
210
+ [data-theme="warm"] {
211
+ --color-bg-primary: #f5f0e8; /* warm parchment */
212
+ --color-bg-secondary: #fdf8f0; /* warmer white */
213
+ --color-bg-tertiary: #fdf8f0;
214
+ --color-bg-hover: #ece5d8;
215
+ --color-bg-input: #fdf8f0;
216
+ --color-bg-card: #fdf8f0;
217
+ --color-bg-overlay: rgba(30, 20, 10, 0.40);
218
+
219
+ --color-border-primary: #e0d6c4;
220
+ --color-border-secondary: #ede6d6;
221
+ --color-border-strong: #c8bba8;
222
+
223
+ --color-text-primary: #2c2416;
224
+ --color-text-secondary: #5c4e38;
225
+ --color-text-tertiary: #8a7a62;
226
+ --color-text-muted: #a89880;
227
+ --color-text-inverse: #ffffff;
228
+ --live: #2e9e5b;
229
+
230
+ --color-accent-primary: #4f46e5;
231
+ --color-accent-hover: #4338ca;
232
+ --color-accent-soft: color-mix(in srgb, var(--color-accent-primary) 10%, transparent);
233
+
234
+ --color-success: #10b981;
235
+ --color-success-bg: #f0fdf4;
236
+ --color-success-border: #bbf7d0;
237
+
238
+ --color-error: #ef4444;
239
+ --color-error-bg: #fef2f2;
240
+ --color-error-border: #fecaca;
241
+
242
+ --color-warning: #f59e0b;
243
+ --color-warning-bg: #fffbeb;
244
+ --color-warning-border: #fcd34d;
245
+
246
+ --color-info: #0ea5e9;
247
+ --color-secondary: #10b981;
248
+ --color-accent-alt: #f59e0b;
249
+
250
+ --color-button-primary: var(--color-accent-primary);
251
+ --color-button-primary-hover: var(--color-accent-hover);
252
+ --color-button-primary-text: #ffffff;
253
+
254
+ --shadow-xs: 0 1px 2px rgba(30, 20, 10, 0.05);
255
+ --shadow-sm: 0 1px 3px rgba(30, 20, 10, 0.07), 0 1px 2px rgba(30, 20, 10, 0.05);
256
+ --shadow-md: 0 4px 12px rgba(30, 20, 10, 0.09), 0 2px 4px rgba(30, 20, 10, 0.05);
257
+ --shadow-lg: 0 12px 28px rgba(30, 20, 10, 0.13), 0 4px 8px rgba(30, 20, 10, 0.07);
258
+ }
259
+
260
+ /* ── Blue-dark theme (VS Code / Apifox style blue-grey dark) ────────────
261
+ Design stance: blue-tinted dark surfaces, familiar dev-tool aesthetic. */
262
+ [data-theme="bluedark"] {
263
+ --color-bg-primary: #1e2228; /* sidebar / shell — blue-grey dark */
264
+ --color-bg-secondary: #252b33; /* main content — slightly lighter */
265
+ --color-bg-tertiary: #252b33;
266
+ --color-bg-hover: #2e3540;
267
+ --color-bg-input: #252b33;
268
+ --color-bg-card: #252b33;
269
+ --color-bg-overlay: rgba(0, 0, 0, 0.60);
270
+
271
+ --color-border-primary: #30373f;
272
+ --color-border-secondary: #282f38;
273
+ --color-border-strong: #44515f;
274
+
275
+ --color-text-primary: #d4dbe6;
276
+ --color-text-secondary: #8b97a8;
277
+ --color-text-tertiary: #5a6678;
278
+ --color-text-muted: #44505e;
279
+ --color-text-inverse: #ffffff;
280
+ --live: #3ed47e;
281
+
282
+ --color-accent-primary: #818cf8;
283
+ --color-accent-hover: #6366f1;
284
+ --color-accent-soft: color-mix(in srgb, var(--color-accent-primary) 14%, transparent);
285
+
286
+ --color-success: #34d399;
287
+ --color-success-bg: #0d2a22;
288
+ --color-success-border: #1a4033;
289
+
290
+ --color-error: #f87171;
291
+ --color-error-bg: #2d1818;
292
+ --color-error-border: #4d2020;
293
+
294
+ --color-warning: #fbbf24;
295
+ --color-warning-bg: #2d1f00;
296
+ --color-warning-border: #4d3200;
297
+
298
+ --color-info: #38bdf8;
299
+ --color-secondary: #34d399;
300
+ --color-accent-alt: #fbbf24;
301
+
302
+ --color-button-primary: var(--color-accent-primary);
303
+ --color-button-primary-hover: var(--color-accent-hover);
304
+ --color-button-primary-text: #ffffff;
305
+
306
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.40);
307
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.42), 0 1px 2px rgba(0, 0, 0, 0.32);
308
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.48), 0 2px 4px rgba(0, 0, 0, 0.30);
309
+ --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.60), 0 4px 8px rgba(0, 0, 0, 0.40);
310
+ }
311
+
155
312
  /* ── Font Size Presets ────────────────────────────────────────────────────
156
313
  Controlled via html[data-font-size]: small / medium / large.
157
314
  Mechanism: change root font-size, which 1rem inherits from. Every CSS
@@ -240,6 +397,33 @@ html { font-size: 16px; }
240
397
  background-size: 200% 100%;
241
398
  animation: skel-shimmer 1.8s infinite linear;
242
399
  }
400
+ [data-theme="dim"] .skel {
401
+ background: linear-gradient(90deg,
402
+ #2e343c 25%,
403
+ #3c444e 50%,
404
+ #2e343c 75%
405
+ );
406
+ background-size: 200% 100%;
407
+ animation: skel-shimmer 1.8s infinite linear;
408
+ }
409
+ [data-theme="warm"] .skel {
410
+ background: linear-gradient(90deg,
411
+ #ece5d8 25%,
412
+ #ddd5c5 50%,
413
+ #ece5d8 75%
414
+ );
415
+ background-size: 200% 100%;
416
+ animation: skel-shimmer 1.8s infinite linear;
417
+ }
418
+ [data-theme="bluedark"] .skel {
419
+ background: linear-gradient(90deg,
420
+ #272e38 25%,
421
+ #333d4a 50%,
422
+ #272e38 75%
423
+ );
424
+ background-size: 200% 100%;
425
+ animation: skel-shimmer 1.8s infinite linear;
426
+ }
243
427
  /* Generic size helpers */
244
428
  .skel-icon { width: 2rem; height: 2rem; border-radius: 50%; flex-shrink: 0; }
245
429
  .skel-title { height: 1.25rem; width: 6rem; }
@@ -266,8 +450,10 @@ body {
266
450
  display: flex;
267
451
  overflow: hidden;
268
452
  transition: background-color 0.3s ease, color 0.3s ease;
453
+ padding: 0 8px 8px;
454
+ box-sizing: border-box;
269
455
  }
270
- #app { display: flex; flex-direction: column; width: 100%; height: 100vh; height: 100dvh; }
456
+ #app { display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; }
271
457
 
272
458
  /* ── Offline Banner ───────────────────────────────────────────────────── */
273
459
  #offline-banner {
@@ -291,6 +477,8 @@ body {
291
477
  display: flex;
292
478
  flex: 1;
293
479
  overflow: hidden;
480
+ border: 1px solid var(--color-border-primary);
481
+ border-radius: 8px;
294
482
  }
295
483
 
296
484
  /* ── Top Header ───────────────────────────────────────────────────────── */
@@ -298,7 +486,6 @@ body {
298
486
  height: 3rem;
299
487
  min-height: 3rem;
300
488
  background: var(--color-bg-primary);
301
- border-bottom: 1px solid var(--color-border-primary);
302
489
  display: flex;
303
490
  align-items: center;
304
491
  justify-content: space-between;
@@ -754,7 +941,7 @@ body {
754
941
  transition: background 0.12s, color 0.12s;
755
942
  }
756
943
  .btn-split-menu-item:hover {
757
- background: var(--color-bg-tertiary);
944
+ background: var(--color-bg-hover);
758
945
  color: var(--color-text-primary);
759
946
  }
760
947
 
@@ -2452,7 +2639,7 @@ body {
2452
2639
  flex-shrink: 0;
2453
2640
  margin-bottom: -9px;
2454
2641
  display: flex;
2455
- padding-inline: 12px;
2642
+ padding-inline: 24px;
2456
2643
  }
2457
2644
  .session-composer > * {
2458
2645
  justify-content: flex-start !important;
@@ -2543,7 +2730,7 @@ body {
2543
2730
  Designed to be quiet and low-contrast so it guides without distracting. */
2544
2731
  .chat-empty-hint {
2545
2732
  margin: auto; /* vertical + horizontal centering inside flex column */
2546
- max-width: 26.25rem;
2733
+ max-width: 30rem;
2547
2734
  padding: 1.5rem 1rem;
2548
2735
  display: flex;
2549
2736
  flex-direction: column;
@@ -2552,47 +2739,43 @@ body {
2552
2739
  color: var(--color-text-secondary);
2553
2740
  user-select: none;
2554
2741
  pointer-events: none; /* purely decorative — don't block clicks/scroll */
2555
- opacity: 0.85;
2556
2742
  }
2557
2743
  .chat-empty-hint .chat-empty-icon {
2558
- color: var(--color-text-tertiary);
2559
- margin-bottom: 0.875rem;
2560
- opacity: 0.55;
2744
+ display: flex;
2745
+ align-items: center;
2746
+ justify-content: center;
2747
+ width: 4rem;
2748
+ height: 4rem;
2749
+ margin-bottom: 1.125rem;
2750
+ border-radius: 50%;
2751
+ color: var(--color-accent-primary);
2752
+ background: color-mix(in srgb, var(--color-accent-primary) 10%, transparent);
2561
2753
  }
2562
2754
  .chat-empty-hint .chat-empty-title {
2563
2755
  color: var(--color-text-primary);
2564
- font-size: 0.9375rem;
2565
- font-weight: 600;
2566
- margin-bottom: 0.25rem;
2756
+ font-size: 1.25rem;
2757
+ font-weight: 500;
2758
+ line-height: 1.3;
2759
+ margin-bottom: 0.5rem;
2567
2760
  }
2568
2761
  .chat-empty-hint .chat-empty-subtitle {
2569
- color: var(--color-text-secondary);
2570
- font-size: 0.8125rem;
2571
- margin-bottom: 1.125rem;
2572
- opacity: 0.85;
2762
+ color: var(--color-text-tertiary);
2763
+ font-size: 0.875rem;
2764
+ line-height: 1.5;
2573
2765
  }
2574
2766
  .chat-empty-hint .chat-empty-tips {
2575
2767
  list-style: none;
2576
2768
  padding: 0;
2577
- margin: 0;
2769
+ margin: 1.5rem 0 0;
2578
2770
  display: flex;
2579
2771
  flex-direction: column;
2580
- gap: 0.375rem;
2581
- font-size: 0.75rem;
2772
+ gap: 0.5rem;
2773
+ font-size: 0.8125rem;
2582
2774
  color: var(--color-text-tertiary);
2583
2775
  }
2584
2776
  .chat-empty-hint .chat-empty-tips li {
2585
- position: relative;
2586
- padding-left: 0.875rem;
2587
2777
  line-height: 1.5;
2588
2778
  }
2589
- .chat-empty-hint .chat-empty-tips li::before {
2590
- content: "·";
2591
- position: absolute;
2592
- left: 0.25rem;
2593
- top: -1px;
2594
- opacity: 0.6;
2595
- }
2596
2779
 
2597
2780
  /* New message notification banner */
2598
2781
  .new-message-banner {
@@ -3311,7 +3494,7 @@ body {
3311
3494
  max-height: 18rem;
3312
3495
  overflow-y: auto;
3313
3496
  }
3314
- .tool-item-status { margin-left: auto; font-size: 0.6875rem; flex-shrink: 0; }
3497
+ .tool-item-status { font-size: 0.6875rem; flex-shrink: 0; }
3315
3498
  .tool-item-status.ok { color: var(--color-success); }
3316
3499
  .tool-item-status.err { color: var(--color-error); }
3317
3500
  .tool-item-status.running { color: var(--live); animation: pulse 1.6s ease-in-out infinite; }
@@ -3558,7 +3741,7 @@ body {
3558
3741
  opacity: 0.45;
3559
3742
  }
3560
3743
  [data-theme="dark"] #sib-id {
3561
- opacity: 0.6;
3744
+ opacity: 1;
3562
3745
  }
3563
3746
  [data-theme="dark"] #sib-dir {
3564
3747
  opacity: 0.85;
@@ -3570,7 +3753,7 @@ body {
3570
3753
  }
3571
3754
 
3572
3755
  #sib-id {
3573
- opacity: 0.45; /* tier 3 — glanceable */
3756
+ opacity: 1;
3574
3757
  font-size: 0.625rem;
3575
3758
  letter-spacing: 0.03em;
3576
3759
  flex-shrink: 0;
@@ -3585,7 +3768,6 @@ body {
3585
3768
  }
3586
3769
  .sib-id-clickable {
3587
3770
  cursor: pointer;
3588
- padding: 1px 0.25rem;
3589
3771
  border-radius: 3px;
3590
3772
  transition: all 0.15s ease;
3591
3773
  }
@@ -11774,6 +11956,113 @@ body.setup-mode[data-theme="dark"] {
11774
11956
  .swatch-sunrise-orange { background: #F59E0B; }
11775
11957
  .swatch-rose-violet { background: #8B5CF6; }
11776
11958
  .swatch-coral-red { background: #EF4444; }
11959
+ .swatch-sky-blue { background: #38BDF8; }
11960
+ .swatch-hot-pink { background: #EC4899; }
11961
+ .swatch-warm-brown { background: #92745A; }
11962
+ .swatch-slate-gray { background: #6B7280; }
11963
+
11964
+ /* ── Background Theme Cards ─────────────────────────────────────────── */
11965
+ .settings-bg-theme-cards {
11966
+ display: flex;
11967
+ flex-wrap: wrap;
11968
+ gap: 0.75rem;
11969
+ padding: 0.25rem 0;
11970
+ }
11971
+
11972
+ .settings-bg-theme-card {
11973
+ display: flex;
11974
+ flex-direction: column;
11975
+ align-items: center;
11976
+ gap: 0.375rem;
11977
+ cursor: pointer;
11978
+ border: none;
11979
+ background: transparent;
11980
+ padding: 0;
11981
+ flex-shrink: 0;
11982
+ }
11983
+
11984
+ /* The thumbnail preview box */
11985
+ .settings-bg-theme-card .bg-card-preview {
11986
+ width: 4.5rem;
11987
+ height: 3rem;
11988
+ border-radius: 0.5rem;
11989
+ border: 2px solid var(--color-border-primary);
11990
+ position: relative;
11991
+ overflow: hidden;
11992
+ transition: border-color 0.15s, transform 0.15s;
11993
+ display: flex;
11994
+ flex-direction: row;
11995
+ }
11996
+
11997
+ .settings-bg-theme-card:hover .bg-card-preview {
11998
+ border-color: var(--color-border-strong);
11999
+ transform: translateY(-1px);
12000
+ }
12001
+
12002
+ .settings-bg-theme-card.active .bg-card-preview {
12003
+ border-color: var(--color-accent-primary);
12004
+ box-shadow: 0 0 0 2px var(--color-accent-soft);
12005
+ }
12006
+
12007
+ /* Active checkmark badge */
12008
+ .settings-bg-theme-card.active .bg-card-preview::after {
12009
+ content: "✓";
12010
+ position: absolute;
12011
+ top: 0.2rem;
12012
+ right: 0.3rem;
12013
+ font-size: 0.625rem;
12014
+ font-weight: 700;
12015
+ color: white;
12016
+ background: var(--color-accent-primary);
12017
+ border-radius: 50%;
12018
+ width: 0.875rem;
12019
+ height: 0.875rem;
12020
+ display: flex;
12021
+ align-items: center;
12022
+ justify-content: center;
12023
+ line-height: 1;
12024
+ }
12025
+
12026
+ /* Sidebar strip (left 35%) */
12027
+ .settings-bg-theme-card .bg-card-sidebar {
12028
+ width: 35%;
12029
+ height: 100%;
12030
+ border-radius: 0;
12031
+ }
12032
+
12033
+ /* Main area (right 65%) */
12034
+ .settings-bg-theme-card .bg-card-main {
12035
+ width: 65%;
12036
+ height: 100%;
12037
+ }
12038
+
12039
+ /* Per-theme colors */
12040
+ .bg-theme-light .bg-card-sidebar { background: #f5f5f3; }
12041
+ .bg-theme-light .bg-card-main { background: #ffffff; }
12042
+
12043
+ .bg-theme-dark .bg-card-sidebar { background: #101114; }
12044
+ .bg-theme-dark .bg-card-main { background: #16181d; }
12045
+
12046
+ .bg-theme-dim .bg-card-sidebar { background: #21262d; }
12047
+ .bg-theme-dim .bg-card-main { background: #2b3038; }
12048
+
12049
+ .bg-theme-warm .bg-card-sidebar { background: #f5f0e8; }
12050
+ .bg-theme-warm .bg-card-main { background: #fdf8f0; }
12051
+
12052
+ .bg-theme-black .bg-card-sidebar { background: #1e2228; }
12053
+ .bg-theme-black .bg-card-main { background: #252b33; }
12054
+
12055
+ /* Label under card */
12056
+ .settings-bg-theme-card .bg-card-label {
12057
+ font-size: 0.6875rem;
12058
+ color: var(--color-text-secondary);
12059
+ white-space: nowrap;
12060
+ }
12061
+
12062
+ .settings-bg-theme-card.active .bg-card-label {
12063
+ color: var(--color-accent-primary);
12064
+ font-weight: 600;
12065
+ }
11777
12066
 
11778
12067
  /* ── Sessions List ───────────────────────────────────────────────────── */
11779
12068
  .billing-sessions-row {
@@ -436,7 +436,34 @@ const Modal = (() => {
436
436
  });
437
437
  }
438
438
 
439
- /** Show a text input prompt dialog. Returns a Promise<string|null>. */
439
+ /**
440
+ * Like confirm(), but shows an extra opt-in checkbox (unchecked by default).
441
+ * Resolves { ok, checked } so the caller can act on both the decision and
442
+ * the checkbox state.
443
+ */
444
+ function confirmWithCheckbox(message, checkboxLabel) {
445
+ return new Promise(resolve => {
446
+ const overlay = $("modal-overlay");
447
+ $("modal-message").textContent = message;
448
+ $("modal-skip-text").textContent = checkboxLabel;
449
+ $("modal-skip-cb").checked = false;
450
+ $("modal-skip-label").style.display = "flex";
451
+ if (overlay.parentNode !== document.body || overlay.nextSibling) {
452
+ document.body.appendChild(overlay);
453
+ }
454
+ overlay.style.display = "flex";
455
+
456
+ const cleanup = (ok) => {
457
+ overlay.style.display = "none";
458
+ $("modal-skip-label").style.display = "none";
459
+ $("modal-yes").onclick = null;
460
+ $("modal-no").onclick = null;
461
+ resolve({ ok, checked: ok && $("modal-skip-cb").checked });
462
+ };
463
+ $("modal-yes").onclick = () => cleanup(true);
464
+ $("modal-no").onclick = () => cleanup(false);
465
+ });
466
+ }
440
467
  function prompt(message, defaultValue = "") {
441
468
  return new Promise(resolve => {
442
469
  const overlay = $("prompt-modal-overlay");
@@ -530,7 +557,7 @@ const Modal = (() => {
530
557
  });
531
558
  }
532
559
 
533
- return { confirm, confirmOnce, prompt, rename };
560
+ return { confirm, confirmOnce, confirmWithCheckbox, prompt, rename };
534
561
  })();
535
562
 
536
563
  Clacky.Modal = Modal;
@@ -714,10 +741,6 @@ window.mobileCloseSidebar = _mobileCloseSidebar;
714
741
 
715
742
  // ── Theme / session-scoped message panel bindings ──────────────────────────
716
743
 
717
- // Theme toggle in header
718
- if ($("theme-toggle-header")) {
719
- $("theme-toggle-header").addEventListener("click", () => Theme.toggle());
720
- }
721
744
  // btn-delete-session, #messages scroll-to-top (load history), and btn-interrupt
722
745
  // moved to sessions.js (_initMessageHistory in Sessions.init()).
723
746
 
@@ -162,16 +162,20 @@ const Onboard = (() => {
162
162
  dropdown.appendChild(placeholder);
163
163
 
164
164
  _providers.forEach(p => {
165
+ // Prefer i18n key (localised); fall back to shipped English `name`.
166
+ const translated = p.name_key ? I18n.t(p.name_key) : null;
167
+ const displayName = (translated && translated !== p.name_key) ? translated : p.name;
168
+
165
169
  const opt = document.createElement("div");
166
170
  opt.className = "custom-select-option";
167
171
  opt.dataset.value = p.id;
168
- opt.dataset.label = p.name;
172
+ opt.dataset.label = displayName;
169
173
  if (p.id === "openclacky") {
170
174
  const nameSpan = document.createElement("span");
171
- nameSpan.textContent = p.name;
175
+ nameSpan.textContent = displayName;
172
176
  opt.innerHTML = nameSpan.outerHTML + ` <span class="provider-badge-recommended">${I18n.t("provider.recommended")}</span>`;
173
177
  } else {
174
- opt.textContent = p.name;
178
+ opt.textContent = displayName;
175
179
  }
176
180
  dropdown.appendChild(opt);
177
181
  });
@@ -24,6 +24,7 @@ const ExtensionsStore = (() => {
24
24
  let _query = ""; // current search text
25
25
  let _sort = "downloads"; // "newest" | "updated" | "downloads"
26
26
  let _filterInstalled = false; // when true, show only installed extensions
27
+ let _filterBrand = false; // when true, show only brand-private extensions
27
28
  let _loading = false;
28
29
  let _error = null; // soft warning when the store is unreachable
29
30
  let _detail = null; // currently opened extension detail, or null
@@ -53,6 +54,7 @@ const ExtensionsStore = (() => {
53
54
  get query() { return _query; },
54
55
  get sort() { return _sort; },
55
56
  get filterInstalled() { return _filterInstalled; },
57
+ get filterBrand() { return _filterBrand; },
56
58
  get loading() { return _loading; },
57
59
  get error() { return _error; },
58
60
  get detail() { return _detail; },
@@ -67,6 +69,7 @@ const ExtensionsStore = (() => {
67
69
  /** Fetch the catalog from the server for the current query + sort. */
68
70
  async load() {
69
71
  if (_filterInstalled) return;
72
+ if (_filterBrand) return Extensions.loadBrandExtensions();
70
73
  _loading = true;
71
74
  _error = null;
72
75
  _emit("extensions:loading");
@@ -97,18 +100,22 @@ const ExtensionsStore = (() => {
97
100
  setQuery(query) {
98
101
  _query = (query || "").trim();
99
102
  if (_filterInstalled) return Extensions.setFilterInstalled(true);
103
+ if (_filterBrand) return Extensions.loadBrandExtensions();
100
104
  return Extensions.load();
101
105
  },
102
106
 
103
107
  /** Set the sort order and reload. */
104
108
  setSort(sort) {
105
109
  _sort = sort || "downloads";
110
+ if (_filterBrand) return Extensions.loadBrandExtensions();
111
+ if (_filterInstalled) return Extensions.setFilterInstalled(true);
106
112
  return Extensions.load();
107
113
  },
108
114
 
109
115
  /** Toggle the "installed only" filter — fetches from local store when enabled. */
110
116
  async setFilterInstalled(onlyInstalled) {
111
117
  _filterInstalled = !!onlyInstalled;
118
+ _filterBrand = false;
112
119
  if (_filterInstalled) {
113
120
  _loading = true;
114
121
  _emit("extensions:loading");
@@ -132,6 +139,40 @@ const ExtensionsStore = (() => {
132
139
  }
133
140
  },
134
141
 
142
+ /** Toggle the "brand only" filter — fetches brand-private extensions. */
143
+ async setFilterBrand(onlyBrand) {
144
+ _filterBrand = !!onlyBrand;
145
+ _filterInstalled = false;
146
+ if (_filterBrand) {
147
+ return Extensions.loadBrandExtensions();
148
+ } else {
149
+ return Extensions.load();
150
+ }
151
+ },
152
+
153
+ /** Fetch brand-private extensions from /api/store/extensions/brand. */
154
+ async loadBrandExtensions() {
155
+ _loading = true;
156
+ _error = null;
157
+ _emit("extensions:loading");
158
+ try {
159
+ const res = await fetch("/api/store/extensions/brand");
160
+ const data = await res.json();
161
+ _extensions = data.extensions || [];
162
+ _error = data.warning || null;
163
+ _loading = false;
164
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
165
+ } catch (e) {
166
+ console.error("[Extensions] loadBrandExtensions failed", e);
167
+ _extensions = [];
168
+ _error = I18n.t("extensions.loadFailed");
169
+ _loading = false;
170
+ _emit("extensions:error", { network: true });
171
+ } finally {
172
+ _loading = false;
173
+ }
174
+ },
175
+
135
176
  /** Open the detail view for one extension (fetches contributes + versions). */
136
177
  async loadDetail(id) {
137
178
  if (!id) return;
@@ -159,6 +200,17 @@ const ExtensionsStore = (() => {
159
200
  }
160
201
  },
161
202
 
203
+ /** Fetch /api/brand/status and return { branded: bool }. */
204
+ async fetchBrandStatus() {
205
+ try {
206
+ const res = await fetch("/api/brand/status");
207
+ const data = await res.json();
208
+ return data;
209
+ } catch (_e) {
210
+ return { branded: false };
211
+ }
212
+ },
213
+
162
214
  /** Close the detail view. */
163
215
  closeDetail() {
164
216
  _detail = null;
@@ -218,13 +270,13 @@ const ExtensionsStore = (() => {
218
270
  },
219
271
 
220
272
  /** Remove an installed extension, then return to the list. */
221
- async uninstall(id) {
273
+ async uninstall(id, purgeData = false) {
222
274
  if (!id) return;
223
275
  try {
224
276
  const res = await fetch("/api/store/extension", {
225
277
  method: "DELETE",
226
278
  headers: { "Content-Type": "application/json" },
227
- body: JSON.stringify({ id }),
279
+ body: JSON.stringify({ id, purge_data: purgeData }),
228
280
  });
229
281
  const data = await res.json();
230
282
  if (!res.ok || !data.ok) throw new Error(data.error || "uninstall failed");