From 69b38185838a0cd7178a76d982509ce485df0c02 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:44 -0700 Subject: [PATCH 01/12] Design: image sizing, alignment, and full-bleed (#109) Layout metadata rides in a Pandoc-style attribute block after the image, so width and alignment survive /:id.md, the EPUB, the print page, and an agent read rather than living only in the editor. align=left and align=right float the image and text wraps beside it; center and an absent align keep their own line. At the 65ch column a 50% float leaves about 32 characters beside it, which the doc records as the weakest part of the design. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-09-13-image-sizing-design.md | 123 +++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/plans/2026-09-13-image-sizing-design.md diff --git a/docs/plans/2026-09-13-image-sizing-design.md b/docs/plans/2026-09-13-image-sizing-design.md new file mode 100644 index 00000000..818f5cfe --- /dev/null +++ b/docs/plans/2026-09-13-image-sizing-design.md @@ -0,0 +1,123 @@ +# Image sizing and placement + +**Issue:** [#109](https://github.com/arfct/vapor/issues/109). **Branch:** `feat/109-image-sizing`. + +**Goal:** A person selects an image in a document and picks its width and its alignment from a small set, including one width that fills the page past the text column. The choice survives `/:id.md`, the EPUB, the print page, and an agent's read over MCP, because it is written into the markdown rather than held beside it. + +**Relationship to other plans:** extends the [attachments plan](2026-09-05-attachments-plan.md), which listed "image resizing or thumbnails" under Out of scope. Nothing here changes upload, storage, serving, or expiry. It adds two attributes to the `attachment` node and a rendering path for them. + +## Decisions + +### 1. Layout metadata rides in a Pandoc-style attribute block + +The form is an image, alone in a paragraph, followed by a brace block: + +```markdown +![cat.png](/abc/attachments/x1y2/cat.png){width=50% align=left} +![banner.png](/abc/attachments/x1y2/banner.png){width=full} +``` + +`width` is a percent from 1% to 100%, or the token `full`. `align` is `left`, `center`, or `right`. Both are optional and independent. `align` has no visible effect at `width=100%` or `width=full`, where the block already fills its container; it is stored and serialized anyway rather than being stripped, so that lowering the width restores the alignment the person last chose. + +Three other conventions were considered. The **title slot** (`![cat.png](/… "w=50% left")`) and the **Obsidian alt pipe** (`![cat.png|320](/…)`) both render clean in a foreign renderer, which is their whole case: a document pasted into GitHub shows an image and no stray text. Each pays for it by hiding layout data in a field that already means something else, the tooltip and the accessibility string respectively. **Class tokens** (`{.half .left}`) map one to one onto CSS class names in all three stylesheets and cost almost no translation layer, and an unrecognized class is free to ignore. The assumption that fails for all three is that the metadata only has to survive vapor. Pandoc reads `width=50%` natively and honors it; it parses `.half` and does nothing with it, and never sees the other two at all. The brace block is the only form where the size survives a conversion. + +The cost is real and is accepted: GitHub and any strict CommonMark renderer print `{width=50% align=left}` as literal text after the image. + +### 2. Left and right alignment float the image and text wraps + +`align=left` and `align=right` float the image and the following text wraps beside it. `align=center` and an absent `align` both keep the image on its own line, centered and in default flow respectively. This follows Dropbox Paper, which was the stated model. + +The consequence to know before choosing a width: every block in the editor is capped at `max-width: 65ch` ([app/app.css:233](../../app/app.css)), so a floated image at 40% leaves about 39 characters per line beside it and at 50% about 32. Paper's column is wider, which is why the same layout reads better there. Widening vapor's column is a separate decision and is not made here. + +`width=full` and `width=100%` ignore `align` and take their own line; there is no room to wrap beside them. + +Sizing is a set of presets rather than a drag handle. Each choice is one `updateAttributes` call, so it is one Yjs write per click. A drag handle writes per frame, needs throttling, and turns two people sizing the same image into a stream of last-write-wins updates rather than one. + +### 3. Full-bleed is in; galleries and editable captions are not + +Paper also groups images dragged onto the same line into a side-by-side gallery, and gives each image an editable caption field. Both are out of scope here. A gallery needs a new container node in `richSchema`, a new markdown form, drag-to-group in the editor, and gallery rules in three stylesheets. Captions need a decision about where the caption text lives in markdown and whether `attachment` can stay `atom: true`. + +One thing worth recording while it is visible: `alt` is set to the uploaded filename ([app/lib/useAttachments.ts:104](../../app/lib/useAttachments.ts)) and rendered both as the `` and as the visible figcaption, so the field named `alt` is the caption and no vapor document currently carries alt text. That is a separate issue from this one and is not fixed here. + +## Design + +### The schema gains two nullable attributes + +`richSchema.attachment` ([app/shared/rich-markdown.ts:87](../../app/shared/rich-markdown.ts)) and the TipTap `Attachment` node ([app/lib/attachment.ts](../../app/lib/attachment.ts)) each gain: + +- `width: { default: null }` holding `"50%"` or `"full"` +- `align: { default: null }` holding `"left" | "center" | "right"` + +Both default to `null` rather than to `"100%"` and `"left"`. The serializer emits a brace block only when at least one is non-null, so every document that exists today serializes byte-identical and no round-trip changes. + +### The parser accepts one new shape and drops what it does not know + +`attachmentRule` ([app/shared/rich-markdown.ts:332](../../app/shared/rich-markdown.ts)) currently requires exactly one meaningful child in the paragraph and that it be an image. It gains a second accepted shape: an image followed by a text child matching `/^\{[^}]*\}$/`. Keys are read out of that block, `width` and `align` are kept, and every other key is dropped. + +Three behaviors are chosen deliberately: + +- **Any percent parses.** A hand-typed `{width=37%}` renders at 37% and is not snapped to the nearest preset. The toolbar only ever writes the preset steps, but the raw markdown is editable by anyone with the link and rewriting someone's text silently is worse than honoring it. +- **Unknown keys are dropped, not preserved.** A future `{width=50% caption="x"}` loses the caption on the next save. Preserving unknown keys would mean carrying an opaque bag on the node; that is a cost to pay when there is a second consumer, not before. +- **A non-brace trailing text child changes nothing.** The paragraph fails the attachment test as it does today and falls through to the existing foreign-image path, which turns the image back into literal text. + +`markdown-it` is 15.0.1 with no attrs plugin in the tree ([package.json:43](../../package.json)), so this is a custom core rule beside the existing one, which is how the rest of vapor's markdown extensions are written. + +### Full-bleed costs one dropped `max-width` + +`.attachment` gains `data-width` and `data-align`. A percent sets `width` on the block under the existing `max-width: 65ch` ([app/app.css:184](../../app/app.css)). `align=left` and `align=right` set `float` with an inline margin on the text side; `align=center` sets `margin-inline: auto`. `full` sets `max-width: none` and drops any float. + +A floated block needs something to end the wrap. The `clear` rule applies to headings, horizontal rules, tables, code blocks, and the next attachment, so a float runs beside body text and stops at the next structural boundary rather than at an arbitrary paragraph. + +`StarterKit` supplies Gapcursor and only `CommentEditor` disables it ([app/components/CommentEditor.tsx:80](../../app/components/CommentEditor.tsx)), so the document editor already gives the caret a target beside a floated atom node. Whether that is sufficient in practice for clicking to the left of a right-floated image is the one part of this design most likely to need a second pass after it runs. + +The usual full-bleed problem does not arise. `.tiptap` is a left-aligned padded block, not a centered fixed-width column ([app/app.css:224](../../app/app.css)), and the comment rail is a 280px flex sibling ([app/components/DocumentLayout.tsx:315](../../app/components/DocumentLayout.tsx)). A full-bleed block fills the editor's content box and the rail keeps its space with no viewport arithmetic and no negative margins. + +### The EPUB needs the rule too, and full-bleed degrades there + +`READING_CSS` ([app/shared/epub.ts:99](../../app/shared/epub.ts)) gains the same width and align rules, and the print page inherits them because it embeds `READING_CSS` and adds only a `@media print` block ([app/shared/epub.ts:226](../../app/shared/epub.ts)). One CSS addition covers both surfaces. + +`full` means 100% of the text width in the EPUB and on the print page, not wider. `READING_CSS` sets `body { margin: 0 auto; max-width: 42em }`, a centered column with no canvas outside it. The documentation states this rather than implying the attribute round-trips to an identical result everywhere. + +Float is emitted in `READING_CSS` as well. Reading systems vary in how they honor `float` on a block, and I have not tested any of them, so a floated image that a given reader lays out on its own line is an accepted outcome and not a bug to chase. + +A bug is introduced if only the CSS changes: `epubChapterHtml` runs a separate plain `MarkdownIt` ([app/shared/epub.ts:62](../../app/shared/epub.ts)) that knows nothing about the brace block, so `{width=50% align=left}` would print as literal text in both the EPUB and the print page. That parser needs the same rule, or a pre-pass that converts the block into a class on the `` before rendering. + +`attachmentImages` ([app/shared/epub.ts:50](../../app/shared/epub.ts)) is unaffected. Its regex captures the URL inside the parens and stops at the closing paren, so a trailing brace block is outside what it matches. + +### The toolbar is a fourth bubble context + +`BubbleToolbar.tsx` already runs a TipTap `BubbleMenu` over a `BubbleContext` union of `selection | suggestion | annotation`. A fourth kind, `attachment`, is detected when the selection is a `NodeSelection` on an `attachment` node whose `kind` is `image`. Its buttons are four widths, plus Full, plus three alignments. + +### Resizing is not a tracked change + +`suggestModePlugin` ([app/lib/suggest-mode.ts:51](../../app/lib/suggest-mode.ts)) implements only `handleTextInput` and `handleKeyDown`. It has no `filterTransaction` and no `appendTransaction`, so a programmatic `updateAttributes` passes through untouched and a resize applies directly even while suggest mode is on. This is the right outcome as well as the current one: CriticMarkup has no syntax for an attribute change, so a tracked resize could not be written to markdown. + +Agents get all of this through `replace`, since agents write markdown. No new MCP tool and no new parameters on `attach`. + +## Verified and not verified + +Verified by reading the source at `aeeaa3f`: the schema, parser, and serializer shapes; the `65ch` cap and the `.tiptap` container; the rail's width and that it is a flex sibling; `READING_CSS` and the print page sharing it; `attachmentImages`' regex; `epubChapterHtml`'s separate parser; `suggestModePlugin`'s two props; the `BubbleContext` union; `markdown-it` 15.0.1 with no attrs plugin. + +Not verified: nothing here has been run. + +Dropbox Paper's behavior was taken from Google's synthesis of Dropbox's blog and community threads rather than from Dropbox's documentation, and one of its two claims was wrong. It reported that Paper does not wrap text around images; Nicholas, who uses Paper, corrected that on 2026-09-13 and the design above reflects the correction. Its other claim from the same source, that Paper has no drag-to-resize handles and uses a preset toolbar, is therefore also unconfirmed. Presets were chosen independently for the one-write-per-click reason given in Decision 2, which does not depend on what Paper does, so the toolbar design stands either way. + +## Tasks + +1. **Schema and markdown** (`app/shared/rich-markdown.ts`, `app/lib/attachment.ts`): the two attrs, the extended `attachmentRule`, the serializer's conditional brace block. Round-trip tests in `tests/unit/shared/rich-markdown.test.ts`. +2. **Editor rendering** (`app/components/AttachmentView.tsx`, `app/app.css`): `data-width` and `data-align` on the wrapper, the width, align, and full-bleed rules. +3. **EPUB and print** (`app/shared/epub.ts`): the brace-block rule in the export parser, the width and align rules in `READING_CSS`. Tests in `tests/unit/shared/epub.test.ts` asserting the block does not reach the chapter XHTML. +4. **Toolbar** (`app/components/BubbleToolbar.tsx`): the `attachment` context and its buttons. Component test for the context detection. +5. **Docs**: the attribute form in the attachment section of `docs/markdown-and-criticmarkup.md`, including that `full` means column width in the EPUB and print. + +Tasks 1 and 3 are independent of 2 and 4 and testable without the editor. + +## Reopens when + +- The 65ch column is widened. Wrapped text beside a 50% image is about 32 characters at the current width, which is the weakest part of this design and is fixed by the column, not by the image. +- A second consumer needs a key this design drops. The rule that unknown keys are discarded is cheap to reverse only before documents contain them. +- Galleries are wanted. A container node changes the markdown form for images generally, so it is a redesign of this decision and not an addition to it. + +## Out of scope + +Drag-to-resize handles; side-by-side galleries; editable captions; alt text distinct from the filename; sizing or alignment for non-image attachments, which stay block chips; server-side resizing or thumbnails; `width` and `align` parameters on the MCP `attach` tool. From 1d1da1ef0ddd2521d010ab0e0f53d71394ef66bc Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:15:58 -0700 Subject: [PATCH 02/12] Schema, parser, and serializer for image width and align (#109) An image alone in a paragraph may now carry a Pandoc-style attribute block: `![cat.png](/doc/attachments/id/cat.png){width=50% align=left}`. Width is a percent from 1% to 100% or the token `full`; align is left, center, or right. Both default to null, so a document without a block serialises byte-identical and no existing round-trip changes. Unrecognised keys and out-of-range values are dropped rather than carried, so the node never holds what it cannot serialise back. Text after the image that is not brace-shaped leaves the paragraph as it was. Co-Authored-By: Claude Opus 5 --- app/shared/rich-markdown.ts | 52 +++++++++++++++++++++++-- tests/unit/shared/rich-markdown.test.ts | 45 +++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/app/shared/rich-markdown.ts b/app/shared/rich-markdown.ts index 78772e0b..c294a9ee 100644 --- a/app/shared/rich-markdown.ts +++ b/app/shared/rich-markdown.ts @@ -93,6 +93,8 @@ export const richSchema = new Schema({ src: { default: "" }, alt: { default: "" }, bytes: { default: null as number | string | null }, + width: { default: null as string | null }, + align: { default: null as "left" | "center" | "right" | null }, }, }, // GFM tables. Cells hold inline content only — one line per cell, as @@ -313,6 +315,37 @@ type TokenCtor = new (type: string, tag: string, nesting: number) => InlineToken const isBlank = (t: InlineToken) => t.type === "text" && t.content.trim() === ""; +const WIDTH_RE = /^(?:full|(?:100|[1-9]\d?)%)$/; +const ALIGNS = new Set(["left", "center", "right"]); +const ATTR_BLOCK_RE = /^\{([^}]*)\}$/; + +export type ImageLayout = { width: string | null; align: "left" | "center" | "right" | null }; + +/** + * A Pandoc-style attribute block after an image: `{width=50% align=left}`. + * Only `width` and `align` are kept; an unrecognised key or value is + * dropped rather than carried, so the node holds nothing it cannot + * serialise back (docs/plans/2026-09-13-image-sizing-design.md). + */ +export function parseImageLayout(text: string): ImageLayout | null { + const block = ATTR_BLOCK_RE.exec(text.trim()); + if (!block) return null; + const layout: ImageLayout = { width: null, align: null }; + for (const [, key, value] of block[1].matchAll(/([a-z]+)=([^\s}]+)/g)) { + if (key === "width" && WIDTH_RE.test(value)) layout.width = value; + else if (key === "align" && ALIGNS.has(value)) layout.align = value as ImageLayout["align"]; + } + return layout; +} + +/** The attribute block for a node's layout, or "" when it carries none. */ +export function serializeImageLayout(layout: ImageLayout): string { + const parts: string[] = []; + if (layout.width) parts.push(`width=${layout.width}`); + if (layout.align) parts.push(`align=${layout.align}`); + return parts.length ? `{${parts.join(" ")}}` : ""; +} + /** * Attachments in markdown: an image, or a link, standing alone in a * paragraph and pointing at an attachment path becomes an `attachment` @@ -328,10 +361,16 @@ function attachmentRule(state: { tokens: InlineToken[]; Token: TokenCtor }): voi const meaningful = inline.children.filter((c) => !isBlank(c)); const alone = toks[i - 1]?.type === "paragraph_open" && toks[i + 1]?.type === "paragraph_close"; - let attachment: { kind: "image" | "file"; src: string; alt: string } | null = null; - if (alone && meaningful.length === 1 && meaningful[0].type === "image") { + let attachment: ({ kind: "image" | "file"; src: string; alt: string } & Partial) | null = null; + const layout = + alone && meaningful.length === 2 && meaningful[0].type === "image" && meaningful[1].type === "text" + ? parseImageLayout(meaningful[1].content) + : null; + if (alone && meaningful[0]?.type === "image" && (meaningful.length === 1 || layout)) { const parsed = parseAttachmentUrl(meaningful[0].attrGet("src") ?? ""); - if (parsed) attachment = { kind: "image", src: parsed.path, alt: meaningful[0].content }; + if (parsed) { + attachment = { kind: "image", src: parsed.path, alt: meaningful[0].content, ...(layout ?? {}) }; + } } else if ( alone && meaningful.length === 3 && @@ -351,6 +390,8 @@ function attachmentRule(state: { tokens: InlineToken[]; Token: TokenCtor }): voi tok.attrSet("kind", attachment.kind); tok.attrSet("src", attachment.src); tok.attrSet("alt", attachment.alt); + if (attachment.width) tok.attrSet("width", attachment.width); + if (attachment.align) tok.attrSet("align", attachment.align); toks.splice(i - 1, 3, tok); i -= 1; continue; @@ -465,6 +506,8 @@ export const markdownParser = new MarkdownParser(richSchema, makeMarkdownIt() as kind: tok.attrGet("kind") === "image" ? "image" : "file", src: tok.attrGet("src") ?? "", alt: tok.attrGet("alt") ?? "", + width: tok.attrGet("width"), + align: tok.attrGet("align"), }), }, hardbreak: { node: "hardBreak" }, @@ -532,7 +575,8 @@ export const markdownSerializer = new MarkdownSerializer( attachment(state, node) { const alt = String(node.attrs.alt ?? "").replace(/[[\]]/g, "\\$&"); const src = String(node.attrs.src ?? ""); - state.write(`${node.attrs.kind === "image" ? "!" : ""}[${alt}](${src})`); + const layout = serializeImageLayout(node.attrs as ImageLayout); + state.write(`${node.attrs.kind === "image" ? "!" : ""}[${alt}](${src})${layout}`); state.closeBlock(node); }, table(state, node) { diff --git a/tests/unit/shared/rich-markdown.test.ts b/tests/unit/shared/rich-markdown.test.ts index 885d1273..2759ca1a 100644 --- a/tests/unit/shared/rich-markdown.test.ts +++ b/tests/unit/shared/rich-markdown.test.ts @@ -365,6 +365,51 @@ describe("attachments", () => { expect(yDocToMarkdown(doc)).toBe(`# Title\n\n${image}\n\nAfter.`); expect(getBlocks(doc)[1].id).toMatch(BLOCK_ID_RE); }); + + it("reads width and align from an attribute block after the image", () => { + const sized = `${image}{width=50% align=left}`; + const parsed = parseMarkdown(sized); + if (!parsed.ok) throw new Error(parsed.message); + const node = parsed.doc.firstChild!; + expect(node.type.name).toBe("attachment"); + expect(node.attrs.width).toBe("50%"); + expect(node.attrs.align).toBe("left"); + expect(roundTrip(sized)).toBe(sized); + }); + + it("round-trips width=full and align on their own", () => { + expect(roundTrip(`${image}{width=full}`)).toBe(`${image}{width=full}`); + expect(roundTrip(`${image}{align=center}`)).toBe(`${image}{align=center}`); + }); + + it("honours a percent that is not one of the toolbar presets", () => { + const parsed = parseMarkdown(`${image}{width=37%}`); + if (!parsed.ok) throw new Error(parsed.message); + expect(parsed.doc.firstChild!.attrs.width).toBe("37%"); + }); + + it("drops unknown keys and out-of-range values from the attribute block", () => { + const parsed = parseMarkdown(`${image}{width=200% align=diagonal caption=hi}`); + if (!parsed.ok) throw new Error(parsed.message); + const node = parsed.doc.firstChild!; + expect(node.type.name).toBe("attachment"); + expect(node.attrs.width).toBe(null); + expect(node.attrs.align).toBe(null); + expect(roundTrip(`${image}{width=200% align=diagonal caption=hi}`)).toBe(image); + }); + + it("leaves an image followed by text that is not an attribute block as literal text", () => { + // Not round-tripped: an image turned back into text is escaped by the + // generic text serialiser, which predates the attribute block. + const parsed = parseMarkdown(`${image} and more`); + if (!parsed.ok) throw new Error(parsed.message); + expect(parsed.doc.firstChild!.type.name).toBe("paragraph"); + expect(parsed.doc.textContent).toBe(`${image} and more`); + }); + + it("serialises no attribute block when the node carries no layout", () => { + expect(roundTrip(image)).toBe(image); + }); }); From ea04ce979c062a4e15b164a1f1a164e90f1aa160 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:17:25 -0700 Subject: [PATCH 03/12] Image layout in the EPUB and print exports (#109) The export parser is a separate MarkdownIt instance that knew nothing about the attribute block, so `{width=50% align=left}` would have printed as literal text in both the EPUB and the print page. It now gets the same rule, applying the layout as inline CSS on the . Width, align, and the full-width rule move to app/shared/image-layout.ts so both parsers share them without pulling ProseMirror into the Worker bundle. READING_CSS clears floats at headings, rules, tables, code, and blockquotes, so a float runs beside body text and stops at the next structural boundary. `full` is 100% of the text width in both exports: READING_CSS sets a centred 42em column and there is no canvas outside it. Co-Authored-By: Claude Opus 5 --- app/shared/epub.ts | 32 ++++++++++++++++++ app/shared/image-layout.ts | 59 ++++++++++++++++++++++++++++++++++ app/shared/rich-markdown.ts | 32 +----------------- tests/unit/shared/epub.test.ts | 20 ++++++++++++ 4 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 app/shared/image-layout.ts diff --git a/app/shared/epub.ts b/app/shared/epub.ts index 653f5f4a..11283be8 100644 --- a/app/shared/epub.ts +++ b/app/shared/epub.ts @@ -2,6 +2,7 @@ import MarkdownIt from "markdown-it"; import { zipSync, strToU8 } from "fflate"; import { stripMentionIds } from "./agent-protocol"; import { parseAttachmentUrl } from "./attachment-policy"; +import { parseImageLayout, imageLayoutStyle } from "./image-layout"; import { titleFromMarkdown } from "./doc-url"; /** @@ -59,7 +60,35 @@ export function attachmentImages(markdown: string): { path: string; id: string; return out; } +type EpubToken = { + type: string; + content: string; + children: EpubToken[] | null; + attrSet(name: string, value: string): void; +}; + +/** + * Turns an image's `{width=… align=…}` block into inline CSS on the `` + * and removes the text, so exports carry the layout rather than printing the + * braces. Mirrors `attachmentRule` in rich-markdown.ts (#109). + */ +function imageLayoutRule(state: { tokens: EpubToken[] }): void { + for (const token of state.tokens) { + if (token.type !== "inline" || !token.children) continue; + const kids = token.children; + for (let i = 0; i < kids.length - 1; i++) { + if (kids[i].type !== "image" || kids[i + 1].type !== "text") continue; + const layout = parseImageLayout(kids[i + 1].content); + if (!layout) continue; + const style = imageLayoutStyle(layout); + if (style) kids[i].attrSet("style", style); + kids[i + 1].content = ""; + } + } +} + const md = new MarkdownIt({ html: false, linkify: true, xhtmlOut: true }); +md.core.ruler.after("inline", "imageLayout", imageLayoutRule as never); function escapeXml(text: string): string { return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); @@ -115,6 +144,9 @@ pre code { background: none; padding: 0; } a { color: inherit; text-decoration: underline; text-decoration-color: #999; } hr { border: 0; border-top: 1px solid #d9d9d9; margin: 1.5em 0; } img { max-width: 100%; height: auto; } +/* A floated image runs beside body text and stops at the next structural + boundary, not at an arbitrary paragraph (#109). */ +h1, h2, h3, hr, table, pre, blockquote { clear: both; } table { border-collapse: collapse; margin: 0 0 0.75em; font-size: 0.95em; } th, td { border: 1px solid #d9d9d9; padding: 0.3em 0.6em; text-align: left; vertical-align: top; } th { font-weight: bold; background: #f7f7f7; } diff --git a/app/shared/image-layout.ts b/app/shared/image-layout.ts new file mode 100644 index 00000000..22363d43 --- /dev/null +++ b/app/shared/image-layout.ts @@ -0,0 +1,59 @@ +/** + * Width and alignment for an image attachment, carried in the markdown as a + * Pandoc-style attribute block after the image: + * `![cat.png](/doc/attachments/id/cat.png){width=50% align=left}`. + * + * Pure and free of ProseMirror, so the editor's schema and the EPUB and print + * exports can share it (docs/plans/2026-09-13-image-sizing-design.md). + */ + +export type ImageAlign = "left" | "center" | "right"; +export type ImageLayout = { width: string | null; align: ImageAlign | null }; + +const WIDTH_RE = /^(?:full|(?:100|[1-9]\d?)%)$/; +const ALIGNS = new Set(["left", "center", "right"]); +const ATTR_BLOCK_RE = /^\{([^}]*)\}$/; + +/** + * Reads an attribute block. Only `width` and `align` are kept; an unknown key + * or an out-of-range value is dropped rather than carried, so a node never + * holds what it cannot serialise back. Returns null when the text is not + * brace-shaped, which leaves the paragraph as it was. + */ +export function parseImageLayout(text: string): ImageLayout | null { + const block = ATTR_BLOCK_RE.exec(text.trim()); + if (!block) return null; + const layout: ImageLayout = { width: null, align: null }; + for (const [, key, value] of block[1].matchAll(/([a-z]+)=([^\s}]+)/g)) { + if (key === "width" && WIDTH_RE.test(value)) layout.width = value; + else if (key === "align" && ALIGNS.has(value)) layout.align = value as ImageAlign; + } + return layout; +} + +/** The attribute block for a layout, or "" when it carries none. */ +export function serializeImageLayout(layout: Partial): string { + const parts: string[] = []; + if (layout.width) parts.push(`width=${layout.width}`); + if (layout.align) parts.push(`align=${layout.align}`); + return parts.length ? `{${parts.join(" ")}}` : ""; +} + +/** A full-width image has no room to wrap beside it, so it ignores align. */ +export function isFullWidth(layout: Partial): boolean { + return layout.width === "full" || layout.width === "100%"; +} + +/** + * The layout as inline CSS, for the EPUB and print exports where there is no + * stylesheet hook per image. Returns "" when there is nothing to say. + */ +export function imageLayoutStyle(layout: Partial): string { + const rules: string[] = []; + if (layout.width) rules.push(`width: ${layout.width === "full" ? "100%" : layout.width}`); + if (layout.align && !isFullWidth(layout)) { + if (layout.align === "center") rules.push("display: block", "margin-left: auto", "margin-right: auto"); + else rules.push(`float: ${layout.align}`, `margin-${layout.align === "left" ? "right" : "left"}: 1em`, "margin-bottom: 0.5em"); + } + return rules.length ? `${rules.join("; ")};` : ""; +} diff --git a/app/shared/rich-markdown.ts b/app/shared/rich-markdown.ts index c294a9ee..58ed8ad2 100644 --- a/app/shared/rich-markdown.ts +++ b/app/shared/rich-markdown.ts @@ -17,6 +17,7 @@ import * as Y from "yjs"; import { yXmlFragmentToProseMirrorRootNode } from "@tiptap/y-tiptap"; import { blockHash, formatMention, parseAnchor as parseLegacyAnchor, type DocBlock } from "./agent-protocol"; import { parseAttachmentUrl } from "./attachment-policy"; +import { parseImageLayout, serializeImageLayout, type ImageLayout } from "./image-layout"; /* ---------- Schema (names must match the TipTap extensions) ---------- */ @@ -315,37 +316,6 @@ type TokenCtor = new (type: string, tag: string, nesting: number) => InlineToken const isBlank = (t: InlineToken) => t.type === "text" && t.content.trim() === ""; -const WIDTH_RE = /^(?:full|(?:100|[1-9]\d?)%)$/; -const ALIGNS = new Set(["left", "center", "right"]); -const ATTR_BLOCK_RE = /^\{([^}]*)\}$/; - -export type ImageLayout = { width: string | null; align: "left" | "center" | "right" | null }; - -/** - * A Pandoc-style attribute block after an image: `{width=50% align=left}`. - * Only `width` and `align` are kept; an unrecognised key or value is - * dropped rather than carried, so the node holds nothing it cannot - * serialise back (docs/plans/2026-09-13-image-sizing-design.md). - */ -export function parseImageLayout(text: string): ImageLayout | null { - const block = ATTR_BLOCK_RE.exec(text.trim()); - if (!block) return null; - const layout: ImageLayout = { width: null, align: null }; - for (const [, key, value] of block[1].matchAll(/([a-z]+)=([^\s}]+)/g)) { - if (key === "width" && WIDTH_RE.test(value)) layout.width = value; - else if (key === "align" && ALIGNS.has(value)) layout.align = value as ImageLayout["align"]; - } - return layout; -} - -/** The attribute block for a node's layout, or "" when it carries none. */ -export function serializeImageLayout(layout: ImageLayout): string { - const parts: string[] = []; - if (layout.width) parts.push(`width=${layout.width}`); - if (layout.align) parts.push(`align=${layout.align}`); - return parts.length ? `{${parts.join(" ")}}` : ""; -} - /** * Attachments in markdown: an image, or a link, standing alone in a * paragraph and pointing at an attachment path becomes an `attachment` diff --git a/tests/unit/shared/epub.test.ts b/tests/unit/shared/epub.test.ts index bf13c08f..85128597 100644 --- a/tests/unit/shared/epub.test.ts +++ b/tests/unit/shared/epub.test.ts @@ -16,6 +16,26 @@ Keep it short. `; describe("EPUB export (#100)", () => { + const sized = "![cat.png](/abcd1234/attachments/abcdefghijklmnop/cat.png){width=50% align=left}"; + + it("renders an image's attribute block as layout, not as literal text (#109)", () => { + const html = epubChapterHtml(sized, []); + expect(html).not.toContain("{width=50%"); + expect(html).toContain("width: 50%"); + expect(html).toContain("float: left"); + }); + + it("clears floated images at the next structural boundary (#109)", () => { + expect(READING_CSS).toMatch(/clear: both/); + }); + + it("renders an image with no attribute block unchanged (#109)", () => { + const plain = "![cat.png](/abcd1234/attachments/abcdefghijklmnop/cat.png)"; + const html = epubChapterHtml(plain, []); + expect(html).not.toContain("style="); + expect(html).toContain(" { const text = readingMarkdown(md); expect(text).toContain("Hello there, new text quoted."); From d2778e62ccc2d65709969f3eea0ad9fd9b387961 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:18:52 -0700 Subject: [PATCH 04/12] Editor rendering for image width, alignment, and full bleed (#109) The node view puts data-width and data-align on the wrapper and sets the width inline, since an arbitrary percent cannot be a static rule. Full bleed drops max-width to escape the 65ch column, which costs nothing here: .tiptap is a left-aligned padded block rather than a centred column, and the comment rail is a flex sibling that keeps its space. Floats clear at headings, rules, tables, code, blockquotes, and non-floated attachments. Below 640px alignment drops and every image takes its own line, because a 50% float leaves too little text beside it to read. Co-Authored-By: Claude Opus 5 --- app/app.css | 44 +++++++++++++++++++ app/components/AttachmentView.tsx | 22 +++++++++- app/lib/attachment.ts | 6 +++ .../unit/components/attachment-view.test.tsx | 44 +++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/app/app.css b/app/app.css index 5e4cf60e..20009ac4 100644 --- a/app/app.css +++ b/app/app.css @@ -185,6 +185,50 @@ body { max-width: 65ch; margin: 0 0 1rem; } +/* Image layout (#109). The width is an inline style, because a percent can + be any value; alignment and full bleed are rules. A left or right aligned + image floats and body text wraps beside it. */ +.tiptap .attachment[data-align="left"] { + float: left; + margin: 0 1.25rem 0.75rem 0; +} +.tiptap .attachment[data-align="right"] { + float: right; + margin: 0 0 0.75rem 1.25rem; +} +.tiptap .attachment[data-align="center"] { + margin-inline: auto; +} +.tiptap .attachment[data-width="full"] { + max-width: none; + width: 100%; +} + +/* A float runs beside body text and stops at the next structural boundary, + not at an arbitrary paragraph. Floated attachments are exempt, or each + would push the one before it down instead of sitting where it was put. */ +.tiptap h1, +.tiptap h2, +.tiptap h3, +.tiptap hr, +.tiptap table, +.tiptap pre, +.tiptap blockquote, +.tiptap .attachment:not([data-align="left"]):not([data-align="right"]) { + clear: both; +} + +/* Below the phone breakpoint a 50% float leaves too little text beside it to + read, so alignment drops and every image takes its own line. */ +@media (max-width: 640px) { + .tiptap .attachment[data-align="left"], + .tiptap .attachment[data-align="right"] { + float: none; + width: 100%; + margin: 0 0 1rem; + } +} + .tiptap .attachment.is-selected > * { outline: 2px solid var(--color-ink); outline-offset: 2px; diff --git a/app/components/AttachmentView.tsx b/app/components/AttachmentView.tsx index 9a8301a2..1b39468f 100644 --- a/app/components/AttachmentView.tsx +++ b/app/components/AttachmentView.tsx @@ -1,5 +1,6 @@ import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import { formatBytes } from "~/shared/attachment-policy"; +import { isFullWidth, type ImageAlign } from "~/shared/image-layout"; import Icon from "~/components/Icon"; /** @@ -8,12 +9,29 @@ import Icon from "~/components/Icon"; * the code-block chrome (see .attachment in app.css). */ export default function AttachmentView({ node, selected }: NodeViewProps) { - const { kind, src, alt } = node.attrs as { kind: "image" | "file"; src: string; alt: string }; + const { kind, src, alt, width, align } = node.attrs as { + kind: "image" | "file"; + src: string; + alt: string; + width: string | null; + align: ImageAlign | null; + }; const bytes = node.attrs.bytes === null || node.attrs.bytes === undefined ? null : Number(node.attrs.bytes); const size = bytes !== null && Number.isFinite(bytes) ? formatBytes(bytes) : null; + // A percent can be any value, so it cannot be a static rule and is set + // inline; `full` is a stylesheet case because it escapes the column. + const fullBleed = isFullWidth({ width }); + return ( - + {kind === "image" ? (
{alt} diff --git a/app/lib/attachment.ts b/app/lib/attachment.ts index 5c168dd7..a30e2d06 100644 --- a/app/lib/attachment.ts +++ b/app/lib/attachment.ts @@ -22,6 +22,8 @@ export const Attachment = Node.create({ src: { default: "", rendered: false }, alt: { default: "", rendered: false }, bytes: { default: null, rendered: false }, + width: { default: null, rendered: false }, + align: { default: null, rendered: false }, }; }, @@ -34,6 +36,8 @@ export const Attachment = Node.create({ src: el.getAttribute("data-src") ?? "", alt: el.getAttribute("data-alt") ?? "", bytes: el.getAttribute("data-bytes") ? Number(el.getAttribute("data-bytes")) : null, + width: el.getAttribute("data-width"), + align: el.getAttribute("data-align"), }), }, ]; @@ -47,6 +51,8 @@ export const Attachment = Node.create({ "data-src": node.attrs.src, "data-alt": node.attrs.alt, "data-bytes": node.attrs.bytes ?? undefined, + "data-width": node.attrs.width ?? undefined, + "data-align": node.attrs.align ?? undefined, }), ]; }, diff --git a/tests/unit/components/attachment-view.test.tsx b/tests/unit/components/attachment-view.test.tsx index c933452f..90acb532 100644 --- a/tests/unit/components/attachment-view.test.tsx +++ b/tests/unit/components/attachment-view.test.tsx @@ -20,6 +20,50 @@ describe("AttachmentView", () => { expect(screen.getByText("2 KB")).toBeTruthy(); }); + it("puts width and align on the wrapper so the stylesheet can size it (#109)", () => { + const { container } = view({ + kind: "image", + src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", + alt: "cat.png", + bytes: null, + width: "50%", + align: "left", + }); + const wrapper = container.querySelector(".attachment")!; + expect(wrapper.getAttribute("data-width")).toBe("50%"); + expect(wrapper.getAttribute("data-align")).toBe("left"); + // An arbitrary percent cannot be a static CSS rule, so the width is inline. + expect((wrapper as HTMLElement).style.width).toBe("50%"); + }); + + it("leaves the width to the stylesheet at full bleed (#109)", () => { + const { container } = view({ + kind: "image", + src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", + alt: "cat.png", + bytes: null, + width: "full", + align: "left", + }); + const wrapper = container.querySelector(".attachment")! as HTMLElement; + expect(wrapper.getAttribute("data-width")).toBe("full"); + expect(wrapper.style.width).toBe(""); + // Nothing wraps beside a full-bleed image, so it drops the float. + expect(wrapper.hasAttribute("data-align")).toBe(false); + }); + + it("omits the layout attributes when the node carries none (#109)", () => { + const { container } = view({ + kind: "image", + src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", + alt: "cat.png", + bytes: null, + }); + const wrapper = container.querySelector(".attachment")!; + expect(wrapper.hasAttribute("data-width")).toBe(false); + expect(wrapper.hasAttribute("data-align")).toBe(false); + }); + it("renders other files as a download chip, and hides an unknown size", () => { view({ kind: "file", src: "/abcd1234/attachments/abcdefghijklmnop/report.pdf", alt: "report.pdf", bytes: null }); const link = screen.getByText("report.pdf").closest("a")!; From 13fd77a080204510a94c78fc6b3f3043895890c8 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:20:26 -0700 Subject: [PATCH 05/12] Width and alignment controls on a selected image (#109) A fourth bubble context, detected before the text gates because a node selection on an atom spans no text and the emptiness and textBetween checks would both reject it. Four width steps, full width, and three alignments, each a single setNodeMarkup and so a single Yjs update. Choosing an alignment on a full-width image steps the width down to 50%: nothing wraps beside a full-width image, so the alignment would be inert where it was just asked for. Co-Authored-By: Claude Opus 5 --- app/components/BubbleToolbar.tsx | 82 +++++++++++++++++++++++++++ app/lib/image-commands.ts | 29 ++++++++++ tests/unit/lib/image-commands.test.ts | 77 +++++++++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 app/lib/image-commands.ts create mode 100644 tests/unit/lib/image-commands.test.ts diff --git a/app/components/BubbleToolbar.tsx b/app/components/BubbleToolbar.tsx index 80625d5a..0de48f78 100644 --- a/app/components/BubbleToolbar.tsx +++ b/app/components/BubbleToolbar.tsx @@ -3,12 +3,15 @@ import { getMarkRange, isMarkActive, type Editor as TiptapEditor } from "@tiptap import type { EditorState } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; import { processRangeAtCursor } from "~/lib/suggestion-actions"; +import { selectedImageAttachment, setImageLayout } from "~/lib/image-commands"; +import { isFullWidth, type ImageAlign } from "~/shared/image-layout"; import Icon from "~/components/Icon"; type BubbleContext = | { kind: "selection" } | { kind: "suggestion" } | { kind: "annotation" } + | { kind: "attachment" } | null; /** @@ -28,6 +31,10 @@ function getContext(state: EditorState): BubbleContext { } function detectContext(state: EditorState): BubbleContext { + // Before the text checks: a node selection on an atom spans no text, so + // the emptiness and textBetween gates below would both reject it. + if (selectedImageAttachment(state)) return { kind: "attachment" }; + const { from, to, empty } = state.selection; if (empty) { @@ -102,6 +109,11 @@ const shouldShowSuggestion = ({ editor, element, view, state }: ShouldShowProps) return getContext(state)?.kind === "suggestion"; }; +const shouldShowAttachment = ({ editor, element, view, state }: ShouldShowProps) => { + if (!baseChecks(view, element, editor)) return false; + return getContext(state)?.kind === "attachment"; +}; + // 44px square icon buttons at every width: Accept/Reject sit side by side // and a mis-tap on track changes is destructive. const btnClass = @@ -109,6 +121,64 @@ const btnClass = const menuClass = "bubble-menu flex bg-ink shadow-md"; +// Narrower than the 44px square buttons: the width row is five cells wide and +// a mis-tap here is one undo, not a lost suggestion. +const stepClass = + "flex h-[44px] min-w-[40px] items-center justify-center px-2 text-[13px] text-paper transition-colors hover:bg-paper/15 cursor-pointer"; + +const WIDTHS = ["25%", "50%", "75%", "100%"] as const; +const ALIGNS: { value: ImageAlign; icon: string; label: string }[] = [ + { value: "left", icon: "format_image_left", label: "Align left" }, + { value: "center", icon: "format_align_center", label: "Align centre" }, + { value: "right", icon: "format_image_right", label: "Align right" }, +]; + +function ImageLayoutButtons({ editor }: { editor: TiptapEditor }) { + const attrs = selectedImageAttachment(editor.state)?.node.attrs ?? {}; + const width = (attrs.width ?? null) as string | null; + const align = (attrs.align ?? null) as ImageAlign | null; + const on = (active: boolean) => `${stepClass}${active ? " bg-paper/25" : ""}`; + + return ( + <> + {WIDTHS.map((w) => ( + + ))} + + {ALIGNS.map((a) => ( + + ))} + + ); +} + // flip/shift keep the menu on screen when the keyboard or viewport edge // would otherwise cover it. const menuOptions = { placement: "bottom" as const, offset: { mainAxis: 8 }, flip: true, shift: true }; @@ -163,6 +233,18 @@ export default function BubbleToolbar({ editor, onNewComment }: { editor: Tiptap + + {/* A selected image → width and alignment */} + + + ); } diff --git a/app/lib/image-commands.ts b/app/lib/image-commands.ts new file mode 100644 index 00000000..b83b02e7 --- /dev/null +++ b/app/lib/image-commands.ts @@ -0,0 +1,29 @@ +import { NodeSelection } from "@tiptap/pm/state"; +import type { Editor } from "@tiptap/core"; +import type { EditorState } from "@tiptap/pm/state"; +import type { Node as PmNode } from "@tiptap/pm/model"; +import type { ImageLayout } from "~/shared/image-layout"; + +/** + * The image attachment a node selection is on, or null. File attachments are + * excluded: a download chip has no width or alignment to set (#109). + */ +export function selectedImageAttachment(state: EditorState): { node: PmNode; pos: number } | null { + const selection = state.selection; + if (!(selection instanceof NodeSelection)) return null; + const node = selection.node; + if (node.type.name !== "attachment" || node.attrs.kind !== "image") return null; + return { node, pos: selection.from }; +} + +/** + * Writes width, alignment, or both onto the selected image. One call per + * choice, so each is a single Yjs update rather than a stream of them. + */ +export function setImageLayout(editor: Editor, patch: Partial): boolean { + const selected = selectedImageAttachment(editor.state); + if (!selected) return false; + const tr = editor.state.tr.setNodeMarkup(selected.pos, undefined, { ...selected.node.attrs, ...patch }); + editor.view.dispatch(tr); + return true; +} diff --git a/tests/unit/lib/image-commands.test.ts b/tests/unit/lib/image-commands.test.ts new file mode 100644 index 00000000..7fed72bf --- /dev/null +++ b/tests/unit/lib/image-commands.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +/** + * Tests for image-commands: which selection offers the image layout controls, + * and what setting a width or an alignment writes (#109). + */ +import { describe, it, expect, afterEach } from "vitest"; +import { Editor } from "@tiptap/core"; +import Document from "@tiptap/extension-document"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import { NodeSelection } from "@tiptap/pm/state"; +import { Attachment } from "~/lib/attachment"; +import { selectedImageAttachment, setImageLayout } from "~/lib/image-commands"; + +let editor: Editor | null = null; + +function makeEditor(kind: "image" | "file") { + editor = new Editor({ + extensions: [Document, Paragraph, Text, Attachment], + content: { + type: "doc", + content: [ + { type: "attachment", attrs: { kind, src: "/a/attachments/b/cat.png", alt: "cat.png" } }, + { type: "paragraph", content: [{ type: "text", text: "After." }] }, + ], + }, + }); + const { state, view } = editor; + view.dispatch(state.tr.setSelection(NodeSelection.create(state.doc, 0))); + return editor; +} + +afterEach(() => { + editor?.destroy(); + editor = null; +}); + +describe("selectedImageAttachment", () => { + it("finds an image attachment under a node selection", () => { + const found = selectedImageAttachment(makeEditor("image").state); + expect(found?.node.attrs.alt).toBe("cat.png"); + expect(found?.pos).toBe(0); + }); + + it("ignores a file attachment, which has no layout to set", () => { + expect(selectedImageAttachment(makeEditor("file").state)).toBe(null); + }); + + it("ignores a text selection", () => { + const ed = makeEditor("image"); + ed.commands.setTextSelection({ from: 2, to: 6 }); + expect(selectedImageAttachment(ed.state)).toBe(null); + }); +}); + +describe("setImageLayout", () => { + it("writes width and align onto the selected image", () => { + const ed = makeEditor("image"); + setImageLayout(ed, { width: "50%", align: "left" }); + expect(ed.state.doc.firstChild!.attrs.width).toBe("50%"); + expect(ed.state.doc.firstChild!.attrs.align).toBe("left"); + }); + + it("clears an attribute when it is set to null", () => { + const ed = makeEditor("image"); + setImageLayout(ed, { width: "50%", align: "left" }); + setImageLayout(ed, { align: null }); + expect(ed.state.doc.firstChild!.attrs.width).toBe("50%"); + expect(ed.state.doc.firstChild!.attrs.align).toBe(null); + }); + + it("does nothing when the selection is not an image", () => { + const ed = makeEditor("file"); + expect(setImageLayout(ed, { width: "50%" })).toBe(false); + expect(ed.state.doc.firstChild!.attrs.width).toBe(null); + }); +}); From ee583826ed8f9f97ef8f9c706b05a3288ff20ea1 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:29:35 -0700 Subject: [PATCH 06/12] Full bleed breaks out of the centred column; toolbar fixes (#109) Three things running it proved wrong. The design said .tiptap is a left-aligned padded block and full bleed cost nothing but a dropped max-width. The editor is inside `mx-auto w-full max-w-3xl`, a centred 672px column, and a full-bleed image measured 630px, the same as a heading. It now breaks out with container units against .doc-frame, the flex sibling of the comment rail; the viewport would be the wrong unit because it includes the rail. At a 1400px viewport the frame is 1120, the text column 630, and full bleed 1078 centred with symmetric gaps, stopping before the rail. BubbleMenu renders its children into a portal and does not re-render them per transaction, so the pressed state read a stale editor.state and every button showed unpressed on a node carrying a width. It reads through useEditorState now. Material Symbols is loaded as a named subset, so the four new icons rendered as raw text until they were added to icon_names in root.tsx. Co-Authored-By: Claude Opus 5 --- app/app.css | 17 ++++++++++++++++- app/components/BubbleToolbar.tsx | 16 +++++++++++++--- app/components/Editor.tsx | 5 ++++- app/root.tsx | 2 +- docs/markdown-and-criticmarkup.md | 2 ++ docs/plans/2026-09-13-image-sizing-design.md | 20 ++++++++++++-------- 6 files changed, 48 insertions(+), 14 deletions(-) diff --git a/app/app.css b/app/app.css index 20009ac4..65283fb9 100644 --- a/app/app.css +++ b/app/app.css @@ -199,9 +199,24 @@ body { .tiptap .attachment[data-align="center"] { margin-inline: auto; } +/* The editor is a centred max-w-3xl column inside .doc-frame, so full bleed + has to break out of it. Container units measure .doc-frame, which is the + space beside the comment rail; the viewport would be wrong whenever the + rail is showing. The negative inline margin re-centres the wider box. */ +.doc-frame { + container-type: inline-size; + container-name: doc-frame; +} .tiptap .attachment[data-width="full"] { max-width: none; - width: 100%; + width: min(100cqw - 3rem, 1600px); + margin-inline: calc((100% - min(100cqw - 3rem, 1600px)) / 2); +} +@supports not (width: 1cqw) { + .tiptap .attachment[data-width="full"] { + width: 100%; + margin-inline: 0; + } } /* A float runs beside body text and stops at the next structural boundary, diff --git a/app/components/BubbleToolbar.tsx b/app/components/BubbleToolbar.tsx index 0de48f78..b1de5be9 100644 --- a/app/components/BubbleToolbar.tsx +++ b/app/components/BubbleToolbar.tsx @@ -1,4 +1,5 @@ import { BubbleMenu } from "@tiptap/react/menus"; +import { useEditorState } from "@tiptap/react"; import { getMarkRange, isMarkActive, type Editor as TiptapEditor } from "@tiptap/core"; import type { EditorState } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; @@ -134,9 +135,18 @@ const ALIGNS: { value: ImageAlign; icon: string; label: string }[] = [ ]; function ImageLayoutButtons({ editor }: { editor: TiptapEditor }) { - const attrs = selectedImageAttachment(editor.state)?.node.attrs ?? {}; - const width = (attrs.width ?? null) as string | null; - const align = (attrs.align ?? null) as ImageAlign | null; + // BubbleMenu renders its children into a portal and does not re-render them + // per transaction, so the pressed state has to subscribe to the editor. + const { width, align } = useEditorState({ + editor, + selector: ({ editor: ed }) => { + const attrs = selectedImageAttachment(ed.state)?.node.attrs ?? {}; + return { + width: (attrs.width ?? null) as string | null, + align: (attrs.align ?? null) as ImageAlign | null, + }; + }, + }); const on = (active: boolean) => `${stepClass}${active ? " bg-paper/25" : ""}`; return ( diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 294a9a87..f5b98f13 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -423,7 +423,10 @@ export default function Editor({ return ( <>
{ // Blocks cascade in; the class leaves once the last one has landed. diff --git a/app/root.tsx b/app/root.tsx index ba015426..4dd83d5e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -50,7 +50,7 @@ export const links: Route.LinksFunction = () => [ // Subset to the icon names actually used — keep this list sorted and in // sync with usages or new glyphs render as raw text. rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=add,add_2,add_comment,alternate_email,attach_file,check,checklist,chevron_left,chevron_right,close,code,comment,computer,content_copy,dark_mode,delete,done_all,download,edit,expand_more,format_bold,format_h1,format_h2,format_h3,format_italic,format_list_bulleted,format_list_numbered,format_paragraph,format_quote,format_size,history,horizontal_rule,ios_share,light_mode,link,login,logout,menu_book,mobile,mode_comment,more_vert,note_add,open_in_new,rate_review,redo,remove_done,robot_2,send,strikethrough_s,table,undo,upload_file&display=block", + href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=add,add_2,add_comment,alternate_email,attach_file,check,checklist,chevron_left,chevron_right,close,code,comment,computer,content_copy,dark_mode,delete,done_all,download,edit,expand_more,format_align_center,format_bold,format_h1,format_h2,format_h3,format_image_left,format_image_right,format_italic,format_list_bulleted,format_list_numbered,format_paragraph,format_quote,format_size,history,horizontal_rule,ios_share,light_mode,link,login,logout,menu_book,mobile,mode_comment,more_vert,note_add,open_in_new,rate_review,redo,remove_done,robot_2,send,strikethrough_s,table,undo,upload_file,width_full&display=block", }, ]; diff --git a/docs/markdown-and-criticmarkup.md b/docs/markdown-and-criticmarkup.md index 1facaebd..03b1c26a 100644 --- a/docs/markdown-and-criticmarkup.md +++ b/docs/markdown-and-criticmarkup.md @@ -171,5 +171,7 @@ Versions are taken when typing settles (60s idle), when the size swings by more A file dropped, pasted, or picked into a document is stored in R2 under `/` and lands in the text as an `attachment` block: images render inline, other files as a chip. Its canonical markdown is an image or a link standing alone in a paragraph at the attachment path, `![cat.png](/abcd1234/attachments//cat.png)` or `[report.pdf](/abcd1234/attachments//report.pdf)`. Only that path shape becomes an attachment; any other image stays literal text (a public document embeds no foreign images) and any other link stays a link. `/:id.md` and Download rewrite the paths to absolute URLs on the request's origin; an upload accepts either form. +An image may carry a Pandoc-style attribute block giving its width and its alignment, `![cat.png](/abcd1234/attachments//cat.png){width=50% align=left}`. `width` is a percent from 1% to 100% or the token `full`, which fills the page past the text column; `align` is `left`, `center`, or `right`, and left and right float the image so body text wraps beside it until the next heading, rule, table, code block, or blockquote. A full-width image ignores `align`, having no room to wrap beside it, and below 640px alignment drops and every image takes its own line. Unknown keys and out-of-range values are dropped on parse rather than carried. In the EPUB and on the print page `full` means 100% of the text width, not wider: those pages are a centred 42em column with no canvas outside it. + Uploading needs a principal: a Google sign-in for people, the OAuth `/mcp` endpoint with `write` for agents. Viewing needs nothing. Limits and allowed types live in `app/shared/attachment-policy.ts` (20 MB a file, 100 MB and 100 files a document, 500 MB and 200 uploads per account a day; images, PDF, text, CSV, JSON, zip, and office formats, sniffed server-side). Attachments expire with the document. diff --git a/docs/plans/2026-09-13-image-sizing-design.md b/docs/plans/2026-09-13-image-sizing-design.md index 818f5cfe..a3d8062e 100644 --- a/docs/plans/2026-09-13-image-sizing-design.md +++ b/docs/plans/2026-09-13-image-sizing-design.md @@ -62,15 +62,17 @@ Three behaviors are chosen deliberately: `markdown-it` is 15.0.1 with no attrs plugin in the tree ([package.json:43](../../package.json)), so this is a custom core rule beside the existing one, which is how the rest of vapor's markdown extensions are written. -### Full-bleed costs one dropped `max-width` +### Full-bleed breaks out of a centred column with container units -`.attachment` gains `data-width` and `data-align`. A percent sets `width` on the block under the existing `max-width: 65ch` ([app/app.css:184](../../app/app.css)). `align=left` and `align=right` set `float` with an inline margin on the text side; `align=center` sets `margin-inline: auto`. `full` sets `max-width: none` and drops any float. +`.attachment` carries `data-width` and `data-align`. Alignment and full bleed are rules; the width is an inline style, because a percent can be any value and `attr()` is not portable enough to read one. -A floated block needs something to end the wrap. The `clear` rule applies to headings, horizontal rules, tables, code blocks, and the next attachment, so a float runs beside body text and stops at the next structural boundary rather than at an arbitrary paragraph. +An earlier draft of this section said `.tiptap` is a left-aligned padded block rather than a centred column, and that full bleed therefore cost nothing but a dropped `max-width`. That was wrong, and running it is what showed it. The editor sits inside `mx-auto w-full max-w-3xl` ([app/components/Editor.tsx:436](../../app/components/Editor.tsx)), a centred 672px column; blocks only look left-aligned because 65ch exceeds that wrapper at every viewport. A full-bleed image measured 630px, identical to a heading. -`StarterKit` supplies Gapcursor and only `CommentEditor` disables it ([app/components/CommentEditor.tsx:80](../../app/components/CommentEditor.tsx)), so the document editor already gives the caret a target beside a floated atom node. Whether that is sufficient in practice for clicking to the left of a right-floated image is the one part of this design most likely to need a second pass after it runs. +Full bleed breaks out with container query units instead. The editor frame, the flex sibling of the 280px comment rail, is marked `container-type: inline-size` as `.doc-frame`, and the full-bleed rule takes `min(100cqw - 3rem, 1600px)` with a negative inline margin to re-centre it. The viewport would be the wrong unit: it includes the rail. Measured at a 1400px viewport, the frame is 1120px, the text column 630px, and a full-bleed image 1078px centred in the frame with symmetric 21px gaps, ending before the rail begins at 1120px. Reading systems without container units fall back to 100% through an `@supports` guard. -The usual full-bleed problem does not arise. `.tiptap` is a left-aligned padded block, not a centered fixed-width column ([app/app.css:224](../../app/app.css)), and the comment rail is a 280px flex sibling ([app/components/DocumentLayout.tsx:315](../../app/components/DocumentLayout.tsx)). A full-bleed block fills the editor's content box and the rail keeps its space with no viewport arithmetic and no negative margins. +Floats clear at headings, rules, tables, code blocks, blockquotes, and non-floated attachments. Floated attachments are exempt from the clear, or each would push the one before it down instead of sitting where it was put. Below 640px alignment drops and every image takes its own line. + +`StarterKit` supplies Gapcursor and only `CommentEditor` disables it ([app/components/CommentEditor.tsx:80](../../app/components/CommentEditor.tsx)), so the caret has a target beside a floated atom node. ### The EPUB needs the rule too, and full-bleed degrades there @@ -86,7 +88,9 @@ A bug is introduced if only the CSS changes: `epubChapterHtml` runs a separate p ### The toolbar is a fourth bubble context -`BubbleToolbar.tsx` already runs a TipTap `BubbleMenu` over a `BubbleContext` union of `selection | suggestion | annotation`. A fourth kind, `attachment`, is detected when the selection is a `NodeSelection` on an `attachment` node whose `kind` is `image`. Its buttons are four widths, plus Full, plus three alignments. +`BubbleToolbar.tsx` already runs a TipTap `BubbleMenu` over a `BubbleContext` union of `selection | suggestion | annotation`. A fourth kind, `attachment`, is detected when the selection is a `NodeSelection` on an `attachment` node whose `kind` is `image`. Its buttons are four widths, plus Full, plus three alignments. The detection runs before the existing text gates: a node selection on an atom spans no text, so both the emptiness check and the `textBetween` check would reject it. + +Two things that only running it revealed. `BubbleMenu` renders its children into a portal and does not re-render them per transaction, so the pressed state has to read the editor through `useEditorState` rather than `editor.state` at render time; without it every button reads unpressed on a node that plainly carries a width. And Material Symbols is loaded as a named subset in [app/root.tsx:53](../../app/root.tsx), so `format_image_left`, `format_image_right`, `format_align_center`, and `width_full` each had to be added to `icon_names` or the ligature renders as raw text. `Icon.tsx` says so in its own doc comment. ### Resizing is not a tracked change @@ -96,9 +100,9 @@ Agents get all of this through `replace`, since agents write markdown. No new MC ## Verified and not verified -Verified by reading the source at `aeeaa3f`: the schema, parser, and serializer shapes; the `65ch` cap and the `.tiptap` container; the rail's width and that it is a flex sibling; `READING_CSS` and the print page sharing it; `attachmentImages`' regex; `epubChapterHtml`'s separate parser; `suggestModePlugin`'s two props; the `BubbleContext` union; `markdown-it` 15.0.1 with no attrs plugin. +Verified by running it at a 1400px viewport against a local dev instance: the float, the wrap and where it ends, the clear at a heading, full bleed's width against the frame and the rail, the toolbar's buttons and pressed state, a click on Align right reaching `/:id.md` as `{width=50% align=right}`, and the print page carrying inline styles rather than literal braces. Verified by reading the source at `aeeaa3f`: the schema, parser, and serializer shapes; the `65ch` cap and the `.tiptap` container; the rail's width and that it is a flex sibling; `READING_CSS` and the print page sharing it; `attachmentImages`' regex; `epubChapterHtml`'s separate parser; `suggestModePlugin`'s two props; the `BubbleContext` union; `markdown-it` 15.0.1 with no attrs plugin. -Not verified: nothing here has been run. +Not verified: no EPUB has been opened in a reading system, so how any of them honours `float` on a block is unknown. The three test files CLAUDE.md names as environment failures (`safe-storage`, `anon-identity`, `use-theme`) still fail here; CLAUDE.md attributes that to Node 26 shipping a global `localStorage`, and this machine runs Node 25.8.1, so that note is one version out of date. Dropbox Paper's behavior was taken from Google's synthesis of Dropbox's blog and community threads rather than from Dropbox's documentation, and one of its two claims was wrong. It reported that Paper does not wrap text around images; Nicholas, who uses Paper, corrected that on 2026-09-13 and the design above reflects the correction. Its other claim from the same source, that Paper has no drag-to-resize handles and uses a preset toolbar, is therefore also unconfirmed. Presets were chosen independently for the one-write-per-click reason given in Decision 2, which does not depend on what Paper does, so the toolbar design stands either way. From 7ec4b08d539ff7b6ade66d62752513ec7ff5015c Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:56:10 -0700 Subject: [PATCH 07/12] Drop image captions, cap floats at a third, take Paper's heading scale (#109) The filename was drawn under every image as a figcaption, which read as a caption the author never wrote. It is gone; `alt` still carries the filename. A floated image now stops at a third of the column, in the editor and in the EPUB and print stylesheets, so the text beside it keeps a readable measure. Dropbox Paper caps its own floats at the same third. Headings take Paper's extended-headings scale: 30px/36px at -0.4px, 24px/30px at -0.2px, 20px/26px, all at weight 600, with Paper's margins. The face stays the system sans. The editor sizes them in em rather than rem because the root here is 14px, which would shrink the whole scale by an eighth. Co-Authored-By: Claude Opus 5 --- app/app.css | 42 ++++++++++--------- app/components/AttachmentView.tsx | 11 ++--- app/shared/epub.ts | 11 +++-- app/shared/image-layout.ts | 4 +- docs/plans/2026-09-13-image-sizing-design.md | 2 +- .../unit/components/attachment-view.test.tsx | 10 +++-- tests/unit/shared/epub.test.ts | 10 +++-- tests/unit/shared/image-layout.test.ts | 14 +++++++ 8 files changed, 64 insertions(+), 40 deletions(-) create mode 100644 tests/unit/shared/image-layout.test.ts diff --git a/app/app.css b/app/app.css index 65283fb9..991d6aad 100644 --- a/app/app.css +++ b/app/app.css @@ -179,7 +179,7 @@ body { margin: 1rem 0 0.5rem; } -/* Attachments: an image with a caption, or a chip that downloads. The +/* Attachments: an image on its own, or a chip that downloads. The selected state matches the code-block chrome. */ .tiptap .attachment { max-width: 65ch; @@ -187,13 +187,16 @@ body { } /* Image layout (#109). The width is an inline style, because a percent can be any value; alignment and full bleed are rules. A left or right aligned - image floats and body text wraps beside it. */ + image floats and body text wraps beside it, capped at a third of the column + so the text keeps a readable measure; Paper caps its own floats at a third. */ .tiptap .attachment[data-align="left"] { float: left; + max-width: 33.333%; margin: 0 1.25rem 0.75rem 0; } .tiptap .attachment[data-align="right"] { float: right; + max-width: 33.333%; margin: 0 0 0.75rem 1.25rem; } .tiptap .attachment[data-align="center"] { @@ -240,6 +243,7 @@ body { .tiptap .attachment[data-align="right"] { float: none; width: 100%; + max-width: 100%; margin: 0 0 1rem; } } @@ -254,13 +258,6 @@ body { height: auto; border-radius: 0.25rem; } -.tiptap .attachment-image figcaption { - display: flex; - gap: 0.75rem; - margin-top: 0.35rem; - font-size: 0.85rem; - color: var(--color-muted); -} .tiptap .attachment-chip { display: inline-flex; max-width: 100%; @@ -293,28 +290,33 @@ body { margin: 0 0 0.5rem; } -/* Rich-node typographic defaults (imported from the notes app) */ +/* Dropbox Paper's extended-headings scale: 30px/36px at -0.4px, 24px/30px at + -0.2px, 20px/26px, all at weight 600, with Paper's own margins. Sized in em + against the editor's 16px body, not rem: the root here is 14px, which would + shrink the whole scale. The type is Paper's; the face stays the system sans. */ .tiptap h1 { - font-size: 1.875rem; - font-weight: bold; + font-size: 1.875em; + font-weight: 600; line-height: 1.2; - margin: 1.5rem 0 1rem; + letter-spacing: -0.013em; + margin: 30px 0 12px; max-width: 65ch; } .tiptap h2 { - font-size: 1.5rem; - font-weight: bold; - line-height: 1.3; - margin: 1.25rem 0 0.75rem; + font-size: 1.5em; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.008em; + margin: 26px 0 10px; max-width: 65ch; } .tiptap h3 { - font-size: 1.25rem; - font-weight: bold; + font-size: 1.25em; + font-weight: 600; line-height: 1.3; - margin: 1rem 0 0.5rem; + margin: 24px 0 8px; max-width: 65ch; } diff --git a/app/components/AttachmentView.tsx b/app/components/AttachmentView.tsx index 1b39468f..48ccee32 100644 --- a/app/components/AttachmentView.tsx +++ b/app/components/AttachmentView.tsx @@ -4,9 +4,10 @@ import { isFullWidth, type ImageAlign } from "~/shared/image-layout"; import Icon from "~/components/Icon"; /** - * How an attachment block looks in the editor: an image with a caption of - * its name and size, or a chip that downloads the file. Selection matches - * the code-block chrome (see .attachment in app.css). + * How an attachment block looks in the editor: an image on its own, or a chip + * that downloads the file. The filename stays as the image's alt text and is + * not drawn under it. Selection matches the code-block chrome (see .attachment + * in app.css). */ export default function AttachmentView({ node, selected }: NodeViewProps) { const { kind, src, alt, width, align } = node.attrs as { @@ -35,10 +36,6 @@ export default function AttachmentView({ node, selected }: NodeViewProps) { {kind === "image" ? (
{alt} -
- {alt} - {size && {size}} -
) : ( diff --git a/app/shared/epub.ts b/app/shared/epub.ts index 11283be8..7035e1b4 100644 --- a/app/shared/epub.ts +++ b/app/shared/epub.ts @@ -127,10 +127,13 @@ export function epubChapterHtml(markdown: string, images: EpubImage[]): string { */ export const READING_CSS = `body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1em; line-height: 1.6; color: #1a1a1a; margin: 0 auto; padding: 1.5em; max-width: 42em; } p { margin: 0 0 0.6em; } -h1, h2, h3, h4 { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-weight: bold; line-height: 1.2; } -h1 { font-size: 1.875em; margin: 1.5em 0 0.6em; } -h2 { font-size: 1.5em; line-height: 1.3; margin: 1.25em 0 0.5em; } -h3, h4 { font-size: 1.25em; line-height: 1.3; margin: 1em 0 0.4em; } +/* Dropbox Paper's extended-headings scale, as ems of the body size: 30px/36px + at -0.4px, 24px/30px at -0.2px, 20px/26px, all at weight 600. The type is + Paper's; the face stays the system sans. */ +h1, h2, h3, h4 { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-weight: 600; } +h1 { font-size: 1.875em; line-height: 1.2; letter-spacing: -0.013em; margin: 1em 0 0.4em; } +h2 { font-size: 1.5em; line-height: 1.25; letter-spacing: -0.008em; margin: 1.08em 0 0.42em; } +h3, h4 { font-size: 1.25em; line-height: 1.3; margin: 1.2em 0 0.4em; } body > h1:first-child { font-size: 2.5em; font-weight: 500; line-height: 1.1; margin-top: 0; } h1:first-child, h2:first-child, h3:first-child { margin-top: 0; } ul, ol { margin: 0 0 0.6em 1.5em; padding: 0; } diff --git a/app/shared/image-layout.ts b/app/shared/image-layout.ts index 22363d43..0c956cbd 100644 --- a/app/shared/image-layout.ts +++ b/app/shared/image-layout.ts @@ -53,7 +53,9 @@ export function imageLayoutStyle(layout: Partial): string { if (layout.width) rules.push(`width: ${layout.width === "full" ? "100%" : layout.width}`); if (layout.align && !isFullWidth(layout)) { if (layout.align === "center") rules.push("display: block", "margin-left: auto", "margin-right: auto"); - else rules.push(`float: ${layout.align}`, `margin-${layout.align === "left" ? "right" : "left"}: 1em`, "margin-bottom: 0.5em"); + // A float wider than a third of the column leaves too narrow a measure + // for the text beside it; Dropbox Paper caps its own floats at the same third. + else rules.push(`float: ${layout.align}`, "max-width: 33.333%", `margin-${layout.align === "left" ? "right" : "left"}: 1em`, "margin-bottom: 0.5em"); } return rules.length ? `${rules.join("; ")};` : ""; } diff --git a/docs/plans/2026-09-13-image-sizing-design.md b/docs/plans/2026-09-13-image-sizing-design.md index a3d8062e..1afc1d54 100644 --- a/docs/plans/2026-09-13-image-sizing-design.md +++ b/docs/plans/2026-09-13-image-sizing-design.md @@ -37,7 +37,7 @@ Sizing is a set of presets rather than a drag handle. Each choice is one `update Paper also groups images dragged onto the same line into a side-by-side gallery, and gives each image an editable caption field. Both are out of scope here. A gallery needs a new container node in `richSchema`, a new markdown form, drag-to-group in the editor, and gallery rules in three stylesheets. Captions need a decision about where the caption text lives in markdown and whether `attachment` can stay `atom: true`. -One thing worth recording while it is visible: `alt` is set to the uploaded filename ([app/lib/useAttachments.ts:104](../../app/lib/useAttachments.ts)) and rendered both as the `` and as the visible figcaption, so the field named `alt` is the caption and no vapor document currently carries alt text. That is a separate issue from this one and is not fixed here. +One thing worth recording while it is visible: `alt` is set to the uploaded filename ([app/lib/useAttachments.ts:104](../../app/lib/useAttachments.ts)). It was also drawn under the image as a figcaption, which made the filename read as a caption; that figcaption is now gone and `alt` is only the ``. No vapor document carries real alt text yet. That is a separate issue from this one and is not fixed here. ## Design diff --git a/tests/unit/components/attachment-view.test.tsx b/tests/unit/components/attachment-view.test.tsx index 90acb532..935e4a57 100644 --- a/tests/unit/components/attachment-view.test.tsx +++ b/tests/unit/components/attachment-view.test.tsx @@ -12,12 +12,14 @@ function view(attrs: Record, selected = false) { } describe("AttachmentView", () => { - it("renders an image with its name and size as a caption", () => { - view({ kind: "image", src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", alt: "cat.png", bytes: 2048 }); + it("renders an image alone, with its name as alt text and no caption under it", () => { + const { container } = view({ kind: "image", src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", alt: "cat.png", bytes: 2048 }); const img = screen.getByRole("img") as HTMLImageElement; expect(img.getAttribute("src")).toBe("/abcd1234/attachments/abcdefghijklmnop/cat.png"); - expect(screen.getByText("cat.png")).toBeTruthy(); - expect(screen.getByText("2 KB")).toBeTruthy(); + expect(img.getAttribute("alt")).toBe("cat.png"); + expect(container.querySelector("figcaption")).toBeNull(); + expect(screen.queryByText("cat.png")).toBeNull(); + expect(screen.queryByText("2 KB")).toBeNull(); }); it("puts width and align on the wrapper so the stylesheet can size it (#109)", () => { diff --git a/tests/unit/shared/epub.test.ts b/tests/unit/shared/epub.test.ts index 85128597..5c022080 100644 --- a/tests/unit/shared/epub.test.ts +++ b/tests/unit/shared/epub.test.ts @@ -98,10 +98,14 @@ describe("EPUB export (#100)", () => { expect(epubFilename("abcd1234", "no heading here")).toBe("abcd1234.epub"); }); - it("sets the page's type: a sans body, bold sans headings stepping down, a larger lighter title, mono code", () => { + it("sets the page's type: Paper's heading scale in the system font, a larger lighter title, mono code", () => { expect(READING_CSS).toMatch(/body \{ font-family: ui-sans-serif, system-ui/); - expect(READING_CSS).toContain("h1 { font-size: 1.875em;"); - expect(READING_CSS).toContain("h2 { font-size: 1.5em;"); + expect(READING_CSS).toContain("h1, h2, h3, h4 { font-family: ui-sans-serif, system-ui"); + // Paper's extended headings, as ems of the body size: 30/36/-0.4px, 24/30/-0.2px, 20/26px, all 600. + expect(READING_CSS).toContain("h1 { font-size: 1.875em; line-height: 1.2; letter-spacing: -0.013em;"); + expect(READING_CSS).toContain("h2 { font-size: 1.5em; line-height: 1.25; letter-spacing: -0.008em;"); + expect(READING_CSS).toContain("h3, h4 { font-size: 1.25em; line-height: 1.3;"); + expect(READING_CSS).toMatch(/^h1, h2, h3, h4 \{[^}]*font-weight: 600;/m); expect(READING_CSS).toContain("body > h1:first-child { font-size: 2.5em; font-weight: 500;"); expect(READING_CSS).toMatch(/code, pre, kbd \{ font-family: "IBM Plex Mono"/); expect(READING_CSS).not.toContain("Georgia"); diff --git a/tests/unit/shared/image-layout.test.ts b/tests/unit/shared/image-layout.test.ts new file mode 100644 index 00000000..7d0a4989 --- /dev/null +++ b/tests/unit/shared/image-layout.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { imageLayoutStyle } from "~/shared/image-layout"; + +describe("imageLayoutStyle (#109)", () => { + it("caps a floated image at a third of the column so text still has a measure", () => { + expect(imageLayoutStyle({ width: "50%", align: "left" })).toContain("max-width: 33.333%"); + expect(imageLayoutStyle({ width: "50%", align: "right" })).toContain("max-width: 33.333%"); + }); + + it("leaves an unfloated image uncapped", () => { + expect(imageLayoutStyle({ width: "50%", align: "center" })).not.toContain("max-width"); + expect(imageLayoutStyle({ width: "full" })).not.toContain("max-width"); + }); +}); From b93cf422aa24ed821cc7733e438defc395833203 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:11:20 -0700 Subject: [PATCH 08/12] Drop the width presets: an image is its natural size unless told otherwise (#109) The bubble offered 25, 50, 75 and 100 percent. It now offers full bleed and the three alignments, nothing else. An image with no `width` in its markdown renders at its natural size, capped by the column; a left or right aligned one is capped at a third of it. Full bleed becomes a toggle, and aligning a full-bleed image clears the width rather than stepping it to 50%. A centred attachment shrinks to its image, or the auto margins have nothing to centre: the wrapper is a block and was filling the column. Co-Authored-By: Claude Opus 5 --- app/app.css | 3 + app/components/BubbleToolbar.tsx | 34 +++----- docs/markdown-and-criticmarkup.md | 4 +- docs/plans/2026-09-13-image-sizing-design.md | 13 +++ .../components/image-layout-buttons.test.tsx | 82 +++++++++++++++++++ 5 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 tests/unit/components/image-layout-buttons.test.tsx diff --git a/app/app.css b/app/app.css index 991d6aad..58c13201 100644 --- a/app/app.css +++ b/app/app.css @@ -199,7 +199,10 @@ body { max-width: 33.333%; margin: 0 0 0.75rem 1.25rem; } +/* Shrink to the image so the auto margins have something to centre; an + explicit inline width overrides this. */ .tiptap .attachment[data-align="center"] { + width: fit-content; margin-inline: auto; } /* The editor is a centred max-w-3xl column inside .doc-frame, so full bleed diff --git a/app/components/BubbleToolbar.tsx b/app/components/BubbleToolbar.tsx index b1de5be9..223bf91c 100644 --- a/app/components/BubbleToolbar.tsx +++ b/app/components/BubbleToolbar.tsx @@ -122,19 +122,18 @@ const btnClass = const menuClass = "bubble-menu flex bg-ink shadow-md"; -// Narrower than the 44px square buttons: the width row is five cells wide and -// a mis-tap here is one undo, not a lost suggestion. +// Narrower than the 44px square buttons: a mis-tap here is one undo, not a +// lost suggestion. const stepClass = "flex h-[44px] min-w-[40px] items-center justify-center px-2 text-[13px] text-paper transition-colors hover:bg-paper/15 cursor-pointer"; -const WIDTHS = ["25%", "50%", "75%", "100%"] as const; const ALIGNS: { value: ImageAlign; icon: string; label: string }[] = [ { value: "left", icon: "format_image_left", label: "Align left" }, { value: "center", icon: "format_align_center", label: "Align centre" }, { value: "right", icon: "format_image_right", label: "Align right" }, ]; -function ImageLayoutButtons({ editor }: { editor: TiptapEditor }) { +export function ImageLayoutButtons({ editor }: { editor: TiptapEditor }) { // BubbleMenu renders its children into a portal and does not re-render them // per transaction, so the pressed state has to subscribe to the editor. const { width, align } = useEditorState({ @@ -149,23 +148,16 @@ function ImageLayoutButtons({ editor }: { editor: TiptapEditor }) { }); const on = (active: boolean) => `${stepClass}${active ? " bg-paper/25" : ""}`; + // An image is its natural size unless the markdown carries a width, so the + // bubble sets no percentages: it toggles full bleed and picks an alignment. + const full = isFullWidth({ width }); + return ( <> - {WIDTHS.map((w) => ( - - ))} } /> diff --git a/app/root.tsx b/app/root.tsx index 4dd83d5e..f9982553 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -50,7 +50,7 @@ export const links: Route.LinksFunction = () => [ // Subset to the icon names actually used — keep this list sorted and in // sync with usages or new glyphs render as raw text. rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=add,add_2,add_comment,alternate_email,attach_file,check,checklist,chevron_left,chevron_right,close,code,comment,computer,content_copy,dark_mode,delete,done_all,download,edit,expand_more,format_align_center,format_bold,format_h1,format_h2,format_h3,format_image_left,format_image_right,format_italic,format_list_bulleted,format_list_numbered,format_paragraph,format_quote,format_size,history,horizontal_rule,ios_share,light_mode,link,login,logout,menu_book,mobile,mode_comment,more_vert,note_add,open_in_new,rate_review,redo,remove_done,robot_2,send,strikethrough_s,table,undo,upload_file,width_full&display=block", + href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=add,add_2,add_comment,alternate_email,attach_file,check,checklist,chevron_left,chevron_right,close,code,computer,content_copy,dark_mode,delete,done_all,download,edit,expand_more,format_align_center,format_bold,format_h1,format_h2,format_h3,format_image_left,format_image_right,format_italic,format_list_bulleted,format_list_numbered,format_paragraph,format_quote,format_size,history,horizontal_rule,ios_share,light_mode,link,login,logout,menu,menu_book,mobile,mode_comment,more_vert,note_add,open_in_new,rate_review,redo,remove_done,robot_2,send,strikethrough_s,table,undo,upload_file,width_full&display=block", }, ]; diff --git a/tests/unit/components/header-menu.test.tsx b/tests/unit/components/header-menu.test.tsx index e5868678..a294910a 100644 --- a/tests/unit/components/header-menu.test.tsx +++ b/tests/unit/components/header-menu.test.tsx @@ -69,6 +69,14 @@ describe("HeaderMenu", () => { expect(screen.queryByText("Sign in")).toBeNull(); }); + it("trigger is the three-line menu glyph in plain Edit, and the mode's glyph otherwise", () => { + const { unmount } = renderWithDocument(createElement(HeaderMenu)); + expect(screen.getByLabelText("Menu").textContent?.trim()).toBe("menu"); + unmount(); + renderWithDocument(createElement(HeaderMenu), { context: { mode: "suggest" } }); + expect(screen.getByLabelText("Menu").textContent?.trim()).toBe("rate_review"); + }); + it("trigger shows the mode when it isn't plain Edit", () => { renderWithDocument(createElement(HeaderMenu), { context: { mode: "suggest" } }); expect(screen.getByLabelText("Menu").getAttribute("title")).toBe("Suggest"); From 760bf6dd30521a83d36ba471c3ba48dcf93cb04f Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:18:23 -0700 Subject: [PATCH 11/12] Triple the typing speed of agents natural goes from 180-320ms between chunks to 60-107ms, and its sentence pause from 300-900ms to 100-300ms; fast goes from 20-40ms to 7-13ms. Chunk sizes are unchanged, so the granularity reads the same and only the rate moves: natural measures about 36 chars/s against 12 before. The doc comment no longer calls natural "a brisk human typist". 36 chars/s is roughly 430 words a minute, and the MCP `pace` description now says what the setting does rather than claiming it is human-paced. The tests were named for a 30-80ms natural and a 10-20ms fast that the code never ran at; their assertions were loose enough to pass either way. They now pin both ends of both ranges. Co-Authored-By: Claude Opus 5 --- agents/mcp-tools.ts | 2 +- app/lib/performance-chunks.ts | 15 +++++---- tests/unit/lib/performance-chunks.test.ts | 40 ++++++++++++++--------- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/agents/mcp-tools.ts b/agents/mcp-tools.ts index e6e763d2..3485320d 100644 --- a/agents/mcp-tools.ts +++ b/agents/mcp-tools.ts @@ -245,7 +245,7 @@ const docId = z.string().describe("The 8-character document id (from its URL).") const pace = z .enum(["natural", "fast", "instant"]) .optional() - .describe("How the edit is performed: natural (human-paced typing), fast, or instant."); + .describe("How the edit is performed: natural (typed out, with pauses at sentence ends), fast (typed out, no pauses), or instant."); const anchorDesc = "A block anchor from read_document, e.g. k3f0a9x2-a91f0c2d: a persistent block id plus the block's content hash. The id survives edits; a changed hash returns stale_block with the block's current state so you can retry without a full re-read."; diff --git a/app/lib/performance-chunks.ts b/app/lib/performance-chunks.ts index 82c9d2b2..4f2276c0 100644 --- a/app/lib/performance-chunks.ts +++ b/app/lib/performance-chunks.ts @@ -11,10 +11,13 @@ const SENTENCE_ENDINGS = new Set([".", "!", "?", "\n"]); * the delay before the *next* chunk — used to simulate an agent typing into * the document instead of pasting it in one shot. * - * - `"natural"`: 2-4 chars/tick, 180-320ms base delay (~10 chars/s, a - * brisk human typist); an extra 300-900ms pause is added after a tick - * ending in ".", "!", "?", or "\n". - * - `"fast"`: 6-12 chars/tick, 20-40ms delay; no sentence pauses. + * - `"natural"`: 2-4 chars/tick, 60-107ms base delay (~36 chars/s); an extra + * 100-300ms pause is added after a tick ending in ".", "!", "?", or "\n". + * - `"fast"`: 6-12 chars/tick, 7-13ms delay; no sentence pauses. + * + * Both paces were tripled on 2026-09-13 at Nicholas's request. "natural" is + * named for its shape, not its rate: 36 chars/s is roughly 430 words a + * minute, well past any typist. * * `rng` defaults to `Math.random` and is injectable so tests can produce * deterministic output (e.g. `() => 0.5`). @@ -27,7 +30,7 @@ export function chunkTyping( const ticks: TypingTick[] = []; const [minChars, maxChars, minDelay, maxDelay] = - pace === "fast" ? [6, 12, 20, 40] : [2, 4, 180, 320]; + pace === "fast" ? [6, 12, 7, 13] : [2, 4, 60, 107]; let cursor = 0; // Carried from a sentence-ending chunk onto the delay of the *next* @@ -55,7 +58,7 @@ export function chunkTyping( const delayMs = minDelay + Math.floor(rng() * (maxDelay - minDelay + 1)) + extraDelayForNext; extraDelayForNext = 0; if (pace === "natural" && SENTENCE_ENDINGS.has(chunk[chunk.length - 1])) { - extraDelayForNext = 300 + Math.floor(rng() * 601); + extraDelayForNext = 100 + Math.floor(rng() * 201); } ticks.push({ chunk, delayMs }); diff --git a/tests/unit/lib/performance-chunks.test.ts b/tests/unit/lib/performance-chunks.test.ts index 8004e142..a87e101e 100644 --- a/tests/unit/lib/performance-chunks.test.ts +++ b/tests/unit/lib/performance-chunks.test.ts @@ -7,10 +7,12 @@ describe("chunkTyping", () => { expect(ticks.map((t) => t.chunk).join("")).toBe("Hello world. Bye."); }); - it("pauses after sentence ends", () => { + it("pauses after sentence ends, by more than a whole tick", () => { const ticks = chunkTyping("Hi. Yo", "natural", () => 0.5); const afterDot = ticks.find((t) => t.chunk.startsWith(" Yo") || t.chunk.startsWith("Yo")); - expect(afterDot!.delayMs).toBeGreaterThanOrEqual(300); + // The base delay at rng 0.5 is 84ms; the pause adds 100-300 on top. + expect(afterDot!.delayMs).toBeGreaterThanOrEqual(184); + expect(afterDot!.delayMs).toBeLessThanOrEqual(407); }); it("fast pace uses bigger chunks", () => { @@ -22,25 +24,33 @@ describe("chunkTyping", () => { expect(chunkTyping("", "natural", () => 0.5)).toEqual([]); }); - it("natural pace ticks fall within the 2-6 char, 30-80ms base range", () => { - const ticks = chunkTyping("abcdefghij", "natural", () => 0); - for (const tick of ticks) { - expect(tick.chunk.length).toBeGreaterThanOrEqual(1); - expect(tick.chunk.length).toBeLessThanOrEqual(6); - expect(tick.delayMs).toBeGreaterThanOrEqual(30); + it("natural pace ticks fall within the 2-4 char, 60-107ms base range", () => { + for (const [rng, chars, delay] of [[0, 2, 60], [0.999, 4, 107]] as const) { + for (const tick of chunkTyping("abcdefghij", "natural", () => rng)) { + expect(tick.chunk.length).toBeLessThanOrEqual(chars); + expect(tick.delayMs).toBe(delay); + } } }); - it("fast pace ticks fall within the 8-16 char, 10-20ms base range", () => { - const ticks = chunkTyping("abcdefghijklmnopqrstuvwxyz", "fast", () => 0); - for (const tick of ticks) { - expect(tick.chunk.length).toBeGreaterThanOrEqual(1); - expect(tick.chunk.length).toBeLessThanOrEqual(16); - expect(tick.delayMs).toBeGreaterThanOrEqual(10); - expect(tick.delayMs).toBeLessThanOrEqual(20); + it("fast pace ticks fall within the 6-12 char, 7-13ms base range", () => { + for (const [rng, chars, delay] of [[0, 6, 7], [0.999, 12, 13]] as const) { + for (const tick of chunkTyping("abcdefghijklmnopqrstuvwxyz", "fast", () => rng)) { + expect(tick.chunk.length).toBeLessThanOrEqual(chars); + expect(tick.delayMs).toBe(delay); + } } }); + it("types about three characters per 100ms at natural pace", () => { + const text = "abcdefghij".repeat(30); + const ticks = chunkTyping(text, "natural", () => 0.5); + const totalMs = ticks.reduce((sum, t) => sum + t.delayMs, 0); + // 300 characters at 3 per 84ms is 8.4 seconds; there are no sentence + // endings in the text, so nothing is added on top. + expect(Math.round((text.length / totalMs) * 1000)).toBeGreaterThanOrEqual(30); + }); + it("is deterministic for a fixed rng", () => { const a = chunkTyping("Hello world. Bye.", "natural", () => 0.5); const b = chunkTyping("Hello world. Bye.", "natural", () => 0.5); From 210537507c9a74f1101334f31011d5049f20fe76 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:26:00 -0700 Subject: [PATCH 12/12] Resolving a comment defocuses it, so the card leaves the rail (#111) Both filters that hide resolved threads exempt the active one, and resolveThread left activeThreadId pointing at the thread it had just resolved, so the card sat there until something else was clicked. It now clears the selection on resolve, which deleteThread already did twenty-seven lines further down the same file. This is option 1 from #111: the rail's "Show resolved (n)" is the only undo path, and the mobile sheet still has no such control. Reopening is unchanged and leaves the selection alone, since the card is already in view under Show resolved. Closes #111 Co-Authored-By: Claude Opus 5 --- app/lib/useThreads.ts | 6 +- tests/unit/lib/resolve-thread.test.tsx | 79 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/unit/lib/resolve-thread.test.tsx diff --git a/app/lib/useThreads.ts b/app/lib/useThreads.ts index 7688d3c7..c13e5664 100644 --- a/app/lib/useThreads.ts +++ b/app/lib/useThreads.ts @@ -317,9 +317,13 @@ export function useThreads({ const thread: ThreadData = JSON.parse(raw); if (!thread.resolved) { - // Resolving: remove inline markup, mark resolved + // Resolving: remove inline markup, mark resolved, and drop the + // selection. The rail and the sheet both keep the active thread + // visible whatever its state, so without this the card sits there + // until something else is clicked (#111). removeInlineComment(thread); thread.resolved = true; + setActiveThreadId((prev) => (prev === threadId ? null : prev)); } else { // Unresolving: just toggle back (markup already removed) thread.resolved = false; diff --git a/tests/unit/lib/resolve-thread.test.tsx b/tests/unit/lib/resolve-thread.test.tsx new file mode 100644 index 00000000..f07b25a7 --- /dev/null +++ b/tests/unit/lib/resolve-thread.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +/** + * Resolving a thread defocuses it, so the card leaves the rail and the sheet + * instead of sitting there until something else is clicked (#111). + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import * as Y from "yjs"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { CriticAddition, CriticDeletion, CriticComment, CriticHighlight } from "~/lib/critic-marks"; +import { useThreads } from "~/lib/useThreads"; + +const user = { id: "u-jon", name: "Jon Wiley", color: "#BA68C8", colorLight: "#E1BEE7" }; + +function setup(comments: string[]) { + vi.useFakeTimers(); + const doc = new Y.Doc(); + const editor = new Editor({ + extensions: [StarterKit.configure({ undoRedo: false }), CriticAddition, CriticDeletion, CriticComment, CriticHighlight], + content: "

Hello there

", + }); + const hook = renderHook(() => useThreads({ doc, editor, user })); + for (const text of comments) { + act(() => { + editor + .chain() + .command(({ tr }) => { + tr.insertText(text, 6); + tr.addMark(6, 6 + text.length, editor.schema.marks.criticComment.create()); + return true; + }) + .run(); + }); + act(() => { + vi.advanceTimersByTime(3000); + }); + } + return { editor, ...hook }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("resolveThread (#111)", () => { + it("defocuses the thread it resolves", () => { + const { result, editor } = setup(["needs work"]); + const id = result.current.threads[0].id; + act(() => result.current.setActiveThreadId(id)); + expect(result.current.activeThreadId).toBe(id); + + act(() => result.current.resolveThread(id)); + expect(result.current.threads[0].resolved).toBe(true); + expect(result.current.activeThreadId).toBe(null); + editor.destroy(); + }); + + it("leaves the selection alone when a different thread is resolved", () => { + const { result, editor } = setup(["second one", "first one"]); + const [a, b] = result.current.threads.map((t) => t.id); + act(() => result.current.setActiveThreadId(a)); + + act(() => result.current.resolveThread(b)); + expect(result.current.activeThreadId).toBe(a); + editor.destroy(); + }); + + it("reopening does not change the selection: the card is already in view under Show resolved", () => { + const { result, editor } = setup(["needs work"]); + const id = result.current.threads[0].id; + act(() => result.current.resolveThread(id)); + + act(() => result.current.resolveThread(id)); + expect(result.current.threads[0].resolved).toBe(false); + expect(result.current.activeThreadId).toBe(null); + editor.destroy(); + }); +});