From 185b949253cccf97efd7727a80556498a2cdeb78 Mon Sep 17 00:00:00 2001 From: pdelfan Date: Sat, 17 Oct 2026 21:09:55 -0700 Subject: [PATCH] A streaming reply's words come into focus as they arrive, fading in from a 4px blur as Streamdown's blurIn does, rather than only fading. The theme gains --animate-blur-in, its blur-in keyframes, and a --blur-in token for how blurred a word starts, since the app doesn't load Streamdown's stylesheet. Timing, the cascade, list markers, and reduced motion are as before. --- CHANGELOG.md | 2 +- apps/web/src/parts/markdown.css | 7 ++++--- apps/web/src/parts/markdown.tsx | 6 +++--- apps/web/src/shared/cn.ts | 1 + apps/web/src/styles/theme.css | 11 +++++++++++ specs/styling.md | 4 ++-- specs/web-ui.md | 2 +- 7 files changed, 23 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49437b0..f29410f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ What changed in each release of SCN Chat, for the people who use it, run it, or ### Changed -- A streaming reply's words fade in as they arrive, rather than appearing at once. Nothing moves under reduced motion. +- A streaming reply's words fade in from a slight blur as they arrive, rather than appearing at once. Nothing moves under reduced motion. ## [0.2.2] - 2026-10-16 diff --git a/apps/web/src/parts/markdown.css b/apps/web/src/parts/markdown.css index e8d8430..5f96f41 100644 --- a/apps/web/src/parts/markdown.css +++ b/apps/web/src/parts/markdown.css @@ -206,11 +206,12 @@ word-break: break-all; } - /* While a part streams, Streamdown wraps each new word in a span, and a list item's marker, - which can't take opacity, fades in by its color. Each starts at the delay Streamdown gives + /* While a part streams, Streamdown wraps each new word in a span, which comes into focus as + it fades in, and a list item's marker, which can't take opacity or a filter, fades in by its + color. Each starts at the delay Streamdown gives it, so a burst of words arrives as a short cascade. */ & [data-sd-animate] { - animation: var(--animate-fade-in); + animation: var(--animate-blur-in); animation-delay: var(--sd-delay, 0ms); } diff --git a/apps/web/src/parts/markdown.tsx b/apps/web/src/parts/markdown.tsx index 4d0b519..33e70cb 100644 --- a/apps/web/src/parts/markdown.tsx +++ b/apps/web/src/parts/markdown.tsx @@ -36,10 +36,10 @@ import { codeFilename, downloadText } from './download.ts' */ /** - * Words fade in as they stream, over --dur-enter, which markdown.css animates them with. The + * Words blur in as they stream, over --dur-enter, which markdown.css animates them with. The * duration here only paces when each word starts. */ -const fadeIn = { duration: 240 } +const blurIn = { animation: 'blurIn', duration: 240 } /** Without rehype-raw, so HTML in the model's text is not rendered. */ const { raw: _raw, ...safeRehypePlugins } = defaultRehypePlugins @@ -381,7 +381,7 @@ export function Markdown(props: Props) { , )` in oklch, so dark mode needs no `da | Type | `--text-caption`, `small`, `ui`, `body`, `heading`, `title`, `display` | `text-*` | | Radius | `--radius-panel` (was `--radius`), `card`, `small`, `badge`, `row`, `menu`, `bubble`, `composer`, with Tailwind's own `rounded-full` for pills | `rounded-*` | | Shadows | `--shadow-float`, `--shadow-lift` | `shadow-*` | -| Motion | `--ease-out`, `--ease-slide` for the sidebar, and `--animate-*` with their `@keyframes`: fade-in, fade-out, panel-in, panel-up, landed, breathe for live work, skeleton for content on its way, and spin for a busy control | `ease-*`, `animate-*` | +| Motion | `--ease-out`, `--ease-slide` for the sidebar, and `--animate-*` with their `@keyframes`: fade-in, blur-in for a streaming reply's words, fade-out, panel-in, panel-up, landed, breathe for live work, skeleton for content on its way, and spin for a busy control | `ease-*`, `animate-*` | | Named sizes | `--spacing-icon`, `sidebar`, `sidebar-pad`, `row`, `bar`, `toggle`, `composer-button`, `gutter`, `card-pad`, `within`, `turn` | `size-*`, `w-*`, `h-*`, `p-*`, `gap-*`, … | | Widths | `--container-column`, `--container-settings`, `--container-narrow` (34rem) | `max-w-*`, `@max-narrow:` | | Breakpoint | `--breakpoint-wide: 45rem`, where the sidebar becomes a drawer below | `wide:`, `max-wide:` | Ink colors are named `ink` because a color named `text` would make `text-text`, and the type scale already owns `text-*`. -Durations, `--menu-pad`, `--menu-edge`, `--rise`, `--wait-quiet`, `--slow`, and the icon strokes `--icon-stroke` and `--icon-stroke-control` have no Tailwind namespace. They are plain custom properties on `:root` in `theme.css`, used as `duration-(--dur-panel)`. A token built from others, like `bubble` or `radius-menu`, refers to them with `var()`, which resolves where it is used. +Durations, `--menu-pad`, `--menu-edge`, `--rise`, `--blur-in`, `--wait-quiet`, `--slow`, and the icon strokes `--icon-stroke` and `--icon-stroke-control` have no Tailwind namespace. They are plain custom properties on `:root` in `theme.css`, used as `duration-(--dur-panel)`. A token built from others, like `bubble` or `radius-menu`, refers to them with `var()`, which resolves where it is used. Two tokens change with the device, in `theme.css` below the tokens: `--spacing-gutter` narrows to 1rem below the `wide` breakpoint, and `--input-floor` is 16px on a coarse pointer, so iOS never zooms into a field. diff --git a/specs/web-ui.md b/specs/web-ui.md index 9262264..287f685 100644 --- a/specs/web-ui.md +++ b/specs/web-ui.md @@ -82,7 +82,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query - Instructions in the More menu opens a panel with a textarea holding the chat's own instructions, the `systemPrompt` of its `info` record, which the model follows in every reply in this chat after the user's custom instructions, as chat-turns says. Saving sends them trimmed through the conversation's `PATCH` route and closes the panel, and saving them empty clears them, which leaves `systemPrompt` out of the record, as when the chat was created. Saving them unchanged only closes the panel, so the chat doesn't move up the list. A failed save shows in the conversation's alert and leaves the panel open, and Escape or Cancel closes it. Every chat has an `info` record from its creation, so the menu item is disabled until the record has arrived from the local copy or the PDS, and the panel never starts empty over stored instructions. - **Message.** A message shows its work as glass, not a black box: what the model did is visible as it happens, collapsed to a line by default, and every step can be opened down to its exact input and output. - Its header names its author: "You", or the model that wrote the reply, from the record's `model`. - - Assistant text parts and reasoning are rendered as GitHub-flavored Markdown with Streamdown, as ADR 0005 explains. While a part streams, Streamdown completes its unfinished Markdown, so a `**` not yet closed already shows bold and a link still being typed shows as its text, and renders it block by block, so the blocks already written don't render again. Its words fade in as they arrive, over the theme's enter duration, a burst of them as a short cascade, and not under reduced motion. Code blocks and math appear without fading, and a finished part renders without the fade's spans. Fenced code is highlighted by Shiki in the theme's colors, as it streams too, and a language Shiki doesn't know shows as plain text. Finished code highlighted earlier in the session is colored from its block's first frame, as when a reply's record replaces its stream, rather than drawn plain and then again in color. `highlight.ts` keeps the tokens of the last 200 finished blocks for that. Math between `$$` renders with KaTeX, loaded the first time a text holds some, and emphasis beside Chinese, Japanese, and Korean punctuation works. Raw HTML is not rendered and shows as the text it is, links open in a new tab, apart from links within the text, such as a footnote and its way back, which scroll to their target in the same reply, since every reply numbers its footnotes from one, a fenced code block has buttons in its corner that copy its code and download it as a file named for its language, as `code.py`, once its fence closes, a table has menus in its corner that copy it as Markdown, CSV, or TSV and download it as CSV or Markdown, through Streamdown's table helpers with our own buttons and menu, and these show on hover and always on a touch screen, and images show their alt text, full URL, and a load button instead of loading, so injected content cannot leak the chat through an image URL without the user choosing to load it. User text is shown as plain text, except that runs of lines starting with `>`, as a quote from a reply sends them, show as quotes without their markers, in the same bubble as the text, through `splitQuotes` in `parts/quotes.ts`. Full Markdown for user text is planned. + - Assistant text parts and reasoning are rendered as GitHub-flavored Markdown with Streamdown, as ADR 0005 explains. While a part streams, Streamdown completes its unfinished Markdown, so a `**` not yet closed already shows bold and a link still being typed shows as its text, and renders it block by block, so the blocks already written don't render again. Its words fade in from a slight blur as they arrive, over the theme's enter duration, a burst of them as a short cascade, and not under reduced motion. Code blocks and math appear without fading, and a finished part renders without the fade's spans. Fenced code is highlighted by Shiki in the theme's colors, as it streams too, and a language Shiki doesn't know shows as plain text. Finished code highlighted earlier in the session is colored from its block's first frame, as when a reply's record replaces its stream, rather than drawn plain and then again in color. `highlight.ts` keeps the tokens of the last 200 finished blocks for that. Math between `$$` renders with KaTeX, loaded the first time a text holds some, and emphasis beside Chinese, Japanese, and Korean punctuation works. Raw HTML is not rendered and shows as the text it is, links open in a new tab, apart from links within the text, such as a footnote and its way back, which scroll to their target in the same reply, since every reply numbers its footnotes from one, a fenced code block has buttons in its corner that copy its code and download it as a file named for its language, as `code.py`, once its fence closes, a table has menus in its corner that copy it as Markdown, CSV, or TSV and download it as CSV or Markdown, through Streamdown's table helpers with our own buttons and menu, and these show on hover and always on a touch screen, and images show their alt text, full URL, and a load button instead of loading, so injected content cannot leak the chat through an image URL without the user choosing to load it. User text is shown as plain text, except that runs of lines starting with `>`, as a quote from a reply sends them, show as quotes without their markers, in the same bubble as the text, through `splitQuotes` in `parts/quotes.ts`. Full Markdown for user text is planned. - Parts show in the order the model produced them. Text, sources, images, and files show through `partViews` in `src/parts/index.ts`, and each run of reasoning and tool parts is one block of steps, drawn by `StepsView`, a `
` whose summary names the steps, such as "Thought · web_search ×2". A block of one step shows that step alone. A block shows only while the model is working in it, so once the reply's text starts streaming the work gives way to it. The block stays mounted but hidden, so its steps' times are still recorded. A finished reply keeps its steps behind a button at the end of its footer, named by their summary across every block, as "Thought for 12s · web_search", which opens them as one list under the footer and keeps the footer shown while open. That list has no cards or numbers: each step sits on a quiet rail with a light dot, green when its tool finished, red when it failed, and grey for a thought, joined to the next by a short soft segment. A reasoning step is a row like a tool step's, naming it, "Thought for 12s", or "Thinking" while it runs, with a muted glimpse of its first line of text, its heading, quote, and list marks and inline Markdown left out. It opens onto the whole reasoning as Markdown, and the glimpse goes while it is open. A tool call and its result, matched by `callId`, are one step whose summary shows the tool and a glimpse of its input, which goes while the step is open, and whose body shows the input and output pretty-printed when they are JSON, with a link to `outputBlob` when there is one. A failed tool or a call with no result is marked as such. A step renders its body only once it is first opened, and keeps it once closed, so a closed thought doesn't parse every token streaming into it and a closed tool step doesn't hold all the tool returned. - The footer's steps and each step start closed, and the reader opens what they want to see. - Sources are shown after the parts behind their count, such as "3 sources", a `
` whose summary is a grey chip shaped like the source rows, with a link icon before the count and a chevron after it, and which starts closed, so the sources stay out of the reply's way until asked for. Opening it shows them as a numbered list, one source to a line. Each is a link named by its title, followed by its site, or named by its site alone when it has no title, with the full URL on hover, and a long title is cut short to keep its line. A streaming reply's sources join the list as they arrive. The list is not folded, since opening the count already asks for every source. The search results in a web search step fold past seven through `useFold` in `src/parts/fold.ts`. -- knotmirror 0.0.1