sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
feat(web): la franja es la misma de gioser.net y tawasuyu.net, y el enlace compartido trae tarjeta
1/* franja.js — la franja de los sitios de gioser: gioser.net, tawasuyu.net y takana-os.net.
2 *
3 * UNA sola fuente. Vive en gioser-web (`franja.js`) y se copia TAL CUAL a tawasuyu
4 * (`web/tawasuyu-web/franja.js`) y a takana (`web/takana-os.net/franja.js`): esos dos sitios no le
5 * piden nada a terceros, así que no la pueden pedir a gioser.net. `scripts/franja-copias.sh`
6 * compara las tres y dice cuál se separó. Antes eran tres copias escritas a mano, y un mismo
7 * defecto —el enlace al inicio que le robaba el clic a la primera pestaña— hubo que arreglarlo
8 * tres veces, una de ellas de otra manera (14-oct).
9 *
10 * Lo que hace, igual en los tres:
11 * · el símbolo sube desde la portada con el primer scroll y se planta en la franja;
12 * · la franja se pinta clara sobre la portada y con el color del texto sobre el cuerpo;
13 * · lo que se pasa por debajo de la franja (la tarjeta de la portada) se apaga antes de tocarla;
14 * · cuando las pestañas llegan arriba se encogen y se acomodan en la franja: el menú son ellas;
15 * · se pasa de una pestaña a otra deslizando, como un PageView de Flutter (el panel sigue al
16 * dedo, entra el vecino desde ese lado, rebota en el borde; lo que se desplaza de costado
17 * manda hasta su borde; el gesto se reclama para que el navegador no lo tome como «volver»);
18 * · elegir una pestaña a mano manda una señal mínima a `/visto?t=<pestaña>` (sin cookies ni
19 * identificadores: el `#` de la dirección nunca llega al servidor, y sin esto no hay forma de
20 * saber qué se lee). Caddy contesta 204 y lo anota en su registro.
21 *
22 * Lo que es de cada sitio entra por las opciones: qué elementos, cómo se elige una pestaña (en
23 * tawasuyu hay que traer el catálogo primero), qué panel está a la vista.
24 */
25(function () {
26 'use strict';
27
28 var suave = function (t) { return t * t * (3 - 2 * t); };
29 var RAMPA = 90; // px de scroll en que las pestañas se vuelven menú
30
31 function Franja(o) {
32 var quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
33 var sc = o.scroller || null; // el que se desplaza; null = la ventana
34 var pos = function () { return sc ? sc.scrollTop : scrollY; };
35 var ir = function (top, suavecito) {
36 var op = { top: top, behavior: (suavecito && !quieto) ? 'smooth' : 'auto' };
37 if (sc) sc.scrollTo(op); else scrollTo(op);
38 };
39 var franja = o.franja, marca = o.marca, grande = o.grande, glifos = o.glifos;
40 var barra = o.barra, tabs = o.tabs, apagar = o.apagar || [];
41 var esc = o.escala || [0.55, 0.92], margen = o.margen || 104;
42 var geo = null, menu = null, p = 0;
43 document.documentElement.classList.add('js');
44
45 function medir() {
46 var s = pos(), alto = franja.offsetHeight;
47 if (marca && grande) {
48 marca.style.transform = 'none';
49 var h = grande.getBoundingClientRect(), b = marca.getBoundingClientRect();
50 geo = { hx: h.left, hy: h.top + s, hw: h.width, bx: b.left, by: b.top, bw: b.width };
51 }
52 barra.style.setProperty('--p', 0);
53 tabs.forEach(function (t) { t.style.transform = 'none'; });
54 var top = alto - barra.offsetHeight; // pegada, asoma sólo su franja de abajo
55 barra.style.setProperty(o.varTop, top + 'px');
56 // un sticky reporta su posición PEGADA: el lugar natural de la barra lo dice el sitio
57 var docTop = s + o.inicioBarra();
58 var br = barra.getBoundingClientRect();
59 var rot = tabs.map(function (t) {
60 var l = t.querySelector(o.rotulo).getBoundingClientRect(), b = t.getBoundingClientRect();
61 return { cx: l.left + l.width / 2, w: l.width, cy: l.top + l.height / 2 - br.top,
62 ox: l.left + l.width / 2 - b.left, oy: l.top + l.height / 2 - b.top };
63 });
64 // dónde caben en la franja: entre el símbolo (con su palabra) y los glifos
65 var izq = (marca ? marca.getBoundingClientRect().right : 0) + (innerWidth < 720 ? 14 : margen);
66 var der = (glifos ? glifos.getBoundingClientRect().left : innerWidth) - 14;
67 var gap = innerWidth < 720 ? 12 : 22;
68 var suma = rot.reduce(function (a, l) { return a + l.w; }, 0);
69 var k = Math.max(esc[0], Math.min(esc[1], (der - izq - gap * (rot.length - 1)) / suma));
70 var total = suma * k + gap * (rot.length - 1);
71 var x0 = innerWidth / 2 - total / 2;
72 if (x0 < izq) x0 = izq;
73 if (x0 + total > der) x0 = Math.max(izq, der - total);
74 var x = x0;
75 rot.forEach(function (l) { l.dx = x + l.w * k / 2 - l.cx; x += l.w * k + gap; });
76 menu = { alto: alto, top: top, pegado: docTop - top, rot: rot, k: k };
77 tabs.forEach(function (t, i) { t.style.transformOrigin = rot[i].ox + 'px ' + rot[i].oy + 'px'; });
78 }
79
80 function volar(s) {
81 if (!geo) return;
82 // el símbolo sube con la portada hasta la altura de la franja, y en ese trayecto se
83 // corre a su lugar y se achica
84 var t = Math.min(1, Math.max(0, s / Math.max(1, geo.hy - geo.by))), e = suave(t);
85 var x = geo.hx + (geo.bx - geo.hx) * e, y = Math.max(geo.by, geo.hy - s);
86 var k = geo.hw / geo.bw + (1 - geo.hw / geo.bw) * e;
87 marca.style.transform = 'translate(' + (x - geo.bx).toFixed(1) + 'px, ' + (y - geo.by).toFixed(1) +
88 'px) scale(' + k.toFixed(3) + ')';
89 franja.classList.toggle('llego', t > 0.98);
90 }
91
92 function encoger(s) {
93 if (!menu) return;
94 var t = Math.min(1, Math.max(0, (s - (menu.pegado - RAMPA)) / RAMPA));
95 p = suave(t);
96 barra.style.setProperty('--p', p.toFixed(3));
97 var falta = Math.max(0, menu.pegado - s);
98 tabs.forEach(function (tab, i) {
99 var l = menu.rot[i], dy = menu.alto / 2 - (menu.top + l.cy + falta), k = 1 - (1 - menu.k) * p;
100 tab.style.transform = p === 0 ? 'none'
101 : 'translate(' + (l.dx * p).toFixed(1) + 'px, ' + (dy * p).toFixed(1) + 'px) scale(' + k.toFixed(3) + ')';
102 });
103 franja.classList.toggle('con-menu', p > 0.5);
104 }
105
106 var tick = false;
107 function pintar() {
108 tick = false;
109 var s = pos(), alto = franja.offsetHeight;
110 volar(s); encoger(s);
111 apagar.forEach(function (el) {
112 var tp = el.getBoundingClientRect().top;
113 el.style.opacity = Math.min(1, Math.max(0, (tp - alto) / (alto * 1.5))).toFixed(3);
114 });
115 if (o.cuerpo) franja.classList.toggle('sobre-portada', o.cuerpo.getBoundingClientRect().top > alto);
116 }
117 (sc || window).addEventListener('scroll', function () {
118 if (!tick) { tick = true; requestAnimationFrame(pintar); }
119 }, { passive: true });
120 addEventListener('resize', function () { medir(); pintar(); }, { passive: true });
121
122 function irALista() { if (menu) ir(Math.round(menu.pegado), true); }
123
124 // ── elegir: lo de cada sitio, más la señal de lectura ──
125 var indice = function () {
126 return tabs.findIndex(function (t) { return t.getAttribute('aria-selected') === 'true'; });
127 };
128 function senal(id) {
129 if (!o.senal || !navigator.sendBeacon) return;
130 try { navigator.sendBeacon('/visto?t=' + encodeURIComponent(id)); } catch (e) { /* sin señal */ }
131 }
132 function elegir(id, como) { // como: {foco, comoMenu, desde: 'der'|'izq'}
133 como = como || {};
134 return Promise.resolve(o.elegir(id)).then(function () {
135 senal(id);
136 if (como.foco) { var t = tabs.find(function (x) { return o.id(x) === id; }); if (t) t.focus(); }
137 if (como.desde) {
138 var nuevo = o.panel();
139 if (nuevo) {
140 nuevo.classList.remove('desde-der', 'desde-izq'); void nuevo.offsetWidth;
141 nuevo.classList.add('desde-' + como.desde);
142 nuevo.addEventListener('animationend', function () { nuevo.classList.remove('desde-der', 'desde-izq'); }, { once: true });
143 }
144 }
145 if (como.comoMenu) irALista();
146 });
147 }
148
149 tabs.forEach(function (t, i) {
150 t.addEventListener('click', function () { elegir(o.id(t), { comoMenu: p > 0.5 }); });
151 t.addEventListener('keydown', function (e) {
152 var n = tabs.length;
153 var j = e.key === 'ArrowRight' ? (i + 1) % n : e.key === 'ArrowLeft' ? (i - 1 + n) % n
154 : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
155 if (j >= 0) { e.preventDefault(); elegir(o.id(tabs[j]), { foco: true }); }
156 });
157 });
158 if (o.inicio) o.inicio.addEventListener('click', function (e) { e.preventDefault(); ir(0, true); });
159
160 // ── deslizar entre pestañas (como un PageView) ──
161 var zona = o.zonaGesto;
162 if (zona) {
163 var vecino = function (dir) { var j = indice() + dir; return (j >= 0 && j < tabs.length) ? o.id(tabs[j]) : null; };
164 var corre = function (el) {
165 for (; el && el !== zona; el = el.parentElement) {
166 var ox = getComputedStyle(el).overflowX;
167 if ((ox === 'auto' || ox === 'scroll') && el.scrollWidth > el.clientWidth + 2) return el;
168 }
169 return null;
170 };
171 var x0 = null, y0 = 0, dx = 0, eje = null, panel = null, rueda = null;
172 zona.addEventListener('touchstart', function (e) {
173 if (e.touches.length !== 1) { x0 = null; return; }
174 rueda = corre(e.target);
175 x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; dx = 0; eje = null; panel = o.panel();
176 }, { passive: true });
177 zona.addEventListener('touchmove', function (e) {
178 if (x0 === null || !panel) return;
179 var mx = e.touches[0].clientX - x0, my = e.touches[0].clientY - y0;
180 if (!eje) {
181 if (Math.abs(mx) < 10 && Math.abs(my) < 10) return;
182 eje = Math.abs(mx) > Math.abs(my) * 1.3 ? 'x' : 'y';
183 // como un PageView anidado: el que se desplaza de costado manda, salvo que esté en su borde
184 if (eje === 'x' && rueda) {
185 var finD = rueda.scrollLeft + rueda.clientWidth >= rueda.scrollWidth - 2, finI = rueda.scrollLeft <= 2;
186 if (!(mx < 0 ? finD : finI)) eje = 'rueda';
187 }
188 }
189 if (eje !== 'x') return;
190 // el gesto horizontal es de la página: sin esto el navegador lo toma como «volver atrás»
191 // (en gioser.net el que se desplaza es un `.layer` fijo, donde overscroll-behavior no alcanza)
192 if (e.cancelable) e.preventDefault();
193 dx = vecino(mx < 0 ? 1 : -1) ? mx : mx * 0.25; // en el borde resiste, como el rebote
194 panel.style.transition = 'none';
195 panel.style.transform = 'translateX(' + (dx * 0.6).toFixed(1) + 'px)';
196 panel.style.opacity = (1 - Math.min(0.45, Math.abs(dx) / innerWidth)).toFixed(3);
197 }, { passive: false });
198 zona.addEventListener('touchend', function () {
199 if (x0 === null || !panel) return;
200 x0 = null;
201 var p0 = panel, d = dx, dir = d < 0 ? 1 : -1, id = vecino(dir);
202 p0.style.transition = 'transform .2s ease, opacity .2s ease';
203 p0.style.transform = ''; p0.style.opacity = '';
204 setTimeout(function () { p0.style.transition = ''; }, 220);
205 if (eje !== 'x' || !id || Math.abs(d) < Math.min(90, innerWidth * 0.22)) return;
206 elegir(id, { desde: dir > 0 ? 'der' : 'izq', comoMenu: p > 0.5 });
207 });
208 }
209
210 medir(); pintar();
211 // las fuentes y la portada llegan tarde y mueven las medidas (la primera vez trastabillaba)
212 if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { medir(); pintar(); });
213 addEventListener('load', function () { medir(); pintar(); });
214
215 return {
216 medir: medir, pintar: pintar, irALista: irALista,
217 p: function () { return p; },
218 elegir: function (id, como) { return elegir(id, como); }
219 };
220 }
221
222 window.Franja = Franja;
223})();