diff --git a/.dockerignore b/.dockerignore index 9be43e9..33fb255 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,4 +13,3 @@ npm-debug.log* .agents .codex *.md -DEPLOY.md diff --git a/.gitignore b/.gitignore index 888249e..800e14a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ node_modules dist .cache +.npm .idea/ *.iml +*.log .DS_Store -.env +.env* +!.env.example diff --git a/DEPLOY.md b/DEPLOY.md deleted file mode 100644 index 088ced7..0000000 --- a/DEPLOY.md +++ /dev/null @@ -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. diff --git a/LICENSE.txt b/LICENSE.txt index 294e91d..fd0d2e6 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -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 this software and associated documentation files (the "Software"), to deal in diff --git a/README.md b/README.md new file mode 100644 index 0000000..400d8f8 --- /dev/null +++ b/README.md @@ -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. diff --git a/css/editorial.css b/css/editorial.css index f468a0a..3f6578a 100644 --- a/css/editorial.css +++ b/css/editorial.css @@ -732,101 +732,6 @@ code { 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 { min-height: 38rem; margin-bottom: 4rem; diff --git a/icon.png b/icon.png index 826d893..d4d16cc 100644 Binary files a/icon.png and b/icon.png differ diff --git a/img/avatar.png b/img/avatar.png index b9c2c87..9f3be0c 100644 Binary files a/img/avatar.png and b/img/avatar.png differ diff --git a/img/flux-icon.png b/img/flux-icon.png index bb3bb92..bdf8c36 100644 Binary files a/img/flux-icon.png and b/img/flux-icon.png differ diff --git a/img/flux-overview.jpg b/img/flux-overview.jpg index f354a78..d8ef0e4 100644 Binary files a/img/flux-overview.jpg and b/img/flux-overview.jpg differ diff --git a/img/moonlit-shell.jpg b/img/moonlit-shell.jpg index f9323dd..5ef891c 100644 Binary files a/img/moonlit-shell.jpg and b/img/moonlit-shell.jpg differ diff --git a/img/tidewm-logo.png b/img/tidewm-logo.png index 0297dd6..b4e42f5 100644 Binary files a/img/tidewm-logo.png and b/img/tidewm-logo.png differ diff --git a/js/app.js b/js/app.js index ba4a4f8..4238f8c 100644 --- a/js/app.js +++ b/js/app.js @@ -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, // incompressible pressure solve, particle-particle separation) rendered as // ASCII. No React, no registry, no CDN. Ported by hand from diff --git a/package-lock.json b/package-lock.json index 244c426..b581c18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,24 @@ { - "name": "fi3w0-portfolio", + "name": "fiwlabs-homepage", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "fi3w0-portfolio", + "name": "fiwlabs-homepage", "version": "1.0.0", "license": "MIT", "devDependencies": { "copy-webpack-plugin": "^14.0.0", "html-webpack-plugin": "^5.6.0", - "three": "0.128.0", "webpack": "^5.91.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^6.0.0", "webpack-merge": "^5.10.0" + }, + "engines": { + "node": ">=24", + "npm": ">=11" } }, "node_modules/@discoveryjs/json-ext": { @@ -4229,13 +4232,6 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", diff --git a/package.json b/package.json index 5221c39..bb842f6 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "fi3w0-portfolio", + "name": "fiwlabs-homepage", "version": "1.0.0", "description": "Personal systems and development portfolio for Alex (Fi3w0).", "private": true, @@ -10,7 +10,12 @@ "homelab" ], "license": "MIT", - "author": "Alex (Fi3w0)", + "author": "Alex (Fi3w0) ", + "homepage": "https://fiwlabs.dev", + "engines": { + "node": ">=24", + "npm": ">=11" + }, "scripts": { "start": "webpack serve --open --config webpack.config.dev.js", "build": "webpack --config webpack.config.prod.js" @@ -18,7 +23,6 @@ "devDependencies": { "copy-webpack-plugin": "^14.0.0", "html-webpack-plugin": "^5.6.0", - "three": "0.128.0", "webpack": "^5.91.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^6.0.0",