Joelclaw joelclaw-web

Update and maintain joelclaw.com — the Next.js web app at apps/web/. Use when writing blog posts, editing pages, updating the network page, changing layout/header/footer, adding components, or fixing anything on the site. Hard content triggers: 'write article about X' (draft in Convex), 'publish article <slug>' (set draft=false + revalidate tags/paths). Also triggers on: 'update the site', 'write a post', 'fix the blog', 'joelclaw.com', 'update network page', 'add a page', 'change the header', or any task involving the public-facing web app.

install
source · Clone the upstream repo
git clone https://github.com/joelhooks/joelclaw
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/joelhooks/joelclaw "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/joelclaw-web" ~/.claude/skills/joelhooks-joelclaw-joelclaw-web && rm -rf "$T"
manifest: skills/joelclaw-web/SKILL.md
source content

joelclaw.com Web App

Next.js app at

apps/web/
in the joelclaw monorepo (
~/Code/joelhooks/joelclaw/
). Deployed to Vercel on push to
main
. Dark theme, minimal, system-y aesthetic.

OPSEC Rules

Never expose real infrastructure details on public pages.

  • Node/host names: Use Stephen King universe aliases, never real tailnet hostnames
  • Ports: Do not publish port numbers for any service
  • Usernames: Strip
    com.joel.
    or similar prefixes from service identifiers
  • IPs/subnets: Never show real IP addresses or CIDR ranges
  • Brands/models of network gear: Generalize (e.g. "NAS" not "Synology DS1821+")
  • Tailscale: OK to mention as the mesh VPN product, but no tailnet name or node IPs

Current King universe mapping (network page):

RoleAliasSource
Mac Mini (control plane)OverlookThe Shining
NAS (archive)DerryIT
Laptop (dev machine)FlaggThe Dark Tower
Linux serverBlaineThe Dark Tower
Router (exit node)TodashThe Dark Tower

Content Model (Convex-first)

Canonical runtime content lives in Convex

contentResources
:

  • article:<slug>
    (
    type = article
    )
  • adr:<slug>
    (
    type = adr
    )
  • discovery:<slug>
    (
    type = discovery
    )

Filesystem content under

apps/web/content/
is seed/backfill material, not runtime source.

Runtime read policy:

  • apps/web/lib/posts.ts
    → Convex-first articles (
    article:*
    )
  • apps/web/lib/adrs.ts
    → Convex-first ADRs (
    adr:*
    )
  • apps/web/lib/discoveries.ts
    → Convex-first discoveries (
    discovery:*
    )
  • Optional local escape hatches (non-production only):
    • JOELCLAW_ALLOW_FILESYSTEM_POSTS_FALLBACK=1
      (articles)
    • JOELCLAW_ALLOW_FILESYSTEM_CONTENT_FALLBACK=1
      (ADRs/discoveries)

Article fields still mirror MDX frontmatter shape:

---
title: "Post Title"
type: "article" | "essay" | "note" | "tutorial"
date: "2026-02-19T11:00:00"        # ISO datetime, NOT just date
updated: "2026-02-19T14:30:00"     # optional, bumps sort position
description: "One-liner for cards and meta"
tags: ["tag1", "tag2"]
draft: true                         # optional, hides from prod
source: "https://..."               # optional, for video-notes
channel: "Channel Name"             # optional, for video-notes
duration: "00:42:02"                # optional, for video-notes
---

Sorting: Posts sort by

updated ?? date
descending. Use full ISO datetimes (not bare dates) for deterministic ordering. Setting
updated
bumps a post to the top without changing its original publish date.

Slugs: Derived from

fields.slug
in Convex (
resourceId = article:<slug>
).

Hard Trigger Workflow

write article about X

  1. Generate slug from title/topic.
  2. Upsert
    contentResources
    with
    resourceId = article:<slug>
    ,
    type = "article"
    , full MDX body in
    fields.content
    , and
    fields.draft = true
    .
  3. Set
    fields.date
    to current ISO timestamp.
  4. Return slug + draft preview link (
    /<slug>
    if draft-visible in dev).

publish article <slug>

  1. Read
    article:<slug>
    from Convex.
  2. Patch/upsert with
    fields.draft = false
    and
    fields.updated = now
    .
  3. Revalidate all affected surfaces via
    POST /api/revalidate
    with:
    • tags:
      post:<slug>
      ,
      article:<slug>
      ,
      articles
    • paths:
      /
      ,
      /<slug>
      ,
      /<slug>.md
      ,
      /<slug>/md
      ,
      /feed.xml
      ,
      /sitemap.md
  4. Verify
    /
    ,
    /<slug>
    ,
    /<slug>.md
    , and
    /feed.xml
    include the published post.

Media Embeds

Always embed YouTube videos in

/cool
discoveries and articles when they add context. Use the
<YouTube id="VIDEO_ID" />
MDX component (available in both
.mdx
articles and
.md
discoveries). Extract the video ID from the URL (
youtube.com/watch?v=VIDEO_ID
). Place embeds near the top of the relevant section, before the prose discussion.

Writing Voice

Use the canonical

joel-writing-style
skill for prose. Key traits: direct, first-person when the claim is actually Joel's, strategic profanity, short paragraphs, bold emphasis, conversational but technical. Never corporate-speak and never fabricate Joel's beliefs or philosophy.

Design System

  • Theme: Dark (
    bg-[#0a0a0a]
    ), neutral grays,
    --color-claw: #ff1493
    (hot pink accent)
  • Fonts: Geist Sans (body), Geist Mono (code/data), Dank Mono (code blocks with ligatures)
  • Content width:
    max-w-2xl
    (672px) — intentionally narrow for reading
  • Header: Single row — claw icon + "JoelClaw" left, nav links + search right. No tagline in header.
  • Nav items: Writing (
    /
    ), Cool (
    /cool
    ), ADRs (
    /adrs
    ), Network (
    /network
    )
  • Active nav: White text vs neutral-500 for inactive, detected via
    usePathname()
  • Search: ⌘K dialog using pagefind, type-based icons/badges
  • Mobile: Full-screen overlay nav via
    MobileNav
    component
  • Code blocks: Catppuccin Macchiato theme, rehype-pretty-code
  • Sidenotes: Tufte-style CSS sidenotes (pure CSS, no JS)

Key Files

FilePurpose
app/layout.tsx
Root layout, fonts, metadata, footer
app/page.tsx
Home page (post list)
app/[slug]/page.tsx
Post detail pages
app/adrs/page.tsx
ADR list
app/adrs/[slug]/page.tsx
ADR detail (strips H1 to avoid duplicate title)
app/cool/page.tsx
Cool/discoveries list
app/network/page.tsx
Infrastructure status page
components/site-header.tsx
Header with active nav (client component)
components/mobile-nav.tsx
Mobile overlay nav
components/search-dialog.tsx
⌘K search
lib/posts.ts
Article loading from Convex (
article:*
)
lib/adrs.ts
ADR loading from Convex (
adr:*
)
lib/discoveries.ts
Discovery loading from Convex (
discovery:*
)
lib/constants.ts
Site name, URL, tagline
lib/claw.ts
SVG path for claw icon

ADR Display Rules

  • ADR runtime source is Convex (
    contentResources
    with
    resourceId = adr:<slug>
    )
  • Vault sync still updates repo snapshots under
    apps/web/content/adrs/
  • Project snapshots into Convex with:
    bun scripts/seed-adrs-discoveries.ts
  • The detail page (
    app/adrs/[slug]/page.tsx
    ) strips the H1 from markdown content because the page already renders the title with ADR number prefix
  • Regex:
    content.replace(/^#\s+(?:ADR-\d+:\s*)?.*$/m, "").trim()

Adding a New Post

  1. Draft in Convex (
    contentResources.upsert
    ,
    resourceId = article:<slug>
    ,
    draft: true
    ).
  2. Use ISO datetime in
    date
    field.
  3. Add images to
    apps/web/public/images/<slug>/
    if needed and reference
    /images/<slug>/...
    in MDX.
  4. Publish by setting
    draft: false
    , then revalidate tags + paths (
    post:<slug>
    ,
    article:<slug>
    ,
    articles
    ,
    /
    ,
    /<slug>
    ,
    /<slug>.md
    ,
    /<slug>/md
    ,
    /feed.xml
    ,
    /sitemap.md
    ).
  5. Verify route + markdown twin + homepage + feed consistency.

Backfill scripts:

  • scripts/seed-articles.ts
    for article resources
  • scripts/seed-adrs-discoveries.ts
    for ADR + discovery resources

Network Page

The network page (

app/network/page.tsx
) shows real infrastructure with aliased names. When updating:

  1. Check actual system state (
    kubectl get pods
    ,
    tailscale status
    ,
    launchctl print
    , etc.)
  2. Apply OPSEC rules — alias all hostnames, strip ports/IPs/usernames
  3. Keep data arrays at top of file for easy updates
  4. Status dots: green (Online) with ping animation, yellow (Idle), gray (Offline)