From 7ecf78755e14d56410cfe7643aaf139dc7456497 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 17 Apr 2026 15:36:34 +0200 Subject: [PATCH 1/3] docs(capture-harness): rework react theming screenshot pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Share one retina context across captures (viewport 1280x1200 @1x) so localStorage and auth tokens persist and the message list has time to populate before each screenshot. Previous per-capture contexts raced an empty channel list on themed variants. - scrollToLatest() before every screenshot so both users' latest bubbles land in the viewport. - Explicitly create the target channel with both members + a friendly name before seeding, and truncate it so reruns don't stack duplicate messages. - waitForChatUI() now also waits for at least one rendered .str-chat__li. - Headless by default (HEADED=1 to override). - Extract the CSS override blocks to theming-variants.mjs so the harness and the doc (01-themingv2.md) stay in lockstep. - Fix ASSETS_DIR to the real docs-content tree (../../../../docs/data/docs/chat-sdk/react/v14-latest/_assets) — previous path resolved to a non-existent directory. - Drop obsolete capture flows (link-attachment text color, layout-only CSS) — neither applies to v14. - Rewrite the seed conversation: two-user dialogue with emojis, markdown (**bold**, *italic*, `code`), GitHub URL preview, images, and richer reaction variety. - Add @playwright/test as a devDep of examples/vite. --- .../seed-channel-and-screenshot.mjs | 497 ++++++++---------- .../vite/docs-playwright/theming-variants.mjs | 182 +++++++ examples/vite/package.json | 1 + examples/vite/yarn.lock | 41 +- 4 files changed, 426 insertions(+), 295 deletions(-) create mode 100644 examples/vite/docs-playwright/theming-variants.mjs diff --git a/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs b/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs index 647bd650a3..ac771ff56b 100644 --- a/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs +++ b/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs @@ -12,19 +12,39 @@ import fs from 'fs'; import zlib from 'zlib'; import { fileURLToPath } from 'url'; +import { + variants as themingVariants, + baselineVariants, + rtlVariant, +} from './theming-variants.mjs'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ASSETS_DIR = path.resolve( - __dirname, - '../../docs/data/docs/chat-sdk/react/v14/_assets', -); +// Target the docs-content tree used by the published React SDK docs. +// Path anchor: `../../../../docs` resolves from +// stream-chat-sdks/stream-chat-react/examples/vite/docs-playwright/ +// to +// stream-chat-sdks/docs/ +// i.e. the sibling `docs` repo / submodule on the host workspace. +const ASSETS_DIR = + process.env.ASSETS_DIR || + path.resolve(__dirname, '../../../../docs/data/docs/chat-sdk/react/v14-latest/_assets'); const TMP_DIR = '/tmp/stream-chat-seed-images'; const BASE_URL = process.env.BASE_URL || 'http://localhost:5175'; -const CHANNEL_PARAMS = 'view=chat&channel=internal'; +// Dedicated channel + users for the theming doc captures, kept separate from +// any live demo channels so regenerating screenshots stays deterministic. +const CHANNEL_ID = process.env.DOC_CHANNEL_ID || 'theming-docs-v14'; +const CHANNEL_PARAMS = `view=chat&channel=${CHANNEL_ID}`; const UI_LOAD_TIMEOUT = 20000; -// Two users for the conversation -const USER_A = 'stream_dev_alice'; -const USER_B = 'stream_dev_bob'; +// Viewport used for every captured screenshot. 1280×1200 at deviceScaleFactor +// 1 yields 1280×1200 PNGs — taller frame so more of the conversation is +// visible without relying on retina scaling. +const CAPTURE_VIEWPORT = { width: 1280, height: 1200 }; +const CAPTURE_DPR = 1; + +// Two users scoped to the docs capture harness. +const USER_A = 'docs_v14_alice'; +const USER_B = 'docs_v14_bob'; // Free avatar photos (Unsplash, small crop) const USER_AVATARS = { @@ -177,57 +197,89 @@ function saveImages() { const CONVERSATION = [ { user: 'a', - text: 'Hey team 👋 just pushed the redesign branch — would love some eyes on it', - reactions: ['😮', '🔥'], + text: 'Morning 👋 just pushed the redesign branch — would love eyes on the **new composer** and **channel list**', + reactions: ['🔥', '❤️'], }, { user: 'b', - text: 'On it! First impression is really solid. Love the new header', - reactions: ['❤️'], + text: 'Reviewing now 👀 First impression: the bubble rhythm *finally* feels intentional', + reactions: ['👍'], }, { user: 'a', - text: "Thanks! Here's a side-by-side of the old vs new layout", + text: "Here's a side-by-side — old vs new channel list affordances", images: ['photo-mountains.png'], - reactions: ['🔥'], + reactions: ['😮', '🔥'], }, { user: 'b', - text: 'Wow, the spacing improvement is huge. One thing — the mobile breakpoint looks a bit cramped', + text: 'The spacing improvement is huge 🎯 Are the v14 theme tokens documented anywhere yet?', }, { user: 'a', - text: "Good catch. I was playing with a warmer background too, here's a mock", - images: ['photo-sunset.png'], - reactions: ['😂'], + text: "Yep, full write-up here → https://getstream.io/chat/docs/sdk/react/theming/themingv2/\n\nTL;DR: override `--accent-primary`, `--chat-bg-outgoing`, `--text-link` and you're 80% of the way there 🎨", + reactions: ['❤️'], }, { user: 'b', - text: 'That warmth really works 🎨 Can we see it next to the ocean palette as well?', + text: 'Thanks! Also poking at the React SDK repo → https://github.com/GetStream/stream-chat-react', reactions: ['👍'], }, { user: 'a', - text: 'Sure, here are both side by side', + text: 'Quick mock of the outgoing bubbles against the **new accent** 🎨', + images: ['photo-sunset.png'], + reactions: ['🔥'], + }, + { + user: 'b', + text: 'Contrast is *noticeably* better. Type stays legible even at the smaller weight', + }, + { + user: 'a', + text: 'Stacked both brand directions side by side 👇', images: ['photo-ocean.png', 'photo-pattern.png'], }, { user: 'b', - text: 'Ocean variant all the way. The contrast on the action buttons is much better 👍', + text: '**Ocean variant** all the way — the `--border-utility-focused` treatment is really crisp 👌', reactions: ['❤️'], }, { user: 'a', - text: "Agreed! I'll update the design tokens and cut a new build tonight", + text: "Agreed 🙌 I'll finalize the tokens tonight. Sync tomorrow at **9:30**?", + reactions: ['👍'], }, { user: 'b', - text: 'Perfect. Also dropping the reference photo the designer shared', + text: "Works for me — I'll bring the component sweep 📋", + }, + { + user: 'a', + text: 'One more — mood board the design team shared, really captures the vibe ✨', images: ['photo-forest.png'], + reactions: ['🔥', '😮'], + }, + { + user: 'b', + text: "That's exactly it. Let's ship 🚀", + reactions: ['👍'], + }, + { + user: 'a', + text: 'Thread surface finally matches the composer rhythm — *single source of truth* for `--radius-max` + spacing tokens 🎯', + reactions: ['❤️'], + }, + { + user: 'a', + text: 'Last thing — dropped the PR for a final pass 🙏 https://github.com/GetStream/stream-chat-react', + reactions: ['👍'], + }, + { + user: 'b', + text: 'Design team signed off too ✅ marking this sprint item **done** 🎉', reactions: ['🔥'], }, - { user: 'a', text: "That's exactly the vibe. Let's go with it 🚀", reactions: ['👍'] }, - { user: 'b', text: 'Design team signed off too ✅ Marking this sprint item as done!' }, ]; // --------------------------------------------------------------------------- @@ -236,9 +288,65 @@ const CONVERSATION = [ async function waitForChatUI(page) { await page.waitForSelector('.str-chat__channel-list', { timeout: UI_LOAD_TIMEOUT }); await page.waitForSelector('.str-chat__message-list', { timeout: UI_LOAD_TIMEOUT }); + // Wait for at least one rendered message item so the capture doesn't + // race an empty list during the initial channel query. + await page + .waitForSelector('.str-chat__li', { timeout: UI_LOAD_TIMEOUT }) + .catch(() => null); await page.waitForTimeout(1500); } +// Force the message list to the bottom so the most recent exchange (which +// includes both users' most recent bubbles) is visible in the capture +// viewport. Needed because the list only auto-scrolls for messages the +// current user sent — incoming messages arriving while we're in the scripted +// seeding phase can leave the scroll anchored to an older position. +async function scrollToLatest(page) { + await page.evaluate(() => { + const container = + document.querySelector('.str-chat__message-list-scroll') || + document.querySelector('.str-chat__message-list'); + if (container) container.scrollTop = container.scrollHeight; + // Virtualized list variant uses its own inner scroller. + const virt = document.querySelector('.str-chat__virtual-list'); + if (virt) virt.scrollTop = virt.scrollHeight; + }); + await page.waitForTimeout(500); +} + +// A single retina context is reused for every captured screenshot. Using one +// context (rather than a fresh one per page) lets localStorage, IndexedDB, +// and cached auth tokens persist across captures — otherwise each themed +// capture re-runs the token-fetch flow from scratch and can race the +// message list before it populates. +let _captureContext = null; + +async function getCaptureContext(browser) { + if (!_captureContext) { + _captureContext = await browser.newContext({ + viewport: CAPTURE_VIEWPORT, + deviceScaleFactor: CAPTURE_DPR, + }); + } + return _captureContext; +} + +async function newCapturePage(browser) { + const ctx = await getCaptureContext(browser); + return ctx.newPage(); +} + +async function closeCapturePage(page) { + await page.close(); +} + +async function disposeCaptureContext() { + if (_captureContext) { + await _captureContext.close(); + _captureContext = null; + } +} + async function sendMessage(page, text, imagePaths = []) { if (imagePaths.length > 0) { const fileInput = page.locator('.str-chat__file-input').first(); @@ -331,145 +439,22 @@ async function addReactionToLastMessage(page, emoji) { } // --------------------------------------------------------------------------- -// CSS overrides — each block mirrors a code example in 01-themingv2.md +// CSS overrides mirroring each code example in 01-themingv2.md live in +// `./theming-variants.mjs`. Keep that file and the doc in lockstep. // --------------------------------------------------------------------------- -// Docs §"Global variables" (lines 72–89) -// Screenshot: stream-chat-css-chat-ui-theme-customization-screenshot.png -const CSS_GLOBAL_VARS = ` -@layer stream-overrides { - .str-chat { - --brand-50: #edf7f7; - --brand-100: #e0f2f1; - --brand-150: #b2dfdb; - --brand-200: #80cbc4; - --brand-300: #4db6ac; - --brand-400: #26a69a; - --brand-500: #009688; - --brand-600: #00897b; - --brand-700: #00796b; - --brand-800: #00695c; - --brand-900: #004d40; - --accent-primary: var(--brand-500); - --radius-full: 6px; - } -}`; - -// Docs §"Component variables" — avatar (lines 112–133) -// Screenshot: stream-chat-css-custom-avatar-color-screenshot.png -const CSS_AVATAR_COLOR = ` -@layer stream-overrides { - .str-chat { - --brand-50: #edf7f7; - --brand-100: #e0f2f1; - --brand-150: #b2dfdb; - --brand-200: #80cbc4; - --brand-300: #4db6ac; - --brand-400: #26a69a; - --brand-500: #009688; - --brand-600: #00897b; - --brand-700: #00796b; - --brand-800: #00695c; - --brand-900: #004d40; - --accent-primary: var(--brand-500); - --radius-full: 6px; - --avatar-palette-bg-1: #bf360c; - --avatar-palette-text-1: #ffffff; - } -}`; - -// Docs §"Component variables" — message bubble color (lines 148–169) -// Screenshot: stream-chat-css-message-color-customization-screenshot.png -const CSS_MESSAGE_BUBBLE = ` -@layer stream-overrides { - .str-chat { - --brand-50: #edf7f7; - --brand-100: #e0f2f1; - --brand-150: #b2dfdb; - --brand-200: #80cbc4; - --brand-300: #4db6ac; - --brand-400: #26a69a; - --brand-500: #009688; - --brand-600: #00897b; - --brand-700: #00796b; - --brand-800: #00695c; - --brand-900: #004d40; - --accent-primary: var(--brand-500); - --radius-full: 6px; - --avatar-palette-bg-1: #bf360c; - --avatar-palette-text-1: #ffffff; - --str-chat__message-bubble-color: #00695c; - } -}`; - -// Docs §"Component variables" — bubble + card attachment (lines 178–200) -// Screenshot: stream-chat-css-message-color-customization2-screenshot.png -const CSS_MESSAGE_BUBBLE_AND_CARD = ` -@layer stream-overrides { - .str-chat { - --brand-50: #edf7f7; - --brand-100: #e0f2f1; - --brand-150: #b2dfdb; - --brand-200: #80cbc4; - --brand-300: #4db6ac; - --brand-400: #26a69a; - --brand-500: #009688; - --brand-600: #00897b; - --brand-700: #00796b; - --brand-800: #00695c; - --brand-900: #004d40; - --accent-primary: var(--brand-500); - --radius-full: 6px; - --avatar-palette-bg-1: #bf360c; - --avatar-palette-text-1: #ffffff; - --str-chat__message-bubble-color: #00695c; - --str-chat__card-attachment-color: #00695c; - } -}`; - -// Docs §"Dark and light themes" — custom dark (lines 373–404) -// Screenshot: stream-chat-css-custom-dark-theme-screenshot.png -const CSS_CUSTOM_DARK = ` -@layer stream-overrides { - .str-chat { - --radius-full: 6px; - } - .str-chat__theme-light { - --brand-500: #009688; - --brand-400: #26a69a; - --brand-300: #4db6ac; - --brand-200: #80cbc4; - --brand-150: #b2dfdb; - --brand-100: #e0f2f1; - --brand-50: #edf7f7; - --accent-primary: var(--brand-500); - --avatar-palette-bg-1: #bf360c; - --avatar-palette-text-1: #ffffff; - } - .str-chat__theme-dark { - --brand-500: #26a69a; - --brand-400: #4db6ac; - --brand-300: #80cbc4; - --brand-200: #b2dfdb; - --brand-150: #e0f2f1; - --brand-100: #00796b; - --brand-50: #004d40; - --accent-primary: var(--brand-400); - --avatar-palette-bg-1: #ff7043; - --avatar-palette-text-1: #ffffff; - } -}`; - -// Helper: open a page, optionally inject raw CSS +// Helper: open a page with the shared capture viewport/DPR, optionally +// inject raw CSS, and scroll to the latest message so both users' most +// recent bubbles are in frame. async function openWithCSS(browser, url, css) { - const page = await browser.newPage(); - await page.setViewportSize({ width: 1280, height: 900 }); + const page = await newCapturePage(browser); await page.goto(url); await waitForChatUI(page); if (css) { await page.addStyleTag({ content: css }); await page.waitForTimeout(400); } + await scrollToLatest(page); return page; } @@ -479,12 +464,13 @@ async function screenshot(page, filename) { } async function takeScreenshots(browser) { - // ------- Default light theme ------- + // ------- Default light theme (baselines + region crops) ------- console.log('\n--- Default (light) screenshots ---'); { const page = await openWithCSS(browser, channelUrl(USER_A, 'light')); await screenshot(page, 'stream-chat-css-chat-ui-screenshot.png'); + // Region crops used by other doc pages (kept for backwards compatibility). await page .locator('.str-chat__channel-list') .first() @@ -525,145 +511,38 @@ async function takeScreenshots(browser) { console.warn(' ⚠ Emoji picker screenshot failed:', e.message.substring(0, 60)); } - await page.close(); - } - - // ------- Default dark theme ------- - console.log('\n--- Default dark theme ---'); - { - const page = await openWithCSS(browser, channelUrl(USER_A, 'dark')); - await screenshot(page, 'stream-chat-css-dark-ui-screenshot.png'); - await page.close(); - } - - // ------- Docs §"Global variables": teal brand palette + square radius ------- - console.log('\n--- Theming guide screenshots ---'); - { - const page = await openWithCSS(browser, channelUrl(USER_A, 'light'), CSS_GLOBAL_VARS); - await screenshot(page, 'stream-chat-css-chat-ui-theme-customization-screenshot.png'); - await page.close(); - } - - // ------- Docs §"Component variables": message color before override ------- - // Same as global vars — shows message text without custom bubble color - { - const page = await openWithCSS(browser, channelUrl(USER_A, 'light'), CSS_GLOBAL_VARS); - await screenshot(page, 'stream-chat-css-message-color-screenshot.png'); - await page.close(); - } - - // ------- Docs §"Component variables": avatar color override ------- - { - const page = await openWithCSS( - browser, - channelUrl(USER_A, 'light'), - CSS_AVATAR_COLOR, - ); - await screenshot(page, 'stream-chat-css-custom-avatar-color-screenshot.png'); - await page.close(); - } - - // ------- Docs §"Component variables": message bubble text color ------- - { - const page = await openWithCSS( - browser, - channelUrl(USER_A, 'light'), - CSS_MESSAGE_BUBBLE, - ); - await screenshot(page, 'stream-chat-css-message-color-customization-screenshot.png'); - await page.close(); - } - - // ------- Docs §"Component variables": bubble + card attachment text color ------- - { - const page = await openWithCSS( - browser, - channelUrl(USER_A, 'light'), - CSS_MESSAGE_BUBBLE_AND_CARD, - ); - await screenshot(page, 'stream-chat-css-message-color-customization2-screenshot.png'); - await page.close(); + await closeCapturePage(page); } - // ------- Docs §"Dark and light themes": custom dark + light overrides ------- - // Uses dark theme URL; CSS targets .str-chat__theme-dark specifically - { - const page = await openWithCSS(browser, channelUrl(USER_A, 'dark'), CSS_CUSTOM_DARK); - await screenshot(page, 'stream-chat-css-custom-dark-theme-screenshot.png'); - await page.close(); + // ------- Additional baseline variants from theming-variants.mjs ------- + console.log('\n--- Theming baselines ---'); + for (const v of baselineVariants) { + if (v.screenshot === 'stream-chat-css-chat-ui-screenshot.png') continue; // already taken above + const page = await openWithCSS(browser, channelUrl(USER_A, v.theme), v.css); + await screenshot(page, v.screenshot); + await closeCapturePage(page); } - // ------- Docs §"Creating your own theme": round + square themes ------- - // The docs show customClasses with square on channelList and round on channel. - // CSS variable overrides via layers don't visually apply due to cascade priority, - // so we apply border-radius via inline styles on the avatar elements directly. - { - const page = await browser.newPage(); - await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto(channelUrl(USER_A, 'light')); - await waitForChatUI(page); - // Square avatars in channel list (6px), round in channel (default 9999px) - await page.evaluate(() => { - document - .querySelectorAll('.str-chat__channel-list .str-chat__avatar') - .forEach((el) => { - el.style.setProperty('border-radius', '6px', 'important'); - }); - }); - await page.waitForTimeout(400); - await screenshot(page, 'stream-chat-css-square-theme-screenshot.png'); - await page.close(); + // ------- Docs theming examples: drive from theming-variants.mjs ------- + console.log('\n--- Theming guide variants ---'); + for (const v of themingVariants) { + const page = await openWithCSS(browser, channelUrl(USER_A, v.theme), v.css); + await screenshot(page, v.screenshot); + await closeCapturePage(page); } // ------- Docs §"RTL support" ------- + console.log('\n--- RTL ---'); { - const page = await browser.newPage(); - await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto(channelUrl(USER_A, 'light')); - await waitForChatUI(page); + const page = await openWithCSS(browser, channelUrl(USER_A, rtlVariant.theme)); await page.evaluate(() => document.documentElement.setAttribute('dir', 'rtl')); await page.waitForTimeout(500); - await screenshot(page, 'stream-chat-css-rtl-layout-screenshot.png'); - await page.close(); - } - - // ------- Docs §"Apply your own look and feel": layout-only ------- - console.log('\n--- Layout-only screenshot ---'); - { - const page = await browser.newPage(); - await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto(channelUrl(USER_A, 'light')); - await waitForChatUI(page); - // Simulate importing only index.layout.scss by stripping all visual theming - await page.addStyleTag({ - content: [ - '.str-chat, .str-chat * { box-shadow: none !important; text-shadow: none !important; }', - '.str-chat { color: #000 !important; background: #fff !important; }', - ].join('\n'), - }); - await page.evaluate(() => { - const stripped = { - '--str-chat__primary-color': 'transparent', - '--str-chat__active-primary-color': 'transparent', - '--str-chat__surface-color': 'transparent', - '--str-chat__secondary-surface-color': 'transparent', - '--str-chat__primary-surface-color': 'transparent', - '--str-chat__primary-surface-color-low-emphasis': 'transparent', - '--str-chat__border-radius-circle': '0', - '--str-chat__font-family': 'inherit', - }; - document.querySelectorAll('.str-chat').forEach((el) => { - for (const [k, v] of Object.entries(stripped)) { - el.style.setProperty(k, v, 'important'); - } - }); - }); - await page.waitForTimeout(400); - await screenshot(page, 'stream-chat-css-chat-ui-layout-screenshot.png'); - await page.close(); + await scrollToLatest(page); + await screenshot(page, rtlVariant.screenshot); + await closeCapturePage(page); } - // ------- Thread ------- + // ------- Thread (utility crop used by other doc pages) ------- console.log('\n--- Thread screenshot ---'); { const page = await openWithCSS(browser, channelUrl(USER_A, 'light')); @@ -693,7 +572,7 @@ async function takeScreenshots(browser) { } } if (!threadOpened) console.warn(' ⚠ Could not open thread, skipping'); - await page.close(); + await closeCapturePage(page); } } @@ -712,7 +591,9 @@ async function run() { if (!SKIP_SEED) console.log(` ✓ ${Object.keys(imagesByName).length} images written to ${TMP_DIR}`); - const browser = await chromium.launch({ headless: false }); + const browser = await chromium.launch({ + headless: process.env.HEADED !== '1', + }); try { // ----------------------------------------------------------------------- // Step 1: Seed with two users conversing @@ -731,6 +612,51 @@ async function run() { await pageA.goto(channelUrl(USER_A)); await waitForChatUI(pageA); + // Explicitly create the channel with both members + a friendly name + // before either user tries to post. Without this, USER_B lands on a + // channel they're not a member of and has no composer. Also truncate + // any prior seed so reruns don't stack duplicate messages. + console.log(` Creating channel "${CHANNEL_ID}" with both members...`); + const createResult = await pageA.evaluate( + async ({ userA, userB, channelId }) => { + const findClient = () => { + const el = + document.querySelector('.str-chat__channel') || + document.querySelector('.str-chat'); + if (!el) return null; + const key = Object.keys(el).find((k) => k.startsWith('__reactFiber')); + let fiber = el[key]; + while (fiber) { + if (fiber.memoizedProps?.client?.channel) return fiber.memoizedProps.client; + fiber = fiber.return; + } + return null; + }; + const client = findClient(); + if (!client) return { ok: false, reason: 'no client' }; + try { + const ch = client.channel('messaging', channelId, { + members: [userA, userB], + name: 'Design redesign — v14', + }); + await ch.watch(); + const existingCount = (ch.state.messages || []).length; + if (existingCount > 0) { + await ch.truncate(); + } + return { ok: true, truncated: existingCount }; + } catch (err) { + return { ok: false, reason: err?.message?.substring(0, 120) || String(err) }; + } + }, + { userA: USER_A, userB: USER_B, channelId: CHANNEL_ID }, + ); + console.log( + createResult.ok + ? ` ✓ channel ready (truncated ${createResult.truncated} prior msg${createResult.truncated === 1 ? '' : 's'})` + : ` ⚠ channel create: ${createResult.reason}`, + ); + console.log(` Loading ${USER_B}...`); await pageB.goto(channelUrl(USER_B)); await waitForChatUI(pageB); @@ -861,6 +787,7 @@ async function run() { console.log(`\n✅ Done! Screenshots saved to:\n ${ASSETS_DIR}`); } finally { + await disposeCaptureContext(); await browser.close(); } } diff --git a/examples/vite/docs-playwright/theming-variants.mjs b/examples/vite/docs-playwright/theming-variants.mjs new file mode 100644 index 0000000000..787ab1c218 --- /dev/null +++ b/examples/vite/docs-playwright/theming-variants.mjs @@ -0,0 +1,182 @@ +/** + * CSS override blocks for each code example in the React theming doc + * docs-content: chat-sdk/react/v14-latest/02-ui-components/02-theming/01-themingv2.md + * + * Keep the blocks here in sync with the doc. Each entry lists: + * - `docSection`: which heading in the doc the block belongs to + * - `screenshot`: the asset filename the capture pipeline writes + * - `theme`: which SDK theme URL param to use ("light" or "dark") + * - `css`: the raw CSS to inject via Playwright `page.addStyleTag` + * + * Whenever you edit the doc, edit the matching entry here so the capture + * script and the doc never drift apart. + */ + +// -- §"Global variables" ------------------------------------------------------ +// Semantic tokens actually consumed by v14's built CSS. +// See 01-themingv2.md first ```css @layer stream-overrides { ... }``` block. +const CSS_GLOBAL_VARS = ` +@layer stream-overrides { + .str-chat { + --accent-primary: #0d47a1; + + --chat-bg-outgoing: #1e3a8a; + --chat-bg-attachment-outgoing: #0d47a1; + --chat-bg-incoming: #dbeafe; + --chat-text-outgoing: #ffffff; + --chat-reply-indicator-outgoing: #93c5fd; + + --text-link: #1e40af; + --chat-text-link: #93c5fd; + + --background-core-elevation-1: #dbeafe; + --background-core-app: #c7dafc; + + --border-utility-focused: #1e40af; + + --radius-max: 8px; + --button-radius-full: 6px; + } +}`; + +// -- §"Component variables" — avatar colors ----------------------------------- +// Uses v14's avatar-specific tokens (--avatar-bg-default / --avatar-text-default). +const CSS_AVATAR_COLOR = ` +@layer stream-overrides { + .str-chat { + --accent-primary: #0d47a1; + --chat-bg-outgoing: #1e3a8a; + --chat-text-outgoing: #ffffff; + --chat-bg-incoming: #dbeafe; + + --avatar-bg-default: #bf360c; + --avatar-text-default: #ffffff; + } +}`; + +// -- §"Component variables" — message bubble text color ----------------------- +// Extends the avatar block with a component-level bubble text color. +const CSS_MESSAGE_BUBBLE = ` +@layer stream-overrides { + .str-chat { + --accent-primary: #0d47a1; + --chat-bg-outgoing: #1e3a8a; + --chat-text-outgoing: #ffffff; + --chat-bg-incoming: #dbeafe; + + --avatar-bg-default: #bf360c; + --avatar-text-default: #ffffff; + + --str-chat__message-bubble-color: #00695c; + } +}`; + +// -- §"Dark and light themes" — per-theme palettes ---------------------------- +// Applied to .str-chat__theme-light / .str-chat__theme-dark scopes so both +// modes can be themed independently. +const CSS_CUSTOM_DARK = ` +@layer stream-overrides { + .str-chat { + --radius-max: 8px; + --button-radius-full: 6px; + } + .str-chat__theme-light { + --accent-primary: #0d47a1; + --chat-bg-outgoing: #1e3a8a; + --chat-bg-incoming: #dbeafe; + --chat-text-outgoing: #ffffff; + --text-link: #1e40af; + --background-core-elevation-1: #dbeafe; + --background-core-app: #c7dafc; + --avatar-bg-default: #bf360c; + --avatar-text-default: #ffffff; + } + .str-chat__theme-dark { + --accent-primary: #4a90e2; + --chat-bg-outgoing: #1e3a8a; + --chat-bg-incoming: #1f2937; + --chat-text-outgoing: #e2e8f0; + --chat-text-incoming: #e2e8f0; + --text-link: #93c5fd; + --background-core-elevation-1: #0f172a; + --background-core-app: #020617; + --avatar-bg-default: #ff7043; + --avatar-text-default: #ffffff; + } +}`; + +// -- §"Creating your own theme" — square variant ------------------------------ +// The doc shows round vs square via --radius-max (40 consumers in v14 CSS). +// We capture the "square" variant; "round" matches the SDK default. +const CSS_SQUARE_THEME = ` +@layer stream-overrides { + .str-chat { + --radius-max: 6px; + --button-radius-full: 6px; + } +}`; + +// --------------------------------------------------------------------------- + +export const variants = [ + { + docSection: 'Global variables', + screenshot: 'stream-chat-css-chat-ui-theme-customization-screenshot.png', + theme: 'light', + css: CSS_GLOBAL_VARS, + }, + { + docSection: 'Component variables — avatar', + screenshot: 'stream-chat-css-custom-avatar-color-screenshot.png', + theme: 'light', + css: CSS_AVATAR_COLOR, + }, + { + docSection: 'Component variables — message bubble', + screenshot: 'stream-chat-css-message-color-customization-screenshot.png', + theme: 'light', + css: CSS_MESSAGE_BUBBLE, + }, + { + docSection: 'Dark and light themes — custom palettes', + screenshot: 'stream-chat-css-custom-dark-theme-screenshot.png', + theme: 'dark', + css: CSS_CUSTOM_DARK, + }, + { + docSection: 'Creating your own theme — square', + screenshot: 'stream-chat-css-square-theme-screenshot.png', + theme: 'light', + css: CSS_SQUARE_THEME, + }, +]; + +// Baseline screenshots that don't inject any override CSS (just theme + url). +export const baselineVariants = [ + { + docSection: 'Default light UI', + screenshot: 'stream-chat-css-chat-ui-screenshot.png', + theme: 'light', + css: null, + }, + { + docSection: 'Default message (before custom color)', + screenshot: 'stream-chat-css-message-color-screenshot.png', + theme: 'light', + css: null, + }, + { + docSection: 'Default dark UI', + screenshot: 'stream-chat-css-dark-ui-screenshot.png', + theme: 'dark', + css: null, + }, +]; + +// RTL needs a DOM-level toggle, not CSS — handled by the capture script. +export const rtlVariant = { + docSection: 'RTL support', + screenshot: 'stream-chat-css-rtl-layout-screenshot.png', + theme: 'light', + css: null, +}; diff --git a/examples/vite/package.json b/examples/vite/package.json index 3ccd0cd5bf..092772ae9f 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -15,6 +15,7 @@ "stream-chat-react": "link:../../" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@types/react": "link:../../node_modules/@types/react", "@types/react-dom": "link:../../node_modules/@types/react-dom", "@typescript-eslint/eslint-plugin": "^7.2.0", diff --git a/examples/vite/yarn.lock b/examples/vite/yarn.lock index a531301f48..a6abb6bd94 100644 --- a/examples/vite/yarn.lock +++ b/examples/vite/yarn.lock @@ -449,6 +449,13 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@playwright/test@^1.59.1": + version "1.59.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6" + integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg== + dependencies: + playwright "1.59.1" + "@react-aria/focus@^3": version "3.16.2" resolved "https://registry.yarnpkg.com/@react-aria/focus/-/focus-3.16.2.tgz#2285bc19e091233b4d52399c506ac8fa60345b44" @@ -1486,6 +1493,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -1795,11 +1807,6 @@ lodash.deburr@^4.1.0: resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-4.1.0.tgz#ddb1bbb3ef07458c0177ba07de14422cb033ff9b" integrity sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ== -lodash.defaultsdeep@^4.6.1: - version "4.6.1" - resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6" - integrity sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA== - lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -2462,6 +2469,20 @@ picomatch@^4.0.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +playwright-core@1.59.1: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2" + integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg== + +playwright@1.59.1: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a" + integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw== + dependencies: + playwright-core "1.59.1" + optionalDependencies: + fsevents "2.3.2" + postcss@^8.5.6: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" @@ -2518,11 +2539,6 @@ react-fast-compare@^3.0.1, react-fast-compare@^3.2.2: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== -react-image-gallery@1.2.12: - version "1.2.12" - resolved "https://registry.yarnpkg.com/react-image-gallery/-/react-image-gallery-1.2.12.tgz#b08a633cc336bab2a5afdb96941e023925043c6a" - integrity sha512-JIh85lh0Av/yewseGJb/ycg00Y/weQiZEC/BQueC2Z5jnYILGB6mkxnrOevNhsM2NdZJpvcDekCluhy6uzEoTA== - react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -2832,6 +2848,11 @@ ts-api-utils@^1.3.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== +ts-pattern@^5.9.0: + version "5.9.0" + resolved "https://registry.yarnpkg.com/ts-pattern/-/ts-pattern-5.9.0.tgz#f0fb5205ce0b0c59af72beba62fee7612a61cea4" + integrity sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg== + tslib@^2.4.0, tslib@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" From 756ac6d9081b2bbb5c5a35ae5730e07e03682546 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 17 Apr 2026 16:13:24 +0200 Subject: [PATCH 2/3] docs(examples/tutorial): sync with published v14 theming tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the step-by-step tutorial example with the React chat tutorial that now ships in docs-content and getstream.io-tutorials: - layout.css (steps 3-7, previously byte-identical): replace dead --brand-* palette + --radius-full with the semantic tokens v14 actually consumes (--accent-primary, --chat-bg-*, --text-link, --background-core-*, --border-utility-focused, --radius-max, --button-radius-full). - App.tsx (steps 3/4/5): rename theme='str-chat__theme-custom' → theme='custom-theme'. The str-chat__ prefix is reserved for SDK-owned classes and the SDK does no wildcard matching on str-chat__theme-*, so the prefix added nothing. Both changes mirror the companion doc-content PR (GetStream/docs-content#1213). --- examples/tutorial/src/3-channel-list/App.tsx | 2 +- .../tutorial/src/3-channel-list/layout.css | 39 ++++++++++++------- .../src/4-custom-ui-components/App.tsx | 2 +- .../src/4-custom-ui-components/layout.css | 39 ++++++++++++------- .../src/5-custom-attachment-type/App.tsx | 2 +- .../src/5-custom-attachment-type/layout.css | 39 ++++++++++++------- .../tutorial/src/6-emoji-picker/layout.css | 39 ++++++++++++------- examples/tutorial/src/7-livestream/layout.css | 39 ++++++++++++------- 8 files changed, 128 insertions(+), 73 deletions(-) diff --git a/examples/tutorial/src/3-channel-list/App.tsx b/examples/tutorial/src/3-channel-list/App.tsx index f53d0f7065..02eca591d1 100644 --- a/examples/tutorial/src/3-channel-list/App.tsx +++ b/examples/tutorial/src/3-channel-list/App.tsx @@ -39,7 +39,7 @@ const App = () => { if (!client) return
Setting up client & connection...
; return ( - + diff --git a/examples/tutorial/src/3-channel-list/layout.css b/examples/tutorial/src/3-channel-list/layout.css index facdbd5d57..3ee1c6ae56 100644 --- a/examples/tutorial/src/3-channel-list/layout.css +++ b/examples/tutorial/src/3-channel-list/layout.css @@ -2,20 +2,31 @@ @import 'stream-chat-react/dist/css/index.css' layer(stream); @layer stream-overrides { - .str-chat__theme-custom { - --brand-50: #edf7f7; - --brand-100: #e0f2f1; - --brand-150: #b2dfdb; - --brand-200: #80cbc4; - --brand-300: #4db6ac; - --brand-400: #26a69a; - --brand-500: #009688; - --brand-600: #00897b; - --brand-700: #00796b; - --brand-800: #00695c; - --brand-900: #004d40; - --accent-primary: var(--brand-500); - --radius-full: 6px; + .custom-theme { + /* Accent */ + --accent-primary: #0d47a1; + + /* Message bubble colors */ + --chat-bg-outgoing: #1e3a8a; + --chat-bg-attachment-outgoing: #0d47a1; + --chat-bg-incoming: #dbeafe; + --chat-text-outgoing: #ffffff; + --chat-reply-indicator-outgoing: #93c5fd; + + /* Links */ + --text-link: #1e40af; + --chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ + --background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --border-utility-focused: #1e40af; + + /* Radii */ + --radius-max: 8px; + --button-radius-full: 6px; } } diff --git a/examples/tutorial/src/4-custom-ui-components/App.tsx b/examples/tutorial/src/4-custom-ui-components/App.tsx index b51bee8232..8b58d15a99 100644 --- a/examples/tutorial/src/4-custom-ui-components/App.tsx +++ b/examples/tutorial/src/4-custom-ui-components/App.tsx @@ -151,7 +151,7 @@ const App = () => { Message: CustomMessage, }} > - + diff --git a/examples/tutorial/src/4-custom-ui-components/layout.css b/examples/tutorial/src/4-custom-ui-components/layout.css index facdbd5d57..3ee1c6ae56 100644 --- a/examples/tutorial/src/4-custom-ui-components/layout.css +++ b/examples/tutorial/src/4-custom-ui-components/layout.css @@ -2,20 +2,31 @@ @import 'stream-chat-react/dist/css/index.css' layer(stream); @layer stream-overrides { - .str-chat__theme-custom { - --brand-50: #edf7f7; - --brand-100: #e0f2f1; - --brand-150: #b2dfdb; - --brand-200: #80cbc4; - --brand-300: #4db6ac; - --brand-400: #26a69a; - --brand-500: #009688; - --brand-600: #00897b; - --brand-700: #00796b; - --brand-800: #00695c; - --brand-900: #004d40; - --accent-primary: var(--brand-500); - --radius-full: 6px; + .custom-theme { + /* Accent */ + --accent-primary: #0d47a1; + + /* Message bubble colors */ + --chat-bg-outgoing: #1e3a8a; + --chat-bg-attachment-outgoing: #0d47a1; + --chat-bg-incoming: #dbeafe; + --chat-text-outgoing: #ffffff; + --chat-reply-indicator-outgoing: #93c5fd; + + /* Links */ + --text-link: #1e40af; + --chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ + --background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --border-utility-focused: #1e40af; + + /* Radii */ + --radius-max: 8px; + --button-radius-full: 6px; } } diff --git a/examples/tutorial/src/5-custom-attachment-type/App.tsx b/examples/tutorial/src/5-custom-attachment-type/App.tsx index d81784a2ff..d03dfd555b 100644 --- a/examples/tutorial/src/5-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/5-custom-attachment-type/App.tsx @@ -118,7 +118,7 @@ const App = () => { return ( - + diff --git a/examples/tutorial/src/5-custom-attachment-type/layout.css b/examples/tutorial/src/5-custom-attachment-type/layout.css index facdbd5d57..3ee1c6ae56 100644 --- a/examples/tutorial/src/5-custom-attachment-type/layout.css +++ b/examples/tutorial/src/5-custom-attachment-type/layout.css @@ -2,20 +2,31 @@ @import 'stream-chat-react/dist/css/index.css' layer(stream); @layer stream-overrides { - .str-chat__theme-custom { - --brand-50: #edf7f7; - --brand-100: #e0f2f1; - --brand-150: #b2dfdb; - --brand-200: #80cbc4; - --brand-300: #4db6ac; - --brand-400: #26a69a; - --brand-500: #009688; - --brand-600: #00897b; - --brand-700: #00796b; - --brand-800: #00695c; - --brand-900: #004d40; - --accent-primary: var(--brand-500); - --radius-full: 6px; + .custom-theme { + /* Accent */ + --accent-primary: #0d47a1; + + /* Message bubble colors */ + --chat-bg-outgoing: #1e3a8a; + --chat-bg-attachment-outgoing: #0d47a1; + --chat-bg-incoming: #dbeafe; + --chat-text-outgoing: #ffffff; + --chat-reply-indicator-outgoing: #93c5fd; + + /* Links */ + --text-link: #1e40af; + --chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ + --background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --border-utility-focused: #1e40af; + + /* Radii */ + --radius-max: 8px; + --button-radius-full: 6px; } } diff --git a/examples/tutorial/src/6-emoji-picker/layout.css b/examples/tutorial/src/6-emoji-picker/layout.css index facdbd5d57..3ee1c6ae56 100644 --- a/examples/tutorial/src/6-emoji-picker/layout.css +++ b/examples/tutorial/src/6-emoji-picker/layout.css @@ -2,20 +2,31 @@ @import 'stream-chat-react/dist/css/index.css' layer(stream); @layer stream-overrides { - .str-chat__theme-custom { - --brand-50: #edf7f7; - --brand-100: #e0f2f1; - --brand-150: #b2dfdb; - --brand-200: #80cbc4; - --brand-300: #4db6ac; - --brand-400: #26a69a; - --brand-500: #009688; - --brand-600: #00897b; - --brand-700: #00796b; - --brand-800: #00695c; - --brand-900: #004d40; - --accent-primary: var(--brand-500); - --radius-full: 6px; + .custom-theme { + /* Accent */ + --accent-primary: #0d47a1; + + /* Message bubble colors */ + --chat-bg-outgoing: #1e3a8a; + --chat-bg-attachment-outgoing: #0d47a1; + --chat-bg-incoming: #dbeafe; + --chat-text-outgoing: #ffffff; + --chat-reply-indicator-outgoing: #93c5fd; + + /* Links */ + --text-link: #1e40af; + --chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ + --background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --border-utility-focused: #1e40af; + + /* Radii */ + --radius-max: 8px; + --button-radius-full: 6px; } } diff --git a/examples/tutorial/src/7-livestream/layout.css b/examples/tutorial/src/7-livestream/layout.css index facdbd5d57..3ee1c6ae56 100644 --- a/examples/tutorial/src/7-livestream/layout.css +++ b/examples/tutorial/src/7-livestream/layout.css @@ -2,20 +2,31 @@ @import 'stream-chat-react/dist/css/index.css' layer(stream); @layer stream-overrides { - .str-chat__theme-custom { - --brand-50: #edf7f7; - --brand-100: #e0f2f1; - --brand-150: #b2dfdb; - --brand-200: #80cbc4; - --brand-300: #4db6ac; - --brand-400: #26a69a; - --brand-500: #009688; - --brand-600: #00897b; - --brand-700: #00796b; - --brand-800: #00695c; - --brand-900: #004d40; - --accent-primary: var(--brand-500); - --radius-full: 6px; + .custom-theme { + /* Accent */ + --accent-primary: #0d47a1; + + /* Message bubble colors */ + --chat-bg-outgoing: #1e3a8a; + --chat-bg-attachment-outgoing: #0d47a1; + --chat-bg-incoming: #dbeafe; + --chat-text-outgoing: #ffffff; + --chat-reply-indicator-outgoing: #93c5fd; + + /* Links */ + --text-link: #1e40af; + --chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ + --background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --border-utility-focused: #1e40af; + + /* Radii */ + --radius-max: 8px; + --button-radius-full: 6px; } } From 6d6988d43aa78617ff6403e6c19948839d4cd15e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 17 Apr 2026 16:20:53 +0200 Subject: [PATCH 3/3] docs(examples/tutorial): fetch tokens from pronto.getstream.io Mirror the pattern used by examples/vite: credentials.ts now exports apiKey, userId, userName, and a tokenProvider function that mints a fresh JWT from pronto for whichever user_id is active. The app stays runnable without pasting a token that expires, and users can switch identities at runtime via ?user_id=... / ?user_name=... URL params. - credentials.ts: drop the static userToken export; add a tokenProvider() that hits VITE_TOKEN_ENDPOINT (default https://pronto.getstream.io/api/auth/create-token) with VITE_TOKEN_ENVIRONMENT (default "demo") and the active user_id. userId/userName now derive from URL params first, then env, then sensible defaults. - All 7 step App.tsx files: rename the userToken import and tokenOrProvider arg to tokenProvider. - .env.example: drop VITE_USER_TOKEN; document the new optional VITE_TOKEN_ENDPOINT / VITE_TOKEN_ENVIRONMENT overrides and the URL-param fallbacks. --- examples/tutorial/.env.example | 16 +++++-- examples/tutorial/src/1-client-setup/App.tsx | 4 +- .../src/1-client-setup/credentials.ts | 48 +++++++++++++++++-- .../src/2-core-component-setup/App.tsx | 4 +- examples/tutorial/src/3-channel-list/App.tsx | 4 +- .../src/4-custom-ui-components/App.tsx | 4 +- .../src/5-custom-attachment-type/App.tsx | 4 +- examples/tutorial/src/6-emoji-picker/App.tsx | 4 +- examples/tutorial/src/7-livestream/App.tsx | 4 +- 9 files changed, 71 insertions(+), 21 deletions(-) diff --git a/examples/tutorial/.env.example b/examples/tutorial/.env.example index 52545ac36e..46895730ee 100644 --- a/examples/tutorial/.env.example +++ b/examples/tutorial/.env.example @@ -1,4 +1,14 @@ +# Required: your Stream app's public key. VITE_API_KEY=REPLACE_WITH_API_KEY -VITE_USER_ID=REPLACE_WITH_USER_ID -VITE_USER_NAME=REPLACE_WITH_USER_NAME -VITE_USER_TOKEN=REPLACE_WITH_USER_TOKEN + +# Optional. If unset, the app defaults to user_id "react-tutorial" and +# derives user_name from it. You can also override either value per-run +# via URL params: ?user_id=alice&user_name=Alice +# VITE_USER_ID=react-tutorial +# VITE_USER_NAME=React Tutorial + +# Optional. Token endpoint used to mint a fresh JWT for the active +# user_id. Defaults to Stream's demo endpoint; override if you're +# pointing at a different Stream app / token service. +# VITE_TOKEN_ENDPOINT=https://pronto.getstream.io/api/auth/create-token +# VITE_TOKEN_ENVIRONMENT=demo diff --git a/examples/tutorial/src/1-client-setup/App.tsx b/examples/tutorial/src/1-client-setup/App.tsx index f94e0cd22b..70a7fd0aa2 100644 --- a/examples/tutorial/src/1-client-setup/App.tsx +++ b/examples/tutorial/src/1-client-setup/App.tsx @@ -1,10 +1,10 @@ import { Chat, useCreateChatClient } from 'stream-chat-react'; -import { apiKey, userId, userName, userToken } from './credentials'; +import { apiKey, userId, userName, tokenProvider } from './credentials'; const App = () => { const client = useCreateChatClient({ apiKey, - tokenOrProvider: userToken, + tokenOrProvider: tokenProvider, userData: { id: userId, name: userName }, }); diff --git a/examples/tutorial/src/1-client-setup/credentials.ts b/examples/tutorial/src/1-client-setup/credentials.ts index cc27ef84fa..0236916296 100644 --- a/examples/tutorial/src/1-client-setup/credentials.ts +++ b/examples/tutorial/src/1-client-setup/credentials.ts @@ -1,5 +1,45 @@ -// your Stream app information +// Stream Chat credentials for the tutorial example. +// +// The example fetches a fresh JWT from pronto.getstream.io for whichever +// user_id is active, so the app stays runnable without pasting a token +// that expires, and you can switch users via URL params at runtime: +// +// ?user_id=alice // different user +// ?user_id=alice&user_name=Alice // + display name override +// +// Notes: +// - apiKey is the one thing you still need to set (via VITE_API_KEY). +// - The token endpoint and environment default to the values shared with +// the other example apps in this repo; override with VITE_TOKEN_ENDPOINT +// and VITE_TOKEN_ENVIRONMENT if you're pointing at a different Stream +// app. + +const searchParams = new URLSearchParams(window.location.search); + export const apiKey = import.meta.env.VITE_API_KEY; -export const userId = import.meta.env.VITE_USER_ID; -export const userName = import.meta.env.VITE_USER_NAME; -export const userToken = import.meta.env.VITE_USER_TOKEN; + +export const userId = + searchParams.get('user_id') || import.meta.env.VITE_USER_ID || 'react-tutorial'; + +export const userName = + searchParams.get('user_name') || import.meta.env.VITE_USER_NAME || userId; + +const tokenEndpoint = + import.meta.env.VITE_TOKEN_ENDPOINT || + 'https://pronto.getstream.io/api/auth/create-token'; +const tokenEnvironment = import.meta.env.VITE_TOKEN_ENVIRONMENT || 'demo'; + +// Stream's `useCreateChatClient` accepts either a token string or a provider +// function. A provider lets the SDK refresh the token on reconnect, which is +// what we want for a long-running example session. +export const tokenProvider = async (): Promise => { + const url = `${tokenEndpoint}?environment=${encodeURIComponent( + tokenEnvironment, + )}&user_id=${encodeURIComponent(userId)}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to mint token from ${tokenEndpoint} (${response.status})`); + } + const data = (await response.json()) as { token: string }; + return data.token; +}; diff --git a/examples/tutorial/src/2-core-component-setup/App.tsx b/examples/tutorial/src/2-core-component-setup/App.tsx index d964923cc6..02dea74cd0 100644 --- a/examples/tutorial/src/2-core-component-setup/App.tsx +++ b/examples/tutorial/src/2-core-component-setup/App.tsx @@ -13,7 +13,7 @@ import { import 'stream-chat-react/dist/css/index.css'; import './layout.css'; -import { apiKey, userId, userName, userToken } from '../1-client-setup/credentials'; +import { apiKey, userId, userName, tokenProvider } from '../1-client-setup/credentials'; const user: User = { id: userId, @@ -25,7 +25,7 @@ const App = () => { const [channel, setChannel] = useState(); const client = useCreateChatClient({ apiKey, - tokenOrProvider: userToken, + tokenOrProvider: tokenProvider, userData: user, }); diff --git a/examples/tutorial/src/3-channel-list/App.tsx b/examples/tutorial/src/3-channel-list/App.tsx index 02eca591d1..267563fb04 100644 --- a/examples/tutorial/src/3-channel-list/App.tsx +++ b/examples/tutorial/src/3-channel-list/App.tsx @@ -12,7 +12,7 @@ import { } from 'stream-chat-react'; import './layout.css'; -import { apiKey, userId, userName, userToken } from '../1-client-setup/credentials'; +import { apiKey, userId, userName, tokenProvider } from '../1-client-setup/credentials'; const user: User = { id: userId, @@ -32,7 +32,7 @@ const options: ChannelOptions = { const App = () => { const client = useCreateChatClient({ apiKey, - tokenOrProvider: userToken, + tokenOrProvider: tokenProvider, userData: user, }); diff --git a/examples/tutorial/src/4-custom-ui-components/App.tsx b/examples/tutorial/src/4-custom-ui-components/App.tsx index 8b58d15a99..cd61f0b6f5 100644 --- a/examples/tutorial/src/4-custom-ui-components/App.tsx +++ b/examples/tutorial/src/4-custom-ui-components/App.tsx @@ -17,7 +17,7 @@ import { } from 'stream-chat-react'; import './layout.css'; -import { apiKey, userId, userName, userToken } from '../1-client-setup/credentials'; +import { apiKey, userId, userName, tokenProvider } from '../1-client-setup/credentials'; const user: User = { id: userId, @@ -118,7 +118,7 @@ const App = () => { const [isReady, setIsReady] = useState(false); const client = useCreateChatClient({ apiKey, - tokenOrProvider: userToken, + tokenOrProvider: tokenProvider, userData: user, }); diff --git a/examples/tutorial/src/5-custom-attachment-type/App.tsx b/examples/tutorial/src/5-custom-attachment-type/App.tsx index d03dfd555b..6418d9a20b 100644 --- a/examples/tutorial/src/5-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/5-custom-attachment-type/App.tsx @@ -19,7 +19,7 @@ import { } from 'stream-chat-react'; import './layout.css'; -import { apiKey, userId, userName, userToken } from '../1-client-setup/credentials'; +import { apiKey, userId, userName, tokenProvider } from '../1-client-setup/credentials'; const user: User = { id: userId, @@ -76,7 +76,7 @@ const App = () => { const [channel, setChannel] = useState(); const client = useCreateChatClient({ apiKey, - tokenOrProvider: userToken, + tokenOrProvider: tokenProvider, userData: user, }); diff --git a/examples/tutorial/src/6-emoji-picker/App.tsx b/examples/tutorial/src/6-emoji-picker/App.tsx index 05d2d09f19..72613dbd6f 100644 --- a/examples/tutorial/src/6-emoji-picker/App.tsx +++ b/examples/tutorial/src/6-emoji-picker/App.tsx @@ -18,7 +18,7 @@ import { init, SearchIndex } from 'emoji-mart'; import data from '@emoji-mart/data'; import './layout.css'; -import { apiKey, userId, userName, userToken } from '../1-client-setup/credentials'; +import { apiKey, userId, userName, tokenProvider } from '../1-client-setup/credentials'; const user: User = { id: userId, @@ -38,7 +38,7 @@ const App = () => { const [isReady, setIsReady] = useState(false); const client = useCreateChatClient({ apiKey, - tokenOrProvider: userToken, + tokenOrProvider: tokenProvider, userData: user, }); diff --git a/examples/tutorial/src/7-livestream/App.tsx b/examples/tutorial/src/7-livestream/App.tsx index 0a83105688..48c3e157d8 100644 --- a/examples/tutorial/src/7-livestream/App.tsx +++ b/examples/tutorial/src/7-livestream/App.tsx @@ -11,7 +11,7 @@ import { } from 'stream-chat-react'; import './layout.css'; -import { apiKey, userId, userName, userToken } from '../1-client-setup/credentials'; +import { apiKey, userId, userName, tokenProvider } from '../1-client-setup/credentials'; const user: User = { id: userId, @@ -23,7 +23,7 @@ const App = () => { const [channel, setChannel] = useState(); const chatClient = useCreateChatClient({ apiKey, - tokenOrProvider: userToken, + tokenOrProvider: tokenProvider, userData: user, });