From e9f5e3e91b42cf16fc851bb76aa36757edcbc9c4 Mon Sep 17 00:00:00 2001 From: Blackwell Code <161588956+Blackwellcode@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:51:18 +0100 Subject: [PATCH 01/30] feat(webui): enforce CSP with per-request script nonces script-src drops 'unsafe-inline': inline ", mobile_app_head_tags(), flags=re.S): + digest = hashlib.sha256(body.encode("utf-8")).digest() + _CSP_INJECTED_SCRIPT_HASHES.append("'sha256-" + base64.b64encode(digest).decode("ascii") + "'") + return _CSP_INJECTED_SCRIPT_HASHES + + +# Third-party hosts the UI talks to (chat emotes: BTTV/FFZ/7TV, sub emotes: ivr.fi; +# clip editor slider: cdnjs). Keep in sync with static/chat_emotes.js. +CSP_SCRIPT_HOSTS = "https://cdn.jsdelivr.net https://cdnjs.cloudflare.com" +CSP_STYLE_HOSTS = "https://fonts.googleapis.com https://cdnjs.cloudflare.com" +CSP_IMG_HOSTS = ( + "https://static-cdn.jtvnw.net https://badges.twitch.tv https://cdn.discordapp.com " + "https://cdn.betterttv.net https://cdn.frankerfacez.com https://cdn.7tv.app" +) +CSP_CONNECT_HOSTS = ( + "https://*.googleapis.com https://clients3.google.com " + "https://api.betterttv.net https://api.frankerfacez.com https://7tv.io https://api.ivr.fi" +) + + @app.after_request def set_security_headers(response): response = inject_mobile_app_head(response) @@ -10633,24 +10678,29 @@ def set_security_headers(response): response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" # HSTS is set once by nginx (avoid duplicate Strict-Transport-Security headers). - # Default is Report-Only. Set VOD_CSP_ENFORCE=true after overlay/player QA. + # Enforced by default; VOD_CSP_ENFORCE=false falls back to Report-Only. + # Inline - {% endif %} @@ -64,29 +70,29 @@
{{ _('START') }} - - + + 00:00 - - - + + +
- - + +
{{ _('END') }} - - - + + + 00:30 - - + +
@@ -96,7 +102,7 @@ - + @@ -109,11 +115,15 @@ {% if is_live %}{% endif %} - diff --git a/WEBUI/templates/components/js_i18n.html b/WEBUI/templates/components/js_i18n.html index 4392815..3693ced 100644 --- a/WEBUI/templates/components/js_i18n.html +++ b/WEBUI/templates/components/js_i18n.html @@ -1,4 +1,4 @@ - diff --git a/WEBUI/templates/files.html b/WEBUI/templates/files.html index e7c37ec..4c53c70 100644 --- a/WEBUI/templates/files.html +++ b/WEBUI/templates/files.html @@ -6,7 +6,7 @@ {% block header_actions %} - + {{ _('Back to dashboard') }} {% endblock %} @@ -34,8 +34,8 @@

{{ _('Upload new file') }}

- - + +
{{ _('Drop a file here or click to select') }}
@@ -61,7 +61,7 @@

{{ _('Archived files (%(count)s)', count=files|length) }}

- +
{{ f.size_str }}
@@ -76,13 +76,13 @@

{{ _('Archived files (%(count)s)', count=files|length) }}

{% endif %} {% if public_shares_enabled %} - {% endif %} -
@@ -96,10 +96,14 @@

{{ _('Archived files (%(count)s)', count=files|length) }}

{% endblock %} {% block scripts %} - {% endblock %} diff --git a/WEBUI/templates/hls.html b/WEBUI/templates/hls.html index e391d29..1869af5 100644 --- a/WEBUI/templates/hls.html +++ b/WEBUI/templates/hls.html @@ -4,8 +4,7 @@ HLS {{ filename }} - - + {% include 'components/styles.html' %}
@@ -43,7 +42,7 @@ {% include 'components/js_i18n.html' %} - - - - diff --git a/WEBUI/templates/streamer.html b/WEBUI/templates/streamer.html index 4152881..1844df1 100644 --- a/WEBUI/templates/streamer.html +++ b/WEBUI/templates/streamer.html @@ -5,14 +5,21 @@ {% block page_subtitle %}{{ _('Channel archive & recording metrics') }}{% endblock %} {% block content %} + {#- CSS url() values are only emitted for same-origin paths or https URLs without quote, + paren, backslash, angle-bracket or whitespace characters (prevents CSS injection). -#} + {% set sv_css_bad_chars = ["'", '"', '(', ')', '\\', '<', '>'] %} + {% set sv_banner_raw = (banner_url or '')|string %} + {% set banner_css_url = sv_banner_raw if (sv_banner_raw and ((sv_banner_raw[:1] == '/' and sv_banner_raw[:2] != '//') or sv_banner_raw[:8] == 'https://') and (sv_banner_raw.split()|join('')) == sv_banner_raw and not (sv_css_bad_chars|select('in', sv_banner_raw)|list)) else '' %} + {% set sv_hero_raw = ((hero_stream.thumbnail_url if hero_stream else '') or '')|string %} + {% set hero_css_url = sv_hero_raw if (sv_hero_raw and ((sv_hero_raw[:1] == '/' and sv_hero_raw[:2] != '//') or sv_hero_raw[:8] == 'https://') and (sv_hero_raw.split()|join('')) == sv_hero_raw and not (sv_css_bad_chars|select('in', sv_hero_raw)|list)) else '' %}
+ style="{% if banner_css_url %}background: linear-gradient(to bottom, rgba(7, 11, 20, 0.35) 0%, rgba(7, 11, 20, 0.90) 100%), url('{{ banner_css_url }}') center/cover no-repeat;{% endif %}" + {% if session.user_role == 'admin' %}data-streamer-open="banner" role="button" tabindex="0" title="{{ _('Customize channel banner') }}"{% endif %}> {% if session.user_role == 'admin' %}
- + {{ _('Change banner') }}
{% endif %} @@ -20,7 +27,7 @@
+ {% if session.user_role == 'admin' %}data-streamer-open="avatar" role="button" tabindex="0" title="{{ _('Customize profile picture') }}"{% endif %}> {% if avatar_url %} {{ streamer }} {% else %} @@ -28,7 +35,7 @@ {% endif %} {% if session.user_role == 'admin' %}
- + {{ _('CHANGE') }}
{% endif %} @@ -68,11 +75,11 @@

{{ display_name or streamer }}

- ⚙️ {{ _('CHANNEL MANAGEMENT (ADMIN)') }} + {{ _('CHANNEL MANAGEMENT (ADMIN)') }}
{{ _('Visible to:') }} - @@ -83,19 +90,19 @@

{{ display_name or streamer }}

-
-
-
@@ -136,7 +143,7 @@

{{ display_name or streamer }}

{% if hero_stream and (twitch_live or online) %}
{% if hero_stream.thumbnail_url %} -
+
{% endif %}
@@ -178,7 +185,7 @@

{{ hero_stream.title }}

{{ _('LIVE') }} {% if not is_mobile %}
- +
{% endif %} @@ -201,10 +208,10 @@

{{ hero_stream.title }}

- + {% if categories %} - {% for cat in categories %} @@ -212,7 +219,7 @@

{{ hero_stream.title }}

{% endif %} - @@ -246,12 +253,23 @@

{{ hero_stream.title }}

{% endif %} +
{% for f in files %}
- +
@@ -331,7 +349,7 @@

{{ hero_stream.title }}

{% endif %} - {% endif %} @@ -511,7 +559,7 @@

(function() { var qInput = document.querySelector('form input[name="q"]'); var catSelect = document.querySelector('form select[name="cat"]'); diff --git a/WEBUI/templates/twitch_live_hub.html b/WEBUI/templates/twitch_live_hub.html index 9da0861..c5fdfaf 100644 --- a/WEBUI/templates/twitch_live_hub.html +++ b/WEBUI/templates/twitch_live_hub.html @@ -5,8 +5,7 @@ Live · StreamVault - - + {% include 'components/styles.html' %} @@ -106,7 +105,7 @@

{{ _('Recently watched') }}

- - - + + - - - + {% include 'components/styles.html' %} {% include 'components/js_i18n.html' %} {% block head %}{% endblock %} + {{ _('Skip to content') }} {% include 'components/sidebar.html' %}
@@ -27,9 +35,10 @@

{% block page_title %}StreamVault{% endblock %}

{% if not is_mobile %} {% endif %} @@ -40,14 +49,14 @@

{% block page_title %}StreamVault{% endblock %}

{% endif %} -
+
{% block content %}{% endblock %}
{% block scripts %}{% endblock %} - - - {% endif %} From 7221095b16d5adc085bd7c19ce09f235505b70ae Mon Sep 17 00:00:00 2001 From: Blackwell Code <161588956+Blackwellcode@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:58:07 +0100 Subject: [PATCH 05/30] feat(ux): confirmation dialog for restart, maintenance and destructive admin actions New static/sv_confirm.js: forms or submit buttons with data-confirm open a native (focus moves in, Escape cancels, focus returns) with an action-labelled confirm button instead of window.confirm(). admin.html: "Restart services" and "Enable maintenance mode" ask first and explain the consequence; restart uses the secondary style, enable maintenance a danger outline. Share revoke and backup delete use the same dialog. Tabs bind via data attributes (no onclick), keep roving tabindex, Home/End keys, aria-labelledby on panels and visually hidden h2s (no h1->h3 jump). Status/error boxes are live regions, the allowlist field has a label, and the content is a
landmark with skip link. The 1024px breakpoint that let the 68px rail overlap content (769-1024px) now matches the rail breakpoint (768px). Co-Authored-By: Claude Opus 5.5 --- WEBUI/static/sv_confirm.js | 114 +++++++++++++++++++++++++ WEBUI/templates/admin.html | 68 +++++++++------ WEBUI/templates/components/styles.html | 8 +- 3 files changed, 162 insertions(+), 28 deletions(-) create mode 100644 WEBUI/static/sv_confirm.js diff --git a/WEBUI/static/sv_confirm.js b/WEBUI/static/sv_confirm.js new file mode 100644 index 0000000..83ed75b --- /dev/null +++ b/WEBUI/static/sv_confirm.js @@ -0,0 +1,114 @@ +/* Accessible confirmation dialog for forms and buttons (replaces inline confirm() handlers). + * + *
+ * - - - + + + +
-
+
+

{{ _('System & recorder') }}

{{ _('System status') }}

{{ _('Load average (1m / 5m):') }} {{ sys_load[0] }} / {{ sys_load[1] }}
{{ _('Storage (total):') }} {{ storage.total_str }}
{{ _('Storage (free):') }} {{ storage.free_str }}
- + - +
@@ -364,7 +368,8 @@

{{ _('Recent task errors') }}

-
+
+

{{ _('Settings') }}

{{ _('Maintenance mode & area control') }}

@@ -400,14 +405,16 @@

{{ _('Maintenance mode & area control') }}

{% else %} -
+ -
@@ -431,8 +438,8 @@

{{ _('Maintenance mode & area control') }}

- - + + @@ -475,7 +482,8 @@

{{ _('Clip settings') }}

-
+
+

{{ _('Security & shares') }}

{{ _('Role model: full access (admin), archive viewer (viewer), live only (live_only) & Discord member.') }} @@ -504,7 +512,7 @@

{{ _('Active shares') }}

{{ share.expires }} {{ _('Open') }} -
+
@@ -576,7 +584,8 @@

{{ _('Login attempts') }}

-
+
+

{{ _('System backups') }}

@@ -614,9 +623,9 @@

{{ _('System backups') }}

{{ _('Download') }} -
+ - +
@@ -633,9 +642,9 @@

{{ _('System backups') }}

-
+
- From d1dfd73ee2a024eef72a0c73ff4a4ba5ff6fc181 Mon Sep 17 00:00:00 2001 From: Blackwell Code <161588956+Blackwellcode@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:58:07 +0100 Subject: [PATCH 06/30] fix(a11y): user/group modals as with focus management and labels The six modals in admin_users.html are native s opened with showModal(): focus moves to the first field, Escape and backdrop click close, focus returns to the button that opened them. 12 label/for pairs, group headings no longer use
{% endblock %}{% block scripts %} - diff --git a/WEBUI/templates/mobile_streamer.html b/WEBUI/templates/mobile_streamer.html index 52e33e7..aa9baab 100644 --- a/WEBUI/templates/mobile_streamer.html +++ b/WEBUI/templates/mobile_streamer.html @@ -3,7 +3,7 @@ {{ display_name or streamer }} · StreamVault Mobile - + {% include 'components/styles.html' %} @@ -11,8 +11,8 @@
- - + +

{{ display_name or streamer }}

@@ -52,18 +52,18 @@

{{ displa {% endif %} {% endif %} {% if session.user_role == 'admin' %} -
+ - +
{% endif %}

-
+ - - + + +
{% endif %} @@ -159,22 +162,45 @@

{{ displa

-
- - +
+
+ + +
+ + diff --git a/WEBUI/templates/search.html b/WEBUI/templates/search.html index 109f506..7479f72 100644 --- a/WEBUI/templates/search.html +++ b/WEBUI/templates/search.html @@ -20,7 +20,7 @@

{{ _('Full-text search query') }}

- +
+ + + + {% endif %} @@ -45,8 +47,8 @@ > {{ _('Your browser does not support the video tag.') }} -
@@ -123,7 +123,8 @@

{{ _('Archived files (%(count)s)', count=files|length) }}

} async function deleteFile(btn, url) { - if (!confirm({{ _('Really delete file?')|tojson }})) return; + const message = btn.getAttribute('data-confirm') || {{ _('Really delete file?')|tojson }}; + if (!(await window.svConfirm(message, btn.getAttribute('data-confirm-ok'), true))) return; const fd = new FormData(); fd.append('csrf_token', '{{ csrf_token() }}'); const res = await fetch(url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}); diff --git a/WEBUI/templates/vod.html b/WEBUI/templates/vod.html index 93a2f51..04fb886 100644 --- a/WEBUI/templates/vod.html +++ b/WEBUI/templates/vod.html @@ -1089,9 +1089,7 @@

{{ _('Create clip') }}

} async function deleteItem(btn, url) { - const confirmed = window.svConfirm - ? await window.svConfirm({{ _('Delete this video? The file is removed from the archive and cannot be restored.')|tojson }}, {{ _('Delete')|tojson }}, true) - : window.confirm({{ _('Delete this video? The file is removed from the archive and cannot be restored.')|tojson }}); + const confirmed = await window.svConfirm({{ _('Delete this video? The file is removed from the archive and cannot be restored.')|tojson }}, {{ _('Delete')|tojson }}, true); if (!confirmed) return; const card = btn.closest('.video-card'); btn.disabled = true; From 196df904ea5ea9c3085b3ad704f61aa6247476cd Mon Sep 17 00:00:00 2001 From: Blackwell Code <161588956+Blackwellcode@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:45:17 +0100 Subject: [PATCH 26/30] style(a11y): exclude .sv-btn from legacy button rules, tokens instead of literals; PWA banner above the bottom nav - The legacy page-wide button selectors (49 in style.css and tube_design_system.css) now read button:where(:not(.sv-btn)); :where() keeps their specificity. The out-specifying html body .sv-btn.sv-btn... block in a11y.css is gone, the dialog buttons use plain .sv-btn--* rules. Browser check: primary #4D63FF, danger #D42A40, white text, restart button transparent with field edge #66789C. - New tokens --nebula-text-on-accent, --nebula-accent-primary-hover, --nebula-danger-bg-hover, --sv-bottom-nav-h; a11y.css has no hex literal left (41 var() fallbacks dropped). - Below 768px the injected PWA install banner sits above the bottom navigation (375px: banner bottom 740px, nav top 751px). Co-Authored-By: Claude Opus 5.5 --- WEBUI/static/css/a11y.css | 104 ++++++++++++------------ WEBUI/static/css/tokens.css | 4 + WEBUI/static/css/tube_design_system.css | 22 ++--- WEBUI/static/style.css | 76 ++++++++--------- 4 files changed, 105 insertions(+), 101 deletions(-) diff --git a/WEBUI/static/css/a11y.css b/WEBUI/static/css/a11y.css index d4d1c5b..53acd1e 100644 --- a/WEBUI/static/css/a11y.css +++ b/WEBUI/static/css/a11y.css @@ -15,7 +15,7 @@ /* One focus language: outline in --nebula-focus-ring (>= 3:1 on every surface). */ :focus-visible, :where(a, button, input, select, textarea, summary, [tabindex], [role="button"], [role="tab"], [contenteditable="true"]):focus-visible { - outline: 2px solid var(--nebula-focus-ring, #8494FF) !important; + outline: 2px solid var(--nebula-focus-ring) !important; outline-offset: 2px !important; } :where(input, select, textarea):focus-visible { @@ -25,7 +25,7 @@ .vod-control-bar button:focus-visible, .mobile-player button:focus-visible, #vodPlayer:focus-visible { - outline: 2px solid var(--nebula-focus-ring, #8494FF) !important; + outline: 2px solid var(--nebula-focus-ring) !important; outline-offset: 2px !important; box-shadow: 0 0 0 4px rgba(7, 11, 20, 0.85) !important; } @@ -37,10 +37,10 @@ left: 8px; z-index: 2147483000; padding: 10px 16px; - border-radius: var(--nebula-radius-sm, 8px); - background: var(--nebula-bg-card, #151F33); - color: var(--nebula-text-main, #F3F5FC); - font: 600 14px/1.2 var(--nebula-font-sans, system-ui, sans-serif); + border-radius: var(--nebula-radius-sm); + background: var(--nebula-bg-card); + color: var(--nebula-text-main); + font: 600 14px/1.2 var(--nebula-font-sans); text-decoration: none; transform: translateY(-200%); } @@ -54,7 +54,7 @@ main[tabindex="-1"]:focus { /* Form fields: visible edges (>= 3:1, WCAG 1.4.11) */ :where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="hidden"], [type="color"]), select, textarea) { - border-color: var(--nebula-field-edge, #66789C) !important; + border-color: var(--nebula-field-edge) !important; } /* Target size (WCAG 2.5.8): 20px box + 2px margin keeps a 24px spacing circle free. */ @@ -64,7 +64,7 @@ input[type="radio"] { block-size: 20px !important; min-inline-size: 20px !important; margin: 2px !important; - accent-color: var(--nebula-accent-primary, #4D63FF); + accent-color: var(--nebula-accent-primary); cursor: pointer; } .vod-channel-link, @@ -84,10 +84,10 @@ button, [role="button"], input[type="submit"], input[type="button"] { inline-size: min(440px, calc(100vw - 32px)); max-block-size: calc(100dvh - 32px); padding: 0; - border: 1px solid var(--nebula-border-bright, #3B4E73); - border-radius: var(--nebula-radius-lg, 16px); - background: var(--nebula-bg-surface, #111A2B); - color: var(--nebula-text-main, #F3F5FC); + border: 1px solid var(--nebula-border-bright); + border-radius: var(--nebula-radius-lg); + background: var(--nebula-bg-surface); + color: var(--nebula-text-main); box-shadow: 0 24px 64px rgba(0, 0, 0, 0.7); overflow: auto; } @@ -103,27 +103,27 @@ button, [role="button"], input[type="submit"], input[type="button"] { align-items: center; gap: 10px; padding: 14px 20px; - background: var(--nebula-bg-card, #151F33); + background: var(--nebula-bg-card); } .sv-dialog__head { justify-content: space-between; - border-bottom: 1px solid var(--nebula-border, #26344F); + border-bottom: 1px solid var(--nebula-border); } .sv-dialog__foot { justify-content: flex-end; - border-top: 1px solid var(--nebula-border, #26344F); + border-top: 1px solid var(--nebula-border); } .sv-dialog__title-row { display: flex; align-items: center; gap: 8px; - color: var(--nebula-accent-text, #8494FF); + color: var(--nebula-accent-text); } .sv-dialog__title { margin: 0; font-size: 16px; font-weight: 700; - color: var(--nebula-text-main, #F3F5FC); + color: var(--nebula-text-main); } .sv-dialog__body { display: flex; @@ -138,22 +138,22 @@ button, [role="button"], input[type="submit"], input[type="button"] { min-inline-size: 32px; min-block-size: 32px; border: none; - border-radius: var(--nebula-radius-sm, 8px); + border-radius: var(--nebula-radius-sm); background: transparent; - color: var(--nebula-text-secondary, #A2AEC8); + color: var(--nebula-text-secondary); font-size: 22px; line-height: 1; cursor: pointer; } .sv-dialog__close:hover { - background: var(--nebula-bg-hover, #1A2740); - color: var(--nebula-text-main, #F3F5FC); + background: var(--nebula-bg-hover); + color: var(--nebula-text-main); } .sv-confirm__msg { margin: 0; font-size: 14px; line-height: 1.5; - color: var(--nebula-text-secondary, #A2AEC8); + color: var(--nebula-text-secondary); } .sv-field { display: flex; @@ -163,16 +163,16 @@ button, [role="button"], input[type="submit"], input[type="button"] { .sv-field > label { font-size: 13px; font-weight: 600; - color: var(--nebula-text-secondary, #A2AEC8); + color: var(--nebula-text-secondary); } .sv-field > :is(input, select, textarea) { inline-size: 100%; box-sizing: border-box; padding: 10px 14px; - border: 1px solid var(--nebula-field-edge, #66789C); - border-radius: var(--nebula-radius-sm, 8px); - background: var(--nebula-bg-deep, #0B1220); - color: var(--nebula-text-main, #F3F5FC); + border: 1px solid var(--nebula-field-edge); + border-radius: var(--nebula-radius-sm); + background: var(--nebula-bg-deep); + color: var(--nebula-text-main); font-size: 14px; } @@ -185,37 +185,37 @@ button, [role="button"], input[type="submit"], input[type="button"] { min-block-size: 36px; padding: 8px 16px; border: 1px solid transparent; - border-radius: var(--nebula-radius-sm, 8px); - font: 600 13px/1.2 var(--nebula-font-sans, system-ui, sans-serif); + border-radius: var(--nebula-radius-sm); + font: 600 13px/1.2 var(--nebula-font-sans); cursor: pointer; transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; } .sv-btn--primary { - background: var(--nebula-accent-primary, #4D63FF); - color: #FFFFFF; + background: var(--nebula-accent-primary); + color: var(--nebula-text-on-accent); } .sv-btn--primary:hover { - background: #3F55F0; + background: var(--nebula-accent-primary-hover); } .sv-btn--secondary { background: transparent; - border-color: var(--nebula-field-edge, #66789C); - color: var(--nebula-text-main, #F3F5FC); + border-color: var(--nebula-field-edge); + color: var(--nebula-text-main); } .sv-btn--secondary:hover { - background: var(--nebula-bg-hover, #1A2740); + background: var(--nebula-bg-hover); } .sv-btn--danger { - background: var(--nebula-danger-bg, #D42A40); - color: #FFFFFF; + background: var(--nebula-danger-bg); + color: var(--nebula-text-on-accent); } .sv-btn--danger:hover { - background: #BC2236; + background: var(--nebula-danger-bg-hover); } .sv-btn--danger-outline { background: transparent; - border-color: var(--nebula-status-live-text, #F2667A); - color: var(--nebula-status-live-text, #F2667A); + border-color: var(--nebula-status-live-text); + color: var(--nebula-status-live-text); } .sv-btn--danger-outline:hover { background: rgba(240, 68, 88, 0.12); @@ -228,7 +228,7 @@ button, [role="button"], input[type="submit"], input[type="button"] { /* Alerts */ .sv-alert { padding: 10px 14px; - border-radius: var(--nebula-radius-sm, 8px); + border-radius: var(--nebula-radius-sm); border: 1px solid transparent; font-size: 13px; font-weight: 500; @@ -236,12 +236,12 @@ button, [role="button"], input[type="submit"], input[type="button"] { .sv-alert--error { background: rgba(240, 68, 88, 0.15); border-color: rgba(240, 68, 88, 0.45); - color: var(--nebula-status-live-text, #F2667A); + color: var(--nebula-status-live-text); } .sv-alert--success { background: rgba(72, 214, 162, 0.12); border-color: rgba(72, 214, 162, 0.45); - color: var(--nebula-status-success, #48D6A2); + color: var(--nebula-status-success); } /* Bottom navigation <= 768px for desktop-UA pages (rail is hidden there) */ @@ -255,22 +255,15 @@ button, [role="button"], input[type="submit"], input[type="button"] { } .mobile-global-nav a.active, .mobile-global-nav a[aria-current="page"] { - color: var(--nebula-accent-text, #8494FF) !important; + color: var(--nebula-accent-text) !important; } .mobile-global-nav a.active svg { - stroke: var(--nebula-accent-text, #8494FF) !important; + stroke: var(--nebula-accent-text) !important; } .mobile-global-nav span { font-size: 12px; } -/* Legacy page CSS styles every submit button as primary with !important; the - dialog/confirm button variants and the bottom-nav logout must keep their own look. */ -html body .sv-btn.sv-btn.sv-btn--primary.sv-btn--primary { background: var(--nebula-accent-primary) !important; color: #FFFFFF !important; border-color: transparent !important; } -html body .sv-btn.sv-btn.sv-btn--secondary.sv-btn--secondary { background: transparent !important; color: var(--nebula-text-main) !important; border: 1px solid var(--nebula-field-edge) !important; box-shadow: none !important; } -html body .sv-btn.sv-btn.sv-btn--secondary.sv-btn--secondary:hover { background: var(--nebula-bg-hover) !important; } -html body .sv-btn.sv-btn.sv-btn--danger.sv-btn--danger { background: var(--nebula-danger-bg) !important; color: #FFFFFF !important; border-color: transparent !important; } -html body .sv-btn.sv-btn.sv-btn--danger-outline.sv-btn--danger-outline { background: transparent !important; color: var(--nebula-status-live-text) !important; border: 1px solid var(--nebula-status-live-text) !important; box-shadow: none !important; } body .mobile-global-nav .sv-bottom-nav__logout > button { background: transparent !important; border: none !important; @@ -282,3 +275,10 @@ body .mobile-global-nav .sv-bottom-nav__logout > button { margin: 0 !important; } body .mobile-global-nav .sv-bottom-nav__logout > button:hover { color: var(--nebula-text-main) !important; } + +/* PWA install banner (injected by webui.py) sits above the bottom navigation, not on it. */ +@media (max-width: 768px) { + body:has(.mobile-global-nav) .ns-pwa-install { + bottom: calc(var(--sv-bottom-nav-h) + 8px + env(safe-area-inset-bottom, 0px)); + } +} diff --git a/WEBUI/static/css/tokens.css b/WEBUI/static/css/tokens.css index a121fbd..5284152 100644 --- a/WEBUI/static/css/tokens.css +++ b/WEBUI/static/css/tokens.css @@ -34,6 +34,9 @@ --nebula-status-warning: #F2BC61; /* Warnung (Amber/Gold) */ --nebula-status-live-text: #F2667A; /* Live/Fehler als Text: >= 4.5:1 auf allen Flaechen */ --nebula-danger-bg: #D42A40; /* Danger-Buttons: Weiss darauf 4.99:1 */ + --nebula-danger-bg-hover: #BC2236; + --nebula-accent-primary-hover: #3F55F0; + --nebula-text-on-accent: #FFFFFF; /* Text auf Primaer-/Danger-Flaechen (4.62:1 / 4.99:1) */ /* Typography Colors */ --nebula-text-main: #F3F5FC; /* Haupttext (Strahlendes Eisweiß) */ @@ -75,6 +78,7 @@ --sv-text-5xl: 40px; --sv-text-6xl: 48px; --nebula-rail-width: 72px; + --sv-bottom-nav-h: 64px; /* Hoehe der unteren Navigationsleiste ohne safe-area */ --sv-min-text: 12px; /* Mindestschriftgroesse fuer Labels/Metadaten */ /* 2. NAMESPACE MAPPINGS FOR SYSTEM INTEGRATION */ diff --git a/WEBUI/static/css/tube_design_system.css b/WEBUI/static/css/tube_design_system.css index ed3786f..94404ef 100644 --- a/WEBUI/static/css/tube_design_system.css +++ b/WEBUI/static/css/tube_design_system.css @@ -730,9 +730,9 @@ body.ns-platform.page-admin input:focus, body.ns-platform.page-stalk-admin input:focus { border-color: var(--ns-brand-crimson) !important; } -body.ns-platform.page-admin button[type="submit"], +body.ns-platform.page-admin button:where(:not(.sv-btn))[type="submit"]:where(:not(.sv-btn)), body.ns-platform.page-admin .btn-primary, -body.ns-platform.page-stalk-admin button[type="submit"], +body.ns-platform.page-stalk-admin button:where(:not(.sv-btn))[type="submit"]:where(:not(.sv-btn)), body.ns-platform.page-stalk-admin .btn-primary { background: var(--ns-brand-crimson) !important; color: #fff !important; @@ -744,8 +744,8 @@ body.ns-platform.page-stalk-admin .btn-primary { cursor: pointer !important; transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease, transform 0.15s ease !important; } -body.ns-platform.page-admin button[type="submit"]:hover, -body.ns-platform.page-stalk-admin button[type="submit"]:hover { +body.ns-platform.page-admin button:where(:not(.sv-btn))[type="submit"]:hover, +body.ns-platform.page-stalk-admin button:where(:not(.sv-btn))[type="submit"]:hover { background: var(--ns-brand-crimson-hover) !important; } /* 5. User Management & Data Tables */ @@ -851,8 +851,8 @@ body .ns-command-rail { width: 100% !important; } .ns-command-rail .ns-logout-btn button.ns-rail-item, -body .ns-command-rail .ns-logout-btn button.ns-rail-item, -body:is(.page-index, .page-channels, .page-streamer, .page-admin, .page-files, .page-search, .page-discord-tool, .page-vod, .page-live, .page-stalk) .ns-command-rail .ns-logout-btn button { +body .ns-command-rail .ns-logout-btn button:where(:not(.sv-btn)).ns-rail-item, +body:is(.page-index, .page-channels, .page-streamer, .page-admin, .page-files, .page-search, .page-discord-tool, .page-vod, .page-live, .page-stalk) .ns-command-rail .ns-logout-btn button:where(:not(.sv-btn)) { width: 44px !important; height: 44px !important; border-radius: var(--ns-radius-md, 10px) !important; @@ -868,7 +868,7 @@ body:is(.page-index, .page-channels, .page-streamer, .page-admin, .page-files, . margin: 0 !important; } .ns-command-rail .ns-logout-btn button.ns-rail-item:hover, -body .ns-command-rail .ns-logout-btn button.ns-rail-item:hover { +body .ns-command-rail .ns-logout-btn button:where(:not(.sv-btn)).ns-rail-item:hover { color: #ef4444 !important; background-color: rgba(239, 68, 68, 0.12) !important; border-color: rgba(239, 68, 68, 0.3) !important; @@ -2602,8 +2602,8 @@ body.page-login input:focus { border-color: var(--nebula-accent-primary) !important; box-shadow: 0 0 12px rgba(77, 99, 255, 0.4) !important; } -body.page-login button#login-submit, -body.page-login button[type="submit"] { +body.page-login button:where(:not(.sv-btn))#login-submit, +body.page-login button:where(:not(.sv-btn))[type="submit"] { background: var(--nebula-gradient-primary) !important; color: #FFFFFF !important; font-weight: 700 !important; @@ -2614,8 +2614,8 @@ body.page-login button[type="submit"] { transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease, transform 0.2s ease !important; cursor: pointer; } -body.page-login button#login-submit:hover, -body.page-login button[type="submit"]:hover { +body.page-login button:where(:not(.sv-btn))#login-submit:hover, +body.page-login button:where(:not(.sv-btn))[type="submit"]:hover { background: linear-gradient(135deg, #5B70FF 0%, #A276FF 100%) !important; box-shadow: 0 6px 28px rgba(77, 99, 255, 0.7), 0 0 16px rgba(187, 120, 255, 0.4) !important; transform: translateY(-1px); diff --git a/WEBUI/static/style.css b/WEBUI/static/style.css index 16405b0..2d0ca61 100644 --- a/WEBUI/static/style.css +++ b/WEBUI/static/style.css @@ -213,7 +213,7 @@ body.page-vod .chat-filter-bar label { font-size: var(--sv-min-text); white-space: nowrap; } -body.page-vod .chat-filter-bar button { +body.page-vod .chat-filter-bar button:where(:not(.sv-btn)) { width: 32px; height: 32px; padding: 0; @@ -551,7 +551,7 @@ body.page-login input::placeholder { body.page-login input:focus { transform: translateY(-1px); } -body.page-login button { +body.page-login button:where(:not(.sv-btn)) { width: 100%; margin-top: 10px; font-size: var(--sv-text-md); @@ -560,7 +560,7 @@ body.page-login button { cursor: pointer; transition: transform .18s ease, box-shadow .18s ease, filter .18s ease; } -body.page-login button:hover { +body.page-login button:where(:not(.sv-btn)):hover { transform: translateY(-2px); box-shadow: 0 24px 46px rgba(255, 83, 23, 0.34); filter: brightness(1.04); @@ -852,7 +852,7 @@ body.page-admin .success { body.page-admin .fail { color: var(--danger); } -body.page-admin button { +body.page-admin button:where(:not(.sv-btn)) { background: none; border: none; cursor: pointer; @@ -987,7 +987,7 @@ body.page-mail label span { } body.page-admin .btn-primary:disabled, body.page-admin input:disabled, -body.page-admin button:disabled { +body.page-admin button:where(:not(.sv-btn)):disabled { opacity: .48; cursor: not-allowed; transform: none; @@ -1248,7 +1248,7 @@ body.page-discord-tool select { margin-bottom: 16px; appearance: none; } -body.page-discord-tool button { +body.page-discord-tool button:where(:not(.sv-btn)) { width: 100%; padding: 12px; border: 0; @@ -1259,7 +1259,7 @@ body.page-discord-tool button { cursor: pointer; transition: background 0.2s; } -body.page-discord-tool button:hover { +body.page-discord-tool button:where(:not(.sv-btn)):hover { background: var(--primary-hover); } body.page-discord-tool .btn-danger { @@ -1625,7 +1625,7 @@ body.page-streamer .video-card { body.page-streamer .video-card:hover { box-shadow: 0 20px 40px -10px rgba(245, 158, 11, 0.15); } -body.page-streamer button { +body.page-streamer button:where(:not(.sv-btn)) { background: none; border: none; cursor: pointer; @@ -1912,7 +1912,7 @@ body.page-vod input { background: rgba(0,0,0,0.3); color: white; } -body.page-vod button { +body.page-vod button:where(:not(.sv-btn)) { padding: 10px 20px; border: 0; border-radius: var(--nebula-radius-md); @@ -1924,7 +1924,7 @@ body.page-vod button { position: relative; overflow: hidden; } -body.page-vod button::after { +body.page-vod button:where(:not(.sv-btn))::after { content: ''; position: absolute; top: 0; @@ -1934,11 +1934,11 @@ body.page-vod button::after { background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); transition: 0.5s; } -body.page-vod button:hover { +body.page-vod button:where(:not(.sv-btn)):hover { transform: scale(1.05); box-shadow: 0 0 15px rgba(245, 158, 11, 0.3); } -body.page-vod button:hover::after { +body.page-vod button:where(:not(.sv-btn)):hover::after { left: 100%; } body.page-vod .note { @@ -2075,7 +2075,7 @@ body.page-hls .note { padding: 0 28px 16px; color: var(--muted); } -body.page-hls button { +body.page-hls button:where(:not(.sv-btn)) { padding: 10px 14px; border: 0; border-radius: var(--nebula-radius-sm); @@ -2177,7 +2177,7 @@ body.page-live input { background: #0f1218; color: var(--ink); } -body.page-live button { +body.page-live button:where(:not(.sv-btn)) { padding: 8px 12px; border: 0; border-radius: var(--nebula-radius-sm); @@ -3131,7 +3131,7 @@ body.page-search input:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2); } -body.page-search button { +body.page-search button:where(:not(.sv-btn)) { background: linear-gradient(135deg, var(--primary), var(--accent)); color: white; padding: 12px 24px; @@ -3143,7 +3143,7 @@ body.page-search button { transition: 0.3s; margin-top: 16px; } -body.page-search button:hover { +body.page-search button:where(:not(.sv-btn)):hover { transform: scale(1.02); box-shadow: 0 0 20px rgba(245, 158, 11, 0.3); } @@ -3237,13 +3237,13 @@ body.page-index .avatar, body.page-index .avatar img { border-radius: var(--nebula-radius-sm) !important; } -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) button, +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) button:where(:not(.sv-btn)), body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) .btn, body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) .btn-primary { transform: none !important; transition: background .18s ease, border-color .18s ease, color .18s ease, transform .18s ease !important; } -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) button:hover, +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) button:where(:not(.sv-btn)):hover, body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) .btn:hover { transform: translateY(-1px) !important; } @@ -3442,7 +3442,7 @@ body.page-index .avatar.online { border-color: var(--ns-purple) !important; box-shadow: 0 0 0 4px rgba(145, 71, 255, 0.14), 0 0 28px rgba(145, 71, 255, 0.24) !important; } -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) button, +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) button:where(:not(.sv-btn)), body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) .btn, body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) .btn-primary { min-height: 36px !important; @@ -3456,7 +3456,7 @@ body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page- text-decoration: none !important; box-shadow: none !important; } -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) button:hover, +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) button:where(:not(.sv-btn)):hover, body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool) .btn:hover { border-color: rgba(145,71,255,0.55) !important; background: rgba(145,71,255,0.18) !important; @@ -3851,7 +3851,7 @@ body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page- } .studio-btn, body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live) .btn-icon, -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live) button { +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live) button:where(:not(.sv-btn)) { min-height: 38px; padding: 9px 13px !important; display: inline-flex !important; @@ -3869,7 +3869,7 @@ body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page- } .studio-btn:hover, body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live) .btn-icon:hover, -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live) button:hover { +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live) button:where(:not(.sv-btn)):hover { background: rgba(255,255,255,.09) !important; border-color: var(--ns-line-strong) !important; } @@ -4263,7 +4263,7 @@ body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page- color: #fff !important; letter-spacing: 0 !important; } -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live,.page-hls,.page-mplus) :is( button, .btn, .btn-action, .btn-restart, .action-btn, .btn-primary, .studio-btn, .mplus-primary, .mplus-link.buttonlike ) { +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live,.page-hls,.page-mplus) :is( button:where(:not(.sv-btn)), .btn, .btn-action, .btn-restart, .action-btn, .btn-primary, .studio-btn, .mplus-primary, .mplus-link.buttonlike ) { border-radius: var(--nebula-radius-xs) !important; border: 1px solid var(--ns-line-strong) !important; color: var(--ns-text) !important; @@ -4273,12 +4273,12 @@ body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page- font-weight: 850 !important; letter-spacing: 0 !important; } -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live,.page-hls,.page-mplus) :is( .btn-primary, .studio-btn.primary, .action-btn.primary, .mplus-primary, .mplus-link.buttonlike, .live-input-group button, .channel-input-group button ) { +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live,.page-hls,.page-mplus) :is( .btn-primary, .studio-btn.primary, .action-btn.primary, .mplus-primary, .mplus-link.buttonlike, .live-input-group button:where(:not(.sv-btn)), .channel-input-group button:where(:not(.sv-btn)) ) { color: #06110f !important; background: linear-gradient(135deg, var(--ns-green), var(--ns-gold)) !important; border-color: rgba(255,255,255,0.2) !important; } -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live,.page-hls,.page-mplus) :is(button,.btn,.btn-action,.btn-restart,.action-btn,.btn-primary,.studio-btn,.mplus-primary):hover { +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-files,.page-search,.page-discord-tool,.page-vod,.page-live,.page-hls,.page-mplus) :is(button:where(:not(.sv-btn)),.btn,.btn-action,.btn-restart,.action-btn,.btn-primary,.studio-btn,.mplus-primary):hover { border-color: rgba(101,216,172,0.42) !important; filter: brightness(1.04); } @@ -4580,7 +4580,7 @@ body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .pa border-color: rgba(242, 229, 141, 0.7) !important; box-shadow: 0 0 0 3px rgba(242, 229, 141, 0.08) !important; } -body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .page-admin, .page-mail, .page-files, .page-search, .page-discord-tool, .page-mplus, .page-twitch-live ) :is( button, .btn, .btn-action, .btn-restart, .action-btn, .btn-primary, .btn-secondary, .studio-btn, .vod-link-btn, .vod-icon-btn, .mplus-primary ) { +body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .page-admin, .page-mail, .page-files, .page-search, .page-discord-tool, .page-mplus, .page-twitch-live ) :is( button:where(:not(.sv-btn)), .btn, .btn-action, .btn-restart, .action-btn, .btn-primary, .btn-secondary, .studio-btn, .vod-link-btn, .vod-icon-btn, .mplus-primary ) { min-height: 38px !important; padding: 8px 13px !important; border: 1px solid var(--tube-line) !important; @@ -4594,19 +4594,19 @@ body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .pa font-weight: 750 !important; text-decoration: none !important; } -body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .page-admin, .page-mail, .page-files, .page-search, .page-discord-tool, .page-mplus, .page-twitch-live ) :is( button, .btn, .btn-action, .btn-restart, .action-btn, .btn-primary, .btn-secondary, .studio-btn, .vod-link-btn, .vod-icon-btn, .mplus-primary ):hover { +body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .page-admin, .page-mail, .page-files, .page-search, .page-discord-tool, .page-mplus, .page-twitch-live ) :is( button:where(:not(.sv-btn)), .btn, .btn-action, .btn-restart, .action-btn, .btn-primary, .btn-secondary, .studio-btn, .vod-link-btn, .vod-icon-btn, .mplus-primary ):hover { color: var(--tube-text) !important; background: var(--tube-surface-3) !important; border-color: #3a3a3a !important; filter: none !important; transform: none !important; } -body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .page-admin, .page-mail, .page-files, .page-search, .page-discord-tool, .page-mplus, .page-twitch-live ) :is( .btn-primary, .studio-btn.primary, .action-btn.primary, .vod-primary-action, .mplus-primary, .live-input-group button, .channel-input-group button ) { +body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .page-admin, .page-mail, .page-files, .page-search, .page-discord-tool, .page-mplus, .page-twitch-live ) :is( .btn-primary, .studio-btn.primary, .action-btn.primary, .vod-primary-action, .mplus-primary, .live-input-group button:where(:not(.sv-btn)), .channel-input-group button:where(:not(.sv-btn)) ) { color: #11100a !important; background: var(--tube-accent) !important; border-color: var(--tube-accent) !important; } -body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .page-admin, .page-mail, .page-files, .page-search, .page-discord-tool, .page-mplus, .page-twitch-live ) :is( .btn-primary, .studio-btn.primary, .action-btn.primary, .vod-primary-action, .mplus-primary, .live-input-group button, .channel-input-group button ):hover { +body:is( .page-index, .page-channels, .page-streamer, .page-vod, .page-live, .page-admin, .page-mail, .page-files, .page-search, .page-discord-tool, .page-mplus, .page-twitch-live ) :is( .btn-primary, .studio-btn.primary, .action-btn.primary, .vod-primary-action, .mplus-primary, .live-input-group button:where(:not(.sv-btn)), .channel-input-group button:where(:not(.sv-btn)) ):hover { color: #11100a !important; background: var(--tube-accent-hover) !important; border-color: var(--tube-accent-hover) !important; @@ -4879,7 +4879,7 @@ body.page-login .login-form label { body.page-login input { min-height: 44px !important; } -body.page-login button { +body.page-login button:where(:not(.sv-btn)) { min-height: 44px !important; border: 1px solid var(--tube-accent) !important; border-radius: var(--nebula-radius-sm) !important; @@ -5675,7 +5675,7 @@ body:is(.page-vod, .page-live) .vod-clip-nudge-group span { font-size: var(--sv-min-text) !important; font-weight: 850 !important; } -body:is(.page-vod, .page-live) .vod-clip-nudge-group button, +body:is(.page-vod, .page-live) .vod-clip-nudge-group button:where(:not(.sv-btn)), body:is(.page-vod, .page-live) .vod-clip-secondary, body:is(.page-vod, .page-live) .vod-clip-primary { min-height: 34px !important; @@ -5687,7 +5687,7 @@ body:is(.page-vod, .page-live) .vod-clip-primary { font-weight: 800 !important; cursor: pointer !important; } -body:is(.page-vod, .page-live) .vod-clip-nudge-group button:hover, +body:is(.page-vod, .page-live) .vod-clip-nudge-group button:where(:not(.sv-btn)):hover, body:is(.page-vod, .page-live) .vod-clip-secondary:hover { border-color: rgba(242, 229, 141, 0.28) !important; background: rgba(255, 255, 255, 0.08) !important; @@ -6284,7 +6284,7 @@ body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-mail,.page-f border-radius: var(--nebula-radius-sm) !important; font-size: var(--sv-text-xs) !important; } -body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-mail,.page-files,.page-search,.page-discord-tool) :is( button, .btn, .btn-action, .btn-restart, .action-btn, .btn-primary, .btn-secondary ) { +body:is(.page-index,.page-channels,.page-streamer,.page-admin,.page-mail,.page-files,.page-search,.page-discord-tool) :is( button:where(:not(.sv-btn)), .btn, .btn-action, .btn-restart, .action-btn, .btn-primary, .btn-secondary ) { min-height: 34px !important; padding: 6px 10px !important; border-radius: var(--nebula-radius-sm) !important; @@ -6620,8 +6620,8 @@ body.page-index .btn-secondary.danger { border-color: rgba(239, 116, 116, 0.28) !important; background: rgba(239, 116, 116, 0.08) !important; } -body.page-index :is(.btn-primary,.btn-secondary,.btn-ghost,button,select):disabled, -body.page-index :is(.btn-primary,.btn-secondary,.btn-ghost,button,select)[aria-disabled="true"] { +body.page-index :is(.btn-primary,.btn-secondary,.btn-ghost,button:where(:not(.sv-btn)),select):disabled, +body.page-index :is(.btn-primary,.btn-secondary,.btn-ghost,button:where(:not(.sv-btn)),select)[aria-disabled="true"] { opacity: 0.48 !important; cursor: not-allowed !important; box-shadow: none !important; @@ -6802,7 +6802,7 @@ body.page-index .streamer-menu-popover { box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34); } body.page-index .streamer-menu-popover a, -body.page-index .streamer-menu-popover button { +body.page-index .streamer-menu-popover button:where(:not(.sv-btn)) { width: 100% !important; min-height: 34px !important; padding: 0 9px !important; @@ -6819,9 +6819,9 @@ body.page-index .streamer-menu-popover button { cursor: pointer; } body.page-index .streamer-menu-popover a:hover, -body.page-index .streamer-menu-popover button:hover, +body.page-index .streamer-menu-popover button:where(:not(.sv-btn)):hover, body.page-index .streamer-menu-popover a:focus-visible, -body.page-index .streamer-menu-popover button:focus-visible { +body.page-index .streamer-menu-popover button:where(:not(.sv-btn)):focus-visible { color: var(--text-primary) !important; background: var(--surface-hover) !important; } From f1443bdb7686267cabdbbf59ed9607f834285d67 Mon Sep 17 00:00:00 2001 From: Blackwell Code <161588956+Blackwellcode@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:45:18 +0100 Subject: [PATCH 27/30] fix(a11y): unique field ids in the add/edit storage provider modals Both modals rendered the provider fields with id="cfg_"; ids are now prefixed with the modal container, labels point at them. Co-Authored-By: Claude Opus 5.5 --- WEBUI/templates/storage.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WEBUI/templates/storage.html b/WEBUI/templates/storage.html index 28da819..64e4211 100644 --- a/WEBUI/templates/storage.html +++ b/WEBUI/templates/storage.html @@ -463,11 +463,11 @@

{{ _('Edit provider') }}

.replace(/'/g, '''); } - function fieldMarkup(field, values) { + function fieldMarkup(field, values, idPrefix) { const value = values && values[field.key] !== undefined ? values[field.key] : (field.default || ''); const setFlag = values && values[field.key + '__set']; const key = escapeHtml(field.key); - const id = 'cfg_' + key; + const id = idPrefix + '-cfg_' + key; // unique per modal (add and edit render the same fields) let input = ''; if (field.type === 'select') { input = ' + {%- if _rail %} + + {%- else %} + + {%- endif %} + diff --git a/WEBUI/templates/components/sidebar.html b/WEBUI/templates/components/sidebar.html index 8f7ffeb..d81c606 100644 --- a/WEBUI/templates/components/sidebar.html +++ b/WEBUI/templates/components/sidebar.html @@ -65,12 +65,7 @@ -
- - -
+ {% with logout_all_variant = 'rail' %}{% include 'components/logout_everywhere.html' %}{% endwith %} {% endif %} @@ -96,12 +91,6 @@ {{ _('Log out') }} -
- - -
+ {% with logout_all_variant = 'nav' %}{% include 'components/logout_everywhere.html' %}{% endwith %} {% endif %} diff --git a/WEBUI/templates/mobile_index.html b/WEBUI/templates/mobile_index.html index 1410dcb..83757bf 100644 --- a/WEBUI/templates/mobile_index.html +++ b/WEBUI/templates/mobile_index.html @@ -196,13 +196,7 @@

- - - + {% with logout_all_variant = 'nav' %}{% include 'components/logout_everywhere.html' %}{% endwith %} {% include 'components/js_i18n.html' %} diff --git a/WEBUI/templates/mobile_streamer.html b/WEBUI/templates/mobile_streamer.html index 63cbdf0..f4e3220 100644 --- a/WEBUI/templates/mobile_streamer.html +++ b/WEBUI/templates/mobile_streamer.html @@ -184,13 +184,7 @@

{{ displa {{ _('Log out') }} -
- - -
+ {% with logout_all_variant = 'nav' %}{% include 'components/logout_everywhere.html' %}{% endwith %} {% include 'components/js_i18n.html' %} ", html.index("/static/overlay_common.js") + 1)) + + def test_parse_token_prefers_fragment_and_moves_query_token_into_it(self): + require_node(self) + script = """ +const o = require(%s); +const cases = [ + "https://h/tts/overlay?streamer=a&token=Q#toast=0", + "https://h/alerts/overlay#token=F", + "https://h/alerts/overlay?token=Q#token=F", + "https://h/alerts/overlay", +]; +process.stdout.write(JSON.stringify(cases.map(function (u) { + const r = o.parseToken(u); + return [r.token, r.fromQuery, r.cleanUrl, r.params.get("streamer"), r.params.get("toast"), r.params.has("token")]; +}))); +""" % json.dumps(self.COMMON) + result = json.loads(run_node(script)) + self.assertEqual(result, [ + ["Q", True, "https://h/tts/overlay?streamer=a#toast=0&token=Q", "a", "0", False], + ["F", False, "https://h/alerts/overlay#token=F", None, None, False], + ["F", False, "https://h/alerts/overlay#token=F", None, None, False], + ["", False, "https://h/alerts/overlay", None, None, False], + ]) + + def test_poller_backs_off_on_errors_and_stops_on_401(self): + require_node(self) + script = """ +const o = require(%s); +const results = ["error", "error", "error", "error", "error", "error", "ok", "unauthorized", "ok"]; +const delays = []; +let pending = null; +let unauthorized = 0; +let calls = 0; +const poller = o.createPoller(async function () { return results[calls++]; }, { + interval: 1000, maxInterval: 8000, + schedule: function (fn, ms) { delays.push(ms); pending = fn; return delays.length; }, + cancel: function () { pending = null; }, + onUnauthorized: function () { unauthorized += 1; }, +}); +(async function () { + const flush = function () { return new Promise(function (r) { setImmediate(r); }); }; + poller.start(); + await flush(); + for (let i = 0; i < 20 && pending; i++) { + const fn = pending; pending = null; + await fn(); + } + await flush(); + process.stdout.write(JSON.stringify({delays: delays, calls: calls, stopped: poller.stopped, unauthorized: unauthorized})); +})(); +""" % json.dumps(self.COMMON) + result = json.loads(run_node(script)) + self.assertEqual(result["delays"], [2000, 4000, 8000, 8000, 8000, 8000, 1000]) + self.assertEqual(result["calls"], 8) + self.assertTrue(result["stopped"]) + self.assertEqual(result["unauthorized"], 1) class LogoutEverywhereTemplateTests(IsolatedBaseTest): - """POST /logout/all is offered in the rail, the bottom nav and both mobile templates.""" - - def test_forms_post_with_csrf_and_confirmation(self): - from WEBUI import webui - webui.ensure_admin_user() - webui.app.config.update(TESTING=True) - client = webui.app.test_client() - with client.session_transaction() as sess: - now = time.time() - sess.update(logged_in=True, user_role="admin", username=config.VOD_USER, - login_time=now, last_active=now, _csrf_token="t") - cases = (("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0", 2), - ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148 Safari/604.1", 1)) - for ua, expected in cases: - with self.subTest(user_agent=ua.split(" ")[1]): - html = client.get("/", headers={"User-Agent": ua}).get_data(as_text=True) - forms = re.findall(r'
]*)>\s*' - r'', html) + """POST /logout/all (components/logout_everywhere.html) in the rail, the bottom nav and both + mobile templates, rendered with CSRF token and confirmation.""" + + DESKTOP = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0" + MOBILE = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148 Safari/604.1" + + def _forms(self, html): + return re.findall(r']*)>\s*' + r'', html) + + def test_dashboard_and_mobile_streamer_render_the_form(self): + (self.base / "teststreamer").mkdir() + client = admin_test_client() + cases = (("/", self.DESKTOP, 2), ("/", self.MOBILE, 1), ("/streamer/teststreamer", self.MOBILE, 1)) + for path, ua, expected in cases: + with self.subTest(path=path, mobile=ua is self.MOBILE): + response = client.get(path, headers={"User-Agent": ua}) + self.assertEqual(response.status_code, 200) + html = response.get_data(as_text=True) + if path.startswith("/streamer/"): + self.assertIn("mobile-body", html) # mobile_streamer.html, not the desktop page + forms = self._forms(html) self.assertEqual(len(forms), expected) for attrs in forms: self.assertIn("data-confirm=", attrs) - source = (TEMPLATES / "mobile_streamer.html").read_text(encoding="utf-8") - self.assertIn("url_for('logout_everywhere')", source) + self.assertIn("data-confirm-danger", attrs) + for name in ("components/sidebar.html", "mobile_index.html", "mobile_streamer.html"): + source = (TEMPLATES / name).read_text(encoding="utf-8") + self.assertIn("components/logout_everywhere.html", source) + self.assertNotIn("url_for('logout_everywhere')", source) + + +class ObsOverlayPanelTests(IsolatedBaseTest): + """Options > Settings recommends overlay URLs with the token in the fragment.""" + + def test_admin_panel_shows_fragment_token_urls(self): + html = admin_test_client().get("/admin").get_data(as_text=True) + for field_id, path in (("obsTtsUrl", "/tts/overlay?streamer=CHANNEL#token=TOKEN"), + ("obsAlertsUrl", "/alerts/overlay#token=TOKEN")): + with self.subTest(field=field_id): + match = re.search(r']*value="([^"]+)"' % field_id, html) + self.assertIsNotNone(match) + value = match.group(1) + self.assertTrue(value.endswith(path), value) + self.assertNotIn("?token=", value) + self.assertNotIn("&token=", value) + + +class NoNativeConfirmTests(unittest.TestCase): + def test_templates_use_the_confirmation_dialog(self): + for path in TEMPLATES.rglob("*.html"): + source = path.read_text(encoding="utf-8") + with self.subTest(template=path.name): + self.assertNotRegex(source, r"(?", source) + self.assertIn("renderFields('add-provider-fields'", source) + self.assertIn("renderFields('edit-provider-fields'", source) if __name__ == "__main__": diff --git a/tests/webui_test_helpers.py b/tests/webui_test_helpers.py new file mode 100644 index 0000000..f6dab05 --- /dev/null +++ b/tests/webui_test_helpers.py @@ -0,0 +1,18 @@ +"""Shared helpers for WebUI tests (not a test module itself).""" +import time + +from core import config + + +def admin_test_client(csrf_token="t"): + """Flask test client with a logged-in admin session and a fixed CSRF token.""" + from WEBUI import webui + + webui.ensure_admin_user() + webui.app.config.update(TESTING=True) + client = webui.app.test_client() + with client.session_transaction() as sess: + now = time.time() + sess.update(logged_in=True, user_role="admin", username=config.VOD_USER, + login_time=now, last_active=now, _csrf_token=csrf_token) + return client