v1.2.0 Current 2026-08-27, 06:05

Creodin's Scene Model + Draft Intelligence

Creodin's editor stopped being a wall-drawing prototype: a versioned scene document is now the thing the app edits, with rooms, a registry-driven tool system and a concept image export on top of it. Lafter's draft tools stopped being a demo. Best Bans was a hardcoded placeholder and is now real, suggestions only ever worked for registered NA players and now work for anyone in the lobby, and the sort dropdowns genuinely sort. Around that: the groundwork for company tournament spaces, org verification, a real form builder, the Tournament-V5 wiring, plus the legal pages the Riot production key application requires. Candor's Jens page became a canon: the framework went from ~30 one-line principles behind a bubble menu to 12 books and 468 numbered verses served from a single JSON source, ending three copies of the same prose. Repo-wide, changelog discipline got written down after both updates pages quietly drifted for a fortnight.

Lafter v0.2.12 drafting tool
  • Feature The Connector streams your game without an account. The heartbeat authenticated with users.session_password, the token was the account, so no signup meant no stream. src/php/devices.php adds a device token that does one job: authorise "publish a live session for this PUUID". Not an identity, not a preference store. It can't simply be dropped, with no credential anything could POST a heartbeat claiming any PUUID. SQL: db/connector_devices.sql
  • Feature Preferences did not change and that is the point. user_preferences(user_id, data) stays; linked_accounts joins the account to the PUUID, so preferences are attached to the League account transitively. lafter_seed_preferences() gives an account its defaults row at sign-in, so "signed in" and "has preferences" are the same state
  • Security Signed out, a session is code-gated by force, not by default. The client default is one edited request away from being false, and an anonymous credential publishing an open session is the one combination worth refusing outright, so the server overrides requires_code for any credential that is not signed in. The Connector also disables the checkbox and re-forces it every launch
  • Feature Claiming happens in api/auth/signin.php, which takes an optional device_token, the one moment both credentials exist in the same place, and returns device_claimed. Everything that install published anonymously becomes the account's. An earlier pass read a browser cookie for this, which was wrong: the browser never has the device token
  • Fix Three things silently assumed an account. live/index.php deduped with DISTINCT ON (la.user_id), dropping every anonymous row and picking arbitrarily between two machines; the host lookup inner-joined users, making a device-owned session invisible rather than anonymous; and live/status.php had LIMIT 1 with no ORDER BY, so with two rows per PUUID it could report a live player offline
  • Infra relay/server.js required a numeric userId to authenticate. It turned out userId was only ever a gate and a socket-registry key, the session was already keyed on the PUUID via puuidOwners. Auth now accepts an account or a device token and derives ws.hostKey. Driven with a real ws client: both host types authenticate, no-credential is refused, and the code gate still holds
  • Infra Two partial uniques on linked_accounts rather than one rule: the shipped Connector 0.2.12 sends an account token and no device and will until everyone updates, so (user_id, riot_puuid) WHERE device_id IS NULL and (device_id, riot_puuid) WHERE device_id IS NOT NULL both exist and the heartbeat picks its ON CONFLICT target from the credential
  • Feature Org-restricted forms no longer require an account. The verified work email is the identity now; a Lafter account is an optional attachment to it. lafter.org_members is keyed on (org_id, lower(work_email)) with a nullable user_id, form_responses carries respondent_email, and lafter.org_grants holds the browser-side proof, a row, not a signed cookie, so it can be revoked and listed. The cookie is scoped to .lafter.gg so a grant issued on amazon.lafter.gg/verify is readable on muster.lafter.gg, which is where the forms are. SQL: db/org_email_access.sql
  • Feature Opening an org form unverified shows a gate, "enter your @amazon.com address and we'll email you a link", instead of the bare 403 that told a player to go and find the org's site. The link lands on the org's /verify, which mints the grant and redirects back to the form via org_verification_tokens.redirect_to. That value is checked against ^https://[a-z0-9-]+\.lafter\.gg before use: it comes off a row an unauthenticated request created, so taking it at face value would make /verify an open redirect
  • Feature Three ways a verification attaches to an account, all handled by org_claim_for_user(): the grant cookie is in the browser when they sign in; they sign up with the same address as the verified email (users.email match, no cookie needed); or they verified while already signed in. Past responses under those emails are claimed too, so the account doesn't start empty. Hooked in src/php/init.php behind a session flag rather than at the three places a session is created, a fourth would be added one day and not wired up
  • Infra Single-response is now enforced on the identity that actually applies: a partial unique index on (form_id, lower(respondent_email)) for org forms, the existing account rule for everything else. respondent_email is written on every org-form response including signed-in ones, stored only for anonymous ones, the same person could answer once signed out and once signed in and neither index would see the collision
  • Fix org/index.php was fatal on any org with a published form. It calls forms_public_url(), which lived in muster/init.php, a file the org pages never require. Introduced with the clean-URL work and invisible until an org actually had a form attached. Moved to src/php/forms.php; the org page's join form also stopped requiring a sign-in first, and confirms in place rather than reloading into a card a signed-out visitor has no row for
  • Fix The grant cookie derives its scope from the request host instead of hardcoding domain=.lafter.gg; Secure. Off the production domain those two silently set no cookie at all, which presents as "verification doesn't work" with nothing in any log
  • Fix Light-theme contrast, measured rather than eyeballed: 70 failing text/background pairs to 0 (dark: 81 to 0). A Playwright pass walks every page, composites each text node against whatever is actually painted behind it, including gradient stops, and reports anything under WCAG AA. It clustered into six causes, all fixable at the token level, so the whole pass is +55/−52 lines and the per-element light overrides in style.css drop from 9 selectors to 3
  • Fix The accent chip. Seven rules spelled out --maroon background + --gold-dim border + --gold text, dark red and gold, which is right in the dark theme. In the light theme --maroon is a mid blue, so that became gold on blue: the Sign In button measured 1.91:1 and .badge 1.01:1. Now one trio, --accent / --accent-border / --accent-text, defined once per theme. Dark keeps exactly the values it had; light gets #2f6296 with white text (6.35:1). The override block that patched only the background is deleted
  • Fix --on-gold was #0c0808 in both themes, but the light theme's golds are dark, near-black on a gold button measured 2.7:1. It is white in light now, and the eleven buttons that said color: var(--bg) (a dark-theme idiom that only accidentally inverts) name --on-gold instead: 4.48 → 5.04:1
  • Fix Tokens that carry text were tuned as accents and sat just under AA. Light --gold-dim #9a7f42#816b37 (3.40 → 4.56 on --bg; it is the footer link, the admin toggle and the tag chips). Dark --gold-dim#917838, --red-x#d84949, --text-muted#8e7b7b, --side-red#dd4637 (its partner --side-blue was already 8.4:1)
  • Fix Hardcoded colours that could not flip: rehearsal.css painted a #1a0808 wash behind the whole page, so in light mode ten labels sat on a dark maroon surface at 2.6–3.5:1, it uses --topbar-tint now. Also #6bcf7f--verified, #e07a7a--red-x (four inline styles in the settings pages too), #c0392b--side-red, and .linked-remove-btn was using --border, a line colour, as its text, at 1.36:1
  • Infra --blood-light deliberately left alone. Reaching 4.5:1 on #0c0808 would mean brightening the deep brand red by ~40%, which is a brand decision rather than a contrast fix. The two rules using it as small body text (.account-signout, .dropdown-item.signout) point at --red-x, which is the token for that and now clears
  • Infra src/php/site_head.php, the same eight lines of <head> (charset, theme guard, viewport, three favicons, fonts, stylesheet) were hand-copied into 27 pages and had drifted in nine of them. The whole sign-in flow carried no favicon and no style.css at all; org/admin had lost the apple-touch icon; performance and rehearsal two favicons each. Pages now set $pageTitle / $pageDescription / $pageStyles / $pageShare and require the include. live/index.php's <title> and og:title disagreed about escaping, one value feeds both now
  • Fix Giving the login pages style.css would have rendered them blank, and the check caught it before it shipped: none of the four loaded script.js, which is what lifts html{visibility:hidden}. They, and my/settings/delete_account.php, now use page_footer.php. So do the eight further pages that were hand-assembling its three includes: index.php, my/, my/settings/, my/preferences/, lookup/, live/, download/. None of them had overlay.php, so lafterOverlay was simply unavailable on nine pages
  • Fix my/login/login.css carried a copy of style.css's palette, 39 variables across both themes, plus a duplicate Google Fonts @import. It had already gone stale: the light golds were the pre-contrast-pass values, so the first page a new account sees was the one page still shipping the #b89968 border that measured 2.3:1. Deleted; --radius and --shadow stay because nothing outside the login flow defines them
  • Infra src/css/availability-grid.css, the weekly grid was defined twice under two class names, .availGrid in take.css and .heatmap in results.css, agreeing only because both were written in one sitting. One stylesheet, one class; the respondent-only states (.selected, .isNow, .nowCol) stay in take.css
  • Infra muster/admin/delete_modal.php was a second dialog implementation, its own scrim, focus handling, Escape key and button pair, all of which lafterOverlay already does on every page. Markup and 58 lines of CSS deleted; delete-form.js calls lafterOverlay.confirm and surfaces failures through lafterOverlay.alert. Driven end to end: card removed, row gone, slug freed, notice shown
  • Infra The three remaining inline <style> blocks (rehearsal 201 lines, performance 49, org/admin 26) are stylesheets now. A page-local <style> is invisible to every tool that looks at stylesheets, and this repo has already lost a <link> to a merge that kept the other side's inline block instead
  • Infra A page's own CSS is linked docroot-absolute, not by absolute URL: muster.lafter.gg serves lafter/muster/, so https://lafter.gg/muster/take.css was pointing off-host to reach a file in its own root. org.css stays absolute, amazon.lafter.gg serves lafter/amazon/, so for it that genuinely is another host. Net across the refactor: 1,290 lines deleted, 642 added
  • Infra One path convention, and a tool that enforces it. shared/tools/audit-paths.php checks four things repo-wide: a require that resolves to nothing, an href/src that resolves to nothing (rewrite-aware), a page-relative fetch(), and, for lafter, the convention itself: no page inside src/php/, no page-relative asset, no internal link naming a .php file, and an amazon/ wrapper for every org-scoped page under org/. Exit code 1 on findings. Lafter is at zero; the rules are written up under Paths in AI_INSTRUCTIONS.md and step 6 of the change checklist now points at the tool
  • Infra Clean URLs, one .htaccess block per document root rather than a rule per page: /name serves name.php when name.php exists and nothing real is called name, and a trailing slash 301s off anything that isn't a real directory. So /terms, /admin/edit, /settings/delete_account, amazon.lafter.gg/verify. api/ opts out deliberately, it has its own .htaccess, and mod_rewrite rules don't inherit into a directory that defines its own, which is what keeps the Connector's hardcoded endpoints working
  • Fix The legal pages moved out of src/php/, which is an includes directory. They are now lafter/terms.php and lafter/privacy.php, served at lafter.gg/terms and lafter.gg/privacy, with 301s from the old URLs in .htaccess rather than stubs left behind, the Riot API application points at the old ones. This is the structural half of the fix; the require and asset paths were the symptom
  • Fix Every __DIR__-relative require in lafter (20 of them, across index.php, api/, lookup/, live/, download/) converted to the absolute /var/www/gangdev/lafter/… form the other 178 call sites already used. __DIR__ is correct until the file moves and silently wrong after; it is what made tos.php fatal. Includes requiring a sibling still use it, siblings move together
  • Fix Every page-relative asset reference in lafter is now docroot-absolute: /login/login.css, /admin/builder.js, /take.js. A relative src resolves against the URL's directory, so the same page at /form and /form/ asks for two different files, exactly the trap clean URLs open up. Same for the two window.location.href = 'index.php' redirects in the muster builder, and for builder.js/delete-form.js's fetch('api/…') calls
  • Fix The admin toolbar's Rehearsal link pointed at https://rehearsal.lafter.gg, a host with no vhost, no .htaccess, no CORS entry and no second reference anywhere in the repo. Rehearsal is served from lafter/rehearsal/, so the link is lafter.gg/rehearsal. Also org_detect_from_host()'s reserved-subdomain list was missing backstage and download: an org row claiming either name would shadow a live host the moment the wildcard vhost lands, and the lookup runs on the wildcard so it never gets to refuse
  • Feature The org verification magic link is now {org}.lafter.gg/verify. Links already sent carrying /verify.php still resolve, the rewrite only adds the extension-less form, it doesn't remove the file
  • Fix Clean form URLs silently broke every submission. muster/.htaccess now rewrites /<slug> to take.php?slug=…, and forms_public_url() is the single place that builds those links (org page ×2, admin list). But take.js read the slug from location.search, which a rewritten URL does not carry, since the query string only exists server-side, so api/submit.php answered "Missing form." for every response. The slug now comes off #takeForm's data-slug, and the fetch path is root-absolute (/api/submit.php) because the rewrite also matches a trailing slash, under which the old relative path resolved to /<slug>/api/submit.php
  • Fix src/php/tos.php and pp.php were fatal on every request since commit 6679fa1 moved them out of the lafter root. require_once __DIR__ . '/src/php/init.php' resolved to src/php/src/php/init.php, and style.css/script.js stayed relative so both 404'd from the new directory. The missing script.js mattered twice: it is what lifts style.css's html{visibility:hidden} FOUC guard, so the page would have rendered blank even with the require fixed. Every page's footer links to both files
  • Feature Theme control moved out of my/settings/ and into src/php/site_footer.php, so it is on all 19 pages that include the footer instead of one. theme_init.js now owns the binding: any element with data-theme-value is a theme control, wired by delegation off document and kept in sync (.active + aria-pressed) on lafter-theme-change. The settings page's own toggle already used that attribute, so my/settings/settings.js was deleted rather than duplicated
  • Fix Light-theme golds were too pale to do their jobs: --gold-dim at #b89968 measured ~2.3:1 against --bg, below the 3:1 borders and icons need, and --gold-light was the fill under white button text. Now #9a7f42 / #8a6a24, with --gold at #6d5119
  • Fix color-scheme: dark / light on :root, plus accent-color: var(--gold) on checkboxes and radios. The browser paints unchecked controls, <select> popups, date pickers and native scrollbars itself; without the declaration those all rendered light, which put a column of stark white radio dots down every dark-theme form. accent-color only reaches the checked state
  • Infra Availability grid transposed, days down the left, hours across, width: 100% so the 24 hour columns divide the form's width, and cells up from 26×16 to a full-width ×20px target. It was 24 rows of 7, a tall narrow column that dwarfed the rest of the form. muster/admin/results.php's heatmap is transposed to match: reading a response against the grid that produced it no longer means mentally transposing a 7×24 table
  • Infra The grid carries its own --cellBg/--cellLine rather than borrowing --overlay-soft and --border, which are tuned for text blocks, on the light theme they gave a 3.5%-black cell on a near-white page inside a #d7dee5 rule, so the whole week read as one faint grey rectangle with no squares in it
  • Fix The builder's preview eye rendered as a white tile: .iconBtn styles an <a> and a <button>, and a button with no background takes the browser's default. Explicit background: transparent + appearance: none
  • Feature Respondent form restructured: a .takeHead block carrying title, description and a question/required count, borders separating each .fieldBlock, a small superscript required marker with an .sr-only "(required)" beside it instead of a full-size asterisk trailing the label, and 12h/24h as a segmented control with the active option filled. The "Now" button is gone, the current hour's column is already tinted and its cell ringed
  • Feature Optional spectate code gate, hosts choose whether watching requires a code ("Require code to watch" in the Connector, "No code required" in Rehearsal). Open sessions take viewers straight to the live draft with no code prompt; code-gated sessions keep the relay-verified gate
  • Fix PDO binds PHP false as '', and Postgres rejects '' for a boolean column, true binds as '1' and works, so this only ever broke in the "off" direction. In Muster it meant unchecking Required on any field (or either form-level toggle) made the whole save throw, reported as "Slug may already be in use". Every affected bind is now cast to (int)
  • Fix The same bug is why the "Require code to watch" toggle never turned off. api/index.php's heartbeat bound $requiresCode directly; toggling the gate off threw, and the surrounding catch, written to survive a missing column, silently fell back to an insert that omits requires_code entirely, leaving the old value in place. Reproduced against a real Postgres, both directions confirmed working after the cast. This is the root cause behind a bug that survived several rounds of unrelated fixes across multiple sessions
  • Fix The save endpoint no longer blames the slug for every failure. A genuine unique violation (SQLSTATE 23505) says so; anything else reports the real error and writes it to error_log. The old catch-all message actively misdirected debugging
  • Feature Builder gets an eyeball across from the heading (preview the form as a respondent, opens in a new tab) and a discard trash beside the Save button. The plain "Cancel" link that blended into the save-status text is gone. take.php accepts ?preview=1 from an admin so a draft can be previewed, previously you could only preview something already published
  • Feature Every form field takes an optional description, shown under the question to respondents and above the tally in results. The help_text column already existed and already rendered on the respondent page, the builder simply never had an input for it, so it was unreachable. Matters most for True / false, where the label alone rarely conveys what answering true commits you to
  • Infra Field types consolidated 7 → 6 and renamed for the question rather than the widget: Short text, Long text, True / false, Pick one, Pick many, Weekly availability. multi_select is retired, it meant exactly what checkbox_group means but rendered a <select multiple>, which hides that you must ctrl+click to choose more than one. Existing rows canonicalise to checkbox_group on read and migrate on the next save; both store an answer as an array of chosen options, so no stored response changes meaning
  • Fix Pick one now renders radio buttons rather than a dropdown until a list passes 8 options, at which point it falls back to a select. A dropdown costs two clicks and hides every choice; below that threshold it was never the right control
  • Feature Forms can be deleted, trash-can button on each card in the Muster admin list, plus a separated "Danger zone" at the foot of the form editor (deliberately not beside Save, where a misclick would destroy real responses). Both go through one confirmation dialog that names the form, shows how many responses will be destroyed with it, and states that the URL slug is released. Deleting from the editor returns to the list and reports the freed slug there. The dialog markup and its script live in a single shared include used by both pages rather than being hand-copied, the same drift that made nav.php necessary. Deletion removes answers, responses and fields explicitly in one transaction, deepest first, rather than relying on ON DELETE CASCADE: there's no checked-in schema.sql for lafter.*, so the FK setup can't be verified from the repo, if cascades exist those statements are no-ops, and if they don't this is the difference between a clean delete and orphaned answer rows. Because the row is genuinely gone, the UNIQUE constraint on forms.slug stops holding the slug and it can be reused immediately; previously any slug ever typed was taken forever, since nothing could delete a form at all
  • Security The new delete endpoint requires a same-origin Origin header alongside the admin and ownership checks. There is no CSRF token convention anywhere in this codebase and a destructive endpoint is the wrong place to invent one unilaterally, but a cross-site form POST can't forge Origin, this uses the same pattern report_bug.php already relies on. The wider site-wide CSRF gap is now tracked in PLAN.md rather than left unsaid
  • Feature Muster (muster.lafter.gg), a generic form/survey builder, not a one-off: short text, long text, checkbox groups, multi/single-select, and a custom availability_grid field type. Availability is stored in each respondent's own timezone and converted against a fixed reference week into an admin-chosen display timezone before bucketing, so a cross-timezone heatmap actually means something. Single-response enforced by a UNIQUE (form_id, user_id) constraint, not just app logic
  • Feature Org spaces, {org}.lafter.gg scaffolding (orgs, org_members, org_verification_tokens) with employment verification by email-domain magic link. The UI states the real limitation plainly: it proves inbox access at a company domain, not current employment. Forms can be scoped to an org, enforced at submit time server-side, not just hidden in the view
  • Feature Terms of Service + Privacy Policy pages, built for the Riot Developer API production-key application. Cookies section is accurate (session cookie + lafter_theme preference, no tracking), so no consent banner is warranted
  • Feature Site-wide bug report widget, description, error/code paste, and up to 5 screenshots via file picker, drag-and-drop or Ctrl+V, delivered as real email attachments. Honeypot + timestamp + per-IP rate limit + pattern heuristics for spam; CORS scoped to *.lafter.gg origins explicitly rather than a wildcard
  • Feature Best Bans finished, it had been a hardcoded "Soon" placeholder that was never wired up. Scores enemy threats on mastery, win rate and games, then pivots to Contest Picks once bans are done, with star badges on suggestions that deny an enemy favorite
  • Feature Tournament Games column on live.lafter.gg alongside Live Games, plus a rebuilt tournaments/ page listing every org with verified member counts. Deliberately shows "nothing running" rather than placeholder data, since no tournament data model exists yet. Clash is treated as one tournament type, not a separate section
  • Feature Nav org badge, a verified member's org renders stacked under the Lafter wordmark, absolutely positioned specifically so no content length can ever change the nav bar's height
  • Feature Tournament-V5 in the RiotAPI wrapper (register provider, create tournament, generate codes, code details, code update, lobby events), built by extending the existing request() method to POST/PUT rather than a parallel handler, so every existing GET call site is untouched. Will 403 until Tournament API access is granted, which is a separate approval from the production key
  • Fix Connector code edits now reach the relay immediately (new relay_update_code re-auth over the live socket), previously an edited code only applied after a full reconnect. Live page also accepts 4-12 char codes, matching what the Connector's editor allows (was hard-locked to exactly 6)
  • Fix Relay no longer lets viewers subscribe to a session before its host connection exists, closed a gap where a viewer could join early and silently receive draft data from a code-protected session
  • Fix Suggestion/Best Bans sorting actually works for mastery-only players, the fallback data made every sort identical to Best Match (mastery aliased to score, win rate/games zero across the board). Sorts now order by real absolute mastery points and fall through the tiebreak chain honestly
  • Fix Swapping yourself onto the other roster in Rehearsal now moves everything: your new team shows on the left of the live page with pick suggestions, the old one becomes the opponent, and Best Bans retarget accordingly
  • Security General lookup endpoints in api/index.php (account, summoner, league, mastery, matches, match, spectator, clash, rotation) had no lafter_can_use_api() check at all, only suggestions was gated. Any visitor could drive live Riot API calls regardless of the site's public/private state. Found by a real test, not an audit
  • Fix Fatal TypeError in RiotAPI::request() whenever Riot actually returned 429, $body = curl_exec($ch) overwrote the request-body parameter with the raw response. Took down whole page loads mid-fetch, which in turn stopped the heartbeat that writes session state, so it presented as several unrelated bugs
  • Fix Suggestions never populated for most players: the fallback lookup only ever queried the NA platform, and the champion-select payload only carried gameName/tagLine for the local player, so nobody else could be resolved at all
  • Fix Bans never reached the relay, and ban hovers arrived with an undefined actorCellId and were silently dropped by live.js, champion select data was being read correctly and then discarded on the way to the dashboard
  • Fix requires_code read as a PostgreSQL boolean string in live/index.php, so a PHP truthy check treated "f" as true and the code gate stayed on regardless of the toggle
  • Fix Two PHP parse errors in api/auth/signin.php, Connector sign-in had been completely broken, not degraded
  • Fix Champion data now invalidates on Data Dragon version rather than a 24-hour timer, so it can't serve a patch behind; Live Games' "no sessions" message no longer vanishes after the first poll; suggestion lists reconcile the DOM instead of rebuilding it every update
  • Fix lafter_is_admin_view(), lafter_is_admin() checks role only, which never changes when "view as user" is toggled, so admin-only nav items stayed visible during a preview. Deliberately scoped to display gates only; real permission checks stay on the strict role check, since muting those during a preview would unlock actual admin actions
  • Infra Shared includes for lafter: nav.php, site_footer.php, site_fonts.php, and page_footer.php. The audit behind this found the 7 original pages disagreed with each other, my/* carried no Riot disclaimer at all. page_footer.php exists because style.css sets html { visibility: hidden; } for FOUC prevention and only script.js lifts it: a page missing it isn't unstyled, it's permanently invisible. That bug recurred four times before being bundled into one include
  • Infra amazon.lafter.gg is served from a literal amazon/ folder, not the assumed *.lafter.gg wildcard. Wrapper files require the real org/ pages and org_detect_from_host() reads the Host header regardless of which folder served the request, zero duplicated logic. Wildcard routing is still the right target once a second org exists
  • Infra Connector 0.2.12 built and shipped (api/update.json + signed installer on download.lafter.gg)
credin v0.1.0 design platform
  • Feature The scene document replaces the walls table as the thing the editor edits. One versioned JSON model per floor, schemaVersion, units, levels, a flat entities map and a draw order, carried in a new scene JSONB column (creodin/db/floor_scene.sql). Everything downstream reads it: the renderer, area totals, the export, and later the rule engine, cost estimates and schedules. The old studio/api/walls.php could only ever describe walls, so rooms, openings, dimensions and labels had nowhere to live
  • Feature Room tool. Drag to place, drag to move, eight handles to resize, Delete to remove, marquee to select several. Rooms carry a name, a room type and a shape rather than a rectangle: shape: {kind: 'rect', …} reads back through polygon(), so area, bounds, hit-testing and the export already work against a polygon and a non-rectangular room later changes one file
  • Infra Two registries, so a new tool is one file and one line. studio/editor/entities/registry.js holds each object type's draw/bounds/hitTest/handles/moveBy/fields; studio/editor/tools/registry.js holds the tools. The renderer iterates descriptors by layer, the select tool resizes and moves through them, the inspector builds its form from the declarative fields() each type returns, and the toolbar is generated from the registry, asserted in the smoke test rather than assumed
  • Infra Data flows one way. Input routes to the active tool, tools mutate the scene through scene.commit(), the renderer only reads. Every commit clones, diffs against the previous document and pushes undo, with a coalesceKey so one drag is one undo entry, not one per pointermove
  • Fix The camera owns devicePixelRatio and bakes it into both transforms. worldTransform() returns [scale·dpr, 0, 0, scale·dpr, x·dpr, y·dpr] and screenTransform() returns [dpr, 0, 0, dpr, 0, 0], so every frame sets a transform that already carries dpr. The classic version of this bug, ctx.scale(dpr, dpr) at resize plus setTransform(1,0,0,1,0,0) per frame, loses dpr from frame two and only looks slightly soft. Two smoke checks pin it: the transform after repeated frames, and after a viewport resize
  • Infra Zoom is anchored to the cursor (the world point under the pointer is invariant, asserted to 1e-9), world-space line widths are divided by zoom so a hairline stays a hairline, and all text draws in screen space so it never scales or shears. The grid drops to its 4ft module when 1ft lines fall under 7px apart
  • Feature Concept image export. Renders the scene to an offscreen 2000×1400 canvas through the same renderer, not a screenshot of the viewport, fits the content, and stamps a diagonal CONCEPT ONLY / NOT FOR CONSTRUCTION watermark plus a footer carrying the project name, the date and the same wording. The export camera runs at an effective dpr of 2, without which labels come out at 11px on a 2000px sheet
  • Fix Exterior and bearing overrides now survive a reload. They were JS-only state with no column behind them, so a refresh silently reverted anything a user had corrected. They are exteriorOverride/bearingOverride on the entity, saved with the scene, and null means "use the detected value". The detection itself is unchanged and still a convex-hull heuristic reported as such, derive.js returns a confidence alongside it, and the inspector says it reads the layout rather than assessing the structure
  • Infra studio/api/scene.php checks ownership, caps payload size and entity count, and refuses a schemaVersion it does not know rather than storing something it cannot read back. Before the migration is applied it still reads, falling back to hydrating a scene from the pre-existing creodin.walls rows, and refuses to save with a message naming the file to run. creodin.walls is deliberately not dropped; creodin/db/README.md carries the check to run before dropping it by hand
  • Infra A browser smoke test that drives the real editor (studio/test/smoke.mjs, 33 checks, Playwright against a static harness that boots the same modules with an in-memory store). It covers the two silent canvas failures specifically: that the canvas fills its container rather than falling back to the intrinsic 300×150, which looks laid out and eats every pointer event, and that dpr survives repeated frames. Plus place/move/resize/delete, undo granularity, snap, pan, zoom anchoring, a byte-for-byte save/load round trip, that the canvas actually paints pixels, and that no prohibited claim appears in the copy
  • Infra The 587-line studio/editor.js IIFE and studio/api/walls.php are deleted, not left beside their replacements. Tool behaviour was inlined in its mousedown handler and wall fields were hardcoded through the inspector, the hit test and the save path, so every new tool meant editing the core
  • Feature Admin, carried through from GangDev. creodin_ensure_user() writes role = 'admin' when a GangDev admin arrives over SSO, and promotes a row that predates the flag. Without that write the session would say admin while the row said user, and anything reading the column rather than the session would disagree with the session about the same person. Admin lifts caps rather than changing your tier, creodin_limits_for() returns the tier's own config with project, floor and export limits nulled and a lifted_by_admin marker, so the tier screens keep telling the truth about the account
  • Feature An admin bar, bottom-right, in the shape of lafter's: current project allowance, a tier switcher, an admin/user view preview, and links out. It renders nothing at all for a normal account so it is safe to include unconditionally, and it deliberately uses creodin_is_admin() rather than creodin_is_admin_view(), it has to stay reachable while previewing as a user or there is no way to switch the preview back off
  • Feature Projects can be archived or deleted. Archive flips is_active, which is what the cap counts, so it frees a slot without losing work, and restoring re-checks the cap, or archiving would have been a way around it. Delete removes walls, then floors, then the project in one transaction, deepest first: there is no checked-in schema for creodin.*, so ON DELETE CASCADE cannot be verified from the repo. If the cascades exist those statements are no-ops; if not, this is the difference between a clean delete and orphaned rows
  • Feature account/tiers.php, what each plan allows, what you are on, and how many projects you are using. Every paid tier carries available => false with a reason shown on hover, because no billing is connected; the page says plainly that nothing on it charges anyone. Tier definitions moved to src/php/tiers.php, deliberately free of any database or session dependency so the limit logic can be tested on its own
  • Infra The CHROME check in shared/tools/audit-paths.php now covers creodin as well as lafter, the hex-trunk lockup, the tigerseyeGradient defs and <nav> each have exactly one home, and a page writing its own copy is a finding. Verified by planting one and watching it fail. Both products sit at zero
  • Infra One navbar, one lockup. src/php/wordmark.php owns the hex-trunk mark and the <linearGradient id="tigerseyeGradient"> defs, nine pages were hand-copying both, because url(#id) resolves per document. src/php/nav.php is the navbar for every page that is not the editor; the editor keeps its own topbar, being tool chrome rather than a site header, but renders the same lockup. The logo is a single clamp() with the mark sized in em, so it scales with the viewport and stays pinned top-left
  • Fix Every Creodin subdomain was silently rendering in the wrong font. Bricolage Grotesque is served from creodin.com, and both account.creodin.com and studio.creodin.com reference it, the account stylesheets by absolute URL, the studio through a stylesheet loaded from the apex. Browsers enforce CORS on @font-face specifically, unlike stylesheets or images, and creodin had no .htaccess anywhere, so the font was refused and every page outside the apex fell back to the system sans without saying a word. creodin/src/fonts/.htaccess now sets the header. Same bug, same fix, and the same write-up as lafter/src/fonts/.htaccess, which had already been through it
  • Infra The font stack is declared once. theme.js reads --sans and --mono off the DOM and hands them to the canvas, so the renderer and the PNG export use literally the same string as the stylesheet. There were five hardcoded copies across renderer.js and export-image.js, and the export ones had already drifted to a different fallback chain. The smoke test now asserts the canvas font equals the CSS token and walks every text-bearing element to confirm it resolves to the brand face or the mono face
  • Fix The studio pages requested Inter from Google Fonts and never used it, a blocking request for a font applied to nothing. Removed from both
  • Feature Click the project name to rename it, in place in the topbar. Enter commits, Escape abandons, blur commits, whitespace is trimmed. It writes through studio/api/project.php, a new ownership-checked endpoint, and rolls the name back in the UI if the write fails
  • Feature A settings menu behind a wireframe gear, beside Save. It holds the mode toggles, Advanced mode (reveals the exterior and bearing overrides, off by default so the normal path stays legible), Explain decisions, Buildability guidance, and the region picker, which moved out of the inspector where it never belonged. Modes persist per browser. Advanced gating is declarative: an inspector field marked advanced: true is simply not rendered unless the mode is on, so no entity type needs to know the mode exists
  • Feature The gear prods you towards setting a region. With none set, or one Creodin cannot back, the gear carries a marker and the panel opens with a note saying code guidance stays off until a region is chosen. The old Advanced mode button, with its half-square corners, is gone along with the rest of the pre-rewrite editor
  • Feature A knowledge base, deliberately not a scrape. studio/editor/knowledge/ holds one structured entry per requirement, a plain-language what, a why that actually teaches, a machine-readable limit an algorithm can check, and a citation to code and section. Scraping the I-Codes was rejected on three grounds: they are copyrighted and whether adopted-into-law text is freely copyable is contested; a corpus cannot drive an algorithm or become a two-line tooltip; and a scrape carries no provenance, which is the whole game. Fourteen entries to start, across stairs, rooms, structure and walls, each carrying a reach of model-code, practice or principle so the UI never presents "this is how framing works" as though it were law
  • Feature Projects carry a jurisdiction (country / region / locality) and code requirements resolve through it. Five standings, and the difference between them is the point: the number matches the adopted edition; it is from a different edition and says which; the jurisdiction is unverified and the number is offered as a starting point with lookup links rather than as law; or it is an engineering principle and is labelled as one
  • Feature All 51 US jurisdictions researched, in four honesty tiers. documented (6) has the edition established with an official state source on file; reported (24) has an edition from secondary sources but nothing confirmed against the state; local-adoption (13) means the state has no statewide residential code at all and only a city or county can answer, Texas, Arizona, Illinois, Kansas, Missouri, Montana, Wyoming among them; unverified (8) could not be pinned down. 34 of 51 resolve to a real code edition, up from 2
  • Feature Guidance switches off where Creodin cannot back it. readiness() returns canGuide: false for local-adoption and unverified jurisdictions and the panel says why, rather than quietly falling back to model-code defaults and letting them read as law. Local-adoption states get a city/county field, since naming the jurisdiction is the only thing that resolves them. Guidance is live in 30 of 51
  • Fix The earlier position, that code adoption data could not be gathered, was wrong, and the reasoning behind it was sloppy. The code text is copyrighted and still is not scraped. But which code a state adopted, what edition and when it took effect is public administrative fact, and facts are not copyrightable. The copyright argument was doing work it had no business doing. Every profile now carries checkedOn so staleness is visible; the 8 unverified and 13 local-adoption entries are a live worklist, not a shrug
  • Feature "What Creodin worked out", the editor now narrates its own decisions in plain language, each tagged by what it rests on: measured, a guess, a convention, your setting. "Assumed this wall carries load" is labelled a convention rather than a measurement, because that is exactly what it is, and it explains that what really bears depends on which way the joists run, something a floor plan cannot tell you
  • Feature The jurisdiction picker can mark how much friction to expect per state, on stated factual axes, adoption scope, edition currency, amendment weight, local layer. It deliberately does not rate laws as sensible or ridiculous: California's demands trace to earthquakes and wildfire, Florida's to hurricanes, and "demanding" and "arbitrary" are different claims of which only the first is defensible. Unverified states are marked unknown, never guessed. Marks are wireframe SVG on currentColor, the smoke test asserts no codepoint above U+2100, so an emoji cannot get in
  • Fix Wall corners are solved, not overlapped. Every wall was drawn as its own rectangle, so an L-joint left a notch and the whole drawing read as computer-generated. studio/editor/joints.js indexes coincident endpoints into nodes, and each wall body is now built from its offset-line intersections: two walls meeting at a corner share their corner vertices exactly, at any angle. Sharp corners past a mitre limit of 4× half-thickness fall back to a square end instead of growing a spike
  • Feature Rounded corners are real fillets, and they are constrained like construction. Inside face, centreline and outside face are concentric arcs; both walls pull back to their tangent points; the arc is its own polygon sharing an exact edge with each wall. The user sets the inside radius because that is the number that decides buildability. Three constraints, deliberately different in kind: geometry is clamped (a radius bigger than the walls can carry is drawn at what fits, arithmetic, not judgement); matching wall thickness is required (a fillet between different thicknesses is not concentric arcs, so the field is not offered and says why); and the 2 ft framed-construction minimum is advised, never enforced, below it the plates need kerfing and standard board will not take the bend, so Creodin draws it and raises an issue. It does not refuse
  • Feature studio/editor/rules.js, the first bricks of the buildability engine, in the shape gameplan §16 specifies: id, severity, message, affected objects, suggested fix. Two corner rules today. The smoke test asserts the guardrail directly: every issue carries a severity and a fix, and no message contains a verdict word (pass, fail, approved, compliant, certified)
  • Fix Exterior walls are found from the loop, not a convex hull. With a joint graph available, closed runs of walls are traced and every wall on an outermost loop is exterior, reported as confidence: 'enclosed-loop'. The hull guess called the inner corner of an L-shaped footprint interior, which was visibly wrong on any concave plan and rendered those walls in the wrong ink. The hull stays as the fallback for open runs, still reported as a heuristic
  • Fix Dragging a corner handle now moves every wall endpoint sitting on that vertex, so a mitred corner survives editing instead of quietly coming apart. Entity types opt in with tiesToVertices
  • Feature Tools moved to a vertical rail down the left, icon over a small uppercase label, active tool marked by an inset accent bar. Stacking beats going inline here: the build order adds openings, dimensions, labels, stairs and roof tools, and a horizontal toolbar runs out of room long before that list does. Each tool now declares its own icon as inline SVG on the descriptor, so adding a tool is still one file and one import
  • Fix The rail was a <nav>, and creodin/style.css styles the nav element globally with justify-content: space-between, so the three buttons spread down the full height of the rail instead of stacking at the top. It is a div with role="toolbar" now, which is the more accurate role anyway. The smoke test asserts the buttons are stacked, adjacent, and left of the canvas
  • Fix The canvas was wearing the brand palette and could not stop. Jungle-green ground, amber grid, green room fills, tiger's-eye everything, and six modules held the colour as literals (rgba(63, 174, 92, 0.12) and friends), so none of it could respond to anything. A drafting sheet is not a brand surface. Every canvas value now comes from a --draft-* custom property read once by theme.js; the chrome uses neutral --ui-* tokens; the only brand colour left in the editor is the wordmark gradient and a single accent
  • Infra No canvas module may contain a colour literal, and the smoke test greps for #hex and rgba( across the seven that draw and fails on a match. This is precisely the thing that returns one rgba() at a time
  • Feature Two drafting surfaces, switched from the topbar or with P and remembered in localStorage: Dark (neutral charcoal, light linework) and Paper (warm off-white sheet, near-black poché walls, reads as an actual plan). The chrome stays dark neutral in both, only the sheet flips, so there is no second theme to port. The export renders on the active surface, which is what turns paper mode into a sheet you would hand someone
  • Fix Colour on the canvas now means exactly one thing, selection, and it is carried on the edge: outline plus handles, never a fill over the whole object. A selected room used to wash a large area muddy gold and a selected wall became a solid gold bar. Severity colour on warnings will be the second and last thing colour is allowed to mean
  • Infra Typography carries the drafting read instead: room names small, uppercase and letter-spaced in the sans; every number, dimensions, areas, inspector readouts, in JetBrains Mono with tabular figures. Name and type inputs stay in the sans, since a proportional field is not a numeral
  • Infra Canvas contrast is measured, not eyeballed. The smoke test composites each text and line token against the actually-painted surface through a 1×1 probe canvas and asserts 4.5:1 for text, 3:1 for linework, on both surfaces. Worst case is labelDim at 5.06:1 on paper. This is the same rule AI_INSTRUCTIONS.md sets for CSS, applied where a stylesheet auditor cannot reach
  • Infra Creodin's three document roots (creodin.com, account.creodin.com, studio.creodin.com) registered in shared/tools/audit-paths.php, which had only known lafter, main and shared. Creodin comes back at zero findings and stays checked
Candor v0.6.0 personal OS
  • Fix It was written in the second person and that is why it read biblical. 647 "you" against 134 "I" across the whole thing. Second person imperative is the voice of a commandment, and this is supposed to be one person's perspective rather than instructions issued to a reader. Every node rewritten: 112 "you" against 855 "I", and the ones left are idiomatic rather than instructional.
  • Fix Every sentence was landing on a turn of phrase. Nine of those in a row is why it read as generated. The X-not-Y construction is gone (38 down to near zero), points are short paragraphs that mostly explain and occasionally land, and the numbering went with them because numbers were reading as line breaks rather than structure. 850 one-liners became 599 paragraphs.
  • Feature Nodes can carry a tension, which is where the author does not hold his own rule. It keeps a node from reading as a flat law. Five carry one so far, including "read before you write", where the honest position is about cost rather than order.
  • Fix The read counter never recorded anything. Not once. The document root is not writable by the web user, so every write failed silently and the page did 0 + 1 and reported 1 forever, no matter how many people came. Two rounds of fixing locking and dedup did nothing because neither was the problem. It is in Postgres now (candor.jens_reads, one row per unique reader, ON CONFLICT DO NOTHING for the dedup), which is already writable, safe under concurrency and survives a deploy. Needs candor/db/jens_reads.sql applied.
  • Fix The earlier file version, for the record. views.txt was never tracked by git, so the real total was sitting on the server while the new store started from zero. It migrates on first write now. It also answered 0 when the directory was not writable, which reads as "nobody has been here", so it reports the last known count and the page leaves the dash rather than printing a zero it does not mean. A GET returns the store's state without counting, so the next failure is diagnosable from a terminal.
  • Feature Focusing the search lifts the whole board out of the way and the results fade in under the field, instead of everything jumping the moment the first result arrives. The premise and the read count fade out with it to make the room. Clearing or picking a result drops it back exactly where it was.
  • Fix Six nodes had a one-line claim that was just their opening paragraph again, so a search for one of those turned up the same sentence twice in a row. They have their own line now; the highest overlap left is 0.29.
  • Fix Two scrollbars. The page scrolled behind the panel as well as the panel scrolling. The map is a fixed board rather than a document, so on desktop the page is locked to the viewport and only the panel scrolls. The flat read opts out, because that one is a document.
  • Fix On a phone the panel covers the whole screen and slides up from the bottom, and the page behind it is locked so it cannot move under your finger.
  • Fix Search results landed on top of the search box. They were a centred overlay, so the moment they appeared there was no way back to the input or out of the results. They sit under the field now, in flow, with the middle re-centring around them and a clear button next to the count. Escape still clears.
  • Fix Anchors say what is on screen. #craft-1 meant nothing, since craft is the old id for what displays as Code; it is #code/read-before-you-write now, slugged from the display names.
GangDev v1.2.0 infra
  • Infra AI_INSTRUCTIONS.md: the two updates pages are written down now, this one technical and Backstage for users, both updated the session a change ships. Added a rule to check UI work in a real browser, and to reach the database through shared/db/psql.sh rather than typing a name.
  • Fix Both changelogs had gone stale and nobody noticed. Backstage was 12 days behind and this page had nothing on Lafter after Aug 11. Caught up, and three weeks of Lafter work moved out of the v1.1.0 Creodin slide where it never belonged.
  • Fix Past slides showed today's version numbers, since every block interpolated $VERSIONS live. GangDev's is pinned per slide.
  • Fix Expanded cards were hiding most of their own text. product-themes.css capped .changeList at max-height: 600px with overflow: hidden, so the Lafter block rendered 600px of the 3404 it needs. updates/script.js measures scrollHeight and animates to that, then drops the cap so a resize can't re-clip it.
  • Feature shared/deploy/setup.sh, a one-shot installer for the deploy cron. Reads the web user off the running process instead of assuming www-data, validates the sudoers file with visudo -c before installing it, appends the cron line without rewriting the rest, and runs a deploy to prove the grant works.
  • Feature shared/db/psql.sh and lafter/db/verify.sql. The wrapper connects with the same .env the site uses, so schema can't land in a database the app doesn't read, which has happened before (see creodin/PLAN.md). verify.sql reports what is applied and which database answered.
  • Feature Jens' page rebuilt. #overview holds one viewport, the timeline is banner tiles on a rail with per-role detail on hover, Expertise breaks each stack into chips, and Labs got its own grid instead of borrowing Expertise's. About Me only takes the wheel after a second of hover, so scrolling past the section no longer scrolls the text.
  • Fix Timeline banners were boxed in a 64px square with object-fit: cover, which sliced a 2000x666 banner down to an unreadable fragment. They fill the tile now, with a 1.05 zoom to cut the white frame baked into the teksystems and milestone art and a percentage radius to clip the black corners baked into quincy, innout and aws.
  • Fix Per-role breakdowns had been dropped in a July banner rework. Recovered from 7190178b, which took a git fetch --unshallow to reach at all: the working clone only went back 100 commits.
  • Fix roastGenerator: json/nouns.json was invalid JSON and none of the three word lists were ever loaded, since script.js carried hardcoded copies. The JSON is the source now, read in index.php. Adjectives draw without replacement and clamp to the pool.
  • Fix mmfGenerator: coordinates were normalised with a bare .sort(), which is lexicographic, so [10, 9] stayed that way and every negative sorted wrong. Retiling now picks the tile shape per region rather than a fixed 16x16, so a 1x1000 chunk strip costs 4 commands instead of 63 and every region tested lands on ceil(area / 256).
  • Feature Both generators rebuilt. MMF updates live with a diagram of the requested region against the chunk-snapped one, chunk and command counts, and a .mcfunction download. Roast got a target name, heat levels, history and a countdown you can switch off.
  • Feature Every product owns its history now, in <product>/changelog/entries.php. All 186 entries moved out of this page: candor 6, creodin 51, dcops 5, inspectre 2, lafter 81, main 41. shared/php/changelog.php holds the schema, the tag map and the renderer, so no two products can drift on what a feat is called. A version of null means whatever $VERSIONS says now. Nothing renders them yet and that is fine, the file is the record; each product's PLAN.md carries the page as a TODO.
  • Fix Jens' page had three backgrounds where it needed one. About Me, Expertise and Labs each carried a four-layer stack and the hue went cool, then plum, then back to cool, so every boundary read as a seam. They sit inside one .gradientRun now, 300vh with a single ramp, and the sections are transparent, so there is nothing to line up. Measured across the five boundaries: 143, 48, 45, 470 and 124 down to 3.6, 1.6, 2.0, 3.6 and 1.0.
  • Feature The run is one ramp in oklab, since slate to plum through sRGB dips through a grey at the midpoint and over 300vh that midpoint is a whole screen. A sticky glow drifts across it, an oklch longer hue band gives it a sheen, and grain kills the banding a ramp that long picks up on an 8-bit display. Scroll snap is proximity, so the three panels lock when you stop near an edge and let go when you don't.
  • Fix The first version of that ran at a 22ms median with a 62ms p95, from a full-height blend layer driven by a registered custom property, which is not composited, so every frame repainted 1440x2700. One viewport-tall layer, no blend, movement as a transform: back to a flat 16.7ms, the same as before the change.
  • Fix Fixing the seams inside the run made the two at its edges the loudest thing on the page, near-black meeting flat peach in one pixel. The hero dissolves into the run's first stop and My Links opens on its last, so both joins are one colour meeting itself.
  • Fix The timeline was unusable on a phone. Above the 900px breakpoint each row gets its height from .timeline being a fixed-height flex column; below it the timeline is height: auto, so height: 100% on the banner image resolved against nothing and every card collapsed to 10px, six banners stacked on top of each other with the text overlapping. Letting the image size itself fixes it through the path that already exists, since all six are 3:1. 10px rows to 136px, zero overlaps, desktop untouched.
  • Feature Sessions last 30 days and remember-me 90, everywhere. One number each in shared/php/init_base.php (GANGDEV_SESSION_DAYS, GANGDEV_REMEMBER_DAYS); five products were restating the same seven-day literal and candor was passing 0, which is a cookie that dies with the browser, so candor signed you out on every restart while everything else held a week.
  • Fix The reason nothing lasted seven days either. Three things decide when you are signed out and the shortest wins in silence: the cookie, session.gc_maxlifetime, and the product's own session row. gc_maxlifetime was the one nobody ever set, so it was PHP's default of 1440 seconds and everyone was dropped after 24 idle minutes while three files claimed a week. Raising the cookie on its own would have changed nothing.
  • Infra Session files moved to /var/lib/gangdev/sessions, created by shared/deploy/setup.sh (step 7, idempotent). Debian sweeps the default path from its own cron job, which reads gc_maxlifetime out of php.ini and ignores ini_set, so a 30-day session there gets deleted on the distro's schedule instead. It cannot live under /var/www/gangdev either: shared/ is a document root, so a session file in the tree is a live session id served over HTTP. Falls back to the default path if the directory is missing, so a server that never reruns setup keeps working.
  • Security Signing out of gangdev.co did not sign you out. signout.php was three lines and never touched the remember-me cookie, and src/php/init.php restores a session from that cookie whenever there is not one, so signing out and loading any page signed you straight back in. It now deletes the token row, clears the cookie with the attributes it was set with, drops the session_tokens row and expires the session cookie.
  • Security The remember-me cookie was the one credential on the site allowed over plaintext. It used the positional setcookie() signature, which passed false for secure and has no slot for samesite, while the session cookie beside it required TLS. Array form, secure, httponly, samesite=Lax, and the token went from 16 to 32 bytes.
  • Fix dcops sign-out cleared $_SESSION first and never called dcops_logout(), which is the thing that deletes the dcops.sessions row and needs the token out of that array to find it. Every sign-out left a live row behind; harmless at seven days, less so at thirty. candor and creodin already did it correctly.
  • Infra main/auth/exchange.php is called by product backends, never by a browser, and never reads $_SESSION. It was starting a session and setting a cookie for nobody. start_session => false.
  • Feature shared/tools/session_test.php, because none of this is visible in a diff or in a browser until a tab is left alone over lunch. Backdates real session files and asserts the collector spares 25 minutes, 6 hours, 7 days and 29 days and takes 31, checks the cookie flags, and says plainly when the save path is still the one Debian sweeps.
  • Infra Dead files out, 19 of them, 3.6 MB. IndieFlower.ttf (Indie Flower comes from Google Fonts and no @font-face pointed at the local copy), lafter/my/account.js (superseded by my.js, which carries the same polling and is what my/index.php actually loads), four fighting-game sprites the game never asks for, a favicon/old/ folder, the extension's 1.1 MB source art next to the icons resized from it, an unreferenced 1.5 MB PNG, and the Jalyn backdrop the CSS gradient replaced.
  • Fix candor/script.js and dcops/script.js were empty files that both landing pages still loaded with a <script> tag. Deleting the file alone would have turned each into a 404, so the tags went with them. Neither product has the FOUC guard that makes lafter's script.js load-bearing.
  • Infra One SQL file removed and eleven kept. dedupe_linked_accounts.sql is a one-off that api/index.php now does on every heartbeat. Everything else defines tables live code queries, which a filename grep cannot see: nothing in the repo mentions tournaments.sql, and it creates lafter.games, which 30 files read.
  • Feature shared/db/dump-schema.sh, which writes <product>/db/schema.sql from the live database. lafter/PLAN.md records three schema files being deleted once applied, "by design", and then says four separate times that the schema cannot be verified from the repo, twice in places where a delete names every child table by hand because ON DELETE CASCADE cannot be checked. A migration is the record of a change; a dump is the record of the state, and only the second one makes the first disposable. Verified by round-trip: the dump re-applied to an empty database reproduces 213 columns, 209 constraints and 52 indexes identically.
  • Fix A deploy that fails because someone generated a file on the box used to log "server has diverged", which is not what happened: there are no local commits, an untracked file is just sitting on a path an incoming commit wants to add, and git merge --ff-only refuses to overwrite it. Being told to look for a divergence sends you hunting for something that is not there. deploy.sh now tells the two apart and names the blocking files. Reproduced both cases against real repositories before and after.
  • Infra dump-schema.sh writes into the deploy tree, so it now closes by saying to commit and push from the box. That leaves the server's HEAD equal to origin and the next deploy a no-op, instead of a conflict nobody sees until they read the log.
  • Feature The live schema is checked in: lafter/db/schema.sql (24 tables), creodin (16), candor (11), gangdev (6), dcops (4), dumped off the server with shared/db/dump-schema.sh. Four notes across lafter/PLAN.md and creodin/PLAN.md saying the schema could not be verified from the repo are answered rather than restated.
  • Security Account deletion is rejected, not cascaded, in three places, and the schema dump is what found them. Across all five schemas 44 of 50 foreign keys are ON DELETE CASCADE; the exceptions are the ones that matter. lafter.user_preferences has no ON DELETE clause, which Postgres defaults to NO ACTION, and lafter_seed_preferences() gives every account that row at sign-in, so process_delete_account.php fails for every lafter user. candor.users(account_id) and dcops.users(account_id) point at gangdev.users the same way, so main/account/delete/cron_delete.php cannot finish a deletion request from anyone who ever signed into either product, and being a cron it fails into a log. creodin.export_packages(requested_by) does it once more. All three reproduced by loading the dumped schema into a throwaway Postgres and running the deletion statements verbatim. Written up in each product's PLAN.md; not fixed here, because the fix is a migration or a code change and that is a decision, not a typo.
  • Security Account deletion works now, in all four products. Every foreign key across the five schemas has an explicit ON DELETE rule, and every user-owned table has a foreign key. Four migrations, none applied yet: lafter/db/user_deletion_fks.sql, creodin/db/user_deletion_fks.sql, candor/db/user_deletion_fks.sql, main/db/sso_link_deletion.sql.
  • Fix Seven columns had no foreign key at all, which is the quieter half of the problem: lafter.devices, lafter.org_grants, and five candor planning tables. Those do not reject a delete, they let it succeed and leave the rows pointing at an id that no longer exists. An orphaned devices row matters, because a device row is what the Connector authenticates with. Candor's five had never had a key because candor.users.id is bigint and those columns are integer, so one could not be declared; the migration widens them first, which rewrites those tables.
  • Fix The SSO link is ON DELETE SET NULL, not CASCADE, and the schema argued for it: account_id is nullable on both candor.users and dcops.users, which is the state someone who signed up on the product directly is in, and both carry their own password_hash. Cascading would have destroyed a separately-created working account because a different account was deleted. Deleting a GangDev account now unlinks SSO and leaves the product account reachable by its own password.
  • Infra All four verified against the real dumped schema in a throwaway Postgres, not read and reasoned about: each migration applied, re-applied to prove it is idempotent, then every product's actual deletion statement run verbatim. Before: rejected. After: the user goes, their children go, and a project owned by someone else survives. Zero foreign keys left without a rule, zero orphans left behind.
  • Feature dump-schema.sh --commit. The dump has to run on the server, because only the server can reach the database, but the three commands after it did not have to be hand-typed every time. It stages exactly the files it wrote, commits, pushes, and if the push fails it undoes the commit rather than stranding one origin does not have, which would jam the next --ff-only deploy. It also supplies a git identity if the box has none, without writing to config.
  • Fix Two dumps of an unchanged database were never identical. Recent pg_dump wraps its output in \restrict / \unrestrict carrying a freshly random token each run, so every re-dump committed a change that was not one, and a real constraint change would have been buried between two random strings. Those two lines are stripped and nothing else is; the guard they provide is against a tampered dump running psql meta-commands during a restore, which is not what a file generated from our own database and read in diffs is exposed to. Five schemas now dump byte-identical across runs, and a genuine change shows as a one-line diff.
  • Feature The dump ends by naming any foreign key still left with no ON DELETE rule. That is the exact condition that broke account deletion in three products, it is invisible until someone tries to delete something, and it is one query, so it runs every time the schema is recorded.
  • Infra The schema snapshot maintains itself. A nightly cron on the box runs dump-schema.sh --all --commit, installed by setup.sh step 8. <product>/db/schema.sql is derived from the live database, and only the box can read that database, because Postgres listens on localhost — but that was a reason for the dump to run there, never a reason for a person to type three git commands after it. Apply a migration and the file catches up on its own.
  • Fix The nightly run brings the tree up to origin/master before committing, so a schema change on a day master moved still pushes instead of rolling back and waiting until tomorrow. Tested across six simulated nights: idle nights commit nothing, a migration lands as a one-line diff, and a night where someone else pushed fast-forwards first and leaves their work intact.
  • Fix setup.sh verifies root can push using env -i, the same bare environment cron gives it. A key that works from an interactive shell can still fail without an ssh-agent, and that failure is quiet by design — the dump rolls its commit back rather than leaving one that jams deploys — so it would simply never record anything. Better to find out during setup.
  • Infra Dead files out: a tracked cron_delete.log, three empty script.js, and companies/gangdev.png, an unreferenced 886KB AI banner. *.log is gitignored. Left the landscape backgrounds alone, since init.php loads them by glob() and a filename grep calls them unused.
v1.1.0 Past 2026-08-04, 12:40

Creodin Launch

New product: Creodin, a residential design platform. Full account system, project saving, and a from-scratch visual identity. Plus account-security work across Lafter and a continuity system rolled out repo-wide.

credin v0.0.0 design platform
  • Feature Landing page, account dashboard, and studio entry point, nav, hero, tier pricing (Concept/Buildability/Pro/Studio)
  • Feature Full account subsite (account.creodin.com): sign-up with email verification (60-min token), sign-in with 6-digit email OTP (10-min expiry), GangDev SSO callback, forgot-password (1-hour reset token, doesn't leak whether an email is registered), account deletion (password-confirmed, or session-confirmed for SSO-only accounts)
  • Feature Real project/save system, studio/new.php creates a project + its first floor, enforces per-tier active-project limits (1/5/25/unlimited) before allowing creation, studio/project.php is a real DB-backed view, not a stub
  • Feature Visual identity went through several full directions (blueprint cyan, hazard yellow/black, verdigris, dart-frog green) before landing on jungle green + a tiger's-eye gemstone gradient accent (dark brown → gold band → dark brown via background-clip: text, not a flat hex value), self-hosted Bricolage Grotesque, and a hexagonal tree-trunk wordmark mark, concentric hex rings with alternating 30° rotation, replacing a dropped trailing period
  • Feature Generative seismic-ridge SVG hero background (layered noise, no two renders identical) + OG/Twitter banner (1200×630) + favicon set built from the same mark
  • Infra creodin Postgres schema applied live, 14 tables (users, sessions, pending_users, login_otps, password_resets, projects, lots, floors, rooms, walls, openings, stairs, roof_planes, warnings, export_packages, export_credit_ledger)
  • Feature Added to the gangdev.co navbar, home page listing, and this updates page, including the hex-trunk mark itself, not just a text link
Lafter v0.2.12 drafting tool
  • Infra Reorganized icons into src/img/ (favicon, apple-touch-icon), kept a root favicon.ico as a fallback for crawlers/clients that ignore the <link> tag
  • Feature Real Open Graph + Twitter Card banner (1200×630, matches the site's actual background gradient and logo gradient) instead of a bare title-only link preview
  • Feature Forgot-password flow (password_resets table, 1-hour token) and account deletion, matching the pattern built for creodin
GangDev v1.1.0 infra
  • Infra PLAN.md added for every product (candor, dcops, inspectre, lafter, main, creodin), a persistent continuity file any future session can open to pick up state instead of re-deriving it
  • Infra Consolidated per-product brand fonts into single .font-X utility classes in style.css, was declared separately (and inconsistently) in both navbar.css and product-themes.css
  • Fix Real font-loading bug: the Google Fonts @import (Comfortaa, Indie Flower, JetBrains Mono, VT323) only ever loaded via navbar.css, which is only linked through $navbar echoed in <body>, never guaranteed before the page's own title text painted. DCOPS and CRUST were silently falling back to system fonts on the home and updates pages. Added a direct font <link> to <head> on both.
  • Fix This updates page wasn't linking product-themes.css at all, no product's changelog styling had ever actually applied here until this session
  • Fix Candor's logo was visually off-center in every fixed-size container, the source PNG had asymmetric transparent padding around the mark itself, so no CSS fix could correct it. Autocropped to the actual content bounds and recentered.
  • Feature This page: hover-to-expand product banners, compact by default, full change list reveals on hover/focus, so you can scan every product without scrolling past ones you don't care about
  • Infra Audited candor and dcops for forgot-password/account-deletion, neither has it yet; tracked as open work in their new PLAN.md files
v1.0.0 Past 2026-07-17, 13:34

Unified Identity + Multi-Product Auth

Every product had been building its own login system in isolation, duplicated user tables, duplicated password logic, no way to know if the same person was using two products at once. This release replaced that with one account model across everything: sign in once via GangDev SSO, or per-product if you'd rather, and every product recognizes the same identity. One schema pattern, one auth convention, four products retrofitted to match.

GangDev v1.0.0 platform
  • Feature Sign in with GangDev, secure auth-code exchange across all products
  • Infra Central gangdev PostgreSQL schema for identity (users, sessions, remember_tokens, password_resets, pending_users, auth_codes)
  • Infra Full project restructure, main/, per-product roots, shared/ strictly cross-product
  • Security .env blocked, credentials rotated, .htaccess hardened, directory listing disabled
  • Feature Contact form status banners (site-wide, auto-dismiss)
  • Feature Updates page rework
CRUST v0.1.0 game engine
  • Fix WebSocket connection, missing .js extensions on ES module imports
  • Infra package.json + npm scripts (gateway, game1, game2, map generation)
  • Infra Node v22 LTS upgrade, PM2 restart, map regeneration
  • Fix All server paths updated for new folder structure
Candor v0.6.0 personal OS
  • Feature Sign in with GangDev on login + signup pages
  • Infra Init refactor, init_base.php, absolute asset URLs for subdomains
  • Feature Removed confirm email from signup (streamlined)
DCOPS v0.0.0 operations
  • Feature Sign in with GangDev on login + signup pages
  • Infra Init refactor + shared base integration
Lafter v0.1.0 drafting tool
  • Feature Full account subsite (my.lafter.gg), sign-up, sign-in, and GangDev SSO
  • Feature Added to navbar, Products tab with Indie Flower branding
  • Feature Listed on gangdev.co home page Products section
inspectre v0.1.0 extension
  • Infra Migrated from f12/ to standalone inspectre/ project
v0.0.0 Foundation ??.??.??, ??:??

Foundation

Everything that existed before versioned releases.

GangDev v0.0.0 platform
  • Feature Landing page with animated backgrounds and section carousel
  • Feature Account system, signup, signin, email verification, password reset
  • Feature User profiles with icon upload + cropper
  • Feature Hamburger nav with sidebar and product links
  • Infra Apache2 + PHP + PostgreSQL on Vultr VPS
  • Infra PHPMailer integration via Namecheap Private Email
  • Infra SSL via Let's Encrypt for all subdomains
CRUST v0.0.0 game engine
  • Feature Multiplayer WebSocket engine, gateway + game server architecture
  • Feature JWT auth flow (PHP → client → gateway → game server)
  • Feature Perlin noise terrain generation (water, sand, grass, forest, mountain)
  • Feature Two game instances (game1, game2) on separate ports
Candor v0.0.0 personal OS
  • Feature Separate user system with email verification
  • Feature Do dashboard scaffold (tasks, notes, planner)
  • Feature Profile onboarding (birthday, health consent, unit system)
DCOPS v0.0.0 operations
  • Feature Separate user system with OTP verification
  • Feature Organization-based access (milestone/meta/personal)
  • Feature Trust-level + admin-rank permission system
inspectre v0.0.0 extension
  • Feature Browser extension, DOM inspector tool (Chrome)