From 928e905235384e00b5224e709c23e771d5e61489 Mon Sep 17 00:00:00 2001
From: "pouriade.com"
Date: Tue, 27 Oct 2026 21:12:13 -0700
Subject: [PATCH] Share typography as Text and Title
Page titles copied six classes three times, and hints and notes wrote
their size, color, and weight by hand. Text sizes them on one xs to
3xl ladder over the type scale, and Title separates a heading's level
from its size, as Mantine's do.
---
CHANGELOG.md | 4 ++
.../admin/components/SuspendControl.tsx | 5 +-
.../features/auth/components/AccountCard.tsx | 5 +-
.../web/src/features/auth/pages/LoginPage.tsx | 5 +-
.../chat-list/components/SearchResults.tsx | 9 +--
.../chat-list/components/SyncStatus.tsx | 5 +-
.../chat-list/components/TagFilter.tsx | 5 +-
.../conversation/components/DeletePanel.tsx | 5 +-
.../components/EmptyConversation.tsx | 10 ++-
.../components/InstructionsPanel.tsx | 5 +-
.../conversation/components/PendingReply.tsx | 5 +-
.../conversation/components/PromptStack.tsx | 5 +-
.../components/StoreUnavailable.tsx | 9 +--
.../conversation/components/TagsPanel.tsx | 5 +-
.../features/models/components/ModelTable.tsx | 5 +-
.../settings/pages/ApiKeySettings.tsx | 9 +--
.../settings/pages/SettingsLayout.tsx | 5 +-
.../src/features/sharing/pages/SharedPage.tsx | 10 +--
apps/web/src/parts/tools/ui.tsx | 5 +-
apps/web/src/parts/tools/web-fetch/index.tsx | 5 +-
apps/web/src/shared/ErrorPage.tsx | 10 +--
apps/web/src/shared/SectionNav.tsx | 11 +++-
.../src/shared/schema-fields/SchemaFields.tsx | 5 +-
apps/web/src/shared/ui/ItemList.tsx | 11 +++-
apps/web/src/shared/ui/SettingsPage.tsx | 20 +++---
apps/web/src/shared/ui/Text.tsx | 61 +++++++++++++++++++
apps/web/src/shared/ui/Title.tsx | 39 ++++++++++++
specs/styling.md | 2 +
28 files changed, 222 insertions(+), 58 deletions(-)
create mode 100644 apps/web/src/shared/ui/Text.tsx
create mode 100644 apps/web/src/shared/ui/Title.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e56aef..ae5be05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ What changed in each release of SCN Chat, for the people who use it, run it, or
## [Unreleased]
+### Changed
+
+- The new chat's "What can I help with?" draws in a touch, as other titles do. For forks: `shared/ui/Text.tsx` has a new `Text`, sized on one `xs` to `3xl` ladder over the type scale, with a `color` and `weight`, and `shared/ui/Title.tsx` a new `Title`, whose `order` is its heading level and `size` its look. Page titles, hints, and other plain text now use them.
+
## [0.7.2] - 2026-10-27
### Changed
diff --git a/apps/web/src/features/admin/components/SuspendControl.tsx b/apps/web/src/features/admin/components/SuspendControl.tsx
index 6199f11..46d30ed 100644
--- a/apps/web/src/features/admin/components/SuspendControl.tsx
+++ b/apps/web/src/features/admin/components/SuspendControl.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useAppForm } from '../../../shared/form.tsx'
import { Badge } from '../../../shared/ui/Badge.tsx'
import { Button } from '../../../shared/ui/Button.tsx'
+import { Text } from '../../../shared/ui/Text.tsx'
import type { AdminUser } from '../queries.ts'
interface Props {
@@ -34,9 +35,9 @@ export function SuspendControl(props: Props) {
Suspended
-
+
{note}
-
+
diff --git a/apps/web/src/features/chat-list/components/TagFilter.tsx b/apps/web/src/features/chat-list/components/TagFilter.tsx
index 006ac3e..c373690 100644
--- a/apps/web/src/features/chat-list/components/TagFilter.tsx
+++ b/apps/web/src/features/chat-list/components/TagFilter.tsx
@@ -3,6 +3,7 @@ import { HashIcon } from '../../../shared/icons.tsx'
import { plural } from '../../../shared/plural.ts'
import { CloseButton } from '../../../shared/ui/CloseButton.tsx'
import { sidebarRow } from '../../../shared/ui/sidebar.ts'
+import { Text } from '../../../shared/ui/Text.tsx'
import { FoldingSection } from './FoldingSection.tsx'
interface Props {
@@ -48,7 +49,9 @@ export function TagFilter(props: Props) {
>
{t.tag}
- {t.count}
+
+ {t.count}
+
))}
diff --git a/apps/web/src/features/conversation/components/DeletePanel.tsx b/apps/web/src/features/conversation/components/DeletePanel.tsx
index 4581893..4b8d786 100644
--- a/apps/web/src/features/conversation/components/DeletePanel.tsx
+++ b/apps/web/src/features/conversation/components/DeletePanel.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Button } from '../../../shared/ui/Button.tsx'
import { PanelCard } from '../../../shared/ui/PanelCard.tsx'
+import { Text } from '../../../shared/ui/Text.tsx'
interface Props {
title: string
@@ -21,7 +22,9 @@ export function DeletePanel(props: Props) {
return (
{/* The PDS holds the only copy, so deleting is for good. */}
-
Delete “{props.title}” for good?
+
+ Delete “{props.title}” for good?
+
Delete chat
diff --git a/apps/web/src/features/conversation/components/EmptyConversation.tsx b/apps/web/src/features/conversation/components/EmptyConversation.tsx
index 265e22d..839c4a2 100644
--- a/apps/web/src/features/conversation/components/EmptyConversation.tsx
+++ b/apps/web/src/features/conversation/components/EmptyConversation.tsx
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'
import { BulbIcon, PencilIcon, SparkleIcon } from '../../../shared/icons.tsx'
import { Button } from '../../../shared/ui/Button.tsx'
+import { Text } from '../../../shared/ui/Text.tsx'
+import { Title } from '../../../shared/ui/Title.tsx'
import { useMe } from '../../auth/session.ts'
/** Ways to start a chat, each the opening of a message for the user to finish. */
@@ -24,13 +26,15 @@ export function EmptyConversation(props: Props) {
return (
-
What can I help with?
-
+
+ What can I help with?
+
+
Describe what you need, or drop in a file to start.{' '}
{me.storageMode === 'space'
? 'This chat is saved to your own PDS.'
: 'This chat is saved on this server until your PDS supports spaces.'}
-
+
{STARTERS.map((starter) => (
diff --git a/apps/web/src/features/conversation/components/InstructionsPanel.tsx b/apps/web/src/features/conversation/components/InstructionsPanel.tsx
index dc2f455..a61575d 100644
--- a/apps/web/src/features/conversation/components/InstructionsPanel.tsx
+++ b/apps/web/src/features/conversation/components/InstructionsPanel.tsx
@@ -2,6 +2,7 @@ import { useId } from 'react'
import { useAppForm } from '../../../shared/form.tsx'
import { Button } from '../../../shared/ui/Button.tsx'
import { PanelCard, panelForm } from '../../../shared/ui/PanelCard.tsx'
+import { Text } from '../../../shared/ui/Text.tsx'
interface Props {
/** The chat's stored instructions, empty when it has none. */
@@ -31,9 +32,9 @@ export function InstructionsPanel(props: Props) {
-
+
Followed in every reply in this chat, after your custom instructions.
-
+
{(field) => (
-
+
This server isn't writing the reply. It may have restarted, or another chat service you've
signed into may have picked up your message. If replies keep failing, revoke that service's
access in your PDS account settings.
-
+
)
}
diff --git a/apps/web/src/features/conversation/components/PromptStack.tsx b/apps/web/src/features/conversation/components/PromptStack.tsx
index f03fd41..ba02c3c 100644
--- a/apps/web/src/features/conversation/components/PromptStack.tsx
+++ b/apps/web/src/features/conversation/components/PromptStack.tsx
@@ -1,6 +1,7 @@
import { cva } from 'class-variance-authority'
import { type KeyboardEvent, useEffect, useId, useRef, useState } from 'react'
import { ChatIcon } from '../../../shared/icons.tsx'
+import { Text } from '../../../shared/ui/Text.tsx'
import { ToggleChip } from '../../../shared/ui/ToggleChip.tsx'
import { jumpShowsReplies, rememberJumpReplies } from '../lib/jump-replies.ts'
import type { OutlineEntry } from '../lib/outline.ts'
@@ -156,9 +157,9 @@ export function PromptStack(props: Props) {
{/* The title holds still under the header, in the column the messages stack in, while they
scroll and fade out under it. */}
-
+
Jump to a message
-
+
}
diff --git a/apps/web/src/features/conversation/components/StoreUnavailable.tsx b/apps/web/src/features/conversation/components/StoreUnavailable.tsx
index 5bb6228..b13271f 100644
--- a/apps/web/src/features/conversation/components/StoreUnavailable.tsx
+++ b/apps/web/src/features/conversation/components/StoreUnavailable.tsx
@@ -3,6 +3,7 @@ import { AlertIcon, InfoIcon, RetryIcon } from '../../../shared/icons.tsx'
import { appName } from '../../../shared/site.ts'
import { Button } from '../../../shared/ui/Button.tsx'
import { IconMark } from '../../../shared/ui/IconMark.tsx'
+import { Text } from '../../../shared/ui/Text.tsx'
import type { HandoverState } from '../../../store/handover.ts'
import { useStore, useStoreState } from '../../../store/react.tsx'
@@ -80,14 +81,14 @@ function Layer(props: LayerProps) {
tone={failed ? 'danger' : undefined}
className="mb-3"
/>
-
+
{failed ? "Couldn't open your chats" : `${appName()} is open in another tab`}
-
-
+
+
{failed
? "This device's copy of your chats didn't open."
: 'Your chats can be open in one tab at a time.'}
-
+
-
+
Press Enter or type a comma after each tag.
-
+
{(field) => (
(props: Props) {
// A space, so the box's name reads "GPT gpt Added" rather than "GPT gptAdded".
<>
{' '}
- Added
+
+ Added
+
>
)}
diff --git a/apps/web/src/features/settings/pages/ApiKeySettings.tsx b/apps/web/src/features/settings/pages/ApiKeySettings.tsx
index e181739..d072a2b 100644
--- a/apps/web/src/features/settings/pages/ApiKeySettings.tsx
+++ b/apps/web/src/features/settings/pages/ApiKeySettings.tsx
@@ -17,6 +17,7 @@ import {
SettingsGroup,
SettingsPage,
} from '../../../shared/ui/SettingsPage.tsx'
+import { Text } from '../../../shared/ui/Text.tsx'
import { noCapabilities } from '../../models/capabilities.ts'
import { ModelTable } from '../../models/components/ModelTable.tsx'
import { useKeysChanged } from '../hooks/changes.ts'
@@ -143,11 +144,11 @@ interface KeyHintProps {
function KeyHint(props: KeyHintProps) {
if (props.credential.keyUnreadable)
return (
-
+
{props.credential.baseUrl
? "Can't be read. Delete it and add it again."
: "Can't be read. Add it again."}
-
+
)
return (
-
+
{listed.length > 0 &&
`${plural(listed.length, { one: '# model', other: '# models' })} loaded`}
-
+
state.isSubmitting}>
{(submitting) => (
diff --git a/apps/web/src/features/settings/pages/SettingsLayout.tsx b/apps/web/src/features/settings/pages/SettingsLayout.tsx
index 85698f8..3103a36 100644
--- a/apps/web/src/features/settings/pages/SettingsLayout.tsx
+++ b/apps/web/src/features/settings/pages/SettingsLayout.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { KeyIcon, PuzzleIcon, SlidersIcon, SyncIcon } from '../../../shared/icons.tsx'
import { AreaTabs, NavHeader, SectionNav } from '../../../shared/SectionNav.tsx'
import { SidebarLayout } from '../../../shared/SidebarLayout.tsx'
+import { Text } from '../../../shared/ui/Text.tsx'
import { version } from '../../../shared/version.ts'
import { useMe } from '../../auth/session.ts'
@@ -28,7 +29,9 @@ export function SettingsLayout(props: Props) {
{me.admin ? : }
{/* Which release is running, for checking a deployment or reporting a bug. */}
-
Version {version}
+
+ Version {version}
+
>
}
>
diff --git a/apps/web/src/features/sharing/pages/SharedPage.tsx b/apps/web/src/features/sharing/pages/SharedPage.tsx
index 043ed02..b0e8d0d 100644
--- a/apps/web/src/features/sharing/pages/SharedPage.tsx
+++ b/apps/web/src/features/sharing/pages/SharedPage.tsx
@@ -2,6 +2,8 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { useLocation } from '@tanstack/react-router'
import type { MessageRecord } from '../../../shared/message.ts'
import { button } from '../../../shared/ui/Button.tsx'
+import { Text } from '../../../shared/ui/Text.tsx'
+import { Title } from '../../../shared/ui/Title.tsx'
import { useDocumentTitle } from '../../../shared/useDocumentTitle.ts'
import { BranchView } from '../../conversation/components/BranchView.tsx'
import { DetailsButton, recordRows } from '../../conversation/components/RecordDetails.tsx'
@@ -55,12 +57,10 @@ function SharedChat(props: ChatProps) {
return (
-
- {shared.title ?? 'Shared chat'}
-
-
+
{shared.title ?? 'Shared chat'}
+
Shared by {shared.owner.handle ?? shared.owner.did}
-
+
{props.title}
- {siteOf(props.url)}
+
+ {siteOf(props.url)}
+
>
)
}
diff --git a/apps/web/src/parts/tools/web-fetch/index.tsx b/apps/web/src/parts/tools/web-fetch/index.tsx
index bb1dfef..f77f1f9 100644
--- a/apps/web/src/parts/tools/web-fetch/index.tsx
+++ b/apps/web/src/parts/tools/web-fetch/index.tsx
@@ -1,3 +1,4 @@
+import { Text } from '../../../shared/ui/Text.tsx'
import { siteOf } from '../../links.ts'
import { clip } from '../../steps.ts'
import type { ToolEntry, ToolViewProps } from '../../types.ts'
@@ -58,10 +59,10 @@ function WebFetchView(props: ToolViewProps) {
{props.sections.map((section) => (
diff --git a/apps/web/src/shared/schema-fields/SchemaFields.tsx b/apps/web/src/shared/schema-fields/SchemaFields.tsx
index d08707e..aaea163 100644
--- a/apps/web/src/shared/schema-fields/SchemaFields.tsx
+++ b/apps/web/src/shared/schema-fields/SchemaFields.tsx
@@ -7,6 +7,7 @@ import { Input, Switch, TextArea } from '../ui/controls.tsx'
import { dividedRows } from '../ui/divided-rows.ts'
import { Select } from '../ui/Select.tsx'
import { SettingRow } from '../ui/SettingsPage.tsx'
+import { Text } from '../ui/Text.tsx'
import { applyRule, type FieldRule } from './field-rules.ts'
export type Property = {
@@ -100,9 +101,9 @@ function SchemaField(props: SchemaFieldProps) {
diff --git a/apps/web/src/shared/ui/ItemList.tsx b/apps/web/src/shared/ui/ItemList.tsx
index 153d1f1..3cf6acd 100644
--- a/apps/web/src/shared/ui/ItemList.tsx
+++ b/apps/web/src/shared/ui/ItemList.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { cn } from '../cn.ts'
import { ChevronRightIcon } from '../icons.tsx'
import { Loading, Skeleton, SkeletonText } from './Skeleton.tsx'
+import { Text } from './Text.tsx'
/*
* Lists of things in a card, such as keys, members, or plugins: a row each, with its names and its
@@ -38,9 +39,15 @@ interface LabelProps {
/** A quiet label over a list in a card, such as who is waiting to sign in. */
export function Label(props: LabelProps) {
return (
-
+
{props.children}
-
+
)
}
diff --git a/apps/web/src/shared/ui/SettingsPage.tsx b/apps/web/src/shared/ui/SettingsPage.tsx
index 81dd1ff..ef28ba3 100644
--- a/apps/web/src/shared/ui/SettingsPage.tsx
+++ b/apps/web/src/shared/ui/SettingsPage.tsx
@@ -9,6 +9,8 @@ import { Button, IconButton } from './Button.tsx'
import { Radio } from './controls.tsx'
import { dividedRows } from './divided-rows.ts'
import { FieldDescriptionContext } from './field-description.ts'
+import { Text } from './Text.tsx'
+import { Title } from './Title.tsx'
/*
* The pieces settings and admin pages are built from: a page of cards, each holding rows of
@@ -37,10 +39,12 @@ export function SettingsPage(props: PageProps) {
{props.back &&
diff --git a/apps/web/src/shared/ui/Text.tsx b/apps/web/src/shared/ui/Text.tsx
new file mode 100644
index 0000000..9d70f03
--- /dev/null
+++ b/apps/web/src/shared/ui/Text.tsx
@@ -0,0 +1,61 @@
+import type { ComponentProps } from 'react'
+import { cn } from '../cn.ts'
+
+/**
+ * The type scale as one ladder of sizes, as Mantine's are, shared by `Text` and `Title`. Each step
+ * is a `--text-*` token in theme.css.
+ */
+export const textSizes = {
+ xs: 'text-caption',
+ sm: 'text-small',
+ md: 'text-ui',
+ lg: 'text-body',
+ xl: 'text-heading',
+ '2xl': 'text-title',
+ '3xl': 'text-display',
+}
+
+export type TextSize = keyof typeof textSizes
+
+const colors = {
+ ink: 'text-ink',
+ muted: 'text-muted',
+ faint: 'text-faint',
+ danger: 'text-danger',
+}
+
+const weights = {
+ normal: 'font-normal',
+ medium: 'font-medium',
+ semibold: 'font-semibold',
+}
+
+interface Props extends Omit, 'ref'> {
+ /** A span by default, or a paragraph, a side comment, or a block. */
+ as?: 'span' | 'p' | 'small' | 'div'
+ /** The size around it by default. */
+ size?: TextSize
+ /** The color around it by default. */
+ color?: keyof typeof colors
+ /** The weight around it by default. */
+ weight?: keyof typeof weights
+}
+
+/**
+ * A run of text in a size, color, and weight from the theme, such as a hint in small grey. Its
+ * `className` places it, or truncates or balances it.
+ */
+export function Text(props: Props) {
+ const { as: Tag = 'span', size, color, weight, className, ...rest } = props
+ return (
+
+ )
+}
diff --git a/apps/web/src/shared/ui/Title.tsx b/apps/web/src/shared/ui/Title.tsx
new file mode 100644
index 0000000..d0c066c
--- /dev/null
+++ b/apps/web/src/shared/ui/Title.tsx
@@ -0,0 +1,39 @@
+import type { ComponentProps } from 'react'
+import { cn } from '../cn.ts'
+import { type TextSize, textSizes } from './Text.tsx'
+
+/**
+ * What a size needs to read as a title: big titles draw in a touch. Once the theme's `--text-*`
+ * tokens carry their own line height and letter spacing, these move there and leave the sizes.
+ */
+const titleSizes: Partial> = {
+ xl: 'leading-tight tracking-heading',
+ '2xl': 'leading-tight tracking-title',
+ '3xl': 'leading-tight tracking-title',
+}
+
+const tags = { 1: 'h1', 2: 'h2', 3: 'h3', 4: 'h4' } as const
+
+interface Props extends ComponentProps<'h1'> {
+ /** The heading's level in the page, which the size doesn't follow. */
+ order: keyof typeof tags
+ /** `2xl` for a page's title by default. */
+ size?: TextSize
+}
+
+/** A heading in ink at semibold, its lines balanced, sized apart from its level, as Mantine's is. */
+export function Title(props: Props) {
+ const { order, size = '2xl', className, ...rest } = props
+ const Tag = tags[order]
+ return (
+
+ )
+}
diff --git a/specs/styling.md b/specs/styling.md
index 453c5b2..394ee93 100644
--- a/specs/styling.md
+++ b/specs/styling.md
@@ -103,6 +103,8 @@ Spacing literals from `design-reference` snap to the nearest quarter step of the
- `CopyButton.tsx`: Copy, as words or as an icon that cross-fades to a check.
- `CloseButton.tsx`: `CloseButton`, an `IconButton` with a ×, which removes, clears, or closes what its label names, and never turns red. It takes the `IconButton`'s sizes and radius: `xs` with `radius="sm"` inside a chip, `sm` in a file chip, a quote, or a heading's action, `md` at a row's end, and `lg` round for the composer's Clear.
- `ExternalLink.tsx`: `ExternalLink`, a link out of the app, such as a reply's link, a source, or Report an issue, opened in a new tab with `rel="noreferrer"`. It draws nothing of its own. A link within the app is the router's `Link`, which renders a plain anchor for an outside URL but leaves the new tab to its caller and rejects a written-out URL in its types.
+- `Text.tsx`: `Text`, a run of text in a `size`, `color`, and `weight` from the theme, such as a hint in small grey, as a span, or with `as` a paragraph, a `small`, or a block, its `className` placing, truncating, or balancing it. Its sizes, `textSizes`, are the type scale as one ladder, as Mantine's are: `xs` caption, `sm` small, `md` ui, `lg` body, `xl` heading, `2xl` title, and `3xl` display. Text in a component's own look, such as a row's or a chip's, keeps its utilities.
+- `Title.tsx`: `Title`, a heading in ink at semibold with balanced lines, whose `order` is its level in the page and whose `size`, from the same ladder, is `2xl` for a page's title unless it says otherwise, so a page's `h2` can wear a title's size. Its `xl` and up draw in a touch, with a tight line height and the `heading` or `title` tracking. Those belong to the sizes rather than to titles, and move into the `--text-*` tokens as their `--line-height` and `--letter-spacing` once the theme carries them, which leaves `Title` only its weight, color, and balance.
- `Code.tsx`: `Code`, code inside a line of text outside a reply, such as a model ID, a package, or a role's name, in plain monospace a step smaller than the text around it, as in a reply, but never under the caption size.
- `TimeAgo.tsx`: `TimeAgo`, how long ago something happened, such as "Last used 5 minutes ago", after an optional prefix or capitalized without one, with the full date and time on hover. It draws only in the browser, whose locale and time zone the server lacks.
- `ItemList.tsx`: lists in a card, imported as `import * as ItemList` and composed from its parts: `Root`, each `Item` with a `Mark`, its `Names` over a line of details, and its actions, and `LinkItem`, a row of the same parts that opens the page its title links to.
--
knotmirror 0.0.1