From e3dfc456a64a270422265afe9b5a03ef121e01fc Mon Sep 17 00:00:00 2001 From: Tsuni Date: Wed, 12 Jun 2024 22:32:32 -0500 Subject: [PATCH] refactor: several changes - Move getConfig out of popups - Change some default settings - Format OpenAI response as list - Improve image sizing - Run formatter on contents file - Remove extra logging from background - Add actual error handling in background --- src/background/index.chrome.ts | 24 ++- src/background/index.firefox.ts | 20 ++- src/background/parsing.ts | 24 +-- src/background/services/github.ts | 3 - src/contents/index.tsx | 237 ++++++++++++++++++------------ src/contents/styles.css | 15 +- src/defaults.ts | 28 +++- src/popup/index.tsx | 108 +++++++------- src/popup/styles.css | 15 +- 9 files changed, 257 insertions(+), 217 deletions(-) diff --git a/src/background/index.chrome.ts b/src/background/index.chrome.ts index f55c27b..da931d3 100644 --- a/src/background/index.chrome.ts +++ b/src/background/index.chrome.ts @@ -3,17 +3,25 @@ import { installedHandler } from './background'; import { parseAndReply } from './parsing'; const scrapeHandler = async ({ url }, res: (response?: any) => void) => { - const resp = await fetch(url) - const html = await resp.text(); - const doc = new DOMParser().parseFromString(html, 'text/html'); - // @ts-expect-error - linkedom's document is FAKE and missing lots of properties, but we don't care because we don't use them :) - await parseAndReply(doc, url, res) + try { + const resp = await fetch(url) + const html = await resp.text(); + const doc = new DOMParser().parseFromString(html, 'text/html'); + // @ts-expect-error - linkedom's document is FAKE and missing lots of properties, but we don't care because we don't use them :) + await parseAndReply(doc, url, res) + } catch (err) { + res({ error: err.message }) + } } const parseHTMLHandler = async ({ html, url }, res: (response?: any) => void) => { - const doc = new DOMParser().parseFromString(html, 'text/html'); - // @ts-expect-error - see above - await parseAndReply(doc, url, res) + try { + const doc = new DOMParser().parseFromString(html, 'text/html'); + // @ts-expect-error - see above + await parseAndReply(doc, url, res) + } catch (err) { + res({ error: err.message }) + } } const messageHandler = (req: any, sender, res: (response?: any) => void) => { diff --git a/src/background/index.firefox.ts b/src/background/index.firefox.ts index 237ee6f..76e786b 100644 --- a/src/background/index.firefox.ts +++ b/src/background/index.firefox.ts @@ -2,15 +2,23 @@ import { installedHandler } from "./background"; import { parseAndReply } from "./parsing"; const scrapeHandler = async ({ url }, res: (response?: any) => void) => { - const resp = await fetch(url) - const html = await resp.text(); - const doc = new DOMParser().parseFromString(html, "text/html") - await parseAndReply(doc, url, res) + try { + const resp = await fetch(url) + const html = await resp.text(); + const doc = new DOMParser().parseFromString(html, "text/html") + await parseAndReply(doc, url, res) + } catch (err) { + res({ error: err.message }) + } } const parseHTMLHandler = async ({ html, url }, res: (response?: any) => void) => { - const doc = new DOMParser().parseFromString(html, "text/html") - await parseAndReply(doc, url, res) + try { + const doc = new DOMParser().parseFromString(html, "text/html") + await parseAndReply(doc, url, res) + } catch (err) { + res({ error: err.message }) + } } const messageHandler = (req: any, sender, res: (response?: any) => void) => { diff --git a/src/background/parsing.ts b/src/background/parsing.ts index 3f867f3..a479c09 100644 --- a/src/background/parsing.ts +++ b/src/background/parsing.ts @@ -13,9 +13,12 @@ const parseHTMLMeta = (doc: Document, url: string) => { doc.querySelector('title')?.textContent; const description = (doc.querySelector('meta[property="og:description"]') as HTMLMetaElement)?.content || (doc.querySelector('meta[name="description"]') as HTMLMetaElement)?.content; - const imageUrl = (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement)?.content || + let imageUrl = (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement)?.content || (doc.querySelector('meta[property="og:image:url"]') as HTMLMetaElement)?.content || - getFirstImage(doc, url); + (doc.querySelector('img') as HTMLImageElement)?.src; + if (!imageUrl.startsWith("http")) { + imageUrl = new URL(imageUrl, url).href; + } return { title, description, @@ -29,23 +32,6 @@ const addBaseElement = (doc: Document, url: string) => { doc.head.append(baseEl); } -const getFirstImage = (doc: Document, url: string) => { - // If we don't have an image handed to us, take the first image in the body of the page - const img = doc.querySelector('img'); - - if (img) { - var imgObj: { url: string, width?: string, height?: string } = { url: '' }; - - const src = (img as HTMLImageElement).src; - // The src might be relative, so we need to convert it to an absolute URL - if (src && src.startsWith('http')) { - return src; - } else { - return new URL(src, url).href; - } - } -} - const parseReadability = async (doc: Document) => { const documentClone = doc.cloneNode(true); // @ts-ignore - Readability wants a document and we're giving it a node, but it doesn't actually matter diff --git a/src/background/services/github.ts b/src/background/services/github.ts index 4942682..f063ccd 100644 --- a/src/background/services/github.ts +++ b/src/background/services/github.ts @@ -8,12 +8,9 @@ const GithubParser: Parser = { return path.length >= 3 }, parse: async (node: Node, url: string) => { - console.log("Parsing Github") const path = new URL(url).pathname.split("/") // ["", "user", "repo", ...] const data = await fetch(`https://api.github.com/repos/${path[1]}/${path[2]}/readme`).then(res => res.json()) - console.log(data) const decoded = atob(data.content) - console.log(decoded) return { body: `URL: ${url}\nREADME: ${decoded}`, siteName: "Github", diff --git a/src/contents/index.tsx b/src/contents/index.tsx index 44f934e..a248d5e 100644 --- a/src/contents/index.tsx +++ b/src/contents/index.tsx @@ -1,41 +1,25 @@ import cssText from "data-text:~contents/styles.css" import OpenAI from "openai" import type { ChatCompletionMessageParam } from "openai/resources" +import type { PlasmoCSConfig } from "plasmo" import { useEffect, useRef, useState } from "react" -import { Storage } from "@plasmohq/storage" -import type { PlasmoCSConfig } from "plasmo" -import { defaultSettings } from "~defaults" +import { getConfig } from "~defaults" export const config: PlasmoCSConfig = { - matches: [""], - exclude_matches: ["*://*.wikipedia.com/*"], - css: ["./global.css"], - } - + matches: [""], + exclude_matches: ["*://*.wikipedia.com/*"], + css: ["./global.css"] +} + export const getStyle = () => { const style = document.createElement("style") style.textContent = cssText return style } -const settings = new Storage() - -const getConfig = async () => { - // Grab our config - const config = { - apiKey: await settings.get("openai-key"), - baseURL: (await settings.get("openai-baseURL")) || defaultSettings.baseURL, - model: (await settings.get("openai-model")) || defaultSettings.model, - prompt: (await settings.get("system-prompt")) || defaultSettings.prompt, - inputTokens: (parseInt(await settings.get("input-tokens"))) || defaultSettings.inputTokens, - outputTokens: (parseInt(await settings.get("output-tokens"))) || defaultSettings.outputTokens, - aiThreshold: (parseInt(await settings.get("ai-threshold"))) || defaultSettings.aiThreshold, - } - return config -} - -chrome.runtime.onMessage.addListener((msg, sender, response) => { // Get HTML of current page +chrome.runtime.onMessage.addListener((msg, sender, response) => { + // Get HTML of current page if (msg.name === "DOMInfo") { try { response({ html: document.documentElement.outerHTML }) @@ -57,12 +41,15 @@ const ContentPopup = () => { right?: number bottom?: number }) - const [animationState, setAnimationState] = useState("closed" as "closed" | "opening" | "open" | "closing") + const [animationState, setAnimationState] = useState( + "closed" as "closed" | "opening" | "open" | "closing" + ) const [title, setTitle] = useState("") const [publisher, setPublisher] = useState("") const [imageUrl, setImageUrl] = useState("") const [description, setDescription] = useState("") const [aiSummary, setSummary] = useState("") + const [imageType, setImageType] = useState("image-cover") const imageRef = useRef(null) const resetState = async () => { @@ -86,7 +73,8 @@ const ContentPopup = () => { * Sets the animation state to "open" when the image has loaded. This is to prevent the popup from opening before the image is ready. */ const imageLoaded = () => { - setAnimationState((current) => current == "opening" ? "open" : current) + setAnimationState((current) => (current == "opening" ? "open" : current)) + setImageType(getImageType()) } const closePopup = async () => { @@ -96,14 +84,18 @@ const ContentPopup = () => { // Case #3: it can't interfere with itself, so we need to assume that "closing" means another instance already set the timeout setAnimationState((current) => { if (current == "opening" || current == "open") { - setTimeout(() => setAnimationState((current) => { - return (current == "closing") ? "closed" : current - }), 300) + setTimeout( + () => + setAnimationState((current) => { + return current == "closing" ? "closed" : current + }), + 300 + ) return "closing" } return current }) - await new Promise(r => setTimeout(r, 300)); + await new Promise((r) => setTimeout(r, 300)) } /** @@ -118,7 +110,7 @@ const ContentPopup = () => { bounds.top < window.innerHeight / 2 ? { top: bounds.bottom } : { bottom: window.innerHeight - bounds.top } - const horizontal = + const horizontal = bounds.left + WIDTH < window.innerWidth ? { left: bounds.left } : { right: 0 } @@ -141,26 +133,44 @@ const ContentPopup = () => { if (!url || url.startsWith("#") || url.startsWith("javascript")) { throw new Error("Invalid URL") } - if (url.startsWith("/")) { - url = window.location.origin + url + if (!url.startsWith("http")) { + url = new URL(url, window.location.href).href } return url } - /** - * Uses the OpenAI API to generate a more extensive summary for the given content. Output is appended to the description state. - * @param tagData The data to generate a summary for - */ - const getOAIData = async (tagData: { title: string; description: string; body: string; }) => { + const getOAIData = async ( + tagData: { + title: string + description: string + body: string + }, + output: (value: React.SetStateAction) => void, + context?: string + ) => { const config = await getConfig() - if (!tagData.body) { return } // Not much to summarize, innit? - if (!config.apiKey) { return } // Skip if we don't have an API key - if (tagData.description && tagData.description.length > config.aiThreshold) { return } // Skip if the description is long enough already + if (!tagData.body) { + return + } // Not much to summarize, innit? + if (!config.apiKey) { + return + } // Skip if we don't have an API key + if ( + tagData.description && + tagData.description.length > config.aiThreshold + ) { + return + } // Skip if the description is long enough already // Maybe the text of the link is ambiguous and the user wants to know how the content relates - const linkText = popupTarget.textContent || "Unknown" const messages = [ { role: "system", content: config.prompt }, - { role: "user", content: `Context: "${linkText}"\nContent: "${tagData.body.slice(0, config.inputTokens * 3)}[...]\nSummary:"` }, + { + role: "user", + content: + (context ? `# Context\n${context}\n` : "") + + `# Content\n${tagData.body.slice(0, config.inputTokens * 3)}[...]\n` + + `# Summary` + } ] as ChatCompletionMessageParam[] const openai = new OpenAI({ apiKey: config.apiKey, @@ -172,15 +182,20 @@ const ContentPopup = () => { model: config.model, messages: messages, stream: true, - max_tokens: config.outputTokens, + max_tokens: config.outputTokens }) for await (const chunk of stream) { if (!chunk.choices[0].delta) continue - setSummary((prev) => prev + (chunk.choices[0].delta.content || "")) + output((prev) => prev + (chunk.choices[0].delta.content || "")) } } - const renderTagPopup = (tagData: { title: any; description: string; imageUrl: string; siteName: string }) => { + const renderTagPopup = (tagData: { + title: any + description: string + imageUrl: string + siteName: string + }) => { if (!tagData.title && !tagData.description) { throw new Error("No data found") } @@ -201,9 +216,18 @@ const ContentPopup = () => { try { const url = getURL() // Plasmo doesn't convert `chrome` to `browser` when this component is used in a tab page - const tagData = (process.env.PLASMO_BROWSER == "firefox") ? - await browser.runtime.sendMessage({ name: "scrape", target: "background", url }) : - await chrome.runtime.sendMessage({ name: "scrape", target: "background", url }) + const tagData = + process.env.PLASMO_BROWSER == "firefox" + ? await browser.runtime.sendMessage({ + name: "scrape", + target: "background", + url + }) + : await chrome.runtime.sendMessage({ + name: "scrape", + target: "background", + url + }) if (tagData.error) throw new Error(tagData.error) // It is not worth showing just a title. if (!tagData.description && !tagData.body && !tagData.image) { @@ -211,7 +235,7 @@ const ContentPopup = () => { } renderTagPopup(tagData) try { - await getOAIData(tagData) + await getOAIData(tagData, setSummary, popupTarget.textContent) } catch (e) { console.warn("Error getting OpenAI completion: ", e) setSummary("Error getting summary: " + e) @@ -240,7 +264,7 @@ const ContentPopup = () => { // Summon on releasing shift useEffect(() => { const callback = async (event: { key: string }) => { - keyLock = (event.key !== "Shift") + keyLock = event.key !== "Shift" } window.addEventListener("keydown", callback) return () => { @@ -249,20 +273,23 @@ const ContentPopup = () => { }) const isElementEditable = (element) => { - let value = element.contentEditable; + let value = element.contentEditable while (value === "inherit" && element.parentElement) { - element = element.parentElement; - value = element.contentEditable; + element = element.parentElement + value = element.contentEditable } - return value === "inherit" || value === "false" ? false : true; -} + return value === "inherit" || value === "false" ? false : true + } // Summon on releasing shift useEffect(() => { const callback = async (event: { key: string }) => { - if (!isElementEditable(document.activeElement) && // If there isn't a focused text box (discord, youtube comments) - event.key === "Shift" && - hoverTarget && !keyLock) { + if ( + !isElementEditable(document.activeElement) && // If there isn't a focused text box (discord, youtube comments) + event.key === "Shift" && + hoverTarget && + !keyLock + ) { await openPopup() } } @@ -310,23 +337,51 @@ const ContentPopup = () => { url = popupTarget.getAttribute("href") } catch (_) {} - // If it's got transparency, we don't want to cut it off (could be icon or logo) = use contain. Otherwise, it looks prettier to use cover - const getImageType = () => { - if (!imageUrl) {return} + const formatSummary = (summary: string) => { + const lines = summary + .split("\n") + .map((line) => line.replace(/^\s*-\s*/, "")) + return ( +
    + {lines.map((content, i) => ( +
  • + {content.split(" ").map((word, i) => ( + + {word}{" "} + + ))} +
  • + ))} +
+ ) + } + + const getImageType = (): "image-contain" | "image-cover" => { if (imageRef && imageRef.current) { const height = imageRef.current.naturalHeight const width = imageRef.current.naturalWidth - if (Math.abs(width / height - 1) < 0.1 || width < 100 || height < 100) return "image-contain" + if (Math.abs(width / height - 1) < 0.1 || width < 100 || height < 100) + return "image-contain" + } + if (!imageUrl) { + return "image-cover" } return /svg|gif/.test(imageUrl) ? "image-contain" : "image-cover" } // Position.top is defined only if the popup is anchored to the bottom of an element // This means that the popup is expanding towards the bottom of the screen, so maxHeight is the distance before it goes offscreen - const maxHeight = Math.round(position.top ? window.innerHeight - position.top : window.innerHeight - position.bottom) - 10 + const maxHeight = + Math.round( + position.top + ? window.innerHeight - position.top + : window.innerHeight - position.bottom + ) - 10 // Shrink the image for tiny popups - const imageType = getImageType() - const imageMaxHeight = imageType == "image-contain" ? Math.min(maxHeight / 3, 100) : Math.min(maxHeight / 3, 200) + const imageMaxHeight = + imageType == "image-contain" + ? Math.min(maxHeight / 3, 100) + : Math.min(maxHeight / 3, 200) return (
{ display: animationState == "closed" ? "none" : "block" }}> {animationState == "opening" &&
} -
+
{(title || description || aiSummary) && ( -
- {title && {title}} - {description && description.split("\n").map((content, i) => ( -

- {content} -

- ))} - {aiSummary && ( -
- {aiSummary.split("\n").map((content, i) => ( -

- {content.split(" ").map((word, i) => ( - {word} - ))} -

- ))} -
- )} -
)} +
+ {title && ( + + {title} + + )} + {description && + description + .split("\n") + .map((content, i) =>

{content}

)} + {aiSummary && formatSummary(aiSummary)} +
+ )} {publisher && ( -
-

{publisher}

-
)} +
+

{publisher}

+
+ )}
) diff --git a/src/contents/styles.css b/src/contents/styles.css index fadfed9..939eb27 100644 --- a/src/contents/styles.css +++ b/src/contents/styles.css @@ -14,19 +14,8 @@ display: none; } -.summary { - margin-left: 12px; -} - -.summary::before { - content: " "; - position: absolute; - top: 0; - bottom: 0; - left: -8px; - background: rgba(232, 166, 250, 0.50); - width: 2px; - border-radius: 2px; +ul.summary li::marker { + color: rgba(232, 166, 250, 0.50); } .word { diff --git a/src/defaults.ts b/src/defaults.ts index e690777..06aafc4 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -1,9 +1,29 @@ +import { Storage } from "@plasmohq/storage" + export const defaultSettings = { baseURL: "https://api.openai.com/v1/", model: "gpt-3.5-turbo", prompt: - "Generate a concise and to the point summary for the following content. Do not begin with 'The article...' or similar. Make sure the summary relates to the context snippet provided.", + "Generate a concise and to the point bulleted summary for the following content. Make sure the summary relates to the context snippet provided.", inputTokens: 1000, - outputTokens: 200, - aiThreshold: 200, -} \ No newline at end of file + outputTokens: 150, + aiThreshold: 300, + }; + +const settings = new Storage() + +export const getConfig = async () => { + const config = { + apiKey: await settings.get("openai-key"), + baseURL: (await settings.get("openai-baseURL")) || defaultSettings.baseURL, + model: (await settings.get("openai-model")) || defaultSettings.model, + prompt: (await settings.get("system-prompt")) || defaultSettings.prompt, + inputTokens: parseInt(await settings.get("input-tokens")) || + defaultSettings.inputTokens, + outputTokens: parseInt(await settings.get("output-tokens")) || + defaultSettings.outputTokens, + aiThreshold: parseInt(await settings.get("ai-threshold")) || + defaultSettings.aiThreshold + } + return config +} diff --git a/src/popup/index.tsx b/src/popup/index.tsx index eab1f7b..b225a2d 100644 --- a/src/popup/index.tsx +++ b/src/popup/index.tsx @@ -1,34 +1,11 @@ -import "./styles.css" - import OpenAI from "openai" import type { ChatCompletionMessageParam } from "openai/resources" -import { useEffect, useRef, useState } from "react" - -import { Storage } from "@plasmohq/storage" -import { defaultSettings } from "~defaults" +import "./styles.css" -const settings = new Storage() +import { useEffect, useRef, useState } from "react" -const getConfig = async () => { - // Grab our config - const config = { - apiKey: await settings.get("openai-key"), - baseURL: (await settings.get("openai-baseURL")) || defaultSettings.baseURL, - model: (await settings.get("openai-model")) || defaultSettings.model, - prompt: (await settings.get("system-prompt")) || defaultSettings.prompt, - inputTokens: - parseInt(await settings.get("input-tokens")) || - defaultSettings.inputTokens, - outputTokens: - parseInt(await settings.get("output-tokens")) || - defaultSettings.outputTokens, - aiThreshold: - parseInt(await settings.get("ai-threshold")) || - defaultSettings.aiThreshold - } - return config -} +import { getConfig } from "~defaults" const Popup = () => { const [isDoneLoading, setIsDoneLoading] = useState(false) @@ -37,6 +14,7 @@ const Popup = () => { const [imageUrl, setImageUrl] = useState("") const [description, setDescription] = useState("") const [aiSummary, setSummary] = useState("") + const [imageType, setImageType] = useState("image-cover") const imageRef = useRef(null) /** @@ -44,17 +22,18 @@ const Popup = () => { */ const imageLoaded = () => { setIsDoneLoading(true) + setImageType(getImageType()) } - /** - * Uses the OpenAI API to generate a more extensive summary for the given content. Output is appended to the description state. - * @param tagData The data to generate a summary for - */ - const getOAIData = async (tagData: { - title: string - description: string - body: string - }) => { + const getOAIData = async ( + tagData: { + title: string + description: string + body: string + }, + output: (value: React.SetStateAction) => void, + context?: string + ) => { const config = await getConfig() if (!tagData.body) { return @@ -73,7 +52,10 @@ const Popup = () => { { role: "system", content: config.prompt }, { role: "user", - content: `Context: ${title}\nContent: "${tagData.body.slice(0, config.inputTokens * 3)}[...]\nSummary:"` + content: + (context ? `# Context\n${context}\n` : "") + + `# Content\n${tagData.body.slice(0, config.inputTokens * 3)}[...]\n` + + `# Summary` } ] as ChatCompletionMessageParam[] const openai = new OpenAI({ @@ -90,7 +72,7 @@ const Popup = () => { }) for await (const chunk of stream) { if (!chunk.choices[0].delta) continue - setSummary((prev) => prev + (chunk.choices[0].delta.content || "")) + output((prev) => prev + (chunk.choices[0].delta.content || "")) } } @@ -105,7 +87,7 @@ const Popup = () => { } setTitle(tagData.title) setPublisher(tagData.siteName) - setImageUrl(tagData.imageUrl || "") + setImageUrl(tagData.imageUrl) setDescription(tagData.description) if (!tagData.imageUrl) { imageLoaded() @@ -129,13 +111,14 @@ const Popup = () => { chrome.runtime.sendMessage( { name: "parseHTML", target: "background", url: tabs[0].url, html }, (tagData) => { - if (tagData.error) throw new Error("Error parsing HTML: " + tagData.error) + if (tagData.error) + throw new Error("Error parsing HTML: " + tagData.error) if (!tagData.description && !tagData.body && !tagData.image) { throw new Error("No data found") } renderTagPopup(tagData) try { - getOAIData(tagData) + getOAIData(tagData, setSummary, tagData.title) } catch (e) { console.warn("Error getting OpenAI completion: ", e) setSummary("Error getting summary: " + e) @@ -150,13 +133,34 @@ const Popup = () => { } } - // If it's got transparency, we don't want to cut it off (could be icon or logo) = use contain. Otherwise, it looks prettier to use cover - const getImageType = () => { - if (!imageUrl) {return} + const formatSummary = (summary: string) => { + const lines = summary + .split("\n") + .map((line) => line.replace(/^\s*-\s*/, "")) + return ( +
    + {lines.map((content, i) => ( +
  • + {content.split(" ").map((word, i) => ( + + {word}{" "} + + ))} +
  • + ))} +
+ ) + } + + const getImageType = (): "image-contain" | "image-cover" => { if (imageRef && imageRef.current) { const height = imageRef.current.naturalHeight const width = imageRef.current.naturalWidth - if (Math.abs(width / height - 1) < 0.1 || width < 100 || height < 100) return "image-contain" + if (Math.abs(width / height - 1) < 0.1 || width < 100 || height < 100) + return "image-contain" + } + if (!imageUrl) { + return "image-cover" } return /svg|gif/.test(imageUrl) ? "image-contain" : "image-cover" } @@ -176,7 +180,7 @@ const Popup = () => { onLoad={imageLoaded} src={imageUrl} // This is blank initially and reset to be blank occasionally, so it should be fine. ref={imageRef} - className={getImageType()} + className={imageType} /> {(title || description || aiSummary) && (
@@ -187,19 +191,7 @@ const Popup = () => { description .split("\n") .map((content, i) =>

{content}

)} - {aiSummary && ( -
- {aiSummary.split("\n").map((content, i) => ( -

- {content.split(" ").map((word, i) => ( - - {word}{" "} - - ))} -

- ))} -
- )} + {aiSummary && formatSummary(aiSummary)}
)} {publisher && ( diff --git a/src/popup/styles.css b/src/popup/styles.css index a300386..76a642d 100644 --- a/src/popup/styles.css +++ b/src/popup/styles.css @@ -11,19 +11,8 @@ body { background: #42414d; } -.summary { - margin-left: 12px; -} - -.summary::before { - content: " "; - position: absolute; - top: 0; - bottom: 0; - left: -8px; - background: rgba(232, 166, 250, 0.50); - width: 2px; - border-radius: 2px; +ul.summary li::marker { + color: rgba(232, 166, 250, 0.50); } .word {