for-humanity 0.4.3 → 0.4.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.4 — 2026-10-04
6
+
7
+ - Keep the document list and page outline in separate native scroll areas, so a shorter outline cannot clamp the document list position.
8
+ - Restore desktop document navigation before its first paint and avoid reinserting the same navigation DOM during startup.
9
+ - Indicate remaining navigation content with conditional top and bottom fades, keeping native scrollbars in forced colors and without JavaScript.
10
+ - Connect the active outline branch through its parent while highlighting only the exact current heading.
11
+
5
12
  ## 0.4.3 — 2026-10-04
6
13
 
7
14
  - Use standard native navigation scrollbars in forced-color mode so browser color adjustment cannot hide custom thumbs against the background.
package/README.md CHANGED
@@ -33,7 +33,7 @@ Write Markdown. Keep the context. Share a static site.
33
33
  Node.js 22 or later and pnpm. Install the CLI in your document project:
34
34
 
35
35
  ```sh
36
- pnpm add -D --save-exact for-humanity@0.4.3
36
+ pnpm add -D --save-exact for-humanity@0.4.4
37
37
  mkdir docs
38
38
  ```
39
39
 
@@ -52,7 +52,7 @@ pnpm exec for-humanity preview docs
52
52
 
53
53
  If your Markdown files are in the project root, use `.` instead of `docs`. The root `README.md` becomes the home page; root `AGENTS.md` and `CLAUDE.md` stay out of the site.
54
54
 
55
- Version `0.4.3` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
55
+ Version `0.4.4` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
56
56
  Cloudflare Pages can host the generated site. See [Deployment](https://github.com/minu-ha/for-humanity/blob/main/templates/document/deployment.md) for the build settings.
57
57
 
58
58
  ### Develop the kit
@@ -74,7 +74,7 @@ pnpm preview # Preview the static site
74
74
  pnpm build:pages # Add Cloudflare Pages headers and a 404 page
75
75
  ```
76
76
 
77
- Changes pushed to `main` update the documentation site. A stable version tag such as `v0.4.3` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
77
+ Changes pushed to `main` update the documentation site. A stable version tag such as `v0.4.4` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
78
78
  The npm README updates with each package release. See [Maintenance](https://github.com/minu-ha/for-humanity/blob/main/templates/document/maintenance.md#packaging-and-release) for the release steps.
79
79
 
80
80
  ## Make it yours
package/dist/cli.css CHANGED
@@ -448,9 +448,6 @@
448
448
 
449
449
  /* src/component/widget/shell/_wg-shell-toc.css */
450
450
  .wg_shellToc__root {
451
- margin-top: var(--app-space-group);
452
- padding-top: var(--app-space-group);
453
- border-top: 1px solid var(--app-color-border-soft);
454
451
  font-size: var(--app-font-size-dense);
455
452
  line-height: var(--app-line-height-tight);
456
453
  }
@@ -565,6 +562,9 @@
565
562
  &::after {
566
563
  border-left-color: var(--app-color-text-strong);
567
564
  }
565
+ & > .wg_shellToc__link::before {
566
+ border-top-color: var(--app-color-text-strong);
567
+ }
568
568
  }
569
569
  .wg_shellToc__sub {
570
570
  margin: var(--app-space-tree-row) 0 4px var(--app-space-tree-indent);
@@ -754,6 +754,7 @@
754
754
  --app-size-mobile-header: 56px;
755
755
  --app-size-menu-button: 44px;
756
756
  --app-size-drawer: 320px;
757
+ --app-size-navigation-fade: 28px;
757
758
  --app-space-anchor: 80px;
758
759
  --app-radius-pill: 0;
759
760
  --app-radius-card: 0;
@@ -884,34 +885,64 @@
884
885
  top: 0;
885
886
  align-self: start;
886
887
  min-width: 0;
887
- min-height: 100vh;
888
- max-height: 100vh;
888
+ height: 100dvh;
889
889
  margin-inline-end: var(--app-space-sidebar-gap);
890
- overflow-y: auto;
891
- overscroll-behavior-y: contain;
892
- scrollbar-gutter: stable;
893
- scrollbar-width: auto;
894
- scrollbar-color: auto;
895
- scroll-padding-block: var(--app-size-brand) var(--app-space-block);
896
- &::-webkit-scrollbar {
897
- width: 3px;
898
- }
899
- &::-webkit-scrollbar-track {
900
- background: transparent;
901
- }
902
- &::-webkit-scrollbar-thumb {
903
- border-radius: 3px;
904
- background: transparent;
905
- }
906
- &:hover::-webkit-scrollbar-thumb {
907
- background: var(--app-color-border-soft);
908
- }
909
- &:focus-within::-webkit-scrollbar-thumb {
910
- background: var(--app-color-border-soft);
911
- }
912
890
  }
913
891
  .wg_shell__navigation {
892
+ display: flex;
893
+ flex-direction: column;
894
+ height: calc(100dvh - var(--app-size-brand));
914
895
  min-width: 0;
896
+ min-height: 0;
897
+ }
898
+ .wg_shell__documents {
899
+ flex: none;
900
+ max-height: 60%;
901
+ }
902
+ .wg_shell__outline {
903
+ display: flex;
904
+ flex-direction: column;
905
+ min-height: 0;
906
+ margin-top: var(--app-space-group);
907
+ padding-top: var(--app-space-group);
908
+ border-top: 1px solid var(--app-color-border-soft);
909
+ &:has(.wg_shell__headings:empty) {
910
+ display: none;
911
+ }
912
+ }
913
+ .wg_shell__headings {
914
+ min-height: 0;
915
+ }
916
+ .wg_shell__scroll {
917
+ overflow-y: auto;
918
+ overscroll-behavior-y: contain;
919
+ scroll-padding-block: var(--app-size-navigation-fade);
920
+ }
921
+ .wg_shell__scroll--ready {
922
+ scrollbar-width: none;
923
+ }
924
+ .wg_shell__scroll--above {
925
+ mask-image:
926
+ linear-gradient(
927
+ to bottom,
928
+ transparent,
929
+ #000 var(--app-size-navigation-fade));
930
+ }
931
+ .wg_shell__scroll--below {
932
+ mask-image:
933
+ linear-gradient(
934
+ to bottom,
935
+ #000 calc(100% - var(--app-size-navigation-fade)),
936
+ transparent);
937
+ }
938
+ .wg_shell__scroll--above.wg_shell__scroll--below {
939
+ mask-image:
940
+ linear-gradient(
941
+ to bottom,
942
+ transparent,
943
+ #000 var(--app-size-navigation-fade),
944
+ #000 calc(100% - var(--app-size-navigation-fade)),
945
+ transparent);
915
946
  }
916
947
  .wg_shell__main {
917
948
  grid-area: main;
@@ -934,35 +965,12 @@
934
965
  border-right: 1px solid var(--app-color-border-layout);
935
966
  background: var(--app-color-ground);
936
967
  color: var(--app-color-text);
937
- scrollbar-gutter: stable;
938
- scrollbar-width: auto;
939
- scrollbar-color: auto;
940
- overscroll-behavior: contain;
941
- scroll-padding-block: var(--app-size-mobile-header) var(--app-space-group);
942
- &::-webkit-scrollbar {
943
- width: 3px;
944
- }
945
- &::-webkit-scrollbar-track {
946
- background: transparent;
947
- }
948
- &::-webkit-scrollbar-thumb {
949
- border-radius: 3px;
950
- background: transparent;
951
- }
952
- &:hover::-webkit-scrollbar-thumb {
953
- background: var(--app-color-border-soft);
954
- }
955
- &:focus-within::-webkit-scrollbar-thumb {
956
- background: var(--app-color-border-soft);
957
- }
968
+ overflow: hidden;
958
969
  &::backdrop {
959
970
  background: var(--app-color-backdrop);
960
971
  }
961
972
  }
962
973
  .wg_shell__drawerHeader {
963
- position: sticky;
964
- top: 0;
965
- z-index: var(--app-z-index-sticky);
966
974
  display: flex;
967
975
  align-items: center;
968
976
  justify-content: space-between;
@@ -999,6 +1007,10 @@
999
1007
  }
1000
1008
  .wg_shell__drawerContent {
1001
1009
  min-width: 0;
1010
+ height: calc(100% - var(--app-size-mobile-header) - var(--app-space-block));
1011
+ & > .wg_shell__navigation {
1012
+ height: 100%;
1013
+ }
1002
1014
  }
1003
1015
  .wg_shell__cursorFace {
1004
1016
  position: fixed;
@@ -1013,42 +1025,11 @@
1013
1025
  .wg_shell__cursorFace--visible {
1014
1026
  visibility: visible;
1015
1027
  }
1016
- @supports not selector(::-webkit-scrollbar) {
1017
- .wg_shell__sidebar {
1018
- scrollbar-width: thin;
1019
- scrollbar-color: transparent transparent;
1020
- &:hover {
1021
- scrollbar-color: var(--app-color-border-soft) transparent;
1022
- }
1023
- &:focus-within {
1024
- scrollbar-color: var(--app-color-border-soft) transparent;
1025
- }
1026
- }
1027
- .wg_shell__drawer {
1028
- scrollbar-width: thin;
1029
- scrollbar-color: var(--app-color-border-soft) transparent;
1030
- }
1031
- }
1032
1028
  @media (forced-colors: active) {
1033
- .wg_shell__sidebar {
1029
+ .wg_shell__scroll {
1030
+ mask-image: none;
1034
1031
  scrollbar-width: thin;
1035
1032
  scrollbar-color: auto;
1036
- &:hover {
1037
- scrollbar-color: auto;
1038
- }
1039
- &:focus-within {
1040
- scrollbar-color: auto;
1041
- }
1042
- }
1043
- .wg_shell__drawer {
1044
- scrollbar-width: thin;
1045
- scrollbar-color: auto;
1046
- &:hover {
1047
- scrollbar-color: auto;
1048
- }
1049
- &:focus-within {
1050
- scrollbar-color: auto;
1051
- }
1052
1033
  }
1053
1034
  }
1054
1035
  @media (width < 1024px) {
@@ -1058,13 +1039,17 @@
1058
1039
  }
1059
1040
  .wg_shell__sidebar {
1060
1041
  position: static;
1061
- min-height: 0;
1062
- max-height: none;
1063
- overflow-y: visible;
1042
+ height: auto;
1064
1043
  margin-block-end: var(--app-space-block);
1065
1044
  margin-inline-end: 0;
1066
1045
  border-bottom: 1px solid var(--app-color-border-layout);
1067
1046
  }
1047
+ .wg_shell__sidebar > .wg_shell__navigation {
1048
+ height: auto;
1049
+ & > .wg_shell__documents {
1050
+ max-height: none;
1051
+ }
1052
+ }
1068
1053
  .wg_shell__sidebar--navigationReady {
1069
1054
  position: sticky;
1070
1055
  top: 0;
package/dist/cli.js CHANGED
@@ -33,6 +33,68 @@ var WgProse = (props) => {
33
33
  // src/component/widget/shell/wg-shell.tsx
34
34
  import clsx5 from "clsx";
35
35
 
36
+ // src/component/widget/shell/_constant/navigation.ts
37
+ var navigation_mobile_query = "(width < 1024px)";
38
+ var navigation_scroll_storage_key = "for-humanity:navigation-scroll";
39
+
40
+ // src/component/widget/shell/_function/bind-navigation-overflow.ts
41
+ var bindNavigationOverflow = () => {
42
+ const ports = [...document.querySelectorAll("[data-navigation-scroll]")];
43
+ const frame = { id: 0 };
44
+ const edgeTolerance = 1;
45
+ const updateOverflow = () => {
46
+ frame.id = 0;
47
+ for (const port of ports) {
48
+ port.classList.add("wg_shell__scroll--ready");
49
+ port.classList.toggle("wg_shell__scroll--above", port.scrollTop > edgeTolerance);
50
+ port.classList.toggle("wg_shell__scroll--below", port.scrollHeight - port.clientHeight - port.scrollTop > edgeTolerance);
51
+ }
52
+ };
53
+ const scheduleOverflow = () => {
54
+ if (frame.id === 0) {
55
+ frame.id = requestAnimationFrame(updateOverflow);
56
+ }
57
+ };
58
+ const observer = new ResizeObserver(scheduleOverflow);
59
+ for (const port of ports) {
60
+ port.addEventListener("scroll", scheduleOverflow, { passive: true });
61
+ observer.observe(port);
62
+ if (port.firstElementChild) {
63
+ observer.observe(port.firstElementChild);
64
+ }
65
+ }
66
+ addEventListener("pageshow", scheduleOverflow);
67
+ updateOverflow();
68
+ };
69
+
70
+ // src/component/widget/shell/_function/restore-navigation-scroll.ts
71
+ var restoreNavigationScroll = (storageKey) => {
72
+ const navigation = document.querySelector("[data-navigation]");
73
+ const documents = document.querySelector('[data-navigation-scroll="documents"]');
74
+ const outline = document.querySelector('[data-navigation-scroll="outline"]');
75
+ if (!navigation || !documents || !outline) {
76
+ return;
77
+ }
78
+ try {
79
+ const value = sessionStorage.getItem(storageKey);
80
+ if (value === null) {
81
+ return;
82
+ }
83
+ const stored = JSON.parse(value);
84
+ const signature = [...navigation.querySelectorAll(".wg_shellNav__root a[href]")].map((link) => `${link.pathname}:${link.textContent}`).join("\n");
85
+ if (typeof stored !== "object" || stored === null || !("navigation" in stored) || stored.navigation !== signature) {
86
+ return;
87
+ }
88
+ if ("documents" in stored && typeof stored.documents === "number" && Number.isFinite(stored.documents) && stored.documents >= 0) {
89
+ documents.scrollTop = stored.documents;
90
+ }
91
+ if ("pathname" in stored && stored.pathname === location.pathname && "outline" in stored && typeof stored.outline === "number" && Number.isFinite(stored.outline) && stored.outline >= 0) {
92
+ outline.scrollTop = stored.outline;
93
+ }
94
+ } catch {
95
+ }
96
+ };
97
+
36
98
  // src/component/widget/shell/_function/to-toc-groups.ts
37
99
  import { takeWhile } from "es-toolkit/array";
38
100
 
@@ -263,6 +325,7 @@ var asset_media_extensions = /* @__PURE__ */ new Set([".svg", ".png", ".jpg", ".
263
325
  // src/component/widget/shell/wg-shell.tsx
264
326
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
265
327
  var reloadScript = `new EventSource(${JSON.stringify(asset_reload_path)}).onmessage=function(){location.reload()}`;
328
+ var navigationScript = `(${bindNavigationOverflow.toString()})();if(!matchMedia(${JSON.stringify(navigation_mobile_query)}).matches){(${restoreNavigationScroll.toString()})(${JSON.stringify(`${navigation_scroll_storage_key}:desktop`)})}`;
266
329
  var WgShell = (props) => {
267
330
  const tocGroups = props.outline === void 0 ? [] : toTocGroups(props.outline);
268
331
  return /* @__PURE__ */ jsxs5("html", { lang: "ko", children: [
@@ -296,10 +359,11 @@ var WgShell = (props) => {
296
359
  )
297
360
  ] }),
298
361
  /* @__PURE__ */ jsxs5("div", { className: clsx5("wg_shell__navigation"), "data-navigation": "", children: [
299
- /* @__PURE__ */ jsx5(WgShellNav, { site: props.site, docs: props.docs, current: props.current }),
300
- tocGroups.length > 0 && /* @__PURE__ */ jsx5(WgShellToc, { groups: tocGroups })
362
+ /* @__PURE__ */ jsx5("div", { className: clsx5("wg_shell__documents", "wg_shell__scroll"), "data-navigation-scroll": "documents", children: /* @__PURE__ */ jsx5(WgShellNav, { site: props.site, docs: props.docs, current: props.current }) }),
363
+ /* @__PURE__ */ jsx5("div", { className: clsx5("wg_shell__outline"), children: /* @__PURE__ */ jsx5("div", { className: clsx5("wg_shell__headings", "wg_shell__scroll"), "data-navigation-scroll": "outline", children: tocGroups.length > 0 && /* @__PURE__ */ jsx5(WgShellToc, { groups: tocGroups }) }) })
301
364
  ] })
302
365
  ] }),
366
+ /* @__PURE__ */ jsx5("script", { dangerouslySetInnerHTML: { __html: navigationScript } }),
303
367
  /* @__PURE__ */ jsx5("main", { className: clsx5("wg_shell__main"), children: props.children }),
304
368
  /* @__PURE__ */ jsxs5("dialog", { className: clsx5("wg_shell__drawer"), id: "fh-navigation-drawer", "aria-labelledby": "fh-navigation-title", "data-navigation-drawer": "", children: [
305
369
  /* @__PURE__ */ jsxs5("div", { className: clsx5("wg_shell__drawerHeader"), children: [
@@ -1018,8 +1082,8 @@ var rehypeAssets = (options) => (tree, file) => {
1018
1082
  return;
1019
1083
  }
1020
1084
  const target = resolve2(dirname2(file.path), path);
1021
- const location = relative2(options.root, target);
1022
- if (location === ".." || location.startsWith(`..${sep}`) || isAbsolute(location)) {
1085
+ const location2 = relative2(options.root, target);
1086
+ if (location2 === ".." || location2.startsWith(`..${sep}`) || isAbsolute(location2)) {
1023
1087
  file.fail(`${copy_error_asset}: \uBB38\uC11C \uD3F4\uB354 \uBC16 \xB7 ${url}`, node);
1024
1088
  }
1025
1089
  if (!existsSync2(target)) {
package/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
- var T="fh-cursor-face";var w="(width < 1024px)",x="for-humanity:navigation-scroll";var b=e=>{if(!e)return null;try{return document.getElementById(decodeURIComponent(e.slice(1)))}catch(t){if(t instanceof URIError)return null;throw t}};var y=e=>{let t=b(e);for(let n=t?.closest("details");n;n=n.parentElement?.closest("details"))n.open=!0;return t};var I=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector("[data-navigation-drawer]"),n=document.querySelector("[data-navigation-content]"),a=document.querySelector("[data-navigation-open]"),s=document.querySelector("[data-navigation-close]"),u=e?.parentElement;if(!e||!t||!n||!a||!s||!u)return;let d=matchMedia(w),l=()=>{t.open||(document.body.classList.remove("wg_shell__root--drawerOpen"),a.setAttribute("aria-expanded","false"))},m=()=>{t.close(),l()},g=()=>{!d.matches||t.open||(t.showModal(),document.body.classList.add("wg_shell__root--drawerOpen"),a.setAttribute("aria-expanded","true"))},i=r=>{let o=t.getBoundingClientRect();r.target===t&&(r.clientX<o.left||r.clientX>o.right||r.clientY<o.top||r.clientY>o.bottom)&&m()},L=r=>{if(!t.open||r.defaultPrevented||r.button!==0||r.metaKey||r.ctrlKey||r.shiftKey||r.altKey||!(r.target instanceof Element))return;let o=r.target.closest("a[href]");if(!(!(o instanceof HTMLAnchorElement)||o.target!==""&&o.target!=="_self"||o.hasAttribute("download"))&&(m(),o.origin===location.origin&&o.pathname===location.pathname&&o.search===location.search&&o.hash)){let c=y(o.hash);c&&(c.tabIndex=-1,c.focus({preventScroll:!0}))}},p=()=>{let r=document.activeElement;if(d.matches){a.classList.add("wg_shell__menuToggle--visible"),n.append(e),r instanceof HTMLElement&&e.contains(r)&&a.focus({preventScroll:!0});return}m(),u.append(e),r instanceof HTMLElement&&e.contains(r)?r.focus({preventScroll:!0}):(r===a||r instanceof HTMLElement&&t.contains(r))&&u.querySelector(".wg_shell__brand")?.focus({preventScroll:!0}),a.classList.remove("wg_shell__menuToggle--visible")};a.addEventListener("click",g),s.addEventListener("click",m),t.addEventListener("close",l),t.addEventListener("click",i),e.addEventListener("click",L),d.addEventListener("change",p),p(),u.classList.add("wg_shell__sidebar--navigationReady")};var N=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector(".wg_shell__sidebar"),n=document.querySelector("[data-navigation-drawer]"),a=document.querySelector("[data-navigation-open]");if(!e||!t||!n||!a)return;let s=matchMedia(w),u={mobile:s.matches},d=[...e.querySelectorAll(".wg_shellNav__root a[href]")].map(o=>`${o.pathname}:${o.textContent}`).join(`
2
- `),l=new Map,m=new Map([[t,`${x}:desktop`],[n,`${x}:drawer`]]),g=new AbortController;for(let[o,c]of m){try{let _=sessionStorage.getItem(c);if(_!==null){let h=JSON.parse(_);typeof h=="object"&&h!==null&&"navigation"in h&&h.navigation===d&&"top"in h&&typeof h.top=="number"&&Number.isFinite(h.top)&&h.top>=0&&l.set(o,h.top)}}catch{}for(let _ of["wheel","touchstart","pointerdown","keydown"])o.addEventListener(_,()=>g.abort(),{once:!0,passive:!0})}let i=new Map(l),L=()=>{u.mobile=s.matches;let o=s.matches?n:t,c=l.get(o);c===void 0||s.matches&&!n.open||(o.scrollTop=c)},p=o=>{let c=s.matches?n:t,_=m.get(c);if(!(u.mobile!==s.matches||o.type==="scroll"&&o.currentTarget!==c||_===void 0||s.matches&&!n.open)){l.set(c,c.scrollTop);try{sessionStorage.setItem(_,JSON.stringify({navigation:d,top:c.scrollTop}))}catch{}}},r=async()=>{if(await document.fonts.ready,!g.signal.aborted){let o=s.matches?n:t,c=i.get(o);c!==void 0&&(!s.matches||n.open)&&(o.scrollTop=c)}};t.addEventListener("scroll",p,{passive:!0}),n.addEventListener("scroll",p,{passive:!0}),e.addEventListener("click",p,{capture:!0}),a.addEventListener("click",L),s.addEventListener("change",L),addEventListener("pagehide",p),L(),document.readyState==="complete"?r():addEventListener("load",r,{once:!0})};I();N();var f=document.querySelector("[data-cursor-face]");if(f){let e=matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"),t={x:0,y:0,frame:0},n=()=>{cancelAnimationFrame(t.frame),t.frame=0,f.classList.remove("wg_shell__cursorFace--visible")},a=()=>{e.matches||n()},s=()=>{t.frame===0&&(t.frame=requestAnimationFrame(()=>{t.frame=0,f.style.transform=`translate3d(
3
- ${Math.max(0,Math.min(t.x+16,document.documentElement.clientWidth-f.width-16))}px,
4
- ${Math.max(0,Math.min(t.y+16,document.documentElement.clientHeight-f.height-16))}px,
1
+ var E="fh-cursor-face";var b="(width < 1024px)",S="for-humanity:navigation-scroll";var v=e=>{if(!e)return null;try{return document.getElementById(decodeURIComponent(e.slice(1)))}catch(t){if(t instanceof URIError)return null;throw t}};var p=e=>{let t=v(e);for(let o=t?.closest("details");o;o=o.parentElement?.closest("details"))o.open=!0;return t};var q=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector("[data-navigation-drawer]"),o=document.querySelector("[data-navigation-content]"),r=document.querySelector("[data-navigation-open]"),c=document.querySelector("[data-navigation-close]"),n=e?.parentElement;if(!e||!t||!o||!r||!c||!n)return;let l=matchMedia(b),s=()=>{t.open||(document.body.classList.remove("wg_shell__root--drawerOpen"),r.setAttribute("aria-expanded","false"))},u=()=>{t.close(),s()},g=()=>{!l.matches||t.open||(t.showModal(),document.body.classList.add("wg_shell__root--drawerOpen"),r.setAttribute("aria-expanded","true"))},a=i=>{let d=t.getBoundingClientRect();i.target===t&&(i.clientX<d.left||i.clientX>d.right||i.clientY<d.top||i.clientY>d.bottom)&&u()},y=i=>{if(!t.open||i.defaultPrevented||i.button!==0||i.metaKey||i.ctrlKey||i.shiftKey||i.altKey||!(i.target instanceof Element))return;let d=i.target.closest("a[href]");if(!(!(d instanceof HTMLAnchorElement)||d.target!==""&&d.target!=="_self"||d.hasAttribute("download"))&&(u(),d.origin===location.origin&&d.pathname===location.pathname&&d.search===location.search&&d.hash)){let f=p(d.hash);f&&(f.tabIndex=-1,f.focus({preventScroll:!0}))}},m=()=>{let i=document.activeElement;if(l.matches){r.classList.add("wg_shell__menuToggle--visible"),e.parentElement!==o&&o.append(e),i instanceof HTMLElement&&e.contains(i)&&r.focus({preventScroll:!0});return}u(),e.parentElement!==n&&n.append(e),i instanceof HTMLElement&&e.contains(i)?i.focus({preventScroll:!0}):(i===r||i instanceof HTMLElement&&t.contains(i))&&n.querySelector(".wg_shell__brand")?.focus({preventScroll:!0}),r.classList.remove("wg_shell__menuToggle--visible")};r.addEventListener("click",g),c.addEventListener("click",u),t.addEventListener("close",s),t.addEventListener("click",a),e.addEventListener("click",y),l.addEventListener("change",m),m(),n.classList.add("wg_shell__sidebar--navigationReady")};var C=e=>{let t=document.querySelector("[data-navigation]"),o=document.querySelector('[data-navigation-scroll="documents"]'),r=document.querySelector('[data-navigation-scroll="outline"]');if(!(!t||!o||!r))try{let c=sessionStorage.getItem(e);if(c===null)return;let n=JSON.parse(c),l=[...t.querySelectorAll(".wg_shellNav__root a[href]")].map(s=>`${s.pathname}:${s.textContent}`).join(`
2
+ `);if(typeof n!="object"||n===null||!("navigation"in n)||n.navigation!==l)return;"documents"in n&&typeof n.documents=="number"&&Number.isFinite(n.documents)&&n.documents>=0&&(o.scrollTop=n.documents),"pathname"in n&&n.pathname===location.pathname&&"outline"in n&&typeof n.outline=="number"&&Number.isFinite(n.outline)&&n.outline>=0&&(r.scrollTop=n.outline)}catch{}};var N=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector('[data-navigation-scroll="documents"]'),o=document.querySelector('[data-navigation-scroll="outline"]'),r=document.querySelector("[data-navigation-drawer]"),c=document.querySelector("[data-navigation-open]");if(!e||!t||!o||!r||!c)return;let n=matchMedia(b),l={mobile:n.matches},s=[t,o],u=new Map,g=[...e.querySelectorAll(".wg_shellNav__root a[href]")].map(m=>`${m.pathname}:${m.textContent}`).join(`
3
+ `),a=()=>{if(l.mobile!==n.matches||n.matches&&!r.open)return;let m=`${S}:${n.matches?"drawer":"desktop"}`;u.set(m,new Map(s.map(i=>[i,i.scrollTop])));try{sessionStorage.setItem(m,JSON.stringify({navigation:g,pathname:location.pathname,documents:t.scrollTop,outline:o.scrollTop}))}catch{}},y=()=>{if(l.mobile=n.matches,n.matches&&!r.open)return;let m=`${S}:${n.matches?"drawer":"desktop"}`,i=u.get(m);if(i===void 0)C(m);else for(let d of s){let f=i.get(d);f!==void 0&&(d.scrollTop=f)}a()};for(let m of s)m.addEventListener("scroll",a,{passive:!0});e.addEventListener("click",a,{capture:!0}),r.addEventListener("cancel",a),r.querySelector("[data-navigation-close]")?.addEventListener("click",a,{capture:!0}),r.addEventListener("click",a,{capture:!0}),c.addEventListener("click",y),n.addEventListener("change",y),addEventListener("pagehide",a),a()};q();N();var h=document.querySelector("[data-cursor-face]");if(h){let e=matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"),t={x:0,y:0,frame:0},o=()=>{cancelAnimationFrame(t.frame),t.frame=0,h.classList.remove("wg_shell__cursorFace--visible")},r=()=>{e.matches||o()},c=()=>{t.frame===0&&(t.frame=requestAnimationFrame(()=>{t.frame=0,h.style.transform=`translate3d(
4
+ ${Math.max(0,Math.min(t.x+16,document.documentElement.clientWidth-h.width-16))}px,
5
+ ${Math.max(0,Math.min(t.y+16,document.documentElement.clientHeight-h.height-16))}px,
5
6
  0
6
- )`,f.classList.add("wg_shell__cursorFace--visible")}))},u=g=>{if(!(e.matches&&g.pointerType==="mouse")){n();return}t.x=g.clientX,t.y=g.clientY,s()},d=()=>{f.classList.contains("wg_shell__cursorFace--visible")&&s()},l=()=>{if(f.classList.contains("wg_shell__cursorFace--visible"))try{sessionStorage.setItem(T,JSON.stringify({x:t.x,y:t.y}))}catch{}},m=()=>{try{let g=sessionStorage.getItem(T);if(sessionStorage.removeItem(T),g!==null&&e.matches){let i=JSON.parse(g);typeof i=="object"&&i!==null&&"x"in i&&"y"in i&&typeof i.x=="number"&&typeof i.y=="number"&&Number.isFinite(i.x)&&Number.isFinite(i.y)&&(t.x=i.x,t.y=i.y,s())}}catch{}};m(),document.addEventListener("pointermove",u,{passive:!0}),document.addEventListener("pointerdown",u,{passive:!0}),document.documentElement.addEventListener("pointerleave",n),addEventListener("blur",n),addEventListener("resize",d),addEventListener("pagehide",l),addEventListener("pageshow",m),e.addEventListener("change",a)}var k=y(location.hash),E=null,S=0,v=null,C=()=>{clearTimeout(S),v?.abort(),v=null,E?.classList.remove("wg_prose__headingText--highlighted"),E=null},q=e=>{C(),E=e===null?null:e.querySelector(".wg_prose__headingText"),E!==null&&(E.classList.add("wg_prose__headingText--highlighted"),S=window.setTimeout(C,3e3),v=new AbortController,document.addEventListener("scrollend",P,{signal:v.signal}))},P=e=>{e.target===document&&(clearTimeout(S),v?.abort(),v=null,S=window.setTimeout(C,3e3))},R=e=>{if(!(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)&&e.currentTarget instanceof HTMLAnchorElement&&(e.currentTarget.target===""||e.currentTarget.target==="_self")&&!e.currentTarget.hasAttribute("download")&&e.currentTarget.origin===location.origin&&e.currentTarget.pathname===location.pathname&&e.currentTarget.search===location.search&&e.currentTarget.hash){let t=y(e.currentTarget.hash);e.currentTarget.hash===location.hash&&q(t)}},$=()=>{let e=y(location.hash);e?.scrollIntoView(),q(e)};for(let e of document.querySelectorAll("a[href]"))e.addEventListener("click",R);addEventListener("hashchange",$);var H=[...document.querySelectorAll("[data-toc-link]")].flatMap(e=>{let t=b(e.hash),n=e.parentElement;return!t||!n?[]:[{heading:t,link:e,item:n,list:n.parentElement,subs:[...n.querySelectorAll("[data-toc-sub-link]")].flatMap(a=>{let s=b(a.hash),u=a.parentElement;return s&&u?[{heading:s,link:a,item:u}]:[]})}]}),A=H.at(0);if(A){let e=()=>{let t=Number.parseFloat(getComputedStyle(A.heading).scrollMarginTop)+1,n=H.findLast(d=>d.heading.checkVisibility()&&d.heading.getBoundingClientRect().top<=t)??A,a=n.subs.findLast(d=>d.heading.checkVisibility()&&d.heading.getBoundingClientRect().top<=t),s=H.indexOf(n),u=a===void 0?-1:n.subs.indexOf(a);for(let[d,l]of H.entries()){let m=l===n&&a===void 0;l.link.classList.toggle("wg_shellToc__link--active",m),m?l.link.setAttribute("aria-current","location"):l.link.removeAttribute("aria-current"),l.item.classList.toggle("wg_shellToc__item--trail",l.list===n.list&&d<s),l.item.classList.toggle("wg_shellToc__item--current",l===n);for(let[g,i]of l.subs.entries())i.link.classList.toggle("wg_shellToc__subLink--active",i===a),i===a?i.link.setAttribute("aria-current","location"):i.link.removeAttribute("aria-current"),i.item.classList.toggle("wg_shellToc__subItem--trail",l===n&&g<u),i.item.classList.toggle("wg_shellToc__subItem--current",i===a)}};addEventListener("scroll",e,{passive:!0}),addEventListener("resize",e),addEventListener("toggle",e,!0),e()}var K=new AbortController;for(let e of["wheel","touchmove","keydown","mousedown"])addEventListener(e,()=>K.abort(),{once:!0,passive:!0});var F=async()=>{await document.fonts.ready,k&&!K.signal.aborted&&(k.scrollIntoView({behavior:"instant"}),q(k)),document.documentElement.style.removeProperty("scroll-behavior")};document.readyState==="complete"?F():addEventListener("load",F,{once:!0});
7
+ )`,h.classList.add("wg_shell__cursorFace--visible")}))},n=g=>{if(!(e.matches&&g.pointerType==="mouse")){o();return}t.x=g.clientX,t.y=g.clientY,c()},l=()=>{h.classList.contains("wg_shell__cursorFace--visible")&&c()},s=()=>{if(h.classList.contains("wg_shell__cursorFace--visible"))try{sessionStorage.setItem(E,JSON.stringify({x:t.x,y:t.y}))}catch{}},u=()=>{try{let g=sessionStorage.getItem(E);if(sessionStorage.removeItem(E),g!==null&&e.matches){let a=JSON.parse(g);typeof a=="object"&&a!==null&&"x"in a&&"y"in a&&typeof a.x=="number"&&typeof a.y=="number"&&Number.isFinite(a.x)&&Number.isFinite(a.y)&&(t.x=a.x,t.y=a.y,c())}}catch{}};u(),document.addEventListener("pointermove",n,{passive:!0}),document.addEventListener("pointerdown",n,{passive:!0}),document.documentElement.addEventListener("pointerleave",o),addEventListener("blur",o),addEventListener("resize",l),addEventListener("pagehide",s),addEventListener("pageshow",u),e.addEventListener("change",r)}var H=p(location.hash),L=null,w=0,_=null,k=()=>{clearTimeout(w),_?.abort(),_=null,L?.classList.remove("wg_prose__headingText--highlighted"),L=null},A=e=>{k(),L=e===null?null:e.querySelector(".wg_prose__headingText"),L!==null&&(L.classList.add("wg_prose__headingText--highlighted"),w=window.setTimeout(k,3e3),_=new AbortController,document.addEventListener("scrollend",O,{signal:_.signal}))},O=e=>{e.target===document&&(clearTimeout(w),_?.abort(),_=null,w=window.setTimeout(k,3e3))},K=e=>{if(!(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)&&e.currentTarget instanceof HTMLAnchorElement&&(e.currentTarget.target===""||e.currentTarget.target==="_self")&&!e.currentTarget.hasAttribute("download")&&e.currentTarget.origin===location.origin&&e.currentTarget.pathname===location.pathname&&e.currentTarget.search===location.search&&e.currentTarget.hash){let t=p(e.currentTarget.hash);e.currentTarget.hash===location.hash&&A(t)}},P=()=>{let e=p(location.hash);e?.scrollIntoView(),A(e)};for(let e of document.querySelectorAll("a[href]"))e.addEventListener("click",K);addEventListener("hashchange",P);var M=[...document.querySelectorAll("[data-toc-link]")].flatMap(e=>{let t=v(e.hash),o=e.parentElement;return!t||!o?[]:[{heading:t,link:e,item:o,list:o.parentElement,subs:[...o.querySelectorAll("[data-toc-sub-link]")].flatMap(r=>{let c=v(r.hash),n=r.parentElement;return c&&n?[{heading:c,link:r,item:n}]:[]})}]}),x=M.at(0);if(x){let e=()=>{let t=Number.parseFloat(getComputedStyle(x.heading).scrollMarginTop)+1,o=M.findLast(l=>l.heading.checkVisibility()&&l.heading.getBoundingClientRect().top<=t)??x,r=o.subs.findLast(l=>l.heading.checkVisibility()&&l.heading.getBoundingClientRect().top<=t),c=M.indexOf(o),n=r===void 0?-1:o.subs.indexOf(r);for(let[l,s]of M.entries()){let u=s===o&&r===void 0;s.link.classList.toggle("wg_shellToc__link--active",u),u?s.link.setAttribute("aria-current","location"):s.link.removeAttribute("aria-current"),s.item.classList.toggle("wg_shellToc__item--trail",s.list===o.list&&l<c),s.item.classList.toggle("wg_shellToc__item--current",s===o);for(let[g,a]of s.subs.entries())a.link.classList.toggle("wg_shellToc__subLink--active",a===r),a===r?a.link.setAttribute("aria-current","location"):a.link.removeAttribute("aria-current"),a.item.classList.toggle("wg_shellToc__subItem--trail",s===o&&g<n),a.item.classList.toggle("wg_shellToc__subItem--current",a===r)}};addEventListener("scroll",e,{passive:!0}),addEventListener("resize",e),addEventListener("toggle",e,!0),e()}var B=new AbortController;for(let e of["wheel","touchmove","keydown","mousedown"])addEventListener(e,()=>B.abort(),{once:!0,passive:!0});var I=async()=>{await document.fonts.ready,H&&!B.signal.aborted&&(H.scrollIntoView({behavior:"instant"}),A(H)),document.documentElement.style.removeProperty("scroll-behavior")};document.readyState==="complete"?I():addEventListener("load",I,{once:!0});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Documents written for humans to read: a docs kit with a sidebar, parts, sections and flowcharts, built on Hono and React",
5
5
  "keywords": [
6
6
  "docs",
@@ -107,7 +107,10 @@ export const bindMobileNavigation = () => {
107
107
 
108
108
  if (mobile.matches) {
109
109
  openButton.classList.add("wg_shell__menuToggle--visible");
110
- content.append(navigation);
110
+
111
+ if (navigation.parentElement !== content) {
112
+ content.append(navigation);
113
+ }
111
114
 
112
115
  if (focused instanceof HTMLElement && navigation.contains(focused)) {
113
116
  openButton.focus({preventScroll: true});
@@ -117,7 +120,11 @@ export const bindMobileNavigation = () => {
117
120
  }
118
121
 
119
122
  closeNavigation();
120
- sidebar.append(navigation);
123
+
124
+ // 첫 desktop 연결에서 같은 DOM을 재삽입하면 내부 스크롤이 0으로 초기화됨
125
+ if (navigation.parentElement !== sidebar) {
126
+ sidebar.append(navigation);
127
+ }
121
128
 
122
129
  if (focused instanceof HTMLElement && navigation.contains(focused)) {
123
130
  focused.focus({preventScroll: true});
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 실제 남은 내용이 있는 위·아래 끝만 흐림 표시 · 스크롤·글꼴·화면 크기 추적
3
+ * HTML 직후 직렬화해 실행하므로 import나 바깥 변수 없이 브라우저 DOM만 사용
4
+ * 관찰자는 문서 수명 동안 유지 · BFCache 복귀에서도 같은 탐색 DOM 추적
5
+ */
6
+ export const bindNavigationOverflow = () => {
7
+ const ports = [...document.querySelectorAll<HTMLElement>("[data-navigation-scroll]")];
8
+ const frame = {id: 0};
9
+ // 소수 픽셀 반올림으로 끝에서 흐림이 남는 것을 방지
10
+ const edgeTolerance = 1;
11
+
12
+ /**
13
+ * 콘텐츠가 잘리는 끝만 표시 · 네이티브 스크롤은 그대로 사용
14
+ */
15
+ const updateOverflow = () => {
16
+ frame.id = 0;
17
+
18
+ for (const port of ports) {
19
+ port.classList.add("wg_shell__scroll--ready");
20
+ port.classList.toggle("wg_shell__scroll--above", port.scrollTop > edgeTolerance);
21
+ port.classList.toggle("wg_shell__scroll--below", port.scrollHeight - port.clientHeight - port.scrollTop > edgeTolerance);
22
+ }
23
+ };
24
+
25
+ /**
26
+ * 연속 스크롤과 크기 변경은 프레임당 한 번만 반영
27
+ */
28
+ const scheduleOverflow = () => {
29
+ if (frame.id === 0) {
30
+ frame.id = requestAnimationFrame(updateOverflow);
31
+ }
32
+ };
33
+
34
+ const observer = new ResizeObserver(scheduleOverflow);
35
+
36
+ for (const port of ports) {
37
+ port.addEventListener("scroll", scheduleOverflow, {passive: true});
38
+ observer.observe(port);
39
+
40
+ if (port.firstElementChild) {
41
+ observer.observe(port.firstElementChild);
42
+ }
43
+ }
44
+
45
+ addEventListener("pageshow", scheduleOverflow);
46
+ updateOverflow();
47
+ };
@@ -1,123 +1,84 @@
1
1
  import {navigation_mobile_query, navigation_scroll_storage_key} from "@/component/widget/shell/_constant/navigation";
2
+ import {restoreNavigationScroll} from "@/component/widget/shell/_function/restore-navigation-scroll";
2
3
 
3
4
  /**
4
- * 문서 이동·새로고침에서 공유 탐색의 위치 유지 · 본문 스크롤과 독립
5
- * 저장소 실패는 기본 탐색 유지, 문서 목록 변경은 이전 위치를 사용하지 않음
5
+ * 문서 목록과 페이지 목차의 위치를 따로 저장 · TOC 길이가 목록 위치를 바꾸지 않음
6
+ * 첫 데스크톱 복원은 HTML에서 완료 · 늦은 module 실행에서 다시 이동하지 않음
6
7
  */
7
8
  export const bindNavigationScroll = () => {
8
9
  const navigation = document.querySelector<HTMLElement>("[data-navigation]");
9
- const sidebar = document.querySelector<HTMLElement>(".wg_shell__sidebar");
10
+ const documents = document.querySelector<HTMLElement>('[data-navigation-scroll="documents"]');
11
+ const outline = document.querySelector<HTMLElement>('[data-navigation-scroll="outline"]');
10
12
  const dialog = document.querySelector<HTMLDialogElement>("[data-navigation-drawer]");
11
13
  const openButton = document.querySelector<HTMLButtonElement>("[data-navigation-open]");
12
14
 
13
- if (!navigation || !sidebar || !dialog || !openButton) {
15
+ if (!navigation || !documents || !outline || !dialog || !openButton) {
14
16
  return;
15
17
  }
16
18
 
17
19
  const mobile = matchMedia(navigation_mobile_query);
18
20
  const layout = {mobile: mobile.matches};
19
- // 목차와 현재 상태는 페이지마다 바뀜 · 공통 문서 링크의 순서로 저장 위치의 유효성 판단
21
+ const ports = [documents, outline];
22
+ const positions = new Map<string, Map<HTMLElement, number>>();
20
23
  const signature = [...navigation.querySelectorAll<HTMLAnchorElement>(".wg_shellNav__root a[href]")].map((link) => `${link.pathname}:${link.textContent}`).join("\n");
21
- const positions = new Map<HTMLElement, number>();
22
- const storageKeys = new Map<HTMLElement, string>([
23
- [sidebar, `${navigation_scroll_storage_key}:desktop`],
24
- [dialog, `${navigation_scroll_storage_key}:drawer`],
25
- ]);
26
- const userInput = new AbortController();
27
24
 
28
- for (const [port, key] of storageKeys) {
29
- try {
30
- const value = sessionStorage.getItem(key);
25
+ /**
26
+ * 숨김·닫기보다 먼저 마지막 위치 보존 · 저장소 차단 시에도 드로어 재열기 유지
27
+ */
28
+ const saveScroll = () => {
29
+ if (layout.mobile !== mobile.matches || (mobile.matches && !dialog.open)) {
30
+ return;
31
+ }
31
32
 
32
- if (value !== null) {
33
- const stored: unknown = JSON.parse(value);
33
+ const key = `${navigation_scroll_storage_key}:${mobile.matches ? "drawer" : "desktop"}`;
34
+ positions.set(key, new Map(ports.map((port) => [port, port.scrollTop])));
34
35
 
35
- if (
36
- typeof stored === "object" &&
37
- stored !== null &&
38
- "navigation" in stored &&
39
- stored.navigation === signature &&
40
- "top" in stored &&
41
- typeof stored.top === "number" &&
42
- Number.isFinite(stored.top) &&
43
- stored.top >= 0
44
- ) {
45
- positions.set(port, stored.top);
46
- }
47
- }
36
+ try {
37
+ sessionStorage.setItem(key, JSON.stringify({navigation: signature, pathname: location.pathname, documents: documents.scrollTop, outline: outline.scrollTop}));
48
38
  } catch {
49
- // 차단되거나 손상된 저장소는 복원만 생략 · 스크롤과 기본 링크 이동 유지
39
+ // 현재 페이지 안의 위치는 메모리에 유지 · 저장 실패가 링크 이동을 막지 않음
50
40
  }
51
-
52
- for (const type of ["wheel", "touchstart", "pointerdown", "keydown"]) {
53
- port.addEventListener(type, () => userInput.abort(), {once: true, passive: true});
54
- }
55
- }
56
- const initialPositions = new Map(positions);
41
+ };
57
42
 
58
43
  /**
59
- * 네이티브 스크롤 범위로 복원 · 닫힌 드로어의 0 좌표는 복원하지 않음
44
+ * 열기·반응형 DOM 이동 완료 후 화면별 위치 복원
60
45
  */
61
46
  const restoreScroll = () => {
62
47
  layout.mobile = mobile.matches;
63
- const port = mobile.matches ? dialog : sidebar;
64
- const top = positions.get(port);
65
48
 
66
- if (top === undefined || (mobile.matches && !dialog.open)) {
49
+ if (mobile.matches && !dialog.open) {
67
50
  return;
68
51
  }
69
52
 
70
- port.scrollTop = top;
71
- };
53
+ const key = `${navigation_scroll_storage_key}:${mobile.matches ? "drawer" : "desktop"}`;
54
+ const saved = positions.get(key);
72
55
 
73
- /**
74
- * 드로어 닫기 이전 클릭·현재 스크롤·페이지 이탈에서 마지막 탐색 위치 저장
75
- */
76
- const saveScroll = (event: Event) => {
77
- const port = mobile.matches ? dialog : sidebar;
78
- const key = storageKeys.get(port);
56
+ if (saved === undefined) {
57
+ restoreNavigationScroll(key);
58
+ } else {
59
+ for (const port of ports) {
60
+ const top = saved.get(port);
79
61
 
80
- // 반응형 전환의 숨김·닫기 스크롤은 사용자 위치가 아님 · 활성 포트만 저장
81
- if (layout.mobile !== mobile.matches || (event.type === "scroll" && event.currentTarget !== port) || key === undefined || (mobile.matches && !dialog.open)) {
82
- return;
62
+ if (top !== undefined) {
63
+ port.scrollTop = top;
64
+ }
65
+ }
83
66
  }
84
67
 
85
- positions.set(port, port.scrollTop);
86
-
87
- try {
88
- sessionStorage.setItem(key, JSON.stringify({navigation: signature, top: port.scrollTop}));
89
- } catch {
90
- // 저장 실패도 같은 페이지의 드로어 재열기와 반응형 위치 복원은 유지
91
- }
68
+ saveScroll();
92
69
  };
93
70
 
94
- /**
95
- * 글꼴로 탐색 높이가 달라진 경우 한 번만 재보정 · 사용자 입력이 먼저면 유지
96
- */
97
- const settleScroll = async () => {
98
- await document.fonts.ready;
99
-
100
- if (!userInput.signal.aborted) {
101
- const port = mobile.matches ? dialog : sidebar;
102
- const top = initialPositions.get(port);
103
-
104
- if (top !== undefined && (!mobile.matches || dialog.open)) {
105
- port.scrollTop = top;
106
- }
107
- }
108
- };
71
+ for (const port of ports) {
72
+ port.addEventListener("scroll", saveScroll, {passive: true});
73
+ }
109
74
 
110
- sidebar.addEventListener("scroll", saveScroll, {passive: true});
111
- dialog.addEventListener("scroll", saveScroll, {passive: true});
75
+ // 캡처 단계에서 모바일의 기본 닫기보다 먼저 저장
112
76
  navigation.addEventListener("click", saveScroll, {capture: true});
77
+ dialog.addEventListener("cancel", saveScroll);
78
+ dialog.querySelector("[data-navigation-close]")?.addEventListener("click", saveScroll, {capture: true});
79
+ dialog.addEventListener("click", saveScroll, {capture: true});
113
80
  openButton.addEventListener("click", restoreScroll);
114
81
  mobile.addEventListener("change", restoreScroll);
115
82
  addEventListener("pagehide", saveScroll);
116
- restoreScroll();
117
-
118
- if (document.readyState === "complete") {
119
- settleScroll();
120
- } else {
121
- addEventListener("load", settleScroll, {once: true});
122
- }
83
+ saveScroll();
123
84
  };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 탐색 HTML 직후와 드로어 열기에서 저장 위치 복원 · 첫 paint 전 실행
3
+ * HTML에 함수 본문을 직렬화하므로 import나 바깥 변수 없이 브라우저 DOM만 사용
4
+ * @param storageKey 탭·화면 모드별 저장 키
5
+ */
6
+ export const restoreNavigationScroll = (storageKey: string) => {
7
+ const navigation = document.querySelector<HTMLElement>("[data-navigation]");
8
+ const documents = document.querySelector<HTMLElement>('[data-navigation-scroll="documents"]');
9
+ const outline = document.querySelector<HTMLElement>('[data-navigation-scroll="outline"]');
10
+
11
+ if (!navigation || !documents || !outline) {
12
+ return;
13
+ }
14
+
15
+ try {
16
+ const value = sessionStorage.getItem(storageKey);
17
+
18
+ if (value === null) {
19
+ return;
20
+ }
21
+
22
+ const stored: unknown = JSON.parse(value);
23
+ const signature = [...navigation.querySelectorAll<HTMLAnchorElement>(".wg_shellNav__root a[href]")].map((link) => `${link.pathname}:${link.textContent}`).join("\n");
24
+
25
+ if (typeof stored !== "object" || stored === null || !("navigation" in stored) || stored.navigation !== signature) {
26
+ return;
27
+ }
28
+
29
+ if ("documents" in stored && typeof stored.documents === "number" && Number.isFinite(stored.documents) && stored.documents >= 0) {
30
+ documents.scrollTop = stored.documents;
31
+ }
32
+
33
+ // 문서 목록은 공통 위치 · 목차는 같은 페이지에 다시 들어왔을 때만 복원
34
+ if (
35
+ "pathname" in stored &&
36
+ stored.pathname === location.pathname &&
37
+ "outline" in stored &&
38
+ typeof stored.outline === "number" &&
39
+ Number.isFinite(stored.outline) &&
40
+ stored.outline >= 0
41
+ ) {
42
+ outline.scrollTop = stored.outline;
43
+ }
44
+ } catch {
45
+ // 차단·손상된 저장소는 복원만 생략 · 기본 링크와 스크롤 유지
46
+ }
47
+ };
@@ -2,9 +2,6 @@
2
2
  * 문서 탐색 아래 목차 · 회색 제목·묶음과 본문 글색의 이동 대상 구분
3
3
  */
4
4
  .wg_shellToc__root {
5
- margin-top: var(--app-space-group);
6
- padding-top: var(--app-space-group);
7
- border-top: 1px solid var(--app-color-border-soft);
8
5
  font-size: var(--app-font-size-dense);
9
6
  line-height: var(--app-line-height-tight);
10
7
  }
@@ -150,6 +147,11 @@
150
147
  &::after {
151
148
  border-left-color: var(--app-color-text-strong);
152
149
  }
150
+
151
+ /* 현재 소제목의 상위 절은 글 강조 없이 경로의 가로 가지 연결 */
152
+ & > .wg_shellToc__link::before {
153
+ border-top-color: var(--app-color-text-strong);
154
+ }
153
155
  }
154
156
 
155
157
  .wg_shellToc__sub {
@@ -27,7 +27,7 @@
27
27
  .wg_shell__brandRow {
28
28
  display: flex;
29
29
 
30
- /* 공유 사이드바의 스크롤 기준 · 목록과 목차 위에서 이름 고정 */
30
+ /* 두 스크롤 영역 바깥 · 목록과 목차 위에서 이름 고정 */
31
31
  position: sticky;
32
32
  top: 0;
33
33
  z-index: var(--app-z-index-sticky);
@@ -103,50 +103,69 @@
103
103
  display: flex;
104
104
  }
105
105
 
106
+ /* sticky 틀 안에서 문서 목록과 목차의 스크롤 높이를 독립 유지 */
106
107
  .wg_shell__sidebar {
107
108
  grid-area: nav;
108
-
109
- /* 뷰포트 sticky · 문서 탐색과 목차는 이 컨테이너에서 함께 스크롤 */
110
109
  position: sticky;
111
110
  top: 0;
112
111
  align-self: start;
113
112
  min-width: 0;
114
- min-height: 100vh;
115
- max-height: 100vh;
113
+ height: 100dvh;
116
114
  margin-inline-end: var(--app-space-sidebar-gap);
117
- overflow-y: auto;
118
- overscroll-behavior-y: contain;
115
+ }
119
116
 
120
- /* 얇은 네이티브 손잡이 · 트랙 없이 기존 본문 간격 안쪽에 표시 */
121
- scrollbar-gutter: stable;
122
- scrollbar-width: auto;
123
- scrollbar-color: auto;
124
- scroll-padding-block: var(--app-size-brand) var(--app-space-block);
117
+ .wg_shell__navigation {
118
+ display: flex;
119
+ flex-direction: column;
120
+ height: calc(100dvh - var(--app-size-brand));
121
+ min-width: 0;
122
+ min-height: 0;
123
+ }
125
124
 
126
- &::-webkit-scrollbar {
127
- width: 3px;
128
- }
125
+ .wg_shell__documents {
126
+ /* TOC 길이가 바뀌어도 공통 목록의 스크롤 범위는 동일 */
127
+ flex: none;
128
+ max-height: 60%;
129
+ }
129
130
 
130
- &::-webkit-scrollbar-track {
131
- background: transparent;
131
+ .wg_shell__outline {
132
+ display: flex;
133
+ flex-direction: column;
134
+ min-height: 0;
135
+ margin-top: var(--app-space-group);
136
+ padding-top: var(--app-space-group);
137
+ border-top: 1px solid var(--app-color-border-soft);
138
+
139
+ &:has(.wg_shell__headings:empty) {
140
+ display: none;
132
141
  }
142
+ }
133
143
 
134
- &::-webkit-scrollbar-thumb {
135
- border-radius: 3px;
136
- background: transparent;
137
- }
144
+ .wg_shell__headings {
145
+ min-height: 0;
146
+ }
138
147
 
139
- &:hover::-webkit-scrollbar-thumb {
140
- background: var(--app-color-border-soft);
141
- }
148
+ .wg_shell__scroll {
149
+ overflow-y: auto;
150
+ overscroll-behavior-y: contain;
151
+ scroll-padding-block: var(--app-size-navigation-fade);
152
+ }
142
153
 
143
- &:focus-within::-webkit-scrollbar-thumb {
144
- background: var(--app-color-border-soft);
145
- }
154
+ .wg_shell__scroll--ready {
155
+ /* 흐림으로 넘침을 알릴 때만 손잡이 숨김 · JS 없으면 네이티브 손잡이 유지 */
156
+ scrollbar-width: none;
146
157
  }
147
158
 
148
- .wg_shell__navigation {
149
- min-width: 0;
159
+ .wg_shell__scroll--above {
160
+ mask-image: linear-gradient(to bottom, transparent, #000 var(--app-size-navigation-fade));
161
+ }
162
+
163
+ .wg_shell__scroll--below {
164
+ mask-image: linear-gradient(to bottom, #000 calc(100% - var(--app-size-navigation-fade)), transparent);
165
+ }
166
+
167
+ .wg_shell__scroll--above.wg_shell__scroll--below {
168
+ mask-image: linear-gradient(to bottom, transparent, #000 var(--app-size-navigation-fade), #000 calc(100% - var(--app-size-navigation-fade)), transparent);
150
169
  }
151
170
 
152
171
  .wg_shell__main {
@@ -174,32 +193,7 @@
174
193
  border-right: 1px solid var(--app-color-border-layout);
175
194
  background: var(--app-color-ground);
176
195
  color: var(--app-color-text);
177
- scrollbar-gutter: stable;
178
- scrollbar-width: auto;
179
- scrollbar-color: auto;
180
- overscroll-behavior: contain;
181
- scroll-padding-block: var(--app-size-mobile-header) var(--app-space-group);
182
-
183
- &::-webkit-scrollbar {
184
- width: 3px;
185
- }
186
-
187
- &::-webkit-scrollbar-track {
188
- background: transparent;
189
- }
190
-
191
- &::-webkit-scrollbar-thumb {
192
- border-radius: 3px;
193
- background: transparent;
194
- }
195
-
196
- &:hover::-webkit-scrollbar-thumb {
197
- background: var(--app-color-border-soft);
198
- }
199
-
200
- &:focus-within::-webkit-scrollbar-thumb {
201
- background: var(--app-color-border-soft);
202
- }
196
+ overflow: hidden;
203
197
 
204
198
  &::backdrop {
205
199
  background: var(--app-color-backdrop);
@@ -207,10 +201,7 @@
207
201
  }
208
202
 
209
203
  .wg_shell__drawerHeader {
210
- /* 스크롤하는 drawer 안에서 닫기 버튼 유지 */
211
- position: sticky;
212
- top: 0;
213
- z-index: var(--app-z-index-sticky);
204
+ /* 두 스크롤 영역 바깥에서 닫기 버튼 유지 */
214
205
  display: flex;
215
206
  align-items: center;
216
207
  justify-content: space-between;
@@ -252,6 +243,11 @@
252
243
 
253
244
  .wg_shell__drawerContent {
254
245
  min-width: 0;
246
+ height: calc(100% - var(--app-size-mobile-header) - var(--app-space-block));
247
+
248
+ & > .wg_shell__navigation {
249
+ height: 100%;
250
+ }
255
251
  }
256
252
 
257
253
  .wg_shell__cursorFace {
@@ -270,54 +266,12 @@
270
266
  visibility: visible;
271
267
  }
272
268
 
273
- /* Firefox는 표준 thin 사용 · 손잡이 폭은 브라우저가 결정 */
274
- @supports not selector(::-webkit-scrollbar) {
275
- .wg_shell__sidebar {
276
- scrollbar-width: thin;
277
- scrollbar-color: transparent transparent;
278
-
279
- &:hover {
280
- scrollbar-color: var(--app-color-border-soft) transparent;
281
- }
282
-
283
- &:focus-within {
284
- scrollbar-color: var(--app-color-border-soft) transparent;
285
- }
286
- }
287
-
288
- .wg_shell__drawer {
289
- scrollbar-width: thin;
290
- scrollbar-color: var(--app-color-border-soft) transparent;
291
- }
292
- }
293
-
294
- /* 색상 강제 환경은 시스템 손잡이 유지 · 대비 설정을 덮어쓰지 않음 */
269
+ /* 강제 색상 환경은 흐림 대신 시스템 손잡이로 넘침 표시 */
295
270
  @media (forced-colors: active) {
296
- .wg_shell__sidebar {
297
- /* non-auto 표준 폭은 WebKit 전용 스타일을 비활성화해 네이티브 색상 유지 */
271
+ .wg_shell__scroll {
272
+ mask-image: none;
298
273
  scrollbar-width: thin;
299
274
  scrollbar-color: auto;
300
-
301
- &:hover {
302
- scrollbar-color: auto;
303
- }
304
-
305
- &:focus-within {
306
- scrollbar-color: auto;
307
- }
308
- }
309
-
310
- .wg_shell__drawer {
311
- scrollbar-width: thin;
312
- scrollbar-color: auto;
313
-
314
- &:hover {
315
- scrollbar-color: auto;
316
- }
317
-
318
- &:focus-within {
319
- scrollbar-color: auto;
320
- }
321
275
  }
322
276
  }
323
277
 
@@ -332,14 +286,20 @@
332
286
 
333
287
  .wg_shell__sidebar {
334
288
  position: static;
335
- min-height: 0;
336
- max-height: none;
337
- overflow-y: visible;
289
+ height: auto;
338
290
  margin-block-end: var(--app-space-block);
339
291
  margin-inline-end: 0;
340
292
  border-bottom: 1px solid var(--app-color-border-layout);
341
293
  }
342
294
 
295
+ .wg_shell__sidebar > .wg_shell__navigation {
296
+ height: auto;
297
+
298
+ & > .wg_shell__documents {
299
+ max-height: none;
300
+ }
301
+ }
302
+
343
303
  .wg_shell__sidebar--navigationReady {
344
304
  /* 본문을 스크롤해도 메뉴 진입 유지 · 스크립트 없는 긴 목록에는 적용하지 않음 */
345
305
  position: sticky;
@@ -1,5 +1,8 @@
1
1
  import clsx from "clsx";
2
2
  import type {ReactNode} from "react";
3
+ import {navigation_mobile_query, navigation_scroll_storage_key} from "@/component/widget/shell/_constant/navigation";
4
+ import {bindNavigationOverflow} from "@/component/widget/shell/_function/bind-navigation-overflow";
5
+ import {restoreNavigationScroll} from "@/component/widget/shell/_function/restore-navigation-scroll";
3
6
  import {toTocGroups} from "@/component/widget/shell/_function/to-toc-groups";
4
7
  import type {DocOutline} from "@/component/widget/shell/_type/doc-outline";
5
8
  import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
@@ -18,6 +21,12 @@ import "./wg-shell.css";
18
21
  */
19
22
  const reloadScript = `new EventSource(${JSON.stringify(asset_reload_path)}).onmessage=function(){location.reload()}`;
20
23
 
24
+ /**
25
+ * 탐색 DOM 직후 동기 실행 · 큰 본문과 module 다운로드를 기다리지 않고 첫 위치·흐림 적용
26
+ * 손잡이 숨김으로 달라지는 목록 폭을 먼저 확정한 뒤 좌표 복원
27
+ */
28
+ const navigationScript = `(${bindNavigationOverflow.toString()})();if(!matchMedia(${JSON.stringify(navigation_mobile_query)}).matches){(${restoreNavigationScroll.toString()})(${JSON.stringify(`${navigation_scroll_storage_key}:desktop`)})}`;
29
+
21
30
  /**
22
31
  * 공통 HTML 틀의 입력 · 문서 탐색·현재 목차·본문
23
32
  */
@@ -112,10 +121,23 @@ export const WgShell = (props: WgShellProps) => {
112
121
  * 같은 탐색 DOM을 모바일 대화상자로 이동 · 스크립트 없이도 기본 목록 제공
113
122
  */}
114
123
  <div className={clsx("wg_shell__navigation")} data-navigation="">
115
- <WgShellNav site={props.site} docs={props.docs} current={props.current} />
116
- {tocGroups.length > 0 && <WgShellToc groups={tocGroups} />}
124
+ {/**
125
+ * 모든 페이지에서 같은 높이의 문서 탐색 · 목차 길이와 스크롤 분리
126
+ */}
127
+ <div className={clsx("wg_shell__documents", "wg_shell__scroll")} data-navigation-scroll="documents">
128
+ <WgShellNav site={props.site} docs={props.docs} current={props.current} />
129
+ </div>
130
+ {/**
131
+ * 문서 목록 아래 현재 페이지 목차 · 남은 뷰포트 안에서 독립 스크롤
132
+ */}
133
+ <div className={clsx("wg_shell__outline")}>
134
+ <div className={clsx("wg_shell__headings", "wg_shell__scroll")} data-navigation-scroll="outline">
135
+ {tocGroups.length > 0 && <WgShellToc groups={tocGroups} />}
136
+ </div>
137
+ </div>
117
138
  </div>
118
139
  </div>
140
+ <script dangerouslySetInnerHTML={{__html: navigationScript}} />
119
141
  <main className={clsx("wg_shell__main")}>{props.children}</main>
120
142
  {/**
121
143
  * 브라우저의 모달 포커스·Escape 처리 · 목록은 한 번만 렌더링
@@ -109,6 +109,7 @@
109
109
  --app-size-mobile-header: 56px;
110
110
  --app-size-menu-button: 44px;
111
111
  --app-size-drawer: 320px;
112
+ --app-size-navigation-fade: 28px;
112
113
 
113
114
  /* 화면 위쪽 제목 여백 · hash 도착점과 목차 읽는 선의 공통 기준 */
114
115
  --app-space-anchor: 80px;