From 7f7469ae194658b6b9f1d8777a989f38be381cd7 Mon Sep 17 00:00:00 2001 From: ChrisTorng Date: Sun, 12 Apr 2026 22:36:46 +0800 Subject: [PATCH 1/5] Fix ForceMobileView line-height overlap and add Steve Blank case --- TestCases.md | 1 + scripts/test-force-mobile-view.js | 63 +- src/ForceMobileView.user.js | 52 +- ...eblank.com_2026_04_09_nowhere-is-safe.html | 1441 +++++++++++++++++ 4 files changed, 1553 insertions(+), 4 deletions(-) create mode 100644 tests/Force Mobile View/steveblank.com_2026_04_09_nowhere-is-safe.html diff --git a/TestCases.md b/TestCases.md index 0c86973..274a1ec 100644 --- a/TestCases.md +++ b/TestCases.md @@ -81,6 +81,7 @@ Section note: automated tests inject the script and mock `GM_info`. - 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://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] diff --git a/scripts/test-force-mobile-view.js b/scripts/test-force-mobile-view.js index 2c2aa8a..50a67b7 100644 --- a/scripts/test-force-mobile-view.js +++ b/scripts/test-force-mobile-view.js @@ -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({ @@ -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', () => { @@ -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'); }); @@ -168,4 +172,59 @@ describe('ForceMobileView on captured pages', () => { 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'); + }); }); diff --git a/src/ForceMobileView.user.js b/src/ForceMobileView.user.js index aec59b2..7b8431c 100644 --- a/src/ForceMobileView.user.js +++ b/src/ForceMobileView.user.js @@ -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.0 // @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/ @@ -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'; @@ -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); @@ -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) => { @@ -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) { diff --git a/tests/Force Mobile View/steveblank.com_2026_04_09_nowhere-is-safe.html b/tests/Force Mobile View/steveblank.com_2026_04_09_nowhere-is-safe.html new file mode 100644 index 0000000..f6b4ea8 --- /dev/null +++ b/tests/Force Mobile View/steveblank.com_2026_04_09_nowhere-is-safe.html @@ -0,0 +1,1441 @@ + + + + + + + + +Steve Blank Nowhere Is Safe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ +
+ + +
+
+ +
+ + +
+ +

Nowhere Is Safe

+ + + +
+ +

Drones in Ukraine and in the War with Iran have made the surface of the earth a contested space. The U.S. has discovered that 1) air superiority and missile defense systems (THAAD, Patriot batteries) designed to counter tens or hundreds of aircraft and missiles is insufficient against asymmetric attacks of thousands of drones. And that 2) undefended high value fixed civilian infrastructure – oil tankers, data centers, desalination plants, oil refineries, energy nodes, factories, et al -are all at risk. 

+

When the targets are no longer just military assets but anything valuable on the surface, the long term math no longer favors the defender. To solve this problem the U.S. is spending $10s of billions of dollars on low-cost Counter-UAS systems – detection systems, inexpensive missiles, kamikaze drones, microwave and laser weapons.

+

But what we’re not spending $10s of billions on is learning how to cheaply and quickly put our high-value, hard-to-replace, and time-critical assets (munitions, fuel distribution, Command and Control continuity nodes, spares), etc., out of harm’s way – sheltered, underground (or in space). 

+

The lessons from Gaza reinforce that underground systems can also preserve forces and enable maneuver. The lessons from Ukraine are that survivability while under constant drone observation/attack requires using underground facilities to provide overhead cover (while masking RF, infrared and other signatures). And the lessons from Iran’s attacks on infrastructure in the Gulf Cooperation Council countries is that anything on the surface is going to be a target.

+

We need to rethink the nature of force protection as well as military and civilian infrastructure protection.

+
+
Air Defense Systems
+
For decades the U.S. has built air defense systems designed for shooting down aircraft and missiles.The Navy’s Aegis destroyers provide defense for carrier strike groups using surface-to-air missiles against hostile aircraft and missiles. The Army’s Patriot anti-aircraft batteries provide area protection against aircraft and missiles. The Missile Defense Agency (MDA) provides missile defense from North Korea for Guam and a limited missile defense for the U.S.  MDA is leading the development of Golden Dome, a missile defense system to protect the entire U.S. against ballistic, cruise, and hypersonic missiles from China and Russia. All of these systems were designed to use expensive missiles to shoot down equally expensive aircraft and missiles. None of these systems were designed to shoot down hundreds/thousands of very low-cost drones.
+
+

Aircraft Protection
+After destroying Iraqi aircraft shelters in the Gulf War with 2,000-lb bombs, the U.S. Air Force convinced itself that building aircraft and maintenance shelters was not worth the investment. Instead, their plan – the Agile Combat Employment (ACE) program – was to disperse small teams to remote austere locations (with minimal air defense systems) in time of war. Dispersal along with air superiority would substitute for building hardened shelters. Oops. It didn’t count on low-cost drones finding those dispersed aircraft. (One would have thought that Ukraine’s Operation Spider’s Web using 117 drones smuggled in shipping containers – which struck and destroyed Russian bombers – would have been a wakeup call.)

+

The cost of not having hardened aircraft shelters during the 2026 Iran War came home when Iran destroyed an AWACS aircraft and KC-135 tankers sitting in the open. Meanwhile, China, Iran and North Korea have made massive investments in hardened shelters and underground facilities.

+
+

Protecting Ground Forces
+
The problem of protecting troops with foxholes against artillery is hundreds of years old. In WWI, trenches connected foxholes into systems. Bunkers were hardened against direct hits. Each step was a response to increased lethality from above. Today, drones are the new artillery; a persistent, cheap and precise overhead threat but with the ability to maneuver laterally, enter openings, and loiter. And mass drone attacks put every high value military and civilian target on the surface at risk. Fielding more hardened shelters for soldiers like the Army’s Modular Protective System Overhead Cover shelters is a first step for FPV kamikaze drones defense, but drones can get inside buildings through any sufficiently sized openings. 

+

Drone Protection
+
Ukraine has installed ~500 miles of anti-drone net tunnels with a goal of 2,500 miles by the end of 2026. These are metal poles and fishing nets stretched over roads but they represent the same instinct: the surface is a kill zone, so cover it. Russia has done the same.

+

+

The logical response is to go underground (or out to space) but the technology to do it quickly, cheaply, and at scale is genuinely new. The gap in current thinking is between “put up nets” (cheap, fast, limited) and “build a Cold War concrete bunker” (expensive, slow, permanent). What’s missing is the middle layer – rapidly bored shallow tunnels that provide genuine overhead cover for movement corridors, equipment parking, and personnel protection. 

+

What tunnels solve that nets and shelters don’t
+
A net stops an FPV drone’s propellers. A shelter stops shrapnel. But a tunnel 15-30 feet underground is invisible to ISR, immune most to top-attack munitions, can’t be entered by a drone through a door or window, and survives anything short of a bunker-buster. Gaza proved that even with total air superiority and ground control, Israel has destroyed only about 40 percent of Gaza’s tunnels after two and a half years of war.

+

That’s an asymmetric defender’s advantage the U.S. military should be thinking about for its own use, not just as a threat to overcome.

+

What’s changed to make this feasible is that we may not need boring tunnels per se, but instead modular, pre-fabricated tunnel segments that can be installed with cut-and-cover methods at expeditionary bases. Or autonomous boring machines sized for military logistics (smaller versions of the Boring Company TBMs) corridors rather than highway traffic.

+

+

The problem is a lack of urgency and imagination
+
The problem is real, the incumbents (Army Corps of Engineers) are slow, and the existing commercial tunneling industry isn’t thinking about expeditionary military applications.

+

The doctrinal gap is between “dig a foxhole with an entrenching tool” (individual soldier, hours) or deploy a few Army’s Modular Protective System Overhead Cover shelters or “build a Cold War hardened aircraft shelter” (major construction project, years, billions). There’s no doctrine for rapidly boring hardened underground movement corridors, dispersed equipment shelters, or protected command post positions using modern tunneling technology.

+

Army doctrine treats excavation as something done with organic engineer equipment — backhoes, bulldozers, troops with shovels — to create individual fighting positions and cut-and-cover bunkers. The Air Force doctrine barely addresses physical hardening at all, having spent 30 years assuming air superiority would substitute for it.

+

Nobody in the doctrinal community is asking: what if the Army could cut and cover 100 meters of precast tunnel segments in a day or if we could bore a 12-foot diameter tunnel 30 feet underground at a rate of a hundred of meters per week and use it as a protected logistics corridor, command post, or aircraft revetment?

+

Summary
+
Oceans on both sides and friendly nations on our borders have lulled America into a false sense of security. After all, the U.S. has not fought a foreign force on American soil since 1812.

+

Protection and survivability is no longer a problem for a single service nor is it a problem of a single solution or an incremental solution. Something fundamentally disruptive has changed in the nature of asymmetric warfare and there’s no going back. While we’re actively chasing immediate solutions (Golden Dome, JTAF-401, et al), we need to rethink the nature of force protection, and military and civilian infrastructure protection. Protection and survivability solutions are not as sexy as buying aircraft or weapons systems but they may be the key to winning a war.

+

The U.S. needs a coherent protection and survivability strategy across the DoW and all sectors of our economy. This conversation needs to be not only about how we do it, but how we organize to do it, how we budget and pay for it and how we rapidly deploy it.

+

Lessons Learned

+
+
    +
  • There is no coherent protection and survivability strategy that addresses drones across the DoW and the whole of nation +
      +
    • Just point solutions
    • +
    +
  • +
  • For troops near the front, tunnels could reduce visual, thermal, and RF signature while providing fragment protection with a network of small, concealed, overhead- covered positions, short connectors, buried command posts, protected aid stations, and revetted vehicle hides.  
  • +
  • We need to underground assets that cannot be quickly replaced  +
      +
    • Command posts, comms nodes, ammunition, fuel distribution points, repair facilities, key power systems, maintenance spares, and high-value aircraft or drones.  
    • +
    • Think protected taxiways, blast walls, covered trenches, buried cabling, alternate exits, redundant portals, and rapid runway repair. Sortie generation under attack depends on a whole system, not one bunker.  
    • +
    +
  • +
  • We need to work with commercial companies to harden/defend their sites +
      +
    • Provide active defenses and incentives for under-grounding critical facilities
    • +
    +
  • +
  • The Army and Air Force need to rethink their doctrines and techniques for Protection and Survivability +
      +
    • Army Techniques Publications (ATP) 3-37.34 – Survivability Operations treat excavation as something done with backhoes, bulldozers, troops with shovels to create individual fighting positions and cut-and-cover bunkers. Update it.
    • +
    • The Air Force needs to do the same with AFDP 3-10, AFDP 3-0.1 (Force Protection and AFTTP 3-32.34v3, AFH 10-222, Volume 14 and UFC 3-340-02
    • +
    +
  • +
  • We need to think of force and infrastructure protection not piecemeal but holistically
  • +
  • +
      +
    • Part of any weapons systems requirement and budget should now include protection and survivability 
    • +
    • Protection and survivability should be deployed concurrently with weapons systems
    • +
    +
  • +
  • We need a Whole of Nation approach to protection and survivability for both the force and critical infrastructure
  • +
+
+ + + + +
+
+ + + + + + + +
+ +
+ + +
+
+
+ +

4 Responses

+ +
    + +
  1. +
    + + +

    We are arriving at the end stage gamification of the war machine. Nuclear annihilation is being replaced with anti-personnel drones and anti-population swarms. We have known about this eventuality for 20+ years but have done nothing to stop it. You are right that the only option for the militaries is underground but they will need to surface at some point. How long can they be above ground before they are attacked – maybe just a few minutes.

    +

    Unfortunately, it is also the only option for the public.

    +
    + +
    +
    + +
    + Reply
    +
    +
  2. + +
  3. +
    + + +

    I mean we’ve been loose-y goose-y about drone defense to the point I’m SHOCKED that we haven’t lost an airframe CONUS.

    +

    The B-1 is only stationed at 2 bases with public access <2mi from the flight line. Often with all or most birds out of hangars.

    +

    The B-2 is only stationed at 1 base with public access <1mi from the flight line and hangars.

    +

    The US is not prepared at all for near-peer conflict.

    +
    + +
    +
    + +
    + Reply
    +
    +
  4. + +
  5. +
    + + +

    We’ll get to this after we fix all the potholes… highways….bridges…

    +
    + +
    +
    + +
    + Reply
    +
    +
  6. + +
  7. +
    + + +

    >the U.S. has not fought a foreign force on American soil since 1812

    +

    https://en.wikipedia.org/wiki/Battle_of_Attu

    +
    + +
    +
    + +
    + Reply
    +
    +
  8. +
+ + + + + + + + +
+

Leave a Reply

+ + + + +
+
+ + + + + +
+
+
+ + +
+ + +
+ + + +
+ +
+ + + + + +
+
+ +
+ + +

Discover more from Steve Blank

+ + + +

Subscribe now to keep reading and get access to the full archive.

+ + +
+
+
+
+

+ +

+

+ + + + + + + + +

+
+
+
+
+ + + +

Continue reading

+ +
+
+
+
+
+
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 1cb1042f578c5c7399091c1837bccec3c0cf66f1 Mon Sep 17 00:00:00 2001 From: ChrisTorng Date: Sun, 12 Apr 2026 22:47:34 +0800 Subject: [PATCH 2/5] Tighten mobile side spacing for ForceMobileView --- TestCases.md | 2 +- scripts/test-force-mobile-view.js | 8 +++++++- src/ForceMobileView.user.js | 8 ++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/TestCases.md b/TestCases.md index 274a1ec..0707354 100644 --- a/TestCases.md +++ b/TestCases.md @@ -80,7 +80,7 @@ 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 diff --git a/scripts/test-force-mobile-view.js b/scripts/test-force-mobile-view.js index 50a67b7..71f3a55 100644 --- a/scripts/test-force-mobile-view.js +++ b/scripts/test-force-mobile-view.js @@ -164,8 +164,14 @@ 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'), ''); diff --git a/src/ForceMobileView.user.js b/src/ForceMobileView.user.js index 7b8431c..a7399be 100644 --- a/src/ForceMobileView.user.js +++ b/src/ForceMobileView.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Force Mobile View // @namespace http://tampermonkey.net/ -// @version 2026-04-12_1.7.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/ @@ -42,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; @@ -73,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` + From 7a0870d4ee5b21a7ff1538ef3abd62e536533cbd Mon Sep 17 00:00:00 2001 From: ChrisTorng Date: Sun, 12 Apr 2026 22:57:46 +0800 Subject: [PATCH 3/5] Flatten sidebar-like columns on narrow mobile layouts --- TestCases.md | 2 +- scripts/test-force-mobile-view.js | 77 +++++++++++++++++++++++++++++++ src/ForceMobileView.user.js | 54 ++++++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/TestCases.md b/TestCases.md index 0707354..5cd6b06 100644 --- a/TestCases.md +++ b/TestCases.md @@ -80,7 +80,7 @@ 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, including minimal side-whitespace enforcement; 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 and desktop sidebar flattening; 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 diff --git a/scripts/test-force-mobile-view.js b/scripts/test-force-mobile-view.js index 71f3a55..0ddedd4 100644 --- a/scripts/test-force-mobile-view.js +++ b/scripts/test-force-mobile-view.js @@ -48,6 +48,10 @@ function executeForceMobileView(url, options = {}) { } return { fontSize: element.tagName === 'SPAN' ? '10px' : '16px', + lineHeight: '22px', + width: '360px', + position: 'static', + float: 'none', marginLeft: '0px', marginRight: '0px', paddingLeft: '0px', @@ -148,6 +152,10 @@ describe('ForceMobileView on captured pages', () => { computedStyle(element) { return { fontSize: '16px', + lineHeight: '22px', + width: element.id === 'post' ? '320px' : '360px', + position: 'static', + float: 'none', marginLeft: element.id === 'post' ? '24px' : '0px', marginRight: element.id === 'post' ? '24px' : '0px', paddingLeft: element.id === 'post' ? '18px' : '0px', @@ -179,6 +187,75 @@ describe('ForceMobileView on captured pages', () => { assert.equal(post.getAttribute('data-tm-force-width-spacing-trimmed'), null); }); + test('absolute sidebar columns are flattened so article content can use full mobile width', () => { + const { harness } = executeForceMobileView('https://daringfireball.net/2026/03/your_frustration_is_the_product', { + computedStyle(element) { + if (element.id === 'Sidebar') { + return { + fontSize: '16px', + lineHeight: '22px', + width: '160px', + position: 'absolute', + float: 'none', + left: '0px', + right: 'auto', + marginLeft: '16px', + marginRight: '0px', + paddingLeft: '16px', + paddingRight: '8px' + }; + } + if (element.id === 'Main') { + return { + fontSize: '16px', + lineHeight: '22px', + width: '425px', + position: 'relative', + float: 'none', + left: '0px', + right: 'auto', + marginLeft: '222px', + marginRight: '0px', + paddingLeft: '0px', + paddingRight: '0px' + }; + } + return { + fontSize: '16px', + lineHeight: '22px', + width: '360px', + position: 'static', + float: 'none', + left: 'auto', + right: 'auto', + marginLeft: '0px', + marginRight: '0px', + paddingLeft: '0px', + paddingRight: '0px' + }; + } + }); + const sidebar = harness.document.createElement('aside'); + sidebar.id = 'Sidebar'; + ['Archive', 'The Talk Show', 'Dithering'].forEach((label) => { + const link = harness.document.createElement('a'); + link.href = '#'; + link.textContent = label; + sidebar.appendChild(link); + }); + const main = harness.document.createElement('main'); + main.id = 'Main'; + main.textContent = 'Article text'; + harness.appendToBody(sidebar); + harness.appendToBody(main); + harness.dispatchDocumentEvent('DOMContentLoaded'); + + assert.equal(sidebar.style.getPropertyValue('position'), 'static'); + assert.equal(sidebar.style.getPropertyValue('width'), 'auto'); + assert.equal(sidebar.style.getPropertyValue('max-width'), '100%'); + assert.equal(main.style.getPropertyValue('margin-left'), '0px'); + }); + 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, diff --git a/src/ForceMobileView.user.js b/src/ForceMobileView.user.js index a7399be..88ec4f9 100644 --- a/src/ForceMobileView.user.js +++ b/src/ForceMobileView.user.js @@ -41,6 +41,20 @@ const SPACING_PADDING_LEFT_PRIORITY_ATTR = 'data-tm-force-width-padding-left-priority'; 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 LAYOUT_POSITION_ATTR = 'data-tm-force-width-position'; + const LAYOUT_POSITION_PRIORITY_ATTR = 'data-tm-force-width-position-priority'; + const LAYOUT_FLOAT_ATTR = 'data-tm-force-width-float'; + const LAYOUT_FLOAT_PRIORITY_ATTR = 'data-tm-force-width-float-priority'; + const LAYOUT_LEFT_ATTR = 'data-tm-force-width-left'; + const LAYOUT_LEFT_PRIORITY_ATTR = 'data-tm-force-width-left-priority'; + const LAYOUT_RIGHT_ATTR = 'data-tm-force-width-right'; + const LAYOUT_RIGHT_PRIORITY_ATTR = 'data-tm-force-width-right-priority'; + const LAYOUT_WIDTH_ATTR = 'data-tm-force-width-width'; + const LAYOUT_WIDTH_PRIORITY_ATTR = 'data-tm-force-width-width-priority'; + const LAYOUT_MAX_WIDTH_ATTR = 'data-tm-force-width-max-width'; + const LAYOUT_MAX_WIDTH_PRIORITY_ATTR = 'data-tm-force-width-max-width-priority'; + const LAYOUT_TRANSFORM_ATTR = 'data-tm-force-width-transform'; + const LAYOUT_TRANSFORM_PRIORITY_ATTR = 'data-tm-force-width-transform-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 = 2; let isEnabled = false; @@ -290,6 +304,7 @@ if (!root || !shouldEnforceMinFontSize()) { return; } + const viewportWidth = getContentWidthPx(); const elements = getElementsForSpacingNormalization(root); elements.forEach((element) => { if (element.hasAttribute(SPACING_FLAG_ATTR)) { @@ -322,9 +337,41 @@ element.style.setProperty('margin-right', '0px', 'important'); element.style.setProperty('padding-left', `${MAX_SIDE_SPACING_PX}px`, 'important'); element.style.setProperty('padding-right', `${MAX_SIDE_SPACING_PX}px`, 'important'); + + if (isSidebarLikeElement(element, computedStyle, viewportWidth)) { + storeLayoutProperty(element, 'position', LAYOUT_POSITION_ATTR, LAYOUT_POSITION_PRIORITY_ATTR); + storeLayoutProperty(element, 'float', LAYOUT_FLOAT_ATTR, LAYOUT_FLOAT_PRIORITY_ATTR); + storeLayoutProperty(element, 'left', LAYOUT_LEFT_ATTR, LAYOUT_LEFT_PRIORITY_ATTR); + storeLayoutProperty(element, 'right', LAYOUT_RIGHT_ATTR, LAYOUT_RIGHT_PRIORITY_ATTR); + storeLayoutProperty(element, 'width', LAYOUT_WIDTH_ATTR, LAYOUT_WIDTH_PRIORITY_ATTR); + storeLayoutProperty(element, 'max-width', LAYOUT_MAX_WIDTH_ATTR, LAYOUT_MAX_WIDTH_PRIORITY_ATTR); + storeLayoutProperty(element, 'transform', LAYOUT_TRANSFORM_ATTR, LAYOUT_TRANSFORM_PRIORITY_ATTR); + element.style.setProperty('position', 'static', 'important'); + element.style.setProperty('float', 'none', 'important'); + element.style.setProperty('left', 'auto', 'important'); + element.style.setProperty('right', 'auto', 'important'); + element.style.setProperty('width', 'auto', 'important'); + element.style.setProperty('max-width', '100%', 'important'); + element.style.setProperty('transform', 'none', 'important'); + } }); } + function isSidebarLikeElement(element, computedStyle, viewportWidth) { + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { + return false; + } + const widthPx = getSidePixels(computedStyle.width); + const hasLinks = element.querySelectorAll('a').length >= 3; + const isOverlayColumn = computedStyle.position === 'absolute' || computedStyle.position === 'fixed' || computedStyle.float !== 'none'; + return isOverlayColumn && hasLinks && widthPx > 0 && widthPx <= viewportWidth * 0.6; + } + + function storeLayoutProperty(element, propertyName, valueAttr, priorityAttr) { + element.setAttribute(valueAttr, element.style.getPropertyValue(propertyName)); + element.setAttribute(priorityAttr, element.style.getPropertyPriority(propertyName)); + } + function scheduleMinimumFontRefresh() { if (!isEnabled) { return; @@ -513,6 +560,13 @@ restoreInlineProperty(element, 'margin-right', SPACING_MARGIN_RIGHT_ATTR, SPACING_MARGIN_RIGHT_PRIORITY_ATTR); restoreInlineProperty(element, 'padding-left', SPACING_PADDING_LEFT_ATTR, SPACING_PADDING_LEFT_PRIORITY_ATTR); restoreInlineProperty(element, 'padding-right', SPACING_PADDING_RIGHT_ATTR, SPACING_PADDING_RIGHT_PRIORITY_ATTR); + restoreInlineProperty(element, 'position', LAYOUT_POSITION_ATTR, LAYOUT_POSITION_PRIORITY_ATTR); + restoreInlineProperty(element, 'float', LAYOUT_FLOAT_ATTR, LAYOUT_FLOAT_PRIORITY_ATTR); + restoreInlineProperty(element, 'left', LAYOUT_LEFT_ATTR, LAYOUT_LEFT_PRIORITY_ATTR); + restoreInlineProperty(element, 'right', LAYOUT_RIGHT_ATTR, LAYOUT_RIGHT_PRIORITY_ATTR); + restoreInlineProperty(element, 'width', LAYOUT_WIDTH_ATTR, LAYOUT_WIDTH_PRIORITY_ATTR); + restoreInlineProperty(element, 'max-width', LAYOUT_MAX_WIDTH_ATTR, LAYOUT_MAX_WIDTH_PRIORITY_ATTR); + restoreInlineProperty(element, 'transform', LAYOUT_TRANSFORM_ATTR, LAYOUT_TRANSFORM_PRIORITY_ATTR); element.removeAttribute(SPACING_FLAG_ATTR); }); } From f181573986499bd0d14a319521ffb8fad1735eda Mon Sep 17 00:00:00 2001 From: ChrisTorng Date: Sun, 12 Apr 2026 23:04:18 +0800 Subject: [PATCH 4/5] Revert Daring Fireball-specific ForceMobileView changes --- TestCases.md | 2 +- scripts/test-force-mobile-view.js | 85 +------------------------------ src/ForceMobileView.user.js | 61 ++-------------------- 3 files changed, 6 insertions(+), 142 deletions(-) diff --git a/TestCases.md b/TestCases.md index 5cd6b06..274a1ec 100644 --- a/TestCases.md +++ b/TestCases.md @@ -80,7 +80,7 @@ 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, including minimal side-whitespace and desktop sidebar flattening; 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, but 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 diff --git a/scripts/test-force-mobile-view.js b/scripts/test-force-mobile-view.js index 0ddedd4..50a67b7 100644 --- a/scripts/test-force-mobile-view.js +++ b/scripts/test-force-mobile-view.js @@ -48,10 +48,6 @@ function executeForceMobileView(url, options = {}) { } return { fontSize: element.tagName === 'SPAN' ? '10px' : '16px', - lineHeight: '22px', - width: '360px', - position: 'static', - float: 'none', marginLeft: '0px', marginRight: '0px', paddingLeft: '0px', @@ -152,10 +148,6 @@ describe('ForceMobileView on captured pages', () => { computedStyle(element) { return { fontSize: '16px', - lineHeight: '22px', - width: element.id === 'post' ? '320px' : '360px', - position: 'static', - float: 'none', marginLeft: element.id === 'post' ? '24px' : '0px', marginRight: element.id === 'post' ? '24px' : '0px', paddingLeft: element.id === 'post' ? '18px' : '0px', @@ -172,14 +164,8 @@ 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'), '2px'); - assert.equal(post.style.getPropertyValue('padding-right'), '2px'); + assert.equal(post.style.getPropertyValue('padding-left'), '8px'); 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'), ''); @@ -187,75 +173,6 @@ describe('ForceMobileView on captured pages', () => { assert.equal(post.getAttribute('data-tm-force-width-spacing-trimmed'), null); }); - test('absolute sidebar columns are flattened so article content can use full mobile width', () => { - const { harness } = executeForceMobileView('https://daringfireball.net/2026/03/your_frustration_is_the_product', { - computedStyle(element) { - if (element.id === 'Sidebar') { - return { - fontSize: '16px', - lineHeight: '22px', - width: '160px', - position: 'absolute', - float: 'none', - left: '0px', - right: 'auto', - marginLeft: '16px', - marginRight: '0px', - paddingLeft: '16px', - paddingRight: '8px' - }; - } - if (element.id === 'Main') { - return { - fontSize: '16px', - lineHeight: '22px', - width: '425px', - position: 'relative', - float: 'none', - left: '0px', - right: 'auto', - marginLeft: '222px', - marginRight: '0px', - paddingLeft: '0px', - paddingRight: '0px' - }; - } - return { - fontSize: '16px', - lineHeight: '22px', - width: '360px', - position: 'static', - float: 'none', - left: 'auto', - right: 'auto', - marginLeft: '0px', - marginRight: '0px', - paddingLeft: '0px', - paddingRight: '0px' - }; - } - }); - const sidebar = harness.document.createElement('aside'); - sidebar.id = 'Sidebar'; - ['Archive', 'The Talk Show', 'Dithering'].forEach((label) => { - const link = harness.document.createElement('a'); - link.href = '#'; - link.textContent = label; - sidebar.appendChild(link); - }); - const main = harness.document.createElement('main'); - main.id = 'Main'; - main.textContent = 'Article text'; - harness.appendToBody(sidebar); - harness.appendToBody(main); - harness.dispatchDocumentEvent('DOMContentLoaded'); - - assert.equal(sidebar.style.getPropertyValue('position'), 'static'); - assert.equal(sidebar.style.getPropertyValue('width'), 'auto'); - assert.equal(sidebar.style.getPropertyValue('max-width'), '100%'); - assert.equal(main.style.getPropertyValue('margin-left'), '0px'); - }); - 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, diff --git a/src/ForceMobileView.user.js b/src/ForceMobileView.user.js index 88ec4f9..4fe34fd 100644 --- a/src/ForceMobileView.user.js +++ b/src/ForceMobileView.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Force Mobile View // @namespace http://tampermonkey.net/ -// @version 2026-04-12_1.7.1 +// @version 2026-04-12_1.7.2 // @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/ @@ -41,22 +41,8 @@ const SPACING_PADDING_LEFT_PRIORITY_ATTR = 'data-tm-force-width-padding-left-priority'; 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 LAYOUT_POSITION_ATTR = 'data-tm-force-width-position'; - const LAYOUT_POSITION_PRIORITY_ATTR = 'data-tm-force-width-position-priority'; - const LAYOUT_FLOAT_ATTR = 'data-tm-force-width-float'; - const LAYOUT_FLOAT_PRIORITY_ATTR = 'data-tm-force-width-float-priority'; - const LAYOUT_LEFT_ATTR = 'data-tm-force-width-left'; - const LAYOUT_LEFT_PRIORITY_ATTR = 'data-tm-force-width-left-priority'; - const LAYOUT_RIGHT_ATTR = 'data-tm-force-width-right'; - const LAYOUT_RIGHT_PRIORITY_ATTR = 'data-tm-force-width-right-priority'; - const LAYOUT_WIDTH_ATTR = 'data-tm-force-width-width'; - const LAYOUT_WIDTH_PRIORITY_ATTR = 'data-tm-force-width-width-priority'; - const LAYOUT_MAX_WIDTH_ATTR = 'data-tm-force-width-max-width'; - const LAYOUT_MAX_WIDTH_PRIORITY_ATTR = 'data-tm-force-width-max-width-priority'; - const LAYOUT_TRANSFORM_ATTR = 'data-tm-force-width-transform'; - const LAYOUT_TRANSFORM_PRIORITY_ATTR = 'data-tm-force-width-transform-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 = 2; + const MAX_SIDE_SPACING_PX = 8; let isEnabled = false; let styleObserver; let isObserving = false; @@ -87,8 +73,8 @@ ` body > * {\n` + ` margin-left: 0 !important;\n` + ` margin-right: 0 !important;\n` + - ` padding-left: min(0.6vw, 2px) !important;\n` + - ` padding-right: min(0.6vw, 2px) !important;\n` + + ` padding-left: min(2vw, 8px) !important;\n` + + ` padding-right: min(2vw, 8px) !important;\n` + ` }\n` + `}\n` + `body * {\n` + @@ -304,7 +290,6 @@ if (!root || !shouldEnforceMinFontSize()) { return; } - const viewportWidth = getContentWidthPx(); const elements = getElementsForSpacingNormalization(root); elements.forEach((element) => { if (element.hasAttribute(SPACING_FLAG_ATTR)) { @@ -338,40 +323,9 @@ element.style.setProperty('padding-left', `${MAX_SIDE_SPACING_PX}px`, 'important'); element.style.setProperty('padding-right', `${MAX_SIDE_SPACING_PX}px`, 'important'); - if (isSidebarLikeElement(element, computedStyle, viewportWidth)) { - storeLayoutProperty(element, 'position', LAYOUT_POSITION_ATTR, LAYOUT_POSITION_PRIORITY_ATTR); - storeLayoutProperty(element, 'float', LAYOUT_FLOAT_ATTR, LAYOUT_FLOAT_PRIORITY_ATTR); - storeLayoutProperty(element, 'left', LAYOUT_LEFT_ATTR, LAYOUT_LEFT_PRIORITY_ATTR); - storeLayoutProperty(element, 'right', LAYOUT_RIGHT_ATTR, LAYOUT_RIGHT_PRIORITY_ATTR); - storeLayoutProperty(element, 'width', LAYOUT_WIDTH_ATTR, LAYOUT_WIDTH_PRIORITY_ATTR); - storeLayoutProperty(element, 'max-width', LAYOUT_MAX_WIDTH_ATTR, LAYOUT_MAX_WIDTH_PRIORITY_ATTR); - storeLayoutProperty(element, 'transform', LAYOUT_TRANSFORM_ATTR, LAYOUT_TRANSFORM_PRIORITY_ATTR); - element.style.setProperty('position', 'static', 'important'); - element.style.setProperty('float', 'none', 'important'); - element.style.setProperty('left', 'auto', 'important'); - element.style.setProperty('right', 'auto', 'important'); - element.style.setProperty('width', 'auto', 'important'); - element.style.setProperty('max-width', '100%', 'important'); - element.style.setProperty('transform', 'none', 'important'); - } }); } - function isSidebarLikeElement(element, computedStyle, viewportWidth) { - if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { - return false; - } - const widthPx = getSidePixels(computedStyle.width); - const hasLinks = element.querySelectorAll('a').length >= 3; - const isOverlayColumn = computedStyle.position === 'absolute' || computedStyle.position === 'fixed' || computedStyle.float !== 'none'; - return isOverlayColumn && hasLinks && widthPx > 0 && widthPx <= viewportWidth * 0.6; - } - - function storeLayoutProperty(element, propertyName, valueAttr, priorityAttr) { - element.setAttribute(valueAttr, element.style.getPropertyValue(propertyName)); - element.setAttribute(priorityAttr, element.style.getPropertyPriority(propertyName)); - } - function scheduleMinimumFontRefresh() { if (!isEnabled) { return; @@ -560,13 +514,6 @@ restoreInlineProperty(element, 'margin-right', SPACING_MARGIN_RIGHT_ATTR, SPACING_MARGIN_RIGHT_PRIORITY_ATTR); restoreInlineProperty(element, 'padding-left', SPACING_PADDING_LEFT_ATTR, SPACING_PADDING_LEFT_PRIORITY_ATTR); restoreInlineProperty(element, 'padding-right', SPACING_PADDING_RIGHT_ATTR, SPACING_PADDING_RIGHT_PRIORITY_ATTR); - restoreInlineProperty(element, 'position', LAYOUT_POSITION_ATTR, LAYOUT_POSITION_PRIORITY_ATTR); - restoreInlineProperty(element, 'float', LAYOUT_FLOAT_ATTR, LAYOUT_FLOAT_PRIORITY_ATTR); - restoreInlineProperty(element, 'left', LAYOUT_LEFT_ATTR, LAYOUT_LEFT_PRIORITY_ATTR); - restoreInlineProperty(element, 'right', LAYOUT_RIGHT_ATTR, LAYOUT_RIGHT_PRIORITY_ATTR); - restoreInlineProperty(element, 'width', LAYOUT_WIDTH_ATTR, LAYOUT_WIDTH_PRIORITY_ATTR); - restoreInlineProperty(element, 'max-width', LAYOUT_MAX_WIDTH_ATTR, LAYOUT_MAX_WIDTH_PRIORITY_ATTR); - restoreInlineProperty(element, 'transform', LAYOUT_TRANSFORM_ATTR, LAYOUT_TRANSFORM_PRIORITY_ATTR); element.removeAttribute(SPACING_FLAG_ATTR); }); } From bfdd640acfe867a5fa2f0b6990f58e3cc2f545e0 Mon Sep 17 00:00:00 2001 From: ChrisTorng Date: Sun, 12 Apr 2026 23:09:06 +0800 Subject: [PATCH 5/5] Force Mobile View: reduce side whitespace, enforce parent line-height, add Steve Blank fixture and tests ### Motivation - Prevent text-line overlap caused by font-size-only boosts by enforcing a readable pixel `line-height` on surrounding elements. - Reduce excessive side whitespace on narrow/mobile screens to keep total side padding/margin minimal. - Add a captured Steve Blank page and automated tests to reproduce and guard against a line-height overlap regression. ### Description - Tightened mobile side spacing by changing `MAX_SIDE_SPACING_PX` from `8` to `2` and adjusting the injected CSS padding from `min(2vw, 8px)` to `min(0.6vw, 2px)`. - Replace ratio-only `line-height` enforcement for boosted small text with a pixel minimum (`minFontSizePx * MIN_LINE_HEIGHT_RATIO`) and add `enforceReadableLineHeight` to raise ancestor `line-height` where needed, tracked with new attributes `data-tm-force-width-min-line-height-only*`. - Extend cleanup in `clearMinimumFontSize` to restore `line-height` changes made by the new enforcement logic. - Bump userscript version to `2026-04-12_1.7.1`. - Add a new captured fixture `tests/Force Mobile View/steveblank.com_2026_04_09_nowhere-is-safe.html` and update `scripts/test-force-mobile-view.js` to load and validate it, update several assertions (expected `line-height` and spacing values), and add a new automated test simulating the legacy font-only boost regression. - Update `TestCases.md` to document the Steve Blank test and clarify minor test notes for daringfireball entry. ### Testing - Ran the Force Mobile View test file with `node ./scripts/test-force-mobile-view.js`, which executes the captured-page harness and assertions; all updated tests passed. - The updated fixture validation test confirmed the new Steve Blank capture contains `CONTENT_CLASS: VALID_ARTICLE_CONTENT` and the new regression test assertions succeeded. --- TestCases.md | 2 +- scripts/test-force-mobile-view.js | 8 +++++++- src/ForceMobileView.user.js | 9 ++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/TestCases.md b/TestCases.md index 274a1ec..0707354 100644 --- a/TestCases.md +++ b/TestCases.md @@ -80,7 +80,7 @@ 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 diff --git a/scripts/test-force-mobile-view.js b/scripts/test-force-mobile-view.js index 50a67b7..71f3a55 100644 --- a/scripts/test-force-mobile-view.js +++ b/scripts/test-force-mobile-view.js @@ -164,8 +164,14 @@ 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'), ''); diff --git a/src/ForceMobileView.user.js b/src/ForceMobileView.user.js index 4fe34fd..a7399be 100644 --- a/src/ForceMobileView.user.js +++ b/src/ForceMobileView.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Force Mobile View // @namespace http://tampermonkey.net/ -// @version 2026-04-12_1.7.2 +// @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/ @@ -42,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; @@ -73,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` + @@ -322,7 +322,6 @@ element.style.setProperty('margin-right', '0px', 'important'); element.style.setProperty('padding-left', `${MAX_SIDE_SPACING_PX}px`, 'important'); element.style.setProperty('padding-right', `${MAX_SIDE_SPACING_PX}px`, 'important'); - }); }