Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions web/src/components/MessageInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,7 @@
{:else}{seg.text}{/if}{/each}{#if inputValue.endsWith('\n')}<span class="overlay-trailing-newline"> </span>{/if}</div>
<textarea
bind:this={inputEl}
class="inline-textarea"
rows="1"
placeholder="Message #{channelName}..."
bind:value={inputValue}
Expand Down Expand Up @@ -1767,7 +1768,14 @@
color: transparent;
}

.input-wrap textarea {
/* Overlay-transparency rules are SCOPED to the inline textarea only
(.inline-textarea). The inline textarea is the "transparent-glyph +
.input-overlay" surface: its own glyphs MUST stay transparent so only
the overlay paints visible, syntax-highlighted text. Scoping by class
(instead of the broad `.input-wrap textarea`) keeps these rules from
bleeding onto the dedicated `.block-textarea`, which is a normal visible
textarea and was being forced transparent by the cascade. */
.input-wrap textarea.inline-textarea {
width: 100%;
background: none;
border: none;
Expand Down Expand Up @@ -1807,17 +1815,17 @@
}

/* WebKit / Blink: remove the scrollbar entirely (see scrollbar-width above). */
.input-wrap textarea::-webkit-scrollbar {
.input-wrap textarea.inline-textarea::-webkit-scrollbar {
width: 0;
height: 0;
}

.input-wrap textarea:focus-visible {
.input-wrap textarea.inline-textarea:focus-visible {
outline: none !important;
box-shadow: none !important;
}

.input-wrap textarea::placeholder {
.input-wrap textarea.inline-textarea::placeholder {
color: var(--text-faint);
}

Expand All @@ -1830,7 +1838,7 @@
reported when highlighting. Keeping it transparent means selecting only
paints the semi-transparent ember background tint over the overlay's
colored glyphs — no doubling, ever. */
.input-wrap textarea::selection {
.input-wrap textarea.inline-textarea::selection {
background: rgba(245, 158, 11, 0.32);
color: transparent;
}
Expand Down Expand Up @@ -1909,6 +1917,15 @@
box-shadow: none !important;
}

/* The block textarea is a NORMAL visible textarea (not the overlay surface),
so its selection paints VISIBLE glyphs over the ember tint, the opposite
of the inline `.inline-textarea::selection` rule, which keeps selected
glyphs transparent to avoid doubling against the overlay. */
.block-textarea::selection {
background: rgba(245, 158, 11, 0.32);
color: var(--code-block-fg, var(--text-primary));
}

.input-actions {
display: flex;
gap: 2px;
Expand Down
164 changes: 164 additions & 0 deletions web/tests/message-input-block-textarea-visible.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Regression test for the "invisible code-block text" bug.
//
// The inline composer textarea uses a "transparent-glyph + overlay" technique:
// its own glyphs are `color: transparent` and the separate `.input-overlay`
// paints the visible, syntax-highlighted text. That transparency rule used to
// be written as the broad descendant selector `.input-wrap textarea`
// (specificity 0,1,1). The dedicated block-mode textarea (`.block-textarea`,
// specificity 0,1,0) lives INSIDE `.input-wrap`, so the broad rule beat its
// own `color` and forced its glyphs transparent, so typed code was invisible.
//
// The fix scopes the transparency rules to the inline textarea via a dedicated
// `.inline-textarea` class. This test proves the CASCADE OUTCOME (not merely
// that a class exists): it parses the component's real <style> rules and uses
// `element.matches()` against the live rendered DOM. The rendered elements keep
// their authored classes (plus Svelte's scope class) and are attached to the
// document, so descendant combinators like `.input-wrap textarea.inline-textarea`
// resolve exactly as the browser cascade would when deciding applicability.

import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, fireEvent } from '@testing-library/svelte';
import { tick } from 'svelte';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

import MessageInput from '../src/components/MessageInput.svelte';

const __dirname = dirname(fileURLToPath(import.meta.url));
const COMPONENT_PATH = resolve(__dirname, '../src/components/MessageInput.svelte');

const PARTICIPANTS = {
'phil-key': { key: 'phil-key', name: 'phil', type: 'human', connections: { 'web-1': {} } },
};

function makeStore() {
return {
participants: PARTICIPANTS,
userProfile: { key: 'phil-key', name: 'phil', type: 'human' },
sendMessage: () => {},
notifyTyping: () => {},
};
}

/**
* Parse the component's <style> block into a flat list of
* { selector, declarations } records. Comments are stripped first; the CSS in
* this component is flat (no nested rules) so a single brace pass is exact.
*/
function parseStyleRules() {
const src = readFileSync(COMPONENT_PATH, 'utf-8');
const styleMatch = src.match(/<style[^>]*>([\s\S]*?)<\/style>/);
if (!styleMatch) throw new Error('no <style> block found in MessageInput.svelte');
const css = styleMatch[1].replace(/\/\*[\s\S]*?\*\//g, '');
const rules = [];
const re = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = re.exec(css)) !== null) {
const selector = m[1].trim();
const declarations = m[2].trim();
if (selector) rules.push({ selector, declarations });
}
return rules;
}

/** Pull the `color:` value out of a declaration block, or null if absent. */
function colorOf(declarations) {
const m = declarations.match(/(?:^|;)\s*color\s*:\s*([^;]+?)\s*(?:;|$)/);
return m ? m[1].trim() : null;
}

/** matches() that swallows pseudo-element selectors jsdom can't evaluate. */
function matchesSafe(el, selector) {
try {
return el.matches(selector);
} catch {
return false;
}
}

async function enterBlockMode() {
const store = makeStore();
const { container } = render(MessageInput, {
props: { store, channelName: 'general', typingUsers: [], onOpenEmoji: () => {} },
});
const inline = container.querySelector('textarea[data-testid="message-input"]');
// Trigger B: ``` on its own line + Shift+Enter opens the dedicated block textarea.
inline.value = '```';
inline.setSelectionRange(3, 3);
await fireEvent.input(inline, { target: inline });
await tick();
await fireEvent.keyDown(inline, { key: 'Enter', shiftKey: true });
await tick();
await Promise.resolve();
await tick();
const block = container.querySelector('[data-testid="block-textarea"]');
return { container, inline, block };
}

afterEach(() => cleanup());

describe('MessageInput block textarea visibility (regression: invisible code-block text)', () => {
it('block textarea glyph color is NOT forced transparent by the inline overlay rules', async () => {
const { inline, block } = await enterBlockMode();
expect(block).toBeTruthy();

const rules = parseStyleRules();
// Base color rules only (drop pseudo-element selectors like ::selection).
const colorRules = rules
.map((r) => ({ selector: r.selector, color: colorOf(r.declarations) }))
.filter((r) => r.color !== null && !r.selector.includes('::'));

const transparentRules = colorRules.filter((r) => r.color === 'transparent');
const visibleRules = colorRules.filter((r) => r.color !== 'transparent');

// Sanity: the transparent-glyph overlay technique is still present.
expect(transparentRules.length).toBeGreaterThan(0);

// CASCADE OUTCOME 1, the bug: NO transparent-color rule may apply to the
// block textarea. If none match, it cannot be transparent at any specificity.
for (const r of transparentRules) {
expect(
matchesSafe(block, r.selector),
`block textarea must not match transparent-color rule "${r.selector}"`,
).toBe(false);
}

// CASCADE OUTCOME 2: the block textarea is targeted by a VISIBLE color rule
// (its own `.block-textarea` rule), so its glyphs render.
expect(visibleRules.some((r) => matchesSafe(block, r.selector))).toBe(true);

// CASCADE OUTCOME 3: the inline overlay technique is unchanged: the inline
// textarea IS still targeted by a transparent-glyph rule.
expect(transparentRules.some((r) => matchesSafe(inline, r.selector))).toBe(true);
// ...and is NOT targeted by any visible-color textarea rule that would
// defeat the overlay (only the overlay paints inline text).
const inlineTextareaVisible = visibleRules.filter(
(r) => matchesSafe(inline, r.selector) && /textarea/.test(r.selector),
);
expect(inlineTextareaVisible).toHaveLength(0);
});

it('focused DOM check: block has block-textarea (not inline-textarea); inline has inline-textarea', async () => {
const { inline, block } = await enterBlockMode();
expect(block).toBeTruthy();
expect(block.classList.contains('block-textarea')).toBe(true);
expect(block.classList.contains('inline-textarea')).toBe(false);
expect(inline.classList.contains('inline-textarea')).toBe(true);
});

it('block selection paints VISIBLE glyphs while the inline selection stays transparent', async () => {
await enterBlockMode();
const rules = parseStyleRules();

const blockSelection = rules.find((r) => /\.block-textarea[^,{]*::selection/.test(r.selector));
expect(blockSelection, '.block-textarea::selection rule must exist').toBeTruthy();
expect(colorOf(blockSelection.declarations)).not.toBe('transparent');

const inlineSelection = rules.find((r) => /\.inline-textarea[^,{]*::selection/.test(r.selector));
expect(inlineSelection, '.inline-textarea::selection rule must exist').toBeTruthy();
// Load-bearing: keeping the inline selected glyphs transparent prevents the
// "doubled text" regression (overlay + textarea both painting).
expect(colorOf(inlineSelection.declarations)).toBe('transparent');
});
});