remove bloat
@@ -1,153 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>42</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400&family=DM+Mono:wght@300&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--ink: #1a1a18;
|
||||
--ink-light: #9a9890;
|
||||
--paper: #fafaf7;
|
||||
--accent: #1a1a18;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-weight: 300;
|
||||
font-size: clamp(5rem, 20vw, 9rem);
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--ink);
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
animation: rise 1s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 48px;
|
||||
background: var(--ink-light);
|
||||
margin: 2.5rem auto;
|
||||
opacity: 0;
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
animation: grow 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.6s forwards;
|
||||
}
|
||||
|
||||
.quote {
|
||||
font-family: 'EB Garamond', Georgia, serif;
|
||||
font-size: clamp(1.15rem, 3.5vw, 1.4rem);
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0;
|
||||
animation: rise 1s cubic-bezier(0.16, 1, 0.3, 1) 0.9s forwards;
|
||||
}
|
||||
|
||||
.attribution {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-weight: 300;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink-light);
|
||||
margin-top: 1.25rem;
|
||||
text-transform: uppercase;
|
||||
opacity: 0;
|
||||
animation: rise 1s cubic-bezier(0.16, 1, 0.3, 1) 1.2s forwards;
|
||||
}
|
||||
|
||||
.secret-hint {
|
||||
margin-top: 4rem;
|
||||
opacity: 0;
|
||||
animation: rise 1s cubic-bezier(0.16, 1, 0.3, 1) 2s forwards;
|
||||
}
|
||||
|
||||
.secret-hint p {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-weight: 300;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-light);
|
||||
cursor: default;
|
||||
transition: color 0.4s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.secret-hint p:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.secret-hint a {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-weight: 300;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: transparent;
|
||||
text-decoration: none;
|
||||
transition: color 0.6s, letter-spacing 0.4s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secret-hint:hover a {
|
||||
color: var(--ink-light);
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.secret-hint a:hover {
|
||||
color: var(--ink) !important;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
to { opacity: 1; transform: translateY(0) scaleY(1); }
|
||||
}
|
||||
|
||||
@keyframes grow {
|
||||
to { opacity: 1; transform: scaleY(1); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<div class="number">42</div>
|
||||
<div class="divider"></div>
|
||||
<p class="quote">So long, and thanks for all the fish.</p>
|
||||
<p class="attribution">Douglas Adams · The Hitchhiker's Guide to the Galaxy</p>
|
||||
|
||||
<div class="secret-hint">
|
||||
<p>shhh. i have a secret to share — <a href="/secret">follow me</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,516 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin · samantha42.xyz</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;1,300&display=swap" rel="stylesheet" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #fafaf8;
|
||||
--bg-2: #f1efe8;
|
||||
--text: #1a1a18;
|
||||
--muted: #888780;
|
||||
--border: rgba(0,0,0,0.12);
|
||||
--accent: #185FA5;
|
||||
--accent-dim: #E6F1FB;
|
||||
--success: #0F6E56;
|
||||
--success-dim: #E1F5EE;
|
||||
--danger: #A32D2D;
|
||||
--danger-dim: #FCEBEB;
|
||||
--warn: #854F0B;
|
||||
--warn-dim: #FAEEDA;
|
||||
--ff-mono: 'IBM Plex Mono', monospace;
|
||||
--ff-sans: 'IBM Plex Sans', sans-serif;
|
||||
}
|
||||
|
||||
body.night {
|
||||
--bg: #111110;
|
||||
--bg-2: #1a1a18;
|
||||
--text: #e8e6df;
|
||||
--muted: #5F5E5A;
|
||||
--border: rgba(255,255,255,0.1);
|
||||
--accent-dim: #042C53;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--ff-mono);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2rem;
|
||||
height: 48px;
|
||||
border-bottom: 0.5px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-logo { font-size: 13px; color: var(--text); text-decoration: none; }
|
||||
.nav-right { display: flex; align-items: center; gap: 1.5rem; }
|
||||
.nav-right a { font-size: 12px; color: var(--muted); text-decoration: none; }
|
||||
.nav-right a:hover { color: var(--text); }
|
||||
|
||||
.shell { display: grid; grid-template-columns: 200px 1fr; min-height: calc(100vh - 48px); }
|
||||
|
||||
aside {
|
||||
border-right: 0.5px solid var(--border);
|
||||
padding: 1.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sidebar-links { display: flex; flex-direction: column; gap: 2px; }
|
||||
.sidebar-links a {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.sidebar-links a:hover { background: var(--bg-2); color: var(--text); }
|
||||
.sidebar-links a.active { background: var(--bg-2); color: var(--text); }
|
||||
|
||||
main { padding: 2rem; max-width: 900px; }
|
||||
|
||||
.page-header { margin-bottom: 2rem; }
|
||||
.page-title { font-size: 20px; font-weight: 500; color: var(--text); }
|
||||
.page-sub { font-size: 11px; color: var(--muted); letter-spacing: 1px; }
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-2);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.stat-label { font-size: 10px; color: var(--muted); letter-spacing: 1px; text-transform: uppercase; margin-bottom: 6px; }
|
||||
.stat-value { font-size: 22px; font-weight: 500; color: var(--text); }
|
||||
.stat-sub { font-size: 10px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 0.5px solid var(--border);
|
||||
}
|
||||
.section-head h2 { font-size: 13px; font-weight: 500; color: var(--text); }
|
||||
|
||||
.job-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 2rem; }
|
||||
|
||||
.job-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 100px 80px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.job-name { font-size: 12px; color: var(--text); }
|
||||
.job-time { font-size: 11px; color: var(--muted); }
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--ff-mono);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge-ok { background: var(--success-dim); color: var(--success); }
|
||||
.badge-fail { background: var(--danger-dim); color: var(--danger); }
|
||||
.badge-run { background: var(--warn-dim); color: var(--warn); }
|
||||
.badge-idle { background: var(--bg-2); color: var(--muted); }
|
||||
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
.run-btn {
|
||||
font-family: var(--ff-mono);
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.run-btn:hover { background: var(--bg-2); color: var(--text); }
|
||||
|
||||
.compose-card {
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.compose-head {
|
||||
background: #0C447C;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.compose-head-title { font-size: 12px; color: #B5D4F4; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.compose-head-sub { font-size: 11px; color: #378ADD; }
|
||||
|
||||
.compose-body { padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
.field-label {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.field-input, .field-select, .field-textarea {
|
||||
width: 100%;
|
||||
font-family: var(--ff-mono);
|
||||
font-size: 13px;
|
||||
padding: 9px 12px;
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-2);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.field-input:focus, .field-select:focus, .field-textarea:focus {
|
||||
border-color: #378ADD;
|
||||
box-shadow: 0 0 0 3px rgba(55,138,221,0.12);
|
||||
}
|
||||
.field-textarea { resize: vertical; min-height: 200px; line-height: 1.6; }
|
||||
|
||||
.compose-footer {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 0.5px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sub-count { font-size: 11px; color: var(--muted); }
|
||||
|
||||
.btn-send {
|
||||
font-family: var(--ff-mono);
|
||||
font-size: 12px;
|
||||
padding: 8px 20px;
|
||||
background: #185FA5;
|
||||
color: #E6F1FB;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.btn-send:hover { background: #0C447C; }
|
||||
.btn-send:active { transform: scale(0.98); }
|
||||
|
||||
.btn-preview {
|
||||
font-family: var(--ff-mono);
|
||||
font-size: 12px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-preview:hover { background: var(--bg-2); color: var(--text); }
|
||||
|
||||
.send-result {
|
||||
font-size: 11px;
|
||||
color: var(--success);
|
||||
display: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.htmx-request .htmx-indicator { opacity: 1; }
|
||||
|
||||
.section-gap { margin-bottom: 2.5rem; }
|
||||
|
||||
.ticker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ticker-symbol { font-weight: 500; min-width: 80px; }
|
||||
.ticker-rating-buy { color: var(--success); }
|
||||
.ticker-rating-sell { color: var(--danger); }
|
||||
.ticker-muted { color: var(--muted); flex: 1; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
aside { display: none; }
|
||||
.job-row { grid-template-columns: 1fr 80px; }
|
||||
.job-time, .run-btn { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body id="themeRoot">
|
||||
|
||||
<nav>
|
||||
<a class="nav-logo" href="/">samantha_vero_friis</a>
|
||||
<div class="nav-right">
|
||||
<span style="font-size:11px;color:var(--muted)">admin portal</span>
|
||||
<a href="/logout">logout →</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="shell">
|
||||
<aside>
|
||||
<div>
|
||||
<p class="sidebar-label">admin</p>
|
||||
<div class="sidebar-links">
|
||||
<a href="#overview" class="active">overview</a>
|
||||
<a href="#jobs">job status</a>
|
||||
<a href="#newsletter">newsletter</a>
|
||||
<a href="#research">eq research</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="sidebar-label">site</p>
|
||||
<div class="sidebar-links">
|
||||
<a href="/" target="_blank">view site ↗</a>
|
||||
<a href="/git" target="_blank">git repo ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
|
||||
<div class="page-header" id="overview">
|
||||
<div class="page-title">admin</div>
|
||||
<div class="page-sub">samantha42.xyz · control panel</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">subscribers</div>
|
||||
<div class="stat-value" hx-get="/admin/api/stats/subscribers" hx-trigger="load" hx-swap="innerHTML">—</div>
|
||||
<div class="stat-sub">newsletter list</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">emails sent</div>
|
||||
<div class="stat-value" hx-get="/admin/api/stats/sent" hx-trigger="load" hx-swap="innerHTML">—</div>
|
||||
<div class="stat-sub">all time</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">analyses</div>
|
||||
<div class="stat-value">2</div>
|
||||
<div class="stat-sub">published</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">jobs ok</div>
|
||||
<div class="stat-value" hx-get="/admin/api/stats/jobs" hx-trigger="load" hx-swap="innerHTML">—</div>
|
||||
<div class="stat-sub">last 24h</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-gap" id="jobs">
|
||||
<div class="section-head">
|
||||
<h2>// job status</h2>
|
||||
<button class="run-btn" hx-get="/admin/api/jobs" hx-target="#job-list" hx-swap="innerHTML">refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="job-list" id="job-list"
|
||||
hx-get="/admin/api/jobs"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
|
||||
<div class="job-row">
|
||||
<span class="job-name">portfolio-engine</span>
|
||||
<span class="job-time">checking...</span>
|
||||
<span class="badge badge-idle"><span class="dot"></span> loading</span>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-gap" id="newsletter">
|
||||
<div class="section-head">
|
||||
<h2>// send newsletter</h2>
|
||||
<span class="htmx-indicator" id="send-spinner">sending...</span>
|
||||
</div>
|
||||
|
||||
<div class="compose-card">
|
||||
<div class="compose-head">
|
||||
<span class="compose-head-title">equity research · newsletter</span>
|
||||
<span class="compose-head-sub">to all subscribers</span>
|
||||
</div>
|
||||
<div class="compose-body">
|
||||
<div>
|
||||
<label class="field-label" for="nl-subject">subject</label>
|
||||
<input class="field-input" type="text" id="nl-subject" name="subject"
|
||||
placeholder="MSTR: why the premium collapses without a bid" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="nl-ticker">ticker (optional)</label>
|
||||
<input class="field-input" type="text" id="nl-ticker" name="ticker"
|
||||
placeholder="MSTR · sell" style="width:200px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="nl-body">body</label>
|
||||
<textarea class="field-textarea" id="nl-body" name="body"
|
||||
placeholder="// thesis The truth I keep coming back to..."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="nl-pdf">attach pdf (path on server)</label>
|
||||
<input class="field-input" type="text" id="nl-pdf" name="pdf_path"
|
||||
placeholder="/static/pdf/mstr.pdf" style="width:300px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="compose-footer">
|
||||
<span class="sub-count" hx-get="/admin/api/stats/subscribers" hx-trigger="load" hx-swap="innerHTML"
|
||||
style="font-size:11px;color:var(--muted)">— subscribers</span>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn-preview" onclick="previewEmail()">preview</button>
|
||||
<button class="btn-send"
|
||||
hx-post="/admin/api/newsletter/send"
|
||||
hx-include="#nl-subject,#nl-ticker,#nl-body,#nl-pdf"
|
||||
hx-target="#send-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#send-spinner">
|
||||
send to all →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="send-result" style="padding:0 1.25rem 1rem;font-size:11px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-gap" id="research">
|
||||
<div class="section-head">
|
||||
<h2>// equity research</h2>
|
||||
<a href="#newsletter" style="font-size:11px;color:var(--muted);text-decoration:none;">send as newsletter ↑</a>
|
||||
</div>
|
||||
|
||||
<div class="ticker-row">
|
||||
<span class="ticker-symbol">MSTR</span>
|
||||
<span class="ticker-rating-sell">sell</span>
|
||||
<span class="ticker-muted">Strategy · BTC proxy via debt issuance</span>
|
||||
<a href="/static/pdf/mstr.pdf" target="_blank" style="font-size:11px;color:var(--muted);text-decoration:none;">pdf ↗</a>
|
||||
</div>
|
||||
<div class="ticker-row">
|
||||
<span class="ticker-symbol">NOVO.B / NVO</span>
|
||||
<span class="ticker-rating-buy">buy</span>
|
||||
<span class="ticker-muted">Novo Nordisk · pipeline undervalued</span>
|
||||
<a href="/static/pdf/novo.pdf" target="_blank" style="font-size:11px;color:var(--muted);text-decoration:none;">pdf ↗</a>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1.5rem;">
|
||||
<div class="section-head">
|
||||
<h2>// add new analysis</h2>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px;">
|
||||
<div>
|
||||
<label class="field-label">ticker</label>
|
||||
<input class="field-input" type="text" name="new_ticker" placeholder="AAPL" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">rating</label>
|
||||
<select class="field-select" name="new_rating">
|
||||
<option value="buy">buy</option>
|
||||
<option value="sell">sell</option>
|
||||
<option value="hold">hold</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<label class="field-label">summary</label>
|
||||
<input class="field-input" type="text" name="new_summary" placeholder="one-line thesis" />
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<label class="field-label">pdf path</label>
|
||||
<input class="field-input" type="text" name="new_pdf" placeholder="/static/pdf/aapl.pdf" style="width:300px;" />
|
||||
</div>
|
||||
<button class="btn-send"
|
||||
hx-post="/admin/api/research/add"
|
||||
hx-include="[name='new_ticker'],[name='new_rating'],[name='new_summary'],[name='new_pdf']"
|
||||
hx-target="#research-result"
|
||||
hx-swap="innerHTML">
|
||||
publish →
|
||||
</button>
|
||||
<div id="research-result" style="margin-top:8px;font-size:11px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function previewEmail() {
|
||||
const subject = document.getElementById('nl-subject').value;
|
||||
const body = document.getElementById('nl-body').value;
|
||||
const w = window.open('', '_blank');
|
||||
w.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>
|
||||
body{font-family:'IBM Plex Mono',monospace;max-width:600px;margin:40px auto;padding:0 20px;background:#fafaf8;color:#1a1a18;font-size:13px;line-height:1.7;}
|
||||
h1{font-size:18px;font-weight:500;margin-bottom:4px;}
|
||||
.sub{font-size:11px;color:#888780;margin-bottom:2rem;}
|
||||
pre{white-space:pre-wrap;font-family:inherit;}
|
||||
.footer{margin-top:3rem;padding-top:1rem;border-top:0.5px solid #d3d1c7;font-size:10px;color:#888780;}
|
||||
</style></head><body>
|
||||
<h1>${subject || '(no subject)'}</h1>
|
||||
<div class="sub">samantha42.xyz · equity research</div>
|
||||
<pre>${body || '(no body)'}</pre>
|
||||
<div class="footer">samantha42.xyz · me@samantha42.xyz · unsubscribe</div>
|
||||
</body></html>`);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.sidebar-links a').forEach(a => {
|
||||
a.addEventListener('click', function() {
|
||||
document.querySelectorAll('.sidebar-links a').forEach(x => x.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,383 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>The Industry That Ate Itself - Samantha Vero Friis</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/style/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container--narrow">
|
||||
<header>
|
||||
<a href="/" class="back">← back</a>
|
||||
<p class="tag">// opinion</p>
|
||||
<h1>The Industry<br />That Ate<br /><span>Itself</span></h1>
|
||||
<div class="byline">
|
||||
<span>Samantha Vero Friis</span>
|
||||
<span>—</span>
|
||||
<span>Film & Media</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article>
|
||||
<p class="lead">
|
||||
Cinema is not dying because audiences stopped caring about
|
||||
film. It is dying because the people running it stopped
|
||||
making decisions that could produce anything worth caring
|
||||
about. That is a business problem, not a cultural one - and
|
||||
it has a clear and traceable cause.
|
||||
</p>
|
||||
|
||||
<h2>How You Optimize a Creative Industry to Death</h2>
|
||||
|
||||
<p>
|
||||
Every major studio is ultimately answerable to capital. That
|
||||
is not unusual - most industries are. What is unusual about
|
||||
film is that capital and creative quality are in unusually
|
||||
direct tension. A film that takes a genuine risk might return
|
||||
ten times its budget or nothing. A sequel to an established
|
||||
property will almost certainly return two or three times its
|
||||
budget, reliably, with predictable variance. For a public
|
||||
company managing quarterly earnings, the choice is not
|
||||
difficult. You make the sequel.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The problem is that this logic, applied consistently across
|
||||
an entire industry over twenty years, does not produce a
|
||||
stable business. It produces a slow erosion of the thing
|
||||
that made the business worth having in the first place.
|
||||
Audiences do not go to cinemas to see sequels to sequels.
|
||||
They go because occasionally something extraordinary happens
|
||||
on a screen and they want to be in the room when it does.
|
||||
Strip out the extraordinary, and you have stripped out the
|
||||
reason to show up.
|
||||
</p>
|
||||
|
||||
<h2>The Franchise as a Business Model</h2>
|
||||
|
||||
<p>
|
||||
The Matrix is a precise case study because the original
|
||||
trilogy was, by design, a complete thing. Three films with
|
||||
a beginning, a middle, and an ending. The story resolved.
|
||||
The characters finished where they were going. Whatever
|
||||
criticisms one might make of Reloaded or Revolutions, the
|
||||
trilogy had the structural integrity of something that knew
|
||||
it was going to end. That is increasingly rare, and it
|
||||
matters - because an ending is what separates a story from
|
||||
a content pipeline.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The correct response to owning the Matrix IP after the
|
||||
trilogy was either to leave it alone or to build something
|
||||
genuinely new inside the same universe. The world the
|
||||
Wachowskis constructed is large enough. New characters, a
|
||||
different era, a different corner of the machine war, a
|
||||
different question about the nature of the simulation - any
|
||||
of these could have been the basis for something that
|
||||
respected the original while standing on its own. A current
|
||||
audience does not need Neo. It needs a new conflict with
|
||||
real stakes and a story it has not already seen.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
What Warner Bros. produced instead was The Matrix
|
||||
Resurrections in 2021 - eighteen years after the trilogy
|
||||
ended - with the same lead actors, the same Agent Smith,
|
||||
and a plot that literally resurrects the characters the
|
||||
previous films had concluded. The decision to bring back
|
||||
the same faces was not a creative one. It was a calculation
|
||||
that name recognition and nostalgia would lower the
|
||||
perceived risk of the investment. The studio did not believe
|
||||
in the universe. It believed in the brand. The audience
|
||||
noticed. The film failed - not because the Matrix concept
|
||||
is exhausted, but because the film offered nothing except
|
||||
proof that the people making it were not willing to find
|
||||
out whether it could work without a safety net.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Star Wars industrialised this dynamic at a scale that was
|
||||
previously unimaginable. The acquisition of Lucasfilm was
|
||||
not a creative decision - it was a content library acquisition.
|
||||
What followed was the systematic conversion of a mythology
|
||||
that had genuine cultural weight into a production pipeline.
|
||||
Films, series, spin-offs, prequels, sequels, all released
|
||||
on a cadence driven by release windows and subscriber targets
|
||||
rather than by whether anyone had something worth saying.
|
||||
The audience eventually signalled, clearly, that it was
|
||||
exhausted. The response was not to slow down - it was to
|
||||
try different combinations of the same components.
|
||||
</p>
|
||||
|
||||
<blockquote>
|
||||
A franchise is not a creative decision.<br />
|
||||
It is a <span>risk management strategy</span> dressed as one.
|
||||
</blockquote>
|
||||
|
||||
<h2>Marvel and the Pipeline Fallacy</h2>
|
||||
|
||||
<p>
|
||||
The Marvel Cinematic Universe is the most important case
|
||||
study in modern film economics because it succeeded
|
||||
spectacularly for long enough that the people running it
|
||||
drew the wrong conclusion from their own success. From Iron
|
||||
Man in 2008 through to Endgame in 2019, the MCU produced
|
||||
something genuinely unusual: a connected series of films
|
||||
that were, individually, mostly good, and collectively
|
||||
something audiences had never experienced before. The
|
||||
shared universe worked. Characters crossed over. Threads
|
||||
built across years. When it paid off, it paid off at a
|
||||
scale that reshaped the entire industry's assumptions about
|
||||
what a film could be.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The mistake was in identifying why it worked. The shared
|
||||
universe was not the product. It was the reward for the
|
||||
product. Audiences invested in the connections between
|
||||
films because the films themselves had earned that
|
||||
investment. Iron Man was a good film. The Winter Soldier
|
||||
was a good film. Guardians of the Galaxy was a good film.
|
||||
Each one worked as a standalone experience, which meant
|
||||
that when they intersected, the intersection meant
|
||||
something. The connective tissue had weight because the
|
||||
individual pieces had weight.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
What Marvel - and Disney, which had acquired it - took
|
||||
from this was a different lesson: that the pipeline itself
|
||||
was the asset. That releasing four films and multiple
|
||||
Disney+ series per year, all tagged as MCU content, would
|
||||
sustain audience engagement indefinitely because the brand
|
||||
had accumulated enough goodwill to carry anything attached
|
||||
to it. This is a category error. Goodwill is not a
|
||||
renewable resource. It is the residue of past quality,
|
||||
and it depletes every time it is spent on something that
|
||||
does not deserve it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The post-Endgame MCU inherited an audience that had spent
|
||||
eleven years being rewarded for paying attention. What it
|
||||
offered in return was volume. Films that were
|
||||
competently assembled but existed primarily to move pieces
|
||||
into position for the next film. Disney+ series that were
|
||||
mandatory viewing if you wanted to understand the cinema
|
||||
releases, effectively converting a leisure choice into
|
||||
homework. Characters introduced and abandoned. Storylines
|
||||
launched and quietly dropped. The shared universe, which
|
||||
had once been the payoff for engagement, became the reason
|
||||
engagement was required just to follow along. The audience
|
||||
began to disengage - not because superhero films stopped
|
||||
being possible, but because the implicit contract had been
|
||||
broken. The pipeline had forgotten that it was dependent
|
||||
on the films being good, not merely connected.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Streaming did not fix this dynamic. It accelerated it and
|
||||
added a new and more corrosive incentive on top: subscriber
|
||||
acquisition. A studio making theatrical films needs a film
|
||||
to be good enough that people will pay to see it. A streaming
|
||||
platform needs content to exist in sufficient volume and
|
||||
variety that enough different people will start a subscription
|
||||
and not cancel it. These are genuinely different problems,
|
||||
and the second one does not require quality - it requires
|
||||
surface area.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Altered Carbon is a personal example of what this looks
|
||||
like from the other side. The first season was genuinely
|
||||
good - ambitious production design, a coherent adaptation
|
||||
of its source material, the kind of science fiction that
|
||||
takes its own premise seriously. I liked it. I kept
|
||||
watching. I was exactly the subscriber Netflix should want
|
||||
to retain. The show was cancelled after two seasons
|
||||
regardless, and I eventually cancelled the subscription.
|
||||
Those two facts are more connected than they might appear.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The issue is not that Netflix made a bad decision by its
|
||||
own logic. The issue is what its logic measures. A
|
||||
subscriber who genuinely loves three or four ambitious
|
||||
series and watches them carefully registers identically to
|
||||
a subscriber who half-watches twenty shows and never
|
||||
finishes any of them - as long as both keep paying. The
|
||||
metric that matters is new sign-ups, because that is what
|
||||
the market rewards. Retention of engaged viewers who care
|
||||
about specific content is a secondary concern at best.
|
||||
Cancelling Altered Carbon did not cost Netflix the number
|
||||
it was optimizing for. It cost them me - eventually - and
|
||||
I was never the unit being counted.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
What this produces is a library optimized not for quality
|
||||
or loyalty but for the minimum threshold of good enough to
|
||||
keep someone paying while the next acquisition drives new
|
||||
sign-ups. The service does not need you to love it. It
|
||||
needs you to not quite hate it enough to cancel. That is a
|
||||
meaningful distinction. A library built around that
|
||||
incentive looks very different from one built around making
|
||||
things worth watching - and over time, the difference
|
||||
becomes visible.
|
||||
</p>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="attack-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Primary incentive</th>
|
||||
<th>What it produces</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Theatrical studio</td>
|
||||
<td>
|
||||
Maximise opening weekend and franchise
|
||||
extension potential
|
||||
</td>
|
||||
<td class="counter">
|
||||
IP acquisitions, sequels, safe casting,
|
||||
risk-averse greenlight decisions
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Streaming platform</td>
|
||||
<td>
|
||||
Subscriber acquisition and retention
|
||||
across the broadest possible audience
|
||||
</td>
|
||||
<td class="counter">
|
||||
High volume, uneven quality, early
|
||||
cancellations, content churn
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Franchise extension</td>
|
||||
<td>
|
||||
Extract value from an established
|
||||
property at minimal creative cost
|
||||
</td>
|
||||
<td class="counter">
|
||||
Diminishing quality per instalment,
|
||||
audience fatigue, brand erosion
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>AI-assisted production</td>
|
||||
<td>
|
||||
Reduce per-unit content cost to near
|
||||
zero while maintaining surface coverage
|
||||
</td>
|
||||
<td class="counter">
|
||||
Structural collapse of the floor —
|
||||
unlimited slop at no marginal cost
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>AI Is Where This Was Always Going</h2>
|
||||
|
||||
<p>
|
||||
The question AI puts to the industry is not a technical
|
||||
one. It is a choice: do we hire a writer, or don't we?
|
||||
Do we commission a director with a specific vision, or do
|
||||
we generate the output that vision would have produced?
|
||||
That choice is now explicit in a way it was not before.
|
||||
And given everything the industry has already demonstrated
|
||||
about its priorities - the franchise logic, the subscriber
|
||||
metrics, the systematic elimination of creative risk —
|
||||
there is not much reason to expect most studios and
|
||||
platforms to choose the human when the alternative is
|
||||
cheaper and more controllable.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This is what artists have been worried about, and they are
|
||||
right - but not quite for the reason usually given. The
|
||||
concern is often framed as AI producing bad films. The more
|
||||
accurate concern is that AI produces films optimised for
|
||||
the same thing the industry already optimises for: volume,
|
||||
familiarity, minimum acceptable quality. AI does not
|
||||
introduce a new set of values into the pipeline. It
|
||||
enforces the existing ones more efficiently. A model
|
||||
trained on what has performed before will reliably produce
|
||||
more of what has performed before. For an industry that has
|
||||
spent two decades treating proven formula as the safest
|
||||
bet, that is not a warning. It is a feature.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
What gets lost is not quality in the narrow sense - an
|
||||
AI can produce something watchable, and watchable is
|
||||
already the threshold most of the industry is aiming for.
|
||||
What gets lost is the possibility of something genuinely
|
||||
unexpected. A writer or director brings a specific
|
||||
perspective that did not exist before, that cannot be
|
||||
interpolated from what came before, and that occasionally
|
||||
produces something the market did not know it wanted until
|
||||
it arrived. The first Matrix was that. The early MCU was
|
||||
that. The pipeline, fully enforced by AI, eliminates the
|
||||
conditions under which that becomes possible. Every film
|
||||
becomes a variation on the centroid of everything that
|
||||
already worked. The ceiling does not disappear - it just
|
||||
gets permanent.
|
||||
</p>
|
||||
|
||||
<h2>What Would Fix It</h2>
|
||||
|
||||
<p>
|
||||
The honest answer is that the incentive structure would
|
||||
need to change, and there is no obvious mechanism by which
|
||||
that happens from inside the industry. The studios that
|
||||
consistently produce the best work are either small enough
|
||||
to be insulated from franchise logic, backed by individuals
|
||||
with enough capital and conviction to override short-term
|
||||
return calculations, or operating in markets where the
|
||||
economics of Hollywood do not apply in the same way.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Audiences have some leverage. Theatrical attendance is a
|
||||
direct signal. Not watching a streaming series past its
|
||||
first episode is a direct signal. The platforms and studios
|
||||
read these signals carefully - they just tend to read them
|
||||
as evidence that they need better marketing rather than
|
||||
better films. That misreading is itself a symptom of an
|
||||
industry that has fully internalised the idea that the
|
||||
product is secondary to the pipeline.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>
|
||||
Cinema at its best is one of the few experiences that cannot
|
||||
be fully replicated on a phone screen with half your
|
||||
attention elsewhere. The industry has spent twenty years
|
||||
making content that can be. That is not an accident. It is
|
||||
what you get when every structural incentive points toward
|
||||
volume, safety, and extraction - and nobody in a position
|
||||
to change it has a strong enough reason to try.
|
||||
</p>
|
||||
</article>
|
||||
<footer>
|
||||
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
|
||||
<p>© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,318 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cybersecurity — Samantha Vero Friis</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/style/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container--narrow">
|
||||
<header>
|
||||
<a href="/" class="back">← back</a>
|
||||
<p class="tag">// opinion</p>
|
||||
<h1>The Weakest<br />Link Is Always<br /><span>Human</span></h1>
|
||||
<div class="byline">
|
||||
<span>Samantha Vero Friis</span>
|
||||
<span>—</span>
|
||||
<span>Cybersecurity</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article>
|
||||
<p class="lead">
|
||||
Most breaches are not the result of a sophisticated exploit
|
||||
or a zero-day vulnerability. They are the result of a person
|
||||
clicking the wrong link, reusing a password, or trusting a
|
||||
phone call they should not have answered. Security is not
|
||||
primarily a technical problem. It is an organizational one.
|
||||
</p>
|
||||
|
||||
<h2>Know What You Are Protecting</h2>
|
||||
|
||||
<p>
|
||||
Before you can build a security posture, you need to
|
||||
understand your data. Not all information carries the same
|
||||
weight. Customer financial records, internal credentials,
|
||||
and strategic planning documents are not the same as a
|
||||
public press release or a shared meeting agenda. Treating
|
||||
all data as equally sensitive wastes resources and creates
|
||||
compliance fatigue. Treating it all as equally safe is how
|
||||
companies end up in the news.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The first step is classification. Every organization should
|
||||
have a clear and enforced taxonomy for what they hold — what
|
||||
is critical, what is sensitive, and what is routine. Once
|
||||
data is classified, access decisions, storage choices, and
|
||||
transmission policies all become considerably more
|
||||
straightforward.
|
||||
</p>
|
||||
|
||||
<div class="classify-grid">
|
||||
<div class="classify-card">
|
||||
<p class="level level-critical">● Critical</p>
|
||||
<h3>Restricted</h3>
|
||||
<p>
|
||||
Credentials, financial records, legal documents,
|
||||
private keys. Encrypted at rest and in transit.
|
||||
Need-to-know access only. Audited on access.
|
||||
</p>
|
||||
</div>
|
||||
<div class="classify-card">
|
||||
<p class="level level-high">● Sensitive</p>
|
||||
<h3>Internal</h3>
|
||||
<p>
|
||||
Strategy documents, personnel files, client data.
|
||||
Role-based access. Not to leave internal systems
|
||||
without explicit sign-off.
|
||||
</p>
|
||||
</div>
|
||||
<div class="classify-card">
|
||||
<p class="level level-standard">● Standard</p>
|
||||
<h3>General</h3>
|
||||
<p>
|
||||
Shared documentation, public-facing content,
|
||||
operational guides. Normal access controls. No
|
||||
special handling required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Passwords Are Not a Personal Choice</h2>
|
||||
|
||||
<p>
|
||||
The idea that employees should be trusted to choose and
|
||||
manage their own passwords is one of the most persistent and
|
||||
destructive myths in corporate security. People are not good
|
||||
at passwords. They reuse them across services, they write
|
||||
them down, they choose predictable patterns, and they resist
|
||||
changing them when asked. This is not a character flaw — it
|
||||
is human nature operating against a task humans were never
|
||||
designed to do well.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The answer is a mandatory, company-wide password manager.
|
||||
Not recommended. Not optional. Mandatory. Every account
|
||||
created for company use lives in the vault. Credentials are
|
||||
generated by the manager — long, random, unique per service.
|
||||
No employee ever needs to know or remember most of them.
|
||||
Access to the vault itself is protected by a strong master
|
||||
passphrase and hardware-backed two-factor authentication.
|
||||
</p>
|
||||
|
||||
<blockquote>
|
||||
A password an employee <span>cannot remember</span> is a
|
||||
password an attacker <span>cannot guess</span>.
|
||||
</blockquote>
|
||||
|
||||
<h2>Two-Factor Is Not Optional</h2>
|
||||
|
||||
<p>
|
||||
Two-factor authentication is the single highest-return
|
||||
security investment any organization can make. A stolen
|
||||
password without the second factor is nearly useless. Yet an
|
||||
astonishing number of companies treat 2FA as something to
|
||||
enable "if employees want to." That framing gets it exactly
|
||||
backwards.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Every account with access to company systems requires 2FA,
|
||||
enforced at the policy level — not left to the individual.
|
||||
Preference should go to hardware security keys like
|
||||
<code>YubiKey</code> or authenticator apps generating
|
||||
time-based codes. SMS-based 2FA is better than nothing, but
|
||||
SIM-swap attacks make it the weakest option and it should be
|
||||
phased out wherever possible.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Enrollment should happen on day one, before access is
|
||||
granted to any system. Not after. The moment an employee has
|
||||
a credential without a second factor is a window of exposure
|
||||
that does not need to exist.
|
||||
</p>
|
||||
|
||||
<h2>Make Access Deliberately Hard</h2>
|
||||
|
||||
<p>
|
||||
This sounds counterintuitive. Friction is the enemy of
|
||||
productivity — until it is the thing standing between your
|
||||
database and someone who found a badge in the parking lot.
|
||||
The principle of least privilege is not just a compliance
|
||||
checkbox. It is an architecture decision.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Employees should have access to exactly what they need to do
|
||||
their job, and nothing more. Access should be reviewed on a
|
||||
schedule and revoked immediately upon role change or
|
||||
departure. Broad access to critical systems should require
|
||||
explicit justification, a second approver, and a time limit.
|
||||
The default answer to an access request is not yes — it is:
|
||||
why do you need this, for how long, and who else knows?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This friction is not about distrust. It is about reducing
|
||||
the blast radius of any single compromised account. If an
|
||||
attacker gains access to a junior analyst's credentials,
|
||||
they should find themselves in a narrow corridor, not a wide
|
||||
open floor.
|
||||
</p>
|
||||
|
||||
<h2>Social Engineering Is the Real Attack Surface</h2>
|
||||
|
||||
<p>
|
||||
Technical controls can be near-perfect and still fail the
|
||||
moment someone calls the help desk pretending to be a
|
||||
manager who has been locked out while traveling. Social
|
||||
engineering works because it exploits social instincts that
|
||||
are otherwise useful — the tendency to help, to defer to
|
||||
authority, to avoid confrontation.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Mitigating this requires two things: process and culture.
|
||||
Process means that no credential reset, no access grant, and
|
||||
no sensitive disclosure ever happens over a phone call or an
|
||||
email alone, regardless of how urgent it sounds or who the
|
||||
request appears to come from. Verification must follow a
|
||||
separate, pre-established channel. The louder someone
|
||||
insists the situation is urgent, the more carefully the
|
||||
process should be followed.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Culture means that employees feel safe slowing down and
|
||||
saying no. An employee who challenges an unusual request
|
||||
should be recognized for it, not made to feel obstructive.
|
||||
The organization needs to make it socially acceptable — even
|
||||
admirable — to be the person who held the line.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table class="attack-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Attack Vector</th>
|
||||
<th>Method</th>
|
||||
<th>Countermeasure</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Phishing</td>
|
||||
<td>
|
||||
Fraudulent email mimicking a trusted sender
|
||||
to harvest credentials or deploy malware
|
||||
</td>
|
||||
<td class="counter">
|
||||
Email filtering, DMARC/DKIM, regular drill
|
||||
simulations, no credential entry from email
|
||||
links
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Vishing</td>
|
||||
<td>
|
||||
Voice call impersonating IT, management, or
|
||||
a vendor to extract access or trigger an
|
||||
action
|
||||
</td>
|
||||
<td class="counter">
|
||||
Zero verbal credential resets. All requests
|
||||
verified via a separate known channel, no
|
||||
exceptions
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Credential Stuffing</td>
|
||||
<td>
|
||||
Automated use of leaked passwords from other
|
||||
breaches against company systems
|
||||
</td>
|
||||
<td class="counter">
|
||||
Mandatory password manager with unique
|
||||
credentials per service. 2FA blocks the rest
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Insider Threat</td>
|
||||
<td>
|
||||
Disgruntled or compromised employee
|
||||
exfiltrating data or granting unauthorized
|
||||
access
|
||||
</td>
|
||||
<td class="counter">
|
||||
Least-privilege access, access logging and
|
||||
anomaly alerts, prompt offboarding
|
||||
procedures
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pretexting</td>
|
||||
<td>
|
||||
Fabricated scenario (new hire, auditor,
|
||||
vendor) to gain physical or digital access
|
||||
</td>
|
||||
<td class="counter">
|
||||
Visitor protocols, escort requirements, no
|
||||
tailgating culture, ID verification for all
|
||||
physical access
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Security Is a Continuous Practice</h2>
|
||||
|
||||
<p>
|
||||
A one-time audit followed by a certificate on the wall is
|
||||
not a security posture. It is a snapshot. Threat landscapes
|
||||
change, personnel changes, and systems change. Security
|
||||
needs to be a living practice — regular access reviews,
|
||||
periodic credential audits, tabletop exercises for breach
|
||||
scenarios, and honest post-mortems when something goes
|
||||
wrong.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The companies that handle breaches best are not necessarily
|
||||
the ones that prevented them — they are the ones that
|
||||
detected them fast, contained them well, and communicated
|
||||
clearly. That kind of response capability does not appear on
|
||||
the day of the incident. It is built over years of treating
|
||||
security as infrastructure, not an afterthought.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>
|
||||
You cannot patch people. But you can build systems and
|
||||
cultures that make it very difficult for human error to
|
||||
become organizational catastrophe. Mandatory 2FA. Mandatory
|
||||
password managers. Minimal access by default. Verification
|
||||
processes that do not bend under social pressure. These are
|
||||
not sophisticated controls. They are disciplined ones — and
|
||||
discipline, consistently applied, is the closest thing to
|
||||
security that actually exists.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<footer>
|
||||
<a href="mailto:me@samantha42.xyz"
|
||||
>me@samantha42.xyz</a
|
||||
>
|
||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,314 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FP&A Budgeting Engine — Samantha Vero Friis</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Libre+Caslon+Text:wght@400;700;800&family=Syne:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/static/style/styles.css" />
|
||||
<style>
|
||||
/* ── Page-specific layout ── */
|
||||
header { padding: 2.4rem 0 2rem; }
|
||||
main { padding: 3rem 0 5rem; }
|
||||
|
||||
.prose-block { max-width: 660px; }
|
||||
|
||||
pre {
|
||||
font-family: "DM Mono", monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.8;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.4rem 1.6rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.6rem 0;
|
||||
color: #c8c8c0;
|
||||
}
|
||||
|
||||
/* stat cards at top */
|
||||
.cards { margin: 2rem 0 2.5rem; }
|
||||
|
||||
footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1.6rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
footer a { color: var(--muted); text-decoration: none; }
|
||||
footer a:hover { color: var(--accent); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.cards { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── HEADER ── -->
|
||||
<header>
|
||||
<div class="container">
|
||||
<a class="back" href="/">← back</a>
|
||||
|
||||
<div class="tag">project</div>
|
||||
|
||||
<h1>FP&A <span>Budgeting Engine</span></h1>
|
||||
|
||||
<div class="byline">
|
||||
<span>Samantha Vero Friis</span>
|
||||
<span style="color:var(--muted)">Go · REST API · Finance</span>
|
||||
<span style="color:var(--muted)">March 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── STAT CARDS ── -->
|
||||
<div class="container">
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="card-label">Language</div>
|
||||
<div class="card-value" style="color:var(--accent)">Go</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Database</div>
|
||||
<div class="card-value" style="color:var(--accent)">SQLite</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Architecture</div>
|
||||
<div class="card-value" style="font-size:1.1rem;color:var(--accent)">REST API</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Infrastructure</div>
|
||||
<div class="card-value" style="font-size:1.1rem;color:var(--green)">Zero deps</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── MAIN ── -->
|
||||
<main>
|
||||
<div class="container">
|
||||
<div class="prose-block">
|
||||
|
||||
<p class="lead">
|
||||
A REST API for corporate budget management, actuals ingestion, and variance
|
||||
reporting — built to replace the manual Excel workflow that smaller finance
|
||||
teams still run every month. Single binary, no infrastructure, deploys anywhere.
|
||||
</p>
|
||||
|
||||
<h2>The problem it solves</h2>
|
||||
|
||||
<p>
|
||||
At companies below a certain size — typically below 500 employees, or without
|
||||
the budget for enterprise planning platforms like Anaplan or Adaptive Planning —
|
||||
the monthly FP&A close still runs on spreadsheets. Actuals are exported from
|
||||
the ERP, pasted into a workbook, and variances are computed by hand across
|
||||
departments and GL accounts. The process is slow, error-prone, and breaks the
|
||||
moment someone edits the wrong cell.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Large enterprises have solved this with dedicated tooling. The gap is the
|
||||
mid-market: companies that have outgrown Excel but cannot justify a six-figure
|
||||
implementation project. This engine sits in that space — structured schema,
|
||||
versioned budgets, on-demand actuals ingestion, and instant variance reports
|
||||
with no pivot tables and no shared file conflicts.
|
||||
</p>
|
||||
|
||||
<blockquote>
|
||||
The goal was not to build something clever.<br>
|
||||
It was to build something that solves a real problem<br>
|
||||
<span>finance teams in the mid-market actually have.</span>
|
||||
</blockquote>
|
||||
|
||||
<h2>Stack</h2>
|
||||
|
||||
<p>
|
||||
Most finance tooling is Python notebooks or Excel macros — useful for analysis,
|
||||
fragile in production. Go compiles to a single binary with no runtime
|
||||
dependencies, makes every error path explicit, and handles concurrency safely.
|
||||
SQLite was chosen over Postgres deliberately: for a tool running inside a
|
||||
finance team's environment, zero infrastructure matters. The database is a
|
||||
single file. Backups are a file copy.
|
||||
</p>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Layer</th>
|
||||
<th style="text-align:left">Choice</th>
|
||||
<th style="text-align:left">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ticker">Language</td>
|
||||
<td style="text-align:left">Go</td>
|
||||
<td style="text-align:left;color:var(--muted)">Single binary, no runtime, explicit errors</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ticker">Routing</td>
|
||||
<td style="text-align:left">net/http (stdlib)</td>
|
||||
<td style="text-align:left;color:var(--muted)">No deps; method+path routing since Go 1.22</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ticker">Database</td>
|
||||
<td style="text-align:left">SQLite</td>
|
||||
<td style="text-align:left;color:var(--muted)">Zero infrastructure, file-based, ships with binary</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ticker">Schema</td>
|
||||
<td style="text-align:left">Auto-migrated</td>
|
||||
<td style="text-align:left;color:var(--muted)"><code>CREATE TABLE IF NOT EXISTS</code> on startup</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>API surface</h2>
|
||||
|
||||
<p>
|
||||
Three resource groups. Budgets support full CRUD by department, GL account,
|
||||
fiscal period, and version. Actuals are ingested via a single upsert endpoint
|
||||
— idempotent by period, department, and GL code, designed to accept ERP
|
||||
JSON exports directly. Variance endpoints return computed reports immediately.
|
||||
</p>
|
||||
|
||||
<pre>POST /api/v1/budgets Create a budget line
|
||||
PUT /api/v1/budgets/{id} Update amount or notes
|
||||
DELETE /api/v1/budgets/{id} Remove a budget line
|
||||
|
||||
POST /api/v1/actuals/ingest Upsert an actual (idempotent)
|
||||
|
||||
GET /api/v1/variance Full variance report
|
||||
GET /api/v1/variance/alerts Lines exceeding threshold
|
||||
|
||||
GET /api/v1/health Returns 200 if DB is reachable</pre>
|
||||
|
||||
<p>
|
||||
Variance endpoints accept <code>year</code>, <code>period</code>,
|
||||
<code>dept</code>, <code>version</code>, and <code>threshold</code>
|
||||
as query parameters.
|
||||
</p>
|
||||
|
||||
<h2>Finance concepts implemented correctly</h2>
|
||||
|
||||
<p>
|
||||
<strong style="color:var(--text)">Favourability logic.</strong> Whether a
|
||||
variance is good or bad depends on account type. Revenue accounts are
|
||||
favourable when actuals exceed budget; cost accounts when actuals come in
|
||||
under. The engine handles both via a <code>favour_high</code> flag per GL
|
||||
account — a distinction generic reporting tools consistently leave to manual
|
||||
configuration.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong style="color:var(--text)">Budget versioning.</strong> Original budget
|
||||
and up to three forecast revisions are stored separately, enabling a full
|
||||
budget vs. forecast vs. actual three-way comparison without overwriting
|
||||
history — the standard FP&A reforecast workflow.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong style="color:var(--text)">GL account typing.</strong> Accounts are
|
||||
typed as <code>revenue</code>, <code>cogs</code>, <code>opex</code>,
|
||||
<code>capex</code>, or <code>headcount</code>. This drives the P&L
|
||||
rollup structure and the favourability logic together, reflecting how an
|
||||
actual chart of accounts is structured.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong style="color:var(--text)">Fiscal period decoupling.</strong> Periods
|
||||
are stored as integers 1–12, decoupled from calendar months — required for
|
||||
any company not on a January fiscal year.
|
||||
</p>
|
||||
|
||||
<h2>Example output</h2>
|
||||
|
||||
<p>
|
||||
Engineering department, FY2024 P09. Cloud infrastructure came in under budget.
|
||||
Consulting exceeded by 51%.
|
||||
</p>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL</th>
|
||||
<th>Description</th>
|
||||
<th>Budget</th>
|
||||
<th>Actual</th>
|
||||
<th>Var %</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>6100</td>
|
||||
<td style="text-align:left;color:var(--text)">Salaries & Wages</td>
|
||||
<td>4,200k</td>
|
||||
<td>4,380k</td>
|
||||
<td class="down">−4.29%</td>
|
||||
<td class="down">unfavourable</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>6500</td>
|
||||
<td style="text-align:left;color:var(--text)">Consulting & Contractors</td>
|
||||
<td>450k</td>
|
||||
<td>680k</td>
|
||||
<td class="down">−51.11%</td>
|
||||
<td class="down">unfavourable</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5000</td>
|
||||
<td style="text-align:left;color:var(--text)">Cloud Infrastructure</td>
|
||||
<td>850k</td>
|
||||
<td>791k</td>
|
||||
<td class="up">+6.94%</td>
|
||||
<td class="up">favourable</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Running it</h2>
|
||||
|
||||
<pre>git clone https://git.samantha42.xyz/samantha/FPandA-Engine
|
||||
cd FPandA-Engine
|
||||
cp .env.example .env
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
# → http://localhost:8080
|
||||
# → fpa.db created on first run</pre>
|
||||
|
||||
<p>
|
||||
Two environment variables: <code>DB_PATH</code> (use <code>:memory:</code>
|
||||
for tests) and <code>PORT</code>. Demo seed data for Engineering, Sales,
|
||||
and Marketing is included in <code>scripts/seed_demo.sql</code>.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>
|
||||
Source at
|
||||
<a href="https://git.samantha42.xyz/samantha/FPandA-Engine"
|
||||
style="color:var(--accent);text-decoration:none"
|
||||
target="_blank">git.samantha42.xyz/samantha/FPandA-Engine</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container" style="display:flex;justify-content:space-between;width:100%">
|
||||
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
|
||||
<span>© 2026 — All rights reserved</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,265 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Consensus Is the Problem — Samantha Vero Friis</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/style/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container--narrow">
|
||||
<header>
|
||||
<a href="/" class="back">← back</a>
|
||||
<p class="tag">// opinion</p>
|
||||
<h1>Consensus Is<br />the<br /><span>Problem</span></h1>
|
||||
<div class="byline">
|
||||
<span>Samantha Vero Friis</span>
|
||||
<span>—</span>
|
||||
<span>Finance & Investing</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article>
|
||||
<p class="lead">
|
||||
A stock price is not a summary of a company's fundamentals.
|
||||
It is the market's current best guess about the future.
|
||||
The price already contains expectations. The question worth
|
||||
asking is whether those expectations are right.
|
||||
</p>
|
||||
|
||||
<h2>What a Price Is Really Saying</h2>
|
||||
|
||||
<p>
|
||||
Every valuation implies a story. A company trading at forty
|
||||
times earnings is not just expensive — it is telling you that
|
||||
the market believes something specific: that growth will remain
|
||||
high for long enough, that margins will hold, that the
|
||||
competitive position is durable. Unpack the multiple into its
|
||||
components and you have a set of forecasts baked into the
|
||||
price. The investor's job is to decide whether those forecasts
|
||||
are reasonable.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This reframing matters because it shifts the question from
|
||||
"is this stock cheap?" to "is this story credible?" Cheap
|
||||
stocks can be cheap for good reason. Expensive stocks can be
|
||||
undervalued if the embedded assumptions are too conservative.
|
||||
Value, in this sense, is not a property of price alone — it
|
||||
is the gap between what the price implies and what reality is
|
||||
likely to deliver.
|
||||
</p>
|
||||
|
||||
<h2>The Consensus Problem</h2>
|
||||
|
||||
<p>
|
||||
Markets aggregate information quickly and, on average, do it
|
||||
well. But consensus is not the same as correctness. Consensus
|
||||
is the average of what a large number of participants currently
|
||||
believe, weighted by their capital. It reflects what is known,
|
||||
what is legible, and what is socially acceptable to believe.
|
||||
It is systematically slow to update on things that are
|
||||
ambiguous, uncomfortable, or genuinely novel.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
When an investment thesis is consensus — when every analyst
|
||||
covers the stock, every major fund holds it, and the positive
|
||||
narrative is repeated on every earnings call — the upside is
|
||||
already in the price. There is no one left to convince. A
|
||||
buyer who enters at that point is not getting paid for a
|
||||
correct view. They are paying for the privilege of agreeing
|
||||
with everyone else.
|
||||
</p>
|
||||
|
||||
<blockquote>
|
||||
The consensus trade is <span>already priced</span>.<br />
|
||||
Only disagreement can <span>generate alpha</span>.
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
This is the central problem with conventional value investing
|
||||
as it is commonly practiced today. The stocks that look most
|
||||
obviously cheap are often the ones where the consensus has
|
||||
already decided they deserve to be cheap. The screening
|
||||
criteria that identify "value" are, by construction, widely
|
||||
known and widely applied. The edge that came from running
|
||||
those screens in 1975 does not exist in the same form now.
|
||||
</p>
|
||||
|
||||
<h2>Where Mispricing Actually Lives</h2>
|
||||
|
||||
<p>
|
||||
Genuine mispricing tends to occur in situations where the
|
||||
consensus view is wrong in a specific and identifiable way,
|
||||
and where that wrongness has not yet been corrected. This
|
||||
can happen for several structural reasons.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Institutional constraints create systematic blind spots. Fund
|
||||
managers are evaluated on short cycles, penalized for holding
|
||||
unfashionable names, and often prohibited from owning
|
||||
securities outside their mandate. These pressures push capital
|
||||
away from certain situations not because the economics are bad
|
||||
but because the social and career costs of being wrong there
|
||||
are high. The result is that some assets are systematically
|
||||
underowned relative to their intrinsic value.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Narrative lag is another source. Companies undergoing genuine
|
||||
change — a new management team, a shift in cost structure, an
|
||||
improving competitive position — often continue to be priced
|
||||
against their old story long after the underlying reality has
|
||||
shifted. The market is very good at updating on quantitative
|
||||
signals and very slow at updating on qualitative ones.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Complexity discounts are real and often excessive. A business
|
||||
that is hard to model, that operates across multiple segments,
|
||||
or that sits at the intersection of industries analysts do not
|
||||
cover together will often trade at a lower multiple than a
|
||||
simpler business with identical economics. That discount is not
|
||||
always irrational — complexity carries risk — but it is often
|
||||
overdone.
|
||||
</p>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="attack-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Why consensus gets it wrong</th>
|
||||
<th>What to look for</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Institutional constraints</td>
|
||||
<td>
|
||||
Capital avoids the name for structural,
|
||||
not economic, reasons
|
||||
</td>
|
||||
<td class="counter">
|
||||
Forced sellers, mandate mismatches,
|
||||
index exclusions
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Narrative lag</td>
|
||||
<td>
|
||||
Price reflects the old story; reality
|
||||
has already changed
|
||||
</td>
|
||||
<td class="counter">
|
||||
Management change, cost restructuring,
|
||||
improving unit economics
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Complexity discount</td>
|
||||
<td>
|
||||
Analysts underweight what they cannot
|
||||
easily model
|
||||
</td>
|
||||
<td class="counter">
|
||||
Conglomerates, cross-sector businesses,
|
||||
opaque but healthy cash flows
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sentiment overshoot</td>
|
||||
<td>
|
||||
Fear or enthusiasm has moved price far
|
||||
beyond the rational range
|
||||
</td>
|
||||
<td class="counter">
|
||||
Implied assumptions that require
|
||||
implausible outcomes to justify current price
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>The Role of Implied Expectations</h2>
|
||||
|
||||
<p>
|
||||
The most disciplined version of this approach is to work
|
||||
backwards from price. Rather than building a model and
|
||||
concluding that a stock is cheap or expensive, start by
|
||||
asking: what does this price require? What growth rate, what
|
||||
margin profile, what reinvestment assumption would you need
|
||||
to believe to justify paying what the market is asking today?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If the answer requires a long run of performance with no
|
||||
credible foundation in the company's history, competitive
|
||||
position, or industry structure — the price is wrong in one
|
||||
direction. If the assumptions are so pessimistic that even a
|
||||
mediocre outcome would beat them, it is wrong in the other.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This framing makes the investment case explicit and
|
||||
falsifiable. It forces a specific disagreement with the
|
||||
market rather than a vague sense that something is cheap or
|
||||
expensive. And it clarifies what would need to be true for
|
||||
the thesis to fail — which is at least as important as
|
||||
knowing what would need to be true for it to succeed.
|
||||
</p>
|
||||
|
||||
<h2>On Being Wrong in Public</h2>
|
||||
|
||||
<p>
|
||||
A genuinely contrarian position is, by definition, one that
|
||||
the consensus thinks is mistaken. Holding it requires being
|
||||
comfortable with the fact that most informed observers
|
||||
currently disagree with you, and that some period of time
|
||||
will pass before — if — the price reflects the view you hold.
|
||||
That discomfort is not incidental to the strategy. It is the
|
||||
mechanism by which the return is generated. If the position
|
||||
were comfortable, it would be consensus, and it would already
|
||||
be priced.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This does not mean contrarianism for its own sake is sound.
|
||||
Being different from the consensus is not the same as being
|
||||
right. The goal is not to disagree with the market but to
|
||||
identify specific, well-reasoned cases where the market's
|
||||
current view is demonstrably wrong — and where the gap
|
||||
between implied expectations and probable reality is large
|
||||
enough to be worth the risk of being early, or simply
|
||||
incorrect.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>
|
||||
Value is not a style. It is a discipline applied wherever
|
||||
the math supports it. The companies that look most like
|
||||
"value stocks" by conventional screens are often the ones
|
||||
where the opportunity has already been arbitraged away. The
|
||||
real work is in finding the situations where the consensus
|
||||
has constructed a story the underlying economics cannot
|
||||
support — or has failed to construct one that they clearly
|
||||
do. That gap, wherever it appears, is where returns come
|
||||
from.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<footer>
|
||||
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
|
||||
<p>© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,197 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Infrastructure — Samantha Vero Friis</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/style/styles.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<a href="/" class="back">← back</a>
|
||||
<p class="tag">// opinion</p>
|
||||
<h1>Convenience<br />Is Making Us<br /><span>Worse</span></h1>
|
||||
<div class="byline">
|
||||
<span>Samantha Vero Friis</span>
|
||||
<span>—</span>
|
||||
<span>Computer Infrastructure</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article>
|
||||
<p class="lead">
|
||||
The tools a company chooses to build on top of says a lot
|
||||
about how serious it is about the craft. The industry-wide
|
||||
drift toward Word, Teams, and Microsoft 365 is not a
|
||||
productivity upgrade — it is a slow erosion of competence
|
||||
dressed up as convenience.
|
||||
</p>
|
||||
|
||||
<h2>The Convenience Trap</h2>
|
||||
|
||||
<p>
|
||||
Every tool that hides complexity from you takes something
|
||||
away. When a new hire can open a laptop running Windows,
|
||||
install Office in ten minutes, and be "operational" by noon,
|
||||
that feels like a win. But operational is not the same as
|
||||
capable. What that person has actually learned is how to
|
||||
navigate a UI. They have not learned how their machine
|
||||
works, how their data flows, or how to adapt when the
|
||||
abstraction breaks.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Large vendor ecosystems like Microsoft 365 are engineered
|
||||
for the lowest common denominator. They are excellent at
|
||||
onboarding someone with no background — and they stop there.
|
||||
The ceiling is built in by design. You cannot meaningfully
|
||||
extend Word. You cannot script Teams in any serious way. You
|
||||
are a tenant inside someone else's system, paying monthly
|
||||
for the privilege of not understanding what is happening
|
||||
underneath you.
|
||||
</p>
|
||||
|
||||
<blockquote>
|
||||
Convenience optimizes for the speed of the
|
||||
<span>newcomer</span>.<br />
|
||||
It does not care about the ceiling of the
|
||||
<span>expert</span>.
|
||||
</blockquote>
|
||||
|
||||
<h2>What We Trade Away</h2>
|
||||
|
||||
<p>
|
||||
The deeper issue is what gets lost over time. A team that
|
||||
has never had to configure anything has never had to
|
||||
understand anything. When your document pipeline is Word
|
||||
plus SharePoint, you never learn what a proper typesetting
|
||||
system looks like. When your servers are Azure with a
|
||||
click-through UI, you never develop intuition for
|
||||
networking, permissions, or failure modes. The knowledge
|
||||
simply does not accumulate.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The companies most dependent on big vendor stacks also tend
|
||||
to be the most fragile. A license change, a price increase,
|
||||
a deprecated feature — and suddenly nobody knows what to do.
|
||||
There is no internal knowledge to fall back on. The stack
|
||||
was always a rented abstraction.
|
||||
</p>
|
||||
|
||||
<div class="compare">
|
||||
<div class="compare-col bad">
|
||||
<h3>Vendor-Locked Stack</h3>
|
||||
<ul>
|
||||
<li>Fast to start, hard to master</li>
|
||||
<li>Knowledge lives in the UI, not the team</li>
|
||||
<li>Expensive at scale, locked by contracts</li>
|
||||
<li>No meaningful customization</li>
|
||||
<li>Fragile when the vendor changes terms</li>
|
||||
<li>Homogeneous — everyone works the same way</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="compare-col good">
|
||||
<h3>Custom Infrastructure</h3>
|
||||
<ul>
|
||||
<li>Slower to start, no ceiling</li>
|
||||
<li>Knowledge compounds inside the team</li>
|
||||
<li>Cheap at scale, you own what you build</li>
|
||||
<li>Configured precisely for your workflow</li>
|
||||
<li>Resilient — you understand every layer</li>
|
||||
<li>Personal — the environment fits the person</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Linux as a Philosophy</h2>
|
||||
|
||||
<p>
|
||||
A Linux desktop is not just a different operating system. It
|
||||
is a different relationship with your machine. You configure
|
||||
your window manager, your keybindings, your terminal, your
|
||||
dotfiles. The environment becomes an extension of how you
|
||||
think. Two engineers on the same team can have entirely
|
||||
different setups that are both optimized for their own
|
||||
cognitive style — and that is a feature, not a problem.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This is not about being difficult or contrarian. It is about
|
||||
the fact that a person who has spent time configuring
|
||||
<code>neovim</code>, managing a
|
||||
<code>systemd</code> service, or writing their own shell
|
||||
scripts has fundamentally more understanding of computation
|
||||
than someone who has spent the same time clicking through
|
||||
GUIs. That understanding compounds. It transfers across
|
||||
every problem they touch.
|
||||
</p>
|
||||
|
||||
<h2>Documents Should Be Code</h2>
|
||||
|
||||
<p>
|
||||
The same logic applies to document production. Word
|
||||
processing is a GUI wrapped around formatting decisions you
|
||||
did not make. The output is inconsistent, the versioning is
|
||||
painful, and the result is never quite right. A typesetting
|
||||
system like <code>Typst</code> treats a document as source
|
||||
code — deterministic, version-controlled, composable. You
|
||||
write a template once and it is correct everywhere, forever.
|
||||
The document becomes a reproducible artifact.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Custom pipelines like this require an upfront investment.
|
||||
You have to learn the tool, build the template, establish
|
||||
the workflow. But once built, the leverage is enormous.
|
||||
Reports, invoices, analysis documents — all generated
|
||||
cleanly from structured data, with zero manual formatting.
|
||||
That is not possible in Word. It is not even imaginable in
|
||||
Word.
|
||||
</p>
|
||||
|
||||
<h2>Own Your Servers</h2>
|
||||
|
||||
<p>
|
||||
Self-hosted infrastructure demands that someone in your
|
||||
organization understands it. That is not a burden — it is
|
||||
the point. When you run your own services, you know exactly
|
||||
where your data lives, what it costs to run, and what
|
||||
happens when something fails. You build operational
|
||||
knowledge that no managed cloud service can give you.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This does not mean rejecting cloud entirely. It means being
|
||||
intentional. Use the cloud where the economics clearly favor
|
||||
it. Build and operate everything else yourself. The goal is
|
||||
a team that could reconstruct its own stack from scratch if
|
||||
it had to — because a team capable of that is a team that
|
||||
actually understands what it has built.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>
|
||||
The best infrastructure is not the most convenient. It is
|
||||
the most understood. Tools that demand something from you —
|
||||
configuration, learning, patience — are the tools that make
|
||||
you better over time. Convenience is a ceiling. Complexity,
|
||||
embraced deliberately, is a floor that keeps getting higher.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<footer>
|
||||
<a href="mailto:me@samantha42.xyz"
|
||||
>me@samantha42.xyz</a
|
||||
>
|
||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,97 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Samantha Vero Friis</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;1,300&display=swap"
|
||||
rel="stylesheet" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link rel="stylesheet" href="/static/output.css"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="theme-root day " id="themeRoot">
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<a style="text-decoration: underline; font-size: 13px; color: #85B7EB; cursor: pointer; font-family: 'DM Mono', monospace;" onclick="closelogin()">← back</a>
|
||||
<p class="brand" style="margin-top: 12px;">samantha<span>42</span>.xyz</p>
|
||||
<p class="brand-sub">Secure Portal</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div id="login-error"></div>
|
||||
|
||||
<form
|
||||
hx-post="/login"
|
||||
hx-target="#login-error"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#login-spinner"
|
||||
hx-on::after-request="if(event.detail.successful && event.detail.xhr.status===200 && !event.detail.xhr.getResponseHeader('HX-Redirect')) {}"
|
||||
>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="password">Password</label>
|
||||
<input
|
||||
class="field-input"
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="totp">2FA Code</label>
|
||||
<input
|
||||
class="field-input"
|
||||
type="text"
|
||||
id="totp"
|
||||
name="totp"
|
||||
placeholder="123456"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="submit-btn" type="submit">Continue →</button>
|
||||
<p class="htmx-indicator" id="login-spinner">// verifying...</p>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-wrap { z-index: 10; min-width: 50%; min-height: 520px; display: flex; align-items: center; justify-content: center; padding: 2rem 0; }
|
||||
.login-card { background: white; border: 0.5px solid var(--color-border-tertiary); border-radius: 8px; width: 100%; max-width: 400px; overflow: hidden; }
|
||||
.card-header { background: #0C447C; padding: 2rem 2rem 1.5rem; }
|
||||
.brand { font-family: 'DM Serif Display', serif; font-size: 22px; color: #E6F1FB; letter-spacing: -0.5px; margin: 0 0 4px; }
|
||||
.brand span { font-style: italic; color: #85B7EB; }
|
||||
.brand-sub { font-family: 'DM Mono', monospace; font-size: 11px; color: #378ADD; letter-spacing: 2px; text-transform: uppercase; margin: 0; }
|
||||
.card-body { padding: 1.75rem 2rem; }
|
||||
.field-group { margin-bottom: 1rem; }
|
||||
.field-label { font-family: 'DM Mono', monospace; font-size: 11px; color: var(--color-text-secondary); letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 6px; }
|
||||
.field-input { width: 100%; box-sizing: border-box; font-family: 'DM Mono', monospace; font-size: 14px; padding: 10px 12px; border-radius: 6px; border: 0.5px solid var(--color-border-tertiary); background: var(--color-background-secondary); color: var(--color-text-primary); transition: border-color 0.2s, box-shadow 0.2s; }
|
||||
.field-input:focus { outline: none; border-color: #378ADD; box-shadow: 0 0 0 3px rgba(55,138,221,0.15); }
|
||||
.submit-btn { width: 100%; padding: 11px; background: #185FA5; color: #E6F1FB; border: none; border-radius: 6px; font-family: 'DM Mono', monospace; font-size: 13px; letter-spacing: 1px; cursor: pointer; transition: background 0.2s, transform 0.1s; margin-top: 0.5rem; }
|
||||
.submit-btn:hover { background: #0C447C; }
|
||||
.submit-btn:active { transform: scale(0.98); }
|
||||
.htmx-indicator { opacity: 0; transition: opacity 0.2s; font-family: 'DM Mono', monospace; font-size: 11px; color: var(--color-text-secondary); text-align: center; margin-top: 8px; }
|
||||
.htmx-request .htmx-indicator { opacity: 1; }
|
||||
.htmx-request .submit-btn { background: #0C447C; cursor: not-allowed; pointer-events: none; }
|
||||
.error-banner { font-family: 'DM Mono', monospace; font-size: 11px; color: #A32D2D; background: #FCEBEB; border: 0.5px solid #F09595; border-radius: 6px; padding: 8px 12px; margin-bottom: 1rem; }
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,236 +0,0 @@
|
||||
|
||||
<style>
|
||||
html {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
#matrix-wrap {
|
||||
background: #000;
|
||||
min-height: 1200px;
|
||||
width: 100%;
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
canvas#rain {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
}
|
||||
#terminal {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 2rem 2.5rem;
|
||||
min-height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#boot-lines {
|
||||
flex: 1;
|
||||
}
|
||||
.boot-line {
|
||||
font-size: 13px;
|
||||
color: #00ff41;
|
||||
line-height: 1.8;
|
||||
opacity: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
.boot-line.dim { color: #00aa2b; }
|
||||
.boot-line.bright { color: #a3ffb4; font-weight: 500; }
|
||||
.boot-line.warn { color: #ffcc00; }
|
||||
#prompt-area {
|
||||
margin-top: 1.5rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
#prompt-area.visible { opacity: 1; }
|
||||
#input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
#prompt-label {
|
||||
color: #00ff41;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#cmd-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid #00ff4166;
|
||||
border-radius: 0;
|
||||
color: #00ff41;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
padding: 2px 4px;
|
||||
caret-color: #00ff41;
|
||||
}
|
||||
#cmd-input:focus { border-bottom-color: #00ff41; }
|
||||
#output {
|
||||
margin-top: 1rem;
|
||||
font-size: 13px;
|
||||
color: #00ff41;
|
||||
line-height: 1.9;
|
||||
min-height: 60px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.o-dim { color: #00aa2b; }
|
||||
.o-bright { color: #a3ffb4; }
|
||||
.o-warn { color: #ffcc00; }
|
||||
.o-pink { color: #ff79c6; }
|
||||
</style>
|
||||
|
||||
<div id="matrix-wrap">
|
||||
<canvas id="rain"></canvas>
|
||||
<div id="terminal">
|
||||
<div id="boot-lines"></div>
|
||||
<div id="prompt-area">
|
||||
<div id="input-row">
|
||||
<span id="prompt-label">samantha42@secret:~$</span>
|
||||
<input id="cmd-input" type="text" autocomplete="off" spellcheck="false" placeholder="type 'help'" aria-label="Terminal input" />
|
||||
</div>
|
||||
<div id="output"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('rain');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const wrap = document.getElementById('matrix-wrap');
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = wrap.offsetWidth;
|
||||
canvas.height = wrap.offsetHeight;
|
||||
}
|
||||
resizeCanvas();
|
||||
|
||||
const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF@#$%&*';
|
||||
const fontSize = 14;
|
||||
let cols, drops;
|
||||
|
||||
function initDrops() {
|
||||
cols = Math.floor(canvas.width / fontSize);
|
||||
drops = Array(cols).fill(1);
|
||||
}
|
||||
initDrops();
|
||||
|
||||
function drawRain() {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.05)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = fontSize + 'px monospace';
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
const bright = Math.random() > 0.95;
|
||||
ctx.fillStyle = bright ? '#a3ffb4' : '#00ff41';
|
||||
ctx.globalAlpha = bright ? 1 : 0.7;
|
||||
const ch = chars[Math.floor(Math.random() * chars.length)];
|
||||
ctx.fillText(ch, i * fontSize, drops[i] * fontSize);
|
||||
ctx.globalAlpha = 1;
|
||||
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) drops[i] = 0;
|
||||
drops[i]++;
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(drawRain, 50);
|
||||
|
||||
const bootSequence = [
|
||||
{ text: 'SAMANTHA42 OS v4.2.0 — initializing...', cls: 'dim', delay: 200 },
|
||||
{ text: 'Loading kernel modules... [OK]', cls: 'dim', delay: 400 },
|
||||
{ text: 'Mounting secret filesystem... [OK]', cls: 'dim', delay: 600 },
|
||||
{ text: 'Verifying identity... [OK]', cls: 'dim', delay: 900 },
|
||||
{ text: '> ACCESS GRANTED ', cls: 'bright', delay: 1200 },
|
||||
{ text: '', cls: '', delay: 1350 },
|
||||
{ text: 'Welcome back, curious one. ', cls: 'warn', delay: 1500 },
|
||||
{ text: 'You found the secret page. Nice work. ', cls: '', delay: 1700 },
|
||||
{ text: '', cls: '', delay: 1850 },
|
||||
{ text: "Type 'help' to see what's hidden here.", cls: 'dim', delay: 2000 },
|
||||
];
|
||||
|
||||
const bootEl = document.getElementById('boot-lines');
|
||||
const promptArea = document.getElementById('prompt-area');
|
||||
|
||||
bootSequence.forEach(({ text, cls, delay }) => {
|
||||
setTimeout(() => {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'boot-line' + (cls ? ' ' + cls : '');
|
||||
line.textContent = text;
|
||||
bootEl.appendChild(line);
|
||||
requestAnimationFrame(() => { line.style.opacity = '1'; });
|
||||
}, delay);
|
||||
});
|
||||
|
||||
setTimeout(() => { promptArea.classList.add('visible'); }, 2200);
|
||||
|
||||
const commands = {
|
||||
help: () => `<span class="o-bright">available commands:</span>
|
||||
<span class="o-dim"> whoami </span>— the real story
|
||||
<span class="o-dim"> skills </span>— the unofficial skill list
|
||||
<span class="o-dim"> facts </span>— things my CV won't tell you
|
||||
<span class="o-dim"> crush </span>— current hyperfixations
|
||||
<span class="o-dim"> 42 </span>— the answer
|
||||
<span class="o-dim"> clear </span>— clear the terminal`,
|
||||
|
||||
whoami: () => `<span class="o-bright">samantha</span> — numberphile, programmer, overthinker.
|
||||
loves: movies, pixel-perfect layouts or fun design, staying up too late debugging.
|
||||
hates: Comic Sans and broken padding.
|
||||
found here by: <span class="o-pink">being curious</span> (good trait btw)`,
|
||||
|
||||
skills: () => `<span class="o-bright">UNOFFICIAL SKILL TREE</span>
|
||||
<span class="o-dim"> ████████████████████</span> pretending to understand the error
|
||||
<span class="o-dim"> █████████████████░░░</span> googling things I definitely know
|
||||
<span class="o-dim"> ████████████████████</span> making things pretty at 2am
|
||||
<span class="o-dim"> █████████░░░░░░░░░░░</span> replying to emails promptly
|
||||
<span class="o-dim"> ████████████████████</span> convincing clients the bug is a feature`,
|
||||
|
||||
facts: () => `<span class="o-bright">fun facts not on
|
||||
→ 42 is my favourite number (obviously)
|
||||
→ I debug best when listening to lo-fi`,
|
||||
|
||||
crush: () => `<span class="o-bright">current hyperfixations:</span>
|
||||
<span class="o-pink"> ♡</span> linux ricing
|
||||
<span class="o-pink"> ♡</span> Japanese Katakana typography
|
||||
<span class="o-pink"> ♡</span> number go up. But Fuck btc, lol
|
||||
<span class="o-pink"> ♡</span> praying to loard gaben. If you know, you know
|
||||
<span class="o-pink"> ♡</span> building useless website pages, clearly`,
|
||||
|
||||
42: () => `<span class="o-bright">"The Answer to the Ultimate Question of Life,
|
||||
the Universe, and Everything."</span>
|
||||
— Deep Thought, after 7.5 million years
|
||||
|
||||
also: my lucky number. also: this website's name.
|
||||
<span class="o-dim">don't ask what the question is. nobody knows.</span>`,
|
||||
|
||||
clear: () => '__clear__',
|
||||
};
|
||||
|
||||
const input = document.getElementById('cmd-input');
|
||||
const output = document.getElementById('output');
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const cmd = input.value.trim().toLowerCase();
|
||||
input.value = '';
|
||||
if (!cmd) return;
|
||||
|
||||
if (commands[cmd]) {
|
||||
const result = commands[cmd]();
|
||||
if (result === '__clear__') {
|
||||
output.innerHTML = '';
|
||||
} else {
|
||||
output.innerHTML = `<span class="o-dim">$ ${cmd}</span>\n` + result + '\n\n';
|
||||
}
|
||||
} else {
|
||||
output.innerHTML = `<span class="o-dim">$ ${cmd}</span>\n<span class="o-warn">command not found: ${cmd}</span>\ntype <span class="o-bright">help</span> to see available commands\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('focus', () => {});
|
||||
setTimeout(() => { input.focus(); }, 2300);
|
||||
</script>
|
||||
|
Before Width: | Height: | Size: 6.8 MiB |
|
Before Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 734 KiB |
|
Before Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 727 KiB |
|
Before Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 7.6 MiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#aa00ff"><path d="M3 11.5066C3 16.7497 7.25034 21 12.4934 21C16.2209 21 19.4466 18.8518 21 15.7259C12.4934 15.7259 8.27411 11.5066 8.27411 3C5.14821 4.55344 3 7.77915 3 11.5066Z" stroke="#aa00ff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||
|
Before Width: | Height: | Size: 438 B |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 45 KiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#ffff00"><path d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z" stroke="#ffd043" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M22 12L23 12" stroke="#ffff00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 2V1" stroke="#ffff00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 23V22" stroke="#ffff00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20 20L19 19" stroke="#ffff00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20 4L19 5" stroke="#ffff00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M4 20L5 19" stroke="#ffff00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M4 4L5 5" stroke="#ffff00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M1 12L2 12" stroke="#ffff00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 533 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
@@ -1,69 +0,0 @@
|
||||
= Top 5 Movies
|
||||
1. Blade Runner 2049
|
||||
2. A cure of Wellness
|
||||
3. Fight Club
|
||||
4. Dracula: A Love Tale
|
||||
5. Girl with a Dragon Tattoo
|
||||
|
||||
|
||||
= Movies
|
||||
- Tenet
|
||||
- The Outrun
|
||||
- Hail Mary
|
||||
- Day Break
|
||||
- l, Tonya (2018)
|
||||
- Caddo lake (2024)
|
||||
- The Shawshank Redemption
|
||||
- Fight Club
|
||||
- Perl
|
||||
- A Cure of Wellness, (2016)
|
||||
- green book
|
||||
- spirited away
|
||||
- Saltburn
|
||||
- Dracula: A Love Tale (2025)
|
||||
- Frankenstein (2015)
|
||||
- The Fall Guy
|
||||
- The Notebook
|
||||
- Primal Fear
|
||||
- blade runner 2049
|
||||
- girl with a dragon tatto
|
||||
- Wicked
|
||||
- Matrix
|
||||
- Book of Eli
|
||||
- Pulp Fiction
|
||||
- Midsommer
|
||||
- Living
|
||||
- Moneyball
|
||||
- Legally Blonde (2001)
|
||||
- Dune
|
||||
- Internal sunshine
|
||||
|
||||
|
||||
== Danish
|
||||
- A royal affiar
|
||||
- Adams æbler
|
||||
|
||||
= Top 5 Shows
|
||||
1. Chernobyl
|
||||
2. Succesion
|
||||
3. Breaking Bad
|
||||
4. Avatar, the last air bender
|
||||
5. Adolescence
|
||||
|
||||
= Shows
|
||||
- succesion
|
||||
- Breaking bad
|
||||
- Adolescence (2025)
|
||||
- The penguin
|
||||
- Community
|
||||
- Avatar, the last air bender
|
||||
- Foundation
|
||||
- Lessons in Chemistry
|
||||
- Hanna
|
||||
- You
|
||||
- killing eve
|
||||
- ozark
|
||||
- Chernobyl
|
||||
- The wire
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 710 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 929 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 939 KiB |
|
Before Width: | Height: | Size: 624 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,742 +0,0 @@
|
||||
function cureClick() {
|
||||
if (document.getElementById('cure-canvas')) return;
|
||||
if (document.getElementById('lightning-canvas')) stopLightning();
|
||||
if (document.getElementById('typewriter-canvas')) stopTypewriter();
|
||||
if (document.getElementById('matrix-canvas')) stopMatrix();
|
||||
if (document.getElementById('rain-canvas')) stopRain();
|
||||
|
||||
const el = document.getElementById('interest');
|
||||
el.textContent = "Actually... I'm feeling much better now!"
|
||||
startWater();
|
||||
isDay = false;
|
||||
makenight();
|
||||
setTimeout(() => {
|
||||
el.textContent = 'interest';
|
||||
stopWater();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
let cureCanvas, cureCtx, cureAnimFrame;
|
||||
|
||||
function startWater() {
|
||||
cureCanvas = document.createElement('canvas');
|
||||
cureCanvas.id = 'cure-canvas';
|
||||
cureCanvas.width = window.innerWidth;
|
||||
cureCanvas.height = window.innerHeight;
|
||||
Object.assign(cureCanvas.style, {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: -1, // behind everything
|
||||
opacity: '0',
|
||||
transition: 'opacity 1.4s ease'
|
||||
});
|
||||
document.body.appendChild(cureCanvas);
|
||||
cureCtx = cureCanvas.getContext('2d');
|
||||
requestAnimationFrame(() => { cureCanvas.style.opacity = '1'; });
|
||||
|
||||
const W = cureCanvas.width;
|
||||
const H = cureCanvas.height;
|
||||
|
||||
// waterline sits at 38% from the top
|
||||
const waterlineY = H * 0.38;
|
||||
|
||||
// floating particles below the surface
|
||||
const particles = Array.from({ length: 55 }, () => ({
|
||||
x: Math.random() * W,
|
||||
y: waterlineY + 20 + Math.random() * (H - waterlineY - 20),
|
||||
r: 1 + Math.random() * 2.5,
|
||||
speedX: (Math.random() - 0.5) * 0.3,
|
||||
speedY: -0.08 - Math.random() * 0.18, // slow upward drift
|
||||
opacity: 0.08 + Math.random() * 0.18
|
||||
}));
|
||||
|
||||
function drawFrame(ts) {
|
||||
cureCtx.clearRect(0, 0, W, H);
|
||||
|
||||
// ── sky above waterline (very subtle, mostly transparent) ──────────
|
||||
const sky = cureCtx.createLinearGradient(0, 0, 0, waterlineY);
|
||||
sky.addColorStop(0, 'rgba(190, 230, 255, 0.06)');
|
||||
sky.addColorStop(1, 'rgba(190, 230, 255, 0.01)');
|
||||
cureCtx.fillStyle = sky;
|
||||
cureCtx.fillRect(0, 0, W, waterlineY);
|
||||
|
||||
// ── water body below waterline ─────────────────────────────────────
|
||||
const water = cureCtx.createLinearGradient(0, waterlineY, 0, H);
|
||||
water.addColorStop(0, 'rgba(80, 190, 220, 0.28)');
|
||||
water.addColorStop(0.35, 'rgba(40, 140, 190, 0.22)');
|
||||
water.addColorStop(0.7, 'rgba(20, 90, 150, 0.20)');
|
||||
water.addColorStop(1, 'rgba(10, 50, 110, 0.22)');
|
||||
cureCtx.fillStyle = water;
|
||||
cureCtx.fillRect(0, waterlineY, W, H - waterlineY);
|
||||
|
||||
// ── caustic shimmer streaks ────────────────────────────────────────
|
||||
const numStreaks = 10;
|
||||
for (let i = 0; i < numStreaks; i++) {
|
||||
const phase = (ts * 0.0004 + i * 0.63) % 1;
|
||||
const x = (i / numStreaks) * W + Math.sin(ts * 0.0008 + i) * 60;
|
||||
const yTop = waterlineY + 10 + Math.sin(ts * 0.001 + i * 1.3) * 18;
|
||||
const yBot = yTop + 40 + Math.sin(ts * 0.0007 + i * 0.9) * 30;
|
||||
const streak = cureCtx.createLinearGradient(x, yTop, x + 6, yBot);
|
||||
streak.addColorStop(0, 'rgba(200, 245, 255, 0)');
|
||||
streak.addColorStop(0.4, `rgba(200, 245, 255, ${0.05 + 0.06 * Math.sin(ts * 0.002 + i)})`);
|
||||
streak.addColorStop(1, 'rgba(200, 245, 255, 0)');
|
||||
cureCtx.fillStyle = streak;
|
||||
cureCtx.fillRect(x, yTop, 5, yBot - yTop);
|
||||
}
|
||||
|
||||
// ── floating particles ─────────────────────────────────────────────
|
||||
particles.forEach(p => {
|
||||
p.x += p.speedX;
|
||||
p.y += p.speedY;
|
||||
// reset when reaching the surface
|
||||
if (p.y < waterlineY + 5) {
|
||||
p.y = H - 10;
|
||||
p.x = Math.random() * W;
|
||||
}
|
||||
if (p.x < 0) p.x = W;
|
||||
if (p.x > W) p.x = 0;
|
||||
|
||||
const depth = (p.y - waterlineY) / (H - waterlineY); // 0 = surface, 1 = bottom
|
||||
cureCtx.beginPath();
|
||||
cureCtx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
cureCtx.fillStyle = `rgba(180, 240, 255, ${p.opacity * (0.4 + 0.6 * (1 - depth))})`;
|
||||
cureCtx.fill();
|
||||
});
|
||||
|
||||
// ── animated surface waves (2 layers, different speeds & amplitudes) ─
|
||||
function drawWaveLine(amp, freq, speed, lineW, alpha) {
|
||||
cureCtx.beginPath();
|
||||
for (let x = 0; x <= W; x += 3) {
|
||||
const y = waterlineY
|
||||
+ Math.sin(x * freq + ts * speed) * amp
|
||||
+ Math.sin(x * freq * 1.7 + ts * speed * 0.6) * (amp * 0.4);
|
||||
x === 0 ? cureCtx.moveTo(x, y) : cureCtx.lineTo(x, y);
|
||||
}
|
||||
cureCtx.strokeStyle = `rgba(210, 245, 255, ${alpha})`;
|
||||
cureCtx.lineWidth = lineW;
|
||||
cureCtx.stroke();
|
||||
}
|
||||
|
||||
// primary wave
|
||||
drawWaveLine(7, 0.012, 0.0018, 2.0, 0.70);
|
||||
// secondary wave (slightly offset phase)
|
||||
drawWaveLine(4, 0.018, 0.0024, 1.2, 0.40);
|
||||
// micro ripple
|
||||
drawWaveLine(2, 0.030, 0.0032, 0.7, 0.25);
|
||||
|
||||
// ── foam/highlight on the crest ────────────────────────────────────
|
||||
cureCtx.beginPath();
|
||||
for (let x = 0; x <= W; x += 3) {
|
||||
const y = waterlineY
|
||||
+ Math.sin(x * 0.012 + ts * 0.0018) * 7
|
||||
+ Math.sin(x * 0.020 + ts * 0.0024) * 3;
|
||||
x === 0 ? cureCtx.moveTo(x, y - 1) : cureCtx.lineTo(x, y - 1);
|
||||
}
|
||||
cureCtx.strokeStyle = 'rgba(255, 255, 255, 0.18)';
|
||||
cureCtx.lineWidth = 1;
|
||||
cureCtx.stroke();
|
||||
|
||||
if (cureCanvas) cureAnimFrame = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
cureAnimFrame = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
function stopWater() {
|
||||
if (!cureCanvas) return;
|
||||
cureCanvas.style.opacity = '0';
|
||||
cancelAnimationFrame(cureAnimFrame);
|
||||
setTimeout(() => {
|
||||
cureCanvas.remove();
|
||||
cureCanvas = null;
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
function draculaClick() {
|
||||
if (document.getElementById('lightning-canvas')) return;
|
||||
|
||||
if (document.getElementById('cure-canvas')) stopWater();
|
||||
if (document.getElementById('typewriter-canvas')) stopTypewriter();
|
||||
if (document.getElementById('matrix-canvas')) stopMatrix();
|
||||
if (document.getElementById('rain-canvas')) stopRain();
|
||||
|
||||
const el = document.getElementById('interest');
|
||||
el.textContent = 'You have your whole life ahead of you. And I only offer death.';
|
||||
startLightning();
|
||||
isDay = false;
|
||||
makenight();
|
||||
setTimeout(() => {
|
||||
el.textContent = 'interest';
|
||||
stopLightning();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
let lightningCanvas, lightningCtx, lightningInterval, lightningFlashTimeout;
|
||||
|
||||
function startLightning() {
|
||||
lightningCanvas = document.createElement('canvas');
|
||||
lightningCanvas.id = 'lightning-canvas';
|
||||
lightningCanvas.width = window.innerWidth;
|
||||
lightningCanvas.height = window.innerHeight;
|
||||
Object.assign(lightningCanvas.style, {
|
||||
position: 'fixed', top: 0, left: 0,
|
||||
pointerEvents: 'none', zIndex: 9999,
|
||||
opacity: '0', transition: 'opacity 0.3s ease'
|
||||
});
|
||||
document.body.appendChild(lightningCanvas);
|
||||
lightningCtx = lightningCanvas.getContext('2d');
|
||||
requestAnimationFrame(() => { lightningCanvas.style.opacity = '1'; });
|
||||
|
||||
function drawBolt(x1, y1, x2, y2, roughness, depth) {
|
||||
if (depth === 0) {
|
||||
lightningCtx.beginPath();
|
||||
lightningCtx.moveTo(x1, y1);
|
||||
lightningCtx.lineTo(x2, y2);
|
||||
lightningCtx.stroke();
|
||||
return;
|
||||
}
|
||||
const mx = (x1 + x2) / 2 + (Math.random() - 0.5) * roughness;
|
||||
const my = (y1 + y2) / 2 + (Math.random() - 0.5) * roughness;
|
||||
drawBolt(x1, y1, mx, my, roughness / 2, depth - 1);
|
||||
drawBolt(mx, my, x2, y2, roughness / 2, depth - 1);
|
||||
// random fork branch
|
||||
if (depth > 2 && Math.random() < 0.4) {
|
||||
const fx = mx + (Math.random() - 0.3) * roughness * 2;
|
||||
const fy = my + Math.random() * roughness * 2;
|
||||
lightningCtx.save();
|
||||
lightningCtx.globalAlpha *= 0.5;
|
||||
drawBolt(mx, my, fx, fy, roughness / 2, depth - 2);
|
||||
lightningCtx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function flashStrike() {
|
||||
lightningCtx.clearRect(0, 0, lightningCanvas.width, lightningCanvas.height);
|
||||
|
||||
const numBolts = 1 + Math.floor(Math.random() * 3);
|
||||
for (let i = 0; i < numBolts; i++) {
|
||||
const startX = lightningCanvas.width * (0.1 + Math.random() * 0.8);
|
||||
const endX = startX + (Math.random() - 0.5) * 300;
|
||||
const color = Math.random() < 0.6
|
||||
? `rgba(220, 180, 255, ${0.6 + Math.random() * 0.4})` // purple
|
||||
: `rgba(255, 255, 255, ${0.7 + Math.random() * 0.3})`; // white
|
||||
|
||||
lightningCtx.strokeStyle = color;
|
||||
lightningCtx.lineWidth = 0.5 + Math.random() * 2;
|
||||
lightningCtx.shadowColor = color;
|
||||
lightningCtx.shadowBlur = 18 + Math.random() * 30;
|
||||
lightningCtx.globalAlpha = 1;
|
||||
|
||||
drawBolt(startX, 0, endX, lightningCanvas.height * (0.4 + Math.random() * 0.5), 120, 7);
|
||||
}
|
||||
|
||||
// ambient flash on the body
|
||||
document.body.style.transition = 'background 0.05s';
|
||||
document.body.style.background = `rgba(80, 0, 120, 0.08)`;
|
||||
setTimeout(() => { document.body.style.background = ''; }, 80);
|
||||
|
||||
// fade the bolt out
|
||||
lightningFlashTimeout = setTimeout(() => {
|
||||
lightningCtx.clearRect(0, 0, lightningCanvas.width, lightningCanvas.height);
|
||||
}, 80 + Math.random() * 120);
|
||||
}
|
||||
|
||||
// irregular strike cadence — like real lightning
|
||||
function scheduleStrike() {
|
||||
flashStrike();
|
||||
const delay = 600 + Math.random() * 2200;
|
||||
lightningInterval = setTimeout(scheduleStrike, delay);
|
||||
}
|
||||
scheduleStrike();
|
||||
}
|
||||
|
||||
function stopLightning() {
|
||||
if (!lightningCanvas) return;
|
||||
lightningCanvas.style.opacity = '0';
|
||||
clearTimeout(lightningInterval);
|
||||
clearTimeout(lightningFlashTimeout);
|
||||
setTimeout(() => {
|
||||
lightningCanvas.remove();
|
||||
lightningCanvas = null;
|
||||
}, 800);
|
||||
}
|
||||
|
||||
|
||||
function dragonTypewriterClick() {
|
||||
if (document.getElementById('typewriter-canvas')) return;
|
||||
if (document.getElementById('lightning-canvas')) stopLightning();
|
||||
if (document.getElementById('cure-canvas')) stopWater();
|
||||
if (document.getElementById('matrix-canvas')) stopMatrix();
|
||||
if (document.getElementById('rain-canvas')) stopRain();
|
||||
|
||||
const el = document.getElementById('interest');
|
||||
el.textContent = 'case file 36609...';
|
||||
startTypewriter();
|
||||
isDay = false;
|
||||
makenight();
|
||||
setTimeout(() => {
|
||||
el.textContent = 'interest';
|
||||
stopTypewriter();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
let twCanvas, twCtx, twAnimFrame;
|
||||
|
||||
function startTypewriter() {
|
||||
twCanvas = document.createElement('canvas');
|
||||
twCanvas.id = 'typewriter-canvas';
|
||||
twCanvas.width = window.innerWidth;
|
||||
twCanvas.height = window.innerHeight;
|
||||
Object.assign(twCanvas.style, {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: -1,
|
||||
opacity: '0',
|
||||
transition: 'opacity 1.2s ease'
|
||||
});
|
||||
document.body.appendChild(twCanvas);
|
||||
twCtx = twCanvas.getContext('2d');
|
||||
requestAnimationFrame(() => { twCanvas.style.opacity = '1'; });
|
||||
|
||||
const W = twCanvas.width;
|
||||
const H = twCanvas.height;
|
||||
|
||||
// ── case file fragments ──────────────────────────────────────────────
|
||||
const fragments = [
|
||||
'SUBJECT: HARRIET VANGER',
|
||||
'CASE NO. 36609-74',
|
||||
'CLASSIFICATION: MISSING / PRESUMED',
|
||||
'████████████ DECEASED ████████████',
|
||||
'LAST SEEN: HEDEBY ISLAND',
|
||||
'NOVEMBER 1, 1966',
|
||||
'INVESTIGATING OFFICER: ██████████',
|
||||
'FINANCIAL IRREGULARITIES NOTED',
|
||||
'SEE ATTACHMENT ██-C',
|
||||
'WENNERSTROM GROUP — CLASSIFIED',
|
||||
'DO NOT DISTRIBUTE',
|
||||
'SOURCE: CONFIDENTIAL',
|
||||
'SALANDER, L. — AUTHORIZED',
|
||||
'BLOMKVIST, M. — AUTHORIZED',
|
||||
'ALL OTHERS: ACCESS DENIED',
|
||||
'████████████████████████████',
|
||||
'MOTIVE: UNKNOWN',
|
||||
'SUSPECT LIST: REDACTED',
|
||||
'EVIDENCE SEALED BY ORDER OF',
|
||||
'████████ COURT ████████',
|
||||
'TRAUMA INDICATED / SEE REPORT 7',
|
||||
'LISBETH — CLEARANCE LEVEL 3',
|
||||
'THE DRAGON TATTOO',
|
||||
'——————————————————————',
|
||||
'WHERE IS SHE.',
|
||||
'WHO KNOWS.',
|
||||
'FILE CLOSED: ██/██/████',
|
||||
];
|
||||
|
||||
// glitch chars pool
|
||||
const glitchChars = '!@#$%&*?|\\/<>[]{}~^░▒▓█▄▀■□▪▫';
|
||||
function randGlitch() {
|
||||
return glitchChars[Math.floor(Math.random() * glitchChars.length)];
|
||||
}
|
||||
|
||||
// ── active lines on screen ───────────────────────────────────────────
|
||||
const lines = [];
|
||||
let fragmentPool = [...fragments];
|
||||
|
||||
function pickFragment() {
|
||||
if (!fragmentPool.length) fragmentPool = [...fragments];
|
||||
const i = Math.floor(Math.random() * fragmentPool.length);
|
||||
return fragmentPool.splice(i, 1)[0];
|
||||
}
|
||||
|
||||
function spawnLine() {
|
||||
const text = pickFragment();
|
||||
const fontSize = 11 + Math.floor(Math.random() * 9);
|
||||
const x = 40 + Math.random() * (W * 0.55);
|
||||
const y = 60 + Math.random() * (H - 120);
|
||||
const isRedact = text.includes('█') || Math.random() < 0.12;
|
||||
const speed = 28 + Math.random() * 22; // chars per second
|
||||
|
||||
lines.push({
|
||||
text,
|
||||
x, y,
|
||||
fontSize,
|
||||
typed: 0, // chars revealed so far (float)
|
||||
speed,
|
||||
glitching: false,
|
||||
glitchTimer: 0,
|
||||
glitchStr: '',
|
||||
opacity: 0.72 + Math.random() * 0.26,
|
||||
isRedact,
|
||||
done: false,
|
||||
holdTimer: 0,
|
||||
fadeOut: false,
|
||||
fadeAlpha: 1,
|
||||
stamp: Math.random() < 0.08, // rare CLASSIFIED/DENIED stamp
|
||||
});
|
||||
}
|
||||
|
||||
// spawn cadence
|
||||
spawnLine();
|
||||
const spawnTimer = setInterval(() => {
|
||||
if (lines.length < 14) spawnLine();
|
||||
}, 600);
|
||||
twCanvas._spawnTimer = spawnTimer;
|
||||
|
||||
let lastTs = null;
|
||||
|
||||
function drawFrame(ts) {
|
||||
if (!lastTs) lastTs = ts;
|
||||
const dt = (ts - lastTs) / 1000;
|
||||
lastTs = ts;
|
||||
|
||||
twCtx.clearRect(0, 0, W, H);
|
||||
|
||||
// faint scanline overlay — gives it that CRT/photocopy feel
|
||||
for (let sy = 0; sy < H; sy += 3) {
|
||||
twCtx.fillStyle = 'rgba(0,0,0,0.04)';
|
||||
twCtx.fillRect(0, sy, W, 1);
|
||||
}
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
twCtx.font = `${line.fontSize}px "Courier New", Courier, monospace`;
|
||||
|
||||
if (line.fadeOut) {
|
||||
line.fadeAlpha -= dt * 0.6;
|
||||
if (line.fadeAlpha <= 0) {
|
||||
lines.splice(idx, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const alpha = line.opacity * (line.fadeOut ? line.fadeAlpha : 1);
|
||||
|
||||
if (!line.done) {
|
||||
line.typed += line.speed * dt;
|
||||
if (line.typed >= line.text.length) {
|
||||
line.typed = line.text.length;
|
||||
line.done = true;
|
||||
}
|
||||
|
||||
// random mid-type glitch burst
|
||||
if (!line.glitching && Math.random() < 0.004) {
|
||||
line.glitching = true;
|
||||
line.glitchTimer = 0.18 + Math.random() * 0.22;
|
||||
line.glitchStr = Array.from(
|
||||
{ length: 3 + Math.floor(Math.random() * 5) },
|
||||
randGlitch
|
||||
).join('');
|
||||
}
|
||||
} else {
|
||||
// after typing finishes, hold then fade
|
||||
line.holdTimer += dt;
|
||||
if (line.holdTimer > 3.5 + Math.random() * 3) {
|
||||
line.fadeOut = true;
|
||||
// spawn a replacement
|
||||
setTimeout(spawnLine, 200 + Math.random() * 600);
|
||||
}
|
||||
|
||||
// occasional re-glitch on finished lines
|
||||
if (!line.glitching && Math.random() < 0.003) {
|
||||
line.glitching = true;
|
||||
line.glitchTimer = 0.1 + Math.random() * 0.15;
|
||||
line.glitchStr = Array.from(
|
||||
{ length: 2 + Math.floor(Math.random() * 4) },
|
||||
randGlitch
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// resolve glitch burst
|
||||
if (line.glitching) {
|
||||
line.glitchTimer -= dt;
|
||||
if (line.glitchTimer <= 0) {
|
||||
line.glitching = false;
|
||||
line.glitchStr = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── build visible string ───────────────────────────────────────
|
||||
const visible = line.text.slice(0, Math.floor(line.typed));
|
||||
let display = visible;
|
||||
|
||||
// blinking cursor at the type head
|
||||
if (!line.done) {
|
||||
const blink = Math.floor(ts / 530) % 2 === 0;
|
||||
display += blink ? '▌' : ' ';
|
||||
}
|
||||
|
||||
// append glitch burst after cursor
|
||||
if (line.glitching) display += line.glitchStr;
|
||||
|
||||
// ── color choice ───────────────────────────────────────────────
|
||||
// redacted blocks stay white-ish; normal text is the faded amber/grey
|
||||
const isRedactLine = line.text.includes('█');
|
||||
if (isRedactLine) {
|
||||
twCtx.fillStyle = `rgba(220, 215, 200, ${alpha})`;
|
||||
} else if (line.stamp) {
|
||||
twCtx.fillStyle = `rgba(180, 30, 30, ${alpha * 0.85})`;
|
||||
} else {
|
||||
// slight warm/cool variation per line
|
||||
const warm = Math.random() < 0.5;
|
||||
twCtx.fillStyle = warm
|
||||
? `rgba(210, 200, 180, ${alpha})`
|
||||
: `rgba(185, 200, 195, ${alpha})`;
|
||||
}
|
||||
|
||||
twCtx.fillText(display, line.x, line.y);
|
||||
|
||||
// ── underline for "stamp" lines ────────────────────────────────
|
||||
if (line.stamp && line.done) {
|
||||
const mw = twCtx.measureText(line.text).width;
|
||||
twCtx.strokeStyle = `rgba(180, 30, 30, ${alpha * 0.6})`;
|
||||
twCtx.lineWidth = 1;
|
||||
twCtx.beginPath();
|
||||
twCtx.moveTo(line.x, line.y + 3);
|
||||
twCtx.lineTo(line.x + mw, line.y + 3);
|
||||
twCtx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
if (twCanvas) twAnimFrame = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
twAnimFrame = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
function stopTypewriter() {
|
||||
if (!twCanvas) return;
|
||||
twCanvas.style.opacity = '0';
|
||||
clearInterval(twCanvas._spawnTimer);
|
||||
cancelAnimationFrame(twAnimFrame);
|
||||
setTimeout(() => {
|
||||
twCanvas.remove();
|
||||
twCanvas = null;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function matrixClick() {
|
||||
if (document.getElementById('matrix-canvas')) return;
|
||||
|
||||
if (document.getElementById('cure-canvas')) stopWater();
|
||||
if (document.getElementById('lightning-canvas')) stopLightning();
|
||||
if (document.getElementById('typewriter-canvas')) stopTypewriter();
|
||||
if (document.getElementById('rain-canvas')) stopRain();
|
||||
|
||||
const el = document.getElementById('interest');
|
||||
el.textContent = 'wake up, neo...';
|
||||
startMatrix();
|
||||
isDay = false;
|
||||
makenight();
|
||||
setTimeout(() => {
|
||||
el.textContent = 'interest';
|
||||
stopMatrix();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
let matrixCanvas, matrixCtx, matrixAnimFrame, matrixStopping = false;
|
||||
|
||||
function startMatrix() {
|
||||
matrixCanvas = document.createElement('canvas');
|
||||
matrixCanvas.id = 'matrix-canvas';
|
||||
matrixCanvas.width = window.innerWidth;
|
||||
matrixCanvas.height = window.innerHeight;
|
||||
Object.assign(matrixCanvas.style, {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: -1,
|
||||
opacity: '0',
|
||||
transition: 'opacity 1.2s ease'
|
||||
});
|
||||
document.body.appendChild(matrixCanvas);
|
||||
matrixCtx = matrixCanvas.getContext('2d');
|
||||
requestAnimationFrame(() => { matrixCanvas.style.opacity = '1'; });
|
||||
|
||||
const W = matrixCanvas.width;
|
||||
const H = matrixCanvas.height;
|
||||
|
||||
const FONT_SIZE = 16;
|
||||
const COLS = Math.floor(W / FONT_SIZE);
|
||||
|
||||
// katakana unicode block ァ–ン (U+30A1–U+30F6)
|
||||
const KATA_START = 0x30A1;
|
||||
const KATA_END = 0x30F6;
|
||||
function randKata() {
|
||||
return String.fromCharCode(
|
||||
KATA_START + Math.floor(Math.random() * (KATA_END - KATA_START + 1))
|
||||
);
|
||||
}
|
||||
|
||||
// each column: how far down it has dripped (in rows)
|
||||
// start negative so columns begin off-screen at staggered times
|
||||
const drops = Array.from({ length: COLS }, () => ({
|
||||
y: -(Math.random() * 50), // staggered start above top
|
||||
speed: 0.1 + Math.random() * 0.2,
|
||||
glyphs: [] // trail of { char, age }
|
||||
}));
|
||||
|
||||
const TRAIL_LEN = 14; // how many glyphs in the tail
|
||||
|
||||
function drawFrame() {
|
||||
// dim the previous frame — this creates the fade trail
|
||||
matrixCtx.fillStyle = 'rgba(0, 0, 0, 0.10)';
|
||||
matrixCtx.fillRect(0, 0, W, H);
|
||||
|
||||
matrixCtx.font = `${FONT_SIZE}px monospace`;
|
||||
|
||||
drops.forEach((drop, col) => {
|
||||
const x = col * FONT_SIZE;
|
||||
|
||||
// advance drop
|
||||
drop.y += drop.speed;
|
||||
|
||||
// push a new glyph at the head
|
||||
if (drop.y >= 0) {
|
||||
drop.glyphs.unshift({ char: randKata(), age: 0 });
|
||||
if (drop.glyphs.length > TRAIL_LEN) drop.glyphs.pop();
|
||||
}
|
||||
|
||||
// age each glyph and draw
|
||||
drop.glyphs.forEach((g, i) => {
|
||||
g.age++;
|
||||
// randomly mutate the character occasionally
|
||||
if (Math.random() < 0.04) g.char = randKata();
|
||||
|
||||
const headY = Math.floor(drop.y) - i;
|
||||
if (headY < 0 || headY * FONT_SIZE > H) return;
|
||||
|
||||
const t = i / TRAIL_LEN; // 0 = head, 1 = tail
|
||||
|
||||
if (i === 0) {
|
||||
// head glyph — bright white flash
|
||||
matrixCtx.fillStyle = `rgba(200, 255, 220, 0.98)`;
|
||||
} else if (i < 3) {
|
||||
// near-head — bright green
|
||||
matrixCtx.fillStyle = `rgba(100, 255, 140, ${1 - t * 0.3})`;
|
||||
} else {
|
||||
// tail — fade to dark green
|
||||
const alpha = Math.max(0, 1 - t * 1.1);
|
||||
const green = Math.floor(180 - t * 120);
|
||||
matrixCtx.fillStyle = `rgba(0, ${green}, 60, ${alpha})`;
|
||||
}
|
||||
|
||||
matrixCtx.fillText(g.char, x, headY * FONT_SIZE);
|
||||
});
|
||||
|
||||
// reset column when it scrolls fully off the bottom
|
||||
// reset column when it scrolls fully off the bottom
|
||||
if (drop.y * FONT_SIZE > H + TRAIL_LEN * FONT_SIZE) {
|
||||
if (matrixStopping) {
|
||||
// don't recycle — park it off-screen so the trail dies
|
||||
drop.y = -(TRAIL_LEN * 10);
|
||||
drop.glyphs = [];
|
||||
} else {
|
||||
drop.y = -(Math.random() * 30);
|
||||
drop.speed = 0.3 + Math.random() * 0.7;
|
||||
drop.glyphs = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (matrixCanvas) matrixAnimFrame = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
matrixAnimFrame = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
function stopMatrix() {
|
||||
if (!matrixCanvas) return;
|
||||
|
||||
// signal: no new heads, let existing trails die out
|
||||
matrixStopping = true;
|
||||
|
||||
// after trails have had time to fade, pull the canvas
|
||||
setTimeout(() => {
|
||||
cancelAnimationFrame(matrixAnimFrame);
|
||||
matrixCanvas.style.opacity = '0';
|
||||
matrixCanvas.style.transition = 'opacity 1.2s ease';
|
||||
setTimeout(() => {
|
||||
matrixCanvas.remove();
|
||||
matrixCanvas = null;
|
||||
matrixStopping = false;
|
||||
}, 1200);
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
|
||||
function bladerunnerClick() {
|
||||
if (document.getElementById('rain-canvas')) return; // already running, do nothing
|
||||
|
||||
if (document.getElementById('cure-canvas')) stopWater();
|
||||
if (document.getElementById('matrix-canvas')) stopMatrix();
|
||||
if (document.getElementById('typewriter-canvas')) stopTypewriter();
|
||||
if (document.getElementById('lightning-canvas')) stopLightning();
|
||||
|
||||
|
||||
const el = document.getElementById('interest');
|
||||
el.textContent = 'tears in rain';
|
||||
|
||||
startRain();
|
||||
|
||||
isDay = false
|
||||
makenight();
|
||||
|
||||
setTimeout(() => {
|
||||
el.textContent = "interest";
|
||||
stopRain();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
let rainCanvas, rainCtx, rainInterval;
|
||||
|
||||
function startRain() {
|
||||
rainCanvas = document.createElement('canvas');
|
||||
rainCanvas.id = 'rain-canvas';
|
||||
rainCanvas.width = window.innerWidth;
|
||||
rainCanvas.height = window.innerHeight;
|
||||
document.body.appendChild(rainCanvas);
|
||||
rainCtx = rainCanvas.getContext('2d');
|
||||
|
||||
requestAnimationFrame(() => { rainCanvas.style.opacity = '1'; });
|
||||
|
||||
const drops = Array.from({ length: 120 }, () => ({
|
||||
x: Math.random() * rainCanvas.width,
|
||||
y: Math.random() * rainCanvas.height,
|
||||
speed: 8 + Math.random() * 7,
|
||||
length: 12 + Math.random() * 20,
|
||||
opacity: 0.2 + Math.random() * 0.4
|
||||
}));
|
||||
|
||||
rainInterval = setInterval(() => {
|
||||
rainCtx.clearRect(0, 0, rainCanvas.width, rainCanvas.height);
|
||||
drops.forEach(drop => {
|
||||
rainCtx.beginPath();
|
||||
rainCtx.moveTo(drop.x, drop.y);
|
||||
rainCtx.lineTo(drop.x - 2, drop.y + drop.length);
|
||||
rainCtx.strokeStyle = `rgba(174, 214, 241, ${drop.opacity})`;
|
||||
rainCtx.lineWidth = 3;
|
||||
rainCtx.stroke();
|
||||
|
||||
drop.y += drop.speed;
|
||||
if (drop.y > rainCanvas.height) {
|
||||
drop.y = -drop.length;
|
||||
drop.x = Math.random() * rainCanvas.width;
|
||||
}
|
||||
});
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function stopRain() {
|
||||
if (!rainCanvas) return;
|
||||
rainCanvas.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
clearInterval(rainInterval);
|
||||
rainCanvas.remove();
|
||||
rainCanvas = null;
|
||||
}, 800);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<section id="automation">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">Technical Tools & Automation</h2>
|
||||
<span class="page-sub">engineering/programing</span>
|
||||
</div>
|
||||
<div class="exp-grid">
|
||||
<p class="exp-body">
|
||||
The task here is simple: to reduce work, it's a simple investment. The choice of picking the correct
|
||||
bottleneck is as important as the tool itself, as this gives it use. If it's never used, it never creates
|
||||
value. The same goes for uptime, it's important that stability is almost perfect, as downtime undermines
|
||||
the utility of having the tool. Remember that making and using a tool also creates debt for future
|
||||
maintenance of the tool.
|
||||
<br>
|
||||
To adhere to this, I often choose Golang or Python as they are cheap to maintain. Golang has good
|
||||
stability because it is easy to code and compile checks give early error detection. A word of caution on
|
||||
AI generated tools: while they are cheap to make, the maintenance debt grows exponentially, as the code is
|
||||
often poorly understood by the team and difficult to reason about over time.
|
||||
|
||||
</p>
|
||||
<div class="exp-card" >
|
||||
<div class="exp-header">
|
||||
<span class="exp-title">Portfolio Engine</span>
|
||||
</div>
|
||||
<p class="exp-body">A lightweight portfolio and financial data backend written in Go. Tracks companies,
|
||||
currencies, revenue reports, and trades across configurable time periods — replacing spreadsheets with a
|
||||
proper database, REST API, and interactive shell.</p>
|
||||
<span class="exp-title">Roadmap</span>
|
||||
<ul style="padding: 20px;">
|
||||
<li class="exp-body">Price update endpoint</li>
|
||||
<li class="exp-body">Multi-currency conversion</li>
|
||||
<li class="exp-body">Frontend dashboard</li>
|
||||
<li class="exp-body">Shares outstanding history database table</li>
|
||||
<li class="exp-body">Earnings per share (EPS) based on shares owned</li>
|
||||
<li class="exp-body">First-In, First-Out (FIFO) realized gains</li>
|
||||
<li class="exp-body">Dividends earned and related taxes</li>
|
||||
<li class="exp-body">Total tax obligations</li>
|
||||
</ul>
|
||||
<a class="outlink" href="https://git.samantha42.xyz/samantha/Portfolio-Engine">Git repo</a>
|
||||
</div>
|
||||
<div class="exp-card">
|
||||
<div class="exp-header">
|
||||
<span class="exp-title">Accounting Management</span>
|
||||
</div>
|
||||
<p class="exp-body">
|
||||
A lightweight, SQL-backed, web-based CLI system designed to track assets and liabilities. The goal is to
|
||||
provide a simple, fast, and scalable financial tracking tool that can grow from personal use to more
|
||||
advanced scenarios.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||