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 The month grid was drawing day cells 161px tall against 90px wide. .monthGrid was flex: 1 with no grid-auto-rows, so the panel's height got split across the week rows with nothing declaring what a row should be. The number sat pinned at the top of the slab and the today outline drew a tall rectangle around it. Rows are a real track now (minmax(56px, 1fr)), so the grid still fills the panel and a cell is big enough to carry the day's items.
  • Fix Month header controls go 32px to 40px with the glyphs at 24px, and the Today pill matches, so the row is one height instead of three in a header with room to spare. 40px is also a usable touch target, which 30px was not.
  • Fix The version and the GangDev credit sat together in the middle of the footer on do. Every other Candor stylesheet has that footer at space-between; do carries its own copy because it loads no other stylesheet, and that copy said center. Version left, credit right, matching the rest of the product.
  • Fix Selection and today were two rules stacking on one cell, so the default view drew a blue outline around an orange fill. Selection is a ring in whatever hue the cell already is now: blue on an ordinary day, orange on today. Day cells have their border back, today is the app orange rather than the yellow it had drifted to, and the Today button wears the same orange fill as every other primary action instead of passing for a disabled outline. Measured: 5.82:1 for the today number, 7.66:1 at the darkest end of the button gradient.
  • Fix The pace card was never computed. [data-focus-pace] was looked up in the script and then never written to, and the dot was pinned at left: 50% in CSS, so the card read "On pace" every hour of every day whatever the schedule said. It now compares how much of today's scheduled windows have elapsed against how much of today's work is done, weighted by each task's estimate. Ahead, on pace or behind, with the dot placed on the margin. With no windows or no tasks it says so rather than inventing a reading. 14 cases covered by a test that runs the shipped function.
  • Fix The address bar kept naming a node after you left it. Opening one wrote the hash and nothing ever took it off, so closing the panel or going back to the middle left the URL reading #background/homeschooled on a page showing nothing but the name. Copying that link sent somebody to a node the sender was not looking at. The hash is written and cleared through one function now, and it is only there while a node is open.
  • Fix The line under his name sat left of centre. .corePremise set margin-inline: auto and then a margin shorthand three lines later, which resets left and right to zero, so a 24rem block sat flush left inside a 34rem centred core. The two-line home version nearly filled the block and hid it; every one-line region summary landed about 5rem off. One declaration now, measured at 0px on desktop and mobile.
  • Fix The line under his name was an advertisement. "Full capacity. Full honesty. Full responsibility. Zero trade-offs accepted as necessary." Three fragments sharing a first word and a fourth as the kicker, and it was the first thing anybody read. It says what the page is now: What I've worked out so far. Most of it isn't finished. core.md gets the rule and a grep that fails on three short sentences opening the same way.
  • Fix That line was also hardcoded in the map's markup while the flat read and the download rendered it from brain.json. Two copies of one fact, on the project whose own node says not to do that. The map renders it from the source now.
  • Fix The therapy node carried its caveat in tension, which the panel renders under "Where I break it". It is a caveat on the claim rather than a rule he breaks, so it moved to note.
  • Fix "You don't have to be right immediately" had the idea backwards. It framed correctness as somewhere you arrive, just later than you'd like. Jens' version: logic is the thing that's right, a person is only ever more or less lined up with it, and since nobody is perfect nobody actually gets there. So the question was never whether you're right, it's whether you're closer than you were. Retitled "You don't get to be right".
  • Fix Every node ended on a punchline. An epigram ("the only reliable auditor is the one nobody lies to") or an instruction to the reader ("find your gap"), which are the same reflex, and stacked up they are what makes prose read as generated. All 57 nodes rewritten: no closing summary lines, no advice, the mirrored-sentence trick gone ("loud doesn't mean right ... quiet doesn't mean agreement either"), and the metaphors doing an argument's job cut back to the ones that are the clearest way to say the thing. docs/core.md carries the rules.
  • Fix A blind contraction pass had broken sentences where it was an object rather than a subject: "choosing not to say it's a decision", "warmer than it's". Seven of those.
  • Fix The wire mesh drew itself on load, before anything was clicked. A resize event fires on its own -- mobile chrome settling, a rotate, devtools opening -- and the layout pass painted every wire regardless of whether the map was open, so the lines appeared over an empty stage with no pills attached to them. Layout now returns early while the map is closed.
  • Fix The caption under the map is gone and the note it came from is one line. The download link comes off both pages for now; download.php still works if you go to it.
  • Fix Eight regions came from nothing he had written. Health, Thinking, Time, Talking, Money, Building, Being wrong and Passing it on were drafted against a 2,000 word source that never touched them, and his own read was that 90 percent of the map was stuff he would not say. They are gone rather than sitting there flagged, which takes it from 20 regions and 104 nodes to 12 and 57. No confirmed node went with them and no link was left pointing at a node that no longer exists.
  • Fix The drafted nodes were written as him. 46 of the 57 are propositions he has not seen yet, and they were in the first person, which is the map claiming he holds a position nobody has asked him about. They state the idea instead now: "so I try to take the feeling off and see what's left" became "take the feeling off and whatever is left is usually obvious". The eleven confirmed nodes keep his voice, so the badge and the register agree. 88 "I" across the confirmed ones against 7 in the drafts, and those seven are quoted excuses rather than claims about him.
  • Fix Two invented admissions went with that. A tension saying he still reaches for something because the day started, and a changed saying giving tools rather than outcomes is the rule he breaks least often. Neither traced to anything he said. Those two fields are only worth carrying if they are real.
  • 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. Three carry one, 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.
  • Fix Scrolling on Jens' page was broken and it was the scroll snap. The three panels are exactly one viewport tall, so the midpoint between two snap points is half a screen, and proximity snapping pulled anything shorter than that straight back where it started. Measured from a panel edge: a wheel of 100, 200 or 300px moved the page by zero. A mouse notch is about 100. So an ordinary scroll did nothing, you had to shove, and the only scroll that worked arrived all at once, which is where the transition felt harsh. Snap removed; every notch now moves exactly what it should. It looked like the lab tiles were catching the pointer, but the wheel was dead everywhere at that position.
  • Feature Each section paints its own gradient again. One ramp across all three with a moving light on top was seamless, and seamless turned out to be the problem, you could not tell you had arrived anywhere. About Me is cool slate, Expertise is plum, Labs is ink with the ember the brand run ends on. Three rooms rather than one wash.
  • Feature Each panel is introduced by a hairline of the brand run, ember to rose to magenta, fading out at both ends, with a soft lift under it. About Me gets one too even though what is above it is a photograph, because without it the page opened by dissolving into a colour, which was the one edge that looked accidental. The hero now fades to what About Me actually opens on, measured across its top row rather than guessed from the stop it starts at.
  • Infra The shared sticky glow is gone with the shared gradient, and the page is better for it in both directions: 16.7ms median with a 16.9ms p95 and a 19.5ms worst frame, against a 22ms median and 62ms p95 for the first version of the one-gradient idea. Fewer layers, nothing sticky, nothing composited per frame.
  • Feature Panel gradients are a shared system now, src/css/panels.css, so other products can pick them up. The base ramp lives on the container and each panel adds only its own blooms, which means a boundary colour is never written twice and cannot drift. Jens' page went from nine gradient functions across three near-identical blocks to none: it declares three ramp stops and what each panel is made of.
  • Fix The line between sections is smooth. Blooms are masked to fade out before each panel's own edges, so at a boundary only the ramp is painted and there is nothing to step. The colour jump across all four boundaries went from 42, 77, 39 and 13 down to 3.6, 6.6, 7.4 and 6.4.
  • Feature Expertise carries more than the other two: a magenta bloom, a plum one, a conic sweep turning through the middle, and a sheen that takes the long way round the hue circle so it passes through the colours between rose and violet rather than fading between them. Optional layers, off unless a panel asks.
  • Infra The brand palette moved to the same file, since a shared mechanism cannot depend on tokens defined in one product's stylesheet. background: transparent on a panel is !important and that is deliberate: main's .sect.two sets a flat colour and outranks a single class, which painted over the whole ramp until it was caught by measuring.
  • Fix Nothing is drawn at a section boundary any more. A hairline measured 180 on a channel-sum, and even a glow that faded in from transparent still read as a line, because a thin feature spanning the full width is visible long before its amplitude sounds like much. Colour is the whole transition now.
  • Fix Two things were making lines that were not lines. The grain field stopped dead at the wrapper's edge, which the eye reads as an edge even though no colour changes there, and the blooms needed a 30% fade rather than 15% to clear a boundary completely. Measured by averaging each row across the full width and looking for a spike, which is what actually finds a faint full-width line; measuring a few columns misses it. All four boundaries went from 3.2 / 5.6 / 4.4 / 10.1 to 2.8 / 3.1 / 2.8 / 3.6.
  • Infra panels.css moved to main/src/css/, which is where every page already loads its stylesheets from and is therefore known to serve. It was briefly on shared.gangdev.co, a host with no other stylesheet on it and no way to check from here.
  • Feature A colour scheme per page load, site-wide. shared/php/palette.php emits one number — a base hue — and everything else is derived from it in src/css/panels.css: the panel ramp, the blooms, the dawn into My Links, the paper past it. Server-side, so there is no first paint in one scheme and a second in another, and no JavaScript involved.
  • Feature It stays legible because only hue moves. Lightness and chroma are fixed in OKLCH, where lightness is perceptual, so a panel sits the same distance from white text whatever hue came up. Swept all 360: contrast runs 10.03 to 10.45, a spread of 0.42, and no hue falls below AA or even AA-large. Measured by painting each hue and reading the pixels back, since a computed-style read just hands the oklch() back unresolved.
  • Infra The offsets were not invented, they were measured off the hand-picked palette. The warm accent sat at base+180 — ember is the exact complement of the slate the page was built on — with the richer accents at +95 and +120, and the dawn a rotation from the ramp's last hue round to that complement while lightness climbs. So a random hue reproduces relationships that were originally chosen by eye. At the default hue of 242 the ramp renders rgb(36,53,66), rgb(51,35,76), rgb(14,19,25), which is the old palette exactly.
  • Fix Every handoff follows the hue too. The hero fade and the dawn were hex, so they only lined up at the hue they were picked for: the last boundary measured 3.3 at 242 and 22.4 at hue 0. All five boundaries now stay between 1.4 and 6.7 across the whole circle.
  • Fix The dawn into My Links had a horizon ruled with a set square. A gradient boundary is exactly level and identical all the way across, which is the one thing no real horizon is, and the eye reads that as a band rather than as light. It is displaced by turbulence now: an SVG mask draws the fade and runs it through feDisplacementMap, so the line comes out soft and irregular and different along its whole length, with no blob placed by hand.
  • Fix Two things that were not obvious. Displacement samples beyond its own source, so the first version pulled transparency in as black wedges that punched holes in the corners — the source rect is drawn far outside the viewBox now. And the section kept its own straight coral-to-peach ramp underneath, so the straight edge showed through the wavy one on top; the base stops at the coral and the mask alone draws the horizon.
  • Fix Where that horizon sits turned out to be an accessibility decision rather than a taste one. Dropped low it looked best and put the heading on the coral at 4.23 against #333, under AA. At 24-44% the settled colour reaches the heading and it measures 7.70, holding at 7.63 or better across every hue.
  • Feature Schemes vary in three ways now, not one. Hue alone gave every load the same bones — accents always the same distance from the base, always the same saturation — so it read as one design in different paint. --gp-spread swings the accents from nearly analogous to past the complement; --gp-sat moves the whole thing between dusty and vivid. Neither touches lightness, which is the only channel that decides whether text can be read: 1620 combinations of hue, spread and saturation tested, worst contrast 9.98, none below AA.
  • Feature The panels are on every page of gangdev.co that has them — home, services, apps, contact, gaming, mods and both minecraft pages, and Isaac's. Light sections are deliberately left alone: they carry dark text and would be unreadable on the ramp.
  • Fix Links inside a panel take the scheme's accent. A panel is darker than the flat colours those sections used to carry, so a link that was legible on mid-teal could fail on the ramp — an unstyled anchor on the gaming page went from 7.18 to 1.01. Scoped to headings, paragraphs and list items, so a card that is itself a link keeps its own colours. Contact also had a hardcoded style="color: purple" that measured 2.38 even on the old background; removed.
  • Infra The first pass at wrapping the sections matched markup inside HTML comments, which put a wrapper's opening tag inside one on the gaming page and left the real sections with no background at all — white, not dark. Caught by screenshotting the page rather than by the contrast numbers, which were reporting nonsense for the same reason. The transform masks comments now.
  • 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.
  • Feature Every hard edge on the site is gone, not just the ones between panels. A run of panels was seamless inside itself and then stopped dead on whatever came next: a photograph on near-black, near-black on paper, paper on the copyright bar. The same displaced-mask trick that draws the dawn is a shared formula now, so a band at one end of a section is painted in its neighbour's colour through a turbulence mask and the handoff wanders instead of ruling a line.
  • Infra No page declares any of it. The rules work the boundaries out from the markup that is already there — a section above a run, a section below one, the bar at the bottom — so adding a run to a page softens both its ends with nothing else written. .gdSoft is the opt-in for the two joins the markup cannot infer.
  • Fix Light goes into dark, never the other way. The first version bled the panel's dark down into the light section below it, and on the home page the word Contact landed on near-black — a contrast bug that only appears on pages whose heading sits near the top. The light section's colour rises into the bottom of the last panel instead, which is dead space everywhere, and the light section stays light all the way up. The band sits under the panel's cards rather than over them, so only the ground behind them lightens.
  • Fix Getting an edge to look bubbly rather than blurred is a ratio, not a setting. A long two-stop fade leaves no front for the displacement to move and reads as a clean vertical ramp with the turbulence doing nothing visible; a short one wanders but changes as abruptly as the straight line it replaced. Three stops with the middle one low: the colour comes up slowly across most of the band and commits over the last third, and the displacement moves that front by more than a quarter of the band's height along its length.
  • Fix Mask layers do not pair off with background layers — they composite together and apply to the element as a whole. Two full-size colour layers left the first covering the second, so a section bleeding to two different colours painted both bands the same one, which put Isaac's peach next to the dark bar. Each colour is confined by background-size and background-position instead.
  • Fix The copyright bar hands off inside itself rather than above it. The first version reached up into the section above and the contact form's send button sat squarely in the band. The bar is taller than it was and its line sits at the bottom, clear of where the colour is still moving.
  • Feature The four section grounds come off the generated scheme now instead of four hexes that only agreed with each other in the blue the site used to be. One and four stay light with dark copy; two and three sit on the panel ramp's own stops, so a section outside a run still lands on a colour the run would have reached. The bar is the ramp's last stop, which is why a run above it needs no transition at all.
  • Fix The gaming page had a run of panels nested inside another run, so the second started its own ramp and put a guaranteed step in the middle of the page. It also had a <b> that was never closed, which hoisted the rest of the page inside it.
  • Infra Jens' page keeps its dawn and gives up the rest: the hero fade and the peach-to-paper ramp were the formula's job all along and are gone from its stylesheet. Measured at every boundary on all ten pages, the largest step anywhere is 4.6 on a channel-sum, against 559 for the version that painted the wrong colour and 95 for the one before that.
  • Fix The copyright bar has a soft edge now, and it is part of the scheme. It was the ramp's last stop, which meant that on every page ending in a run of panels it was exactly the colour above it -- correctly seamless, and not what a soft edge looks like. It sits on --gp-floor instead, two thirds of a stop below where the ramp ends, so there is something for the dissolve to do on every page.
  • Fix Mask layers do not pair off with background layers, part two. The bar's colour came down correctly for two thirds of the band, went back up almost to where it started, and fell off a cliff at the exact pixel the band's box ended. The unused bottom mask still had a real height, and because the two masks composite and then apply to the whole element, it was un-masking the region where only the top colour is painted. Depths are zero unless a colour is set. A full-width row scan of the last three hundred pixels went from 667 to 6.3.
  • Fix A section following a run had the machinery on it from an earlier design and outranked .gdFooter by one class, so the bar's own band depth never applied. That join is the run's tail now, so the selector is gone rather than fixed.
  • Feature The chrome follows the scheme too. Buttons, text fields and their edges, the navbar drawer and the buttons in it were darkgray, gray, dimgray, white and #333 -- a fifth palette sitting on top of the one each load generates, on every page of the site. All of it reads a --gp-* token now.
  • Fix Two of those were already failing before they rotated. White on gray in the navbar drawer measured 3.95:1, under AA. A field's edge is the only thing telling you where the field is, so it is a UI element and wants 3:1 against what surrounds it; at its first value the worst hue measured 1.83 against the paper. Both are swept over 3,240 combinations of hue, spread and saturation now: the drawer button holds 3.04 against its ground with 5.06 of ink on it, and the field edge 3.35.
  • Feature The rule under the home page's heading is the same mark as the underline on jens' page, from the same token, rather than flat white -- the one colour on that panel saying nothing about the scheme it sits in.
  • Infra panels.css opens with where to change the scheme: two files, palette.php for the three numbers and the :root block for what they become, and a rule that a stylesheet on this site does not write a colour. Two deliberate exceptions, both signals rather than decoration -- the amber warning bar and the red error banner do not rotate, because a scheme that turns an error mint on some loads is telling you the wrong thing.
  • Infra The page harness had never copied main/style.css, so the home page's own rules were checked against a build that did not load them. Its heading rule rendered as a two-pixel dot for a whole pass and the screenshots agreed with each other about it.
  • Feature Every entry in the navbar has a banner. The products always did; the rest were flat colour, and when the drawer and the buttons on it ended up reading the same token they vanished into it entirely. All of them sit on --gp-banner now, nested entries taking it the other way up, and the product artwork still wins where it is declared.
  • Feature The edge structure is generated per load too. Where a soft edge wanders and how many bends fit across the page are the seed and frequency of a turbulence field, and there was no reason for those to be fixed when the colour is not. palette.php builds both masks -- they live inside a data URI, which a custom property cannot be interpolated into, so it has to happen server-side. The geometry does not move: the three stops and the displacement scale are what make an edge sit on its boundary, and only the field underneath them changes.
  • Feature A transition straddles its boundary instead of sitting on one side of it. The hero's reached a fifth of a screen up into the photograph while the panel below did nothing, which reads as the panel having eaten the photo. Half each side now: the photo fades down to a meeting colour and the panel fades up from it.
  • Fix The first version of that had each side reach full strength of its own colour at the edge, which is not a blend meeting in the middle, it is two blends butted together. Seams went from 3 to 275. Both halves name one token and arrive at the identical colour, and it is weighted toward the ramp rather than the true midpoint -- a mid grey laid across a photograph reads as a bar, not as haze. Back to 3.53.
  • Infra Coming out of a run is deliberately not balanced. The light section below can start with a heading an inch from its top, and a dark half reaching into that is what put the word "Contact" on near-black the first time. There the light rises into the last panel and the light section stays light all the way up.
  • Fix The two pinned buttons follow what is behind them. They cross every section on the way down and one fixed colour can only be right on some of them. Sections declare which way round they are and navbar.js reads that off whatever is under each button. A blend mode would have been less code and wrong: difference blending is exactly invisible against a mid grey, which is what every soft edge on this site passes through on its way across.
  • Fix The hamburger had been doing this since before the panels and had quietly stopped working. It read backgroundColor off what was underneath and walked up until it found one, but a panel sets background: transparent !important and the ramp is a gradient on the container, so it found nothing all the way to body and fell back to assuming white -- the opposite of what is there. It is on the same declared tone now, with the luminance walk kept as the fallback.
  • Infra A defaults block should never win an argument. The soft-edge defaults were a plain selector list, so an element matching two of its branches picked up the more specific one, which outranked .gdFooter's own band depth by a class and silently ignored it. Wrapped in :where(), which contributes no specificity at all.
  • Fix The torn edge. The upward mask started climbing at 8 of its 100, but feDisplacementMap samples up to 14 either side of where it reads -- so at the very top of its own band it was already a few percent on, and a few percent of a colour spanning the full width against a photograph is a visible rip. Both masks are flat for the first and last 18 now, well inside the displacement's reach.
  • Fix And it was three things in a row rather than one transition. Both masks are fully on for their last fifth, which lays about fifty pixels of unvarying colour across the boundary; at a true midpoint of near-black and paper that is a grey stripe over the photograph however clean its edges are. The meeting colour is close to the ramp instead, so those fifty pixels are nearly the ramp and the whole thing reads as one continuous darkening.
  • Fix Flat again. The gradient-and-hairline treatment on the navbar entries and the two pinned buttons read as machined parts. Solid colour, no outlines: both levels of navbar entry on one ground two steps above the drawer, and the drawer's two levels never share a screen so the step between them bought nothing.
  • Fix The W and S buttons had their contrast backwards -- a pale plate over the paper section, which is the one place a pale plate cannot be seen. Light over dark, dark over light. And at the top or bottom of a page they are gone rather than a quarter-opacity ghost, which on a light section was invisible anyway and read as a bug.
  • Feature The contact form's fields are on the scheme. They were white, which is the one surface on a generated page that says nothing about the scheme it is sitting in.
  • Feature The dawn's field is generated per load too, keeping its own geometry. Where its horizon sits is a contrast decision rather than a taste one, so the stops stay; only the turbulence underneath moves.
  • Fix Jalyn's portrait is framed for a photograph taken in portrait. A square frame with object-fit: cover keeps the middle, which on a standing shot is the middle of her back.
  • Fix The tear across every page. Sections are sized in vh, so on most window heights their boxes end on a fractional pixel; the browser rounds each box's paint on its own and can leave a one-pixel row that neither covers, showing the canvas behind it. Invisible at some heights and obvious at others -- 13vh of 900 is a whole number, of 789 it is 102.57 -- which is exactly how it survived a test harness pinned to 900. Measured at one boundary: 134 against 2.1. A pixel of overlap on the page's top-level stack closes it.
  • Feature No more shift-reload. Stylesheets and scripts were linked bare, so a browser that had them cached kept serving yesterday's. Every link carries the file's own modification time now -- nothing to remember to bump, and it changes exactly when the file does. Relative links stay relative: rewriting jens' style.css to an absolute gangdev.co URL points it at a host that does not serve that path.
  • Fix The two pinned buttons contrast with what is behind them and no more. Over a panel the plate is 3.7:1 with 5.5:1 of ink on it, over the paper 7.9:1. The version before reached for the brightest surface in the palette on one side and near-black on the other, which is a lot of shouting for a button the size of a thumbnail.
  • Feature The light sections and the form fields are tinted rather than white -- the one surface on a generated page that says nothing about the scheme it is sitting in. The field is on the base hue rather than the complement, so it reads as a brighter patch of the same sheet instead of a colour laid on it.
  • Infra The harness was blanking the new versioned links instead of resolving them, which left href="" on every stylesheet and made two pages look broken in ways they were not -- jens' dawn rendered as a hard edge for a whole round of debugging because his stylesheet was not loading at all.
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)