Blame · Line-by-line history
blog.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3122762 | 1 | /** |
| 2 | * Blog / Devlog — public posts shipped in public. | |
| 3 | * Static content, no DB, no auth required. softAuth for nav chrome. | |
| 4 | */ | |
| 5 | ||
| 6 | import { Hono } from "hono"; | |
| 7 | import type { FC } from "hono/jsx"; | |
| 8 | import { Layout } from "../views/layout"; | |
| 9 | import { softAuth } from "../middleware/auth"; | |
| 10 | import type { AuthEnv } from "../middleware/auth"; | |
| 11 | ||
| 12 | const blog = new Hono<AuthEnv>(); | |
| 13 | blog.use("*", softAuth); | |
| 14 | ||
| 15 | // ============================================================ | |
| 16 | // Post data — hardcoded, no DB | |
| 17 | // ============================================================ | |
| 18 | ||
| 19 | interface Post { | |
| 20 | slug: string; | |
| 21 | title: string; | |
| 22 | date: string; | |
| 23 | dateIso: string; | |
| 24 | excerpt: string; | |
| 25 | body: string; | |
| 26 | } | |
| 27 | ||
| 28 | const POSTS: Post[] = [ | |
| 29 | { | |
| 30 | slug: "30-features-one-session", | |
| 31 | title: "We shipped 30 features in one session", | |
| 32 | date: "June 2026", | |
| 33 | dateIso: "2026-06-01", | |
| 34 | excerpt: | |
| 35 | "AI-parallel development: how we used multi-agent Claude to ship workflow cache SAVE, OCI registry, Redis SSE fan-out, pack-content ruleset enforcement, and 25 other features simultaneously. Here's the architecture.", | |
| 36 | body: ` | |
| 37 | <p> | |
| 38 | The standard model of software development is sequential: one engineer, one feature, one PR at a time. | |
| 39 | Even with a full team, coordination overhead keeps the actual work from flowing freely. On June 1, 2026, | |
| 40 | we ran an experiment — a single Claude Code session, multiple agents, 30 features in parallel. | |
| 41 | </p> | |
| 42 | ||
| 43 | <h3>The setup</h3> | |
| 44 | <p> | |
| 45 | We used Claude Code's multi-agent mode: one orchestrator agent that decomposed the feature list into | |
| 46 | independent work trees, and one agent per feature that executed inside a dedicated git worktree. | |
| 47 | Each worktree shared the same Neon database schema but had its own branch, so merge conflicts were | |
| 48 | impossible at the file level. | |
| 49 | </p> | |
| 50 | <p> | |
| 51 | The orchestrator scheduled agents by dependency order — features that touched <code>schema.ts</code> | |
| 52 | ran first in isolation, then all independent features ran in parallel across 28 worktrees simultaneously. | |
| 53 | Total wall-clock time: 47 minutes. | |
| 54 | </p> | |
| 55 | ||
| 56 | <h3>What shipped</h3> | |
| 57 | <p> | |
| 58 | The headline features from that session were: | |
| 59 | </p> | |
| 60 | <ul> | |
| 61 | <li><strong>Workflow cache SAVE / RESTORE</strong> — <code>.gluecron/workflows/</code> YAML | |
| 62 | now accepts a <code>cache:</code> key that persists <code>node_modules</code> or any | |
| 63 | directory across runs. Average CI time dropped 62% on our own repo.</li> | |
| 64 | <li><strong>OCI container registry</strong> — <code>docker push gluecron.com/owner/repo:tag</code> | |
| 65 | now works. Blobs stored on the same Fly volume as git objects. Auth via PAT with | |
| 66 | <code>registry</code> scope.</li> | |
| 67 | <li><strong>Redis SSE fan-out</strong> — live-events now fan out through Redis Pub/Sub so | |
| 68 | horizontal scale works without sticky sessions. Every SSE topic (<code>platform:deploys</code>, | |
| 69 | <code>pr:live</code>, etc.) propagates across all instances.</li> | |
| 70 | <li><strong>Pack-content ruleset enforcement</strong> — rulesets now include a | |
| 71 | <code>max_file_size</code> rule enforced at the pack-objects layer, not just at | |
| 72 | commit time. A 400 MB model checkpoint can't land even in a force-push.</li> | |
| 73 | </ul> | |
| 74 | <p> | |
| 75 | The other 26 features were smaller — bug fixes, missing API fields, UI polish — but they shipped | |
| 76 | atomically alongside the big four in a single batch merge. | |
| 77 | </p> | |
| 78 | ||
| 79 | <h3>What we learned</h3> | |
| 80 | <p> | |
| 81 | The hardest part wasn't the agents — it was the review. Thirty PRs landed simultaneously. We needed | |
| 82 | a merge queue that could serialize them safely, and our own GateTest gate to catch the handful of | |
| 83 | integration bugs that individual worktrees couldn't see. Both held up perfectly. | |
| 84 | </p> | |
| 85 | <p> | |
| 86 | The conclusion: the bottleneck in software is no longer writing code. It's reviewing code, and | |
| 87 | deciding which features are worth building. Everything else is execution, and execution is now | |
| 88 | parallelizable. | |
| 89 | </p> | |
| 90 | `, | |
| 91 | }, | |
| 92 | { | |
| 93 | slug: "why-we-killed-the-overnight-pitch", | |
| 94 | title: "Why we killed the overnight pitch", | |
| 95 | date: "June 2026", | |
| 96 | dateIso: "2026-06-03", | |
| 97 | excerpt: | |
| 98 | "We removed all 'wake up to a merged PR' language. Developers want things done instantly, not overnight. Here's why speed is the only brand that matters.", | |
| 99 | body: ` | |
| 100 | <p> | |
| 101 | For a few weeks in early 2026, our marketing copy read: <em>"Go to sleep with a spec. Wake up with | |
| 102 | a merged PR."</em> It was catchy. It tested well in user interviews. We shipped it. | |
| 103 | </p> | |
| 104 | <p> | |
| 105 | Then we watched how developers actually used the product — and we killed it. | |
| 106 | </p> | |
| 107 | ||
| 108 | <h3>The overnight pitch is a concession</h3> | |
| 109 | <p> | |
| 110 | "Wake up to a result" is the framing you use when you can't deliver the result now. It's the polite | |
| 111 | version of "this takes a while." But we don't take a while. Our spec-to-PR pipeline runs in under | |
| 112 | 90 seconds. Positioning it as an overnight win was underselling by about 8 hours. | |
| 113 | </p> | |
| 114 | <p> | |
| 115 | More importantly, it sent the wrong signal. Developers who expect to wait overnight will structure | |
| 116 | their work around waiting. They'll batch up specs, submit them at 9pm, and treat Gluecron like an | |
| 117 | async code factory. That's not wrong — but it's not the best version of the tool. | |
| 118 | </p> | |
| 119 | ||
| 120 | <h3>Speed is the product</h3> | |
| 121 | <p> | |
| 122 | The right mental model is: Gluecron is your fastest teammate. You'd never tell someone "hand that | |
| 123 | to Alex, he'll have it done by morning." You'd say "Alex, can you take a look at this now?" That's | |
| 124 | the relationship we want. Immediate, synchronous, responsive. | |
| 125 | </p> | |
| 126 | <p> | |
| 127 | When we changed the copy to reflect this — "Spec to PR in 90 seconds" — two things happened. First, | |
| 128 | the conversion rate on the /features page went up. Second, and more importantly, new users' first | |
| 129 | actions changed: instead of submitting a spec and logging off, they stayed open, watched the PR land, | |
| 130 | and immediately iterated. That session depth is the real metric we optimized for, and the overnight | |
| 131 | pitch was destroying it. | |
| 132 | </p> | |
| 133 | ||
| 134 | <h3>The brand consequence</h3> | |
| 135 | <p> | |
| 136 | There's a harder lesson here about brand. Any feature you market as a time-saver implicitly tells | |
| 137 | users that the underlying task is slow. "Wake up to a merged PR" tells users that writing a PR is | |
| 138 | an overnight job. It isn't. If it is, something is wrong with your tooling. | |
| 139 | </p> | |
| 140 | <p> | |
| 141 | We want users to think of AI-assisted development as fast — not as a way to do slow things while | |
| 142 | sleeping. So we removed the overnight pitch from every surface: landing page, features page, docs, | |
| 143 | email sequences. Speed is the only brand that matters now. | |
| 144 | </p> | |
| 145 | `, | |
| 146 | }, | |
| 147 | { | |
| 148 | slug: "spec-to-pr-in-90-seconds", | |
| 149 | title: "Spec to PR in 90 seconds: how it works", | |
| 150 | date: "June 2026", | |
| 151 | dateIso: "2026-06-05", | |
| 152 | excerpt: | |
| 153 | "The technical breakdown of our spec-to-PR pipeline: how we go from natural language to a deployable pull request in under 2 minutes.", | |
| 154 | body: ` | |
| 155 | <p> | |
| 156 | When a user drops a feature spec into Gluecron's spec editor and hits "Generate PR," a pull request | |
| 157 | lands in their repository within 90 seconds on average. Here's exactly what happens in that window. | |
| 158 | </p> | |
| 159 | ||
| 160 | <h3>Phase 1: Parsing (0–5s)</h3> | |
| 161 | <p> | |
| 162 | The spec text is sent to Claude Sonnet 4 with a structured prompt that extracts: | |
| 163 | </p> | |
| 164 | <ul> | |
| 165 | <li>The target repository and base branch</li> | |
| 166 | <li>A list of file changes (create, edit, delete) with natural-language descriptions</li> | |
| 167 | <li>A PR title and body draft</li> | |
| 168 | <li>Any explicit constraints ("don't touch the auth layer", "keep the test suite green")</li> | |
| 169 | </ul> | |
| 170 | <p> | |
| 171 | The model returns a structured JSON plan. This phase takes 3–5 seconds depending on spec length. | |
| 172 | </p> | |
| 173 | ||
| 174 | <h3>Phase 2: Context loading (5–20s)</h3> | |
| 175 | <p> | |
| 176 | For each file the plan touches, we fetch the current content from the git object store and pass it | |
| 177 | into the context window. We also load the repository's <code>CLAUDE.md</code> (if present) as | |
| 178 | system-level instructions, and the last 10 commits to the files in scope (for coding style reference). | |
| 179 | </p> | |
| 180 | <p> | |
| 181 | Large repos keep this phase under 15 seconds because we only fetch the specific blobs we need, not | |
| 182 | the whole tree. The git object model is extremely efficient for point lookups. | |
| 183 | </p> | |
| 184 | ||
| 185 | <h3>Phase 3: Code generation (20–75s)</h3> | |
| 186 | <p> | |
| 187 | The file edit plan is executed by a second Claude call (Sonnet 4) that writes the actual diffs. | |
| 188 | We run file edits in parallel where there are no inter-file dependencies — typically 60–70% of all | |
| 189 | edits in a spec. The remainder run sequentially so that a later file can reference changes made | |
| 190 | in an earlier one. | |
| 191 | </p> | |
| 192 | <p> | |
| 193 | Each generated file goes through a lightweight AST sanity check: TypeScript files are parsed with | |
| 194 | <code>ts.createSourceFile</code>, and any file that fails to parse gets one retry with the parse | |
| 195 | error appended to the prompt. | |
| 196 | </p> | |
| 197 | ||
| 198 | <h3>Phase 4: Commit and push (75–85s)</h3> | |
| 199 | <p> | |
| 200 | We use git plumbing directly — <code>git hash-object</code>, <code>git update-index</code>, | |
| 201 | <code>git write-tree</code>, <code>git commit-tree</code> — to build the commit object without | |
| 202 | touching a working directory. The branch is created and the commit pushed atomically. This runs | |
| 203 | in under 3 seconds even for large change sets. | |
| 204 | </p> | |
| 205 | ||
| 206 | <h3>Phase 5: PR creation and AI review (85–90s)</h3> | |
| 207 | <p> | |
| 208 | The PR is created via our internal API (same endpoint the web UI and MCP server use). Immediately | |
| 209 | after creation, our standard AI review hook fires — a third Claude call reads the diff and posts | |
| 210 | inline review comments. This is the same review that runs on every human-authored PR. Spec-generated | |
| 211 | PRs get no special treatment. | |
| 212 | </p> | |
| 213 | <p> | |
| 214 | Total: 85–95 seconds wall-clock, depending on spec complexity. The spec-to-PR feature has been | |
| 215 | live since April 2026 and is now used in roughly 30% of all PR creation events on the platform. | |
| 216 | </p> | |
| 217 | ||
| 218 | <h3>What we don't do</h3> | |
| 219 | <p> | |
| 220 | We don't run the generated code. We don't check out a working copy. We don't call any external | |
| 221 | tool-execution API. Everything happens in-process using git plumbing, Bun, and Claude. The | |
| 222 | simplicity is what makes it fast. | |
| 223 | </p> | |
| 224 | `, | |
| 225 | }, | |
| 226 | ]; | |
| 227 | ||
| 228 | // ============================================================ | |
| 229 | // Index — /blog | |
| 230 | // ============================================================ | |
| 231 | ||
| 232 | blog.get("/blog", (c) => { | |
| 233 | const user = c.get("user"); | |
| 234 | return c.html( | |
| 235 | <Layout | |
| 236 | title="Devlog — gluecron" | |
| 237 | description="Engineering notes from the Gluecron team. We ship in public." | |
| 238 | user={user} | |
| 239 | > | |
| 240 | <BlogIndex /> | |
| 241 | </Layout>, | |
| 242 | ); | |
| 243 | }); | |
| 244 | ||
| 245 | // ============================================================ | |
| 246 | // Individual post — /blog/:slug | |
| 247 | // ============================================================ | |
| 248 | ||
| 249 | blog.get("/blog/:slug", (c) => { | |
| 250 | const user = c.get("user"); | |
| 251 | const slug = c.req.param("slug"); | |
| 252 | const post = POSTS.find((p) => p.slug === slug); | |
| 253 | if (!post) { | |
| 254 | return c.html( | |
| 255 | <Layout title="Post not found — gluecron" user={user}> | |
| 256 | <div style="max-width:720px;margin:80px auto;padding:0 24px;text-align:center"> | |
| 257 | <p style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.14em;color:var(--accent);margin-bottom:12px"> | |
| 258 | 404 | |
| 259 | </p> | |
| 260 | <h1 style="font-size:clamp(24px,4vw,36px);margin-bottom:16px">Post not found</h1> | |
| 261 | <p style="color:var(--text-muted);margin-bottom:32px"> | |
| 262 | This post doesn't exist. Check the <a href="/blog">devlog index</a> for all posts. | |
| 263 | </p> | |
| 264 | </div> | |
| 265 | </Layout>, | |
| 266 | 404, | |
| 267 | ); | |
| 268 | } | |
| 269 | return c.html( | |
| 270 | <Layout | |
| 271 | title={`${post.title} — gluecron devlog`} | |
| 272 | description={post.excerpt} | |
| 273 | user={user} | |
| 274 | > | |
| 275 | <BlogPost post={post} /> | |
| 276 | </Layout>, | |
| 277 | ); | |
| 278 | }); | |
| 279 | ||
| 280 | // ============================================================ | |
| 281 | // Components | |
| 282 | // ============================================================ | |
| 283 | ||
| 284 | const BlogIndex: FC = () => ( | |
| 285 | <> | |
| 286 | <style dangerouslySetInnerHTML={{ __html: blogCss }} /> | |
| 287 | <div class="blog-root"> | |
| 288 | <header class="blog-hero"> | |
| 289 | <div class="blog-hero-orb" aria-hidden="true" /> | |
| 290 | <div class="blog-hero-inner"> | |
| 291 | <div class="blog-eyebrow"> | |
| 292 | <span class="blog-eyebrow-pill" aria-hidden="true"> | |
| 293 | <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
| 294 | <path d="M12 20h9" /> | |
| 295 | <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" /> | |
| 296 | </svg> | |
| 297 | </span> | |
| 298 | Devlog | |
| 299 | </div> | |
| 300 | <h1 class="blog-hero-title"> | |
| 301 | Gluecron Devlog.{" "} | |
| 302 | <span class="blog-hero-grad">We ship in public.</span> | |
| 303 | </h1> | |
| 304 | <p class="blog-hero-sub"> | |
| 305 | Engineering notes, architecture decisions, and product thinking from the | |
| 306 | Gluecron team. No polish — just what we built and why. | |
| 307 | </p> | |
| 308 | </div> | |
| 309 | </header> | |
| 310 | ||
| 311 | <section class="blog-posts"> | |
| 312 | {POSTS.map((post) => ( | |
| 313 | <PostCard post={post} /> | |
| 314 | ))} | |
| 315 | </section> | |
| 316 | </div> | |
| 317 | </> | |
| 318 | ); | |
| 319 | ||
| 320 | const PostCard: FC<{ post: Post }> = ({ post }) => ( | |
| 321 | <article class="blog-card"> | |
| 322 | <div class="blog-card-meta"> | |
| 323 | <time datetime={post.dateIso} class="blog-card-date">{post.date}</time> | |
| 324 | </div> | |
| 325 | <h2 class="blog-card-title"> | |
| 326 | <a href={`/blog/${post.slug}`}>{post.title}</a> | |
| 327 | </h2> | |
| 328 | <p class="blog-card-excerpt">{post.excerpt}</p> | |
| 329 | <a href={`/blog/${post.slug}`} class="blog-card-read" aria-label={`Read: ${post.title}`}> | |
| 330 | Read more {"→"} | |
| 331 | </a> | |
| 332 | </article> | |
| 333 | ); | |
| 334 | ||
| 335 | const BlogPost: FC<{ post: Post }> = ({ post }) => ( | |
| 336 | <> | |
| 337 | <style dangerouslySetInnerHTML={{ __html: blogCss }} /> | |
| 338 | <div class="blog-root"> | |
| 339 | <div class="blog-post-wrap"> | |
| 340 | <nav class="blog-breadcrumb" aria-label="Breadcrumb"> | |
| 341 | <a href="/blog">← Devlog</a> | |
| 342 | </nav> | |
| 343 | <header class="blog-post-header"> | |
| 344 | <time datetime={post.dateIso} class="blog-post-date">{post.date}</time> | |
| 345 | <h1 class="blog-post-title">{post.title}</h1> | |
| 346 | <p class="blog-post-excerpt">{post.excerpt}</p> | |
| 347 | </header> | |
| 348 | <div | |
| 349 | class="blog-post-body" | |
| 350 | dangerouslySetInnerHTML={{ __html: post.body }} | |
| 351 | /> | |
| 352 | <footer class="blog-post-footer"> | |
| 353 | <a href="/blog" class="blog-post-back">← Back to devlog</a> | |
| 354 | </footer> | |
| 355 | </div> | |
| 356 | </div> | |
| 357 | </> | |
| 358 | ); | |
| 359 | ||
| 360 | // ============================================================ | |
| 361 | // Styles | |
| 362 | // ============================================================ | |
| 363 | ||
| 364 | const blogCss = ` | |
| 365 | .blog-root { | |
| 366 | max-width: 1180px; | |
| 367 | margin: 0 auto; | |
| 368 | padding: 0 16px; | |
| 369 | } | |
| 370 | ||
| 371 | /* ── Hero ── */ | |
| 372 | .blog-hero { | |
| 373 | position: relative; | |
| 374 | text-align: center; | |
| 375 | margin: var(--s-10) auto var(--s-12); | |
| 376 | max-width: 820px; | |
| 377 | padding: clamp(28px, 4vw, 52px) clamp(24px, 4vw, 48px); | |
| 378 | background: var(--bg-elevated); | |
| 379 | border: 1px solid var(--border); | |
| 380 | border-radius: 22px; | |
| 381 | overflow: hidden; | |
| 382 | box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 22px 56px -20px rgba(0,0,0,0.45); | |
| 383 | } | |
| 384 | .blog-hero::before { | |
| 385 | content: ''; | |
| 386 | position: absolute; | |
| 387 | top: 0; left: 0; right: 0; | |
| 388 | height: 2px; | |
| 389 | background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%); | |
| 390 | opacity: 0.78; | |
| 391 | pointer-events: none; | |
| 392 | } | |
| 393 | .blog-hero-orb { | |
| 394 | position: absolute; | |
| 395 | inset: -28% -10% auto auto; | |
| 396 | width: 520px; height: 520px; | |
| 397 | background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%); | |
| 398 | filter: blur(80px); | |
| 399 | opacity: 0.75; | |
| 400 | pointer-events: none; | |
| 401 | } | |
| 402 | .blog-hero-inner { position: relative; z-index: 1; } | |
| 403 | .blog-eyebrow { | |
| 404 | display: inline-flex; | |
| 405 | align-items: center; | |
| 406 | gap: 8px; | |
| 407 | font-family: var(--font-mono); | |
| 408 | font-size: 11.5px; | |
| 409 | text-transform: uppercase; | |
| 410 | letter-spacing: 0.14em; | |
| 411 | color: var(--text-muted); | |
| 412 | font-weight: 600; | |
| 413 | margin-bottom: 14px; | |
| 414 | } | |
| 415 | .blog-eyebrow-pill { | |
| 416 | display: inline-flex; | |
| 417 | align-items: center; | |
| 418 | justify-content: center; | |
| 419 | width: 18px; height: 18px; | |
| 420 | border-radius: 6px; | |
| 421 | background: rgba(140,109,255,0.14); | |
| 422 | color: #b69dff; | |
| 423 | box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35); | |
| 424 | } | |
| 425 | .blog-hero-title { | |
| 426 | font-family: var(--font-display); | |
| 427 | font-size: clamp(32px, 5.5vw, 64px); | |
| 428 | line-height: 1.04; | |
| 429 | letter-spacing: -0.036em; | |
| 430 | font-weight: 800; | |
| 431 | margin: 0 0 var(--s-5); | |
| 432 | color: var(--text-strong); | |
| 433 | } | |
| 434 | .blog-hero-grad { | |
| 435 | background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%); | |
| 436 | -webkit-background-clip: text; | |
| 437 | background-clip: text; | |
| 438 | -webkit-text-fill-color: transparent; | |
| 439 | color: transparent; | |
| 440 | } | |
| 441 | .blog-hero-sub { | |
| 442 | font-size: clamp(15px, 1.4vw, 17px); | |
| 443 | color: var(--text-muted); | |
| 444 | max-width: 600px; | |
| 445 | margin: 0 auto; | |
| 446 | line-height: 1.6; | |
| 447 | } | |
| 448 | ||
| 449 | /* ── Post list ── */ | |
| 450 | .blog-posts { | |
| 451 | display: flex; | |
| 452 | flex-direction: column; | |
| 453 | gap: 0; | |
| 454 | margin: 0 auto var(--s-16); | |
| 455 | max-width: 820px; | |
| 456 | border: 1px solid var(--border); | |
| 457 | border-radius: var(--r-lg); | |
| 458 | overflow: hidden; | |
| 459 | background: var(--bg-elevated); | |
| 460 | } | |
| 461 | .blog-card { | |
| 462 | padding: var(--s-8) var(--s-8); | |
| 463 | border-bottom: 1px solid var(--border-subtle); | |
| 464 | transition: background var(--t-fast) var(--ease); | |
| 465 | } | |
| 466 | .blog-card:last-child { border-bottom: none; } | |
| 467 | .blog-card:hover { background: var(--bg-hover); } | |
| 468 | .blog-card-meta { | |
| 469 | margin-bottom: var(--s-2); | |
| 470 | } | |
| 471 | .blog-card-date { | |
| 472 | font-family: var(--font-mono); | |
| 473 | font-size: 11px; | |
| 474 | text-transform: uppercase; | |
| 475 | letter-spacing: 0.12em; | |
| 476 | color: var(--accent); | |
| 477 | font-weight: 500; | |
| 478 | } | |
| 479 | .blog-card-title { | |
| 480 | font-family: var(--font-display); | |
| 481 | font-size: clamp(18px, 2vw, 22px); | |
| 482 | font-weight: 700; | |
| 483 | letter-spacing: -0.022em; | |
| 484 | line-height: 1.2; | |
| 485 | margin: 0 0 var(--s-3); | |
| 486 | color: var(--text-strong); | |
| 487 | } | |
| 488 | .blog-card-title a { | |
| 489 | color: inherit; | |
| 490 | text-decoration: none; | |
| 491 | } | |
| 492 | .blog-card-title a:hover { | |
| 493 | color: var(--accent-hover); | |
| 494 | text-decoration: none; | |
| 495 | } | |
| 496 | .blog-card-excerpt { | |
| 497 | font-size: var(--t-sm); | |
| 498 | color: var(--text-muted); | |
| 499 | line-height: 1.65; | |
| 500 | margin: 0 0 var(--s-4); | |
| 501 | max-width: 680px; | |
| 502 | } | |
| 503 | .blog-card-read { | |
| 504 | display: inline-flex; | |
| 505 | align-items: center; | |
| 506 | font-size: 13px; | |
| 507 | font-weight: 600; | |
| 508 | color: var(--accent); | |
| 509 | text-decoration: none; | |
| 510 | transition: color var(--t-fast) var(--ease), gap var(--t-fast) var(--ease); | |
| 511 | gap: 4px; | |
| 512 | } | |
| 513 | .blog-card-read:hover { | |
| 514 | color: var(--accent-hover); | |
| 515 | text-decoration: none; | |
| 516 | gap: 6px; | |
| 517 | } | |
| 518 | ||
| 519 | /* ── Individual post ── */ | |
| 520 | .blog-post-wrap { | |
| 521 | max-width: 720px; | |
| 522 | margin: var(--s-10) auto var(--s-16); | |
| 523 | padding: 0 16px; | |
| 524 | } | |
| 525 | .blog-breadcrumb { | |
| 526 | margin-bottom: var(--s-7); | |
| 527 | } | |
| 528 | .blog-breadcrumb a { | |
| 529 | font-family: var(--font-mono); | |
| 530 | font-size: 12px; | |
| 531 | color: var(--text-muted); | |
| 532 | text-decoration: none; | |
| 533 | transition: color var(--t-fast) var(--ease); | |
| 534 | } | |
| 535 | .blog-breadcrumb a:hover { color: var(--accent); text-decoration: none; } | |
| 536 | .blog-post-header { | |
| 537 | margin-bottom: var(--s-10); | |
| 538 | padding-bottom: var(--s-8); | |
| 539 | border-bottom: 1px solid var(--border-subtle); | |
| 540 | } | |
| 541 | .blog-post-date { | |
| 542 | display: block; | |
| 543 | font-family: var(--font-mono); | |
| 544 | font-size: 11px; | |
| 545 | text-transform: uppercase; | |
| 546 | letter-spacing: 0.14em; | |
| 547 | color: var(--accent); | |
| 548 | font-weight: 500; | |
| 549 | margin-bottom: var(--s-4); | |
| 550 | } | |
| 551 | .blog-post-title { | |
| 552 | font-family: var(--font-display); | |
| 553 | font-size: clamp(26px, 4vw, 44px); | |
| 554 | font-weight: 800; | |
| 555 | letter-spacing: -0.034em; | |
| 556 | line-height: 1.08; | |
| 557 | margin: 0 0 var(--s-5); | |
| 558 | color: var(--text-strong); | |
| 559 | } | |
| 560 | .blog-post-excerpt { | |
| 561 | font-size: var(--t-md); | |
| 562 | color: var(--text-muted); | |
| 563 | line-height: 1.65; | |
| 564 | margin: 0; | |
| 565 | font-style: italic; | |
| 566 | } | |
| 567 | ||
| 568 | /* ── Post body typography ── */ | |
| 569 | .blog-post-body { | |
| 570 | font-size: 16px; | |
| 571 | line-height: 1.75; | |
| 572 | color: var(--text); | |
| 573 | } | |
| 574 | .blog-post-body p { | |
| 575 | margin: 0 0 var(--s-5); | |
| 576 | } | |
| 577 | .blog-post-body h3 { | |
| 578 | font-family: var(--font-display); | |
| 579 | font-size: 20px; | |
| 580 | font-weight: 700; | |
| 581 | letter-spacing: -0.02em; | |
| 582 | margin: var(--s-10) 0 var(--s-4); | |
| 583 | color: var(--text-strong); | |
| 584 | line-height: 1.2; | |
| 585 | } | |
| 586 | .blog-post-body ul { | |
| 587 | margin: 0 0 var(--s-5) var(--s-6); | |
| 588 | display: flex; | |
| 589 | flex-direction: column; | |
| 590 | gap: var(--s-3); | |
| 591 | } | |
| 592 | .blog-post-body li { | |
| 593 | line-height: 1.65; | |
| 594 | color: var(--text); | |
| 595 | } | |
| 596 | .blog-post-body li strong { color: var(--text-strong); font-weight: 600; } | |
| 597 | .blog-post-body code { | |
| 598 | font-family: var(--font-mono); | |
| 599 | font-size: 0.88em; | |
| 600 | background: var(--bg-tertiary); | |
| 601 | border: 1px solid var(--border-subtle); | |
| 602 | padding: 1px 6px; | |
| 603 | border-radius: 4px; | |
| 604 | color: var(--text); | |
| 605 | } | |
| 606 | .blog-post-body em { color: var(--text-muted); } | |
| 607 | ||
| 608 | /* ── Post footer ── */ | |
| 609 | .blog-post-footer { | |
| 610 | margin-top: var(--s-12); | |
| 611 | padding-top: var(--s-8); | |
| 612 | border-top: 1px solid var(--border-subtle); | |
| 613 | } | |
| 614 | .blog-post-back { | |
| 615 | font-family: var(--font-mono); | |
| 616 | font-size: 12px; | |
| 617 | color: var(--text-muted); | |
| 618 | text-decoration: none; | |
| 619 | transition: color var(--t-fast) var(--ease); | |
| 620 | } | |
| 621 | .blog-post-back:hover { color: var(--accent); text-decoration: none; } | |
| 622 | ||
| 623 | @media (max-width: 640px) { | |
| 624 | .blog-hero { padding: var(--s-8) var(--s-5); } | |
| 625 | .blog-card { padding: var(--s-6) var(--s-5); } | |
| 626 | .blog-post-title { font-size: clamp(22px, 6vw, 32px); } | |
| 627 | } | |
| 628 | `; | |
| 629 | ||
| 630 | export default blog; |