@puredesktop/puredesktop-ui-bridge 2.1.6 → 2.1.8
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/package.json +55 -1
- package/src/bridge/agentTypes.ts +21 -1
- package/src/bridge/collectionImagePaste.ts +19 -1
- package/src/bridge/context.mjs +32 -0
- package/src/bridge/documents.d.mts +76 -0
- package/src/bridge/documents.mjs +40 -0
- package/src/bridge/methods.d.mts +23 -0
- package/src/bridge/methods.mjs +23 -0
- package/src/bridge/pureRender/base.css +44 -0
- package/src/bridge/pureRender/document.ts +29 -2
- package/src/bridge/pureRender/index.ts +3 -0
- package/src/bridge/pureRender/normalize.ts +167 -0
- package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
- package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
- package/src/bridge/pureRender/styles.ts +125 -5
- package/src/bridge/pureRender/theme.test.ts +259 -0
- package/src/bridge/pureRender/theme.ts +19 -1
- package/src/bridge/pureRender/types.ts +33 -0
- package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
- package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
- package/src/bridge/react/useDocumentHotkeys.ts +41 -0
- package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
- package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
- package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
- package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
- package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
- package/src/bridge/storage.d.mts +3 -1
- package/src/bridge/storage.test.ts +2 -2
- package/src/bridge/types.ts +7 -0
- package/src/components/agents/AgentDrawerPanel.tsx +2 -0
- package/src/components/agents/AgentMessageList.tsx +6 -1
- package/src/components/agents/AgentToolCallCard.tsx +20 -20
- package/src/components/agents/ContextPicker.tsx +239 -0
- package/src/components/agents/agentPanelStyles.ts +3 -4
- package/src/components/agents/agentTypes.ts +2 -0
- package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
- package/src/components/chrome/workspaceTabTypes.ts +2 -0
- package/src/components/common/containers/AppFrame.test.tsx +4 -4
- package/src/components/common/containers/AppFrame.tsx +10 -1
- package/src/components/common/containers/AppHeader.tsx +22 -0
- package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
- package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
- package/src/components/common/documents/index.ts +8 -0
- package/src/components/common/research/EvidenceDossier.tsx +1232 -150
- package/src/components/common/research/index.ts +2 -0
- package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
- package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
- package/src/components/print-design/index.ts +2 -0
- package/src/components/print-design/previewContent.test.ts +32 -0
- package/src/components/print-design/previewContent.ts +31 -0
- package/src/context/buildContextDocument.test.ts +240 -0
- package/src/context/buildContextDocument.ts +309 -0
- package/src/context/contextAccess.ts +66 -0
- package/src/context/contextConfig.ts +72 -0
- package/src/context/contextDocument.test.ts +155 -0
- package/src/context/contextDocument.ts +300 -0
- package/src/context/contextSections.test.ts +88 -0
- package/src/context/contextSections.ts +84 -0
- package/src/context/htmlToContextMarkdown.test.ts +134 -0
- package/src/context/htmlToContextMarkdown.ts +369 -0
- package/src/theme/appAccents.test.ts +36 -35
- package/src/theme/appAccents.ts +87 -104
- package/src/theme/appIdentityCss.mjs +169 -231
- package/src/theme/appIdentityCss.ts +213 -233
|
@@ -22,6 +22,265 @@ describe('Pure Render themes', () => {
|
|
|
22
22
|
)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
+
it('maps flourish settings into CSS variables', () => {
|
|
26
|
+
const css = themeToPureRenderTokenCss({
|
|
27
|
+
...DEFAULT_PURE_RENDER_THEME,
|
|
28
|
+
setting: {
|
|
29
|
+
...DEFAULT_PURE_RENDER_THEME.setting,
|
|
30
|
+
headingRule: 'underline',
|
|
31
|
+
eyebrowText: 'Essay · July 2026',
|
|
32
|
+
dropCap: true,
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(css).toContain('--heading-rule-width:1.5px;')
|
|
37
|
+
expect(css).toContain('--eyebrow-display:block;')
|
|
38
|
+
expect(css).toContain('--eyebrow-text:"Essay · July 2026";')
|
|
39
|
+
expect(css).toContain('--drop-cap-float:left;')
|
|
40
|
+
expect(css).toContain('--drop-cap-ink:var(--accent);')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('keeps flourish variables inert when settings are absent', () => {
|
|
44
|
+
const css = themeToPureRenderTokenCss(DEFAULT_PURE_RENDER_THEME)
|
|
45
|
+
|
|
46
|
+
expect(css).toContain('--heading-rule-width:0;')
|
|
47
|
+
expect(css).toContain('--eyebrow-display:none;')
|
|
48
|
+
expect(css).toContain('--drop-cap-float:none;')
|
|
49
|
+
expect(css).toContain('--drop-cap-ink:inherit;')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('maps cover, custom header and folio settings', async () => {
|
|
53
|
+
const { themePageOverridesCss } = await import('./styles.js')
|
|
54
|
+
const themed = {
|
|
55
|
+
...DEFAULT_PURE_RENDER_THEME,
|
|
56
|
+
setting: {
|
|
57
|
+
...DEFAULT_PURE_RENDER_THEME.setting,
|
|
58
|
+
coverPage: true,
|
|
59
|
+
runningHeader: 'custom' as const,
|
|
60
|
+
runningHeaderText: 'Draft · Confidential',
|
|
61
|
+
folio: 'page' as const,
|
|
62
|
+
folioSlot: 'bottom-right' as const,
|
|
63
|
+
folioStyle: 'of-total' as const,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
const pageCss = themePageOverridesCss(themed)
|
|
67
|
+
// Cover is structural: its named page appears in the overrides.
|
|
68
|
+
expect(pageCss).toContain('@page cover{')
|
|
69
|
+
expect(pageCss).toContain('content:"Draft · Confidential"')
|
|
70
|
+
// Styled folio replaces the profile default at its new slot.
|
|
71
|
+
expect(pageCss).toContain('@bottom-center{ content:none }')
|
|
72
|
+
expect(pageCss).toContain(
|
|
73
|
+
'@bottom-right{ content:counter(page) " / " counter(pages)',
|
|
74
|
+
)
|
|
75
|
+
// Custom header with EMPTY text emits nothing.
|
|
76
|
+
const empty = themePageOverridesCss({
|
|
77
|
+
...DEFAULT_PURE_RENDER_THEME,
|
|
78
|
+
setting: {
|
|
79
|
+
...DEFAULT_PURE_RENDER_THEME.setting,
|
|
80
|
+
runningHeader: 'custom' as const,
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
expect(empty).not.toContain('@top-center')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('wraps the title block into a cover section with author/date', async () => {
|
|
87
|
+
const { injectCoverSection } = await import('./normalize.js')
|
|
88
|
+
const article =
|
|
89
|
+
'<article><h1 class="doc-title">T</h1><p class="authors">A. Hyde</p>' +
|
|
90
|
+
'<section id="sec-one"><h2>First</h2><p>x</p></section></article>'
|
|
91
|
+
const withCover = injectCoverSection(article, {
|
|
92
|
+
author: 'A. Hyde',
|
|
93
|
+
date: 'July 2026',
|
|
94
|
+
})
|
|
95
|
+
expect(withCover).toContain('data-type="cover"')
|
|
96
|
+
expect(withCover).toContain('class="cover-author"')
|
|
97
|
+
expect(withCover).toContain('class="cover-date"')
|
|
98
|
+
// Title moved INSIDE the cover, before the first body section.
|
|
99
|
+
expect(withCover.indexOf('data-type="cover"')).toBeLessThan(
|
|
100
|
+
withCover.indexOf('id="sec-one"'),
|
|
101
|
+
)
|
|
102
|
+
// Idempotent.
|
|
103
|
+
expect(injectCoverSection(withCover)).toBe(withCover)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('emits the cover named page with background/image and no furniture', async () => {
|
|
107
|
+
const { themePageOverridesCss } = await import('./styles.js')
|
|
108
|
+
const css = themePageOverridesCss({
|
|
109
|
+
...DEFAULT_PURE_RENDER_THEME,
|
|
110
|
+
setting: {
|
|
111
|
+
...DEFAULT_PURE_RENDER_THEME.setting,
|
|
112
|
+
coverPage: true,
|
|
113
|
+
coverBackground: '#10241E',
|
|
114
|
+
coverImageDataUri: 'data:image/png;base64,BBBB',
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
expect(css).toContain(
|
|
118
|
+
'@page cover{ background:url("data:image/png;base64,BBBB") center / cover no-repeat, #10241E;',
|
|
119
|
+
)
|
|
120
|
+
expect(css).toContain('@bottom-center{ content:none }')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('generates a table of contents from body sections', async () => {
|
|
124
|
+
const { injectGeneratedToc } = await import('./normalize.js')
|
|
125
|
+
const article =
|
|
126
|
+
'<article><h1 class="doc-title">T</h1>' +
|
|
127
|
+
'<section data-type="abstract"><h2>Abstract</h2><p>a</p></section>' +
|
|
128
|
+
'<section id="sec-one"><h2>First Part</h2><p>x</p></section>' +
|
|
129
|
+
'<section id="sec-two"><h2>Second Part</h2><p>y</p></section>' +
|
|
130
|
+
'<section data-type="references"><h2>References</h2></section></article>'
|
|
131
|
+
const withToc = injectGeneratedToc(article)
|
|
132
|
+
expect(withToc).toContain('data-type="toc"')
|
|
133
|
+
expect(withToc).toContain('href="#sec-one"')
|
|
134
|
+
expect(withToc).toContain('toc-label">First Part')
|
|
135
|
+
expect(withToc).toContain('toc-label">Second Part')
|
|
136
|
+
expect(withToc).toContain('class="toc-leader"')
|
|
137
|
+
expect(withToc).not.toContain('toc-label">References')
|
|
138
|
+
// The toc sits before the first body section.
|
|
139
|
+
expect(withToc.indexOf('data-type="toc"')).toBeLessThan(
|
|
140
|
+
withToc.indexOf('id="sec-one"'),
|
|
141
|
+
)
|
|
142
|
+
// Idempotent.
|
|
143
|
+
expect(injectGeneratedToc(withToc)).toBe(withToc)
|
|
144
|
+
// Nothing to list → unchanged.
|
|
145
|
+
expect(injectGeneratedToc('<article><p>solo</p></article>')).toBe(
|
|
146
|
+
'<article><p>solo</p></article>',
|
|
147
|
+
)
|
|
148
|
+
// Writer articles keep h2s as LOOSE children — no section wrappers.
|
|
149
|
+
const loose =
|
|
150
|
+
'<article><h1 class="doc-title">T</h1>' +
|
|
151
|
+
'<h2 id="alpha">Alpha</h2><p>x</p><h2 id="beta">Beta</h2><p>y</p></article>'
|
|
152
|
+
const looseToc = injectGeneratedToc(loose)
|
|
153
|
+
expect(looseToc).toContain('data-type="toc"')
|
|
154
|
+
expect(looseToc).toContain('href="#alpha"')
|
|
155
|
+
expect(looseToc).toContain('toc-label">Alpha')
|
|
156
|
+
expect(looseToc).toContain('toc-label">Beta')
|
|
157
|
+
// Inserted before the first heading, after the title block.
|
|
158
|
+
expect(looseToc.indexOf('doc-title')).toBeLessThan(
|
|
159
|
+
looseToc.indexOf('data-type="toc"'),
|
|
160
|
+
)
|
|
161
|
+
expect(looseToc.indexOf('data-type="toc"')).toBeLessThan(
|
|
162
|
+
looseToc.indexOf('id="alpha"'),
|
|
163
|
+
)
|
|
164
|
+
// With a cover, the toc sits IMMEDIATELY after it — before the title.
|
|
165
|
+
const covered =
|
|
166
|
+
'<article><section data-type="cover"><p class="cover-title">T</p></section>' +
|
|
167
|
+
'<h1 class="doc-title">T</h1><p>Intro para.</p>' +
|
|
168
|
+
'<h2 id="alpha">Alpha</h2><p>x</p></article>'
|
|
169
|
+
const coveredToc = injectGeneratedToc(covered)
|
|
170
|
+
expect(coveredToc.indexOf('data-type="cover"')).toBeLessThan(
|
|
171
|
+
coveredToc.indexOf('data-type="toc"'),
|
|
172
|
+
)
|
|
173
|
+
expect(coveredToc.indexOf('data-type="toc"')).toBeLessThan(
|
|
174
|
+
coveredToc.indexOf('doc-title'),
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('wraps loose writer body content so columns have a section', async () => {
|
|
179
|
+
const { wrapLooseBodyRun } = await import('./normalize.js')
|
|
180
|
+
const loose =
|
|
181
|
+
'<article><section data-type="cover"><p class="cover-title">T</p></section>' +
|
|
182
|
+
'<h1 class="doc-title">T</h1><section data-type="toc"><h2>Contents</h2></section>' +
|
|
183
|
+
'<h2 id="alpha">Alpha</h2><p>x</p><h2 id="beta">Beta</h2><p>y</p></article>'
|
|
184
|
+
const wrapped = wrapLooseBodyRun(loose)
|
|
185
|
+
// Body run in ONE untyped section; cover/title/toc left outside.
|
|
186
|
+
expect(wrapped).toMatch(/<section><h2 id="alpha">/)
|
|
187
|
+
expect(wrapped.indexOf('data-type="toc"')).toBeLessThan(
|
|
188
|
+
wrapped.indexOf('<section><h2 id="alpha">'),
|
|
189
|
+
)
|
|
190
|
+
// Already-sectioned articles are untouched.
|
|
191
|
+
const sectioned =
|
|
192
|
+
'<article><h1 class="doc-title">T</h1><section id="s"><h2>S</h2></section></article>'
|
|
193
|
+
expect(wrapLooseBodyRun(sectioned)).toBe(sectioned)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('maps the table grid flag into a border token', () => {
|
|
197
|
+
const withGrid = themeToPureRenderTokenCss({
|
|
198
|
+
...DEFAULT_PURE_RENDER_THEME,
|
|
199
|
+
profile_rules: { table: { rule_weight: 'light', grid: 'lines' } },
|
|
200
|
+
})
|
|
201
|
+
expect(withGrid).toContain('--table-grid-width:1px;')
|
|
202
|
+
const without = themeToPureRenderTokenCss({
|
|
203
|
+
...DEFAULT_PURE_RENDER_THEME,
|
|
204
|
+
profile_rules: { table: { rule_weight: 'light' } },
|
|
205
|
+
})
|
|
206
|
+
expect(without).toContain('--table-grid-width:0;')
|
|
207
|
+
// Default (no profile rules) stays gridless.
|
|
208
|
+
expect(themeToPureRenderTokenCss(DEFAULT_PURE_RENDER_THEME)).toContain(
|
|
209
|
+
'--table-grid-width:0;',
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('overrides page margins from theme.page.margin (sanitized)', async () => {
|
|
214
|
+
const { themePageOverridesCss } = await import('./styles.js')
|
|
215
|
+
const withMargin = themePageOverridesCss({
|
|
216
|
+
...DEFAULT_PURE_RENDER_THEME,
|
|
217
|
+
page: { ...DEFAULT_PURE_RENDER_THEME.page, margin: '14mm 12mm' },
|
|
218
|
+
})
|
|
219
|
+
expect(withMargin).toContain('@page{ margin:14mm 12mm; }')
|
|
220
|
+
// Junk values never reach the stylesheet.
|
|
221
|
+
const junk = themePageOverridesCss({
|
|
222
|
+
...DEFAULT_PURE_RENDER_THEME,
|
|
223
|
+
page: { ...DEFAULT_PURE_RENDER_THEME.page, margin: '12mm } body{color:red' },
|
|
224
|
+
})
|
|
225
|
+
expect(junk).not.toContain('body{color:red')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('maps page furniture settings into page override CSS', async () => {
|
|
229
|
+
const { themePageOverridesCss, buildPureRenderStylesheet } = await import(
|
|
230
|
+
'./styles.js'
|
|
231
|
+
)
|
|
232
|
+
const themed = {
|
|
233
|
+
...DEFAULT_PURE_RENDER_THEME,
|
|
234
|
+
page: { ...DEFAULT_PURE_RENDER_THEME.page, columns: 2 },
|
|
235
|
+
setting: {
|
|
236
|
+
...DEFAULT_PURE_RENDER_THEME.setting,
|
|
237
|
+
runningHeader: 'section' as const,
|
|
238
|
+
folio: 'none' as const,
|
|
239
|
+
logoDataUri: 'data:image/png;base64,AAAA',
|
|
240
|
+
logoSlot: 'bottom-right' as const,
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
const pageCss = themePageOverridesCss(themed)
|
|
244
|
+
expect(pageCss).toContain('@top-center{ content:string(sectitle)')
|
|
245
|
+
expect(pageCss).toContain('@bottom-center{ content:none }')
|
|
246
|
+
expect(pageCss).toContain(
|
|
247
|
+
'@bottom-right{ content:" "; background:url("data:image/png;base64,AAAA") no-repeat right center / contain',
|
|
248
|
+
)
|
|
249
|
+
expect(themeToPureRenderTokenCss(themed)).toContain('--body-columns:2;')
|
|
250
|
+
|
|
251
|
+
// The full stylesheet includes flourish rules AND page overrides.
|
|
252
|
+
const sheet = buildPureRenderStylesheet({ profile: 'writer', theme: themed })
|
|
253
|
+
expect(sheet).toContain('--eyebrow-display')
|
|
254
|
+
expect(sheet).toContain('.doc-title::before')
|
|
255
|
+
expect(sheet).toContain('column-count:var(--body-columns,1)')
|
|
256
|
+
expect(sheet).toContain('@top-center{ content:string(sectitle)')
|
|
257
|
+
|
|
258
|
+
// Untouched settings emit no overrides.
|
|
259
|
+
expect(themePageOverridesCss(DEFAULT_PURE_RENDER_THEME)).toBe('')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('never emits comma-lists of ::first-letter selectors (Paged.js crash)', async () => {
|
|
263
|
+
// Paged.js' stylesheet transformer throws "item doesn't belong to
|
|
264
|
+
// list" (css-tree List error) when a selector LIST contains multiple
|
|
265
|
+
// ::first-letter selectors. Single-selector rules are fine.
|
|
266
|
+
const { buildPureRenderStylesheet } = await import('./styles.js')
|
|
267
|
+
for (const profile of ['writer', 'manuscript', 'book'] as const) {
|
|
268
|
+
const sheet = buildPureRenderStylesheet({
|
|
269
|
+
profile,
|
|
270
|
+
theme: DEFAULT_PURE_RENDER_THEME,
|
|
271
|
+
}).replace(/\/\*[\s\S]*?\*\//g, '')
|
|
272
|
+
for (const rule of sheet.split('}')) {
|
|
273
|
+
const selector = rule.split('{')[0] ?? ''
|
|
274
|
+
const pseudoCount = selector.split('::first-letter').length - 1
|
|
275
|
+
if (pseudoCount > 1) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`selector list with ${pseudoCount} ::first-letter selectors: ${selector.trim()}`,
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
25
284
|
it('maps safe profile rules into CSS variables', () => {
|
|
26
285
|
const css = themeToPureRenderTokenCss({
|
|
27
286
|
...DEFAULT_PURE_RENDER_THEME,
|
|
@@ -18,7 +18,7 @@ export const DEFAULT_PURE_RENDER_THEME: PureRenderTheme = {
|
|
|
18
18
|
type: {
|
|
19
19
|
body: '10.6pt',
|
|
20
20
|
lead: 1.5,
|
|
21
|
-
measure: '
|
|
21
|
+
measure: '38rem',
|
|
22
22
|
scaleRatio: 1.333,
|
|
23
23
|
fonts: {
|
|
24
24
|
display: '"Archivo", system-ui, sans-serif',
|
|
@@ -72,6 +72,22 @@ export function themeToPureRenderTokenCss(
|
|
|
72
72
|
--theme-hyphenate:${theme.setting?.hyphenate === false ? 'manual' : 'auto'};
|
|
73
73
|
--text-align:var(--theme-text-align);
|
|
74
74
|
--hyphenate:var(--theme-hyphenate);
|
|
75
|
+
--heading-rule-width:${
|
|
76
|
+
theme.setting?.headingRule === 'underline' ? '1.5px' : '0'
|
|
77
|
+
};
|
|
78
|
+
--heading-rule-pad:${
|
|
79
|
+
theme.setting?.headingRule === 'underline' ? '.18em' : '0'
|
|
80
|
+
};
|
|
81
|
+
--eyebrow-display:${theme.setting?.eyebrowText?.trim() ? 'block' : 'none'};
|
|
82
|
+
--eyebrow-text:${JSON.stringify(theme.setting?.eyebrowText?.trim() ?? '')};
|
|
83
|
+
--drop-cap-float:${theme.setting?.dropCap ? 'left' : 'none'};
|
|
84
|
+
--drop-cap-size:${theme.setting?.dropCap ? '3.05em' : '1em'};
|
|
85
|
+
--drop-cap-lh:${theme.setting?.dropCap ? '.78' : 'inherit'};
|
|
86
|
+
--drop-cap-weight:${theme.setting?.dropCap ? '700' : 'inherit'};
|
|
87
|
+
--drop-cap-pad:${theme.setting?.dropCap ? '.06em .12em 0 0' : '0'};
|
|
88
|
+
--drop-cap-ink:${theme.setting?.dropCap ? 'var(--accent)' : 'inherit'};
|
|
89
|
+
--drop-cap-font:${theme.setting?.dropCap ? 'var(--display)' : 'inherit'};
|
|
90
|
+
--body-columns:${theme.page?.columns === 2 ? '2' : '1'};
|
|
75
91
|
${profileVars}
|
|
76
92
|
}`
|
|
77
93
|
}
|
|
@@ -131,6 +147,7 @@ function profileRuleCssVariables(theme: PureRenderTheme): string {
|
|
|
131
147
|
' --chapter-ornament-display:none;',
|
|
132
148
|
' --chapter-ornament-border:0;',
|
|
133
149
|
' --table-rule-width:1px;',
|
|
150
|
+
' --table-grid-width:0;',
|
|
134
151
|
' --table-row-rule-width:.5px;',
|
|
135
152
|
' --table-header-bg:transparent;',
|
|
136
153
|
' --table-header-ink:var(--ink);',
|
|
@@ -173,6 +190,7 @@ function profileRuleCssVariables(theme: PureRenderTheme): string {
|
|
|
173
190
|
};`,
|
|
174
191
|
` --chapter-ornament-border:${ornamentWidth};`,
|
|
175
192
|
` --table-rule-width:${tableRule};`,
|
|
193
|
+
` --table-grid-width:${rules.table?.grid === 'lines' ? tableRule : '0'};`,
|
|
176
194
|
` --table-row-rule-width:${density.rowRuleWidth};`,
|
|
177
195
|
` --table-header-bg:${header.bg};`,
|
|
178
196
|
` --table-header-ink:${header.ink};`,
|
|
@@ -63,6 +63,37 @@ export interface PureRenderTheme {
|
|
|
63
63
|
setting?: {
|
|
64
64
|
align?: 'justified' | 'flush-left' | string
|
|
65
65
|
hyphenate?: boolean
|
|
66
|
+
/** Accent rule beneath section headings. */
|
|
67
|
+
headingRule?: 'none' | 'underline'
|
|
68
|
+
/** Small metadata kicker above the document title; empty/absent = off. */
|
|
69
|
+
eyebrowText?: string
|
|
70
|
+
/** Oversized accent first letter on the opening paragraph. */
|
|
71
|
+
dropCap?: boolean
|
|
72
|
+
/** Running header content in the top margin (suppressed on page 1). */
|
|
73
|
+
runningHeader?: 'none' | 'title' | 'section' | 'custom'
|
|
74
|
+
/** Freeform running-header text when runningHeader is 'custom'. */
|
|
75
|
+
runningHeaderText?: string
|
|
76
|
+
/** Page-number footer; 'none' hides it. */
|
|
77
|
+
folio?: 'page' | 'none'
|
|
78
|
+
/** Where the page number sits. */
|
|
79
|
+
folioSlot?: 'bottom-center' | 'bottom-left' | 'bottom-right' | 'top-right'
|
|
80
|
+
/** Page-number treatment. */
|
|
81
|
+
folioStyle?: 'plain' | 'accent' | 'of-total'
|
|
82
|
+
/** Standalone cover: the title block gets page 1 to itself. */
|
|
83
|
+
coverPage?: boolean
|
|
84
|
+
/** Cover page background (any CSS colour). */
|
|
85
|
+
coverBackground?: string
|
|
86
|
+
/** Full-bleed cover image (data URI); layered over the background. */
|
|
87
|
+
coverImageDataUri?: string
|
|
88
|
+
/** Author line on the cover. */
|
|
89
|
+
coverAuthor?: string
|
|
90
|
+
/** Date line on the cover. */
|
|
91
|
+
coverDate?: string
|
|
92
|
+
/** Generated table of contents after the title block. */
|
|
93
|
+
toc?: boolean
|
|
94
|
+
/** Logo image (data URI) placed in a page margin box. */
|
|
95
|
+
logoDataUri?: string
|
|
96
|
+
logoSlot?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
|
66
97
|
}
|
|
67
98
|
enforced_rules?: readonly string[]
|
|
68
99
|
profile_rules?: PureRenderProfileRules
|
|
@@ -96,6 +127,8 @@ export interface PureRenderProfileRules {
|
|
|
96
127
|
rule_weight?: 'hairline' | 'light' | 'medium' | string
|
|
97
128
|
header_treatment?: string
|
|
98
129
|
density?: 'airy' | 'balanced' | 'dense' | string
|
|
130
|
+
/** 'lines' draws vertical cell rules + an outer border (full grid). */
|
|
131
|
+
grid?: 'none' | 'lines' | string
|
|
99
132
|
}
|
|
100
133
|
caption?: {
|
|
101
134
|
position?: 'above' | 'below' | string
|
|
@@ -42,6 +42,32 @@ describe('inspectPureRenderVisualLayout', () => {
|
|
|
42
42
|
expect(result.candidate?.id).toBe('compact-figure')
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
+
it('flags colliding blocks and proposes containment, escalating to vision', () => {
|
|
46
|
+
const result = inspectPureRenderVisualLayout({
|
|
47
|
+
usedHeightRatio: 0.9,
|
|
48
|
+
overlappingBlocks: 3,
|
|
49
|
+
overflowingBlocks: 1,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(result.issues).toContain('content-overlap')
|
|
53
|
+
expect(result.issues).toContain('content-overflow')
|
|
54
|
+
expect(result.decision).toBe('rerender-candidate')
|
|
55
|
+
// Deterministic containment first; vision look if geometry survives.
|
|
56
|
+
expect(result.candidate?.id).toBe('contain-overflow')
|
|
57
|
+
expect(result.candidate?.cssPatch).toContain('table-layout:fixed')
|
|
58
|
+
expect(result.requiresVisionReview).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('stays quiet when geometry is clean', () => {
|
|
62
|
+
const result = inspectPureRenderVisualLayout({
|
|
63
|
+
usedHeightRatio: 0.9,
|
|
64
|
+
overlappingBlocks: 0,
|
|
65
|
+
overflowingBlocks: 0,
|
|
66
|
+
})
|
|
67
|
+
expect(result.issues).not.toContain('content-overlap')
|
|
68
|
+
expect(result.issues).not.toContain('content-overflow')
|
|
69
|
+
})
|
|
70
|
+
|
|
45
71
|
it('rejects caption orphan candidates deterministically', () => {
|
|
46
72
|
const result = inspectPureRenderVisualLayout({
|
|
47
73
|
imagePage: 2,
|
|
@@ -5,6 +5,8 @@ export type PureRenderVisualIssue =
|
|
|
5
5
|
| 'caption-orphan'
|
|
6
6
|
| 'image-not-visible-on-risk-page'
|
|
7
7
|
| 'blank-page'
|
|
8
|
+
| 'content-overlap'
|
|
9
|
+
| 'content-overflow'
|
|
8
10
|
|
|
9
11
|
export type PureRenderVisualDecision =
|
|
10
12
|
| 'observe'
|
|
@@ -31,6 +33,8 @@ export interface PureRenderVisualLayoutMetrics {
|
|
|
31
33
|
topGapRatio?: number
|
|
32
34
|
bottomGapRatio?: number
|
|
33
35
|
usedHeightRatio?: number
|
|
36
|
+
overlappingBlocks?: number
|
|
37
|
+
overflowingBlocks?: number
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export interface PureRenderVisualCandidate {
|
|
@@ -92,6 +96,32 @@ export function inspectPureRenderVisualLayout(
|
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
if (
|
|
100
|
+
issues.includes('content-overlap') ||
|
|
101
|
+
issues.includes('content-overflow')
|
|
102
|
+
) {
|
|
103
|
+
// Geometry says blocks collide or escape their box (e.g. a wide table
|
|
104
|
+
// bleeding into the next column). Try deterministic containment first;
|
|
105
|
+
// if the collision survives, this page needs a vision look.
|
|
106
|
+
return {
|
|
107
|
+
decision: 'rerender-candidate',
|
|
108
|
+
recommendation:
|
|
109
|
+
'Blocks overlap or overflow their box on this page — try the containment candidate; if geometry still collides afterwards, escalate to vision review.',
|
|
110
|
+
requiresUserReview: true,
|
|
111
|
+
requiresVisionReview: true,
|
|
112
|
+
issues,
|
|
113
|
+
metrics: normalized,
|
|
114
|
+
candidate: {
|
|
115
|
+
id: 'contain-overflow',
|
|
116
|
+
label: 'Contain overflow candidate',
|
|
117
|
+
summary:
|
|
118
|
+
'Constrain tables, code blocks and media to their column so colliding blocks reflow.',
|
|
119
|
+
cssPatch:
|
|
120
|
+
'table{ table-layout:fixed; width:100%; } td,th{ overflow-wrap:break-word; word-break:break-word; } .table-wrap{ max-width:100%; } pre{ white-space:pre-wrap; overflow-wrap:anywhere; max-width:100%; } img,svg{ max-width:100%; height:auto; }',
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
95
125
|
if (canTryCompactFigure) {
|
|
96
126
|
return {
|
|
97
127
|
decision: 'rerender-compact-figure-candidate',
|
|
@@ -123,6 +153,45 @@ export function inspectPureRenderVisualLayout(
|
|
|
123
153
|
}
|
|
124
154
|
}
|
|
125
155
|
|
|
156
|
+
export type PureRenderSnapshotRole =
|
|
157
|
+
| 'problem'
|
|
158
|
+
| 'figure'
|
|
159
|
+
| 'image'
|
|
160
|
+
| 'caption'
|
|
161
|
+
| 'sample'
|
|
162
|
+
| 'candidate'
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Which pages of a paged render are worth snapshotting for inspection:
|
|
166
|
+
* the metric-flagged problem/heading/figure/image/caption pages, else page 1
|
|
167
|
+
* as a sample. Shared by the app-side vision typesetting loops.
|
|
168
|
+
*/
|
|
169
|
+
export function relevantPagedSnapshotPages(
|
|
170
|
+
metrics: PureRenderVisualLayoutMetrics | undefined,
|
|
171
|
+
pageCount: number,
|
|
172
|
+
): Array<{ page: number; role: PureRenderSnapshotRole }> {
|
|
173
|
+
const pages: Array<{ page: number; role: PureRenderSnapshotRole }> = []
|
|
174
|
+
const add = (value: number | undefined, role: PureRenderSnapshotRole) => {
|
|
175
|
+
if (
|
|
176
|
+
typeof value !== 'number' ||
|
|
177
|
+
!Number.isFinite(value) ||
|
|
178
|
+
value < 1 ||
|
|
179
|
+
value > pageCount ||
|
|
180
|
+
pages.some(item => item.page === value)
|
|
181
|
+
) {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
pages.push({ page: value, role })
|
|
185
|
+
}
|
|
186
|
+
add(metrics?.problemPage, 'problem')
|
|
187
|
+
add(metrics?.headingPage, 'problem')
|
|
188
|
+
add(metrics?.figurePage, 'figure')
|
|
189
|
+
add(metrics?.imagePage, 'image')
|
|
190
|
+
add(metrics?.captionPage, 'caption')
|
|
191
|
+
if (pages.length === 0) add(1, 'sample')
|
|
192
|
+
return pages.sort((a, b) => a.page - b.page)
|
|
193
|
+
}
|
|
194
|
+
|
|
126
195
|
export function visualIssuesForMetrics(
|
|
127
196
|
metrics: PureRenderVisualLayoutMetrics,
|
|
128
197
|
): PureRenderVisualIssue[] {
|
|
@@ -157,5 +226,11 @@ export function visualIssuesForMetrics(
|
|
|
157
226
|
if (metrics.imageVisible === false) {
|
|
158
227
|
issues.push('image-not-visible-on-risk-page')
|
|
159
228
|
}
|
|
229
|
+
if ((metrics.overlappingBlocks ?? 0) > 0) {
|
|
230
|
+
issues.push('content-overlap')
|
|
231
|
+
}
|
|
232
|
+
if ((metrics.overflowingBlocks ?? 0) > 0) {
|
|
233
|
+
issues.push('content-overflow')
|
|
234
|
+
}
|
|
160
235
|
return Array.from(new Set(issues))
|
|
161
236
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface UseDocumentHotkeysOptions {
|
|
4
|
+
onSave?: () => void
|
|
5
|
+
onNew?: () => void
|
|
6
|
+
onOpen?: () => void
|
|
7
|
+
enabled?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Standard document shortcuts inside an app iframe: ⌘S (name-or-flash),
|
|
12
|
+
* ⌘N (new document), ⌘O (document switcher). Apps receive their own key
|
|
13
|
+
* events, so this binds within the app document only.
|
|
14
|
+
*/
|
|
15
|
+
export function useDocumentHotkeys({
|
|
16
|
+
onSave,
|
|
17
|
+
onNew,
|
|
18
|
+
onOpen,
|
|
19
|
+
enabled = true,
|
|
20
|
+
}: UseDocumentHotkeysOptions): void {
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!enabled) return
|
|
23
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
24
|
+
const meta = event.metaKey || event.ctrlKey
|
|
25
|
+
if (!meta || event.altKey || event.shiftKey) return
|
|
26
|
+
const key = event.key.toLowerCase()
|
|
27
|
+
if (key === 's' && onSave) {
|
|
28
|
+
event.preventDefault()
|
|
29
|
+
onSave()
|
|
30
|
+
} else if (key === 'n' && onNew) {
|
|
31
|
+
event.preventDefault()
|
|
32
|
+
onNew()
|
|
33
|
+
} else if (key === 'o' && onOpen) {
|
|
34
|
+
event.preventDefault()
|
|
35
|
+
onOpen()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
window.addEventListener('keydown', onKeyDown)
|
|
39
|
+
return () => window.removeEventListener('keydown', onKeyDown)
|
|
40
|
+
}, [enabled, onNew, onOpen, onSave])
|
|
41
|
+
}
|