mosage 0.2.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.
- package/README.md +16 -15
- package/bin.js +2 -0
- package/dist/build-C7NW_3Pk.js +14 -0
- package/dist/check-CP4873Wx.js +41 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +228 -0
- package/dist/config-DPm1BBAb.js +2619 -0
- package/dist/config-TlTe7Ona.d.ts +24 -0
- package/dist/context-BqsdSrAQ.js +1084 -0
- package/dist/dev-Biz42qlu.js +17 -0
- package/dist/diagram-xlVDekYk.js +763 -0
- package/dist/export-Bi6nuxjT.js +31 -0
- package/dist/import-D2jNB07F.js +25 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.js +693 -0
- package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
- package/dist/preview-CLm51aRt.js +19 -0
- package/dist/sdk-DjpX6mCv.js +51 -0
- package/dist/vite/index.d.ts +25 -0
- package/dist/vite/index.js +2 -0
- package/env.d.ts +83 -0
- package/package.json +59 -12
- package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
- package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
- package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
- package/src/app/app.tsx +42 -0
- package/src/app/components/data-table.tsx +196 -0
- package/src/app/components/design-panel/design-panel.tsx +318 -0
- package/src/app/components/design-panel/design-provider.tsx +121 -0
- package/src/app/components/design-panel/use-design.ts +85 -0
- package/src/app/components/diagram.tsx +76 -0
- package/src/app/components/doc-assets.tsx +129 -0
- package/src/app/components/doc-search.tsx +248 -0
- package/src/app/components/doc-sidebar.tsx +162 -0
- package/src/app/components/flow-page.tsx +93 -0
- package/src/app/components/footnote.tsx +204 -0
- package/src/app/components/image-placeholder.tsx +50 -0
- package/src/app/components/inspector/inspector.tsx +518 -0
- package/src/app/components/numbering.tsx +224 -0
- package/src/app/components/page-frame.tsx +70 -0
- package/src/app/components/sidebar/folder-item.tsx +212 -0
- package/src/app/components/sidebar/icon-picker.tsx +99 -0
- package/src/app/components/sidebar/sidebar.tsx +252 -0
- package/src/app/components/table-of-contents.tsx +93 -0
- package/src/app/components/theme-toggle.tsx +50 -0
- package/src/app/components/themes/markdown.tsx +249 -0
- package/src/app/components/themes/theme-preview.tsx +74 -0
- package/src/app/components/ui/menu.tsx +143 -0
- package/src/app/index.html +12 -0
- package/src/app/lib/agent-bridge.ts +140 -0
- package/src/app/lib/assets.ts +151 -0
- package/src/app/lib/design-presets.ts +109 -0
- package/src/app/lib/design.ts +88 -0
- package/src/app/lib/diagnostics.ts +282 -0
- package/src/app/lib/doc-preview.tsx +29 -0
- package/src/app/lib/docs.ts +26 -0
- package/src/app/lib/docx/extract.ts +1623 -0
- package/src/app/lib/docx/fonts.test.ts +136 -0
- package/src/app/lib/docx/fonts.ts +166 -0
- package/src/app/lib/docx/media.ts +102 -0
- package/src/app/lib/docx/model.ts +206 -0
- package/src/app/lib/docx/paragraph.test.ts +92 -0
- package/src/app/lib/docx/paragraph.ts +107 -0
- package/src/app/lib/docx/props.ts +187 -0
- package/src/app/lib/docx/styles.ts +306 -0
- package/src/app/lib/docx/units.ts +35 -0
- package/src/app/lib/docx/write.test.ts +507 -0
- package/src/app/lib/docx/write.ts +581 -0
- package/src/app/lib/docx/xml.ts +39 -0
- package/src/app/lib/export-docx.ts +289 -0
- package/src/app/lib/export-dom.ts +318 -0
- package/src/app/lib/export-html.ts +156 -0
- package/src/app/lib/export-image.ts +70 -0
- package/src/app/lib/export-pdf.ts +165 -0
- package/src/app/lib/flow-measure.test.ts +31 -0
- package/src/app/lib/flow-measure.ts +183 -0
- package/src/app/lib/flow.test.ts +110 -0
- package/src/app/lib/flow.ts +136 -0
- package/src/app/lib/folders.ts +192 -0
- package/src/app/lib/footnotes.test.tsx +102 -0
- package/src/app/lib/footnotes.ts +94 -0
- package/src/app/lib/inspector/fiber.ts +99 -0
- package/src/app/lib/labels.test.ts +18 -0
- package/src/app/lib/labels.ts +181 -0
- package/src/app/lib/outline.ts +118 -0
- package/src/app/lib/page-context.tsx +43 -0
- package/src/app/lib/page-range.test.ts +95 -0
- package/src/app/lib/page-range.ts +90 -0
- package/src/app/lib/print-ready.ts +69 -0
- package/src/app/lib/rasterize.ts +173 -0
- package/src/app/lib/scan.ts +26 -0
- package/src/app/lib/sdk.test.ts +32 -0
- package/src/app/lib/sdk.ts +115 -0
- package/src/app/lib/themes.ts +31 -0
- package/src/app/lib/use-doc-module.ts +53 -0
- package/src/app/lib/use-doc-pages.ts +147 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/lib/view-mode.test.ts +91 -0
- package/src/app/lib/view-mode.ts +104 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +257 -0
- package/src/app/routes/doc.tsx +877 -0
- package/src/app/routes/home-shell.tsx +203 -0
- package/src/app/routes/home.tsx +269 -0
- package/src/app/routes/themes.tsx +121 -0
- package/src/app/styles.css +97 -0
- package/src/app/virtual.d.ts +30 -0
- package/template/AGENTS.md +1 -1
- package/template/README.md +24 -52
- package/template/docs/getting-started/index.tsx +2 -2
- package/template/mosage.config.ts +1 -1
- package/template/package.json +1 -1
- package/template/tsconfig.json +1 -1
- package/README.zh-TW.md +0 -28
- /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
package/src/app/app.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import config from 'virtual:mosage/config';
|
|
2
|
+
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom';
|
|
3
|
+
import { AssetsPage } from './routes/assets';
|
|
4
|
+
import { Doc } from './routes/doc';
|
|
5
|
+
import { Home } from './routes/home';
|
|
6
|
+
import { HomeShell } from './routes/home-shell';
|
|
7
|
+
import { ThemeDetailPage, ThemesGalleryPage } from './routes/themes';
|
|
8
|
+
|
|
9
|
+
export function App() {
|
|
10
|
+
return (
|
|
11
|
+
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
|
12
|
+
<Routes>
|
|
13
|
+
{config.build.showDocBrowser ? (
|
|
14
|
+
<Route element={<HomeShell />}>
|
|
15
|
+
<Route path="/" element={<Home />} />
|
|
16
|
+
<Route path="/themes" element={<ThemesGalleryPage />} />
|
|
17
|
+
<Route path="/themes/:themeId" element={<ThemeDetailPage />} />
|
|
18
|
+
<Route path="/assets" element={<AssetsPage />} />
|
|
19
|
+
</Route>
|
|
20
|
+
) : (
|
|
21
|
+
<Route path="/" element={<NotFound />} />
|
|
22
|
+
)}
|
|
23
|
+
<Route path="/d/:docId" element={<Doc />} />
|
|
24
|
+
<Route path="*" element={<NotFound />} />
|
|
25
|
+
</Routes>
|
|
26
|
+
</BrowserRouter>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function NotFound() {
|
|
31
|
+
return (
|
|
32
|
+
<div className="grid h-screen place-items-center bg-background px-6 text-center text-foreground">
|
|
33
|
+
<div>
|
|
34
|
+
<p className="font-mono text-muted-foreground text-xs uppercase tracking-widest">404</p>
|
|
35
|
+
<h1 className="mt-2 font-medium text-xl tracking-tight">Nothing here</h1>
|
|
36
|
+
<Link to="/" className="mt-4 inline-block text-muted-foreground text-xs underline">
|
|
37
|
+
Back to documents
|
|
38
|
+
</Link>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
import { Figure } from './numbering';
|
|
3
|
+
|
|
4
|
+
export type DataAlign = 'left' | 'center' | 'right';
|
|
5
|
+
|
|
6
|
+
export type DataFormat =
|
|
7
|
+
| 'text'
|
|
8
|
+
| 'number'
|
|
9
|
+
| 'integer'
|
|
10
|
+
| 'percent'
|
|
11
|
+
| ((value: unknown, row: Record<string, unknown>) => ReactNode);
|
|
12
|
+
|
|
13
|
+
export type DataColumn = {
|
|
14
|
+
key: string;
|
|
15
|
+
label?: ReactNode;
|
|
16
|
+
align?: DataAlign;
|
|
17
|
+
format?: DataFormat;
|
|
18
|
+
/** Column width. A number is px; `tableLayout: fixed` means these are honoured. */
|
|
19
|
+
width?: number | string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DataTableProps = {
|
|
23
|
+
/** Rows as parsed from a `.csv`/`.tsv` import, or any array of objects. */
|
|
24
|
+
rows: Array<Record<string, unknown>>;
|
|
25
|
+
/** Columns to print, in order. Derived from the first row when omitted. */
|
|
26
|
+
columns?: Array<DataColumn | string>;
|
|
27
|
+
/** Numbers the table and prints the caption above it. */
|
|
28
|
+
caption?: ReactNode;
|
|
29
|
+
/** Stable id for `<Ref to>`. Only meaningful alongside `caption`. */
|
|
30
|
+
id?: string;
|
|
31
|
+
/** Print at most this many rows, with a note about what was left out. */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** Tighter rows, for a dense appendix table. */
|
|
34
|
+
compact?: boolean;
|
|
35
|
+
/** What an empty cell prints as. Defaults to an em dash. */
|
|
36
|
+
emptyValue?: ReactNode;
|
|
37
|
+
style?: CSSProperties;
|
|
38
|
+
className?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function normalizeColumn(column: DataColumn | string): DataColumn {
|
|
42
|
+
return typeof column === 'string' ? { key: column } : column;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isNumber(value: unknown): value is number {
|
|
46
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A column of numbers aligns right; anything mixed stays left. */
|
|
50
|
+
function inferAlign(rows: Array<Record<string, unknown>>, key: string): DataAlign {
|
|
51
|
+
let seen = 0;
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const value = row[key];
|
|
54
|
+
if (value === null || value === undefined || value === '') continue;
|
|
55
|
+
if (!isNumber(value)) return 'left';
|
|
56
|
+
seen++;
|
|
57
|
+
}
|
|
58
|
+
return seen > 0 ? 'right' : 'left';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatValue(
|
|
62
|
+
value: unknown,
|
|
63
|
+
format: DataFormat | undefined,
|
|
64
|
+
row: Record<string, unknown>,
|
|
65
|
+
emptyValue: ReactNode,
|
|
66
|
+
): ReactNode {
|
|
67
|
+
if (typeof format === 'function') return format(value, row);
|
|
68
|
+
if (value === null || value === undefined || value === '') return emptyValue;
|
|
69
|
+
|
|
70
|
+
if (isNumber(value)) {
|
|
71
|
+
if (format === 'integer') return Math.round(value).toLocaleString();
|
|
72
|
+
if (format === 'percent')
|
|
73
|
+
return `${(value * 100).toLocaleString(undefined, { maximumFractionDigits: 1 })}%`;
|
|
74
|
+
if (format === 'text') return String(value);
|
|
75
|
+
// Grouping separators are what make a column of figures readable; the
|
|
76
|
+
// fraction digits follow the data rather than padding every integer.
|
|
77
|
+
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return String(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A print-shaped table from data, so the numbers in a report come from a file
|
|
85
|
+
* instead of being retyped into JSX. Alignment and grouping follow the column's
|
|
86
|
+
* contents, which is the part people get wrong by hand.
|
|
87
|
+
*/
|
|
88
|
+
export function DataTable({
|
|
89
|
+
rows,
|
|
90
|
+
columns,
|
|
91
|
+
caption,
|
|
92
|
+
id,
|
|
93
|
+
limit,
|
|
94
|
+
compact = false,
|
|
95
|
+
emptyValue = '—',
|
|
96
|
+
style,
|
|
97
|
+
className,
|
|
98
|
+
}: DataTableProps) {
|
|
99
|
+
const resolved: DataColumn[] = (columns ?? Object.keys(rows[0] ?? {})).map(normalizeColumn);
|
|
100
|
+
|
|
101
|
+
const shown = limit !== undefined ? rows.slice(0, limit) : rows;
|
|
102
|
+
const hidden = rows.length - shown.length;
|
|
103
|
+
|
|
104
|
+
const cellPadding = compact ? '4px 8px' : '7px 8px';
|
|
105
|
+
|
|
106
|
+
const table = (
|
|
107
|
+
<table
|
|
108
|
+
className={className}
|
|
109
|
+
style={{
|
|
110
|
+
width: '100%',
|
|
111
|
+
borderCollapse: 'collapse',
|
|
112
|
+
tableLayout: 'fixed',
|
|
113
|
+
margin: 0,
|
|
114
|
+
...style,
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<thead>
|
|
118
|
+
<tr>
|
|
119
|
+
{resolved.map((column) => {
|
|
120
|
+
const align = column.align ?? inferAlign(rows, column.key);
|
|
121
|
+
return (
|
|
122
|
+
<th
|
|
123
|
+
key={column.key}
|
|
124
|
+
style={{
|
|
125
|
+
textAlign: align,
|
|
126
|
+
width: column.width,
|
|
127
|
+
fontFamily: 'var(--od-font-heading)',
|
|
128
|
+
fontSize: 11,
|
|
129
|
+
fontWeight: 600,
|
|
130
|
+
letterSpacing: '0.04em',
|
|
131
|
+
textTransform: 'uppercase',
|
|
132
|
+
color: 'var(--od-muted)',
|
|
133
|
+
borderBottom: '1px solid var(--od-rule)',
|
|
134
|
+
padding: '0 8px 6px',
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{column.label ?? column.key}
|
|
138
|
+
</th>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
</tr>
|
|
142
|
+
</thead>
|
|
143
|
+
<tbody>
|
|
144
|
+
{shown.map((row, rowIndex) => (
|
|
145
|
+
// Rows come from a file and carry no id of their own; their position
|
|
146
|
+
// is what identifies them.
|
|
147
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: row order is the row's identity here
|
|
148
|
+
<tr key={rowIndex}>
|
|
149
|
+
{resolved.map((column) => {
|
|
150
|
+
const align = column.align ?? inferAlign(rows, column.key);
|
|
151
|
+
return (
|
|
152
|
+
<td
|
|
153
|
+
key={column.key}
|
|
154
|
+
style={{
|
|
155
|
+
textAlign: align,
|
|
156
|
+
fontSize: compact ? 11 : 12,
|
|
157
|
+
padding: cellPadding,
|
|
158
|
+
borderBottom: '1px solid var(--od-rule)',
|
|
159
|
+
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
|
160
|
+
overflowWrap: 'anywhere',
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{formatValue(row[column.key], column.format, row, emptyValue)}
|
|
164
|
+
</td>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</tr>
|
|
168
|
+
))}
|
|
169
|
+
</tbody>
|
|
170
|
+
{hidden > 0 && (
|
|
171
|
+
<tfoot>
|
|
172
|
+
<tr>
|
|
173
|
+
<td
|
|
174
|
+
colSpan={resolved.length}
|
|
175
|
+
style={{
|
|
176
|
+
fontSize: 'var(--od-size-caption)',
|
|
177
|
+
color: 'var(--od-muted)',
|
|
178
|
+
padding: '6px 8px 0',
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{hidden} more row{hidden === 1 ? '' : 's'} not shown
|
|
182
|
+
</td>
|
|
183
|
+
</tr>
|
|
184
|
+
</tfoot>
|
|
185
|
+
)}
|
|
186
|
+
</table>
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (!caption) return table;
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<Figure kind="table" caption={caption} {...(id !== undefined ? { id } : {})}>
|
|
193
|
+
{table}
|
|
194
|
+
</Figure>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { Loader2, RotateCcw, Shuffle, X } from 'lucide-react';
|
|
2
|
+
import type { DesignSystem } from '../../lib/design';
|
|
3
|
+
import { useDesignPanelState } from './design-provider';
|
|
4
|
+
|
|
5
|
+
const FONT_OPTIONS: Array<{ label: string; value: string }> = [
|
|
6
|
+
{
|
|
7
|
+
label: 'System sans',
|
|
8
|
+
value: '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif',
|
|
9
|
+
},
|
|
10
|
+
{ label: 'Helvetica', value: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
|
|
11
|
+
{ label: 'Georgia serif', value: 'Georgia, "Times New Roman", serif' },
|
|
12
|
+
{ label: 'Times serif', value: '"Times New Roman", Times, serif' },
|
|
13
|
+
{ label: 'Mono', value: 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const PALETTE_KEYS: Array<{ key: keyof DesignSystem['palette']; label: string }> = [
|
|
17
|
+
{ key: 'bg', label: 'Sheet' },
|
|
18
|
+
{ key: 'text', label: 'Text' },
|
|
19
|
+
{ key: 'muted', label: 'Muted' },
|
|
20
|
+
{ key: 'accent', label: 'Accent' },
|
|
21
|
+
{ key: 'rule', label: 'Rule' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const TYPE_KEYS: Array<{
|
|
25
|
+
key: keyof DesignSystem['typeScale'];
|
|
26
|
+
label: string;
|
|
27
|
+
min: number;
|
|
28
|
+
max: number;
|
|
29
|
+
}> = [
|
|
30
|
+
{ key: 'title', label: 'Title', min: 24, max: 72 },
|
|
31
|
+
{ key: 'h1', label: 'H1', min: 16, max: 48 },
|
|
32
|
+
{ key: 'h2', label: 'H2', min: 14, max: 36 },
|
|
33
|
+
{ key: 'h3', label: 'H3', min: 12, max: 28 },
|
|
34
|
+
{ key: 'body', label: 'Body', min: 9, max: 20 },
|
|
35
|
+
{ key: 'caption', label: 'Caption', min: 7, max: 16 },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function DesignPanel({ onClose }: { onClose: () => void }) {
|
|
39
|
+
const {
|
|
40
|
+
loaded,
|
|
41
|
+
exists,
|
|
42
|
+
warning,
|
|
43
|
+
draft,
|
|
44
|
+
dirty,
|
|
45
|
+
committing,
|
|
46
|
+
error,
|
|
47
|
+
update,
|
|
48
|
+
commit,
|
|
49
|
+
discard,
|
|
50
|
+
resetToDefaults,
|
|
51
|
+
shuffle,
|
|
52
|
+
} = useDesignPanelState();
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<aside className="flex w-72 flex-none flex-col border-border border-l bg-background">
|
|
56
|
+
<header className="flex h-10 flex-none items-center justify-between border-border border-b px-3">
|
|
57
|
+
<span className="font-medium text-xs uppercase tracking-wider">Design</span>
|
|
58
|
+
<div className="flex items-center gap-0.5">
|
|
59
|
+
<IconButton label="Shuffle preset" onClick={shuffle}>
|
|
60
|
+
<Shuffle className="size-3.5" />
|
|
61
|
+
</IconButton>
|
|
62
|
+
<IconButton label="Reset to defaults" onClick={resetToDefaults}>
|
|
63
|
+
<RotateCcw className="size-3.5" />
|
|
64
|
+
</IconButton>
|
|
65
|
+
<IconButton label="Close design panel" onClick={onClose}>
|
|
66
|
+
<X className="size-3.5" />
|
|
67
|
+
</IconButton>
|
|
68
|
+
</div>
|
|
69
|
+
</header>
|
|
70
|
+
|
|
71
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
|
72
|
+
{!loaded || !draft ? (
|
|
73
|
+
<div className="grid place-items-center py-10">
|
|
74
|
+
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
75
|
+
</div>
|
|
76
|
+
) : (
|
|
77
|
+
<>
|
|
78
|
+
{warning && (
|
|
79
|
+
<p className="mb-3 rounded-md border border-border bg-muted px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
80
|
+
{warning}
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
{!exists && (
|
|
84
|
+
<p className="mb-3 rounded-md border border-border bg-muted px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
85
|
+
This document has no <code className="font-mono">design</code> const yet. Saving
|
|
86
|
+
writes one into <code className="font-mono">index.tsx</code>.
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
<Section title="Palette">
|
|
91
|
+
{PALETTE_KEYS.map(({ key, label }) => (
|
|
92
|
+
<Row key={key} label={label}>
|
|
93
|
+
<input
|
|
94
|
+
type="color"
|
|
95
|
+
value={draft.palette[key]}
|
|
96
|
+
onChange={(e) => {
|
|
97
|
+
const value = e.target.value;
|
|
98
|
+
update((d) => {
|
|
99
|
+
d.palette[key] = value;
|
|
100
|
+
});
|
|
101
|
+
}}
|
|
102
|
+
className="size-6 cursor-pointer rounded border border-border bg-transparent"
|
|
103
|
+
/>
|
|
104
|
+
<input
|
|
105
|
+
type="text"
|
|
106
|
+
value={draft.palette[key]}
|
|
107
|
+
onChange={(e) => {
|
|
108
|
+
const value = e.target.value;
|
|
109
|
+
update((d) => {
|
|
110
|
+
d.palette[key] = value;
|
|
111
|
+
});
|
|
112
|
+
}}
|
|
113
|
+
className="w-20 rounded border border-border bg-transparent px-1.5 py-0.5 font-mono text-[11px]"
|
|
114
|
+
/>
|
|
115
|
+
</Row>
|
|
116
|
+
))}
|
|
117
|
+
</Section>
|
|
118
|
+
|
|
119
|
+
<Section title="Fonts">
|
|
120
|
+
{(['heading', 'body', 'mono'] as const).map((slot) => (
|
|
121
|
+
<Row key={slot} label={slot[0].toUpperCase() + slot.slice(1)}>
|
|
122
|
+
<select
|
|
123
|
+
value={
|
|
124
|
+
FONT_OPTIONS.some((o) => o.value === draft.fonts[slot])
|
|
125
|
+
? draft.fonts[slot]
|
|
126
|
+
: 'custom'
|
|
127
|
+
}
|
|
128
|
+
onChange={(e) => {
|
|
129
|
+
const value = e.target.value;
|
|
130
|
+
if (value === 'custom') return;
|
|
131
|
+
update((d) => {
|
|
132
|
+
d.fonts[slot] = value;
|
|
133
|
+
});
|
|
134
|
+
}}
|
|
135
|
+
className="w-40 rounded border border-border bg-transparent px-1.5 py-0.5 text-[11px]"
|
|
136
|
+
>
|
|
137
|
+
{FONT_OPTIONS.map((option) => (
|
|
138
|
+
<option key={option.label} value={option.value}>
|
|
139
|
+
{option.label}
|
|
140
|
+
</option>
|
|
141
|
+
))}
|
|
142
|
+
<option value="custom">Custom (from source)</option>
|
|
143
|
+
</select>
|
|
144
|
+
</Row>
|
|
145
|
+
))}
|
|
146
|
+
</Section>
|
|
147
|
+
|
|
148
|
+
<Section title="Type scale">
|
|
149
|
+
{TYPE_KEYS.map(({ key, label, min, max }) => (
|
|
150
|
+
<SliderRow
|
|
151
|
+
key={key}
|
|
152
|
+
label={label}
|
|
153
|
+
value={draft.typeScale[key]}
|
|
154
|
+
min={min}
|
|
155
|
+
max={max}
|
|
156
|
+
step={1}
|
|
157
|
+
suffix="px"
|
|
158
|
+
onChange={(value) =>
|
|
159
|
+
update((d) => {
|
|
160
|
+
d.typeScale[key] = value;
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
))}
|
|
165
|
+
</Section>
|
|
166
|
+
|
|
167
|
+
<Section title="Page">
|
|
168
|
+
<SliderRow
|
|
169
|
+
label="Margin"
|
|
170
|
+
value={draft.margin}
|
|
171
|
+
min={32}
|
|
172
|
+
max={140}
|
|
173
|
+
step={2}
|
|
174
|
+
suffix="px"
|
|
175
|
+
onChange={(value) =>
|
|
176
|
+
update((d) => {
|
|
177
|
+
d.margin = value;
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
/>
|
|
181
|
+
<SliderRow
|
|
182
|
+
label="Leading"
|
|
183
|
+
value={draft.leading}
|
|
184
|
+
min={1.2}
|
|
185
|
+
max={2}
|
|
186
|
+
step={0.05}
|
|
187
|
+
onChange={(value) =>
|
|
188
|
+
update((d) => {
|
|
189
|
+
d.leading = Number(value.toFixed(2));
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
/>
|
|
193
|
+
<SliderRow
|
|
194
|
+
label="Radius"
|
|
195
|
+
value={draft.radius}
|
|
196
|
+
min={0}
|
|
197
|
+
max={24}
|
|
198
|
+
step={1}
|
|
199
|
+
suffix="px"
|
|
200
|
+
onChange={(value) =>
|
|
201
|
+
update((d) => {
|
|
202
|
+
d.radius = value;
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
/>
|
|
206
|
+
</Section>
|
|
207
|
+
|
|
208
|
+
{draft.typeScale.body < 12 && (
|
|
209
|
+
<p className="mt-3 rounded-md border border-border bg-muted px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
210
|
+
Body under 12px prints below 9pt — hard to read on paper.
|
|
211
|
+
</p>
|
|
212
|
+
)}
|
|
213
|
+
</>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<footer className="flex-none border-border border-t p-3">
|
|
218
|
+
{error && <p className="mb-2 text-[11px] text-muted-foreground">{error}</p>}
|
|
219
|
+
<div className="flex items-center gap-2">
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
onClick={commit}
|
|
223
|
+
disabled={!dirty || committing}
|
|
224
|
+
className="flex flex-1 items-center justify-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-xs transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
225
|
+
>
|
|
226
|
+
{committing && <Loader2 className="size-3.5 animate-spin" />}
|
|
227
|
+
Save to source
|
|
228
|
+
</button>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={discard}
|
|
232
|
+
disabled={!dirty || committing}
|
|
233
|
+
className="rounded-md border border-border px-3 py-1.5 text-xs transition-colors hover:bg-accent disabled:opacity-50"
|
|
234
|
+
>
|
|
235
|
+
Discard
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
238
|
+
</footer>
|
|
239
|
+
</aside>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
244
|
+
return (
|
|
245
|
+
<section className="mb-4">
|
|
246
|
+
<h3 className="mb-1.5 text-[10px] text-muted-foreground uppercase tracking-wider">{title}</h3>
|
|
247
|
+
<div className="space-y-1.5">{children}</div>
|
|
248
|
+
</section>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
|
253
|
+
return (
|
|
254
|
+
<div className="flex items-center justify-between gap-2">
|
|
255
|
+
<span className="text-xs">{label}</span>
|
|
256
|
+
<div className="flex items-center gap-1.5">{children}</div>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function SliderRow({
|
|
262
|
+
label,
|
|
263
|
+
value,
|
|
264
|
+
min,
|
|
265
|
+
max,
|
|
266
|
+
step,
|
|
267
|
+
suffix = '',
|
|
268
|
+
onChange,
|
|
269
|
+
}: {
|
|
270
|
+
label: string;
|
|
271
|
+
value: number;
|
|
272
|
+
min: number;
|
|
273
|
+
max: number;
|
|
274
|
+
step: number;
|
|
275
|
+
suffix?: string;
|
|
276
|
+
onChange: (value: number) => void;
|
|
277
|
+
}) {
|
|
278
|
+
return (
|
|
279
|
+
<div className="flex items-center gap-2">
|
|
280
|
+
<span className="w-16 flex-none text-xs">{label}</span>
|
|
281
|
+
<input
|
|
282
|
+
type="range"
|
|
283
|
+
min={min}
|
|
284
|
+
max={max}
|
|
285
|
+
step={step}
|
|
286
|
+
value={value}
|
|
287
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
288
|
+
className="h-1 flex-1 cursor-pointer accent-foreground"
|
|
289
|
+
/>
|
|
290
|
+
<span className="w-12 flex-none text-right font-mono text-[11px] tabular-nums">
|
|
291
|
+
{value}
|
|
292
|
+
{suffix}
|
|
293
|
+
</span>
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function IconButton({
|
|
299
|
+
label,
|
|
300
|
+
onClick,
|
|
301
|
+
children,
|
|
302
|
+
}: {
|
|
303
|
+
label: string;
|
|
304
|
+
onClick: () => void;
|
|
305
|
+
children: React.ReactNode;
|
|
306
|
+
}) {
|
|
307
|
+
return (
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
aria-label={label}
|
|
311
|
+
title={label}
|
|
312
|
+
onClick={onClick}
|
|
313
|
+
className="flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
314
|
+
>
|
|
315
|
+
{children}
|
|
316
|
+
</button>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { type DesignSystem, defaultDesign, designToCssVars } from '../../lib/design';
|
|
12
|
+
import { shuffleDesign } from '../../lib/design-presets';
|
|
13
|
+
import { useDesign as useDesignFetch } from './use-design';
|
|
14
|
+
|
|
15
|
+
type DesignCtx = {
|
|
16
|
+
docId: string;
|
|
17
|
+
loaded: boolean;
|
|
18
|
+
exists: boolean;
|
|
19
|
+
warning: string | null;
|
|
20
|
+
design: DesignSystem | null;
|
|
21
|
+
draft: DesignSystem | null;
|
|
22
|
+
dirty: boolean;
|
|
23
|
+
committing: boolean;
|
|
24
|
+
error: string | null;
|
|
25
|
+
update: (mut: (next: DesignSystem) => void) => void;
|
|
26
|
+
commit: () => Promise<void>;
|
|
27
|
+
discard: () => void;
|
|
28
|
+
resetToDefaults: () => void;
|
|
29
|
+
shuffle: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const Ctx = createContext<DesignCtx | null>(null);
|
|
33
|
+
|
|
34
|
+
export function useDesignPanelState(): DesignCtx {
|
|
35
|
+
const value = useContext(Ctx);
|
|
36
|
+
if (!value) throw new Error('useDesignPanelState must be used inside <DesignProvider>');
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clone<T>(value: T): T {
|
|
41
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function DesignProvider({ docId, children }: { docId: string; children: ReactNode }) {
|
|
45
|
+
const { design, exists, warning, loaded, save } = useDesignFetch(docId);
|
|
46
|
+
const [draft, setDraft] = useState<DesignSystem | null>(null);
|
|
47
|
+
const [committing, setCommitting] = useState(false);
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
const draftRef = useRef<DesignSystem | null>(null);
|
|
50
|
+
draftRef.current = draft;
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (design) setDraft(clone(design));
|
|
54
|
+
}, [design]);
|
|
55
|
+
|
|
56
|
+
const dirty = useMemo(() => {
|
|
57
|
+
if (!draft || !design) return false;
|
|
58
|
+
return JSON.stringify(draft) !== JSON.stringify(design);
|
|
59
|
+
}, [draft, design]);
|
|
60
|
+
|
|
61
|
+
const update = useCallback((mut: (next: DesignSystem) => void) => {
|
|
62
|
+
const prev = draftRef.current;
|
|
63
|
+
if (!prev) return;
|
|
64
|
+
const next = clone(prev);
|
|
65
|
+
mut(next);
|
|
66
|
+
setDraft(next);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const commit = useCallback(async () => {
|
|
70
|
+
if (!draft) return;
|
|
71
|
+
setCommitting(true);
|
|
72
|
+
const result = await save(draft);
|
|
73
|
+
setCommitting(false);
|
|
74
|
+
setError(result.ok ? null : (result.error ?? 'Failed to save'));
|
|
75
|
+
}, [draft, save]);
|
|
76
|
+
|
|
77
|
+
const discard = useCallback(() => {
|
|
78
|
+
if (design) setDraft(clone(design));
|
|
79
|
+
setError(null);
|
|
80
|
+
}, [design]);
|
|
81
|
+
|
|
82
|
+
const resetToDefaults = useCallback(() => setDraft(clone(defaultDesign)), []);
|
|
83
|
+
const shuffle = useCallback(() => setDraft(clone(shuffleDesign(draftRef.current))), []);
|
|
84
|
+
|
|
85
|
+
// PageFrame writes its design vars inline on each page root, so the draft
|
|
86
|
+
// overlay has to outrank inline styles — hence `!important`.
|
|
87
|
+
const previewCss = useMemo(() => {
|
|
88
|
+
if (!dirty || !draft) return '';
|
|
89
|
+
const lines = Object.entries(designToCssVars(draft))
|
|
90
|
+
.map(([k, v]) => ` ${k}: ${v} !important;`)
|
|
91
|
+
.join('\n');
|
|
92
|
+
return `[data-od-page] {\n${lines}\n}`;
|
|
93
|
+
}, [dirty, draft]);
|
|
94
|
+
|
|
95
|
+
const value: DesignCtx = {
|
|
96
|
+
docId,
|
|
97
|
+
loaded,
|
|
98
|
+
exists,
|
|
99
|
+
warning,
|
|
100
|
+
design,
|
|
101
|
+
draft,
|
|
102
|
+
dirty,
|
|
103
|
+
committing,
|
|
104
|
+
error,
|
|
105
|
+
update,
|
|
106
|
+
commit,
|
|
107
|
+
discard,
|
|
108
|
+
resetToDefaults,
|
|
109
|
+
shuffle,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<Ctx.Provider value={value}>
|
|
114
|
+
{previewCss && (
|
|
115
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted local css built from draft state
|
|
116
|
+
<style dangerouslySetInnerHTML={{ __html: previewCss }} />
|
|
117
|
+
)}
|
|
118
|
+
{children}
|
|
119
|
+
</Ctx.Provider>
|
|
120
|
+
);
|
|
121
|
+
}
|