@tomehq/theme 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/entry.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  entry_default
3
- } from "./chunk-X4VQYPKO.js";
3
+ } from "./chunk-45M5UIAB.js";
4
4
  export {
5
5
  entry_default as default
6
6
  };
package/dist/index.d.ts CHANGED
@@ -44,6 +44,11 @@ interface ShellProps {
44
44
  label: string;
45
45
  href: string;
46
46
  }>;
47
+ banner?: {
48
+ text: string;
49
+ link?: string;
50
+ dismissible?: boolean;
51
+ };
47
52
  [key: string]: unknown;
48
53
  };
49
54
  navigation: Array<{
@@ -95,8 +100,9 @@ interface ShellProps {
95
100
  title: string;
96
101
  content: string;
97
102
  }>;
103
+ basePath?: string;
98
104
  }
99
- declare function Shell({ config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents, pageTitle, pageDescription, headings, tocEnabled, editUrl, lastUpdated, changelogEntries, onNavigate, allPages, versioning, currentVersion, i18n, currentLocale, docContext, }: ShellProps): react_jsx_runtime.JSX.Element;
105
+ declare function Shell({ config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents, pageTitle, pageDescription, headings, tocEnabled, editUrl, lastUpdated, changelogEntries, onNavigate, allPages, versioning, currentVersion, i18n, currentLocale, docContext, basePath, }: ShellProps): react_jsx_runtime.JSX.Element;
100
106
 
101
107
  interface AiChatProps {
102
108
  provider: "openai" | "anthropic" | "custom";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  Shell,
4
4
  THEME_PRESETS,
5
5
  entry_default
6
- } from "./chunk-X4VQYPKO.js";
6
+ } from "./chunk-45M5UIAB.js";
7
7
  export {
8
8
  AiChat,
9
9
  entry_default as App,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomehq/theme",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Tome default theme and React app shell",
5
5
  "type": "module",
6
6
  "main": "./src/index.tsx",
@@ -9,8 +9,8 @@
9
9
  "./entry": "./src/entry.tsx"
10
10
  },
11
11
  "dependencies": {
12
- "@tomehq/components": "0.2.6",
13
- "@tomehq/core": "0.2.6"
12
+ "@tomehq/components": "0.2.8",
13
+ "@tomehq/core": "0.2.8"
14
14
  },
15
15
  "peerDependencies": {
16
16
  "react": "^18.0.0 || ^19.0.0",
@@ -797,3 +797,75 @@ describe("Shell AI chat integration", () => {
797
797
  expect(screen.getByTestId("ai-chat-button")).toBeInTheDocument();
798
798
  });
799
799
  });
800
+
801
+ // ── Banner (Shell banner) ────────────────────────────────
802
+
803
+ describe("Shell banner", () => {
804
+ beforeEach(() => {
805
+ localStorage.clear();
806
+ });
807
+
808
+ it("renders banner text when banner config is provided", () => {
809
+ renderShell({ config: { ...baseConfig, banner: { text: "New version available!" } } });
810
+ expect(screen.getByText("New version available!")).toBeInTheDocument();
811
+ });
812
+
813
+ it("renders banner as a link when link is provided", () => {
814
+ renderShell({
815
+ config: { ...baseConfig, banner: { text: "Click here", link: "https://example.com" } },
816
+ });
817
+ const link = screen.getByText("Click here");
818
+ expect(link.tagName).toBe("A");
819
+ expect(link).toHaveAttribute("href", "https://example.com");
820
+ });
821
+
822
+ it("does not render banner when banner config is not provided", () => {
823
+ renderShell();
824
+ expect(screen.queryByText("New version available!")).not.toBeInTheDocument();
825
+ });
826
+
827
+ it("dismisses banner when X button is clicked", () => {
828
+ renderShell({ config: { ...baseConfig, banner: { text: "Dismiss me" } } });
829
+ expect(screen.getByText("Dismiss me")).toBeInTheDocument();
830
+ const dismissBtn = screen.getByLabelText("Dismiss banner");
831
+ fireEvent.click(dismissBtn);
832
+ expect(screen.queryByText("Dismiss me")).not.toBeInTheDocument();
833
+ });
834
+ });
835
+
836
+ // ── Feedback widget ──────────────────────────────────────
837
+
838
+ describe("Shell feedback widget", () => {
839
+ it("renders 'Was this helpful?' text", () => {
840
+ renderShell();
841
+ expect(screen.getByText("Was this helpful?")).toBeInTheDocument();
842
+ });
843
+
844
+ it("renders thumbs up and thumbs down buttons", () => {
845
+ renderShell();
846
+ expect(screen.getByText("\uD83D\uDC4D")).toBeInTheDocument();
847
+ expect(screen.getByText("\uD83D\uDC4E")).toBeInTheDocument();
848
+ });
849
+
850
+ it("shows 'Thanks for your feedback!' after clicking thumbs up", () => {
851
+ renderShell();
852
+ fireEvent.click(screen.getByText("\uD83D\uDC4D"));
853
+ expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
854
+ });
855
+
856
+ it("shows 'Thanks for your feedback!' after clicking thumbs down", () => {
857
+ renderShell();
858
+ fireEvent.click(screen.getByText("\uD83D\uDC4E"));
859
+ expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
860
+ });
861
+ });
862
+
863
+ // ── Image zoom ───────────────────────────────────────────
864
+
865
+ describe("Shell image zoom", () => {
866
+ it("does not show zoom overlay initially", () => {
867
+ const { container } = renderShell();
868
+ const zoomOverlay = container.querySelector('[style*="cursor: zoom-out"]');
869
+ expect(zoomOverlay).toBeNull();
870
+ });
871
+ });
package/src/Shell.tsx CHANGED
@@ -96,12 +96,14 @@ function AlgoliaSearchModal({
96
96
  indexName,
97
97
  onNavigate,
98
98
  onClose,
99
+ basePath = "",
99
100
  }: {
100
101
  appId: string;
101
102
  apiKey: string;
102
103
  indexName: string;
103
104
  onNavigate: (id: string) => void;
104
105
  onClose: () => void;
106
+ basePath?: string;
105
107
  }) {
106
108
  const [DocSearchComponent, setDocSearchComponent] = useState<React.ComponentType<any> | null>(null);
107
109
  const [loadFailed, setLoadFailed] = useState(false);
@@ -122,7 +124,12 @@ function AlgoliaSearchModal({
122
124
  const extractPageId = useCallback((url: string): string => {
123
125
  try {
124
126
  const parsed = new URL(url, "http://localhost");
125
- const pathname = parsed.pathname;
127
+ let pathname = parsed.pathname;
128
+ // Strip basePath prefix
129
+ if (basePath) {
130
+ const bp = basePath.replace(/\/$/, "");
131
+ if (pathname.startsWith(bp)) pathname = pathname.slice(bp.length);
132
+ }
126
133
  return pathname
127
134
  .replace(/^\//, "")
128
135
  .replace(/\/index\.html$/, "")
@@ -131,7 +138,7 @@ function AlgoliaSearchModal({
131
138
  } catch {
132
139
  return "index";
133
140
  }
134
- }, []);
141
+ }, [basePath]);
135
142
 
136
143
  if (loadFailed) {
137
144
  return (
@@ -291,6 +298,7 @@ interface ShellProps {
291
298
  ai?: { enabled?: boolean; provider?: "openai" | "anthropic" | "custom"; model?: string; apiKeyEnv?: string };
292
299
  toc?: { enabled?: boolean; depth?: number };
293
300
  topNav?: Array<{ label: string; href: string }>;
301
+ banner?: { text: string; link?: string; dismissible?: boolean };
294
302
  [key: string]: unknown;
295
303
  };
296
304
  navigation: Array<{
@@ -315,12 +323,13 @@ interface ShellProps {
315
323
  i18n?: I18nInfo;
316
324
  currentLocale?: string;
317
325
  docContext?: Array<{ id: string; title: string; content: string }>;
326
+ basePath?: string;
318
327
  }
319
328
 
320
329
  export function Shell({
321
330
  config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
322
331
  pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries, onNavigate, allPages,
323
- versioning, currentVersion, i18n, currentLocale, docContext,
332
+ versioning, currentVersion, i18n, currentLocale, docContext, basePath = "",
324
333
  }: ShellProps) {
325
334
  const themeMode = config.theme?.mode || "auto";
326
335
 
@@ -336,6 +345,15 @@ export function Shell({
336
345
  const [searchOpen, setSearch] = useState(false);
337
346
  const [versionDropdownOpen, setVersionDropdown] = useState(false);
338
347
  const [localeDropdownOpen, setLocaleDropdown] = useState(false);
348
+ const [zoomSrc, setZoomSrc] = useState<string | null>(null);
349
+ const [feedbackGiven, setFeedbackGiven] = useState<Record<string, boolean>>({});
350
+ const [bannerDismissed, setBannerDismissed] = useState(() => {
351
+ if (!config.banner?.text) return true;
352
+ try {
353
+ const hash = Array.from(config.banner.text).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0).toString(36);
354
+ return localStorage.getItem("tome-banner-dismissed") === hash;
355
+ } catch { return false; }
356
+ });
339
357
 
340
358
  // TOM-30: Determine if viewing an old version
341
359
  const isOldVersion = versioning && currentVersion && currentVersion !== versioning.current;
@@ -398,6 +416,62 @@ export function Shell({
398
416
 
399
417
  useEffect(() => { contentRef.current?.scrollTo(0, 0); }, [currentPageId]);
400
418
 
419
+ // ── Image zoom: delegate click on .tome-content img ──
420
+ useEffect(() => {
421
+ const el = contentRef.current;
422
+ if (!el) return;
423
+ const handler = (e: MouseEvent) => {
424
+ const target = e.target as HTMLElement;
425
+ if (target.tagName === "IMG" && target.closest(".tome-content")) {
426
+ setZoomSrc((target as HTMLImageElement).src);
427
+ }
428
+ };
429
+ el.addEventListener("click", handler);
430
+ return () => el.removeEventListener("click", handler);
431
+ }, []);
432
+
433
+ // ── Content link interception: delegate click on .tome-content a ──
434
+ // Intercepts internal links in rendered markdown so they navigate via pushState
435
+ // instead of triggering a full page reload.
436
+ useEffect(() => {
437
+ const el = contentRef.current;
438
+ if (!el) return;
439
+ const handler = (e: MouseEvent) => {
440
+ const anchor = (e.target as HTMLElement).closest("a");
441
+ if (!anchor) return;
442
+ const href = anchor.getAttribute("href");
443
+ if (!href) return;
444
+ // Skip external links, mailto, tel, pure anchors
445
+ if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("//")) return;
446
+ // Pure heading anchor — let default scrolling work
447
+ if (href.startsWith("#")) return;
448
+ // Internal link — resolve page ID by stripping basePath
449
+ e.preventDefault();
450
+ let pageId = href.replace(/^\.\//, "").replace(/^\//, "").replace(/\.mdx?$/, "").replace(/\/$/, "");
451
+ // Strip basePath prefix (e.g. "docs/" from "/docs/quickstart")
452
+ if (basePath) {
453
+ const normalized = basePath.replace(/^\//, "").replace(/\/$/, "");
454
+ if (normalized && pageId.startsWith(normalized + "/")) {
455
+ pageId = pageId.slice(normalized.length + 1);
456
+ } else if (normalized && pageId === normalized) {
457
+ pageId = "index";
458
+ }
459
+ }
460
+ if (!pageId) pageId = "index";
461
+ onNavigate(pageId);
462
+ };
463
+ el.addEventListener("click", handler);
464
+ return () => el.removeEventListener("click", handler);
465
+ }, [onNavigate, basePath]);
466
+
467
+ // ── Image zoom: Escape to dismiss ──
468
+ useEffect(() => {
469
+ if (!zoomSrc) return;
470
+ const handler = (e: KeyboardEvent) => { if (e.key === "Escape") setZoomSrc(null); };
471
+ window.addEventListener("keydown", handler);
472
+ return () => window.removeEventListener("keydown", handler);
473
+ }, [zoomSrc]);
474
+
401
475
  // ── TOC: Config-based depth filtering + frontmatter opt-out ──
402
476
  const tocConfig = config.toc;
403
477
  const tocDepth = tocConfig?.depth ?? 3;
@@ -498,8 +572,55 @@ export function Shell({
498
572
 
499
573
  const PageComponent = pageComponent;
500
574
 
575
+ // Compute banner link properties
576
+ const bannerLink = config.banner?.link;
577
+ const bannerIsInternal = bannerLink ? (bannerLink.startsWith("#") || (basePath && bannerLink.startsWith(basePath + "/"))) : false;
578
+
501
579
  return (
502
- <div className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", minHeight: "100vh" }}>
580
+ <div className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", minHeight: "100vh", overflow: "hidden" }}>
581
+ {/* Banner */}
582
+ {config.banner?.text && !bannerDismissed && (
583
+ <div style={{
584
+ display: "flex", alignItems: "center", justifyContent: "center", gap: 12,
585
+ background: "var(--ac)", color: "#fff", padding: "8px 16px",
586
+ fontSize: 13, fontFamily: "var(--font-body)", fontWeight: 500, textAlign: "center",
587
+ width: "100%", boxSizing: "border-box",
588
+ }}>
589
+ {config.banner.link ? (
590
+ <a
591
+ href={bannerIsInternal && bannerLink!.startsWith("#") ? (basePath + "/" + bannerLink!.slice(1)) : bannerLink!}
592
+ {...(bannerIsInternal ? {} : { target: "_blank", rel: "noopener noreferrer" })}
593
+ style={{ color: "#fff", textDecoration: "underline" }}
594
+ onClick={bannerIsInternal ? (e: React.MouseEvent) => {
595
+ e.preventDefault();
596
+ const bp = basePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
597
+ const pageId = bannerLink!.startsWith("#") ? bannerLink!.slice(1) : bannerLink!.replace(new RegExp("^" + bp + "/?"), "");
598
+ onNavigate(pageId || "index");
599
+ } : undefined}
600
+ >
601
+ {config.banner.text}
602
+ </a>
603
+ ) : (
604
+ <span>{config.banner.text}</span>
605
+ )}
606
+ {config.banner.dismissible !== false && (
607
+ <button
608
+ onClick={() => {
609
+ setBannerDismissed(true);
610
+ try {
611
+ const hash = Array.from(config.banner!.text).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0).toString(36);
612
+ localStorage.setItem("tome-banner-dismissed", hash);
613
+ } catch {}
614
+ }}
615
+ aria-label="Dismiss banner"
616
+ style={{ background: "none", border: "none", color: "#fff", cursor: "pointer", fontSize: 16, lineHeight: 1, padding: 0, opacity: 0.8 }}
617
+ >
618
+ &times;
619
+ </button>
620
+ )}
621
+ </div>
622
+ )}
623
+
503
624
  {/* Search Modal (TOM-16: branch on provider) */}
504
625
  {searchOpen && config.search?.provider === "algolia" && config.search.appId && config.search.apiKey && config.search.indexName ? (
505
626
  <AlgoliaSearchModal
@@ -508,6 +629,7 @@ export function Shell({
508
629
  indexName={config.search.indexName}
509
630
  onNavigate={(id) => { onNavigate(id); setSearch(false); }}
510
631
  onClose={() => setSearch(false)}
632
+ basePath={basePath}
511
633
  />
512
634
  ) : searchOpen ? (
513
635
  <SearchModal
@@ -518,7 +640,7 @@ export function Shell({
518
640
  />
519
641
  ) : null}
520
642
 
521
- <div style={{ display: "flex", height: "100vh" }}>
643
+ <div style={{ display: "flex", flex: 1, height: config.banner?.text && !bannerDismissed ? "calc(100vh - 32px)" : "100vh" }}>
522
644
  {/* Mobile sidebar backdrop */}
523
645
  {mobile && sbOpen && (
524
646
  <div onClick={() => setSb(false)} style={{
@@ -587,6 +709,31 @@ export function Shell({
587
709
  ))}
588
710
  </nav>
589
711
 
712
+ {/* Mobile version switcher in sidebar footer */}
713
+ {versioning && mobile && (
714
+ <div style={{ padding: "8px 16px", borderTop: "1px solid var(--bd)", display: "flex", gap: 6 }}>
715
+ {versioning.versions.map(v => (
716
+ <button
717
+ key={v}
718
+ onClick={() => {
719
+ // Navigate to the version's index page via hash routing
720
+ const targetId = v === versioning.current ? "index" : `${v}/index`;
721
+ onNavigate(targetId);
722
+ }}
723
+ style={{
724
+ flex: 1, padding: "6px 0", textAlign: "center",
725
+ background: v === (currentVersion || versioning.current) ? "var(--acD)" : "var(--sf)",
726
+ border: "1px solid var(--bd)", borderRadius: 2, cursor: "pointer",
727
+ color: v === (currentVersion || versioning.current) ? "var(--ac)" : "var(--tx2)",
728
+ fontSize: 12, fontFamily: "var(--font-code)",
729
+ fontWeight: v === versioning.current ? 600 : 400,
730
+ }}
731
+ >
732
+ {v}{v === versioning.current ? " (latest)" : ""}
733
+ </button>
734
+ ))}
735
+ </div>
736
+ )}
590
737
  <div style={{ padding: "12px 16px", borderTop: "1px solid var(--bd)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
591
738
  {/* TOM-12: Only show toggle when mode is "auto" */}
592
739
  {themeMode === "auto" ? (
@@ -605,6 +752,7 @@ export function Shell({
605
752
  <header style={{
606
753
  display: "flex", alignItems: "center", gap: mobile ? 8 : 12, padding: mobile ? "8px 12px" : "10px 24px",
607
754
  borderBottom: "1px solid var(--bd)", background: "var(--hdBg)", backdropFilter: "blur(12px)",
755
+ maxWidth: "100vw", overflow: "visible", position: "relative", zIndex: 200,
608
756
  }}>
609
757
  <button aria-label={sbOpen ? "Close sidebar" : "Open sidebar"} onClick={() => setSb(!sbOpen)} style={{ background: "none", border: "none", color: "var(--txM)", cursor: "pointer", display: "flex" }}>
610
758
  {sbOpen ? <XIcon /> : <MenuIcon />}
@@ -629,12 +777,18 @@ export function Shell({
629
777
  {config.topNav && config.topNav.length > 0 && !mobile && (
630
778
  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
631
779
  {config.topNav.map((link) => {
632
- const isExternal = link.href.startsWith("http") || !link.href.startsWith("#");
780
+ const isInternal = link.href.startsWith("#") || (basePath && link.href.startsWith(basePath + "/"));
781
+ const isExternal = !isInternal;
633
782
  return (
634
783
  <a
635
784
  key={link.label}
636
- href={link.href}
785
+ href={isInternal && link.href.startsWith("#") ? (basePath + "/" + link.href.slice(1)) : link.href}
637
786
  {...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
787
+ onClick={isInternal ? (e: React.MouseEvent) => {
788
+ e.preventDefault();
789
+ const pageId = link.href.startsWith("#") ? link.href.slice(1) : link.href.replace(new RegExp("^" + basePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/?"), "");
790
+ onNavigate(pageId);
791
+ } : undefined}
638
792
  style={{
639
793
  display: "flex", alignItems: "center", gap: 4,
640
794
  color: "var(--txM)", textDecoration: "none", fontSize: 12,
@@ -653,8 +807,15 @@ export function Shell({
653
807
  </div>
654
808
  )}
655
809
 
656
- {/* TOM-30: Version Switcher */}
657
- {versioning && (
810
+ {/* Theme toggle in header on mobile */}
811
+ {mobile && themeMode === "auto" && (
812
+ <button aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} onClick={() => setDark(d => !d)} style={{ background: "none", border: "none", color: "var(--txM)", cursor: "pointer", display: "flex", flexShrink: 0 }}>
813
+ {isDark ? <SunIcon /> : <MoonIcon />}
814
+ </button>
815
+ )}
816
+
817
+ {/* TOM-30: Version Switcher — hidden on mobile */}
818
+ {versioning && !mobile && (
658
819
  <div style={{ position: "relative" }}>
659
820
  <button
660
821
  data-testid="version-switcher"
@@ -685,9 +846,9 @@ export function Shell({
685
846
  key={v}
686
847
  onClick={() => {
687
848
  setVersionDropdown(false);
688
- // Navigate to the version's root page
689
- const targetUrl = v === versioning.current ? "/" : `/${v}`;
690
- window.location.href = targetUrl;
849
+ // Navigate to the version's index page via hash routing
850
+ const targetId = v === versioning.current ? "index" : `${v}/index`;
851
+ onNavigate(targetId);
691
852
  }}
692
853
  style={{
693
854
  display: "block", width: "100%", textAlign: "left",
@@ -706,8 +867,8 @@ export function Shell({
706
867
  </div>
707
868
  )}
708
869
 
709
- {/* TOM-34: Language Switcher */}
710
- {i18n && i18n.locales.length > 1 && (
870
+ {/* TOM-34: Language Switcher — hidden on mobile */}
871
+ {i18n && i18n.locales.length > 1 && !mobile && (
711
872
  <div style={{ position: "relative" }}>
712
873
  <button
713
874
  data-testid="language-switcher"
@@ -786,7 +947,7 @@ export function Shell({
786
947
  >
787
948
  <span>You're viewing docs for {currentVersion}.</span>
788
949
  <button
789
- onClick={() => { window.location.href = "/"; }}
950
+ onClick={() => { onNavigate("index"); }}
790
951
  style={{
791
952
  background: "none", border: "none", color: "var(--ac)",
792
953
  cursor: "pointer", fontWeight: 600, fontSize: 13,
@@ -850,8 +1011,25 @@ export function Shell({
850
1011
  </div>
851
1012
  )}
852
1013
 
1014
+ {/* Feedback widget */}
1015
+ <div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 24, padding: "12px 0" }}>
1016
+ {feedbackGiven[currentPageId] ? (
1017
+ <span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Thanks for your feedback!</span>
1018
+ ) : (
1019
+ <>
1020
+ <span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Was this helpful?</span>
1021
+ <button onClick={() => { setFeedbackGiven(prev => ({ ...prev, [currentPageId]: true })); try { localStorage.setItem(`tome-feedback-${currentPageId}`, "up"); } catch {} }} style={{
1022
+ background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
1023
+ }}>👍</button>
1024
+ <button onClick={() => { setFeedbackGiven(prev => ({ ...prev, [currentPageId]: true })); try { localStorage.setItem(`tome-feedback-${currentPageId}`, "down"); } catch {} }} style={{
1025
+ background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
1026
+ }}>👎</button>
1027
+ </>
1028
+ )}
1029
+ </div>
1030
+
853
1031
  {/* Prev / Next */}
854
- <div style={{ display: "flex", flexDirection: mobile ? "column" : "row", justifyContent: "space-between", marginTop: (editUrl || lastUpdated) ? 16 : 48, paddingTop: 24, borderTop: "1px solid var(--bd)", gap: mobile ? 12 : 16 }}>
1032
+ <div style={{ display: "flex", flexDirection: mobile ? "column" : "row", justifyContent: "space-between", marginTop: 16, paddingTop: 24, borderTop: "1px solid var(--bd)", gap: mobile ? 12 : 16 }}>
855
1033
  {prev ? (
856
1034
  <button onClick={() => onNavigate(prev.id)} style={{
857
1035
  display: "flex", alignItems: "center", gap: 8, background: "none",
@@ -915,6 +1093,16 @@ export function Shell({
915
1093
  context={docContext?.map((d) => `## ${d.title}\n${d.content}`).join("\n\n") ?? allPages.map((p) => `- ${p.title}${p.description ? ": " + p.description : ""}`).join("\n")}
916
1094
  />
917
1095
  )}
1096
+
1097
+ {/* Image zoom overlay */}
1098
+ {zoomSrc && (
1099
+ <div onClick={() => setZoomSrc(null)} style={{
1100
+ position: "fixed", inset: 0, zIndex: 9999, display: "flex", alignItems: "center", justifyContent: "center",
1101
+ background: "rgba(0,0,0,0.7)", backdropFilter: "blur(8px)", cursor: "zoom-out",
1102
+ }}>
1103
+ <img src={zoomSrc} alt="" style={{ maxWidth: "90vw", maxHeight: "90vh", objectFit: "contain", borderRadius: 4, boxShadow: "0 16px 64px rgba(0,0,0,0.4)" }} />
1104
+ </div>
1105
+ )}
918
1106
  </div>
919
1107
  );
920
1108
  }