@eightyfourthousand/lib-editing 2026.3.1 → 2026.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/lib/components/editor/EditorBackMatterPage.tsx +3 -1
  3. package/src/lib/components/editor/EditorHeader.tsx +1 -1
  4. package/src/lib/components/editor/EditorProvider.tsx +76 -0
  5. package/src/lib/components/editor/PaginationProvider.tsx +30 -45
  6. package/src/lib/components/editor/extensions/Heading/Heading.ts +4 -3
  7. package/src/lib/components/editor/extensions/InternalLink/InternalLink.ts +4 -1
  8. package/src/lib/components/editor/extensions/Mention/Mention.ts +4 -1
  9. package/src/lib/components/editor/extensions/Passage/EditorOptions.tsx +2 -2
  10. package/src/lib/components/editor/extensions/Passage/Passage.tsx +3 -3
  11. package/src/lib/components/editor/extensions/Passage/ReaderOptions.tsx +3 -3
  12. package/src/lib/components/editor/extensions/Passage/ShowAnnotations.tsx +3 -3
  13. package/src/lib/components/editor/extensions/TitlesNode.ts +1 -1
  14. package/src/lib/components/reader/ReaderBackMatterPage.tsx +1 -0
  15. package/src/lib/components/reader/ReaderBackMatterPanel.tsx +3 -0
  16. package/src/lib/components/reader/ReaderBodyPanel.tsx +1 -1
  17. package/src/lib/components/shared/BackMatterPanel.tsx +17 -10
  18. package/src/lib/components/shared/BodyPanel.tsx +6 -6
  19. package/src/lib/components/shared/LabeledElement.tsx +3 -3
  20. package/src/lib/components/shared/LeftPanel.tsx +3 -3
  21. package/src/lib/components/shared/NavigationContext.ts +1 -1
  22. package/src/lib/components/shared/NavigationProvider.tsx +33 -1
  23. package/src/lib/components/shared/ReaderSearchButton.tsx +110 -0
  24. package/src/lib/components/shared/SearchReplacePanel.tsx +259 -0
  25. package/src/lib/components/shared/SourceReader.tsx +13 -4
  26. package/src/lib/components/shared/SuggestRevisionForm.tsx +1 -1
  27. package/src/lib/components/shared/TableOfContents.tsx +40 -27
  28. package/src/lib/components/shared/ThreeColumnRenderer.tsx +16 -9
  29. package/src/lib/components/shared/TranslationHeader.tsx +2 -63
  30. package/src/lib/components/shared/bibliography/BibliographyList.tsx +2 -2
  31. package/src/lib/components/shared/glossary/GlossaryInstanceBody.tsx +15 -5
  32. package/src/lib/components/shared/glossary/GlossaryPaginationProvider.tsx +17 -56
  33. package/src/lib/components/shared/glossary/GlossaryTermList.tsx +4 -5
  34. package/src/lib/components/shared/hooks/usePaginationLoadTriggers.spec.tsx +158 -0
  35. package/src/lib/components/shared/hooks/usePaginationLoadTriggers.ts +129 -0
  36. package/src/lib/components/shared/titles/FramedCard.tsx +3 -3
  37. package/src/lib/components/shared/titles/Titles.tsx +17 -6
  38. package/src/lib/components/shared/titles/TitlesCard.tsx +6 -6
  39. package/src/lib/transformers/has-abbreviation.ts +7 -2
@@ -9,7 +9,7 @@ import type {
9
9
  Work,
10
10
  } from '@eightyfourthousand/data-access';
11
11
  import { createContext, useContext } from 'react';
12
- import { PanelName, PanelsState, PanelState, TabName } from './types';
12
+ import { PanelName, PanelsState, PanelState } from './types';
13
13
 
14
14
  export interface NavigationState {
15
15
  uuid: string;
@@ -27,14 +27,16 @@ import {
27
27
  import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation';
28
28
  import {
29
29
  PANEL_NAMES,
30
+ PANEL_FOR_SECTION,
30
31
  PanelName,
31
32
  PanelsState,
32
33
  PanelState,
34
+ TAB_FOR_SECTION,
33
35
  TabName,
34
36
  } from './types';
35
37
  import { HoverCardProvider } from './HoverCardProvider';
36
38
  import { GatedFeature, useFeatureFlagEnabled } from '@eightyfourthousand/lib-instr';
37
- import { useIsMobile } from '@eightyfourthousand/lib-utils';
39
+ import { isXmlId, useIsMobile } from '@eightyfourthousand/lib-utils';
38
40
  import { RestrictionWarning } from './RestrictionWarning';
39
41
  import { NavigationContext, DEFAULT_PANELS } from './NavigationContext';
40
42
 
@@ -267,6 +269,36 @@ export const NavigationProvider = ({
267
269
  window.history.replaceState(null, '', newUrl);
268
270
  }, [toh, panels]);
269
271
 
272
+ // On initial load, check for an XML ID in the URL hash and resolve it to a passage UUID
273
+ useEffect(() => {
274
+ const hash = window.location.hash.replace(/^#/, '');
275
+ if (!hash || !isXmlId(hash)) {
276
+ return;
277
+ }
278
+
279
+ // Remove hash from URL immediately
280
+ window.history.replaceState(
281
+ null,
282
+ '',
283
+ `${window.location.pathname}${window.location.search}`,
284
+ );
285
+
286
+ (async () => {
287
+ const passage = await getPassage({ client: graphqlClient, xmlId: hash });
288
+ if (!passage) {
289
+ return;
290
+ }
291
+
292
+ const panel = PANEL_FOR_SECTION[passage.type] ?? 'main';
293
+ const tab = TAB_FOR_SECTION[passage.type] ?? 'translation';
294
+
295
+ updatePanel({
296
+ name: panel,
297
+ state: { open: true, tab, hash: passage.uuid },
298
+ });
299
+ })();
300
+ }, []);
301
+
270
302
  useEffect(() => {
271
303
  if (!uuid || !toh) {
272
304
  return;
@@ -0,0 +1,110 @@
1
+ 'use client';
2
+
3
+ import { BodyItemType } from '@eightyfourthousand/data-access';
4
+ import {
5
+ PassageMatch,
6
+ SearchButton,
7
+ SearchResult,
8
+ } from '@eightyfourthousand/lib-search';
9
+ import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
10
+ import { useEditorState } from '../editor/EditorProvider';
11
+ import { useNavigation } from './NavigationProvider';
12
+ import { SearchReplacePanel } from './SearchReplacePanel';
13
+ import { PanelName, PanelState, TabName } from './types';
14
+
15
+ export const ReaderSearchButton = () => {
16
+ const { uuid, toh, updatePanel } = useNavigation();
17
+ const { applyReplacedPassages, canEdit, dirtyStore } = useEditorState();
18
+ const [canReplace, setCanReplace] = useState(false);
19
+ const isDirty = useSyncExternalStore(
20
+ dirtyStore.subscribe.bind(dirtyStore),
21
+ dirtyStore.getSnapshot.bind(dirtyStore),
22
+ () => false,
23
+ );
24
+
25
+ useEffect(() => {
26
+ let active = true;
27
+
28
+ (async () => {
29
+ const editable = await canEdit();
30
+ if (active) {
31
+ setCanReplace(editable);
32
+ }
33
+ })();
34
+
35
+ return () => {
36
+ active = false;
37
+ };
38
+ }, [canEdit]);
39
+
40
+ const onResultSelected = useCallback(
41
+ (result: SearchResult) => {
42
+ let side: PanelName = 'main';
43
+ const panelState: PanelState = {
44
+ open: true,
45
+ hash: result.uuid,
46
+ };
47
+
48
+ const TAB_FOR_PASSAGE_SECTION: Partial<Record<BodyItemType, TabName>> = {
49
+ abbreviations: 'abbreviations',
50
+ endnotes: 'endnotes',
51
+ summary: 'front',
52
+ introduction: 'front',
53
+ acknowledgements: 'front',
54
+ };
55
+
56
+ const SIDE_FOR_PASSAGE_SECTION: Partial<Record<BodyItemType, PanelName>> =
57
+ {
58
+ abbreviations: 'right',
59
+ endnotes: 'right',
60
+ };
61
+
62
+ switch (result.type) {
63
+ case 'passage':
64
+ {
65
+ const passage = result as PassageMatch;
66
+ side = SIDE_FOR_PASSAGE_SECTION[passage.section] || 'main';
67
+ panelState.tab =
68
+ TAB_FOR_PASSAGE_SECTION[passage.section] || 'translation';
69
+ }
70
+ break;
71
+ case 'alignment':
72
+ side = 'main';
73
+ panelState.tab = 'compare';
74
+ break;
75
+ case 'bibliography':
76
+ side = 'right';
77
+ panelState.tab = 'bibliography';
78
+ break;
79
+ case 'glossary':
80
+ side = 'right';
81
+ panelState.tab = 'glossary';
82
+ break;
83
+ }
84
+
85
+ updatePanel({
86
+ name: side,
87
+ state: panelState,
88
+ });
89
+ },
90
+ [updatePanel],
91
+ );
92
+
93
+ return (
94
+ <SearchButton
95
+ workUuid={uuid}
96
+ toh={toh}
97
+ onResultSelected={onResultSelected}
98
+ renderActions={(searchContext) => (
99
+ <SearchReplacePanel
100
+ canReplace={canReplace}
101
+ replaceDisabledReason={
102
+ isDirty ? 'Save changes before using search and replace.' : undefined
103
+ }
104
+ onPassagesReplaced={applyReplacedPassages}
105
+ searchContext={searchContext}
106
+ />
107
+ )}
108
+ />
109
+ );
110
+ };
@@ -0,0 +1,259 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createGraphQLClient,
5
+ replace,
6
+ type ReplacedPassage,
7
+ } from '@eightyfourthousand/client-graphql';
8
+ import {
9
+ Button,
10
+ Collapsible,
11
+ CollapsibleContent,
12
+ CollapsibleTrigger,
13
+ Input,
14
+ } from '@eightyfourthousand/design-system';
15
+ import type { SearchActionContext } from '@eightyfourthousand/lib-search';
16
+ import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
17
+ import { useEffect, useMemo, useState } from 'react';
18
+
19
+ const REPLACE_CHUNK_SIZE = 200;
20
+
21
+ export interface SearchReplacePanelProps {
22
+ canReplace: boolean;
23
+ onPassagesReplaced?: (passages: ReplacedPassage[]) => Promise<void> | void;
24
+ replaceDisabledReason?: string;
25
+ searchContext: SearchActionContext;
26
+ }
27
+
28
+ export const SearchReplacePanel = ({
29
+ canReplace,
30
+ onPassagesReplaced,
31
+ replaceDisabledReason,
32
+ searchContext,
33
+ }: SearchReplacePanelProps) => {
34
+ const client = useMemo(() => createGraphQLClient(), []);
35
+ const [replaceOpen, setReplaceOpen] = useState(false);
36
+ const [replaceQuery, setReplaceQuery] = useState('');
37
+ const [replacing, setReplacing] = useState(false);
38
+
39
+ const canRunReplace =
40
+ canReplace &&
41
+ !replaceDisabledReason &&
42
+ !!searchContext.searchQuery &&
43
+ !!replaceQuery &&
44
+ searchContext.passageOccurrences.length > 0 &&
45
+ !replacing;
46
+ const canStepBackward =
47
+ replaceOpen && searchContext.activeOccurrenceIndex > 0;
48
+ const canStepForward =
49
+ replaceOpen &&
50
+ searchContext.activeOccurrenceIndex <
51
+ searchContext.passageOccurrences.length - 1;
52
+
53
+ useEffect(() => {
54
+ searchContext.setShouldScrollActiveOccurrence(replaceOpen);
55
+
56
+ return () => {
57
+ searchContext.setShouldScrollActiveOccurrence(false);
58
+ };
59
+ }, [replaceOpen, searchContext]);
60
+
61
+ useEffect(() => {
62
+ if (!replaceOpen || searchContext.passageOccurrences.length === 0) {
63
+ return;
64
+ }
65
+
66
+ const handleKeyDown = (event: KeyboardEvent) => {
67
+ if (event.key === 'ArrowUp') {
68
+ event.preventDefault();
69
+ searchContext.moveActiveOccurrence('previous');
70
+ }
71
+
72
+ if (event.key === 'ArrowDown') {
73
+ event.preventDefault();
74
+ searchContext.moveActiveOccurrence('next');
75
+ }
76
+ };
77
+
78
+ window.addEventListener('keydown', handleKeyDown);
79
+ return () => window.removeEventListener('keydown', handleKeyDown);
80
+ }, [replaceOpen, searchContext]);
81
+
82
+ if (!canReplace || !searchContext.searchQuery) {
83
+ return null;
84
+ }
85
+
86
+ const runReplace = async ({ replaceAll }: { replaceAll: boolean }) => {
87
+ if (!canRunReplace) {
88
+ return;
89
+ }
90
+
91
+ setReplacing(true);
92
+
93
+ try {
94
+ if (replaceAll) {
95
+ const targetUuids = searchContext.passages.map((passage) => passage.uuid);
96
+ if (targetUuids.length === 0) {
97
+ return;
98
+ }
99
+
100
+ const allReplacedPassages: ReplacedPassage[] = [];
101
+ for (let i = 0; i < targetUuids.length; i += REPLACE_CHUNK_SIZE) {
102
+ const chunk = targetUuids.slice(i, i + REPLACE_CHUNK_SIZE);
103
+ const response = await replace({
104
+ client,
105
+ searchText: searchContext.searchQuery,
106
+ replaceText: replaceQuery,
107
+ targetUuids: chunk,
108
+ });
109
+
110
+ if (!response.success) {
111
+ console.error(`Replace failed: ${response.error ?? 'unknown error'}`);
112
+ return;
113
+ }
114
+
115
+ allReplacedPassages.push(...response.passages);
116
+ }
117
+
118
+ await onPassagesReplaced?.(allReplacedPassages);
119
+ await searchContext.refreshSearch({ nextSelection: { kind: 'index', index: 0 } });
120
+ return;
121
+ }
122
+
123
+ // Single replace: target only the active passage and use its exact position as the cursor.
124
+ const activeOccurrence = searchContext.activeOccurrence;
125
+ if (!activeOccurrence) {
126
+ return;
127
+ }
128
+
129
+ const response = await replace({
130
+ client,
131
+ searchText: searchContext.searchQuery,
132
+ replaceText: replaceQuery,
133
+ targetUuids: [activeOccurrence.passageUuid],
134
+ cursorPassageUuid: activeOccurrence.passageUuid,
135
+ cursorStart: activeOccurrence.start,
136
+ });
137
+
138
+ if (!response.success) {
139
+ console.error(`Replace failed: ${response.error ?? 'unknown error'}`);
140
+ return;
141
+ }
142
+
143
+ await onPassagesReplaced?.(response.passages);
144
+
145
+ const nextSelection =
146
+ response.nextPassageUuid != null && response.nextOccurrenceStart != null
147
+ ? {
148
+ kind: 'cursor' as const,
149
+ passageUuid: response.nextPassageUuid,
150
+ start: response.nextOccurrenceStart,
151
+ }
152
+ : {
153
+ kind: 'index' as const,
154
+ index: Math.min(
155
+ searchContext.activeOccurrenceIndex + 1,
156
+ Math.max(searchContext.passageOccurrences.length - 1, 0),
157
+ ),
158
+ };
159
+
160
+ await searchContext.refreshSearch({ nextSelection });
161
+ } catch (error) {
162
+ console.error('Replace failed:', error);
163
+ } finally {
164
+ setReplacing(false);
165
+ }
166
+ };
167
+
168
+ return (
169
+ <Collapsible
170
+ open={replaceOpen}
171
+ onOpenChange={setReplaceOpen}
172
+ className="bg-background border rounded-lg px-4 py-3 text-foreground"
173
+ >
174
+ <CollapsibleTrigger asChild>
175
+ <button
176
+ type="button"
177
+ className="group/collapsible flex w-full items-center justify-start gap-2 text-left text-sm font-medium cursor-pointer"
178
+ >
179
+ <ChevronRightIcon className="size-4 transition-transform group-data-[state=open]/collapsible:rotate-90" />
180
+ <span>Replace</span>
181
+ </button>
182
+ </CollapsibleTrigger>
183
+ <CollapsibleContent className="pt-3">
184
+ <div className="flex flex-col">
185
+ <Input
186
+ id="replace-query"
187
+ placeholder="Replace with..."
188
+ value={replaceQuery}
189
+ onChange={(e) => {
190
+ const nextValue = e.target.value;
191
+ if (nextValue === replaceQuery) {
192
+ return;
193
+ }
194
+ setReplaceQuery(nextValue);
195
+ if (nextValue) {
196
+ searchContext.scrollActiveOccurrenceIntoView();
197
+ }
198
+ }}
199
+ />
200
+ <div className="flex flex-wrap items-center gap-2 text-sm text-secondary-foreground">
201
+ <span>
202
+ {searchContext.passageOccurrences.length > 0 &&
203
+ searchContext.activePassageUuid
204
+ ? `Occurrence ${searchContext.activeOccurrenceIndex + 1} of ${searchContext.passageOccurrences.length} in ${searchContext.activePassageLabel || searchContext.activePassageUuid}`
205
+ : 'No exact passage occurrences available for replacement.'}
206
+ </span>
207
+ {replaceDisabledReason && <span>{replaceDisabledReason}</span>}
208
+ </div>
209
+ <div className="text-sm text-muted-foreground pb-4">
210
+ Replace is case sensitive and is only applied to passages.
211
+ </div>
212
+ <div className="flex flex-wrap items-center gap-2">
213
+ <Button
214
+ size="sm"
215
+ disabled={!canRunReplace}
216
+ onClick={() => void runReplace({ replaceAll: false })}
217
+ >
218
+ {replacing ? 'Replacing…' : 'Replace'}
219
+ </Button>
220
+ <Button
221
+ size="sm"
222
+ variant="outline"
223
+ disabled={!canRunReplace}
224
+ onClick={() => void runReplace({ replaceAll: true })}
225
+ >
226
+ Replace all
227
+ </Button>
228
+ <div className="ml-auto flex items-center gap-2">
229
+ <Button
230
+ size="icon"
231
+ variant="ghost"
232
+ className="size-8"
233
+ disabled={!canStepBackward}
234
+ onClick={() => {
235
+ searchContext.moveActiveOccurrence('previous');
236
+ }}
237
+ >
238
+ <ChevronRightIcon className="size-4 rotate-[-90deg]" />
239
+ <span className="sr-only">Previous occurrence</span>
240
+ </Button>
241
+ <Button
242
+ size="icon"
243
+ variant="ghost"
244
+ className="size-8"
245
+ disabled={!canStepForward}
246
+ onClick={() => {
247
+ searchContext.moveActiveOccurrence('next');
248
+ }}
249
+ >
250
+ <ChevronDownIcon className="size-4" />
251
+ <span className="sr-only">Next occurrence</span>
252
+ </Button>
253
+ </div>
254
+ </div>
255
+ </div>
256
+ </CollapsibleContent>
257
+ </Collapsible>
258
+ );
259
+ };
@@ -3,7 +3,10 @@
3
3
  import { useInView } from 'motion/react';
4
4
  import { useCallback, useEffect, useRef, useState } from 'react';
5
5
  import { useNavigation } from './NavigationProvider';
6
- import { createGraphQLClient, getWorkFolios } from '@eightyfourthousand/client-graphql';
6
+ import {
7
+ createGraphQLClient,
8
+ getWorkFolios,
9
+ } from '@eightyfourthousand/client-graphql';
7
10
  import type { Folio } from '@eightyfourthousand/data-access';
8
11
  import { LabeledElement } from './LabeledElement';
9
12
  import { PassageSkeleton } from './PassageSkeleton';
@@ -61,9 +64,15 @@ export const SourceReader = () => {
61
64
  className="mt-0.5"
62
65
  contentType="source"
63
66
  >
64
- <div className="leading-7 font-tibetan text-lg 2xl:whitespace-pre-wrap whitespace-normal">
65
- {folio.content}
66
- </div>
67
+ {folio.content ? (
68
+ <div className="leading-7 min-h-8 font-tibetan text-lg 2xl:whitespace-pre-wrap whitespace-normal">
69
+ {folio.content}
70
+ </div>
71
+ ) : (
72
+ <div className="h-12 text-center text-muted-foreground">
73
+ [blank]
74
+ </div>
75
+ )}
67
76
  </LabeledElement>
68
77
  ))}
69
78
  <div ref={loadMoreRef} className="h-0" />
@@ -58,7 +58,7 @@ export const SuggestRevisionForm = ({
58
58
  return (
59
59
  <div className="p-1">
60
60
  <p className="text-sm font-semibold">Suggest a Revision</p>
61
- <p className="text-secondary text-xs capitalize py-2">
61
+ <p className="text-primary text-xs capitalize py-2">
62
62
  {parseToh(toh)} &middot; {type} {label}
63
63
  </p>
64
64
  <form onSubmit={handleSubmit}>
@@ -14,7 +14,7 @@ import {
14
14
  Separator,
15
15
  } from '@eightyfourthousand/design-system';
16
16
  import { cn, parseToh } from '@eightyfourthousand/lib-utils';
17
- import { useCallback, useEffect, useMemo, useState } from 'react';
17
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
18
18
  import { useNavigation } from './NavigationProvider';
19
19
  import {
20
20
  PanelName,
@@ -59,38 +59,51 @@ export const TableOfContentsSection = ({
59
59
  }
60
60
 
61
61
  return (
62
- <Accordion type="multiple" className={cn(depth && 'pl-2.5')}>
63
- {node.children.map((child) => {
62
+ <Accordion type="multiple" className={cn(depth && 'pl-5')}>
63
+ {node.children.map((child, index) => {
64
64
  // 'The Translation' is always bold
65
65
  const maybeBold =
66
66
  child.content === 'The Translation' ? 'font-bold' : '';
67
+ // Show dividers above and below the top-level introduction section
68
+ const isIntroduction =
69
+ depth === 0 && child.section === 'introduction';
70
+ const prevChild = node.children[index - 1];
71
+ const prevWasIntroduction =
72
+ depth === 0 && prevChild?.section === 'introduction';
73
+ const showDividerBefore =
74
+ isIntroduction && !prevWasIntroduction;
67
75
  return child.children?.length ? (
68
- <AccordionItem
69
- key={child.uuid}
70
- value={child.uuid}
71
- className="border-b-0"
72
- >
73
- <AccordionTrigger className={cn(baseStyle, className)}>
76
+ <React.Fragment key={child.uuid}>
77
+ {showDividerBefore && <Separator className="my-4" />}
78
+ <AccordionItem
79
+ value={child.uuid}
80
+ className="border-b-0"
81
+ >
82
+ <AccordionTrigger className={cn(baseStyle, className)}>
83
+ <span
84
+ className={cn('line-clamp-2', maybeBold)}
85
+ onClick={() => onClick(child)}
86
+ >
87
+ {child.content}
88
+ </span>
89
+ </AccordionTrigger>
90
+ <AccordionContent className="border-l border-dotted py-0">
91
+ <TableOfContentsSection node={child} depth={depth + 1} />
92
+ </AccordionContent>
93
+ </AccordionItem>
94
+ </React.Fragment>
95
+ ) : (
96
+ <React.Fragment key={child.uuid}>
97
+ {showDividerBefore && <Separator className="my-4" />}
98
+ <div className={baseStyle}>
74
99
  <span
75
100
  className={cn('line-clamp-2', maybeBold)}
76
101
  onClick={() => onClick(child)}
77
102
  >
78
103
  {child.content}
79
104
  </span>
80
- </AccordionTrigger>
81
- <AccordionContent className="border-l border-dotted py-0">
82
- <TableOfContentsSection node={child} depth={depth + 1} />
83
- </AccordionContent>
84
- </AccordionItem>
85
- ) : (
86
- <div key={child.uuid} className={baseStyle}>
87
- <span
88
- className={cn('line-clamp-2', maybeBold)}
89
- onClick={() => onClick(child)}
90
- >
91
- {child.content}
92
- </span>
93
- </div>
105
+ </div>
106
+ </React.Fragment>
94
107
  );
95
108
  })}
96
109
  </Accordion>
@@ -216,7 +229,7 @@ export const TableOfContents = ({
216
229
  return (
217
230
  <div>
218
231
  <div className={cn(baseStyle, 'flex gap-2 mt-6')}>
219
- <div className="size-5 text-primary-200 [&_svg]:stroke-1 [&_svg]:size-5 my-auto">
232
+ <div className="size-5 text-primary [&_svg]:stroke-1 [&_svg]:size-5 my-auto">
220
233
  <LibraryBigIcon />
221
234
  </div>
222
235
  <span className="my-auto font-light">
@@ -224,13 +237,13 @@ export const TableOfContents = ({
224
237
  </span>
225
238
  </div>
226
239
  <div className={cn(baseStyle, 'flex gap-2')}>
227
- <div className="size-5 text-primary-200 [&_svg]:stroke-1 [&_svg]:size-5 my-auto">
240
+ <div className="size-5 text-primary [&_svg]:stroke-1 [&_svg]:size-5 my-auto">
228
241
  <BookOpenIcon />
229
242
  </div>
230
- <span className="my-auto font-semibold">{title}</span>
243
+ <span className="my-auto font-bold">{title}</span>
231
244
  </div>
232
245
  <div className="flex gap-2">
233
- <div className="size-5 text-primary-200 [&_svg]:stroke-1 [&_svg]:size-5 my-auto">
246
+ <div className="size-5 text-primary [&_svg]:stroke-1 [&_svg]:size-5 my-auto">
234
247
  <HashIcon />
235
248
  </div>
236
249
  {work.toh.length > 1 ? (
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { ThreeColumns } from '@eightyfourthousand/design-system';
4
- import { ReactNode } from 'react';
4
+ import { ReactNode, useMemo } from 'react';
5
5
  import { useNavigation } from './NavigationProvider';
6
6
  import { cn } from '@eightyfourthousand/lib-utils';
7
7
  import { TranslationHeader } from './TranslationHeader';
8
+ import { ReaderSearchButton } from './ReaderSearchButton';
9
+ import { GatedFeature } from '@eightyfourthousand/lib-instr';
8
10
 
9
11
  export const ThreeColumnRenderer = ({
10
12
  withHeader = false,
@@ -16,6 +18,8 @@ export const ThreeColumnRenderer = ({
16
18
  const { panels, updatePanel, hasTranslationContent } = useNavigation();
17
19
  const rightPanelEnabled = hasTranslationContent;
18
20
 
21
+ const searchButton = useMemo(() => <ReaderSearchButton />, []);
22
+
19
23
  return (
20
24
  <div
21
25
  className={cn(
@@ -23,13 +27,16 @@ export const ThreeColumnRenderer = ({
23
27
  withHeader ? 'pb-20' : '',
24
28
  )}
25
29
  >
26
- <div className="pb-2.5">
27
- <TranslationHeader className="rounded-full shadow-lg" />
28
- </div>
30
+ <GatedFeature flag='show-reader-header'>
31
+ <div className="pb-2.5">
32
+ <TranslationHeader className="rounded-full shadow-lg" />
33
+ </div>
34
+ </GatedFeature>
29
35
  <ThreeColumns
30
36
  leftPanelOpen={panels.left.open}
31
37
  rightPanelOpen={rightPanelEnabled ? panels.right.open : false}
32
38
  rightPanelEnabled={rightPanelEnabled}
39
+ mainPanelActions={searchButton}
33
40
  onLeftPanelOpenChange={(open) => {
34
41
  updatePanel({
35
42
  name: 'left',
@@ -39,11 +46,11 @@ export const ThreeColumnRenderer = ({
39
46
  onRightPanelOpenChange={
40
47
  rightPanelEnabled
41
48
  ? (open) => {
42
- updatePanel({
43
- name: 'right',
44
- state: { open, tab: panels.right.tab },
45
- });
46
- }
49
+ updatePanel({
50
+ name: 'right',
51
+ state: { open, tab: panels.right.tab },
52
+ });
53
+ }
47
54
  : undefined
48
55
  }
49
56
  >