Files
site-portfolio/public/en/cases/card-columns-obsidian/index.html
T

817 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: Card Columns, a view plugin for Obsidian Bases. It displays notes as cards arranged in columns, with tag filtering using AND/OR."
/>
<title>Card Columns, a plugin for Obsidian Bases: a case study</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article img,
.case-article video {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0 1rem;
}
.case-article ul,
.case-article ol {
margin: 0.5rem 0 1rem 1.25rem;
padding: 0;
}
.case-article li {
margin-bottom: 0.35rem;
}
.case-article a:not(.btn) {
color: var(--accent);
}
.case-article .case-brief {
list-style: none;
margin: 1.5rem 0 0;
padding: 1.1rem 1.25rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.85rem 1.5rem;
font-size: 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-md, 12px);
}
.case-article .case-brief li {
margin: 0;
}
.case-article .case-brief span {
display: block;
margin-bottom: 0.15rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.case-article .case-todo {
margin: 1rem 0 1.25rem;
padding: 0.9rem 1.1rem;
border-left: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-radius: 0 var(--radius-sm, 8px) var(--radius-sm, 8px) 0;
font-size: 0.92rem;
}
.case-article .case-todo p {
margin: 0 0 0.5rem;
}
.case-article .case-todo p:last-child {
margin-bottom: 0;
}
.case-article .case-todo__q {
font-weight: 600;
color: var(--accent);
}
.case-todo__label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
opacity: 0.85;
}
.case-article details.case-details {
margin: 0.75rem 0 1rem;
padding: 0.6rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-sm, 8px);
}
.case-article details.case-details > summary {
cursor: pointer;
font-weight: 600;
color: var(--accent);
padding: 0.25rem 0;
}
.case-article details.case-details[open] > summary {
margin-bottom: 0.5rem;
}
.case-block {
margin-top: 3rem;
padding-top: 2.5rem;
border-top: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
}
.case-block:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="card-columns">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Obsidian Bases · Plugin</p>
<h1 class="case-page__h1">
A Kanban view for Obsidian Bases<br />
that leaves your notes untouched
</h1>
<p class="case-page__sub">
Card Columns: Notes as cards in columns,<br />
filtered by AND/OR tags with just two clicks
</p>
<!-- МЕДИА: hero-спойлер, финальный экран плагина. Раскомментировать при вставке скрина.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/hero.webp" alt="Card Columns: card columns on top of the .base file" width="1920" height="1080" loading="eager" decoding="async" data-image-viewer />
<figcaption>"Columns" view on top of the note database</figcaption>
</figure>
-->
<ul class="case-brief">
<li>Role: Product Design and Development</li>
<li><span>Platform:</span> Obsidian Bases</li>
<li><span>Status</span>: Working plugin, open source (MIT)</li>
<li><span>Team: Solo</span>, side project</li>
<li><span>Status:</span> Draft response, to be replaced</li>
</ul>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
How long did it take from the initial idea to a working version? Weeks? A month of weekends?
</p>
<p>
Draft answer: I put it together in three weeks in the evenings, then spent another month refining it while
working on my own projects. Replace with the actual time.
</p>
</div>
<p class="section__prose">
Obsidian now has Bases, a separate file type that displays notes not as text, but
in various formats: tables, cards, and trees. The standard card view didn’t work for me: it’s
flat, with no grouping, and it’s hard to make sense of when there are hundreds of notes. I wanted to see them
arranged in columns, like a Kanban board, but without the extra overhead. That’s how the Card Columns view plugin came about.
</p>
<div class="case-block" id="kontekst">
<h2>Context</h2>
<p class="section__prose">
A “Bases” is neither a note nor a separate database. It’s a query to the vault: you select
notes using a filter, and the framework displays them in the chosen view. Obsidian itself has
a built-in card view, but it’s one-dimensional: no grouping by properties, no
proper filtering. I decided to create my own view that integrates into the
Bases toggle alongside the default ones.
</p>
<p class="section__prose">
The main limitation was in the framework itself: the plugin doesn’t store data, but rather renders what
the Bases query returns (Sort, Filter, Properties, Search from the toolbar). This means that all the logic
for data retrieval remains with Bases, and my task is solely to display and navigate the results.
</p>
<!-- МЕДИА: общий скрин/видео обзора — доска колонок поверх .base файла.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/overview.webp" alt="View of "Card Columns" in the .base file: notes on the columns" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Species Overview</figcaption>
</figure>
-->
</div>
<div class="case-block" id="pochemu-svoi">
<h2>Why not use a ready-made solution?</h2>
<p class="section__prose">
Before writing any code, I checked what was already available. Three options, none of them worked:
</p>
<p class="section__prose">
<strong>The default Bases card view.</strong>
It displays a continuous stream of cards without grouping.
When there are over a hundred notes, it becomes a feed where you have to scroll and squint at the titles.
I ruled that out first.
</p>
<p class="section__prose">
<strong>Kanban plugins from the community.</strong>
Most come with their own storage system:
the board exists separately from the vault, and notes are duplicated. This results in a second database
that needs to be synchronized. I didn’t need that.
</p>
<p class="section__prose">
<strong>The neighboring bases-kanban.</strong>
The closest in spirit: it hooks into Bases to create
a Kanban board. But it writes to the notes: dragging a card changes the frontmatter. For a view plugin,
this is a risk, which I’ll discuss below.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
The decision to forgo drag-and-drop isn’t a shortcoming—it’s a design choice. A view plugin should not
write to user data. This decision shaped everything else.
</p>
</div>
</aside>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
Did you actually install and test other people’s plugins, or did you just go by the descriptions? Which
ones did you look at specifically?
</p>
<p>
Preliminary answer: I installed three of them, ran them for a week on my database, then uninstalled them. I looked at the source code for bases-kanban
but didn’t install it. Replace with actual information; include the plugin names.
</p>
</div>
</div>
<div class="case-block" id="zadacha">
<h2>Task</h2>
<p class="section__prose">
Design a view for Bases that displays notes as cards arranged in columns and allows you to
quickly select the ones you need. Strict constraint: the plugin does not write anything to the notes and does not store
its own state, and sorting, filtering, properties, and search from the standard Bases toolbar must
work as before.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
The main risk is creating a view that conflicts with the standard Bases toolbar. If that happens,
the user loses the sorting and search capabilities—the very features that make Bases worth using. This could not
be allowed to happen.
</p>
</div>
</aside>
</div>
<div class="case-block" id="reshenie">
<h2>Solutions</h2>
<p class="section__prose">
The plugin adds a “Columns” view to Bases. Cards are built from the results of a Bases query;
grouping is defined by a property, and filtering is done via clickable tags. Next, there are five options
that determine the result. Each one includes something I decided against.
</p>
<!-- МЕДИА: скриншот вида с колонками и карточками внутри.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/board.webp" alt="Columns of cards grouped by property" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>View with columns</figcaption>
</figure>
-->
<h3>Grouping by any property</h3>
<p class="section__prose">
Columns are generated using the “Group by” option in the Bases toolbar, by tag, status, category, or any
frontmatter property. Change the property in the query, and the board reloads automatically. This way, a single
database covers different perspectives: projects by status, notes by tags, tasks by
priority.
</p>
<h3>Tag filter by AND and OR</h3>
<p class="section__prose">
This is exactly what the plugin was designed for. Below the toolbar, there are tag icons; when clicked, they
filter cards across all columns at once:
</p>
<ul class="section__prose">
<li>Left-click on a tag to show only that tag (resets the other filters).</li>
<li>Left-click on the next tag to show notes with ANY of the selected tags (OR mode).</li>
<li>Ctrl+Click or right-click to switch tags without resetting the selection.</li>
<li>The OR/AND button: toggle between “any of” and “all of” for multiple tags.</li>
<li>All: Clear all filters.</li>
</ul>
<p class="section__prose">
The combination of “OR—add with a left-click + exact toggle via Ctrl” turned out to be intuitive:
quick selection doesn’t disrupt what’s already selected. I immediately rejected the alternative—moving the filter to a modal window—
because it requires an extra click and loses the context of what’s already been filtered.
</p>
<p class="section__prose">
The choice here isn’t about the feature itself, but where it lives. I chose the pills below the toolbar instead of
a separate filter window because filtering by tags is an action performed at every step of working
with the database. A modal window turns it into an interrupted workflow: open, configure, close, check.
The pill-shaped icons keep the result right in front of you, and the filter status is always visible.
</p>
<!-- МЕДИА: короткое видео фильтрации по тегам (OR / AND).
<figure class="case-media" style="margin: 0.85rem 0 1.2rem">
<div class="case-video-shell">
<video controls preload="metadata" playsinline poster="../assets/card-columns-obsidian/filter-poster.webp" data-case-video-player style="display: block; width: 100%; height: auto; margin: 0">
<source src="../assets/card-columns-obsidian/filter.webm" type="video/webm" />
<source src="../assets/card-columns-obsidian/filter.mp4" type="video/mp4" />
Your browser does not support video.
</video>
</div>
<figcaption>Filtering by tags: OR and AND</figcaption>
</figure>
-->
<h3>Cards and Covers</h3>
<p class="section__prose">
Properties such as chips, text, tags, links, Booleans, and dates are displayed for each note. You can
show a cover: from the first image in the note or from a separate property. You have
full control over the cover, including aspect ratio (Auto, 1:1, 3:2, 4:3, 16:9), border, fit
(cover/contain), orientation, and position on the card (above the title, below it, at the end).
</p>
<h3>Solution: Settings have been moved out of the code</h3>
<p class="section__prose">
At first, the parameters were hardcoded for my database. This was a mistake: the view would stop working
for someone else’s scenario. I moved everything to the gear menu, dividing it into four sections so that the view
could adapt to different databases, rather than just my single use case. More than 15 parameters.
</p>
<details class="case-details">
<summary>Show the full list of settings</summary>
<p class="section__prose"><strong>General</strong></p>
<ul class="section__prose">
<li>Where to open the card: active panel, pop-up window, new tab, split to the right / down.</li>
<li>Card width (px).</li>
<li>Columns per group (1–6), grid within a column.</li>
<li>Zebra columns (alternating background colors for easy scanning).</li>
<li>Masonry layout (cards fill vertical gaps).</li>
</ul>
<p class="section__prose"><strong>Title</strong></p>
<ul class="section__prose">
<li>Title wrapping, bold title, font size (px).</li>
</ul>
<p class="section__prose"><strong>Properties</strong></p>
<ul class="section__prose">
<li>Chip layout: stack (vertical) or grid (compact in a row).</li>
<li>Wrap multi-line values, font size.</li>
<li>Date and date-time formats + locale (Moment.js).</li>
</ul>
<p class="section__prose"><strong>Cover</strong></p>
<ul class="section__prose">
<li>Source: none / first image in the post / cover property.</li>
<li>Style (borderless / with border), aspect ratio, orientation, fit, position on the card.</li>
</ul>
</details>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
Is it true about the hard-coded parameters? If not, replace it with the real reason for moving them
to the settings: user requests, multiple databases with different structures, whatever.
</p>
<p>
Preliminary answer: Yes, the first version was tailored to my database; the settings were added when
I needed to make the plugin available externally. I’ll check and fix it.
</p>
</div>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>
I implemented the split preview just like in Obsidian itself: left-click reuses the panel, Ctrl+click opens
a new tab within the split. Without this, on a wide screen, it’s inconvenient to
view cards next to the board.
</p>
</div>
</aside>
<h3>Works within the standard toolbar</h3>
<p class="section__prose">
The view hooks into the BasesView framework, so sorting, filtering, properties, and search from
the Bases toolbar remain functional. The plugin only renders the query results; the data is not
duplicated and does not depend on a separate storage.
</p>
<!-- МЕДИА: скриншот настроек (gear-меню) с секциями.
<figure class="case-media case-media--content">
<img src="../assets/card-columns-obsidian/settings.webp" alt="Plugin Settings Window: General, Title, Properties, Cover" width="1200" height="675" loading="lazy" decoding="async" data-image-viewer />
<figcaption>View Settings</figcaption>
</figure>
-->
</div>
<div class="case-block" id="rezultat">
<h2>The Result and How I Tested It</h2>
<p class="section__prose">
This view solved my problem: a Bases query now reads like a board, rather than a flat stream
of cards. Grouping by property + filtering by AND/OR tags—exactly what the default
view was missing. Everything runs on the Bases query, so moving the vault and syncing don’t
break anything.
</p>
<h3>Metrics: number of steps to filter</h3>
<p class="section__prose">
The main task I used to measure success: filter notes by two tags and find the right
card. In the standard card view, this involves scrolling through the feed and scanning the titles with your eyes. In
Columns, two clicks on the pills and the column is right in front of you.
</p>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
Can you run this test for real? Time 10 repetitions of the task: the default
view versus Columns. Or count the exact number of steps.
</p>
<p>
Preliminary answer: on a sample of 25 notes, filtering by two tags took about 40 seconds and
required scrolling through the feed; in Columns, two clicks, about 5 seconds. These are rough estimates—
replace them with actual measurements.
</p>
</div>
<h3>Test: Long-term usability in daily work</h3>
<p class="section__prose">
The second criterion is more subjective, but for a tool tailored to my needs, it’s more important: will I still be
using the plugin a month from now? I am—it’s become an integral part of my work with the database and doesn’t require
me to remember how to use it.
</p>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
Are there any external indicators: installs, GitHub stars, reviews, or questions in the issue tracker? Any
number confirming that you’re not the only one using the plugin.
</p>
<p>
Preliminary answer: The plugin currently needs to be installed manually; it hasn’t been approved for the community repository yet, so
there are only a handful of installs and no stars so far. If there’s actual data, include it here—
it’s more convincing than any self-report.
</p>
</div>
<p class="section__prose">
What I’d do differently. I deliberately chose not to implement drag-and-drop of cards between columns,
since that changes a property in the note, and for a view plugin, that poses a separate data integrity risk.
But I see a demand for this among users, and I might add it as an optional mode. For
now, since the plugin is maintained manually (it hasn’t been reviewed by the community directory yet), this is the only thing
preventing users from using it right away.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">✅</span>
<div class="case-callout__content">
<p>
Main conclusion: a view for Bases only works when it respects
the framework’s toolbar. I added a view, not a second storage system, and the notes
remained regular notes.
</p>
</div>
</aside>
</div>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">See also</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
aria-label="AI Assistant — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI Assistant</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Figma plugin</li>
</ul>
<p class="mp-card__desc">A Figma plugin with AI features.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
aria-label="Card Columns — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Obsidian Plugin</li>
</ul>
<p class="mp-card__desc">A plugin for displaying cards in columns in Obsidian.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
aria-label="This website — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">This website</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Development with AI IDE</li>
</ul>
<p class="mp-card__desc">Website development using AI IDE.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-whatsapp" href="https://wa.me/qr/E2AWW5ZHE3ANJ1" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/whatsapp.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">WhatsApp</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">Русский</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var l = links[i].getAttribute("data-lang");
links[i].setAttribute("href", (l === "en" ? "/en/" : "/") + base);
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>