diff --git a/app/components/TalkThumbnail.tsx b/app/components/TalkThumbnail.tsx
new file mode 100644
index 0000000..6a7c96c
--- /dev/null
+++ b/app/components/TalkThumbnail.tsx
@@ -0,0 +1,204 @@
+"use client"
+import styled from "styled-components"
+
+//
+// Types
+//
+
+export type TalkThumbnailData = {
+ speakerName: string
+ hook: string
+ profilePhotoUrl?: string
+}
+
+export type TalkThumbnailProps = TalkThumbnailData & {
+ /** Width in pixels; height is derived for 16:9 (YouTube thumbnail). */
+ width?: number
+ className?: string
+}
+
+//
+// Constants
+//
+
+const ASPECT_RATIO = 16 / 9
+const DEFAULT_WIDTH = 640
+const BG_IMAGE_PATH = "/images/devx-thumbnail-bg.png"
+const LOGO_IMAGE_PATH = "/images/sd-devx-brand.png"
+
+//
+// Components
+//
+
+/**
+ * Renders a DEVx-style talk video thumbnail matching the DEVxYouTubeThumbnail.svg
+ * template layout: hook text on the left, circular speaker photo on the right,
+ * DEVxSD branding at bottom-left, dark silk texture background.
+ *
+ * 16:9 aspect ratio (YouTube standard 1280x720).
+ */
+export function TalkThumbnail({
+ speakerName,
+ hook,
+ profilePhotoUrl,
+ width = DEFAULT_WIDTH,
+ className
+}: TalkThumbnailProps) {
+ const height = Math.round(width / ASPECT_RATIO)
+
+ // Layout proportions matching the template
+ const pad = width * 0.06
+ const photoRadius = Math.round(height * 0.3)
+ const photoBackdropRadius = photoRadius + Math.round(width * 0.01)
+ const photoCx = width - pad - photoBackdropRadius
+ const photoCy = height * 0.44
+ const titleX = pad
+ const photoLeftEdge = photoCx - photoBackdropRadius
+ const titleGap = width * 0.04
+ const titleMaxWidth = photoLeftEdge - pad - titleGap
+ const titleFontSize = Math.round(width * 0.058)
+ const titleY = height * 0.38
+ const logoWidth = Math.round(width * 0.18)
+ const logoHeight = Math.round(logoWidth * (582 / 2772))
+ const logoX = pad
+ const logoY = height - pad - logoHeight
+
+ const titleLines = wrapText(hook || "Your Hook", titleMaxWidth, titleFontSize)
+
+ return (
+
+
+
+ )
+}
+
+const Wrapper = styled.div<{ $width: number; $height: number }>`
+ width: ${(p) => p.$width}px;
+ max-width: 100%;
+ aspect-ratio: 16 / 9;
+ overflow: hidden;
+ border-radius: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: #0a0a0a;
+
+ svg {
+ display: block;
+ width: 100%;
+ height: auto;
+ }
+`
+
+//
+// Functions
+//
+
+/** Wrap text into lines that fit within maxWidth, max 3 lines. */
+function wrapText(text: string, maxWidth: number, fontSize: number): string[] {
+ if (!text.trim()) return ["Your Hook"]
+ const words = text.trim().split(/\s+/)
+ const approxCharWidth = fontSize * 0.52
+ const maxLines = 3
+ const lines: string[] = []
+ let current = ""
+
+ for (const word of words) {
+ const candidate = current ? `${current} ${word}` : word
+ if (candidate.length * approxCharWidth <= maxWidth) {
+ current = candidate
+ } else {
+ if (current) lines.push(current)
+ if (lines.length >= maxLines) {
+ const last = lines[lines.length - 1]
+ if (last && word) {
+ lines[lines.length - 1] = last + "..."
+ }
+ return lines
+ }
+ current = word
+ }
+ }
+ if (current && lines.length < maxLines) lines.push(current)
+ return lines
+}
diff --git a/app/submit-talk/page.tsx b/app/submit-talk/page.tsx
index 3849fac..eed1e1e 100644
--- a/app/submit-talk/page.tsx
+++ b/app/submit-talk/page.tsx
@@ -11,6 +11,7 @@ import { TextareaInput } from "../components/TextareaInput"
import { RadioInput } from "../components/RadioInput"
import { PageContainer } from "../components/PageContainer"
import { SuccessMessage as SuccessMessageComponent } from "../components/SuccessMessage"
+import { TalkThumbnail } from "../components/TalkThumbnail"
import Link from "next/link"
export default function SubmitTalk() {
@@ -23,12 +24,14 @@ export default function SubmitTalk() {
const [userEmail, setUserEmail] = useState("")
const [userFullName, setUserFullName] = useState("")
const [userHandle, setUserHandle] = useState(null)
+ const [profilePhotoUrl, setProfilePhotoUrl] = useState(null)
const [profileId, setProfileId] = useState(null)
const [profilePhoneNumber, setProfilePhoneNumber] = useState(null)
const [isEditingPhone, setIsEditingPhone] = useState(false)
const [formData, setFormData] = useState({
phoneNumber: "",
talkTitle: "",
+ hook: "",
talkSynopsis: "",
slidesType: "upload" as "url" | "upload",
slidesUrl: "",
@@ -63,15 +66,16 @@ export default function SubmitTalk() {
const { handle } = getProfileFromCache(user)
setUserHandle(handle)
- // Load profile to get full name, profile_id, and phone number
+ // Load profile to get full name, profile_id, phone number, and profile photo (for thumbnail)
const { data: profile } = await supabaseClient
.from("profiles")
- .select("id, full_name, phone_number")
+ .select("id, full_name, phone_number, profile_photo")
.eq("user_id", user.id)
.single()
if (profile) {
setUserFullName(profile.full_name)
+ setProfilePhotoUrl(profile.profile_photo || null)
setProfileId(profile.id)
setProfilePhoneNumber(profile.phone_number)
// If profile has phone number, use it; otherwise start with empty
@@ -239,6 +243,7 @@ export default function SubmitTalk() {
profile_id: profileId,
user_id: user.id,
talk_title: formData.talkTitle.trim(),
+ talk_hook: formData.hook.trim() || null,
talk_synopsis: formData.talkSynopsis.trim(),
slides_type: formData.slidesType,
slides_url: formData.slidesType === "url" ? formData.slidesUrl.trim() : null,
@@ -255,6 +260,7 @@ export default function SubmitTalk() {
setFormData({
phoneNumber: profilePhoneNumber || "",
talkTitle: "",
+ hook: "",
talkSynopsis: "",
slidesType: "upload",
slidesUrl: "",
@@ -400,6 +406,43 @@ export default function SubmitTalk() {
required
/>
+
+
+
+ Short, punchy text for the video thumbnail. Defaults to your talk title if
+ left empty.
+
+ setFormData({ ...formData, hook: e.target.value })}
+ placeholder={formData.talkTitle || "Enter your talk title first"}
+ maxLength={50}
+ />
+
+
+
+
+ This is how your talk could look as a YouTube thumbnail. Update your{" "}
+ {userHandle ? (
+ nametag
+ ) : (
+ nametag
+ )}{" "}
+ to change your name and photo.
+
+
+
+
+
@@ -569,6 +612,13 @@ const InfoNote = styled.p`
line-height: 1.5;
`
+const HookNote = styled.p`
+ margin: 0;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 0.8125rem;
+ line-height: 1.4;
+`
+
const InfoLink = styled(Link)`
color: rgba(156, 163, 255, 0.9);
text-decoration: underline;
@@ -579,6 +629,11 @@ const InfoLink = styled(Link)`
}
`
+const ThumbnailPreviewWrap = styled.div`
+ max-width: 100%;
+ margin-top: 0.5rem;
+`
+
const Label = styled.label`
font-size: 0.875rem;
font-weight: 700;
diff --git a/app/talk-thumbnail-gen/page.tsx b/app/talk-thumbnail-gen/page.tsx
new file mode 100644
index 0000000..baab6e7
--- /dev/null
+++ b/app/talk-thumbnail-gen/page.tsx
@@ -0,0 +1,502 @@
+"use client"
+import styled from "styled-components"
+import { useState, useRef, useCallback } from "react"
+import { supabaseClient } from "../../lib/supabaseClient"
+import { TalkThumbnail } from "../components/TalkThumbnail"
+import { TextInput } from "../components/TextInput"
+import { Button } from "../components/Button"
+import { PotionBackground } from "../components/PotionBackground"
+import { PageContainer } from "../components/PageContainer"
+
+//
+// Constants
+//
+
+const THUMBNAIL_WIDTH = 1280
+const THUMBNAIL_HEIGHT = 720
+
+//
+// Components
+//
+
+export default function TalkThumbnailGen() {
+ const [hook, setHook] = useState("")
+ const [handle, setHandle] = useState("")
+ const [photoUrl, setPhotoUrl] = useState(null)
+ const [photoSource, setPhotoSource] = useState<"none" | "upload" | "handle">("none")
+ const [handleLoading, setHandleLoading] = useState(false)
+ const [handleError, setHandleError] = useState(null)
+ const fileInputRef = useRef(null)
+ const svgContainerRef = useRef(null)
+
+ const handleFileUpload = useCallback(
+ (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ if (photoUrl && photoSource === "upload") {
+ URL.revokeObjectURL(photoUrl)
+ }
+
+ const blobUrl = URL.createObjectURL(file)
+ setPhotoUrl(blobUrl)
+ setPhotoSource("upload")
+ setHandle("")
+ setHandleError(null)
+ },
+ [photoUrl, photoSource]
+ )
+
+ const handleLookup = useCallback(async () => {
+ if (!handle.trim()) return
+
+ setHandleLoading(true)
+ setHandleError(null)
+
+ try {
+ const { data: profile, error } = await supabaseClient
+ .from("profiles")
+ .select("full_name, profile_photo")
+ .eq("handle", handle.trim().toLowerCase())
+ .single()
+
+ if (error || !profile) {
+ setHandleError(`No profile found for @${handle.trim()}`)
+ return
+ }
+
+ if (profile.profile_photo) {
+ if (photoUrl && photoSource === "upload") {
+ URL.revokeObjectURL(photoUrl)
+ }
+ setPhotoUrl(profile.profile_photo)
+ setPhotoSource("handle")
+ } else {
+ setHandleError(`@${handle.trim()} has no profile photo`)
+ }
+ } catch {
+ setHandleError("Failed to look up profile")
+ } finally {
+ setHandleLoading(false)
+ }
+ }, [handle, photoUrl, photoSource])
+
+ const clearPhoto = useCallback(() => {
+ if (photoUrl && photoSource === "upload") {
+ URL.revokeObjectURL(photoUrl)
+ }
+ setPhotoUrl(null)
+ setPhotoSource("none")
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }, [photoUrl, photoSource])
+
+ const downloadRaster = useCallback(
+ async (format: "png" | "jpg") => {
+ const svgEl = svgContainerRef.current?.querySelector("svg")
+ if (!svgEl) return
+
+ const svgString = await buildEmbeddedSvgString(svgEl)
+ const svgBlob = new Blob([svgString], { type: "image/svg+xml" })
+ const svgBlobUrl = URL.createObjectURL(svgBlob)
+
+ const img = new Image()
+ img.width = THUMBNAIL_WIDTH
+ img.height = THUMBNAIL_HEIGHT
+
+ img.onload = () => {
+ const canvas = document.createElement("canvas")
+ canvas.width = THUMBNAIL_WIDTH
+ canvas.height = THUMBNAIL_HEIGHT
+ const ctx = canvas.getContext("2d")
+ if (!ctx) return
+
+ // JPG has no transparency — fill white first
+ if (format === "jpg") {
+ ctx.fillStyle = "#000000"
+ ctx.fillRect(0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
+ }
+
+ ctx.drawImage(img, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
+ URL.revokeObjectURL(svgBlobUrl)
+
+ const mimeType = format === "jpg" ? "image/jpeg" : "image/png"
+ // Quality 0.92 for JPG keeps it well under 2 MB
+ const quality = format === "jpg" ? 0.92 : undefined
+
+ canvas.toBlob(
+ (blob) => {
+ if (!blob) return
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = makeFilename(hook, format)
+ a.click()
+ URL.revokeObjectURL(url)
+ },
+ mimeType,
+ quality
+ )
+ }
+
+ img.src = svgBlobUrl
+ },
+ [hook]
+ )
+
+ return (
+ <>
+
+
+
+
+
+ Talk Thumbnail Generator
+ Create a YouTube thumbnail for your DEVx talk
+
+
+
+
+ setHook(e.target.value)}
+ placeholder="Enter the thumbnail hook text"
+ maxLength={50}
+ />
+
+
+
+
+
+
+
+
+ or
+
+
+
+
+ @
+ setHandle(e.target.value)}
+ placeholder="username"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ handleLookup()
+ }
+ }}
+ />
+
+
+ {handleError && {handleError}}
+
+
+
+ {photoUrl && (
+
+
+ Photo loaded
+ {photoSource === "handle" ? ` from @${handle}` : " from upload"}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ 1280 x 720 px · under 2 MB · YouTube ready
+
+
+ >
+ )
+}
+
+//
+// Styled Components
+//
+
+const BackgroundContainer = styled.section`
+ background-color: #0a0a0a;
+ position: fixed;
+ height: 100vh;
+ width: 100vw;
+ top: 0;
+ left: 0;
+ z-index: -1;
+`
+
+const WidePageContainer = styled(PageContainer)`
+ max-width: 960px;
+`
+
+const Container = styled.main`
+ min-height: 100vh;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 2rem 1rem;
+`
+
+const Title = styled.h1`
+ font-size: 2rem;
+ font-weight: 700;
+ color: white;
+ margin: 0;
+ text-align: center;
+`
+
+const Subtitle = styled.p`
+ color: rgba(255, 255, 255, 0.7);
+ margin: -1rem 0 0 0;
+ text-align: center;
+`
+
+const FormSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ width: 100%;
+`
+
+const PhotoRow = styled.div`
+ display: flex;
+ align-items: flex-end;
+ gap: 1.5rem;
+ width: 100%;
+
+ & > div {
+ flex: 1;
+ }
+
+ @media (max-width: 600px) {
+ flex-direction: column;
+ align-items: stretch;
+ }
+`
+
+const PhotoStatusRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+`
+
+const Preview = styled.div`
+ width: 100%;
+ border-radius: 0.5rem;
+ overflow: hidden;
+
+ & > div {
+ width: 100% !important;
+ }
+`
+
+const Field = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+`
+
+const Label = styled.label`
+ font-size: 0.875rem;
+ font-weight: 700;
+ color: rgba(255, 255, 255, 0.9);
+`
+
+const FileInput = styled.input`
+ padding: 0.5rem;
+ background-color: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 0.5rem;
+ color: white;
+ font-size: 0.875rem;
+ cursor: pointer;
+
+ &::file-selector-button {
+ padding: 0.375rem 0.75rem;
+ margin-right: 0.75rem;
+ background-color: rgba(156, 163, 255, 0.2);
+ border: 1px solid rgba(156, 163, 255, 0.4);
+ border-radius: 0.375rem;
+ color: white;
+ cursor: pointer;
+ font-size: 0.8125rem;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: rgba(156, 163, 255, 0.3);
+ }
+ }
+`
+
+const HandleRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+
+ input {
+ flex: 1;
+ }
+`
+
+const HandlePrefix = styled.span`
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 1rem;
+ font-weight: 600;
+ flex-shrink: 0;
+`
+
+const ErrorText = styled.p`
+ margin: 0;
+ color: #ff6b6b;
+ font-size: 0.8125rem;
+`
+
+const PhotoStatus = styled.p`
+ margin: 0;
+ color: rgba(156, 163, 255, 0.9);
+ font-size: 0.8125rem;
+`
+
+const OrDivider = styled.div`
+ text-align: center;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ flex-shrink: 0;
+ padding-bottom: 0.5rem;
+
+ @media (max-width: 600px) {
+ padding-bottom: 0;
+ }
+`
+
+const DownloadRow = styled.div`
+ display: flex;
+ justify-content: center;
+ gap: 0.75rem;
+`
+
+const SpecNote = styled.p`
+ margin: -1rem 0 0 0;
+ text-align: center;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 0.75rem;
+`
+
+//
+// Functions
+//
+
+function makeFilename(title: string, ext: string): string {
+ const slug = (title || "talk-thumbnail")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 50)
+ return `${slug}-thumbnail.${ext}`
+}
+
+/**
+ * Convert an image URL (relative, absolute, or blob) to a base64 data URL
+ * by drawing it onto a temporary canvas.
+ */
+async function urlToDataUrl(src: string): Promise {
+ // Already a data URL — return as-is
+ if (src.startsWith("data:")) return src
+
+ // Resolve relative paths to absolute
+ const resolved = src.startsWith("/") ? `${window.location.origin}${src}` : src
+
+ const response = await fetch(resolved)
+ const blob = await response.blob()
+
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onloadend = () => resolve(reader.result as string)
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+}
+
+/**
+ * Clone the live SVG element, convert every `` to an
+ * embedded base64 data URL, and return the serialized SVG string.
+ * This makes the exported SVG/PNG fully self-contained.
+ */
+async function buildEmbeddedSvgString(svgEl: SVGSVGElement): Promise {
+ const clone = svgEl.cloneNode(true) as SVGSVGElement
+ const images = clone.querySelectorAll("image")
+
+ await Promise.all(
+ Array.from(images).map(async (img) => {
+ const href =
+ img.getAttribute("href") || img.getAttributeNS("http://www.w3.org/1999/xlink", "href")
+ if (!href || href.startsWith("data:")) return
+ try {
+ const dataUrl = await urlToDataUrl(href)
+ img.setAttribute("href", dataUrl)
+ // Remove xlink:href if present to avoid duplicates
+ img.removeAttributeNS("http://www.w3.org/1999/xlink", "href")
+ } catch {
+ // If an image fails to convert, leave the original href
+ }
+ })
+ )
+
+ const serializer = new XMLSerializer()
+ return serializer.serializeToString(clone)
+}
diff --git a/bun.lock b/bun.lock
index 0630f62..21e4358 100644
--- a/bun.lock
+++ b/bun.lock
@@ -32,7 +32,7 @@
"lint-staged": "^15.2.7",
"postcss": "^8.4.39",
"prettier": "3.3.3",
- "supabase": "^2.58.5",
+ "supabase": "^2.76.7",
"tailwindcss": "^3.4.4",
"typescript": "^5",
},
@@ -1027,7 +1027,7 @@
"sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
- "supabase": ["supabase@2.58.5", "", { "dependencies": { "bin-links": "^6.0.0", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2", "tar": "7.5.2" }, "bin": { "supabase": "bin/supabase" } }, "sha512-mYZSkUIePTdmwlHd26Pff8wpmjfre8gcuWzrc5QqhZgZvCXugVzAQQhcjaQisw5kusbPQWNIjUwcHYEKmejhPw=="],
+ "supabase": ["supabase@2.76.7", "", { "dependencies": { "bin-links": "^6.0.0", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2", "tar": "7.5.7" }, "bin": { "supabase": "bin/supabase" } }, "sha512-tUseXvr7uLkw665cHdY3mg8NTHL5GABViRk7OhJkkbpOzvAJW/ydxQWAwLTcvXaI4P+cjbAmUgIv+6m8lOUe1Q=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -1039,7 +1039,7 @@
"tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
- "tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="],
+ "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
"tar-fs": ["tar-fs@2.1.1", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng=="],
diff --git a/package.json b/package.json
index d2a2fd2..bc3dfa5 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "next dev",
+ "dev:supabase": "supabase start",
"build": "next build",
"start": "next start",
"lint": "next lint",
@@ -45,7 +46,7 @@
"lint-staged": "^15.2.7",
"postcss": "^8.4.39",
"prettier": "3.3.3",
- "supabase": "^2.58.5",
+ "supabase": "^2.76.7",
"tailwindcss": "^3.4.4",
"typescript": "^5"
}
diff --git a/public/images/devx-thumbnail-bg.png b/public/images/devx-thumbnail-bg.png
new file mode 100644
index 0000000..d6b527d
Binary files /dev/null and b/public/images/devx-thumbnail-bg.png differ
diff --git a/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/metadata.json b/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/metadata.json
index 9b4deeb..ba24890 100644
--- a/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/metadata.json
+++ b/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/metadata.json
@@ -1,6 +1,6 @@
{
- "title": "Houdini Overview and Custom Properties Dive",
- "description": "A lightning talk covering the Houdini initive and then narrowing in on how we can use the full power of CSS custom properties.",
- "author": "AJ Caldwell",
- "timestamp": "2026-01-23T00:00:00.000Z"
+ "title": "Houdini Overview and Custom Properties Dive",
+ "description": "A lightning talk covering the Houdini initive and then narrowing in on how we can use the full power of CSS custom properties.",
+ "author": "AJ Caldwell",
+ "timestamp": "2026-01-23T00:00:00.000Z"
}
diff --git a/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/slides.html b/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/slides.html
index 2e2ce0a..1bff922 100644
--- a/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/slides.html
+++ b/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/slides.html
@@ -1,157 +1,181 @@
-
+
-
-
-
- Houdini Overview and Custom Properties Dive
-
-
-
-
-
-

-
-
-

-
-
-

-
-
-

-
-
-

-
-
-

-
-
-

-
-
-

-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+ Houdini Overview and Custom Properties Dive
+
+
+
+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+
+
+
+
+
+