Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion TestCases.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ Section note: automated tests inject the script and mock `GM_info`.
- https://news.ycombinator.com/item?id=46255285 [CONTENT_CLASS: VALID_NON_ARTICLE_OR_LISTING] [TEST_STATUS: AUTOMATED]
- https://archive.is/75aY9 [CONTENT_CLASS: VALID_ARTICLE_CONTENT] [TEST_STATUS: AUTOMATED]
- https://lcamtuf.coredump.cx/prep/index-old.shtml [CONTENT_CLASS: VALID_ARTICLE_CONTENT] [TEST_STATUS: AUTOMATED] (tiny-font auto-enable behavior)
- https://daringfireball.net/2026/03/your_frustration_is_the_product [CONTENT_CLASS: VALID_ARTICLE_CONTENT] [TEST_STATUS: LIMITED] (mobile spacing logic is AUTOMATED via synthetic DOM harness, but full live-page capture coverage is still limited)
- https://daringfireball.net/2026/03/your_frustration_is_the_product [CONTENT_CLASS: VALID_ARTICLE_CONTENT] [TEST_STATUS: LIMITED] (mobile spacing logic is AUTOMATED via synthetic DOM harness, including minimal side-whitespace enforcement; full live-page capture coverage is still limited)
- https://steveblank.com/2026/04/09/nowhere-is-safe/ [CONTENT_CLASS: VALID_ARTICLE_CONTENT] [TEST_STATUS: AUTOMATED] (line-height overlap regression is simulated with local DOM harness against captured page content)

# Better Mobile View
- https://hackernews.betacat.io/ [CONTENT_CLASS: VALID_ARTICLE_CONTENT]
Expand Down
71 changes: 68 additions & 3 deletions scripts/test-force-mobile-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ const scriptContents = fs.readFileSync(scriptPath, 'utf8');
const hnFixturePath = path.join(repoRoot, 'tests', 'Force Mobile View', 'news.ycombinator.com_item_id_46255285.html');
const archiveFixturePath = path.join(repoRoot, 'tests', 'Force Mobile View', 'archive.is_75aY9.html');
const lcamtufFixturePath = path.join(repoRoot, 'tests', 'Force Mobile View', 'lcamtuf.coredump.cx_prep_index-old.shtml.html');
const steveBlankFixturePath = path.join(repoRoot, 'tests', 'Force Mobile View', 'steveblank.com_2026_04_09_nowhere-is-safe.html');
const hnFixtureHtml = fs.readFileSync(hnFixturePath, 'utf8');
const archiveFixtureHtml = fs.readFileSync(archiveFixturePath, 'utf8');
const lcamtufFixtureHtml = fs.readFileSync(lcamtufFixturePath, 'utf8');
const steveBlankFixtureHtml = fs.readFileSync(steveBlankFixturePath, 'utf8');

function executeForceMobileView(url, options = {}) {
const harness = createHarness({
Expand Down Expand Up @@ -74,13 +76,15 @@ function executeForceMobileView(url, options = {}) {
}

describe('ForceMobileView on captured pages', () => {
test('fixtures contain captured HN, archive.is, and lcamtuf content', () => {
test('fixtures contain captured HN, archive.is, lcamtuf, and Steve Blank content', () => {
assert.match(hnFixtureHtml, /CONTENT_CLASS:\s*VALID_/);
assert.match(hnFixtureHtml, /hnmain/);
assert.match(archiveFixtureHtml, /CONTENT_CLASS:\s*VALID_/);
assert.match(archiveFixtureHtml, /archive\.is/i);
assert.match(lcamtufFixtureHtml, /CONTENT_CLASS:\s*VALID_/);
assert.match(lcamtufFixtureHtml, /lcamtuf\.coredump\.cx/i);
assert.match(steveBlankFixtureHtml, /CONTENT_CLASS:\s*VALID_/);
assert.match(steveBlankFixtureHtml, /steveblank\.com/i);
});

test('matched URL auto-enables mobile view style, min font enforcement, and toggle button', () => {
Expand All @@ -95,7 +99,7 @@ describe('ForceMobileView on captured pages', () => {
assert(button, 'Expected mobile view toggle button to be created.');
assert.equal(button.getAttribute('aria-pressed'), 'true');
assert.equal(textElement.style.getPropertyValue('font-size'), '18px');
assert.equal(textElement.style.getPropertyValue('line-height'), '1.4');
assert.equal(textElement.style.getPropertyValue('line-height'), '25.2px');
assert.equal(textElement.getAttribute('data-tm-force-width-min-font'), 'true');
});

Expand Down Expand Up @@ -160,12 +164,73 @@ describe('ForceMobileView on captured pages', () => {
const button = harness.document.body.children.find((child) => child.tagName === 'BUTTON' && child.textContent === '↔');

assert.equal(post.style.getPropertyValue('margin-left'), '0px');
assert.equal(post.style.getPropertyValue('padding-left'), '8px');
assert.equal(post.style.getPropertyValue('padding-left'), '2px');
assert.equal(post.style.getPropertyValue('padding-right'), '2px');
assert.equal(post.getAttribute('data-tm-force-width-spacing-trimmed'), 'true');
const totalSideWhitespace = Number.parseFloat(post.style.getPropertyValue('margin-left') || '0') +
Number.parseFloat(post.style.getPropertyValue('margin-right') || '0') +
Number.parseFloat(post.style.getPropertyValue('padding-left') || '0') +
Number.parseFloat(post.style.getPropertyValue('padding-right') || '0');
assert(totalSideWhitespace <= 4, 'Expected side whitespace to stay minimal on narrow mobile screens.');

button.click();
assert.equal(post.style.getPropertyValue('margin-left'), '');
assert.equal(post.style.getPropertyValue('padding-left'), '');
assert.equal(post.getAttribute('data-tm-force-width-spacing-trimmed'), null);
});

test('legacy font-only boost overlaps text lines, while current behavior raises parent line-height', () => {
const { harness } = executeForceMobileView('https://steveblank.com/2026/04/09/nowhere-is-safe/', {
fixtureHtml: steveBlankFixtureHtml,
computedStyle(element) {
const inlineFont = element.style.getPropertyValue('font-size');
const inlineLineHeight = element.style.getPropertyValue('line-height');
if (element.id === 'problem-line-box') {
return {
fontSize: inlineFont || '20px',
lineHeight: inlineLineHeight || '12px',
marginLeft: '0px',
marginRight: '0px',
paddingLeft: '0px',
paddingRight: '0px'
};
}
if (element.id === 'problem-small-text') {
return {
fontSize: inlineFont || '10px',
lineHeight: inlineLineHeight || '10px',
marginLeft: '0px',
marginRight: '0px',
paddingLeft: '0px',
paddingRight: '0px'
};
}
return {
fontSize: inlineFont || '16px',
lineHeight: inlineLineHeight || '22px',
marginLeft: '0px',
marginRight: '0px',
paddingLeft: '0px',
paddingRight: '0px'
};
}
});
const paragraph = harness.document.createElement('p');
paragraph.id = 'problem-line-box';
const span = harness.document.createElement('span');
span.id = 'problem-small-text';
span.textContent = 'Dense translated text';
paragraph.appendChild(span);
harness.appendToBody(paragraph);

const legacyLineHeight = 12;
const legacyFontAfterBoost = 18;
assert(legacyLineHeight < legacyFontAfterBoost, 'Legacy font-only boost would have line overlap.');

harness.dispatchDocumentEvent('DOMContentLoaded');
assert.equal(span.style.getPropertyValue('font-size'), '18px');
assert.equal(span.style.getPropertyValue('line-height'), '25.2px');
assert.equal(paragraph.getAttribute('data-tm-force-width-min-line-height-only'), 'true');
assert.equal(paragraph.style.getPropertyValue('line-height'), '28px');
});
});
58 changes: 53 additions & 5 deletions src/ForceMobileView.user.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// ==UserScript==
// @name Force Mobile View
// @namespace http://tampermonkey.net/
// @version 2026-03-19_1.6.0
// @version 2026-04-12_1.7.1
// @description Keep pages within the viewport width, trim excessive horizontal spacing on all enabled pages, wrap long content, and expose a draggable top-right ↔ toggle button with auto-enable for matched URLs or tiny fonts.
// @author ChrisTorng
// @homepage https://github.com/ChrisTorng/TampermonkeyScripts/
Expand Down Expand Up @@ -29,6 +29,9 @@
const MIN_FONT_PRIORITY_ATTR = 'data-tm-force-width-font-priority';
const MIN_LINE_HEIGHT_VALUE_ATTR = 'data-tm-force-width-line-height-value';
const MIN_LINE_HEIGHT_PRIORITY_ATTR = 'data-tm-force-width-line-height-priority';
const MIN_LINE_HEIGHT_ONLY_FLAG_ATTR = 'data-tm-force-width-min-line-height-only';
const MIN_LINE_HEIGHT_ONLY_VALUE_ATTR = 'data-tm-force-width-min-line-height-only-value';
const MIN_LINE_HEIGHT_ONLY_PRIORITY_ATTR = 'data-tm-force-width-min-line-height-only-priority';
const SPACING_FLAG_ATTR = 'data-tm-force-width-spacing-trimmed';
const SPACING_MARGIN_LEFT_ATTR = 'data-tm-force-width-margin-left';
const SPACING_MARGIN_LEFT_PRIORITY_ATTR = 'data-tm-force-width-margin-left-priority';
Expand All @@ -39,7 +42,7 @@
const SPACING_PADDING_RIGHT_ATTR = 'data-tm-force-width-padding-right';
const SPACING_PADDING_RIGHT_PRIORITY_ATTR = 'data-tm-force-width-padding-right-priority';
const SPACING_TARGET_SELECTOR = 'main, article, section, div, aside, header, footer, nav, ul, ol, li, p, blockquote, pre, figure, table';
const MAX_SIDE_SPACING_PX = 8;
const MAX_SIDE_SPACING_PX = 2;
let isEnabled = false;
let styleObserver;
let isObserving = false;
Expand Down Expand Up @@ -70,8 +73,8 @@
` body > * {\n` +
` margin-left: 0 !important;\n` +
` margin-right: 0 !important;\n` +
` padding-left: min(2vw, 8px) !important;\n` +
` padding-right: min(2vw, 8px) !important;\n` +
` padding-left: min(0.6vw, 2px) !important;\n` +
` padding-right: min(0.6vw, 2px) !important;\n` +
` }\n` +
`}\n` +
`body * {\n` +
Expand Down Expand Up @@ -394,6 +397,7 @@
if (!root || !Number.isFinite(minFontSizePx)) {
return;
}
const enforcedAncestors = new Set();
const elements = [];
if (root.nodeType === Node.ELEMENT_NODE) {
elements.push(root);
Expand All @@ -417,10 +421,41 @@
element.setAttribute(MIN_LINE_HEIGHT_VALUE_ATTR, lineHeightValue);
element.setAttribute(MIN_LINE_HEIGHT_PRIORITY_ATTR, lineHeightPriority);
element.style.setProperty('font-size', `${minFontSizePx}px`, 'important');
element.style.setProperty('line-height', String(MIN_LINE_HEIGHT_RATIO), 'important');
const minimumLineHeightPx = minFontSizePx * MIN_LINE_HEIGHT_RATIO;
element.style.setProperty('line-height', `${minimumLineHeightPx}px`, 'important');
let parent = element.parentElement;
while (parent && parent !== document.documentElement) {
if (!enforcedAncestors.has(parent)) {
enforceReadableLineHeight(parent);
enforcedAncestors.add(parent);
}
parent = parent.parentElement;
}
});
}

function enforceReadableLineHeight(element) {
if (!element || element.hasAttribute(MIN_FONT_FLAG_ATTR) || element.hasAttribute(MIN_LINE_HEIGHT_ONLY_FLAG_ATTR)) {
return;
}
const computedStyle = window.getComputedStyle(element);
const computedFontSize = Number.parseFloat(computedStyle.fontSize);
const computedLineHeight = Number.parseFloat(computedStyle.lineHeight);
if (!Number.isFinite(computedFontSize) || !Number.isFinite(computedLineHeight)) {
return;
}
const minimumLineHeightPx = computedFontSize * MIN_LINE_HEIGHT_RATIO;
if (computedLineHeight >= minimumLineHeightPx) {
return;
}
const lineHeightValue = element.style.getPropertyValue('line-height');
const lineHeightPriority = element.style.getPropertyPriority('line-height');
element.setAttribute(MIN_LINE_HEIGHT_ONLY_FLAG_ATTR, 'true');
element.setAttribute(MIN_LINE_HEIGHT_ONLY_VALUE_ATTR, lineHeightValue);
element.setAttribute(MIN_LINE_HEIGHT_ONLY_PRIORITY_ATTR, lineHeightPriority);
element.style.setProperty('line-height', `${minimumLineHeightPx}px`, 'important');
}

function clearMinimumFontSize() {
const elements = document.querySelectorAll(`[${MIN_FONT_FLAG_ATTR}="true"]`);
elements.forEach((element) => {
Expand All @@ -444,6 +479,19 @@
element.removeAttribute(MIN_LINE_HEIGHT_VALUE_ATTR);
element.removeAttribute(MIN_LINE_HEIGHT_PRIORITY_ATTR);
});
const lineHeightOnlyElements = document.querySelectorAll(`[${MIN_LINE_HEIGHT_ONLY_FLAG_ATTR}="true"]`);
lineHeightOnlyElements.forEach((element) => {
const lineHeightValue = element.getAttribute(MIN_LINE_HEIGHT_ONLY_VALUE_ATTR) || '';
const lineHeightPriority = element.getAttribute(MIN_LINE_HEIGHT_ONLY_PRIORITY_ATTR) || '';
if (lineHeightValue) {
element.style.setProperty('line-height', lineHeightValue, lineHeightPriority);
} else {
element.style.removeProperty('line-height');
}
element.removeAttribute(MIN_LINE_HEIGHT_ONLY_FLAG_ATTR);
element.removeAttribute(MIN_LINE_HEIGHT_ONLY_VALUE_ATTR);
element.removeAttribute(MIN_LINE_HEIGHT_ONLY_PRIORITY_ATTR);
});
}

function restoreInlineProperty(element, propertyName, valueAttr, priorityAttr) {
Expand Down
Loading
Loading