commit 1e21ffcb25194f5bca4a4f95ec5967d088e8b101
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-14T04:47:34Z |
| subject | Added focused reading |
commit 1e21ffcb25194f5bca4a4f95ec5967d088e8b101
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-14T04:47:34Z
Added focused reading
---
README.md | 25 +++++++-
app/build.gradle | 4 +-
app/src/main/assets/reader/content.js | 56 +++++++++++++++-
app/src/main/assets/reader/manifest.json | 2 +-
.../net/astropenguin/naroureader/MainActivity.java | 75 +++++++++++++++++++++-
.../naroureader/ReaderApplication.java | 6 ++
tests/reader.test.cjs | 39 +++++++++--
7 files changed, 192 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 57ca324..69e5788 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,8 @@ The first launch opens the chapter supplied in the original request:
https://ncode.syosetu.com/n0552ep/75/
- Swipe right to advance through the columns; swipe left to go back.
-- Tap the text to show or hide controls. Long-press still selects text.
+- Tap a paragraph to focus its text color. Tap the focused paragraph again or
+ tap empty space to show or hide controls. Long-press still selects text.
- **文字**: font size; **行間**: column spacing; **段落**: paragraph spacing.
- **配色**: paper, white or dark theme.
- **自動**: open the reader automatically on chapter pages.
@@ -175,10 +176,10 @@ The neutral zone is symmetric around the calibrated position, correcting the old
offset-dependent range. The old auto-anchor option is replaced by the reader's
existing saved position, checkpointed every two seconds while motion scrolling.
-1. Tap text to show controls. Tap **傾き: OFF** to enable it.
+1. Tap empty space (or the focused paragraph) to show controls. Tap **傾き: OFF** to enable it.
2. Hold the phone at your comfortable reading angle; tap **Set neutral**.
3. Tap **読書に戻る**. Tilt sideways to accelerate, tilt the other way to reverse,
- or return to neutral to brake. Tap the text to stop immediately.
+ or return to neutral to brake. Opening controls stops scrolling immediately.
4. Expand **Tilt scrolling** to adjust acceleration, maximum speed, neutral-zone
width, braking strength, or reverse direction. Controls initially pause scrolling.
@@ -213,3 +214,21 @@ Run `node tests/motion.test.cjs` for physics checks and `node tests/reader.test.
for Firefox integration checks with a simulated native accelerometer. The latter
covers movement, pausing, reversing, inactive samples, rotation and settings.
Actual sensor feel and device lifecycle still require a phone check.
+
+## Version 1.2.0 — paragraph focus
+
+Tap any paragraph, including its ruby text, to apply the focused text color.
+Only one paragraph is focused at a time. Enter or Space on a keyboard-focused
+paragraph also selects it. Links, swipes and long-press text selection keep
+their existing behavior. Tap the focused paragraph again or tap empty space
+to toggle controls.
+
+While accelerometer scrolling moves the text outside the braking range, focus
+tracks the visible paragraph nearest the screen center, in either direction
+and during calibration preview. Inside the braking range, the current focus
+stays unchanged, even while residual velocity brakes. Manual scrolling does
+not automatically change focus.
+
+**Normal text** and **Focus text** color controls save your choices locally.
+Custom colors apply across themes; **Reset text colors** restores the current
+theme's defaults. Focus changes only text color and does not shift the layout.
diff --git a/app/build.gradle b/app/build.gradle
index b622e2c..895dd6b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -8,8 +8,8 @@ android {
applicationId 'net.astropenguin.naroureader'
minSdk 26
targetSdk 35
- versionCode 4
- versionName '1.1.1'
+ versionCode 5
+ versionName '1.2.0'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
diff --git a/app/src/main/assets/reader/content.js b/app/src/main/assets/reader/content.js
index 176645a..4049284 100644
--- a/app/src/main/assets/reader/content.js
+++ b/app/src/main/assets/reader/content.js
@@ -7,7 +7,8 @@
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};
+ const defaults = {font: 20, spacing: 1.9, paragraph: 0.3, theme: 'paper', auto: true, normalColor: null, highlightColor: null};
+ const textColors = {paper: ['#2d332f', '#17694f'], white: ['#222222', '#17694f'], dark: ['#e2e4de', '#79dfb5']};
let settings = {...defaults}, opened = false, lastFocus;
let nativeTheme = null;
let controlsVisible = false, savedPosition = 0, restoring = false, saveTimer;
@@ -73,6 +74,8 @@
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; }
+ article { color: var(--normal-text, var(--fg)); }
+ article p.reading-focus { color: var(--highlight-text); }
ruby { ruby-position: over; }
rt { font-size: .5em; }
img { max-height: 100%; max-width: 70vw; object-fit: contain; }
@@ -118,6 +121,16 @@
const autoLabel = el('label', '自動');
const auto = el('input', '', {type: 'checkbox', title: 'Automatically open vertical mode on chapters'});
autoLabel.prepend(auto); header.append(autoLabel);
+ const colorInputs = {};
+ for (const [key, label] of [['normalColor', '通常の文字 / Normal text'], ['highlightColor', '注目の文字 / Focus text']]) {
+ const wrap = el('label', label);
+ const input = el('input', '', {type: 'color', id: `reading-${key}`});
+ colorInputs[key] = input; wrap.append(input); header.append(wrap);
+ input.addEventListener('input', () => { settings[key] = input.value; applyTextColors(); save(); });
+ }
+ const resetColors = el('button', '配色の文字色に戻す / Reset text colors', {type: 'button', id: 'reading-reset-colors'});
+ resetColors.addEventListener('click', () => { settings.normalColor = settings.highlightColor = null; applyTextColors(); save(); });
+ header.append(resetColors);
const appMenu = el('button', '☰', {type: 'button', 'aria-label': 'App menu'});
appMenu.addEventListener('click', () => notify({type: 'menu'}));
header.prepend(appMenu);
@@ -217,6 +230,34 @@
article.append(section);
}
viewport.append(article);
+ const paragraphs = [...article.querySelectorAll('p')];
+ let focusedParagraph = null, lastAutoFocus = 0;
+ for (const paragraph of paragraphs) paragraph.tabIndex = 0;
+ function focusParagraph(paragraph) {
+ if (!paragraph || paragraph === focusedParagraph) return;
+ focusedParagraph?.classList.remove('reading-focus');
+ focusedParagraph?.removeAttribute('aria-current');
+ focusedParagraph = paragraph;
+ paragraph.classList.add('reading-focus'); paragraph.setAttribute('aria-current', 'true');
+ }
+ function autoFocusParagraph(now) {
+ if (now - lastAutoFocus < 100) return;
+ lastAutoFocus = now;
+ const view = viewport.getBoundingClientRect(), center = (view.left + view.right) / 2;
+ let nearest = null, distance = Infinity;
+ for (const paragraph of paragraphs) {
+ const rect = paragraph.getBoundingClientRect();
+ if (rect.right <= view.left || rect.left >= view.right || !rect.width || !rect.height) continue;
+ const d = Math.max(rect.left - center, center - rect.right, 0);
+ if (d < distance) { nearest = paragraph; distance = d; }
+ }
+ focusParagraph(nearest);
+ }
+ article.addEventListener('keydown', event => {
+ if (event.target.matches('p') && (event.key === 'Enter' || event.key === ' ')) {
+ event.preventDefault(); event.stopPropagation(); focusParagraph(event.target);
+ }
+ });
const footer = el('footer');
const nav = el('nav', '', {'aria-label': '章移動'});
const pager = document.querySelector('.c-pager, .novel_bn');
@@ -279,7 +320,9 @@
else {
if (lastFrame) {
const delta = motion.step(motionSample.x, (now - lastFrame) / 1000, {...motionSettings, neutral});
+ const before = viewport.scrollLeft;
move(delta);
+ if (viewport.scrollLeft !== before && Math.abs(motionSample.x - neutral) > motionSettings.deadzone) autoFocusParagraph(now);
if ((delta > 0 && viewport.scrollLeft <= -range()) || (delta < 0 && viewport.scrollLeft >= 0)) motion.reset();
}
lastFrame = now;
@@ -392,6 +435,7 @@
function apply(position = opened ? fraction() : savedPosition) {
restoring = true;
panel.dataset.theme = settings.theme;
+ applyTextColors();
if (nativeTheme !== settings.theme) {
nativeTheme = settings.theme;
notify({type: 'theme', theme: settings.theme});
@@ -414,6 +458,13 @@
try { await browser.storage.local.set({narouTategaki: settings}); }
catch (err) { console.warn('縦書き: settings could not be saved', err); }
}
+ function applyTextColors() {
+ const [normal, highlight] = textColors[settings.theme];
+ article.style.setProperty('--normal-text', settings.normalColor || normal);
+ article.style.setProperty('--highlight-text', settings.highlightColor || highlight);
+ colorInputs.normalColor.value = settings.normalColor || normal;
+ colorInputs.highlightColor.value = settings.highlightColor || highlight;
+ }
function open() {
if (opened) return;
opened = true; lastFocus = document.activeElement;
@@ -464,6 +515,8 @@
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;
+ const paragraph = event.target.closest('article p');
+ if (paragraph && paragraph !== focusedParagraph) { focusParagraph(paragraph); return; }
setControls(!controlsVisible);
}, {passive: true});
document.addEventListener('visibilitychange', () => { if (document.hidden) savePosition(); });
@@ -501,6 +554,7 @@
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;
+ for (const key of ['normalColor', 'highlightColor']) settings[key] = /^#[0-9a-f]{6}$/i.test(stored[key]) ? stored[key] : null;
if (settings.auto && !opened) open(); else apply(savedPosition);
}).catch(() => { open(); });
// Keep a bounded local history; no network requests or reading telemetry.
diff --git a/app/src/main/assets/reader/manifest.json b/app/src/main/assets/reader/manifest.json
index 493c957..8e8f7ec 100644
--- a/app/src/main/assets/reader/manifest.json
+++ b/app/src/main/assets/reader/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "縦書き Reader — Android",
- "version": "1.1.1",
+ "version": "1.2.0",
"description": "Built-in vertical reading view for Narou Reader.",
"browser_specific_settings": {
"gecko": {
diff --git a/app/src/main/java/net/astropenguin/naroureader/MainActivity.java b/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
index ac49b19..2b73b67 100644
--- a/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
+++ b/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
@@ -103,6 +103,77 @@ public final class MainActivity extends Activity {
}
});
session.setPromptDelegate(new GeckoSession.PromptDelegate() {
+ @Override public GeckoResult<PromptResponse> onColorPrompt(GeckoSession s, ColorPrompt prompt) {
+ GeckoResult<PromptResponse> result = new GeckoResult<>();
+ android.widget.LinearLayout picker = new android.widget.LinearLayout(MainActivity.this);
+ picker.setOrientation(android.widget.LinearLayout.VERTICAL);
+ picker.setPadding(dp(20), dp(12), dp(20), dp(12));
+ View swatch = new View(MainActivity.this);
+ picker.addView(swatch, new android.widget.LinearLayout.LayoutParams(-1, dp(48)));
+ EditText hex = new EditText(MainActivity.this);
+ hex.setSingleLine(true); hex.setHint("#RRGGBB");
+ hex.setContentDescription("Hex color, #RRGGBB");
+ hex.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ picker.addView(hex);
+ String initial = prompt.defaultValue;
+ if (initial == null || !initial.matches("#[0-9a-fA-F]{6}")) initial = "#000000";
+ int color = Color.parseColor(initial);
+ android.widget.SeekBar[] channels = new android.widget.SeekBar[3];
+ int[] values = {Color.red(color), Color.green(color), Color.blue(color)};
+ String[] names = {"Red", "Green", "Blue"};
+ for (int i = 0; i < 3; i++) {
+ android.widget.TextView label = new android.widget.TextView(MainActivity.this);
+ label.setText(names[i]); picker.addView(label);
+ channels[i] = new android.widget.SeekBar(MainActivity.this);
+ channels[i].setContentDescription(names[i]); channels[i].setMax(255);
+ channels[i].setProgress(values[i]); picker.addView(channels[i]);
+ }
+ hex.addTextChangedListener(new android.text.TextWatcher() {
+ @Override public void beforeTextChanged(CharSequence text, int start, int count, int after) {}
+ @Override public void onTextChanged(CharSequence text, int start, int before, int count) {
+ String value = text.toString().trim();
+ if (!value.matches("#[0-9a-fA-F]{6}")) return;
+ hex.setError(null);
+ int selected = Color.parseColor(value); swatch.setBackgroundColor(selected);
+ channels[0].setProgress(Color.red(selected));
+ channels[1].setProgress(Color.green(selected));
+ channels[2].setProgress(Color.blue(selected));
+ }
+ @Override public void afterTextChanged(android.text.Editable text) {}
+ });
+ for (android.widget.SeekBar channel : channels) channel.setOnSeekBarChangeListener(new android.widget.SeekBar.OnSeekBarChangeListener() {
+ @Override public void onProgressChanged(android.widget.SeekBar bar, int value, boolean fromUser) {
+ if (fromUser) hex.setText(String.format(java.util.Locale.ROOT, "#%02x%02x%02x",
+ channels[0].getProgress(), channels[1].getProgress(), channels[2].getProgress()));
+ }
+ @Override public void onStartTrackingTouch(android.widget.SeekBar bar) {}
+ @Override public void onStopTrackingTouch(android.widget.SeekBar bar) {}
+ });
+ hex.setText(initial);
+ android.widget.ScrollView scroll = new android.widget.ScrollView(MainActivity.this);
+ scroll.addView(picker);
+ AlertDialog dialog = new AlertDialog.Builder(MainActivity.this)
+ .setTitle("Text color").setView(scroll)
+ .setPositiveButton("Apply", null).setNegativeButton("Cancel", null).create();
+ prompt.setDelegate(new PromptInstanceDelegate() {
+ @Override public void onPromptDismiss(BasePrompt p) { dialog.dismiss(); }
+ });
+ dialog.setOnDismissListener(d -> {
+ if (!prompt.isComplete()) result.complete(prompt.dismiss());
+ immersive();
+ });
+ dialog.setOnShowListener(d -> {
+ dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
+ String value = hex.getText().toString().trim();
+ if (!value.matches("#[0-9a-fA-F]{6}")) { hex.setError("Use #RRGGBB, for example #79dfb5"); return; }
+ if (!prompt.isComplete()) result.complete(prompt.confirm(value));
+ dialog.dismiss();
+ });
+ });
+ dialog.show();
+ return result;
+ }
@Override public GeckoResult<PromptResponse> onChoicePrompt(GeckoSession s, ChoicePrompt prompt) {
// The reader uses a single-choice HTML select for its theme.
if (prompt.type == ChoicePrompt.Type.MULTIPLE) return GeckoResult.fromValue(prompt.dismiss());
@@ -192,7 +263,7 @@ public final class MainActivity extends Activity {
menuButton.setVisibility(reading ? View.GONE : View.VISIBLE);
if (reading && !prefs.getBoolean("hintShown", false)) {
prefs.edit().putBoolean("hintShown", true).apply();
- Toast.makeText(MainActivity.this, "Tap the text for controls. Swipe right to read forward.", Toast.LENGTH_LONG).show();
+ Toast.makeText(MainActivity.this, "Tap a paragraph to focus. Tap it again or empty space for controls.", Toast.LENGTH_LONG).show();
}
}
return GeckoResult.fromValue(null);
@@ -231,7 +302,7 @@ public final class MainActivity extends Activity {
}
if (which == 3) openExternal(currentUrl);
if (which == 4) new AlertDialog.Builder(this).setTitle("縦書き Reader " + BuildConfig.VERSION_NAME)
- .setMessage("Swipe right to read forward. Tap text to show or hide controls.\n\n文字: font size\n行間: column spacing\n段落: paragraph spacing\n配色: theme\n傾き: accelerometer scrolling; enable, set neutral, then return to reading\n自動: open reader automatically\n\nEmpty spacer paragraphs are removed. The last chapter and reading position are saved on this device.\n\nPowered by Mozilla GeckoView 158. No analytics. Website content and cookies stay in this app’s separate browser profile. GeckoView: MPL 2.0. Engine license details are in the supplied source package.")
+ .setMessage("Swipe right to read forward. Tap a paragraph to focus. Tap it again or empty space for controls.\n\n文字: font size\n行間: column spacing\n段落: paragraph spacing\n配色: theme\nNormal / Focus text: saved text colors\n傾き: accelerometer scrolling; enable, set neutral, then return to reading\n自動: open reader automatically\n\nEmpty spacer paragraphs are removed. The last chapter and reading position are saved on this device.\n\nPowered by Mozilla GeckoView 158. No analytics. Website content and cookies stay in this app’s separate browser profile. GeckoView: MPL 2.0. Engine license details are in the supplied source package.")
.setNeutralButton("Engine licenses", (d, w) -> session.loadUri("about:license"))
.setPositiveButton("OK", null).show();
}).setNegativeButton("Continue reading", null).create();
diff --git a/app/src/main/java/net/astropenguin/naroureader/ReaderApplication.java b/app/src/main/java/net/astropenguin/naroureader/ReaderApplication.java
index 7498694..3b0d2e5 100644
--- a/app/src/main/java/net/astropenguin/naroureader/ReaderApplication.java
+++ b/app/src/main/java/net/astropenguin/naroureader/ReaderApplication.java
@@ -13,8 +13,14 @@ public final class ReaderApplication extends Application {
public synchronized GeckoRuntime runtime() {
if (runtime == null) {
+ /* PROD
runtime = GeckoRuntime.create(this, new GeckoRuntimeSettings.Builder()
.consoleOutput(BuildConfig.DEBUG).build());
+ */
+ runtime = GeckoRuntime.create(this, new GeckoRuntimeSettings.Builder()
+ .consoleOutput(BuildConfig.DEBUG)
+ .remoteDebuggingEnabled(BuildConfig.DEBUG)
+ .build());
extension = runtime.getWebExtensionController().ensureBuiltIn(
"resource://android/assets/reader/", "narou-reader-android@penguin.local");
}
diff --git a/tests/reader.test.cjs b/tests/reader.test.cjs
index 72e58aa..1f5f8f5 100644
--- a/tests/reader.test.cjs
+++ b/tests/reader.test.cjs
@@ -24,7 +24,24 @@ const ROOT=path.resolve(__dirname,'..');
assert(messages.some(m=>m.type==='reader'&&m.open));
assert(messages.some(m=>m.type==='theme'&&m.theme==='paper'));
const width=await v.evaluate(e=>e.scrollWidth);
- await page.touchscreen.tap(210,350);assert(!(await panel.getAttribute('class')).includes('chrome-hidden'));
+ const firstParagraph=panel.locator('article p').first();
+ // A tap on ruby text focuses its paragraph without opening chrome.
+ await firstParagraph.locator('rt').dispatchEvent('pointerdown',{clientX:100,clientY:100});
+ await firstParagraph.locator('rt').dispatchEvent('pointerup',{clientX:100,clientY:100});
+ assert.equal(await panel.locator('article p.reading-focus').count(),1);
+ assert.equal(await firstParagraph.getAttribute('aria-current'),'true');
+ assert((await panel.getAttribute('class')).includes('chrome-hidden'),'first paragraph tap only highlights');
+ assert.equal(await v.evaluate(e=>e.scrollWidth),width,'focus color must not reflow text');
+ await panel.locator('article p').nth(1).dispatchEvent('keydown',{key:'Enter'});
+ assert.equal(await panel.locator('article p').nth(1).getAttribute('aria-current'),'true','keyboard paragraph focus');
+ await page.touchscreen.tap(5,5);assert(!(await panel.getAttribute('class')).includes('chrome-hidden'));
+ await panel.locator('#reading-normalColor').evaluate(e=>{e.value='#8899aa';e.dispatchEvent(new Event('input'));});
+ await panel.locator('#reading-highlightColor').evaluate(e=>{e.value='#ff7755';e.dispatchEvent(new Event('input'));});
+ assert.equal(data.narouTategaki.normalColor,'#8899aa');
+ assert.equal(data.narouTategaki.highlightColor,'#ff7755');
+ assert.equal(await firstParagraph.evaluate(e=>getComputedStyle(e).color),'rgb(136, 153, 170)');
+ assert.equal(await panel.locator('article p.reading-focus').evaluate(e=>getComputedStyle(e).color),'rgb(255, 119, 85)');
+ assert.equal(await panel.locator('article p.reading-focus rt').evaluate(e=>getComputedStyle(e).color),'rgb(255, 119, 85)','ruby inherits highlight');
assert.equal(await v.evaluate(e=>e.scrollWidth),width,'controls must not reflow text');
await panel.locator('#paragraph').evaluate(e=>{e.value='1.2';e.dispatchEvent(new Event('input'));});
await page.waitForTimeout(100);assert.equal(data.narouTategaki.paragraph,1.2);
@@ -42,8 +59,9 @@ const ROOT=path.resolve(__dirname,'..');
const restored=await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth));
assert(Math.abs(before-restored)<.002,`${before} != ${restored}`);
assert.equal(await panel.getAttribute('data-theme'),'dark');
+ assert.equal(await panel.locator('#reading-highlightColor').inputValue(),'#ff7755','custom colors survive reload and theme change');
assert(messages.filter(m=>m.type==='theme'&&m.theme==='dark').length>=2,'restored theme must reach the native app');
- await page.touchscreen.tap(210,350);
+ await page.touchscreen.tap(5,5);
// Optional: inspect with a local screenshot here.
await panel.getByRole('button',{name:'元のページ',exact:true}).click();assert(await panel.isHidden());
assert(messages.some(m=>m.type==='reader'&&!m.open));
@@ -54,7 +72,7 @@ const ROOT=path.resolve(__dirname,'..');
assert.equal(await v.evaluate(e=>e.scrollHeight),await v.evaluate(e=>e.clientHeight));
// Optional: inspect landscape layout here.
// Motion uses mocked native accelerometer samples, real Gecko layout and animation.
- await page.touchscreen.tap(400,220);
+ await page.touchscreen.tap(5,5);
await panel.locator('#motion-toggle').click();
await page.waitForTimeout(250);
await panel.locator('#motion-neutral').click();
@@ -63,8 +81,17 @@ const ROOT=path.resolve(__dirname,'..');
sensor.x=.5;
await page.waitForTimeout(900);
assert(await v.evaluate(e=>e.scrollLeft)<motionStart-20,'tilt must scroll forward');
+ assert.equal(await panel.locator('article p.reading-focus').count(),1,'tilt automatically focuses a paragraph');
+ assert(await panel.locator('article p.reading-focus').evaluate(e=>{const r=e.getBoundingClientRect();return Math.max(r.left-innerWidth/2,innerWidth/2-r.right,0)<40;}),'automatic focus follows the screen center (including column gaps and update interval)');
+ sensor.x=0;await page.waitForTimeout(180);
+ // Keep a manually selected paragraph while braking, including residual movement.
+ await panel.locator('article p').last().dispatchEvent('keydown',{key:'Enter'});
+ await page.waitForTimeout(250);
+ assert.equal(await panel.locator('article p').last().getAttribute('aria-current'),'true','braking range suppresses automatic selection');
+ sensor.x=.5;await page.waitForTimeout(250);
+ assert.equal(await panel.locator('article p').last().getAttribute('aria-current'),null,'automatic focus resumes outside braking range');
// Opening controls brakes immediately.
- await page.touchscreen.tap(400,220);
+ await page.touchscreen.tap(5,5);
const stopped=await v.evaluate(e=>e.scrollLeft);
await page.waitForTimeout(350);
assert.equal(await v.evaluate(e=>e.scrollLeft),stopped,'controls must pause motion');
@@ -118,13 +145,13 @@ const ROOT=path.resolve(__dirname,'..');
sensor.active=true;sensor.rotation=1;sensor.x=-.6;
await page.waitForTimeout(350);
assert.equal(await v.evaluate(e=>e.scrollLeft),inactive,'rotation must reset neutral');
- await page.touchscreen.tap(400,220);
+ await page.touchscreen.tap(5,5);
await panel.locator('#motion-toggle').click();
assert(messages.some(m=>m.type==='motion'&&!m.enabled));
await load();
assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'false','disabled state persists');
assert.equal(data.narouMotion.reverse,true,'motion preferences persist');
- await page.touchscreen.tap(400,220);
+ await page.touchscreen.tap(5,5);
await panel.locator('#motion-toggle').click();
await page.waitForTimeout(150);
await load();