Skip to main content

How I built a LinkedIn 'inner circle' highlighter in a weekend

ยท 21 min read
Norah Sakal
AI Consultant & Developer

How I built a LinkedIn 'inner circle' highlighter in a weekend

What you'll learn

How to build and deploy a Chrome extension from scratch that highlights Linkedin posts from people you actually wanted to engage with.

Why I built thisโ€‹

I kept realizing after scrolling that I'd missed Linkedin posts from people I actually wanted to engage with.

Fix: highlight "inner circle" directly in the feed:

  • No backend
  • No login
  • Instant cues

I built a Chrome extension that highlights people I want to engage with

I built a Chrome extension that highlights people I want to engage with

Constraints and design goalsโ€‹

  • Manifest V3, content-script only: no background service worker and no external backend calls
  • No data collection: config stays in chrome.storage.sync, no network requests, no analytics
  • DOM resilience: target top-level feed cards only
  • Language-aware-ish: detect POST / LIKE / COMMENT

Architecture in 60 secondsโ€‹

  1. Options page writes {overlayColor, overlayOpacity, icNames} โ†’ chrome.storage.sync
  2. ui.js loads config, normalizes names, injects dynamic CSS, exposes IC_UI
  3. Content script scans feed, finds top-level cards, classifies reason, adds overlay + badge
  4. Debounced MutationObserver handles infinite scroll
One tiny global

I keep the UI helpers and config in a tiny global called IC_UI (Inner Circle UI). The content script reads names from it, injects styles, and adds badges.

Architecture overviewโ€‹

Build the chrome extension from scratchโ€‹

Create the foundationโ€‹

This folder and file structure is the goal and what we'll create together:

Your folder structure
.
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ host
โ”‚ย ย  โ””โ”€โ”€ content-linkedin.js
โ”œโ”€โ”€ icons
โ”‚ย ย  โ””โ”€โ”€ icon128.png
โ”œโ”€โ”€ lib
โ”‚ย ย  โ””โ”€โ”€ ui.js
โ”œโ”€โ”€ manifest.json
โ”œโ”€โ”€ options.html
โ”œโ”€โ”€ options.js
โ””โ”€โ”€ styles
โ””โ”€โ”€ common.css

Let's start a new project by creating a new folder and navigate to the root of that folder, copy and paste this into your terminal:

Terminal
mkdir inner-circle-extension
cd inner-circle-extension

Then create a file named

Copy and paste this minimal configuration into manifest.json:

manifest.json
{
"manifest_version": 3,
"name": "InnerCircle - Feed Highlighter",
"version": "0.1.0",
"description": "Highlights posts/likes/comments from your shortlist directly in the feed (works on linkedin.com). No tracking; settings stay in chrome.storage.sync.",
"action": { "default_title": "Inner Circle" },
"permissions": ["storage"],
"host_permissions": ["*://www.linkedin.com/*"],
"content_scripts": [
{
"matches": ["*://www.linkedin.com/*"],
"js": ["lib/ui.js", "host/content-linkedin.js"],
"css": ["styles/common.css"],
"run_at": "document_idle"
}
],
"options_page": "options.html",
"icons": { "128": "icons/icon128.png" },
"web_accessible_resources": [
{
"resources": [],
"matches": ["*://www.linkedin.com/*"]
}
]
}
What this code does ๐Ÿ’ก
  • manifest_version: 3 - uses Chrome's MV3
  • permissions: ["storage"] - read/write user settings
  • host_permissions - allow scripts to run on LinkedIn only
  • content_scripts - inject order matters: lib/ui.js first (exports IC_UI), then host/content-linkedin.js
    • run_at: "document_idle" โ†’ wait for initial DOM paint
    • css: ["styles/common.css"] โ†’ optional global styles (most styles are injected dynamically by ui.js)
  • options_page - opens your settings UI
  • icons - toolbar/extension page artwork
  • web_accessible_resources - empty now, placeholder if you later expose assets to the page
  • Load order: lib/ui.js runs before host/content-linkedin.js, so window.IC_UI exists when the content script executes

Organize iconsโ€‹

The next step is to find a suitable icon.

Create a folder named icons in the project root:

Terminal
mkdir icons

I'll go with this icon

Go ahead and download the icon and save it to your icons folder:

Download icon (PNG, 128x128)

Your folder structure
.
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ host
โ”‚ย ย  โ””โ”€โ”€ content-linkedin.js
โ”œโ”€โ”€ icons
โ”‚ย ย  โ””โ”€โ”€ icon128.png # Save your icon here
โ”œโ”€โ”€ lib
โ”‚ย ย  โ””โ”€โ”€ ui.js
โ”œโ”€โ”€ manifest.json
โ”œโ”€โ”€ options.html
โ”œโ”€โ”€ options.js
โ””โ”€โ”€ styles
โ””โ”€โ”€ common.css

Create the UI surfaceโ€‹

The next step is to create the UI surface and add dynamic styles and name set.

We'll expose a tiny global namespace - IC_UI - from ui.js so the content script can reuse helpers/state without imports.

Create a new folder called lib in your root project:

Terminal
mkdir lib

Then, create a new file called in the folder lib:

Your folder structure
...
โ”œโ”€โ”€ lib
โ”‚ย ย  โ””โ”€โ”€ ui.js # Create this file
...

Copy this code into ui.js:

lib/ui.js
// lib/ui.js
(function (global) {
// ----- defaults -----
const DEFAULTS = {
overlayColor: "#ff0000",
overlayOpacity: 0.25,
icNames: "", // one per line
};

// ----- utils -----
function hexToRgb(hex) {
let h = (hex || "").trim().replace("#", "");
if (h.length === 3)
h = h
.split("")
.map((c) => c + c)
.join("");
return {
r: parseInt(h.slice(0, 2) || "ff", 16),
g: parseInt(h.slice(2, 4) || "00", 16),
b: parseInt(h.slice(4, 6) || "00", 16),
};
}
function rgba({ r, g, b }, a) {
return `rgba(${r},${g},${b},${a})`;
}
function darker({ r, g, b }, k) {
return { r: Math.round(r * k), g: Math.round(g * k), b: Math.round(b * k) };
}

function ensureAnchor(el) {
try {
if (getComputedStyle(el).position === "static")
el.style.position = "relative";
} catch {}
}

function norm(s) {
return (s || "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "") // strip diacritics
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}

// ----- dynamic styles -----
let dynamicStyle = null;
function applyConfig(cfg) {
const base = hexToRgb(cfg.overlayColor);
const text = darker(base, 0.69);
const textHex = `#${[text.r, text.g, text.b]
.map((x) => x.toString(16).padStart(2, "0"))
.join("")}`;

if (!dynamicStyle) {
dynamicStyle = document.createElement("style");
dynamicStyle.id = "ic-dynamic";
(document.head || document.documentElement).appendChild(dynamicStyle);
}
dynamicStyle.textContent = `
.ic-overlay{ background:${rgba(base, cfg.overlayOpacity)} !important; }
.ic-badge{
background:${rgba(base, 0.14)} !important;
border:1px solid ${rgba(darker(base, 0.69), 0.35)} !important;
color:${textHex} !important;
position:absolute; top:8px; right:85px; z-index:2;
padding:2px 6px; border-radius:8px; font:12px/1.2 system-ui;
pointer-events:none;
}
.ic-marked{ outline:0 !important; }
`;
}

// ----- names state -----
let currentCfg = null;
let icSet = new Set();

function rebuildNameSet(cfg) {
icSet = new Set();
const lines = (cfg.icNames || "").split(/\r?\n/).map(norm).filter(Boolean);
for (const line of lines) icSet.add(line);
}
function getNameSet() {
return icSet;
} // always returns the latest Set

// ----- storage wiring -----
function loadConfig(cb) {
chrome.storage.sync.get(DEFAULTS, (cfg) => {
currentCfg = cfg;
applyConfig(cfg);
rebuildNameSet(cfg);
cb && cb(cfg);
});
}

chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync") return;
chrome.storage.sync.get(DEFAULTS, (cfg) => {
currentCfg = cfg;
applyConfig(cfg);
if (changes.icNames) rebuildNameSet(cfg);
});
});

// ----- overlay api -----
function addOverlayAndBadge(hostCard, label) {
if (!hostCard || hostCard.classList.contains("ic-marked")) return;
hostCard.classList.add("ic-marked");
ensureAnchor(hostCard);

const overlay = document.createElement("div");
overlay.className = "ic-overlay";
overlay.style.cssText =
"position:absolute;inset:0;pointer-events:none;border-radius:8px;";
hostCard.appendChild(overlay);

const badge = document.createElement("div");
badge.className = "ic-badge";
badge.textContent = label || "INNER";
hostCard.appendChild(badge);
}

// expose
global.IC_UI = {
DEFAULTS,
loadConfig,
addOverlayAndBadge,
getNameSet,
norm,
get cfg() {
return currentCfg;
},
};
})(window);
note

We'll keep the UI helpers and config in IC_UI so we don't need a bundler/imports for MV3.
The content script just calls IC_UI.getNameSet() and IC_UI.addOverlayAndBadge(...).

What this code does ๐Ÿ’ก
  • Defines DEFAULTS and a norm() helper (lowercase, strip diacritics, collapse spaces)
  • Builds a Set of normalized names from the textarea (whole-word matching later)
  • Injects a single dynamic <style> block so color/opacity changes reflect instantly
  • Exposes a tiny API on window.IC_UI:
    • loadConfig(cb) - read chrome.storage.sync, apply styles, build name set, then callback
    • getNameSet() - latest normalized names
    • addOverlayAndBadge(card, label) - decorates a card (.ic-marked guard)
    • norm, cfg getters
  • Subscribes to storage changes so Options edits auto-propagate without reload
  • Adds a positioning guard (ensureAnchor) so absolutely-positioned overlay/badge attach correctly

Create classification coreโ€‹

We have the UI and dynamic styles ready, let's create the classification core.

Create the folder host in the project root:

Terminal
mkdir host

Then create a new file named in the host folder:

Your folder structure
...
โ”œโ”€โ”€ host # Create this folder
โ”‚ย ย  โ””โ”€โ”€ content-linkedin.js # Create this file
...

Copy this snippet to content-linkedin.js:

host/content-linkedin.js
// host/content-linkedin.js
(function () {
console.log("[Inner Circle] LinkedIn content script");

// Target ONLY top-level feed cards to avoid comment-level badges
const CARD_SEL = ".feed-shared-update-v2, article.update-components-article";

function topCard(node) {
// climb to the OUTERMOST ancestor that matches CARD_SEL
let best = null,
el = node;
while (el && el !== document.body) {
if (el.matches && el.matches(CARD_SEL)) best = el;
el = el.parentNode;
}
return best || node;
}

function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const norm = (s) => window.IC_UI.norm(s);

// --- language verbs (minimal; expand as needed) ---
const LIKE_WORDS = [
"like",
"liked",
"gillade",
"me gusta",
"gefรคllt",
"curtiu",
"likes",
"celebrates",
"reacted",
"insightful",
"loves",
];
const COMMENT_WORDS = [
"comment",
"commented",
"kommenterade",
"comentรณ",
"kommentiert",
"comentou",
];
const REPOSTED_WORDS = ["reposted"];

function anyWord(words, blob) {
const b = " " + norm(blob) + " ";
for (const w of words) {
const p = new RegExp(`\\b${escapeRegex(norm(w))}\\b`, "i");
if (p.test(b)) return true;
}
return false;
}

// Extract key text zones from the top-level card
function zones(card) {
const z = { author: "", byline: "", headerBlob: "" };

// 1) Actor (author)
const actor = card.querySelector(
".update-components-actor, .update-components-actor__meta, header"
);
if (actor) z.author = actor.textContent || "";

// 2) โ€œX liked/commentedโ€ area
const byline = card.querySelector(
".update-components-actor__sub-description, .update-components-social-activity, .social-details-social-activity"
);
if (byline) z.byline = byline.textContent || "";

// 3) Conservative header scan to catch variants
const head = card.querySelector("header") || card;
let buf = "";
const small = head.querySelectorAll("span, a");
for (let i = 0; i < small.length && i < 40; i++) {
const t = small[i].textContent || "";
if (t && t.length <= 120) buf += " " + t;
}
z.headerBlob = buf;

return z;
}

// Decide if/why this card matches the inner circle
function detectReason(card, nameSet) {
if (!nameSet || nameSet.size === 0) return null;

const { author, byline, headerBlob } = zones(card);
const blobs = [author, byline, headerBlob].filter(Boolean).map(norm);

// helper: does any blob contain the full normalized name as whole phrase?
const hasName = (full) => {
const pat = new RegExp(`\\b${escapeRegex(full)}\\b`, "i");
return blobs.some((b) => pat.test(b));
};

// 1) Author match โ‡’ POST
for (const full of nameSet) {
if (!full) continue;
const authorNorm = norm(author);
if (
authorNorm &&
new RegExp(`\\b${escapeRegex(full)}\\b`, "i").test(authorNorm)
) {
return { label: "POST" };
}
}

// 2) Byline match โ‡’ LIKE / COMMENT
for (const full of nameSet) {
if (!full) continue;
if (hasName(full)) {
const by = norm(byline + " " + headerBlob);
if (by && anyWord(COMMENT_WORDS, by)) return { label: "COMMENT" };
if (by && anyWord(LIKE_WORDS, by)) return { label: "LIKE" };
if (by && anyWord(REPOSTED_WORDS, by)) return { label: "REPOST" };
// fallback when language pattern unknown
return { label: "INNER" };
}
}

return null;
}

function scan(root) {
const cards = (root || document).querySelectorAll(CARD_SEL);
for (let i = 0; i < cards.length; i++) {
const card = topCard(cards[i]);
if (!card || card.classList.contains("ic-marked")) continue;

try {
const match = detectReason(card, window.IC_UI.getNameSet());
if (match) window.IC_UI.addOverlayAndBadge(card, match.label);
} catch {}
}
}

// Debounced observer
let obs = null,
queued = false;
function onMut() {
if (queued) return;
queued = true;
setTimeout(() => {
queued = false;
try {
scan();
} catch {}
}, 150);
}

function start() {
try {
scan();
} catch {}
obs = new MutationObserver((muts) => {
for (const m of muts) {
for (const n of m.addedNodes || []) {
if (n && n.nodeType === 1) {
onMut();
break;
}
}
}
});
try {
obs.observe(document, { subtree: true, childList: true });
} catch {}
}

function removeUI(card) {
card.querySelectorAll(".ic-overlay,.ic-badge").forEach((el) => el.remove());
card.classList.remove("ic-marked");
}

function refreshAll() {
const nodes = document.querySelectorAll(
".feed-shared-update-v2, article.update-components-article"
);
for (const n of nodes) {
const card = n; // already top-level by our selector
const match = detectReason(card, window.IC_UI.getNameSet());
if (match) {
if (!card.classList.contains("ic-marked"))
window.IC_UI.addOverlayAndBadge(card, match.label);
else {
const b = card.querySelector(".ic-badge");
if (b && b.textContent !== match.label) b.textContent = match.label;
}
} else if (card.classList.contains("ic-marked")) {
removeUI(card);
}
}
}

chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync") return;
if (changes.icNames) {
// ensure IC_UI has rebuilt its set, then refresh DOM
setTimeout(() => {
try {
refreshAll();
} catch {}
}, 50);
}
});

window.IC_UI.loadConfig(() => start());
})();
What this code does ๐Ÿ’ก
  • Targets top-level feed cards only (.feed-shared-update-v2, article.update-components-article) to avoid double-marking comments
  • Extracts three text zones per card: author, byline ("X liked/commented"), and a conservative header blob
  • Classifies why a card is relevant:
    • If author matches a name โ†’ POST
    • Else if byline/header mention a name โ†’ try verbs โ†’ COMMENT / LIKE / REPOST โ†’ fallback is just an empty string โ†’
  • Uses a MutationObserver with debouncing to rescan on infinite scroll
  • Listens to storage changes (name edits) and refreshes existing badges
  • Ensures no double-marking: .ic-marked prevents duplicates, removeUI() cleans up when a card no longer matches

Create the options UIโ€‹

We're almost ready to test our extension, the next step is to create the options HTML file.

Why do we need an options page?

Hard-coding names and colors in code is brittle. The Options page lets you:

  • edit your inner-circle list without rebuilding the extension
  • tweak color/opacity and see it live
  • persist everything via chrome.storage.sync (and sync across Chrome profiles signed into the same account)

No backend. No analytics. Just local settings.

Add to the root of your folder structure:

Your folder structure
...
โ”œโ”€โ”€ options.html # Create this file
...

Add this code to options.html to create an option page:

options.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Inner Circle - Options</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root {
--bg: #f7f8fb;
--card: #fff;
--muted: #667085;
--text: #101828;
--border: #e5e7eb;
--accent: #2563eb;
--shadow: 0 6px 18px rgba(17, 24, 39, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
color: var(--text);
background: var(--bg);
padding: 32px;
display: grid;
place-items: start center;
}
.card {
width: 100%;
max-width: 760px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 24px;
}
h1 {
margin: 0 0 8px;
font-size: 20px;
}
p.subtitle {
margin: 0 0 20px;
color: var(--muted);
}
.section {
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin: 0 0 16px;
background: #fff;
}
.section h2 {
margin: 0 0 12px;
font-size: 13px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.row {
display: grid;
grid-template-columns: 160px 1fr auto;
gap: 12px;
align-items: center;
margin: 10px 0;
}
label {
color: var(--text);
}
input[type="color"] {
width: 42px;
height: 32px;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #fff;
padding: 0;
cursor: pointer;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: #f9fafb;
min-width: 44px;
text-align: center;
}
textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
resize: vertical;
}
.hint {
color: var(--muted);
font-size: 12px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--muted);
margin-top: 12px;
}
.btn {
appearance: none;
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 12px;
background: #fff;
cursor: pointer;
}
.btn:hover {
border-color: #d0d5dd;
}
</style>
</head>
<body>
<main class="card">
<h1>Inner Circle - Settings</h1>
<p class="subtitle">
Pick your highlight style and define your Inner Circle (one name per
line).
</p>

<section class="section">
<h2>Appearance</h2>
<div class="row">
<label for="overlayColor">Overlay color</label>
<input type="color" id="overlayColor" />
<span class="hint">Any hex</span>
</div>
<div class="row">
<label for="overlayOpacity">Opacity</label>
<input
type="range"
id="overlayOpacity"
min="0"
max="0.95"
step="0.05"
/>
<span id="opVal" class="pill">0.25</span>
</div>
</section>

<section class="section">
<h2>Inner Circle</h2>
<div class="row" style="grid-template-columns: 1fr">
<label for="icNames">Names (one per line)</label>
<textarea
id="icNames"
rows="10"
placeholder="e.g.
Sundar Pichai"
></textarea>
<div class="hint">
Matching is case/diacritic-insensitive, whole-word. โ€œJane Doeโ€ will
match exact tokens in author/byline.
</div>
</div>
<div class="footer">
<span>Changes save instantly. Refresh existing LinkedIn tabs.</span>
<button id="restoreDefaults" class="btn" type="button">
Restore defaults
</button>
</div>
</section>
</main>
<!-- <script src="options.js"></script> -->
</body>
</html>
What this code does ๐Ÿ’ก
  • Pure HTML/CSS UI (no frameworks) with inputs for color, opacity, and names list
  • The page itself doesn't save anything until options.js is wired, then each input input event writes to chrome.storage.sync
  • Copy explains matching rules (case/diacritic-insensitive, whole-word)
  • Minimal, accessible layout that works without JS, JS enhances persistence only

Add CSS formattingโ€‹

The last step before we check the progress of our extension is to add some formatting.

Go ahead and create a folder named :

Terminal
mkdir styles

Then, create a file inside styles called :

Your folder structure
.
โ””โ”€โ”€ styles
โ””โ”€โ”€ common.css # Create this file

Copy and paste this code into common.css:

App.js
/* styles/common.css */
/* Keep minimal; runtime UI is injected by lib/ui.js */
.ic-overlay { border-radius: 8px; }
.ic-badge { -webkit-font-smoothing: antialiased; }
What this code does ๐Ÿ’ก
  • Adds a common stylesheet for small visual tweaks
  • .ic-overlay gets rounded corners
  • .ic-badge uses font smoothing for cleaner text
  • Keep this file minimal - the heavy lifting is done dynamically by ui.js

Great, we're ready to check the progress of our extension.

Check the progressโ€‹

Let's pack up our extension and try it out before we add the last logic.

Save all changes and open a new Chrome tab, then paste this into the address bar:

You should see an overview of all your extensions:

Overview of existing Chrome extensions

You should see an overview of existing Chrome extensions

Make sure the Developer mode is switched on:

Make sure the Developer mode is switched on

Make sure the Developer mode is switched on

Having the Developer mode switched one will give you access to 3 new features:

  • Load unpacked
  • Pack extension
  • Update

Having the Developer mode switched one will give you access to 3 new features

Having the Developer mode switched one will give you access to 3 new features

Then once switched on, click Load unpacked in the left upper corner:

Click Load unpacked

Click Load unpacked

This will open your directory, click on the name of your project where we created all the files and click Select:

Click on the name of your project where we created all the files and click Select

Click on the name of your project where we created all the files and click Select

You should now see the extension we just built appear in the All extensions overview:

You should now see the extension we just built

You should now see the extension we just built

Now go ahead and check the options page, click on the extension icon in the right corner and then Options:

Click on the extension icon in the right corner and then Options

Click on the extension icon in the right corner and then Options

This will take you to the options.html UI we just created:

The options.html UI we just created

The options.html UI we just created

You can play with the overlay and names, but nothing appears in your Linkedin feed because we haven't connected the options.html with Javascript logic.

Let's do that in the next step.

Add Javascript logicโ€‹

We tried our frontend where you can add names and change the overlay, but there is no logic that saves the changes or make them appear.

Let's connect our options.html with Javascript logic.

Create a new file called in your root folder:

Your folder structure
...
โ”œโ”€โ”€ options.js # Create this file
...

Add this code snippet to options.js:

options.js
// options.js
const colorInput = document.getElementById("overlayColor");
const opacityInput = document.getElementById("overlayOpacity");
const opVal = document.getElementById("opVal");
const icNames = document.getElementById("icNames");
const restoreBtn = document.getElementById("restoreDefaults");

const DEFAULTS = {
overlayColor: "#ff0000",
overlayOpacity: 0.25,
icNames: "",
};

function save(partial) {
chrome.storage.sync.set(partial);
if (partial.overlayOpacity != null)
opVal.textContent = String(partial.overlayOpacity);
}

function load() {
chrome.storage.sync.get(DEFAULTS, (cfg) => {
colorInput.value = cfg.overlayColor;
opacityInput.value = cfg.overlayOpacity;
opVal.textContent = cfg.overlayOpacity;
icNames.value = cfg.icNames || "";
});
}

function restoreDefaults() {
chrome.storage.sync.set(DEFAULTS, load);
}

// live wiring
colorInput.addEventListener("input", () =>
save({ overlayColor: colorInput.value })
);
opacityInput.addEventListener("input", () =>
save({ overlayOpacity: parseFloat(opacityInput.value) })
);
icNames.addEventListener("input", () => save({ icNames: icNames.value }));
restoreBtn.addEventListener("click", restoreDefaults);

// init
load();
What this code does ๐Ÿ’ก
  • Defines default settings for color, opacity, and names
  • save() writes changes instantly to chrome.storage.sync
  • load() initializes the UI from saved settings
  • restoreDefaults() resets everything with one click
  • Live-wires inputs: color, opacity, and names update storage on every change
  • Subscribes the UI so LinkedIn feed updates automatically without reload

Connect options html with javascriptโ€‹

We have the Javascript logic, let's connect it with our options.html file.

Open your options.html file again.

Scroll down to the bottom of options.html and uncomment this line of code:

options.html
...
</main>
<script src="options.js"></script> // Uncomment this line
</body>
</html
...

Reload the changesโ€‹

Save the changed and head back to chrome://extensions.

Click on Reload:

Click Reload to reload the changed we just made to the extension

Click Reload to reload the changed we just made to the extension

Then navigate to https://www.linkedin.com/feed/.

Open the extension options again in a new tab alongside your LinkedIn feed:

Open the extension options again alongside your LinkedIn feed

Open the extension options again alongside your LinkedIn feed

Pick a name from the first post you see and add that full name to the textfield and you should immediately see a red overlay on the post:

Pick a name from the first post you see and add that full name to the textfield

Pick a name from the first post you see and add that full name to the textfield

Here's the working Chrome extension:

I built a Chrome extension that highlights people I want to engage with

I built a Chrome extension that highlights people I want to engage with

Summaryโ€‹

You just built a clean MV3, content-script-only extension:

  • Config lives in chrome.storage.sync (no network calls)
  • UI layer (ui.js) injects dynamic styles and exposes a tiny API
  • Classifier (content-linkedin.js) marks cards as POST/LIKE/COMMENT/REPOST/INNER
  • Observer keeps up with infinite scroll without jank

Next stepsโ€‹

If you like practical builds, I have a free mini-course on how to build you own first AI agent: [LINK]