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.
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.sqluser_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 staterequires_code for any credential that is not signed in. The Connector also disables the checkbox and re-forces it every launchapi/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 tokenlive/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 offlinerelay/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 holdslinked_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 credentiallafter.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/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 redirectorg_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(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 collisionorg/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 fordomain=.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 logstyle.css drop from 9 selectors to 3--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--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--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)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--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 clearssrc/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 nowstyle.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 pagesmy/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 themsrc/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.cssmuster/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<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 insteadmuster.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 addedshared/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.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 workingsrc/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__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/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/…') callshttps://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{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 filemuster/.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.phpsrc/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 filesmy/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--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 #6d5119color-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 statewidth: 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--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.iconBtn styles an <a> and a <button>, and a button with no background takes the browser's default. Explicit background: transparent + appearance: none.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 ringedfalse 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)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 sessions23505) says so; anything else reports the real error and writes it to error_log. The old catch-all message actively misdirected debuggingtake.php accepts ?preview=1 from an admin so a draft can be previewed, previously you could only preview something already publishedhelp_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 tomulti_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 meaningON 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 allOrigin 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 unsaidavailability_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{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 viewlafter_theme preference, no tracking), so no consent banner is warranted*.lafter.gg origins explicitly rather than a wildcardtournaments/ 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 sectionRiotAPI 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 keyrelay_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)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 auditTypeError 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 bugsgameName/tagLine for the local player, so nobody else could be resolved at allactorCellId and were silently dropped by live.js, champion select data was being read correctly and then discarded on the way to the dashboardrequires_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 toggleapi/auth/signin.php, Connector sign-in had been completely broken, not degradedlafter_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 actionsnav.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 includeamazon.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 existsapi/update.json + signed installer on download.lafter.gg)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 liveshape: {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 filestudio/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 assumedscene.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 pointermoveworldTransform() 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 resizeexteriorOverride/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 structurestudio/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 handstudio/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 copystudio/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 corecreodin_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 accountcreodin_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 offis_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 rowsaccount/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 ownCHROME 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 zerosrc/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-leftcreodin.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 ittheme.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 facestudio/api/project.php, a new ownership-checked endpoint, and rolls the name back in the UI if the write failsadvanced: true is simply not rendered unless the mode is on, so no entity type needs to know the mode existsstudio/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 lawcountry / 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 onedocumented (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 2readiness() 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 51checkedOn so staleness is visible; the 8 unverified and 13 local-adoption entries are a live worklist, not a shrugcurrentColor, the smoke test asserts no codepoint above U+2100, so an emoji cannot get instudio/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 spikestudio/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)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 heuristictiesToVerticesicon as inline SVG on the descriptor, so adding a tool is still one file and one import<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 canvasrgba(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#hex and rgba( across the seven that draw and fails on a match. This is precisely the thing that returns one rgba() at a timelocalStorage: 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 someonelabelDim at 5.06:1 on paper. This is the same rule AI_INSTRUCTIONS.md sets for CSS, applied where a stylesheet auditor cannot reachcreodin.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 checkedtension, which is where the author does not hold his own rule. It keeps a node from reading as a flat law. Five carry one so far, including "read before you write", where the honest position is about cost rather than order.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.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.#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.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.$VERSIONS live. GangDev's is pinned per slide.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.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.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.#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.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.7190178b, which took a git fetch --unshallow to reach at all: the working clone only went back 100 commits.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.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)..mcfunction download. Roast got a target name, heat levels, history and a countdown you can switch off.<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..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.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..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.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.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./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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.
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 stubbackground-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 periodcreodin 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)src/img/ (favicon, apple-touch-icon), kept a root favicon.ico as a fallback for crawlers/clients that ignore the <link> tagpassword_resets table, 1-hour token) and account deletion, matching the pattern built for creodin.font-X utility classes in style.css, was declared separately (and inconsistently) in both navbar.css and product-themes.css@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.product-themes.css at all, no product's changelog styling had ever actually applied here until this sessionEvery 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 PostgreSQL schema for identity (users, sessions, remember_tokens, password_resets, pending_users, auth_codes)main/, per-product roots, shared/ strictly cross-productf12/ to standalone inspectre/ projectEverything that existed before versioned releases.
© 2026 GangDev, All Rights Reserved