<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-04-29T22:59:59+00:00</updated><id>/feed.xml</id><title type="html">Kevin Donnelly</title><subtitle>Personal blog and site.</subtitle><author><name>Kevin</name></author><entry><title type="html">Towards a Perfect Notes App</title><link href="/2026/04/22/now-onto-ruin/" rel="alternate" type="text/html" title="Towards a Perfect Notes App" /><published>2026-04-22T00:00:00+00:00</published><updated>2026-04-22T00:00:00+00:00</updated><id>/2026/04/22/now-onto-ruin</id><content type="html" xml:base="/2026/04/22/now-onto-ruin/"><![CDATA[<p>I’ve had the perfect notes app in my head for years. I’ve tried dozens of apps or “knowledge bases”, as some of them like to be called (usually before the pivot to be a Notion competitor targeting enterprise). For how I want to work, they all are missing core functionality. So I built my own.</p>

<p>I call it Ruin, after a line in the T. S. Eliot poem <em>The Waste Land</em>, which I am almost certainly misusing or misunderstanding:</p>
<blockquote>
  <p>These fragments I have shored against my ruins</p>
</blockquote>

<p>It’s certainly not the perfect note-taking application; I’m not even sure it’s good. The important thing is it now exists, for better or worse. It’s less an ‘app’ and more a concept car; let’s call it a thought-experiment because that sounds contemplative and sophisticated.</p>

<h2 id="now-onto-ruin">Now Onto Ruin</h2>
<p>Ruin was built with the goal of filling in functionality I’ve found missing from other notes apps. That makes it a bit peculiar, as I’ll explain below. Currently, it has two parts:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">ruin-cli</code>: A command line interface for managing and querying a vault (ie. a folder) of notes.</li>
  <li><code class="language-plaintext highlighter-rouge">lazyruin</code>: A terminal user interface for creating, querying, and viewing notes in a <em>fairly</em> user-friendly fashion (if you are used to terminals). Inspired by the great <a href="https://github.com/jesseduffield/lazygit">lazygit</a>.</li>
</ul>

<figure>
  <img src="/images/lazyruin-main.png" alt="Lazyruin terminal interface showing a project note with parents, queries, and inline tags" style="border-radius: 8px; max-width: 80%;" />
  <figcaption>The <code>lazyruin</code> Terminal User Interface (TUI)</figcaption>
</figure>

<p>You can install it via <code class="language-plaintext highlighter-rouge">brew</code> <a href="https://github.com/donnellyk/lazyruin#installation">here</a>. On first launch you’ll set up a vault and have the option of going through a tutorial. I recommend the tutorial, this blog post will explain some of the <em>what</em> and <em>why</em> of Ruin, but not necessarily the <em>how</em>. The tutorial and documentation in the repo(s) will explain more of that.</p>

<p>Eventually, I want Ruin to be a native Mac and iOS app. For now, it lives in the terminal so I can quickly iterate and polish the core ideas and UX. I want to answer questions like “is this any good?” and “should I just learn Org-mode?” before investing more.</p>

<h2 id="core-features">Core Features</h2>

<h3 id="tags">Tags</h3>
<p>For most notes apps, tags are an all-or-nothing thing: A note has the tag or it doesn’t. Ruin has two kinds of tags: global and inline. A global tag acts like a tag in any other notes app, it represents the whole note and is useful for cataloging and searching. An inline tag is meant to give context to a specific line of text. This difference allows you to extract, view, and act on those lines outside of the note it is written in. With this, tags can become a lightweight task tracker, reading list, or whatever you need.</p>

<p>For example, as an engineering manager, I write a lot of notes to myself—notes from a meeting, what I did yesterday, what I need to do tomorrow. As I’m thinking and writing, I find myself coming up with little bits of followup. So, I tag those lines with <code class="language-plaintext highlighter-rouge">#followup</code>; if they are timely I add a date like <code class="language-plaintext highlighter-rouge">@tomorrow</code> or <code class="language-plaintext highlighter-rouge">@next-week</code>.</p>

<p>This looks a lot like a todo because it functionally is. I could just put these bits into a todo app (I often do). However, I like to write and I don’t want to break that flow to put a bit of info somewhere else; I want to capture these little snippets of context as I think of them. I, then, don’t want to go hunt for them later.</p>

<h3 id="pick">Pick</h3>
<p>Ruin has the typical file search. You can easily get a list of every note that mentioned <code class="language-plaintext highlighter-rouge">#followup</code>. Ruin also has a tool called “Pick”, which allows you to query and extract those lines with inline tags into their own view or document.</p>

<figure>
<div class="ev-container" id="ev-container">
    <div class="ev-viewport" role="img" aria-label="Interactive visualization comparing Search and Extract modes for finding tagged content across multiple documents.">
        <div class="ev-controls">
            <div class="ev-mode-buttons">
                <button class="ev-btn ev-btn-mode" data-mode="search">
                    Search
                </button>
                <button class="ev-btn ev-btn-mode" data-mode="extract">
                    Extract
                </button>
            </div>
            <div class="ev-tag-group">
                <span class="ev-tag-label">Tags</span>
                <button class="ev-btn ev-btn-tag ev-active" data-tag="red">
                    <span class="ev-swatch" data-color="red"></span>#followup
                </button>
                <button class="ev-btn ev-btn-tag" data-tag="blue">
                    <span class="ev-swatch" data-color="blue"></span>#project
                </button>
                <button class="ev-btn ev-btn-tag" data-tag="purple">
                    <span class="ev-swatch" data-color="purple"></span>#bob
                </button>
            </div>
        </div>
        <div class="ev-stage">
            <div class="ev-stage-inner">
                <div class="ev-file-list-wrap">
                    <div class="ev-file-list-title">Results</div>
                    <div class="ev-file-list"></div>
                </div>
                <div class="ev-docs"><div class="ev-stripe"></div></div>
            </div>
            <!-- ev-stage-inner -->
            <div class="ev-extract-panel">
                <div class="ev-extract-panel-title"></div>
                <div class="ev-extract-panel-list"></div>
            </div>
            <div class="ev-nav-row">
                <button class="ev-nav ev-nav-prev" aria-label="Previous document">
                    <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
                        <path d="M6.5 1.5L3.5 5L6.5 8.5" />
                    </svg>
                </button>
                <div class="ev-file-chips"></div>
                <button class="ev-nav ev-nav-next" aria-label="Next document">
                    <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
                        <path d="M3.5 1.5L6.5 5L3.5 8.5" />
                    </svg>
                </button>
            </div>
            <div class="ev-caption"></div>
        </div>
    </div>
    <noscript><p>
            <em>Interactive visualization requires JavaScript.</em>
        </p></noscript>
</div>

<style>
    .ev-container {
        margin: 2rem 0;
        font-family: "Lato", "Helvetica Neue", Helvetica, sans-serif;
        -webkit-user-select: none;
        user-select: none;
    }
    .ev-controls {
        display: flex;
        align-items: center;
        padding: 0.6rem 0.75rem;
        flex-wrap: wrap;
        gap: 0.5rem;
        position: relative;
        z-index: 3;
    }
    .ev-mode-buttons {
        display: flex;
        gap: 0.35rem;
    }
    .ev-tag-group {
        display: flex;
        align-items: center;
        gap: 0.35rem;
        margin-left: 0.5rem;
        opacity: 0;
        pointer-events: none;
        transition: opacity 0.3s ease;
    }
    .ev-container.ev-mode-search .ev-tag-group,
    .ev-container.ev-mode-extract .ev-tag-group {
        opacity: 1;
        pointer-events: auto;
    }
    .ev-tag-label {
        font-size: 0.7rem;
        font-weight: 300;
        color: #999;
        margin-right: 0.15rem;
    }
    .ev-btn {
        font-family: inherit;
        font-size: 0.75rem;
        font-weight: 300;
        padding: 0.3rem 0.7rem;
        border: 1px solid #ddd;
        border-radius: 3px;
        background: #fafafa;
        background-image: none;
        text-shadow: none;
        color: #777;
        cursor: pointer;
        transition: all 0.15s ease;
        line-height: 1.2;
    }
    .ev-btn:hover {
        background: #f0f0f0;
        background-image: none;
        text-shadow: none;
        color: #444;
    }
    .ev-btn.ev-active {
        background: #333;
        background-image: none;
        color: #fff;
        border-color: #333;
    }
    .ev-btn-tag {
        padding: 0.3rem 0.55rem 0.3rem 0.45rem;
        display: flex;
        align-items: center;
        gap: 0.3rem;
        font-family: "Source Code Pro", Consolas, monospace;
    }
    .ev-swatch {
        display: inline-block;
        width: 12px;
        height: 12px;
        border-radius: 2px;
    }
    .ev-swatch[data-color="red"] {
        background: #d64045;
    }
    .ev-swatch[data-color="blue"] {
        background: #4a90d9;
    }
    .ev-swatch[data-color="purple"] {
        background: #9b59b6;
    }

    .ev-viewport {
        position: relative;
        min-height: 280px;
        background: #f9f9f9;
        border: 1px solid #e8e8e8;
        border-radius: 6px;
        overflow: hidden;
    }

    /* --- Stage --- */
    .ev-stage {
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 1.5rem 0.5rem 1rem;
        position: relative;
    }
    .ev-stage-inner {
        display: flex;
        align-items: flex-start;
        justify-content: center;
        width: 100%;
    }
    .ev-docs {
        display: flex;
        gap: 8px;
        align-items: flex-start;
        position: relative;
    }

    /* --- File list (search & extract modes) --- */
    .ev-file-list-wrap {
        width: 0;
        flex-shrink: 0;
        margin-right: 0;
        border: 1px solid transparent;
        border-radius: 4px;
        background: transparent;
        padding: 0;
        opacity: 0;
        pointer-events: none;
        overflow: hidden;
        box-sizing: border-box;
        transition:
            opacity 0.3s ease,
            width 0.3s ease,
            margin-right 0.3s ease,
            padding 0.3s ease,
            border-color 0.3s ease,
            background-color 0.3s ease;
    }
    .ev-container.ev-mode-search .ev-file-list-wrap {
        opacity: 1;
        pointer-events: auto;
        width: 110px;
        margin-right: 12px;
        padding: 6px;
        border-color: #e0e0e0;
        background: #fdfdfd;
    }
    .ev-file-list-title {
        font-size: 0.55rem;
        font-weight: 900;
        color: #aaa;
        text-transform: uppercase;
        letter-spacing: 0.05em;
        margin-bottom: 4px;
    }
    .ev-file-list {
        display: flex;
        flex-direction: column;
        gap: 2px;
    }
    .ev-file-item {
        font-size: 0.55rem;
        padding: 0.25rem 0.4rem;
        border-radius: 3px;
        color: #999;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        cursor: pointer;
        transition:
            background 0.15s ease,
            color 0.15s ease;
    }
    .ev-file-item:hover {
        background: #eee;
        color: #555;
    }
    .ev-file-item.ev-file-active {
        background: #333;
        color: #fff;
    }
    .ev-file-match {
        display: none;
        margin-top: 3px;
        height: 3px;
        gap: 1px;
    }
    .ev-container.ev-mode-extract .ev-file-match {
        display: flex;
    }
    .ev-nav-row {
        display: flex;
        gap: 0.75rem;
        margin-top: 1rem;
        opacity: 0;
        pointer-events: none;
        transition: opacity 0.3s ease;
    }
    .ev-container.ev-mode-search .ev-nav-row {
        opacity: 1;
        pointer-events: auto;
    }
    .ev-file-chips {
        display: none;
        flex-wrap: wrap;
        justify-content: center;
        align-items: center;
        gap: 4px;
        max-width: 100%;
        flex: 0 1 auto;
    }
    .ev-file-chip {
        font-size: 0.65rem;
        padding: 0.25rem 0.5rem;
        border-radius: 999px;
        border: 1px solid #ddd;
        background: #fff;
        color: #777;
        cursor: pointer;
        white-space: nowrap;
        font-family: "Source Code Pro", Consolas, monospace;
        transition:
            background 0.15s ease,
            color 0.15s ease,
            border-color 0.15s ease;
        -webkit-touch-callout: none;
        -webkit-tap-highlight-color: transparent;
    }
    .ev-file-chip:hover {
        background: #f0f0f0;
        color: #444;
    }
    .ev-file-chip.ev-chip-active {
        background: #333;
        color: #fff;
        border-color: #333;
    }
    /* Show chips when the sidebar is hidden (≤ 820px) */
    @media screen and (max-width: 820px) {
        .ev-container.ev-mode-search .ev-file-chips {
            display: flex;
        }
    }

    /* --- Caption --- */
    .ev-caption {
        margin-top: 1rem;
        font-size: 0.75rem;
        font-weight: 300;
        color: #777;
        text-align: center;
        padding: 0 1rem;
        min-height: 1em;
    }
    .ev-caption-swatch {
        display: inline-block;
        width: 0.7em;
        height: 0.7em;
        border-radius: 2px;
        vertical-align: middle;
        margin: 0 0.15em;
        position: relative;
        top: -1px;
    }
    .ev-caption-swatch[data-color="red"] {
        background: #d64045;
    }
    .ev-caption-swatch[data-color="blue"] {
        background: #4a90d9;
    }
    .ev-caption-swatch[data-color="purple"] {
        background: #9b59b6;
    }
    .ev-caption-tag-name {
        font-family: "Source Code Pro", Consolas, monospace;
        font-weight: 900;
        color: #444;
        margin-left: 0.2em;
        margin-right: 0.1em;
    }

    /* --- Nav arrows --- */
    .ev-nav {
        font-family: inherit;
        font-size: 1rem;
        font-weight: 300;
        width: 2rem;
        height: 2rem;
        border: 1px solid #ddd;
        border-radius: 50%;
        background: #fff;
        background-image: none;
        text-shadow: none;
        color: #999;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
        line-height: 1;
        flex-shrink: 0;
        line-height: 1;
        padding: 0;
        transition:
            background 0.15s ease,
            color 0.15s ease,
            border-color 0.15s ease;
    }
    .ev-nav:hover {
        background: #f0f0f0;
        background-image: none;
        text-shadow: none;
        color: #444;
        border-color: #bbb;
    }

    /* --- Extract panel (overlays the aligned docs) --- */
    .ev-extract-panel {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%) scale(0.96);
        width: calc(100% - 3rem);
        max-width: 420px;
        background: #fff;
        border: 1px solid #d0d0d0;
        border-radius: 6px;
        box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
        padding: 0.75rem 0.9rem;
        opacity: 0;
        pointer-events: none;
        transition:
            opacity 0.35s ease,
            transform 0.35s ease,
            top 0.35s ease;
        z-index: 5;
        box-sizing: border-box;
    }
    .ev-container.ev-mode-extract .ev-extract-panel {
        opacity: 1;
        transform: translate(-50%, -50%) scale(1);
        pointer-events: auto;
    }
    .ev-extract-panel-title {
        font-size: 0.7rem;
        color: #666;
        margin-bottom: 0.6rem;
        display: flex;
        align-items: center;
        gap: 0.35rem;
    }
    .ev-extract-panel-tag-name {
        font-family: "Source Code Pro", Consolas, monospace;
        font-weight: 900;
        color: #333;
    }
    .ev-extract-panel-count {
        color: #999;
        font-weight: 300;
        text-transform: none;
        letter-spacing: 0;
        margin-left: auto;
        font-size: 0.6rem;
    }
    .ev-extract-panel-list {
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
    }
    .ev-extract-panel-item {
        display: flex;
        flex-direction: column;
        gap: 3px;
    }
    .ev-extract-panel-item-label {
        font-size: 0.55rem;
        color: #999;
    }
    .ev-extract-panel-item-line {
        display: flex;
        gap: 2px;
        height: 12px;
        align-items: center;
    }
    .ev-extract-panel-item-line .ev-word {
        height: 12px;
        border-radius: 2px;
    }
    .ev-extract-panel-item-line .ev-tag-named {
        height: 12px;
        width: auto !important;
        padding: 0 5px;
        font-family: "Source Code Pro", Consolas, monospace;
        font-size: 8px;
        font-weight: 700;
        color: #fff;
        display: inline-flex;
        align-items: center;
        border-radius: 2px;
        white-space: nowrap;
        flex-shrink: 0;
    }

    /* Dim the docs behind the extract panel */
    .ev-container.ev-mode-extract .ev-docs {
        opacity: 0.35;
        transition: opacity 0.3s ease;
    }

    /* Peek mode: hold the pointer on a document to reveal the alignment beneath the panel */
    .ev-container.ev-mode-extract .ev-doc-card {
        cursor: pointer;
        touch-action: manipulation;
        -webkit-touch-callout: none;
        -webkit-tap-highlight-color: transparent;
    }
    @media (hover: hover) {
        .ev-container.ev-mode-extract .ev-docs:hover {
            opacity: 1;
        }
    }
    .ev-container.ev-mode-extract.ev-peek .ev-docs,
    .ev-container.ev-mode-extract.ev-peek .ev-docs:hover {
        opacity: 1;
    }
    .ev-container.ev-mode-extract.ev-peek .ev-extract-panel {
        transform: translate(-50%, 35%) scale(0.95);
        pointer-events: none;
    }

    /* --- Stripe (extract only) --- */
    .ev-stripe {
        position: absolute;
        left: 0;
        right: 0;
        height: 10px;
        border-radius: 2px;
        border: 1.5px dashed transparent;
        background: transparent;
        pointer-events: none;
        z-index: 1;
        opacity: 0;
        transition:
            top 0.4s ease,
            opacity 0.3s ease,
            background-color 0.3s ease,
            border-color 0.3s ease;
    }
    .ev-container.ev-mode-extract .ev-stripe {
        opacity: 1;
    }

    /* --- Document card --- */
    .ev-doc-card {
        width: 110px;
        background: #fdfdfd;
        border: 1px solid #e0e0e0;
        border-radius: 4px;
        padding: 10px;
        display: flex;
        flex-direction: column;
        gap: 5px;
        position: relative;
        box-sizing: border-box;
        flex-shrink: 0;
        transition:
            transform 0.4s ease,
            opacity 0.4s ease,
            box-shadow 0.3s ease;
    }
    .ev-doc-card-label {
        font-size: 0.55rem;
        color: #aaa;
        margin-bottom: 2px;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }
    .ev-doc-card.ev-selected {
        transform: scale(1.3);
        z-index: 2;
        box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
    }
    .ev-doc-card.ev-no-match {
        opacity: 0.3;
    }

    /* --- Lines --- */
    .ev-line {
        display: flex;
        gap: 2px;
        height: 4px;
    }
    .ev-word {
        height: 100%;
        border-radius: 1.5px;
        background: #d8d8d8;
        transition: background-color 0.3s ease;
    }
    .ev-word-tag {
        background: #d8d8d8;
    }
    .ev-word-tag.ev-tag-active {
        opacity: 1;
    }
    .ev-word-tag[data-color="red"].ev-tag-active {
        background: #d64045;
    }
    .ev-word-tag[data-color="blue"].ev-tag-active {
        background: #4a90d9;
    }
    .ev-word-tag[data-color="purple"].ev-tag-active {
        background: #9b59b6;
    }

    /* Tablet / narrow desktop: hide sidebar (would push docs out of view) */
    @media screen and (max-width: 820px) {
        .ev-file-list-wrap {
            display: none !important;
        }
        .ev-doc-card {
            width: 90px;
            padding: 8px;
        }
        .ev-doc-card .ev-doc-card-label {
            font-size: 0.5rem;
        }
    }

    /* Mobile */
    @media screen and (max-width: 500px) {
        .ev-doc-card {
            width: 44px;
            padding: 4px;
            gap: 2px;
        }
        .ev-doc-card .ev-line {
            height: 3px;
            gap: 1px;
        }
        .ev-doc-card .ev-doc-card-label {
            display: none;
        }
        .ev-docs {
            gap: 3px;
        }
        .ev-stage {
            padding: 1.25rem 0.25rem 0.75rem;
        }
        .ev-stripe {
            height: 9px;
        }
        .ev-file-list-wrap {
            display: none !important;
        }
        .ev-tag-label {
            display: none;
        }
        .ev-controls {
            padding: 0.5rem 0.5rem;
            gap: 0.4rem;
        }
        .ev-tag-group {
            margin-left: 0;
        }
        .ev-btn {
            font-size: 0.7rem;
            padding: 0.25rem 0.5rem;
        }
        .ev-btn-tag {
            padding: 0.25rem 0.45rem 0.25rem 0.4rem;
            gap: 0.25rem;
        }
        .ev-swatch {
            width: 10px;
            height: 10px;
        }
        .ev-extract-panel {
            width: calc(100% - 1.5rem);
            padding: 0.6rem 0.7rem;
        }
        .ev-extract-panel-item-line .ev-tag-named {
            font-size: 7px;
            padding: 0 4px;
            height: 11px;
        }
        .ev-extract-panel-item-line {
            height: 11px;
        }
        .ev-extract-panel-item-line .ev-word {
            height: 11px;
        }
        .ev-caption {
            font-size: 0.7rem;
            padding: 0 0.5rem;
        }
        .ev-nav {
            width: 2.4rem;
            height: 2.4rem;
        }
        .ev-nav-row {
            gap: 1rem;
            margin-top: 0.75rem;
        }
    }

    /* Touch-only behavior: hover affordance doesn't trigger, so keep docs slightly more visible */
    @media (hover: none) {
        .ev-container.ev-mode-extract .ev-docs {
            opacity: 0.5;
        }
    }
</style>

<script>
    (function () {
        var COLORS = { red: "#d64045", blue: "#4a90d9", purple: "#9b59b6" };
        var TAG_NAMES = { red: "#followup", blue: "#project", purple: "#bob" };

        var DOCUMENTS = [
            {
                label: "meeting-notes.md",
                lines: [
                    { t: "text", w: 92 },
                    { t: "text", w: 78 },
                    { t: "tag", c: "red", w: 28 },
                    { t: "text", w: 85 },
                    { t: "text", w: 64 },
                    { t: "text", w: 90 },
                    { t: "text", w: 70 },
                    { t: "text", w: 88 },
                    { t: "tag", c: "purple", w: 24 },
                    { t: "text", w: 76 },
                ],
            },
            {
                label: "project-plan.md",
                lines: [
                    { t: "text", w: 80 },
                    { t: "tag", c: "blue", w: 30 },
                    { t: "text", w: 95 },
                    { t: "text", w: 72 },
                    { t: "text", w: 88 },
                    { t: "text", w: 65 },
                    { t: "text", w: 82 },
                    { t: "tag", c: "red", w: 34 },
                    { t: "text", w: 76 },
                ],
            },
            {
                label: "weekly-review.md",
                lines: [
                    { t: "text", w: 88 },
                    { t: "text", w: 74 },
                    { t: "text", w: 92 },
                    { t: "tag", c: "red", w: 30 },
                    { t: "text", w: 68 },
                    { t: "text", w: 84 },
                    { t: "text", w: 78 },
                    { t: "tag", c: "purple", w: 22 },
                    { t: "tag", c: "blue", w: 28 },
                    { t: "text", w: 90 },
                ],
            },
            {
                label: "ideas.md",
                lines: [
                    { t: "text", w: 76 },
                    { t: "tag", c: "purple", w: 26 },
                    { t: "text", w: 90 },
                    { t: "text", w: 82 },
                    { t: "text", w: 70 },
                    { t: "text", w: 94 },
                    { t: "text", w: 66 },
                    { t: "text", w: 86 },
                    { t: "text", w: 72 },
                ],
            },
            {
                label: "standup-log.md",
                lines: [
                    { t: "text", w: 84 },
                    { t: "text", w: 70 },
                    { t: "tag", c: "blue", w: 34 },
                    { t: "text", w: 92 },
                    { t: "text", w: 78 },
                    { t: "text", w: 86 },
                    { t: "text", w: 68 },
                    { t: "text", w: 90 },
                ],
            },
        ];

        var state = { mode: "all", tag: "red", docIndex: 0 };
        var container = document.getElementById("ev-container");
        var docsEl = container.querySelector(".ev-docs");
        var fileListEl = container.querySelector(".ev-file-list");
        var stripe = container.querySelector(".ev-stripe");
        var captionEl = container.querySelector(".ev-caption");
        var panelTitle = container.querySelector(".ev-extract-panel-title");
        var panelList = container.querySelector(".ev-extract-panel-list");
        var chipsEl = container.querySelector(".ev-file-chips");
        var chips = [];
        var cards = [];
        var fileItems = [];

        function seededRng(seed) {
            return function () {
                seed = (seed * 16807 + 0) % 2147483647;
                return (seed - 1) / 2147483646;
            };
        }

        // Returns an array of { cls, width } segments for a line, consuming rng
        function lineSegments(line, rng) {
            var segs = [];
            if (line.t === "tag") {
                var before = 1 + Math.floor(rng() * 3);
                var after = 1 + Math.floor(rng() * 2);
                for (var b = 0; b < before; b++)
                    segs.push({
                        cls: "ev-word",
                        w: 8 + Math.floor(rng() * 16),
                    });
                segs.push({
                    cls: "ev-word ev-word-tag",
                    w: line.w,
                    color: line.c,
                });
                for (var a = 0; a < after; a++)
                    segs.push({
                        cls: "ev-word",
                        w: 8 + Math.floor(rng() * 16),
                    });
            } else {
                var filled = 0;
                while (filled < line.w - 5) {
                    var ww = 6 + Math.floor(rng() * 18);
                    if (filled + ww > line.w) ww = line.w - filled;
                    if (ww < 4) break;
                    segs.push({ cls: "ev-word", w: ww });
                    filled += ww + 2;
                }
            }
            return segs;
        }

        function buildLineEl(segs, className) {
            var row = document.createElement("div");
            row.className = className || "ev-line";
            segs.forEach(function (seg) {
                var el = document.createElement("span");
                el.className = seg.cls;
                el.style.width = seg.w + "%";
                if (seg.color) {
                    el.setAttribute("data-color", seg.color);
                    el.setAttribute("title", TAG_NAMES[seg.color]);
                }
                row.appendChild(el);
            });
            return row;
        }

        function buildCard(doc) {
            var card = document.createElement("div");
            card.className = "ev-doc-card";
            var label = document.createElement("div");
            label.className = "ev-doc-card-label";
            label.textContent = doc.label;
            card.appendChild(label);
            var rng = seededRng(doc.label.length * 97 + 13);
            doc.lines.forEach(function (line) {
                card.appendChild(
                    buildLineEl(lineSegments(line, rng), "ev-line"),
                );
            });
            return card;
        }

        // Get the segments for a specific line index in a doc, with the correct rng state
        function getLineSegs(doc, lineIndex) {
            var rng = seededRng(doc.label.length * 97 + 13);
            var segs;
            for (var j = 0; j <= lineIndex; j++) {
                segs = lineSegments(doc.lines[j], rng);
            }
            return segs;
        }

        function docHasTag(i) {
            return DOCUMENTS[i].lines.some(function (l) {
                return l.t === "tag" && l.c === state.tag;
            });
        }

        function selectFirstMatchingDoc() {
            for (var i = 0; i < DOCUMENTS.length; i++) {
                if (docHasTag(i)) {
                    state.docIndex = i;
                    return;
                }
            }
        }

        function render() {
            // Remove all children except the stripe
            while (docsEl.firstChild) {
                if (docsEl.firstChild === stripe) break;
                docsEl.removeChild(docsEl.firstChild);
            }
            while (stripe.nextSibling) {
                docsEl.removeChild(stripe.nextSibling);
            }
            cards = [];
            DOCUMENTS.forEach(function (doc) {
                var card = buildCard(doc);
                docsEl.appendChild(card);
                cards.push(card);
            });
            renderFileList();
            renderChips();
            update();
        }

        function renderChips() {
            chipsEl.innerHTML = "";
            chips = [];
            DOCUMENTS.forEach(function (doc, i) {
                var chip = document.createElement("button");
                chip.className = "ev-file-chip";
                chip.setAttribute("data-index", i);
                chip.textContent = doc.label;
                chipsEl.appendChild(chip);
                chips.push(chip);
            });
        }

        function updateChips() {
            chips.forEach(function (chip, i) {
                var hasTag = docHasTag(i);
                chip.style.display = hasTag ? "" : "none";
                chip.classList.toggle("ev-chip-active", i === state.docIndex);
            });
        }

        function renderFileList() {
            fileListEl.innerHTML = "";
            fileItems = [];
            DOCUMENTS.forEach(function (doc, i) {
                var item = document.createElement("div");
                item.className = "ev-file-item";
                item.setAttribute("data-index", i);

                var label = document.createElement("div");
                label.textContent = doc.label;
                item.appendChild(label);

                // Build match line previews for each tag color, using the exact same segments as the card
                ["red", "blue", "purple"].forEach(function (color) {
                    for (var j = 0; j < doc.lines.length; j++) {
                        if (
                            doc.lines[j].t === "tag" &&
                            doc.lines[j].c === color
                        ) {
                            var segs = getLineSegs(doc, j);
                            var matchEl = buildLineEl(segs, "ev-file-match");
                            matchEl.setAttribute("data-match-color", color);
                            // Activate the tag color in the preview
                            var tagWord = matchEl.querySelector(".ev-word-tag");
                            if (tagWord) tagWord.classList.add("ev-tag-active");
                            item.appendChild(matchEl);
                            break;
                        }
                    }
                });

                fileListEl.appendChild(item);
                fileItems.push(item);
            });
        }

        function updateExtractPanel() {
            // Build the list of matching lines
            panelList.innerHTML = "";
            var count = 0;
            DOCUMENTS.forEach(function (doc, i) {
                for (var j = 0; j < doc.lines.length; j++) {
                    if (
                        doc.lines[j].t === "tag" &&
                        doc.lines[j].c === state.tag
                    ) {
                        var item = document.createElement("div");
                        item.className = "ev-extract-panel-item";

                        var segs = getLineSegs(doc, j);
                        var lineEl = buildLineEl(
                            segs,
                            "ev-extract-panel-item-line",
                        );
                        var tagWord = lineEl.querySelector(".ev-word-tag");
                        if (tagWord) {
                            tagWord.classList.add("ev-tag-active");
                            tagWord.classList.add("ev-tag-named");
                            tagWord.textContent = TAG_NAMES[state.tag];
                            tagWord.style.width = "";
                        }
                        item.appendChild(lineEl);

                        var label = document.createElement("div");
                        label.className = "ev-extract-panel-item-label";
                        label.textContent = doc.label;
                        item.appendChild(label);

                        panelList.appendChild(item);
                        count++;
                        break;
                    }
                }
            });

            // Title
            panelTitle.innerHTML =
                '<span class="ev-caption-swatch" data-color="' +
                state.tag +
                '"></span><span class="ev-extract-panel-tag-name">' +
                TAG_NAMES[state.tag] +
                "</span>" +
                '<span class="ev-extract-panel-count">' +
                count +
                " across " +
                count +
                " notes</span>";
        }

        function updateCaption() {
            var swatch =
                '<span class="ev-caption-swatch" data-color="' +
                state.tag +
                '"></span>';
            var name =
                '<span class="ev-caption-tag-name">' +
                TAG_NAMES[state.tag] +
                "</span>";
            var tag = swatch + name;
            var text;
            if (state.mode === "all") {
                text = "All notes in a vault";
            } else if (state.mode === "search") {
                text =
                    "Search filters to the " +
                    tag +
                    " tag, viewing one at a time";
            } else {
                text =
                    "Extraction takes snippets matching the " +
                    tag +
                    " tag, viewable all together in a separate context";
            }
            captionEl.innerHTML = text;
        }

        function updateFileList() {
            var isSearch = state.mode === "search";
            fileItems.forEach(function (item, i) {
                var hasTag = docHasTag(i);
                item.style.display = hasTag ? "" : "none";
                item.classList.toggle(
                    "ev-file-active",
                    isSearch && i === state.docIndex,
                );
                // Show only the match line for the active tag
                var matches = item.querySelectorAll(".ev-file-match");
                for (var j = 0; j < matches.length; j++) {
                    matches[j].style.display =
                        matches[j].getAttribute("data-match-color") ===
                        state.tag
                            ? ""
                            : "none";
                }
            });
        }

        function update() {
            var isAll = state.mode === "all";
            var isSearch = state.mode === "search";
            var isExtract = state.mode === "extract";

            updateFileList();
            updateChips();
            updateCaption();
            updateExtractPanel();

            // Tag highlighting
            var tags = docsEl.querySelectorAll(".ev-word-tag");
            for (var i = 0; i < tags.length; i++) {
                if (isAll || tags[i].getAttribute("data-color") === state.tag) {
                    tags[i].classList.add("ev-tag-active");
                } else {
                    tags[i].classList.remove("ev-tag-active");
                }
            }

            // Per-card state
            var tagIndices = [];
            var maxIdx = 0;
            if (isExtract) {
                tagIndices = DOCUMENTS.map(function (doc) {
                    for (var j = 0; j < doc.lines.length; j++) {
                        if (
                            doc.lines[j].t === "tag" &&
                            doc.lines[j].c === state.tag
                        )
                            return j;
                    }
                    return -1;
                });
                maxIdx = Math.max.apply(
                    null,
                    tagIndices.filter(function (x) {
                        return x >= 0;
                    }),
                );
            }

            var lineStep = 9;
            var isMobile = window.innerWidth <= 500;
            if (isMobile) lineStep = 6;

            cards.forEach(function (card, idx) {
                var hasTag = docHasTag(idx);

                // Dimming
                if (isAll) {
                    card.classList.remove("ev-no-match");
                } else {
                    card.classList.toggle("ev-no-match", !hasTag);
                }

                // Selected (search only)
                card.classList.toggle(
                    "ev-selected",
                    isSearch && idx === state.docIndex,
                );

                // Vertical offset (extract only)
                if (isExtract && tagIndices[idx] >= 0) {
                    var offset = (maxIdx - tagIndices[idx]) * lineStep;
                    card.style.transform = "translateY(" + offset + "px)";
                } else {
                    card.style.transform = "";
                }
            });

            // Stripe positioning (extract only)
            if (isExtract) {
                // Measure actual positions from the first matching card's tag line
                var refIdx = -1;
                for (var ri = 0; ri < DOCUMENTS.length; ri++) {
                    if (tagIndices[ri] >= 0) {
                        refIdx = ri;
                        break;
                    }
                }
                var stripeH = 10;
                var stripeTop = 0;
                if (refIdx >= 0) {
                    var refCard = cards[refIdx];
                    var tagEl =
                        refCard.querySelectorAll(".ev-line")[
                            tagIndices[refIdx]
                        ];
                    // Position relative to card top, then add card's translateY offset
                    var cardTop = refCard.offsetTop;
                    var tagOffsetInCard = tagEl.offsetTop;
                    var tagH = tagEl.offsetHeight;
                    var cardTranslateY =
                        (maxIdx - tagIndices[refIdx]) * lineStep;
                    var tagCenter =
                        cardTop +
                        cardTranslateY +
                        tagOffsetInCard +
                        tagH / 2 -
                        0.5;
                    stripeTop = tagCenter - stripeH / 2;
                }

                stripe.style.top = stripeTop + "px";
                var c = COLORS[state.tag];
                var r = parseInt(c.slice(1, 3), 16),
                    g = parseInt(c.slice(3, 5), 16),
                    b = parseInt(c.slice(5, 7), 16);
                stripe.style.backgroundColor =
                    "rgba(" + r + "," + g + "," + b + ",0.08)";
                stripe.style.borderColor = c;
            }
        }

        function setMode(mode) {
            state.mode = mode;
            container.className = "ev-container ev-mode-" + mode;
            container.querySelectorAll(".ev-btn-mode").forEach(function (btn) {
                btn.classList.toggle(
                    "ev-active",
                    btn.getAttribute("data-mode") === mode,
                );
            });
            if (mode === "search") selectFirstMatchingDoc();
            update();
        }

        function setTag(tag) {
            state.tag = tag;
            container.querySelectorAll(".ev-btn-tag").forEach(function (btn) {
                btn.classList.toggle(
                    "ev-active",
                    btn.getAttribute("data-tag") === tag,
                );
            });
            if (state.mode === "search") selectFirstMatchingDoc();
            update();
        }

        function navigate(dir) {
            for (var step = 0; step < DOCUMENTS.length; step++) {
                var next =
                    (state.docIndex + dir + DOCUMENTS.length) %
                    DOCUMENTS.length;
                state.docIndex = next;
                if (docHasTag(next)) break;
            }
            update();
        }

        container.addEventListener("click", function (e) {
            var btn = e.target.closest(".ev-btn-mode");
            if (btn) {
                var m = btn.getAttribute("data-mode");
                setMode(state.mode === m ? "all" : m);
                return;
            }
            btn = e.target.closest(".ev-btn-tag");
            if (btn) {
                setTag(btn.getAttribute("data-tag"));
                return;
            }
            var fileItem = e.target.closest(".ev-file-item");
            if (fileItem) {
                var idx = parseInt(fileItem.getAttribute("data-index"), 10);
                if (docHasTag(idx)) {
                    state.docIndex = idx;
                    update();
                }
                return;
            }
            var chip = e.target.closest(".ev-file-chip");
            if (chip) {
                var cidx = parseInt(chip.getAttribute("data-index"), 10);
                if (docHasTag(cidx)) {
                    state.docIndex = cidx;
                    update();
                }
                return;
            }
            if (e.target.closest(".ev-nav-prev")) {
                navigate(-1);
                return;
            }
            if (e.target.closest(".ev-nav-next")) {
                navigate(1);
                return;
            }
        });

        // Peek-through: in extract mode, hold pointer outside the panel to see the alignment
        var stage = container.querySelector(".ev-stage");
        function startPeek(e) {
            if (state.mode !== "extract") return;
            // Only trigger when pressing on a document card
            if (!e.target.closest(".ev-doc-card")) return;
            container.classList.add("ev-peek");
            e.preventDefault();
        }
        function endPeek() {
            container.classList.remove("ev-peek");
        }
        stage.addEventListener("mousedown", startPeek);
        window.addEventListener("mouseup", endPeek);
        stage.addEventListener("touchstart", startPeek, { passive: false });
        window.addEventListener("touchend", endPeek);
        window.addEventListener("touchcancel", endPeek);

        // Init
        container.className = "ev-container ev-mode-all";
        render();
    })();
</script>

<figcaption>An interactive example of the more common straight-forward file search and more dynamic line extraction </figcaption>
</figure>

<p>With this you can:</p>
<ul>
  <li>View all lines you’ve marked as <code class="language-plaintext highlighter-rouge">#followup</code></li>
  <li>View all lines you marked as <code class="language-plaintext highlighter-rouge">#followup</code> for a specific project <code class="language-plaintext highlighter-rouge">#important-project-dont-screw-up</code></li>
  <li>View all lines you’ve marked with <code class="language-plaintext highlighter-rouge">#followup @YYYY-MM-DD</code> on that specific day.</li>
</ul>

<p>The tag <code class="language-plaintext highlighter-rouge">#followup</code> is not special here, it is just the tag I decided to use; you can create any tag you’d like. I use different tags like <code class="language-plaintext highlighter-rouge">#idea</code> and <code class="language-plaintext highlighter-rouge">#bug</code> to represent different kinds of action items but that is just my workflow. You can mark a line as done with <code class="language-plaintext highlighter-rouge">#done</code>, which is a special tag in Ruin for this use-case. By default, <code class="language-plaintext highlighter-rouge">#done</code> lines don’t show up in Pick results and are dimmed in notes.</p>

<figure>
  <img src="/images/lazyruin-pick.png" alt="Lazyruin Pick view showing extracted #followup lines from across multiple notes" style="border-radius: 8px; max-width: 80%;" />
  <figcaption>Showing all <code>#followup</code> lines extracted via Pick</figcaption>
</figure>

<h3 id="composition">Composition</h3>
<p>In most notes apps, where you write a line is where it stays. To read a passage, you open that note and scroll to it. Some, like Notion, try to be a bit more dynamic (by being a database dressed up like a notes app). The trade-off for this is a more fiddly experience; typing in one of these apps doesn’t feel like typing anywhere else on your computer.</p>

<p>Ruin attempts to find a middle ground through composing smaller notes into larger documents. These can be as permanent or temporary as you’d like, depending on your needs and current priorities. Currently, there are three ways to compose a document:</p>
<ul>
  <li>Parent-&gt;Child relationships between notes</li>
  <li><code class="language-plaintext highlighter-rouge">![[note]]</code> embeds within the text of a note, <code class="language-plaintext highlighter-rouge">![[note#header]]</code> is also supported for smaller sections</li>
  <li><code class="language-plaintext highlighter-rouge">![[search|pick|compose|query: &lt;query&gt;]]</code> dynamic embeds within the text of a note</li>
</ul>

<figure>
<div class="cv-container" id="cv-container">
  <div class="cv-viewport" role="img" aria-label="Interactive visualization showing how notes compose into larger documents through parent-child relationships, embeds, and dynamic queries.">
    <div class="cv-controls">
      <div class="cv-mode-buttons">
        <button class="cv-btn cv-btn-mode" data-mode="parent-child">Parent / Child</button>
        <button class="cv-btn cv-btn-mode" data-mode="embed">Embed</button>
        <button class="cv-btn cv-btn-mode" data-mode="query">Dynamic</button>
      </div>
      <div class="cv-sub-group">
        <button class="cv-btn cv-btn-sub" data-sub="individual">Individual</button>
        <button class="cv-btn cv-btn-sub" data-sub="composed">Composed</button>
      </div>
    </div>
    <div class="cv-stage">
      <div class="cv-notes"></div>
      <div class="cv-composed">
        <div class="cv-composed-title"></div>
        <div class="cv-composed-body"></div>
      </div>
      <div class="cv-caption"></div>
    </div>
  </div>
  <noscript><p><em>Interactive visualization requires JavaScript.</em></p></noscript>
</div>

<style>
.cv-container {
  margin: 2rem 0;
  font-family: 'Lato', 'Helvetica Neue', Helvetica, sans-serif;
  -webkit-user-select: none;
  user-select: none;
}
.cv-viewport {
  position: relative;
  min-height: 280px;
  background: #f9f9f9;
  border: 1px solid #e8e8e8;
  border-radius: 6px;
  overflow: hidden;
}
.cv-controls {
  display: flex;
  align-items: center;
  padding: 0.6rem 0.75rem;
  flex-wrap: wrap;
  gap: 0.5rem;
  position: relative;
  z-index: 3;
}
.cv-mode-buttons {
  display: flex;
  gap: 0.35rem;
}
.cv-sub-group {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  margin-left: 0.5rem;
}
.cv-btn {
  font-family: inherit;
  font-size: 0.75rem;
  font-weight: 300;
  padding: 0.3rem 0.7rem;
  border: 1px solid #ddd;
  border-radius: 3px;
  background: #fafafa;
  background-image: none;
  text-shadow: none;
  color: #777;
  cursor: pointer;
  transition: all 0.15s ease;
  line-height: 1.2;
}
.cv-btn:hover {
  background: #f0f0f0;
  background-image: none;
  text-shadow: none;
  color: #444;
}
.cv-btn.cv-active {
  background: #333;
  background-image: none;
  color: #fff;
  border-color: #333;
}

/* --- Stage --- */
.cv-stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 1.5rem 0.5rem 1rem;
  position: relative;
  min-height: 200px;
}

/* --- Source note cards --- */
.cv-notes {
  display: flex;
  gap: 10px;
  align-items: flex-start;
  position: relative;
  transition: opacity 0.3s ease;
}
.cv-container.cv-show-composed .cv-notes {
  opacity: 0.35;
}
.cv-note-card {
  width: 110px;
  background: #fdfdfd;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  padding: 10px;
  padding-top: 12px;
  display: flex;
  flex-direction: column;
  gap: 5px;
  position: relative;
  box-sizing: border-box;
  flex-shrink: 0;
  overflow: hidden;
}
.cv-note-card-accent {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
}
.cv-note-card-label {
  font-size: 0.55rem;
  margin-bottom: 2px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Ref syntax lines inside note cards */
.cv-card-ref {
  font-family: 'Source Code Pro', Consolas, monospace;
  font-size: 7px;
  color: #aaa;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* --- Graph layout (parent-child mode) --- */
.cv-notes-graph {
  display: grid !important;
  grid-template-columns: auto auto auto;
  grid-template-rows: auto auto;
  align-items: center;
  justify-content: center;
  column-gap: 90px;
  row-gap: 8px;
  position: relative;
}
.cv-graph-svg {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
}
.cv-graph-svg text {
  font-family: 'Source Code Pro', Consolas, monospace;
  fill: #bbb;
  font-size: 8px;
}

/* --- Lines (shared between cards and composed panel) --- */
.cv-line {
  display: flex;
  gap: 2px;
  height: 4px;
}
.cv-word {
  height: 100%;
  border-radius: 1.5px;
  background: #d8d8d8;
}
.cv-word-bold {
  background: #bbb;
}
.cv-word-tag {
  border-radius: 2px;
}

/* --- Composed panel (slides up from bottom) --- */
.cv-composed {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, 80%);
  width: calc(100% - 3rem);
  max-width: 420px;
  background: #fff;
  border: 1px solid #d0d0d0;
  border-radius: 6px;
  box-shadow: 0 6px 24px rgba(0,0,0,0.12);
  padding: 0.75rem 0.9rem;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease, transform 0.4s ease-out;
  z-index: 5;
  box-sizing: border-box;
}
.cv-container.cv-show-composed .cv-composed {
  opacity: 1;
  transform: translate(-50%, -50%);
  pointer-events: auto;
}
.cv-composed-title {
  font-size: 0.7rem;
  color: #666;
  margin-bottom: 0.6rem;
  display: flex;
  align-items: center;
  gap: 0.35rem;
}
.cv-composed-title-name {
  font-weight: 900;
  color: #333;
}
.cv-composed-title-detail {
  color: #999;
  font-weight: 300;
}
.cv-composed-body {
  display: flex;
  flex-direction: column;
  gap: 0;
}

/* Composed line: accent bar + word blocks */
.cv-composed-line {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 2.5px 0;
}
.cv-composed-line-accent {
  width: 3px;
  align-self: stretch;
  border-radius: 1.5px;
  flex-shrink: 0;
}
.cv-composed-line-words {
  display: flex;
  gap: 2px;
  height: 6px;
  flex: 1;
  align-items: center;
}
.cv-composed-line-words .cv-word {
  height: 6px;
  border-radius: 2px;
}
.cv-composed-line-words .cv-word-bold {
  background: #bbb;
}
.cv-composed-line-words .cv-word-tag {
  background: #d8d8d8;
}

/* Markdown-style headings in composed panel */
.cv-composed-heading {
  display: flex;
  align-items: baseline;
  gap: 6px;
  padding: 5px 0 2px;
}
.cv-composed-heading-accent {
  width: 3px;
  align-self: stretch;
  border-radius: 1.5px;
  flex-shrink: 0;
}
.cv-composed-heading-prefix {
  font-family: 'Source Code Pro', Consolas, monospace;
  color: #ccc;
  flex-shrink: 0;
}
.cv-composed-heading-text {
  font-weight: 900;
  color: #444;
}
.cv-composed-heading.cv-h1 .cv-composed-heading-prefix,
.cv-composed-heading.cv-h1 .cv-composed-heading-text { font-size: 0.7rem; }
.cv-composed-heading.cv-h2 .cv-composed-heading-prefix,
.cv-composed-heading.cv-h2 .cv-composed-heading-text { font-size: 0.6rem; }
.cv-composed-heading.cv-h3 .cv-composed-heading-prefix,
.cv-composed-heading.cv-h3 .cv-composed-heading-text { font-size: 0.55rem; }

/* Tag color in dynamic/query and all modes */
.cv-container.cv-mode-query .cv-word-tag {
  background: #d64045;
}

/* Query reference line in composed panel */
.cv-composed-ref {
  font-family: 'Source Code Pro', Consolas, monospace;
  font-size: 0.55rem;
  color: #999;
  padding: 3px 0 1px;
  margin-left: 9px;
}

/* --- Caption --- */
.cv-caption {
  margin-top: 1rem;
  font-size: 0.75rem;
  font-weight: 300;
  color: #777;
  text-align: center;
  padding: 0 1rem;
  min-height: 1em;
}
.cv-caption code {
  font-family: 'Source Code Pro', Consolas, monospace;
  font-size: inherit;
  background: #f0f0f0;
  padding: 0 3px;
  border-radius: 2px;
  color: #555;
}

/* --- Peek-through --- */
.cv-container.cv-show-composed .cv-note-card {
  cursor: pointer;
  touch-action: manipulation;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {
  .cv-container.cv-show-composed .cv-notes:hover {
    opacity: 1;
  }
}
.cv-container.cv-show-composed.cv-peek .cv-notes,
.cv-container.cv-show-composed.cv-peek .cv-notes:hover {
  opacity: 1;
}
.cv-container.cv-show-composed.cv-peek .cv-composed {
  transform: translate(-50%, 30%);
  opacity: 0.3;
  pointer-events: none;
}

/* --- Responsive: tablet --- */
@media screen and (max-width: 820px) {
  .cv-note-card {
    width: 90px;
    padding: 8px;
    padding-top: 10px;
  }
  .cv-note-card .cv-note-card-label {
    font-size: 0.5rem;
  }
  .cv-card-ref {
    font-size: 6px;
  }
  .cv-notes-graph {
    column-gap: 70px;
  }
}

/* --- Responsive: mobile --- */
@media screen and (max-width: 500px) {
  .cv-note-card {
    width: 60px;
    padding: 5px;
    padding-top: 7px;
    gap: 3px;
  }
  .cv-note-card .cv-line {
    height: 3px;
    gap: 1px;
  }
  .cv-note-card .cv-note-card-label {
    display: none;
  }
  .cv-card-ref {
    font-size: 5px;
  }
  .cv-notes {
    gap: 4px;
  }
  .cv-notes-graph {
    grid-template-columns: auto auto !important;
    grid-template-rows: auto auto auto !important;
    column-gap: 10px !important;
    row-gap: 30px !important;
  }
  .cv-graph-svg text {
    display: none;
  }
  .cv-stage {
    padding: 1.25rem 0.25rem 0.75rem;
  }
  .cv-composed {
    width: calc(100% - 1.5rem);
    padding: 0.6rem 0.7rem;
  }
  .cv-composed-line-words {
    height: 5px;
  }
  .cv-composed-line-words .cv-word {
    height: 5px;
  }
  .cv-controls {
    padding: 0.5rem 0.5rem;
    gap: 0.4rem;
  }
  .cv-btn {
    font-size: 0.7rem;
    padding: 0.25rem 0.5rem;
  }
  .cv-caption {
    font-size: 0.7rem;
    padding: 0 0.5rem;
  }
}

@media screen and (max-width: 500px) {
  .cv-graph-svg text {
    display: none;
  }
}

@media (hover: none) {
  .cv-container.cv-show-composed .cv-notes {
    opacity: 0.5;
  }
}
</style>

<script>
(function() {
  var NOTES = [
    {
      id: 'a', label: 'weekly-review.md', color: '#4a90d9',
      title: 'Weekly Review',
      lines: [
        { type: 'header', w: 60 },
        { type: 'text', w: 85 },
        { type: 'text', w: 72 },
        { type: 'tag', w: 90, tagW: 24 },
        { type: 'text', w: 65 },
      ]
    },
    {
      id: 'b', label: 'project-alpha.md', color: '#d97706',
      title: 'Project Alpha',
      lines: [
        { type: 'header', w: 55 },
        { type: 'text', w: 78 },
        { type: 'text', w: 82 },
        { type: 'text', w: 70 },
      ]
    },
    {
      id: 'c', label: '1-on-1-bob.md', color: '#059669',
      title: '1-on-1 Bob',
      lines: [
        { type: 'header', w: 68 },
        { type: 'tag', w: 88, tagW: 22 },
        { type: 'text', w: 76 },
        { type: 'text', w: 60 },
      ]
    },
    {
      id: 'd', label: 'updates.md', color: '#8b5cf6',
      title: 'Updates',
      lines: [
        { type: 'header', w: 50 },
        { type: 'text', w: 75 },
        { type: 'tag', w: 68, tagW: 22 },
      ]
    }
  ];

  var noteMap = {};
  NOTES.forEach(function(n) { noteMap[n.id] = n; });

  /* Per-mode card line overrides: injects ref syntax into specific cards */
  var CARD_LINES = {
    'embed': {
      'a': [
        { type: 'line', idx: 0 },
        { type: 'line', idx: 1 },
        { type: 'ref', text: '![[project-alpha]]' },
        { type: 'line', idx: 2 },
        { type: 'line', idx: 3 },
        { type: 'ref', text: '![[1-on-1-bob#summary]]' },
        { type: 'line', idx: 4 },
      ]
    },
    'query': {
      'a': [
        { type: 'line', idx: 0 },
        { type: 'line', idx: 1 },
        { type: 'line', idx: 2 },
        { type: 'ref', text: '![[pick: #followup]]' },
        { type: 'line', idx: 3 },
        { type: 'line', idx: 4 },
      ]
    }
  };

  /* Composed panel content for each mode */
  var COMP = {
    'parent-child': {
      titleName: 'weekly-review.md',
      titleDetail: 'parent with children',
      items: [
        { type: 'heading', text: 'Weekly Review', noteId: 'a', level: 1 },
        { type: 'lines', noteId: 'a', indices: [1,2,3,4] },
        { type: 'heading', text: 'Project Alpha', noteId: 'b', level: 2 },
        { type: 'lines', noteId: 'b', indices: [1,2,3] },
        { type: 'heading', text: 'Updates', noteId: 'd', level: 3 },
        { type: 'lines', noteId: 'd', indices: [1,2] },
        { type: 'heading', text: '1-on-1 Bob', noteId: 'c', level: 2 },
        { type: 'lines', noteId: 'c', indices: [1,2,3] },
      ]
    },
    'embed': {
      titleName: 'weekly-review.md',
      titleDetail: 'with embeds resolved',
      items: [
        { type: 'lines', noteId: 'a', indices: [0,1] },
        { type: 'heading', text: 'Project Alpha', noteId: 'b', level: 2 },
        { type: 'lines', noteId: 'b', indices: [1,2,3] },
        { type: 'lines', noteId: 'a', indices: [2,3] },
        { type: 'heading', text: '1-on-1 Bob', noteId: 'c', level: 2 },
        { type: 'lines', noteId: 'c', indices: [1] },
        { type: 'lines', noteId: 'a', indices: [4] },
      ]
    },
    'query': {
      titleName: 'weekly-review.md',
      titleDetail: 'with dynamic query',
      items: [
        { type: 'lines', noteId: 'a', indices: [0,1,2] },
        { type: 'ref', text: '![[pick: #followup]]' },
        { type: 'lines', noteId: 'a', indices: [3] },
        { type: 'heading', text: '1-on-1 Bob', noteId: 'c', level: 2 },
        { type: 'lines', noteId: 'c', indices: [1] },
        { type: 'heading', text: 'Updates', noteId: 'd', level: 2 },
        { type: 'lines', noteId: 'd', indices: [2] },
        { type: 'lines', noteId: 'a', indices: [4] },
      ]
    }
  };

  var state = { mode: 'parent-child', sub: 'individual' };
  var container = document.getElementById('cv-container');
  var notesEl = container.querySelector('.cv-notes');
  var composedTitle = container.querySelector('.cv-composed-title');
  var composedBody = container.querySelector('.cv-composed-body');
  var captionEl = container.querySelector('.cv-caption');

  /* --- Utilities --- */

  function seededRng(seed) {
    return function() {
      seed = (seed * 16807 + 0) % 2147483647;
      return (seed - 1) / 2147483646;
    };
  }

  function lineSegments(line, rng) {
    var segs = [];
    if (line.type === 'header') {
      var filled = 0;
      while (filled < line.w - 8) {
        var ww = 12 + Math.floor(rng() * 18);
        if (filled + ww > line.w) ww = line.w - filled;
        if (ww < 6) break;
        segs.push({ cls: 'cv-word cv-word-bold', w: ww });
        filled += ww + 2;
      }
    } else if (line.type === 'tag') {
      var before = 1 + Math.floor(rng() * 3);
      var after = 1 + Math.floor(rng() * 2);
      for (var b = 0; b < before; b++)
        segs.push({ cls: 'cv-word', w: 8 + Math.floor(rng() * 16) });
      segs.push({ cls: 'cv-word cv-word-tag', w: line.tagW });
      for (var a = 0; a < after; a++)
        segs.push({ cls: 'cv-word', w: 8 + Math.floor(rng() * 16) });
    } else {
      var filled = 0;
      while (filled < line.w - 5) {
        var ww = 6 + Math.floor(rng() * 18);
        if (filled + ww > line.w) ww = line.w - filled;
        if (ww < 4) break;
        segs.push({ cls: 'cv-word', w: ww });
        filled += ww + 2;
      }
    }
    return segs;
  }

  function getNoteLineSegs(note, lineIndex) {
    var rng = seededRng(note.label.length * 97 + 13);
    var segs;
    for (var j = 0; j <= lineIndex; j++) {
      segs = lineSegments(note.lines[j], rng);
    }
    return segs;
  }

  function buildLineEl(segs, className) {
    var row = document.createElement('div');
    row.className = className || 'cv-line';
    segs.forEach(function(seg) {
      var el = document.createElement('span');
      el.className = seg.cls;
      el.style.width = seg.w + '%';
      row.appendChild(el);
    });
    return row;
  }

  /* --- Card building --- */

  function buildNoteCard(note) {
    var card = document.createElement('div');
    card.className = 'cv-note-card';

    var accent = document.createElement('div');
    accent.className = 'cv-note-card-accent';
    accent.style.background = note.color;
    card.appendChild(accent);

    var label = document.createElement('div');
    label.className = 'cv-note-card-label';
    label.textContent = note.label;
    label.style.color = note.color;
    card.appendChild(label);

    var overrides = CARD_LINES[state.mode] && CARD_LINES[state.mode][note.id];
    var rng = seededRng(note.label.length * 97 + 13);

    if (overrides) {
      var allSegs = [];
      note.lines.forEach(function(line) {
        allSegs.push(lineSegments(line, rng));
      });
      overrides.forEach(function(item) {
        if (item.type === 'line') {
          card.appendChild(buildLineEl(allSegs[item.idx], 'cv-line'));
        } else {
          var ref = document.createElement('div');
          ref.className = 'cv-card-ref';
          ref.textContent = item.text;
          card.appendChild(ref);
        }
      });
    } else {
      note.lines.forEach(function(line) {
        card.appendChild(buildLineEl(lineSegments(line, rng), 'cv-line'));
      });
    }
    return card;
  }

  /* --- Rendering --- */

  function renderNotes() {
    notesEl.innerHTML = '';
    if (state.mode === 'parent-child') {
      renderGraph();
    } else {
      renderRow();
    }
  }

  function renderRow() {
    notesEl.className = 'cv-notes';
    NOTES.forEach(function(note) {
      notesEl.appendChild(buildNoteCard(note));
    });
  }

  function isVerticalGraph() {
    return window.innerWidth <= 500;
  }

  function renderGraph() {
    notesEl.className = 'cv-notes cv-notes-graph';
    var nodes = {};
    var vert = isVerticalGraph();

    function place(el, col, row, id) {
      el.style.gridColumn = col;
      el.style.gridRow = row;
      notesEl.appendChild(el);
      if (id) nodes[id] = el;
    }

    var parentWrap = document.createElement('div');
    parentWrap.appendChild(buildNoteCard(NOTES[0]));

    var child1 = document.createElement('div');
    child1.appendChild(buildNoteCard(NOTES[1]));

    var grandchild = document.createElement('div');
    grandchild.appendChild(buildNoteCard(NOTES[3]));

    var child2 = document.createElement('div');
    child2.appendChild(buildNoteCard(NOTES[2]));

    if (vert) {
      parentWrap.style.justifySelf = 'center';
      place(parentWrap, '1 / 3', '1', 'parent');
      place(child1, '1', '2', 'child1');
      place(child2, '2', '2', 'child2');
      grandchild.style.justifySelf = 'center';
      place(grandchild, '1', '3', 'grandchild');
    } else {
      place(parentWrap, '1', '1 / 3', 'parent');
      place(child1, '2', '1', 'child1');
      place(grandchild, '3', '1', 'grandchild');
      place(child2, '2', '2', 'child2');
    }

    requestAnimationFrame(function() { drawConnections(nodes, vert); });
  }

  function drawConnections(nodes, vert) {
    var cRect = notesEl.getBoundingClientRect();

    function cardRect(node) {
      var r = node.querySelector('.cv-note-card').getBoundingClientRect();
      return {
        left:   r.left - cRect.left,
        right:  r.right - cRect.left,
        top:    r.top - cRect.top,
        bottom: r.bottom - cRect.top,
        cx:     (r.left + r.right) / 2 - cRect.left,
        cy:     (r.top + r.bottom) / 2 - cRect.top
      };
    }

    var ns = 'http://www.w3.org/2000/svg';
    var svg = document.createElementNS(ns, 'svg');
    svg.setAttribute('class', 'cv-graph-svg');

    var defs = document.createElementNS(ns, 'defs');
    var marker = document.createElementNS(ns, 'marker');
    marker.setAttribute('id', 'cv-ah');
    marker.setAttribute('markerWidth', '6');
    marker.setAttribute('markerHeight', '5');
    marker.setAttribute('refX', '5.5');
    marker.setAttribute('refY', '2.5');
    marker.setAttribute('orient', 'auto');
    var ap = document.createElementNS(ns, 'path');
    ap.setAttribute('d', 'M0,0.5 L5,2.5 L0,4.5');
    ap.setAttribute('fill', 'none');
    ap.setAttribute('stroke', '#ccc');
    ap.setAttribute('stroke-width', '1.2');
    ap.setAttribute('stroke-linecap', 'round');
    ap.setAttribute('stroke-linejoin', 'round');
    marker.appendChild(ap);
    defs.appendChild(marker);
    svg.appendChild(defs);

    var R = 8;

    function makePath(d) {
      var path = document.createElementNS(ns, 'path');
      path.setAttribute('d', d);
      path.setAttribute('fill', 'none');
      path.setAttribute('stroke', '#ccc');
      path.setAttribute('stroke-width', '1.5');
      path.setAttribute('marker-end', 'url(#cv-ah)');
      svg.appendChild(path);
    }

    function addLabel(x, y, text) {
      var el = document.createElementNS(ns, 'text');
      el.setAttribute('x', x);
      el.setAttribute('y', y);
      el.setAttribute('text-anchor', 'middle');
      el.textContent = text;
      svg.appendChild(el);
    }

    function connectH(fromId, toId, label) {
      var f = cardRect(nodes[fromId]);
      var t = cardRect(nodes[toId]);
      var x1 = f.right, y1 = f.cy;
      var x2 = t.left,  y2 = t.cy;
      var dy = y2 - y1;
      var d;

      if (Math.abs(dy) < 2) {
        d = 'M' + x1 + ',' + y1 + ' L' + x2 + ',' + y2;
        if (label) addLabel((x1 + x2) / 2, y1 - 6, label);
      } else {
        var forkX = x1 + (x2 - x1) * 0.35;
        var r = Math.min(R, Math.abs(dy) / 2, (x2 - forkX) / 2);
        if (dy < 0) {
          d = 'M' + x1 + ',' + y1 +
              ' L' + forkX + ',' + y1 +
              ' L' + forkX + ',' + (y2 + r) +
              ' Q' + forkX + ',' + y2 + ' ' + (forkX + r) + ',' + y2 +
              ' L' + x2 + ',' + y2;
        } else {
          d = 'M' + x1 + ',' + y1 +
              ' L' + forkX + ',' + y1 +
              ' L' + forkX + ',' + (y2 - r) +
              ' Q' + forkX + ',' + y2 + ' ' + (forkX + r) + ',' + y2 +
              ' L' + x2 + ',' + y2;
        }
        if (label) addLabel((forkX + r + x2) / 2, y2 - 6, label);
      }
      makePath(d);
    }

    function connectV(fromId, toId) {
      var f = cardRect(nodes[fromId]);
      var t = cardRect(nodes[toId]);
      var x1 = f.cx, y1 = f.bottom;
      var x2 = t.cx, y2 = t.top;
      var dx = x2 - x1;
      var d;

      if (Math.abs(dx) < 2) {
        d = 'M' + x1 + ',' + y1 + ' L' + x2 + ',' + y2;
      } else {
        var forkY = y1 + (y2 - y1) * 0.35;
        var r = Math.min(R, Math.abs(dx) / 2, (y2 - forkY) / 2);
        if (dx < 0) {
          d = 'M' + x1 + ',' + y1 +
              ' L' + x1 + ',' + forkY +
              ' L' + (x2 + r) + ',' + forkY +
              ' Q' + x2 + ',' + forkY + ' ' + x2 + ',' + (forkY + r) +
              ' L' + x2 + ',' + y2;
        } else {
          d = 'M' + x1 + ',' + y1 +
              ' L' + x1 + ',' + forkY +
              ' L' + (x2 - r) + ',' + forkY +
              ' Q' + x2 + ',' + forkY + ' ' + x2 + ',' + (forkY + r) +
              ' L' + x2 + ',' + y2;
        }
      }
      makePath(d);
    }

    if (vert) {
      connectV('parent', 'child1');
      connectV('parent', 'child2');
      connectV('child1', 'grandchild');
    } else {
      connectH('parent', 'child1', 'parent of');
      connectH('parent', 'child2', 'parent of');
      connectH('child1', 'grandchild', 'parent of');
    }

    notesEl.appendChild(svg);
  }

  function buildComposedLine(noteId, lineIndex) {
    var note = noteMap[noteId];
    var segs = getNoteLineSegs(note, lineIndex);

    var row = document.createElement('div');
    row.className = 'cv-composed-line';

    var bar = document.createElement('div');
    bar.className = 'cv-composed-line-accent';
    bar.style.background = note.color;
    row.appendChild(bar);

    var words = document.createElement('div');
    words.className = 'cv-composed-line-words';
    segs.forEach(function(seg) {
      var el = document.createElement('span');
      el.className = seg.cls;
      el.style.width = seg.w + '%';
      words.appendChild(el);
    });
    row.appendChild(words);
    return row;
  }

  function buildHeading(item) {
    var note = noteMap[item.noteId];
    var el = document.createElement('div');
    el.className = 'cv-composed-heading cv-h' + item.level;

    var bar = document.createElement('div');
    bar.className = 'cv-composed-heading-accent';
    bar.style.background = note.color;
    el.appendChild(bar);

    var prefix = document.createElement('span');
    prefix.className = 'cv-composed-heading-prefix';
    prefix.textContent = '#'.repeat(item.level);
    el.appendChild(prefix);

    var text = document.createElement('span');
    text.className = 'cv-composed-heading-text';
    text.textContent = item.text;
    el.appendChild(text);

    return el;
  }

  function renderComposed() {
    composedBody.innerHTML = '';
    if (state.sub !== 'composed') return;

    var mode = COMP[state.mode];
    composedTitle.innerHTML =
      '<span class="cv-composed-title-name">' + mode.titleName + '</span>' +
      '<span class="cv-composed-title-detail">' + mode.titleDetail + '</span>';

    mode.items.forEach(function(item) {
      switch (item.type) {
        case 'lines':
          item.indices.forEach(function(li) {
            composedBody.appendChild(buildComposedLine(item.noteId, li));
          });
          break;
        case 'heading':
          composedBody.appendChild(buildHeading(item));
          break;
        case 'ref':
          var ref = document.createElement('div');
          ref.className = 'cv-composed-ref';
          ref.textContent = item.text;
          composedBody.appendChild(ref);
          break;
      }
    });
  }

  function updateCaption() {
    var text;
    var m = state.mode;
    var s = state.sub;
    if (m === 'parent-child' && s === 'individual') {
      text = 'Notes linked by parent-child relationships';
    } else if (m === 'parent-child') {
      text = 'Child notes compose under a parent, viewed as a single document';
    } else if (m === 'embed' && s === 'individual') {
      text = '<code>![[note]]</code> syntax references another note\'s content';
    } else if (m === 'embed') {
      text = '<code>![[note]]</code> inlines another note\'s content in place';
    } else if (m === 'query' && s === 'individual') {
      text = '<code>![[pick: #tag]]</code> syntax queries tagged lines across notes';
    } else if (m === 'query') {
      text = '<code>![[pick: #followup]]</code> dynamically pulls matching tagged lines';
    }
    captionEl.innerHTML = text;
  }

  function applyContainerClasses() {
    var cls = 'cv-container cv-mode-' + state.mode;
    if (state.sub === 'composed') {
      cls += ' cv-show-composed';
    }
    container.className = cls;
  }

  function updateButtons() {
    container.querySelectorAll('.cv-btn-mode').forEach(function(btn) {
      btn.classList.toggle('cv-active', btn.getAttribute('data-mode') === state.mode);
    });
    container.querySelectorAll('.cv-btn-sub').forEach(function(btn) {
      btn.classList.toggle('cv-active', btn.getAttribute('data-sub') === state.sub);
    });
  }

  function update() {
    applyContainerClasses();
    updateButtons();
    renderNotes();
    renderComposed();
    updateCaption();
  }

  /* --- Mode switching --- */

  function setMode(mode) {
    if (state.mode === mode) return;
    state.mode = mode;
    state.sub = 'individual';
    update();
  }

  function setSubMode(sub) {
    if (state.sub === sub) return;
    state.sub = sub;
    update();
  }

  /* --- Events --- */

  container.addEventListener('click', function(e) {
    var btn = e.target.closest('.cv-btn-mode');
    if (btn) { setMode(btn.getAttribute('data-mode')); return; }
    btn = e.target.closest('.cv-btn-sub');
    if (btn) { setSubMode(btn.getAttribute('data-sub')); return; }
  });

  /* Peek-through */
  var stage = container.querySelector('.cv-stage');
  function startPeek(e) {
    if (state.sub !== 'composed') return;
    if (!e.target.closest('.cv-note-card')) return;
    container.classList.add('cv-peek');
    e.preventDefault();
  }
  function endPeek() {
    container.classList.remove('cv-peek');
  }
  stage.addEventListener('mousedown', startPeek);
  window.addEventListener('mouseup', endPeek);
  stage.addEventListener('touchstart', startPeek, { passive: false });
  window.addEventListener('touchend', endPeek);
  window.addEventListener('touchcancel', endPeek);

  /* Redraw graph on resize (SVG positions are absolute) */
  var resizeTimer;
  window.addEventListener('resize', function() {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function() {
      if (state.mode === 'parent-child') renderNotes();
    }, 150);
  });

  /* Init */
  update();
})();
</script>

<figcaption>An interactive example of the types of composition</figcaption>
</figure>

<p>These three methods are not mutually exclusive, they can be combined in a single document to suit your (current and ever-changing) needs.</p>

<h3 id="global-input">Global Input</h3>
<p>An area I found neglected by most notes apps is quickly jotting something down. Todo apps, particularly Todoist and Things 3, do this very well with a ‘Quick Capture’ modal that can popup anywhere on your system with a compact but full-featured input.</p>

<figure>
  <img src="/images/global-input.png" alt="Things 3 Quick Add popup" style="border-radius: 8px; max-width: 80%;" />
  <figcaption>Things 3's Quick Entry modal</figcaption>
</figure>

<p>I’m not sitting down and writing novels, I’m capturing thoughts that could fit on a <a href="https://en.wikipedia.org/wiki/Zettelkasten">note card</a>. I tend to want to get ideas down somewhere quickly. The longer they are in my head, the more likely I will forget because I unlocked my phone, opened a new app/tab, or walked through a doorway.</p>

<p>A goal for the Mac version of Ruin will be to have a global-hotkey that brings up a small, fully featured modal. You’ll be able to write a note, complete with the formatting and suggestions available in the main application to get your idea out of your head as fast as possible.</p>

<p>This is currently a little limited with the terminal-version. <code class="language-plaintext highlighter-rouge">$ lazyruin --new</code> launches directly to the New Note dialog and quits on save (or if you close the dialog), so you can approximate the functionality with a script and third-party launcher such as Raycast.</p>

<figure>
  <img src="/images/lazyruin-input.png" alt="Lazyruin new note dialog with inline tag suggestions and date completion" style="border-radius: 8px; max-width: 80%;" />
  <figcaption>New Note dialog via global shortcut (from third-party app launcher)</figcaption>
</figure>

<h3 id="daily-notes">Daily Notes</h3>
<p>A common feature in notes apps is a note tied to a specific date. You get a new one every day, hence the name ‘Daily Note’. Any notes app can have them, even if they don’t support it; just make a new note every morning with today’s date.</p>

<p>It’s nice to have a blank note every morning and a default place to put things. However, every time you go to write something down you are left with a question: where does it go? Is it a ‘Daily Note’ kind of idea or an evergreen thing that should live elsewhere? Every time you want to recall something, do you go look in your daily notes or your project notes?</p>

<p>Ruin avoids this issue by having a dynamic date view. You can see every note that was created or updated on a given day as well as any inline tags / todos that are marked with that date via the <code class="language-plaintext highlighter-rouge">@YYYY-MM-DD</code> syntax. This way, you never have to think about where something needs to go. Just get it out of your head now and find it again later.</p>

<figure>
  <img src="/images/lazyruin-daily.png" alt="Lazyruin daily view showing notes, tags, and todos for a given day" style="border-radius: 8px; max-width: 80%;" />
  <figcaption>Dynamic daily view, populated with all notes from today</figcaption>
</figure>

<h2 id="other-features">Other Features</h2>

<h3 id="markdown-notes">Markdown Notes</h3>
<p>Notes are just <code class="language-plaintext highlighter-rouge">markdown.md</code> files. Your vault is just a folder. Sync them however you’d like, take them to another notes app when you want. Ruin syntax is broadly compatible with Obsidian, but some differences exist.</p>

<p>Key information is stored in note <a href="https://www.markdownlang.com/advanced/frontmatter.html">frontmatter</a> and plaintext indexes (stored in your vault in <code class="language-plaintext highlighter-rouge">/.ruin</code>), to speed up queries in very large vaults.</p>

<p>You can edit the contents of your vault however you’d like, either through the cli, another notes app, a script, or manually. Just run <code class="language-plaintext highlighter-rouge">ruin doctor</code> afterwards and ruin’s internal indexes will update based on your changes.</p>

<h3 id="version-control">Version Control</h3>
<p>Ruin uses <code class="language-plaintext highlighter-rouge">git</code>, if installed on your system, to keep track of any changes. If you ever need to roll something back, interact with the vault as if it’s any other repo.</p>

<h3 id="wikilinks">Wikilinks</h3>
<p>Easily link across notes in your vault with <code class="language-plaintext highlighter-rouge">[[wiki-style]]</code> links. When you save a note, the first H1 is extracted as the title for easy reference in wikilinks and search queries.</p>

<h3 id="links">Links</h3>
<p>Links are a special kind of note, automatically tagged with <code class="language-plaintext highlighter-rouge">#link</code> if a URL is the first line of a note. If you add a link via the New Link dialog, a title and summary is automatically resolved from the link.</p>

<figure>
  <img src="/images/lazyruin-links.png" alt="Lazyruin new link dialog with auto-resolved title and summary" style="border-radius: 8px; max-width: 80%;" />
  <figcaption>Adding a link with auto-resolved title and summary</figcaption>
</figure>

<h3 id="fast-standard--native">Fast, Standard, &amp; Native</h3>
<p>The CLI and TUI are written in Go. The Mac and iOS apps will be written in Swift with the standard UI library. The CLI will always represent the source of truth and be used by all Ruin implementations.</p>

<h3 id="ai-ready">AI-Ready</h3>
<p>With the ruin-cli and markdown files on disk, AI agents have ample read/write access to your vault, if you want to chat with your notes. A skill (along with helpful Raycast scripts) will be posted in a separate repo shortly.</p>

<h2 id="what-comes-next">What Comes Next</h2>
<p>As I said, I see this release as a test bed / proving ground of these ideas.</p>

<p>Eventually, I aim to build highly-polished, fully-native iOS and Mac apps. I expect these to be closed-source and come with some sort of payment model. ‘Free to start, paid once you get above some number of notes, monthly or a higher priced one-time lifetime purchase option’ is current structure in my head. The CLI and TUI will remain free forever. The ruin CLI (or a cross-compiled version of it) will always be the source-of-truth for behavior and functionality, with the GUIs being as dumb as possible and focused on polish and OS integration.</p>

<p>Because this is a test bed, there is a chance I end up not exactly clicking with the mental model I’ve built here, the mental model that has been in my head as “a good idea” for years. There is a chance it’s too complicated, too fussy for how I <em>actually</em> want to use a tool like that. Only time will tell and now I have a platform to prove it.</p>]]></content><author><name>Kevin</name></author><summary type="html"><![CDATA[I’ve had the perfect notes app in my head for years. I’ve tried dozens of apps or “knowledge bases”, as some of them like to be called (usually before the pivot to be a Notion competitor targeting enterprise). For how I want to work, they all are missing core functionality. So I built my own.]]></summary></entry></feed>