Estante 3D: entradas, orden A–Z, sin conexión, pósit y giroscopio calibrado

- Estante: entradas desde el menú, la librería y la ficha; orden reciente o A–Z; foto del estante, arte de lomos y portadas
  guardados en el dispositivo; sin conexión muestra solo lo guardado.
- Notas de 40 letras sin cortar palabras, lomo curvo, sombra en las portadas de frente y lomos con logo sin el título.
- Pósit cuadrado con el capítulo y la página por la que va el usuario; paso al lector sin pantalla de carga.
- Giroscopio: integra rotationRate (sin los saltos de β/γ con el dispositivo vertical), tope suave, menos sensibilidad al
  caminar, lado en horizontal según la gravedad, suavizado por tiempo y más rango en el estante y el libro.
- Laboratorio del giroscopio en Ajustes (temporal, solo desarrollo) para grabar los sensores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 21:08:51 -06:00
co-authored by Claude Opus 5.5
parent 8b82cbbfdb
commit 8ae3594803
9 changed files with 737 additions and 145 deletions
+146 -41
View File
@@ -41,6 +41,7 @@ const ICONS = {
queue: '<path d="M3 6h13M3 12h13M3 18h9"/><path d="m17 15 3 3-3 3"/><path d="M20 18h-6"/>',
library: '<path d="M4 4v16M9 4v16"/><path d="m14 4.5 4.5 15.2"/><path d="M3 20h18"/>',
bookmark: '<path d="M6 3h12v18l-6-4-6 4z"/>',
shelf: '<path d="M3 3v18M21 3v18M3 12h18M3 21h18"/><path d="M6 12V6M9 12V5M12.5 12l1.6-6"/><path d="M6 21v-5M9 21v-6M15 21v-6M18 21v-4"/>',
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
@@ -388,12 +389,13 @@ async function router() {
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
if (parts[0] === 'library') return await viewLibrary(params)
if (parts[0] === 'shelf') return await viewShelf()
if (parts[0] === 'shelf') return await viewShelf(params)
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
if (parts[0] === 'queues') return await viewQueues()
if (parts[0] === 'stats') return await viewStats()
if (parts[0] === 'clips') return await viewClips(params)
if (parts[0] === 'settings') return viewSettings()
if (parts[0] === 'gyrolab') return await viewGyroLab() // solo desarrollo
return await viewHome(params)
} catch (e) {
if (e.message !== 'No autenticado') app.innerHTML = `<p class="error">${esc(e.message)}</p>`
@@ -722,6 +724,7 @@ async function viewManga(id, langOverride) {
<div id="mscores"></div>
<div class="actions">${mainBtn}
${chapters.length ? `<button class="btn" id="tochap" title="Ir a la lista de capítulos">${icon('queue')} Capítulos</button>` : ''}
${SHELF_OK && !isBook && !/^(manhwa|manhua|webtoon)$/i.test(manga.type || '') ? `<a class="btn" id="toshelf" href="#/shelf?focus=${encodeURIComponent(id)}" title="Ver la obra en el estante 3D">${icon('shelf')} Estante</a>` : ''}
<button class="btn" id="libbtn" title="Más opciones" aria-label="Más opciones"></button>
</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
@@ -3073,6 +3076,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const fromShelf = shelfTrip?.mangaId === mangaId // desde el estante se lee en 3D aunque el ajuste diga otra cosa (sin guardarlo)
const b3dWanted = () => B3D_OK && (!!settings.book3d || fromShelf) && isPaged() && settings.fit !== 'width'
const b3dOn = () => b3dWanted() && !atEnd
if (fromShelf) document.body.dataset.handoff = b3dWanted() ? '3d' : 'flat' // la foto del estante espera al libro 3D, o no (handoffCover)
const b3dPaper = () => {
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255)
@@ -3823,40 +3827,77 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="car
</a>`
// ---------- Estante 3D ----------
const SHELF_OK = 'WebGL2RenderingContext' in window // también con ratón o teclado: a diferencia del lector, no depende de los gestos táctiles
if (!SHELF_OK) document.querySelector('#topbar nav a[href="#/shelf"]')?.remove()
// Foto del último fotograma del estante, encima de todo mientras el lector se monta; se desvanece cuando el libro 3D ya está dibujado.
function handoffCover(src) {
document.querySelector('.handoff')?.remove()
const img = Object.assign(document.createElement('img'), { className: 'handoff', src, alt: '' })
document.body.append(img)
document.body.classList.add('handing')
const t0 = performance.now()
const fade = () => { img.classList.add('out'); setTimeout(() => img.remove(), 450) }
let mounted = 0 // cuándo se supo que el lector se monta sin libro 3D
delete document.body.dataset.handoff // lo dice el lector al montarse: '3d' o 'flat'
const fade = why => { img.dataset.why = why; delete document.body.dataset.handoff; document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) }
const wait = () => {
if (!img.isConnected) return
if (!img.isConnected) return document.body.classList.remove('handing')
const ready = document.querySelector('#b3d:not([hidden]) canvas')
if (ready) return setTimeout(fade, 350) // las texturas de la tapa tardan un instante
if (performance.now() - t0 > 2500 || !location.hash.startsWith('#/read/')) return fade()
if (ready) return setTimeout(() => fade('3d'), 350) // las texturas de la tapa tardan un instante
// Montado sin libro 3D (modo vertical, ajuste a lo ancho): ya no habrá lienzo que esperar. Con él, se espera a que dibuje.
if (!mounted && document.body.dataset.handoff === 'flat') mounted = performance.now()
// El lector no llegó a montarse (un error en su lugar). Mientras siga el estante, la ruta aún no ha cambiado de vista.
const failed = !document.querySelector('.reader, #shelf3d') && performance.now() - t0 > 400
if (failed) return fade('error')
if (mounted && performance.now() - mounted > 600) return fade('flat')
if (performance.now() - t0 > 90000) return fade('tarde') // por si el lector se queda colgado: mientras carga, la foto sigue
if (!location.hash.startsWith('#/read/')) return fade('ruta')
setTimeout(wait, 60)
}
wait()
// Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector.
return (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(() => new Promise(r => requestAnimationFrame(() => { setTimeout(wait, 120); r() }))) // para entonces ya se está en el lector
}
async function viewShelf() {
// Lo último que se supo del estante, en este dispositivo: con ello se monta al momento (sin esperar a cada obra)
// y sin conexión. Tomos y logos se refrescan luego, y solo se rehace lo que cambie.
const shelfSnapKey = () => `shelfSnap:${me?.username || ''}`
const LOGO_DAYS = { found: 30, none: 7 }
const shelfSort = (works, order) => order === 'az'
? [...works].sort((a, b) => String(a.title).replace(/^[^\p{L}\p{N}]+/u, '').localeCompare(String(b.title).replace(/^[^\p{L}\p{N}]+/u, ''), 'es', { sensitivity: 'base', numeric: true }))
: [...works].sort((a, b) => (b.last || 0) - (a.last || 0))
async function viewShelf(params) {
if (!SHELF_OK) {
app.innerHTML = `<h1>Estante 3D</h1><p class="muted">Este navegador no tiene WebGL2.</p><p><a class="btn" href="#/library">${icon('library')} Volver a la librería</a></p>`
return
}
document.body.classList.add('reading') // sin la barra de arriba
app.innerHTML = `<div id="shelf3d"></div><a class="btn small shelfback" href="#/library">${icon('back')} Librería</a><p class="shelfnote" id="shelfnote">Colocando los tomos…</p>
let order = store.get('shelfOrder', 'recent')
const orderLabel = () => (order === 'az' ? `${icon('sort')} A–Z` : `${icon('sort')} Recientes`)
app.innerHTML = `<div id="shelf3d"></div><div class="shelftools"><a class="btn small" href="#/library">${icon('back')} Librería</a><button class="btn small" id="shelforder" type="button" title="Cambiar el orden"></button></div>
<p class="shelfnote" id="shelfnote"></p>
<section class="shelfpeek" id="shelfpeek" hidden aria-live="polite"></section>`
const note = $('#shelfnote'), card = $('#shelfpeek')
const focusOn = shelfTrip?.mangaId || null // al volver del lector, con ese tomo a la vista
const note = $('#shelfnote'), card = $('#shelfpeek'), orderBtn = $('#shelforder')
orderBtn.innerHTML = orderLabel()
const say = (text, ms) => { clearTimeout(note.t); note.textContent = text; note.hidden = !text; if (ms) note.t = setTimeout(() => { note.hidden = true }, ms) }
const focusOn = shelfTrip?.mangaId || params?.get('focus') || null // al volver del lector, o desde la ficha
shelfTrip = null
let shelf = null, gone = false
let shelf = null, gone = false, offline = !serverReachable
const onKey = e => { if (e.key === 'Escape') shelf?.unpeek() }
document.addEventListener('keydown', onKey)
cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); shelf?.destroy(); shelf = null }
cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); clearTimeout(snapTimer); saveSnap(); shelf?.destroy(); shelf = null }
// Foto del estante en el dispositivo.
const snap = store.get(shelfSnapKey(), null)
const logos = snap?.logos || {} // id -> { url, at }
let works = [], snapTimer = 0
const saveSnap = () => { if (works.length && !offline) store.set(shelfSnapKey(), { at: Date.now(), works, logos }) }
const saveSoon = () => { clearTimeout(snapTimer); snapTimer = setTimeout(saveSnap, 1500) }
// Sin conexión, cada tomo se lee por los capítulos guardados en el dispositivo.
const onDevice = id => !offline || offHas(id)
// Vistazo: lo que se sabe del tomo sacado, junto a él.
const readOf = info => {
const { work, vol, state } = info
if (offline) {
if (state === 'reading' && work.chapterId && offHas(work.chapterId) && (!vol.chapters?.length || vol.chapters.includes(work.chapterId))) return readUrl(work.id, work.chapterId, work.page || 0)
const here = (vol.chapters || []).find(offHas)
return here ? readUrl(work.id, here) : null
}
if (state === 'reading' && work.chapterId) return readUrl(work.id, work.chapterId, work.page || 0)
if (vol.chapters?.length) return readUrl(work.id, vol.chapters[0])
return work.chapterId ? readUrl(work.id, work.chapterId, work.page || 0) : `#/manga/${work.id}`
@@ -3866,72 +3907,126 @@ async function viewShelf() {
const toTable = info => {
if (leaving) return
const href = readOf(info)
const go = snap => {
if (!href) return say('Este tomo no está guardado en el dispositivo.', 2500)
const go = async snap => {
if (gone) return
if (href.startsWith('#/read/')) shelfTrip = { mangaId: info.work.id }
if (snap) handoffCover(snap)
if (snap) await handoffCover(snap) // la foto ya está pintada antes de quitar el estante: no se ve nada entre medias
location.hash = href
}
leaving = true
if (!href.startsWith('#/read/') || !shelf?.fly(go)) go(null)
}
// El tomo sacado lleva su nota, como la de la balda, con lo que se sabe de él.
const showPeek = info => {
if (!info) { card.classList.remove('on'); clearTimeout(card.hideTimer); card.hideTimer = setTimeout(() => { if (!card.classList.contains('on')) card.hidden = true }, 250); return }
const { work, vol, state } = info, n = vol.chapters?.length || 0, at = n && work.chapterId ? vol.chapters.indexOf(work.chapterId) : -1
const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : '', vol.pages ? `${vol.pages} páginas` : ''].filter(Boolean).join(' · ')
const pct = state === 'read' ? 100 : at >= 0 ? Math.round((at / n) * 100) : 0
const where = state === 'read' ? 'Leído' : state === 'reading' ? (at >= 0 ? `Vas por el capítulo ${at + 1} de ${n} de este tomo` : 'Lo estás leyendo') : 'Sin empezar'
const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : ''].filter(Boolean).join(' · ')
// Por dónde va, escrito: el capítulo de la obra (si se sabe su número) y la página.
const page = work.page > 0 ? `, página ${work.page + 1}` : ''
const where = state === 'read' ? 'Ya leído'
: state === 'reading' || state === 'unknown' ? (work.chapterNum != null && work.chapterNum !== '' ? `Vas por el capítulo ${esc(work.chapterNum)}${page}` : at >= 0 ? `Vas por el capítulo ${at + 1} de este tomo${page}` : 'Lo estás leyendo')
: 'Sin empezar'
const href = readOf(info), st = shelfMod.noteStyle(work.id)
card.style.cssText = `--np:${st.paper};--ni:${st.ink};--nf:"${st.family}";--nw:${st.weight};--ns:${st.scale};--nt:${(st.tilt * -18).toFixed(2)}deg`
// Un pósit de verdad: sin botones. Se lee tocando el tomo (o esta línea, que lleva al mismo sitio).
card.innerHTML = `<button class="peekx" type="button" aria-label="Devolver al estante">${icon('x')}</button>
<h2 class="ptitle">${esc(work.title)}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
<div class="pbar"><i style="width:${pct}%"></i></div><p class="pwhere">${where}</p>
<div class="pacts"><a class="btn primary" id="peekread" href="${readOf(info)}">${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}</a><a class="btn" href="#/manga/${work.id}">Ver ficha</a></div>`
<h2 class="ptitle">${esc(shelfMod.shortTitle(work.title))}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
<p class="pwhere">${where}</p>
${href ? `<a class="phint" id="peekread" href="${href}">Toca el tomo para ${state === 'reading' ? 'seguir leyendo' : 'leerlo'} →</a>` : '<p class="phint">No está guardado en el dispositivo</p>'}`
card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek())
card.querySelector('#peekread').addEventListener('click', e => { e.preventDefault(); toTable(info) })
card.querySelector('#peekread')?.addEventListener('click', e => { e.preventDefault(); toTable(info) })
clearTimeout(card.hideTimer)
card.hidden = false
requestAnimationFrame(() => card.classList.add('on'))
}
const ver = name => document.querySelector(`meta[name=${name}-version]`)?.content || ''
try {
const [{ works }, lib, mod] = await Promise.all([api('/api/shelf'), import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)])
if (gone) return
if (!works.length) { note.textContent = 'Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.'; return }
shelf = mod.createShelf3D($('#shelf3d'), lib, {
works,
let shelfMod = null
// Lo guardado en el dispositivo, para cuando no hay servidor.
const offlineWorks = () => {
const known = new Map((snap?.works || []).map(w => [w.id, w]))
return offByManga().filter(m => !isBookId(m.mangaId)).map(m => known.get(m.mangaId) || { id: m.mangaId, title: m.title, cover: m.cover, last: m.at, chapterId: null, page: 0, lang: m.chapters[0]?.lang || null })
}
const start = (list, lib) => {
works = list
shelf = shelfMod.createShelf3D($('#shelf3d'), lib, {
works: shelfSort(works, order),
still: matchMedia('(prefers-reduced-motion: reduce)').matches,
onPeek: showPeek,
onOpen: info => toTable(info), // tocar el tomo sacado lo lleva a la mesa
table: me?.tableV ? `/api/table?v=${me.tableV}` : null,
// Un tomo sin portada saca el arte del lomo de sus primeras páginas: la 0 suele ser de créditos.
pagesOf: chapterId => api(`/api/chapter/${chapterId}?saver=0&hd=0`).then(d => (d?.pages || []).slice(1, 6), () => []),
pagesOf: offline ? null : chapterId => api(`/api/chapter/${chapterId}?saver=0&hd=0`).then(d => (d?.pages || []).slice(1, 6), () => []),
})
if (!shelf) throw new Error('sin WebGL2')
if (focusOn) shelf.focus(focusOn)
// Los tomos llegan aparte, de pocos en pocos: la primera vez pueden tardar por las fuentes externas.
let next = 0, pending = works.length
for (const w of works) if (logos[w.id]?.url) shelf.setLogo(w.id, logos[w.id].url)
}
orderBtn.addEventListener('click', () => {
order = order === 'az' ? 'recent' : 'az'
store.set('shelfOrder', order)
orderBtn.innerHTML = orderLabel()
shelf?.unpeek()
shelf?.setWorks(shelfSort(works, order))
shelf?.toTop()
})
try {
const [lib, mod] = await Promise.all([import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)])
shelfMod = mod
if (gone) return
mod.loadNoteFonts()
// Con foto guardada, el mueble sale ya; la lista del servidor llega después.
if (snap?.works?.length && !offline) start(snap.works.filter(w => wantsId(w.id)), lib)
else if (!snap?.works?.length && !offline) say('Colocando los tomos…')
let fresh = null
if (!offline) fresh = await api('/api/shelf').catch(e => { if (e.message === NO_SERVER || !serverReachable) offline = true; else throw e; return null })
if (gone) return
if (offline) {
const list = offlineWorks()
if (shelf) { works = list; shelf.setWorks(shelfSort(works, order)) } else if (list.length) start(list, lib)
return say(list.length ? 'Sin conexión: solo lo guardado en este dispositivo.' : 'Sin conexión, y no hay tomos guardados en este dispositivo.', list.length ? 4000 : 0)
}
// La lista del servidor manda; de la foto se conservan los tomos ya conocidos.
const had = new Map(works.map(w => [w.id, w]))
const next = fresh.works.map(w => (had.get(w.id)?.vols ? { ...w, vols: had.get(w.id).vols } : w))
if (!next.length) { shelf?.destroy(); shelf = null; works = []; store.set(shelfSnapKey(), null); return say('Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.') }
const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.chapterNum === w.chapterNum && o.page === w.page && o.last === w.last })
if (!shelf) start(next, lib)
else if (!sameList) { works = next; shelf.setWorks(shelfSort(works, order)) }
else works = next.map(w => had.get(w.id))
saveSoon()
// Los tomos llegan aparte, de pocos en pocos: primero las obras que aún no tienen, luego se repasan las demás.
const queue = [...works.filter(w => !w.vols), ...works.filter(w => w.vols)]
let pending = queue.filter(w => !w.vols).length
if (!pending) say('')
const worker = async () => {
while (next < works.length && !gone) {
const w = works[next++]
while (queue.length && !gone) {
const w = queue.shift(), first = !w.vols
const r = await api(`/api/volumes/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
if (gone) return
if (r?.volumes?.length) shelf.setVolumes(w.id, r.volumes)
if (--pending === 0) note.hidden = true
const vols = r?.volumes?.length ? r.volumes : null
if (vols && JSON.stringify(vols) !== JSON.stringify(w.vols || null)) { w.vols = vols; shelf.setVolumes(w.id, vols); saveSoon() }
if (first && --pending <= 0) say('')
}
}
for (let k = 0; k < 3; k++) worker()
// Logos para el título de los lomos: la primera vez el servidor los busca en Wikimedia Commons, sin prisa.
let nextLogo = 0
const day = 864e5, now = Date.now()
const logoQueue = works.filter(w => { const l = logos[w.id]; return !l || now - (l.at || 0) > (l.url ? LOGO_DAYS.found : LOGO_DAYS.none) * day })
const logoWorker = async () => {
while (nextLogo < works.length && !gone) {
const w = works[nextLogo++]
const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
while (logoQueue.length && !gone) {
const w = logoQueue.shift()
const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => undefined)
if (gone) return
if (r?.logo) shelf.setLogo(w.id, r.logo)
if (r === undefined) continue // error: se vuelve a preguntar en la próxima visita
logos[w.id] = { url: r.logo || null, at: Date.now() }
if (r.logo) shelf.setLogo(w.id, r.logo)
saveSoon()
}
}
for (let k = 0; k < 2; k++) logoWorker()
} catch (e) {
if (!gone) note.textContent = `No se pudo abrir el estante: ${e.message}`
if (!gone) say(`No se pudo abrir el estante: ${e.message}`)
}
}
@@ -3955,7 +4050,7 @@ async function viewLibrary(params) {
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado
]
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
app.innerHTML = `<div class="row libhead"><h1>Librería</h1>${SHELF_OK ? `<a class="btn small" href="#/shelf" id="toshelf">${icon('library')} Estante 3D</a>` : ''}</div>
app.innerHTML = `<div class="row libhead"><h1>Librería</h1>${SHELF_OK ? `<a class="btn small" href="#/shelf" id="toshelf">${icon('shelf')} Estante 3D</a>` : ''}</div>
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
@@ -4346,6 +4441,13 @@ async function tableJpeg(file) {
return blob
} finally { URL.revokeObjectURL(url) }
}
// Laboratorio del giroscopio: solo para desarrollo, se borrará (con public/gyrolab.js, gyrolab.js del servidor y su botón en Ajustes).
async function viewGyroLab() {
app.innerHTML = '<p class="muted">Cargando el laboratorio…</p>'
const m = await import(`/gyrolab.js?v=${Date.now()}`)
if (!location.hash.startsWith('#/gyrolab')) return
cleanup = m.mountGyroLab(app, { esc, toast })
}
function viewSettings() {
app.innerHTML = `<h1>Ajustes</h1>
<div class="settings">
@@ -4381,6 +4483,9 @@ function viewSettings() {
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
: `<p class="muted" style="margin:0;font-size:.88rem">Instalar la app y leer sin conexión necesita que la página se abra por <b>HTTPS</b> (el navegador no lo permite en <code>http://${esc(location.host)}</code>). Por ejemplo con <code>tailscale serve</code> o un proxy inverso con certificado.</p>`}
</section>
<section><b>${icon('phone')} Desarrollo: laboratorio del giroscopio</b>
<p class="muted" style="margin:0;font-size:.88rem">Graba los sensores de movimiento de este dispositivo para calibrar el giroscopio del estante y del libro 3D. Es temporal: se quitará al terminar.</p>
<div class="row"><a class="btn small" href="#/gyrolab">${icon('phone')} Iniciar el laboratorio</a></div></section>
<section id="secsec"><b>${icon('key')} Seguridad: IPs bloqueadas</b><div id="bans" class="muted" style="font-size:.88rem">Cargando…</div></section>
<section><b>${icon('bell')} Avisos de capítulos nuevos</b>
<p class="muted" style="margin:0;font-size:.88rem">El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos).