+ {/* The border is always present but transparent when idle, so lighting it
+ up on hover can't nudge the surrounding layout. */}
+
setMediaHovered(true)}
+ onMouseLeave={() => setMediaHovered(false)}
+ style={{
+ position: "relative",
+ display: "inline-block",
+ width: "100%",
+ padding: 3,
+ borderRadius: 7,
+ border: `1px solid ${mediaHovered ? "var(--border-strong)" : "transparent"}`,
+ transition: "border-color 0.15s ease",
+ }}
+ >
{inlineMedia()}
{thumbnailStrip()}
diff --git a/data/content.ts b/data/content.ts
index f634b2f..2804612 100644
--- a/data/content.ts
+++ b/data/content.ts
@@ -29,6 +29,24 @@ export type Project = {
year: string;
};
+export type Mod = {
+ id: number;
+ title: string;
+ description: string;
+ link: string;
+ linkLabel?: string;
+ version?: string;
+ tags?: string[];
+};
+
+export type ModdedGame = {
+ id: number;
+ slug: string;
+ title: string;
+ platform?: string;
+ description?: string;
+ mods: Mod[];
+};
export const PROFILE = {
name: "Hunter",
@@ -297,10 +315,15 @@ A real-world solution for event staff and volunteer management.
id: 2,
slug: "clean-space",
title: "Clean Space",
- description: "Clean Space is an architecture, proof of concept, and an in-development tool for Space Engineers and Space Engineers 2 server owners featuring client and server side .",
+ description: "Clean Space is a proof of concept and in-development tool for (Space Engineers) Torch server owners featuring client and server side plugin hashing, identification, and filtering controls.",
tags: ["C#", ".NET", "WPF", "Netcode", "Security"],
link: "https://github.com/FerrenF/CleanSpace",
year: "2025",
+ images: [
+ "img/projects/clean/cleanspace.png",
+ "img/projects/clean/ss1.png",
+ "img/projects/clean/ss2.png",
+ "img/projects/clean/ss3.png"],
},{
id: 3,
slug: "ue4ss-explorer",
@@ -321,6 +344,25 @@ A real-world solution for event staff and volunteer management.
}];
+export const MOD_GAMES: ModdedGame[] = [{
+ id: 1,
+ slug: "palworld",
+ title: "Palworld",
+ platform: "UE4SS / Dedicated server",
+ description: "Server-side tooling and quality-of-life changes for dedicated Palworld servers.",
+ mods: [{
+ id: 1,
+ title: "Got Your Number",
+ description: "Adds paldex numbers to the Palbox pal details screen, and the main menu pal status screen.",
+ link: "https://steamcommunity.com/sharedfiles/filedetails/?id=3780862038",
+ linkLabel: "Steam",
+ version: "0.2.0",
+ tags: ["Client", "Unreal Engine", "Palworld", "UE4SS", "C++"],
+ }]
+ }
+ ];
+
+
import { remark } from "remark";
import remarkParse from "remark-parse";
import rehypeRaw from "rehype-raw";
diff --git a/public/img/projects/clean/cleanspace.png b/public/img/projects/clean/cleanspace.png
new file mode 100644
index 0000000..30c465c
Binary files /dev/null and b/public/img/projects/clean/cleanspace.png differ
diff --git a/public/img/projects/clean/ss1.png b/public/img/projects/clean/ss1.png
new file mode 100644
index 0000000..ee472ff
Binary files /dev/null and b/public/img/projects/clean/ss1.png differ
diff --git a/public/img/projects/clean/ss2.png b/public/img/projects/clean/ss2.png
new file mode 100644
index 0000000..b58b391
Binary files /dev/null and b/public/img/projects/clean/ss2.png differ
diff --git a/public/img/projects/clean/ss3.png b/public/img/projects/clean/ss3.png
new file mode 100644
index 0000000..1109181
Binary files /dev/null and b/public/img/projects/clean/ss3.png differ
diff --git a/public/img/projects/clean/ss4.png b/public/img/projects/clean/ss4.png
new file mode 100644
index 0000000..3b13c7d
Binary files /dev/null and b/public/img/projects/clean/ss4.png differ
diff --git a/styles/gallery.css b/styles/gallery.css
index 491d40f..ea314cd 100644
--- a/styles/gallery.css
+++ b/styles/gallery.css
@@ -11,8 +11,9 @@
padding: 0;
}
.gallery-action-button button {
- width: 28;
- height: 28;
+ width: 28px;
+ height: 28px;
+ display: flex;
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
background-color: var(--surface);
@@ -21,11 +22,15 @@
color: var(--fg);
z-index: 1001;
cursor: pointer;
- transition: background 0.15s ease, border-color 0.15s ease;
- margin-right: 0.3em;
+ transition: background 0.15s ease, border-color 0.15s ease, filter 0.15s ease;
}
-.gallery-action-button button:hover {
- filter:brightness(120%)
+/* `.is-hovered` is set while the media itself is hovered, so the button shows
+ the same state as a direct hover on it. */
+.gallery-action-button button:hover,
+.gallery-action-button.is-hovered button {
+ filter: brightness(120%);
+ border-color: var(--accent-hover);
+ background-color: var(--surface-hover);
}
.action-button-svg {
diff --git a/styles/mods.css b/styles/mods.css
new file mode 100644
index 0000000..42b5f8b
--- /dev/null
+++ b/styles/mods.css
@@ -0,0 +1,258 @@
+
+/* ---- mods tab ---- */
+
+.mod-games {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+/* ---- game section ---- */
+
+.mod-game {
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 20px 20px 22px;
+ transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out),
+ border-color 0.2s ease;
+ min-width: 0;
+}
+
+.mod-game-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 0;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ color: inherit;
+ font: inherit;
+}
+
+.mod-game-chevron {
+ display: flex;
+ flex: 0 0 auto;
+ color: var(--muted);
+ transition: transform 0.2s var(--ease-out);
+}
+
+.mod-game-heading {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ flex: 1 1 auto;
+ min-width: 0;
+ flex-wrap: wrap;
+}
+
+.mod-game-title {
+ font-family: var(--mono);
+ font-size: var(--text-lg);
+ font-weight: 700;
+ transition: color 0.2s ease;
+}
+
+.mod-game-platform {
+ font-family: var(--mono);
+ font-size: var(--text-xs);
+ color: var(--muted);
+}
+
+.mod-game-count {
+ flex: 0 0 auto;
+ font-family: var(--mono);
+ font-size: var(--text-xs);
+ color: var(--muted);
+ padding: 2px 8px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ white-space: nowrap;
+}
+
+.mod-game-description {
+ font-family: var(--sans);
+ font-size: 13px;
+ line-height: var(--leading-normal);
+ color: var(--fg-secondary);
+ margin: 10px 0 0 20px;
+}
+
+/* ---- mod list ---- */
+
+.mod-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ margin: 16px 0 0 20px;
+}
+
+.mod-list-empty {
+ font-family: var(--mono);
+ font-size: var(--text-xs);
+ color: var(--muted);
+ margin: 16px 0 0 20px;
+}
+
+/* ---- mod card ---- */
+
+.mod-card {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ padding: 16px;
+ border-radius: var(--radius-md);
+ text-decoration: none;
+ cursor: pointer;
+ transition: all 0.3s var(--ease-out);
+}
+
+.mod-card-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.mod-card-title {
+ font-family: var(--mono);
+ font-size: 15px;
+ font-weight: 700;
+ transition: color 0.2s ease;
+ min-width: 0;
+}
+
+.mod-card-version {
+ flex: 0 0 auto;
+ font-family: var(--mono);
+ font-size: var(--text-xs);
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.mod-card-description {
+ font-family: var(--sans);
+ font-size: 13px;
+ line-height: var(--leading-normal);
+ color: var(--fg-secondary);
+ margin: 0 0 14px 0;
+
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.mod-card-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ flex-wrap: wrap;
+ /* Keeps the link pinned to the bottom when neighbouring cards are taller. */
+ margin-top: auto;
+}
+
+.mod-card-tags {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ min-width: 0;
+}
+
+.mod-tag {
+ font-family: var(--mono);
+ font-size: var(--text-xs);
+ font-weight: 500;
+ color: var(--accent);
+ background: var(--accent-bg);
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ letter-spacing: 0.02em;
+}
+
+.mod-card-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex: 0 0 auto;
+ font-family: var(--mono);
+ font-size: var(--text-xs);
+ padding: 5px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg);
+ white-space: nowrap;
+ transition: all 0.2s ease;
+}
+
+/* ---- narrow layout: single column, less per card ---- */
+
+@media (max-width: 768px) {
+ .mod-games {
+ gap: 14px;
+ }
+
+ .mod-game {
+ padding: 16px 14px 18px;
+ border-radius: var(--radius-md);
+ }
+
+ .mod-game-title {
+ font-size: var(--text-base);
+ }
+
+ /* The platform line and the game blurb are context, not content -- the
+ narrow layout drops them and keeps the mod list itself. */
+ .mod-game-platform,
+ .mod-game-description {
+ display: none;
+ }
+
+ .mod-list,
+ .mod-list-empty {
+ grid-template-columns: minmax(0, 1fr);
+ margin-left: 0;
+ margin-top: 12px;
+ }
+
+ .mod-card {
+ padding: 12px 14px;
+ }
+
+ .mod-card-head {
+ margin-bottom: 6px;
+ }
+
+ .mod-card-description {
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ margin-bottom: 10px;
+ }
+
+ /* Tags are the first thing to go: the title, blurb and link carry the card. */
+ .mod-card-tags {
+ display: none;
+ }
+
+ .mod-card-link {
+ width: 100%;
+ justify-content: center;
+ padding: 8px 10px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .mod-game,
+ .mod-card,
+ .mod-game-chevron {
+ transition: none;
+ }
+}