silly personal website
1

Configure Feed

Select the types of activity you want to include in your feed.

refactor: restructure components, extract content/services, and rework CSS system

Lívia (May 19, 2026, 1:29 AM -0300) 8dfb3a2d 2c0d5b30

+1700 -1305
+7
assets/ui/arrow.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" shape-rendering="crispEdges"> 2 + <rect x="2" y="0" width="6" height="2" fill="white" /> 3 + <rect x="4" y="2" width="4" height="2" fill="white" /> 4 + <rect x="2" y="4" width="2" height="2" fill="white" /> 5 + <rect x="6" y="4" width="2" height="2" fill="white" /> 6 + <rect x="0" y="6" width="2" height="2" fill="white" /> 7 + </svg>
-13
extra/arrow.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" shape-rendering="crispEdges"> 2 - <rect x="0" y="0" width="2" height="2" fill="currentColor" opacity="0.45" /> 3 - <rect x="2" y="2" width="2" height="2" fill="currentColor" opacity="0.45" /> 4 - <rect x="0" y="4" width="2" height="2" fill="currentColor" opacity="0.45" /> 5 - <rect x="4" y="4" width="2" height="2" fill="currentColor" opacity="0.45" /> 6 - <rect x="2" y="6" width="2" height="2" fill="currentColor" opacity="0.45" /> 7 - <rect x="6" y="6" width="2" height="2" fill="currentColor" opacity="0.45" /> 8 - <rect x="2" y="0" width="6" height="2" fill="currentColor" /> 9 - <rect x="4" y="2" width="4" height="2" fill="currentColor" /> 10 - <rect x="2" y="4" width="2" height="2" fill="currentColor" /> 11 - <rect x="6" y="4" width="2" height="2" fill="currentColor" /> 12 - <rect x="0" y="6" width="2" height="2" fill="currentColor" /> 13 - </svg>
-153
src/components/Fm.tsx
··· 1 - /** 2 - * Copyright (c) 2025 adoravel 3 - * SPDX-License-Identifier: AGPL-3.0-or-later 4 - */ 5 - 6 - import { boundaries, elevation, fontSize, radius, spacing, theme } from "~/layout.tsx"; 7 - import { css, Import } from "../mech/css.ts"; 8 - import { FM_USER, tracks } from "../mech/fm.ts"; 9 - 10 - const fm = css(` 11 - :scope { 12 - margin-top: 0.5rem !important; 13 - } 14 - 15 - .fm-more:hover { 16 - opacity: 1; 17 - } 18 - 19 - .fm-more::before { 20 - content: "→"; 21 - position: absolute; 22 - color: transparent; 23 - font-size: 15rem; 24 - -webkit-text-stroke: 2px ${theme.surfaceBorder}; 25 - z-index: ${elevation.below}; 26 - } 27 - 28 - .fm-more { 29 - position: relative; 30 - display: flex; 31 - align-items: center; 32 - justify-content: center; 33 - flex-direction: column; 34 - overflow: hidden; 35 - width: 100%; 36 - height: 100%; 37 - opacity: 75%; 38 - 39 - background: ${theme.base}; 40 - border: 1px solid ${theme.baseBorder}; 41 - border-radius: ${radius.art}; 42 - font-size: ${fontSize.sm}; 43 - color: ${theme.text}; 44 - padding: ${spacing[3]}; 45 - z-index: ${elevation.base}; 46 - 47 - @media (max-width: 930px) { 48 - display: none; 49 - } 50 - } 51 - 52 - :scope { 53 - display: grid; 54 - grid-template-columns: repeat(auto-fill, minmax(120px, 160px)); 55 - gap: ${spacing[6]}; 56 - margin-top: ${spacing[2]}; 57 - padding: 0; 58 - margin: 0; 59 - list-style: none; 60 - } 61 - 62 - li > a { 63 - display: flex; 64 - flex-direction: column; 65 - align-items: center; 66 - text-align: center; 67 - gap: ${spacing[3]}; 68 - max-width: ${boundaries.avatarSize}; 69 - } 70 - 71 - li > a > .cover { 72 - width: ${boundaries.avatarSize}; 73 - height: ${boundaries.avatarSize}; 74 - border-radius: ${radius.art}; 75 - background: ${theme.lift}; 76 - object-fit: cover; 77 - } 78 - 79 - li > a > .meta { 80 - display: flex; 81 - flex-direction: column; 82 - font-size: ${fontSize.md}; 83 - color: ${theme.subtext}; 84 - } 85 - 86 - li > a > .meta > .loved { 87 - color: ${theme.accent}; 88 - } 89 - 90 - li > a > .meta strong { 91 - font-weight: 600; 92 - font-size: ${fontSize.base}; 93 - color: ${theme.text}; 94 - } 95 - 96 - li > a > .meta strong, 97 - li > a > .meta span { 98 - display: -webkit-box; 99 - -webkit-box-orient: vertical; 100 - -webkit-line-clamp: 2; 101 - line-clamp: 2; 102 - overflow: hidden; 103 - word-wrap: anywhere; 104 - text-overflow: ellipsis; 105 - max-width: 100%; 106 - } 107 - `); 108 - 109 - export function Fm() { 110 - if (!tracks) return; 111 - 112 - const content = tracks().slice(0, 4); 113 - 114 - return ( 115 - <ul class={fm.scope}> 116 - <Import styles={[fm]} /> 117 - {content.map((track) => ( 118 - <li class="fm-recent" key={track.artist + track.name}> 119 - <a href={track.url}> 120 - <img 121 - class="cover" 122 - src={track.cover || 123 - "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"} 124 - alt="" 125 - /> 126 - <div class="meta"> 127 - <strong class="title">{track.name}</strong> 128 - <span>{track.artist}</span> 129 - {track.playing 130 - ? ( 131 - <span class="loved" title="Now playing"> 132 - 133 - </span> 134 - ) 135 - : ( 136 - track.loved && ( 137 - <span class="loved" title="Loved"> 138 - 139 - </span> 140 - ) 141 - )} 142 - </div> 143 - </a> 144 - </li> 145 - ))} 146 - <li> 147 - <a class="fm-more" href={`https://last.fm/user/${FM_USER}`}> 148 - Check out more on Last.fm 149 - </a> 150 - </li> 151 - </ul> 152 - ); 153 - }
+35 -29
src/components/Footer.tsx src/components/layout/Footer.tsx
··· 3 3 * SPDX-License-Identifier: AGPL-3.0-or-later 4 4 */ 5 5 6 - import { css, Import } from "~/mech/css.ts"; 7 - import { Fluxer, GitHub, Mail, Tangled } from "./Icon.tsx"; 6 + import { css } from "~/lib/css.ts"; 7 + import { Fluxer, GitHub, Mail, Tangled } from "~/components/ui/Icon.tsx"; 8 8 import { fontSize, spacing, theme } from "~/layout.tsx"; 9 9 10 - const footer = css(` 10 + const Styled = css(` 11 11 :scope { 12 + display: block; 12 13 color: ${theme.textMuted}; 13 14 font-size: ${fontSize.md}; 14 15 } 15 16 17 + hr { 18 + border: none; 19 + border-top: 1px solid ${theme.surfaceBorder}; 20 + margin: ${spacing.section} 0; 21 + } 22 + 23 + .highlight { 24 + color: ${theme.text}; 25 + font-weight: 500; 26 + } 27 + 28 + .highlight-rose { 29 + color: ${theme.rose}; 30 + } 31 + 16 32 ul { 17 33 display: flex; 18 34 flex-wrap: wrap; 19 - padding-left: 0; 35 + gap: ${spacing[2]}; 20 36 margin-top: ${spacing[4]}; 37 + padding-left: 0; 21 38 list-style: none; 22 - gap: ${spacing[2]}; 23 39 } 24 40 25 - ul > li > :is(a,button) { 26 - font-size: ${fontSize.sm}; 27 - display: flex; 41 + ul > li > :is(a, button) { 42 + display: inline-flex; 28 43 align-items: center; 29 44 justify-content: center; 30 45 padding: ${spacing[1]} ${spacing[4]}; 31 46 gap: 1ch; 32 47 color: ${theme.subtext}; 33 48 background-color: ${theme.surface}; 49 + font-size: ${fontSize.sm}; 50 + text-decoration: none; 34 51 border: none; 35 52 outline: none; 36 53 border-radius: 1000px; 37 54 cursor: pointer; 55 + transition: filter 0.15s ease; 38 56 39 57 &:hover { 40 - filter: brightness(0.75); 58 + filter: brightness(0.85); 41 59 } 42 60 } 43 - 44 - hr { 45 - border: none; 46 - border-top: 1px solid ${theme.surfaceBorder}; 47 - margin: ${spacing.section} 0; 48 - } 49 - 50 - .highlight-rose { 51 - color: ${theme.rose}; 52 - } 53 61 `); 54 62 55 - export function Footer() { 63 + export default function Footer() { 56 64 return ( 57 - <footer class={footer.scope}> 65 + <Styled.footer> 58 66 <hr /> 59 - <Import styles={[footer]} /> 60 67 <p> 61 68 © {new Date().getFullYear()} <span class="highlight">kyu.re</span> 62 69 {" · "} 63 70 Made with <span class="highlight-rose">❤</span> · Source code available at{" "} 64 - <a href="https://kyu.re/~web">https://kyu.re/~web</a> under the{" "} 65 - <a href="https://spdx.org/licenses/AGPL-3.0-or-later.html"> 71 + <a class="highlight" href="https://kyu.re/~web">https://kyu.re/~web</a> under the{" "} 72 + <a class="highlight" href="https://spdx.org/licenses/AGPL-3.0-or-later.html"> 66 73 GNU Affero General Public License v3.0 67 74 </a> 68 75 , with all site content licensed under{" "} 69 - <a href="https://creativecommons.org/licenses/by-sa/4.0/"> 76 + <a class="highlight" href="https://creativecommons.org/licenses/by-sa/4.0/"> 70 77 CC BY-SA 4.0 71 78 </a> 72 79 . ··· 85 92 </a> 86 93 </li> 87 94 <li> 88 - <button onclick="let t=this.lastChild,n=t.nodeValue;navigator.clipboard.writeText(n.trim());if(!this.dataset.t){this.dataset.t=n;t.nodeValue=' copied to clipboard';setTimeout(()=>{t.nodeValue=this.dataset.t;delete this.dataset.t},3000)}"> 95 + <button onclick="let t=this.lastChild,n=t.nodeValue;if(!this.dataset.t){navigator.clipboard.writeText(n.trim());this.dataset.t=n;t.nodeValue=' copied!';setTimeout(()=>{t.nodeValue=this.dataset.t;delete this.dataset.t},2000)}"> 89 96 <Fluxer /> 90 97 queen#0001 91 98 </button> 92 99 </li> 93 - 94 100 <li> 95 101 <button 96 - data-mail="bWFpbHRvOmtAa3l1LnJlCg==" 102 + data-mail="bWFpbHRvOmtAa3l1LnJl" 97 103 onclick="window.open(atob(this.dataset.mail),'_blank','noopener')" 98 104 > 99 105 <Mail /> Email address 100 106 </button> 101 107 </li> 102 108 </ul> 103 - </footer> 109 + </Styled.footer> 104 110 ); 105 111 }
+39 -29
src/components/Heading.tsx src/components/home/Heading.tsx
··· 1 - import { css, Import } from "~/mech/css.ts"; 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { css } from "~/lib/css.ts"; 2 7 import { ease, fontFamily, fontSize, spacing, theme } from "~/layout.tsx"; 3 8 4 9 const repeat = (input: string, count: number, delim: string = ",") => Array(count).fill(input).join(delim); ··· 11 16 lesbian: ["#c46a4f", "#e09f7a", "#e7e0e7", "#cca1be", "#965275"], 12 17 } as const satisfies FlagPalette; 13 18 14 - const palette: string = Object.values(flags) 19 + const palette = Object.values(flags) 15 20 .flatMap((colors) => colors.flatMap((color) => [color, color])) 16 21 .join(", "); 17 22 18 - const style = css(` 23 + const Styled = css(` 19 24 :scope { 20 - margin-bottom: ${spacing.section}; 21 - margin-top: ${spacing[6]}; 22 25 display: flex; 23 26 flex-direction: column; 24 27 width: 100%; 28 + margin-top: ${spacing[6]}; 29 + margin-bottom: ${spacing.section}; 25 30 } 26 31 27 32 .typography-stack { 28 33 position: relative; 29 34 display: flex; 30 35 flex-direction: column; 31 - line-height: 0.85; 32 36 font-family: ${fontFamily.heading}; 37 + line-height: 0.85; 33 38 } 34 39 35 40 .first-name { 36 - font-size: calc(${fontSize["2xl"]} * 1.75); 41 + display: block; 42 + width: fit-content; 43 + color: ${theme.text}; 37 44 font-weight: 800; 45 + font-size: calc(${fontSize["2xl"]} * 1.75); 38 46 letter-spacing: ${spacing.letter.tight}; 39 - color: ${theme.text}; 40 - width: fit-content; 41 47 } 42 48 43 49 .connecting-arrow { 44 50 position: absolute; 45 - left: -1rem; 46 51 top: 3rem; 52 + left: -1rem; 47 53 width: 6.48rem; 48 54 height: 4.05rem; 49 55 color: ${theme.text}; 50 - pointer-events: none; 51 - transition: color ${ease.fast}, opacity ${ease.fast}; 52 56 filter: url(#ds-chalk); 57 + pointer-events: none; 53 58 animation: boil 2s steps(1) infinite; 59 + transition: color ${ease.fast}, opacity ${ease.fast}; 54 60 } 55 61 56 - .connecting-arrow g { opacity: 0; } 62 + .connecting-arrow g { 63 + opacity: 0; 64 + } 65 + 57 66 .connecting-arrow[data-frame="1"] .f1 { opacity: 1; } 58 67 .connecting-arrow[data-frame="2"] .f2 { opacity: 1; } 59 68 .connecting-arrow[data-frame="3"] .f3 { opacity: 1; } ··· 61 70 .connecting-arrow[data-frame="5"] .f5 { opacity: 1; } 62 71 63 72 .second-name { 64 - font-size: calc(${fontSize["2xl"]} * 1.5); 65 - font-weight: 800; 66 - 73 + display: block; 74 + width: fit-content; 67 75 margin-left: ${spacing[18]}; 76 + font-weight: 800; 77 + font-size: calc(${fontSize["2xl"]} * 1.5); 68 78 letter-spacing: ${spacing.letter.tight}; 69 - width: fit-content; 70 79 71 80 background: repeating-linear-gradient(-90deg, ${repeat(theme.text, 32)}, ${palette}, ${repeat(theme.text, 32)}); 72 81 background-size: 1000% 100%; ··· 86 95 } 87 96 88 97 @keyframes identity-animation { 89 - 0% { background-position: 0% 50%; } 98 + 0% { background-position: 0% 50%; } 90 99 100% { background-position: -900% 50%; } 91 100 } 92 101 `); ··· 104 113 stroke-linejoin="round" 105 114 aria-hidden="true" 106 115 xmlns="http://www.w3.org/2000/svg" 107 - version="1.1" 108 - xmlns:xlink="http://www.w3.org/1999/xlink" 109 116 > 110 117 <defs> 111 118 <filter id="ds-chalk" x="-20%" y="-20%" width="140%" height="140%"> ··· 148 155 } 149 156 150 157 const useBoilDriver = ` 151 - const el = document.currentScript.previousElementSibling; 152 - el.addEventListener("animationiteration", () => { 153 - let frame = parseInt(el.getAttribute("data-frame") || "1"); 154 - el.setAttribute("data-frame", (frame % 5) + 1); 155 - }); 158 + (() => { 159 + const el = document.currentScript.parentElement.querySelector(".connecting-arrow"); 160 + if (!el) return; 161 + 162 + el.addEventListener("animationiteration", (e) => { 163 + if (e.animationName !== "boil") return; 164 + const frame = parseInt(el.getAttribute("data-frame") || "1", 10); 165 + el.setAttribute("data-frame", (frame % 5) + 1); 166 + }); 167 + })(); 156 168 `; 157 169 158 170 export default function Heading() { 159 171 return ( 160 - <header class={style.scope}> 161 - <Import styles={[style]} /> 162 - 172 + <Styled.header> 163 173 <h1 class="typography-stack"> 164 174 <span class="first-name">Júlia</span> 165 175 <Pointy /> 166 176 <script dangerouslySetInnerHTML={{ __html: useBoilDriver }} /> 167 177 <span class="second-name">Lívia</span> 168 178 </h1> 169 - </header> 179 + </Styled.header> 170 180 ); 171 181 }
src/components/Icon.tsx src/components/ui/Icon.tsx
-62
src/components/KVTable.tsx
··· 1 - /** 2 - * Copyright (c) 2025 adoravel 3 - * SPDX-License-Identifier: AGPL-3.0-or-later 4 - */ 5 - 6 - import { css, Import } from "~/mech/css.ts"; 7 - import { ease, fontSize, spacing, theme } from "~/layout.tsx"; 8 - 9 - const table = css(` 10 - :scope { 11 - border: 1px solid ${theme.surfaceBorder}; 12 - } 13 - 14 - .kv-row { 15 - display: grid; 16 - grid-template-columns: minmax(80px, 140px) 1fr; 17 - border-bottom: 1px solid ${theme.surfaceBorder}; 18 - transition: background ${ease.fast}; 19 - } 20 - 21 - .kv-row:last-child { border-bottom: none; } 22 - 23 - .kv-row:hover { background: ${theme.surface}; } 24 - 25 - .kv-key, .kv-val { 26 - padding: ${spacing[3]} ${spacing[4]}; 27 - line-height: 1; 28 - font-family: inherit; 29 - } 30 - 31 - .kv-key { 32 - font-size: ${fontSize.xs}; 33 - letter-spacing: ${spacing.letter.plus}; 34 - color: ${theme.textMuted}; 35 - border-right: 1px solid ${theme.surfaceBorder}; 36 - font-weight: bold; 37 - text-transform: uppercase; 38 - } 39 - 40 - .kv-val { 41 - font-size: ${fontSize.sm}; 42 - color: ${theme.accent}; 43 - } 44 - `); 45 - 46 - export interface KVTableProps { 47 - data: Record<string, [string, string]>; 48 - } 49 - 50 - export default function KVTable({ data }: KVTableProps) { 51 - return ( 52 - <div class={table.scope}> 53 - <Import styles={[table]} /> 54 - {Object.entries(data).map(([key, [name, url]]) => ( 55 - <div key={key} class="kv-row"> 56 - <span class="kv-key">{key}</span> 57 - <a class="kv-val link" href={url} target="_blank" rel="noopener noreferrer">{name}</a> 58 - </div> 59 - ))} 60 - </div> 61 - ); 62 - }
-196
src/components/MediaItem.tsx
··· 1 - /** 2 - * Copyright (c) 2025 adoravel 3 - * SPDX-License-Identifier: AGPL-3.0-or-later 4 - */ 5 - 6 - import { css, Import } from "~/mech/css.ts"; 7 - import { boundaries, fontSize, radius, spacing, theme } from "~/layout.tsx"; 8 - 9 - export interface MediaItemProps { 10 - header: string; 11 - album: string; 12 - artist: string; 13 - releaseDate: string; 14 - url: string; 15 - tag?: string; 16 - coverUrl?: string; 17 - } 18 - 19 - const styles = css(` 20 - :scope { 21 - display: flex; 22 - flex-direction: column; 23 - background: ${theme.base}; 24 - border: 1px solid ${theme.baseBorder}; 25 - border-radius: ${radius.lg}; 26 - font-size: ${fontSize.sm}; 27 - color: ${theme.subtext}; 28 - width: 100%; 29 - } 30 - 31 - .listen-link { 32 - margin-left: auto; 33 - gap: ${spacing[1]}; 34 - font-size: ${fontSize.xs}; 35 - color: ${theme.textMuted}; 36 - } 37 - 38 - .listen-link::after { 39 - background-color: ${theme.textMuted} !important; 40 - } 41 - 42 - .listen-link:hover { 43 - &::after { 44 - background-color: ${theme.accent} !important; 45 - } 46 - color: ${theme.accent}; 47 - } 48 - 49 - .header { 50 - display: flex; 51 - align-items: center; 52 - border-bottom: 1px solid ${theme.baseBorder}; 53 - padding: ${spacing[2]} ${spacing[4]}; 54 - gap: ${spacing[2]}; 55 - } 56 - 57 - .release-date { 58 - display: flex; 59 - align-items: center; 60 - font-size: ${fontSize.xs}; 61 - color: ${theme.textMuted}; 62 - letter-spacing: ${spacing.letter.plus}; 63 - } 64 - 65 - .body { 66 - display: flex; 67 - align-items: flex-start; 68 - padding: ${spacing[4]}; 69 - gap: ${spacing[4]}; 70 - 71 - @media (max-width: ${boundaries.mobileMaxWidth}) { 72 - align-items: center; 73 - text-align: center; 74 - flex-direction: column; 75 - } 76 - } 77 - 78 - .art { 79 - width: 64px; 80 - height: 64px; 81 - overflow: hidden; 82 - flex-shrink: 0; 83 - background: ${theme.base}; 84 - border: 1px solid ${theme.baseBorder}; 85 - border-radius: ${radius.art}; 86 - } 87 - 88 - .info-tag { 89 - margin-left: 1ch; 90 - color: ${theme.textMuted}; 91 - } 92 - 93 - .default-pattern { 94 - width: 64px; 95 - height: 64px; 96 - display: grid; 97 - grid-template-columns: repeat(4, 1fr); 98 - grid-template-rows: repeat(4, 1fr); 99 - } 100 - 101 - .art-px { display: block } 102 - .art-px-0 { background: ${theme.base} } 103 - .art-px-1 { background: ${theme.accent} opacity: 0.85; } 104 - .art-px-2 { background: ${theme.onAccent} opacity: 0.9; } 105 - .art-px-3 { background: ${theme.textMuted} opacity: 0.25; } 106 - .art-px-4 { background: ${theme.surfaceBorder} } 107 - 108 - .art[data-loaded] > .default-pattern { 109 - display: none; 110 - } 111 - 112 - .info { 113 - display: flex; 114 - flex-direction: column; 115 - line-height: 1.75; 116 - } 117 - 118 - .info-title { 119 - font-size: ${fontSize.md}; 120 - color: ${theme.text}; 121 - font-weight: 500; 122 - } 123 - 124 - .info-artist { 125 - font-size: ${fontSize.sm}; 126 - color: ${theme.textMuted}; 127 - } 128 - 129 - .info-artist > span { 130 - colo: ${theme.textMuted}; 131 - } 132 - 133 - .art-cover { 134 - width: 64px; 135 - height: 64px; 136 - object-fit: cover; 137 - } 138 - `); 139 - 140 - const ART_PATTERN = [ 141 - [0, 1, 2, 0], 142 - [1, 4, 1, 3], 143 - [2, 1, 3, 1], 144 - [0, 3, 1, 2], 145 - ] as const; 146 - 147 - function Art({ url }: { url: string | undefined }) { 148 - return ( 149 - <div class="art"> 150 - <img class="art-cover" alt="" src={url} onload="this.parentNode.dataset.loaded=''" onerror="this.remove()" /> 151 - <div class="default-pattern"> 152 - {ART_PATTERN.flat().map((n) => <span class={`art-px art-px-${n}`} />)} 153 - </div> 154 - </div> 155 - ); 156 - } 157 - 158 - export function MediaItem({ 159 - header, 160 - artist, 161 - album, 162 - releaseDate, 163 - coverUrl, 164 - url, 165 - tag, 166 - }: MediaItemProps) { 167 - return ( 168 - <> 169 - <Import styles={[styles]} /> 170 - <div class={styles.scope}> 171 - <div class="header"> 172 - <div class="release-date"> 173 - {releaseDate} 174 - </div> 175 - <a class="listen-link link" href={url} target="_blank" rel="noopener noreferrer"> 176 - listen 177 - </a> 178 - </div> 179 - <a class="body" href={url} target="_blank" rel="noopener noreferrer"> 180 - <Art url={coverUrl} /> 181 - <div class="info"> 182 - <div class="info-title"> 183 - {header} 184 - {tag && <span class="info-tag">{tag}</span>} 185 - </div> 186 - <div class="info-artist"> 187 - {album && <span>{album}</span>} 188 - <br /> 189 - {artist} 190 - </div> 191 - </div> 192 - </a> 193 - </div> 194 - </> 195 - ); 196 - }
-69
src/components/NavigationBar.tsx
··· 1 - /** 2 - * Copyright (c) 2025 adoravel 3 - * SPDX-License-Identifier: AGPL-3.0-or-later 4 - */ 5 - 6 - import { css, Import } from "~/mech/css.ts"; 7 - import { fontSize, spacing, theme } from "~/layout.tsx"; 8 - 9 - const style = css(` 10 - :scope { 11 - display: flex; 12 - flex-wrap: wrap; 13 - align-items: center; 14 - justify-content: center; 15 - margin-bottom: ${spacing.section}; 16 - } 17 - 18 - .itemList { 19 - display: flex; 20 - flex-wrap: wrap; 21 - list-style: none; 22 - font-weight: 500; 23 - width: fit-content; 24 - padding: ${spacing[1]}; 25 - gap: ${spacing[1]}; 26 - font-size: ${fontSize.md}; 27 - background-color: ${theme.surface}; 28 - border: 1px solid ${theme.surfaceBorder}; 29 - border-radius: 1000px; 30 - } 31 - 32 - .selected > .item { 33 - background-color: ${theme.accent}; 34 - color: ${theme.background}; 35 - font-weight: 600; 36 - border-radius: 1000px; 37 - } 38 - 39 - .item { 40 - color: ${theme.subtext}; 41 - padding: ${spacing[2]} ${spacing[4]}; 42 - line-height: 1; 43 - display: block; 44 - } 45 - `); 46 - 47 - interface Props<T extends readonly [string, string][]> { 48 - items: T; 49 - selected: T[number][0]; 50 - } 51 - 52 - export function NavigationBar<P extends readonly [string, string][]>({ items, selected }: Props<P>) { 53 - return ( 54 - <> 55 - <Import styles={[style]} /> 56 - <nav class={style.scope}> 57 - <ul class="itemList"> 58 - {items.map(([name, href]) => ( 59 - <li class={name === selected ? "selected" : undefined}> 60 - {name === selected 61 - ? <a href={href} class="item" data-current="true" aria-current="page">{name}</a> 62 - : <a href={href} class="item">{name}</a>} 63 - </li> 64 - ))} 65 - </ul> 66 - </nav> 67 - </> 68 - ); 69 - }
-174
src/components/Projects.tsx
··· 1 - /** 2 - * Copyright (c) 2025 adoravel 3 - * SPDX-License-Identifier: AGPL-3.0-or-later 4 - */ 5 - 6 - import { css, Import } from "~/mech/css.ts"; 7 - import { boundaries, ease, elevation, fontSize, radius, spacing, theme } from "~/layout.tsx"; 8 - import { ExternalLink, License } from "~/components/Icon.tsx"; 9 - 10 - export interface Project { 11 - author: string; 12 - name: string; 13 - description: string; 14 - lang: ProjectLanguage; 15 - license: string; 16 - url: string; 17 - } 18 - 19 - export enum ProjectLanguage { 20 - TypeScript = 0x3178c6, 21 - C = 0x555, 22 - Scala = 0xc22d40, 23 - } 24 - 25 - export interface ProjectsProps { 26 - projects: Project[]; 27 - } 28 - 29 - const projects = css(` 30 - :scope { 31 - list-style: none; 32 - padding-left: 0; 33 - display: grid; 34 - grid-template-columns: repeat(2, minmax(0, 1fr)); 35 - 36 - @media (max-width: ${boundaries.mobileMaxWidth}) { 37 - grid-template-columns: 1fr; 38 - } 39 - 40 - margin-top: ${spacing[2]}; 41 - gap: ${spacing[2]}; 42 - } 43 - 44 - .project-card { 45 - display: flex; 46 - flex-direction: column; 47 - text-decoration: none; 48 - position: relative; 49 - overflow: hidden; 50 - height: 100%; 51 - color: ${theme.text}; 52 - background-color: ${theme.base}; 53 - border: 1px solid ${theme.baseBorder}; 54 - padding: ${spacing[3]} ${spacing[4]}; 55 - border-radius: ${radius.lg}; 56 - line-height: ${boundaries.lineHeight + .25}; 57 - } 58 - 59 - .project-card:hover { 60 - background-color: ${theme.surfaceHover}; 61 - border-color: ${theme.surfaceBorderHover}; 62 - 63 - &::before { 64 - transform: scale(1.125); 65 - opacity: 1; 66 - } 67 - } 68 - 69 - .project-card::before { 70 - display: flex; 71 - position: absolute; 72 - left: 0; 73 - top: -7rem; 74 - content: "</;^//"; 75 - color: transparent; 76 - font-size: 32rem; 77 - letter-spacing: -0.075em; 78 - line-height: 0.95; 79 - text-align: center; 80 - align-items: center; 81 - justify-content: center; 82 - transition: transform ${ease.fast}; 83 - -webkit-text-stroke: 2px ${theme.baseBorder}; 84 - opacity: 0.32; 85 - } 86 - 87 - .external-icon { 88 - position: absolute; 89 - color: ${theme.subtext}; 90 - top: ${spacing[3]}; 91 - right: ${spacing[3]}; 92 - opacity: 33%; 93 - } 94 - 95 - .license { 96 - font-size: ${fontSize.sm}; 97 - color: ${theme.subtext}; 98 - line-height: 1; 99 - opacity: 0.5; 100 - } 101 - 102 - .license > svg { 103 - vertical-align: bottom; 104 - margin-right: 0.5ch; 105 - } 106 - 107 - .author { 108 - font-size: ${fontSize.md}; 109 - margin-bottom: ${spacing[1]}; 110 - } 111 - 112 - .description { 113 - font-size: ${fontSize.sm}; 114 - color: ${theme.subtext}; 115 - margin-bottom: ${spacing[3]}; 116 - overflow: hidden; 117 - display: -webkit-box; 118 - -webkit-box-orient: vertical; 119 - -webkit-line-clamp: 2; 120 - } 121 - 122 - .description, .info, .author { 123 - position: relative; 124 - z-index: ${elevation.base}; 125 - } 126 - 127 - .info { 128 - display: flex; 129 - align-items: center; 130 - gap: ${spacing[2]}; 131 - font-size: ${fontSize.xs}; 132 - color: ${theme.subtext}; 133 - margin-top: auto; 134 - } 135 - 136 - .language { 137 - width: 10px; 138 - height: 10px; 139 - border-radius: ${radius.circle}; 140 - background-color: var(--lang-colour, #ccc); 141 - display: inline-block; 142 - } 143 - `); 144 - 145 - export default function Projects(props: ProjectsProps) { 146 - if (!props.projects) return; 147 - 148 - return ( 149 - <ul class={projects.scope}> 150 - <Import styles={[projects]} /> 151 - {props.projects.map((project) => ( 152 - <li> 153 - <a class="project-card" href={project.url} target="_blank" rel="noopener noreferrer"> 154 - <span class="external-icon" aria-label="Open externally"> 155 - <ExternalLink size={18} /> 156 - </span> 157 - <div class="author"> 158 - <strong>{project.author}</strong>/{project.name} 159 - </div> 160 - <p class="description">{project.description}</p> 161 - <div class="info"> 162 - <span class="language" style={"--lang-colour: #" + project.lang.toString(16)}></span> 163 - {ProjectLanguage[project.lang]} 164 - <div class="license" aria-label="License"> 165 - <License size={12} /> 166 - <span>{project.license}</span> 167 - </div> 168 - </div> 169 - </a> 170 - </li> 171 - ))} 172 - </ul> 173 - ); 174 - }
-26
src/components/SectionTitle.tsx
··· 1 - /** 2 - * Copyright (c) 2025 adoravel 3 - * SPDX-License-Identifier: AGPL-3.0-or-later 4 - */ 5 - 6 - import { css, Import } from "~/mech/css.ts"; 7 - 8 - const style = css(` 9 - :scope { 10 - font-size: var(--font-size-md); 11 - font-weight: bold; 12 - position: relative; 13 - display: flex; 14 - align-items: center; 15 - color: var(--theme-foreground); 16 - } 17 - `); 18 - 19 - export default function SectionTitle({ children, class: className }: { children: string; class?: string }) { 20 - return ( 21 - <h2 class={style.scope + (className ? ` ${className}` : "")}> 22 - <Import styles={[style]} /> 23 - {children} 24 - </h2> 25 - ); 26 - }
+142
src/components/content/FeaturedSong.tsx
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { css } from "~/lib/css.ts"; 7 + import { boundaries, fontFamily, fontSize, radius, spacing, theme } from "~/layout.tsx"; 8 + import { Song } from "~/content/songs.ts"; 9 + import { SongArt } from "~/components/content/SongArt.tsx"; 10 + 11 + interface Props extends Song {} 12 + 13 + const Styled = css(` 14 + :scope { 15 + display: flex; 16 + flex-direction: column; 17 + width: 100%; 18 + background: ${theme.base}; 19 + border: 1px solid ${theme.baseBorder}; 20 + border-radius: ${radius.lg}; 21 + font-size: ${fontSize.sm}; 22 + color: ${theme.subtext}; 23 + 24 + font-family: ${fontFamily.misc}; 25 + letter-spacing: ${spacing.letter.misc}; 26 + } 27 + 28 + .header { 29 + display: flex; 30 + align-items: center; 31 + line-height: 1; 32 + padding: ${spacing[4]} ${spacing[4]}; 33 + gap: ${spacing[2]}; 34 + border-bottom: 1px solid ${theme.baseBorder}; 35 + } 36 + 37 + .release-date { 38 + display: flex; 39 + align-items: center; 40 + color: ${theme.textMuted}; 41 + letter-spacing: ${spacing.letter.plus}; 42 + } 43 + 44 + .listen-link { 45 + margin-left: auto; 46 + gap: ${spacing[1]}; 47 + color: ${theme.textMuted}; 48 + font-size: ${fontSize.xs}; 49 + } 50 + 51 + .listen-link::after { 52 + background-color: ${theme.textMuted} !important; 53 + margin-right: 0; 54 + margin-left: 8px; 55 + } 56 + 57 + .listen-link:hover { 58 + color: ${theme.accent}; 59 + 60 + &::after { 61 + background-color: ${theme.accent} !important; 62 + } 63 + } 64 + 65 + .body { 66 + display: flex; 67 + align-items: flex-start; 68 + padding: ${spacing[4]}; 69 + gap: ${spacing[4]}; 70 + text-decoration: none; 71 + color: inherit; 72 + 73 + @media (max-width: ${boundaries.mobileMaxWidth}) { 74 + flex-direction: column; 75 + align-items: center; 76 + text-align: center; 77 + } 78 + } 79 + 80 + .info { 81 + display: flex; 82 + flex-direction: column; 83 + line-height: 1.75; 84 + } 85 + 86 + .info-title { 87 + color: ${theme.text}; 88 + font-weight: 500; 89 + font-size: ${fontSize.md}; 90 + } 91 + 92 + .info-tag { 93 + margin-left: 1ch; 94 + color: ${theme.textMuted}; 95 + } 96 + 97 + .info-artist { 98 + color: ${theme.textMuted}; 99 + font-size: ${fontSize.sm}; 100 + } 101 + 102 + .info-artist > span { 103 + color: ${theme.textMuted}; 104 + } 105 + `); 106 + 107 + export default function FeaturedSong({ 108 + title, 109 + artist, 110 + album, 111 + releaseDate, 112 + coverUrl, 113 + url, 114 + tag, 115 + }: Props) { 116 + return ( 117 + <Styled.div> 118 + <div class="header"> 119 + <div class="release-date"> 120 + {releaseDate} 121 + </div> 122 + <a class="listen-link link" href={url} target="_blank" rel="noopener noreferrer"> 123 + listen 124 + </a> 125 + </div> 126 + <a class="body" href={url} target="_blank" rel="noopener noreferrer"> 127 + <SongArt url={coverUrl} /> 128 + <div class="info"> 129 + <div class="info-title"> 130 + {title} 131 + {tag && <span class="info-tag">{tag}</span>} 132 + </div> 133 + <div class="info-artist"> 134 + {album && <span>{album}</span>} 135 + <br /> 136 + {artist} 137 + </div> 138 + </div> 139 + </a> 140 + </Styled.div> 141 + ); 142 + }
+166
src/components/content/Projects.tsx
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { Project, ProjectLanguage } from "~/content/projects.ts"; 7 + import { css } from "~/lib/css.ts"; 8 + import { boundaries, ease, elevation, fontFamily, fontSize, radius, spacing, theme } from "~/layout.tsx"; 9 + import { ExternalLink, License } from "~/components/ui/Icon.tsx"; 10 + 11 + interface Props { 12 + projects: Project[]; 13 + } 14 + 15 + const Styled = css(` 16 + :scope { 17 + display: grid; 18 + grid-template-columns: repeat(2, minmax(0, 1fr)); 19 + gap: ${spacing[2]}; 20 + margin-top: ${spacing[2]}; 21 + padding-left: 0; 22 + list-style: none; 23 + 24 + font-family: ${fontFamily.misc}; 25 + letter-spacing: ${spacing.letter.misc}; 26 + 27 + @media (max-width: ${boundaries.mobileMaxWidth}) { 28 + grid-template-columns: 1fr; 29 + } 30 + } 31 + 32 + .project-card { 33 + position: relative; 34 + display: flex; 35 + flex-direction: column; 36 + height: 100%; 37 + padding: ${spacing[3]} ${spacing[4]}; 38 + color: ${theme.text}; 39 + background-color: ${theme.base}; 40 + border: 1px solid ${theme.baseBorder}; 41 + border-radius: ${radius.lg}; 42 + line-height: ${boundaries.lineHeight + 0.25}; 43 + text-decoration: none; 44 + overflow: hidden; 45 + } 46 + 47 + .project-card:hover { 48 + background-color: ${theme.surfaceHover}; 49 + border-color: ${theme.surfaceBorderHover}; 50 + 51 + &::before { 52 + transform: scale(1.125); 53 + opacity: 1; 54 + } 55 + } 56 + 57 + .project-card::before { 58 + position: absolute; 59 + top: -7rem; 60 + left: 0; 61 + display: flex; 62 + align-items: center; 63 + justify-content: center; 64 + width: 100%; 65 + content: "</;^//"; 66 + color: transparent; 67 + font-size: 32rem; 68 + letter-spacing: -0.075em; 69 + line-height: 0.95; 70 + text-align: center; 71 + opacity: 0.32; 72 + -webkit-text-stroke: 2px ${theme.baseBorder}; 73 + transition: transform ${ease.fast}; 74 + } 75 + 76 + .external-icon { 77 + position: absolute; 78 + top: ${spacing[3]}; 79 + right: ${spacing[3]}; 80 + color: ${theme.subtext}; 81 + opacity: 0.33; 82 + } 83 + 84 + .author { 85 + font-size: ${fontSize.md}; 86 + margin-bottom: ${spacing[1]}; 87 + } 88 + 89 + .description { 90 + font-size: ${fontSize.sm}; 91 + color: ${theme.subtext}; 92 + margin-bottom: ${spacing[3]}; 93 + overflow: hidden; 94 + display: -webkit-box; 95 + -webkit-box-orient: vertical; 96 + -webkit-line-clamp: 2; 97 + } 98 + 99 + .author, .description, .info { 100 + position: relative; 101 + z-index: ${elevation.base}; 102 + } 103 + 104 + .info { 105 + display: flex; 106 + align-items: center; 107 + gap: ${spacing[2]}; 108 + margin-top: auto; 109 + color: ${theme.subtext}; 110 + font-size: ${fontSize.xs}; 111 + } 112 + 113 + .language { 114 + display: inline-block; 115 + width: 10px; 116 + height: 10px; 117 + background-color: var(--lang-colour, #ccc); 118 + border-radius: ${radius.circle}; 119 + } 120 + 121 + .license { 122 + display: inline-flex; 123 + align-items: center; 124 + color: ${theme.subtext}; 125 + font-size: ${fontSize.sm}; 126 + opacity: 0.5; 127 + } 128 + 129 + .license > svg { 130 + margin-right: 0.5ch; 131 + } 132 + `); 133 + 134 + export default function Projects({ projects }: Props) { 135 + if (!projects?.length) return; 136 + 137 + return ( 138 + <Styled.ul> 139 + {projects.map((project) => { 140 + const langColour = project.lang.toString(16).padStart(6, "0"); 141 + 142 + return ( 143 + <li key={project.url}> 144 + <a class="project-card" href={project.url} target="_blank" rel="noopener noreferrer"> 145 + <span class="external-icon" aria-label="Open externally"> 146 + <ExternalLink size={18} /> 147 + </span> 148 + <div class="author"> 149 + <strong>{project.author}</strong>/{project.name} 150 + </div> 151 + <p class="description">{project.description}</p> 152 + <div class="info"> 153 + <span class="language" style={{ "--lang-colour": `#${langColour}` }}></span> 154 + {ProjectLanguage[project.lang]} 155 + <div class="license" aria-label="License"> 156 + <License size={12} /> 157 + <span>{project.license}</span> 158 + </div> 159 + </div> 160 + </a> 161 + </li> 162 + ); 163 + })} 164 + </Styled.ul> 165 + ); 166 + }
+152
src/components/content/RecentTracks.tsx
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { boundaries, ease, elevation, fontFamily, fontSize, radius, spacing, theme } from "~/layout.tsx"; 7 + import { css } from "~/lib/css.ts"; 8 + import { tracks } from "~/services/lastfm.ts"; 9 + import { SongArt } from "~/components/content/SongArt.tsx"; 10 + 11 + export interface RecentTracksProps { 12 + tracks: NonNullable<ReturnType<typeof tracks>>; 13 + cutoff: number; 14 + profileUrl: string; 15 + } 16 + 17 + const Styled = css(` 18 + :scope { 19 + display: grid; 20 + grid-template-columns: repeat(auto-fill, minmax(120px, 160px)); 21 + gap: ${spacing[6]}; 22 + margin: 0; 23 + margin-top: 0.5rem !important; 24 + padding: 0; 25 + list-style: none; 26 + 27 + font-family: ${fontFamily.misc}; 28 + letter-spacing: ${spacing.letter.misc}; 29 + } 30 + 31 + li > a { 32 + display: flex; 33 + flex-direction: column; 34 + align-items: center; 35 + text-align: center; 36 + gap: ${spacing[3]}; 37 + max-width: ${boundaries.avatarSize}; 38 + } 39 + 40 + .cover { 41 + width: ${boundaries.avatarSize}; 42 + height: ${boundaries.avatarSize}; 43 + border-radius: ${radius.art}; 44 + background: ${theme.lift}; 45 + object-fit: cover; 46 + } 47 + 48 + .meta { 49 + display: flex; 50 + flex-direction: column; 51 + font-size: ${fontSize.md}; 52 + color: ${theme.subtext}; 53 + } 54 + 55 + .meta strong { 56 + font-weight: 600; 57 + font-size: ${fontSize.base}; 58 + color: ${theme.text}; 59 + } 60 + 61 + .loved { 62 + color: ${theme.accent}; 63 + } 64 + 65 + .meta strong, 66 + .meta span { 67 + display: -webkit-box; 68 + -webkit-box-orient: vertical; 69 + -webkit-line-clamp: 2; 70 + overflow: hidden; 71 + word-break: break-word; 72 + text-overflow: ellipsis; 73 + max-width: 100%; 74 + } 75 + 76 + .fm-more { 77 + position: relative; 78 + display: flex; 79 + flex-direction: column; 80 + align-items: center; 81 + justify-content: center; 82 + overflow: hidden; 83 + width: 100%; 84 + height: 100%; 85 + opacity: 0.75; 86 + 87 + background: ${theme.base}; 88 + border: 1px solid ${theme.baseBorder}; 89 + border-radius: ${radius.art}; 90 + font-size: ${fontSize.sm}; 91 + color: ${theme.text}; 92 + padding: ${spacing[3]}; 93 + z-index: ${elevation.base}; 94 + transition: opacity ${ease.fast}; 95 + 96 + @media (max-width: 930px) { 97 + display: none; 98 + } 99 + } 100 + 101 + .fm-more:hover { 102 + opacity: 1; 103 + } 104 + 105 + .fm-more::before { 106 + content: "→"; 107 + position: absolute; 108 + color: transparent; 109 + font-size: 15rem; 110 + -webkit-text-stroke: 2px ${theme.surfaceBorder}; 111 + z-index: ${elevation.below}; 112 + } 113 + `); 114 + 115 + function TrackItem({ track }: { track: RecentTracksProps["tracks"][number] }) { 116 + const { url, coverUrl, title, artist, playing, loved } = track; 117 + const showStatus = playing || loved; 118 + 119 + return ( 120 + <li class="fm-recent"> 121 + <a href={url}> 122 + <SongArt class="cover" url={coverUrl} /> 123 + <div class="meta"> 124 + <strong class="title">{title}</strong> 125 + <span>{artist}</span> 126 + 127 + {showStatus && ( 128 + <span class="loved"> 129 + {playing && <span title="Now playing">▷</span>} 130 + {loved && <span class="loved" title="Loved">❤</span>} 131 + </span> 132 + )} 133 + </div> 134 + </a> 135 + </li> 136 + ); 137 + } 138 + 139 + export default function RecentTracks({ tracks, cutoff, profileUrl }: RecentTracksProps) { 140 + const content = cutoff !== undefined ? tracks.slice(0, cutoff) : tracks; 141 + 142 + return ( 143 + <Styled.ul> 144 + {content.map((track) => <TrackItem key={track.url} track={track} />)} 145 + <li> 146 + <a class="fm-more" href={profileUrl}> 147 + Check out more on Last.fm 148 + </a> 149 + </li> 150 + </Styled.ul> 151 + ); 152 + }
+102
src/components/content/SongArt.tsx
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { css } from "~/lib/css.ts"; 7 + import { radius, theme } from "~/layout.tsx"; 8 + 9 + const ART_PATTERN = [ 10 + [0, 1, 2, 0], 11 + [1, 4, 1, 3], 12 + [2, 1, 3, 1], 13 + [0, 3, 1, 2], 14 + ] as const; 15 + 16 + const randomisePattern = (): number[][] => { 17 + const pool = ART_PATTERN.flat(); 18 + 19 + const size = ART_PATTERN.length; 20 + const grid: number[][] = Array.from({ length: size }, () => []); 21 + 22 + for (let r = 0; r < size; r++) { 23 + for (let c = 0; c < size; c++) { 24 + const leftNeighbor = c > 0 ? grid[r][c - 1] : null; 25 + const topNeighbor = r > 0 ? grid[r - 1][c] : null; 26 + 27 + const validIndices: number[] = []; 28 + for (let i = 0; i < pool.length; i++) { 29 + if (pool[i] !== leftNeighbor && pool[i] !== topNeighbor) { 30 + validIndices.push(i); 31 + } 32 + } 33 + 34 + const targetIndex = validIndices.length > 0 35 + ? validIndices[Math.floor(Math.random() * validIndices.length)] 36 + : Math.floor(Math.random() * pool.length); 37 + 38 + const [chosenValue] = pool.splice(targetIndex, 1); 39 + grid[r].push(chosenValue); 40 + } 41 + } 42 + 43 + return grid; 44 + }; 45 + 46 + const Styled = css(` 47 + :scope { 48 + position: relative; 49 + width: 64px; 50 + height: 64px; 51 + flex-shrink: 0; 52 + background: ${theme.base}; 53 + border: 1px solid ${theme.baseBorder}; 54 + border-radius: ${radius.art}; 55 + overflow: hidden; 56 + } 57 + 58 + .art-cover { 59 + width: 100%; 60 + height: 100%; 61 + object-fit: cover; 62 + display: block; 63 + } 64 + 65 + .pattern { 66 + position: absolute; 67 + inset: 0; 68 + display: grid; 69 + grid-template-columns: repeat(4, 1fr); 70 + grid-template-rows: repeat(4, 1fr); 71 + } 72 + 73 + .art-px { display: block; } 74 + .art-px-0 { background-color: ${theme.base}; } 75 + .art-px-1 { background-color: ${theme.accent}; opacity: 0.85; } 76 + .art-px-2 { background-color: ${theme.onAccent}; opacity: 0.9; } 77 + .art-px-3 { background-color: ${theme.textMuted}; opacity: 0.25; } 78 + .art-px-4 { background-color: ${theme.surfaceBorder}; } 79 + 80 + .art-cover[data-loaded="true"] + .pattern { 81 + display: none; 82 + } 83 + `); 84 + 85 + export function SongArt({ url, class: className }: { class?: string; url: string | undefined }) { 86 + return ( 87 + <Styled.div class={className ?? ""} data-loaded={!url ? "false" : undefined}> 88 + {url && ( 89 + <img 90 + class="art-cover" 91 + alt="" 92 + src={url} 93 + onload="this.setAttribute('data-loaded', 'true')" 94 + onerror="this.remove()" 95 + /> 96 + )} 97 + <div class="pattern"> 98 + {randomisePattern().flat().map((n, idx) => <span key={idx} class={`art-px art-px-${n}`} />)} 99 + </div> 100 + </Styled.div> 101 + ); 102 + }
+66
src/components/home/ButtonWall.tsx
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { css } from "~/lib/css.ts"; 7 + import { spacing, theme } from "~/layout.tsx"; 8 + import { friends } from "~/content/friends.ts"; 9 + 10 + const Styled = css(` 11 + :scope { 12 + display: flex; 13 + flex-wrap: wrap; 14 + gap: ${spacing[1]}; 15 + } 16 + 17 + a, iframe { 18 + display: inline-block; 19 + text-align: center; 20 + width: 88px; 21 + height: 31px; 22 + image-rendering: pixelated; 23 + background-color: ${theme.lift}; 24 + color: ${theme.text}; 25 + clip-path: polygon( 26 + 0px calc(100% - 2px), 27 + 2px calc(100% - 2px), 28 + 2px 100%, 29 + calc(100% - 2px) 100%, 30 + calc(100% - 2px) calc(100% - 2px), 31 + 100% calc(100% - 2px), 32 + 100% 2px, 33 + calc(100% - 2px) 2px, 34 + calc(100% - 2px) 0px, 35 + 2px 0px, 36 + 2px 2px, 37 + 0px 2px 38 + ); 39 + } 40 + `); 41 + 42 + export default function ButtonWall({ buttons }: { buttons: typeof friends }) { 43 + return ( 44 + <Styled.div> 45 + <iframe width="88" height="31" style="border:none" src="/button.min.html"></iframe> 46 + <a href={buttons["88x31"][0].href} rel="noopener nofollow"> 47 + <img src={buttons["88x31"][0].src} alt={buttons["88x31"][0].alt} width="88" height="31" /> 48 + </a> 49 + {buttons.iframe.map((script: string, idx: number) => ( 50 + <iframe 51 + key={"iframe-" + idx} 52 + width="88" 53 + height="31" 54 + style="border:none" 55 + sandbox="allow-scripts allow-popups" 56 + srcdoc={script} 57 + /> 58 + ))} 59 + {buttons["88x31"].slice(1).map(({ href, src, alt }: any, idx: number) => ( 60 + <a key={idx} href={href} rel="noopener nofollow"> 61 + <img src={src} alt={alt} width="88" height="31" onerror="this.parentElement.remove()" /> 62 + </a> 63 + ))} 64 + </Styled.div> 65 + ); 66 + }
+83
src/components/layout/NavigationBar.tsx
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { css } from "~/lib/css.ts"; 7 + import { fontSize, spacing, theme } from "~/layout.tsx"; 8 + 9 + interface Props<T extends readonly [string, string][]> { 10 + items: T; 11 + selected: T[number][0]; 12 + } 13 + 14 + const Styled = css(` 15 + :scope { 16 + display: flex; 17 + flex-wrap: wrap; 18 + align-items: center; 19 + justify-content: center; 20 + margin-bottom: ${spacing.section}; 21 + } 22 + 23 + ul { 24 + display: flex; 25 + flex-wrap: wrap; 26 + width: fit-content; 27 + padding: ${spacing[1]}; 28 + gap: ${spacing[1]}; 29 + background-color: ${theme.surface}; 30 + border: 1px solid ${theme.surfaceBorder}; 31 + border-radius: 1000px; 32 + list-style: none; 33 + font-weight: 500; 34 + font-size: ${fontSize.md}; 35 + } 36 + 37 + .item { 38 + display: block; 39 + padding: ${spacing[2]} ${spacing[4]}; 40 + color: ${theme.subtext}; 41 + line-height: 1; 42 + text-decoration: none; 43 + border-radius: 1000px; 44 + transition: color 0.15s ease, background-color 0.15s ease; 45 + } 46 + 47 + .item:hover { 48 + color: ${theme.text}; 49 + } 50 + 51 + .selected > .item { 52 + color: ${theme.background}; 53 + background-color: ${theme.accent}; 54 + font-weight: 600; 55 + } 56 + `); 57 + 58 + export function NavigationBar<P extends readonly [string, string][]>({ items, selected }: Props<P>) { 59 + if (!items || items.length === 0) return null; 60 + 61 + return ( 62 + <Styled.nav> 63 + <ul> 64 + {items.map(([name, href]) => { 65 + const isActive = name === selected; 66 + 67 + return ( 68 + <li key={name} class={isActive ? "selected" : undefined}> 69 + <a 70 + href={href} 71 + class="item" 72 + aria-current={isActive ? "page" : undefined} 73 + data-current={isActive ? "true" : undefined} 74 + > 75 + {name} 76 + </a> 77 + </li> 78 + ); 79 + })} 80 + </ul> 81 + </Styled.nav> 82 + ); 83 + }
+54
src/components/ui/CollapsibleParagraph.tsx
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { css } from "~/lib/css.ts"; 7 + import { theme } from "~/layout.tsx"; 8 + 9 + const Styled = css(` 10 + :scope { 11 + display: inline; 12 + } 13 + 14 + .read-more-checkbox { 15 + display: none; 16 + 17 + & ~ .read-more-content { display: none !important; } 18 + & ~ .read-more-label-collapse { display: none; } 19 + 20 + &:checked { 21 + & ~ .read-more-content { display: block !important; } 22 + & ~ .read-more-label-expand { display: none; } 23 + & ~ .read-more-label-collapse { display: inline-block; } 24 + } 25 + } 26 + 27 + .read-more-btn { 28 + margin-left: 1ch; 29 + cursor: pointer; 30 + user-select: none; 31 + color: ${theme.accent}; 32 + } 33 + 34 + .read-more-content { 35 + margin-top: 2ch; 36 + padding: 0 2ch; 37 + border-left: 2px solid ${theme.lift}; 38 + } 39 + `); 40 + 41 + export default function CollapsibleParagraph({ id, children }: { id: string; children: any }) { 42 + return ( 43 + <Styled.span> 44 + <input type="checkbox" id={id} class="read-more-checkbox" autocomplete="off" /> 45 + <label for={id} class="read-more-btn link read-more-label-expand"> 46 + Read more 47 + </label> 48 + <label for={id} class="read-more-btn link read-more-label-collapse"> 49 + Show less 50 + </label> 51 + <div class="read-more-content">{children}</div> 52 + </Styled.span> 53 + ); 54 + }
+25
src/components/ui/Heading.tsx
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { css } from "~/lib/css.ts"; 7 + 8 + const Styled = css(` 9 + :scope { 10 + font-size: var(--font-size-md); 11 + font-weight: bold; 12 + position: relative; 13 + display: flex; 14 + align-items: center; 15 + color: var(--theme-foreground); 16 + } 17 + `); 18 + 19 + export default function Heading({ children, class: className }: { children: any; class?: string }) { 20 + return ( 21 + <Styled.h2 class={className ? ` ${className}` : ""}> 22 + {children} 23 + </Styled.h2> 24 + ); 25 + }
+89
src/components/ui/PropertyTable.tsx
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { css } from "~/lib/css.ts"; 7 + import { ease, fontFamily, fontSize, radius, spacing, theme } from "~/layout.tsx"; 8 + 9 + interface Props { 10 + data: Record<string, [string, string | undefined]>; 11 + } 12 + 13 + const Styled = css(` 14 + :scope { 15 + display: block; 16 + border: 1px solid ${theme.surfaceBorder}; 17 + border-radius: ${radius.lg}; 18 + 19 + font-family: ${fontFamily.misc}; 20 + letter-spacing: ${spacing.letter.misc}; 21 + } 22 + 23 + .kv-row { 24 + display: grid; 25 + grid-template-columns: minmax(80px, 140px) 1fr; 26 + border-bottom: 1px solid ${theme.surfaceBorder}; 27 + transition: background-color ${ease.fast}; 28 + } 29 + 30 + .kv-row:last-child { 31 + border-bottom: none; 32 + } 33 + 34 + .kv-row:hover { 35 + background-color: ${theme.surface}; 36 + } 37 + 38 + .kv-key, .kv-val { 39 + display: flex; 40 + align-items: center; 41 + padding: ${spacing[3]} ${spacing[4]}; 42 + line-height: 1.4; 43 + font-family: inherit; 44 + } 45 + 46 + .kv-key { 47 + color: ${theme.textMuted}; 48 + font-weight: bold; 49 + font-size: ${fontSize.xs}; 50 + text-transform: uppercase; 51 + letter-spacing: ${spacing.letter.plus}; 52 + border-right: 1px solid ${theme.surfaceBorder}; 53 + } 54 + 55 + .kv-val { 56 + color: ${theme.accent}; 57 + font-size: ${fontSize.sm}; 58 + text-decoration: none; 59 + } 60 + 61 + a.kv-val:hover { 62 + text-decoration: underline; 63 + } 64 + 65 + .kv-val.link::after { 66 + margin-left: 8px; 67 + } 68 + `); 69 + 70 + export default function PropertyTable({ data }: Props) { 71 + if (!data || Object.keys(data).length === 0) return null; 72 + 73 + return ( 74 + <Styled.div> 75 + {Object.entries(data).map(([key, [name, url]]) => ( 76 + <div key={key} class="kv-row"> 77 + <span class="kv-key">{key}</span> 78 + {url 79 + ? ( 80 + <a class="kv-val link" href={url} target="_blank" rel="noopener noreferrer"> 81 + {name} 82 + </a> 83 + ) 84 + : <span class="kv-val">{name}</span>} 85 + </div> 86 + ))} 87 + </Styled.div> 88 + ); 89 + }
-97
src/constants.ts
··· 1 - import { Project, ProjectLanguage } from "~/components/Projects.tsx"; 2 - import { MediaItemProps } from "./components/MediaItem.tsx"; 3 - 4 - function art(path: string, size = "512x512bb"): string { 5 - return `https://is1-ssl.mzstatic.com/image/thumb/${path}.jpg/${size}.jpg`; 6 - } 7 - 8 - export const songs: MediaItemProps[] = [ 9 - { 10 - header: "matryoshka", 11 - album: "heartstrings", 12 - artist: "lexycat", 13 - tag: "digicore, plunderphonics", 14 - url: "https://song.link/i/1722427758", 15 - coverUrl: art("Music211/v4/b9/7b/66/b97b6672-7c40-8b1c-fd7b-51e8866c0a6a/cover_198662567310"), 16 - releaseDate: "Released on August 18, 2024", 17 - }, 18 - { 19 - header: "tile floors", 20 - album: "lettinggomakestheheartburnhotter", 21 - artist: "fallingwithscissors", 22 - tag: "experimental metalcore", 23 - url: "https://song.link/i/1681979278", 24 - coverUrl: art("Music116/v4/53/4d/18/534d18da-75d9-5d48-2a14-17256a49c2c9/9b0f51d8-05f2-47a6-ba89-01ad8804828b"), 25 - releaseDate: "Released on April 17, 2023", 26 - }, 27 - { 28 - header: "lose myself in you", 29 - album: "lose myself in you", 30 - artist: "coffret de bijoux", 31 - tag: "atmospheric black metal", 32 - url: "https://song.link/i/1886039227", 33 - coverUrl: art("Music221/v4/39/19/03/39190326-8671-70f7-a488-39ae4d6d74bb/5063959778586_cover"), 34 - releaseDate: "Released on March 9, 2026", 35 - }, 36 - ]; 37 - 38 - export const projects: Project[] = [ 39 - { 40 - author: "w", 41 - name: "snarl", 42 - description: "a minimal web framework for deno", 43 - license: "Apache-2.0", 44 - url: "/~snarl", 45 - lang: ProjectLanguage.TypeScript, 46 - }, 47 - { 48 - author: "w", 49 - name: "ratazana", 50 - description: 51 - "minimal implementation of logitech and razer mouse firmware, repurposing their onboard memory as a covert channel for arbitrary data", 52 - license: "BSD-3-Clause", 53 - url: "/~ratazana", 54 - lang: ProjectLanguage.C, 55 - }, 56 - { 57 - author: "w", 58 - name: "ribbon", 59 - description: "modular client mod for fluxer", 60 - url: "/~ribbon", 61 - license: "EUPL-1.2 & MPL-2.0", 62 - lang: ProjectLanguage.TypeScript, 63 - }, 64 - { 65 - author: "w", 66 - name: "scrobkit", 67 - description: "a minimal CLI toolkit for working with last.fm scrobbles", 68 - url: "/~scrobkit", 69 - license: "BSD-3-Clause", 70 - lang: ProjectLanguage.TypeScript, 71 - }, 72 - { 73 - author: "w", 74 - name: "terracotta", 75 - description: "mill-based toolchain for crossplatform and multi-version minecraft mod development", 76 - url: "/~terracotta", 77 - license: "LGPL-3.0", 78 - lang: ProjectLanguage.Scala, 79 - }, 80 - { 81 - author: "w", 82 - name: "wildcat", 83 - description: "lightweight, minimal, portable, crossplatform, and straightforward game engine inspired by raylib", 84 - license: "BSD-3-Clause", 85 - url: "/~wildcat", 86 - lang: ProjectLanguage.C, 87 - }, 88 - ]; 89 - 90 - export const friends = [ 91 - { href: "https://katelyn.moe/", src: "https://katelyn.moe/8831.png", alt: "katelyn" }, 92 - { href: "https://worf.win", src: "https://worf.win/images/worfwin.gif", alt: "worf.win" }, 93 - // { href: "https://urwq.moe", src: "https://urwq.moe/88x31.png", alt: "urwq" }, 94 - { href: "https://mugman.tech", src: "https://mugman.tech/88x31/me.gif", alt: "mugman" }, 95 - { href: "https://www.juwuba.xyz", src: "https://www.juwuba.xyz/88x31.gif", alt: "Júlia" }, 96 - { href: "https://codeberg.org/paige", src: "/88x31/paige.gif", alt: "paige" }, 97 - ];
+18
src/content/friends.ts
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + export const friends = { 7 + "88x31": [ 8 + { href: "https://katelyn.moe/", src: "https://katelyn.moe/8831.png", alt: "katelyn" }, 9 + { href: "https://worf.win", src: "https://worf.win/images/worfwin.gif", alt: "worf.win" }, 10 + // { href: "https://urwq.moe", src: "https://urwq.moe/88x31.png", alt: "urwq" }, 11 + { href: "https://mugman.tech", src: "https://mugman.tech/88x31/me.gif", alt: "mugman" }, 12 + { href: "https://www.juwuba.xyz", src: "https://www.juwuba.xyz/88x31.gif", alt: "Júlia" }, 13 + { href: "https://codeberg.org/paige", src: "/88x31/paige.gif", alt: "paige" }, 14 + ], 15 + iframe: [ 16 + "<!doctype html><body onload=\"d=d.style,d.position=`absolute`,x=0,y=Math.random()*66|0,u=v=1,c=3;setInterval`x+=u${166}y+=v,z=x<=0||x>=20,w=y<=0||y>=67;z&&w?c=(c+3)%6-1:0,u^=-z-z,v^=-w-w;d.background='hwb('+60*c+' 0 0)';d.top=x+'px',d.left=y+'px'`\"bgcolor=#000><a href=https://github.com/rniii target=_blank><img id=d src=data:image/gif;base64,R0lGODdhFQALAHcAACH5BAkKAAAALAAAAAAVAAsAgAAAAAAAAAInDI4Xa6m8EkNQPQtivnvuH3mc1pSlU3EUxJrtC8eig851Q2daTjMFADs>", 17 + ], 18 + };
+71
src/content/projects.ts
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + export interface Project { 7 + author: string; 8 + name: string; 9 + description: string; 10 + lang: ProjectLanguage; 11 + license: string; 12 + url: string; 13 + } 14 + 15 + export enum ProjectLanguage { 16 + TypeScript = 0x3178c6, 17 + C = 0x555555, 18 + Scala = 0xc22d40, 19 + } 20 + 21 + export const projects: Project[] = [ 22 + { 23 + author: "w", 24 + name: "snarl", 25 + description: "a minimal web framework for deno", 26 + license: "Apache-2.0", 27 + url: "/~snarl", 28 + lang: ProjectLanguage.TypeScript, 29 + }, 30 + { 31 + author: "w", 32 + name: "ratazana", 33 + description: 34 + "minimal implementation of logitech and razer mouse firmware, repurposing their onboard memory as a covert channel for arbitrary data", 35 + license: "BSD-3-Clause", 36 + url: "/~ratazana", 37 + lang: ProjectLanguage.C, 38 + }, 39 + { 40 + author: "w", 41 + name: "ribbon", 42 + description: "modular client mod for fluxer", 43 + url: "/~ribbon", 44 + license: "EUPL-1.2 & MPL-2.0", 45 + lang: ProjectLanguage.TypeScript, 46 + }, 47 + { 48 + author: "w", 49 + name: "scrobkit", 50 + description: "a minimal CLI toolkit for working with last.fm scrobbles", 51 + url: "/~scrobkit", 52 + license: "BSD-3-Clause", 53 + lang: ProjectLanguage.TypeScript, 54 + }, 55 + { 56 + author: "w", 57 + name: "terracotta", 58 + description: "mill-based toolchain for crossplatform and multi-version minecraft mod development", 59 + url: "/~terracotta", 60 + license: "LGPL-3.0", 61 + lang: ProjectLanguage.Scala, 62 + }, 63 + { 64 + author: "w", 65 + name: "wildcat", 66 + description: "lightweight, minimal, portable, crossplatform, and straightforward game engine inspired by raylib", 67 + license: "BSD-3-Clause", 68 + url: "/~wildcat", 69 + lang: ProjectLanguage.C, 70 + }, 71 + ];
+48
src/content/songs.ts
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + export interface Song { 7 + title: string; 8 + album: string; 9 + artist: string; 10 + tag?: string; 11 + releaseDate: string; 12 + url: string; 13 + coverUrl?: string; 14 + } 15 + 16 + function art(path: string, size = "512x512bb"): string { 17 + return `https://is1-ssl.mzstatic.com/image/thumb/${path}.jpg/${size}.jpg`; 18 + } 19 + 20 + export const songs: Song[] = [ 21 + { 22 + title: "matryoshka", 23 + album: "heartstrings", 24 + artist: "lexycat", 25 + tag: "digicore, plunderphonics", 26 + url: "https://song.link/i/1722427758", 27 + coverUrl: art("Music211/v4/b9/7b/66/b97b6672-7c40-8b1c-fd7b-51e8866c0a6a/cover_198662567310"), 28 + releaseDate: "Released on August 18, 2024", 29 + }, 30 + { 31 + title: "tile floors", 32 + album: "lettinggomakestheheartburnhotter", 33 + artist: "fallingwithscissors", 34 + tag: "experimental metalcore", 35 + url: "https://song.link/i/1681979278", 36 + coverUrl: art("Music116/v4/53/4d/18/534d18da-75d9-5d48-2a14-17256a49c2c9/9b0f51d8-05f2-47a6-ba89-01ad8804828b"), 37 + releaseDate: "Released on April 17, 2023", 38 + }, 39 + { 40 + title: "lose myself in you", 41 + album: "lose myself in you", 42 + artist: "coffret de bijoux", 43 + tag: "atmospheric black metal", 44 + url: "https://song.link/i/1886039227", 45 + coverUrl: art("Music221/v4/39/19/03/39190326-8671-70f7-a488-39ae4d6d74bb/5063959778586_cover"), 46 + releaseDate: "Released on March 9, 2026", 47 + }, 48 + ];
+1
src/global.ts src/lib/context.ts
··· 5 5 6 6 import { Middleware } from "@july/snarl"; 7 7 import { Context } from "@july/snarl"; 8 + 8 9 import { AsyncLocalStorage } from "node:async_hooks"; 9 10 10 11 const contextStorage = new AsyncLocalStorage<Context>();
-54
src/hash/meowmix1.ts
··· 1 - // meowmix1 'rithm, insert description here o algo así 2 - // SPDX-License-Identifier: 0BSD 3 - 4 - // thats why u lwk shouldnt let yo feline step on the keyboard 5 - const LUT = 6 - "m me mew meow mrow mrrr ow mreow rr nya prr purr rrr eow miao mraow" 7 - .split(" ") 8 - .map(s => [ 9 - s, 10 - [...s].reduce((n, c) => Math.imul(n ^ c.charCodeAt(0), 0x5bd1e995) >>> 0, 0x9e3779b1) 11 - ] as const) 12 - .sort((a, b) => b[1] - a[1]) 13 - .map(([s]) => s); 14 - 15 - function purrmux1(input: string) { 16 - // the birth and negation of a pair of breasts 17 - let h = (0x80085 * ~0x80085) >>> 0; // my brutha in christ what the actual fuck is this❓ 18 - // pls get the children outta the room 19 - 20 - for (let i = 0; i < input.length; i++) { 21 - const disgrace = input.codePointAt(i)!; 22 - if (disgrace > 0xFFFF) i++; // utf-16 hates ur whole bloodline 23 - h = Math.imul(h ^ disgrace, 0x9e3779b9); // 2³² ÷ Φ 24 - } 25 - 26 - // hoe thinks it be murmurhash 🥀 27 - h ^= h >>> 15; 28 - h = Math.imul(h, 0x85ebca77); 29 - h ^= h >>> 13; 30 - h = Math.imul(h, 0xc2b2ae35); 31 - h ^= h >>> 16; 32 - return h >>> 0; 33 - } 34 - 35 - export default function meowmix1(input: string) { 36 - let bits = purrmux1(input), mrrp = ""; 37 - 38 - let syllables = (bits & 0b111) + 1; 39 - bits >>>= 3; 40 - 41 - while (syllables--) { 42 - const mask = bits & 0b111111; 43 - const idx = mask & 0b1111, mode = mask >>> 4; 44 - 45 - let word = LUT[idx]; 46 - word = (mode & 2) ? word + word : word; 47 - mrrp += (mode & 1) ? word.toUpperCase() : word; 48 - 49 - if ((bits >>= 6) < 0x40) // 32-bit bijective avalanche mix refill 50 - bits = Math.imul((bits ^ idx) + 0x1337, 0x165667b1); 51 - } 52 - 53 - return mrrp; 54 - }
+22 -13
src/layout.tsx
··· 4 4 */ 5 5 6 6 import { jsx, JsxElement, JsxNode } from "@july/snarl/jsx-runtime"; 7 + import { ScopedStyling } from "~/lib/css.ts"; 8 + 9 + export interface LayoutProps { 10 + children?: any; 11 + scope: ScopedStyling; 12 + class?: string; 13 + } 7 14 8 15 export const rem = (val: number | string) => `${val}rem`; 9 16 export const em = (val: number | string) => `${val}em`; ··· 59 66 spacing: { 60 67 letter: { 61 68 tight: em(-.05), 69 + misc: em(-.025), 62 70 plus: em(.03), 63 71 }, 64 72 "1": rem(.25), ··· 112 120 }, 113 121 misc: { 114 122 arrow: url( 115 - "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4IDgiIHNoYXBlLXJlbmRlcmluZz0iY3Jpc3BFZGdlcyI+CiAgPHJlY3QgeD0iMiIgeT0iMCIgd2lkdGg9IjYiIGhlaWdodD0iMiIgZmlsbD0id2hpdGUiLz4KICA8cmVjdCB4PSI0IiB5PSIyIiB3aWR0aD0iNCIgaGVpZ2h0PSIyIiBmaWxsPSJ3aGl0ZSIvPgogIDxyZWN0IHg9IjIiIHk9IjQiIHdpZHRoPSIyIiBoZWlnaHQ9IjIiIGZpbGw9IndoaXRlIi8+CiAgPHJlY3QgeD0iNiIgeT0iNCIgd2lkdGg9IjIiIGhlaWdodD0iMiIgZmlsbD0id2hpdGUiLz4KICA8cmVjdCB4PSIwIiB5PSI2IiB3aWR0aD0iMiIgaGVpZ2h0PSIyIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K", 123 + `data:image/svg+xml;base64,${btoa(Deno.readTextFileSync("./assets/ui/arrow.svg"))}`, 116 124 ), 117 125 }, 118 126 fontFamily: { 119 - default: '"Iosevka Custom Web", "Iosevka Custom", Iosevka, monospace, sans-serif', 120 - heading: "'Space Grotesk', sans-serif", 127 + default: 128 + '"Bricolage Grotesque", "Iosevka Custom Web", "Iosevka Custom", Iosevka, "Space Grotesk", sans-serif, monospace', 129 + misc: 130 + '"Iosevka Custom Web", "Iosevka Custom", Iosevka, "Bricolage Grotesque", "Space Grotesk", sans-serif, monospace', 131 + heading: "'Space Grotesk', sans-serif, monospace", 121 132 }, 122 133 }); 123 134 ··· 135 146 html { 136 147 overflow-x: clip; 137 148 font-family: ${tokens.fontFamily.default}; 149 + font-weight: 400; 138 150 line-height: ${tokens.boundaries.lineHeight}; 139 151 font-size: ${tokens.fontSize.root}; 140 152 text-rendering: optimizeLegibility; ··· 161 173 text-decoration: none; 162 174 } 163 175 164 - section p { 176 + section p, section i { 165 177 margin-bottom: ${tokens.spacing[4]}; 166 178 font-size: ${tokens.fontSize.body}; 167 179 color: ${tokens.theme.subtext}; ··· 187 199 display: inline-block; 188 200 width: 8px; 189 201 height: 8px; 190 - margin-left: 8px; 202 + margin-left: 4px; 203 + margin-right: 4px; 191 204 background-color: ${tokens.theme.accent}; 192 205 mask: no-repeat center / contain ${tokens.misc.arrow}; 193 206 } ··· 206 219 } 207 220 `; 208 221 209 - export interface LayoutProps { 210 - children?: JsxElement | JsxElement[]; 211 - class?: string; 212 - } 213 - 214 222 interface CollectResult { 215 223 head: JsxNode[]; 216 224 body: JsxNode[]; ··· 251 259 result.body.push(jsx(tag, { ...props, children: child.body as any })); 252 260 } 253 261 254 - export function Layout({ children, class: className }: LayoutProps = {}) { 262 + export function Layout({ scope, children, class: className }: LayoutProps) { 255 263 const result: CollectResult = { head: [], body: [] }; 256 264 collect(children, result); 257 265 ··· 269 277 <meta property="og:title" content="júlia lívia" /> 270 278 <meta name="theme-color" content={tokens.theme.accentDim} /> 271 279 <link rel="stylesheet" href="/fonts/iosevka-custom/import.css" /> 280 + <link rel="stylesheet" href="/fonts/bricolage-grotesque/import.css" /> 272 281 <style>{styles}</style> 273 282 {result.head} 274 283 </head> 275 - <body class={className}> 284 + <scope.body class={className}> 276 285 {result.body} 277 - </body> 286 + </scope.body> 278 287 </html> 279 288 ); 280 289 }
+143
src/lib/css.ts
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { Component, jsx } from "@july/snarl/jsx-runtime"; 7 + 8 + import { contextualisedStyles, styleRegistry } from "~/middleware/scoped-css.ts"; 9 + import { retrieveContext } from "~/lib/context.ts"; 10 + import meowmix1 from "~/lib/meowmix1.ts"; 11 + 12 + export type ScopedStyling = { 13 + [K in keyof HTMLElementTagNameMap]: Component<{ children: any; [key: string]: unknown }>; 14 + }; 15 + 16 + // brace characters inside string values should be replaced with unicode escapes 17 + const CONDITIONAL_AT = /^@(media|supports|layer|container|document)\b/i; 18 + 19 + const RAW_AT = /^@(keyframes|font-face|font-palette-values|counter-style|page|viewport|color-profile)\b/i; 20 + 21 + function prefixSelectors(selector: string, scope: string): string { 22 + return selector 23 + .split(",") 24 + .map((s) => s.trim()) 25 + .filter(Boolean) 26 + .map((s) => { 27 + if (s.includes(":scope")) return s.replace(/:scope\b/g, scope); 28 + if (s.startsWith("&")) return s; 29 + return `${scope} ${s}`; 30 + }) 31 + .join(","); 32 + } 33 + 34 + function transformRules(src: string, scope: string): string { 35 + let out = ""; 36 + let i = 0; 37 + 38 + while (i < src.length) { 39 + while (i < src.length && src[i] <= " ") i++; 40 + if (i >= src.length) break; 41 + 42 + if (src[i] === "/" && src[i + 1] === "*") { 43 + const end = src.indexOf("*/", i + 2); 44 + i = end === -1 ? src.length : end + 2; 45 + continue; 46 + } 47 + 48 + if (src[i] === "}") { 49 + i++; 50 + break; 51 + } 52 + 53 + let sel = ""; 54 + let hasBlock = false; 55 + 56 + scan: while (i < src.length) { 57 + if (src[i] === "/" && src[i + 1] === "*") { 58 + const end = src.indexOf("*/", i + 2); 59 + i = end === -1 ? src.length : end + 2; 60 + continue; 61 + } 62 + switch (src[i]) { 63 + case "{": 64 + hasBlock = true; 65 + i++; 66 + break scan; 67 + case "}": 68 + break scan; 69 + default: 70 + sel += src[i++]; 71 + } 72 + } 73 + 74 + sel = sel.trim(); 75 + if (!hasBlock || !sel) break; 76 + 77 + const blockStart = i; 78 + let depth = 1; 79 + while (i < src.length && depth > 0) { 80 + if (src[i] === "{") depth++; 81 + else if (src[i] === "}") depth--; 82 + i++; 83 + } 84 + 85 + const block = src.slice(blockStart, i - 1); 86 + 87 + if (RAW_AT.test(sel)) { 88 + out += `${sel}{${block}}`; 89 + } else if (CONDITIONAL_AT.test(sel)) { 90 + out += `${sel}{${transformRules(block, scope)}}`; 91 + } else { 92 + out += `${prefixSelectors(sel, scope)}{${block}}`; 93 + } 94 + } 95 + 96 + return out; 97 + } 98 + 99 + function generateScopedStyle(content: string, scopeId: string): string { 100 + return transformRules(content.trim(), `.${scopeId}`); 101 + } 102 + 103 + export function css(src: string): ScopedStyling { 104 + src = src.trim(); 105 + if (!src) throw new Error("css: empty stylesheet"); 106 + 107 + const hash = meowmix1(src); 108 + if (styleRegistry.has(hash)) { 109 + throw new Error(`css: hash collision ${hash}`); 110 + } 111 + 112 + const style = generateScopedStyle(src, hash); 113 + styleRegistry.set(hash, style); 114 + 115 + const createComponent = <T extends keyof HTMLElementTagNameMap>(tag: T) => { 116 + const Component = function (props: Parameters<Component>[0]) { 117 + const ctx = retrieveContext()!; 118 + 119 + let mem = contextualisedStyles.get(ctx); 120 + if (!mem) { 121 + contextualisedStyles.set(ctx, mem = new Set()); 122 + } 123 + mem.add(hash); 124 + 125 + return jsx(tag, { 126 + ...props, 127 + class: props.class ? `${props.class} ${hash}` : hash, 128 + }); 129 + }; 130 + 131 + return Component; 132 + }; 133 + 134 + const target = Object.create(null); 135 + return new Proxy(target, { 136 + get(target, property) { 137 + if (property in target) return target[property]; 138 + 139 + const tag = String(property).toLowerCase(); 140 + return target[property] = createComponent(tag as keyof HTMLElementTagNameMap); 141 + }, 142 + }); 143 + }
+30
src/lib/interval.ts
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + export async function withInterval<T>( 7 + callback: () => Promise<T>, 8 + seconds: number, 9 + ): Promise<() => T | undefined> { 10 + let value: T | undefined; 11 + 12 + try { 13 + value = await callback(); 14 + } catch (e) { 15 + console.warn("[interval] initial call failed, will retry:", e); 16 + } 17 + 18 + async function tick(): Promise<void> { 19 + try { 20 + value = await callback(); 21 + } catch (e) { 22 + console.warn("[interval] tick failed, keeping stale value:", e); 23 + } 24 + setTimeout(tick, seconds * 1000); 25 + } 26 + 27 + setTimeout(tick, seconds * 1000); 28 + 29 + return () => value; 30 + }
+57
src/lib/meowmix1.ts
··· 1 + // meowmix1 'rithm, insert description here o algo así 2 + // SPDX-License-Identifier: 0BSD 3 + 4 + // thats why u lwk shouldnt let yo feline step on the keyboard 5 + const LUT = "m me mew meow mrow mrrr ow mreow rr nya prr purr rrr eow miao mraow" 6 + .split(" ") 7 + .map((s) => 8 + [ 9 + s, 10 + [...s].reduce((n, c) => Math.imul(n ^ c.charCodeAt(0), 0x5bd1e995) >>> 0, 0x9e3779b1), 11 + ] as const 12 + ) 13 + .sort((a, b) => b[1] - a[1]) 14 + .map(([s]) => s); 15 + 16 + // deno-fmt-ignore 17 + function purrmux1(input: string) { 18 + // the birth and negation of a pair of breasts 19 + let h = (0x80085 * ~0x80085) >>> 0; // my brutha in christ what the actual fuck is this❓ 20 + // pls get the children outta the room 21 + 22 + for (let i = 0; i < input.length; i++) { 23 + const disgrace = input.codePointAt(i)!; 24 + if (disgrace > 0xFFFF) i++; // utf-16 hates ur whole bloodline 25 + h = Math.imul(h ^ disgrace, 0x9e3779b9); // 2³² ÷ Φ 26 + } 27 + 28 + // hoe thinks it be murmurhash 🥀 29 + h ^= h >>> 15; 30 + h = Math.imul(h, 0x85ebca77); 31 + h ^= h >>> 13; 32 + h = Math.imul(h, 0xc2b2ae35); 33 + h ^= h >>> 16; 34 + return h >>> 0; 35 + } 36 + 37 + export default function meowmix1(input: string) { 38 + let bits = purrmux1(input), mrrp = ""; 39 + 40 + let syllables = (bits & 0b111) + 1; 41 + bits >>>= 3; 42 + 43 + while (syllables--) { 44 + const mask = bits & 0b111111; 45 + const idx = mask & 0b1111, mode = mask >>> 4; 46 + 47 + let word = LUT[idx]; 48 + word = (mode & 2) ? word + word : word; 49 + mrrp += (mode & 1) ? word.toUpperCase() : word; 50 + 51 + if ((bits >>= 6) < 0x40) { // 32-bit bijective avalanche mix refill 52 + bits = Math.imul((bits ^ idx) + 0x1337, 0x165667b1); 53 + } 54 + } 55 + 56 + return mrrp; 57 + }
-232
src/mech/css.ts
··· 1 - /** 2 - * Copyright (c) 2025 adoravel 3 - * SPDX-License-Identifier: AGPL-3.0-or-later 4 - */ 5 - 6 - import meowmix1 from "~/hash/meowmix1.ts"; 7 - import { Context, Middleware } from "@july/snarl"; 8 - import { retrieveContext } from "../global.ts"; 9 - 10 - const CLASS_RE = /\.([a-zA-Z_][\w-]*)/g; 11 - 12 - const styleRegistry = new Map<string, string>(); 13 - const contextualisedStyles = new WeakMap<Context<any>, Set<string>>(); 14 - 15 - type ExtractFlatClassKeys<T extends string> = T extends `${infer _}.${infer Rest}` 16 - ? Rest extends `${infer Name}${" " | "{" | ":" | "\n" | "\t" | "," | ">" | "+" | "~" | "[" | "." | "#"}${infer Tail}` 17 - ? Name | ExtractFlatClassKeys<Tail> 18 - : Rest extends `${infer Name}` ? Name 19 - : never 20 - : never; 21 - 22 - export type ScopedStyles<T extends string> = 23 - & { 24 - [K in ExtractFlatClassKeys<T>]: string; 25 - } 26 - & { 27 - readonly scope: string; 28 - href: string; 29 - use(ctx: Context): void; 30 - }; 31 - 32 - // brace characters inside string values should be replaced with unicode escapes 33 - const CONDITIONAL_AT = /^@(media|supports|layer|container|document)\b/i; 34 - 35 - const RAW_AT = /^@(keyframes|font-face|font-palette-values|counter-style|page|viewport|color-profile)\b/i; 36 - 37 - function prefixSelectors(selector: string, scope: string): string { 38 - return selector 39 - .split(",") 40 - .map((s) => s.trim()) 41 - .filter(Boolean) 42 - .map((s) => { 43 - if (s.includes(":scope")) return s.replace(/:scope\b/g, scope); 44 - if (s.startsWith("&")) return s; 45 - return `${scope} ${s}`; 46 - }) 47 - .join(","); 48 - } 49 - 50 - function transformRules(src: string, scope: string): string { 51 - let out = ""; 52 - let i = 0; 53 - 54 - while (i < src.length) { 55 - while (i < src.length && src[i] <= " ") i++; 56 - if (i >= src.length) break; 57 - 58 - if (src[i] === "/" && src[i + 1] === "*") { 59 - const end = src.indexOf("*/", i + 2); 60 - i = end === -1 ? src.length : end + 2; 61 - continue; 62 - } 63 - 64 - if (src[i] === "}") { 65 - i++; 66 - break; 67 - } 68 - 69 - let sel = ""; 70 - let hasBlock = false; 71 - 72 - scan: while (i < src.length) { 73 - if (src[i] === "/" && src[i + 1] === "*") { 74 - const end = src.indexOf("*/", i + 2); 75 - i = end === -1 ? src.length : end + 2; 76 - continue; 77 - } 78 - switch (src[i]) { 79 - case "{": 80 - hasBlock = true; 81 - i++; 82 - break scan; 83 - case "}": 84 - break scan; 85 - default: 86 - sel += src[i++]; 87 - } 88 - } 89 - 90 - sel = sel.trim(); 91 - if (!hasBlock || !sel) break; 92 - 93 - const blockStart = i; 94 - let depth = 1; 95 - while (i < src.length && depth > 0) { 96 - if (src[i] === "{") depth++; 97 - else if (src[i] === "}") depth--; 98 - i++; 99 - } 100 - 101 - const block = src.slice(blockStart, i - 1); 102 - 103 - if (RAW_AT.test(sel)) { 104 - out += `${sel}{${block}}`; 105 - } else if (CONDITIONAL_AT.test(sel)) { 106 - out += `${sel}{${transformRules(block, scope)}}`; 107 - } else { 108 - out += `${prefixSelectors(sel, scope)}{${block}}`; 109 - } 110 - } 111 - 112 - return out; 113 - } 114 - 115 - function generateScopedStyle(content: string, scopeId: string): string { 116 - return transformRules(content.trim(), `.${scopeId}`); 117 - } 118 - 119 - function extractClassKeys(input: string): Set<string> { 120 - const classes = new Set<string>(); 121 - CLASS_RE.lastIndex = 0; 122 - 123 - let match; 124 - while ((match = CLASS_RE.exec(input)) !== null) { 125 - classes.add(match[1]); 126 - } 127 - 128 - return classes; 129 - } 130 - 131 - export function css<const S extends string>(src: S): ScopedStyles<S> { 132 - src = src.trim() as S; 133 - if (!src) throw new Error("css: empty stylesheet"); 134 - 135 - const hash = meowmix1(src); 136 - if (styleRegistry.has(hash)) { 137 - throw new Error(`css: hash collision ${hash}`); 138 - } 139 - 140 - const style = generateScopedStyle(src, hash); 141 - styleRegistry.set(hash, style); 142 - 143 - const result: any = { 144 - scope: hash, 145 - __css: style, 146 - href: `/css/${hash}.css`, 147 - use(ctx: Context<any>) { 148 - let styles = contextualisedStyles.get(ctx); 149 - if (!styles) { 150 - contextualisedStyles.set(ctx, styles = new Set()); 151 - } 152 - styles.add(hash); 153 - }, 154 - }; 155 - 156 - const keys = extractClassKeys(src); 157 - for (const key of keys) { 158 - result[key] = key; 159 - } 160 - 161 - return result as ScopedStyles<S>; 162 - } 163 - 164 - export function cssMiddleware(): Middleware { 165 - return (ctx: Context, next: () => Promise<Response>) => { 166 - if ( 167 - !ctx.url.pathname.startsWith("/css/") || 168 - !ctx.url.pathname.endsWith(".css") 169 - ) { 170 - return next(); 171 - } 172 - 173 - const hash = ctx.url.pathname.slice(5, -4); 174 - const content = styleRegistry.get(hash); 175 - 176 - if (!content) { 177 - return ctx.notFound(); 178 - } 179 - 180 - return new Response(content, { 181 - headers: { 182 - "Content-Type": "text/css; charset=utf-8", 183 - "Cache-Control": "public, max-age=31536000, immutable", 184 - }, 185 - }); 186 - }; 187 - } 188 - 189 - export function styleInjectionMiddleware(): Middleware { 190 - return async (ctx, next) => { 191 - const res = await next(); 192 - 193 - const mem = contextualisedStyles.get(ctx); 194 - if (!mem || mem.size === 0) return res; 195 - 196 - const html = await res.text(); 197 - 198 - const links = [...mem] 199 - .map((hash) => `<link rel="stylesheet" href="/css/${hash}.css">`) 200 - .join(""); 201 - 202 - const injected = html.replace("</head>", `${links}</head>`); 203 - 204 - return new Response(injected, { 205 - headers: res.headers, 206 - }); 207 - }; 208 - } 209 - 210 - declare module "@july/snarl" { 211 - interface Context { 212 - useStyles(...styles: ScopedStyles<string>[]): void; 213 - } 214 - } 215 - 216 - Context.prototype.useStyles = function (...styles: ScopedStyles<string>[]) { 217 - let mem = contextualisedStyles.get(this); 218 - if (!mem) { 219 - contextualisedStyles.set(this, mem = new Set()); 220 - } 221 - for (const s of styles) { 222 - mem.add(s.scope); 223 - } 224 - }; 225 - 226 - export function Import({ styles = [] }: { styles: ScopedStyles<string>[] }) { 227 - const ctx = retrieveContext()!; 228 - if (styles.length) { 229 - ctx.useStyles(...styles); 230 - } 231 - return null; 232 - }
+27 -15
src/mech/fm.ts src/services/lastfm.ts
··· 1 - import { withInterval } from "../utils/temp.ts"; 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { withInterval } from "~/lib/interval.ts"; 7 + import { Song } from "~/content/songs.ts"; 8 + 9 + const BASE_URL = "https://ws.audioscrobbler.com/2.0/"; 2 10 3 - const FM_API_KEY = Deno.env.get("LASTFM_API_KEY"); 11 + export const lastfm = { 12 + apiKey: Deno.env.get("LASTFM_API_KEY") ?? "790c37d90400163a5a5fe00d6ca32ef0", 13 + user: Deno.env.get("LASTFM_USER") ?? "satisfeita", 14 + }; 4 15 5 16 export interface Parameters { 6 17 api_key: string; ··· 47 58 } 48 59 49 60 export async function fm(params: Parameters, secret?: string) { 50 - const url = new URL("https://ws.audioscrobbler.com/2.0/"); 61 + const url = new URL(BASE_URL); 51 62 const parameters: Record<string, string> = { ...params, format: "json" }; 52 63 53 64 Object.entries(parameters) ··· 83 94 return await fm({ 84 95 method: "user.getRecentTracks", 85 96 user: username, 86 - api_key: FM_API_KEY, 97 + api_key: lastfm.apiKey, 87 98 limit, 88 99 page, 89 100 extended, 90 101 }); 91 102 } 92 - 93 - export const FM_USER = "satisfeita"; 94 103 95 104 export const tracks = await withInterval(async () => { 96 - const { recenttracks } = await getRecentTracks(FM_USER, 1, 5); 105 + const { recenttracks } = await getRecentTracks(lastfm.user, 1, 5); 97 106 98 - return recenttracks.track?.map((track) => ({ 99 - artist: track.artist.name, 100 - name: track.name, 101 - loved: track.loved === "1" ? true : false, 102 - playing: track["@attr"]?.nowplaying || false, 103 - cover: track.image?.at(-1)?.["#text"], 104 - url: track.url || "#", 105 - })); 107 + return recenttracks.track?.map((track) => 108 + ({ 109 + artist: track.artist.name, 110 + album: track.album["#text"], 111 + title: track.name, 112 + loved: track.loved === "1" ? true : false, 113 + playing: track["@attr"]?.nowplaying || false, 114 + coverUrl: track.image?.at(-1)?.["#text"], 115 + url: track.url || "#", 116 + }) as Partial<Song> & { loved: boolean; playing: boolean } 117 + ); 106 118 }, 60);
-59
src/mech/mini.ts
··· 1 - /** 2 - * Copyright (c) 2025 adoravel 3 - * SPDX-License-Identifier: AGPL-3.0-or-later 4 - */ 5 - 6 - import { Context, Middleware } from "@july/snarl"; 7 - import { minify as mini } from "@minify-html/deno"; 8 - 9 - const cache = new Map<string, string>(); 10 - 11 - const encoder = new TextEncoder(); 12 - const decoder = new TextDecoder(); 13 - 14 - export default function minify(): Middleware { 15 - return async (_ctx: Context, next: () => Promise<Response>) => { 16 - const response = await next(); 17 - 18 - const contentType = response.headers.get("Content-Type") || ""; 19 - if (!contentType.includes("text/html") && !contentType.includes("text/css")) { 20 - return response; 21 - } 22 - 23 - let body = await response.text(); 24 - if (!body) return response; 25 - 26 - const cached = cache.get(body); 27 - if (cached) { 28 - return new Response(cached, { 29 - status: response.status, 30 - statusText: response.statusText, 31 - headers: response.headers, 32 - }); 33 - } 34 - 35 - const originalBody = body; 36 - if (contentType.includes("text/css")) { 37 - body = body 38 - .replace(/\/\*[\s\S]*?\*\//g, "") 39 - .replace(/\s*([{}:;,])\s*/g, "$1") 40 - .replace(/^\s+|\s+$/gm, "") 41 - .replace(/\n+/g, ""); 42 - } else { 43 - const data = mini(encoder.encode(body), { 44 - keep_spaces_between_attributes: false, 45 - keep_comments: false, 46 - minify_css: true, 47 - minify_js: true, 48 - }); 49 - body = decoder.decode(data); 50 - } 51 - cache.set(originalBody, body); 52 - 53 - return new Response(body, { 54 - status: response.status, 55 - statusText: response.statusText, 56 - headers: response.headers, 57 - }); 58 - }; 59 - }
src/mech/routing.ts src/middleware/routing.ts
+95
src/middleware/minification.ts
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { Context, Middleware } from "@july/snarl"; 7 + import { minify as mini } from "@minify-html/deno"; 8 + 9 + const encoder = new TextEncoder(); 10 + const decoder = new TextDecoder(); 11 + 12 + const MAX_ENTRIES = 128; 13 + const MAX_BYTES = 8 * 1024 * 1024; // 8 MiB 14 + 15 + class LRUCache { 16 + private map = new Map<string, string>(); 17 + private bytes = 0; 18 + 19 + get(key: string): string | undefined { 20 + const value = this.map.get(key); 21 + if (value !== undefined) { 22 + this.map.delete(key); 23 + this.map.set(key, value); 24 + } 25 + return value; 26 + } 27 + 28 + set(key: string, value: string): void { 29 + if (this.map.has(key)) return; // already cached 30 + 31 + this.bytes += key.length + value.length; 32 + this.map.set(key, value); 33 + 34 + while (this.map.size > MAX_ENTRIES || this.bytes > MAX_BYTES) { 35 + const entry = this.map.entries().next().value; 36 + if (!entry) continue; 37 + 38 + const [oldestKey, oldestValue] = entry; 39 + 40 + this.bytes -= oldestKey.length + oldestValue.length; 41 + this.map.delete(oldestKey); 42 + } 43 + } 44 + } 45 + 46 + const cache = new LRUCache(); 47 + 48 + export default function minify(): Middleware { 49 + return async (_ctx: Context, next: () => Promise<Response>) => { 50 + const response = await next(); 51 + 52 + const contentType = response.headers.get("Content-Type") ?? ""; 53 + const isHtml = contentType.includes("text/html"); 54 + const isCss = contentType.includes("text/css"); 55 + if (!isHtml && !isCss) return response; 56 + 57 + const raw = await response.text(); 58 + if (!raw) return response; 59 + 60 + const cached = cache.get(raw); 61 + if (cached) { 62 + return new Response(cached, { 63 + status: response.status, 64 + statusText: response.statusText, 65 + headers: response.headers, 66 + }); 67 + } 68 + 69 + let minified: string; 70 + 71 + if (isCss) { 72 + minified = raw 73 + .replace(/\/\*[\s\S]*?\*\//g, "") 74 + .replace(/\s*([{}:;,])\s*/g, "$1") 75 + .replace(/^\s+|\s+$/gm, "") 76 + .replace(/\n+/g, ""); 77 + } else { 78 + const data = mini(encoder.encode(raw), { 79 + keep_spaces_between_attributes: false, 80 + keep_comments: false, 81 + minify_css: true, 82 + minify_js: true, 83 + }); 84 + minified = decoder.decode(data); 85 + } 86 + 87 + cache.set(raw, minified); 88 + 89 + return new Response(minified, { 90 + status: response.status, 91 + statusText: response.statusText, 92 + headers: response.headers, 93 + }); 94 + }; 95 + }
+55
src/middleware/scoped-css.ts
··· 1 + /** 2 + * Copyright (c) 2025 adoravel 3 + * SPDX-License-Identifier: AGPL-3.0-or-later 4 + */ 5 + 6 + import { Context, Middleware } from "@july/snarl"; 7 + 8 + export const styleRegistry = new Map<string, string>(); 9 + export const contextualisedStyles = new WeakMap<Context<any>, Set<string>>(); 10 + 11 + export function cssMiddleware(): Middleware { 12 + return (ctx: Context, next: () => Promise<Response>) => { 13 + if ( 14 + !ctx.url.pathname.startsWith("/css/") || 15 + !ctx.url.pathname.endsWith(".css") 16 + ) { 17 + return next(); 18 + } 19 + 20 + const hash = ctx.url.pathname.slice(5, -4); 21 + const content = styleRegistry.get(hash); 22 + 23 + if (!content) { 24 + return ctx.notFound(); 25 + } 26 + 27 + return new Response(content, { 28 + headers: { 29 + "Content-Type": "text/css; charset=utf-8", 30 + "Cache-Control": "public, max-age=31536000, immutable", 31 + }, 32 + }); 33 + }; 34 + } 35 + 36 + export function styleInjectionMiddleware(): Middleware { 37 + return async (ctx, next) => { 38 + const res = await next(); 39 + 40 + const mem = contextualisedStyles.get(ctx); 41 + if (!mem || mem.size === 0) return res; 42 + 43 + const html = await res.text(); 44 + 45 + const links = [...mem] 46 + .map((hash) => `<link rel="stylesheet" href="/css/${hash}.css">`) 47 + .join(""); 48 + 49 + const injected = html.replace("</head>", `${links}</head>`); 50 + 51 + return new Response(injected, { 52 + headers: res.headers, 53 + }); 54 + }; 55 + }
+13 -8
src/mod.ts
··· 4 4 */ 5 5 6 6 import { createRouter } from "@july/snarl"; 7 - import { cssMiddleware as scopedStyles, styleInjectionMiddleware } from "~/mech/css.ts"; 8 - import { scanRoutes as scan } from "./mech/routing.ts"; 9 - import minify from "./mech/mini.ts"; 10 - import { contextMiddleware } from "./global.ts"; 11 7 import { staticFiles } from "@july/snarl"; 12 8 9 + import { cssMiddleware as scopedStyles, styleInjectionMiddleware } from "~/middleware/scoped-css.ts"; 10 + import { scanRoutes as scan } from "~/middleware/routing.ts"; 11 + import minify from "~/middleware/minification.ts"; 12 + import { contextMiddleware } from "~/lib/context.ts"; 13 + 13 14 const router = createRouter(); 14 - router.use(minify(), styleInjectionMiddleware(), contextMiddleware(), scopedStyles()); 15 - router.use(staticFiles("./static", { 16 - maxAge: 259200, 17 - })); 15 + router.use( 16 + contextMiddleware(), 17 + scopedStyles(), 18 + staticFiles("./static", { maxAge: 259200 }), 19 + staticFiles("./assets", { maxAge: 259200 }), 20 + minify(), 21 + styleInjectionMiddleware(), 22 + ); 18 23 19 24 scan(router); 20 25
+42 -44
src/routes/mod.tsx
··· 3 3 * SPDX-License-Identifier: AGPL-3.0-or-later 4 4 */ 5 5 6 - import { css, Import } from "~/mech/css.ts"; 6 + import { css } from "~/lib/css.ts"; 7 7 import { boundaries, fontSize, Layout, spacing, theme } from "~/layout.tsx"; 8 - // import { NavigationBar } from "~/components/NavigationBar.tsx"; 9 - import Heading from "~/components/Heading.tsx"; 10 - import SectionTitle from "~/components/SectionTitle.tsx"; 11 - import Projects from "~/components/Projects.tsx"; 12 - import { MediaItem } from "~/components/MediaItem.tsx"; 13 - import { Fm } from "~/components/Fm.tsx"; 14 - import KVTable from "~/components/KVTable.tsx"; 15 - import { Footer } from "~/components/Footer.tsx"; 16 - import { friends, projects, songs } from "~/constants.ts"; 8 + import Hero from "~/components/home/Heading.tsx"; 9 + import Heading from "~/components/ui/Heading.tsx"; 10 + import Projects from "~/components/content/Projects.tsx"; 11 + import FeaturedSong from "~/components/content/FeaturedSong.tsx"; 12 + import RecentTracks from "~/components/content/RecentTracks.tsx"; 13 + import PropertyTable from "~/components/ui/PropertyTable.tsx"; 14 + import Footer from "~/components/layout/Footer.tsx"; 15 + import { songs } from "~/content/songs.ts"; 16 + import { projects } from "~/content/projects.ts"; 17 + import { lastfm, tracks } from "~/services/lastfm.ts"; 18 + import { friends } from "~/content/friends.ts"; 19 + import CollapsibleParagraph from "~/components/ui/CollapsibleParagraph.tsx"; 17 20 18 - const home = css(` 21 + const Styled = css(` 19 22 #heading { 20 23 margin-bottom: ${spacing.section}; 21 24 position: relative; ··· 162 165 163 166 export default () => { 164 167 return ( 165 - <Layout class={home.scope}> 168 + <Layout scope={Styled}> 166 169 <head> 167 170 <title>hewo!!</title> 168 - <Import styles={[home]} /> 169 171 <link rel="stylesheet" href="/fonts/space-grotesk/import.css" /> 170 172 </head> 171 - {/* <NavigationBar items={[["home", "/"], ["reports", "/reports"]]} selected="home" /> */} 172 173 <header id="heading"> 173 - <Heading /> 174 + <Hero /> 174 175 </header> 175 176 <section id="intro"> 176 - <SectionTitle>Introduction</SectionTitle> 177 - <div id="intro-text-container"> 178 - <p> 177 + <Heading>Introduction</Heading> 178 + <div> 179 + <p style={{ display: "inline" }}> 179 180 I'm an aspiring computer engineer passionate about open access and well-crafted software. I really love 180 181 linguistics, functional programming, the C programming language, and unconventional TypeScript. 181 182 </p> 182 - <input type="checkbox" id="intro-toggle" class="read-more-checkbox" autocomplete="off" /> 183 - <label for="intro-toggle" class="read-more-btn link read-more-label-expand">Read more</label> 184 - <label for="intro-toggle" class="read-more-btn link read-more-label-collapse">Show less</label> 185 - <div id="read-more-content"> 183 + <CollapsibleParagraph id="intro-text-container"> 186 184 <p> 187 - <i>In a more personal tone~</i> 188 - </p> 189 - <p> 185 + <i>In a more personal tone~</i>{" "} 190 186 I'm especially interested in atypical low-level systems and the intersection of hardware and software 191 187 interface, with a particular fascination for the x86 and RISC-V ISAs, and I love finding elegant solutions 192 188 in places most people don't bother to look at. ··· 202 198 <a href="/~snarl">snarl</a>, my own web framework, built on the principle that the best way to understand 203 199 something is to build it yourself and have fun doing it. Go check it out!!!111! 204 200 </p> 205 - </div> 201 + </CollapsibleParagraph> 206 202 </div> 207 203 </section> 208 204 <section id="song"> 209 - <SectionTitle>Current favourite songs</SectionTitle> 205 + <Heading>Current favourite songs</Heading> 210 206 <ul class="media"> 211 207 {songs.map((song) => ( 212 208 <li> 213 - <MediaItem {...song} /> 209 + <FeaturedSong {...song} /> 214 210 </li> 215 211 ))} 216 212 </ul> 217 213 </section> 218 214 <section id="projects"> 219 - <SectionTitle>Projects</SectionTitle> 215 + <Heading>Projects</Heading> 220 216 <Projects 221 217 projects={projects} 222 218 /> 223 219 </section> 224 220 <section id="fm"> 225 - <SectionTitle>Recently listened</SectionTitle> 226 - <Fm /> 221 + <Heading>Recently listened</Heading> 222 + <RecentTracks tracks={tracks() ?? []} cutoff={4} profileUrl={`https://last.fm/user/${lastfm.user}`} /> 227 223 </section> 228 224 <section id="friends"> 229 - <SectionTitle>{"Friends &lt;3"}</SectionTitle> 225 + <Heading>{"Friends &lt;3"}</Heading> 230 226 <p>Precious friendships; from the bottom of my heart, I am genuinely grateful for their existence~</p> 231 227 <div class="buttons"> 232 228 <iframe width="88" height="31" style="border:none" src="/button.min.html"></iframe> 233 - <a href={friends[0].href} rel="noopener nofollow"> 234 - <img src={friends[0].src} alt={friends[0].alt} width="88" height="31" /> 229 + <a href={friends["88x31"][0].href} rel="noopener nofollow"> 230 + <img src={friends["88x31"][0].src} alt={friends["88x31"][0].alt} width="88" height="31" /> 235 231 </a> 236 - <iframe 237 - width="88" 238 - height="31" 239 - style="border:none" 240 - sandbox="allow-scripts allow-popups" 241 - srcdoc="<!doctype html><body onload=&#34;d=d.style,d.position=`absolute`,x=0,y=Math.random()*66|0,u=v=1,c=3;setInterval`x+=u${166}y+=v,z=x<=0||x>=20,w=y<=0||y>=67;z&&w?c=(c+3)%6-1:0,u^=-z-z,v^=-w-w;d.background='hwb('+60*c+' 0 0)';d.top=x+'px',d.left=y+'px'`&#34;bgcolor=#000><a href=https://github.com/rniii target=_blank><img id=d src=data:image/gif;base64,R0lGODdhFQALAHcAACH5BAkKAAAALAAAAAAVAAsAgAAAAAAAAAInDI4Xa6m8EkNQPQtivnvuH3mc1pSlU3EUxJrtC8eig851Q2daTjMFADs>" 242 - /> 243 - {friends.slice(1).map(({ href, src, alt }) => ( 232 + {friends.iframe.map((script) => ( 233 + <iframe 234 + width="88" 235 + height="31" 236 + style="border:none" 237 + sandbox="allow-scripts allow-popups" 238 + srcdoc={script} 239 + /> 240 + ))} 241 + {friends["88x31"].slice(1).map(({ href, src, alt }) => ( 244 242 <a href={href} rel="noopener nofollow"> 245 243 <img src={src} alt={alt} width="88" height="31" onerror="this.parentElement.remove()" /> 246 244 </a> ··· 248 246 </div> 249 247 </section> 250 248 <section id="donate"> 251 - <SectionTitle>Piggy bank</SectionTitle> 249 + <Heading>Piggy bank</Heading> 252 250 <p> 253 251 If you enjoy throwing money at people on the internet, please consider me to help keep my projects alive and 254 252 help me navigate some rough financial patches and stay afloat while things are tight (like rn) 255 253 </p> 256 - <KVTable 254 + <PropertyTable 257 255 data={{ 258 256 "GitHub Sponsors": ["adoravel", "https://github.com/sponsors/adoravel"], 259 257 "Ko-Fi": ["west", "https://ko-fi.com/west"],
+3 -4
src/routes/reports.tsx
··· 3 3 * SPDX-License-Identifier: AGPL-3.0-or-later 4 4 */ 5 5 6 - import { css, Import } from "~/mech/css.ts"; 6 + import { css } from "~/lib/css.ts"; 7 7 import { Layout } from "~/layout.tsx"; 8 8 9 - const reports = css(` 9 + const Styled = css(` 10 10 .sorry {} 11 11 `); 12 12 13 13 export default () => { 14 14 return ( 15 - <Layout class={reports.scope}> 16 - <Import styles={[reports]} /> 15 + <Layout scope={Styled}> 17 16 <span>wip, sorry</span> 18 17 </Layout> 19 18 );
-23
src/utils/temp.ts
··· 1 - /** 2 - * Copyright (c) 2025 adoravel 3 - * SPDX-License-Identifier: AGPL-3.0-or-later 4 - */ 5 - 6 - export async function withInterval<T>( 7 - callback: () => Promise<T>, 8 - seconds: number, 9 - ): Promise<() => T> { 10 - let value: T = await callback(); 11 - 12 - async function tick() { 13 - try { 14 - value = await callback(); 15 - } catch (e) { 16 - console.warn("withInterval: op failed, keeping stale value", e); 17 - } 18 - setInterval(tick, seconds * 1000); 19 - } 20 - setInterval(tick, seconds * 1000); 21 - 22 - return () => value; 23 - }
static/88x31/paige.gif assets/88x31/paige.gif
static/bnuy.webp assets/bnuy.webp
static/button.html assets/button.html
+109
static/fonts/bricolage-grotesque/import.css
··· 1 + /* bricolage-grotesque-latin-400-normal */ 2 + @font-face { 3 + font-family: 'Bricolage Grotesque'; 4 + font-style: normal; 5 + font-display: swap; 6 + font-weight: 400; 7 + src: url(webfonts/bricolage-grotesque-latin-400-normal.woff2) format('woff2'), 8 + url(webfonts/bricolage-grotesque-latin-400-normal.woff) format('woff'); 9 + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 10 + } 11 + 12 + /* bricolage-grotesque-latin-500-normal */ 13 + @font-face { 14 + font-family: 'Bricolage Grotesque'; 15 + font-style: normal; 16 + font-display: swap; 17 + font-weight: 500; 18 + src: url(webfonts/bricolage-grotesque-latin-500-normal.woff2) format('woff2'), 19 + url(webfonts/bricolage-grotesque-latin-500-normal.woff) format('woff'); 20 + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 21 + } 22 + 23 + /* bricolage-grotesque-latin-600-normal */ 24 + @font-face { 25 + font-family: 'Bricolage Grotesque'; 26 + font-style: normal; 27 + font-display: swap; 28 + font-weight: 600; 29 + src: url(webfonts/bricolage-grotesque-latin-600-normal.woff2) format('woff2'), 30 + url(webfonts/bricolage-grotesque-latin-600-normal.woff) format('woff'); 31 + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 32 + } 33 + 34 + /* bricolage-grotesque-latin-700-normal */ 35 + @font-face { 36 + font-family: 'Bricolage Grotesque'; 37 + font-style: normal; 38 + font-display: swap; 39 + font-weight: 700; 40 + src: url(webfonts/bricolage-grotesque-latin-700-normal.woff2) format('woff2'), 41 + url(webfonts/bricolage-grotesque-latin-700-normal.woff) format('woff'); 42 + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 43 + } 44 + 45 + /* bricolage-grotesque-latin-800-normal */ 46 + @font-face { 47 + font-family: 'Bricolage Grotesque'; 48 + font-style: normal; 49 + font-display: swap; 50 + font-weight: 800; 51 + src: url(webfonts/bricolage-grotesque-latin-800-normal.woff2) format('woff2'), 52 + url(webfonts/bricolage-grotesque-latin-800-normal.woff) format('woff'); 53 + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 54 + } 55 + 56 + /* bricolage-grotesque-latin-ext-400-normal */ 57 + @font-face { 58 + font-family: 'Bricolage Grotesque'; 59 + font-style: normal; 60 + font-display: swap; 61 + font-weight: 400; 62 + src: url(webfonts/bricolage-grotesque-latin-ext-400-normal.woff2) format('woff2'), 63 + url(webfonts/bricolage-grotesque-latin-ext-400-normal.woff) format('woff'); 64 + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; 65 + } 66 + 67 + /* bricolage-grotesque-latin-ext-500-normal */ 68 + @font-face { 69 + font-family: 'Bricolage Grotesque'; 70 + font-style: normal; 71 + font-display: swap; 72 + font-weight: 500; 73 + src: url(webfonts/bricolage-grotesque-latin-ext-500-normal.woff2) format('woff2'), 74 + url(webfonts/bricolage-grotesque-latin-ext-500-normal.woff) format('woff'); 75 + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; 76 + } 77 + 78 + /* bricolage-grotesque-latin-ext-600-normal */ 79 + @font-face { 80 + font-family: 'Bricolage Grotesque'; 81 + font-style: normal; 82 + font-display: swap; 83 + font-weight: 600; 84 + src: url(webfonts/bricolage-grotesque-latin-ext-600-normal.woff2) format('woff2'), 85 + url(webfonts/bricolage-grotesque-latin-ext-600-normal.woff) format('woff'); 86 + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; 87 + } 88 + 89 + /* bricolage-grotesque-latin-ext-700-normal */ 90 + @font-face { 91 + font-family: 'Bricolage Grotesque'; 92 + font-style: normal; 93 + font-display: swap; 94 + font-weight: 700; 95 + src: url(webfonts/bricolage-grotesque-latin-ext-700-normal.woff2) format('woff2'), 96 + url(webfonts/bricolage-grotesque-latin-ext-700-normal.woff) format('woff'); 97 + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; 98 + } 99 + 100 + /* bricolage-grotesque-latin-ext-800-normal */ 101 + @font-face { 102 + font-family: 'Bricolage Grotesque'; 103 + font-style: normal; 104 + font-display: swap; 105 + font-weight: 800; 106 + src: url(webfonts/bricolage-grotesque-latin-ext-800-normal.woff2) format('woff2'), 107 + url(webfonts/bricolage-grotesque-latin-ext-800-normal.woff) format('woff'); 108 + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; 109 + }
static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-200-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-200-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-300-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-300-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-400-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-400-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-500-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-500-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-600-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-600-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-700-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-700-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-800-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-800-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-200-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-200-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-300-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-300-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-400-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-400-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-500-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-500-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-600-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-600-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-700-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-700-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-800-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-latin-ext-800-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-200-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-200-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-300-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-300-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-400-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-400-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-500-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-500-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-600-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-600-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-700-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-700-normal.woff2

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-800-normal.woff

This is a binary file and will not be displayed.

static/fonts/bricolage-grotesque/webfonts/bricolage-grotesque-vietnamese-800-normal.woff2

This is a binary file and will not be displayed.

static/fonts/iosevka-custom/TTF/IosevkaCustom-Bold.ttf static/fonts/iosevka-custom/ttf/IosevkaCustom-Bold.ttf
static/fonts/iosevka-custom/TTF/IosevkaCustom-BoldItalic.ttf static/fonts/iosevka-custom/ttf/IosevkaCustom-BoldItalic.ttf
static/fonts/iosevka-custom/TTF/IosevkaCustom-Extended.ttf static/fonts/iosevka-custom/ttf/IosevkaCustom-Extended.ttf
static/fonts/iosevka-custom/TTF/IosevkaCustom-ExtendedBold.ttf static/fonts/iosevka-custom/ttf/IosevkaCustom-ExtendedBold.ttf
static/fonts/iosevka-custom/TTF/IosevkaCustom-ExtendedBoldItalic.ttf static/fonts/iosevka-custom/ttf/IosevkaCustom-ExtendedBoldItalic.ttf
static/fonts/iosevka-custom/TTF/IosevkaCustom-ExtendedItalic.ttf static/fonts/iosevka-custom/ttf/IosevkaCustom-ExtendedItalic.ttf
static/fonts/iosevka-custom/TTF/IosevkaCustom-Italic.ttf static/fonts/iosevka-custom/ttf/IosevkaCustom-Italic.ttf
static/fonts/iosevka-custom/TTF/IosevkaCustom-Regular.ttf static/fonts/iosevka-custom/ttf/IosevkaCustom-Regular.ttf
+4 -4
static/fonts/iosevka-custom/import.css
··· 4 4 font-weight: 400; 5 5 font-stretch: normal; 6 6 font-style: normal; 7 - src: url("TTF/IosevkaCustom-Regular.ttf") format("truetype"); 7 + src: url("ttf/IosevkaCustom-Regular.ttf") format("truetype"); 8 8 } 9 9 10 10 @font-face { ··· 13 13 font-weight: 400; 14 14 font-stretch: normal; 15 15 font-style: italic; 16 - src: url("TTF/IosevkaCustom-Italic.ttf") format("truetype"); 16 + src: url("ttf/IosevkaCustom-Italic.ttf") format("truetype"); 17 17 } 18 18 19 19 @font-face { ··· 22 22 font-weight: 700; 23 23 font-stretch: normal; 24 24 font-style: normal; 25 - src: url("TTF/IosevkaCustom-Bold.ttf") format("truetype"); 25 + src: url("ttf/IosevkaCustom-Bold.ttf") format("truetype"); 26 26 } 27 27 28 28 @font-face { ··· 31 31 font-weight: 700; 32 32 font-stretch: normal; 33 33 font-style: italic; 34 - src: url("TTF/IosevkaCustom-BoldItalic.ttf") format("truetype"); 34 + src: url("ttf/IosevkaCustom-BoldItalic.ttf") format("truetype"); 35 35 }
static/fonts/space-grotesk/WOFF2/space-grotesk-v22-latin-700.woff2 static/fonts/space-grotesk/webfonts/space-grotesk-v22-latin-700.woff2
+2 -1
static/fonts/space-grotesk/import.css
··· 1 1 @font-face { 2 2 font-family: 'Space Grotesk'; 3 3 font-style: normal; 4 + font-display: swap; 4 5 font-weight: 700; 5 6 src: local('Space Grotesk Bold'), local('SpaceGrotesk-Bold'), 6 - url('WOFF2/space-grotesk-v22-latin-700.woff2') format('woff2'); 7 + url('webfonts/space-grotesk-v22-latin-700.woff2') format('woff2'); 7 8 }