setLinkOpen(false)} />
+
e.stopPropagation()}>
+ setLinkValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ applyLink();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ setLinkOpen(false);
+ }
+ }}
+ />
+
+ {editor.isActive("link") && (
+
+ )}
+
+ >
+ )}
+
{tool(false, () => editor.chain().focus().setHorizontalRule().run(), "Scene break",
)}
{tool(false, () => fileRef.current?.click(), "Insert image",
)}
(node.type.name === "heading" ? "" : "Begin your chapter…"),
diff --git a/src/export/epub.ts b/src/export/epub.ts
index eacdbbf..34e96e4 100644
--- a/src/export/epub.ts
+++ b/src/export/epub.ts
@@ -1,6 +1,6 @@
import type { JSONContent } from "@tiptap/core";
import { invoke } from "@tauri-apps/api/core";
-import type { Book } from "../model/book";
+import { type Book, TRIM_DIMS } from "../model/book";
export interface EpubFile {
path: string;
@@ -41,9 +41,11 @@ function mediaType(path: string): string {
function inline(node: JSONContent): string {
if (node.type === "text") {
let text = esc(node.text ?? "");
- const marks = (node.marks ?? []).map((m) => m.type);
- if (marks.includes("bold")) text = `
${text}`;
- if (marks.includes("italic")) text = `
${text}`;
+ const marks = node.marks ?? [];
+ if (marks.some((m) => m.type === "bold")) text = `
${text}`;
+ if (marks.some((m) => m.type === "italic")) text = `
${text}`;
+ const href = marks.find((m) => m.type === "link")?.attrs?.href;
+ if (href) text = `
${text}`;
return text;
}
if (node.type === "hardBreak") return "
";
@@ -103,6 +105,88 @@ function chapterBody(content: JSONContent, paths: Map
): string {
.join("\n");
}
+function wrapTitle(title: string, max = 16): string[] {
+ const words = title.split(/\s+/).filter(Boolean);
+ const lines: string[] = [];
+ let line = "";
+ for (const word of words) {
+ if (line && (line + " " + word).length > max) {
+ lines.push(line);
+ line = word;
+ } else {
+ line = line ? line + " " + word : word;
+ }
+ }
+ if (line) lines.push(line);
+ return lines.length ? lines.slice(0, 4) : ["Untitled"];
+}
+
+function coverSvg(book: Book): string {
+ const c = book.cover;
+ const m = book.metadata;
+ const dims = TRIM_DIMS[book.settings.trim];
+ const W = 1200;
+ const H = Math.round((W * dims.h) / dims.w);
+
+ const lines = wrapTitle(m.title || "Untitled");
+ const titleSize = lines.length > 2 ? 88 : 104;
+ const lineH = titleSize * 1.12;
+ const blockMid = H * 0.4;
+ const firstY = Math.round(blockMid - ((lines.length - 1) * lineH) / 2);
+ const titleSpans = lines
+ .map((l, i) => `${esc(l)}`)
+ .join("");
+ const ruleY = firstY + Math.round((lines.length - 1) * lineH + 84);
+ const rule = ``;
+ const subtitle = m.subtitle
+ ? `${esc(m.subtitle)}`
+ : "";
+ const author = m.author
+ ? `${esc(m.author.toUpperCase())}`
+ : "";
+
+ return `
+`;
+}
+
+function buildCover(book: Book): { asset: EpubFile; href: string; mediaType: string } {
+ const c = book.cover;
+ if (c.kind === "image" && c.image.startsWith("data:")) {
+ const href = `assets/cover.${imageExtension(c.image)}`;
+ return {
+ asset: { path: `OEBPS/${href}`, data: c.image.slice(c.image.indexOf(",") + 1), encoding: "base64" },
+ href,
+ mediaType: mediaType(href),
+ };
+ }
+ const href = "assets/cover.svg";
+ return {
+ asset: { path: `OEBPS/${href}`, data: coverSvg(book), encoding: "utf8" },
+ href,
+ mediaType: "image/svg+xml",
+ };
+}
+
+function coverXhtml(book: Book, href: string): string {
+ const title = book.metadata.title || "Cover";
+ return `
+
+
+ ${esc(title)}
+
+
+
+
+
+`;
+}
+
function extractFigures(book: Book): { assets: EpubFile[]; paths: Map } {
const paths = new Map();
const assets: EpubFile[] = [];
@@ -142,9 +226,16 @@ function containerXml(): string {
`;
}
-function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): string {
+function contentOpf(
+ book: Book,
+ chapterIds: string[],
+ assetPaths: string[],
+ cover: { href: string; mediaType: string },
+): string {
const meta = book.metadata;
const manifest: string[] = [
+ ` `,
+ ` `,
` `,
` `,
` `,
@@ -161,7 +252,7 @@ function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): str
);
});
- const spine = [``]
+ const spine = [``, ``]
.concat(chapterIds.map((_, i) => ``))
.join("\n ");
@@ -172,6 +263,7 @@ function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): str
${esc(meta.title || "Untitled")}
${esc(meta.author || "")}
${esc(meta.language || "en")}
+
2026-01-01T00:00:00Z
@@ -245,6 +337,18 @@ html {
font-family: "Literata", Georgia, serif;
}
+.cover {
+ margin: 0;
+ padding: 0;
+ text-align: center;
+ page-break-after: always;
+}
+
+.cover img {
+ max-width: 100%;
+ max-height: 100vh;
+}
+
body {
margin: 0 5%;
line-height: 1.5;
@@ -366,15 +470,22 @@ figure.placement-float-top {
export function bookToEpub(book: Book): EpubFile[] {
const { assets, paths } = extractFigures(book);
+ const cover = buildCover(book);
const chapterIds = book.chapters.map((c) => c.id);
const assetPaths = Array.from(paths.values());
const files: EpubFile[] = [
{ path: "mimetype", data: "application/epub+zip", encoding: "utf8" },
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
- { path: "OEBPS/content.opf", data: contentOpf(book, chapterIds, assetPaths), encoding: "utf8" },
+ {
+ path: "OEBPS/content.opf",
+ data: contentOpf(book, chapterIds, assetPaths, cover),
+ encoding: "utf8",
+ },
+ { path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" },
+ cover.asset,
];
book.chapters.forEach((_, i) => {
diff --git a/src/export/typst.ts b/src/export/typst.ts
index 6649927..12c9993 100644
--- a/src/export/typst.ts
+++ b/src/export/typst.ts
@@ -28,9 +28,11 @@ function str(value: string): string {
function inline(node: JSONContent): string {
if (node.type === "text") {
let text = esc(node.text ?? "");
- const marks = (node.marks ?? []).map((m) => m.type);
- if (marks.includes("italic")) text = `#emph[${text}]`;
- if (marks.includes("bold")) text = `#strong[${text}]`;
+ const marks = node.marks ?? [];
+ if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`;
+ if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`;
+ const href = marks.find((m) => m.type === "link")?.attrs?.href;
+ if (href) text = `#link(${str(href)})[${text}]`;
return text;
}
if (node.type === "hardBreak") return "#linebreak()";
@@ -143,6 +145,41 @@ function imageExtension(dataUrl: string): string {
return kind === "jpeg" ? "jpg" : kind;
}
+function coverBlock(book: Book, coverPath?: string): string {
+ const c = book.cover;
+ const meta = book.metadata;
+
+ if (c.kind === "image" && coverPath) {
+ return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
+ }
+
+ const subtitle = meta.subtitle
+ ? `#v(0.55em)\n #text(font: "Literata", size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
+ : "";
+ const author = meta.author
+ ? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
+ : "#v(1fr)";
+
+ return `#page(margin: 0pt, numbering: none, fill: rgb(${str(c.bg)}))[
+ #set align(center)
+ #set text(fill: rgb(${str(c.ink)}))
+ #v(1fr)
+ #text(font: "Literata", size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
+ #v(0.5em)
+ #line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
+ ${subtitle}
+ ${author}
+]`;
+}
+
+function pushCoverImage(book: Book, images: ImageInput[]): string | undefined {
+ const c = book.cover;
+ if (c.kind !== "image" || !c.image.startsWith("data:")) return undefined;
+ const path = `assets/cover.${imageExtension(c.image)}`;
+ images.push({ path, data: c.image.slice(c.image.indexOf(",") + 1) });
+ return path;
+}
+
function collectImages(contents: JSONContent[]): { images: ImageInput[]; paths: Map } {
const paths = new Map();
const images: ImageInput[] = [];
@@ -167,8 +204,9 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map chapter.content));
}
-export function bookToTypst(book: Book, paths: Map = new Map()): string {
+export function bookToTypst(book: Book, paths: Map = new Map(), coverPath?: string): string {
const meta = book.metadata;
+ const cover = `${coverBlock(book, coverPath)}\n#pagebreak(weak: true)`;
const front = `#set page(numbering: none)
#titlepage(${str(meta.title || "Untitled")}, ${str(meta.subtitle)}, ${str(meta.author)})
#pagebreak(weak: true)
@@ -183,12 +221,19 @@ export function bookToTypst(book: Book, paths: Map = new Map()):
})
.join("\n\n");
- return `${preamble(book)}\n\n${front}\n\n${body}\n`;
+ return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
}
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
const { images, paths } = extractImages(book);
- return { source: bookToTypst(book, paths), images };
+ const coverPath = pushCoverImage(book, images);
+ return { source: bookToTypst(book, paths, coverPath), images };
+}
+
+export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
+ const images: ImageInput[] = [];
+ const coverPath = pushCoverImage(book, images);
+ return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images };
}
function chapterToTypst(book: Book, index: number, paths: Map): string {
diff --git a/src/import/epub.ts b/src/import/epub.ts
index b587468..4059033 100644
--- a/src/import/epub.ts
+++ b/src/import/epub.ts
@@ -1,7 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import type { JSONContent } from "@tiptap/core";
-import type { Book, BookMetadata, Chapter } from "../model/book";
+import { type Book, type BookMetadata, type Chapter, createCover } from "../model/book";
export interface RawFile {
path: string;
@@ -404,6 +404,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
metadata,
theme: "quiet-press",
settings: { trim: "6x9", bleed: true },
+ cover: createCover(),
chapters,
};
}
diff --git a/src/model/book.ts b/src/model/book.ts
index bc0afba..622b6a2 100644
--- a/src/model/book.ts
+++ b/src/model/book.ts
@@ -16,6 +16,42 @@ export interface BookSettings {
bleed: boolean;
}
+export type CoverKind = "default" | "image";
+
+export interface Cover {
+ kind: CoverKind;
+ image: string;
+ bg: string;
+ ink: string;
+}
+
+export interface CoverPalette {
+ id: string;
+ label: string;
+ bg: string;
+ ink: string;
+}
+
+export const COVER_PALETTES: CoverPalette[] = [
+ { id: "paper", label: "Paper", bg: "#f4efe3", ink: "#23201b" },
+ { id: "ink", label: "Ink", bg: "#23201b", ink: "#f3ecdd" },
+ { id: "forest", label: "Forest", bg: "#2c382f", ink: "#eae3d2" },
+ { id: "oxblood", label: "Oxblood", bg: "#4a2327", ink: "#f0e4d8" },
+ { id: "navy", label: "Navy", bg: "#222e3e", ink: "#e9e3d4" },
+];
+
+export const TRIM_DIMS: Record = {
+ "6x9": { w: 6, h: 9 },
+ "5.5x8.5": { w: 5.5, h: 8.5 },
+ "5x8": { w: 5, h: 8 },
+ a5: { w: 148, h: 210 },
+};
+
+export function trimRatio(trim: TrimSize): number {
+ const { w, h } = TRIM_DIMS[trim];
+ return w / h;
+}
+
export interface Chapter {
id: string;
title: string;
@@ -28,6 +64,7 @@ export interface Book {
metadata: BookMetadata;
theme: string;
settings: BookSettings;
+ cover: Cover;
chapters: Chapter[];
}
@@ -43,6 +80,14 @@ export function createChapter(title = "Untitled chapter"): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc() };
}
+export function createCover(): Cover {
+ return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
+}
+
+export function normalizeBook(book: Book): Book {
+ return { ...book, cover: book.cover ? { ...createCover(), ...book.cover } : createCover() };
+}
+
export function createBook(): Book {
return {
schema: "margin/1",
@@ -50,6 +95,7 @@ export function createBook(): Book {
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" },
theme: "quiet-press",
settings: { trim: "6x9", bleed: true },
+ cover: createCover(),
chapters: [createChapter("Chapter One")],
};
}
diff --git a/src/store/useBook.ts b/src/store/useBook.ts
index 695d3e2..3cac551 100644
--- a/src/store/useBook.ts
+++ b/src/store/useBook.ts
@@ -1,6 +1,15 @@
import { create } from "zustand";
import type { JSONContent } from "@tiptap/core";
-import { type Book, type BookMetadata, type BookSettings, createChapter } from "../model/book";
+import {
+ type Book,
+ type BookMetadata,
+ type BookSettings,
+ type Cover,
+ createChapter,
+ normalizeBook,
+} from "../model/book";
+
+export const COVER_ID = "__cover__";
interface BookState {
book: Book | null;
@@ -12,8 +21,10 @@ interface BookState {
setChapterContent: (id: string, content: JSONContent) => void;
setChapterTitle: (id: string, title: string) => void;
addChapter: () => void;
+ moveChapter: (from: number, to: number) => void;
setMetadata: (patch: Partial) => void;
setSettings: (patch: Partial) => void;
+ setCover: (patch: Partial) => void;
markSaved: () => void;
}
@@ -21,7 +32,10 @@ export const useBook = create((set) => ({
book: null,
activeChapterId: "",
dirty: false,
- openBook: (book) => set({ book, activeChapterId: book.chapters[0]?.id ?? "", dirty: false }),
+ openBook: (book) => {
+ const normalized = normalizeBook(book);
+ set({ book: normalized, activeChapterId: normalized.chapters[0]?.id ?? "", dirty: false });
+ },
closeBook: () => set({ book: null, activeChapterId: "", dirty: false }),
setActiveChapter: (id) => set({ activeChapterId: id }),
setChapterContent: (id, content) =>
@@ -42,9 +56,20 @@ export const useBook = create((set) => ({
const chapter = createChapter();
return { activeChapterId: chapter.id, dirty: true, book: { ...s.book, chapters: [...s.book.chapters, chapter] } };
}),
+ moveChapter: (from, to) =>
+ set((s) => {
+ if (!s.book || from === to) return {};
+ const chapters = [...s.book.chapters];
+ if (from < 0 || from >= chapters.length || to < 0 || to >= chapters.length) return {};
+ const [moved] = chapters.splice(from, 1);
+ chapters.splice(to, 0, moved);
+ return { dirty: true, book: { ...s.book, chapters } };
+ }),
setMetadata: (patch) =>
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
setSettings: (patch) =>
set((s) => (s.book ? { dirty: true, book: { ...s.book, settings: { ...s.book.settings, ...patch } } } : {})),
+ setCover: (patch) =>
+ set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
markSaved: () => set({ dirty: false }),
}));
diff --git a/src/styles/app.css b/src/styles/app.css
index 3191ebf..61a0fa9 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -193,6 +193,49 @@ button {
color: var(--accent);
}
+.chapter .grip {
+ display: grid;
+ place-items: center;
+ align-self: center;
+ margin: 0 -5px 0 -6px;
+ color: var(--ink-faint);
+ opacity: 0;
+ cursor: grab;
+ transition: opacity 120ms var(--ease);
+}
+
+.chapter:hover .grip,
+.chapter[data-active="true"] .grip {
+ opacity: 0.55;
+}
+
+.chapter .grip:active {
+ cursor: grabbing;
+}
+
+.chapter[data-dragging="true"] {
+ opacity: 0.4;
+}
+
+.chapter[data-drop-before="true"]::after,
+.chapter[data-drop-after="true"]::after {
+ content: "";
+ position: absolute;
+ left: 10px;
+ right: 10px;
+ height: 2px;
+ background: var(--accent);
+ border-radius: 2px;
+}
+
+.chapter[data-drop-before="true"]::after {
+ top: -1px;
+}
+
+.chapter[data-drop-after="true"]::after {
+ bottom: -1px;
+}
+
.add-chapter {
margin: 8px 18px 4px;
padding: 9px 12px;
@@ -277,6 +320,14 @@ button {
font-weight: 500;
}
+.prose a {
+ color: var(--accent);
+ text-decoration: underline;
+ text-decoration-color: var(--line-strong);
+ text-underline-offset: 2px;
+ cursor: pointer;
+}
+
.prose blockquote {
margin: 1.7rem 0;
padding-left: 1.2rem;
@@ -528,6 +579,80 @@ button {
margin: 0 5px;
}
+.tool-wrap {
+ position: relative;
+ display: inline-flex;
+}
+
+.link-pop-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 6;
+}
+
+.link-pop {
+ position: absolute;
+ bottom: calc(100% + 10px);
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 7;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 5px;
+ background: var(--glass);
+ -webkit-backdrop-filter: blur(10px);
+ backdrop-filter: blur(10px);
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ box-shadow: var(--shadow-pop);
+}
+
+.link-input {
+ width: 220px;
+ height: 28px;
+ padding: 0 12px;
+ border: none;
+ background: transparent;
+ color: var(--ink);
+ font-family: var(--font-ui);
+ font-size: var(--t-2);
+}
+
+.link-input::placeholder {
+ color: var(--ink-faint);
+}
+
+.link-btn {
+ height: 28px;
+ padding: 0 12px;
+ display: grid;
+ place-items: center;
+ border-radius: 999px;
+ background: var(--accent);
+ color: var(--accent-contrast);
+ font-family: var(--font-ui);
+ font-size: var(--t-2);
+ font-weight: 600;
+ transition: opacity 120ms var(--ease);
+}
+
+.link-btn:hover {
+ opacity: 0.85;
+}
+
+.link-btn.ghost {
+ padding: 0 8px;
+ background: transparent;
+ color: var(--ink-soft);
+}
+
+.link-btn.ghost:hover {
+ background: var(--accent-wash);
+ color: var(--ink);
+ opacity: 1;
+}
+
.figure {
margin: 1.8rem 0;
}
@@ -940,3 +1065,238 @@ button {
.btn-primary:hover {
background: var(--accent-ink);
}
+
+/* Cover */
+.cover-item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ margin: 2px 10px 4px;
+ padding: 8px 12px;
+ border-radius: var(--r-sm);
+ color: var(--ink-soft);
+ font-family: var(--font-ui);
+ font-size: var(--t-3);
+ cursor: pointer;
+ transition: background 120ms var(--ease), color 120ms var(--ease);
+}
+
+.cover-item .title {
+ font-weight: 500;
+}
+
+.cover-item svg {
+ color: var(--ink-faint);
+}
+
+.cover-item:hover {
+ background: var(--accent-wash);
+ color: var(--ink);
+}
+
+.cover-item[data-active="true"] {
+ background: var(--paper);
+ color: var(--ink);
+ box-shadow: 0 1px 2px rgba(35, 32, 27, 0.05);
+}
+
+.cover-item[data-active="true"] svg {
+ color: var(--accent);
+}
+
+.cover-item[data-active="true"]::before {
+ content: "";
+ position: absolute;
+ left: -10px;
+ top: 8px;
+ bottom: 8px;
+ width: 3px;
+ background: var(--accent);
+ border-radius: 0 2px 2px 0;
+}
+
+.cover-pane {
+ width: 100%;
+ max-width: var(--measure);
+ padding: 64px 24px 120px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 32px;
+}
+
+.cover-stage {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+}
+
+.cover-art {
+ width: min(360px, 80%);
+ container-type: size;
+ position: relative;
+ overflow: hidden;
+ border-radius: var(--r-md);
+ border: 1px solid var(--line);
+ box-shadow: var(--shadow-page);
+}
+
+.cover-face {
+ width: 100%;
+ height: 100%;
+}
+
+.cover-face-image {
+ object-fit: cover;
+ display: block;
+}
+
+.cover-face-default {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 12cqw 10cqw;
+}
+
+.cover-stack {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.cover-title {
+ font-family: var(--font-book);
+ font-weight: 500;
+ font-size: 9.5cqw;
+ line-height: 1.08;
+ letter-spacing: -0.01em;
+ margin: 0;
+}
+
+.cover-rule {
+ display: block;
+ width: 15cqw;
+ height: 1px;
+ margin: 5.5cqw 0;
+ opacity: 0.7;
+}
+
+.cover-subtitle {
+ font-family: var(--font-book);
+ font-style: italic;
+ font-size: 4.6cqw;
+ line-height: 1.3;
+ margin: 0;
+ opacity: 0.84;
+}
+
+.cover-author {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 9cqw;
+ margin: 0;
+ padding: 0 8cqw;
+ font-family: var(--font-ui);
+ font-weight: 600;
+ font-size: 3.4cqw;
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ opacity: 0.92;
+}
+
+.cover-controls {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+}
+
+.cover-palettes {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.cover-swatch {
+ width: 34px;
+ height: 34px;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--line-strong);
+ font-family: var(--font-book);
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: transform 120ms var(--ease), box-shadow 120ms var(--ease);
+}
+
+.cover-swatch:hover {
+ transform: translateY(-1px);
+}
+
+.cover-swatch[data-on="true"] {
+ box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px var(--accent);
+}
+
+.cover-color {
+ width: 34px;
+ height: 34px;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--line-strong);
+ overflow: hidden;
+ position: relative;
+ cursor: pointer;
+}
+
+.cover-color input {
+ position: absolute;
+ inset: -4px;
+ width: calc(100% + 8px);
+ height: calc(100% + 8px);
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: pointer;
+}
+
+.cover-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.cover-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 9px 14px;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--line-strong);
+ background: var(--raised);
+ color: var(--ink);
+ font-family: var(--font-ui);
+ font-size: var(--t-3);
+ font-weight: 500;
+ cursor: pointer;
+ transition: border-color 120ms var(--ease), color 120ms var(--ease);
+}
+
+.cover-btn:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.cover-hint {
+ margin: 0;
+ font-family: var(--font-ui);
+ font-size: var(--t-2);
+ color: var(--ink-faint);
+ text-align: center;
+}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index e591ee4..a95173d 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -8,7 +8,7 @@
--pane-sidebar: 248px;
--pane-dock: 384px;
- --measure: 34em;
+ --measure: 46em;
--t-1: 11px;
--t-2: 12px;