@tienne/gestalt 0.115.0 → 0.116.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 (63) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/plugin/role-agents/lens-reader/AGENT.md +22 -1
  3. package/dist/plugin/skills/_shared/proactive-routing.md +1 -1
  4. package/dist/plugin/skills/lens/SKILL.md +114 -8
  5. package/dist/plugin/skills/video-digest/SKILL.md +4 -2
  6. package/dist/plugin/skills/video-digest/templates/comments.md +1 -0
  7. package/dist/src/cli/commands/digest.d.ts +1 -0
  8. package/dist/src/cli/commands/digest.d.ts.map +1 -1
  9. package/dist/src/cli/commands/digest.js +15 -4
  10. package/dist/src/cli/commands/digest.js.map +1 -1
  11. package/dist/src/cli/index.d.ts.map +1 -1
  12. package/dist/src/cli/index.js +1 -0
  13. package/dist/src/cli/index.js.map +1 -1
  14. package/dist/src/digest/render-blocks.d.ts +5 -0
  15. package/dist/src/digest/render-blocks.d.ts.map +1 -1
  16. package/dist/src/digest/render-blocks.js +2 -2
  17. package/dist/src/digest/render-blocks.js.map +1 -1
  18. package/dist/src/digest/render-html.d.ts +2 -2
  19. package/dist/src/digest/render-html.d.ts.map +1 -1
  20. package/dist/src/digest/render-html.js +20 -3
  21. package/dist/src/digest/render-html.js.map +1 -1
  22. package/dist/src/digest/render-md.d.ts.map +1 -1
  23. package/dist/src/digest/render-md.js +9 -2
  24. package/dist/src/digest/render-md.js.map +1 -1
  25. package/dist/src/digest/server.d.ts +46 -1
  26. package/dist/src/digest/server.d.ts.map +1 -1
  27. package/dist/src/digest/server.js +213 -10
  28. package/dist/src/digest/server.js.map +1 -1
  29. package/dist/src/digest/session-request.d.ts +78 -0
  30. package/dist/src/digest/session-request.d.ts.map +1 -0
  31. package/dist/src/digest/session-request.js +105 -0
  32. package/dist/src/digest/session-request.js.map +1 -0
  33. package/dist/src/digest/svg.d.ts +5 -0
  34. package/dist/src/digest/svg.d.ts.map +1 -1
  35. package/dist/src/digest/svg.js +19 -9
  36. package/dist/src/digest/svg.js.map +1 -1
  37. package/dist/src/digest/templates.d.ts +9 -0
  38. package/dist/src/digest/templates.d.ts.map +1 -1
  39. package/dist/src/digest/templates.js +12 -0
  40. package/dist/src/digest/templates.js.map +1 -1
  41. package/dist/src/digest/viewer-js.d.ts.map +1 -1
  42. package/dist/src/digest/viewer-js.js +534 -46
  43. package/dist/src/digest/viewer-js.js.map +1 -1
  44. package/dist/src/digest/viewer-logic.d.ts +26 -5
  45. package/dist/src/digest/viewer-logic.d.ts.map +1 -1
  46. package/dist/src/digest/viewer-logic.js +44 -7
  47. package/dist/src/digest/viewer-logic.js.map +1 -1
  48. package/dist/src/mcp/tools/peer.d.ts.map +1 -1
  49. package/dist/src/mcp/tools/peer.js +2 -17
  50. package/dist/src/mcp/tools/peer.js.map +1 -1
  51. package/dist/src/peer/locate.d.ts +15 -0
  52. package/dist/src/peer/locate.d.ts.map +1 -1
  53. package/dist/src/peer/locate.js +43 -0
  54. package/dist/src/peer/locate.js.map +1 -1
  55. package/package.json +1 -1
  56. package/plugin/.codex-plugin/plugin.json +1 -1
  57. package/plugin/.mcp.json +1 -1
  58. package/plugin/mcp.json +1 -1
  59. package/plugin/role-agents/lens-reader/AGENT.md +22 -1
  60. package/plugin/skills/_shared/proactive-routing.md +1 -1
  61. package/plugin/skills/lens/SKILL.md +114 -8
  62. package/plugin/skills/video-digest/SKILL.md +4 -2
  63. package/plugin/skills/video-digest/templates/comments.md +1 -0
@@ -15,6 +15,10 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
15
15
  var starts = data.starts;
16
16
  var root = document.documentElement;
17
17
  var isFile = location.protocol === 'file:';
18
+ // serve가 내줄 때만 페이지에 끼우는 토큰. 있으면 없는 리포트를 serve를 띄운 세션에 바로 요청하고, 없거나 요청이 실패하면 명령 복사로 돌아간다
19
+ var tokenMeta = document.querySelector('meta[name="gestalt-serve-token"]');
20
+ var serveToken = !isFile && tokenMeta ? tokenMeta.getAttribute('content') : null;
21
+ var SENT_MSG = '세션에 요청을 넣었어요. 다 만들어지면 새로고침하지 않아도 이 화면에 바로 들어와요.';
18
22
  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
19
23
  var holder = document.getElementById('player');
20
24
  var slot = document.getElementById('player-slot');
@@ -346,10 +350,13 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
346
350
  pop.tabIndex = -1;
347
351
  pop.setAttribute('role', 'dialog');
348
352
  document.body.appendChild(pop);
349
- document.querySelectorAll('.cite, .kw').forEach(function(b){
350
- b.setAttribute('aria-expanded', 'false');
351
- b.setAttribute('aria-controls', 'digest-pop');
352
- });
353
+ function mark(root){
354
+ root.querySelectorAll('.cite, .kw').forEach(function(b){
355
+ b.setAttribute('aria-expanded', 'false');
356
+ b.setAttribute('aria-controls', 'digest-pop');
357
+ });
358
+ }
359
+ mark(document);
353
360
  var popFor = null;
354
361
  function closePop(){
355
362
  if (popFor) popFor.setAttribute('aria-expanded', 'false');
@@ -441,6 +448,7 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
441
448
  closePop();
442
449
  });
443
450
  return {
451
+ mark: mark,
444
452
  isOpen: function(){ return !pop.hidden; },
445
453
  closeAndReturn: function(){
446
454
  var back = popFor;
@@ -482,14 +490,14 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
482
490
  }, 250);
483
491
  }
484
492
 
493
+ // 리포트를 바꿔 끼우면 목차와 미니맵 링크도 바뀌어서 표시할 때마다 다시 읽는다
485
494
  function setupSectionMarks() {
486
- var marks = document.querySelectorAll('[data-nav]');
487
- if (!('IntersectionObserver' in window) || !marks.length) return;
495
+ if (!('IntersectionObserver' in window)) return { observe: function(){} };
488
496
  var io = new IntersectionObserver(function(entries){
489
497
  entries.forEach(function(en){
490
498
  if (!en.isIntersecting) return;
491
499
  var id = en.target.id;
492
- marks.forEach(function(m){
500
+ document.querySelectorAll('[data-nav]').forEach(function(m){
493
501
  var on = m.getAttribute('data-nav') === id;
494
502
  m.classList.toggle('is-current', on);
495
503
  if (on) m.setAttribute('aria-current', 'location');
@@ -497,15 +505,16 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
497
505
  });
498
506
  });
499
507
  }, { rootMargin: '-15% 0px -70% 0px' });
500
- document.querySelectorAll('section.sec').forEach(function(s){ io.observe(s); });
508
+ function observe(root){ root.querySelectorAll('section.sec').forEach(function(s){ io.observe(s); }); }
509
+ observe(document);
510
+ return { observe: observe };
501
511
  }
502
512
 
503
513
  // 읽는 소섹션이 바뀌면 영상을 그 구간 첫 문장으로 옮긴다. 재생 중이면 요약을 스크롤해도 영상을 옮기지 않고 빠르게 훑을 때는 멈춘 자리만 따른다.
504
514
  // 방금 시각을 눌렀으면 유예가 끝난 뒤 영상이 멈춰 있을 때만 따라간다. 기다리는 사이 그 소섹션이 화면에서 빠지면 따라가지 않는다
505
515
  // 앞선 이동의 버퍼링이 덜 끝났으면 재생 중으로 읽히므로 300ms 뒤 두 번까지 다시 본다
506
516
  function setupSubsectionFollow(player) {
507
- var subs = document.querySelectorAll('.sub[data-from]');
508
- if (!player.live || !('IntersectionObserver' in window) || !subs.length) return;
517
+ if (!player.live || !('IntersectionObserver' in window)) return { observe: function(){} };
509
518
  var followTimer = null, heldTimer = null, pendingSub = null;
510
519
  var currentIdx = function(){
511
520
  var t = player.currentTime();
@@ -547,7 +556,9 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
547
556
  }, 400);
548
557
  });
549
558
  }, { rootMargin: '-15% 0px -70% 0px' });
550
- subs.forEach(function(s){ subIo.observe(s); });
559
+ function observe(root){ root.querySelectorAll('.sub[data-from]').forEach(function(s){ subIo.observe(s); }); }
560
+ observe(document);
561
+ return { observe: observe };
551
562
  }
552
563
 
553
564
  // 플레이어 자리가 화면 밖으로 나가면 미니 플레이어로 띄운다. iframe을 DOM에서 옮기면 영상이 처음부터 다시 로드돼서 자리는 두고 위치만 고정한다
@@ -596,6 +607,406 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
596
607
  (ok ? done : fail)();
597
608
  }
598
609
  }
610
+ // 넣은 요청은 새로고침해도 이어서 기다리게 sessionStorage에 둔다. 세션이 다시 렌더하면 serve가 주는 ETag가 바뀌어서
611
+ // 그때 새 페이지를 받아 새로 생긴 탭과 리포트만 지금 화면에 끼운다. 처음 연 페이지의 ETag를 기준으로 삼으려고 serve로 열었으면 열자마자 한 번 받아 둔다
612
+ var PENDING_KEY = 'gestalt-digest-pending:' + data.slug;
613
+ var WATCH_MS = 3000;
614
+ var WATCH_LIMIT_MS = 30 * 60 * 1000;
615
+ // 다른 탭을 보는 동안은 드물게만 묻는다. 돌아오면 바로 한 번 묻는다
616
+ var WATCH_HIDDEN_MS = 30000;
617
+ function readPending(){
618
+ var raw = null;
619
+ try { raw = sessionStorage.getItem(PENDING_KEY); } catch (e) {}
620
+ return readPendingList(raw, Date.now());
621
+ }
622
+ function writePending(list){
623
+ try {
624
+ if (list.length) sessionStorage.setItem(PENDING_KEY, JSON.stringify(list));
625
+ else sessionStorage.removeItem(PENDING_KEY);
626
+ } catch (e) {}
627
+ }
628
+ function addPending(entry){
629
+ var key = JSON.stringify(entry.b);
630
+ writePending(readPending().filter(function(p){ return JSON.stringify(p.b) !== key; }).concat([entry]));
631
+ }
632
+ function pageTag(){
633
+ return fetch(location.pathname, { method: 'HEAD', cache: 'no-store', credentials: 'same-origin' })
634
+ .then(function(res){ return res.ok ? res.headers.get('ETag') : null; }, function(){ return null; });
635
+ }
636
+ // 렌더가 파일을 쓰는 도중에 받으면 데이터가 잘려 있다. 그때는 null을 돌려주고 다음 차례에 다시 받는다
637
+ function fetchPage(){
638
+ return fetch(location.pathname, { cache: 'no-store', credentials: 'same-origin' }).then(function(res){
639
+ if (!res.ok) return null;
640
+ var tag = res.headers.get('ETag');
641
+ return res.text().then(function(html){
642
+ var doc = new DOMParser().parseFromString(html, 'text/html');
643
+ var el = doc.getElementById('digest-data');
644
+ var next = null;
645
+ try { next = el ? JSON.parse(el.textContent) : null; } catch (e) {}
646
+ return next ? { doc: doc, data: next, tag: tag } : null;
647
+ });
648
+ }).then(null, function(){ return null; });
649
+ }
650
+ var baseTag = serveToken && window.fetch && window.DOMParser ? pageTag() : null;
651
+ var watching = false;
652
+ var watchTimer = null;
653
+ var watchBusy = false;
654
+ var watchNow = null;
655
+ function watchPage(){
656
+ if (watching || !baseTag) return;
657
+ watching = true;
658
+ baseTag.then(function(base){
659
+ function schedule(ms){ watchTimer = setTimeout(tick, ms); }
660
+ function tick(){
661
+ watchTimer = null;
662
+ var list = readPending();
663
+ if (!list.length) { watching = false; return; }
664
+ if (list.every(function(p){ return Date.now() - p.at > WATCH_LIMIT_MS; })) {
665
+ watching = false;
666
+ progress.timeout();
667
+ return;
668
+ }
669
+ if (document.hidden) { schedule(WATCH_HIDDEN_MS); return; }
670
+ watchBusy = true;
671
+ pageTag().then(function(tag){
672
+ if (!tag || !base || tag === base) {
673
+ watchBusy = false;
674
+ if (!base) base = tag;
675
+ schedule(WATCH_MS);
676
+ return;
677
+ }
678
+ fetchPage().then(function(page){
679
+ watchBusy = false;
680
+ if (!page) { schedule(WATCH_MS); return; }
681
+ base = page.tag || tag;
682
+ // 끼우다 멈추면 화면이 반쯤 바뀐 채라 다시 읽는다. 남은 요청은 resumePending이 이어 받는다
683
+ try {
684
+ if (!livePatch(page)) { location.reload(); return; }
685
+ progress.settle();
686
+ } catch (e) {
687
+ location.reload();
688
+ return;
689
+ }
690
+ schedule(WATCH_MS);
691
+ });
692
+ });
693
+ }
694
+ watchNow = function(){
695
+ if (!watching || watchBusy || !watchTimer) return;
696
+ clearTimeout(watchTimer);
697
+ tick();
698
+ };
699
+ schedule(WATCH_MS);
700
+ });
701
+ }
702
+ document.addEventListener('visibilitychange', function(){ if (!document.hidden && watchNow) watchNow(); });
703
+ // 새로 그려진 페이지에서 만들어진 요청은 그 리포트나 탭을 열고 아직 안 된 요청은 만드는 중 표시를 다시 띄운다.
704
+ // 30분이 지난 요청은 세션이 놓친 것으로 보고 버린다
705
+ function resumePending(reports, tabs){
706
+ var list = readPending().filter(function(p){ return Date.now() - p.at < WATCH_LIMIT_MS; });
707
+ var left = [];
708
+ var ready = null;
709
+ list.forEach(function(p){ if (requestMade(p.b, data.reports, data.extensions)) ready = p.b; else left.push(p); });
710
+ writePending(left);
711
+ left.forEach(function(p){
712
+ requestState[JSON.stringify(p.b)] = 'sent';
713
+ progress.add(p, true);
714
+ });
715
+ if (ready && ready.action === 'template') {
716
+ (data.extensions || []).forEach(function(x){
717
+ if (requestMade(ready, [], [x])) tabs.open(x.panel);
718
+ });
719
+ } else if (ready) {
720
+ tabs.open('panel-summary');
721
+ reports.open(ready.difficulty, ready.lang);
722
+ }
723
+ if (left.length) watchPage();
724
+ }
725
+ // 같은 요청은 한 번만 보낸다. 이미 넣은 요청을 다시 누르면 넣었다는 안내만 다시 보여 주고, 응답을 기다리는 중이면 그 응답에 함께 건다
726
+ var requestState = {};
727
+ function requestToSession(body, title, done, fail) {
728
+ if (!serveToken || !window.fetch) { fail(); return; }
729
+ var key = JSON.stringify(body);
730
+ var state = requestState[key];
731
+ if (state === 'sent') { done(); return; }
732
+ if (state) { state.push([done, fail]); return; }
733
+ var waiting = [[done, fail]];
734
+ requestState[key] = waiting;
735
+ function settle(ok){
736
+ if (ok) {
737
+ requestState[key] = 'sent';
738
+ var entry = { b: body, t: title, at: Date.now() };
739
+ addPending(entry);
740
+ progress.add(entry);
741
+ watchPage();
742
+ } else delete requestState[key];
743
+ waiting.forEach(function(w){ w[ok ? 0 : 1](); });
744
+ }
745
+ fetch('/__request', {
746
+ method: 'POST',
747
+ headers: { 'Content-Type': 'application/json', 'X-Gestalt-Token': serveToken },
748
+ body: key,
749
+ credentials: 'same-origin',
750
+ cache: 'no-store'
751
+ }).then(function(res){
752
+ return res.json().then(function(j){ return res.ok && j && j.ok === true; }, function(){ return false; });
753
+ }).then(settle, function(){ settle(false); });
754
+ }
755
+
756
+ // 만드는 중인 요청을 화면에 띄운다. 템플릿은 탭 줄에 빈 탭을 세우고 리포트는 쉽게 보기 옆에 칩을 단다. 흐른 시간은 1초마다 고친다
757
+ function setupProgress(tabs, reports) {
758
+ var items = [];
759
+ var timer = null;
760
+ var status = document.createElement('p');
761
+ status.className = 'sr-only';
762
+ status.setAttribute('role', 'status');
763
+ status.setAttribute('aria-live', 'polite');
764
+ document.body.appendChild(status);
765
+ function tick(){
766
+ if (document.hidden && timer) return;
767
+ var els = document.querySelectorAll('[data-pending-at]');
768
+ els.forEach(function(el){ el.textContent = elapsedLabel(Date.now() - Number(el.getAttribute('data-pending-at'))); });
769
+ if (!els.length && timer) { clearInterval(timer); timer = null; }
770
+ }
771
+ function startTimer(){
772
+ tick();
773
+ if (!timer) timer = setInterval(tick, 1000);
774
+ }
775
+ function dot(){
776
+ var s = document.createElement('span');
777
+ s.className = 'busy-dot';
778
+ s.setAttribute('aria-hidden', 'true');
779
+ return s;
780
+ }
781
+ // 매초 바뀌는 글자라 스크린리더에는 숨긴다. 시간 초과 안내로 바뀌면 다시 읽히게 푼다
782
+ function elapsed(p){
783
+ var s = document.createElement('span');
784
+ s.setAttribute('aria-hidden', 'true');
785
+ s.setAttribute('data-pending-at', String(p.at));
786
+ return s;
787
+ }
788
+ function para(cls, text){
789
+ var el = document.createElement('p');
790
+ el.className = cls;
791
+ if (text) el.textContent = text;
792
+ return el;
793
+ }
794
+ function find(p){
795
+ var key = JSON.stringify(p.b);
796
+ var hit = null;
797
+ items.forEach(function(it){ if (it.key === key) hit = it; });
798
+ return hit;
799
+ }
800
+ function addTemplate(p){
801
+ var list = document.querySelector('.tab-row .tabs');
802
+ if (!list) return null;
803
+ var id = 'pending-' + p.b.template;
804
+ var title = p.t || String(p.b.template);
805
+ var tab = document.createElement('button');
806
+ tab.type = 'button';
807
+ tab.className = 'is-pending';
808
+ tab.id = 'tab-' + id;
809
+ tab.setAttribute('role', 'tab');
810
+ tab.setAttribute('data-tab', id);
811
+ tab.setAttribute('aria-controls', id);
812
+ tab.setAttribute('aria-selected', 'false');
813
+ tab.tabIndex = -1;
814
+ tab.appendChild(dot());
815
+ tab.appendChild(document.createTextNode(title));
816
+ var panel = document.createElement('section');
817
+ panel.className = 'ext ext-pending';
818
+ panel.id = id;
819
+ panel.hidden = true;
820
+ panel.tabIndex = 0;
821
+ panel.setAttribute('role', 'tabpanel');
822
+ panel.setAttribute('aria-labelledby', tab.id);
823
+ var h = document.createElement('h2');
824
+ h.textContent = title;
825
+ panel.appendChild(h);
826
+ panel.appendChild(para('', '세션이 만드는 중이에요. 다 만들어지면 새로고침하지 않아도 이 자리에 바로 들어와요.'));
827
+ var meta = para('pending-meta', '');
828
+ meta.appendChild(dot());
829
+ meta.appendChild(elapsed(p));
830
+ panel.appendChild(meta);
831
+ panel.appendChild(para('pending-hint', '세션 화면에 승인 창이 떠 있으면 거기서 먼저 확인해 주세요.'));
832
+ list.appendChild(tab);
833
+ placePanel(panel);
834
+ return { tab: tab, panel: panel, meta: meta };
835
+ }
836
+ function addReport(p){
837
+ var toggle = document.getElementById('easy-toggle');
838
+ if (!toggle) return null;
839
+ var chip = document.createElement('span');
840
+ chip.className = 'busy-chip';
841
+ chip.appendChild(dot());
842
+ chip.appendChild(document.createTextNode((p.b.difficulty === 'easy' ? '쉬운' : '보통') + ' 리포트 만드는 중 · '));
843
+ chip.appendChild(elapsed(p));
844
+ toggle.parentNode.insertBefore(chip, toggle.nextSibling);
845
+ return { chip: chip };
846
+ }
847
+ // 버튼으로 넣은 요청은 넣었다는 안내를 따로 띄워서 announce는 새로고침 뒤 이어 받을 때만 켠다
848
+ function add(p, announce){
849
+ if (find(p)) return;
850
+ var ui = p.b.action === 'template' ? addTemplate(p) : addReport(p);
851
+ if (!ui) return;
852
+ ui.key = JSON.stringify(p.b);
853
+ ui.p = p;
854
+ items.push(ui);
855
+ if (announce) {
856
+ var what = p.b.action === 'template' ? (p.t || String(p.b.template)) : (p.b.difficulty === 'easy' ? '쉬운' : '보통') + ' 리포트';
857
+ status.textContent = what + ' 만드는 중이에요.';
858
+ }
859
+ startTimer();
860
+ return ui;
861
+ }
862
+ function drop(it){ items = items.filter(function(x){ return x !== it; }); }
863
+ // 새로 들어온 확장에 맞는 빈 탭을 넘겨준다. livePatch가 그 자리에 진짜 탭을 끼운다
864
+ function takeTab(x){
865
+ var hit = null;
866
+ items.forEach(function(it){ if (it.tab && requestMade(it.p.b, [], [x])) hit = it; });
867
+ if (hit) drop(hit);
868
+ return hit;
869
+ }
870
+ // 들어온 요청은 목록에서 뺀다. 리포트 칩은 누르면 그 리포트를 여는 버튼으로 바꾼다
871
+ function settle(){
872
+ var left = [];
873
+ var done = [];
874
+ readPending().forEach(function(p){
875
+ if (requestMade(p.b, data.reports, data.extensions)) done.push(p);
876
+ else left.push(p);
877
+ });
878
+ writePending(left);
879
+ done.forEach(function(p){
880
+ delete requestState[JSON.stringify(p.b)];
881
+ var it = find(p);
882
+ if (p.b.action === 'template') {
883
+ if (it) { it.tab.remove(); it.panel.remove(); drop(it); }
884
+ status.textContent = (p.t || '확장') + ' 탭이 다 만들어졌어요.';
885
+ return;
886
+ }
887
+ var label = (p.b.difficulty === 'easy' ? '쉬운' : '보통') + ' 리포트';
888
+ status.textContent = label + '가 다 만들어졌어요.';
889
+ if (!it) return;
890
+ drop(it);
891
+ var btn = document.createElement('button');
892
+ btn.type = 'button';
893
+ btn.className = 'busy-chip is-done';
894
+ btn.textContent = label + ' 보기';
895
+ btn.addEventListener('click', function(){
896
+ btn.remove();
897
+ tabs.open('panel-summary');
898
+ reports.open(p.b.difficulty, p.b.lang);
899
+ });
900
+ it.chip.parentNode.replaceChild(btn, it.chip);
901
+ });
902
+ tick();
903
+ }
904
+ function timeout(){
905
+ items.forEach(function(it){
906
+ var box = it.meta || it.chip;
907
+ box.querySelectorAll('.busy-dot').forEach(function(d){ d.classList.add('is-stopped'); });
908
+ box.querySelectorAll('[data-pending-at]').forEach(function(el){
909
+ el.removeAttribute('data-pending-at');
910
+ el.removeAttribute('aria-hidden');
911
+ el.textContent = '30분이 지나도 안 들어왔어요. 세션 화면을 확인해 주세요.';
912
+ });
913
+ });
914
+ if (items.length) status.textContent = '요청한 리포트가 30분이 지나도 안 들어왔어요. 세션 화면을 확인해 주세요.';
915
+ tick();
916
+ }
917
+ return { add: add, takeTab: takeTab, settle: settle, timeout: timeout };
918
+ }
919
+ // 확장 패널은 요약 패널과 앞선 확장 패널 뒤에 차례로 붙는다
920
+ function placePanel(panel){
921
+ var all = document.querySelectorAll('section.ext');
922
+ var after = all.length ? all[all.length - 1] : document.getElementById('panel-summary');
923
+ if (after) after.parentNode.insertBefore(panel, after.nextSibling);
924
+ }
925
+ function reportKeys(list){ return (list || []).map(function(r){ return r.key; }).join('|'); }
926
+ // 새 렌더에서 지금 화면에 없는 확장 탭과 바뀐 리포트 묶음만 옮겨 온다. 보던 탭과 리포트, 스크롤은 그대로 둔다.
927
+ // 화면 짜임이 달라 끼울 자리를 못 찾으면 false를 돌려주고 부른 쪽이 새로고침한다
928
+ function livePatch(page){
929
+ var next = page.data;
930
+ var doc = page.doc;
931
+ if (next.slug !== data.slug) return false;
932
+ var list = document.querySelector('.tab-row .tabs');
933
+ var fresh = [];
934
+ var ok = true;
935
+ (next.extensions || []).forEach(function(x){
936
+ if (document.getElementById(x.panel)) return;
937
+ var panel = doc.getElementById(x.panel);
938
+ var tab = doc.getElementById('tab-' + x.panel);
939
+ if (!panel || !tab || !list) ok = false;
940
+ else fresh.push({ x: x, panel: panel, tab: tab });
941
+ });
942
+ var swapReports = reportKeys(next.reports) !== reportKeys(data.reports);
943
+ var oldBoxes = document.querySelectorAll('.report[data-ri]');
944
+ var newBoxes = doc.querySelectorAll('.report[data-ri]');
945
+ var oldSelect = document.getElementById('report-select');
946
+ var newSelect = doc.getElementById('report-select');
947
+ if (swapReports && (!oldBoxes.length || !newBoxes.length || Boolean(oldSelect) !== Boolean(newSelect))) ok = false;
948
+ if (!ok) return false;
949
+ var keep = data.reports[reports.current()].key;
950
+ data.reports = next.reports;
951
+ data.extensions = next.extensions;
952
+ if (swapReports) {
953
+ swapAll(oldBoxes, newBoxes, null);
954
+ swapAll(document.querySelectorAll('.minimap[data-ri]'), doc.querySelectorAll('.minimap[data-ri]'), dataEl);
955
+ if (oldSelect && newSelect) {
956
+ oldSelect.textContent = '';
957
+ Array.prototype.forEach.call(newSelect.children, function(o){ oldSelect.appendChild(document.importNode(o, true)); });
958
+ }
959
+ reports.refresh(keep);
960
+ }
961
+ fresh.forEach(function(f){
962
+ var panel = document.importNode(f.panel, true);
963
+ var tab = document.importNode(f.tab, true);
964
+ var slot = progress.takeTab(f.x);
965
+ var wasOpen = slot && slot.tab.getAttribute('aria-selected') === 'true';
966
+ var active = document.activeElement;
967
+ var hadFocus = slot && (active === slot.tab || slot.panel.contains(active));
968
+ panel.hidden = true;
969
+ tab.setAttribute('aria-selected', 'false');
970
+ tab.tabIndex = -1;
971
+ tab.classList.add('is-new');
972
+ if (slot) {
973
+ list.replaceChild(tab, slot.tab);
974
+ slot.panel.parentNode.replaceChild(panel, slot.panel);
975
+ } else {
976
+ list.appendChild(tab);
977
+ placePanel(panel);
978
+ }
979
+ enhance(panel);
980
+ // 만들어진 템플릿은 더 만들기 메뉴에서 뺀다
981
+ var id = f.x.key.split('.')[0];
982
+ document.querySelectorAll('#more-menu [data-template]').forEach(function(b){
983
+ if (b.getAttribute('data-template') === id && b.parentNode) b.parentNode.remove();
984
+ });
985
+ if (wasOpen) tabs.open(f.x.panel);
986
+ if (hadFocus) tab.focus();
987
+ setTimeout(function(){ tab.classList.remove('is-new'); }, 6000);
988
+ });
989
+ return true;
990
+ }
991
+ // 옛 노드 자리에 새 노드를 차례로 끼우고 옛 노드를 지운다. 옛 노드가 없으면 fallback 앞에 붙인다
992
+ function swapAll(olds, news, fallback){
993
+ var at = olds.length ? olds[0] : fallback;
994
+ if (!at) return;
995
+ Array.prototype.forEach.call(news, function(n){
996
+ var el = document.importNode(n, true);
997
+ at.parentNode.insertBefore(el, at);
998
+ enhance(el);
999
+ });
1000
+ Array.prototype.forEach.call(olds, function(o){ o.remove(); });
1001
+ }
1002
+ // 새로 끼운 노드에 처음 로드 때 한 번만 걸던 처리를 다시 건다. 클릭과 키 입력은 document에 걸려 있어 따로 안 건다
1003
+ function enhance(root){
1004
+ popups.mark(root);
1005
+ sectionMarks.observe(root);
1006
+ subFollow.observe(root);
1007
+ zoom.wrap(root);
1008
+ }
1009
+
599
1010
  var flash = makeFlash(function(){ return document.getElementById('copy-status'); });
600
1011
  function copyWith(btn, label, text) {
601
1012
  copyText(text, function(){ flash(btn, label, false); }, function(){ flash(btn, label, true); });
@@ -610,13 +1021,16 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
610
1021
  });
611
1022
  }
612
1023
 
613
- // 요약 탭과 확장 탭을 오간다. 리포트 바와 미니맵은 요약 탭 안이나 body 표시로 같이 숨는다
1024
+ // 요약 탭과 확장 탭을 오간다. 리포트 바와 미니맵은 요약 탭 안이나 body 표시로 같이 숨는다.
1025
+ // 탭은 나중에 끼워 넣기도 해서 누를 때마다 탭 목록을 다시 읽는다
614
1026
  function setupTabs() {
615
1027
  var TAB_KEY = 'gestalt-digest-tab:' + data.slug;
616
1028
  var SUMMARY = 'panel-summary';
617
- var tabs = Array.prototype.slice.call(document.querySelectorAll('.tabs [role="tab"]'));
1029
+ var list = document.querySelector('.tab-row .tabs');
1030
+ function all(){ return Array.prototype.slice.call(document.querySelectorAll('.tabs [role="tab"]')); }
618
1031
  var current = SUMMARY;
619
1032
  function select(id, focus){
1033
+ var tabs = all();
620
1034
  if (!tabs.some(function(t){ return t.getAttribute('data-tab') === id; })) id = SUMMARY;
621
1035
  current = id;
622
1036
  tabs.forEach(function(t){
@@ -629,18 +1043,27 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
629
1043
  });
630
1044
  if (id === SUMMARY) document.body.removeAttribute('data-tab');
631
1045
  else document.body.setAttribute('data-tab', 'ext');
1046
+ // 만드는 중인 빈 탭은 다음에 열 때 없을 수 있어서 기억하지 않는다
1047
+ if (id.indexOf('pending-') === 0) return;
632
1048
  try { localStorage.setItem(TAB_KEY, id); } catch (e) {}
633
1049
  }
634
- tabs.forEach(function(t, i){
635
- t.addEventListener('click', function(){ select(t.getAttribute('data-tab'), false); });
636
- t.addEventListener('keydown', function(ev){
1050
+ function tabOf(ev){ return ev.target.closest ? ev.target.closest('[role="tab"]') : null; }
1051
+ if (list) {
1052
+ list.addEventListener('click', function(ev){
1053
+ var t = tabOf(ev);
1054
+ if (t) select(t.getAttribute('data-tab'), false);
1055
+ });
1056
+ list.addEventListener('keydown', function(ev){
1057
+ var tabs = all();
1058
+ var i = tabs.indexOf(tabOf(ev));
1059
+ if (i < 0) return;
637
1060
  var j = tabStep(i, tabs.length, ev.key);
638
1061
  if (j < 0) return;
639
1062
  ev.preventDefault();
640
1063
  select(tabs[j].getAttribute('data-tab'), true);
641
1064
  });
642
- });
643
- if (tabs.length) {
1065
+ }
1066
+ if (all().length) {
644
1067
  // 공유된 앵커가 있으면 그 앵커가 든 탭을 연다. 저장해 둔 탭보다 먼저다
645
1068
  var anchor = safeDecode(location.hash.slice(1));
646
1069
  var target = anchor ? document.getElementById(anchor) : null;
@@ -651,6 +1074,7 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
651
1074
  else if (!target && saved) select(saved, false);
652
1075
  }
653
1076
  return {
1077
+ open: function(id){ select(id, false); },
654
1078
  markdown: function(){
655
1079
  if (current === SUMMARY) return null;
656
1080
  var hit = null;
@@ -660,14 +1084,26 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
660
1084
  };
661
1085
  }
662
1086
 
663
- // 없는 템플릿은 뷰어가 만들지 않고 세션에 붙여 넣을 명령만 복사해 준다
664
- function setupMore(reports) {
1087
+ // 없는 템플릿은 뷰어가 만들지 않는다. serve로 열었으면 세션에 요청하고 만드는 중 탭을 연다. 아니면 세션에 붙여 넣을 명령만 복사해 준다
1088
+ function setupMore(reports, tabs) {
665
1089
  var btn = document.getElementById('more-btn');
666
1090
  var menu = document.getElementById('more-menu');
667
1091
  var box = document.getElementById('more-cmd-box');
668
1092
  var code = document.getElementById('more-cmd');
669
1093
  var copy = document.getElementById('more-copy');
1094
+ var hint = document.getElementById('more-hint');
1095
+ var msg = document.getElementById('more-msg');
1096
+ var wrap = document.getElementById('more-cmd-wrap');
670
1097
  if (!btn || !menu || !box || !code || !copy) return { close: function(){ return false; } };
1098
+ if (serveToken && hint) hint.textContent = '템플릿을 고르면 serve를 띄운 세션에 만들어 달라고 요청해요.';
1099
+ // 응답이 오기 전에 다른 템플릿을 고르면 앞 응답은 안내를 덮지 않는다
1100
+ var seq = 0;
1101
+ function showCmd(text, message){
1102
+ code.textContent = text;
1103
+ if (msg) msg.textContent = message;
1104
+ if (wrap) wrap.hidden = false;
1105
+ box.hidden = false;
1106
+ }
671
1107
  function setOpen(open){
672
1108
  menu.hidden = !open;
673
1109
  btn.setAttribute('aria-expanded', open ? 'true' : 'false');
@@ -675,9 +1111,33 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
675
1111
  btn.addEventListener('click', function(){ setOpen(menu.hidden); });
676
1112
  menu.querySelectorAll('[data-template]').forEach(function(b){
677
1113
  b.addEventListener('click', function(){
678
- code.textContent = templateCommand(data.slug, b.getAttribute('data-template'), data.reports[reports.current()].lang);
1114
+ var id = b.getAttribute('data-template');
1115
+ var lang = data.reports[reports.current()].lang;
1116
+ var command = templateCommand(data.slug, id, lang, data.lens);
1117
+ var name = b.querySelector('b');
1118
+ var mine = ++seq;
1119
+ if (!serveToken) {
1120
+ showCmd(command, '');
1121
+ copyWith(copy, '명령 복사', command);
1122
+ return;
1123
+ }
1124
+ if (msg) msg.textContent = '세션에 요청하는 중이에요.';
1125
+ if (wrap) wrap.hidden = true;
679
1126
  box.hidden = false;
680
- copyWith(copy, '명령 복사', code.textContent);
1127
+ requestToSession(templateRequest(data.slug, id, lang), name ? name.textContent : id, function(){
1128
+ if (mine !== seq) return;
1129
+ // 이 상자는 곧 숨기니 안내는 늘 보이는 status에 쓴다. 메뉴 항목이 사라져 포커스는 만드는 중 탭으로 옮긴다
1130
+ if (msg) msg.textContent = '';
1131
+ var status = document.getElementById('copy-status');
1132
+ if (status) status.textContent = SENT_MSG;
1133
+ box.hidden = true;
1134
+ setOpen(false);
1135
+ tabs.open('pending-' + id);
1136
+ var pendingTab = document.getElementById('tab-pending-' + id);
1137
+ (pendingTab || btn).focus();
1138
+ }, function(){
1139
+ if (mine === seq) showCmd(command, '세션에 요청을 넣지 못했어요. 아래 명령을 세션에 붙여 넣어 주세요.');
1140
+ });
681
1141
  });
682
1142
  });
683
1143
  copy.addEventListener('click', function(){ copyWith(copy, '명령 복사', code.textContent); });
@@ -691,11 +1151,10 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
691
1151
  };
692
1152
  }
693
1153
 
694
- // 툴바는 쉽게 보기 토글 하나로 보통과 쉬운 리포트를 오간다. 없는 리포트는 서버가 만들지 않고 생성 명령만 보여 준다
1154
+ // 툴바는 쉽게 보기 토글 하나로 보통과 쉬운 리포트를 오간다. 없는 리포트는 서버가 만들지 않고 생성 명령만 보여 준다.
1155
+ // 리포트 묶음은 나중에 통째로 바꿔 끼우기도 해서 보여 줄 때마다 다시 읽는다
695
1156
  function setupReports() {
696
1157
  var REPORT_KEY = 'gestalt-digest-report:' + data.slug;
697
- var boxes = document.querySelectorAll('.report[data-ri]');
698
- var maps = document.querySelectorAll('.minimap[data-ri]');
699
1158
  var easyToggle = document.getElementById('easy-toggle');
700
1159
  var select = document.getElementById('report-select');
701
1160
  var missing = document.getElementById('report-missing');
@@ -706,15 +1165,25 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
706
1165
  var copyCmd = document.getElementById('copy-cmd');
707
1166
  var initial = data.reports[data.initial] ? data.initial : 0;
708
1167
  var current = initial;
1168
+ // 안내를 닫거나 다른 리포트로 옮긴 뒤 도착한 요청 응답은 안내를 다시 띄우지 않는다
1169
+ var noticeSeq = 0;
709
1170
  function hideMissing(){
1171
+ noticeSeq++;
710
1172
  if (missing) missing.hidden = true;
711
1173
  if (status) status.textContent = '';
712
1174
  }
1175
+ function showNotice(msg, command){
1176
+ if (missingMsg) missingMsg.textContent = msg;
1177
+ if (cmdWrap) cmdWrap.hidden = command === null;
1178
+ if (cmd) cmd.textContent = command || '';
1179
+ if (missing) missing.hidden = false;
1180
+ if (status) status.textContent = msg;
1181
+ }
713
1182
  function show(ri){
714
1183
  if (!data.reports[ri]) ri = initial;
715
1184
  current = ri;
716
- boxes.forEach(function(b){ b.hidden = Number(b.getAttribute('data-ri')) !== ri; });
717
- maps.forEach(function(m){ m.hidden = Number(m.getAttribute('data-ri')) !== ri; });
1185
+ document.querySelectorAll('.report[data-ri]').forEach(function(b){ b.hidden = Number(b.getAttribute('data-ri')) !== ri; });
1186
+ document.querySelectorAll('.minimap[data-ri]').forEach(function(m){ m.hidden = Number(m.getAttribute('data-ri')) !== ri; });
718
1187
  var r = data.reports[ri];
719
1188
  if (easyToggle) easyToggle.setAttribute('aria-pressed', r.difficulty === 'easy' ? 'true' : 'false');
720
1189
  if (select) select.value = String(ri);
@@ -727,12 +1196,15 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
727
1196
  // 짝이 없는 옛 짧게, 기본 리포트는 길게로 간다. 스킬이 길게만 만들어서다. 구간이나 요청이 붙은 리포트는 다른 리포트로 넘기지 않는다
728
1197
  if (ri < 0 && r.preset) ri = pickReport(data.reports, 'long', difficulty);
729
1198
  if (ri >= 0) { show(ri); return; }
730
- var notice = missingNotice(r, data.slug, difficulty);
731
- if (missingMsg) missingMsg.textContent = notice.msg;
732
- if (cmdWrap) cmdWrap.hidden = notice.cmd === null;
733
- if (cmd) cmd.textContent = notice.cmd || '';
734
- if (missing) missing.hidden = false;
735
- if (status) status.textContent = notice.msg;
1199
+ var notice = missingNotice(r, data.slug, difficulty, data.lens);
1200
+ var mine = ++noticeSeq;
1201
+ if (notice.cmd === null || !serveToken) { showNotice(notice.msg, notice.cmd); return; }
1202
+ showNotice('세션에 만들어 달라고 요청하는 중이에요.', null);
1203
+ requestToSession(reportRequest(data.slug, difficulty, r.lang, r.range), '', function(){
1204
+ if (mine === noticeSeq) showNotice(SENT_MSG, null);
1205
+ }, function(){
1206
+ if (mine === noticeSeq) showNotice('세션에 요청을 넣지 못했어요. 아래 명령을 세션에 붙여 넣으면 만들어요.', notice.cmd);
1207
+ });
736
1208
  }
737
1209
  // 눌림 상태는 보이는 리포트만 따른다. 안내가 떠 있으면 다시 눌러 닫는다
738
1210
  if (easyToggle) easyToggle.addEventListener('click', function(){
@@ -754,7 +1226,20 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
754
1226
  if (owner) start = Number(owner.getAttribute('data-ri'));
755
1227
  if (start !== initial) show(start);
756
1228
  if (owner && target) target.scrollIntoView();
757
- return { current: function(){ return current; } };
1229
+ return {
1230
+ current: function(){ return current; },
1231
+ open: function(difficulty, lang){
1232
+ data.reports.forEach(function(r, i){
1233
+ if (r.difficulty === difficulty && (!lang || r.lang === lang) && data.reports[current].difficulty !== difficulty) show(i);
1234
+ });
1235
+ },
1236
+ // 리포트 묶음을 바꿔 끼운 뒤 보던 리포트를 키로 다시 찾는다. 자리 번호는 새 렌더에서 밀릴 수 있다
1237
+ refresh: function(key){
1238
+ var ri = current;
1239
+ data.reports.forEach(function(r, i){ if (r.key === key) ri = i; });
1240
+ show(ri);
1241
+ }
1242
+ };
758
1243
  }
759
1244
 
760
1245
  // 고른 답을 맞춰 보고 해설을 연다. 아무것도 안 골랐으면 정답과 해설만 연다
@@ -794,9 +1279,9 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
794
1279
  }
795
1280
 
796
1281
  // 장면 사진은 확대 창을 열 수 있을 때만 버튼으로 감싼다. 서버가 버튼을 그리면 스크립트가 없을 때 Tab에는 잡히는데 눌러도 반응이 없다
797
- function wrapShots() {
1282
+ function wrapShots(root) {
798
1283
  if (typeof document.createElement('dialog').showModal !== 'function') return;
799
- var imgs = document.querySelectorAll('.shot > img');
1284
+ var imgs = root.querySelectorAll('.shot > img');
800
1285
  for (var i = 0; i < imgs.length; i++) {
801
1286
  var img = imgs[i];
802
1287
  var btn = document.createElement('button');
@@ -813,10 +1298,10 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
813
1298
  }
814
1299
  }
815
1300
 
816
- // 그림과 장면을 화면 크기로 키워 본다. 노드를 눌러 영상으로 가면 창을 닫는다
1301
+ // 그림과 장면을 화면 크기로 키워 본다. 노드를 눌러 영상으로 가면 창을 닫는다.
1302
+ // 그림이 든 탭이 나중에 들어올 수 있어 그림이 없어도 창은 만들어 둔다
817
1303
  function setupZoom() {
818
- wrapShots();
819
- if (!document.querySelector('[data-zoom]')) return { isOpen: function(){ return false; } };
1304
+ wrapShots(document);
820
1305
  var dialog = document.createElement('dialog');
821
1306
  dialog.id = 'zoom-dialog';
822
1307
  dialog.setAttribute('aria-label', '그림 크게 보기');
@@ -833,7 +1318,7 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
833
1318
  close.addEventListener('click', function(){ dialog.close(); });
834
1319
  dialog.addEventListener('click', function(ev){
835
1320
  if (ev.target === dialog) dialog.close();
836
- else if (ev.target.closest && ev.target.closest('[data-sec]')) dialog.close();
1321
+ else if (ev.target.closest && ev.target.closest('[data-sec], [data-unit]')) dialog.close();
837
1322
  });
838
1323
  dialog.addEventListener('close', function(){
839
1324
  body.textContent = '';
@@ -854,7 +1339,7 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
854
1339
  opener = btn;
855
1340
  dialog.showModal();
856
1341
  });
857
- return { isOpen: function(){ return dialog.open; } };
1342
+ return { isOpen: function(){ return dialog.open; }, wrap: wrapShots };
858
1343
  }
859
1344
 
860
1345
  // 인터랙티브 그림은 렌더러가 상태마다 그려 둔 것 가운데 하나만 보인다. 여기서 값을 계산하지 않는다
@@ -893,22 +1378,25 @@ export const VIEWER_JS = VIEWER_LOGIC_JS +
893
1378
  // 요약 앵커로 스크롤하려면 요약 패널이 먼저 보여야 해서 탭을 리포트보다 먼저 연다
894
1379
  var tabs = setupTabs();
895
1380
  var reports = setupReports();
896
- var more = setupMore(reports);
1381
+ var more = setupMore(reports, tabs);
1382
+ var progress = setupProgress(tabs, reports);
897
1383
  var player = createPlayer();
898
1384
  setupTimeLinks(player);
899
1385
  var docPane = setupDocPane();
900
1386
  var popups = setupPopups(docPane);
901
1387
  setupScriptFollow(player);
902
- setupSectionMarks();
903
- setupSubsectionFollow(player);
1388
+ var sectionMarks = setupSectionMarks();
1389
+ var subFollow = setupSubsectionFollow(player);
904
1390
  var mini = setupMiniPlayer(player);
905
1391
  setupCopyButton(reports, tabs);
906
1392
  setupQuiz();
907
1393
  var zoom = setupZoom();
908
- // 도식 노드는 Enter와 Space를 클릭으로 넘겨야 시각 이동과 확대 창 닫기가 같이 돈다
1394
+ // 빈 탭을 다시 세우고 끼울 때 쓰는 처리가 모두 준비된 뒤에 이어서 기다린다
1395
+ resumePending(reports, tabs);
1396
+ // 도식 노드는 Enter와 Space를 클릭으로 넘겨야 시각 이동이나 문단 열기와 확대 창 닫기가 같이 돈다
909
1397
  document.addEventListener('keydown', function(ev){
910
1398
  if (ev.key !== 'Enter' && ev.key !== ' ') return;
911
- var el = ev.target.closest ? ev.target.closest('g[data-sec]') : null;
1399
+ var el = ev.target.closest ? ev.target.closest('g[data-sec], g[data-unit]') : null;
912
1400
  if (!el) return;
913
1401
  ev.preventDefault();
914
1402
  el.dispatchEvent(new MouseEvent('click', { bubbles: true }));