diff --git a/.dockerignore b/.dockerignore index 3da3763..72c4c07 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ .hugo_build.lock .DS_Store public +dist resources/_gen diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 8b49adb..e0feab3 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -29,3 +29,17 @@ jobs: --restart unless-stopped \ -p 8083:80 \ akkolli-website:latest + + - name: Purge Cloudflare cache + env: + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + if [ -n "$CLOUDFLARE_ZONE_ID" ] && [ -n "$CLOUDFLARE_API_TOKEN" ]; then + curl --fail --silent --show-error \ + --request POST \ + --url "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + --header "Content-Type: application/json" \ + --data '{"purge_everything":true}' + fi diff --git a/.gitignore b/.gitignore index 1b4bbc5..6c3c88e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .hugo_build.lock resources/_gen/ public/ +dist/ .DS_Store diff --git a/Dockerfile b/Dockerfile index 9cf2a81..c9edfe4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,4 +7,5 @@ RUN hugo --minify --cleanDestinationDir FROM nginx:1.27-alpine COPY --from=build /src/public /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 diff --git a/static/css/main.css b/assets/css/main.css similarity index 80% rename from static/css/main.css rename to assets/css/main.css index 132eb80..b82497d 100644 --- a/static/css/main.css +++ b/assets/css/main.css @@ -3,7 +3,7 @@ src: url("/fonts/VT323-Regular.woff") format("woff"), url("/fonts/VT323-Regular.ttf") format("truetype"); - font-display: block; + font-display: swap; font-style: normal; font-weight: 400; unicode-range: U+0020-007E, U+00A0, U+2013-2014, U+2018-2019, U+201C-201D, U+2026; @@ -29,7 +29,7 @@ --margin-gap: 1.5rem; --panel-padding: 0.85rem; --section-gap: 1.1rem; - --bottom-scroll-room: 38vh; + --bottom-scroll-room: 15vh; --row-padding: 0.2rem; --rule: 2px; --control-size: 0.66rem; @@ -42,6 +42,7 @@ --vignette-alpha: 0.07; --body-font: Monaco, Menlo, "SF Mono", ui-monospace, Consolas, "Courier New", monospace; + --reading-font: var(--body-font); --display-font: "VT323 Local", "Print Char 21", "Web437 Apple II", "Apple II Screen Typeface", var(--body-font); --signature-font: "Snell Roundhand", "Apple Chancery", "Brush Script MT", "Segoe Script", cursive; } @@ -78,9 +79,9 @@ html { font-size: 18px; line-height: 1.45; scroll-behavior: smooth; - -webkit-font-smoothing: none; - font-smooth: never; - image-rendering: pixelated; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + image-rendering: auto; } body { @@ -99,7 +100,7 @@ body::before { content: ""; position: fixed; inset: 0; - z-index: 9999; + z-index: 0; pointer-events: none; background: repeating-linear-gradient( @@ -112,7 +113,7 @@ body::before { ), radial-gradient(circle at center, transparent 62%, rgb(0 0 0 / var(--vignette-alpha))); background-size: 100% auto, 100% 100%; - mix-blend-mode: multiply; + mix-blend-mode: normal; } html[data-display="clean"] body::before, @@ -125,6 +126,25 @@ html[data-display="plain"] body::before { color: var(--ink); } +.skip-link { + position: fixed; + top: 0.5rem; + left: 0.5rem; + z-index: 10000; + padding: 0.4rem 0.65rem; + border: var(--rule) solid var(--line); + background: var(--paper); + color: var(--ink); + box-shadow: 2px 2px 0 var(--line); + font-size: 0.78rem; + font-weight: 700; + transform: translateY(calc(-100% - 1rem)); +} + +.skip-link:focus { + transform: translateY(0); +} + a { color: inherit; text-decoration: none; @@ -141,7 +161,8 @@ a:active { } a:focus-visible, -button:focus-visible { +button:focus-visible, +summary:focus-visible { outline: var(--rule) solid var(--ink); outline-offset: 2px; background: var(--ink); @@ -169,6 +190,7 @@ summary, .home { position: relative; + z-index: 1; width: var(--article-width); max-width: calc(100vw - 2.5rem); margin: 1.5rem auto; @@ -210,8 +232,8 @@ summary, display: inline-flex; align-items: center; justify-content: center; - width: 1.5rem; - height: 1.5rem; + width: 2rem; + height: 2rem; border: var(--rule) solid var(--line); background: var(--paper); box-shadow: 1px 1px 0 var(--line); @@ -254,9 +276,12 @@ summary, } .file-tab { - display: block; + display: inline-flex; + min-height: 2.15rem; + align-items: center; + justify-content: center; margin-bottom: calc(-1 * var(--rule)); - padding: 0.08rem 0.55rem 0.1rem; + padding: 0.2rem 0.65rem 0.24rem; border: var(--rule) solid var(--line); background: var(--soft); box-shadow: none; @@ -297,6 +322,10 @@ summary, border-top: 0; } +.file-panel[hidden] { + display: none; +} + .sr-only { position: absolute; width: 1px; @@ -310,7 +339,7 @@ summary, .item-list { display: grid; - gap: 0.22rem; + gap: 0.38rem; } .item-row { @@ -319,14 +348,16 @@ summary, gap: 0.28rem; align-items: start; min-width: 0; - padding: 0.16rem 0.32rem 0.2rem; - border: var(--rule) solid var(--line); + padding: 0.42rem 0.48rem 0.46rem; + border: 1px solid var(--line); background: var(--paper); - box-shadow: 2px 2px 0 var(--line); + box-shadow: none; } .item-row[data-row-href] { cursor: pointer; + border-width: var(--rule); + box-shadow: 2px 2px 0 var(--line); } .item-row[data-row-href]:hover, @@ -347,10 +378,6 @@ summary, color: var(--paper); } -.item-row + .item-row { - border-top: var(--rule) solid var(--line); -} - .post-list { border-bottom: 0; } @@ -372,8 +399,11 @@ summary, } .tag-filter-status a { - align-self: baseline; - padding: 0 0.22rem; + display: inline-flex; + min-height: 1.8rem; + align-items: center; + align-self: center; + padding: 0.1rem 0.4rem; border: var(--rule) solid var(--line); background: var(--paper); color: var(--ink); @@ -395,7 +425,7 @@ summary, grid-template-columns: 5.85rem minmax(0, 1fr); gap: 0.65rem; align-items: baseline; - padding: var(--row-padding) 0; + padding: max(var(--row-padding), 0.3rem) 0; border-top: 0; } @@ -522,17 +552,18 @@ summary, } .row-summary { + display: -webkit-box; grid-column: 1 / -1; - min-width: 4rem; + min-width: 0; max-width: 100%; - margin: 0; - padding-left: 1rem; + margin: 0.12rem 0 0; + padding-left: 0; overflow: hidden; color: var(--muted); font-size: 0.78rem; - line-height: 1.1; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 1.28; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } .item-status { @@ -572,9 +603,12 @@ summary, } .item-links a { - display: inline-block; + display: inline-flex; + min-height: 1.9rem; + align-items: center; + justify-content: center; max-width: 100%; - padding: 0.06rem 0.36rem 0.08rem; + padding: 0.12rem 0.42rem; border: 1px solid currentColor; background: var(--paper); color: var(--ink); @@ -606,6 +640,40 @@ summary, font-weight: 600; } +.tag-empty { + margin: 0.45rem 0 0; + padding: 0.55rem; + border: 1px solid var(--line); + color: var(--muted); + font-size: 0.82rem; + font-weight: 600; +} + +.tag-empty[hidden] { + display: none; +} + +.publication-row .item-title { + overflow: visible; + text-overflow: clip; + white-space: normal; +} + +.publication-venue { + grid-column: 1 / -1; + margin: 0.12rem 0 0; + color: var(--muted); + font-size: 0.75rem; + font-weight: 700; + line-height: 1.25; +} + +.publication-links { + grid-column: 1 / -1; + justify-self: start; + margin-top: 0.25rem; +} + .contact-section { margin-top: var(--section-gap); padding: 0.34rem 0.42rem 0.42rem; @@ -636,6 +704,9 @@ summary, .contact-section a { color: var(--ink); font-weight: 600; + text-decoration: underline; + text-decoration-thickness: 0.08em; + text-underline-offset: 0.16em; } .contact-section a:hover, @@ -644,6 +715,33 @@ summary, color: var(--paper); } +.error-panel { + margin-top: var(--section-gap); + padding: 1rem; + border: var(--rule) solid var(--line); + text-align: center; +} + +.error-code { + margin: 0; + font-family: var(--display-font); + font-size: 4.5rem; + line-height: 0.8; +} + +.error-panel h2 { + margin: 0.7rem 0 0.25rem; + font-family: var(--display-font); + font-size: 1.6rem; + font-weight: 400; +} + +.error-panel > p:not(.error-code) { + margin: 0 0 0.8rem; + color: var(--muted); + font-size: 0.82rem; +} + .post { margin: 1.5rem auto; padding: 0 1.25rem var(--bottom-scroll-room); @@ -651,6 +749,7 @@ summary, .post-layout { position: relative; + z-index: 1; width: var(--article-width); max-width: calc(100vw - 2.5rem); margin: 0 auto; @@ -662,9 +761,12 @@ summary, .back-link, .post-button { - display: inline-block; + display: inline-flex; min-width: 3.2rem; - padding: 0.08rem 0.45rem; + min-height: 2.15rem; + align-items: center; + justify-content: center; + padding: 0.18rem 0.55rem; border: var(--rule) solid var(--line); background: var(--paper); box-shadow: inset 0 0 0 1px var(--paper); @@ -710,15 +812,20 @@ summary, .post-content { width: 100%; min-width: 0; - font-size: 0.95rem; - line-height: 1.55; + font-family: var(--reading-font); + font-size: 0.94rem; + font-weight: 400; + line-height: 1.72; } .post-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; margin-top: 1.25rem; padding-top: 0.65rem; border-top: var(--rule) solid var(--line); - text-align: right; } .post-content > :first-child { @@ -732,7 +839,7 @@ summary, line-height: 1.18; font-weight: 400; letter-spacing: 0; - margin-top: 1.75em; + margin-top: 1.9em; margin-bottom: 0.45em; } @@ -756,15 +863,26 @@ summary, .post-content table, .post-content figure, .post-content .highlight { - margin-top: 0.9rem; - margin-bottom: 0.7rem; + margin-top: 1rem; + margin-bottom: 0.82rem; } .post-content strong, .post-content b { color: var(--ink); - font-weight: 900; - text-shadow: 0.035em 0 0 var(--ink); + font-weight: 750; + text-shadow: none; +} + +.post-content p a, +.post-content li a, +.post-content blockquote a, +.post-content figcaption a, +.post-content td a, +.post-content .footnotes a { + text-decoration: underline; + text-decoration-thickness: 0.08em; + text-underline-offset: 0.16em; } .post-content blockquote { @@ -776,6 +894,7 @@ summary, } .post-content code { + font-family: var(--body-font); font-size: 0.9em; background: repeating-linear-gradient(45deg, rgb(0 0 0 / 0.08) 0 1px, transparent 1px 4px), @@ -871,8 +990,19 @@ summary, } .post-content img { + display: block; max-width: 100%; height: auto; + image-rendering: auto; +} + +.figure-media { + display: block; + width: 100%; +} + +.figure-media img { + margin-inline: auto; } .post-content table { @@ -926,6 +1056,7 @@ summary, } .post-margin { + display: none; width: 100%; margin-left: 0; margin-bottom: 1rem; @@ -955,34 +1086,42 @@ summary, text-align: center; } -#TableOfContents ul { +#TableOfContents ul, +#MobileTableOfContents ul { list-style: none; padding-left: 0; margin: 0; } -#TableOfContents > ul { +#TableOfContents > ul, +#MobileTableOfContents > ul { padding: 0.28rem; } -#TableOfContents ul ul { +#TableOfContents ul ul, +#MobileTableOfContents ul ul { padding-left: 0.55rem; margin-top: 0.08rem; } -#TableOfContents li { +#TableOfContents li, +#MobileTableOfContents li { margin: 0.06rem 0; } -#TableOfContents a { - display: block; - padding: 0.04rem 0.12rem; +#TableOfContents a, +#MobileTableOfContents a { + display: flex; + min-height: 1.55rem; + align-items: center; + padding: 0.16rem 0.2rem; text-decoration: none; line-height: 1.18; white-space: normal; } -#TableOfContents ul ul a { +#TableOfContents ul ul a, +#MobileTableOfContents ul ul a { font-size: 0.68rem; font-weight: 600; } @@ -993,6 +1132,44 @@ summary, font-weight: 600; } +.post-toc-inline { + margin-top: 1rem; + border: var(--rule) solid var(--line); + background: var(--paper); + font-family: var(--body-font); + font-size: 0.78rem; + font-weight: 600; +} + +.post-toc-inline summary { + min-height: 2.25rem; + padding: 0.38rem 0.55rem; + background: var(--soft); + font-family: var(--display-font); + font-size: 1.08rem; + font-weight: 400; + line-height: 1.15; +} + +.post-toc-inline[open] summary { + border-bottom: var(--rule) solid var(--line); +} + +.post-toc-inline summary:focus-visible { + background: var(--ink); + color: var(--paper); +} + +.post-toc-inline-body { + padding: 0.4rem; +} + +#MobileTableOfContents a { + display: flex; + min-height: 1.9rem; + padding: 0.3rem 0.35rem; +} + .sidenote-number { counter-increment: sidenote; } @@ -1046,6 +1223,7 @@ summary, } .post-layout.has-toc .post-margin { + display: block; grid-column: 1; grid-row: 1; width: var(--margin-width); @@ -1056,15 +1234,13 @@ summary, left: calc(50% - var(--article-half-width) - var(--margin-gap) - var(--margin-width)); align-self: start; } + + .post-toc-inline { + display: none; + } } @media (max-width: 1280px) { - .post-margin { - max-height: none; - margin-bottom: 2rem; - padding-top: 1rem; - } - .sidenote { display: none; float: none; @@ -1084,7 +1260,7 @@ summary, :root { --panel-padding: 0.72rem; --section-gap: 0.8rem; - --bottom-scroll-room: 34vh; + --bottom-scroll-room: 10vh; --dither-alpha: 0.055; --scanline-alpha: 0.045; --scanline-soft-alpha: 0.014; @@ -1156,17 +1332,13 @@ summary, .item-row { grid-template-columns: 4.35rem minmax(0, 1fr); gap: 0.2rem; - padding: 0.28rem 0.32rem; + padding: 0.4rem 0.42rem 0.44rem; } .post-row + .post-row { border-top: var(--rule) solid var(--line); } - .item-row + .item-row { - border-top: 1px solid var(--line); - } - .item-body { grid-template-columns: minmax(0, 1fr) auto; gap: 0.08rem 0.36rem; @@ -1188,14 +1360,13 @@ summary, min-width: 0; padding-left: 0; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; } .item-links { width: auto; gap: 0.24rem; - flex-wrap: nowrap; + flex-wrap: wrap; justify-self: end; } @@ -1230,8 +1401,8 @@ summary, } .post-content { - font-size: 0.92rem; - line-height: 1.5; + font-size: 0.95rem; + line-height: 1.66; } .post-content h2 { @@ -1263,21 +1434,6 @@ summary, font-size: 0.72rem; } - .post-margin { - max-height: 12rem; - margin-bottom: 1rem; - padding-top: 0; - overflow: auto; - font-size: 0.72rem; - } - - .toc-title { - font-size: 0.95rem; - } - - #TableOfContents > ul { - padding: 0.22rem; - } } @media (max-width: 420px) { @@ -1290,8 +1446,8 @@ summary, } .social-link { - width: 1.42rem; - height: 1.42rem; + width: 2rem; + height: 2rem; } .social-link img { @@ -1299,12 +1455,45 @@ summary, height: 0.88rem; } + .file-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.25rem; + overflow: visible; + padding-left: 0; + border-bottom: 0; + } + .file-tab { - padding-right: 0.34rem; - padding-left: 0.34rem; + width: 100%; + margin-bottom: 0; + padding-right: 0.42rem; + padding-left: 0.42rem; font-size: 0.95rem; } + .file-tab.active, + .file-tab.active:hover, + .file-tab.active:focus-visible { + border-bottom-color: var(--line); + background: var(--ink); + color: var(--paper); + } + + .file-panel { + margin-top: 0.4rem; + border-top: var(--rule) solid var(--line); + } + + .item-row { + grid-template-columns: 1fr; + gap: 0.16rem; + } + + .item-links { + justify-self: start; + } + .post-title, .item-title { font-size: 1.12rem; @@ -1315,6 +1504,12 @@ summary, } } +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { :root { --dither-alpha: 0; diff --git a/assets/js/site.js b/assets/js/site.js new file mode 100644 index 0000000..df04170 --- /dev/null +++ b/assets/js/site.js @@ -0,0 +1,240 @@ +(() => { + const $ = (selector, root = document) => root.querySelector(selector); + const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + + function isPlainLeftClick(event) { + return ( + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ); + } + + function setupFileTabs() { + const index = $("[data-file-index]"); + if (!index) return; + + const tabs = $$("[data-file-tab]", index); + const panels = $$("[data-file-panel]", index); + const announcer = $("[data-file-announcer]", index); + const canonical = $('link[rel="canonical"]'); + const routes = new Map( + tabs.map((tab) => [new URL(tab.href, window.location.href).pathname, tab.dataset.fileTab]) + ); + let activeId = null; + + function idForLocation() { + return routes.get(window.location.pathname) || "posts"; + } + + function activate(id, { announce = false } = {}) { + const activeTab = tabs.find((tab) => tab.dataset.fileTab === id) || tabs[0]; + if (!activeTab) return; + const changed = activeId !== activeTab.dataset.fileTab; + activeId = activeTab.dataset.fileTab; + + for (const tab of tabs) { + const active = tab === activeTab; + tab.classList.toggle("active", active); + if (active) tab.setAttribute("aria-current", "page"); + else tab.removeAttribute("aria-current"); + } + + for (const panel of panels) { + panel.hidden = panel.dataset.filePanel !== activeTab.dataset.fileTab; + } + + document.title = activeTab.dataset.pageTitle || document.title; + if (canonical) canonical.href = new URL(activeTab.href, window.location.href).href; + if (announce && changed && announcer) { + announcer.textContent = `Showing ${activeTab.textContent.trim()}`; + } + } + + for (const tab of tabs) { + tab.addEventListener("click", (event) => { + if (!isPlainLeftClick(event)) return; + event.preventDefault(); + + const url = new URL(tab.href, window.location.href); + const nextLocation = `${url.pathname}${url.search}${url.hash}`; + const currentLocation = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (nextLocation !== currentLocation) { + window.history.pushState({ fileTab: tab.dataset.fileTab }, "", nextLocation); + } + + activate(tab.dataset.fileTab, { announce: true }); + window.dispatchEvent(new CustomEvent("filetabchange")); + }); + } + + window.addEventListener("popstate", () => activate(idForLocation(), { announce: true })); + activate(idForLocation()); + } + + function setupToc() { + const headings = $$(".post-content h2[id], .post-content h3[id]"); + const links = $$("#TableOfContents a"); + if (!headings.length || !links.length) return; + + let queued = false; + + function update() { + const y = window.scrollY + window.innerHeight * 0.35; + let activeId = headings[0].id; + + for (const heading of headings) { + const headingY = heading.getBoundingClientRect().top + window.scrollY; + if (headingY > y) break; + activeId = heading.id; + } + + for (const link of links) { + link.classList.toggle( + "active", + decodeURIComponent(link.hash.slice(1)) === activeId + ); + } + + queued = false; + } + + function queue() { + if (queued) return; + queued = true; + window.requestAnimationFrame(update); + } + + window.addEventListener("scroll", queue, { passive: true }); + window.addEventListener("resize", queue); + update(); + } + + function setupPostTags() { + const list = $("[data-post-list]"); + if (!list) return; + + const rows = $$(".post-row", list); + const links = $$("[data-tag]"); + const status = $("[data-tag-filter-status]"); + const clear = status?.querySelector("a"); + const empty = $("[data-tag-empty]"); + + function setUrl(tag) { + const url = new URL(window.location.href); + if (tag) url.searchParams.set("tag", tag); + else url.searchParams.delete("tag"); + window.history.pushState({ tag }, "", `${url.pathname}${url.search}`); + } + + function applyTag(tag) { + for (const link of links) { + link.classList.toggle("active", link.dataset.tag === tag); + } + + let visible = 0; + for (const row of rows) { + const tags = JSON.parse(row.dataset.tags || "[]"); + const matched = !tag || tags.includes(tag); + row.hidden = !matched; + if (matched) visible += 1; + } + + if (status) { + const name = $("[data-tag-filter-name]", status); + const count = $("[data-tag-filter-count]", status); + if (name) name.textContent = tag ? `[${tag}]` : ""; + if (count) count.textContent = `${visible} ${visible === 1 ? "post" : "posts"}`; + status.hidden = !tag; + status.classList.toggle("is-empty", visible === 0); + } + + if (empty) empty.hidden = !tag || visible > 0; + } + + function applyFromLocation() { + applyTag(new URLSearchParams(window.location.search).get("tag")); + } + + for (const link of links) { + link.addEventListener("click", (event) => { + if (!isPlainLeftClick(event)) return; + event.preventDefault(); + const nextTag = link.classList.contains("active") ? null : link.dataset.tag; + setUrl(nextTag); + applyTag(nextTag); + }); + } + + if (clear) { + clear.addEventListener("click", (event) => { + if (!isPlainLeftClick(event)) return; + event.preventDefault(); + setUrl(null); + applyTag(null); + }); + } + + window.addEventListener("popstate", applyFromLocation); + window.addEventListener("filetabchange", applyFromLocation); + applyFromLocation(); + } + + function setupClickableRows() { + const rows = $$("[data-row-href]"); + + for (const row of rows) { + row.addEventListener("click", (event) => { + if (event.target.closest("a")) return; + const href = row.dataset.rowHref; + if (href) window.location.assign(href); + }); + + row.addEventListener("pointerdown", (event) => { + if (event.target.closest("a")) return; + row.classList.add("is-pressed"); + }); + + for (const eventName of ["pointerup", "pointercancel", "pointerleave"]) { + row.addEventListener(eventName, () => row.classList.remove("is-pressed")); + } + } + } + + function setupPrefetch() { + const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + if (connection?.saveData || /(^|-)2g$/.test(connection?.effectiveType || "")) return; + + const prefetched = new Set(); + + function prefetch(href) { + const url = new URL(href, window.location.href); + if (url.origin !== window.location.origin || url.hash || url.pathname === window.location.pathname) return; + if (prefetched.has(url.href)) return; + prefetched.add(url.href); + + const hint = document.createElement("link"); + hint.rel = "prefetch"; + hint.as = "document"; + hint.href = url.href; + document.head.append(hint); + } + + for (const link of $$(".item-title[href], .post-title[href], .back-link[href], .post-button[href]")) { + link.addEventListener("pointerenter", () => prefetch(link.href), { once: true }); + link.addEventListener("focus", () => prefetch(link.href), { once: true }); + } + + for (const row of $$("[data-row-href]")) { + row.addEventListener("pointerenter", () => prefetch(row.dataset.rowHref), { once: true }); + } + } + + setupFileTabs(); + setupToc(); + setupPostTags(); + setupClickableRows(); + setupPrefetch(); +})(); diff --git a/content/posts/blackwell_datacenter_vs_geforce.md b/content/posts/blackwell_datacenter_vs_geforce.md index d01a696..6527eee 100644 --- a/content/posts/blackwell_datacenter_vs_geforce.md +++ b/content/posts/blackwell_datacenter_vs_geforce.md @@ -68,7 +68,7 @@ So the practical distinction is not "does this GPU support FP4 at all?" It does. I still wanted to measure the card instead of just reading target tables. So I pulled CUTLASS and ran its NVFP4 matrix multiplication example on the RTX 5090. -{{< figure src="/images/1_blackwell_dc_vs_gf/5090_65536_cropped.png" alt="A screenshot of a CUTLASS NVFP4 matrix multiplication benchmark on an RTX 5090" />}} +{{< figure src="/images/1_blackwell_dc_vs_gf/5090_65536_cropped.png" width="236" height="77" alt="A screenshot of a CUTLASS NVFP4 matrix multiplication benchmark on an RTX 5090" />}} That is over a petaflop of NVFP4 compute. @@ -76,7 +76,7 @@ A petaflop means one quadrillion floating-point operations per second. For a des But the next question is whether the Tensor Cores are being fed efficiently. Nsight Compute gives the more interesting picture. -{{< figure src="/images/1_blackwell_dc_vs_gf/geforce_ncu.png" alt="Nsight Compute showing register pressure and memory bottlenecks on a GeForce GPU" />}} +{{< figure src="/images/1_blackwell_dc_vs_gf/geforce_ncu.png" width="1974" height="807" alt="Nsight Compute showing register pressure and memory bottlenecks on a GeForce GPU" />}} The short read is: memory is the bottleneck. The Tensor Cores can chew through math faster than the rest of the kernel can keep them supplied. Shared memory pressure shows up immediately. @@ -86,9 +86,9 @@ That is exactly the problem the datacenter path is designed to address. To compare against datacenter Blackwell, I rented a B200 instance on Vast.ai and ran the same kind of matrix multiplication with CUTLASS kernels targeting `sm_100a`. -{{< figure src="/images/1_blackwell_dc_vs_gf/nvtop_b200.png" alt="nvtop showing B200 GPU memory capacity" />}} +{{< figure src="/images/1_blackwell_dc_vs_gf/nvtop_b200.png" width="647" height="106" alt="nvtop showing B200 GPU memory capacity" />}} -{{< figure src="/images/1_blackwell_dc_vs_gf/b200_65536_cropped.png" alt="A CUTLASS benchmark result from a B200 GPU" />}} +{{< figure src="/images/1_blackwell_dc_vs_gf/b200_65536_cropped.png" width="300" height="108" alt="A CUTLASS benchmark result from a B200 GPU" />}} That run gets past 2 petaflops, and I suspect better kernels can push it further. The point is not just the number. The point is that the B200 has access to the datacenter Blackwell path that the 5090 does not. diff --git a/content/posts/qwen36_tokens_per_kwh.md b/content/posts/qwen36_tokens_per_kwh.md index 129fc71..bbc014b 100644 --- a/content/posts/qwen36_tokens_per_kwh.md +++ b/content/posts/qwen36_tokens_per_kwh.md @@ -31,7 +31,7 @@ That is awkward for a large GPU. A 5090 has a lot of compute, but a single decod So raw GPU size does not always turn into efficient local inference. -{{< figure src="/images/2_qwen36_tokens_per_kwh/fig5_autoregressive_decode.svg" alt="Autoregressive decoding diagram showing one model pass producing one accepted token at a time" />}} +{{< figure src="/images/2_qwen36_tokens_per_kwh/fig5_autoregressive_decode.svg" width="1200" height="520" alt="Autoregressive decoding diagram showing one model pass producing one accepted token at a time" />}} Speculative decoding tries to fix this shape. @@ -49,7 +49,7 @@ That is why the Qwen3.6 MTP model caught my attention. `Qwen3.6-27B` is a dense Large enough to be useful. Small enough to run locally. -{{< figure src="/images/2_qwen36_tokens_per_kwh/fig6_mtp_decode.svg" alt="MTP speculative decoding diagram showing a lookahead head proposing several draft tokens and the target model verifying them" />}} +{{< figure src="/images/2_qwen36_tokens_per_kwh/fig6_mtp_decode.svg" width="1200" height="560" alt="MTP speculative decoding diagram showing a lookahead head proposing several draft tokens and the target model verifying them" />}} ## The 5090 settings @@ -84,7 +84,7 @@ The same power settings without MTP gave me **32.1 tok/s**. So MTP was not a small change. It moved the setup from usable to comfortable. -{{< figure src="/images/2_qwen36_tokens_per_kwh/author_nvidia_mtp_x6_215w_report.png" alt="Author measurement screenshot showing Qwen3.6-27B MTP throughput and a 215 watt GPU power reading" />}} +{{< figure src="/images/2_qwen36_tokens_per_kwh/author_nvidia_mtp_x6_215w_report.png" width="1656" height="807" alt="Author measurement screenshot showing Qwen3.6-27B MTP throughput and a 215 watt GPU power reading" />}} ## The math @@ -170,7 +170,7 @@ That is still fine for me. I would leave that running. Could I push the GPU lower? Yes. I can get the 5090 closer to **150 W**. I need a proper sweep before I say where the best point is. For now, 215 W is the point I like. It is fast enough, and it does not feel wasteful. -{{< figure src="/images/2_qwen36_tokens_per_kwh/fig1_tokens_per_day.png" alt="Bar chart comparing generated tokens per day across the measured NVIDIA run and M3 Ultra benchmark rows" />}} +{{< figure src="/images/2_qwen36_tokens_per_kwh/fig1_tokens_per_day.png" width="1800" height="990" alt="Bar chart comparing generated tokens per day across the measured NVIDIA run and M3 Ultra benchmark rows" />}} ## The Mac comparison @@ -190,7 +190,7 @@ Those are good numbers for agent work. The missing number is power. Against my GPU only 215 W point, the M3 Ultra 80 core oQ4 MTP rows need to run around **95 to 104 W at the wall** to match the 5090 on generated tokens per kWh. -{{< figure src="/images/2_qwen36_tokens_per_kwh/fig3_m3_break_even_power.png" alt="Bar chart showing the M3 Ultra wall-power levels needed to match the measured NVIDIA GPU-side tokens per kWh" />}} +{{< figure src="/images/2_qwen36_tokens_per_kwh/fig3_m3_break_even_power.png" width="1800" height="990" alt="Bar chart showing the M3 Ultra wall-power levels needed to match the measured NVIDIA GPU-side tokens per kWh" />}} Against my rough 275 W whole box estimate, the break even numbers move up. @@ -211,7 +211,7 @@ That is why I do not want to turn this into a clean win for either side. The Mac The real comparison needs outlet power during the same workload. -{{< figure src="/images/2_qwen36_tokens_per_kwh/fig4_m3_power_sensitivity.png" alt="Line chart showing M3 Ultra tokens per kWh across different assumed wall-power levels" />}} +{{< figure src="/images/2_qwen36_tokens_per_kwh/fig4_m3_power_sensitivity.png" width="1800" height="990" alt="Line chart showing M3 Ultra tokens per kWh across different assumed wall-power levels" />}} ## What changed for me diff --git a/content/projects/clipbored.md b/content/projects/clipbored.md index 10ffffb..784fdfb 100644 --- a/content/projects/clipbored.md +++ b/content/projects/clipbored.md @@ -19,7 +19,7 @@ math: false ClipBored is a small native macOS clipboard manager. It captures local clipboard history and opens a keyboard-first responsive bottom panel for search, sorting, copy, paste, pinning, deletion, and organization. -{{< figure src="/images/projects/clipbored/panel.png" alt="ClipBored clipboard panel snapshot." />}} +{{< figure src="/images/projects/clipbored/panel.png" width="3024" height="860" alt="ClipBored clipboard panel snapshot." />}} ## What It Does diff --git a/content/projects/feedme.md b/content/projects/feedme.md index 7f47e5e..eaef9c7 100644 --- a/content/projects/feedme.md +++ b/content/projects/feedme.md @@ -15,7 +15,7 @@ math: false FeedMe is a small native SwiftUI feed reader for macOS and iOS. It is local-first, account-free, and built around a shared Swift package for feed parsing, OPML import/export, refresh orchestration, and SQLite persistence. -{{< figure src="/images/projects/feedme/demo.png" alt="FeedMe running with a local demo library on iOS." />}} +{{< figure src="/images/projects/feedme/demo.png" width="644" height="1400" alt="FeedMe running with a local demo library on iOS." />}} ## What It Does diff --git a/content/projects/ihatepdfs.md b/content/projects/ihatepdfs.md index c3cec9d..08d2abc 100644 --- a/content/projects/ihatepdfs.md +++ b/content/projects/ihatepdfs.md @@ -21,7 +21,7 @@ math: false I Hate PDFs is a small native macOS PDF reader for local reading, highlighting, commenting, and review. It uses SwiftUI, AppKit, and PDFKit, keeps documents on your Mac, and avoids accounts, tracking, and cloud upload. -{{< figure src="/images/projects/ihatepdfs/default-reading.png" alt="I Hate PDFs default reading mode." />}} +{{< figure src="/images/projects/ihatepdfs/default-reading.png" width="2024" height="1568" alt="I Hate PDFs default reading mode." />}} ## What It Does diff --git a/content/projects/inspire-me.md b/content/projects/inspire-me.md index 0174321..0f1ab18 100644 --- a/content/projects/inspire-me.md +++ b/content/projects/inspire-me.md @@ -17,7 +17,7 @@ math: false Inspire Me is a small browser app that displays a changing stream of words over a soft animated atmosphere. It is built as a static web app with JavaScript, Canvas, CSS typography controls, and a local word corpus. -{{< figure src="/images/projects/inspire-me/preview.png" alt="Inspire Me showing a large word over a soft animated paper-like background." />}} +{{< figure src="/images/projects/inspire-me/preview.png" width="1440" height="900" alt="Inspire Me showing a large word over a soft animated paper-like background." />}} ## What It Does diff --git a/docs/performance-deployment.md b/docs/performance-deployment.md new file mode 100644 index 0000000..2e18210 --- /dev/null +++ b/docs/performance-deployment.md @@ -0,0 +1,12 @@ +# Production cache setup + +The container now sends long-lived cache headers for fingerprinted CSS and JavaScript, 30-day headers for fonts and images, and a one-hour shared-cache lifetime for HTML. + +Cloudflare does not cache HTML by default. Add a Cache Rule for `akkolli.net` that marks successful `GET` and `HEAD` HTML responses as eligible for cache, respects the origin `Cache-Control` header, and uses the origin-provided Edge TTL. + +Add these Gitea Actions secrets so a deploy purges stale HTML after the new container starts: + +- `CLOUDFLARE_ZONE_ID` +- `CLOUDFLARE_API_TOKEN` with `Cache Purge` permission for the zone + +The deployment remains functional when those secrets are absent; it simply skips the purge. diff --git a/layouts/404.html b/layouts/404.html new file mode 100644 index 0000000..f15cdf7 --- /dev/null +++ b/layouts/404.html @@ -0,0 +1,11 @@ +{{ define "main" }} +
+ {{ partial "home-intro.html" . }} +
+ +

File not found

+

The page may have moved, or the address may be incomplete.

+ < Home +
+
+{{ end }} diff --git a/layouts/_default/list.html b/layouts/_default/list.html index da4a9b9..4554968 100644 --- a/layouts/_default/list.html +++ b/layouts/_default/list.html @@ -1,5 +1,5 @@ {{ define "main" }} -
+
{{ partial "home-intro.html" . }} {{ partial "file-index.html" (dict "active" .Section) }}
diff --git a/layouts/_default/single.html b/layouts/_default/single.html index d9780fa..d1f0482 100644 --- a/layouts/_default/single.html +++ b/layouts/_default/single.html @@ -1,6 +1,7 @@ {{ define "main" }} -
+
{{ $hasToc := and (ne .Params.toc false) (gt (len (findRE "
  • " .TableOfContents)) 0) }} + {{ $mobileToc := replace .TableOfContents `id="TableOfContents"` `id="MobileTableOfContents"` }} {{ $backHref := "/" | relURL }} {{ $backLabel := "Home" }} {{ if in (slice "projects" "websites") .Section }} @@ -10,7 +11,7 @@ {{ $backHref = "/publications/" | relURL }} {{ $backLabel = "Publications" }} {{ end }} -
    +
    {{ if $hasToc }}
    - -
  • + +
    {{ end }} diff --git a/layouts/_shortcodes/figure.html b/layouts/_shortcodes/figure.html index be2ba72..005a6d3 100644 --- a/layouts/_shortcodes/figure.html +++ b/layouts/_shortcodes/figure.html @@ -2,13 +2,43 @@ {{ $src := .Get "src" }} {{ $caption := .Get "caption" }} {{ $alt := .Get "alt" | default $caption }} +{{ $width := .Get "width" }} +{{ $height := .Get "height" }} +{{ $loading := .Get "loading" | default "lazy" }} +{{ $fetchpriority := .Get "fetchpriority" }} {{ $figures := .Page.Scratch.Get "figures" | default dict }} {{ $number := add (len $figures) 1 }} {{ .Page.Scratch.SetInMap "figures" $id $number }}
    {{ with $src }} - {{ $alt }} + {{ $ext := path.Ext . }} + {{ $base := strings.TrimSuffix $ext . }} + {{ $srcset := slice }} + {{ range slice 480 644 768 960 1344 }} + {{ $candidate := printf "%s-%d.webp" $base . }} + {{ if fileExists (printf "static%s" $candidate) }} + {{ $srcset = $srcset | append (printf "%s %dw" ($candidate | relURL) .) }} + {{ end }} + {{ end }} + + {{ with $srcset }} + + {{ end }} + {{ $alt }} + {{ end }} {{ if or $caption .Inner }} diff --git a/layouts/baseof.html b/layouts/baseof.html index 2ab40c2..77b3ef4 100644 --- a/layouts/baseof.html +++ b/layouts/baseof.html @@ -7,19 +7,37 @@ - - {{- if .IsHome -}} - {{ site.Title }} · {{ site.Params.author }} - {{- else -}} - {{ .Title }} · {{ site.Title }} - {{- end -}} - - - + {{ $pageTitle := site.Title }} + {{ if not .IsHome }}{{ $pageTitle = printf "%s · %s" .Title site.Title }}{{ end }} + {{ $description := .Description | default site.Params.description }} + {{ $socialImage := "og.jpg" | absURL }} + + {{ $pageTitle }} + + + + + + + + + + + + + + {{ if .IsPage }}{{ end }} + + + + + + - + {{ $styles := resources.Get "css/main.css" | minify | fingerprint "sha384" }} + {{ if .Param "math" }} @@ -31,10 +49,12 @@ > {{ end }} - + {{ $scripts := resources.Get "js/site.js" | minify | fingerprint "sha384" }} + + {{ block "main" . }}{{ end }} diff --git a/layouts/home.html b/layouts/home.html index 2847b90..33a4092 100644 --- a/layouts/home.html +++ b/layouts/home.html @@ -1,5 +1,5 @@ {{ define "main" }} -
    +
    {{ partial "home-intro.html" . }} {{ partial "file-index.html" (dict "active" "posts") }}
    diff --git a/layouts/partials/contact-section.html b/layouts/partials/contact-section.html index 86c7098..48ee1a1 100644 --- a/layouts/partials/contact-section.html +++ b/layouts/partials/contact-section.html @@ -1,3 +1,3 @@ -
    +

    Email me at akshaykolli@hotmail.com

    diff --git a/layouts/partials/file-index.html b/layouts/partials/file-index.html index 4ec1edb..bc7566c 100644 --- a/layouts/partials/file-index.html +++ b/layouts/partials/file-index.html @@ -1,17 +1,19 @@ {{ $active := .active | default "posts" }} {{ $tabs := slice - (dict "id" "posts" "label" "Posts" "href" ("/" | relURL)) - (dict "id" "projects" "label" "Projects" "href" ("/projects/" | relURL)) - (dict "id" "publications" "label" "Publications" "href" ("/publications/" | relURL)) - (dict "id" "contact" "label" "Contact" "href" ("/contact/" | relURL)) + (dict "id" "posts" "label" "Posts" "href" ("/" | relURL) "pageTitle" site.Title) + (dict "id" "projects" "label" "Projects" "href" ("/projects/" | relURL) "pageTitle" (printf "Projects · %s" site.Title)) + (dict "id" "publications" "label" "Publications" "href" ("/publications/" | relURL) "pageTitle" (printf "Publications · %s" site.Title)) + (dict "id" "contact" "label" "Contact" "href" ("/contact/" | relURL) "pageTitle" (printf "Contact · %s" site.Title)) }} - diff --git a/layouts/partials/page-list.html b/layouts/partials/page-list.html index 9860d0a..7511d89 100644 --- a/layouts/partials/page-list.html +++ b/layouts/partials/page-list.html @@ -2,7 +2,7 @@
    {{ range .ByWeight }} {{ $pageTitle := .Title }} -
    +
    {{ with .Date }}{{ .Format "Jan 2006" }}{{ end }}
    diff --git a/layouts/partials/post-list.html b/layouts/partials/post-list.html index 176289c..c3f4983 100644 --- a/layouts/partials/post-list.html +++ b/layouts/partials/post-list.html @@ -1,9 +1,11 @@ {{ with . }} -