kryten-webqueue 0.23.1__tar.gz → 0.24.0__tar.gz

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 (106) hide show
  1. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/CHANGELOG.md +9 -0
  2. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/static/css/main.css +40 -0
  4. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/user/dashboard.html +96 -2
  5. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/pyproject.toml +1 -1
  6. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/.github/workflows/python-publish.yml +0 -0
  7. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/.github/workflows/release.yml +0 -0
  8. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/.gitignore +0 -0
  9. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/README.md +0 -0
  10. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/config.example.json +0 -0
  11. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/deploy/kryten-webqueue.service +0 -0
  12. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/deploy/nginx-queue.conf +0 -0
  13. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  14. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/IMPL_API_GATE.md +0 -0
  15. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/IMPL_ECONOMY.md +0 -0
  16. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  17. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/IMPL_ROBOT.md +0 -0
  18. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  19. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/PRE_PLAN_GAPS.md +0 -0
  20. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/PRODUCT_PLAN.md +0 -0
  21. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  22. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/docs/UX_POLISH_PLAN.md +0 -0
  23. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/__init__.py +0 -0
  24. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/__main__.py +0 -0
  25. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  26. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/api_gate/client.py +0 -0
  27. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/app.py +0 -0
  28. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/auth/__init__.py +0 -0
  29. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/auth/otp.py +0 -0
  30. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  31. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/auth/session.py +0 -0
  32. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/catalog/__init__.py +0 -0
  33. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/catalog/db.py +0 -0
  34. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/catalog/images.py +0 -0
  35. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  36. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/catalog/sync.py +0 -0
  37. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/config.py +0 -0
  38. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/integrations/__init__.py +0 -0
  39. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  40. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  41. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  42. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  43. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  44. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  45. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  46. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  47. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/jobs/__init__.py +0 -0
  48. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  49. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/jobs/manager.py +0 -0
  50. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/jobs/tasks.py +0 -0
  51. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/logging_config.py +0 -0
  52. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/playlists/__init__.py +0 -0
  53. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  54. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/playlists/fire.py +0 -0
  55. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/playlists/importer.py +0 -0
  56. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/playlists/ordering.py +0 -0
  57. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  58. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/promos/__init__.py +0 -0
  59. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/promos/director.py +0 -0
  60. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/queue/__init__.py +0 -0
  61. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/queue/ordering.py +0 -0
  62. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/queue/poller.py +0 -0
  63. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/queue/presence.py +0 -0
  64. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/queue/shadow.py +0 -0
  65. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/__init__.py +0 -0
  66. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  67. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  68. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  69. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  70. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  71. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  72. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/auth.py +0 -0
  73. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/catalog.py +0 -0
  74. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/pages.py +0 -0
  75. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/queue.py +0 -0
  76. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/routes/user.py +0 -0
  77. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/static/js/main.js +0 -0
  78. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/admin/index.html +0 -0
  79. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  80. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  81. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  82. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  83. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/auth/login.html +0 -0
  84. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/base.html +0 -0
  85. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  86. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  87. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  88. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/templates/queue/index.html +0 -0
  89. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/ws/__init__.py +0 -0
  90. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/ws/handler.py +0 -0
  91. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/kryten_webqueue/ws/manager.py +0 -0
  92. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/__init__.py +0 -0
  93. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_config_persistence.py +0 -0
  94. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_fetchurls_sharepoint.py +0 -0
  95. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_phase1.py +0 -0
  96. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_phase2_jobs.py +0 -0
  97. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_phase3_jobs.py +0 -0
  98. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_phase4_live_fixes.py +0 -0
  99. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_presence_refund.py +0 -0
  101. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_promo_director.py +0 -0
  102. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_queue_announce.py +0 -0
  104. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_save_results_to_playlist.py +0 -0
  105. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_schedule_lock.py +0 -0
  106. {kryten_webqueue-0.23.1 → kryten_webqueue-0.24.0}/tests/test_search_facets.py +0 -0
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.24.0] — 2026-06-21
9
+
10
+ ### Added
11
+
12
+ - **Live readability preview for the chat-color picker.** The dashboard's chat-color editor now previews the chosen color exactly as it renders in chat — a sample chat line (gray timestamp, username, message) on the near-black chat background (`#111`) — and scores it as you pick, mirroring kryten-economy's server-side guard (combined APCA lightness contrast × red-chroma penalty). A contrast badge reports **ok / warn / reject**: colors that fail the readability threshold (very dark colors and harsh pure reds) show a **reject** badge and the **Save button is disabled**, so users can't buy a color the server would refuse; borderline colors get a non-blocking **warn**. The thresholds (`reject < 30`, `warn < 40`) and background match the economy service so the dashboard and server always agree.
13
+
14
+ [0.24.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.24.0
15
+
7
16
  ## [0.23.1] — 2026-06-21
8
17
 
9
18
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.23.1
3
+ Version: 0.24.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -1009,6 +1009,46 @@ a.np-chip {
1009
1009
  text-transform: uppercase;
1010
1010
  }
1011
1011
 
1012
+ /* Chat-color live preview + readability badge */
1013
+ .color-preview {
1014
+ background: #111;
1015
+ border: 1px solid var(--border);
1016
+ border-radius: var(--radius);
1017
+ padding: 0.6rem 0.75rem;
1018
+ margin: 0 0 0.6rem;
1019
+ font-size: 0.95rem;
1020
+ line-height: 1.5;
1021
+ }
1022
+ .color-preview .cp-ts { color: #AAA; }
1023
+ .color-preview .cp-name { font-weight: 700; }
1024
+ .color-preview .cp-msg { /* color set inline to the chosen value */ }
1025
+ .contrast-badge {
1026
+ display: flex;
1027
+ align-items: flex-start;
1028
+ gap: 0.5rem;
1029
+ font-size: 0.82rem;
1030
+ padding: 0.5rem 0.6rem;
1031
+ border-radius: var(--radius);
1032
+ margin-bottom: 0.75rem;
1033
+ border: 1px solid var(--border);
1034
+ }
1035
+ .contrast-badge .cb-icon { flex-shrink: 0; line-height: 1.3; }
1036
+ .contrast-badge.cb-ok {
1037
+ background: rgba(0, 184, 148, 0.10);
1038
+ border-color: var(--success, #00b894);
1039
+ color: var(--success, #00b894);
1040
+ }
1041
+ .contrast-badge.cb-warn {
1042
+ background: rgba(253, 203, 110, 0.12);
1043
+ border-color: var(--warning, #fdcb6e);
1044
+ color: var(--warning, #fdcb6e);
1045
+ }
1046
+ .contrast-badge.cb-reject {
1047
+ background: rgba(225, 112, 85, 0.12);
1048
+ border-color: var(--danger, #e17055);
1049
+ color: var(--danger, #e17055);
1050
+ }
1051
+
1012
1052
  /* Transaction filter toggle */
1013
1053
  .tx-toggle {
1014
1054
  display: flex;
@@ -293,15 +293,92 @@ async function saveGreeting() {
293
293
  }
294
294
  }
295
295
 
296
+ // ── Chat-color readability scoring (mirrors kryten-economy contrast.py) ──
297
+ // The economy server is authoritative and re-checks on purchase; this gives
298
+ // instant local preview/feedback. Defaults match the economy config defaults.
299
+ const CONTRAST_BG = '#111111';
300
+ const CONTRAST_MIN = 30.0; // reject below this combined score
301
+ const CONTRAST_WARN = 40.0; // warn below this
302
+ const CHROMA_KNEE = 160; // green+blue at which the red penalty clears
303
+
304
+ function _hexRgb(h) {
305
+ let s = h.replace('#', '');
306
+ if (s.length === 3) s = s.split('').map(c => c + c).join('');
307
+ return [parseInt(s.slice(0,2),16), parseInt(s.slice(2,4),16), parseInt(s.slice(4,6),16)];
308
+ }
309
+ function _screenY(h) {
310
+ const [r,g,b] = _hexRgb(h);
311
+ return 0.2126729*Math.pow(r/255,2.4) + 0.7151522*Math.pow(g/255,2.4) + 0.0721750*Math.pow(b/255,2.4);
312
+ }
313
+ function _apcaLc(textHex, bgHex) {
314
+ let yt = _screenY(textHex), yb = _screenY(bgHex);
315
+ if (yt <= 0.022) yt += Math.pow(0.022 - yt, 1.414);
316
+ if (yb <= 0.022) yb += Math.pow(0.022 - yb, 1.414);
317
+ if (Math.abs(yb - yt) < 0.0005) return 0;
318
+ let out;
319
+ if (yb > yt) { const s = (Math.pow(yb,0.56)-Math.pow(yt,0.57))*1.14; out = s < 0.1 ? 0 : s - 0.027; }
320
+ else { const s = (Math.pow(yb,0.65)-Math.pow(yt,0.62))*1.14; out = s > -0.1 ? 0 : s + 0.027; }
321
+ return Math.abs(out * 100);
322
+ }
323
+ function _chromaFactor(h) {
324
+ const [, g, b] = _hexRgb(h);
325
+ return Math.min(g + b, CHROMA_KNEE) / CHROMA_KNEE;
326
+ }
327
+ function classifyColor(hex) {
328
+ const lc = _apcaLc(hex, CONTRAST_BG);
329
+ const score = Math.round(lc * _chromaFactor(hex) * 10) / 10;
330
+ let level;
331
+ if (score < CONTRAST_MIN) level = 'reject';
332
+ else if (score < CONTRAST_WARN) level = 'warn';
333
+ else level = 'ok';
334
+ return { score, level };
335
+ }
336
+
337
+ function updateColorPreview(hex) {
338
+ const msg = document.getElementById('cp-msg');
339
+ const sw = document.getElementById('color-input');
340
+ const badge = document.getElementById('contrast-badge');
341
+ const save = document.getElementById('color-save');
342
+ const valid = /^#[0-9A-Fa-f]{6}$/.test(hex);
343
+ if (msg) msg.style.color = valid ? hex : '#FFFFFF';
344
+ if (!valid || !badge || !save) return;
345
+ const { score, level } = classifyColor(hex);
346
+ if (level === 'reject') {
347
+ badge.className = 'contrast-badge cb-reject';
348
+ badge.innerHTML = '<span class="cb-icon">⛔</span><span>Too hard to read on the dark chat background. ' +
349
+ 'Try a lighter shade, or add some green/blue (e.g. pink instead of pure red).</span>';
350
+ save.disabled = true;
351
+ save.title = 'This color is too low-contrast to use';
352
+ } else if (level === 'warn') {
353
+ badge.className = 'contrast-badge cb-warn';
354
+ badge.innerHTML = '<span class="cb-icon">⚠️</span><span>Readable, but low-contrast — viewers with vision ' +
355
+ 'issues may struggle to read your chat lines.</span>';
356
+ save.disabled = false;
357
+ save.title = '';
358
+ } else {
359
+ badge.className = 'contrast-badge cb-ok';
360
+ badge.innerHTML = '<span class="cb-icon">✅</span><span>Good contrast on the chat background.</span>';
361
+ save.disabled = false;
362
+ save.title = '';
363
+ }
364
+ }
365
+
296
366
  function editColor() {
297
367
  if (!accountState) return;
298
368
  const sym = accountState.currency_symbol || 'Z';
299
369
  const cur = (accountState.vanity && accountState.vanity.custom_color) || '';
300
370
  const cost = (accountState.vanity_costs && accountState.vanity_costs.custom_color) || 0;
301
371
  const seed = /^#[0-9A-Fa-f]{6}$/.test(cur) ? cur : '#6C5CE7';
372
+ const uname = (accountState.username) || 'You';
302
373
  showModal(`
303
374
  <h3>Chat color</h3>
304
375
  <p class="muted">Pick a color for your chat messages. Cost: ${fmt(cost, sym)} per update.</p>
376
+ <div class="color-preview">
377
+ <span class="cp-ts">[12:34:56]</span>
378
+ <span class="cp-name" id="cp-name">${escapeHtml(uname)}:</span>
379
+ <span class="cp-msg" id="cp-msg">This is how your chat messages will look.</span>
380
+ </div>
381
+ <div class="contrast-badge cb-ok" id="contrast-badge"></div>
305
382
  <div class="color-picker-row">
306
383
  <input type="color" id="color-input" value="${seed}">
307
384
  <input type="text" id="color-hex" class="color-hex" value="${escapeHtml(cur || seed)}" maxlength="7" placeholder="#RRGGBB">
@@ -312,14 +389,25 @@ function editColor() {
312
389
  </div>`);
313
390
  const picker = document.getElementById('color-input');
314
391
  const hex = document.getElementById('color-hex');
315
- picker.addEventListener('input', () => { hex.value = picker.value.toUpperCase(); });
392
+ const normalize = (v) => {
393
+ v = v.trim();
394
+ if (!v.startsWith('#')) v = '#' + v;
395
+ return v.toUpperCase();
396
+ };
397
+ picker.addEventListener('input', () => {
398
+ hex.value = picker.value.toUpperCase();
399
+ updateColorPreview(picker.value.toUpperCase());
400
+ });
316
401
  hex.addEventListener('input', () => {
317
402
  const val = hex.value.trim();
318
403
  if (/^#?[0-9A-Fa-f]{6}$/.test(val)) {
319
- picker.value = (val.startsWith('#') ? '' : '#') + val;
404
+ const norm = normalize(val);
405
+ picker.value = norm;
406
+ updateColorPreview(norm);
320
407
  }
321
408
  });
322
409
  document.getElementById('color-save').addEventListener('click', saveColor);
410
+ updateColorPreview(normalize(cur || seed));
323
411
  }
324
412
 
325
413
  async function saveColor() {
@@ -329,6 +417,12 @@ async function saveColor() {
329
417
  return;
330
418
  }
331
419
  if (!value.startsWith('#')) value = '#' + value;
420
+ value = value.toUpperCase();
421
+ // Block locally on reject (the server enforces this too).
422
+ if (classifyColor(value).level === 'reject') {
423
+ showToast('That color is too low-contrast to use on the chat background', 'error');
424
+ return;
425
+ }
332
426
  const btn = document.getElementById('color-save');
333
427
  btn.disabled = true;
334
428
  let resp;
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.23.1"
3
+ version = "0.24.0"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"