Lets a page under App/pages/ be previewed by an admin before the public can see it: list its route in draft_routes (App/config.php), checked in bootstrap.php alongside the existing /admin/* gate. Not authenticated -> same plain 404 an unmatched route gets, not a login prompt, so a draft's existence isn't revealed. Authenticated -> renders normally. No separate login flow needed - Basic Auth credentials are scoped to the whole origin/realm, so authenticating once at /admin covers draft URLs too. AdminAuth::isAuthenticated() is extracted out of requireLogin() so the draft gate can reuse the same credential check with a different failure response (404 vs. a 401 challenge). Renderer::render() gains an $excludeFromCache param so a draft without its own sidecar can't get written to the static HTML cache - .htaccess serves a cached file before PHP, and therefore any auth check, ever runs again, so an uncached exception is required, not just the auth gate. While testing this, found the same bug already existed for /admin itself: novaconium/pages/admin/index.twig has no sidecar, so it was already being cached - meaning any admin visiting /admin once caused the panel to be served to everyone, unauthenticated, straight from public/cache/admin/. Fixed in this change by excluding every /admin/* route from the cache the same way, and documented as a standing rule in AGENTS.md: any future mechanism that conditionally hides page content from the public has to make the same check, not just gate the initial request. Closes the "Draft pages (admin-only preview)" backlog item in novaconium/ISSUES.md.
24 KiB
Backlog & Roadmap
Official issue tracker: https://git.4lt.ca/4lt/novaconium/issues — bug reports and feature requests are filed and discussed there, not here.
This file is the roadmap that sits above the tracker: a curated, lower-noise list of what's planned, in progress, or decided against, used to triage and clean up the tracker (grouping related issues, deciding priority/sequencing, deciding what's not going to happen) rather than to replace it. An entry here should generally reference the tracker issue(s) it corresponds to once one exists; an entry can also exist here before any tracker issue is filed, for things that are still just an idea.
How to use this file
- Add new items to Backlog using the template below. Don't build anything the moment it's added — Backlog is "known, not yet started."
- Link the tracker issue once one is filed (
Issue:line). Not every Backlog entry needs one yet — file the tracker issue when it's ready to be actionable/discussed, not necessarily when the idea is first written down here. - When work begins, move the item to In Progress.
- When shipped, move it to Done, keep the entry (don't delete), and add
a
Shipped:line with the date and, once committed, the commit/PR reference. - If something is decided against, move it to Won't Do with a
Reason:line rather than deleting it — the "why not" is worth keeping. Close the corresponding tracker issue with a link back to that entry. - Keep entries terse. This file is a map, not a design doc — link out to
/admin/docs/design-notes, the tracker issue, or a futuredocs/note for anything long enough to need one.
Entry template
### <Short title>
- **Type:** Feature | Bug
- **Status:** Backlog | In Progress | Done | Won't Do
- **Priority:** Low | Medium | High
- **Added:** YYYY-MM-DD
- **Depends on:** <other entry title(s)> — omit if none
- **Issue:** https://git.4lt.ca/4lt/novaconium/issues/N — once filed
- **Shipped:** YYYY-MM-DD (commit/PR ref) — only once Done
<1-3 sentence description: what and why. For bugs, include repro steps and
expected vs. actual behavior. For features, include the motivating use case.>
Backlog
Suggested build order (foundations first, since admin login builds on two of the others):
- Blog tags/categories — no dependencies, but now needs a metadata
source design decision first (
PostRepositorywas removed whenhello-world/second-postbecame plain Twig pages — see the entry below), so worth doing first or together with Blog RSS feed. - Blog RSS feed — no hard dependency, but a per-tag feed is easiest once tags/categories exist.
- Internal search — SQLite groundwork it needed is done; ready to build.
- XML sitemap — no hard dependency, but shares crawling logic with Internal search, so easiest right after (or alongside) it.
- Media/file manager — no hard dependency; usable standalone, though best gated behind admin login once that exists.
- Syntax highlighting on code blocks — no hard dependency on the
copy button (see Done — shipped 2026-07-14), but touches the same
<pre><code>markup it did. - Admin login & user management — SQLite groundwork and session handling it needed are both done; ready to build.
- Ecommerce functionality — needs admin login & user management (order/product admin, and customer accounts); SQLite groundwork and session handling (cart) it needed are both done.
- Paywall functionality — needs everything Ecommerce needs, plus
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
build after it rather than in parallel — also now has a concrete
precedent to follow for the "gated content must skip the static cache"
part of its design (see Draft pages (admin-only preview) in Done, and
the caching/auth standing rule in
AGENTS.md), which was still an open question when this entry was originally written.
MySQL support, Session handling (with flash sessions), and Draft pages (admin-only preview) all shipped 2026-07-14 — see Done.
See Won't Do below for 404 tracking, dropped in favor of Matomo.
Blog tags/categories
- Type: Feature
- Status: Backlog
- Priority: Medium
- Added: 2026-07-12
Tag (or category) each blog post so posts can be browsed/filtered by
topic — e.g. App/pages/blog/tag/[tag]/index.php listing matching posts,
using the [param] route-capture mechanism (see /admin/docs/routing;
this project's own App/pages/blog/ doesn't currently use [param] for
posts, every post is its own plain directory named after its slug).
Lib\PostRepository (which used to back hello-world/second-post) was
removed when those two posts became plain Twig pages, like
twig-syntax-guide and style-guide — so there's no shared metadata
store anymore. App/pages/blog/index.php now just hand-lists each
post's slug/title/excerpt in a plain array; adding tags means adding a
tags field to each entry there and a lookup by tag over that same
array. Fine at current scale (4 posts); if the array grows unwieldy or
tags need real querying, that's a SQLite-groundwork question — no need
to block on it now. Once a feed exists, consider a per-tag feed too
(e.g. App/pages/blog/tag/[tag]/feed/index.php).
Blog RSS feed
- Type: Feature
- Status: Backlog
- Priority: Medium
- Added: 2026-07-12
An RSS (or Atom) feed for the blog, e.g. App/pages/blog/feed/index.php
returning Response::xml(...) — the sidecar contract already supports
this, no new mechanism needed. App/pages/blog/index.php already
hand-lists every post's slug/title/excerpt in a plain array (no
PostRepository anymore — see the Blog tags/categories entry above for
why); the remaining gap is a published-date field per entry so a feed
can sort them, regardless of whether that array stays hardcoded or moves
onto SQLite later. Should link from <link rel="alternate" type="application/rss+xml"> in the blog layout (or the root layout) for
feed auto-discovery, and probably wants its own App/pages/blog/_layout/
or a sidecar-only page — no index.twig needed since the sidecar returns
XML directly (see /admin/docs/sidecars's JSON-only example for the same
pattern, just with Response::json() instead of Response::xml()).
Internal search
- Type: Feature
- Status: Backlog
- Priority: Medium
- Depends on: SQLite groundwork (Done)
- Added: 2026-07-12
Crawl the site's own pages (likely via App/pages/ + rendered output,
rather than an external HTTP crawl, to avoid hitting the static cache/
.htaccess layer) and index the content into SQLite once the groundwork
above exists, so a search box can query it — a search sidecar (e.g.
App/pages/search/index.php) that reads the index and returns matching
pages, no external search service. Needs a decision on crawl trigger
(on-demand via an admin action vs. a cron-like re-crawl) and on indexing
granularity (whole-page vs. per-section). Consider reusing the SQLite
FTS5 extension if available, rather than hand-rolling text search.
XML sitemap
- Type: Feature
- Status: Backlog
- Priority: Medium
- Added: 2026-07-12
Generate a /sitemap.xml listing every routable page (App/pages/ +
novaconium/pages/ overlay, same resolution Router/Overlay already do)
for search engine discovery — one more piece of the SEO groundwork already
laid (/admin/docs/seo, canonical links, etc.). No hard dependency on
SQLite: a first version can be built by walking pages_dirs directly
(skipping reserved _/404 segments, resolving [param] routes only if
their concrete values are knowable from some data source — moot for
App/pages/blog/ today, since every post there is now a plain directory
rather than a [param] route) the same way Router::resolve() walks it,
with no separate crawl step.
That said, it may be worth sharing a crawler with Internal search rather
than building two separate page-enumeration mechanisms — if Internal
search's crawler already walks and records every real URL (including
resolved [param] values), the sitemap can just be a different
serialization of that same data instead of its own logic. Worth deciding
together when either is picked up. Should also exclude /admin/docs/* if
it isn't publicly reachable (see admin authentication) and the noindex
pages already marked via the robots block.
Media/file manager
- Type: Feature
- Status: Backlog
- Priority: Medium
- Added: 2026-07-12
An upload/browse/delete UI for media (images, PDFs, etc.) so sidecars and
Twig templates have a consistent place to reference uploaded files from
— e.g. a blog post's header image — instead of authors manually copying
files into public/. Likely a new /admin/media page — already covered
by the admin authentication gate (every /admin/* route) the moment it's
added, no extra wiring needed — backed by a plain directory under
public/uploads/ rather than a database (files are already static
assets; no need for SQLite here unless metadata like alt text/captions is
wanted later, in which case that part could ride on SQLite groundwork).
Needs basic safety handling: extension allowlist, filename sanitization,
and a max upload size, since this is a file-write surface.
Syntax highlighting on code blocks
- Type: Feature
- Status: Backlog
- Priority: Low
- Added: 2026-07-13
Color the PHP/Twig/Bash/HTML snippets across /admin/docs/* and the
blog's reference posts instead of the current flat, single-color
<pre><code> rendering. PHP has a built-in highlight_string(), but it
only understands PHP — everything else on this site's code blocks (Twig
template syntax, Bash/Docker commands in /admin/docs/styling, plain
HTML) needs something else, so use highlight.js
client-side for everything uniformly, with the ir-black theme (dark,
high-contrast — fits this project's existing dark/teal default palette).
ir-black is a dark-only theme, though, and this site now has a light
theme too (see the dark/light toggle) — decide whether code blocks stay
ir-black regardless of site theme (simplest, and arguably fine since
code blocks are visually distinct boxes already), or whether a second,
light-appropriate highlight.js theme gets swapped in via the same
data-theme attribute the color-palette toggle already sets.
Staying consistent with the no-CDN, no-build-step philosophy (Twig itself
is vendored, not npm installed) means vendoring highlight.js's built
highlight.min.js + the ir-black.min.css theme file directly under
novaconium/vendor/ next to twig/, following the same
one-subdirectory-per-vendor convention established there — rather than
pulling from a CDN. highlight.js doesn't ship a Twig grammar out of the
box; either register a custom language definition for it (Twig's syntax
is close enough to Jinja2 that an existing community Jinja grammar may
mostly work) or leave Twig snippets using the plain/no-highlight class
and accept that only PHP/Bash/HTML/XML get colored initially.
No hard dependency on the copy-to-clipboard button above, but both touch
every <pre><code> block on the site, so doing them in the same pass
avoids visiting each doc page's code samples twice. If both ship, the
copy button must keep copying the plain, unhighlighted text (via
textContent, not innerHTML — see that entry) even after this adds
<span> wrappers around tokens, so a copied snippet doesn't come out
full of stray markup.
Admin login & user management
- Type: Feature
- Status: Backlog
- Priority: Medium
- Depends on: SQLite groundwork (Done), Session handling (with flash sessions) (Done)
- Added: 2026-07-12
A single-user HTTP Basic Auth stopgap now gates /admin/*
(novaconium/src/AdminAuth.php, admin_username/admin_password_hash in
App/config.php — see /admin/docs/admin-auth), so /admin is no longer
wide open by default choice. This entry is the real, larger replacement:
multiple accounts, a user store (the SQLite groundwork above — a users
table with hashed passwords via password_hash()/password_verify(), no
external auth library), proper sessions instead of Basic Auth (rides on
session handling above), and basic user management (create/disable a
user, change password). Ship this by replacing AdminAuth::requireLogin()
with the new mechanism, not layering on top of it.
Ecommerce functionality
- Type: Feature
- Status: Backlog
- Priority: Low
- Depends on: SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
- Added: 2026-07-12
Product catalog, cart, checkout, and order storage — a products /
orders table in SQLite, a session-based cart (rides on the flash-session
work above), and a payment gateway integration for actually taking money.
Given the project's no-Composer/no-vendored-SDK philosophy, prefer calling
a payment provider's HTTP API directly (e.g. Stripe's REST API via cURL)
over vendoring a full SDK, same reasoning as vendoring only Twig's src/
rather than pulling in a package manager. Needs a decision on which
provider(s) to support first. Order/product management rides on admin
login above. Large feature — likely worth its own sub-breakdown (catalog,
cart, checkout, order admin) once it's actually picked up rather than
planning it all up front here.
Paywall functionality
- Type: Feature
- Status: Backlog
- Priority: Low
- Depends on: Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
- Added: 2026-07-12
Subscription/membership content gating, similar to OnlyFans/Patreon:
recurring billing tied to a user account, content (posts, pages, media)
marked as gated behind an active subscription, and access checks in
sidecars ($_SESSION's logged-in user + subscription status, similar to
how admin login gates /admin/*). Reuses Ecommerce's payment-gateway
plumbing for the recurring-charge side rather than integrating a payment
provider a second time — build after Ecommerce rather than in parallel.
Also needs a decision on how gated content is authored (a gated: true
flag in a sidecar's returned context vs. a separate content root) and
what happens to cached pages once caching only applies to sidecar-less
pages — gated pages will need a sidecar to check access, so they're never
statically cached, which is consistent with the existing caching model
but worth being explicit about up front.
In Progress
Nothing yet.
Done
Draft pages (admin-only preview)
- Type: Feature
- Status: Done
- Priority: Medium
- Added: 2026-07-13
- Shipped: 2026-07-14
Let a page under App/pages/ be written and previewed by an admin without
being visible to the public — a config-driven list, draft_routes in
App/config.php (Route::$dir-format entries, e.g. 'blog/upcoming-post'),
checked in novaconium/bootstrap.php right alongside the existing
/admin/* gate. Reuses AdminAuth::isAuthenticated() (a new method,
extracted out of requireLogin() so the credential check could be reused
with a different failure response) rather than a second auth mechanism:
not authenticated → the same plain 404 an unmatched route gets (not a
login prompt, so a draft's existence isn't revealed to anyone poking at
the URL), authenticated → renders normally. No separate login flow needed
— an admin authenticates once at /admin, and the browser then resends
those same Basic Auth credentials to draft URLs automatically, since
they're scoped to the whole origin/realm.
The gotcha flagged when this entry was written was designed around
correctly: sidecar-less pages get written to the static HTML cache and
served by .htaccess before PHP ever runs, so a draft without its own
sidecar needed an explicit exclusion, not just the auth gate —
Renderer::render() gained an $excludeFromCache param for this.
A second, real instance of the same bug was found while testing this
feature, pre-dating it entirely: /admin itself
(novaconium/pages/admin/index.twig) has no sidecar, so it was already
being written to the static cache — meaning once any admin visited
/admin once, the admin panel was served to every subsequent visitor,
unauthenticated, straight from public/cache/admin/, completely
bypassing AdminAuth. Fixed in the same change by passing
$excludeFromCache = true for every /admin/* route too, not just
drafts. Documented as a standing rule in AGENTS.md (next to the
mb_substr/|slice and |escape('js') notes) and at
/admin/docs/drafts//admin/docs/caching: any future mechanism that
conditionally hides page content from the public has to make the same
check, not just gate the initial request.
Session handling (with flash sessions)
- Type: Feature
- Status: Done
- Priority: High
- Added: 2026-07-12
- Shipped: 2026-07-14
A Lib\ wrapper around PHP's native session handling (session_start(),
$_SESSION, not a custom session store) — Lib\Session
(novaconium/lib/Session.php), all-static and lazy-start, same shape as
the already-shipped Lib\Csrf (which also touches the native session; the
two coexist in the same request without conflict). Ships
get()/set()/has()/remove() plus CodeIgniter-style flash data
(flash()/getFlash()) — a value set now that's readable on exactly the
next request, then gone, for post/redirect/GET flows like
App/pages/contact/index.php's hand-rolled ?sent=1 (not refactored to
use it in this change — cited in the original spec as the motivating
example, not a mandate to touch working demo code). The flash mechanism is
a single per-request swap (snapshot last request's flash bucket into an
in-memory static on first touch, then clear the stored bucket so this
request's flash() calls fill a fresh one for the request after), not a
separate expiry/sweep pass — verified end-to-end across three real,
separate HTTP requests sharing a cookie jar (not three calls in one PHP
process), confirming a flashed value appears on exactly the next request
and is gone on the one after. Foundational alongside SQLite groundwork —
admin login (next up) depends on it for logged-in state. Documented at
/admin/docs/session and in AGENTS.md next to the Lib\Csrf/Lib\Db
sections.
MySQL support
- Type: Feature
- Status: Done
- Priority: Medium
- Depends on: SQLite groundwork (Done)
- Added: 2026-07-12
- Shipped: 2026-07-14
Let a project point Lib\Db at MySQL — but went further than the original
spec ("point the Db wrapper at MySQL instead of SQLite"): a real
requirement surfaced during implementation that a single request may need
both at once (sidecars have full access to any Lib\ class, so nothing
stops one from querying this site's own SQLite data and a legacy MySQL
database in the same request). So Lib\Db was redesigned around multiple,
independently-configured, simultaneously-open named connections
(config['db_connections'], keyed by name — 'default' is the only
required one) rather than one global connection switched by a single
db_driver key. This superseded the flat db_driver/db_path/
db_migrations_dir keys the SQLite groundwork entry above originally
shipped with (which had no downstream consumers yet, so no migration path
was needed). Db::query(string $sql, array $params = [], string $connection = 'default') and Db::connection(string $name = 'default')
both default to 'default' so the common single-database case reads the
same as before; a third/first argument targets any other configured
connection. Each connection has its own lazy PDO connect ('sqlite' and
'mysql' drivers implemented), its own optional migrations_dir, and its
own independent schema_migrations table — verified for real (not just by
inspection) by running a local MariaDB instance alongside the existing
SQLite connection and executing queries against both from the same script.
db_connections needed one deliberate exception to the project's usual
shallow config-merge rule — merged one level deeper, by connection name, so
an App/config.php adding a legacy connection doesn't silently delete
the framework's default one — documented in AGENTS.md and
/admin/docs/database, including the exact "capture defaults before the
top-level array_merge() overwrites them" ordering bug hit once while
building this (caught by the end-to-end MySQL test, not by review).
SQLite groundwork
- Type: Feature
- Status: Done
- Priority: High
- Added: 2026-07-12
- Shipped: 2026-07-14
Laid the groundwork for optional SQLite storage: Lib\Db
(novaconium/lib/Db.php) is a thin, no-ORM PDO wrapper (prepared
statements only, no string-interpolation helper ever, per Lib\Input's
existing documented security stance) so features that need persistence —
404 tracking (see Won't Do; superseded by Matomo before this shipped),
admin login, blog tags, internal search, and anything future — have a
common place to store data instead of ad hoc flat files. Data lives in a
new top-level data/ directory — deliberately outside both public/
(would be web-accessible) and novaconium/ (gets wholesale-replaced by the
"Updating the framework" workflow documented at
/admin/docs/getting-started, so anything persisted there would be
destroyed by the next update) — gitignored per-file, with a tracked
.gitkeep. Designed driver-agnostic (no SQLite-only SQL in the mechanism
itself) specifically so MySQL support wouldn't need a retrofit — see that
entry below (shipped 2026-07-14) for the connection/config/migration API,
which superseded the single-connection shape (db_driver/db_path/
db_migrations_dir config keys) this entry originally shipped with.
Copy-to-clipboard button on code blocks
- Type: Feature
- Status: Done
- Priority: Low
- Added: 2026-07-13
- Shipped: 2026-07-14
Every <pre><code> block across /admin/docs/* and the blog's reference
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — added a
small button on hover that copies the block's text via the
Clipboard API
(navigator.clipboard.writeText(...)), consistent with this project's
no-build-step philosophy: vanilla JS, no dependency, same event-delegation
pattern as the dark/light theme toggle (novaconium/pages/_layout/nav.twig).
Implemented as a single site-wide partial
(novaconium/pages/_layout/code-copy.twig, included from
_layout/layout.twig's footer) that injects a button into every <pre>
containing a <code> on DOMContentLoaded, rather than touching each doc
page's markup individually. Added copy/check icons to
novaconium/pages/_layout/icons.twig and matching styles in
novaconium/sass/main.sass (hover/focus-revealed button, .copied state).
Icon markup reaches JS via two <template> elements read through
.innerHTML, not Twig's |escape('js') — that filter calls
Twig\Runtime\mb_ord() and fatals without the mbstring extension, hit
for real once on a bare-PHP install; see the standing rule added to
AGENTS.md next to the existing |slice/mb_substr gotcha.
Copies via code.textContent, not innerHTML, so HTML-entity-escaped
samples (e.g. <h1> in the SEO starter template) come out as literal
characters rather than escaped markup. Shows a "Copied!" label/checkmark
for 1.5s after a successful copy.
Won't Do
404 tracking
- Type: Feature
- Status: Won't Do
- Priority: Medium
- Added: 2026-07-12
- Reason: Matomo (see
/admin/docs/matomo) already tracks 404 hits —Renderer::renderNotFound()setsis_404 => trueand the tracking snippet tags the document title as404/URL = <path>/From = <referrer>beforetrackPageView, so 404s are already filterable in Matomo under Behaviour → Page URLs. A separate in-app SQLite-backed 404 log would just duplicate what Matomo already captures, for no real benefit.
Originally proposed as: log requests that hit the 404 page (path,
timestamp, referrer, user agent) into SQLite, similar to Joomla's 404 log,
with an /admin page to view them.