Compare commits

...

7 Commits

Author SHA1 Message Date
962efd0e75 chore: add caching headers
All checks were successful
Generate a build and push to Cloudflare Pages / Build and Deploy to Cloudflare Pages (push) Successful in 4m0s
2026-05-11 11:30:56 -04:00
7ff82151d3 fix: update video element formatting
All checks were successful
Generate a build and push to Cloudflare Pages / Build and Deploy to Cloudflare Pages (push) Successful in 1m24s
2026-04-11 07:57:33 -04:00
12e61a8d75 update blog posts with alerts and editorial improvements 2026-04-11 07:56:51 -04:00
ad96c06506 feat: add GitHub-style markdown alert support 2026-04-11 07:55:17 -04:00
497da8038e accepted into OMSCS at Georgia Tech! 2026-04-11 07:48:05 -04:00
688788a80d feat: improve education timeline component
- add spacing between timeline items
- connect timeline line between nodes by extending the line
- add isLast prop to hide line extension on final item
- auto-detect future start dates and display "Starting X" instead of "X - present"
- reorder awards section before core courses section on mobile
2026-04-11 07:43:55 -04:00
34b6887118 fix: import z directly from zod to resolve deprecation warnings 2026-04-08 08:44:30 -04:00
13 changed files with 173 additions and 29 deletions

View File

@@ -9,6 +9,7 @@ import rehypeExternalLinks from 'rehype-external-links'
import rehypePrettyCode from 'rehype-pretty-code' import rehypePrettyCode from 'rehype-pretty-code'
import rehypeAutolinkHeadings from 'rehype-autolink-headings' import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import remarkEmoji from 'remark-emoji' import remarkEmoji from 'remark-emoji'
import remarkGithubAlerts from 'remark-github-blockquote-alert'
import remarkMath from 'remark-math' import remarkMath from 'remark-math'
import remarkSectionize from 'remark-sectionize' import remarkSectionize from 'remark-sectionize'
import rehypeDocument from 'rehype-document' import rehypeDocument from 'rehype-document'
@@ -159,6 +160,6 @@ export default defineConfig({
}, },
], ],
], ],
remarkPlugins: [remarkMath, remarkEmoji, remarkSectionize], remarkPlugins: [remarkMath, remarkEmoji, remarkSectionize, remarkGithubAlerts],
}, },
}) })

View File

@@ -30,7 +30,7 @@
"astro": "astro", "astro": "astro",
"generate:og": "tsx scripts/generate-og-image.ts", "generate:og": "tsx scripts/generate-og-image.ts",
"prettier": "prettier --write **/*.{ts,tsx,css,astro} --ignore-path .gitignore", "prettier": "prettier --write **/*.{ts,tsx,css,astro} --ignore-path .gitignore",
"deploy": "git push origin main:prod" "push:prod": "git push origin main:prod"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.8", "@astrojs/check": "^0.9.8",
@@ -59,6 +59,7 @@
"rehype-external-links": "^3.0.0", "rehype-external-links": "^3.0.0",
"rehype-pretty-code": "^0.14.3", "rehype-pretty-code": "^0.14.3",
"remark-emoji": "^5.0.2", "remark-emoji": "^5.0.2",
"remark-github-blockquote-alert": "^2.1.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-sectionize": "^2.1.0", "remark-sectionize": "^2.1.0",
"satori": "^0.16.2", "satori": "^0.16.2",

11
pnpm-lock.yaml generated
View File

@@ -86,6 +86,9 @@ importers:
remark-emoji: remark-emoji:
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
remark-github-blockquote-alert:
specifier: ^2.1.0
version: 2.1.0
remark-math: remark-math:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
@@ -2235,6 +2238,10 @@ packages:
remark-gfm@4.0.1: remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-github-blockquote-alert@2.1.0:
resolution: {integrity: sha512-J392jmIP684d7iGsENN0uguL10IGbRdc8bTUSrd/jOLzdWkwg721Fj3JPQGN8tF6fTIrE5HHOIA3nBuwuaeuPQ==}
engines: {node: '>=16'}
remark-math@6.0.0: remark-math@6.0.0:
resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
@@ -5443,6 +5450,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
remark-github-blockquote-alert@2.1.0:
dependencies:
unist-util-visit: 5.1.0
remark-math@6.0.0: remark-math@6.0.0:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4

26
public/_headers Normal file
View File

@@ -0,0 +1,26 @@
/*
Cache-Control: public, max-age=3600
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/fonts/*
Cache-Control: public, max-age=31536000, immutable
/fonts2/*
Cache-Control: public, max-age=31536000, immutable
/static/*
Cache-Control: public, max-age=31536000, immutable
/image/*
Cache-Control: public, max-age=2592000
/*.png
Cache-Control: public, max-age=2592000
/*.ico
Cache-Control: public, max-age=2592000
/*.svg
Cache-Control: public, max-age=2592000

View File

@@ -29,9 +29,10 @@
interface Props { interface Props {
entry: EducationEntry entry: EducationEntry
isLast?: boolean
} }
let { entry }: Props = $props() let { entry, isLast = false }: Props = $props()
const dateDisplay = $derived(() => { const dateDisplay = $derived(() => {
const start = entry.startMonth ? `${entry.startMonth} ${entry.startYear}` : `${entry.startYear}` const start = entry.startMonth ? `${entry.startMonth} ${entry.startYear}` : `${entry.startYear}`
@@ -40,6 +41,18 @@
const end = entry.endMonth ? `${entry.endMonth} ${entry.endYear}` : `${entry.endYear}` const end = entry.endMonth ? `${entry.endMonth} ${entry.endYear}` : `${entry.endYear}`
return `${start} - ${end}` return `${start} - ${end}`
} }
// Check if start date is in the future
const now = new Date()
const currentYear = now.getFullYear()
const isFutureYear = entry.startYear > currentYear
const isFutureMonth = entry.startYear === currentYear && entry.startMonth &&
new Date(`${entry.startMonth} 1, ${entry.startYear}`).getTime() > now.getTime()
if (isFutureYear || isFutureMonth) {
return `Starting ${start}`
}
if (entry.expected) { if (entry.expected) {
return `${start} - Present (Expected)` return `${start} - Present (Expected)`
} }
@@ -47,9 +60,9 @@
}) })
</script> </script>
<div class="relative pl-8 pb-8 last:pb-0"> <div class="relative pl-8">
<!-- Timeline line --> <!-- Timeline line -->
<div class="absolute left-[7px] top-3 bottom-0 w-0.5 bg-border last:hidden"></div> <div class="absolute left-[7px] top-3 w-0.5 bg-border" class:-bottom-8={!isLast} class:bottom-0={isLast}></div>
<!-- Timeline marker --> <!-- Timeline marker -->
<div class="absolute left-0 top-1.5 size-4 rounded-full border-2 border-primary bg-background"></div> <div class="absolute left-0 top-1.5 size-4 rounded-full border-2 border-primary bg-background"></div>
@@ -85,10 +98,10 @@
</div> </div>
<!-- Courses and Awards grid --> <!-- Courses and Awards grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 sm:gap-4">
<!-- Courses --> <!-- Courses (order-2 on mobile so awards appear first) -->
{#if entry.courses && entry.courses.length > 0} {#if entry.courses && entry.courses.length > 0}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2 order-2 sm:order-1">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Core Courses</p> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Core Courses</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each entry.courses as course} {#each entry.courses as course}
@@ -101,9 +114,9 @@
</div> </div>
{/if} {/if}
<!-- Awards --> <!-- Awards (order-1 on mobile so it appears before courses) -->
{#if entry.awards && entry.awards.length > 0} {#if entry.awards && entry.awards.length > 0}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2 order-1 sm:order-2">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Awards & Scholarships</p> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Awards & Scholarships</p>
<ul class="text-sm space-y-2"> <ul class="text-sm space-y-2">
{#each entry.awards as award} {#each entry.awards as award}

View File

@@ -8,6 +8,6 @@
let { children }: Props = $props() let { children }: Props = $props()
</script> </script>
<div class="relative"> <div class="relative flex flex-col gap-6">
{@render children()} {@render children()}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { glob } from 'astro/loaders' import { glob } from 'astro/loaders'
import { defineCollection, z } from 'astro:content' import { defineCollection } from 'astro:content'
import { z } from 'zod'
const blog = defineCollection({ const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
@@ -23,7 +24,7 @@ const projects = defineCollection({
description: z.string(), description: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
image: image(), image: image(),
link: z.string().url().optional(), link: z.url().optional(),
order: z.number().optional(), order: z.number().optional(),
startDate: z.coerce.date().optional(), startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(), endDate: z.coerce.date().optional(),

View File

@@ -18,6 +18,7 @@ What if you could block ads and trackers across your entire network, on every de
That's what we'll build in this guide. There are already a few [ready-made solutions](#part-11-where-to-go-from-here---pi-hole-and-adguard-home), but building a simple system ourselves will help us understand how the underlying logic actually works. By the end, you'll have a working network-level ad-blocker running on a Debian machine, and a solid understanding of the DNS system that makes it all possible. That's what we'll build in this guide. There are already a few [ready-made solutions](#part-11-where-to-go-from-here---pi-hole-and-adguard-home), but building a simple system ourselves will help us understand how the underlying logic actually works. By the end, you'll have a working network-level ad-blocker running on a Debian machine, and a solid understanding of the DNS system that makes it all possible.
> [!IMPORTANT]
> **Prerequisites**: A machine running Debian (this guide uses Debian 13 Trixie), basic comfort with the terminal, and a home network you control. You could also follow along up to Part 5 on Windows or Mac. > **Prerequisites**: A machine running Debian (this guide uses Debian 13 Trixie), basic comfort with the terminal, and a home network you control. You could also follow along up to Part 5 on Windows or Mac.
--- ---
@@ -106,6 +107,7 @@ Add a line like this to your hosts file:
0.0.0.0 facebook.com 0.0.0.0 facebook.com
``` ```
> [!NOTE]
> If your network uses IPv6, you may also need to add the line `:: facebook.com` to block the IPv6 address. `::` is the IPv6 equivalent of `0.0.0.0`. We'll assume we're on an IPv4 network for the remainder of this post. > If your network uses IPv6, you may also need to add the line `:: facebook.com` to block the IPv6 address. `::` is the IPv6 equivalent of `0.0.0.0`. We'll assume we're on an IPv4 network for the remainder of this post.
Save the file and try it: Save the file and try it:
@@ -116,11 +118,13 @@ curl -I facebook.com
You should see a "connection refused" error, which is exactly what we want. The address resolves to `0.0.0.0`, nothing is listening there, and the connection fails. Alternatively, try visiting `facebook.com` in your browser — you'll get an error page instead of the site. You should see a "connection refused" error, which is exactly what we want. The address resolves to `0.0.0.0`, nothing is listening there, and the connection fails. Alternatively, try visiting `facebook.com` in your browser — you'll get an error page instead of the site.
> **Note:** This same mechanism is what makes DNS poisoning attacks possible. If an attacker can modify your hosts file or intercept your DNS queries, they can redirect `facebook.com` to a malicious server that looks identical to the real thing. This is one reason why HTTPS and certificate validation matter: even if DNS is compromised, a valid TLS certificate is hard to fake. > [!WARNING]
> This same mechanism is what makes DNS poisoning attacks possible. If an attacker can modify your hosts file or intercept your DNS queries, they can redirect `facebook.com` to a malicious server that looks identical to the real thing. This is one reason why HTTPS and certificate validation matter: even if DNS is compromised, a valid TLS certificate is hard to fake.
Remove the `facebook.com` line when you're done experimenting. Remove the `facebook.com` line when you're done experimenting.
> **Note:** You may notice that pinging `0.0.0.0` actually reaches the loopback address `127.0.0.1`. This is because RFC 1122 specifies that `0.0.0.0` is only valid as a source address. On most Unix-like systems, using it as a destination causes the OS to treat it as the local loopback instead. Keep this in mind if you have a local web server running, as it may respond to requests directed at these blocked domains. > [!NOTE]
> You may notice that pinging `0.0.0.0` actually reaches the loopback address `127.0.0.1`. This is because RFC 1122 specifies that `0.0.0.0` is only valid as a source address. On most Unix-like systems, using it as a destination causes the OS to treat it as the local loopback instead. Keep this in mind if you have a local web server running, as it may respond to requests directed at these blocked domains.
--- ---
## Part 4: Using the Hosts File to Block Ads ## Part 4: Using the Hosts File to Block Ads
@@ -240,6 +244,7 @@ bogus-priv
addn-hosts=/etc/hosts.blocklist addn-hosts=/etc/hosts.blocklist
``` ```
> [!TIP]
> **Find your interface name** with `ip link`. It's usually `eth0` for wired connections, but modern Debian systems often use names like `enp3s0` or `ens3`. > **Find your interface name** with `ip link`. It's usually `eth0` for wired connections, but modern Debian systems often use names like `enp3s0` or `ens3`.
### Restarting dnsmasq ### Restarting dnsmasq

View File

@@ -2,6 +2,7 @@
title: "Deploying to Cloudflare Pages with GitHub Actions" title: "Deploying to Cloudflare Pages with GitHub Actions"
description: "A simple CI/CD workflow for deploying static site to Cloudflare Pages using GitHub Actions" description: "A simple CI/CD workflow for deploying static site to Cloudflare Pages using GitHub Actions"
date: 2026-03-26 date: 2026-03-26
updatedDate:
tags: ['CI/CD', 'Gitea', 'Cloudflare Pages', 'DevOps'] tags: ['CI/CD', 'Gitea', 'Cloudflare Pages', 'DevOps']
authors: authors:
- "Patrick Jaroszewski" - "Patrick Jaroszewski"
@@ -16,9 +17,20 @@ This post walks through the workflow I use to deploy this site and others I host
--- ---
## Prerequisites
Before getting started, you'll need:
- A Gitea instance with Actions enabled ([documentation](https://docs.gitea.com/usage/actions/overview))
- A registered Gitea runner (ubuntu-latest or equivalent)
- A Cloudflare account with access to Pages
- Your project set up with pnpm (adjust commands for npm/yarn as needed)
---
## The Basic Workflow ## The Basic Workflow
Here's a minimal Gitea Actions workflow that builds an Astro site and deploys it to Cloudflare Pages: Here's a minimal Gitea Actions workflow that builds an Astro site and deploys it to Cloudflare Pages. Note that Gitea uses `.gitea/workflows/` instead of GitHub's `.github/workflows/`.
```yaml title=".gitea/workflows/deploy.yaml" ```yaml title=".gitea/workflows/deploy.yaml"
name: Generate a build and push to Cloudflare Pages name: Generate a build and push to Cloudflare Pages
@@ -64,6 +76,21 @@ jobs:
The workflow triggers on pushes to `main`, installs dependencies, builds the site, and deploys using Cloudflare's Wrangler action. Every push to main goes straight to production. The workflow triggers on pushes to `main`, installs dependencies, builds the site, and deploys using Cloudflare's Wrangler action. Every push to main goes straight to production.
> [!NOTE]
> Adjust `./dist` to match your framework's output directory—this is Astro's default. For example, Next.js static exports use `out`.
---
## Required Secrets
> [!TIP]
> If your Cloudflare Pages project doesn't exist yet, Wrangler will create it automatically on the first deployment.
You'll need to configure two secrets in your Gitea repository (Settings → Actions → Secrets):
- `CLOUDFLARE_API_TOKEN` — Create this in the Cloudflare dashboard: Profile → API Tokens → Create Token → Use the "Edit Cloudflare Pages" template (or create a custom token with **Account: Cloudflare Pages: Edit** permission)
- `CLOUDFLARE_ACCOUNT_ID` — Found in the Cloudflare dashboard sidebar under **Workers & Pages → Overview**, or in the URL when viewing any Pages project (`dash.cloudflare.com/<account-id>/pages`)
--- ---
## Using a Separate Production Branch ## Using a Separate Production Branch
@@ -89,12 +116,12 @@ Then add a script to promote `main` to `prod` when you're ready:
```json title="package.json" ```json title="package.json"
{ {
"scripts": { "scripts": {
"deploy": "git push origin main:prod" "push:prod": "git push origin main:prod"
} }
} }
``` ```
Now you develop freely on `main`, and run `pnpm deploy` when you want to go live. Now you develop freely on `main`, and run `pnpm push:prod` when you want to go live.
--- ---
@@ -103,7 +130,7 @@ Now you develop freely on `main`, and run `pnpm deploy` when you want to go live
If you use a `prod` branch, there's a catch: Cloudflare Pages uses the **branch name** to determine the deployment environment. If you use a `prod` branch, there's a catch: Cloudflare Pages uses the **branch name** to determine the deployment environment.
- `main` or `master` → production deployment (your primary URL) - `main` or `master` → production deployment (your primary URL)
- Any other branch → preview deployment (e.g., `prod.your-project.pages.dev`) - Any other branch → preview deployment (e.g., `branch-name.your-project.pages.dev`)
If you deploy from a branch called `prod`, Cloudflare treats it as a preview deployment, not production. Your site ends up at a subdomain instead of your main URL. If you deploy from a branch called `prod`, Cloudflare treats it as a preview deployment, not production. Your site ends up at a subdomain instead of your main URL.
@@ -121,12 +148,28 @@ Option 1 is cleaner because it aligns Cloudflare's understanding with your actua
--- ---
## Required Secrets ## Using a Preview Branch
You'll need to configure two secrets in your Gitea repository (Settings → Actions → Secrets): Since Cloudflare treats all other branches as preview deployments, you can easily deploy a preview of your site by triggering the workflow on any branch (e.g., `preview`).
- `CLOUDFLARE_API_TOKEN` — Create an API token in Cloudflare with "Cloudflare Pages: Edit" permissions ```yaml
- `CLOUDFLARE_ACCOUNT_ID` — Found in your Cloudflare dashboard URL or account settings on:
push:
branches:
- prod
- preview
```
Pushing to the `preview` branch deploys your site to `preview.your-project.pages.dev`. To make this convenient, add another script to your `package.json`:
```json title="package.json"
{
"scripts": {
"push:prod": "git push origin main:prod",
"push:preview": "git push origin main:preview"
}
}
```
--- ---

View File

@@ -18,9 +18,15 @@ startDate: '2025-09-23'
Cue is an on-demand resource booking system built for an arcade and pool venue. The UI was designed to run on a terminal-like POS touchscreen. Cue is an on-demand resource booking system built for an arcade and pool venue. The UI was designed to run on a terminal-like POS touchscreen.
<video autoplay loop muted playsinline preload="auto" data-astro-reload> <video
<source src="/static/cue.webm" type="video/webm" /> src="/static/cue.webm"
</video> autoplay
loop
muted
playsinline
preload="auto"
class="w-full"
></video>
## The Problem ## The Problem

View File

@@ -1,6 +1,13 @@
import type { EducationEntry } from '@/components/svelte/EducationItem.svelte' import type { EducationEntry } from '@/components/svelte/EducationItem.svelte'
export const education: EducationEntry[] = [ export const education: EducationEntry[] = [
{
school: 'Georgia Institute of Technology',
degree: 'Masters of Computer Science',
location: 'Atlanta, USA',
startYear: 2026,
startMonth: 'Sept',
},
{ {
school: 'University of Guelph', school: 'University of Guelph',
degree: 'Bachelor of Computing', degree: 'Bachelor of Computing',
@@ -12,7 +19,7 @@ export const education: EducationEntry[] = [
endYear: 2024, endYear: 2024,
endMonth: 'May', endMonth: 'May',
expected: false, expected: false,
courses: ['Data Structures', 'Algorithms', 'Software Engineering', 'Operating Systems', 'Databases', 'Computer Networks'], courses: ['Data Structures', 'Algorithms', 'Software Engineering', 'Operating Systems', 'Databases', 'Computer Networks', 'Cloud Computing', 'Systems Programming'],
awards: [ awards: [
{ name: "Dean's List", description: 'In each full-time semester' }, { name: "Dean's List", description: 'In each full-time semester' },
{ name: 'Weiner Mathematical Scholarship', description: 'Highest mathematics average (1st year)' }, { name: 'Weiner Mathematical Scholarship', description: 'Highest mathematics average (1st year)' },

View File

@@ -172,8 +172,8 @@ const turnstileSitekey = import.meta.env.PUBLIC_TURNSTILE_SITEKEY
</div> </div>
<div class="mt-4"> <div class="mt-4">
<Timeline client:load> <Timeline client:load>
{education.map(entry => ( {education.map((entry, i) => (
<EducationItem entry={entry} client:load /> <EducationItem entry={entry} isLast={i === education.length - 1} client:load />
))} ))}
</Timeline> </Timeline>
</div> </div>

View File

@@ -83,6 +83,36 @@
@apply [&>p]:my-0 [&>p:first-child]:mt-0 [&>p:last-child]:mb-0; @apply [&>p]:my-0 [&>p:first-child]:mt-0 [&>p:last-child]:mb-0;
} }
/* GitHub-style admonitions */
.markdown-alert {
@apply my-4 rounded-md border-l-4 px-4 py-2 not-italic;
@apply [&>p]:my-0 [&>p:not(.markdown-alert-title)]:mt-1;
}
.markdown-alert-title {
@apply flex items-center gap-2 font-semibold text-sm;
@apply [&>svg]:size-4 [&>svg]:fill-current;
}
.markdown-alert-note {
@apply border-blue-500 bg-blue-500/10;
@apply [&>.markdown-alert-title]:text-blue-600 dark:[&>.markdown-alert-title]:text-blue-400;
}
.markdown-alert-tip {
@apply border-green-500 bg-green-500/10;
@apply [&>.markdown-alert-title]:text-green-600 dark:[&>.markdown-alert-title]:text-green-400;
}
.markdown-alert-important {
@apply border-purple-500 bg-purple-500/10;
@apply [&>.markdown-alert-title]:text-purple-600 dark:[&>.markdown-alert-title]:text-purple-400;
}
.markdown-alert-warning {
@apply border-yellow-500 bg-yellow-500/10;
@apply [&>.markdown-alert-title]:text-yellow-600 dark:[&>.markdown-alert-title]:text-yellow-400;
}
.markdown-alert-caution {
@apply border-red-500 bg-red-500/10;
@apply [&>.markdown-alert-title]:text-red-600 dark:[&>.markdown-alert-title]:text-red-400;
}
hr { hr {
@apply border-border my-10 border-t-2; @apply border-border my-10 border-t-2;
} }