From 86c46baffbfd5539ed09072dacaba3fbf5ec59f4 Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 6 May 2026 14:22:48 +0200 Subject: [PATCH] ui: /f/[slug] two-column form|chat + phone-style bubbles + per-user color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m: "The difference between our form and the live chat needs to be more prominent. I think we should style it more like a chat, too — maybe separate into two columns — Form on the Left, Chat on the Right. And chat messages more... like a chat on the phone. And we colorize different users differently." Layout - New .fb-shell--wide modifier (max-width 960px) on the participant shell so the two columns breathe. .fb-participant grid: 1fr at <900px, 1fr 1fr at ≥900px. Form column on the left, chat column on the right. Single-column flow (form-only or chat-only) just fills its column. - Title + description + closed-line + name field stay full-width above the columns. Live results + footer stay full-width below. Chat bubbles - New .fb-bubble primitive replaces the old .fb-chat__post box-with-border pattern on /f/[slug] only (admin moderator UI keeps .fb-chat__post). - My posts: right-aligned, --color-primary background, white text, no border. Others: left-aligned, --color-bg-secondary background, 3px left-border in the speaker's deterministic color. - Author name: tiny + weight 500. For others, name text is the speaker's color; for me, it's --color-primary (you stay green). - Timestamp: HH:mm if today, dd.MM HH:mm otherwise; viewer-localised. - Vertical density: 0.6rem gap by default, +0.5rem extra when the speaker changes (bumps to ~1.1rem — visually distinct turn boundaries). Per-user color - 7-color palette (rose / orange / amber / cyan / blue / violet / pink), greens deliberately excluded so they don't clash with --color-primary which the viewer's own bubbles use. - Stable hash of client_session_id maps to a palette index — same user gets the same color across reloads. - Color applied to: author name text, bubble left-border. No avatar circle in v1. Compose - New .fb-compose at the bottom of the chat column (in flex-flow, no position:sticky needed — bubbles take flex:1 and scroll, compose stays pinned). Textarea + send button side-by-side. - Single-row textarea (rows=1, max-height 10rem) grows on input. - Enter alone sends; Shift+Enter inserts a newline; IME composition is respected (e.zh ne IME's confirm Enter doesn't submit). - Placeholder includes the keyboard hint in German. Auto-scroll - $effect on posts.length scrolls the bubbles container to the bottom on append + initial load with behavior:'smooth'. Replaces the old imperative queueMicrotask calls inside fetchPosts/postChat. Form column - Section H2 ("Fragebogen") dropped — column boundary already names the section. Submit button: full-width on mobile, auto-width on desktop. - Submit-success banner now uses .fb-form-banner--success class (replaces inline style hardcoded green). CSS hygiene - Removed orphaned .fb-chat__list / .fb-chat__form / .fb-chat__form-actions / .fb-chat__empty (only the participant page used them; new bubble primitives replace them). Admin-side .fb-chat / .fb-chat__post / etc. kept intact for the moderator UI. Anti-scope honored: admin pages untouched, German strings unchanged, 3s polling interval kept, no new dependencies. --- src/lib/styles/feedback.css | 231 ++++++++++++-- src/routes/f/[slug]/+page.svelte | 521 +++++++++++++++++-------------- 2 files changed, 489 insertions(+), 263 deletions(-) diff --git a/src/lib/styles/feedback.css b/src/lib/styles/feedback.css index 60c1911..636da3a 100644 --- a/src/lib/styles/feedback.css +++ b/src/lib/styles/feedback.css @@ -523,22 +523,15 @@ body { min-height: 100vh; } /* Chat */ +/* Admin chat moderator UI — kept for /admin/feedback/[id] Chat tab. + Participant page uses the new .fb-bubble* primitives below. */ + .fb-chat { display: flex; flex-direction: column; gap: 0.75rem; } -.fb-chat__list { - display: flex; - flex-direction: column; - gap: 0.5rem; - max-height: 60vh; - overflow-y: auto; - padding: 0.25rem; - margin: -0.25rem; -} - .fb-chat__post { padding: 0.7rem 0.85rem; border-radius: var(--radius-lg); @@ -578,25 +571,6 @@ body { min-height: 100vh; } font-size: 0.95rem; } -.fb-chat__form { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.fb-chat__form-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; -} - -.fb-chat__empty { - text-align: center; - color: var(--fb-muted); - padding: 1.5rem 0; - font-size: 0.95rem; -} - .fb-foot { text-align: center; color: var(--fb-muted); @@ -1425,3 +1399,202 @@ body { min-height: 100vh; } flex-wrap: wrap; margin-top: var(--space-4); } + +/* ───────────────────────────────────────────────────────────────── + Participant page — two-column form|chat layout, phone-style + bubbles, sticky compose. Used only on /f/[slug]. + ───────────────────────────────────────────────────────────────── */ + +/* Wider shell on participant page so the two columns breathe. */ +.fb-shell.fb-shell--wide { + max-width: 960px; +} + +.fb-participant { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-7); + margin-bottom: var(--space-7); +} + +@media (min-width: 900px) { + .fb-participant { + grid-template-columns: 1fr 1fr; + } +} + +.fb-participant__col { + min-width: 0; + display: flex; + flex-direction: column; +} + +/* Form column — sequential questions, submit at the bottom. */ +.fb-participant__col--form { + gap: var(--space-4); +} + +.fb-participant__col--form .fb-question { + margin-bottom: 0; +} + +.fb-participant__submit { + margin-top: var(--space-4); +} + +@media (min-width: 900px) { + .fb-participant__submit { + align-self: flex-start; + } +} + +@media (max-width: 899px) { + .fb-participant__submit-btn { + width: 100%; + } +} + +/* Chat column — bubbles fill the column height, compose pinned at bottom. */ +.fb-participant__col--chat { + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + padding: var(--space-3); + min-height: 320px; +} + +@media (max-width: 899px) { + .fb-participant__col--chat { + max-height: 70vh; + } +} + +.fb-bubbles { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.6rem; + overflow-y: auto; + padding: 0.25rem; + margin: -0.25rem; + min-height: 200px; +} + +.fb-bubbles__empty { + text-align: center; + color: var(--color-text-muted); + padding: var(--space-5) 0; + font-size: 0.9rem; + margin: auto 0; +} + +/* Bubble — defaults to left-aligned (other speakers); --mine flips to right. */ +.fb-bubble { + display: flex; + flex-direction: column; + gap: 0.15rem; + max-width: 80%; + align-self: flex-start; +} + +.fb-bubble--speaker-change { + margin-top: 0.5rem; +} + +.fb-bubble--mine { + align-self: flex-end; +} + +.fb-bubble__author { + font-size: 0.78rem; + font-weight: 500; + color: var(--color-text-muted); + padding: 0 0.5rem; +} + +.fb-bubble--mine .fb-bubble__author { + align-self: flex-end; + color: var(--color-primary); +} + +.fb-bubble__body { + padding: 0.55rem 0.85rem; + border-radius: var(--radius-xl); + background: var(--color-bg-secondary); + color: var(--color-text-primary); + white-space: pre-wrap; + word-break: break-word; + line-height: 1.45; + font-size: 0.95rem; + border-left: 3px solid var(--color-border-primary); + box-shadow: var(--shadow-sm); +} + +.fb-bubble--mine .fb-bubble__body { + background: var(--color-primary); + color: var(--color-text-inverse); + border-left: 0; + box-shadow: var(--shadow-sm); +} + +.fb-bubble__body--hidden { + background: var(--color-bg-tertiary); + color: var(--color-text-muted); + font-style: italic; + border-left-color: var(--color-border-primary); +} + +.fb-bubble--mine .fb-bubble__body--hidden { + background: var(--color-bg-tertiary); + color: var(--color-text-muted); +} + +.fb-bubble__time { + font-size: 0.7rem; + color: var(--color-text-muted); + padding: 0 0.5rem; +} + +.fb-bubble--mine .fb-bubble__time { + align-self: flex-end; +} + +/* Compose — pinned to the bottom of the chat column via flex layout. */ +.fb-compose { + display: flex; + gap: 0.5rem; + align-items: flex-end; + padding-top: var(--space-2); + margin-top: var(--space-2); + border-top: 1px solid var(--color-border-primary); +} + +.fb-compose__textarea { + flex: 1; + min-height: 2.5rem; + max-height: 10rem; + resize: none; +} + +.fb-compose__send { + flex-shrink: 0; +} + +.fb-compose__error { + margin: 0 0 var(--space-2); + font-size: 0.85rem; + color: var(--color-error); +} + +/* Submit-success / already-submitted banners on the form column — + replaces the old inline style hardcoded green. */ +.fb-form-banner--success { + background: var(--color-primary-light); + color: var(--color-primary-hover); + border-color: var(--color-primary); +} + +@media (prefers-color-scheme: dark) { + .fb-form-banner--success { + color: var(--color-primary); + } +} diff --git a/src/routes/f/[slug]/+page.svelte b/src/routes/f/[slug]/+page.svelte index 9275ec8..ca9ce69 100644 --- a/src/routes/f/[slug]/+page.svelte +++ b/src/routes/f/[slug]/+page.svelte @@ -44,6 +44,25 @@ let results = $state(null); let resultsPollHandle: ReturnType | null = null; + // Per-user color palette — deterministic hash of client_session_id maps to + // one of these colors. Greens excluded so they don't clash with --color-primary + // (which is reserved for the viewer's own bubbles). + const PARTICIPANT_PALETTE = [ + '#e11d48', // rose + '#ea580c', // orange + '#ca8a04', // amber + '#0891b2', // cyan + '#2563eb', // blue + '#7c3aed', // violet + '#db2777', // pink + ]; + + function colorForSession(sid: string): string { + let h = 0; + for (let i = 0; i < sid.length; i++) h = ((h << 5) - h + sid.charCodeAt(i)) | 0; + return PARTICIPANT_PALETTE[Math.abs(h) % PARTICIPANT_PALETTE.length]; + } + function uuid(): string { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { return crypto.randomUUID(); @@ -108,9 +127,6 @@ if (fresh.length) posts = [...posts, ...fresh]; } lastSeenAt = body.posts[body.posts.length - 1].created_at; - queueMicrotask(() => { - if (chatListEl) chatListEl.scrollTop = chatListEl.scrollHeight; - }); } } catch { // network blip — next poll retries @@ -154,9 +170,6 @@ } chatBody = ''; saveName(); - queueMicrotask(() => { - if (chatListEl) chatListEl.scrollTop = chatListEl.scrollHeight; - }); } catch (e) { chatError = e instanceof Error ? e.message : 'Netzwerkfehler'; } finally { @@ -267,9 +280,26 @@ return p.client_session_id === sessionId; } - function fmtTime(iso: string): string { + // Today: HH:mm — earlier days: dd.MM HH:mm. Localised to viewer. + const sameDayFmt = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit' }); + const dayBeforeFmt = new Intl.DateTimeFormat([], { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + + function fmtBubbleTime(iso: string): string { const d = new Date(iso); - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const now = new Date(); + if ( + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate() + ) { + return sameDayFmt.format(d); + } + return dayBeforeFmt.format(d); } function toggleMultiChoice(q: FeedbackQuestion, opt: string): void { @@ -322,6 +352,27 @@ } } + function onComposeKeydown(e: KeyboardEvent): void { + // Enter alone (no shift, no IME composition) sends. Shift+Enter inserts a newline. + // Cmd/Ctrl+Enter also sends — covers users who expect that shortcut. + if (e.key !== 'Enter' || e.isComposing) return; + if (e.shiftKey) return; + e.preventDefault(); + void postChat(); + } + + // Auto-scroll the chat column to the bottom on initial load and when a new + // post arrives. Reading posts.length keeps this effect reactive to appends. + $effect(() => { + const _len = posts.length; + if (_len === 0 || !chatListEl) return; + queueMicrotask(() => { + if (chatListEl) { + chatListEl.scrollTo({ top: chatListEl.scrollHeight, behavior: 'smooth' }); + } + }); + }); + onMount(() => { displayName = loadName(); const s = loadSession(); @@ -348,7 +399,7 @@ -
+

{data.title}

{#if data.description} @@ -377,255 +428,257 @@
- {#if chatEnabled} -
-

Live-Feedback

-
-
- {#if posts.length === 0} -
Noch keine Beiträge — sei der erste.
- {/if} - {#each posts as p (p.id)} -
-
- {p.display_name ?? 'anonym'} - {fmtTime(p.created_at)} +
+ {#if formDef} +
+ {#if submitSuccess} +
+ Danke für dein Feedback! +
+ + {:else if alreadySubmitted} +
+ Du hast schon abgesendet. Du kannst trotzdem nochmal antworten: +
+ + {:else if isClosed} +
Das Formular ist geschlossen.
+ {:else} +
{ + e.preventDefault(); + submitForm(); + }} + > + + + {#each formDef.questions as q (q.id)} +
+ + + {#if q.type === 'short_text'} + setAnswer(q.id, (e.target as HTMLInputElement).value)} + /> + {:else if q.type === 'long_text'} + + {:else if q.type === 'single_choice'} +
+ {#each q.options as opt (opt)} + + {/each} +
+ {:else if q.type === 'multi_choice'} +
+ {#each q.options as opt (opt)} + + {/each} +
+ {:else if q.type === 'scale'} +
+ {#each Array.from({ length: q.max - q.min + 1 }, (_, i) => i + q.min) as v (v)} + + {/each} +
+ {#if q.min_label || q.max_label} +
+ {q.min_label ?? q.min} + {q.max_label ?? q.max} +
+ {/if} + {:else if q.type === 'boolean'} +
+ + +
+ {:else if q.type === 'date_ranked_choice'} +
+ {#if q.scale?.min_label || q.scale?.max_label} +
+ 1 — {q.scale.min_label ?? 'passt nicht'} + 5 — {q.scale.max_label ?? 'passt super'} +
+ {/if} + {#each q.options as opt (opt.id)} +
+
+
{fmtDateOption(opt.start, opt.end)}
+ {#if opt.label}
{opt.label}
{/if} +
+
+ {#each [1, 2, 3, 4, 5] as v (v)} + + {/each} + +
+
+ {/each} +
+ {/if} + + {#if q.help} +
{q.help}
+ {/if}
-
- {#if p.hidden && !isMine(p)} + {/each} + + {#if submitError} +
{submitError}
+ {/if} + +
+ +
+ + {/if} +
+ {/if} + + {#if chatEnabled} +
+
+ {#if posts.length === 0} +
Noch keine Beiträge — sei der erste.
+ {/if} + {#each posts as p, i (p.id)} + {@const mine = isMine(p)} + {@const speakerChange = i > 0 && posts[i - 1].client_session_id !== p.client_session_id} + {@const userColor = mine ? 'var(--color-primary)' : colorForSession(p.client_session_id)} +
+
+ {p.display_name ?? 'anonym'} +
+
+ {#if p.hidden && !mine} (Beitrag entfernt) - {:else if p.hidden && isMine(p)} + {:else if p.hidden && mine} (von Moderation entfernt — du siehst es noch)
{p.body} {:else} {p.body} {/if}
+
{fmtBubbleTime(p.created_at)}
{/each}
{#if chatStatus === 'open'} + {#if chatError} + + {/if}
{ e.preventDefault(); postChat(); }} > - {#if chatError} -
{chatError}
- {/if} -
- -
+
{/if} -
-
- {/if} - - {#if formDef} -
-

Fragebogen

- - {#if submitSuccess} -
- Danke für dein Feedback! -
- - {:else if alreadySubmitted} -
- Du hast schon abgesendet. Du kannst trotzdem nochmal antworten: -
- - {:else if isClosed} -
Das Formular ist geschlossen.
- {:else} -
{ - e.preventDefault(); - submitForm(); - }} - > - - - {#each formDef.questions as q (q.id)} -
- - - {#if q.type === 'short_text'} - setAnswer(q.id, (e.target as HTMLInputElement).value)} - /> - {:else if q.type === 'long_text'} - - {:else if q.type === 'single_choice'} -
- {#each q.options as opt (opt)} - - {/each} -
- {:else if q.type === 'multi_choice'} -
- {#each q.options as opt (opt)} - - {/each} -
- {:else if q.type === 'scale'} -
- {#each Array.from({ length: q.max - q.min + 1 }, (_, i) => i + q.min) as v (v)} - - {/each} -
- {#if q.min_label || q.max_label} -
- {q.min_label ?? q.min} - {q.max_label ?? q.max} -
- {/if} - {:else if q.type === 'boolean'} -
- - -
- {:else if q.type === 'date_ranked_choice'} -
- {#if q.scale?.min_label || q.scale?.max_label} -
- 1 — {q.scale.min_label ?? 'passt nicht'} - 5 — {q.scale.max_label ?? 'passt super'} -
- {/if} - {#each q.options as opt (opt.id)} -
-
-
{fmtDateOption(opt.start, opt.end)}
- {#if opt.label}
{opt.label}
{/if} -
-
- {#each [1, 2, 3, 4, 5] as v (v)} - - {/each} - -
-
- {/each} -
- {/if} - - {#if q.help} -
{q.help}
- {/if} -
- {/each} - - {#if submitError} -
{submitError}
- {/if} - - -
- {/if} -
- {/if} + + {/if} +
{#if liveResultsEnabled && (alreadySubmitted || submitSuccess) && results}