MAINTAINING.md
Rama main · réplica de lectura del repositorio original.
# Maintaining QuickSite
Procedures for changing the engine itself — each one dormant most of the time,
and exact when it is not. They live here rather than in `CLAUDE.md` so that the
rules governing *every* change stay short enough to actually be read.
> **This is not the contributor guide.** How to report a bug, translate, or open
> a pull request is in `README.md` under *Contributing*. This file is for someone
> already inside the codebase performing one of the procedures below.
Read `CLAUDE.md` first — it holds the standards that bind every change
(dependency-free, centralize shared logic, HTML-in-JS hygiene, the data-shape
rule, and the `docs/` content-hygiene rules).
> **Why these checklists are so specific.** Every layer named below was found the
> hard way: a command that was routable and completely unusable because it had no
> category; a deleted command that survived in an allowlist nobody remembered; a
> registration layer in the admin API that mirrors nothing and had to be edited by
> hand. The lists are long because the failure modes are silent.
## Contents
| Procedure | Consult it when |
|---|---|
| [Documentation maintenance — the trigger table](#documentation-maintenance--the-trigger-table) | **Every change.** You edited a file and need to know which `docs/` file that obliges you to re-check. |
| [Adding a new command](#adding-a-new-command--checklist) | You are adding a `secure/management/command/<name>.php`. |
| [Removing a command](#removing-a-command--the-checklist-is-eight-layers-and-it-is-longer-than-adding) | You are deleting one. It is a longer list than adding. |
| [Release discipline](#release-discipline) | You are about to cut a git tag. |
| [How to write and review a `docs/` file](#how-to-write-and-review-a-docs-file) | You are restructuring or reviewing one of the six canonical docs. |
---
## Documentation maintenance — the trigger table
The six canonical docs in `docs/` (`ARCHITECTURE.md`, `ADMIN_PANEL.md`,
`COMMAND_API.md`, `WORKFLOW_SYSTEM.md`, `PROJECT_STRUCTURE.md`,
`DESIGN_DECISIONS.md`) drift fast. When editing the source files listed below,
re-check the corresponding doc.
| When you change… | Re-check |
|---|---|
| `secure/management/routes.php` (command count) | `ARCHITECTURE.md` §3, `COMMAND_API.md` (catalogue + count) |
| `secure/management/config/roles.php` (roles or permissions) | `ARCHITECTURE.md` §3 (roles table), and `help.php`'s `authentication.role_system`: one line per role, saying what that role adds and nothing a higher role adds |
| `secure/admin/functions/AdminHelper.php` (`getCommandCategories()`) | None — but **REQUIRED** for any new command that should appear on `/admin/command`. See *Adding a new command* checklist step 6. |
| `public/admin/assets/js/core/storage-keys.js` | `ADMIN_PANEL.md` §6 (storage key registry) |
| `secure/admin/templates/pages/preview/tools.php` — **a tool is one button, in this one row**, and `data-panel-opens` on it says its panel holds its options from the moment it is picked. **The panel's state, `preview-workspace--panel-open`, is set at every width (`preview.js` `setPanelOpen()`) and only the phone block of `responsive.css` gives it a meaning:** no script reads a width, and what asks whether the panel folds away reads the workspace's own box, `display: contents` on a phone (`panelFolds()`). The same goes for `preview-workspace--panel-fills` (the two Save forms, as Add and Edit Params fill the window on a phone) and `preview-page--rows-hidden` (the button beside Reload, a base rule at every width). **On a phone `.preview-sidebar` is always drawn:** the class shows or hides `.preview-sidebar__options`, and the bar under them, `.preview-sidebar__toggle`, stays. **The panel's width is `.preview-sidebar`'s `min(20rem, 40vw)`, its minimum too:** `preview-sidebar-resize.js` reads the minimum from the stylesheet and holds none of its own. **Move and the panel's handle run on pointer events,** so a finger drives them; `.qs-drag-locked` and the handle carry `touch-action: none`, and a change there is read under Responsive Design Mode's touch simulation | `ADMIN_PANEL.md` §8.1 (visual editor modes), §5.1 (*The three views*) |
| `secure/src/classes/JsonToHtmlRenderer.php` (node kinds, tag blacklist, QS.* registry) | `ARCHITECTURE.md` §2, §8, §9 |
| `secure/src/classes/ApiResponse.php` (envelope shape) | `COMMAND_API.md` (response shape) |
| `secure/src/classes/CommandRunner.php` (allowlist) | `COMMAND_API.md` (internals) |
| `secure/src/functions/filePolicy.php` (archive limits — `max_entries` / `max_total_bytes` / `max_entry_bytes` / `max_ratio`; import + publish extension allowlists; override merge rules) **and** `secure/management/config/import-policy.php.example` — the two must ALWAYS change together, the `.example` is the shipped documentation of the defaults. The same file's import content check, `qs_import_validate_content()`, and its one exemption (site JSON that is never served) are documented separately | `COMMAND_API.md` (*Archive import limits* — the table of defaults + the override section; its *How an entry's content is checked* for the content check) and, for the content check, the `importProject` `help.php` notes |
| `secure/management/command/importProject.php` / `exportProject.php` (what an archive carries, and what an import refuses — one entry, or the whole archive through the archive gate `importFirstStructureFailure()`; and the fixed spellings in `importEntryNameRefusal()`: a file or folder the engine reads by name joins that list, or a case variant of it imports differently on Linux and Windows) | `COMMAND_API.md` — **both** the *Export / Import* catalogue row and *Archive import limits*, which state the same contract twice — and the two commands' `help.php` entries, which state it a third time |
| `secure/management/command/exportProject.php` — **a saved export's name**: `<id>_export_<date>_<time>.zip`, then `_2`, `_3`… **claimed with an exclusive create** (`exportClaimName()`) before the archive is moved over it, so two requests never take one name, the claim readable by its owner only from the start and again after a move that copies; the archive built in a `tempnam()` file of its own (readable by its owner only); the limit of five ordered by modification time, then by the name's date and number (`exportsBeyondLimit()`, `exportNameOrder()`). **The name's shape has four readers**: the two `<id>_export_*.zip` globs (`exportsBeyondLimit()`, `clearExports`), `downloadExport`'s name check (which answers a claim not yet filled, an empty file, 409 `export.not_ready`) and the dashboard's `importNameFromFile()`; a change to the shape changes all four | The `help.php` entries of `exportProject` and `downloadExport` |
| `secure/src/classes/IframeSandbox.php` (the install-wide iframe embed policy: file shape, host matching, the `VALID_PERMISSIONS` ceiling, the same-origin / own-host / `srcdoc` guard rails), the **two render paths that apply it** — `secure/src/classes/JsonToHtmlRenderer.php` and `secure/src/classes/JsonToPhpCompiler.php`, which call one entry point and must keep doing so, or the same iframe is sandboxed differently in preview and in a built site — **and** `secure/management/config/embed-policy.json.example`: the class reads the live `embed-policy.json`, the `.example` is its shipped documentation and the source both setup scripts copy, and a build bundles the policy at `<secure>/data/embed-policy.json`, refusing to build when it does not parse (`build.php`) | `ARCHITECTURE.md` §8.1 (CSP `frame-src`), `COMMAND_API.md` (`getIframeSandbox`), `ADMIN_PANEL.md` (Embed security page) |
| `secure/src/functions/nodeParamPolicy.php` (the write-side attribute gate: `firstUnsafeParam` and the whole-structure verifier), **any command or helper that writes a structure file** — each one calls `qs_first_unsafe_structure_param()` on exactly what it is about to write (`qs_first_unsafe_param_in_tree()` before copying a whole tree) and refuses with `qs_unsafe_structure_param_response()`; there is no shared writer, so a new writer that skips the call is one the gate does not reach — **and** `secure/src/classes/RegexPatterns.php` `html_attribute_name`, the name charset the renderer, the compiler and the write gate share (its `D` modifier is load-bearing). **And what a binding may write** (`qs_binding_attribute_refusal()`, the same file): `firstUnsafeParam()` applies it to a `data-bind-attr` value, against the node's tag when the walk passes one, and `ApiEndpointManager::responseBindingProblem()` to a response binding's `attribute`, on every way `editApi` writes an endpoint. It is the write side's copy of `qs.js` `_qsBoundAttributeRefusal()`, which is the gate: the two change together. **Every runtime write of an attribute whose name or value comes from data goes through `qs.js` `_qsSetBoundAttribute()`** (today three: `setBindValue()`'s `data-bind-attr` and its image's default `src`, `applyBindings()`'s scalar binding), and its lists mirror the server's: `QS_ALLOWED_URL_SCHEMES` is `UrlPolicy::ALLOWED_SCHEMES`, `QS_URL_ATTRIBUTES` and `_qsIsUrlAttribute()` are `URL_ATTRIBUTES` and `isUrlAttribute()`'s pattern, and its name test is `html_attribute_name`: a change to one is a change to the other | `ARCHITECTURE.md` §8 (the *XSS in JSON content* and *Inline JS injection* rows state the name rule and which writers enforce the policies, a binding's included) and its `QS.redirect` paragraph; `ADMIN_PANEL.md` §9.2 and §9.6 (a binding's attribute); the `editApi` `help.php` entry (`addEndpoint`, its 400) and the structure writers' `unsafe_param` lines; `qsDataAttributeCatalog.php` (`data-bind-attr`) and `qsVerbCatalog.php` (`renderList`); `DESIGN_DECISIONS.md` (*A binding writes only what an author could write*) |
| `secure/src/classes/RegexPatterns.php` (a pattern's accepted values, description or examples — and the class header's rules: a validator anchors `^…$` and carries `D`, a pattern comes with its caller) | The `help.php` entry of every command that calls the pattern — its parameter's `validation` / `description` states the same rule, and `validationError()` returns the pattern's description and examples in the command's 400. For `html_attribute_name`, also `ARCHITECTURE.md` §8 (the row above) |
| A regex literal anchored at both ends anywhere in shipped PHP outside `RegexPatterns.php` (a new one, or a changed one) | It carries `D` when it validates. The two kinds that keep matching before a final newline — detectors, and parsers of hand-written text — are listed with their reasons in `DESIGN_DECISIONS.md` (*Every validator in the engine ends at the true end of the string…*); a new exception belongs beside them. Also the `help.php` entry of the command that uses it |
| `secure/src/functions/projectSettings.php` — a setting's rule (`qs_project_setting_error()`), a new setting, or `QS_PROJECT_SETTING_KEYS` — **and any command or helper that writes a project `config.php` setting** (`createProject`, `addLang`, `deleteLang`, `setDefaultLang`, `setMultilingual`, `setThemeMode`, `editFavicon`, `qs_favicon_repoint()` in `utilsManagement.php`). Each checks the value it is about to write with `qs_project_settings_guard()` or `qs_project_setting_error()`; a new writer that skips it can write what the import refuses. A project's name is not a setting: it lives in its own file (`config/project.json`); a `config.php` that still holds `SITE_NAME` keeps it, unread | The writer's `help.php` entry and `importProject` / `exportProject`'s (their notes list what an archive carries and each setting's rule); a new setting joins `QS_PROJECT_SETTING_KEYS` or is lost on a round trip; `COMMAND_API.md` (*Export / Import*, *Archive import limits*) if what an archive carries changes |
| `secure/src/functions/languageRegistry.php` (the loader, `qs_language_is_listed()`, `qs_project_has_language()`), `secure/management/config/languages.json.example`, **and every language parameter** — a NEW language asks `qs_language_is_listed()`, an EXISTING one `qs_project_has_language()`; a parameter that asks neither is one the list does not reach | The `help.php` entry of every language command (one wording per rule), `getLanguageList`'s notes, `DESIGN_DECISIONS.md` (*One language list for the installation…*); `languages.NOTICE.txt` whenever the list's names come from a different source; `PROJECT_STRUCTURE.md` (the config tree) when the list's files change |
| `secure/src/functions/languageRegistry.php` `qs_language_default()` / `qs_language_default_read()` (the installation's default language: its file, the fallback to `en`, what is logged) and `secure/management/config/default-language.php.example`, **and every reader of the default**: `createProject` (its `language` default), `getLanguageList` (`default_language`), the New Project dialog (`dashboard.js` `fillCreateLanguageSelect`), the console's `createProject` picker (`command-form.js`, `preselect: 'default'`), and item 9 of `setup.sh` / `setup.bat`, which checks a code against the list the same way. A new place that gives a NEW project a language reads `qs_language_default()`; a place that reads an EXISTING project's language never does, and neither does the import: an archive names its own languages or is refused | The `help.php` entries of `createProject` and `getLanguageList`: each states the rule, never the value, and `help` must answer the same whatever the setting says. `DESIGN_DECISIONS.md` (*A new project starts in the installation's default language*), `ADMIN_PANEL.md` §9 (the Dashboard row), `PROJECT_STRUCTURE.md` (the config tree), and the `.example` itself |
| `secure/src/functions/projectLanguage.php` `qs_project_default_language()` / `qs_project_language_codes()` — the one language fallback for a project that exists (its default language, else `en`; its list, else its default alone) — **and every reader of an existing project's languages**: a new reader calls them, never `CONFIG['LANGUAGE_DEFAULT']` / `CONFIG['LANGUAGES_SUPPORTED']` with a fallback of its own. The file travels into a build, so a compiled page may call them; `languageRegistry.php` never travels | `DESIGN_DECISIONS.md` (*An archive names its languages or is refused…*), and the `help.php` entries of the commands that answer a project's languages (`getLangList`, `listProjects`) |
| `secure/src/functions/FileSystem.php` — the one delete (`qs_delete_tree()`, `qs_delete_entry()`, `qs_delete_tree_rollback()`), the one copy (`qs_copy_tree()`, `copyDirectory()` its boolean face) and the one measure (`getDirectorySize()`, `countDirectoryFiles()`) — **every command that removes, copies or measures a directory tree calls them**: a link is never followed (a Windows junction included) — the delete removes it, the folder it is given included, the copy leaves it out and lists it, the measure does not count it; a read-only file is removed and copied; a delete or a copy keeps going and reports what failed, relative to its root. A command with a recursive delete, copy or size of its own is one these rules do not reach. One copier is separate on purpose: the publish copier `qs_copy_publishable_directory()` (`filePolicy.php`). The export's three walkers (`exportProject.php`) and `downloadBuild`'s one (`addDirectoryToZip()`, `ZipUtilities.php`) write into an archive, not a folder, and leave out a link by the same test (`qs_delete_tree_leads_elsewhere()`; a folder reached through one, `exportPathLeadsElsewhere()`). A new walker that writes an archive does the same | `DESIGN_DECISIONS.md` (the 2026-08-24 delete entry, *An archive names its languages or is refused…*, *The style merge keeps what it is given, every folder is copied and measured one way…*), and the `help.php` entries of `deleteProject` (`survived` / `retained`), `restoreBackup` (`restore.incomplete`, `failed_items`), `backupProject` and `cloneProject` (a link is not followed) |
| `public/admin/assets/js/pages/dashboard.js` — **it reads only the words the page ships it**: the layout's `QUICKSITE_CONFIG.translations.dashboard` (`layout.php` lists each word it ships) or a `data-*` attribute of `dashboard.php` (the language picker's placeholders, the clone dialog's `data-oauth-keys-text`). A word read from neither shows nothing, or the script's English fallback: a new word is shipped one of the two ways | `ADMIN_PANEL.md` §9 (the Dashboard row) |
| `secure/management/command/restoreBackup.php`'s answers (`restore.success`, `restore.incomplete`, `restore.no_files_restored`, `backup.prerestore_failed`, `quota.storage_exceeded`) **and `backupProject.php`'s** (`backup.created`, `backup.incomplete`, `backup.no_files_copied`, `quota.storage_exceeded`), **and the dashboard's handlers** (`dashboard.js`: the Backup button, `setupRestoreConfirmModal()` with its `delete_backup` box), which tell a failure by its status: a new failure answer is shown as a failure only if it is not a 2xx | The two `help.php` entries; `en.json` `dashboard.projects.restore_incomplete`, `backup_incomplete`, `backup_failed`, `deleteBackupAfterRestore`; and `layout.php`, which hands the JS its keys |
| `secure/src/functions/projectBackup.php` (`QS_BACKUP_ITEMS`, `QS_BACKUP_CONFIG_KEPT`) — what a backup holds and a restore replaces; `backupProject`, `restoreBackup` (and its pre-restore copy) and `listBackups` read it. **It holds what `exportProject` carries**: an item one carries and the other does not is one a restore loses. The one difference is on purpose: a backup always holds the project's OAuth keys, both sets, which an export carries only when asked, and then the build set alone (the row on what carries `data/` out, below) Never `config/members.json`, its lock or `config/project.json` (the name: a restore never renames). A copy never writes through a link | The `help.php` entries of those three commands; `COMMAND_API.md` (the Backups row); and, when the export's contents change, this list |
| `secure/src/functions/projectIdentity.php` — a project's identity: the id (`QS_PROJECT_ID_*`, `qs_project_id_draw()`, `qs_project_id_claim()`, `qs_project_id_from_input()`) and the name (the rule `qs_project_name_problem()`; the file `config/project.json` with its reader `qs_project_name_in()` / `qs_project_name()` and its writer `qs_project_name_write()`; the label `qs_project_label()` and its client twin `QuickSiteAdmin.projectLabel()` in `admin.js`). **Every command or helper that makes a project claims its id through `qs_project_id_claim()`** (`createProject`, `cloneProject`, `importProject`, `firstRun.php`'s copy); a maker that takes an id from a request is one the rule does not reach. **Every place that shows a project reads its name through these:** nothing keeps a copy — a `users.php` entry is built by `qs_membership_mirror_entry()` with no name; a notice keeps the name of its moment (the id for a requester of a private project) | The `help.php` entries of `createProject`, `cloneProject`, `importProject`, `listProjects`, `renameProject`; `COMMAND_API.md` (*Projects*, *Export / Import*); `ARCHITECTURE.md` §2 and §7; `PROJECT_STRUCTURE.md`; `ADMIN_PANEL.md` (the picker, the Dashboard row, Memberships); `DESIGN_DECISIONS.md` (*A project's id is drawn and its name is free…*) |
| `secure/src/functions/firstRun.php` (`qs_first_run_adopt_projects()`, `qs_first_run_copy_project()`) — first run gives the first account a copy of each project that shipped without a roster (the starter), under a drawn id, and leaves the shipped folder as it came | `ARCHITECTURE.md` §3 (*That first account…*); `README.md` (*What that first account gets*, the three files not created at setup); `PROJECT_STRUCTURE.md` (the starter); `DESIGN_DECISIONS.md` (*A project's id is drawn and its name is free…*) |
| `secure/src/functions/quota.php` (`qs_quota_check_storage()`, its kinds and their words, `qs_quota_storage_owner()`), `spaceUsage.php` `qs_invalidate_space_cache()`, **and every write that adds a copy's worth of bytes** (`uploadAsset`, `importProject`, `backupProject`, `restoreBackup`, `cloneProject`, `build`, `exportProject` with `save`, and `createProject` when the account is already over): each calls the check before it keeps what it writes, charged to the account that will own the bytes, with the project it writes to measured afresh (`project`), and drops that project's cached measurement after it writes; `deleteProject` drops it too, so a project made or imported later under the same name measures itself. A new write of that size that skips either is one the quota does not reach | `COMMAND_API.md` (*Per-user resource limits*); `secure/management/config/quota.php.example` (the shipped documentation); the `help.php` entries of those commands (their 507); `DESIGN_DECISIONS.md` (*One rule inserts at a structure's root…*, *A storage quota is charged to the project's owner…*) |
| `secure/src/functions/quota.php` — the small-write checks (`qs_small_file_kind()` and its kinds, `QS_FILE_CAP_DEFAULTS`, `qs_small_write_refusal()`, `qs_small_write_guard()` and its journal), **and every command or shared helper that writes a project file or a personal snippet**: each calls one of the two checks before it writes (a chain of helpers: the guard, and the command answers `qs_small_write_refused()`). A new writer that calls neither is one the caps and the over-quota rule do not reach. **A command that only removes content calls `qs_small_write_removal_only()` as its first statement** (the fifteen in `help`'s `limits.removals`, `COMMAND_API.md` *Small files* and the DD entry: three lists kept equal); a new removal command calls it, a command that can also add content never does | `COMMAND_API.md` (*Per-user resource limits*, *Small files*); `quota.php.example` (axes 1 and 3); the `help` entries of those commands (their 413 / 507) and `help`'s `limits` overview and `limits.removals`; `DESIGN_DECISIONS.md` (*A command that only removes content is never refused…*) |
| `secure/src/functions/quota.php` — the write rate (`QS_WRITE_RATE_DEFAULT`, `QS_WRITE_RATE_READ_CATEGORIES` / `_COMMANDS`, `qs_write_rate_admit()`, the `RateLimit` headers, `qs_write_rate_panel()`), **the admission in `public/management/index.php`**, and the categories in `categories.php` (a command moved into or out of `content.read` / `config.read` changes what counts) | `COMMAND_API.md` (*The write rate*); `help`'s `limits.write_rate`; `quota.php.example` (axis 4); `ADMIN_PANEL.md` §7 (`writeRate`) and §8.12 (*The write rate*) |
| `public/admin/assets/js/core/api.js` (the write allowance, `countsAsWrite()`), `admin.js` `initWriteRate()`, `preview-ai-tools.js` (`_planRun()`, `_executeSteps()`, the kept rest: `aiKeptSteps` holds the steps of the phase a run stopped in and the later phases by name) | `ADMIN_PANEL.md` §4 (`quicksite:write-rate`), §6 (`aiKeptSteps`), §7, §8.12 |
| `public/admin/assets/js/core/api.js` `watchedFetch()` / `logRequestError()` — **every request the module makes goes through `watchedFetch()` and reports a failure through `logRequestError()`**: Firefox rejects a request still out when the page leaves (a reload included) with the error a real network failure raises, and a `fetch()` or `console.error()` of its own prints that as a red console line on every reload. The `beforeunload` listener stays attached only while a request is out, so an idle page keeps its place in the back/forward cache | `ADMIN_PANEL.md` §5.1 (the `api.js` row) |
| `preview-ai-tools.js` `_startRun()`, the `workflow-generate-steps` arm of `public/admin/api/index.php` and `WorkflowManager::renderPrompt()` — **a run's parameters:** they travel as JSON in the arm's body (booleans and lists as they are); `{{param.x}}` writes a list as `{{data.x}}` does (`formatValue()`, its items joined by ", ") and any other value as it came | `ADMIN_PANEL.md` §8.12 (the Parameters entry) |
| `secure/src/classes/NodeNavigator.php` `insertAtRoot()` **and `insertAt()`** — **every command that inserts at a structure's root (`targetNodeId: "root"`) calls the first** (`insertSnippet`, `addComponentToNode`, `addNode`, `addComplexElement`), **and every command that inserts before, after or inside a node the second** (`addNode`, `addComplexElement`, `addComponentToNode`, `insertSnippet`, `duplicateNode`, `moveNode`, `editStructure`'s insertBefore / insertAfter). A list root takes the node as its first entry, a node root as its first child; an empty structure is a list; inside a node is its first child; a target that cannot hold children (a text node, a component call, or a void element: `TagRegistry::isVoidElement()`) is refused by both, inside, at the end and at a root — the one answer, `400 operation.failed`: no command checks it first with an answer of its own; several nodes land in order when inserted last one first at one place. `moveNode` and `addNode` take `inside-end` (the target's last child). **And the editor:** `preview.js` sends `root` when the selection is the root's `data-qs-node=""`, for every kind of add, and offers *Inside* only where the selected node can hold children and, for a text, *Before* / *After* only beside a node the page shows inline (`_syncAddPositionOptions()`, from the preview's `holdsChildren` and `inline`, the browser's computed display); the Text tab's *Inside* sends `inside-end`, which the preview's live insert (`insertNodeIntoDom()`) puts last; the drag and drop (`preview-iframe-inject.js`) sends `inside-end` for a drop inside and offers no drop inside where the server refuses (`cannotHoldChildren()`, which reads the browser's void set) | The `help.php` entries of those commands (each states the rule); `DESIGN_DECISIONS.md` (*One rule inserts at a structure's root…*, *Every insert next to or into a node follows one rule…*, *`moveNode` moves through the one insert…*, *An emptied page renders a selectable root…*); `ADMIN_PANEL.md` §8 (the empty-page paragraph, the drag and drop) |
| `secure/src/classes/NodeNavigator.php` `removeNode()` — **every command that removes a node goes through it** (`deleteNode`, `moveNode` before it puts the node back, `editStructure`'s delete through `updateNode()`): it answers the structure without the node, and the node it removed. A command with a removal of its own is one this rule does not reach | The `help.php` entries of `deleteNode` and `moveNode` |
| `secure/src/classes/Page.php` (a build) and `secure/src/classes/PageManagement.php` (`/p/`) — **one body:** the menu, the page and the footer with nothing around them; both compute the title with `qs_page_title()` just before escaping it with the same flags: a title the page has takes the request's `{{param:}}` / `{{resolved:}}` (`qs_apply_runtime_placeholders()`, resolved values first); a page with none shows the last segment of its address, readable and never filled. A change to one shell's body or title is made to both | `ARCHITECTURE.md` (the page emit, §6.3 *How captured params flow*); `DESIGN_DECISIONS.md` (*One page shell on both surfaces…*, *A page title takes the request's placeholders…*, *A page with no title shows its address, readable…*) |
| `secure/src/classes/Translator.php` — the missing marker (`MISSING_PREFIX`: what `translate()` answers for a key no language holds). **Code that recognises it asks `Translator::isMissing()`**, never a copy of the text: today `qs_page_title()` (a page with no title) and `runtimeHandoff.php` `qs_translated_call_argument()` (a positional call argument with no translation is the author's own text) | `ARCHITECTURE.md` §6.3 (*How captured params flow*) |
| `secure/src/classes/TagRegistry.php` `RESERVED_PARAMS`, `TRANSLATION_KEY_PARAMS` and `withoutReservedParams()` — **the params `addNode` and `editNode` keep:** both filter a tag's params through `withoutReservedParams()` — `addNode` with its tag, `editNode` before its checks with the tag the request names (none named: a null tag, so the params some tag declares wait) and again with the tag the node ends with — which drops the attributes the translation system fills except those the tag takes as a translation key (an iframe's `title`) | The `help.php` entries of `addNode` (`params`) and `editNode` (`addParams`) |
| `secure/src/classes/TagRegistry.php` `TRANSLATABLE_ATTRIBUTES` (the reserved params and `alt`) and `EMPTY_MEANINGFUL_ATTRIBUTES` — **one list of each, and every reader takes it**: the renderer and the compiler (a preview and a build translate the same attributes and keep the same empty ones), `deleteNode` (the keys it removes, answered in the list's order), `duplicateNode` (the keys it makes, numbered in it), `insertSnippet` (the keys it makes), `qs_component_variables()` (`componentPolicy.php`: a slot's type, for every reader of a component's slots), and the editor's snippet preview through `editorPayload()` (`TAG_INFO`). A copy of either list anywhere else is one that drifts | `ARCHITECTURE.md` (*Translatable attributes*); the `help.php` entries of `insertSnippet` (the attributes whose keys it makes) and of `addNode` / `editNode` (the reserved ones) |
| `secure/src/functions/componentPolicy.php` `qs_component_variables()` — **the one reader of a component's slots**: `addComponentToNode`, `editComponentToNode`, `insertSnippet`, `listComponents`, `getComponent`, and their two answer shapes (`qs_component_variable_answer()`, `qs_component_variable_list()`). A node or a list of nodes; a slot passed into a nested call typed by the called component (`qs_component_called_slots()`, through `qs_resolve_component_path()`). A command that reads slots on its own is one the rule does not reach. **A command file never includes another to borrow a function:** its last lines answer over HTTP | The `help.php` entries of those five commands, `COMMAND_API.md` (the Components row), `DESIGN_DECISIONS.md` (*A component's slots are read one way…*) |
| What a structure starts with: `secure/src/functions/utilsManagement.php` `generate_page_json()` — **every creator of a page's structure takes it** (`addRoute` for a page and its parent, `createProject` for the home page, `policyPageHelpers.php` for a policy page's parent) — `createProject.php` `createMenuAndFooter()` and `createNotFoundPageStructure()` (with the three `404.*` texts `createDefaultTranslations()` writes), and `editStructure` (an emptied structure is saved empty) | The `help.php` entries of `createProject`, `addRoute` and `editStructure`; `ADMIN_PANEL.md` §8; `DESIGN_DECISIONS.md` (*One page shell on both surfaces…*) |
| `secure/src/functions/utilsManagement.php` `qs_page_name_refusal()` — the one page-name rule: **every command that checks a page name calls it**: `getStructure`, `editStructure`, `editTitle`, the node, component and interaction commands, `insertSnippet`, `importStructureTranslations`, `addPageEvent`, `editPageEvent`, `getPageEvents`, `setStateStores` (`deletePageEvent` and `getStateStores` read stored keys and check no name). A page is named by its route path: literal segments, or `:` and a parameter name (`products/:slug`), and a route of the project or a special page; a name that is not a string is 400 `validation.invalid_type`. A command with a page-name check of its own is one the rule does not reach | The `help.php` entries of those commands (each states the rule); `DESIGN_DECISIONS.md` (*A page is named by its route path everywhere…*, *Every command checks a page name and a component name…*) |
| `secure/src/functions/utilsManagement.php` `qs_component_name_refusal()` — **every command that builds a component's file from a name it was given calls it** (the node, component and interaction commands, `insertSnippet`, `getStructure`, `editStructure`, `duplicateComponent`, `renameComponent`). It applies the reference rule (`RegexPatterns` `component_name`, `qs_is_valid_component_reference()`), so a change to that rule changes what a page may place AND what a command may save. A command that builds the path itself is one the rule does not reach, and can write another project's files | The `help.php` entries of those commands; `DESIGN_DECISIONS.md` (*Every command checks a page name and a component name…*, *A component reference is a name, not a path*); `ARCHITECTURE.md` (*Component references*) |
| `secure/src/functions/utilsManagement.php` `qs_is_literal_text()` — **every command that copies nodes keeps a `__RAW__` / `__LIT__` value as written, before any key is made**: `insertSnippet` (`processSnippetStructure()`, in its three places: a text, a translatable attribute, a component's text variable) and `duplicateNode`. A copier that skips it shows the key's name instead of the text | The `help.php` entries of `insertSnippet` and `duplicateNode`; `DESIGN_DECISIONS.md` (*Every insert next to or into a node follows one rule…*) |
| `secure/src/functions/utilsStyleManagement.php` — the stylesheet scan (`QS_CSS_DENYLIST`, `qs_css_first_danger()`, `qs_css_normalize_for_scan()`) — **and any command or helper that puts CSS text into a project stylesheet** (today `editStyles`, `setStyleRule`, `setKeyframes`, `setRootVariables`, `injectSnippetCss`, and the import's content check `qs_import_validate_content()`): each runs `qs_css_first_danger()` on the text as it will be written, pieces joined as it joins them; a new writer that skips it is one the scan does not reach | The `help.php` entries of those five commands (`editStyles`' notes state the scan once, the others point to it) and `importProject`'s notes; `COMMAND_API.md` (*How an entry's content is checked*, *Archive import limits*, the *Export / Import* row); `DESIGN_DECISIONS.md` (*One scan for every stylesheet writer and the import…*) |
| `secure/src/classes/CssParser.php` — the declaration rule (`declarationProblem()`, `readDeclarations()`, `DECLARATION_RULE`) — **and any command that writes a declaration list into a project stylesheet** (today `setStyleRule`'s styles, each `setKeyframes` frame, each `setRootVariables` variable as `--name: value`): each checks the text with `CssParser::declarationProblem()` before writing, and the parser splits and writes with `readDeclarations()`; a writer that skips the check can write a quote, a comment or a bracket that reads on into the rules after it. The keyframe editor (`preview-style-motion.js` `frameDeclarations()`) splits declarations the same way — keep the two in step. **And `setStyleRule`'s merge** (`parseStyleDeclarations()`, `declarationKey()`, `mergeStyles()`): a property sent replaces every declaration of it, written as sent; names compare without case, ASCII only, a custom property with it; a comment is never dropped; a fallback not sent stays. **And how a rule inside `@media` is written:** its declarations one level in from its selector and its `}` at the selector's column (`lineIndent()`), for an update, a rule added to a block (after one blank line) and a new block (after one blank line, as a new global rule); a rule taken out of a block leaves nothing where it was (`withoutBlock()`, shared by `deleteStyleRule` and `setStyleRule`'s rule left with no declaration) | The `help.php` entries of those three commands (each states the rule), `DESIGN_DECISIONS.md` (*A command writes only declarations a stylesheet can hold…*, *The style merge keeps what it is given…*), `ADMIN_PANEL.md` §8.11; `DESIGN_DECISIONS.md` (*A rule inside `@media` is written one way…*) |
| A command's success status — **never 204:** a 204 carries no body, and a web server drops one, so `data` would reach nobody (PHP's own server passes it, which is why a probe over `php -S` will not see it). `deleteAsset` answers 200, or 207 `operation.partial_success` when a batch deletes only some, and its two readers (`assets.js` `deleteSelected()`, `command-form.js` `initDeleteAssetForm()`) read `data.failed`. A command that acts on a set joins that list in `COMMAND_API.md`. `api.js` keeps its 204 branch for a server in front of the panel | `COMMAND_API.md` (`status`) |
| `secure/management/command/checkStructureMulti.php` — **it scans what `scanAllPageJsonFiles()` finds**, and names a page by its route. A new place structures live joins it, or `clean` says nothing about them | `help.php` `checkStructureMulti` |
| `secure/src/functions/utilsManagement.php` `qs_format_size()` — **the one formatter of a size a command answers as text** (`*_formatted`, an export's `size`): a command calls it and keeps no private copy. The panel shows these as text and parses none | `help.php` (the sizes its examples show) |
| `secure/management/command/testApiEndpoint.php` `$maskedHeaders` and `QS_API_TEST_TIMEOUT_MAX` — **the answer reports a credential masked, and a test waits 1 to 60 seconds:** a new credential header this command adds gets its masked twin beside it, and the cap is stated by value in `help` | `help.php` `testApiEndpoint` |
| `secure/src/functions/qsVerbCatalog.php` — **a description opens with a plain example, and what a reader may not know goes at its tail:** an argument's description is also its input's placeholder (`preview-js-interactions.js`), so its first words are what shows. No reader parses a description | `COMMAND_API.md`, `ADMIN_PANEL.md` (the interaction picker) |
| `secure/src/functions/apiRegistry.php` `qs_http_header_name_is_token()`, `qs_http_header_value_is_safe()`, `qs_api_header_map_problem()` and `QS_API_RESERVED_HEADERS` — **an endpoint's `headers` are checked at both ends, by the one function:** `ApiEndpointManager::validateEndpoint()` when one is saved (every write path of `editApi`), `serverFetch` and `testApiEndpoint` when a request is built, because a registry can arrive by import or by hand; the Test command's own `headers` parameter is held to it too. **Every place that turns stored API config or a request's parameter into an outgoing header** does the same (`ApiEndpointManager::validateAuth()` for a token source's name, `serverFetch` and `testApiEndpoint` for it and for a key or token's value), or it can split a request. It refuses before anything is sent, and its words never repeat a value | The `addApi` / `editApi` / `testApiEndpoint` help entries; `ADMIN_PANEL.md` §9.1; `DESIGN_DECISIONS.md` (*An endpoint's headers are checked where they are saved and where they are sent*) |
| `secure/src/classes/TrimParameters.php` / `secure/src/functions/routeHelpers.php` / a project's `secure/management/routes.php` schema (param routes) — a change to what the router answers is also a change to what the browser reports (`window.QS_ROUTE`). **Every reader of the request path takes the site's base off through `qs_path_without_base()`** (`projectLanguage.php`): the URL space in a build; on `/p/` surface B's marker, and nothing after its rewrite. Today that is the router, the alias rewrite (`qs_apply_alias_routing()`), the language reader (`qs_project_language_from_path()`) and the system placeholders (`qs_system_placeholders()`); a reader that takes the space off on its own takes it off twice on `/p/`. A decoded parameter value that is not UTF-8 makes the route not found. `samePageUrl()` writes the address the visitor wrote, never the pattern | `ARCHITECTURE.md` §6.3 (routing — matching algorithm, param syntax, decoding and the UTF-8 404, NTFS `:` ↔ `__` sanitisation, and *How captured params flow*) and §4 (the language reader's path) |
| `secure/src/classes/TrimParametersManagement.php` (how the management API reads its input) — **a value that is not UTF-8 — a segment of the address, a key or value of the query string or of a form-encoded body, at any depth — is answered 400 before any command reads it** (`refuseNonUtf8()`): a command that repeated such a value in its answer could not encode it. A new input source goes through the same check | `COMMAND_API.md` (*Endpoint*) |
| `secure/management/command/addRoute.php` / `deleteRoute.php` (route body shape, conflict warnings) | `ADMIN_PANEL.md` §9.8 (sitemap UX + add-form validation), `COMMAND_API.md` (param-route example) |
| `public/admin/assets/js/pages/sitemap.js` (tree rendering, add form, badges, resolver list view + per-config modal) — **it writes no markup:** an icon is `QSDom.iconEl` of a constant (a `QuickSiteUtils.ICON_PATHS` entry or one of the file's own `ICONS`), never a value from data; a button's working state is `QSDom.setButtonBusy()`; a clear is `replaceChildren()`. The same holds for `assets.js` | `ADMIN_PANEL.md` §9.7 (resolvers) + §9.8 (sitemap) |
| `secure/management/command/deleteAsset.php` — the asset list (`AssetMetadataManager`) is saved only when it named a deleted file, and a failed save is answered `500 server.file_write_failed`, the files deleted, the asset list or the favicon setting not saved, `data.not_saved` naming which — **and the Media page** (`assets.js` `deletedButListNotSaved()`), which follows the delete on exactly that answer. A new answer given after the files are gone keeps that code, or the page keeps a deleted file on screen | The `deleteAsset` `help.php` entry, `COMMAND_API.md` (the Assets row), `ADMIN_PANEL.md` §9.15 |
| `secure/src/functions/utilsManagement.php` `qs_asset_write_failed()` and `qs_favicon_repoint()` (its answer: `unchanged`, `repointed`, `failed`) — **every asset writer answers a failed write of the asset list or of the favicon setting through them**: `500 server.file_write_failed`, a size cap's refusal answered as itself; an upload is not kept, an edit is taken back whole (`qs_small_write_rollback()`), a delete names what it did not save (`not_saved`). A new asset writer does the same, or a client reads a success over a stale list | The `help.php` entries of `uploadAsset`, `editAsset` and `deleteAsset`, `COMMAND_API.md` (the Assets row), `ADMIN_PANEL.md` §9.15, `DESIGN_DECISIONS.md` (*Every asset writer answers a failed save…*) |
| The Sitemap File (`sitemap.js` `loadSitemapLinks()` / `generateSitemapFile()` / `showGeneratedSitemap()`, `sitemap.php`) — **the file shown after Generate is read back through `getSiteMap/text`**, so it is the file only while that text form and `setSiteMapConfig`'s write build the same bytes from the same saved configuration (`qs_sitemap_apply_config()`, one URL a line); change one and keep the other in step. The links keep the choices on screen across a reload; only opening the page reads the saved ones | `ADMIN_PANEL.md` §9.8 (sitemap), `help.php` (`getSiteMap`'s text format) |
| `secure/src/functions/resolverHelpers.php` (sidecar shape — scalar vs array; validation; flat-namespace collision rule) | `ADMIN_PANEL.md` §9.7 (resolver authoring), `ARCHITECTURE.md` §9.4 (resolver subsection) |
| `secure/src/classes/DataResolver.php` (`resolveMany`; per-resolver `onMiss`; flat + namespaced exposure) | `ADMIN_PANEL.md` §9.7 (failure-mode table, namespaced access), `ARCHITECTURE.md` §9.4 |
| `secure/src/functions/serverFetch.php` (`serverFetchMulti()`; `_serverFetchPrepare`; auth handling; cache eligibility) — **a call it refuses before sending is the site's own misconfiguration, and `resolverRuntime.php` `qs_resolver_error_is_config_bug()` recognises it by its words:** a new refusal's words join that list, or a resolver shows a quiet 500, or nothing under `onMiss: render-empty` | `ADMIN_PANEL.md` §9.7 (auth-cacheable rule, cache observability header), `ARCHITECTURE.md` §9.4 |
| `secure/management/command/setRouteResolver.php` (body shapes + `index` param + collision errors) | `COMMAND_API.md` (catalogue + example), `ADMIN_PANEL.md` §9.7 |
| `secure/management/command/cleanResolverCache.php` | `COMMAND_API.md` (catalogue) |
| `secure/admin/templates/pages/sitemap-resolver.php` / `sitemap-resolver-list.php` (modal markup) | `ADMIN_PANEL.md` §9.7 (admin authoring section) |
| `secure/src/functions/runtimeHandoff.php` (hydration emit — it writes `window.QS_RESOLVED` + `window.QS_RESOLVED_BY_INDEX`, and `window.QS_ROUTE`, the route the server resolved for the request, which `qs.js` exposes as `QS.routePath` / `QS.routeParams` / `QS.routeFound`, with the base the page is served under, `QS.basePath`. **Each caller passes the route its page was rendered with** — `PageManagement::render()` on `/p/`, `Page::render()` in a build: a caller that passes none gives the browser no route). **Every script tag carries `?v=`** (`qs_asset_version()`): on `/p/` each file's date — the caller passes `runtimeFile`, the file `scripts/qs.js` serves (the engine's own copy) — and in a build the build's one number (`assetVersion`). The icon helpers `qs_page_icon_path()` / `qs_page_icon_linked()` / `qs_page_icon_href()` live here too: both page shells and the build use them, so a page links its icon only when the file is there | `ADMIN_PANEL.md` §9.7 (hydration handoff) and §9.15 (*The favicon control*), `ARCHITECTURE.md` §6.3 (*How captured params flow*), §9.4 and §9.5 (the handoff table and the `?v=` number) |
| **Any code that writes a value into an inline `<script>`** — the runtime handoff and its encoder `qs_inline_script_json()` (`secure/src/functions/runtimeHandoff.php`), the theme scripts in `Page.php` / `PageManagement.php`, the component preview in `public/p/index.php`, the panel's config blocks (`secure/admin/templates/layout.php`, `pages/preview-config.php`), and the call-argument literal `qs_js_single_quoted()` with its request-time writer `qs_translated_call_argument()` (same file; `CallTransformer` and a multilingual build's compiled pages call them). A data value goes through the encoder and a call argument through the literal; a new writer that skips them is one the rule does not reach. **The encoder never returns an empty value:** a byte sequence that is not UTF-8 is written U+FFFD and a value it still cannot encode `null`, because every caller writes its result after `=`, and an empty result drops the whole script. Values reach it from the request (the handoff's `QS_ROUTE`) and from API responses (`QS_RESOLVED`), neither of them promised to be UTF-8 | `DESIGN_DECISIONS.md` (*A data value written into an inline script cannot end or change the script*, *The browser reads the route the server resolved, a value in an inline script is always a complete expression…*, and *A multilingual build looks a translated call argument up when the page is served…*), the `contentSecurityPolicy.php` docblock (its DATA rule), `ADMIN_PANEL.md` §9.7 (the page-source paragraph), `ARCHITECTURE.md` §8.1 (the `script-src` row) and §9.0.2 |
| `secure/src/runtime/qs.js` `readServedRoute()` (the browser's route is `window.QS_ROUTE`; `qs.js` matches nothing, so whatever the router decides — the base, a language segment, an alias, decoding, precedence — the browser reports as it is; it also sets `QS.basePath` from the route's `base`) and `JsonToPhpCompiler::callSegmentsToPhp()` (which arguments a build looks up when the page is served). **`exchangeMagicLink` follows `?return=` only when it is a path on this site** (one `/`, then neither a second `/` nor a backslash), composed against `QS.basePath`; `QS.redirect()` keeps refusing a control character and a scheme outside its allowlist at the sink. Its twin on the server is `qs_site_path()` (a row below): change both together | `ARCHITECTURE.md` §9.0.2, §6.3 (*How captured params flow*) and §9.5; `DESIGN_DECISIONS.md` (*A multilingual build looks a translated call argument up when the page is served…*, *The browser reads the route the server resolved…*, *A page is named by its route path everywhere…*) |
| `secure/src/functions/buildSiteRuntime.php` `qs_site_config_php()`, `secure/management/command/build.php` (writes `qs-site.php` after the assets copy), `secure/src/runtime/site/index.php` (validates it and defines `QS_BUILD_VERSION` / `QS_BUILD_ICON`) and `Page.php` (reads them and checks nothing) — **a value a built page decides once belongs in `qs-site.php`**: today the `?v=` number and the icon | `ARCHITECTURE.md` §11 (build steps 7–8) and §9.5 |
| `secure/src/functions/requestRuntime.php` `qs_site_path()` and `OAuthHandler::returnTarget()` / `sanitiseReturnTo()` — **every server redirect whose target comes from a request value goes through them** (one `/`, then neither a second `/` nor a backslash, no control character) and is composed against `QS_PUBLIC_BASE`: today OAuth's sign-in, callback, sign-out and the sign-out's two early exits; no other server redirect takes a request value. Its twin in the browser is `exchangeMagicLink`'s check in `qs.js`: change both together | `ARCHITECTURE.md` §11 (the `?return=` paragraph), `ADMIN_PANEL.md` §9.5, `DESIGN_DECISIONS.md` (*OAuth: the installation offers the providers…*) |
| `secure/management/config/oauth-providers.json.example` (the shipped providers) and `qs_oauth_provider_problem()` (`oauthProviderHelpers.php`, the entry check) — **a field a provider entry may carry is added in four places together:** the known-fields list, the `.example`'s `_fields`, `ADMIN_PANEL.md` §9.5 (*The provider list*) and `listOAuthProviders`' `help` notes. An address the server calls is checked by `qs_oauth_address_problem()`; `privacy_url`, which only a visitor's browser opens, by `qs_oauth_link_problem()` (https even in development, a fragment allowed). No command writes the list, and an operator's `oauth-providers.json` replaces the `.example` whole: a change to a shipped entry reaches only installations without a copy, so the release notes say what an operator with a copy must add | `ADMIN_PANEL.md` §9.5, the `listOAuthProviders` `help.php` entry, the release notes |
| `secure/src/functions/consentLayerHelpers.php` `consentOAuthLinks()` — **a policy names and links an OAuth provider from the provider list** (`qs_oauth_provider_name()`, `privacy_url`) and finds the project's providers through the one scan of its sign-in routes (`qs_oauth_project_routes()`; an address route: every offered provider with keys, in either set). Its three readers — `generateCookiePolicy`, `generatePrivacyPolicy` and the privacy page's scan (`privacyAuthSeed()`) — take its answer as it is: a second list of names or links anywhere is one that drifts | `ADMIN_PANEL.md` §9.10 and §9.11, the two generators' `help.php` entries, `DESIGN_DECISIONS.md` (*A provider's name and privacy link come from the provider list*) |
| `secure/management/command/setOAuthCredentials.php`, `qs_oauth_keys_read()` / `qs_oauth_keys_write()` and `qs_oauth_sign_in_keys()` (`oauthProviderHelpers.php`) — **the project's key file** (provider → `preview` / `build` → client id + secret) is read by the sign-in, written by the command, carried by `build.php` and `exportProject` (the build set only) and shown by the providers page: a change to its shape changes all of them. **A secret no project stores and no provider issues** — the placeholder (`qs_oauth_secret_placeholder()`), anything with a space or outside printable ASCII, an empty string, a value that is not a string — is read as left out (`secret_left_out`), never sent, never taken for a public client (only an absent or null secret is one): every writer writes a set through `qs_oauth_stored_set()`, every copy carried out through `qs_oauth_carried_set()`, so the read's flag never reaches a file. **A copy that writes a key file calls `qs_oauth_keys_protect()` once it is written** — a backup, the copy kept before a restore and a restore (all three in `qs_backup_copy_item()`), and an import — so it is readable by the server's own user only, as the writer and a build leave it | the `help.php` entries of `setOAuthCredentials` and `listOAuthProviders`, `ADMIN_PANEL.md` §9.5, `ARCHITECTURE.md` §11 |
| `secure/management/command/build.php`'s OAuth step and its `functionFiles` (the engine files a build carries) — **the OAuth step decides what a build carries for sign-in** (the providers its routes use, their build keys: each client id, each secret only with `include_oauth_secrets`, which only a role holding `setOAuthCredentials` may send) and reports what is missing in its `oauth` answer (`secrets_left_out` among it), which the builds page shows (`builds.js` `_renderOAuthGaps()`) and the build's README repeats. **A function or constant a built site's sign-in needs lives in a file `functionFiles` lists:** the session cookie's name once lived only in an authoring file, and every callback failed | `ADMIN_PANEL.md` (the builds page table), `COMMAND_API.md` (the builds row) |
| `secure/management/command/build.php` — **the `README.txt` a build carries.** The Builds page's line under its buttons (`builds.serverHint` in `en.json`, its fallback in `builds.php`) says what that file holds: where each folder goes, what Apache and nginx need, how to test | That line, `README.md` (*Putting a project on its own domain*) and `ADMIN_PANEL.md` §9.16 (*What the page shows*) |
| `secure/src/functions/NginxConfig.php` `generate_nginx_config()` — **the root block is always the free one, ending in `=404`**, and the comment of a build's `nginx_routes.conf` (`qs_site_nginx_config()`, `buildSiteRuntime.php`) lists what this file declares: a new block changes that list. A comment there ships in every build | `README.md` (*Already have a `dynamic_routes.conf`?*), and that comment |
| **Every command that carries a project's `data/` out of it** — `build`, `exportProject` (and `downloadBuild` / `downloadExport`, which stream what those made), `cloneProject`, `backupProject` / `restoreBackup`, `importProject` and first run's copy (`qs_first_run_copy_project()`) — **decides what it carries of `data/oauth-secrets.json`**: a build and an export the build set alone, through `qs_oauth_carried_set()` (the secret only with `include_oauth_secrets`, else the placeholder in a build and no key file in an export); a backup and its restore both sets; a clone none, nor the source's `qs_build/` and `exports/`, which hold keys; an import what its archive holds. A new path that copies `data/` whole carries both sets, the preview keys with them | `ADMIN_PANEL.md` §9.5, §9.16 and the Dashboard row of §9, the `help.php` entries of `build`, `exportProject` and `cloneProject`, `COMMAND_API.md` (*Backups*, *Export / Import*), `DESIGN_DECISIONS.md` (*A build carries a project's OAuth secret only when asked…*) |
| `secure/management/command/downloadBuild.php`, `deleteBuild.php` (with `qs_build_holds_secrets()` and `qs_caller_may_set_oauth_keys()`, `utilsManagement.php`) — **a build that carries the OAuth secrets is a credential**: it downloads and deletes only for a role that may set the project's keys (`setOAuthCredentials`, the permission `build.php` asks before it takes `include_oauth_secrets`), refused 403 before any archive is made or anything removed; a manifest that does not say `oauth_secrets_included` false is read as carrying them, and a build with no manifest stays deletable by every role that may build. `build.php`'s refusal tells that role who to ask. **The panel asks the same** (`builds.js` `_outOfReach()`, `_mayDownload()`, `_mayDelete()`; `dashboard.js` `_renderManageSpaceBuild()`): it hides both buttons, and the Builds page shows `builds.secretsRoleOnly` instead. A new way to hand a build over or remove one asks the same | `ADMIN_PANEL.md` (the builds page table), the `help.php` entries of `downloadBuild`, `deleteBuild` and `build`, `COMMAND_API.md` (*Builds*), `DESIGN_DECISIONS.md` (*A build that carries the OAuth secrets goes only through who may set the keys*) |
| `secure/management/command/build.php` — **a build that does not finish leaves nothing**: every refusal and failure goes through `abort_build()`, and a request stopped any other way (a fatal error, the time or the memory limit) is caught by the shutdown hook armed once the build folder exists, which releases the lock and removes the folder the same way. The hook is disarmed just before the success answer: a new check that can discard the build — after the manifest too — runs before the disarm. **The lock is the project's, and the one-build refusal is asked again under it** (`refuse_existing_build()`): a lock keyed on anything narrower lets two requests land two builds | the `build` `help.php` entry, `COMMAND_API.md` (the builds row), `ARCHITECTURE.md` (the build pipeline's steps before the lock) |
| `secure/src/functions/ZipUtilities.php` `qs_temp_archive_arm()` / `qs_temp_archive_send()` — **a temporary archive a request builds and streams is armed as soon as its file exists and streamed through the send** (today `exportProject`'s stream and `downloadBuild`): it is removed however the request ends — sent, the client gone mid-transfer, a fatal error — and the removal closes the send's handle first, since Windows refuses to remove an open file. A command that streams a temporary file with a `readfile()` of its own leaves it behind when the client cuts the transfer | the `help.php` entries of `exportProject`, `downloadExport` and `downloadBuild` |
| `secure/src/functions/PathManagement.php` `is_valid_project_name()` — **the one project-id rule**: a letter first, then letters, digits and `-`, at most 50 characters, **never `_`** (a project's storage names are `qsp_<id>_<key>`, so an id holding `_` could build another project's name). **Every place that takes an id from a request calls it**: the management and admin-API dispatchers (`public/admin/api/index.php` peels the marker and leaves its shape to the call), the `/p/` surface (`qs_surface_b_gate()`), first run's scan (`qs_first_run_unowned_projects()`), the panel's own endpoints and every command that takes an id; `qs_project_id_draw()` draws only ids that pass. A pattern of its own anywhere is a second rule | `ARCHITECTURE.md` (the id paragraph of *A project has an id and a name*), `DESIGN_DECISIONS.md` (*A project id holds no `_`*), the `projectIdentity.php` header |
| `secure/management/command/generateConsentLayer.php` and every writer of `data/consent.json` (`saveConsentConfig()`: also `generateCookiePolicy`, `deleteCookiePolicy`) — **a generated consent layer stays on**: `generateConsentLayer` is the one writer of `enabled` and writes true; no command writes false, so a site that declares a non-essential key keeps asking its visitors. **`generateConsentLayer` is also the one writer of `essentialBanner`**, and only when its body holds it. A new writer of the file keeps `enabled` and `essentialBanner` as it found them | the `generateConsentLayer` `help.php` entry, `ADMIN_PANEL.md` §9.10, `DESIGN_DECISIONS.md` (*A generated consent layer stays on*, *A site with nothing to ask shows no banner…*) |
| `secure/src/functions/consentHelpers.php` `qs_consent_asks()` — **the one place that says whether a site asks its visitors anything**: a non-essential key declared, or `essentialBanner` true. Its readers take its answer and restate nothing: `qs_consent_payload()` (the page's `asks`, live and, through `consent-runtime.json`, in a build), the answers of `generateConsentLayer` and `getConsentStatus`, and from those `qs.js` (`_consentNeedsChoice()`, `_consentFollowRegistry()`) and the Storage dialog (the result line, the **Ask visitors again** box). The dialog's offer shows while no non-essential key is declared (`_declaredNonEssentialCategories()`), which is not the rule. **`qs.js` compares `asks` strictly:** a payload without it keeps asking. **What carries `data-consent-asks`** is written by `buildCookiePolicyStructure()`: the preferences button's paragraph, the consent record's row (`onlyWhenAsking` in `consentEngineCookies()`) and the prefix note when only that row holds a prefix. A new element that means something only on a site that asks carries it and is always written: the panel generates the policy before the layer, so a test at generation would lose it, and a key declared later could not bring it back. **A popup has one row per `CONSENT_TOGGLE_CATEGORIES`**, hidden when its category has no key, and `_consentFollowRegistry()` shows a row exactly while its category has one. With no `window.QS_CONSENT` nothing moves, which the editor relies on. `_consentInjectStyles()` holds the rule that keeps a hidden row or carrier hidden whatever an author's stylesheet says, and injects for a carrier on a page with neither the banner nor the popup. **A carrier valued `false` is the other way round:** it shows only on a site with nothing to ask, and the table with its `qs-cookie-policy__none` line is the case: when the record's row is the table's only row, `buildCookiePolicyStructure()` writes the attribute on the table and the line beside it, the line hidden, so a site with the layer off shows the table. **`_consentDropRecord()` removes the engine's own record at load when `asks` is false**, under the name and Path `QS.setConsent()` writes: a change to one is a change to both, and it touches no other cookie | `ADMIN_PANEL.md` §9.10 and §10 (the attribute table), `ARCHITECTURE.md` (the `window.QS_CONSENT` row and paragraph), the `help.php` entries of `generateConsentLayer`, `getConsentStatus` and `generateCookiePolicy`, `qsDataAttributeCatalog.php`, `DESIGN_DECISIONS.md` (*A site with nothing to ask shows no banner…*) |
| `secure/src/functions/runtimeHandoff.php` `qs_theme_storage_key()` — **the theme switch's key, one spelling**: both page shells read it (`Page.php`, `PageManagement.php`), the toggle script writes it, and the generated cookie policy lists it (`buildCookiePolicyStructure()`, when theme mode and the visitor toggle are both on: essential, local storage, kept until cleared, set only by a click). It stays outside the registry and the consent rule; anything else QuickSite stores for a visitor outside the registry is listed in the policy the same way | the `generateCookiePolicy` `help.php` entry, `ADMIN_PANEL.md` §9.10 (the cookie policy) |
| `secure/src/functions/consentLayerHelpers.php` `consentEngineCookies()` — **what keeps the policy's list true: a cookie QuickSite sets for a visitor outside the registry joins `consentEngineCookies()`**, named by what names it (`qs_project_cookie_name()`, `QS_CONSENT_COOKIE`, `QS_OAUTH_COOKIE`, `QS_OAUTH_SESSION_COOKIE`, all in `requestRuntime.php`; `qs.js` holds the consent record's bare name too, under the same constant name), its two texts under `consent.policy` in `<secure>/src/translations/<lang>.json` (`en` always; a language with no file takes English). **The words state retentions the code sets:** `qs.js` `QS.setConsent` (180 days), `OAuthHandler::SESSION_TTL_SECONDS` (14 days), `_oauthEnsureSession()` (until the browser closes): a change there changes the words. A text is written only where a key has none, in every file `qs_starting_text_targets()` names, so an author's own stands. The OAuth rows show when `consentOAuthLinks()` lists a provider, the section's own test. The panel's `QSSESSID` and `qs_form_token` are not listed: a visitor of the site never gets them | the `generateCookiePolicy` `help.php` entry, `ADMIN_PANEL.md` §9.10, `DESIGN_DECISIONS.md` (*A generated cookie policy names the cookies QuickSite sets…*) |
| `secure/src/functions/storageHelpers.php` `validateStorageItem()` — **one rule for a storage-registry item**, its key included (no whitespace, never `__proto__`, which a page's consent map would lose): `addStorageItem`, `editStorageItem` (a rename too) and the import's archive gate (`importFirstInvalidStorageItem()`, refusing the whole archive) apply it. A new writer of `data/storage.json` applies it | the `help.php` entries of `addStorageItem`, `editStorageItem` and `importProject` |
| `public/admin/assets/js/pages/preview/preview-js-interactions.js` (state-store wizard init source kinds) | `ADMIN_PANEL.md` §9.6 (init source kind picker) |
| `secure/src/functions/interactionHelpers.php` `getAvailableEventsForTag()` (the buckets `listInteractions` answers) and the events the interaction commands accept (`UNIVERSAL_EVENTS`, `TAG_SPECIFIC_EVENTS`) — **the editor offers the Common and Less common buckets only** (`populateEventDropdown()`): the commands refuse every Advanced event. An event moved into an offered bucket must be one the commands accept, and the tooltips — `en.json`'s `preview.eventTooltips`, `EVENT_TOOLTIPS_FALLBACK` in `preview-js-interactions.js` — are exactly the accepted events | `ADMIN_PANEL.md` §8.1 (the Interactions row) |
| `public/admin/assets/js/pages/preview/preview.js` `switchFor()` — **the one rule of what is outside the Edit list's target.** `showNodePanel()` asks by it, and `switchMode()` drops by it a selection another tool made, so the outline, the Edit list and Select's actions never disagree. A new kind of target or of structure in the frame answers here. An Add names the struct selected (`parseStruct(selectedStruct)`), never the Edit list's target | `ADMIN_PANEL.md` §8.1 (*Picking a target*) |
| `public/admin/assets/js/pages/preview/preview.js` `refreshEditList()` — **the one rebuild of the Edit list, and of the toolbar's languages.** It reads `getRoutes`, `listComponents` and `getLangList`, moves the page's language state (`rebuildLanguageList()`: `multilingual`, `defaultLang`, the two `PreviewConfig` copies, the Language list, which `toolbar.php` therefore always draws, hidden unless there are two languages to pick; navigates when the frame's address changes), rebuilds the Pages group (the routes, then `PreviewConfig.specialPages`, which `preview.php` lists and `preview-config.php` hands over) and the Components group, keeps the target or leaves it (`leaveVanishedTarget()`), and sends `quicksite:routes-changed`. **A new way to change a route, a component or a language on this page calls it, before the preview reloads** (today `addComplexNode()` after a wizard's `preSubmit`, and the runner's `_runSteps()`): it answers whether the editor moved, in which case the preview is already loading. **A new copy of the project's languages on this page is reset by `rebuildLanguageList()`** (today Translate from CSV's list). **A new copy of the routes on this page listens to `quicksite:routes-changed`** (today `availableRoutes`, the datalist in `route-input.js`, the OAuth wizard's caches). **Code that sets the list's value goes through `showEditTarget()`:** the searchable select reads its select only when told, or the closed list shows the old target. **The list spells a route as `flattenRoutes()` does** (`utilsManagement.php`), in the print and in the rebuild, and a path is always text: a route named with digits only is an integer key once PHP has it. A move in the AI tools passes `keepMode` to `navigateTo()`; a read that fails changes nothing and shows nothing | `ADMIN_PANEL.md` §8.2 and §4, `DESIGN_DECISIONS.md` (*The Edit list follows what changes under it…*); and, for `flattenRoutes()`, the `getRoutes` `help.php` entry |
| `secure/src/functions/apiRegistry.php` `qs_api_forwarded_cookies()`, `QS_ENGINE_COOKIES`, `QS_ENGINE_COOKIE_PREFIX`, `qs_cookie_name_is_engine()` and `qs_api_jar_may_reach()` — **the one place a visitor's cookies leave the server.** `serverFetch.php` calls it for a `cookie` API, `testApiEndpoint` sends no cookie, and a new server-side call that carries a visitor's cookies goes through it. **A new cookie the engine sets joins the list or starts `qsp_`:** every `setcookie()`, `session_name()` and `document.cookie` write today names `QSSESSID`, `qs_form_token`, `qs_oauth_session` or a `qsp_` name (`qs_project_cookie_name()`, `_cookieName()` in `qs.js`). The names are spelled there because a build carries `apiRegistry.php` and not the files that set them. `qs_api_jar_may_reach()` refuses plain `http` from a page served over `https` on every surface, by `_oauthIsHttps()` (`oauthStateStore.php`, which a build carries), the one test that also hears a proxy; it knows a build by `QS_BUILD_VERSION`, and everywhere else the cookies are the installation's, so they go only to the page's own host | `ADMIN_PANEL.md` §9.5 (*A server call*), `ARCHITECTURE.md` §9.4 (the table's row), `DESIGN_DECISIONS.md` (*The engine's own cookies never leave a server call…*), and the release notes: a deployed build holds its own copy and takes a change here only when it is rebuilt |
| `secure/src/functions/apiRegistry.php` `QS_API_TOKEN_LOCATIONS` — **where each auth type takes its token from, the one list:** `ApiEndpointManager::validateAuth()` refuses any other pair, and `apis.php` writes it onto the location options (`data-auth-types`), which `apis.js` `_offerTokenLocations()` offers. A new location is added there, as an option in `apis.php`, and, when it names a storage key, in `STORAGE_PREFIXES` in `apis.js`; and something must read it, in `serverFetch` or in `qs.js`. A storage prefix takes the picker; any other is typed. `serverFetch` refuses an `apiKey` whose source is not a header rather than call bare. **Saving with no name is refused by the page**, never given a name of its own: an undeclared key is never stored under a consent layer. A saved pair the type does not read opens on the first location with no name (`_tokenSourceField().load()`). The import's fixed keys (`openapi-converter.js`, the `testapi-filemanager` format in `apis.js`) are not declared by it, and `_importKeyNotes()` names each undeclared key in the preview, for every format | `ADMIN_PANEL.md` §9.1, the `addApi` / `editApi` help entries, `DESIGN_DECISIONS.md` (*The last typed storage keys take the picker…*, *A token source's location is what reads it*) |
| `secure/src/functions/apiRegistry.php` `qs_api_effective_auth()` and `QS_API_ENDPOINT_AUTH_WORDS` — **the one reading of an endpoint's `auth`:** `serverFetch`, `testApiEndpoint` and `ApiEndpointManager::compileToJs()` call it, which gives the browser an endpoint's `auth` when it differs from its API's, and `qs.js` `resolveEndpoint()` prefers that. **A reader that cannot call it reads the same:** `apis.js` `_endpointAuthType()` (the card, the Test panel, the form through `_showEndpointAuth()`), `sitemap.js` `_normalizeAuthValue()` (its cache badge). `validateEndpoint()` refuses anything else at save. `qs_api_effective_callable_from()` reads only an object's type on purpose: `none` under an API-key API stays server-only | `ADMIN_PANEL.md` §9.1 (*What a call carries*), `ARCHITECTURE.md` §9.4, the `editApi` help entry (`addEndpoint`), `DESIGN_DECISIONS.md` (*An endpoint's auth means one thing on every path*) |
| `secure/src/runtime/qs.js` `getTokenValue()`, `setStoredToken()` and `applyCsrf()` — **a page's token only ever comes from, and goes to, a slot under `qsp_<project>_`:** a read and a write go through `_storageGet()` / `_storageSet()`, a page with no project id has no token, a credential is never a literal in the registry, and a CSRF cookie named `qsp_…` is the engine's, refused by `validateAuth()` and ignored at a call. A new way for a token to enter a request reads through these. **The Test panel's tokens are the panel's** (`apis.js` `_authTokensKey()`): `QuickSiteStorageKeys.apiAuthTokens` + `:<project>`, which no page reaches, as every name a page uses starts `qsp_`. **No panel code reads a name under `qsp_`:** `QuickSiteUtils.storageKeyPrefix()` is for showing it | `ADMIN_PANEL.md` §9.1, §9.5 and §6, `DESIGN_DECISIONS.md` (*A token comes from the project's own namespace and nowhere else*, *The Test panel's tokens live in the panel's own namespace…*) |
| `public/admin/assets/js/pages/api-import/response-draft.js` `draftResponseSchema()` — **a draft holds types and names and never a value:** a response schema is compiled into the client's config, which a visitor can read, and a test response may not be public. **What it writes is what the readers walk:** `_flattenSchema()`, `_itemFieldsFor()`, `schemaPaths()` (`preview-js-interactions.js`) compare `type` to `"object"` exactly, so a null stays out of `type`; `_generateSampleFromSchemaPath()` (`resolverHelpers.php`); `_validateSchemaShape()` (`apis.js`), which asks an object for `properties`. An empty `properties` comes back from a save as an empty list, which it reads as empty. **The limits** are its named constants (`DRAFT_MAX_DEPTH`, `DRAFT_MAX_ITEMS`, `DRAFT_MAX_FIELDS`, `DRAFT_MAX_NAME`), and the panel's words read them through `DRAFT_LIMITS`, as `ADMIN_PANEL.md` states them. A new reason it reports needs its words in `_renderDraftNotes()`. `testApiEndpoint`'s `response.body` decodes through PHP arrays, so an empty object and an empty list arrive alike. **`apis.js` `_renderDraftOffer()`** offers on `success` and `isJson` alone | `ADMIN_PANEL.md` §9.1 (*A test response becomes a draft response schema*), `DESIGN_DECISIONS.md` (*A test response gives a draft, never a schema*) |
| `public/admin/assets/js/pages/apis.js` `_authToSave()` and `AUTH_FORM_KEYS` — **the API form keeps what it does not show:** `editApi` replaces `auth` whole, so Save starts from what the saved `auth` holds beyond `AUTH_FORM_KEYS`, while the type stays what it was. A new field the form shows joins the list, or a save sends the old value beside the new one's absence; a key the form never shows needs nothing. **An API's id is fixed on an edit:** the field is read-only and Save sends `editApi` alone, never `deleteApi` first, which also removes what refers to the API. **`canRun()` and `apis.php`'s `data-requires-command` hide what a rank cannot run** (`QuickSiteAdmin.hasPermission()`, the cards drawn once `permissionsReady` has settled); a new control names its command, and the command's own refusal stays the authority. **`_carriesToken()`** is what a call takes a token under: the card's row (`_tokenRowType()`) and the Test panel read it, as `testApiEndpoint` does | `ADMIN_PANEL.md` §9.1 |
| `public/admin/assets/js/core/api.js` `request()` — **every command answers an envelope, so an empty 2xx is `client.empty_response`:** a command that answers nothing on success is a command this rule breaks. A command that answers bytes goes through `downloadFile()`, and a text answer is never empty (`getSiteMap/text` ends in a line break) | `ADMIN_PANEL.md` §5.1 |
| `getRoutes`'s `routes` — **a level is a map of names, which JSON makes a list when its children are named 0, 1, 2 and so on:** a reader walks its keys and never asks whether it is a list. `flat_routes` and `root_routes` are always text, and every reader that can use them does; `complex-oauth-button.js` `_flattenLiteralRoutes()` is the one that walks `routes`. The shape is unchanged on purpose: it is a public answer | `help.php` `getRoutes`, `DESIGN_DECISIONS.md` (*A route named 0 is read from `routes` by its key…*) |
| `public/admin/assets/js/pages/sitemap.js` the resolver form's kind list and `secure/src/functions/resolverHelpers.php`'s hints — **`session:` is accepted and never offered:** `resolverRuntime.php` gives a resolver no session, so it reads as nothing. The form shows it only on a row saved with it, and saves it as it was; a hint names only what reads something. When the runtime gives a resolver a session, the form and the hints offer it again | `ADMIN_PANEL.md` §9.7, `ARCHITECTURE.md` §9.4 |
| `public/admin/assets/js/pages/apis.js` `handleImportConfirm()` — **an import says what the server refused:** an API that exists is replaced in one `editApi` call with its `endpoints`, whole or not at all, which `ApiEndpointManager::editApi()` validates before it saves and whose left-out endpoints `editApi.php` clears as `deleteEndpoint` does; never `deleteApi` first, which also removes what refers to the API. A new API is added, then its endpoints one by one, each refusal named | `ADMIN_PANEL.md` §9.1 (*After import*), the `editApi` help entry (`endpoints`), `DESIGN_DECISIONS.md` (*An import says what it cannot do, and a replacement is whole*) |
| `public/admin/assets/js/pages/preview/preview.js` — **what runs while the script loads calls no command:** `QuickSiteAdmin`, the API client, is defined by `admin.js`, a later script of the page, so a first read waits for `DOMContentLoaded` (the title field's does) | None |
| `public/admin/assets/js/pages/preview/preview.js` `setDevice()` and the device frame — **the frame keeps the size it is given, and the room around it scrolls.** `setDevice()` writes the frame's size inline from the device's own last size; `FRAME_SIZES` (each device's common sizes, its default marked) and `FRAME_MIN` / `FRAME_MAX` (200 × 200 to 3840 × 10000) are the one place of the sizes and the limits, and the list, the two boxes, rotate, the handles and a stored size all go through `setFrameSize()` and `clampFrame()`. The device and both sizes are `QuickSiteStorageKeys.previewFrame`, read in a `try` and kept part by part (`readStoredFrame()`): a new part of the key keeps that rule. **The three views are the stylesheet's alone:** `responsive.css` shows a Tablet at its own width on a tablet and every device at its own width on a phone, with the room and the frame as they are with no device, and no bar or handle; no script reads a width. The handles drag through `QSDom.followPointer()` (`js/core/dom.js`), the options panel's handle's routine too, and `data-sizing` on the container takes the transition and the frame's pointer away while one drags; a new handle that drags a size goes through it. `main-area.php` draws the bar and the handles, and the script fills the list and the boxes | `ADMIN_PANEL.md` §8.3, §6, §5.1 (`dom.js`); `DESIGN_DECISIONS.md` (*The editor's device frame keeps the size you give it…*) |
| The panel's stylesheets (`public/admin/assets/admin.css`, `public/admin/assets/css/*.css`) — **a class that sets `display` on an element a script shows and hides through `hidden` needs its own `[hidden]` rule** (`.members-policy-advisory[hidden] { display: none; }`): an author's `display` beats the browser's `[hidden] { display: none }` | None |
| `public/admin/assets/js/pages/preview/preview-style-source.js`: when Source reads `style.css` (every opening, and before every save), the conflict notice (`main-area.php`), the draft's shape (its base and its project), and when the preview carries Source's copy. **Also any panel code that writes the whole stylesheet with `editStyles`:** it reads the file before it writes, and writes only over the version its user has seen. Today that is Source; the CSS Refiner (`optimize.js`) does not yet. **An empty save asks first** (`confirmEmptySave()`: Save, and Overwrite before the conflict notice goes), in the panel's dialog: `editStyles` accepts an empty file | `ADMIN_PANEL.md` §8.10 (*What it shows*, *How a save checks first*, *Live preview*, *Draft persistence*, *Dirty guards*, *An empty stylesheet is asked about first*), `DESIGN_DECISIONS.md` (*A whole-file stylesheet editor reads the file again…*), and §6 if the draft's key changes |
| `public/admin/assets/js/pages/preview/preview-style-selectors.js` `isLoaded()` — **the Style tool opens a tab's load only while that tab's module answers `isLoaded()` false** (`openStyleTab()` in `preview.js`), so the selector browser answers whether its **list is drawn**, not whether its data is in: the Interactions tool and Motion's apply-keyframe picker load the data alone, through `loadData()`, which answers at once once the data is in. A module behind a Style tab keeps the same meaning for its own `isLoaded()` | None |
| `public/admin/assets/js/pages/preview/preview-transition-editor.js` `saveTransition()` — **Save writes only what the author asked for:** first what an open add row holds (`addPendingProperties()`, both rows read before either is added, as an add re-reads the rule and rebuilds them), then `transition` only when `buildTransitionValue()` differs from its value when the editor opened (`transitionEditorOpenedValue`). **`parseExistingTransition()` and `buildTransitionValue()` round-trip what the controls hold**: a single transition of `all` as All Properties' one set, anything else as a Specific Properties row per transition, each with its own settings (`all` inside a list as one row's property; the units, any order, a `cubic-bezier()`), and a timing the picker lacks kept in its row as written (`_setTimingControl()` adds it as one more choice of that row's list). A row is `transition.php`'s `<template id="transition-row-template">`, cloned by `_renderTransitionRow()`; its picker is `QSPropertySelector`, each row's leaving out the others' properties (`_refreshRowExclusions()`). The rule's transition is read on opening only (`loadTransitionSelectorStates(selector, true)`): a reload keeps the author's rows and settings | `ADMIN_PANEL.md` §8.11 (the State & Animation Editor paragraph) |
| **Any code under `public/admin/assets/js/pages/preview/`** — **no file writes markup:** no `innerHTML`, `outerHTML`, `insertAdjacentHTML` or `srcdoc` write, no clear through `innerHTML = ''` (`replaceChildren()` clears). The one exception is `preview-iframe-inject.js`'s three parses of the HTML the engine itself rendered (`addNode`, `editNode`, `duplicateNode`). A builder uses `QSDom.el` / `createElement`, and `preview.js`'s shared builders (`_renderOption()`, `_renderParamRow()`, `_renderPanelError()`, `_renderSelectorNote()`…) sit together: a second copy of one is the duplication `CLAUDE.md` warns about. **An icon goes through `QSDom.iconEl` with a constant** (a `QuickSiteUtils.ICON_PATHS` entry or a local inner-markup constant), never a value from data: it parses its argument as markup | `DESIGN_DECISIONS.md` (*The visual editor parses no markup but the engine's own…*) |
| `public/admin/assets/js/pages/preview/preview.js` — **the snippet preview's builder** (`snippetText()`, `snippetAttributeValue()`, `snippetPreviewNodes()`, `_renderSnippetElement()`, `snippetTableRows()`) mirrors `JsonToHtmlRenderer`'s `renderTextNode()` / `renderAttribute()` (the `__RAW__` / `__LIT__` prefixes, the boolean rule; the translatable attributes and the ones written when empty are `TagRegistry`'s two lists, read through `TAG_INFO`) and reads `TAG_INFO` for the allowed, blocked and void tags: a change to one is a change to the other. The frame's document stays `_snippet-selector.php`'s fixed `srcdoc`, filled with the frame's own `createElement`, sandboxed without scripts; no HTML string reaches it | `ADMIN_PANEL.md` §8.1 (*The snippet preview*), `DESIGN_DECISIONS.md` (*The snippet preview is built in its frame element by element…*) |
| `secure/admin/templates/pages/preview/_tag-examples.php` and `_tag-selector.php` — **the tag examples:** each example is a whole fragment (a `<tr>` inside its `<table>`, a list item inside its list), every translation passes through `adminEscape()` where it enters the markup, and `_tag-selector.php` writes one `<template id="<selectorId>-tag-example-<tag>">` per example that is not `false`. The code view reads the template's markup, so a change to an example changes what its **`</>`** button shows | `ADMIN_PANEL.md` §8.1 (*The tag preview*), `DESIGN_DECISIONS.md` (*A tag's example is a template the page parses once…*) |
| `public/admin/assets/js/pages/preview/preview-iframe-inject.js` `renumberSiblings()` — **every live insert, removal or duplicate renumbers through it, inside one structure** (`[data-qs-struct]`): the page, the menu and the footer each number from 0, so a live update that renumbers on its own can move another structure's ids | `ADMIN_PANEL.md` §8.5 |
| `public/admin/assets/js/pages/preview/contextual-complex/wizard-text.js` — **the wizards' words:** every wizard string goes through `QSComplexWizard.t()` with its key under `complexWizard.<kind>`, and `preview-config.php` ships the whole `complexWizard` subtree as `QS_COMPLEX_WIZARD_I18N` through `qs_inline_script_json()` | `ADMIN_PANEL.md` §8.7 (the `QSComplexWizard.t` row) |
| `public/admin/assets/js/pages/preview/contextual-complex/storage-key-picker.js` `createStorageKeyPicker()` — **the one picker for a storage key, and the one Create in the panel**: a field that names a storage key mounts it, or the key can be typed and an undeclared key is never stored under a consent layer. `setValue()` never calls `onChange`: showing a key changes nothing. A new reason a saved key is not on the list gets its row's words here (`savedKeyWords()`), and the key is still shown and kept. **The parameter editor's widget** (`data-attr-picker.js` `renderStorageSpecWidget()`) composes nothing on render and nothing with no key, and with a key always composes: a cleared value is read as a removal by Edit Params' diff. **The Interactions tool's row** (`_renderStorageKeyArg()`) keeps its hidden input as the value the collectors read, as the translation key's and the response path's rows do: both editors' pre-fills (`editInteraction()`, `editPageEventEntry()`) go through `_prefillHiddenArg()` to the row's `setValue()`, or the field reads empty over a filled input; a new row of that kind gets its handle there. A post-fetch action's row writes the input straight after building it, which the row's first tick shows. **A state store's start value** (`_renderSsFieldRow()`) keeps `.ss-field-init-key` as what the collector reads, each typed kind its own value and the two storage kinds one key (`_ssInitNamesStorageKey()`): nothing is carried over as something else, or it saves unnoticed. **The API page's token sources** (`apis.js` `_tokenSourceField()`) do the same across their location, mount the picker again at each opening so the registry is read afresh, and stop Enter in its Create form, which sits inside the API's `<form>`; the page's Escape leaves an open list to close itself, or the modal goes with it. **A page outside the editor that mounts it** loads `wizard-text.js`, the picker and `QS_COMPLEX_WIZARD_I18N` with `complexWizard.storageKeyPicker` alone (`apis.php`); the layout gives it the rest. A description goes in the language `listStorageItems` answers (`descLang`). **`QuickSiteUtils.storageKeyPrefix()` / `renderStorageKeyPrefixChip()`** (`core/utils.js`) are the prefix's and the chip's one home in the panel, read by the picker and the Storage page, kept in step with `QS_STORAGE_PREFIX` in `qs.js`; the prefix is never stored | `ADMIN_PANEL.md` §9.10 (*The storage-key picker*), §10 (*The storage attributes' widget*), §9.6, §9.1 and §8.7 (the primitives table), `DESIGN_DECISIONS.md` (*A storage key is picked or created, never typed*, *The last typed storage keys take the picker…*) |
| `public/admin/assets/js/pages/command-form.js` `_fillFromPicker()` and `_initCommandPicker()` — **a date picker opens empty and fills its field on a change**: the field is what the form sends, and a picker that showed a date beforehand could not be picked. **A command name lists the index's commands** (`QS_COMMAND_GROUPS`, from `getCommandCategories()`, which a new command joins anyway): typing stays open, as a log keeps a removed command's name. **A list that shows its own label re-reads its select after the form's Reset**, which fires no change | `ADMIN_PANEL.md` §9.17 (*What a parameter's field shows*) |
| `public/admin/assets/js/pages/command-form.js` `_isJsonType()` — **what the console sends as JSON**: a parameter `help` types `object`, `array` or `object\|array` is a JSON box, so its value reaches the command parsed. Any other type is sent as text, unless a field's name makes it a JSON editor (`array/object` for `editStructure`'s `structure`). A new mixed type in `help` is read against this first: `setRouteResolver`'s `object\|array` went out as a string, which the command refuses | `ADMIN_PANEL.md` §9.17 (*What a parameter's field shows*) |
| `public/admin/assets/js/pages/command-form.js` `_renderNotPermittedNotice()`, `command.js` `showDisabledCommands()`, `admin.js` `filterByPermissions()`, `AdminRouter::commandExists()` — **a name no command has is told from a command the role does not grant**: the router answers 404 and `command.php` draws no form for a name `getAllCommands()` lacks, so the rest of this row is only ever about a command that exists. **A command the role does not grant**: `filterByPermissions()` hides every `[data-command]`, and `#command-form` carries that attribute, so the form goes and the notice stands in its place. A greyed row in the index stays a link, its reason in its `title`. **`commands.*` words reach `QuickSiteAdmin.t()` only through `layout.php`'s list**, one line per word: a key that is not listed shows as its own path | `ADMIN_PANEL.md` §9.17 (*A command your role cannot run*, *An address that names no command*) |
| `public/admin/assets/js/pages/storage.js` `_runConsentGenerate()` — every outcome ends in a toast as well as the dialog's line: yellow when there is no page (the route left empty, an overwrite declined, the page's command refused), green otherwise. The line's last sentence follows the command's `asks`: it never says to look for a banner that will not show. `essentialBanner` is sent only when the offer was on screen, since absent keeps what is stored | `ADMIN_PANEL.md` §9.10 |
| `secure/src/functions/startingTexts.php`, the word files `secure/src/translations/<lang>.json`, `ComplexElementBuilder::startingTexts()` and `addComplexElement`, and `generateConsentLayer`, `generateCookiePolicy` and `generatePrivacyPolicy` for what they generate (`qs_starting_words_seed()`: the keys are what the English word file holds under the trees each names, a key and its word the same dot path, so a word added there is written into a site at its next generation, and one removed leaves a marker) — **a starting text**: a builder answers one for each key it names, and the command writes it only where the key has no text, into the files the project's pages read (`qs_starting_text_targets()`: never a new language file), checked with the structure in one size check. **A word QuickSite writes into a site lives in the word files, read by key** (`qs_starting_word()`), never in code: a new word goes into every file (`en.json` and `fr.json` hold the same keys), a new language is a new file. **A language code names a file only once it passes `language_code`** (`RegexPatterns`): a translation file to write (`qs_starting_text_targets()`, whose list a hand edit can fill with anything) and a word file to read (`qs_starting_words_file()`). A builder whose element names a key it does not answer a text for leaves the missing-translation marker on the page | `ADMIN_PANEL.md` §8.7 (*Starting texts*) and §9.5 (the OAuth button's step 3), the `addComplexElement` `help.php` entry, `DESIGN_DECISIONS.md` (*A key a complex element names gets a starting text…*), `PROJECT_STRUCTURE.md` (the `src/` tree) when the folder moves |
| `secure/admin/templates/pages/preview-config.php` — **the editor's words:** a key the editor reads as `PreviewConfig.i18n.<name>` needs an `en.json` entry **and** its line here (`qs_inline_script_json(__admin('preview.<name>'))`, no fallback); without the line the panel shows `undefined`, or the code's fallback, whatever the language — never the key's path. The `complexWizard` subtree is the exception: it ships whole | None — but **REQUIRED** for every new key the editor reads |
| `public/admin/assets/js/pages/preview/preview-translation.js` `enter()` / `_load()` — **the Translations panel fetches its data at every opening** (the loading state on the first only, the rows kept on screen after), keeps the scope chosen, and drops an older load's answer that arrives after a newer one's (`_loadSeq`) | `ADMIN_PANEL.md` §8.9, `DESIGN_DECISIONS.md` (*The Translations panel fetches its data at every opening*) |
| `public/admin/assets/js/core/bubble.js` / `public/admin/assets/css/bubble.css` — **a tooltip in the panel is a `title`**, in a template or written by a script, and the bubble shows it (hover, keyboard focus): a new tooltip needs nothing else. While the bubble shows a title the element's `title` reads `""`: code that reads a title it did not just write does it outside a hover or focus handler. A title that must stay the browser's sits inside `data-qs-native-title` (a visitor's markup); an `<iframe>`'s and an `<option>`'s always do. **A list whose entries need a tooltip is a `QSSearchableSelect` with `data-description`, never a native `<select>` with option titles:** the browser draws a native list outside the page | `ADMIN_PANEL.md` §2 (the boot order), §5.1 (*Tooltips: the panel's bubble*), §9.9.2; `DESIGN_DECISIONS.md` (*Every title in the panel shows through the panel's own bubble…*) |
| `public/admin/assets/js/core/searchable-select.js` — an option's `data-description` is its item's line and its title; code that sets the wrapped select's `value` without a `change` calls the picker's `refresh()` (the kind and event pickers do), which also mirrors the select's `disabled`. **The trigger takes the select's name** — its `aria-labelledby`, else its `aria-label`, else its `<label for>`, which the widget points at itself so a click on the label opens it (`destroy()` points it back): a select the panel wraps has one of the three. Its words are `common.*`, which `layout.php` ships on every page. **It has no minimum width of its own**, or it overflows the sidebar's columns: a place that needs one gives it (`admin.css`: the toolbar's Edit list and the header's project list, each 200px). **`currentOnTop`** draws the chosen option as the dropdown's first line and leaves it out of the rows; only a list whose trigger may cut the name asks for it (the header's project list). **`currentCopy`** gives that line a button copying the option's value (the same list). **`fitOptions`** makes the dropdown as wide as its longest row, for a list of short names and no descriptions (the editor's Edit list): a long description would push a dropdown across the page, which is why a list keeps its trigger's width by default | `ADMIN_PANEL.md` §9.9.2, §7 |
| **A project list in the panel** — a dropdown of the account's projects is the searchable select (`QSSearchableSelect`), ordered by `QuickSiteAdmin.orderProjectSelect()` (a list of rows by `starredFirst()`) and ordered again on `quicksite:stars-changed`; a new one does the same, or it shows starred projects in name order | `ADMIN_PANEL.md` §8.0, §9 (the Dashboard row), §9.12 |
| `public/admin/assets/admin.js` `urlSegments()` and the console's submit — **a URL field travels encoded:** each piece between two slashes is encoded on its own, the slash kept as a separator (`getStructure` and `getPageEvents` take a nested page name in several segments). A command that decodes its segments once more after the dispatcher has marks its fields `data-url-encode="twice"` in its own console form (`command-form.js`; today `getStyleRule`), and any panel code that reads it calls the same encoder (`QuickSiteAdmin.styleRuleSegments()`, the editor's three readers); a new command that decodes again does both, or a `+` reaches it as a space and a `/` splits its value | `ADMIN_PANEL.md` §9.17 (*What a parameter's field shows*), the command's `help` notes |
| `public/admin/assets/js/pages/preview/preview-style-selectors.js`, `preview-style-editor.js`, `preview-transition-editor.js` — **a rule is a selector and its `@media` block**: a chip carries its query (`data-media`), and every read and write of the three editors carries it (`styleRuleSegments()`, `mediaQuery`). A new action on the selected rule does the same | `ADMIN_PANEL.md` §8.1 (*The Selectors tab and `@media`*), `DESIGN_DECISIONS.md` (*The Selectors tab's chip is one rule…*) |
| `help.php`'s `error_responses` and `success_response` — **an entry lists what its command answers, and nothing else.** A code in the command's file or a helper it calls gets a line, and a code that goes gets its line removed. The shared layer (no session, no permission, no marker, the rate limit) is named once, in `help`'s own `authentication` and `limits`, and an entry need not repeat it. An `example*` is a request a reader could send: run it. | `COMMAND_API.md` (the command tables) |
| A command's message or `errors[].usage` that names an address | **It names the one the dispatcher takes:** `/management/p/<projectId>/<command>` on a project-scoped command. Without the marker, a reader who follows it gets `400 project.required`. |
| `public/admin/assets/js/pages/command-form.js` `renderCommandDocs()` and `help.php` `commandsList[].example` — **an entry's example is any key starting `example` that holds a string:** the card shows each, `commandsList` the first. A new verb's example needs no edit to either | `ADMIN_PANEL.md` §9.17 |
| `public/admin/assets/js/pages/command-form.js` `renderParameterForm()` and `help.php`'s `required` values — **a parameter's `required` is `true`, `false` or `'conditional'`** (needed by some requests, its description saying which): the console lists a conditional one under *Required in Some Cases* and never makes the browser require it, or a request of the other kind could not be sent. **A parameter whose `help` lists its values (`enum`) is a list of exactly those** (`renderFormField()`), so a new fixed set needs only its `enum` in `help.php`. **The tag fields** (`addNode`, `editNode`) list `command-form.php`'s `QS_TAG_GROUPS`: `TagRegistry::getUICategories()`, and every other `ALLOWED_TAGS` entry in its Other group — a tag added to `TagRegistry` reaches the console with nothing else to edit | `ADMIN_PANEL.md` §9.17 (*What a parameter's field shows*) |
| `public/admin/assets/js/core/utils.js` `openModal()` / `openDialog()` / `confirm()` / `prompt()` and `admin.css` `.admin-modal-overlay--dialog` — **`openModal()` is the panel's one modal** (the layer, the focus, Tab, the keys, the focus given back): `confirm()`, `prompt()` and the mode chooser draw their content in it, and a new modal question does too rather than building a second. **The panel never calls the browser's `confirm()`, `prompt()` or `alert()`**: a question goes through `QuickSiteUtils.confirm()` / `prompt()` (`type: 'danger'` before a delete, a discard or an overwrite) and the caller acts only after the answer (a handler that must decide at once holds the event and acts after a yes); a failure goes to `QuickSiteUtils.showToast(text, 'error')`. The one exception is CSS Source's `beforeunload`, which a page cannot replace. The dialog stays above every panel layer: a new modal or dropdown above the highest one today (the property selector's dropdown) moves the dialog and the bubble above it | `ADMIN_PANEL.md` §5.1 (*Dialogs: the panel's own*), `DESIGN_DECISIONS.md` (*The panel asks with its own dialogs…*) |
| `secure/admin/templates/pages/privacy.php` / `storage.php` and `privacy.js` / `storage.js` — the words of their dialogs and toasts are the `privacy` / `storage` sub-trees of the panel's translations, written whole as one value through `qs_inline_script_json()`; a new word there is a key in the sub-tree, read with the page's own `t()`. `sitemap.php` / `sitemap.js` likewise, with the `sitemapPage` sub-tree as `QS_SITEMAP_I18N`: a page template runs before the layout writes `QUICKSITE_CONFIG`, so a page's own words never go there | `ADMIN_PANEL.md` §7 |
| `secure/src/runtime/qs.js` `_storageKey()` — **every read, write and remove of local or session storage takes its name from it** (`_storageGet()`, `_storageSet()`, `_storageRemove()`): `qsp_<projectId>_<key>`, on a page with no project id `qsp__<key>`, never the bare name. That is the whole of what keeps a page out of the panel's keys, which share its origin (`qs_`, `qs-`, `quicksite_`, `quicksite-`): no name is refused for that reason, so a storage call that skips it, or author JavaScript let back in, is one this boundary does not reach. The one other name a page uses is the theme switch's (`qs_theme_storage_key()`): the server composes it from the project id, and no author names it. The prefix is composed there and stored nowhere: no command adds it | `ARCHITECTURE.md` (*Browser storage is namespaced per project*), `ADMIN_PANEL.md` §6 and §9.10, `DESIGN_DECISIONS.md` (*Browser storage is namespaced per project…*, *A site's storage key may have any name…*) |
| `secure/src/runtime/qs.js` `_onConsentActionClick()` and `QS.openConsent()` — **one listener on the document answers every `data-consent-action`**: each action inside `#qs-consent-banner` / `#qs-consent-popup`, handled once; anywhere else only `customize`, which reopens the preferences and records nothing. A new action is added to the listener's switch and works inside the banner and the popup only, unless it records nothing; the catalogue's `data-consent-action` entry (`qsDataAttributeCatalog.php`) lists the values and says the same | `ADMIN_PANEL.md` §9.10 and §10, `DESIGN_DECISIONS.md` (*Only `customize` works outside the cookie banner…*) |
| `secure/src/runtime/qs.js` — **the consent rule**: `_consentRefusal()` is the one decision, for a write (`_consentAllowsWrite()`, at `setStoredToken()` and `QS.store`) and for a read (`_storageGet()`); **every reader of local or session storage goes through `_storageGet()`** (the auth headers, the refresh token, `_hasValue()` behind `data-auth-show` / `data-storage-show` / `QS.isAuthed`, `data-storage-value`, a state store's seed), so a new reader that calls `getItem` itself is one the rule does not reach. **The clean-up `_consentRemoveRefused()` runs in exactly two places**: `QS.setConsent()` (every way a choice is recorded goes through it) and first in `_qsAuthAndConsentInit()`, before `_initStores()` and the page events' load handlers; it deletes declared keys by name and never lists storage. **A consent counts under its category's fingerprint** (`_consentCurrent()`), the one `qs_consent_fingerprints()` (`consentHelpers.php`) puts in the payload and a build precomputes: a change to what goes into a fingerprint changes when every visitor is asked again. **The payload is written before `qs.js`** (`runtimeHandoff.php`): the gate reads an absent map as a layer that is off, so a script between the two would read a refused value | `ADMIN_PANEL.md` §9.10 (*The consent rule*, *Asked again, per category*), the `generateConsentLayer` `help.php` entry, `ARCHITECTURE.md` (the `window.QS_CONSENT` row), `DESIGN_DECISIONS.md` (*Consent governs reading and keeping what QuickSite stores…*) |
| `secure/src/runtime/qs.js` auth verbs (saveToken / clearToken / refresh / exchangeMagicLink / requestMagicLink / logoutServer) + `applyAuthState` modes | `ADMIN_PANEL.md` §9.5 (auth flows Tier 1+2+3), `ARCHITECTURE.md` §9 (verb count + auth/storage row) |
| `secure/src/classes/CallTransformer.php` `CHAIN_AWAITABLE` list | `ARCHITECTURE.md` §9.0.1 (chain execution rules), `ADMIN_PANEL.md` §9.5 (gotcha at end of Tier 3) |
| `secure/src/classes/CallTransformer.php` (target segments), `JsonToHtmlRenderer::composeCallTarget()`, `JsonToPhpCompiler::callTargetPhp()` and `secure/src/functions/qsVerbCatalog.php`'s `siteUrl` marks — **an argument the catalogue marks `siteUrl` whose value starts with one `/` is composed by the surface's own URL function** (`processUrl()`): a `page` with the base and the request's language, a `resource` with the base alone. `//host`, a backslash after the `/`, a scheme and a relative value are written as authored. A new verb that takes a URL marks its argument, or the rule does not reach it | `ARCHITECTURE.md` §9 (the call-target paragraph), `DESIGN_DECISIONS.md` (*A page is named by its route path everywhere…*) |
| `secure/src/functions/surfaceB.php` (surface-B `/p/<id>/` serving — `/p/` detection, visibility/membership gate, static-passthrough jail, render setup). **Its `REQUEST_URI` rewrite (`qs_surface_b_finish()`) sets `$GLOBALS['__qs_sb']['rewritten']`, which `qs_path_without_base()` reads:** the space went with the marker, so a reader that takes the space off on its own takes it off twice | `ARCHITECTURE.md` §6.1, §7, §8 (per-project serving row), `ADMIN_PANEL.md` §8.0 |
| `secure/src/functions/projectPublicArtifacts.php` (per-project generated-artifact targets + served-base mirror + on-serve regen) / the `public/index.php` `/p/` hook / the `public/management/index.php` per-project `PUBLIC_CONTENT_PATH` override | `ARCHITECTURE.md` §6.1, §7 |
| `secure/src/functions/renderBootstrap.php` (`qs_resolve_public_base()`) — **`QS_PUBLIC_BASE`, the base every in-page URL composes against, is always derived from the request**: on `/p/` the `/p/<id>/` the page is served under; on the management path the install base + `p/<id>/`, for the editor's fragment render. The `QS_PUBLIC_BASE_URL` server variable feeds only the absolute base (`getSiteMap`) | `ARCHITECTURE.md` §7 (the bootstrap diagram and the vhost variables), `README.md` (the vhost variables), the two `secure/deploy/*-vhosts.conf.example` |
| `setup.sh` / `setup.bat` item 3 (the URL space) — **a space's segments: none empty, `.`, `..` or `p`** (`/p/` takes the project from the segment after the first `p`); the two scripts check the same | `README.md` (the two setup tables) |
| `setup.sh` / `setup.bat` item 7 (storage quotas) — `quota_setting()` / `quota_label()` and `QUOTA_LABEL` **say the same thing, and say what applies without `quota.php`** (the size caps and the write rate at their defaults; no storage ceiling, no upload rate); a reader takes a setting from its own line, never the `.example`'s commented line above it | `README.md` (item 7) |
| `secure/admin/AdminRouter.php` `getCurrentProject` (edited project) / `getServedProject` (main) + `secure/admin/templates/layout.php` (project picker) + `secure/admin/templates/pages/preview.php` (iframe target) | `ADMIN_PANEL.md` §7 (`currentProject`), §8.0 (per-user editing) |
| `secure/src/functions/SessionManagement.php` (session boot/establish/destroy/touch, the session ini settings it pins, cookie attributes, save path; the login and registration throttles — `qs_login_throttle_*` / `qs_registration_throttle_*`: what an attempt costs and when it is counted, and the wait an admitted attempt starts, which the sign-in's refusal carries; the store's sweep — `qs_session_sweep*`, `qs_session_response_complete()`: what it removes, that it runs only once a sign-in's response is complete with the session saved first, and its position file; the session cookie's lifetime — `qs_session_cookie_send()`, `qs_session_remember_refresh()`; and the defaults of the throttles and the session knobs) or `AuthManagement.php` `qs_session_auth` / `validateBearerToken` / the generation counter / the login gate `qs_auth_attempt_login` / the password cost and its helpers (`QS_PASSWORD_BCRYPT_COST`, `qs_password_*`). **Code that opens QuickSite's session goes through `qs_session_boot()`:** a `session_start()` of its own makes PHP send the cookie again without a lifetime, and a remembered sign-in loses its 30 days | `ARCHITECTURE.md` §3 (its `auth.php` table too) + §7 (auth rows), `COMMAND_API.md` (*Authentication*), `ADMIN_PANEL.md` §1 + §6, the `login` and `register` `help.php` entries, whose notes state the throttles and their defaults, `secure/management/config/auth.php.example` (the shipped documentation of every default), `secure/tools/README.md` (what the sweep removes and how the script is run), and `DESIGN_DECISIONS.md` (*A sign-in never waits for the session sweep…*) |
| The sign-in doors — `secure/management/command/login.php`, `AdminRouter::attemptLogin()` / `clearToken()` / `attemptSetup()`, `secure/admin/templates/pages/login.php` + `setup.php` (what a sign-in takes and returns — **the refusal that starts a wait carries it**: `retry_after` on the command's 401, `invalid_credentials:<seconds>` from `attemptLogin()` (the second line of the login page's alert), `invalid_token:<seconds>` from `attemptSetup()` (the second line of the first-run page's alert), and `retry_after` on the 401 of `/admin/self`'s two credential checks — what the first-run form asks for, and what each door writes to the security trail — the panel's must stay the commands' events and payloads). **A door calls `qs_session_establish()` and then writes its response;** it never writes to the session from a shutdown function registered after that call, because the sweep the call may schedule saves and closes the session before it completes the response | the `login` `help.php` entry, `COMMAND_API.md` (*Authentication* **and** *The security trail*), `ADMIN_PANEL.md` §1, `ARCHITECTURE.md` §3, `DESIGN_DECISIONS.md` (*A wait or a limit in time is said in the panel's words…*) |
| Registration — `secure/management/command/register.php`, `qs_auth_attempt_register()` (`AuthManagement.php`), `AdminRouter::attemptRegister()`, `secure/admin/templates/pages/register.php` + `login.php` (what registration takes and returns, and where and for how long the assigned username is shown — `QS_REGISTER_FLASH_TTL` / `qs_register_note()` in `SessionManagement.php`) | the `register` `help.php` entry, `COMMAND_API.md` (*Authentication* **and** the `account.created` row of *The security trail*), `ARCHITECTURE.md` §3 (*Accounts*), `ADMIN_PANEL.md` §1 + §9.13, **and the texts that state the note's lifetime**: `en.json` `login.registered.warning` and `register.usernameNotice.body`, and the `register` command's own success message — the same contract is stated in all of them |
| `secure/admin/functions/AdminTranslation.php` `__adminDuration()` — **every panel text that states a wait or a limit in time goes through it**: `'up'` for a wait (the three throttle messages: `login.php`, `register.php` and `setup.php` pass their router's seconds as `:duration`), `'nearest'` for an "about" (the login page's hint). Its words are the `common.duration.*` keys, in every panel language: a language without them shows key paths on the sign-in pages. **Its browser twin is `QuickSiteAdmin.durationText()`** (`admin.js`), for a wait the browser writes (My account): the same rule, the same `common.duration.*` words, which `layout.php` ships; change the two together | `ADMIN_PANEL.md` §1, `DESIGN_DECISIONS.md` (*A wait or a limit in time is said in the panel's words…*, *A wait the browser says…*) |
| `secure/admin/templates/pages/login.php` — **the hint under *Keep me signed in* states `qs_session_config()`'s limits** (`SessionManagement.php`): `idle_ttl`, and both when `remember_ttl` is the shorter (`login.rememberHint`, `login.rememberHintBoth`, in en and fr). A change to what the two knobs mean, or to their minimums, changes the hint, setup's item 10 (the row below) and the `auth.php` table | `ADMIN_PANEL.md` §1, `ARCHITECTURE.md` §3 (*Authentication — `auth.php`*), `README.md` (item 10) |
| `setup.sh` `check_php_floor()` / `setup.bat`'s PHP floor block — **the PHP version is checked before the first write** (the ownership fix, the landing page, the embed policy, the conf), on the `php` the PATH finds or the one `PHP` names. The floor is `CLAUDE.md`'s *Runtime floor*: raising it changes both scripts, `README.md`'s requirement and its patched-release line, `build.php`'s deployment notes and the build workflow's requirement item (`build-quicksite-v2.json`, en and fr) | `README.md` (*Requirements*, *Quick setup*), `CLAUDE.md` (*Runtime floor*), `DESIGN_DECISIONS.md` (*PHP 8.3 is the floor…*) |
| `setup.sh` / `setup.bat` item 10 (`idle_current()`, `idle_label()`, `item_idle()`; `:idle_read`, `:idle_words`, `:item_idle`) — **it writes `auth.php`'s `session.idle_ttl` in seconds, at least 300 (`qs_session_config()`'s minimum), and its label shows what the engine applies** (1 day when `auth.php` sets none). A change to the engine's minimum or default changes both scripts; `setup.bat` reads its answers line by line from the console or a redirected file (on a pipe, cmd's `set /p` keeps only the first line); `setup.bat`'s header lists what may not work on Windows and when (*KNOWN LIMITS ON WINDOWS*): a case met is added there | `README.md` (item 10), `ARCHITECTURE.md` §3 |
| `secure/admin/functions/panelState.php` / `public/admin/state/index.php` (per-user edited-project pointer — panel state, not a command) — `qs_admin_set_project_star()` is the one writer of `users.php` `starred_projects`, `qs_admin_starred_projects()` its reader, handed to the page as `QUICKSITE_CONFIG.starredProjects`; `qs_admin_set_selected_project()` repairs the opened project's `projects` entry in the same write; `qs_admin_set_panel_mode()` is the one writer of `panel_mode` (the next row). **Accepting an invitation goes through the same write** (`memberships.js` `openAcceptedProject()`): the panel opens the project, and should the write fail the membership stands and the page stays | `ADMIN_PANEL.md` §8.0 and *My Memberships*, `ARCHITECTURE.md` §7 and §3 (the `users.php` field table), `COMMAND_API.md` (*What is deliberately not a command*, the route table), `DESIGN_DECISIONS.md` (*Accepting an invitation opens the project*) |
| `secure/src/functions/AuthManagement.php` — **the membership repair.** The rule (`qs_membership_desired_statuses()`, `qs_membership_reconciled_entry()`, `qs_membership_roster_readable()`, `QS_MEMBERSHIP_NOTICES`, and the approved notice it keeps on a member entry and never writes: `QS_MEMBERSHIP_APPROVED`, `qs_membership_approved_entry()`, `qs_membership_has_approved_notice()`) is shared by `reconcileMemberships`, the dashboard's per-account repair (`qs_membership_sync_account()`, called from `layout.php` through `qs_membership_repair_account()`) a switch (`qs_admin_set_selected_project()`), and the inbox (`qs_membership_list_invitations()`), which writes the copy only and bumps no token: a change to one is a change to all four. The repair's cache (`<secure>/cache/membership/`, `QS_MEMBERSHIP_REPAIR_TTL`) is dropped by its token: **every change of a membership calls `qs_membership_token_bump()`** — `qs_members_mutate()` and `qs_project_birth_write_members()` after a successful write, `deleteProject` once the folder is gone. A new way to change a membership that bypasses the two writers calls it too, or the header's menu can stay out of date for five minutes | `ADMIN_PANEL.md` §8.0 and §9.12, `DESIGN_DECISIONS.md` (*The header's project menu reads the account's copy…*), `.gitignore`, the `reconcileMemberships` `help.php` entry |
| `secure/admin/functions/membershipSelf.php` `qs_membership_dismiss_notice()` and `qs_membership_list_invitations()` — **a dismissal ends no live state**: a terminal notice (`QS_MEMBERSHIP_NOTICES`) removes its entry, the approved notice only its mark on the member entry, anything else answers `400 notice.not_dismissable`. **A new notice** is either a terminal status (its entry goes) or a mark on a live entry (only the mark goes), and its writer, the inbox, the repair rule (the row above) and `memberships.js` `NOTICE_LABEL_KEYS` with its chip's CSS learn it together | `ADMIN_PANEL.md` §9.12, `COMMAND_API.md` (the self-service route table), `users.php.example` (the `projects` field), the `approveJoinRequest` and `reconcileMemberships` `help.php` entries, `DESIGN_DECISIONS.md` (*An accepted join request leaves a notice…*) |
| `public/admin/assets/js/pages/preview/preview-ai-tools.js` `_buildParamInput()` / `_updateDependentSelects()` and `secure/admin/workflows/schema.json` — **a parameter's field:** its control carries `data-param-control`, which the cascade replaces whole (a picker destroyed first), and the id its label points at (`_paramControlId()`); a searchable select's widget is mounted once the field is in the page; the cascade never rebuilds the field whose own change set it off; a select's value is one its list offers. A parameter property the runner reads (`searchable`, `filterFrom`, `excludeFrom`) is declared in `schema.json` and documented in `WORKFLOW_SYSTEM.md`. `optionsFrom` data is a list or a map, read in one place (`_resolveOptionsFromData()`); `filterFrom` and `excludeFrom` read their parameter through `_refValues()`, and the cascade rebuilds a select with either. A run reads its parameters once, when it starts (`_startRun()`), and locks the fields until it ends | `ADMIN_PANEL.md` §8.12, `WORKFLOW_SYSTEM.md` |
| `secure/src/classes/WorkflowManager.php` `getWorkflowPhases()` / `planPhases()` / `resolvePhase()` and the `workflow-generate-steps` arm — **a run's phases are the server's one rule**: every phase is resolved when the run reaches it (the plan is a preview) and asked for by its key, an AI phase's prompt is built then, an AI answer is read back through the same arm; a runner or a page that runs workflow steps is a client of it. `loadWorkflow()` loads only a workflow-id name (`identifier_alphanum`): an id from a request never reaches a file outside `core/` | `ADMIN_PANEL.md` §8.12 (*A run, phase by phase*), `WORKFLOW_SYSTEM.md` (*Sub-Workflows*, *Execution Flow*), `DESIGN_DECISIONS.md` (*A workflow runs in phases…*) |
| `WorkflowManager::stepEffect()` (`DELETE_NAME`, `CREATE_NAME`, `CONTENT_CATEGORIES`, `EFFECT_GROUPS`) — **what a run warns about**: a new command needs nothing when its name tells (delete…, add…) and its category is right; a command that deletes under another name is flagged `destructive` in its help, or it is warned as a change. `DELETE_NAME` is the console's convention (`admin.js` `isDestructiveCommand()`): change the two together. A new `what` group gets its words in `preview-ai-tools.js` `EFFECT_WHAT` and `en.json`. **A command flagged `destructive` that deletes nothing gets its words in `preview-ai-tools.js` `EFFECT_DOES`** (a word, a line, each in `en.json` and `preview-config.php`), or a run says it deletes; the gate reads `effect` alone, so those words never move what stops a run | `ADMIN_PANEL.md` §8.12, `WORKFLOW_SYSTEM.md` (what a run warns about), `DESIGN_DECISIONS.md` (*What a run deletes or changes…*) |
| `preview-ai-tools.js` `_executeSteps()` / `_executeWithRetries()` — **the runner honours a step's `abortOnFail`, `retryOn`, `maxRetries`, `retryDelayMs`** with `schema.json`'s defaults and ranges: a change to one changes the other | `ADMIN_PANEL.md` §8.12, `WORKFLOW_SYSTEM.md` (the step options), `schema.json` |
| The switch `aiAutoExecute` ("Run without stopping") — **read in three places, off until `'true'` is stored**: the runner's `_readToggle()`, `settings.js`, `ai-connections.js` | `ADMIN_PANEL.md` §6 and §8.12 |
| `public/admin/assets/js/pages/ai/lib/provider-catalog.js` `maxOutputTokens()`, `DEFAULT_MAX_OUTPUT_TOKENS`, `MAX_OUTPUT_TOKENS_RANGE` — **an AI request's length is its connection's**: every request body reads it, and an explicit `max_tokens` wins. The connection form (`ai-connections.js` `_renderOutputLimitField()`) and its `en.json` words show the same range and default | `ADMIN_PANEL.md` §5.4 (the AI library rows) and §8.12 (*BYOK calls*), `DESIGN_DECISIONS.md` (*An AI answer is as long as its connection allows…*) |
| `stream-parsers.js` (each parser's `finish`), `provider-catalog.js` `parseFinishReason()` / `finishKind()` (`FINISH_LENGTH`, `FINISH_STOP`), `ai-call.js` (`finish`, `finishReason`, an error's `partial`) — **why an answer ended**: a new provider format needs its parser to emit `finish` and its whole-answer reader in `parseFinishReason()`; a provider's new word for the limit goes in `FINISH_LENGTH`. The runner reads it in `_sendToAi()` | `ADMIN_PANEL.md` §5.4 and §8.12 (*AI response*, *BYOK calls*) |
| `preview-ai-tools.js` `_salvageAnswer()`, `_continuePlan()`, `_onContinueAnswerClick()`, `_joinCommands()`, `_onSendAgainClick()` and the words `aiToolsContinueRequest` / `…Boundary` — **a cut answer's recovery**: what counts as a whole command; when Continue and Send again are offered (the status's `cause`); what the request says; and that an answer put together never runs on its own (`_answerParts`, read by `_scheduleAutoExecute()` and the Execute button). A new reason an answer ends (a new `cause`) is decided there | `ADMIN_PANEL.md` §8.12 (*A cut answer*, *AI response*), `DESIGN_DECISIONS.md` (*A cut AI answer is continued…*) |
| `WorkflowManager.php` `comparisonValue()` / `booleanLike()` / `parseFilterValue()` — **a condition and a filter read their right side one way:** a `{{path}}` from the context, else a literal; a condition normalises a value read from the context on either side. The prompt's `{{#if}}` takes the literal parse. A new place in a workflow that compares two values calls them | `WORKFLOW_SYSTEM.md` (*Condition Syntax*, *Value Normalization*), `DESIGN_DECISIONS.md` (*A condition compares two values…*) |
| `WorkflowManager.php` `collectPhases()` / `collectSubPhases()` / `settleRequests()` / `reachableWorkflows()` / `runCommands()` and `schema.json` `subWorkflowEntry` — **a sub-workflow entry:** `forEach` / `filter` read by the step's own `forEachItems()` against the run's parameters when the run is planned, `label` naming each phase, `request` handed down through AI parents (`requestFrom`); the AI tools list (`preview-config.php`) checks `runCommands()`, the same walk taking every entry. A new entry property is declared in `schema.json` and documented in `WORKFLOW_SYSTEM.md`; the runner reads `requestFrom` only through `_requestKey()` | `WORKFLOW_SYSTEM.md` (*Sub-Workflows*, `preWorkflows` / `postWorkflows`), `ADMIN_PANEL.md` §8.12 (*What is listed*, *Your prompt*), `DESIGN_DECISIONS.md` (*A sub-workflow may take its parent's request*, *Create Website v2…*) |
| `public/admin/assets/css/responsive.css` and any other stylesheet of the panel — **the three views, and the only media queries:** tablet `@media (max-width: 64rem)` then phone `@media (max-width: 40rem)`, one block each, in this file, the last stylesheet `layout.php` links. A rule here beats a rule of the same specificity anywhere else, so a rule moved here that never applied there comes alive: read what the page showed first. No other width, in any stylesheet or `<style>`, and no `matchMedia` in the panel's scripts. **One query that is no view:** `@media (prefers-reduced-motion: reduce)`, once, in this file, after the phone block, where a motion of the panel stops (the mode row's glow), and nowhere else. **A control that a `:hover` reveals on a computer has its line in the tablet block**, or a finger never sees it (`.sitemap-actions`, the `.asset-card` controls). **Every variable a stylesheet, a template's style attribute or a script's style string reads is defined in `admin.css`'s `:root`**, and a read keeps no fallback: one that means the same as a `:root` variable reads that variable. **A rule's class is named by a template or a script** (whole, or as a stem a script completes), **and a class the markup names has a rule or a script that reads it**: an unstyled class goes from the markup rather than being given a style. **Merging two rules moves no declaration past a rule that sets the same property, its shorthand or its longhands**, whatever its selector; where two places would do, the later is kept. **A value on the panel's scale reads its variable** (a font size, a spacing, a radius, a weight), and colours are written in one format, CSS Refiner's own (hex). `preview-iframe-overlay.css` is the editor frame's, read inside the user's page: its variables are its own | `CLAUDE.md` (*The panel works at three views*), `ADMIN_PANEL.md` §5.1 (*The three views*), `DESIGN_DECISIONS.md` (*The admin panel has three views, in one responsive file*; *The panel's stylesheets are tidied by its own CSS Refiner, and every variable they read is defined*) |
| `secure/admin/templates/layout.php` the header, with `admin.js` `initHeaderMenus()` and `initHeaderHeight()` — **one set of elements in the three views:** `.admin-header__panel` holds the menu alone, `display: contents` above `40rem` and the panel at `40rem` and under, so nothing is written twice and no script moves anything or reads a width; the actions sit on the row in every view, and the page holds the header's parts in the order the eye reads them. **The header sticks all the way down** because `.admin-body` is `min-height: 100vh`: a fixed `height` on it ends the stick after one screen. **One helper opens and closes** a group, the account menu and the panel: a new menu in the header joins its list and closes by its rules, and only a `pointerType === 'mouse'` opens one on hover. A group the phone's panel shows in place closes only by its arrow, another group, Escape or the panel: an outside rule that closed it would move the next entry from under the press. A group's word stays a direct child of its group, as `.admin-nav__group--has-active > .admin-nav__group-toggle` is a child selector. **`--admin-header-height` has four readers** (`.admin-security-warning`, `.admin-toast-container`, the editor's `.admin-main`, `.optimize-sidebar`) and the panel's `max-height`: a new one that sits under the header reads it, a new one with a number in it is wrong at some width, and the header never reads it. The two logouts stay `POST` forms carrying the token. Each entry of Members carries its own share of the count (`members-badge.js`). **The menu is drawn from `panelMenu.php`** (the next row): a new entry goes there, never as markup in the layout | `ADMIN_PANEL.md` §5.1 (*The header*), `DESIGN_DECISIONS.md` (*The menu is two bands, an account menu, and an arrow to open a group*) |
| `secure/admin/functions/panelMenu.php` `qs_admin_menu()` — **the mode's one definition, and the menu's**: every entry with its words, icon, role filter (`requires`, the commands `data-requires-command` names), setup switch (`offered`) and `simple`. `layout.php` draws the menu from it (`qs_admin_menu_drawn()`), and the chooser's lists and drawings read it (`qs_admin_menu_pages()`, `partials/mode-chooser.php`), so the two never differ. **An entry without `simple` is advanced**: a new page is out of the simple menu until it is named. **The mode is `users.php` `panel_mode`** (`simple` or `advanced`, absent until the first pick), written only by `qs_admin_set_panel_mode()` through `/admin/state/panel-mode`, read by `qs_admin_panel_mode()`; every `users.php` writer edits inside an entry, so the key survives each one, and a new writer that replaces a whole entry would drop it. **The server draws the mode** (`admin-body--simple` on `<body>`, `data-menu-advanced` on what simple hides), so the first frame is right and `js/core/panel-mode.js` switches by the class without a reload; `admin.js` `applyMenuGroups()` folds a group after both filters (none left: not drawn; one: that entry). **The chooser** is rendered for a signed-in account with no mode only (`layout.php`, never on sign-in, register or first run) and opened through `QuickSiteUtils.openModal()` once permissions load. **A page of the advanced menu opened in simple says so** at the top of `<main>` (`qs_admin_menu_page_is_advanced()`, read from the same definition, the line drawn in either mode and shown by the simple class). ⚠ **The mode is not a permission:** no permission, control or command reads it, and a comment that touches it says so. Readers to come: the editor's simple view reads the same key | `ADMIN_PANEL.md` §5.1 (*The mode: simple or advanced*, *The header*), §7 (`panelMode`), `ARCHITECTURE.md` §3 (the `users.php` table) and §8 (*What the panel shows*), `DESIGN_DECISIONS.md` (*A beginner's menu and the full one…*) |
| `secure/src/functions/setupSwitch.php` `qs_setup_switch()` — **the one reader of a setup switch**, behind `qs_console_allowed()` (`consolePolicy.php`, item 8) and `qs_embed_security_shown()` (`embedSecurityPolicy.php`, item 11): absent means on; present and unreadable, malformed, or anything but boolean `true` means off, logged. A new page switch reads through it and is offered by both setup scripts as the next item, written only to say no, from its `.example`; **`setup.sh` / `setup.bat` item 11** (`embed_current()`, `embed_label()`, `item_embed()`; `EMBED_LABEL`, `:item_embed`) writes `embed-security.php`'s `show_embed_security`. Hidden, Embed Security leaves the menu (`panelMenu.php` `offered`) and its page says so; the policy and `getIframeSandbox` are untouched. ⚠ A switch removes reach, never access | `README.md` (the setup items, the files an update never touches), `ADMIN_PANEL.md` §9 (the Embed security row), §9.17 (*An installation can decline to offer it*), `ARCHITECTURE.md` §3 (the config table), `PROJECT_STRUCTURE.md` (the config tree) |
| `admin.css` `.admin-figures` / `.admin-figure` — **a row of figures reads label then number**, the dashboard's and the APIs page's alike, in this one style; a new figure takes it. **`QuickSiteAdmin.canRun()`** (`admin.js`) is the one answer to whether a page draws a control a command backs (the role holds it, and a project-scoped command has a project): a page's own `canRun` is a call to it | `ADMIN_PANEL.md` §5.1 (`admin.js`) |
| `admin.css` `.admin-table-wrapper` and `QSDom.tableWrapper()` (`js/core/dom.js`) — **every `.admin-table` sits in it**, and another wide block shares the class, setting `--admin-scroll-cover` to the surface it sits on (`background-color`, not `background`, which would remove the shadow's layers). The shadow paints under the content, so an opaque background on a cell hides it: `.admin-table tr:hover td` is translucent for that reason | `ADMIN_PANEL.md` §5.1 (*What is wider than the screen*) |
| `admin.css` the dialogs' overlay rule (`.admin-modal` and the others beside it) and each dialog's box — **a new kind of dialog joins that rule:** a fixed box on a page wider than the screen takes the page's width, and what it centres goes with it. A dialog's body scrolls and its header and buttons stay (`.admin-modal__content` is a flex column; `.storage-modal-dialog` and `.privacy-modal-dialog` hold them sticky, having no body of their own) | `ADMIN_PANEL.md` §5.1 (*What is wider than the screen*) |
| `admin.css` `:root` `--admin-accent` and its family, `--admin-on-accent` — **every rule that puts text on an accent background reads `--admin-on-accent`**: light text reads at 2.4:1 on that orange. No copy of the accent's value anywhere else in the panel; `--admin-accent-rgb` is its three numbers, changed with it | `DESIGN_DECISIONS.md` (*The panel's accent comes from the logo*) |
| `admin.css` `.admin-page-header__icon` — **one size on every page**, set nowhere else; a page's icon is its entry's in `layout.php`'s menu, copied, so a change to a menu icon changes the page's too | None |
| `admin.css` `.admin-btn:disabled, .admin-btn[aria-disabled="true"]` — **one look for a disabled admin button:** a variant's `:hover` leaves a disabled one out (`:where(:not(:disabled, [aria-disabled="true"]))`, its specificity unchanged); no `pointer-events: none` (a title still shows, an `aria-disabled` button still answers its click). A scoped copy is added only for a look of its own (`--danger`, `--success`) | `ADMIN_PANEL.md` §5.1 (*A disabled button*) |
| `create-website-v2.json` / `.md` and `translate-site.json` / `.md` — **v2 keeps Create Website's parameters:** a field added to one is added to the other while both ship; v2's prompt writes no other language and no look, which its later phases write | `ADMIN_PANEL.md` §8.12 (*Setup Languages, Create Website, Create Website v2 and Translate Site*) |
| `WorkflowManager.php` `resolveDataPath()` / `walkPath()` and a condition's left-side pattern — **a path reads every item of a list through `*`** (an item without the rest of the path is left out), on either side of a condition, a key with a hyphen allowed. The prompt's `{{#if}}` and a data requirement's condition are other readers and do not take it | `WORKFLOW_SYSTEM.md` (*Condition Syntax*), `DESIGN_DECISIONS.md` (*The language switch is rebuilt only when it is out of date*) |
| `update-lang-switch.json` (its `switchNow` and its step's condition), `setup-languages.json` (`updateLangSwitch` and its post-workflow), the runner's `_runStepsPhase()` and the no-changes toast in `_startRun()` — **the language switch is rebuilt only when it is out of date:** Setup Languages passes the box, the two Create Websites do not; a later phase of steps with none passes without a review; a run with no step ends with the green toast | `ADMIN_PANEL.md` §8.12 (*Setup Languages…*, *Batch*, *The review*), `DESIGN_DECISIONS.md` (*The language switch is rebuilt only when it is out of date*) |
| `preview-ai-tools.js` *A run's cost* (`_tallyCall()`, `_tallyPrompt()`, `_tallyAnswer()`, `_costRecord()`, `_renderCost()`) and `ai-call.js` `mergeUsage()` — **a run's cost has one builder:** every count goes through the three tally functions; `_costRecord()` builds the only record, and the display reads only it; the record holds ids and numbers, no word of the panel (a phase's title for the display only); nothing is written (no storage, no request). A new way to send or paste an AI exchange calls a tally function; a new display reads the record. A stream's counts are its events' merged (`mergeUsage()`, each count its largest): a provider format whose counts come in parts needs nothing more | `ADMIN_PANEL.md` §5.4 (the `ai-call.js` row) and §8.12 (*This phase's cost*, *This run's AI cost*), `DESIGN_DECISIONS.md` (*A run's AI cost is shown and never stored*) |
| `WorkflowManager.php` `collectPhases()` / `collectSubPhases()` (the key) and `resolvePhase()` (`$options['key']`, `planChanged`), the `workflow-generate-steps` arm's 409, the runner's `_fetchPhase()` callers, `_planChangedError()` and the kept record's `phaseKey` — **a phase is asked for by its key:** the key names the entries on the way (list, position, id) and a repeated entry's item; every phase request of a run sends it, and the server refuses rather than resolves a phase no longer planned there. A new entry property that changes which phases an entry yields (as `forEach` does) goes into the key; a new phase request of a run sends it | `ADMIN_PANEL.md` §8.12 (*The run's plan changed*), `WORKFLOW_SYSTEM.md` (*Sub-Workflows*), `DESIGN_DECISIONS.md` (*A phase is asked for by its key…*) |
| `secure/admin/functions/updateCheck.php` / the `update-check` arm in `public/admin/api/index.php` | `COMMAND_API.md` (*Update detection is not part of this API*), `ADMIN_PANEL.md` §9.14 |
| `secure/admin/functions/adminJsonEndpoint.php` (the shared admin JSON auth gate) | `ARCHITECTURE.md` §8 (entry-point count in the error-hygiene row) |
| `secure/src/functions/LoggingManagement.php` (what is logged, the redaction list, which bucket a command lands in) **or** `secure/src/functions/securityLog.php` (the install-wide security trail — and the trail's key: `qs_security_key()`, the file `security-trail-key.txt`) | `COMMAND_API.md` (*Command history storage* + *The security trail*) **and** `ADMIN_PANEL.md` §9.18 (what a history row represents — refusals are recorded, not only executed commands), `.gitignore` (the key's rule) and `PROJECT_STRUCTURE.md` (the config tree) |
| `secure/admin/functions/accountSelf.php` / `membershipSelf.php` / `directory.php` / `public/admin/self/index.php` (account + membership self-service and the two directory lookups — not commands) | `COMMAND_API.md` (*What is deliberately not a command* — the route table AND the guarantees paragraph), `ARCHITECTURE.md` §3 (global-scope list, consent-model prose), `ADMIN_PANEL.md` §9.13 (My account) + §9.12 (Memberships & Project members) |
| **Adding ANY new directory under `public/admin/`** (a JSON endpoint, an asset folder — anything) | Check **both** directions. **Shadowing** — Apache resolves a real directory before `public/admin/.htaccess`'s `FallbackResource`, so a directory named like an `AdminRouter::$validPages` entry silently shadows that page. This has shipped for real: an endpoint named `account` while `/admin/account` was the My Account page. It is also why the asset page is routed `media`, not `assets`. **Routing** — a directory holding its own `index.php` is a front controller. Apache picks up each directory's own `.htaccess` automatically; nginx has a central list instead, so give it a location block in `generate_nginx_config()` (`secure/src/functions/NginxConfig.php`) rather than relying on `location /admin/` falling through. `/admin/api/`, `/admin/self/` and `/admin/state/` each have one. |
| Implementing a design decision that was locked earlier (sprint design round, scope reduction, alternative chosen, etc.) | `DESIGN_DECISIONS.md` — append a new entry **once the code lands**. The full rule, including what an entry must contain and what must never go in one, is *Design-decision discipline* in `CLAUDE.md`. |
---
## Adding a new command — checklist
Adding a `secure/management/command/<name>.php` is the first of **six** steps: the file, then **five** registrations. Until every registration is done the command is missing at a different layer — router, category map, role permissions, `help` documentation, and the `/admin/command` UI listing.
1. **Create** `secure/management/command/<name>.php`. **Two file styles are legitimate — pick by whether the command must be callable in-process:**
- **Top-level script** (49 of the 149 command files). The file simply executes on include: it reads its parameters, does the work, and calls `->send()`. The dispatcher includes exactly one command file per request, so this is safe and is the simpler default.
- **`__command_<name>()` function + HTTP-dispatch guard** (100 of 149). The file defines `function __command_<name>(array $params = [], array $urlParams = []): ApiResponse` and ends with `if (!defined('COMMAND_INTERNAL_CALL')) { … __command_<name>(…)->send(); }`. The guard lets another command `require_once` this file and call the function directly without the include also firing an HTTP response.
- **The function+guard style is REQUIRED** when the command is (or may become) an entry in `CommandRunner`'s allowlist, or when another command file calls it directly. Every one of `CommandRunner`'s 25 allowlisted commands defines its function — that invariant is what makes in-process execution work, so a new allowlist entry must be converted first. When in doubt, use the function+guard style: it costs three lines and keeps the option open.
- **If it writes a small project file** (a page, a structure, a stylesheet, translations, the routes, a snippet, a setting), it calls the size check before its first write: `qs_small_write_refusal()` with every file and its bytes, or it writes through a guarded helper (`qs_small_write_guard()`) and answers `qs_small_write_refused()`. A writer that calls neither is one the caps and the over-quota rule do not reach.
2. **Append** `'<name>'` to the array in `secure/management/routes.php` (the single-line returns array; alphabetisation isn't enforced, place it near its conceptual neighbours). Makes the command **routable**.
3. **Add to `secure/management/config/categories.php`** — every routed command must belong to **exactly one** category, either an existing one or a new `'<category>' => ['scope' => …, 'access' => …, 'commands' => [...]]` block. The category fixes the command's **scope** (global vs project-marker) as well as its permission group. **This is not optional**: `routes.php` ↔ `categories.php` must stay 1:1 in *both* directions, and a routed-but-uncategorised command fails closed at `hasPermission` — it is routable and completely unusable. Note the shape: `category => ['scope','commands']`, **not** a flat command→category map. **A `scope: global` command also goes in `FALLBACK_GLOBAL_COMMANDS`** (`public/admin/assets/js/core/api.js`) — the client's copy of the global set, used only when a page fails to emit the live one. A global command missing from it is sent with a project marker, or refused client-side when no project is selected. **A `scope: global` read also goes in `QS_LOG_SKIP_GLOBAL_COMMANDS`** (`secure/src/functions/LoggingManagement.php`), or every call writes a `_global` record. **A read outside `content.read` / `config.read` goes in `QS_WRITE_RATE_READ_COMMANDS`** (`secure/src/functions/quota.php`), or every call counts against the write rate.
4. **Roles grant categories, not commands.** Each role in `secure/management/config/roles.php` and `roles.php.example` lists the `categories` it holds, so a command added to an existing category is permitted to every role that holds it — there is nothing to edit here. Only a **new** category is added to the roles that should hold it, in both files. **Skip this step for a category that is `scope: global` + `access: 'any'`** — those are open to every authenticated user and are never role-listed; adding one here is inventing an edit.
5. **`help.php` `$GLOBALS['__help_commands']`** — append an entry describing parameters, success/error shapes, notes. Makes the command **documented** (consumed by the runtime `help` endpoint AND by callers that want spec info). Don't smoke-test routing via `help` — POST to the endpoint directly. **Name constants, never their values** — see *`help` states no deployment-specific value* in `CLAUDE.md`. ⚠ **If the command destroys or overwrites data and its NAME does not begin with `delete` / `remove` / `clear` / `reset` / `purge`, set `'destructive' => true` in its help entry** — `/admin/command` reads that flag (via `data-destructive` on the form) and falls back to the name convention, so without it the console executes the command with no confirmation, and it gets its words in `preview-ai-tools.js` `EFFECT_DOES` when it deletes nothing. A form that takes over the console's submit (`deleteAsset`'s) calls `QuickSiteAdmin.confirmDestructiveCommand()`, or its command never asks. The failure is silent: a missing flag is not an error, it is a missing "are you sure". A run reads the same flag (`WorkflowManager::stepEffect()`) and warns of the step as a delete, so "Run without stopping" stops on it when an AI phase emits it. **The test is what is lost if it runs by mistake, not how the name sounds:** a command run many times in a sitting, or that already asks through its own `confirm` or 409, stays unflagged. **An `example` is one value a user can type as it stands**; when it must show alternatives, separate them with the word "or", and the console then shows it under the field as **Examples:** instead of as the placeholder.
6. **`secure/admin/functions/AdminHelper.php` → `getCommandCategories()`** — add the command name to the appropriate category's `'commands'` array. Makes the command **visible on `/admin/command`** (the categorisation that drives the UI list is hand-curated here, not derived from routes.php). Easy to miss; without it the command exists, is routable, has permissions, has docs — but doesn't show up in the admin UI list.
7. **OPcache caveat** — after editing `routes.php`, the dispatcher won't see the new command until OPcache refreshes. Restart WAMP or `opcache_reset()` during dev. `AdminHelper.php` and `roles.php` are also OPcache-cached but get hot-reloaded on most admin page navigations; `routes.php` is the strictest. `/admin/command/<name>` reads the same file, so until then it says *Command not found* too.
8. **"Command not found" can mean four things** — when debugging, rule out in this order:
- Hand-built URL with a double slash. The management base ends with `/`, so `base + '/foo'` produces `/management//foo` and the dispatcher reads an empty command. **Always use `QuickSiteAdmin.apiRequest(cmd, method, body, urlParams?)`** instead of hand-building.
- OPcache holding old `routes.php`. Restart WAMP / reset OPcache.
- Missing entry in `routes.php`. Confirm with grep.
- Missing entry in `categories.php` — an uncategorised command fails closed at `hasPermission`, which surfaces as a permission error, not a routing one.
- Missing role permission — check `secure/management/config/roles.php` for the current user's tier.
9. **"Command exists but doesn't appear on /admin/command"** — almost always step 6 (missing from `AdminHelper.php → getCommandCategories()`). Confirm with grep + hard-refresh the page.
10. **Doc trigger** — see the *Documentation maintenance* table above: routes.php change ⇒ re-check `ARCHITECTURE.md §3` + `COMMAND_API.md` (command count).
### REMOVING a command — the checklist is EIGHT layers, and it is longer than adding
Adding needs the registrations above. **Removing** needs every place that can hold a
command NAME, and two of them are outside the add-checklist entirely — both were missed
until a beta.10 deletion (`switchProject` + `getActiveProject`) tripped over them:
1. `secure/management/routes.php` — the routable allowlist.
2. `secure/management/config/categories.php` — the command→category map. **The 1:1
invariant must hold in BOTH directions**: no routed-but-uncategorised command
(fails closed at `hasPermission`) and no categorised-but-unrouted stray.
3. `secure/management/config/roles.php` **and** `roles.php.example` — only when the
command's category goes with it: roles list categories, never commands. (Global
`access:'any'` categories are never role-listed.)
4. `secure/management/command/help.php` — the `$GLOBALS['__help_commands']` entry.
5. `secure/admin/functions/AdminHelper.php` → `getCommandCategories()` — the
`/admin/command` UI list.
6. **`secure/src/classes/CommandRunner.php`** — its hardcoded read-only allowlist for
in-process execution. It is NOT derived from routes.php, so a deleted command can
linger here as a dead entry. This is where `listTokens` survived one beta, and
`getActiveProject` survived into the next.
7. **`public/admin/assets/js/core/api.js`** — `FALLBACK_GLOBAL_COMMANDS`, the client's
defensive scope list used when the page fails to emit the live set.
8. **`public/admin/api/index.php`** — `QS_API_ARM_COMMANDS` (the arm-name →
command permission map) **and** the `switch` case that calls
`makeInternalApiCall('<name>')`. This is a REGISTRATION layer, not a mirror:
the admin API arms its own endpoints from it. Missed until beta.11 found
`listBuilds` living here.
9. **Non-registration references the grep must still catch**: shipped workflows
under `secure/admin/workflows/`, admin page JS (`dashboard.js` and friends),
`docs/` catalogues, the logging lists in `LoggingManagement.php`
(`QS_LOG_SKIP_GLOBAL_COMMANDS`, `QS_LOG_SKIP_BODY_COMMANDS`), and explanatory
COMMENTS in neighbouring commands. None of these break dispatch, but all of
them go stale.
⚠ The starter project's own content pages
(`secure/projects/quicksite/templates/model/json/pages/` + its translations) also
name commands. Treat that as a SEPARATE decision, not part of a removal: that page
already documents three commands deleted in beta.10 (`switchProject`,
`getActiveProject`, `listTokens`) and covers well under half the command surface,
so patching two entries there is arbitrary rather than corrective.
Then: delete the command file itself, re-check the doc trigger table (`ARCHITECTURE.md`
§3 + `COMMAND_API.md` count + catalogue row), and **prove the 1:1 invariant
programmatically** rather than by eye. Grep the whole tree for the command name before
declaring it gone — prose comments and the starter project's own content pages can also
reference it.
---
## Release discipline
- **Bump the `VERSION` file on every git tag.** `secure/admin/functions/updateCheck.php` (reached at `GET /admin/api/update-check`) reads this file via `qs_local_version()` and compares it (via PHP's `version_compare`, which natively handles `beta < release`) against the latest GitHub release. The panel's footer and its Settings page read it through the same function, as a build does. A stale `VERSION` makes the in-app update notice misreport, and the panel name the wrong version. Bump `VERSION` *before* tagging.
- **Tag format**: `v1.0.0-beta.X` (matches GitHub release tags and PHP's `version_compare` ordering).
---
## How to write and review a `docs/` file
These conventions were settled during a full claim-by-claim review of
`ARCHITECTURE.md`, and they govern every session that touches a `docs/` file, not
just an active doc pass:
- **A schema beats a long text** — a diagram and a table where five dense paragraphs were.
- **A file gets a field table, not a paragraph** — shape first, then fields with defaults and meaning.
- **A section named after a file must describe that file** — and say what the file does *not* hold.
- **Ask "does this belong in this file at all?" before "is it in the right order?"**
- **Check the destination before cutting** — of six apparent duplicates in that review, two turned out to be the authoritative copy.
- **A catalogue with a runtime source belongs beside that source** — verb lists, `data-*` lists, command lists all have a generator and an endpoint; a third hand-maintained copy is how one goes wrong.
- **Don't name one web server when the behaviour is server-agnostic.**
- **One spelling per concept** — the project id had eight.
- **Judge historical commentary grammatically, not lexically** — the tell is a past-tense sentence describing what the system did before; a keyword scan misses them.
- **Convert the tense, don't delete the reasoning** — "let a param carry a scheme past the check" becomes "would let"; the changelog voice goes, the security rationale stays.
- **State which docs have been reviewed and which have not.** Not every `docs/`
file has had a claim-by-claim pass. Before relying on one, establish whether it
has — and say so in the review you write. Content transcribed *into* an
unreviewed file is not thereby verified.
**Renumbering sections is its own procedure.** Section numbers are cited from
other docs, from `CLAUDE.md`, and from the trigger table above, and a renumber
silently breaks every one of them. Two defect classes exist and only the first is
loud: a reference that no longer resolves, and a reference that still resolves but
now lands on the wrong section. Record what moved from which number to which, and
prove the references with a script that walks every `§` in the tree against the
real headings — an eyeball pass is what let six stale references survive a review
that reported itself verified.