What's new

Changelog

Every Deckyard release, in plain language. The commit-level detail lives on GitHub; here is what it means for the people using it.

  1. v1.37.0 Latest

    Feature release

    Edit a slide in the slide library with the full slide form instead of only its name; favorites in the library are remembered, per user; and a security fix for library tags.

    Editing a slide in the slide library used to mean changing its name and description, or placing it in a throwaway deck to change anything else. Now Edit opens the full slide form. This release also fixes library favorites and closes a hole in how library tags are checked. Update if more than one person signs in to your install.

    Edit a library slide with the full slide form

    • Edit opens the same form you use in a deck, for that slide’s type: nested cards and members, images through the usual picker, background, the Layout chip and the accessibility fields, with Undo and Redo. Saving writes back to the same library item; you no longer need a temporary deck.
    • If you may not change a shared team item, because you did not create it and are not an admin, Edit stays greyed out with the reason, and Duplicate to my library makes a personal copy and opens the editor on it.
    • Two people saving the same item at once get a conflict beside Save instead of overwriting each other, and closing the editor with unsaved changes asks first.

    Favorites are remembered

    • The star in the slide library now sticks. Until this version it looked set and was gone after a reload. Favorites are per user: yours are yours, and a colleague sees their own.

    Security: library tags follow the item’s permissions

    • Until this version, a signed-in user could change the tags of a library item that was not theirs. That is closed. Tags now follow the same rule as the rest of the item: only someone who may change the item can change its tags, and another person’s personal item is not reachable through the API at all.
    • Tags are read-only in the library preview when you may not edit the item, like the description.

    No database migrations between 1.36.1 and 1.37.0; a normal upgrade is enough. The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.37.0 release on GitHub.

  2. v1.36.1

    Security release

    A security fix for the slide library: a personal item can only be changed or deleted by its owner, and a shared team item by its creator or an admin.

    A patch with one change, and it is a security fix. Update if more than one person signs in to your install. If you are still on 1.35.0 or earlier, read the 1.36.0 notes first: the trash cleanup it introduces runs on the first start.

    Slide library permissions are enforced on the server

    • Until this version, a signed-in user could change or delete a colleague’s personal library item through the API. That is closed. Every change to a library item now goes through one server-side check: you can change or delete only your own personal items, and a shared team item only if you created it or are an admin.
    • Two edits at the same time no longer overwrite each other silently. The second save gets a conflict instead of replacing the first.
    • Editing an item’s content keeps its other language versions in step, instead of leaving them on the old content.
    • Saving a custom-HTML slide to the library follows the editor’s rule: only people allowed to author custom HTML can create or change one there.

    For integrations

    • Changing a library item’s name, description or content through the API needs its revision. Every item now carries a revision; send it in If-Match. Without it the server answers 428, with an outdated one 409. An update that carries a field the library does not know is refused with 400. The app itself already does this.

    The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.36.1 release on GitHub.

  3. v1.36.0

    Feature release

    The trash now deletes what it says it deletes, after a retention window you can set; an image picked from ImageKit becomes your own media before it reaches a slide; and image blocks, text blocks and the editor lay out the way you read them.

    The trash page has always said that trashed presentations are deleted after 30 days, and until this release nothing deleted them. Now something does. That is the change to read before you update; the rest is an ImageKit picker that stops tying decks to an outside CDN, and a round of layout and editor fixes.

    Check your trash before you update. From this version on, a daily cleanup permanently deletes every presentation that has been in the trash longer than TRASH_RETENTION_DAYS (default 30 days), thumbnails included. The cleanup also runs as soon as the server starts, so the first start after the upgrade deletes the whole backlog at once: everything that has been in the trash for longer than the window on your install is gone within minutes of booting. To keep that backlog, restore what you need first, or set TRASH_RETENTION_DAYS higher before you deploy. There is no value that switches the cleanup off; 0, a negative number or anything that is not a number falls back to 30.

    The trash keeps its promise

    • Trashed presentations are deleted after the retention window. The cleanup is the same deletion as Delete permanently, one presentation at a time, thumbnails included. It runs once a day, so a presentation can outlive its window by up to a day.
    • Restoring stops the clock. A presentation restored before its window ends stays; move it to the trash again and it starts a fresh window.
    • The trash page states the configured number, in every language. An install with TRASH_RETENTION_DAYS=7 says 7 days. See Trash & Recovery and the data retention settings.
    • Permanent deletion only applies to a presentation in the trash. The app refuses to permanently delete a presentation that has not been trashed first, and asking it to restore one that is not in the trash now answers 409 not_trashed instead of 400.

    An ImageKit pick becomes your own media

    • Choosing an image from ImageKit copies it into your install’s own media first, and the slide points at that copy. A deck no longer breaks when the file is moved, renamed or deleted in ImageKit. If the copy fails, the picker stays open with the reason next to “Use this image” and the slide is left as it was. Decks that already hold ImageKit URLs are not changed.
    • The copy is an upload, so it needs uploads. On an install with DISABLE_UPLOADS=1, or in demo or sandbox mode, the picker says so instead of placing the image.

    Layout and editing

    • Image blocks use the space they have. On an image-blocks slide that keeps each picture’s original shape, the images are sized to the room under the heading: four landscape pictures fit on one row again, a second row no longer pushes the content over the heading, and the titles under the pictures line up.
    • A collection under a heading centres in the space below it. Text blocks with a few rows no longer leave an empty band at the bottom of the slide, and a table centres under its caption; content that is too long still starts under the heading. The text-blocks heading is centred by default, with left alignment still in the Layout chip.
    • Formatting is visible while you type. The formatting toolbar appears at the cursor as soon as you start editing a rich-text field, empty ones included, so you can switch on bold or italic before typing. Links still need selected text.
    • Text-blocks fields are in reading order: row heading and colour, the blocks, then the arrow to the next row, with the bottom subheading after all rows. The first row gets its heading chip too.

    Smaller changes

    • Importing a file that is not a .deck bundle gives a plain reason (“the file is not a zip archive”) instead of a library error, and a bundle with a broken manifest.json or deck.json names the entry that is broken.
    • Library tiles in the slide picker keep their own content when you change the preview mode or background.

    No security fixes in this release; 1.36.1, released the same day, is one, so update straight to that. The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.36.0 release on GitHub.

  4. v1.35.0

    Feature release

    A .deck file carries the whole deck, every language, theme and custom slide types included; the reader export reads as a document with real lists, quotes, asides and figures; and a numbered list typed in the editor no longer shows 1, 1, 1.

    One day after 1.34.0, two tracks land together: the second half of the portable deck, and the reader projection that turns a deck into a document. One breaking change, in the shape of the deck export, and one fix that is worth updating for on its own.

    Check your integrations before you update. The JSON export and the .deck bundle now carry every language of a deck, so ?lang= on the JSON export no longer selects one; content is the dominant language. /api/v1/schema/deck.json describes that export instead of the stored model: format, version and title are required, id, schemaVersion, created, modified, settings and description are gone, and slides carry no id. An import refuses a deck whose language, or one of its translation tags, the instance does not support, or whose lang contradicts the request, instead of silently repairing it. Two smaller repairs that used to happen silently stopped too: an action button with a bare domain (example.com) or a tel: link, and a LinkedIn link without https://, no longer render; the editor shows a hint next to the field so you can fix the value. Deckyard is in beta, so these ship in a minor version.

    Numbered lists number correctly

    • A numbered list typed in the editor is one list. Typing 1. First, Enter, 2. Second used to leave loose paragraphs until you left the field, and a stored list with blank lines between its items rendered as three lists that each said “1.”. Enter at the end of a line that starts with 1. or - now turns it into a list item on the spot and opens the next one, blank lines between items no longer split a list, and a list that starts at 3 shows 3. Decks already stored in the split shape render correctly without a change.

    A .deck file carries the whole deck

    • Export and import a .deck from the app. The export menu has a .deck row under Data & bundle, and the new-presentation dialog has an Import .deck tab, the default. If something did not come along, the dialog stays open with the reason and an Open presentation button; a broken bundle gives an error at the file input.
    • Every language, the notes, the timing and the visibility travel. The JSON and .deck exports carry all language versions plus speaker notes, duration and visibility per slide; an import restores the translations as language versions.
    • The deck’s theme and its custom slide types travel too. A deck on an organisation theme carries that theme: theme.json, the logos, and the curated Google fonts as files (Adobe, Monotype and uploaded fonts by name only, with the reason listed). Slide types made in Settings > Slide Types that the deck uses ride along. Whoever may manage themes and slide types sees one checkbox to install them, off by default; an existing theme or type is never overwritten, a taken slug gets -2. Without installing, the deck lands on the default theme and those slides import as placeholders that say the definition is in the bundle. Export, import, export produces the same bundle byte for byte.
    • A slide on a custom database slide type round-trips through JSON as itself again, and its translations come along in the JSON and .deck exports.

    The reader reads as a document

    The reader view (/p/…/reader) and the reflowable HTML export now follow the structure each slide type declares, instead of guessing from field names.

    • Lists, quotes, asides and footers are what they say. A numbered list slide is a numbered list with visible numbers; poll and Likert options are lines, not subheadings; a quote is a <blockquote> with the name and role in a <footer>; notes, tips and warnings are an <aside> that names its kind in the deck’s language; a byline or logo name is a <figcaption> under the picture; the first column of a table is a row header for screen readers.
    • Copy reads in the deck’s language. A callout without a label is named by its kind (“Key point”, “Let op”), in the table of contents too, and a chart’s data table gets a summary in the deck’s language (“Line chart with 5 points. Min: 25. Max: 85.”), which is also what screen readers get on the slide itself.
    • Values read as values. A KPI reads “98%”, a countdown duration is a <time> (1:30), a Likert slider names its scale (1 “Useless” to 10 “Essential”), an action or map link jumps to its slide (“Slide 3”), a social link is a real link with its label as text, and the end slide’s e-mail address is a mailto:.
    • A custom-HTML slide reads as its content, not as a code block showing its HTML and CSS. The first heading in the markup names it in the table of contents; inline styles and the CSS field stay on the canvas and drop out of the reader.
    • Pictures read true or nothing. A picture gets only a real alt text, explicit or the name of a logo or portrait, never a caption or file name; an image set or gallery is one figure group with one caption; a decorative set hides all its pictures.
    • Publishing refuses a meaningful picture without alt text, inline next to Publish, naming the slide and the field (API v1: 422 missing_alt with details). It used to be a “publish anyway” confirm for two slide types.
    • The printed handout is the reader’s projection: the same content and structure, figures, asides and lists included, in the deck’s language. The print wrapper is now reader-slide; the old print-slide and per-type print classes are gone, so custom print CSS keyed on those needs updating.

    Smaller changes

    • Choosing an image on an instance with more than one source (library, gradients, ImageKit) shows a card per source with a description; ImageKit is the primary choice, and the background field says what the picture is for.
    • Every HTML output (presenter, export, embed, print, reader) marks the slide wrapper with data-slide-type, the reader marks each block with data-field, and the variant of a callout, list or comparison and the tone of a matrix cell are attributes, for anyone styling or reading the HTML.
    • deckyard-mcp --help lists every tool and prompt the server registers, fork tools included, so the list never lags the code.

    For forks and slide-type authors

    • New optional declarations: markup: true on a code field, nameKey on an image field, unitKey, hrefKey, headingKey, duration: { secondsKey }, scale, the url and email field types, orderedWhen, rowHeader: 'first', role: 'aside' with kindKey, role: 'attribution', termWhen, defaultFromOption with copyKey, a datasetSummary function, and semantic: true on an enum. See docs/developer/slide-types.md and docs/reference/reflowable-html-export.md in core.
    • The .deck bundle is bundleVersion 3 (docs/reference/deck-bundle.md); a bundle with an unknown version is refused rather than half-read. Import responses say bundledTheme and bundledSlideTypes when something was carried but not installed.

    No security fixes in this release. The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.35.0 release on GitHub.

  5. v1.34.0

    Feature release

    Custom slide types now show on shared links, in follow-along and in slide-type previews, exports lay out image rows and emoji as the editor does, and the reader export gives every slide a real heading.

    A release with one new behaviour for the reader export and a set of fixes for slide types that your own instance adds. Nothing to do beyond updating.

    Custom slide types show up everywhere

    A slide type that your instance adds itself, or one built in the slide-type builder, is drawn by the server. A few places never asked for that drawing and showed an empty placeholder instead.

    • Shared links, follow-along and the speaker-notes companion now show those slides to viewers who are not logged in. On a password link, the slides appear once the password has been entered.
    • The slide-type settings and the Insert slide picker show a thumbnail for them, where they used to show an empty tile.

    Exports match the editor

    • Image blocks with images at their original aspect ratio stay on one row in PNG, PPTX, PDF and print exports. Before, four landscape images could wrap to two rows and cover the title.
    • Emoji render in colour in exports from the Docker image, instead of as empty boxes.

    Every slide in the reader export has a heading

    • The visible heading of a section is the slide’s title field, declared per slide type. A quote slide no longer turns its quote into the heading, and an image slide uses its title, not its caption.
    • A slide without a title still gets a heading for screen readers, named after the slide, so the outline of the document stays complete. Only visible headings are numbered.
    • Custom slide types get a visible heading once they declare which field is the heading. Without that, they get the hidden one.

    The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.34.0 release on GitHub.

  6. v1.33.5

    Maintenance release

    Saved slides in your slide library show their full content again, including slides saved before the slide format changed.

    A fix for the slide library. Nothing to do beyond updating.

    Older saved slides show their content again

    • A slide saved to the library before a slide type changed its shape now shows everything it holds. Before, such an item could show only its title and subtitle, for example a team slide without its people, both in the library preview and when you inserted it into a deck.
    • Inserting such an item gives you the current form of the slide, so it edits like any other slide of that type.
    • Decks that already received one of those slides get the content back when they are opened, as long as they have not been saved since.

    The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.33.5 release on GitHub.

  7. v1.33.4

    Maintenance release

    The "+ Subtitle" buttons on a slide work again on your own slide types: a click adds the field and puts the cursor in it.

    A fix for installations with their own slide types. Nothing to do beyond updating.

    The ”+” buttons on your own slide types add the field again

    • On a custom slide type, a click on ”+ Subtitle”, ”+ Meta” and the like now adds that field and puts the cursor in it. Before, the click did nothing on any slide type your installation defines itself, including your own version of a built-in type, because the slide was still loading from the server when the editor looked for the new field.
    • Text and rich-text fields both work, and typed text stays after saving.
    • If you click elsewhere while the slide is still loading, nothing is left behind on the slide you started on.

    For installations that extend Deckyard in code: the slide-server-rendered DOM event is gone. To wait for a server-rendered slide, use slideRendered(el) from client/lib/slide-runtime/slide-render.js, which resolves true once the markup is in and false if it never arrives.

    The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.33.4 release on GitHub.

  8. v1.33.3

    Maintenance release

    Opening a shared deck no longer blocks your colleagues: a slide is only locked once you actually change it, and the lock lets go when you stop.

    A fix for teams editing the same deck, with collaborative live edits off (the default). Nothing to do beyond updating.

    A slide is locked when you edit it, not when you look at it

    • Opening a deck or clicking through its slides no longer locks anything. Before, whoever opened a shared deck first held slide 1 for as long as their tab stayed open, so everyone after them saw “This slide is being edited by someone else” and read it as “you may not edit this”.
    • The first change you make to a slide takes the lock. Changing the deck title or its settings does not.
    • A lock lets go on its own: two minutes after your last change, as soon as you switch to another tab or window, and when you move to another slide. Pending changes are saved first. Start typing again and the lock comes back without you noticing.
    • If someone else is editing the slide, your change is not kept: you see who holds it, and the slide shows their version again, presenter notes included.
    • A lock left behind by a closed laptop or a crashed tab no longer blocks the slide until you reload. Once it has expired on the server, the slide becomes editable within half a minute.

    The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.33.3 release on GitHub.

  9. v1.33.2

    Maintenance release

    Copy beside an image sits on the middle of its column, so a short title and paragraph balance against the picture instead of hanging from the top.

    A single layout fix, the third release today. Nothing to do beyond updating; existing slides pick it up on their own.

    Text beside an image is centred vertically

    • On an image-text slide, the copy now sits in the vertical middle of its column: equal space above the title and below the last line, the aside included. This holds for every image width and with the image on either side. Short copy next to an illustration no longer leaves the bottom half of the column empty.
    • The image-set slide does the same in its beside layout, where the images stand in a stack next to the text.
    • Copy that is too long still starts at the top, so the title never slides off the slide.
    • The corner layout and the image-set layouts with a row of images keep the text at the top. There the text belongs to the image above or beside it, and the open space underneath is part of the design.
    • This is the default, not a new setting. If your fork sets its own vertical alignment on the copy column in its CSS, that value still wins.

    The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.33.2 release on GitHub.

  10. v1.33.1

    Maintenance release

    Fullscreen on the published page and in the presenter: no scrollbar, and both bars return as overlays when you move the mouse.

    A single fix, released the same day as 1.33.0. Nothing else changed, and there is nothing to do beyond updating. The reason to be on 1.33.x at all is in 1.33.0.

    Fullscreen fills the screen

    • On the published page, fullscreen hides both bars. Pressing F used to leave the control row hanging below the slide with a scrollbar to reach it. Now the title bar and the control row are hidden, the slide fills the screen, and both bars slide in as overlays when you move the mouse, without shifting the slide. They fade out again after a few seconds.
    • A window that fills the screen counts as fullscreen too. Safari’s green button and F11 do not use the browser’s fullscreen mode, so the bars used to stay in place. The page now treats a screen-filling window the same way.
    • The arrow keys, space and a clicker do not bring the bars back, so they no longer flash on every slide. Tabbing into a bar does, and a bar stays up while the mouse rests on it.
    • The presenter follows the same rules. In fullscreen its top bar now returns along with the progress bar, and the slide uses the full height instead of leaving room for a bar that is hidden.
    • ?ui=min is unchanged: it has no bars, and F still works there.

    The built-in slide-type count is unchanged at 35.

    For the complete, commit-level list, see the 1.33.1 release on GitHub.

  11. v1.33.0

    Feature release

    A new slide type for two or three images, speaker notes and a theme template in the PowerPoint export, countdowns that start when you reach them and run on the published page, and one breaking change to chart data.

    Ten days of work, most of it on two fronts. The PowerPoint export starts carrying more of the deck: speaker notes, and a template built from your theme. And the slides you present with got a round of repairs from live use, from countdown timers to a vote tally that a latecomer could not see.

    Check your charts before you update. The first row of a chart’s data is now always the header and is never plotted. A stored chart whose first row was data will read that row as column names and lose it as a point. Run node scripts/scan-chart-headers.js (it reads Postgres via .env, or deck JSON with --dir <path>) and look at each hit: a year header that used to be misread needs nothing, genuinely headerless data needs one header row added in the grid editor. Deckyard is in beta, so this breaking change ships in a minor version.

    A new slide type for several images

    • Images + text (image-set-slide) puts two or three images beside, above or below the text. The layout tiles “Row above”, “Row below” and “Beside the text” now switch to this type.
    • image-text-slide is one image. Decks that stored several images on it are upgraded when they are read, with no migration to run. An API client that wrote images[], imageFit or textColumns to that type should write an image-set-slide instead.

    PowerPoint export

    • Speaker notes travel with the PPTX, as PowerPoint notes on each slide. They used to arrive only as a separate notes.md or notes.docx in the handoff zip. The document is also authored under your app name now.
    • A new export row, “PPTX template”, downloads an empty .pptx with layouts from the deck’s theme: background, fonts, text colours and logo, set per slide. It is not a master that restyles an existing presentation.

    Charts

    • Bars land on the gridline that names them. A bar chart uses one axis per plot, so a 7 no longer sits on the 8 line; line-chart gridlines stay inside the plot, and a negative bar shows up where it used to disappear.
    • Pasting into the top-left cell of the grid reads the first row as column names, and the AI writes plain years as series names again.
    • Live-data bindings and quote attribution write to a field that is actually rendered.

    Countdowns and live sessions

    • An auto-start countdown starts when you reach its slide, not when the presentation opens. Before, every timer in the deck ran from the moment you started presenting. Leaving the slide does not pause it.
    • Countdowns run on the published page and in the embed, with Start, Pause and Reset. They used to show their start time and never move.
    • The digits sit in the middle of the slide, with or without a title, in the theme’s heading font and weight. The editor preview no longer flickers between two sizes.
    • A screen or phone that joins in the middle of a vote sees the current tally instead of “Total: 0” until the next vote comes in.

    Smaller repairs

    • The ImageKit library shows the newest files first, tag chips included. A large library used to show only its oldest sixty.
    • A backup export is complete: it includes the author photos on quote slides, no longer downloads call-to-action links as images, and includes uploads when UPLOADS_DIR is set.
    • A custom slide type made in Settings gets the same background, accessibility and logo controls as a built-in one, and can now be created through the public API and the MCP tools instead of failing with Unknown slide type.
    • The share dialog holds one height while it loads. The chart, KPI and Likert tiles in the slide picker show a real example, and an English countdown says “Time!”.
    • The reader view names a KPI item by its label and shows a video slide as “Video” with a link instead of a video ID.

    For forks and integrators

    • create_presentation_from_slides refuses a key the type does not declare (unknown_field) instead of dropping it, and accepts every example from get_slide_types. update_slide with a type refuses a type change it cannot map.
    • Every slide is created by one factory, so a deck from an import, the AI or an agent gets the same defaults as an editor insert, per language; an agent-made poll slide now gets a pollId.
    • A slide type declares how faithfully it exports (fidelity: { pptx }); a file-based type without it logs a warning at start-up. See docs/reference/slide-type-fidelity.md in core.
    • New optional field declarations: itemLabelField on an items field, mediaRef on a string field, foldUnofferedTo on an enum. A theme can declare the background its slides start on (defaultBackground).

    No security fixes in this release. The built-in slide-type count goes from 34 to 35.

    For the complete, commit-level list, see the 1.33.0 release on GitHub.

  12. v1.32.0

    Maintenance release

    A fork that adds its own MCP tools is handed the storage scope it needs, instead of copying four lines of core logic and watching them drift.

    A one-item release, and the item is for people running a fork. Nothing changes in the editor, in a presentation or in an export; if you run Deckyard as it comes, this update is optional.

    For forks and integrators

    • The custom-tools seam hands over storageScopeOf(context). Every facade under server/storage/ has taken a storage scope since 1.4.0 and refuses a bare repoRoot string, but the only builder for that scope lived inside core and never crossed the registerCustom seam. A fork tool that wanted to reach storage could therefore pass ctx.repoRoot and fail for every keyed caller, or copy core’s logic into the fork and re-copy it on the next merge. storageScopeOf now sits on the seam’s ctx, next to repoRoot rather than in its place: repoRoot is still the disk path for themes and uploads, it is just not a storage argument. Core’s docs/reference/mcp-server.md shows the call.

    No breaking changes and no security fixes in this release. The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.32.0 release on GitHub.

  13. v1.31.0

    Feature release

    A theme can carry a light and a dark logo and Deckyard picks the one that is visible on each slide; and a translation that has been showing the wrong slide since May is repaired by a migration.

    Two days after 1.30.0, a release about things a deck knows about itself. A theme can now hand over more than one logo and let Deckyard decide which one is legible on the slide at hand. The image library opens on the upload, not on the grid. And a translation that quietly fell behind its source in May comes back into line.

    Run npm run migrate after updating. Migration 081 takes the schema from 12 to 13 and repairs language versions that a May migration skipped: a translation showing an archived slide where the main language shows a timeline becomes a timeline again. Decks are also healed on read, so nothing stays broken while you wait, but the migration is what makes it permanent.

    A theme can carry two logos

    • logoOnDark and logoOnLight next to assets.logo, plus titleLogoOn* for the title slide. Deckyard reads the slide’s own surface - background colour, or a background photo - and places the mark that is visible there. A theme with one mark behaves exactly as before, so this costs you nothing until you want it.

    The image library and the slides

    • The picker leads with uploading. The dropzone and the URL field sit on one row at the top, with the grid underneath. A URL you paste no longer slides away under your fingers.
    • A card over a photo gets a real scrim. Text on a card stack over a background image was sitting on a glassy tint; it now has a scrim that makes it readable.

    Translations catch up

    • Every language version migrates, not only the dominant one. The May migration touched the source and left the translations behind, which is why one language could be showing an archived slide where the others show a timeline. Migration 081 fixes the stored deck, and a read repairs it in the meantime.
    • An old slide-type name is rewritten on every read. A deck still carrying a retired type name loads without anyone running a script, on every backend and in every language version.
    • The translation prompt treats an undeclared text key as prose. Such a field was being skipped; it is now translated into every language.

    Smaller repairs

    • The email default locale in settings rolls back to what the server holds when saving fails, instead of showing a value that was never stored.
    • OpenAI-compatible providers get the caller’s token budget, so a long generation is no longer cut off at a fixed default.

    For forks and integrators

    • err.details has a fixed shape per error code. An API consumer can switch on code and know which fields details carries.
    • In MCP, a tool’s gate belongs to its name. A fork registrar that only wraps a core tool keeps that tool’s permission and readOnly flag instead of dropping them. An explicit permission: null on a gated tool now throws at start-up rather than silently opening it. This only matters with a custom/mcp-tools.js of your own.
    • Nothing breaks: no breaking change and no security fix in this release.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.31.0 release on GitHub.

  14. v1.30.0

    Feature release

    Every refusal in Deckyard now appears where it was caused - at the field, next to the button, in the place the rows would have been - and alt text in the image library follows the deck's own languages.

    A small release, three days of work on one idea: a message about something you still have to fix should not be a toast that disappears while you read it. 1.29.0 started this at the save button; 1.30.0 finishes it across the editor, the settings screens and the sign-in pages. The image library also stops assuming your deck is Dutch and English.

    There is no reason to hurry with this one. Nothing is broken in 1.29.0 that this repairs, and nothing changes shape for a fork.

    A refusal stays where you caused it

    • A form that says no says it at the field. An empty name, an invalid colour, the wrong file type: the message sits at the control, with focus moved there, instead of in a corner of the screen.
    • An API that refuses a save says so next to the Save button. Settings tabs, email templates, the font and theme editors and the library save all keep the refusal visible until your next attempt, so you can read it while you fix the thing it names.
    • Signing in, resetting a password, unlocking a shared deck and joining as a guest do the same. These were the last screens still using a toast for something the reader has to act on.
    • A list that will not load says so in its own place. The share modal, the comments, the trash and the slide library show the failure where the rows would have been, and they do not steal your focus to do it.

    Alt text follows the deck’s languages

    • One alt-text input per language the deck has, in the image detail and at upload, instead of a fixed Dutch-and-English pair. The AI generator fills exactly those languages, and picking an image seeds every language buffer rather than one.

    For forks and integrators

    • Nothing breaks. createInlineError() and the doctrine in docs/reference/feedback-surfaces.md are now the one way to show a refusal, and a guard test rejects a hand-rolled *-error class. This only matters if your fork ships editor panels of its own.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.30.0 release on GitHub.

  15. v1.29.0

    Feature release

    A deck no longer has two languages but a list of them, each one a version you can edit, present and share; and every embed since 1.0.0 has been ignoring its own configuration, which this release fixes.

    This is the release where a deck stops being “the Dutch one or the English one” and becomes a deck with a list of language versions. One menu in the editor topbar shows which version you are editing, what other versions exist, and how far each one has been translated. It is also the release that repairs the embed: the configuration you pass to an embedded deck has not reached it since 1.0.0.

    Take this update if you have an embed running anywhere. The boot payload the embed page hands to the player went through HTML escaping into a raw-text script block, so it never parsed and every embed fell back to defaults. Slide count, language switch, start, loop, ui and a configured allowedOrigins list now do what you set them to. An empty origin list still allows all origins, as documented.

    Take it too if you have the MCP server enabled. A call over SSE is now scoped to the organization of the API key it authenticated with, and tool calls are gated on that key’s permissions and quota.

    A deck’s languages are a list

    • One language menu, one version per language. The editor topbar’s fixed NL|EN pair is gone. In its place a single button names the version you are editing, in its own language (“Nederlands”), and opens onto every version the deck has plus the languages you can add. A deck with one language shows one entry; a deck with five shows five.
    • The source version stays put while you edit a translation. Switching to a translation no longer moves the deck’s source out from under you, and you can promote another version to source from the same menu when the translation has become the one you maintain.
    • The translate modals name their source and target, so you can see which direction you are about to run before you run it, and per language you get a translation-progress figure derived from the deck itself rather than a guess.
    • Nested item texts are translated too. Text blocks that carry rows of blocks (rows[].blocks[]) were skipped by the translator and came back in the source language.

    The viewer and the follow codes follow the versions

    • /p/ and the embed show one language button per version that exists, instead of a fixed pair that could offer a language the deck does not have.
    • A session mints one follow code per version, so an audience member joining in their language lands in that language.
    • The presenter console renders the next-slide thumbnail in the deck’s language, which it previously drew in the interface language.

    New on a slide

    • callout-slide, the admonition family. Note, tip, warning and the rest, as a slide of their own.
    • Comparison slides have sub-variants: versus, before-after, pros-cons and tradeoff, so the layout follows what you are actually comparing.
    • Aside insets put a contrast block inside a slide, on the text, list and image-and-text types, without needing a slide of its own.
    • The title slide has its own typographic step (a size above the rest of the scale), and the cover steps down a size by itself when the text will not fit rather than overflowing.

    Repairs you will notice

    • Deck thumbnails load again, and they are content-addressed, so a deck you just changed shows a current thumbnail instead of a cached old one.
    • A refused save says which field refused it, next to the button that refused, with focus moved to the field. Local form refusals now sit inline at the field rather than in a toast that disappears while you read it.
    • An error announces as an error. A failure message is announced as such to a screen reader, Escape closes a message, and a message carrying a button stays up until you have used the button.

    For forks and integrators

    • ⚠ Two CSS class renames. .slide-title-universal becomes .slide-title and .slide-lijstje becomes .slide-list on the title and list types, and .sfi-card-kicker becomes .sfi-card-title on the feedback and follow-invite types. Deckyard’s own stylesheets and the core themes move with them; only a fork with its own rules on the old names has to rename. The tsu-* and .lijst* families are unchanged. Deckyard stays on 1.x while the beta badge is up, so a minor release carrying breaking changes is the intended shape rather than a mistake.
    • Custom slide-type definitions are validated, and scaffold only writes definitions that pass. A slide type stored in the database gets its CSS scoped to its own root, and there is a stated scoping convention for file-based JS types plus a shared partials library for eyebrows, badges and highlights.
    • An authenticated but unauthorized API call gets 403, not 404. A storage refusal now carries details with field, index, itemIndex and reason, so a client can point at what was wrong.

    The built-in slide-type count goes from 33 to 34 with callout-slide.

    For the complete, commit-level list, see the 1.29.0 release on GitHub.

  16. v1.28.0

    Feature release

    One command renders your theme across every slide type on a single sheet, Companion is the same product name in all twelve languages, and the background migration script finally works on Postgres.

    The third release of the day, and the one with something new in it: a contact sheet for themes. If you maintain a theme, npm run theme:preview <theme-id> now renders every slide type against every background that theme offers, in one HTML page you can scroll, so you find out where a theme breaks before an audience does.

    See a whole theme at once

    • npm run theme:preview <theme-id> writes a contact sheet. One tile per slide type and background combination, grouped by type, plus a contrast table for the theme’s flat background variants. Until now the only preview was the theme editor’s live one, which renders a database draft across a handful of types; a fork that versions its house style as a file theme in custom/themes/ had no preview at all, and nobody saw what a theme did to the thirty-odd types the panel leaves out.
    • A tile is what an export produces. Every tile goes through the same render path a PDF or PNG export runs, rather than a lookalike that would keep passing the regressions the sheet exists to catch. It needs no running server and no database.
    • The sheet walks the matrix the editor offers, including the backgrounds a theme adds on top of a slide type’s own, and it skips a theme-scoped type under a theme that cannot use it. The contrast table scores what is honestly measurable: a variant on a solid or gradient ground gets a ratio, artwork grounds are reported as not measured rather than scored as a failure they do not have.

    Translations settle on one word

    • “Companion” is one product name in all twelve languages. Nine locales had translated it - “Följevy”, “Begleitansicht”, “Acompañante”, “Compagnon”, “Asystent” and the rest - which made the same view look like a different feature depending on your language. The head noun is now the loanword everywhere, while the modifier stays translated, so German reads “Publikums-Companion” and French “Companion du public”.
    • Italian addresses you informally throughout. The locale was already roughly two to one in the tu form, but an earlier sweep had pushed the click verbs to the formal Lei (“Faccia clic”); around 260 strings now read as one register, courtesy possessives and clitics included. A few Italian terms were settled at the same time, including the follow-along invitation, which uses the loanword the way English and Dutch do.
    • Polish and Finnish get a handful of rewrites where the earlier pass had left something readable but not idiomatic. Key sets, key order and placeholders are untouched in all 27 files; only the words changed.

    For self-hosters

    • The legacy background-image migration works on Postgres and covers language versions. The script walked one directory of deck JSON, so a Postgres install migrated nothing and reported a clean zero, and a deck’s translated versions were skipped in both stores - in one production dataset that surface held more legacy slides than the decks themselves. It now takes --backend auto|file|postgres|both and walks decks, language versions and library items by construction. If you ran the migration earlier on a file store you do not need to do anything; if you skipped it, it now works in either setup. Version snapshots are left alone unless you pass --include-versions, because that fold is lossy and legacy content renders correctly until the first edit after a restore anyway.

    The built-in slide-type count is unchanged at 33.

    For the complete, commit-level list, see the 1.28.0 release on GitHub.

  17. v1.27.2

    Maintenance release

    A theme you switched off is now really off in every picker, the images field stops jumping under your cursor, and a handful of English labels settle on one spelling.

    Three repairs, cut the same day as 1.27.1. The one worth reading is the theme allowlist: it was documented as the visible subset of themes but behaved as a label, so a theme you had withdrawn was still reachable from two of the three pickers that offer one.

    A withdrawn theme is actually withdrawn

    • The enabledThemes allowlist is enforced on the server, in every picker. GET /api/themes used to return every theme and tag the ones outside the allowlist as disabled; only the creation grid read that tag, and a “Show all themes” toggle undid it in one click. The editor’s deck-settings select and the “start from a theme” row on Home ignored it entirely. The endpoint now filters, so no picker can offer what the allowlist leaves out, and the soft toggle is gone.
    • A deck that predates a withdrawal keeps its own theme. The pickers pass the theme the deck is on, and the resolved default theme is always kept in the list, so an organization cannot allowlist itself out of the theme its own new decks are created with.
    • Themes you manage stay visible where you manage them. The Themes settings tab asks for the unfiltered list, since it cannot offer a checkbox for a theme it is not allowed to see, and its checkboxes now read the allowlist that is really in force.
    • New ENABLED_THEMES environment variable, beside DEFAULT_THEME and with the same precedence: the app setting wins, then the comma-separated variable, then no allowlist at all.

    Fixed

    • The “Selected images” list refreshes where it stands. Ticking a preset checkbox in an images field wrote the value but left the list showing the previous one, and the two paths that did redraw rebuilt the entire form, taking focus off the checkbox you had just used. The list now updates in place, and a selection that runs past the field’s maximum unticks the box whose image did not make the cut instead of leaving it looking selected.
    • One English per label. Four keys carried a different English in the slide-type registry than in the interface: “Centre label” against “Center label”, a “Title (optional)” for a field that is not marked optional anyway, a Dutch example in an English help text, and a pair of aria-labels that repeated their own label. They agree now, which means a few English labels in the inspector read slightly differently than they did in 1.27.1; the Dutch and the other ten languages are unchanged.

    The built-in slide-type count is unchanged at 33.

    For the complete, commit-level list, see the 1.27.2 release on GitHub.

  18. v1.27.1

    Security release

    A theme can no longer send the PDF exporter out to the network, a bilingual deck keeps both languages through a collaborative edit, a theme variant's photo background reaches the PDF, and the editor shows one background control per slide.

    A patch release of repairs, and one of them is about not losing work. A bilingual deck holding a text-blocks slide could silently lose its second language the moment someone opened it in a collaborative session; that is fixed, along with a PDF exporter that dropped theme artwork and an inspector that showed the same background picker twice.

    Take this update if anyone but you can edit a theme. A remote URL written into a theme variable reached headless Chrome during a PDF export, which made free-form CSS in the theme editor a server-side request forgery route into whatever your export host can reach. The exporter now strips remote URLs from theme values before the document is rendered.

    And take it before the next collab edit if you keep bilingual decks. The dominant language was written into both versions on round-trip, and nothing warned you.

    Bilingual decks survive collaboration

    • Hidden prose is still prose. The collab codec treated every hidden field as a single machine value shared across languages, but the text-blocks slide keeps 38 hidden mirrors of human-written text, so a round-trip collapsed them to one language. Eight modules held eleven slightly different opinions on what counts as translatable text; they now share one definition - a field is text when its type says so, at every nesting level - and the mirrors translate like the prose they are.

    Exports render the theme’s artwork, and only the theme’s artwork

    • A theme variant’s image background arrives in the PDF. Two independent bugs blanked it: a local image URL in a theme variable never went through the embed pass that every other image takes, and the gradient rasterizer flattened a photo-with-scrim down to its fallback colour. Right in the editor, empty in the export - now the same in both.
    • Theme values cannot send the exporter to the network. The same pass now strips any remote URL from theme variables before the document reaches headless Chrome, closing a server-side request forgery route through free-form CSS in the theme editor.
    • Two latent export defects went with it: a class-name substring match that pushed gradients into the PDF as per-pixel shading - seconds per page - and a stylesheet order that let the base theme override a variant’s own text colour in exports only.

    A dark background gets readable foregrounds

    • A background variant now declares how dark its ground is. Setting a variant’s text colour already says so; the slide now carries the same contrast class the background-image path emits, so per-type treatments key on the luminance instead of recognizing background names. A dark variant no longer produces white cards holding white text.

    One background control per slide

    • The doubled “Background image” picker is gone. A half-finished unification folded the legacy per-type image pair into the shared control for one slide type only; any type following the developer doc got both. The fold now runs for every type, heals a stored deck on first edit, and clearing a background no longer quietly re-seeds the photo you just removed.

    The editor speaks your language throughout

    • Dropdown options and shared field labels are translated in all twelve languages. Sixty-six option labels and the eighteen shared field labels - the CTA-button fields, text size, poll and scale close behaviour, the alignment controls - showed raw English in every locale; they now carry the words each language already uses elsewhere in the interface.

    The built-in slide-type count is unchanged at 33.

    For the complete, commit-level list, see the 1.27.1 release on GitHub.

  19. v1.27.0

    Security release

    An instance admin no longer holds silent power in a workspace where they are only a member: the server now scopes the five remaining bypasses to the active organization.

    If you run Deckyard with more than one organization, this is a release to take. An instance admin who switched into a workspace where their membership says “member” kept six abilities the interface had already stopped offering them, because the server never followed the client. Those are now closed.

    Take this update if you run multi-organization mode. In a single-workspace instance nothing changes: the rule collapses to the check it always made.

    Admin power stops at the workspace boundary

    • The server scopes the five remaining admin bypasses to the active organization. The previous release hid ten admin affordances that read the instance-wide role instead of the role in the workspace you are actually in, but the requests behind them still succeeded. In that workspace an instance admin could remove an audience question, delete a library image, resolve and edit anyone’s comments, change any deck’s visibility including organization to private, and lock or unlock slides on a deck they did not author. All six checks now ask whether you are an admin of the organization you are in.
    • The ten affordances in the client follow the active workspace too, so the button and the request behind it finally answer the same question. A refused permission also renders as a plain refusal rather than a “Something went wrong” heading over a stack trace.
    • The e-mail template preview renders in a sandboxed frame. Admin-written markup with substituted fields was being written straight into the settings page, in the same origin as your session. It was never a privilege escalation, since writer and reader hold identical rights, but it was the last place that argument was holding up. As a bonus the preview now shows the actual e-mail: the wrapper carrying the mail’s own font, colour and 600px width used to be discarded on the way in, so you were previewing the settings page’s styling.

    Fixed

    • ?limit=abc no longer empties the page. The public v1 API had its own copy of the pagination parser without a guard for a value that is not a number, so a malformed limit produced an empty result and a null limit in the envelope. Both copies are now one function, which falls back to the default.
    • All three Q&A views read the same field. The follow page, the presenter’s notes panel and the moderator route each owned a copy of the question list, and they had drifted on which field carries the question’s text. They agree today, so nothing was visibly wrong, but the moderator was one schema change away from deleting a question whose text they had never seen. There is now one accessor, one feed and one set of mutations.
    • en/ is a superset of every locale again. Sixty-two keys existed only in Dutch, and the global slide fields had a separate key per slide type instead of one shared key per field. Both are fixed and translated into all ten fill languages, along with a copy sweep through six of them.

    Breaking, for contributors

    Deckyard stays on 1.x while the beta badge is up, so a minor release carrying breaking changes is the intended shape rather than a mistake. These two touch the translation scripts in the repository, not a running instance:

    • npm run i18n:validate is gone. Every check it made is now a test.
    • The i18n scripts share one flag vocabulary. --dry-run and --report and the bare language argument are replaced by --apply and --json, with reading as the default, so a script only writes when you say so.

    The built-in slide-type count is unchanged at 33.

    For the complete, commit-level list, see the 1.27.0 release on GitHub.

  20. v1.26.0

    Feature release

    The interface is now complete in twelve languages, and a poll or scale keeps its answers in one array instead of ten numbered fields.

    Deckyard shipped ten translations that were only partly translated: the language was selectable, but somewhere between a third and two thirds of every screen fell back to English. All ten are now finished, so the interface is complete in twelve languages. Alongside that, the last legacy numbered fields in the deck format are gone.

    If you write decks through the API or MCP rather than the editor, the poll and likert change below is the one to read; existing decks migrate on their own.

    The interface is complete in twelve languages

    • German, French, Spanish, Portuguese, Italian, Polish, Finnish, Danish, Swedish and Norwegian are finished. Each went from roughly 1,200 keys, the rest falling back to English, to the full set of about 3,400. English and Dutch were already complete, so twelve languages now render every screen in the language you picked, not just the menus.

    Polls and scales keep their answers in one list

    • A poll’s answers and a scale’s points are one options array. They were option1 through option4 on a poll and option1 through option10 on a likert slide, the last numbered field family in the format. Schema step v8 to v9 folds each into a single list of { text } entries. Existing decks migrate automatically, and votes already cast keep pointing at the answer they were cast on. If you generate decks through the API, MCP or the AI catalogue, write options: [{ text: '...' }] from now on.
    • Blanking an answer mid-poll no longer re-points the votes. Because the numbered form could have holes, both readers counted votes against the compacted list, so emptying the second answer of a running poll silently moved every vote cast on the third. A real list has no holes, so this stops happening.
    • A scale reads as an ordered list, a poll as an unordered one, in the reader and in exports, because the likert array declares that its order carries meaning.

    The reader projects structure, not configuration

    • Table and dataset slides follow their structure contract. A caption spans the section rather than the table’s width, and a markdown cell renders inline instead of being wrapped in its own paragraph.
    • Configuration stopped appearing as prose. An icon name, a media library id, a poll’s target slide and a blob of zoom coordinates are all strings, so the reader printed them as text. A field now declares whether it is readable text. The visible effect: an icon-card grid was headed “rocket” instead of by its title.
    • Alt text is no longer said twice. An item’s own image now folds into its figure, the way slide-level images always did, so a team card’s alt text is the picture’s description and not also the card’s heading.

    Fixed

    • A deck with a custom slide type saves. The Settings builder stores a custom type in the database, but the save path checked slides against the file-based registry, which a database row can never be in. So the editor inserted the slide and every autosave after it answered 400, leaving the deck unsaveable until you removed it. Saves now validate against the organization’s own registry, the same one the viewers and exports already read.
    • The images field is finished as an extension point, so a fork’s own slide types can use the multi-image field; its dead alt path is gone.

    The built-in slide-type count is unchanged at 33.

    For the complete, commit-level list, see the 1.26.0 release on GitHub.

  21. v1.25.0

    Feature release

    A share link finally works for the people it exists for: someone without an account gets the deck, its theme, and an answer when verification fails.

    A share link’s whole audience is people who are not logged in, and for them the viewer was broken in three ways at once. This release fixes all three, and puts a CSP header on the app shell itself.

    If you send share links to people outside your organization, take this update. An anonymous visitor holding a perfectly valid link got “Failed to load presentation”.

    • The deck loads. The viewer fetched it from an id-addressed route behind the login gate, so someone without an account got a 401. It now rides on the one call that proves the link, POST /api/share/:token/verify, which is also the only place a password can be checked. Slides marked hidden from viewers never leave the server, and ownership stamps, organization and visibility do not either.
    • The deck’s own theme comes with it. A custom theme is a database row resolved through a route behind the login gate, so every anonymous surface caught a 401 there, swallowed it and rendered the deck on a neutral fallback: silently unbranded. The theme now travels on the payload the capability already authorizes, which covers the share-link viewer, the follow-along audience and the notes companion. Built-in themes are static files the client loads itself.
    • A failed verification says something. A guest whose e-mail verification link failed was redirected back and shown nothing at all. There is now a banner between the deck chrome and the slide, because the link is valid and the deck is readable; only the identity step failed. It carries the button to request a new link rather than describing where to find it. One case gets its own answer: a verification token is spent on first use, so clicking the same mail twice while already holding a live session says what happened and offers nothing, rather than telling you to request a link you do not need.

    Typography scales with the slide

    • The slide type scale is derived from the slide’s own box. It was ten literal pixel values, and the theme multiplier its own comment announced was never built. Every step is now a number of reference pixels turned into a length by the slide’s width, and --t-slide-text-scale works. On the 1600x900 reference canvas, which is every path today, each step resolves to exactly the value it had before; on any other box it is proportional.

    Security

    • The app shell sends a CSP header, completing the work the previous two releases did for the render paths.

    Fixed

    • Pasting under a parent lands after its children, not inside them. The nesting rendered correctly, but the deck order interleaved, so the numbering read 1 Parent, 3 Child, 4 Child, 2 Pasted. Duplicating from the form header’s menu had the same bug.
    • Guest-join error copy is keyed on the machine code, and the duplicate e-mail guard is dropped.
    • The development auth bypass uses a valid address (dev@local.test).

    The built-in slide-type count is unchanged at 33.

    For the complete, commit-level list, see the 1.25.0 release on GitHub.

  22. v1.24.0

    Security release

    Lead capture is removed root and branch, the CSP becomes a real header on published pages and embeds, and 21 API error codes collapse into one.

    The lead-capture slide type is gone, along with everything that fed on it. It was a form you could put on a slide but never submit: the marketing consent it gated on was never wired up, so it collected nothing. Deckyard’s beta stance says a half-built feature gets stripped rather than parked, and the plan to collect visitor details properly will replace the code rather than preserve it.

    The built-in slide-type count goes from 34 to 33. A deck that stored a lead-capture slide still loads, presents and exports; the slide renders as an archived placeholder that names the type and keeps every field visible. A scan of stored decks on 22 August found none using it.

    What went with it

    • The .slide-lead-capture and .lead-capture-* CSS classes are no longer emitted. If you style slide types in a fork, those names are gone.
    • The /api/leads endpoints are removed, along with the lead storage, the GDPR token store and the owner-notification e-mail with its two templates.
    • The lead.submitted webhook is gone, and its admin row with it. Seven configurable events remain.
    • The leads.retentionDays setting, the lead e-mail preference and the admin “Lead capture” card are removed, as are /my-data and the analytics leads tab.
    • A migration drops the lead_submissions and gdpr_verification_tokens tables. Nothing else read either: share-link guests carry their own verification columns and the analytics erasure path has its own token, both untouched.

    This is not a major release: /api/leads was never part of /api/v1, and since the type was removed no deck could produce a lead.

    The CSP becomes a header

    • Published pages (/p/) and embeds (/embed/) send the document CSP as a real HTTP header, not only as a meta tag. Several directives are ignored in meta form, so a policy in a header is the one a browser can act on fully.
    • hls.js is vendored, closing the last CDN on the render path. Video playback no longer depends on a third-party host being up.

    One code for bad input

    If your API client branches on an invalid_<thing> error code, it needs updating. Twenty-one of them collapse into error: "invalid" plus details.field, which names the input the code used to encode in its suffix. The status is unchanged: 400 throughout. Gone are invalid_category, invalid_colors, invalid_contact, invalid_email, invalid_fonts, invalid_format, invalid_label, invalid_level, invalid_new_owner, invalid_order, invalid_permission, invalid_permissions, invalid_presentation, invalid_registration_mode, invalid_role, invalid_slide_index, invalid_slug, invalid_source, invalid_style, invalid_usage and invalid_weight.

    The four 401 codes stay: invalid_password, invalid_token, invalid_or_expired and invalid_or_revoked say the credential does not hold, which is a different answer at a different status.

    Fixed

    • Power over a deck reads the owner stamp, not owner-or-creator. Someone who created a deck and then transferred it away, keeping no collaborator row, could still write to it, delete it, reshare it and manage its collaborators. They now get a read-only editor. This is only reachable after an ownership transfer that did not keep the previous owner as a collaborator.
    • The never-mounted cookie-consent banner is removed. It was in the code and never appeared.

    The built-in slide-type count goes from 34 to 33.

    For the complete, commit-level list, see the 1.24.0 release on GitHub.

  23. v1.23.0

    Security release

    Every rendered document now carries a Content-Security-Policy, the media provider is called S3 instead of Scaleway, and people are named rather than addressed.

    The last two releases took third-party scripts off the render path. This one tells the browser about it: every document Deckyard renders carries a Content-Security-Policy naming the hosts it is allowed to load code from. It is the first of the three statements about third-party origins that the machine running the document can actually refuse.

    If you use remote media storage, your environment variables are renamed. The old names keep working until the first release after 2026-11-01, with a boot warning each. Details below.

    A policy the browser enforces

    • Every render path emits a CSP, assembled in one place and emitted before anything that can load. It is a host allowlist for code and styles rather than an XSS defence, and it says so: the render paths inline their own runtime, so script-src and style-src need 'unsafe-inline'. What it refuses is a script from a host nobody decided on, and default-src 'none' makes any new kind of fetch fail closed.
    • The last CDN loads are vendored. pdf.js came from cdnjs into headless Chrome; Prism and KaTeX are served from the vendored copies on every render path, not just in the app.
    • Analytics provider ids are validated rather than escaped.

    The media provider is S3, not Scaleway

    The remote media provider was named after one vendor while being plain S3-compatible underneath.

    • MEDIA_STORAGE_MODE=scaleway becomes =s3
    • SCW_ACCESS_KEY / SCW_SECRET_KEY / SCW_BUCKET / SCW_REGION / SCW_ENDPOINT / SCW_CDN_URL become S3_ACCESS_KEY / S3_SECRET_KEY / S3_BUCKET / S3_REGION / S3_ENDPOINT / S3_PUBLIC_URL

    The old names are read until the first release after 2026-11-01, only when their S3_* counterpart is unset, and each one that is read prints a boot warning naming its replacement. S3_ENDPOINT is mandatory under the new names: MEDIA_STORAGE_MODE=s3 refuses to boot without it. An untouched legacy install is the one exception, where an unset endpoint is still derived as https://s3.<region>.scw.cloud. A partly configured set now warns at boot instead of silently staying on local storage.

    People have names, not addresses

    The identity work that started in 1.16.0 finishes on the surfaces that face other people.

    • Comments and slide locks name people with an id and a display name. A comment carries an author pair instead of an address plus a name, and the AI author is flagged by the server rather than by the client comparing addresses.
    • Creator and trasher stamps are pairs too, and the e-mail fallback in identity matching is retired.
    • /api/v1/slide-library returns createdById instead of createdBy. The old field was the creator’s e-mail address, disclosed to any API key with library read access. The rest of the public v1 surface already exposed ids only.

    Removed

    • The presentation-level lock and its access-request queue are gone, along with the /api/presentations/:id/lock/* routes, the presentation_locks and lock_requests tables and the USE_DB_LOCKS variable. The client had hardcoded slide-level locking; this path was never reached. Slide-level locking is unchanged, and one real bug in it is fixed: releasing a lock read the row without an expiry filter, so an expired lock held by somebody else answered 409 for a lock nobody held.

    A transfer of ownership is final

    • Someone who transferred a deck away can no longer transfer it back. POST /api/presentations/:id/transfer-ownership answers 401 for an actor holding only the creator stamp; the decision keys on the owner stamp rather than on owner-or-creator. You keep the authorship record, not the power.

    For API clients

    • Every storage failure answers the status its own register entry states, rather than 400 by default. parent_not_found is 404, own_question is 403, and the internal-failure codes are 5xx. Eight codes are renamed to snake_case tokens.
    • The reason synonyms collapse to one spelling each.

    Fixed

    • A download is titled with the deck name. The <title> of a print, PDF-slides or PNG-export document is now the deck title alone; anything of yours that matched on the old suffix will stop matching.
    • Escape peels one overlay at a time and hands control back properly, instead of closing more than you asked for.
    • Copied slides keep their nesting and get fresh instance ids.
    • The more-menu wires each item once, closing before it acts, so a menu item cannot fire twice.
    • “Add as second slide” puts the invite second.
    • The markdown import buttons stop saying “Import JSON”.
    • Code blocks highlight on every render path, and the fork CSS seam serves custom/styles/ and fork fonts.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.23.0 release on GitHub.

  24. v1.22.0

    Maintenance release

    The built-in theme called "Deckyard" is renamed to "Amethyst", and only one built-in theme still wears the Deckyard mark.

    A small release with one rename in it, worth checking if you have decks on the violet built-in theme.

    The built-in theme id deckyard is now amethyst, labelled “Amethyst”. There is no alias for the old id. A deck stored with "theme": "deckyard" still loads and renders, in the default theme (brand, Forest), but it will fail validation on its next save until you pick a theme again. Custom themes in custom/themes/ are unaffected.

    • Amethyst carries the neutral placeholder logo, like the other archetype themes. brand (Forest) is now the one built-in theme that wears the Deckyard mark, which is what an archetype meant to be a starting point should look like.
    • The default theme is decided by the server, not picked client-side. The slide-library compose path used to choose one itself.

    Removed

    • Two undocumented AI wizard endpoints are gone. POST /api/ai/wizard-v2 and /api/ai/wizard-v2/outline are removed; the streaming endpoint /api/ai/wizard-v2/stream is the one path and is unchanged. Neither was in the OpenAPI document, so this is only noticeable if you called them directly.

    Fixed

    • Markdown actually renders in MCP previews and exports. The MCP process never initialized its sanitizer, so anyone previewing or exporting a slide through the MCP server saw lists and formatting as visible <ul> markup. It does now, and if a sanitizer is missing somewhere there is one warning on stderr instead of silence.
    • The follow API answers 404 for an unknown presentation and 503 for an unavailable store, where both used to be a 400. If you matched on 400, that needs updating.
    • The descriptor-table translation gaps are filled, and the coverage check can now see them, so the next gap fails the build instead of shipping as English in a Dutch interface.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.22.0 release on GitHub.

  25. v1.21.0

    Feature release

    Imported Notion images stop expiring, outgoing webhooks can be signed, and a data request no longer needs an account to complete.

    Two things here fix promises that were only half kept: an imported deck whose images died an hour later, and a webhook receiver that had no way to tell a real delivery from a forged one.

    Run migrations 075, 076 and 077 with this update. Without 076 the slide library does not start correctly, and without 077 a configured slide-library webhook URL goes missing.

    Notion images survive the import

    • Images imported from Notion are re-hosted in your own media library. Notion hands out signed URLs that expire in about an hour, and without ImageKit configured Deckyard kept those URLs verbatim, so an imported deck lost its images shortly after import. Each image is now fetched and stored through your configured media provider, and the block points at the durable copy. If an individual image cannot be fetched, the original URL is kept rather than the image being dropped. An instance with ImageKit configured is unchanged.
    • Those fetches are SSRF-guarded and enforce a maximum size, because the import is now making outbound requests on a URL from a document.

    Webhooks

    • Deliveries can be signed. Set webhooks.signingSecret and every delivery carries x-sb-signature: sha256=<hmac-of-body> next to the existing x-sb-event header. Absent when the secret is unset, so nothing changes until you opt in. There is an admin field for it.
    • actor.id carries the stable user id, not the address. The address stays available as actor.email, a contact value rather than an identity. If your receiver keyed on actor.id as an address, it needs updating.
    • The event slide.added_to_team_library is renamed slide.added_to_organization_library, in the payload and in the x-sb-event header. There is no alias, so a receiver matching the old string stops recognising it silently.
    • The user-agent is Deckyard-Webhook/1, replacing presentation-system-webhook/1.

    Data requests without an account

    • The my-data self-service routes are public, with a landing page and a durable token store behind them, so someone can complete an access or erasure request without being able to sign in. In 1.20.0 the verification mail started going out; this is the other half.
    • leads.retentionDays is in the admin settings screen, rather than only in a config file.

    For API clients

    • Public API v1 answers with one error envelope. Every route on the v1 surface now returns the same error shape.
    • Publishing runs on one shared core, so the internal publish path and the v1 publish endpoint cannot drift apart.
    • The MCP filter argument is ownership, not scope. It applies to list_presentations and list_recent_comments, in the answer as well; the values are unchanged (owned, shared, all). An old call falls back to the default silently rather than erroring.
    • The slide library speaks of a shelf. The API takes shelf: 'personal' | 'organization', and the bulk-export ZIP entry is slide-library/organization.json where it was team.json.

    Fixed

    • A two-column list caps at six items when a subheading meets three-line titles, instead of overflowing the slide.
    • Team-library listings get past a latent 100-row cap, and the authorization guard on them is resolved.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.21.0 release on GitHub.

  26. v1.20.0

    Feature release

    Feature switches all read the same way round: `AI_ENABLED` instead of `DISABLE_AI`. The old names keep working until November.

    Three kill switches were spelled as negatives, so turning something off meant setting a variable to true and reading a configuration file meant working out a double negative. They are now enable-form, defaulting to on.

    If you set any of these, plan a rename before 2026-11-01.

    • DISABLE_AI becomes AI_ENABLED
    • DISABLE_UPLOADS becomes UPLOADS_ENABLED
    • DISABLE_IMAGE_LIBRARY becomes IMAGE_LIBRARY_ENABLED

    All three default to on. The old spellings are still honoured, inverted, until the first release after 2026-11-01, and each one you have set prints a boot warning naming its replacement and the removal date. When both spellings are set, the new one wins. docs/reference/feature-flags.md records the rule, so a future flag arrives in the enable form too.

    GDPR self-service actually delivers

    • The verification link for a data request is sent by e-mail. The endpoint minted a token and never delivered it, so it told people “you will receive a verification link” while sending nothing; the self-service access and erasure flow was unreachable in production, which is exactly where it matters legally. An unconfigured mail setup now answers an honest 501 rather than pretending.

    Removed

    • The on-view refresh mode is gone. It appeared in the data-source settings and was never implemented, so choosing it did nothing.

    Fixed

    • MCP tool writes go through the maintenance gate. An instance in maintenance mode could still be written to over MCP.

    Security

    • The unauthorized datasource:refreshed broadcast is dropped. A refresh event went out over SSE to listeners who had no claim on the data source.
    • Analytics report changes are scoped to their own presentation. A report mutation was not checked against the deck it belonged to.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.20.0 release on GitHub.

  27. v1.19.0

    Feature release

    Every theme lever is a role token now. The per-slide-type tokens are gone, which is worth reading if you maintain a custom theme.

    A theme used to be able to reach into individual slide types: there were tokens for KPI tiles, for table variants, for the icon-card grid, for quote text. That let a theme style one slide type and say nothing about the next one, so a new slide type arrived unstyled and every theme had to be updated to catch up. Themes now set roles instead, and each slide type derives its look from those.

    If you maintain a custom theme, read the list below. Unknown tokens in an existing theme are harmless (they simply do nothing), so nothing breaks, but a theme that leaned on a removed token will render with the role-derived styling instead of what it asked for.

    What changed in the token vocabulary

    • KPI tokens are removed. --t-kpi-tile-{1..4}-* and --t-kpi-delta-* are gone; tiles render the neutral defaults, and a coloured series comes from --t-chart-*.
    • Table variant tokens are removed. --t-table-<variant>-* is gone; table planes follow --t-color-accent (plus its contrast) and the mist and raised surfaces.
    • Icon-card grid tokens are removed. --t-icon-card-grid-* is gone; the icon plane derives from mist and accent (override with --t-color-accent-soft), card bodies read --t-color-surface-raised, and header text follows the gradient and on-surface roles.
    • Quote and chapter text tokens are replaced. --t-quote-text-color and --t-chapter-text-color become --t-slide-bg-dark-text; --t-quote-author-color is renamed --t-color-accent-on-dark.
    • --t-list-item-title-letter-spacing is removed.
    • The legacy aliases are no longer emitted. --t-primary, --t-accent, --t-bg-dark and --t-brand-1/-2 are gone; brand slots --t-color-brand-{1..3} fill from brandColors.

    Fixed

    • DELETE /api/leads/my-data reaches the erasure handler instead of being routed to the id-addressed row. If you accepted GDPR erasure requests, they were landing in the wrong place.
    • Fresh invitations no longer show as “expired” in the admin user list.
    • A queued export reads its type from the job name, so a PDF job does not come back as the wrong format.
    • The remaining error stragglers use the canonical envelope, so an API client sees one error shape everywhere.
    • A failing sendEmail is typed. A misconfiguration answers 501 and an upstream failure stays 502, so “we cannot send mail” and “the mail server rejected it” stop looking the same.
    • The unused image-size transitive dependency is stubbed out, removing an unpatched package from the tree.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.19.0 release on GitHub.

  28. v1.18.0

    Feature release

    One word per meaning: a deck has a visibility, the tenant is an organization, and the API says so. Revoking access is now logged and told to the person.

    Deckyard used the word “scope” for three different things and “workspace” for two. This release picks one word per meaning and changes the wire, the database values and the documentation to match. If you call the API, one field is renamed; everything else in here is behaviour.

    If you use the public API, scope becomes visibility. The values are private and organization; workspace is gone as a value. The internal PATCH /api/presentations/:id/scope becomes /:id/visibility, and the webhook event presentation.moved_to_workspace becomes presentation.moved_to_organization, carrying fromVisibility and toVisibility. “Workspace” survives as a word people see in the interface; it is no longer a word in the data.

    • API-key capability lists are permissions, which is what the settings screen already called them.

    Losing access is now visible

    • The access log is symmetric. It recorded grants but not revocations, so a collaborator list could change without a trace. Revocations and permission changes are logged, and the person who lost access is told rather than finding out by clicking.

    For API clients

    • Slide-library tag updates take { tags: [...] } only. The other accepted shapes are gone; if your client sent a bare array or a single string, it needs the object.
    • One JSON entry point for every request body, which also means a non-object body is rejected consistently instead of per route.

    Under the hood

    • Deck versions, slide locks and the live SSE stream are keyed on the user id, continuing the identity work from 1.16.0 and 1.17.0. Trash and the slide library route their authorization through the same identity match.
    • The deck schema moves to v6. Per-field alignment keys that no longer did anything are folded away. Migration runs when a deck is read and is persisted on its next save, so there is nothing to do.

    Fixed

    • Analytics honours the retention setting, and the dead analytics tables are dropped.
    • Device labels work in secretless boot modes, deriving an ephemeral key instead of failing.
    • A comparison slide’s subheading sits one step above the column prose, which is what it was always meant to do.
    • A list slide re-measures its capacity against the current sizes, so items stop being dropped or crammed after a size change.
    • One canonical list of e-mail template types, and a dead export-ready path is gone.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.18.0 release on GitHub.

  29. v1.17.0

    Feature release

    Ownership is decided on the user id instead of the e-mail address, anonymous viewers can erase their own analytics data, and the analytics API stops handing out identifiers it should not.

    The identity work started in 1.16.0 becomes real here: ownership, workspace access and per-user settings are now decided on the stable user id rather than on the address someone happens to sign in with. Change your address and you keep your decks.

    There are two privacy fixes in here worth reading, both on the analytics surface, both below.

    Identity is the user id now

    • Deck ownership compares user ids. The API carries ownerId as the identity and the client compares that, instead of matching address strings.
    • A workspace grant is decided against the actor’s own organization, not inferred.
    • Per-user settings are keyed on the user id, so they survive an address change.
    • The migration verifies and backfills itself, so an existing instance ends up with every stamp filled rather than half converted. After upgrading, node scripts/verify-identity-migration.js confirms it landed on every row.
    • Version snapshots no longer carry owner e-mail addresses, and a migration wipes them from the rows you already have.

    Analytics

    • Anonymous viewers can erase their own data. Someone who viewed a deck without an account can now request erasure of their own session, proving possession of the session token rather than needing an account. If you present to audiences who never sign in, this is the surface that answers a GDPR request from one of them.
    • The internal/external tracking toggle is gone. analytics.enabled is the only switch; the second toggle promised a distinction the data never carried. If you had tracking off through that second toggle, you now have to turn analytics.enabled off, or you will start collecting again.
    • The session list no longer returns the session token. It was in the response and had no reader.
    • A per-deck device label replaces the raw device id. The dashboard shows “this is the same visitor” without the identifier that would let two decks be correlated.

    Presenting

    • The join link can edit the session’s speaker notes. Someone holding the join link can now write notes during the session, rather than only reading them.

    Fixed

    • A failed collaborator invite answers 500, not 400. A storage failure was reported as though you had sent something wrong.
    • DATABASE_URL wins over the individual DATABASE_* variables in db:migrate, matching what the server itself does. If you set both, migrations used to run against a different database than the app.
    • Queued exports load their theme properly, so a background export is styled the way the interactive one is.
    • An invite failure branches on the error code, not on the message text.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.17.0 release on GitHub.

  30. v1.16.0

    Feature release

    People start being identified by a stable user id rather than by their e-mail address, and the last audience data leaves the disk.

    A quiet release with one long-running theme underneath it: who someone is stops being their e-mail address. Deckyard used the address as the identity in ownership, collaborator rows and audit stamps, which meant that changing your address changed who you were. This release adds the stable user id alongside it; the releases that follow move each surface over to it.

    Identity gets a stable key

    • Decks carry an owner, creator and last editor as user ids, next to the addresses they already stored. Nothing changes for you yet; the reads switch over in 1.17.0.
    • Collaborator rows carry a user id alongside the address.
    • An ownership transfer persists the new owner rather than leaving the stamp behind.

    The rest of the audience data moves to Postgres

    • Questions, interactions and feedback are database rows. With 1.15.0 having removed the file backend, this closes the last path that wrote audience input to disk.

    Fixed

    • Collaborator rows are scoped to the deck, not to the session. The wrong scope could show a collaborator list that belonged to a different context.
    • A failing language switch says so. Switching a deck’s language silently did nothing when the switch failed; it now reports the failure.

    Security

    • The public share-link password check is rate-limited. A password-protected link could be attacked at whatever rate the network allowed. If you publish password-protected links, this is the reason to take this update.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.16.0 release on GitHub.

  31. v1.15.0

    Feature release

    PostgreSQL becomes the only storage backend. An instance that still keeps its decks as JSON on disk has to import them before it will boot.

    Deckyard used to be able to store everything as JSON files in a data directory, with Postgres as an option. That option is now the only one: the file backend is removed, and every deck, setting, live session and e-mail template lives in the database.

    This is the release that needs a plan before you update. If your instance has been running on file storage, it will refuse to boot after this update rather than present you with an empty workspace next to data it can no longer read. The way through is one command, and it is below.

    Import before you restart

    The boot check counts the decks in your data directory. If it finds them and the database is empty, it stops and prints both ways out instead of starting.

    • Run npm run db:import. It reads the old data directory and writes it into Postgres. The file data is only read, never moved or deleted, so the import can be repeated and the old directory stays as your fallback.
    • Take a database backup and keep the data directory until you have opened a few decks and confirmed they look right.
    • STORAGE_MODE now has one spelling per backend. postgresql used to be a silent alias for postgres, and an unknown value used to fall through to file storage. Both are boot errors now, so you can no longer end up on a backend you did not ask for.

    Postgres ships with the stack

    • The compose stack includes a Postgres service, so a fresh install needs no separate database setup. .env.example documents the database block as ordinary configuration rather than as an opt-in.
    • The installer fails fast on a stale DATABASE_HOST instead of starting and failing later.

    What moved into the database

    Four things that were still JSON on disk are now rows, which is what makes a multi-instance or containerised deployment behave:

    • Questions, interactions and audience feedback.
    • Live sessions and follow codes.
    • Instance settings, so an admin change no longer depends on a writable config file.
    • E-mail templates.

    Sandbox TTL cleanup and quota accounting run on Postgres too. The compose sandbox stack is the one exception: it has no database service and still reads deck JSON from disk, so it pins STORAGE_MODE=file explicitly.

    Also in this release

    • The interface is offered in twelve languages, of which Dutch and English are the two that are gated on completeness; the other ten are best-effort and fall back to English where a string is missing.
    • Image usage is found in the database, so the image library no longer reports an image as unused because it was looking on disk.
    • Seven slide-type CSS classes are no longer emitted: team-cards-group-left, kpi-note, quote-author-text, poll-results-main, sfi-header, sfi-card-code, sfi-card-qr. No built-in theme targeted them, so Deckyard’s own themes are unaffected. If you maintain a fork that styled one of these names, move those rules to the surviving class on the same element.
    • The numbering stays in 1.x. A 2.0.0 was briefly prepared and reverted; Deckyard’s beta stance caps breaking changes at a minor for now, which is why a release like this one is not a major.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.15.0 release on GitHub.

  32. v1.14.0

    Security release

    The last two libraries the app fetched from a CDN are now served by your own instance, and themes get a first set of role tokens.

    The release before this one took DOMPurify off a CDN. This one finishes the job for the two remaining third-party scripts, so an instance behind a firewall or on an air-gapped network no longer depends on somebody else’s server to highlight a code block or set a formula.

    Syntax highlighting and formulas come from your instance

    Prism (code highlighting) and KaTeX (mathematical notation) were loaded from a public CDN at page load. They are now vendored into the repository and served from your own instance, loaded lazily by the app shell rather than on every page.

    • An offline or CDN-blocked install renders code and formulas correctly, where before it fell back to unstyled text.
    • They load only when a deck needs them. Nothing is fetched for a deck that contains neither a code block nor a formula.

    Themes get role tokens

    The first phase of a longer piece of work on the theme system: slides start reading tokens that name a role (a surface, an accent, text on a dark plane) instead of naming a slide type. Nothing changes visually and no existing theme breaks; it is the groundwork for the consolidation that lands in 1.19.0.

    • docs/reference/slide-roles.md documents the vocabulary, if you write your own themes and want to see where this is heading.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.14.0 release on GitHub.

  33. v1.13.0

    Security release

    Deckyard stops asking a CDN for its sanitizer, the bundled fonts turn out to have been the wrong ones all along, and there is now an image source with no licence attached.

    Two of the three things in this release are the same thing: an instance should not need anybody else’s server to work correctly. The sanitizer came off a CDN, and the fonts that were meant to be self-hosted turned out to be re-downloaded from Google on every install, without a pin, and with the wrong bytes.

    This is a reason to update. If your instance blocks third-party requests, or sits behind a firewall that quietly failed to fetch cdn.jsdelivr.net, the editor’s sanitizer was not loading at all. And separately: every deck has almost certainly been rendering in a fallback typeface rather than the one its theme names. Both are below.

    The sanitizer is served by your own instance

    The app shell loaded DOMPurify from cdn.jsdelivr.net, which made a third party a dependency of two things at once: the security posture, and whether the app booted at all. It is now vendored from the npm copy that the server-side sanitizer already used, pinned by the integrity hash in package-lock.json.

    • An air-gapped or CDN-blocked install gets a working sanitizer, where before it got whatever the browser did when the script failed to load.
    • The browser and the server now run the same DOMPurify. The CDN tag was stuck on 3.2.2 while the server had moved to 3.4.12.

    The fonts were the wrong bytes

    This one is worth reading even though it is filed as a fix. Google serves each font family split into per-script subsets and expects the browser to pick between them by unicode-range. The installer took whichever @font-face came back first, which was cyrillic-ext. So every curated font on disk contained no Latin glyphs at all, and everything that asked for one, slides, exports, the theme picker and the app’s own interface, silently rendered in a fallback face instead. All four Inter weights were byte-identical to the same Cyrillic file.

    • Each weight now ships as latin plus latin-ext, with a real unicode-range on every generated @font-face. Your decks will start rendering in the typeface the theme actually names, which for most installs is a visible change.
    • The download is pinned. scripts/google-fonts.lock.json records the exact URL, SHA-256 and byte size of every file, and the installer refuses anything that does not match. Until now the contents of assets/fonts/google/ were a function of Google’s release schedule rather than of the release you installed.
    • Exports lose about two thirds of their font weight. Most curated families are variable fonts where every weight resolves to the same file, but each weight was declared separately, and an export base64-inlines every rule. The default theme was shipping roughly 930 KB of fonts where about 253 KB is unique, in every standalone HTML file and every PDF or PNG render.
    • Two built-in themes were exporting a fallback face where their CSS asked for a weight the theme did not list: Corporate was missing Archivo 500, Midnight was missing JetBrains Mono 500 and 700. All six themes are regenerated, and a test now fails if a list drifts again.
    • Font family names are escaped properly on the way into CSS. A name ending in a backslash could escape the closing quote of the declaration it sat in, and family names reach that path from uploaded fonts and custom theme JSON, so this was reachable input rather than a lint. Five places were interpolating names and URLs into CSS strings, and three of them were doing no escaping at all.

    An image source with no licence attached

    There is a third image source in the picker: 30 abstract gradients rendered from the six built-in themes’ own palettes. No API key, no attribution, no rate limit, no external request.

    The reason it exists is the licence question rather than the aesthetics. Stock images come from a third-party API with terms attached, which is awkward on a public or guest-facing instance where uploads are off and stock is the only source someone has.

    • The toggle is stockMedia.bundled.enabled, and it is off by default. There is an admin checkbox for it. Nothing changes on an instance that leaves it alone.
    • A picked gradient survives export. The files sit under assets/gradients/, which is the prefix the exporter inlines, so it works in PDF and PNG exports, published pages and embeds with no special handling.

    The built-in slide-type count is unchanged at 34.

    For the complete, commit-level list, see the 1.13.0 release on GitHub.

  34. v1.12.1

    Maintenance release

    Two deprecated slide types leave the registry, a page without video stops loading a video player, and links stay readable on dark theme variants.

    A small release on the same day as 1.12.0, with one clean-up worth reading about: the two slide types that had been deprecated for a while are now actually gone. Nothing breaks, and there is nothing to run.

    Two slide types are retired

    content-columns-slide and card-stack-slide are out of the built-in set. The built-in slide-type count goes from 36 to 34.

    • Stored decks do not break. A slide of either type renders as an archived placeholder with all of its content still visible, so nothing is lost and nothing crashes.
    • Card stacks have a successor. The placeholder names icon-card-grid as the rebuild target; it takes the same items[] shape, and scripts/migrate-slides.js will do the conversion for you if you want it done in bulk.
    • Content columns has no successor. Rebuild those slides with whichever type fits; there is no automatic conversion, because there is no type that means the same thing.

    A page without video makes no third-party requests

    Bunny’s player.js was loaded on every page of the live app, whether or not a deck contained a video. It is lazy-loaded now, and only when a video slide is actually present. For a deck without video that is zero third-party requests, which is both a privacy improvement and a faster first load.

    A theme variant with its own slide background inherited the base link colour, which could fall away against that background. The link colour is now derived per variant, so an inverted or dark variant gets a readable link without a manual override.

    Upgrade notes

    • Removing deprecated slide types in a 1.x release is the intended shape. Deckyard stays on 1.x for the whole beta, so a retirement like this one is not held back for a major version.
    • Nothing to run. The migration script is optional and only exists for people who would rather convert their card stacks than leave them as placeholders.

    For the complete, commit-level list, see the 1.12.1 release on GitHub.

  35. v1.12.0

    Feature release

    The slide editor was rebuilt on one generic engine, so every type edits the same way, and `?lang=` becomes the only way a URL names a language.

    Almost every hand-built slide form in the editor is gone, replaced by one editor that reads each type’s own declaration. You will notice it as consistency rather than as a new feature: adding, removing and reordering items works identically on every slide type now, because it is literally the same code.

    If you share links carrying ?locale=, they need to become ?lang=. The old alias is removed in this release; see the URL section below.

    The editor got rebuilt

    The per-type forms were the oldest part of the editor and the least consistent: seven different collection forms, each with its own idea of what an add button looks like.

    • One generic collection editor replaces all seven. Lists, cards, matrix and the rest now add, remove and reorder the same way.
    • Charts and tables become a closed per-field vocabulary. Each field is a declared control instead of a bespoke form, which is why they now behave like the rest of the editor.
    • The image forms became declarations too, sharing one element card.
    • New items get defaults in the deck’s language. Per-item defaults resolve per deck language, so adding a row to a Dutch deck no longer seeds it with English placeholder text.
    • The slide list reads each type’s labelField, so the label in the left-hand list is the field that actually identifies the slide.

    A URL that names a language is obeyed

    • ?lang= is now the only UI-locale URL parameter. The ?locale= alias is removed. Nothing inside the app used it, but a link you sent someone might: those links need updating, or they open in the recipient’s own saved language rather than the one you meant.
    • ?lang= outranks the saved interface language for that session, so a link that names a language opens in it, even for someone who normally works in another.

    Fixed

    • Quote slides stop jumping while you type. The editor canvas reflowed when the text field took focus, so the text moved under the cursor as you clicked into it. A delegated control on the same slide also stopped rendering as if it were broken.

    For self-hosters

    • The retention cleanups are actually scheduled now. All three ran only if something called them, and nothing did. They are wired into a daily job, honouring ACTIVITY_RETENTION_DAYS (default 180). If you have been running an instance for a while, expect activity data older than the retention window to disappear on the first nightly run after upgrading, which is what the setting was always supposed to do.

    The built-in slide-type count is unchanged at 36.

    For the complete, commit-level list, see the 1.12.0 release on GitHub.

  36. v1.11.0

    Feature release

    A slide type now has exactly one spelling on the wire, the write API refuses types it does not know, and PDF export stops producing files nobody wanted to send.

    This release settles something the format had been vague about: what a slide’s type is actually called. Until now the same type could reach you under two spellings depending on which door it came out of, and a write could put any string at all into that field. Both are fixed here. Around that, organizations became usable for the people in them, and PDF export got a round of repairs.

    If anything you run reads slides[].type off an export or an API response, read the first section before updating. Stored decks migrate themselves, so an instance needs nothing; an integration that pattern-matches on the old spelling does.

    One spelling for a slide’s type

    Export and every read API now write the canonical reverse-DNS id, eu.deckyard.slide.title, and nothing else. Before, the bare registry key could also appear on the wire, so a consumer had to know both spellings and guess which one it was looking at.

    • Anything reading the bare key has to move. There is one spelling now, and it is the reverse-DNS one.
    • The slideTypes manifest is gone from the deck envelope. It described the types used in the deck, was read by nothing, and duplicated a fact already on every slide.

    The write API validates the slide type

    An unknown slides[].type is a 400 on every write path. On Postgres installs any string used to pass through silently, which meant a typo in an integration produced a deck full of slides that could never render, and nothing said so until somebody opened it.

    If you write decks over the API, an unknown type now fails at the point of writing rather than at the point of reading.

    Stored decks migrate to schema v4 by themselves

    Decks already on disk carrying an old type spelling are folded to the canonical form on the next read or write. There is no script to run and no downtime; an administrator does not have to do anything for this one.

    Organizations: inviting people, and a profile screen

    1.10.0 gave multi-organization installs their first screens and left two obvious holes. Both are filled here.

    • Invite people into an organization from the interface, with the invitations that actually went out reported back to you.
    • Members can reach the member list, not just admins, so an ordinary member can see who else is in the organization.
    • An organization profile screen, including delete for the owner.
    • Administering the organization you are currently in behaves correctly, which it did not always do when you belonged to several.

    PDF export gets a round of repairs

    Five separate ways a PDF could come out wrong, all fixed:

    • Images are capped at their display size instead of their source resolution, which is most of the reason exported PDFs were as large as they were.
    • Gradient slide backgrounds are rasterized, and so are gradient pseudo-element layers, so a gradient no longer disappears or bands in the PDF.
    • Blurred shadows stop reaching the PDF as luminosity masks, which is what turned a soft shadow into a grey rectangle.
    • The slide font is anchored to the theme, so the app’s own interface font stops leaking into the exported file.
    • Local url() assets are inlined, and an image that cannot be fetched is blanked rather than left as a broken box.

    Dutch field labels for eight slide types

    Comparison, timeline, countdown, custom HTML, embed, lead capture, matrix and process slides had English field labels inside a Dutch interface. They are translated now, so a Dutch editing session no longer switches language halfway down the form.

    For implementors

    The spec work from this cycle is visible in the reference documentation: the published JSON Schema is open and the structure facet is normative, type ids are reverse-DNS with -slide dropped, and the catalogue now carries three tiers plus a fallback facet that says what a reader does with a type it has never seen.

    The built-in slide-type count is unchanged at 36.

    Upgrade notes

    • A breaking change in a minor version is the intended shape. Deckyard stays on 1.x for the whole beta; 2.0.0 is reserved for leaving beta, not for a cleanup that happens to break something. The single-spelling change and the stricter write validation are exactly that kind of cleanup.
    • Nothing to run. Stored decks migrate on access; the work is in your integrations, if you have any.

    For the complete, commit-level list, see the 1.11.0 release on GitHub.

  37. v1.10.0

    Feature release

    Organizations get a real interface, an exported deck loses about half its weight, and one slide type is retired - with the rename handled for you on upgrade.

    The headline of this release is not on a slide: an install that runs more than one organization finally has an interface for it. Around that, exported decks got dramatically lighter, and a slide type that was only ever a second name for another one is gone.

    If you run on the file store, the upgrade has one manual step. Stored decks are renamed automatically by database migration 056, but a file-store install runs node scripts/migrate-lijstje-slide.js once. Details below.

    Organizations get an interface

    Multi-organization installs have had the data model for a while and no way to see it. This release adds the screens.

    • An organization switcher in the user menu, so you can tell which organization you are working in and move between them.
    • Admin screens now follow your role in the active organization, not the instance-wide admin flag. Someone who is an admin in organization A and an ordinary member in B no longer sees admin screens while working in B.
    • The Users tab becomes the member list of the active organization in a multi-org install, and it is now a place where you do things: change a member’s role, remove someone, leave yourself, transfer ownership. The list is paged, so a large organization is workable.
    • Several permission checks around organization ownership and settings were tightened along the way.

    Still missing, so do not plan around it: inviting people from the interface, and the organization profile screen. Multi-organization remains in development.

    One slide type fewer, and the rename is done for you

    lijstje-slide is retired. It was a second name for list-slide and never a second type, which meant the same slide could be stored under either name depending on how it was created.

    • Stored decks are renamed automatically when you upgrade, by database migration 056.
    • File-store installs run node scripts/migrate-lijstje-slide.js once, after upgrading.
    • Anything that slips through both renders as an archived slide pointing at list-slide, not as a broken slide.

    split-partner-title-slide also leaves the registry in this release. It has been archived for several versions, so it was already unavailable when building a deck; slides that still carry it degrade to archived slides. Between the two, the built-in slide-type count goes from 38 to 36.

    An exported deck stops carrying the editor

    A standalone HTML export shipped the editor’s entire stylesheet: modals, inspectors, the slide-type picker, settings, none of which an exported deck can use. The export now ships viewer CSS only. That takes roughly 92% off the stylesheet inside the file, which on a real deck is about half its total weight.

    Nothing about the export changes visually, and ?ui=min still collapses the chrome the same way.

    A green and brass default theme

    There is a new built-in theme, Forest: forest green with brass, warm paper and sage surfaces, and a chart ramp that alternates hue and lightness so two adjacent categories differ by more than color. It is now the default theme.

    Existing decks are unaffected. A deck stores its theme id, so only new decks and decks explicitly set to default move. The violet deckyard theme keeps its id and its name; it is one style among six now rather than the one everybody inherits.

    Smaller changes

    • Video slides: the watch link shown in exports is yours to set. A video cannot play inside a PDF, so the export shows a link instead; you can now say where it points.
    • KPI tiles take their colors from the theme through tokens, instead of carrying their own.
    • A video slide’s poster image now renders in the PDF and PNG placeholders.
    • AI suggestions in comments are recognized by the effective author, so a suggestion made on someone’s behalf is labeled correctly.

    For self-hosters

    • Maintenance mode for deploys. Putting the instance into maintenance during a deploy means a save that lands mid-deploy fails visibly instead of silently, which is the difference between a retry and lost work.
    • A partial write no longer erases deck columns. Writing part of a deck could blank fields it did not touch.
    • The published JSON Schema no longer carries legacy fields, so a second implementation validating against it is not told to support fields Deckyard has dropped.
    • The editor got quieter. Deck thumbnails are rendered when you save rather than when you look, and the slide list no longer rebuilds itself every time a lock or comment event arrives.

    For the complete, commit-level list, see the 1.10.0 release on GitHub.

  38. v1.9.0

    Feature release

    Deck thumbnails stop turning back into placeholders after every edit, a table can carry one colour across its whole top row, and AI refine accepts the new text-blocks shape.

    The second release of the day, after 1.8.0, and the one worth updating for is the fix at the top: the deck overview stops flickering back to grey every time you open a deck.

    Thumbnails stop reloading after every edit

    The card for a deck on the home screen shows a picture of its first slide. That picture was cached against the deck’s revision number, which changes on every save - so editing slide nine, or just opening a deck and closing it again, threw the picture away. And a thrown-away picture costs about ten seconds of shimmering placeholder before the card fills in again.

    The cache now keys on what the picture is actually made of: slide 1 and the resolved theme. An edit anywhere else in the deck no longer invalidates anything. While a new picture is being rendered the previous one is served, so a card is never blank just because slide 1 is one edit stale.

    One colour across a table’s top row

    The top-left corner cell of a table has always taken the colour of the label column, so a soft table shows a faintly tinted corner set slightly apart from its header. That is a legitimate look, and now it is a choice: a new Top-left cell setting picks whether the corner belongs to the label column (as before) or to the header row, so the whole top row reads as one band.

    It composes with every table style rather than being a fourth style, so your existing table keeps whatever style it has. The difference is visible on soft; on plain and panel it changes nothing, because their header and first column already share a colour.

    AI refine accepts four-row text blocks

    Since text blocks became an array (rows[]), the schema behind AI refine still demanded the older numbered fields, so it rejected every array-shaped text-blocks slide - including freshly created ones, and any slide using 1.8.0’s fourth row. Refine now validates the array shape, with the numbered fields optional and passed through untouched.

    For self-hosters

    • server/data/deck-thumbs/ gets cleaned up. It grew by one file per edit to a first slide and nothing ever pruned it. Older rasters for a deck are now removed once the fresh one lands.
    • A 500 in the log when two people opened the same slide is gone. Acquiring a slide lock is now atomic; the editor always handled the collision quietly, so this was never visible in the interface, only in the server log.

    For the complete, commit-level list, see the 1.9.0 release on GitHub.

  39. v1.8.0

    Feature release

    A list slide keeps the text size you chose and fills the slide, text blocks take a fourth row, and quote slides stop typing themselves out.

    Three changes to how slides look while you are reading them, none of which asks anything of you. Existing decks are not migrated and nothing breaks; two of the three simply make slides you already have look the way you meant them to.

    A list slide keeps the size you picked

    Choosing “Large” on a list of seven short bullets did nothing: the slide rendered at the default size, in two columns, with the bottom half empty. Two separate causes, both now fixed.

    • An explicit text size wins. Past six items the size was thrown away outright, on the assumption that the list was still one column. At seven items it is two, where roughly twice as much text fits. Size and column count are now resolved together against a measured capacity table. The one case that still steps down - a list long and wordy enough to spill across two columns - says so, in the inspector under “Text size”, instead of silently changing nothing you can see.
    • The rows spend the leftover height. They used to stack against the top of the list box and leave the rest of the slide blank. They now grow into it, so the dividers spread evenly across the slide rather than crowding into its top half.

    Measured across 1512 combinations of item count, title length, text length, subheading, columns and text size: nothing overflows, 628 of them are fuller, and none is emptier.

    Decks imported from markdown benefit too. lijstje-slide, which the importer still emits, was a full copy of the list-slide definition that had drifted to a much older layout; it is now a real alias, so the same content renders the same either way.

    Text blocks take a fourth row

    The cap on a text-blocks slide goes from three rows to four. Nothing changes for a slide with three or fewer - those render byte-for-byte as before - and the layout tightens a notch at four rows so the extra one fits without riding over the title.

    The fourth row lives only in the canonical rows[] shape, which is what the editor writes. The older numbered fields (row1, row2, row3) stay frozen at three on purpose, so hand-written or imported content keeps its meaning.

    Quote slides stop typing themselves out

    A quote slide typed its quote out character by character, and it did so regardless of the deck’s “Builds” setting: turning builds off never reached this one effect. It also jittered, because the quote block centres the whole group - quote, byline and portraits - so the block shifted while the text grew.

    The effect is removed rather than adjusted; it can come back properly later. The per-bullet typewriter is a different effect and still works: the “Typewriter” reveal style in the deck settings is untouched.

    For the complete, commit-level list, see the 1.8.0 release on GitHub.

  40. v1.7.1

    Maintenance release

    One fix: the steps on a horizontal process slide line up along their top edge again.

    A single fix, released the same day as 1.7.0. Nothing else changed, and there is nothing to do beyond updating. The reason to be on 1.7.x at all is in 1.7.0, where PPTX export stopped being silently wrong after a PDF import.

    Process slides stop looking crooked

    On a horizontal process slide, every step was centred vertically against the tallest one, so a step with a shorter description sank as a whole: number, title and all. Descriptions are rarely the same length, so the row usually looked misaligned by a few pixels in a way that was hard to name and easy to see.

    The steps now share a top edge, while the group as a whole stays centred in the slide, so a deck that already looked right does not suddenly shift upward. Checked across multiple rows as well, at six steps.

    Existing decks simply look better after the update.

    For the complete, commit-level list, see the 1.7.1 release on GitHub.

  41. v1.7.0

    Feature release

    The field list an agent is handed now comes from the code that defines it, PPTX export stops breaking after a PDF import, and the format sentinel takes the product name.

    Three changes: one that repairs what an AI writes into a slide, one that repairs a file it turned out you could not trust, and one rename that asks nothing of you.

    If you use PPTX export and file import on the same instance, this is a reason to update now. On 1.6.0 and earlier, exporting a PowerPoint file after somebody had imported a PDF produced a deck with rubbish where the slides should be, and it did so without an error. Details below.

    The catalogue agents read is generated, not maintained

    A slide type declares its fields once, in the registry. The list handed to a model over MCP was a second copy of that, kept by hand, and it had drifted in five places. Two of them were visibly broken:

    • video-slide was offered a field that does not exist. Agents were told to fill videoUrl; the type reads source. So every AI-written video slide fell back, silently, to the demo video.
    • payoff-slide was offered a tagline that the type never reads at all.

    That catalogue is now derived from the registry, so this class of mistake cannot be introduced again. The same change also tells agents about fields an author could always fill in by hand but the catalogue had never mentioned.

    PPTX export was silently wrong after a PDF import

    Both exports render through headless Chrome, and after a PDF import had run in the same server process, Chrome started handing back a different kind of bytes. The PPTX builder took them at face value, so the file it wrote had nonsense where the slides should be, with no error anywhere in the chain.

    Restarting the server cleared it, which is exactly what lets a bug like this survive: it looks like a fluke, and the next export works. It is fixed, and there is now a smoke test that starts a real browser, so a silent regression of this shape fails in CI rather than in somebody’s deck.

    The format sentinel says deckyard.deck

    The format field inside a portable deck, and the media type of a .deck package, used to say slidecreator - a placeholder from December 2025, written before this product had its name. They now say deckyard.deck and application/vnd.deckyard.deck.

    Nothing to do, and nothing breaks. The old values are accepted on read for good, so a deck exported by an older version still opens. The file extension is unchanged: a package still downloads as <title>.deck. The one thing worth a look is tooling of your own that matches on the format field, which should accept both values.

    The format spec has been updated to match, and names both.

    For the complete, commit-level list, see the 1.7.0 release on GitHub.

  42. v1.6.0

    Feature release

    A published deck stops requesting things it does not use, and an embedded one can now drop the presenter chrome around it.

    Two changes, both about the page you hand to somebody else. A published deck used to load syntax highlighting, math typesetting and a video player whether or not it contained code, math or video; now it loads them only when it does. And a deck in an iframe can be asked for a minimal interface, so the frame is the slide and nothing else.

    Fifteen requests to third parties, down to none

    Measured on a real nine-slide deck with no code blocks, no math and no video: fifteen requests to third parties before, none now. Fourteen of them went to jsDelivr for Prism (the core, ten hard-coded language packs and a theme stylesheet) plus KaTeX, and one to mediadelivery.net for the Bunny player.

    Three things are worth being exact about:

    • This is every published page, not just the HTML download. /p/, the embed route and the exported file are built by the same code, so an operator sharing a link gets the same benefit as one sending a file.
    • The requests became conditional, not removed. A deck with a code block still loads Prism from jsDelivr, now the core plus the languages that deck actually uses instead of ten fixed ones. Math still loads KaTeX; a Bunny video still loads its player. A deck with a code block and a formula makes five requests.
    • Nothing leaked and nothing was repaired. Requests were being made that were not needed. That is a different thing from a vulnerability, and it should not be filed as one.

    Why it is the headline rather than a performance footnote: Deckyard is self-hosted software that people run to keep their data in their own hands, and jsDelivr runs on Fastly in the United States. A deck that phoned out to two American CDNs to display nine slides of plain text was an awkward thing to hand a public institution.

    ?ui=min for embedded decks

    Add ?ui=min to any published or exported deck URL and the title bar and control row disappear, with their layout rows collapsing to zero height. The scaled 1600x900 stage is then the whole frame, which means a host page can size an iframe with a plain aspect-ratio: 16 / 9 instead of compensating for a fixed chrome height.

    • The 3px progress bar stays. It is absolutely positioned, so it costs no layout height and the frame remains exactly 16:9.
    • The visible slide counter goes, because it was the part that wrapped on a narrow frame and the host page can show its own. Screen readers still hear “Slide 3 of 9” and the slide title.
    • Arrows, space, Home, End and F for fullscreen all keep working.
    • Without the parameter nothing changes, so existing links are untouched.

    The name and meaning match the ui option the embed API already had, so the two runtimes keep one vocabulary.

    Along the way, the reference documentation for the standalone export gained a section on URL parameters, which it did not have at all: loop, autoplay and interval existed but were written down nowhere.

    For the complete, commit-level list, see the 1.6.0 release on GitHub.

  43. v1.5.1

    Maintenance release

    Dependency maintenance, with one thing to check first: the practical Node floor moved to 22.13.

    A maintenance release: seven dependency updates and the one code change one of them required. Nothing new, and nothing behaves differently. There is one thing worth doing before you update, and it is not in the release itself.

    Check your Node version first. Deckyard has asked for Node 22 for a while and still does, but two dependencies in this release raised their own floor: jsdom (25 to 29) now wants 22.13 or newer, and puppeteer-core (24 to 25) wants 22.12. On Node 22.0 through 22.12 you were fine on 1.5.0 and are now under both. npm does not enforce that by default, so it installs with a warning rather than an error, and the parts that would notice are HTML export and sanitisation. That is the kind of thing you find out three weeks later, which is why it is here.

    What else is in it

    • zod moved to 4, and the AI schema validators moved with it: zod 4 dropped ZodError.errors and the five validators read that field. Both sit in this release, so nothing about AI generation changes between 1.5.0 and 1.5.1.
    • Chrome 150 under the export, via puppeteer-core 25. Verified by hand on a real render, because no test starts a browser; the output is unchanged.
    • jsdom 25 to 29, four majors under the HTML sanitizer. Twenty-one XSS and mXSS vectors through the four sanitise functions produce byte-identical output on both versions. Nothing was repaired here; it was checked that nothing changed.
    • The RSS, Atom and JSON feeds are unchanged after feed 5 to 6, plus a group of eight minor and patch updates and two CI actions that self-hosters never see.

    For the complete, commit-level list, see the 1.5.1 release on GitHub.

  44. v1.5.0

    Feature release

    Deckyard now picks the text color on a colored surface by measuring contrast instead of guessing from brightness, and the theme editor shows you the number while you choose.

    Two commits, and one of them changes how decks you already have look. Deckyard puts light or dark text on a colored surface for you; that choice used to run on brightness and now runs on a measured contrast ratio, which flips the text color on nine of the twenty palette colors across the five built-in themes. Alongside it, the theme editor gained a contrast readout, so the number you were previously supposed to check elsewhere now sits next to the picker that produces it.

    If you run Deckyard, read the upgrade note before you field the first “did something break?” message. Text that was white yesterday can be dark today, and that is the fix rather than a regression.

    The automatic text color is now measured

    Cards, accent blocks and background variants get their text color from Deckyard rather than from you. The old rule split on brightness: darker than the middle, use light text. That picks the color that looks logical, not the color that reads. Both candidates are now run through the contrast formula and the higher ratio wins.

    Measured across all five built-in themes:

    • Nine of the twenty palette colors get a different text color.
    • Every flip lifts a failing pair to WCAG AA or better. No pair gets worse.
    • Pairs under AA: nine, down to one.
    • The sharpest case is midnight’s icon block, which went from 2.05:1 - white on light blue, effectively invisible - to 8.16:1.

    The one pair still under AA is playful’s accent #ea580c at 4.40:1, where the accent color itself is the limit rather than the choice of text color.

    Because contrast is not symmetric around the middle of the range, the crossover sits near a relative luminance of 0.21, so mid-light backgrounds get dark text. That asymmetry is exactly why the old rule left so many pairs under AA.

    The theme editor shows contrast while you choose

    Every color pair in the editor now carries its contrast ratio and a WCAG 2.2 verdict: fail, AA or AAA, judged against the threshold for the text size in that role. Buttons and headings are measured as large text, body copy against the stricter one. An APCA Lc value sits beside it as a second reading.

    • It reports, it does not block. A theme with a failing pair still saves. A self-hoster can have a brand reason to ship a low-contrast variant, and a settings panel is not the place to overrule that.
    • The ratio is always shown, passing or not, so contrast is a property you can steer toward rather than an alarm that appears once you are already past the line.
    • WCAG is the verdict; APCA stands next to it. WCAG 2.2 is what EN 301 549 and the European Accessibility Act reference, so it is the claim anyone can lean on. APCA, candidate work for WCAG 3, models perceived contrast better - particularly light text on dark, which is what midnight is built on - but it never decides.

    The readout covers the four main colors and the background variants. The brand palette carries no verdicts yet, deliberately: those tokens are about to be redefined, and badges on them would be obsolete on arrival.

    Upgrade notes

    • Existing decks will look different, and that is the point. Nothing to configure and nothing to migrate: the text color is derived at render time, so opening a deck is enough. Expect the change to be most visible on midnight and anywhere an icon block or accent card sat on a mid-light color. If someone reports it as a bug, the pair they are looking at almost certainly measured under 4.5:1 before.
    • A custom theme benefits without being edited. The derivation is shared, so your own colors go through the same measurement. Open the theme editor to see where each pair now lands.

    For the complete, commit-level list, see the 1.5.0 release on GitHub.

  45. v1.4.0

    Feature release

    A slide type can now carry the rules your organization set for filling it, and every registered type is either visible to an agent or withheld on purpose.

    What an AI agent knew about a slide type used to come from a hand-written catalog inside Deckyard: editorial copy that says which type to pick. This release opens that up. Every registered type is now visible to agents, including the ones your own instance defines, and each type can carry the rules your organization set for filling it: which sources to draw on, which cut-off date applies, what has to be named. It is the first feature where a self-hoster fills something in, rather than something working better behind the scenes.

    Two versions were tagged the same day. 1.4.1 followed half an hour later with a single fix to the streaming API, described under Upgrade notes. Install 1.4.1.

    Your house rules travel with the type

    • Every registered type reaches an agent, or is withheld on purpose. The get_slide_types tool used to walk the hand-written catalog, so a type without an entry was invisible to agents and indistinguishable from one held back deliberately. It now reads the runtime registry, and types defined in your own builder UI arrive there too. Of the 38 registered types, 31 are offered and 7 carry an explicit opt-out; none are undocumented, and a test fails the build if that ever changes.
    • A type can carry its own usage rules. Alongside the copy that says what a type is for, a type can now hold the house rules for filling it in - the source a chart must come from, the reporting date that counts, the caveat that has to be stated. Those rules ship in every get_slide_types response, after the schema, so an agent reads the shape first and your rule second.
    • Four places to write them, depending on where the type lives. On a core type’s catalog entry; as an override in a fork, without patching the open-source file; on a file-defined type of your own; or in the builder UI for a type defined in the database. Rules are capped at 1000 characters per type, because they multiply by every visible type in every response.

    A removed type keeps its content

    The freeform-slide type is no longer registered. It had been deprecated for a while and was offered nowhere, so most instances have no such slides; if a stored deck does, nothing is lost. The content stays in the deck JSON, and the slide now renders a placeholder that names the type, says it was deliberately removed and points at the successor, instead of a bare “Unknown slide type” box.

    That placeholder is a general promise now, not a patch for one type. On canvas - editor, presenter, embed, PNG, PDF - it is bounded and says how many fields it withheld, because a 16:9 frame cannot grow. In the reader and reflow views it shows everything, since that is the recovery surface. Importing a deck with an unresolved type stores the original content as markdown rather than dropping it, which was the one path that actually destroyed anything. And the editor no longer crashes on such a deck: opening one used to produce a white error page instead of a placeholder.

    A shorter inspector rail

    The “INSPECTOR” title bar is gone; the cross that collapses the rail stays where it was. The background section is split by how often you touch it: colour is a plain visible field, while the image with its crop, focal point and overlay sits in a collapsed block that shows a thumbnail once a background is set. On a title slide the rail went from roughly 1310 to 376 pixels, which means Accessibility now fits on one screen without scrolling.

    Fixes

    • #slide=<n> works again in exported HTML. An exported standalone deck ignored the deep link in the URL and always opened on slide 1, so sharing an export with a link to a particular slide did nothing. The pattern was emitted inside a template literal, where \d collapses to a literal d; it never matched, and had been broken for as long as the export runtime has existed.
    • GET /api/v1/image-library answered 500 on every call. A documented endpoint that had never worked: the route imported a function the storage layer does not export. It is wired up now, with the search, category and pagination parameters actually implemented and the OpenAPI description corrected to match the fields the library really has.
    • Writing tags answered 400. PUT /api/presentations/:id/tags and POST /api/tags read the request body one level too deep, so writing tags through the API was dead on arrival.

    Upgrade notes

    • SSE error events changed shape. This is the whole of 1.4.1. Server-sent events on the import, conversion and AI generation streams now carry their error text in message instead of error, and no longer include an ok field. Inside the app you will notice nothing; if you have a fork or an integration listening on those streams, this is one field name to rename.
    • A breaking change in a minor version is the intended shape. Deckyard stays on 1.x for the whole beta, so 2.0.0 is reserved for leaving beta rather than for a cleanup that happens to break something. The freeform-slide removal above is that change.

    For the complete, commit-level list, see the 1.4.0 and 1.4.1 releases on GitHub.

  46. v1.3.0

    Feature release

    Organizations become usable on a single instance, alignment becomes a property of the block, and a cross-user XSS in the slide list is closed.

    The organizations track lands, and with it the answer to a question that had been half-built for months: how one Deckyard instance serves several organizations without either of them seeing the other. There is also a security fix that is a reason to update on its own.

    Update if you run a multi-user instance. This release closes a DOM XSS in the slide list, described at the bottom of this page. A slide title is written by a person, so in a deck shared with colleagues the payload can come from someone else’s slide, and it fires when you drag one in the slide list. There is no configuration to change: updating is the fix.

    Organizations on one instance

    Three changes that only make sense together:

    • Who you are no longer depends on where you are. Every authentication lookup used to filter on the organization stamped onto your account, while membership allows you to belong to several. Anyone whose home organization was not the one the request ran in resolved to nobody and got a 401. That is why MULTI_WORKSPACE_ENABLED was never wired end to end: switching it on would have logged people out. Identity is now resolved on its own, and membership is a separate question.
    • A request runs in the organization your session resolved to. Switching workspace used to change the session cookie and nothing else, so reads, writes and lists all stayed in the default organization. The organization now travels with the request, and only ever one you are a verified member of: membership is re-checked per request, because a token outlives a revocation by up to two weeks.
    • Authorization knows about organizations too. The permission layer granted workspace-wide reads and writes without mentioning organizations at all, and relied on the storage layer to scope the query. That held in practice, but a layer that only holds because the layer beneath it remembers to scope is not a layer. The four workspace grants now check that both sides are in the same organization; ownership, authorship and collaborator access are untouched.

    Single-organization installations, which is most of them, behave exactly as before and pay nothing for this: the organization is answered from configuration without a database lookup, and no query is added anywhere.

    Alignment belongs to the block

    Aligning a title slide used to mean setting the title, subtitle and meta line each to “center”, which still left them centred on axes far apart, because each box sits somewhere different on the slide. Alignment is now a property of the visual block rather than of every field in it, set once through the layout switcher you already use. Fields that no longer own their own alignment say so instead of showing a control that does nothing.

    Fixes

    • Nothing outlives its view any more. A lifecycle audit of the client found six leaks of the same shape: something registered on mount that survived the unmount with no handle left to stop it. Pending reconnects kept reopening streams nothing could close, and cleanups registered after teardown vanished into a map nobody drained. The notification bell and both Q&A streams now reconnect with backoff instead of retrying every 1.2 seconds per viewer.
    • A new deck starts in the language of your interface. Opening the app in English gave an English interface with the language toggle in “New presentation” still on the workspace default. A language you picked yourself still wins over both.
    • A stalled thumbnail always resolves. A card in the deck grid whose thumbnail request never completed shimmered forever. Every card now reaches a real end state, and upgrades to the real image if it arrives later.
    • Card stack follows the theme. The last-resort palette was a hardcoded purple belonging to one theme, so a custom theme rendered off-brand cards. It is now derived from the theme’s accent colour, with a neutral ramp as the final fallback.
    • Optimistic-lock conflicts speak the standard error shape. Three presentation write routes returned a hand-rolled body when a save collided with someone else’s. They now emit the canonical envelope, with the stable conflict and locked codes and the revision details preserved. Status codes are unchanged and the app is unaffected; if you built against the API directly, the error field now carries a machine code and the human sentence moved to message.

    Security

    A cross-user XSS in the slide list. The drag image built when you pick up a slide interpolated the slide’s title straight into HTML. A slide titled with a fragment of markup would run it the moment someone dragged that slide, in the browser of whoever was dragging. Because slide titles are written by people and decks are shared, on a multi-user instance the code and the victim need not be the same person. The drag ghost now builds its label as text, and two neighbouring sinks in the JSON debug and import panels were rebuilt the same way.

    For the complete, commit-level list, see the 1.3.0 release on GitHub.

  47. v1.2.0

    Maintenance release

    A small release: interface copy that reaches every locale, a translated editor toast, and a round of hardening for publicly reachable instances.

    A quiet release between two bigger ones. Nothing changes in how you build a deck; the work sits in the interface language and in the parts of the stack that face the open internet.

    Every locale gets the interface, not just English

    Deckyard ships in 12 languages, but a label written straight into the code has no key to translate, so it rendered English everywhere without any test noticing. An audit found and translated the strings still on the main path: the slide-library toast, the theme card specimen, the data-source modal’s provider hints and field labels, the autoplay toggle, the focal-point control and the theme logo alt text. A new check in CI now fails the build on new hardcoded copy, so the list can only shrink.

    The JSON debug modal’s “Invalid JSON” toast, the last raw string in an otherwise translated modal, now goes through the translation layer as well.

    Hardening for instances open to the public

    This round targets the anonymous sandbox, and it matters for any install where strangers can create decks:

    • Rate limiting behind a reverse proxy. If Deckyard sits behind a proxy without being told so, the limiter keys every request on the proxy’s own address, so all visitors share one bucket and a single abusive guest trips the limit for everyone. TRUST_PROXY and TRUSTED_PROXY_COUNT make it key on the real client address instead.
    • A per-guest quota. Deck count and total bytes are now capped per guest at every point where a deck is minted (create, import, duplicate), with a smaller request-body cap bounding each one. A guest at the cap gets a clear 429 rather than filling the shared volume.
    • One more SSRF hole closed. Remote images already went through the guard on export; a CSS background-image: url() smuggled in through an imported custom-HTML slide did not. It is now inlined or blanked before the HTML reaches the renderer.

    For the complete, commit-level list, see the 1.2.0 release on GitHub.

  48. v1.1.0

    Feature release

    The first big release since going open source: real-time collaboration, a reworked editor, self-contained exports, and a hardened self-hosting story.

    Deckyard 1.1 is the first substantial release since the code went public. It deepens the parts that make Deckyard a platform rather than a slide editor: people working in the same deck at the same time, exports you can hand to anyone, and a self-hosting path that stands up to real scrutiny.

    Real-time collaboration

    • Live presence: see who else is in a deck, with gliding name labels and focus chips on the slides they’re editing.
    • Conflict-free co-editing built on CRDTs, so two people can type in the same deck without stepping on each other.
    • Comments with @mentions and autocomplete, per-user read status, and GitHub-style subscriptions that follow you into the threads you join.

    A sharper editor

    • Selection-aware inspector that switches between element and slide settings as you work.
    • Inline markdown editing straight on the canvas, with a floating toolbar.
    • Draggable focal points on cropped images, fully keyboard-navigable.
    • Reorder cards inline with overlay grip handles, and open image settings from a chip on the image itself.
    • A spreadsheet-style data grid for chart slides, with a CSV toggle for pasting numbers in bulk.

    Exports you own

    • A unified export modal with color-coded formats, so the whole menu reads at a glance.
    • Self-contained .deck bundles that export and re-import with everything intact.
    • Standalone HTML with fonts embedded, so a deck renders offline with nothing to phone home for.
    • A semantic, reflowable HTML reader export, plus smaller PDFs thanks to image compression before embedding.

    Themes and branding

    • Richer theme configuration stored on the theme itself, including background presets and title-slide layouts.
    • Theme surface tokens now flow through the slide design system, so brand colors land consistently across slide types.

    Self-hosting and security

    • Single-IdP SSO over OIDC for self-hosted instances.
    • A one-command installer with interactive .env setup, plus a non-interactive flow for agents and CI.
    • Security hardening across the board: rate limiting and brute-force throttling on auth, SSRF and CSRF protection, per-resource authorization for live sessions, multi-workspace isolation, and security headers.

    Accessibility

    • An HTML semantics pass across the render layer: landmarks, native lists, and RTL support.
    • A document heading outline for visual export and embed, and an accessibility status panel with collapsible rules.

    For the complete, commit-level list, see the 1.1.0 release on GitHub.

  49. v1.0.0

    Public launch

    Deckyard goes open source. The full platform - create, present, publish, collaborate - lands on GitHub under the MIT license.

    The first public release. Deckyard was already a working platform in daily use; 1.0 is the moment its source became yours to read, run and change.

    What shipped

    • The full end-to-end platform: 35+ slide types, live presenting, one-click web publishing, and collaboration.
    • Self-hosting from a single Docker Compose file, Node and Postgres.
    • AI on your own terms: bring your own key, tune the prompts, or drive it over MCP.
    • Export to PDF, PowerPoint, self-contained HTML, PNG and JSON, so nothing is locked in.
    • MIT licensed, GDPR-native, built to run on infrastructure you control.