chore: prepare homepage repository for publishing

This commit is contained in:
Fi3w0 2026-08-11 17:25:11 +02:00
parent f8ac123283
commit 25ae07d364
15 changed files with 114 additions and 531 deletions

View file

@ -13,4 +13,3 @@ npm-debug.log*
.agents .agents
.codex .codex
*.md *.md
DEPLOY.md

5
.gitignore vendored
View file

@ -1,7 +1,10 @@
node_modules node_modules
dist dist
.cache .cache
.npm
.idea/ .idea/
*.iml *.iml
*.log
.DS_Store .DS_Store
.env .env*
!.env.example

View file

@ -1,52 +0,0 @@
# Server deployment
The production image builds the Webpack site with Node and serves the generated `dist/` directory with an unprivileged Nginx process on port `8080`. The container does not publish a host port; Traefik reaches it over an external Docker network.
The runtime is read-only, drops every Linux capability, cannot gain new privileges, uses a bounded in-memory `/tmp`, and has CPU, memory, process, and log limits. Browser security headers are set by Nginx, and the site makes no third-party font, icon, or script requests.
## Defaults
| Setting | Default |
| --- | --- |
| Domain | `fiwlabs.dev` |
| Traefik network | `proxy` |
| HTTPS entrypoint | `websecure` |
| Certificate resolver | `porkbun` |
Compose reads overrides from `.env`. The available keys and defaults are documented in `.env.example`.
## Prerequisites
1. Point the `A` and optional `AAAA` records for `fiwlabs.dev` to the server.
2. Ensure Traefik has its Docker provider enabled.
3. Attach Traefik to the same external network configured by `TRAEFIK_NETWORK`.
4. Ensure the configured certificate resolver exists in Traefik.
Create the default shared network once if the Traefik stack has not already created it:
```sh
docker network create proxy
```
## Deploy
Validate the resolved Compose configuration:
```sh
docker compose -f docker-compose.yml config
```
Build and start the site:
```sh
docker compose -f docker-compose.yml up --build -d
```
Check container health and logs:
```sh
docker compose -f docker-compose.yml ps
docker compose -f docker-compose.yml logs -f fiws-page
```
The container exposes `/healthz` internally for Docker health checks. Traefik serves the site at `https://fiwlabs.dev`; the server's `web` entrypoint redirects HTTP requests to HTTPS globally.

View file

@ -1,4 +1,7 @@
Copyright (c) HTML5 Boilerplate MIT License
Copyright (c) 2026 Alex (Fi3w0)
Portions copyright (c) HTML5 Boilerplate
Permission is hereby granted, free of charge, to any person obtaining a copy of Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in this software and associated documentation files (the "Software"), to deal in

93
README.md Normal file
View file

@ -0,0 +1,93 @@
# FIW Labs Homepage
The source for [fiwlabs.dev](https://fiwlabs.dev), the personal portfolio of Alex (`Fi3w0`). It presents systems and desktop projects including TideWM, Scorium, Flux, Moonlit Shell, and the FIW Minecraft tooling collection.
The site is intentionally framework-light: semantic HTML, hand-written CSS, and vanilla JavaScript are compiled into a static production bundle. There is no analytics, telemetry, backend, database, or runtime dependency on third-party fonts, icons, or scripts.
## Highlights
- Responsive editorial interface with accessible navigation and reduced-motion support.
- Interactive liquid ASCII artwork with graceful text fallbacks.
- Self-contained assets and strict browser security headers.
- Reproducible Webpack production build.
- Unprivileged, read-only Nginx container designed to sit behind Traefik.
## Technology
| Area | Choice |
| --- | --- |
| Interface | HTML, CSS, vanilla JavaScript |
| Build | Node.js 24, Webpack 5 |
| Runtime | Unprivileged Nginx on port `8080` |
| Deployment | Docker Compose and Traefik |
| Privacy | No analytics or third-party runtime requests |
## Local development
Requirements: Node.js 24 and npm.
```sh
npm ci
npm start
```
Create the production bundle with:
```sh
npm run build
```
Webpack writes the static site to `dist/`.
## Production deployment
The Compose stack expects an existing external Docker network shared with Traefik. Copy the example environment file and adjust it for the target host:
```sh
cp .env.example .env
docker network create proxy
docker compose config
docker compose up --build -d
```
The network only needs to be created once. If Traefik already owns it, skip that command.
| Variable | Default | Purpose |
| --- | --- | --- |
| `DOMAIN` | `fiwlabs.dev` | Traefik host rule |
| `TRAEFIK_NETWORK` | `proxy` | Shared external network |
| `TRAEFIK_HTTPS_ENTRYPOINT` | `websecure` | HTTPS entrypoint |
| `TRAEFIK_CERT_RESOLVER` | `porkbun` | Configured certificate resolver |
| `IMAGE_NAME` | `fiws-page` | Local/container registry image name |
| `IMAGE_TAG` | `latest` | Image tag to deploy |
Check the deployment with:
```sh
docker compose ps
docker compose logs --tail=100 fiws-page
```
Docker monitors `/healthz` inside the container. The service is exposed only to the shared Docker network; it does not publish a host port.
## Security posture
The production container runs as an unprivileged user with a read-only filesystem, all Linux capabilities dropped, privilege escalation disabled, and bounded CPU, memory, process, temporary-storage, and log usage. Nginx serves only static files and applies CSP, HSTS, referrer, permissions, framing, and content-type protections.
Keep secrets out of the frontend and out of Docker build arguments. Everything delivered to a browser is public by definition.
## Repository layout
```text
css/ Site styles
deploy/nginx.conf Static server and security headers
img/ Optimized project artwork
js/app.js Interaction and visual effects
Dockerfile Multi-stage production image
docker-compose.yml Traefik-connected runtime
webpack.*.js Development and production builds
```
## License
The website source is available under the [MIT License](LICENSE.txt). Product names, screenshots, logos, and third-party marks retain their respective rights.

View file

@ -732,101 +732,6 @@ code {
transform: translateX(1.2rem); transform: translateX(1.2rem);
} }
.current-project.has-tide-ascii .current-watermark {
opacity: 0;
}
/* ASCIIText, ported from React Bits to vanilla JS/three.js. Lazily mounted
(see app.js) to replace the flat "TIDE" watermark with a rippling,
wave-distorted ASCII render a nod to the compositor's water theme.
Falls back to the plain watermark above if WebGL/three.js is unavailable. */
.tide-ascii {
position: absolute;
z-index: 1;
bottom: -9%;
left: -6%;
width: min(82rem, 126%);
height: 55%;
opacity: 0;
pointer-events: none;
transition: opacity 900ms ease;
}
.tide-ascii.is-ready {
opacity: 0.6;
}
.tide-ascii-inner {
position: absolute;
inset: 0;
}
.tide-ascii-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.7;
filter: saturate(1.4);
image-rendering: pixelated;
}
.tide-ascii-pre {
position: absolute;
top: 0;
left: 0;
z-index: 2;
margin: 0;
padding: 0;
overflow: hidden;
color: rgba(17, 23, 26, 0.86);
line-height: 1em;
mix-blend-mode: multiply;
white-space: pre;
user-select: none;
}
@media (max-width: 68rem) {
.tide-ascii {
display: none;
}
.current-project.has-tide-ascii .current-watermark {
opacity: 1;
}
}
.project-visual-flux.has-flux-ascii .flux-watermark {
opacity: 0;
}
.flux-ascii {
position: absolute;
z-index: 1;
bottom: 34%;
left: -14%;
width: min(58rem, 118%);
height: 88%;
opacity: 0;
pointer-events: none;
transition: opacity 900ms ease;
}
.flux-ascii.is-ready {
opacity: 0.85;
}
@media (max-width: 64rem) {
.flux-ascii {
display: none;
}
.project-visual-flux.has-flux-ascii .flux-watermark {
opacity: 1;
}
}
.project-feature { .project-feature {
min-height: 38rem; min-height: 38rem;
margin-bottom: 4rem; margin-bottom: 4rem;

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 32 KiB

368
js/app.js
View file

@ -241,374 +241,6 @@ if (gooeyNav && gooeyLinks.length && !reducedMotion) {
}); });
} }
// --- ASCIIText, ported from React Bits (vanilla JS, three.js bundled and
// loaded on demand). Replaces the flat "TIDE" watermark on the TideWM card with a
// rippling, wave-distorted ASCII render once the card scrolls into view.
// The plain-text watermark stays in the DOM as the fallback if WebGL or the
// three.js module load ever fails. ---
(() => {
const ASCII_FONT_STACK = '"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace';
const targets = [
{
host: document.querySelector("#tide-ascii"),
ancestorSelector: ".current-project",
readyClass: "has-tide-ascii",
text: "TIDE",
enableWaves: true,
asciiFontSize: 11,
textFontSize: 220,
},
{
// Flux is a media-editing tool, not a water compositor — keep its
// watermark shape still and crisp (no mesh ripple), but let the
// glyphs themselves flicker/decode for a "processing feed" feel.
host: document.querySelector("#flux-ascii"),
ancestorSelector: ".project-visual-flux",
readyClass: "has-flux-ascii",
text: "FLUX",
enableWaves: false,
charJitter: true,
asciiFontSize: 8,
textFontSize: 240,
},
]
.map((t) => ({ ...t, ancestor: t.host?.closest(t.ancestorSelector) }))
.filter((t) => t.host && t.ancestor);
if (!targets.length || reducedMotion) return;
let hasWebGL = false;
try {
const testCanvas = document.createElement("canvas");
hasWebGL = Boolean(testCanvas.getContext("webgl") || testCanvas.getContext("experimental-webgl"));
} catch {
hasWebGL = false;
}
if (!hasWebGL) return;
let threeLoadPromise = null;
const loadThree = () => {
if (window.THREE) return Promise.resolve();
if (!threeLoadPromise) {
threeLoadPromise = import("three").then((three) => {
window.THREE = three;
});
}
return threeLoadPromise;
};
const mapRange = (n, start, stop, start2, stop2) => ((n - start) / (stop - start)) * (stop2 - start2) + start2;
const vertexShader = `
varying vec2 vUv;
uniform float uTime;
uniform float uEnableWaves;
void main() {
vUv = uv;
float time = uTime * 5.0;
float waveFactor = uEnableWaves;
vec3 transformed = position;
transformed.x += sin(time + position.y) * 0.5 * waveFactor;
transformed.y += cos(time + position.z) * 0.15 * waveFactor;
transformed.z += sin(time + position.x) * waveFactor;
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
}
`;
const fragmentShader = `
varying vec2 vUv;
uniform float uTime;
uniform float uEnableWaves;
uniform sampler2D uTexture;
void main() {
float time = uTime;
vec2 pos = vUv;
float amount = mix(0.006, 0.01, uEnableWaves);
float r = texture2D(uTexture, pos + cos(time * 2.0 - time + pos.x) * amount).r;
float g = texture2D(uTexture, pos + tan(time * 0.5 + pos.x - time) * amount).g;
float b = texture2D(uTexture, pos - cos(time * 2.0 + time + pos.y) * amount).b;
float a = texture2D(uTexture, pos).a;
gl_FragColor = vec4(r, g, b, a);
}
`;
class AsciiFilter {
constructor(renderer, { fontSize, fontFamily, charset, charJitter } = {}) {
this.renderer = renderer;
this.domElement = document.createElement("div");
this.domElement.className = "tide-ascii-inner";
this.pre = document.createElement("pre");
this.pre.className = "tide-ascii-pre";
this.domElement.append(this.pre);
this.canvas = document.createElement("canvas");
this.canvas.className = "tide-ascii-canvas";
this.context = this.canvas.getContext("2d");
this.domElement.append(this.canvas);
this.fontSize = fontSize ?? 8;
this.fontFamily = fontFamily ?? ASCII_FONT_STACK;
this.charset = charset ?? " .'`^\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
// Optional per-cell character flicker — a subtle "decoding" glitch
// where a handful of glyphs jump to a denser/lighter char and settle
// back, independent of any mesh/wave motion.
this.charJitter = charJitter ?? false;
this.context.imageSmoothingEnabled = false;
}
setSize(width, height) {
if (!width || !height) return;
this.width = width;
this.height = height;
this.renderer.setSize(width, height);
this.reset();
}
reset() {
this.context.font = `${this.fontSize}px ${this.fontFamily}`;
const charWidth = this.context.measureText("A").width;
this.cols = Math.max(1, Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize))));
this.rows = Math.max(1, Math.floor(this.height / this.fontSize));
this.canvas.width = this.cols;
this.canvas.height = this.rows;
this.pre.style.fontFamily = this.fontFamily;
this.pre.style.fontSize = `${this.fontSize}px`;
}
render(scene, camera, time) {
this.renderer.render(scene, camera);
const w = this.canvas.width;
const h = this.canvas.height;
this.context.clearRect(0, 0, w, h);
if (this.context && w && h) this.context.drawImage(this.renderer.domElement, 0, 0, w, h);
this.asciify(this.context, w, h, time ?? 0);
}
asciify(ctx, w, h, time = 0) {
if (!w || !h) return;
const imgData = ctx.getImageData(0, 0, w, h).data;
const jitter = this.charJitter;
const frameBucket = Math.floor(time * 7);
const last = this.charset.length - 1;
let str = "";
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const i = x * 4 + y * 4 * w;
const [r, g, b, a] = [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]];
if (a === 0) {
str += " ";
continue;
}
const gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;
let idx = this.charset.length - Math.floor((1 - gray) * last) - 1;
if (jitter) {
const seed = Math.sin(x * 12.9898 + y * 78.233 + frameBucket * 37.719) * 43758.5453;
const rnd = seed - Math.floor(seed);
if (rnd > 0.85) {
const swing = Math.floor(((rnd - 0.85) / 0.15) * 8) - 4;
idx = Math.min(last, Math.max(0, idx + swing));
}
}
str += this.charset[idx];
}
str += "\n";
}
this.pre.textContent = str;
}
dispose() {}
}
class CanvasTxt {
constructor(txt, { fontSize = 200, fontFamily = "Arial", color = "#fdf9f3" } = {}) {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
this.txt = txt;
this.font = `600 ${fontSize}px ${fontFamily}`;
this.color = color;
}
resize() {
this.context.font = this.font;
const metrics = this.context.measureText(this.txt);
this.canvas.width = Math.ceil(metrics.width) + 20;
this.canvas.height = Math.ceil((metrics.actualBoundingBoxAscent || 0) + (metrics.actualBoundingBoxDescent || 0)) + 20;
}
render() {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.fillStyle = this.color;
this.context.font = this.font;
const metrics = this.context.measureText(this.txt);
this.context.fillText(this.txt, 10, 10 + metrics.actualBoundingBoxAscent);
}
get width() {
return this.canvas.width;
}
get height() {
return this.canvas.height;
}
get texture() {
return this.canvas;
}
}
class CanvAscii {
constructor({ text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves, charJitter }, container, width, height) {
this.textString = text;
this.asciiFontSize = asciiFontSize;
this.textFontSize = textFontSize;
this.textColor = textColor;
this.planeBaseHeight = planeBaseHeight;
this.container = container;
this.width = width;
this.height = height;
this.enableWaves = enableWaves;
this.charJitter = charJitter;
this.camera = new window.THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000);
this.camera.position.z = 30;
this.scene = new window.THREE.Scene();
}
init() {
this.setMesh();
this.setRenderer();
}
setMesh() {
const THREE = window.THREE;
this.textCanvas = new CanvasTxt(this.textString, {
fontSize: this.textFontSize,
fontFamily: ASCII_FONT_STACK,
color: this.textColor,
});
this.textCanvas.resize();
this.textCanvas.render();
this.texture = new THREE.CanvasTexture(this.textCanvas.texture);
this.texture.minFilter = THREE.NearestFilter;
const textAspect = this.textCanvas.width / this.textCanvas.height;
const planeW = this.planeBaseHeight * textAspect;
this.geometry = new THREE.PlaneGeometry(planeW, this.planeBaseHeight, 36, 36);
this.material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
uniforms: {
uTime: { value: 0 },
uTexture: { value: this.texture },
uEnableWaves: { value: this.enableWaves ? 1 : 0 },
},
});
this.mesh = new THREE.Mesh(this.geometry, this.material);
this.scene.add(this.mesh);
}
setRenderer() {
const THREE = window.THREE;
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
this.renderer.setPixelRatio(1);
this.renderer.setClearColor(0x000000, 0);
this.filter = new AsciiFilter(this.renderer, { fontSize: this.asciiFontSize, charJitter: this.charJitter });
this.container.append(this.filter.domElement);
this.setSize(this.width, this.height);
}
setSize(w, h) {
if (!w || !h) return;
this.width = w;
this.height = h;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.filter.setSize(w, h);
}
animate() {
const frame = () => {
this.animationFrameId = requestAnimationFrame(frame);
this.renderFrame();
};
frame();
}
renderFrame() {
const time = Date.now() * 0.001;
this.mesh.material.uniforms.uTime.value = Math.sin(time);
this.filter.render(this.scene, this.camera, time);
}
dispose() {
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
this.filter?.dispose();
this.filter?.domElement?.remove();
this.renderer?.dispose();
}
}
const mount = async ({ host, ancestor, readyClass, text, enableWaves, charJitter, asciiFontSize, textFontSize }) => {
try {
await loadThree();
} catch {
return;
}
if (!window.THREE) return;
const rect = host.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return;
let instance;
try {
instance = new CanvAscii(
{
text,
asciiFontSize: asciiFontSize ?? 11,
textFontSize: textFontSize ?? 220,
textColor: "#eaf9f7",
planeBaseHeight: 8,
enableWaves: enableWaves ?? true,
charJitter: charJitter ?? false,
},
host,
rect.width,
rect.height,
);
await instance.init();
instance.animate();
} catch {
instance?.dispose();
return;
}
host.classList.add("is-ready");
ancestor.classList.add(readyClass);
if ("ResizeObserver" in window) {
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
if (width > 20 && height > 20) instance.setSize(width, height);
});
resizeObserver.observe(host);
}
};
targets.forEach((target) => {
const observer = new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
obs.disconnect();
mount(target);
});
},
{ rootMargin: "200px" },
);
observer.observe(target.host);
});
})();
// --- Liquid ASCII: a real FLIP/PIC fluid simulation (grid + particles, // --- Liquid ASCII: a real FLIP/PIC fluid simulation (grid + particles,
// incompressible pressure solve, particle-particle separation) rendered as // incompressible pressure solve, particle-particle separation) rendered as
// ASCII. No React, no registry, no CDN. Ported by hand from // ASCII. No React, no registry, no CDN. Ported by hand from

16
package-lock.json generated
View file

@ -1,21 +1,24 @@
{ {
"name": "fi3w0-portfolio", "name": "fiwlabs-homepage",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fi3w0-portfolio", "name": "fiwlabs-homepage",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"copy-webpack-plugin": "^14.0.0", "copy-webpack-plugin": "^14.0.0",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
"three": "0.128.0",
"webpack": "^5.91.0", "webpack": "^5.91.0",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "^6.0.0", "webpack-dev-server": "^6.0.0",
"webpack-merge": "^5.10.0" "webpack-merge": "^5.10.0"
},
"engines": {
"node": ">=24",
"npm": ">=11"
} }
}, },
"node_modules/@discoveryjs/json-ext": { "node_modules/@discoveryjs/json-ext": {
@ -4229,13 +4232,6 @@
"tslib": "^2" "tslib": "^2"
} }
}, },
"node_modules/three": {
"version": "0.128.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.128.0.tgz",
"integrity": "sha512-i0ap/E+OaSfzw7bD1TtYnPo3VEplkl70WX5fZqZnfZsE3k3aSFudqrrC9ldFZfYFkn1zwDmBcdGfiIm/hnbyZA==",
"dev": true,
"license": "MIT"
},
"node_modules/thunky": { "node_modules/thunky": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",

View file

@ -1,5 +1,5 @@
{ {
"name": "fi3w0-portfolio", "name": "fiwlabs-homepage",
"version": "1.0.0", "version": "1.0.0",
"description": "Personal systems and development portfolio for Alex (Fi3w0).", "description": "Personal systems and development portfolio for Alex (Fi3w0).",
"private": true, "private": true,
@ -10,7 +10,12 @@
"homelab" "homelab"
], ],
"license": "MIT", "license": "MIT",
"author": "Alex (Fi3w0)", "author": "Alex (Fi3w0) <alex@fiwlabs.dev>",
"homepage": "https://fiwlabs.dev",
"engines": {
"node": ">=24",
"npm": ">=11"
},
"scripts": { "scripts": {
"start": "webpack serve --open --config webpack.config.dev.js", "start": "webpack serve --open --config webpack.config.dev.js",
"build": "webpack --config webpack.config.prod.js" "build": "webpack --config webpack.config.prod.js"
@ -18,7 +23,6 @@
"devDependencies": { "devDependencies": {
"copy-webpack-plugin": "^14.0.0", "copy-webpack-plugin": "^14.0.0",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
"three": "0.128.0",
"webpack": "^5.91.0", "webpack": "^5.91.0",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "^6.0.0", "webpack-dev-server": "^6.0.0",