argui-prototipo.js

El runtime: switch de modo, vista limpia, controles de rol/fase/escenario y comentario por bloque.

argui-prototipo.js/462 líneas

metodologia/templates/prototype/argui-prototipo.js
/* Argui — andamiaje de prototipo trazable (runtime)
 *
 * El prototipo es un solo archivo HTML con un switch de modo:
 *  - Modo prototipo ENCENDIDO: insignias de trazabilidad por ID de requisito,
 *    notas de aclaración y comentario por bloque.
 *  - Modo prototipo APAGADO: el lienzo se ve como producción; solo queda el marco.
 *  - Vista limpia (?limpio): sin marco ni anotaciones — para demo de venta o capturas.
 *
 * Qué hace:
 *  - Insignias `data-prd` clicables que enlazan al requisito en el PRD por su ID estable.
 *  - Notas de aclaración para el cliente (`data-prd-note`).
 *  - Comentario por bloque, acumulado en localStorage (con respaldo en memoria).
 *  - Export del feedback a Markdown y JSON.
 *  - Envío opcional del feedback por Formspree (sin backend propio).
 *
 * Cómo se cablea (inline, dentro del archivo único del prototipo): el CSS va en un <style> y
 * este archivo en un <script> con la configuración en atributos data-*:
 *
 *   <script data-proyecto="acme"
 *           data-prd-base="prd.html"
 *           data-formspree="https://formspree.io/f/xxxxxxx">
 *     …contenido de este archivo…
 *   <\/script>
 *
 * (La barra invertida evita que este comentario cierre el <script> que lo contiene.)
 *
 * Y en cada bloque de UI:
 *   <section data-prd="RF-12 · RDA automática">…</section>
 *   <p data-prd-note="¿El cliente confirma que el tope es 30 días?">…</p>
 *
 * Controles del prototipo (rol, fase del flujo, escenario) — se declaran en el script:
 *   data-roles="Admin,Operario"  data-fases="Solicitud,Cierre"
 *   data-escenarios="Normal,Vacío,Carga,Error,Sin permiso"
 * El estado seleccionado se refleja en <html data-rol data-fase data-escenario> y los bloques
 * se muestran u ocultan solos con data-cuando-rol / data-cuando-fase / data-cuando-escenario:
 *   <div data-cuando-escenario="vacio">Aún no hay solicitudes</div>
 *   <button data-cuando-rol="admin">Eliminar</button>
 *
 * Vanilla JS, sin dependencias ni build. Portable a cualquier prototipo estático.
 */
(function () {
  "use strict";

  var script = document.currentScript || (function () {
    var s = document.getElementsByTagName("script");
    return s[s.length - 1];
  })();

  var params = location.search;
  var CFG = {
    prdBase: (script && script.dataset.prdBase) || "",
    formspree: (script && script.dataset.formspree) || "",
    proyecto: (script && script.dataset.proyecto) || location.pathname,
    limpio: /[?&]limpio\b/.test(params)
  };
  CFG.storageKey = "argui-feedback:" + CFG.proyecto;
  CFG.modeKey = "argui-modo:" + CFG.proyecto;

  /* ---- controles del prototipo: rol, fase del flujo, escenario ----
   * Los tres son el mismo mecanismo: una dimensión con valores declarados que se
   * refleja en <html data-*> y que muestra u oculta bloques con data-cuando-*.
   * Con el modo apagado siguen funcionando: pertenecen al marco, no a la anotación. */
  var DIMENSIONES = [
    { clave: "rol", etiqueta: "Rol", attr: "roles" },
    { clave: "fase", etiqueta: "Fase", attr: "fases" },
    { clave: "escenario", etiqueta: "Escenario", attr: "escenarios" }
  ];

  function slug(v) {
    return String(v).trim().toLowerCase()
      .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
      .replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
  }

  CFG.dimensiones = DIMENSIONES.map(function (d) {
    var crudo = (script && script.dataset[d.attr]) || "";
    var valores = crudo.split(",").map(function (v) { return v.trim(); })
      .filter(Boolean)
      .map(function (v) { return { etiqueta: v, valor: slug(v) }; });
    return { clave: d.clave, etiqueta: d.etiqueta, valores: valores };
  }).filter(function (d) { return d.valores.length; });

  /* ---- almacenamiento del feedback ----
   * localStorage puede no estar disponible (file:// en algunos navegadores, modo
   * privado). En ese caso se guarda en memoria y el marco avisa que el feedback
   * dura solo la sesión: exportar antes de cerrar. */
  var memoria = {};
  var volatil = false;

  function load() {
    if (volatil) return memoria;
    try {
      return JSON.parse(localStorage.getItem(CFG.storageKey)) || {};
    } catch (e) {
      volatil = true;
      return memoria;
    }
  }
  function save(data) {
    memoria = data;
    if (volatil) return;
    try {
      localStorage.setItem(CFG.storageKey, JSON.stringify(data));
    } catch (e) {
      volatil = true;
      marcarVolatil();
    }
  }
  function totalComments(data) {
    return Object.keys(data).reduce(function (n, k) { return n + data[k].length; }, 0);
  }

  /* ---- trazabilidad por ID estable ---- */
  // "RF-12 · RDA automática" → "RF-12". El ID viaja con el requisito aunque el PRD
  // se parta en dos documentos (F3.4); un número de sección, no.
  var ID_RE = /^([A-Z]{2,5}-\d+)/;

  function refId(ref) {
    var m = ID_RE.exec(String(ref).trim());
    return m ? m[1] : "";
  }

  function prdHref(el, ref) {
    if (el.dataset.prdHref) return el.dataset.prdHref;
    if (!CFG.prdBase) return null;
    var id = refId(ref);
    return CFG.prdBase + (id ? "#" + id.toLowerCase() : "");
  }

  /* ---- marco del prototipo ---- */
  var els = {};

  function buildChrome() {
    var bar = document.createElement("div");
    bar.className = "argui-bar";
    bar.innerHTML =
      "<strong>Argui · prototipo</strong>" +
      '<label class="argui-switch">' +
      '<input type="checkbox" data-act="modo"><span class="arg-track"></span>' +
      "<span>Modo prototipo</span></label>" +
      '<div class="argui-controles" id="argui-controles"></div>' +
      '<span class="arg-count"></span>' +
      '<span class="arg-volatil" hidden>Sin guardado: exporta antes de cerrar</span>' +
      '<span class="arg-spacer"></span>' +
      '<button data-act="export">Exportar feedback</button>' +
      (CFG.formspree ? '<button data-act="send">Enviar por correo</button>' : "") +
      '<button data-act="clear">Limpiar</button>';

    var panel = document.createElement("div");
    panel.className = "argui-panel";
    panel.innerHTML = '<div class="arg-card"></div>';
    panel.addEventListener("click", function (e) {
      if (e.target === panel) closePanel();
    });

    document.body.appendChild(bar);
    document.body.appendChild(panel);
    document.documentElement.classList.add("argui-marco");

    bar.addEventListener("click", function (e) {
      var act = e.target.getAttribute("data-act");
      if (act === "export") openExport();
      else if (act === "send") sendFormspree();
      else if (act === "clear") clearAll();
    });
    bar.querySelector('[data-act="modo"]').addEventListener("change", function (e) {
      setModo(e.target.checked);
    });

    els.bar = bar;
    els.panel = panel;
    els.card = panel.firstChild;
    els.count = bar.querySelector(".arg-count");
    els.volatil = bar.querySelector(".arg-volatil");
    els.switch = bar.querySelector('[data-act="modo"]');
  }

  function marcarVolatil() {
    if (els.volatil) els.volatil.hidden = false;
  }

  /* ---- controles del prototipo ---- */
  function buildControles() {
    var cont = els.bar.querySelector(".argui-controles");
    els.selects = {};
    CFG.dimensiones.forEach(function (dim) {
      var label = document.createElement("label");
      label.textContent = dim.etiqueta + " ";
      var sel = document.createElement("select");
      sel.setAttribute("aria-label", dim.etiqueta);
      dim.valores.forEach(function (v) {
        var opt = document.createElement("option");
        opt.value = v.valor;
        opt.textContent = v.etiqueta;
        sel.appendChild(opt);
      });
      sel.value = document.documentElement.getAttribute("data-" + dim.clave);
      sel.addEventListener("change", function () {
        document.documentElement.setAttribute("data-" + dim.clave, sel.value);
        aplicarVisibilidad();
      });
      label.appendChild(sel);
      cont.appendChild(label);
      els.selects[dim.clave] = sel;
    });
  }

  // Valor inicial de cada dimensión: el de la URL si es válido (para compartir un estado
  // concreto), si no el primero declarado. Se fija también en vista limpia, donde no hay
  // selectores pero los bloques condicionales igual tienen que filtrarse.
  function fijarValoresIniciales() {
    CFG.dimensiones.forEach(function (dim) {
      var forzado = new RegExp("[?&]" + dim.clave + "=([^&]+)").exec(params);
      var pedido = forzado ? decodeURIComponent(forzado[1]) : null;
      var valido = pedido && dim.valores.some(function (v) { return v.valor === pedido; });
      document.documentElement.setAttribute("data-" + dim.clave,
        valido ? pedido : dim.valores[0].valor);
    });
  }

  // Los bloques con data-cuando-<dimensión> solo se ven cuando la dimensión está en uno
  // de sus valores (separados por espacio o coma). Sin el atributo, siempre visibles.
  function aplicarVisibilidad() {
    if (!CFG.dimensiones.length) return;
    CFG.dimensiones.forEach(function (dim) {
      var actual = document.documentElement.getAttribute("data-" + dim.clave);
      var sel = "[data-cuando-" + dim.clave + "]";
      Array.prototype.forEach.call(document.querySelectorAll(sel), function (el) {
        var permitidos = el.getAttribute("data-cuando-" + dim.clave).split(/[\s,]+/).filter(Boolean);
        el.classList.toggle("argui-oculto", permitidos.indexOf(actual) === -1);
      });
    });
  }

  /* ---- anotación del lienzo ---- */
  var decorando = false;

  function decorateBlocks() {
    if (decorando || CFG.limpio) return;
    decorando = true;
    var data = load();

    Array.prototype.forEach.call(document.querySelectorAll("[data-prd]"), function (el) {
      if (el.dataset.argDecorated) return;
      el.dataset.argDecorated = "1";
      var ref = el.dataset.prd;

      var badge = document.createElement("a");
      badge.className = "argui-badge";
      var href = prdHref(el, ref);
      if (href) badge.href = href;
      var count = (data[ref] || []).length;
      badge.innerHTML =
        '<span class="arg-ref-txt">' + escapeHtml(ref) + "</span>" +
        '<span class="arg-comment" role="button">💬 ' + count + "</span>";

      badge.querySelector(".arg-comment").addEventListener("click", function (e) {
        e.preventDefault();
        openComment(ref);
      });

      el.insertBefore(badge, el.firstChild);
    });

    Array.prototype.forEach.call(document.querySelectorAll("[data-prd-note]"), function (el) {
      if (el.dataset.argNoted) return;
      el.dataset.argNoted = "1";
      var note = document.createElement("div");
      note.className = "argui-note";
      note.textContent = el.dataset.prdNote;
      el.insertBefore(note, el.firstChild);
    });

    decorando = false;
    refreshCount();
  }

  function refreshCount() {
    var data = load();
    var n = totalComments(data);
    if (els.count) els.count.textContent = n + (n === 1 ? " comentario" : " comentarios");
    Array.prototype.forEach.call(document.querySelectorAll("[data-prd]"), function (el) {
      var c = el.querySelector(".argui-badge .arg-comment");
      if (c) c.textContent = "💬 " + (data[el.dataset.prd] || []).length;
    });
  }

  /* ---- comentar un bloque ---- */
  function openComment(ref) {
    els.card.innerHTML =
      "<h2>Comentar bloque</h2>" +
      '<p class="arg-ref">' + escapeHtml(ref) + "</p>" +
      '<textarea placeholder="Tu comentario sobre este bloque…"></textarea>' +
      '<div class="arg-actions">' +
      '<button class="arg-ghost" data-x="cancel">Cancelar</button>' +
      '<button class="arg-primary" data-x="save">Guardar</button></div>';
    var ta = els.card.querySelector("textarea");
    els.card.querySelector('[data-x="cancel"]').onclick = closePanel;
    els.card.querySelector('[data-x="save"]').onclick = function () {
      var text = ta.value.trim();
      if (!text) return;
      var data = load();
      (data[ref] = data[ref] || []).push({ text: text, ts: new Date().toISOString() });
      save(data);
      refreshCount();
      closePanel();
    };
    openPanel();
    ta.focus();
  }

  /* ---- export ---- */
  function toMarkdown(data) {
    var lines = ["# Feedback del prototipo", "", "- Proyecto: `" + CFG.proyecto + "`",
      "- Exportado: " + new Date().toISOString(), ""];
    var refs = Object.keys(data);
    if (!refs.length) lines.push("_Sin comentarios._");
    refs.forEach(function (ref) {
      lines.push("## " + ref, "");
      data[ref].forEach(function (c) { lines.push("- " + c.text + "  _(" + c.ts + ")_"); });
      lines.push("");
    });
    return lines.join("\n");
  }

  function openExport() {
    var data = load();
    els.card.innerHTML =
      "<h2>Exportar feedback</h2>" +
      "<p>" + totalComments(data) + " comentario(s). Descarga el archivo que alimenta la " +
      "iteración del PRD.</p>" +
      '<div class="arg-actions">' +
      '<button class="arg-ghost" data-x="json">Descargar JSON</button>' +
      '<button class="arg-primary" data-x="md">Descargar Markdown</button></div>';
    els.card.querySelector('[data-x="md"]').onclick = function () {
      download("feedback-prototipo.md", toMarkdown(data), "text/markdown");
    };
    els.card.querySelector('[data-x="json"]').onclick = function () {
      download("feedback-prototipo.json", JSON.stringify(data, null, 2), "application/json");
    };
    openPanel();
  }

  function download(name, content, type) {
    var blob = new Blob([content], { type: type });
    var a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = name;
    a.click();
    URL.revokeObjectURL(a.href);
  }

  /* ---- envío por Formspree ---- */
  function sendFormspree() {
    if (!CFG.formspree) return;
    var data = load();
    fetch(CFG.formspree, {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify({ proyecto: CFG.proyecto, feedback: data, markdown: toMarkdown(data) })
    }).then(function (r) {
      flash(r.ok ? "Feedback enviado. ¡Gracias!" : "No se pudo enviar. Intenta de nuevo.");
    }).catch(function () { flash("No se pudo enviar. Revisa tu conexión."); });
  }

  function clearAll() {
    if (!confirm("¿Borrar todos los comentarios guardados en este navegador?")) return;
    memoria = {};
    try { localStorage.removeItem(CFG.storageKey); } catch (e) {}
    refreshCount();
  }

  function flash(msg) {
    els.card.innerHTML = "<h2>" + escapeHtml(msg) + "</h2>" +
      '<div class="arg-actions"><button class="arg-primary" data-x="ok">Cerrar</button></div>';
    els.card.querySelector('[data-x="ok"]').onclick = closePanel;
    openPanel();
  }

  function openPanel() { els.panel.classList.add("arg-open"); }
  function closePanel() { els.panel.classList.remove("arg-open"); }

  /* ---- switch del modo prototipo ---- */
  function setModo(on) {
    if (CFG.limpio) return; // en vista limpia no hay nada que encender
    document.documentElement.classList.toggle("argui-proto-on", on);
    if (els.switch) els.switch.checked = on;
    if (on) decorateBlocks();
    try { localStorage.setItem(CFG.modeKey, on ? "1" : "0"); } catch (e) {}
  }

  function modoInicial() {
    var forzado = /[?&]prototipo=([01])/.exec(params);
    if (forzado) return forzado[1] === "1";
    try {
      var guardado = localStorage.getItem(CFG.modeKey);
      if (guardado !== null) return guardado === "1";
    } catch (e) { /* sin storage: cae al default */ }
    return true; // primera visita: encendido — la audiencia de la fase 3 valida
  }

  // El prototipo de archivo único navega mostrando y ocultando pantallas: hay que filtrar y
  // anotar los bloques que aparecen después del arranque.
  function observarPantallas() {
    if (!window.MutationObserver) return;
    var pending = null;
    new MutationObserver(function () {
      if (pending) return;
      pending = setTimeout(function () {
        pending = null;
        aplicarVisibilidad();
        if (document.documentElement.classList.contains("argui-proto-on")) decorateBlocks();
      }, 50);
    }).observe(document.body, { childList: true, subtree: true });
  }

  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, function (c) {
      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
    });
  }

  /* ---- arranque ---- */
  function init() {
    fijarValoresIniciales();
    aplicarVisibilidad();
    observarPantallas();

    // El prototipo puede pilotar el andamiaje por código; la API existe también en vista
    // limpia, donde solo las dimensiones tienen efecto.
    window.ArguiPrototipo = {
      refrescar: function () { aplicarVisibilidad(); decorateBlocks(); },
      modo: setModo,
      dimension: function (clave, valor) {
        document.documentElement.setAttribute("data-" + clave, valor);
        if (els.selects && els.selects[clave]) els.selects[clave].value = valor;
        aplicarVisibilidad();
      }
    };

    // Vista limpia: ni marco ni anotaciones. Las dimensiones sí se aplicaron arriba — sin
    // ellas se verían a la vez todos los bloques condicionales, justo lo contrario de una
    // vista limpia.
    if (CFG.limpio) return;

    buildChrome();
    buildControles();
    load();
    if (volatil) marcarVolatil();
    setModo(modoInicial());
    refreshCount();

    window.ArguiPrototipo.controles = document.getElementById("argui-controles");
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();