= {
+ system: ,
+ light: ,
+ dark: ,
+}
+
+/**
+ * The account menu's row for light or dark on this device, the same choice as Preferences' Theme:
+ * a label lined up with the rows around it, and a button for each choice at its end, the chosen one
+ * tinted as the search toggle is when pressed. Choosing leaves the menu open, so the change shows
+ * behind it. The menu mounts only once opened in the browser, so unlike Preferences it needs no
+ * stand-in for the server's page, which can't know the choice.
+ */
+export function ThemeSwitch() {
+ const theme = useThemeChoice()
+ const label = useId()
+
+ return (
+ // The label half of sidebarRow, without its hover, and tinting only its own icon, since the
+ // buttons' icons follow whether they are pressed.
+
+
+ Theme
+
+ {themeChoices.map((choice) => (
+ chooseTheme(choice.value)}
+ className="flex size-row cursor-pointer items-center justify-center rounded-row text-muted transition-colors duration-(--dur-feedback) hover:bg-row-hover aria-pressed:bg-row-active aria-pressed:text-ink"
+ >
+ {icons[choice.value]}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/features/settings/pages/PreferencesSettings.tsx b/apps/web/src/features/settings/pages/PreferencesSettings.tsx
index 47b3225..1b68586 100644
--- a/apps/web/src/features/settings/pages/PreferencesSettings.tsx
+++ b/apps/web/src/features/settings/pages/PreferencesSettings.tsx
@@ -4,7 +4,12 @@ import { ClientOnly } from '@tanstack/react-router'
import { ErrorAlert } from '../../../shared/ErrorAlert.tsx'
import { formKey, useAppForm } from '../../../shared/form.tsx'
import { ChevronDownIcon } from '../../../shared/icons.tsx'
-import { chooseTheme, type ThemeChoice, useThemeChoice } from '../../../shared/theme.ts'
+import {
+ chooseTheme,
+ type ThemeChoice,
+ themeChoices,
+ useThemeChoice,
+} from '../../../shared/theme.ts'
import { Select } from '../../../shared/ui/Select.tsx'
import {
type Save,
@@ -52,12 +57,6 @@ export function PreferencesSettings() {
)
}
-const themes: { value: ThemeChoice; label: string }[] = [
- { value: 'system', label: 'System' },
- { value: 'light', label: 'Light' },
- { value: 'dark', label: 'Dark' },
-]
-
/** Light or dark for this device, applied at once and kept here, apart from the saved preferences. */
function Appearance() {
return (
@@ -82,7 +81,7 @@ function ThemeSelect() {
return (
chooseTheme(value as ThemeChoice)}
/>
diff --git a/apps/web/src/shared/icons.tsx b/apps/web/src/shared/icons.tsx
index 79e6e67..bae8a9e 100644
--- a/apps/web/src/shared/icons.tsx
+++ b/apps/web/src/shared/icons.tsx
@@ -503,6 +503,44 @@ const SoftAlert = createLucideIcon('soft-alert', [
['circle', { cx: '12', cy: '15.75', r: '0.4', fill: 'currentColor', key: 'dot' }],
])
+/**
+ * Lucide's contrast in Info's softer circle, for the theme: a smaller solid half inside, so it is
+ * no heavier than the icons beside it.
+ */
+const SoftContrast = createLucideIcon('soft-contrast', [
+ ['circle', { cx: '12', cy: '12', r: '8.5', key: 'ring' }],
+ ['path', { d: 'M12 7a5 5 0 0 1 0 10z', fill: 'currentColor', key: 'half' }],
+])
+
+/**
+ * Lucide's sun made puffy, to sit with the bubble letters of the logo: a bigger round middle, and
+ * eight short rays with round ends floating off it, nearly dots at a small size.
+ */
+const PuffySun = createLucideIcon('puffy-sun', [
+ ['circle', { cx: '12', cy: '12', r: '4.5', key: 'disc' }],
+ [
+ 'path',
+ {
+ d: 'M19.75 12H21M17.48 17.48l.88.88M12 19.75V21M6.52 17.48l-.88.88M4.25 12H3M6.52 6.52l-.88-.88M12 4.25V3M17.48 6.52l.88-.88',
+ key: 'rays',
+ },
+ ],
+])
+
+/** Lucide's moon at Info's softer radius, with a shallower bite, so the crescent is fuller. */
+const SoftMoon = createLucideIcon('soft-moon', [
+ ['path', { d: 'M12 3.5a6.5 6.5 0 0 0 8.5 8.5 8.5 8.5 0 1 1-8.5-8.5z', key: 'moon' }],
+])
+
+/**
+ * Lucide's monitor made puffy, to sit with the bubble letters of the logo: a wide screen rounded
+ * deep at its corners, over a short bar for a stand, floating as the sidebar toggle's bar does.
+ */
+const PuffyMonitor = createLucideIcon('puffy-monitor', [
+ ['rect', { width: '18', height: '12', x: '3', y: '4.5', rx: '4', key: 'screen' }],
+ ['path', { d: 'M9 20h6', key: 'stand' }],
+])
+
export const PlusIcon = icon(SoftPlus)
export const NewChatIcon = icon(PuffyChatPlus)
export const HashIcon = icon(SoftHash)
@@ -554,6 +592,10 @@ export const BulbIcon = icon(PuffyBulb)
export const SparkleIcon = icon(PuffySparkles)
export const AlertIcon = icon(SoftAlert)
export const RetryIcon = icon(PuffyRetry)
+export const ContrastIcon = icon(SoftContrast)
+export const SunIcon = icon(PuffySun)
+export const MoonIcon = icon(SoftMoon)
+export const MonitorIcon = icon(PuffyMonitor)
const LoaderIcon = icon(LoaderCircle)
diff --git a/apps/web/src/shared/theme.ts b/apps/web/src/shared/theme.ts
index f45ddd1..5835de9 100644
--- a/apps/web/src/shared/theme.ts
+++ b/apps/web/src/shared/theme.ts
@@ -3,6 +3,13 @@ import { useSyncExternalStore } from 'react'
/** The scheme a device shows: the system's light or dark, or one fixed in Preferences. */
export type ThemeChoice = 'system' | 'light' | 'dark'
+/** Each choice with its name, in the order the pickers show them. */
+export const themeChoices: { value: ThemeChoice; label: string }[] = [
+ { value: 'system', label: 'System' },
+ { value: 'light', label: 'Light' },
+ { value: 'dark', label: 'Dark' },
+]
+
export const themeKey = 'scn-chat:theme'
/**
diff --git a/apps/web/src/shared/ui/Separator.tsx b/apps/web/src/shared/ui/Separator.tsx
new file mode 100644
index 0000000..fdc9341
--- /dev/null
+++ b/apps/web/src/shared/ui/Separator.tsx
@@ -0,0 +1,16 @@
+import { cn } from '../cn.ts'
+
+interface Props {
+ /** Changes the room around it where it is used. */
+ className?: string
+}
+
+/**
+ * A hairline between groups, such as the rows of a menu, one device pixel thick on a 2x screen,
+ * with room above and below to set the groups apart.
+ */
+export function Separator(props: Props) {
+ return (
+
+ )
+}
diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css
index 6c670c6..b228598 100644
--- a/apps/web/src/styles/theme.css
+++ b/apps/web/src/styles/theme.css
@@ -113,6 +113,9 @@
--color-tint-quiet: light-dark(oklch(0.555 0 0), oklch(0.635 0 0));
--color-line: light-dark(oklch(0.894 0 0), oklch(0.302 0 0));
+ /* A separator's hairline between groups, such as a menu's rows: a card's border in light, and a
+ step brighter in dark, where a hairline at the border's shade would vanish against a menu. */
+ --color-separator: light-dark(oklch(0.89 0 0), oklch(0.34 0 0));
/* A field's border while you type in it. */
--color-line-strong: light-dark(oklch(0.865 0 0), oklch(0.348 0 0));
/* The line between the sidebar and the panel, wider and quieter than a card's border. */
@@ -399,6 +402,8 @@
/* A floating menu's padding, the gap between New chat and the search button, and its edge. */
--menu-pad: 0.35rem;
--menu-edge: 2px;
+ /* A hairline: 1px, halved on a screen of twice the density or more, so one device pixel at 2x. */
+ --hairline: 1px;
/* Icon strokes are drawn in screen pixels whatever the icon's size, at the weight of the text
beside them: regular text, or the medium text of a control. */
--icon-stroke: 1.25;
@@ -410,6 +415,12 @@
--input-floor: 0px;
}
+ @media (resolution >= 2dppx) {
+ :root {
+ --hairline: 0.5px;
+ }
+ }
+
@media (pointer: coarse) {
:root {
--input-floor: 16px;
diff --git a/apps/web/tests/features/chat-list/components/ChatList.test.tsx b/apps/web/tests/features/chat-list/components/ChatList.test.tsx
index 33c05df..435114d 100644
--- a/apps/web/tests/features/chat-list/components/ChatList.test.tsx
+++ b/apps/web/tests/features/chat-list/components/ChatList.test.tsx
@@ -450,4 +450,29 @@ describe('ChatList sidebar', () => {
await userEvent.keyboard('{Escape}')
expect(screen.queryByRole('link', { name: 'Settings' })).toBeNull()
})
+
+ it('switches the theme from the account, and stays open to show it', async () => {
+ stubFetch()
+ await renderSidebar()
+ await userEvent.click(screen.getByRole('button', { name: 'Account' }))
+ const theme = screen.getByRole('group', { name: 'Theme' })
+ expect(within(theme).getByRole('button', { name: 'System' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
+ await userEvent.click(within(theme).getByRole('button', { name: 'Dark' }))
+ expect(document.documentElement.dataset.theme).toBe('dark')
+ expect(localStorage.getItem('scn-chat:theme')).toBe('dark')
+ expect(within(theme).getByRole('button', { name: 'Dark' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
+ expect(within(theme).getByRole('button', { name: 'System' })).toHaveAttribute(
+ 'aria-pressed',
+ 'false',
+ )
+ await userEvent.click(within(theme).getByRole('button', { name: 'System' }))
+ expect(document.documentElement.dataset.theme).toBeUndefined()
+ expect(screen.getByRole('link', { name: 'Settings' })).toBeInTheDocument()
+ })
})
diff --git a/specs/styling.md b/specs/styling.md
index 7d507eb..3d454d2 100644
--- a/specs/styling.md
+++ b/specs/styling.md
@@ -2,7 +2,7 @@
## Summary
-The web app's look comes from one theme file of design tokens, and each component styles itself with Tailwind utilities that use only those tokens. The default look is the design built on the `design-reference` branch, ported onto main's components along with the behavior that design added. Light and dark follow the system, and a picker in Preferences can fix one on a device. Code blocks in replies are highlighted in the theme's colors.
+The web app's look comes from one theme file of design tokens, and each component styles itself with Tailwind utilities that use only those tokens. The default look is the design built on the `design-reference` branch, ported onto main's components along with the behavior that design added. Light and dark follow the system, and a picker in Preferences or the account menu can fix one on a device. Code blocks in replies are highlighted in the theme's colors.
## Motivation
@@ -50,7 +50,7 @@ Every color is `light-dark(, )` in oklch, so dark mode needs no `da
|---|---|---|
| Surfaces | `chrome`, `panel` (was `--bg`), `sidebar`, `surface`, `sunken`, `card` (was `--group`), `well` (was `--quote-well`), `raised`, `raised-hover`, `lifted`, `bubble`, `hover`, `row-hover`, `row-active`, `row-current`, `row-hover-solid`, `knob`, `illo-face`, `illo-rim`, `illo-back`, `logo-face`, `logo-rim` | `bg-*`, `fill-*` |
| Ink | `ink` (was `--text`), `ink-strong` (was `--strong`), `prose`, `muted`, `muted-quiet`, `faint`, `icon-quiet`, `row-ink`, `section-ink`, `tint-quiet` | `text-*` |
-| Lines | `line` (was `--border`), `line-strong`, `divider`, `bevel`, `image-edge` | `border-*`, `outline-*` |
+| Lines | `line` (was `--border`), `line-strong`, `divider`, `separator`, `bevel`, `image-edge` | `border-*`, `outline-*` |
| Accent | `accent`, `accent-strong`, `accent-soft`, `accent-soft-hover`, `accent-border`, `accent-mark`, `on-accent`, `focus` (was `--focus-ring`) | any color utility |
| Danger | `danger`, `danger-soft`, `danger-soft-hover`, `danger-border`, `danger-muted` | any color utility |
| Highlight | `highlight`, `highlight-ink` | any color utility |
@@ -66,9 +66,9 @@ Every color is `light-dark(, )` in oklch, so dark mode needs no `da
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`, `--hairline`, `--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.
-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.
+Three tokens change with the device, in `theme.css` below the tokens: `--spacing-gutter` narrows to 1rem below the `wide` breakpoint, `--input-floor` is 16px on a coarse pointer, so iOS never zooms into a field, and `--hairline` halves to 0.5px on a screen of twice the density or more, so a hairline is one device pixel at 2x.
Spacing literals from `design-reference` snap to the nearest quarter step of the scale, a sixteenth of a rem, such as .35rem to `1.5` (.375rem) and .45rem to `1.75` (.4375rem). Sizes the design names, like the sidebar's width or a row's height, stay named sizes.
@@ -94,6 +94,7 @@ Spacing literals from `design-reference` snap to the nearest quarter step of the
- `controls.tsx`: the `field` look of text fields, selects, and textareas, `Switch`, and `Radio`. The switch stays a checkbox to the browser and screen readers, and the switch and radio draw their knob and dot as elements beside the input, so every browser can show them.
- `Select.tsx`: the select-only combobox from `design-reference`, its list rendered into the body and placed by `usePlacement` in `src/shared/`. A portal rather than the Popover API keeps it testable in jsdom. It scrolls the active option into view only when the active option changes, since the list re-renders as it scrolls, and scrolling back then would keep a long list from reaching its end. With `searchLabel`, a list of eight options or more, about as many as it shows before it scrolls, starts with a search box named by it that takes the focus, and only the options under it scroll. On a touch screen the box waits for a tap instead, since its keyboard would cover the list, and the list moves to stay in the part of the window the reader can see while the keyboard comes and goes. Typing narrows the options to those holding every word typed, in any case, in their label or `keywords`, says "No matches" through a status that is there from the start, and moves to the first that matches. An option's id comes from its place among every option, so the active id changes with each move and screen readers follow it. The arrow keys, Page Up, Page Down, and Enter work from the box, while Home, End, and Space stay the field's. Escape closes the list and Tab moves on, each from the button, which takes the focus back, as it does after a pick, and a letter typed on the closed button opens the list searching for it.
- `menu.ts`: the look of floating menus and panels and of the rows in them, shared by the select's list and the app's menus.
+- `Separator.tsx`: `Separator`, a hairline in `separator` between groups, such as the account menu's theme row and the rows around it, with room above and below that a `className` can change.
- `PanelCard.tsx`: `PanelCard`, a floating panel of controls under its title, such as a chat's sharing or instructions, and `panelForm`, the class for a form around one, which lays out no box of its own so the card takes no room in the row it opens from.
- `FileChip.tsx`: `FileChip`, a file as a well with a quote's corner, its preview or a file icon, its name over a detail line from `detailOf`, such as "PDF · 1.2 MB", both at medium weight as a chip's label is, the detail set apart only by its grey, and whatever ends it, shared by the composer's attachments and a sent message's files.
- `CopyButton.tsx`: Copy, as words or as an icon that cross-fades to a check.
@@ -108,6 +109,7 @@ The form kit in `src/shared/form.tsx` gives its text, textarea, and select field
- With no choice, the page follows the system: `:root` has `color-scheme: light dark`, and every `light-dark()` token follows it.
- Preferences has an Appearance setting of System, Light, or Dark. It is kept on the device in `localStorage` under `scn-chat:theme`, since a person often wants different modes on different devices, and needs no record.
+- The account menu has the same choice as a Theme row, with a button for each at its end, the chosen one tinted. Both read and write the same store, so they stay in step, and choosing from the menu leaves it open, so the change shows behind it.
- Light or Dark sets `data-theme` on ``, which `base.css` turns into `color-scheme: light` or `dark`. System removes it.
- An inline script in the root route's `head` applies the stored choice before the first paint, on server-rendered and client-only routes alike. It ignores a missing or unreadable store.
- `useInstantThemeSwitch` turns transitions off for two frames whenever the scheme changes, from the system or the picker, so colors change at once rather than smearing.
@@ -146,6 +148,7 @@ The port is done. Main's stand-in sheets, the `legacy` layer they loaded in, and
- [x] A select's long list can be scrolled to its end without jumping back to the active option.
- [x] Changing a color token in `theme.css` changes it everywhere it is used, in light and dark.
- [x] Appearance in Preferences switches between System, Light, and Dark at once, and the choice survives a reload with no flash, on server-rendered and client-only routes.
+- [x] The account menu's Theme row switches between System, Light, and Dark at once, marks the choice, and stays in step with Preferences.
- [x] The guard test fails on a raw color or arbitrary length in a component.
- [x] Every area matches `design-reference` in light and dark, at desktop width and at 390px.
- [x] Animations stop under reduced motion.
@@ -156,8 +159,8 @@ The port is done. Main's stand-in sheets, the `legacy` layer they loaded in, and
- `apps/web/src/styles/`
- `apps/web/src/shared/ui/`, `apps/web/src/shared/cn.ts`, `apps/web/src/shared/icons.tsx`, `apps/web/src/shared/theme.ts`, `apps/web/src/shared/useInstantThemeSwitch.ts`, `apps/web/src/logo.svg`
-- `apps/web/tests/styles/theme.test.ts`, `apps/web/tests/shared/theme.test.tsx`
+- `apps/web/tests/styles/theme.test.ts`, `apps/web/tests/shared/theme.test.tsx`, `apps/web/tests/features/chat-list/components/ChatList.test.tsx`
- `apps/web/src/parts/markdown.tsx`, `apps/web/src/parts/markdown.css`
- `apps/web/src/routes/__root.tsx`
-- `apps/web/src/features/settings/pages/PreferencesSettings.tsx`
+- `apps/web/src/features/settings/pages/PreferencesSettings.tsx`, `apps/web/src/features/auth/components/ThemeSwitch.tsx`
- `apps/web/vite.config.ts`, `biome.json`
diff --git a/specs/web-ui.md b/specs/web-ui.md
index 6ae5996..0763fe5 100644
--- a/specs/web-ui.md
+++ b/specs/web-ui.md
@@ -70,7 +70,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query
- New chat, which goes to `/`, so a chat is only created once its first message is sent, with a search button beside it that shows the search box below, focused, and hides it again. The box shows from the start when the URL holds a search. Hiding it, by the button or by Escape in the box, clears the search, and Escape returns focus to the button.
- Tags, when any chat has some: a section that folds away under its heading, with a row for every tag across the chats and how many chats carry it, which filters the list to that tag, or shows every chat again when picked while active. A button at the heading's end clears the filter. Tags come from each chat's index entry.
- The chats, newest first, under date headings by `updatedAt` in local time: Today, Yesterday, Previous 7 days, Previous 30 days, then by month, with the year for other years. Each chat is one line. Untitled chats show "New chat". A chat with a pending reply shows a breathing green dot labeled "Replying", and one whose newest complete reply this device hasn't seen shows a still dot labeled "New reply" and a bolder title. A menu on each chat, shown on hover or focus and always on a touch screen, renames it, edits its tags as a comma-separated list cut to the lexicon's 32 tags of 64 characters, or deletes it after asking, through the chat routes, then syncs the index. Renaming, tagging, or confirming a delete takes the chat's place in the list as a card. Deleting the open chat goes to `/`. The chats load 50 at a time, the next page as the end of the list nears, with a few skeleton rows while it loads. A tag's count and the filter cover every chat, and the filter pages through only its chats. Only the chats, or the search's results, scroll, fading out at both ends.
- - An account card shows who is signed in: their avatar, display name, and `@handle` from `/api/me/profile` and `/api/me`. Without a display name it shows the handle alone, and without an avatar, or when it fails to load, the name's first letter in a rounded square. A dot on the avatar's corner shows how current this device's copy is: green when synced, breathing while syncing or downloading, grey when open in another tab, and red when it failed, with the status in words on hover: "Synced 2 min ago", "Downloading 3 chats…", "Syncing…", or that the copy is open in another tab or failed to open. The card is a button that opens a menu above it. The menu starts with where the chats live, "Stored on your PDS", or "Stored on this server" with "Your PDS doesn't support spaces yet" beneath, then the same dot, the status in words, and a Sync button that syncs the index now, then Settings, Admin for admins, and Sign out. Escape or a click outside closes it.
+ - An account card shows who is signed in: their avatar, display name, and `@handle` from `/api/me/profile` and `/api/me`. Without a display name it shows the handle alone, and without an avatar, or when it fails to load, the name's first letter in a rounded square. A dot on the avatar's corner shows how current this device's copy is: green when synced, breathing while syncing or downloading, grey when open in another tab, and red when it failed, with the status in words on hover: "Synced 2 min ago", "Downloading 3 chats…", "Syncing…", or that the copy is open in another tab or failed to open. The card is a button that opens a menu above it. The menu starts with where the chats live, "Stored on your PDS", or "Stored on this server" with "Your PDS doesn't support spaces yet" beneath, then the same dot, the status in words, and a Sync button that syncs the index now, then Settings, Admin for admins, a Theme row with System, Light, and Dark buttons, set apart by a line above and below, and Sign out. Escape or a click outside closes it, and choosing a theme leaves it open.
What this device has seen is kept in `localStorage` per account, as the newest seen reply of each chat. The first list on a device seeds it, so older replies don't all show as new, and the open chat's newest reply counts as seen. Icons come from `src/shared/icons.tsx`: Lucide's, and the app's own puffier ones drawn to sit with the logo's bubble letters, in the text color and hidden from assistive technology, which a fork can swap for another set.
- **Conversation.** It shows the messages on the current branch, from the root to the selected leaf. A message with siblings shows "2/3" between chevrons that switch branches. By default the branch follows the newest sibling at each level, and below a focused message it follows the newest sibling at each level after it. A conversation with no messages, once its refresh from the PDS has finished, says to describe what they need or drop in a file, an instruction rather than a question so it hints at what the app takes, where the chat is saved, and offers a few starters, each of which begins the message in the composer for the user to finish. With no chat open, on `/`, the page is that empty conversation with its composer, whose first message creates the chat, sends to it, and opens it. A send that fails after creating the chat reuses that chat when tried again, so a retry never makes a second one.
@@ -94,7 +94,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query
- **Composer.** A textarea that grows with its text up to a cap, then scrolls, whose placeholder instructs rather than asks: "Ask anything" in a new chat, and "Reply" under a message, and nothing while an open chat's messages are on their way, so a chat opened on a reload never says one and then the other, and a chat just started goes from "Ask anything" to nothing to "Reply" as it opens, and a row under it with an attach button, a model select from `/api/models`, and Send. Attach and Send are icon buttons at either end of the row, a paperclip that opens the hidden file input and an up arrow, so the main action always sits in the corner. The model select sits just before Send. Once its list holds eight entries or more, about as many as it shows before scrolling, it opens with a Search models box, which finds a model by its name, its ID, or its provider, as the model select in Preferences does. For a model with the reasoning capability, the chosen one or, with none chosen, the model the server falls back to, a quiet effort select, the same one as in Preferences, sits beside it, between it and Send, and shows the chosen level's pie and name. Each message names the model that answers it, the one chosen or, with none chosen, the one the select names, so a chat started on the default keeps that model whatever later becomes of the default. In a chat with a model of its own, the model last used on the branch, the select shows that model by name and offers no "Default", following a switch of model from the moment the message that made it arrives, through a reply that is running, stopped, or failed, and on reopening the chat. A chosen model the catalog no longer offers shows as unavailable, and is never swapped for another. Before the chat has a model, "Default" is the default from settings, the user's or the admin's, and names it, as "Default · Claude Sonnet", once the catalog offers it, and says "Default model" otherwise. While an open chat's messages are on their way, the select shows a loading line and no effort, since nothing yet says which model the chat uses. The theme grows the textarea with `field-sizing: content`, and where a browser lacks it, `useAutoGrow` sets its height from its text. The effort is sent only while its select is shown. It starts on the effort last chosen in the composer on this device, kept in `localStorage` as a convenience, or on Auto. The attach button accepts images only for models with vision, plus any type an ingester supports. With no model chosen it follows the fallback model's capabilities when the catalog lists it, and otherwise accepts images and leaves the check to the server. Files also attach when pasted into the textarea or dropped anywhere on the conversation, which marks itself with `data-dropping` while files are dragged over it. A paste that carries text as well as files pastes as text, since office apps copy a picture of the copied text alongside it. Pasted and dropped files skip the attach button's filter, so the server refuses a type no ingester reads, and an image for a model without vision is refused before upload. Each file uploads as soon as it is added and shows as a chip above the textarea, with a local preview for an image or a file icon, its name over its kind and size, as "PDF · 1.2 MB", with "Uploading…" in their place until its upload finishes, and a button that removes it. Removing a file while it uploads cancels the upload. A failed upload drops its chip and says which file failed and why. An image attached before switching to a model without vision stays, and Send is disabled under a note that the model can't read images, until the image is removed or a model that can is chosen. Send waits until every upload has finished, and sending lets go of the attachments it sent, keeping any added while it was on its way. Quotes from a reply show above the attachments, each as a block quote across the card cut to two lines, the whole text on hover, and a button that removes it, so the user sees what the model will be shown apart from what they wrote. Sending puts each quote at the start of the message as a Markdown block quote, in the order they were added, above the text, and lets go of the quotes it sent, keeping any added while it was on its way. Send is disabled while there is nothing to send, no text but spaces, no quotes, and no attachments. Once the composer holds any text, quote, or attachment, a Clear button, an × in the top corner of the box, as a field's own clear button rather than among the controls under it, empties the textarea, removes every quote and attachment, cancelling uploads still running, clears the composer's error, and focuses the textarea. It is disabled while a message is on its way, since sending clears the composer itself. Each paragraph in the textarea runs in the direction of its own text, as Persian runs right to left, while the box keeps the page's direction, so text either way stops short of Clear. Enter sends, and Shift+Enter adds a newline. On a touch screen, which has no hover, Enter adds a newline too and Send sends, as phone keyboards lead people to expect, and an Enter that an input method uses to pick its words, as for Japanese, never sends. Send is disabled while a message is on its way, and Enter does nothing then, so a message is sent once. Until an open chat's messages arrive, Send is disabled and Enter does nothing too, since the message would not yet know which one it answers. While the reply at the end of the branch runs, Stop, a square icon, takes Send's place and Enter does nothing, and that reply's own actions leave Stop to the composer. A composer editing an earlier message says so above the box, that sending adds the message as a new version, with a Cancel button, and Escape in the textarea cancels too. Its Send's tooltip says "Send as a new version", and it sends as usual. The file input clears after each pick, so choosing the same file again attaches it again. The model select offers "Default model" only when the server can choose a model without one, following chat-turns: the user's preferences or the admin set a default. Otherwise the composer asks for a model, and sending waits until one is chosen. When `/api/models` lists no models at all, Send is disabled and a note points to Settings to add an API key.
- While the page's code loads, the composer's frame stands in for it, in the same card at the same place: a plain textarea with the composer's placeholder, which is "Ask anything" for a new chat and nothing for an open one, whose messages haven't said yet whether it replies, the attach button, the model select's loading line, and Send, all disabled but the textarea. The server sends it with the page's frame, so the box takes typing before any script runs, and Enter only adds a line, since there is nothing yet to send it. The conversation's column shows it again while its own code downloads. Each frame that gives way leaves what its box holds, with the cursor and whether it had the focus, in `composer-handoff.ts`, and the frame or composer that takes its place shows the text, puts the cursor back, and takes the focus, so the swap can't be seen. A frame with no text that never had the focus leaves nothing. Only a composer that starts empty takes a draft, never one editing a message, and a draft nothing took, as when the page fails to load, waits for the next one. The frame and the composer are built from the same parts, in `composer-parts.tsx`. The composer's own parts, in `Composer.tsx`, read its draft from a `Composer.Provider`, and `ReplyComposer` and `EditComposer` each compose the parts they show.
- **Settings.** A sidebar links back to the chats and to each section, with an icon, marks the current one, and links to the admin area for admins. Under the links, a quiet line names the app's version, from the root `package.json`, so anyone can tell which release a deployment runs. Signing out is in the chat sidebar's account menu. Only the current section is shown, under its title and a line saying what it is for, as cards of related settings in one narrow column. A setting is a row with its label and a description on the left and its control on the right, which drops under them on a narrow screen. On/off settings are checkboxes drawn as switches, except several on one row, such as what a key's model can do, which are toggle chips. A form's Save sits in a bar that sticks to the foot of the panel, shows "Saved" after a save until the next change, and shows a failed save's message. Save is disabled until the form differs from what was saved, and the bar says "Unsaved changes" beside it until then, though not beside a button that adds, such as Add plugin. Leaving the page doesn't ask, so unsaved changes are lost. Below 720px the sidebar is a drawer, as on the chat page, opened by a toggle at the top of the page and closed by the backdrop or by following a link in it. The admin area shares this layout and these pieces, which live in `src/shared/ui/SettingsPage.tsx`.
- - Preferences: a theme of System, Light, or Dark under Appearance, which applies at once and is kept on this device in `localStorage` rather than in the preferences record, as the styling spec says. Then default model and thinking effort under Models, custom instructions, and generate titles under Chats. Saving also stores the browser's time zone, which the app saves on sign-in as well when it differs from the stored one. A stored default model the model list no longer offers, as after its key is deleted, shows as unavailable and is kept on save until the user picks another. When the preferences can't load, the section shows why and no form.
+ - Preferences: a theme of System, Light, or Dark under Appearance, which applies at once and is kept on this device in `localStorage` rather than in the preferences record, as the styling spec says. The account menu offers the same choice. Then default model and thinking effort under Models, custom instructions, and generate titles under Chats. Saving also stores the browser's time zone, which the app saves on sign-in as well when it differs from the stored one. A stored default model the model list no longer offers, as after its key is deleted, shows as unavailable and is kept on save until the user picks another. When the preferences can't load, the section shows why and no form.
- API keys: a list of the user's keys, each with its name, provider, how many models it covers, and last four characters, or a note when there are none. Each row opens the key's page. Deleting a key asks to confirm in its row first. Below it, a form to add a key, with its labels over its fields: provider, key, an optional name, and model IDs, with a button to load the provider's models where it lists them. Loaded models appear below the field as a checklist, none ticked, and the key is added with the ticked ones and any typed IDs. For providers with user endpoints, base URL and slug fields. The slug fills in from the name as it is typed, until the user edits the slug, and clearing it hands it back to the name. A key's page (`/settings/api-keys/`), under a link back to the list, is titled with the key's name over its provider and base URL. Its Models card has a row for each model: its name, its ID when that differs, Vision, Thinking, and Tools as chips that stay pressed while on, and a button that removes it. In a narrow column the chips go under the name. Under it, Add models has a field to type a model ID, where Enter adds rather than saves, and, for providers that list their models, Load models, which lists them with the saved key to tick the ones to use. Unticking one takes it out of the draft, as its remove button does. A key that can't be read anymore shows a red note asking for it to be deleted and added again, and a page for a key that isn't saved anymore says so in place of the form. The changes are one draft under one Save, which is disabled while the key has no models. Below the draft, a red-edged card deletes the key, asking to confirm in place first, then returns to the list. The API keys section stays marked in the sidebar there.
- Plugins: one form with a card per plugin and a single Save, which saves every plugin's settings and any tool switches that changed. A card has a switch for each tool the user may switch, labeled "Enabled" when it is the plugin's only one and by tool name otherwise, with the tool's description, then the fields generated from the plugin's JSON schema. When no plugin has settings or switches for the user, the section says so rather than showing nothing.
- Sync: a note saying where the user's chats are stored, their PDS or this server, then the background sync switch, shown only when the admin allows users to opt out, and a button that deletes and rebuilds the browser store, which asks to confirm in place first. The switch moves as soon as it is clicked.