Skip to content

Instantly share code, notes, and snippets.

@kiranwayne
Created March 5, 2026 05:16
Show Gist options
  • Select an option

  • Save kiranwayne/2268670ef926ac23edf2bbb6d95b4000 to your computer and use it in GitHub Desktop.

Select an option

Save kiranwayne/2268670ef926ac23edf2bbb6d95b4000 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Goodreads Enhanced
// @namespace https://www.goodreads.com/
// @version 2.5.0
// @description Width control UI for Goodreads home, book, and author pages with book reviews-focused expansion.
// @author You
// @match https://www.goodreads.com/
// @match https://www.goodreads.com/?*
// @match https://www.goodreads.com/book/show/*
// @match https://www.goodreads.com/author/show/*
// @updateURL https://gist.github.com/kiranwayne/2268670ef926ac23edf2bbb6d95b4000/raw/goodreads_enhanced.js
// @downloadURL https://gist.github.com/kiranwayne/2268670ef926ac23edf2bbb6d95b4000/raw/goodreads_enhanced.js
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'grWideLayoutSettingsV2';
const MIN_VIEWPORT = 1400;
const MAX_WIDE_MODE_SIDE_GAP = 24; // "max width" from previous version
const defaultSettings = {
slider: 100,
useDefaultWidth: false,
panelVisible: false,
panelX: 20,
panelY: 80
};
const settings = loadSettings();
let uiStyleEl = null;
let layoutStyleEl = null;
let panelEl = null;
let toggleBtnEl = null;
let sliderEl = null;
let checkboxEl = null;
let sliderValueEl = null;
let metricsEl = null;
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...defaultSettings };
const parsed = JSON.parse(raw);
return {
...defaultSettings,
...parsed,
slider: clampNumber(parsed.slider, 0, 100, defaultSettings.slider),
panelX: clampNumber(parsed.panelX, 0, 99999, defaultSettings.panelX),
panelY: clampNumber(parsed.panelY, 0, 99999, defaultSettings.panelY)
};
} catch {
return { ...defaultSettings };
}
}
function saveSettings() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// ignore storage failures
}
}
function clampNumber(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function ensureStyles() {
if (!uiStyleEl) {
uiStyleEl = document.createElement('style');
uiStyleEl.id = 'grwl-ui-style';
uiStyleEl.textContent = `
#grwl-toggle-btn {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 2147483646;
border: 1px solid rgba(255,255,255,0.35);
border-radius: 8px;
background: rgba(15, 15, 18, 0.78);
color: #f5f5f5;
padding: 8px 12px;
font: 600 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
cursor: pointer;
backdrop-filter: blur(6px);
}
#grwl-panel {
position: fixed;
z-index: 2147483647;
width: 320px;
color: #f2f2f2;
border: 1px solid rgba(255,255,255,0.24);
border-radius: 10px;
overflow: hidden;
background: rgba(20, 21, 24, 0.76);
backdrop-filter: blur(10px);
box-shadow: 0 12px 36px rgba(0,0,0,0.45);
font: 13px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
}
#grwl-panel.grwl-hidden {
display: none;
}
#grwl-panel-header {
padding: 9px 12px;
font-weight: 700;
letter-spacing: 0.2px;
background: rgba(45, 47, 54, 0.8);
border-bottom: 1px solid rgba(255,255,255,0.14);
cursor: move;
user-select: none;
}
#grwl-panel-body {
padding: 12px;
}
.grwl-row + .grwl-row {
margin-top: 10px;
}
.grwl-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
}
#grwl-slider {
width: 100%;
}
#grwl-slider-value {
font-weight: 700;
color: #f4d58a;
}
#grwl-metrics {
color: rgba(255,255,255,0.82);
font-size: 12px;
}
.grwl-endpoints {
display: flex;
justify-content: space-between;
color: rgba(255,255,255,0.62);
font-size: 11px;
margin-top: 2px;
}
`;
document.documentElement.appendChild(uiStyleEl);
}
if (!layoutStyleEl) {
layoutStyleEl = document.createElement('style');
layoutStyleEl.id = 'grwl-layout-style';
document.documentElement.appendChild(layoutStyleEl);
}
}
function buildLayoutCss(sideGapPx) {
return `
@media (min-width: ${MIN_VIEWPORT}px) {
.PageFrame,
.siteHeader__contents,
main,
.mainContent,
.mainContentContainer,
.gr-mainContentContainer {
max-width: none !important;
}
.PageFrame,
.siteHeader__contents,
main,
.mainContent {
width: calc(100vw - ${sideGapPx * 2}px) !important;
margin-left: ${sideGapPx}px !important;
margin-right: ${sideGapPx}px !important;
}
/* Goodreads book/review pages use centered PageFrame wrapper */
main.PageFrame__main {
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
/* ── Book + Review pages: expand the outer PageFrame ── */
html.grwl-page-book .PageFrame {
width: calc(100vw - ${sideGapPx * 2}px) !important;
max-width: none !important;
margin-left: ${sideGapPx}px !important;
margin-right: ${sideGapPx}px !important;
}
html.grwl-page-book main.PageFrame__main {
display: block !important;
width: 100% !important;
max-width: none !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
/* ── Book Show page: override the fixed CSS-grid container ── */
html.grwl-page-book-show .BookPage__gridContainer {
display: flex !important;
width: 100% !important;
max-width: none !important;
gap: 16px !important;
grid-template-columns: none !important;
}
html.grwl-page-book-show .BookPage__leftColumn {
flex: 0 0 297px !important;
width: 297px !important;
min-width: 297px !important;
max-width: 297px !important;
margin: 0 !important;
grid-column: unset !important;
}
html.grwl-page-book-show .BookPage__bookCover {
width: 208px !important;
min-width: 208px !important;
max-width: 208px !important;
margin-left: auto !important;
margin-right: auto !important;
}
html.grwl-page-book-show .BookPage__bookCover img {
width: 100% !important;
max-width: 208px !important;
height: auto !important;
}
html.grwl-page-book-show .BookPage__rightColumn {
flex: 1 1 auto !important;
width: auto !important;
max-width: none !important;
min-width: 0 !important;
margin: 0 !important;
padding-left: 0 !important;
grid-column: unset !important;
}
html.grwl-page-book-show #ReviewsSection,
html.grwl-page-book-show .ReviewsSection,
html.grwl-page-book-show .ReviewsList {
width: 100% !important;
max-width: none !important;
}
/* Keep top review-area card covers from scaling with slider width */
html.grwl-page-book-show .CarouselGroup__item--4-col {
flex: 0 0 195px !important;
width: 195px !important;
max-width: 195px !important;
}
html.grwl-page-book-show .BookCard .BookCover {
width: 195px !important;
max-width: 195px !important;
}
html.grwl-page-book-show .BookCard .BookCover .ResponsiveImage {
width: 195px !important;
max-width: 195px !important;
height: auto !important;
}
/* ── Book Reviews page: override the fixed CSS-grid container ── */
html.grwl-page-book-reviews .BookReviewsPage__gridContainer {
display: flex !important;
width: 100% !important;
max-width: none !important;
gap: 16px !important;
grid-template-columns: none !important;
}
html.grwl-page-book-reviews .BookReviewsPage__leftColumn {
flex: 0 0 291px !important;
width: 291px !important;
min-width: 291px !important;
max-width: 291px !important;
margin: 0 !important;
grid-column: unset !important;
}
html.grwl-page-book-reviews .BookReviewsPage__rightColumn {
flex: 1 1 auto !important;
width: auto !important;
max-width: none !important;
min-width: 0 !important;
margin: 0 !important;
grid-column: unset !important;
}
html.grwl-page-book-reviews .ReviewsList {
width: 100% !important;
max-width: none !important;
}
/* Legacy Goodreads author pages */
html.grwl-page-author .mainContent > .mainContentFloat {
width: 100% !important;
max-width: none !important;
float: none !important;
margin: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
box-sizing: border-box !important;
}
html.grwl-page-author .mainContent .reverseColumnSizes {
width: 100% !important;
max-width: none !important;
display: grid !important;
grid-template-columns: minmax(280px, 340px) minmax(680px, 1fr) !important;
column-gap: 20px !important;
align-items: start !important;
}
html.grwl-page-author .mainContent .reverseColumnSizes > .leftContainer {
grid-column: 1 !important;
}
html.grwl-page-author .mainContent .reverseColumnSizes > .rightContainer {
grid-column: 2 !important;
}
html.grwl-page-author .mainContent .leftContainer,
html.grwl-page-author .mainContent .rightContainer {
float: none !important;
width: auto !important;
min-width: 0 !important;
margin: 0 !important;
}
/* Goodreads home page uses floated 3-column layout; convert to responsive grid */
body.gr-homePageBody main {
display: grid !important;
grid-template-columns: minmax(260px, 300px) minmax(720px, 1fr) minmax(260px, 300px) !important;
column-gap: 20px !important;
align-items: start !important;
}
body.gr-homePageBody main::after {
content: none !important;
display: none !important;
}
body.gr-homePageBody main > .homeSecondaryColumn,
body.gr-homePageBody main > .homePrimaryColumn,
body.gr-homePageBody main > .homeTertiaryColumn {
float: none !important;
width: auto !important;
margin: 12px 0 0 0 !important;
}
body.gr-homePageBody main > .homePrimaryColumn {
min-width: 0 !important;
}
}
`;
}
function updatePageTypeClasses() {
const html = document.documentElement;
if (!html) return;
html.classList.remove('grwl-page-author', 'grwl-page-book', 'grwl-page-book-show', 'grwl-page-book-reviews');
const path = location.pathname || '';
if (path.startsWith('/author/show/')) {
html.classList.add('grwl-page-author');
}
if (path.startsWith('/book/show/')) {
html.classList.add('grwl-page-book');
if (path.includes('/reviews')) {
html.classList.add('grwl-page-book-reviews');
} else {
html.classList.add('grwl-page-book-show');
}
}
}
function getElementSideGap(el) {
if (!el) return null;
const rect = el.getBoundingClientRect();
if (!rect || rect.width < 320) return null;
const leftGap = rect.left;
const rightGap = window.innerWidth - rect.right;
return Math.max(0, Math.round(Math.min(leftGap, rightGap)));
}
function measureDefaultSideGap() {
const previousCss = layoutStyleEl ? layoutStyleEl.textContent : '';
if (layoutStyleEl) {
layoutStyleEl.textContent = '';
}
const candidates = [
['.PageFrame', document.querySelector('.PageFrame')],
['main', document.querySelector('main')],
['.siteHeader__contents', document.querySelector('.siteHeader__contents')],
['.mainContent', document.querySelector('.mainContent')],
['.gr-mainContentContainer', document.querySelector('.gr-mainContentContainer')]
];
const measured = [];
for (const [name, el] of candidates) {
const gap = getElementSideGap(el);
if (gap == null) continue;
measured.push({ name, gap });
}
if (layoutStyleEl) {
layoutStyleEl.textContent = previousCss;
}
if (measured.length === 0) return null;
// Prefer a truly constrained centered container when available.
const constrained = measured.find((m) => m.gap >= 40);
return (constrained || measured[0]).gap;
}
function computeSideGap(defaultGap) {
const sliderRatio = clampNumber(settings.slider, 0, 100, 100) / 100;
const minGap = Math.min(MAX_WIDE_MODE_SIDE_GAP, defaultGap);
const customGap = Math.round(defaultGap - ((defaultGap - minGap) * sliderRatio));
return {
minGap,
customGap
};
}
function applyLayout() {
if (!layoutStyleEl) return;
updatePageTypeClasses();
if (window.innerWidth < MIN_VIEWPORT) {
layoutStyleEl.textContent = '';
updateMetrics(null, null, null, 'Viewport too small (layout untouched).');
return;
}
const defaultGap = measureDefaultSideGap();
if (defaultGap == null) {
layoutStyleEl.textContent = '';
updateMetrics(null, null, null, 'Main content not found yet.');
return;
}
const { minGap, customGap } = computeSideGap(defaultGap);
const useDefaultNow = settings.useDefaultWidth || settings.slider <= 0 || customGap >= defaultGap;
if (useDefaultNow) {
layoutStyleEl.textContent = '';
updateMetrics(defaultGap, defaultGap, minGap, 'Using Goodreads default width');
} else {
layoutStyleEl.textContent = buildLayoutCss(customGap);
updateMetrics(defaultGap, customGap, minGap, 'Custom width active');
}
updateSliderReadout(defaultGap, minGap);
}
function updateSliderReadout(defaultGap, minGap) {
if (!sliderValueEl) return;
const pct = Math.round(clampNumber(settings.slider, 0, 100, 100));
sliderValueEl.textContent = settings.useDefaultWidth ? 'Default' : `${pct}% wider`;
if (sliderEl) {
sliderEl.disabled = !!settings.useDefaultWidth;
sliderEl.title = `Default gap: ${defaultGap ?? '?'}px, Wide mode min gap: ${minGap ?? '?'}px`;
}
}
function updateMetrics(defaultGap, currentGap, minGap, status) {
if (!metricsEl) return;
if (defaultGap == null || currentGap == null) {
metricsEl.textContent = status || '';
return;
}
const defaultWidth = Math.max(0, window.innerWidth - (defaultGap * 2));
const currentWidth = Math.max(0, window.innerWidth - (currentGap * 2));
metricsEl.textContent = `${status} • default ${defaultWidth}px, current ${currentWidth}px • gap ${currentGap}px (min ${minGap}px)`;
}
function setPanelVisibility(visible) {
settings.panelVisible = !!visible;
if (panelEl) {
panelEl.classList.toggle('grwl-hidden', !settings.panelVisible);
}
if (toggleBtnEl) {
toggleBtnEl.textContent = settings.panelVisible ? 'Hide Settings' : 'Show Settings';
}
saveSettings();
}
function setPanelPosition(x, y) {
settings.panelX = clampNumber(x, 0, Math.max(0, window.innerWidth - 80), defaultSettings.panelX);
settings.panelY = clampNumber(y, 0, Math.max(0, window.innerHeight - 40), defaultSettings.panelY);
if (panelEl) {
panelEl.style.left = `${settings.panelX}px`;
panelEl.style.top = `${settings.panelY}px`;
}
}
function setupDragging(headerEl) {
let dragging = false;
let dx = 0;
let dy = 0;
headerEl.addEventListener('pointerdown', (event) => {
dragging = true;
headerEl.setPointerCapture(event.pointerId);
const rect = panelEl.getBoundingClientRect();
dx = event.clientX - rect.left;
dy = event.clientY - rect.top;
});
headerEl.addEventListener('pointermove', (event) => {
if (!dragging) return;
setPanelPosition(event.clientX - dx, event.clientY - dy);
});
function stopDrag(event) {
if (!dragging) return;
dragging = false;
try {
headerEl.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
saveSettings();
}
headerEl.addEventListener('pointerup', stopDrag);
headerEl.addEventListener('pointercancel', stopDrag);
}
function createUI() {
if (toggleBtnEl || panelEl) return;
toggleBtnEl = document.createElement('button');
toggleBtnEl.id = 'grwl-toggle-btn';
toggleBtnEl.type = 'button';
toggleBtnEl.textContent = settings.panelVisible ? 'Hide Settings' : 'Show Settings';
toggleBtnEl.addEventListener('click', () => setPanelVisibility(!settings.panelVisible));
panelEl = document.createElement('div');
panelEl.id = 'grwl-panel';
if (!settings.panelVisible) {
panelEl.classList.add('grwl-hidden');
}
panelEl.innerHTML = `
<div id="grwl-panel-header">Goodreads Width Settings</div>
<div id="grwl-panel-body">
<div class="grwl-row">
<label class="grwl-checkbox-label">
<input id="grwl-default-checkbox" type="checkbox">
<span>Use Goodreads default width</span>
</label>
</div>
<div class="grwl-row">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
<span>Main content width</span>
<span id="grwl-slider-value"></span>
</div>
<input id="grwl-slider" type="range" min="0" max="100" step="1">
<div class="grwl-endpoints"><span>Default</span><span>Max (wide mode)</span></div>
</div>
<div class="grwl-row" id="grwl-metrics"></div>
</div>
`;
checkboxEl = panelEl.querySelector('#grwl-default-checkbox');
sliderEl = panelEl.querySelector('#grwl-slider');
sliderValueEl = panelEl.querySelector('#grwl-slider-value');
metricsEl = panelEl.querySelector('#grwl-metrics');
checkboxEl.checked = !!settings.useDefaultWidth;
sliderEl.value = String(clampNumber(settings.slider, 0, 100, 100));
checkboxEl.addEventListener('change', () => {
settings.useDefaultWidth = checkboxEl.checked;
saveSettings();
applyLayout();
});
sliderEl.addEventListener('input', () => {
settings.slider = clampNumber(sliderEl.value, 0, 100, 100);
saveSettings();
applyLayout();
});
setPanelPosition(settings.panelX, settings.panelY);
setupDragging(panelEl.querySelector('#grwl-panel-header'));
document.documentElement.appendChild(toggleBtnEl);
document.documentElement.appendChild(panelEl);
}
function init() {
ensureStyles();
createUI();
applyLayout();
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
setPanelPosition(settings.panelX, settings.panelY);
applyLayout();
}, 120);
});
// Apply again after late page render/layout shifts.
setTimeout(applyLayout, 300);
setTimeout(applyLayout, 1200);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment