PageSourceSearch

https://koaning.io/about/

html koaning.io collected 2026-10-03 21:38:31 UTC 68,273 bytes, 1,588 lines download raw bytes

1<!doctype html>
2<html lang="en" class="light">
3    <head>
4        <meta charset="UTF-8" />
5        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6        <title>About</title>
7        
8
9        <!-- Fonts -->
10        <link rel="preconnect" href="https://fonts.googleapis.com" />
11        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12        <link
13            href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;700&display=swap"
14            rel="stylesheet"
15        />
16
17        <!-- Favicons -->
18        <link
19            rel="apple-touch-icon"
20            sizes="180x180"
21            href="/static/favicon_io/apple-touch-icon.png"
22        />
23        <link
24            rel="icon"
25            type="image/png"
26            sizes="32x32"
27            href="/static/favicon_io/favicon-32x32.png"
28        />
29        <link
30            rel="icon"
31            type="image/png"
32            sizes="16x16"
33            href="/static/favicon_io/favicon-16x16.png"
34        />
35        <link rel="manifest" href="/static/favicon_io/site.webmanifest" />
36
37        
37<script src="https://cdn.tailwindcss.com"></script>
37
38        
38<script src="https://unpkg.com/alpinejs" defer></script>
38
39        
39<script
40            defer
41            data-domain="koaning.io"
42            src="https://plausible.io/js/script.js"
43        ></script>
43
44        
44<script>
45            tailwind.config = {
46                darkMode: "class",
47                theme: {
48                    extend: {
49                        colors: {
50                            primary: "#0969da",
51                            secondary: "#24292f",
52                            "dark-primary": "#58a6ff",
53                            "dark-secondary": "#c9d1d9",
54                            "dark-bg": "#0d1117",
55                            "dark-card": "#161b22",
56                        },
57                        fontFamily: {
58                            sans: ["IBM Plex Sans", "Helvetica", "Arial", "sans-serif"],
59                            mono: [
60                                "JetBrains Mono",
61                                "Courier New",
62                                "Consolas",
63                                "monospace",
64                            ],
65                        },
66                    },
67                },
68            };
69        </script>
69
70
71        <link
72            rel="stylesheet"
73            href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css"
74            integrity="sha384-zh0CIslj+VczCZtlzBcjt5ppRcsAmDnRem7ESsYwWwg3m/OaJ2l4x7YBZl9Kxxib"
75            crossorigin="anonymous"
76        />
77        
77<script
78            defer
79            src="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js"
80            integrity="sha384-Rma6DA2IPUwhNxmrB/7S3Tno0YY7sFu9WSYMCuulLhIqYSGZ2gKCJWIqhBWqMQfh"
81            crossorigin="anonymous"
82        ></script>
82
83        
83<script
84            defer
85            src="https://cdn.jsdelivr.net/npm/[email protected]/dist/contrib/auto-render.min.js"
86            integrity="sha384-hCXGrW6PitJEwbkoStFjeJxv+fSOOQKOPbJxSfM6G5sWZjAyWhXiTIIAmQqnlLlh"
87            crossorigin="anonymous"
88        ></script>
88
89        
89<script>
90            document.addEventListener("DOMContentLoaded", function () {
91                renderMathInElement(document.body, {
92                    // customised options
93                    // • auto-render specific keys, e.g.:
94                    delimiters: [
95                        { left: "$$", right: "$$", display: true },
96                        { left: "$", right: "$", display: false },
97                        { left: "\\(", right: "\\)", display: false },
98                        { left: "\\[", right: "\\]", display: true },
99                    ],
100                    // • rendering keys, e.g.:
101                    throwOnError: false,
102                });
103            });
104        </script>
104
105
106        <!-- Prism CSS - Simple setup with one theme -->
107        <link
108            rel="stylesheet"
109            href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css"
110        />
111
112        <!-- Prism JS - Load required components directly -->
113        
113<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
113
114        
114<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
114
115        
115<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
115
116        
116<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-toml.min.js"></script>
116
117        
117<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
117
118        
118<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
118
119        
119<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
119
120        
120<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
120
121        
121<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
121
122        
122<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js"></script>
122
123        
123<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-docker.min.js"></script>
123
124        
124<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-rust.min.js"></script>
124
125        
125<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-go.min.js"></script>
125
126        <style>
127            /* Base styles with fluid typography */
128            :root {
129                --font-base: "IBM Plex Sans", Helvetica, Arial, sans-serif;
130                --size-step-0: clamp(1rem, 0.96rem + 0.22vw, 1.125rem);
131                --size-step-1: clamp(1.125rem, 1.06rem + 0.33vw, 1.313rem);
132                --size-step-2: clamp(1.266rem, 1.17rem + 0.48vw, 1.563rem);
133                --size-step-3: clamp(1.424rem, 1.28rem + 0.72vw, 1.875rem);
134                --space-s: clamp(0.75rem, 0.69rem + 0.29vw, 0.938rem);
135                --space-m: clamp(1rem, 0.92rem + 0.39vw, 1.25rem);
136                --space-l: clamp(1.5rem, 1.38rem + 0.58vw, 1.875rem);
137                --color-primary: #089ce7;
138                --color-dark: #252525;
139                --color-light: #f9f9f9;
140            }
141
142            /* Apply monospace font globally */
143            body {
144                font-family: var(--font-base);
145            }
146
147            .prose {
148                font-family: var(--font-base);
149                font-size: var(--size-step-0);
150                line-height: 1.7;
151            }
152            .prose {
153                max-width: 65ch;
154                color: #24292f;
155            }
156            /* Interactive (.mdx) posts: keep body text at a readable measure, but
157               let the marimo widget break out (centered) wider than the text
158               column. The WidgetDAG is content-sized and its flex layout shrinks
159               its nodes to fit the container, so it needs its natural width or the
160               convolution previews get squeezed. */
161            .prose.prose-wide-widgets {
162                max-width: none;
163            }
164            .prose.prose-wide-widgets > :where(p, h1, h2, h3, h4, h5, h6, ul, ol, blockquote, pre, table) {
165                max-width: 65ch;
166            }
167            /* Make the widget a block-level horizontal-scroll container so an
168               oversized graph scrolls within the widget instead of the page. */
169            .prose.prose-wide-widgets .marimo-island-host {
170                display: block;
171                overflow-x: auto;
172            }
173            /* Keep the WidgetDAG at its natural size: render at content width and
174               stop the flex layout (and the prose img rule) from shrinking the
175               convolution previews to fit a narrow container. */
176            .prose.prose-wide-widgets [data-wdag-root] {
177                width: max-content;
178            }
179            .prose.prose-wide-widgets [data-wdag-root] img {
180                flex-shrink: 0;
181                max-width: none;
182            }
183            /* Only break out beyond the text frame on viewports wide enough to
184               hold the widget; on narrow screens it stays in-column. */
185            @media (min-width: 900px) {
186                .prose.prose-wide-widgets .marimo-island-host {
187                    width: max-content;
188                    max-width: min(900px, 96vw);
189                    position: relative;
190                    left: 50%;
191                    transform: translateX(-50%);
192                }
193            }
194            .dark .prose {
195                color: #c9d1d9;
196            }
197            .dark .katex,
198            .dark .katex-display {
199                color: #c9d1d9;
200            }
201
202            /* Typography */
203            .prose h1 {
204                font-size: var(--size-step-3);
205                font-weight: 600;
206                line-height: 1.2;
207                letter-spacing: -0.02em;
208                text-wrap: balance;
209                margin-top: 1.5em;
210                margin-bottom: 0.5em;
211                color: #24292f;
212            }
213
214            .prose h2 {
215                font-size: var(--size-step-2);
216                font-weight: 600;
217                line-height: 1.2;
218                letter-spacing: -0.02em;
219                text-wrap: balance;
220                margin-top: 1.5em;
221                margin-bottom: 0.5em;
222                color: #24292f;
223            }
224
225            .prose h3,
226            .prose h4 {
227                font-size: var(--size-step-1);
228                font-weight: 600;
229                line-height: 1.2;
230                text-wrap: balance;
231                margin-top: 1.5em;
232                margin-bottom: 0.5em;
233                color: #24292f;
234            }
235
236            .dark .prose h1,
237            .dark .prose h2,
238            .dark .prose h3,
239            .dark .prose h4 {
240                color: #c9d1d9;
241            }
242
243            /* Paragraph spacing */
244            .prose p {
245                margin-top: 1.4em;
246                margin-bottom: 1.4em;
247                text-wrap: pretty;
248            }
249
250            .prose p:first-child {
251                margin-top: 0;
252            }
253
254            .prose p:last-child {
255                margin-bottom: 0;
256            }
257
258            /* Links */
259            .prose a {
260                color: #0969da;
261                text-decoration: underline;
262                text-decoration-color: rgba(9, 105, 218, 0.3);
263                text-underline-offset: 2px;
264                transition: all 0.2s ease;
265            }
266            .prose a:hover {
267                color: #0550ae;
268                text-decoration-color: #0550ae;
269            }
270            .dark .prose a {
271                color: #58a6ff;
272                text-decoration-color: rgba(88, 166, 255, 0.3);
273            }
274            .dark .prose a:hover {
275                color: #79c0ff;
276                text-decoration-color: #79c0ff;
277            }
278
279            /* Code blocks */
280            .prose pre {
281                background-color: #f8f9fb;
282                border: 1px solid #e2e6ea;
283                border-radius: 8px;
284                padding: 1.25em;
285                overflow-x: auto;
286                position: relative;
287                margin-top: 1.5em;
288                margin-bottom: 1.5em;
289            }
290            .dark .prose pre {
291                background-color: #161b22;
292                border-color: #30363d;
293            }
294
295            .prose pre code {
296                background-color: transparent;
297                border: none;
298                padding: 0;
299                border-radius: 0;
300                font-size: 0.8125em;
301                line-height: 1.6;
302                color: #24292f;
303                display: block;
304            }
305
306            .dark .prose pre code {
307                color: #e6edf3;
308            }
309
310            .prose code {
311                font-family: "JetBrains Mono", SFMono-Regular, C
311onsolas, Liberation Mono, Menlo, monospace;
312                font-size: 0.875em;
313                padding: 0.15em 0.4em;
314                background-color: #f0f3f6;
315                border: 1px solid #e2e6ea;
316                border-radius: 4px;
317                color: #24292f;
318            }
319            .dark .prose code {
320                background-color: #1c2128;
321                border-color: #30363d;
322                color: #e6edf3;
323            }
324
325            /* Fixed code line highlighting */
326            .prose pre code .line {
327                display: block;
328                margin: 0;
329                padding: 0 1em;
330                margin-left: -1em;
331                margin-right: -1em;
332                min-height: 1.5em;
333                line-height: 1.5;
334                transition: background-color 0.2s ease;
335            }
336
337            .prose pre code .line:empty::before {
338                content: "\200B"; /* Zero-width space to maintain line height */
339            }
340
341            .prose pre code .line:hover {
342                background-color: rgba(56, 139, 253, 0.1);
343            }
344
345            .dark .prose pre code .line:hover {
346                background-color: rgba(56, 139, 253, 0.15);
347            }
348
349            /* Code block titles */
350            .code-block-wrapper {
351                margin: 1em 0;
352                border-radius: 8px;
353                overflow: hidden;
354                border: 1px solid #e2e6ea;
355            }
356
357            .dark .code-block-wrapper {
358                border-color: #30363d;
359            }
360
361            .code-block-wrapper .prose pre,
362            .code-block-wrapper pre {
363                border: none;
364                border-radius: 0;
365            }
366
367            .code-block-title {
368                background-color: #f0f3f6;
369                color: #57606a;
370                padding: 0.5rem 1rem;
371                font-size: 0.8125rem;
372                font-family: "JetBrains Mono", SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
373                border-bottom: 1px solid #e2e6ea;
374            }
375
376            /* Dark mode code block titles */
377            .dark .code-block-title {
378                background-color: #1c2128;
379                color: #8b949e;
380                border-bottom: 1px solid #30363d;
381            }
382
383            /* Ensure code blocks in wrappers have no top margin */
384            .code-block-wrapper pre {
385                margin-top: 0;
386                border-top-left-radius: 0;
387                border-top-right-radius: 0;
388            }
389
390            /* Lists - fix rendering */
391            .prose ul {
392                list-style-type: disc;
393                margin-left: 1.5em;
394                margin-top: 1em;
395                margin-bottom: 1em;
396            }
397
398            .prose ol {
399                list-style-type: decimal;
400                margin-left: 1.5em;
401                margin-top: 1em;
402                margin-bottom: 1em;
403            }
404
405            .prose li {
406                margin-bottom: 0.5em;
407                text-wrap: pretty;
408            }
409
410            .prose li > ul,
411            .prose li > ol {
412                margin-top: 0.5em;
413                margin-bottom: 0.5em;
414            }
415
416            /* Figure and figcaption styles */
417            figure {
418                margin: 2rem auto 2.5rem auto;
419                display: block;
420                max-width: 100%;
421                text-align: center;
422            }
423
424            figure img {
425                margin: 0 auto;
426                display: inline-block;
427                max-width: 100%;
428                height: auto;
429            }
430
431            figcaption {
432                text-align: center;
433                font-size: 0.875rem;
434                color: #6b7280;
435                margin-top: 0.5rem;
436                padding: 0 1rem;
437            }
438
439            .dark figcaption {
440                color: #9ca3af;
441            }
442
443            /* Image spacing and centering */
444            .prose img {
445                margin: 2rem auto 2.5rem auto;
446                display: block;
447                max-width: 100%;
448                height: auto;
449            }
450
451            .prose p + img,
452            .prose img + p {
453                margin-top: 1.5em;
454            }
455
456            /* Copy button for code blocks */
457            .code-copy-button {
458                position: absolute;
459                top: 0.5rem;
460                right: 0.5rem;
461                width: 2rem;
462                height: 2rem;
463                display: flex;
464                align-items: center;
465                justify-content: center;
466                background-color: rgba(255, 255, 255, 0.8);
467                border: 1px solid #e2e6ea;
468                border-radius: 6px;
469                color: #57606a;
470                cursor: pointer;
471                opacity: 0;
472                transition:
473                    opacity 0.15s,
474                    background-color 0.15s;
475            }
476
477            pre:hover .code-copy-button {
478                opacity: 1;
479            }
480
481            .code-copy-button:hover {
482                background-color: #f0f3f6;
483                color: #24292f;
484            }
485
486            .code-copy-button svg {
487                width: 0.875rem;
488                height: 0.875rem;
489            }
490
491            .dark .code-copy-button {
492                background-color: rgba(22, 27, 34, 0.8);
493                border-color: #30363d;
494                color: #8b949e;
495            }
496
497            .dark .code-copy-button:hover {
498                background-color: #1c2128;
499                color: #e6edf3;
500            }
501
502            /* Blockquotes */
503            .prose blockquote {
504                border-left: 4px solid #d0d7de;
505                padding-left: 1em;
506                color: #57606a;
507                margin-left: 0;
508                margin-top: 1.5em;
509                margin-bottom: 1.5em;
510            }
511            .dark .prose blockquote {
512                border-left-color: #30363d;
513                color: #8b949e;
514            }
515
516            /* Custom Prism.js styling */
517            pre.language-python,
518            pre.language-javascript,
519            pre.language-bash,
520            pre.language-css,
521            pre.language-json,
522            pre.language-html,
523            pre.language-toml,
524            pre.language-yaml,
525            pre.language-yml,
526            pre.language-typescript,
527            pre.language-ts,
528            pre.language-jsx,
529            pre.language-tsx,
530            pre.language-markdown,
531            pre.language-md,
532            pre.language-sql,
533            pre.language-docker,
534            pre.language-dockerfile,
535            pre.language-rust,
536            pre.language-rs,
537            pre.language-go {
538                position: relative;
539            }
540
541            .token.comment,
542            .token.prolog,
543            .token.doctype,
544            .token.cdata {
545                color: #6e7781;
546            }
547
548            .token.punctuation {
549                color: #24292f;
550            }
551
552            .token.property,
553            .token.tag,
554            .token.boolean,
555            .token.number,
556            .token.constant,
557            .token.symbol,
558            .token.deleted {
559                color: #0550ae;
560            }
561
562            .token.selector,
563            .token.attr-name,
564            .token.string,
565            .token.char,
566            .token.builtin,
567            .token.inserted {
568                color: #0a3069;
569            }
570
571            .token.operator,
572            .token.entity,
573            .token.url,
574            .language-css .token.string,
575            .style .token.string {
576                color: #8250df;
577            }
578
579            .token.atrule,
580            .token.attr-value,
581            .token.keyword {
582                color: #116329;
583            }
584
585            .token.function,
586            .token.class-name {
587                color: #953800;
588            }
589
590            .token.regex,
591            .token.important,
592            .token.variable {
593                color: #953800;
594            }
595
596            .dark .token.comment,
597            .dark .token.prolog,
598            .dark .token.doctype,
599            .dark .token.cdata {
600                color: #8b949e;
601            }
602
603            .dark .token.punctuation {
604                color: #c9d1d9;
605            }
606
607            .dark .token.property,
608            .dark .token.tag,
609            .dark .token.boolean,
610            .dark .token.number,
611            .dark .token.constant,
612            .dark .token.symbol,
613            .dark .token.deleted {
614                color: #79c0ff;
615            }
616
617            .dark .token.selector,
618            .dark .token.attr-name,
619            .dark .token.string,
620            .dark .token.char,
621            .dark .token.builtin,
622            .dark .token.inserted {
623                color: #a5d6ff;
624            }
625
626            .dark .token.operator,
627            .dark .token.entity,
628            .dark .token.url,
629            .dark .language-css .token.string,
630            .dark .style .token.string {
631                color: #d2a8ff;
632            }
633
634            /* Fix Tailwind conflict with Prism TOML tokens */
635            .prose pre code .token.table {
636                display: inline !important;
637            }
638
639            .dark .token.atrule,
640            .dark .token.attr-value,
641            .dark .token.keyword {
642                color: #7ee787;
643            }
644
645            .dark .token.function,
646            .dark .token.class-name {
647                color: #d2a8ff;
648            }
649
650            .dark .token.regex,
651            .dark .token.important,
652            .dark .token.variable {
653                color: #f0883e;
654            }
655
656            /* Responsive iframe container */
657            .prose .video-container,
658            .prose .iframe-container {
659                position: relative;
660                width: 100%;
661                padding-bottom: 56.25%; /* 16:9 aspect ratio */
662                height: 0;
663                overflow: hidden;
664                margin: 2rem 0;
665            }
666
667            .prose .video-container iframe,
668            .prose .iframe-container iframe {
669                position: absolute;
670                top: 0;
671                left: 0;
672                width: 100%;
673                height: 100%;
674                border: 0;
675            }
676
677            /* Direct iframe styling for backwards compatibility */
678            .prose iframe {
679                width: 100%;
680                max-width: 100%;
681                height: auto;
682                aspect-ratio: 16 / 9;
683                margin: 2rem 0;
684            }
685
686            /* Table styling */
687            .prose table {
688                width: 100%;
689                border-collapse: collapse;
690                margin: 2rem 0;
691                font-size: 0.95em;
692                border-radius: 8px;
693                box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
694            }
695
696            .prose thead {
697                background-color: #f6f8fa;
698                border-bottom: 2px solid #d0d7de;
699            }
700
701            .dark .prose thead {
702                background-color: #161b22;
703                border-bottom-color: #30363d;
704            }
705
706            .prose th {
707                padding: 0.75rem 1rem;
708                text-align: left;
709                font-weight: 600;
710                color: #24292f;
711            }
712
713            .dark .prose th {
714                color: #f0f6fc;
715            }
716
717            .prose td {
718                padding: 0.75rem 1rem;
719                text-align: left;
720                border-bottom: 1px solid #d0d7de;
721            }
722
723            .dark .prose td {
724                border-bottom-color: #30363d;
725            }
726
727            .prose tbody tr:last-child td {
728                border-bottom: none;
729            }
730
731            .prose tbody tr {
732                background-color: #ffffff;
733                transition: background-color 0.2s ease;
734            }
735
736            .dark .prose tbody tr {
737                background-color: #0d1117;
738            }
739
740            .prose tbody tr:hover {
741                background-color: #f3f4f6;
742            }
743
744            .dark .prose tbody tr:hover {
745                background-color: #1c2128;
746            }
747
748            /* Responsive tables */
749            @media (max-width: 640px) {
750                .prose table {
751                    font-size: 0.875em;
752                }
753
754                .prose th,
755                .prose td {
756                    padding: 0.5rem 0.75rem;
757                }
758
759                .prose iframe {
760                    margin: 1.5rem 0;
761                }
762
763                .prose .video-container,
764                .prose .iframe-container {
765                    margin: 1.5rem 0;
766                }
767            }
768        </style>
769        
769<script>
770            // Set theme based on user preference
771            function setTheme(theme) {
772                if (theme === "dark") {
773                    document.documentElement.classList.add("dark");
774                    document.documentElement.classList.remove("light");
775                    localStorage.setItem("theme", "dark");
776                } else {
777                    document.documentElement.classList.add("light");
778                    document.documentElement.classList.remove("dark");
779                    localStorage.setItem("theme", "light");
780                }
781            }
782
783            // Check for saved theme preference or use the system preference
784            const savedTheme = localStorage.getItem("theme");
785            if (savedTheme) {
786                setTheme(savedTheme);
787            } else if (
788                window.matchMedia("(prefers-color-scheme: dark)").matches
789            ) {
790                setTheme("dark");
791            } else {
792                setTheme("light");
793            }
794        </script>
794
795        <!-- Prism JS - Core first, then components -->
796        
796<script>
797            // Theme toggle
798            document.addEventListener("DOMContentLoaded", function () {
799                document
800                    .getElementById("theme-toggle")
801                    .addEventListener("click", function () {
802                        const currentTheme =
803                            localStorage.getItem("theme") || "light";
804                        if (currentTheme === "light") {
805                            setTheme("dark");
806                        } else {
807                            setTheme("light");
808                        }
809                    });
810            });
811
812            // Function to copy text to clipboard with fallback
813            function copyTextToClipboard(text, callback) {
814                // Fallback for browsers that don't support clipboard API
815                if (!navigator.clipboard) {
816                    const textArea = document.createElement("textarea");
817                    textArea.value = text;
818
819                    // Make the textarea out of viewport
820                    textArea.style.position = "fixed";
821                    textArea.style.left = "-999999px";
822                    textArea.style.top = "-999999px";
823                    document.body.appendChild(textArea);
824                    textArea.focus();
825                    textArea.select();
826
827                    try {
828                        const successful = document.execCommand("copy");
829                        callback(successful);
830                    } catch (err) {
831                        console.error("Fallback: Could not copy text: ", err);
832                        callback(false);
833                    }
834
835                    document.body.removeChild(textArea);
836                    return;
837                }
838
839                // Use Clipboard API if available
840                navigator.clipboard
841                    .writeText(text)
842                    .then(() => callback(true))
843                    .catch((err) => {
844                        console.error(
845                            "Clipboard API: Could not copy text: ",
846                            err,
847                        );
848                        callback(false);
849                    });
850            }
851
852            // Function to add copy buttons to code blocks
853            function addCopyButtons() {
854                document.querySelectorAll("pre").forEach(function (pre) {
855                    // Skip if a copy button is already added
856                    if (pre.querySelector(".code-copy-button")) return;
857
858                    // Add copy button with icon
859                    const copyButton = document.createElement("button");
860                    copyButton.className = "code-copy-button";
861                    copyButton.innerHTML = `
862                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
863                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
864                    </svg>
865                `;
866
867                    // Add tooltip
868                    copyButton.title = "Copy to clipboard";
869
870                    // Add click event to copy code
871                    copyButton.addEventListener("click", function (e) {
872                        e.preventDefault();
873                        e.stopPropagation();
874
875                        // Get the code content, preserving newlines properly
876                        const codeElement = pre.querySelector("code") || pre;
877                        const code =
878                            codeElement.innerText || codeElement.textContent;
879
880                        // Copy to clipboard with fallback
881                        copyTextToClipboard(code, function (success) {
882                            if (success) {
883                                // Show success state
884                                copyButton.innerHTML = `
885                                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
886                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
887                                </svg>
888                            `;
889                                setTimeout(() => {
890                                    copyButton.innerHTML = `
891                                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
892                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
893                                    </svg>
894                                `;
895                                }, 2000);
896                            } else {
897                                // Show error state
898                                copyButton.innerHTML = `
899                                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
900                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
901                                </svg>
902                            `;
903                                setTimeout(() => {
904                                    copyButton.innerHTML = `
905                                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
906                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
907                                    </svg>
908                                `;
909                                }, 2000);
910                            }
911                        });
912                    });
913
914                    // Append the button to the pre element
915                    pre.appendChild(copyButton);
916                });
917            }
918
919            // Add language classes and copy buttons when DOM is loaded
920            document.addEventListener("DOMContentLoaded", function () {
921                // Process code blocks for Prism.js
922                document.querySelectorAll("pre code").forEach(function (code) {
923                    // Find the language from the class
924                    let lang = "text";
925                    const classMatch = code.className.match(/language-(\w+)/);
926                    if (classMatch) {
927                        lang = classMatch[1];
928                    } else {
929                        // Add a default language class if none exists
930                        code.classList.add("language-text");
931                    }
932
933                    // Make sure the parent pre element also has the language class
934                    if (
935                        !code.parentElement.classList.contains(
936                            `language-${lang}`,
937                        )
938                    ) {
939                        code.parentElement.classList.add(`language-${lang}`);
940                    }
941                });
942
943                // Highlight code blocks
944                if (window.Prism) {
945                    Prism.highlightAll();
946                }
947
948                // Fixed line wrapping for hover highlighting
949                // Wait a bit for Prism to finish its work
950                setTimeout(function () {
951                    document
952                        .querySelectorAll("pre code")
953                        .forEach(function (code) {
954                            // Only process if not already processed
955                            if (code.querySelector(".line")) return;
956
957                            // Get the HTML content after Prism highlighting
958                            const content = code.innerHTML;
959
960                            // Split by newlines but preserve the line breaks
961                            const lines = content.split("\n");
962
963                            // Wrap each line, preserving empty lines
964                            const wrappedLines = lines.map(
965                                function (line, index) {
966                                    // For last empty line, don't wrap
967                                    if (
968                                        index === lines.length - 1 &&
969                                        line === ""
970                                    ) {
971                                        return "";
972                                    }
973                                    // Use span with proper display handling
974                                    return (
975                                        '<span class="line">
975' + line + "</span>"
976                                    );
977                                },
978                            );
979
980                            // Join without adding extra newlines
981                            code.innerHTML = wrappedLines.join("");
982                        });
983                }, 100);
984
985                // Add copy buttons
986                addCopyButtons();
987            });
988
989            // Add copy buttons when Prism is done highlighting
990            if (window.Prism) {
991                Prism.hooks.add("complete", function () {
992                    addCopyButtons();
993                });
994            }
995        </script>
995
996    </head>
997    <body
998        class="bg-white dark:bg-dark-bg text-secondary dark:text-dark-secondary min-h-screen"
999    >
1000        
1001        <header class="container mx-auto max-w-3xl px-4 sm:px-6">
1002            <!-- Newspaper-style header -->
1003            <div
1004                class="mb-4 pb-4 border-b-4 border-gray-800 dark:border-gray-600"
1005            >
1006                <div class="flex justify-between items-center">
1007                    <a href="/" class="hover:no-underline">
1008                        <h1
1009                            class="text-5xl font-bold text-gray-900 dark:text-gray-100 mb-2 pt-4"
1010                        >
1011                            koaning.io
1012                        </h1>
1013                    </a>
1014                    <button
1015                        id="theme-toggle"
1016                        class="text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-dark-primary"
1017                    >
1018                        <svg
1019                            xmlns="http://www.w3.org/2000/svg"
1020                            width="20"
1021                            height="20"
1022                            fill="currentColor"
1023                            class="bi bi-moon-stars dark:hidden"
1024                            viewBox="0 0 16 16"
1025                        >
1026                            <path
1027                                d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278zM4.858 1.311A7.269 7.269 0 0 0 1.025 7.71c0 4.02 3.279 7.276 7.319 7.276a7.316 7.316 0 0 0 5.205-2.162c-.337.042-.68.063-1.029.063-4.61 0-8.343-3.714-8.343-8.29 0-1.167.242-2.278.681-3.286z"
1028                            />
1029                            <path
1030                                d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162a.217.217 0 0 1 .412 0l.387 1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a.145.145 0 0 1-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z"
1031                            />
1032                        </svg>
1033                        <svg
1034                            xmlns="http://www.w3.org/2000/svg"
1035                            width="20"
1036                            height="20"
1037                            fill="currentColor"
1038                            class="bi bi-sun hidden dark:block"
1039                            viewBox="0 0 16 16"
1040                        >
1041                            <path
1042                                d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"
1043                            />
1044                        </svg>
1045                    </button>
1046                </div>
1047                <div class="flex justify-between items-center">
1048                    <p class="text-lg text-gray-700 dark:text-gray-300">
1049                        Blog of a data person
1050                    </p>
1051                    <!-- Desktop menu -->
1052                    <div
1053                        class="mt-3 hidden md:flex space-x-6 text-gray-700 dark:text-gray-300"
1054                    >
1055                        <a
1056                            href="/posts"
1057                            class="hover:text-primary dark:hover:text-dark-primary"
1058                            >All Posts</a
1059                        >
1060                        <a
1061                            href="/apps/"
1062                            class="hover:text-primary dark:hover:text-dark-primary"
1063                            >Apps</a
1064                        >
1065                        <a
1066                            href="/about/"
1067                            class="hover:text-primary dark:hover:text-dark-primary"
1068                            >About</a
1069                        >
1070                        <a
1071                            href="/feed.xml"
1072                            class="hover:text-primary dark:hover:text-dark-primary"
1073                            >RSS</a
1074                        >
1075                        <a
1076                            href="https://koaning.substack.com/subscribe?utm_source=menu&simple=true&next=https%3A%2F%2Fkoaning.substack.com%2F"
1077                            class="hover:text-primary dark:hover:text-dark-primary"
1078                            >Subscribe</a
1079                        >
1080                    </div>
1081
1082                    <!-- Mobile hamburger menu -->
1083                    <div class="md:hidden" x-data="{ open: false }">
1084                        <button
1085                            @click="open = !open"
1086                            class="text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary focus:outline-none mt-3 z-20"
1087                        >
1088                            <svg
1089                                xmlns="http://www.w3.org/2000/svg"
1090                                class="h-6 w-6"
1091                                fill="none"
1092                                viewBox="0 0 24 24"
1093                                stroke="currentColor"
1094                            >
1095                                <path
1096                                    stroke-linecap="round"
1097                                    stroke-linejoin="round"
1098                                    stroke-width="2"
1099                                    d="M4 6h16M4 12h16M4 18h16"
1100                                />
1101                            </svg>
1102                        </button>
1103
1104                        <div
1105                            x-show="open"
1106                            @click.away="open = false"
1107                            class="absolute right-0 mt-2 py-2 w-48 bg-white dark:bg-dark-card rounded-md shadow-lg z-10"
1108                        >
1109                            <a
1110                                href="/"
1111                                class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary"
1112                                >Home</a
1113                            >
1114                            <a
1115                                href="/posts"
1116                                class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary"
1117                                >All Posts</a
1118                            >
1119                            <a
1120                                href="/apps/"
1121                                class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary"
1122                                >Apps</a
1123                            >
1124                            <a
1125                                href="/about/"
1126                                class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary"
1127                                >About</a
1128                            >
1129                            <a
1130                                href="/feed.xml"
1131                                class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary"
1132                                >RSS</a
1133                            >
1134                            <a
1135                                href="https://koaning.substack.com/subscribe?utm_source=menu&simple=true&next=https%3A%2F%2Fkoaning.substack.com%2F"
1136                                class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary"
1137                                >Subscribe</a
1138                            >
1139                        </div>
1140                    </div>
1141                </div>
1142            </div>
1143        </header>
1144        
1145
1146        <main class="container mx-auto max-w-3xl px-4 sm:px-6 py-8">
1147            
1148<div class="prose dark:prose-invert max-w-3xl mx-auto">
1149    <figure>
1150  <img src="https://pbs.twimg.com/profile_images/869526969809076226/o12QmaVF_400x400.jpg" alt="Vincent Warmerdam" class="px-10 max-w-lg max-h-lg" style="margin-left:auto; margin-right: auto; width: 100%; display: block;">
1151  <figcaption>Hi. My name is Vincent. <b>AskMeAnything[tm]</b>.</figcaption>
1152</figure>
1153<p>This is the bio I typically use for conferences:</p>
1154<blockquote>
1155<p>Vincent is a senior data professional who worked as an engineer, researcher, team lead, and educator in the past. You might know him from tech talks with an attempt to defend common sense over hype in the data space. He is especially interested in understanding algorithmic systems so that one may prevent failure. As such, he has always had a preference to keep calm, to try and understand the failure scenario, and to check the dataset before flowing all the tensors.</p>
1156</blockquote>
1157<p>He currently works at marimo, where he spends his time rethinking everything related to computers.</p>
1158<p>
1158Sometimes I also add this bit:</p>
1159<blockquote>
1160<p>Vincent is also well known for creating a lot of open-source packages, which have been downloaded well over two million times. He's also well known for his <a href="https://calmcode.io">calmcode.io</a>/<a href="https://remember.cards">remember.cards</a> projects, as well his blog over at <a href="https://koaning.io">koaning.io</a>. You may have also seen him on YouTube where the explains, amoung other things, why ergonomic keyboards are friggin' cool.</p>
1161</blockquote>
1162<h2>Fun Facts</h2>
1163<p>I also keep a list of fun facts that people might know me for.</p>
1164<ul>
1165<li>me on the front page of <a href="https://twitter.com/fishnets88/status/883032331778498560">reddit</a></li>
1166<li>proud creator of <a href="https://calmcode.io">calmcode.io</a></li>
1167<li>a digital nomad with an <a href="http://koaning.io/posts/digital-nomad/">amazing postcard</a></li>
1168<li>a preferred Rstudio training partner</li>
1169<li>co-founder and co-chair of <a href="https://amsterdam.pydata.org/">PyData Amsterdam</a></li>
1170<li>co-founder of the <a href="https://satrdays.org/">satRdays Amsterdam conference</a></li>
1171<li>co-creator of some <a href="https://github.com/koaning/">open source packages</a></li>
1172<li>a data/open source evangelist with a track record of <a href="https://www.youtube.com/watch?v=C9p7suS-NGk&amp;ab_channel=PyData">speaking</a></li>
1173<li>involved with many data projects</li>
1174<li>helping out with the <a href="https://normconf.com/">normconf</a> conference</li>
1175<li>featured in a <a href="https://www.youtube.com/watch?v=cDofhN-RJqg&amp;t=373s&amp;ab_channel=3Blue1Brown">3b1b maths video</a></li>
1176<li>featured in a <a href="https://www.youtube.com/watch?v=0B5eIE_1vpU">freeCodeCamp video</a></li>
1177</ul>
1178<h2>Past</h2>
1179<p>Before joining marimo I worked as a outreach engineer over at <a href="https://probabl.ai">probabl</a> with the <a href="https://scikit-learn.org/">scikit-learn people</a>. Before that I was a machine learning engineer over at <a href="https://explosion.ai/">Explosion</a> who are behind tools such as <a href="https://spacy.io/">spaCy</a> and <a href="https://prodi.gy/">Prodigy</a>. Working here was a blast! Before joining Explosion, I worked as a Research Advocate at Rasa where I collaborated with the research team to explain and understand conversational systems better.</p>
1180<p>I was also a consultant for a bit. But I am still recovering from that.</p>
1181<h2>Disclosures</h2>
1182<p>There are a few things I should disclose:</p>
1183<ul>
1184<li>I am a <a href="https://marimo.io">marimo</a> employee. They are very open and let me do my own thing on the side, but I should disclose this because it does influence what tools I use.</li>
1185<li>I created <a href="https://calmcode.io">calmcode</a> and this brings in a small amount of revenue. Most of the content there is free but I do have a premium version that is a bit more in-depth. I also host ads there via <a href="https://www.ethicalads.io/">EthicalAds</a>.</li>
1186<li>I will occasionally make YouTube videos for 3rd parties as well as do some dev-rel related consulting. Companies that I have advised this way include <a href="https://www.conductor.build/">conductor.build</a> and <a href="https://exe.dev">exe.dev</a>. My policy here is that it should be a fun activity, so I only consider doing this kind of work for companies that build tools that I really (!) enjoy using.</li>
1187<li>I provide ad-hoc advice services to folks who reach out to me personally, assuming there is no conflict of interest. Investors have reached out to me in the past for paid advice and I may certainly do these in the future too. These talks, understandibly, come with a strict non-disclosure agreement.</li>
1188<li>I review and use ergonomic keyboards. I refuse affiliate links, but I have accepted keyboards in return for a review on YouTube. Again, and vendors really need to understand that this is key, I do not accept affiliate links what so ever! Adding an incentive to my review would ruin the reputation of the channel. Whenever I produce content that is sponsored the video will make that abundantly clear.</li>
1189</ul>
1190<h2>Concluding</h2>
1191<p>I believe in open tools/collaboration and I certainly don’t mind to evangelise from time to time. But it does feel like a lot of tools are still missing.</p>
1192<blockquote>
1193<p>
1193The future is pretty awesome, all we have to do is build it.</p>
1194</blockquote>
1195<h2>Note</h2>
1196<p>This blog has a matured a few times in the past. An earlier version built with Pelican can be found <a href="http://koaning.io.s3-website.eu-west-2.amazonaws.com/old/">here</a>. A more recent version, built with Distill, can be found <a href="https://koaning.github.io/koaning-io-old/">here</a>. On every upgrade I only decided to port a subset of the blogposts, partially to save time, but also because the blog matures over time ... and some of the old blogposts feel less relevant.</p>
1197
1198</div>
1199
1200        </main>
1201
1202        <footer class="border-t border-gray-200 dark:border-gray-700 py-8 mt-8">
1203            <div
1204                class="container mx-auto px-4 sm:px-6 text-center text-gray-600 dark:text-gray-400"
1205            >
1206                <p>&copy; 2026 Vincent D. Warmerdam. All rights reserved.</p>
1207            </div>
1208        </footer>
1209
1210        <!-- Search Modal -->
1211        <div
1212            id="searchModal"
1213            class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-start justify-center pt-20"
1214        >
1215            <div
1216                class="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-2xl mx-4"
1217            >
1218                <!-- Search Input -->
1219                <div class="relative">
1220                    <div
1221                        class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"
1222                    >
1223                        <svg
1224                            class="h-5 w-5 text-gray-400"
1225                            fill="none"
1226                            stroke="currentColor"
1227                            viewBox="0 0 24 24"
1228                        >
1229                            <path
1230                                stroke-linecap="round"
1231                                stroke-linejoin="round"
1232                                stroke-width="2"
1233                                d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
1234                            ></path>
1235                        </svg>
1236                    </div>
1237                    <input
1238                        type="text"
1239                        id="modalSearchInput"
1240                        placeholder="Search posts..."
1241                        class="w-full pl-12 pr-4 py-4 text-lg border-0 border-b border-gray-200 dark:border-gray-700 bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:border-gray-400 rounded-t-lg"
1242                    />
1243                    <div
1244                        class="absolute inset-y-0 right-0 pr-4 flex items-center"
1245                    >
1246                        <span
1247                            class="text-xs text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded border"
1248                            >ESC</span
1249                        >
1250                    </div>
1251                </div>
1252
1253                <!-- Results -->
1254                <div id="modalResults" class="max-h-96 overflow-y-auto">
1255                    <div id="modalNoResults" class="hidden p-8 text-center">
1256                        <div class="text-gray-500 dark:text-gray-400">
1257                            <p class="text-sm">
1258                                No posts found. Try a different search term.
1259                            </p>
1260                        </div>
1261                    </div>
1262
1263                    <div
1264                        id="modalResultsList"
1265                        class="divide-y divide-gray-200 dark:divide-gray-700"
1266                    >
1267                        <!-- Results will be populated here -->
1268                    </div>
1269                </div>
1270
1271                <!-- Footer -->
1272                <div
1273                    class="px-4 py-3 bg-gray-50 dark:bg-gray-800 rounded-b-lg border-t border-gray-200 dark:border-gray-700"
1274                >
1275                    <div
1276                        class="flex justify-between text-xs text-gray-500 dark:text-gray-400"
1277                    >
1278                        <span>↑↓ to navigate</span>
1279                        <span>↵ to select</span>
1280                        <span>ESC to close</span>
1281                    </div>
1282                </div>
1283            </div>
1284        </div>
1285
1286        
1286<script>
1287            document.addEventListener("DOMContentLoaded", function () {
1288                const modal = document.getElementById("searchModal");
1289                const modalInput = document.getElementById("modalSearchInput");
1290                const modalResults = document.getElementById("modalResults");
1291                const modalNoResults =
1292                    document.getElementById("modalNoResults");
1293                const modalResultsList =
1294                    document.getElementById("modalResultsList");
1295
1296                let modalSelectedIndex = -1;
1297                let modalVisibleItems = [];
1298                let allPosts = [];
1299
1300                // Available commands
1301                const commands = [
1302                    {
1303                        title: "Toggle Dark Mode",
1304                        description: "Switch between light and dark themes",
1305                        action: () => {
1306                            const currentTheme =
1307                                localStorage.getItem("theme") || "light";
1308                            setTheme(
1309                                currentTheme === "light" ? "dark" : "light",
1310                            );
1311                        },
1312                        icon: "🌙",
1313                    },
1314                    {
1315                        title: "Toggle Light Mode",
1316                        description: "Switch between light and dark themes",
1317                        action: () => {
1318                            const currentTheme =
1319                                localStorage.getItem("theme") || "light";
1320                            setTheme(
1321                                currentTheme === "dark" ? "light" : "dark",
1322                            );
1323                        },
1324                        icon: "☀️",
1325                    },
1326                ];
1327
1328                // Load posts data from the posts page
1329                function loadPostsData() {
1330                    if (allPosts.length > 0) return Promise.resolve();
1331
1332                    return fetch("/posts/")
1333                        .then((response) => response.text())
1334                        .then((html) => {
1335                            const parser = new DOMParser();
1336                            const doc = parser.parseFromString(
1337                                html,
1338                                "text/html",
1339                            );
1340                            const postItems =
1341                                doc.querySelectorAll(".post-item");
1342
1343                            allPosts = Array.from(postItems).map((item) => ({
1344                                title: item.dataset.title,
1345                                description: item.dataset.description || "",
1346                                tags: item.dataset.tags
1347                                    ? item.dataset.tags
1348                                          .split(",")
1349                                          .filter((t) => t.trim())
1350                                    : [],
1351                                year: item.dataset.year,
1352                                url: item.dataset.url,
1353                                displayTitle: item
1354                                    .querySelector("h3")
1355                                    .textContent.trim(),
1356                                displayDate: item
1357                                    .querySelector("time")
1358                                    .textContent.trim(),
1359                            }));
1360                        })
1361                        .catch((err) => {
1362                            console.error("Failed to load posts data:", err);
1363                        });
1364                }
1365
1366                function updateModalSelection() {
1367                    modalVisibleItems.forEach((item, index) => {
1368                        const element = modalResultsList.children[index];
1369                        if (element) {
1370                            if (index === modalSelectedIndex) {
1371                                element.classList.add(
1372                                    "bg-gray-100",
1373                                    "dark:bg-gray-800",
1374                                );
1375                            } else {
1376                                element.classList.remove(
1377                                    "bg-gray-100",
1378                                    "dark:bg-gray-800",
1379                                );
1380                            }
1381                        }
1382                    });
1383                }
1384
1385                function searchPosts(query) {
1386                    if (!query.trim()) {
1387                        modalResultsList.innerHTML = "";
1388                        modalNoResults.classList.add("hidden");
1389                        modalVisibleItems = [];
1390                        modalSelectedIndex = -1;
1391                        return;
1392                    }
1393
1394                    const searchTerm = query.toLowerCase();
1395
1396                    // Search commands
1397                    const matchingCommands = commands.filter(
1398                        (cmd) =>
1399                            cmd.title.toLowerCase().includes(searchTerm) ||
1400                            cmd.description
1401                                .toLowerCase()
1402                                .includes(searchTerm) ||
1403                            searchTerm.includes("dark") ||
1404                            searchTerm.includes("light") ||
1405                            searchTerm.includes("theme"),
1406                    );
1407
1408                    // Search posts
1409                    const matchingPosts = allPosts.filter(
1410                        (post) =>
1411                            post.title.includes(searchTerm) ||
1412                            post.description.includes(searchTerm),
1413                    );
1414
1415                    modalVisibleItems = [...matchingCommands, ...matchingPosts];
1416                    modalSelectedIndex = -1;
1417
1418                    if (modalVisibleItems.length === 0) {
1419                        modalResultsList.innerHTML = "";
1420                        modalNoResults.classList.remove("hidden");
1421                    } else {
1422                        modalNoResults.classList.add("hidden");
1423
1424                        const commandsHtml = matchingCommands
1425                            .map(
1426                                (cmd, index) => `
1427                    <div class="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors border-l-4 border-blue-500" data-command="${index}">
1428                        <div class="flex items-center gap-4">
1429                            <span class="text-lg w-8 flex-shrink-0">${cmd.icon}</span>
1430                            <div class="flex-1">
1431                                <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
1432                                    ${cmd.title}
1433                                </h3>
1434                                <p class="text-sm text-gray-600 dark:text-gray-400">${cmd.description}</p>
1435                            </div>
1436                            <span class="text-xs text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded border">CMD</span>
1437                        </div>
1438                    </div>
1439                `,
1440                            )
1441                            .join("");
1442
1443                        const postsHtml = matchingPosts
1444                            .map(
1445                                (post, index) => `
1446                    <div class="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" data-url="${post.url}">
1447                        <div class="flex items-center gap-4">
1448                            <time class="text-sm font-mono text-gray-500 dark:text-gray-500 w-24 flex-shrink-0">
1449                                ${post.displayDate}
1450                            </time>
1451                            <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 flex-1">
1452                                ${post.displayTitle}
1453                            </h3>
1454                            ${
1455                                post.tags.length > 0
1456                                    ? `
1457                                <div class="flex gap-1 flex-shrink-0">
1458                                    ${post.tags
1459                                        .slice(0, 2)
1460                                        .map(
1461                                            (tag) => `
1462                                        <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-600">
1463                                            ${tag}
1464                                        </span>
1465                                    `,
1466                                        )
1467                                        .join("")}
1468                                    ${post.tags.length > 2 ? `<span class="text-xs text-gray-400">+${post.tags.length - 2}</span>` : ""}
1469                                </div>
1470                            `
1471                                    : ""
1472                            }
1473                        </div>
1474                    </div>
1475                `,
1476                            )
1477                            .join("");
1478
1479                        modalResultsList.innerHTML = commandsHtml + postsHtml;
1480
1481                        // Add click handlers for commands
1482                        modalResultsList
1483                            .querySelectorAll("[data-command]")
1484                            .forEach((element, index) => {
1485                                element.addEventListener("click", () => {
1486                                    matchingCommands[index].action();
1487                                    modal.classList.add("hidden");
1488                                    modalInput.value = "";
1489                                    modalResultsList.innerHTML = "";
1490                                    modalSelectedIndex = -1;
1491                                });
1492                            });
1493
1494                        // Add click handlers for posts
1495                        modalResultsList
1496                            .querySelectorAll("[data-url]")
1497                            .forEach((element, index) => {
1498                                element.addEventListener("click", () => {
1499                                    window.location.href = element.dataset.url;
1500                                });
1501                            });
1502                    }
1503                }
1504
1505                // Keyboard shortcuts
1506                document.addEventListener("keydown", function (e) {
1507                    // Cmd+K or Ctrl+K to open
1508                    if ((e.metaKey || e.ctrlKey) && e.key === "k") {
1509                        e.preventDefault();
1510                        loadPostsData().then(() => {
1511                            modal.classList.remove("hidden");
1512                            modalInput.focus();
1513                        });
1514                    }
1515
1516                    // ESC to close
1517                    if (
1518                        e.key === "Escape" &&
1519                        !modal.classList.contains("hidden")
1520                    ) {
1521                        e.preventDefault();
1522                        modal.classList.add("hidden");
1523                        modalInput.value = "";
1524                        modalResultsList.innerHTML = "";
1525                        modalSelectedIndex = -1;
1526                    }
1527                });
1528
1529                // Modal input events
1530                modalInput.addEventListener("input", function () {
1531                    searchPosts(this.value);
1532                });
1533
1534                modalInput.addEventListener("keydown", function (e) {
1535                    if (e.key === "ArrowDown") {
1536                        e.preventDefault();
1537                        if (modalVisibleItems.length > 0) {
1538                            modalSelectedIndex = Math.min(
1539                                modalSelectedIndex + 1,
1540                                modalVisibleItems.length - 1,
1541                            );
1542                            updateModalSelection();
1543                        }
1544                    } else if (e.key === "ArrowUp") {
1545                        e.preventDefault();
1546                        if (modalVisibleItems.length > 0) {
1547                            modalSelectedIndex = Math.max(
1548                                modalSelectedIndex - 1,
1549                                0,
1550                            );
1551                            updateModalSelection();
1552                        }
1553                    } else if (e.key === "Enter") {
1554                        e.preventDefault();
1555                        if (
1556                            modalSelectedIndex >= 0 &&
1557                            modalSelectedIndex < modalVisibleItems.length
1558                        ) {
1559                            const selectedItem =
1560                                modalVisibleItems[modalSelectedIndex];
1561                            if (selectedItem.action) {
1562                                // It's a command
1563                                selectedItem.action();
1564                                modal.classList.add("hidden");
1565                                modalInput.value = "";
1566                                modalResultsList.innerHTML = "";
1567                                modalSelectedIndex = -1;
1568                            } else {
1569                                // It's a post
1570                                window.location.href = selectedItem.url;
1571                            }
1572                        }
1573                    }
1574                });
1575
1576                // Click outside to close
1577                modal.addEventListener("click", function (e) {
1578                    if (e.target === modal) {
1579                        modal.classList.add("hidden");
1580                        modalInput.value = "";
1581                        modalResultsList.innerHTML = "";
1582                        modalSelectedIndex = -1;
1583                    }
1584                });
1585            });
1586        </script>
1586
1587    </body>
1588</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.