@tomehq/theme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/entry.tsx ADDED
@@ -0,0 +1,191 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { Shell } from "./Shell.js";
4
+
5
+ // @ts-ignore — resolved by vite-plugin-tome
6
+ import config from "virtual:tome/config";
7
+ // @ts-ignore — resolved by vite-plugin-tome
8
+ import { routes, navigation } from "virtual:tome/routes";
9
+ // @ts-ignore — resolved by vite-plugin-tome
10
+ import loadPageModule from "virtual:tome/page-loader";
11
+ // @ts-ignore — resolved by vite-plugin-tome
12
+ import docContext from "virtual:tome/doc-context";
13
+
14
+ // TOM-8: Built-in MDX components from @tomehq/components
15
+ // These are injected into every MDX page automatically
16
+ import {
17
+ Callout,
18
+ Tabs,
19
+ Card,
20
+ CardGroup,
21
+ Steps,
22
+ Accordion,
23
+ } from "@tomehq/components";
24
+
25
+ const MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {
26
+ Callout,
27
+ Tabs,
28
+ Card,
29
+ CardGroup,
30
+ Steps,
31
+ Accordion,
32
+ };
33
+
34
+ // ── CONTENT STYLES ───────────────────────────────────────
35
+ const contentStyles = `
36
+ @import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&display=swap');
37
+
38
+ .tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; display: flex; align-items: center; gap: 10px; letter-spacing: 0.01em; }
39
+ .tome-content h2::before { content: "#"; font-family: var(--font-heading); font-size: 1.2em; font-weight: 300; font-style: italic; color: var(--ac); opacity: 0.5; }
40
+ .tome-content h3 { font-family: var(--font-body); font-size: 1.15em; font-weight: 600; margin-top: 1.5em; margin-bottom: 0.5em; }
41
+ .tome-content h4 { font-family: var(--font-body); font-size: 1.05em; font-weight: 600; margin-top: 1.2em; margin-bottom: 0.5em; }
42
+ .tome-content p { color: var(--tx2); line-height: 1.8; margin-bottom: 1em; font-size: 14.5px; }
43
+ .tome-content a { color: var(--ac); text-decoration: none; }
44
+ .tome-content a:hover { text-decoration: underline; }
45
+ .tome-content .heading-anchor { display: none; }
46
+ .tome-content ul, .tome-content ol { color: var(--tx2); padding-left: 1.5em; margin-bottom: 1em; }
47
+ .tome-content li { margin-bottom: 0.3em; line-height: 1.7; }
48
+ .tome-content code { font-family: var(--font-code); font-size: 0.88em; background: var(--cdBg); padding: 0.15em 0.4em; border-radius: 2px; color: var(--ac); }
49
+ .tome-content pre { margin-bottom: 1.2em; border-radius: 2px; overflow-x: auto; border: 1px solid var(--bd); }
50
+ .tome-content pre code { background: none; padding: 1em 1.2em; display: block; font-size: 12.5px; line-height: 1.7; color: var(--cdTx); }
51
+ .tome-content blockquote { border-left: 3px solid var(--ac); padding: 0.5em 1em; margin: 1em 0; background: var(--acD); border-radius: 0 2px 2px 0; }
52
+ .tome-content blockquote p { color: var(--tx2); margin: 0; }
53
+ .tome-content table { width: 100%; border-collapse: collapse; margin-bottom: 1em; }
54
+ .tome-content th, .tome-content td { padding: 0.5em 0.8em; border: 1px solid var(--bd); text-align: left; font-size: 0.9em; }
55
+ .tome-content th { background: var(--sf); font-weight: 600; }
56
+ .tome-content img { max-width: 100%; border-radius: 2px; }
57
+ .tome-content hr { border: none; border-top: 1px solid var(--bd); margin: 2em 0; }
58
+
59
+ /* Selection style */
60
+ ::selection { background: var(--acD); color: var(--ac); }
61
+
62
+ /* Scrollbar style */
63
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
64
+ ::-webkit-scrollbar-track { background: transparent; }
65
+ ::-webkit-scrollbar-thumb { background: var(--bd); border-radius: 10px; }
66
+
67
+ /* Grain overlay */
68
+ .tome-grain::before {
69
+ content: ""; position: fixed; inset: 0; z-index: 9999; pointer-events: none;
70
+ opacity: .35;
71
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
72
+ background-repeat: repeat; background-size: 256px;
73
+ }
74
+
75
+ /* Shiki dual-theme support */
76
+ .shiki { background: var(--cdBg) !important; }
77
+ html.dark .shiki .shiki-light { display: none; }
78
+ html.light .shiki .shiki-dark { display: none; }
79
+ `;
80
+
81
+ // ── PAGE TYPES ────────────────────────────────────────────
82
+ interface HtmlPage {
83
+ isMdx: false;
84
+ html: string;
85
+ frontmatter: { title: string; description?: string };
86
+ headings: Array<{ depth: number; text: string; id: string }>;
87
+ }
88
+
89
+ interface MdxPage {
90
+ isMdx: true;
91
+ component: React.ComponentType<{ components?: Record<string, React.ComponentType> }>;
92
+ frontmatter: { title: string; description?: string };
93
+ headings: Array<{ depth: number; text: string; id: string }>;
94
+ }
95
+
96
+ type LoadedPage = HtmlPage | MdxPage;
97
+
98
+ // ── PAGE LOADER ──────────────────────────────────────────
99
+ async function loadPage(id: string): Promise<LoadedPage | null> {
100
+ try {
101
+ const route = routes.find((r: any) => r.id === id);
102
+ const mod = await loadPageModule(id);
103
+
104
+ if (route?.isMdx && mod.meta) {
105
+ // TOM-8: MDX page — mod.default is the React component
106
+ return {
107
+ isMdx: true,
108
+ component: mod.default,
109
+ frontmatter: mod.meta.frontmatter,
110
+ headings: mod.meta.headings,
111
+ };
112
+ }
113
+
114
+ // Regular .md page — mod.default is { html, frontmatter, headings }
115
+ if (!mod.default) return null;
116
+ return { isMdx: false, ...mod.default };
117
+ } catch (err) {
118
+ console.error(`Failed to load page: ${id}`, err);
119
+ return null;
120
+ }
121
+ }
122
+
123
+ // ── APP ──────────────────────────────────────────────────
124
+ function App() {
125
+ const [currentPageId, setCurrentPageId] = useState(() => {
126
+ const hash = window.location.hash.slice(1);
127
+ if (hash && routes.some((r: any) => r.id === hash)) return hash;
128
+ return routes[0]?.id || "index";
129
+ });
130
+
131
+ const [pageData, setPageData] = useState<LoadedPage | null>(null);
132
+ const [loading, setLoading] = useState(true);
133
+
134
+ const navigateTo = useCallback(async (id: string) => {
135
+ setLoading(true);
136
+ setCurrentPageId(id);
137
+ window.location.hash = id;
138
+ const data = await loadPage(id);
139
+ setPageData(data);
140
+ setLoading(false);
141
+ }, []);
142
+
143
+ useEffect(() => { navigateTo(currentPageId); }, []);
144
+
145
+ useEffect(() => {
146
+ const onHashChange = () => {
147
+ const hash = window.location.hash.slice(1);
148
+ // Only navigate if hash matches a known route ID (ignore heading anchors)
149
+ if (hash && hash !== currentPageId && routes.some((r: any) => r.id === hash)) {
150
+ navigateTo(hash);
151
+ }
152
+ };
153
+ window.addEventListener("hashchange", onHashChange);
154
+ return () => window.removeEventListener("hashchange", onHashChange);
155
+ }, [currentPageId, navigateTo]);
156
+
157
+ const allPages = routes.map((r: any) => ({
158
+ id: r.id,
159
+ title: r.frontmatter.title,
160
+ description: r.frontmatter.description,
161
+ }));
162
+
163
+ return (
164
+ <>
165
+ <style>{contentStyles}</style>
166
+ <Shell
167
+ config={config}
168
+ navigation={navigation}
169
+ currentPageId={currentPageId}
170
+ pageHtml={!pageData?.isMdx ? (loading ? "<p>Loading...</p>" : pageData?.html || "<p>Page not found</p>") : undefined}
171
+ pageComponent={pageData?.isMdx ? pageData.component : undefined}
172
+ mdxComponents={MDX_COMPONENTS}
173
+ pageTitle={pageData?.frontmatter.title || (loading ? "Loading..." : "Not Found")}
174
+ pageDescription={pageData?.frontmatter.description}
175
+ headings={pageData?.headings || []}
176
+ onNavigate={navigateTo}
177
+ allPages={allPages}
178
+ docContext={docContext}
179
+ />
180
+ </>
181
+ );
182
+ }
183
+
184
+ // ── MOUNT ────────────────────────────────────────────────
185
+ const container = document.getElementById("tome-root");
186
+ if (container) {
187
+ const root = createRoot(container);
188
+ root.render(<App />);
189
+ }
190
+
191
+ export default App;
@@ -0,0 +1,22 @@
1
+ declare const __TOME_AI_API_KEY__: string | undefined;
2
+
3
+ declare module "virtual:tome/config" {
4
+ const config: any;
5
+ export default config;
6
+ }
7
+
8
+ declare module "virtual:tome/routes" {
9
+ export const routes: any[];
10
+ export const navigation: any[];
11
+ export const versions: any;
12
+ export const i18n: any;
13
+ }
14
+
15
+ declare module "virtual:tome/page-loader" {
16
+ export default function loadPageModule(id: string): Promise<any>;
17
+ }
18
+
19
+ declare module "virtual:tome/doc-context" {
20
+ const docContext: Array<{ id: string; title: string; content: string }>;
21
+ export default docContext;
22
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,6 @@
1
+ export { Shell } from "./Shell.js";
2
+ export { AiChat } from "./AiChat.js";
3
+ export type { AiChatProps } from "./AiChat.js";
4
+ export { default as App } from "./entry.js";
5
+ export { THEME_PRESETS } from "./presets.js";
6
+ export type { ThemeTokens, ThemePreset, PresetName } from "./presets.js";
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { THEME_PRESETS } from "./presets.js";
3
+ import type { PresetName } from "./presets.js";
4
+
5
+ const TOKEN_KEYS = [
6
+ "bg", "sf", "sfH", "bd",
7
+ "tx", "tx2", "txM",
8
+ "ac", "acD", "acT",
9
+ "cdBg", "cdTx", "sbBg", "hdBg",
10
+ ] as const;
11
+
12
+ const FONT_KEYS = ["heading", "body", "code"] as const;
13
+
14
+ const PRESET_NAMES: PresetName[] = ["amber", "editorial"];
15
+
16
+ describe("THEME_PRESETS", () => {
17
+ it("contains both amber and editorial presets", () => {
18
+ expect(THEME_PRESETS).toHaveProperty("amber");
19
+ expect(THEME_PRESETS).toHaveProperty("editorial");
20
+ });
21
+
22
+ for (const name of PRESET_NAMES) {
23
+ describe(`${name} preset`, () => {
24
+ it("has dark and light token sets", () => {
25
+ expect(THEME_PRESETS[name]).toHaveProperty("dark");
26
+ expect(THEME_PRESETS[name]).toHaveProperty("light");
27
+ });
28
+
29
+ for (const mode of ["dark", "light"] as const) {
30
+ describe(`${mode} tokens`, () => {
31
+ it(`has all 14 token properties`, () => {
32
+ const tokens = THEME_PRESETS[name][mode];
33
+ for (const key of TOKEN_KEYS) {
34
+ expect(tokens).toHaveProperty(key);
35
+ expect(typeof tokens[key]).toBe("string");
36
+ expect(tokens[key].length).toBeGreaterThan(0);
37
+ }
38
+ });
39
+
40
+ it("has exactly 14 token properties", () => {
41
+ const tokens = THEME_PRESETS[name][mode];
42
+ expect(Object.keys(tokens)).toHaveLength(14);
43
+ });
44
+ });
45
+ }
46
+
47
+ describe("fonts", () => {
48
+ it("has heading, body, and code font families defined", () => {
49
+ const fonts = THEME_PRESETS[name].fonts;
50
+ for (const key of FONT_KEYS) {
51
+ expect(fonts).toHaveProperty(key);
52
+ expect(typeof fonts[key]).toBe("string");
53
+ expect(fonts[key].length).toBeGreaterThan(0);
54
+ }
55
+ });
56
+ });
57
+ });
58
+ }
59
+ });
package/src/presets.ts ADDED
@@ -0,0 +1,51 @@
1
+ // ── Theme Preset Types ────────────────────────────────────
2
+
3
+ export interface ThemeTokens {
4
+ bg: string; sf: string; sfH: string; bd: string;
5
+ tx: string; tx2: string; txM: string;
6
+ ac: string; acD: string; acT: string;
7
+ cdBg: string; cdTx: string; sbBg: string; hdBg: string;
8
+ }
9
+
10
+ export interface ThemePreset {
11
+ dark: ThemeTokens;
12
+ light: ThemeTokens;
13
+ fonts: { heading: string; body: string; code: string };
14
+ }
15
+
16
+ // ── Theme Presets ─────────────────────────────────────────
17
+
18
+ export const THEME_PRESETS = {
19
+ amber: {
20
+ dark: {
21
+ bg:"#09090b",sf:"#111114",sfH:"#18181c",bd:"#1e1e24",
22
+ tx:"#e4e4e7",tx2:"#a1a1aa",txM:"#919199",
23
+ ac:"#e8a845",acD:"rgba(232,168,69,0.12)",acT:"#fbbf24",
24
+ cdBg:"#0c0c0f",cdTx:"#c4c4cc",sbBg:"#0c0c0e",hdBg:"rgba(9,9,11,0.85)",
25
+ },
26
+ light: {
27
+ bg:"#fafaf9",sf:"#ffffff",sfH:"#f5f5f4",bd:"#e7e5e4",
28
+ tx:"#1c1917",tx2:"#57534e",txM:"#706b66",
29
+ ac:"#96640a",acD:"rgba(150,100,10,0.08)",acT:"#7a5208",
30
+ cdBg:"#f5f3f0",cdTx:"#2c2520",sbBg:"#f5f5f3",hdBg:"rgba(250,250,249,0.85)",
31
+ },
32
+ fonts: { heading: "Instrument Serif", body: "DM Sans", code: "JetBrains Mono" },
33
+ },
34
+ editorial: {
35
+ dark: {
36
+ bg:"#080c1f",sf:"#0e1333",sfH:"#141940",bd:"#1a2050",
37
+ tx:"#e8e6f0",tx2:"#b5b1c8",txM:"#9490ae",
38
+ ac:"#ff6b4a",acD:"rgba(255,107,74,0.1)",acT:"#ff8a70",
39
+ cdBg:"#0a0e27",cdTx:"#b8b4cc",sbBg:"#0a0e27",hdBg:"rgba(8,12,31,0.9)",
40
+ },
41
+ light: {
42
+ bg:"#f6f4f0",sf:"#ffffff",sfH:"#eeece6",bd:"#ddd9d0",
43
+ tx:"#1a1716",tx2:"#4a443e",txM:"#706960",
44
+ ac:"#b83d22",acD:"rgba(184,61,34,0.07)",acT:"#9c3019",
45
+ cdBg:"#edeae4",cdTx:"#3a3530",sbBg:"#f0ede8",hdBg:"rgba(246,244,240,0.92)",
46
+ },
47
+ fonts: { heading: "Cormorant Garamond", body: "Bricolage Grotesque", code: "Fira Code" },
48
+ },
49
+ } as const satisfies Record<string, ThemePreset>;
50
+
51
+ export type PresetName = keyof typeof THEME_PRESETS;
@@ -0,0 +1,5 @@
1
+ import "@testing-library/jest-dom";
2
+
3
+ // jsdom stubs
4
+ Element.prototype.scrollTo = () => {};
5
+ Element.prototype.scrollIntoView = () => {};
@@ -0,0 +1,14 @@
1
+ declare module "virtual:tome/config" {
2
+ const config: import("@tomehq/core").TomeConfig;
3
+ export default config;
4
+ }
5
+
6
+ declare module "virtual:tome/routes" {
7
+ export const routes: Array<{
8
+ id: string;
9
+ urlPath: string;
10
+ frontmatter: import("@tomehq/core").PageFrontmatter;
11
+ isMdx: boolean;
12
+ }>;
13
+ export const navigation: import("@tomehq/core").NavigationGroup[];
14
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
5
+ }
6
+ }
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import { resolve } from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
6
+
7
+ export default defineConfig({
8
+ root: __dirname,
9
+ test: {
10
+ name: "theme",
11
+ environment: "jsdom",
12
+ globals: true,
13
+ include: ["src/**/*.test.tsx", "src/**/*.test.ts"],
14
+ setupFiles: [resolve(__dirname, "src/test-setup.ts")],
15
+ coverage: {
16
+ provider: "v8",
17
+ include: ["src/**/*.tsx", "src/**/*.ts"],
18
+ exclude: ["src/**/*.test.*", "src/test-setup.ts", "src/virtual.d.ts"],
19
+ },
20
+ },
21
+ });