@@ -175,8 +178,8 @@ export function PrWorkflowPanel({
) : null}
{workflow?.body ? (
-
-
{workflow.body}
+
+
) : null}
diff --git a/apps/web/src/hooks/themeConfig.ts b/apps/web/src/hooks/themeConfig.ts
new file mode 100644
index 00000000..f735c3a1
--- /dev/null
+++ b/apps/web/src/hooks/themeConfig.ts
@@ -0,0 +1,26 @@
+export type Theme = "light" | "dark" | "system";
+export type ColorTheme =
+ | "default"
+ | "iridescent-void"
+ | "carbon"
+ | "purple-stuff"
+ | "hot-tamale"
+ | "custom";
+export type FontFamily = "dm-sans" | "inter" | "plus-jakarta-sans";
+
+export const COLOR_THEMES: { id: ColorTheme; label: string }[] = [
+ { id: "default", label: "Default" },
+ { id: "iridescent-void", label: "Iridescent Void" },
+ { id: "carbon", label: "Carbon" },
+ { id: "purple-stuff", label: "Deep Purple" },
+ { id: "hot-tamale", label: "Hot Tamale" },
+ { id: "custom", label: "Custom" },
+];
+
+export const FONT_FAMILIES: { id: FontFamily; label: string }[] = [
+ { id: "inter", label: "Inter" },
+ { id: "dm-sans", label: "DM Sans" },
+ { id: "plus-jakarta-sans", label: "Plus Jakarta Sans" },
+];
+
+export const DEFAULT_COLOR_THEME: ColorTheme = "carbon";
diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts
new file mode 100644
index 00000000..d68b23ce
--- /dev/null
+++ b/apps/web/src/hooks/useTheme.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from "vitest";
+
+import { COLOR_THEMES } from "./themeConfig";
+
+describe("COLOR_THEMES", () => {
+ it("includes the Hot Tamale preset", () => {
+ expect(COLOR_THEMES.some((theme) => theme.id === "hot-tamale")).toBe(true);
+ });
+});
diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts
index eb032ef9..bebf966f 100644
--- a/apps/web/src/hooks/useTheme.ts
+++ b/apps/web/src/hooks/useTheme.ts
@@ -1,10 +1,8 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { initCustomTheme } from "../lib/customTheme";
-
-type Theme = "light" | "dark" | "system";
-type ColorTheme = "default" | "iridescent-void" | "carbon" | "purple-stuff" | "custom";
-
-type FontFamily = "dm-sans" | "inter" | "plus-jakarta-sans";
+import { DEFAULT_COLOR_THEME } from "./themeConfig";
+export { COLOR_THEMES, DEFAULT_COLOR_THEME, FONT_FAMILIES } from "./themeConfig";
+import type { ColorTheme, FontFamily, Theme } from "./themeConfig";
type ThemeSnapshot = {
theme: Theme;
@@ -13,25 +11,10 @@ type ThemeSnapshot = {
fontFamily: FontFamily;
};
-export const COLOR_THEMES: { id: ColorTheme; label: string }[] = [
- { id: "default", label: "Default" },
- { id: "iridescent-void", label: "Iridescent Void" },
- { id: "carbon", label: "Carbon" },
- { id: "purple-stuff", label: "Deep Purple" },
- { id: "custom", label: "Custom" },
-];
-
-export const FONT_FAMILIES: { id: FontFamily; label: string }[] = [
- { id: "inter", label: "Inter" },
- { id: "dm-sans", label: "DM Sans" },
- { id: "plus-jakarta-sans", label: "Plus Jakarta Sans" },
-];
-
const STORAGE_KEY = "okcode:theme";
const COLOR_THEME_STORAGE_KEY = "okcode:color-theme";
const FONT_FAMILY_STORAGE_KEY = "okcode:font-family";
const MEDIA_QUERY = "(prefers-color-scheme: dark)";
-export const DEFAULT_COLOR_THEME: ColorTheme = "carbon";
const SERVER_SNAPSHOT: ThemeSnapshot = {
theme: "system",
@@ -66,6 +49,7 @@ function getStoredColorTheme(): ColorTheme {
normalized === "iridescent-void" ||
normalized === "carbon" ||
normalized === "purple-stuff" ||
+ normalized === "hot-tamale" ||
normalized === "custom"
) {
if (normalized !== raw) {
diff --git a/apps/web/src/lib/markdownHtml.test.ts b/apps/web/src/lib/markdownHtml.test.ts
new file mode 100644
index 00000000..7dcec37a
--- /dev/null
+++ b/apps/web/src/lib/markdownHtml.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.hoisted(() => {
+ if (typeof globalThis.HTMLElement === "undefined") {
+ Object.defineProperty(globalThis, "HTMLElement", {
+ configurable: true,
+ // oxlint-disable-next-line typescript-eslint/no-extraneous-class
+ value: class HTMLElement {},
+ writable: true,
+ });
+ }
+
+ if (typeof globalThis.customElements === "undefined") {
+ Object.defineProperty(globalThis, "customElements", {
+ configurable: true,
+ value: {
+ define() {},
+ get() {
+ return undefined;
+ },
+ },
+ writable: true,
+ });
+ }
+});
+
+import {
+ markdownLooksLikeGitHubMarkdown,
+ renderMarkdownHtml,
+ resolveMarkdownPreviewTheme,
+} from "./markdownHtml";
+
+describe("resolveMarkdownPreviewTheme", () => {
+ it("maps app themes to the GitHub preview themes", () => {
+ expect(resolveMarkdownPreviewTheme("light")).toBe("github");
+ expect(resolveMarkdownPreviewTheme("dark")).toBe("github-dark");
+ });
+});
+
+describe("markdownLooksLikeGitHubMarkdown", () => {
+ it("detects GitHub-flavored markdown features", () => {
+ expect(markdownLooksLikeGitHubMarkdown("- [x] complete")).toBe(true);
+ expect(markdownLooksLikeGitHubMarkdown("| A | B |\n| - | - |\n| 1 | 2 |")).toBe(true);
+ expect(markdownLooksLikeGitHubMarkdown("Plain text only")).toBe(false);
+ });
+});
+
+describe("renderMarkdownHtml", () => {
+ it("renders GitHub-flavored markdown into HTML", () => {
+ const { html, css } = renderMarkdownHtml(
+ "# Title\n\n- [x] done\n- [ ] todo\n\n| A | B |\n| - | - |\n| 1 | 2 |",
+ "github",
+ );
+
+ expect(html).toContain("
{
+ const { html } = renderMarkdownHtml("", "github");
+
+ expect(html).toBe("");
+ });
+});
diff --git a/apps/web/src/lib/markdownHtml.ts b/apps/web/src/lib/markdownHtml.ts
new file mode 100644
index 00000000..dac10ad2
--- /dev/null
+++ b/apps/web/src/lib/markdownHtml.ts
@@ -0,0 +1,79 @@
+import type { CSSProperties } from "react";
+
+import { parse } from "@create-markdown/core";
+import { blocksToHTML, themes } from "@create-markdown/preview";
+
+import { MARKDOWN_PREVIEW_CLASS_PREFIX, scopeMarkdownPreviewThemeCss } from "~/markdownPreview";
+
+export type MarkdownPreviewTheme = "github" | "github-dark";
+
+export const MARKDOWN_PREVIEW_CONTAINER_STYLE: CSSProperties = {
+ "--cm-bg": "transparent",
+ "--cm-text": "var(--foreground)",
+ "--cm-border": "var(--border)",
+ "--cm-muted": "var(--muted-foreground)",
+ "--cm-link": "var(--primary)",
+ "--cm-code-bg": "var(--secondary)",
+ "--cm-inline-code-bg": "var(--secondary)",
+ "--cm-table-header-bg": "var(--secondary)",
+ "--cm-table-stripe-bg": "var(--accent)",
+ "--cm-callout-bg": "var(--secondary)",
+ "--cm-radius": "12px",
+ "--cm-font":
+ 'var(--font-ui, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif)',
+ "--cm-mono": '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
+} as CSSProperties;
+
+function getThemeCss(theme: MarkdownPreviewTheme): string {
+ const themeCss = theme === "github" ? themes.github : themes.githubDark;
+ if (typeof themeCss !== "string") {
+ throw new Error(`Unsupported markdown preview theme: ${theme}`);
+ }
+
+ return scopeMarkdownPreviewThemeCss(themeCss);
+}
+
+export function resolveMarkdownPreviewTheme(resolvedTheme: "light" | "dark"): MarkdownPreviewTheme {
+ return resolvedTheme === "dark" ? "github-dark" : "github";
+}
+
+export function markdownLooksLikeGitHubMarkdown(markdown: string): boolean {
+ return (
+ /^#{1,6}\s/m.test(markdown) ||
+ /^>\s/m.test(markdown) ||
+ /^(-|\*|\+)\s/m.test(markdown) ||
+ /^\d+\.\s/m.test(markdown) ||
+ /^\s*[-*+]\s+\[[ xX]\]\s/m.test(markdown) ||
+ /\|.+\|/m.test(markdown) ||
+ /```[\s\S]*```/.test(markdown) ||
+ /`[^`]+`/.test(markdown) ||
+ /\[[^\]]+\]\([^)]+\)/.test(markdown) ||
+ /\*\*[^*]+\*\*/.test(markdown) ||
+ /\*[^*\n]+\*/.test(markdown)
+ );
+}
+
+export function renderMarkdownHtml(
+ markdown: string,
+ theme: MarkdownPreviewTheme,
+): { html: string; css: string } {
+ if (markdown.trim().length === 0) {
+ return {
+ html: "",
+ css: getThemeCss(theme),
+ };
+ }
+
+ const blocks = parse(markdown);
+ const html = blocksToHTML(blocks, {
+ classPrefix: MARKDOWN_PREVIEW_CLASS_PREFIX,
+ linkTarget: "_blank",
+ sanitize: true,
+ theme,
+ });
+
+ return {
+ html,
+ css: getThemeCss(theme),
+ };
+}
diff --git a/apps/web/src/themes.css b/apps/web/src/themes.css
index ee74f713..5a5d605d 100644
--- a/apps/web/src/themes.css
+++ b/apps/web/src/themes.css
@@ -125,7 +125,8 @@
/* ─── Deep Purple ─── minimal cool elegant dashboard portfolio ─── */
-:root.theme-purple-stuff {
+:root.theme-purple-stuff,
+:root.theme-hot-tamale {
color-scheme: light;
--background: oklch(0.9838 0.0035 247.8583);
--foreground: oklch(0.1284 0.0267 261.5937);
@@ -187,7 +188,8 @@
--spacing: 0.25rem;
}
-:root.theme-purple-stuff.dark {
+:root.theme-purple-stuff.dark,
+:root.theme-hot-tamale.dark {
color-scheme: dark;
--background: oklch(0.1091 0.0091 301.6956);
--foreground: oklch(0.9838 0.0035 247.8583);