feat: add audio playback and enhanced note UI with sidebar navigation
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

- Add AudioSection component with media player and waveform visualization
- Add NoteContent component with inline link support and syntax highlighting
- Add NoteSidebar with collapsible sections for navigation
- Add SidebarContext for managing sidebar state
- Update note page layout to use new component architecture
- Add assets utility for GCS audio/video URL generation
- Update content library with audio/skill/prompt type support
- Add vision.md with editorial guidelines
- Update .gitignore with additional security patterns
- Add upload-asset.sh script for GCS asset management

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-08 11:08:25 -07:00
parent a65c3f7243
commit ec0b89206f
23 changed files with 1014 additions and 229 deletions

18
.gitignore vendored
View File

@ -5,10 +5,24 @@ node_modules/
.next/
out/
dist/
*.exe
*.dmg
vendor/
__pycache__/
# Environment
.env*
!.env.example
.envault/
*.pem
*.key
*.p12
*.pfx
credentials.json
service-account*.json
*_secret*
*_token*
*_password*
# IDE
.vscode/
@ -20,3 +34,7 @@ dist/
# Logs
*.log
# Large assets (stored in GCS)
blog/public/audio/
blog/public/video/

View File

@ -16,8 +16,25 @@ cd blog && npm run dev
# Open http://localhost:19197
```
## Deploy
"Deploy" = sync assets to GCS + commit + push. Cloud Build auto-deploys on push.
```bash
# Sync any new audio/video to GCS
gcloud storage cp blog/public/audio/notes/{slug}/* gs://orchard9-assets/research-notes/audio/notes/{slug}/
# Then commit and push
```
## AI Routing
### Editorial voice
→ [`blog/vision.md`](blog/vision.md) — raw notes, direct tone, no fluff
Writing feedback → understand it → update CLAUDE.md
**Style:** flat over nested, show don't hide, inline over accordion, direct lowercase casual
### For blog development
`blog/CLAUDE.md`

View File

@ -2,6 +2,10 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Asset URL for GCS storage
ARG NEXT_PUBLIC_ASSET_BASE_URL=https://storage.googleapis.com/orchard9-assets/research-notes
ENV NEXT_PUBLIC_ASSET_BASE_URL=$NEXT_PUBLIC_ASSET_BASE_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate

View File

@ -71,6 +71,16 @@ content/
| `src/components/white-paper/OutlineSection.tsx` | Outline sections |
| `src/components/copyable.tsx` | CopyButton, CopyableBlock, ExpandableFile |
## Inline Links
Reference sidebar items in content.md:
- `[[prompt:id]]` — link to a prompt by its id
- `[[file:name.md]]` — link to a file by its name
- `[[skill:name]]` — link to a skill by its name
- `[[audio:file.m4a]]` — link to an audio file by its name
Click scrolls to item + highlights for 1.5s.
## Note meta.yaml Schema
```yaml
@ -90,6 +100,12 @@ filesCreated:
- name: filename.md
description: What this file is
audioFiles: # optional
- name: filename.m4a
title: "Display title"
description: "What this covers"
source: NotebookLM # optional badge
navigation:
prev: null # or { slug, id, title }
next:

View File

@ -339,6 +339,12 @@ skillsUsed:
- ANNOUNCE wave plan before executing
- MAX 3 fix cycles per task, then escalate
audioFiles:
- name: Maxwell_Verifies_AI_With_Thermodynamic_Fingerprints.m4a
title: "Research Overview"
description: "NotebookLM synthesis of 10 research directives"
source: NotebookLM
filesCreated:
- name: firecracker-latency-benchmarks.md
description: Benchmarking pause/resume latency for thermal emergencies

168
blog/package-lock.json generated
View File

@ -17,6 +17,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
},
@ -3045,6 +3046,18 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@ -4114,6 +4127,64 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-raw": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-from-parse5": "^8.0.0",
"hast-util-to-parse5": "^8.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"parse5": "^7.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@ -4141,6 +4212,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@ -4154,6 +4244,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@ -4181,6 +4288,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -6490,6 +6607,18 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -6737,6 +6866,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rehype-raw": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@ -7912,6 +8056,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@ -7926,6 +8084,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -18,6 +18,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
},

View File

@ -135,11 +135,18 @@
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: "kern" 1, "liga" 1;
font-feature-settings:
"kern" 1,
"liga" 1;
}
/* Research paper typography */
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-serif);
@apply tracking-tight;
}
@ -150,11 +157,11 @@
/* Lists */
ol {
@apply list-decimal list-inside my-4 space-y-2 pl-4;
@apply list-decimal list-outside my-4 space-y-2 pl-6;
}
ul {
@apply list-disc list-inside my-4 space-y-2 pl-4;
@apply list-disc list-outside my-4 space-y-2 pl-6;
}
li {
@ -166,3 +173,18 @@
font-size: 17px;
}
}
/* Sidebar highlight animation */
@keyframes sidebar-highlight {
0%,
100% {
background-color: transparent;
}
25% {
background-color: hsl(var(--accent));
}
}
.sidebar-highlight {
animation: sidebar-highlight 1.5s ease-out;
}

View File

@ -1,14 +1,14 @@
import { notFound } from "next/navigation";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { getAllNoteSlugs, getNoteBySlug } from "@/lib/content";
import { PageLayout } from "@/components/layout/PageLayout";
import { BackNav } from "@/components/layout/BackNav";
import { NoteHeader } from "@/components/notes/NoteHeader";
import { PromptsSection } from "@/components/notes/PromptsSection";
import { SkillsSection } from "@/components/notes/SkillsSection";
import { FilesSection } from "@/components/notes/FilesSection";
import { AudioSection } from "@/components/notes/AudioSection";
import { NoteFooter } from "@/components/notes/NoteFooter";
import { NoteSidebarLayout } from "@/components/notes/NoteSidebar";
import { NoteContent } from "@/components/notes/NoteContent";
interface NotePageProps {
params: Promise<{ slug: string }>;
@ -27,55 +27,36 @@ export default async function NotePage({ params }: NotePageProps) {
notFound();
}
// Count resources for the mobile button
const resourceCount =
note.prompts.length +
note.files.length +
(note.skillsUsed?.length || 0) +
(note.audioFiles?.length || 0);
// Sidebar content
const sidebarContent = (
<>
<PromptsSection prompts={note.prompts} />
<SkillsSection skills={note.skillsUsed || []} />
<FilesSection files={note.files} />
<AudioSection noteSlug={note.slug} audioFiles={note.audioFiles || []} />
</>
);
return (
<PageLayout>
<NoteSidebarLayout sidebar={sidebarContent} resourceCount={resourceCount}>
<BackNav href="/maxwell" label="Back to Maxwell" />
<NoteHeader id={note.id} date={note.date} title={note.title} />
<PromptsSection prompts={note.prompts} />
<SkillsSection skills={note.skillsUsed || []} />
<FilesSection files={note.files} />
<div className="prose prose-neutral dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h2: ({ children }) => (
<h2 className="text-xl font-semibold mb-4 mt-12 first:mt-0">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="font-medium mt-6 mb-2">{children}</h3>
),
p: ({ children }) => <p className="mb-4">{children}</p>,
ul: ({ children }) => (
<ul className="mb-4 text-muted-foreground">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-4 text-muted-foreground">{children}</ol>
),
li: ({ children }) => <li className="mb-1">{children}</li>,
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{children}
</strong>
),
em: ({ children }) => <em>{children}</em>,
}}
>
{note.content}
</ReactMarkdown>
</div>
<NoteContent content={note.content} />
<NoteFooter
prev={note.navigation.prev}
next={note.navigation.next}
allNotesHref="/maxwell"
/>
</PageLayout>
</NoteSidebarLayout>
);
}

View File

@ -7,7 +7,7 @@ interface PageLayoutProps {
export function PageLayout({ children }: PageLayoutProps) {
return (
<div className="min-h-screen bg-background">
<article className="mx-auto max-w-[800px] px-6 py-16 text-foreground">
<article className="mx-auto max-w-[800px] px-6 py-8 text-foreground">
{children}
</article>
</div>

View File

@ -0,0 +1,69 @@
"use client";
import type { AudioRef } from "@/lib/content";
import { getAudioUrl } from "@/lib/assets";
import { SidebarItem } from "./SidebarItem";
interface AudioSectionProps {
noteSlug: string;
audioFiles: AudioRef[];
}
export function AudioSection({ noteSlug, audioFiles }: AudioSectionProps) {
if (audioFiles.length === 0) {
return null;
}
return (
<section className="mb-8">
<h2 className="text-sm font-medium text-muted-foreground mb-4">Audio</h2>
<div className="space-y-6">
{audioFiles.map((audio) => {
const audioPath = getAudioUrl(noteSlug, audio.name);
return (
<SidebarItem key={audio.name} id={`audio-${audio.name}`}>
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{audio.title}</span>
{audio.source && (
<span className="text-xs px-1.5 py-0.5 bg-muted rounded text-muted-foreground">
{audio.source}
</span>
)}
<a
href={audioPath}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground"
>
open
</a>
</div>
<p className="text-sm text-muted-foreground mb-3">
{audio.description}
</p>
<audio controls className="w-full" preload="metadata">
<source src={audioPath} type="audio/mp4" />
Your browser does not support the audio element.
</audio>
</div>
</SidebarItem>
);
})}
</div>
<p className="text-xs text-muted-foreground mt-4">
Made with{" "}
<a
href="https://notebooklm.google.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
NotebookLM
</a>
: upload research files Audio Overview download .m4a {" "}
<code className="text-[10px]">scripts/upload-asset.sh</code>
</p>
</section>
);
}

View File

@ -2,6 +2,7 @@
import { useState } from "react";
import { ExpandableFile } from "@/components/copyable";
import { SidebarItem } from "./SidebarItem";
import type { NoteFile } from "@/lib/content";
interface FilesSectionProps {
@ -12,15 +13,15 @@ export function FilesSection({ files }: FilesSectionProps) {
const [expandedFile, setExpandedFile] = useState<string | null>(null);
return (
<section className="mb-12 p-4 border border-border rounded">
<section className="mb-8">
<h2 className="text-sm font-medium text-muted-foreground mb-4">
Files Created
</h2>
{files.length > 0 ? (
<div className="space-y-3">
{files.map((file) => (
<SidebarItem key={file.name} id={`file-${file.name}`}>
<ExpandableFile
key={file.name}
name={file.name}
description={file.description}
content={file.content}
@ -29,6 +30,7 @@ export function FilesSection({ files }: FilesSectionProps) {
setExpandedFile(expandedFile === file.name ? null : file.name)
}
/>
</SidebarItem>
))}
</div>
) : (

View File

@ -0,0 +1,27 @@
"use client";
import { useSidebar } from "./SidebarContext";
interface InlineLinkProps {
type: "prompt" | "file" | "skill" | "audio";
id: string;
children: React.ReactNode;
}
export function InlineLink({ type, id, children }: InlineLinkProps) {
const { highlightItem } = useSidebar();
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
highlightItem(`${type}-${id}`);
};
return (
<button
onClick={handleClick}
className="text-foreground underline underline-offset-2 decoration-muted-foreground/50 hover:decoration-foreground transition-colors cursor-pointer"
>
{children}
</button>
);
}

View File

@ -0,0 +1,147 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { InlineLink } from "./InlineLink";
interface NoteContentProps {
content: string;
}
// Match [[type:id]] patterns
const INLINE_LINK_REGEX = /\[\[(prompt|file|skill|audio):([^\]]+)\]\]/g;
interface ContentPart {
type: "text" | "link";
content: string;
linkType?: "prompt" | "file" | "skill" | "audio";
linkId?: string;
}
function parseContentWithLinks(text: string): ContentPart[] {
const parts: ContentPart[] = [];
let lastIndex = 0;
let match;
// Reset regex
INLINE_LINK_REGEX.lastIndex = 0;
while ((match = INLINE_LINK_REGEX.exec(text)) !== null) {
// Add text before the match
if (match.index > lastIndex) {
parts.push({
type: "text",
content: text.slice(lastIndex, match.index),
});
}
// Add the link
parts.push({
type: "link",
content: match[2],
linkType: match[1] as "prompt" | "file" | "skill" | "audio",
linkId: match[2],
});
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push({
type: "text",
content: text.slice(lastIndex),
});
}
return parts;
}
function renderTextWithLinks(text: string): React.ReactNode {
const parts = parseContentWithLinks(text);
if (parts.length === 1 && parts[0].type === "text") {
return text;
}
return parts.map((part, index) => {
if (part.type === "link") {
return (
<InlineLink
key={index}
type={part.linkType!}
id={part.linkId!}
>
{part.content}
</InlineLink>
);
}
return part.content;
});
}
export function NoteContent({ content }: NoteContentProps) {
return (
<div className="prose prose-neutral dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h2: ({ children }) => (
<h2 className="text-xl font-semibold mb-4 mt-12 first:mt-0">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="font-medium mt-6 mb-2">{children}</h3>
),
p: ({ children }) => {
// Process text children for inline links
const processedChildren = processChildren(children);
return <p className="mb-4">{processedChildren}</p>;
},
ul: ({ children }) => (
<ul className="mb-4 text-muted-foreground">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-4 text-muted-foreground">{children}</ol>
),
li: ({ children }) => {
const processedChildren = processChildren(children);
return <li className="mb-1">{processedChildren}</li>;
},
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{children}
</strong>
),
em: ({ children }) => <em>{children}</em>,
}}
>
{content}
</ReactMarkdown>
</div>
);
}
function processChildren(children: React.ReactNode): React.ReactNode {
if (typeof children === "string") {
return renderTextWithLinks(children);
}
if (Array.isArray(children)) {
return children.map((child, index) => {
if (typeof child === "string") {
const result = renderTextWithLinks(child);
// If it's still just a string, return as-is
if (typeof result === "string") {
return result;
}
// If it's an array of parts, wrap in fragment with key
return <span key={index}>{result}</span>;
}
return child;
});
}
return children;
}

View File

@ -0,0 +1,107 @@
"use client";
import { ReactNode } from "react";
import { useSidebar, SidebarProvider } from "./SidebarContext";
interface NoteSidebarLayoutProps {
children: ReactNode;
sidebar: ReactNode;
resourceCount: number;
}
function MobileDrawerButton({ count }: { count: number }) {
const { openDrawer } = useSidebar();
return (
<button
onClick={openDrawer}
className="lg:hidden fixed top-4 right-4 z-40 flex items-center gap-1.5 px-3 py-1.5 bg-card border border-border rounded-full text-sm font-medium shadow-sm hover:bg-accent transition-colors"
>
<span>resources</span>
<span className="text-xs bg-muted px-1.5 py-0.5 rounded-full">
{count}
</span>
</button>
);
}
function MobileDrawer({ children }: { children: ReactNode }) {
const { isDrawerOpen, closeDrawer } = useSidebar();
return (
<>
{/* Backdrop */}
<div
className={`lg:hidden fixed inset-0 z-40 bg-black/50 transition-opacity ${isDrawerOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={closeDrawer}
/>
{/* Drawer */}
<div
className={`lg:hidden fixed top-0 right-0 z-50 h-full w-[320px] max-w-[85vw] bg-background border-l border-border shadow-xl transform transition-transform ${isDrawerOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between p-4 border-b border-border">
<span className="text-sm font-medium text-muted-foreground">
Resources
</span>
<button
onClick={closeDrawer}
className="text-muted-foreground hover:text-foreground"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="p-4 overflow-y-auto h-[calc(100%-57px)]">{children}</div>
</div>
</>
);
}
function LayoutInner({
children,
sidebar,
resourceCount,
}: NoteSidebarLayoutProps) {
return (
<div className="min-h-screen bg-background">
<MobileDrawerButton count={resourceCount} />
<MobileDrawer>{sidebar}</MobileDrawer>
<div className="relative mx-auto flex gap-8 px-6">
{/* Main content */}
<article className="flex-1 py-8 text-foreground">
{children}
</article>
{/* Desktop sidebar */}
<aside className="hidden lg:block w-[320px] xl:w-[380px] pt-16 pr-6">
<div className="sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto">
{sidebar}
</div>
</aside>
</div>
</div>
);
}
export function NoteSidebarLayout(props: NoteSidebarLayoutProps) {
return (
<SidebarProvider>
<LayoutInner {...props} />
</SidebarProvider>
);
}

View File

@ -1,4 +1,7 @@
"use client";
import { CopyableBlock } from "@/components/copyable";
import { SidebarItem } from "./SidebarItem";
import type { Prompt } from "@/lib/content";
interface PromptsSectionProps {
@ -7,17 +10,18 @@ interface PromptsSectionProps {
export function PromptsSection({ prompts }: PromptsSectionProps) {
return (
<section className="mb-8 p-4 border border-border rounded">
<section className="mb-8">
<h2 className="text-sm font-medium text-muted-foreground mb-4">
Prompts Used
</h2>
<div className="space-y-4">
{prompts.map((prompt) => (
<SidebarItem key={prompt.id} id={`prompt-${prompt.id}`}>
<CopyableBlock
key={prompt.id}
label={prompt.label}
content={prompt.content.trim()}
/>
</SidebarItem>
))}
</div>
</section>

View File

@ -0,0 +1,66 @@
"use client";
import {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
interface SidebarContextValue {
isDrawerOpen: boolean;
openDrawer: () => void;
closeDrawer: () => void;
highlightedItem: string | null;
highlightItem: (id: string) => void;
}
const SidebarContext = createContext<SidebarContextValue | null>(null);
export function SidebarProvider({ children }: { children: ReactNode }) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [highlightedItem, setHighlightedItem] = useState<string | null>(null);
const openDrawer = useCallback(() => setIsDrawerOpen(true), []);
const closeDrawer = useCallback(() => setIsDrawerOpen(false), []);
const highlightItem = useCallback((id: string) => {
// Scroll to the element
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
}
// Open drawer on mobile
setIsDrawerOpen(true);
// Set highlight (will trigger animation)
setHighlightedItem(id);
// Clear highlight after animation completes
setTimeout(() => setHighlightedItem(null), 1500);
}, []);
return (
<SidebarContext.Provider
value={{
isDrawerOpen,
openDrawer,
closeDrawer,
highlightedItem,
highlightItem,
}}
>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const context = useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
}

View File

@ -0,0 +1,23 @@
"use client";
import { ReactNode } from "react";
import { useSidebar } from "./SidebarContext";
interface SidebarItemProps {
id: string;
children: ReactNode;
}
export function SidebarItem({ id, children }: SidebarItemProps) {
const { highlightedItem } = useSidebar();
const isHighlighted = highlightedItem === id;
return (
<div
id={id}
className={isHighlighted ? "sidebar-highlight rounded" : ""}
>
{children}
</div>
);
}

View File

@ -2,6 +2,7 @@
import { useState } from "react";
import { CopyableBlock } from "@/components/copyable";
import { SidebarItem } from "./SidebarItem";
export interface SkillUsed {
name: string;
@ -20,13 +21,14 @@ export function SkillsSection({ skills }: SkillsSectionProps) {
if (skills.length === 0) return null;
return (
<section className="mb-8 p-4 border border-border rounded">
<section className="mb-8">
<h2 className="text-sm font-medium text-muted-foreground mb-4">
Skills Used
</h2>
<div className="space-y-3">
{skills.map((skill) => (
<div key={skill.name} className="space-y-2">
<SidebarItem key={skill.name} id={`skill-${skill.name}`}>
<div className="space-y-2">
<div className="flex items-center gap-3">
<button
onClick={() =>
@ -51,6 +53,7 @@ export function SkillsSection({ skills }: SkillsSectionProps) {
<CopyableBlock content={skill.usage} />
)}
</div>
</SidebarItem>
))}
</div>
</section>

10
blog/src/lib/assets.ts Normal file
View File

@ -0,0 +1,10 @@
const ASSET_BASE = process.env.NEXT_PUBLIC_ASSET_BASE_URL
|| "https://storage.googleapis.com/orchard9-assets/research-notes";
export function getAssetUrl(path: string): string {
return `${ASSET_BASE}/${path}`;
}
export function getAudioUrl(noteSlug: string, filename: string): string {
return getAssetUrl(`audio/notes/${noteSlug}/${filename}`);
}

View File

@ -30,6 +30,13 @@ export interface FileRef {
description: string;
}
export interface AudioRef {
name: string;
title: string;
description: string;
source?: string;
}
export interface SkillUsed {
name: string;
command: string;
@ -52,6 +59,7 @@ export interface NoteMeta {
prompts: Prompt[];
skillsUsed?: SkillUsed[];
filesCreated: FileRef[];
audioFiles?: AudioRef[];
navigation: {
prev: NoteNavLink | null;
next: NoteNavLink | null;
@ -179,3 +187,32 @@ export function getWhitePaperOutline(): WhitePaperOutline {
const fileContents = fs.readFileSync(filePath, "utf8");
return yaml.load(fileContents) as WhitePaperOutline;
}
// Inline link parsing for [[type:id]] syntax
const INLINE_LINK_REGEX = /\[\[(prompt|file|skill|audio):([^\]]+)\]\]/g;
export interface InlineLinkMatch {
type: "prompt" | "file" | "skill" | "audio";
id: string;
displayText: string;
}
export function parseInlineLinks(content: string): string {
return content.replace(INLINE_LINK_REGEX, (_match, type, id) => {
// Create a custom element that React can render
return `<inline-ref type="${type}" id="${id}">${id}</inline-ref>`;
});
}
export function extractInlineLinks(content: string): InlineLinkMatch[] {
const matches: InlineLinkMatch[] = [];
let match;
while ((match = INLINE_LINK_REGEX.exec(content)) !== null) {
matches.push({
type: match[1] as InlineLinkMatch["type"],
id: match[2],
displayText: match[2],
});
}
return matches;
}

41
blog/vision.md Normal file
View File

@ -0,0 +1,41 @@
# Vision
Notes from building things.
## What This Is
A project log. I write down what I'm doing as I do it. Prompts, decisions, dead ends, things that worked.
Sometimes there's audio or video when it's easier to explain that way.
## Why
Two reasons:
1. **For me** — I forget what I did and why. Notes help me pick up where I left off.
2. **For others** — If you're building something similar, maybe this saves you time.
## Tone
- Casual
- Concise
- Direct
- No fluff
- No hyperbole
If something sucked, I'll say it sucked. If something worked, I'll say what worked.
## Format
Each project gets a series of notes. Each note covers one session or one phase. Notes include:
- What I was trying to do
- The prompts I used
- What happened
- What I learned
Code snippets, screenshots, audio/video when useful.
## Not a Tutorial
This isn't polished educational content. It's raw notes. Some things will be wrong. Some things will be incomplete. That's fine.

16
scripts/upload-asset.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
set -e
BUCKET="orchard9-assets"
PREFIX="research-notes"
LOCAL_FILE="$1"
REMOTE_PATH="$2"
[[ -z "$LOCAL_FILE" || -z "$REMOTE_PATH" ]] && {
echo "Usage: $0 <local-file> <remote-path>"
echo "Example: $0 ./audio.m4a audio/notes/003-research-planning/audio.m4a"
exit 1
}
gcloud storage cp "$LOCAL_FILE" "gs://${BUCKET}/${PREFIX}/${REMOTE_PATH}"
echo "https://storage.googleapis.com/${BUCKET}/${PREFIX}/${REMOTE_PATH}"