// ==================================================================== // SKETCH — 手書きスクラップブック風 // Left sidebar, warm off-white (the original wireframe design) // ==================================================================== const { useState: useStateS, useEffect: useEffectS, useMemo: useMemoS } = React; // SK_POSTS / SK_TAGS は posts.js(build-posts-manifest.py で自動生成)から読み込む const SK_POSTS = (typeof window !== "undefined" && window.SK_POSTS) || []; const SK_TAGS = (typeof window !== "undefined" && window.SK_TAGS) || []; function SkOctoLogo({ size = 36, animate = false }) { // 脱力系ハイテクタコ: 眠そうな半月目 + LEDほっぺ + 8本足のパチパチ波 const legCount = 8; return ( {/* 頭 */} {/* 目(点) */} {/* LEDほっぺ(アクセント色) */} {/* 8本足 */} {[...Array(legCount)].map((_, i) => ( ))} ); } function SkSquiggle({ w = 80 }) { return ; } function SkPlaceholder({ label = "image", aspect = "4/3", className = "", subtle = false, img = null, alt = "" }) { if (img) { return (
{alt
); } return (
{label}
ここにアイキャッチが入ります
); } function SkKoma({ n, hint, img = null }) { // 画像がある場合は作画済みなのでコマ番号も hint も表示しない return (
{!img && {n}}
{img ? ( {hint ) : ( <>
{hint &&
{hint}
} )}
); } function SketchSidebar({ page, setPage, onTagClick }) { const nav = [ { id: "home", label: "トップ", sub: "最新 & 注目バズ", icon: "⌂" }, { id: "post", label: "記事詳細", sub: "4コマ + 本文", icon: "✎" }, { id: "search", label: "セマンティック検索", sub: "キーワード + 意味", icon: "◎" }, { id: "tags", label: "タグ & アーカイブ", sub: "時系列・一覧", icon: "#" }, { id: "tools", label: "ツール", sub: "シミュレーター + 補助", icon: "⚙", href: "/tools/" }, ]; return ( ); } function SketchHome({ setPage, setPostId, selectedTag, clearTag, onTagClick }) { const visiblePosts = selectedTag ? SK_POSTS.filter(p => (p.tags || []).includes(selectedTag)) : SK_POSTS; const hero = visiblePosts[0]; if (!hero) { return (
system: idle

booting octopachi…

まもなく最初の観測記録が届きます。
X バズ → 記憶参照 → SEO記事 → 4コマ の自動パイプラインが記事を運んでくる予定です。

pipeline: buzz-memory-blog v5
); } return (
{selectedTag && (
#{selectedTag}」で絞り込み中({visiblePosts.length} 件)
)}
{hero.date} · {hero.readMin}min{hero.updatedAt ? ` · 更新 ${hero.updatedAt}` : ""}

{hero.title}

{hero.excerpt}

{hero.komaHint.map((k, i) => )}
{hero.tags.map(t => ( ))}
📨 週イチのまとめ
毎週日曜 23:00 配信 / 無料

さいきんの観測

recent — {SK_POSTS.length} posts
{visiblePosts.map(p => (
{ setPostId(p.id); setPage("post"); }} className="sketch-border-sm p-4 text-left relative cursor-pointer" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter") { setPostId(p.id); setPage("post"); } }}>
{p.date} · {p.readMin}min{p.updatedAt ? ` · 更新 ${p.updatedAt}` : ""}
{p.title}
{p.excerpt}
{p.tags.map(t => ( ))}
))}
); } function SketchPost({ postId, setPage, setPostId, onTagClick }) { const post = SK_POSTS.find(p => p.id === postId) || SK_POSTS[0]; // 記事が1件もない場合は home に誘導 if (!post) { return (
404 / empty

表示できる記事がまだありません。

); } const koma = post.komaHint || ["panel 1","panel 2","panel 3","panel 4"]; const panels = post.panels || [null, null, null, null]; // 4コマ画像の native aspect から最適レイアウトを自動選択する。 // aspect >= 1.8 → tall (横長ストリップを縦積み) // aspect < 0.6 → 1x4 (縦長ポートレートを横並び) // それ以外 → 2x2 (ほぼ正方形の4コマ) // Tweaks パネル(edit mode)を触った場合はそちらが最後に data-koma を書くので自動値を上書きする。 React.useEffect(() => { const a = post.panelAspect; if (!a) return; const layout = a >= 1.8 ? "tall" : a < 0.6 ? "1x4" : "2x2"; const prev = document.documentElement.getAttribute("data-koma"); document.documentElement.setAttribute("data-koma", layout); // 各記事固有のパネル aspect を CSS 変数で渡す(shell.jsx 側がフォールバック 928/288 で受ける) document.documentElement.style.setProperty("--panel-aspect", String(a)); return () => { const t = window.__octopachiTweaks; if (t && t.komaLayout) document.documentElement.setAttribute("data-koma", t.komaLayout); else if (prev) document.documentElement.setAttribute("data-koma", prev); document.documentElement.style.removeProperty("--panel-aspect"); }; }, [post.id, post.panelAspect]); // body (draft.md) と 見出しリストを非同期で取得 const [bodyHtml, setBodyHtml] = useStateS(""); const [bodyHeadings, setBodyHeadings] = useStateS([]); useEffectS(() => { if (!post.content || !window.marked) { setBodyHtml(""); setBodyHeadings([]); return; } let cancelled = false; fetch(post.content).then(r => r.ok ? r.text() : "").then(md => { if (cancelled) return; // YAMLフロントマター削除 + 先頭H1(タイトル)削除(post.title と重複するため) const stripped = md .replace(/^---[\s\S]*?\n---\s*\n/, "") // frontmatter .replace(/^\s*#\s+.+\n+/, ""); // 先頭H1(改行含む先行空白を許可) const html = window.marked.parse(stripped); // H2にIDを付与してTOC候補を抽出 + mermaid code block を div 化 const tmp = document.createElement("div"); tmp.innerHTML = html; // mermaid:
tmp.querySelectorAll("pre > code.language-mermaid").forEach((codeEl, i) => { const pre = codeEl.parentElement; const div = document.createElement("div"); div.className = "mermaid"; div.textContent = codeEl.textContent; div.id = `mermaid-${i}`; pre.replaceWith(div); }); const h2s = Array.from(tmp.querySelectorAll("h2")); const headings = h2s.map((h, i) => { const id = `sec-md-${i}`; h.id = id; h.style.scrollMarginTop = "140px"; return { id, label: h.textContent.trim() }; }); setBodyHtml(tmp.innerHTML); setBodyHeadings(headings); }).catch(() => { if (!cancelled) { setBodyHtml(""); setBodyHeadings([]); } }); return () => { cancelled = true; }; }, [post.content]); // mermaid 後処理(bodyHtml 更新後にレンダリング) useEffectS(() => { if (!bodyHtml || !window.mermaid) return; try { window.mermaid.initialize({ startOnLoad: false, theme: "neutral", securityLevel: "loose" }); const nodes = document.querySelectorAll(".sk-article-body .mermaid"); if (nodes.length) window.mermaid.run({ nodes: Array.from(nodes) }); } catch (e) { /* noop */ } }, [bodyHtml]); // TOC は Markdown H2 のみ(構造アンカー eyecatch/koma は TOC に出さない) const sections = React.useMemo(() => [ ...bodyHeadings, ], [bodyHeadings]); const [pinned, setPinned] = useStateS(false); const [activeId, setActiveId] = useStateS("sec-eyecatch"); const [progress, setProgress] = useStateS(0); const [scrolling, setScrolling] = useStateS(false); const articleRef = React.useRef(null); const heroRef = React.useRef(null); const idleTimerRef = React.useRef(null); // Scroll effects: pin header after hero leaves, track active section, progress bar useEffectS(() => { const scroller = document.querySelector(".sketch-scroll") || window; const getScrollTop = () => (scroller === window ? window.scrollY : scroller.scrollTop); const onScroll = () => { const top = getScrollTop(); // pin threshold = hero bottom if (heroRef.current) { const rect = heroRef.current.getBoundingClientRect(); setPinned(rect.bottom < 80); } // progress if (articleRef.current) { const ar = articleRef.current; const total = ar.scrollHeight - (scroller === window ? window.innerHeight : scroller.clientHeight); const passed = scroller === window ? window.scrollY - ar.offsetTop : scroller.scrollTop - ar.offsetTop; setProgress(Math.max(0, Math.min(1, passed / Math.max(total, 1)))); } // active section = last section whose top is above 140px if (sections.length) { let current = sections[0].id; for (const s of sections) { const el = document.getElementById(s.id); if (el && el.getBoundingClientRect().top < 180) current = s.id; } setActiveId(current); } // activity flag — true while scroll events fire, decays after 350ms idle setScrolling(true); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); idleTimerRef.current = setTimeout(() => setScrolling(false), 350); }; scroller.addEventListener("scroll", onScroll, { passive: true }); onScroll(); return () => scroller.removeEventListener("scroll", onScroll); }, [postId, bodyHeadings]); const jumpTo = (id) => { const el = document.getElementById(id); if (!el) return; const scroller = document.querySelector(".sketch-scroll"); const offset = 140; if (scroller) { const rect = el.getBoundingClientRect(); const srect = scroller.getBoundingClientRect(); scroller.scrollBy({ top: rect.top - srect.top - offset, behavior: "smooth" }); } else { window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - offset, behavior: "smooth" }); } }; return (
{/* Pinned compact header */}
{post.title}
{post.tags.slice(0,2).map(t => ( ))} {post.readMin}min
{/* LEFT — TOC */} {/* CENTER — article body */}
{post.tags.map(t => ( ))} {post.date} · {post.readMin}min{post.updatedAt ? ` · 更新 ${post.updatedAt}` : ""}

{post.title}

{post.excerpt}

{/* EYECATCH HERO — full-bleed, taped on, subtle rotation */}
{/* 4-KOMA — single manga page */}
no.{String(SK_POSTS.indexOf(post)+1).padStart(3,"0")}
{koma.map((h, i) => )}
{bodyHtml ? (
) : (

本文を読み込み中...(draft.md が存在しない場合はここが空のままになります)

)}
); } function SketchSearch({ setPage, setPostId, onTagClick }) { const [q, setQ] = useStateS(""); const [uiMode, setUiMode] = useStateS("A"); const results = useMemoS(() => { const tokens = q.trim().toLowerCase().split(/\s+/).filter(Boolean); const score = (p) => { if (tokens.length === 0) return 0.5; const text = (p.title + " " + (p.excerpt || "") + " " + (p.tags || []).join(" ")).toLowerCase(); let hits = 0; for (const t of tokens) if (text.includes(t)) hits++; return hits / tokens.length; }; return SK_POSTS .map((p, i) => ({ ...p, sim: score(p), x: 25 + ((i * 17) % 50), y: 20 + ((i * 23) % 55) })) .filter(p => tokens.length === 0 || p.sim > 0) .sort((a, b) => b.sim - a.sim); }, [q]); return (
semantic search · キーワードと意味の両対応

意味で探す

setQ(e.target.value)} placeholder="キーワードや文章で探す" className="flex-1 bg-transparent border-none outline-none font-hand text-[18px] ink" />
{q.trim() ? `${results.length} 件ヒット` : `すべての記事 ${SK_POSTS.length} 件を表示中(入力すると絞り込みます)`}
{uiMode === "A" && (
{results.map((r, i) => ( ))}
)} {uiMode === "B" && (
vector space · 2D projection (mock)
{results.map(r => (
{ setPostId(r.id); setPage("post"); }} className="absolute cursor-pointer" style={{ left: `${r.x}%`, top: `${r.y}%` }}>
))}
{results.map(r => ( ))}
)} {uiMode === "C" && (
query expansion
{["自己書き換え","auto-PR","self-improving","CI loop"].map(e => + {e})}
🤖 このクエリで下書き生成 →
{results.map(r => ( ))}
)}
); } function SketchTags({ setPage, setPostId, onTagClick }) { return (

タグ & アーカイブ

tag cloud — クリックで絞り込み
{SK_TAGS.map(t => ( ))}
timeline
2026-04
{SK_POSTS.map(p => ( ))}
calendar · 2026-04
{["月","火","水","木","金","土","日"].map(d =>
{d}
)}
{[...Array(30)].map((_, i) => { const dateStr = `2026-04-${String(i+1).padStart(2,"0")}`; const has = SK_POSTS.some(p => p.date === dateStr); return (
{i+1}
); })}
); } function SketchApp() { // 初期ステートを URL ハッシュから読む: // #/post/ → page="post", postId= // #/search → page="search" // #/tags → page="tags" // それ以外 → page="home" // 外部リンク(X/SNS)からの deep link を実現しつつ、戻るボタンも機能させる。 const parseHash = () => { if (typeof window === "undefined") return { page: "home", postId: null }; const h = (window.location.hash || "").replace(/^#\/?/, ""); if (h.startsWith("post/")) { const slug = h.slice("post/".length).replace(/\/.*$/, ""); return { page: "post", postId: slug }; } if (h === "search" || h === "tags" || h === "home") return { page: h, postId: null }; return { page: "home", postId: null }; }; const initial = parseHash(); const [page, setPage] = useStateS(initial.page); const [postId, setPostId] = useStateS(initial.postId || "hermes-agent"); const [selectedTag, setSelectedTag] = useStateS(null); const onTagClick = (tag) => { setSelectedTag(tag); setPage("home"); if (typeof window !== "undefined") window.scrollTo(0, 0); }; const clearTag = () => setSelectedTag(null); // page/postId → URL ハッシュに反映 React.useEffect(() => { const target = page === "post" && postId ? `#/post/${postId}` : page === "home" ? "" : `#/${page}`; if ((window.location.hash || "") !== target) { // replaceState で hashchange イベント発火を避け、ナビゲーション履歴も汚さない const url = window.location.pathname + window.location.search + target; window.history.replaceState(null, "", url); } }, [page, postId]); // ブラウザの戻る/進むボタン対応 React.useEffect(() => { const onPop = () => { const s = parseHash(); setPage(s.page); if (s.postId) setPostId(s.postId); }; window.addEventListener("popstate", onPop); window.addEventListener("hashchange", onPop); return () => { window.removeEventListener("popstate", onPop); window.removeEventListener("hashchange", onPop); }; }, []); return (
{/* Mobile-only top bar (sidebar is hidden at <=820px) */}
octopachi
octopachi / {page}
{page === "home" && } {page === "post" && } {page === "search" && } {page === "tags" && }
); } window.SketchApp = SketchApp;