diff --git a/.cspell.json b/.cspell.json index c477c7f7..6863ea30 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,4 +1,4 @@ { "version": "0.2", - "words": ["devpool", "supabase"] + "words": ["devpool", "ratelimit", "supabase", "UBIQUIBOT"] } diff --git a/.env.example b/.env.example index f7321ea2..00f6613d 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ SUPABASE_URL= -SUPABASE_ANON_KEY= \ No newline at end of file +SUPABASE_ANON_KEY= +UBIQUIBOT_GITHUB_USERNAME= +UBIQUIBOT_GITHUB_PASSWORD= \ No newline at end of file diff --git a/cypress.config.ts b/cypress.config.ts index 3481949e..799dfe65 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -11,10 +11,21 @@ export default defineConfig({ }, viewportHeight: 900, viewportWidth: 1440, - env: { - GITHUB_USERNAME: process.env.UBIQUIBOT_GITHUB_USERNAME, - GITHUB_PASSWORD: process.env.UBIQUIBOT_GITHUB_PASSWORD, - }, + env: readEnvironmentVariables(), watchForFileChanges: false, video: true, }); + +function readEnvironmentVariables() { + const UBIQUIBOT_GITHUB_USERNAME = process.env["UBIQUIBOT_GITHUB_USERNAME"]; + const UBIQUIBOT_GITHUB_PASSWORD = process.env["UBIQUIBOT_GITHUB_PASSWORD"]; + + if (!UBIQUIBOT_GITHUB_USERNAME) { + throw new Error("Please provide `UBIQUIBOT_GITHUB_USERNAME` environment variable"); + } + + if (!UBIQUIBOT_GITHUB_PASSWORD) { + throw new Error("Please provide `UBIQUIBOT_GITHUB_PASSWORD` environment variable"); + } + return { UBIQUIBOT_GITHUB_USERNAME, UBIQUIBOT_GITHUB_PASSWORD }; +} diff --git a/cypress/e2e/devpool.cy.ts b/cypress/e2e/devpool.cy.ts index 1c9a1d3f..ca41f575 100644 --- a/cypress/e2e/devpool.cy.ts +++ b/cypress/e2e/devpool.cy.ts @@ -186,8 +186,11 @@ describe("DevPool", () => { cy.get("#filter").should("not.be.visible"); cy.get("#github-login-button").click(); cy.origin("https://github.com/login", () => { - cy.get("#login_field").type(Cypress.env("GITHUB_USERNAME")); - cy.get("#password").type(Cypress.env("GITHUB_PASSWORD")); + const username = Cypress.env("UBIQUIBOT_GITHUB_USERNAME"); + const password = Cypress.env("UBIQUIBOT_GITHUB_PASSWORD"); + + cy.get("#login_field").type(username); + cy.get("#password").type(password, { parseSpecialCharSequences: false }); cy.get(".position-relative > .btn").click(); // This part of the test can sometimes fail if the endpoint for OAuth is hit too many times, asking the user to // authorize the app again. It should not happen in a normal testing scenario since it's only hit once, but more @@ -205,4 +208,21 @@ describe("DevPool", () => { cy.get("#authenticated").should("exist"); cy.get("#filter").should("be.visible"); }); + + describe("Display error modal", () => { + it("should display an error modal when fetching issue previews fails on page load", () => { + cy.intercept("GET", "https://api.github.com/repos/ubiquity/devpool-directory/issues*", { + statusCode: 500, + body: "Internal Server Error", + }).as("getPublicIssues"); + + cy.visit("/"); + + cy.wait("@getPublicIssues"); + + cy.get(".preview-header").should("be.visible"); + cy.get(".preview-header").should("contain", "Something went wrong"); + cy.get(".preview-body-inner").should("contain", "HttpError: Internal Server Error"); + }); + }); }); diff --git a/package.json b/package.json index 8b4eaf4a..8a8b13b0 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "format:prettier": "prettier --write .", "prepare": "husky install", "cypress:open": "cypress open", - "cypress:run": "cypress run" + "cypress:run": "cypress run", + "cypress:headed": "cypress run --headed" }, "keywords": [ "typescript", diff --git a/src/home/fetch-github/fetch-avatar.ts b/src/home/fetch-github/fetch-avatar.ts index 98b7bb23..fbc2ad61 100644 --- a/src/home/fetch-github/fetch-avatar.ts +++ b/src/home/fetch-github/fetch-avatar.ts @@ -1,6 +1,7 @@ import { Octokit } from "@octokit/rest"; import { getGitHubAccessToken } from "../getters/get-github-access-token"; import { getImageFromCache, saveImageToCache } from "../getters/get-indexed-db"; +import { renderErrorInModal } from "../rendering/display-popup-modal"; import { organizationImageCache } from "./fetch-issues-full"; export async function fetchAvatar(orgName: string) { @@ -38,7 +39,7 @@ export async function fetchAvatar(orgName: string) { organizationImageCache.set(orgName, blob); } } catch (error) { - console.error(`Failed to fetch avatar for organization ${orgName}: ${error}`); + renderErrorInModal(error as Error, `Failed to fetch avatar for organization ${orgName}: ${error}`); const { data: { avatar_url: avatarUrl }, } = await octokit.rest.users.getByUsername({ username: orgName }); diff --git a/src/home/fetch-github/fetch-issues-preview.ts b/src/home/fetch-github/fetch-issues-preview.ts index fe75e99b..bc22653e 100644 --- a/src/home/fetch-github/fetch-issues-preview.ts +++ b/src/home/fetch-github/fetch-issues-preview.ts @@ -1,10 +1,11 @@ +import { RequestError } from "@octokit/request-error"; import { Octokit } from "@octokit/rest"; import { getGitHubAccessToken, getGitHubUserName } from "../getters/get-github-access-token"; import { GitHubIssue } from "../github-types"; import { displayPopupMessage } from "../rendering/display-popup-modal"; +import { gitHubLoginButton } from "../rendering/render-github-login-button"; +import { handleRateLimit } from "./handle-rate-limit"; import { TaskNoFull } from "./preview-to-full-mapping"; -import { getGitHubUser } from "../getters/get-github-user"; -import { RequestError } from "@octokit/request-error"; async function checkPrivateRepoAccess(): Promise { const octokit = new Octokit({ auth: await getGitHubAccessToken() }); @@ -58,20 +59,7 @@ export async function fetchIssuePreviews(): Promise { // Fetch issues from the private repository only if the user has access if (hasPrivateRepoAccess) { - const { data: privateResponse } = await octokit.issues.listForRepo({ - owner: "ubiquity", - repo: "devpool-directory-private", - state: "open", - }); - const privateIssues = privateResponse.filter((issue: GitHubIssue) => !issue.pull_request); - - // Mark private issues - const privateIssuesWithFlag = privateIssues.map((issue) => { - return issue; - }); - - // Combine public and private issues - freshIssues = [...privateIssuesWithFlag, ...publicIssues]; + await fetchPrivateIssues(publicIssues); } else { // If user doesn't have access, only load issues from the public repository freshIssues = publicIssues; @@ -80,7 +68,13 @@ export async function fetchIssuePreviews(): Promise { if (error instanceof RequestError && error.status === 403) { await handleRateLimit(octokit, error); } else { - console.error("Error fetching issue previews:", error); + // renderErrorInModal(error as Error, "You have been rate limited. Please login to increase your limits."); // @DEV: user another method to render the modal not as an error + displayPopupMessage({ + modalHeader: "GitHub API rate limit exceeded.", + modalBody: "You have been rate limited. Please login to increase your limits.", + isError: false, + }); + gitHubLoginButton?.classList.add("highlight"); } } @@ -92,45 +86,27 @@ export async function fetchIssuePreviews(): Promise { })) as TaskNoFull[]; return tasks; -} -function rateLimitModal(message: string) { - displayPopupMessage(`GitHub API rate limit exceeded.`, message); -} - -type RateLimit = { - reset: number | null; - user: boolean; -}; + async function fetchPrivateIssues(publicIssues: GitHubIssue[]) { + const { data: privateResponse } = await octokit.issues.listForRepo({ + owner: "ubiquity", + repo: "devpool-directory-private", + state: "open", + }); + const privateIssues = privateResponse.filter((issue: GitHubIssue) => !issue.pull_request); -export async function handleRateLimit(octokit?: Octokit, error?: RequestError) { - const rate: RateLimit = { - reset: null, - user: false, - }; + // Mark private issues + // TODO: indicate private issues in the UI - if (error?.response?.headers["x-ratelimit-reset"]) { - rate.reset = parseInt(error.response.headers["x-ratelimit-reset"]); - } + // const privateIssuesWithFlag = privateIssues.map((issue) => { + // return issue; + // }); - if (octokit) { - try { - const core = await octokit.rest.rateLimit.get(); - const remaining = core.data.resources.core.remaining; - const reset = core.data.resources.core.reset; - - rate.reset = !rate.reset && remaining === 0 ? reset : rate.reset; - rate.user = (await getGitHubUser()) ? true : false; - } catch (err) { - console.error("Error handling GitHub rate limit", err); - } + // Combine public and private issues + freshIssues = [...privateIssues, ...publicIssues]; } +} - const resetParsed = rate.reset && new Date(rate.reset * 1000).toLocaleTimeString(); - - if (!rate.user) { - rateLimitModal(`You have been rate limited. Please log in to GitHub to increase your GitHub API limits, otherwise you can try again at ${resetParsed}.`); - } else { - rateLimitModal(`You have been rate limited. Please try again at ${resetParsed}.`); - } +export function rateLimitModal(message: string) { + displayPopupMessage({ modalHeader: `GitHub API rate limit exceeded.`, modalBody: message, isError: false }); } diff --git a/src/home/fetch-github/handle-rate-limit.ts b/src/home/fetch-github/handle-rate-limit.ts new file mode 100644 index 00000000..62d2e104 --- /dev/null +++ b/src/home/fetch-github/handle-rate-limit.ts @@ -0,0 +1,42 @@ +import { RequestError } from "@octokit/request-error"; +import { Octokit } from "@octokit/rest"; +import { getGitHubUser } from "../getters/get-github-user"; +import { renderErrorInModal } from "../rendering/display-popup-modal"; +import { rateLimitModal } from "./fetch-issues-preview"; + +type RateLimit = { + reset: number | null; + user: boolean; +}; + +export async function handleRateLimit(octokit?: Octokit, error?: RequestError) { + const rate: RateLimit = { + reset: null, + user: false, + }; + + if (error?.response?.headers["x-ratelimit-reset"]) { + rate.reset = parseInt(error.response.headers["x-ratelimit-reset"]); + } + + if (octokit) { + try { + const core = await octokit.rest.rateLimit.get(); + const remaining = core.data.resources.core.remaining; + const reset = core.data.resources.core.reset; + + rate.reset = !rate.reset && remaining === 0 ? reset : rate.reset; + rate.user = (await getGitHubUser()) ? true : false; + } catch (err) { + renderErrorInModal(err as Error, "Error handling GitHub rate limit"); + } + } + + const resetParsed = rate.reset && new Date(rate.reset * 1000).toLocaleTimeString(); + + if (!rate.user) { + rateLimitModal(`You have been rate limited. Please log in to GitHub to increase your GitHub API limits, otherwise you can try again at ${resetParsed}.`); + } else { + rateLimitModal(`You have been rate limited. Please try again at ${resetParsed}.`); + } +} diff --git a/src/home/getters/get-github-user.ts b/src/home/getters/get-github-user.ts index e87e0621..b79a6968 100644 --- a/src/home/getters/get-github-user.ts +++ b/src/home/getters/get-github-user.ts @@ -1,9 +1,9 @@ +import { RequestError } from "@octokit/request-error"; import { Octokit } from "@octokit/rest"; +import { handleRateLimit } from "../fetch-github/handle-rate-limit"; import { GitHubUser, GitHubUserResponse } from "../github-types"; import { OAuthToken } from "./get-github-access-token"; import { getLocalStore } from "./get-local-store"; -import { handleRateLimit } from "../fetch-github/fetch-issues-preview"; -import { RequestError } from "@octokit/request-error"; declare const SUPABASE_STORAGE_KEY: string; // @DEV: passed in at build time check build/esbuild-build.ts export async function getGitHubUser(): Promise { @@ -44,6 +44,7 @@ async function getNewGitHubUser(providerToken: string | null): Promise renderErrorInModal(event.error)); + +// All unhandled promise rejections are caught and displayed in a modal +window.addEventListener("unhandledrejection", (event: PromiseRejectionEvent) => { + renderErrorInModal(event.reason as Error); + event.preventDefault(); +}); + initiateDevRelTracking(); generateSortingToolbar(); renderServiceMessage(); + grid(document.getElementById("grid") as HTMLElement, () => document.body.classList.add("grid-loaded")); // @DEV: display grid background const container = document.getElementById("issues-container") as HTMLDivElement; @@ -18,21 +29,14 @@ if (!container) { } export const taskManager = new TaskManager(container); -// window["taskManager"] = taskManager; void (async function home() { - try { - void authentication(); - void readyToolbar(); - const previews = await fetchAndDisplayPreviewsFromCache(); - const fullTasks = await fetchIssuesFull(previews); - taskManager.syncTasks(fullTasks); - console.trace({ fullTasks }); - await taskManager.writeToStorage(); - return fullTasks; - } catch (error) { - console.error(error); - } + void authentication(); + void readyToolbar(); + const previews = await fetchAndDisplayPreviewsFromCache(); + const fullTasks = await fetchIssuesFull(previews); + taskManager.syncTasks(fullTasks); + await taskManager.writeToStorage(); if ("serviceWorker" in navigator) { window.addEventListener("load", () => { @@ -46,6 +50,7 @@ void (async function home() { ); }); } + return fullTasks; })(); function renderServiceMessage() { diff --git a/src/home/rendering/display-github-user-information.ts b/src/home/rendering/display-github-user-information.ts index 398d3a5d..85975e7c 100644 --- a/src/home/rendering/display-github-user-information.ts +++ b/src/home/rendering/display-github-user-information.ts @@ -1,5 +1,6 @@ import { isOrgMemberWithoutScope } from "../getters/get-github-access-token"; import { GitHubUser } from "../github-types"; +import { renderErrorInModal } from "./display-popup-modal"; import { getSupabase, renderAugmentAccessButton } from "./render-github-login-button"; export async function displayGitHubUserInformation(gitHubUser: GitHubUser) { @@ -25,8 +26,8 @@ export async function displayGitHubUserInformation(gitHubUser: GitHubUser) { const supabase = getSupabase(); const { error } = await supabase.auth.signOut(); if (error) { - console.error("Error logging out:", error); - alert(error); + renderErrorInModal(error, "Error logging out"); + alert("Error logging out"); } window.location.reload(); }); diff --git a/src/home/rendering/display-popup-modal.ts b/src/home/rendering/display-popup-modal.ts index 2306fab6..71c76e44 100644 --- a/src/home/rendering/display-popup-modal.ts +++ b/src/home/rendering/display-popup-modal.ts @@ -1,12 +1,11 @@ import { toolbar } from "../ready-toolbar"; -import { gitHubLoginButton } from "./render-github-login-button"; import { preview, previewBodyInner, titleAnchor, titleHeader } from "./render-preview-modal"; -export function displayPopupMessage(header: string, message: string, url?: string) { - titleHeader.textContent = header; +export function displayPopupMessage({ modalHeader, modalBody, isError, url }: { modalHeader: string; modalBody: string; isError: boolean; url?: string }) { + titleHeader.textContent = modalHeader; if (url) { titleAnchor.href = url; } - previewBodyInner.innerHTML = message; + previewBodyInner.innerHTML = modalBody; preview.classList.add("active"); document.body.classList.add("preview-active"); @@ -16,7 +15,31 @@ export function displayPopupMessage(header: string, message: string, url?: strin left: toolbar.scrollWidth, behavior: "smooth", }); + } + + if (isError) { + preview.classList.add("error"); + } else { + preview.classList.remove("error"); + } + console.trace({ + modalHeader, + modalBody, + isError, + url, + }); +} - gitHubLoginButton?.classList.add("highlight"); +export function renderErrorInModal(error: Error, info?: string) { + if (info) { + console.error(error); + } else { + console.error(info ?? error.message); } + displayPopupMessage({ + modalHeader: error.name, + modalBody: info ?? error.message, + isError: true, + }); + return false; } diff --git a/src/home/rendering/render-github-issues.ts b/src/home/rendering/render-github-issues.ts index 2d585a59..e98c6a38 100644 --- a/src/home/rendering/render-github-issues.ts +++ b/src/home/rendering/render-github-issues.ts @@ -3,6 +3,7 @@ import { organizationImageCache } from "../fetch-github/fetch-issues-full"; import { TaskMaybeFull } from "../fetch-github/preview-to-full-mapping"; import { GitHubIssue } from "../github-types"; import { taskManager } from "../home"; +import { renderErrorInModal } from "./display-popup-modal"; import { preview, previewBodyInner, titleAnchor, titleHeader } from "./render-preview-modal"; import { setupKeyboardNavigation } from "./setup-keyboard-navigation"; @@ -81,23 +82,27 @@ function setUpIssueElement( )}${image}`; issueElement.addEventListener("click", () => { - const issueWrapper = issueElement.parentElement; + try { + const issueWrapper = issueElement.parentElement; - if (!issueWrapper) { - throw new Error("No issue container found"); - } + if (!issueWrapper) { + throw new Error("No issue container found"); + } - Array.from(issueWrapper.parentElement?.children || []).forEach((sibling) => { - sibling.classList.remove("selected"); - }); + Array.from(issueWrapper.parentElement?.children || []).forEach((sibling) => { + sibling.classList.remove("selected"); + }); - issueWrapper.classList.add("selected"); + issueWrapper.classList.add("selected"); - const full = task.full; - if (!full) { - window.open(match?.input, "_blank"); - } else { - previewIssue(task); + const full = task.full; + if (!full) { + window.open(match?.input, "_blank"); + } else { + previewIssue(task); + } + } catch (error) { + return renderErrorInModal(error as Error); } }); } @@ -174,6 +179,7 @@ export function viewIssueDetails(full: GitHubIssue) { // Show the preview preview.classList.add("active"); + preview.classList.remove("error"); document.body.classList.add("preview-active"); } diff --git a/src/home/rendering/render-github-login-button.ts b/src/home/rendering/render-github-login-button.ts index 2cec107d..f894509c 100644 --- a/src/home/rendering/render-github-login-button.ts +++ b/src/home/rendering/render-github-login-button.ts @@ -1,5 +1,6 @@ import { createClient } from "@supabase/supabase-js"; import { toolbar } from "../ready-toolbar"; +import { renderErrorInModal } from "./display-popup-modal"; declare const SUPABASE_URL: string; // @DEV: passed in at build time check build/esbuild-build.ts declare const SUPABASE_ANON_KEY: string; // @DEV: passed in at build time check build/esbuild-build.ts @@ -26,7 +27,7 @@ async function gitHubLoginButtonHandler(scopes = "public_repo read:org") { }, }); if (error) { - console.error("Error logging in:", error); + renderErrorInModal(error, "Error logging in"); } } diff --git a/src/home/rendering/render-preview-modal.ts b/src/home/rendering/render-preview-modal.ts index af5d46dc..7ffad85e 100644 --- a/src/home/rendering/render-preview-modal.ts +++ b/src/home/rendering/render-preview-modal.ts @@ -22,6 +22,13 @@ const openNewLinkIcon = ``; +const error = document.createElement("span"); +error.classList.add("error"); +error.innerHTML = errorIcon; + +titleAnchor.appendChild(error); titleAnchor.appendChild(openNewLink); previewHeader.appendChild(titleAnchor); previewBody.appendChild(previewBodyInner); diff --git a/src/home/sorting/sorting-manager.ts b/src/home/sorting/sorting-manager.ts index 0e0b5105..26106f4e 100644 --- a/src/home/sorting/sorting-manager.ts +++ b/src/home/sorting/sorting-manager.ts @@ -1,6 +1,7 @@ import { fetchAndDisplayPreviewsFromCache } from "../fetch-github/fetch-and-display-previews"; import { getGitHubAccessToken } from "../getters/get-github-access-token"; import { taskManager } from "../home"; +import { renderErrorInModal } from "../rendering/display-popup-modal"; import { Sorting } from "./generate-sorting-buttons"; export class SortingManager { @@ -39,18 +40,22 @@ export class SortingManager { const issuesContainer = document.getElementById("issues-container") as HTMLDivElement; textBox.addEventListener("input", () => { - const filterText = textBox.value.toLowerCase(); - const issues = Array.from(issuesContainer.children) as HTMLDivElement[]; - issues.forEach((issue) => { - const issuePreviewId = issue.children[0].getAttribute("data-preview-id"); - if (!issuePreviewId) throw new Error(`No preview id found for issue ${issue}`); - const fullIssue = taskManager.getTaskByPreviewId(Number(issuePreviewId)).full; - if (!fullIssue) throw new Error(`No full issue found for preview id ${issuePreviewId}`); - const searchableProperties = ["title", "body", "number", "html_url"] as const; - const searchableStrings = searchableProperties.map((prop) => fullIssue[prop]?.toString().toLowerCase()); - const isVisible = searchableStrings.some((str) => str?.includes(filterText)); - issue.style.display = isVisible ? "block" : "none"; - }); + try { + const filterText = textBox.value.toLowerCase(); + const issues = Array.from(issuesContainer.children) as HTMLDivElement[]; + issues.forEach((issue) => { + const issuePreviewId = issue.children[0].getAttribute("data-preview-id"); + if (!issuePreviewId) throw new Error(`No preview id found for issue ${issue}`); + const fullIssue = taskManager.getTaskByPreviewId(Number(issuePreviewId)).full; + if (!fullIssue) throw new Error(`No full issue found for preview id ${issuePreviewId}`); + const searchableProperties = ["title", "body", "number", "html_url"] as const; + const searchableStrings = searchableProperties.map((prop) => fullIssue[prop]?.toString().toLowerCase()); + const isVisible = searchableStrings.some((str) => str?.includes(filterText)); + issue.style.display = isVisible ? "block" : "none"; + }); + } catch (error) { + return renderErrorInModal(error as Error); + } }); return textBox; @@ -67,7 +72,13 @@ export class SortingManager { buttons.appendChild(input); buttons.appendChild(label); - input.addEventListener("click", () => this._handleSortingClick(input, option)); + input.addEventListener("click", () => { + try { + void this._handleSortingClick(input, option); + } catch (error) { + renderErrorCatch(error as ErrorEvent); + } + }); }); return buttons; @@ -103,8 +114,11 @@ export class SortingManager { input.setAttribute("data-ordering", ordering); // instantly load from cache - fetchAndDisplayPreviewsFromCache(option as Sorting, { ordering }).catch((error) => console.error(error)); - + try { + void fetchAndDisplayPreviewsFromCache(option as Sorting, { ordering }); + } catch (error) { + renderErrorCatch(error as ErrorEvent); + } // load from network in the background // const fetchedPreviews = await fetchIssuePreviews(); // const cachedTasks = taskManager.getTasks(); @@ -114,3 +128,7 @@ export class SortingManager { // return fetchAvatars(); } } + +function renderErrorCatch(event: ErrorEvent) { + return renderErrorInModal(event.error); +} diff --git a/static/style/inverted-style.css b/static/style/inverted-style.css index 7990a2e7..068879ad 100644 --- a/static/style/inverted-style.css +++ b/static/style/inverted-style.css @@ -466,12 +466,26 @@ .preview a { word-break: break-all; } - .preview a[href*="//"] .open-new-link svg + .preview .preview-header a[href*="//"] svg { fill: #00000080; vertical-align: middle; height: 20px; } + + .preview.active.error .preview-header > a { + pointer-events: none; + } + .preview.active.error .preview-header > a .open-new-link { + display: none; + } + + .preview.active.error > div > div.preview-header > a > span.error { + display: unset; + } + .preview.active > div > div.preview-header > a > span.error { + display: none; + } .preview-body-inner { line-height: 1.25; } diff --git a/static/style/style.css b/static/style/style.css index 942f24f8..69dc45a1 100644 --- a/static/style/style.css +++ b/static/style/style.css @@ -466,12 +466,26 @@ .preview a { word-break: break-all; } - .preview a[href*="//"] .open-new-link svg + .preview .preview-header a[href*="//"] svg { fill: #ffffff80; vertical-align: middle; height: 20px; } + + .preview.active.error .preview-header > a { + pointer-events: none; + } + .preview.active.error .preview-header > a .open-new-link { + display: none; + } + + .preview.active.error > div > div.preview-header > a > span.error { + display: unset; + } + .preview.active > div > div.preview-header > a > span.error { + display: none; + } .preview-body-inner { line-height: 1.25; }