• v0.9.0 33a1f4b375

    v0.9.0 Stable

    rimskij released this 2026-08-30 21:03:40 +02:00 | 3 commits to dev since this release

    The admin is rebuilt: shared UI primitives, the brand palette and type, an
    editorial index in place of the data grid, and song editing at its own URL. The
    release also repairs an admin that had been unable to write anything for nine
    months, and closes the path traversal that repair would otherwise have made
    reachable.

    Added

    • Song editing has its own URL/admin/songs/<slug> renders the edit form and a files section, so an edit can be reloaded, shared and reached with the browser Back button. A row's Files link deep-links to #files, which keeps uploading one click from the list now that the file dialog is gone
    • Shared UI primitivesModal, Button (variants plus a pending state), Field/TextareaField (label, error and description wiring) and a SongForm extracted from the add and edit dialogs. Each admin surface previously carried its own copy
    • The design language is enforced by testsadmin-palette fails the build on any off-palette Tailwind hue, on a colour class with no dark: sibling, and on classes that cannot work in both themes; token-contrast recomputes WCAG ratios from the real hexes in globals.css. Two acceptance criteria that a reviewer used to have to remember to grep

    Changed

    • The admin wears the brand palette and type — Anton uppercase chrome, warm near-monochrome, and red reserved for hover, focus, active state and destructive actions. The Logout button is no longer red
    • The songs table is an index list — hairline-divided rows mirroring the public index, with the title as the link to its edit page. A list of songs with per-row actions, not a data grid: nothing sorted or compared across columns, and dropping the table dropped the horizontal scroll wrapper five columns needed
    • The dashboard reads top-down — header, then messages, then SONGS with a live count and the Add/Import pair, then the rows, then SETTINGS. The All Songs heading previously sat immediately above the Settings card with the songs list forty lines below it, so a screen-reader user landing on that heading reached a suggestions toggle and a theme picker
    • Featured state is written out — each row reads Featured or Not featured with a contrast step, rather than a single static word whose only state signal was a red checkbox fill. Colour is now redundant reinforcement, not the sole channel
    • The edit and file dialogs are retired — both opened over the list; the page behind them is reachable by link
    • Heading rules are visible in light themePageHeading and SectionHeading drew border-neutral-200, which is the light page ground, so their underline rendered at 1.000:1. Identical, not faint

    Fixed

    • Every admin write route returned 500withValidation consumed the request body to validate it, and each handler then read it again; a Fetch Request body is a one-shot stream, so the second read threw and the error surfaced as an internal error. Adding, importing, editing, deleting and featuring a song were all broken, in dev since 2025-11-12. The middleware now validates a clone. Its own JSDoc example had been demonstrating the double read, which is how the pattern reached every route
    • The file input was unreachable by keyboard — the upload control used display: none, which removes an element from the tab order, so there was no keyboard path to choosing a file at all (WCAG 2.1.1, level A)
    • The song row hover band was flush against its text — the band's edges landed on the glyphs, reading as a tight box rather than a row. It now reaches the page gutter while the text keeps its column

    Security

    • Path traversal in three admin routesslug went into a filesystem path under content/ guarded only by an existence check. delete-song would recursively delete any directory given ../x, delete-file would unlink any file through either slug or filename, and edit-song would overwrite any pre-existing lyrics.md. Post-authentication, and unreachable while the routes were failing, but live the moment they worked. Containment now lives in one place, resolveWithin
    • The write routes had no authorization of their own — they trusted the middleware alone, while three sibling routes each carried an explicit check. All four now match
    • 500 responses leaked absolute server paths — the raw error message was returned to the client, and Node filesystem errors embed paths. Unknown errors now return a constant
    Downloads