Privacy hardening: degoogle, security headers, referrer protection

- Replace Google Fonts with system font stacks across all themes
- Add Referrer-Policy, X-Content-Type-Options, X-Frame-Options, CSP headers
- Add rel="noreferrer noopener" on all outbound links
- Add no-referrer and dns-prefetch-control meta tags to all themes
- Clean tracking params on outbound links from trusted/remote sources
- Remove Google domains from CSP whitelists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Derick Phan 2026-04-08 10:11:57 -07:00
parent 23b634d0e0
commit b86e139bdd
No known key found for this signature in database
6 changed files with 285 additions and 275 deletions

View file

@ -123,6 +123,14 @@ class GatewayHandler(BaseHTTPRequestHandler):
self.send_response(resp["status"]) self.send_response(resp["status"])
self.send_header("Content-Type", resp.get("content_type", "text/html; charset=utf-8")) self.send_header("Content-Type", resp.get("content_type", "text/html; charset=utf-8"))
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy",
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; "
"img-src 'self' data:")
for k, v in resp.get("headers", {}).items(): for k, v in resp.get("headers", {}).items():
self.send_header(k, v) self.send_header(k, v)
self.end_headers() self.end_headers()

View file

@ -245,7 +245,7 @@ def handle_search(query):
snip_html = f'<br>{esc(r["summary"])}' if r["summary"] else "" snip_html = f'<br>{esc(r["summary"])}' if r["summary"] else ""
result_html += ( result_html += (
f'<div class="result">' f'<div class="result">'
f'<a href="{esc(r["url"])}">{esc(r["title"])}</a><br>' f'<a href="{esc(r["url"])}" rel="noreferrer noopener">{esc(r["title"])}</a><br>'
f'<small>{esc(r["url"])}</small>' f'<small>{esc(r["url"])}</small>'
f'{snip_html}' f'{snip_html}'
f'{note_html}{tags_html}' f'{note_html}{tags_html}'
@ -276,7 +276,7 @@ def handle_search(query):
items = "" items = ""
for l in trusted: for l in trusted:
items += ( items += (
f'<li><a href="{esc(l["url"])}">{esc(l["label"])}</a> ' f'<li><a href="{esc(clean_url(l["url"]))}" rel="noreferrer noopener">{esc(l["label"])}</a> '
f'<small>— from {esc(l["source_title"])}</small></li>' f'<small>— from {esc(l["source_title"])}</small></li>'
) )
trusted_html = ( trusted_html = (
@ -311,8 +311,8 @@ def handle_search(query):
for r in items: for r in items:
note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else "" note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else ""
source_items += ( source_items += (
f'<li><a href="{esc(r["url"])}">{esc(r["title"])}</a>' f'<li><a href="{esc(clean_url(r["url"]))}" rel="noreferrer noopener">{esc(r["title"])}</a>'
f'{note_html} <small>({esc(r["url"])})</small></li>' f'{note_html} <small>({esc(clean_url(r["url"]))})</small></li>'
) )
remote_html += ( remote_html += (
f'<details class="remote" open>' f'<details class="remote" open>'
@ -473,7 +473,7 @@ def handle_add_manual_submit(body):
# Log error but don't fail the whole operation # Log error but don't fail the whole operation
print(f"Error generating embeddings: {e}") print(f"Error generating embeddings: {e}")
return handle_add_form(f'Added manually: <a href="{esc(url)}">{esc(manual_title)}</a>') return handle_add_form(f'Added manually: <a href="{esc(url)}" rel="noreferrer noopener">{esc(manual_title)}</a>')
finally: finally:
return_db(db) return_db(db)
@ -500,7 +500,7 @@ def handle_pages(query=None):
tags_html = f' {tag_links}' tags_html = f' {tag_links}'
items += ( items += (
f'<li>{esc(r["title"])}{note_html}{tags_html} ' f'<li>{esc(r["title"])}{note_html}{tags_html} '
f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small> ' f'<small>(<a href="{esc(r["url"])}" rel="noreferrer noopener">{esc(r["url"])}</a>)</small> '
f'<a href="/edit/{r["id"]}">edit</a> ' f'<a href="/edit/{r["id"]}">edit</a> '
f'<a href="/delete/{r["id"]}">remove</a></li>' f'<a href="/delete/{r["id"]}">remove</a></li>'
) )
@ -700,7 +700,7 @@ def handle_style_form(msg=""):
f"<small>Default: reticulum.derickphan.com:4242</small><br>" f"<small>Default: reticulum.derickphan.com:4242</small><br>"
f'<input name="transport_host" value="{esc(transport_host)}" placeholder="hostname" size="30">' f'<input name="transport_host" value="{esc(transport_host)}" placeholder="hostname" size="30">'
f' <input name="transport_port" value="{esc(transport_port)}" placeholder="port" size="6"><br>' f' <input name="transport_port" value="{esc(transport_port)}" placeholder="port" size="6"><br>'
f'<p><a href="https://rmap.world/" target="_blank">discover more nodes</a></p><br>' f'<p><a href="https://rmap.world/" target="_blank" rel="noreferrer noopener">discover more nodes</a></p><br>'
f"<h2>search</h2>" f"<h2>search</h2>"
f"<h3>ai</h3>" f"<h3>ai</h3>"
f'<label><input type="checkbox" name="semantic_search" value="1"{semantic_checked} ' f'<label><input type="checkbox" name="semantic_search" value="1"{semantic_checked} '
@ -851,7 +851,7 @@ def handle_tag_browse(tag_name, query=None):
tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags) tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags)
items += ( items += (
f'<li>{esc(r["title"])}{note_html} {tag_links} ' f'<li>{esc(r["title"])}{note_html} {tag_links} '
f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small></li>' f'<small>(<a href="{esc(r["url"])}" rel="noreferrer noopener">{esc(r["url"])}</a>)</small></li>'
) )
finally: finally:
return_db(db) return_db(db)
@ -1397,8 +1397,7 @@ def dispatch_request(data):
resp["headers"]["Content-Security-Policy"] = ( resp["headers"]["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline'; " "script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "style-src 'self' 'unsafe-inline'; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src * data:; " "img-src * data:; "
"frame-ancestors 'none'; " "frame-ancestors 'none'; "
"form-action 'self'; " "form-action 'self'; "

View file

@ -7,13 +7,13 @@ def esc(s):
DEFAULT_TEMPLATE = "<html>\n<head>\n</head>\n<body>\n{{content}}\n</body>\n</html>" DEFAULT_TEMPLATE = "<html>\n<head>\n<meta name=\"referrer\" content=\"no-referrer\">\n<meta http-equiv=\"x-dns-prefetch-control\" content=\"off\">\n</head>\n<body>\n{{content}}\n</body>\n</html>"
def _default_template(): def _default_template():
name = esc(get_setting("site_name", "tinyweb")) name = esc(get_setting("site_name", "tinyweb"))
return ( return (
"<html>\n<head>\n</head>\n<body>\n" '<html>\n<head>\n<meta name="referrer" content="no-referrer">\n<meta http-equiv="x-dns-prefetch-control" content="off">\n</head>\n<body>\n'
f'<p><b><a href="/">{name}</a></b>' f'<p><b><a href="/">{name}</a></b>'
' | <a href="/">search</a> | <a href="/pages">browse</a>' ' | <a href="/">search</a> | <a href="/pages">browse</a>'
' | <a href="/tags">tags</a> | <a href="/subscriptions">subscriptions</a>' ' | <a href="/tags">tags</a> | <a href="/subscriptions">subscriptions</a>'

View file

@ -3,15 +3,16 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<meta http-equiv="x-dns-prefetch-control" content="off">
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,400;0,600;0,700;1,400&family=Fira+Code:wght@400;500&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { min-height: 100vh; } html, body { min-height: 100vh; }
body { body {
font-family: 'Nunito', -apple-system, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 16px;
line-height: 1.65; line-height: 1.65;
color: #e0d8c8; color: #e0d8c8;
@ -81,7 +82,7 @@
right: 10px; right: 10px;
z-index: 900; z-index: 900;
pointer-events: none; pointer-events: none;
font-family: 'Fira Code', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
@ -289,7 +290,7 @@
} }
nav .site { nav .site {
font-family: 'Fira Code', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
color: #f0d878; color: #f0d878;
@ -383,7 +384,7 @@
border-radius: 4px; border-radius: 4px;
padding: 0.6rem 0.85rem; padding: 0.6rem 0.85rem;
color: #d0c8b8; color: #d0c8b8;
font-family: 'Nunito', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 0.95rem; font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.3s; transition: border-color 0.2s, box-shadow 0.3s;
} }
@ -400,7 +401,7 @@
border-radius: 4px; border-radius: 4px;
padding: 0.6rem 1.1rem; padding: 0.6rem 1.1rem;
color: #a098b0; color: #a098b0;
font-family: 'Nunito', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 0.88rem; font-size: 0.88rem;
transition: all 0.2s; transition: all 0.2s;
} }
@ -443,7 +444,7 @@
.tags { margin-top: 0.3rem; } .tags { margin-top: 0.3rem; }
.tag, .tags a { .tag, .tags a {
font-family: 'Fira Code', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.7rem; font-size: 0.7rem;
color: #8878a0; color: #8878a0;
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08);
@ -486,7 +487,7 @@
/* code */ /* code */
pre { pre {
font-family: 'Fira Code', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.8rem; font-size: 0.8rem;
background: rgba(15, 10, 40, 0.5); background: rgba(15, 10, 40, 0.5);
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08);
@ -498,7 +499,7 @@
} }
code { code {
font-family: 'Fira Code', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.82rem; font-size: 0.82rem;
background: rgba(15, 10, 40, 0.4); background: rgba(15, 10, 40, 0.4);
border-radius: 3px; border-radius: 3px;
@ -513,7 +514,7 @@
border-radius: 4px; border-radius: 4px;
padding: 0.7rem 0.9rem; padding: 0.7rem 0.9rem;
color: #d0c8b8; color: #d0c8b8;
font-family: 'Fira Code', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.6; line-height: 1.6;
resize: vertical; resize: vertical;
@ -547,7 +548,7 @@
hr { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 1rem 0; } hr { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 1rem 0; }
small { small {
font-family: 'Fira Code', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.7rem; font-size: 0.7rem;
color: #5a5070; color: #5a5070;
} }
@ -562,7 +563,7 @@
} }
footer .clock { footer .clock {
font-family: 'Fira Code', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.72rem; font-size: 0.72rem;
color: #3a3050; color: #3a3050;
margin-top: 0.25rem; margin-top: 0.25rem;

View file

@ -3,13 +3,14 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<meta http-equiv="x-dns-prefetch-control" content="off">
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: 'IBM Plex Sans', -apple-system, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 16px;
line-height: 1.65; line-height: 1.65;
color: #c8c8c8; color: #c8c8c8;
@ -71,16 +72,16 @@
z-index: 998; z-index: 998;
} }
/* kodama spirits */ /* kodama spirits */
#kodama { #kodama {
position: fixed; position: fixed;
top: 0; left: 0; top: 0; left: 0;
width: 100%; height: 100%; width: 100%; height: 100%;
pointer-events: none; pointer-events: none;
z-index: 2; z-index: 2;
} }
.shell { .shell {
max-width: 660px; max-width: 660px;
margin: 0 auto; margin: 0 auto;
@ -99,7 +100,7 @@
} }
nav .site { nav .site {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
color: #e8e8e8; color: #e8e8e8;
@ -193,7 +194,7 @@
border-radius: 4px; border-radius: 4px;
padding: 0.6rem 0.85rem; padding: 0.6rem 0.85rem;
color: #d0d0d0; color: #d0d0d0;
font-family: 'IBM Plex Sans', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 0.95rem; font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.3s; transition: border-color 0.2s, box-shadow 0.3s;
} }
@ -210,7 +211,7 @@
border-radius: 4px; border-radius: 4px;
padding: 0.6rem 1.1rem; padding: 0.6rem 1.1rem;
color: #999; color: #999;
font-family: 'IBM Plex Sans', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 0.88rem; font-size: 0.88rem;
transition: all 0.2s; transition: all 0.2s;
} }
@ -253,7 +254,7 @@
.tags { margin-top: 0.3rem; } .tags { margin-top: 0.3rem; }
.tag, .tags a { .tag, .tags a {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.7rem; font-size: 0.7rem;
color: #555; color: #555;
border: 1px solid #252525; border: 1px solid #252525;
@ -296,7 +297,7 @@
/* code */ /* code */
pre { pre {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.8rem; font-size: 0.8rem;
background: #151515; background: #151515;
border: 1px solid #232323; border: 1px solid #232323;
@ -308,7 +309,7 @@
} }
code { code {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.82rem; font-size: 0.82rem;
background: #1a1a1a; background: #1a1a1a;
border-radius: 3px; border-radius: 3px;
@ -323,7 +324,7 @@
border-radius: 4px; border-radius: 4px;
padding: 0.7rem 0.9rem; padding: 0.7rem 0.9rem;
color: #c8c8c8; color: #c8c8c8;
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.6; line-height: 1.6;
resize: vertical; resize: vertical;
@ -357,7 +358,7 @@
hr { border: none; border-top: 1px solid #1e1e1e; margin: 1rem 0; } hr { border: none; border-top: 1px solid #1e1e1e; margin: 1rem 0; }
small { small {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.7rem; font-size: 0.7rem;
color: #484848; color: #484848;
} }
@ -372,7 +373,7 @@
} }
footer .clock { footer .clock {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.72rem; font-size: 0.72rem;
color: #282828; color: #282828;
margin-top: 0.25rem; margin-top: 0.25rem;
@ -393,7 +394,7 @@
</head> </head>
<body> <body>
<canvas id="particles"></canvas> <canvas id="particles"></canvas>
<canvas id="trail"></canvas> <canvas id="trail"></canvas>
<canvas id="kodama"></canvas> <canvas id="kodama"></canvas>
<div class="shell"> <div class="shell">
<nav> <nav>
@ -521,224 +522,224 @@
requestAnimationFrame(drawTrail); requestAnimationFrame(drawTrail);
} }
drawTrail(); drawTrail();
// kodama (tree spirits) // kodama (tree spirits)
var kc = document.getElementById('kodama'); var kc = document.getElementById('kodama');
var kctx = kc.getContext('2d'); var kctx = kc.getContext('2d');
var spirits = []; var spirits = [];
var numSpirits = 8; var numSpirits = 8;
function resizeKodama() { function resizeKodama() {
kc.width = window.innerWidth; kc.width = window.innerWidth;
kc.height = window.innerHeight; kc.height = window.innerHeight;
} }
resizeKodama(); resizeKodama();
window.addEventListener('resize', resizeKodama); window.addEventListener('resize', resizeKodama);
for (var i = 0; i < numSpirits; i++) { for (var i = 0; i < numSpirits; i++) {
// each spirit gets unique proportions // each spirit gets unique proportions
var headR = 0.38 + Math.random() * 0.12; // head radius ratio (bigger = bigger head) var headR = 0.38 + Math.random() * 0.12; // head radius ratio (bigger = bigger head)
var bodyH = 0.25 + Math.random() * 0.2; // body height ratio var bodyH = 0.25 + Math.random() * 0.2; // body height ratio
var bodyW = 0.3 + Math.random() * 0.15; // body width ratio var bodyW = 0.3 + Math.random() * 0.15; // body width ratio
var eyeSpread = 0.18 + Math.random() * 0.1; // how far apart eyes are var eyeSpread = 0.18 + Math.random() * 0.1; // how far apart eyes are
var eyeSize = 0.055 + Math.random() * 0.03; // eye dot size var eyeSize = 0.055 + Math.random() * 0.03; // eye dot size
var eyeY = -0.08 + Math.random() * 0.08; // eye vertical position var eyeY = -0.08 + Math.random() * 0.08; // eye vertical position
var hasMouth = Math.random() > 0.3; // 70% have visible mouth var hasMouth = Math.random() > 0.3; // 70% have visible mouth
var mouthSize = 0.03 + Math.random() * 0.03; var mouthSize = 0.03 + Math.random() * 0.03;
var mouthY = 0.15 + Math.random() * 0.1; var mouthY = 0.15 + Math.random() * 0.1;
var hasArms = Math.random() > 0.4; // 60% have little arm bumps var hasArms = Math.random() > 0.4; // 60% have little arm bumps
var glowSize = 1.4 + Math.random() * 0.8; // glow radius multiplier var glowSize = 1.4 + Math.random() * 0.8; // glow radius multiplier
var glowAlpha = 0.08 + Math.random() * 0.12; // glow brightness var glowAlpha = 0.08 + Math.random() * 0.12; // glow brightness
var tint = Math.floor(Math.random() * 15); // slight warm/cool variation var tint = Math.floor(Math.random() * 15); // slight warm/cool variation
spirits.push({ spirits.push({
x: Math.random() * 0.8 + 0.1, x: Math.random() * 0.8 + 0.1,
baseY: 0.75 + Math.random() * 0.18, baseY: 0.75 + Math.random() * 0.18,
size: 10 + Math.random() * 12, size: 10 + Math.random() * 12,
phase: Math.random() * Math.PI * 2, phase: Math.random() * Math.PI * 2,
tiltSpeed: 1.2 + Math.random() * 2, tiltSpeed: 1.2 + Math.random() * 2,
bobSpeed: 0.6 + Math.random() * 0.8, bobSpeed: 0.6 + Math.random() * 0.8,
opacity: 0, opacity: 0,
targetOpacity: 0.4 + Math.random() * 0.45, targetOpacity: 0.4 + Math.random() * 0.45,
fadeSpeed: 0.002 + Math.random() * 0.004, fadeSpeed: 0.002 + Math.random() * 0.004,
appearing: true, appearing: true,
timer: Math.random() * 600, timer: Math.random() * 600,
lifespan: 500 + Math.random() * 600, lifespan: 500 + Math.random() * 600,
rattleTime: 0, rattleTime: 0,
rattling: false, rattling: false,
// unique shape params // unique shape params
headR: headR, headR: headR,
bodyH: bodyH, bodyH: bodyH,
bodyW: bodyW, bodyW: bodyW,
eyeSpread: eyeSpread, eyeSpread: eyeSpread,
eyeSize: eyeSize, eyeSize: eyeSize,
eyeY: eyeY, eyeY: eyeY,
hasMouth: hasMouth, hasMouth: hasMouth,
mouthSize: mouthSize, mouthSize: mouthSize,
mouthY: mouthY, mouthY: mouthY,
hasArms: hasArms, hasArms: hasArms,
glowSize: glowSize, glowSize: glowSize,
glowAlpha: glowAlpha, glowAlpha: glowAlpha,
tint: tint, tint: tint,
// 8 offsets that warp the head into a unique rock-like blob // 8 offsets that warp the head into a unique rock-like blob
hw: [ hw: [
(Math.random()-0.5)*0.25, (Math.random()-0.5)*0.2, (Math.random()-0.5)*0.25, (Math.random()-0.5)*0.2,
(Math.random()-0.5)*0.2, (Math.random()-0.5)*0.25, (Math.random()-0.5)*0.2, (Math.random()-0.5)*0.25,
(Math.random()-0.5)*0.25, (Math.random()-0.5)*0.2, (Math.random()-0.5)*0.25, (Math.random()-0.5)*0.2,
(Math.random()-0.5)*0.2, (Math.random()-0.5)*0.25 (Math.random()-0.5)*0.2, (Math.random()-0.5)*0.25
], ],
headTall: 0.8 + Math.random() * 0.5 // overall tall vs wide headTall: 0.8 + Math.random() * 0.5 // overall tall vs wide
}); });
} }
function drawKodamaSpirit(x, y, size, tilt, opacity, sp) { function drawKodamaSpirit(x, y, size, tilt, opacity, sp) {
kctx.save(); kctx.save();
kctx.translate(x, y); kctx.translate(x, y);
kctx.globalAlpha = opacity; kctx.globalAlpha = opacity;
var r = size * sp.headR; // head radius var r = size * sp.headR; // head radius
// outer glow aura // outer glow aura
var grd = kctx.createRadialGradient(0, -size * 0.1, r * 0.3, 0, -size * 0.1, r * sp.glowSize); var grd = kctx.createRadialGradient(0, -size * 0.1, r * 0.3, 0, -size * 0.1, r * sp.glowSize);
grd.addColorStop(0, 'rgba(255, 255, 250, ' + sp.glowAlpha + ')'); grd.addColorStop(0, 'rgba(255, 255, 250, ' + sp.glowAlpha + ')');
grd.addColorStop(0.5, 'rgba(255, 255, 250, ' + (sp.glowAlpha * 0.3) + ')'); grd.addColorStop(0.5, 'rgba(255, 255, 250, ' + (sp.glowAlpha * 0.3) + ')');
grd.addColorStop(1, 'rgba(255, 255, 250, 0)'); grd.addColorStop(1, 'rgba(255, 255, 250, 0)');
kctx.fillStyle = grd; kctx.fillStyle = grd;
kctx.beginPath(); kctx.beginPath();
kctx.arc(0, -size * 0.1, r * sp.glowSize, 0, Math.PI * 2); kctx.arc(0, -size * 0.1, r * sp.glowSize, 0, Math.PI * 2);
kctx.fill(); kctx.fill();
// body - stubby rounded shape // body - stubby rounded shape
var bw = size * sp.bodyW; var bw = size * sp.bodyW;
var bh = size * sp.bodyH; var bh = size * sp.bodyH;
var by = size * 0.15; var by = size * 0.15;
kctx.fillStyle = 'rgb(' + (238 + sp.tint) + ',' + (237 + sp.tint) + ',' + (230 + sp.tint) + ')'; kctx.fillStyle = 'rgb(' + (238 + sp.tint) + ',' + (237 + sp.tint) + ',' + (230 + sp.tint) + ')';
kctx.beginPath(); kctx.beginPath();
kctx.ellipse(0, by + bh * 0.4, bw * 0.5, bh * 0.55, 0, 0, Math.PI * 2); kctx.ellipse(0, by + bh * 0.4, bw * 0.5, bh * 0.55, 0, 0, Math.PI * 2);
kctx.fill(); kctx.fill();
// arms - tiny bumps on sides // arms - tiny bumps on sides
if (sp.hasArms) { if (sp.hasArms) {
kctx.beginPath(); kctx.beginPath();
kctx.ellipse(-bw * 0.5 - size * 0.04, by + bh * 0.1, size * 0.05, size * 0.04, -0.3, 0, Math.PI * 2); kctx.ellipse(-bw * 0.5 - size * 0.04, by + bh * 0.1, size * 0.05, size * 0.04, -0.3, 0, Math.PI * 2);
kctx.fill(); kctx.fill();
kctx.beginPath(); kctx.beginPath();
kctx.ellipse(bw * 0.5 + size * 0.04, by + bh * 0.1, size * 0.05, size * 0.04, 0.3, 0, Math.PI * 2); kctx.ellipse(bw * 0.5 + size * 0.04, by + bh * 0.1, size * 0.05, size * 0.04, 0.3, 0, Math.PI * 2);
kctx.fill(); kctx.fill();
} }
// head (tilts) // head (tilts)
kctx.save(); kctx.save();
kctx.rotate(tilt); kctx.rotate(tilt);
// head - unique rock-like blob shape per spirit // head - unique rock-like blob shape per spirit
var hx = 0, hy = -size * 0.15; var hx = 0, hy = -size * 0.15;
var rx = r, ry = r * sp.headTall; var rx = r, ry = r * sp.headTall;
var w = sp.hw; var w = sp.hw;
kctx.fillStyle = 'rgb(' + (243 + sp.tint) + ',' + (242 + sp.tint) + ',' + (237 + sp.tint) + ')'; kctx.fillStyle = 'rgb(' + (243 + sp.tint) + ',' + (242 + sp.tint) + ',' + (237 + sp.tint) + ')';
kctx.beginPath(); kctx.beginPath();
// top // top
kctx.moveTo(hx + r * w[0], hy - ry); kctx.moveTo(hx + r * w[0], hy - ry);
// top-right // top-right
kctx.bezierCurveTo( kctx.bezierCurveTo(
hx + rx * (0.55 + w[0]), hy - ry * (0.9 + w[1]), hx + rx * (0.55 + w[0]), hy - ry * (0.9 + w[1]),
hx + rx * (1.0 + w[1]), hy - ry * (0.4 + w[0]), hx + rx * (1.0 + w[1]), hy - ry * (0.4 + w[0]),
hx + rx * (1.0 + w[2]), hy + ry * w[2]); hx + rx * (1.0 + w[2]), hy + ry * w[2]);
// bottom-right // bottom-right
kctx.bezierCurveTo( kctx.bezierCurveTo(
hx + rx * (1.0 + w[3]), hy + ry * (0.5 + w[2]), hx + rx * (1.0 + w[3]), hy + ry * (0.5 + w[2]),
hx + rx * (0.5 + w[3]), hy + ry * (1.0 + w[3]), hx + rx * (0.5 + w[3]), hy + ry * (1.0 + w[3]),
hx + r * w[4], hy + ry * (0.95 + w[4] * 0.3)); hx + r * w[4], hy + ry * (0.95 + w[4] * 0.3));
// bottom-left // bottom-left
kctx.bezierCurveTo( kctx.bezierCurveTo(
hx - rx * (0.5 + w[5]), hy + ry * (1.0 + w[5]), hx - rx * (0.5 + w[5]), hy + ry * (1.0 + w[5]),
hx - rx * (1.0 + w[5]), hy + ry * (0.5 + w[4]), hx - rx * (1.0 + w[5]), hy + ry * (0.5 + w[4]),
hx - rx * (1.0 + w[6]), hy + ry * w[6]); hx - rx * (1.0 + w[6]), hy + ry * w[6]);
// top-left // top-left
kctx.bezierCurveTo( kctx.bezierCurveTo(
hx - rx * (1.0 + w[7]), hy - ry * (0.4 + w[6]), hx - rx * (1.0 + w[7]), hy - ry * (0.4 + w[6]),
hx - rx * (0.55 + w[7]),hy - ry * (0.9 + w[7]), hx - rx * (0.55 + w[7]),hy - ry * (0.9 + w[7]),
hx + r * w[0], hy - ry); hx + r * w[0], hy - ry);
kctx.fill(); kctx.fill();
// subtle inner highlight // subtle inner highlight
kctx.fillStyle = 'rgba(255, 255, 252, 0.25)'; kctx.fillStyle = 'rgba(255, 255, 252, 0.25)';
kctx.beginPath(); kctx.beginPath();
kctx.arc(-rx * 0.12, hy - ry * 0.1, r * 0.45, 0, Math.PI * 2); kctx.arc(-rx * 0.12, hy - ry * 0.1, r * 0.45, 0, Math.PI * 2);
kctx.fill(); kctx.fill();
// eyes - small round dark dots // eyes - small round dark dots
kctx.fillStyle = 'rgba(15, 15, 15, 0.9)'; kctx.fillStyle = 'rgba(15, 15, 15, 0.9)';
var ey = -size * 0.15 + size * sp.eyeY; var ey = -size * 0.15 + size * sp.eyeY;
var es = size * sp.eyeSize; var es = size * sp.eyeSize;
kctx.beginPath(); kctx.beginPath();
kctx.arc(-size * sp.eyeSpread, ey, es, 0, Math.PI * 2); kctx.arc(-size * sp.eyeSpread, ey, es, 0, Math.PI * 2);
kctx.fill(); kctx.fill();
kctx.beginPath(); kctx.beginPath();
kctx.arc(size * sp.eyeSpread, ey, es, 0, Math.PI * 2); kctx.arc(size * sp.eyeSpread, ey, es, 0, Math.PI * 2);
kctx.fill(); kctx.fill();
// mouth - tiny dot, not all have one // mouth - tiny dot, not all have one
if (sp.hasMouth) { if (sp.hasMouth) {
kctx.fillStyle = 'rgba(15, 15, 15, 0.7)'; kctx.fillStyle = 'rgba(15, 15, 15, 0.7)';
kctx.beginPath(); kctx.beginPath();
kctx.arc(0, ey + size * sp.mouthY, size * sp.mouthSize, 0, Math.PI * 2); kctx.arc(0, ey + size * sp.mouthY, size * sp.mouthSize, 0, Math.PI * 2);
kctx.fill(); kctx.fill();
} }
kctx.restore(); // head tilt kctx.restore(); // head tilt
kctx.restore(); // position kctx.restore(); // position
} }
function drawKodama() { function drawKodama() {
kctx.clearRect(0, 0, kc.width, kc.height); kctx.clearRect(0, 0, kc.width, kc.height);
var t = Date.now() * 0.001; var t = Date.now() * 0.001;
for (var i = 0; i < spirits.length; i++) { for (var i = 0; i < spirits.length; i++) {
var s = spirits[i]; var s = spirits[i];
s.timer++; s.timer++;
if (s.appearing) { if (s.appearing) {
s.opacity += s.fadeSpeed; s.opacity += s.fadeSpeed;
if (s.opacity >= s.targetOpacity) s.opacity = s.targetOpacity; if (s.opacity >= s.targetOpacity) s.opacity = s.targetOpacity;
if (s.timer > s.lifespan) s.appearing = false; if (s.timer > s.lifespan) s.appearing = false;
} else { } else {
s.opacity -= s.fadeSpeed; s.opacity -= s.fadeSpeed;
if (s.opacity <= 0) { if (s.opacity <= 0) {
s.opacity = 0; s.opacity = 0;
s.x = Math.random() * 0.8 + 0.1; s.x = Math.random() * 0.8 + 0.1;
s.baseY = 0.75 + Math.random() * 0.18; s.baseY = 0.75 + Math.random() * 0.18;
s.targetOpacity = 0.4 + Math.random() * 0.45; s.targetOpacity = 0.4 + Math.random() * 0.45;
s.timer = 0; s.timer = 0;
s.lifespan = 500 + Math.random() * 600; s.lifespan = 500 + Math.random() * 600;
s.appearing = true; s.appearing = true;
s.rattleTime = 0; s.rattleTime = 0;
} }
} }
if (s.opacity <= 0) continue; if (s.opacity <= 0) continue;
if (!s.rattling && Math.random() < 0.004) { if (!s.rattling && Math.random() < 0.004) {
s.rattling = true; s.rattling = true;
s.rattleTime = 0; s.rattleTime = 0;
} }
var tilt = Math.sin(t * s.tiltSpeed + s.phase) * 0.1; var tilt = Math.sin(t * s.tiltSpeed + s.phase) * 0.1;
if (s.rattling) { if (s.rattling) {
s.rattleTime++; s.rattleTime++;
tilt = Math.sin(s.rattleTime * 0.9) * 0.35 * Math.max(0, 1 - s.rattleTime / 25); tilt = Math.sin(s.rattleTime * 0.9) * 0.35 * Math.max(0, 1 - s.rattleTime / 25);
if (s.rattleTime > 25) s.rattling = false; if (s.rattleTime > 25) s.rattling = false;
} }
var bobY = Math.sin(t * s.bobSpeed + s.phase) * 2.5; var bobY = Math.sin(t * s.bobSpeed + s.phase) * 2.5;
var px = s.x * kc.width; var px = s.x * kc.width;
var py = s.baseY * kc.height + bobY; var py = s.baseY * kc.height + bobY;
drawKodamaSpirit(px, py, s.size, tilt, s.opacity, s); drawKodamaSpirit(px, py, s.size, tilt, s.opacity, s);
} }
requestAnimationFrame(drawKodama); requestAnimationFrame(drawKodama);
} }
drawKodama(); drawKodama();
})(); })();
</script> </script>

View file

@ -3,8 +3,9 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<meta http-equiv="x-dns-prefetch-control" content="off">
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; * { margin: 0; padding: 0; box-sizing: border-box;
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='3' fill='none' stroke='%23557766' stroke-width='1.5'/%3E%3Ccircle cx='10' cy='10' r='1' fill='%2377aa88'/%3E%3C/svg%3E") 10 10, default; cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='3' fill='none' stroke='%23557766' stroke-width='1.5'/%3E%3Ccircle cx='10' cy='10' r='1' fill='%2377aa88'/%3E%3C/svg%3E") 10 10, default;
@ -13,7 +14,7 @@
html, body { min-height: 100vh; } html, body { min-height: 100vh; }
body { body {
font-family: 'IBM Plex Sans', -apple-system, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 16px;
line-height: 1.65; line-height: 1.65;
color: #9ab4b8; color: #9ab4b8;
@ -65,7 +66,7 @@
} }
nav .site { nav .site {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
color: #b0ccc4; color: #b0ccc4;
@ -158,7 +159,7 @@
border-radius: 4px; border-radius: 4px;
padding: 0.6rem 0.85rem; padding: 0.6rem 0.85rem;
color: #90b4ac; color: #90b4ac;
font-family: 'IBM Plex Sans', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 0.95rem; font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.3s; transition: border-color 0.2s, box-shadow 0.3s;
} }
@ -175,7 +176,7 @@
border-radius: 4px; border-radius: 4px;
padding: 0.6rem 1.1rem; padding: 0.6rem 1.1rem;
color: #5a7880; color: #5a7880;
font-family: 'IBM Plex Sans', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 0.88rem; font-size: 0.88rem;
transition: all 0.2s; transition: all 0.2s;
} }
@ -240,7 +241,7 @@
.tags { margin-top: 0.3rem; } .tags { margin-top: 0.3rem; }
.tag, .tags a { .tag, .tags a {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.7rem; font-size: 0.7rem;
color: #3a5e55; color: #3a5e55;
border: 1px solid rgba(40, 70, 60, 0.35); border: 1px solid rgba(40, 70, 60, 0.35);
@ -273,7 +274,7 @@
li a:hover { border-bottom: 1px solid rgba(80, 130, 110, 0.4); } li a:hover { border-bottom: 1px solid rgba(80, 130, 110, 0.4); }
pre { pre {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.8rem; font-size: 0.8rem;
background: rgba(6, 14, 16, 0.6); background: rgba(6, 14, 16, 0.6);
border: 1px solid rgba(30, 55, 50, 0.3); border: 1px solid rgba(30, 55, 50, 0.3);
@ -285,7 +286,7 @@
} }
code { code {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.82rem; font-size: 0.82rem;
background: rgba(8, 18, 22, 0.6); background: rgba(8, 18, 22, 0.6);
border-radius: 3px; border-radius: 3px;
@ -299,7 +300,7 @@
border-radius: 4px; border-radius: 4px;
padding: 0.7rem 0.9rem; padding: 0.7rem 0.9rem;
color: #9ab4b8; color: #9ab4b8;
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.6; line-height: 1.6;
resize: vertical; resize: vertical;
@ -330,7 +331,7 @@
hr { border: none; border-top: 1px solid rgba(30, 55, 50, 0.3); margin: 1rem 0; } hr { border: none; border-top: 1px solid rgba(30, 55, 50, 0.3); margin: 1rem 0; }
small { small {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.7rem; font-size: 0.7rem;
color: #28454e; color: #28454e;
} }
@ -344,7 +345,7 @@
} }
footer .clock { footer .clock {
font-family: 'IBM Plex Mono', monospace; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.72rem; font-size: 0.72rem;
color: #162a30; color: #162a30;
margin-top: 0.25rem; margin-top: 0.25rem;