@newt-kit/plan 0.0.0-stage → 0.10.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.
@@ -0,0 +1,888 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>newt-plan</title>
7
+ <style>
8
+ /* newt-plan's page: one box per module, one chip per piece; read from cause to effect. Its data is DATA, its words T. */
9
+ :root{
10
+ --del:#c2413b;--del-bg:#fbe4e2;--del-ink:#7a1f1a;
11
+ --bg:#f3f5f7;--panel:#ffffff;--ink:#16202b;--quiet:#5d6b7a;--line:#d5dbe2;--ctx:#e9edf1;--ctx-line:#b9c3ce;
12
+ --new:#0f8f6a;--new-bg:#dff4ec;--new-ink:#07503b;
13
+ --mod:#b8730a;--mod-bg:#fbeed3;--mod-ink:#613c04;
14
+ --use:#2b78cf;--use-bg:#e2eefb;--use-ink:#0d4079;
15
+ --old:#9aa5b1;--old-bg:#f6f8fa;--old-ink:#55626f;
16
+ --on:#ffffff;--shadow:0 8px 30px rgba(20,35,50,.16);
17
+ --sans:"Avenir Next","Segoe UI",system-ui,-apple-system,"Helvetica Neue",sans-serif;
18
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
19
+ }
20
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
21
+ color-scheme:dark;
22
+ --del:#ec6a62;--del-bg:#3d1513;--del-ink:#f8cfcb;
23
+ --bg:#12171d;--panel:#1b222a;--ink:#e7ecf1;--quiet:#98a5b3;--line:#313b46;--ctx:#171d24;--ctx-line:#3d4955;
24
+ --new:#35b98f;--new-bg:#0c3329;--new-ink:#c5f1e1;
25
+ --mod:#e3a23a;--mod-bg:#3a2a0d;--mod-ink:#fae2b6;
26
+ --use:#5aa2ee;--use-bg:#10294a;--use-ink:#cfe3fa;
27
+ --old:#5d6976;--old-bg:#1f262e;--old-ink:#aab5c0;
28
+ --on:#0c1116;--shadow:0 8px 30px rgba(0,0,0,.5);
29
+ }}
30
+ :root[data-theme="dark"]{
31
+ color-scheme:dark;
32
+ --del:#ec6a62;--del-bg:#3d1513;--del-ink:#f8cfcb;
33
+ --bg:#12171d;--panel:#1b222a;--ink:#e7ecf1;--quiet:#98a5b3;--line:#313b46;--ctx:#171d24;--ctx-line:#3d4955;
34
+ --new:#35b98f;--new-bg:#0c3329;--new-ink:#c5f1e1;
35
+ --mod:#e3a23a;--mod-bg:#3a2a0d;--mod-ink:#fae2b6;
36
+ --use:#5aa2ee;--use-bg:#10294a;--use-ink:#cfe3fa;
37
+ --old:#5d6976;--old-bg:#1f262e;--old-ink:#aab5c0;
38
+ --on:#0c1116;--shadow:0 8px 30px rgba(0,0,0,.5);
39
+ }
40
+ *{box-sizing:border-box}
41
+ [hidden]{display:none !important}
42
+ html,body{height:100%}
43
+ body{margin:0;font:14px/1.5 var(--sans);background:var(--bg);color:var(--ink);display:flex;flex-direction:column}
44
+ button{font:inherit;color:inherit}
45
+ button:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--use);outline-offset:2px}
46
+ header{padding:10px 16px 0;display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;border-bottom:1px solid var(--line);flex:none}
47
+ h1{font-size:18px;font-weight:650;margin:0;text-wrap:balance}
48
+ .sub{color:var(--quiet);margin:0 0 8px;font-size:12.5px}
49
+ .tabs{display:flex;gap:2px}
50
+ .tabs button{border:0;background:none;color:var(--quiet);font-weight:600;padding:8px 16px;border-bottom:2px solid transparent;cursor:pointer}
51
+ .tabs button[aria-selected="true"]{color:var(--ink);border-bottom-color:var(--ink)}
52
+ .tab{display:none;flex:1;min-height:0}
53
+ #tab-summary.on{display:block;overflow:auto}
54
+ #tab-changes.on{display:flex;flex-direction:column}
55
+
56
+ /* ---- fichas (chips) compartidas ---- */
57
+ .c{font:12.5px var(--mono);padding:1px 6px;border-radius:5px;white-space:nowrap;border:1.5px solid transparent}
58
+ .c.ev,.c.sch{border-radius:999px}
59
+ .c.n{background:var(--new);color:var(--on)}.c.m{background:var(--mod);color:var(--on)}
60
+ .c.u{background:var(--use-bg);color:var(--use-ink);border-color:var(--use)}
61
+ .c.o{background:var(--old-bg);color:var(--old-ink);border-color:var(--old);border-style:dashed}
62
+ .c.x{background:var(--del-bg);color:var(--del-ink);border-color:var(--del);text-decoration:line-through}
63
+ .kv{font:12px var(--mono)}
64
+
65
+ /* ---- resumen ---- */
66
+ .res{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(280px,1fr);gap:16px;padding:16px;max-width:1280px;margin:0 auto}
67
+ @media (max-width:900px){.res{grid-template-columns:1fr}}
68
+ .res:has(#steps-card[hidden]){grid-template-columns:minmax(0,720px);justify-content:center}
69
+ .side{display:flex;flex-direction:column;gap:16px}
70
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px}
71
+ .card h2{font-size:15px;margin:0 0 10px}
72
+ .card h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--quiet);margin:14px 0 6px}
73
+ .card ul{margin:0;padding:0;list-style:none}
74
+ .card li{margin:5px 0}
75
+ #story{margin:0;padding:0;list-style:none;counter-reset:s;position:relative}
76
+ #story::before{content:"";position:absolute;left:22px;top:20px;bottom:20px;width:2px;background:var(--line)}
77
+ #story li{counter-increment:s;position:relative;padding:10px 12px 10px 52px;border-radius:12px;cursor:pointer;font-size:15px;line-height:1.75;margin:0}
78
+ #story li:hover{background:var(--bg)}
79
+ #story li::before{content:counter(s);position:absolute;left:10px;top:11px;width:26px;height:26px;border-radius:50%;background:var(--ink);color:var(--panel);display:grid;place-items:center;font-size:13px;font-weight:700;box-shadow:0 0 0 4px var(--panel)}
80
+ #story li:hover::before{box-shadow:0 0 0 4px var(--bg)}
81
+ #story .go{display:block;font-size:12px;color:var(--use);margin-top:2px}
82
+ .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;text-align:center}
83
+ .stats div{border-radius:10px;padding:8px 4px}.stats b{display:block;font-size:22px;font-variant-numeric:tabular-nums}.stats span{font-size:11px;line-height:1.2;display:block}
84
+ .check li{padding-left:22px;position:relative}
85
+ .check li::before{content:"✓";position:absolute;left:2px;color:var(--new);font-weight:700}
86
+ .check li.no::before{content:"!";color:var(--mod)}
87
+
88
+ /* ---- barra de cambios ---- */
89
+ .bar{display:flex;flex-wrap:wrap;gap:6px 18px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex:none}
90
+ .legend{display:flex;gap:6px 12px;flex-wrap:wrap;align-items:center}
91
+ .lg{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--quiet);white-space:nowrap}
92
+ .lg .c{font-size:11px;padding:0 6px}
93
+ .lg.del{display:none}.removes .lg.del{display:flex}
94
+ .sep{width:1px;height:16px;background:var(--line)}
95
+ .shape{width:20px;height:12px;border:1.5px solid var(--quiet);border-radius:3px}.shape.ev{border-radius:999px}
96
+ .ln{width:26px;height:0;border-top:1.5px solid var(--quiet)}.ln.ev{border-top-style:dashed}.ln.emit{border-top-width:4px;width:16px;border-radius:2px}
97
+ .steps{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--quiet)}
98
+ .steps button{width:24px;height:24px;border-radius:50%;border:1px solid var(--line);background:var(--panel);cursor:pointer;font-size:12px;font-weight:700;padding:0}
99
+ .steps button[aria-pressed="true"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
100
+ .right{display:flex;gap:8px;margin-left:auto;align-items:center;flex-wrap:wrap}
101
+ .tg{border:1px solid var(--line);background:var(--panel);border-radius:999px;padding:3px 12px 3px 8px;font-size:12.5px;cursor:pointer;display:inline-flex;align-items:center;gap:7px}
102
+ .tg i{width:26px;height:14px;border-radius:999px;background:var(--old);position:relative;transition:background .2s}
103
+ .tg i::after{content:"";position:absolute;left:2px;top:2px;width:10px;height:10px;border-radius:50%;background:var(--panel);transition:left .2s}
104
+ .tg[aria-pressed="true"] i{background:var(--new)}.tg[aria-pressed="true"] i::after{left:14px}
105
+ .zoom{display:inline-flex;gap:3px}
106
+ .zoom button{width:26px;height:26px;border-radius:7px;border:1px solid var(--line);background:var(--panel);cursor:pointer;padding:0}
107
+
108
+ .seg{display:inline-flex;border:1px solid var(--line);border-radius:999px;overflow:hidden;background:var(--panel)}
109
+ .seg button{border:0;background:none;padding:3px 12px;font-size:12.5px;cursor:pointer}
110
+ .seg button[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
111
+ /* ---- lienzo ---- */
112
+ .view.cv #wrap{overflow:hidden;padding:0;touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none}
113
+ .view.cv #wrap.drag{cursor:grabbing}
114
+ .view.cv #sizer{width:100% !important;height:100% !important;margin:0;transition:none}
115
+ .view.cv #wrap.drag #stage{transition:none}
116
+ .view.cv .mh{right:0;bottom:auto;height:50px;width:auto !important;border-right:0;border-bottom:1.5px dashed color-mix(in srgb,currentColor 30%,transparent);border-radius:12px 12px 0 0}
117
+ #mini,#hint{display:none}
118
+ .view.cv #mini{display:block;position:absolute;right:10px;bottom:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow);padding:4px;z-index:4;cursor:pointer;touch-action:none;line-height:0}
119
+ #mini .mc{fill:var(--ctx);stroke:var(--ctx-line)}
120
+ #mini .mm{stroke-width:0}#mini .mm.new{fill:var(--new)}#mini .mm.mod{fill:var(--mod)}#mini .mm.use{fill:var(--use)}#mini .mm.old{fill:var(--old)}#mini .mm.del{fill:var(--del)}
121
+ #mini .mm.dim{opacity:.3}
122
+ #mini .mi{fill:var(--ink)}
123
+ #mini .mv{fill:color-mix(in srgb,var(--ink) 10%,transparent);stroke:var(--ink)}
124
+ .view.cv #hint{display:block;position:absolute;left:10px;bottom:10px;font-size:11.5px;color:var(--quiet);background:color-mix(in srgb,var(--panel) 85%,transparent);padding:2px 8px;border-radius:999px;pointer-events:none;z-index:3}
125
+ .canvas{position:relative;flex:1;min-height:0;background:var(--panel);display:flex}
126
+ .view{position:relative;flex:1;min-width:0}
127
+ #wrap{position:absolute;inset:0;overflow:auto;padding:8px}
128
+ #sizer{position:relative;margin:0 auto;transition:width .5s,height .5s}
129
+ #stage{position:absolute;left:0;top:0;transform-origin:0 0;transition:transform .5s cubic-bezier(.3,.7,.2,1)}
130
+ .ctx,.mod,.it{position:absolute;transition:left .6s cubic-bezier(.3,.7,.2,1),top .6s cubic-bezier(.3,.7,.2,1),width .6s cubic-bezier(.3,.7,.2,1),height .6s cubic-bezier(.3,.7,.2,1),opacity .3s}
131
+ .ctx{border:1.5px dashed var(--ctx-line);border-radius:20px;background:var(--ctx)}
132
+ .ctx b{position:absolute;top:6px;left:14px;font-size:11.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--quiet);font-weight:700;white-space:nowrap}
133
+ .mod{border-radius:14px;border:2px solid var(--old);background:var(--old-bg);color:var(--old-ink)}
134
+ .mod.new{border-color:var(--new);background:var(--new-bg);color:var(--new-ink)}
135
+ .mod.mod-{border-color:var(--mod);background:var(--mod-bg);color:var(--mod-ink)}
136
+ .mod.use{border-color:var(--use);background:var(--use-bg);color:var(--use-ink)}
137
+ .mod.old{border-style:dashed}
138
+ .mod.del{border-color:var(--del);background:var(--del-bg);color:var(--del-ink);border-style:dashed}
139
+ .mod.open{box-shadow:0 0 0 4px color-mix(in srgb,currentColor 25%,transparent)}
140
+ .mh{position:absolute;left:0;top:0;bottom:0;display:flex;flex-direction:column;justify-content:flex-start;gap:3px;padding:9px 12px 0;cursor:pointer;border-radius:12px 0 0 12px;white-space:nowrap;overflow:hidden;background:none;border:0;border-right:1.5px dashed color-mix(in srgb,currentColor 30%,transparent);text-align:left;transition:width .6s}
141
+ .mh .l1,.mh .l2{display:flex;align-items:baseline;gap:6px;line-height:1.2}.mh .l2{align-items:center}
142
+ .mh:hover{background:color-mix(in srgb,currentColor 9%,transparent)}
143
+ .mh .nm{font-weight:700;font-size:15px}
144
+ .mh small{font-size:11px;opacity:.75}
145
+ .mh .badge{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:1px 7px;border-radius:999px;border:1px solid currentColor;opacity:.85}
146
+ .mh .more{font-size:11px;opacity:.75}
147
+ .it{height:28px;display:flex;align-items:center;gap:4px;padding:0 9px;font:13px/1 var(--mono);white-space:nowrap;cursor:pointer;border-radius:7px;border:1.5px solid transparent;z-index:2;overflow:hidden}
148
+ .it.ev,.it.sch{border-radius:999px}
149
+ .it .g{font-family:var(--sans);font-size:11px;width:12px;text-align:center;flex:none}
150
+ .it.n{background:var(--new);color:var(--on);font-weight:600}
151
+ .it.m{background:var(--mod);color:var(--on);font-weight:600}
152
+ .it.u{background:var(--panel);color:var(--use-ink);border-color:var(--use)}
153
+ .it.o{background:var(--panel);color:var(--old-ink);border-color:var(--old);border-style:dashed}
154
+ .it.x{background:var(--del-bg);color:var(--del-ink);border-color:var(--del);text-decoration:line-through}
155
+ .it.loose{height:22px;font-size:11.5px;padding:0 7px;border-width:1px}
156
+ .it.gone,.mod.gone,.ctx.gone{opacity:0 !important;pointer-events:none}
157
+ .it.sel{box-shadow:0 0 0 2px var(--panel),0 0 0 5px var(--ink)}
158
+ .it.rel{box-shadow:0 0 0 2px var(--panel),0 0 0 3.5px color-mix(in srgb,var(--ink) 45%,transparent)}
159
+ .it.fade{opacity:.2}
160
+ .mod.dim{opacity:.35}.ctx.dim{opacity:.6}
161
+ svg#edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none;z-index:1;transition:opacity .25s}
162
+ svg#edges.fade{opacity:0}
163
+ .e{fill:none;stroke-width:1.2;stroke:var(--old);opacity:.75;transition:opacity .25s,stroke-width .2s}
164
+ .e.n{stroke:var(--new);stroke-width:1.8;opacity:1}
165
+ .e.x{stroke:var(--del);stroke-width:1.8;opacity:1;stroke-dasharray:2 4}
166
+ .e.ev{stroke-dasharray:6 4}
167
+ .e.n.ev{animation:flow 1.1s linear infinite}
168
+ .e.emit{stroke-width:3.5;stroke-linecap:round}
169
+ .e.emit.n{stroke-width:4}
170
+ @keyframes flow{to{stroke-dashoffset:-20}}
171
+ .hit{fill:none;stroke:transparent;stroke-width:12;pointer-events:stroke;cursor:help}
172
+ .e.hl{stroke-width:3;opacity:1}
173
+ .e.emit.hl{stroke-width:5}
174
+ .e.o.hl{stroke:var(--quiet)}
175
+ .e.dim{opacity:.08}
176
+ @media (prefers-reduced-motion:reduce){.e.n.ev{animation:none}.ctx,.mod,.it,.mh,#stage,#sizer{transition:none}}
177
+ #detail{position:relative;flex:none;width:340px;overflow:auto;border-left:1px solid var(--line);background:var(--panel);padding:14px 16px}
178
+ #detail h2{font-size:15px;margin:0 0 10px}#detail h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--quiet);margin:14px 0 6px}#detail ul{margin:0;padding:0;list-style:none}#detail li{margin:8px 0}
179
+ @media (max-width:800px){#detail{position:absolute;right:0;top:0;bottom:0;max-width:92%;box-shadow:var(--shadow);z-index:6}}
180
+ #detail .x{position:absolute;right:8px;top:6px;border:0;background:none;font-size:20px;color:var(--quiet);cursor:pointer;width:28px;height:28px}
181
+ #detail h2{padding-right:28px}
182
+ #detail p{margin:0 0 4px}
183
+ .st{display:inline-block;font-size:10.5px;font-weight:600;padding:0 7px;border-radius:999px;margin-left:6px;font-family:var(--sans)}
184
+ .st.n,.st.new{background:var(--new-bg);color:var(--new-ink)}.st.m,.st.mod{background:var(--mod-bg);color:var(--mod-ink)}
185
+ .st.u,.st.use{background:var(--use-bg);color:var(--use-ink)}.st.o,.st.old{background:var(--old-bg);color:var(--old-ink)}.st.x,.st.del{background:var(--del-bg);color:var(--del-ink)}
186
+ .flow{font-size:12.5px;color:var(--quiet);padding-left:12px;line-height:1.9}
187
+ .flow .c{font-size:11.5px}
188
+ #caption{flex:none;display:flex;gap:10px;align-items:flex-start;padding:7px 12px;border-bottom:1px solid var(--line);background:var(--panel);font-size:13.5px;line-height:1.75}
189
+ #caption .num{flex:none;width:24px;height:24px;border-radius:50%;background:var(--ink);color:var(--panel);display:grid;place-items:center;font-weight:700;font-size:12.5px}
190
+ #caption .txt{flex:1;min-width:0}
191
+ #caption .c{font-size:11.5px}
192
+ #caption .lbl{color:var(--quiet);font-size:12px}
193
+ #caption .nav{flex:none;display:flex;gap:6px;align-items:center;white-space:nowrap;font-size:12px;color:var(--quiet)}
194
+ #caption .nav button.sq{width:24px;height:24px;border-radius:7px;border:1px solid var(--line);background:var(--panel);cursor:pointer;padding:0}
195
+ #caption .nav button.sq:disabled{opacity:.4;cursor:default}
196
+ #caption .nav button.rm{border:0;background:none;color:var(--use);cursor:pointer;text-decoration:underline;padding:0 2px}
197
+ #tip{position:fixed;z-index:50;pointer-events:none;max-width:340px;background:var(--ink);color:var(--panel);border-radius:10px;padding:9px 12px;font-size:12.5px;line-height:1.5;opacity:0;transition:opacity .12s;box-shadow:var(--shadow)}
198
+ #tip.on{opacity:1}
199
+ #tip code{font:11.5px var(--mono);background:color-mix(in srgb,var(--panel) 16%,transparent);padding:1px 4px;border-radius:4px;overflow-wrap:anywhere}
200
+ #tip .h{font-weight:650;margin-bottom:3px}
201
+ #tip .why{margin-top:5px}
202
+ #tip .tag{display:inline-block;font-size:10px;font-weight:700;text-transform:uppercase;padding:0 6px;border-radius:999px;margin-left:6px;background:var(--new);color:var(--on)}
203
+ header .end{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap}
204
+ header .acts{display:flex;gap:8px;align-items:center;margin-bottom:8px}
205
+ .act{border:1px solid var(--line);background:var(--panel);border-radius:999px;padding:3px 12px;font-size:12.5px;cursor:pointer}
206
+ .act:hover{background:var(--bg)}
207
+ #act-msg{font-size:12px;color:var(--quiet)}
208
+ #story .state{display:inline-block;font-size:11px;font-weight:650;padding:0 8px;border-radius:999px;margin-left:8px;vertical-align:2px}
209
+ .state.done{background:var(--new-bg);color:var(--new-ink)}.state.partial{background:var(--mod-bg);color:var(--mod-ink)}.state.notStarted{background:var(--del-bg);color:var(--del-ink)}.state.nothingToCheck{background:var(--old-bg);color:var(--old-ink)}
210
+ .checks li{font:12.5px var(--mono);padding-left:22px;position:relative;overflow-wrap:anywhere}
211
+ .checks li b{position:absolute;left:2px;font-family:var(--sans)}
212
+ .checks .done b{color:var(--new)}.checks .missing b,.checks .different b{color:var(--del)}.checks .unplanned b{color:var(--mod)}
213
+ .checks .ok{color:var(--quiet)}
214
+ body.map .it.o,body.map .mod.old{border-style:solid}
215
+ body.map .mh .badge{display:none}
216
+ body.map .tabs,body.map #t-only,body.map #steps,body.map .legend .lg:has(.c),body.map .legend .sep:first-of-type{display:none}
217
+ #tip .tag.o{background:var(--old);color:var(--on)}#tip .tag.u{background:var(--use);color:var(--on)}#tip .tag.m{background:var(--mod);color:var(--on)}#tip .tag.x{background:var(--del);color:var(--on)}
218
+ </style>
219
+ </head>
220
+ <body>
221
+ <header>
222
+ <div>
223
+ <h1 id="title"></h1>
224
+ <p class="sub" id="subtitle"></p>
225
+ </div>
226
+ <div class="end">
227
+ <div class="acts">
228
+ <span id="act-msg" role="status"></span>
229
+ <button class="act" id="save-plan" hidden data-t="savePlan"></button>
230
+ <button class="act" id="load-plan" hidden data-t="loadPlan"></button>
231
+ <input type="file" id="plan-file" accept=".json,application/json" hidden>
232
+ </div>
233
+ <nav class="tabs" role="tablist">
234
+ <button role="tab" data-tab="summary" data-t="summary"></button>
235
+ <button role="tab" data-tab="changes" data-t="changes"></button>
236
+ </nav>
237
+ </div>
238
+ </header>
239
+ <section id="tab-summary" class="tab">
240
+ <div class="res">
241
+ <div class="card" id="steps-card">
242
+ <h2 data-t="steps"></h2>
243
+ <ol id="story"></ol>
244
+ </div>
245
+ <div class="side">
246
+ <div class="card" id="summary"></div>
247
+ <div class="card" id="checks-card" hidden>
248
+ <h2 data-t="checks"></h2>
249
+ <ul class="checks" id="checks"></ul>
250
+ </div>
251
+ <div class="card">
252
+ <h2 data-t="rules"></h2>
253
+ <ul class="check" id="rules"></ul>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ </section>
258
+ <section id="tab-changes" class="tab">
259
+ <div class="bar">
260
+ <div class="legend">
261
+ <span class="lg"><span class="c n" data-t="n"></span></span>
262
+ <span class="lg"><span class="c m" data-t="m"></span></span>
263
+ <span class="lg"><span class="c u" data-t="u"></span></span>
264
+ <span class="lg"><span class="c o" data-t="o"></span></span>
265
+ <span class="lg del"><span class="c x" data-t="x"></span></span>
266
+ <span class="sep"></span>
267
+ <span class="lg"><span class="shape"></span><span data-t="useCase"></span></span>
268
+ <span class="lg"><span class="shape ev"></span><span data-t="eventSchedule"></span></span>
269
+ <span class="sep"></span>
270
+ <span class="lg"><span class="ln emit"></span><span data-t="emits"></span></span>
271
+ <span class="lg"><span class="ln ev"></span><span data-t="triggers"></span></span>
272
+ <span class="lg"><span class="ln"></span><span data-t="calls"></span></span>
273
+ </div>
274
+ <div class="right">
275
+ <span class="steps" id="steps"><span data-t="storyShort"></span></span>
276
+ <span class="seg" id="mode" role="group" data-t-aria="viewMode"><button data-mode="cv" aria-pressed="true" data-t="canvas"></button><button data-mode="ln" aria-pressed="false" data-t="lanes"></button></span>
277
+ <button class="tg" id="t-only" aria-pressed="true"><i></i><span data-t="onlyAffected"></span></button>
278
+ <span class="zoom"><button id="z-" data-t-aria="zoomOut">−</button><button id="z0" data-t-aria="fit">⤢</button><button id="z+" data-t-aria="zoomIn">+</button></span>
279
+ </div>
280
+ </div>
281
+ <div id="caption" hidden></div>
282
+ <div class="canvas">
283
+ <div class="view" id="view"><div id="wrap"><div id="sizer"><div id="stage"><svg id="edges"><defs></defs><g id="eg"></g></svg></div></div></div><div id="mini" data-t-title="minimap"><svg></svg></div><div id="hint" data-t="hint"></div></div>
284
+ <aside id="detail" hidden></aside>
285
+ </div>
286
+ </section>
287
+ <div id="tip"></div>
288
+ <!--@@REVIEW@@-->
289
+ <!--@@LOADED@@-->
290
+ <script>
291
+ /* In a function: a plan loaded in the page writes the document again, in this same window. */
292
+ (() => {
293
+ /* ---------- the plan ----------
294
+ DATA is written by newt-plan's render, nothing else changes between two plans:
295
+ contexts {key: label}; modules {key: {ctx, port, st new|mod|use|old|del, why, items: [[name, kind uc|ev|sch, st n|m|u|o|x, {why, cron}]]}};
296
+ relations [[kind emit|ev|call, from, to, st n|o|x, why, queue, conditional]] between '<module key>.<piece>' ids;
297
+ steps [{title, text, focus, state, done, of}], where a text names a piece as {id}; rules [{ok, text}];
298
+ checks [{id, what, planned, found, verdict, failing}] of a review held against a plan; plan, the plan a page saves;
299
+ maps {base, head}, what a plan loaded in a review's page is held against.
300
+ mode is map (the application as it is), plan or review. A plan loaded in the page sets window.NEWT_DATA. */
301
+ const SOURCE = '<!DOCTYPE html>\n' + document.documentElement.outerHTML;
302
+ const DATA = window.NEWT_DATA || /*@@DATA@@*/null;
303
+ const esc = text => String(text).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
304
+ const WORDS = {
305
+ en: {
306
+ summary:'Summary', changes:'Changes', story:'The story', rules:'Rules checked', impact:'Impact', storyShort:'Story',
307
+ n:'new', m:'modified', u:'consumed', o:'unchanged', x:'removed', existing:'existing',
308
+ useCase:'use case', eventSchedule:'⚡ event · ⏱ schedule', emits:'emits', triggers:'event triggers', calls:'calls',
309
+ canvas:'Canvas', lanes:'Lanes', viewMode:'How to draw it', onlyAffected:'Only what changes', zoomOut:'Zoom out', fit:'Fit', zoomIn:'Zoom in',
310
+ minimap:'Minimap: click or drag to move', hint:'Drag to move · wheel or pinch to zoom',
311
+ moduleDetail:'The module, in detail', port:'port', more:n => `+${n} unchanged`,
312
+ selected:'Selected:', andEmits:'and what it emits', andEmitter:'and who emits it', previous:'Previous step', next:'Next step',
313
+ highlighted:n => `Highlighted: step ${n}`, clear:'clear', highlightStep:n => `Highlight step ${n}`, seeInDiagram:'See it in the diagram →',
314
+ scheduleTriggers:'A schedule triggers a use case', eventTriggers:'An event triggers a use case', triggersSentence:(a, b) => `${a} triggers ${b}`, queue:'Queue',
315
+ callBetween:'A call between modules', callsSentence:(a, b) => `${a} calls ${b}`,
316
+ useCaseEmits:'A use case emits an event', emitsSentence:(a, b, cond) => cond ? `${a} may emit ${b}, under a condition.` : `${a} emits ${b}.`,
317
+ kindUseCase:'Use case', kindEvent:'Event', kindSchedule:'Schedule', consumedWhy:'It was there and stays as it is: what changes uses it.',
318
+ emittedBy:'Emitted by', triggeredBy:'Triggered by', calledBy:'Called by', emitsL:'Emits', triggersL:'Triggers', callsL:'Calls',
319
+ noRelations:'No relation with other pieces.', untouched:'The change leaves this module as it is.', touches:'What the change touches',
320
+ restUnchanged:(n, more) => `${more ? 'And ' : ''}${n} ${more ? 'more ' : ''}piece${n === 1 ? '' : 's'} unchanged.`,
321
+ newModules:n => `new module${n === 1 ? '' : 's'}`, modifiedModules:() => 'modified', consumedModules:() => 'consumed', newRelations:n => `new relation${n === 1 ? '' : 's'}`,
322
+ newPieces:n => `${n} new piece${n === 1 ? '' : 's'} among use cases, events and schedules.`, removedPieces:n => `${n} removed.`,
323
+ noRules:'Nothing checked yet.', plan:'A feature plan', review:'A change under review', map:'The application as it is',
324
+ steps:'The steps', checks:'Against the plan', savePlan:'Save plan', loadPlan:'Load plan', saved:'Saved.', notSaved:'Not saved.',
325
+ notAPlan:'That file is not a plan: ', loaded:n => n ? `${n} thing${n === 1 ? '' : 's'} to look at.` : 'The branch does what the plan says.',
326
+ stateDone:'done', statePartial:(d, o) => `${d} of ${o}`, stateNotStarted:'not started', stateNothingToCheck:'nothing to check',
327
+ done:'done', missing:'missing', different:'different', unplanned:'not planned',
328
+ },
329
+ es: {
330
+ summary:'Resumen', changes:'Cambios', story:'La historia', rules:'Reglas revisadas', impact:'Impacto', storyShort:'Historia',
331
+ n:'nuevo', m:'modificado', u:'consumido', o:'sin cambios', x:'eliminado', existing:'existente',
332
+ useCase:'caso de uso', eventSchedule:'⚡ evento · ⏱ tarea', emits:'emite', triggers:'evento dispara', calls:'llama a',
333
+ canvas:'Lienzo', lanes:'Carriles', viewMode:'Vista del diagrama', onlyAffected:'Solo lo afectado', zoomOut:'Alejar', fit:'Ajustar', zoomIn:'Acercar',
334
+ minimap:'Minimapa: clic o arrastra para moverte', hint:'Arrastra para mover · rueda o pellizco para zoom',
335
+ moduleDetail:'Ver el detalle del módulo', port:'puerto', more:n => `+${n} sin cambios`,
336
+ selected:'Seleccionado:', andEmits:'y lo que emite', andEmitter:'y quien lo emite', previous:'Paso anterior', next:'Paso siguiente',
337
+ highlighted:n => `Resaltado: Paso ${n}`, clear:'quitar', highlightStep:n => `Resaltar el paso ${n}`, seeInDiagram:'Ver en el diagrama →',
338
+ scheduleTriggers:'Tarea programada dispara caso de uso', eventTriggers:'Evento dispara caso de uso', triggersSentence:(a, b) => `${a} dispara ${b}`, queue:'Cola',
339
+ callBetween:'Llamada entre módulos', callsSentence:(a, b) => `${a} llama a ${b}`,
340
+ useCaseEmits:'Caso de uso emite evento', emitsSentence:(a, b, cond) => cond ? `${a} puede emitir ${b}, bajo una condición.` : `${a} emite ${b}.`,
341
+ kindUseCase:'Caso de uso', kindEvent:'Evento', kindSchedule:'Tarea programada', consumedWhy:'Ya existía. No cambia: lo nuevo lo usa.',
342
+ emittedBy:'Lo emite', triggeredBy:'Lo dispara', calledBy:'Lo llama', emitsL:'Emite', triggersL:'Dispara', callsL:'Llama a',
343
+ noRelations:'Sin relaciones con otras piezas.', untouched:'El cambio no toca este módulo.', touches:'Lo que toca el cambio',
344
+ restUnchanged:(n, more) => `${more ? 'Y ' : ''}${n} pieza${n === 1 ? '' : 's'} ${more ? 'más ' : ''}sin cambios.`,
345
+ newModules:n => `módulo${n === 1 ? '' : 's'} nuevo${n === 1 ? '' : 's'}`, modifiedModules:n => `modificado${n === 1 ? '' : 's'}`, consumedModules:n => `consumido${n === 1 ? '' : 's'}`, newRelations:n => `relaci${n === 1 ? 'ón nueva' : 'ones nuevas'}`,
346
+ newPieces:n => `${n} pieza${n === 1 ? '' : 's'} nueva${n === 1 ? '' : 's'} entre casos de uso, eventos y tareas programadas.`, removedPieces:n => `${n} eliminada${n === 1 ? '' : 's'}.`,
347
+ noRules:'Nada revisado todavía.', plan:'Plan de una funcionalidad', review:'Revisión de un cambio', map:'La aplicación tal como está',
348
+ steps:'Los pasos', checks:'Frente al plan', savePlan:'Guardar plan', loadPlan:'Cargar plan', saved:'Guardado.', notSaved:'No se ha guardado.',
349
+ notAPlan:'Ese archivo no es un plan: ', loaded:n => n ? `${n} cosa${n === 1 ? '' : 's'} que mirar.` : 'La rama hace lo que dice el plan.',
350
+ stateDone:'completo', statePartial:(d, o) => `a medias: ${d} de ${o}`, stateNotStarted:'sin empezar', stateNothingToCheck:'nada que comprobar',
351
+ done:'cumplido', missing:'falta', different:'distinto', unplanned:'no planeado',
352
+ },
353
+ };
354
+ const T = WORDS[DATA.language] || WORDS.en;
355
+ const CTX = DATA.contexts, M = DATA.modules, E = DATA.relations;
356
+ document.documentElement.lang = DATA.language;
357
+ document.title = DATA.title;
358
+ document.getElementById('title').textContent = DATA.title;
359
+ document.getElementById('subtitle').textContent = DATA.subtitle || T[DATA.mode];
360
+ if (E.some(e => e[3] === 'x') || Object.values(M).some(d => d.st === 'del')) document.body.classList.add('removes');
361
+ const IS_MAP = DATA.mode === 'map';
362
+ if (IS_MAP) document.body.classList.add('map');
363
+
364
+ /* ---------- derived ---------- */
365
+ const ITEM = {};
366
+ for (const [m, d] of Object.entries(M)) for (const [n,k,s,x] of d.items) ITEM[m+'.'+n] = {mod:m, name:n, kind:k, st:s, x:x||{}, id:m+'.'+n};
367
+ const modOf = id => id.split('.')[0];
368
+ /* relations: emit (use case → the event it emits) · ev (event → use case) · call (use case → use case) */
369
+ const REL = E.map(([kind, from, to, st, why, q, cond], i) => ({id:'r'+i, kind, from, to, st, why, q, cond}));
370
+ const RID = Object.fromEntries(REL.map(r=>[r.id,r]));
371
+ const STL = {new:T.n, mod:T.m, use:T.u, old:T.o, del:T.x, n:T.n, m:T.m, u:T.u, o:T.o, x:T.x};
372
+ const KIND = {uc:'', ev:'⚡', sch:'⏱'};
373
+ const $ = id => document.getElementById(id);
374
+
375
+ /* ---------- view ---------- */
376
+ let only = !IS_MAP, zoom = null, openMod = null, selItem = null, pinned = null, pinStep = -1;
377
+ let mode = 'cv'; /* cv canvas (the default) · ln lanes */
378
+ try { const s = localStorage.getItem('newt-plan-mode'); if (s==='ln' || s==='cv') mode = s; } catch (e) {}
379
+ const cam = {x:0, y:0, z:1}; let camReady = false;
380
+ function view(){
381
+ const rel = REL.filter(r => !only || r.st!=='o');
382
+ const touched = new Set(); rel.forEach(r=>{touched.add(r.from);touched.add(r.to);});
383
+ const mods = {};
384
+ for (const [m,d] of Object.entries(M)) {
385
+ const all = d.items.map(i=>ITEM[m+'.'+i[0]]);
386
+ const vis = only ? all.filter(it => it.st!=='o' || touched.has(it.id)) : all;
387
+ if (!vis.length) continue;
388
+ mods[m] = {nodes: vis.filter(it=>touched.has(it.id)), loose: vis.filter(it=>!touched.has(it.id)), hidden: all.length - vis.length};
389
+ }
390
+ return {mods, rel};
391
+ }
392
+ /* ---------- sizes ---------- */
393
+ const mc = document.createElement('canvas').getContext('2d');
394
+ const MONO = getComputedStyle(document.documentElement).getPropertyValue('--mono');
395
+ const SANS = getComputedStyle(document.documentElement).getPropertyValue('--sans');
396
+ function tw(txt, font){ mc.font = font; return mc.measureText(txt).width; }
397
+ const IH = 28, LH = 22, HEAD = 36;
398
+ const itemW = it => Math.ceil(tw(it.name, `${it.st==='n'||it.st==='m'?600:400} 13px ${MONO}`)) + 22 + (it.kind==='uc'?0:16);
399
+ const looseW = it => Math.ceil(tw(it.name, `11.5px ${MONO}`)) + 18 + (it.kind==='uc'?0:16);
400
+ /* ---------- elements ---------- */
401
+ const stage = $('stage'), sizer = $('sizer'), eg = $('eg'), svg = $('edges'), wrap = $('wrap');
402
+ const itEl = {}, modEl = {}, ctxEl = {};
403
+ for (const [c,label] of Object.entries(CTX)) { const d=document.createElement('div'); d.className='ctx gone'; d.innerHTML=`<b>${esc(label)}</b>`; stage.insertBefore(d, svg); ctxEl[c]=d; }
404
+ for (const [m,d] of Object.entries(M)) {
405
+ const el = document.createElement('div'); el.className = 'mod gone '+(d.st==='mod'?'mod-':d.st); el.dataset.m = m;
406
+ el.innerHTML = `<button class="mh" title="${T.moduleDetail}"><span class="l1"><span class="nm">${m}</span>${d.port?`<small>${T.port}</small>`:''}</span><span class="l2"><span class="badge">${STL[d.st]}</span><span class="more"></span></span></button>`;
407
+ el.querySelector('.mh').addEventListener('click', ev=>{ ev.stopPropagation(); openDetail(openMod===m?null:m); });
408
+ stage.insertBefore(el, svg); modEl[m] = el;
409
+ }
410
+ for (const it of Object.values(ITEM)) {
411
+ const el = document.createElement('div'); el.className = `it ${it.kind} ${it.st} gone`; el.dataset.id = it.id; el.tabIndex = 0;
412
+ el.innerHTML = (KIND[it.kind]?`<span class="g">${KIND[it.kind]}</span>`:'')+`<span class="t">${it.name}</span>`;
413
+ el.addEventListener('mouseenter', ev=>tipItem(ev, it.id)); el.addEventListener('mousemove', moveTip); el.addEventListener('mouseleave', hideTip);
414
+ const pick = ev=>{ ev.stopPropagation(); selItem = selItem===it.id ? null : it.id; setPin(-1, true); render(); focusView(false); };
415
+ el.addEventListener('click', pick); el.addEventListener('keydown', ev=>{ if (ev.key==='Enter'||ev.key===' ') { ev.preventDefault(); pick(ev); } });
416
+ stage.appendChild(el); itEl[it.id] = el;
417
+ }
418
+ /* ---------- lanes: a row per module, columns from cause to effect ---------- */
419
+ const COLGAP = 92, ROWGAP = 12, LPADY = 14, LANEGAP = 10, CTXPAD = 10, CTXHEAD = 28, CTXGAP = 14, LBLPAD = 12, LBLH = 54;
420
+ let laying = 0, SW = 1, SH = 1, POS = {}, BOX = {}, GEO = null, COLS = {x:[],w:[]}, lastSize = [0,0], whole = false;
421
+ function labelW(m, hidden){
422
+ const l1 = tw(m, `700 15px ${SANS}`) + (M[m].port ? 48 : 0);
423
+ const l2 = tw(STL[M[m].st].toUpperCase(), `700 9.5px ${SANS}`) + 20 + (hidden ? tw(T.more(hidden), `11px ${SANS}`) + 8 : 0);
424
+ return Math.ceil(Math.max(l1, l2)) + 2*LBLPAD + 6;
425
+ }
426
+ function layoutLanes(v){
427
+ const names = Object.keys(v.mods);
428
+ const aw = (wrap.clientWidth || innerWidth) - 16, ah = (wrap.clientHeight || innerHeight - 110) - 16;
429
+ /* 1 · columns: the longest path; a source sits next to its first effect */
430
+ const preds = {}, succs = {}, node = new Set();
431
+ for (const mv of Object.values(v.mods)) for (const it of mv.nodes) { node.add(it.id); preds[it.id] = []; succs[it.id] = []; }
432
+ for (const r of v.rel) { succs[r.from].push(r.to); preds[r.to].push(r.from); }
433
+ const layer = {}, busy = new Set();
434
+ const lay = id => { if (id in layer) return layer[id]; if (busy.has(id)) return 0; busy.add(id);
435
+ const l = preds[id].length ? Math.max(...preds[id].map(lay)) + 1 : 0; busy.delete(id); return layer[id] = l; };
436
+ node.forEach(lay);
437
+ node.forEach(id => { if (!preds[id].length && succs[id].length) layer[id] = Math.min(...succs[id].map(s=>layer[s])) - 1; });
438
+ const nL = Math.max(0, ...Object.values(layer)) + 1;
439
+ /* 2 · the lanes' order: the shortest vertical distance of the relations (new ones weigh more) */
440
+ const cross = v.rel.filter(r=>modOf(r.from)!==modOf(r.to)).map(r=>[modOf(r.from), modOf(r.to), r.st==='n'?3:1]);
441
+ const cost = ord => { const ix = {}; ord.forEach((m,i)=>ix[m]=i); let c = 0; for (const [a,b,w] of cross) c += w*Math.abs(ix[a]-ix[b]); return c; };
442
+ const ctxs = Object.keys(CTX).filter(c=>names.some(m=>M[m].ctx===c));
443
+ const perms = a => a.length<2 ? [a] : a.flatMap((x,i)=>perms([...a.slice(0,i),...a.slice(i+1)]).map(p=>[x,...p]));
444
+ let best = null, bestC = Infinity;
445
+ for (const cp of perms(ctxs)) {
446
+ const groups = cp.map(c=>names.filter(m=>M[m].ctx===c)); let cur = cost(groups.flat()), improved = true;
447
+ while (improved) { improved = false;
448
+ for (const g of groups) for (let i=0;i<g.length;i++) for (let j=0;j<g.length;j++) { if (i===j) continue;
449
+ const x = g.splice(i,1)[0]; g.splice(j,0,x); const c = cost(groups.flat());
450
+ if (c < cur) { cur = c; improved = true; } else { g.splice(j,1); g.splice(i,0,x); } } }
451
+ if (cur < bestC) { bestC = cur; best = groups.map(g=>[...g]); best.ctx = cp; }
452
+ }
453
+ const order = best.flat(), lane = {}; order.forEach((m,i)=>lane[m]=i);
454
+ /* 3 · rows in a lane: an event on its emitter's row; the rest, towards where its relations pull */
455
+ const nb = id => [...preds[id], ...succs[id]];
456
+ const pull = id => { const own = lane[modOf(id)], seen = new Set([id]), q = [id], far = [];
457
+ while (q.length) { const x = q.pop(); for (const y of nb(x)) { if (seen.has(y)) continue; seen.add(y); if (modOf(y)===modOf(id)) { if (seen.size < 12) q.push(y); } else far.push(lane[modOf(y)] - own); } }
458
+ return far.length ? far.reduce((a,b)=>a+b,0)/far.length : 0; };
459
+ const row = {}, rows = {};
460
+ for (const m of order) {
461
+ const its = v.mods[m].nodes, key = {}; its.forEach((it,i)=>key[it.id] = pull(it.id)*100 + (it.st==='o'?1:0)*10 + i/100);
462
+ let maxRow = 0; const usedAt = {};
463
+ for (let l=0;l<nL;l++) {
464
+ const cell = its.filter(it=>layer[it.id]===l).sort((a,b)=>key[a.id]-key[b.id]), used = usedAt[l] = new Set(), later = [];
465
+ for (const it of cell) { const p = v.rel.find(r=>r.kind==='emit' && r.to===it.id);
466
+ if (p && row[p.from]!==undefined && !used.has(row[p.from])) { row[it.id] = row[p.from]; used.add(row[it.id]); } else later.push(it); }
467
+ for (const it of later) { let r = 0;
468
+ /* a use case that emits keeps its row in the next column too */
469
+ const emits = v.rel.some(x=>x.kind==='emit' && x.from===it.id);
470
+ while (used.has(r) || (emits && its.some(o=>layer[o.id]===l+1 && row[o.id]===r))) r++;
471
+ row[it.id] = r; used.add(r); }
472
+ maxRow = Math.max(maxRow, ...used, 0);
473
+ }
474
+ rows[m] = its.length ? maxRow + 1 : 0;
475
+ }
476
+ /* 4 · geometry */
477
+ const hasLoose = names.some(m=>v.mods[m].loose.length);
478
+ const LBL = Math.max(hasLoose ? 236 : 150, ...names.map(m=>labelW(m, v.mods[m].hidden)));
479
+ const colW = Array(nL).fill(0); for (const id of node) colW[layer[id]] = Math.max(colW[layer[id]], itemW(ITEM[id]));
480
+ const loose = {}, looseH = {};
481
+ for (const m of names) { let x = 0, y = 0; for (const it of v.mods[m].loose) { const w = looseW(it); if (x && x + w > LBL - 2*LBLPAD) { x = 0; y += LH + 5; } loose[it.id] = {x, y, w}; x += w + 5; }
482
+ looseH[m] = v.mods[m].loose.length ? y + LH + 12 : 0; }
483
+ const laneH = m => Math.max(LBLH + looseH[m], rows[m] ? 2*LPADY + rows[m]*IH + (rows[m]-1)*ROWGAP : 0);
484
+ let y = 0; const laneY = {}, ctxBox = {};
485
+ best.forEach((g,i)=>{ const top = y; y += CTXHEAD; for (const m of g) { laneY[m] = y; y += laneH(m) + LANEGAP; } y += CTXPAD - LANEGAP; ctxBox[best.ctx[i]] = {top, h:y-top}; y += CTXGAP; });
486
+ SH = y - CTXGAP;
487
+ const x0 = CTXPAD + LBL + 30, base = x0 + colW.reduce((a,b)=>a+b,0) + 30 + CTXPAD;
488
+ let gap = COLGAP; /* a screen wider than the diagram opens the columns up */
489
+ if (nL > 1) gap = only ? Math.max(COLGAP, Math.min(230, (SH*aw/ah - base)/(nL-1))) : Math.max(120, Math.min(200, (aw - base)/(nL-1)));
490
+ const colX = []; let x = x0; for (let l=0;l<nL;l++) { colX[l] = x; x += colW[l] + gap; }
491
+ SW = base + gap*(nL-1);
492
+ /* 5 · the geometry */
493
+ const G = {W:base + gap*(nL-1), H:SH, ctx:{}, mod:{}, item:{}, cols:{x:colX, w:colW}, lbl:LBL};
494
+ for (const c of Object.keys(ctxBox)) G.ctx[c] = {x:0, y:ctxBox[c].top, w:G.W, h:ctxBox[c].h};
495
+ for (const m of names) G.mod[m] = {x:CTXPAD, y:laneY[m], w:G.W - 2*CTXPAD, h:laneH(m)};
496
+ for (const id of node) G.item[id] = {x:colX[layer[id]], y:laneY[modOf(id)] + LPADY + row[id]*(IH+ROWGAP), w:itemW(ITEM[id]), h:IH, l:layer[id]};
497
+ for (const [id,p] of Object.entries(loose)) G.item[id] = {x:CTXPAD + LBLPAD + p.x, y:laneY[modOf(id)] + LBLH + p.y, w:p.w, h:LH, loose:true};
498
+ return G;
499
+ }
500
+ /* ---------- canvas: modules as boxes in 2D, grouped by context ---------- */
501
+ const CV = {head:50, pad:14, gx:56, modGX:96, modGY:34, ctxPad:22, ctxHead:34, ctxGap:72};
502
+ function eades(nodes, w){ /* a linear order that leaves few relations going backwards */
503
+ const left = [], right = [], rest = new Set(nodes);
504
+ const deg = n => { let o = 0, i = 0; for (const m of rest) { if (m===n) continue; o += w[n+'>'+m]||0; i += w[m+'>'+n]||0; } return [o,i]; };
505
+ while (rest.size) { let pickN = null, bestD = -Infinity, sink = null, src = null;
506
+ for (const n of rest) { const [o,i] = deg(n); if (!o && i && !sink) sink = n; if (!i && !src) src = n; if (o-i > bestD) { bestD = o-i; pickN = n; } }
507
+ if (src) { left.push(src); rest.delete(src); } else if (sink) { right.unshift(sink); rest.delete(sink); } else { left.push(pickN); rest.delete(pickN); } }
508
+ return [...left, ...right];
509
+ }
510
+ function layoutCanvas(v){
511
+ const names = Object.keys(v.mods), ctxs = Object.keys(CTX).filter(c=>names.some(m=>M[m].ctx===c));
512
+ const mw = {}, cw = {};
513
+ for (const r of v.rel) { const a = modOf(r.from), b = modOf(r.to), k = r.st==='n' ? 3 : 1; if (a===b) continue;
514
+ if (M[a].ctx===M[b].ctx) mw[a+'>'+b] = (mw[a+'>'+b]||0) + k; else cw[M[a].ctx+'>'+M[b].ctx] = (cw[M[a].ctx+'>'+M[b].ctx]||0) + k; }
515
+ const ctxOrder = eades(ctxs, cw);
516
+ /* each module's rank (column) in its context */
517
+ const rank = {}, gx = {};
518
+ for (const c of ctxOrder) { const ms = names.filter(m=>M[m].ctx===c), ord = eades(ms, mw), ix = {}; ord.forEach((m,i)=>ix[m]=i);
519
+ for (const m of ord) { let r = 0; for (const p of ord) if (ix[p] < ix[m] && mw[p+'>'+m]) r = Math.max(r, rank[p]+1); rank[m] = r; }
520
+ for (const m of ms) gx[m] = ctxOrder.indexOf(c)*100 + rank[m]; }
521
+ /* inside a module: columns by its own relations; an event on its emitter's row */
522
+ const inner = {};
523
+ for (const m of names) {
524
+ const its = v.mods[m].nodes, ids = new Set(its.map(i=>i.id)), intra = v.rel.filter(r=>ids.has(r.from) && ids.has(r.to));
525
+ const col = {}, busy = new Set();
526
+ const lay = id => { if (id in col) return col[id]; if (busy.has(id)) return 0; busy.add(id);
527
+ const ps = intra.filter(r=>r.to===id).map(r=>r.from); const l = ps.length ? Math.max(...ps.map(lay)) + 1 : 0; busy.delete(id); return col[id] = l; };
528
+ its.forEach(i=>lay(i.id));
529
+ for (const i of its) { const out = intra.filter(r=>r.from===i.id); if (!intra.some(r=>r.to===i.id) && out.length) col[i.id] = Math.min(...out.map(r=>col[r.to])) - 1; }
530
+ const nC = Math.max(0, ...Object.values(col)) + 1;
531
+ for (const i of its) if (!intra.some(r=>r.from===i.id || r.to===i.id)) { /* relations with other modules only: on the side they come from */
532
+ const far = v.rel.filter(r=>r.from===i.id || r.to===i.id).map(r=>gx[modOf(r.from===i.id ? r.to : r.from)] - gx[m]);
533
+ const avg = far.reduce((a,b)=>a+b,0)/(far.length||1); col[i.id] = avg > 0 ? nC-1 : 0; }
534
+ const row = {}; let nR = 0;
535
+ for (let c=0;c<nC;c++) { const used = new Set(), later = [];
536
+ for (const i of its.filter(i=>col[i.id]===c)) { const p = intra.find(r=>r.kind==='emit' && r.to===i.id);
537
+ if (p && row[p.from]!==undefined && !used.has(row[p.from])) { row[i.id] = row[p.from]; used.add(row[i.id]); } else later.push(i); }
538
+ for (const i of later) { let r = 0; const emits = intra.some(x=>x.kind==='emit' && x.from===i.id);
539
+ while (used.has(r) || (emits && its.some(o=>col[o.id]===c+1 && row[o.id]===r))) r++; row[i.id] = r; used.add(r); }
540
+ nR = Math.max(nR, ...[...used].map(r=>r+1), 0); }
541
+ const colW = Array(nC).fill(0); for (const i of its) colW[col[i.id]] = Math.max(colW[col[i.id]], itemW(i));
542
+ const colX = []; let x = CV.pad; for (let c=0;c<nC;c++) { colX[c] = x; x += colW[c] + CV.gx; }
543
+ const gridW = its.length ? x - CV.gx + CV.pad : 0, gridH = nR ? CV.pad + nR*IH + (nR-1)*ROWGAP : 0;
544
+ const w = Math.max(labelW(m, v.mods[m].hidden) + 10, gridW, v.mods[m].loose.length ? 236 : 0);
545
+ const lp = {}; let lx = 0, ly = 0; for (const i of v.mods[m].loose) { const iw = looseW(i); if (lx && lx + iw > w - 2*CV.pad) { lx = 0; ly += LH + 5; } lp[i.id] = {x:lx, y:ly, w:iw}; lx += iw + 5; }
546
+ const looseH = v.mods[m].loose.length ? ly + LH : 0, looseY = CV.head + gridH + (looseH ? 12 : 0);
547
+ inner[m] = {w, h:looseY + looseH + CV.pad, col, row, colX, lp, looseY};
548
+ }
549
+ /* modules in a context: columns by rank, rows by barycenter */
550
+ const box = {}, G = {W:0, H:0, ctx:{}, mod:{}, item:{}}, ctxSize = {};
551
+ for (const c of ctxOrder) { const ms = names.filter(m=>M[m].ctx===c), nR = Math.max(...ms.map(m=>rank[m])) + 1;
552
+ const cols = Array.from({length:nR}, (_,r)=>ms.filter(m=>rank[m]===r));
553
+ const place = () => { let x = 0; const hs = cols.map(col=>col.reduce((a,m)=>a+inner[m].h, 0) + (col.length-1)*CV.modGY), H = Math.max(...hs);
554
+ cols.forEach((col,i)=>{ const cwid = Math.max(...col.map(m=>inner[m].w)); let y = (H - hs[i])/2; for (const m of col) { box[m] = {x, y, w:inner[m].w, h:inner[m].h}; y += inner[m].h + CV.modGY; } x += cwid + CV.modGX; });
555
+ ctxSize[c] = {w:x - CV.modGX, h:H}; };
556
+ place();
557
+ for (let s=0;s<6;s++) { for (const col of cols) { const key = {};
558
+ for (const m of col) { let sum = 0, n = 0; for (const o of ms) { const k = (mw[m+'>'+o]||0) + (mw[o+'>'+m]||0); if (k && rank[o]!==rank[m]) { sum += k*(box[o].y + box[o].h/2); n += k; } } key[m] = n ? sum/n : box[m].y + box[m].h/2; }
559
+ col.sort((a,b)=>key[a]-key[b]); } place(); }
560
+ }
561
+ const Hmax = Math.max(...ctxOrder.map(c=>ctxSize[c].h)) + CV.ctxHead + CV.ctxPad; let cx = 0;
562
+ for (const c of ctxOrder) { const w = ctxSize[c].w + 2*CV.ctxPad, h = ctxSize[c].h + CV.ctxHead + CV.ctxPad, y = (Hmax - h)/2;
563
+ G.ctx[c] = {x:cx, y, w, h};
564
+ for (const m of names.filter(m=>M[m].ctx===c)) { const b = box[m], I = inner[m], X = cx + CV.ctxPad + b.x, Y = y + CV.ctxHead + b.y;
565
+ G.mod[m] = {x:X, y:Y, w:b.w, h:b.h};
566
+ for (const i of v.mods[m].nodes) G.item[i.id] = {x:X + I.colX[I.col[i.id]], y:Y + CV.head + CV.pad + I.row[i.id]*(IH+ROWGAP), w:itemW(i), h:IH};
567
+ for (const [id,p] of Object.entries(I.lp)) G.item[id] = {x:X + CV.pad + p.x, y:Y + I.looseY + p.y, w:p.w, h:LH, loose:true}; }
568
+ cx += w + CV.ctxGap; }
569
+ G.W = cx - CV.ctxGap; G.H = Hmax;
570
+ return G;
571
+ }
572
+ function layout(){
573
+ const my = ++laying, v = view(), cv = mode==='cv';
574
+ lastSize = [(wrap.clientWidth || innerWidth) - 16, (wrap.clientHeight || innerHeight - 110) - 16];
575
+ const G = GEO = cv ? layoutCanvas(v) : layoutLanes(v);
576
+ SW = G.W; SH = G.H; POS = {}; BOX = G.item; COLS = G.cols || {x:[],w:[]};
577
+ $('view').classList.toggle('cv', cv);
578
+ const put = (el, g) => { el.classList.remove('gone'); Object.assign(el.style, {left:g.x+'px', top:g.y+'px', width:g.w+'px', height:g.h+'px'}); };
579
+ for (const [c,el] of Object.entries(ctxEl)) { if (G.ctx[c]) put(el, G.ctx[c]); else el.classList.add('gone'); }
580
+ for (const [m,el] of Object.entries(modEl)) {
581
+ if (!G.mod[m]) { el.classList.add('gone'); continue; }
582
+ put(el, G.mod[m]); el.querySelector('.mh').style.width = cv ? '' : G.lbl+'px';
583
+ const h = v.mods[m].hidden; el.querySelector('.more').textContent = h ? T.more(h) : '';
584
+ }
585
+ for (const [id,el] of Object.entries(itEl)) { const g = G.item[id];
586
+ if (!g) { el.classList.add('gone'); continue; }
587
+ el.classList.toggle('loose', !!g.loose); put(el, g);
588
+ if (!g.loose) POS[id] = {id, x:g.x, y:g.y+IH/2, w:g.w, l:g.l}; }
589
+ stage.style.width = SW+'px'; stage.style.height = SH+'px';
590
+ svg.setAttribute('width', SW); svg.setAttribute('height', SH);
591
+ svg.classList.add('fade');
592
+ setTimeout(()=>{ if (my!==laying) return; drawEdges(v.rel); svg.classList.remove('fade'); render(); }, eg.childElementCount ? 480 : 0);
593
+ zoom = null; whole = false;
594
+ if (cv) { wrap.scrollTop = wrap.scrollLeft = 0; sizer.style.width = sizer.style.height = ''; render(); camReady = wrap.clientWidth > 0; if (!focusView(true)) fitAll(); }
595
+ else { fit(); render(); }
596
+ }
597
+ /* relations: curves; one that jumps columns takes the route over fewest chips */
598
+ const S = (xa,ya,xb,yb) => { const dx = Math.max(Math.min(28, Math.abs(xb-xa)), (xb-xa)*.5); return [xa,ya, xa+dx,ya, xb-dx,yb, xb,yb]; };
599
+ const Ln = (xa,ya,xb,yb) => [xa,ya, xa,ya, xb,yb, xb,yb];
600
+ const B3 = (a,b,c,d,t) => { const u = 1-t; return u*u*u*a + 3*u*u*t*b + 3*u*t*t*c + t*t*t*d; };
601
+ function hits(segs, rects){ let n = 0;
602
+ for (const s of segs) for (let i=1;i<24;i++) { const t = i/24, x = B3(s[0],s[2],s[4],s[6],t), y = B3(s[1],s[3],s[5],s[7],t);
603
+ for (const r of rects) if (x > r.x-4 && x < r.x+r.w+4 && y > r.y-IH/2-3 && y < r.y+IH/2+3) { n++; break; } }
604
+ return n; }
605
+ const toD = segs => `M${segs[0][0]} ${segs[0][1]}` + segs.map(s => s[0]===s[2] && s[1]===s[3] && s[4]===s[6] ? `L${s[6]} ${s[7]}` : `C${s[2]} ${s[3]} ${s[4]} ${s[5]} ${s[6]} ${s[7]}`).join('');
606
+ function hitsBox(segs, boxes){ let n = 0;
607
+ for (const s of segs) for (let i=1;i<28;i++) { const t = i/28, x = B3(s[0],s[2],s[4],s[6],t), y = B3(s[1],s[3],s[5],s[7],t);
608
+ for (const r of boxes) if (x > r.x-8 && x < r.x+r.w+8 && y > r.y-8 && y < r.y+r.h+8) { n += r.k; break; } }
609
+ return n; }
610
+ function cvPath(a, b, emit){ /* on the canvas a relation may go backwards: it enters on the nearest side and goes around other modules */
611
+ const ar = a.x + a.w, br = b.x + b.w, k = emit ? 0 : 3;
612
+ let sx, ex, ds, de;
613
+ if (b.x >= ar + 12) { sx = ar; ex = b.x - k; ds = de = 1; }
614
+ else if (br <= a.x - 12) { sx = a.x; ex = br + k; ds = de = -1; }
615
+ else { const dx = 36 + Math.abs(a.y-b.y)*.12, mx = Math.max(ar, br); return `M${ar} ${a.y}C${mx+dx} ${a.y} ${mx+dx} ${b.y} ${br+k} ${b.y}`; }
616
+ if (Math.abs(a.y-b.y) < 1) return `M${sx} ${a.y}L${ex} ${b.y}`;
617
+ const d = Math.min(150, Math.max(26, Math.abs(ex-sx)*.5));
618
+ const direct = [[sx,a.y, sx+ds*d,a.y, ex-de*d,b.y, ex,b.y]];
619
+ const ma = modOf(a.id), mb = modOf(b.id); if (ma===mb) return toD(direct);
620
+ const boxes = Object.entries(GEO.mod).filter(([m])=>m!==ma && m!==mb).map(([m,g])=>({...g, k:1}));
621
+ const blocking = boxes.filter(g=>hitsBox(direct, [g]));
622
+ if (!blocking.length) return toD(direct);
623
+ const x0 = Math.min(...blocking.map(g=>g.x)), x1 = Math.max(...blocking.map(g=>g.x+g.w)), lo = Math.min(sx, ex) + 30, hi = Math.max(sx, ex) - 30;
624
+ const mx = Math.max(lo, Math.min(hi, (x0+x1)/2)), half = Math.min((x1-x0)/2 + 20, Math.abs(mx-sx) - 10, Math.abs(ex-mx) - 10);
625
+ let best = direct, bestS = hitsBox(direct, boxes)*1000;
626
+ for (const wy of [Math.min(...blocking.map(g=>g.y)) - 22, Math.max(...blocking.map(g=>g.y+g.h)) + 22]) {
627
+ const h = Math.max(10, half), d1 = Math.min(120, Math.max(20, (Math.abs(mx-sx)-h)*.6)), d2 = Math.min(120, Math.max(20, (Math.abs(ex-mx)-h)*.6));
628
+ const c = [[sx,a.y, sx+ds*d1,a.y, mx-ds*h-ds*d1,wy, mx-ds*h,wy], [mx-ds*h,wy, mx-ds*h,wy, mx+ds*h,wy, mx+ds*h,wy], [mx+ds*h,wy, mx+ds*h+ds*d2,wy, ex-de*d2,b.y, ex,b.y]];
629
+ const s = hitsBox(c, boxes)*1000 + Math.abs(wy-a.y) + Math.abs(wy-b.y);
630
+ if (s < bestS) { bestS = s; best = c; } }
631
+ return toD(best);
632
+ }
633
+ function drawEdges(rels){
634
+ let h = ''; const all = Object.values(POS);
635
+ for (const r of [...rels].sort((a,b)=>(a.st!=='o')-(b.st!=='o'))) {
636
+ const a = POS[r.from], b = POS[r.to]; if (!a || !b) continue;
637
+ if (mode==='cv') { const d = cvPath(a, b, r.kind==='emit');
638
+ h += `<path class="e ${r.st} ${r.kind}" data-e="${r.id}" d="${d}"${r.kind==='emit'?'':` marker-end="url(#a-${r.st})"`}/><path class="hit" data-e="${r.id}" d="${d}"/>`; continue; }
639
+ const x1 = a.x + a.w, x2 = b.x - (r.kind==='emit' ? 0 : 3);
640
+ const e1 = COLS.x[a.l] + COLS.w[a.l]; /* the curve starts at the column's end, clear of wider chips */
641
+ let segs = Math.abs(a.y-b.y) < 1 ? [Ln(x1,a.y,x2,b.y)] : e1 - x1 > 8 ? [Ln(x1,a.y,e1,a.y), S(e1,a.y,x2,b.y)] : [S(x1,a.y,x2,b.y)];
642
+ if (b.l - a.l > 1) {
643
+ const rects = all.filter(p=>p!==a && p!==b), g1 = COLS.x[a.l] + COLS.w[a.l], g2 = COLS.x[b.l-1] + COLS.w[b.l-1];
644
+ const cands = [segs, [Ln(x1,a.y,g1,a.y), S(g1,a.y,COLS.x[a.l+1]-6,b.y), Ln(COLS.x[a.l+1]-6,b.y,x2,b.y)], [Ln(x1,a.y,g2+6,a.y), S(g2+6,a.y,x2,b.y)]];
645
+ let bestN = Infinity; for (const c of cands) { const n = hits(c, rects); if (n < bestN) { bestN = n; segs = c; } }
646
+ }
647
+ const d = toD(segs);
648
+ h += `<path class="e ${r.st} ${r.kind}" data-e="${r.id}" d="${d}"${r.kind==='emit'?'':` marker-end="url(#a-${r.st})"`}/><path class="hit" data-e="${r.id}" d="${d}"/>`;
649
+ }
650
+ eg.innerHTML = h;
651
+ eg.querySelectorAll('.hit').forEach(h=>{
652
+ h.addEventListener('mouseenter', ev=>tipRel(ev, RID[h.dataset.e])); h.addEventListener('mousemove', moveTip); h.addEventListener('mouseleave', hideTip);
653
+ });
654
+ }
655
+ document.querySelector('#edges defs').innerHTML = ['n','o','h','x'].map(s=>`<marker id="a-${s}" viewBox="0 0 10 10" refX="9" refY="5" markerUnits="userSpaceOnUse" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M1 1.5L9 5L1 8.5z" style="fill:var(--${s==='n'?'new':s==='o'?'old':s==='x'?'del':'quiet'})"/></marker>`).join('');
656
+ /* ---------- selection ---------- */
657
+ function linked(id){ /* a use case brings the events it emits; an event, whoever emits it */
658
+ const set = new Set([id]);
659
+ REL.forEach(r=>{ if (r.kind==='emit') { if (r.from===id) set.add(r.to); if (r.to===id) set.add(r.from); } });
660
+ return set;
661
+ }
662
+ function render(){
663
+ let core = null, rel = new Set(), ends = new Set();
664
+ if (selItem) { core = linked(selItem); REL.forEach(r=>{ if (core.has(r.from)||core.has(r.to)) rel.add(r.id); }); }
665
+ else if (pinned) { core = pinned; REL.forEach(r=>{ if (core.has(r.from)&&core.has(r.to)) rel.add(r.id); }); }
666
+ REL.forEach(r=>{ if (rel.has(r.id)) { ends.add(r.from); ends.add(r.to); } });
667
+ eg.querySelectorAll('.e').forEach(p=>{ const on = rel.has(p.dataset.e); p.classList.toggle('hl', !!core && on); p.classList.toggle('dim', !!core && !on);
668
+ if (p.hasAttribute('marker-end')) p.setAttribute('marker-end', `url(#a-${core && on && p.classList.contains('o') ? 'h' : p.classList.contains('n') ? 'n' : p.classList.contains('x') ? 'x' : 'o'})`); });
669
+ for (const [id,el] of Object.entries(itEl)) {
670
+ el.classList.toggle('sel', !!core && core.has(id)); el.classList.toggle('rel', !!core && !core.has(id) && ends.has(id)); el.classList.toggle('fade', !!core && !core.has(id) && !ends.has(id)); }
671
+ const mods = new Set([...(core||[]), ...ends].map(modOf));
672
+ for (const [m,el] of Object.entries(modEl)) el.classList.toggle('dim', !!core && !mods.has(m));
673
+ for (const [c,el] of Object.entries(ctxEl)) el.classList.toggle('dim', !!core && ![...mods].some(m=>M[m].ctx===c));
674
+ cap.hidden = !core;
675
+ if (selItem) { const it = ITEM[selItem], others = [...core].filter(x=>x!==selItem);
676
+ cap.innerHTML = `<div class="txt"><span class="lbl">${T.selected}</span> ${chip(selItem)}${others.length?` <span class="lbl">${it.kind==='uc'?T.andEmits:T.andEmitter}</span> ${others.map(chip).join(' ')}`:''}${it.x.why?` · ${esc(it.x.why)}`:''}</div><span class="nav"><button class="rm">${T.clear}</button></span>`; }
677
+ else if (pinned) { const i = pinStep;
678
+ cap.innerHTML = `<span class="num">${i+1}</span><div class="txt">${STORY[i].txt()}</div><span class="nav"><button class="sq" aria-label="${T.previous}" ${i?'':'disabled'}>‹</button><button class="sq" aria-label="${T.next}" ${i<STORY.length-1?'':'disabled'}>›</button>${T.highlighted(i+1)} · <button class="rm">${T.clear}</button></span>`;
679
+ const [p,n] = cap.querySelectorAll('.sq'); p.onclick = ()=>setPin(i-1); n.onclick = ()=>setPin(i+1); }
680
+ if (core) cap.querySelector('.rm').onclick = clearAll;
681
+ drawMini(core, ends);
682
+ }
683
+ function clearAll(){ selItem = null; setPin(-1); }
684
+ /* ---------- tooltips ---------- */
685
+ const tip = $('tip');
686
+ function moveTip(ev){ tip.style.left = Math.max(8, Math.min(ev.clientX+14, innerWidth-tip.offsetWidth-10))+'px'; tip.style.top = Math.max(8, Math.min(ev.clientY+16, innerHeight-tip.offsetHeight-10))+'px'; }
687
+ function hideTip(){ tip.classList.remove('on'); }
688
+ function showTip(ev, h){ tip.innerHTML = h; tip.classList.add('on'); moveTip(ev); }
689
+ const ref = id => `<code>${KIND[ITEM[id].kind]}${id}</code>`;
690
+ const tag = st => `<span class="tag ${st}">${st==='o'?T.existing:STL[st]}</span>`;
691
+ const queue = r => r.q || `${ITEM[r.to].name}.on.${ITEM[r.from].name}`;
692
+ function tipRel(ev, r){
693
+ const why = r.why ? `<div class="why">${esc(r.why)}</div>` : '';
694
+ if (r.kind==='ev') showTip(ev, `<div class="h">${ITEM[r.from].kind==='sch'?T.scheduleTriggers:T.eventTriggers}${tag(r.st)}</div>${T.triggersSentence(ref(r.from), ref(r.to))}<br>${T.queue} <code>${queue(r)}</code>${why}`);
695
+ else if (r.kind==='call') showTip(ev, `<div class="h">${T.callBetween}${tag(r.st)}</div>${T.callsSentence(ref(r.from), ref(r.to))}${M[modOf(r.to)].port?` (${T.port})`:''}${why}`);
696
+ else showTip(ev, `<div class="h">${T.useCaseEmits}${tag(r.st)}</div>${T.emitsSentence(ref(r.from), ref(r.to), r.cond)}${why}`);
697
+ }
698
+ const KL = {uc:T.kindUseCase, ev:T.kindEvent, sch:T.kindSchedule};
699
+ const list = (rs, side) => rs.map(r=>ref(r[side])).join(', ');
700
+ function tipItem(ev, id){
701
+ const it = ITEM[id], ins = REL.filter(r=>r.to===id), outs = REL.filter(r=>r.from===id), by = k => x => x.kind===k;
702
+ let h = `<div class="h">${KL[it.kind]} <code>${id}</code>${tag(it.st)}</div>`;
703
+ if (it.x.why) h += `<div>${esc(it.x.why)}</div>`;
704
+ else if (it.st==='u') h += `<div>${T.consumedWhy}</div>`;
705
+ if (it.x.cron) h += `<div>cron <code>${esc(it.x.cron)}</code></div>`;
706
+ const rows = [[T.emittedBy, ins.filter(by('emit')), 'from'], [T.triggeredBy, ins.filter(by('ev')), 'from'], [T.calledBy, ins.filter(by('call')), 'from'],
707
+ [T.emitsL, outs.filter(by('emit')), 'to'], [T.triggersL, outs.filter(by('ev')), 'to'], [T.callsL, outs.filter(by('call')), 'to']];
708
+ for (const [l, rs, side] of rows) if (rs.length) h += `<div>${l}: ${list(rs, side)}</div>`;
709
+ if (!ins.length && !outs.length) h += `<div>${T.noRelations}</div>`;
710
+ showTip(ev, h);
711
+ }
712
+ /* ---------- a module's detail ---------- */
713
+ function chip(id){ const it = ITEM[id]; return `<span class="c ${it.kind} ${it.st}">${KIND[it.kind]}${id}</span>`; }
714
+ function moduleHTML(m){
715
+ const d = M[m], items = d.items.map(i=>ITEM[m+'.'+i[0]]);
716
+ const touched = items.filter(i=>i.st!=='o'), rest = items.length - touched.length;
717
+ const li = touched.map(it=>{
718
+ const ins = REL.filter(r=>r.to===it.id), outs = REL.filter(r=>r.from===it.id), flow = [], by = k => x => x.kind===k;
719
+ const add = (l, rs, side) => { if (rs.length) flow.push(l+' '+rs.map(r=>chip(r[side])).join(' ')); };
720
+ add(T.emittedBy, ins.filter(by('emit')), 'from'); add(T.triggeredBy, ins.filter(by('ev')), 'from'); add(T.calledBy, ins.filter(by('call')), 'from');
721
+ add(T.emitsL, outs.filter(by('emit')), 'to'); add(T.triggersL, outs.filter(by('ev')), 'to'); add(T.callsL, outs.filter(by('call')), 'to');
722
+ return `<li><span class="c ${it.kind} ${it.st}">${KIND[it.kind]}${it.name}</span><span class="st ${it.st}">${STL[it.st]}</span>${it.x.why?`<div class="flow" style="color:var(--ink);line-height:1.5">${esc(it.x.why)}</div>`:''}${flow.length?`<div class="flow">${flow.join('<br>')}</div>`:''}</li>`;
723
+ }).join('');
724
+ return `<h2><span class="kv" style="font-size:14px">${d.ctx}/${m}</span><span class="st ${d.st}">${STL[d.st]}</span></h2>${d.why?`<p>${esc(d.why)}</p>`:`<p>${T.untouched}</p>`}
725
+ ${touched.length?`<h3>${T.touches}</h3><ul>${li}</ul>`:''}${rest?`<p class="sub" style="margin:10px 0 0">${T.restUnchanged(rest, touched.length>0)}</p>`:''}`;
726
+ }
727
+ const det = $('detail'), cap = $('caption');
728
+ function openDetail(m){
729
+ openMod = m; for (const [k,el] of Object.entries(modEl)) el.classList.toggle('open', k===m);
730
+ if (!m) { det.hidden = true; return; }
731
+ det.innerHTML = '<button class="x" aria-label="Cerrar">×</button>' + moduleHTML(m); det.hidden = false; det.scrollTop = 0;
732
+ det.querySelector('.x').onclick = ()=>openDetail(null);
733
+ }
734
+ /* ---------- the story ---------- */
735
+ const withChips = text => text.split(/\{([^{}]+)\}/).map((part, i) => i % 2 ? (ITEM[part] ? chip(part) : esc(part)) : esc(part)).join('');
736
+ const stateOf = step => !step.state ? '' : `<span class="state ${step.state}">${step.state === 'partial' ? T.statePartial(step.done, step.of) : T['state' + step.state[0].toUpperCase() + step.state.slice(1)]}</span>`;
737
+ const STORY = DATA.steps.map(step => ({pin:step.focus, txt:()=>`<b>${withChips(step.title)}</b>${stateOf(step)}${step.text ? '<br>' + withChips(step.text) : ''}`}));
738
+
739
+ if (!STORY.length) { $('steps-card').hidden = true; $('steps').hidden = true; }
740
+ $('story').innerHTML = STORY.map((s,i)=>`<li data-i="${i}" tabindex="0">${s.txt()}<span class="go">${T.seeInDiagram}</span></li>`).join('');
741
+ document.querySelectorAll('#story li').forEach(li=>{ const go = ()=>{ selItem = null; show('changes'); setPin(+li.dataset.i); };
742
+ li.onclick = go; li.onkeydown = ev=>{ if (ev.key==='Enter') go(); }; });
743
+ $('steps').insertAdjacentHTML('beforeend', STORY.map((s,i)=>`<button data-i="${i}" aria-pressed="false" title="${T.highlightStep(i+1)}">${i+1}</button>`).join(''));
744
+ document.querySelectorAll('#steps button').forEach(b=>b.onclick=()=>{ selItem = null; setPin(pinStep===+b.dataset.i ? -1 : +b.dataset.i); });
745
+ function setPin(i, quiet){
746
+ pinStep = i; pinned = i<0 ? null : new Set(STORY[i].pin);
747
+ document.querySelectorAll('#steps button').forEach(b=>b.setAttribute('aria-pressed', +b.dataset.i===i));
748
+ if (!quiet) { render(); focusView(true); }
749
+ }
750
+ /* ---------- impact ---------- */
751
+ const cnt = s => Object.values(M).filter(d=>d.st===s).length;
752
+ const nItems = Object.values(ITEM).filter(i=>i.st==='n').length, nEdges = E.filter(e=>e[3]==='n').length, nGone = Object.values(ITEM).filter(i=>i.st==='x').length;
753
+ const stat = (bg, ink, n, label) => `<div style="background:var(--${bg});color:var(--${ink})"><b>${n}</b><span>${label}</span></div>`;
754
+ $('summary').innerHTML = `<h2>${T.impact}</h2><div class="stats">
755
+ ${stat('new-bg','new-ink', cnt('new'), T.newModules(cnt('new')))}${stat('mod-bg','mod-ink', cnt('mod'), T.modifiedModules(cnt('mod')))}${stat('use-bg','use-ink', cnt('use'), T.consumedModules(cnt('use')))}${stat('old-bg','old-ink', nEdges, T.newRelations(nEdges))}</div>
756
+ <p class="sub" style="margin:10px 0 0">${T.newPieces(nItems)}${nGone ? ' ' + T.removedPieces(nGone) : ''}</p>`;
757
+ $('rules').innerHTML = DATA.rules.map(r => `<li class="${r.ok ? '' : 'no'}">${esc(r.text)}</li>`).join('') || `<li>${T.noRules}</li>`;
758
+ /* ---------- against the plan ---------- */
759
+ const MARK = {done:'✓', missing:'✗', different:'≠', unplanned:'+'};
760
+ if (DATA.checks) {
761
+ $('checks-card').hidden = false;
762
+ const sorted = [...DATA.checks].sort((a, b) => Number(b.failing) - Number(a.failing));
763
+ $('checks').innerHTML = sorted.map(c => `<li class="${c.verdict}"><b>${MARK[c.verdict]}</b>${esc(c.id)} <span class="ok">${T[c.verdict]}</span></li>`).join('');
764
+ }
765
+ /* ---------- save and load a plan ---------- */
766
+ const say = text => { $('act-msg').textContent = text; };
767
+ if (DATA.plan) {
768
+ $('save-plan').hidden = false;
769
+ $('save-plan').onclick = async () => {
770
+ const text = JSON.stringify(DATA.plan, null, 2) + '\n';
771
+ const downloads = window.claude ? await window.claude.use('downloads').catch(() => null) : null;
772
+ if (downloads) { try { await downloads.save({filename:'plan.json', data:text}); say(T.saved); } catch (e) { say(T.notSaved); } return; }
773
+ const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], {type:'application/json'})); a.download = 'plan.json'; a.click(); URL.revokeObjectURL(a.href);
774
+ };
775
+ }
776
+ if (DATA.maps && window.newtPlan) {
777
+ $('load-plan').hidden = false;
778
+ $('load-plan').onclick = () => $('plan-file').click();
779
+ $('plan-file').onchange = async ev => {
780
+ const file = ev.target.files[0]; if (!file) return;
781
+ let json; try { json = JSON.parse(await file.text()); } catch (e) { say(T.notAPlan + e.message); return; }
782
+ let made; try { made = window.newtPlan.reviewWithPlan(DATA, json); } catch (e) { say(T.notAPlan + e.message); return; }
783
+ if (made.problems) { say(T.notAPlan + made.problems.join('; ')); return; }
784
+ const data = JSON.stringify(made.data).replace(/</g, '\\u003c');
785
+ const marker = '<!-' + '-@@LOADED@@-->';
786
+ const page = SOURCE.replace(marker, () => `<script>window.NEWT_DATA=${data};<\/script>${marker}`);
787
+ document.open(); document.write(page); document.close();
788
+ };
789
+ }
790
+ /* ---------- controls ---------- */
791
+ $('t-only').addEventListener('click', ev=>{ const b = ev.currentTarget; only = b.getAttribute('aria-pressed')!=='true'; b.setAttribute('aria-pressed', only); layout(); });
792
+ wrap.addEventListener('click', ev=>{ if (!ev.target.closest('.it,.mh')) clearAll(); });
793
+ function fit(){
794
+ if (mode==='cv') { applyCam(); return; }
795
+ const aw = wrap.clientWidth-16, ah = wrap.clientHeight-16; if (aw<=0) return;
796
+ const z = zoom ?? (only || whole ? Math.max(whole?.2:.6, Math.min(1.6, aw/SW, ah/SH)) : Math.max(.5, Math.min(1, aw/SW)));
797
+ stage.dataset.z = z; stage.style.transform = `scale(${z})`; sizer.style.width = SW*z+'px'; sizer.style.height = SH*z+'px';
798
+ }
799
+ /* ---------- canvas: camera, drag, zoom, minimap ---------- */
800
+ const ZMIN = .12, ZMAX = 2.5, clampZ = z => Math.max(ZMIN, Math.min(ZMAX, z));
801
+ function applyCam(){ stage.dataset.z = cam.z; stage.style.transform = `translate(${cam.x}px,${cam.y}px) scale(${cam.z})`; miniView(); }
802
+ function frameBox(x, y, w, h, maxZ, minZ){ /* frames a rectangle of the canvas in the view */
803
+ const vw = wrap.clientWidth, vh = wrap.clientHeight; if (!vw) return;
804
+ cam.z = Math.max(minZ ?? ZMIN, Math.min(maxZ, (vw-32)/w, (vh-32)/h));
805
+ cam.x = vw/2 - (x + w/2)*cam.z; cam.y = vh/2 - (y + h/2)*cam.z; applyCam();
806
+ }
807
+ function fitAll(){ frameBox(0, 0, SW, SH, 1.5); }
808
+ function focusIds(){ /* the pieces in focus: a selection and its relations, or a step of the story */
809
+ if (selItem) { const core = linked(selItem), s = new Set(core); REL.forEach(r=>{ if (core.has(r.from)||core.has(r.to)) { s.add(r.from); s.add(r.to); } }); return s; }
810
+ return pinned;
811
+ }
812
+ function focusView(always){ /* centers and frames the focus; for a selection, only if part of it is out of view */
813
+ if (mode!=='cv') return false; const ids = focusIds(); if (!ids) return false;
814
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
815
+ for (const id of ids) { const g = BOX[id]; if (!g) continue; x0 = Math.min(x0, g.x); y0 = Math.min(y0, g.y); x1 = Math.max(x1, g.x+g.w); y1 = Math.max(y1, g.y+g.h); }
816
+ if (x0===Infinity) return false;
817
+ const P = 70; x0 -= P; y0 -= P; x1 += P; y1 += P;
818
+ const vw = wrap.clientWidth, vh = wrap.clientHeight;
819
+ if (!always) { const inside = x0*cam.z + cam.x >= 0 && y0*cam.z + cam.y >= 0 && x1*cam.z + cam.x <= vw && y1*cam.z + cam.y <= vh; if (inside) return true;
820
+ frameBox(x0, y0, x1-x0, y1-y0, Math.max(cam.z, .5)); return true; }
821
+ frameBox(x0, y0, x1-x0, y1-y0, 1.35); return true;
822
+ }
823
+ function zoomAt(px, py, f){ const z = clampZ(cam.z*f); cam.x = px - (px-cam.x)*z/cam.z; cam.y = py - (py-cam.y)*z/cam.z; cam.z = z; applyCam(); }
824
+ const zoomCenter = f => { if (mode==='cv') zoomAt(wrap.clientWidth/2, wrap.clientHeight/2, f); else { zoom = Math.max(.3, Math.min(2, (+stage.dataset.z||1)*f)); fit(); } };
825
+ $('z+').onclick = ()=>zoomCenter(1.25);
826
+ $('z-').onclick = ()=>zoomCenter(1/1.25);
827
+ $('z0').onclick = ()=>{ if (mode==='cv') fitAll(); else { zoom = null; whole = true; fit(); } };
828
+ wrap.addEventListener('wheel', ev=>{ if (mode!=='cv') return; ev.preventDefault(); const r = wrap.getBoundingClientRect();
829
+ wrap.classList.add('drag'); zoomAt(ev.clientX-r.left, ev.clientY-r.top, Math.exp(-ev.deltaY*(ev.ctrlKey ? .012 : .0018))); clearTimeout(wrap._wt); wrap._wt = setTimeout(()=>wrap.classList.remove('drag'), 150); }, {passive:false});
830
+ const ptrs = new Map(); let dragged = false, pinch = 0;
831
+ wrap.addEventListener('pointerdown', ev=>{ if (mode!=='cv' || ev.button) return; ptrs.set(ev.pointerId, {x:ev.clientX, y:ev.clientY, sx:ev.clientX, sy:ev.clientY}); dragged = false; pinch = 0; });
832
+ wrap.addEventListener('pointermove', ev=>{ const p = ptrs.get(ev.pointerId); if (!p) return;
833
+ const dx = ev.clientX - p.x, dy = ev.clientY - p.y;
834
+ if (!dragged && ptrs.size===1 && Math.hypot(ev.clientX-p.sx, ev.clientY-p.sy) < 5) return;
835
+ if (!dragged) { dragged = true; wrap.classList.add('drag'); hideTip(); try { wrap.setPointerCapture(ev.pointerId); } catch (e) {} }
836
+ p.x = ev.clientX; p.y = ev.clientY;
837
+ if (ptrs.size===1) { cam.x += dx; cam.y += dy; applyCam(); }
838
+ else if (ptrs.size===2) { const [a,b] = [...ptrs.values()], d = Math.hypot(a.x-b.x, a.y-b.y), r = wrap.getBoundingClientRect();
839
+ cam.x += dx/2; cam.y += dy/2; if (pinch) zoomAt((a.x+b.x)/2 - r.left, (a.y+b.y)/2 - r.top, d/pinch); else applyCam(); pinch = d; } });
840
+ const endPtr = ev=>{ if (!ptrs.delete(ev.pointerId)) return; pinch = 0; if (!ptrs.size) { wrap.classList.remove('drag'); if (dragged) setTimeout(()=>dragged = false, 0); } };
841
+ wrap.addEventListener('pointerup', endPtr); wrap.addEventListener('pointercancel', endPtr);
842
+ wrap.addEventListener('click', ev=>{ if (dragged) { ev.stopPropagation(); ev.preventDefault(); dragged = false; } }, true);
843
+ /* minimap */
844
+ const mini = $('mini'), miniSvg = mini.querySelector('svg'); let miniS = .1;
845
+ function drawMini(core, ends){
846
+ if (mode!=='cv' || !GEO) return;
847
+ miniS = Math.min(190/SW, 130/SH); miniSvg.setAttribute('width', SW*miniS); miniSvg.setAttribute('height', SH*miniS); miniSvg.setAttribute('viewBox', `0 0 ${SW} ${SH}`);
848
+ const mods = core ? new Set([...core, ...ends].map(modOf)) : null; let h = '';
849
+ for (const g of Object.values(GEO.ctx)) h += `<rect class="mc" x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="${10/miniS/6}" stroke-width="${1/miniS}"/>`;
850
+ for (const [m,g] of Object.entries(GEO.mod)) h += `<rect class="mm ${M[m].st}${mods && !mods.has(m) ? ' dim' : ''}" x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="${2/miniS}"/>`;
851
+ if (core) for (const id of core) { const g = BOX[id]; if (g) h += `<rect class="mi" x="${g.x}" y="${g.y}" width="${Math.max(g.w, 5/miniS)}" height="${Math.max(g.h, 3/miniS)}"/>`; }
852
+ miniSvg.innerHTML = h + `<rect class="mv" stroke-width="${1.5/miniS}" rx="${2/miniS}"/>`; miniView();
853
+ }
854
+ function miniView(){ const r = miniSvg.querySelector('.mv'); if (!r) return;
855
+ r.setAttribute('x', -cam.x/cam.z); r.setAttribute('y', -cam.y/cam.z); r.setAttribute('width', wrap.clientWidth/cam.z); r.setAttribute('height', wrap.clientHeight/cam.z); }
856
+ function miniGo(ev){ const r = miniSvg.getBoundingClientRect(), wx = (ev.clientX-r.left)/miniS, wy = (ev.clientY-r.top)/miniS;
857
+ cam.x = wrap.clientWidth/2 - wx*cam.z; cam.y = wrap.clientHeight/2 - wy*cam.z; applyCam(); }
858
+ mini.addEventListener('pointerdown', ev=>{ ev.stopPropagation(); ev.preventDefault(); try { mini.setPointerCapture(ev.pointerId); } catch (e) {} mini._d = true; wrap.classList.add('drag'); miniGo(ev); });
859
+ mini.addEventListener('pointermove', ev=>{ if (mini._d) miniGo(ev); });
860
+ const miniEnd = ()=>{ mini._d = false; wrap.classList.remove('drag'); }; mini.addEventListener('pointerup', miniEnd); mini.addEventListener('pointercancel', miniEnd);
861
+ mini.addEventListener('click', ev=>ev.stopPropagation());
862
+ /* lanes / canvas */
863
+ function setMode(m){ mode = m; try { localStorage.setItem('newt-plan-mode', m); } catch (e) {}
864
+ document.querySelectorAll('#mode button').forEach(b=>b.setAttribute('aria-pressed', b.dataset.mode===m)); layout(); }
865
+ document.querySelectorAll('#mode button').forEach(b=>{ b.setAttribute('aria-pressed', b.dataset.mode===mode); b.onclick = ()=>{ if (mode!==b.dataset.mode) setMode(b.dataset.mode); }; });
866
+ let rt;
867
+ function relayoutIfResized(){ const aw = wrap.clientWidth-16, ah = wrap.clientHeight-16; if (aw<=0) return;
868
+ if (mode==='cv') { if (!camReady) { camReady = true; if (!focusView(true)) fitAll(); } else miniView(); return; }
869
+ if (Math.abs(aw-lastSize[0]) > 40 || Math.abs(ah-lastSize[1]) > 40) layout(); else fit(); }
870
+ new ResizeObserver(()=>{ fit(); clearTimeout(rt); rt = setTimeout(relayoutIfResized, 120); }).observe(wrap);
871
+ addEventListener('keydown', ev=>{ if (ev.key==='Escape') { if (openMod) openDetail(null); else clearAll(); } });
872
+ /* ---------- tabs ---------- */
873
+ function show(t){
874
+ document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on', x.id==='tab-'+t));
875
+ document.querySelectorAll('.tabs button').forEach(b=>b.setAttribute('aria-selected', b.dataset.tab===t));
876
+ if (location.hash !== '#'+t) history.replaceState(null,'','#'+t);
877
+ if (t==='changes') relayoutIfResized();
878
+ }
879
+ document.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>show(b.dataset.tab));
880
+ const route = ()=>show(IS_MAP || location.hash==='#changes'?'changes':'summary');
881
+ document.querySelectorAll('[data-t]').forEach(el => el.textContent = T[el.dataset.t]);
882
+ document.querySelectorAll('[data-t-aria]').forEach(el => { el.setAttribute('aria-label', T[el.dataset.tAria]); el.title = T[el.dataset.tAria]; });
883
+ document.querySelectorAll('[data-t-title]').forEach(el => el.title = T[el.dataset.tTitle]);
884
+ addEventListener('hashchange', route); layout(); route();
885
+ })();
886
+ </script>
887
+ </body>
888
+ </html>