tawasuyu

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…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ main web/takana-os.net/franja.js JavaScript · 223 lines · 11.4 KB History Who touched what raw

feat(web): la franja es la misma de gioser.net y tawasuyu.net, y el enlace compartido trae tarjeta 7e7921665 · sergio ·

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})();