Merge kn/chat-fix (Pilot-Feedback 17.10.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-17 15:43:07 +00:00
commit 85808fc115
4 files changed
+128 -8

No files matched your search

+19 -2
View File
@@ -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)
+9 -6
View File
@@ -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%; }
+19
View File
@@ -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) {
+81
View File
@@ -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)
})