argui-prototipo.js
El runtime: switch de modo, vista limpia, controles de rol/fase/escenario y comentario por bloque.
/* 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
}
})();