penguin/narou-android

Simple 縦書き reader for narou

app/src/main/assets/reader/content.js

raw · 31193 bytes

(() => {
  'use strict';
  if (document.getElementById('narou-tategaki-host')) return;
  const sources = [...document.querySelectorAll('.p-novel__text')];
  if (!sources.length) sources.push(...document.querySelectorAll('#novel_p, #novel_honbun, #novel_a'));
  if (!sources.length) return; // Leave table-of-contents and other pages alone.

  const motionDefaults = {acceleration: 900, speed: 120, deadzone: .08, brake: .15, reverse: false};
  let motionSettings = {...motionDefaults};
  const defaults = {font: 20, spacing: 1.9, paragraph: 0.3, theme: 'paper', auto: true};
  let settings = {...defaults}, opened = false, lastFocus;
  let nativeTheme = null;
  let controlsVisible = false, savedPosition = 0, restoring = false, saveTimer;
  const positionKey = `narouPosition:${location.pathname}`;
  const notify = data => browser.runtime.sendNativeMessage('narou', data).catch(() => {});
  function setControls(visible) {
    controlsVisible = visible;
    calibrationRunning = false;
    panel.classList.toggle('chrome-hidden', !visible);
    header.inert = !visible;
    resetMotion();
    footer.inert = !visible;
  }
  function savePosition() {
    if (!opened || restoring) return;
    savedPosition = fraction();
    browser.storage.local.set({[positionKey]: {position: savedPosition, updated: Date.now()}}).catch(() => {});
  }
  const inertBefore = new Map();
  const host = document.createElement('div');
  host.id = 'narou-tategaki-host';
  host.style.setProperty('all', 'initial', 'important');
  host.style.setProperty('position', 'fixed', 'important');
  host.style.setProperty('inset', '0', 'important');
  host.style.setProperty('z-index', '2147483647', 'important');
  host.style.setProperty('pointer-events', 'none', 'important');
  const shadow = host.attachShadow({mode: 'open'});
  const style = document.createElement('style');
  style.textContent = `
    :host { color-scheme: light; }
    * { box-sizing: border-box; }
    [hidden] { display: none !important; }
    button, select, input { font: inherit; }
    button, select { border: 1px solid var(--border); background: var(--bg); color: var(--fg); border-radius: 7px; padding: 7px 10px; }
    button, a, select, input { touch-action: manipulation; }
    button, a { cursor: pointer; }
    button:hover { background: var(--hover); }
    :focus-visible { outline: 3px solid #459a83; outline-offset: 2px; }
    #launch { pointer-events: auto; position: fixed; right: 20px; bottom: 20px; background: #25483f; color: #fff6df; border: 0; font: 16px sans-serif; box-shadow: 0 2px 12px #0004; padding: 12px 18px; }
    #panel { --bg: #f6f0e3; --fg: #2d332f; --border: #cfc8b9; --hover: #e8dfce; pointer-events: auto; position: fixed; inset: 0; height: 100vh; height: 100dvh; display: grid; grid-template-rows: minmax(0, 1fr); background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
    #panel[data-theme="white"] { --bg: #fff; --fg: #222; --border: #ddd; --hover: #eee; }
    #panel[data-theme="dark"] { --bg: #18201e; --fg: #e2e4de; --border: #45504b; --hover: #303b35; color-scheme: dark; }
    header, footer { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 18px; }
    header { max-height: 70dvh; overflow-y: auto; position: absolute; top: 0; left: 0; right: 0; z-index: 2; background: var(--bg); box-shadow: 0 4px 18px #0002; border-bottom: 1px solid var(--border); }
    footer { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; background: var(--bg); box-shadow: 0 -4px 18px #0002; border-top: 1px solid var(--border); }
    #motion-settings { flex-basis: 100%; } #motion-settings label { display: flex; flex-wrap: wrap; margin: 5px 0; } #motion-settings summary { cursor: pointer; padding: 8px 0; }
    #motion-settings input[type=range] { flex: 1; min-width: 100px; }
    #motion-settings output { min-width: 7ch; font-variant-numeric: tabular-nums; }
    #motion-gauge { position: relative; height: 48px; margin: 12px 8px 0; border: 1px solid var(--border); border-radius: 6px; background: var(--hover); overflow: hidden; }
    #motion-band { position: absolute; height: 100%; background: #39866e55; border-left: 2px solid #39866e; border-right: 2px solid #39866e; }
    #motion-anchor { position: absolute; height: 100%; border-left: 1px dashed var(--fg); }
    #motion-marker { position: absolute; top: 0; bottom: 0; width: 4px; background: var(--fg); transform: translateX(-50%); }
    #motion-scale { display: flex; justify-content: space-between; margin: 0 8px; font-variant-numeric: tabular-nums; }
    #motion-boundaries, #motion-status { margin: 6px 0; font-variant-numeric: tabular-nums; }
    header strong { margin-right: auto; }
    label { display: inline-flex; align-items: center; gap: 6px; }
    input[type=range] { width: 85px; accent-color: #39866e; }
    input[type=checkbox] { accent-color: #39866e; }
    #viewport { grid-row: 1; min-height: 0; touch-action: pan-x pinch-zoom; scrollbar-width: none; overflow-x: auto; overflow-y: hidden; direction: rtl; overscroll-behavior: contain; scrollbar-color: #849589 var(--bg); }
    article { writing-mode: vertical-rl; text-orientation: mixed; direction: ltr; height: 100%; width: max-content; min-width: 100%; padding: 30px 40px; font: var(--font, 22px)/var(--spacing, 1.9) "Yu Mincho", "Hiragino Mincho ProN", "Noto Serif CJK JP", "Noto Serif JP", serif; overflow-wrap: break-word; line-break: strict; }
    h1 { font-size: 1.1em; font-weight: 600; line-height: 1.8; margin: 0 0 0 1.8em; }
    section { margin: 0 0 0 2em; }
    section:last-child { margin: 0; }
    h2 { font-size: .7em; font-weight: normal; opacity: .7; margin: 0 0 0 1em; }
    p { margin: 0 0 0 var(--paragraph, .3em); padding: 0; }
    ruby { ruby-position: over; }
    rt { font-size: .5em; }
    img { max-height: 100%; max-width: 70vw; object-fit: contain; }
    article a { color: inherit; text-decoration: underline; }
    nav { display: flex; gap: 12px; }
    nav a { color: var(--fg); text-decoration: none; border-bottom: 1px solid var(--border); }
    #progress { flex: 1; min-width: 80px; direction: rtl; }
    #percent { font-variant-numeric: tabular-nums; min-width: 4ch; }
    .chrome-hidden header, .chrome-hidden footer { visibility: hidden; pointer-events: none; }
    button, select { min-height: 44px; }
    label { min-height: 36px; }
    .hint { opacity: .65; font-size: 12px; }
    @media(max-width: 700px) { header, footer { gap: 7px; padding: 8px; } .hint { display: none; } article { padding: 22px; } #motion-settings { flex-basis: 100%; } #motion-settings label { display: flex; flex-wrap: wrap; margin: 5px 0; } #motion-settings summary { cursor: pointer; padding: 8px 0; }
    header strong { display: none; } }
    @media(prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
  `;
  shadow.append(style);
  const el = (tag, text, attrs = {}) => {
    const node = document.createElement(tag);
    if (text) node.textContent = text;
    for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
    return node;
  };
  const launch = el('button', '縦書き', {id: 'launch', type: 'button', title: '縦書き Reader (Alt+Shift+V)'});
  const panel = el('div', '', {id: 'panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': '縦書き Reader'});
  panel.hidden = true;
  const header = el('header');
  header.append(el('strong', '縦書き Reader'));
  function slider(label, id, min, max, step) {
    const wrap = el('label', label);
    const input = el('input', '', {id, type: 'range', min, max, step});
    const output = el('output');
    wrap.append(input, output); header.append(wrap);
    return [input, output];
  }
  const [font, fontOut] = slider('文字', 'font', 14, 40, 1);
  const [spacing, spaceOut] = slider('行間', 'spacing', 1.4, 3, 0.1);
  const [paragraph, paragraphOut] = slider('段落', 'paragraph', 0, 2, 0.1);
  const themeLabel = el('label', '配色');
  const theme = el('select', '', {'aria-label': '配色'});
  for (const [value, label] of [['paper', '紙'], ['white', '白'], ['dark', '黒']]) theme.append(el('option', label, {value}));
  themeLabel.append(theme); header.append(themeLabel);
  const autoLabel = el('label', '自動');
  const auto = el('input', '', {type: 'checkbox', title: 'Automatically open vertical mode on chapters'});
  autoLabel.prepend(auto); header.append(autoLabel);
  const appMenu = el('button', '☰', {type: 'button', 'aria-label': 'App menu'});
  appMenu.addEventListener('click', () => notify({type: 'menu'}));
  header.prepend(appMenu);
  const close = el('button', '元のページ', {type: 'button'});
  const hideControls = el('button', '読書に戻る', {type: 'button'});
  hideControls.addEventListener('click', () => { setControls(false); viewport.focus({preventScroll: true}); });
  header.append(close, hideControls);
  const motionToggle = el('button', '傾き: OFF', {id: 'motion-toggle', type: 'button', 'aria-pressed': 'false'});
  header.append(motionToggle);
  const motionDetails = el('details', '', {id: 'motion-settings'});
  motionDetails.append(el('summary', '傾きスクロール設定 / Tilt scrolling'));
  const motionStatus = el('p', 'Hold the phone comfortably, enable tilt, then set neutral.', {id: 'motion-status'});
  const motionGauge = el('div', '', {id: 'motion-gauge', role: 'img', 'aria-label': 'Live tilt and stop range'});
  const motionBand = el('span', '', {id: 'motion-band'});
  const motionAnchor = el('span', '', {id: 'motion-anchor'});
  const motionMarker = el('span', '', {id: 'motion-marker'});
  motionMarker.hidden = true;
  motionGauge.append(motionBand, motionAnchor, motionMarker);
  const motionScale = el('div', '', {id: 'motion-scale'});
  for (const text of ['−1 g', '0', '+1 g']) motionScale.append(el('span', text));
  const motionBoundaries = el('p', '', {id: 'motion-boundaries'});
  const previewButton = el('button', '再開 / Resume preview', {id: 'motion-preview', type: 'button', 'aria-pressed': 'false'});
  previewButton.disabled = true;
  let calibrationRunning = false;
  const neutralButton = el('button', 'この傾きを基準に / Set neutral', {type: 'button', id: 'motion-neutral'});
  neutralButton.disabled = true;
  motionDetails.append(motionGauge, motionScale, motionBoundaries, motionStatus, neutralButton, previewButton);
  const offsetLabel = el('label', '停止範囲の中心 / Stop range offset');
  const offsetInput = el('input', '', {id: 'motion-offset', type: 'range', min: -1, max: 1, step: .01, value: 0});
  const offsetOutput = el('output', '0.00 g');
  offsetLabel.append(offsetInput, offsetOutput); motionDetails.append(offsetLabel);
  offsetInput.addEventListener('input', () => { neutral = Number(offsetInput.value); resetMotion(); updateCalibration(); });
  const motionInputs = {};
  for (const [key, label, min, max, step, suffix] of [
    ['acceleration', '加速 / Acceleration', 100, 3000, 100, ' px/s²'],
    ['speed', '最高速度 / Max speed', 20, 600, 10, ' px/s'],
    ['deadzone', '停止範囲 / Neutral zone ±', .02, .4, .01, ' g'],
    ['brake', 'ブレーキ / Braking', .01, 1, .01, '']
  ]) {
    const labelNode = el('label', label);
    const input = el('input', '', {id: `motion-${key}`, type: 'range', min, max, step});
    const output = el('output');
    input.value = motionSettings[key]; output.textContent = input.value + suffix;
    input.addEventListener('input', () => {
      motionSettings[key] = Number(input.value); output.textContent = input.value + suffix;
      resetMotion(); saveMotion();
      updateCalibration();
    });
    motionInputs[key] = {input, output, suffix, min, max};
    labelNode.append(input, output); motionDetails.append(labelNode);
  }
  const reverseLabel = el('label', '方向を逆転 / Reverse direction');
  const reverseMotion = el('input', '', {id: 'motion-reverse', type: 'checkbox'});
  reverseLabel.prepend(reverseMotion); motionDetails.append(reverseLabel);
  reverseMotion.addEventListener('change', () => { motionSettings.reverse = reverseMotion.checked; resetMotion(); saveMotion(); });
  motionDetails.append(el('p', 'Shaded range: braking. Outside the boundaries: accelerate. Resume preview to scroll the text while tuning; Pause stops immediately. The live marker keeps updating while paused.'));
  header.append(motionDetails);
  const viewport = el('div', '', {id: 'viewport', tabindex: '0', role: 'region', 'aria-label': '本文。左矢印またはスペースで読み進めます。'});
  const article = el('article', '', {lang: 'ja'});
  const title = document.querySelector('.p-novel__title, .novel_subtitle');
  article.append(el('h1', title ? title.textContent.trim() : document.title));

  // Copy only passive reading markup. No scripts, page styles, IDs or event handlers.
  const allowed = new Set(['P', 'DIV', 'BR', 'RUBY', 'RB', 'RT', 'RP', 'RTC', 'SPAN', 'EM', 'STRONG', 'B', 'I', 'A', 'IMG', 'SUP', 'SUB']);
  const discard = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'INPUT', 'BUTTON']);
  function safeURL(raw) {
    if (!raw) return null;
    try { const u = new URL(raw, document.baseURI); return ['http:', 'https:'].includes(u.protocol) ? u.href : null; }
    catch (_) { return null; }
  }
  function copy(node) {
    if (node.nodeType === Node.TEXT_NODE) return document.createTextNode(node.textContent);
    if (node.nodeType !== Node.ELEMENT_NODE || discard.has(node.tagName)) return document.createDocumentFragment();
    // Blank spacer paragraphs add entire empty columns in vertical layout.
    if (node.tagName === 'P' && !node.textContent.trim() &&
        [...node.querySelectorAll('*')].every(child => ['BR', 'SPAN', 'WBR'].includes(child.tagName))) {
      return document.createDocumentFragment();
    }
    const clean = allowed.has(node.tagName) ? el(node.tagName.toLowerCase()) : document.createDocumentFragment();
    if (node.tagName === 'A') {
      const url = safeURL(node.getAttribute('href'));
      if (url) clean.setAttribute('href', url);
    }
    if (node.tagName === 'IMG') {
      const url = safeURL(node.getAttribute('src'));
      if (url) clean.setAttribute('src', url);
      clean.setAttribute('alt', node.getAttribute('alt') || '');
    }
    for (const child of node.childNodes) clean.append(copy(child));
    return clean;
  }
  for (const source of sources) {
    const section = el('section');
    if (source.matches('.p-novel__text--preface, #novel_p')) section.append(el('h2', '前書き'));
    if (source.matches('.p-novel__text--afterword, #novel_a')) section.append(el('h2', '後書き'));
    for (const child of source.childNodes) section.append(copy(child));
    article.append(section);
  }
  viewport.append(article);
  const footer = el('footer');
  const nav = el('nav', '', {'aria-label': '章移動'});
  const pager = document.querySelector('.c-pager, .novel_bn');
  if (pager) for (const link of pager.querySelectorAll('a[href]')) {
    const url = safeURL(link.getAttribute('href'));
    if (url) nav.append(el('a', link.textContent.trim(), {href: url}));
  }
  footer.append(nav);
  const back = el('button', '→', {type: 'button', 'aria-label': '前の列'});
  const forward = el('button', '←', {type: 'button', 'aria-label': '次の列'});
  footer.append(back, forward);
  const progress = el('input', '', {id: 'progress', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': '読書位置'});
  const percent = el('span', '0%', {id: 'percent'});
  footer.append(progress, percent, el('span', 'タップで操作を表示 · 右へスワイプで読み進める', {class: 'hint'}));
  panel.append(header, viewport, footer); shadow.append(launch, panel);
  document.body.append(host);
  const lock = el('style');
  lock.textContent = 'html.narou-tategaki-open { overflow: hidden !important; }';
  document.head.append(lock);

  const motion = new NarouMotion();
  let motionEnabled = false, motionSample = null, sampleAt = 0, lastFrame = 0;
  let neutral = null, motionRotation = null, touching = false, motionTimer, motionFrame;
  let motionGeneration = 0, polling = false;
  function resetMotion() { motion.reset(); lastFrame = 0; }
  function updateCalibration(now = performance.now()) {
    const ready = motionEnabled && motionSample?.active && Number.isFinite(motionSample.x) && now - sampleAt <= 350 && neutral !== null;
    const percent = value => `${Math.min(100, Math.max(0, (value + 1) * 50))}%`;
    const center = neutral ?? 0, low = center - motionSettings.deadzone, high = center + motionSettings.deadzone;
    motionBand.style.left = percent(low);
    motionBand.style.width = `${(Math.min(1, high) - Math.max(-1, low)) * 50}%`;
    motionAnchor.style.left = percent(center);
    motionMarker.hidden = !ready;
    if (ready) motionMarker.style.left = percent(motionSample.x);
    offsetInput.value = center; offsetOutput.textContent = `${center.toFixed(2)} g`;
    const left = motionSettings.reverse ? '← Forward' : '→ Back';
    const right = motionSettings.reverse ? '→ Back' : '← Forward';
    motionBoundaries.textContent = `${left}: < ${low.toFixed(2)} g · Brake: ${low.toFixed(2)} to ${high.toFixed(2)} g · ${right}: > ${high.toFixed(2)} g`;
    previewButton.disabled = !ready;
    previewButton.textContent = calibrationRunning ? '一時停止 / Pause preview' : '再開 / Resume preview';
    previewButton.setAttribute('aria-pressed', String(calibrationRunning));
    if (!ready) return;
    const braking = Math.abs(motionSample.x - neutral) <= motionSettings.deadzone;
    const forward = (motionSample.x - neutral) * (motionSettings.reverse ? -1 : 1) > 0;
    const state = controlsVisible && !calibrationRunning ? 'Paused' : touching ? 'Paused — touch' : braking ? 'Braking' : forward ? '← Forward acceleration' : '→ Back acceleration';
    motionStatus.textContent = `Tilt ${motionSample.x.toFixed(2)} g · ${state} · Velocity ${motion.velocity.toFixed(1)} px/s`;
    motionGauge.setAttribute('aria-label', `Tilt ${motionSample.x.toFixed(2)} g; brake range ${low.toFixed(2)} to ${high.toFixed(2)} g; ${state}`);
  }
  function saveMotion() { browser.storage.local.set({narouMotion: {...motionSettings, enabled: motionEnabled}}).catch(() => {}); }
  function updateMotionButton() {
    motionToggle.textContent = motionEnabled ? '傾き: ON' : '傾き: OFF';
    motionToggle.setAttribute('aria-pressed', String(motionEnabled));
  }
  function animateMotion(now) {
    motionFrame = null;
    if (!motionEnabled || !opened || document.hidden) { resetMotion(); return; }
    const paused = (controlsVisible && !(motionDetails.open && calibrationRunning)) || touching || restoring || !motionSample?.active ||
      now - sampleAt > 350 || neutral === null || String(window.getSelection()).trim();
    if (paused) resetMotion();
    else {
      if (lastFrame) {
        const delta = motion.step(motionSample.x, (now - lastFrame) / 1000, {...motionSettings, neutral});
        move(delta);
        if ((delta > 0 && viewport.scrollLeft <= -range()) || (delta < 0 && viewport.scrollLeft >= 0)) motion.reset();
      }
      lastFrame = now;
    }
    updateCalibration(now);
    motionFrame = requestAnimationFrame(animateMotion);
  }
  async function pollMotion() {
    if (polling || !motionEnabled || !opened || document.hidden) return;
    polling = true;
    const generation = motionGeneration;
    try {
      const sample = await browser.runtime.sendNativeMessage('narou', {
        type: 'motion', enabled: true, moving: Math.abs(motion.velocity) > .1
      });
      if (generation !== motionGeneration) return;
      if (!sample?.available) {
        motionEnabled = false; updateMotionButton();
        motionStatus.textContent = 'Accelerometer unavailable on this device.';
        stopMotion(); return;
      }
      motionSample = sample; sampleAt = performance.now();
      neutralButton.disabled = !sample.active;
      if (!sample.active || !Number.isFinite(sample.x)) {
        motionSample = null; resetMotion(); motionStatus.textContent = 'Paused — waiting for sensor.';
      } else {
        if (motionRotation !== sample.rotation) {
          motionRotation = sample.rotation; neutral = sample.x; resetMotion();
        }
        updateCalibration();
      }
	} catch (error) {
		if (generation !== motionGeneration) return;

		motionSample = null;
		resetMotion();
		neutralButton.disabled = true;

		const detail = String(
			error?.message || error || 'Unknown error'
		).slice(0, 240);

		motionStatus.textContent = `Sensor connection unavailable: ${detail}`;
	} finally {
      polling = false;
      if (motionEnabled && opened && !document.hidden) motionTimer = setTimeout(pollMotion, 100);
    }
  }
  function startMotion() {
    if (!motionEnabled || !opened || document.hidden) return;
    if (!motionFrame) motionFrame = requestAnimationFrame(animateMotion);
    clearTimeout(motionTimer); pollMotion();
  }
  function stopMotion() {
    calibrationRunning = false;
    motionGeneration++; clearTimeout(motionTimer); cancelAnimationFrame(motionFrame); motionFrame = null;
    motionSample = null; neutralButton.disabled = true; resetMotion();
    updateCalibration();
    notify({type: 'motion', enabled: false});
  }
  motionToggle.addEventListener('click', () => {
    motionEnabled = !motionEnabled; updateMotionButton(); saveMotion();
    if (motionEnabled) { neutral = null; motionRotation = null; motionDetails.open = true; startMotion(); }
    else { stopMotion(); motionStatus.textContent = 'Tilt scrolling off.'; }
  });
  neutralButton.addEventListener('click', () => {
    if (!motionSample?.active || performance.now() - sampleAt > 350) return;
    neutral = motionSample.x; resetMotion();
    updateCalibration();
  });
  previewButton.addEventListener('click', () => {
    if (previewButton.disabled || !motionDetails.open || !controlsVisible) return;
    calibrationRunning = !calibrationRunning; resetMotion(); updateCalibration();
  });
  motionDetails.addEventListener('toggle', () => {
    if (!motionDetails.open) { calibrationRunning = false; resetMotion(); }
    updateCalibration();
  });
  viewport.addEventListener('pointerdown', () => { touching = true; resetMotion(); }, {passive: true});
  window.addEventListener('pointerup', () => { touching = false; }, {passive: true});
  window.addEventListener('pointercancel', () => { touching = false; }, {passive: true});
  document.addEventListener('visibilitychange', () => {
    touching = false;
    if (document.hidden) stopMotion(); else startMotion();
  });
  window.addEventListener('pagehide', stopMotion);
  window.addEventListener('pageshow', () => {
    if (opened) { notify({type: 'reader', open: true}); notify({type: 'theme', theme: settings.theme}); }
    startMotion();
  });
  // Continuous scrolling must checkpoint periodically, not only after scrolling stops.
  setInterval(() => { if (opened && motionEnabled && Math.abs(motion.velocity) > .1) savePosition(); }, 2000);
  browser.storage.local.get('narouMotion').then(result => {
    const stored = result.narouMotion || {};
    for (const [key, control] of Object.entries(motionInputs)) {
      if (Number.isFinite(stored[key])) motionSettings[key] = Math.min(control.max, Math.max(control.min, stored[key]));
      control.input.value = motionSettings[key]; control.output.textContent = control.input.value + control.suffix;
    }
    motionSettings.reverse = stored.reverse === true; reverseMotion.checked = motionSettings.reverse;
    motionEnabled = stored.enabled === true; updateMotionButton(); startMotion();
    updateCalibration();
  }).catch(() => {});

  const range = () => Math.max(0, viewport.scrollWidth - viewport.clientWidth);
  const fraction = () => range() ? Math.min(1, Math.max(0, -viewport.scrollLeft / range())) : 0;
  function updateProgress() {
    progress.value = String(Math.round(fraction() * 1000));
    percent.textContent = `${Math.round(fraction() * 100)}%`;
  }
  function apply(position = opened ? fraction() : savedPosition) {
    restoring = true;
    panel.dataset.theme = settings.theme;
    if (nativeTheme !== settings.theme) {
      nativeTheme = settings.theme;
      notify({type: 'theme', theme: settings.theme});
    }
    article.style.setProperty('--font', `${settings.font}px`);
    article.style.setProperty('--spacing', settings.spacing);
    article.style.setProperty('--paragraph', `${settings.paragraph}em`);
    font.value = settings.font; spacing.value = settings.spacing; paragraph.value = settings.paragraph;
    fontOut.textContent = `${settings.font}px`; spaceOut.textContent = `${settings.spacing.toFixed(1)}`;
    paragraphOut.textContent = `${settings.paragraph.toFixed(1)}em`;
    theme.value = settings.theme; auto.checked = settings.auto;
    requestAnimationFrame(() => {
      if (opened) viewport.scrollLeft = -position * range();
      savedPosition = position;
      updateProgress();
      requestAnimationFrame(() => { restoring = false; });
    });
  }
  async function save() {
    try { await browser.storage.local.set({narouTategaki: settings}); }
    catch (err) { console.warn('縦書き: settings could not be saved', err); }
  }
  function open() {
    if (opened) return;
    opened = true; lastFocus = document.activeElement;
    for (const child of document.body.children) if (child !== host) { inertBefore.set(child, child.inert); child.inert = true; }
    document.documentElement.classList.add('narou-tategaki-open');
    launch.hidden = true; panel.hidden = false;
    setControls(false); apply(savedPosition); viewport.focus({preventScroll: true});
    notify({type: 'reader', open: true});
    startMotion();
  }
  function shut() {
    if (!opened) return;
    savePosition(); stopMotion(); notify({type: 'reader', open: false});
    opened = false; panel.hidden = true; launch.hidden = false;
    document.documentElement.classList.remove('narou-tategaki-open');
    for (const [node, inert] of inertBefore) node.inert = inert;
    inertBefore.clear();
    if (lastFocus && lastFocus !== document.body && lastFocus.isConnected) lastFocus.focus({preventScroll: true});
    else launch.focus({preventScroll: true});
  }
  const toggle = () => opened ? shut() : open();
  launch.addEventListener('click', open); close.addEventListener('click', shut);
  function move(amount) { viewport.scrollLeft = Math.max(-range(), Math.min(0, viewport.scrollLeft - amount)); }
  const page = () => viewport.clientWidth * .85;
  back.addEventListener('click', () => move(-page()));
  forward.addEventListener('click', () => move(page()));
  viewport.addEventListener('wheel', event => {
    if (event.ctrlKey || event.shiftKey || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
    const scale = event.deltaMode === 1 ? settings.font : event.deltaMode === 2 ? page() : 1;
    move(event.deltaY * scale); event.preventDefault();
  }, {passive: false});
  viewport.addEventListener('scroll', () => {
    updateProgress();
    if (opened && !restoring) {
      savedPosition = fraction();
      clearTimeout(saveTimer);
      saveTimer = setTimeout(savePosition, 250);
    }
  }, {passive: true});
  // Ignore swipes, links and long presses; preserve native selection and touch scrolling.
  let pointerStart = null;
  viewport.addEventListener('pointerdown', event => {
    pointerStart = {x: event.clientX, y: event.clientY, time: performance.now()};
  }, {passive: true});
  viewport.addEventListener('pointercancel', () => { pointerStart = null; });
  viewport.addEventListener('pointerup', event => {
    const start = pointerStart; pointerStart = null;
    if (!start || Math.hypot(event.clientX - start.x, event.clientY - start.y) > 10 ||
        performance.now() - start.time > 450 || event.target.closest('a') ||
        String(window.getSelection()).trim()) return;
    setControls(!controlsVisible);
  }, {passive: true});
  document.addEventListener('visibilitychange', () => { if (document.hidden) savePosition(); });
  window.addEventListener('pagehide', savePosition);
  progress.addEventListener('input', () => { viewport.scrollLeft = -Number(progress.value) / 1000 * range(); updateProgress(); });
  panel.addEventListener('keydown', event => {
    if (event.key === 'Escape') { event.preventDefault(); shut(); return; }
    // Native controls and chapter links keep their normal keyboard behavior.
    if (event.target !== viewport && event.target !== panel) return;
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    const actions = {ArrowLeft: () => move(settings.font * settings.spacing * 3), ArrowRight: () => move(-settings.font * settings.spacing * 3), PageDown: () => move(page()), PageUp: () => move(-page()), ' ': () => move(event.shiftKey ? -page() : page()), Home: () => { viewport.scrollLeft = 0; }, End: () => { viewport.scrollLeft = -range(); }};
    if (actions[event.key]) { event.preventDefault(); actions[event.key](); updateProgress(); }
  });
  font.addEventListener('input', () => { settings.font = Number(font.value); apply(); save(); });
  spacing.addEventListener('input', () => { settings.spacing = Number(spacing.value); apply(); save(); });
  paragraph.addEventListener('input', () => { settings.paragraph = Number(paragraph.value); apply(); save(); });
  theme.addEventListener('change', () => { settings.theme = theme.value; apply(); save(); });
  auto.addEventListener('change', () => { settings.auto = auto.checked; save(); });
  let lastWidth = 0, lastHeight = 0;
  new ResizeObserver(() => {
    if (!opened) return;
    const width = viewport.clientWidth, height = viewport.clientHeight;
    if (width !== lastWidth || height !== lastHeight) {
      lastWidth = width; lastHeight = height;
      apply(savedPosition);
    } else updateProgress();
  }).observe(viewport);
  browser.runtime.onMessage.addListener(message => { if (message.type === 'narou-tategaki-toggle') toggle(); });
  browser.storage.local.get(['narouTategaki', positionKey]).then(result => {
    const position = result[positionKey]?.position;
    savedPosition = Number.isFinite(position) ? Math.min(1, Math.max(0, position)) : 0;
    const stored = result.narouTategaki || {};
    settings.font = Number.isFinite(stored.font) ? Math.min(40, Math.max(14, stored.font)) : defaults.font;
    settings.spacing = Number.isFinite(stored.spacing) ? Math.min(3, Math.max(1.4, stored.spacing)) : defaults.spacing;
    settings.paragraph = Number.isFinite(stored.paragraph) ? Math.min(2, Math.max(0, stored.paragraph)) : defaults.paragraph;
    settings.theme = ['paper', 'white', 'dark'].includes(stored.theme) ? stored.theme : defaults.theme;
    settings.auto = typeof stored.auto === 'boolean' ? stored.auto : defaults.auto;
    if (settings.auto && !opened) open(); else apply(savedPosition);
  }).catch(() => { open(); });
  // Keep a bounded local history; no network requests or reading telemetry.
  browser.storage.local.get(null).then(all => {
    const old = Object.keys(all).filter(key => key.startsWith('narouPosition:'))
      .sort((a, b) => (all[b]?.updated || 0) - (all[a]?.updated || 0)).slice(200);
    if (old.length) return browser.storage.local.remove(old);
  }).catch(() => {});
})();