Leaflet Blog in Deno Fresh

Compare changes

Choose any two refs to compare.

+1
.gitignore
···
_fresh/
# npm dependencies
node_modules/
+
**/.DS_Store
+21
Dockerfile
···
+
FROM denoland/deno:ubuntu
+
+
USER root
+
+
# Create and set up directory
+
WORKDIR /app
+
RUN chown -R deno:deno /app
+
+
# Copy files at build time
+
COPY . /app/
+
+
# Ensure Deno user has write permissions
+
RUN chown -R deno:deno /app
+
+
# Run the build step
+
RUN deno task build
+
+
EXPOSE 8000
+
+
# Run the application
+
CMD ["run", "-A", "main.ts"]
+119
components/TextBlock.tsx
···
+
import { PubLeafletBlocksText } from "npm:@atcute/leaflet";
+
+
interface TextBlockProps {
+
plaintext: string;
+
facets?: PubLeafletBlocksText.Main["facets"];
+
}
+
+
interface LinkFeature {
+
$type: "pub.leaflet.richtext.facet#link";
+
uri: string;
+
}
+
+
function byteToCharIndex(text: string, byteIndex: number): number {
+
const textEncoder = new TextEncoder();
+
const textDecoder = new TextDecoder();
+
const fullBytes = textEncoder.encode(text);
+
const bytes = fullBytes.slice(0, byteIndex);
+
return textDecoder.decode(bytes).length;
+
}
+
+
export function TextBlock({ plaintext, facets }: TextBlockProps) {
+
// Only process facets if at least one facet has features
+
if (!facets || !facets.some((f) => f.features && f.features.length > 0)) {
+
return <>{plaintext}</>;
+
}
+
+
const parts: (string | { text: string; type: string; uri?: string })[] = [];
+
let lastIndex = 0;
+
+
facets.forEach((facet) => {
+
// Convert byte positions to character positions
+
const charStart = byteToCharIndex(plaintext, facet.index.byteStart);
+
const charEnd = byteToCharIndex(plaintext, facet.index.byteEnd);
+
const charLastIndex = byteToCharIndex(plaintext, lastIndex);
+
+
if (charStart > charLastIndex) {
+
parts.push(plaintext.slice(charLastIndex, charStart));
+
}
+
+
const text = plaintext.slice(charStart, charEnd);
+
const feature = facet.features?.[0];
+
+
if (!feature) {
+
parts.push(text);
+
return;
+
}
+
+
if (feature.$type === "pub.leaflet.richtext.facet#bold") {
+
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#highlight") {
+
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#italic") {
+
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#strikethrough") {
+
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#underline") {
+
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#link") {
+
const linkFeature = feature as LinkFeature;
+
parts.push({ text, type: feature.$type, uri: linkFeature.uri });
+
} else {
+
parts.push(text);
+
}
+
+
lastIndex = facet.index.byteEnd;
+
});
+
+
// Convert final lastIndex from bytes to characters
+
const charLastIndex = byteToCharIndex(plaintext, lastIndex);
+
+
if (charLastIndex < plaintext.length) {
+
parts.push(plaintext.slice(charLastIndex));
+
}
+
+
return (
+
<>
+
{parts.map((part, i) => {
+
if (typeof part === "string") {
+
return part;
+
}
+
+
switch (part.type) {
+
case "pub.leaflet.richtext.facet#bold":
+
return <strong key={i}>{part.text}</strong>;
+
case "pub.leaflet.richtext.facet#highlight":
+
return (
+
<mark
+
key={i}
+
className="bg-blue-100 dark:bg-blue-900 text-inherit rounded px-1"
+
style={{ borderRadius: "0.375rem" }}
+
>
+
{part.text}
+
</mark>
+
);
+
case "pub.leaflet.richtext.facet#italic":
+
return <em key={i}>{part.text}</em>;
+
case "pub.leaflet.richtext.facet#strikethrough":
+
return <s key={i}>{part.text}</s>;
+
case "pub.leaflet.richtext.facet#underline":
+
return <u key={i}>{part.text}</u>;
+
case "pub.leaflet.richtext.facet#link":
+
return (
+
<a
+
key={i}
+
href={part.uri}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-600 dark:text-blue-400 hover:underline"
+
>
+
{part.text}
+
</a>
+
);
+
default:
+
return part.text;
+
}
+
})}
+
</>
+
);
+
}
+6 -5
components/bluesky-embed.tsx
···
"use client";
-
import { useEffect, useId, useState } from "npm:react";
+
import { useEffect, useId, useState } from "preact/hooks";
const EMBED_URL = "https://embed.bsky.app";
···
useEffect(() => {
const abortController = new AbortController();
const { signal } = abortController;
-
window.addEventListener(
+
globalThis.addEventListener(
"message",
(event) => {
if (event.origin !== EMBED_URL) {
···
};
}, [id]);
-
const ref_url =
-
"https://" + "knotbin.xyz/post/" + uri.split("/").pop();
+
const ref_url = "https://" + "knotbin.com/post/" + uri.split("/").pop();
const searchParams = new URLSearchParams();
searchParams.set("id", id);
···
className="w-full block border-none grow"
style={{ height }}
data-bluesky-uri={uri}
-
src={`${EMBED_URL}/embed/${uri.slice("at://".length)}?${searchParams.toString()}`}
+
src={`${EMBED_URL}/embed/${
+
uri.slice("at://".length)
+
}?${searchParams.toString()}`}
width="100%"
frameBorder="0"
scrolling="no"
+27 -9
components/footer.tsx
···
-
import { siBluesky as BlueskyIcon, siGithub as GithubIcon } from "npm:simple-icons";
-
+
import {
+
siBluesky as BlueskyIcon,
+
siGithub as GithubIcon,
+
} from "npm:simple-icons";
+
import { useState } from "preact/hooks";
import { env } from "../lib/env.ts";
export function Footer() {
+
const [blueskyHovered, setBlueskyHovered] = useState(false);
+
const [githubHovered, setGithubHovered] = useState(false);
+
return (
-
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
+
<footer class="py-8 flex gap-6 flex-wrap items-center justify-center text-sm">
<a
-
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
+
class="flex items-center gap-2 relative group"
href={`https://bsky.app/profile/${env.NEXT_PUBLIC_BSKY_DID}`}
target="_blank"
rel="noopener noreferrer"
+
data-hovered={blueskyHovered}
+
onMouseEnter={() => setBlueskyHovered(true)}
+
onMouseLeave={() => setBlueskyHovered(false)}
>
<svg
width={16}
height={16}
viewBox="0 0 24 24"
-
className="fill-black dark:fill-white"
+
class="fill-black dark:fill-white"
>
<path d={BlueskyIcon.path} />
</svg>
-
Bluesky
+
<span class="opacity-50 group-hover:opacity-100 transition-opacity">
+
Bluesky
+
</span>
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
</a>
<a
-
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
+
class="flex items-center gap-2 relative group"
href="https://github.com/knotbin"
target="_blank"
rel="noopener noreferrer"
+
data-hovered={githubHovered}
+
onMouseEnter={() => setGithubHovered(true)}
+
onMouseLeave={() => setGithubHovered(false)}
>
<svg
width={16}
height={16}
viewBox="0 0 24 24"
-
className="fill-black dark:fill-white"
+
class="fill-black dark:fill-white"
>
<path d={GithubIcon.path} />
</svg>
-
GitHub
+
<span class="opacity-50 group-hover:opacity-100 transition-opacity">
+
GitHub
+
</span>
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
</a>
</footer>
);
+15
components/post-info.tsx
···
import { Paragraph } from "./typography.tsx";
import type { ComponentChildren } from "preact";
+
// Calculate reading time based on content length
+
function getReadingTime(content: string): number {
+
const wordsPerMinute = 200;
+
const words = content.trim().split(/\s+/).length;
+
const minutes = Math.max(1, Math.ceil(words / wordsPerMinute));
+
return minutes;
+
}
+
export function PostInfo({
createdAt,
content,
···
className?: string;
children?: ComponentChildren;
}) {
+
const readingTime = getReadingTime(content);
+
return (
<Paragraph className={className}>
{includeAuthor && (
···
&middot;{" "}
</>
)}
+
<span>
+
<span style={{ lineHeight: 1, marginRight: "0.25rem" }}>
+
{readingTime} min read
+
</span>
+
</span>
{children}
</Paragraph>
);
+33 -16
components/post-list-item.tsx
···
"use client";
import { useEffect, useRef, useState } from "preact/hooks";
-
import { ComWhtwndBlogEntry } from "npm:@atcute/client/whitewind";
+
import {
+
type PubLeafletBlocksText,
+
type PubLeafletDocument,
+
} from "npm:@atcute/leaflet";
import { cx } from "../lib/cx.ts";
···
post,
rkey,
}: {
-
post: ComWhtwndBlogEntry.Record;
+
post: PubLeafletDocument.Main;
rkey: string;
}) {
const [isHovered, setIsHovered] = useState(false);
···
timeoutRef.current = setTimeout(() => {
setIsHovered(false);
setIsLeaving(false);
-
}, 300); // Match the animation duration
+
}, 300); // Match animation duration
};
+
// Gather all text blocks' plaintext for preview and reading time
+
const allText = post.pages?.[0]?.blocks
+
?.filter((block) => block.block.$type === "pub.leaflet.blocks.text")
+
.map((block) => (block.block as PubLeafletBlocksText.Main).plaintext)
+
.join(" ") || "";
+
return (
<>
{isHovered && (
<div
className={cx(
-
"fixed inset-0 pointer-events-none z-0 overflow-hidden flex items-center",
+
"fixed inset-0 pointer-events-none z-0",
isLeaving ? "animate-fade-out" : "animate-fade-in",
)}
>
-
<div className="absolute whitespace-nowrap animate-marquee font-serif font-medium uppercase overflow-visible flex items-center justify-center leading-none">
-
{Array(10).fill(post.title).join(" ยท ")}
+
<div className="h-full w-full pt-[120px] flex items-center overflow-hidden">
+
<div className="whitespace-nowrap animate-marquee font-serif font-medium uppercase leading-[0.8] text-[20vw] opacity-[0.015] -rotate-12 absolute left-0">
+
{Array(8).fill(post.title).join(" ยท ")}
+
</div>
</div>
</div>
)}
<a
href={`/post/${rkey}`}
-
className="w-full group"
+
className="w-full group block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
-
<article className="w-full flex flex-row border-b items-stretch relative transition-color backdrop-blur-sm hover:bg-slate-700/5 dark:hover:bg-slate-200/10">
-
<div className="w-1.5 diagonal-pattern shrink-0 opacity-20 group-hover:opacity-100 transition-opacity" />
-
<div className="flex-1 py-2 px-4 z-10 relative">
-
<Title className="text-lg" level="h3">
+
<article className="w-full flex flex-row border-b items-stretch relative transition-colors duration-300 ease-[cubic-bezier(0.33,0,0.67,1)] backdrop-blur-sm hover:bg-slate-700/5 dark:hover:bg-slate-200/10">
+
<div className="w-1.5 diagonal-pattern shrink-0 opacity-20 group-hover:opacity-100 transition-opacity duration-300 ease-[cubic-bezier(0.33,0,0.67,1)]" />
+
<div className="flex-1 py-2 px-4 z-10 relative w-full">
+
<Title className="text-lg w-full" level="h3">
{post.title}
</Title>
<PostInfo
-
content={post.content}
-
createdAt={post.createdAt}
-
className="text-xs mt-1"
-
>
-
</PostInfo>
+
content={allText}
+
createdAt={post.publishedAt}
+
className="text-xs mt-1 w-full"
+
/>
+
<div className="grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.33,0,0.67,1)] grid-rows-[0fr] group-hover:grid-rows-[1fr] opacity-0 group-hover:opacity-100 mt-2">
+
<div className="overflow-hidden">
+
<p className="text-sm text-slate-600 dark:text-slate-300 break-words line-clamp-3">
+
{allText}
+
</p>
+
</div>
+
</div>
</div>
</article>
</a>
+150
components/project-list-item.tsx
···
+
"use client";
+
+
import { useEffect, useRef, useState } from "preact/hooks";
+
import { cx } from "../lib/cx.ts";
+
import { Title } from "./typography.tsx";
+
+
interface Project {
+
id: string;
+
title: string;
+
description: string;
+
technologies: string[];
+
url: string;
+
demo?: string;
+
year: string;
+
status: "active" | "completed" | "maintained" | "archived";
+
}
+
+
export function ProjectListItem({ project }: { project: Project }) {
+
const [isHovered, setIsHovered] = useState(false);
+
const [isLeaving, setIsLeaving] = useState(false);
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+
// Clean up any timeouts on unmount
+
useEffect(() => {
+
return () => {
+
if (timeoutRef.current) {
+
clearTimeout(timeoutRef.current);
+
}
+
};
+
}, []);
+
+
const handleMouseEnter = () => {
+
if (timeoutRef.current) {
+
clearTimeout(timeoutRef.current);
+
}
+
setIsLeaving(false);
+
setIsHovered(true);
+
};
+
+
const handleMouseLeave = () => {
+
setIsLeaving(true);
+
timeoutRef.current = setTimeout(() => {
+
setIsHovered(false);
+
setIsLeaving(false);
+
}, 300); // Match animation duration
+
};
+
+
const getStatusColor = (status: string) => {
+
switch (status) {
+
case "active":
+
return "text-green-600 dark:text-green-400";
+
case "completed":
+
return "text-blue-600 dark:text-blue-400";
+
case "maintained":
+
return "text-yellow-600 dark:text-yellow-400";
+
case "archived":
+
return "text-slate-500 dark:text-slate-400";
+
default:
+
return "text-slate-600 dark:text-slate-300";
+
}
+
};
+
+
return (
+
<>
+
{isHovered && (
+
<div
+
className={cx(
+
"fixed inset-0 pointer-events-none z-0",
+
isLeaving ? "animate-fade-out" : "animate-fade-in",
+
)}
+
>
+
<div className="h-full w-full pt-[120px] flex items-center overflow-hidden">
+
<div className="whitespace-nowrap animate-marquee font-serif font-medium uppercase leading-[0.8] text-[20vw] opacity-[0.015] -rotate-12 absolute left-0">
+
{Array(8).fill(project.title).join(" ยท ")}
+
</div>
+
</div>
+
</div>
+
)}
+
<a
+
href={project.demo || project.url}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="w-full group block"
+
onMouseEnter={handleMouseEnter}
+
onMouseLeave={handleMouseLeave}
+
>
+
<article className="w-full flex flex-row border-b items-stretch relative transition-colors duration-300 ease-[cubic-bezier(0.33,0,0.67,1)] backdrop-blur-sm hover:bg-slate-700/5 dark:hover:bg-slate-200/10">
+
<div className="w-1.5 diagonal-pattern shrink-0 opacity-20 group-hover:opacity-100 transition-opacity duration-300 ease-[cubic-bezier(0.33,0,0.67,1)]" />
+
<div className="flex-1 py-2 px-4 z-10 relative w-full">
+
<div className="flex items-start justify-between gap-4">
+
<Title className="text-lg w-full flex-1" level="h3">
+
{project.title}
+
</Title>
+
<div className="flex items-center gap-2 shrink-0">
+
<span className="text-xs text-slate-500 dark:text-slate-400">
+
{project.year}
+
</span>
+
<span
+
className={cx(
+
"text-xs font-medium capitalize",
+
getStatusColor(project.status),
+
)}
+
>
+
{project.status}
+
</span>
+
</div>
+
</div>
+
+
<div className="flex flex-wrap gap-1 mt-2">
+
{project.technologies.slice(0, 4).map((tech) => (
+
<span
+
key={tech}
+
className="text-xs px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded-sm text-slate-600 dark:text-slate-300"
+
>
+
{tech}
+
</span>
+
))}
+
{project.technologies.length > 4 && (
+
<span className="text-xs px-2 py-0.5 text-slate-500 dark:text-slate-400">
+
+{project.technologies.length - 4} more
+
</span>
+
)}
+
</div>
+
+
<div className="grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.33,0,0.67,1)] grid-rows-[0fr] group-hover:grid-rows-[1fr] opacity-0 group-hover:opacity-100 mt-3">
+
<div className="overflow-hidden">
+
<p className="text-sm text-slate-600 dark:text-slate-300 break-words line-clamp-3 mb-3">
+
{project.description}
+
</p>
+
<div className="flex gap-3">
+
{project.demo && (
+
<a
+
href={project.url}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
+
onClick={(e) => e.stopPropagation()}
+
>
+
Source
+
</a>
+
)}
+
</div>
+
</div>
+
</div>
+
</div>
+
</article>
+
</a>
+
</>
+
);
+
}
+12 -4
components/typography.tsx
···
-
import { h } from "preact/src/index.d.ts";
+
import { h } from "preact";
import { cx } from "../lib/cx.ts";
export function Title({
···
return (
<Tag
className={cx(
-
"font-serif font-bold text-balance tracking-wide scroll-m-20 uppercase mt-8 [&>code]:text-[length:inherit] first:mt-0",
+
"font-serif font-bold tracking-wide scroll-m-20 uppercase mt-8 [&>code]:text-[length:inherit] first:mt-0 break-words text-wrap",
style,
className?.toString(),
)}
···
className,
...props
}: h.JSX.HTMLAttributes<HTMLParagraphElement>) {
-
return <p className={cx("font-sans text-pretty", className?.toString())} {...props} />;
+
return (
+
<p
+
className={cx("font-sans text-pretty", className?.toString())}
+
{...props}
+
/>
+
);
}
-
export function Code({ className, ...props }: h.JSX.HTMLAttributes<HTMLElement>) {
+
export function Code({
+
className,
+
...props
+
}: h.JSX.HTMLAttributes<HTMLElement>) {
return (
<code
className={cx(
+10 -15
deno.json
···
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
-
"lint": {
-
"rules": {
-
"tags": [
-
"fresh",
-
"recommended"
-
]
-
}
-
},
-
"exclude": [
-
"**/_fresh/*"
-
],
+
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
+
"exclude": ["**/_fresh/*"],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
+
"@atcute/atproto": "npm:@atcute/atproto@^3.0.1",
+
"@atcute/client": "npm:@atcute/client@^4.0.1",
+
"@atcute/leaflet": "npm:@atcute/leaflet@^1.0.2",
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
+
"@preact-icons/cg": "jsr:@preact-icons/cg@^1.0.13",
+
"@preact-icons/fi": "jsr:@preact-icons/fi@^1.0.13",
+
"@tabler/icons-preact": "npm:@tabler/icons-preact@^3.31.0",
"preact": "https://esm.sh/preact@10.22.0",
"preact/": "https://esm.sh/preact@10.22.0/",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
+
"rss": "npm:rss@^1.2.2",
"tailwindcss": "npm:tailwindcss@3.4.1",
"tailwindcss/": "npm:/tailwindcss@3.4.1/",
"tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js",
"$std/": "https://deno.land/std@0.216.0/"
},
-
"compilerOptions": {
-
"jsx": "react-jsx",
-
"jsxImportSource": "preact"
-
},
+
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
"nodeModulesDir": "auto"
}
+15
docker-compose.yml
···
+
version: "3.8"
+
services:
+
deno-app:
+
build: .
+
# Use named volume for persistent data
+
volumes:
+
- deno_data:/app/data
+
ports:
+
- "8001:8000"
+
environment:
+
- DENO_ENV=production
+
+
volumes:
+
deno_data:
+
name: "deno_data"
+10
fresh.gen.ts
···
import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx";
+
import * as $about from "./routes/about.tsx";
+
import * as $api_atproto_images from "./routes/api/atproto_images.ts";
import * as $index from "./routes/index.tsx";
import * as $post_slug_ from "./routes/post/[slug].tsx";
import * as $rss from "./routes/rss.ts";
+
import * as $work from "./routes/work.tsx";
+
import * as $layout from "./islands/layout.tsx";
import * as $post_list from "./islands/post-list.tsx";
+
import * as $project_list from "./islands/project-list.tsx";
import type { Manifest } from "$fresh/server.ts";
const manifest = {
routes: {
"./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app,
+
"./routes/about.tsx": $about,
+
"./routes/api/atproto_images.ts": $api_atproto_images,
"./routes/index.tsx": $index,
"./routes/post/[slug].tsx": $post_slug_,
"./routes/rss.ts": $rss,
+
"./routes/work.tsx": $work,
},
islands: {
+
"./islands/layout.tsx": $layout,
"./islands/post-list.tsx": $post_list,
+
"./islands/project-list.tsx": $project_list,
},
baseUrl: import.meta.url,
} satisfies Manifest;
+105
islands/layout.tsx
···
+
import { Footer } from "../components/footer.tsx";
+
import type { ComponentChildren } from "preact";
+
import { useEffect, useState } from "preact/hooks";
+
import { useSignal } from "@preact/signals";
+
+
export function Layout({ children }: { children: ComponentChildren }) {
+
const [isScrolled, setIsScrolled] = useState(false);
+
const [blogHovered, setBlogHovered] = useState(false);
+
const [workHovered, setWorkHovered] = useState(false);
+
const [aboutHovered, setAboutHovered] = useState(false);
+
const pathname = useSignal("");
+
+
const isActive = (href: string) => {
+
if (href === "/") {
+
return pathname.value === "/" || pathname.value.startsWith("/post/");
+
}
+
return pathname.value === href;
+
};
+
+
useEffect(() => {
+
const handleScroll = () => {
+
setIsScrolled(globalThis.scrollY > 0);
+
};
+
+
const handlePathChange = () => {
+
pathname.value = globalThis.location.pathname;
+
};
+
+
globalThis.addEventListener("scroll", handleScroll);
+
globalThis.addEventListener("popstate", handlePathChange);
+
handleScroll(); // Check initial scroll position
+
handlePathChange(); // Set initial path
+
+
return () => {
+
globalThis.removeEventListener("scroll", handleScroll);
+
globalThis.removeEventListener("popstate", handlePathChange);
+
};
+
}, []);
+
+
return (
+
<div class="flex flex-col min-h-dvh">
+
<nav class="w-full sticky top-0 z-50 backdrop-blur-sm transition-[padding,border-color] duration-200">
+
<div class="relative">
+
<div
+
class="absolute inset-x-0 bottom-0 h-2 diagonal-pattern opacity-0 transition-opacity duration-300"
+
style={{ opacity: isScrolled ? 0.25 : 0 }}
+
/>
+
<div class="max-w-screen-2xl mx-auto px-8 py-5 flex justify-between items-center">
+
<div class="flex items-center gap-7">
+
<a href="/" class="font-serif text-xl">
+
knotbin
+
</a>
+
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700"></div>
+
<div class="text-base flex items-center gap-7">
+
<a
+
href="/"
+
class="relative group"
+
data-current={isActive("/")}
+
data-hovered={blogHovered}
+
onMouseEnter={() => setBlogHovered(true)}
+
onMouseLeave={() => setBlogHovered(false)}
+
>
+
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
+
blog
+
</span>
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 group-data-[current=true]:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
+
</a>
+
<a
+
href="/work"
+
class="relative group"
+
data-current={isActive("/work")}
+
data-hovered={workHovered}
+
onMouseEnter={() => setWorkHovered(true)}
+
onMouseLeave={() => setWorkHovered(false)}
+
>
+
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
+
work
+
</span>
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 group-data-[current=true]:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
+
</a>
+
<a
+
href="/about"
+
class="relative group"
+
data-current={isActive("/about")}
+
data-hovered={aboutHovered}
+
onMouseEnter={() => setAboutHovered(true)}
+
onMouseLeave={() => setAboutHovered(false)}
+
>
+
<span class="opacity-50 group-hover:opacity-100 group-data-[current=true]:opacity-100 transition-opacity">
+
about
+
</span>
+
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 group-data-[current=true]:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
+
</a>
+
</div>
+
</div>
+
</div>
+
</div>
+
</nav>
+
+
<main class="flex-1">{children}</main>
+
+
<Footer />
+
</div>
+
);
+
}
+8 -9
islands/post-list.tsx
···
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import { PostListItem } from "../components/post-list-item.tsx";
+
import { PubLeafletDocument } from "@atcute/leaflet";
interface PostRecord {
-
value: any;
+
value: PubLeafletDocument.Main;
uri: string;
}
-
export default function PostList({ posts: initialPosts }: { posts: PostRecord[] }) {
+
export default function PostList({
+
posts: initialPosts,
+
}: {
+
posts: PostRecord[];
+
}) {
const posts = useSignal(initialPosts);
useEffect(() => {
···
{posts.value?.map((record) => {
const post = record.value;
const rkey = record.uri.split("/").pop() || "";
-
return (
-
<PostListItem
-
key={record.uri}
-
post={post}
-
rkey={rkey}
-
/>
-
);
+
return <PostListItem key={record.uri} post={post} rkey={rkey} />;
})}
</>
);
+35
islands/project-list.tsx
···
+
import { useSignal } from "@preact/signals";
+
import { useEffect } from "preact/hooks";
+
import { ProjectListItem } from "../components/project-list-item.tsx";
+
+
interface Project {
+
id: string;
+
title: string;
+
description: string;
+
technologies: string[];
+
url: string;
+
demo?: string;
+
year: string;
+
status: "active" | "completed" | "maintained" | "archived";
+
}
+
+
export default function ProjectList(
+
{ projects: initialProjects }: { projects: Project[] },
+
) {
+
const projects = useSignal(initialProjects);
+
+
useEffect(() => {
+
projects.value = initialProjects;
+
}, [initialProjects]);
+
+
return (
+
<>
+
{projects.value?.map((project) => (
+
<ProjectListItem
+
key={project.id}
+
project={project}
+
/>
+
))}
+
</>
+
);
+
}
+15 -17
lib/api.ts
···
import { bsky } from "./bsky.ts";
import { env } from "./env.ts";
-
import { type ComAtprotoRepoListRecords } from "npm:@atcute/client/lexicons";
-
import { type ComWhtwndBlogEntry } from "npm:@atcute/whitewind";
+
import { type ActorIdentifier } from "npm:@atcute/lexicons";
+
import { type ComAtprotoRepoListRecords } from "npm:@atcute/atproto";
+
import { type PubLeafletDocument } from "npm:@atcute/leaflet";
export async function getPosts() {
const posts = await bsky.get("com.atproto.repo.listRecords", {
params: {
-
repo: env.NEXT_PUBLIC_BSKY_DID,
-
collection: "com.whtwnd.blog.entry",
+
repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier,
+
collection: "pub.leaflet.document",
// todo: pagination
},
});
-
return posts.data.records.filter(
-
drafts,
-
) as (ComAtprotoRepoListRecords.Record & {
-
value: ComWhtwndBlogEntry.Record;
-
})[];
-
}
-
function drafts(record: ComAtprotoRepoListRecords.Record) {
-
if (Deno.env.get("NODE_ENV") === "development") return true;
-
const post = record.value as ComWhtwndBlogEntry.Record;
-
return post.visibility === "public";
+
if ("error" in posts.data) {
+
throw new Error(posts.data.error);
+
}
+
+
return posts.data.records as (ComAtprotoRepoListRecords.Record & {
+
value: PubLeafletDocument.Main;
+
})[];
}
export async function getPost(rkey: string) {
const post = await bsky.get("com.atproto.repo.getRecord", {
params: {
-
repo: env.NEXT_PUBLIC_BSKY_DID,
+
repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier,
rkey: rkey,
-
collection: "com.whtwnd.blog.entry",
+
collection: "pub.leaflet.document",
},
});
return post.data as ComAtprotoRepoListRecords.Record & {
-
value: ComWhtwndBlogEntry.Record;
+
value: PubLeafletDocument.Main;
};
}
+3 -4
lib/bsky.ts
···
-
import { CredentialManager, XRPC } from "npm:@atcute/client";
+
import { Client, simpleFetchHandler } from "@atcute/client";
import { env } from "./env.ts";
-
const handler = new CredentialManager({
+
const handler = simpleFetchHandler({
service: env.NEXT_PUBLIC_BSKY_PDS,
-
fetch,
});
-
export const bsky = new XRPC({ handler });
+
export const bsky = new Client({ handler });
+6 -6
lib/env.ts
···
const envVars = {
NODE_ENV: "production",
-
PLAUSIBLE_SITE_ID: "knotbin.xyz",
-
PLAUSIBLE_DOMAIN: "https://plausible.knotbin.xyz",
+
PLAUSIBLE_SITE_ID: "knotbin.com",
+
PLAUSIBLE_DOMAIN: "https://plausible.knotbin.com",
PLAUSIBLE_API_KEY: "",
NEXT_PUBLIC_BSKY_DID: "did:plc:6hbqm2oftpotwuw7gvvrui3i",
-
NEXT_PUBLIC_BSKY_PDS: "https://puffball.us-east.host.bsky.network",
+
NEXT_PUBLIC_BSKY_PDS: "https://knotbin.xyz",
};
// Use cleanEnv to validate and parse the environment variables
···
default: "production",
devDefault: "development",
}),
-
PLAUSIBLE_SITE_ID: str({ default: "knotbin.xyz" }),
-
PLAUSIBLE_DOMAIN: url({ default: "https://plausible.knotbin.xyz" }),
+
PLAUSIBLE_SITE_ID: str({ default: "knotbin.com" }),
+
PLAUSIBLE_DOMAIN: url({ default: "https://plausible.knotbin.com" }),
PLAUSIBLE_API_KEY: str({ default: "" }),
NEXT_PUBLIC_BSKY_DID: str({ default: "did:plc:6hbqm2oftpotwuw7gvvrui3i" }),
NEXT_PUBLIC_BSKY_PDS: url({
-
default: "https://puffball.us-east.host.bsky.network",
+
default: "https://knotbin.xyz",
}),
});
-21
lib/google-font.ts
···
-
// from https://github.com/kosei28/vercel-og-google-fonts/blob/main/src/utils/font.ts
-
export async function loadGoogleFont(font: string, text: string) {
-
const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(
-
text,
-
)}`;
-
-
const css = await (await fetch(url)).text();
-
-
const resource = css.match(
-
/src: url\((.+)\) format\('(opentype|truetype)'\)/,
-
);
-
-
if (resource) {
-
const res = await fetch(resource[1]);
-
if (res.status == 200) {
-
return await res.arrayBuffer();
-
}
-
}
-
-
throw new Error("failed to load font data");
-
}
+14 -15
routes/_404.tsx
···
+
import { Title } from "../components/typography.tsx";
import { Head } from "$fresh/runtime.ts";
+
import { Layout } from "../islands/layout.tsx";
export default function Error404() {
return (
···
<Head>
<title>404 - Page not found</title>
</Head>
-
<div class="px-4 py-8 mx-auto bg-[#86efac]">
-
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
-
<img
-
class="my-6"
-
src="/logo.svg"
-
width="128"
-
height="128"
-
alt="the Fresh logo: a sliced lemon dripping with juice"
-
/>
-
<h1 class="text-4xl font-bold">404 - Page not found</h1>
-
<p class="my-4">
-
The page you were looking for doesn't exist.
-
</p>
-
<a href="/" class="underline">Go back home</a>
+
<Layout>
+
<div class="flex-1 flex items-center justify-center">
+
<div class="p-8 pb-20 sm:p-20 text-center">
+
<Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-6">
+
Page not found
+
</Title>
+
<p class="my-4">The page you were looking for doesn't exist.</p>
+
<a href="/" class="underline">
+
Go back home
+
</a>
+
</div>
</div>
-
</div>
+
</Layout>
</>
);
}
+38
routes/about.tsx
···
+
import { Title } from "../components/typography.tsx";
+
import { Head } from "$fresh/runtime.ts";
+
import { Layout } from "../islands/layout.tsx";
+
+
export default function About() {
+
return (
+
<>
+
<Head>
+
<title>About - knotbin</title>
+
</Head>
+
<Layout>
+
<div class="p-8 pb-20 gap-16 sm:p-20">
+
<div class="max-w-[600px] mx-auto">
+
<Title class="font-serif-italic text-`4xl sm:text-5xl lowercase mb-12">
+
About
+
</Title>
+
+
<div class="prose prose-slate dark:prose-invert space-y-8">
+
<p>
+
I'm a fifteen year-old developer. I'm experienced in iOS
+
development, and a winner of the 2024 Apple Swift Student
+
Challenge. I'm very interested in decentralized systems and AT
+
Protocol in particular. I love designing and building beautiful
+
interfaces, and learning about amazing systems.
+
</p>
+
+
<p>
+
Currently, I'm working with Spark to build a shortform video
+
platform on the AT Protocol. I'm also working on my own
+
projects, and always thinking about big ideas and small details.
+
</p>
+
</div>
+
</div>
+
</div>
+
</Layout>
+
</>
+
);
+
}
+41
routes/api/atproto_images.ts
···
+
import { Handlers } from "$fresh/server.ts";
+
import { IdResolver } from "npm:@atproto/identity";
+
+
const idResolver = new IdResolver();
+
+
export const handler: Handlers = {
+
async GET(req) {
+
const url = new URL(req.url);
+
const did = url.searchParams.get("did") ?? "";
+
const cid = url.searchParams.get("cid") ?? "";
+
+
if (!did || !cid) {
+
return new Response("Missing did or cid", { status: 404 });
+
}
+
+
const identity = await idResolver.did.resolve(did);
+
const service = identity?.service?.find((f: any) =>
+
f.id === "#atproto_pds"
+
);
+
if (!service) {
+
return new Response("No PDS service found", { status: 404 });
+
}
+
+
const blobUrl =
+
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`;
+
const response = await fetch(blobUrl);
+
+
if (!response.ok) {
+
return new Response("Blob not found", { status: 404 });
+
}
+
+
// Clone the response to modify headers
+
const cachedResponse = new Response(response.body, response);
+
cachedResponse.headers.set(
+
"Cache-Control",
+
"public, max-age=31536000, immutable",
+
);
+
+
return cachedResponse;
+
},
+
};
+12 -47
routes/index.tsx
···
-
import { Footer } from "../components/footer.tsx";
import PostList from "../islands/post-list.tsx";
import { Title } from "../components/typography.tsx";
import { getPosts } from "../lib/api.ts";
+
import { Layout } from "../islands/layout.tsx";
export const dynamic = "force-static";
export const revalidate = 3600; // 1 hour
-
-
const stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware = [
-
"is looking into it",
-
"i think therefore imdb",
-
"isn't a real word",
-
"enjoys each protocol equally",
-
"is having a very semantic argument",
-
"fellas is it gay to write unit tests?",
-
"wrote these derivitive taglines",
-
"is way too into css animations",
-
"uses dark mode at noon",
-
"overthinks variable names",
-
"git pushes with -f",
-
"formats on save",
-
"is praising kier",
-
"pretends to understand monads",
-
"brags about their vim config",
-
"documents their code (lies)",
-
"isn't mysterious or important",
-
"wants to be included in discourse",
-
"is deeply offended by semicolons",
-
"is morraly opposed to touching grass",
-
"uses inline css (sue me)",
-
];
-
-
function getRandomTagline() {
-
return stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware[Math.floor(Math.random() * stupidSelfDeprecatingTaglinesToTryToPretendImSelfAware.length)];
-
}
export default async function Home() {
const posts = await getPosts();
-
const tagline = getRandomTagline();
return (
-
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-dvh p-8 pb-20 gap-16 sm:p-20">
-
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start w-full max-w-[600px]">
-
<div className="self-center flex flex-col">
-
<div className="relative">
-
<Title className="m-0 mb-6 font-serif-italic text-4xl sm:text-5xl lowercase">
-
knotbin
-
</Title>
-
<span className="absolute bottom-3 -right-2 font-bold text-xs opacity-50 text-right whitespace-nowrap">
-
{tagline}
-
</span>
-
</div>
-
</div>
+
<Layout>
+
<div class="p-8 pb-20 gap-16 sm:p-20">
+
<div class="max-w-[600px] mx-auto">
+
<Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-12">
+
Knotbin
+
</Title>
-
<div className="flex flex-col gap-4 w-full">
-
<PostList posts={posts} />
+
<div class="space-y-4 w-full">
+
<PostList posts={posts} />
+
</div>
</div>
-
</main>
-
<Footer />
-
</div>
+
</div>
+
</Layout>
);
}
+208 -114
routes/post/[slug].tsx
···
-
/** @jsxImportSource preact */
-
import { CSS, render } from "@deno/gfm";
import { Handlers, PageProps } from "$fresh/server.ts";
-
-
import { Footer } from "../../components/footer.tsx";
+
import { Layout } from "../../islands/layout.tsx";
import { PostInfo } from "../../components/post-info.tsx";
import { Title } from "../../components/typography.tsx";
import { getPost } from "../../lib/api.ts";
import { Head } from "$fresh/runtime.ts";
-
import { BlueskyComments } from "npm:bluesky-comments";
+
import { TextBlock } from "../../components/TextBlock.tsx";
+
import {
+
PubLeafletBlocksHeader,
+
PubLeafletBlocksImage,
+
PubLeafletBlocksText,
+
PubLeafletBlocksUnorderedList,
+
PubLeafletPagesLinearDocument,
+
} from "npm:@atcute/leaflet";
+
import { h } from "preact";
interface Post {
uri: string;
value: {
-
title: string;
-
content: string;
-
createdAt: string;
+
title?: string;
+
description?: string;
+
pages?: PubLeafletPagesLinearDocument.Main[];
+
publishedAt?: string;
};
}
-
// Only override backgrounds in dark mode to make them transparent
-
const transparentDarkModeCSS = `
-
@media (prefers-color-scheme: dark) {
-
.markdown-body {
-
color: white;
-
background-color: transparent;
-
}
+
export const handler: Handlers<Post> = {
+
async GET(_req, ctx) {
+
try {
+
const { slug } = ctx.params;
+
const post = await getPost(slug);
+
return ctx.render(post);
+
} catch (error) {
+
console.error("Error fetching post:", error);
+
return new Response("Post not found", { status: 404 });
+
}
+
},
+
};
+
+
function Block({
+
block,
+
did,
+
isList,
+
}: {
+
block: PubLeafletPagesLinearDocument.Block;
+
did: string;
+
isList?: boolean;
+
}) {
+
let b = block;
-
.markdown-body a {
-
color: #58a6ff;
+
let className = `
+
postBlockWrapper
+
pt-1
+
${
+
isList
+
? "isListItem pb-0 "
+
: "pb-2 last:pb-3 last:sm:pb-4 first:pt-2 sm:first:pt-3"
}
+
${
+
b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight"
+
? "text-right"
+
: b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignCenter"
+
? "text-center"
+
: ""
+
}
+
`;
-
.markdown-body blockquote {
-
border-left-color: #30363d;
-
background-color: transparent;
+
if (b.block.$type === "pub.leaflet.blocks.unorderedList") {
+
return (
+
<ul className="-ml-[1px] sm:ml-[9px] pb-2">
+
{b.block.children.map((child, index) => (
+
<ListItem item={child} did={did} key={index} className={className} />
+
))}
+
</ul>
+
);
}
-
.markdown-body pre,
-
.markdown-body code {
-
background-color: transparent;
-
color: #c9d1d9;
+
if (b.block.$type === "pub.leaflet.blocks.image") {
+
const imageBlock = b.block as PubLeafletBlocksImage.Main;
+
const image = imageBlock.image as { ref: { $link: string } };
+
const alt = imageBlock.alt || "";
+
const aspect = imageBlock.aspectRatio;
+
let width = aspect?.width;
+
let height = aspect?.height;
+
// Fallback to default size if not provided
+
if (!width) width = 600;
+
if (!height) height = 400;
+
return (
+
<img
+
src={`/api/atproto_images?did=${did}&cid=${image.ref.$link}`}
+
alt={alt}
+
width={width}
+
height={height}
+
className={`!pt-3 sm:!pt-4 ${className}`}
+
style={{
+
aspectRatio: width && height ? `${width} / ${height}` : undefined,
+
}}
+
/>
+
);
}
-
.markdown-body table td,
-
.markdown-body table th {
-
border-color: #30363d;
-
background-color: transparent;
+
if (b.block.$type === "pub.leaflet.blocks.text") {
+
return (
+
<div className={` ${className}`}>
+
<TextBlock facets={b.block.facets} plaintext={b.block.plaintext} />
+
</div>
+
);
}
-
}
-
-
.font-sans { font-family: var(--font-sans); }
-
.font-serif { font-family: var(--font-serif); }
-
.font-mono { font-family: var(--font-mono); }
-
.markdown-body h1 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 2.25rem;
-
}
-
-
.markdown-body h2 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 1.75rem;
-
}
-
-
.markdown-body h3 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 1.5rem;
-
}
-
-
.markdown-body h4 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 1.25rem;
-
}
+
if (b.block.$type === "pub.leaflet.blocks.header") {
+
const header = b.block as PubLeafletBlocksHeader.Main;
+
const level = header.level || 1;
+
const Tag = `h${Math.min(level + 1, 6)}` as keyof h.JSX.IntrinsicElements;
+
// Add heading styles based on level
+
let headingStyle =
+
"font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap ";
+
switch (level) {
+
case 1:
+
headingStyle += "text-3xl lg:text-4xl";
+
break;
+
case 2:
+
headingStyle += "text-3xl border-b pb-2 mb-6";
+
break;
+
case 3:
+
headingStyle += "text-2xl";
+
break;
+
case 4:
+
headingStyle += "text-xl";
+
break;
+
case 5:
+
headingStyle += "text-lg";
+
break;
+
case 6:
+
headingStyle += "text-base";
+
break;
+
default:
+
headingStyle += "text-2xl";
+
}
+
return (
+
<Tag className={headingStyle + " " + className}>
+
<TextBlock plaintext={header.plaintext} facets={header.facets} />
+
</Tag>
+
);
+
}
-
.markdown-body h5 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 1rem;
+
return null;
}
-
.markdown-body h6 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 0.875rem;
+
function ListItem(props: {
+
item: PubLeafletBlocksUnorderedList.Main["children"][number];
+
did: string;
+
className?: string;
+
}) {
+
return (
+
<li className={`!pb-0 flex flex-row gap-2`}>
+
<div
+
className={`listMarker shrink-0 mx-2 z-[1] mt-[14px] h-[5px] w-[5px] rounded-full bg-secondary`}
+
/>
+
<div className="flex flex-col">
+
<Block block={{ block: props.item.content }} did={props.did} isList />
+
{props.item.children?.length
+
? (
+
<ul className="-ml-[7px] sm:ml-[7px]">
+
{props.item.children.map((child, index) => (
+
<ListItem
+
item={child}
+
did={props.did}
+
key={index}
+
className={props.className}
+
/>
+
))}
+
</ul>
+
)
+
: null}
+
</div>
+
</li>
+
);
}
-
`;
-
-
export const handler: Handlers<Post> = {
-
async GET(_req, ctx) {
-
try {
-
const { slug } = ctx.params;
-
const post = await getPost(slug);
-
return ctx.render(post);
-
} catch (error) {
-
console.error("Error fetching post:", error);
-
return new Response("Post not found", { status: 404 });
-
}
-
},
-
};
export default function BlogPage({ data: post }: PageProps<Post>) {
if (!post) {
return <div>Post not found</div>;
}
+
const firstPage = post.value.pages?.[0];
+
let blocks: PubLeafletPagesLinearDocument.Block[] = [];
+
if (firstPage?.$type === "pub.leaflet.pages.linearDocument") {
+
blocks = firstPage.blocks || [];
+
}
+
// Deduplicate blocks by $type and plaintext
+
const seen = new Set();
+
const uniqueBlocks = blocks.filter((b) => {
+
const key = b.block.$type + "|" + ((b.block as any).plaintext || "");
+
if (seen.has(key)) return false;
+
seen.add(key);
+
return true;
+
});
+
+
const content = uniqueBlocks
+
.filter((b) => b.block.$type === "pub.leaflet.blocks.text")
+
.map((b) => (b.block as PubLeafletBlocksText.Main).plaintext)
+
.join(" ");
+
return (
<>
<Head>
<title>{post.value.title} โ€” knotbin</title>
-
<meta name="description" content="by Roscoe Rubin-Rottenberg" />
-
{/* Merge GFMโ€™s default styles with our dark-mode overrides */}
-
<style
-
dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }}
+
<meta
+
name="description"
+
content={post.value.description || "by Roscoe Rubin-Rottenberg"}
/>
</Head>
-
<div className="grid grid-rows-[20px_1fr_20px] justify-items-center min-h-dvh py-8 px-4 xs:px-8 pb-20 gap-16 sm:p-20">
-
<link rel="alternate" href={post.uri} />
-
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start w-full max-w-[600px] overflow-hidden">
-
<article className="w-full space-y-8">
-
<div className="space-y-4 w-full">
-
<a
-
href="/"
-
className="hover:underline hover:underline-offset-4 font-medium"
-
>
-
Back
-
</a>
-
<Title>{post.value.title}</Title>
-
<PostInfo
-
content={post.value.content}
-
createdAt={post.value.createdAt}
-
includeAuthor
-
className="text-sm"
-
/>
-
<div className="diagonal-pattern w-full h-3" />
-
</div>
-
<div className="[&>.bluesky-embed]:mt-8 [&>.bluesky-embed]:mb-0">
-
{/* Render GFM HTML via dangerouslySetInnerHTML */}
-
<div
-
class="mt-8 markdown-body"
-
dangerouslySetInnerHTML={{ __html: render(post.value.content) }}
-
/>
-
</div>
-
</article>
-
-
</main>
-
<Footer />
-
</div>
+
<Layout>
+
<div class="p-8 pb-20 gap-16 sm:p-20">
+
<link rel="alternate" href={post.uri} />
+
<div class="max-w-[600px] mx-auto">
+
<article class="w-full space-y-8">
+
<div class="space-y-4 w-full">
+
<Title>{post.value.title || "Untitled"}</Title>
+
{post.value.description && (
+
<p class="text-xl italic md:text-2xl font-serif leading-relaxed max-w-prose">
+
{post.value.description}
+
</p>
+
)}
+
<PostInfo
+
content={content}
+
createdAt={post.value.publishedAt || new Date().toISOString()}
+
includeAuthor
+
className="text-sm"
+
/>
+
<div class="diagonal-pattern w-full h-3" />
+
</div>
+
<div class="postContent flex flex-col">
+
{uniqueBlocks.map((block, index) => (
+
<Block
+
block={block}
+
did={post.uri.split("/")[2]}
+
key={index}
+
/>
+
))}
+
</div>
+
</article>
+
</div>
+
</div>
+
</Layout>
</>
);
}
+17 -7
routes/rss.ts
···
const rss = new RSS({
title: "knotbin",
-
feed_url: "https://knotbin.xyz/rss",
-
site_url: "https://knotbin.xyz",
+
feed_url: "https://knotbin.com/rss",
+
site_url: "https://knotbin.com",
description: "a webbed site",
});
for (const post of posts) {
-
rss.item({
-
title: post.value.title ?? "Untitled",
-
description: await unified()
+
const description = post.value.subtitle
+
? `${post.value.subtitle}\n\n${await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeFormat)
.use(rehypeStringify)
.process(post.value.content)
-
.then((v) => v.toString()),
-
url: `https://knotbin.xyz/post/${post.uri.split("/").pop()}`,
+
.then((v) => v.toString())}`
+
: await unified()
+
.use(remarkParse)
+
.use(remarkRehype)
+
.use(rehypeFormat)
+
.use(rehypeStringify)
+
.process(post.value.content)
+
.then((v) => v.toString());
+
+
rss.item({
+
title: post.value.title ?? "Untitled",
+
description,
+
url: `https://knotbin.com/post/${post.uri.split("/").pop()}`,
date: new Date(post.value.createdAt ?? Date.now()),
});
}
+51
routes/work.tsx
···
+
import ProjectList from "../islands/project-list.tsx";
+
import { Title } from "../components/typography.tsx";
+
import { Layout } from "../islands/layout.tsx";
+
+
export const dynamic = "force-static";
+
export const revalidate = 3600; // 1 hour
+
+
interface Project {
+
id: string;
+
title: string;
+
description: string;
+
technologies: string[];
+
url: string;
+
demo?: string;
+
year: string;
+
status: "active" | "completed" | "maintained" | "archived";
+
}
+
+
// Mock project data - replace with your actual projects
+
const projects: Project[] = [
+
{
+
id: "1",
+
title: "ATP Airport",
+
description: `The first ever graphical PDS migration tool for AT Protocol.
+
Allows users to migrate their data from one PDS to another without any
+
experience or technical knowledge.`,
+
technologies: ["AT Protocol", "Fresh", "Deno", "TypeScript"],
+
url: "https://github.com/knotbin/airport",
+
demo: "https://atpairport.com",
+
year: "2025",
+
status: "active",
+
},
+
];
+
+
export default function Work() {
+
return (
+
<Layout>
+
<div class="p-8 pb-20 gap-16 sm:p-20">
+
<div class="max-w-[600px] mx-auto">
+
<Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-12">
+
Work
+
</Title>
+
+
<div class="space-y-4 w-full">
+
<ProjectList projects={projects} />
+
</div>
+
</div>
+
</div>
+
</Layout>
+
);
+
}
+26 -1
static/logo.svg
···
-
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1100 1100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><rect id="Artboard2" x="0" y="0" width="1100" height="1100" style="fill:none;"/><g id="Artboard1"><path d="M560.559,44.229l0,1011.54c-279.329,0 -505.77,-226.442 -505.77,-505.772c-0,-279.329 226.441,-505.77 505.77,-505.77Z"/><path d="M452.068,136.085c-0,-0 39.262,-7.156 89.518,-16.317c52.805,-9.624 107.159,4.689 148.376,39.072c41.219,34.383 65.045,85.29 65.045,138.966l-0,5.46c-0,0.445 -0.043,0.889 -0.131,1.325c-25.984,129.009 -77.417,115.769 -131.96,85.146c-5.579,-3.122 -10.012,-7.948 -12.648,-13.772c-0.004,0.002 -0.004,0.002 -0.006,0c-13.723,-30.312 -12.6,-65.276 3.039,-94.645c15.64,-29.37 44.025,-49.815 76.836,-55.347c20.106,-3.39 39.323,-6.629 55.114,-9.29c25.333,-4.27 51.346,1.093 72.924,15.033c1.046,0.676 1.969,1.273 2.736,1.77c1.566,1.011 2.64,2.629 2.964,4.464c2.815,15.938 16.57,93.813 22.277,126.134c1.658,9.377 2.392,18.895 2.195,28.416c-0.027,1.356 -0.058,2.788 -0.088,4.293c-1.596,77.216 -58.387,142.146 -134.703,154.008c-11.491,1.785 -22.571,3.507 -32.665,5.077c-31.366,4.875 -63.384,-2.913 -89.006,-21.65l-0.003,0c-14.304,-10.461 -20.422,-28.829 -15.244,-45.777c5.176,-16.949 20.514,-28.763 38.221,-29.446c15.114,-0.581 25.915,-0.997 27.447,-1.056c0.124,-0.004 0.249,-0.003 0.374,0.003c1.559,0.083 12.706,0.679 26.741,1.427c39.26,2.096 73.01,28.537 84.441,66.155c6.722,22.122 14.4,47.39 21.6,71.082c16.23,53.412 4.481,111.406 -31.259,154.288c-6.864,8.237 -13.839,16.605 -20.607,24.725c-30.676,36.809 -81.028,50.621 -126.191,34.619c-21.907,-7.764 -41.227,-14.608 -45.769,-16.218c-0.666,-0.236 -1.291,-0.576 -1.85,-1.007c-2.828,-2.178 -12.462,-9.599 -24.654,-18.989c-42.016,-32.366 -53.852,-90.695 -27.772,-136.876c4.685,-8.297 8.332,-14.755 9.964,-17.645c0.619,-1.095 1.405,-2.086 2.333,-2.935c4.632,-4.247 20.294,-18.605 39.803,-36.489c18.89,-17.315 46.227,-21.833 69.683,-11.516c23.457,10.319 38.599,33.523 38.599,59.148l-0,3.721c-0,0.448 0.046,0.895 0.134,1.333c1.138,5.606 9.314,45.902 16.552,81.572c7.403,36.478 0.596,74.408 -19.028,106.037c-11.626,18.738 -24.407,39.336 -34.147,55.036c-9.934,16.009 -27.352,25.839 -46.19,26.066c-10.593,0.129 -22.873,0.278 -34.963,0.423c-13.789,0.167 -26.868,-6.099 -35.379,-16.947c-8.511,-10.85 -11.482,-25.045 -8.038,-38.396c0.209,-0.81 0.419,-1.624 0.63,-2.441c19.604,-75.988 95.757,-122.898 172.442,-106.223c24.631,5.357 46.594,10.132 59.654,12.973c13.246,2.88 25.807,8.302 36.988,15.966c38.709,26.525 44.277,66.301 26.505,134.808c-1.573,6.073 -2.859,12.218 -3.851,18.415c-12.505,79.426 -78.466,91.846 -155.676,81.98c-31.011,-3.935 -58.763,-21.202 -75.996,-47.283c-17.233,-26.081 -22.232,-58.382 -13.689,-88.452c0.309,-1.096 0.53,-1.872 0.646,-2.279c0.061,-0.221 0.143,-0.436 0.242,-0.643c2.126,-4.451 26.585,-55.66 42.172,-88.296c8.467,-17.726 20.832,-33.307 36.172,-45.579c9.997,-7.996 22.364,-17.889 35.374,-28.298c54.278,-43.42 128.076,-53.37 191.914,-25.873c4.808,2.07 8.089,3.483 9.155,3.942c0.25,0.108 0.506,0.201 0.768,0.277c3.641,1.066 31.707,9.282 46.839,13.712c5.974,1.749 10.634,6.441 12.342,12.427c3.118,10.928 8.483,29.74 13.94,48.866c9.987,35.012 -10.111,71.533 -45.036,81.829c-0.002,0.002 -0.006,0.002 -0.009,0.003c-24.131,7.115 -50.031,-2.885 -63.119,-24.371c-13.09,-21.485 -10.096,-49.085 7.294,-67.266c12.614,-13.188 22.604,-23.632 27.884,-29.151c2.692,-2.815 6.153,-4.776 9.951,-5.638c6.53,-1.481 18.223,-4.134 33.371,-7.573c27.679,-6.28 47.153,-31.119 46.647,-59.497c-0.505,-28.378 -20.852,-52.508 -48.736,-57.8c-8.352,-1.585 -13.84,-2.626 -15.542,-2.949c-0.375,-0.071 -0.756,-0.111 -1.139,-0.117c-6.117,-0.102 -59.681,-0.993 -91.289,-1.517c-14.816,-0.247 -29.158,5.212 -40.061,15.245c-0.632,0.581 -1.271,1.17 -1.915,1.763c-16.532,15.215 -18.76,40.516 -5.141,58.387c8.688,11.4 18.516,24.298 27.302,35.826c16.388,21.504 39.178,37.247 65.089,44.963c4.177,1.244 8.071,2.403 11.31,3.368c5.979,1.781 12.184,2.684 18.421,2.684l49.674,0c21.022,0 41.126,-8.612 55.629,-23.83c5.307,-5.57 11.185,-11.737 17.219,-18.07c31.95,-33.528 37.873,-84.146 14.527,-124.143c-6.599,-11.306 -13.223,-22.653 -19.156,-32.819c-12.673,-21.712 -38.649,-31.814 -62.662,-24.366c-5.375,1.666 -9.885,3.065 -12.39,3.842c-1.382,0.428 -2.587,1.291 -3.438,2.461c-3.547,4.879 -13.862,19.062 -25.419,34.957c-26.192,36.018 -70.209,54.526 -114.271,48.047c-28.023,-4.12 -54.585,-8.025 -59.062,-8.683c-0.441,-0.066 -0.881,-0.133 -1.321,-0.202c-7.434,-1.169 -75.783,-11.922 -108.172,-17.018c-11.096,-1.744 -21.626,-6.072 -30.742,-12.631c-1.614,-1.161 -3.324,-2.391 -5.105,-3.673c-29.14,-20.966 -37.625,-60.615 -19.619,-91.671c5.84,-10.07 11.036,-19.032 14.039,-24.21c1.779,-3.069 4.261,-5.674 7.242,-7.602c4.567,-2.952 12.305,-7.953 21.946,-14.185c25.175,-16.274 57.318,-17.17 83.362,-2.327c26.042,14.843 41.652,42.956 40.479,72.909c-0.103,2.646 -0.181,4.646 -0.23,5.871c-0.035,0.924 -0.209,1.839 -0.515,2.713c-2.137,6.119 -11.319,32.397 -19.064,54.561c-7.423,21.244 -22.065,39.208 -41.375,50.762c-14.704,8.798 -31.07,18.589 -39.626,23.709c-3.962,2.372 -8.104,4.427 -12.388,6.149c-2.33,0.938 -5.171,2.079 -8.31,3.341c-26.083,10.485 -55.952,2.882 -73.849,-18.798c-0.901,-1.092 -1.808,-2.189 -2.716,-3.29c-22.169,-26.857 -22.993,-65.424 -1.991,-93.205c0.423,-0.558 0.749,-0.989 0.964,-1.275c0.2,-0.264 0.432,-0.502 0.688,-0.711c3.682,-2.977 32.787,-26.517 52.11,-42.144c11.292,-9.133 19.87,-21.18 24.806,-34.837c1.622,-4.488 3.406,-9.427 5.25,-14.528c11.282,-31.22 -0.104,-66.115 -27.629,-84.671c-13.012,-8.772 -25.837,-17.419 -34.141,-23.018c-6.063,-4.087 -13.236,-6.21 -20.547,-6.08c-5.716,0.101 -13.163,0.234 -21.266,0.377c-35.992,0.639 -65.882,27.967 -69.736,63.758l-0,0.003c-1.587,14.733 3.646,29.383 14.206,39.776c10.56,10.394 25.29,15.394 39.995,13.575c5.615,-0.695 10.599,-1.312 14.373,-1.779c4.61,-0.57 8.673,-3.302 10.942,-7.355c5.904,-10.554 17.572,-31.407 23.565,-42.12c2.43,-4.344 3.349,-9.37 2.615,-14.292c-2.539,-17.026 -8.882,-59.554 -14.179,-95.074c-5.104,-34.221 -28.31,-63.008 -60.667,-75.261c-0.435,-0.164 -0.668,-0.252 -0.668,-0.252c-0,0 -26.059,-12.597 -47.404,-22.915c-6.21,-3.003 -10.552,-8.86 -11.617,-15.676c-1.066,-6.814 1.282,-13.719 6.28,-18.472c-0,-0.001 0.001,-0.003 0.002,-0.003c13.361,-12.709 31.532,-19.076 49.905,-17.482c18.371,1.593 35.175,10.993 46.149,25.812c2.085,2.816 4.13,5.578 6.106,8.246c0.828,1.119 0.653,2.687 -0.401,3.595c-1.055,0.908 -2.631,0.851 -3.615,-0.134c-0.007,-0.008 -0.015,-0.014 -0.023,-0.022c-6.839,-6.841 -10.606,-16.166 -10.437,-25.837c0.17,-9.672 4.261,-18.858 11.334,-25.456c12.691,-11.834 26.596,-24.802 33.837,-31.554c3.322,-3.098 7.137,-5.621 11.288,-7.465c0.001,-0.001 0.003,-0.003 0.004,-0.003c33.279,-14.79 72.202,-7.931 98.421,17.345c26.217,25.275 34.497,63.921 20.935,97.719c-1.254,3.124 -1.944,4.845 -1.946,4.85c-0.044,0.109 -0.088,0.22 -0.133,0.329c-10.112,24.6 -22.136,42.409 -34.88,54.706c-18.975,18.232 -48.766,18.846 -68.474,1.411c-19.709,-17.434 -22.734,-47.079 -6.954,-68.135c2.773,-3.711 5.454,-7.289 7.917,-10.577c6.515,-8.691 16.639,-13.936 27.497,-14.244c13.166,-0.339 25.563,-9.686 37.384,-23.926c21.63,-26.04 55.012,-39.302 88.616,-35.204c33.603,4.096 62.822,24.988 77.564,55.462c1.373,2.835 2.392,4.942 2.989,6.174c0.402,0.832 0.678,1.719 0.82,2.631c1.12,7.204 6.491,41.771 12.829,82.569c3.732,24.014 -8.005,47.788 -29.338,59.427c-21.334,11.64 -47.676,8.643 -65.85,-7.491c-4.078,-3.62 -7.66,-6.8 -10.593,-9.405c-5.545,-4.923 -8.889,-11.859 -9.283,-19.264c-0.886,-16.594 -2.529,-47.379 -3.61,-67.626c-0.649,-12.17 5.463,-23.707 15.897,-30.006c10.519,-6.348 22.575,-13.625 25.52,-15.402c0.444,-0.269 0.937,-0.449 1.45,-0.533c7.072,-1.152 60.584,-9.868 84.851,-13.82c7.803,-1.271 15.8,-0.516 23.227,2.192c3.841,1.4 8.488,3.094 13.667,4.982c20.848,7.6 36.493,25.15 41.66,46.728c5.167,21.58 -0.832,44.313 -15.975,60.531c-2.276,2.436 -4.385,4.697 -6.283,6.729c-6.196,6.635 -8.326,16.098 -5.573,24.748c0.926,2.91 1.963,6.169 3.079,9.673c5.116,16.075 2.584,33.607 -6.871,47.578c-9.455,13.97 -24.79,22.838 -41.615,24.064c-30.34,2.128 -59.679,12.528 -74.531,38.972c-5.427,9.71 -14.426,16.92 -25.085,20.1c-5.66,1.699 -12.29,3.678 -19.461,5.818c-19.596,5.845 -40.822,0.671 -55.53,-13.538c-14.708,-14.209 -20.613,-35.244 -15.445,-55.031c1.935,-7.413 3.816,-14.617 5.572,-21.344c6.596,-25.261 27.232,-44.412 52.914,-49.106c31.363,-5.733 75.516,-13.803 115.654,-21.14c58.409,-10.676 116.621,19.623 141.385,73.589c1.496,3.259 2.842,6.191 3.991,8.694c2.644,5.766 2.375,12.45 -0.727,17.984c-7.217,12.875 -20.953,37.382 -31.677,56.513c-9.342,16.667 -26.792,27.159 -45.891,27.595c-8.375,0.192 -17.759,0.408 -27.226,0.623c-14.654,0.336 -28.579,-6.373 -37.448,-18.042c-8.87,-11.669 -11.608,-26.881 -7.365,-40.911c0.23,-0.76 0.435,-1.439 0.614,-2.027c0.441,-1.46 1.369,-2.726 2.629,-3.588c21.719,-14.652 -16.221,-111.701 65.038,-53.7c7.872,5.622 16.285,10.449 25.113,14.407c14.282,6.405 36.836,16.52 57.452,25.765c29.334,13.156 48.122,42.403 47.888,74.551c-0.008,1.177 -0.017,2.324 -0.025,3.439c-0.073,10.098 -6.873,18.909 -16.623,21.539c-22.991,6.201 -63.65,17.167 -79.742,21.506c-4.499,1.215 -9.21,1.428 -13.8,0.626c-3.053,-0.534 -6.953,-1.214 -11.384,-1.989c-14.889,-2.601 -30.062,2.69 -40.106,13.982c-10.044,11.295 -13.528,26.982 -9.205,41.465c-0,0.002 -0,0.004 0.001,0.006c6.683,22.398 22.61,40.877 43.779,50.789c21.167,9.913 45.56,10.314 67.044,1.104c8.832,-3.785 15.105,-6.475 16.407,-7.033c0.167,-0.073 0.339,-0.133 0.515,-0.182c2.849,-0.791 28.33,-7.872 52.899,-14.7c26.462,-7.353 42.335,-34.355 35.887,-61.052c-0.34,-1.413 -0.632,-2.621 -0.861,-3.569c-0.363,-1.5 -1.233,-2.83 -2.462,-3.765c-8.626,-6.552 -44.667,-33.933 -67.746,-51.466c-13.079,-9.936 -28.144,-16.942 -44.172,-20.541c-9.671,-2.173 -20.631,-4.634 -29.698,-6.67c-12.443,-2.795 -25.393,-2.373 -37.627,1.225c-10.469,3.08 -24.206,7.121 -38.179,11.231c-39.733,11.687 -71.326,41.926 -84.74,81.11c-1.541,4.5 -3.117,9.106 -4.714,13.768c-22.782,66.554 -4.712,140.281 46.264,188.755c3.288,3.127 5.957,5.666 7.78,7.399c1.781,1.695 3.962,2.912 6.34,3.538c20.638,5.433 122.423,32.231 122.423,32.231c0,-0 0.35,0.114 0.994,0.323c14.123,4.598 25.773,14.732 32.284,28.08c6.512,13.349 7.325,28.768 2.257,42.728c-2.265,6.234 -4.576,12.599 -6.842,18.84c-12.545,34.542 -37.649,63.085 -70.307,79.935c-2.583,1.333 -5.169,2.667 -7.732,3.99c-28.525,14.717 -61.841,17.083 -92.16,6.542c-11.175,-3.884 -23.232,-8.076 -34.882,-12.126c-38.357,-13.334 -69.454,-42.001 -85.86,-79.147c-0.482,-1.095 -0.832,-1.883 -1.016,-2.301c-0.101,-0.229 -0.18,-0.466 -0.238,-0.707c-0.469,-1.98 -2.802,-11.826 -5.82,-24.555c-10.276,-43.345 8.959,-88.319 47.399,-110.831c1.96,-1.146 3.863,-2.261 5.691,-3.332c13.991,-8.193 29.663,-13.087 45.829,-14.311c12.107,-0.916 27.248,-2.061 41.999,-3.177c35.044,-2.653 66.147,-23.443 81.997,-54.81c3.458,-6.839 6.946,-13.746 10.243,-20.269c11.211,-22.186 8.683,-48.839 -6.496,-68.523c-18,-23.338 -42.08,-54.562 -59.201,-76.762c-13.949,-18.088 -22.835,-39.561 -25.747,-62.216c-1.894,-14.734 -4.167,-32.417 -6.35,-49.408c-4.401,-34.253 -23.696,-64.817 -52.727,-83.524c-29.029,-18.706 -64.835,-23.646 -97.846,-13.501c-23.095,7.183 -45.961,18.729 -67.538,32.903c-24.624,16.129 -57.5,10.7 -75.633,-12.489c-8.376,-10.667 -16.818,-21.463 -23.5,-30.008c-7.948,-10.165 -8.605,-24.242 -1.637,-35.102c0.737,-1.15 1.504,-2.344 2.295,-3.578c18.294,-28.513 49.544,-46.089 83.412,-46.91c33.867,-0.822 65.933,15.218 85.588,42.811c16.176,22.868 32.46,45.325 48.838,67.331c8.773,11.692 10.124,27.36 3.485,40.382c-6.641,13.023 -20.115,21.129 -34.73,20.897c-0.006,0.01 -0.01,0.01 -0.013,0.01c-15.831,-0.252 -30.539,8.149 -38.362,21.911c-7.826,13.764 -7.521,30.7 0.794,44.174c13.158,21.323 27.652,44.812 40.634,65.85c23.498,38.079 20.715,86.78 -6.967,121.934c-8.473,10.759 -17.397,22.093 -26.105,33.153c-11.64,14.781 -30.183,22.37 -48.845,19.988c-18.663,-2.38 -34.707,-14.382 -42.261,-31.613c-0.001,-0.003 -0.003,-0.006 -0.004,-0.008c-6.004,-13.695 -3.173,-29.658 7.175,-40.451c10.347,-10.794 26.176,-14.296 40.111,-8.875c28.797,11.202 60.829,23.662 88.7,34.504c43.558,16.945 71.991,59.183 71.297,105.916c-0.492,33.068 -1.073,72.095 -1.599,107.479c-0.302,20.322 -11.734,38.836 -29.766,48.21c-18.032,9.375 -39.756,8.093 -56.561,-3.335c-0.003,-0 -0.004,-0.002 -0.005,-0.003c-14.721,-10.01 -22.101,-27.8 -18.788,-45.29c3.312,-17.492 16.687,-31.351 34.049,-35.284c33.306,-7.545 68.523,-15.524 94.687,-21.451c24.849,-5.63 50.715,3.585 66.407,23.656l0.001,-0c19.202,24.559 23.185,57.748 10.339,86.152c-12.847,28.405 -40.398,47.332 -71.522,49.13c-8.913,0.517 -17.668,1.023 -26.095,1.509c-40.269,2.33 -75.325,28.317 -89.259,66.172c-11.886,32.237 -36.114,38.372 -61.567,27.812c-21.988,-9.158 -38.8,-27.57 -45.927,-50.298c-9.465,-30.157 -26.592,-84.776 -36.519,-116.43c-4.884,-15.576 -16.214,-28.308 -31.117,-34.967c0,-0 -0.001,-0 -0.001,-0.001c-5.963,-2.664 -12.777,-2.661 -18.735,0.01c-5.959,2.67 -10.497,7.754 -12.476,13.977c-1.695,5.33 -3.59,11.288 -5.603,17.617c-7.33,23.049 -2.966,48.224 11.691,67.462c14.66,19.239 37.775,30.124 61.943,29.172c3.268,-0.129 5.523,-0.217 6.437,-0.254c0.302,-0.011 0.602,-0.056 0.894,-0.135c110.799,-29.663 255.406,-36.913 336.27,-80.04c23.721,-12.652 40.197,-35.629 44.569,-62.156c4.371,-26.527 -3.86,-53.576 -22.269,-73.171c-56.064,-59.676 -153.855,-163.769 -153.855,-163.769" style="fill-opacity:0;stroke:#000;stroke-width:24px;"/></g></svg>
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg
+
width="100%"
+
height="100%"
+
viewBox="0 0 1100 1100"
+
version="1.1"
+
xmlns="http://www.w3.org/2000/svg"
+
xmlns:xlink="http://www.w3.org/1999/xlink"
+
xml:space="preserve"
+
xmlns:serif="http://www.serif.com/"
+
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linecap: round; stroke-linejoin: round; stroke-miterlimit: 1.5"
+
>
+
<rect
+
id="Artboard2"
+
x="0"
+
y="0"
+
width="1100"
+
height="1100"
+
style="fill: none"
+
/><g id="Artboard1"><path
+
d="M560.559,44.229l0,1011.54c-279.329,0 -505.77,-226.442 -505.77,-505.772c-0,-279.329 226.441,-505.77 505.77,-505.77Z"
+
/><path
+
d="M452.068,136.085c-0,-0 39.262,-7.156 89.518,-16.317c52.805,-9.624 107.159,4.689 148.376,39.072c41.219,34.383 65.045,85.29 65.045,138.966l-0,5.46c-0,0.445 -0.043,0.889 -0.131,1.325c-25.984,129.009 -77.417,115.769 -131.96,85.146c-5.579,-3.122 -10.012,-7.948 -12.648,-13.772c-0.004,0.002 -0.004,0.002 -0.006,0c-13.723,-30.312 -12.6,-65.276 3.039,-94.645c15.64,-29.37 44.025,-49.815 76.836,-55.347c20.106,-3.39 39.323,-6.629 55.114,-9.29c25.333,-4.27 51.346,1.093 72.924,15.033c1.046,0.676 1.969,1.273 2.736,1.77c1.566,1.011 2.64,2.629 2.964,4.464c2.815,15.938 16.57,93.813 22.277,126.134c1.658,9.377 2.392,18.895 2.195,28.416c-0.027,1.356 -0.058,2.788 -0.088,4.293c-1.596,77.216 -58.387,142.146 -134.703,154.008c-11.491,1.785 -22.571,3.507 -32.665,5.077c-31.366,4.875 -63.384,-2.913 -89.006,-21.65l-0.003,0c-14.304,-10.461 -20.422,-28.829 -15.244,-45.777c5.176,-16.949 20.514,-28.763 38.221,-29.446c15.114,-0.581 25.915,-0.997 27.447,-1.056c0.124,-0.004 0.249,-0.003 0.374,0.003c1.559,0.083 12.706,0.679 26.741,1.427c39.26,2.096 73.01,28.537 84.441,66.155c6.722,22.122 14.4,47.39 21.6,71.082c16.23,53.412 4.481,111.406 -31.259,154.288c-6.864,8.237 -13.839,16.605 -20.607,24.725c-30.676,36.809 -81.028,50.621 -126.191,34.619c-21.907,-7.764 -41.227,-14.608 -45.769,-16.218c-0.666,-0.236 -1.291,-0.576 -1.85,-1.007c-2.828,-2.178 -12.462,-9.599 -24.654,-18.989c-42.016,-32.366 -53.852,-90.695 -27.772,-136.876c4.685,-8.297 8.332,-14.755 9.964,-17.645c0.619,-1.095 1.405,-2.086 2.333,-2.935c4.632,-4.247 20.294,-18.605 39.803,-36.489c18.89,-17.315 46.227,-21.833 69.683,-11.516c23.457,10.319 38.599,33.523 38.599,59.148l-0,3.721c-0,0.448 0.046,0.895 0.134,1.333c1.138,5.606 9.314,45.902 16.552,81.572c7.403,36.478 0.596,74.408 -19.028,106.037c-11.626,18.738 -24.407,39.336 -34.147,55.036c-9.934,16.009 -27.352,25.839 -46.19,26.066c-10.593,0.129 -22.873,0.278 -34.963,0.423c-13.789,0.167 -26.868,-6.099 -35.379,-16.947c-8.511,-10.85 -11.482,-25.045 -8.038,-38.396c0.209,-0.81 0.419,-1.624 0.63,-2.441c19.604,-75.988 95.757,-122.898 172.442,-106.223c24.631,5.357 46.594,10.132 59.654,12.973c13.246,2.88 25.807,8.302 36.988,15.966c38.709,26.525 44.277,66.301 26.505,134.808c-1.573,6.073 -2.859,12.218 -3.851,18.415c-12.505,79.426 -78.466,91.846 -155.676,81.98c-31.011,-3.935 -58.763,-21.202 -75.996,-47.283c-17.233,-26.081 -22.232,-58.382 -13.689,-88.452c0.309,-1.096 0.53,-1.872 0.646,-2.279c0.061,-0.221 0.143,-0.436 0.242,-0.643c2.126,-4.451 26.585,-55.66 42.172,-88.296c8.467,-17.726 20.832,-33.307 36.172,-45.579c9.997,-7.996 22.364,-17.889 35.374,-28.298c54.278,-43.42 128.076,-53.37 191.914,-25.873c4.808,2.07 8.089,3.483 9.155,3.942c0.25,0.108 0.506,0.201 0.768,0.277c3.641,1.066 31.707,9.282 46.839,13.712c5.974,1.749 10.634,6.441 12.342,12.427c3.118,10.928 8.483,29.74 13.94,48.866c9.987,35.012 -10.111,71.533 -45.036,81.829c-0.002,0.002 -0.006,0.002 -0.009,0.003c-24.131,7.115 -50.031,-2.885 -63.119,-24.371c-13.09,-21.485 -10.096,-49.085 7.294,-67.266c12.614,-13.188 22.604,-23.632 27.884,-29.151c2.692,-2.815 6.153,-4.776 9.951,-5.638c6.53,-1.481 18.223,-4.134 33.371,-7.573c27.679,-6.28 47.153,-31.119 46.647,-59.497c-0.505,-28.378 -20.852,-52.508 -48.736,-57.8c-8.352,-1.585 -13.84,-2.626 -15.542,-2.949c-0.375,-0.071 -0.756,-0.111 -1.139,-0.117c-6.117,-0.102 -59.681,-0.993 -91.289,-1.517c-14.816,-0.247 -29.158,5.212 -40.061,15.245c-0.632,0.581 -1.271,1.17 -1.915,1.763c-16.532,15.215 -18.76,40.516 -5.141,58.387c8.688,11.4 18.516,24.298 27.302,35.826c16.388,21.504 39.178,37.247 65.089,44.963c4.177,1.244 8.071,2.403 11.31,3.368c5.979,1.781 12.184,2.684 18.421,2.684l49.674,0c21.022,0 41.126,-8.612 55.629,-23.83c5.307,-5.57 11.185,-11.737 17.219,-18.07c31.95,-33.528 37.873,-84.146 14.527,-124.143c-6.599,-11.306 -13.223,-22.653 -19.156,-32.819c-12.673,-21.712 -38.649,-31.814 -62.662,-24.366c-5.375,1.666 -9.885,3.065 -12.39,3.842c-1.382,0.428 -2.587,1.291 -3.438,2.461c-3.547,4.879 -13.862,19.062 -25.419,34.957c-26.192,36.018 -70.209,54.526 -114.271,48.047c-28.023,-4.12 -54.585,-8.025 -59.062,-8.683c-0.441,-0.066 -0.881,-0.133 -1.321,-0.202c-7.434,-1.169 -75.783,-11.922 -108.172,-17.018c-11.096,-1.744 -21.626,-6.072 -30.742,-12.631c-1.614,-1.161 -3.324,-2.391 -5.105,-3.673c-29.14,-20.966 -37.625,-60.615 -19.619,-91.671c5.84,-10.07 11.036,-19.032 14.039,-24.21c1.779,-3.069 4.261,-5.674 7.242,-7.602c4.567,-2.952 12.305,-7.953 21.946,-14.185c25.175,-16.274 57.318,-17.17 83.362,-2.327c26.042,14.843 41.652,42.956 40.479,72.909c-0.103,2.646 -0.181,4.646 -0.23,5.871c-0.035,0.924 -0.209,1.839 -0.515,2.713c-2.137,6.119 -11.319,32.397 -19.064,54.561c-7.423,21.244 -22.065,39.208 -41.375,50.762c-14.704,8.798 -31.07,18.589 -39.626,23.709c-3.962,2.372 -8.104,4.427 -12.388,6.149c-2.33,0.938 -5.171,2.079 -8.31,3.341c-26.083,10.485 -55.952,2.882 -73.849,-18.798c-0.901,-1.092 -1.808,-2.189 -2.716,-3.29c-22.169,-26.857 -22.993,-65.424 -1.991,-93.205c0.423,-0.558 0.749,-0.989 0.964,-1.275c0.2,-0.264 0.432,-0.502 0.688,-0.711c3.682,-2.977 32.787,-26.517 52.11,-42.144c11.292,-9.133 19.87,-21.18 24.806,-34.837c1.622,-4.488 3.406,-9.427 5.25,-14.528c11.282,-31.22 -0.104,-66.115 -27.629,-84.671c-13.012,-8.772 -25.837,-17.419 -34.141,-23.018c-6.063,-4.087 -13.236,-6.21 -20.547,-6.08c-5.716,0.101 -13.163,0.234 -21.266,0.377c-35.992,0.639 -65.882,27.967 -69.736,63.758l-0,0.003c-1.587,14.733 3.646,29.383 14.206,39.776c10.56,10.394 25.29,15.394 39.995,13.575c5.615,-0.695 10.599,-1.312 14.373,-1.779c4.61,-0.57 8.673,-3.302 10.942,-7.355c5.904,-10.554 17.572,-31.407 23.565,-42.12c2.43,-4.344 3.349,-9.37 2.615,-14.292c-2.539,-17.026 -8.882,-59.554 -14.179,-95.074c-5.104,-34.221 -28.31,-63.008 -60.667,-75.261c-0.435,-0.164 -0.668,-0.252 -0.668,-0.252c-0,0 -26.059,-12.597 -47.404,-22.915c-6.21,-3.003 -10.552,-8.86 -11.617,-15.676c-1.066,-6.814 1.282,-13.719 6.28,-18.472c-0,-0.001 0.001,-0.003 0.002,-0.003c13.361,-12.709 31.532,-19.076 49.905,-17.482c18.371,1.593 35.175,10.993 46.149,25.812c2.085,2.816 4.13,5.578 6.106,8.246c0.828,1.119 0.653,2.687 -0.401,3.595c-1.055,0.908 -2.631,0.851 -3.615,-0.134c-0.007,-0.008 -0.015,-0.014 -0.023,-0.022c-6.839,-6.841 -10.606,-16.166 -10.437,-25.837c0.17,-9.672 4.261,-18.858 11.334,-25.456c12.691,-11.834 26.596,-24.802 33.837,-31.554c3.322,-3.098 7.137,-5.621 11.288,-7.465c0.001,-0.001 0.003,-0.003 0.004,-0.003c33.279,-14.79 72.202,-7.931 98.421,17.345c26.217,25.275 34.497,63.921 20.935,97.719c-1.254,3.124 -1.944,4.845 -1.946,4.85c-0.044,0.109 -0.088,0.22 -0.133,0.329c-10.112,24.6 -22.136,42.409 -34.88,54.706c-18.975,18.232 -48.766,18.846 -68.474,1.411c-19.709,-17.434 -22.734,-47.079 -6.954,-68.135c2.773,-3.711 5.454,-7.289 7.917,-10.577c6.515,-8.691 16.639,-13.936 27.497,-14.244c13.166,-0.339 25.563,-9.686 37.384,-23.926c21.63,-26.04 55.012,-39.302 88.616,-35.204c33.603,4.096 62.822,24.988 77.564,55.462c1.373,2.835 2.392,4.942 2.989,6.174c0.402,0.832 0.678,1.719 0.82,2.631c1.12,7.204 6.491,41.771 12.829,82.569c3.732,24.014 -8.005,47.788 -29.338,59.427c-21.334,11.64 -47.676,8.643 -65.85,-7.491c-4.078,-3.62 -7.66,-6.8 -10.593,-9.405c-5.545,-4.923 -8.889,-11.859 -9.283,-19.264c-0.886,-16.594 -2.529,-47.379 -3.61,-67.626c-0.649,-12.17 5.463,-23.707 15.897,-30.006c10.519,-6.348 22.575,-13.625 25.52,-15.402c0.444,-0.269 0.937,-0.449 1.45,-0.533c7.072,-1.152 60.584,-9.868 84.851,-13.82c7.803,-1.271 15.8,-0.516 23.227,2.192c3.841,1.4 8.488,3.094 13.667,4.982c20.848,7.6 36.493,25.15 41.66,46.728c5.167,21.58 -0.832,44.313 -15.975,60.531c-2.276,2.436 -4.385,4.697 -6.283,6.729c-6.196,6.635 -8.326,16.098 -5.573,24.748c0.926,2.91 1.963,6.169 3.079,9.673c5.116,16.075 2.584,33.607 -6.871,47.578c-9.455,13.97 -24.79,22.838 -41.615,24.064c-30.34,2.128 -59.679,12.528 -74.531,38.972c-5.427,9.71 -14.426,16.92 -25.085,20.1c-5.66,1.699 -12.29,3.678 -19.461,5.818c-19.596,5.845 -40.822,0.671 -55.53,-13.538c-14.708,-14.209 -20.613,-35.244 -15.445,-55.031c1.935,-7.413 3.816,-14.617 5.572,-21.344c6.596,-25.261 27.232,-44.412 52.914,-49.106c31.363,-5.733 75.516,-13.803 115.654,-21.14c58.409,-10.676 116.621,19.623 141.385,73.589c1.496,3.259 2.842,6.191 3.991,8.694c2.644,5.766 2.375,12.45 -0.727,17.984c-7.217,12.875 -20.953,37.382 -31.677,56.513c-9.342,16.667 -26.792,27.159 -45.891,27.595c-8.375,0.192 -17.759,0.408 -27.226,0.623c-14.654,0.336 -28.579,-6.373 -37.448,-18.042c-8.87,-11.669 -11.608,-26.881 -7.365,-40.911c0.23,-0.76 0.435,-1.439 0.614,-2.027c0.441,-1.46 1.369,-2.726 2.629,-3.588c21.719,-14.652 -16.221,-111.701 65.038,-53.7c7.872,5.622 16.285,10.449 25.113,14.407c14.282,6.405 36.836,16.52 57.452,25.765c29.334,13.156 48.122,42.403 47.888,74.551c-0.008,1.177 -0.017,2.324 -0.025,3.439c-0.073,10.098 -6.873,18.909 -16.623,21.539c-22.991,6.201 -63.65,17.167 -79.742,21.506c-4.499,1.215 -9.21,1.428 -13.8,0.626c-3.053,-0.534 -6.953,-1.214 -11.384,-1.989c-14.889,-2.601 -30.062,2.69 -40.106,13.982c-10.044,11.295 -13.528,26.982 -9.205,41.465c-0,0.002 -0,0.004 0.001,0.006c6.683,22.398 22.61,40.877 43.779,50.789c21.167,9.913 45.56,10.314 67.044,1.104c8.832,-3.785 15.105,-6.475 16.407,-7.033c0.167,-0.073 0.339,-0.133 0.515,-0.182c2.849,-0.791 28.33,-7.872 52.899,-14.7c26.462,-7.353 42.335,-34.355 35.887,-61.052c-0.34,-1.413 -0.632,-2.621 -0.861,-3.569c-0.363,-1.5 -1.233,-2.83 -2.462,-3.765c-8.626,-6.552 -44.667,-33.933 -67.746,-51.466c-13.079,-9.936 -28.144,-16.942 -44.172,-20.541c-9.671,-2.173 -20.631,-4.634 -29.698,-6.67c-12.443,-2.795 -25.393,-2.373 -37.627,1.225c-10.469,3.08 -24.206,7.121 -38.179,11.231c-39.733,11.687 -71.326,41.926 -84.74,81.11c-1.541,4.5 -3.117,9.106 -4.714,13.768c-22.782,66.554 -4.712,140.281 46.264,188.755c3.288,3.127 5.957,5.666 7.78,7.399c1.781,1.695 3.962,2.912 6.34,3.538c20.638,5.433 122.423,32.231 122.423,32.231c0,-0 0.35,0.114 0.994,0.323c14.123,4.598 25.773,14.732 32.284,28.08c6.512,13.349 7.325,28.768 2.257,42.728c-2.265,6.234 -4.576,12.599 -6.842,18.84c-12.545,34.542 -37.649,63.085 -70.307,79.935c-2.583,1.333 -5.169,2.667 -7.732,3.99c-28.525,14.717 -61.841,17.083 -92.16,6.542c-11.175,-3.884 -23.232,-8.076 -34.882,-12.126c-38.357,-13.334 -69.454,-42.001 -85.86,-79.147c-0.482,-1.095 -0.832,-1.883 -1.016,-2.301c-0.101,-0.229 -0.18,-0.466 -0.238,-0.707c-0.469,-1.98 -2.802,-11.826 -5.82,-24.555c-10.276,-43.345 8.959,-88.319 47.399,-110.831c1.96,-1.146 3.863,-2.261 5.691,-3.332c13.991,-8.193 29.663,-13.087 45.829,-14.311c12.107,-0.916 27.248,-2.061 41.999,-3.177c35.044,-2.653 66.147,-23.443 81.997,-54.81c3.458,-6.839 6.946,-13.746 10.243,-20.269c11.211,-22.186 8.683,-48.839 -6.496,-68.523c-18,-23.338 -42.08,-54.562 -59.201,-76.762c-13.949,-18.088 -22.835,-39.561 -25.747,-62.216c-1.894,-14.734 -4.167,-32.417 -6.35,-49.408c-4.401,-34.253 -23.696,-64.817 -52.727,-83.524c-29.029,-18.706 -64.835,-23.646 -97.846,-13.501c-23.095,7.183 -45.961,18.729 -67.538,32.903c-24.624,16.129 -57.5,10.7 -75.633,-12.489c-8.376,-10.667 -16.818,-21.463 -23.5,-30.008c-7.948,-10.165 -8.605,-24.242 -1.637,-35.102c0.737,-1.15 1.504,-2.344 2.295,-3.578c18.294,-28.513 49.544,-46.089 83.412,-46.91c33.867,-0.822 65.933,15.218 85.588,42.811c16.176,22.868 32.46,45.325 48.838,67.331c8.773,11.692 10.124,27.36 3.485,40.382c-6.641,13.023 -20.115,21.129 -34.73,20.897c-0.006,0.01 -0.01,0.01 -0.013,0.01c-15.831,-0.252 -30.539,8.149 -38.362,21.911c-7.826,13.764 -7.521,30.7 0.794,44.174c13.158,21.323 27.652,44.812 40.634,65.85c23.498,38.079 20.715,86.78 -6.967,121.934c-8.473,10.759 -17.397,22.093 -26.105,33.153c-11.64,14.781 -30.183,22.37 -48.845,19.988c-18.663,-2.38 -34.707,-14.382 -42.261,-31.613c-0.001,-0.003 -0.003,-0.006 -0.004,-0.008c-6.004,-13.695 -3.173,-29.658 7.175,-40.451c10.347,-10.794 26.176,-14.296 40.111,-8.875c28.797,11.202 60.829,23.662 88.7,34.504c43.558,16.945 71.991,59.183 71.297,105.916c-0.492,33.068 -1.073,72.095 -1.599,107.479c-0.302,20.322 -11.734,38.836 -29.766,48.21c-18.032,9.375 -39.756,8.093 -56.561,-3.335c-0.003,-0 -0.004,-0.002 -0.005,-0.003c-14.721,-10.01 -22.101,-27.8 -18.788,-45.29c3.312,-17.492 16.687,-31.351 34.049,-35.284c33.306,-7.545 68.523,-15.524 94.687,-21.451c24.849,-5.63 50.715,3.585 66.407,23.656l0.001,-0c19.202,24.559 23.185,57.748 10.339,86.152c-12.847,28.405 -40.398,47.332 -71.522,49.13c-8.913,0.517 -17.668,1.023 -26.095,1.509c-40.269,2.33 -75.325,28.317 -89.259,66.172c-11.886,32.237 -36.114,38.372 -61.567,27.812c-21.988,-9.158 -38.8,-27.57 -45.927,-50.298c-9.465,-30.157 -26.592,-84.776 -36.519,-116.43c-4.884,-15.576 -16.214,-28.308 -31.117,-34.967c0,-0 -0.001,-0 -0.001,-0.001c-5.963,-2.664 -12.777,-2.661 -18.735,0.01c-5.959,2.67 -10.497,7.754 -12.476,13.977c-1.695,5.33 -3.59,11.288 -5.603,17.617c-7.33,23.049 -2.966,48.224 11.691,67.462c14.66,19.239 37.775,30.124 61.943,29.172c3.268,-0.129 5.523,-0.217 6.437,-0.254c0.302,-0.011 0.602,-0.056 0.894,-0.135c110.799,-29.663 255.406,-36.913 336.27,-80.04c23.721,-12.652 40.197,-35.629 44.569,-62.156c4.371,-26.527 -3.86,-53.576 -22.269,-73.171c-56.064,-59.676 -153.855,-163.769 -153.855,-163.769"
+
style="fill-opacity: 0; stroke: #000; stroke-width: 24px"
+
/></g>
+
</svg>
+107 -97
static/styles.css
···
-
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
-
@import url('https://fonts.googleapis.com/css2?family=Libre+Bodoni:ital,wght@0,400;0,700;1,400&display=swap');
+
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap");
+
@import url("https://fonts.googleapis.com/css2?family=Libre+Bodoni:ital,wght@0,400..700;1,400..700&display=swap");
@font-face {
-
font-family: 'Berkeley Mono';
-
src: url('/path/to/local/fonts/BerkeleyMono-Regular.woff2') format('woff2'),
-
url('/path/to/local/fonts/BerkeleyMono-Regular.woff') format('woff');
-
font-weight: 400;
-
font-style: normal;
+
font-family: "Berkeley Mono";
+
src:
+
url("/path/to/local/fonts/BerkeleyMono-Regular.woff2") format("woff2"),
+
url("/path/to/local/fonts/BerkeleyMono-Regular.woff") format("woff");
+
font-weight: 400;
+
font-style: normal;
}
@tailwind base;
···
@tailwind utilities;
@theme inline {
-
--color-background: var(--background);
-
--color-foreground: var(--foreground);
+
--color-background: var(--background);
+
--color-foreground: var(--foreground);
}
:root {
-
--font-sans: 'Inter', sans-serif;
-
--font-serif: 'Libre Bodoni', serif;
-
--font-mono: 'Berkeley Mono', monospace;
+
--font-sans: "Inter", sans-serif;
+
--font-serif: "Libre Bodoni", serif;
+
--font-mono: "Berkeley Mono", monospace;
}
-
.font-sans { font-family: var(--font-sans); }
-
.font-serif { font-family: var(--font-serif); }
-
.font-mono { font-family: var(--font-mono); }
-
.font-serif-italic { font-family: var(--font-serif); font-style: italic;}
+
.font-sans {
+
font-family: var(--font-sans);
+
}
+
.font-serif {
+
font-family: var(--font-serif);
+
}
+
.font-mono {
+
font-family: var(--font-mono);
+
}
+
.font-serif-italic {
+
font-family: var(--font-serif);
+
font-style: italic;
+
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
···
color utility to any element that depends on these defaults.
*/
@layer base {
-
*,
-
::after,
-
::before,
-
::backdrop,
-
::file-selector-button {
-
border-color: var(--color-gray-200, currentColor);
-
}
+
*,
+
::after,
+
::before,
+
::backdrop,
+
::file-selector-button {
+
border-color: var(--color-gray-200, currentColor);
+
}
}
@utility text-balance {
-
text-wrap: balance;
+
text-wrap: balance;
}
@layer utilities {
-
:root {
-
--background: #ffffff;
-
--foreground: #171717;
-
}
-
-
@media (prefers-color-scheme: dark) {
:root {
-
--background: #0a0a0a;
-
--foreground: #ededed;
+
--background: #ffffff;
+
--foreground: #171717;
}
-
}
-
body {
-
color: var(--foreground);
-
background: var(--background);
-
font-family: var(--font-sans);
-
}
-
-
@keyframes marquee {
-
0% {
-
opacity: 0;
-
transform: translateX(0px);
-
}
-
2% {
-
opacity: 0.075;
-
}
-
98% {
-
opacity: 0.075;
+
@media (prefers-color-scheme: dark) {
+
:root {
+
--background: #0a0a0a;
+
--foreground: #ededed;
+
}
}
-
100% {
-
opacity: 0;
-
transform: translateX(-4000px);
-
}
-
}
-
@keyframes fadeIn {
-
0% {
-
opacity: 0;
+
body {
+
color: var(--foreground);
+
background: var(--background);
+
font-family: var(--font-sans);
}
-
100% {
-
opacity: 1;
+
+
@keyframes marquee {
+
0% {
+
opacity: 0;
+
transform: translateX(0px);
+
}
+
2% {
+
opacity: 0.075;
+
}
+
98% {
+
opacity: 0.075;
+
}
+
100% {
+
opacity: 0;
+
transform: translateX(-4000px);
+
}
}
-
}
-
@keyframes fadeOut {
-
0% {
-
opacity: 1;
+
@keyframes fadeIn {
+
0% {
+
opacity: 0;
+
}
+
100% {
+
opacity: 1;
+
}
}
-
100% {
-
opacity: 0;
+
+
@keyframes fadeOut {
+
0% {
+
opacity: 1;
+
}
+
100% {
+
opacity: 0;
+
}
}
-
}
-
.animate-marquee {
-
animation: marquee 30s linear infinite;
-
font-size: 100vh;
-
line-height: 0.8;
-
height: 100vh;
-
display: flex;
-
align-items: center;
-
}
+
.animate-marquee {
+
animation: marquee 30s linear infinite;
+
font-size: 100vh;
+
line-height: 0.8;
+
height: 100vh;
+
display: flex;
+
align-items: center;
+
}
-
.animate-fade-in {
-
animation: fadeIn 0.3s ease-in-out forwards;
-
}
+
.animate-fade-in {
+
animation: fadeIn 0.3s ease-in-out forwards;
+
}
-
.animate-fade-out {
-
animation: fadeOut 0.3s ease-in-out forwards;
-
}
+
.animate-fade-out {
+
animation: fadeOut 0.3s ease-in-out forwards;
+
}
}
.diagonal-pattern {
-
background-color: transparent;
-
background: repeating-linear-gradient(
-
-45deg,
-
#000000,
-
#000000 4px,
-
transparent 4px,
-
transparent 10px
-
);
+
background-color: transparent;
+
background: repeating-linear-gradient(
+
-45deg,
+
#000000,
+
#000000 4px,
+
transparent 4px,
+
transparent 10px
+
);
}
@media (prefers-color-scheme: dark) {
-
.diagonal-pattern {
-
background: repeating-linear-gradient(
-
-45deg,
-
#ffffff,
-
#ffffff 4px,
-
transparent 4px,
-
transparent 10px
-
);
-
}
+
.diagonal-pattern {
+
background: repeating-linear-gradient(
+
-45deg,
+
#ffffff,
+
#ffffff 4px,
+
transparent 4px,
+
transparent 10px
+
);
+
}
}