Wekan logo

Wekan

Open source kanban board application built with Meteor

Alternative to: trello


About Versions (218)

v10.92

2026-08-15

Binaries in these bundles

Each bundle carries a Node.js, a FerretDB and the MongoDB Database Tools. Which source has a given CPU varies from release to release - nodejs.org builds some architectures, unofficial-builds others, and the wekan/node-patches build the ones neither of them does - and not every source publishes a checksum. This is what went into this release, and which downloads were checked against a published SHA256.

BundleBinaryFromVersionCheckedSHA256
amd64FerretDBwekan/FerretDBv1.53.0verifiedeae1f0a8f73bfc97…
amd64Node.jsnodejs.orgv24.19.0verified14b342e71204f811…
arm64FerretDBwekan/FerretDBv1.53.0verifiedbdc50caee3ac2849…
arm64Node.jsnodejs.orgv24.19.0verified01443c1e1a29e531…

A row saying no checksum published is not a failed check - it is a source that publishes nothing to check against. Those are the ones worth fixing at the source.

v10.92 2026-08-15 WeKan ® release

In short: this was tagged v10.92 and never published: its release job died before running a line of its own script, with Argument list too long, because the release notes had outgrown the size a single environment variable may hold — so the release, and every job that needs it, never happened. The amd64 and arm64 bundles built and verified; only the publishing failed. The notes now travel from file to file and never become a shell value, which is the one shape that neither runs a backtick as a command nor has a size limit at all. Everything below was written for v10.92 and ships here instead. It begins with what the published Docker image SHIPS, cleaned up against a container scan of ghcr.io/wekan/wekan:v10.91. That scan reported 80 findings against “Node.js”, three of them CRITICAL, and not one of them was in code WeKan runs: npm and node-gyp are build tools that were left in the finished image, and their trees are where tar 6.2.1, sigstore, ip-address and the rest came from. Both go now, after the install that needs them - 83 of the 120 packages in programs/server, and npm itself. The rest of that list is the npm packages Meteor’s own packages bundle, which no package.json here can reach: nodemailer, openpgp, svgo, postcss, nanoid, lodash, qs, body-parser, cookie, on-headers, tmp, diff, @babel/runtime and underscore are raised inside the built bundle, to fixed versions in the same major, by a manifest the release jobs and the Dockerfile share. Below that: the guard suites that keep both from coming back, and what could NOT be fixed and why. Then card export: the PDF and Excel exports of a card were two different answers to “what is on this card”, and are now one - the same fields under the same translated labels, dates in the reader’s own time zone and in the date format the opened card shows, and a description’s markdown drawn as bold and italic rather than stripped. On top of that, #1173 after eight years: a board, a swimlane or a list exports to PDF and Excel in that same card layout, from one selection popup that says what to include. And titles are edited where they are written: a card’s title on the board (#4990, asked in 2022), a board’s by clicking its name in the header bar instead of a pencil beside it. Above all of it, though: five CRITICAL REST API fixes reported by ybsun0215, the worst of which let any user with write access to one board destroy the comments, checklists and history of every card on every board in the instance. Below that: twelve bug fixes - among them Custom Fields, which the browser tests caught being unreachable on a card that had none, which is exactly where it is needed, and a field made from a card that was silently never created - a test that pins that a browser downloads one language file and not all 246 of them, and 81 languages taken past the words on the board into the menus and the login page, beside the Export row that read as the lowercase key export in every one of them because that key had never existed. And then the translations turned out to have a much older problem than any missing string: 8,716 values were written in the wrong language entirely, which no count had ever reported because nothing was looking. Korean held Japanese, Georgian Russian, Hindi Gujarati, Tamil Telugu — and, once a second check asked about the Latin alphabet inside a language that is not written in it, Greek held Italian, Thai Vietnamese and Algerian Arabic French. All of it is translated now, and the scan that found it stays as the guard, reporting zero for both of its checks across all 246 files. Below that: the search operators a user TYPES, in the language they read; the one-letter shorthands beside them, each derived from that language’s own word; and the panels a file never had because they were added after it was last touched. Then dependency updates, thirty-odd bug fixes, the developer-facing changes, and the rest of the translation work.

PlatformBinaryFromVersionSHA256
amd64Node.jsnodejs.orgv24.19.014b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647
amd64FerretDBwekan/FerretDBv1.53.0eae1f0a8f73bfc979738bfff7284d40fd1bc55de2cc56514721fc155c3624f7d
arm64Node.jsnodejs.orgv24.19.001443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc
arm64FerretDBwekan/FerretDBv1.53.0bdc50caee3ac28495b42d2130b94a042a9dd6d3a38f732cac02b648f36c891da
armhfNode.jswekan/node-patchesv24.19.0b55350f3071b765a98ed66fdc410657ff168a937935057077fd7ab33cb30b9aa
armhfFerretDBwekan/FerretDBv1.49.0144404fb9793dc8e039874812f4e2cb3e6d8b1df0ffdbe50254e7790a342a2f4
armv6Node.jswekan/node-patchesv24.19.0128ded0cda638c1f144eadb23ad249889515df017d298fd49c8faf3db110f0f1
armv6FerretDBwekan/FerretDBv1.49.07c27b2c15448709a24eace9b3c951c62fbe33413f7d20d56cb5520f4436efe2d
armv7Node.jswekan/node-patchesv24.19.08dbe0a9aa8550ad5275c5538ebf868eb2037f0c4d9cccbe319f63b7e5854cd45
armv7FerretDBwekan/FerretDBv1.49.0144404fb9793dc8e039874812f4e2cb3e6d8b1df0ffdbe50254e7790a342a2f4
i386Node.jswekan/node-patchesv24.19.03b0b3bbfe27daf583b3a0f432efacc508407a012cdd9e8847250e7c015565bac
i386FerretDBwekan/FerretDBv1.49.01f70cb1687411b2a0fa9ac3b5bfc8c4ed9ce25ec2ddfa17e6fd3efb38136a39c
mac-arm64Node.jsnodejs.orgv24.19.03f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94
mac-arm64FerretDBwekan/FerretDBv1.49.0576364db59dfce3ba564b9a3e484496eb57f95d76d2007b9f83241acdbd2f4fa
mac-x64Node.jsnodejs.orgv24.19.0d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4
mac-x64FerretDBwekan/FerretDBv1.49.037d70cd90aad6d3867b6686507ff1888f1edf6791818c31d014d130e8f39fc14
ppc64leNode.jsnodejs.orgv24.19.0c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3
ppc64leFerretDBwekan/FerretDBv1.49.07c61d4853d5163ad8761449d693fd458ebbb8611a2351c718014876242c5b1fb
riscv64Node.jsunofficial-builds.nodejs.orgv24.19.0cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd
riscv64FerretDBwekan/FerretDBv1.49.0bd4912da70f5e6c1475ab989668c76b4ab7db4ee06c44357693df64f5e1d0e0b
s390xNode.jsnodejs.orgv24.19.0a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4
s390xFerretDBwekan/FerretDBv1.49.0no checksum published
win-arm64Node.jsnodejs.orgv24.19.08502f4a50b458d4cc38ed8f2001556c2cd239d464920f74017926ccb1e1c157f
win-arm64FerretDBwekan/FerretDBv1.49.0792166623e774b0af2aced31ed3ae39f545ca5268dc4c2b8d1a329228ff52cbc
win64Node.jsnodejs.orgv24.19.057f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73
win64FerretDBwekan/FerretDBv1.49.0f42c50aa84095a9616b00f27a584c66b7bf79e3b109450c62a5f146ba3c85478

This release fixes the following CRITICAL SECURITY ISSUES:

The REST API - what an endpoint authorises, and what it then acts on.

PurgeBleed: the single-card DELETE destroyed the contents of any card in the instance. Thanks to ybsun0215 and xet7.

PurgeBleed is the severe one of the five. DELETE /api/boards/{boardId}/lists/{listId}/cards/{cardId} authorised the caller on the board in the URL, and then fetched the card by its id ALONE.

The order is what makes it destructive. cardRemover runs BEFORE the card is removed - it has to, so the children’s before.remove hooks still find their parent - and it removes strictly by card id: checklists, checklist items, comments, the activity history and the whole subcard tree. So it erased those for whatever card the bare lookup returned, which was any card in the instance, on boards the caller cannot read, irreversibly.

The removal that follows uses a triple key, {_id, listId, boardId}, which a foreign card never matches. The card SHELL therefore survived and the endpoint answered 200 with the card id - so nothing in the response said that the contents of somebody else’s card had just been destroyed, and the attack is repeatable for every card id an attacker learns.

Any authenticated user with write access to ONE board - their own is enough - could reach every board on the instance. All deployments with WITH_API=true are affected.

The bulk endpoint had always constrained its lookup with {_id, boardId}; the single-card path is the sibling that was missed, the same shape as PassBleed. It uses the constrained lookup now, so a card outside the authorised board does not resolve, and cardRemover is never reached.

HashBleed: the admin user endpoints answered with password and session-token hashes. Thanks to ybsun0215 and xet7.

HashBleed: GET /api/users/{userId} and PUT /api/users/{userId} serialised the whole Meteor user document with no projection, so every answer carried services.password.bcrypt - an offline-crackable password hash - and services.resume.loginTokens, the hash of every live session with the time it began.

The authorisation was never the problem: both endpoints are admin-only and stay that way. The payload was. Walking the ids that GET /api/users returns harvested the credential material of the whole instance, and a password hash is an attack that continues offline long after the export.

The two sibling endpoints in the same file show what was intended: the list projects down to _id and username, and the self view runs delete data.services before answering. Nothing in the code, the CHANGELOG or the documentation ever said the subtree was meant to be exposed. One helper strips services and sessionData now, and both endpoints answer through it - the PUT as well, which returned the same unprojected document after every action.

GuestBleed: an outsider named on a card could read a private board's cards. Thanks to ybsun0215 and xet7.

GuestBleed is two paths that are each defensible alone. The members and assignees ARRAYS on card create and card update were stored exactly as given, with nothing checking that those ids belong to the card’s board. And GET /api/user/cards answered by CARD membership, without re-checking the caller’s access to the card’s BOARD.

Together they are a channel: a member of a private board writes an outsider’s id onto a card, and the outsider’s own my cards feed then returns that card’s title, its board, list and swimlane ids, its dates and its co-members - for as long as the id stays on the card. Their direct read of the board stayed Forbidden the whole time, which is what made it quiet.

The invariant already existed, documented on the merge endpoint POST .../cards/{cardId}/members/{memberId}, which has refused a non-member with 400 since #5998. It covers the array shapes now - single create, bulk create and update - and an id that may not be assigned is dropped rather than the request refused, so a bulk edit does not fail over one stale id. The listing is filtered by board visibility as well, because fixing only the write path would leave every card placed before this release still answering.

StaleBleed: a removed board member kept seeing the board's id and title. Thanks to ybsun0215 and xet7.

StaleBleed: GET /api/users/{userId}/boards selected boards with a dotted 'members.userId' match, which ignores the membership’s isActive flag. Removing a board member does not delete their entry - it sets isActive: false and isAdmin: false and keeps it - so a removed member’s own board listing went on showing that board’s id and title, indefinitely.

Reading the board itself was already refused, which bounds this to the id and the title. The id is the part that matters, because it is what every other endpoint in the API is addressed by, and a private board’s title is often the thing it is private about.

A dotted path cannot express this: in Mongo, 'members.userId' and 'members.isActive' may be satisfied by DIFFERENT entries of the array. $elemMatch is what ties them to the same entry, and it is what the rest of WeKan uses - the single builder introduced by the fix for GHSA-gwc4-fw7p-gw58, whose header note reads “A share entry counts only while it is active, everywhere”. This listing predates that consolidation and was never converted.

AuthorBleed: six paths let the caller choose whose name went into the board's history. Thanks to ybsun0215 and xet7.

AuthorBleed: six server paths took the actor’s identity from the request body’s authorId field, checking only that such a user exists. An existence check is not an authentication check - it confirms that the name in the envelope belongs to somebody, and says nothing about who wrote the letter.

So a board member could record “victim created this card” and “victim deleted this card” on any board they may write to, and the card document itself recorded the forged userId as its creator. The six are single card create, bulk card create, the linked-card form, single card delete, bulk card delete and custom-field create.

WeKan had already accepted this exact class as a vulnerability and fixed it for card comments in 8.19, and again for the card PUT handler - whose inline note still reads “use req.userId consistently (it previously read req.body.authorId here)”. These six were missed, which makes it an incomplete fix rather than a decision. All six read req.userId now, the session the request authenticated as, which is the only identity the server can vouch for.

and fixes the following SECURITY ISSUES found by container scanning:

The published image - what it carries that it never runs.

npm and node-gyp's tree are not shipped any more, after the install that needed them. Thanks to xet7.

Three CRITICAL findings and most of the HIGH ones were three copies of one package - tar 6.2.1 twice and tar 7.5.11 once - and none of them is reachable from boot.js:

  • node-gyp and @mapbox/node-pre-gyp are dependencies of Meteor’s meteor-dev-bundle, there to COMPILE native modules during the npm install in programs/server. WeKan compiles nothing at run time: uWebSockets.js, bcrypt and argon2 all ship prebuilt .node files that node-gyp-build picks at require time. Their tree is 83 of the 120 packages that install leaves behind, and it brought tar 6.2.1 (via node-gyp and cacache), brace-expansion, minimatch and npm’s networking stack with it.
  • npm itself runs exactly once in the image, for that install. The container starts bash /build/wekan-entrypoint.sh, which never calls it. Shipping it shipped its own bundled tar 7.5.11, sigstore 4.1.0, @sigstore/verify, @sigstore/core, ip-address 10.1.0 and brace-expansion 5.0.4 as image content no code path can reach.

releases/prune-build-only-modules.mjs removes the first, and it is a REACHABILITY walk rather than a list of 83 names - start from every dependency of programs/server/package.json except those two, follow each package’s own dependencies, keep the closure - so it cannot go stale the next time Meteor changes its dev-bundle. The Dockerfile deletes npm and npx in its cleanup step; node stays, because that is what runs WeKan.

It runs in every place a bundle is made, not only in the image: the amd64 build, the arm64 container, the three Windows legs, both macOS legs, and install-node-for-arch.sh for the emulated arches - each one reinstalls programs/server, so each one has the tree to remove. The pruned bundle was booted before and after to prove nothing needs what it takes: identical failure at the database, no missing module.

The npm packages Meteor's own packages bundle are raised inside the built bundle. Thanks to xet7.

The rest of that scan was nodemailer 8.0.3 and openpgp 5.11.1 (meteor/email), svgo 2.8.2, postcss 8.5.1 and nanoid 3.3.15 (meteor/minifier-css), qs 6.13.0, cookie 0.4.1, on-headers 1.0.2 and tmp 0.2.3 (meteor/webapp), lodash 4.17.21 and diff 3.5.0 (ostrio:files), body-parser 1.20.3 (meteor/oauth), @babel/runtime 7.20.7 and underscore 1.13.7.

Npm.depends names an EXACT version, and meteor build copies that version into programs/server/npm/node_modules/meteor/<package>/node_modules. Nothing in this repository’s package.json is consulted for it - not a dependency, not an overrides entry - so the only place those versions can be raised is the bundle, after it has been built. releases/bump-bundle-npm-deps.mjs does that from the minimums in releases/bundle-npm-security-bumps.json, installing with --ignore-scripts so no prebuilt native module is rebuilt by a version bump, and replacing only copies BELOW the minimum.

A minimum stays inside the major the Meteor package was built against, and that rule was learned rather than assumed: uuid 8.3.2 and 9.0.1 are both affected and the lowest fixed release is 11.1.1, which moved its entry point to dist/cjs/index.js - the bundle records dist/index.js at build time, and the server died on boot with Cannot find module .../uuid/dist/index.js. It is in the manifest’s notFixable list with that error, beside lodash.template, which has no fixed release at all.

One pass on the amd64 bundle reaches every architecture, because every other bundle is that bundle with programs/server reinstalled. That reinstall is also why every leg runs it again: meteor-dev-bundle pins underscore 1.13.7 (CVE-2026-27601) and puts it back over the bumped copy - including in the Dockerfile, which reinstalls from the .zip.

Guard suites for both, and the Debian findings that no upgrade can fix. Thanks to xet7.

tests/imageBuildOnlyModules.test.cjs pins that the pruner is reachability-driven and never touches npm/node_modules, that the image prunes AFTER the install and before the bundle is moved, that npm and npx are removed and node is not, and that every leg which installs programs/server prunes afterwards. tests/bundleNpmSecurityBumps.test.cjs pins the manifest’s versions, the --ignore-scripts install, the below-the-minimum-only replacement, and that uuid is NOT in minimums and says why.

Not everything in that report can be fixed here, and it is worth saying which:

  • The debian 13.6 target’s 176 findings are ALL Fixed in: - - unfixed upstream, in perl-base, util-linux, ncurses, glibc and the rest of a base system. No upgrade closes them; only carrying fewer packages helps, which is what the build-dependency purge and the pebble removal already do.
  • lodash.template 4.5.0 has no fixed release; it reaches the bundle through aldeed:simple-schema.
  • nodemailer’s fifth advisory needs 9.0.1, a major the Meteor email package is not written against; 8.0.11 fixes the other four.
  • The nine build/<tool> binaries and build/ferretdb are other repositories’ builds - wekan/mongo-tools-patches and wekan/FerretDB - and are fixed there.

and adds the following new features:

Import into a swimlane, a list or a card, beside the one whose menu was used. Thanks to xet7.

The other direction of the same popup. Importing used to mean one thing: a whole new board, from the full-width page at All Boards. A swimlane’s menu now imports a swimlane BELOW that swimlane, a list’s menu imports a list after it - which is to its RIGHT in English and to its LEFT in Arabic - and a card’s menu imports a card below it, into the board that is already open.

The RTL side of that is ONE rule and not two, which is worth saying because it looks like a missing branch: the page carries dir, so the board’s row of lists mirrors itself and “after in sort order” is already “the other side”. A direction branch in the placement would mirror it twice and put the list back where it started.

Placement is a FRACTION between the target’s sort and the next one’s, not a renumbering of every sibling - renumbering is what a board with ten thousand cards cannot afford, and what two clients doing it at once get wrong. Several imported items spread evenly through that gap, so importing a swimlane of ten lists does not put nine of them in the same position.

The file is what the export writes, at any scope, and the SAME checkboxes decide what comes in: a document full of comments imported with Comments unticked brings the cards and leaves the comments. A .zip is unpacked in the browser and its wekan.json handed to the same method, so there is one import path rather than two. Nothing is merged - everything created is new, because an import that half-updated a board would be an edit nobody could undo - and a custom field is matched by NAME, since an id from another board matches nothing here.

Importing WRITES, so unlike the exports beside it, it asks whether you may change the board rather than whether you may see it, and a read-only member is not offered it at all.

All Boards - the overview and the tiles in it.

A starred page and a Template Container are the same height as a board and as "+ Add Board". Thanks to xet7.

In All Boards / Starred, a bookmark tile stood 8px taller than the board tile beside it and than the grey ”+ Add Board” box, and pulled its whole row up with it.

Two of the four tiles in that grid carry border: 4px solid #fff - a bookmark and the Template Container - to say they are not an ordinary board. The tile’s height was a 72px floor with 42px of padding added outside it, which comes to the same 114px for a plain tile and to 122px for a bordered one, because on a content-box element a border is added to the height rather than taken out of it. The heights all said 72px, so nothing in the stylesheet looked wrong.

The tile now states the height it actually renders at - 114px, with the padding and the border folded into it (box-sizing: border-box), which is what the mobile rules in the same file already do for the same reason. Nothing moves except the two bordered tiles, which lose the 8px they were never meant to have. The guard compares RENDERED heights now, borders included, instead of comparing the declared min-height of two tiles that were both content-box - which is how this went unnoticed while a test watched it.

One import page, for every source, with the same checkboxes the exports use. Thanks to xet7.

The last piece of #1173: “combine all import options to same template”. They were fourteen - a link per source in a pop-over, each going to its own address - and the page each one landed on never said which other sources existed, so “where do I import a Jira export” was answered by a menu somewhere else, if you knew it was there.

All Boards / + Add Board / Import is now one full-width page that lists every source it can read - Trello, Jira, CSV/TSV, Excel, Kanboard, NextCloud Deck, OpenProject, GitHub, GitLab, Gitea, Forgejo, Asana, Zenkit, and a previous export of this WeKan, named with the Product name this instance is branded with rather than with a product the reader has never seen.

Under the source picker are the same what to include checkboxes every export offers, from the same list, and on this side they say what comes IN. That works for every source without teaching five different creators a selection each: the parts that were not ticked are taken OUT of the parsed document before any creator sees it, and a creator that never sees a comment cannot import one. A section that was not ticked is EMPTIED rather than removed, because the creators read board.comments directly and an undefined array is a crash where an empty one is “there were none”. A source’s own name for a part is pruned with it - Trello calls its comments actions - and a key this list does not know about is left alone rather than silently dropped.

/import/:source still works, under its own route name, so every existing link, bookmark and back button lands exactly where it did.

Imported cards bring their attachments, from a .json and from a .zip. Thanks to xet7.

The round trip was half a round trip: a card imported from a swimlane, list or card menu arrived with its checklists and comments and without its files.

A .json export carries each attachment’s bytes as base64, and the importer now writes them the way the board import always has - the server-side Meteor-Files writeAsync, one attachment at a time. A .zip carries them as the files they are, named attachments/<id>-<name>; the archive is unpacked in the browser and each file is put back on the metadata row its id names, so the server sees the same document either way and there is one import path rather than one per container.

An attachment lands where its CARD landed - the list and swimlane it is in now, not the ones it was exported from - and one unreadable file is warned about and skipped rather than losing the rest of the import. A url attachment from an older export is still fetched through the downloader that validates and pins every hop, so FollowBleed stays fixed on this path too.

The whole-board import on the new import page takes a .json or a .zip the same way, through the same reader, which is the case the .zip exists for: a board whose attachments are too large to sit inside one JSON string.

A large .zip is uploaded and streamed instead of unpacked in the browser. Thanks to xet7.

Reading an archive in the browser and sending its attachments as base64 over DDP is fine for a card and wrong for a board: 2 GB of attachments become 2.7 GB in one message, in the browser’s memory and then in the server’s.

A .zip now goes to POST /api/import/zip as the file itself, and nothing is ever whole in memory on the way in. The request body is streamed to a temp file as it arrives; unzipper.Open.file reads the archive’s central directory, so entries are opened on demand rather than inflated together - the same approach the backup restore already takes; and each attachment is piped from the archive into the attachments collection by addAttachmentFromStream, which writes it to a temp file and hands Meteor-Files the PATH rather than a Buffer. That helper is lifted out of the attachment-copy code that already did exactly this, so there is one way to add an attachment from a stream rather than two.

Where the files END UP is not decided by the import: addFile fires the collection’s onAfterUpload, which validates the file and moves it to the default storage configured in the Admin Panel, exactly as an ordinary upload does.

The upload is capped as it ARRIVES rather than after (WEKAN_IMPORT_ZIP_MAX_BYTES, 5 GB by default), so an oversized archive never lands, and the temp file is removed whatever happens. An entry’s name is data and never a path: only the attachment id before the first dash is read from it, the temp file is named by WeKan, and the path is built through the same safeEntryPath containment check the backup restore uses - so an entry called ../../etc/cron.d/x can only ever be an attachment with a strange name (ZipBleed).

A .json still travels as a document over DDP, which is what it is.

The import page asks with the app's own checkboxes, in two columns. Thanks to xet7.

Three things about the page that lists the sources. The pop-over that opens it said “Import board (Trello, Jira, WeKan export, CSV, Excel, …)” - a list of five of the fourteen sources, in a menu whose only job is to get to the page that lists all of them - and now says Import board and nothing else.

Import from: and Select what to include: were rows of grey ticks that were never anything else: a fa-check drawn once per row, the same shape whether that row was chosen or not, so the answer to “which one did I pick” was a shade of grey. Both are the app’s own animated checkbox now (.materialCheckBox, the one Admin Panel / Announcement uses), which grows its green tick out of the empty square over 0.2s, so a click is visible as it happens. The box and its label are spaced apart by the same amount ticked or unticked - the checked box is a narrower shape shifted left, so the two margins differ by exactly that shift - and the row itself declares no alignment of its own, because the tick’s rotation is written for the plain flow and an align-items: center on top of it lands the tick on the first word.

The two questions sit side by side when the window has room for two 280px columns and stack when it does not, so the second one is not below the fold on a page whose first list is fourteen rows long. No source is chosen to begin with, and choosing one un-chooses the last, because an import reads one file in one format; every part is ticked to begin with, because an import that silently left things out would be worse than one that asks.

And Import without mapping members (map later) is a primary button like the Import it sits beside. It was the one unstyled button on the page, which reads as disabled - and it is not a cancel, it is the same import with one question skipped.

A board tile in All Boards is the board's whole theme, and light themes are readable on it. Thanks to xet7.

A board on the clearpink theme was a pink rectangle floating inside a grey tile, while the flat-coloured board beside it filled its tile edge to edge.

Two lists decided what a tile is coloured with. boardColors.css painted .board-list .board-color-<name> a - the LINK inside the tile, which sits inside the tile’s own 24px/18px padding and so can never reach its edges - and boardsList.css carried a hand-copied list of seventeen flat background colours on the tile itself, which is what made the flat themes look right. The five colour SLIDE themes added later were never copied into that list, so their tiles kept the default grey and only the inset link showed the slide. clearblue was in the list, flattened to one hex, so it did not show its slide either, and Clean Dark and Clean Light were in neither list.

There is one list now. Each theme paints .board-list li.board-color-<name> where it paints its header bar and its Public Boards row, the copy in boardsList.css is gone, and a tile is the whole tile at every theme.

The other half of a theme is the text on it. A tile writes its title in #f6f6f6, which is right on the fifteen dark themes and invisible on a light one: Apple Glass Pastel’s tile is a pastel wash from #f6f7fb, so its name and description were white on near-white and only the tile’s shape said a board was there. Both light themes now write their title, description and archive line in their own dark ink, and darken the card-count pill and the unstarred star that sit on the same tile - in ONE block, named as the place a third light theme goes, because a per-theme copy is what caused the first half of this.

Clean Light's top bar is one shade of dark, icons included. Thanks to xet7.

The Clean Light theme’s top bar is #F1F1F3, and header.css paints every icon in that bar white by id (#header-quick-access i.fa). The buttons inside a ul li escaped that through a more specific color: inherit and took the theme’s own text colour; the house and the notification bell are in no ul li, so they stayed white on near-white - the bell’s LABEL was readable beside a bell that was not there, which is how it was noticed.

Both are dark now, and so is everything beside them: the bar’s labels were rgba(10, 10, 20, 0.5) and its board title full black, so fixing the two icons alone would have left the bar at three darknesses. There is ONE shade in it now. The current entry used to be the dark one among pale ones - a difference that no longer exists - so it is the full-black, heavier one instead. A bell with something to report keeps its white glyph, because it is on the red circle then and not on the bar.

The dividers between the bar’s groups - the seam that separates the page’s own controls from your account - are white too, a background in the first bar and a border in the second, so on this theme they were not there at all. They are in ink now, at the divider’s own weight rather than the text’s: a hairline as dark as the label beside it stops reading as a seam and becomes a stroke. The starred group’s outline - the box around the caret, the count and the star - was white for the same reason, so the three sat loose on this theme; it is the same 0.7 in ink now, which is the dark outline the phone/desktop toggle beside it already draws for itself and why that one looked right and this one did not.

Hovering an icon lightens it - #header-quick-access i.fa:hover goes to #ccc - which on a dark bar reads as “brighter, so you know you are on it” and on this one made the house fade towards the bar it sits on. On this theme it goes the other way, to full black, which is the same message read the right way round for a light bar.

And with Member Settings / Change color / All Boards on, a tile is the theme’s own fill rather than the accent under a flat white veil. The veil made every tile one shade lighter than the selected row in the left menu beside it, which reads as two colours rather than as one page. It reads --theme-accent-fill now, not --theme-accent: the accent is ONE colour - the solid end of a colour-slide theme - so on clearorange the tiles came out flat beside a menu row that slid. The fill is the theme’s slide where it has one and its accent where it does not, which is what every other themed control already reads. The popup’s own All Boards button is filled from the same variable, because the button and the tiles it turns on are one decision and were two looks.

On the Modern theme the popup that asks all this was a single narrow column of swatches. That theme set width: 260px on every popup’s content, so any popup that asks for width by name - Change Color, Export board, Show on Card, Show on Minicard - was pinned to 260px whatever it had asked for. A theme decides what a popup looks like; how wide it is belongs to the popup, and the declaration is gone.

And on the Dark theme that popup had no title bar at all. That theme hid every popup’s header outright, and the header is not decoration: it carries the popup’s title, the back arrow into the popup it came from, and the X - so “Change Color” was an untitled panel that could only be left with Escape or a click outside. No other theme does that, and Dark no longer does either.

Member Settings / Font / Size moves all of the text, not the part written in rem. Thanks to xet7.

Picking Largest grew the page heading and the minicards and left the header bar, the left menu, the lists, the swimlane header and every popup exactly as they were - a setting that half-works, which is harder to use than one that does nothing.

The preset was a percentage on the root element, and a root percentage only reaches text whose size is written in a RELATIVE unit: rem is measured against the root, em against its parent. WeKan writes most of its sizes in px, and px is absolute - no root percentage moves it. So exactly the rem-sized parts scaled. The base rule made it worse: html, body, input, select, textarea, button re-stated font: 14px …, so the body took the stock size straight back off the scaled root, and everything inheriting from the body with it.

The preset is now published as a NUMBER as well (--wekan-ui-font-scale), and every px font-size and line-height in the client stylesheets - 433 declarations across 37 files - is written calc(14px * var(--wekan-ui-font-scale, 1)). One preset moves the whole interface. line-height scales with the size on purpose: 21px of type in an 18px line box is the same setting half-applied.

Nothing renders differently until a preset is chosen: the fallback in every one of those declarations is 1, and the variable is REMOVED for Default, so an instance where nobody touched the setting computes exactly the sizes it always did. The base font rule is split into font-family and font-size because the shorthand’s size was the one thing the setting had to be able to move. A guard fails on any bare px font-size or line-height added later, so a new one cannot quietly opt out of the setting.

The text colour under Member Settings / Font reaches all of the text. Thanks to xet7.

Colour is inherited, and it was set on <body> and the form controls, so it only ever reached text that had no colour of its own - and WeKan gives most of its text one: the header bar’s buttons, the left menu’s rows, a minicard’s title, a list header. Choosing green recoloured the page heading and the menu and left the rest exactly as it was. It is set on every element now.

.fa is left out, because those are Font Awesome GLYPHS rather than text - a red alert and a green tick mean something by being that colour - and the icons that are meant to follow their label already say color: inherit, so they follow this anyway.

Member Settings / Font / Text background color is removed. Thanks to xet7.

A colour painted behind the text needs elements to sit on, and neither choice of them looks good. On the boxes - which is what it did, from <body> - it painted the whole window: the board canvas and the empty space under the lists, which is a page background and not a text background. On the elements that actually carry text it striped every heading, menu row and paragraph with a full-width band. A setting nobody can make look good is worse than no setting, so it is removed rather than tuned a third time.

The wheel and its Unset button are gone from the popup, nothing reads profile.uiTextBgColor, and no stylesheet rule paints it. A profile that already HAS a colour is cleaned rather than left dormant: setUiColors unsets the field on every call, whatever it is passed - and it still accepts the argument, so an older client cannot fail against a newer server. The schema key stays declared for exactly one reason: a modifier touching a key SimpleSchema does not know is rejected, which would leave those profiles the only ones that could not be cleaned.

The text colour beside it stays.

A custom colour can be chosen in Member Settings and the Admin Panel, not only on a board. Thanks to xet7.

All three places render the same picker, so the Custom color wheel was already written for all three - it was just never reachable in two of them. It appeared only once a named theme had been SELECTED, and a board always has a colour whose first entry is flat, so Board Settings / Change Color always showed it; Member Settings / Change Color and Admin Panel / Settings / Visibility open on Default (no override) with nothing selected, so both looked as though they had no custom colour at all.

The wheel is offered from that state too, as the flat category’s single colour - which is what a custom colour means with no theme under it - and choosing one applies it over the first flat theme. That fallback was already in the code that saves; it is now written into the picker’s own selection as well, because otherwise the wheel would store a theme the page did not show as chosen and the next click would read the selection back as none.

Picking a clear theme first still gives the two wheels its colour slide needs. One helper answers “which category’s custom colours is this picker offering”, so the wheel, the preview and what gets saved cannot disagree.

Every export popup is one popup with one scope, and looks like it. Thanks to xet7.

There were two export menus. The board popup wrote nineteen formats of its own under four subheadings, in two panes across the width of the window; the swimlane, list, card and checklist popups wrote five in one narrow column with no panes. So “the export popup” looked like two different features depending on which menu opened it, and a format added to one was missing from the other.

They are one template now, drawing one TABLE of formats. Each entry says what it is - an icon, a label, and either a path to download or a class to click - and which scopes it belongs to: BOARD_ONLY for the ones that only make sense for a whole board (the HTML archive, the dependency graph, the CSV columns and the exports written for Trello, Jira, Kanboard and the rest), and nothing at all for the ones that work anywhere, because exportUrl already carries the scope - a swimlane, a list and a card differ from a board by a query parameter, not by a route. The board popup is now the same one-line include the other four are, with no markup and no URL builders of its own; the nineteen helpers that built those URLs are gone with the list that called them.

All five get the board’s panel: what to include in one pane and what to export it to in the other, side by side when the window has room and stacked when it does not, pinned to the viewport so the header and its X are always reachable. The layout is written against the panes’ own class rather than against one popup’s name, so it cannot be true of one menu and not another.

Which scope a popup is, is asked in ONE place - and every scope is named there, because a scope left out would be read as “a whole board” and offered a board’s formats.

Every menu on a board has an Export row and an Import row. Thanks to xet7.

The card, list, swimlane and board menus each had ONE row, and each named itself differently: Export card, Export list / Import, Export swimlane / Import, Export board. Two of them mentioned an import that was a section inside the popup rather than a thing you could go to, and the card and board menus did not mention it at all - so importing into a list was discoverable and importing into a card was not.

Each of the four now has Export and Import, in that order. The rows are named for what they do and not for what they act on: the menu already says whether it is a card, a list, a swimlane or the board, so Export card inside the card menu said “card” twice.

Both rows open the same popup - the same panes, the same table of formats, the same what to include selection - with one difference: the second pane offers the formats to write out, or the file to read in. The selection means the same thing in both directions, which is why it is the pane they share.

Import writes, so it is offered only to somebody who may change the board. That question is asked once, in one function, registered as a helper the four menus use - and asked AGAIN inside the popup, because a row that is merely hidden is not a permission check.

Search - finding a card by what people call it.

Cards can be searched by their card number. Thanks to mfilser and xet7.

#5006, open since 2023: “A customer has just hinted us that we are not able to search cards by its number (#)”. A card carries a number, the board shows it, people quote it to each other in meetings and in tickets - and the one thing that could not be done with it was find the card again.

number:12 now does, in the global search and everywhere else that parses the same query. It is an EQUALITY match on a number rather than a regular expression on text, which is the whole difference between finding card 12 and finding cards 12, 120 and 312. number:abc is refused with the same “expected a number” error limit: already gives, because a string never equals a numeric field and a search that quietly finds nothing teaches nobody anything.

The issue’s title says “number/#”, and both of those forms work too: #12 and a bare 12 search for a label called 12 AND the card numbered 12. A board calls a card “#12” and a label can be called anything, so which of the two somebody means cannot be read off the text - answering with both is the only reading that never hides what they were looking for.

The two halves are joined with OR, which is what makes that safe rather than destructive: a search that used to find a label called 2024 still finds it, and the card numbered 2024 is ADDED to the answer instead of replacing it. An AND would have found nothing at all in almost every case and looked like the feature working. Only a term that is entirely digits gets the second half, so #red is the label red exactly as before. And #12 on a board with no label called 12 no longer reports “label not found” - the card-number half is a real answer, and a not-found message beside the card it just found contradicts the screen.

Board, swimlane and list export - printing a board, and what goes in it.

The export selection reaches every format, and the menu opens wide enough to read at once. Thanks to xet7.

Two halves of the same menu.

The selection now reaches all of it. PDF, Excel, JSON and .zip took the parts checkboxes; CSV, TSV, Kanboard and the eleven JSON dialects built their own addresses and could not carry them, so ticking “no comments” changed four downloads out of nineteen. Every board export link is built by the one url helper now, so ?fields= rides along with all of them.

What a format can DROP depends on what it has, and that is the honest part. A CSV has no comments to leave out - what it has is columns, so the selection lands there: unticking People removes five of them, Dates removes eight, Custom Fields removes the trailing block. One mask filters the header and every row, so the two cannot drift apart. A Trello, Jira or GitHub export carries a title, a description, a due date and labels; those three parts are gated and nothing pretends to gate what is not there.

It is a full-width panel, and its X is always reachable. It was anchored to the button that opens it and clamped into the window using an assumed width - and the assumption and the stylesheet disagreed by a few percent, so on a narrow window the panel’s trailing edge, with the pop-over’s own X on it, ended up past the edge of the screen: Escape or a click away were the only ways to shut it. It is pinned to the top of the viewport now, at the 10px gutter every popup keeps, and is calc(100vw - 20px) wide - the whole window, less that gutter on each side - so the header, the X and both panes are always on screen.

And the menu is a menu again. Twenty-odd entries under three subheadings were a single column you scrolled past. On a window with room it is now TWO PANES - what to include on the left, what to export to on the right - each filling its own width with as many columns as fit, so the whole menu is visible at once. The panes are grid COLUMNS, which is what makes the right-to-left case free: a mirrored page puts the first column on the right, so the selection lands on the right and the formats on the left with no second rule to write or forget, and the divider between them is a border-inline-start for the same reason. Below 800px they stack, because popup.css already lays every popup out as a full-screen sheet there, which is what a phone should get. The 1100px width is mirrored in client/lib/popupOffset.js, which places a popup using its width: left at the default 380 a wide menu opened near the right edge lands most of the way off the screen.

A rule sits above each subheading, so a group’s name says where the group before it ended - one rule per heading and no stray separators, which a test counts.

A component that others import loads its own template, so the client bundle cannot die at startup. Thanks to xet7.

Template.exportScopeBody.helpers(...) runs at module scope, and it throws when that template is not defined YET. That does not break one popup - it stops the module evaluating, so every template registered after it never registers either. The visible symptom was the SIGN-IN page: a blank form and

Uncaught TypeError: can't access property "helpers", Template.exportScopeBody is
undefined
[UserAccounts] Warning no template passwordInput found!

The central lists in client/features/*.js import a component’s .jade before its .js, which is enough for a component nobody else imports. The export popup body is imported by the sidebar, by the card details and by the import page, and whichever of those is reached first evaluates it - long before the feature list gets to the template. It imports its own .jade now, so the order is a fact rather than a hope.

A guard in tests/clientBundleImports.test.cjs checks the whole rule, and found a second component with the same fragility that had not fired yet - migrationProgress.js, imported by boardBody.js. Fixed the same way.

The board Export menu is grouped under subheadings instead of one flat list. Thanks to xet7.

It was nineteen entries in one list, each spelling out its whole family: “Export / CSV (,)”, “Export / CSV (;)”, “Export / TSV”, then eleven lines beginning “Export / JSON /”. The part that differed - the only part worth reading - started two thirds of the way along every line.

A subheading names the family once and the entries under it say only what they are: PDF, Excel and HTML, then Dependencies (JSON, SVG), CSV ((,), (;), TSV) and JSON (JSON, JSON without attachments, .zip, Kanboard, Trello, Jira, NextCloud Deck, OpenProject, GitHub, GitLab, Gitea, Forgejo, Asana, Zenkit). The .zip sits with the JSON it is a container for.

The parts checkboxes stay at the top, from the shared popup body: the board passes hideFormats because it lays the formats out itself, and borrows the selection rather than growing a second copy of it. Its links are built by the same url helper every other menu uses, so a menu cannot send a different selection than the one on the screen. Nothing was dropped in the regrouping, and a test walks the whole list in order to say so.

JSON and .zip export at every menu, streamed, with the attachments as files. Thanks to xet7.

The board’s Export menu had JSON; nothing else did, and there was no .zip at all. Both are now offered wherever an export is - the board, a swimlane, a list, a card and a checklist - from the same popup, with the same checkboxes deciding what goes in.

They are the same export in two shapes, not two exports. The document is written by models/exporter.js’s streaming writer either way, so a .zip’s wekan.json and a .json download of the same scope are the same bytes. What differs is where the attachments are: base64 INSIDE the document for JSON - or omitted, with the existing “without attachments” option now offered at every menu - and beside it under attachments/ as the files they are for the .zip.

Both halves stream. The JSON writer already wrote a document at a time from raw cursors with backpressure; the .zip gives it a PassThrough that archiver compresses as it fills, and pipes every attachment from the file store rather than reading it into a Buffer. A board with a gigabyte of attachments costs a gigabyte of disk reads and not a gigabyte of RAM - which is what makes the .zip the shape to use when the JSON is too large to hold as one string.

A SCOPED export is the same wekan-board-1.0.0 document with fewer rows in it, plus the lists and swimlanes its cards refer to, so what comes out can be imported back into somewhere. A section the popup did not tick is an EMPTY array rather than a missing key, for the same reason. A checklist scope exports the card that holds it, because a checklist alone has nowhere to land.

A board, a swimlane and a list export to PDF and Excel in the card export's own layout. Thanks to xet7.

#1173 “Add Feature: Print Board with Params” has been open since 2017, and two things were missing. There was no CHOICE of what to print: the board’s Excel and PDF exports took everything they knew how to render and nothing else, while the card export already had a popup with a checkbox per section. And they did not LOOK like the card export - the board’s Excel export was a spreadsheet table, one row per card and eighteen columns, which is a data dump rather than a printed board.

Both now render a board as the board’s own header followed by every card as the CARD export’s block, drawn by the card export’s own code - cardBlockLines() for the PDF, ExporterExcelCard.renderCardBlock() for the Excel one - so a card looks the same whether it was exported alone or as part of its board, and the two cannot drift into two layouts again. Each card starts on its own page in the Excel export, because a printed board is read a card at a time.

The swimlane and list menus offer the same export, which is the board export with one more parameter saying which cards are in scope. They sit beside “Copy link” rather than below the permission checks, because exporting is reading. The file is named for what was exported - a PDF titled with the board that holds one list is a file nobody can place afterwards.

Rendering a card block needs the whole board in memory, and models/server/ExporterExcel.js STREAMS on purpose - it was rewritten that way after the in-memory version ate gigabytes on boards with thousands of cards. So that exporter is still there and still reachable: unticking Card details asks for it. That is a checkbox in the popup, not a silent fallback nobody can see.

One selection popup, and one list of what an export can contain. Thanks to xet7.

The card export’s popup had a list of sections, and the server had another one, under a comment reading “Must match ALL_FIELDS in models/server/ExporterExcelCard.js” - which is a comment, not a mechanism. A section added on one side and forgotten on the other is either a checkbox that does nothing or a section nobody can turn off.

Both now import models/lib/exportFields.js, and so do the board, swimlane and list popups, which are ONE shared body - exportScopeBody - included with a different scope each. The selection is remembered for the session, because somebody printing a board rarely wants a different shape for each list of it.

The same ?fields= gates the same sections in both formats: the card export’s checkboxes used to be labelled “fields to include in Excel export” and did nothing to the PDF, so one popup meant two things. A section a request does not name is not rendered and, where the export is the only reason to read it, not even fetched.

Titles - renaming a thing where its name is written.

Clicking a card's title on the board edits it there. Thanks to bonnebulle and xet7.

A list’s title has always been edited in place: click the heading, type, save. A card’s title could only be changed by opening the card, so correcting a typo on a board of forty cards was open, edit, close, forty times - which is what #4990 asked about, in 2022.

The minicard’s title text is now an inlinedForm, the same component the list heading uses, with the same textarea, Save button and X. Only the TEXT opens it, and only for somebody who may write: the complete checkbox, the linked-card icons and the card number beside it keep doing what they did, and everything else on the card still opens the card. A title nobody may edit still opens it too.

The minicard sits inside the link to the card, so a click in the open editor would have navigated away mid-rename. That default is cancelled - except on the Save button, whose own default IS the submit, and which the browser picks over the link around it because the innermost element with an activation behaviour is the one that runs. An empty save is a no-op rather than a way to end up with a card that has nothing to click.

Half of the title edits; the other half drags. With drag handles OFF a card is dragged by its own body, so a title that is entirely an edit target leaves nowhere on that line to take hold of - a grab that moves a few pixels is a click, and the editor opens instead of the card moving. The edit target is the LEADING half (left in English, right in Arabic, from one logical edge) and the trailing half is there to drag from. With handles ON the handle is the only drag source, so nothing has to be reserved and the whole title edits.

The opened card’s title splits the same way, and its drag handle now appears only when drag handles are on - with them off, the title bar is what moves the window. The drag surface there is the header ROW rather than the heading: a heading is only as wide as its own text, so on a short title “the trailing half” was a few pixels and the empty space beside it - the obvious place to take hold of - belonged to nothing. The heading fills the row now, and the handler steps aside for the buttons in it, for the drag handle when there is one, and for the half that edits.

A board is renamed by clicking its name, and the pencil beside it is gone. Thanks to xet7.

The board’s name in the first header bar had a pencil next to it. Two targets for one job, and the smaller of the two was the one that did it.

The name itself now opens the rename popup - the SAME boardChangeTitlePopup that the pencil opened, with the title and the description in it, so only what you click to get there changed. It is opened with the board as its data context, because that bar’s context is the page rather than the board. Board admins only; everybody else sees a plain title, as before.

A board whose title is EMPTY renders no text at all, and an element with no content is zero pixels wide - so there would be nothing to aim at, and an empty title could never be given one. The clickable title carries a minimum width and height for exactly that.

Card and minicard menus - the settings about a thing, in that thing’s menu.

Subtasks Settings is in the menu of the subtask it is about. Thanks to xet7.

What subtasks DO on a board - whether they are shown at all, which board and list a new one is deposited in, and how a parent is named on a minicard - was in Board Settings only: open the sidebar, open the board menu, find Subtasks Settings. Somebody who wants to change it is usually looking at a subtask on an opened card at that moment.

It is behind a hamburger at the end of the Subtasks heading on an opened card - where the card and the minicard already keep theirs - and clicking it opens the same popup Board Settings did. Board Settings no longer has the entry: the settings are where subtasks are.

The heading IS the control that folds the section, so the hamburger’s click has to stop there. Without that, opening the menu would close the thing it was opened from.

It is the SAME template in both places, with its own state, helpers and handlers, so neither place needs code of its own and the two lists cannot drift. That is what the move needed: a template included in two places cannot reach its parent’s helpers, because a helper is looked up on the template it is written in.

Show on Card and Show on Minicard do not print their own title again. Thanks to xet7.

The heading row named the columns of a ROW - Show on Card over the checkbox and Description over the setting’s name - which is what they labelled, but not what they looked like. The rows flow into as many columns as the window fits, so the two headings sat above the FIRST of three or four columns and read as if they named those: Show on Card over one column of settings and Description over another, when both columns hold the same kind of row.

The first heading also said what the popup’s own title says. Show on Card opened a popup titled Show on Card and repeated it a line below, and the same for Show on Minicard.

Both are gone, and the two-pixel rule under them with them - one more line across a popup that is a list of lines. A row is a checkbox and the name of a setting, which needs no heading over it. One template serves both popups, so both lose it together, and the sticky positioning that existed only to keep those headings in view while the rows scrolled goes with it.

Show on Card and Show on Minicard lead the card's and the minicard's menus. Thanks to xet7.

Board Settings / Card Settings is a table of twenty-four settings with two columns - what a card shows, and what a MINICARD shows - and it lived in one place only: the right sidebar, four clicks from the card whose fields it decides.

It is now also the first entry of the two menus it is about. The hamburger on an opened card offers Show on Card, the hamburger on a card on the board offers Show on Minicard, each followed by a rule like every other group in those menus. Both open the SAME popup with the other column hidden by a class, so there is still one list of settings and one set of handlers: a setting added to the table appears in all three places by itself, and none of them can drift from the others.

Both are wide, and lay their rows out in columns. Two dozen settings in one column is a list nobody sees the end of - “is Description on?” was somewhere below the fold. With one of the two checkbox columns hidden each row is half as wide as it was, so the popup is min(90vw, 900px) and the rows flow into as many columns as fit: one on a narrow window, three or four on a wide one, with the headings still spanning the width because they name the columns of a ROW rather than of the grid.

The permission is the one Board Settings already used - a board admin - asked in the menu and again where it acts. The one menu is opened from two places and its data context is the card either way, so the opener says which hamburger it was in client/lib/cardMenuSource.js: a module-level reactive value, not a field on the card document, which is gone the next time Blaze re-renders the popup with a fresh copy of it.

Hide minicard label text became the Labels text row, and now works when logged in. Thanks to xet7.

It was in the right sidebar under the People and Organizations tabs, which is not where anything else about a minicard is. It is a row of the same table now, directly under Labels, named “Labels text” with the Labels icon and the Description Text icon in front of it - because it is about what those labels look like on a minicard: coloured words, or coloured bars with the words left out.

It is ticked when the text is SHOWN, which is what a board does out of the box. The stored flag is a “hidden” one, so reading it straight through would have left the box unticked for the default behaviour, which reads as a broken checkbox.

It is the one PERSONAL row of a board-wide table - the user’s own profile, or this browser when nobody is logged in - so it appears in the minicard’s view and nowhere else, and it is offered to EVERYBODY. A reader who is not a board admin gets that row alone rather than a table of checkboxes the server would refuse.

The move found a bug. The setting was written out three times, and the minicard’s own copy only ever wrote localStorage: a logged-in user toggling it set something nothing reads, because for a user the value is read from the profile. One module reads and writes it now (client/lib/minicardLabelText.js), so the two halves cannot disagree again.

Custom Fields and Edit custom fields are one group in the card's menu. Thanks to xet7.

The board’s LIST of custom fields - where a field is created, renamed or deleted - was in Board Settings only, and the picker for which of those fields are on THIS card was down among Voting, Planning Poker and Spent time. Two halves of one subject, three groups apart.

They are one group now, under Watch and its rule: Custom Fields first, because a field has to exist before a card can be given it, then Edit custom fields, then a rule like every other group in that menu. A board admin’s, the same as in Board Settings.

Card Settings is gone from Board Settings. Every setting in that table is in the menu of the thing it is about now, so a board-wide entry as well would be a third way to one list - and the way that is furthest from what it changes. The table itself is unchanged; the two menus include it.

Everything about custom fields is on the section that shows them. It was spread over three places: Board Settings had the board’s LIST of fields behind a right-sidebar view, the card menu had an entry for that list and another for the picker of which fields are on this card, and the picker had a cog that jumped back to the sidebar - closing the menu and the card pane on the way.

One place now: the hamburger at the end of the card’s own Custom Fields heading. It opens every field the board has, ticked when it is on THIS card, with a pencil each and, under a rule, Add custom field. Edit and Add open in the same pop-over on top of that list, so the back arrow returns to it and the card stays open behind; they are the board’s own forms, not second copies of them. The card menu’s entry and the Board Settings row are both gone, and so is the wrapper popup that nothing opened any more.

Show list on minicard became the List title row of Show on Minicard. Thanks to xet7.

It was a line in the card’s menu that read “Show list on minicard”, or “Hide list on minicard” once it was on - a menu entry that changes its own name is the shape a checkbox has, drawn as a sentence. It is a checkbox now, the FIRST row of Show on Minicard, above Received, named List title and unchecked by default, which is what the field itself has always been.

It shows the name of the LIST the card is in on its minicard, which is worth having where a card is read away from the column it lives in: a swimlane, search results, My Cards. The board-wide “Show lists” row further down the same table turns that on for EVERY card; this one is for a single card that needs it, and either is enough for the name to show.

It belongs to the card rather than to the board, so it is drawn only in the minicard’s own view - Board Settings has no card to act on - and only for somebody who may change that card, which is the permission the menu entry had. The card is passed into the popup and re-read from the collection when it is toggled: a minicard’s menu is opened from the board, where there is no “current card” to fall back on.

On the minicard itself the name sits on its own line under the badges. It was landing BESIDE them: the badge row is float: inline-start, so the line after it shared its row and read as one more badge rather than as the line it is.

Change Background Image holds both ways of setting one, and Unset sits beside Save. Thanks to xet7.

Two popups of the same menu answered one question. Change Background Image asked for a URL; Board backgrounds listed the images uploaded to this board and carried the upload button. So the popup that reads as the place to set a background had half of the ways to set one, and the other half was behind a different entry.

The upload is under the URL field now - a picture from the web and a picture from this machine, read together. Board backgrounds keeps what it is named for: the images already there, to set active, download or delete. The uploader moved as its own template, once, rather than being copied.

In the same popup, Unset was five blank lines and a rule below the Save it belongs beside. They are one row, Save first, sharing the width. Unset is type="button": inside that form a button with no type is a SUBMIT button, so beside Save it would have saved the URL it is meant to clear.

Members and Labels fold in the board sidebar, by their headings. Thanks to xet7.

The two sections at the top of the right sidebar could not be folded at all. The People / Organizations / Teams / Domains tabs with their avatars, and every label of the board, were always open and pushed everything under them down the panel.

Both have a caret in front of their heading now, and the heading is the button

  • the same control an opened card’s sections use, and the same one Activities got below them. Folding Members hides the tabs and the avatars in them; folding Labels hides the labels and the + that creates one. Enter and Space do what a click does.

The choice is the reader’s and lasts the session, not the board’s: neither decides anything on the server, so folding them for yourself must not fold them for everybody. Activities is the exception and stays as it is - its caret writes board.showActivities, which also decides what the publication sends.

The board's controls in the header bar fold into one caret. Thanks to xet7.

A caret leads the group, at its leading edge - left of the lock in a left-to-right page, right of it in Arabic, because its direction comes from the same rule every other caret uses. Folded, the seven controls after it are gone

  • Private, Muted, Sort Cards, Filter, Search, Show dependencies and Multi-Selection - and the bar is the logo, the board’s name and the caret.

The buttons a board’s own Rules put in that bar are NOT folded with them: somebody added those to this board on purpose, so the standard controls fold around them. It is named by the words the app already has - Collapse and Uncollapse - so no key was added to 147 language files to say them again, and it answers Enter and Space like the button it says it is.

All three folds share one store, one helper and one class (client/lib/foldState.js): a caret in the header and a caret in the sidebar pointing different ways in one language is the bug that avoids.

The board sidebar's Activities has a caret instead of an eye. Thanks to xet7.

The heading read “Activities” and beside it sat an eye and the words “Show activities” - one setting with two controls and its own name said twice. It is a caret in front of the heading now, and the heading is the button: click it and the activities appear under it, click it again and they are gone. That is the same control every collapsible section of an opened card has, from the same function (client/lib/sectionCaret.js), so the caret cannot point one way on a card and another in the sidebar of the same language - it points down when open, and toward the text when closed, which is right in English and left in Arabic. It carries role="button" and a tabindex, so Enter and Space do what a click does.

and fixes the following bugs:

The release workflow - what reaches the Release page.

Three snaps built, published, and then fell off the Release: one file listed twice. Thanks to xet7.

v10.91’s release run failed in four jobs, and three of them - s390x, ppc64el and riscv64 - had done all the work: each snap BUILT on Launchpad and each was published to the Snap Store. What failed was attaching it to the GitHub Release:

HTTP 404: Not Found (https://uploads.github.com/repos/wekan/wekan/releases/370103352/assets?label=&name=wekan_10.91_s390x.snap)

The step collected the file with two patterns - wekan_${VERSION}_<arch>*.snap and *_<arch>.snap - and a snap called wekan_10.91_s390x.snap matches BOTH, so the same path was passed to gh release upload --clobber twice. Asked to attach one name twice, it deletes the asset it has just uploaded and then 404s on it. The two other places in the same job that build this list were already deduplicated; this one was missed.

It is deduplicated now, and the attach is confirmed from the other side the way the native snap job already did it: read the release’s assets back and fail if this snap is not among them. An upload that reports success and leaves nothing behind is the failure nobody notices until somebody’s download 404s - and here every job that lost an architecture had already said the snap was built and published.

The fourth failure, armhf, is not this: snapcraft died with SSLEOFError while downloading the build log from Launchpad, on all three attempts, and produced no snap. That one is Launchpad’s side of the wire.

v10.92 could not start bash: the release notes outgrew an environment variable. Thanks to xet7.

v10.92’s release job failed before running a line of its script, and with it every job that needs the release - snap, docker, AppImage, the Windows and macOS bundles - so nothing published at all:

  ##[error]An error occurred trying to start process '/usr/bin/bash' with
  working directory '/home/runner/work/wekan/wekan'. Argument list too long

Nothing in the step was wrong. The notes are the whole newest CHANGELOG section - prepare measured them at 172,458 characters - and they reached the step as env: CHANGELOG:. Linux caps a SINGLE argv/envp string at MAX_ARG_STRLEN, 128 KiB, so execve refused to start the shell. The amd64 and arm64 bundles had already built and verified; only the publishing died.

The environment was itself the fix for the PREVIOUS failure. Interpolated inline as ${{ }}, the notes become part of the shell SOURCE, so a backtick in any code span runs as a command: that is how v10.59 published nothing and printed Incorrect: command not found. One shape is unsafe and the other does not scale, and this release was the first big enough to find the second wall.

So the notes stop being a shell value at all. releases/release-notes.sh prints the CHANGELOG section for a version, reading the file itself and taking its arguments from the environment inside a QUOTED heredoc, and each job appends its stdout to release-notes.md. Only file PATHS are passed around, which has no size limit and leaves the text as data that no shell ever parses.

prepare keeps the validation - a missing section is still cheap to fix there - but no longer publishes the text as a job output, because an output nobody can safely consume is a trap for the next person to find. The release job and the notes-rewrite job check out ref: main, the same ref prepare read.

The guard test is rewritten around the new shape: no changelog job output, no CHANGELOG in an env:, no interpolation into a script, and the extraction script is what all three consumers run. It passed throughout this failure, because it only knew about the injection.

Starting up - what a browser sees while WeKan cannot yet serve.

A container waiting for its database says so, instead of timing out. Thanks to Alishara and xet7.

“We upgraded to 10.91 … Gateway timeout appears” - #6595, from the same reporter as #6592, whose fix shipped in 10.91. It could not reach them: that fix is the SNAP’s, and this is a container.

WeKan does not open its web port until the database answers, and in a container nothing else was listening while it waited - so a reverse proxy in front returned a gateway timeout, and that is the same symptom for two completely different faults: WeKan is broken, or the database has not come up yet.

The entrypoint now asks whether the database answers (one ping, with the driver already in the bundle), and while it does not it serves the same bridge page the recovery case uses, saying WeKan is waiting for its database and where to look. What is bounded is the PAGE, not the wait: a database can take minutes to come up after an update, and giving up on it would be worse than waiting - so when the window ends the page stops, WeKan starts, and WeKan keeps waiting exactly as it did before. WEKAN_DB_WAIT_PAGE=false turns it off.

Performance - what the database is asked, and what it has to walk.

Cards, activities, comments and checklists are indexed at last. Thanks to xet7.

Reported by email against 10.91: “Still slow on loading cards. Test server with me as only user.” One user is the part that says what it is NOT - with nobody else on the server there is no contention, no queue and no lock, so the time is going into the queries themselves.

It was. cards, activities, cardComments, checklists and checklistItems had no index at all - everything a board draws and everything an opened card pulls in. “The cards of this list”, “the comments of this card”, “the newest activities of this board” each walked the whole collection. That is invisible on a demo board and expensive on a real one, and worse on FerretDB, whose SQLite backend has to walk the same documents.

The activities publication even explains that it keeps its selector flat “so both push down to FerretDB v1 (SQLite)‘s index instead of forcing a full-collection scan” - and there was no index for it to push down to.

Each new index matches a selector the app really makes WITH the sort it really uses, so it serves both the filter and the order: a filter-only index still leaves an in-memory sort of everything it matched, which on a board with a year of history is the slow half. They are created through the same idempotent ensureIndex every other index here uses, so a restart does not rebuild them and a backend that refuses one logs it instead of stopping the server.

Attachments and the snap’s databases - what can be read, and what cannot.

Moving attachments out of CollectionFS no longer deletes a binary two records share. Thanks to sbruckmueller and xet7.

Moving from MongoDB CollectionFS to File-System stopped on some attachments with FileNotFound: file 66336fc372e64200010f4832 was not found, and the reporter had it exactly: it is the identical files.

A CollectionFS filerecord points at its binary by copies.<coll>.key, the id of a file in the cfs_gridfs.<coll> bucket, and TWO filerecords can carry the SAME key - the same file attached twice, or a board copied with its attachments. The migration deleted the binary as soon as it had moved the FIRST of them, so the second read a file that was no longer there. The binary now goes only when no other filerecord still names it, and when the question cannot be asked the binary stays: a file left behind can be removed later, an attachment deleted out from under another record cannot be brought back.

A binary that really is missing - metadata restored without the chunks - is no longer a MongoDB stack trace naming a GridFS id. It names the attachment, says where it was looked for, and counts as SKIPPED rather than failed, because there was nothing to move. The Admin Panel’s summary line shows how many were skipped and how many failed, so a run that leaves attachments behind cannot look like a run that moved everything.

A snap whose MongoDB files no reader can open stops comparing them with FerretDB. Thanks to mueschel and xet7.

An instance whose MongoDB data was written by a MongoDB older than any mongod the snap carries printed this at every start, before the site came up: BOTH databases have been written to since the migration and then [autopick] reading both databases to see which one holds the work ....

mongodb_has_data answers “are the files there”, not “can anything here open them”, and on that instance those are different answers - mongod 7, 5.0 and 4.2 each refused the files in turn. So the comparison ran on a copy that cannot be served, and it is not a cheap question: it starts a mongod that cannot open the data, and then a SECOND FerretDB against the SQLite directory the running one already holds.

migration-control has already tried every reader in the snap and left .mongodb-data-too-old behind when none could open the files. With that marker present the MongoDB copy is no longer a candidate, and WeKan serves the FerretDB that has the data. Nothing is deleted and the marker stays: it is true, and it is right again on a snap that can read those files.

Comparing the two databases reads the one that is already running. Thanks to waltermhl and xet7.

snap run wekan.database-compare answered MongoDB: <unreadable> and FerretDB: <unreadable> on a live instance whose wekan.sqlite was 85 MB and whose site was up.

FerretDB was unreadable BECAUSE it was up: SQLite has one writer, so the second FerretDB the comparison started against the same directory did not get the data. The tool then reported the database it had been talking to all along as unreadable, and refused to choose.

The live one is asked first now - evidence only counts and sorts - and a second copy is started only for a database that is not running. Both speak the MongoDB wire protocol on the same port, so which one is answering is asked rather than assumed: FerretDB names itself in buildInfo and a mongod does not. Reading one as the other would be a wrong answer given with confidence, which is worse than “unreadable”.

The page sidebar - the controls of a page that has no sidebar of its own.

The controls in the page sidebar are rows, not three links run together. Thanks to xet7.

Rules, My Cards, Due Cards and Global Search each keep their controls in the shared page sidebar, and each draws them as .board-header-btn - which is what they were when they lived in that page’s own second header bar.

Every rule for that class is scoped to #header-quick-access, so inside the sidebar they had NO styling at all. On the Rules page that is three bare links jammed onto one line - ”← Board 🔀 Workflow view ⇄ Import / export” - with the icons run into the words and nothing to click but the text itself.

They are rows now: one per line, the width of the panel, with the icon in a fixed column so every label starts at the same x, and a hover to click against - the shape the board sidebar’s own rows have. Both sidebars built on that shell get it, All Boards’ as well as the page one.

Minicards - what a click on one does.

The stickers popup is wide enough to see the stickers. Thanks to xet7.

A hundred and fifty icons, eight to a row, in a popup 380 pixels wide: a column taller than the screen, so choosing a sticker meant scrolling past most of them.

It is as wide as the colour pickers now - min(90vw, 720px) - and the picker is a grid that fits as many columns as the width allows instead of stopping at eight. The clamp that places a popup knows the new width too, or a popup opened near the right edge would have landed half off screen.

A label on a minicard opens the labels, and not the card as well. Thanks to xet7.

Clicking a label opened the card’s labels popup AND the card details behind it: the click reached the minicard too, so one click did two things and the one nobody asked for was underneath the one they did.

It stops at the label now. A click in the labels AREA that is not on a label is still the card’s, as before - and which label was clicked is read from the EVENT rather than from :hover, which answers about the pointer and on a touch screen can still be true for whatever was tapped last.

Card details - the card as it is opened and edited.

Custom Fields is reachable on a card that has none, which is where it is needed. Thanks to xet7.

Everything about custom fields moved to the hamburger at the end of the card’s own Custom Fields heading - the picker of which fields are on this card, a pencil each, and Add custom field - and the card menu’s entry, the Board Settings row and the wrapper popup went with it. But the heading itself was drawn only when the card already HAD a custom field on it.

So the one way in existed only once you were already through it. A card with no custom field had no heading and therefore no hamburger; a board that had never used them had no way in anywhere, because the sidebar view that still holds the create and edit forms is opened by nothing. It is invisible on any board that already has a field - which is every board a developer tests on - and the browser tests found it on a seeded board that had none.

The heading is drawn for every card its reader may write to now, the same condition the rest of the editable card uses. The FIELDS below it still come from the card’s own values, so a card with none shows an empty section rather than a phantom row per board definition, and a reader who may not write sees no heading at all.

The anchor that used to open the old popup went too: the move took away its label text and left the tag, so it rendered nothing, could not be clicked, and still had two handlers bound to it. tests/customFieldsSectionMenu.test.cjs gains the two checks that would have caught this - the heading is gated on who may write rather than on what the card already has, and no empty anchor or orphaned handler is left behind.

A custom field made from a card is actually created, and its pencil is beside its name. Thanks to xet7.

Two things wrong with the popup the Custom Fields heading opens.

A new field made from it never appeared. Add custom field sits OUTSIDE the list of fields, so its data context is the popup’s own - which is the CARD - and the create and edit forms are one form that decided which it was doing by whether its context had an _id. A card has one, so creating a field from a card ran the UPDATE branch against a custom field whose id was a card’s: no such document, nothing written, and nothing said so. The same form works from the board sidebar, where the context has no _id, which is why this only ever happened from a card.

Both halves are fixed. The popup hands the form an empty context, because a new field is made from nothing. And the form no longer trusts a bare _id: it asks whether that id NAMES a custom field, so a context arriving from anywhere cannot make it update one that is not there.

The pencil that edits a field sat on a line of its own under the field’s name. Every other pop-over list has ONE anchor per row, so the row is a block and the anchor fills it - and two anchors in a block stack. This list has two by design, the name with the checkbox that puts the field on this card and the pencil that edits the field itself, so the row is the flex container now and the name takes the space the pencil does not. Scoped to this popup, so no other list moves.

Ten popups had no header, and so no close button. Thanks to xet7.

A pop-over draws its header from its title, and with no title it renders as no-title: no header, no X, no back arrow - Escape or a click away are the only ways out. Noticed on the question “Are you sure you want to delete this background image?”, which is the worst place for it.

Ten of them were in that state, and each is titled the way the other 151 already were: a <name>Popup-title key. Delete Background Image, Delete Duplicate Lists, Delete Account, Add Domain, Remove Domain, Map to existing user, Export swimlane, Export list, Export checklist, and the member popup.

They were added to EVERY language file at the same position, as the English placeholder a pull would produce - the files are one key order, and a key inserted in some and appended in others makes every later diff unreadable.

The other mechanism, Popup.open(name, { titleKey }), is not a second way but the same one pointed at a phrase the app HAS - “Custom Fields”, “Sort Boards”, “Show on Card” - so a word already translated 147 times is not copied into a new key that would start as English in all of them. tests/popupTitles.test.cjs walks every popup template and fails if one resolves no title at all, so the next one cannot ship without a header.

Upload background image did nothing, and every attachment upload now shares one config. Thanks to xet7.

“Upload background image” picked a file and then nothing arrived. Two fields of an upload’s config are not optional in the way they look, and the board background’s config - written by hand - had neither:

  • fileId, and the same id copied into meta.fileId. Attachments’ namingFunction is what decides the name a file is STORED under, and on the client it reads that id out of meta and deletes it. Without it the name is undefined.
  • transport. HTTP everywhere, because Meteor-Files’ default DDP floods the WebSocket and makes Safari reconnect at ~95%; DDP on Sandstorm, whose http-bridge strips the x-* upload headers so every chunk comes back “Can’t continue upload, session expired” [408].

Both come from one builder now, in attachmentUploadConfig.js, used by the card’s file picker, the card’s pasted image and the board background alike - so the next uploader gets them by asking for a config rather than by knowing two things nothing would have told it.

One popup answers the whole question now - the URL, the upload, and the pictures uploaded to this board - and Board Settings / Board backgrounds is gone with the popup it opened: a second entry to a list that is already on screen is a second place to look for one thing. Clicking a PICTURE, or its name, puts that one behind the board; it used to be a 14px check icon in the row under an 80px picture of the thing it applies, so the obvious click did nothing. Download and delete stay in that row, being the two things a picture cannot say.

Three things around the upload made the failure impossible to read, and are fixed with it. insertAsync can reject BEFORE there is an uploader to listen to, and that rejection went nowhere: the spinner stopped, no message appeared, and the picture simply never turned up. It is caught and shown now. Each picture is listed with its NAME, because two photos are the same picture at 80 pixels and nothing said which one had arrived. And a finished upload puts itself behind the board: “add background image” is asked for by somebody who wants that picture there, and an upload that only lands in a list, with the board unchanged, reads as one that did not work.

An avatar with no name to draw broke the render around it. Thanks to xet7.

getInitials() ended with this.username[0], so a user document that arrived without a username threw a TypeError - from a Blaze helper, mid-render. Two helpers call it, so each avatar threw twice, and the second throw left the SVG’s viewBox half-written (0 0 15), which Firefox refuses outright: the avatar drew nothing and took the rest of that render pass with it.

It is total now: initials, then a fullname, then the first letter of a username if there is one, then an empty string - a blank circle rather than a broken page. A fullname of only spaces used to spell the literal word “undefined” in the circle, because an empty word still contributed its missing first letter; empty words are skipped.

The Date Format menu is back: two collapse mechanisms were folding one field. Thanks to xet7.

The dropdown that chooses a card’s date format disappeared, and nothing brought it back - the Received, Start, Due and End dates under the same heading were there, but the selector above them was gone.

Two things were folding it. #1591 gave every FIELD of an opened card a caret on its own title and hid everything after that title when it was clicked, storing the fold per card in the user’s profile. The section carets that replaced it put ONE handle on each section’s heading - and that heading is drawn on the first field of the section, so a click on it was also a click on a field title. The old handler ran too, folded that field, and stored it. Reopening the section brought the dates back but not the field the heading sits on, and the per-field caret had been suppressed inside a group, so there was nothing left to open it: the Date Format select was hidden for good.

The #1591 half is gone - the handler, the caret it drew, the rule that hid the siblings, and the per-card state it re-applied on render. Every field lives in a section now, and the section’s heading is the only handle. The same per-user store still belongs to the CHECKLISTS, which key their own entries by checklist id and are untouched; that is why the store itself stays.

What the card restructuring nearly took with it, and a Copy card link button that can be read. Thanks to xet7.

Moving eleven sections with a script is how markup disappears without anything failing: the file still compiles, the tests still pass, and a card is quietly missing a row. Comparing the card against its state before the section work turned up four things, three of them lost:

  • the card button row (+cardButtons) - a slice used it as a boundary and swallowed it;
  • a custom field’s name: +cardCustomField renders the value and nothing else, so a card with three custom fields showed three values with nothing to say what they were;
  • the attachment count beside the Attachments heading, which the board setting for it still governs;
  • and one thing GAINED that should not have been: a second, bare + beside the End date’s add button.

All four now have a test of their own, because each was invisible to everything else.

The Copy card link to clipboard button carried only .btn, so it fell back to the plain grey button whose dark label is nearly unreadable on a dark theme. It is a .primary now - the board’s accent with white text - named in the same rules as the other themed buttons rather than given a copy of them.

A group's caret is on its first field's title, so the titles read as one row. Thanks to xet7.

Grouping the fields gave each group a header LINE of its own, and for a group named after its first field that line said the same word twice, one above the other: “Labels” and then “Labels, Stickers, Location”.

There is one row of titles now, with the caret at the start of it: Labels Stickers Location, Members Assignee Creator, and their + buttons on the row below. The caret belongs to the first field’s title rather than to a header above it, and everything it folds - that field’s own content and every field beside it - is behind the same switch, so a collapsed group is exactly one line.

The rule moved with it, from inside a field to the group, where it spans the card. The Checklists icon is a plain check again rather than a check in a box, which is what it was before it became a section.

Requested By and Assigned By are set with a + like Members, and survive an import. Thanks to xet7.

Requested By and Assigned By were set by clicking the word “Add”. They have the round + the two fields beside them use now, opening the same editor the text does.

Checking where else those two live turned up a round trip that lost them, which is the worst shape this kind of bug takes - the export file looks complete and the import reports success. They were exported by the card PDF, the card Excel and the board CSV, and imported by NOTHING: a card exported and imported back came home having forgotten who asked for it and who assigned it. Both importers put them back now, our own JSON and .zip and the per-menu one.

The board’s Excel TABLE did not export them at all, so that one gained two columns - header and value together, since a header without its value shifts every column after it.

And from other trackers, where the same idea has another name: Jira’s Reporter is who asked for the work, and an issue’s author on GitHub, Gitea, Forgejo and GitLab is the same thing. Both arrive as Requested By, as free text, so they survive an import from a tracker nobody on this board has an account on. A source with no such field - Trello, CSV - gains nothing, which a negative test pins.

Assignee and Creator sit beside Members, with their + buttons on one line. Thanks to xet7.

Grouping the fields left two things crooked. Members, Assignee and Creator were each a full-width row, so Assignee and Creator fell to the line below Members - the class that made a FIELD full width was from before the groups, and the group is the full-width row now. And a field whose title had been taken over by the group header had its + on the first line, level with its neighbours’ titles, instead of on the second line with theirs.

Every field in a group keeps its own title again. The group’s header names the family and folds it; the field’s title names the field, and its content - the +, the avatars - starts on the line under it, level all the way across.

A card's fields fold in GROUPS, from one caret each, and Members reads Members, Assignee, Creator. Thanks to xet7.

The caret beside Labels folded the Labels field and left Stickers and Location sitting under it, and every field had a caret of its own on the right - #1591 draws one on each .card-details-item - so a row could have two handles saying the same thing in two sizes.

The fields belong to FAMILIES, and the families are what fold now:

  • Labels - Labels, Stickers, Location
  • Date Format - the format, Received, Start, Due, End
  • Members - Members, Assignee, Creator, Requested By, Assigned By
  • Dependencies
  • Sort - Sort, List, Spent time

One caret per group, on its title, at the reading direction’s start. The fields inside a group have none: #1591’s per-item caret is turned off there, and its title is no longer a handle. Outside a group it is untouched, so nothing that folded before stopped folding. A collapsed group is one line - the caret, the icon and the name - because everything else is inside the fold.

Members reads Members, Assignee, Creator, which is the order it was asked for. The loose rule the layout drew above the users block is gone with it: the group’s own rule is above the whole group, so there is one line there instead of two.

The rules between card sections are the page's own line, and there is one where there should be. Thanks to xet7.

Four small things about those separators, all of them visible at a glance and none of them visible in the source.

The rule drew a border of its own, darker than the line the card already had above Creator. It is that same lighter hr now - the section rule sets only its spacing, so there is one line style on the card rather than two.

Labels had a rule above it. It is the first section: there is nothing above it to be separated from. Members had one too, immediately under the rule the layout already draws above Creator - two lines with a heading between them. Neither draws one now, and every other section still does.

And the rule above Activities was to the LEFT of the heading rather than above it, because .activity-title is display: flex and a rule inside it is a flex item. The heading sits outside that row now.

A section is one full-width row with one rule, and is named once. Thanks to xet7.

Two things the collapsible sections got wrong the first time, both visible the moment a card was opened.

Short rules everywhere. .card-details-items is a wrapping flex row, and the rule was drawn INSIDE a section - so it was as wide as that box, which is a stub of a line beside a heading rather than a separator across the card. The six sections that live in that row are now rows of their own, so the rule in them spans the card, and the items that were laid out side by side on purpose - Stickers, Location, the four dates, Creator, Assignees - still share rows as they did.

Two headings for one section. The Checklists and Subtasks templates draw their own title, and the new section header drew it again, so a card showed each of those names twice, one above the other. The templates keep what belongs to the LIST - add a checklist, add a subtask - and the section header is the only thing that names the section.

Every section of an opened card collapses from its own caret, with one rule between sections. Thanks to xet7.

A card had ONE caret, in its header, which collapsed the whole card - and Activities had an eye beside its heading. Every other section was a heading with no control at all, and the rules between them were written by hand wherever somebody remembered one.

Labels, Date Format, Members, Dependencies, Sort, Custom Fields, Description, Checklists, Subtasks, Attachments, Comments and Activities each carry a caret that collapses that section, and one rule above it. Both come from ONE template, so eleven sections cannot end up with ten separators and nine carets. The heading is a real button - it announces itself as one and Enter or Space works it - rather than a click target only a mouse can find. The caret points DOWN when a section is open and toward the way the reader reads when it is closed: right in English, left in Arabic, Hebrew and Persian.

Activities starts collapsed and everything else open, because a card is opened to read the card and its history is the thing you go looking for.

The eye is gone, and that needed one piece of care: it was NOT the same control as the caret. showActivities: false - its default - subscribed to activityType: 'addComment' only, so the eye chose between “comments” and “the whole history” as much as it showed and hid. Removing it without more would have quietly turned an opened Activities section into a comments-only list. So a card section that is OPEN now asks for the whole history, and one that is closed subscribes to nothing at all - which is the cheaper half of what the flag was for, without a second control to keep in step. A card that had the flag explicitly set to false keeps it.

Custom Fields is one section with one caret however many fields a card has: its header was briefly inside the loop that draws them, which would have been six sections sharing a caret on a card with six fields.

A dependency's card title wraps instead of being cut off at the pane edge. Thanks to xet7.

In an opened card’s Dependencies section each row is an icon, the linked card’s title and its controls, and the title was held on one line with an ellipsis. The card details pane is narrow and a card title is a sentence, so most of a real title - everything to the right of the coloured icon - could not be read at all.

It wraps now, and the row grows as tall as it needs to. Two properties, and the second is the one that is easy to miss: white-space: normal alone would have changed nothing, because a flex item’s default min-width: auto refuses to shrink below its content, so the text overflowed the row instead of wrapping inside it. min-width: 0 is what lets it. A title that is one long token - a URL, an id - breaks inside itself rather than pushing the type, colour and remove controls off the row, and the icon and those controls now sit beside the FIRST line instead of floating halfway down a three-line block.

Editing a card title keeps the X that closes it. Thanks to xet7.

Clicking a card’s title opens the title editor in place of the card header - and the header is where the card’s own close X lives, so the X disappeared the moment you started editing and Save became the only visible way out. Escape still worked; nothing on the screen said so.

The editor draws the X itself now, reusing the header button’s own class, so it is the same size in the same place and does not jump as the editor opens and closes. That class floats to inline-end, which is the right in English and the left in Arabic, Hebrew and Persian without a second rule - so the mirroring needs no direction branch and must not grow one.

The same one-line omission was in the Requested by and Assigned by editors right below it: the close anchor was there, its icon was not, so the click target existed and was invisible. They have their X now too.

Card export - what it says, and in whose language, format and time zone.

A card's PDF and Excel exports carry the same fields, under the same translated labels. Thanks to Heart1010 and xet7.

#6586 was reopened for what the first fix left: the PDF export of a card had grown separately from the Excel export of the same card, and every point the reporter came back with was a symptom of that. The labels were hard-coded English - “Assignee, Labels, due,… these titels should be in the user set language”. They were not even consistent with each other: the card export wrote Due: , the board export wrote due with no colon and a lowercase letter. And a card carries more than either export printed - “I think all those other things we set in a card should be also present in the pdf? Location, Voting, Checklists, Subtasks, Custom Fields, Attachments, Comments…”.

Two exports of one card that disagree about what is on it are one bug reported twice, so the fix is to stop them being two things. Both now carry the same fields, in the same order, under the same i18n keys, in the language the request carries: title, labels, creator, assignees, members, board, swimlane, list, card number, requested by, assigned by, the six dates, spent time, description, custom fields, checklists, subtasks, comments, attachments, voting and Planning Poker. The three sections neither export had - custom fields, voting and poker - are new on both sides, selectable like the others in the Excel export’s field checkboxes, and appended to that list rather than inserted, so a saved ?fields= link still asks for what it always asked for.

Inside the PDF exporters one field() helper writes every Label: value, which is what makes the board export’s “due” and the card export’s “Due:” impossible to have at once again. Every label carries its English text as the fallback, so a language that has not translated a key shows the word rather than the key.

Exported dates are in the reader's own time zone and date format, not the server's. Thanks to Heart1010 and xet7.

“The marked due date - here the time is not in the user set timezone (-2h wrong for Europe/Berlin).” It was worse than -2h: the PDF export printed UTC and the Excel export printed whatever zone the server process was started in, so the same card could come out with two different times on it and neither was the reader’s.

Dates are stored in UTC, and a WeKan profile carries no time zone at all, so the only place the reader’s zone exists is the browser. Every export link now sends it - Intl.DateTimeFormat().resolvedOptions().timeZone, the IANA name the server’s own Intl wants back - together with the date format the opened card is showing, which for a reader who is not logged in lives in localStorage where no server-side lookup can reach it. An export that printed 2026-08-14 for a card showing 14-08-2026 was the same card in two formats.

Both exports format through one helper, formatDateByUserPreference, which gained an optional zone; without one it still renders in the process’s own zone, which is what every client-side caller - the card view itself - wants. A server-built export that is given no zone renders UTC and SAYS UTC, rather than printing the server’s and looking like the reader’s. The route accepts only the three formats that helper understands, and a zone name is length-bounded: they are request parameters, not free text. 12:00Z now prints as 14:00 for Europe/Berlin, as 00:00 on the 15th for Pacific/Auckland, and a zone the runtime does not know falls back instead of failing the download.

A description's markdown is drawn as bold and italic instead of being stripped. Thanks to Heart1010 and xet7.

“Would it make sense to support markdown formated text in description? (so it gets transformed correct in the pdf output with bold, underline,…)” The first fix removed the syntax and kept the words, because **bold** in a PDF is four stray asterisks; this is the other half.

A description is now cut into RUNS - **bold** into a bold run, *italic* into an italic one, ***both*** into both, nested emphasis keeping both - and each is drawn in the matching Courier face: Courier, Courier-Bold, Courier-Oblique, Courier-BoldOblique, all base-14, so no font binary is embedded. Nothing is measured to place them: consecutive Tj operators continue at the current text position, so a font switch between two of them lands the next run exactly where the last ended, whatever the glyph widths are. Wrapping counts characters across the whole line, indent included, so an emphasised word does not push a line past the page.

What has no face is not invented: ~~strikethrough~~ and `code` keep their words and lose their markers, because a base-14 Type1 font has no strike and there is no fifth Courier to give code. Block markdown is still flattened either way - a heading loses its # and is drawn in the bold font, a bullet keeps one shape, a fence keeps its code. An underscore inside a word stays an underscore: file_name_here is an identifier, not three-quarters of an italic.

and has the following developer-facing changes:

Language loading - which of the 246 language files a visitor is sent.

The browser downloads one language, and a test says so. Thanks to xet7.

imports/i18n/data/ is 37 MB across 246 files, every one of them reachable from the client. The only thing keeping them out of the initial bundle is that each entry in imports/i18n/languages.js loads its file through () => import('./data/<tag>.i18n.json') - a call Meteor’s dynamic-import package code-splits into a module fetched on demand - and that tap.js imports exactly ONE of them statically, English, so the interface stays readable when dynamic import is broken (#6503).

That was true when checked and nothing pinned it. A single import data from './data/xx.i18n.json' added anywhere on the client would quietly ship that language to every visitor, and nothing about the app would look wrong - it would just be a heavier download, which no other test measures.

tests/i18nLazyLoading.test.cjs checks the six things that have to hold together: every registered language has a dynamic loader, languages.js pulls in no data itself, tap.js statically imports English and nothing else, the loader is called once for the single resolved tag rather than mapped over the registry, no client file bundles a language file, and dynamic-import is still in .meteor/packages. A negative test proves the detector really sees a static import, so the other checks cannot pass by failing to look.

Browser tests - the guards that drive a real browser, and what they say.

Three page objects still described the markup as it was before it moved. Thanks to xet7.

A run failed the same four tests in Chromium and in Firefox, and three of the four were the guard describing the OLD markup rather than the app being wrong.

editTitle waited for both js-card-title and js-open-inlined-form on ONE element. The title splits now - the leading half opens the editor and the rest of the heading drags the window - so the class is a DESCENDANT of the title, and the old selector matched nothing and waited out its timeout.

The Activities heading in the board sidebar carries TWO icons since the caret became shared with the card sections: the caret that says whether the section is open, and the section’s own comment icon. A bare i.fa matches both, which Playwright fails as a strict-mode violation rather than picking one. The spec asks for the caret specifically now - the three directions caretClassFor can produce - which is also a stronger assertion, since the caret is the part that indicates state.

The fourth