+```
+
+### Adding a New Page
+Create new `.astro` file in `src/pages/`:
+```astro
+---
+import BaseLayout from '../layouts/BaseLayout.astro';
+---
+
+
+
+
New Page
+
+
+```
+Note: Pages route based on filename (e.g., `about.astro` → `/about`)
+
+## Implementation Guidelines
+
+**DO:**
+- Maintain cozy, warm aesthetic (coffee/cappuccino theme)
+- Keep site lightweight - static HTML/CSS only, no JavaScript runtime
+- Use CSS custom properties for all colors (defined in `src/layouts/BaseLayout.astro`)
+- Use `.fade-in` class for animations, `.card` class for consistent card styling
+- Test production builds and Docker builds after changes
+- Ensure responsive design works on mobile
+- Follow standard Astro structure (layouts in `src/layouts/`, pages in `src/pages/`)
+
+**DON'T:**
+- Add tracking or external dependencies (privacy-first approach)
+- Add client-side JavaScript unless absolutely necessary
+- Break the coffee/warm color theme
+- Create sterile or corporate design elements
+
+## Astro-Specific Notes
+
+- Frontmatter (code between `---`) runs at build time only
+- `
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 98e57ac..5b00fc9 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -54,8 +54,18 @@ const fullOgImage = new URL(ogImage, Astro.site).href;
name="keywords"
content="self-hosted, privacy, open-source, furry, developer, cozy, hidden den"
/>
-
-
+
+
+
+
@@ -101,6 +111,9 @@ const fullOgImage = new URL(ogImage, Astro.site).href;
--color-blue: #89b4fa;
--color-green: #a6e3a1;
--color-peach: #fab387;
+ --color-glass: rgba(30, 30, 46, 0.8);
+ --color-glass-nav: rgba(30, 30, 46, 0.85);
+ color-scheme: dark;
/* Spacing */
--space-xs: 0.5rem;
@@ -113,6 +126,25 @@ const fullOgImage = new URL(ogImage, Astro.site).href;
--font-body: "JetBrains Mono", "Fira Code", "SF Mono", Consolas, monospace;
}
+ @media (prefers-color-scheme: light) {
+ :root {
+ --color-bg: #f6efe6;
+ --color-bg-light: #efe1cf;
+ --color-surface: #d8c2a8;
+ --color-text: #35251a;
+ --color-text-dim: #6b5442;
+ --color-accent: #8b5e3c;
+ --color-accent-bright: #a16c45;
+ --color-warm: #b6794f;
+ --color-blue: #326b8f;
+ --color-green: #3f7c47;
+ --color-peach: #b5693e;
+ --color-glass: rgba(246, 239, 230, 0.82);
+ --color-glass-nav: rgba(246, 239, 230, 0.88);
+ color-scheme: light;
+ }
+ }
+
* {
margin: 0;
padding: 0;
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..0cafdc1
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,73 @@
+import { createHash, randomBytes } from 'node:crypto';
+import db from './db';
+
+function getSecretKey(): string {
+ const key = process.env.SECRET_KEY;
+ if (!key) throw new Error('SECRET_KEY environment variable is required');
+ return key;
+}
+
+export function hashIP(ip: string): string {
+ return createHash('sha256').update(getSecretKey() + ':' + ip).digest('hex').slice(0, 16);
+}
+
+export function generateId(): string {
+ return randomBytes(32).toString('hex');
+}
+
+export interface AdminSession {
+ id: string;
+ user_id: string;
+ created_at: string;
+ expires_at: string;
+}
+
+export function createSession(userId: string): string {
+ const sessionId = generateId();
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+
+ db.prepare(
+ `INSERT INTO admin_sessions (id, user_id, expires_at) VALUES (?, ?, ?)`
+ ).run(sessionId, userId, expiresAt);
+
+ return sessionId;
+}
+
+export function getSession(sessionId: string): AdminSession | undefined {
+ return db
+ .prepare(
+ `SELECT * FROM admin_sessions
+ WHERE id = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
+ )
+ .get(sessionId) as AdminSession | undefined;
+}
+
+export function deleteSession(sessionId: string): void {
+ db.prepare(`DELETE FROM admin_sessions WHERE id = ?`).run(sessionId);
+}
+
+export function cleanExpiredSessions(): void {
+ db.prepare(
+ `DELETE FROM admin_sessions WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
+ ).run();
+}
+
+export const SESSION_COOKIE = 'admin_session';
+
+function shouldUseSecureCookies(): boolean {
+ const secureOverride = process.env.COOKIE_SECURE?.trim().toLowerCase();
+ if (secureOverride === 'true') return true;
+ if (secureOverride === 'false') return false;
+
+ return process.env.NODE_ENV === 'production';
+}
+
+export function sessionCookieOptions(maxAge: number) {
+ return {
+ httpOnly: true,
+ secure: shouldUseSecureCookies(),
+ sameSite: 'strict' as const,
+ path: '/',
+ maxAge,
+ };
+}
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
new file mode 100644
index 0000000..169f8df
--- /dev/null
+++ b/src/lib/blog.ts
@@ -0,0 +1,17 @@
+export function formatBlogDate(date: Date) {
+ return date.toISOString().split("T")[0];
+}
+
+export function slugifyTag(tag: string) {
+ return tag
+ .toLowerCase()
+ .trim()
+ .replace(/&/g, " and ")
+ .replace(/[^a-z0-9\s-]/g, "")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-");
+}
+
+export function getTagHref(tag: string) {
+ return `/blog/tag/${slugifyTag(tag)}`;
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..e3ae8bf
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,57 @@
+import Database from 'better-sqlite3';
+import { mkdirSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+
+const dbPath = resolve(process.env.DB_PATH ?? './data/guestbook.db');
+
+// Ensure directory exists
+mkdirSync(dirname(dbPath), { recursive: true });
+
+const db = new Database(dbPath);
+
+// WAL mode improves concurrent read performance
+db.pragma('journal_mode = WAL');
+db.pragma('foreign_keys = ON');
+
+db.exec(`
+ CREATE TABLE IF NOT EXISTS guestbook_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ display_name TEXT NOT NULL,
+ message TEXT NOT NULL,
+ website TEXT,
+ ip_hash TEXT,
+ status TEXT NOT NULL DEFAULT 'pending',
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ moderated_at TEXT,
+ moderation_note TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS admin_sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ expires_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS rate_limit (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ip_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ action TEXT NOT NULL,
+ entry_id INTEGER,
+ admin_session TEXT,
+ note TEXT,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_entries_status ON guestbook_entries(status);
+ CREATE INDEX IF NOT EXISTS idx_entries_created ON guestbook_entries(created_at);
+ CREATE INDEX IF NOT EXISTS idx_rate_limit_ip ON rate_limit(ip_hash, created_at);
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires ON admin_sessions(expires_at);
+`);
+
+export default db;
diff --git a/src/lib/guestbook.ts b/src/lib/guestbook.ts
new file mode 100644
index 0000000..a7c0612
--- /dev/null
+++ b/src/lib/guestbook.ts
@@ -0,0 +1,125 @@
+import db from './db';
+
+export interface GuestbookEntry {
+ id: number;
+ display_name: string;
+ message: string;
+ website: string | null;
+ ip_hash: string | null;
+ status: 'pending' | 'approved' | 'rejected' | 'spam';
+ created_at: string;
+ moderated_at: string | null;
+ moderation_note: string | null;
+}
+
+export interface SubmitData {
+ display_name: string;
+ message: string;
+ website: string | null;
+ ip_hash: string | null;
+}
+
+const PAGE_SIZE = 20;
+
+export function getApprovedEntries(page = 1): { entries: GuestbookEntry[]; total: number; hasMore: boolean } {
+ const offset = (page - 1) * PAGE_SIZE;
+ const entries = db
+ .prepare(
+ `SELECT id, display_name, message, website, created_at
+ FROM guestbook_entries
+ WHERE status = 'approved'
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?`
+ )
+ .all(PAGE_SIZE, offset) as GuestbookEntry[];
+
+ const { total } = db
+ .prepare(`SELECT COUNT(*) as total FROM guestbook_entries WHERE status = 'approved'`)
+ .get() as { total: number };
+
+ return { entries, total, hasMore: offset + PAGE_SIZE < total };
+}
+
+export function getPendingEntries(): GuestbookEntry[] {
+ return db
+ .prepare(
+ `SELECT * FROM guestbook_entries WHERE status = 'pending' ORDER BY created_at ASC`
+ )
+ .all() as GuestbookEntry[];
+}
+
+export function getRecentModerated(limit = 30): GuestbookEntry[] {
+ return db
+ .prepare(
+ `SELECT * FROM guestbook_entries
+ WHERE status IN ('approved', 'rejected', 'spam')
+ ORDER BY moderated_at DESC
+ LIMIT ?`
+ )
+ .all(limit) as GuestbookEntry[];
+}
+
+export function submitEntry(data: SubmitData): number {
+ const result = db
+ .prepare(
+ `INSERT INTO guestbook_entries (display_name, message, website, ip_hash)
+ VALUES (?, ?, ?, ?)`
+ )
+ .run(data.display_name, data.message, data.website, data.ip_hash);
+
+ return result.lastInsertRowid as number;
+}
+
+export function moderateEntry(
+ id: number,
+ status: 'approved' | 'rejected' | 'spam',
+ note: string | null,
+ sessionId: string
+): boolean {
+ const result = db
+ .prepare(
+ `UPDATE guestbook_entries
+ SET status = ?, moderated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), moderation_note = ?
+ WHERE id = ? AND status = 'pending'`
+ )
+ .run(status, note, id);
+
+ if (result.changes > 0) {
+ db.prepare(
+ `INSERT INTO audit_log (action, entry_id, admin_session, note) VALUES (?, ?, ?, ?)`
+ ).run(`moderate:${status}`, id, sessionId, note);
+ }
+
+ return result.changes > 0;
+}
+
+export function checkRateLimit(ipHash: string): boolean {
+ // Clean up old entries (>1 hour)
+ db.prepare(
+ `DELETE FROM rate_limit WHERE created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hour')`
+ ).run();
+
+ const { count } = db
+ .prepare(`SELECT COUNT(*) as count FROM rate_limit WHERE ip_hash = ?`)
+ .get(ipHash) as { count: number };
+
+ // Allow max 3 submissions per hour per IP hash
+ return count < 3;
+}
+
+export function recordSubmission(ipHash: string): void {
+ db.prepare(`INSERT INTO rate_limit (ip_hash) VALUES (?)`).run(ipHash);
+}
+
+export function isDuplicateSubmission(displayName: string, message: string): boolean {
+ // Check for exact duplicate in last 24 hours (regardless of status)
+ const row = db
+ .prepare(
+ `SELECT id FROM guestbook_entries
+ WHERE display_name = ? AND message = ?
+ AND created_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')`
+ )
+ .get(displayName, message);
+
+ return row !== undefined;
+}
diff --git a/src/lib/readingTime.ts b/src/lib/readingTime.ts
new file mode 100644
index 0000000..0ae82af
--- /dev/null
+++ b/src/lib/readingTime.ts
@@ -0,0 +1,19 @@
+export function getReadingTime(content: string) {
+ const normalized = content
+ .replace(/```[\s\S]*?```/g, " ")
+ .replace(/`[^`]*`/g, " ")
+ .replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/<[^>]+>/g, " ")
+ .replace(/[#>*_~-]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ const wordCount = normalized ? normalized.split(" ").length : 0;
+ const minutes = Math.max(1, Math.ceil(wordCount / 200));
+
+ return {
+ minutes,
+ text: `${minutes} min read`,
+ };
+}
diff --git a/src/lib/spam.ts b/src/lib/spam.ts
new file mode 100644
index 0000000..4a547d9
--- /dev/null
+++ b/src/lib/spam.ts
@@ -0,0 +1,120 @@
+// Lightweight spam detection and input sanitization
+
+const MAX_NAME_LENGTH = 60;
+const MAX_MESSAGE_LENGTH = 1000;
+const MAX_WEBSITE_LENGTH = 200;
+
+// Patterns that strongly suggest spam
+const SPAM_PATTERNS = [
+ /\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto|investment|forex)\b/i,
+ /\b(click here|buy now|free money|earn \$|make money)\b/i,
+ /(https?:\/\/[^\s]{0,10}){3,}/i, // 3+ URLs in message
+];
+
+// Basic URL validation for the optional website field
+const SAFE_URL_PATTERN = /^https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+(\/[^\s]*)?$/i;
+
+export interface ValidationError {
+ field: string;
+ message: string;
+}
+
+export function sanitizeText(input: string): string {
+ // Normalize whitespace: collapse runs of spaces/tabs, normalize line endings
+ return input
+ .replace(/\r\n/g, '\n')
+ .replace(/\r/g, '\n')
+ .replace(/[ \t]+/g, ' ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+export function stripHtml(input: string): string {
+ // Remove anything that looks like an HTML tag
+ return input.replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/gi, (match) => {
+ const entities: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ ''': "'",
+ };
+ return entities[match] ?? match;
+ });
+}
+
+export function validateWebsite(url: string): string | null {
+ if (!url || url.trim() === '') return null;
+
+ const cleaned = url.trim();
+
+ if (cleaned.length > MAX_WEBSITE_LENGTH) return null;
+ if (!SAFE_URL_PATTERN.test(cleaned)) return null;
+
+ // Block localhost and private ranges in URLs
+ if (/localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\./i.test(cleaned)) return null;
+
+ return cleaned;
+}
+
+export function validateEntry(data: {
+ display_name: string;
+ message: string;
+ website: string;
+ consent: string;
+ honeypot: string;
+}): ValidationError[] {
+ const errors: ValidationError[] = [];
+
+ // Honeypot check: must be empty (bots fill it in)
+ if (data.honeypot && data.honeypot.trim() !== '') {
+ errors.push({ field: 'honeypot', message: 'Spam detected' });
+ return errors;
+ }
+
+ // Consent required
+ if (!data.consent || data.consent !== 'yes') {
+ errors.push({ field: 'consent', message: 'You must consent to public posting' });
+ }
+
+ // Display name validation
+ const name = stripHtml(sanitizeText(data.display_name));
+ if (!name || name.length < 1) {
+ errors.push({ field: 'display_name', message: 'Please enter a display name' });
+ } else if (name.length > MAX_NAME_LENGTH) {
+ errors.push({ field: 'display_name', message: `Name must be ${MAX_NAME_LENGTH} characters or less` });
+ }
+
+ // Message validation
+ const message = stripHtml(sanitizeText(data.message));
+ if (!message || message.length < 1) {
+ errors.push({ field: 'message', message: 'Please enter a message' });
+ } else if (message.length > MAX_MESSAGE_LENGTH) {
+ errors.push({ field: 'message', message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less` });
+ }
+
+ return errors;
+}
+
+export function scoreSpam(data: { display_name: string; message: string; website: string | null }): number {
+ let score = 0;
+ const combined = `${data.display_name} ${data.message} ${data.website ?? ''}`;
+
+ for (const pattern of SPAM_PATTERNS) {
+ if (pattern.test(combined)) score += 30;
+ }
+
+ // Lots of URLs in message is suspicious
+ const urlCount = (data.message.match(/https?:\/\//gi) ?? []).length;
+ if (urlCount >= 2) score += 20 * urlCount;
+
+ // All-caps message is a mild spam signal
+ const upperRatio = (data.message.match(/[A-Z]/g) ?? []).length / Math.max(data.message.length, 1);
+ if (upperRatio > 0.6 && data.message.length > 10) score += 15;
+
+ return score;
+}
+
+export function isLikelySpam(data: { display_name: string; message: string; website: string | null }): boolean {
+ return scoreSpam(data) >= 50;
+}
diff --git a/src/pages/404.astro b/src/pages/404.astro
index a6c5ac0..d25f62e 100644
--- a/src/pages/404.astro
+++ b/src/pages/404.astro
@@ -66,7 +66,7 @@ import BaseLayout from "../layouts/BaseLayout.astro";
.container {
max-width: 500px;
width: 100%;
- background: rgba(30, 30, 46, 0.8);
+ background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
diff --git a/src/pages/about.astro b/src/pages/about.astro
index 1a4906a..c15bdc9 100644
--- a/src/pages/about.astro
+++ b/src/pages/about.astro
@@ -4,67 +4,192 @@ import BaseLayout from "../layouts/BaseLayout.astro";
+
Latte / Hidden Den
About
══════════════════════════════
-
-
-
-
The Den
-
- Hidden Den Cafe is my little corner of the internet — self-hosted,
- self-maintained, and free from corporate nonsense. No trackers, no ads,
- no data harvesting. Just a cozy space that I built and control.
+
+ Hi, I'm Latte. Hidden Den exists because I wanted a personal place on
+ the internet that feels the way I want technology to feel: calm,
+ understandable, warm, and fully mine.
-
+
Who I Am
- I'm Latte — an IT wizard with a homelab,
- a love for privacy, and a deep distrust of companies that treat your data
- like their product. I believe in owning your infrastructure, running your
- own services, and keeping things under your own roof.
+ I'm Latte - an IT professional, a
+ developer, and someone who spends a lot of time close to systems. A
+ lot of my day-to-day thinking is shaped by infrastructure, maintenance,
+ deployment, networking, and the quiet work required to keep things
+ reliable. I like understanding how things fit together, not just using
+ them from a distance.
- I'm a gay furry developer who builds things because I want them to exist —
- not because some product manager told me to. My stack leans toward Python
- and self-hosted tooling, but I'm always exploring new things.
+ I run a homelab because I enjoy learning by building, breaking,
+ fixing, and gradually improving the systems I rely on. I tend to
+ prefer tools I can audit, services I can migrate, setups I can back
+ up, and infrastructure I can replace without begging a platform to keep
+ my life intact.
-
The Homelab
+
The Person Behind The Stack
- Most of what runs here lives on my own hardware — Gitea for code, Docker
- for deployment, nginx for serving. Where physical infra doesn't make sense,
- I rent VPS capacity from OVH and Play.hosting. For work, the Microsoft 365
- ecosystem does what it needs to do.
+ The technical side is real, but it is not the whole story. I'm also a
+ gay furry developer with a soft spot for cozy cafe aesthetics, warm
+ tones, coffee culture, quiet spaces, and slow building. I am much less
+ interested in performing some polished hacker persona than I am in
+ making a space that feels thoughtful, lived in, and unmistakably human.
- The goal isn't purity — it's control. Keep data minimal, choose providers
- you understand, avoid surveillance-adjacent platforms. Self-host what you
- can; rent infra where it's practical; use what you need without pretending
- you don't.
+ Hidden Den reflects that mix. It is technical, but not sterile. It is
+ personal, but not performative. It is a place where infrastructure,
+ writing, projects, experiments, and internet philosophy can sit next to
+ warmth, identity, and the kinds of details that make a site feel like
+ someone actually lives there.
+
+
+
+ More cozy tech wizard than cyberpunk hacker.
+
+
+
+
+
+
How I Tend To Build
+
+
+
Understand It
+
+ I am most comfortable with systems I can inspect and reason
+ about. If I do not understand the tradeoffs, the failure modes,
+ or the path out, I do not feel like I really own the tool.
+
+
+
+
Keep It Durable
+
+ I prefer setups that can be backed up, migrated, repaired, and
+ replaced. Durable systems are not always flashy, but they age
+ better and make better foundations for real life.
+
+
+
+
Leave Room For Care
+
+ I care about interfaces and environments that feel intentional.
+ Warmth matters to me. I do not think technical spaces need to be
+ cold to be serious.
+
+
+
+
Stay Practical
+
+ I do not treat purity as a goal. I self-host a lot because it
+ teaches me things and gives me control, but I still care about
+ workable systems more than ideological posturing.
+
self-hosting: If you can run it yourself, you should.
-
open source: Knowledge should be shared.
-
small web: The internet is better when it's personal.
+
Why Hidden Den Exists
+
+ This site is not a portfolio, a startup brand, or a personal marketing
+ project. It exists because I wanted a real personal website again: a
+ place for writing, projects, experiments, infrastructure notes, and the
+ kinds of ideas that do not fit neatly into social platforms.
+
+
+ I wanted something quieter than a feed and more honest than a polished
+ personal brand. Hidden Den gives me room to publish on my own terms and
+ let the site grow slowly, in the shape that actually suits me.
+
+
+
+
+
Why This Matters To Me
+
+ Working with infrastructure changes how you see the internet. It reveals
+ the parts most people never notice: who owns the platform, where the data
+ goes, what happens when the service changes, and how little control people
+ often have over the spaces they depend on. That matters to me both
+ technically and personally.
+
+
+ I care about technology that feels intentional instead of engineered for
+ surveillance, lock-in, or endless engagement. I want the tools around me
+ to be legible. I want the places I spend time in to respect the people
+ using them. Hidden Den is one small attempt to build that kind of space.
+
+
+
+
+
On The Internet I Want
+
+ Too much of the modern web is optimized for extraction: attention,
+ behavior, identity, and dependence. I prefer a smaller internet made of
+ personal sites, weird projects, community infrastructure, and spaces
+ that are allowed to be specific. Not everything needs to become a
+ platform, and not every page needs to be a funnel.
+
+
+ I still believe the web is better when more people make places that feel
+ like their own. Places with taste. Places with personality. Places that
+ are maintained because someone cares about them, not because they have
+ been optimized against a dashboard.
+
+
+
+
+
Privacy-First, In Practice
+
+ Privacy is part of the philosophy here, but it is also part of the
+ implementation. Hidden Den avoids trackers, ads, invasive analytics, and
+ unnecessary third-party dependencies. Static pages are not a compromise
+ for me. They are often the cleaner solution.
+
+
+ The same goes for the infrastructure behind the site. I prefer systems I
+ can audit, migrate, back up, and replace. People should be able to visit
+ a personal website without quietly being turned into a behavioral profile.
+
+
+
no trackers: Visitors are guests, not telemetry.
+
minimal dependencies: Fewer external systems means fewer leaks.
+
self-hosting bias: Control matters when the tradeoff is reasonable.
+
human scale: The site is built to feel inhabited, not optimized.
+
+
What I Want This Place To Be
+
+ Hidden Den is meant to feel like a quiet corner of the internet: warm,
+ thoughtful, technical, and personal. A place where I can share what I am
+ building and thinking about without flattening myself into a bio, a
+ brand, or a feed.
+
+
+ If this page does its job, it should feel clear that there is a real
+ person behind the site. Someone who likes systems and infrastructure,
+ cares about privacy, prefers warm light over neon, and still thinks the
+ internet is worth building on carefully.
+