first
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>About — Samantha 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="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<a href="/" class="back">← back</a>
|
||||
<p class="tag">// about me</p>
|
||||
<h1>Samantha<br/><span>Friis</span></h1>
|
||||
<p class="header-sub">Denmark, Aalborg · samantha42.xyz</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<p class="summary">
|
||||
Bachelor's student in Business Administration seeking an entry-level analyst or finance-related role while studying. Strong numerical skills, advanced Excel, and experience in company, risk, and financial analysis. Analytical, detail-oriented, and efficiency-focused.
|
||||
</p>
|
||||
|
||||
<!-- Skills -->
|
||||
<section>
|
||||
<p class="section-label">// skills</p>
|
||||
<div class="skills-list">
|
||||
<div class="skill-row"><span>Excel</span> <div class="skill-bar-track"><div class="skill-bar-fill" style="width:85%;animation-delay:0.1s"></div></div></div>
|
||||
<div class="skill-row"><span>Python</span> <div class="skill-bar-track"><div class="skill-bar-fill" style="width:75%;animation-delay:0.2s"></div></div></div>
|
||||
<div class="skill-row"><span>SQL</span> <div class="skill-bar-track"><div class="skill-bar-fill" style="width:60%;animation-delay:0.3s"></div></div></div>
|
||||
<div class="skill-row"><span>Go</span> <div class="skill-bar-track"><div class="skill-bar-fill" style="width:70%;animation-delay:0.4s"></div></div></div>
|
||||
<div class="skill-row"><span>Git</span> <div class="skill-bar-track"><div class="skill-bar-fill" style="width:65%;animation-delay:0.5s"></div></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Experience -->
|
||||
<section>
|
||||
<p class="section-label">// experience</p>
|
||||
|
||||
<div class="exp-block">
|
||||
<h2>Independent Equity Research</h2>
|
||||
<p>Perform fundamental analysis using SEC filings (10-K, 10-Q) and earnings reports. Assess profitability, liquidity, risk factors, and long-term business sustainability. Handle incomplete or low-transparency information through structured cross-validation.</p>
|
||||
</div>
|
||||
|
||||
<div class="exp-block">
|
||||
<h2>Technical Tools & Automation</h2>
|
||||
<p>Build lightweight backend tools to automate repetitive analytical and operational tasks. Use Python for data processing, validation, and workflow automation. Develop lightweight online services in Go, focusing on speed and reliability.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Education -->
|
||||
<section>
|
||||
<p class="section-label">// education</p>
|
||||
|
||||
<div class="edu-item">
|
||||
<span class="edu-year">2025 — now</span>
|
||||
<div>
|
||||
<p class="edu-title">Bachelor of Economics & Business Administration (HA)</p>
|
||||
<p class="edu-sub">Aalborg University · Expected 2028</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="edu-item">
|
||||
<span class="edu-year">2020 — 2023</span>
|
||||
<div>
|
||||
<p class="edu-title">HTX – Technical Upper Secondary Education</p>
|
||||
<p class="edu-sub">Viden Gymnasier · Mathematics A, Biology B<br/>Study project: Mathematics & Technology</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Languages -->
|
||||
<section>
|
||||
<p class="section-label">// languages</p>
|
||||
<div class="skills-list">
|
||||
<div class="skill-row"><span>Danish</span> <div class="skill-bar-track"><div class="skill-bar-fill" style="width:100%;animation-delay:0.1s"></div></div></div>
|
||||
<div class="skill-row"><span>English</span><div class="skill-bar-track"><div class="skill-bar-fill" style="width:80%;animation-delay:0.2s"></div></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Interests -->
|
||||
<section>
|
||||
<p class="section-label">// interests</p>
|
||||
<div class="tags">
|
||||
<span class="tag-pill">Gaming</span>
|
||||
<span class="tag-pill">Board games</span>
|
||||
<span class="tag-pill">Programming</span>
|
||||
<span class="tag-pill">Financial markets</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a href="mailto:contact@samantha42.xyz">contact@samantha42.xyz</a>
|
||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Cybersecurity — Samantha 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="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 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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:contact@samantha42.xyz">contact@samantha42.xyz</a>
|
||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Git — Samantha 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="styles.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<a href="/" class="back">← back</a>
|
||||
<p class="tag">// git</p>
|
||||
<h1>Code &<br/><span>Repositories</span></h1>
|
||||
<p class="header-sub">Personal projects · Private & public</p>
|
||||
</header>
|
||||
|
||||
<main class="two-col">
|
||||
|
||||
<!-- ── LEFT: Login / Private Dashboard ── -->
|
||||
<div>
|
||||
<p class="section-label">// private access</p>
|
||||
|
||||
<div class="login-panel" id="login-panel">
|
||||
<h2>Sign In</h2>
|
||||
<p class="desc">Access to private repositories and personal workspace. Requires credentials and 2FA.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" placeholder="samantha" autocomplete="username" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" placeholder="••••••••••••" autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<div class="twofa-group" id="twofa-group">
|
||||
<label for="twofa">Authenticator Code</label>
|
||||
<input type="text" id="twofa" placeholder="000 000" maxlength="7" autocomplete="one-time-code" />
|
||||
<p class="twofa-hint">Enter the 6-digit code from your authenticator app.</p>
|
||||
</div>
|
||||
|
||||
<button class="btn-login" id="login-btn" onclick="handleLogin()">Continue →</button>
|
||||
<p class="login-error" id="login-error">Invalid credentials. Please try again.</p>
|
||||
</div>
|
||||
|
||||
<!-- Private dashboard (shown after login) -->
|
||||
<div id="private-dashboard">
|
||||
<div class="repo-list">
|
||||
<a href="#" class="repo-item">
|
||||
<div>
|
||||
<p class="repo-name">samantha-site</p>
|
||||
<p class="repo-desc">Personal website and Go backend server</p>
|
||||
</div>
|
||||
<span class="repo-badge private">private</span>
|
||||
</a>
|
||||
<a href="#" class="repo-item">
|
||||
<div>
|
||||
<p class="repo-name">equity-tools</p>
|
||||
<p class="repo-desc">Python scripts for SEC filing analysis and financial modeling</p>
|
||||
</div>
|
||||
<span class="repo-badge active">active</span>
|
||||
</a>
|
||||
<a href="#" class="repo-item">
|
||||
<div>
|
||||
<p class="repo-name">infra-config</p>
|
||||
<p class="repo-desc">Dotfiles, server configs, and deployment scripts</p>
|
||||
</div>
|
||||
<span class="repo-badge private">private</span>
|
||||
</a>
|
||||
<a href="#" class="repo-item">
|
||||
<div>
|
||||
<p class="repo-name">typst-templates</p>
|
||||
<p class="repo-desc">Custom Typst document templates for reports and analysis</p>
|
||||
</div>
|
||||
<span class="repo-badge active">active</span>
|
||||
</a>
|
||||
</div>
|
||||
<button class="logout-btn" onclick="handleLogout()">← sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── RIGHT: Public section ── -->
|
||||
<div>
|
||||
<div class="repo-list">
|
||||
<a href="/gitpage/TicTacToe" class="repo-item">
|
||||
<div>
|
||||
<p class="repo-name">TicTacToe</p>
|
||||
<p class="repo-desc">Python learning tic tac toe game in terminal</p>
|
||||
</div>
|
||||
<span class="repo-badge private">private</span>
|
||||
</a>
|
||||
<a href="/gitpage/infra-config" class="repo-item">
|
||||
<div>
|
||||
<p class="repo-name">infra-config</p>
|
||||
<p class="repo-desc">Dotfiles, server configs, and deployment scripts</p>
|
||||
</div>
|
||||
<span class="repo-badge private">private</span>
|
||||
</a>
|
||||
<a href="/gitpage/typst-templates" class="repo-item">
|
||||
<div>
|
||||
<p class="repo-name">typst-templates</p>
|
||||
<p class="repo-desc">Custom Typst document templates for reports and analysis</p>
|
||||
</div>
|
||||
<span class="repo-badge active">active</span>
|
||||
</a>
|
||||
</div>
|
||||
<button class="logout-btn" onclick="handleLogout()">← sign out</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a href="mailto:contact@samantha42.xyz">contact@samantha42.xyz</a>
|
||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let step = 1;
|
||||
|
||||
function handleLogin() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value.trim();
|
||||
const twofa = document.getElementById('twofa').value.trim();
|
||||
const tfGroup = document.getElementById('twofa-group');
|
||||
|
||||
document.getElementById('login-error').classList.remove('visible');
|
||||
|
||||
if (step === 1) {
|
||||
if (!username || !password) { showError('Please enter your username and password.'); return; }
|
||||
step = 2;
|
||||
tfGroup.classList.add('visible');
|
||||
document.getElementById('login-btn').textContent = 'Verify →';
|
||||
document.getElementById('twofa').focus();
|
||||
} else {
|
||||
if (!twofa || twofa.replace(/\s/g, '').length < 6) { showError('Enter the 6-digit code from your authenticator app.'); return; }
|
||||
if (twofa.replace(/\s/g, '').match(/^\d{6}$/)) {
|
||||
showDashboard();
|
||||
} else {
|
||||
showError('Invalid authenticator code.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const error = document.getElementById('login-error');
|
||||
const panel = document.getElementById('login-panel');
|
||||
error.textContent = msg;
|
||||
error.classList.add('visible');
|
||||
panel.classList.add('shake');
|
||||
setTimeout(() => panel.classList.remove('shake'), 500);
|
||||
}
|
||||
|
||||
function showDashboard() {
|
||||
document.getElementById('login-panel').style.display = 'none';
|
||||
document.getElementById('private-dashboard').classList.add('visible');
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
step = 1;
|
||||
document.getElementById('login-panel').style.display = '';
|
||||
document.getElementById('private-dashboard').classList.remove('visible');
|
||||
document.getElementById('username').value = '';
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('twofa').value = '';
|
||||
document.getElementById('twofa-group').classList.remove('visible');
|
||||
document.getElementById('login-btn').textContent = 'Continue →';
|
||||
document.getElementById('login-error').classList.remove('visible');
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Enter') handleLogin(); });
|
||||
|
||||
document.getElementById('twofa').addEventListener('input', (e) => {
|
||||
let val = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||
if (val.length > 3) val = val.slice(0, 3) + ' ' + val.slice(3);
|
||||
e.target.value = val;
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,953 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>username / project-name</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Syne:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
/* ─── Variables ─── */
|
||||
:root {
|
||||
--bg: #252525;
|
||||
--surface: #2b2b2b;
|
||||
--surface2: #313131;
|
||||
--border: #3a3a3a;
|
||||
--border-focus: #4e4e4e;
|
||||
--text: #e8e8e0;
|
||||
--muted: #777770;
|
||||
--accent: #B88FE0;
|
||||
--accent2: #6247AA;
|
||||
--green: #6fcf8a;
|
||||
--red: #e07070;
|
||||
--yellow: #e0c870;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'DM Mono', monospace;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Grain overlay */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.container { max-width: 980px; margin: 0 auto; padding: 0 2rem; }
|
||||
|
||||
/* ─── Top nav ─── */
|
||||
.topbar {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem 0;
|
||||
animation: fadeUp 0.4s ease both;
|
||||
}
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.site-logo {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.site-logo span { color: var(--accent); }
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.breadcrumb a:hover { opacity: 0.7; }
|
||||
.breadcrumb .sep { color: var(--border-focus); }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; }
|
||||
.nav-link {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: var(--text); }
|
||||
|
||||
/* ─── Repo header ─── */
|
||||
.repo-header {
|
||||
padding: 2.5rem 0 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
animation: fadeUp 0.5s 0.05s ease both;
|
||||
}
|
||||
.repo-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.repo-title-group { flex: 1; min-width: 0; }
|
||||
.repo-tag {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.repo-name {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: clamp(1.6rem, 3vw, 2.4rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.repo-name span { color: var(--accent); }
|
||||
.repo-desc-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
max-width: 540px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.repo-topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.topic {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid rgba(184, 143, 224, 0.2);
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.08em;
|
||||
background: rgba(184, 143, 224, 0.05);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.topic:hover { background: rgba(184, 143, 224, 0.12); cursor: default; }
|
||||
|
||||
/* stats row */
|
||||
.repo-stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.stat-item .stat-icon { font-size: 0.85rem; opacity: 0.6; }
|
||||
.stat-item strong { color: var(--text); font-weight: 500; }
|
||||
|
||||
/* action buttons */
|
||||
.repo-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent2);
|
||||
color: var(--text);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--border-focus); color: var(--text); }
|
||||
|
||||
/* ─── Tab bar ─── */
|
||||
.tabs {
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 0;
|
||||
animation: fadeUp 0.5s 0.1s ease both;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tab {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: 1rem 1.2rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tab-count {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ─── Main layout ─── */
|
||||
.repo-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 260px;
|
||||
gap: 2rem;
|
||||
padding: 2rem 0 4rem;
|
||||
animation: fadeUp 0.5s 0.15s ease both;
|
||||
}
|
||||
|
||||
/* ─── File browser ─── */
|
||||
.file-browser {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.file-browser-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 1rem;
|
||||
}
|
||||
.branch-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.35rem 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.branch-selector:hover { border-color: var(--border-focus); }
|
||||
.branch-name { color: var(--accent); }
|
||||
.file-meta {
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
flex: 1;
|
||||
}
|
||||
.latest-commit {
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.latest-commit span { color: var(--text); }
|
||||
.commit-hash {
|
||||
font-size: 0.65rem;
|
||||
color: var(--accent2);
|
||||
border: 1px solid rgba(98, 71, 170, 0.3);
|
||||
padding: 0.1rem 0.4rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.commit-hash:hover { background: rgba(98, 71, 170, 0.1); }
|
||||
|
||||
/* file rows */
|
||||
.file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5rem 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.file-row:last-child { border-bottom: none; }
|
||||
.file-row:hover { background: var(--surface2); }
|
||||
.file-icon { font-size: 0.85rem; text-align: center; }
|
||||
.file-name {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.file-row:hover .file-name { color: var(--accent); }
|
||||
.file-row.dir .file-name { color: var(--accent2); }
|
||||
.file-row.dir:hover .file-name { color: var(--accent); }
|
||||
.file-commit {
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-age {
|
||||
font-size: 0.62rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* README */
|
||||
.readme {
|
||||
border: 1px solid var(--border);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.readme-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.readme-bar-icon { color: var(--accent); }
|
||||
.readme-content {
|
||||
padding: 2rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.9;
|
||||
color: #c8c8c0;
|
||||
}
|
||||
.readme-content h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.8rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.readme-content h2 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin: 2rem 0 0.8rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.readme-content h2::before {
|
||||
content: '// ';
|
||||
color: var(--accent2);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-weight: 300;
|
||||
}
|
||||
.readme-content p { margin-bottom: 1rem; }
|
||||
.readme-content code {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
color: var(--accent);
|
||||
background: rgba(184, 143, 224, 0.08);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid rgba(184, 143, 224, 0.15);
|
||||
}
|
||||
.readme-content pre {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.2rem 1.4rem;
|
||||
margin: 1rem 0 1.4rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.readme-content pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.76rem;
|
||||
color: #c8c8c0;
|
||||
}
|
||||
.readme-content ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.readme-content ul li {
|
||||
padding-left: 1.2rem;
|
||||
position: relative;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.readme-content ul li::before {
|
||||
content: '▸';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent2);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.readme-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.62rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
margin-right: 0.4rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.readme-badge.green { color: var(--green); border-color: rgba(111, 207, 138, 0.3); }
|
||||
.readme-badge.purple { color: var(--accent); border-color: rgba(184, 143, 224, 0.3); }
|
||||
.readme-badge.yellow { color: var(--yellow); border-color: rgba(224, 200, 112, 0.3); }
|
||||
|
||||
/* ─── Sidebar ─── */
|
||||
.sidebar { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
|
||||
.sidebar-section { }
|
||||
.sidebar-title {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.8rem;
|
||||
padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* about */
|
||||
.about-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.about-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.about-link:hover { opacity: 0.7; }
|
||||
|
||||
/* languages */
|
||||
.lang-bar {
|
||||
height: 4px;
|
||||
display: flex;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.lang-seg { height: 100%; transition: opacity 0.2s; }
|
||||
.lang-seg:hover { opacity: 0.7; cursor: default; }
|
||||
.lang-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.lang-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.lang-label { display: flex; align-items: center; gap: 0.5rem; color: var(--muted); }
|
||||
.lang-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.lang-pct { color: var(--muted); }
|
||||
|
||||
/* activity */
|
||||
.activity-graph {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(26, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.activity-col { display: flex; flex-direction: column; gap: 2px; }
|
||||
.activity-cell {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 1px;
|
||||
background: var(--surface2);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.activity-cell:hover { opacity: 0.7; cursor: default; }
|
||||
.activity-cell.l1 { background: rgba(98, 71, 170, 0.25); }
|
||||
.activity-cell.l2 { background: rgba(98, 71, 170, 0.5); }
|
||||
.activity-cell.l3 { background: rgba(184, 143, 224, 0.5); }
|
||||
.activity-cell.l4 { background: var(--accent); }
|
||||
.activity-label {
|
||||
font-size: 0.6rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* contributors */
|
||||
.contributors { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.contributor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.contributor:hover { opacity: 0.7; }
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.contributor-info { flex: 1; }
|
||||
.contributor-name { font-size: 0.73rem; color: var(--text); }
|
||||
.contributor-commits { font-size: 0.62rem; color: var(--muted); }
|
||||
|
||||
/* releases */
|
||||
.release-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
padding-bottom: 0.8rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.release-item:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
|
||||
.release-tag-label {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: rgba(111, 207, 138, 0.1);
|
||||
border: 1px solid rgba(111, 207, 138, 0.3);
|
||||
color: var(--green);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.release-info {}
|
||||
.release-name { font-size: 0.75rem; color: var(--text); margin-bottom: 0.15rem; }
|
||||
.release-date { font-size: 0.62rem; color: var(--muted); }
|
||||
|
||||
/* ─── Footer ─── */
|
||||
footer {
|
||||
padding: 2rem 0 2.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
animation: fadeUp 0.6s 0.4s ease both;
|
||||
}
|
||||
.footer-copy { font-size: 0.65rem; color: var(--muted); letter-spacing: 0.05em; }
|
||||
footer a { color: var(--accent); text-decoration: none; font-size: 0.75rem; transition: opacity 0.2s; }
|
||||
footer a:hover { opacity: 0.7; }
|
||||
|
||||
/* ─── Animations ─── */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 768px) {
|
||||
.repo-body { grid-template-columns: 1fr; }
|
||||
.sidebar { order: -1; }
|
||||
.repo-actions { flex-direction: row; flex-wrap: wrap; }
|
||||
.file-row { grid-template-columns: 1.5rem 1fr auto; }
|
||||
.file-commit { display: none; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.repo-title-row { flex-direction: column; }
|
||||
.tabs { gap: 0; }
|
||||
.tab { padding: 0.8rem 0.8rem; font-size: 0.65rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top nav -->
|
||||
<nav class="topbar">
|
||||
<div class="container">
|
||||
<div class="topbar-inner">
|
||||
<a href="/gitpage" class="site-logo">Git</a>
|
||||
<div class="breadcrumb">
|
||||
<a href="#">samantha</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="#" style="font-weight:500;">TicTacToe</a>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<a href="#" class="nav-link">Projects</a>
|
||||
<a href="#" class="nav-link">About</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Repo header -->
|
||||
<div class="repo-header">
|
||||
<div class="container">
|
||||
<div class="repo-title-row">
|
||||
<div class="repo-title-group">
|
||||
<div class="repo-tag">// public repository</div>
|
||||
<div class="repo-name">project-<span>name</span></div>
|
||||
<div class="repo-desc-text">A fast, minimal toolkit for processing and visualizing time-series data. Built for clarity and performance with zero dependencies.</div>
|
||||
<div class="repo-topics">
|
||||
<span class="topic">typescript</span>
|
||||
<span class="topic">data-viz</span>
|
||||
<span class="topic">time-series</span>
|
||||
<span class="topic">zero-deps</span>
|
||||
<span class="topic">cli</span>
|
||||
</div>
|
||||
<div class="repo-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon">☆</span>
|
||||
<strong>248</strong> stars
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon">⑂</span>
|
||||
<strong>34</strong> forks
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon">◎</span>
|
||||
<strong>12</strong> watching
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon">⬡</span>
|
||||
MIT License
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-actions">
|
||||
<a href="#" class="btn-primary">⬇ Clone</a>
|
||||
<a href="#" class="btn-ghost">☆ Star</a>
|
||||
<a href="#" class="btn-ghost">⑂ Fork</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<div class="container" style="display:flex;">
|
||||
<a href="#" class="tab active">
|
||||
◫ Code
|
||||
</a>
|
||||
<a href="#" class="tab">
|
||||
◎ Issues <span class="tab-count">7</span>
|
||||
</a>
|
||||
<a href="#" class="tab">
|
||||
⇄ Pull Requests <span class="tab-count">2</span>
|
||||
</a>
|
||||
<a href="#" class="tab">
|
||||
◷ Actions
|
||||
</a>
|
||||
<a href="#" class="tab">
|
||||
◈ Releases <span class="tab-count">4</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main body -->
|
||||
<main>
|
||||
<div class="container">
|
||||
<div class="repo-body">
|
||||
|
||||
<!-- Left: file browser + readme -->
|
||||
<div>
|
||||
<!-- File browser -->
|
||||
<div class="file-browser">
|
||||
<div class="file-browser-bar">
|
||||
<div class="branch-selector">
|
||||
⎇ <span class="branch-name">main</span> ▾
|
||||
</div>
|
||||
<div class="file-meta">
|
||||
<span class="latest-commit"><span>fix: handle edge case in parser</span></span>
|
||||
<span class="commit-hash">a3f92d1</span>
|
||||
</div>
|
||||
<div style="font-size:0.65rem;color:var(--muted);white-space:nowrap;">3 days ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Dir rows -->
|
||||
<a href="#" class="file-row dir">
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-name">src</span>
|
||||
<span class="file-commit">refactor: split core modules into separate files</span>
|
||||
<span class="file-age">2 wks ago</span>
|
||||
</a>
|
||||
<a href="#" class="file-row dir">
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-name">tests</span>
|
||||
<span class="file-commit">test: add coverage for edge cases</span>
|
||||
<span class="file-age">3 days ago</span>
|
||||
</a>
|
||||
<a href="#" class="file-row dir">
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-name">docs</span>
|
||||
<span class="file-commit">docs: update API reference</span>
|
||||
<span class="file-age">1 wk ago</span>
|
||||
</a>
|
||||
<a href="#" class="file-row dir">
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-name">examples</span>
|
||||
<span class="file-commit">chore: add more usage examples</span>
|
||||
<span class="file-age">5 days ago</span>
|
||||
</a>
|
||||
<a href="#" class="file-row">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">.gitignore</span>
|
||||
<span class="file-commit">chore: initial setup</span>
|
||||
<span class="file-age">3 mos ago</span>
|
||||
</a>
|
||||
<a href="#" class="file-row">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">LICENSE</span>
|
||||
<span class="file-commit">chore: initial setup</span>
|
||||
<span class="file-age">3 mos ago</span>
|
||||
</a>
|
||||
<a href="#" class="file-row">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">README.md</span>
|
||||
<span class="file-commit">docs: improve getting started section</span>
|
||||
<span class="file-age">1 wk ago</span>
|
||||
</a>
|
||||
<a href="#" class="file-row">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">package.json</span>
|
||||
<span class="file-commit">chore: bump dependencies</span>
|
||||
<span class="file-age">4 days ago</span>
|
||||
</a>
|
||||
<a href="#" class="file-row">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">tsconfig.json</span>
|
||||
<span class="file-commit">chore: strict mode enabled</span>
|
||||
<span class="file-age">2 mos ago</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- README -->
|
||||
<div class="readme">
|
||||
<div class="readme-bar">
|
||||
<span class="readme-bar-icon">◫</span>
|
||||
README.md
|
||||
</div>
|
||||
<div class="readme-content">
|
||||
<span class="readme-badge green">● build passing</span>
|
||||
<span class="readme-badge purple">v2.1.0</span>
|
||||
<span class="readme-badge yellow">MIT</span>
|
||||
|
||||
<h1>project-name</h1>
|
||||
<p>A fast, minimal toolkit for processing and visualizing time-series data. Built with TypeScript, zero runtime dependencies, works in Node and the browser.</p>
|
||||
|
||||
<h2>Installation</h2>
|
||||
<pre><code>npm install project-name
|
||||
# or
|
||||
yarn add project-name</code></pre>
|
||||
|
||||
<h2>Quick Start</h2>
|
||||
<pre><code>import { TimeSeries, plot } from 'project-name';
|
||||
|
||||
const ts = new TimeSeries(data);
|
||||
const smoothed = ts.rollingMean(7);
|
||||
plot(smoothed, { width: 80, height: 20 });</code></pre>
|
||||
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li>Zero runtime dependencies — ships as a single ESM/CJS bundle</li>
|
||||
<li>Rolling statistics: mean, median, stddev, percentiles</li>
|
||||
<li>Gap detection and interpolation for irregular time series</li>
|
||||
<li>ASCII plot renderer for terminal output</li>
|
||||
<li>Full TypeScript support with strict types</li>
|
||||
<li>~8kb gzipped</li>
|
||||
</ul>
|
||||
|
||||
<h2>Documentation</h2>
|
||||
<p>Full API reference available in <code>docs/</code> or at the project website. Each method is documented with examples and edge-case notes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
|
||||
<!-- About -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">About</div>
|
||||
<div class="about-desc">Fast, minimal time-series processing and visualization. Zero deps.</div>
|
||||
<a href="#" class="about-link">🔗 project-name.dev</a>
|
||||
<a href="#" class="about-link">📦 npm package</a>
|
||||
</div>
|
||||
|
||||
<!-- Languages -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">Languages</div>
|
||||
<div class="lang-bar">
|
||||
<div class="lang-seg" style="width:74%;background:var(--accent2);"></div>
|
||||
<div class="lang-seg" style="width:16%;background:var(--accent);"></div>
|
||||
<div class="lang-seg" style="width:7%;background:var(--yellow);"></div>
|
||||
<div class="lang-seg" style="width:3%;background:var(--muted);"></div>
|
||||
</div>
|
||||
<div class="lang-list">
|
||||
<div class="lang-item">
|
||||
<div class="lang-label"><div class="lang-dot" style="background:var(--accent2);"></div>TypeScript</div>
|
||||
<div class="lang-pct">74%</div>
|
||||
</div>
|
||||
<div class="lang-item">
|
||||
<div class="lang-label"><div class="lang-dot" style="background:var(--accent);"></div>JavaScript</div>
|
||||
<div class="lang-pct">16%</div>
|
||||
</div>
|
||||
<div class="lang-item">
|
||||
<div class="lang-label"><div class="lang-dot" style="background:var(--yellow);"></div>Shell</div>
|
||||
<div class="lang-pct">7%</div>
|
||||
</div>
|
||||
<div class="lang-item">
|
||||
<div class="lang-label"><div class="lang-dot" style="background:var(--muted);"></div>Other</div>
|
||||
<div class="lang-pct">3%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Releases -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">Releases</div>
|
||||
<div class="release-item">
|
||||
<span class="release-tag-label">v2.1.0</span>
|
||||
<div class="release-info">
|
||||
<div class="release-name">Rolling stats & fix</div>
|
||||
<div class="release-date">5 days ago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="release-item">
|
||||
<span class="release-tag-label">v2.0.0</span>
|
||||
<div class="release-info">
|
||||
<div class="release-name">Major rewrite</div>
|
||||
<div class="release-date">3 wks ago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="release-item">
|
||||
<span class="release-tag-label">v1.4.2</span>
|
||||
<div class="release-info">
|
||||
<div class="release-name">Bug fix release</div>
|
||||
<div class="release-date">2 mos ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">Activity</div>
|
||||
<div class="activity-graph" id="activityGraph"></div>
|
||||
<div class="activity-label">Last 26 weeks</div>
|
||||
</div>
|
||||
|
||||
<!-- Contributors -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">Contributors</div>
|
||||
<div class="contributors">
|
||||
<a href="#" class="contributor">
|
||||
<div class="avatar">U</div>
|
||||
<div class="contributor-info">
|
||||
<div class="contributor-name">username</div>
|
||||
<div class="contributor-commits">142 commits</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="contributor">
|
||||
<div class="avatar">J</div>
|
||||
<div class="contributor-info">
|
||||
<div class="contributor-name">jsmith</div>
|
||||
<div class="contributor-commits">28 commits</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="contributor">
|
||||
<div class="avatar">K</div>
|
||||
<div class="contributor-info">
|
||||
<div class="contributor-name">k_dev</div>
|
||||
<div class="contributor-commits">11 commits</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<a href="#">username</a>
|
||||
<div class="footer-copy">© 2026 — built with intention</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Generate activity graph
|
||||
const graph = document.getElementById('activityGraph');
|
||||
const levels = [null, 'l1', 'l2', 'l3', 'l4'];
|
||||
const pattern = [0,0,1,0,2,1,0,1,3,2,1,0,0,1,2,3,4,2,1,0,1,2,1,0,3,2];
|
||||
|
||||
for (let w = 0; w < 26; w++) {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'activity-col';
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const cell = document.createElement('div');
|
||||
const activity = Math.random() < 0.4 ? Math.min(4, Math.floor(Math.random() * 3) + pattern[w]) : 0;
|
||||
cell.className = 'activity-cell' + (activity > 0 ? ` l${activity}` : '');
|
||||
col.appendChild(cell);
|
||||
}
|
||||
graph.appendChild(col);
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Home</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="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<p class="tag">// personal space</p>
|
||||
<h1>Hello,<br/>Friend<span>.</span></h1>
|
||||
<p class="subtitle">Developer, tinkerer, investor. This is my little corner of the internet.</p>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<div class="nav-grid">
|
||||
|
||||
<a href="/about" class="nav-item">
|
||||
<p class="nav-number">01 —</p>
|
||||
<p class="nav-title">About Me <span class="arrow">↗</span></p>
|
||||
<p class="nav-desc">Who I am, what I'm working on, and how to get in touch.</p>
|
||||
</a>
|
||||
|
||||
<a href="/portfolio" class="nav-item">
|
||||
<p class="nav-number">02 —</p>
|
||||
<p class="nav-title">Portfolio <span class="arrow">↗</span></p>
|
||||
<p class="nav-desc">Investment thesis, positions, and market observations.</p>
|
||||
</a>
|
||||
|
||||
|
||||
<a href="/gitpage" target="_blank" rel="noopener" class="nav-item">
|
||||
<p class="nav-number">03 —</p>
|
||||
<p class="nav-title">Git <span class="arrow">↗</span></p>
|
||||
<p class="nav-desc">Personal repositories, open-source projects and code experiments.</p>
|
||||
</a>
|
||||
|
||||
<a href="/Klacq ApS" class="nav-item">
|
||||
<p class="nav-number">04 —</p>
|
||||
<p class="nav-title">Klacq ApS<span class="arrow">↗</span></p>
|
||||
<p class="nav-desc">Small personaly owned streaming service</p>
|
||||
</a>
|
||||
|
||||
<a href="/infra" class="nav-item">
|
||||
<p class="nav-number">05 —</p>
|
||||
<p class="nav-title">Infrastructure <span class="arrow">↗</span></p>
|
||||
<p class="nav-desc">Computer setups for company efficiency </p>
|
||||
</a>
|
||||
|
||||
<a href="/cyber" class="nav-item">
|
||||
<p class="nav-number">06 —</p>
|
||||
<p class="nav-title">Cyber Security<span class="arrow">↗</span></p>
|
||||
<p class="nav-desc">Protect data, personal and companies.</p>
|
||||
</a>
|
||||
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
|
||||
<footer>
|
||||
<a href="mailto:contact@samantha42.xyz">contact@samantha42.xyz</a>
|
||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Infrastructure — Samantha 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="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 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:contact@samantha42.xyz">contact@samantha42.xyz</a>
|
||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,589 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Portfolio — Samantha 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="styles.css">
|
||||
<style>
|
||||
/* ── Trades accordion ── */
|
||||
.trades-section {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.trades-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border, #2a2a3a);
|
||||
border-bottom: 1px solid var(--border, #2a2a3a);
|
||||
width: 100%;
|
||||
padding: 0.85rem 0;
|
||||
color: inherit;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.trades-toggle:hover .toggle-label {
|
||||
color: var(--accent, #f59e0b);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
color: var(--muted, #475569);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.toggle-label span {
|
||||
color: var(--accent, #f59e0b);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
color: var(--muted, #475569);
|
||||
font-size: 1rem;
|
||||
transition: transform 0.25s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trades-toggle[aria-expanded="true"] .toggle-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.trades-body {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease;
|
||||
}
|
||||
|
||||
.trades-body.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.trades-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trades-filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a3a);
|
||||
border-radius: 4px;
|
||||
color: var(--muted, #475569);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-btn:hover,
|
||||
.filter-btn.active {
|
||||
border-color: var(--accent, #f59e0b);
|
||||
color: var(--accent, #f59e0b);
|
||||
background: rgba(245,158,11,0.06);
|
||||
}
|
||||
|
||||
.trades-table-wrap {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.trades-table thead tr {
|
||||
border-bottom: 1px solid var(--border, #2a2a3a);
|
||||
}
|
||||
|
||||
.trades-table th {
|
||||
color: var(--muted, #475569);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.07em;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trades-table td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(42,42,58,0.5);
|
||||
white-space: nowrap;
|
||||
color: var(--text, #e2e8f0);
|
||||
}
|
||||
|
||||
.trades-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.trades-table tbody tr:hover td {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.trades-table tr.hidden-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dir-buy { color: #4ade80; }
|
||||
.dir-sell { color: #f87171; }
|
||||
|
||||
.pnl-pos { color: #4ade80; }
|
||||
.pnl-neg { color: #f87171; }
|
||||
.pnl-zero{ color: var(--muted, #475569); }
|
||||
|
||||
.trade-code {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
background: rgba(42,42,58,0.8);
|
||||
color: var(--muted, #475569);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.trade-ticker {
|
||||
font-weight: 500;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.trades-count {
|
||||
color: var(--muted, #475569);
|
||||
font-size: 0.68rem;
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Live price styles ── */
|
||||
.price-cell {
|
||||
position: relative;
|
||||
}
|
||||
.price-val {
|
||||
transition: color 0.4s;
|
||||
}
|
||||
.price-val.loading {
|
||||
color: var(--muted, #475569);
|
||||
}
|
||||
.price-val.flash-up {
|
||||
color: #4ade80;
|
||||
}
|
||||
.price-val.flash-down {
|
||||
color: #f87171;
|
||||
}
|
||||
.price-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted, #475569);
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.price-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted, #475569);
|
||||
display: inline-block;
|
||||
}
|
||||
.price-dot.live { background: #4ade80; box-shadow: 0 0 4px #4ade80; }
|
||||
.price-dot.error { background: #f87171; }
|
||||
.price-dot.loading {
|
||||
background: #f59e0b;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a3a);
|
||||
border-radius: 4px;
|
||||
color: var(--muted, #475569);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.68rem;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
border-color: var(--accent, #f59e0b);
|
||||
color: var(--accent, #f59e0b);
|
||||
}
|
||||
.section-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<a href="/" class="back">← back</a>
|
||||
<p class="tag">// portfolio</p>
|
||||
<h1>Investment<br/><span>Portfolio</span></h1>
|
||||
<p class="header-sub">Equity positions · Personal research</p>
|
||||
</header>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<p class="card-label">Total Weight</p>
|
||||
<p class="card-value neutral">100,10%</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="card-label">Weighted Return</p>
|
||||
<p class="card-value up">+7,84%</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="card-label">Price Return (w)</p>
|
||||
<p class="card-value up">+7,73%</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="card-label">Positions</p>
|
||||
<p class="card-value neutral">5</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invested table -->
|
||||
<div class="section-header-row">
|
||||
<p class="section-label" style="margin:0">// invested positions</p>
|
||||
<span class="price-status" id="priceStatus">
|
||||
<span class="price-dot loading" id="priceDot"></span>
|
||||
<span id="priceStatusText">fetching prices…</span>
|
||||
</span>
|
||||
<button class="refresh-btn" onclick="fetchHoldingPrices()">↺ refresh</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th>Market Price</th>
|
||||
<th>Avg Price</th>
|
||||
<th>Shares Out.</th>
|
||||
<th>Mkt Cap (M)</th>
|
||||
<th>Weight</th>
|
||||
<th>Return Rate</th>
|
||||
<th>Price Return</th>
|
||||
<th>Rate × Weight</th>
|
||||
<th>P.Return × Weight</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr data-ticker="MAERSK-B.CO">
|
||||
<td><span class="ticker">MEARSK</span><span class="currency-badge">DKK</span></td>
|
||||
<td class="price-cell"><span class="price-val" id="price-MAERSK-B.CO">15,270.00</span></td><td>12,414</td><td>15,224,309</td><td>232,475</td>
|
||||
<td><div class="weight-cell">45.95%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:46%"></div></div></div></td>
|
||||
<td class="neutral">9%</td><td class="up">23.01%</td><td>4.17%</td><td class="up">10.57%</td>
|
||||
</tr>
|
||||
<tr data-ticker="DFDS.CO">
|
||||
<td><span class="ticker">DFDS</span><span class="currency-badge">DKK</span></td>
|
||||
<td class="price-cell"><span class="price-val" id="price-DFDS.CO">98.80</span></td><td>89.82</td><td>53,892,216</td><td>5,325</td>
|
||||
<td><div class="weight-cell">10.43%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:10%"></div></div></div></td>
|
||||
<td class="neutral">8%</td><td class="up">10.00%</td><td>0.83%</td><td class="up">1.04%</td>
|
||||
</tr>
|
||||
<tr data-ticker="ORSTED.CO">
|
||||
<td><span class="ticker">ORSTED</span><span class="currency-badge">DKK</span></td>
|
||||
<td class="price-cell"><span class="price-val" id="price-ORSTED.CO">152.00</span></td><td>128.80</td><td>756,088,069</td><td>114,925</td>
|
||||
<td><div class="weight-cell">9.17%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:9%"></div></div></div></td>
|
||||
<td class="neutral">3.25%</td><td class="up">18.01%</td><td>0.30%</td><td class="up">1.65%</td>
|
||||
</tr>
|
||||
<tr data-ticker="CSIQ">
|
||||
<td><span class="ticker">CSIQ</span><span class="currency-badge">USD</span></td>
|
||||
<td class="price-cell"><span class="price-val" id="price-CSIQ">20.74</span></td><td>20.53</td><td>67,620,463</td><td>1,402</td>
|
||||
<td><div class="weight-cell">3.09%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:3%"></div></div></div></td>
|
||||
<td class="neutral">4.95%</td><td class="up">1.02%</td><td>0.15%</td><td class="up">0.03%</td>
|
||||
</tr>
|
||||
<tr data-ticker="NOVO-B.CO">
|
||||
<td><span class="ticker">NOVO NORDISK</span><span class="currency-badge">DKK</span></td>
|
||||
<td class="price-cell"><span class="price-val" id="price-NOVO-B.CO">251.00</span></td><td>305.00</td><td>4,442,064,180</td><td>1,114,958</td>
|
||||
<td><div class="weight-cell">31.46%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:31%"></div></div></div></td>
|
||||
<td class="neutral">8%</td><td class="down">−17.70%</td><td>2.38%</td><td class="down">−5.57%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody>
|
||||
<tr><td colspan="10" class="watchlist-label">// watchlist — not invested</td></tr>
|
||||
<tr class="watchlist-row">
|
||||
<td><span class="ticker" style="color:var(--muted)">COST</span><span class="currency-badge">USD</span></td>
|
||||
<td>978.00</td><td>—</td><td>—</td><td>—</td>
|
||||
<td><div class="weight-cell">—</div></td>
|
||||
<td>—</td><td>—</td><td>—</td><td>—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ── Trades accordion ── -->
|
||||
<div class="trades-section">
|
||||
<button class="trades-toggle" aria-expanded="false" onclick="toggleTrades(this)">
|
||||
<span class="toggle-label">// <span>trade history</span> — all executed orders</span>
|
||||
<span class="toggle-icon">▾</span>
|
||||
</button>
|
||||
|
||||
<div class="trades-body" id="tradesBody">
|
||||
<div class="trades-inner">
|
||||
|
||||
<!-- Filter buttons -->
|
||||
<div class="trades-filter">
|
||||
<button class="filter-btn active" onclick="filterTrades('all', this)">All</button>
|
||||
<button class="filter-btn" onclick="filterTrades('NOVOBc', this)">NOVOBc</button>
|
||||
<button class="filter-btn" onclick="filterTrades('CSIQ', this)">CSIQ</button>
|
||||
<button class="filter-btn" onclick="filterTrades('PYPL', this)">PYPL</button>
|
||||
<button class="filter-btn" onclick="filterTrades('options', this)">Options</button>
|
||||
</div>
|
||||
|
||||
<p class="trades-count" id="tradesCount">10 trades</p>
|
||||
|
||||
<div class="trades-table-wrap">
|
||||
<table class="trades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Asset</th>
|
||||
<th>Ccy</th>
|
||||
<th>Date / Time</th>
|
||||
<th>Dir</th>
|
||||
<th>Qty</th>
|
||||
<th>Trade Price</th>
|
||||
<th>Curr. Price</th>
|
||||
<th>Proceeds</th>
|
||||
<th>Comm/Fee</th>
|
||||
<th>Basis</th>
|
||||
<th>Realized P/L</th>
|
||||
<th>MTM P/L</th>
|
||||
<th>Code</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tradesBody_rows">
|
||||
|
||||
<!-- NOVOBc -->
|
||||
<tr data-sym="NOVOBc">
|
||||
<td><span class="trade-ticker">NOVOBc</span></td>
|
||||
<td>Stocks</td><td>DKK</td>
|
||||
<td>2026-02-24 03:05</td>
|
||||
<td class="dir-buy">BUY</td>
|
||||
<td>8</td><td>241.70</td><td>243.65</td>
|
||||
<td>−1,933.60</td><td>−10.00</td><td>1,943.60</td>
|
||||
<td class="pnl-zero">0.00</td><td class="pnl-pos">+15.60</td>
|
||||
<td><span class="trade-code">O</span></td>
|
||||
</tr>
|
||||
<tr data-sym="NOVOBc">
|
||||
<td><span class="trade-ticker">NOVOBc</span></td>
|
||||
<td>Stocks</td><td>DKK</td>
|
||||
<td>2026-02-25 07:10</td>
|
||||
<td class="dir-buy">BUY</td>
|
||||
<td>3</td><td>239.50</td><td>238.40</td>
|
||||
<td>−718.50</td><td>−10.00</td><td>728.50</td>
|
||||
<td class="pnl-zero">0.00</td><td class="pnl-neg">−3.30</td>
|
||||
<td><span class="trade-code">O</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- CSIQ -->
|
||||
<tr data-sym="CSIQ">
|
||||
<td><span class="trade-ticker">CSIQ</span></td>
|
||||
<td>Stocks</td><td>USD</td>
|
||||
<td>2026-01-20 14:33</td>
|
||||
<td class="dir-buy">BUY</td>
|
||||
<td>8</td><td>20.48</td><td>20.51</td>
|
||||
<td>−163.84</td><td>−0.38</td><td>164.22</td>
|
||||
<td class="pnl-zero">0.00</td><td class="pnl-pos">+0.24</td>
|
||||
<td><span class="trade-code">O</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- PYPL -->
|
||||
<tr data-sym="PYPL">
|
||||
<td><span class="trade-ticker">PYPL</span></td>
|
||||
<td>Stocks</td><td>USD</td>
|
||||
<td>2026-01-20 14:31</td>
|
||||
<td class="dir-sell">SELL</td>
|
||||
<td>−3</td><td>55.18</td><td>55.08</td>
|
||||
<td>165.53</td><td>−0.35</td><td>−187.91</td>
|
||||
<td class="pnl-neg">−22.73</td><td class="pnl-pos">+0.29</td>
|
||||
<td><span class="trade-code">C</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- Options -->
|
||||
<tr data-sym="options">
|
||||
<td><span class="trade-ticker">RGTI 15JAN27 5.5 P</span></td>
|
||||
<td>Options</td><td>USD</td>
|
||||
<td>2026-02-24 09:34</td>
|
||||
<td class="dir-buy">BUY</td>
|
||||
<td>1</td><td>0.53</td><td>0.5271</td>
|
||||
<td>−53.00</td><td>−1.05</td><td>54.05</td>
|
||||
<td class="pnl-zero">0.00</td><td class="pnl-neg">−0.29</td>
|
||||
<td><span class="trade-code">O</span></td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="mailto:contact@samantha42.xyz">contact@samantha42.xyz</a>
|
||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Holdings price fetch ──
|
||||
// Calls the Go backend's /api/prices endpoint with a list of tickers.
|
||||
// Falls back to FetchEOD-style Yahoo Finance if the backend isn't available.
|
||||
|
||||
const HOLDINGS = [
|
||||
{ ticker: 'MAERSK-B.CO', label: 'MEARSK' },
|
||||
{ ticker: 'DFDS.CO', label: 'DFDS' },
|
||||
{ ticker: 'ORSTED.CO', label: 'ORSTED' },
|
||||
{ ticker: 'CSIQ', label: 'CSIQ' },
|
||||
{ ticker: 'NOVO-B.CO', label: 'NOVO NORDISK' },
|
||||
];
|
||||
|
||||
function setStatus(state, text) {
|
||||
const dot = document.getElementById('priceDot');
|
||||
const label = document.getElementById('priceStatusText');
|
||||
dot.className = 'price-dot ' + state;
|
||||
label.textContent = text;
|
||||
}
|
||||
|
||||
function flashPrice(id, newVal, oldVal) {
|
||||
const el = document.getElementById('price-' + id);
|
||||
if (!el) return;
|
||||
const direction = newVal > oldVal ? 'flash-up' : newVal < oldVal ? 'flash-down' : '';
|
||||
el.textContent = newVal.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
if (direction) {
|
||||
el.classList.add(direction);
|
||||
setTimeout(() => el.classList.remove(direction), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHoldingPrices() {
|
||||
setStatus('loading', 'fetching prices…');
|
||||
|
||||
// Read current displayed values before overwriting
|
||||
const prev = {};
|
||||
HOLDINGS.forEach(h => {
|
||||
const el = document.getElementById('price-' + h.ticker);
|
||||
if (el) prev[h.ticker] = parseFloat(el.textContent.replace(/\./g, '').replace(',', '.')) || 0;
|
||||
});
|
||||
|
||||
try {
|
||||
// POST to Go backend — expects JSON array: [{ticker, close}, ...]
|
||||
const res = await fetch('/api/prices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tickers: HOLDINGS.map(h => h.ticker) }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('backend returned ' + res.status);
|
||||
|
||||
const data = await res.json(); // [{ ticker, close, date, currency }, ...]
|
||||
const map = {};
|
||||
data.forEach(d => { map[d.ticker] = d.close; });
|
||||
|
||||
HOLDINGS.forEach(h => {
|
||||
const close = map[h.ticker];
|
||||
if (close != null) flashPrice(h.ticker, close, prev[h.ticker] || close);
|
||||
});
|
||||
|
||||
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
setStatus('live', 'updated ' + now);
|
||||
|
||||
} catch (err) {
|
||||
console.warn('Backend unavailable, trying Yahoo Finance directly…', err);
|
||||
await fetchDirectYahoo(prev);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct Yahoo Finance fallback (CORS proxy) — same approach as the React artifact
|
||||
async function fetchDirectYahoo(prev) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
// Roll back past weekends
|
||||
while (yesterday.getDay() === 0 || yesterday.getDay() === 6) {
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
}
|
||||
const start = Math.floor(yesterday.setHours(0,0,0,0) / 1000);
|
||||
const end = start + 86400;
|
||||
|
||||
let ok = 0, fail = 0;
|
||||
|
||||
await Promise.allSettled(HOLDINGS.map(async h => {
|
||||
try {
|
||||
const url = `https://corsproxy.io/?${encodeURIComponent(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${h.ticker}?period1=${start}&period2=${end}&interval=1d`
|
||||
)}`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
const close = json?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.[0];
|
||||
if (close) {
|
||||
flashPrice(h.ticker, close, prev[h.ticker] || close);
|
||||
ok++;
|
||||
} else {
|
||||
fail++;
|
||||
}
|
||||
} catch { fail++; }
|
||||
}));
|
||||
|
||||
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
if (fail === 0) {
|
||||
setStatus('live', 'updated ' + now + ' (direct)');
|
||||
} else if (ok > 0) {
|
||||
setStatus('live', `${ok} ok, ${fail} failed — ${now}`);
|
||||
} else {
|
||||
setStatus('error', 'price fetch failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fetch on page load
|
||||
document.addEventListener('DOMContentLoaded', fetchHoldingPrices);
|
||||
|
||||
// ── Trades accordion ──
|
||||
function toggleTrades(btn) {
|
||||
const body = document.getElementById('tradesBody');
|
||||
const expanded = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', String(!expanded));
|
||||
body.classList.toggle('open', !expanded);
|
||||
}
|
||||
|
||||
function filterTrades(sym, btn) {
|
||||
// Update active button
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const rows = document.querySelectorAll('#tradesBody_rows tr');
|
||||
let visible = 0;
|
||||
rows.forEach(row => {
|
||||
const match = sym === 'all' || row.dataset.sym === sym;
|
||||
row.classList.toggle('hidden-row', !match);
|
||||
if (match) visible++;
|
||||
});
|
||||
|
||||
document.getElementById('tradesCount').textContent =
|
||||
visible + ' trade' + (visible !== 1 ? 's' : '') + (sym !== 'all' ? ' — ' + sym : '');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,451 @@
|
||||
/* ─── Variables ─── */
|
||||
:root {
|
||||
--bg: #252525;
|
||||
--surface: #2b2b2b;
|
||||
--surface2: #313131;
|
||||
--border: #3a3a3a;
|
||||
--border-focus: #4e4e4e;
|
||||
--text: #e8e8e0;
|
||||
--muted: #777770;
|
||||
--accent: #B88FE0;
|
||||
--accent2: #6247AA;
|
||||
--green: #6fcf8a;
|
||||
--red: #e07070;
|
||||
--yellow: #e0c870;
|
||||
}
|
||||
|
||||
/* ─── Reset ─── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
/* ─── Base ─── */
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'DM Mono', monospace;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Grain overlay */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* ─── Layout ─── */
|
||||
.container { max-width: 860px; margin: 0 auto; padding: 0 2rem; }
|
||||
.container--wide { max-width: 1100px; margin: 0 auto; padding: 0 2rem; }
|
||||
.container--narrow { max-width: 720px; margin: 0 auto; padding: 0 2rem; }
|
||||
|
||||
/* ─── Header ─── */
|
||||
header {
|
||||
padding: 4rem 0 3rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
animation: fadeUp 0.6s ease both;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.back:hover { color: var(--accent); }
|
||||
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: clamp(2.2rem, 4vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h1 span { color: var(--accent); }
|
||||
|
||||
h1--hero { font-size: clamp(2.8rem, 6vw, 5rem); line-height: 1; }
|
||||
|
||||
.header-sub {
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.byline {
|
||||
margin-top: 1.2rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.byline span { color: var(--accent); }
|
||||
|
||||
/* ─── Typography ─── */
|
||||
h2 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin: 2.8rem 0 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
h2::before {
|
||||
content: '// ';
|
||||
color: var(--accent2);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.9;
|
||||
color: #c8c8c0;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
background: rgba(184, 143, 224, 0.08);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid rgba(184, 143, 224, 0.15);
|
||||
}
|
||||
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 2.5rem 0; }
|
||||
|
||||
.section-label {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 1rem;
|
||||
line-height: 1.9;
|
||||
color: var(--text);
|
||||
border-left: 2px solid var(--accent2);
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 2.5rem 0;
|
||||
padding: 1.6rem 2rem;
|
||||
background: var(--surface);
|
||||
border-left: 3px solid var(--accent);
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
blockquote span { color: var(--accent); }
|
||||
|
||||
/* ─── Color utilities ─── */
|
||||
.up { color: var(--green); }
|
||||
.down { color: var(--red); }
|
||||
.neutral { color: var(--muted); }
|
||||
|
||||
/* ─── Cards ─── */
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
margin: 3rem 0 2.5rem;
|
||||
animation: fadeUp 0.6s 0.1s ease both;
|
||||
}
|
||||
.card { background: var(--surface); padding: 1.4rem 1.6rem; }
|
||||
.card-label {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.card-value {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ─── Nav grid (homepage) ─── */
|
||||
.nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.nav-item {
|
||||
display: block;
|
||||
background: var(--surface);
|
||||
padding: 2rem 2rem 1.8rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 0.2s ease;
|
||||
animation: fadeUp 0.7s ease both;
|
||||
}
|
||||
.nav-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0; left: 0;
|
||||
width: 0; height: 2px;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.nav-item:hover { background: #161616; }
|
||||
.nav-item:hover::after { width: 100%; }
|
||||
.nav-item:hover .arrow { transform: translate(4px, -4px); }
|
||||
.nav-number { font-size: 0.65rem; color: var(--muted); letter-spacing: 0.15em; margin-bottom: 1.4rem; }
|
||||
.nav-title {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.arrow { font-size: 1rem; color: var(--accent); transition: transform 0.25s ease; flex-shrink: 0; }
|
||||
.nav-desc { font-size: 0.75rem; color: var(--muted); line-height: 1.6; }
|
||||
|
||||
/* ─── Table ─── */
|
||||
.table-wrap { overflow-x: auto; animation: fadeUp 0.6s 0.2s ease both; margin-bottom: 2.5rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
|
||||
thead tr { border-bottom: 1px solid var(--border); }
|
||||
thead th {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: 0.7rem 1rem;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
thead th:first-child { text-align: left; }
|
||||
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.15s; }
|
||||
tbody tr:hover { background: var(--surface2); }
|
||||
tbody td { padding: 1rem; text-align: right; white-space: nowrap; color: var(--text); }
|
||||
tbody td:first-child { text-align: left; }
|
||||
|
||||
.ticker { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 0.9rem; color: var(--text); }
|
||||
.currency-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.6rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
margin-left: 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.weight-cell { display: flex; align-items: center; justify-content: flex-end; gap: 0.6rem; }
|
||||
.weight-bar-track { width: 60px; height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||||
.weight-bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent2), var(--accent)); border-radius: 2px; }
|
||||
.watchlist-row td { color: var(--muted); }
|
||||
.watchlist-label { font-size: 0.62rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); padding: 1.2rem 1rem 0.5rem; }
|
||||
|
||||
/* ─── Skill bars ─── */
|
||||
.skills-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.skill-row { display: grid; grid-template-columns: 80px 1fr; align-items: center; gap: 1rem; font-size: 0.78rem; color: var(--muted); }
|
||||
.skill-bar-track { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||||
.skill-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent2), var(--accent));
|
||||
border-radius: 2px;
|
||||
transform-origin: left;
|
||||
animation: growBar 0.8s ease both;
|
||||
}
|
||||
|
||||
/* ─── Compare grid ─── */
|
||||
.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); border: 1px solid var(--border); margin: 2rem 0; }
|
||||
.compare-col { background: var(--surface); padding: 1.4rem 1.6rem; }
|
||||
.compare-col h3 { font-family: 'Syne', sans-serif; font-size: 0.85rem; font-weight: 700; margin-bottom: 1rem; padding-bottom: 0.6rem; border-bottom: 1px solid var(--border); }
|
||||
.compare-col.bad h3 { color: var(--red); }
|
||||
.compare-col.good h3 { color: var(--green); }
|
||||
.compare-col ul { list-style: none; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.compare-col ul li { font-size: 0.75rem; color: var(--muted); line-height: 1.5; padding-left: 1rem; position: relative; }
|
||||
.compare-col.bad ul li::before { content: '—'; position: absolute; left: 0; color: var(--red); }
|
||||
.compare-col.good ul li::before { content: '+'; position: absolute; left: 0; color: var(--green); }
|
||||
|
||||
/* ─── Experience & Education ─── */
|
||||
.exp-block { background: var(--surface); border: 1px solid var(--border); padding: 1.5rem; margin-bottom: 1px; }
|
||||
.exp-block p { font-size: 0.78rem; color: var(--muted); line-height: 1.7; margin-top: 0.4rem; }
|
||||
|
||||
.edu-item { display: grid; grid-template-columns: 80px 1fr; gap: 1.5rem; margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border); }
|
||||
.edu-item:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
.edu-year { font-size: 0.65rem; color: var(--accent); letter-spacing: 0.1em; padding-top: 0.15rem; }
|
||||
.edu-title { font-family: 'Syne', sans-serif; font-size: 0.95rem; font-weight: 700; margin-bottom: 0.25rem; }
|
||||
.edu-sub { font-size: 0.75rem; color: var(--muted); line-height: 1.6; }
|
||||
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.tag-pill { font-size: 0.7rem; padding: 0.3rem 0.75rem; border: 1px solid var(--border); color: var(--muted); letter-spacing: 0.05em; }
|
||||
|
||||
/* ─── Forms / Login ─── */
|
||||
.form-group { margin-bottom: 1.2rem; }
|
||||
label { display: block; font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); margin-bottom: 0.5rem; }
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
input[type="text"]:focus, input[type="password"]:focus {
|
||||
border-color: var(--accent2);
|
||||
box-shadow: 0 0 0 2px rgba(98, 71, 170, 0.15);
|
||||
}
|
||||
input::placeholder { color: var(--muted); }
|
||||
|
||||
.btn { width: 100%; background: var(--accent2); color: var(--text); font-family: 'DM Mono', monospace; font-size: 0.78rem; letter-spacing: 0.1em; text-transform: uppercase; padding: 0.8rem 1rem; border: none; cursor: pointer; transition: background 0.2s, opacity 0.2s; }
|
||||
.btn:hover { background: var(--accent); }
|
||||
.btn:active { opacity: 0.8; }
|
||||
|
||||
/* ─── Footer ─── */
|
||||
footer {
|
||||
padding: 2.5rem 0 3rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-align: center;
|
||||
animation: fadeUp 0.6s 0.4s ease both;
|
||||
}
|
||||
footer a { color: var(--accent); text-decoration: none; font-size: 0.85rem; transition: opacity 0.2s; }
|
||||
footer a:hover { opacity: 0.7; }
|
||||
.footer-copy { font-size: 0.68rem; color: var(--muted); letter-spacing: 0.05em; }
|
||||
|
||||
/* ─── Animations ─── */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes growBar {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-6px); }
|
||||
40% { transform: translateX(6px); }
|
||||
60% { transform: translateX(-4px); }
|
||||
80% { transform: translateX(4px); }
|
||||
}
|
||||
.shake { animation: shake 0.4s ease; }
|
||||
|
||||
/* ─── Article layout ─── */
|
||||
article { padding: 3.5rem 0; animation: fadeUp 0.6s 0.1s ease both; }
|
||||
main { padding: 3.5rem 0; }
|
||||
|
||||
/* ─── Classify grid (cyber page) ─── */
|
||||
.classify-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.classify-card { background: var(--surface); padding: 1.4rem 1.3rem; }
|
||||
.classify-card h3 { font-family: 'Syne', sans-serif; font-size: 0.9rem; font-weight: 700; margin-bottom: 0.5rem; }
|
||||
.classify-card p { font-size: 0.72rem; color: var(--muted); margin: 0; line-height: 1.6; }
|
||||
.level { font-size: 0.6rem; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 0.6rem; font-weight: 500; }
|
||||
.level-critical { color: var(--red); }
|
||||
.level-high { color: var(--yellow); }
|
||||
.level-standard { color: var(--green); }
|
||||
|
||||
/* ─── Principles (cyber page) ─── */
|
||||
.principles { display: flex; flex-direction: column; gap: 1px; margin: 2rem 0; }
|
||||
.principle { display: grid; grid-template-columns: 2.5rem 1fr; gap: 1.2rem; background: var(--surface); padding: 1.2rem 1.4rem; border: 1px solid var(--border); margin-bottom: 1px; align-items: start; }
|
||||
.principle-num { font-family: 'Syne', sans-serif; font-size: 1.1rem; font-weight: 800; color: var(--accent2); padding-top: 0.1rem; }
|
||||
.principle-title { font-family: 'Syne', sans-serif; font-size: 0.9rem; font-weight: 700; color: var(--text); margin-bottom: 0.35rem; }
|
||||
.principle p { font-size: 0.77rem; color: var(--muted); line-height: 1.65; margin: 0; }
|
||||
|
||||
/* ─── Attack table (cyber page) ─── */
|
||||
.attack-table { width: 100%; border-collapse: collapse; font-size: 0.76rem; margin: 2rem 0; }
|
||||
.attack-table thead th { font-size: 0.6rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); padding: 0.6rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
.attack-table tbody tr { border-bottom: 1px solid var(--border); transition: background 0.15s; }
|
||||
.attack-table tbody tr:hover { background: var(--surface2); }
|
||||
.attack-table tbody td { padding: 0.9rem 1rem; color: #c8c8c0; vertical-align: top; line-height: 1.55; text-align: left; }
|
||||
.attack-table td:first-child { color: var(--red); font-weight: 500; white-space: nowrap; }
|
||||
.counter { color: var(--green); font-size: 0.72rem; }
|
||||
|
||||
/* ─── Login panel ─── */
|
||||
.login-panel { border: 1px solid var(--border); background: var(--surface); padding: 2rem; }
|
||||
.login-panel h2 { font-family: 'Syne', sans-serif; font-size: 1rem; font-weight: 700; margin-bottom: 0.3rem; }
|
||||
.login-panel .desc { font-size: 0.72rem; color: var(--muted); margin-bottom: 2rem; line-height: 1.6; }
|
||||
.twofa-group { display: none; margin-bottom: 1.2rem; }
|
||||
.twofa-group.visible { display: block; }
|
||||
.twofa-hint { font-size: 0.68rem; color: var(--muted); margin-top: 0.4rem; }
|
||||
.btn-login { width: 100%; background: var(--accent2); color: var(--text); font-family: 'DM Mono', monospace; font-size: 0.78rem; letter-spacing: 0.1em; text-transform: uppercase; padding: 0.8rem 1rem; border: none; cursor: pointer; transition: background 0.2s, opacity 0.2s; margin-top: 0.4rem; }
|
||||
.btn-login:hover { background: var(--accent); }
|
||||
.btn-login:active { opacity: 0.8; }
|
||||
.login-error { font-size: 0.72rem; color: var(--red); margin-top: 1rem; display: none; }
|
||||
.login-error.visible { display: block; }
|
||||
|
||||
/* ─── Private dashboard ─── */
|
||||
#private-dashboard { display: none; }
|
||||
#private-dashboard.visible { display: block; }
|
||||
.repo-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.repo-item { background: var(--surface); border: 1px solid var(--border); padding: 1.1rem 1.3rem; display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; transition: background 0.15s; cursor: pointer; text-decoration: none; }
|
||||
.repo-item:hover { background: var(--surface2); }
|
||||
.repo-name { font-family: 'Syne', sans-serif; font-size: 0.88rem; font-weight: 700; color: var(--text); margin-bottom: 0.2rem; }
|
||||
.repo-desc { font-size: 0.7rem; color: var(--muted); }
|
||||
.repo-badge { font-size: 0.6rem; padding: 0.2rem 0.5rem; border: 1px solid var(--border); color: var(--muted); white-space: nowrap; flex-shrink: 0; }
|
||||
.repo-badge.private { color: var(--red); border-color: rgba(224, 112, 112, 0.3); }
|
||||
.repo-badge.active { color: var(--green); border-color: rgba(111, 207, 138, 0.3); }
|
||||
.logout-btn { margin-top: 1.2rem; font-size: 0.65rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); background: none; border: none; cursor: pointer; font-family: 'DM Mono', monospace; padding: 0; transition: color 0.2s; }
|
||||
.logout-btn:hover { color: var(--red); }
|
||||
|
||||
/* ─── Empty state ─── */
|
||||
.empty-state { border: 1px dashed var(--border); padding: 3rem 2rem; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 0.8rem; min-height: 280px; }
|
||||
.empty-icon { font-size: 1.8rem; opacity: 0.2; }
|
||||
.empty-title { font-family: 'Syne', sans-serif; font-size: 0.9rem; font-weight: 700; color: var(--muted); }
|
||||
.empty-sub { font-size: 0.7rem; color: var(--muted); opacity: 0.6; max-width: 200px; line-height: 1.6; }
|
||||
|
||||
/* ─── Two column main ─── */
|
||||
main.two-col { padding: 3.5rem 0; display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; animation: fadeUp 0.6s 0.1s ease both; }
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 700px) { .cards { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 640px) { main.two-col { grid-template-columns: 1fr; gap: 2.5rem; } }
|
||||
@media (max-width: 560px) {
|
||||
.nav-grid { grid-template-columns: 1fr; }
|
||||
.edu-item { grid-template-columns: 1fr; gap: 0.4rem; }
|
||||
.skill-row { grid-template-columns: 70px 1fr; }
|
||||
.compare { grid-template-columns: 1fr; }
|
||||
footer { flex-direction: column; gap: 0.5rem; }
|
||||
}
|
||||
@media (max-width: 420px) { .cards { grid-template-columns: 1fr; } }
|
||||
Reference in New Issue
Block a user