{"id":8029,"date":"2026-09-04T08:25:33","date_gmt":"2026-09-04T08:25:33","guid":{"rendered":"https:\/\/plugin.manueldelao.com.es\/arsenal-wp\/"},"modified":"2026-09-04T09:39:23","modified_gmt":"2026-09-04T09:39:23","slug":"arsenal-wp","status":"publish","type":"page","link":"https:\/\/plugin.manueldelao.com.es\/pt\/arsenal-wp\/","title":{"rendered":"Arsenal WP"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"8029\" class=\"elementor elementor-8029\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"elementor-element elementor-element-8a5bbcb e-con-full e-flex e-con e-parent\" data-id=\"8a5bbcb\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;background_background&quot;:&quot;classic&quot;,&quot;jet_parallax_layout_list&quot;:[]}\">\n\t\t\t\t<div class=\"elementor-element elementor-element-5afec25 elementor-widget elementor-widget-shortcode\" data-id=\"5afec25\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"shortcode.default\">\n\t\t\t\t\t\t\t<div class=\"elementor-shortcode\"><script id=\"eb-arsenal-acceso\">window.EB_ARSENAL_ACCESO=\"https:\\\/\\\/plugin.manueldelao.com.es\\\/pt\\\/acceso-administrador\\\/\";<\/script><link rel=\"preconnect\" href=\"https:\/\/fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https:\/\/fonts.gstatic.com\" crossorigin=\"\">\n<link rel=\"stylesheet\" href=\"https:\/\/fonts.googleapis.com\/css2?family=Big+Shoulders+Display:wght@500;700;800;900&amp;family=IBM+Plex+Mono:wght@400;500;600&amp;display=swap\">\n\n<style>\n:root{\n  --ink:#0B0A0C;\n  --panel:#131118;\n  --panel-2:#1A1720;\n  --rule:#2E2938;\n  --paper:#EFEAE0;\n  --dim:#9A93A5;\n  --pink:#FF2E88;\n  --blue:#4A6BFF;\n  --yellow:#FFD84D;\n\n  --display:\"Big Shoulders Display\",\"Arial Narrow\",Haettenschweiler,Impact,sans-serif;\n  --mono:\"IBM Plex Mono\",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;\n\n  --gap:16px;\n  --pad:20px;\n  --max:1220px;\n}\n\n*{box-sizing:border-box}\n[hidden]{display:none !important}\nhtml{-webkit-text-size-adjust:100%}\nbody{\n  margin:0;\n  background:var(--ink);\n  color:var(--paper);\n  font-family:var(--mono);\n  font-size:14px;\n  line-height:1.55;\n  overflow-x:hidden;\n}\n::selection{background:var(--pink);color:var(--ink)}\na{color:inherit}\nbutton,input,textarea,select{font:inherit;color:inherit}\n:focus-visible{outline:3px solid var(--yellow);outline-offset:2px}\n\n\/* Noise tile is rasterised once and repeated: a full-viewport live SVG filter\n   repaints on every scroll and stalls the page. *\/\n.grain{\n  position:fixed;inset:0;z-index:900;pointer-events:none;opacity:.09;\n  background-image:url(\"data:image\/svg+xml,%3Csvg xmlns='http:\/\/www.w3.org\/2000\/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'\/%3E%3C\/filter%3E%3Crect width='140' height='140' filter='url(%23n)'\/%3E%3C\/svg%3E\");\n}\n.halftone{\n  position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.55;\n  background-image:radial-gradient(var(--rule) 1px,transparent 1.2px);\n  background-size:6px 6px;\n  -webkit-mask-image:radial-gradient(ellipse 90% 55% at 50% 0%,#000 0%,transparent 75%);\n  mask-image:radial-gradient(ellipse 90% 55% at 50% 0%,#000 0%,transparent 75%);\n}\n\n.wrap{position:relative;z-index:1;max-width:var(--max);margin:0 auto;padding:0 var(--pad)}\n\nheader.masthead{position:relative;z-index:3;border-bottom:3px solid var(--paper);background:var(--ink)}\n.masthead .wrap{\n  display:flex;align-items:flex-end;justify-content:space-between;\n  gap:var(--gap);flex-wrap:wrap;padding-top:22px;padding-bottom:14px;\n}\n.wordmark{\n  font-family:var(--display);font-weight:900;\n  font-size:clamp(44px,9vw,88px);line-height:.82;letter-spacing:-.02em;\n  text-transform:uppercase;margin:0;\n  text-shadow:3px 0 0 var(--pink),-3px 0 0 var(--blue);\n}\n.wordmark span{\n  display:inline-block;background:var(--yellow);color:var(--ink);\n  text-shadow:none;font-size:.3em;padding:2px 8px 0;\n  transform:rotate(-3deg) translateY(-.6em);letter-spacing:.06em;\n}\n.masthead-right{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding-bottom:6px}\n.count{\n  font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);\n  border:1px solid var(--rule);padding:6px 10px;white-space:nowrap;\n}\n.count b{color:var(--paper);font-weight:600}\n\n.ticker{border-bottom:2px solid var(--rule);background:var(--panel);overflow:hidden;white-space:nowrap;position:relative;z-index:2}\n.ticker-track{display:inline-block;padding:7px 0;animation:roll 46s linear infinite}\n.ticker-track span{\n  font-family:var(--display);font-weight:700;font-size:16px;text-transform:uppercase;\n  letter-spacing:.05em;color:var(--dim);padding:0 14px;\n}\n.ticker-track span b{color:var(--pink);font-weight:700}\n@keyframes roll{from{transform:translateX(0)}to{transform:translateX(-50%)}}\n\n.controls{position:sticky;top:0;z-index:5;background:var(--ink);border-bottom:2px solid var(--rule)}\n.controls .wrap{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding-top:12px;padding-bottom:12px}\n.tabs{display:flex;border:2px solid var(--paper)}\n.tab{\n  background:transparent;border:0;border-right:2px solid var(--paper);\n  padding:8px 14px;cursor:pointer;text-transform:uppercase;letter-spacing:.1em;\n  font-size:11px;font-weight:600;\n}\n.tab:last-child{border-right:0}\n.tab[aria-selected=\"true\"]{background:var(--paper);color:var(--ink)}\n.search{flex:1 1 200px;min-width:170px;background:var(--panel);border:2px solid var(--rule);padding:9px 12px;font-size:13px}\n.search::placeholder{color:var(--dim)}\n.search:focus{border-color:var(--pink);outline:none}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{\n  background:transparent;border:1px solid var(--rule);color:var(--dim);\n  padding:6px 10px;cursor:pointer;font-size:11px;letter-spacing:.1em;text-transform:uppercase;\n}\n.chip:hover{border-color:var(--paper);color:var(--paper)}\n.chip[aria-pressed=\"true\"]{background:var(--pink);border-color:var(--pink);color:var(--ink);font-weight:600}\n\n.btn{\n  background:var(--paper);color:var(--ink);border:2px solid var(--paper);\n  padding:8px 14px;cursor:pointer;font-weight:600;font-size:11px;\n  letter-spacing:.12em;text-transform:uppercase;text-decoration:none;display:inline-block;\n}\n.btn:hover{background:var(--yellow);border-color:var(--yellow)}\n.btn.ghost{background:transparent;color:var(--paper)}\n.btn.ghost:hover{background:var(--paper);color:var(--ink)}\n.btn.danger{background:transparent;border-color:var(--pink);color:var(--pink)}\n.btn.danger:hover{background:var(--pink);color:var(--ink)}\n.btn.sm{padding:5px 9px;font-size:10px;letter-spacing:.1em}\n\n.stripe{height:10px;background:repeating-linear-gradient(45deg,var(--yellow) 0 10px,var(--ink) 10px 20px);opacity:.85}\n\nmain{padding:26px 0 80px}\n.grid{display:grid;gap:var(--gap);grid-template-columns:repeat(auto-fill,minmax(290px,1fr))}\n\n.card{\n  position:relative;background:var(--panel);border:2px solid var(--rule);\n  padding:18px 16px 14px;display:flex;flex-direction:column;gap:10px;\n  text-align:left;cursor:pointer;width:100%;font-family:var(--mono);\n  transition:border-color .12s,transform .12s;\n}\n.card::before{content:\"\";position:absolute;top:-8px;left:22px;width:54px;height:16px;background:var(--yellow);opacity:.75;transform:rotate(-4deg)}\n.card:hover{border-color:var(--paper);transform:translate(-2px,-2px)}\n.card:hover .card-title{text-shadow:2px 0 0 var(--pink),-2px 0 0 var(--blue)}\n.card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:10px}\n.kind{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--dim);border-bottom:1px solid var(--rule);padding-bottom:2px}\n.stamp{\n  font-family:var(--display);font-weight:800;font-size:13px;letter-spacing:.1em;\n  text-transform:uppercase;border:2px solid currentColor;padding:1px 7px 0;\n  transform:rotate(-4deg);white-space:nowrap;\n}\n.stamp.stable{color:var(--blue)}\n.stamp.beta{color:var(--yellow)}\n.stamp.wip{color:var(--pink)}\n.card-title{font-family:var(--display);font-weight:800;font-size:30px;line-height:.95;text-transform:uppercase;margin:0;text-wrap:balance;overflow-wrap:break-word}\n.card-tag{color:var(--dim);font-size:12.5px;margin:0;line-height:1.5}\n.tags{display:flex;gap:5px;flex-wrap:wrap;margin-top:auto;padding-top:6px}\n.tags i{font-style:normal;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);border:1px solid var(--rule);padding:2px 6px}\n.card-foot{display:flex;justify-content:space-between;align-items:center;gap:8px;border-top:1px solid var(--rule);padding-top:9px;font-size:11px;color:var(--dim);letter-spacing:.06em}\n.card-foot em{font-style:normal;color:var(--pink);font-weight:600;letter-spacing:.14em}\n.card-admin{display:flex;gap:6px;margin-top:8px}\n\n.empty{border:2px dashed var(--rule);padding:40px 24px;text-align:center;color:var(--dim)}\n.empty h3{font-family:var(--display);font-size:28px;text-transform:uppercase;margin:0 0 6px;color:var(--paper)}\n\n.spread{display:grid;gap:28px;grid-template-columns:minmax(0,1fr) 320px;align-items:start}\n@media(max-width:900px){.spread{grid-template-columns:1fr}}\n.back{background:none;border:0;padding:0;cursor:pointer;color:var(--pink);font-size:11px;letter-spacing:.16em;text-transform:uppercase;font-weight:600;margin-bottom:14px}\n.back:hover{color:var(--yellow)}\n.detail-title{\n  font-family:var(--display);font-weight:900;font-size:clamp(38px,7vw,72px);\n  line-height:.86;text-transform:uppercase;margin:0 0 8px;text-wrap:balance;overflow-wrap:break-word;\n  text-shadow:3px 0 0 var(--pink),-3px 0 0 var(--blue);\n}\n.detail-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}\n.detail-lead{font-size:15px;margin:0 0 18px;max-width:62ch}\n.block{margin:0 0 26px}\n.block h3{font-family:var(--display);font-weight:800;font-size:20px;letter-spacing:.06em;text-transform:uppercase;margin:0 0 10px;padding-bottom:5px;border-bottom:2px solid var(--rule)}\n.block p{margin:0 0 12px;max-width:66ch;white-space:pre-wrap}\n.steps{margin:0;padding:0;list-style:none;counter-reset:s}\n.steps li{counter-increment:s;position:relative;padding:0 0 10px 40px;max-width:64ch}\n.steps li::before{\n  content:counter(s,decimal-leading-zero);position:absolute;left:0;top:1px;\n  font-family:var(--display);font-weight:800;font-size:15px;color:var(--pink);\n  border:1px solid var(--rule);padding:1px 6px 0;\n}\n.codebox{position:relative;border:2px solid var(--rule);background:var(--panel-2);margin-bottom:12px}\n.codebox pre{margin:0;padding:14px;overflow-x:auto;font-size:12.5px;line-height:1.6;white-space:pre}\n.codebox .copy{position:absolute;top:8px;right:8px}\n.meta{background:var(--panel);border:2px solid var(--rule);padding:16px;position:sticky;top:74px}\n@media(max-width:900px){.meta{position:static}}\n.meta h4{font-family:var(--display);font-weight:800;font-size:15px;letter-spacing:.12em;text-transform:uppercase;margin:0 0 10px;color:var(--dim)}\n.meta dl{margin:0 0 16px;display:grid;grid-template-columns:auto 1fr;gap:5px 12px;font-size:12.5px}\n.meta dt{color:var(--dim);text-transform:uppercase;letter-spacing:.08em;font-size:10.5px;padding-top:2px}\n.meta dd{margin:0;word-break:break-word}\n.repo-btn{text-align:center;margin-bottom:10px;width:100%}\n.zip-note{font-size:10.5px;color:var(--dim);margin:-2px 0 14px;line-height:1.45}\n.dl-list{list-style:none;margin:0 0 8px;padding:0;display:flex;flex-direction:column;gap:6px}\n.dl-list li{border-left:2px solid var(--blue);padding:2px 0 2px 8px;font-size:11.5px;display:flex;flex-direction:column;gap:1px}\n.dl-list b{font-weight:600;word-break:break-all}\n.dl-list span{color:var(--yellow);font-size:10px;letter-spacing:.08em}\n.dl-list em{font-style:normal;color:var(--dim);font-size:10.5px}\n.dl-btn{\n  display:flex;justify-content:space-between;align-items:center;gap:10px;width:100%;\n  margin-bottom:7px;text-align:left;\n}\n.dl-btn span{font-weight:400;opacity:.7;letter-spacing:.04em}\n.url-line{\n  font-size:10.5px;color:var(--paper);background:var(--panel-2);border:1px solid var(--rule);\n  padding:6px 8px;margin:0 0 8px;word-break:break-all;line-height:1.4;user-select:all;\n}\n.sitelist{display:flex;flex-direction:column;gap:8px}\n.sitelink{display:block;text-decoration:none;border:1px solid var(--rule);padding:8px 10px;font-size:12.5px}\n.sitelink:hover{border-color:var(--pink);color:var(--pink)}\n\n.site-card{background:var(--panel);border:2px solid var(--rule);display:flex;flex-direction:column;overflow:hidden}\n.site-card:hover{border-color:var(--paper)}\n.thumb{aspect-ratio:16\/10;background:var(--panel-2);display:block;width:100%;object-fit:cover;border-bottom:2px solid var(--rule)}\n.thumb-fallback{\n  aspect-ratio:16\/10;border-bottom:2px solid var(--rule);display:grid;place-items:center;\n  background:repeating-linear-gradient(135deg,var(--panel-2) 0 12px,var(--panel) 12px 24px);\n  font-family:var(--display);font-weight:900;font-size:34px;color:var(--rule);text-transform:uppercase;\n}\n.site-body{padding:14px 14px 12px;display:flex;flex-direction:column;gap:8px;flex:1}\n.site-body h3{font-family:var(--display);font-weight:800;font-size:24px;text-transform:uppercase;margin:0;line-height:1}\n.site-body .sector{color:var(--dim);font-size:12px;margin:0}\n.site-body .note{font-size:12.5px;margin:0;white-space:pre-wrap}\n.site-body a.url{color:var(--pink);font-size:12px;word-break:break-all;text-decoration:none}\n.site-body a.url:hover{color:var(--yellow);text-decoration:underline}\n.uses{display:flex;gap:5px;flex-wrap:wrap;margin-top:auto;padding-top:6px}\n.uses button{background:transparent;border:1px solid var(--blue);color:var(--blue);cursor:pointer;font-size:10px;letter-spacing:.08em;text-transform:uppercase;padding:2px 6px}\n.uses button:hover{background:var(--blue);color:var(--ink)}\n\n.modal-bg{position:fixed;inset:0;z-index:1000;background:rgba(5,4,6,.88);display:flex;align-items:flex-start;justify-content:center;padding:24px 16px;overflow-y:auto}\n.modal{background:var(--ink);border:3px solid var(--paper);width:100%;max-width:760px;box-shadow:12px 12px 0 var(--pink)}\n.modal-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:14px 18px;border-bottom:2px solid var(--rule)}\n.modal-head h2{font-family:var(--display);font-weight:900;font-size:26px;text-transform:uppercase;margin:0}\n.modal-body{padding:18px;display:grid;gap:14px}\n.row{display:grid;gap:14px;grid-template-columns:1fr 1fr}\n.row.three{grid-template-columns:1fr 1fr 1fr}\n@media(max-width:640px){.row,.row.three{grid-template-columns:1fr}}\nlabel.f{display:flex;flex-direction:column;gap:5px;font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}\nlabel.f input,label.f textarea,label.f select{\n  background:var(--panel);border:2px solid var(--rule);padding:8px 10px;font-size:13px;\n  letter-spacing:0;text-transform:none;color:var(--paper);width:100%;font-family:var(--mono);\n}\nlabel.f textarea{min-height:80px;resize:vertical;line-height:1.55}\nlabel.f input:focus,label.f textarea:focus,label.f select:focus{border-color:var(--pink);outline:none}\nlabel.f small{color:var(--dim);text-transform:none;letter-spacing:0;font-size:11px}\n.modal-foot{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;padding:14px 18px;border-top:2px solid var(--rule)}\n.weight{font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim)}\n.picker{display:flex;gap:10px;align-items:center;flex-wrap:wrap}\n.picker img{width:130px;border:2px solid var(--rule)}\n.checks{display:flex;gap:6px;flex-wrap:wrap}\n.checks label{display:flex;gap:6px;align-items:center;border:1px solid var(--rule);padding:5px 9px;font-size:11px;letter-spacing:.06em;text-transform:uppercase;cursor:pointer;color:var(--dim)}\n.checks input{accent-color:var(--pink)}\n.newsite{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}\n.newsite input{flex:1 1 160px;min-width:140px}\n.newsite .btn{flex:0 0 auto;white-space:nowrap}\n\n.toast{\n  position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:1100;\n  background:var(--paper);color:var(--ink);border:3px solid var(--ink);\n  box-shadow:6px 6px 0 var(--blue);padding:11px 16px;font-size:12px;font-weight:600;\n  letter-spacing:.06em;max-width:calc(100vw - 32px);text-align:center;\n}\n.toast.bad{box-shadow:6px 6px 0 var(--pink)}\n.toast.warn{box-shadow:6px 6px 0 var(--yellow)}\n\n@media (prefers-reduced-motion:reduce){\n  .ticker-track{animation:none}\n  .card,.card:hover{transition:none;transform:none}\n}\n\n\/* ============ Reintegraci\u00f3n en WordPress ============\n   En Claude esta p\u00e1gina estaba sola en el documento. Aqu\u00ed convive con Hello\n   Elementor, que declara sobre `button` cosas que el artefacto nunca tuvo que\n   declarar \u2014 y sus tarjetas, chips y pesta\u00f1as SON botones:\n     \u00b7 white-space:nowrap  \u2192 la descripci\u00f3n sal\u00eda en una sola l\u00ednea y se\n       desbordaba de la caja (medido: 848px de contenido en 254px de tarjeta).\n     \u00b7 un hover magenta #c36 \u2192 pintaba tarjetas, chips y pesta\u00f1as al pasar.\n   Esto restituye el comportamiento original. No cambia el dise\u00f1o. *\/\n.card,.card *,.tab,.chip,.back,.modal,.modal *,.spread,.spread *,.site-card,.site-card *{white-space:normal}\n.btn,.stamp,.ticker-track,.ticker-track span{white-space:nowrap}\n.card:hover,.card:focus{background:var(--panel)}\n.chip:hover,.chip:focus{background:transparent;border-color:var(--paper);color:var(--paper)}\n.chip[aria-pressed=\"true\"],.chip[aria-pressed=\"true\"]:hover,.chip[aria-pressed=\"true\"]:focus{background:var(--pink);border-color:var(--pink);color:var(--ink)}\n.tab:hover,.tab:focus{background:transparent;color:var(--paper)}\n.tab[aria-selected=\"true\"],.tab[aria-selected=\"true\"]:hover,.tab[aria-selected=\"true\"]:focus{background:var(--paper);color:var(--ink)}\n.back:hover,.back:focus{background:none}\n\n\/* Las variables CSS heredan del ancestro MAS CERCANO que las define, y el\n   contenedor de Elementor declara su propio --gap (20px 20px). Eso tapaba el\n   --gap:16px del :root del artefacto y `gap:var(--gap)` quedaba en `normal`:\n   las tarjetas salian pegadas, sin separacion ninguna. Los tres tokens de\n   medida se fijan aqui con el valor literal del original. *\/\n.grid{gap:16px}\n.masthead .wrap{gap:16px}\n.wrap{max-width:1220px;padding:0 20px}\n\n\/* Cajas que no revientan en pantallas estrechas: con minmax(290px,...) fijo,\n   por debajo de ~330px de ancho \u00fatil la columna era m\u00e1s ancha que la pantalla\n   y aparec\u00eda scroll horizontal. *\/\n.grid{grid-template-columns:repeat(auto-fill,minmax(min(290px,100%),1fr))}\n.card-title,.card-tag,.detail-title,.detail-lead,.site-body,.sitelist,.dl-list{overflow-wrap:anywhere}\n.wrap{box-sizing:border-box}\n\n\/* La FICHA en movil se iba de lado: `1fr` es minmax(auto,1fr) y `auto` nunca baja\n   del contenido minimo, asi que un bloque de codigo o una URL larga estiraban la\n   columna a 425px dentro de un contenedor de 335px. minmax(0,1fr) + min-width:0\n   permiten encogerla, y lo que no puede partirse se desplaza dentro de su caja. *\/\n.spread{grid-template-columns:minmax(0,1fr) 320px}\n@media(max-width:900px){.spread{grid-template-columns:minmax(0,1fr)}}\n.spread>*,.meta,.detail{min-width:0}\n.spread pre,.codebox,.codebox pre{max-width:100%;overflow-x:auto}\n.spread p,.spread li,.spread dd,.spread h3,.spread h4,.url-line,.zip-note{overflow-wrap:anywhere}\n.meta .btn,.dl-btn,.repo-btn{white-space:normal;text-align:center}\n\n@media(max-width:560px){\n  .grid{grid-template-columns:1fr;gap:14px}\n  .card{padding:16px 14px 12px}\n  .card-title{font-size:26px}\n  .masthead .wrap,.controls .wrap{gap:10px}\n  .masthead-right{flex-wrap:wrap;gap:8px}\n}\n<\/style>\n\n<div class=\"grain\" aria-hidden=\"true\"><\/div>\n<div class=\"halftone\" aria-hidden=\"true\"><\/div>\n\n<header class=\"masthead\">\n  <div class=\"wrap\">\n    <h1 class=\"wordmark\">Arsenal<span>WP<\/span><\/h1>\n    <div class=\"masthead-right\">\n      <a class=\"btn ghost\" id=\"eb-volver\" href=\"\/\">&larr; Volver<\/a>\n      <div class=\"count\" id=\"count\"><\/div>\n      <button class=\"btn ghost\" id=\"add-tool\" hidden=\"\">+ Herramienta<\/button>\n      <button class=\"btn ghost\" id=\"add-site\" hidden=\"\">+ Web<\/button>\n      <button class=\"btn ghost\" id=\"set-base\" hidden=\"\">Origen<\/button>\n    <\/div>\n  <\/div>\n<\/header>\n\n<div class=\"ticker\" aria-hidden=\"true\"><div class=\"ticker-track\" id=\"ticker\"><\/div><\/div>\n\n<div class=\"controls\">\n  <div class=\"wrap\">\n    <div class=\"tabs\" role=\"tablist\">\n      <button class=\"tab\" role=\"tab\" id=\"tab-tools\" aria-selected=\"true\">Herramientas<\/button>\n      <button class=\"tab\" role=\"tab\" id=\"tab-webs\" aria-selected=\"false\">Webs<\/button>\n    <\/div>\n    <input class=\"search\" id=\"search\" type=\"search\" placeholder=\"Buscar por nombre, tag o descripci\u00f3n\u2026\" aria-label=\"Buscar\">\n    <div class=\"chips\" id=\"chips\"><\/div>\n  <\/div>\n<\/div>\n<div class=\"stripe\"><\/div>\n\n<main><div class=\"wrap\" id=\"view\"><\/div><\/main>\n\n<div id=\"layer\"><\/div>\n\n<script id=\"catalog\" type=\"application\/json\">{\"version\":1,\"distBase\":\"\",\"tools\":[{\"id\":\"plantilla-producto\",\"name\":\"Plantilla Producto\",\"tagline\":\"Ficha de producto WooCommerce + Elementor donde las variaciones son filas seleccionables, cada una con su precio.\",\"kind\":\"plantilla\",\"status\":\"stable\",\"tags\":[\"woocommerce\",\"elementor-pro\",\"jetwoobuilder\",\"theme-builder\",\"ficha-producto\"],\"repo\":\"\",\"release\":null,\"version\":\"1.2.0\",\"author\":\"Juan Barbat\",\"description\":\"Ficha de producto estilo farmacia: galer\\u00eda a la izquierda y una caja de compra donde las variaciones son filas seleccionables \\u2014 cada una con su propio precio, su pill de descuento, su etiqueta destacada y su precio por unidad. Debajo, el banner de chat, las tarjetas de informaci\\u00f3n y las pesta\\u00f1as del producto.\\n\\nSon dos piezas y hacen falta las dos: el plugin manda dentro de la caja de compra, la plantilla de Elementor manda en la p\\u00e1gina que la rodea.\\n\\nY viene con la tarjeta del archivo de tienda a juego: una grid con la marca, las etiquetas destacadas, la valoraci\\u00f3n y el precio tachado, leyendo los mismos n\\u00fameros que la ficha.\\n\\nQu\\u00e9 trae cada pieza:\\n\\u2022 plugin\\\/nvm-variation-rows \\u2014 el plugin, que en WordPress aparece como \\\"NovaMira Variation Rows for WooCommerce\\\". Filas de variante, precio unitario, porcentajes de descuento, el descuento extra como regla real de carrito, el stepper de cantidad, el control de wishlist, y en la tarjeta la marca, los badges, el n\\u00famero de rese\\u00f1as y la pill de descuento.\\n\\u2022 elementor-template\\\/ \\u2014 la plantilla de Single Product y la de Product Archive.\\n\\u2022 mockup\\\/ \\u2014 la maqueta est\\u00e1tica aprobada, el contrato visual contra el que se verifica el build.\",\"requires\":{\"wp\":\"6.4+\",\"php\":\"7.4+\",\"woo\":\"8.0+\",\"other\":\"Elementor Pro (obligatorio: Theme Builder y widgets Woo son Pro) \\u00b7 JetWooBuilder (solo para la tarjeta de archivo) \\u00b7 JetCompareWishlist (opcional, solo el coraz\\u00f3n)\"},\"install\":[\"Baj\\u00e1 nvm-variation-rows.zip del release v1.2.0 y subilo en Plugins \\u2192 A\\u00f1adir nuevo \\u2192 Subir plugin. Activ\\u00e1. Ese ZIP trae solo el plugin, ya listo: no hay que clonar el repo ni recortar carpetas.\",\"Baj\\u00e1 nvm-ficha-producto-template.json del mismo release e importalo en Plantillas \\u2192 Theme Builder \\u2192 Importar.\",\"Asign\\u00e1 la condici\\u00f3n de visualizaci\\u00f3n: include\\\/product, o las categor\\u00edas que deban usarla. Sin condici\\u00f3n la plantilla existe y no se renderiza en ning\\u00fan lado \\u2014 es el motivo n\\u00famero uno de que una importaci\\u00f3n correcta parezca no hacer nada.\"],\"installCode\":\"\",\"usage\":\"Shortcodes que el plugin a\\u00f1ade a la tarjeta (leen el $product global del loop):\\n\\n[nvm_badges]        etiquetas destacadas, cada una con su color\\n                    discount=\\\"no\\\" quita el porcentaje \\u00b7 limit=\\\"4\\\" corta la pila\\n[nvm_discount]      la pill de descuento, p. ej. -44%  (vac\\u00eda si no est\\u00e1 en oferta)\\n[nvm_brand]         la marca, desde la taxonom\\u00eda product_brand \\u00b7 link=\\\"yes\\\" la enlaza\\n[nvm_rating_count]  el n\\u00famero de rese\\u00f1as, p. ej. (35)\\n\\nLos badges son una taxonom\\u00eda propia, nvm_badge (\\\"Etiquetas destacadas\\\"), con color de\\nfondo y color de texto por t\\u00e9rmino: se editan una vez en el t\\u00e9rmino, no producto a producto.\\n\\n--nvm-card-media-ratio  (por defecto 1 \\\/ 1) fija el marco de la foto en la tarjeta.\\nAjustalo al cat\\u00e1logo: 4 \\\/ 3 para packshots apaisados, 3 \\\/ 4 para botellas y cajas.\",\"notes\":\"C\\u00d3MO SE MANTIENE EL ZIP. tools\\\/build-plugin.ps1 empaqueta, y tres hooks en .githooks\\\/ lo relanzan en cada momento en que el \\u00e1rbol puede empezar a discrepar del zip: pre-commit, post-merge y post-checkout. Est\\u00e1 registrado con core.hooksPath. El zip queda gitignoreado (*.zip ya estaba en .gitignore): es un artefacto reconstruible, y commitearlo mete un binario que cambia entero en cada edici\\u00f3n y que puede discrepar del fuente que tiene al lado.\\n\\nTres cosas que el script hace y que un zip -r no:\\n1. Aborta si las versiones no coinciden. Lee la versi\\u00f3n de la cabecera y falla si NVM_VR_VERSION dice otra cosa. Son dos sitios a mano, y bumpear solo uno deja los assets con el ?ver= viejo: el navegador sirve el CSS cacheado y el deploy parece no haber ocurrido.\\n2. Empaqueta desde git, no desde la carpeta. La lista sale de git ls-files --cached --others --exclude-standard: entra el trabajo sin commitear, no entra la basura de editores ni nada gitignoreado.\\n3. Nombra las entradas \\u00e9l mismo. Compress-Archive escribe los nombres con backslash, que el formato zip no permite, y ZipFile::CreateFromDirectory hace lo mismo. Windows descomprime igual, as\\u00ed que pasa inspecci\\u00f3n local; pero PHP lee los nombres literales y WordPress instala 18 ficheros sueltos llamados nvm-variation-rows\\\\includes\\\\class-\\u2026.php, sin plugin en la lista. Ahora las entradas se construyen a mano y el build se relee a s\\u00ed mismo antes de reportar \\u00e9xito.\\n\\nEl asset del release v1.2.0 fue auditado con un lector independiente: 18 entradas, 0 backslashes, ra\\u00edz \\u00fanica, CRCs v\\u00e1lidos.\\n\\nEl producto tiene que ser VARIABLE y con UN solo atributo de variaci\\u00f3n. Con dos o m\\u00e1s, una opci\\u00f3n deja de mapear a un precio \\u00fanico, as\\u00ed que el plugin se aparta y WooCommerce renderiza sus desplegables nativos en lugar de imprimir una fila con un precio adivinado.\\n\\nPonele atributo por defecto. Sin \\u00e9l WooCommerce carga sin variaci\\u00f3n seleccionada, el total queda oculto y el bot\\u00f3n de a\\u00f1adir se estira hasta llenar la fila.\\n\\nPara la tarjeta de archivo tienen que alinearse tres cosas o la tarjeta sale en blanco:\\n1. Una plantilla Archive Item (post type jet-woo-builder, _elementor_template_type = jet-woo-builder-archive).\\n2. WooCommerce \\u2192 Ajustes \\u2192 JetWooBuilder: Custom archive page activada y apuntando a esa plantilla.\\n3. Widgets render method en Elementor Default. Con el valor por defecto, Macros, Jet lee un meta pre-cocinado que solo existe si la plantilla se guard\\u00f3 desde el editor de Elementor \\u2014 una plantilla escrita por script no lo tiene y Jet no renderiza nada, sin error en ning\\u00fan lado.\\n\\nEstrellas: us\\u00e1 un glifo relleno (Rating 3). El widget de valoraci\\u00f3n de Jet dibuja la nota apilando una segunda fila de estrellas recortada por porcentaje, con el MISMO glifo que la vac\\u00eda; con el Rating 1 por defecto, que es hueco, un producto de cuatro estrellas muestra cuatro estrellas huecas en gris \\u2014 invisible a tama\\u00f1o tarjeta.\",\"addedAt\":\"2026-09-01\"},{\"id\":\"importador-proveedores\",\"name\":\"Importador de Proveedores\",\"tagline\":\"Importa cat\\u00e1logos de proveedores sin API a WooCommerce, con margen de reventa configurable.\",\"kind\":\"importador\",\"status\":\"beta\",\"tags\":[\"woocommerce\",\"importador\",\"scraping\",\"action-scheduler\",\"proveedores\"],\"repo\":\"\",\"release\":null,\"version\":\"0.1.0\",\"author\":\"barbatjuan\",\"description\":\"Plugin de WordPress\\\/WooCommerce para traer cat\\u00e1logos de proveedores que NO exponen API. Lee la web del proveedor, arma el producto en tu tienda y le aplica el margen de reventa que le digas.\\n\\nCuatro adaptadores registrados de f\\u00e1brica: talla100, Shopify, WooCommerce y PrestaShop gen\\u00e9rico. Cada uno implementa la misma interfaz (interface-supplier-adapter.php), as\\u00ed que sumar un proveedor nuevo es escribir una clase m\\u00e1s en includes\\\/adapters\\\/ y registrarla.\\n\\nEl trabajo pesado va en cola con Action Scheduler: descubrir una categor\\u00eda, importar un producto, actualizarlo y bajar sus im\\u00e1genes son cuatro jobs distintos. El rate limit se aplica POR HOST de origen mediante un transient, no por concurrencia de la cola, as\\u00ed que se permiten 3 lotes en paralelo sin saltarse el l\\u00edmite del proveedor.\",\"requires\":{\"wp\":\"sin declarar en la cabecera\",\"php\":\"8.0+ (el c\\u00f3digo usa match)\",\"woo\":\"obligatorio (Requires Plugins: woocommerce)\",\"other\":\"Action Scheduler, que ya viene con WooCommerce. Composer NO hace falta para usarlo.\"},\"install\":[\"Baj\\u00e1 importador-proveedores.zip del release v0.1.0 y subilo en Plugins \\u2192 A\\u00f1adir nuevo \\u2192 Subir plugin. Nada que compilar.\",\"Activ\\u00e1 el plugin. WooCommerce tiene que estar activo: si no, el plugin se auto-desactiva y lo avisa con un notice en el admin.\"],\"installCode\":\"\",\"usage\":\"Pantalla: WooCommerce \\u2192 Importador de Proveedores.\\n\\nDos modos:\\n\\u2022 Categor\\u00eda completa \\u2014 peg\\u00e1s la URL de la categor\\u00eda del proveedor y recorre todas sus p\\u00e1ginas.\\n\\u2022 Un solo producto \\u2014 peg\\u00e1s la URL del producto.\\n\\nEn los dos casos eleg\\u00eds adaptador y margen de reventa (%). El progreso se muestra en vivo\\ny hay un informe de qu\\u00e9 cambi\\u00f3 en cada actualizaci\\u00f3n, con la opci\\u00f3n de quitar un producto.\\n\\nHooks disponibles si quer\\u00e9s dispararlo desde c\\u00f3digo o desde WP-CLI:\\n\\ndo_action( 'importador_discover_category', $category_url, $adapter_slug, $margin_percent, $page );\\ndo_action( 'importador_import_product',    $product_url,  $adapter_slug, $margin_percent, $category_name, $category_parents );\\ndo_action( 'importador_update_product',    $product_id );\\ndo_action( 'importador_import_images',     $product_id,   $image_urls );\",\"notes\":\"composer install NO hace falta para usarlo. composer.json solo declara require-dev (phpunit y yoast\\\/phpunit-polyfills), y vendor\\\/autoload.php se carga \\u00fanicamente desde tests\\\/bootstrap.php y tests\\\/wp-bootstrap.php \\u2014 en producci\\u00f3n el plugin resuelve sus clases con su propio spl_autoload_register. Corr\\u00e9 composer install solo si vas a ejecutar los tests.\\n\\nPara ver la pantalla hace falta la capability manage_woocommerce.\\n\\nVersi\\u00f3n 0.1.0 y sin README todav\\u00eda: lo de ac\\u00e1 est\\u00e1 sacado del c\\u00f3digo (cabecera del plugin, registro de adaptadores y la pantalla de admin). Si document\\u00e1s el repo, actualiz\\u00e1 esta ficha desde EDITAR.\\n\\nTiene tests: phpunit.xml.dist y phpunit-wp.xml.dist en la ra\\u00edz.\",\"addedAt\":\"2026-09-01\"},{\"id\":\"wordpress-orchestrator\",\"name\":\"WordPress Orchestrator\",\"tagline\":\"Agente + skills para dise\\u00f1ar webs WordPress premium y volcarlas al sitio real. Elementor probado, Divi en scaffold.\",\"kind\":\"orquestador\",\"status\":\"stable\",\"version\":\"\\u2014\",\"author\":\"barbatjuan\",\"repo\":\"\",\"release\":null,\"noRelease\":\"No tiene release ni ZIP instalable, y no deber\\u00eda: no es un plugin de WordPress. Es un framework que vive en tu m\\u00e1quina, dentro de ~\\\/.claude, y se instala clonando y corriendo el instalador. Adem\\u00e1s pesa 6,4 MB, casi todo la galer\\u00eda de skills\\\/html-mockup\\\/assets.\",\"tags\":[\"claude-code\",\"agentes\",\"skills\",\"elementor\",\"divi\",\"mockup\",\"qa\"],\"description\":\"Sistema modular para construir webs WordPress premium y reutilizarlo entre proyectos \\u2014 taller, cl\\u00ednica, inmobiliaria, ecommerce \\u2014 cambiando la config de marca y sumando skills de dominio. El orquestador y las bases no se tocan.\\n\\nPrincipio: el agente piensa, las skills ejecutan. Un agente router m\\u00ednimo decide QU\\u00c9 skill corre, en qu\\u00e9 orden y con qu\\u00e9 contexto. No lleva CSS, HTML ni PHP dentro: eso vive en las skills y sus assets\\\/.\\n\\nQu\\u00e9 hay dentro:\\n\\u2022 agents\\\/wordpress-orchestrator.md \\u2014 el router (piensa, pregunta, deriva).\\n\\u2022 project-context\\\/ \\u2014 detecta builder (elementor|divi), plugins y marca.\\n\\u2022 web-templates\\\/ \\u2014 ARQUITECTURA de p\\u00e1gina: arquetipos de home (ecommerce|corporate), p\\u00e1ginas internas (PDP, tienda, sobre nosotros, contacto), recomendador y toggles.\\n\\u2022 ux-design-system\\\/ \\u2014 lenguaje visual agn\\u00f3stico del builder (tokens, motion, layout).\\n\\u2022 html-mockup\\\/ \\u2014 preview est\\u00e1tica en HTML para aprobaci\\u00f3n del cliente ANTES de tocar WordPress.\\n\\u2022 elementor-core\\\/ \\u2014 ejecuci\\u00f3n Elementor, probada en producci\\u00f3n (+ es-builder.php).\\n\\u2022 divi-core\\\/ \\u2014 ejecuci\\u00f3n Divi: scaffold sin validar, todav\\u00eda sin assets.\\n\\u2022 elementor-theme-parts\\\/ \\u2014 header, footer y partes del Theme Builder (Elementor Pro).\\n\\u2022 woocommerce\\\/ \\u2014 tienda, producto, side cart, plantillas (solo por la v\\u00eda Elementor).\\n\\u2022 wordpress-forms\\\/ \\u2014 formularios con entrega PROBADA, no un formulario renderizado.\\n\\u2022 wordpress-legal\\\/ \\u2014 p\\u00e1ginas legales con los datos reales del cliente + banner que bloquea de verdad.\\n\\u2022 wordpress-performance\\\/, wordpress-seo\\\/, qa-review\\\/.\\n\\u2022 visual-verification\\\/ \\u2014 juzga la p\\u00e1gina renderizada a ojo, dentro de un subagente y con presupuesto de capturas.\\n\\u2022 framework-audit\\\/ \\u2014 audita el framework mismo: que cada regla tenga verificador y que ninguna skill apunte a un archivo que no existe.\\n\\u2022 blind-judges\\\/ \\u2014 revisi\\u00f3n doble a ciegas, con agents\\\/blind-judge-a.md y blind-judge-b.md.\\n\\nAl lado, agents\\\/wordpress-copywriter.md es un subagente que escribe el copy real en su propia ventana de contexto. Se le llama por delegaci\\u00f3n expl\\u00edcita y nunca toca WordPress.\",\"requires\":{\"wp\":\"el sitio destino\",\"php\":\"8.2 (CLI, en tu PATH)\",\"woo\":\"opcional\",\"other\":\"Claude Code (las skills y agentes cargan desde ~\\\/.claude\\\/) \\u00b7 git \\u00b7 Elementor (v\\u00eda probada) o Divi (scaffold sin validar)\"},\"install\":[\"Prerrequisitos: Claude Code, PHP en el PATH (el generador de galer\\u00eda y la auditor\\u00eda del framework son scripts PHP CLI; probado en 8.2, comprob\\u00e1 con php -v) y git.\",\"Clon\\u00e1 el repo y entr\\u00e1 en la carpeta.\",\"Corr\\u00e9 el instalador: .\\\/install.ps1 en Windows (PowerShell) o .\\\/install.sh en macOS\\\/Linux. Los dos copian agents\\\/ y skills\\\/ dentro de ~\\\/.claude\\\/. Las skills recargan en vivo, no hace falta reiniciar.\",\"Re-ejecut\\u00e1 el instalador despu\\u00e9s de cada git pull que toque skills\\\/** o agents\\\/**: mergear upstream NO actualiza tu ~\\\/.claude\\\/, y hasta que lo vuelvas a correr Claude Code sigue ejecutando la versi\\u00f3n vieja del framework.\"],\"installCode\":\"git clone https:\\\/\\\/github.com\\\/barbatjuan\\\/wordpress-orchestrator.git\\ncd wordpress-orchestrator\\n\\n# Windows (PowerShell)\\n.\\\/install.ps1\\n\\n# macOS \\\/ Linux\\n.\\\/install.sh\",\"usage\":\"La primera pregunta del agente es \\\"\\u00bfsitio nuevo o sitio existente?\\\" \\u2014 de ah\\u00ed depende si se inspecciona WordPress o no.\\n\\nSitio nuevo (greenfield) \\u2014 no se escribe nada en WordPress hasta el gate:\\n  web-templates \\u2192 ux-design-system \\u2192 html-mockup \\u2192 [BUILD GATE] \\u2192 project-context\\n  \\u2192 elementor-core | divi-core \\u2192 woocommerce \\u2192 performance \\\/ seo \\u2192 qa-review\\n\\nSitio existente \\u2014 primero inspecciona, despu\\u00e9s deriva seg\\u00fan lo que encontr\\u00f3:\\n  project-context \\u2192 web-templates \\u2192 ux-design-system \\u2192 html-mockup \\u2192 [BUILD GATE]\\n  \\u2192 elementor-core | divi-core \\u2192 woocommerce \\u2192 performance \\\/ seo \\u2192 qa-review\\n\\nEL BUILD GATE ES UNA PARADA DURA. Despu\\u00e9s de aprobar la maqueta y ANTES de cualquier\\nescritura en WordPress, el agente para y pide un s\\u00ed expl\\u00edcito para ese build. Esper\\u00e1 que\\nse frene ah\\u00ed. Sin maqueta aprobada y sin s\\u00ed expl\\u00edcito, no hay build nativo. En un sitio\\nexistente adem\\u00e1s confirma cada sobrescritura de p\\u00e1gina por nombre.\\n\\nLa maqueta es el gate de aprobaci\\u00f3n y el contrato visual; nunca se importa al builder.\",\"notes\":\"Tres tipos de skill: conocimiento (web-templates, ux-design-system \\u2014 deciden, no tocan nada), solo lectura (project-context, qa-review \\u2014 inspeccionan e informan, nunca escriben) y operativas (html-mockup produce un Artifact; el resto escribe en el sitio real detr\\u00e1s del gate).\\n\\nOJO con el copiado: sobrescribe en sitio pero NO borra lo que se elimin\\u00f3 upstream, as\\u00ed que una skill o referencia borrada del repo sigue viviendo en tu ~\\\/.claude\\\/ hasta que la saques a mano. No es cosm\\u00e9tico: una plantilla retirada puede seguir ofreci\\u00e9ndose como vigente.\\nPara listar lo que qued\\u00f3 colgado:  diff -rq skills ~\\\/.claude\\\/skills | grep \\\"^Only in\\\"\\n\\nEn PowerShell, cp -rf es un idiom de Bash y falla (cp es alias de Copy-Item, que no tiene -rf). Us\\u00e1 el instalador, o:\\nCopy-Item -Path skills\\\\* -Destination \\\"$HOME\\\\.claude\\\\skills\\\" -Recurse -Force\\n\\nEl README del repo todav\\u00eda clona con el nombre viejo (novamira-web-framework). El repo que resuelve hoy es barbatjuan\\\/wordpress-orchestrator.\\n\\nSolo elementor-core y woocommerce tienen references\\\/knowledge.md y references\\\/gotchas.md; divi-core tiene solo gotchas, y project-context \\\/ qa-review \\\/ wordpress-performance \\\/ wordpress-seo \\\/ wordpress-forms \\\/ wordpress-legal todav\\u00eda no tienen references\\\/.\",\"addedAt\":\"2026-09-01\"},{\"id\":\"agency-mcp-bridge\",\"name\":\"Agency MCP Bridge\",\"tagline\":\"Servidor MCP de m\\u00ednimo privilegio para WordPress, sobre la Abilities API del core y el adaptador oficial.\",\"kind\":\"conector\",\"status\":\"wip\",\"tags\":[\"mcp\",\"abilities-api\",\"seguridad\",\"claude-code\",\"rest-api\"],\"repo\":\"\",\"release\":null,\"version\":\"0.26.0\",\"author\":\"Adri Rodrigo Agencia\",\"description\":\"Servidor MCP de m\\u00ednimo privilegio para WordPress, construido sobre la Abilities API del core y el adaptador oficial wordpress\\\/mcp-adapter.\\n\\nExpone a un cliente de IA un conjunto chico y expl\\u00edcito de abilities tipadas. No ejecuta PHP arbitrario, no escribe archivos y no corre WP-CLI.\\n\\nModelo de seguridad en siete capas, cada una contestando a un atacante concreto:\\nL0  allowlist expl\\u00edcita de abilities \\u2014 nada se autodescubre.\\nL1  rol propio mcp_agent y capability use_mcp_bridge, nunca manage_options.\\nL2  puerta de transporte: HTTPS, doble kill switch, auth solo por token, rate limit, allowlist de IP opcional.\\nL3  chequeo de capability por ability.\\nL4  guardarra\\u00edles de escritura: solo borrador, revisiones forzadas, allowlist de meta y de options, saneado de contenido.\\nL5  separaci\\u00f3n de entornos aplicada en el registro, no en la llamada.\\nL6  log de auditor\\u00eda.\\nL7  higiene de credenciales.\\nHoy est\\u00e1n implementadas L0, L1, L2, L3 y L5. L4 y L6 llegan con las abilities de Tier 2.\",\"requires\":{\"wp\":\"7.0+ (la Abilities API viene en el core)\",\"php\":\"8.3+\",\"woo\":\"no\",\"other\":\"HTTPS obligatorio \\u00b7 dependencias de Composer en runtime: league\\\/oauth2-server ^8.5, nyholm\\\/psr7 ^1.8, wordpress\\\/mcp-adapter 0.6.1\"},\"install\":[\"Baj\\u00e1 agency-mcp-bridge-0.26.0.zip del release v0.26.0 y subilo en Plugins \\u2192 A\\u00f1adir nuevo \\u2192 Subir plugin. Activ\\u00e1. Ese ZIP ya lleva vendor\\\/ dentro y pas\\u00f3 los 11 checks del smoke test: no hay que construir nada.\",\"And\\u00e1 a Herramientas \\u2192 MCP Bridge y le\\u00e9 la tabla de estado ANTES de habilitar nada.\",\"Declar\\u00e1 el entorno en wp-config.php. Las abilities de escritura no se registran en producci\\u00f3n, y WordPress reporta production siempre que WP_ENVIRONMENT_TYPE no est\\u00e9 definida \\u2014 as\\u00ed que un staging tiene que decirlo expl\\u00edcitamente.\",\"Cre\\u00e1 el usuario agente con el rol MCP Agent. Nunca conectes como administrador: el sentido del rol es que una credencial filtrada no herede m\\u00e1s que eso.\",\"En el perfil de ese usuario gener\\u00e1 una Application Password con el nombre de la m\\u00e1quina que la va a usar (claude-code-<maquina>). Se muestra una sola vez, guardala en un gestor de credenciales. Es revocable desde la misma pantalla.\",\"Herramientas \\u2192 MCP Bridge \\u2192 habilit\\u00e1 el bridge. La tabla de estado muestra la URL del endpoint.\"],\"installCode\":\"\\\/\\\/ en wp-config.php, para un staging:\\ndefine( 'WP_ENVIRONMENT_TYPE', 'staging' );\\n\\n\\\/\\\/ o, deliberadamente y sitio por sitio, para permitir escrituras en producci\\u00f3n:\\ndefine( 'AMB_ALLOW_PRODUCTION_WRITES', true );\",\"usage\":\"Verific\\u00e1 primero desde la shell:\\n\\ncurl -u \\\"mcp-agent:APPLICATION PASSWORD\\\" \\\\\\n  -H \\\"Content-Type: application\\\/json\\\" \\\\\\n  -d '{\\\"jsonrpc\\\":\\\"2.0\\\",\\\"id\\\":1,\\\"method\\\":\\\"tools\\\/list\\\"}' \\\\\\n  https:\\\/\\\/example.com\\\/wp-json\\\/mcp\\\/bridge\\n\\nY despu\\u00e9s conectalo en .mcp.json (gitignoreado \\u2014 la password es una credencial bearer):\\n\\n{\\n  \\\"mcpServers\\\": {\\n    \\\"site-staging\\\": {\\n      \\\"type\\\": \\\"http\\\",\\n      \\\"url\\\": \\\"https:\\\/\\\/example.com\\\/wp-json\\\/mcp\\\/bridge\\\",\\n      \\\"headers\\\": {\\n        \\\"Authorization\\\": \\\"Basic BASE64_DE_USUARIO_DOSPUNTOS_APPPASSWORD\\\"\\n      }\\n    }\\n  }\\n}\",\"notes\":\"Si quer\\u00e9s construir el ZIP vos mismo: corr\\u00e9 composer install ANTES de tools\\\/build.sh. El script arranca por el smoke test, y ese hace require de vendor\\\/autoload.php \\u2014 en un checkout limpio revienta con un fatal antes de llegar a instalar nada. Es un bug de orden en el script: la instalaci\\u00f3n de dependencias tendr\\u00eda que ir primero. Con vendor\\\/ ya presente, build.sh corre entero y saca dist\\\/agency-mcp-bridge-<version>.zip.\\n\\nOJO con el README: dice PHP 8.1+, pero la cabecera del plugin y composer.json exigen 8.3+. El suelo lo impone lcobucci\\\/clock, dependencia transitiva del servidor OAuth: su l\\u00ednea 2.x se corta en 8.2 y la 3.x arranca en 8.3, as\\u00ed que no hay versi\\u00f3n que abarque las dos. En un host por debajo de 8.3 el plugin carga pero se queda inactivo y lo dice con un notice, en vez de tumbar el sitio desde el autoloader.\\n\\nEl repo ya trae un servidor OAuth2 completo cableado en parse_request (includes\\\/oauth\\\/: authorize, token, register, metadata, router, claves y esquema) que el README todav\\u00eda no documenta. Lo de la Application Password sigue siendo v\\u00e1lido, pero no es lo \\u00fanico que hay.\\n\\nConstantes de wp-config. Cada interruptor que ensancha el bridge vive en wp-config.php, fuera del alcance del agente y de cualquiera que solo tenga wp-admin:\\n\\nWP_ENVIRONMENT_TYPE          por defecto production \\u00b7 local, development o staging habilitan escrituras Tier 2\\nAMB_ALLOW_PRODUCTION_WRITES  sin definir \\u00b7 habilita escrituras Tier 2 en producci\\u00f3n\\nAMB_ALLOW_PHP_EXEC           sin definir \\u00b7 habilita Tier 3. Ignorada en producci\\u00f3n, siempre\\nAMB_ENABLED                  sin definir \\u00b7 en false fuerza el bridge cerrado pese al toggle del admin\\nAMB_ROUTE_SLUG               por defecto bridge \\u00b7 cambia la ruta del endpoint\\nAMB_ALLOWED_IPS              sin definir \\u00b7 allowlist de IPs cliente separadas por coma\\nAMB_RATE_LIMIT               por defecto 120 \\u00b7 peticiones por minuto y usuario\\nAMB_TRUST_FORWARDED_PROTO    por defecto true \\u00b7 ponelo en false si el sitio no est\\u00e1 detr\\u00e1s de un proxy que termine TLS\\nAMB_GRANT_CAP_TO_ADMIN       sin definir \\u00b7 da la capability del bridge a los administradores. Desaconsejado, solo staging.\\n\\nEl razonamiento de cada capa est\\u00e1 en docs\\\/decisions.md del repo.\",\"addedAt\":\"2026-09-01\"},{\"id\":\"woo-jetwoo-quick-add\",\"name\":\"Woo JetWooBuilder Quick Add\",\"tagline\":\"A\\u00f1adir al carrito desde la grid de JetWooBuilder, productos variables incluidos, sin ir a la ficha.\",\"kind\":\"plugin\",\"status\":\"stable\",\"tags\":[\"woocommerce\",\"jetwoobuilder\",\"carrito\",\"variaciones\",\"ajax\"],\"repo\":\"\",\"release\":null,\"version\":\"3.2.0\",\"author\":\"barbatjuan\",\"description\":\"Deja que el cliente a\\u00f1ada productos al carrito directamente desde una grid de JetWooBuilder \\u2014 productos variables incluidos \\u2014 en lugar de mandarlo a la p\\u00e1gina del producto.\\n\\nSin dependencia de pago, pero usa una si el sitio la tiene: la 1.x exig\\u00eda Variation Swatches Pro, la 2.x la reemplaz\\u00f3 por un desplegable nativo, y la 3.0 detecta cu\\u00e1l de las dos puede hacer el sitio y corre esa.\\n\\nPor qu\\u00e9 existe: en un loop de WooCommerce de f\\u00e1brica la mitad de esto no hace falta, porque WooCommerce ya pinta botones AJAX de a\\u00f1adir al carrito. JetWooBuilder reemplaza ese loop por su propio markup y su propio manejo de clicks, y los rompe. Este plugin es el pegamento que devuelve el comportamiento \\u2014 y suma lo \\u00fanico que WooCommerce nunca hizo en un listado: elegir un producto variable sin abrir su ficha.\\n\\nTres funciones, cada una con su propio chequeo de dependencias y cada una un no-op si su stack no est\\u00e1:\\nA. La tarjeta deja de navegar. Con \\\"Clickable item\\\" activado, Jet envuelve la tarjeta y bindea un handler en document que termina en window.location = permalink. Excluye comparar, wishlist y vista r\\u00e1pida \\u2014 pero no el bot\\u00f3n de a\\u00f1adir al carrito. Resultado: el click dispara el AJAX Y navega. El arreglo: los dos listeners est\\u00e1n en ra\\u00edces distintas (WooCommerce en document.body, Jet en document, m\\u00e1s afuera), as\\u00ed que un handler en document.body que llama stopPropagation() corta el evento antes de llegar a document, y el de WooCommerce igual corre.\\nB. Los productos variables se eligen desde la tarjeta. WC_AJAX::add_to_cart() acepta directamente un ID de variaci\\u00f3n y resuelve padre y atributos solo, as\\u00ed que el navegador nunca manda pares de atributos: basta poner el ID correcto en el bot\\u00f3n. Dos selectores seg\\u00fan lo que tenga el sitio: con Variation Swatches Pro, sus swatches de archivo reconectados (WJQA_Variations_Swatches); con cualquier otra cosa, un select nativo de variaciones comprables (WJQA_Variations_Select).\\nC. Mueve el bot\\u00f3n fuera del overlay en las anchuras donde el hover no existe.\",\"requires\":{\"wp\":\"6.0+\",\"php\":\"7.4+\",\"woo\":\"8.0+ (probado hasta 11.0)\",\"other\":\"JetWooBuilder (sin \\u00e9l el plugin no hace nada, a prop\\u00f3sito) \\u00b7 Variation Swatches Pro (opcional, cambia qu\\u00e9 selector te toca) \\u00b7 sin dependencias de Composer\"},\"install\":[\"Baj\\u00e1 woo-jetwoo-quick-add.zip del release v3.2.0 y subilo en Plugins \\u2192 A\\u00f1adir nuevo \\u2192 Subir plugin. Activ\\u00e1. Nada que compilar, no usa Composer.\",\"WooCommerce \\u2192 Ajustes \\u2192 Productos: activ\\u00e1 \\\"Enable AJAX add to cart buttons on archives\\\" (obligatorio, la funci\\u00f3n B lo comprueba) y desactiv\\u00e1 \\\"Redirect to the cart page after successful addition\\\".\",\"Dale al cliente d\\u00f3nde ver el resultado: un Menu Cart de Elementor, un side cart, o cualquier widget que refresque con los cart fragments de WooCommerce. Sin eso, un a\\u00f1adido correcto es invisible.\"],\"installCode\":\"\",\"usage\":\"En el widget de productos de JetWooBuilder, activ\\u00e1 \\\"Clickable item\\\" si quer\\u00e9s que la tarjeta entera sea clicable: la funci\\u00f3n A ya se encarga de que el bot\\u00f3n de a\\u00f1adir no navegue.\\n\\nSelector de variaciones, elegido solo:\\n  el sitio tiene Variation Swatches Pro  \\u2192  swatches de archivo reconectados  (WJQA_Variations_Swatches)\\n  cualquier otro caso                    \\u2192  select nativo de variaciones        (WJQA_Variations_Select)\\n\\nEl desplegable mantiene su apariencia nativa del navegador, as\\u00ed que hereda el aspecto del sitio.\",\"notes\":\"No tiene pantalla de ajustes: el plugin decide qu\\u00e9 ejecutar a partir de lo que hay instalado.\\n\\nEstilos: la hoja del plugin fija layout y nada m\\u00e1s, con una excepci\\u00f3n inevitable \\u2014 el bot\\u00f3n una vez que la funci\\u00f3n C lo movi\\u00f3. En el resto no hay colores, ni bordes, ni tipograf\\u00eda.\\n\\nDale apariencia al bot\\u00f3n movido: los grises por defecto son a prop\\u00f3sito. Un bot\\u00f3n de tarjeta dentro de un overlay suele llevar fondo transparente porque se apoya sobre la foto; movido al fondo propio de la tarjeta, un bot\\u00f3n transparente deja de leerse como bot\\u00f3n y parece texto suelto.\\n\\nLo preferible es ensanchar la regla del propio sitio: si el sitio ya estiliza ese bot\\u00f3n en alg\\u00fan lado, extend\\u00e9 esa regla a las anchuras donde ahora aparece y no toques el plugin. Una regla, una fuente de verdad, nada que mantener sincronizado. Una regla del tema con !important pisa al plugin sin pelear.\",\"addedAt\":\"2026-09-01\"}],\"sites\":[]}<\/script>\n\n<script>\n(function () {\n  \"use strict\";\n\n  \/\/ Snapshot of the served document, taken before anything mutates the DOM.\n  \/\/ Republishing rewrites only the catalog block inside this snapshot.\n  var PRISTINE = \"<!doctype html>\\n\" + document.documentElement.outerHTML;\n  var CATALOG_RE = \/<script\\b[^>]*id=\"catalog\"[^>]*>[\\s\\S]*?<\\\/script>\/;\n\n  var state = JSON.parse(document.getElementById(\"catalog\").textContent);\n  if (!Array.isArray(state.tools)) state.tools = [];\n  if (!Array.isArray(state.sites)) state.sites = [];\n  if (typeof state.distBase !== \"string\") state.distBase = \"\";\n\n  var api = null; \/\/ artifact capability, once resolved\n  var query = \"\";\n  var kindFilter = \"\";\n\n  var view = document.getElementById(\"view\");\n  var layer = document.getElementById(\"layer\");\n  var searchInput = document.getElementById(\"search\");\n  var chipsBox = document.getElementById(\"chips\");\n  var tabTools = document.getElementById(\"tab-tools\");\n  var tabWebs = document.getElementById(\"tab-webs\");\n  var addToolBtn = document.getElementById(\"add-tool\");\n  var addSiteBtn = document.getElementById(\"add-site\");\n  var setBaseBtn = document.getElementById(\"set-base\");\n\n  var KINDS = [\"plugin\", \"plantilla\", \"conector\", \"importador\", \"orquestador\", \"snippet\"];\n  var STATUS_LABEL = { stable: \"Estable\", beta: \"Beta\", wip: \"En obra\" };\n\n  \/* ---------- helpers ---------- *\/\n\n  function esc(s) {\n    return String(s == null ? \"\" : s)\n      .replace(\/&\/g, \"&amp;\").replace(\/<\/g, \"&lt;\").replace(\/>\/g, \"&gt;\")\n      .replace(\/\"\/g, \"&quot;\").replace(\/'\/g, \"&#39;\");\n  }\n\n  function slugify(s) {\n    return String(s || \"\").toLowerCase().normalize(\"NFD\")\n      .replace(\/[\\u0300-\\u036f]\/g, \"\")\n      .replace(\/[^a-z0-9]+\/g, \"-\").replace(\/^-+|-+$\/g, \"\").slice(0, 60);\n  }\n\n  function joinUrl(base, name) {\n    return String(base).replace(\/\\\/+$\/, \"\") + \"\/\" + name;\n  }\n\n  \/\/ A shared folder is one link for everything; a plain folder on a server\n  \/\/ resolves each file by name.\n  function isFolder(base) {\n    return \/drive\\.google\\.com|dropbox\\.com|onedrive|1drv\\.ms\/i.test(base);\n  }\n\n  function byId(list, id) {\n    for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];\n    return null;\n  }\n\n  function toast(msg, kind) {\n    var old = document.querySelector(\".toast\");\n    if (old) old.remove();\n    var t = document.createElement(\"div\");\n    t.className = \"toast\" + (kind ? \" \" + kind : \"\");\n    t.setAttribute(\"role\", \"status\");\n    t.textContent = msg;\n    document.body.appendChild(t);\n    setTimeout(function () { if (t.parentNode) t.remove(); }, 4200);\n  }\n\n  function docWeight() {\n    var bytes = new Blob([PRISTINE, JSON.stringify(state)]).size;\n    if (bytes > 1048576) return (bytes \/ 1048576).toFixed(2) + \" MB\";\n    return Math.round(bytes \/ 1024) + \" KB\";\n  }\n\n  \/* ---------- persistence ---------- *\/\n\n  \/* Portado a WordPress: el original se republicaba a si mismo como Artifact.\n     Aqui el estado entero viaja a una ruta REST del sitio y vive en una opcion,\n     asi que lo que guarda un editor lo ven todos, no solo su navegador. *\/\n  async function persist() {\n    if (!api) {\n      toast(\"Solo los editores pueden guardar cambios.\", \"bad\");\n      return false;\n    }\n    try {\n      var r = await fetch(api.rest, {\n        method: \"POST\",\n        credentials: \"same-origin\",\n        headers: { \"Content-Type\": \"application\/json\", \"X-WP-Nonce\": api.nonce },\n        body: JSON.stringify({ estado: state })\n      });\n      var j = await r.json();\n      if (!r.ok) {\n        toast(\"No se pudo guardar: \" + ((j && j.message) || r.status), \"bad\");\n        return false;\n      }\n      return true;\n    } catch (e) {\n      toast(\"No se pudo guardar: \" + ((e && e.message) || \"error de red\"), \"bad\");\n      return false;\n    }\n  }\n\n  \/* ---------- chrome ---------- *\/\n\n  function paintChrome() {\n    document.getElementById(\"count\").innerHTML =\n      \"<b>\" + state.tools.length + \"<\/b> herramientas \u00b7 <b>\" + state.sites.length + \"<\/b> webs\";\n\n    var names = state.tools.map(function (t) {\n      return \"<span>\" + esc(t.name) + \" <b>\/<\/b><\/span>\";\n    }).join(\"\");\n    if (!names) names = \"<span>Todav\u00eda no hay nada aqu\u00ed <b>\/<\/b><\/span>\";\n    document.getElementById(\"ticker\").innerHTML = names + names;\n\n    var used = {};\n    state.tools.forEach(function (t) { used[t.kind] = true; });\n    var kinds = KINDS.filter(function (k) { return used[k]; });\n    var html = '<button class=\"chip\" data-kind=\"\" aria-pressed=\"' + (kindFilter === \"\" ? \"true\" : \"false\") + '\">Todo<\/button>';\n    kinds.forEach(function (k) {\n      html += '<button class=\"chip\" data-kind=\"' + esc(k) + '\" aria-pressed=\"' + (kindFilter === k ? \"true\" : \"false\") + '\">' + esc(k) + \"<\/button>\";\n    });\n    chipsBox.innerHTML = html;\n  }\n\n  function setTab(which) {\n    tabTools.setAttribute(\"aria-selected\", which === \"tools\" ? \"true\" : \"false\");\n    tabWebs.setAttribute(\"aria-selected\", which === \"webs\" ? \"true\" : \"false\");\n    var browsing = which !== \"detail\";\n    searchInput.style.display = browsing ? \"\" : \"none\";\n    chipsBox.style.display = which === \"tools\" ? \"\" : \"none\";\n  }\n\n  \/* ---------- rendering ---------- *\/\n\n  function matches(t) {\n    if (kindFilter && t.kind !== kindFilter) return false;\n    if (!query) return true;\n    var hay = [t.name, t.tagline, t.description, t.notes, (t.tags || []).join(\" \"), t.kind]\n      .join(\" \").toLowerCase();\n    return hay.indexOf(query) !== -1;\n  }\n\n  function toolCard(t) {\n    return '<button class=\"card\" data-tool=\"' + esc(t.id) + '\">' +\n      '<div class=\"card-top\">' +\n        '<span class=\"kind\">' + esc(t.kind) + \"<\/span>\" +\n        '<span class=\"stamp ' + esc(t.status) + '\">' + esc(STATUS_LABEL[t.status] || t.status) + \"<\/span>\" +\n      \"<\/div>\" +\n      '<h2 class=\"card-title\">' + esc(t.name) + \"<\/h2>\" +\n      '<p class=\"card-tag\">' + esc(t.tagline) + \"<\/p>\" +\n      '<div class=\"tags\">' + (t.tags || []).map(function (x) { return \"<i>\" + esc(x) + \"<\/i>\"; }).join(\"\") + \"<\/div>\" +\n      '<div class=\"card-foot\"><span>v' + esc(t.version || \"\u2014\") + \"<\/span><em>Ver ficha \u2192<\/em><\/div>\" +\n      \"<\/button>\";\n  }\n\n  function renderTools() {\n    setTab(\"tools\");\n    var list = state.tools.filter(matches);\n    if (!list.length) {\n      view.innerHTML = '<div class=\"empty\"><h3>Nada por ac\u00e1<\/h3><p>' +\n        (state.tools.length ? \"Ning\u00fan resultado para ese filtro.\" : \"Todav\u00eda no cargaste ninguna herramienta.\") +\n        \"<\/p><\/div>\";\n      return;\n    }\n    var html = '<div class=\"grid\">' + list.map(toolCard).join(\"\") + \"<\/div>\";\n    view.innerHTML = html;\n  }\n\n  function siteCard(s) {\n    var media = s.thumb\n      ? '<img decoding=\"async\" class=\"thumb\" src=\"' + esc(s.thumb) + '\" alt=\"Captura de ' + esc(s.name) + '\">'\n      : '<div class=\"thumb-fallback\" aria-hidden=\"true\">' + esc((s.name || \"?\").slice(0, 2)) + \"<\/div>\";\n    var chips = (s.toolIds || []).map(function (id) {\n      var t = byId(state.tools, id);\n      return t ? '<button data-goto=\"' + esc(t.id) + '\">' + esc(t.name) + \"<\/button>\" : \"\";\n    }).join(\"\");\n    var admin = api\n      ? '<div class=\"card-admin\"><button class=\"btn ghost sm\" data-edit-site=\"' + esc(s.id) + '\">Editar<\/button>' +\n        '<button class=\"btn danger sm\" data-del-site=\"' + esc(s.id) + '\">Borrar<\/button><\/div>'\n      : \"\";\n    return '<article class=\"site-card\">' + media +\n      '<div class=\"site-body\">' +\n        \"<h3>\" + esc(s.name) + \"<\/h3>\" +\n        (s.sector ? '<p class=\"sector\">' + esc(s.sector) + \"<\/p>\" : \"\") +\n        '<a class=\"url\" href=\"' + esc(s.url) + '\" target=\"_blank\" rel=\"noopener noreferrer\">' + esc(s.url) + \"<\/a>\" +\n        (s.note ? '<p class=\"note\">' + esc(s.note) + \"<\/p>\" : \"\") +\n        (chips ? '<div class=\"uses\">' + chips + \"<\/div>\" : \"\") +\n        admin +\n      \"<\/div><\/article>\";\n  }\n\n  function renderSites() {\n    setTab(\"webs\");\n    \/* El listado de clientes es confidencial: sin sesi\u00f3n el servidor no lo manda\n       siquiera, as\u00ed que aqu\u00ed se ofrece el acceso en vez de una lista vac\u00eda. *\/\n    if (!(window.EB_ARSENAL && window.EB_ARSENAL.puedeEditar)) {\n      view.innerHTML = '<div class=\"empty\"><h3>Contenido reservado<\/h3>' +\n        \"<p>Las webs del equipo y qu\u00e9 herramienta usa cada una son informaci\u00f3n interna.<\/p>\" +\n        '<p style=\"margin-top:14px\"><a class=\"btn\" href=\"' + esc(window.EB_ARSENAL_ACCESO || \"\/acceso-administrador\/\") + '\" rel=\"nofollow\">Acceder<\/a><\/p><\/div>';\n      return;\n    }\n    if (!state.sites.length) {\n      view.innerHTML = '<div class=\"empty\"><h3>Sin webs todav\u00eda<\/h3><p>' +\n        (api ? \"Carg\u00e1 la primera con \u201c+ Web\u201d arriba a la derecha.\" : \"Nadie carg\u00f3 una web de ejemplo todav\u00eda.\") +\n        \"<\/p><\/div>\";\n      return;\n    }\n    view.innerHTML = '<div class=\"grid\">' + state.sites.map(siteCard).join(\"\") + \"<\/div>\";\n  }\n\n  function codeBlock(label, code) {\n    if (!code) return \"\";\n    return '<div class=\"codebox\"><button class=\"btn sm copy\" data-copy=\"' + esc(code) + '\">Copiar<\/button>' +\n      \"<pre><code>\" + esc(code) + \"<\/code><\/pre><\/div>\";\n  }\n\n  function renderDetail(id) {\n    var t = byId(state.tools, id);\n    if (!t) { location.hash = \"#\/\"; return; }\n    setTab(\"detail\");\n\n    var req = t.requires || {};\n    var sites = state.sites.filter(function (s) { return (s.toolIds || []).indexOf(t.id) !== -1; });\n\n    var left =\n      \"<div>\" +\n      '<div class=\"detail-head\">' +\n        '<span class=\"kind\">' + esc(t.kind) + \"<\/span>\" +\n        '<span class=\"stamp ' + esc(t.status) + '\">' + esc(STATUS_LABEL[t.status] || t.status) + \"<\/span>\" +\n      \"<\/div>\" +\n      '<h2 class=\"detail-title\">' + esc(t.name) + \"<\/h2>\" +\n      '<p class=\"detail-lead\">' + esc(t.tagline) + \"<\/p>\" +\n      (t.description ? '<section class=\"block\"><h3>Qu\u00e9 hace<\/h3><p>' + esc(t.description) + \"<\/p><\/section>\" : \"\") +\n      ((t.install && t.install.length) || t.installCode\n        ? '<section class=\"block\"><h3>C\u00f3mo se instala<\/h3>' +\n          (t.install && t.install.length\n            ? '<ol class=\"steps\">' + t.install.map(function (s) { return \"<li>\" + esc(s) + \"<\/li>\"; }).join(\"\") + \"<\/ol>\"\n            : \"\") +\n          codeBlock(\"install\", t.installCode) +\n          \"<\/section>\"\n        : \"\") +\n      (t.usage ? '<section class=\"block\"><h3>C\u00f3mo se usa<\/h3>' + codeBlock(\"usage\", t.usage) + \"<\/section>\" : \"\") +\n      (t.notes ? '<section class=\"block\"><h3>Lo que hay que saber<\/h3><p>' + esc(t.notes) + \"<\/p><\/section>\" : \"\") +\n      \"<\/div>\";\n\n    \/* Puerta de acceso: el repositorio y los archivos son material interno, as\u00ed\n       que a quien no ha iniciado sesi\u00f3n se le ofrece el acceso en lugar de los\n       enlaces \u2014 igual que en los cat\u00e1logos de plugins y documentaci\u00f3n. *\/\n    var puedeVer = !!(window.EB_ARSENAL && window.EB_ARSENAL.puedeEditar);\n    var urlAcceso = window.EB_ARSENAL_ACCESO || \"\/acceso-administrador\/\";\n    var puerta =\n      '<a class=\"btn dl-btn\" href=\"' + esc(urlAcceso) + '\" rel=\"nofollow\">Acceder para ver los enlaces y las descargas<\/a>' +\n      '<p class=\"zip-note\">El repositorio y los archivos son internos del equipo. Inicia sesi\u00f3n y esta ficha te muestra el repo, los ZIP y la carpeta de descargas.<\/p>';\n\n    var right =\n      '<aside class=\"meta\">' +\n      (puedeVer ? \"\" : puerta) +\n      (puedeVer && t.repo ? '<a class=\"btn repo-btn\" href=\"' + esc(t.repo) + '\" target=\"_blank\" rel=\"noopener noreferrer\">Ver repo \u2197<\/a>' : \"\") +\n      (puedeVer && t.release\n        ? '<h4>Instalable \u00b7 ' + esc(t.release.tag) + \"<\/h4>\" +\n          (state.distBase\n            ? (isFolder(state.distBase)\n                ? (t.release.files || []).map(function (f) {\n                    \/* Un fichero con enlace propio (un asset publico de GitHub) va directo; si no, por su id de Drive. *\/\n                    return f.url || f.id\n                      ? '<a class=\"btn dl-btn\" href=\"' + esc(f.url ? f.url : 'https:\/\/drive.google.com\/uc?export=download&id=' + f.id) + '\" target=\"_blank\" rel=\"noopener noreferrer\">' +\n                        \"Descargar \" + esc(f.name) + \" <span>\" + esc(f.size) + \"<\/span><\/a>\"\n                      : '<div class=\"dl-list\"><span><b>' + esc(f.name) + \"<\/b> \" + esc(f.size) + \"<\/span><\/div>\";\n                  }).join(\"\") +\n                  \/* La carpeta de Drive ya no es publica: solo se ofrece si algun fichero aun depende de ella. *\/\n                  ((t.release.files || []).some(function (f) { return !f.url; })\n                    ? '<a class=\"btn ghost dl-btn\" href=\"' + esc(state.distBase) + '\" target=\"_blank\" rel=\"noopener noreferrer\">Ver la carpeta entera \u2197<\/a>'\n                    : \"\") +\n                  '<p class=\"zip-note\">Descarga directa desde GitHub o desde este panel.<\/p>'\n                : (t.release.files || []).map(function (f) {\n                    return '<a class=\"btn dl-btn\" href=\"' + esc(joinUrl(state.distBase, f.name)) + '\" target=\"_blank\" rel=\"noopener noreferrer\">' +\n                      \"Descargar \" + esc(f.name) + \" <span>\" + esc(f.size) + \"<\/span><\/a>\";\n                  }).join(\"\") +\n                  '<p class=\"zip-note\">Descarga directa desde tu servidor. No hace falta cuenta de nada.<\/p>')\n            : '<ul class=\"dl-list\">' + (t.release.files || []).map(function (f) {\n                return \"<li><b>\" + esc(f.name) + \"<\/b> <span>\" + esc(f.size) + \"<\/span><em>\" + esc(f.what) + \"<\/em><\/li>\";\n              }).join(\"\") + \"<\/ul>\" +\n              '<p class=\"url-line\">' + esc(t.release.url) + \"<\/p>\" +\n              '<button class=\"btn ghost repo-btn\" data-copy=\"' + esc(t.release.url) + '\">Copiar link del release<\/button>' +\n              '<p class=\"zip-note\">Todav\u00eda no hay origen de descargas configurado, as\u00ed que esto apunta al release de GitHub \u2014 y ese repo es privado: da 404 a quien no tenga sesi\u00f3n con acceso. Carg\u00e1 el origen desde el bot\u00f3n \u201cOrigen\u201d de arriba y estos botones pasan a bajar el archivo directo.<\/p>')\n        : \"\") +\n      (puedeVer && t.noRelease ? '<p class=\"zip-note\">' + esc(t.noRelease) + \"<\/p>\" : \"\") +\n      \"<h4>Requisitos<\/h4><dl>\" +\n        (req.wp ? \"<dt>WP<\/dt><dd>\" + esc(req.wp) + \"<\/dd>\" : \"\") +\n        (req.php ? \"<dt>PHP<\/dt><dd>\" + esc(req.php) + \"<\/dd>\" : \"\") +\n        (req.woo ? \"<dt>Woo<\/dt><dd>\" + esc(req.woo) + \"<\/dd>\" : \"\") +\n        (req.other ? \"<dt>Otros<\/dt><dd>\" + esc(req.other) + \"<\/dd>\" : \"\") +\n        \"<dt>Versi\u00f3n<\/dt><dd>\" + esc(t.version || \"\u2014\") + \"<\/dd>\" +\n        \"<dt>Autor<\/dt><dd>\" + esc(t.author || \"\u2014\") + \"<\/dd>\" +\n      \"<\/dl>\" +\n      \"<h4>Tags<\/h4><div class=\\\"tags\\\">\" + (t.tags || []).map(function (x) { return \"<i>\" + esc(x) + \"<\/i>\"; }).join(\"\") + \"<\/div>\" +\n      '<h4 style=\"margin-top:16px\">Webs que lo usan<\/h4>' +\n      (!puedeVer\n        \/* Qu\u00e9 cliente usa qu\u00e9 herramienta es informaci\u00f3n confidencial: sin sesi\u00f3n\n           ni se lista ni viaja en el HTML, el servidor la recorta antes. *\/\n        ? '<a class=\"btn ghost dl-btn\" href=\"' + esc(urlAcceso) + '\" rel=\"nofollow\">Acceder para verlas<\/a>'\n        : sites.length\n          ? '<div class=\"sitelist\">' + sites.map(function (s) {\n              return '<a class=\"sitelink\" href=\"' + esc(s.url) + '\" target=\"_blank\" rel=\"noopener noreferrer\">' +\n                esc(s.name) + (s.sector ? \" \u00b7 \" + esc(s.sector) : \"\") + \" \u2197<\/a>\";\n            }).join(\"\") + \"<\/div>\"\n          : '<p style=\"color:var(--dim);font-size:12.5px;margin:0\">Ninguna cargada todav\u00eda.<\/p>') +\n      (api\n        ? '<div class=\"card-admin\" style=\"margin-top:16px\">' +\n          '<button class=\"btn ghost sm\" data-edit-tool=\"' + esc(t.id) + '\">Editar<\/button>' +\n          '<button class=\"btn danger sm\" data-del-tool=\"' + esc(t.id) + '\">Borrar<\/button><\/div>'\n        : \"\") +\n      \"<\/aside>\";\n\n    view.innerHTML = '<button class=\"back\" data-back>\u2190 Volver al listado<\/button><div class=\"spread\">' + left + right + \"<\/div>\";\n    window.scrollTo(0, 0);\n  }\n\n  \/* ---------- router ---------- *\/\n\n  function route() {\n    var h = location.hash || \"#\/\";\n    if (h.indexOf(\"#\/t\/\") === 0) renderDetail(decodeURIComponent(h.slice(4)));\n    else if (h === \"#\/webs\") renderSites();\n    else renderTools();\n  }\n\n  \/* ---------- modal ---------- *\/\n\n  function closeModal() { layer.innerHTML = \"\"; document.body.style.overflow = \"\"; }\n\n  function openModal(title, bodyHtml, onSave, saveLabel) {\n    document.body.style.overflow = \"hidden\";\n    layer.innerHTML =\n      '<div class=\"modal-bg\" role=\"dialog\" aria-modal=\"true\"><div class=\"modal\">' +\n      '<div class=\"modal-head\"><h2>' + esc(title) + '<\/h2><button class=\"btn ghost sm\" data-close>Cerrar<\/button><\/div>' +\n      '<div class=\"modal-body\">' + bodyHtml + \"<\/div>\" +\n      '<div class=\"modal-foot\"><span class=\"weight\">Peso del documento: ' + docWeight() + \" \/ 16 MB<\/span>\" +\n      '<span><button class=\"btn ghost\" data-close>Cancelar<\/button> <button class=\"btn\" data-save>' + esc(saveLabel || \"Guardar y publicar\") + \"<\/button><\/span><\/div>\" +\n      \"<\/div><\/div>\";\n\n    layer.querySelectorAll(\"[data-close]\").forEach(function (b) { b.addEventListener(\"click\", closeModal); });\n    layer.querySelector(\"[data-save]\").addEventListener(\"click\", async function (ev) {\n      var btn = ev.currentTarget;\n      btn.disabled = true;\n      btn.textContent = \"Publicando\u2026\";\n      var ok = await onSave();\n      if (!ok) { btn.disabled = false; btn.textContent = saveLabel || \"Guardar y publicar\"; }\n    });\n    var first = layer.querySelector(\"input,textarea,select\");\n    if (first) first.focus();\n  }\n\n  function field(name, label, value, opts) {\n    opts = opts || {};\n    var input;\n    if (opts.textarea) {\n      input = '<textarea name=\"' + name + '\"' + (opts.rows ? ' rows=\"' + opts.rows + '\"' : \"\") +\n        (opts.placeholder ? ' placeholder=\"' + esc(opts.placeholder) + '\"' : \"\") + \">\" + esc(value || \"\") + \"<\/textarea>\";\n    } else if (opts.options) {\n      input = '<select name=\"' + name + '\">' + opts.options.map(function (o) {\n        return '<option value=\"' + esc(o[0]) + '\"' + (o[0] === value ? \" selected\" : \"\") + \">\" + esc(o[1]) + \"<\/option>\";\n      }).join(\"\") + \"<\/select>\";\n    } else {\n      input = '<input name=\"' + name + '\" value=\"' + esc(value || \"\") + '\"' +\n        (opts.placeholder ? ' placeholder=\"' + esc(opts.placeholder) + '\"' : \"\") + \">\";\n    }\n    return '<label class=\"f\">' + esc(label) + input + (opts.hint ? \"<small>\" + esc(opts.hint) + \"<\/small>\" : \"\") + \"<\/label>\";\n  }\n\n  function val(name) {\n    var n = layer.querySelector('[name=\"' + name + '\"]');\n    return n ? n.value.trim() : \"\";\n  }\n\n  \/* ---------- tool form ---------- *\/\n\n  function toolForm(existing) {\n    var t = existing || {\n      id: \"\", name: \"\", tagline: \"\", kind: \"plugin\", status: \"wip\", tags: [],\n      repo: \"\", version: \"\", author: \"\", description: \"\", requires: {},\n      install: [], installCode: \"\", usage: \"\", notes: \"\"\n    };\n    var req = t.requires || {};\n    \/\/ Webs creadas desde este formulario. Entran a state.sites reci\u00e9n al publicar.\n    var pendingSites = [];\n    var body =\n      '<div class=\"row\">' +\n        field(\"name\", \"Nombre\", t.name) +\n        field(\"id\", \"Slug (URL)\", t.id, { hint: \"Se genera del nombre si lo dej\u00e1s vac\u00edo.\" }) +\n      \"<\/div>\" +\n      field(\"tagline\", \"Una l\u00ednea\", t.tagline, { textarea: true, rows: 2, placeholder: \"Qu\u00e9 hace, en una frase.\" }) +\n      '<div class=\"row three\">' +\n        field(\"kind\", \"Tipo\", t.kind, { options: KINDS.map(function (k) { return [k, k]; }) }) +\n        field(\"status\", \"Estado\", t.status, { options: [[\"stable\", \"Estable\"], [\"beta\", \"Beta\"], [\"wip\", \"En obra\"]] }) +\n        field(\"version\", \"Versi\u00f3n\", t.version) +\n      \"<\/div>\" +\n      '<div class=\"row\">' +\n        field(\"repo\", \"Repo de GitHub\", t.repo) +\n        field(\"author\", \"Autor\", t.author) +\n      \"<\/div>\" +\n      '<div class=\"row\">' +\n        field(\"relTag\", \"Tag del release\", (t.release || {}).tag, { placeholder: \"v1.0.0\" }) +\n        field(\"relUrl\", \"URL del release\", (t.release || {}).url, { hint: \"https:\/\/github.com\/USUARIO\/REPO\/releases\/tag\/vX\" }) +\n      \"<\/div>\" +\n      field(\"relFiles\", \"Archivos del release\", ((t.release || {}).files || []).map(function (f) {\n        return [f.name, f.size, f.what].join(\" | \");\n      }).join(\"\\n\"), { textarea: true, rows: 3, hint: \"Uno por l\u00ednea: nombre.zip | 39 KB | qu\u00e9 es\" }) +\n      field(\"noRelease\", \"Sin release, porque\u2026\", t.noRelease, { textarea: true, rows: 2, hint: \"Dejalo vac\u00edo si s\u00ed tiene release.\" }) +\n      field(\"tags\", \"Tags\", (t.tags || []).join(\", \"), { hint: \"Separados por coma.\" }) +\n      field(\"description\", \"Qu\u00e9 hace\", t.description, { textarea: true, rows: 7 }) +\n      '<div class=\"row\">' +\n        field(\"req_wp\", \"Requiere WP\", req.wp) +\n        field(\"req_php\", \"Requiere PHP\", req.php) +\n      \"<\/div>\" +\n      '<div class=\"row\">' +\n        field(\"req_woo\", \"Requiere Woo\", req.woo) +\n        field(\"req_other\", \"Otras dependencias\", req.other) +\n      \"<\/div>\" +\n      field(\"install\", \"Pasos de instalaci\u00f3n\", (t.install || []).join(\"\\n\"), { textarea: true, rows: 5, hint: \"Un paso por l\u00ednea.\" }) +\n      field(\"installCode\", \"Comandos de instalaci\u00f3n\", t.installCode, { textarea: true, rows: 4, hint: \"Se muestra en un bloque con bot\u00f3n de copiar.\" }) +\n      field(\"usage\", \"C\u00f3mo se usa\", t.usage, { textarea: true, rows: 6, hint: \"Shortcodes, hooks, config. Bloque copiable.\" }) +\n      field(\"notes\", \"Lo que hay que saber\", t.notes, { textarea: true, rows: 5, hint: \"Trampas, gotchas, requisitos raros.\" }) +\n      '<label class=\"f\">Webs que lo usan<div class=\"checks\" id=\"tool-sites\"><\/div>' +\n        '<div class=\"newsite\">' +\n          '<input id=\"ns-url\" placeholder=\"https:\/\/sitio-del-cliente.com\">' +\n          '<input id=\"ns-name\" placeholder=\"Nombre (opcional)\">' +\n          '<button type=\"button\" class=\"btn sm\" id=\"ns-add\">+ A\u00f1adir web<\/button>' +\n        \"<\/div>\" +\n        \"<small>Peg\u00e1 la URL de una web que no est\u00e9 en la lista y se crea ac\u00e1 mismo, ya marcada. Despu\u00e9s pod\u00e9s completarle sector y captura desde la pesta\u00f1a Webs.<\/small>\" +\n      \"<\/label>\";\n\n    openModal(existing ? \"Editar herramienta\" : \"Nueva herramienta\", body, async function () {\n      var name = val(\"name\");\n      if (!name) { toast(\"Ponele un nombre.\", \"bad\"); return false; }\n      var id = slugify(val(\"id\") || name);\n      if (!id) { toast(\"El slug qued\u00f3 vac\u00edo.\", \"bad\"); return false; }\n      var clash = byId(state.tools, id);\n      if (clash && (!existing || clash.id !== existing.id)) { toast(\"Ya existe una herramienta con ese slug.\", \"bad\"); return false; }\n\n      var next = {\n        id: id,\n        name: name,\n        tagline: val(\"tagline\"),\n        kind: val(\"kind\"),\n        status: val(\"status\"),\n        version: val(\"version\"),\n        author: val(\"author\"),\n        repo: val(\"repo\"),\n        release: val(\"relUrl\")\n          ? {\n              tag: val(\"relTag\"),\n              url: val(\"relUrl\"),\n              files: val(\"relFiles\").split(\"\\n\").map(function (line) {\n                var bits = line.split(\"|\").map(function (x) { return x.trim(); });\n                if (!bits[0]) return null;\n                \/* El id de Drive y el enlace propio no estan en el formulario: se conservan\n                   del fichero que ya tenia ese mismo nombre, o editar la ficha los perdia. *\/\n                var antes = ((existing && existing.release && existing.release.files) || []).filter(function (f) { return f.name === bits[0]; })[0] || {};\n                var f = { name: bits[0], size: bits[1] || \"\", what: bits[2] || \"\" };\n                if (antes.id) f.id = antes.id;\n                if (antes.url) f.url = antes.url;\n                return f;\n              }).filter(Boolean)\n            }\n          : null,\n        noRelease: val(\"noRelease\"),\n        tags: val(\"tags\").split(\",\").map(function (s) { return s.trim(); }).filter(Boolean),\n        description: val(\"description\"),\n        requires: { wp: val(\"req_wp\"), php: val(\"req_php\"), woo: val(\"req_woo\"), other: val(\"req_other\") },\n        install: val(\"install\").split(\"\\n\").map(function (s) { return s.trim(); }).filter(Boolean),\n        installCode: val(\"installCode\"),\n        usage: val(\"usage\"),\n        notes: val(\"notes\"),\n        addedAt: (existing && existing.addedAt) || new Date().toISOString().slice(0, 10)\n      };\n\n      var snapshot = JSON.stringify(state);\n      if (existing) {\n        var i = state.tools.indexOf(existing);\n        state.tools[i] = next;\n        if (existing.id !== id) {\n          state.sites.forEach(function (s) {\n            s.toolIds = (s.toolIds || []).map(function (x) { return x === existing.id ? id : x; });\n          });\n        }\n      } else {\n        state.tools.push(next);\n      }\n\n      pendingSites.forEach(function (s) {\n        if (byId(state.sites, s.id)) return;\n        delete s.__new;\n        state.sites.push(s);\n      });\n\n      \/\/ La relaci\u00f3n herramienta-web se edita desde los dos lados: ac\u00e1 se\n      \/\/ sincroniza el toolIds de cada web con lo que qued\u00f3 tildado.\n      var pickedSites = [];\n      layer.querySelectorAll(\"#tool-sites input:checked\").forEach(function (c) { pickedSites.push(c.value); });\n      state.sites.forEach(function (s) {\n        var has = (s.toolIds || []).indexOf(id) !== -1;\n        var want = pickedSites.indexOf(s.id) !== -1;\n        if (want && !has) s.toolIds = (s.toolIds || []).concat([id]);\n        if (!want && has) s.toolIds = s.toolIds.filter(function (x) { return x !== id; });\n      });\n\n      var ok = await persist();\n      if (!ok) { state = JSON.parse(snapshot); return false; }\n      closeModal();\n      toast(\"Publicado.\");\n      paintChrome();\n      location.hash = \"#\/t\/\" + encodeURIComponent(id);\n      route();\n      return true;\n    });\n\n    function renderSiteChecks() {\n      var box = layer.querySelector(\"#tool-sites\");\n      var all = state.sites.concat(pendingSites);\n      var checkedNow = {};\n      box.querySelectorAll(\"input:checked\").forEach(function (c) { checkedNow[c.value] = true; });\n      if (!all.length) {\n        box.innerHTML = \"<small>Todav\u00eda no hay ninguna web cargada. Peg\u00e1 una URL ac\u00e1 abajo para crear la primera.<\/small>\";\n        return;\n      }\n      box.innerHTML = all.map(function (s) {\n        var on = checkedNow[s.id] || s.__new || (s.toolIds || []).indexOf(t.id) !== -1;\n        return '<label><input type=\"checkbox\" value=\"' + esc(s.id) + '\"' + (on ? \" checked\" : \"\") + \"> \" + esc(s.name) + \"<\/label>\";\n      }).join(\"\");\n    }\n\n    renderSiteChecks();\n\n    layer.querySelector(\"#ns-add\").addEventListener(\"click\", function () {\n      var urlBox = layer.querySelector(\"#ns-url\");\n      var nameBox = layer.querySelector(\"#ns-name\");\n      var url = urlBox.value.trim();\n      if (!\/^https?:\\\/\\\/\/i.test(url)) {\n        toast(\"La URL tiene que empezar por http:\/\/ o https:\/\/\", \"bad\");\n        urlBox.focus();\n        return;\n      }\n      var name = nameBox.value.trim();\n      if (!name) {\n        try { name = new URL(url).hostname.replace(\/^www\\.\/, \"\").split(\".\")[0]; } catch (e) { name = url; }\n        name = name.charAt(0).toUpperCase() + name.slice(1);\n      }\n      var sid = slugify(name);\n      if (byId(state.sites, sid) || byId(pendingSites, sid)) sid = sid + \"-\" + Date.now().toString(36).slice(-4);\n      pendingSites.push({ id: sid, name: name, url: url, sector: \"\", toolIds: [], thumb: \"\", note: \"\", __new: true });\n      urlBox.value = \"\";\n      nameBox.value = \"\";\n      renderSiteChecks();\n      toast(\"\u201c\" + name + \"\u201d a\u00f1adida. Se guarda cuando publiques.\");\n    });\n  }\n\n  \/* ---------- site form ---------- *\/\n\n  var pendingThumb = null;\n\n  function siteForm(existing) {\n    var s = existing || { id: \"\", name: \"\", url: \"\", sector: \"\", note: \"\", toolIds: [], thumb: \"\" };\n    pendingThumb = s.thumb || \"\";\n\n    var checks = state.tools.map(function (t) {\n      var on = (s.toolIds || []).indexOf(t.id) !== -1;\n      return '<label><input type=\"checkbox\" value=\"' + esc(t.id) + '\"' + (on ? \" checked\" : \"\") + \"> \" + esc(t.name) + \"<\/label>\";\n    }).join(\"\");\n\n    var body =\n      '<div class=\"row\">' +\n        field(\"name\", \"Nombre de la web\", s.name) +\n        field(\"sector\", \"Sector \/ cliente\", s.sector) +\n      \"<\/div>\" +\n      field(\"url\", \"URL\", s.url, { placeholder: \"https:\/\/\u2026\" }) +\n      field(\"note\", \"Nota\", s.note, { textarea: true, rows: 3, hint: \"Qu\u00e9 se hizo ah\u00ed, qu\u00e9 mirar.\" }) +\n      '<label class=\"f\">Herramientas que usa<div class=\"checks\" id=\"site-tools\">' + (checks || \"<small>Carg\u00e1 herramientas primero.<\/small>\") + \"<\/div><\/label>\" +\n      '<label class=\"f\">Captura<div class=\"picker\">' +\n        '<input type=\"file\" accept=\"image\/*\" id=\"thumb-input\">' +\n        '<img decoding=\"async\" id=\"thumb-preview\"' + (pendingThumb ? ' src=\"' + esc(pendingThumb) + '\"' : ' hidden') + ' alt=\"\">' +\n        '<button type=\"button\" class=\"btn ghost sm\" id=\"thumb-clear\">Quitar<\/button>' +\n      \"<\/div><small>Se comprime a 640 px de ancho y se guarda dentro de la p\u00e1gina. No hay servidor de im\u00e1genes.<\/small><\/label>\";\n\n    openModal(existing ? \"Editar web\" : \"Nueva web\", body, async function () {\n      var name = val(\"name\");\n      var url = val(\"url\");\n      if (!name || !url) { toast(\"Nombre y URL son obligatorios.\", \"bad\"); return false; }\n      if (!\/^https?:\\\/\\\/\/i.test(url)) { toast(\"La URL tiene que empezar por http:\/\/ o https:\/\/\", \"bad\"); return false; }\n\n      var id = slugify(name);\n      var clash = byId(state.sites, id);\n      if (clash && (!existing || clash.id !== existing.id)) id = id + \"-\" + Date.now().toString(36).slice(-4);\n\n      var picked = [];\n      layer.querySelectorAll(\"#site-tools input:checked\").forEach(function (c) { picked.push(c.value); });\n\n      var next = {\n        id: existing ? existing.id : id,\n        name: name,\n        url: url,\n        sector: val(\"sector\"),\n        note: val(\"note\"),\n        toolIds: picked,\n        thumb: pendingThumb || \"\"\n      };\n\n      var snapshot = JSON.stringify(state);\n      if (existing) state.sites[state.sites.indexOf(existing)] = next;\n      else state.sites.push(next);\n\n      var ok = await persist();\n      if (!ok) { state = JSON.parse(snapshot); return false; }\n      closeModal();\n      toast(\"Publicado.\");\n      paintChrome();\n      location.hash = \"#\/webs\";\n      route();\n      return true;\n    });\n\n    var input = layer.querySelector(\"#thumb-input\");\n    var preview = layer.querySelector(\"#thumb-preview\");\n    layer.querySelector(\"#thumb-clear\").addEventListener(\"click\", function () {\n      pendingThumb = \"\";\n      preview.hidden = true;\n      preview.removeAttribute(\"src\");\n      input.value = \"\";\n    });\n    input.addEventListener(\"change\", function () {\n      var file = input.files && input.files[0];\n      if (!file) return;\n      var reader = new FileReader();\n      reader.onload = function () {\n        var img = new Image();\n        img.onload = function () {\n          var w = Math.min(640, img.width);\n          var h = Math.round(img.height * (w \/ img.width));\n          var c = document.createElement(\"canvas\");\n          c.width = w; c.height = h;\n          c.getContext(\"2d\").drawImage(img, 0, 0, w, h);\n          var data = c.toDataURL(\"image\/jpeg\", 0.7);\n          if (data.length > 200000) {\n            data = c.toDataURL(\"image\/jpeg\", 0.5);\n          }\n          if (data.length > 260000) {\n            toast(\"Esa imagen pesa demasiado incluso comprimida. Prob\u00e1 con una m\u00e1s chica.\", \"bad\");\n            return;\n          }\n          pendingThumb = data;\n          preview.src = data;\n          preview.hidden = false;\n        };\n        img.src = reader.result;\n      };\n      reader.readAsDataURL(file);\n    });\n  }\n\n  \/* ---------- delete ---------- *\/\n\n  async function removeTool(id) {\n    var t = byId(state.tools, id);\n    if (!t) return;\n    if (!confirm(\"Borrar \u201c\" + t.name + \"\u201d del arsenal. No se puede deshacer. \u00bfSeguimos?\")) return;\n    var snapshot = JSON.stringify(state);\n    state.tools.splice(state.tools.indexOf(t), 1);\n    state.sites.forEach(function (s) {\n      s.toolIds = (s.toolIds || []).filter(function (x) { return x !== id; });\n    });\n    var ok = await persist();\n    if (!ok) { state = JSON.parse(snapshot); return; }\n    toast(\"Borrado.\");\n    paintChrome();\n    location.hash = \"#\/\";\n    route();\n  }\n\n  async function removeSite(id) {\n    var s = byId(state.sites, id);\n    if (!s) return;\n    if (!confirm(\"Borrar la web \u201c\" + s.name + \"\u201d. No se puede deshacer. \u00bfSeguimos?\")) return;\n    var snapshot = JSON.stringify(state);\n    state.sites.splice(state.sites.indexOf(s), 1);\n    var ok = await persist();\n    if (!ok) { state = JSON.parse(snapshot); return; }\n    toast(\"Borrada.\");\n    paintChrome();\n    route();\n  }\n\n  \/* ---------- events ---------- *\/\n\n  document.addEventListener(\"click\", function (e) {\n    var el = e.target.closest(\"[data-tool],[data-back],[data-copy],[data-goto],[data-edit-tool],[data-del-tool],[data-edit-site],[data-del-site]\");\n    if (!el) return;\n\n    if (el.hasAttribute(\"data-tool\")) { location.hash = \"#\/t\/\" + encodeURIComponent(el.getAttribute(\"data-tool\")); return; }\n    if (el.hasAttribute(\"data-goto\")) { location.hash = \"#\/t\/\" + encodeURIComponent(el.getAttribute(\"data-goto\")); return; }\n    if (el.hasAttribute(\"data-back\")) { location.hash = \"#\/\"; return; }\n    if (el.hasAttribute(\"data-edit-tool\")) { toolForm(byId(state.tools, el.getAttribute(\"data-edit-tool\"))); return; }\n    if (el.hasAttribute(\"data-del-tool\")) { removeTool(el.getAttribute(\"data-del-tool\")); return; }\n    if (el.hasAttribute(\"data-edit-site\")) { siteForm(byId(state.sites, el.getAttribute(\"data-edit-site\"))); return; }\n    if (el.hasAttribute(\"data-del-site\")) { removeSite(el.getAttribute(\"data-del-site\")); return; }\n\n    if (el.hasAttribute(\"data-copy\")) {\n      var text = el.getAttribute(\"data-copy\");\n      navigator.clipboard.writeText(text).then(function () {\n        var prev = el.textContent;\n        el.textContent = \"Copiado\";\n        setTimeout(function () { el.textContent = prev; }, 1400);\n      }, function () { toast(\"El navegador no dej\u00f3 copiar.\", \"bad\"); });\n    }\n  });\n\n  chipsBox.addEventListener(\"click\", function (e) {\n    var c = e.target.closest(\".chip\");\n    if (!c) return;\n    kindFilter = c.getAttribute(\"data-kind\");\n    paintChrome();\n    if (location.hash.indexOf(\"#\/t\/\") === 0) location.hash = \"#\/\";\n    else renderTools();\n  });\n\n  searchInput.addEventListener(\"input\", function () {\n    query = searchInput.value.trim().toLowerCase();\n    if (location.hash === \"#\/webs\") return;\n    if (location.hash.indexOf(\"#\/t\/\") === 0) location.hash = \"#\/\";\n    else renderTools();\n  });\n\n  tabTools.addEventListener(\"click\", function () { location.hash = \"#\/\"; });\n  tabWebs.addEventListener(\"click\", function () { location.hash = \"#\/webs\"; });\n  addToolBtn.addEventListener(\"click\", function () { toolForm(null); });\n  addSiteBtn.addEventListener(\"click\", function () { siteForm(null); });\n\n  setBaseBtn.addEventListener(\"click\", function () {\n    var body = field(\"base\", \"URL de la carpeta de descargas\", state.distBase, {\n      placeholder: \"https:\/\/drive.google.com\/drive\/folders\/\u2026\",\n      hint: \"D\u00f3nde viven los .zip. Si es una carpeta de Drive o Dropbox, cada ficha muestra un bot\u00f3n que abre la carpeta. Si es una carpeta de tu servidor terminada en barra, cada archivo tiene su propio bot\u00f3n de descarga directa. Vac\u00edo deja todo apuntando a los releases de GitHub.\"\n    });\n    openModal(\"Origen de descargas\", body, async function () {\n      var base = val(\"base\");\n      if (base && !\/^https?:\\\/\\\/\/i.test(base)) {\n        toast(\"Tiene que empezar por http:\/\/ o https:\/\/\", \"bad\");\n        return false;\n      }\n      var snapshot = JSON.stringify(state);\n      state.distBase = base;\n      var ok = await persist();\n      if (!ok) { state = JSON.parse(snapshot); return false; }\n      closeModal();\n      toast(base ? \"Origen configurado.\" : \"Origen borrado.\");\n      route();\n      return true;\n    });\n  });\n\n  document.addEventListener(\"keydown\", function (e) {\n    if (e.key === \"Escape\" && layer.innerHTML) closeModal();\n  });\n\n  window.addEventListener(\"hashchange\", route);\n\n  \/* ---------- boot ---------- *\/\n\n  paintChrome();\n  route();\n\n  \/* En WordPress el permiso lo dice el servidor al pintar la pagina: si el\n     visitante no puede publicar, EB_ARSENAL no trae credenciales y los botones\n     de administracion siguen ocultos, igual que en el artefacto original. *\/\n  if (window.EB_ARSENAL && window.EB_ARSENAL.puedeEditar) {\n    api = { rest: window.EB_ARSENAL.rest, nonce: window.EB_ARSENAL.nonce };\n    addToolBtn.hidden = false;\n    addSiteBtn.hidden = false;\n    setBaseBtn.hidden = false;\n    route();\n  }\n})();\n<\/script>\n<\/div>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"","protected":false},"author":3,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"elementor_header_footer","meta":{"footnotes":""},"class_list":["post-8029","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/plugin.manueldelao.com.es\/pt\/wp-json\/wp\/v2\/pages\/8029","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/plugin.manueldelao.com.es\/pt\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/plugin.manueldelao.com.es\/pt\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/plugin.manueldelao.com.es\/pt\/wp-json\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/plugin.manueldelao.com.es\/pt\/wp-json\/wp\/v2\/comments?post=8029"}],"version-history":[{"count":10,"href":"https:\/\/plugin.manueldelao.com.es\/pt\/wp-json\/wp\/v2\/pages\/8029\/revisions"}],"predecessor-version":[{"id":8076,"href":"https:\/\/plugin.manueldelao.com.es\/pt\/wp-json\/wp\/v2\/pages\/8029\/revisions\/8076"}],"wp:attachment":[{"href":"https:\/\/plugin.manueldelao.com.es\/pt\/wp-json\/wp\/v2\/media?parent=8029"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}