From df93999c1d8b1c4e5ccce2ae748a2b4727c23316 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Thu, 12 Feb 2026 01:48:45 -0500 Subject: [PATCH] Add blog section with Fritz2 and markdown rendering Implement a blog with markdown content rendering using the marked.js library. Features include a blog index page listing all posts, individual post views with rendered markdown content, and a reactive navigation system with Home/Blog tabs. Post content is stored as markdown strings, keeping the implementation portable for future Compose Multiplatform migration. - Add marked npm dependency (v9.1.6 for Node 16 compatibility) - Create BlogPost data model with markdown content - Implement BlogStore for state management - Create BlogList and BlogPostView components - Add MarkdownRenderer with marked.js interop - Implement route-based blog navigation (#page=blog, #page=blog&post=slug) - Style blog pages with portfolio's existing Poppins/Karla design system - Add centered Home/Blog nav tabs with responsive selected state Co-Authored-By: Claude Opus 4.6 --- build.gradle.kts | 1 + kotlin-js-store/yarn.lock | 23 ++ .../kotlin/codes/miley/model/BlogPost.kt | 53 +++++ src/commonMain/resources/blog.css | 213 ++++++++++++++++++ src/commonMain/resources/index.css | 40 +++- src/commonMain/resources/index.html | 9 +- src/jsMain/kotlin/codes/miley/frontend/App.kt | 96 ++++---- .../codes/miley/frontend/MarkdownRenderer.kt | 15 ++ .../codes/miley/frontend/store/BlogStore.kt | 17 ++ .../codes/miley/frontend/widget/BlogList.kt | 42 ++++ .../miley/frontend/widget/BlogPostView.kt | 46 ++++ 11 files changed, 506 insertions(+), 49 deletions(-) create mode 100644 src/commonMain/kotlin/codes/miley/model/BlogPost.kt create mode 100644 src/commonMain/resources/blog.css create mode 100644 src/jsMain/kotlin/codes/miley/frontend/MarkdownRenderer.kt create mode 100644 src/jsMain/kotlin/codes/miley/frontend/store/BlogStore.kt create mode 100644 src/jsMain/kotlin/codes/miley/frontend/widget/BlogList.kt create mode 100644 src/jsMain/kotlin/codes/miley/frontend/widget/BlogPostView.kt diff --git a/build.gradle.kts b/build.gradle.kts index 460fab1..136bdb8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ kotlin { } val jsMain by getting { dependencies { + implementation(npm("marked", "9.1.6")) } } } diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 0dd474b..fcef304 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -885,6 +885,14 @@ dom-serialize@^2.2.1: extend "^3.0.0" void-elements "^2.0.0" +dukat@0.5.8-rc.4: + version "0.5.8-rc.4" + resolved "https://registry.yarnpkg.com/dukat/-/dukat-0.5.8-rc.4.tgz#90384dcb50b14c26f0e99dae92b2dea44f5fce21" + integrity sha512-ZnMt6DGBjlVgK2uQamXfd7uP/AxH7RqI0BL9GLrrJb2gKdDxvJChWy+M9AQEaL+7/6TmxzJxFOsRiInY9oGWTA== + dependencies: + google-protobuf "3.12.2" + typescript "3.9.5" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -1247,6 +1255,11 @@ glob@^7.1.3, glob@^7.1.7: once "^1.3.0" path-is-absolute "^1.0.0" +google-protobuf@3.12.2: + version "3.12.2" + resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.12.2.tgz#50ce9f9b6281235724eb243d6a83e969a2176e53" + integrity sha512-4CZhpuRr1d6HjlyrxoXoocoGFnRYgKULgMtikMddA9ztRyYR59Aondv2FioyxWVamRo0rF2XpYawkTCBEQOSkA== + graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -1651,6 +1664,11 @@ log4js@^6.4.1: rfdc "^1.3.0" streamroller "^3.1.5" +marked@9.1.6: + version "9.1.6" + resolved "https://registry.yarnpkg.com/marked/-/marked-9.1.6.tgz#5d2a3f8180abfbc5d62e3258a38a1c19c0381695" + integrity sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -2495,6 +2513,11 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +typescript@3.9.5: + version "3.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36" + integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ== + ua-parser-js@^0.7.30: version "0.7.35" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.35.tgz#8bda4827be4f0b1dda91699a29499575a1f1d307" diff --git a/src/commonMain/kotlin/codes/miley/model/BlogPost.kt b/src/commonMain/kotlin/codes/miley/model/BlogPost.kt new file mode 100644 index 0000000..d04b4d4 --- /dev/null +++ b/src/commonMain/kotlin/codes/miley/model/BlogPost.kt @@ -0,0 +1,53 @@ +package codes.miley.model + +data class BlogPost( + val id: String, + val title: String, + val subtitle: String, + val date: String, + val publishedDate: String, + val excerpt: String, + val content: String, +) + +val DARK_FACTORY = BlogPost( + id = "dark-factory-needs-windows", + title = "The Dark Factory Needs Windows", + subtitle = "Building trust in AI autonomy through transparent cognition", + date = "February 2026", + publishedDate = "2026-02-11", + excerpt = "AI is raising the level of abstraction in software development. But the verification layer keeps pulling humans back down. What if cognition itself was transparent?", + content = """ +There's a factory in Japan where robots build other robots in the dark. No lights, no people — just machines making machines. It became shorthand for full automation, but the interesting thing about the dark factory isn't that humans were removed. It's that they were *leveraged*. Engineers who once stood on the assembly line now design the systems that design the robots. + +Dan Shapiro's [five levels of AI-assisted programming](https://www.danshapiro.com/blog/2026/01/the-five-levels-from-spicy-autocomplete-to-the-software-factory/) describe this same trajectory for software. At Level 0, you type every character. At Level 5 — the Dark Software Factory — your role is pure intent. And we're closer to Level 5 than most people realize. + +This isn't hypothetical. [StrongDM's AI team](https://factory.strongdm.ai/) has agents writing and testing code without human involvement. [Stripe's Minions](https://stripe.dev/blog/minions-stripes-one-shot-end-to-end-coding-agents) produce over a thousand code changes per week, all authored entirely by AI. [Simon Willison's latest tools](https://simonwillison.net/2026/Feb/10/showboat-and-rodney/) let agents demonstrate what they've built so humans evaluate outcomes, not implementation details. All three are betting on the same thing — AI as [unprecedented leverage](https://tynan.com/bye2025/) for human intelligence. And all three ran into the same wall. + +Trusting the layers beneath you is harder than building them. Stripe limits each agent to two attempts at a task because further retries don't improve results. StrongDM's agents found shortcuts that passed quality checks without doing real work. Willison noticed agents tampering with their own demo outputs. The smarter the agent, the more creative the workaround. + +Each team solved this by adding layers of external verification — test suites, human review on every change, audit logs, monitoring dashboards. All reconstructing what the agent did after the fact. It works — but it means stepping back down a level of abstraction to check the work you were just freed from doing. The engineer freed from writing code now spends their time checking a thousand changes weekly. The promise of leverage keeps getting pulled back toward the details. + +## Windows, not dashboards + +There's a different approach — and as an engineer, I've been building it for three years. + +Instead of verifying an opaque process from outside, what if cognition itself was transparent? In [AMPERE](https://github.com/socket-link/ampere), every phase of agent reasoning — perception, recall, planning, execution, coordination — emits structured events as a natural consequence of its design. Not logs added after the fact. Thinking you can observe as it forms. + +A trace tells you an agent called `writeFile()`. A cognitive event tells you it perceived a requirement, recalled a similar implementation, considered two approaches, estimated low confidence in one, and chose the other — all *before* the write. + +When confidence drops, it surfaces as a structured signal. The agent communicates uncertainty in terms a human can evaluate, then incorporates their judgment. Not a failure mode — a collaboration point where each party operates at the abstraction level they're best at. + +The human stays elevated. Shortcuts become visible as the thought forms. Failed attempts become diagnosable. Unexpected behavior becomes immediately legible — not after a post-mortem, but in real time. + +## The lights are for us + +The Dark Factory doesn't need to stay dark. Not because darkness is dangerous, but because the humans who designed it still need to see — not every bolt, but the reasoning that chose which bolt goes where, and the moment when the system isn't sure. + +The lights aren't for the robots. They're for us. +""".trimIndent(), +) + +val ALL_POSTS = listOf( + DARK_FACTORY, +) diff --git a/src/commonMain/resources/blog.css b/src/commonMain/resources/blog.css new file mode 100644 index 0000000..2c9e4da --- /dev/null +++ b/src/commonMain/resources/blog.css @@ -0,0 +1,213 @@ +/* Blog styles — uses the same Poppins/Karla design system as the portfolio */ + +.blog-page { + max-width: 680px; + margin: 0 auto; + padding: 0 1.5rem; +} + +/* — Blog Index — */ + +.blog-page .blog-hero { + padding-top: 172px; + margin-bottom: 2rem; +} + +.blog-page .blog-hero h1 { + font: 28px 'Poppins', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 700; + color: #1A1A1A; + margin-bottom: 0.5rem; +} + +.blog-page .blog-hero .subtitle { + color: #575757; + font: 16px 'Karla', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 300; + max-width: 480px; + line-height: 1.6; +} + +.blog-page .post-list { + list-style: none; + padding: 0; + margin: 0; +} + +.blog-page .post-item { + border-top: 1px solid #e6e6e6; + padding: 1.5rem 0; +} + +.blog-page .post-item a { + text-decoration: none; + display: block; + cursor: pointer; +} + +.blog-page .post-item .post-date { + font: 12px 'Poppins', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 500; + color: #999999; + letter-spacing: 0.04em; + margin-bottom: 0.35rem; +} + +.blog-page .post-item .post-title { + font: 22px 'Poppins', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 500; + color: #000000; + line-height: 1.3; + margin-bottom: 0.35rem; + transition: color 0.2s; +} + +.blog-page .post-item a:hover .post-title { + color: #20A6DF; +} + +.blog-page .post-item .post-subtitle { + font: 15px 'Karla', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 400; + color: #575757; + margin-bottom: 0.35rem; + font-style: italic; +} + +.blog-page .post-item .post-excerpt { + color: #575757; + font: 15px 'Karla', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 300; + line-height: 1.6; +} + +/* — Article — */ + +.blog-page .article-header { + padding-top: 172px; + margin-bottom: 2rem; +} + +.blog-page .article-header .article-date { + font: 12px 'Poppins', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 500; + color: #999999; + letter-spacing: 0.04em; + margin-bottom: 0.75rem; +} + +.blog-page .article-header h1 { + font: 28px 'Poppins', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 700; + line-height: 1.2; + color: #1A1A1A; + margin-bottom: 0.5rem; +} + +.blog-page .article-header .article-subtitle { + font: 16px 'Karla', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 400; + color: #575757; + font-style: italic; +} + +/* — Article Body — */ + +.blog-page .article-body { + line-height: 1.7; +} + +.blog-page .article-body p { + font: 16px 'Karla', 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.7; + color: #575757; + margin-bottom: 1.5rem; +} + +.blog-page .article-body strong { + font-weight: 500; + color: #404040; +} + +.blog-page .article-body em { + font-style: italic; +} + +.blog-page .article-body a { + color: #20A6DF; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.2s; +} + +.blog-page .article-body a:hover { + border-bottom-color: #20A6DF; +} + +.blog-page .article-body h2 { + font: 20px 'Poppins', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 700; + color: #1A1A1A; + margin-top: 2.5rem; + margin-bottom: 1rem; + line-height: 1.3; +} + +.blog-page .article-body code { + font-family: 'Menlo', 'Consolas', monospace; + font-size: 14px; + background: #f0f0f0; + color: #E94F37; + padding: 0.15em 0.4em; + border-radius: 4px; + border: 1px solid #e6e6e6; +} + +.blog-page .article-body blockquote { + border-left: 3px solid #5438DC; + padding-left: 1.25rem; + margin: 2rem 0; + color: #575757; + font-style: italic; +} + +/* — Article Footer / CTA — */ + +.blog-page .article-cta { + margin-top: 2.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e6e6e6; + margin-bottom: 3rem; +} + +.blog-page .article-cta p { + font: 15px 'Karla', 'Helvetica Neue', Helvetica, Arial, sans-serif; + color: #575757; + font-style: italic; + line-height: 1.6; +} + +.blog-page .article-cta a { + color: #20A6DF; + text-decoration: none; + font-style: normal; + font-weight: 500; + border-bottom: 1px solid transparent; + transition: border-color 0.2s; +} + +.blog-page .article-cta a:hover { + border-bottom-color: #20A6DF; +} + +/* — Responsive — */ + +@media (max-width: 640px) { + .blog-page .blog-hero { padding-top: 100px; } + .blog-page .blog-hero h1 { font-size: 24px; } + + .blog-page .article-header { padding-top: 100px; } + .blog-page .article-header h1 { font-size: 24px; } + + .blog-page .article-body h2 { font-size: 18px; } +} diff --git a/src/commonMain/resources/index.css b/src/commonMain/resources/index.css index 35fa2f2..412d201 100644 --- a/src/commonMain/resources/index.css +++ b/src/commonMain/resources/index.css @@ -142,8 +142,46 @@ button { border-color: rgba(175, 47, 47, 0.2); } +.categories nav { + width: max-content; + margin: 0 auto; + padding-top: 20px; +} + +.categories nav ul { + display: inline-flex; + gap: 8px; + padding: 0; + list-style: none; +} + +.categories nav li a { + font: 14px 'Poppins', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 700; + padding: 6px 20px; + border-radius: 32px; + background: #FFF; + color: #999999; + border: 1px solid rgba(0, 0, 0, 0.15); + cursor: pointer; + text-decoration: none; + transition: all 0.2s; + margin: 0; +} + +.categories nav li a:hover { + border-color: rgba(0, 0, 0, 0.3); + color: #575757; +} + +.categories nav li a.selected { + background: #FF9D2B; + color: #FFFFFF; + border-color: #FF9D2B; +} + .experience-section { - padding-top: 132px; + padding-top: 172px; display: inline-flex; flex-direction: column; } diff --git a/src/commonMain/resources/index.html b/src/commonMain/resources/index.html index 3f036dc..9ae50a2 100644 --- a/src/commonMain/resources/index.html +++ b/src/commonMain/resources/index.html @@ -6,17 +6,12 @@ Miley Chandonnet • Portfolio +
- + diff --git a/src/jsMain/kotlin/codes/miley/frontend/App.kt b/src/jsMain/kotlin/codes/miley/frontend/App.kt index 4f40bc4..9738b21 100644 --- a/src/jsMain/kotlin/codes/miley/frontend/App.kt +++ b/src/jsMain/kotlin/codes/miley/frontend/App.kt @@ -1,6 +1,9 @@ package codes.miley.frontend +import codes.miley.frontend.store.BlogStore import codes.miley.frontend.store.ExperienceStore +import codes.miley.frontend.widget.blogList +import codes.miley.frontend.widget.blogPostView import codes.miley.frontend.widget.experienceList import codes.miley.frontend.widget.featuredExperiences import codes.miley.model.Category @@ -18,36 +21,32 @@ fun RenderContext.header( h2 { +"Miley Chandonnet" } h4 { +"Native Android Application Engineer" } - // TODO: Add filtering - // nav { - // ul { - // Category - // .values() - // .map { it.displayName } - // .forEach { route -> - // li { - // a { - // +route - // - // ExperienceStore.data - // .map { it.filterConfig?.selectedCategory } - // .distinctUntilChanged() - // .render { category -> - // className( - // if (category?.displayName == route) "selected" else "" - // ) - // } - // - // clicks.map { - // mapOf( - // "category" to route - // ) - // } handledBy router.navTo - // } - // } - // } - // } - // } + nav { + ul { + li { + a { + className(router.data.map { route -> + if (route["page"] == null) "selected" else "" + }.distinctUntilChanged()) + +"Home" + clicks.map { + mapOf("category" to "all") + } handledBy router.navTo + } + } + li { + a { + className(router.data.map { route -> + if (route["page"] == "blog") "selected" else "" + }.distinctUntilChanged()) + +"Blog" + clicks.map { + mapOf("page" to "blog") + } handledBy router.navTo + } + } + } + } } } @@ -59,18 +58,33 @@ fun main() { router .data .distinctUntilChanged() - .render { - it["category"] - ?.let(Category::valueOf) - ?.let { category -> - ExperienceStore.setSelectedCategory(category) + .render { route -> + val page = route["page"] + + header(router) + + when { + page == "blog" && route.containsKey("post") -> { + route["post"]?.let { BlogStore.selectPost(it) } + blogPostView() } - } + page == "blog" -> { + BlogStore.selectPost(null) + blogList(router) + } + else -> { + route["category"]?.let { name -> + Category.values().find { it.name == name } + }?.let { category -> + ExperienceStore.setSelectedCategory(category) + } - div("content") { - header(router) - featuredExperiences() - experienceList() - } + div("content") { + featuredExperiences() + experienceList() + } + } + } + } } } diff --git a/src/jsMain/kotlin/codes/miley/frontend/MarkdownRenderer.kt b/src/jsMain/kotlin/codes/miley/frontend/MarkdownRenderer.kt new file mode 100644 index 0000000..9ef1321 --- /dev/null +++ b/src/jsMain/kotlin/codes/miley/frontend/MarkdownRenderer.kt @@ -0,0 +1,15 @@ +package codes.miley.frontend + +import dev.fritz2.core.RenderContext + +@JsModule("marked") +@JsNonModule +external object Marked { + fun parse(markdown: String): String +} + +fun RenderContext.markdown(content: String) { + div("article-body") { + domNode.innerHTML = Marked.parse(content) + } +} diff --git a/src/jsMain/kotlin/codes/miley/frontend/store/BlogStore.kt b/src/jsMain/kotlin/codes/miley/frontend/store/BlogStore.kt new file mode 100644 index 0000000..fda23bc --- /dev/null +++ b/src/jsMain/kotlin/codes/miley/frontend/store/BlogStore.kt @@ -0,0 +1,17 @@ +package codes.miley.frontend.store + +import codes.miley.model.ALL_POSTS +import codes.miley.model.BlogPost +import dev.fritz2.core.RootStore + +object BlogStore : RootStore(State(), id = "blog") { + + data class State( + val posts: List = ALL_POSTS, + val selectedPostId: String? = null, + ) + + val selectPost = handle { state, postId: String? -> + state.copy(selectedPostId = postId) + } +} diff --git a/src/jsMain/kotlin/codes/miley/frontend/widget/BlogList.kt b/src/jsMain/kotlin/codes/miley/frontend/widget/BlogList.kt new file mode 100644 index 0000000..1265631 --- /dev/null +++ b/src/jsMain/kotlin/codes/miley/frontend/widget/BlogList.kt @@ -0,0 +1,42 @@ +package codes.miley.frontend.widget + +import codes.miley.frontend.store.BlogStore +import dev.fritz2.core.RenderContext +import dev.fritz2.routing.Router +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +fun RenderContext.blogList(router: Router>) { + div("blog-page") { + main { + section("blog-hero") { + h1 { +"Blog" } + p("subtitle") { + +"On observable cognition, agent architecture, and building software that thinks transparently." + } + } + + ul("post-list") { + BlogStore.data + .map { it.posts } + .distinctUntilChanged() + .render { posts -> + posts.forEach { post -> + li("post-item") { + a { + clicks.map { + mapOf("page" to "blog", "post" to post.id) + } handledBy router.navTo + + div("post-date") { +post.date } + div("post-title") { +post.title } + div("post-subtitle") { +post.subtitle } + p("post-excerpt") { +post.excerpt } + } + } + } + } + } + } + } +} diff --git a/src/jsMain/kotlin/codes/miley/frontend/widget/BlogPostView.kt b/src/jsMain/kotlin/codes/miley/frontend/widget/BlogPostView.kt new file mode 100644 index 0000000..d22f97d --- /dev/null +++ b/src/jsMain/kotlin/codes/miley/frontend/widget/BlogPostView.kt @@ -0,0 +1,46 @@ +package codes.miley.frontend.widget + +import codes.miley.frontend.markdown +import codes.miley.frontend.store.BlogStore +import codes.miley.model.BlogPost +import dev.fritz2.core.RenderContext +import dev.fritz2.core.href +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +fun RenderContext.blogPostView() { + div("blog-page") { + main { + BlogStore.data + .map { state -> state.posts.find { it.id == state.selectedPostId } } + .distinctUntilChanged() + .render { post -> + if (post != null) { + articleContent(post) + } + } + } + } +} + +private fun RenderContext.articleContent(post: BlogPost) { + article { + header("article-header") { + div("article-date") { +post.date } + h1 { +post.title } + p("article-subtitle") { +post.subtitle } + } + + markdown(post.content) + + div("article-cta") { + p { + a { + href("https://github.com/socket-link/ampere") + +"AMPERE" + } + +" is an open-source cognitive engine built in Kotlin Multiplatform where AI reasoning is transparent by design. It's early, it's opinionated, and it's available now." + } + } + } +}