From 670ead30911030095b23b13eba9bd0388bf280f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:17:26 +0000 Subject: [PATCH] Kundenansicht neu Chat-Fix: Chat als Vollbild-Ansicht (Verlauf scrollt in sich, Eingabe unten, Spalte zentriert, "Chat" klein im Kopf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pilot-Feedback 17.10.: Der Chat wirkte durchsichtig und verrutscht. - Handy: Inhalt steht fest zwischen Kopf und Leiste (wie das Blatt); die Eingabe sitzt höchstens 6 px über der Leiste bzw. dem Widerrufsstreifen und bei offener Tastatur am unteren Rand des sichtbaren Bereichs. "Chat" steht klein im Kopf (die H1 bleibt nur für Screenreader), der Kopf ist deckend mit Haarlinie, der Verlauf beginnt direkt darunter und scrollt in sich. - Desktop: Spalte in Fensterhöhe, Chat höchstens 760 px breit und mittig, Verlauf füllt die Höhe, Eingabe am unteren Spaltenrand. - Verlauf steht unten (neueste Nachricht sichtbar), kurze Verläufe unten bei der Eingabe; Blasen fremd #f8f9fb, eigen #ebeef1. - "Senden" bleibt schwarz, auch bei leerem Feld (gesperrt im Ansichtsmodus weiter gedämpft). - haltFokus: Druck auf Senden, Anhang oder Mikrofon nimmt dem Textfeld den Fokus nicht; sonst kommt die Leiste zurück, schiebt die feste Eingabe hoch und der Tipp auf "Senden" geht bei offener Tastatur verloren (im Browser nachgestellt). public/chat-composer.js und die alte Ansicht bleiben unverändert. Co-Authored-By: Claude Sonnet 5.5 --- public/neu/ansicht-chat.js | 21 +++++++++- public/neu/ansichten.css | 15 ++++--- public/neu/huelle.css | 19 +++++++++ test/knChatVollbild.test.js | 81 +++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 test/knChatVollbild.test.js diff --git a/public/neu/ansicht-chat.js b/public/neu/ansicht-chat.js index 2969d13..041c9bc 100644 --- a/public/neu/ansicht-chat.js +++ b/public/neu/ansicht-chat.js @@ -58,20 +58,36 @@ export function verdrahteEingabe({ win = globalThis.window, doc = globalThis.doc } } -let aktiv = null // der laufende Chat: { timer, composer, eingabe, beobachter } +/** + * Druck auf einen Knopf der Eingabezeile (Senden, Anhang, Mikrofon) nimmt dem Textfeld den Fokus nicht. Sonst endet mit dem Fokus die Tastatur-Stellung der Hülle + * (leiste.js): die Leiste kommt zurück und schiebt die fest stehende Eingabe um ihre Höhe nach oben, bevor der Finger loslässt, und der Tipp auf „Senden“ + * geht ins Leere (Android). Das Textfeld behält den Fokus, die Tastatur bleibt wie in jedem Chat offen. Tab und Tastatur-Bedienung bleiben unberührt. + * Rückgabe { abbau } entfernt den Hörer. + */ +export function haltFokus(wurzel) { + const beiDruck = (ev) => { + const knopf = ev.target?.closest?.('button') + if (knopf && wurzel.contains(knopf)) ev.preventDefault() + } + wurzel.addEventListener('mousedown', beiDruck) + return { abbau() { wurzel.removeEventListener('mousedown', beiDruck) } } +} + +let aktiv = null // der laufende Chat: { timer, composer, eingabe, beobachter, fokus } function raeumeAuf() { if (!aktiv) return if (aktiv.timer) clearInterval(aktiv.timer) try { aktiv.composer?.destroy?.() } catch { /* schon abgebaut */ } try { aktiv.eingabe?.abbau() } catch { /* schon abgebaut */ } + try { aktiv.fokus?.abbau() } catch { /* schon abgebaut */ } try { aktiv.beobachter?.disconnect() } catch { /* schon abgebaut */ } aktiv = null } export async function zeigeChat(wurzel, { route, me, nav } = {}) { raeumeAuf() - const eigener = { timer: null, composer: null, eingabe: null, beobachter: null } + const eigener = { timer: null, composer: null, eingabe: null, beobachter: null, fokus: null } aktiv = eigener const gilt = () => aktiv === eigener leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …')) @@ -160,6 +176,7 @@ export async function zeigeChat(wurzel, { route, me, nav } = {}) { // Eingabe über der Tastatur halten; wird der Verlauf kleiner (Tastatur, Leiste), bleibt die neueste Nachricht sichtbar, sofern man unten war (E-Fix M5) eigener.eingabe = verdrahteEingabe({ win: globalThis.window, doc: globalThis.document, wurzel: composerWurzel }) + eigener.fokus = haltFokus(composerWurzel) if (typeof globalThis.ResizeObserver === 'function') { eigener.beobachter = new globalThis.ResizeObserver(() => { if (verlauf.dataset.stick !== 'false') verlauf.scrollTop = verlauf.scrollHeight }) eigener.beobachter.observe(verlauf) diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index 7467b6a..6f80df3 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -128,13 +128,16 @@ --chat-surface: var(--kn-bg); --chat-line: var(--kn-line-strong); --chat-muted: var(--kn-text-2); - --chat-bubble-in: var(--kn-bg-hover); + --chat-bubble-in: var(--kn-bg-subtle); --chat-bubble-out: var(--kn-grau-bg); --chat-track: var(--kn-spur); - display: flex; flex-direction: column; gap: var(--kn-sp-2); min-width: 0; + display: flex; flex: 1 1 auto; flex-direction: column; gap: var(--kn-sp-2); width: 100%; min-width: 0; min-height: 0; max-width: 760px; margin-inline: auto; } -.kn .kn-chat .chat-box { display: flex; flex-direction: column; gap: var(--kn-sp-2); min-width: 0; } -.kn .kn-chat .chat-messages { display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto; padding: 4px 0; } +.kn .kn-chat .chat-box { display: flex; flex: 1 1 auto; flex-direction: column; gap: var(--kn-sp-2); min-width: 0; min-height: 0; } +/* Der Verlauf füllt die Höhe zwischen Kopf und Eingabe und scrollt in sich; kurze Verläufe stehen unten, bei der Eingabe (Rahmen der Höhe: huelle.css) */ +.kn .kn-chat .chat-messages { display: flex; flex: 1 1 0; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 8px 0; } +.kn .kn-chat .chat-messages > :first-child { margin-top: auto; } +.kn .kn-chat .chat-messages > .kn-leer { margin: auto 0; padding: 24px 0; } .kn .kn-chat .chat-bubble { align-self: flex-start; max-width: 78%; padding: 8px 12px; border: 0; border-radius: var(--kn-radius-lg); background: var(--chat-bubble-in); color: var(--kn-text); overflow-wrap: anywhere; @@ -158,6 +161,7 @@ .kn .kn-chat .chat-icon-btn:hover:not(:disabled) { background: var(--kn-bg-hover); } .kn .kn-chat .chat-icon-btn[aria-expanded="true"], .kn .kn-chat .chat-icon-btn[aria-expanded="true"]:hover:not(:disabled) { background: var(--kn-accent-soft); box-shadow: 0 0 0 1px var(--kn-accent); } .kn .kn-chat .chat-icon-btn:disabled, .kn .kn-chat .chat-send:disabled { opacity: .55; cursor: not-allowed; } +.kn .kn-chat .chat-composer:not(.is-disabled) .chat-send:disabled:not(.is-busy) { opacity: 1; cursor: default; } /* „Senden“ bleibt der schwarze Hauptknopf, auch bei leerem Feld; gesperrt (Ansichtsmodus) wirkt er weiter grau */ .kn .kn-chat .chat-send.is-busy:disabled { opacity: .8; cursor: progress; } .kn .kn-chat .chat-rec { border-color: var(--kn-danger-ring); background: var(--kn-rot-bg); } .kn .kn-chat .chat-rec .chat-icon-btn { background: transparent; box-shadow: none; } @@ -171,7 +175,7 @@ .kn .kn-chat .chat-tray-item { border: 1px solid transparent; background: var(--kn-bg-hover); } .kn .kn-chat .chat-tray-item.is-error { border-color: var(--kn-danger); background: var(--kn-rot-bg); } .kn .kn-chat.chat-drop-active::after, .kn .kn-chat .chat-box.chat-drop-active::after { background: var(--kn-ablage-hell); color: var(--kn-text); border-color: var(--kn-text-3); } -.kn .kn-chat .kn-chat-composer { margin-top: 0; } +.kn .kn-chat .kn-chat-composer { flex: none; margin-top: 0; } .kn .kn-chat .kn-chat-kasten { position: relative; } .kn .kn-chat .kn-chat-verlauf { scroll-behavior: auto; } .kn .kn-chat .chat-composer { margin-top: 8px; } @@ -377,7 +381,6 @@ .kn-sperrzeile { margin-bottom: 8px; } .kn-bestellen .kn-knopf { width: 100%; min-height: 44px; } .kn .kn-chat .chat-bubble { max-width: 88%; } - .kn .kn-chat .chat-messages { min-height: 240px; max-height: 55vh; } .kn .kn-chat .chat-input-row textarea { font-size: 16px; } .kn .ab-portal .ab-karte-aktionen a, .kn .ab-portal .ab-knopf, .kn .ab-portal .ab-details > summary, .kn .ab-portal .ab-links a { min-height: 44px; } .kn .ab-portal .ab-knopf { width: 100%; } diff --git a/public/neu/huelle.css b/public/neu/huelle.css index 074706d..c97c626 100644 --- a/public/neu/huelle.css +++ b/public/neu/huelle.css @@ -173,6 +173,25 @@ } .kn-tastatur .kn-leiste { transform: translateY(110%); visibility: hidden; transition: transform 400ms var(--kn-bremse), visibility 0s 400ms; } +/* ---------- Chat: Vollbild-Ansicht (E-Fix Chat) ---------- + Solange der Bereich Chat zu sehen ist, füllt er die Höhe: Kopf und Eingabe stehen fest, nur der Verlauf scrollt in sich (Aussehen: ansichten.css). + Der Chat hat keine Etage, die Hauptseite ist hier nie inert. Desktop: Spalte in Fensterhöhe, die Eingabe am unteren Rand der zentrierten Spalte + (höchstens 760 px). Handy: der Inhalt steht fest zwischen Kopf und Leiste (wie das Blatt), „Chat“ steht klein im Kopf statt als große Überschrift. */ +.kn-spalte:has(#bereich-chat:not([hidden])) { display: flex; flex-direction: column; height: calc(100vh - var(--kn-banner-h)); height: calc(100dvh - var(--kn-banner-h)); min-height: 0; } +.kn-spalte:has(#bereich-chat:not([hidden])) > :not(.kn-haupt) { flex: none; } +.kn-haupt:has(#bereich-chat:not([hidden])) { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; max-width: none; padding-bottom: 24px; } +.kn-haupt > #bereich-chat:not([hidden]) { display: flex; flex: 1 1 auto; flex-direction: column; width: 100%; min-height: 0; max-width: 760px; margin: 0 auto; } +#bereich-chat > .kn-titel { flex: none; margin: 0 0 16px; } +#chat-body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; } + +@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) { + .kn-spalte:has(#bereich-chat:not([hidden])) { display: block; height: auto; } + .kn-haupt:has(#bereich-chat:not([hidden])) { position: fixed; inset: calc(var(--kn-banner-h) + var(--kn-kopf-h) + env(safe-area-inset-top)) 0 var(--kn-unten, var(--kn-zeile-h)); padding: 0 max(16px, env(safe-area-inset-right)) 6px max(16px, env(safe-area-inset-left)); background: var(--kn-bg); } + #bereich-chat > .kn-titel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } /* bleibt für Screenreader die Überschrift der Seite */ + .kn:has(#bereich-chat:not([hidden])) .kn-kopf { height: calc(var(--kn-kopf-h) + env(safe-area-inset-top)); border-bottom-color: var(--kn-line); } /* feste Höhe wie der Rand oben des Inhalts: im Querformat wäre der Kopf mit Haarlinie 1 px höher */ + .kn:has(#bereich-chat:not([hidden])) .kn-kopf-titel { opacity: 1; transform: none; } +} + /* ---------- Querformat (Handy gedreht): niedrigere Zeile, keine große Überschrift ---------- */ @media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) { diff --git a/test/knChatVollbild.test.js b/test/knChatVollbild.test.js new file mode 100644 index 0000000..4ae7960 --- /dev/null +++ b/test/knChatVollbild.test.js @@ -0,0 +1,81 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { haltFokus } from '../public/neu/ansicht-chat.js' +import { lies, handyTeil } from './support/knHuelle.js' + +// Chat als Vollbild-Ansicht (Pilot-Feedback 17.10.: „durchsichtig und verrutscht“). Die Lage prüfen die Ansichten kn-p2-chat-* des Werkzeugs im Browser; +// diese Pins lesen nur den Quelltext und halten die Regeln fest, an denen die Lage hängt. +const ANSICHTEN = lies('ansichten.css') +const HUELLE = lies('huelle.css') +const regel = (css, selektor) => { + const m = css.match(new RegExp(`(?:^|\\n)${selektor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{([^}]*)\\}`)) + return m ? m[1] : '' +} +const chatBlock = () => HUELLE.slice(HUELLE.indexOf('Chat: Vollbild-Ansicht')) + +test('Chat: der Verlauf füllt die Höhe und scrollt in sich, ohne feste Höhengrenze (kein 60vh/55vh mehr)', () => { + const verlauf = regel(ANSICHTEN, '.kn .kn-chat .chat-messages') + assert.match(verlauf, /flex:\s*1 1 0/) + assert.match(verlauf, /min-height:\s*0/) + assert.match(verlauf, /overflow-y:\s*auto/) + assert.doesNotMatch(verlauf, /max-height/) + assert.doesNotMatch(ANSICHTEN, /\.chat-messages\s*\{[^}]*(?:min-height:\s*(?:240|280)px|max-height:\s*\d+vh)/) + assert.match(ANSICHTEN, /\.kn \.kn-chat \.chat-messages > :first-child\s*\{\s*margin-top:\s*auto/) // kurze Verläufe stehen unten bei der Eingabe +}) + +test('Chat: Spalte höchstens 760 px, mittig; Eingabe fest unten', () => { + const spalte = ANSICHTEN.match(/\n\.kn-chat \{[^}]*\}/)[0] + assert.match(spalte, /max-width:\s*760px/) + assert.match(spalte, /margin-inline:\s*auto/) + assert.match(regel(ANSICHTEN, '.kn .kn-chat .kn-chat-composer'), /flex:\s*none/) + assert.match(regel(HUELLE, '.kn-haupt > #bereich-chat:not([hidden])'), /max-width:\s*760px[^}]*margin:\s*0 auto/) +}) + +test('Chat: Blasen ruhig (Fläche #f8f9fb für fremde, #ebeef1 für eigene), über Tokens', () => { + assert.match(ANSICHTEN, /--chat-bubble-in:\s*var\(--kn-bg-subtle\)/) + assert.match(ANSICHTEN, /--chat-bubble-out:\s*var\(--kn-grau-bg\)/) + const tokens = lies('tokens.css') + assert.match(tokens, /--kn-bg-subtle:\s*#f8f9fb/) + assert.match(tokens, /--kn-grau-bg:\s*#ebeef1/) +}) + +test('Chat: „Senden“ bleibt der schwarze Hauptknopf, auch bei leerem Feld; gesperrt (Ansichtsmodus) und beim Senden bleibt er gedämpft', () => { + assert.match(regel(ANSICHTEN, '.kn .kn-chat .chat-composer:not(.is-disabled) .chat-send:disabled:not(.is-busy)'), /opacity:\s*1\b/) + assert.match(regel(ANSICHTEN, '.kn .kn-chat .chat-icon-btn:disabled, .kn .kn-chat .chat-send:disabled'), /opacity:\s*\.55/) + assert.match(regel(ANSICHTEN, '.kn .kn-chat .chat-send.is-busy:disabled'), /opacity:\s*\.8/) +}) + +test('Chat in der Hülle: Fensterhöhe am Desktop, am Handy fest zwischen Kopf und Leiste, „Chat“ klein im Kopf statt große Überschrift', () => { + const block = chatBlock() + assert.match(regel(block, '.kn-spalte:has(#bereich-chat:not([hidden]))'), /display:\s*flex[^}]*height:\s*calc\(100dvh - var\(--kn-banner-h\)\)[^}]*min-height:\s*0/) + assert.match(regel(block, '#chat-body'), /flex:\s*1 1 auto[^}]*min-height:\s*0/) + const handy = handyTeil(block) + assert.match(handy, /\.kn-haupt:has\(#bereich-chat:not\(\[hidden\]\)\)\s*\{[^}]*position:\s*fixed[^}]*inset:\s*calc\(var\(--kn-banner-h\) \+ var\(--kn-kopf-h\) \+ env\(safe-area-inset-top\)\) 0 var\(--kn-unten, var\(--kn-zeile-h\)\)/) + assert.match(handy, /\.kn-haupt:has\(#bereich-chat:not\(\[hidden\]\)\)\s*\{[^}]*padding:\s*0 max\(16px, env\(safe-area-inset-right\)\) 6px max\(16px, env\(safe-area-inset-left\)\)/) // höchstens 8 px Luft über der Leiste + assert.match(handy, /#bereich-chat > \.kn-titel\s*\{[^}]*position:\s*absolute[^}]*width:\s*1px[^}]*height:\s*1px[^}]*clip:\s*rect\(0 0 0 0\)/) // nur noch für Screenreader + assert.match(handy, /\.kn:has\(#bereich-chat:not\(\[hidden\]\)\) \.kn-kopf\s*\{[^}]*height:[^}]*border-bottom-color:\s*var\(--kn-line\)/) // deckender Kopf mit Haarlinie + assert.match(handy, /\.kn:has\(#bereich-chat:not\(\[hidden\]\)\) \.kn-kopf-titel\s*\{[^}]*opacity:\s*1[^}]*transform:\s*none/) + assert.doesNotMatch(block, /backdrop-filter|gradient|rgba\(/) // deckend, kein Glas +}) + +test('haltFokus: Druck auf einen Knopf im Composer nimmt dem Eingabefeld den Fokus nicht (sonst springt die Eingabe, wenn die Leiste zurückkommt, und der Tipp auf „Senden“ geht verloren)', () => { + const hoerer = new Map() + const knopf = { matches: (s) => s === 'button' } + const wurzel = { + addEventListener: (n, f) => hoerer.set(n, f), + removeEventListener: (n) => hoerer.delete(n), + contains: (el) => el === knopf || el === symbol, + } + const symbol = { closest: (s) => (s === 'button' ? knopf : null) } // das Symbol im Knopf ist das Ziel des Drucks + const feld = { closest: () => null } + const draussen = { closest: () => ({}) } // ein Knopf, der nicht im Composer liegt + const h = haltFokus(wurzel) + assert.deepEqual([...hoerer.keys()], ['mousedown']) + const druck = (target) => { let verhindert = false; hoerer.get('mousedown')({ target, preventDefault: () => { verhindert = true } }); return verhindert } + assert.equal(druck(symbol), true, 'Knopf im Composer: Fokus bleibt') + assert.equal(druck(feld), false, 'Textfeld: normal') + assert.equal(druck(draussen), false, 'Knopf außerhalb: normal') + assert.equal(druck(undefined), false) + h.abbau() + assert.equal(hoerer.size, 0) +})