Building My Chrome Home
The Philosophy
Before writing a single line I made three rules that shaped every subsequent decision:
- No dependencies. No React, no Vue, no lodash, no build step. Pure HTML, CSS, and vanilla JavaScript. If you can open the folder and read the files, you understand the whole thing.
- No accounts, no tracking. Every piece of data either stays on your device or goes to a clearly declared public API. Nothing phones home to my server.
- No over-engineering. The right tool is the simplest one that works. Three similar lines are better than a premature abstraction. I could have used IndexedDB, but
chrome.storage.localis enough.
The result is a single 1,200-line JavaScript file, one CSS file, and an HTML template. Reload time is effectively zero because there is nothing to bundle or hydrate.
Data Architecture
Chrome extensions have two storage areas and the distinction matters:
| Area | What it stores | Why |
|---|---|---|
chrome.storage.sync |
Settings, daily intention | Follows you across every Chrome install automatically |
chrome.storage.local |
Bookmarks, notes, weather/wallpaper/history caches | Larger quota; no point syncing cached API responses |
Settings use a DEFAULT_SETTINGS object merged with whatever is stored, so new settings added in later versions just fall back to their default value – no migration code needed:
async function loadSettings() {
const { settings } = await chrome.storage.sync.get('settings');
return Object.assign({}, DEFAULT_SETTINGS, settings);
}
Caching Strategy
Three external APIs are called on every new tab. Without caching, opening ten tabs in a morning would fire thirty API requests. The rules are:
- Weather – cache for 10 minutes. A timestamp is stored alongside the data; on load, if
Date.now() - wCacheTime < 600_000the cached payload is used without any network call. - Wallpaper – cache by date. The cache entry stores today’s ISO date string. If it matches, skip the fetch. The photo changes at midnight, not every ten minutes.
- History events – cache by date for the same reason. Wikipedia’s “On This Day” content doesn’t change mid-afternoon.
The Header
The Clock – Numeric and Written
The clock starts as a conventional 24-hour display. A small button beneath it labelled Numeric or Words toggles between modes. In word mode, 14:32 becomes thirty-two past two in the afternoon.
The conversion function handles all the edge cases you’d expect:
function timeToWords(h, m) {
const HOURS = ['twelve','one','two','three','four','five','six',
'seven','eight','nine','ten','eleven'];
const MINS = ['','one','two',... 'twenty-nine'];
if (h === 0 && m === 0) return 'midnight';
if (h === 12 && m === 0) return 'noon';
const h12 = h % 12;
const period = h < 12 ? 'in the morning'
: h < 17 ? 'in the afternoon'
: h < 21 ? 'in the evening'
: 'at night';
if (m === 0) return `${HOURS[h12]} o'clock ${period}`;
if (m === 15) return `quarter past ${HOURS[h12]} ${period}`;
if (m === 30) return `half past ${HOURS[h12]} ${period}`;
if (m === 45) {
if (h === 23) return 'quarter to midnight';
if (h === 11) return 'quarter to noon';
return `quarter to ${HOURS[(h12 + 1) % 12]} ${period}`;
}
if (m < 30) return `${MINS[m]} past ${HOURS[h12]} ${period}`;
return `${MINS[60 - m]} to ${HOURS[(h12 + 1) % 12]} ${period}`;
}
The h12 = h % 12 trick means index 0 in the HOURS array is twelve, which correctly handles both midnight and noon hours. The (h12 + 1) % 12 expression gets the next hour for “to” constructions and wraps cleanly from eleven back to twelve. The 23:45 and 11:45 special cases avoid the awkward “quarter to twelve at night” in favour of “quarter to midnight”.
The preference is saved to chrome.storage.local so it persists across sessions without using the sync quota for something this trivial.
The Weather Widget
Weather data comes from Open-Meteo – a fully open-source weather API with no key required. The extension requests only the two fields it uses: temperature_2m and weather_code. The city name comes from a separate call to OpenStreetMap’s Nominatim reverse-geocoder. Both requests fire in parallel with Promise.all to keep the total latency down to a single round-trip:
const [weatherRes, geoRes] = await Promise.all([
fetch(`https://api.open-meteo.com/v1/forecast
?latitude=${lat}&longitude=${lon}
¤t=temperature_2m,weather_code`),
fetch(`https://nominatim.openstreetmap.org/reverse
?format=json&lat=${lat}&lon=${lon}&zoom=10`),
]);
WMO weather codes (the international standard the API uses) are mapped to emoji and plain-English descriptions via a lookup object. A code of 63 is “Rain”, 95 is “Thunderstorm”, and so on.
The TFW Weather Mode
The default weather display is polite. The alternative – inspired by The Fucking Weather – is not. A button labelled Normal or TFW switches between them. In TFW mode the widget switches from ⛅ 14°C Partly cloudy · Exeter to IT’S PRETTY COLD in large capitalised text.
The Phrase Bank
Rather than a single hardcoded string per condition, each condition maps to an array. A pick() helper selects one at random on every render:
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
const TFW_PHRASES = {
thunderstorm: ["STAY THE HELL INSIDE"],
snow_baltic: ["IT'S PROPER BALTIC"], // snow + below -5°C
snow: ["IT'S SNOWING"],
showers: ["IT'S PISSING DOWN"],
rain_cold: ["COLD AND WET. LOVELY."], // rain + below 5°C
rain: ["IT'S RAINING"],
drizzle: ["IT'S A BIT DRIZZLY"],
fog: ["CAN'T SEE SHIT"],
arctic: ["IT'S FUCKING ARCTIC"], // below -10°C
freezing: ["IT'S BLOODY FREEZING"],
very_cold: ["IT'S VERY COLD"],
cold: ["IT'S PRETTY COLD"],
chilly: ["A BIT CHILLY"],
ok: ["NOT BAD, ACTUALLY"],
nice: ["QUITE NICE"],
warm: ["PRETTY WARM"],
hot: ["IT'S HOT"],
very_hot: ["IT'S VERY HOT"],
roasting: ["IT'S FUCKING ROASTING"],
};
Each array currently holds one phrase, but the structure is deliberately ready to expand – adding more alternatives to any bucket is a one-line edit. A small ↻ button appears next to the widget in TFW mode; clicking it calls applyWeatherDisplay(lastWeatherData), which re-calls tfwDescription() and therefore re-picks. Because the same weather data is reused, no extra network call is made.
The Temperature Gradient
When TFW mode is active, the right section of the header gets a colour wash derived from the temperature. Cold temperatures produce blue tones; mild temperatures produce greens; warm temperatures drift through amber and into deep red. The colour is computed from a stepped lookup:
function tfwBgColor(temp) {
if (temp < -10) return '#0d1b2a'; // near-black blue
if (temp < 0) return '#1a3a5c';
if (temp < 5) return '#1e4976';
if (temp < 10) return '#1a5c7a';
if (temp < 15) return '#1a5c4a';
if (temp < 18) return '#2d5a27';
if (temp < 22) return '#4a7c59';
if (temp < 26) return '#8b5a00';
if (temp < 30) return '#a03000';
if (temp < 35) return '#b01500';
return '#8b0000'; // deep red
}</
Rather than setting a flat colour, this is converted to an rgba gradient (parsing the hex manually to get r, g, b components) and applied as a linear-gradient directly on the #header-right element. The CSS transition on that element handles the smooth fade between temperatures:
#header-right {
transition: background 0.8s ease;
}
Scoping it to #header-right keeps the effect contained – it’s a signal about the weather in the weather section, not a full-page mood board.
The Wallpaper System
Four wallpaper sources are supported: Bing Photo of the Day, NASA’s Astronomy Picture of the Day, a solid colour, and a custom HTTPS URL.
The NASA source has one complication: APOD sometimes features a video rather than a photograph. The extension handles this by walking backwards through recent dates until it finds a still image, trying up to five days:
for (let daysBack = 0; daysBack < 5; daysBack++) {
const d = new Date();
d.setDate(d.getDate() - daysBack);
const date = d.toISOString().slice(0, 10);
const data = await fetch(`${NASA_API}?api_key=${key}&date=${date}`)
.then(r => r.json());
if (data.media_type === 'image') {
return { url: data.hdurl || data.url, copyright: data.title };
}
// media_type === 'video' - try the day before
}
Before any URL is applied as a CSS background, it goes through URL parsing and protocol validation. The final value is JSON-stringified before being interpolated into url(...) – this prevents CSS injection regardless of what the API response or a user’s custom URL contains.
Search and Bangs
The search bar supports three engines as tabs. Switching updates the placeholder text instantly. If the input looks like a bare domain – matching /^[\w.-]+\.\w{2,}$/ – it navigates directly rather than searching.
Bang shortcuts let you redirect to any supported site regardless of which engine tab is active. The detection handles three patterns with a pair of regexes:
const bangStart = raw.match(/^!(\w+)\s+([\s\S]+)$/); // !yt lo-fi music
const bangEnd = raw.match(/^([\s\S]+)\s+!(\w+)$/); // dark souls !r
const bangOnly = raw.match(/^!(\w+)$/); // !gh (navigate, no query)
const bangKey = bangStart?.[1] || bangEnd?.[2] || bangOnly?.[1];
const bangQ = bangStart?.[2] || bangEnd?.[1] || '';
Fifty-one shortcuts are defined, grouped by category: Search, Video, Social, Shopping, Developer, Knowledge, AI, Google services, and Music. The !? button next to the engine tabs builds the reference panel lazily from the same BANGS object – no duplication between the data and the UI.
Ambient Sounds
All six ambient sounds are synthesised at runtime using the Web Audio API. No sound files are bundled or fetched. The base material for all of them is noise – the difference is in how that noise is filtered.
| Sound | Noise base | Processing |
|---|---|---|
| White Noise | White | None – raw random values |
| Pink Noise | White | Butterworth filter (−3 dB/octave roll-off) |
| Brown Noise | Integrated white | Brownian motion accumulation |
| Rain | White | Bandpass at 700 Hz, Q = 0.8 |
| Ocean | Pink | Lowpass at 400 Hz + slow LFO at 0.12 Hz modulating gain |
| Fireplace | Brown | Lowpass at 800 Hz |
The LFO (low-frequency oscillator) on the ocean sound is what gives it its wave-like rhythm. A 0.12 Hz oscillator modulates the gain – that’s roughly one swell every eight seconds, which sounds more like open water than like a looped sample.
The AudioContext is created only when playback starts and closed immediately when it stops. This avoids keeping an audio graph alive in the background and respects browser policies that now require a user gesture before any audio context can run.
The Bookmarks Panel
Bookmarks are grouped into named collections – Favourites, Work, Reading, whatever you like. Each group renders as a sub-tab. The grid uses repeat(auto-fill, minmax(110px, 1fr)), so it fills available width and reflows gracefully as you add more items.
Drag-to-Reorder
Each bookmark card is draggable. The reorder logic uses the HTML5 Drag and Drop API. During a drag, a blue left or right border on the card under the cursor indicates the insertion point:
a.addEventListener('dragover', e => {
e.preventDefault();
const rect = a.getBoundingClientRect();
a.classList.add(
e.clientX < rect.left + rect.width / 2 ? 'drag-left' : 'drag-right'
);
});
a.addEventListener('drop', e => {
const [moved] = bms.splice(dragSrcIdx, 1);
let insertAt = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
if (dragSrcIdx < idx) insertAt--; // correct for the splice shift
bms.splice(insertAt, 0, moved);
saveBookmarks();
renderBookmarks();
});
The dragSrcIdx < idx correction handles a subtle off-by-one: splicing the source element out of the array before inserting shifts all subsequent indices down by one, so the target position needs compensating.
Favicons
Favicons are fetched via Google’s public S2 service: https://www.google.com/s2/favicons?domain=<host>&sz=32. This requires no authentication, returns a 32px icon for almost any domain, and fails gracefully – if the image errors, a CSS display: none hides it without breaking the card layout.
Dual Interface
Notes exist in two places simultaneously. The dashboard panel shows a card grid sorted by most recently updated, each card showing a title, a 3-line preview, and the edit date. Clicking opens a full-width modal editor with Ctrl+S to save.
The second interface is the extension popup – the panel that appears when you click the extension icon in Chrome’s toolbar from any page. This is important: you can capture a thought without leaving what you’re reading. The popup keeps a note count in its header so you can see at a glance how many notes exist, and Ctrl+Enter saves from the textarea without reaching for the mouse.
Both interfaces write to the same chrome.storage.local array. The note model is deliberately flat:
{
id: "lhyxyk2z4oi", // uid() - timestamp + random suffix
title: "Meeting notes",
content: "...",
created: "2026-05-15T09:14:00.000Z",
updated: "2026-05-15T11:32:00.000Z"
}
Google Tasks – Agenda View
The Tasks tab is the most architecturally interesting addition because it crosses the boundary into external accounts. Everything else in the extension is self-contained; Google Tasks is not.
Authentication
Chrome’s identity API handles the OAuth2 flow without the extension needing to manage tokens manually. The manifest declares the OAuth client ID and the required scope:
"permissions": ["identity"],
"oauth2": {
"client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"scopes": ["https://www.googleapis.com/auth/tasks"]
}
On first use, chrome.identity.getAuthToken({ interactive: true }) launches Chrome’s built-in consent flow. On subsequent loads, { interactive: false } returns the cached token silently. If the token has expired the API returns a 401, the cached token is evicted, and the sign-in prompt is shown again:
async function tasksApiFetch(path, init = {}) {
const token = await getAuthToken(false);
const res = await fetch(`${TASKS_API}${path}`, {
...init,
headers: { Authorization: `Bearer ${token}`, ... },
});
if (res.status === 401) {
await new Promise(r =>
chrome.identity.removeCachedAuthToken({ token }, r)
);
throw new Error('auth-expired');
}
return res;
}
Fetching All Lists in Parallel
Google Tasks organises tasks into named lists (the default is “My Tasks”). The extension fetches all lists first, then fires a task request for each list simultaneously using Promise.all:
const lists = await tasksApiFetch('/users/@me/lists?maxResults=100')
.then(r => r.json())
.then(d => d.items ?? []);
const taskArrays = await Promise.all(
lists.map(list =>
tasksApiFetch(`/lists/${list.id}/tasks
?showCompleted=false&maxResults=100`)
.then(r => r.json())
.then(d => (d.items ?? []).map(t => ({
...t,
listTitle: list.title,
listId: list.id,
})))
)
);
The listTitle and listId are spread onto each task object so the rendering code has everything it needs in one place without a secondary lookup.
The Agenda Bucketing Logic
Tasks are sorted into six time buckets. The key implementation detail is normalising all date comparisons to midnight to avoid timezone and time-of-day mismatches:
const today = new Date(); today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1);
const thisWeekEnd = new Date(today); thisWeekEnd.setDate(today.getDate() + 7);
tasks.forEach(task => {
if (!task.due) { buckets.noDate.push(task); return; }
const dueDay = new Date(task.due); dueDay.setHours(0, 0, 0, 0);
if (dueDay < today) buckets.overdue.push(task);
else if (dueDay.getTime() === today.getTime()) buckets.today.push(task);
else if (dueDay.getTime() === tomorrow.getTime()) buckets.tomorrow.push(task);
else if (dueDay < thisWeekEnd) buckets.thisWeek.push(task);
else buckets.later.push(task);
});
Overdue renders with a red heading, Today with the accent colour, and everything else in muted grey. Clicking the circular checkbox on any task fires a PATCH request to the API with status: "completed", then animates the item out before removing it from the DOM:
itemEl.classList.add('task-completing'); // CSS: opacity → 0, translateX(-10px)
await tasksApiFetch(`/lists/${task.listId}/tasks/${task.id}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'completed' }),
});
setTimeout(() => {
itemEl.remove();
if (!section.querySelector('.task-item')) section.remove();
}, 380);
Design System
Glass Morphism
Every surface uses a semi-transparent dark glass treatment: background: rgba(0,0,0,0.2) with backdrop-filter: blur(8px). This lets the wallpaper bleed through while keeping text readable at any contrast level. A subtle border: 1px solid rgba(255,255,255,0.09) adds definition without looking heavy. The technique works because the wallpaper already has a dark gradient overlay – there is never a case where a light photo makes the glass panels unreadable.
CSS Custom Properties
The accent colour is a single CSS variable – --accent –
set on the root element in JavaScript when settings load:
document.documentElement.style.setProperty('--accent', settings.accentColor);
Every interactive element – search button, active tabs, hover borders, bookmark edit badges, task headings – reads from var(--accent). Changing the colour in the options page updates the entire UI instantly, with no page reload.
Security – Content and URLs
All external data is inserted via textContent, never innerHTML. This is non-negotiable in a Manifest V3 extension – Chrome’s CSP blocks eval() entirely, but injected HTML through innerHTML is still a real risk when API responses contain untrusted content.
Wallpaper URLs go through a dedicated sanitisation path before touching any CSS property:
function applyWallpaper(url) {
const parsed = new URL(url); // throws on invalid URL
if (!['https:','http:'].includes(parsed.protocol)) // block data:, javascript:
return;
el.style.backgroundImage = `url(${JSON.stringify(parsed.href)})`;
// ^^ escapes quotes, prevents CSS injection
}
What I’d Do Differently
Building something you use every day clarifies your opinions quickly. A few things I’d reconsider if starting again:
- The weather cache key. Caching by timestamp works, but a geolocation change (travelling) doesn’t invalidate the cache until the ten minutes expire. A better key would include a rounded latitude/longitude.
- Word clock update granularity. The clock updates every second via
setInterval(updateClock, 1000). In word mode, the text only changes on the minute – running sixty unnecessary re-evaluations per minute is wasteful. A smarter approach would be to calculate the milliseconds until the next minute boundary and reschedule accordingly. - Google Tasks caching. The Tasks tab currently fetches fresh data every time it is opened. Caching the task list for five minutes (with a manual refresh button already in place) would make the tab feel much faster when you switch between panels.
What’s Next
- No idea! – I built this purely with my own needs in mind, learning new bits of functionality as I went.
You open a new tabe dozens of times a day – it’s one of the most-viewed surfaces on your computer. A little investment in making it genuinely useful pays back quickly. And the Web Audio API generating convincing rain from scratch in fifty lines of JavaScript still feels like a small miracle every time I hear it and happy memories of messing around with the SID chup on the Commodore 64.
Comments (0)