Files
multica/packages/views/editor/styles/prose.css
Jiayuan Zhang 7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00

433 lines
11 KiB
CSS

/*
* Rich text prose typography.
*
* Design tier: "Compact" (same tier as Linear, Slack). Optimized for
* short-form content (issue descriptions, comments) that users scan, not
* long-form reading.
*
* Typography values benchmarked against (April 2026):
* - github-markdown-css (GitHub's markdown renderer)
* - @tailwindcss/typography prose-sm preset
* - Linear's editor (Tiptap-based, 14px body)
*
* Key decisions:
* Body: 14px (text-body), line-height 1.625 (between GitHub 1.5 and Tailwind 1.714)
* Headings: h1=22px (1.57x), h2=18px (1.29x), h3=15px (1.07x) — compact but
* with clear hierarchy. Previous h3 was 14px (same as body = no differentiation).
* Paragraph spacing: 10px (was 8px; GitHub uses 10px, Tailwind prose-sm uses 16px)
* List indent: 20px for ul (was 16px; standard is 22-32px)
* Code block margin: 12px (was 8px; gives breathing room between code and prose)
* Blockquote border: 3px (was 2px; GitHub/Tailwind both use 4px)
* Links: var(--brand) blue with 40% opacity underline (was var(--primary) near-black)
*
* Inline elements (mention cards, inline code) that exceed line-height:
* The browser auto-expands the line box for lines containing taller inline
* elements. Controlled via vertical-align on [data-node-view-wrapper] and
* box-decoration-break: clone on inline code.
*/
/* Long unbreakable tokens (paths, URLs, identifiers) wrap mid-word instead
of overflowing. Tiptap injects the legacy alias `word-wrap: break-word`
on .ProseMirror, so the editable render already behaves this way; without
this rule the readonly render keeps such tokens on one line and the two
renders wrap the same text differently (machine-diffed: inline code and
code blocks gained 1-2 lines in the editor only). */
.rich-text-editor {
overflow-wrap: break-word;
}
/* Headings — compact but with clear visual hierarchy */
.rich-text-editor h1 {
font-size: var(--text-display-sm);
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.3;
letter-spacing: -0.01em;
}
.rich-text-editor h2 {
font-size: var(--text-title);
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.35;
}
.rich-text-editor h3 {
font-size: var(--text-body-lg);
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.625rem;
margin-bottom: 0.625rem;
line-height: 1.625;
}
/* First child should not have top margin */
.rich-text-editor > *:first-child {
margin-top: 0;
}
/* Last child should not have bottom margin */
.rich-text-editor > *:last-child {
margin-bottom: 0;
}
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
padding-inline-end: 0.5rem;
margin: 0.5rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.5rem;
margin: 0.5rem 0;
}
.rich-text-editor li {
margin: 0.25rem 0;
line-height: 1.625;
}
.rich-text-editor li + li {
margin-top: 0.25rem;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
/* Remove paragraph margins inside list items (Tiptap wraps li content in <p>) */
.rich-text-editor li > p {
margin: 0;
}
.rich-text-editor li > p + p {
margin-top: 0.25rem;
}
.rich-text-editor .math-node {
display: inline-flex;
max-width: 100%;
vertical-align: middle;
}
.rich-text-editor .math-node.inline {
align-items: center;
}
.rich-text-editor .math-node.block {
display: block;
margin: 0.75rem 0;
overflow-x: auto;
overflow-y: hidden;
}
.rich-text-editor .math-node.block .katex-display {
margin: 0;
}
.rich-text-editor .math-node .katex {
max-width: 100%;
}
/* Nested lists — bullet style progression and tighter spacing */
.rich-text-editor ul ul {
list-style-type: circle;
margin: 0.25rem 0;
}
.rich-text-editor ul ul ul {
list-style-type: square;
}
.rich-text-editor ol ol {
list-style-type: lower-alpha;
margin: 0.25rem 0;
}
.rich-text-editor ol ol ol {
list-style-type: lower-roman;
}
/*
* Task lists (checkboxes) — `- [ ]` / `- [x]`.
*
* The editor and readonly views emit DIFFERENT DOM, so they are styled
* separately rather than sharing a flex rule:
*
* - Editor (Tiptap NodeView):
* ul[data-type="taskList"] > li[data-checked]
* > label(contenteditable) > input + span
* > div(content) > p, [nested ul]
* The item body is wrapped in a <div>, so the row is a flex layout and a
* nested task list inside that <div> still stacks below the text.
*
* - Readonly (remark-gfm):
* ul.contains-task-list > li.task-list-item
* > input[disabled] + text [ + nested ul ]
* There is NO body wrapper — a nested <ul> is a direct sibling of the
* checkbox and text. Flex here would pull the nested list onto the same
* row, so the item stays a block and the checkbox is positioned inline.
*/
/* Shared: checkbox appearance. */
.rich-text-editor input[type="checkbox"] {
width: 0.95rem;
height: 0.95rem;
accent-color: var(--brand);
cursor: pointer;
}
/* --- Editor (Tiptap) --- */
.rich-text-editor ul[data-type="taskList"] {
list-style: none;
padding-inline: 0;
margin: 0.5rem 0;
}
/* Child combinators keep these rules off nested plain bullet/ordered lists. */
.rich-text-editor ul[data-type="taskList"] > li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0;
}
/* Checkbox column. The top margin nudges the box onto the first text line
(line-height 1.625). */
.rich-text-editor ul[data-type="taskList"] > li > label {
flex: 0 0 auto;
margin: 0.28rem 0 0;
user-select: none;
}
/* Content column fills the remaining width and may wrap/scroll. */
.rich-text-editor ul[data-type="taskList"] > li > div {
flex: 1 1 auto;
min-width: 0;
}
.rich-text-editor ul[data-type="taskList"] > li > div > p {
margin: 0;
}
/* Completed editor item: muted text (no strikethrough), matching Linear. The
NodeView keeps `data-checked` on the <li> (but not data-type). */
.rich-text-editor ul[data-type="taskList"] > li[data-checked="true"] > div {
color: var(--muted-foreground);
}
/* --- Readonly (remark-gfm) --- */
.rich-text-editor ul.contains-task-list {
list-style: none;
padding-inline: 0;
margin: 0.5rem 0;
}
.rich-text-editor li.task-list-item {
padding-inline: 0;
}
/* Inline checkbox on the first text line; a nested <ul> stays block and drops
below the item instead of sharing the row. */
.rich-text-editor li.task-list-item > input[type="checkbox"] {
margin: 0 0.5rem 0 0;
vertical-align: -0.1em;
}
/* Indent nested readonly task lists under their parent item. */
.rich-text-editor li.task-list-item .contains-task-list {
padding-inline-start: 1.45rem;
margin: 0.25rem 0;
}
/* Completed readonly item: muted text, matched via :has(input:checked). The
reset keeps a completed parent from bleeding its muted color into the
(independently evaluated) nested items below it. */
.rich-text-editor li.task-list-item:has(> input:checked) {
color: var(--muted-foreground);
}
.rich-text-editor li.task-list-item:has(> input:checked) .contains-task-list {
color: var(--foreground);
}
/* Tables */
.rich-text-editor .tableWrapper {
overflow-x: auto;
margin: 1rem 0;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.rich-text-editor table {
/* `width`, not just `min-width`: Tiptap stamps an inline
`style="min-width: NNpx"` on its tables, which would override a
class-level min-width and let editor tables shrink to content while
readonly tables stretch full width. */
width: 100%;
min-width: 100%;
border-collapse: collapse;
}
.rich-text-editor colgroup {
display: none;
}
/* Header-row background. Two structures produce header rows: readonly
(react-markdown) emits a real <thead>; Tiptap keeps the <th> row inside
<tbody>, so the tr:has(> th) arm covers the editable table. */
.rich-text-editor thead,
.rich-text-editor tr:has(> th) {
background: color-mix(in srgb, var(--muted) 50%, transparent);
}
.rich-text-editor tbody tr {
border-top: 1px solid var(--border);
}
/* Tiptap tables have no <thead>, so their header row is tbody's first row —
without this it draws a border-top right against the wrapper's own border. */
.rich-text-editor table > tbody:first-child > tr:first-child {
border-top: 0;
}
.rich-text-editor tr:hover td {
background: color-mix(in srgb, var(--muted) 30%, transparent);
transition: background 0.15s;
}
.rich-text-editor th,
.rich-text-editor td {
text-align: left;
padding: 0.625rem 1rem;
font-size: var(--text-body);
/* Match body-paragraph line-height. Tiptap wraps cell content in <p>
(which carries 1.625); the readonly render keeps bare text in the cell,
which otherwise inherits the host's text-body line-height (~1.43) and
makes every readonly cell 2.5px shorter per line. */
line-height: 1.625;
}
.rich-text-editor th {
font-weight: 600;
}
/* Remove paragraph margin inside table cells */
.rich-text-editor th p,
.rich-text-editor td p {
margin: 0;
}
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 3px solid color-mix(in srgb, var(--muted-foreground) 30%, transparent);
padding-left: 0.75rem;
margin: 0.625rem 0;
color: var(--muted-foreground);
font-style: italic;
}
.rich-text-editor blockquote p {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.rich-text-editor blockquote > *:first-child {
margin-top: 0;
}
.rich-text-editor blockquote > *:last-child {
margin-bottom: 0;
}
.rich-text-editor blockquote blockquote {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
border-left-color: color-mix(in srgb, var(--muted-foreground) 15%, transparent);
}
/* Horizontal rules */
.rich-text-editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Links */
.rich-text-editor a {
color: var(--brand);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--brand) 40%, transparent);
text-underline-offset: 2px;
cursor: pointer;
}
.rich-text-editor a:hover {
text-decoration-color: var(--brand);
}
/* Issue mention cards — inline cards that sit within text flow */
.rich-text-editor a.issue-mention {
color: inherit;
text-decoration: none;
}
.rich-text-editor a.issue-mention:hover {
text-decoration: none;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
font-weight: 600;
text-decoration: none;
margin: 0 0.125rem;
}
/* Slash command pills */
.rich-text-editor .slash-command {
color: var(--primary);
font-weight: 600;
text-decoration: none;
margin: 0 0.125rem;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;
}
.rich-text-editor em {
font-style: italic;
}
.rich-text-editor s,
.rich-text-editor del {
text-decoration: line-through;
color: var(--muted-foreground);
}
/* Text highlight (`==text==` → <mark>). Single yellow tint that stays legible
in both light and dark themes; `color: inherit` keeps the underlying text
color so highlighted links/code remain readable. box-decoration-break clones
the background across wrapped lines (same approach as inline code above). */
.rich-text-editor mark {
background-color: color-mix(in srgb, #facc15 45%, transparent);
color: inherit;
border-radius: 0.2em;
padding: 0 0.15em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}