ecadlib 0.3.0__py3-none-any.whl

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 (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2098 @@
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>ecadlib</title>
7
+ <link rel="icon" href="data:,">
8
+ <style>
9
+ /* Hairline: one ground colour, 1px lines, ink for what matters, motion that eases out (github.com/lucasmarkes/hairline) */
10
+ :root {
11
+ color-scheme: light dark;
12
+ --bg: #ffffff; --panel: #ffffff; --text: #232327; --muted: #6f6f78; --line: #e0e0e4; --line-2: #ececef; --hover: #f6f6f7;
13
+ --accent: #232327; --accent-weak: #f3f3f5; --on-accent: #ffffff;
14
+ --ok: #2f8a57; --warn: #b07210; --bad: #c4392f; --info: #4b5bd6; --off: #8a8c95;
15
+ --ok-bg: transparent; --warn-bg: transparent; --bad-bg: transparent; --info-bg: transparent; --off-bg: transparent;
16
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
17
+ --ease: cubic-bezier(.32, .72, 0, 1);
18
+ --hairline-plate: var(--bg); --hairline-hi: #232327; --hairline-edge: #a1a1aa; --hairline-mid: #b4b4bb; --hairline-lo: #e4e4e7;
19
+ /* the footprint preview is Altium's PCB editor in both themes: black board, Top Layer red, Multi-Layer silver */
20
+ --pcb-board: #000000; --pcb-grid: #1e1e1e; --pcb-grid-major: #3a3a3a; --pcb-top: #ff0000; --pcb-multi: #c0c0c0;
21
+ --pcb-hole: #1a1a1a; --pcb-fab: #ff00ff; --pcb-crtyd: #00d000; --pcb-text: #ffffff; --pcb-origin: #ffffff;
22
+ /* one colour per design tool, on the library hub's tiles, columns and tags */
23
+ --t-altium: #8f6410; --t-altium365: #08787a; --t-kicad: #3a52c4; --t-fusion: #b84a0c; --t-easyeda: #8433b0; --t-all: var(--text);
24
+ }
25
+ @media (prefers-color-scheme: dark) {
26
+ :root { --bg: #08090a; --panel: #08090a; --text: #d0d6e0; --muted: #8a8c95; --line: #29292d; --line-2: #1d1d20; --hover: #111214;
27
+ --accent: #d0d6e0; --accent-weak: #16171a; --on-accent: #08090a;
28
+ --ok: #5fc58c; --warn: #e2a64a; --bad: #f07a70; --info: #9aa6ff; --off: #6f7178;
29
+ --hairline-hi: #fafafa; --hairline-edge: #a1a1aa; --hairline-mid: #52525b; --hairline-lo: #27272a;
30
+ --t-altium: #e0b45c; --t-altium365: #4cc9c9; --t-kicad: #8c9dff; --t-fusion: #ff9a57; --t-easyeda: #c98af0; }
31
+ }
32
+ * { box-sizing: border-box; }
33
+ [hidden] { display: none !important; }
34
+ html { scroll-behavior: smooth; }
35
+ body { margin: 0; font: 13.5px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; }
36
+ ::selection { background: var(--text); color: var(--bg); }
37
+ header { display: flex; align-items: baseline; gap: 18px; padding: 18px 24px 14px; flex-wrap: wrap; }
38
+ header h1 { font: 500 15px/1 var(--mono); letter-spacing: .02em; margin: 0; }
39
+ header h1::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; border: 1px solid var(--text); margin-right: 9px; vertical-align: 1px; animation: pulse 3.2s var(--ease) infinite; }
40
+ header .meta { color: var(--muted); font: 11.5px/1.5 var(--mono); overflow-wrap: anywhere; }
41
+ main { max-width: 1240px; margin: 0 auto; padding: 22px 20px 80px; }
42
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 22px 24px; margin-bottom: 18px; animation: rise .7s var(--ease) both; }
43
+ main > .card:nth-of-type(2) { animation-delay: .04s; } main > .card:nth-of-type(3) { animation-delay: .08s; } main > .card:nth-of-type(4) { animation-delay: .12s; }
44
+ main > .card:nth-of-type(5) { animation-delay: .16s; } main > .card:nth-of-type(6) { animation-delay: .2s; } main > .card:nth-of-type(n+7) { animation-delay: .24s; }
45
+ .card h2 { font-size: 15px; font-weight: 550; letter-spacing: -.005em; margin: 0 0 4px; }
46
+ .drop { display: grid; grid-template-columns: minmax(150px, 230px) 1fr; align-items: center; gap: 8px 24px; border: 1px dashed var(--line); border-radius: 12px; padding: 8px 24px 8px 8px; text-align: left; color: var(--muted); cursor: pointer; transition: border-color .4s var(--ease), background .4s var(--ease); }
47
+ .drop strong { color: var(--text); font-weight: 550; }
48
+ .drop .fig { width: 100%; aspect-ratio: 5 / 4; }
49
+ .drop.over, .drop:hover, .drop:focus-visible { border-color: var(--text); background: var(--hover); outline: none; }
50
+ .drop.over { border-style: solid; }
51
+ @media (max-width: 600px) { .drop { grid-template-columns: 1fr; padding: 8px 16px 16px; text-align: center; } .drop .fig { max-width: 220px; margin: 0 auto; } }
52
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
53
+ .spread { justify-content: space-between; }
54
+ button, select, input[type=search], input[type=text] { font: inherit; font-size: 13px; border-radius: 8px; border: 1px solid var(--line); background: transparent; color: var(--text); padding: 6px 12px; min-height: 32px; transition: border-color .3s var(--ease), background .3s var(--ease), color .3s var(--ease), transform .3s var(--ease); }
55
+ select { background: var(--bg); }
56
+ button { cursor: pointer; }
57
+ button:hover:not(:disabled) { border-color: var(--text); }
58
+ button:active:not(:disabled) { transform: translateY(1px); }
59
+ button.primary { background: var(--text); border-color: var(--text); color: var(--bg); }
60
+ button.primary:hover:not(:disabled) { opacity: .88; }
61
+ input[type=search]:focus, input[type=text]:focus, select:focus, textarea:focus { border-color: var(--text); outline: none; }
62
+ button:focus-visible, a:focus-visible, .chip:focus-visible, select:focus-visible, input:focus-visible, summary:focus-visible { outline: 1.5px solid var(--text); outline-offset: 2px; }
63
+ button.danger { border-color: color-mix(in srgb, var(--bad) 55%, transparent); color: var(--bad); }
64
+ button:disabled { opacity: .4; cursor: default; }
65
+ input[type=search] { min-width: 220px; }
66
+ input::placeholder { color: var(--muted); opacity: .7; }
67
+ #boms { max-width: 100%; }
68
+ label.field { display: inline-flex; gap: 6px; align-items: center; color: var(--muted); }
69
+ .signoff { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin-top: 14px; }
70
+ .acts { display: flex; gap: 4px; flex-wrap: wrap; }
71
+ td .sub { font-size: 11.5px; margin-top: 3px; }
72
+ #picklist table td { font-size: 12.5px; }
73
+ .chips { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
74
+ .chip { border-radius: 999px; padding: 3px 11px 3px 9px; font-size: 12px; cursor: pointer; border: 1px solid var(--line); color: var(--text) !important; transition: border-color .3s var(--ease), background .3s var(--ease); }
75
+ .chip:hover { border-color: var(--muted); }
76
+ .chip.active { border-color: var(--text); background: var(--hover); }
77
+ /* status: a tone, shown as a dot on a hairline pill, never as a filled block */
78
+ .s-ok { --tone: var(--ok); } .s-warn { --tone: var(--warn); } .s-bad { --tone: var(--bad); } .s-info { --tone: var(--info); } .s-off { --tone: var(--off); }
79
+ .chip[class*="s-"]::before, .pill[class*="s-"]::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--tone); margin-right: 7px; vertical-align: 1px; }
80
+ .tablewrap { overflow-x: auto; }
81
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
82
+ th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line-2); vertical-align: top; }
83
+ th { color: var(--muted); font: 500 10.5px/1.4 var(--mono); letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; border-bottom-color: var(--line); }
84
+ tbody tr { transition: background .3s var(--ease); animation: rise .6s var(--ease) both; }
85
+ tbody tr:hover td { background: var(--hover); }
86
+ tbody tr:nth-child(2) { animation-delay: .03s; } tbody tr:nth-child(3) { animation-delay: .06s; } tbody tr:nth-child(4) { animation-delay: .09s; }
87
+ tbody tr:nth-child(5) { animation-delay: .12s; } tbody tr:nth-child(6) { animation-delay: .15s; } tbody tr:nth-child(7) { animation-delay: .18s; }
88
+ tbody tr:nth-child(8) { animation-delay: .21s; } tbody tr:nth-child(9) { animation-delay: .24s; } tbody tr:nth-child(n+10) { animation-delay: .27s; }
89
+ td.notes { color: var(--muted); max-width: 340px; overflow-wrap: anywhere; }
90
+ td code, .mono { font-family: var(--mono); font-size: 12px; }
91
+ .pill { display: inline-block; border: 1px solid var(--line); border-radius: 999px; padding: 1px 9px 1px 8px; font-size: 11.5px; white-space: nowrap; color: var(--text) !important; }
92
+ .muted { color: var(--muted); }
93
+ .small { font-size: 12.5px; }
94
+ .scroll { overflow-x: auto; max-width: 100%; }
95
+ button.small { padding: 3px 10px; font-size: 12px; min-height: 28px; white-space: nowrap; }
96
+ ul.why { margin: 0; padding-left: 16px; }
97
+ #libout td.mono { word-break: normal; white-space: nowrap; }
98
+ /* banners and next-step lines: a toned hairline on the left, text in ink */
99
+ .banner { border: 1px solid var(--line); border-left: 2px solid var(--tone, var(--line)); border-radius: 8px; padding: 9px 12px; margin: 10px 0; white-space: pre-wrap; display: flex; gap: 10px; justify-content: space-between; align-items: start; color: var(--text) !important; animation: slide .5s var(--ease) both; }
100
+ .banner button { padding: 0 8px; min-height: 0; border: none; background: transparent; color: var(--muted); font-size: 16px; }
101
+ .next { border: 1px solid var(--line); border-left: 2px solid var(--tone, var(--line)); border-radius: 8px; padding: 9px 12px; margin: 10px 0; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; color: var(--text) !important; animation: slide .5s var(--ease) both; }
102
+ .next > span { flex: 1 1 260px; }
103
+ .next button { padding: 3px 10px; font-size: 12px; min-height: 28px; }
104
+ .progress { position: relative; height: 1px; background: var(--line); overflow: hidden; margin-top: 10px; }
105
+ .progress > div { height: 100%; background: var(--text); width: 0; transition: width .6s var(--ease); }
106
+ .progress::after { content: ""; position: absolute; inset: 0; width: 30%; background: linear-gradient(90deg, transparent, var(--text), transparent); opacity: .5; animation: sweep 1.6s var(--ease) infinite; }
107
+ .busyfig { width: 150px; aspect-ratio: 5 / 4; margin: 6px 0 -6px -10px; }
108
+ .busyrow { display: flex; align-items: center; gap: 12px; }
109
+ .busyrow > div:last-child { flex: 1; min-width: 0; }
110
+ .emptyfig { width: 140px; aspect-ratio: 5 / 4; margin: 4px 0 0 -10px; }
111
+ .empty { display: flex; align-items: center; gap: 10px; }
112
+ .grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; align-items: start; }
113
+ .box .out table { overflow-wrap: normal; }
114
+ .box .out td:first-child { min-width: 9.5em; }
115
+ .box .out { overflow-x: auto; }
116
+ .box .out .pill { white-space: normal; border-radius: 10px; }
117
+ .box { border: 1px solid var(--line); border-radius: 12px; padding: 16px; transition: border-color .4s var(--ease); }
118
+ .box:hover { border-color: var(--muted); }
119
+ .box h3 { font-size: 13.5px; font-weight: 550; margin: 0 0 4px; }
120
+ .box p { margin: 4px 0 12px; }
121
+ .out { margin-top: 8px; font-size: 12.5px; overflow-wrap: anywhere; }
122
+ dialog { border: 1px solid var(--line); border-radius: 14px; background: var(--bg); color: var(--text); padding: 0; width: min(1040px, 96vw); box-shadow: 0 30px 80px -30px rgba(0,0,0,.35); }
123
+ dialog[open] { animation: pop .45s var(--ease) both; }
124
+ dialog::backdrop { background: color-mix(in srgb, var(--bg) 55%, transparent); backdrop-filter: blur(3px); }
125
+ .dlg-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--line); gap: 10px; flex-wrap: wrap; }
126
+ .dlg-body { display: grid; grid-template-columns: 1fr 1.15fr; gap: 18px; padding: 18px; }
127
+ @media (max-width: 760px) { .dlg-body { grid-template-columns: 1fr; } input[type=search] { min-width: 0; width: 100%; } }
128
+ textarea { width: 100%; min-height: 330px; font: 12px/1.5 var(--mono); background: transparent; color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 10px; }
129
+ /* footprint preview: Altium's PCB editor */
130
+ .preview { background: var(--pcb-board); border: 1px solid #000; border-radius: 10px 10px 0 0; padding: 0; text-align: center; min-height: 240px; display: grid; place-items: center; overflow: hidden; }
131
+ .preview svg { display: block; width: 100%; height: auto; max-height: 360px; animation: fadein .6s var(--ease) both; }
132
+ .layertabs { display: flex; gap: 0; overflow-x: auto; background: #1b1b1b; border: 1px solid #000; border-top: 0; border-radius: 0 0 10px 10px; font: 11px/1 system-ui, sans-serif; color: #d6d6d6; scrollbar-width: none; }
133
+ .layertabs span { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; white-space: nowrap; border-right: 1px solid #000; }
134
+ .layertabs span:first-child { color: #9a9a9a; }
135
+ .layertabs span.on { background: #2a2a2a; color: #fff; }
136
+ .layertabs i { width: 9px; height: 9px; display: inline-block; border: 1px solid #000; }
137
+ svg.fp .board { fill: var(--pcb-board); }
138
+ svg.fp .grid { fill: none; stroke: var(--pcb-grid); stroke-width: .012; }
139
+ svg.fp .grid.major { stroke: var(--pcb-grid-major); stroke-width: .016; }
140
+ svg.fp .pad { fill: var(--pcb-top); }
141
+ svg.fp .pad.tht { fill: var(--pcb-multi); }
142
+ svg.fp .pad.p1 { stroke: #ffffff; stroke-width: .03; }
143
+ svg.fp g.pad.p1 polygon { stroke: #ffffff; stroke-width: .03; }
144
+ svg.fp .pad.ep { fill: var(--pcb-top); }
145
+ svg.fp .crtyd { fill: none; stroke: var(--pcb-crtyd); stroke-width: .03; }
146
+ svg.fp .fab { fill: none; stroke: var(--pcb-fab); stroke-width: .03; }
147
+ svg.fp .hole { fill: var(--pcb-hole); stroke: #6e6e6e; stroke-width: .02; }
148
+ svg.fp .padnum { fill: var(--pcb-text); font-family: Arial, Helvetica, sans-serif; text-anchor: middle; dominant-baseline: central; pointer-events: none; }
149
+ svg.fp .origin { fill: none; stroke: var(--pcb-origin); stroke-width: .03; }
150
+ svg.fp .pad:hover, svg.fp g.pad:hover polygon { fill: #ff5a5a; }
151
+ .nums { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 0; margin-top: 12px; font-size: 12.5px; text-align: left; border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
152
+ .nums div { border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 6px 9px; font-family: var(--mono); font-size: 12px; animation: fadein .5s var(--ease) both; }
153
+ .nums div:nth-child(2) { animation-delay: .03s; } .nums div:nth-child(3) { animation-delay: .06s; } .nums div:nth-child(4) { animation-delay: .09s; } .nums div:nth-child(n+5) { animation-delay: .12s; }
154
+ .toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 9px 16px; border-radius: 999px; display: none; max-width: 90vw; font-size: 12.5px; animation: toast .5s var(--ease) both; }
155
+ a { color: var(--text); text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 3px; transition: text-decoration-color .3s var(--ease); }
156
+ a:hover { text-decoration-color: var(--text); }
157
+ pre.diff { white-space: pre-wrap; max-height: 280px; overflow: auto; background: transparent; border: 1px solid var(--line); border-radius: 10px; padding: 10px; font: 12px/1.5 var(--mono); }
158
+ #rulestoml { min-height: 240px; }
159
+ /* step bar and next-step hints */
160
+ nav.steps { position: sticky; top: 0; z-index: 5; display: flex; gap: 2px; overflow-x: auto; padding: 0 20px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); scrollbar-width: none; }
161
+ nav.steps a { position: relative; white-space: nowrap; padding: 11px 12px; text-decoration: none; color: var(--muted); font: 12px/1 var(--mono); letter-spacing: .01em; transition: color .3s var(--ease); }
162
+ nav.steps a::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: -1px; height: 1px; background: var(--text); transform: scaleX(0); transform-origin: left; transition: transform .5s var(--ease); }
163
+ nav.steps a:hover, nav.steps a.here { color: var(--text); }
164
+ nav.steps a:hover::after, nav.steps a.here::after { transform: scaleX(1); }
165
+ nav.steps a.off { color: var(--muted); opacity: .55; }
166
+ nav.steps .count { border: 1px solid var(--warn); color: var(--warn); border-radius: 999px; padding: 1px 6px; margin-left: 6px; font-size: 10.5px; }
167
+ nav.steps .count:empty { display: none; }
168
+ main > section { scroll-margin-top: 56px; }
169
+ .stepno { display: inline-block; font: 500 11px/1 var(--mono); color: var(--muted); margin-right: 10px; letter-spacing: .04em; vertical-align: 1px; }
170
+ .stepno::before { content: "0"; }
171
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 0; margin: 14px 0; border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
172
+ .stats div { border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 12px 14px; color: var(--muted); font-size: 12px; animation: fadein .6s var(--ease) both; }
173
+ .stats div:nth-child(2) { animation-delay: .05s; } .stats div:nth-child(3) { animation-delay: .1s; } .stats div:nth-child(4) { animation-delay: .15s; } .stats div:nth-child(n+5) { animation-delay: .2s; }
174
+ .stats b { display: block; font: 400 26px/1.15 var(--mono); color: var(--text); letter-spacing: -.02em; font-variant-numeric: tabular-nums; margin-bottom: 2px; }
175
+ .check { padding: 7px 0; border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; }
176
+ @media (max-width: 600px) { header { padding: 14px 16px 10px; } main { padding: 16px 12px 60px; } .card { padding: 18px 16px; } .signoff input[type=text] { width: 100%; } label.field { flex-wrap: wrap; } }
177
+ /* team mode */
178
+ header .user { margin-left: auto; display: flex; gap: 8px; align-items: center; font-size: 12.5px; flex-wrap: wrap; }
179
+ header .user .who { color: var(--muted); font-family: var(--mono); font-size: 11.5px; }
180
+ header .user button { padding: 3px 10px; font-size: 12px; min-height: 28px; }
181
+ .signin { max-width: 380px; margin: 10vh auto 0; }
182
+ .signin label { display: block; margin-top: 12px; font-size: 12.5px; color: var(--muted); }
183
+ .signin input, .form input[type=password], .form input[type=text], .form input[type=email] { width: 100%; margin-top: 4px; }
184
+ input[type=password], input[type=email] { font: inherit; font-size: 13px; border-radius: 8px; border: 1px solid var(--line); background: transparent; color: var(--text); padding: 6px 12px; min-height: 32px; }
185
+ .form { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px 12px; align-items: end; margin-top: 10px; }
186
+ .form label { font-size: 12.5px; color: var(--muted); }
187
+ [data-role-hidden] { display: none !important; }
188
+ header .user[hidden], label.field[hidden], span[hidden], nav.steps[hidden] { display: none; }
189
+ .form label { display: block; }
190
+ .form select { display: block; width: 100%; margin-top: 4px; }
191
+ /* review queue */
192
+ #qrows tr.sel td { background: var(--hover); }
193
+ .qview { border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; margin-top: 12px; animation: rise .6s var(--ease) both; }
194
+ .qview h3 { font-size: 13.5px; margin: 16px 0 6px; }
195
+ .qview h3:first-child { margin-top: 0; }
196
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; font-size: 13px; }
197
+ .kv dt { color: var(--muted); }
198
+ .kv dd { margin: 0; overflow-wrap: anywhere; }
199
+ .pics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
200
+ .pics figure { margin: 0; border: 1px solid var(--line); border-radius: 10px; padding: 10px; min-height: 120px; }
201
+ .pics figcaption { font: 10.5px/1.4 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
202
+ .pics img { display: block; max-width: 100%; height: auto; margin: 0 auto 8px; background: #fff; border-radius: 4px; }
203
+ @media (max-width: 760px) { .pics { grid-template-columns: 1fr; } }
204
+ #qnote { min-height: 56px; }
205
+ ol.hist { margin: 0; padding-left: 18px; font-size: 12.5px; }
206
+ /* round 10 (fluid and friendly), in the Hairline style: 1px lines, ink for what matters, tones only as dots */
207
+ .skip { position: absolute; left: -999px; top: 0; background: var(--bg); color: var(--text); border: 1px solid var(--line); padding: 8px 12px; z-index: 50; border-radius: 0 0 8px 0; }
208
+ .skip:focus { left: 0; }
209
+ header .keyhint { color: var(--muted); font: 11.5px/1 var(--mono); border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px; min-height: 0; background: transparent; }
210
+ header .keyhint:hover { color: var(--text); }
211
+ .welcome h2 { font-size: 17px; margin-bottom: 6px; }
212
+ .welcome ol { margin: 10px 0 14px; padding-left: 20px; }
213
+ .welcome ol li { margin: 3px 0; }
214
+ .joblines { list-style: none; margin: 10px 0 0; padding: 0; max-height: 190px; overflow-y: auto; font: 12px/1.5 var(--mono); border-top: 1px solid var(--line); }
215
+ .joblines:empty { display: none; }
216
+ .joblines li { display: flex; gap: 10px; align-items: center; padding: 4px 2px; border-bottom: 1px solid var(--line-2); animation: fadein .4s var(--ease) both; }
217
+ .joblines .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; border: 1px solid var(--muted); }
218
+ .joblines .working .dot { border-color: var(--text); animation: pulse 1.4s var(--ease) infinite; }
219
+ .joblines .ready .dot, .joblines .done .dot, .joblines .found .dot { background: var(--ok); border-color: var(--ok); }
220
+ .joblines .held .dot, .joblines .missing .dot { background: var(--warn); border-color: var(--warn); }
221
+ .joblines .awaiting_approval .dot { background: var(--info); border-color: var(--info); }
222
+ .jobhead { display: flex; gap: 10px; align-items: center; justify-content: space-between; flex-wrap: wrap; margin-top: 10px; }
223
+ .jobhead .muted { flex: 1 1 240px; }
224
+ /* drop anywhere */
225
+ #dropall { position: fixed; inset: 0; z-index: 40; display: none; align-items: center; justify-content: center; background: color-mix(in srgb, var(--bg) 70%, transparent); backdrop-filter: blur(3px); }
226
+ #dropall.on { display: flex; }
227
+ #dropall > div { border: 1px dashed var(--text); border-radius: 14px; padding: 40px 48px; background: var(--bg); font-size: 16px; text-align: center; max-width: 90vw; animation: pop .45s var(--ease) both; }
228
+ /* toasts: ink pills stacked at the bottom, an optional action */
229
+ #toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); z-index: 60; display: flex; flex-direction: column; gap: 8px; align-items: center; width: max-content; max-width: min(560px, 92vw); pointer-events: none; }
230
+ #toast .t { pointer-events: auto; background: var(--text); color: var(--bg); padding: 7px 8px 7px 16px; min-height: 36px; border-radius: 999px; display: flex; gap: 12px; align-items: center; font-size: 12.5px; box-shadow: 0 18px 40px -18px rgba(0,0,0,.45); animation: rise .45s var(--ease) both; }
231
+ #toast .t:not(:has(button)) { padding-right: 16px; }
232
+ #toast .t.bad::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--bad); flex: none; }
233
+ #toast .t button { border: 1px solid color-mix(in srgb, var(--bg) 45%, transparent); background: transparent; color: inherit; padding: 2px 12px; min-height: 24px; font-size: 12px; border-radius: 999px; }
234
+ #toast .t button:hover { border-color: var(--bg); }
235
+ /* part cards */
236
+ .libsum { display: flex; gap: 8px 16px; flex-wrap: wrap; align-items: baseline; margin-top: 10px; }
237
+ .libsum .big { font-size: 15px; }
238
+ .libsum .big strong { font: 400 22px/1 var(--mono); letter-spacing: -.02em; }
239
+ .r9 { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 0; }
240
+ .ptools { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 14px 0 8px; }
241
+ .ptools .chips { margin: 0; }
242
+ .parts { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
243
+ .pcard { display: grid; grid-template-columns: 76px 1fr auto; gap: 14px; align-items: center; border-bottom: 1px solid var(--line-2); padding: 10px 6px; transition: background .3s var(--ease); animation: rise .5s var(--ease) both; }
244
+ .pcard:hover { background: var(--hover); }
245
+ .pcard.sel { background: var(--hover); box-shadow: inset 2px 0 0 var(--text); }
246
+ .pcard.pending { opacity: .7; }
247
+ .pcard.pending .pwhy { color: var(--text); }
248
+ .thumb { width: 76px; height: 58px; border-radius: 8px; background: var(--pcb-board); border: 1px solid #000; display: flex; align-items: center; justify-content: center; overflow: hidden; color: #9a9a9a; font: 10px/1.25 var(--mono); text-align: center; }
249
+ .thumb.none { background: transparent; border: 1px dashed var(--line); color: var(--muted); }
250
+ .thumb svg { width: 100%; height: 100%; display: block; }
251
+ .thumb svg .padnum, .thumb svg .grid { display: none; }
252
+ .pmain { min-width: 0; }
253
+ .pname { font: 500 13px/1.3 var(--mono); background: none; border: none; padding: 0; min-height: 0; color: var(--text); text-align: left; cursor: pointer; overflow-wrap: anywhere; margin-right: 6px; }
254
+ .pname:hover:not(:disabled) { text-decoration: underline; text-underline-offset: 3px; }
255
+ .pdesc { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
256
+ .pwhy { font-size: 12.5px; margin-top: 3px; }
257
+ .ptags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 5px; }
258
+ .ptags .pill { font-size: 11px; }
259
+ .r9 .pill, .ptags .pill { white-space: normal; overflow-wrap: anywhere; border-radius: 10px; }
260
+ .pacts { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
261
+ @media (max-width: 600px) { .pcard { grid-template-columns: 60px 1fr; gap: 10px; } .thumb { width: 60px; height: 48px; } .pacts { grid-column: 1 / -1; justify-content: flex-start; } .pdesc { white-space: normal; } }
262
+ /* side panel: a sheet from the right, one hairline on its edge */
263
+ #panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(500px, 100vw); z-index: 30; background: var(--bg); border-left: 1px solid var(--line); box-shadow: -30px 0 80px -40px rgba(0,0,0,.35); overflow-y: auto; transform: translateX(100%); transition: transform .5s var(--ease), visibility 0s linear .5s; visibility: hidden; }
264
+ #panel.open { transform: none; visibility: visible; transition: transform .5s var(--ease); }
265
+ #panel:focus { outline: none; }
266
+ #panel .phead { position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); padding: 16px 20px 12px; display: flex; gap: 10px; justify-content: space-between; align-items: start; z-index: 1; }
267
+ #panel .phead h2 { font: 500 14px/1.35 var(--mono); margin: 0 0 4px; overflow-wrap: anywhere; }
268
+ #panel .pbody { padding: 14px 20px 32px; }
269
+ #panel h3 { font: 500 10.5px/1.4 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 22px 0 8px; }
270
+ #panel .bigpic { border-radius: 10px; overflow: hidden; border: 1px solid #000; background: var(--pcb-board); }
271
+ #panel .bigpic svg { display: block; width: 100%; height: auto; max-height: 240px; }
272
+ .reason { border-left: 1px solid var(--warn); padding: 3px 0 3px 12px; margin: 8px 0; }
273
+ .reason .fix { color: var(--muted); font-size: 12.5px; margin-top: 3px; }
274
+ .reason .fix b { color: var(--text); font-weight: 550; }
275
+ .vs td.diff { color: var(--text); font-weight: 550; font-family: var(--mono); background: color-mix(in srgb, var(--warn) 10%, var(--bg)); }
276
+ .pactions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 14px; }
277
+ .pactions a { font-size: 12.5px; }
278
+ kbd { font: 11.5px var(--mono); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; color: var(--text); }
279
+ #keysdlg { width: min(520px, 94vw); }
280
+ #keysdlg table td { padding: 7px 8px; }
281
+ #keysdlg table td:first-child { white-space: nowrap; width: 1%; }
282
+ @keyframes jobpulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
283
+ .joblines .working .dot { animation-name: jobpulse; }
284
+ details summary { cursor: pointer; color: var(--muted); }
285
+ :is(.s-ok, .s-warn, .s-bad, .s-info, .s-off):not(.pill, .chip, .banner, .next) { color: var(--tone); }
286
+ /* library hub: every part once, a hairline column per tool */
287
+ .hubbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0 4px; }
288
+ .hubbar .chips { margin: 0; }
289
+ .hubbar label.field select { min-width: 0; }
290
+ .hubtable td, .hubtable th { white-space: nowrap; }
291
+ .hubtable td.hp { white-space: normal; min-width: 180px; max-width: 320px; }
292
+ .hubtable th.tool span { display: block; font-size: 10px; text-transform: none; letter-spacing: 0; opacity: .8; }
293
+ .hubtable td.cell { text-align: left; }
294
+ .hubtable td.cell .sub, .hubtable td .sub { font-size: 11px; color: var(--muted); font-family: var(--mono); }
295
+ .hubtable .none { color: var(--muted); opacity: .6; font-family: var(--mono); }
296
+ .hubtable tr { cursor: pointer; }
297
+ .hubtable tr:focus-visible td { background: var(--hover); outline: none; }
298
+ @media (max-width: 760px) { .hubtable td.hp, .hubtable thead th:first-child { position: sticky; left: 0; background: var(--bg); z-index: 1; min-width: 140px; box-shadow: 1px 0 0 var(--line); } }
299
+ [data-tc="altium"] { --tc: var(--t-altium); } [data-tc="altium365"] { --tc: var(--t-altium365); } [data-tc="kicad"] { --tc: var(--t-kicad); }
300
+ [data-tc="fusion"] { --tc: var(--t-fusion); } [data-tc="easyeda"] { --tc: var(--t-easyeda); } [data-tc="all"] { --tc: var(--t-all); }
301
+ .hubtools { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin: 14px 0 6px; }
302
+ .hubtool { all: unset; box-sizing: border-box; display: flex; flex-direction: column; gap: 3px; padding: 12px 14px 13px; cursor: pointer; border: 1.5px solid var(--tc, var(--line)); border-radius: 10px; font-size: 12px; color: var(--muted); transition: background .3s var(--ease), box-shadow .3s var(--ease); animation: fadein .6s var(--ease) both; min-width: 0; }
303
+ .hubtool:hover { background: color-mix(in srgb, var(--tc, var(--text)) 6%, transparent); }
304
+ .hubtool:focus-visible { outline: 2px solid var(--tc, var(--text)); outline-offset: 2px; }
305
+ .hubtool[aria-selected="true"] { background: color-mix(in srgb, var(--tc, var(--text)) 9%, transparent); box-shadow: inset 0 0 0 1.5px var(--tc, var(--text)); }
306
+ .hubtool .tn { font: 500 10.5px/1.4 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--tc, var(--text)); }
307
+ .hubtool.off { border-style: dashed; }
308
+ .tooltag { display: inline-block; border: 1px solid var(--tc, var(--line)); color: var(--tc, var(--text)); border-radius: 999px; padding: 0 8px; font-size: 11.5px; line-height: 18px; margin: 0 4px 3px 0; white-space: nowrap; }
309
+ .hubtable th.tool { color: var(--tc, var(--muted)); box-shadow: inset 0 2px 0 var(--tc, transparent); }
310
+ .hublibs .chip[data-tc] { border-color: color-mix(in srgb, var(--tc) 55%, var(--line)); }
311
+ .hublibs .chip[data-tc].active { border-color: var(--tc); background: color-mix(in srgb, var(--tc) 9%, transparent); }
312
+ #hubdlg .copies td:first-child { box-shadow: inset 3px 0 0 var(--tc, transparent); padding-left: 10px; }
313
+ #hubdlg .copies td:first-child b { color: var(--tc, var(--text)); }
314
+ .hubtool b { font: 400 26px/1.15 var(--mono); color: var(--text); letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
315
+ .hubtool .tw { overflow-wrap: anywhere; }
316
+ .hubtool.off { opacity: .6; }
317
+ @media (max-width: 760px) { .hubtools { grid-template-columns: repeat(2, minmax(0, 1fr)); } .hubtool { padding: 9px 11px 10px; gap: 1px; } .hubtool b { font-size: 20px; } .hubtool .tx { display: none; } }
318
+ .hublibs { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 2px; }
319
+ .hubstrip { display: flex; gap: 10px; align-items: center; justify-content: space-between; flex-wrap: wrap; border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; margin: 10px 0 2px; font-size: 13px; }
320
+ .hubpager { display: flex; gap: 8px; align-items: center; justify-content: space-between; margin-top: 10px; flex-wrap: wrap; }
321
+ #hubdlg { width: min(1180px, 96vw); }
322
+ #hubdlg .dlg-body { grid-template-columns: minmax(0, .9fr) minmax(0, 1.25fr); }
323
+ #hubdlg .dlg-body > div { min-width: 0; }
324
+ #hubdlg .kv dd, #hubdlg .copies td { overflow-wrap: break-word; word-break: break-word; }
325
+ #hubdlg h3 { font: 500 10.5px/1.4 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 20px 0 8px; }
326
+ #hubdlg h3:first-child { margin-top: 0; }
327
+ .hubpics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
328
+ .hubpics figure { margin: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
329
+ .hubpics figcaption { font: 10.5px/1.4 var(--mono); color: var(--muted); padding: 6px 9px; border-top: 1px solid var(--line); }
330
+ .hubpics .fpbox { background: var(--pcb-board); display: grid; place-items: center; min-height: 120px; }
331
+ .hubpics .fpbox svg { display: block; width: 100%; height: auto; max-height: 190px; }
332
+ .hubpics .symbox { background: #fffdf2; display: grid; place-items: center; min-height: 120px; padding: 6px; }
333
+ .hubpics .symbox svg { display: block; max-width: 100%; height: auto; max-height: 260px; }
334
+ svg.sym .body { fill: #ffffb0; stroke: #800000; stroke-width: 1; }
335
+ svg.sym .pin { stroke: #000; stroke-width: 1; }
336
+ svg.sym text { font: 9px Arial, Helvetica, sans-serif; fill: #000; }
337
+ svg.sym .num { fill: #444; font-size: 8px; }
338
+ svg.sym .ref { fill: #800000; font-size: 9px; }
339
+ .copies td { vertical-align: top; }
340
+ .copies .why { font-size: 12px; margin-top: 3px; }
341
+ .hubfix { display: flex; gap: 8px; flex-wrap: wrap; margin: 4px 0 2px; }
342
+ ol.hubhist { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); font-size: 12.5px; }
343
+ ol.hubhist li { display: grid; grid-template-columns: 9.5em 1fr; gap: 10px; padding: 6px 2px; border-bottom: 1px solid var(--line-2); }
344
+ ol.hubhist .t { font: 11.5px/1.5 var(--mono); color: var(--muted); }
345
+ @media (max-width: 760px) { #hubdlg .dlg-body { grid-template-columns: minmax(0, 1fr); } #hubdlg .dlg-body > div:last-child { order: -1; } #hubdlg .copies thead { display: none; } #hubdlg .copies, #hubdlg .copies tbody, #hubdlg .copies tr, #hubdlg .copies td { display: block; } #hubdlg .copies tr { padding: 8px 0; border-bottom: 1px solid var(--line-2); } #hubdlg .copies td { border: 0; padding: 2px 0; overflow-wrap: break-word; } ol.hubhist li { grid-template-columns: 1fr; gap: 0; } }
346
+ /* motion: everything eases out on (.32, .72, 0, 1), and holds still for people who asked for less */
347
+ @keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
348
+ @keyframes slide { from { opacity: 0; transform: translateX(-6px); } to { opacity: 1; transform: none; } }
349
+ @keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
350
+ @keyframes pop { from { opacity: 0; transform: translateY(10px) scale(.985); } to { opacity: 1; transform: none; } }
351
+ @keyframes toast { from { opacity: 0; transform: translate(-50%, 10px); } to { opacity: 1; transform: translate(-50%, 0); } }
352
+ @keyframes sweep { from { transform: translateX(-100%); } to { transform: translateX(340%); } }
353
+ @keyframes pulse { 0%, 100% { background: transparent; } 50% { background: var(--text); } }
354
+ @media (prefers-reduced-motion: reduce) {
355
+ *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
356
+ html { scroll-behavior: auto; }
357
+ }
358
+ </style>
359
+ </head>
360
+ <body>
361
+ <a class="skip" href="#app">Skip to content</a>
362
+ <header>
363
+ <h1>ecadlib</h1>
364
+ <span class="meta" id="meta">loading…</span>
365
+ <button class="keyhint" id="keysbtn" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)">? Shortcuts</button>
366
+ <div class="user" id="usermenu" hidden>
367
+ <span class="who" id="whoami"></span>
368
+ <button id="teambtn" data-role="admin">Users</button>
369
+ <button id="pwbtn">Change password</button>
370
+ <button id="logout">Sign out</button>
371
+ </div>
372
+ </header>
373
+ <nav class="steps" id="steps" aria-label="Sections" hidden>
374
+ <a href="#bomcard">1. Check a BOM</a><a href="#libcard" data-needs-bom>2. Fill the library</a><a href="#export" data-needs-bom>3. Send to Altium</a><a href="#hubcard">Your library</a><a href="#queuecard">Review queue<span class="count" id="navq"></span></a><a href="#fpcard">Footprints</a><a href="#statuscard">Status</a><a href="#setup">Settings</a>
375
+ </nav>
376
+ <section class="card signin" id="signin" hidden>
377
+ <h2>Sign in to ecadlib</h2>
378
+ <p class="muted small" style="margin:4px 0 0" id="signinhint">This is a shared team server. Ask your ecadlib admin for an account.</p>
379
+ <form id="signinform" autocomplete="on">
380
+ <label>User name <input type="text" id="login_user" name="username" autocomplete="username" required autofocus></label>
381
+ <label>Password <input type="password" id="login_pw" name="password" autocomplete="current-password" required></label>
382
+ <div class="row" style="margin-top:14px"><button class="primary" type="submit" id="signinbtn">Sign in</button></div>
383
+ </form>
384
+ <div id="signinerr"></div>
385
+ </section>
386
+ <main id="app">
387
+ <div id="rolenote"></div>
388
+
389
+ <section class="card welcome" id="welcome" hidden aria-labelledby="welcomeh">
390
+ <h2 id="welcomeh">Welcome to ecadlib</h2>
391
+ <p style="margin:0">ecadlib turns a BOM into a checked parts library for Altium and KiCad. Nothing is in this workspace yet. Three ways to start:</p>
392
+ <ol>
393
+ <li><b>Try it with a sample BOM</b>: seven common parts, built from ecadlib's own standard parts. Offline, no API key, about a minute.</li>
394
+ <li><b>Drop your own BOM</b> anywhere on this page (CSV or XLSX from Altium, KiCad or a spreadsheet).</li>
395
+ <li><b>Run the setup check</b> to see which part sources and tools are ready.</li>
396
+ </ol>
397
+ <div class="row">
398
+ <button class="primary" id="demobtn" data-role="librarian">Try it with a sample BOM</button>
399
+ <button id="welcomefile" data-role="librarian">Choose a BOM file…</button>
400
+ <button id="welcomedoctor" data-role="admin">Run the setup check</button>
401
+ </div>
402
+ <div id="demobusy" hidden>
403
+ <div class="jobhead"><span class="muted small" id="demobusytext" role="status" aria-live="polite"></span><button class="small" id="demostop">Stop</button></div>
404
+ <div class="progress"><div id="demobar"></div></div>
405
+ <ul class="joblines" id="demolines" aria-label="Progress per part"></ul>
406
+ </div>
407
+ <div id="demoerr"></div>
408
+ </section>
409
+
410
+ <section class="card" id="teamcard" hidden>
411
+ <div class="row spread">
412
+ <div><h2>Team users</h2>
413
+ <p class="muted small" style="margin:2px 0 0">Viewers read everything; librarians run triage, the librarian, exports and release-check overrides; approvers also sign off parts and push to Altium 365; admins also manage users and the library rules. Changes apply at once.</p></div>
414
+ <div class="row"><button id="auditbtn">Audit log</button><button id="teamclose">Close</button></div>
415
+ </div>
416
+ <div class="out" id="teamerr"></div>
417
+ <div class="tablewrap"><table>
418
+ <thead><tr><th>User</th><th>Name / e-mail</th><th>Role</th><th>Signs as</th><th>Sign-in</th><th></th></tr></thead>
419
+ <tbody id="teamrows"></tbody>
420
+ </table></div>
421
+ <h3 style="font-size:14px;margin:16px 0 0">Add a user</h3>
422
+ <form class="form" id="adduser">
423
+ <label>User name<input type="text" name="username" required autocomplete="off"></label>
424
+ <label>Full name<input type="text" name="name" autocomplete="off"></label>
425
+ <label>E-mail<input type="email" name="email" autocomplete="off"></label>
426
+ <label>Role<select name="role" id="addrole"></select></label>
427
+ <label>Initial password (10+ characters)<input type="password" name="password" autocomplete="new-password" minlength="10" required></label>
428
+ <div><button class="primary" type="submit">Add user</button></div>
429
+ </form>
430
+ <div class="out" id="auditout"></div>
431
+ </section>
432
+
433
+ <section class="card" id="bomcard">
434
+ <h2 style="margin-bottom:8px"><span class="stepno">1</span>Check a BOM</h2>
435
+ <div class="drop" id="drop" data-role="librarian" tabindex="0" role="button" aria-label="Choose a BOM file">
436
+ <div class="fig" data-fig="seat" aria-hidden="true"></div>
437
+ <div>
438
+ <strong>Drop a BOM here</strong> (CSV or XLSX), or click to choose a file.
439
+ <div class="muted small" style="margin-top:6px">Altium, KiCad and spreadsheet BOMs work. Columns such as Designator, Comment, MPN, Manufacturer, LCSC, Value and Footprint are matched automatically.</div>
440
+ </div>
441
+ <input type="file" id="file" accept=".csv,.tsv,.txt,.xlsx,.xlsm,.xls" hidden>
442
+ </div>
443
+ <div class="row" style="margin-top:10px" data-role="librarian">
444
+ <span class="muted">Or re-check one already in the workspace:</span>
445
+ <select id="boms" aria-label="BOM already in the workspace"></select>
446
+ <button id="rerun" data-role="librarian">Check again</button>
447
+ </div>
448
+ <div id="busy" class="busyrow" hidden>
449
+ <div class="busyfig" data-fig="slow" aria-hidden="true"></div>
450
+ <div>
451
+ <div class="jobhead"><span class="muted small" id="busytext" role="status" aria-live="polite"></span><button class="small" id="busystop">Stop</button></div>
452
+ <div class="progress"><div id="bar"></div></div>
453
+ <ul class="joblines" id="busylines" aria-label="Progress per BOM line"></ul>
454
+ </div>
455
+ </div>
456
+ <div id="error"></div>
457
+ <div id="history">
458
+ <div class="row spread" style="margin-top:14px">
459
+ <h2 style="margin:0">Past BOM checks</h2>
460
+ <div class="row"><input type="search" id="histq" placeholder="Filter by BOM or person…" aria-label="Filter past BOM checks"><button class="small" id="histreload">Refresh</button></div>
461
+ </div>
462
+ <div class="emptyfig" data-fig="sieve" id="histfig" aria-hidden="true" hidden></div>
463
+ <div class="muted small" id="histnote" style="margin:2px 0 6px">Every finished triage in this workspace. Opening one shows its saved result; nothing is looked up again.</div>
464
+ <div class="tablewrap" id="histwrap"><table>
465
+ <thead><tr><th>BOM</th><th>Finished (UTC)</th><th>By</th><th>Lines</th><th>Status</th><th></th></tr></thead>
466
+ <tbody id="histrows"></tbody>
467
+ </table></div>
468
+ <div id="histerr"></div>
469
+ </div>
470
+ </section>
471
+
472
+ <section class="card" id="results" hidden>
473
+ <div class="row spread">
474
+ <div><h2 id="bomname"></h2><div class="muted small" id="saved" hidden></div><div class="muted small" id="mapping"></div></div>
475
+ <div class="row small"><a id="reportmd" href="#">report.md</a> <a id="reportcsv" href="#">report.csv</a></div>
476
+ </div>
477
+ <div class="chips" id="chips"></div>
478
+ <div id="tnext"></div>
479
+ <div class="row" style="margin-bottom:8px"><input type="search" id="q" placeholder="Filter by ref, MPN, package…" aria-label="Filter lines"></div>
480
+ <div class="tablewrap"><table>
481
+ <thead><tr><th>#</th><th>Ref</th><th>MPN</th><th>Package</th><th>Status</th><th>Local symbol / footprint</th><th>Source sym / fp / 3D</th><th>Notes</th><th></th></tr></thead>
482
+ <tbody id="rows"></tbody>
483
+ </table></div>
484
+ <div class="muted small" id="shown" style="margin-top:6px"></div>
485
+ </section>
486
+
487
+ <section class="card" id="libcard" hidden>
488
+ <div class="row spread">
489
+ <div><h2><span class="stepno">2</span>Fill the library</h2>
490
+ <p class="muted small" style="margin:2px 0 0">Finds every missing symbol and footprint (official KiCad libraries, then EasyEDA, then the datasheet), converts them to Altium files and checks each part before release. Parts that fail a check are held back from Altium 365 until you look at them.</p></div>
491
+ <div class="row"><button id="librun" data-role="librarian">Fill missing parts</button><button id="librerun" hidden>Redo all</button></div>
492
+ </div>
493
+ <div id="libbusy" class="busyrow" hidden>
494
+ <div class="busyfig" data-fig="slow" aria-hidden="true"></div>
495
+ <div>
496
+ <div class="jobhead"><span class="muted small" id="libbusytext" role="status" aria-live="polite"></span><button class="small" id="libstop">Stop</button></div>
497
+ <div class="progress"><div id="libbar"></div></div>
498
+ <ul class="joblines" id="liblines" aria-label="Progress per part"></ul>
499
+ </div>
500
+ </div>
501
+ <div class="signoff" id="libtools" hidden>
502
+ <div class="row">
503
+ <label class="field small" id="signerwrap">Your name <input type="text" id="signer" placeholder="Ann Lee &lt;ann@example.com&gt;" size="28" autocomplete="name"></label>
504
+ <label class="field small" id="signrolewrap">Role <input type="text" id="signrole" placeholder="optional, e.g. librarian" size="16"></label>
505
+ <label class="field small" id="signaswrap" hidden>Sign as <select id="signas"></select></label>
506
+ <span class="small" id="signedas" hidden></span>
507
+ <button class="small" id="apprstatus">Approval status</button>
508
+ <button class="small" id="dedupe" data-role="librarian">Find duplicates</button>
509
+ </div>
510
+ <div class="muted small" style="margin-top:6px" id="signnote">There is no login: ecadlib is a local tool, so whatever name you type is recorded as given with each approval, rejection or release override (in the plan and in <span class="mono">approvals_audit.jsonl</span>). Sign-off is enforced only when an <span class="mono">approvals.json</span> sits next to the plan.</div>
511
+ <div class="out" id="approut"></div>
512
+ <div class="out" id="dedupeout"></div>
513
+ </div>
514
+ <div id="liberr"></div>
515
+ <div class="out" id="libout"></div>
516
+ </section>
517
+
518
+ <section class="card" id="export" hidden>
519
+ <h2><span class="stepno">3</span>Send to Altium</h2>
520
+ <p class="muted small" style="margin:2px 0 0">Pick one way in: a database library (DbLib) you open in Altium Designer, plain .SchLib / .PcbLib files, or managed components in an Altium 365 workspace.</p>
521
+ <div class="grid3" style="margin-top:10px">
522
+ <div class="box">
523
+ <h3>Database library (DbLib)</h3>
524
+ <p class="muted small">Writes parts.sqlite, parts.xlsx and two .DbLib files for this BOM. Open a .DbLib in Altium Designer; the SQLite one needs the SQLite3 ODBC driver, the Excel one needs the Access database engine.</p>
525
+ <button id="dblib" data-role="librarian">Export DbLib</button>
526
+ <div class="out" id="dblibout"></div>
527
+ </div>
528
+ <div class="box">
529
+ <h3>Altium library files</h3>
530
+ <p class="muted small">Converts this BOM's footprints from your KiCad libraries to .PcbLib and writes generic Resistor and Capacitor .SchLib symbols. Footprints you approve here get a .PcbLib automatically.</p>
531
+ <button id="models" data-role="librarian">Build .PcbLib / .SchLib</button>
532
+ <div class="out" id="modelsout"></div>
533
+ </div>
534
+ <div class="box">
535
+ <h3>Altium 365 workspace</h3>
536
+ <p class="muted small" id="a365hint">Plans one managed component per part. A part is ready once its .SchLib and .PcbLib exist.</p>
537
+ <div class="row">
538
+ <button id="plan" data-role="librarian">Plan release</button>
539
+ <select id="wsurl" hidden></select>
540
+ <button id="dry" disabled data-role="librarian">Dry run</button>
541
+ <button id="push" class="danger" disabled data-role="approver">Push</button>
542
+ </div>
543
+ <div class="out" id="planout"></div>
544
+ <div class="out" id="dryout"></div>
545
+ </div>
546
+ </div>
547
+ <div class="row" style="margin-top:10px"><span class="muted small">Other tools, read back pad by pad:</span>
548
+ <button class="small" id="toeagle" data-role="librarian">Fusion / EAGLE .lbr</button>
549
+ <button class="small" id="toeasyeda" data-role="librarian">EasyEDA Pro .elibz</button></div>
550
+ <div class="out" id="toolout"></div>
551
+ </section>
552
+
553
+ <section class="card" id="hubcard">
554
+ <div class="row spread">
555
+ <div><h2>Your library</h2>
556
+ <p class="muted small" style="margin:2px 0 0">Your parts in every tool you use. Pick a tool to see your library as it is there, when each part last changed and who changed it, or compare all tools side by side. Your own library files are only read; copies between tools go into ecadlib's hub library.</p></div>
557
+ <div class="row"><button class="small" id="hubrescan">Look again</button></div>
558
+ </div>
559
+ <div class="hubtools" id="hubtools" role="tablist" aria-label="Tools"></div>
560
+ <div id="hubtoolhead"></div>
561
+ <div id="hubstats"></div>
562
+ <div class="hubbar">
563
+ <input type="search" id="hubq" placeholder="Search name, MPN, maker…" aria-label="Search the library">
564
+ <div class="chips" id="hubchips" role="group" aria-label="Show"></div>
565
+ </div>
566
+ <div class="hubbar">
567
+ <label class="field small" data-hubcompare>In <select id="hubhave" aria-label="In this tool"><option value="">any tool</option></select></label>
568
+ <label class="field small" data-hubcompare>but not in <select id="hublack" aria-label="Not in this tool"><option value="">–</option></select></label>
569
+ <label class="field small">Project <select id="hubproject" aria-label="Project"><option value="">all parts</option></select></label>
570
+ <label class="field small">Sort <select id="hubsort" aria-label="Sort"><option value="name">by name</option><option value="updated">last changed first</option><option value="attention">needs a look first</option></select></label>
571
+ </div>
572
+ <div id="hubproj"></div>
573
+ <div id="hubnote"></div>
574
+ <div class="tablewrap" id="hubwrap"><table class="hubtable">
575
+ <thead id="hubhead"></thead>
576
+ <tbody id="hubrows"></tbody>
577
+ </table></div>
578
+ <div class="empty" id="hubempty" hidden><div class="emptyfig" data-fig="drawer" aria-hidden="true"></div><div class="muted small" id="hubemptytext"></div></div>
579
+ <div class="hubpager" id="hubpager"></div>
580
+ <div id="hubprob"></div>
581
+ </section>
582
+
583
+ <section class="card" id="queuecard">
584
+ <div class="row spread">
585
+ <div><h2>Review queue</h2>
586
+ <p class="muted small" style="margin:2px 0 0">Parts the library agent found and built. Each one is checked against its datasheet. Look at the before and after pictures and the proof, then approve, reject or ask for a rebuild. An approval is also recorded in the part's plan (<span class="mono">approvals_audit.jsonl</span>), so the release check and the Altium 365 push see it.</p></div>
587
+ <div class="row"><select id="qbatch" aria-label="Batch"><option value="">All batches</option></select><button class="small" id="qreload">Refresh</button></div>
588
+ </div>
589
+ <div class="chips" id="qchips"></div>
590
+ <div id="qbatchinfo" class="small"></div>
591
+ <div id="qerr"></div>
592
+ <div class="tablewrap" id="qwrap"><table>
593
+ <thead><tr><th>Part</th><th>Change</th><th>Status</th><th>Proof</th><th>Batch</th><th>Updated (UTC)</th><th></th></tr></thead>
594
+ <tbody id="qrows"></tbody>
595
+ </table></div>
596
+ <div class="empty" id="qemptywrap" hidden><div class="emptyfig" data-fig="drawer" aria-hidden="true"></div><div class="muted small" id="qempty">Nothing in the queue. The library agent adds parts here when a watched project or BOM changes.</div></div>
597
+ <div class="qview" id="qview" hidden></div>
598
+ </section>
599
+
600
+ <section class="card" id="fpcard">
601
+ <div class="row spread">
602
+ <div><h2>Footprint generator</h2>
603
+ <p class="muted small" style="margin:2px 0 0">IPC-7351B land patterns from datasheet dimensions. You preview each one; it is only saved (KiCad .kicad_mod and Altium .PcbLib) when you approve it.</p></div>
604
+ <button id="newfp">New footprint…</button>
605
+ </div>
606
+ </section>
607
+
608
+ <section class="card" id="statuscard">
609
+ <div class="row spread">
610
+ <div><h2>Status</h2>
611
+ <p class="muted small" style="margin:2px 0 0">What is in this workspace and what is waiting for someone.</p></div>
612
+ <div class="row"><button class="small" id="streload">Refresh</button><button class="small" id="doctorbtn" data-role="admin">Run setup check</button></div>
613
+ </div>
614
+ <div id="stout" class="out"></div>
615
+ <div id="doctorout" class="out"></div>
616
+ </section>
617
+
618
+ <section class="card" id="setup">
619
+ <div class="row spread">
620
+ <div><h2>Settings: library rules</h2>
621
+ <p class="muted small" style="margin:2px 0 0" id="rulesstate">Loading…</p></div>
622
+ <button id="rulesscan" data-role="admin">Set up from my libraries…</button>
623
+ </div>
624
+ <div id="rulesform" hidden style="margin-top:10px">
625
+ <label class="muted small" for="rulespaths">Folders or files to scan, one per line: your Altium library folders (.SchLib / .PcbLib), a .DbLib, KiCad libraries, BOMs, an Altium 365 component list (.json). Leave empty to scan the --lib folders and uploaded BOMs. Only names, parameters and pad and layer data are read.</label>
626
+ <textarea id="rulespaths" style="min-height:64px" spellcheck="false" placeholder="D:\Altium\Libraries&#10;D:\Altium\house.DbLib"></textarea>
627
+ <div class="row" style="margin-top:8px"><button class="primary" id="rulesgo">Scan and propose</button><span class="muted small">Nothing is written until you save.</span></div>
628
+ </div>
629
+ <div id="rulesbusy" class="muted small" hidden>Scanning your libraries…<div class="progress"><div style="width:30%"></div></div></div>
630
+ <div id="rulesout" class="out"></div>
631
+ <div id="rulesfile" hidden>
632
+ <div class="muted small" style="margin:10px 0 4px">The proposed <span class="mono">ecadlib.toml</span>. Each value says where it came from; edit anything before saving.</div>
633
+ <textarea id="rulestoml" spellcheck="false" aria-label="Proposed ecadlib.toml"></textarea>
634
+ <div class="row" style="margin-top:8px"><button id="rulesapply" hidden>Apply my answers</button><button class="primary" id="rulessave" data-role="admin">Save ecadlib.toml</button><button class="danger" id="rulesreplace" hidden>Replace the existing file</button></div>
635
+ <div id="rulesdiff"></div>
636
+ </div>
637
+ </section>
638
+
639
+ <section class="card" id="style">
640
+ <div class="row spread">
641
+ <div><h2>Settings: your house style</h2>
642
+ <p class="muted small" style="margin:2px 0 0" id="stylestate">Loading…</p></div>
643
+ <button id="styleopen" data-role="admin">Teach it my style…</button>
644
+ </div>
645
+ <div id="styleform" hidden style="margin-top:10px">
646
+ <label class="muted small" for="stylepaths">Your library folders or files, one per line (empty: the --lib folders). ecadlib learns how your parts are named and described, how values are written, which parameters they carry and how active-low pins are drawn.</label>
647
+ <textarea id="stylepaths" style="min-height:52px" spellcheck="false" placeholder="D:\Altium\Libraries"></textarea>
648
+ <label class="muted small" for="styledir" style="display:block;margin-top:8px">Your direction, one instruction per line. It wins over what the library does today.</label>
649
+ <textarea id="styledir" style="min-height:64px" placeholder="IC descriptions start with the function in capitals&#10;Write tolerances as ±1%"></textarea>
650
+ <div class="row" style="margin-top:8px"><label class="small"><input type="checkbox" id="styleai"> Let my AI model write a style guide from this (uses the model set in ecadlib.toml: Claude, another API or a local one)</label></div>
651
+ <div class="row" style="margin-top:8px"><button class="primary" id="stylego">Learn my style</button><span class="muted small">Saved next to your library rules. New parts follow it.</span></div>
652
+ </div>
653
+ <div id="stylebusy" class="muted small" hidden>Learning your style…<div class="progress"><div style="width:30%"></div></div></div>
654
+ <div id="styleout" class="out"></div>
655
+ </section>
656
+ </main>
657
+
658
+ <dialog id="dlg">
659
+ <div class="dlg-head">
660
+ <strong id="dlgtitle">Generate footprint</strong>
661
+ <div class="row">
662
+ <select id="example" aria-label="Example package"><option value="">Start from an example…</option></select>
663
+ <select id="density" aria-label="Density"><option value="">density (from spec)</option><option value="A">A (most)</option><option value="B">B (nominal)</option><option value="C">C (least)</option></select>
664
+ <button id="close">Close</button>
665
+ </div>
666
+ </div>
667
+ <div class="dlg-body">
668
+ <div>
669
+ <div class="muted small" style="margin-bottom:6px">Package dimensions in mm, min/max from the datasheet drawing (<span class="mono">package_schema.json</span>)</div>
670
+ <textarea id="spec" spellcheck="false" aria-label="Package dimensions JSON"></textarea>
671
+ </div>
672
+ <div>
673
+ <div class="preview" id="preview"></div>
674
+ <div class="layertabs" aria-label="Layers, coloured as in Altium Designer"><span>LS</span><span class="on"><i style="background:var(--pcb-top)"></i>Top Layer</span><span><i style="background:var(--pcb-multi)"></i>Multi-Layer</span><span><i style="background:var(--pcb-fab)"></i>Top Assembly</span><span><i style="background:var(--pcb-crtyd)"></i>Top Courtyard</span><span><i style="background:var(--pcb-hole)"></i>Drill</span></div>
675
+ <div class="nums" id="nums"></div>
676
+ <div id="dlgerr"></div>
677
+ <div class="row" style="margin-top:12px; justify-content:flex-end">
678
+ <button id="previewbtn">Preview</button>
679
+ <button id="approve" class="primary" disabled data-role="librarian">Approve and add to library</button>
680
+ </div>
681
+ </div>
682
+ </div>
683
+ </dialog>
684
+ <dialog id="pickdlg">
685
+ <div class="dlg-head">
686
+ <strong id="picktitle">Pick a symbol or footprint</strong>
687
+ <div class="row">
688
+ <select id="pickkind" aria-label="Which half"><option value="">missing half</option><option value="symbol">symbol</option><option value="footprint">footprint</option></select>
689
+ <button id="pickclose">Close</button>
690
+ </div>
691
+ </div>
692
+ <div style="padding:14px 16px">
693
+ <div id="pickinfo" class="small"></div>
694
+ <div id="pickbusy" class="muted small" hidden><span id="pickbusytext">Looking for candidates…</span><div class="progress"><div id="pickbar"></div></div></div>
695
+ <div id="pickerr"></div>
696
+ <div id="picklist"></div>
697
+ </div>
698
+ </dialog>
699
+ <dialog id="pwdlg">
700
+ <form method="dialog" id="pwform" style="padding:16px; min-width:min(360px, 90vw)">
701
+ <strong id="pwtitle">Change password</strong>
702
+ <label class="muted small" id="pwcurwrap" style="display:block;margin-top:10px">Current password<input type="password" id="pwcur" autocomplete="current-password" style="width:100%"></label>
703
+ <label class="muted small" style="display:block;margin-top:10px">New password (10+ characters)<input type="password" id="pwnew" autocomplete="new-password" minlength="10" style="width:100%"></label>
704
+ <label class="muted small" style="display:block;margin-top:10px">New password again<input type="password" id="pwnew2" autocomplete="new-password" style="width:100%"></label>
705
+ <div id="pwerr"></div>
706
+ <div class="row" style="margin-top:12px;justify-content:flex-end"><button value="cancel" id="pwcancel" formnovalidate>Cancel</button><button class="primary" id="pwok" value="ok">Save</button></div>
707
+ </form>
708
+ </dialog>
709
+ <dialog id="hubdlg" aria-labelledby="hubtitle">
710
+ <div class="dlg-head"><div><strong id="hubtitle"></strong><div class="muted small" id="hubsub"></div></div><button id="hubclose">Close</button></div>
711
+ <div class="dlg-body" id="hubbody"></div>
712
+ </dialog>
713
+ <div id="toast" role="status" aria-live="polite"></div>
714
+ <div id="dropall" aria-hidden="true"><div><strong>Drop the BOM to check it</strong><div class="muted small" style="margin-top:6px">CSV or XLSX, up to 20 MB</div></div></div>
715
+ <aside id="panel" aria-labelledby="ptitle" tabindex="-1">
716
+ <div class="phead"><div><h2 id="ptitle"></h2><div class="muted small" id="psub"></div></div><button class="small" id="pclose" aria-label="Close the part panel">Close</button></div>
717
+ <div class="pbody" id="pbody"></div>
718
+ </aside>
719
+ <dialog id="keysdlg" aria-labelledby="keystitle">
720
+ <div class="dlg-head"><strong id="keystitle">Keyboard shortcuts</strong><button id="keysclose">Close</button></div>
721
+ <div style="padding:10px 16px 16px"><table><tbody>
722
+ <tr><td><kbd>?</kbd></td><td>Show this list</td></tr>
723
+ <tr><td><kbd>/</kbd></td><td>Search parts (or the BOM lines)</td></tr>
724
+ <tr><td><kbd>j</kbd> <kbd>k</kbd></td><td>Next / previous part</td></tr>
725
+ <tr><td><kbd>Enter</kbd></td><td>Open the part under focus</td></tr>
726
+ <tr><td><kbd>Esc</kbd></td><td>Close the part panel or a dialog</td></tr>
727
+ <tr><td><kbd>h</kbd></td><td>Show held parts only (again: all)</td></tr>
728
+ <tr><td><kbd>u</kbd></td><td>Choose a BOM file</td></tr>
729
+ <tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td><td>Go to step 1, 2 or 3</td></tr>
730
+ <tr><td><kbd>l</kbd></td><td>Go to your library (every part across tools)</td></tr>
731
+ <tr><td><kbd>q</kbd> / <kbd>s</kbd></td><td>Go to the review queue / status</td></tr>
732
+ </tbody></table>
733
+ <p class="muted small" style="margin:10px 0 0">Shortcuts are off while you type in a box. You can also drop a BOM file anywhere on the page.</p></div>
734
+ </dialog>
735
+ <script type="module">
736
+ // Line figures from Hairline (github.com/lucasmarkes/hairline): each [data-fig] element gets one. They only decorate,
737
+ // so a figure that fails to draw is skipped and the page works without it.
738
+ import * as HL from "/hairline.js";
739
+ const OPTS = { seat: { play: true }, slow: { intensity: 0.3 }, sieve: {}, drawer: {} };
740
+ for (const el of document.querySelectorAll("[data-fig]")) {
741
+ try { HL[el.dataset.fig](el, OPTS[el.dataset.fig]); } catch (e) { el.remove(); }
742
+ }
743
+ </script>
744
+
745
+ <script>
746
+ const $ = s => document.querySelector(s);
747
+ const STATUS = {
748
+ "in_library": ["In library", "s-ok"],
749
+ "in_library (generated footprint)": ["In library (generated fp)", "s-ok"],
750
+ "available_easyeda": ["Available (EasyEDA)", "s-info"],
751
+ "easyeda_footprint_only": ["Footprint only (EasyEDA)", "s-warn"],
752
+ "needs_footprint_generation": ["Needs footprint", "s-bad"],
753
+ "unverified (source unreachable)": ["Unverified (source unreachable)", "s-warn"],
754
+ "dnp (not fitted)": ["Not fitted (DNP)", "s-off"],
755
+ };
756
+ const GENERATE = new Set(["needs_footprint_generation", "unverified (source unreachable)", "easyeda_footprint_only"]);
757
+ let current = null, filter = null, specs = {}, info = null, busy = false;
758
+
759
+ let session = { team: false };
760
+ const RANK = { viewer: 0, librarian: 1, approver: 2, admin: 3 };
761
+ const can = role => !session.team || (session.user && RANK[session.user.role] >= RANK[role]);
762
+ async function api(path, body, raw) {
763
+ const opt = body === undefined ? { credentials: "same-origin" } : { method: "POST", credentials: "same-origin", body: raw ? body : JSON.stringify(body),
764
+ headers: raw ? { "X-Filename": encodeURIComponent(raw) } : { "Content-Type": "application/json" } };
765
+ if (body !== undefined && session.csrf) opt.headers["X-CSRF-Token"] = session.csrf;
766
+ let r;
767
+ try { r = await fetch(path, opt); } catch (e) { throw new Error(session.team ? "The ecadlib server isn't responding." : "ecadlib isn't responding. Is it still running in your terminal?"); }
768
+ const j = await r.json().catch(() => ({ error: r.statusText }));
769
+ if (r.status === 401 && session.team && path !== "/api/login") { showSignin("Your session ended. Sign in again."); }
770
+ if (!r.ok) { const e = new Error(j.error || r.statusText); e.status = r.status; throw e; }
771
+ return j;
772
+ }
773
+ const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
774
+ const fileUrl = p => "/api/file?path=" + encodeURIComponent(p);
775
+ const fileLink = p => `<a href="${fileUrl(p)}">${esc(p.split("/").pop())}</a>`;
776
+ // toasts: a short line at the bottom; an optional action button (Undo, Open); stacked, newest last, max 3
777
+ function toast(msg, opt = {}) {
778
+ const box = $("#toast"), t = document.createElement("div");
779
+ t.className = "t" + (opt.bad ? " bad" : "");
780
+ const span = document.createElement("span"); span.textContent = msg; t.appendChild(span);
781
+ let done = false;
782
+ const close = () => { if (done) return; done = true; t.remove(); clearTimeout(t.timer); if (opt.onClose) opt.onClose(); };
783
+ if (opt.action) {
784
+ const b = document.createElement("button"); b.textContent = opt.action;
785
+ b.onclick = () => { const f = opt.onAction; opt.onClose = null; close(); f && f(); };
786
+ t.appendChild(b);
787
+ }
788
+ box.appendChild(t);
789
+ while (box.children.length > 3) box.firstElementChild.remove();
790
+ t.timer = setTimeout(close, opt.ms || (opt.action ? 6000 : 3600));
791
+ return { close, el: t };
792
+ }
793
+ const reduced = () => window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
794
+ const motion = () => reduced() ? "auto" : "smooth";
795
+ function banner(el, msg, cls = "s-bad") {
796
+ el.innerHTML = msg ? `<div class="banner ${cls}"><span>${esc(msg)}</span><button aria-label="Dismiss">×</button></div>` : "";
797
+ if (msg) el.querySelector("button").onclick = () => el.innerHTML = "";
798
+ }
799
+ const showErr = e => banner($("#error"), e ? String(e.message || e) : "");
800
+ // "what happened, what next" line under an action; html is built here with esc() around every value
801
+ function nextStep(el, html, go, cls = "s-info") {
802
+ el.innerHTML = html ? `<div class="next ${cls}" role="status"><span>${html}</span>${go ? `<button class="small" data-go="${esc(go[0])}">${esc(go[1])}</button>` : ""}</div>` : "";
803
+ }
804
+ function goTo(id) {
805
+ const el = document.getElementById(id);
806
+ if (!el) return;
807
+ if (el.hidden) return toast("Check a BOM first (step 1), or open one under Past BOM checks.");
808
+ el.scrollIntoView({ behavior: motion(), block: "start" });
809
+ const f = el.querySelector("button.primary:not(:disabled), button:not(:disabled):not([data-role-hidden])");
810
+ if (f) f.focus({ preventScroll: true });
811
+ }
812
+ document.addEventListener("click", e => { const g = e.target.closest && e.target.closest("[data-go]"); if (g) { e.preventDefault(); goTo(g.dataset.go); } });
813
+ // the step bar underlines the section in the middle of the screen
814
+ const spy = new IntersectionObserver(es => es.forEach(e => {
815
+ if (e.isIntersecting) document.querySelectorAll("#steps a").forEach(a => a.classList.toggle("here", a.getAttribute("href") === "#" + e.target.id));
816
+ }), { rootMargin: "-40% 0px -55% 0px" });
817
+ document.querySelectorAll("#steps a").forEach(a => { const t = document.getElementById(a.getAttribute("href").slice(1)); if (t) spy.observe(t); });
818
+ $("#steps").addEventListener("click", e => {
819
+ const a = e.target.closest("a"); if (!a) return;
820
+ e.preventDefault(); goTo(a.getAttribute("href").slice(1));
821
+ });
822
+ const hasBom = () => !!(current && current.bom);
823
+ function navState() { document.querySelectorAll("#steps [data-needs-bom]").forEach(a => { a.classList.toggle("off", !hasBom()); a.title = hasBom() ? "" : "Check a BOM first"; }); }
824
+ const when = ts => esc(String(ts || "").replace("T", " ").replace(/(\+00:00|Z)$/, " UTC"));
825
+
826
+ // uploads/<YYYYMMDD-HHMMSS>-<rand>/<name> -> "name (uploaded YYYY-MM-DD HH:MM:SS UTC, #rand)"; other paths as they are
827
+ const UPLOAD = /^uploads\/(\d{4})(\d\d)(\d\d)-(\d\d)(\d\d)(\d\d)-([0-9a-f]+)\/(.+)$/;
828
+ function uploadNote(b) {
829
+ const m = UPLOAD.exec(b || "");
830
+ return m ? `uploaded ${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]} UTC, #${m[7]}` : b;
831
+ }
832
+ const bomLabel = b => UPLOAD.test(b || "") ? `${UPLOAD.exec(b)[8]} (${uploadNote(b)})` : b;
833
+ async function loadInfo() {
834
+ info = await api("/api/info");
835
+ const prov = info.providers.filter(p => p.enabled).map(p => p.name + (p.configured ? "" : " (no key)")).join(", ");
836
+ $("#meta").title = `workspace ${info.workspace}`;
837
+ $("#meta").textContent = `workspace ${info.workspace.split(/[\\/]/).pop()} · ${info.symbols} symbols, ${info.footprints} footprints indexed` +
838
+ (info.offline ? " · offline" : "") + ` · sources: ${prov}`;
839
+ const sel = $("#boms"), keep = sel.value;
840
+ sel.innerHTML = info.boms.map(b => `<option value="${esc(b)}">${esc(bomLabel(b))}</option>`).join("") || "<option value=''>(none yet)</option>";
841
+ if (info.boms.includes(keep)) sel.value = keep;
842
+ $("#rerun").disabled = busy || !info.boms.length; sel.disabled = !info.boms.length;
843
+ $("#rerun").title = info.boms.length ? "" : "No BOM in the workspace yet: drop one above";
844
+ $("#a365hint").textContent = info.altium365.token
845
+ ? "Plans one managed component per part. A part is ready once its .SchLib and .PcbLib exist. Dry run shows the requests without sending anything."
846
+ : "Plans one managed component per part (ready once its .SchLib and .PcbLib exist). To push, restart ecadlib with NEXAR_TOKEN set; Dry run works without it.";
847
+ welcome();
848
+ }
849
+
850
+ function setBusy(on, text = "", frac = null) {
851
+ busy = on; $("#busy").hidden = !on; $("#busytext").textContent = text;
852
+ $("#bar").style.width = frac == null ? "0" : `${Math.round(frac * 100)}%`;
853
+ ["#rerun", "#file"].forEach(s => $(s).disabled = on);
854
+ }
855
+
856
+ const triageWords = t => { const todo = t.rows.filter(r => !String(r.status).startsWith("in_library") && !String(r.status).startsWith("dnp")).length;
857
+ return todo ? `${t.rows.length - todo} of ${t.rows.length} lines already in your library, ${todo} need work` : `all ${t.rows.length} lines already in your library`; };
858
+ async function runJob(start, label) {
859
+ if (busy) return;
860
+ showErr(); setBusy(true, `Checking ${label}…`); $("#busylines").innerHTML = "";
861
+ try {
862
+ const { job } = await start;
863
+ const j = await pollJob(job, { text: $("#busytext"), bar: $("#bar"), lines: $("#busylines"), stop: $("#busystop"),
864
+ label: j => j.total ? `Checking ${label}: line ${Math.min(j.done + 1, j.total)} of ${j.total}${secs(j)}` : `Checking ${label}…` });
865
+ showResult(j.result); loadHistory().catch(() => {}); loadStatus().catch(() => {}); loadHub().catch(() => {});
866
+ toast(`Checked ${j.result.name || label}: ${triageWords(j.result)}.`);
867
+ $("#results").scrollIntoView({ behavior: motion(), block: "start" });
868
+ } catch (e) {
869
+ if (e.cancelled) toast("Stopped checking the BOM. Nothing was saved.");
870
+ else { showErr(e); $("#error").scrollIntoView({ behavior: motion(), block: "center" }); }
871
+ }
872
+ finally { setBusy(false); loadInfo().then(() => { if (current && current.bom) $("#boms").value = current.bom; welcome(); }).catch(() => {}); }
873
+ }
874
+
875
+ function showResult(t) {
876
+ current = t; filter = null; $("#q").value = ""; planned = null; parts = null; pfilter = ""; closePanel();
877
+ ["#dblibout", "#modelsout", "#planout", "#dryout", "#libout", "#liberr", "#approut", "#dedupeout", "#toolout"].forEach(s => $(s).innerHTML = ""); $("#librerun").hidden = true; $("#libtools").hidden = true;
878
+ $("#dry").disabled = $("#push").disabled = true; disabledWhy(); render();
879
+ if (t.bom && !libbusy) loadParts(true).catch(() => {}); // a plan from an earlier run: show its parts at once
880
+ }
881
+
882
+ function render() {
883
+ const t = current;
884
+ $("#results").hidden = false;
885
+ $("#export").hidden = $("#libcard").hidden = !t.bom; // an old triage whose BOM isn't known: read-only view
886
+ $("#bomname").textContent = t.name || (t.bom || "").split("/").pop();
887
+ $("#saved").hidden = !t.finished;
888
+ $("#saved").textContent = t.finished ? `Triage finished ${t.finished.replace("T", " ").replace("+00:00", " UTC")}` + (t.user ? ` by ${t.user}` : "") +
889
+ (t.bom ? ` · ${bomLabel(t.bom)}` : "") : "";
890
+ $("#mapping").textContent = "Columns used: " + Object.entries(t.mapping).map(([k, v]) => `${k} ← "${v}"`).join(", ");
891
+ $("#reportmd").href = fileUrl(t.reports[0]); $("#reportcsv").href = fileUrl(t.reports[1]);
892
+ $("#chips").innerHTML = Object.entries(t.counts).map(([k, v]) => {
893
+ const [lab, cls] = STATUS[k] || [k, "s-info"];
894
+ return `<span class="chip ${cls} ${filter === k ? "active" : ""}" data-s="${esc(k)}" role="button" tabindex="0">${esc(lab)}: ${v}</span>`;
895
+ }).join("");
896
+ const q = $("#q").value.trim().toLowerCase();
897
+ const rows = t.rows.filter(r => (!filter || r.status === filter) &&
898
+ (!q || [r.designator, r.mpn, r.manufacturer, r.value, r.package, r.lcsc].join(" ").toLowerCase().includes(q)));
899
+ $("#rows").innerHTML = rows.map(r => {
900
+ const [lab, cls] = STATUS[r.status] || [r.status, "s-info"];
901
+ const src = [r.src_symbol, r.src_footprint, r.src_3d].map(x => x || "–").join(" / ");
902
+ const act = GENERATE.has(r.status) ? `<button data-gen="${esc(r.line)}">${can("librarian") ? "Generate…" : "Preview…"}</button>` : "";
903
+ return `<tr><td>${r.line}</td><td>${esc(r.designator)}</td><td><code>${esc(r.mpn) || "–"}</code><div class="muted">${esc(r.manufacturer)}</div></td>
904
+ <td>${esc(r.package)}<div class="muted">${esc(r.value)}</div></td><td><span class="pill ${cls}">${esc(lab)}</span></td>
905
+ <td><div>${esc(r.local_symbol) || "<span class=muted>–</span>"}</div><div class="muted mono">${esc(r.local_footprint)}</div></td>
906
+ <td>${r.src ? esc(r.src) + ": " : ""}${src}</td><td class="notes">${esc(r.notes)}</td><td>${act}</td></tr>`;
907
+ }).join("") || `<tr><td colspan="9" class="muted">No lines match.</td></tr>`;
908
+ const todo = t.rows.filter(r => !String(r.status).startsWith("in_library") && !String(r.status).startsWith("dnp")).length;
909
+ if (!t.bom) nextStep($("#tnext"), "This is a saved result. Its BOM file is no longer in the workspace, so upload the BOM again to work on it.", null, "s-warn");
910
+ else if (!todo) nextStep($("#tnext"), `All ${t.rows.length} lines are already in your library. Next: step 2 builds the Altium files and checks each part before release.`, ["libcard", "Go to step 2"]);
911
+ else nextStep($("#tnext"), `<b>${todo} of ${t.rows.length}</b> lines need a symbol or footprint (filter with the chips above). Next: ` +
912
+ (can("librarian") ? "step 2, <i>Fill missing parts</i>, looks them up and builds them." : "a librarian fills them in (step 2)."), ["libcard", "Go to step 2"]);
913
+ navState();
914
+ $("#shown").textContent = rows.length === t.rows.length ? `${t.rows.length} lines` : `${rows.length} of ${t.rows.length} lines shown`;
915
+ }
916
+
917
+ const chipToggle = e => { const s = e.target.dataset.s; if (!s) return; filter = filter === s ? null : s; render(); };
918
+ $("#chips").addEventListener("click", chipToggle);
919
+ $("#chips").addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); chipToggle(e); } });
920
+ $("#q").addEventListener("input", () => current && render());
921
+ $("#rows").addEventListener("click", e => { const n = e.target.dataset.gen; if (n) openGen(current.rows.find(r => r.line == n)); });
922
+
923
+ const drop = $("#drop");
924
+ drop.addEventListener("click", () => !busy && $("#file").click());
925
+ drop.addEventListener("keydown", e => { if ((e.key === "Enter" || e.key === " ") && !busy) { e.preventDefault(); $("#file").click(); } });
926
+ // drop a BOM anywhere on the page
927
+ const hasFiles = e => e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files");
928
+ let dragDepth = 0;
929
+ document.addEventListener("dragenter", e => { if (!hasFiles(e) || !can("librarian") || $("#app").hidden) return; e.preventDefault(); dragDepth++; $("#dropall").classList.add("on"); drop.classList.add("over"); });
930
+ document.addEventListener("dragover", e => { if (hasFiles(e)) e.preventDefault(); });
931
+ document.addEventListener("dragleave", e => { if (!hasFiles(e)) return; if (--dragDepth <= 0) { dragDepth = 0; $("#dropall").classList.remove("on"); drop.classList.remove("over"); } });
932
+ document.addEventListener("drop", e => {
933
+ if (!hasFiles(e)) return;
934
+ e.preventDefault(); dragDepth = 0; $("#dropall").classList.remove("on"); drop.classList.remove("over");
935
+ if (!can("librarian") || $("#app").hidden) return;
936
+ const f = e.dataTransfer.files[0];
937
+ if (f) { if (busy) toast("A BOM is still being checked; drop this one when it has finished.", { bad: true }); else upload(f); }
938
+ });
939
+ $("#file").addEventListener("change", e => { if (e.target.files[0]) upload(e.target.files[0]); e.target.value = ""; });
940
+ async function upload(f) {
941
+ if (busy) return;
942
+ if (f.size > 20 * 1024 * 1024) return showErr("That file is over 20 MB, which is too big for a BOM.");
943
+ $("#bomcard").scrollIntoView({ behavior: motion(), block: "start" });
944
+ runJob(api("/api/upload", await f.arrayBuffer(), f.name), f.name);
945
+ }
946
+ $("#rerun").addEventListener("click", () => { const b = $("#boms").value; if (b) runJob(api("/api/triage", { bom: b }), b); });
947
+
948
+ // ---- first visit: an empty workspace offers the sample BOM and the setup check
949
+ function welcome() {
950
+ const empty = info && !info.boms.length && !(history || []).length;
951
+ $("#welcome").hidden = !(empty && can("librarian")) && !demoRunning;
952
+ }
953
+ let demoRunning = false;
954
+ $("#demobtn").addEventListener("click", async () => {
955
+ if (demoRunning || busy) return;
956
+ demoRunning = true; $("#demobtn").disabled = true; banner($("#demoerr"), "");
957
+ $("#demobusy").hidden = false; $("#demolines").innerHTML = ""; $("#demobusytext").textContent = "Starting…";
958
+ try {
959
+ const { job } = await api("/api/demo", {});
960
+ const j = await pollJob(job, { text: $("#demobusytext"), bar: $("#demobar"), lines: $("#demolines"), stop: $("#demostop"),
961
+ label: j => (j.total ? `Building the sample library: ${Math.min(j.done + 1, j.total)} of ${j.total}` : "Checking the sample BOM") + `${secs(j)}…` });
962
+ showResult(j.result.triage); await showLibrarian(j.result);
963
+ toast(`Sample library built: ${counts({ ready: j.result.ready, held: j.result.held, waiting: j.result.awaiting_approval })}.`);
964
+ demoRunning = false; $("#welcome").hidden = true;
965
+ loadHistory().catch(() => {}); loadStatus().catch(() => {}); loadHub().catch(() => {}); await loadInfo().catch(() => {});
966
+ if (current && current.bom) $("#boms").value = current.bom;
967
+ goTo("libcard");
968
+ } catch (e) {
969
+ if (e.cancelled) toast("Stopped the sample. Nothing was added to your library.");
970
+ else banner($("#demoerr"), e.message);
971
+ } finally { demoRunning = false; $("#demobtn").disabled = false; $("#demobusy").hidden = true; }
972
+ });
973
+ $("#welcomefile").addEventListener("click", () => $("#file").click());
974
+ $("#welcomedoctor").addEventListener("click", () => { goTo("statuscard"); $("#doctorbtn").click(); });
975
+
976
+ // ---- past triages (any signed-in user: finished results are library data; running jobs stay private)
977
+ let history = [];
978
+ async function loadHistory() {
979
+ history = (await api("/api/triage/history")).triages; renderHistory(); welcome();
980
+ }
981
+ function renderHistory() {
982
+ const q = $("#histq").value.trim().toLowerCase();
983
+ const rows = history.filter(h => !q || [h.name, h.bom, h.user].join(" ").toLowerCase().includes(q));
984
+ $("#histwrap").hidden = !history.length; $("#histfig").hidden = !!history.length;
985
+ $("#histnote").textContent = history.length ? "Every finished triage in this workspace. Opening one shows its saved result; nothing is looked up again."
986
+ : "No triages yet." + (can("librarian") ? " Drop a BOM above to run the first one." : " A librarian runs them; they show up here.");
987
+ $("#histrows").innerHTML = rows.map(h => {
988
+ const todo = Object.entries(h.counts || {}).filter(([k]) => !k.startsWith("in_library") && !k.startsWith("dnp")).reduce((a, [, v]) => a + v, 0);
989
+ return `<tr><td>${esc(h.name)}<div class="muted sub">${esc(h.bom ? uploadNote(h.bom) : "BOM not recorded")}${h.bom && !h.bom_exists ? " (file gone)" : ""}</div></td>
990
+ <td class="mono small">${esc((h.finished || "").replace("T", " ").replace("+00:00", ""))}</td><td>${esc(h.user || "–")}</td><td>${esc(h.lines)}</td>
991
+ <td>${todo ? `<span class="pill s-warn">${todo} need work</span>` : '<span class="pill s-ok">all in library</span>'}</td>
992
+ <td><button class="small" data-open="${esc(h.id)}">Open</button></td></tr>`;
993
+ }).join("") || `<tr><td colspan="6" class="muted">No triages match.</td></tr>`;
994
+ }
995
+ $("#histq").addEventListener("input", renderHistory);
996
+ $("#histreload").addEventListener("click", () => loadHistory().catch(e => banner($("#histerr"), e.message)));
997
+ $("#histrows").addEventListener("click", async e => {
998
+ const id = e.target.dataset.open; if (!id || busy) return;
999
+ banner($("#histerr"), "");
1000
+ try { showResult(await api("/api/triage/result?id=" + encodeURIComponent(id))); if (current.bom && info && info.boms.includes(current.bom)) $("#boms").value = current.bom; $("#results").scrollIntoView({ behavior: motion() }); }
1001
+ catch (err) { banner($("#histerr"), err.message); }
1002
+ });
1003
+
1004
+ // ---- Altium
1005
+ function disabledWhy() {
1006
+ if ($("#dry").disabled) $("#dry").title = "Click Plan release first";
1007
+ if ($("#push").disabled) $("#push").title = !info || !info.altium365.token ? "Needs NEXAR_TOKEN: restart ecadlib with it set" : planned && !planned.ready ? "No part is ready to release" : "Click Plan release first";
1008
+ else $("#push").title = "";
1009
+ }
1010
+ async function action(btn, out, fn) {
1011
+ btn.disabled = true; const old = btn.textContent; btn.textContent = old + "…";
1012
+ try { out.innerHTML = await fn(); } catch (e) { banner(out, e.message); }
1013
+ finally { btn.disabled = false; btn.textContent = old; }
1014
+ }
1015
+ $("#dblib").addEventListener("click", e => action(e.target, $("#dblibout"), async () => {
1016
+ const r = await api("/api/export/dblib", { bom: current.bom });
1017
+ return "Written: " + r.files.map(fileLink).join(" · ") +
1018
+ `<div class="next s-info"><span>Next: copy these files next to your Altium libraries and open a .DbLib in Altium Designer. The SQLite one needs the SQLite3 ODBC driver; the Excel one needs the Access database engine.</span></div>`;
1019
+ }));
1020
+ $("#models").addEventListener("click", e => action(e.target, $("#modelsout"), async () => {
1021
+ const r = await api("/api/altium/models", { bom: current.bom });
1022
+ await loadInfo();
1023
+ return `${r.footprints.length} footprint(s) as .PcbLib${r.footprints.length ? ": " + esc(r.footprints.join(", ")) : ""}. Symbols: ${esc(r.symbols.join(", ") || "none")}.` +
1024
+ (r.skipped.length ? `<div class="muted">Not converted: ${esc(r.skipped.join("; "))}</div>` : "") +
1025
+ `<div class="muted">Files are in the <span class="mono">altium_models</span> folder of the workspace.</div>` +
1026
+ (r.footprints.length ? "" : `<div class="next s-warn"><span>No footprint was converted: none of this BOM's footprints is in your KiCad libraries. Step 2 (Fill the library) finds and builds them.</span></div>`);
1027
+ }));
1028
+ ["eagle", "easyeda"].forEach(tool => $("#to" + tool).addEventListener("click", e => action(e.target, $("#toolout"), async () => {
1029
+ const r = await api("/api/export/tool", { bom: current.bom, tool });
1030
+ return `Written: ${fileLink(r.file)} (${Object.entries(r.counts).map(([k, v]) => v + " " + k).join(", ")}). ` +
1031
+ `To hand over: ${fileLink(r.bundle)} (the library, ${esc(r.models)} STEP model(s) and ${fileLink(r.install)} with the import steps).` +
1032
+ (r.ok ? `<div class="muted">Read back the same as the library.</div>` : `<div class="next s-warn"><span>${esc(r.errors.length)} difference(s) on reading back: ${esc(r.errors.slice(0, 3).join("; "))}</span></div>`) +
1033
+ (r.approximations.length ? `<div class="muted">Approximated for this format: ${esc(r.approximations.slice(0, 5).join("; "))}</div>` : "");
1034
+ })));
1035
+ let planned = null;
1036
+ $("#plan").addEventListener("click", e => action(e.target, $("#planout"), async () => {
1037
+ planned = await api("/api/altium365/plan", { bom: current.bom });
1038
+ $("#dry").disabled = false; $("#dry").title = "";
1039
+ if (info.altium365.token) {
1040
+ try {
1041
+ const w = await api("/api/altium365/workspaces", {});
1042
+ $("#wsurl").innerHTML = w.workspaces.map(x => `<option value="${esc(x.url)}">${esc(x.name)}</option>`).join("");
1043
+ $("#wsurl").hidden = false; $("#push").disabled = !w.workspaces.length || !planned.ready; disabledWhy();
1044
+ } catch (err) { banner($("#planout"), "Couldn't list Altium 365 workspaces: " + err.message); }
1045
+ }
1046
+ const why = i => i.status === "held" ? "held: " + ((i.hold_reasons || [])[0] || "failed the release check") :
1047
+ i.status === "awaiting_approval" ? "awaiting approval: " + (((i.approval || {}).missing || [])[0] || "not signed off") :
1048
+ i.status === "duplicate" ? "duplicate of " + (i.duplicate_of || "?") :
1049
+ !i.symbol.file && !i.footprint.file ? "needs symbol and footprint" : !i.symbol.file ? `needs symbol ${i.symbol.ref}.SchLib` : `needs footprint ${i.footprint.ref}.PcbLib`;
1050
+ const notReady = planned.total - planned.ready;
1051
+ return `<strong>${planned.ready} of ${planned.total}</strong> parts ready to release${planned.source === "librarian" ? " (from Fill the library, after its release check)" : ""}. ${fileLink(planned.plan)}` +
1052
+ `<div class="next s-info"><span>Next: <i>Dry run</i> lists what would be sent, without sending anything.${notReady ? ` The ${notReady} part(s) not ready are skipped; step 2 shows why.` : ""}</span></div>` + `
1053
+ <table style="margin-top:6px"><tbody>${planned.items.map(i => `<tr><td>${esc(i.name)}<div class="muted">${esc(i.folder)}</div></td>
1054
+ <td>${i.status === "ready" ? '<span class="pill s-ok">ready</span>' : `<span class="pill s-warn">${esc(why(i))}</span>`}</td></tr>`).join("")}</tbody></table>`;
1055
+ }));
1056
+ $("#dry").addEventListener("click", e => action(e.target, $("#dryout"), async () => {
1057
+ const r = await api("/api/altium365/push", { bom: current.bom, workspace: $("#wsurl").value || "", dry_run: true });
1058
+ const n = a => r.results.filter(x => x.action === a).length;
1059
+ return `Dry run, nothing sent: ${n("release")} would be released, ${n("skip")} skipped. Requests: ${fileLink(r.report)}` +
1060
+ `<div class="next s-info"><span>${info.altium365.token ? (can("approver") ? "Next: <i>Push</i> sends them to the workspace you picked (it asks first)." : "An approver can now push them.")
1061
+ : "Nothing can be sent yet: restart ecadlib with NEXAR_TOKEN set (README, Altium 365) to push."}</span></div>`;
1062
+ }));
1063
+ $("#push").addEventListener("click", e => {
1064
+ const ws = $("#wsurl").selectedOptions[0];
1065
+ if (!ws || !confirm(`Release ${planned.ready} part(s) into the Altium 365 workspace "${ws.textContent}"? This creates real components there and updates parameters of parts that already exist.`)) return;
1066
+ action(e.target, $("#dryout"), async () => {
1067
+ const r = await api("/api/altium365/push", { bom: current.bom, workspace: ws.value, dry_run: false });
1068
+ const bad = r.results.filter(x => x.action === "error");
1069
+ loadStatus().catch(() => {}); loadHub().catch(() => {});
1070
+ return `Pushed: ${r.results.filter(x => x.action === "release").length} released, ${r.results.filter(x => x.action === "update_parameters").length} updated, ${bad.length} failed. Report: ${fileLink(r.report)}` +
1071
+ (bad.length ? `<div class="muted">${bad.map(b => esc(b.name + ": " + b.detail)).join("<br>")}</div>` : "");
1072
+ });
1073
+ });
1074
+
1075
+ // ---- long jobs: poll the job, show its live per-part lines, Stop
1076
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
1077
+ const secs = j => j.elapsed >= 2 ? ` (${Math.round(j.elapsed)} s so far)` : "";
1078
+ function jobLines(ul, lines) {
1079
+ const atEnd = ul.scrollTop + ul.clientHeight >= ul.scrollHeight - 4;
1080
+ ul.innerHTML = lines.map(l => `<li class="${esc(l.state)}"><span class="dot" aria-hidden="true"></span><span>${esc(l.text || l.name)}</span></li>`).join("");
1081
+ if (atEnd) ul.scrollTop = ul.scrollHeight;
1082
+ }
1083
+ async function pollJob(id, view) {
1084
+ view.stop.disabled = false; view.stop.textContent = "Stop";
1085
+ view.stop.onclick = async () => {
1086
+ view.stop.disabled = true; view.stop.textContent = "Stopping…";
1087
+ try { await api("/api/job/cancel", { id }); } catch (e) { view.stop.disabled = false; view.stop.textContent = "Stop"; toast(e.message, { bad: true }); }
1088
+ };
1089
+ for (;;) {
1090
+ await sleep(400);
1091
+ const j = await api("/api/job?id=" + encodeURIComponent(id));
1092
+ view.text.textContent = view.label(j);
1093
+ view.bar.style.width = j.total ? `${Math.round(j.done / j.total * 100)}%` : "6%";
1094
+ if (view.lines) jobLines(view.lines, j.lines || []);
1095
+ if (j.state === "done") return j;
1096
+ if (j.state === "cancelled") { const e = new Error("Stopped."); e.cancelled = true; throw e; }
1097
+ if (j.state === "error") throw new Error(j.error);
1098
+ }
1099
+ }
1100
+
1101
+ // ---- librarian: fill the library, then the parts as cards with a side panel per part
1102
+ let libbusy = false, parts = null, pfilter = "", popen = null;
1103
+ const pending = new Map(); // part -> {what, timer, commit}: decisions still inside their Undo window
1104
+ const WORD = { ready: "ready", held: "held", awaiting_approval: "waiting for sign-off", duplicate: "duplicate", existing: "already in Altium 365" };
1105
+ const PILL = { ready: "s-ok", held: "s-warn", awaiting_approval: "s-info", duplicate: "s-off", existing: "s-off" };
1106
+ const counts = c => [c.ready && `${c.ready} ready`, c.held && `${c.held} held`, c.waiting && `${c.waiting} waiting for sign-off`].filter(Boolean).join(", ") || "no parts";
1107
+ const usd = v => "$" + (v >= 1 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v.toFixed(4));
1108
+ async function librarian(refresh) {
1109
+ if (libbusy || !hasBom()) return;
1110
+ libbusy = true; $("#librun").disabled = $("#librerun").disabled = true; banner($("#liberr"), "");
1111
+ $("#libbusy").hidden = false; $("#liblines").innerHTML = ""; $("#libbusytext").textContent = "Filling the library: starting…"; $("#libbar").style.width = "4%";
1112
+ const bom = current.bom;
1113
+ try {
1114
+ const { job } = await api("/api/librarian/run", { bom, refresh: refresh || null });
1115
+ const j = await pollJob(job, { text: $("#libbusytext"), bar: $("#libbar"), lines: $("#liblines"), stop: $("#libstop"),
1116
+ label: j => (j.total ? `Filling the library: part ${Math.min(j.done + 1, j.total)} of ${j.total}${j.label ? ", " + j.label : ""}` : `Filling the library: ${j.label || "starting"}`) +
1117
+ `${secs(j)}. Online look-ups take about a second each; the rest of the page keeps working.` });
1118
+ if (current && current.bom === bom) await showLibrarian(j.result);
1119
+ toast(`Library filled: ${counts({ ready: j.result.ready, held: j.result.held, waiting: j.result.awaiting_approval })}.`);
1120
+ } catch (e) {
1121
+ if (e.cancelled) toast("Stopped. The library plan was not changed; Fill missing parts starts again from the top, reusing what was downloaded.");
1122
+ else banner($("#liberr"), e.message);
1123
+ } finally {
1124
+ libbusy = false; $("#libbusy").hidden = true; $("#librun").disabled = $("#librerun").disabled = false; planned = null;
1125
+ ["#planout", "#dryout"].forEach(s => $(s).innerHTML = ""); $("#dry").disabled = $("#push").disabled = true; disabledWhy();
1126
+ loadStatus().catch(() => {}); loadHub().catch(() => {});
1127
+ }
1128
+ }
1129
+ // after the librarian, a pick, a decision or a release: reload the cards (the summary r is only a fallback)
1130
+ async function showLibrarian(r) {
1131
+ $("#librerun").hidden = false; $("#libtools").hidden = false;
1132
+ try { await loadParts(); }
1133
+ catch (e) { $("#libout").innerHTML = `<strong>${r.ready} of ${r.parts}</strong> parts ready, ${r.held} held. ` + r.reports.map(fileLink).join(" · ") + libNext(r); }
1134
+ }
1135
+ async function loadParts(quiet) {
1136
+ const bom = current && current.bom;
1137
+ if (!bom) return;
1138
+ try { parts = await api("/api/parts?bom=" + encodeURIComponent(bom)); }
1139
+ catch (e) { if (quiet) { parts = null; $("#libout").innerHTML = ""; return; } throw e; }
1140
+ if (!current || current.bom !== bom) return;
1141
+ $("#librerun").hidden = false; $("#libtools").hidden = false;
1142
+ renderParts();
1143
+ if (popen) { const p = parts.parts.find(x => x.part === popen); if (p) showPanel(p, false); else closePanel(); }
1144
+ }
1145
+ function libNext(r) {
1146
+ const held = r.held || 0, aw = r.awaiting_approval || 0, lib = can("librarian");
1147
+ const out = [];
1148
+ if (held) out.push(`<b>${held} held</b>: click a part to see why and how to fix it. ` + (lib ? "There you can <i>Pick…</i> a symbol or footprint, open the datasheet, or <i>Release anyway</i> once you have checked the part." : "A librarian picks a symbol or footprint for each."));
1149
+ if (aw) out.push(`<b>${aw} waiting for sign-off</b>: ` + (can("approver") ? (session.team ? "click Approve on each." : "type your name above, then click Approve on each.") : "an approver signs them off."));
1150
+ if (r.ready) out.push(`${out.length ? "Meanwhile" : "Next"}: the <b>${r.ready}</b> ready part(s) can go to Altium (step 3).`);
1151
+ else if (!out.length) out.push("No part is ready yet.");
1152
+ return `<div class="next ${held || aw ? "s-warn" : "s-ok"}" role="status"><span>${out.join("<br>")}</span>${r.ready ? '<button class="small" data-go="export">Go to step 3</button>' : ""}</div>`;
1153
+ }
1154
+ function effective(p) { // the status a pending (not yet sent) decision will give
1155
+ const d = pending.get(p.part);
1156
+ return d ? d.status : p.status;
1157
+ }
1158
+ function renderParts() {
1159
+ if (!parts) return;
1160
+ const c = parts.counts, cost = parts.cost, q = ($("#pq") && $("#pq").value || "").trim().toLowerCase();
1161
+ const sum = { ready: c.ready, held: c.held, awaiting_approval: c.waiting, parts: c.parts };
1162
+ const r9 = [];
1163
+ if (c.cross_check_disagreed) r9.push(`<span class="pill s-warn">cross-check: ${c.cross_check_disagreed} disagree</span>`);
1164
+ if (c.parity_mismatch) r9.push(`<span class="pill s-warn">KiCad / Altium: ${c.parity_mismatch} differ</span>`);
1165
+ else if (parts.parts.some(p => p.parity.result === "match")) r9.push(`<span class="pill s-ok">KiCad / Altium: all compared parts match</span>`);
1166
+ r9.push(cost.answers ? `<span class="pill s-info">datasheet reads so far: ${usd(cost.usd)} for ${cost.answers} read(s)${cost.models.length ? " · " + esc(cost.models.join(", ")) : ""}</span>`
1167
+ : `<span class="pill s-off">no datasheet reads yet ($0)</span>`);
1168
+ const keep = document.activeElement && document.activeElement.id === "pq";
1169
+ const states = ["held", "awaiting_approval", "ready", "duplicate"].filter(s => parts.parts.some(p => effective(p) === s));
1170
+ const shown = parts.parts.filter(p => (!pfilter || effective(p) === pfilter) &&
1171
+ (!q || [p.part, p.description, p.folder, p.footprint.ref, p.symbol.ref].join(" ").toLowerCase().includes(q)));
1172
+ const lib = can("librarian"), appr = can("approver");
1173
+ $("#libout").innerHTML = `<div class="libsum"><span class="big"><strong>${c.ready} of ${c.parts}</strong> parts ready</span><span>${esc(counts(c))}</span><span class="small">${parts.reports.map(fileLink).join(" · ")}</span></div>
1174
+ <div class="r9">${r9.join("")}</div>` + libNext(sum) +
1175
+ `<div class="ptools"><input type="search" id="pq" placeholder="Search parts, footprints, folders…" aria-label="Search parts" value="${esc(q)}">
1176
+ <div class="chips" role="group" aria-label="Show parts">${[["", `All ${c.parts}`]].concat(states.map(s => [s, `${WORD[s] || s} ${parts.parts.filter(p => effective(p) === s).length}`]))
1177
+ .map(([s, lab]) => `<button class="chip ${s ? PILL[s] : "s-off"}${pfilter === s ? " active" : ""}" data-pf="${esc(s)}" aria-pressed="${pfilter === s}">${esc(lab)}</button>`).join("")}</div></div>` +
1178
+ `<ul class="parts" id="plist" aria-label="Parts">${shown.map(p => card(p, lib, appr)).join("") || '<li class="muted">No parts match.</li>'}</ul>`;
1179
+ if (keep) { const i = $("#pq"); i.focus(); i.setSelectionRange(i.value.length, i.value.length); }
1180
+ }
1181
+ function card(p, lib, appr) {
1182
+ const st = effective(p), d = pending.get(p.part);
1183
+ const tags = [];
1184
+ if (p.allowed) tags.push('<span class="pill s-info">released anyway</span>');
1185
+ if (p.cross_check) tags.push(`<span class="pill ${p.cross_check.status === "disagreed" ? "s-warn" : p.cross_check.status === "agreed" ? "s-ok" : "s-off"}">cross-check: ${esc(p.cross_check.status === "disagreed" ? "disagrees" : p.cross_check.status)}</span>`);
1186
+ if (p.parity.result) tags.push(`<span class="pill ${p.parity.result === "match" ? "s-ok" : "s-warn"}">KiCad / Altium ${p.parity.result === "match" ? "match" : "differ"}</span>`);
1187
+ if (p.cost) tags.push(`<span class="pill s-off">${usd(p.cost.usd)}${p.cost.models.length ? " · " + esc(p.cost.models[0]) : ""}</span>`);
1188
+ if (p.approval && p.approval.approved_by && p.approval.approved_by.length) tags.push(`<span class="pill s-ok">approved by ${esc(p.approval.approved_by.join(", "))}</span>`);
1189
+ const why = d ? `<div class="pwhy">${esc(d.what)}, sending in a moment… <button class="small" data-undo="${esc(p.part)}">Undo</button></div>`
1190
+ : st === "held" && p.reasons.length ? `<div class="pwhy">${esc(p.reasons[0].text)}${p.reasons.length > 1 ? ` <span class="muted">(+${p.reasons.length - 1} more)</span>` : ""}</div>`
1191
+ : st === "awaiting_approval" && p.reasons.length ? `<div class="pwhy">${esc(p.reasons[0].text)}</div>` : "";
1192
+ const acts = (st === "held" ? `<button class="small" data-fix="${esc(p.part)}">${lib ? "Fix…" : "Why?"}</button>` : "") +
1193
+ (appr && !d && (st === "ready" || st === "awaiting_approval") ? `<button class="small" data-approve="${esc(p.part)}">Approve</button><button class="small" data-reject="${esc(p.part)}">Reject…</button>` : "");
1194
+ return `<li class="pcard${popen === p.part ? " sel" : ""}${d ? " pending" : ""}" data-part="${esc(p.part)}">
1195
+ <div class="thumb${p.picture ? "" : " none"}" aria-hidden="true">${p.picture || (p.footprint.found ? "" : "no<br>footprint")}</div>
1196
+ <div class="pmain"><button class="pname" data-openp="${esc(p.part)}" aria-label="${esc(p.part)}, ${esc(WORD[st] || st)}: open details">${esc(p.part)}</button>
1197
+ <span class="pill ${PILL[st] || "s-info"}">${esc(d ? d.label : WORD[st] || st)}</span>
1198
+ <div class="pdesc">${esc(p.description || p.folder)}${p.footprint.ref ? ` · ${esc(p.footprint.ref)}` : ""}${p.footprint.pads ? ` · ${p.footprint.pads} pads` : ""}</div>
1199
+ ${why}<div class="ptags">${tags.join("")}</div></div>
1200
+ <div class="pacts">${acts}</div></li>`;
1201
+ }
1202
+ // ---- the side panel of one part
1203
+ function showPanel(p, focus = true) {
1204
+ popen = p.part;
1205
+ const lib = can("librarian"), appr = can("approver"), st = effective(p), url = p.datasheet && safeUrl(p.datasheet.url);
1206
+ const dsLink = (page, txt) => url ? `<a href="${esc(url)}${page ? "#page=" + encodeURIComponent(page) : ""}" target="_blank" rel="noopener noreferrer">${esc(txt)}</a>` : "";
1207
+ $("#ptitle").textContent = p.part;
1208
+ $("#psub").innerHTML = `<span class="pill ${PILL[st] || "s-info"}">${esc(WORD[st] || st)}</span> ${esc(p.description || "")}`;
1209
+ const reasons = p.reasons.map(r => `<div class="reason"><div>${esc(r.text)}</div><div class="fix"><b>Next:</b> ${esc(r.fix)}</div></div>`).join("");
1210
+ const cc = p.cross_check, par = p.parity;
1211
+ const ccRows = cc && cc.rows.length ? `<div class="scroll"><table class="vs"><thead><tr><th>What</th><th>Datasheet read</th><th>${cc.rows[0].second_is === "table" ? "Standard table" : "Second read"}</th><th>Page</th></tr></thead><tbody>` +
1212
+ cc.rows.map(r => `<tr><td>${esc(r.label)}</td><td class="diff">${esc(r.first)}</td><td class="diff">${esc(r.second)}</td><td>${r.page ? dsLink(r.page, "p. " + r.page) || esc(r.page) : "–"}</td></tr>`).join("") + "</tbody></table></div>" +
1213
+ `<div class="muted small">Compared against ${esc(cc.rows[0].against.replace(/^standard:/, "the standard package "))}. Neither value was picked for you.</div>` : "";
1214
+ const parRows = par.rows.length ? `<div class="scroll"><table class="vs"><thead><tr><th>What</th><th>KiCad</th><th>Altium</th></tr></thead><tbody>` +
1215
+ par.rows.map(r => `<tr><td>${esc(r.what)}</td><td class="diff">${esc(r.kicad)}</td><td class="diff">${esc(r.altium)}</td></tr>`).join("") + "</tbody></table></div>" : "";
1216
+ const acts = [];
1217
+ if (lib && st === "held") acts.push(`<button class="primary" data-pick="${esc(p.part)}">Pick a symbol or footprint…</button>`);
1218
+ if (url) acts.push(`<a class="small" href="${esc(url)}${p.datasheet.page ? "#page=" + encodeURIComponent(p.datasheet.page) : ""}" target="_blank" rel="noopener noreferrer" id="pdslink">Open the datasheet${p.datasheet.page ? ` (page ${esc(p.datasheet.page)})` : ""}</a>`);
1219
+ if (lib && st === "held" && !p.allowed) acts.push(`<button data-allow="${esc(p.part)}">Release anyway (I checked it)</button>`);
1220
+ if (lib && p.allowed) acts.push(`<button data-unallow="${esc(p.part)}">Hold it again</button>`);
1221
+ if (appr && (st === "ready" || st === "awaiting_approval") && !pending.has(p.part)) acts.push(`<button class="primary" data-approve="${esc(p.part)}">Approve</button><button data-reject="${esc(p.part)}">Reject…</button>`);
1222
+ $("#pbody").innerHTML = `${p.picture ? `<div class="bigpic" aria-label="Footprint top view">${p.picture.replace(/width="\d+" height="\d+"/, "")}</div>` : ""}
1223
+ ${reasons ? `<h3>${st === "held" ? "Why it is held" : "What it waits for"}</h3>${reasons}` : st === "ready" ? `<p>This part passed every check and is ready for Altium${p.allowed ? " (released anyway by a person)" : ""}.</p>` : ""}
1224
+ ${acts.length ? `<div class="pactions">${acts.join("")}</div>` : ""}
1225
+ <div id="perr"></div>
1226
+ ${cc ? `<h3>Cross-check: ${esc(cc.label)}</h3>${ccRows || `<div class="small">${esc(cc.summary || "")}</div>`}` : ""}
1227
+ <h3>KiCad and Altium</h3><div class="small">${par.result === "match" ? '<span class="pill s-ok">match</span> The KiCad footprint and the Altium files read back the same.' : par.result === "mismatch" ? '<span class="pill s-warn">differ</span> Both values are below.' : '<span class="muted">Not compared yet: compared once both files exist.</span>'}</div>${parRows}
1228
+ <h3>Where it came from</h3>
1229
+ <dl class="kv"><dt>Symbol</dt><dd>${p.symbol.found ? esc(p.symbol.from || p.symbol.ref) : `<span class="s-warn pill">none yet</span>`}</dd>
1230
+ <dt>Footprint</dt><dd>${p.footprint.found ? esc(p.footprint.from || p.footprint.ref) : `<span class="s-warn pill">none yet</span>`}${p.footprint.pads ? ` · ${p.footprint.pads} pads` : ""}</dd>
1231
+ <dt>Folder</dt><dd>${esc(p.folder || "–")}</dd>
1232
+ <dt>Datasheet</dt><dd>${url ? dsLink(p.datasheet.page, url.replace(/^https:\/\//, "")) : "no link"}</dd>
1233
+ <dt>Datasheet reads</dt><dd>${p.cost ? `${usd(p.cost.usd)} for ${p.cost.answers} read(s)${p.cost.models.length ? " · " + esc(p.cost.models.join(", ")) : ""}` : "none ($0)"}</dd></dl>
1234
+ ${p.tried.length ? `<details style="margin-top:10px"><summary class="muted small">What ecadlib tried (${p.tried.length})</summary><ul class="why small">${p.tried.map(t => `<li>${esc(t)}</li>`).join("")}</ul></details>` : ""}`;
1235
+ const panel = $("#panel");
1236
+ if (!panel.classList.contains("open")) panel.returnFocus = document.activeElement;
1237
+ panel.classList.add("open");
1238
+ document.querySelectorAll("#plist .pcard").forEach(li => li.classList.toggle("sel", li.dataset.part === p.part));
1239
+ if (focus) panel.focus({ preventScroll: true });
1240
+ }
1241
+ function closePanel() {
1242
+ const panel = $("#panel"); if (!panel.classList.contains("open")) return;
1243
+ panel.classList.remove("open"); const part = popen; popen = null;
1244
+ document.querySelectorAll("#plist .pcard.sel").forEach(li => li.classList.remove("sel"));
1245
+ const back = part && document.querySelector(`#plist [data-openp="${CSS.escape(part)}"]`) || panel.returnFocus;
1246
+ if (back && back.focus) back.focus({ preventScroll: true });
1247
+ }
1248
+ $("#pclose").addEventListener("click", closePanel);
1249
+ const findPart = n => parts && parts.parts.find(p => p.part === n);
1250
+ // approve / reject: the card changes at once; the decision is sent when the Undo window closes (nothing to take back)
1251
+ function decideLater(part, decision, who, role, comment) {
1252
+ const label = decision === "approve" ? `approved by ${who}` : "rejected";
1253
+ const what = `${decision === "approve" ? "Approved" : "Rejected"} ${part}`;
1254
+ const send = async () => {
1255
+ pending.delete(part);
1256
+ try {
1257
+ const r = await api("/api/approvals/decide", session.team ? { bom: current.bom, part, decision, role, comment } : { bom: current.bom, part, decision, user: who, role, comment });
1258
+ banner($("#approut"), ""); loadStatus().catch(() => {}); loadHub().catch(() => {});
1259
+ if (!r.decision.enforced) toast(`${what}. Recorded (there is no approvals.json, so sign-off is not enforced).`);
1260
+ await loadParts();
1261
+ } catch (err) { await loadParts().catch(() => {}); toast(`${what} failed: ${err.message}`, { bad: true, ms: 8000 }); }
1262
+ };
1263
+ pending.set(part, { status: decision === "approve" ? "ready" : "held", label, what, commit: send });
1264
+ const t = toast(`${what}.`, { action: "Undo", onAction: () => { pending.delete(part); renderParts(); if (popen === part) showPanel(findPart(part), false); toast(`${what}: undone, nothing was sent.`); },
1265
+ onClose: send });
1266
+ pending.get(part).toast = t;
1267
+ renderParts(); if (popen === part) showPanel(findPart(part), false);
1268
+ }
1269
+ function undoPending(part) { const d = pending.get(part); if (d && d.toast) { const f = d.toast; d.toast = null; pending.delete(part); f.el.querySelector("button").click(); } }
1270
+ window.addEventListener("pagehide", () => pending.forEach(d => { if (d.toast) { d.toast.close(); } }));
1271
+ // release anyway: recorded at once; Undo takes it back (Hold it again)
1272
+ async function release(part, allow) {
1273
+ try {
1274
+ await api("/api/release-check", { bom: current.bom, allow: allow ? [part] : [], unallow: allow ? [] : [part], user: session.team ? null : $("#signer").value.trim() || null });
1275
+ await loadParts(); loadStatus().catch(() => {}); loadHub().catch(() => {});
1276
+ if (allow) toast(`Released ${part} anyway: it goes to Altium with the next push.`, { action: "Undo", onAction: () => release(part, false) });
1277
+ else toast(`${part} is held again.`);
1278
+ } catch (err) { banner(popen ? $("#perr") : $("#liberr"), err.message); }
1279
+ }
1280
+ async function partClick(e) {
1281
+ const b = e.target.closest("button, a"); if (!b) return;
1282
+ const d = b.dataset;
1283
+ if (d.openp || d.fix) { const p = findPart(d.openp || d.fix); if (p) showPanel(p); return; }
1284
+ if (d.pf !== undefined) { pfilter = d.pf; renderParts(); const n = $(`#libout [data-pf="${CSS.escape(pfilter)}"]`); if (n) n.focus(); return; }
1285
+ if (d.undo) return undoPending(d.undo);
1286
+ if (d.pick) return openPick(d.pick);
1287
+ if (d.approve || d.reject) return decide(d.approve || d.reject, d.approve ? "approve" : "reject", b);
1288
+ if (d.allow || d.unallow) return release(d.allow || d.unallow, !!d.allow);
1289
+ }
1290
+ $("#libout").addEventListener("click", partClick);
1291
+ $("#pbody").addEventListener("click", partClick);
1292
+ $("#libout").addEventListener("input", e => { if (e.target.id === "pq") renderParts(); });
1293
+ $("#librun").addEventListener("click", () => librarian(null));
1294
+ $("#librerun").addEventListener("click", () => { if (confirm("Look up every part again, including the ones already finished?")) librarian(true); });
1295
+
1296
+ function whyList(why) {
1297
+ const items = (why || "").split("; ").filter(Boolean);
1298
+ if (!items.length) return '<span class="muted">nothing</span>';
1299
+ const li = a => a.map(t => `<li>${esc(t)}</li>`).join("");
1300
+ return `<ul class="why">${li(items.slice(0, 3))}</ul>` +
1301
+ (items.length > 3 ? `<details><summary class="muted">${items.length - 3} more</summary><ul class="why">${li(items.slice(3))}</ul></details>` : "");
1302
+ }
1303
+
1304
+ // ---- held parts: pick a candidate
1305
+ const pickdlg = $("#pickdlg");
1306
+ let picking = null, pickbusy = false;
1307
+ function openPick(part) {
1308
+ picking = part; $("#pickkind").value = ""; $("#picktitle").textContent = `Pick a symbol or footprint for ${part}`;
1309
+ $("#pickinfo").innerHTML = ""; $("#picklist").innerHTML = ""; banner($("#pickerr"), "");
1310
+ pickdlg.showModal(); loadCandidates();
1311
+ }
1312
+ async function loadCandidates() {
1313
+ if (pickbusy || !picking) return;
1314
+ pickbusy = true; $("#pickbusy").hidden = false; $("#pickbar").style.width = "0"; $("#picklist").innerHTML = ""; banner($("#pickerr"), "");
1315
+ try {
1316
+ const { job } = await api("/api/resolve/candidates", { bom: current.bom, part: picking, kind: $("#pickkind").value || null });
1317
+ for (;;) {
1318
+ await new Promise(r => setTimeout(r, 400));
1319
+ const j = await api("/api/job?id=" + job);
1320
+ if (j.total) { $("#pickbusytext").textContent = `Looking for ${j.label}…`; $("#pickbar").style.width = `${Math.round(j.done / j.total * 100)}%`; }
1321
+ if (j.state === "done") { showCandidates(j.result); break; }
1322
+ if (j.state === "error") throw new Error(j.error);
1323
+ }
1324
+ } catch (e) { banner($("#pickerr"), e.message); }
1325
+ finally { pickbusy = false; $("#pickbusy").hidden = true; }
1326
+ }
1327
+ const pinMapText = m => Object.entries(m || {}).map(([a, b]) => `${a} → ${b}`).join(", ");
1328
+ function showCandidates(r) {
1329
+ const it = r.item, cmp = it.pins_vs_pads;
1330
+ $("#pickinfo").innerHTML = `<div><span class="pill s-warn">${esc(it.status)}</span> ${esc(it.description || "")} <span class="muted">(${esc(it.folder)})</span></div>` +
1331
+ `<div class="muted" style="margin-top:4px">Symbol: ${esc(it.symbol.source || "none")}${it.symbol.pins ? ` (${it.symbol.pins.length} pins)` : ""} · Footprint: ${esc(it.footprint.source || "none")}${it.footprint.pads ? ` (${new Set(it.footprint.pads).size} pads)` : ""}` +
1332
+ (cmp && !cmp.match ? ` · pins without pad: ${esc(cmp.pins_without_pad.join(", ") || "–")}, pads without pin: ${esc(cmp.pads_without_pin.join(", ") || "–")}` : "") + `</div>` +
1333
+ (it.hold_reasons.length ? `<ul class="why" style="margin-top:4px">${it.hold_reasons.map(h => `<li>${esc(h)}</li>`).join("")}</ul>` : "");
1334
+ $("#picklist").innerHTML = Object.entries(r.candidates).map(([kind, cs]) => `<h3 style="font-size:14px;margin:14px 0 4px">${kind === "symbol" ? "Symbols" : "Footprints"}</h3>` +
1335
+ (cs.length ? `<div class="scroll"><table><thead><tr><th>Candidate</th><th>Why</th><th>${kind === "symbol" ? "Pins" : "Pads"}</th><th>Match</th><th>Suggested pin map</th><th></th></tr></thead><tbody>` +
1336
+ cs.map((c, i) => `<tr><td class="mono">${esc(c.id)}${c.current ? ' <span class="pill s-info">current</span>' : ""}<div class="muted sub">score ${c.score}</div></td>
1337
+ <td>${whyList(c.why.join("; "))}${c.error ? `<div class="s-bad sub">${esc(c.error)}</div>` : ""}</td>
1338
+ <td>${c.count ?? "?"}</td>
1339
+ <td>${c.match === true ? '<span class="pill s-ok">yes</span>' : c.match === false ? '<span class="pill s-warn">no</span>' +
1340
+ `<div class="muted sub">${c.pins_without_pad.length ? "pins without pad: " + esc(c.pins_without_pad.join(", ")) : ""}${c.pads_without_pin.length ? " pads without pin: " + esc(c.pads_without_pin.join(", ")) : ""}</div>` : '<span class="muted">?</span>'}</td>
1341
+ <td>${c.pin_map ? `<label class="field"><input type="checkbox" data-usemap="${kind}:${i}" checked> <span class="mono">${esc(pinMapText(c.pin_map))}</span></label>` : '<span class="muted">–</span>'}</td>
1342
+ <td><button class="small${c.match ? " primary" : ""}" data-choose="${kind}:${i}" ${c.error ? "disabled" : ""}>Choose</button></td></tr>`).join("") +
1343
+ `</tbody></table></div>` : `<div class="muted">No ${kind} candidates found. Try the other half, or add a library with --lib.</div>`)).join("");
1344
+ showCandidates.last = r;
1345
+ }
1346
+ $("#pickkind").addEventListener("change", loadCandidates);
1347
+ $("#pickclose").addEventListener("click", () => pickdlg.close());
1348
+ $("#picklist").addEventListener("click", async e => {
1349
+ const k = e.target.dataset.choose;
1350
+ if (!k) return;
1351
+ const [kind, i] = k.split(":"), c = showCandidates.last.candidates[kind][+i];
1352
+ const use = $(`#picklist [data-usemap="${kind}:${i}"]`);
1353
+ const pin_map = c.pin_map && use && use.checked ? c.pin_map : null;
1354
+ e.target.disabled = true; banner($("#pickerr"), "");
1355
+ try {
1356
+ const r = await api("/api/resolve/choose", { bom: current.bom, part: picking, kind, id: c.id, pin_map });
1357
+ showLibrarian(r); pickdlg.close();
1358
+ toast(`${r.chosen.name}: ${kind} ← ${c.id}; now ${r.chosen.status}` + (r.chosen.hold_reasons.length ? ` (${r.chosen.hold_reasons[0]})` : ""));
1359
+ } catch (err) { banner($("#pickerr"), err.message); e.target.disabled = false; }
1360
+ });
1361
+
1362
+ // ---- sign-off and duplicates
1363
+ const store = { get(k) { try { return localStorage.getItem(k) || ""; } catch (e) { return ""; } },
1364
+ set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } };
1365
+ $("#signer").value = store.get("ecadlib.signer"); $("#signrole").value = store.get("ecadlib.role");
1366
+ $("#signer").addEventListener("change", e => store.set("ecadlib.signer", e.target.value.trim()));
1367
+ $("#signrole").addEventListener("change", e => store.set("ecadlib.role", e.target.value.trim()));
1368
+ function signer() {
1369
+ if (session.team) return session.user.identity;
1370
+ const who = $("#signer").value.trim();
1371
+ if (!who) { banner($("#approut"), "Type your name first: it is recorded with your decision. There is no login, so nothing fills it in for you."); $("#signer").focus(); }
1372
+ return who;
1373
+ }
1374
+ async function decide(part, decision, btn) {
1375
+ const who = signer(); if (!who) return;
1376
+ let comment = "";
1377
+ if (decision === "reject") { comment = prompt(`What is wrong with ${part}? (required for a reject)`) || ""; if (!comment.trim()) return; }
1378
+ const role = session.team ? $("#signas").value || null : $("#signrole").value.trim() || null;
1379
+ banner($("#approut"), "");
1380
+ decideLater(part, decision, who, role, comment);
1381
+ }
1382
+ $("#apprstatus").addEventListener("click", e => action(e.target, $("#approut"), async () => {
1383
+ const r = await api("/api/approvals/status", { bom: current.bom });
1384
+ return (r.policy_file ? `Policy ${fileLink(r.policy_file)}: ${r.awaiting_approval} part(s) awaiting approval.`
1385
+ : "No approvals.json next to the plan: decisions are recorded, but Altium 365 push does not wait for them.") +
1386
+ (r.audit ? ` Log: ${fileLink(r.audit)}` : "") +
1387
+ `<div class="scroll"><table style="margin-top:6px"><thead><tr><th>Part</th><th>Status</th><th>Needs sign-off</th><th>Approved by</th><th>Missing</th><th>Stale</th></tr></thead><tbody>` +
1388
+ r.rows.map(x => `<tr><td class="mono">${esc(x.name)}</td><td>${esc(x.status)}</td><td>${esc(x.needs_signoff || "–")}</td><td>${esc(x.approved_by || "–")}</td><td>${esc(x.missing || "–")}</td><td class="muted">${esc(x.stale || "")}</td></tr>`).join("") +
1389
+ "</tbody></table></div>";
1390
+ }));
1391
+ function showDupes(r) {
1392
+ const plan = r.mapping.filter(m => m.parts).length;
1393
+ const who = m => `<span class="mono">${esc(m.name)}</span> <span class="muted">(${esc(m.origin)}${m.used_by ? ", used by " + esc(m.used_by.join(", ")) : ""})</span>`;
1394
+ return (r.groups.length ? `<strong>${r.groups.length}</strong> duplicate group(s). ` : "No duplicates found. ") + r.reports.map(fileLink).join(" · ") +
1395
+ (r.groups.length ? `<div class="scroll"><table style="margin-top:6px"><thead><tr><th>Kind</th><th>Keep</th><th>Duplicates</th></tr></thead><tbody>` +
1396
+ r.groups.map(g => { const k = g.members.find(m => m.name === g.keeper);
1397
+ return `<tr><td>${esc(g.kind.replace("_", " "))}${g.kind === "mpn" ? `<div class="muted sub mono">${esc(g.key)}</div>` : ""}</td><td>${k ? who(k) : '<span class="muted">same name, different content: pick by hand</span>'}</td>
1398
+ <td>${g.members.filter(m => m.name !== g.keeper).map(who).join("<br>")}</td></tr>`; }).join("") + "</tbody></table></div>" : "") +
1399
+ (r.errors.length ? `<div class="muted">Could not read: ${esc(r.errors.join("; "))}</div>` : "") +
1400
+ (r.merged ? `<div>Merged: ${plan} plan reference(s) now point at their keeper; the old plan is kept as plan.json.pre-dedupe. Library files were not changed.</div>`
1401
+ : plan ? `<div class="row" style="margin-top:6px"><button class="small danger" id="dedupemerge">Merge ${plan} plan reference(s) into the keepers…</button><span class="muted">Library files are never changed.</span></div>` : "");
1402
+ }
1403
+ $("#dedupe").addEventListener("click", e => action(e.target, $("#dedupeout"), async () => showDupes(await api("/api/dedupe", { bom: current.bom }))));
1404
+ $("#dedupeout").addEventListener("click", e => {
1405
+ if (e.target.id !== "dedupemerge" || !confirm("Point the duplicate parts, footprints and symbols in the plan at their keepers? Duplicate parts are then skipped by the Altium 365 push, and approvals of changed parts go stale.")) return;
1406
+ action(e.target, $("#dedupeout"), async () => { const r = await api("/api/dedupe", { bom: current.bom, merge: true }); showLibrarian(r.summary); return showDupes(r); });
1407
+ });
1408
+
1409
+ // ---- footprint dialog
1410
+ const dlg = $("#dlg");
1411
+ async function loadSpecs() {
1412
+ specs = await api("/api/specs");
1413
+ $("#example").innerHTML = '<option value="">Start from an example…</option>' + Object.keys(specs).map(k => `<option>${esc(k)}</option>`).join("");
1414
+ }
1415
+ const norm = s => String(s || "").toUpperCase().replace(/[^A-Z0-9]/g, "");
1416
+ function guessSpec(row) {
1417
+ const pkg = norm(row && row.package);
1418
+ if (!pkg) return null;
1419
+ const hits = Object.keys(specs).filter(k => norm(k).includes(pkg));
1420
+ const cls = (row.designator.match(/^[A-Z]/i) || [""])[0].toUpperCase();
1421
+ return hits.find(k => k.toUpperCase().startsWith(cls + "_")) || hits.find(k => !/^[RCL]_/.test(k)) || null;
1422
+ }
1423
+ const placeholder = '<span class="muted small">No example matches this package, so these values are placeholders. Replace them with the dimensions from the datasheet drawing, then Preview.</span>';
1424
+ function openGen(row) {
1425
+ $("#dlgtitle").textContent = row ? `Generate footprint for ${row.designator} (${row.mpn || row.value})` : "Generate footprint";
1426
+ const g = guessSpec(row);
1427
+ const tmpl = g ? specs[g] : { name: row ? (row.package || row.mpn || "MyPackage") : "MyPackage", description: row ? `${row.manufacturer} ${row.mpn}`.trim() : "",
1428
+ package_type: "gullwing", pin_count: 8, pitch: 1.27, lead_span: { min: 5.8, max: 6.2 }, body_width: { min: 3.8, max: 4.0 },
1429
+ body_length: { min: 4.8, max: 5.0 }, terminal_length: { min: 0.4, max: 1.27 }, terminal_width: { min: 0.31, max: 0.51 },
1430
+ density: "B", source: { datasheet: "" } };
1431
+ $("#spec").value = JSON.stringify(tmpl, null, 2); $("#example").value = g || "";
1432
+ $("#preview").innerHTML = g ? "" : placeholder; $("#nums").innerHTML = ""; banner($("#dlgerr"), "");
1433
+ $("#approve").disabled = true; dlg.showModal(); if (g) preview();
1434
+ }
1435
+ $("#newfp").addEventListener("click", () => openGen(null));
1436
+ $("#close").addEventListener("click", () => dlg.close());
1437
+ $("#example").addEventListener("change", e => { if (specs[e.target.value]) { $("#spec").value = JSON.stringify(specs[e.target.value], null, 2); preview(); } });
1438
+ $("#density").addEventListener("change", preview);
1439
+ $("#spec").addEventListener("input", () => { $("#approve").disabled = true; });
1440
+ $("#previewbtn").addEventListener("click", preview);
1441
+ function readSpec() {
1442
+ try { return JSON.parse($("#spec").value); } catch (e) { throw new Error("The dimensions aren't valid JSON: " + e.message); }
1443
+ }
1444
+ async function preview() {
1445
+ banner($("#dlgerr"), "");
1446
+ try {
1447
+ const r = await api("/api/footprint/preview", { spec: readSpec(), density: $("#density").value || null });
1448
+ $("#preview").innerHTML = r.svg;
1449
+ const rep = r.report, f = x => typeof x === "number" ? x.toFixed(2) : x;
1450
+ const items = [["IPC family", rep.ipc_family], ["density", rep.density], ["Z", f(rep.Z)], ["G", f(rep.G)], ["X", f(rep.X)],
1451
+ ["pads", rep.pads.length], ["courtyard", rep.courtyard.map(f).join(" × ")], ["pad 1", `${f(rep.pads[0].w)} × ${f(rep.pads[0].h)}`]];
1452
+ if (rep.EP) items.push(["EP", `${rep.EP[0]} × ${rep.EP[1]}`]);
1453
+ $("#nums").innerHTML = items.map(([k, v]) => `<div><span class="muted">${k}</span><br>${esc(v)}</div>`).join("") +
1454
+ (rep.warning ? `<div class="s-warn" style="grid-column:1/-1">⚠ ${esc(rep.warning)}</div>` : "") +
1455
+ (rep.checks || []).map(c => `<div class="${c.level === "error" ? "s-bad" : "s-warn"}" style="grid-column:1/-1">${c.level === "error" ? "✖" : "⚠"} ${esc(c.rule)}: ${esc(c.message)}</div>`).join("") +
1456
+ (rep.checks && !rep.checks.length ? `<div class="s-ok" style="grid-column:1/-1">✓ Passes the copper, silkscreen, fab and courtyard checks</div>` : "") +
1457
+ (r.exists ? `<div class="s-info" style="grid-column:1/-1">A footprint called ${esc(r.name)} is already in the workspace library; approving will ask before replacing it.</div>` : "");
1458
+ $("#approve").disabled = false;
1459
+ } catch (e) { banner($("#dlgerr"), e.message); $("#approve").disabled = true; }
1460
+ }
1461
+ async function approve(overwrite) {
1462
+ const r = await api("/api/footprint/approve", { spec: readSpec(), density: $("#density").value || null, overwrite });
1463
+ dlg.close(); toast(`Saved ${r.name}: ${r.files.map(f => f.split(".").pop()).join(" + ")}` + (r.warnings.length ? ` (${r.warnings.join("; ")})` : ""));
1464
+ loadInfo();
1465
+ if (current && current.bom) runJob(api("/api/triage", { bom: current.bom }), current.bom);
1466
+ }
1467
+ $("#approve").addEventListener("click", async () => {
1468
+ try { await approve(false); }
1469
+ catch (e) {
1470
+ if (e.status === 409 && confirm(e.message.replace("; approve again with overwrite to replace it", ".") + "\n\nReplace it?")) {
1471
+ try { await approve(true); } catch (e2) { banner($("#dlgerr"), e2.message); }
1472
+ } else if (e.status !== 409) banner($("#dlgerr"), e.message);
1473
+ }
1474
+ });
1475
+
1476
+ // ---- library rules (setup)
1477
+ let rulesProp = null;
1478
+ async function loadRules() {
1479
+ const r = await api("/api/rules");
1480
+ $("#rulesstate").textContent = r.exists
1481
+ ? `Using ${r.file}: new parts follow its naming, parameters, folders, layers and footprint density.`
1482
+ : "No rules file yet, so ecadlib uses its own conventions. Scan your existing libraries and ecadlib proposes rules that make new parts match them.";
1483
+ $("#rulesscan").textContent = r.exists ? "Re-scan…" : "Set up from my libraries…";
1484
+ }
1485
+ $("#rulesscan").addEventListener("click", () => { $("#rulesform").hidden = !$("#rulesform").hidden; });
1486
+ async function proposeRules(choices) {
1487
+ const paths = $("#rulespaths").value.split("\n").map(x => x.trim()).filter(Boolean);
1488
+ $("#rulesgo").disabled = $("#rulesapply").disabled = true; $("#rulesbusy").hidden = false; $("#rulesdiff").innerHTML = "";
1489
+ try {
1490
+ const { job } = await api("/api/rules/propose", { paths, choices: choices || {} });
1491
+ for (;;) {
1492
+ await new Promise(r => setTimeout(r, 400));
1493
+ const j = await api("/api/job?id=" + job);
1494
+ if (j.state === "done") { rulesProp = j.result; showRules(); break; }
1495
+ if (j.state === "error") throw new Error(j.error);
1496
+ }
1497
+ } catch (e) { banner($("#rulesout"), e.message); }
1498
+ finally { $("#rulesgo").disabled = $("#rulesapply").disabled = false; $("#rulesbusy").hidden = true; }
1499
+ }
1500
+ $("#rulesgo").addEventListener("click", () => proposeRules());
1501
+ function showRules() {
1502
+ const p = rulesProp, qs = Object.fromEntries(p.questions.map(q => [q.key, q]));
1503
+ const val = v => typeof v === "string" ? v : JSON.stringify(v);
1504
+ const lvl = { high: "s-ok", medium: "s-warn", low: "s-bad" };
1505
+ const scanned = Object.entries(p.scanned).map(([k, v]) => `${v} ${k}`).join(", ") || "nothing ecadlib can read";
1506
+ const rows = p.settings.map(s => `<tr><td class="mono">${esc(s.key)}</td><td>${qs[s.key]
1507
+ ? `<select data-key="${esc(s.key)}" aria-label="${esc(qs[s.key].text)}">${qs[s.key].choices.map(c => `<option${c === s.value ? " selected" : ""}>${esc(c)}</option>`).join("")}</select><div class="muted sub">${esc(qs[s.key].text)}</div>`
1508
+ : `<span class="mono">${esc(val(s.value))}</span>`}</td>
1509
+ <td><span class="pill ${lvl[s.level]}">${esc(s.level)} ${Math.round(s.confidence * 100)}%</span></td>
1510
+ <td class="notes">${esc(s.source)}<ul class="why">${s.evidence.slice(0, 3).map(e => `<li>${esc(e)}</li>`).join("")}</ul></td></tr>`).join("");
1511
+ $("#rulesout").innerHTML = `<div>Scanned ${esc(scanned)}: ${p.components} parts, ${p.footprints} footprints, ${p.boms} BOMs.` +
1512
+ (p.questions.length ? ` <strong>${p.questions.length} setting(s) had split evidence</strong>: pick below, then Apply my answers.` : "") + `</div>` +
1513
+ (p.warnings.length ? `<div class="muted small">${p.warnings.slice(0, 5).map(esc).join("<br>")}</div>` : "") +
1514
+ (p.errors.length ? `<div class="banner s-bad">${p.errors.map(esc).join("<br>")}</div>` : "") +
1515
+ (rows ? `<div class="tablewrap"><table style="margin-top:6px"><thead><tr><th>Setting</th><th>Proposed</th><th>Confidence</th><th>Evidence</th></tr></thead><tbody>${rows}</tbody></table></div>`
1516
+ : `<div class="muted">Nothing to infer from these files. Add your Altium or KiCad library folders above.</div>`);
1517
+ $("#rulestoml").value = p.toml; $("#rulesfile").hidden = !rows; $("#rulesapply").hidden = !p.questions.length; $("#rulesreplace").hidden = true;
1518
+ $("#rulesdiff").innerHTML = p.diff ? `<div class="muted small" style="margin-top:8px">Changes against your current ${esc(p.file)}:</div><pre class="diff">${esc(p.diff)}</pre>` : "";
1519
+ }
1520
+ $("#rulesapply").addEventListener("click", () => {
1521
+ const ch = {}; document.querySelectorAll("#rulesout select[data-key]").forEach(x => ch[x.dataset.key] = x.value); proposeRules(ch);
1522
+ });
1523
+ async function saveRules(overwrite) {
1524
+ try {
1525
+ const r = await api("/api/rules/save", { text: $("#rulestoml").value, overwrite });
1526
+ if (r.needs_confirm) {
1527
+ $("#rulesdiff").innerHTML = `<div class="banner s-warn"><span>${esc(r.path)} already exists and differs. Nothing was written. Review the changes, then replace it (the old file is kept as .bak).</span></div><pre class="diff">${esc(r.diff)}</pre>`;
1528
+ $("#rulesreplace").hidden = false; return;
1529
+ }
1530
+ $("#rulesreplace").hidden = true;
1531
+ toast(r.unchanged ? "No changes: the file already says this." : `Saved ${r.path}` + (r.backup ? ` (old file: ${r.backup})` : ""));
1532
+ $("#rulesdiff").innerHTML = ""; await loadRules(); await loadInfo();
1533
+ } catch (e) { banner($("#rulesdiff"), e.message); }
1534
+ }
1535
+ $("#rulessave").addEventListener("click", () => saveRules(false));
1536
+ async function loadStyle() {
1537
+ const r = await api("/api/style");
1538
+ $("#stylestate").textContent = r.exists
1539
+ ? `Learned from ${r.components} parts (${r.file}); new parts follow it.` + (r.direction && r.direction.length ? ` ${r.direction.length} line(s) of your direction.` : "")
1540
+ : "Not learned yet. ecadlib can learn how your library writes parts and adapt new ones to it, plus anything you tell it.";
1541
+ $("#styleout").innerHTML = r.exists ? `<pre class="diff">${esc(r.summary)}</pre>` : "";
1542
+ }
1543
+ $("#styleopen").addEventListener("click", () => { $("#styleform").hidden = !$("#styleform").hidden; });
1544
+ $("#stylego").addEventListener("click", async () => {
1545
+ const lines = id => $(id).value.split("\n").map(x => x.trim()).filter(Boolean);
1546
+ $("#stylego").disabled = true; $("#stylebusy").hidden = false;
1547
+ try {
1548
+ const { job } = await api("/api/style/learn", { paths: lines("#stylepaths"), direction: lines("#styledir"), ai: $("#styleai").checked });
1549
+ for (;;) {
1550
+ await new Promise(r => setTimeout(r, 400));
1551
+ const j = await api("/api/job?id=" + job);
1552
+ if (j.state === "done") { toast("House style saved"); $("#styledir").value = ""; await loadStyle(); break; }
1553
+ if (j.state === "error") throw new Error(j.error);
1554
+ }
1555
+ } catch (e) { banner($("#styleout"), e.message); }
1556
+ finally { $("#stylego").disabled = false; $("#stylebusy").hidden = true; }
1557
+ });
1558
+ $("#rulesreplace").addEventListener("click", () => saveRules(true));
1559
+
1560
+ // ---- team mode: sign-in, user menu, roles, users page
1561
+ function applyRoles() {
1562
+ document.querySelectorAll("[data-role]").forEach(el => { if (can(el.dataset.role)) el.removeAttribute("data-role-hidden"); else el.setAttribute("data-role-hidden", ""); });
1563
+ const t = session.team, u = session.user;
1564
+ $("#usermenu").hidden = !t || !u;
1565
+ if (!t || !u) return;
1566
+ $("#whoami").textContent = `${u.name || u.username} · ${u.role}`;
1567
+ $("#pwbtn").hidden = session.proxy; $("#logout").hidden = session.proxy;
1568
+ $("#signerwrap").hidden = $("#signrolewrap").hidden = true;
1569
+ $("#signaswrap").hidden = u.sign_as.length < 2;
1570
+ $("#signas").innerHTML = u.sign_as.map(r => `<option>${esc(r)}</option>`).join("");
1571
+ $("#signedas").hidden = false; $("#signedas").textContent = `Signing as ${u.identity}` + (u.sign_as.length < 2 ? ` (${u.sign_as[0]})` : "");
1572
+ $("#signnote").textContent = "You are signed in: approvals, rejections and release overrides are recorded under your account, in the plan, in approvals_audit.jsonl and in the team audit log. You cannot approve a part you released with Release anyway. Sign-off is enforced only when an approvals.json sits next to the plan.";
1573
+ }
1574
+ function showSignin(msg) {
1575
+ session = { team: true, user: null };
1576
+ $("#app").hidden = true; $("#usermenu").hidden = true; $("#signin").hidden = false; $("#steps").hidden = true;
1577
+ $("#meta").textContent = "team server";
1578
+ banner($("#signinerr"), msg || "", "s-warn");
1579
+ $("#login_user").focus();
1580
+ }
1581
+ $("#signinform").addEventListener("submit", async e => {
1582
+ e.preventDefault(); $("#signinbtn").disabled = true; banner($("#signinerr"), "");
1583
+ try {
1584
+ session = await api("/api/login", { username: $("#login_user").value.trim(), password: $("#login_pw").value });
1585
+ $("#login_pw").value = ""; startApp();
1586
+ } catch (err) { banner($("#signinerr"), err.message); }
1587
+ finally { $("#signinbtn").disabled = false; }
1588
+ });
1589
+ $("#logout").addEventListener("click", async () => { try { await api("/api/logout", {}); } catch (e) {} location.reload(); });
1590
+ // password dialog: own password (current + new) or an admin resetting someone else's (new only)
1591
+ const pwdlg = $("#pwdlg"); let pwFor = null;
1592
+ function openPw(user) {
1593
+ pwFor = user; $("#pwtitle").textContent = user ? `New password for ${user}` : "Change your password";
1594
+ $("#pwcurwrap").hidden = !!user; ["#pwcur", "#pwnew", "#pwnew2"].forEach(s => $(s).value = ""); banner($("#pwerr"), "");
1595
+ pwdlg.showModal();
1596
+ }
1597
+ $("#pwbtn").addEventListener("click", () => openPw(null));
1598
+ $("#pwcancel").addEventListener("click", e => { e.preventDefault(); pwdlg.close(); });
1599
+ $("#pwform").addEventListener("submit", async e => {
1600
+ e.preventDefault();
1601
+ if ($("#pwnew").value !== $("#pwnew2").value) return banner($("#pwerr"), "The two new passwords differ.");
1602
+ try {
1603
+ if (pwFor) { showUsers(await api("/api/team/users", { action: "reset-password", username: pwFor, password: $("#pwnew").value })); toast(`Password reset for ${pwFor}; their sessions have ended.`); }
1604
+ else { session = await api("/api/account/password", { current: $("#pwcur").value, new: $("#pwnew").value }); toast("Password changed. Your other sessions have ended."); }
1605
+ pwdlg.close();
1606
+ } catch (err) { banner($("#pwerr"), err.message); }
1607
+ });
1608
+ // users page (admin)
1609
+ function showUsers(r) {
1610
+ const roles = r.roles.map(x => `<option>${esc(x)}</option>`).join("");
1611
+ $("#addrole").innerHTML = roles;
1612
+ $("#teamrows").innerHTML = r.users.map(u => `<tr data-user="${esc(u.username)}"><td class="mono">${esc(u.username)}${u.username === r.me ? ' <span class="pill s-info">you</span>' : ""}</td>
1613
+ <td>${esc(u.name || "–")}<div class="muted sub">${esc(u.email || "")}</div></td>
1614
+ <td><select data-f="role" aria-label="Role of ${esc(u.username)}">${r.roles.map(x => `<option${x === u.role ? " selected" : ""}>${esc(x)}</option>`).join("")}</select></td>
1615
+ <td><input type="text" data-f="sign_as" size="16" value="${esc((u.sign_as || []).join(", "))}" placeholder="${esc(u.role)}" aria-label="Approval roles of ${esc(u.username)}"></td>
1616
+ <td class="small">${u.password ? "password" : "SSO proxy only"}${r.locked.includes(u.username) ? ' <span class="pill s-bad">locked</span>' : ""}</td>
1617
+ <td><div class="acts"><button class="small" data-act="set-role">Save</button><button class="small" data-act="reset-password">Reset password…</button>` +
1618
+ (r.locked.includes(u.username) ? `<button class="small" data-act="unlock">Unlock</button>` : "") +
1619
+ (u.username === r.me ? "" : `<button class="small danger" data-act="remove">Remove…</button>`) + `</div></td></tr>`).join("");
1620
+ }
1621
+ async function openTeam() {
1622
+ $("#teamcard").hidden = false; banner($("#teamerr"), ""); $("#auditout").innerHTML = "";
1623
+ try { showUsers(await api("/api/team/users")); } catch (e) { banner($("#teamerr"), e.message); }
1624
+ $("#teamcard").scrollIntoView({ behavior: motion() });
1625
+ }
1626
+ $("#teambtn").addEventListener("click", openTeam);
1627
+ $("#teamclose").addEventListener("click", () => { $("#teamcard").hidden = true; });
1628
+ $("#teamrows").addEventListener("click", async e => {
1629
+ const act = e.target.dataset.act; if (!act) return;
1630
+ const tr = e.target.closest("tr"), username = tr.dataset.user;
1631
+ if (act === "reset-password") return openPw(username);
1632
+ if (act === "remove" && !confirm(`Remove ${username}? They are signed out at once. Their past approvals stay in the plans and logs.`)) return;
1633
+ const body = { action: act, username };
1634
+ if (act === "set-role") { body.role = tr.querySelector("[data-f=role]").value; body.sign_as = tr.querySelector("[data-f=sign_as]").value; }
1635
+ try { showUsers(await api("/api/team/users", body)); banner($("#teamerr"), ""); toast(`${username}: ${act.replace("-", " ")} done`); }
1636
+ catch (err) { banner($("#teamerr"), err.message); }
1637
+ });
1638
+ $("#adduser").addEventListener("submit", async e => {
1639
+ e.preventDefault(); const f = new FormData(e.target), body = { action: "add" };
1640
+ for (const [k, v] of f.entries()) body[k] = String(v).trim() ? v : "";
1641
+ try { showUsers(await api("/api/team/users", body)); e.target.reset(); toast(`Added ${body.username}`); banner($("#teamerr"), ""); }
1642
+ catch (err) { banner($("#teamerr"), err.message); }
1643
+ });
1644
+ $("#auditbtn").addEventListener("click", e => action(e.target, $("#auditout"), async () => {
1645
+ const r = await api("/api/team/audit");
1646
+ return `<div class="scroll"><table style="margin-top:10px"><thead><tr><th>When (UTC)</th><th>User</th><th>Route</th><th>Result</th><th>Details</th><th>From</th></tr></thead><tbody>` +
1647
+ r.entries.map(x => { const { ts, user, route, status, ip, ...rest } = x;
1648
+ return `<tr><td class="mono">${esc(ts)}</td><td>${esc(user || "–")}</td><td class="mono">${esc(route)}</td><td><span class="pill ${status < 300 ? "s-ok" : status < 500 ? "s-warn" : "s-bad"}">${esc(status)}</span></td>
1649
+ <td class="small mono">${esc(Object.entries(rest).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" "))}</td><td class="muted small">${esc(ip)}</td></tr>`; }).join("") +
1650
+ `</tbody></table></div>`;
1651
+ }));
1652
+
1653
+ // ---- review queue (library agent)
1654
+ const QSTATE = { detected: "s-info", building: "s-off", proving: "s-info", review: "s-warn", approved: "s-ok", pushed: "s-ok", failed: "s-bad", rejected: "s-bad" };
1655
+ const QVERDICT = { pass: "s-ok", doubt: "s-warn", fail: "s-bad", skipped: "s-off" };
1656
+ let qfilter = "", qopen = null, qtoken = null;
1657
+ const pill = (t, cls) => `<span class="pill ${cls || "s-off"}">${esc(t)}</span>`;
1658
+ const safeUrl = u => /^https:\/\/[^\s"'<>]+$/.test(u || "") ? u : null;
1659
+ const picUrl = p => "/api/agent/queue/picture?path=" + encodeURIComponent(p);
1660
+ async function loadQueue() {
1661
+ const b = $("#qbatch").value;
1662
+ const r = await api("/api/agent/queue?" + new URLSearchParams({ status: qfilter, batch: b }));
1663
+ const total = Object.values(r.counts).reduce((a, x) => a + x, 0);
1664
+ $("#qchips").innerHTML = `<span class="chip s-off${qfilter ? "" : " active"}" data-s="" role="button" tabindex="0" aria-pressed="${!qfilter}">All ${total}</span>` +
1665
+ r.states.filter(s => r.counts[s]).map(s => `<span class="chip ${QSTATE[s]}${qfilter === s ? " active" : ""}" data-s="${esc(s)}" role="button" tabindex="0" aria-pressed="${qfilter === s}">${esc(s === "review" ? "waiting for review" : s)} ${r.counts[s]}</span>`).join("");
1666
+ if (!qfilter) $("#navq").textContent = r.counts.review || "";
1667
+ $("#qbatch").innerHTML = `<option value="">All batches</option>` + r.batches.map(x => `<option value="${esc(x.id)}">${esc(x.id)} (${x.count}, ${esc(x.status)})</option>`).join("");
1668
+ $("#qbatch").value = r.batches.some(x => x.id === b) ? b : "";
1669
+ const bt = r.batch;
1670
+ $("#qbatchinfo").innerHTML = bt ? `<div class="signoff" style="margin:0 0 10px">Batch <span class="mono">${esc(bt.id)}</span> · ${pill(bt.status || "open", "s-info")} · ${esc(bt.items.length)} item(s) · created ${esc(bt.created)}` +
1671
+ (safeUrl(bt.pr_url) ? ` · <a href="${esc(bt.pr_url)}" target="_blank" rel="noopener noreferrer">pull request</a>` : bt.pr_url ? ` · PR ${esc(bt.pr_url)}` : " · no pull request yet") + `</div>` : "";
1672
+ $("#qrows").innerHTML = r.items.map(it => `<tr data-id="${esc(it.id)}"${it.id === qopen ? ' class="sel"' : ""}>
1673
+ <td><strong>${esc(it.mpn)}</strong><div class="muted sub">${esc(it.manufacturer || "–")} · ${esc(it.origin)}</div></td>
1674
+ <td>${esc(it.kind || "–")}<div class="muted sub">${esc(it.source)}</div></td>
1675
+ <td>${pill(it.status, QSTATE[it.status])}</td>
1676
+ <td>${it.verdict ? pill(it.verdict, QVERDICT[it.verdict]) : '<span class="muted">–</span>'}${it.findings ? `<div class="muted sub">${esc(it.findings)} finding(s)</div>` : ""}</td>
1677
+ <td class="mono">${esc(it.batch || "–")}</td><td class="mono small">${esc(it.updated)}</td>
1678
+ <td><button class="small" data-open="${esc(it.id)}">Open</button></td></tr>`).join("");
1679
+ $("#qwrap").hidden = !r.items.length; $("#qemptywrap").hidden = !!r.items.length;
1680
+ $("#qempty").textContent = total ? "No items match this filter." : "Nothing in the queue. The library agent adds parts here when a watched project or BOM changes.";
1681
+ }
1682
+ function kv(obj, keys) {
1683
+ const rows = keys.filter(k => obj && obj[k] !== undefined && obj[k] !== null && obj[k] !== "" && !(Array.isArray(obj[k]) && !obj[k].length))
1684
+ .map(k => `<dt>${esc(k)}</dt><dd>${esc(Array.isArray(obj[k]) ? obj[k].join(", ") : typeof obj[k] === "object" ? JSON.stringify(obj[k]) : obj[k])}</dd>`);
1685
+ return rows.length ? `<dl class="kv">${rows.join("")}</dl>` : '<div class="muted small">–</div>';
1686
+ }
1687
+ function figure(title, paths) {
1688
+ const imgs = (paths || []).map(p => p ? `<img src="${esc(picUrl(p))}" alt="${esc(title)}: ${esc(p.split("/").pop())}" loading="lazy">` : '<div class="muted small">(a picture outside the agent folder is not shown)</div>').join("");
1689
+ return `<figure><figcaption>${esc(title)}</figcaption>${imgs || '<div class="muted small">none</div>'}</figure>`;
1690
+ }
1691
+ function showQueueItem(r) {
1692
+ qtoken = r.build_token || null;
1693
+ const it = r.item, ch = it.change || {}, b = it.build || {}, p = it.proof, pics = r.pictures, allowed = r.allowed;
1694
+ const line = ch.line || {}, was = ch.was || {};
1695
+ const lineKeys = [...new Set([...Object.keys(line), ...Object.keys(was)])];
1696
+ const diff = lineKeys.length ? `<div class="scroll"><table><thead><tr><th>Field</th><th>Before</th><th>Now</th></tr></thead><tbody>` +
1697
+ lineKeys.map(k => `<tr><td class="muted">${esc(k)}</td><td>${esc(was[k] ?? "")}</td><td${k in was && String(was[k] ?? "") !== String(line[k] ?? "") ? ' class="s-warn"' : ""}>${esc(line[k] ?? "")}</td></tr>`).join("") + `</tbody></table></div>` : "";
1698
+ const findings = p && (p.findings || []).length ? `<div class="scroll"><table><thead><tr><th>Severity</th><th>What</th><th>Expected</th><th>Found</th><th>Page</th></tr></thead><tbody>` +
1699
+ p.findings.map(f => `<tr><td>${pill(f.severity, { error: "s-bad", warning: "s-warn", info: "s-info" }[f.severity])}</td><td>${esc(f.what)}</td><td>${esc(f.expected)}</td><td>${esc(f.found)}</td><td>${esc(f.page ?? "")}</td></tr>`).join("") + `</tbody></table></div>` : "";
1700
+ const canApprove = allowed.includes("approved") && it.status === "review" && can("approver");
1701
+ const canReject = allowed.includes("rejected") && can("librarian");
1702
+ const canRebuild = (it.status === "review" || it.status === "failed" || it.status === "rejected") && can("librarian");
1703
+ const signer = session.team ? `<span class="small">Deciding as ${esc(session.user.identity)}</span>` :
1704
+ `<label class="field small">Your name <input type="text" id="qsigner" placeholder="Ann Lee &lt;ann@example.com&gt;" size="28" value="${esc($("#signer").value)}"></label>`;
1705
+ $("#qview").innerHTML = `
1706
+ <div class="row spread"><div><strong>${esc(it.mpn)}</strong> <span class="muted">${esc(it.manufacturer || "")}</span> ${pill(it.status, QSTATE[it.status])}
1707
+ <div class="muted small mono">${esc(it.id)} · key ${esc(it.key)} · ${esc(it.origin)}</div></div><button class="small" id="qclose">Close</button></div>
1708
+ <h3>What triggered it</h3>
1709
+ ${kv({ kind: ch.kind || ch.check, source: ch.source ? `${ch.source.type || ""} ${ch.source.name || ch.source.ref || ""}`.trim() : "", detected: ch.detected, what: ch.what || ch.summary, detail: ch.detail }, ["kind", "source", "detected", "what", "detail"])}
1710
+ ${diff}
1711
+ <h3>Build</h3>
1712
+ ${kv(b, ["bom", "part", "user", "sources", "notes", "files", "requested_by", "requested_at", "request_note", "error"])}
1713
+ <h3>Proof ${p ? pill(p.verdict, QVERDICT[p.verdict]) : ""}${p && p.score != null ? ` <span class="muted small">score ${esc(p.score)}</span>` : ""}</h3>
1714
+ ${p ? `<div class="small">${esc(p.reason || "")}</div>${p.model ? `<div class="muted small">${esc(p.model)}${p.cost_usd != null ? ` · $${esc(p.cost_usd)}` : ""}</div>` : ""}${findings}` : '<div class="muted small">Not proved yet.</div>'}
1715
+ <h3>Pictures</h3>
1716
+ <div class="pics">${figure("Before", pics.before)}${figure("After", pics.after)}</div>
1717
+ ${(pics.ours.length || pics.datasheet.length) ? `<div class="pics" style="margin-top:12px">${figure("Ours (rendered)", pics.ours)}${figure("Datasheet", pics.datasheet)}</div>` : ""}
1718
+ ${it.approval ? `<h3>Approval</h3>${kv(it.approval, ["by", "role", "at", "recorded", "reason", "status"])}` : ""}
1719
+ ${it.rejection ? `<h3>Rejection</h3>${kv(it.rejection, ["by", "at", "note", "recorded", "reason"])}` : ""}
1720
+ ${r.batch ? `<h3>Batch</h3><div class="small"><span class="mono">${esc(r.batch.id)}</span> · ${esc(r.batch.status || "")}${safeUrl(it.pr_url || r.batch.pr_url) ? ` · <a href="${esc(it.pr_url || r.batch.pr_url)}" target="_blank" rel="noopener noreferrer">pull request</a>` : ""}</div>` : ""}
1721
+ <h3>History</h3>
1722
+ <ol class="hist">${it.history.map(h => `<li><span class="mono">${esc(h.at)}</span> ${esc(h.from || "–")} → <strong>${esc(h.to)}</strong>${h.user ? ` · ${esc(h.user)}` : ""}${h.note ? ` · <span class="muted">${esc(h.note)}</span>` : ""}</li>`).join("")}</ol>
1723
+ ${canApprove || canReject || canRebuild ? `<h3>Decision</h3>
1724
+ <div class="row" style="margin-bottom:6px">${signer}</div>
1725
+ <textarea id="qnote" placeholder="Note (required to reject): what is wrong, or what you checked" aria-label="Note"></textarea>
1726
+ <div class="row" style="margin-top:8px">
1727
+ ${canApprove ? '<button class="primary" data-q="approve">Approve</button>' : ""}
1728
+ ${canReject ? '<button class="danger" data-q="reject">Reject…</button>' : ""}
1729
+ ${canRebuild ? `<button data-q="rebuild">${it.status === "review" ? "Rebuild" : "Retry"}</button>` : ""}
1730
+ </div>` : `<div class="muted small" style="margin-top:12px">${it.status === "review" ? "You can look at this item; deciding needs the librarian (reject, rebuild) or approver (approve) role." : "No decision to make in this state."}</div>`}
1731
+ <div id="qdecerr"></div>`;
1732
+ $("#qview").hidden = false;
1733
+ }
1734
+ async function openQueueItem(id) {
1735
+ qopen = id; banner($("#qerr"), "");
1736
+ try { showQueueItem(await api("/api/agent/queue/item?id=" + encodeURIComponent(id))); }
1737
+ catch (e) { banner($("#qerr"), e.message); }
1738
+ document.querySelectorAll("#qrows tr").forEach(tr => tr.classList.toggle("sel", tr.dataset.id === id));
1739
+ }
1740
+ $("#qrows").addEventListener("click", e => { const id = e.target.dataset.open; if (id) openQueueItem(id).then(() => $("#qview").scrollIntoView({ behavior: motion(), block: "nearest" })); });
1741
+ const qchip = e => { const c = e.target.closest(".chip"); if (!c) return; qfilter = c.dataset.s; loadQueue().then(() => { const n = $(`#qchips [data-s="${qfilter}"]`); if (n && e.type === "keydown") n.focus(); }).catch(err => banner($("#qerr"), err.message)); };
1742
+ $("#qchips").addEventListener("click", qchip);
1743
+ $("#qchips").addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); qchip(e); } });
1744
+ $("#qbatch").addEventListener("change", () => loadQueue().catch(err => banner($("#qerr"), err.message)));
1745
+ $("#qreload").addEventListener("click", () => loadQueue().catch(err => banner($("#qerr"), err.message)));
1746
+ $("#qview").addEventListener("click", async e => {
1747
+ if (e.target.id === "qclose") { $("#qview").hidden = true; qopen = null; document.querySelectorAll("#qrows tr.sel").forEach(tr => tr.classList.remove("sel")); return; }
1748
+ const act = e.target.dataset.q; if (!act || !qopen) return;
1749
+ const note = $("#qnote").value.trim();
1750
+ if (act === "reject" && !note) return banner($("#qdecerr"), "Say what is wrong in the note before rejecting.");
1751
+ const body = { id: qopen, note };
1752
+ if (qtoken) body.expect = qtoken;
1753
+ if (!session.team) {
1754
+ body.user = ($("#qsigner") || {}).value?.trim() || "";
1755
+ if (!body.user) return banner($("#qdecerr"), "Type your name first: it is recorded with the decision.");
1756
+ $("#signer").value = body.user;
1757
+ }
1758
+ const btn = e.target;
1759
+ const send = async () => {
1760
+ btn.disabled = true;
1761
+ try {
1762
+ const r = await api(`/api/agent/queue/${act}`, body);
1763
+ const name = r.item.mpn || body.id, st = r.item.status;
1764
+ const what = st === "approved" ? `Approved ${name}` + (r.approval ? (r.approval.recorded ? " (also recorded in its plan)" : " (in the queue only)") : "") +
1765
+ ". The library agent pushes it on its next pass (or run: ecadlib agent push)."
1766
+ : st === "rejected" ? `Rejected ${name}. It stays out of the library until its source changes or someone clicks Retry.`
1767
+ : `${name}: rebuild requested (now ${st}). The library agent rebuilds it on its next pass, then it comes back here for review.`;
1768
+ await loadQueue();
1769
+ if (qopen === body.id) showQueueItem(await api("/api/agent/queue/item?id=" + encodeURIComponent(body.id)));
1770
+ nextStep($("#qdecerr"), esc(what), null, st === "rejected" ? "s-warn" : "s-ok"); loadStatus().catch(() => {}); loadHub().catch(() => {});
1771
+ if (act === "rebuild") toast(what);
1772
+ } catch (err) { banner($("#qdecerr"), err.message); btn.disabled = false; toast(`${act === "approve" ? "Approval" : "Rejection"} failed: ${err.message}`, { bad: true, ms: 8000 }); }
1773
+ };
1774
+ if (act === "rebuild") return send();
1775
+ // approve / reject: shown at once, sent when the Undo window closes (a sent decision cannot be taken back)
1776
+ const name = ($("#qview strong") || {}).textContent || body.id, verb = act === "approve" ? "Approved" : "Rejected";
1777
+ document.querySelectorAll("#qview [data-q]").forEach(b => b.disabled = true);
1778
+ $("#qdecerr").innerHTML = `<div class="banner s-info pending" role="status"><span>${esc(verb)} ${esc(name)}: sending in a few seconds.</span><button class="small" id="qundo" style="font-size:12.5px;border:1px solid currentColor">Undo</button></div>`;
1779
+ const t = toast(`${verb} ${name}.`, { action: "Undo", ms: 5000, onClose: send,
1780
+ onAction: () => { $("#qdecerr").innerHTML = ""; document.querySelectorAll("#qview [data-q]").forEach(b => b.disabled = false); toast(`${verb} ${name}: undone, nothing was sent.`); } });
1781
+ $("#qundo").onclick = () => { const b = t.el.querySelector("button"); if (b) b.click(); };
1782
+ });
1783
+ // ---- your library (the hub): a tile per tool (your library as it is in that tool), or every tool side by side
1784
+ const HUBSTATE = { same: ["same", "s-ok"], present: ["there", "s-info"], different: ["different", "s-warn"], behind: ["behind", "s-bad"] };
1785
+ const HUBCHIPS = [["", "All"], ["look", "Needs a look"], ["held", "Held"], ["eol", "End of life"], ["duplicate", "Duplicates"],
1786
+ ["ecadlib", "Built by ecadlib"], ["library", "Your libraries"]];
1787
+ const COPYABLE = ["altium", "kicad"];
1788
+ let hub = null, hubchip = "", hubpage = 1, hubtimer = null, hubkey = null, hublib = "";
1789
+ let hubtool = store.get("hubtool") || "", hubpicked = !!hubtool; // "all" = compare every tool
1790
+ const day = ts => ts ? esc(String(ts).slice(0, 10)) : "";
1791
+ const whoShort = w => w ? esc(String(w).replace(/\s*<[^>]*>$/, "")) : "not recorded";
1792
+ const toolLabel = t => (hub && (hub.tools.find(x => x.tool === t) || {}).label) || t;
1793
+ function hubQuery(rescan) {
1794
+ const one = hubtool && hubtool !== "all";
1795
+ const p = new URLSearchParams({ q: $("#hubq").value.trim(), sort: $("#hubsort").value, page: hubpage, size: 100,
1796
+ project: $("#hubproject").value });
1797
+ if (one) { p.set("tool", hubtool); if (hublib) p.set("library", hublib); }
1798
+ else { p.set("have", $("#hubhave").value); p.set("lack", $("#hublack").value); }
1799
+ if (hubchip === "look") p.set("state", "different,behind");
1800
+ else if (["held", "eol", "duplicate"].includes(hubchip)) p.set("flags", hubchip === "held" ? "held,awaiting_approval" : hubchip);
1801
+ else if (hubchip) p.set("origin", hubchip);
1802
+ if (rescan) p.set("rescan", "1");
1803
+ return "/api/hub?" + p.toString();
1804
+ }
1805
+ async function loadHub(rescan) {
1806
+ banner($("#hubprob"), "");
1807
+ if (!hubpicked) { // until a tile is clicked: the tool with the most parts, else compare
1808
+ const first = await api("/api/hub?size=1");
1809
+ const best = first.tools.filter(t => t.parts).sort((x, y) => y.parts - x.parts)[0];
1810
+ hubtool = best ? best.tool : "all";
1811
+ }
1812
+ hub = await api(hubQuery(rescan));
1813
+ if (hubtool !== "all" && !hub.tools.some(t => t.tool === hubtool)) { hubtool = "all"; hub = await api(hubQuery()); }
1814
+ renderHub();
1815
+ }
1816
+ function hubCell(c) {
1817
+ if (!c) return `<td class="cell"><span class="none" aria-label="missing">–</span></td>`;
1818
+ const [w, cls] = HUBSTATE[c.state] || [c.state, "s-info"];
1819
+ const tip = [c.note, c.updated ? `last changed ${String(c.updated).replace("T", " ").slice(0, 16)} UTC by ${c.updated_by || "not recorded"}` : ""].filter(Boolean).join(" · ");
1820
+ return `<td class="cell" title="${esc(tip)}"><span class="pill ${cls}">${esc(w)}${c.duplicate ? ` ×${c.copies}` : ""}</span><div class="sub">${day(c.updated)}</div></td>`;
1821
+ }
1822
+ function hubTiles(r) {
1823
+ const s = r.summary;
1824
+ const tile = (id, name, body, off) => `<button class="hubtool${off ? " off" : ""}" role="tab" aria-selected="${hubtool === id}" data-hubtool="${esc(id)}" data-tc="${esc(id)}">${body.replace("%N", `<span class="tn">${esc(name)}</span>`)}</button>`;
1825
+ return r.tools.map(t => !t.reader && !t.parts
1826
+ ? tile(t.tool, t.label, `%N<b>–</b><span>Not connected yet</span>`, true)
1827
+ : tile(t.tool, t.label, `%N<b>${esc(t.parts)}</b><span class="tx">${t.parts ? `part${t.parts === 1 ? "" : "s"} in ${t.libraries.length} librar${t.libraries.length === 1 ? "y" : "ies"}` : "parts"}</span>` +
1828
+ `<span class="tw">${t.updated ? `Changed ${day(t.updated)}${t.updated_by ? " by " + whoShort(t.updated_by) : ""}` : t.parts ? "No changes recorded" : "Nothing here yet"}</span>` +
1829
+ `<span>${t.behind || t.different ? `<span class="pill ${t.behind ? "s-bad" : "s-warn"}">${t.behind + t.different} to look at</span>` : t.missing ? `${t.missing} not here yet` : "Has every part"}</span>`)).join("") +
1830
+ tile("all", "Compare all", `%N<b>${esc(s.parts)}</b><span class="tx">parts across every tool</span><span class="tx">${s.updated ? `Newest change ${day(s.updated)}` : "No changes recorded"}</span><span>Side by side, a column per tool</span>`);
1831
+ }
1832
+ function renderHub() {
1833
+ const r = hub, tools = r.tools, one = hubtool !== "all", t = tools.find(x => x.tool === hubtool);
1834
+ $("#hubtools").innerHTML = hubTiles(r);
1835
+ document.querySelectorAll("[data-hubcompare]").forEach(el => el.hidden = one);
1836
+ // the chosen tool: its libraries, and the parts other tools have that it lacks
1837
+ let head = "";
1838
+ if (one && t) {
1839
+ head = `<div class="hublibs" role="group" aria-label="Libraries in ${esc(t.label)}"><span class="chip${hublib ? "" : " active"}" tabindex="0" role="button" data-tc="${esc(t.tool)}" data-hublib="">All ${esc(t.label)} libraries</span>` +
1840
+ t.libraries.map(l => `<span class="chip${hublib === l.library ? " active" : ""}" tabindex="0" role="button" data-tc="${esc(t.tool)}" data-hublib="${esc(l.library)}" title="${esc(l.library)}${l.updated ? ` · changed ${esc(String(l.updated).slice(0, 16).replace("T", " "))} by ${esc(l.updated_by || "not recorded")}` : ""}">${esc(l.library.split("/").pop())} · ${esc(l.parts)}</span>`).join("") + `</div>`;
1841
+ if (t.can_copy && can("librarian"))
1842
+ head += `<div class="hubstrip"><span>${esc(t.can_copy)} part${t.can_copy === 1 ? "" : "s"} in your other tools ${t.can_copy === 1 ? "isn't" : "aren't"} in ${esc(t.label)} yet. ecadlib can convert ${t.can_copy === 1 ? "it" : "them"} into its hub library for ${esc(t.label)}.</span><button class="small primary" data-hubcopyall="${esc(t.tool)}">Copy ${t.can_copy === 1 ? "it" : "them"} to ${esc(t.label)}</button></div>`;
1843
+ else if (t.missing && !COPYABLE.includes(t.tool) && t.tool !== "altium365" && t.reader)
1844
+ head += `<div class="muted small" style="margin-top:6px">${esc(t.missing)} part(s) aren't in ${esc(t.label)}. For a part ecadlib built, open it and use Make the ${esc(t.label)} version; copying your own parts into ${esc(t.label)} isn't supported yet.</div>`;
1845
+ }
1846
+ $("#hubtoolhead").innerHTML = head;
1847
+ const s = r.summary;
1848
+ const stat = (n, lab) => `<div><b>${esc(n)}</b><span class="muted small">${esc(lab)}</span></div>`;
1849
+ $("#hubstats").innerHTML = one ? "" : `<div class="stats">${stat(s.parts, "parts")}${stat(s.ecadlib, "built by ecadlib")}${stat(s.library, "in your libraries")}` +
1850
+ `${stat(s.different, "differ between tools")}${stat(s.behind, "with a copy behind")}${stat(s.held, "held or awaiting sign-off")}${stat(s.eol, "end of life")}</div>` +
1851
+ `<div class="small muted">Libraries read: ${r.libraries.length ? r.libraries.map(esc).join(", ") : "none (start ecadlib with --lib, or add [hub] libraries to ecadlib.toml)"}</div>`;
1852
+ $("#hubchips").innerHTML = HUBCHIPS.map(([k, lab]) => `<span class="chip${hubchip === k ? " active" : ""}" tabindex="0" role="button" aria-pressed="${hubchip === k}" data-hubchip="${k}">${esc(lab)}</span>`).join("");
1853
+ for (const [sel, first] of [["#hubhave", "any tool"], ["#hublack", "–"]]) {
1854
+ const el = $(sel), keep = el.value;
1855
+ el.innerHTML = `<option value="">${first}</option>` + tools.map(t => `<option value="${esc(t.tool)}">${esc(t.label)}</option>`).join("");
1856
+ el.value = keep;
1857
+ }
1858
+ const pj = $("#hubproject"), pkeep = pj.value, plist = r.project_list || [];
1859
+ pj.innerHTML = `<option value="">${plist.length ? "all parts" : "none found ([hub] projects)"}</option>` +
1860
+ plist.map(p => `<option value="${esc(p.path)}">${esc(p.name)} (${esc(toolLabel(p.tool))}) · ${esc(p.parts)} parts</option>`).join("");
1861
+ pj.value = plist.some(p => p.path === pkeep) ? pkeep : "";
1862
+ const proj = plist.find(p => p.path === pj.value);
1863
+ $("#hubproj").innerHTML = proj ? `<div class="hubstrip"><span><b>${esc(proj.name)}</b> uses ${esc(proj.parts)} of your library's parts` +
1864
+ (proj.missing ? `; ${esc(proj.missing)} of them ${proj.missing === 1 ? "isn't" : "aren't"} in ${esc(toolLabel(proj.tool))} yet.` : `, all of them in ${esc(toolLabel(proj.tool))}.`) + `</span>` +
1865
+ (proj.can_copy && can("librarian") ? `<button class="small primary" data-hubcopyall="${esc(proj.tool)}" data-project="${esc(proj.path)}">Copy ${proj.can_copy === proj.missing ? (proj.missing === 1 ? "it" : "them") : proj.can_copy} into ${esc(proj.name)}</button>`
1866
+ : proj.missing ? `<span class="muted small">No other tool has a copy ecadlib can convert${proj.missing === 1 ? "" : " for these"}.</span>` : "") + `</div>` : "";
1867
+ if (one && t) {
1868
+ $("#hubhead").innerHTML = `<tr><th>Part</th><th>Library</th><th>Symbol · footprint</th><th>Last changed</th><th>Against other tools</th><th>Also in</th><th>Used in</th></tr>`;
1869
+ $("#hubrows").innerHTML = r.rows.map(p => {
1870
+ const c = p.cells[t.tool] || {}, pl = (p.places[t.tool] || [])[0] || {}, others = tools.filter(x => x.tool !== t.tool && p.cells[x.tool]);
1871
+ const [w, cls] = !others.length ? ["only here", "s-info"] : HUBSTATE[c.state] || [c.state, "s-info"];
1872
+ return `<tr tabindex="0" data-hub="${esc(p.key)}">
1873
+ <td class="hp"><b class="mono">${esc(p.name)}</b>${p.mpn && p.mpn !== p.name ? `<div class="sub">${esc(p.mpn)}${p.manufacturer ? " · " + esc(p.manufacturer) : ""}</div>` : (p.manufacturer ? `<div class="sub">${esc(p.manufacturer)}</div>` : "")}
1874
+ ${p.flags.filter(f => ["held", "awaiting_approval", "eol"].includes(f)).map(f => `<span class="pill ${f === "eol" ? "s-bad" : "s-warn"}">${f === "eol" ? esc(p.lifecycle ? p.lifecycle.label : "end of life") : f === "held" ? "held" : "awaiting sign-off"}</span> `).join("")}</td>
1875
+ <td><span class="mono small" title="${esc(pl.library || "")}">${esc(String(pl.library || "").split("/").pop())}</span><div class="sub">${pl.origin === "ecadlib" ? "ecadlib" : "your library"}${c.duplicate ? ` · ${c.copies} copies` : ""}</div></td>
1876
+ <td><span class="mono small">${esc(String(pl.symbol || "").split(":").pop() || "–")}</span><div class="sub">${esc(String(pl.footprint || "").split(":").pop())}</div></td>
1877
+ <td>${day(c.updated)}<div class="sub">${whoShort(c.updated_by)}</div></td>
1878
+ <td title="${esc(c.note || "")}"><span class="pill ${cls}">${esc(w)}</span></td>
1879
+ <td class="small">${others.map(x => `<span class="tooltag" data-tc="${esc(x.tool)}">${esc(x.label)}</span>`).join("") || '<span class="none">–</span>'}</td>
1880
+ <td>${p.used_in ? `${p.used_in} project${p.used_in > 1 ? "s" : ""}` : '<span class="none">–</span>'}</td></tr>`;
1881
+ }).join("");
1882
+ } else {
1883
+ $("#hubhead").innerHTML = `<tr><th>Part</th>${tools.map(t => `<th class="tool" data-tc="${esc(t.tool)}">${esc(t.label)}<span>${t.reader ? `${t.parts} parts` : "no reader yet"}</span></th>`).join("")}<th>Last changed</th><th>Used in</th></tr>`;
1884
+ $("#hubrows").innerHTML = r.rows.map(p => `<tr tabindex="0" data-hub="${esc(p.key)}">
1885
+ <td class="hp"><b class="mono">${esc(p.name)}</b>${p.mpn && p.mpn !== p.name ? `<div class="sub">${esc(p.mpn)}${p.manufacturer ? " · " + esc(p.manufacturer) : ""}</div>` : (p.manufacturer ? `<div class="sub">${esc(p.manufacturer)}</div>` : "")}
1886
+ ${p.flags.filter(f => ["held", "awaiting_approval", "eol"].includes(f)).map(f => `<span class="pill ${f === "eol" ? "s-bad" : "s-warn"}">${f === "eol" ? esc(p.lifecycle ? p.lifecycle.label : "end of life") : f === "held" ? "held" : "awaiting sign-off"}</span> `).join("")}</td>
1887
+ ${tools.map(t => hubCell(p.cells[t.tool])).join("")}
1888
+ <td>${day(p.updated)}<div class="sub">${whoShort(p.updated_by)}</div></td><td>${p.used_in ? `${p.used_in} project${p.used_in > 1 ? "s" : ""}` : '<span class="none">–</span>'}</td></tr>`).join("");
1889
+ }
1890
+ const none = !r.total;
1891
+ $("#hubwrap").hidden = none || !r.rows.length; $("#hubempty").hidden = !none && !!r.rows.length;
1892
+ $("#hubemptytext").textContent = none ? "Nothing here yet. Point ecadlib at your libraries (ecadlib ui --lib FOLDER, or [hub] libraries in ecadlib.toml), or fill a library from a BOM; every part then shows up here, under each tool that has it."
1893
+ : one && t && !t.parts ? `None of your parts are in ${t.label} yet.` + (t.can_copy ? ` Copy them over with the button above.` : "")
1894
+ : "No part matches. Clear the search or pick All.";
1895
+ const from = (r.page - 1) * r.size;
1896
+ $("#hubpager").innerHTML = r.matched > r.size ? `<span class="muted small">Showing ${from + 1}–${Math.min(from + r.size, r.matched)} of ${r.matched}</span>
1897
+ <span class="row"><button class="small" data-hubpage="-1" ${r.page <= 1 ? "disabled" : ""}>Previous</button><button class="small" data-hubpage="1" ${from + r.size >= r.matched ? "disabled" : ""}>Next</button></span>`
1898
+ : (r.total ? `<span class="muted small">${r.matched} of ${one && t ? t.parts : r.total} parts</span>` : "");
1899
+ const probs = [...r.errors.map(e => "couldn't read " + e), ...r.warnings];
1900
+ $("#hubprob").innerHTML = probs.length ? `<details style="margin-top:8px"><summary class="small">${probs.length} library file(s) or folder(s) couldn't be read</summary><ul class="why small">${probs.map(p => `<li>${esc(p)}</li>`).join("")}</ul></details>` : "";
1901
+ }
1902
+ function copyDone(r) {
1903
+ const lab = toolLabel(r.tool), n = r.copied.length;
1904
+ const skip = r.skipped.length ? ` ${r.skipped.length} skipped: ${r.skipped.slice(0, 3).map(x => `${esc(x.name)} (${esc(x.why)})`).join("; ")}${r.skipped.length > 3 ? "…" : ""}.` : "";
1905
+ const where = r.project ? esc(r.project) + "." : r.tool === "kicad"
1906
+ ? `They're in the ecadlib_hub library (<span class="mono">${esc(r.library)}</span>); add its sym-lib-table and fp-lib-table rows to KiCad once, or pick a project above and copy into it.`
1907
+ : `They're in <span class="mono">${esc(r.library)}</span>; add that folder to Altium's libraries once, or pick a project above and copy into it.`;
1908
+ return `Copied ${n} part${n === 1 ? "" : "s"} to ${esc(lab)}. ${n ? where : ""}${skip}`;
1909
+ }
1910
+ async function hubCopyAll(btn) {
1911
+ const tool = btn.dataset.hubcopyall, project = btn.dataset.project || "";
1912
+ btn.disabled = true; banner($("#hubnote"), "");
1913
+ try {
1914
+ const p = new URLSearchParams({ lack: tool, size: 500, project });
1915
+ const keys = (await api("/api/hub?" + p.toString())).rows.map(r => r.key);
1916
+ const r = await api("/api/hub/copy", { keys, tool, project, user: $("#signer") ? $("#signer").value.trim() : "" });
1917
+ await loadHub();
1918
+ nextStep($("#hubnote"), copyDone(r), null, r.copied.length ? "s-ok" : "s-warn");
1919
+ } catch (e) { banner($("#hubnote"), e.message); }
1920
+ finally { btn.disabled = false; }
1921
+ }
1922
+ const hubReload = () => { hubpage = 1; loadHub().catch(e => banner($("#hubprob"), e.message)); };
1923
+ $("#hubq").addEventListener("input", () => { clearTimeout(hubtimer); hubtimer = setTimeout(hubReload, 250); });
1924
+ ["#hubhave", "#hublack", "#hubsort", "#hubproject"].forEach(s => $(s).addEventListener("change", hubReload));
1925
+ $("#hubtools").addEventListener("click", e => { const b = e.target.closest("[data-hubtool]"); if (!b) return; hubtool = b.dataset.hubtool; hubpicked = true; hublib = ""; store.set("hubtool", hubtool); hubReload(); });
1926
+ $("#hubtools").addEventListener("keydown", e => {
1927
+ if (!["ArrowRight", "ArrowLeft"].includes(e.key)) return;
1928
+ const all = [...document.querySelectorAll("[data-hubtool]")], i = all.indexOf(document.activeElement);
1929
+ if (i < 0) return; e.preventDefault(); all[(i + (e.key === "ArrowRight" ? 1 : all.length - 1)) % all.length].focus();
1930
+ });
1931
+ function hubLib(e) { const c = e.target.closest("[data-hublib]"); if (!c) return; hublib = c.dataset.hublib; hubReload(); }
1932
+ $("#hubtoolhead").addEventListener("click", e => { const b = e.target.closest("[data-hubcopyall]"); if (b) return hubCopyAll(b); hubLib(e); });
1933
+ $("#hubtoolhead").addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); hubLib(e); } });
1934
+ $("#hubproj").addEventListener("click", e => { const b = e.target.closest("[data-hubcopyall]"); if (b) hubCopyAll(b); });
1935
+ $("#hubrescan").addEventListener("click", e => action(e.target, $("#hubnote"), async () => { hubpage = 1; await loadHub(true); toast("Looked at every library again."); return ""; }));
1936
+ function hubChip(e) { const c = e.target.closest("[data-hubchip]"); if (!c) return; hubchip = hubchip === c.dataset.hubchip ? "" : c.dataset.hubchip; hubReload(); }
1937
+ $("#hubchips").addEventListener("click", hubChip);
1938
+ $("#hubchips").addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); hubChip(e); } });
1939
+ $("#hubpager").addEventListener("click", e => { const b = e.target.closest("[data-hubpage]"); if (!b) return; hubpage += +b.dataset.hubpage; loadHub().then(() => $("#hubcard").scrollIntoView({ behavior: motion() })).catch(err => banner($("#hubprob"), err.message)); });
1940
+ $("#hubrows").addEventListener("click", e => { const tr = e.target.closest("[data-hub]"); if (tr) openHubPart(tr.dataset.hub); });
1941
+ $("#hubrows").addEventListener("keydown", e => { const tr = e.target.closest("[data-hub]"); if (tr && e.key === "Enter") { e.preventDefault(); openHubPart(tr.dataset.hub); } });
1942
+ $("#hubclose").addEventListener("click", () => $("#hubdlg").close());
1943
+
1944
+ async function openHubPart(key) {
1945
+ hubkey = key;
1946
+ $("#hubtitle").textContent = "Loading…"; $("#hubsub").textContent = ""; $("#hubbody").innerHTML = "";
1947
+ if (!$("#hubdlg").open) $("#hubdlg").showModal();
1948
+ try { renderHubPart(await api("/api/hub/part?key=" + encodeURIComponent(key))); }
1949
+ catch (e) { $("#hubtitle").textContent = "Couldn't open this part"; banner($("#hubbody"), e.message); }
1950
+ }
1951
+ function renderHubPart(p) {
1952
+ $("#hubtitle").textContent = p.name;
1953
+ $("#hubsub").textContent = [p.mpn !== p.name ? p.mpn : "", p.manufacturer, p.description].filter(Boolean).join(" · ");
1954
+ const pics = p.pictures.map(f => `<figure><div class="fpbox">${f.svg}</div><figcaption>${esc(f.tool)} footprint ${esc(f.footprint || "")} · ${f.pads} pads</figcaption></figure>`).join("") +
1955
+ (p.symbol ? `<figure><div class="symbox">${p.symbol.svg}</div><figcaption>${esc(p.symbol.tool)} symbol ${esc(p.symbol.symbol || "")} · ${p.symbol.pins} pins (pinout sketch)</figcaption></figure>` : "");
1956
+ const fixes = p.fixes.filter(f => f.action === "open_plan" || can("librarian")).map(f =>
1957
+ `<button class="small${f.action === "build" || f.action === "kicad" ? " primary" : ""}" data-hubfix="${esc(f.action)}" data-bom="${esc(p.plan ? p.plan.bom || "" : "")}" title="${esc(f.why)}">${esc(f.label)}</button>`).join("");
1958
+ const copies = p.copies.map(c => {
1959
+ const [w, cls] = HUBSTATE[c.state] || [c.state, "s-info"];
1960
+ const why = c.state === "behind" ? `${esc(c.behind.words)} behind ${esc(({altium: "Altium", altium365: "Altium 365", kicad: "KiCad", fusion: "Fusion", easyeda: "EasyEDA"})[c.behind.tool] || c.behind.tool)}` : "";
1961
+ return `<tr><td data-tc="${esc(c.tool)}"><b>${esc(c.tool_label)}</b><div class="sub muted">${c.origin === "ecadlib" ? "built by ecadlib" : "your library"}</div></td>
1962
+ <td><span class="mono small">${esc(c.where || "")}</span>${c.file ? `<div class="sub muted mono" title="${esc(c.file)}">${esc(c.file.split("/").slice(-2).join("/"))}</div>` : ""}
1963
+ <div class="sub muted">${[c.symbol, c.footprint].filter(Boolean).map(esc).join(" · ")}${c.revision ? " · rev " + esc(c.revision) : ""}</div></td>
1964
+ <td class="mono small">${c.updated ? when(c.updated) : "–"}<div class="sub muted">${whoShort(c.updated_by)}${c.updated_how ? " · " + esc(c.updated_how) : ""}</div></td>
1965
+ <td><span class="pill ${cls}">${esc(w)}</span>${why ? `<div class="why">${why}</div>` : ""}${(c.diffs || []).map(d => `<div class="why">${esc(d.what)}: ${esc(d.text)}</div>`).join("")}${c.origin !== "ecadlib" && c.diffs && c.diffs.length ? '<div class="why muted">ecadlib never edits your library files: update this copy in the tool, or copy ecadlib\'s version over it.</div>' : ""}</td></tr>`;
1966
+ }).join("");
1967
+ const missing = hub ? hub.tools.filter(t => t.reader && !p.cells[t.tool]).map(t => t.label) : [];
1968
+ const pl = p.plan;
1969
+ const release = pl ? `<h3>Release check</h3><div><span class="pill ${pl.status === "ready" ? "s-ok" : pl.status === "held" ? "s-warn" : "s-info"}">${esc(pl.status_label || pl.status)}</span>${pl.allowed ? ' <span class="pill s-warn">released by override</span>' : ""}</div>` +
1970
+ (pl.reasons || []).map(x => `<div class="reason"><div>${esc(x.text)}</div>${x.fix ? `<div class="fix"><b>Next:</b> ${esc(x.fix)}</div>` : ""}</div>`).join("") +
1971
+ `<h3>Sign-off</h3>` + (pl.approvals.length ? `<ol class="hubhist">${pl.approvals.map(a => `<li><span class="t">${when(a.when)}</span><span>${a.decision === "approve" ? "Approved" : "Rejected"} by ${esc(a.who)}${a.role ? ` as ${esc(a.role)}` : ""}${a.comment ? `: ${esc(a.comment)}` : ""}</span></li>`).join("")}</ol>` : '<div class="muted small">Nobody has signed it off yet.</div>') +
1972
+ `<h3>Sources</h3><dl class="kv">${Object.entries(pl.source).filter(([, v]) => v).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(v)}</dd>`).join("")}` +
1973
+ `${pl.datasheet ? `<dt>datasheet</dt><dd><a href="${esc(pl.datasheet)}" target="_blank" rel="noopener noreferrer">${esc(pl.datasheet.split("/").pop() || pl.datasheet)}</a></dd>` : ""}` +
1974
+ `${Object.entries(pl.field_sources).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="small">${esc(v)}</dd>`).join("")}</dl>` +
1975
+ (pl.notes.length ? `<details style="margin-top:6px"><summary class="small">How ecadlib built it</summary><ul class="why small">${[...pl.tried, ...pl.notes].map(n => `<li>${esc(n)}</li>`).join("")}</ul></details>` : "")
1976
+ : `<h3>Release check</h3><div class="muted small">Not built by ecadlib, so it has no release check or sign-off here. ${p.mpn ? "Build it with ecadlib to have both copies compared." : ""}</div>`;
1977
+ const life = p.lifecycle ? `<span class="pill ${["nrnd", "ltb", "obsolete"].includes(p.lifecycle.status) ? "s-bad" : p.lifecycle.status === "active" ? "s-ok" : "s-off"}">${esc(p.lifecycle.label || p.lifecycle.status)}</span> <span class="muted small">from ${esc(p.lifecycle.from || "")}${p.lifecycle.checked ? ", checked " + when(p.lifecycle.checked) : ""}</span>`
1978
+ : '<span class="muted small">Not checked yet: the weekly upkeep (lifecycle check) or an Altium 365 pull fills this in.</span>';
1979
+ const used = p.where_used.length ? `<ol class="hubhist">${p.where_used.map(u => `<li><span class="t">${u.qty}×</span><span><b>${esc(u.project)}</b> <span class="muted small">${esc(u.designators.join(", "))}</span></span></li>`).join("")}</ol>`
1980
+ : `<div class="muted small">${p.projects_set ? "No project in the [hub] projects folders uses it." : "Add your project folders to [hub] projects in ecadlib.toml to see which designs use each part."}</div>`;
1981
+ const hist = p.history.length ? `<ol class="hubhist">${p.history.map(e => `<li><span class="t">${when(e.when)}</span><span>${esc(e.what)}${e.tool_label ? ` in ${esc(e.tool_label)}` : ""}${e.who ? ` by ${esc(e.who)}` : ""}${e.detail ? `<span class="muted small"> · ${esc(e.detail)}</span>` : ""}${e.file ? `<div class="sub muted mono">${esc(String(e.file).split("/").slice(-2).join("/"))}</div>` : ""}</span></li>`).join("")}</ol>`
1982
+ : '<div class="muted small">No changes recorded since the hub first looked. From now on every added, changed or removed copy and every fix made here is listed.</div>';
1983
+ $("#hubbody").innerHTML = `<div>${pics ? `<div class="hubpics">${pics}</div>` : '<div class="muted small">No footprint or symbol to draw: no copy has pads or pins ecadlib can read.</div>'}
1984
+ ${release}<h3>Lifecycle</h3><div>${life}</div><h3>Used in</h3>${used}</div>
1985
+ <div>${fixes ? `<div class="hubfix">${fixes}</div>` : ""}<div id="hubfixout"></div>
1986
+ <h3>Copies</h3><div class="tablewrap"><table class="copies"><thead><tr><th>Tool</th><th>Where</th><th>Last changed (UTC)</th><th>Compared</th></tr></thead><tbody>${copies}</tbody></table></div>
1987
+ ${missing.length ? `<div class="muted small" style="margin-top:6px">Not in ${missing.map(esc).join(", ")}.</div>` : ""}
1988
+ <h3>History</h3>${hist}</div>`;
1989
+ }
1990
+ $("#hubbody").addEventListener("click", async e => {
1991
+ const b = e.target.closest("[data-hubfix]"); if (!b) return;
1992
+ const act = b.dataset.hubfix, out = $("#hubfixout");
1993
+ if (act === "open_plan") {
1994
+ const h = history.find(x => x.bom === b.dataset.bom);
1995
+ if (!h) return toast("Couldn't find the BOM check for this part under Past BOM checks.");
1996
+ $("#hubdlg").close();
1997
+ try { showResult(await api("/api/triage/result?id=" + encodeURIComponent(h.id))); goTo("libcard"); } catch (err) { showErr(err); }
1998
+ return;
1999
+ }
2000
+ b.disabled = true; banner(out, "");
2001
+ try {
2002
+ const r = await api("/api/hub/fix", { key: hubkey, action: act, user: $("#signer") ? $("#signer").value.trim() : "" });
2003
+ if (act === "build") {
2004
+ out.innerHTML = `<div class="jobhead"><span class="muted small" id="hubjobtext" role="status" aria-live="polite">Building it from its MPN…</span><button class="small" id="hubjobstop">Stop</button></div><div class="progress"><div id="hubjobbar"></div></div><ul class="joblines" id="hubjoblines"></ul>`;
2005
+ const j = await pollJob(r.job, { text: $("#hubjobtext"), bar: $("#hubjobbar"), lines: $("#hubjoblines"), stop: $("#hubjobstop"),
2006
+ label: j => j.total ? `Building it from its MPN: step ${Math.min(j.done + 1, j.total)} of ${j.total}` : "Building it from its MPN…" });
2007
+ const s = j.result || {};
2008
+ nextStep(out, `Built: ${esc(s.ready || 0)} ready, ${esc(s.held || 0)} held. Open the part again to compare the copies.`, null, s.ready ? "s-ok" : "s-warn");
2009
+ } else if (act.startsWith("copy:")) {
2010
+ nextStep(out, copyDone(r.result), null, r.result.copied.length ? "s-ok" : "s-warn");
2011
+ } else if (act === "kicad") {
2012
+ nextStep(out, `Wrote the KiCad library for this part's BOM in <span class="mono">${esc(r.result.folder)}</span>.`, null, "s-ok");
2013
+ } else if (act === "a365_preview") {
2014
+ const x = (r.result.results || [])[0];
2015
+ nextStep(out, x ? `Dry run: ${esc(x.status || "")} ${esc(x.action || "")}. Nothing was sent; use Send to Altium for the real push.` : "Dry run done: nothing to send for this part.", ["export", "Send to Altium"]);
2016
+ } else {
2017
+ nextStep(out, "Done.", null, "s-ok");
2018
+ }
2019
+ await loadHub(); if ($("#hubdlg").open) { const keep = out.innerHTML; await openHubPart(hubkey); $("#hubfixout").innerHTML = keep; }
2020
+ } catch (err) { banner(out, err.message); }
2021
+ finally { b.disabled = false; }
2022
+ });
2023
+
2024
+ // ---- status (workspace summary) and setup check
2025
+ async function loadStatus() {
2026
+ const r = await api("/api/status"), t = r.totals, a = r.agent;
2027
+ const stat = (n, lab) => `<div><b>${esc(n)}</b><span class="muted small">${esc(lab)}</span></div>`;
2028
+ const todo = [];
2029
+ if (!r.boms) todo.push(can("librarian") ? "No BOM yet: drop one in step 1 to start." : "No BOM yet: a librarian starts by checking one.");
2030
+ if (t.held) todo.push(`${t.held} part(s) held: open the BOM under Past BOM checks; step 2 says why each one is held.`);
2031
+ if (t.awaiting_approval) todo.push(`${t.awaiting_approval} part(s) await sign-off in step 2.`);
2032
+ if (a.waiting_review) todo.push(`${a.waiting_review} item(s) wait in the Review queue.`);
2033
+ if (!r.rules) todo.push("Optional: Settings can propose library rules from your existing libraries, so new parts match them.");
2034
+ $("#navq").textContent = a.waiting_review || "";
2035
+ const lt = r.last_triage;
2036
+ $("#stout").innerHTML = `<div class="stats">${stat(r.boms, "BOMs uploaded")}${stat(t.parts, "parts in library plans")}${stat(t.ready, "ready")}${stat(t.held, "held")}` +
2037
+ `${stat(t.awaiting_approval, "awaiting approval")}${stat(a.waiting_review, "in the review queue")}</div>` +
2038
+ `<div class="small muted">Last BOM check: ${lt ? `${esc(lt.name || lt.report)}, ${when(lt.finished)}${lt.user ? " by " + esc(lt.user) : ""}` : "none yet"} · ` +
2039
+ `Library agent: ${esc(a.state)} · Rules: ${r.rules ? esc(r.rules) : "ecadlib defaults"}</div>` +
2040
+ (todo.length ? `<div class="next s-info"><span>${todo.map(esc).join("<br>")}</span></div>` : `<div class="next s-ok"><span>Nothing is waiting for anyone.</span></div>`) +
2041
+ (r.problems.length ? `<div class="banner s-warn"><span>Could not read: ${esc(r.problems.join("; "))}</span></div>` : "");
2042
+ }
2043
+ $("#streload").addEventListener("click", () => loadStatus().catch(e => banner($("#stout"), e.message)));
2044
+ $("#doctorbtn").addEventListener("click", e => action(e.target, $("#doctorout"), async () => {
2045
+ const r = await api("/api/doctor"), L = { OK: "s-ok", WARN: "s-warn", FAIL: "s-bad" };
2046
+ const row = c => `<div class="check"><span class="pill ${L[c.level] || "s-info"}">${esc(c.level)}</span> <b>${esc(c.check)}</b>: ${esc(c.detail)}${c.hint ? `<div class="muted small">${esc(c.hint)}</div>` : ""}</div>`;
2047
+ const ok = r.checks.filter(c => c.level === "OK").length, fail = r.checks.filter(c => c.level === "FAIL").length;
2048
+ return `<div class="next ${fail ? "s-bad" : "s-ok"}"><span>${fail ? `<b>${fail} check(s) failed</b>: each row says how to fix it.` : "Setup works."} ${ok} of ${r.checks.length} checks OK. ` +
2049
+ `Warnings are optional extras (an API key turns on one more source). Keys are shown by name only.</span></div>` +
2050
+ r.checks.filter(c => c.level !== "OK").map(row).join("") +
2051
+ `<details style="margin-top:6px"><summary class="muted small">${ok} OK check(s)</summary>${r.checks.filter(c => c.level === "OK").map(row).join("")}</details>`;
2052
+ }));
2053
+ function roleNote() {
2054
+ const u = session.team && session.user;
2055
+ nextStep($("#rolenote"), u && u.role === "viewer" ? `You are signed in as <b>${esc(u.username)}</b> with the viewer role: you can open everything, but checking BOMs, filling the library and approving parts need the librarian or approver role. Ask an ecadlib admin.` : "");
2056
+ }
2057
+ function startApp() {
2058
+ $("#signin").hidden = true; $("#app").hidden = false; $("#steps").hidden = false;
2059
+ applyRoles(); roleNote(); navState(); loadStatus().catch(e => banner($("#stout"), e.message));
2060
+ loadInfo().catch(showErr); loadSpecs().catch(showErr); loadRules().catch(showErr); loadStyle().catch(showErr); loadHistory().catch(e => banner($("#histerr"), e.message));
2061
+ loadQueue().catch(e => banner($("#qerr"), e.message)); loadHub().catch(e => banner($("#hubprob"), e.message));
2062
+ }
2063
+ // ---- keyboard shortcuts ("?" lists them); never while typing in a box or with Ctrl / Alt / Cmd held
2064
+ const keysdlg = $("#keysdlg");
2065
+ $("#keysbtn").addEventListener("click", () => keysdlg.showModal());
2066
+ $("#keysclose").addEventListener("click", () => keysdlg.close());
2067
+ function movePart(step) {
2068
+ const cards = Array.from(document.querySelectorAll("#plist [data-openp]"));
2069
+ if (!cards.length) return toast("No parts shown yet: fill the library first (step 2).");
2070
+ const i = cards.indexOf(document.activeElement);
2071
+ const n = cards[i < 0 ? (step > 0 ? 0 : cards.length - 1) : Math.max(0, Math.min(cards.length - 1, i + step))];
2072
+ n.focus(); n.closest(".pcard").scrollIntoView({ behavior: motion(), block: "nearest" });
2073
+ if ($("#panel").classList.contains("open")) { const p = findPart(n.dataset.openp); if (p) showPanel(p, false); }
2074
+ }
2075
+ document.addEventListener("keydown", e => {
2076
+ if (e.key === "Escape" && $("#panel").classList.contains("open") && !document.querySelector("dialog[open]")) { e.preventDefault(); return closePanel(); }
2077
+ const t = e.target, typing = t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
2078
+ if (typing || e.ctrlKey || e.metaKey || e.altKey || document.querySelector("dialog[open]") || $("#app").hidden) return;
2079
+ const k = e.key;
2080
+ const go = id => { e.preventDefault(); goTo(id); };
2081
+ if (k === "?") { e.preventDefault(); keysdlg.showModal(); }
2082
+ else if (k === "/") { e.preventDefault(); const f = $("#pq") || (!$("#results").hidden && $("#q")) || $("#histq"); f.focus(); f.scrollIntoView({ behavior: motion(), block: "center" }); }
2083
+ else if (k === "j") { e.preventDefault(); movePart(1); }
2084
+ else if (k === "k") { e.preventDefault(); movePart(-1); }
2085
+ else if (k === "h" && parts) { e.preventDefault(); pfilter = pfilter === "held" ? "" : "held"; renderParts(); goTo("libcard"); toast(pfilter ? "Showing held parts only." : "Showing all parts."); }
2086
+ else if (k === "u" && can("librarian")) { e.preventDefault(); $("#file").click(); }
2087
+ else if (k === "1") go("bomcard"); else if (k === "2") go("libcard"); else if (k === "3") go("export");
2088
+ else if (k === "l") go("hubcard");
2089
+ else if (k === "q") go("queuecard"); else if (k === "s") go("statuscard");
2090
+ });
2091
+
2092
+ (async () => {
2093
+ try { session = await api("/api/session"); } catch (e) { session = { team: false }; }
2094
+ if (session.team && !session.user) showSignin(); else startApp();
2095
+ })();
2096
+ </script>
2097
+ </body>
2098
+ </html>