Bugs, security issues, and rough edges found and fixed during development.
File: config.php
Error: Uncaught mysqli_sql_exception: No such file or directory in system/model.php:12
Cause: The app container (workflow_app) and the database container (workflow_db) are separate containers in docker-compose.yml. db_host was set to 'localhost', which makes PHP's mysqli_connect try a Unix socket instead of TCP — there's no MySQL socket inside the app container, hence "No such file or directory."
Fix: point at the Docker Compose service name instead of localhost, and match the root password set in docker-compose.yml's MYSQL_ROOT_PASSWORD (now sourced from .env — see the .env-based secrets migration further down).
- $config['db_host'] = 'localhost';
+ $config['db_host'] = 'db';
$config['db_username'] = 'root';
- $config['db_password'] = '';
+ $config['db_password'] = '<matches MYSQL_ROOT_PASSWORD>';
$config['db_name'] = 'workflow_platform';Note: if your real deployment runs MySQL on the same host as PHP (no Docker), keep
db_host = 'localhost'as-is — this fix is specific to the Dockerized setup. If MySQL genuinely is local but socket-based connections still fail, setdb_hostto'127.0.0.1'instead to force TCP.
File: setup/index.php
Error: Uncaught Error: mysqli object is already closed in setup/index.php:153
Cause: The connection was closed once on success (line 113) inside the if branch, then closed again unconditionally after the if/else block (line 152-153). PHP 8.1+ throws on a double-close instead of silently no-op'ing like older versions.
Fix: remove the early/redundant close and let the single close at the end of the block handle it.
$mysqli->query("UPDATE base_users SET email = '{$adminEmailEsc}', password = '{$adminHash}', updated_on = NOW() WHERE user_id = 1");
- $mysqli->close();
-
// Write updated config.php(The existing if (isset($mysqli) && $mysqli instanceof mysqli) { @$mysqli->close(); } block further down still runs and closes the connection exactly once.)
File: system/form.php
Symptom: entire page renders empty (no header/sidebar), plus repeated warnings in error.log:
session_start(): Session cannot be started after headers have already been sent and
Cannot modify header information - headers already sent by (output started at system/form.php:245)
Cause: the file ended with ?> — a literal trailing space after the closing PHP tag. That space is treated as raw output and gets sent to the browser the moment form.php is required in index.php (line 28), which happens before session_start() is called (line 40). Once output has started, session_start() and any header()/redirect calls silently fail.
Fix: deleted system/form.php outright and removed its require from index.php. Its Form class (get(), addTextbox(), addSelect(), etc.) was never instantiated or called anywhere in application/ or system/ — it was dead code being loaded on every request for no reason. Removing it also removes the trailing-whitespace bug entirely.
require(ROOT_DIR . 'system/view.php');
require(ROOT_DIR . 'system/controller.php');
- require(ROOT_DIR . 'system/form.php');
require(ROOT_DIR . 'system/pip.php');And delete system/form.php itself.
If your original project actually uses the
Formhelper class somewhere, don't delete the file — instead just drop the trailing space / closing?>tag as originally planned.
- Same trailing-whitespace-after-
?>bug class could exist in any other included file — worth a repo-wide check:grep -rn '?> \+$' .or just stripping closing?>tags from all pure-PHP includes.
File: .htaccess
Symptom: GET /setup (no filename) → 403 "Cannot serve directory ... No matching DirectoryIndex found". Only /setup/index.php worked.
Cause: .htaccess sets DirectorySlash Off (to stop Apache from redirecting real-directory-named routes like /system, /application before the app's own rewrite rules can claim them). That same setting breaks normal DirectoryIndex resolution for /setup, since Apache no longer auto-appends the trailing slash needed to trigger it.
Fix: added an explicit rewrite rule so bare /setup and /setup/ are routed to setup/index.php.
RewriteRule ^(application|system|logs|docs|setup/workflow_database\.sql)(/.*)?$ index.php [L]
+ # DirectorySlash is off above, so bare /setup and /setup/ won't auto-resolve
+ # to setup/index.php on their own — send them there explicitly.
+ RewriteRule ^setup/?$ setup/index.php [L]
+
# Everything else — if not a real file or directory, route through index.php.Files: .gitignore (new), config.sample.php (new), index.php
Concern: config.php had real (if low-stakes, local-only) DB credentials baked in as the tracked "default" state — fine for this Docker Compose sandbox, but risky if the repo is made public or the credentials change per-environment.
Fix:
- Added
.gitignoreignoringconfig.php,setup/.installed,error.log, and generated content underlogs/anduploads/(with.gitkeepplaceholders so the empty directories still get committed). - Added
config.sample.phpas a committed template (placeholder passwordCHANGE_ME, with a comment explaining the Docker Composedb_hostgotcha from fix #1). index.phpnow checksfile_exists(config.php)before requiring it, and redirects to/setup/index.phpif missing, instead of fataling:
define('ROOT_DIR', realpath(dirname(__FILE__)) . '/');
define('APP_DIR', ROOT_DIR . 'application/');
+ if (!file_exists(ROOT_DIR . 'config.php')) {
+ header('Location: setup/index.php');
+ exit;
+ }
+
require(ROOT_DIR . 'config.php');Net effect: a fresh clone with no config.php at all still installs cleanly — visiting /, /setup, /setup/, or /setup/index.php all lead to the installer, and no real credentials ship in version control going forward.
File: application/helper/upload.php
Cause: the upload whitelist (line 13) allowed php, js, html, and css extensions. Uploads land directly in the web-served uploads/ directory with no execution restriction. Any authenticated user could upload a .php webshell via the Knowledge Base "Add reference" file upload and execute it by visiting its URL — full remote code execution.
Fix:
- $allowed = array('mp4', 'avi', 'mov', 'mp3', 'wav', 'jpg', 'jpeg', 'png', 'gif', 'docx', 'xlsx', 'txt', 'pdf', 'json', 'php', 'js', 'html', 'css', 'md');
+ $allowed = array('mp4', 'avi', 'mov', 'mp3', 'wav', 'jpg', 'jpeg', 'png', 'gif', 'docx', 'xlsx', 'txt', 'pdf', 'json', 'md');Added uploads/.htaccess as defense-in-depth, blocking script execution in that directory outright regardless of what the whitelist ever allows in the future (.php, .phtml, .phar, .cgi, .pl, .py, .sh, .asp, .aspx all Require all denied, plus php_flag engine off for mod_php setups).
Files: application/controllers/adrs.php, application/views/adrs/index.phtml
Cause: api_create_adr() stored $_POST['status'] with no validation against the known set (Proposed/Accepted/Superseded). The view then rendered it unescaped in both an HTML attribute (class="badge badge-<?php echo $a['status']; ?>") and as text. Any authenticated user could submit a crafted status value (e.g. breaking out of the attribute or injecting a <script>) that would execute for every user who later viewed the ADR list, since ADRs are shared/global.
Fix: whitelist status server-side against the three valid values (falling back to Proposed), and htmlspecialchars() it on render regardless, as defense in depth. Also fixed an adjacent PHP warning: $_POST['pid'] was accessed without a null-coalescing fallback, unlike sibling fields.
Files: application/controllers/knowledgehub.php, application/views/knowledge/index.phtml
Cause: api_create_ref() accepted any string as the reference url with no scheme validation, and the view rendered it directly into an <a href="...">. A reference saved with url = javascript:... would execute arbitrary JS in the browser of whoever clicked it.
Fix: reject URLs that don't start with http:// or https:// at creation time; also guard at render time (in case bad rows already exist) by falling back to # for any non-http(s) URL, and added rel="noopener" to the target="_blank" link (unrelated but same line — prevents reverse tabnabbing).
File: assets/css/app.css
Cause: there are actually two parallel design-token systems in this app: app.css's own :root (--border-light, --text-main, --text-muted, --accent-green, ...), and a second global :root defined inline in application/views/static/top.phtml (--ink, --bg, --border, --accent, ...) that's loaded on every page and is what .panel, .btn, .chip-row, .chip, and the modal styles actually depend on. Neither one defines --text, --muted, or --brand — but app.css's "Sprint 10 — Pager + empty state" block and .btn-export referenced exactly those, so those specific declarations (pagination controls, the CSV export button) were silently invalid and falling back to browser defaults.
Fix: pointed those declarations at app.css's real tokens (--border-light, --text-main, --text-muted, --accent-green). Also removed a duplicate .panel rule I'd initially added to app.css — it's dead code, since top.phtml's .panel (loaded after app.css in the DOM) already wins the cascade.
Follow-up worth doing later (not done here, out of scope): Knowledge Base, ADRs, and other pages each re-declare their own local
:root(--ink,--bg,--accent, etc.) inside page-specific<style>blocks — an exact duplicate of the tokenstop.phtmlalready provides globally. Harmless today (same values), but redundant and worth deleting once someone has time, so there's one source of truth instead of two overlapping token systems (app.css's andtop.phtml's).
Files: application/views/knowledge/index.phtml, application/views/adrs/index.phtml, application/views/releases/index.phtml
Cause: three different ad-hoc empty-state treatments — a plain <p class="muted"> on two pages, a hand-rolled centered <div> with an emoji on the third — despite the app already having a proper shared partial (application/views/static/_empty_state.phtml, backed by .empty-state/.empty-icon/.empty-title/.empty-body in app.css) that a fourth page (project/index.phtml) already used correctly.
Fix: all three now use the shared _empty_state.phtml partial with consistent icon/title/body copy, each wrapped in the same .panel card treatment (promoted from a Knowledge-Base-local style into app.css as a reusable class):
- Knowledge Base: 🔗 "No shared references yet"
- ADRs: 🗂️ "No ADRs recorded yet"
- Release Notes: 📦 "No release notes yet"
File: application/controllers/chat.php
Cause: api_get_messages always ran WHERE c.id > $last_id ORDER BY c.id ASC LIMIT 50. On first load the frontend calls this with last_id=0, so in any channel with more than 50 messages the very first page shown was the oldest 50 ever posted — backwards from what a chat UI needs (most recent messages, newest at the bottom).
Fix: split into three explicit modes based on which query param is present: no last_id/before_id → latest 50 messages (ORDER BY id DESC LIMIT 50, reversed for oldest-first rendering) plus a has_more flag; last_id → unchanged poll-for-new behavior; new before_id → fetch the next-older page for lazy-loading history on scroll-up. Also fixed api_send_message returning a boolean instead of the real inserted row id (insertArray() returns a mysqli_query result, not mysqli_insert_id() — needed getInsertId() afterward), while adding file-attachment support to the same endpoint.
12. Critical — Setup wizard silently ignored the chosen database name, and never set up an encryption key
Files: setup/workflow_database.sql, setup/index.php
Cause: three compounding bugs, all in the installer, all found by actually running it end-to-end against a disposable database rather than trusting the code:
workflow_database.sqlhardcodedCREATE DATABASE IF NOT EXISTS workflow_platform+USE workflow_platform;at the top. The PHP installer already doesCREATE DATABASE IF NOT EXISTS {chosen name}and$mysqli->select_db(...)before importing this file — but the file's ownUSEstatement, executing partway through themulti_query()batch, silently redirected the connection's active database to the literal stringworkflow_platformfor every statement after it. Anyone who typed a different database name into the setup form got an empty database under their chosen name and all the actual tables landed inworkflow_platforminstead (or nowhere, if that database didn't already exist with the right permissions).- Because of (1), the connection's active database was still
workflow_platformby the time the installer ran its post-importUPDATE base_users SET email = ..., password = ...— so on a host that already had aworkflow_platformdatabase (e.g. re-running the installer during development), that query silently targeted the existing database instead of the fresh one. - Independent of the above: that same
UPDATE base_usersquery also setupdated_on = NOW(), butbase_usershas noupdated_on(orupdated_at) column at all — it'screated_ononly. The query failed outright on every run.mysqli_report(MYSQLI_REPORT_OFF)is set for the installer's connection, so this failure was completely silent — no exception, no error message, and the code never checked$mysqli->errorafter this specific call — so the wizard reported "successfully installed" while the admin account still had whatever email/password the schema's seed data shipped with. - Separately — not a pre-existing bug, but a gap left over from when Note Keeper/Team Chat field encryption was added: the installer's config.php-writing template never included
$config['encryption_key']. A fresh install would produce aconfig.phpwith no key at all, which crashes the first note or chat message ever created (Crypto::key()throws on a missing/invalid key).
Fix:
- Removed the
CREATE DATABASE/USElines fromworkflow_database.sql— it's designed to be imported into an already-selected database, not to declare its own. Manual imports now needUSE your_db;first (documented at the top of the file). - Removed
updated_on = NOW()from the admin-accountUPDATE(the column doesn't exist), and added an explicit error check so a real failure there surfaces as an install error instead of a false "success". - Added
$encryptionKey = base64_encode(random_bytes(32));to the installer, written into the generatedconfig.phpalongside everything else — every fresh install gets its own unique key automatically, nothing to hand-configure.
Verified by actually running the installer against a disposable workflow_install_test database via curl: confirmed all 25 tables land in the chosen database (not workflow_platform), the submitted admin email/password take effect, a real config.php with a working encryption key gets written, and a note created immediately after first login round-trips through encryption correctly. Test database and container state cleaned up afterward; the site's real config.php/database were untouched throughout (verified via mysqldump-comparable row counts before and after).
File: application/views/projects/index.phtml
Cause: let curProjectId = urlParams.get('pid') || <?php echo !empty($pageData['projects']) ? $pageData['projects'][0]['id'] : 0; ?>; falls back to 0 when the user has no projects. loadProjectData() then does if (!curProjectId) return Promise.resolve(); — a correct-looking guard that avoids a pointless API call, but it also means the page's initial "Loading..." / "Loading tasks..." placeholder text is never replaced with anything, since the function that would normally do that replacement exits before touching the DOM. The page looks permanently hung rather than showing "you have no projects."
Fix: the whole chip-row + task-table toolbar now only renders when $pageData['projects'] is non-empty. When it's empty, the page renders the shared _empty_state.phtml partial instead — icon, title, and (for users who can manage projects) a "+ Create a project" CTA linking to ?new=1, which the page's existing DOMContentLoaded handler already knows how to pick up and open the New Project wizard for. Users who can't create projects (not a Tech Lead/Admin/Product Owner/PM) see a different message pointing them at asking to be added, rather than a CTA they can't use.
File: application/controllers/projects.php (api_create_project)
Cause: Model::insertArray() returns the raw mysqli_query() boolean, not the inserted row's ID — a pattern already fixed twice elsewhere this project (Notes, Chat) but missed here. $id = $pm->insertArray('proj_projects', [...]) therefore set $id to true, which PHP silently casts to 1 wherever it's later used as an int/string. The very next line, $pm->setProjectMembers($id, $members), inserted every new project's member rows against project_id = 1 regardless of which project was actually just created — a real data-corruption bug (member rows attached to the wrong project), not just a cosmetic one. The "New Project Assignment" notification link (projects?pid=1) and the JSON response's id field were wrong for the same reason.
Fix: call $pm->getInsertId() (wraps mysqli_insert_id()) right after the insert instead of trusting its return value.
- $id = $pm->insertArray('proj_projects', ['name' => $name, ...]);
+ $pm->insertArray('proj_projects', ['name' => $name, ..., 'owner_id' => $_SESSION['user']['user_id']]);
+ $id = $pm->getInsertId();Found while adding the owner_id field for the Control Panel feature (see MODULES.md → Control Panel) — worth grepping for other unchecked insertArray() call sites if this pattern shows up a fourth time.
Files: application/controllers/knowledgehub.php (api_upload, api_create_ref), application/controllers/adrs.php (api_create_adr), application/controllers/releases.php (api_create_release)
Cause: the fourth (and fifth, sixth, seventh) time — same root cause as #14: $id = $m->insertArray(...) captured the boolean mysqli_query() result, not the real row ID. Every id field in these four endpoints' JSON responses was true, not a number. None of the current frontend code happens to use that id for anything today, so it was silently wrong rather than visibly broken — caught while verifying the new CRUD permission system, testing api_create_adr end-to-end and noticing "id":true in the response.
Fix: same as #14 — call getInsertId() after the insert instead of trusting its return value, at all four call sites.
Grepped the full codebase afterward for any remaining $var = $model->insertArray(...) pattern; none left.
Files: application/views/projects/index.phtml (wizCreate()), application/controllers/projects.php (api_create_feature)
Cause: the "New Project" wizard collects three steps of data into a client-side wiz object — project details, features, and sprints-with-tasks — but wizCreate() only ever POSTed the project-level fields (name, description, color, members) to api_create_project. Everything entered in Steps 2 and 3 (wiz.features, wiz.sprints[].tasks) was built up in memory and then thrown away the moment the project was created and the page reloaded. No error, no partial save — the fields just never left the browser. Also found in the same pass: api_create_feature() never returned the new feature's id, which blocks fixing this properly since tasks need to reference a feature by its real (post-creation) id, not the wizard's temporary client-side one.
Fix: wizCreate() is now async and, after the project is created, walks features first (building a wizard-local-id → real-id map), then walks sprints, then each sprint's tasks — resolving each task's feature_id through that map before creating it. api_create_feature() now returns id via getInsertId() so this mapping is possible.
File: application/views/projects/index.phtml (toggleProjectDropdown(), toggleProjSettingsMenu())
Cause: the horizontal-scroll mechanism applied broadly across the app (top.phtml's .hscroll-row { overflow-x: auto; }, added via initHScroll() in footer.phtml to .chip-row, .mt-chip-row, .tabs, .update-tabs) triggers a CSS Overflow Module spec rule: setting only overflow-x to a non-visible value forces the browser to also treat overflow-y as clipped, even though it was never explicitly set. Both the project-switcher dropdown and the project Settings ▾ menu live inside a .chip-row, absolutely positioned to pop out below it — and position: absolute doesn't escape an ancestor's overflow clipping. The menus were being toggled to display: block correctly (confirmed via live inspection: real geometry, visibility: visible, correct z-index) but were completely clipped out of the visible/paintable/hit-testable area — document.elementsFromPoint() at the menu's own coordinates hit the task table underneath it instead.
Fix: both toggle functions now switch the menu to position: fixed with top/left computed from the trigger button's getBoundingClientRect() at open time, escaping the clipping ancestor entirely rather than touching the shared .hscroll-row CSS (used across too much of the app to risk changing its overflow behavior for one page).
Diagnosed by reproducing live in a real (non-credentialed) browser session — see readme/DEPLOYMENT.md-adjacent technique: a temporary, token-gated _debug_login.php that creates a session server-side so the actual browser can be driven and inspected, deleted immediately after use. Never touched the real admin password to do this.
File: sw.js
Cause: res.clone() was called inside the .then() callback of a separate, un-awaited caches.open(...) promise chain, rather than synchronously right after fetch() resolved. By the time that inner callback ran, the response had already been returned to the page and its body had started being consumed, so clone() failed. The actual asset still loaded fine (the response itself was returned before the failure), but the caching side-effect silently never happened, and the console was flooded with uncaught promise rejections on every page load.
Fix: clone the response synchronously, immediately after fetch() resolves, before doing anything async with it. Bumped CACHE_NAME to purge any previously (potentially inconsistent) cached assets from the broken version.
Files: application/controllers/settings.php, application/views/settings/index.phtml
Cause: base_users.status existed in the schema (same soft-enable pattern as base_roles/base_modules) and login already filtered on status = 1, but nothing ever wrote anything but 1 to it, and nothing ever read it back — the Team Members table hardcoded every row's status cell to "● Active" regardless of the actual column value, and there was no action of any kind (button, endpoint) to change it. This wasn't a broken button; the feature had never been built.
Fix: added Settings::api_toggle_user_status() (Admin-only, blocks deactivating yourself, blocks deactivating the last active Admin), wired a real Active/Inactive status cell plus a Deactivate/Reactivate action into the Team Members table, and logged both directions to the audit trail.
File: application/controllers/chat.php
Cause: Chat::api_send_message() created every chat/mention notification with a hardcoded link of BASE_PATH . 'chat' (lines 143/145), even though $channel_id was already in scope. The click handler and chat/index.phtml's ?cid= deep-link support were both already correct — the channel id was just never included in the stored link in the first place.
Fix: append ?cid=' . $channel_id to both notification URLs.
File: application/views/updates/index.phtml
Cause: renderGroupedTable() unconditionally appended an "Important Notes" header row plus an empty editable row to every report table on every render, with no way to opt out — and the JS appendTableRow() fallback (used when building a table fresh via the action rail on an empty draft) re-added the exact same rows, so even removing the PHP side alone wouldn't have fixed it.
Fix: removed the unconditional injection from both the PHP render function and the JS fallback; added an explicit "Notes" button to the action rail that inserts the same section on demand instead.
Files: application/controllers/projects.php (new api_delete_task()), plus archived-task filters added to application/models/project_model.php, application/controllers/dashboard.php, application/controllers/controlpanel.php, application/controllers/updates.php, application/controllers/search.php
Cause: No delete/archive endpoint existed for tasks at all (only projects had this pattern, via status = 'Archived'). Since this needed to be built from scratch, "archived" was added as a proj_tasks.status value reachable only through the new endpoint (deliberately excluded from api_update_task()'s normal status allowlist, so it can't be set from the ordinary status dropdown) — which meant every query that lists tasks needed a matching filter added, or a deleted task would keep showing up everywhere.
Fix: api_delete_task() sets status = 'archived' (soft delete, not a hard DELETE — a task can be referenced by subtasks, dependencies, discussions, references, and notes with no cascade defined anywhere in this schema). Allowed callers: the task's creator, its current assignee, or an Admin. Added a "Delete Task" action to the task detail page, gated the same way. Added status != 'archived' filtering to every active-task query found: project task lists, My Queue, Dashboard's counts/priority/due-soon widgets, Control Panel's overloaded-member and dependency-blocked queries, Daily/Weekly Updates' needs-attention/due-soon/created-this-week queries, the dependency-link picker on the task detail page, and global search.
File: application/views/notes/index.phtml
Cause: A note with scope_type = 'global' was always filtered to WHERE user_id = {current user} at the database level — it was never actually visible to anyone but its creator. The UI called it "Global (just for me)", which reads as self-contradictory (and worse, could make someone think a note was shared when it wasn't).
Fix: relabeled to "Private (just for me)" everywhere it appears (the scope filter chip, the create-note dropdown, the per-note badge, the page subtitle, and the icon). No behavior changed — the note was already private; only the wording was wrong.
24. New feature — unique ticket IDs for tasks and features (Phase 1 of Unique IDs / Testing-Feedback / Discussions / Ideas)
Files: application/models/project_model.php (new assignTicketCode()), application/controllers/projects.php (api_create_task, api_create_feature, applyTaskTemplate), application/views/projects/index.phtml, application/views/projects/task.phtml, application/views/static/top.phtml, application/controllers/search.php, setup/workflow_database.sql, setup/migration_2026-07-28_ticket_ids.sql
Why: tasks and features only had internal DB ids, no stable human-referenceable short code — needed as the foundation for later Testing/Feedback, Discussions, and Ideas features (converted feedback becomes a real task with a code), and generally useful on its own for referencing work in conversation.
What: proj_tasks/proj_features gained a unique ticket_code column. Codes are type-based and platform-wide, not per-project: TASK-<id> and FEATURE-<id>, where <id> is simply the row's own auto-increment id — no counter table or atomic-sequence logic needed, since the id is already globally unique. (An earlier version of this feature used a Jira-style per-project prefix — TEST-1, TEST-2 shared across a project's tasks and features — but that wasn't the intended scheme; corrected before this shipped. Later phases reuse the same assignTicketCode() helper for TEST-<id> on test programs and ISSUE-<id> on tester feedback.) Wired into api_create_task(), api_create_feature(), and template-driven bulk task creation (applyTaskTemplate()) right after insert, once the row's id is known. Existing rows stay code-less (no backfill) — codes only appear on new tasks/features created after this shipped. Displayed as a small .ticket-code badge on task board rows, feature/sprint group headers, the task detail header, and the Depends On list; global search now also matches against ticket_code.
25. New feature — external Testing/Feedback links (Phase 2 of Unique IDs / Testing-Feedback / Discussions / Ideas)
Files: application/controllers/publictest.php (new, fully public), application/views/publictest/view.phtml (new), application/controllers/projects.php (new api_create_test_program/api_get_test_program(s)/api_update_test_program_status/api_rotate_test_token/api_delete_test_program/api_update_feedback_status/api_convert_feedback_to_task), application/models/project_model.php (new Testing/Feedback methods + isPublicThrottled()), application/views/projects/index.phtml (new "🧪 Testing" panel), application/helper/upload.php (optional $maxBytes/$allowedOverride params), setup/workflow_database.sql, setup/migration_2026-07-29_testing_feedback.sql
Why: the original request — external beta testers need a link (no WorkFlow account) to submit feedback against a project's features/tasks, and that feedback should be convertible into a real tracked task.
What: An admin creates a "test program" from the new Testing panel inside a project (name, instructions, optional picklist of the project's features/tasks) and gets a public link ({BASE_PATH}publictest/view/{token}, bin2hex(random_bytes(24)) — same primitive as session ids). Publictest is the app's first fully public controller — no $_SESSION check anywhere, by design. Testers submit name/email (hard-required), a feedback type (bug/suggestion/question/praise), a message, and optional attachments (image/video/pdf, 15MB cap, tighter extension allowlist than internal uploads) with zero login. Submissions are rate-limited per IP (DB-backed, 10/5-minute window via proj_public_throttle, INSERT ... ON DUPLICATE KEY UPDATE request_count = request_count + 1). Feedback gets an ISSUE-<id> code, test programs get TEST-<id> (reusing Phase 1's assignTicketCode()); "Convert to task" creates a real task (bug→High priority, others→Medium) with a TASK-<id> code and links back via converted_task_id. Every mutation — including anonymous public submissions (user_id = null) — is audit-logged.
Bugs found and fixed during verification, before this shipped:
getTestProgramByToken()initially used a plaingetRow()with no project join, so the public page always rendered "Beta testing feedback for " with the project name missing — fixed to joinproj_projects.- The public share link was rendered as
location.origin + BASE_PATH + ..., producing a doubled origin (http://localhost:8080http://localhost:8080/...) —BASE_PATHalready contains the full origin in this app'sconfig.php. Fixed by dropping the redundantlocation.origin. - The throttle bucket key (
feedback:<48-char-token>:<ip>) could exceed theproj_public_throttle.bucketcolumn's width and threw a fatalmysqli_sql_exceptionon the very first public submission — fixed by hashing the bucket key withmd5()to a fixed 32-char length before storing/querying. rotateTestToken()/deleteTestProgram()initially calledsgConfirm(title, message, callback)— the shared confirm dialog actually takes a single options object and returns a Promise (sgConfirm({title, message, ...}).then(ok => ...)), so both confirmations silently showed the generic "Are you sure?" default text instead of the intended copy. Fixed to match the real API.
RBAC: deliberately reuses PROJECT_CREATE/PROJECT_EDIT/PROJECT_DELETE rather than minting a new module — same precedent as Task Templates, since this is a sub-feature of the Projects page, not an independently-permissioned area. The public Publictest controller calls no permission check at all.
Files: application/controllers/discussions.php (new), application/models/discussion_model.php (new), application/views/discussions/index.phtml (new), application/helper/icons.php (new message-square icon), application/views/static/top.phtml ($iconMap/$pageTitles/$pageSubs/$navConfig), application/helper/templates.php ($tierModules/$tierPermissions), setup/workflow_database.sql, setup/migration_2026-07-29b_discussions.sql
Why: the original request — a lightweight place for cross-team decisions, separate from task-level chat, with a real per-item visibility control (workspace-wide, project-scoped, or specific members/a role) rather than everything being all-or-nothing.
What: new top-level DISCUSSIONS nav module, registered via the full 5-spot pattern (module/permissions in the DB, icon, nav config, onboarding-template defaults) and granted to every role by default — broad participation is the point. Visibility (workspace/project/members) is enforced with a real ACL, not just tagging: a shared WHERE-fragment (Discussion_model::visibilityWhere()) is reused for both the list query and the single-item fetch, so a direct link to a discussion you can't see 404s instead of leaking content — verified live by fetching a hidden discussion's id directly as a second test user and confirming "Discussion not found". Own-content rule: a discussion's creator can always edit/resolve/reopen/delete it regardless of RBAC; DISCUSSIONS_EDIT/DISCUSSIONS_DELETE only gate moderating someone else's — verified live with a non-owner, non-admin test user (had DISCUSSIONS_EDIT by default RBAC grant, so could resolve someone else's discussion; correctly blocked from deleting it, since Delete stays Admin-only by default like every other module). Replying is ungated (any user who can see the thread), mirroring the existing Projects::api_post_discussion() precedent for task discussions — including its exact @mention stripos-detection loop, reused verbatim. Resolving requires a non-empty decision summary (same "no opaque status change" rule as blocked/on-hold tasks); reopening keeps the decision text visible as a "Previous decision (reopened)" banner rather than clearing it — this was fixed during verification (see below).
Bugs found and fixed during verification, before this shipped:
- The debug-login test harness itself was broken: the app names its session cookie
workflow_app(set viasession_name()inindex.php), not PHP's defaultPHPSESSID— a plainsession_start()created an unrelated, empty session, so every "logged in" test request silently bounced back to the login page. Fixed by callingsession_name('workflow_app')beforesession_start()in the test harness. (Not an app bug — a testing-infrastructure gap worth remembering for future phases.) canModerate()and severalapi_*methods each called$this->loadHelper('auth')independently, and this codebase'sloadHelper()usesrequire(notrequire_once) — so any request path that hitcanModerate()and then anAuth::audit()call in the same method loadedauth.phptwice and fataled with "Cannot declare class Auth, because the name is already in use". This silently corrupted two live test rows (the DB insert had already committed before the fatal, but the client never got a JSON response) before being caught and fixed by removing the redundant secondloadHelper('auth')call inapi_create_discussion(),api_resolve(),api_reopen(), andapi_delete_discussion().- The "reopen keeps the decision visible" behavior was implemented correctly at the data layer (
decision_textwas never cleared) but the view only rendered the decision box whenstatus === 'resolved', so reopening silently hid it from the UI despite the data being intact. Fixed to always render the box whendecision_textis present, labeled "Decision" when resolved or "Previous decision (reopened)" when open.
RBAC: new DISCUSSIONS module with DISCUSSIONS_CREATE/_EDIT/_DELETE, granted to every role (Admin gets all three; everyone else gets Create+Edit, Delete stays opt-in — same pattern as every other CRUD module).
27. New feature — Ideas board (Phase 4, final phase, of Unique IDs / Testing-Feedback / Discussions / Ideas)
Files: application/controllers/ideas.php (new), application/models/idea_model.php (new), application/views/ideas/index.phtml (new), application/helper/icons.php (new lightbulb icon), application/views/static/top.phtml ($iconMap/$pageTitles/$pageSubs/$navConfig), application/helper/templates.php ($tierModules/$tierPermissions), setup/workflow_database.sql, setup/migration_2026-07-29c_ideas.sql
Why: the original request — a voteable idea board so the team can surface and prioritize ideas together, with the best ones promotable straight into a real, tracked feature.
What: new top-level IDEAS nav module, registered via the same 5-spot pattern as Discussions and granted to every role by default. Reuses Discussions' visibility ACL exactly (workspace/project/members, same shared-WHERE-fragment pattern, same own-content-vs-RBAC moderation rule) — verified live the same way: a second test user saw only the workspace-visible idea plus the one explicitly shared with them, got a clean "not found" on a direct link to a hidden one, could vote and promote (via their default IDEAS_EDIT grant) but was correctly blocked from deleting someone else's idea (Delete stays Admin-only by default). Voting (Ideas::api_toggle_vote()) does an existence-check against proj_idea_votes first rather than catching a duplicate-key error, matching this codebase's existing check-then-mutate style; proj_ideas.vote_count is a denormalized counter kept in sync alongside it so idea lists can sort by votes without a COUNT join. "Promote to feature" (Ideas::api_promote()) creates a real proj_features row (prompting for a project first if the idea itself wasn't tagged to one), assigns it a FEATURE-<id> code via Phase 1's Project_model::assignTicketCode(), marks the idea promoted, and notifies the idea's original author if someone else did the promoting — all verified live end-to-end, including the notification only firing when promoter ≠ author.
Bug avoided (caught before it shipped, via the Phase 3 postmortem): built Ideas::canModerate() and several api_* methods with the exact same loadHelper('auth')-called-twice-per-request shape that caused a real fatal error during Phase 3's verification. Recognized the pattern immediately from FIXES.md #26 and fixed it before testing this time — every loadHelper('auth') call site in both ideas.php and (retroactively) discussions.php now goes through if (!class_exists('Auth')) { $this->loadHelper('auth'); } instead of an unconditional call, so loading the helper twice in one request is no longer possible regardless of which code path runs first.
RBAC: new IDEAS module with IDEAS_CREATE/_EDIT/_DELETE, granted to every role the same way as Discussions (Admin gets all three; everyone else gets Create+Edit). Promoting an idea is treated as an edit action, not a dedicated permission code.
File: application/views/projects/index.phtml
Cause: curProjectId was computed once as urlParams.get('pid') || <first accessible project id> — visiting /projects with no ?pid= always defaulted to whichever project happened to sort first, discarding whatever the user had actually been looking at. Same class of bug already fixed once this session for Chat's last-open-channel behavior.
Fix: added a wf-projects-last-pid localStorage key, written whenever a project successfully loads (loadProjectData()'s success branch) and read as a fallback when ?pid= is absent — validated against the user's actual accessible-project list (ACCESSIBLE_PROJECT_IDS, dumped server-side) so a saved id for an archived/removed/inaccessible project doesn't silently break the page, falling through to the first accessible project in that case. Verified live: switched to a project that was neither first nor most-recently-created in the list, reloaded /projects with no query string, confirmed it reopened the same project rather than defaulting.
Files: application/models/project_model.php (getItemTarget(), markVerified(), clearVerified(), clearVerifiedForItem(), extended createTestProgram()/getTestProgramItems()/getTestFeedback()/submitTestFeedback()), application/controllers/projects.php (new api_mark_verified/api_clear_verified, auto-clear hook in api_update_task()), application/views/projects/index.phtml (Testing-targets picklist now includes Sprint/whole-Project, "Testing Targets" section with Mark/Clear actions in the program detail view, badge rendering on task rows/group headers/project header), application/views/projects/task.phtml (badge on task detail header), application/views/static/top.phtml (.verified-badge shared CSS class), setup/workflow_database.sql, setup/migration_2026-07-29d_testing_verified_badge.sql
Why: user-requested backlog item — Testing/Feedback (Phase 2) had no way to mark a feature/task/sprint/project as confirmed-working once testing came back clean, so there was no way to signal "this was tested and verified" anywhere in the UI.
What: scoped via 3 clarifying questions before building (see conversation): (1) test targets extended beyond Feature/Task to also include Sprint and "whole project" (proj_test_program_items gained target_type enum + sprint_id); (2) the badge is set via an explicit "Mark as Verified" action reviewed against that item's feedback, not inferred automatically from feedback status — an item with zero feedback would otherwise look identical to one thoroughly tested and confirmed working; (3) the badge auto-clears (not manually re-earned) whenever new feedback lands against that item (Project_model::submitTestFeedback() → clearVerifiedForItem()) or, for tasks specifically, whenever the task's status changes (api_update_task()) — both verified live, including via a real unauthenticated curl POST to the public feedback endpoint to prove the clear isn't just a client-side artifact. verified_at/verified_by columns added to all four target tables (proj_features/proj_tasks/proj_sprints/proj_projects); Project_model::getItemTarget() resolves a test-program-item to its concrete target table/id regardless of type, so markVerified()/clearVerified() and the auto-clear hook are single generic methods rather than four near-duplicate ones. Badge renders as a small green "✓ Verified" pill (new .verified-badge CSS class, styled to match the existing .ticket-code badge) on: task board rows, feature/sprint group headers, the task detail page header, the project title, and inline in the Testing panel's "Testing Targets" list.
30. UI polish — Projects chip-row clutter, dropdown icon mismatch, sidebar length, mobile bottom-nav gap
Files: application/views/projects/index.phtml, application/helper/icons.php (new plus/download/upload/chevron-down icons), application/views/static/top.phtml
Why: four pieces of live UI feedback in one screenshot — after Testing/Discussions/Ideas were added this session, the Projects page chip-row and sidebar had grown noticeably busier, and two pre-existing rough edges (icon style mismatch, mobile padding) got flagged alongside them.
What:
- Chip-row decluttering — the standalone "+ Feature"/"+ Sprint" chips were folded into the existing "⚙ Settings ▾" dropdown (renamed "☰ Manage ▾") as "Add Feature"/"Add Sprint" menu items, cutting the always-visible chip-row from 5 buttons down to 3 for managers. Real bug caught while wiring this up: the dropdown items' inline
onclickattributes were dead code — a later script (document.getElementById('addFeatureChip').onclick = newFeatureModal) overwrites the whole handler rather than adding to it, which would have left the dropdown menu stuck open behind the new-feature modal. Fixed by moving the menu-close into that same assignment instead. - Dropdown icon style mismatch — the "Manage" dropdown's 7 items were still using colorful Unicode-entity icons (✎ 💾 📥 📋 🗑) from before this session's icon-system pass. Swapped all of them to the same monochrome
Icons::svg()stroke-icon set used everywhere else (addedplus/download/uploadtoicons.phpfor the 3 that had no existing equivalent). - Sidebar rail length — Discussions and Ideas (added this session) pushed the "Team" group to 3 items and the rail noticeably taller overall. Added click-to-collapse on each group label (
nav-section-label), state remembered per-group inlocalStorage, except a group containing the currently-active page always renders expanded regardless of its saved state — so collapsing "Docs" and then navigating to Knowledge Hub doesn't strand you looking at a hidden nav item. - Mobile bottom-nav gap —
main.content's bottom padding on mobile was72px + safe-area-inset + 20px; the extra+ 20pxwas pure excess beyond what's needed to clear the fixed bottom tab bar, showing as a visible dead-space gap between the last content item and the nav. Removed the extra 20px.
All 4 verified live (desktop + mobile viewport, including a page reload to confirm the collapsed-group state and last-project-pid persistence didn't regress against each other).
31. Bug — sidebar silently clipped (not scrolled) on shorter browser windows, plus further shortened by moving Control Panel to the account menu
File: application/views/static/top.phtml
Cause: .shell is height:100vh; overflow:hidden, and .sidebar (a flex column child of .shell) had no overflow-y/min-height of its own. A flex item's default min-height:auto means it refuses to shrink below its content size, so once the sidebar's own content (now 4 groups + ~11 modules after Discussions/Ideas were added) grew taller than the actual browser window, the excess was silently clipped by .shell's overflow:hidden instead of becoming scrollable — items at the bottom (Note Keeper, Control Panel) were simply unreachable on a shorter window, not just visually longer. Confirmed live: at a 1024×280 viewport the sidebar's scrollHeight (333px) exceeded its clientHeight (280px) with no way to reach the difference before the fix.
Fix: added min-height: 0; overflow-y: auto; to .sidebar — the standard fix for "flex child won't scroll," letting it shrink to fit .shell and scroll internally past that point. Verified live at 1024×280 that the sidebar now has its own visible scrollbar and canScroll: true (scrollHeight > clientHeight), where before the same content would have been cut off with no way to reach it.
Also (same conversation, addressing "sidebar looks too long"): moved Control Panel out of the main sidebar into the account dropdown menu, alongside Settings (same unset($allowed['CONTROLPANEL'])-before-render pattern Settings already used) — one fewer always-visible row, and one less admin-only item competing for space with the modules everyone uses. Also swapped the account dropdown's plain-Unicode Settings/Logout icons for the same Icons::svg() set used everywhere else, and added a log-out icon (was reusing the wrong icon initially — caught before shipping).
Files: application/controllers/notifications.php, application/views/notifications/index.phtml, application/controllers/chat.php, application/controllers/settings.php, application/views/settings/index.phtml, application/views/static/top.phtml
Previously the only way to see notifications was the bell dropdown, which only ever shows the last 20 unread ones — there was no way to browse history or re-find something already marked read.
Notifications page (notifications route): lists a user's last 200 notifications (read + unread, same "cap at 200" convention as the Audit Log page), with All/Unread/Read filter chips (client-side, via a data-read attribute), click-to-mark-read + navigate, and a "mark all read" action reusing the existing api_mark_read endpoint as-is. Bell dropdown gained a "View all notifications" link at the bottom.
Data Retention settings (Settings, admin-only): two new base_config keys, chat_retention_days (default 60) and notification_retention_days (default 30), added via the existing generic api_save_general key/audit-group pattern — no new backend plumbing needed beyond registering the keys.
Cleanup mechanism: this app has no cron. Following the existing opportunistic-cleanup precedent (Project_model::isPublicThrottled()), both Chat::api_get_messages() and Notifications::api_get_unread() — both frequently-polled hot paths — now roll mt_rand(1, 50) === 1 after serving their normal response, and on a hit run a DELETE ... WHERE created_at < DATE_SUB(NOW(), INTERVAL {days} DAY) using the relevant retention config (read fresh each time, with a defensive (int) cast + minimum-1 fallback to the hardcoded default in case the config row is missing or garbage). Chat cleanup additionally selects attachment_url for the rows about to be deleted first and unlink()s each corresponding file under ROOT_DIR . upload_dir (extracted via basename() on the stored URL) before running the DELETE, so old attachments don't pile up on disk after their messages are gone.
Verification: live-tested via debug session — Notifications page renders and paginates correctly, filter chips and mark-read/mark-all-read confirmed against real rows; Settings panel renders with correct 60/30 defaults, saves via api_save_general, and persists to base_config. The cleanup DELETE and attachment-unlink logic were verified directly (seeded rows older/newer than the retention window, ran the exact SQL the controller runs, confirmed only the old rows were removed; verified the attachment_url → filesystem path conversion and unlink() against a real file inside the running container) rather than waiting on the 1-in-50 random gate to fire during a live click-through. Test data and the temporary debug-login harness were removed afterward.
File: application/views/myqueue/index.phtml
Cause: mqMarkDone() called mqReload() immediately after a successful status update. Since the default status filter is "Open (not Done/Cancelled)", the just-completed task vanished from the list the instant the checkbox was clicked — no strike-through, no chance to see or undo it, and no confirmation before the status change was committed.
Fix: checking the box now shows a confirmation dialog (sgConfirm(), the app's existing shared confirm pattern) before submitting — unchecking (reopening a task) does not require confirmation, only completing one does. On success, the row is updated in place instead of triggering a reload: title gets done-txt (strike-through), the row gets a new .mq-row-done class (55% opacity, "grayed out"), and the status badge updates to "Done" — the task stays visible in its current position. It only drops out of the default Open filter on the next actual reload (filter change or page refresh), matching the requested behavior.
Verification: live-tested — checking a real assigned task's box showed the "Mark as complete?" confirm dialog; confirming struck through and grayed the row in place without removing it; a full page reload afterward correctly excluded it from the Open filter. Test task's status was reset back to todo afterward and the debug-login harness removed.
34. UX — returning from the Testing panel to the task board required reselecting the project from the dropdown
File: application/views/projects/index.phtml
Cause: toggleProjectDropdown() (bound to the project-name chip) always opened the project-switch menu, regardless of whether the Testing panel was currently showing. So the natural instinct — click the project name to "go back" — just popped a menu; the user then had to click their own already-selected project inside that menu (triggering a full page navigation) to actually get back to the task board.
Fix: toggleProjectDropdown() now checks whether #testing-panel-wrap is visible first. If it is, the click is treated as "leave Testing" — it calls the existing toggleTestingPanel() (an instant client-side swap back to the already-loaded board, no refetch/reload) and returns without opening the project menu. If Testing isn't showing, the chip behaves exactly as before (opens the project-switch menu).
Verification: live-tested — from the task board, clicked Testing (panel showed, board hid); clicked the project-name chip once; the task board reappeared immediately with no menu and no page reload. Re-tested with Testing not active to confirm the chip still opens the project-switch menu normally.
Files: setup/migration_2026-07-29e_task_type.sql, setup/workflow_database.sql, application/models/project_model.php, application/controllers/projects.php, application/controllers/myqueue.php, application/views/static/top.phtml, application/views/projects/index.phtml, application/views/projects/task.phtml, application/views/myqueue/index.phtml, application/views/controlpanel/team_queue.phtml
Previously every task looked identical regardless of whether it was regular to-do work, a reported bug, or an enhancement request — no way to tell them apart at a glance, and no way to tag one at creation time.
Schema: proj_tasks.task_type VARCHAR(20) NOT NULL DEFAULT 'task', restricted in application code to Project_model::TASK_TYPES = ['task', 'bug', 'improvement'] (both api_create_task and api_update_task fall back to 'task' on anything outside that list rather than trusting client input directly).
Tagging at creation: the "New task" modal on the project board gained a Type dropdown (defaults to Task) alongside Priority; api_create_task writes it straight into the insert. The task detail page also got an editable Type field using the same generic updateTaskField() pattern Priority already uses.
Display: a shared .type-badge / .type-task (gray) / .type-bug (red) / .type-improvement (blue) CSS set was added to top.phtml (alongside the existing .ticket-code/.verified-badge classes) so every view renders the badge identically. Wired into: the project board's task table (new Type column), the task detail header (next to the ticket code), My Queue's row badges, and Team Queue's row badges.
Filtering: a type filter was added to the project board's chip-row (client-side, filters the already-loaded task list) and to My Queue (server-side, via a new task_type param threaded through Myqueue::api_get_queue() → Project_model::getMyQueue()'s existing filter-array pattern).
Bug caught during verification: the initial api_create_task change validated $_POST['task_type'] against Project_model::TASK_TYPES before $this->loadModel('Project_model') had run, so the class didn't exist yet — every task creation fataled with "Class Project_model not found". Fixed by moving the validation after the loadModel() call. Caught immediately via a live create-task attempt (network tab showed the fatal's HTML in the response body) rather than assuming the code path from a syntax check alone.
Verification: live-tested — created a task with Type=Bug from the board modal (correct red badge + ticket code appeared); edited it to Improvement from the task detail page (badge and dropdown updated, confirmed against the DB); board's type filter correctly isolated the Bug/Improvement task; My Queue's type filter correctly isolated it too; Team Queue displayed the same badge for the same task. Test task and debug-login harness removed afterward.
Files: application/helper/icons.php, application/views/settings/index.phtml
The Actions dropdown (Edit Role / Deactivate-Reactivate / Delete User) still used raw HTML entities (✎, 🗑) left over from before the Icons::svg() set existed, inconsistent with every other dropdown in the app (Projects' "Manage" menu, the account menu, etc.).
Fix: added a new power icon to Icons::$paths (used for both Deactivate and Reactivate — only the label text and color change between the two states, not the icon). Swapped Edit Role to Icons::svg('edit', 15) and Delete User to Icons::svg('trash', 15). The status-toggle link's label was wrapped in its own <span id="userActionsToggleStatusLabel"> so the JS that flips "Deactivate"/"Reactivate" only touches that span's textContent instead of overwriting the whole link's innerHTML (which would have wiped out the icon on every open).
Verification: live-tested — opened the Actions dropdown for an inactive user; all three rows render with icons matching the Projects "Manage" dropdown's style; "Reactivate" showed the power icon; toggling state correctly flips the label without losing the icon.
Files: setup/migration_2026-07-29f_remember_me.sql, setup/workflow_database.sql, application/helper/remember_me.php (new), index.php, application/controllers/in.php
The ask was framed as "localStorage-based sessions," but localStorage isn't sent with requests the way a cookie is and is readable by any injected JS — not a viable or secure mechanism for this. Built as the standard equivalent instead: a selector+validator "remember me" cookie (RememberMe helper), always issued on every login (no opt-in checkbox, per instruction).
Mechanics: base_remember_tokens stores user_id, a public selector, and an HMAC (ENCRYPTION_KEY-keyed) of the validator — never the raw validator, so a DB read alone can't forge a valid cookie. RememberMe::issue() runs right after In::login() sets $_SESSION['user'], writing the token row and a workflow_remember cookie (httpOnly, secure when on HTTPS, SameSite=Lax, 7-day expiry) alongside the existing session-only workflow_app cookie. RememberMe::tryRestoreSession() runs once per request from index.php, right after session_start() and only when there's no $_SESSION['user'] yet — it validates the cookie, rebuilds the session exactly like a real login (same modules/permissions/must_reset shape, so every controller's existing $_SESSION['user']['user_id'] check needs no changes), regenerates the PHP session id, and slides the token's expires_at forward another 7 days so an actively-returning user is never logged out. RememberMe::clear() runs on explicit logout — deletes only that browser's token row (by its selector) and clears the cookie, so logging out on one device doesn't invalidate remember-me on others.
Deliberate simplification: the validator is not rotated on each restore (only the expiry slides) — rotating it would make two tabs loading concurrently race each other into an involuntary logout, which isn't worth it for this app's threat model. Opportunistic cleanup of expired tokens (mt_rand(1,50)===1, no cron in this app) piggybacks on the same restore path.
Verification: live-tested end-to-end with a throwaway test account (temp password, deleted afterward) — logged in via curl, confirmed both cookies were set with the remember cookie's real ~7-day expiry; replayed a request with only the remember cookie (simulating a closed-and-reopened browser) and confirmed /dashboard loaded without a login, with a REMEMBER_ME_LOGIN row correctly written to the audit log for the right user; called /in/logout and confirmed the DB token row was deleted and the cookie cleared; replayed the same (now-stale) cookie value afterward and confirmed it correctly bounced to the login page instead of restoring a session. Test user, tokens, sessions, and audit rows all cleaned up afterward.
38. Redesign — task detail page: flat single-column layout to a sectioned two-column view + real Activity feed
Files: application/views/projects/task.phtml, application/controllers/projects.php, application/models/project_model.php
The task detail page was one long flat stack of sections (a 7-field grid, then Description, then Subtasks, References, Discussion, and Depends On all stacked one after another) with no visual grouping — everything competed for the same vertical rhythm regardless of importance. Redesigned to match a reference layout (Notion/Jira-style: main content left, a fixed-width Details/metadata sidebar right), rebuilt using WorkFlow's own theme variables (not the reference's raw colors) so it's automatically light/dark-theme correct.
Layout: new .tk-layout two-column CSS grid (1fr 340px, collapsing to a single column under 900px), with every section now its own bordered .tk-box card with a small-caps header instead of a flat .tm-section-title divider. Left column: Description, Subtasks, Comments. Right sidebar: Details (the old 7-field grid, now a compact vertical list of label+control rows instead of a 3-wide grid), Reference Documents, Depends On, and a new Activity feed. The header also gained read-only status/priority pills next to the existing ticket-code/type/verified badges, for an at-a-glance summary above the fold (the actual editing still happens through the Details sidebar's dropdowns, same as before).
New: real Activity feed. This app had no per-task audit trail at all — api_update_task/api_create_task/dependency endpoints never called Auth::audit(). Added it: TASK_CREATED, TASK_STATUS_CHANGED, TASK_PRIORITY_CHANGED, TASK_ASSIGNEE_CHANGED, TASK_TYPE_CHANGED, DEPENDENCY_ADDED, DEPENDENCY_REMOVED — each only logged when the value actually changed (not on every field POST). Project_model::getTaskActivity($taskId) reads log_audit scoped to that task (same table the Settings > Audit Log page already reads), joined to the actor's name. Tasks created before this shipped have no TASK_CREATED row, so the view synthesizes one from the task's own created_at/created_by rather than leaving the feed's bottom entry missing.
Also fixed while restructuring: the subtask/reference/comment "append without reloading" JS used to find its count-label and insertion point via brittle positional lookups (document.querySelector('.tm-section-title'), document.querySelectorAll('.tm-section-title')[1]/[2]) that depended on section order in the DOM. Reordering sections for the new layout would have silently broken those counters. Replaced with explicit ids (subtasks-count, refs-count, disc-count, refs-empty) so the JS no longer depends on section position at all.
Verification: live-tested — page renders correctly in both the new two-column layout (confirmed via computed grid-template-columns at desktop width) and collapsed to one column on a mobile viewport; confirmed in both light and dark theme. Changed a task's priority via the sidebar dropdown and confirmed the TASK_PRIORITY_CHANGED audit row was written and rendered correctly in the Activity feed ("changed priority from Medium to High") with the header pill updating on reload; added a subtask and a reference document via the existing "add without reload" JS paths and confirmed both the DOM append and the (N) count updated correctly through the new ID-based selectors. Test data cleaned up and debug-login harness removed afterward.