board: a card turns over to a checklist, labels and a description

Enter on a card floats a sheet over the board: its labels, a markdown
description written in the vim editor (:w writes it to the card), and a
checklist walked with j/k, ticked with Space, grown with o. Labels take
their colour from their name. A repeating card comes back with its
checklist reopened.

Code in the editor and in rendered fences is coloured from nvim's
yorha.lua, one class per highlight group, for every language
@codemirror/language-data knows; the languages load on first use.
This commit is contained in:
nvrl committed 2026-10-21 23:53:24 +02:00
1 parent 1fd1aa1eb7
commit 3c4c383f72
24 files changed
+1274 -127

No files matched your search

+61
View File
@@ -9,6 +9,7 @@
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-markdown": "^6.3.1",
"@codemirror/language": "^6.10.8",
"@codemirror/language-data": "^6.5.2",
"@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.2",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -34,22 +35,62 @@
"@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="],
"@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="],
"@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="],
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
"@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="],
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="],
"@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="],
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
"@codemirror/lang-jinja": ["@codemirror/lang-jinja@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.4.0" } }, "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw=="],
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
"@codemirror/lang-less": ["@codemirror/lang-less@6.0.2", "", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ=="],
"@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.3.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-U9EnyzWuAx1TX3MZSKDXydKH734n1Aogk/ijKdW+jdIwmbf9zSPQYOIKggC/b2PTRjQb7ddvMktmxnfTLuv/SQ=="],
"@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="],
"@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="],
"@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="],
"@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="],
"@codemirror/lang-sass": ["@codemirror/lang-sass@6.0.2", "", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/sass": "^1.0.0" } }, "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q=="],
"@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="],
"@codemirror/lang-vue": ["@codemirror/lang-vue@0.1.3", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug=="],
"@codemirror/lang-wast": ["@codemirror/lang-wast@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q=="],
"@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="],
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="],
"@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="],
"@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="],
"@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.5", "", { "dependencies": { "@codemirror/streamparser": "^6.0.0" } }, "sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg=="],
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
"@codemirror/search": ["@codemirror/search@6.7.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA=="],
"@codemirror/state": ["@codemirror/state@6.7.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA=="],
"@codemirror/streamparser": ["@codemirror/streamparser@6.0.0", "", {}, "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w=="],
"@codemirror/view": ["@codemirror/view@6.43.11", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
@@ -120,18 +161,38 @@
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/cpp": ["@lezer/cpp@1.1.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA=="],
"@lezer/css": ["@lezer/css@1.3.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g=="],
"@lezer/go": ["@lezer/go@1.0.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="],
"@lezer/java": ["@lezer/java@1.1.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q=="],
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@lezer/markdown": ["@lezer/markdown@1.7.2", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ=="],
"@lezer/php": ["@lezer/php@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.1.0" } }, "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw=="],
"@lezer/python": ["@lezer/python@1.1.19", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ=="],
"@lezer/rust": ["@lezer/rust@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ=="],
"@lezer/sass": ["@lezer/sass@1.1.0", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ=="],
"@lezer/xml": ["@lezer/xml@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="],
"@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
+1
View File
@@ -21,6 +21,7 @@
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-markdown": "^6.3.1",
"@codemirror/language": "^6.10.8",
"@codemirror/language-data": "^6.5.2",
"@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.2",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
+98 -4
View File
@@ -17,6 +17,7 @@
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte'
import Backlinks, { openBacklink } from './lib/Backlinks.svelte'
import CardSheet from './lib/CardSheet.svelte'
import History, { moveHistory, restoreShown } from './lib/History.svelte'
import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte'
@@ -669,7 +670,7 @@
workspace.cardCursor = Math.max((workspace.list?.cards.length ?? 0) - 1, 0)
return true
case 'Enter':
void workspace.guard(() => workspace.openCard())
workspace.openCardSheet()
return true
case 'o':
case 'c':
@@ -708,6 +709,90 @@
}
}
/*
* The card sheet reads the list keys over its checklist, and the board's
* card keys for the card itself — `D`, `H`/`L`, `R` act on it as they do
* on the board under it. While the description is being written the keys
* are the editor's.
*/
function cardKey(event: KeyboardEvent): boolean {
const key = event.key
const previous = pending
pending = ''
if (previous === 'g' && key === 'g') return void workspace.moveTask(-Infinity), true
if (previous === 'g' && key === 'f') {
workspace.overlay = 'none'
void workspace.guard(() => workspace.openCard())
return true
}
if (previous === 'd' && key === 'd') return void workspace.guard(() => workspace.deleteTask()), true
switch (key) {
case 'g':
case 'd':
pending = key
return true
case 'j':
case 'ArrowDown':
void workspace.moveTask(1)
return true
case 'k':
case 'ArrowUp':
void workspace.moveTask(-1)
return true
case 'G':
void workspace.moveTask(Infinity)
return true
case 'J':
void workspace.guard(() => workspace.nudgeTask(1))
return true
case 'K':
void workspace.guard(() => workspace.nudgeTask(-1))
return true
case ' ':
case 'x':
void workspace.guard(() => workspace.toggleTask())
return true
case 'o':
cmdline = ':task '
return true
case 'r': {
const task = workspace.card?.checklist[workspace.taskCursor]
if (task) cmdline = `:task! ${task.text}`
return true
}
case 'e':
case 'i':
case 'Enter':
workspace.describe()
return true
case 'a':
cmdline = ':label '
return true
case 'R':
cmdline = `:name ${workspace.card?.title ?? ''}`
return true
case 'D':
cmdline = ':due '
return true
case 'H':
void workspace.guard(() => workspace.sendCard(-1))
return true
case 'L':
void workspace.guard(() => workspace.sendCard(1))
return true
case ':':
cmdGlobal = false
cmdline = ':'
return true
case 'q':
case 'Escape':
workspace.closeCardSheet()
return true
default:
return false
}
}
function isTypingTarget(target: EventTarget | null) {
const node = target as HTMLElement | null
return (
@@ -722,7 +807,8 @@
function onkeydown(event: KeyboardEvent) {
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
if (workspace.open) void workspace.guard(() => workspace.save())
if (workspace.describing) void workspace.guard(() => workspace.saveDescription())
else if (workspace.open) void workspace.guard(() => workspace.save())
return
}
@@ -742,6 +828,12 @@
// The command line over an overlay — `D` in the agenda — keeps its keys.
const listening = cmdline === null && !isTypingTarget(event.target)
if (workspace.overlay === 'card' && !workspace.describing && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (cardKey(event)) {
event.preventDefault()
return
}
}
if (workspace.overlay === 'help' && listening && !event.metaKey && !event.altKey) {
if (helpKey(event)) {
event.preventDefault()
@@ -801,6 +893,8 @@
}
if (event.key === 'Escape') {
// The description's editor has had its Esc; the sheet stays up under it.
if (workspace.describing) return
if (workspace.overlay !== 'none') {
workspace.overlay = 'none'
return
@@ -1285,9 +1379,9 @@
{#if workspace.overlay !== 'none'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay === 'card' ? workspace.closeCardSheet() : (workspace.overlay = 'none'))}
>
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else if workspace.overlay === 'card'}<CardSheet />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
+87 -4
View File
@@ -1,9 +1,11 @@
/* ── palette ─────────────────────────────────────────────────────────────── */
/*
* YoRHa dark, the palette of the desktop this runs on (quickshell Theme.qml,
* hypr palette.lua) and of narl.io. A green-black ramp, sage-grey text, and
* only two colours that mean anything: `light` is focus, selection and
* whatever is live; `alarm` is something wrong. Nothing else carries hue.
* hypr palette.lua, nvim's yorha.lua) and of narl.io. A green-black ramp,
* sage-grey text, and only two colours that mean anything: `light` is focus,
* selection and whatever is live; `alarm` is something wrong. The earth hues
* below are the terminal's, pulled down so they sit in the void: they colour
* code, the way nvim colours it, and a card's labels — and nothing else.
*/
:root {
--ink: #090a08;
@@ -24,6 +26,17 @@
--light-soft: #bcc1bf;
--alarm: #c8684f;
--sand: #c9b27c;
--sand-br: #d8c491;
--moss: #8fa37e;
--moss-br: #a5b893;
--slate: #7f9aa8;
--slate-br: #96afbc;
--heather: #a48ba0;
--heather-br: #b8a0b4;
--dusk: #86a8a0;
--dusk-br: #9cbdb4;
--font-sans: "Noto Sans Variable", "Noto Sans", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace;
@@ -512,7 +525,77 @@ input::placeholder { color: var(--muted); }
overflow-x: auto;
margin: 0 0 1em;
}
.md pre code { background: none; border: 0; padding: 0; font-size: inherit; }
.md pre code { background: none; border: 0; padding: 0; font-size: inherit; color: var(--fg); }
/* Inline code is moss on a lift and a block sits on the hull, as
render-markdown draws them in nvim. */
.md :not(pre) > code { color: var(--moss); }
.md pre { background: var(--hull); }
/* A card's label: its hue on a breath of the same, as nvim draws virtual text. */
.chip {
padding: 0 7px;
border-radius: var(--radius-s);
font-family: var(--font-mono);
font-size: 10px;
line-height: 16px;
letter-spacing: 0.06em;
color: var(--hue);
background: color-mix(in oklab, var(--hue) 14%, transparent);
}
.chip.sand { --hue: var(--sand); }
.chip.moss { --hue: var(--moss); }
.chip.dusk { --hue: var(--dusk); }
.chip.slate { --hue: var(--slate-br); }
.chip.heather { --hue: var(--heather); }
/* ── syntax ────────────────────────────────────────────────────────────── */
/*
* nvim's yorha.lua, group for group: structure is sand, names are the text,
* data is moss and heather, shapes are dusk, punctuation recedes into the
* grey. The classes are `highlight.ts`'s, and the same in the editor and in
* a rendered code block, so code reads alike in both.
*/
.tok-comment { color: var(--muted); font-style: italic; }
.tok-keyword { color: var(--sand); }
.tok-string { color: var(--moss); }
.tok-escape { color: var(--dusk-br); }
.tok-regexp { color: var(--dusk); }
.tok-number { color: var(--heather-br); }
.tok-constant { color: var(--heather); }
.tok-builtin { color: var(--heather); font-style: italic; }
.tok-variable { color: var(--fg); }
.tok-parameter { color: var(--subtle); font-style: italic; }
.tok-property { color: var(--slate-br); }
.tok-function { color: var(--light); }
.tok-type { color: var(--dusk); }
.tok-type-builtin { color: var(--dusk); font-style: italic; }
.tok-namespace { color: var(--light-soft); }
.tok-macro { color: var(--heather); }
.tok-tag { color: var(--sand); }
.tok-attribute { color: var(--slate-br); font-style: italic; }
.tok-operator { color: var(--subtle); }
.tok-punctuation { color: var(--muted); }
.tok-invalid { color: var(--alarm); font-weight: 700; }
.tok-inserted { color: var(--moss); }
.tok-deleted { color: var(--alarm); }
.tok-changed { color: var(--sand); }
/* markdown, as @markup.* is drawn */
.tok-heading1 { color: var(--light); font-weight: 700; }
.tok-heading2 { color: var(--light-soft); font-weight: 700; }
.tok-heading3 { color: var(--fg); font-weight: 700; }
.tok-heading4 { color: var(--subtle); font-weight: 700; }
.tok-heading5 { color: var(--subtle); }
.tok-heading6 { color: var(--muted); }
.tok-strong { color: var(--light); font-weight: 700; }
.tok-emphasis { font-style: italic; }
.tok-strikethrough { text-decoration: line-through; }
.tok-link { color: var(--slate-br); }
.tok-url { color: var(--slate); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--slate) 50%, transparent); }
.tok-raw { color: var(--moss); }
.tok-quote { color: var(--muted); font-style: italic; }
.tok-list { color: var(--light-dim); }
.tok-mark { color: var(--muted); }
.tok-rule { color: var(--rim); }
/* A long equation scrolls on its own rather than widening the page. */
.md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; }
+21 -20
View File
@@ -4,9 +4,8 @@
* idiom as the list. The keys live in App.svelte with every other key, so
* this file only draws and — for a pointer — moves the two cursors.
*/
import type { Card } from './api'
import { workspace } from './store.svelte'
import { formatDay } from './util'
import { formatDay, labelHue, urgency } from './util'
let columns = $state<HTMLDivElement>()
@@ -16,19 +15,6 @@
columns?.querySelector<HTMLElement>(`[data-at="${at}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
})
const midnight = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return Math.floor(today.getTime() / 1000)
}
/** `overdue`, `today` or nothing — a date only earns colour when it bites. */
function urgency(card: Card): '' | 'today' | 'overdue' {
if (card.due_at === null || card.done_at !== null) return ''
const start = midnight()
if (card.due_at < start) return 'overdue'
return card.due_at < start + 86400 ? 'today' : ''
}
const total = $derived(workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0)
const lastIndex = $derived((workspace.board?.lists.length ?? 0) - 1)
@@ -95,15 +81,27 @@
class:done={card.done_at !== null}
data-at="{listIndex}:{cardIndex}"
onclick={() => pick(listIndex, cardIndex)}
ondblclick={() => workspace.guard(() => workspace.openCard())}
title={card.node_id === null ? 'Enter starts a document for it' : 'Enter opens its document'}
ondblclick={() => workspace.openCardSheet()}
title="Enter turns the card over"
>
{#if card.labels.length}
<span class="labels">
{#each card.labels as label (label)}<span class="chip {labelHue(label)}">{label}</span>{/each}
</span>
{/if}
<span class="title">
<span class="tick" class:ticked={card.done_at !== null}></span>
<span class="text">{card.title}</span>
</span>
{#if card.due_at !== null || card.node_id !== null || card.repeat !== null}
{#if card.due_at !== null || card.node_id !== null || card.repeat !== null || card.checklist.length || card.body.trim()}
{@const ticked = card.checklist.filter((task) => task.done).length}
<span class="meta">
{#if card.body.trim()}
<span title="has a description">≡</span>
{/if}
{#if card.checklist.length}
<span class:complete={ticked === card.checklist.length} title="steps done">☑ {ticked}/{card.checklist.length}</span>
{/if}
{#if card.due_at !== null}
<span class={urgency(card)}>due {formatDay(card.due_at)}</span>
{/if}
@@ -147,7 +145,7 @@
<span><span class="kbd">h</span><span class="kbd">j</span><span class="kbd">k</span><span class="kbd">l</span> move</span>
<span><span class="kbd">H</span><span class="kbd">L</span> send</span>
<span><span class="kbd">␣</span> done</span>
<span><span class="kbd">↵</span> notes</span>
<span><span class="kbd">↵</span> open</span>
<span><span class="kbd">o</span> new</span>
<span><span class="kbd">D</span> due</span>
<span><span class="kbd">R</span> rename</span>
@@ -286,7 +284,7 @@
.card:hover { background: var(--surface-alt); color: var(--light); border-color: transparent; }
.card:active { transform: none; }
.card.active, .card:focus-visible { background: var(--light); color: var(--ink); }
.card.active :is(.meta, .today, .overdue, .doc) { color: var(--ink); }
.card.active :is(.meta, .today, .overdue, .doc, .complete) { color: var(--ink); }
.card.active .tick { border-color: var(--ink); }
.card.active .tick.ticked { background: var(--ink); }
.card.done .text { text-decoration: line-through; color: var(--muted); }
@@ -304,6 +302,9 @@
.tick.ticked { background: var(--light-dim); border-color: var(--light-dim); }
.meta { display: flex; gap: 10px; font-size: 11px; padding-left: 18px; color: var(--muted); }
.meta .today { color: var(--light); }
.meta .complete { color: var(--moss); }
.card.active .chip { color: var(--ink); background: color-mix(in oklab, var(--hue) 55%, transparent); }
.labels { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 18px; }
.meta .overdue { color: var(--alarm); }
.doc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 24ch; }
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
/*
* A card's description, written in place on its sheet: the same editor as
* a document's — vim, markdown, the colours — over `workspace.description`
* instead of a tile. `:w` writes it to the card, `:q` leaves, and Esc in
* normal mode leaves too when nothing is unsaved, since there is no tree
* underneath to go back to.
*/
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { indentOnInput } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import { drawSelection, EditorView, highlightActiveLine, keymap, placeholder } from '@codemirror/view'
import { getCM, Vim, vim } from '@replit/codemirror-vim'
import { onDestroy, onMount } from 'svelte'
import { bindVim } from './Editor.svelte'
import { editorTheme, highlighting, languages } from './editorTheme'
import { workspace } from './store.svelte'
let host = $state<HTMLDivElement>()
let view: EditorView | undefined
let mode = $state('normal')
/* Esc, before vim sees it: in normal mode it is the way out of the editor. */
function onkeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || (workspace.vimEnabled && mode !== 'normal')) return
event.preventDefault()
event.stopPropagation()
workspace.stopDescribing()
}
onMount(() => {
bindVim()
view = new EditorView({
parent: host!,
state: EditorState.create({
doc: workspace.description,
extensions: [
...(workspace.vimEnabled ? [vim({ status: false })] : []),
history(),
drawSelection(),
indentOnInput(),
highlightActiveLine(),
markdown({ base: markdownLanguage, codeLanguages: languages }),
highlighting,
EditorView.lineWrapping,
placeholder('what this card is about…'),
editorTheme,
EditorView.theme({ '.cm-content': { padding: '10px 14px', maxWidth: 'none' } }),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => (void workspace.guard(() => workspace.saveDescription()), true),
},
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
workspace.description = update.state.doc.toString()
workspace.descriptionDirty = true
}),
],
}),
})
view.focus()
const cm = workspace.vimEnabled ? getCM(view) : null
if (cm) {
const emitter = cm as unknown as { on?: (event: string, handler: (e: { mode: string }) => void) => void }
emitter.on?.('vim-mode-change', (event) => (mode = event.mode))
// An empty description is there to be written, so it starts in insert mode.
if (!workspace.description.trim()) Vim.handleKey(cm, 'i', 'workspace')
} else mode = 'insert'
})
onDestroy(() => {
view?.destroy()
view = undefined
})
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="writing" onkeydowncapture={onkeydown}>
<div class="editor" bind:this={host}></div>
<div class="modeline label">
<span class="mode" class:insert={mode !== 'normal'}>{mode}</span>
{#if workspace.descriptionDirty}<span class="dirty">+</span>{/if}
<span class="hint"><span class="kbd">:w</span> write · <span class="kbd">:q</span> leave · <span class="kbd">:q!</span> discard</span>
</div>
</div>
<style>
.writing {
display: flex;
flex-direction: column;
border: 1px solid var(--light-dim);
border-radius: var(--radius-m);
background: var(--base);
overflow: hidden;
}
.editor { min-height: 10em; max-height: 50vh; overflow: auto; }
.modeline {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 12px;
border-top: 1px solid var(--rim);
background: var(--hull);
font-size: 10px;
}
.mode { padding: 0 8px; border-radius: var(--radius-s); background: var(--light-soft); color: var(--ink); }
.mode.insert { background: var(--light); }
.dirty { color: var(--light); }
.hint { margin-left: auto; color: var(--muted); text-transform: none; letter-spacing: 0.04em; }
</style>
+204
View File
@@ -0,0 +1,204 @@
<script lang="ts">
/*
* Enter on a card turns it over: a window floated over the board, as the
* desktop floats one, with what the card holds on its back — labels, the
* description, the checklist. The keys live in App.svelte with the others;
* this draws and lets a pointer do the same.
*/
import { renderMarkdown } from './markdown'
import { workspace } from './store.svelte'
import { formatDay, labelHue, urgency } from './util'
let steps = $state<HTMLUListElement>()
const card = $derived(workspace.card)
const list = $derived(workspace.list)
const lists = $derived(workspace.board?.lists.length ?? 0)
const role = $derived(lists < 2 ? '' : workspace.listCursor === 0 ? 'inbox' : workspace.listCursor === lists - 1 ? 'done' : '')
const ticked = $derived(card?.checklist.filter((task) => task.done).length ?? 0)
const html = $derived(card?.body.trim() ? renderMarkdown(card.body) : '')
// A card deleted or moved out of sight under the sheet takes the sheet with it.
$effect(() => {
if (!card) workspace.overlay = 'none'
})
$effect(() => {
const at = workspace.taskCursor
steps?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' })
})
function pick(index: number) {
workspace.taskCursor = index
}
function tick(index: number) {
workspace.taskCursor = index
void workspace.guard(() => workspace.toggleTask())
}
</script>
{#if card}
<div class="sheet">
<header>
<span class="label rule">
<span class="marker"></span><span class="title">card</span>
{workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{#if role}<span class="role">{role}</span>{/if}
</span>
<button onclick={() => workspace.closeCardSheet()}>esc</button>
</header>
<div class="scroll">
<h2 class:done={card.done_at !== null}>{card.title}</h2>
{#if card.labels.length || card.due_at !== null || card.repeat !== null || card.node_id !== null}
<div class="meta">
{#each card.labels as label (label)}<span class="chip {labelHue(label)}">{label}</span>{/each}
{#if card.due_at !== null}<span class={urgency(card)}>due {formatDay(card.due_at)}</span>{/if}
{#if card.repeat !== null}<span>↻ {card.repeat}</span>{/if}
{#if card.node_id !== null}
<button class="doc" onclick={() => workspace.guard(() => workspace.openCard())}>→ {card.node_name ?? `#${card.node_id}`}</button>
{/if}
</div>
{/if}
<section>
<div class="label head"><span class="marker"></span>description</div>
{#if workspace.describing}
{#await import('./CardEditor.svelte')}
<div class="empty label">loading editor</div>
{:then module}
<module.default />
{/await}
{:else if html}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<article class="md description" ondblclick={() => workspace.describe()}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
</article>
{:else}
<button class="empty" onclick={() => workspace.describe()}>nothing written yet — <span class="kbd">e</span> writes it</button>
{/if}
</section>
<section>
<div class="label head">
<span class="marker"></span>checklist
{#if card.checklist.length}
<span class="count" class:complete={ticked === card.checklist.length}>{ticked}/{card.checklist.length}</span>
{/if}
</div>
{#if card.checklist.length}
<div class="progress"><span style="width: {(ticked / card.checklist.length) * 100}%"></span></div>
{/if}
<ul bind:this={steps}>
{#each card.checklist as task, index (index)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.taskCursor && !workspace.describing} class:ticked={task.done} data-index={index} onclick={() => pick(index)}>
<button class="tick" aria-label={task.done ? 'untick' : 'tick'} onclick={(e) => (e.stopPropagation(), tick(index))}></button>
<span class="text">{task.text}</span>
</li>
{:else}
<li class="none muted">no steps — <span class="kbd">o</span> adds one</li>
{/each}
</ul>
</section>
</div>
<footer class="label">
<span><span class="kbd">j</span><span class="kbd">k</span> step</span>
<span><span class="kbd">␣</span> tick</span>
<span><span class="kbd">o</span> add</span>
<span><span class="kbd">r</span> reword</span>
<span><span class="kbd">J</span><span class="kbd">K</span> order</span>
<span><span class="kbd">dd</span> drop</span>
<span><span class="kbd">e</span> describe</span>
<span><span class="kbd">a</span> label</span>
<span><span class="kbd">D</span> due</span>
<span><span class="kbd">H</span><span class="kbd">L</span> send</span>
<span><span class="kbd">gf</span> notes</span>
<span><span class="kbd">q</span> close</span>
</footer>
</div>
{/if}
<style>
/* A float, as Hyprland draws one over the tiles: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 100%;
width: min(100%, 76ch);
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
animation: resolve var(--t-slow) var(--ease-snap) both;
overflow: hidden;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.role { margin-left: 8px; color: var(--muted); font-size: 10px; letter-spacing: 0.18em; }
.scroll { display: flex; flex-direction: column; gap: 18px; min-height: 0; overflow-y: auto; }
h2 { margin: 0; font-family: var(--font-sans); font-size: 20px; font-weight: 600; color: var(--light); }
h2.done { text-decoration: line-through; color: var(--muted); }
.meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; font-size: 11px; color: var(--muted); }
.meta .today { color: var(--light); }
.meta .overdue { color: var(--alarm); }
.meta .doc { padding: 0 6px; border: 0; font-size: 11px; text-transform: none; letter-spacing: 0; color: var(--slate-br); }
section { display: flex; flex-direction: column; gap: 8px; }
.head { display: flex; align-items: center; gap: 10px; }
.count { font-variant-numeric: tabular-nums; }
.count.complete { color: var(--moss); }
.description { padding: 4px 2px; font-size: 14px; }
.description :global(> :last-child) { margin-bottom: 0; }
.empty { align-self: flex-start; border: 0; padding: 4px 2px; font-size: 12px; text-transform: none; letter-spacing: 0; color: var(--muted); }
.progress { height: 2px; border-radius: 1px; background: var(--surface-alt); overflow: hidden; }
.progress span { display: block; height: 100%; background: var(--moss); transition: width var(--t-base) var(--ease-glide); }
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
li {
display: flex;
align-items: baseline;
gap: 10px;
padding: 5px 8px;
border-radius: var(--radius-s);
font-family: var(--font-sans);
font-size: 14px;
cursor: default;
}
li:hover { background: var(--surface); }
li.at { background: var(--light); color: var(--ink); }
li.ticked .text { text-decoration: line-through; color: var(--muted); }
li.at.ticked .text { color: var(--ink); }
li.none { font-family: var(--font-mono); font-size: 12px; }
.tick {
width: 11px;
height: 11px;
flex: none;
padding: 0;
border: 1px solid var(--outline);
border-radius: 2px;
transform: translateY(1px);
}
li.ticked .tick { background: var(--moss); border-color: var(--moss); }
li.at .tick { border-color: var(--ink); }
li.at.ticked .tick { background: var(--ink); }
footer { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 10px; }
footer > span { display: flex; gap: 3px; align-items: center; }
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
</style>
+5 -70
View File
@@ -120,7 +120,7 @@
Vim.mapCommand('gT', 'action', 'workspacePrevTile', {}, { context: 'normal' })
}
function bindVim() {
export function bindVim() {
if (vimBound) return
vimBound = true
bridgeExCommands()
@@ -136,7 +136,7 @@
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
import { indentOnInput } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import {
drawSelection,
@@ -145,10 +145,10 @@
keymap,
placeholder,
} from '@codemirror/view'
import { tags } from '@lezer/highlight'
import { onDestroy, onMount } from 'svelte'
import { api } from './api'
import { hooks } from './commands'
import { editorTheme, highlighting, languages } from './editorTheme'
import { embedSnippet } from './markdown'
import { workspace, type Tile } from './store.svelte'
@@ -159,71 +159,6 @@
let view: EditorView | undefined
const focus = () => view?.focus()
const markdownHighlight = HighlightStyle.define([
{ tag: tags.heading, color: 'var(--light)', fontWeight: '600' },
{ tag: tags.strong, color: 'var(--light)', fontWeight: '700' },
{ tag: tags.emphasis, color: 'var(--subtle)', fontStyle: 'italic' },
{ tag: tags.link, color: 'var(--light)', textDecoration: 'underline', textDecorationColor: 'var(--overlay)' },
{ tag: tags.url, color: 'var(--light-dim)' },
{ tag: tags.monospace, color: 'var(--light-soft)', backgroundColor: 'var(--surface)' },
{ tag: tags.quote, color: 'var(--subtle)', fontStyle: 'italic' },
{ tag: tags.list, color: 'var(--light-dim)' },
{ tag: tags.contentSeparator, color: 'var(--muted)' },
{ tag: tags.processingInstruction, color: 'var(--muted)' },
])
/*
* CodeMirror's base theme paints panels and the caret for a light editor
* (`&light .cm-panels { color: black }`), and those selectors outrank
* anything the app stylesheet can say. Theme rules do win, so the parts that
* would otherwise render black on black live here rather than in app.css.
* `dark: true` also stops the light defaults applying in the first place.
*/
const editorTheme = EditorView.theme(
{
'&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' },
'.cm-content': { padding: '12px 28px', maxWidth: '90ch' },
'.cm-line': { padding: '0' },
'.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--light)' },
'.cm-panels': {
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
borderColor: 'var(--rim)',
},
'.cm-panels-bottom': { borderTop: '1px solid var(--rim)' },
/*
* The `:` prompt is a bare text node in the panel; the input follows it.
* The input is an inline-block whose baseline sits below the text node's,
* so the two are laid out as flex items instead of aligned by baseline.
*/
'.cm-vim-panel': {
padding: '4px 12px',
color: 'var(--light)',
display: 'flex',
alignItems: 'center',
},
'.cm-tooltip': {
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
border: '1px solid var(--rim)',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--surface-alt)',
color: 'var(--light)',
},
'.cm-completionDetail': { color: 'var(--muted)', fontStyle: 'normal', marginLeft: '1ch' },
'.cm-vim-panel input': {
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'var(--light)',
caretColor: 'var(--light)',
flex: '1',
},
},
{ dark: true },
)
/*
* `[[` offers the titles of documents as the search finds them, each with
* where it sits; picking one closes the brackets.
@@ -298,9 +233,9 @@
indentOnInput(),
highlightActiveLine(),
EditorState.allowMultipleSelections.of(true),
markdown({ base: markdownLanguage, codeLanguages: [] }),
markdown({ base: markdownLanguage, codeLanguages: languages }),
autocompletion({ override: [titleCompletions], icons: false }),
syntaxHighlighting(markdownHighlight),
highlighting,
EditorView.lineWrapping,
placeholder('write…'),
editorTheme,
+19 -1
View File
@@ -106,7 +106,7 @@
['J / K', 'move the card down / up its list'],
['H / L', 'move the card to the list left / right'],
['Space', 'into the done list, or back out to the inbox'],
['Enter', "the card's notes — a document, made on first use; q comes back"],
['Enter', 'turn the card over: its labels, description and checklist'],
['o · c', 'new card on this list'],
['D', 'due date: fri, tm, +3d, 10-31 …'],
['R', 'rename the card, or the list when it is empty'],
@@ -117,6 +117,22 @@
['q · Esc', 'leave the special workspace'],
]
const cardKeys: [string, string][] = [
['j / k', 'down / up the checklist'],
['gg / G', 'first / last step'],
['Space · x', 'tick the step, or untick it'],
['o', 'a new step below the cursor — :task on the line'],
['r', 'reword the step'],
['J / K', 'move the step down / up'],
['dd', 'drop the step'],
['e · i · Enter', 'write the description, in the editor — :w writes it, :q or Esc in normal mode leaves'],
['a', 'put on a label, or take it off — :label on the line, Tab offers the board\'s'],
['R · D', 'rename the card, its due date'],
['H / L', 'send the card to the list left / right'],
['gf', "the card's notes — a document, made on first use; q comes back"],
['q · Esc', 'back to the board'],
]
const agendaKeys: [string, string][] = [
['j / k', 'down / up the cards, then what was written on this day in earlier years, then into the calendar'],
['h', 'to the board'],
@@ -159,6 +175,7 @@
['vim', 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes'],
['drop · paste', 'an image, video or audio file attaches it'],
['$x^2$ · $$…$$', 'set as math'],
['```rust', 'a fenced block in a language is coloured as nvim colours it, written and read'],
['```typst', 'a block typst sets, shown as an image — math, tables, figures; no packages or files'],
[':12', 'jumps straight to entry 12'],
['y · d', 'copied to the system clipboard — :set noclipboard keeps them in the registers'],
@@ -168,6 +185,7 @@
const sections: [string, Row[]][] = [
['keys', fromPairs(keys)],
['board keys', fromPairs(boardKeys)],
['card keys', fromPairs(cardKeys)],
['agenda keys', fromPairs(agendaKeys)],
['in the editor', fromPairs(editorKeys)],
...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]),
+12
View File
@@ -170,11 +170,20 @@ export interface Card {
done_at: number | null
/** `daily`, `weekly`, `monthly`, `yearly`, `+3d`, `+2w`, `+6m` — or none. */
repeat: string | null
checklist: Task[]
/** Lower case, each once. */
labels: string[]
position: number
created_at: number
updated_at: number
}
/** A step on a card's checklist. */
export interface Task {
text: string
done: boolean
}
/** A card after a move or an edit, and the copy a repeating card it finished left in the inbox. */
export interface Moved extends Card {
again: Card | null
@@ -564,6 +573,9 @@ export const api = {
node_id?: number | null
due_at?: number | null
repeat?: string | null
/** The whole checklist, as it should now read. */
checklist?: Task[]
labels?: string[]
done?: boolean
},
) => request<Moved>(`/cards/${id}`, { method: 'PUT', body: body(patch) }),
+68
View File
@@ -0,0 +1,68 @@
/*
* A fenced code block, coloured the way the editor colours it: the same
* parser for the language named after the fence, the same classes. Each
* language is fetched the first time a block on the page is written in it,
* as KaTeX is for math; until it arrives the block shows plain. The languages
* are state, so markdown rendered without one renders again once it is here.
*/
import type { Language } from '@codemirror/language'
/** What colours a block once there is one to colour, fetched with its first language. */
type Engine = { highlightCode: typeof import('@lezer/highlight').highlightCode; highlighter: typeof import('./highlight').highlighter }
let engine: Engine | null = null
/** By the name after the fence; null for a name no language answers to. */
let loaded = $state.raw(new Map<string, Language | null>())
const asked = new Set<string>()
/** The block as coloured HTML, or null while its language loads or when there is none. */
export function highlighted(source: string, fence: string): string | null {
const name = fence.trim().split(/\s/)[0].toLowerCase()
if (!name) return null
const language = loaded.get(name)
if (language === undefined) {
load(name)
return null
}
if (language === null || !engine) return null
const { highlightCode, highlighter } = engine
let html = ''
highlightCode(
source,
language.parser.parse(source),
highlighter,
(text, classes) => (html += classes ? `<span class="${classes}">${escape(text)}</span>` : escape(text)),
() => (html += '\n'),
)
return html
}
function load(name: string) {
if (asked.has(name)) return
asked.add(name)
const settle = (language: Language | null) => (loaded = new Map(loaded).set(name, language))
// The list of languages and the parsers' machinery come with CodeMirror,
// which the reader does not otherwise need, so they are fetched with the
// first language.
Promise.all([
import('@codemirror/language'),
import('@codemirror/language-data'),
import('@lezer/highlight'),
import('./highlight'),
])
.then(([{ LanguageDescription }, { languages }, { highlightCode }, { highlighter }]) => {
engine = { highlightCode, highlighter }
const description = LanguageDescription.matchLanguageName(languages, name, true)
return description ? description.load().then((support) => support.language) : null
})
.then(settle)
.catch((e) => {
console.error(`code: could not load ${name}`, e)
settle(null)
})
}
function escape(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
+41 -2
View File
@@ -298,8 +298,8 @@ export const commands: CommandSpec[] = [
name: 'write',
aliases: ['w'],
group: 'editing',
help: 'save the open document',
run: () => workspace.guard(() => workspace.save()),
help: "save the open document — or the card's description being written",
run: () => workspace.guard(() => (workspace.describing ? workspace.saveDescription() : workspace.save())),
bang: {
help: 'save even over a newer version saved somewhere else since this one was opened',
run: () => workspace.guard(() => workspace.save({ force: true })),
@@ -311,6 +311,11 @@ export const commands: CommandSpec[] = [
group: 'editing',
help: 'save and leave insert/editor mode',
run: async () => {
if (workspace.describing) {
await workspace.guard(() => workspace.saveDescription())
workspace.stopDescribing()
return
}
await workspace.guard(() => workspace.save())
workspace.editing = false
hooks.focusList()
@@ -330,6 +335,7 @@ export const commands: CommandSpec[] = [
group: 'editing',
help: 'close the editor, or the document when already reading',
run: async () => {
if (workspace.describing) return void workspace.stopDescribing()
// A document `o` made and nothing was written in is not kept.
if (await workspace.abandonIfBlank()) {
if (workspace.returnTo === 'special') await workspace.guard(() => workspace.returnToSpecial())
@@ -348,6 +354,7 @@ export const commands: CommandSpec[] = [
bang: {
help: 'discard changes and close the editor',
run: async () => {
if (workspace.describing) return void workspace.stopDescribing(true)
const open = workspace.open
workspace.editing = false
workspace.dirty = false
@@ -508,6 +515,38 @@ export const commands: CommandSpec[] = [
return workspace.guard(() => workspace.toggleDone())
},
},
{
name: 'task',
aliases: ['step'],
args: '<text>',
group: 'board',
help: "add a step to the open card's checklist, below the cursor",
run: async (arg) => {
if (workspace.overlay !== 'card') return workspace.say('no card open — Enter on the board opens one', 'error')
if (!arg.trim()) return workspace.say('usage: :task <text>', 'error')
await workspace.guard(() => workspace.addTask(arg.trim()))
},
bang: {
help: 'reword the step under the cursor',
run: async (arg) => {
if (workspace.overlay !== 'card') return workspace.say('no card open — Enter on the board opens one', 'error')
if (!arg.trim()) return workspace.say('usage: :task! <text>', 'error')
await workspace.guard(() => workspace.renameTask(arg.trim()))
},
},
},
{
name: 'label',
args: '<name>',
group: 'board',
help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
complete: () => workspace.boardLabels,
run: async (arg) => {
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
if (!arg.trim()) return workspace.say(workspace.card?.labels.join(' · ') || 'no labels — :label <name> adds one')
await workspace.guard(() => workspace.toggleLabel(arg))
},
},
{
name: 'list',
args: '<name>',
+65
View File
@@ -0,0 +1,65 @@
/*
* What every editor on the page looks like: the frame of it, and the colours
* of what is written in it, in any language a fence names.
*/
import { syntaxHighlighting } from '@codemirror/language'
import { languages } from '@codemirror/language-data'
import { EditorView } from '@codemirror/view'
import { highlighter } from './highlight'
/** The languages a fence can be written in, each fetched the first time one is. */
export { languages }
export const highlighting = syntaxHighlighting(highlighter)
/*
* CodeMirror's base theme paints panels and the caret for a light editor
* (`&light .cm-panels { color: black }`), and those selectors outrank
* anything the app stylesheet can say. Theme rules do win, so the parts that
* would otherwise render black on black live here rather than in app.css.
* `dark: true` also stops the light defaults applying in the first place.
*/
export const editorTheme = EditorView.theme(
{
'&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' },
'.cm-content': { padding: '12px 28px', maxWidth: '90ch' },
'.cm-line': { padding: '0' },
'.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--light)' },
'.cm-panels': {
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
borderColor: 'var(--rim)',
},
'.cm-panels-bottom': { borderTop: '1px solid var(--rim)' },
/*
* The `:` prompt is a bare text node in the panel; the input follows it.
* The input is an inline-block whose baseline sits below the text node's,
* so the two are laid out as flex items instead of aligned by baseline.
*/
'.cm-vim-panel': {
padding: '4px 12px',
color: 'var(--light)',
display: 'flex',
alignItems: 'center',
},
'.cm-tooltip': {
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
border: '1px solid var(--rim)',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--surface-alt)',
color: 'var(--light)',
},
'.cm-completionDetail': { color: 'var(--muted)', fontStyle: 'normal', marginLeft: '1ch' },
'.cm-vim-panel input': {
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'var(--light)',
caretColor: 'var(--light)',
flex: '1',
},
},
{ dark: true },
)
+55
View File
@@ -0,0 +1,55 @@
/*
* The colours of code, as classes — `tok-keyword`, `tok-string` — painted in
* app.css from nvim's yorha.lua. The editor (editorTheme.ts) and a rendered
* code block (code.svelte.ts) are both given these, so a fence reads the same
* written and read. Only lezer here: the reader loads it with the app, and
* CodeMirror comes only with an editor.
*/
import { tagHighlighter, tags as t } from '@lezer/highlight'
/** Lezer's tags onto yorha.lua's groups. The most specific tag that matches wins. */
export const highlighter = tagHighlighter([
{ tag: t.comment, class: 'tok-comment' },
{ tag: [t.keyword, t.labelName], class: 'tok-keyword' },
{ tag: [t.string, t.character, t.attributeValue], class: 'tok-string' },
{ tag: [t.escape, t.special(t.string)], class: 'tok-escape' },
{ tag: t.regexp, class: 'tok-regexp' },
{ tag: [t.number, t.bool, t.null, t.unit], class: 'tok-number' },
{ tag: [t.atom, t.constant(t.variableName), t.constant(t.propertyName)], class: 'tok-constant' },
{ tag: [t.self, t.standard(t.variableName)], class: 'tok-builtin' },
{ tag: t.variableName, class: 'tok-variable' },
{ tag: [t.propertyName, t.definition(t.propertyName)], class: 'tok-property' },
{
tag: [t.function(t.variableName), t.function(t.propertyName), t.function(t.definition(t.variableName))],
class: 'tok-function',
},
{ tag: [t.typeName, t.className], class: 'tok-type' },
{ tag: t.standard(t.typeName), class: 'tok-type-builtin' },
{ tag: t.namespace, class: 'tok-namespace' },
{ tag: [t.macroName, t.meta, t.annotation], class: 'tok-macro' },
{ tag: t.tagName, class: 'tok-tag' },
{ tag: t.attributeName, class: 'tok-attribute' },
{ tag: t.operator, class: 'tok-operator' },
{ tag: [t.punctuation, t.angleBracket], class: 'tok-punctuation' },
{ tag: t.invalid, class: 'tok-invalid' },
{ tag: t.inserted, class: 'tok-inserted' },
{ tag: t.deleted, class: 'tok-deleted' },
{ tag: t.changed, class: 'tok-changed' },
{ tag: [t.heading, t.heading1], class: 'tok-heading1' },
{ tag: t.heading2, class: 'tok-heading2' },
{ tag: t.heading3, class: 'tok-heading3' },
{ tag: t.heading4, class: 'tok-heading4' },
{ tag: t.heading5, class: 'tok-heading5' },
{ tag: t.heading6, class: 'tok-heading6' },
{ tag: t.strong, class: 'tok-strong' },
{ tag: t.emphasis, class: 'tok-emphasis' },
{ tag: t.strikethrough, class: 'tok-strikethrough' },
{ tag: t.link, class: 'tok-link' },
{ tag: t.url, class: 'tok-url' },
{ tag: t.monospace, class: 'tok-raw' },
{ tag: t.quote, class: 'tok-quote' },
{ tag: t.list, class: 'tok-list' },
{ tag: t.processingInstruction, class: 'tok-mark' },
{ tag: t.contentSeparator, class: 'tok-rule' },
])
+4 -1
View File
@@ -1,5 +1,6 @@
import DOMPurify from 'dompurify'
import { marked, type TokenizerAndRendererExtension } from 'marked'
import { highlighted } from './code.svelte'
import { mathEngine } from './math.svelte'
import { typeset } from './typst.svelte'
import { hardWrapped, titleHref } from './util'
@@ -94,7 +95,9 @@ marked.use({
code(token) {
if (token.lang === 'math') return tex(token.text, true)
if (token.lang === 'typst' || token.lang === 'typ') return typst(token.text)
return false
const html = token.lang ? highlighted(token.text, token.lang) : null
if (html === null) return false
return `<pre><code class="language-${escapeHtml(token.lang!.split(/\s/)[0])}">${html}</code></pre>\n`
},
},
})
+156 -1
View File
@@ -17,6 +17,7 @@ import {
type Revision,
type RevisionSummary,
type SpaceOverview,
type Task,
type TrashItem,
type VaultStatus,
} from './api'
@@ -282,6 +283,13 @@ class Workspace {
})
listCursor = $state(0)
cardCursor = $state(0)
/** The step of the open card's checklist under the cursor. */
taskCursor = $state(0)
/** The open card's description is being written, and what it says so far. */
describing = $state(false)
description = $state('')
descriptionDirty = $state(false)
/** `z` on the board: show done cards older than a week too. */
showOldDone = $state(false)
/*
@@ -296,7 +304,7 @@ class Workspace {
pane = $state<Pane>('list')
flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks'>('none')
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks' | 'card'>('none')
/** Which of the special workspace's tiles the keys go to. */
specialFocus = $state<SpecialTile>('board')
/**
@@ -1679,6 +1687,153 @@ class Workspace {
this.say(`deleted "${card.title}"`)
}
/*
* The card sheet: one card opened up over its board, the way a card on a
* paper board is turned over to read its back — the description, the
* checklist, the labels. It is the card under the board's cursor, so the
* board keys that act on a card (`D`, `H`/`L`, `:repeat`) act on it here
* too, and the cursor follows it wherever a move puts it.
*/
openCardSheet() {
if (!this.card) return this.say('no card selected', 'error')
this.taskCursor = 0
this.describing = false
this.overlay = 'card'
}
/** `q`: the sheet away, unless that would lose a description being written. */
closeCardSheet() {
if (this.describing && !this.stopDescribing()) return
this.overlay = 'none'
}
/** `e`: write the description, from what it says now. */
describe() {
const card = this.card
if (!card) return
this.description = card.body
this.descriptionDirty = false
this.describing = true
}
async saveDescription() {
const card = this.card
if (!card || !this.describing) return
await this.patchCard({ body: this.description })
this.descriptionDirty = false
this.say(`"${card.title}" described`)
}
/** Stop writing; false, and a word why, while something is unsaved. */
stopDescribing(discard = false): boolean {
if (this.descriptionDirty && !discard) {
this.say('unsaved description — :w to write, :q! to discard', 'error')
return false
}
this.describing = false
this.descriptionDirty = false
return true
}
/*
* The checklist and the labels are written back whole, so two keys pressed
* faster than the board reloads would each start from the card as it was,
* and the second would undo the first. Each change waits for the one
* before it, and reads the card as that one left it.
*/
private cardWrites: Promise<unknown> = Promise.resolve()
private inTurn(change: () => Promise<unknown>): Promise<void> {
const next = this.cardWrites.then(change)
this.cardWrites = next.catch(() => {})
return next.then(() => {})
}
/** The checklist written back whole, with the cursor on step `at`. */
async writeChecklist(checklist: Task[], at = this.taskCursor) {
await this.patchCard({ checklist })
this.taskCursor = Math.min(Math.max(at, 0), Math.max(checklist.length - 1, 0))
}
/** `o` / `O`: a new step below or above the cursor. */
addTask(text: string, above = false) {
return this.inTurn(async () => {
const card = this.card
if (!card) return this.say('no card selected', 'error')
const tasks = [...card.checklist]
const at = tasks.length === 0 ? 0 : above ? this.taskCursor : this.taskCursor + 1
tasks.splice(at, 0, { text, done: false })
await this.writeChecklist(tasks, at)
})
}
/** Space: tick the step under the cursor, or untick it. */
toggleTask() {
return this.inTurn(async () => {
const tasks = this.card?.checklist ?? []
const task = tasks[this.taskCursor]
if (!task) return this.say('no step here — o adds one', 'error')
const next = tasks.map((t, i) => (i === this.taskCursor ? { ...t, done: !t.done } : t))
await this.writeChecklist(next)
this.say(`${next.filter((t) => t.done).length}/${next.length} done`)
})
}
renameTask(text: string) {
return this.inTurn(async () => {
const tasks = this.card?.checklist ?? []
if (!tasks[this.taskCursor]) return this.say('no step here — o adds one', 'error')
await this.writeChecklist(tasks.map((t, i) => (i === this.taskCursor ? { ...t, text } : t)))
})
}
deleteTask() {
return this.inTurn(async () => {
const tasks = this.card?.checklist ?? []
const task = tasks[this.taskCursor]
if (!task) return this.say('no step here', 'error')
await this.writeChecklist(tasks.filter((_, i) => i !== this.taskCursor))
this.say(`dropped "${task.text}"`)
})
}
/** `J` / `K`: the step takes the cursor with it. */
nudgeTask(delta: number) {
return this.inTurn(async () => {
const tasks = [...(this.card?.checklist ?? [])]
const to = this.taskCursor + delta
if (!tasks[this.taskCursor] || to < 0 || to >= tasks.length) return
;[tasks[this.taskCursor], tasks[to]] = [tasks[to], tasks[this.taskCursor]]
await this.writeChecklist(tasks, to)
})
}
/** `j`/`k`, and `gg`/`G` as ±Infinity — in turn too, so a move lands on the steps as written. */
moveTask(delta: number) {
return this.inTurn(async () => {
const count = this.card?.checklist.length ?? 0
this.taskCursor = Math.min(Math.max(this.taskCursor + delta, 0), Math.max(count - 1, 0))
})
}
/** `:label work`: on the card, or off it when it is on already. */
toggleLabel(name: string) {
return this.inTurn(async () => {
const card = this.card
if (!card) return this.say('no card selected', 'error')
const label = name.trim().toLowerCase()
const on = card.labels.includes(label)
await this.patchCard({ labels: on ? card.labels.filter((l) => l !== label) : [...card.labels, label] })
this.say(on ? `${label} off` : `${label} on`)
})
}
/** Every label on the board, for Tab on `:label`. */
get boardLabels(): string[] {
const all = (this.rawBoard?.lists ?? []).flatMap((list) => list.cards.flatMap((card) => card.labels))
return [...new Set(all)].sort()
}
async createList(name: string) {
if (this.boardSpaceId === null) return
this.rawBoard = await api.createList(this.boardSpaceId, name)
+22
View File
@@ -362,3 +362,25 @@ export function hitParts(excerpt: string): { text: string; lit: boolean }[] {
}
return parts
}
/*
* A label's colour, from its name alone, so the same label is the same hue on
* every card and every board without anything to set. The hues are the
* terminal's earth tones; rust stays out, because rust means wrong.
*/
const LABEL_HUES = ['sand', 'moss', 'dusk', 'slate', 'heather'] as const
export function labelHue(name: string): (typeof LABEL_HUES)[number] {
let hash = 0
for (const char of name) hash = (hash * 31 + char.codePointAt(0)!) >>> 0
return LABEL_HUES[hash % LABEL_HUES.length]
}
/** `overdue`, `today` or nothing — a card's date only earns colour when it bites. */
export function urgency(card: { due_at: number | null; done_at: number | null }): '' | 'today' | 'overdue' {
if (card.due_at === null || card.done_at !== null) return ''
const today = new Date()
today.setHours(0, 0, 0, 0)
const start = Math.floor(today.getTime() / 1000)
if (card.due_at < start) return 'overdue'
return card.due_at < start + 86400 ? 'today' : ''
}