@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
@@ -1,4 +1,12 @@
1
- import { Check, Download, FileImage, WandSparkles, X } from 'lucide-react'
1
+ import {
2
+ Check,
3
+ Download,
4
+ FileImage,
5
+ Play,
6
+ Square,
7
+ WandSparkles,
8
+ X,
9
+ } from 'lucide-react'
2
10
  import type { CSSProperties, ReactNode } from 'react'
3
11
  import { useEffect, useId, useMemo, useRef, useState } from 'react'
4
12
  import { styled } from 'styled-components'
@@ -65,6 +73,14 @@ export interface PrintDesignVisionTypesetting {
65
73
  snapshotUrl?: string
66
74
  snapshotAlt?: string
67
75
  onSnapshotOpen?: () => void
76
+ /** Issue chips beside the latest page snapshot, flipped to fixed as patches land. */
77
+ snapshotIssues?: readonly PrintDesignVisionSnapshotIssue[]
78
+ /**
79
+ * Accumulating per-render snapshot strip: every checked page and fix
80
+ * attempt appends a card (image + issue chips) instead of replacing the
81
+ * previous one, so a run reads as scrollable progress.
82
+ */
83
+ snapshotHistory?: readonly PrintDesignVisionSnapshotHistoryEntry[]
68
84
  maxFixesPerIssue?: number
69
85
  onMaxFixesPerIssueChange?: (value: number) => void
70
86
  exportLabel?: string
@@ -72,6 +88,34 @@ export interface PrintDesignVisionTypesetting {
72
88
  exportDisabled?: boolean
73
89
  onToggle?: (enabled: boolean) => void | Promise<void>
74
90
  onExport?: () => void
91
+ /** Stop a running typesetting loop; shown while progress.state is running. */
92
+ onStop?: () => void
93
+ }
94
+
95
+ export interface PrintDesignLivePreview {
96
+ /** Post-layout Paged.js document HTML (no scripts run on display). */
97
+ html?: string
98
+ pageCount?: number
99
+ loading?: boolean
100
+ }
101
+
102
+ export interface PrintDesignVisionSnapshotIssue {
103
+ id: string
104
+ label: string
105
+ state: 'open' | 'fixed'
106
+ }
107
+
108
+ export interface PrintDesignVisionSnapshotHistoryEntry {
109
+ id: string
110
+ url: string
111
+ /** e.g. "Page 2" or "Page 2 · fix 1" */
112
+ label: string
113
+ alt?: string
114
+ /** Empty/omitted issues render a single pass chip. */
115
+ issues?: readonly PrintDesignVisionSnapshotIssue[]
116
+ /** Inspector comment shown with the card (recommendation, fix summary). */
117
+ note?: string
118
+ onOpen?: () => void
75
119
  }
76
120
 
77
121
  export interface PrintDesignVisionProgress {
@@ -118,6 +162,13 @@ export interface PrintDesignVisionSnapshot {
118
162
  onOpen?: () => void
119
163
  }
120
164
 
165
+ /** Host-document excerpt shown in the live preview instead of sample copy. */
166
+ export interface PrintDesignPreviewDocument {
167
+ title?: string
168
+ byline?: string
169
+ paragraphs?: readonly string[]
170
+ }
171
+
121
172
  export interface PrintDesignPanelProps {
122
173
  presets?: readonly PrintDesignThemePreset[]
123
174
  value?: PureRenderTheme
@@ -125,6 +176,17 @@ export interface PrintDesignPanelProps {
125
176
  density?: PrintDesignPanelDensity
126
177
  headerAccessory?: ReactNode
127
178
  modes?: readonly PrintDesignMode[]
179
+ /**
180
+ * The document being exported, for the live preview. When present, the
181
+ * preview typesets THIS content (a "Sample" checkbox switches back to the
182
+ * built-in copy); when absent, the sample is used.
183
+ */
184
+ previewDocument?: PrintDesignPreviewDocument
185
+ /**
186
+ * Real Paged.js live preview (post-layout HTML, all pages). When present
187
+ * it replaces the styled facsimile in the workspace well.
188
+ */
189
+ livePreview?: PrintDesignLivePreview
128
190
  actions?: readonly PrintDesignPanelAction[]
129
191
  extractionCapability?: PrintDesignExtractionCapability
130
192
  visionTypesetting?: PrintDesignVisionTypesetting
@@ -152,6 +214,12 @@ export interface PrintDesignPopoverProps {
152
214
  const DEFAULT_PRESETS: readonly PrintDesignThemePreset[] =
153
215
  PURE_RENDER_THEME_PRESETS
154
216
 
217
+ const SAMPLE_PREVIEW_PARAGRAPHS: readonly string[] = [
218
+ 'Typography is the craft of endowing human language with a durable visual form. A page earns trust quietly: a steady measure, an even rhythm, ink that sits comfortably on paper.',
219
+ 'The reader should notice none of it. Margins hold the text the way hands hold a book, and the leading you chose above sets the pace at which the eye walks each line — no faster, no slower.',
220
+ 'Change a control and this page follows: display and body faces, the accent that marks structure, the alignment of this very paragraph.',
221
+ ]
222
+
155
223
  const DEFAULT_VISION_SAMPLE_REPORT = [
156
224
  ['sample', 'No export inspection has run yet.'],
157
225
  [
@@ -210,11 +278,32 @@ function snapshotDisplayLabel(
210
278
  const Panel = styled.section<{
211
279
  $variant: PrintDesignPanelVariant
212
280
  $density: PrintDesignPanelDensity
281
+ $mode: PrintDesignMode
213
282
  }>`
214
283
  --print-design-page-surface: var(--platform-colors-elevated, #fff);
215
284
  --print-design-panel-surface: var(--platform-colors-surface, #ffffff);
216
285
  --print-design-preview-paper: #fff;
217
286
  --platform-panel-tab-surface: var(--print-design-page-surface);
287
+ /* Shared export component: identity is INHERITED from the host app via
288
+ --app-acc / --app-block (Writer citron, Manuscript olive, …). Never a
289
+ baked-in colour. --app-block is the deep desaturated variant for the one
290
+ large fill (the Export primary) so white text stays legible on
291
+ light-base hosts. */
292
+ --print-design-hue: var(--app-acc, var(--platform-colors-accent));
293
+ --print-design-hue-block: var(
294
+ --app-block,
295
+ var(--app-acc, var(--platform-colors-accent))
296
+ );
297
+ --print-design-mono: var(
298
+ --platform-typography-font-family-mono,
299
+ 'JetBrains Mono',
300
+ ui-monospace,
301
+ monospace
302
+ );
303
+ --print-design-faint: var(
304
+ --ink-faint,
305
+ var(--platform-colors-text-secondary)
306
+ );
218
307
 
219
308
  display: grid;
220
309
  gap: ${({ $variant }) =>
@@ -230,7 +319,9 @@ const Panel = styled.section<{
230
319
  $variant === 'workspace'
231
320
  ? `
232
321
  flex: 1 1 auto;
233
- grid-template-columns: minmax(0, 1fr) minmax(380px, 440px);
322
+ /* Merged Typesetter: DesignMain (controls | well) plus a slim right
323
+ rail carrying the run summary/Activity ledger. */
324
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 320px);
234
325
  grid-template-rows: auto minmax(0, 1fr);
235
326
  align-content: stretch;
236
327
  align-items: stretch;
@@ -398,6 +489,8 @@ const WorkspaceSummaryText = styled.span`
398
489
  max-width: 260px;
399
490
  overflow: hidden;
400
491
  text-overflow: ellipsis;
492
+ color: var(--print-design-faint);
493
+ font-family: var(--print-design-mono);
401
494
  `
402
495
 
403
496
  const WorkspaceSummarySwatches = styled.span`
@@ -424,11 +517,36 @@ const HeaderActionButton = styled(Button)`
424
517
  white-space: nowrap;
425
518
  `
426
519
 
520
+ /**
521
+ * The one identity-carrying primary in this component. Uses the host's
522
+ * --app-block (deep desaturated hue, white text) so it reads correctly on
523
+ * light-base hosts like Writer/Manuscript — never a hard-coded colour.
524
+ */
525
+ const ExportPrimaryButton = styled(HeaderActionButton)`
526
+ && {
527
+ border-color: var(--print-design-hue-block);
528
+ background: var(--print-design-hue-block);
529
+ color: #fff;
530
+ }
531
+
532
+ &&:hover:not(:disabled) {
533
+ border-color: var(--print-design-hue-block);
534
+ background: color-mix(
535
+ in oklch,
536
+ var(--print-design-hue-block) 92%,
537
+ white 8%
538
+ );
539
+ }
540
+ `
541
+
427
542
  const SectionTitle = styled.div`
428
- color: var(--platform-colors-text-secondary);
429
- font-size: var(--platform-typography-font-size-xs);
543
+ color: var(--print-design-faint);
544
+ font-family: var(--print-design-mono);
545
+ font-size: 10px;
430
546
  font-weight: var(--platform-typography-font-weight-semibold);
547
+ letter-spacing: 0.08em;
431
548
  line-height: 1.2;
549
+ text-transform: uppercase;
432
550
  `
433
551
 
434
552
  const DesignSection = styled.section<{ $variant: PrintDesignPanelVariant }>`
@@ -481,18 +599,15 @@ const DesignMain = styled.div<{
481
599
  gap: var(--platform-spacing-sm);
482
600
  background: var(--print-design-page-surface);
483
601
 
484
- ${({ $variant, $mode }) =>
602
+ ${({ $variant }) =>
485
603
  $variant === 'workspace'
486
604
  ? `
605
+ /* Merged view: slim controls column, the page well takes the rest. */
487
606
  grid-column: 1 / 2;
488
607
  grid-row: 2 / 3;
489
- grid-template-columns: ${
490
- $mode === 'vision' ? 'minmax(0, 1fr)' : 'minmax(0, 1fr)'
491
- };
492
- grid-template-rows: ${
493
- $mode === 'vision' ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto'
494
- };
495
- gap: ${$mode === 'vision' ? '0' : 'var(--platform-spacing-md)'};
608
+ grid-template-columns: minmax(320px, 390px) minmax(0, 1fr);
609
+ grid-template-rows: minmax(0, 1fr) auto;
610
+ gap: var(--platform-spacing-md);
496
611
  min-height: 0;
497
612
  overflow: hidden;
498
613
  `
@@ -513,33 +628,18 @@ const WorkspaceControls = styled.div<{
513
628
  ? `
514
629
  display: grid;
515
630
  align-content: start;
516
- gap: var(--platform-spacing-md);
631
+ gap: var(--platform-spacing-lg);
517
632
  min-width: 0;
518
633
  min-height: 0;
519
634
  overflow: auto;
635
+ grid-column: 1 / 2;
636
+ grid-row: 1 / 2;
520
637
  padding-right: var(--platform-spacing-md);
521
638
  border-right: 1px solid var(--platform-colors-divider);
522
639
  `
523
640
  : `
524
641
  display: contents;
525
642
  `}
526
-
527
- ${({ $variant, $mode }) =>
528
- $variant === 'workspace' && $mode !== 'vision'
529
- ? `
530
- grid-column: 1 / 2;
531
- grid-row: 1 / 2;
532
- padding-right: var(--platform-spacing-md);
533
- border-right: 0;
534
- `
535
- : ''}
536
-
537
- ${({ $variant, $mode }) =>
538
- $variant === 'workspace' && $mode === 'vision'
539
- ? `
540
- display: none;
541
- `
542
- : ''}
543
643
  `
544
644
 
545
645
  const WorkspaceFooter = styled.div<{
@@ -553,29 +653,14 @@ const WorkspaceFooter = styled.div<{
553
653
  align-self: end;
554
654
  gap: var(--platform-spacing-sm);
555
655
  min-width: 0;
656
+ grid-column: 1 / 2;
657
+ grid-row: 2 / 3;
556
658
  padding-right: var(--platform-spacing-md);
557
659
  border-right: 1px solid var(--platform-colors-divider);
558
660
  `
559
661
  : `
560
662
  display: contents;
561
663
  `}
562
-
563
- ${({ $variant, $mode }) =>
564
- $variant === 'workspace' && $mode === 'vision'
565
- ? `
566
- display: none;
567
- `
568
- : ''}
569
-
570
- ${({ $variant, $mode }) =>
571
- $variant === 'workspace' && $mode !== 'vision'
572
- ? `
573
- grid-column: 1 / 2;
574
- grid-row: 2 / 3;
575
- padding-right: var(--platform-spacing-md);
576
- border-right: 0;
577
- `
578
- : ''}
579
664
  `
580
665
 
581
666
  const WorkspaceCanvas = styled.div<{
@@ -586,7 +671,8 @@ const WorkspaceCanvas = styled.div<{
586
671
  $variant === 'workspace'
587
672
  ? `
588
673
  display: grid;
589
- align-content: start;
674
+ grid-template-rows: minmax(0, 1fr);
675
+ align-content: stretch;
590
676
  gap: var(--platform-spacing-md);
591
677
  min-width: 0;
592
678
  min-height: 0;
@@ -598,22 +684,6 @@ const WorkspaceCanvas = styled.div<{
598
684
  : `
599
685
  display: contents;
600
686
  `}
601
-
602
- ${({ $variant, $mode }) =>
603
- $variant === 'workspace' && $mode !== 'vision'
604
- ? `
605
- display: none;
606
- `
607
- : ''}
608
-
609
- ${({ $variant, $mode }) =>
610
- $variant === 'workspace' && $mode === 'vision'
611
- ? `
612
- grid-column: 1 / 2;
613
- grid-row: 1 / 2;
614
- padding-right: var(--platform-spacing-md);
615
- `
616
- : ''}
617
687
  `
618
688
 
619
689
  const DesignReadout = styled.aside<{ $variant: PrintDesignPanelVariant }>`
@@ -650,6 +720,157 @@ const DesignReadout = styled.aside<{ $variant: PrintDesignPanelVariant }>`
650
720
  : ''}
651
721
  `
652
722
 
723
+ /**
724
+ * Live typeset preview: a miniature page rendered from the ACTIVE theme —
725
+ * fonts, ink/paper/accent, lead, alignment — updating with every control.
726
+ * The stage sits on --well so the page floats like paper.
727
+ */
728
+ const PreviewStage = styled.div`
729
+ container-type: inline-size;
730
+ display: grid;
731
+ align-content: start;
732
+ justify-items: center;
733
+ gap: var(--platform-spacing-sm);
734
+ width: 100%;
735
+ min-height: 0;
736
+ padding: var(--platform-spacing-md);
737
+ border-radius: var(--platform-radius-md);
738
+ background: var(--well, var(--platform-colors-surface-active, #eceef1));
739
+ overflow: auto;
740
+ `
741
+
742
+ const PreviewPage = styled.div<{
743
+ $paper: string
744
+ $ink: string
745
+ $bodyFont: string
746
+ $lead: number
747
+ $justified: boolean
748
+ }>`
749
+ /* Fill the preview column (the whole point of the pane) and size type
750
+ with the container so the page reads like a page at any width. NOTE:
751
+ padding is in cqw, not % — percentage padding resolves against the
752
+ CONTAINING BLOCK's width, which blew the margins up to stage-width
753
+ proportions. */
754
+ width: min(92cqw, 720px);
755
+ aspect-ratio: 1 / 1.414;
756
+ padding: 6.5cqw 6cqw;
757
+ background: ${({ $paper }) => $paper};
758
+ box-shadow:
759
+ 0 1px 2px rgba(15, 18, 20, 0.14),
760
+ 0 10px 28px rgba(15, 18, 20, 0.14);
761
+ color: ${({ $ink }) => $ink};
762
+ font-family: ${({ $bodyFont }) => $bodyFont};
763
+ font-size: clamp(10.5px, 1.7cqw, 16px);
764
+ line-height: ${({ $lead }) => $lead};
765
+ text-align: ${({ $justified }) => ($justified ? 'justify' : 'left')};
766
+ overflow: hidden;
767
+ user-select: none;
768
+ `
769
+
770
+ const PreviewKicker = styled.div<{ $accent: string; $labelFont: string }>`
771
+ margin-bottom: 0.9em;
772
+ color: ${({ $accent }) => $accent};
773
+ font-family: ${({ $labelFont }) => $labelFont};
774
+ font-size: 0.72em;
775
+ letter-spacing: 0.14em;
776
+ text-transform: uppercase;
777
+ `
778
+
779
+ const PreviewTitle = styled.div<{ $displayFont: string }>`
780
+ margin-bottom: 0.35em;
781
+ font-family: ${({ $displayFont }) => $displayFont};
782
+ font-size: 1.8em;
783
+ font-weight: 600;
784
+ line-height: 1.18;
785
+ text-align: left;
786
+ `
787
+
788
+ const PreviewByline = styled.div`
789
+ margin-bottom: 1.4em;
790
+ font-size: 0.82em;
791
+ opacity: 0.72;
792
+ text-align: left;
793
+ `
794
+
795
+ const PreviewRule = styled.div<{ $accent: string }>`
796
+ height: 1.5px;
797
+ margin-bottom: 1.4em;
798
+ background: ${({ $accent }) => $accent};
799
+ `
800
+
801
+ const PreviewParagraph = styled.p`
802
+ margin: 0 0 0.9em;
803
+ hyphens: auto;
804
+ `
805
+
806
+ const PreviewFolio = styled.div`
807
+ margin-top: 1.6em;
808
+ font-family: var(--print-design-mono);
809
+ font-size: 0.72em;
810
+ opacity: 0.55;
811
+ text-align: center;
812
+ `
813
+
814
+ /** Vision mode: the LIVE page snapshot from the Paged.js inspection pass. */
815
+ const PreviewSnapshotButton = styled.button`
816
+ display: block;
817
+ width: min(92cqw, 720px);
818
+ padding: 0;
819
+ border: 1px solid var(--platform-colors-border);
820
+ border-radius: 2px;
821
+ background: var(--print-design-preview-paper);
822
+ box-shadow:
823
+ 0 1px 2px rgba(15, 18, 20, 0.14),
824
+ 0 10px 28px rgba(15, 18, 20, 0.14);
825
+ cursor: pointer;
826
+
827
+ img {
828
+ display: block;
829
+ width: 100%;
830
+ height: auto;
831
+ }
832
+ `
833
+
834
+ const PreviewSampleToggle = styled.label`
835
+ display: inline-flex;
836
+ flex: 0 0 auto;
837
+ align-items: center;
838
+ gap: 5px;
839
+ color: var(--print-design-faint);
840
+ font-family: var(--print-design-mono);
841
+ font-size: 10px;
842
+ letter-spacing: 0.08em;
843
+ text-transform: uppercase;
844
+ cursor: pointer;
845
+
846
+ input {
847
+ margin: 0;
848
+ accent-color: var(--print-design-hue);
849
+ }
850
+
851
+ &[data-disabled='true'] {
852
+ opacity: 0.5;
853
+ cursor: default;
854
+ }
855
+ `
856
+
857
+ const PreviewMeta = styled.div`
858
+ max-width: 100%;
859
+ overflow: hidden;
860
+ text-overflow: ellipsis;
861
+ white-space: nowrap;
862
+ color: var(--print-design-faint);
863
+ font-family: var(--print-design-mono);
864
+ font-size: var(--platform-typography-font-size-xs);
865
+ `
866
+
867
+ const ActivityScroll = styled.div<{ $tall?: boolean }>`
868
+ /* In vision mode the report IS the rail's main content — let it run tall. */
869
+ max-height: ${({ $tall }) => ($tall ? 'none' : '200px')};
870
+ min-height: 0;
871
+ overflow: auto;
872
+ `
873
+
653
874
  const ModeTabs = styled(PanelTabStrip<PrintDesignMode>)`
654
875
  grid-area: nav;
655
876
  justify-self: start;
@@ -702,12 +923,14 @@ const PresetOption = styled.button<{
702
923
  padding: ${({ $density }) =>
703
924
  $density === 'compact' ? '3px 8px' : '5px 9px'};
704
925
  border: 1px solid var(--platform-colors-border);
926
+ /* Selected preset carries the HOST hue (edge + underline), not the theme
927
+ accent — identity marks the choice, preset palettes stay content. */
705
928
  border-color: ${({ $active }) =>
706
- $active
707
- ? 'var(--platform-colors-accent, var(--platform-colors-border))'
708
- : 'var(--platform-colors-border)'};
929
+ $active ? 'var(--print-design-hue)' : 'var(--platform-colors-border)'};
709
930
  border-radius: var(--platform-radius-sm);
710
931
  background: var(--print-design-panel-surface);
932
+ box-shadow: ${({ $active }) =>
933
+ $active ? 'inset 0 -2px 0 0 var(--print-design-hue)' : 'none'};
711
934
  color: var(--platform-colors-text);
712
935
  font: inherit;
713
936
  font-size: var(--platform-typography-font-size-xs);
@@ -747,8 +970,9 @@ const ControlStack = styled.div`
747
970
 
748
971
  const ReferenceGrid = styled.div<{ $variant: PrintDesignPanelVariant }>`
749
972
  display: grid;
973
+ /* Workspace: the controls column is narrow — stack Image and Describe. */
750
974
  grid-template-columns: ${({ $variant }) =>
751
- $variant === 'popover' || $variant === 'workspace'
975
+ $variant === 'popover'
752
976
  ? 'minmax(240px, 0.72fr) minmax(360px, 1.28fr)'
753
977
  : 'minmax(0, 1fr)'};
754
978
  gap: var(--platform-spacing-sm);
@@ -790,8 +1014,11 @@ const VisionPanel = styled.div<{ $variant: PrintDesignPanelVariant }>`
790
1014
  $variant === 'workspace'
791
1015
  ? `
792
1016
  grid-template-columns: minmax(0, 1fr);
793
- align-items: start;
1017
+ grid-template-rows: auto minmax(0, 1fr);
1018
+ align-content: stretch;
1019
+ align-items: stretch;
794
1020
  gap: var(--platform-spacing-sm);
1021
+ min-height: 0;
795
1022
  `
796
1023
  : ''}
797
1024
  `
@@ -812,11 +1039,11 @@ const VisionSettingsRail = styled.div<{ $variant: PrintDesignPanelVariant }>`
812
1039
  `
813
1040
 
814
1041
  const VisionEvidenceColumn = styled.div`
815
- display: grid;
1042
+ display: flex;
1043
+ flex-direction: column;
816
1044
  gap: var(--platform-spacing-sm);
817
1045
  min-width: 0;
818
1046
  min-height: 0;
819
- align-content: start;
820
1047
  `
821
1048
 
822
1049
  const VisionSettingsStrip = styled.div`
@@ -1226,16 +1453,17 @@ const ManualGrid = styled.div<{
1226
1453
  $variant === 'popover'
1227
1454
  ? 'repeat(3, minmax(132px, 156px))'
1228
1455
  : $variant === 'workspace'
1229
- ? 'repeat(3, minmax(160px, 1fr))'
1456
+ ? 'repeat(2, minmax(160px, 1fr))'
1230
1457
  : $density === 'compact'
1231
1458
  ? 'repeat(2, minmax(0, 1fr))'
1232
1459
  : 'repeat(3, minmax(0, 1fr))'};
1460
+ /* 8px rhythm between tune rows. */
1233
1461
  gap: ${({ $density, $variant }) =>
1234
1462
  $variant === 'popover'
1235
1463
  ? 'var(--platform-spacing-xs) var(--platform-spacing-sm)'
1236
1464
  : $density === 'compact'
1237
1465
  ? 'var(--platform-spacing-xs)'
1238
- : 'var(--platform-spacing-sm)'};
1466
+ : '8px 16px'};
1239
1467
  justify-content: start;
1240
1468
  max-width: ${({ $variant }) => ($variant === 'popover' ? '504px' : 'none')};
1241
1469
 
@@ -1254,10 +1482,13 @@ const ManualGrid = styled.div<{
1254
1482
  const ManualField = styled.label<{ $span?: 'wide' }>`
1255
1483
  display: grid;
1256
1484
  min-width: 0;
1257
- gap: 3px;
1258
- color: var(--platform-colors-text-secondary);
1259
- font-size: var(--platform-typography-font-size-xs);
1485
+ gap: 4px;
1486
+ color: var(--print-design-faint);
1487
+ font-family: var(--print-design-mono);
1488
+ font-size: 10px;
1489
+ letter-spacing: 0.08em;
1260
1490
  line-height: 1.2;
1491
+ text-transform: uppercase;
1261
1492
 
1262
1493
  ${({ $span }) =>
1263
1494
  $span === 'wide'
@@ -1271,7 +1502,7 @@ const ManualField = styled.label<{ $span?: 'wide' }>`
1271
1502
  : ''}
1272
1503
  `
1273
1504
 
1274
- const ManualInput = styled.input`
1505
+ const ManualInput = styled.input<{ $mono?: boolean }>`
1275
1506
  width: 100%;
1276
1507
  min-width: 0;
1277
1508
  min-height: 28px;
@@ -1281,15 +1512,64 @@ const ManualInput = styled.input`
1281
1512
  background: var(--print-design-page-surface);
1282
1513
  color: var(--platform-colors-text);
1283
1514
  font: inherit;
1515
+ font-family: ${({ $mono }) =>
1516
+ $mono ? 'var(--print-design-mono)' : 'inherit'};
1284
1517
  font-size: var(--platform-typography-font-size-xs);
1518
+ letter-spacing: normal;
1285
1519
  line-height: 1.2;
1520
+ text-transform: none;
1521
+ `
1522
+
1523
+ /**
1524
+ * Ink / Paper / Accent as SWATCH CHIPS + hex, not full-width colour slabs
1525
+ * (the fill-large mistake — a giant Accent bar read as identity; these are
1526
+ * the DOCUMENT's colours and stay user-set, just presented small). The chip
1527
+ * is bordered so a white Paper value still reads as a control, and the
1528
+ * invisible native input keeps the OS picker.
1529
+ */
1530
+ const ColorChipsRow = styled.div`
1531
+ grid-column: 1 / -1;
1532
+ display: flex;
1533
+ flex-wrap: wrap;
1534
+ gap: 8px 16px;
1535
+ `
1536
+
1537
+ const ColorChipSwatch = styled.span<{ $color: string }>`
1538
+ position: relative;
1539
+ display: inline-block;
1540
+ flex: 0 0 auto;
1541
+ width: 28px;
1542
+ height: 28px;
1543
+ border: 1px solid var(--platform-colors-border);
1544
+ border-radius: var(--platform-radius-sm);
1545
+ background: ${({ $color }) => $color};
1546
+ cursor: pointer;
1286
1547
 
1287
- &[type='color'] {
1288
- height: 28px;
1289
- padding: 2px;
1548
+ input {
1549
+ position: absolute;
1550
+ inset: 0;
1551
+ width: 100%;
1552
+ height: 100%;
1553
+ opacity: 0;
1554
+ cursor: pointer;
1290
1555
  }
1291
1556
  `
1292
1557
 
1558
+ const ColorChipValue = styled.span`
1559
+ align-self: center;
1560
+ color: var(--platform-colors-text);
1561
+ font-family: var(--print-design-mono);
1562
+ font-size: var(--platform-typography-font-size-xs);
1563
+ letter-spacing: normal;
1564
+ text-transform: lowercase;
1565
+ `
1566
+
1567
+ const ColorChipControl = styled.span`
1568
+ display: inline-flex;
1569
+ align-items: center;
1570
+ gap: 8px;
1571
+ `
1572
+
1293
1573
  const ManualSelect = styled.select`
1294
1574
  width: 100%;
1295
1575
  min-width: 0;
@@ -1345,8 +1625,9 @@ const StatusText = styled.span`
1345
1625
  overflow: hidden;
1346
1626
  text-overflow: ellipsis;
1347
1627
  white-space: nowrap;
1348
- color: var(--platform-colors-text-secondary);
1349
- font-size: var(--platform-typography-font-size-sm);
1628
+ color: var(--print-design-faint);
1629
+ font-family: var(--print-design-mono);
1630
+ font-size: var(--platform-typography-font-size-xs);
1350
1631
  line-height: var(--platform-typography-line-height-base);
1351
1632
  `
1352
1633
 
@@ -1418,53 +1699,225 @@ const DecisionType = styled.span<{ $type: string }>`
1418
1699
  white-space: nowrap;
1419
1700
  `
1420
1701
 
1421
- const DecisionMessage = styled(Text)`
1702
+ const VisionPagesScroll = styled.div`
1703
+ /* The grey well: same treatment as the app page surfaces — pages sit
1704
+ forward on a sunken ground so each snapshot reads as a PAGE. */
1705
+ display: flex;
1706
+ flex-direction: column;
1707
+ gap: var(--platform-spacing-xl, 32px);
1708
+ /* Fill the canvas column — no dead white band under the well. */
1709
+ flex: 1 1 auto;
1710
+ min-height: 0;
1711
+ overflow-y: auto;
1712
+ scroll-behavior: smooth;
1713
+ padding: var(--platform-spacing-lg, 24px);
1714
+ background: var(--well, var(--platform-colors-background, #ececec));
1715
+ border: 1px solid var(--platform-colors-border);
1716
+ border-radius: var(--platform-radius-sm);
1717
+ `
1718
+
1719
+ const VisionPageCard = styled.div`
1720
+ /* Page + its annotation rail travel as one centered unit — the notes sit
1721
+ right beside the page, not stranded at the far edge of the well. */
1722
+ display: flex;
1723
+ justify-content: center;
1724
+ align-items: flex-start;
1725
+ gap: var(--platform-spacing-md);
1422
1726
  min-width: 0;
1423
- display: block;
1424
- padding-top: 1px;
1727
+ `
1728
+
1729
+ const VisionPageImageButton = styled.button`
1730
+ display: flex;
1731
+ justify-content: center;
1732
+ max-width: 100%;
1733
+ padding: 0;
1734
+ border: none;
1735
+ background: transparent;
1736
+ cursor: zoom-in;
1737
+
1738
+ img {
1739
+ display: block;
1740
+ /* Fit the WHOLE page in the frame. */
1741
+ max-height: 60vh;
1742
+ max-width: 100%;
1743
+ width: auto;
1744
+ height: auto;
1745
+ background: #fff;
1746
+ box-shadow:
1747
+ 0 1px 2px rgb(0 0 0 / 0.16),
1748
+ 0 10px 28px rgb(0 0 0 / 0.14);
1749
+ }
1750
+ `
1751
+
1752
+ const VisionPageAnnotations = styled.div`
1753
+ /* White rail: the card's label, chips and inspector note on a raised
1754
+ surface beside the page. */
1755
+ display: flex;
1756
+ flex-direction: column;
1757
+ gap: 8px;
1758
+ flex: 0 0 240px;
1759
+ width: 240px;
1760
+ padding: 12px 14px;
1761
+ background: var(--platform-colors-elevated, #fff);
1762
+ border: 1px solid var(--platform-colors-border);
1763
+ border-radius: var(--platform-radius-sm);
1764
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
1765
+ `
1766
+
1767
+ const VisionPageNote = styled.span`
1425
1768
  color: var(--platform-colors-text-secondary);
1426
1769
  font-size: var(--platform-typography-font-size-xs);
1427
- line-height: 1.55;
1770
+ line-height: 1.5;
1428
1771
  overflow-wrap: break-word;
1429
- word-break: break-word;
1430
- white-space: normal;
1431
1772
  `
1432
1773
 
1433
- const DecisionColor = styled.span<{ $color: string }>`
1434
- display: inline-flex;
1435
- align-items: center;
1436
- gap: 3px;
1437
- margin: 0 2px;
1438
- white-space: nowrap;
1774
+ /**
1775
+ * Presentation chrome injected into the live preview document: the grey
1776
+ * well behind centered pages with a paper shadow — matching the snapshot
1777
+ * strip. The iframe cannot read the app's CSS variables, so the well
1778
+ * colour is a fixed fallback.
1779
+ */
1780
+ const LIVE_PREVIEW_CHROME = `<style data-live-preview-chrome>
1781
+ html, body { background: #eceef1 !important; }
1782
+ .pagedjs_pages { display: flex; flex-direction: column; align-items: center; gap: 28px; padding: 28px 0; }
1783
+ .pagedjs_page { background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / 0.16), 0 10px 28px rgb(0 0 0 / 0.14); }
1784
+ </style>`
1785
+
1786
+ function livePreviewDocument(html: string): string {
1787
+ return html.includes('</head>')
1788
+ ? html.replace('</head>', `${LIVE_PREVIEW_CHROME}</head>`)
1789
+ : `${LIVE_PREVIEW_CHROME}${html}`
1790
+ }
1439
1791
 
1440
- &::before {
1441
- content: '';
1442
- width: 0.75em;
1443
- height: 0.75em;
1444
- border: 1px solid var(--platform-colors-border);
1445
- border-radius: 2px;
1446
- background: ${({ $color }) => $color};
1447
- }
1792
+ const LivePreviewFrame = styled.iframe`
1793
+ flex: 1 1 auto;
1794
+ width: 100%;
1795
+ min-height: 480px;
1796
+ border: 1px solid var(--platform-colors-border);
1797
+ border-radius: var(--platform-radius-sm);
1798
+ background: var(--well, var(--platform-colors-background, #ececec));
1448
1799
  `
1449
1800
 
1450
- const DecisionLink = styled.a`
1451
- color: var(--platform-colors-accent);
1452
- text-decoration: none;
1453
-
1454
- &:hover {
1455
- text-decoration: underline;
1456
- }
1801
+ const WellViewSwitch = styled.div`
1802
+ display: flex;
1803
+ align-items: center;
1804
+ gap: 6px;
1457
1805
  `
1458
1806
 
1459
- const HiddenFile = styled.input`
1460
- width: 1px;
1461
- height: 1px;
1462
- opacity: 0;
1463
- position: absolute;
1464
- pointer-events: none;
1807
+ const WellViewButton = styled.button<{ $active: boolean }>`
1808
+ font-family: var(--print-design-mono);
1809
+ font-size: 10px;
1810
+ letter-spacing: 0.06em;
1811
+ text-transform: uppercase;
1812
+ padding: 4px 10px;
1813
+ border-radius: var(--platform-radius-sm);
1814
+ border: 1px solid
1815
+ ${({ $active }) =>
1816
+ $active ? 'var(--print-design-hue)' : 'var(--platform-colors-border)'};
1817
+ background: ${({ $active }) =>
1818
+ $active
1819
+ ? 'color-mix(in srgb, var(--print-design-hue) 10%, transparent)'
1820
+ : 'transparent'};
1821
+ color: var(--platform-colors-text);
1822
+ cursor: pointer;
1465
1823
  `
1466
1824
 
1467
- export function PrintDesignPopover({
1825
+ const PageZoomOverlay = styled.div`
1826
+ position: fixed;
1827
+ inset: 0;
1828
+ z-index: 60;
1829
+ display: flex;
1830
+ align-items: center;
1831
+ justify-content: center;
1832
+ background: rgb(15 17 21 / 0.62);
1833
+ cursor: zoom-out;
1834
+ `
1835
+
1836
+ const PageZoomImage = styled.img`
1837
+ max-width: 92vw;
1838
+ max-height: 92vh;
1839
+ width: auto;
1840
+ height: auto;
1841
+ background: #fff;
1842
+ box-shadow: 0 24px 80px rgb(0 0 0 / 0.45);
1843
+ `
1844
+
1845
+ const SnapshotHistoryLabel = styled.span`
1846
+ font-family: var(--print-design-mono);
1847
+ font-size: 10px;
1848
+ letter-spacing: 0.08em;
1849
+ text-transform: uppercase;
1850
+ color: var(--print-design-faint, var(--platform-colors-text-secondary));
1851
+ `
1852
+
1853
+ const SnapshotIssueList = styled.div`
1854
+ display: flex;
1855
+ flex-direction: column;
1856
+ gap: 4px;
1857
+ margin-top: 8px;
1858
+ `
1859
+
1860
+ const SnapshotIssueRow = styled.div`
1861
+ display: flex;
1862
+ align-items: center;
1863
+ gap: 8px;
1864
+ min-width: 0;
1865
+ `
1866
+
1867
+ const SnapshotIssueLabel = styled.span<{ $fixed: boolean }>`
1868
+ color: var(--platform-colors-text-secondary);
1869
+ font-size: var(--platform-typography-font-size-xs);
1870
+ text-decoration: ${({ $fixed }) => ($fixed ? 'line-through' : 'none')};
1871
+ opacity: ${({ $fixed }) => ($fixed ? 0.7 : 1)};
1872
+ `
1873
+
1874
+ const DecisionMessage = styled(Text)`
1875
+ min-width: 0;
1876
+ display: block;
1877
+ padding-top: 1px;
1878
+ color: var(--platform-colors-text-secondary);
1879
+ font-size: var(--platform-typography-font-size-xs);
1880
+ line-height: 1.55;
1881
+ overflow-wrap: break-word;
1882
+ word-break: break-word;
1883
+ white-space: normal;
1884
+ `
1885
+
1886
+ const DecisionColor = styled.span<{ $color: string }>`
1887
+ display: inline-flex;
1888
+ align-items: center;
1889
+ gap: 3px;
1890
+ margin: 0 2px;
1891
+ white-space: nowrap;
1892
+
1893
+ &::before {
1894
+ content: '';
1895
+ width: 0.75em;
1896
+ height: 0.75em;
1897
+ border: 1px solid var(--platform-colors-border);
1898
+ border-radius: 2px;
1899
+ background: ${({ $color }) => $color};
1900
+ }
1901
+ `
1902
+
1903
+ const DecisionLink = styled.a`
1904
+ color: var(--platform-colors-accent);
1905
+ text-decoration: none;
1906
+
1907
+ &:hover {
1908
+ text-decoration: underline;
1909
+ }
1910
+ `
1911
+
1912
+ const HiddenFile = styled.input`
1913
+ width: 1px;
1914
+ height: 1px;
1915
+ opacity: 0;
1916
+ position: absolute;
1917
+ pointer-events: none;
1918
+ `
1919
+
1920
+ export function PrintDesignPopover({
1468
1921
  open,
1469
1922
  children,
1470
1923
  title = 'Design - PDF Typesetter',
@@ -1623,6 +2076,15 @@ function paperColorForTheme(theme: PureRenderTheme): string {
1623
2076
  return theme.color.paper ?? theme.color.surface ?? '#FFFFFF'
1624
2077
  }
1625
2078
 
2079
+ /** Numeric leading for the live preview page (type.lead is number | string). */
2080
+ function previewLeadValue(theme: PureRenderTheme): number {
2081
+ const lead =
2082
+ typeof theme.type.lead === 'number'
2083
+ ? theme.type.lead
2084
+ : Number.parseFloat(theme.type.lead)
2085
+ return Number.isFinite(lead) && lead >= 1.1 && lead <= 2.4 ? lead : 1.5
2086
+ }
2087
+
1626
2088
  function fontRoleForFamily(family: string | undefined): 'serif' | 'sans' {
1627
2089
  return /sans|inter|arial|helvetica|system-ui/i.test(family ?? '')
1628
2090
  ? 'sans'
@@ -1846,10 +2308,15 @@ export function PrintDesignPanel({
1846
2308
  actions = [],
1847
2309
  extractionCapability,
1848
2310
  visionTypesetting,
2311
+ previewDocument,
2312
+ livePreview,
1849
2313
  onThemeChange,
1850
2314
  onProfileExtractRequest,
1851
2315
  }: PrintDesignPanelProps): React.ReactElement {
1852
2316
  const imageInputId = useId()
2317
+ // Preview shows the host document by default; "Sample" opts back into the
2318
+ // built-in copy (and is forced when the host supplies no content).
2319
+ const [useSampleContent, setUseSampleContent] = useState(false)
1853
2320
  const [mode, setMode] = useState<PrintDesignMode>('preset')
1854
2321
  const [presetId, setPresetId] = useState(presets[0]?.id ?? 'default')
1855
2322
  const [description, setDescription] = useState('')
@@ -1880,6 +2347,59 @@ export function PrintDesignPanel({
1880
2347
  const [manualAlign, setManualAlign] = useState<'justified' | 'flush-left'>(
1881
2348
  'justified',
1882
2349
  )
2350
+ const [manualHeadingRule, setManualHeadingRule] = useState<
2351
+ 'none' | 'underline'
2352
+ >('none')
2353
+ const [manualEyebrow, setManualEyebrow] = useState('')
2354
+ const [manualDropCap, setManualDropCap] = useState(false)
2355
+ const [manualColumns, setManualColumns] = useState<1 | 2>(1)
2356
+ const [manualMarginY, setManualMarginY] = useState('')
2357
+ const [manualMarginX, setManualMarginX] = useState('')
2358
+ const [manualTableRule, setManualTableRule] = useState<
2359
+ 'hairline' | 'light' | 'medium'
2360
+ >('light')
2361
+ const [manualTableHeader, setManualTableHeader] = useState<
2362
+ 'default' | 'plain' | 'shaded' | 'accent'
2363
+ >('default')
2364
+ const [manualTableDensity, setManualTableDensity] = useState<
2365
+ 'airy' | 'regular' | 'compact'
2366
+ >('regular')
2367
+ const [manualTableGrid, setManualTableGrid] = useState(false)
2368
+ const [manualRunningHeader, setManualRunningHeader] = useState<
2369
+ 'none' | 'title' | 'section' | 'custom'
2370
+ >('none')
2371
+ const [manualRunningHeaderText, setManualRunningHeaderText] = useState('')
2372
+ const [manualFolio, setManualFolio] = useState<'page' | 'none'>('page')
2373
+ const [manualFolioSlot, setManualFolioSlot] = useState<
2374
+ 'bottom-center' | 'bottom-left' | 'bottom-right' | 'top-right'
2375
+ >('bottom-center')
2376
+ const [manualFolioStyle, setManualFolioStyle] = useState<
2377
+ 'plain' | 'accent' | 'of-total'
2378
+ >('plain')
2379
+ const [manualCoverPage, setManualCoverPage] = useState(false)
2380
+ const [manualCoverBackground, setManualCoverBackground] = useState('')
2381
+ const [manualCoverAuthor, setManualCoverAuthor] = useState('')
2382
+ const [manualCoverDate, setManualCoverDate] = useState('')
2383
+ const [manualCoverImage, setManualCoverImage] = useState('')
2384
+ const [manualToc, setManualToc] = useState(false)
2385
+ const coverImageInputRef = useRef<HTMLInputElement | null>(null)
2386
+ // Workspace: the Reference (image/describe) inputs start collapsed.
2387
+ const [referenceOpen, setReferenceOpen] = useState(false)
2388
+ const [manualLogo, setManualLogo] = useState('')
2389
+ const [manualLogoSlot, setManualLogoSlot] = useState<
2390
+ 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
2391
+ >('top-right')
2392
+ // Which face the well shows once a run exists: the live preview or the
2393
+ // last run's annotated pages. Style edits flip back to live and mark the
2394
+ // pages stale until the next Run.
2395
+ const [wellView, setWellView] = useState<'live' | 'pages'>('pages')
2396
+ const [stripStale, setStripStale] = useState(false)
2397
+ // In-window zoom for page snapshots — no external viewer.
2398
+ const [zoomedPage, setZoomedPage] = useState<{
2399
+ url: string
2400
+ alt: string
2401
+ } | null>(null)
2402
+ const logoFileInputRef = useRef<HTMLInputElement | null>(null)
1883
2403
  const [decisions, setDecisions] = useState<readonly [string, string][]>([])
1884
2404
  const [status, setStatus] = useState<string>(
1885
2405
  onProfileExtractRequest ? 'Ready' : 'Preset/manual only',
@@ -1898,9 +2418,15 @@ export function PrintDesignPanel({
1898
2418
  }, [modes])
1899
2419
 
1900
2420
  useEffect(() => {
2421
+ // Workspace is ONE merged view — pin mode to 'preset' so no stale tab
2422
+ // state (or host default) can hide the design controls.
2423
+ if (variant === 'workspace') {
2424
+ if (mode !== 'preset') setMode('preset')
2425
+ return
2426
+ }
1901
2427
  if (modeOptions.some(option => option.value === mode)) return
1902
2428
  setMode(modeOptions[0]?.value ?? 'preset')
1903
- }, [mode, modeOptions])
2429
+ }, [mode, modeOptions, variant])
1904
2430
 
1905
2431
  useEffect(() => {
1906
2432
  if (!value) return
@@ -1940,8 +2466,61 @@ export function PrintDesignPanel({
1940
2466
  setManualMeasure(activeTheme.type.measure)
1941
2467
  setManualLead(String(activeTheme.type.lead))
1942
2468
  setManualAlign(manualAlignForTheme(activeTheme.setting?.align))
2469
+ setManualHeadingRule(
2470
+ activeTheme.setting?.headingRule === 'underline' ? 'underline' : 'none',
2471
+ )
2472
+ setManualEyebrow(activeTheme.setting?.eyebrowText ?? '')
2473
+ setManualDropCap(activeTheme.setting?.dropCap === true)
2474
+ setManualColumns(activeTheme.page?.columns === 2 ? 2 : 1)
2475
+ const marginParts = activeTheme.page?.margin?.trim().split(/\s+/) ?? []
2476
+ setManualMarginY(marginParts[0] ?? '')
2477
+ setManualMarginX(marginParts[1] ?? marginParts[0] ?? '')
2478
+ const tableRules = activeTheme.profile_rules?.table
2479
+ setManualTableRule(
2480
+ tableRules?.rule_weight === 'hairline' ||
2481
+ tableRules?.rule_weight === 'medium'
2482
+ ? tableRules.rule_weight
2483
+ : 'light',
2484
+ )
2485
+ setManualTableHeader(
2486
+ tableRules?.header_treatment === 'plain' ||
2487
+ tableRules?.header_treatment === 'shaded' ||
2488
+ tableRules?.header_treatment === 'accent'
2489
+ ? tableRules.header_treatment
2490
+ : 'default',
2491
+ )
2492
+ setManualTableDensity(
2493
+ tableRules?.density === 'airy'
2494
+ ? 'airy'
2495
+ : tableRules?.density === 'dense' || tableRules?.density === 'compact'
2496
+ ? 'compact'
2497
+ : 'regular',
2498
+ )
2499
+ setManualTableGrid(tableRules?.grid === 'lines')
2500
+ setManualRunningHeader(activeTheme.setting?.runningHeader ?? 'none')
2501
+ setManualRunningHeaderText(activeTheme.setting?.runningHeaderText ?? '')
2502
+ setManualFolio(activeTheme.setting?.folio === 'none' ? 'none' : 'page')
2503
+ setManualFolioSlot(activeTheme.setting?.folioSlot ?? 'bottom-center')
2504
+ setManualFolioStyle(activeTheme.setting?.folioStyle ?? 'plain')
2505
+ setManualCoverPage(activeTheme.setting?.coverPage === true)
2506
+ setManualCoverBackground(activeTheme.setting?.coverBackground ?? '')
2507
+ setManualCoverAuthor(activeTheme.setting?.coverAuthor ?? '')
2508
+ setManualCoverDate(activeTheme.setting?.coverDate ?? '')
2509
+ setManualCoverImage(activeTheme.setting?.coverImageDataUri ?? '')
2510
+ setManualToc(activeTheme.setting?.toc === true)
2511
+ setManualLogo(activeTheme.setting?.logoDataUri ?? '')
2512
+ setManualLogoSlot(activeTheme.setting?.logoSlot ?? 'top-right')
1943
2513
  }, [activeTheme])
1944
2514
 
2515
+ useEffect(() => {
2516
+ if (!zoomedPage) return
2517
+ const onKey = (event: KeyboardEvent): void => {
2518
+ if (event.key === 'Escape') setZoomedPage(null)
2519
+ }
2520
+ window.addEventListener('keydown', onKey)
2521
+ return () => window.removeEventListener('keydown', onKey)
2522
+ }, [zoomedPage])
2523
+
1945
2524
  function extractionUnavailableReason(
1946
2525
  targetMode: PrintDesignMode,
1947
2526
  ): string | undefined {
@@ -1979,7 +2558,10 @@ export function PrintDesignPanel({
1979
2558
  ? `${images.length} images`
1980
2559
  : images[0]?.name ?? 'Choose image'
1981
2560
  const imageButtonLabel = images.length > 0 ? 'Replace image' : 'Choose image'
1982
- const unifiedDesignMode = mode === 'preset'
2561
+ // The merged workspace view has no tabs — design controls ALWAYS show
2562
+ // there, whatever mode state was left behind (e.g. 'vision' from before
2563
+ // the merge, preserved across HMR or by a host).
2564
+ const unifiedDesignMode = mode === 'preset' || variant === 'workspace'
1983
2565
  const rawVisionReport = visionTypesetting?.report?.length
1984
2566
  ? visionTypesetting.report
1985
2567
  : []
@@ -1998,6 +2580,66 @@ export function PrintDesignPanel({
1998
2580
  ? [...liveReadoutEntries].reverse()
1999
2581
  : liveReadoutEntries
2000
2582
  const visionEvidence = visionTypesetting?.evidence ?? []
2583
+ // The vision canvas owns ALL information — there is no right rail in
2584
+ // vision mode (the report lives in Design mode's Activity section).
2585
+ const snapshotHistory =
2586
+ mode === 'vision' || variant === 'workspace'
2587
+ ? visionTypesetting?.snapshotHistory ?? []
2588
+ : []
2589
+ // New cards append at the bottom — keep the strip pinned to the latest
2590
+ // render so a running sweep reads as progress.
2591
+ const snapshotHistoryRef = useRef<HTMLDivElement | null>(null)
2592
+ useEffect(() => {
2593
+ const node = snapshotHistoryRef.current
2594
+ if (!node) return
2595
+ if (typeof node.scrollTo === 'function') {
2596
+ node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' })
2597
+ } else {
2598
+ node.scrollTop = node.scrollHeight
2599
+ }
2600
+ }, [snapshotHistory.length])
2601
+ // Bidirectional contract: new run output pulls the well back to the
2602
+ // pages view and clears staleness; a cleared history resets it.
2603
+ const prevHistoryLengthRef = useRef(0)
2604
+ useEffect(() => {
2605
+ const prev = prevHistoryLengthRef.current
2606
+ prevHistoryLengthRef.current = snapshotHistory.length
2607
+ if (snapshotHistory.length === 0) {
2608
+ setStripStale(false)
2609
+ return
2610
+ }
2611
+ if (snapshotHistory.length > prev) {
2612
+ setWellView('pages')
2613
+ setStripStale(false)
2614
+ }
2615
+ }, [snapshotHistory.length])
2616
+ // Style edits make the last run's snapshots stale — show the live
2617
+ // preview until the user runs typesetting again.
2618
+ const markDesignEdited = (): void => {
2619
+ if (prevHistoryLengthRef.current > 0) {
2620
+ setStripStale(true)
2621
+ setWellView('live')
2622
+ }
2623
+ }
2624
+ const hasHostPreviewContent = Boolean(
2625
+ previewDocument?.paragraphs?.length || previewDocument?.title?.trim(),
2626
+ )
2627
+ const previewUsesSample = useSampleContent || !hasHostPreviewContent
2628
+ const previewContent = previewUsesSample
2629
+ ? {
2630
+ title: 'The Shape of a Page',
2631
+ byline: `A. Reader · Typeset with ${activeTheme.name}`,
2632
+ paragraphs: SAMPLE_PREVIEW_PARAGRAPHS,
2633
+ }
2634
+ : {
2635
+ title: previewDocument?.title?.trim() || 'Untitled document',
2636
+ byline:
2637
+ previewDocument?.byline?.trim() ||
2638
+ `Typeset with ${activeTheme.name}`,
2639
+ paragraphs: previewDocument?.paragraphs?.length
2640
+ ? previewDocument.paragraphs.slice(0, 4)
2641
+ : SAMPLE_PREVIEW_PARAGRAPHS,
2642
+ }
2001
2643
  const imageSectionEnabled = !modes || modes.includes('image')
2002
2644
  const descriptionSectionEnabled = !modes || modes.includes('description')
2003
2645
  const showPresets = unifiedDesignMode
@@ -2013,24 +2655,40 @@ export function PrintDesignPanel({
2013
2655
  action => /export/i.test(action.id) || /export\s+pdf/i.test(action.label),
2014
2656
  )
2015
2657
  const secondaryActions = actions.filter(action => action !== exportAction)
2016
- const routeExportThroughVision =
2017
- visionTypesetting?.enabled === true &&
2018
- visionTypesetting.onExport !== undefined &&
2019
- exportAction !== undefined
2020
- const headerExportDisabled =
2021
- exportAction?.disabled ||
2022
- exportAction?.busy ||
2023
- (routeExportThroughVision &&
2024
- (visionTypesetting?.exportDisabled || visionTypesetting?.exportBusy))
2025
- const headerExportBusy = routeExportThroughVision
2026
- ? visionTypesetting?.exportBusy
2027
- : exportAction?.busy
2658
+ // Export ALWAYS just exports the PDF. The vision inspection loop is its
2659
+ // own explicit action — a separate Run button shown only in the vision
2660
+ // tab (pressing Export there previously ran the whole loop, and there was
2661
+ // no way to simply export).
2662
+ const headerExportDisabled = exportAction?.disabled || exportAction?.busy
2663
+ const headerExportBusy = exportAction?.busy
2028
2664
  const headerExportLabel =
2029
2665
  headerExportBusy && exportAction
2030
2666
  ? exportAction.busyLabel ?? exportAction.label
2031
- : (routeExportThroughVision
2032
- ? visionTypesetting?.exportLabel
2033
- : exportAction?.label) ?? exportAction?.label
2667
+ : exportAction?.label
2668
+ const visionRunning = visionTypesetting?.progress?.state === 'running'
2669
+ // While a loop runs, the Run button becomes STOP (the sweep walks every
2670
+ // page until end-of-document or the user halts it).
2671
+ const visionRunAction =
2672
+ (mode === 'vision' || variant === 'workspace') &&
2673
+ visionTypesetting?.enabled === true &&
2674
+ visionTypesetting.onExport !== undefined
2675
+ ? visionRunning && visionTypesetting.onStop
2676
+ ? {
2677
+ label: 'Stop',
2678
+ stop: true as const,
2679
+ disabled: false,
2680
+ onClick: visionTypesetting.onStop,
2681
+ }
2682
+ : {
2683
+ label: visionRunning ? 'Running…' : 'Run typesetting',
2684
+ stop: false as const,
2685
+ disabled:
2686
+ visionRunning ||
2687
+ visionTypesetting.exportBusy === true ||
2688
+ visionTypesetting.exportDisabled === true,
2689
+ onClick: visionTypesetting.onExport,
2690
+ }
2691
+ : null
2034
2692
  const showModeActionButton =
2035
2693
  mode !== 'preset' &&
2036
2694
  mode !== 'manual' &&
@@ -2043,6 +2701,7 @@ export function PrintDesignPanel({
2043
2701
  setDecisions([['apply', `${preset.label} preset selected`]])
2044
2702
  onThemeChange?.(preset.theme)
2045
2703
  setStatus('Ready')
2704
+ markDesignEdited()
2046
2705
  }
2047
2706
  }
2048
2707
 
@@ -2125,6 +2784,29 @@ export function PrintDesignPanel({
2125
2784
  measure: string
2126
2785
  lead: string
2127
2786
  align: 'justified' | 'flush-left'
2787
+ headingRule: 'none' | 'underline'
2788
+ eyebrowText: string
2789
+ dropCap: boolean
2790
+ columns: 1 | 2
2791
+ marginY: string
2792
+ marginX: string
2793
+ tableRule: 'hairline' | 'light' | 'medium'
2794
+ tableHeader: 'default' | 'plain' | 'shaded' | 'accent'
2795
+ tableDensity: 'airy' | 'regular' | 'compact'
2796
+ tableGrid: boolean
2797
+ runningHeader: 'none' | 'title' | 'section' | 'custom'
2798
+ runningHeaderText: string
2799
+ folio: 'page' | 'none'
2800
+ folioSlot: 'bottom-center' | 'bottom-left' | 'bottom-right' | 'top-right'
2801
+ folioStyle: 'plain' | 'accent' | 'of-total'
2802
+ coverPage: boolean
2803
+ coverBackground: string
2804
+ coverAuthor: string
2805
+ coverDate: string
2806
+ coverImageDataUri: string
2807
+ toc: boolean
2808
+ logoDataUri: string
2809
+ logoSlot: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
2128
2810
  }> = {},
2129
2811
  ): void {
2130
2812
  const ink = overrides.ink ?? manualInk
@@ -2135,6 +2817,38 @@ export function PrintDesignPanel({
2135
2817
  const labelRole = overrides.labelFont ?? manualLabelFont
2136
2818
  const measure = overrides.measure ?? manualMeasure
2137
2819
  const align = overrides.align ?? manualAlign
2820
+ const headingRule = overrides.headingRule ?? manualHeadingRule
2821
+ const eyebrowText = overrides.eyebrowText ?? manualEyebrow
2822
+ const dropCap = overrides.dropCap ?? manualDropCap
2823
+ const columns = overrides.columns ?? manualColumns
2824
+ const marginY = (overrides.marginY ?? manualMarginY).trim()
2825
+ const marginX = (overrides.marginX ?? manualMarginX).trim()
2826
+ const pageMargin = marginY
2827
+ ? marginX && marginX !== marginY
2828
+ ? `${marginY} ${marginX}`
2829
+ : marginY
2830
+ : undefined
2831
+ const tableRule = overrides.tableRule ?? manualTableRule
2832
+ const tableHeader = overrides.tableHeader ?? manualTableHeader
2833
+ const tableDensity = overrides.tableDensity ?? manualTableDensity
2834
+ const tableGrid = overrides.tableGrid ?? manualTableGrid
2835
+ const runningHeader = overrides.runningHeader ?? manualRunningHeader
2836
+ const runningHeaderText =
2837
+ overrides.runningHeaderText ?? manualRunningHeaderText
2838
+ const folio = overrides.folio ?? manualFolio
2839
+ const folioSlot = overrides.folioSlot ?? manualFolioSlot
2840
+ const folioStyle = overrides.folioStyle ?? manualFolioStyle
2841
+ const coverPage = overrides.coverPage ?? manualCoverPage
2842
+ const requestedToc = overrides.toc ?? manualToc
2843
+ const coverBackground = overrides.coverBackground ?? manualCoverBackground
2844
+ const coverAuthor = overrides.coverAuthor ?? manualCoverAuthor
2845
+ const coverDate = overrides.coverDate ?? manualCoverDate
2846
+ const coverImageDataUri =
2847
+ overrides.coverImageDataUri ?? manualCoverImage
2848
+ // Contents implies a cover.
2849
+ const toc = coverPage ? requestedToc : false
2850
+ const logoDataUri = overrides.logoDataUri ?? manualLogo
2851
+ const logoSlot = overrides.logoSlot ?? manualLogoSlot
2138
2852
  const leadText = overrides.lead ?? manualLead
2139
2853
  const lead = Number.parseFloat(leadText)
2140
2854
  const nextLead = Number.isFinite(lead) && lead >= 1.2 ? lead : 1.5
@@ -2165,10 +2879,31 @@ export function PrintDesignPanel({
2165
2879
  },
2166
2880
  },
2167
2881
  rhythm: `${(10.6 * nextLead).toFixed(1)}pt`,
2882
+ page: {
2883
+ ...activeTheme.page,
2884
+ columns,
2885
+ margin: pageMargin,
2886
+ },
2168
2887
  setting: {
2169
2888
  ...activeTheme.setting,
2170
2889
  align,
2171
2890
  hyphenate: align === 'justified',
2891
+ headingRule,
2892
+ eyebrowText: eyebrowText.trim() || undefined,
2893
+ dropCap,
2894
+ runningHeader,
2895
+ runningHeaderText: runningHeaderText.trim() || undefined,
2896
+ folio,
2897
+ folioSlot,
2898
+ folioStyle,
2899
+ coverPage,
2900
+ coverBackground: coverBackground.trim() || undefined,
2901
+ coverAuthor: coverAuthor.trim() || undefined,
2902
+ coverDate: coverDate.trim() || undefined,
2903
+ coverImageDataUri: coverImageDataUri.trim() || undefined,
2904
+ toc,
2905
+ logoDataUri: logoDataUri.trim() || undefined,
2906
+ logoSlot,
2172
2907
  },
2173
2908
  profile_rules: {
2174
2909
  ...activeTheme.profile_rules,
@@ -2176,6 +2911,19 @@ export function PrintDesignPanel({
2176
2911
  ...activeTheme.profile_rules?.caption,
2177
2912
  label_style: labelRole === 'sans' ? 'small-caps' : 'plain',
2178
2913
  },
2914
+ table: {
2915
+ ...activeTheme.profile_rules?.table,
2916
+ rule_weight: tableRule,
2917
+ density:
2918
+ tableDensity === 'airy'
2919
+ ? 'airy'
2920
+ : tableDensity === 'compact'
2921
+ ? 'dense'
2922
+ : 'balanced',
2923
+ header_treatment:
2924
+ tableHeader === 'default' ? undefined : tableHeader,
2925
+ grid: tableGrid ? 'lines' : 'none',
2926
+ },
2179
2927
  },
2180
2928
  enforced_rules: [
2181
2929
  ...(activeTheme.enforced_rules ?? []),
@@ -2190,6 +2938,7 @@ export function PrintDesignPanel({
2190
2938
  ])
2191
2939
  onThemeChange?.(theme)
2192
2940
  setStatus('Ready')
2941
+ markDesignEdited()
2193
2942
  }
2194
2943
 
2195
2944
  async function chooseImages(files: readonly File[]): Promise<void> {
@@ -2241,10 +2990,50 @@ export function PrintDesignPanel({
2241
2990
  }
2242
2991
  }
2243
2992
 
2993
+ // Live theme facsimile — shown in the popover readout AND as the merged
2994
+ // workspace well's idle state (until a run fills it with real pages).
2995
+ const facsimilePreview = (
2996
+ <PreviewStage aria-label="Live typeset preview">
2997
+ <PreviewPage
2998
+ $paper={paperColorForTheme(activeTheme)}
2999
+ $ink={activeTheme.color.ink}
3000
+ $bodyFont={activeTheme.type.fonts.serif}
3001
+ $lead={previewLeadValue(activeTheme)}
3002
+ $justified={activeTheme.setting?.align !== 'flush-left'}
3003
+ >
3004
+ <PreviewKicker
3005
+ $accent={activeTheme.color.accent}
3006
+ $labelFont={activeTheme.type.fonts.sans}
3007
+ >
3008
+ {previewUsesSample
3009
+ ? activeTheme.setting?.eyebrowText?.trim() || 'Research Article'
3010
+ : activeTheme.setting?.eyebrowText?.trim() || 'Your document'}
3011
+ </PreviewKicker>
3012
+ <PreviewTitle
3013
+ $displayFont={
3014
+ activeTheme.type.fonts.display ?? activeTheme.type.fonts.serif
3015
+ }
3016
+ >
3017
+ {previewContent.title}
3018
+ </PreviewTitle>
3019
+ <PreviewByline>{previewContent.byline}</PreviewByline>
3020
+ <PreviewRule $accent={activeTheme.color.accent} />
3021
+ {previewContent.paragraphs.map((paragraph, index) => (
3022
+ <PreviewParagraph key={index}>{paragraph}</PreviewParagraph>
3023
+ ))}
3024
+ <PreviewFolio>· 1 ·</PreviewFolio>
3025
+ </PreviewPage>
3026
+ <PreviewMeta title={activeDesignSummary}>
3027
+ {activeDesignSummary}
3028
+ </PreviewMeta>
3029
+ </PreviewStage>
3030
+ )
3031
+
2244
3032
  return (
2245
3033
  <Panel
2246
3034
  $variant={variant}
2247
3035
  $density={panelDensity}
3036
+ $mode={mode}
2248
3037
  data-density={panelDensity}
2249
3038
  data-panel-variant={variant}
2250
3039
  aria-label="Print design"
@@ -2265,36 +3054,59 @@ export function PrintDesignPanel({
2265
3054
  </WorkspaceSummary>
2266
3055
  </WorkspaceIdentity>
2267
3056
  ) : null}
2268
- <ModeTabs
2269
- items={modeOptions.map(option => ({
2270
- id: option.value,
2271
- label: option.label,
2272
- }))}
2273
- activeId={mode}
2274
- onSelect={setMode}
2275
- data-mode-count={modeOptions.length}
2276
- aria-label="Print design source"
2277
- />
2278
- {exportAction || headerAccessory ? (
3057
+ {variant !== 'workspace' ? (
3058
+ // Workspace is ONE merged Typesetter view — no Design/vision tabs.
3059
+ <ModeTabs
3060
+ items={modeOptions.map(option => ({
3061
+ id: option.value,
3062
+ label: option.label,
3063
+ }))}
3064
+ activeId={mode}
3065
+ onSelect={setMode}
3066
+ data-mode-count={modeOptions.length}
3067
+ aria-label="Print design source"
3068
+ />
3069
+ ) : null}
3070
+ {exportAction || headerAccessory || visionRunAction ? (
2279
3071
  <HeaderActions>
2280
3072
  {headerAccessory}
2281
- {exportAction ? (
3073
+ {visionRunAction ? (
2282
3074
  <HeaderActionButton
2283
3075
  type="button"
2284
- variant={exportAction.variant ?? 'primary'}
2285
- disabled={headerExportDisabled}
2286
- onClick={() => {
2287
- if (routeExportThroughVision) {
2288
- visionTypesetting?.onExport?.()
2289
- } else {
2290
- exportAction.onClick()
2291
- }
2292
- }}
3076
+ variant="subtle"
3077
+ disabled={visionRunAction.disabled}
3078
+ onClick={() => visionRunAction.onClick?.()}
2293
3079
  >
2294
- {exportAction.icon}
2295
- {headerExportLabel}
3080
+ <PlatformIcon
3081
+ icon={visionRunAction.stop ? Square : Play}
3082
+ size={14}
3083
+ />
3084
+ {visionRunAction.label}
2296
3085
  </HeaderActionButton>
2297
3086
  ) : null}
3087
+ {exportAction ? (
3088
+ (exportAction.variant ?? 'primary') === 'primary' ? (
3089
+ <ExportPrimaryButton
3090
+ type="button"
3091
+ variant="primary"
3092
+ disabled={headerExportDisabled}
3093
+ onClick={() => exportAction.onClick()}
3094
+ >
3095
+ {exportAction.icon}
3096
+ {headerExportLabel}
3097
+ </ExportPrimaryButton>
3098
+ ) : (
3099
+ <HeaderActionButton
3100
+ type="button"
3101
+ variant={exportAction.variant}
3102
+ disabled={headerExportDisabled}
3103
+ onClick={() => exportAction.onClick()}
3104
+ >
3105
+ {exportAction.icon}
3106
+ {headerExportLabel}
3107
+ </HeaderActionButton>
3108
+ )
3109
+ ) : null}
2298
3110
  </HeaderActions>
2299
3111
  ) : null}
2300
3112
  </HeaderRow>
@@ -2305,7 +3117,18 @@ export function PrintDesignPanel({
2305
3117
  <DesignSection $variant={variant}>
2306
3118
  <SectionHeader>
2307
3119
  <SectionTitle>Reference</SectionTitle>
3120
+ {variant === 'workspace' ? (
3121
+ // Collapsed by default: the image/describe extraction
3122
+ // inputs are occasional-use and ate half the column.
3123
+ <ReferenceButton
3124
+ type="button"
3125
+ onClick={() => setReferenceOpen(open => !open)}
3126
+ >
3127
+ {referenceOpen ? 'Hide' : 'Show'}
3128
+ </ReferenceButton>
3129
+ ) : null}
2308
3130
  </SectionHeader>
3131
+ {variant !== 'workspace' || referenceOpen ? (
2309
3132
  <ReferenceGrid $variant={variant}>
2310
3133
  {(unifiedDesignMode && imageSectionEnabled) ||
2311
3134
  mode === 'image' ? (
@@ -2377,6 +3200,7 @@ export function PrintDesignPanel({
2377
3200
  </ReferencePane>
2378
3201
  ) : null}
2379
3202
  </ReferenceGrid>
3203
+ ) : null}
2380
3204
  </DesignSection>
2381
3205
  ) : null}
2382
3206
 
@@ -2384,7 +3208,35 @@ export function PrintDesignPanel({
2384
3208
  <DesignSection $variant={variant}>
2385
3209
  <SectionHeader>
2386
3210
  <SectionTitle>Presets</SectionTitle>
3211
+ {variant === 'workspace' ? (
3212
+ <PresetPreview aria-hidden="true">
3213
+ <Swatch $color={activeTheme.color.ink} />
3214
+ <Swatch $color={activeTheme.color.accent} />
3215
+ <Swatch $color={paperColorForTheme(activeTheme)} />
3216
+ </PresetPreview>
3217
+ ) : null}
2387
3218
  </SectionHeader>
3219
+ {variant === 'workspace' ? (
3220
+ // Compact dropdown — the full list ate half the column.
3221
+ <ManualSelect
3222
+ aria-label="Print theme presets"
3223
+ value={
3224
+ presets.some(preset => preset.id === presetId)
3225
+ ? presetId
3226
+ : ''
3227
+ }
3228
+ onChange={event => applyPreset(event.currentTarget.value)}
3229
+ >
3230
+ {presets.some(preset => preset.id === presetId) ? null : (
3231
+ <option value="">Custom theme</option>
3232
+ )}
3233
+ {presets.map(preset => (
3234
+ <option key={preset.id} value={preset.id}>
3235
+ {preset.label}
3236
+ </option>
3237
+ ))}
3238
+ </ManualSelect>
3239
+ ) : (
2388
3240
  <PresetList $variant={variant} aria-label="Print theme presets">
2389
3241
  {presets.map(preset => (
2390
3242
  <PresetOption
@@ -2415,6 +3267,7 @@ export function PrintDesignPanel({
2415
3267
  </PresetOption>
2416
3268
  ))}
2417
3269
  </PresetList>
3270
+ )}
2418
3271
  </DesignSection>
2419
3272
  ) : null}
2420
3273
 
@@ -2437,42 +3290,62 @@ export function PrintDesignPanel({
2437
3290
  $variant={variant}
2438
3291
  aria-label="Manual theme controls"
2439
3292
  >
2440
- <ManualField>
2441
- Ink
2442
- <ManualInput
2443
- type="color"
2444
- value={manualInk}
2445
- onChange={event => {
2446
- const ink = event.currentTarget.value
2447
- setManualInk(ink)
2448
- applyManualTheme({ ink })
2449
- }}
2450
- />
2451
- </ManualField>
2452
- <ManualField>
2453
- Paper
2454
- <ManualInput
2455
- type="color"
2456
- value={manualPaper}
2457
- onChange={event => {
2458
- const paper = event.currentTarget.value
2459
- setManualPaper(paper)
2460
- applyManualTheme({ paper })
2461
- }}
2462
- />
2463
- </ManualField>
2464
- <ManualField>
2465
- Accent
2466
- <ManualInput
2467
- type="color"
2468
- value={manualAccent}
2469
- onChange={event => {
2470
- const accent = event.currentTarget.value
2471
- setManualAccent(accent)
2472
- applyManualTheme({ accent })
2473
- }}
2474
- />
2475
- </ManualField>
3293
+ <ColorChipsRow>
3294
+ <ManualField>
3295
+ Ink
3296
+ <ColorChipControl>
3297
+ <ColorChipSwatch $color={manualInk}>
3298
+ <input
3299
+ type="color"
3300
+ aria-label="Ink color"
3301
+ value={manualInk}
3302
+ onChange={event => {
3303
+ const ink = event.currentTarget.value
3304
+ setManualInk(ink)
3305
+ applyManualTheme({ ink })
3306
+ }}
3307
+ />
3308
+ </ColorChipSwatch>
3309
+ <ColorChipValue>{manualInk}</ColorChipValue>
3310
+ </ColorChipControl>
3311
+ </ManualField>
3312
+ <ManualField>
3313
+ Paper
3314
+ <ColorChipControl>
3315
+ <ColorChipSwatch $color={manualPaper}>
3316
+ <input
3317
+ type="color"
3318
+ aria-label="Paper color"
3319
+ value={manualPaper}
3320
+ onChange={event => {
3321
+ const paper = event.currentTarget.value
3322
+ setManualPaper(paper)
3323
+ applyManualTheme({ paper })
3324
+ }}
3325
+ />
3326
+ </ColorChipSwatch>
3327
+ <ColorChipValue>{manualPaper}</ColorChipValue>
3328
+ </ColorChipControl>
3329
+ </ManualField>
3330
+ <ManualField>
3331
+ Accent
3332
+ <ColorChipControl>
3333
+ <ColorChipSwatch $color={manualAccent}>
3334
+ <input
3335
+ type="color"
3336
+ aria-label="Accent color"
3337
+ value={manualAccent}
3338
+ onChange={event => {
3339
+ const accent = event.currentTarget.value
3340
+ setManualAccent(accent)
3341
+ applyManualTheme({ accent })
3342
+ }}
3343
+ />
3344
+ </ColorChipSwatch>
3345
+ <ColorChipValue>{manualAccent}</ColorChipValue>
3346
+ </ColorChipControl>
3347
+ </ManualField>
3348
+ </ColorChipsRow>
2476
3349
  <ManualField>
2477
3350
  Display
2478
3351
  <ManualSelect
@@ -2524,6 +3397,7 @@ export function PrintDesignPanel({
2524
3397
  <ManualField>
2525
3398
  Measure
2526
3399
  <ManualInput
3400
+ $mono
2527
3401
  value={manualMeasure}
2528
3402
  onChange={event => {
2529
3403
  const measure = event.currentTarget.value
@@ -2535,6 +3409,7 @@ export function PrintDesignPanel({
2535
3409
  <ManualField>
2536
3410
  Lead
2537
3411
  <ManualInput
3412
+ $mono
2538
3413
  value={manualLead}
2539
3414
  onChange={event => {
2540
3415
  const lead = event.currentTarget.value
@@ -2559,13 +3434,495 @@ export function PrintDesignPanel({
2559
3434
  <option value="flush-left">Flush left</option>
2560
3435
  </ManualSelect>
2561
3436
  </ManualField>
3437
+ <ManualField>
3438
+ Heading rule
3439
+ <ManualSelect
3440
+ value={manualHeadingRule}
3441
+ onChange={event => {
3442
+ const headingRule = event.currentTarget.value as
3443
+ | 'none'
3444
+ | 'underline'
3445
+ setManualHeadingRule(headingRule)
3446
+ applyManualTheme({ headingRule })
3447
+ }}
3448
+ >
3449
+ <option value="none">None</option>
3450
+ <option value="underline">Accent underline</option>
3451
+ </ManualSelect>
3452
+ </ManualField>
3453
+ <ManualField>
3454
+ Drop cap
3455
+ <ManualSelect
3456
+ value={manualDropCap ? 'on' : 'off'}
3457
+ onChange={event => {
3458
+ const dropCap = event.currentTarget.value === 'on'
3459
+ setManualDropCap(dropCap)
3460
+ applyManualTheme({ dropCap })
3461
+ }}
3462
+ >
3463
+ <option value="off">Off</option>
3464
+ <option value="on">Accent letter</option>
3465
+ </ManualSelect>
3466
+ </ManualField>
3467
+ <ManualField>
3468
+ Eyebrow
3469
+ <ManualInput
3470
+ value={manualEyebrow}
3471
+ placeholder="e.g. Essay · July 2026"
3472
+ onChange={event => {
3473
+ const eyebrowText = event.currentTarget.value
3474
+ setManualEyebrow(eyebrowText)
3475
+ applyManualTheme({ eyebrowText })
3476
+ }}
3477
+ />
3478
+ </ManualField>
3479
+ <ManualField>
3480
+ Margins · top/bottom
3481
+ <ManualInput
3482
+ $mono
3483
+ value={manualMarginY}
3484
+ placeholder="e.g. 20mm"
3485
+ onChange={event => {
3486
+ const marginY = event.currentTarget.value
3487
+ setManualMarginY(marginY)
3488
+ applyManualTheme({ marginY })
3489
+ }}
3490
+ />
3491
+ </ManualField>
3492
+ <ManualField>
3493
+ Margins · sides
3494
+ <ManualInput
3495
+ $mono
3496
+ value={manualMarginX}
3497
+ placeholder="e.g. 18mm"
3498
+ onChange={event => {
3499
+ const marginX = event.currentTarget.value
3500
+ setManualMarginX(marginX)
3501
+ applyManualTheme({ marginX })
3502
+ }}
3503
+ />
3504
+ </ManualField>
3505
+ <ManualField>
3506
+ Columns
3507
+ <ManualSelect
3508
+ value={String(manualColumns)}
3509
+ onChange={event => {
3510
+ const columns =
3511
+ event.currentTarget.value === '2' ? 2 : (1 as const)
3512
+ setManualColumns(columns)
3513
+ applyManualTheme({ columns })
3514
+ }}
3515
+ >
3516
+ <option value="1">Single column</option>
3517
+ <option value="2">Two columns</option>
3518
+ </ManualSelect>
3519
+ </ManualField>
3520
+ <ManualField>
3521
+ Cover
3522
+ <ManualSelect
3523
+ value={manualCoverPage ? 'on' : 'off'}
3524
+ onChange={event => {
3525
+ const coverPage = event.currentTarget.value === 'on'
3526
+ setManualCoverPage(coverPage)
3527
+ applyManualTheme({ coverPage })
3528
+ }}
3529
+ >
3530
+ <option value="off">Off</option>
3531
+ <option value="on">Cover page</option>
3532
+ </ManualSelect>
3533
+ </ManualField>
3534
+ {manualCoverPage ? (
3535
+ <>
3536
+ <ManualField>
3537
+ Cover background
3538
+ <ManualInput
3539
+ $mono
3540
+ value={manualCoverBackground}
3541
+ placeholder="e.g. #10241E"
3542
+ onChange={event => {
3543
+ const coverBackground = event.currentTarget.value
3544
+ setManualCoverBackground(coverBackground)
3545
+ applyManualTheme({ coverBackground })
3546
+ }}
3547
+ />
3548
+ </ManualField>
3549
+ <ManualField>
3550
+ Cover author
3551
+ <ManualInput
3552
+ value={manualCoverAuthor}
3553
+ placeholder="e.g. A. Hyde"
3554
+ onChange={event => {
3555
+ const coverAuthor = event.currentTarget.value
3556
+ setManualCoverAuthor(coverAuthor)
3557
+ applyManualTheme({ coverAuthor })
3558
+ }}
3559
+ />
3560
+ </ManualField>
3561
+ <ManualField>
3562
+ Cover date
3563
+ <ManualInput
3564
+ value={manualCoverDate}
3565
+ placeholder="e.g. July 2026"
3566
+ onChange={event => {
3567
+ const coverDate = event.currentTarget.value
3568
+ setManualCoverDate(coverDate)
3569
+ applyManualTheme({ coverDate })
3570
+ }}
3571
+ />
3572
+ </ManualField>
3573
+ <ManualField>
3574
+ Cover image
3575
+ <ReferenceButton
3576
+ type="button"
3577
+ onClick={() => coverImageInputRef.current?.click()}
3578
+ >
3579
+ {manualCoverImage ? 'Replace image' : 'Choose image'}
3580
+ </ReferenceButton>
3581
+ {manualCoverImage ? (
3582
+ <ReferenceButton
3583
+ type="button"
3584
+ onClick={() => {
3585
+ setManualCoverImage('')
3586
+ applyManualTheme({ coverImageDataUri: '' })
3587
+ }}
3588
+ >
3589
+ Remove image
3590
+ </ReferenceButton>
3591
+ ) : null}
3592
+ <HiddenFile
3593
+ ref={coverImageInputRef}
3594
+ type="file"
3595
+ accept="image/png,image/jpeg,image/webp,image/svg+xml"
3596
+ aria-label="Choose cover image"
3597
+ onChange={event => {
3598
+ const file = event.currentTarget.files?.[0]
3599
+ event.currentTarget.value = ''
3600
+ if (!file) return
3601
+ const reader = new FileReader()
3602
+ reader.onload = () => {
3603
+ const coverImageDataUri =
3604
+ typeof reader.result === 'string'
3605
+ ? reader.result
3606
+ : ''
3607
+ if (!coverImageDataUri) return
3608
+ setManualCoverImage(coverImageDataUri)
3609
+ applyManualTheme({ coverImageDataUri })
3610
+ }
3611
+ reader.readAsDataURL(file)
3612
+ }}
3613
+ />
3614
+ </ManualField>
3615
+ </>
3616
+ ) : null}
3617
+ {manualCoverPage ? (
3618
+ // Contents implies a cover — no toc without one.
3619
+ <ManualField>
3620
+ Contents
3621
+ <ManualSelect
3622
+ value={manualToc ? 'on' : 'off'}
3623
+ onChange={event => {
3624
+ const toc = event.currentTarget.value === 'on'
3625
+ setManualToc(toc)
3626
+ applyManualTheme({ toc })
3627
+ }}
3628
+ >
3629
+ <option value="off">Off</option>
3630
+ <option value="on">Generated contents</option>
3631
+ </ManualSelect>
3632
+ </ManualField>
3633
+ ) : null}
3634
+ <ManualField>
3635
+ Running header
3636
+ <ManualSelect
3637
+ value={manualRunningHeader}
3638
+ onChange={event => {
3639
+ const runningHeader = event.currentTarget.value as
3640
+ | 'none'
3641
+ | 'title'
3642
+ | 'section'
3643
+ | 'custom'
3644
+ setManualRunningHeader(runningHeader)
3645
+ applyManualTheme({ runningHeader })
3646
+ }}
3647
+ >
3648
+ <option value="none">None</option>
3649
+ <option value="title">Document title</option>
3650
+ <option value="section">Section titles</option>
3651
+ <option value="custom">Custom text</option>
3652
+ </ManualSelect>
3653
+ </ManualField>
3654
+ {manualRunningHeader === 'custom' ? (
3655
+ <ManualField>
3656
+ Header text
3657
+ <ManualInput
3658
+ value={manualRunningHeaderText}
3659
+ placeholder="e.g. Draft · Confidential"
3660
+ onChange={event => {
3661
+ const runningHeaderText = event.currentTarget.value
3662
+ setManualRunningHeaderText(runningHeaderText)
3663
+ applyManualTheme({ runningHeaderText })
3664
+ }}
3665
+ />
3666
+ </ManualField>
3667
+ ) : null}
3668
+ <ManualField>
3669
+ Footer
3670
+ <ManualSelect
3671
+ value={manualFolio === 'none' ? 'none' : manualFolioStyle}
3672
+ onChange={event => {
3673
+ const value = event.currentTarget.value
3674
+ if (value === 'none') {
3675
+ setManualFolio('none')
3676
+ applyManualTheme({ folio: 'none' })
3677
+ return
3678
+ }
3679
+ const folioStyle = value as
3680
+ | 'plain'
3681
+ | 'accent'
3682
+ | 'of-total'
3683
+ setManualFolio('page')
3684
+ setManualFolioStyle(folioStyle)
3685
+ applyManualTheme({ folio: 'page', folioStyle })
3686
+ }}
3687
+ >
3688
+ <option value="plain">Page number</option>
3689
+ <option value="accent">Accent number</option>
3690
+ <option value="of-total">Page X / Y</option>
3691
+ <option value="none">None</option>
3692
+ </ManualSelect>
3693
+ </ManualField>
3694
+ {manualFolio !== 'none' ? (
3695
+ <ManualField>
3696
+ Footer slot
3697
+ <ManualSelect
3698
+ value={manualFolioSlot}
3699
+ onChange={event => {
3700
+ const folioSlot = event.currentTarget.value as
3701
+ | 'bottom-center'
3702
+ | 'bottom-left'
3703
+ | 'bottom-right'
3704
+ | 'top-right'
3705
+ setManualFolioSlot(folioSlot)
3706
+ applyManualTheme({ folioSlot })
3707
+ }}
3708
+ >
3709
+ <option value="bottom-center">Bottom · center</option>
3710
+ <option value="bottom-left">Bottom · left</option>
3711
+ <option value="bottom-right">Bottom · right</option>
3712
+ <option value="top-right">Top · right</option>
3713
+ </ManualSelect>
3714
+ </ManualField>
3715
+ ) : null}
3716
+ <ManualField>
3717
+ Tables
3718
+ <ManualSelect
3719
+ value={
3720
+ !manualTableGrid &&
3721
+ manualTableRule === 'hairline' &&
3722
+ manualTableHeader === 'default' &&
3723
+ manualTableDensity === 'regular'
3724
+ ? 'quiet'
3725
+ : !manualTableGrid &&
3726
+ manualTableRule === 'light' &&
3727
+ manualTableHeader === 'plain' &&
3728
+ manualTableDensity === 'regular'
3729
+ ? 'ruled'
3730
+ : manualTableGrid &&
3731
+ manualTableRule === 'light' &&
3732
+ manualTableHeader === 'shaded' &&
3733
+ manualTableDensity === 'compact'
3734
+ ? 'grid'
3735
+ : !manualTableGrid &&
3736
+ manualTableRule === 'medium' &&
3737
+ manualTableHeader === 'accent' &&
3738
+ manualTableDensity === 'airy'
3739
+ ? 'ledger'
3740
+ : 'custom'
3741
+ }
3742
+ onChange={event => {
3743
+ const presetsByName: Record<
3744
+ string,
3745
+ {
3746
+ tableRule: 'hairline' | 'light' | 'medium'
3747
+ tableHeader: 'default' | 'plain' | 'shaded' | 'accent'
3748
+ tableDensity: 'airy' | 'regular' | 'compact'
3749
+ tableGrid: boolean
3750
+ }
3751
+ > = {
3752
+ quiet: {
3753
+ tableRule: 'hairline',
3754
+ tableHeader: 'default',
3755
+ tableDensity: 'regular',
3756
+ tableGrid: false,
3757
+ },
3758
+ ruled: {
3759
+ tableRule: 'light',
3760
+ tableHeader: 'plain',
3761
+ tableDensity: 'regular',
3762
+ tableGrid: false,
3763
+ },
3764
+ grid: {
3765
+ tableRule: 'light',
3766
+ tableHeader: 'shaded',
3767
+ tableDensity: 'compact',
3768
+ tableGrid: true,
3769
+ },
3770
+ ledger: {
3771
+ tableRule: 'medium',
3772
+ tableHeader: 'accent',
3773
+ tableDensity: 'airy',
3774
+ tableGrid: false,
3775
+ },
3776
+ }
3777
+ const preset = presetsByName[event.currentTarget.value]
3778
+ if (!preset) return
3779
+ setManualTableRule(preset.tableRule)
3780
+ setManualTableHeader(preset.tableHeader)
3781
+ setManualTableDensity(preset.tableDensity)
3782
+ setManualTableGrid(preset.tableGrid)
3783
+ applyManualTheme(preset)
3784
+ }}
3785
+ >
3786
+ <option value="custom">Custom</option>
3787
+ <option value="quiet">Quiet (hairlines)</option>
3788
+ <option value="ruled">Ruled</option>
3789
+ <option value="grid">Bordered grid</option>
3790
+ <option value="ledger">Ledger (accent header)</option>
3791
+ </ManualSelect>
3792
+ </ManualField>
3793
+ <ManualField>
3794
+ Table borders
3795
+ <ManualSelect
3796
+ value={manualTableGrid ? 'grid' : manualTableRule}
3797
+ onChange={event => {
3798
+ const value = event.currentTarget.value
3799
+ if (value === 'grid') {
3800
+ setManualTableGrid(true)
3801
+ applyManualTheme({ tableGrid: true })
3802
+ return
3803
+ }
3804
+ const tableRule = value as
3805
+ | 'hairline'
3806
+ | 'light'
3807
+ | 'medium'
3808
+ setManualTableGrid(false)
3809
+ setManualTableRule(tableRule)
3810
+ applyManualTheme({ tableGrid: false, tableRule })
3811
+ }}
3812
+ >
3813
+ <option value="hairline">Hairline rules</option>
3814
+ <option value="light">Light rules</option>
3815
+ <option value="medium">Medium rules</option>
3816
+ <option value="grid">Full grid</option>
3817
+ </ManualSelect>
3818
+ </ManualField>
3819
+ <ManualField>
3820
+ Header row
3821
+ <ManualSelect
3822
+ value={manualTableHeader}
3823
+ onChange={event => {
3824
+ const tableHeader = event.currentTarget.value as
3825
+ | 'default'
3826
+ | 'plain'
3827
+ | 'shaded'
3828
+ | 'accent'
3829
+ setManualTableHeader(tableHeader)
3830
+ applyManualTheme({ tableHeader })
3831
+ }}
3832
+ >
3833
+ <option value="default">Small caps</option>
3834
+ <option value="plain">Plain</option>
3835
+ <option value="shaded">Shaded</option>
3836
+ <option value="accent">Accent fill</option>
3837
+ </ManualSelect>
3838
+ </ManualField>
3839
+ <ManualField>
3840
+ Table density
3841
+ <ManualSelect
3842
+ value={manualTableDensity}
3843
+ onChange={event => {
3844
+ const tableDensity = event.currentTarget.value as
3845
+ | 'airy'
3846
+ | 'regular'
3847
+ | 'compact'
3848
+ setManualTableDensity(tableDensity)
3849
+ applyManualTheme({ tableDensity })
3850
+ }}
3851
+ >
3852
+ <option value="airy">Airy</option>
3853
+ <option value="regular">Regular</option>
3854
+ <option value="compact">Compact</option>
3855
+ </ManualSelect>
3856
+ </ManualField>
3857
+ <ManualField>
3858
+ Logo
3859
+ <ReferenceButton
3860
+ type="button"
3861
+ onClick={() => logoFileInputRef.current?.click()}
3862
+ >
3863
+ {manualLogo ? 'Replace logo' : 'Choose logo'}
3864
+ </ReferenceButton>
3865
+ <HiddenFile
3866
+ ref={logoFileInputRef}
3867
+ type="file"
3868
+ accept="image/png,image/jpeg,image/webp,image/svg+xml"
3869
+ aria-label="Choose logo image"
3870
+ onChange={event => {
3871
+ const file = event.currentTarget.files?.[0]
3872
+ event.currentTarget.value = ''
3873
+ if (!file) return
3874
+ const reader = new FileReader()
3875
+ reader.onload = () => {
3876
+ const logoDataUri =
3877
+ typeof reader.result === 'string'
3878
+ ? reader.result
3879
+ : ''
3880
+ if (!logoDataUri) return
3881
+ setManualLogo(logoDataUri)
3882
+ applyManualTheme({ logoDataUri })
3883
+ }
3884
+ reader.readAsDataURL(file)
3885
+ }}
3886
+ />
3887
+ </ManualField>
3888
+ {manualLogo ? (
3889
+ <ManualField>
3890
+ Logo slot
3891
+ <ManualSelect
3892
+ value={manualLogoSlot}
3893
+ onChange={event => {
3894
+ const logoSlot = event.currentTarget.value as
3895
+ | 'top-left'
3896
+ | 'top-right'
3897
+ | 'bottom-left'
3898
+ | 'bottom-right'
3899
+ setManualLogoSlot(logoSlot)
3900
+ applyManualTheme({ logoSlot })
3901
+ }}
3902
+ >
3903
+ <option value="top-left">Header · left</option>
3904
+ <option value="top-right">Header · right</option>
3905
+ <option value="bottom-left">Footer · left</option>
3906
+ <option value="bottom-right">Footer · right</option>
3907
+ </ManualSelect>
3908
+ <ReferenceButton
3909
+ type="button"
3910
+ onClick={() => {
3911
+ setManualLogo('')
3912
+ applyManualTheme({ logoDataUri: '' })
3913
+ }}
3914
+ >
3915
+ Remove logo
3916
+ </ReferenceButton>
3917
+ </ManualField>
3918
+ ) : null}
2562
3919
  </ManualGrid>
2563
3920
  </DesignSection>
2564
3921
  ) : null}
2565
3922
  </WorkspaceControls>
2566
3923
 
2567
3924
  <WorkspaceCanvas $variant={variant} $mode={mode}>
2568
- {mode === 'vision' ? (
3925
+ {mode === 'vision' || variant === 'workspace' ? (
2569
3926
  <VisionPanel
2570
3927
  $variant={variant}
2571
3928
  aria-label="Beta vision typesetting"
@@ -2644,6 +4001,143 @@ export function PrintDesignPanel({
2644
4001
  </VisionProgressLabel>
2645
4002
  </VisionProgressStrip>
2646
4003
  ) : null}
4004
+ {variant === 'workspace' && snapshotHistory.length > 0 ? (
4005
+ // Bidirectional views: the last run's annotated pages vs
4006
+ // the live preview. Style edits auto-flip to live and
4007
+ // mark the pages stale until the next Run.
4008
+ <WellViewSwitch aria-label="Well view">
4009
+ <WellViewButton
4010
+ type="button"
4011
+ $active={wellView === 'live'}
4012
+ onClick={() => setWellView('live')}
4013
+ >
4014
+ Live preview
4015
+ </WellViewButton>
4016
+ <WellViewButton
4017
+ type="button"
4018
+ $active={wellView === 'pages'}
4019
+ onClick={() => setWellView('pages')}
4020
+ >
4021
+ {stripStale ? 'Run pages · stale' : 'Run pages'}
4022
+ </WellViewButton>
4023
+ </WellViewSwitch>
4024
+ ) : null}
4025
+ {snapshotHistory.length > 0 &&
4026
+ (variant !== 'workspace' || wellView === 'pages') ? (
4027
+ // The document, page by page: every checked page and fix
4028
+ // attempt appends an annotated card (screenshot + issue /
4029
+ // fixed chips + inspector comment). Auto-follows the
4030
+ // newest card while the sweep runs; scrollable after.
4031
+ <VisionPagesScroll
4032
+ ref={snapshotHistoryRef}
4033
+ aria-label="Page snapshot history"
4034
+ >
4035
+ {snapshotHistory.map(entry => (
4036
+ <VisionPageCard key={entry.id}>
4037
+ <VisionPageImageButton
4038
+ type="button"
4039
+ aria-label={entry.alt ?? entry.label}
4040
+ onClick={() =>
4041
+ setZoomedPage({
4042
+ url: entry.url,
4043
+ alt: entry.alt ?? entry.label,
4044
+ })
4045
+ }
4046
+ >
4047
+ <img src={entry.url} alt={entry.alt ?? entry.label} />
4048
+ </VisionPageImageButton>
4049
+ <VisionPageAnnotations>
4050
+ <SnapshotHistoryLabel>
4051
+ {entry.label}
4052
+ </SnapshotHistoryLabel>
4053
+ <SnapshotIssueList
4054
+ aria-label={`${entry.label} issues`}
4055
+ >
4056
+ {entry.issues?.length ? (
4057
+ entry.issues.map(issue => (
4058
+ <SnapshotIssueRow key={issue.id}>
4059
+ <DecisionType
4060
+ $type={
4061
+ issue.state === 'fixed' ? 'fix' : 'issue'
4062
+ }
4063
+ >
4064
+ {issue.state === 'fixed'
4065
+ ? 'fixed'
4066
+ : 'issue'}
4067
+ </DecisionType>
4068
+ <SnapshotIssueLabel
4069
+ $fixed={issue.state === 'fixed'}
4070
+ >
4071
+ {issue.label}
4072
+ </SnapshotIssueLabel>
4073
+ </SnapshotIssueRow>
4074
+ ))
4075
+ ) : (
4076
+ <SnapshotIssueRow>
4077
+ <DecisionType $type="pass">pass</DecisionType>
4078
+ <SnapshotIssueLabel $fixed={false}>
4079
+ no layout issues
4080
+ </SnapshotIssueLabel>
4081
+ </SnapshotIssueRow>
4082
+ )}
4083
+ </SnapshotIssueList>
4084
+ {entry.note ? (
4085
+ <VisionPageNote>{entry.note}</VisionPageNote>
4086
+ ) : null}
4087
+ </VisionPageAnnotations>
4088
+ </VisionPageCard>
4089
+ ))}
4090
+ </VisionPagesScroll>
4091
+ ) : variant === 'workspace' &&
4092
+ (snapshotHistory.length > 0 ||
4093
+ (visionEvidence.length === 0 &&
4094
+ !visionTypesetting?.snapshotUrl)) ? (
4095
+ // Merged view, live face: the REAL Paged.js preview when
4096
+ // the host supplies one (all pages, scrollable, adopted
4097
+ // fixes included); the styled facsimile until then.
4098
+ <>
4099
+ <SectionHeader>
4100
+ <SectionTitle>
4101
+ {livePreview?.html
4102
+ ? `Live preview${
4103
+ livePreview.pageCount
4104
+ ? ` · ${livePreview.pageCount} page(s)`
4105
+ : ''
4106
+ }`
4107
+ : 'Preview'}
4108
+ </SectionTitle>
4109
+ {livePreview?.html ? null : (
4110
+ <PreviewSampleToggle
4111
+ data-disabled={!hasHostPreviewContent}
4112
+ >
4113
+ <input
4114
+ type="checkbox"
4115
+ checked={previewUsesSample}
4116
+ disabled={!hasHostPreviewContent}
4117
+ onChange={event =>
4118
+ setUseSampleContent(event.currentTarget.checked)
4119
+ }
4120
+ />
4121
+ Sample
4122
+ </PreviewSampleToggle>
4123
+ )}
4124
+ <StatusText title={displayedStatus}>
4125
+ {livePreview?.loading
4126
+ ? 'Laying out pages…'
4127
+ : displayedStatus}
4128
+ </StatusText>
4129
+ </SectionHeader>
4130
+ {livePreview?.html ? (
4131
+ <LivePreviewFrame
4132
+ sandbox=""
4133
+ srcDoc={livePreviewDocument(livePreview.html)}
4134
+ title="Live paged preview"
4135
+ />
4136
+ ) : (
4137
+ facsimilePreview
4138
+ )}
4139
+ </>
4140
+ ) : (
2647
4141
  <VisionSnapshotGrid $hasEvidence={visionEvidence.length > 0}>
2648
4142
  <VisionSnapshotFrame>
2649
4143
  <VisionSnapshotLabel>
@@ -2685,10 +4179,17 @@ export function PrintDesignPanel({
2685
4179
  ))}
2686
4180
  </VisionEvidenceList>
2687
4181
  ) : visionTypesetting?.snapshotUrl ? (
2688
- visionTypesetting.onSnapshotOpen ? (
4182
+ <>
2689
4183
  <VisionSnapshotButton
2690
4184
  type="button"
2691
- onClick={visionTypesetting.onSnapshotOpen}
4185
+ onClick={() =>
4186
+ setZoomedPage({
4187
+ url: visionTypesetting.snapshotUrl!,
4188
+ alt:
4189
+ visionTypesetting.snapshotAlt ??
4190
+ 'Visual typesetting page snapshot',
4191
+ })
4192
+ }
2692
4193
  aria-label="Open page snapshot preview"
2693
4194
  >
2694
4195
  <VisionSnapshotImage
@@ -2699,15 +4200,27 @@ export function PrintDesignPanel({
2699
4200
  }
2700
4201
  />
2701
4202
  </VisionSnapshotButton>
2702
- ) : (
2703
- <VisionSnapshotImage
2704
- src={visionTypesetting.snapshotUrl}
2705
- alt={
2706
- visionTypesetting.snapshotAlt ??
2707
- 'Visual typesetting page snapshot'
2708
- }
2709
- />
2710
- )
4203
+ {visionTypesetting.snapshotIssues?.length ? (
4204
+ <SnapshotIssueList aria-label="Latest page issues">
4205
+ {visionTypesetting.snapshotIssues.map(issue => (
4206
+ <SnapshotIssueRow key={issue.id}>
4207
+ <DecisionType
4208
+ $type={
4209
+ issue.state === 'fixed' ? 'fix' : 'issue'
4210
+ }
4211
+ >
4212
+ {issue.state === 'fixed' ? 'fixed' : 'issue'}
4213
+ </DecisionType>
4214
+ <SnapshotIssueLabel
4215
+ $fixed={issue.state === 'fixed'}
4216
+ >
4217
+ {issue.label}
4218
+ </SnapshotIssueLabel>
4219
+ </SnapshotIssueRow>
4220
+ ))}
4221
+ </SnapshotIssueList>
4222
+ ) : null}
4223
+ </>
2711
4224
  ) : (
2712
4225
  <VisionSnapshotPage aria-label="Sample PDF page snapshot">
2713
4226
  <VisionPageHeading />
@@ -2740,6 +4253,7 @@ export function PrintDesignPanel({
2740
4253
  </VisionSnapshotCopy>
2741
4254
  )}
2742
4255
  </VisionSnapshotGrid>
4256
+ )}
2743
4257
  </VisionEvidenceColumn>
2744
4258
  </VisionPanel>
2745
4259
  ) : null}
@@ -2827,21 +4341,70 @@ export function PrintDesignPanel({
2827
4341
  </WorkspaceFooter>
2828
4342
  </DesignMain>
2829
4343
 
2830
- {variant !== 'sidebar' ? (
4344
+ {variant === 'workspace' ? (
4345
+ // Merged view's right rail: the run summary / design ledger.
2831
4346
  <DesignReadout $variant={variant} aria-label="Live design readout">
2832
4347
  <DesignSection $variant={variant}>
2833
4348
  <SectionHeader>
2834
- <SectionTitle>Live readout</SectionTitle>
4349
+ <SectionTitle>Activity</SectionTitle>
2835
4350
  <StatusText title={displayedStatus}>{displayedStatus}</StatusText>
2836
4351
  </SectionHeader>
2837
4352
  {displayedReadoutEntries.length > 0 ? (
2838
- <DecisionReadout entries={displayedReadoutEntries} />
4353
+ <ActivityScroll $tall>
4354
+ <DecisionReadout entries={displayedReadoutEntries} />
4355
+ </ActivityScroll>
4356
+ ) : (
4357
+ <StatusText>No design decisions yet.</StatusText>
4358
+ )}
4359
+ </DesignSection>
4360
+ </DesignReadout>
4361
+ ) : null}
4362
+ {variant === 'popover' && mode !== 'vision' ? (
4363
+ <DesignReadout $variant={variant} aria-label="Live design readout">
4364
+ <DesignSection $variant={variant}>
4365
+ <SectionHeader>
4366
+ <SectionTitle>Preview</SectionTitle>
4367
+ <PreviewSampleToggle data-disabled={!hasHostPreviewContent}>
4368
+ <input
4369
+ type="checkbox"
4370
+ checked={previewUsesSample}
4371
+ disabled={!hasHostPreviewContent}
4372
+ onChange={event =>
4373
+ setUseSampleContent(event.currentTarget.checked)
4374
+ }
4375
+ />
4376
+ Sample
4377
+ </PreviewSampleToggle>
4378
+ <StatusText title={displayedStatus}>
4379
+ {displayedStatus}
4380
+ </StatusText>
4381
+ </SectionHeader>
4382
+ {facsimilePreview}
4383
+ </DesignSection>
4384
+ <DesignSection $variant={variant}>
4385
+ <SectionHeader>
4386
+ <SectionTitle>Activity</SectionTitle>
4387
+ </SectionHeader>
4388
+ {displayedReadoutEntries.length > 0 ? (
4389
+ <ActivityScroll $tall={false}>
4390
+ <DecisionReadout entries={displayedReadoutEntries} />
4391
+ </ActivityScroll>
2839
4392
  ) : (
2840
4393
  <StatusText>No design decisions yet.</StatusText>
2841
4394
  )}
2842
4395
  </DesignSection>
2843
4396
  </DesignReadout>
2844
4397
  ) : null}
4398
+ {zoomedPage ? (
4399
+ // In-window zoom: click anywhere (or Esc) to dismiss.
4400
+ <PageZoomOverlay
4401
+ role="dialog"
4402
+ aria-label="Page snapshot zoom"
4403
+ onClick={() => setZoomedPage(null)}
4404
+ >
4405
+ <PageZoomImage src={zoomedPage.url} alt={zoomedPage.alt} />
4406
+ </PageZoomOverlay>
4407
+ ) : null}
2845
4408
  </Panel>
2846
4409
  )
2847
4410
  }