# Conventions and gotchas

Rules for working in `app/Forum/`, and the traps this code has already fallen
into. Most entries below are here because they cost real debugging time.

---

## SQL

### Every migration names its database

Forum schema changes live in `app/Admin/sql/`. **Start every `.sql` file with a
`USE`** — after the header comment, before the first statement — or fully
qualify every object it touches. `USE` is preferred: one line at the top instead
of a prefix on every statement, and it cannot be half-applied.

```sql
-- ---------------------------------------------------------------------------
-- gcc.forum_section — widen `glyph` to varchar(16).
-- Target: gcc (the GAME database), NOT gcc_admin.
-- ---------------------------------------------------------------------------

USE `gcc`;

ALTER TABLE `forum_section` MODIFY `glyph` varchar(16) DEFAULT NULL;
```

That folder holds migrations for `gcc_admin`, `gcc` **and** `gcc_log`, and its
name biases the reader toward `gcc_admin` while most files target `gcc`. All
forum tables are in `gcc`. A migration run against the wrong database is a
silent no-op, or creates a table somewhere nothing will ever read it.

With the `USE` present, no database argument is needed:

```bash
mysql -u WolfrenInd -p < app/Admin/sql/forum_schema.sql
```

### Migrations are re-runnable

`CREATE TABLE IF NOT EXISTS`, `ALTER … ADD INDEX IF NOT EXISTS`, `MODIFY`, and
`INSERT … ON DUPLICATE KEY UPDATE`. Apply locally twice and confirm with
`information_schema`, not by trusting that the statement returned success.

### Never alter or delete a legacy row

`he`, `hef`, `he2`, `hef2`, their `_old` twins, `he_s`, `hef_s` and `he_type`
hold twenty years of posts. Forum migrations are **additive only**: new
`forum_*` tables and new indexes. If a change seems to need a column on `hef`,
it belongs in a side table keyed by `(src, thread_id)`.

### Bind every dynamic value

```cfml
runQuery("select ... where id = :id", { id: [ arguments.id, "cf_sql_integer" ] });
```

Params are `{ name: [ value, cfsqltype ] }` and bind by **name** — positional
binding is unreliable on this Lucee version, the same reason the Admin panel and
the Player API both bind by name.

### CFML arrays cannot hold null

```cfml
, rid : [ javaCast("null", ""), "cf_sql_integer" ]        // WRONG
```

That builds an array whose element 1 **does not exist**, and fails at bind time
with the wonderfully unhelpful *"Element at position [1] does not exist in
list"*. Nullable parameters use the three-element form:

```cfml
, rid : [ val(arguments.replyId), "cf_sql_integer", val(arguments.replyId) LTE 0 ]
```

`runQuery` reads the third element as "this is NULL".

---

## Performance

Four rules, each learned by making the board take 2.2 seconds a page.

### Never sort more than a few hundred structs in CFML

`arraySort` with a closure comparator over 3,081 member structs took **1.7s**
(2.1s by name, 7.9s by date), because every one of ~35,000 comparisons is a CFML
function call. The database does it in 58ms. **Sorting and paging belong in the
query.**

### Never `ORDER BY` a value computed from a LEFT JOIN

The listing sorted on `pinned`
(`forum_thread_meta.pinned_at IS NOT NULL OR hef_s.id IS NOT NULL`). Because
that comes from LEFT JOINs, MariaDB could not use an index: it materialised all
7,416 threads in the section, computed the flag for each, sorted, then took 25 —
`Using temporary; Using filesort`. **30ms → 0.44ms** once pinned threads became
their own small query.

### Prefer a correlated subquery to a joined derived table

MariaDB does not push the outer predicate into a derived table, so
`(select belongto, count(*) from hef2 group by belongto)` aggregated all 122,000
replies on every page load to decorate 25 rows. A correlated subquery hits the
`(belongto, id)` index once per returned row: **28ms → 1ms**.

### Profile signed IN

`myStanding()` feeds the right rail and ran that 1.7s sort *per request* — but
only for signed-in users, so a signed-out profile showed 30ms pages and looked
fine. Anything added to the router's data block must be timed with a session.

---

## CFML (Lucee)

### An argument named `src` shadows a method named `src()`

The board validator is therefore `board()`. Same class of bug as `this.endpoints`
shadowing `endpoints()` in the Player API: **whatever a method is called is
barred from being an argument or property name in the same component.**

### Adding a NEW method to a CFC needs a container restart

On this dev stack Lucee recompiles an edited method *body* fine, but a new
method signature is not picked up. You get:

```
Component [components.People] has no function with name [myOpenTicketCount]
```

…while the method is plainly there on disk, and `applicationStop()` does **not**
clear it. `docker restart gcc-local-app`. Worth knowing before spending twenty
minutes hunting a phantom syntax error.

### `##` in a CFML string is a literal `#`

Inside a `.cfm` `<cfoutput>` or a cfscript string literal, `#` interpolates and
`##` is a literal hash. So `###var#` is *literal-hash then interpolation*, which
is what you want for `#123`. Regexes in `Text.cfc` use `\x23` instead of a bare
hash to sidestep the ambiguity entirely.

### `try/catch/finally` in a `.cfm` cfscript block

Can make Lucee emit bytecode that fails JVM verification — *"operand stack
underflow"*. Use `try/catch` and put the cleanup after it.

### `limit` cannot be an argument name

It silently breaks the query. Use `maxRows` / `depth` / `per`.

### Never write an angle-bracketed tag name in a CFC comment

Lucee parses the token even inside a `/** */` block and tries to validate it as
a real tag, which breaks the whole component at parse time. Write "the cfquery
tag" in prose.

### Reserved scopes

`server`, `local`, `url`, `form`, `request`, `application`. A
`var server = ...` silently resolves to the scope. Name locals `slot`, `srv`,
`rows`.

---

## Output and text

### Never render a post body without `F.render()`

The single most load-bearing rule in the module. HTML is stored as HTML — for
everyone, no staff gate — so the whitelist in `Text.render()` is the *only*
thing between a post and the page. The board already holds iframes pointing at
third-party IPs and a 2010 reply that disables the reply box for everyone who
opens the thread. See [`HISTORY.md`](HISTORY.md) for how they got there.

### `style` is rebuilt, never passed through

`Text.safeStyle()` splits the attribute, checks each property against
`ALLOW_CSS`, scrubs each value and reassembles. Deliberately absent: `position`,
`top`/`left`/`right`/`bottom`, `z-index`, `float`, `display` — a fixed-position
element laid over the page turns a forum post into a clickjack. `font-size` caps
at 36px, `opacity` below 0.4 is dropped.

`style` is also the one attribute emitted **without** `encodeForHTMLAttribute`,
which would turn every `:` and `;` into an entity. It is safe raw only because
`safeStyle()` has already rejected quotes, angle brackets, parens and braces.

### Player names are already encoded

`nic`, `usernic` and `fed.name` are stored **already HTML-encoded** (entities
like `&#922;` for Greek capitals). Output them with `F.nic()`, which passes them
through raw. Running `encodeForHTML` over them double-escapes and breaks every
non-Latin name on the board.

Everything else goes through `F.h()` / `F.ha()`.

### Every text font size scales with `--fs-scale`

The top bar's A−/A+ control is an accessibility setting: it sets `data-fs` on
the root element (stored in `localStorage` as `sf_fs`, applied before first
paint by the inline script in `index.cfm`), which sets `--fs-scale` to .9, 1,
1.15, 1.3 or 1.5. It scales **text, not layout** — rails, boxes and avatars stay
put. That only works if every size opts in:

```css
.thing { font-size: calc(12px * var(--fs-scale)); }   /* right */
.thing { font-size: 12px; }                           /* ignores the setting */
```

A plain `px` size is a silent bug: that text stays small for the one reader
who asked for it bigger. **The exception** is a glyph inside a fixed-size box —
`.av` initials, `.iconbtn`, `.f-icon`, `.brand-mark` — which would overflow its
box when scaled; mark those `/* fixed box: not scaled */`. Inline
`style="font-size:…"` in a view is not reachable by the variable at all, so do
not use it.

### `[code]` is the only literal-angle-bracket path

Code blocks are pulled out of the body **before** any other processing — so they
escape cleanly, the word filter never touches them, and their newlines survive —
and put back last as an escaped `<pre class="codeblock">`.

---

## Phone layout

The board is read on phones. Section 22 of `forum.css` holds the rules; these
are the traps behind them.

### The sidebar is the only navigation below 980px

`.topnav` is hidden from 1180px down and the Menu side tab from 980px down, so
under 980px the sidebar carries every link there is. Left in the flow it pushed
the first thread **679px down a 375px screen** — a whole phone screen of links
before any content, on every page. Below 980px it is an off-canvas drawer
opened by the hamburger in the top bar.

**The drawer rules are gated on `:root.js`**, a class the inline script in the
head of `index.cfm` sets before first paint. With scripting off the class never
appears, none of the drawer rules match, and the sidebar renders in the flow
exactly as it always did. That is what keeps
[`ARCHITECTURE.md`](ARCHITECTURE.md)'s promise that the board still navigates
with `forum.js` deleted — hiding navigation that only a script can bring back
would have broken it.

### `.shell` is a stacking context, so a scrim outside it wins

`.shell` is `position: relative; z-index: 1`. The drawer is inside it, so the
drawer's `z-index: 90` is resolved **within** the shell — against the page, the
whole shell is still `1`. A scrim rendered as a sibling of the shell therefore
painted **over** the drawer no matter how high the drawer's number went.

The scrim lives inside `.shell` with the drawer, and the shell itself is lifted
to `z-index: 95` while the drawer is open so the pair clears the top bar. The
same trap catches anything else meant to float above the page from inside a
view.

### A stacked table cell keeps its fixed height

`table.tbl td` sets `height: var(--row-h)` for the 34px desktop row. When
`stack-sm` turns those cells into blocks that becomes a **fixed** height, and a
cell with a title, a blurb and a note overflows its 34px box and prints on top
of the line below it. `table.tbl.stack-sm td { height: auto; }` is load-bearing,
not tidying.

### Every secondary cell in a `stack-sm` table needs a `data-lbl`

Stacked, the header row is gone. Without labels a row reads
`22  12  Jammer  07 May 2013` — four values and no way to tell what any of them
are. `data-lbl` puts the header back on each cell, and the rule is keyed on the
attribute rather than on `.num` / `.mid` so a cell keeps whatever alignment it
needs in the desktop table.

The first cell is the exception: it is the row's title and speaks for itself.
Where the leading column is not a title — the mod log opens on a timestamp —
label every cell including the first.

### Field text has a 16px floor below 980px

iOS Safari zooms the page in whenever a focused field's text is under 16px, and
does not zoom back out, so tapping the reply box leaves the whole board
magnified. Phone widths use `font-size: max(16px, calc(13px * var(--fs-scale)))`
— a floor that still honours the reader's text-size setting rather than a flat
16px that would ignore it. See
[the `--fs-scale` rule](#every-text-font-size-scales-with---fs-scale).

### Widths belong in a class, not an inline style

A media query cannot override `style="max-width:190px"` without `!important`.
Filter boxes carry `class="field filter"` so the phone rules can widen them.

---

## Style

Match the surrounding code, which follows Amanda's house style:

- **Allman braces**; `{` on its own line.
- **No space after `if(`/`for(`/`while(`.**
- Single-statement bodies take no braces, just an indented line.
- `else if` / `else` on their own line.
- Comments explain **why**, not what. A comment restating the code is noise; a
  comment recording why a query is shaped oddly is the reason the next person
  does not "fix" it back.
