{post.title}
{post.excerpt}
本文を読み込み中...(draft.md が存在しない場合はここが空のままになります)
// ==================================================================== // 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 ( ); } function SkSquiggle({ w = 80 }) { return ; } function SkPlaceholder({ label = "image", aspect = "4/3", className = "", subtle = false, img = null, alt = "" }) { if (img) { return (
まもなく最初の観測記録が届きます。
X バズ → 記憶参照 → SEO記事 → 4コマ の自動パイプラインが記事を運んでくる予定です。
{hero.excerpt}
表示できる記事がまだありません。
→
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;