@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.
Files changed (64) hide show
  1. package/package.json +55 -1
  2. package/src/bridge/agentTypes.ts +21 -1
  3. package/src/bridge/collectionImagePaste.ts +19 -1
  4. package/src/bridge/context.mjs +32 -0
  5. package/src/bridge/documents.d.mts +76 -0
  6. package/src/bridge/documents.mjs +40 -0
  7. package/src/bridge/methods.d.mts +23 -0
  8. package/src/bridge/methods.mjs +23 -0
  9. package/src/bridge/pureRender/base.css +44 -0
  10. package/src/bridge/pureRender/document.ts +29 -2
  11. package/src/bridge/pureRender/index.ts +3 -0
  12. package/src/bridge/pureRender/normalize.ts +167 -0
  13. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  14. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  15. package/src/bridge/pureRender/styles.ts +125 -5
  16. package/src/bridge/pureRender/theme.test.ts +259 -0
  17. package/src/bridge/pureRender/theme.ts +19 -1
  18. package/src/bridge/pureRender/types.ts +33 -0
  19. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  20. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  21. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  22. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  23. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  24. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  25. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  26. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  27. package/src/bridge/storage.d.mts +3 -1
  28. package/src/bridge/storage.test.ts +2 -2
  29. package/src/bridge/types.ts +7 -0
  30. package/src/components/agents/AgentDrawerPanel.tsx +2 -0
  31. package/src/components/agents/AgentMessageList.tsx +6 -1
  32. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  33. package/src/components/agents/ContextPicker.tsx +239 -0
  34. package/src/components/agents/agentPanelStyles.ts +3 -4
  35. package/src/components/agents/agentTypes.ts +2 -0
  36. package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
  37. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  38. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  39. package/src/components/common/containers/AppFrame.tsx +10 -1
  40. package/src/components/common/containers/AppHeader.tsx +22 -0
  41. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  42. package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
  43. package/src/components/common/documents/index.ts +8 -0
  44. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  45. package/src/components/common/research/index.ts +2 -0
  46. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  47. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  48. package/src/components/print-design/index.ts +2 -0
  49. package/src/components/print-design/previewContent.test.ts +32 -0
  50. package/src/components/print-design/previewContent.ts +31 -0
  51. package/src/context/buildContextDocument.test.ts +240 -0
  52. package/src/context/buildContextDocument.ts +309 -0
  53. package/src/context/contextAccess.ts +66 -0
  54. package/src/context/contextConfig.ts +72 -0
  55. package/src/context/contextDocument.test.ts +155 -0
  56. package/src/context/contextDocument.ts +300 -0
  57. package/src/context/contextSections.test.ts +88 -0
  58. package/src/context/contextSections.ts +84 -0
  59. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  60. package/src/context/htmlToContextMarkdown.ts +369 -0
  61. package/src/theme/appAccents.test.ts +36 -35
  62. package/src/theme/appAccents.ts +87 -104
  63. package/src/theme/appIdentityCss.mjs +169 -231
  64. 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: '34rem',
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
+ }