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.
Files changed (121) hide show
  1. package/README.md +16 -15
  2. package/bin.js +2 -0
  3. package/dist/build-C7NW_3Pk.js +14 -0
  4. package/dist/check-CP4873Wx.js +41 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +228 -0
  7. package/dist/config-DPm1BBAb.js +2619 -0
  8. package/dist/config-TlTe7Ona.d.ts +24 -0
  9. package/dist/context-BqsdSrAQ.js +1084 -0
  10. package/dist/dev-Biz42qlu.js +17 -0
  11. package/dist/diagram-xlVDekYk.js +763 -0
  12. package/dist/export-Bi6nuxjT.js +31 -0
  13. package/dist/import-D2jNB07F.js +25 -0
  14. package/dist/index.d.ts +455 -0
  15. package/dist/index.js +693 -0
  16. package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
  17. package/dist/preview-CLm51aRt.js +19 -0
  18. package/dist/sdk-DjpX6mCv.js +51 -0
  19. package/dist/vite/index.d.ts +25 -0
  20. package/dist/vite/index.js +2 -0
  21. package/env.d.ts +83 -0
  22. package/package.json +59 -12
  23. package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
  24. package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
  25. package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
  26. package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
  27. package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
  28. package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
  29. package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
  30. package/src/app/app.tsx +42 -0
  31. package/src/app/components/data-table.tsx +196 -0
  32. package/src/app/components/design-panel/design-panel.tsx +318 -0
  33. package/src/app/components/design-panel/design-provider.tsx +121 -0
  34. package/src/app/components/design-panel/use-design.ts +85 -0
  35. package/src/app/components/diagram.tsx +76 -0
  36. package/src/app/components/doc-assets.tsx +129 -0
  37. package/src/app/components/doc-search.tsx +248 -0
  38. package/src/app/components/doc-sidebar.tsx +162 -0
  39. package/src/app/components/flow-page.tsx +93 -0
  40. package/src/app/components/footnote.tsx +204 -0
  41. package/src/app/components/image-placeholder.tsx +50 -0
  42. package/src/app/components/inspector/inspector.tsx +518 -0
  43. package/src/app/components/numbering.tsx +224 -0
  44. package/src/app/components/page-frame.tsx +70 -0
  45. package/src/app/components/sidebar/folder-item.tsx +212 -0
  46. package/src/app/components/sidebar/icon-picker.tsx +99 -0
  47. package/src/app/components/sidebar/sidebar.tsx +252 -0
  48. package/src/app/components/table-of-contents.tsx +93 -0
  49. package/src/app/components/theme-toggle.tsx +50 -0
  50. package/src/app/components/themes/markdown.tsx +249 -0
  51. package/src/app/components/themes/theme-preview.tsx +74 -0
  52. package/src/app/components/ui/menu.tsx +143 -0
  53. package/src/app/index.html +12 -0
  54. package/src/app/lib/agent-bridge.ts +140 -0
  55. package/src/app/lib/assets.ts +151 -0
  56. package/src/app/lib/design-presets.ts +109 -0
  57. package/src/app/lib/design.ts +88 -0
  58. package/src/app/lib/diagnostics.ts +282 -0
  59. package/src/app/lib/doc-preview.tsx +29 -0
  60. package/src/app/lib/docs.ts +26 -0
  61. package/src/app/lib/docx/extract.ts +1623 -0
  62. package/src/app/lib/docx/fonts.test.ts +136 -0
  63. package/src/app/lib/docx/fonts.ts +166 -0
  64. package/src/app/lib/docx/media.ts +102 -0
  65. package/src/app/lib/docx/model.ts +206 -0
  66. package/src/app/lib/docx/paragraph.test.ts +92 -0
  67. package/src/app/lib/docx/paragraph.ts +107 -0
  68. package/src/app/lib/docx/props.ts +187 -0
  69. package/src/app/lib/docx/styles.ts +306 -0
  70. package/src/app/lib/docx/units.ts +35 -0
  71. package/src/app/lib/docx/write.test.ts +507 -0
  72. package/src/app/lib/docx/write.ts +581 -0
  73. package/src/app/lib/docx/xml.ts +39 -0
  74. package/src/app/lib/export-docx.ts +289 -0
  75. package/src/app/lib/export-dom.ts +318 -0
  76. package/src/app/lib/export-html.ts +156 -0
  77. package/src/app/lib/export-image.ts +70 -0
  78. package/src/app/lib/export-pdf.ts +165 -0
  79. package/src/app/lib/flow-measure.test.ts +31 -0
  80. package/src/app/lib/flow-measure.ts +183 -0
  81. package/src/app/lib/flow.test.ts +110 -0
  82. package/src/app/lib/flow.ts +136 -0
  83. package/src/app/lib/folders.ts +192 -0
  84. package/src/app/lib/footnotes.test.tsx +102 -0
  85. package/src/app/lib/footnotes.ts +94 -0
  86. package/src/app/lib/inspector/fiber.ts +99 -0
  87. package/src/app/lib/labels.test.ts +18 -0
  88. package/src/app/lib/labels.ts +181 -0
  89. package/src/app/lib/outline.ts +118 -0
  90. package/src/app/lib/page-context.tsx +43 -0
  91. package/src/app/lib/page-range.test.ts +95 -0
  92. package/src/app/lib/page-range.ts +90 -0
  93. package/src/app/lib/print-ready.ts +69 -0
  94. package/src/app/lib/rasterize.ts +173 -0
  95. package/src/app/lib/scan.ts +26 -0
  96. package/src/app/lib/sdk.test.ts +32 -0
  97. package/src/app/lib/sdk.ts +115 -0
  98. package/src/app/lib/themes.ts +31 -0
  99. package/src/app/lib/use-doc-module.ts +53 -0
  100. package/src/app/lib/use-doc-pages.ts +147 -0
  101. package/src/app/lib/utils.ts +6 -0
  102. package/src/app/lib/view-mode.test.ts +91 -0
  103. package/src/app/lib/view-mode.ts +104 -0
  104. package/src/app/main.tsx +14 -0
  105. package/src/app/routes/assets.tsx +257 -0
  106. package/src/app/routes/doc.tsx +877 -0
  107. package/src/app/routes/home-shell.tsx +203 -0
  108. package/src/app/routes/home.tsx +269 -0
  109. package/src/app/routes/themes.tsx +121 -0
  110. package/src/app/styles.css +97 -0
  111. package/src/app/virtual.d.ts +30 -0
  112. package/template/AGENTS.md +1 -1
  113. package/template/README.md +24 -52
  114. package/template/docs/getting-started/index.tsx +2 -2
  115. package/template/mosage.config.ts +1 -1
  116. package/template/package.json +1 -1
  117. package/template/tsconfig.json +1 -1
  118. package/README.zh-TW.md +0 -28
  119. /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
  120. /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
  121. /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
@@ -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
+ }