diff --git a/apps/web/src/lib/custom-editor-theme.test.mjs b/apps/web/src/lib/custom-editor-theme.test.mjs
index cd2a0922..1d56e704 100644
--- a/apps/web/src/lib/custom-editor-theme.test.mjs
+++ b/apps/web/src/lib/custom-editor-theme.test.mjs
@@ -3,8 +3,10 @@ import {
CUSTOM_EDITOR_THEME_FILE_SCHEMA,
CUSTOM_EDITOR_THEME_FILE_VERSION,
CustomEditorThemeFileError,
+ DEFAULT_CUSTOM_EDITOR_THEME_NAME,
DEFAULT_CUSTOM_LIGHT_COLORS,
DEFAULT_CUSTOM_DARK_COLORS,
+ localizeStoredCustomThemeName,
normalizeThemeColors,
customEditorThemeFileName,
getEditorThemeContrastIssues,
@@ -106,4 +108,11 @@ describe("custom editor theme files", () => {
test("creates a filesystem-safe descriptive filename", () => {
expect(customEditorThemeFileName(theme.name)).toBe("EdgeEver-Calm - Green.json");
});
+
+ test("localizes factory custom theme names without rewriting user names", () => {
+ const copy = { defaultName: "自定义主题", newName: (index) => `新主题 ${index}` };
+ expect(localizeStoredCustomThemeName(DEFAULT_CUSTOM_EDITOR_THEME_NAME, copy)).toBe("自定义主题");
+ expect(localizeStoredCustomThemeName("New theme 2", copy)).toBe("新主题 2");
+ expect(localizeStoredCustomThemeName("Calm / Green", copy)).toBe("Calm / Green");
+ });
});
diff --git a/apps/web/src/lib/custom-editor-theme.ts b/apps/web/src/lib/custom-editor-theme.ts
index fe5d5a59..363c26ea 100644
--- a/apps/web/src/lib/custom-editor-theme.ts
+++ b/apps/web/src/lib/custom-editor-theme.ts
@@ -41,14 +41,28 @@ export const DEFAULT_CUSTOM_DARK_COLORS: ThemeColors = {
border: "#44403c",
};
+export const DEFAULT_CUSTOM_EDITOR_THEME_NAME = "My custom theme";
+
export const DEFAULT_CUSTOM_EDITOR_THEME: CustomEditorTheme = {
id: "custom-default",
- name: "My custom theme",
+ name: DEFAULT_CUSTOM_EDITOR_THEME_NAME,
light: DEFAULT_CUSTOM_LIGHT_COLORS,
dark: DEFAULT_CUSTOM_DARK_COLORS,
customCss: "",
};
+const NEW_THEME_NAME_PATTERN = /^New theme (\d+)$/;
+
+export const localizeStoredCustomThemeName = (
+ name: string,
+ copy: { defaultName: string; newName: (index: number) => string },
+) => {
+ if (name === DEFAULT_CUSTOM_EDITOR_THEME_NAME) return copy.defaultName;
+ const match = NEW_THEME_NAME_PATTERN.exec(name);
+ if (match) return copy.newName(Number(match[1]));
+ return name;
+};
+
export const CUSTOM_EDITOR_THEME_FILE_SCHEMA = "edgeever.editor-theme";
export const CUSTOM_EDITOR_THEME_FILE_VERSION = 1;
export const MAX_CUSTOM_EDITOR_THEME_FILE_BYTES = 64 * 1024;
diff --git a/apps/web/src/lib/dark-theme.test.mjs b/apps/web/src/lib/dark-theme.test.mjs
index bf481429..21387acb 100644
--- a/apps/web/src/lib/dark-theme.test.mjs
+++ b/apps/web/src/lib/dark-theme.test.mjs
@@ -12,7 +12,6 @@ const BUILT_IN_EDITOR_THEMES = [
"outline-emerald",
"wechat-green",
"modern-mint",
- "marxico",
];
const readDarkThemeTokens = (theme) => {
@@ -107,15 +106,6 @@ describe("dark theme contracts", () => {
}
});
- test("Marxico keeps note content legible in dark mode", () => {
- const { css, tokens } = readDarkThemeTokens("marxico");
-
- expect(css).toContain(':root.dark .edgeever-editor[data-editor-theme="marxico"]');
- expect(css).toContain("color: var(--editor-theme-text);");
- expect(contrastRatio(tokens.text, "#191e1b")).toBeGreaterThanOrEqual(4.5);
- expect(contrastRatio(tokens.heading, "#191e1b")).toBeGreaterThanOrEqual(4.5);
- });
-
test("automatic Mermaid themes follow the resolved appearance", () => {
expect(resolveMermaidTheme("auto", "light")).toBe("zinc-light");
expect(resolveMermaidTheme("auto", "dark")).toBe("zinc-dark");
diff --git a/apps/web/src/lib/publish-layout.test.mjs b/apps/web/src/lib/publish-layout.test.mjs
index 4d0c3165..0e553c95 100644
--- a/apps/web/src/lib/publish-layout.test.mjs
+++ b/apps/web/src/lib/publish-layout.test.mjs
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
import {
DEFAULT_PUBLISH_LAYOUT,
DEFAULT_PUBLISH_PALETTE,
+ PAPER_EDITOR_THEME_PALETTES,
+ PUBLISH_LAYOUT_CHROME,
PUBLISH_LAYOUT_IDS,
PUBLISH_LAYOUT_RHYTHM,
PUBLISH_PALETTE_IDS,
@@ -19,7 +21,7 @@ import {
} from "./publish-layout.ts";
describe("publish layout catalog", () => {
- test("keeps ten paper editor themes and eight palettes", () => {
+ test("keeps ten paper editor themes and a palette per layout", () => {
expect(PUBLISH_LAYOUT_IDS).toEqual([
"letter",
"guide",
@@ -32,15 +34,46 @@ describe("publish layout catalog", () => {
"zen",
"grove",
]);
- expect(PUBLISH_PALETTE_IDS).toHaveLength(8);
+ expect(PUBLISH_PALETTE_IDS).toEqual([
+ "emerald",
+ "ink",
+ "dawn",
+ "clay",
+ "navy",
+ "slate",
+ "plum",
+ "forest",
+ "azure",
+ "amber",
+ "teal",
+ "violet",
+ "mist",
+ ]);
expect(DEFAULT_PUBLISH_LAYOUT).toBe("letter");
expect(DEFAULT_PUBLISH_PALETTE).toBe("emerald");
expect(PUBLISH_PALETTES.emerald.accent).toBe("#16A06E");
+ expect(PUBLISH_PALETTES.dawn.accent).toBe("#D9A521");
+ expect(PUBLISH_PALETTES.azure.accent).toBe("#2F80ED");
+ expect(PUBLISH_PALETTES.azure.surface).toBe("#F5F8FC");
+ expect(PUBLISH_PALETTES.azure.surface).not.toBe("#F0FDF4");
+ expect(PUBLISH_PALETTES.clay.accent).toBe("#E02D32");
});
test("maps paper editor themes to a fixed layout and palette", () => {
expect(resolvePaperEditorTheme("letter")).toEqual({ layout: "letter", palette: "dawn" });
- expect(resolvePaperEditorTheme("guide")).toEqual({ layout: "guide", palette: "emerald" });
+ expect(resolvePaperEditorTheme("guide")).toEqual({ layout: "guide", palette: "azure" });
+ expect(PAPER_EDITOR_THEME_PALETTES).toEqual({
+ letter: "dawn",
+ guide: "azure",
+ blueprint: "navy",
+ journal: "slate",
+ stance: "clay",
+ stub: "amber",
+ brief: "teal",
+ outline: "violet",
+ zen: "mist",
+ grove: "forest",
+ });
expect(resolvePaperEditorTheme("default")).toBeNull();
});
@@ -51,13 +84,18 @@ describe("publish layout catalog", () => {
expect(resolveStoredPublishPalette("dawn")).toBe("dawn");
});
- test("numbers letter and blueprint chapters without CSS counters", () => {
+ test("numbers magazine chapters without CSS counters", () => {
expect(formatChapterIndex(0)).toBe("01");
expect(planHeadingDecoration("h2", "letter", 0)).toEqual({ kind: "chapter", chapterLabel: "01" });
- expect(planHeadingDecoration("h2", "blueprint", 1)).toEqual({ kind: "chapter", chapterLabel: "02" });
+ expect(planHeadingDecoration("h2", "guide", 0)).toEqual({ kind: "chapter", chapterLabel: "01" });
+ expect(planHeadingDecoration("h2", "blueprint", 1)).toEqual({ kind: "none" });
expect(planHeadingDecoration("h2", "stub", 0)).toEqual({ kind: "chapter", chapterLabel: "01" });
- expect(planHeadingDecoration("h2", "guide", 0)).toEqual({ kind: "none" });
+ expect(planHeadingDecoration("h2", "grove", 0)).toEqual({ kind: "none" });
expect(planHeadingDecoration("h1", "letter", 0)).toEqual({ kind: "title" });
+ expect(PUBLISH_LAYOUT_CHROME.letter.chapter).toBe("aside-serif");
+ expect(PUBLISH_LAYOUT_CHROME.letter.quote).toBe("grid-card");
+ expect(PUBLISH_LAYOUT_CHROME.brief.quoteBanner).toBe("EDITOR'S NOTE");
+ expect(PUBLISH_LAYOUT_CHROME.grove.small).toBe("slash");
});
});
@@ -67,24 +105,43 @@ describe("publish layout CSS", () => {
const desktop = buildPublishStyles("letter", PUBLISH_PALETTES.emerald, "desktop");
expect(phone.p).toContain("font-size: 15px");
- expect(phone.p).toContain("line-height: 1.9");
- expect(phone.p).toContain("margin: 0 0 16px");
+ expect(phone.p).toContain("line-height: 2");
+ expect(phone.p).toContain("letter-spacing: 0.06em");
+ expect(phone.p).toContain("margin: 0 0 28px");
expect(phone.h1).toContain("font-size: 24px");
expect(phone.h2).toContain("font-size: 20px");
expect(desktop.p).toContain("font-size: 15px");
- expect(desktop.p).toContain("line-height: 1.9");
+ expect(desktop.p).toContain("line-height: 2");
});
test("uses a looser reading rhythm than the note editor contract", () => {
- const letter = buildPublishStyles("letter", PUBLISH_PALETTES.emerald);
- const guide = buildPublishStyles("guide", PUBLISH_PALETTES.emerald);
+ const letter = buildPublishStyles("letter", PUBLISH_PALETTES.dawn);
+ const blueprint = buildPublishStyles("blueprint", PUBLISH_PALETTES.navy);
- expect(letter.p).toContain("line-height: 1.9");
- expect(letter.p).toContain("margin: 0 0 24px");
+ expect(letter.p).toContain("line-height: 2");
+ expect(letter.p).toContain("letter-spacing: 0.06em");
+ expect(letter.p).toContain("margin: 0 0 28px");
expect(letter.h1).toContain("text-align: center");
- expect(guide.h2).toContain("border-left: 4px solid #16A06E");
- expect(letter.blockquote).toContain("border-radius: 12px");
- expect(letter.strong).toContain("color: #16A06E");
+ expect(letter.h1).toContain("letter-spacing: 0.08em");
+ expect(blueprint.h2).toContain("border-left: 3px solid #4271B3");
+ expect(letter.blockquote).toContain("border-radius: 18px");
+ expect(letter.strong).toContain("color: #D9A521");
+ expect(letter.th).toContain("background: #FFF7DF");
+ expect(letter.a).toContain("color: #7C5500");
+ });
+
+ test("gives each paper theme its own line height and tracking", () => {
+ const stance = buildPublishStyles("stance", PUBLISH_PALETTES.clay);
+ const zen = buildPublishStyles("zen", PUBLISH_PALETTES.mist);
+ const stub = buildPublishStyles("stub", PUBLISH_PALETTES.amber);
+
+ expect(PUBLISH_LAYOUT_RHYTHM.stance.lineHeight).toBe("1.68");
+ expect(PUBLISH_LAYOUT_RHYTHM.zen.lineHeight).toBe("2.2");
+ expect(stance.p).toContain("letter-spacing: 0em");
+ expect(zen.p).toContain("letter-spacing: 0.1em");
+ expect(stub.h1).toContain("letter-spacing: 0.14em");
+ expect(new Set(Object.values(PUBLISH_LAYOUT_RHYTHM).map((rhythm) => rhythm.lineHeight)).size).toBe(10);
+ expect(new Set(Object.values(PUBLISH_LAYOUT_RHYTHM).map((rhythm) => rhythm.letterSpacing)).size).toBe(10);
});
test("keeps WeChat-safe inline CSS for every layout and palette", () => {
@@ -110,11 +167,12 @@ describe("copy pipeline wiring", () => {
expect(source).toContain("ProseMirror-separator");
expect(source).toContain("findOriginalImage");
expect(source).toContain("svgToWeChatImage");
+ expect(source).toContain("rasterizePublishOrnamentsForWeChat");
expect(source).toContain('fillStyle = "#ffffff"');
expect(source).toContain("image/jpeg");
expect(source).toContain("text-align: center");
expect(preferenceCard).not.toContain('t("settings.publishLayoutTitle")');
- expect(preferenceCard).toContain('t("settings.editorThemes.letter")');
+ expect(preferenceCard).not.toContain('t("settings.editorThemeTitle")');
});
test("drives the rich editor rhythm from paper editor themes", () => {
@@ -122,10 +180,19 @@ describe("copy pipeline wiring", () => {
const css = readFileSync(new URL("../styles/publish-layout.css", import.meta.url), "utf8");
const vars = publishEditorCssVars("letter", "dawn");
- expect(PUBLISH_LAYOUT_RHYTHM.letter.lineHeight).toBe("1.9");
- expect(PUBLISH_LAYOUT_RHYTHM.letter.paragraphSpacing).toBe("24px");
- expect(vars["--editor-body-line-height"]).toBe("1.9");
- expect(vars["--publish-accent"]).toBe("#C49A3C");
+ expect(PUBLISH_LAYOUT_RHYTHM.letter.lineHeight).toBe("2");
+ expect(PUBLISH_LAYOUT_RHYTHM.letter.paragraphSpacing).toBe("28px");
+ expect(vars["--editor-body-line-height"]).toBe("2");
+ expect(vars["--editor-letter-spacing"]).toBe("0.06em");
+ expect(vars["--editor-heading-letter-spacing"]).toBe("0.08em");
+ expect(css).toContain("letter-spacing: var(--editor-letter-spacing)");
+ expect(css).toContain("letter-spacing: var(--editor-heading-letter-spacing)");
+ expect(vars["--publish-accent"]).toBe("#D9A521");
+ expect(vars["--publish-accent-grid"]).toBe("rgba(217,165,33,0.14)");
+ expect(vars["--brand-green-rgb"]).toBe("217 165 33");
+ expect(vars["--publish-ornament-brush"]).toContain("url(\"data:image/svg+xml");
+ expect(css).toContain("--editor-theme-accent: var(--publish-accent)");
+ expect(css).toContain("--brand-green: var(--publish-accent)");
expect(editorPane).toContain("isPaperEditorTheme(editorTheme)");
expect(editorPane).toContain('data-paper-theme={isPaperEditorTheme(editorTheme) ? "true" : undefined}');
expect(editorPane).toContain("publishEditorCssVars");
@@ -135,7 +202,7 @@ describe("copy pipeline wiring", () => {
expect(css).toContain('[data-editor-theme="letter"]');
expect(css).toContain('[data-editor-theme="stance"]');
expect(css).toContain('[data-editor-theme="grove"]');
- expect(css).not.toMatch(/\.ProseMirror p\s*\{[^}]*line-height\s*:/);
+ expect(css).toContain("line-height: var(--editor-body-line-height)");
});
test("phone preview markup applies the paper layout instead of raw editor chrome", () => {
diff --git a/apps/web/src/lib/publish-layout.ts b/apps/web/src/lib/publish-layout.ts
index 37a6b7be..cc6eb2e3 100644
--- a/apps/web/src/lib/publish-layout.ts
+++ b/apps/web/src/lib/publish-layout.ts
@@ -1,3 +1,10 @@
+import {
+ PUBLISH_LAYOUT_ORNAMENTS,
+ publishOrnamentCssVars,
+ publishOrnamentDataUri,
+ type PublishOrnamentId,
+} from "./publish-ornaments";
+
export const PAPER_EDITOR_THEME_IDS = [
"letter",
"guide",
@@ -22,6 +29,11 @@ export const PUBLISH_PALETTE_IDS = [
"slate",
"plum",
"forest",
+ "azure",
+ "amber",
+ "teal",
+ "violet",
+ "mist",
] as const;
export type PublishPaletteId = (typeof PUBLISH_PALETTE_IDS)[number];
@@ -30,14 +42,14 @@ export const DEFAULT_PUBLISH_PALETTE: PublishPaletteId = "emerald";
export const PAPER_EDITOR_THEME_PALETTES = {
letter: "dawn",
- guide: "emerald",
+ guide: "azure",
blueprint: "navy",
journal: "slate",
stance: "clay",
- stub: "dawn",
- brief: "forest",
- outline: "ink",
- zen: "slate",
+ stub: "amber",
+ brief: "teal",
+ outline: "violet",
+ zen: "mist",
grove: "forest",
} as const satisfies Record
;
@@ -58,6 +70,7 @@ export type PublishPalette = {
accent: string;
accentSoft: string;
accentBorder: string;
+ accentGrid: string;
text: string;
textStrong: string;
textMuted: string;
@@ -69,12 +82,17 @@ export type PublishPalette = {
bg: string;
};
+const palette = (
+ id: PublishPaletteId,
+ colors: Omit,
+): PublishPalette => ({ id, ...colors });
+
export const PUBLISH_PALETTES: Record = {
- emerald: {
- id: "emerald",
+ emerald: palette("emerald", {
accent: "#16A06E",
accentSoft: "#E8F6F0",
accentBorder: "#B7E0CC",
+ accentGrid: "rgba(22,160,110,0.14)",
text: "#2C3A34",
textStrong: "#14241C",
textMuted: "#5C6F66",
@@ -84,12 +102,12 @@ export const PUBLISH_PALETTES: Record = {
codeBg: "#F3F7F5",
codeText: "#0F7A54",
bg: "#FFFFFF",
- },
- ink: {
- id: "ink",
+ }),
+ ink: palette("ink", {
accent: "#1A5C4A",
accentSoft: "#E7F2EE",
accentBorder: "#B5D2C8",
+ accentGrid: "rgba(26,92,74,0.14)",
text: "#24332E",
textStrong: "#12201B",
textMuted: "#5A6C65",
@@ -99,72 +117,72 @@ export const PUBLISH_PALETTES: Record = {
codeBg: "#EEF4F1",
codeText: "#164C3E",
bg: "#FFFFFF",
- },
- dawn: {
- id: "dawn",
- accent: "#C49A3C",
- accentSoft: "#FBF6EA",
- accentBorder: "#E8D7A8",
- text: "#3D3426",
- textStrong: "#2A2418",
- textMuted: "#7A6E58",
- surface: "#FFFCF6",
- divider: "#EFE3C8",
- link: "#8A6420",
- codeBg: "#FBF6EA",
- codeText: "#8A6420",
+ }),
+ dawn: palette("dawn", {
+ accent: "#D9A521",
+ accentSoft: "#FFF7DF",
+ accentBorder: "#EAD89F",
+ accentGrid: "rgba(217,165,33,0.14)",
+ text: "#3D2A1B",
+ textStrong: "#3A3020",
+ textMuted: "#74613C",
+ surface: "#FFFDF8",
+ divider: "#F0E3BD",
+ link: "#7C5500",
+ codeBg: "#FFFAF0",
+ codeText: "#7C5500",
bg: "#FFFFFF",
- },
- clay: {
- id: "clay",
- accent: "#C45C3E",
- accentSoft: "#FBEFEA",
- accentBorder: "#E8C4B6",
- text: "#3D2E29",
- textStrong: "#271C18",
- textMuted: "#7A635B",
- surface: "#FDF8F6",
- divider: "#EEDDD6",
- link: "#9A3E28",
- codeBg: "#F8EEE9",
- codeText: "#9A3E28",
+ }),
+ clay: palette("clay", {
+ accent: "#E02D32",
+ accentSoft: "#FFF1F1",
+ accentBorder: "#FFC2C2",
+ accentGrid: "rgba(224,45,50,0.14)",
+ text: "#374151",
+ textStrong: "#1C1917",
+ textMuted: "#374151",
+ surface: "#FFF7F7",
+ divider: "#FFD6D6",
+ link: "#B42318",
+ codeBg: "#FFF1F1",
+ codeText: "#B42318",
bg: "#FFFFFF",
- },
- navy: {
- id: "navy",
- accent: "#3B6B9A",
- accentSoft: "#EAF1F8",
- accentBorder: "#B7C9DC",
- text: "#2C3640",
- textStrong: "#162029",
- textMuted: "#5C6A78",
- surface: "#F5F8FB",
- divider: "#D7E1EB",
- link: "#2A5278",
+ }),
+ navy: palette("navy", {
+ accent: "#4271B3",
+ accentSoft: "#F3F4F7",
+ accentBorder: "#C8D0DB",
+ accentGrid: "rgba(66,113,179,0.14)",
+ text: "#3D4650",
+ textStrong: "#272D35",
+ textMuted: "#495464",
+ surface: "#F9FAFA",
+ divider: "#D7DDE5",
+ link: "#2F578A",
codeBg: "#EEF3F8",
- codeText: "#2A5278",
+ codeText: "#2F578A",
bg: "#FFFFFF",
- },
- slate: {
- id: "slate",
- accent: "#5A6B73",
- accentSoft: "#EEF1F2",
- accentBorder: "#C5CED2",
- text: "#2F373B",
- textStrong: "#1A2023",
- textMuted: "#66747B",
- surface: "#F7F8F9",
- divider: "#DEE3E6",
- link: "#3E4C53",
- codeBg: "#F0F2F3",
- codeText: "#3E4C53",
+ }),
+ slate: palette("slate", {
+ accent: "#89798B",
+ accentSoft: "#F5F4F6",
+ accentBorder: "#D4CDD5",
+ accentGrid: "rgba(137,121,139,0.14)",
+ text: "#5C505E",
+ textStrong: "#312A32",
+ textMuted: "#5C505E",
+ surface: "#FAFAFA",
+ divider: "#E0DBE1",
+ link: "#6B5C6D",
+ codeBg: "#F5F4F6",
+ codeText: "#6B5C6D",
bg: "#FFFFFF",
- },
- plum: {
- id: "plum",
+ }),
+ plum: palette("plum", {
accent: "#7A4E6E",
accentSoft: "#F5EEF3",
accentBorder: "#D4BCCB",
+ accentGrid: "rgba(122,78,110,0.14)",
text: "#3A2E36",
textStrong: "#241820",
textMuted: "#746068",
@@ -174,22 +192,97 @@ export const PUBLISH_PALETTES: Record = {
codeBg: "#F4ECF1",
codeText: "#5E3A54",
bg: "#FFFFFF",
- },
- forest: {
- id: "forest",
- accent: "#4A7C4E",
- accentSoft: "#EAF3EB",
- accentBorder: "#BDD4BF",
- text: "#2E3A2F",
- textStrong: "#172017",
- textMuted: "#5E6E5F",
- surface: "#F5F9F5",
- divider: "#D6E4D7",
- link: "#37633B",
- codeBg: "#EEF4EE",
- codeText: "#37633B",
+ }),
+ forest: palette("forest", {
+ accent: "#4D9D7F",
+ accentSoft: "#F3F6F5",
+ accentBorder: "#CAD8D3",
+ accentGrid: "rgba(77,157,127,0.14)",
+ text: "#4D6159",
+ textStrong: "#29332F",
+ textMuted: "#4D6159",
+ surface: "#FAFAFA",
+ divider: "#D9E3DF",
+ link: "#3B7A62",
+ codeBg: "#F3F6F5",
+ codeText: "#3B7A62",
bg: "#FFFFFF",
- },
+ }),
+ azure: palette("azure", {
+ accent: "#2F80ED",
+ accentSoft: "#EEF6FF",
+ accentBorder: "#B9D7FF",
+ accentGrid: "rgba(47,128,237,0.14)",
+ text: "#374151",
+ textStrong: "#111827",
+ textMuted: "#4B5563",
+ surface: "#F5F8FC",
+ divider: "#D7E8FF",
+ link: "#1D64C7",
+ codeBg: "#EEF6FF",
+ codeText: "#1D64C7",
+ bg: "#FFFFFF",
+ }),
+ amber: palette("amber", {
+ accent: "#E3A321",
+ accentSoft: "#FFF8E8",
+ accentBorder: "#F4D58B",
+ accentGrid: "rgba(227,163,33,0.14)",
+ text: "#555555",
+ textStrong: "#1A1A1A",
+ textMuted: "#555555",
+ surface: "#FFFEF8",
+ divider: "#F7E3B4",
+ link: "#B07C12",
+ codeBg: "#FFF8E8",
+ codeText: "#B07C12",
+ bg: "#FFFFFF",
+ }),
+ teal: palette("teal", {
+ accent: "#22B8A7",
+ accentSoft: "#ECFBF8",
+ accentBorder: "#A8E5DD",
+ accentGrid: "rgba(34,184,167,0.14)",
+ text: "#4D4F46",
+ textStrong: "#23251D",
+ textMuted: "#4D4F46",
+ surface: "#EEEFE9",
+ divider: "#CDEFEA",
+ link: "#1A8F82",
+ codeBg: "#ECFBF8",
+ codeText: "#1A8F82",
+ bg: "#FFFFFF",
+ }),
+ violet: palette("violet", {
+ accent: "#7567F8",
+ accentSoft: "#F3F1FF",
+ accentBorder: "#CFC9FF",
+ accentGrid: "rgba(117,103,248,0.14)",
+ text: "#3F3F46",
+ textStrong: "#18181B",
+ textMuted: "#3F3F46",
+ surface: "#F4F4F5",
+ divider: "#E1DEFF",
+ link: "#5B4EE0",
+ codeBg: "#F3F1FF",
+ codeText: "#5B4EE0",
+ bg: "#FFFFFF",
+ }),
+ mist: palette("mist", {
+ accent: "#35A7B8",
+ accentSoft: "#EEF9FA",
+ accentBorder: "#B7E3E8",
+ accentGrid: "rgba(53,167,184,0.14)",
+ text: "#525252",
+ textStrong: "#26352D",
+ textMuted: "#526359",
+ surface: "#F7FAFA",
+ divider: "#D4EEF1",
+ link: "#2A8492",
+ codeBg: "#EEF9FA",
+ codeText: "#2A8492",
+ bg: "#FFFFFF",
+ }),
};
export const isPublishLayoutId = (value: string | null | undefined): value is PublishLayoutId =>
@@ -246,6 +339,144 @@ export const writePublishPalettePreference = (palette: PublishPaletteId) => {
export const formatChapterIndex = (index: number) => String(index + 1).padStart(2, "0");
export type PublishHeadingKind = "title" | "chapter" | "small" | "none";
+export type PublishChapterStyle =
+ | "none"
+ | "aside-serif"
+ | "aside-part"
+ | "badge"
+ | "badge-section"
+ | "stacked-section"
+ | "kicker"
+ | "issue";
+export type PublishQuoteStyle =
+ | "grid-card"
+ | "rule"
+ | "panel"
+ | "marks"
+ | "rule-thick"
+ | "banner"
+ | "labeled"
+ | "italic-center"
+ | "italic-rule";
+export type PublishSmallHeadingStyle = "bookmark" | "slash" | "bar" | "dash" | "plain";
+export type PublishTitleRule = "none" | "short" | "brush";
+
+export type PublishLayoutChrome = {
+ kicker: string | null;
+ h1Align: "left" | "center";
+ h1Rule: PublishTitleRule;
+ h1Vol: boolean;
+ chapter: PublishChapterStyle;
+ small: PublishSmallHeadingStyle;
+ quote: PublishQuoteStyle;
+ quoteBanner?: string;
+ strongAccent: boolean;
+};
+
+export const PUBLISH_LAYOUT_CHROME: Record = {
+ letter: {
+ kicker: null,
+ h1Align: "center",
+ h1Rule: "brush",
+ h1Vol: false,
+ chapter: "aside-serif",
+ small: "bookmark",
+ quote: "grid-card",
+ strongAccent: true,
+ },
+ guide: {
+ kicker: "GUIDE · ARTICLE",
+ h1Align: "left",
+ h1Rule: "short",
+ h1Vol: false,
+ chapter: "aside-part",
+ small: "plain",
+ quote: "rule",
+ strongAccent: true,
+ },
+ blueprint: {
+ kicker: null,
+ h1Align: "left",
+ h1Rule: "short",
+ h1Vol: true,
+ chapter: "none",
+ small: "bookmark",
+ quote: "panel",
+ strongAccent: true,
+ },
+ journal: {
+ kicker: null,
+ h1Align: "left",
+ h1Rule: "short",
+ h1Vol: true,
+ chapter: "issue",
+ small: "dash",
+ quote: "italic-rule",
+ strongAccent: true,
+ },
+ stance: {
+ kicker: "ARTICLE",
+ h1Align: "left",
+ h1Rule: "none",
+ h1Vol: false,
+ chapter: "badge-section",
+ small: "bar",
+ quote: "marks",
+ strongAccent: true,
+ },
+ stub: {
+ kicker: "EDITION · FIELD NOTES",
+ h1Align: "left",
+ h1Rule: "short",
+ h1Vol: false,
+ chapter: "badge",
+ small: "bar",
+ quote: "rule-thick",
+ strongAccent: false,
+ },
+ brief: {
+ kicker: "EDITION · FIELD NOTES",
+ h1Align: "left",
+ h1Rule: "none",
+ h1Vol: false,
+ chapter: "aside-part",
+ small: "plain",
+ quote: "banner",
+ quoteBanner: "EDITOR'S NOTE",
+ strongAccent: true,
+ },
+ outline: {
+ kicker: "MINIMAL · ARTICLE",
+ h1Align: "left",
+ h1Rule: "none",
+ h1Vol: false,
+ chapter: "stacked-section",
+ small: "plain",
+ quote: "labeled",
+ quoteBanner: "QUOTE",
+ strongAccent: false,
+ },
+ zen: {
+ kicker: "FIELD NOTES",
+ h1Align: "left",
+ h1Rule: "short",
+ h1Vol: false,
+ chapter: "kicker",
+ small: "bar",
+ quote: "italic-center",
+ strongAccent: false,
+ },
+ grove: {
+ kicker: null,
+ h1Align: "left",
+ h1Rule: "none",
+ h1Vol: false,
+ chapter: "none",
+ small: "slash",
+ quote: "marks",
+ strongAccent: true,
+ },
+};
export const planHeadingDecoration = (
tag: string,
@@ -254,7 +485,7 @@ export const planHeadingDecoration = (
): { kind: PublishHeadingKind; chapterLabel?: string } => {
if (tag === "h1") return { kind: "title" };
if (tag === "h3") return { kind: "small" };
- if (tag === "h2" && (layout === "letter" || layout === "blueprint" || layout === "stub" || layout === "outline")) {
+ if (tag === "h2" && PUBLISH_LAYOUT_CHROME[layout].chapter !== "none") {
return { kind: "chapter", chapterLabel: formatChapterIndex(chapterIndex) };
}
return { kind: "none" };
@@ -266,42 +497,144 @@ export type PublishRhythm = {
fontSize: string;
lineHeight: string;
paragraphSpacing: string;
+ letterSpacing: string;
+ headingLetterSpacing: string;
headingAlign: "left" | "center";
};
export const PHONE_ARTICLE_RHYTHM = {
- fontSize: "15px",
- lineHeight: "1.9",
- paragraphSpacing: "16px",
h1Size: "24px",
h2Size: "20px",
h3Size: "15px",
} as const;
-const resolvePublishRhythm = (layout: PublishLayoutId, surface: PublishSurface): PublishRhythm => {
- const desktop = PUBLISH_LAYOUT_RHYTHM[layout];
- if (surface !== "phone") return desktop;
- return {
- fontSize: PHONE_ARTICLE_RHYTHM.fontSize,
- lineHeight: PHONE_ARTICLE_RHYTHM.lineHeight,
- paragraphSpacing: PHONE_ARTICLE_RHYTHM.paragraphSpacing,
- headingAlign: desktop.headingAlign,
- };
-};
+const resolvePublishRhythm = (layout: PublishLayoutId, _surface: PublishSurface): PublishRhythm =>
+ PUBLISH_LAYOUT_RHYTHM[layout];
export const PUBLISH_LAYOUT_RHYTHM: Record = {
- letter: { fontSize: "15px", lineHeight: "1.9", paragraphSpacing: "24px", headingAlign: "center" },
- guide: { fontSize: "15px", lineHeight: "1.8", paragraphSpacing: "16px", headingAlign: "left" },
- blueprint: { fontSize: "16px", lineHeight: "1.8", paragraphSpacing: "20px", headingAlign: "left" },
- journal: { fontSize: "16px", lineHeight: "1.9", paragraphSpacing: "26px", headingAlign: "left" },
- stance: { fontSize: "16px", lineHeight: "1.8", paragraphSpacing: "20px", headingAlign: "left" },
- stub: { fontSize: "15px", lineHeight: "1.9", paragraphSpacing: "32px", headingAlign: "left" },
- brief: { fontSize: "15px", lineHeight: "1.9", paragraphSpacing: "24px", headingAlign: "left" },
- outline: { fontSize: "16px", lineHeight: "1.8", paragraphSpacing: "22px", headingAlign: "left" },
- zen: { fontSize: "16px", lineHeight: "2", paragraphSpacing: "32px", headingAlign: "left" },
- grove: { fontSize: "15px", lineHeight: "1.9", paragraphSpacing: "24px", headingAlign: "left" },
+ letter: {
+ fontSize: "15px",
+ lineHeight: "2",
+ paragraphSpacing: "28px",
+ letterSpacing: "0.06em",
+ headingLetterSpacing: "0.08em",
+ headingAlign: "center",
+ },
+ guide: {
+ fontSize: "15px",
+ lineHeight: "1.72",
+ paragraphSpacing: "14px",
+ letterSpacing: "0.01em",
+ headingLetterSpacing: "0.02em",
+ headingAlign: "left",
+ },
+ blueprint: {
+ fontSize: "16px",
+ lineHeight: "1.78",
+ paragraphSpacing: "18px",
+ letterSpacing: "0.02em",
+ headingLetterSpacing: "0.04em",
+ headingAlign: "left",
+ },
+ journal: {
+ fontSize: "16px",
+ lineHeight: "1.95",
+ paragraphSpacing: "28px",
+ letterSpacing: "0.04em",
+ headingLetterSpacing: "0.06em",
+ headingAlign: "left",
+ },
+ stance: {
+ fontSize: "16px",
+ lineHeight: "1.68",
+ paragraphSpacing: "16px",
+ letterSpacing: "0em",
+ headingLetterSpacing: "0.01em",
+ headingAlign: "left",
+ },
+ stub: {
+ fontSize: "15px",
+ lineHeight: "1.88",
+ paragraphSpacing: "30px",
+ letterSpacing: "0.08em",
+ headingLetterSpacing: "0.14em",
+ headingAlign: "left",
+ },
+ brief: {
+ fontSize: "15px",
+ lineHeight: "1.82",
+ paragraphSpacing: "22px",
+ letterSpacing: "0.03em",
+ headingLetterSpacing: "0.05em",
+ headingAlign: "left",
+ },
+ outline: {
+ fontSize: "16px",
+ lineHeight: "1.76",
+ paragraphSpacing: "20px",
+ letterSpacing: "0.05em",
+ headingLetterSpacing: "0.1em",
+ headingAlign: "left",
+ },
+ zen: {
+ fontSize: "16px",
+ lineHeight: "2.2",
+ paragraphSpacing: "36px",
+ letterSpacing: "0.1em",
+ headingLetterSpacing: "0.12em",
+ headingAlign: "left",
+ },
+ grove: {
+ fontSize: "15px",
+ lineHeight: "1.98",
+ paragraphSpacing: "26px",
+ letterSpacing: "0.045em",
+ headingLetterSpacing: "0.06em",
+ headingAlign: "left",
+ },
};
+const hexToRgbChannels = (hex: string) => {
+ const value = hex.replace("#", "");
+ if (!/^[0-9a-fA-F]{6}$/.test(value)) return "22 160 110";
+ return [0, 2, 4].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16)).join(" ");
+};
+
+export const publishColorCssVars = (
+ palette: PublishPalette,
+ host: "editor" | "article" = "editor",
+): Record => ({
+ "--publish-accent": palette.accent,
+ "--publish-accent-soft": palette.accentSoft,
+ "--publish-accent-border": palette.accentBorder,
+ "--publish-accent-grid": palette.accentGrid,
+ "--publish-text": palette.text,
+ "--publish-text-strong": palette.textStrong,
+ "--publish-text-muted": palette.textMuted,
+ "--publish-surface": palette.surface,
+ "--publish-divider": palette.divider,
+ "--publish-link": palette.link,
+ "--publish-code-bg": palette.codeBg,
+ "--publish-code-text": palette.codeText,
+ "--publish-bg": palette.bg,
+ "--brand-green-rgb": hexToRgbChannels(palette.accent),
+ "--brand-green-hover-rgb": hexToRgbChannels(palette.link),
+ "--brand-green-200-rgb": hexToRgbChannels(palette.accentBorder),
+ "--brand-green-300-rgb": hexToRgbChannels(palette.accent),
+ "--brand-green-600-rgb": hexToRgbChannels(palette.link),
+ "--memo-content-divider-color": palette.accentBorder,
+ ...(host === "article"
+ ? {
+ "--brand-green": palette.accent,
+ "--brand-green-hover": palette.link,
+ "--brand-green-muted": palette.accentSoft,
+ "--brand-green-soft": palette.surface,
+ "--brand-green-border": palette.accentBorder,
+ "--brand-green-text": palette.link,
+ }
+ : {}),
+});
+
export const publishEditorCssVars = (
layout: PublishLayoutId,
paletteId: PublishPaletteId,
@@ -313,18 +646,10 @@ export const publishEditorCssVars = (
"--editor-body-font-size": rhythm.fontSize,
"--editor-body-line-height": rhythm.lineHeight,
"--editor-paragraph-spacing": rhythm.paragraphSpacing,
- "--publish-accent": palette.accent,
- "--publish-accent-soft": palette.accentSoft,
- "--publish-accent-border": palette.accentBorder,
- "--publish-text": palette.text,
- "--publish-text-strong": palette.textStrong,
- "--publish-text-muted": palette.textMuted,
- "--publish-surface": palette.surface,
- "--publish-divider": palette.divider,
- "--publish-link": palette.link,
- "--publish-code-bg": palette.codeBg,
- "--publish-code-text": palette.codeText,
- "--publish-bg": palette.bg,
+ "--editor-letter-spacing": rhythm.letterSpacing,
+ "--editor-heading-letter-spacing": rhythm.headingLetterSpacing,
+ ...publishColorCssVars(palette),
+ ...publishOrnamentCssVars(),
};
};
@@ -336,6 +661,102 @@ const css = (declarations: Record) =>
export type PublishTagStyles = Record;
+const quoteStyleRules = (
+ layout: PublishLayoutId,
+ palette: PublishPalette,
+ phone: boolean,
+): Record => {
+ const chrome = PUBLISH_LAYOUT_CHROME[layout];
+ const bottom = phone ? "14px" : "28px";
+ const pad = phone ? "10px 12px" : "16px 18px";
+ switch (chrome.quote) {
+ case "grid-card":
+ return {
+ margin: `0 0 ${bottom}`,
+ padding: phone ? "14px 14px 16px 18px" : "18px 16px 20px 22px",
+ border: `1px solid ${palette.accentBorder}`,
+ "border-radius": "18px",
+ "background-color": palette.surface,
+ "background-image": `linear-gradient(to right, ${palette.accentGrid} 1px, transparent 1px), linear-gradient(to bottom, ${palette.accentGrid} 1px, transparent 1px)`,
+ "background-size": "30px 30px",
+ color: palette.textMuted,
+ };
+ case "panel":
+ return {
+ margin: `0 0 ${bottom}`,
+ padding: pad,
+ border: `1px solid ${palette.accentBorder}`,
+ "border-radius": "10px",
+ background: palette.surface,
+ color: palette.textMuted,
+ };
+ case "marks":
+ return {
+ margin: `0 0 ${bottom}`,
+ padding: phone ? "12px 14px 14px" : "16px 18px 18px",
+ border: `1px solid ${palette.accentBorder}`,
+ "border-radius": "12px",
+ background: palette.accentSoft,
+ color: palette.textMuted,
+ };
+ case "rule-thick":
+ return {
+ margin: `0 0 ${bottom}`,
+ padding: phone ? "8px 0 8px 14px" : "6px 0 6px 16px",
+ "border-left": `4px solid ${palette.accent}`,
+ background: "transparent",
+ color: palette.textMuted,
+ };
+ case "banner":
+ return {
+ margin: "0",
+ padding: phone ? "12px 14px 14px" : "14px 16px 16px",
+ border: "0",
+ background: palette.accentSoft,
+ color: palette.textMuted,
+ };
+ case "labeled":
+ return {
+ margin: `0 0 ${bottom}`,
+ padding: "0",
+ border: "0",
+ background: "transparent",
+ color: palette.textStrong,
+ "text-align": "center",
+ "font-weight": "600",
+ };
+ case "italic-center":
+ return {
+ margin: `0 0 ${bottom}`,
+ padding: phone ? "4px 8px" : "4px 24px",
+ border: "0",
+ background: "transparent",
+ color: palette.textMuted,
+ "font-style": "italic",
+ "text-align": "center",
+ };
+ case "italic-rule":
+ return {
+ margin: `0 0 ${bottom}`,
+ padding: phone ? "12px 4px" : "18px 8px",
+ "border-top": `1px solid ${palette.accentBorder}`,
+ "border-bottom": `1px solid ${palette.accentBorder}`,
+ background: palette.surface,
+ color: palette.textMuted,
+ "font-style": "italic",
+ };
+ case "rule":
+ default:
+ return {
+ margin: `0 0 ${bottom}`,
+ padding: phone ? "4px 0 4px 12px" : "4px 0 4px 16px",
+ "border-left": `3px solid ${palette.accent}`,
+ background: "transparent",
+ color: palette.textMuted,
+ };
+ }
+};
+
export const buildPublishStyles = (
layout: PublishLayoutId,
palette: PublishPalette,
@@ -343,132 +764,30 @@ export const buildPublishStyles = (
): PublishTagStyles => {
const rhythm = resolvePublishRhythm(layout, surface);
const phone = surface === "phone";
- const align = rhythm.headingAlign;
- const h1AlignRules =
- layout === "letter"
- ? {
- "text-align": "center",
- "border-bottom": `3px solid ${palette.accent}`,
- "padding-bottom": "10px",
- }
- : layout === "blueprint"
- ? {
- "text-align": "left",
- "border-top": `3px solid ${palette.accent}`,
- "padding-top": "10px",
- }
- : layout === "stance"
- ? {
- "text-align": "left",
- "border-bottom": `6px solid ${palette.accent}`,
- "padding-bottom": "10px",
- }
- : layout === "stub"
- ? {
- "text-align": "left",
- "letter-spacing": "0.12em",
- "border-bottom": `1px dashed ${palette.accent}`,
- "padding-bottom": "10px",
- }
- : layout === "outline"
- ? {
- "text-align": "left",
- "letter-spacing": "0.06em",
- "border-bottom": `1px solid ${palette.divider}`,
- "padding-bottom": "10px",
- }
- : layout === "zen"
- ? { "text-align": "left", "font-weight": "600" }
- : { "text-align": align };
-
+ const chrome = PUBLISH_LAYOUT_CHROME[layout];
+ const h1Weight = layout === "letter" || layout === "guide" || layout === "stance" ? "800" : "700";
const h2Rules =
- layout === "guide" || layout === "blueprint" || layout === "stance"
- ? {
- "border-left": layout === "stance" ? `6px solid ${palette.accent}` : `4px solid ${palette.accent}`,
- "padding-left": "12px",
- }
- : layout === "journal" || layout === "grove"
- ? {
- "border-bottom": `1px solid ${palette.divider}`,
- "padding-bottom": "8px",
- }
- : layout === "stub"
- ? {
- "border-bottom": `1px dashed ${palette.accentBorder}`,
- "padding-bottom": "8px",
- }
- : {};
-
+ layout === "blueprint"
+ ? { "border-left": `3px solid ${palette.accent}`, "padding-left": "12px" }
+ : layout === "grove"
+ ? { "display": "inline-block", "border-bottom": `2px solid ${palette.accent}`, "padding-bottom": "6px" }
+ : {};
const h3Rules =
- layout === "letter"
- ? {
- "text-align": "center",
- "border-bottom": `2px solid ${palette.accentBorder}`,
- "padding-bottom": "8px",
- }
- : layout === "blueprint" || layout === "stance"
- ? {
- "border-bottom": `2px solid ${palette.accent}`,
- "padding-bottom": "6px",
- }
- : layout === "stub"
- ? {
- "border-bottom": `1px dashed ${palette.accentBorder}`,
- "padding-bottom": "6px",
- }
+ chrome.small === "bookmark"
+ ? { "text-align": "center" }
+ : chrome.small === "bar"
+ ? { "border-left": `3px solid ${palette.accent}`, "padding-left": "10px" }
+ : chrome.small === "slash"
+ ? { "text-align": "center", color: palette.accent }
: {};
-
- const quoteRules =
- layout === "letter" || layout === "grove"
- ? {
- margin: "0 0 28px",
- padding: "16px 18px",
- border: `1px solid ${palette.accentBorder}`,
- "border-radius": layout === "grove" ? "16px" : "12px",
- background: palette.surface,
- color: palette.textMuted,
- }
- : layout === "journal" || layout === "zen"
- ? {
- margin: "0 0 28px",
- padding: layout === "zen" ? "8px 0 8px 0" : "4px 0 4px 16px",
- "border-left": layout === "zen" ? "0" : `2px solid ${palette.divider}`,
- background: "transparent",
- color: palette.textMuted,
- "font-style": "italic",
- }
- : layout === "stub"
- ? {
- margin: "0 0 28px",
- padding: "14px 16px",
- border: `1px dashed ${palette.accent}`,
- "border-radius": "2px",
- background: palette.surface,
- color: palette.textMuted,
- }
- : layout === "outline"
- ? {
- margin: "0 0 24px",
- padding: "12px 16px",
- border: `1px solid ${palette.divider}`,
- background: "transparent",
- color: palette.textMuted,
- }
- : {
- margin: "0 0 24px",
- padding: "12px 16px",
- "border-left": layout === "stance" ? `6px solid ${palette.accent}` : `4px solid ${palette.accent}`,
- background: palette.accentSoft,
- color: palette.textMuted,
- };
-
- const quote = phone ? { ...quoteRules, margin: "0 0 14px", padding: "10px 12px" } : quoteRules;
+ const quote = quoteStyleRules(layout, palette, phone);
return {
root: css({
"font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
"font-size": rhythm.fontSize,
"line-height": rhythm.lineHeight,
+ "letter-spacing": rhythm.letterSpacing,
color: palette.text,
"background-color": palette.bg,
"word-break": "break-word",
@@ -477,26 +796,28 @@ export const buildPublishStyles = (
margin: `0 0 ${rhythm.paragraphSpacing}`,
padding: "0",
"line-height": rhythm.lineHeight,
+ "letter-spacing": rhythm.letterSpacing,
"font-size": rhythm.fontSize,
color: palette.text,
}),
h1: css({
- ...h1AlignRules,
- margin: phone ? "0 0 12px" : "0 0 24px",
+ "text-align": chrome.h1Align,
+ margin: phone ? "0 0 12px" : "0 0 18px",
"font-size": phone
? PHONE_ARTICLE_RHYTHM.h1Size
: layout === "journal" || layout === "zen"
? "26px"
: "24px",
"line-height": phone ? "1.4" : "1.35",
- "font-weight": "800",
- ...(phone ? { "letter-spacing": "0.02em" } : {}),
+ "letter-spacing": rhythm.headingLetterSpacing,
+ "font-weight": h1Weight,
color: palette.textStrong,
}),
h2: css({
margin: phone ? "18px 0 10px" : "28px 0 16px",
"font-size": phone ? PHONE_ARTICLE_RHYTHM.h2Size : "20px",
"line-height": "1.35",
+ "letter-spacing": rhythm.headingLetterSpacing,
"font-weight": phone ? "600" : "700",
color: palette.textStrong,
"text-align": "left",
@@ -506,29 +827,34 @@ export const buildPublishStyles = (
margin: phone ? "14px 0 8px" : "24px 0 12px",
"font-size": phone ? PHONE_ARTICLE_RHYTHM.h3Size : layout === "letter" ? "15px" : "16px",
"line-height": "1.45",
+ "letter-spacing": rhythm.headingLetterSpacing,
"font-weight": "700",
- color: palette.textStrong,
+ color: chrome.small === "slash" ? palette.accent : palette.textStrong,
...h3Rules,
}),
blockquote: css({
"line-height": rhythm.lineHeight,
+ "letter-spacing": rhythm.letterSpacing,
...quote,
}),
ul: css({
margin: "0 0 1em",
"padding-left": "1.6em",
"line-height": rhythm.lineHeight,
+ "letter-spacing": rhythm.letterSpacing,
color: palette.text,
}),
ol: css({
margin: "0 0 1em",
"padding-left": "1.6em",
+ "letter-spacing": rhythm.letterSpacing,
"line-height": rhythm.lineHeight,
color: palette.text,
}),
li: css({
margin: "0.25em 0",
"line-height": rhythm.lineHeight,
+ "letter-spacing": rhythm.letterSpacing,
color: palette.text,
}),
a: css({
@@ -537,7 +863,7 @@ export const buildPublishStyles = (
}),
strong: css({
"font-weight": "700",
- color: palette.accent,
+ color: chrome.strongAccent ? palette.accent : palette.textStrong,
}),
em: css({
"font-style": "italic",
@@ -649,24 +975,512 @@ const themeBlockStyles = (
const isChromeElement = (element: HTMLElement) => element.getAttribute("data-ee-publish-chrome") === "true";
-const wrapHeading = (heading: HTMLElement) => {
- if (heading.parentElement?.getAttribute("data-ee-publish-heading")) {
- return heading.parentElement;
- }
- const document = heading.ownerDocument;
- const wrapper = document.createElement("section");
- wrapper.setAttribute("data-ee-publish-heading", heading.tagName.toLowerCase());
- heading.parentNode?.insertBefore(wrapper, heading);
- wrapper.appendChild(heading);
+const SERIF_STACK = "Georgia, 'Times New Roman', serif";
+
+const chromeNode = (doc: Document, tag: string, text: string, style: string) => {
+ const node = doc.createElement(tag);
+ node.setAttribute("data-ee-publish-chrome", "true");
+ node.textContent = text;
+ node.style.cssText = style;
+ return node;
+};
+
+const chromeOrnament = (doc: Document, id: PublishOrnamentId, color: string, style: string) => {
+ const image = doc.createElement("img");
+ image.setAttribute("data-ee-publish-chrome", "true");
+ image.setAttribute("data-ee-publish-ornament", id);
+ image.alt = "";
+ image.src = publishOrnamentDataUri(id, color);
+ image.style.cssText = style;
+ return image;
+};
+
+const wrapBlock = (element: HTMLElement, attr: string, value: string) => {
+ if (element.parentElement?.getAttribute(attr) === value) return element.parentElement;
+ const wrapper = element.ownerDocument.createElement("section");
+ wrapper.setAttribute(attr, value);
+ element.parentNode?.insertBefore(wrapper, element);
+ wrapper.appendChild(element);
return wrapper;
};
+const wrapHeading = (heading: HTMLElement) => wrapBlock(heading, "data-ee-publish-heading", heading.tagName.toLowerCase());
+
+const appendTitleRule = (
+ wrapper: HTMLElement,
+ palette: PublishPalette,
+ phone: boolean,
+ kind: PublishTitleRule,
+ align: "left" | "center",
+ ornament?: PublishOrnamentId,
+) => {
+ if (kind === "none") return;
+ if (ornament) {
+ wrapper.appendChild(
+ chromeOrnament(
+ wrapper.ownerDocument,
+ ornament,
+ palette.accent,
+ css({
+ display: "block",
+ width: phone ? "112px" : "148px",
+ height: "auto",
+ margin: align === "center" ? "8px auto 0" : "8px 0 0",
+ padding: "0",
+ border: "0",
+ }),
+ ),
+ );
+ return;
+ }
+ const bar = chromeNode(
+ wrapper.ownerDocument,
+ "p",
+ "",
+ css({
+ width: kind === "brush" ? (align === "center" ? "42%" : "72px") : "42px",
+ height: "3px",
+ margin: align === "center" ? "8px auto 0" : "8px 0 0",
+ padding: "0",
+ border: "0",
+ background: palette.accent,
+ "font-size": "0",
+ "line-height": "0",
+ }),
+ );
+ wrapper.appendChild(bar);
+ if (phone) bar.style.marginTop = "6px";
+};
+
+const decorateTitle = (
+ heading: HTMLElement,
+ chrome: PublishLayoutChrome,
+ palette: PublishPalette,
+ phone: boolean,
+ layout: PublishLayoutId,
+) => {
+ const ornaments = PUBLISH_LAYOUT_ORNAMENTS[layout];
+ const wrapper = wrapHeading(heading);
+ wrapper.style.cssText = css({
+ margin: phone ? "0 0 16px" : "0 0 24px",
+ padding: "0",
+ });
+ heading.style.margin = "0";
+ if (chrome.kicker) {
+ wrapper.insertBefore(
+ chromeNode(
+ heading.ownerDocument,
+ "p",
+ chrome.kicker,
+ css({
+ margin: "0 0 8px",
+ padding: "0",
+ color: palette.accent,
+ "font-size": "11px",
+ "font-weight": "600",
+ "letter-spacing": "0.16em",
+ "line-height": "1.4",
+ }),
+ ),
+ heading,
+ );
+ }
+ if (ornaments?.titleEnd) {
+ const table = heading.ownerDocument.createElement("table");
+ table.style.cssText = css({
+ width: "auto",
+ "max-width": "100%",
+ "border-collapse": "collapse",
+ margin: chrome.h1Align === "center" ? "0 auto" : "0",
+ padding: "0",
+ border: "0",
+ });
+ const row = heading.ownerDocument.createElement("tr");
+ const titleCell = heading.ownerDocument.createElement("td");
+ titleCell.style.cssText = "padding:0;border:0;vertical-align:middle;";
+ titleCell.appendChild(heading);
+ const markCell = heading.ownerDocument.createElement("td");
+ markCell.setAttribute("data-ee-publish-chrome", "true");
+ markCell.style.cssText = "padding:0 0 0 8px;border:0;vertical-align:middle;width:1%;";
+ markCell.appendChild(
+ chromeOrnament(
+ heading.ownerDocument,
+ ornaments.titleEnd,
+ palette.accent,
+ css({
+ display: "block",
+ width: phone ? "22px" : "28px",
+ height: "auto",
+ margin: "0",
+ padding: "0",
+ border: "0",
+ }),
+ ),
+ );
+ row.appendChild(titleCell);
+ row.appendChild(markCell);
+ table.appendChild(row);
+ wrapper.appendChild(table);
+ }
+ appendTitleRule(wrapper, palette, phone, chrome.h1Rule, chrome.h1Align, ornaments?.titleRule);
+ if (chrome.h1Vol) {
+ wrapper.appendChild(
+ chromeNode(
+ heading.ownerDocument,
+ "p",
+ "VOL. 01",
+ css({
+ margin: "6px 0 0",
+ padding: "0",
+ color: palette.textMuted,
+ "font-size": "11px",
+ "font-weight": "600",
+ "letter-spacing": "0.14em",
+ }),
+ ),
+ );
+ }
+};
+
+const asideNumberTable = (
+ heading: HTMLElement,
+ number: string,
+ palette: PublishPalette,
+ phone: boolean,
+ options: {
+ serif?: boolean;
+ part?: boolean;
+ badge?: boolean;
+ mark?: PublishOrnamentId;
+ end?: PublishOrnamentId;
+ },
+) => {
+ const wrapper = wrapHeading(heading);
+ wrapper.style.cssText = css({
+ margin: phone ? "18px 0 12px" : "28px 0 16px",
+ padding: "0",
+ });
+ const doc = heading.ownerDocument;
+ const table = doc.createElement("table");
+ table.style.cssText = "width:100%;border-collapse:collapse;margin:0;padding:0;border:0;";
+ const row = doc.createElement("tr");
+ const numberCell = doc.createElement("td");
+ numberCell.setAttribute("data-ee-publish-chrome", "true");
+ numberCell.style.cssText = css({
+ width: options.badge ? "1%" : "1%",
+ padding: options.badge ? "0 10px 0 0" : "0 12px 0 0",
+ "vertical-align": "middle",
+ "white-space": "nowrap",
+ border: "0",
+ });
+ const numberLabel = chromeNode(
+ doc,
+ "p",
+ number,
+ options.badge
+ ? css({
+ margin: "0",
+ padding: phone ? "5px 8px" : "6px 8px",
+ color: "#ffffff",
+ background: palette.accent,
+ "border-radius": "6px",
+ "font-size": phone ? "13px" : "14px",
+ "font-weight": "700",
+ "line-height": "1",
+ "text-align": "center",
+ "white-space": "nowrap",
+ })
+ : css({
+ margin: "0",
+ padding: "0",
+ color: palette.accent,
+ "font-family": options.serif ? SERIF_STACK : "inherit",
+ "font-size": phone ? "28px" : options.serif ? "42px" : "34px",
+ "font-style": options.serif ? "italic" : "normal",
+ "font-weight": "700",
+ "line-height": "1",
+ "white-space": "nowrap",
+ }),
+ );
+ numberCell.appendChild(numberLabel);
+ if (options.part) {
+ numberCell.appendChild(
+ chromeNode(
+ doc,
+ "p",
+ "PART",
+ css({
+ margin: "4px 0 0",
+ padding: "0",
+ color: palette.accent,
+ "font-size": "10px",
+ "font-weight": "700",
+ "letter-spacing": "0.16em",
+ "line-height": "1",
+ }),
+ ),
+ );
+ }
+ const titleCell = doc.createElement("td");
+ titleCell.style.cssText = "vertical-align:middle;padding:0;border:0;";
+ heading.style.margin = "0";
+ heading.style.padding = "0";
+ titleCell.appendChild(heading);
+ row.appendChild(numberCell);
+ if (options.mark) {
+ const markCell = doc.createElement("td");
+ markCell.setAttribute("data-ee-publish-chrome", "true");
+ markCell.style.cssText = "vertical-align:middle;padding:0 8px 0 0;border:0;width:1%;";
+ markCell.appendChild(
+ chromeOrnament(
+ doc,
+ options.mark,
+ palette.accent,
+ css({
+ display: "block",
+ width: phone ? "18px" : "22px",
+ height: "auto",
+ margin: "0",
+ padding: "0",
+ border: "0",
+ }),
+ ),
+ );
+ row.appendChild(markCell);
+ }
+ row.appendChild(titleCell);
+ if (options.end) {
+ const endCell = doc.createElement("td");
+ endCell.setAttribute("data-ee-publish-chrome", "true");
+ endCell.style.cssText = "vertical-align:middle;padding:0 0 0 8px;border:0;width:1%;";
+ endCell.appendChild(
+ chromeOrnament(
+ doc,
+ options.end,
+ palette.accent,
+ css({
+ display: "block",
+ width: phone ? "22px" : "26px",
+ height: "auto",
+ margin: "0",
+ padding: "0",
+ border: "0",
+ }),
+ ),
+ );
+ row.appendChild(endCell);
+ }
+ table.appendChild(row);
+ wrapper.appendChild(table);
+ return wrapper;
+};
+
+const decorateChapter = (
+ heading: HTMLElement,
+ chapterLabel: string,
+ chrome: PublishLayoutChrome,
+ palette: PublishPalette,
+ phone: boolean,
+ layout: PublishLayoutId,
+) => {
+ const stacked = (lines: Array<{ text: string; style: string }>) => {
+ const wrapper = wrapHeading(heading);
+ wrapper.style.cssText = css({
+ margin: phone ? "18px 0 12px" : "28px 0 16px",
+ padding: "0",
+ });
+ for (const line of lines) {
+ wrapper.insertBefore(chromeNode(heading.ownerDocument, "p", line.text, line.style), heading);
+ }
+ heading.style.margin = "6px 0 0";
+ return wrapper;
+ };
+
+ const ornaments = PUBLISH_LAYOUT_ORNAMENTS[layout];
+ switch (chrome.chapter) {
+ case "aside-serif":
+ return asideNumberTable(heading, chapterLabel, palette, phone, {
+ serif: true,
+ mark: ornaments?.chapterMark,
+ end: ornaments?.chapterEnd,
+ });
+ case "aside-part":
+ return asideNumberTable(heading, chapterLabel, palette, phone, { serif: true, part: true });
+ case "badge":
+ return asideNumberTable(heading, chapterLabel, palette, phone, { badge: true });
+ case "badge-section": {
+ const wrapper = asideNumberTable(heading, chapterLabel, palette, phone, { badge: true });
+ const titleCell = wrapper.querySelector("td:last-child");
+ if (titleCell && heading.parentElement === titleCell) {
+ titleCell.insertBefore(
+ chromeNode(
+ heading.ownerDocument,
+ "p",
+ `SECTION ${chapterLabel}`,
+ css({
+ margin: "0 0 4px",
+ padding: "0",
+ color: palette.accent,
+ "font-size": "11px",
+ "font-weight": "700",
+ "letter-spacing": "0.14em",
+ }),
+ ),
+ heading,
+ );
+ }
+ return wrapper;
+ }
+ case "stacked-section":
+ return stacked([
+ {
+ text: chapterLabel,
+ style: css({
+ margin: "0",
+ padding: "0",
+ color: palette.accent,
+ "font-family": SERIF_STACK,
+ "font-size": phone ? "36px" : "52px",
+ "font-style": "italic",
+ "font-weight": "700",
+ "line-height": "0.9",
+ "white-space": "nowrap",
+ }),
+ },
+ {
+ text: `SECTION ${chapterLabel}`,
+ style: css({
+ margin: "6px 0 0",
+ padding: "0",
+ color: palette.textMuted,
+ "font-size": "11px",
+ "font-weight": "700",
+ "letter-spacing": "0.16em",
+ }),
+ },
+ ]);
+ case "kicker":
+ return stacked([
+ {
+ text: `${chapterLabel} · CHAPTER ONE`,
+ style: css({
+ margin: "0",
+ padding: "0",
+ color: palette.textMuted,
+ "font-size": "11px",
+ "font-weight": "600",
+ "letter-spacing": "0.16em",
+ }),
+ },
+ ]);
+ case "issue":
+ return stacked([
+ {
+ text: `NO. ${chapterLabel}`,
+ style: css({
+ margin: "0",
+ padding: "0",
+ color: palette.textMuted,
+ "font-size": "11px",
+ "font-weight": "600",
+ "letter-spacing": "0.16em",
+ }),
+ },
+ ]);
+ default:
+ return wrapHeading(heading);
+ }
+};
+
+const decorateSmallHeading = (
+ heading: HTMLElement,
+ chrome: PublishLayoutChrome,
+ palette: PublishPalette,
+ phone: boolean,
+) => {
+ const wrapper = wrapHeading(heading);
+ wrapper.style.cssText = css({
+ margin: phone ? "14px 0 8px" : "24px 0 12px",
+ padding: "0",
+ });
+ heading.style.margin = "0";
+ if (chrome.small === "bookmark") {
+ heading.style.textAlign = "center";
+ const rule = heading.ownerDocument.createElement("table");
+ rule.setAttribute("data-ee-publish-chrome", "true");
+ rule.style.cssText = "width:160px;margin:10px auto 0;border-collapse:collapse;border:0;";
+ const row = heading.ownerDocument.createElement("tr");
+ const accent = heading.ownerDocument.createElement("td");
+ accent.setAttribute("data-ee-publish-chrome", "true");
+ accent.style.cssText = `width:28px;height:0;padding:0;border-top:3px solid ${palette.accent};`;
+ const rest = heading.ownerDocument.createElement("td");
+ rest.setAttribute("data-ee-publish-chrome", "true");
+ rest.style.cssText = `height:0;padding:0;border-top:1px solid ${palette.accentBorder};`;
+ row.appendChild(accent);
+ row.appendChild(rest);
+ rule.appendChild(row);
+ wrapper.appendChild(rule);
+ }
+ if (chrome.small === "slash") {
+ wrapper.style.textAlign = "center";
+ heading.style.textAlign = "center";
+ heading.style.color = palette.accent;
+ wrapper.insertBefore(
+ chromeNode(
+ heading.ownerDocument,
+ "p",
+ `//`,
+ css({
+ display: "inline-block",
+ margin: "0 8px 0 0",
+ padding: "0",
+ color: palette.accent,
+ "font-weight": "800",
+ }),
+ ),
+ heading,
+ );
+ wrapper.appendChild(
+ chromeNode(
+ heading.ownerDocument,
+ "p",
+ `//`,
+ css({
+ display: "inline-block",
+ margin: "0 0 0 8px",
+ padding: "0",
+ color: palette.accent,
+ "font-weight": "800",
+ }),
+ ),
+ );
+ heading.style.display = "inline";
+ }
+ if (chrome.small === "dash") {
+ wrapper.insertBefore(
+ chromeNode(
+ heading.ownerDocument,
+ "span",
+ "— ",
+ css({
+ color: palette.textMuted,
+ "font-weight": "600",
+ }),
+ ),
+ heading,
+ );
+ heading.style.display = "inline";
+ }
+};
+
const decorateHeadings = (
root: HTMLElement,
layout: PublishLayoutId,
palette: PublishPalette,
surface: PublishSurface = "desktop",
) => {
+ const chrome = PUBLISH_LAYOUT_CHROME[layout];
+ const phone = surface === "phone";
const headings = Array.from(root.querySelectorAll("h1, h2, h3")).filter((heading) => {
if (heading.closest("table, pre, [data-edgeever-theme-block]")) return false;
if (heading.closest("[data-ee-publish-heading]")) return false;
@@ -678,28 +1492,182 @@ const decorateHeadings = (
const tag = heading.tagName.toLowerCase();
const plan = planHeadingDecoration(tag, layout, chapterIndex);
if (plan.kind === "chapter") chapterIndex += 1;
- if (plan.kind !== "chapter" || !plan.chapterLabel) continue;
-
- const wrapper = wrapHeading(heading);
- wrapper.style.cssText = "margin: 0; padding: 0;";
- const label = heading.ownerDocument.createElement("p");
- label.setAttribute("data-ee-publish-chrome", "true");
- label.textContent = plan.chapterLabel;
- label.style.cssText = css({
- margin: surface === "phone" ? "16px 0 0" : "28px 0 0",
- padding: "0",
- color: palette.accent,
- "font-size": surface === "phone" ? "15px" : layout === "letter" ? "28px" : "13px",
- "line-height": "1.1",
- "font-weight": "700",
- "font-style": layout === "letter" ? "italic" : "normal",
- "letter-spacing": layout === "blueprint" ? "0.08em" : "0",
- });
- wrapper.insertBefore(label, heading);
- heading.style.margin = surface === "phone" ? "4px 0 10px" : "6px 0 16px";
+ if (plan.kind === "title") {
+ decorateTitle(heading, chrome, palette, phone, layout);
+ continue;
+ }
+ if (plan.kind === "chapter" && plan.chapterLabel) {
+ decorateChapter(heading, plan.chapterLabel, chrome, palette, phone, layout);
+ continue;
+ }
+ if (plan.kind === "small") {
+ decorateSmallHeading(heading, chrome, palette, phone);
+ }
+ const headingRule = PUBLISH_LAYOUT_ORNAMENTS[layout]?.headingRule;
+ if (tag === "h2" && headingRule) {
+ const wrapper = wrapHeading(heading);
+ wrapper.appendChild(
+ chromeOrnament(
+ heading.ownerDocument,
+ headingRule,
+ palette.accent,
+ css({
+ display: "block",
+ width: phone ? "72px" : "96px",
+ height: "auto",
+ margin: "6px 0 0",
+ padding: "0",
+ border: "0",
+ }),
+ ),
+ );
+ }
}
};
+const decorateQuotes = (
+ root: HTMLElement,
+ layout: PublishLayoutId,
+ palette: PublishPalette,
+ surface: PublishSurface = "desktop",
+) => {
+ const chrome = PUBLISH_LAYOUT_CHROME[layout];
+ const phone = surface === "phone";
+ root.querySelectorAll("blockquote").forEach((quote) => {
+ if (quote.closest("[data-ee-publish-quote], table, pre, [data-edgeever-theme-block]")) return;
+ const wrapper = wrapBlock(quote, "data-ee-publish-quote", chrome.quote);
+ wrapper.style.cssText = css({
+ margin: phone ? "0 0 14px" : "0 0 28px",
+ padding: "0",
+ });
+ quote.style.margin = "0";
+ const ornaments = PUBLISH_LAYOUT_ORNAMENTS[layout];
+ if (ornaments?.quoteTape) {
+ const tapeRow = chromeNode(
+ quote.ownerDocument,
+ "p",
+ "",
+ css({
+ margin: phone ? "0 8px -4px 0" : "0 12px -6px 0",
+ padding: "0",
+ "text-align": "right",
+ "font-size": "0",
+ "line-height": "0",
+ }),
+ );
+ tapeRow.appendChild(
+ chromeOrnament(
+ quote.ownerDocument,
+ ornaments.quoteTape,
+ palette.accent,
+ css({
+ display: "inline-block",
+ width: phone ? "42px" : "54px",
+ height: "auto",
+ margin: "0",
+ padding: "0",
+ border: "0",
+ transform: "rotate(12deg)",
+ }),
+ ),
+ );
+ wrapper.insertBefore(tapeRow, quote);
+ }
+ if (chrome.quote === "banner" && chrome.quoteBanner) {
+ wrapper.style.overflow = "hidden";
+ wrapper.style.borderRadius = "8px";
+ wrapper.insertBefore(
+ chromeNode(
+ quote.ownerDocument,
+ "p",
+ chrome.quoteBanner,
+ css({
+ margin: "0",
+ padding: phone ? "8px 12px" : "9px 16px",
+ color: "#ffffff",
+ background: palette.accent,
+ "font-size": "11px",
+ "font-weight": "700",
+ "letter-spacing": "0.16em",
+ }),
+ ),
+ quote,
+ );
+ }
+ if (chrome.quote === "labeled" && chrome.quoteBanner) {
+ wrapper.insertBefore(
+ chromeNode(
+ quote.ownerDocument,
+ "p",
+ chrome.quoteBanner,
+ css({
+ margin: "0 0 10px",
+ padding: "0",
+ color: palette.textMuted,
+ "font-size": "11px",
+ "font-weight": "700",
+ "letter-spacing": "0.18em",
+ "text-align": "center",
+ }),
+ ),
+ quote,
+ );
+ }
+ if (ornaments?.quoteMark) {
+ wrapper.insertBefore(
+ chromeOrnament(
+ quote.ownerDocument,
+ ornaments.quoteMark,
+ palette.accent,
+ css({
+ display: "block",
+ width: phone ? "22px" : "28px",
+ height: "auto",
+ margin: phone ? "0 0 2px 12px" : "0 0 4px 16px",
+ padding: "0",
+ border: "0",
+ }),
+ ),
+ quote,
+ );
+ } else if (chrome.quote === "marks" || chrome.quote === "grid-card") {
+ wrapper.insertBefore(
+ chromeNode(
+ quote.ownerDocument,
+ "p",
+ "“",
+ css({
+ margin: "0 0 4px",
+ padding: "0",
+ color: palette.accent,
+ "font-family": SERIF_STACK,
+ "font-size": phone ? "28px" : "34px",
+ "font-weight": "700",
+ "line-height": "1",
+ }),
+ ),
+ quote,
+ );
+ }
+ if (chrome.quote === "italic-center") {
+ wrapper.appendChild(
+ chromeNode(
+ quote.ownerDocument,
+ "p",
+ "—",
+ css({
+ margin: "8px 0 0",
+ padding: "0",
+ color: palette.accent,
+ "text-align": "center",
+ "letter-spacing": "0.2em",
+ }),
+ ),
+ );
+ }
+ });
+};
+
const applyThemeBlocks = (
root: HTMLElement,
layout: PublishLayoutId,
@@ -740,7 +1708,7 @@ export const applyPublishLayout = (
) => {
const palette = PUBLISH_PALETTES[paletteId] ?? PUBLISH_PALETTES[DEFAULT_PUBLISH_PALETTE];
const styles = buildPublishStyles(layout, palette, surface);
- root.style.cssText = styles.root;
+ root.style.cssText = `${styles.root}; ${css(publishColorCssVars(palette, "article"))}`;
root.querySelectorAll("*").forEach((element) => {
if (isChromeElement(element)) return;
@@ -764,6 +1732,7 @@ export const applyPublishLayout = (
});
decorateHeadings(root, layout, palette, surface);
+ decorateQuotes(root, layout, palette, surface);
applyThemeBlocks(root, layout, palette, surface);
};
diff --git a/apps/web/src/lib/publish-ornaments.test.mjs b/apps/web/src/lib/publish-ornaments.test.mjs
new file mode 100644
index 00000000..d95221cf
--- /dev/null
+++ b/apps/web/src/lib/publish-ornaments.test.mjs
@@ -0,0 +1,65 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import {
+ PUBLISH_LAYOUT_ORNAMENTS,
+ PUBLISH_ORNAMENT_IDS,
+ publishOrnamentCssVars,
+ publishOrnamentDataUri,
+ renderPublishOrnamentSvg,
+} from "./publish-ornaments.ts";
+
+describe("publish ornaments", () => {
+ test("renders original tintable stationery marks", () => {
+ expect(PUBLISH_ORNAMENT_IDS).toEqual([
+ "blossom",
+ "sprig",
+ "sun",
+ "tape",
+ "quote",
+ "brush",
+ "swoosh",
+ "leaf",
+ ]);
+ const svg = renderPublishOrnamentSvg("blossom", "#D9A521");
+ expect(svg).toContain("#D9A521");
+ expect(svg).not.toContain("{{color}}");
+ expect(svg).toContain("