mosage 0.1.0 → 0.8.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.
Files changed (146) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +16 -217
  3. package/bin.js +2 -0
  4. package/dist/build-C7NW_3Pk.js +14 -0
  5. package/dist/check-CP4873Wx.js +41 -0
  6. package/dist/cli/bin.d.ts +1 -0
  7. package/dist/cli/bin.js +228 -0
  8. package/dist/config-DPm1BBAb.js +2619 -0
  9. package/dist/config-TlTe7Ona.d.ts +24 -0
  10. package/dist/context-BqsdSrAQ.js +1084 -0
  11. package/dist/dev-Biz42qlu.js +17 -0
  12. package/dist/diagram-xlVDekYk.js +763 -0
  13. package/dist/export-Bi6nuxjT.js +31 -0
  14. package/dist/import-D2jNB07F.js +25 -0
  15. package/dist/index.d.ts +455 -0
  16. package/dist/index.js +693 -0
  17. package/dist/init-Bbtj2pxF.js +262 -0
  18. package/dist/preview-CLm51aRt.js +19 -0
  19. package/dist/sdk-DjpX6mCv.js +51 -0
  20. package/dist/vite/index.d.ts +25 -0
  21. package/dist/vite/index.js +2 -0
  22. package/env.d.ts +83 -0
  23. package/package.json +84 -64
  24. package/skills/apply-comments/SKILL.md +43 -56
  25. package/skills/create-doc/SKILL.md +106 -0
  26. package/skills/create-theme/SKILL.md +184 -0
  27. package/skills/current-doc/SKILL.md +120 -0
  28. package/skills/doc-authoring/SKILL.md +434 -0
  29. package/skills/doc-authoring/references/assets.md +47 -0
  30. package/skills/doc-authoring/references/design-system.md +81 -0
  31. package/skills/doc-authoring/references/long-form.md +131 -0
  32. package/skills/doc-authoring/references/pagination.md +118 -0
  33. package/skills/doc-authoring/references/tables-and-charts.md +161 -0
  34. package/src/app/app.tsx +42 -0
  35. package/src/app/components/data-table.tsx +196 -0
  36. package/src/app/components/design-panel/design-panel.tsx +318 -0
  37. package/src/app/components/design-panel/design-provider.tsx +121 -0
  38. package/src/app/components/design-panel/use-design.ts +85 -0
  39. package/src/app/components/diagram.tsx +76 -0
  40. package/src/app/components/doc-assets.tsx +129 -0
  41. package/src/app/components/doc-search.tsx +248 -0
  42. package/src/app/components/doc-sidebar.tsx +162 -0
  43. package/src/app/components/flow-page.tsx +93 -0
  44. package/src/app/components/footnote.tsx +204 -0
  45. package/src/app/components/image-placeholder.tsx +50 -0
  46. package/src/app/components/inspector/inspector.tsx +518 -0
  47. package/src/app/components/numbering.tsx +224 -0
  48. package/src/app/components/page-frame.tsx +70 -0
  49. package/src/app/components/sidebar/folder-item.tsx +212 -0
  50. package/src/app/components/sidebar/icon-picker.tsx +99 -0
  51. package/src/app/components/sidebar/sidebar.tsx +252 -0
  52. package/src/app/components/table-of-contents.tsx +93 -0
  53. package/src/app/components/theme-toggle.tsx +50 -0
  54. package/src/app/components/themes/markdown.tsx +249 -0
  55. package/src/app/components/themes/theme-preview.tsx +74 -0
  56. package/src/app/components/ui/menu.tsx +143 -0
  57. package/src/app/index.html +12 -0
  58. package/src/app/lib/agent-bridge.ts +140 -0
  59. package/src/app/lib/assets.ts +151 -0
  60. package/src/app/lib/design-presets.ts +109 -0
  61. package/src/app/lib/design.ts +88 -0
  62. package/src/app/lib/diagnostics.ts +282 -0
  63. package/src/app/lib/doc-preview.tsx +29 -0
  64. package/src/app/lib/docs.ts +26 -0
  65. package/src/app/lib/docx/extract.ts +1623 -0
  66. package/src/app/lib/docx/fonts.test.ts +136 -0
  67. package/src/app/lib/docx/fonts.ts +166 -0
  68. package/src/app/lib/docx/media.ts +102 -0
  69. package/src/app/lib/docx/model.ts +206 -0
  70. package/src/app/lib/docx/paragraph.test.ts +92 -0
  71. package/src/app/lib/docx/paragraph.ts +107 -0
  72. package/src/app/lib/docx/props.ts +187 -0
  73. package/src/app/lib/docx/styles.ts +306 -0
  74. package/src/app/lib/docx/units.ts +35 -0
  75. package/src/app/lib/docx/write.test.ts +507 -0
  76. package/src/app/lib/docx/write.ts +581 -0
  77. package/src/app/lib/docx/xml.ts +39 -0
  78. package/src/app/lib/export-docx.ts +289 -0
  79. package/src/app/lib/export-dom.ts +318 -0
  80. package/src/app/lib/export-html.ts +156 -0
  81. package/src/app/lib/export-image.ts +70 -0
  82. package/src/app/lib/export-pdf.ts +165 -0
  83. package/src/app/lib/flow-measure.test.ts +31 -0
  84. package/src/app/lib/flow-measure.ts +183 -0
  85. package/src/app/lib/flow.test.ts +110 -0
  86. package/src/app/lib/flow.ts +136 -0
  87. package/src/app/lib/folders.ts +192 -0
  88. package/src/app/lib/footnotes.test.tsx +102 -0
  89. package/src/app/lib/footnotes.ts +94 -0
  90. package/src/app/lib/inspector/fiber.ts +99 -0
  91. package/src/app/lib/labels.test.ts +18 -0
  92. package/src/app/lib/labels.ts +181 -0
  93. package/src/app/lib/outline.ts +118 -0
  94. package/src/app/lib/page-context.tsx +43 -0
  95. package/src/app/lib/page-range.test.ts +95 -0
  96. package/src/app/lib/page-range.ts +90 -0
  97. package/src/app/lib/print-ready.ts +69 -0
  98. package/src/app/lib/rasterize.ts +173 -0
  99. package/src/app/lib/scan.ts +26 -0
  100. package/src/app/lib/sdk.test.ts +32 -0
  101. package/src/app/lib/sdk.ts +115 -0
  102. package/src/app/lib/themes.ts +31 -0
  103. package/src/app/lib/use-doc-module.ts +53 -0
  104. package/src/app/lib/use-doc-pages.ts +147 -0
  105. package/src/app/lib/utils.ts +6 -0
  106. package/src/app/lib/view-mode.test.ts +91 -0
  107. package/src/app/lib/view-mode.ts +104 -0
  108. package/src/app/main.tsx +14 -0
  109. package/src/app/routes/assets.tsx +257 -0
  110. package/src/app/routes/doc.tsx +877 -0
  111. package/src/app/routes/home-shell.tsx +203 -0
  112. package/src/app/routes/home.tsx +269 -0
  113. package/src/app/routes/themes.tsx +121 -0
  114. package/src/app/styles.css +97 -0
  115. package/src/app/virtual.d.ts +30 -0
  116. package/template/AGENTS.md +27 -0
  117. package/template/README.md +39 -0
  118. package/template/docs/getting-started/index.tsx +230 -0
  119. package/template/mosage.config.ts +5 -0
  120. package/template/package.json +24 -0
  121. package/template/tsconfig.json +17 -0
  122. package/README.en.md +0 -57
  123. package/dist/cli.js +0 -4545
  124. package/dist/web/assets/index-Czg2WeHe.js +0 -182
  125. package/dist/web/assets/index-DKAyt92W.css +0 -1
  126. package/dist/web/index.html +0 -15
  127. package/skills/current-position/SKILL.md +0 -65
  128. package/skills/kickoff/SKILL.md +0 -105
  129. package/skills/mosage-reference/SKILL.md +0 -164
  130. package/skills/outline/SKILL.md +0 -79
  131. package/skills/review/SKILL.md +0 -64
  132. package/skills/write-chapter/SKILL.md +0 -58
  133. package/template/book/STYLE.md +0 -6
  134. package/template/book/assets/.gitkeep +0 -0
  135. package/template/book/book.yaml +0 -42
  136. package/template/book/brief.md +0 -6
  137. package/template/book/chapters/.gitkeep +0 -0
  138. package/template/book/notes/README.md +0 -7
  139. package/template/project/AGENTS.md +0 -82
  140. package/template/project/CLAUDE.md +0 -1
  141. package/template/project/README.md +0 -48
  142. package/template/project/books/.gitkeep +0 -0
  143. package/template/project/gitignore +0 -5
  144. package/template/project/mosage.yaml +0 -23
  145. package/template/project/notes/README.md +0 -8
  146. package/template/project/package.json +0 -14
@@ -0,0 +1,203 @@
1
+ import { Menu as MenuIcon } from 'lucide-react';
2
+ import { useCallback, useMemo } from 'react';
3
+ import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
4
+ import {
5
+ ALL_DOCS_ID,
6
+ ASSETS_ID,
7
+ DRAFT_ID,
8
+ Sidebar,
9
+ THEMES_ID,
10
+ } from '../components/sidebar/sidebar';
11
+ import { ThemeToggle } from '../components/theme-toggle';
12
+ import { Menu, MenuItem } from '../components/ui/menu';
13
+ import { useAssetCount } from '../lib/assets';
14
+ import { docIds } from '../lib/docs';
15
+ import { useFolders } from '../lib/folders';
16
+ import type { FoldersManifest } from '../lib/sdk';
17
+ import { themes } from '../lib/themes';
18
+
19
+ export type HomeOutletContext = {
20
+ manifest: FoldersManifest;
21
+ loading: boolean;
22
+ /** Documents with no folder assignment. */
23
+ draftDocs: string[];
24
+ docsByFolder: Record<string, string[]>;
25
+ /** ALL_DOCS_ID, DRAFT_ID, a folder id, THEMES_ID, or ASSETS_ID. */
26
+ selectedId: string;
27
+ selectFolder: (id: string) => void;
28
+ assign: (docId: string, folderId: string | null) => Promise<void>;
29
+ renameDoc: (docId: string, title: string) => Promise<void>;
30
+ duplicateDoc: (docId: string, newId?: string) => Promise<string>;
31
+ deleteDoc: (docId: string) => Promise<void>;
32
+ };
33
+
34
+ function pathToSelectedId(pathname: string, search: URLSearchParams): string {
35
+ if (pathname === '/themes' || pathname.startsWith('/themes/')) return THEMES_ID;
36
+ if (pathname === '/assets') return ASSETS_ID;
37
+ return search.get('f') ?? ALL_DOCS_ID;
38
+ }
39
+
40
+ export function HomeShell() {
41
+ const {
42
+ manifest,
43
+ loading,
44
+ create,
45
+ update,
46
+ remove,
47
+ reorder,
48
+ assign,
49
+ renameDoc,
50
+ duplicateDoc,
51
+ deleteDoc,
52
+ } = useFolders();
53
+ const navigate = useNavigate();
54
+ const location = useLocation();
55
+ const [searchParams] = useSearchParams();
56
+ const assetCount = useAssetCount();
57
+
58
+ const selectedId = pathToSelectedId(location.pathname, searchParams);
59
+
60
+ const selectFolder = useCallback(
61
+ (id: string) => {
62
+ if (id === THEMES_ID) navigate('/themes');
63
+ else if (id === ASSETS_ID) navigate('/assets');
64
+ else if (id === ALL_DOCS_ID) navigate('/');
65
+ else navigate(`/?f=${encodeURIComponent(id)}`);
66
+ },
67
+ [navigate],
68
+ );
69
+
70
+ const { draftDocs, docsByFolder } = useMemo(() => {
71
+ const byFolder: Record<string, string[]> = {};
72
+ const draft: string[] = [];
73
+ const known = new Set(manifest.folders.map((f) => f.id));
74
+ for (const id of docIds) {
75
+ const folderId = manifest.assignments[id];
76
+ if (folderId && known.has(folderId)) {
77
+ byFolder[folderId] ??= [];
78
+ byFolder[folderId].push(id);
79
+ } else {
80
+ draft.push(id);
81
+ }
82
+ }
83
+ return { draftDocs: draft, docsByFolder: byFolder };
84
+ }, [manifest]);
85
+
86
+ const countFor = (folderId: string | null) =>
87
+ folderId === null ? draftDocs.length : (docsByFolder[folderId]?.length ?? 0);
88
+
89
+ const ctx: HomeOutletContext = {
90
+ manifest,
91
+ loading,
92
+ draftDocs,
93
+ docsByFolder,
94
+ selectedId,
95
+ selectFolder,
96
+ assign,
97
+ renameDoc,
98
+ duplicateDoc,
99
+ deleteDoc,
100
+ };
101
+
102
+ const isAssetsRoute = location.pathname === '/assets';
103
+
104
+ return (
105
+ <div className="flex h-dvh overflow-hidden bg-background text-foreground">
106
+ <div className="hidden md:block">
107
+ <Sidebar
108
+ folders={manifest.folders}
109
+ countFor={countFor}
110
+ allCount={docIds.length}
111
+ themesCount={themes.length}
112
+ assetsCount={assetCount}
113
+ selectedId={selectedId}
114
+ onSelect={selectFolder}
115
+ onCreate={create}
116
+ onRename={(id, name) => update(id, { name })}
117
+ onChangeIcon={(id, icon) => update(id, { icon })}
118
+ onDelete={async (id) => {
119
+ if (selectedId === id) selectFolder(ALL_DOCS_ID);
120
+ await remove(id).catch(() => {});
121
+ }}
122
+ onDropToFolder={(folderId, docId) => void assign(docId, folderId)}
123
+ onDropToDraft={(docId) => void assign(docId, null)}
124
+ onReorder={(ids) => void reorder(ids).catch(() => {})}
125
+ />
126
+ </div>
127
+
128
+ <div className="relative flex min-w-0 flex-1 flex-col overflow-y-auto bg-canvas">
129
+ <div className="flex items-center justify-between border-border border-b bg-background px-4 py-3 md:hidden">
130
+ <h1 className="font-semibold text-base tracking-tight">MoSage</h1>
131
+ <div className="-mr-1 flex items-center gap-0.5">
132
+ <ThemeToggle />
133
+ <Menu
134
+ trigger={(props) => (
135
+ <button
136
+ type="button"
137
+ aria-label="Menu"
138
+ className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground aria-expanded:bg-accent"
139
+ {...props}
140
+ >
141
+ <MenuIcon className="size-4" />
142
+ </button>
143
+ )}
144
+ >
145
+ {(close) => (
146
+ <>
147
+ <MenuItem
148
+ active={selectedId !== THEMES_ID && selectedId !== ASSETS_ID}
149
+ onClick={() => {
150
+ selectFolder(ALL_DOCS_ID);
151
+ close();
152
+ }}
153
+ >
154
+ Documents
155
+ </MenuItem>
156
+ <MenuItem
157
+ active={selectedId === DRAFT_ID}
158
+ onClick={() => {
159
+ selectFolder(DRAFT_ID);
160
+ close();
161
+ }}
162
+ >
163
+ Unfiled
164
+ </MenuItem>
165
+ <MenuItem
166
+ active={selectedId === THEMES_ID}
167
+ onClick={() => {
168
+ selectFolder(THEMES_ID);
169
+ close();
170
+ }}
171
+ >
172
+ Themes
173
+ </MenuItem>
174
+ {import.meta.env.DEV && (
175
+ <MenuItem
176
+ active={selectedId === ASSETS_ID}
177
+ onClick={() => {
178
+ selectFolder(ASSETS_ID);
179
+ close();
180
+ }}
181
+ >
182
+ Assets
183
+ </MenuItem>
184
+ )}
185
+ </>
186
+ )}
187
+ </Menu>
188
+ </div>
189
+ </div>
190
+
191
+ <div
192
+ className={
193
+ isAssetsRoute
194
+ ? 'flex min-h-0 flex-1 flex-col'
195
+ : 'mx-auto w-full max-w-[1180px] px-5 py-8 md:px-10 md:py-10'
196
+ }
197
+ >
198
+ <Outlet context={ctx} />
199
+ </div>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,269 @@
1
+ import {
2
+ Copy,
3
+ FileText,
4
+ FolderInput,
5
+ MoreHorizontal,
6
+ Palette,
7
+ PencilLine,
8
+ Trash2,
9
+ } from 'lucide-react';
10
+ import { useMemo, useState } from 'react';
11
+ import { Link, useNavigate, useOutletContext } from 'react-router-dom';
12
+ import { PageFrame } from '../components/page-frame';
13
+ import { DOC_DND_MIME } from '../components/sidebar/folder-item';
14
+ import { ALL_DOCS_ID, DRAFT_ID } from '../components/sidebar/sidebar';
15
+ import { Menu, MenuItem, MenuSeparator } from '../components/ui/menu';
16
+ import { coverContent, pageCountLabel } from '../lib/doc-preview';
17
+ import { docCreatedAt, docIds, docThemes } from '../lib/docs';
18
+ import { resolvePageGeometry } from '../lib/sdk';
19
+ import { findTheme } from '../lib/themes';
20
+ import { useDocModule } from '../lib/use-doc-module';
21
+ import type { HomeOutletContext } from './home-shell';
22
+
23
+ const THUMB_WIDTH = 190;
24
+
25
+ export function Home() {
26
+ const ctx = useOutletContext<HomeOutletContext>();
27
+ const [error, setError] = useState<string | null>(null);
28
+
29
+ const folder = ctx.manifest.folders.find((f) => f.id === ctx.selectedId);
30
+ const visibleIds = useMemo(() => {
31
+ const source =
32
+ ctx.selectedId === ALL_DOCS_ID
33
+ ? [...docIds]
34
+ : ctx.selectedId === DRAFT_ID
35
+ ? [...ctx.draftDocs]
36
+ : [...(ctx.docsByFolder[ctx.selectedId] ?? [])];
37
+ return source.sort((a, b) => {
38
+ const at = docCreatedAt[a] ?? 0;
39
+ const bt = docCreatedAt[b] ?? 0;
40
+ if (at !== bt) return bt - at;
41
+ return a.localeCompare(b);
42
+ });
43
+ }, [ctx.selectedId, ctx.draftDocs, ctx.docsByFolder]);
44
+
45
+ const heading =
46
+ ctx.selectedId === ALL_DOCS_ID
47
+ ? 'Documents'
48
+ : ctx.selectedId === DRAFT_ID
49
+ ? 'Unfiled'
50
+ : (folder?.name ?? 'Documents');
51
+
52
+ return (
53
+ <div>
54
+ <header className="mb-8">
55
+ <h1 className="font-medium text-lg tracking-tight">{heading}</h1>
56
+ <p className="mt-1 text-muted-foreground text-sm">
57
+ {ctx.selectedId === ALL_DOCS_ID ? (
58
+ <>
59
+ Every folder under <code className="font-mono">docs/</code> with an{' '}
60
+ <code className="font-mono">index.tsx</code>.
61
+ </>
62
+ ) : ctx.selectedId === DRAFT_ID ? (
63
+ 'Documents that have not been filed into a folder yet.'
64
+ ) : (
65
+ `${visibleIds.length} document${visibleIds.length === 1 ? '' : 's'} in this folder.`
66
+ )}
67
+ </p>
68
+ {error && (
69
+ <p className="mt-3 rounded-md border border-border bg-background px-3 py-2 text-xs">
70
+ {error}
71
+ </p>
72
+ )}
73
+ </header>
74
+
75
+ {visibleIds.length === 0 ? (
76
+ <div className="py-16 text-center">
77
+ <FileText className="mx-auto size-6 text-muted-foreground" />
78
+ <p className="mt-3 font-medium text-sm">Nothing here yet</p>
79
+ <p className="mt-1 text-muted-foreground text-xs">
80
+ {ctx.selectedId === ALL_DOCS_ID ? (
81
+ <>
82
+ Create <code className="font-mono">docs/&lt;id&gt;/index.tsx</code> and it appears
83
+ here.
84
+ </>
85
+ ) : (
86
+ 'Drag a document onto this folder in the sidebar to file it here.'
87
+ )}
88
+ </p>
89
+ </div>
90
+ ) : (
91
+ <div className="grid gap-7 [grid-template-columns:repeat(auto-fill,minmax(190px,1fr))]">
92
+ {visibleIds.map((id) => (
93
+ <DocCard key={id} docId={id} ctx={ctx} onError={setError} />
94
+ ))}
95
+ </div>
96
+ )}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ function DocCard({
102
+ docId,
103
+ ctx,
104
+ onError,
105
+ }: {
106
+ docId: string;
107
+ ctx: HomeOutletContext;
108
+ onError: (message: string | null) => void;
109
+ }) {
110
+ const navigate = useNavigate();
111
+ const state = useDocModule(docId);
112
+ const doc = state.doc;
113
+ const geometry = resolvePageGeometry(doc?.meta);
114
+ const scale = THUMB_WIDTH / geometry.width;
115
+ const cover = coverContent(doc);
116
+ const theme = findTheme(docThemes[docId]);
117
+ const title = doc?.meta?.title ?? docId;
118
+ const currentFolder = ctx.manifest.assignments[docId];
119
+
120
+ const run = async (action: () => Promise<unknown>) => {
121
+ onError(null);
122
+ try {
123
+ await action();
124
+ } catch (err) {
125
+ onError(String((err as Error).message));
126
+ }
127
+ };
128
+
129
+ return (
130
+ // biome-ignore lint/a11y/noStaticElementInteractions: dragging files a document — the card menu's "Move to" is the keyboard path
131
+ <div
132
+ className="group flex flex-col gap-2.5"
133
+ draggable={import.meta.env.DEV}
134
+ onDragStart={(e) => {
135
+ if (!import.meta.env.DEV) return;
136
+ e.dataTransfer.setData(DOC_DND_MIME, docId);
137
+ e.dataTransfer.effectAllowed = 'move';
138
+ }}
139
+ >
140
+ <Link to={`/d/${docId}`}>
141
+ <div
142
+ className="overflow-hidden rounded-md ring-1 ring-border transition-shadow group-hover:shadow-lg"
143
+ style={{ width: THUMB_WIDTH, height: geometry.height * scale }}
144
+ >
145
+ {cover ? (
146
+ <PageFrame
147
+ index={0}
148
+ total={doc?.default?.length ?? 1}
149
+ geometry={geometry}
150
+ scale={scale}
151
+ design={doc?.design}
152
+ flat
153
+ >
154
+ {cover}
155
+ </PageFrame>
156
+ ) : (
157
+ <div className="size-full bg-muted" />
158
+ )}
159
+ </div>
160
+ </Link>
161
+
162
+ <div className="flex min-w-0 items-start gap-1">
163
+ <div className="min-w-0 flex-1">
164
+ <Link to={`/d/${docId}`} className="block truncate font-medium text-sm hover:underline">
165
+ {title}
166
+ </Link>
167
+ <p className="mt-0.5 truncate text-muted-foreground text-xs">
168
+ {pageCountLabel(doc)} · {doc?.meta?.pageSize ?? 'A4'}
169
+ </p>
170
+ {theme && (
171
+ <Link
172
+ to={`/themes/${theme.id}`}
173
+ className="mt-1.5 inline-flex items-center gap-1 rounded-full border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
174
+ >
175
+ <Palette className="size-2.5" />
176
+ {theme.name}
177
+ </Link>
178
+ )}
179
+ </div>
180
+
181
+ {import.meta.env.DEV && (
182
+ <Menu
183
+ trigger={(props) => (
184
+ <button
185
+ type="button"
186
+ aria-label={`${title} options`}
187
+ className="flex size-6 flex-none items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 aria-expanded:opacity-100"
188
+ {...props}
189
+ >
190
+ <MoreHorizontal className="size-3.5" />
191
+ </button>
192
+ )}
193
+ >
194
+ {(close) => (
195
+ <>
196
+ <MenuItem
197
+ onClick={() => {
198
+ close();
199
+ const next = window.prompt('Document title', title);
200
+ if (next && next !== title) void run(() => ctx.renameDoc(docId, next));
201
+ }}
202
+ >
203
+ <PencilLine className="size-3.5" />
204
+ Rename
205
+ </MenuItem>
206
+ <MenuItem
207
+ onClick={() => {
208
+ close();
209
+ void run(async () => {
210
+ const newId = await ctx.duplicateDoc(docId);
211
+ navigate(`/d/${newId}`);
212
+ });
213
+ }}
214
+ >
215
+ <Copy className="size-3.5" />
216
+ Duplicate
217
+ </MenuItem>
218
+
219
+ <MenuSeparator />
220
+ <p className="px-2 py-1 text-[10px] text-muted-foreground uppercase tracking-wider">
221
+ Move to
222
+ </p>
223
+ <MenuItem
224
+ active={!currentFolder}
225
+ onClick={() => {
226
+ close();
227
+ void run(() => ctx.assign(docId, null));
228
+ }}
229
+ >
230
+ <FolderInput className="size-3.5" />
231
+ Unfiled
232
+ </MenuItem>
233
+ {ctx.manifest.folders.map((folder) => (
234
+ <MenuItem
235
+ key={folder.id}
236
+ active={currentFolder === folder.id}
237
+ onClick={() => {
238
+ close();
239
+ void run(() => ctx.assign(docId, folder.id));
240
+ }}
241
+ >
242
+ <FolderInput className="size-3.5" />
243
+ {folder.name}
244
+ </MenuItem>
245
+ ))}
246
+
247
+ <MenuSeparator />
248
+ <MenuItem
249
+ destructive
250
+ onClick={() => {
251
+ close();
252
+ if (
253
+ !window.confirm(`Delete "${title}"? This removes docs/${docId}/ from disk.`)
254
+ )
255
+ return;
256
+ void run(() => ctx.deleteDoc(docId));
257
+ }}
258
+ >
259
+ <Trash2 className="size-3.5" />
260
+ Delete document
261
+ </MenuItem>
262
+ </>
263
+ )}
264
+ </Menu>
265
+ )}
266
+ </div>
267
+ </div>
268
+ );
269
+ }
@@ -0,0 +1,121 @@
1
+ import { ArrowLeft, Palette } from 'lucide-react';
2
+ import { Link, useParams } from 'react-router-dom';
3
+ import { Markdown } from '../components/themes/markdown';
4
+ import { ThemePreview } from '../components/themes/theme-preview';
5
+ import { docsByTheme } from '../lib/docs';
6
+ import { findTheme, themes } from '../lib/themes';
7
+
8
+ const GALLERY_WIDTH = 200;
9
+ const DETAIL_WIDTH = 260;
10
+
11
+ export function ThemesGalleryPage() {
12
+ return (
13
+ <div>
14
+ <header className="border-border border-b px-8 py-6">
15
+ <h1 className="font-medium text-lg tracking-tight">Themes</h1>
16
+ <p className="mt-1 text-muted-foreground text-sm">
17
+ Every <code className="font-mono">.md</code> file under{' '}
18
+ <code className="font-mono">themes/</code>. A theme is documentation — palette, type
19
+ scale, and paste-ready components a document copies from.
20
+ </p>
21
+ </header>
22
+
23
+ {themes.length === 0 ? (
24
+ <div className="px-8 py-16 text-center">
25
+ <Palette className="mx-auto size-6 text-muted-foreground" />
26
+ <p className="mt-3 font-medium text-sm">No themes yet</p>
27
+ <p className="mt-1 text-muted-foreground text-xs">
28
+ Ask your agent for the <code className="font-mono">create-theme</code> skill, or add{' '}
29
+ <code className="font-mono">themes/&lt;id&gt;.md</code>.
30
+ </p>
31
+ </div>
32
+ ) : (
33
+ <div className="grid gap-8 px-8 py-8 [grid-template-columns:repeat(auto-fill,minmax(200px,1fr))]">
34
+ {themes.map((theme) => (
35
+ <Link key={theme.id} to={`/themes/${theme.id}`} className="group flex flex-col gap-3">
36
+ <div className="transition-shadow group-hover:shadow-lg">
37
+ <ThemePreview theme={theme} width={GALLERY_WIDTH} />
38
+ </div>
39
+ <div className="min-w-0">
40
+ <p className="truncate font-medium text-sm">{theme.name}</p>
41
+ <p className="mt-0.5 line-clamp-2 text-muted-foreground text-xs">
42
+ {theme.description || theme.id}
43
+ </p>
44
+ </div>
45
+ </Link>
46
+ ))}
47
+ </div>
48
+ )}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ export function ThemeDetailPage() {
54
+ const { themeId } = useParams<{ themeId: string }>();
55
+ const theme = findTheme(themeId);
56
+
57
+ if (!theme) {
58
+ return (
59
+ <div className="px-8 py-16 text-center">
60
+ <p className="font-medium text-sm">Theme “{themeId}” not found.</p>
61
+ <Link to="/themes" className="mt-3 inline-block text-muted-foreground text-xs underline">
62
+ Back to themes
63
+ </Link>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ const usedBy = docsByTheme(theme.id);
69
+ const chips = [theme.pageSize, theme.mode].filter(Boolean);
70
+
71
+ return (
72
+ <div className="px-8 py-6">
73
+ <Link
74
+ to="/themes"
75
+ className="inline-flex items-center gap-1.5 text-muted-foreground text-xs hover:text-foreground"
76
+ >
77
+ <ArrowLeft className="size-3" />
78
+ Themes
79
+ </Link>
80
+
81
+ <div className="mt-4 flex flex-wrap items-baseline gap-3">
82
+ <h1 className="font-medium text-lg tracking-tight">{theme.name}</h1>
83
+ <code className="font-mono text-muted-foreground text-xs">{theme.id}</code>
84
+ {chips.map((chip) => (
85
+ <span
86
+ key={chip}
87
+ className="rounded-full border border-border px-2 py-0.5 text-[10px] text-muted-foreground"
88
+ >
89
+ {chip}
90
+ </span>
91
+ ))}
92
+ </div>
93
+ {theme.description && (
94
+ <p className="mt-1 max-w-2xl text-muted-foreground text-sm">{theme.description}</p>
95
+ )}
96
+
97
+ <div className="mt-6">
98
+ <ThemePreview theme={theme} width={DETAIL_WIDTH} all />
99
+ </div>
100
+
101
+ {usedBy.length > 0 && (
102
+ <div className="mt-6 flex flex-wrap items-center gap-2">
103
+ <span className="text-muted-foreground text-xs">Used by</span>
104
+ {usedBy.map((docId) => (
105
+ <Link
106
+ key={docId}
107
+ to={`/d/${docId}`}
108
+ className="rounded-full border border-border px-2 py-0.5 text-[11px] transition-colors hover:bg-accent"
109
+ >
110
+ {docId}
111
+ </Link>
112
+ ))}
113
+ </div>
114
+ )}
115
+
116
+ <article className="mt-8 max-w-3xl border-border border-t pt-6">
117
+ <Markdown source={theme.body} />
118
+ </article>
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,97 @@
1
+ @import "tailwindcss";
2
+
3
+ @custom-variant dark (&:is(.dark *));
4
+
5
+ @theme inline {
6
+ --font-sans: -apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif;
7
+ --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
8
+
9
+ --color-background: var(--background);
10
+ --color-foreground: var(--foreground);
11
+ --color-canvas: var(--canvas);
12
+ --color-muted: var(--muted);
13
+ --color-muted-foreground: var(--muted-foreground);
14
+ --color-accent: var(--accent);
15
+ --color-accent-foreground: var(--accent-foreground);
16
+ --color-primary: var(--primary);
17
+ --color-primary-foreground: var(--primary-foreground);
18
+ --color-border: var(--border);
19
+
20
+ --radius-md: 6px;
21
+ }
22
+
23
+ :root {
24
+ --background: oklch(0.99 0 0);
25
+ --foreground: oklch(0.145 0 0);
26
+ --canvas: oklch(0.93 0 0);
27
+ --muted: oklch(0.955 0 0);
28
+ --muted-foreground: oklch(0.5 0 0);
29
+ --accent: oklch(0.945 0 0);
30
+ --accent-foreground: oklch(0.185 0 0);
31
+ --primary: oklch(0.185 0 0);
32
+ --primary-foreground: oklch(0.985 0 0);
33
+ --border: oklch(0.9 0 0);
34
+ }
35
+
36
+ .dark {
37
+ --background: oklch(0.16 0 0);
38
+ --foreground: oklch(0.97 0 0);
39
+ --canvas: oklch(0.12 0 0);
40
+ --muted: oklch(0.24 0 0);
41
+ --muted-foreground: oklch(0.68 0 0);
42
+ --accent: oklch(0.25 0 0);
43
+ --accent-foreground: oklch(0.97 0 0);
44
+ --primary: oklch(0.97 0 0);
45
+ --primary-foreground: oklch(0.16 0 0);
46
+ --border: oklch(0.28 0 0);
47
+ }
48
+
49
+ @layer base {
50
+ * {
51
+ border-color: var(--border);
52
+ }
53
+ /* A heading's space-above is what separates it from the section before it —
54
+ but at the top of a page that space is just a dent. Strip it there so the
55
+ first block of every flow page starts flush with the margin. */
56
+ [data-od-flow-block]:first-child > * {
57
+ /* biome-ignore lint/complexity/noImportantStyles: blocks carry inline styles, which no stylesheet rule can outrank without it */
58
+ margin-top: 0 !important;
59
+ }
60
+ html,
61
+ body,
62
+ #root {
63
+ height: 100%;
64
+ }
65
+ body {
66
+ margin: 0;
67
+ background: var(--background);
68
+ color: var(--foreground);
69
+ font-family: var(--font-sans);
70
+ -webkit-font-smoothing: antialiased;
71
+ }
72
+ }
73
+
74
+ /* Search hits.
75
+ *
76
+ * Drawn with the CSS Custom Highlight API, so nothing in the page is wrapped or
77
+ * rewritten to show a match. Wrapping matches in <mark> would mean editing a
78
+ * tree React owns, and the next render would throw the marks away — or worse,
79
+ * keep them and lose the reader's place. Highlights are ranges held beside the
80
+ * DOM instead, which is what they are for.
81
+ *
82
+ * A browser without the API shows no highlight; the page-to-page jumping still
83
+ * works, so search degrades to "take me there" rather than breaking.
84
+ */
85
+ /* Literal colours, deliberately. These paint the sheet, and the sheet is paper —
86
+ it does not follow the viewer's theme. A chrome token here resolves to
87
+ near-white in dark mode and disappears on a white page, which is the leak the
88
+ guidelines warn about, arriving as an invisible highlight rather than an
89
+ obviously wrong one. Amber is what every reader already reads as "found". */
90
+ ::highlight(od-search) {
91
+ background-color: rgb(255 214 0 / 0.42);
92
+ color: inherit;
93
+ }
94
+ ::highlight(od-search-active) {
95
+ background-color: rgb(255 138 0 / 0.62);
96
+ color: inherit;
97
+ }