mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) A project has no `MUL-123`-style identifier — only a UUID and a free-text title — so there is nothing for the bare-identifier autolink preprocessor to detect, and the link copied out of the app is how people actually reference one. It rendered as a raw URL. RichLink now unfurls a bare in-app entity URL into the same chip the `mention://project/<uuid>` form already produces (issue URLs go through the same path for symmetry). Render-only: stored markdown is untouched, and the editable Tiptap path is deliberately unaffected. Three guards, each load-bearing: the link must be bare (an authored label is never discarded), same-workspace (a chip resolves its title in the current workspace only), and address exactly one entity page by UUID with no query or fragment. Also: - mobile: tapping a `mention://project/` link navigated nowhere despite the `project/[id]` route existing — it now pushes the project detail. - agents had no documented way to emit a clickable project reference: add the link form to the runtime brief's Mentions section and to the projects skill, and record in the mentioning skill why `project` sits outside `MentionRe` (render-only, enqueues nothing). Co-authored-by: multica-agent <github@multica.ai> * fix(rich-content): unfurl issue URLs in identifier form The unfurl required a UUID id, on the stated grounds that "every link the app itself produces carries a UUID". That holds for a project but not for an issue: `copyLink` and `openInNewTab` both build `paths.issueDetail(issueIdentifier || issueId)`, and the issue route rewrites a UUID URL back to the identifier — so `MUL-123` is the shape a user actually copies, out of the app or out of the address bar. The issue half of the feature could not fire on the links people paste, while bare `MUL-123` prose did become a chip: the fuller reference lost to the shorter one. `parseWorkspaceEntityLink` now accepts an issue identifier as well as a UUID. A project still requires a UUID — it has no shorthand, so an identifier-shaped id under /projects/ addresses nothing. An identifier needs a lookup, which means it can miss, and the miss has to differ by entry point. `AutolinkedIssueMentionLink` degraded to plain text, which is right for autolinked prose and wrong for a URL: the author wrote a link, and an issue this workspace cannot see must not cost them the only pointer to it. The fallback is now a prop — plain text for the autolink path, the original anchor for a URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(editor): stop drawing link chrome over mention chips A mention chip already carries its own affordance — border, icon, hover background — so the generic `.rich-text-editor a` color and underline draw a second, competing one straight through the card. `.issue-mention` reset it; `.project-mention` never did, so project chips shipped with a brand-coloured underline through them. The rule belongs to the chip shape rather than to one entity, so both selectors now share it and a future chip is one line. The hover card had the same gap: it skipped `.issue-mention` only, so hovering a project chip opened a URL card offering to copy `/{slug}/projects/{uuid}` — an in-app path, not the shareable link that wording implies. Both are pre-existing, but a bare project URL now renders as a chip, so what used to surface on hand-written mentions alone shows up on ordinary pasted links. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(rich-content): decide in-app by resolving the URL, not by its prefix `href.startsWith("/")` was standing in for "this deployment". It is not: a browser reads `//other.example/x` and `/\other.example/x` as another host and goes there, and both start with a slash. The parser skipped the origin check for exactly the hrefs that most needed it. Nothing shipped from this: an unfurled chip links to `paths.projectDetail(uuid)`, so the href it was parsed from is discarded and a misparse could not send anyone anywhere. The prefix test was still the wrong instrument. Adding `&& !startsWith("//")` would have looked like a fix while leaving the backslash spelling through — the gap is the technique, not the case, so this resolves the href against the app origin with `URL` and compares `origin`, which is one comparison for every spelling and for the schemes (`javascript:`, `data:`) whose opaque origin can never match. Relative and absolute now take the same path, so the slugless legacy form parses identically whether or not it carries the origin — previously the absolute spelling was rejected by a reserved-slug test meant for workspace slugs, and the two disagreed. `openLink` still tests the prefix, and its result IS navigated. That is a live issue, older than this feature and wider than it; it needs its own change rather than a quiet ride here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(skills): state what mobile actually does with a project mention The projects skill told agents a `mention://project/<uuid>` link "renders as a navigable project chip on web, desktop, and mobile", and that a pasted project URL is unfurled into that same chip by "the reader's client". Neither holds on mobile: `apps/mobile/lib/markdown/markdown.tsx` renders the default enriched link and only routes the tap, and a bare URL still goes to `Linking.openURL`, which leaves the app. These files enter agent context and read as product contract, so an agent choosing between a mention link and a pasted URL was choosing on false information — and the URL is the option that strands a mobile reader in a browser. Both skills and both source maps now say chip on web/desktop, ordinary link that opens the project on tap on mobile, and unfurling as web/desktop only. The projects skill also now states the preference outright rather than presenting the two forms as equivalent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(views): give a project mention the component an issue mention has `IssueMentionCard` owns "chip inside a link" for issues; the project equivalent lived inline in the readonly renderer, so nothing named the pairing and nothing held the rules that come with being a link. That cost was not hypothetical. Both gaps fixed a commit ago landed on project mentions alone: `.project-mention` never got the CSS rule cancelling generic link chrome, and the hover card never learned to skip it. Each was written for `.issue-mention` at the component that owns it, and project had no such place for the second half to be written. `ProjectMentionCard` is that place. No behaviour change: same anchor, same href, same hover affordance, same accessibility contract that project-mention-a11y.test.tsx pins. The "open in new tab" preference stays out — it is scoped to issue links, and inheriting it by symmetry would be inventing product. Also drops `not-prose` from both cards. It has no definition anywhere in the repo — Tailwind's typography plugin is not installed, and the class does not appear in built CSS — so it read as protection that was not there. The editor's `MentionView` keeps its hand-rolled anchors: it needs a modifier-click intent hook `AppLink` does not expose, and it does the same for issues, so the two stay symmetric there too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
438 lines
12 KiB
CSS
438 lines
12 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);
|
|
}
|
|
|
|
/* Mention chips — inline cards that sit within text flow. The chip already
|
|
carries its own affordance (border, icon, hover background), so the generic
|
|
link color and underline above would be a second, competing one drawn
|
|
through the card. Every chip-shaped mention belongs here. */
|
|
.rich-text-editor a.issue-mention,
|
|
.rich-text-editor a.project-mention {
|
|
color: inherit;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.rich-text-editor a.issue-mention:hover,
|
|
.rich-text-editor a.project-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;
|
|
}
|