# Phase 5 — CSS Foundation and Viewport

Arc Post-Mortem Summary. This arc rebuilt the stylesheets' foundation and made pages answer the width they are given. It named the repeated colors, lengths, and control heights as custom properties (I), said lengths in rem and clamp rather than inches (II), and used logical direction properties so right-to-left falls out for free (III). It moved the phone-versus-desktop split from a user-agent guess into width-based media queries and added a short-height media query so a phone in landscape gets the compact layout too (IV), and added a built-in light/dark mode following the visitor's preference along with reduced-motion and focus-visible accessibility handling (V). It closed the interface fixes handed over from Phase 4.5 — the resource toolbar's uneven control heights, its layout on a phone, and the Add Search dialog (VI). Alongside the planned work it fixed a run of live-site issues: a large-folder listing timeout, a wiki title, several mail-server out-of-memory paths, a served-static-folder whole-file read, and the static-folder directory-index links, and it added a memory-fatal log that names the request the always-on server dies on. Two follow-ups were settled as not worth doing: an automated accessibility checker beside the existing one, and a visitor-facing theme picker.

Arc started 2026-07-17, after Phase 4.5. Sections are Roman numerals and steps within them are lettered; each step's title says what it does, followed by a sentence on what was proposed and, once worked, a few on what was done and anything learned. Marker legend: ▶ being worked on, ⚑ implemented and waiting to be reviewed, ✓ done, ✓✗ closed by deciding not to do it, ? open decision, unmarked = planned.

## What this arc is for

Yioop decides how it looks in two places at once. The server reads the user agent and branches on `$_SERVER["MOBILE"]` in 101 places across 45 files, sending different markup to a phone; the stylesheets then branch again on a `.mobile` class in 290 places. A page therefore cannot answer the width it is actually given, only the width the server guessed from a name. The viewport meta tag that would let a phone say how wide it is is emitted only when the server has already decided the reader is on one (`WebLayout.php:100`), because emitting it always was tried and reverted: the layout is sized in inches and points and runs off the side of a phone when the tag is honoured.

So the units come first, then the layout can flex, then the tag can be told the truth, and then the branches can go. That order is why item 25 was moved out of Phase 0 and why it sits at the end here rather than the start. The measurements below were taken on 2026-07-17 at the start of the arc; the master to-do's figures predate several arcs and read lower.

What | search.css | all css
--- | --- | ---
lengths in `in` | 309 | 340
lengths in `pt` | 138 | 169
lengths in `px` | 1171 | 1736
lengths in `rem` or `em` | 32 | 33
`.html-ltr` / `.html-rtl` selectors | 110 | 124
logical properties already used | — | 18
`.mobile` selectors | — | 290
custom properties defined | 0 | 0
distinct hex colors | — | 111

## I. Name the things the stylesheets repeat

Master to-do item 26. Foundational: items 9, 17, 18 consume these, and sections II and IV below cannot be done tidily without them.

1. ✓ A. Gather the colors into custom properties. Name the palette on `:root` and replace the literals. Four parts:
   1. ✓ The four a site sets for itself were written into every page's head along with the selectors using them. Those rules now live in search.css and a page sends only the values, cutting its style block from 1418 bytes to 247.
   2. ✓ The grays became a ladder from `--gray-0` to `--gray-100`, numbered by lightness, replacing 132 literals in search.css.
   3. ✓ The named colors followed: 175 uses of `black`, `white`, `gray` and their kin in search.css, and 48 more with 139 hex literals across mail.css, messages.css, frise.css and editor.css, which read the same ladder.
   4. ✓✗ The hued colors stay literals: 43 of them over 60 uses, nearly all one-offs, and they want naming as roles — error, link, success — which is a design decision rather than a rounding.
   A rung was kept for every neutral used three or more times, so almost nothing moved: the largest shift anywhere is 7 of 255, and tinted grays such as the `#e9e9ed` button face stayed literals rather than being flattened, which a pixel comparison caught a first attempt doing.
2. ✓ B. Name the spacing and the type sizes. So that section II has something to convert the inches and points into rather than converting each one by hand. Two parts, and only one of them was worth doing:
   1. ✓ The type sizes are named: `--font-size-1` through `--font-size-7`, smallest first, with `--font-size-base` naming the 12pt that body text is set in and that a browser would have chosen anyway. 160 declarations across the five stylesheets now read them, leaving 19 rare sizes as literals. Six pages render identically.
   2. ✓✗ The spacing is not named, because there is no scale there to name. Margins and paddings use 1, 2, 3, 4, 5, 6, 8, 10 and 12 pixels, and the odd steps are not strays: 3px is used 53 times and 5px 69. A `--space-1: 1px` would rename a number to a number, and snapping the odd steps onto an even ladder would move 122 pieces of layout for tidiness alone.
   Naming the type sizes is what pays: saying type in a unit a screen understands rather than a printer is now a change to seven lines in `:root` instead of 160 declarations, which is the whole reason this section comes before section II.
3. ✓ C. Name the control height the toolbars share. The 32-pixel height of an icon button was written out eight times in search.css and is why a row of controls that ought to line up does not. `--control-height` now names it: how tall a thing you can press or type into stands, an icon button, a select, a search field. The eight are the icon button and its container, the button group, the filter field on a phone once it has focus, and four of the git issue controls; the one 32px left is a glyph's size, which is not a control. Six pages render identically, and section VI.A can now say what it means when it makes the resource toolbar one row of one height.

## II. Say lengths the way a screen understands them

Master to-do item 29. A phone cannot honour an inch; this is what made the viewport meta tag unusable.

1. ✓ A. Convert the inches and points to `rem`, `em`, `%`, `ch` and `vw`. Every inch across the stylesheets is now `rem`, at six rem to the inch, which is an inch's ninety-six pixels over a rem's sixteen: 309 in search.css and 31 more in the editor, frise, mail and messages sheets. That ratio keeps the rendered size exactly as it was at the default type size while letting it grow when a reader enlarges their text, which is the point of the change; six pages render to the pixel. The points went with the type scale in I.B, save the scale's own defining values in `:root`, which stay points because that is where the sizes are set. The wide page-content widths are in `rem` like the rest; whether some should instead be a share of the viewport is II.C's question, left open there rather than pre-empted here.
2. ✓ B. Let the sizes that should breathe use `clamp()`. Two things now grow with a wide screen and hold still on a normal one, bounded so neither runs away:
   1. ✓ The seven type sizes. Each is a `clamp()` of its present size as a floor, a middle that grows once the viewport passes an onset of 1100px, and a ceiling a few points above. Body text is 16px at 1100px and below, 17.7px at 1440px, and 20px from 1920px up.
   2. ✓ The page-content columns — body, search, and activity. Each keeps its designed width up to the onset and may grow toward the viewport beyond it, capped: the body column is 57rem to 1100px, about 67rem at 1440px, and 72rem from 1920px up.
   Two named tokens carry the shape, `--fluid-onset` (the 1100px width below which nothing changes) and `--fluid-rate` (how fast a size grows past it). At and below the onset every value equals what it was, so a normal window is untouched; the growth is on wider screens. This is the first Phase 5 change that alters rendering by design rather than holding it identical, so the static renderer here cannot check it — `wkhtmltoimage` does not resolve `vw` against a set width — and the sizes were verified by arithmetic across viewports rather than by pixel diff. It wants a browser's eye at a few widths.
3. ✓ C. Decide what to do about the fixed pixel lengths. A pixel is a screen unit and is not wrong the way an inch is, so the rule Chris settled was to keep what draws a line and convert what holds space:
   1. ✓ Borders, border widths, border radii, and box shadows stay in `px`: they draw the interface's lines and edges, which should not thicken when a reader enlarges their text.
   2. ✓ Padding, margin, gap, width, height, and the positioning offsets of three pixels or more become `rem` at sixteen pixels to the rem, so the space around and between things grows with the reader's type. 799 lengths in search.css moved this way, every one dividing cleanly since sixteen is a power of two, so the size is identical at the default and six pages render to the pixel.
   3. ✓ The one- and two-pixel lengths stay `px`: they are hairline nudges that align an edge, not space that should scale, and rounding them into rem would move them by a rem's rounding rather than hold the line they were placed on.

## III. Say directions the way a document understands them

Master to-do item 27. Independent of I and II, and can be worked in parallel or dropped without affecting them.

1. ✓ A. Replace the paired `.html-ltr`/`.html-rtl` rules with logical properties. Across the stylesheets the mirror pairs are now single rules: where left-to-right said `float: left` and right-to-left said `float: right`, one rule says `float: inline-start`, and the same for `margin-inline-start`, `padding-inline-end`, `inset-inline-start`, `text-align: start` and the rest. 43 pairs collapsed in search.css and six more in mail.css and messages.css, the last two a directional border radius named with the logical corner properties (`border-end-end-radius` and its siblings); editor.css and frise.css had none.
   1. ✓ Each merged rule keeps both the `.html-ltr` and `.html-rtl` selectors in its list rather than dropping the prefix. Dropping it lowers the rule's specificity by a class and lets other rules win that did not before, which broke the layout when first tried; keeping both holds the specificity exactly while the logical property does the direction.
   2. ✓ Eight rules in search.css stay a scoped pair on purpose: three are not mirrors (a pagination cell with a thicker border and a centered label in one direction only; a small feed body inset differently each way; a side advert at a different top offset each way), and the machine-details arrow is a right-to-left glyph, content a logical property does not cover.
   3. ✓ The renderer here cannot check this: its old engine ignores logical properties outright, so a pixel diff is meaningless. Correctness was checked by resolving every logical property back to its physical value for a left-to-right reading and confirming each rule sets exactly what it set before, and by resolving the corner radii for both directions; right-to-left follows from the symmetric mapping and wants a browser with a right-to-left locale to confirm.

## IV. Let a page answer the width it is given

Master to-do items 23 and 25. This is the point of the arc; I and II exist so that this can be done without the layout running off the side.

1. ✓ A. Move the `$_SERVER["MOBILE"]` branches into media queries. 108 branch sites across 49 PHP files (the earlier 101/45 count was close). File by file, and each one is a decision about markup as much as CSS: a branch that emits different content is not the same as one that emits a different arrangement, and only the second can become a media query. Too large for one patch, so in substeps:
   1. ✓ Triage all 108 sites into content-diff (stays a PHP branch), arrangement-diff (becomes a media query), and logic-diff (neither — a real behavior choice); record the verdict per site so the conversion patches have a settled list. The full table is in IVA-mobile-branch-triage.html: of the 108, after moving the small-data column-hides to CSS per Chris's filesize rule, 52 are arrangement and become media queries in the next part, 36 are content and 18 are logic and stay PHP, and 2 are the viewport meta that IV.B removes. Done.
   2. ✓ Convert the arrangement-diff sites to media queries. Done in one pass against the settled triage. The conversion reuses the codebase's `@media (max-width: 600px)` block with four utilities: `hide-narrow` (a wide-only column or cell, always emitted and hidden below 600px, with the spacer-row `colspan` made the constant desktop count), `show-narrow` (a phone-only `<br>` or clear div, hidden by default and shown below 600px), `center-wide` (centered only above 600px), and a `configure-lang-shift` class for the one language-label offset. The column-hides across Manageclassifiers, Managelocales, Crawlstatus, Manageusers, Managegroups, Manageadvertisements, Searchsources, and Wiki resources became CSS, as did the clear divs, footer and result `<br>`s, the center swaps, and the configure nudge. Doing the work moved a set of sites out of arrangement: a branch that emits different markup — a `<br>` versus a `</td><td>` cell break, or a `</tr><tr>` row-break in the classifier and locale edit forms — cannot become a media query without first making the table one shape, so those stay PHP, and so does the pagination gap-versus-full-list. The triage records the final split: 35 arrangement (converted), 45 content, 25 logic, 2 viewport, 1 body-class. Done.
   3. ✓ Resolve the mixed sites — the ones that expressed a phone layout as different markup, a `<br>` or a `</tr><tr>` row-break where desktop used table cells. Each is split into the part that stays PHP and the part that becomes CSS by emitting one markup shape — the desktop cells — and adding a `stack-narrow` class whose cells become `display: block` below 600px, so the row stacks on a phone as the row-break used to. It covers the search form (`SearchformHelper`, its `$item_sep` now a constant cell break), the classifier and locale edit forms (`EditclassifierElement`, `EditlocalesElement`), and the group-users cell (`ManagegroupsElement`); the submit-row and status spacers are always emitted and hidden narrow. No `MOBILE` arrangement branch remains. Done.
2. ✓ B. Emit the viewport meta tag always. The head emitted `<meta name="viewport" content="width=device-width">` only when the request was sniffed as mobile, so a narrow screen that was not sniffed kept a desktop-width viewport and never triggered the new media queries. The gate is removed at `WebLayout.php:100` and at the upgrade page's own head in `VersionFunctions.php`, so the viewport is always declared and the width, not the user agent, decides the layout. With A's media queries in place this is safe; the `.mobile` class rules still coexist until C retires them. Implemented, waiting on review.
3. ✓ C. Retire what is left of the `.mobile` class. 290 selectors depend on it. Whatever survives A and B is either a real content decision or a rule that should have been a media query. In substeps:
   1. ✓ Inventory the `.mobile` selectors that remain after A and B and sort each into remove, fold into a media query, or keep as a real content marker. The full list is in IVC-mobile-selector-inventory.html: 246 rules across the four stylesheets (search.css, mail.css, frise.css, messages.css). 243 fold into the existing `@media (max-width: 600px)` block with the `.mobile` prefix dropped — the media query already means the narrow screen the class stood for — and 3 are removals where a non-mobile sibling in the same rule already applies. None are genuine content markers to keep. Implemented, waiting on review.
   2. ✓ Retire them by that inventory. Each `.mobile X` rule in search.css, mail.css, and messages.css was folded into that file's `@media (max-width: 600px)` block as `X` (mail.css and messages.css had no such block, so one was made), and the 3 redundant rules lost their `.mobile` selector. The layout's inline `body.mobile` and `.mobile .top-landing-spacer` styles became media queries too, and the `<body>` no longer carries the `mobile` class. A narrow-width render with the old class and the new media query matched. The content and logic branches keep reading the boolean `$data['MOBILE']`, which is a separate value set earlier and untouched. Implemented, waiting on review.

## V. Themes and readers

Master to-do items 28 and 30. Both consume section I.

1. ✓ A. Built-in light and dark modes. `color-scheme: light dark`, `light-dark()`, `prefers-color-scheme`. Nothing in the stylesheets mentions any of them today. This is not a theme in the `getThemeNames()` sense — those are site-specific CSS files that override Yioop's built-in styles (the V.D picker). A mode is the built-in styles themselves carrying a second value for each named color: both modes live in search.css, and the named colors from I.A get a light value and a dark value through `light-dark()` so the one stylesheet answers either. The toggle is got differently from a theme file — the browser's `prefers-color-scheme` with `color-scheme` on the root, not a linked override sheet. Needs I.A first, since a mode is a second set of values for named colors and there is nothing to name them against yet. A per-site switch gates it: a checkbox in Appearance, stored as a new domain profile field beside the existing color fields (`SystemComponent`'s `APPEARANCE_DOMAIN_FIELDS`, written by `ProfileModel::updateProfile`, so no schema change), lets a site turn light/dark on or off globally; off leaves the site in its single built-in appearance as today. The field is given its default through `nsconddefine` in Config after Profile.php loads, the way the other profile-backed appearance fields are, so under the persistent WebSite server the toggle takes effect from `p()`'s cache on save rather than needing a restart on an install whose profile predated the field. Implemented, waiting on review.
2. ✓ B. Accessibility-aware media features. A `prefers-reduced-motion: reduce` block in search.css now cuts the animation and the transitions this file sets to instant, the way mail.css already did for its one spinner, so a visitor who asks the system for less motion is not given the sliding panels or the spin. The `aria-label` hover tooltips became `:focus-visible` as well as `:hover` so a keyboard visitor gets the tooltip on tab focus while a mouse click no longer flashes it. WCAG 2.2 AA contrast was checked with the ratio formula on the landing and an admin page in both light and dark: every text node meets its 4.5:1 (or 3:1 for large text) threshold. Left for later, since AA already holds and each carries regression risk out of proportion to the gain here: `prefers-contrast` and gating the many `:hover` rules behind `@media (hover: hover)`. Implemented, waiting on review.
3. ✓✗ C. Add a checker to sit alongside the WAVE check. axe-core or Lighthouse. Settled as not worth doing: both re-report what the existing WAVE check and the manual WCAG 2.2 AA pass in B already cover, so a third checker would add maintenance without finding more.
4. ✓✗ D. Let a visitor pick the site theme. Distinct from the light and dark modes of A: those are the built-in styles in two shades, while these named themes are whole CSS files that override the built-in styles. The site already owns a list of named themes — each a CSS file under the theme folder, listed by `ProfileModel::getThemeNames()` and today chosen per domain by an admin. This gives that choice to the visitor, in the two places a visitor already changes how the site looks and reads:
   1. A theme control in the landing page's hamburger menu, in the same Settings group as the language control (`SearchmenuElement`, beside the `language` element it already renders), and a matching one in a signed-in user's account settings next to the language row (`ManageaccountElement`, after the `options_element_language_label` row). Both offer the same `<select>` of `getThemeNames()`. Proposed.
   2. Persist the pick the way the neighboring settings already persist: in `$_SESSION['THEME']` for a visitor who is not signed in, following the `SAFE_SEARCH` and `OPEN_IN_TABS` pattern in `SearchController`, and for a signed-in user in the per-user session blob the `USER_SESSION` table already holds (`UserModel::getUserSession` / `setUserSession`), where locale-style preferences already live, so it follows them between devices with no schema change. Proposed.
   3. Apply it through the seam the layout already has: `WebLayout` reads `$data['PAGE_THEME_NAME']` to choose which theme stylesheet to link, falling back to the domain's `AUXILIARY_CSS_NAME`. Feeding the visitor's pick into `PAGE_THEME_NAME` (account value when signed in, else the session value, else the domain default) makes it the visitor's theme without a new emission path, and leaves a wiki page's own theme, which sets the same field, untouched. Proposed.

## VI. The interface fixes Phase 4.5 handed over

These were tried in Phase 4.5 on 2026-07-17 and put back: fixing them there meant adding the `.mobile` branches item 23 removes and the `in` units item 29 removes. They are small, they are the reason this arc's order is what it is, and they are worth doing as soon as I and II land, since they are also the proof that I and II were done right.

1. ✓ A. Make the resource toolbar one row of one height. The toolbar's controls came out at different heights and on different lines, and the sort and overflow dropdowns were pushed down by a fixed nudge to fake the alignment. Every control now takes the one named control height and centers on the middle line, so the row is a single clean height. The row stays end-aligned as it was; only the buttons were fixed. Implemented, waiting on review.
2. ✓ B. Make the phone layout trigger in landscape, not only portrait. The compact layout was gated on `max-width: 600px` alone, which a phone held sideways never hit: it is wide (around 850px) but short, so it fell to the desktop layout and the desktop min-width ran off the side and clipped the page. The gate is now `(max-width: 600px), (max-height: 480px)`, so a short landscape phone gets the compact layout while a landscape tablet, which stays well above 480px tall, keeps the desktop one. Measured, an 844×390 view went from overflowing at 1056px to fitting its 844px, and portrait, a landscape tablet, and the desktop are unchanged. The portrait toolbar can still fall to a second row when a long folder name and every control will not fit the width together; holding it to one row would mean shrinking the controls, so the wrap is left as it was.
3. ✓ C. Give the Add Search dialog the shape of a dialog. It was built in `wiki.js` with a heavy sixteen-pixel green border, a fixed width fighting left and right insets, and a fixed height, so it looked unlike any other dialog and was cut off on a phone. It now takes the same shape as the standard light-content dialog: a rounded box with the sidebar-color border, centered, sized to fit its content and held within the viewport so it no longer runs off a phone. A poor line break in the related Word Filter Preferences title — the bracketed Factory Settings link splitting across two lines — is fixed in the same pass by keeping the bracketed link together on one line. Waiting on review.

## Order and dependencies

I before II (there is nothing to convert lengths into until the scale is named), I and II before IV (the tag is only safe once the layout can flex), I before V (a theme is a second set of values for named colors), and I and II before VI. III stands alone and can go at any point. IV.B is the last thing in the arc.

## Other work accomplished

- ✓ A static HTML folder's directory index links to, and serves, its files by their static path. A page whose resources are served as a static HTML folder listed each file with a wiki resource link that opened it in the viewer's frame, and the matching static path returned Not Found. Two things were wrong: the listing built resource links regardless of the static-folder setting, and the static path was never url-decoded, so a file named with spaces was looked for under its "+"-encoded name and missed. Now every entry in a static-folder listing links to its own static path under the page, and the served path decodes each segment the way the resource server already does, so following a link fetches the file directly. The decode splits an encoded slash back out before the "." and ".." checks, so it cannot be used to step outside the page's folder. Waiting on review.
- ✓ A served static-folder file no longer reads the whole file into memory, and the server names the request that runs it out of memory. When an editor viewed a page's served static HTML folder, the file was read whole and then copied again to append an edit button, so a large served file could exhaust the always-on server; it is now streamed in blocks with the button emitted after, the way the same method already served the file for a reader. Separately, the server now records the request it is handling and, if it dies of a memory-limit fatal mid-request — which the after-the-request memory log cannot catch because it never runs — writes one "[website-memory-fatal]" line naming that request and the peak memory, using a small reserve freed inside the handler so there is room to write it. This was added to catch a live out-of-memory whose cause the existing logs did not show. Waiting on review.
- ✓ The Appearance screen has a help link. The Appearance activity now shows a "?" help button at the top of the screen, floated to the end so the first control sits beside it rather than below an empty row, that opens a help page explaining the site name, colors, logos, landing page, theme, custom stylesheet, and per-domain settings. No heading was added, since the activity name already appears above. The light/dark checkbox is relabeled from "Offer Light/Dark Mode" to "Light/Dark Mode Ready". The help page text is delivered as a file to paste in through the wiki, since the help pages are generated and not edited in place. Waiting on review.
- ✓ The site lays itself out by the width of the window, not by guessing the device. Retiring the old phone-versus-desktop switch left a few narrow-screen pages broken: the logo, the mail and message panes, the sign-in links, and the search page all misbehaved on a small window. Each now responds to the window width instead, and was checked in a real browser at a phone width so nothing runs off the edge. Waiting on review.
- ✓ A folder with a thousand files lists again, and a request that runs too long ends on a proper page. Listing a large folder of videos had become so slow it timed the page out, because the site was reading every file's contents just to name its type. It now names the type the quick way as it used to, and a request that does run past its time limit ends on a short "timed out" page instead of a bare error.
- ✓ A wiki page shown without signing in now shows its own title. Public wiki pages, including a site's landing page, had their title mangled into "Site Name - Title" instead of just the page's own title. They now show the title exactly as the page names it, the same as when you are signed in, and a page with no title of its own falls back to a sensible "Site Name - Page Name".
- ✓ The mail server turns away an oversized login instead of crashing. A sign-in that claimed to be far larger than any real password could make the mail server eat memory until it fell over. It now refuses an over-long login before reading it, and keeps a hard ceiling on incoming data so no future path can be flooded the same way.
- ✓ The account bar and the group bar are laid out separately. A change that lined up the account bar's home button had knocked the busier group bar out of line. Each bar now has its own layout, so both sit correctly.
- ✓ The home button in the top bar lines up with the activity name beside it. The little house icon sat lower than the words next to it. The bar now centers everything on one line, so the house, the dash, and the name all sit level.
- ✓ The mail server no longer runs out of memory sending a large mailbox to a slow reader. Fetching a whole large mailbox to a client that accepted it slowly could pile the entire mailbox into memory and crash every connection. The server now waits for the slow reader to catch up, so memory stays small and a slow send shows up in the log instead of as a crash.
- ✓ A group's name lines up with the counts beside it. On the account and groups pages a group's name sat a few pixels below the post and thread counts next to it. It is no longer pushed down, so the name and the counts now sit on the same line.
- ✓ Editing a group is now called Group Settings and shows a gear. The pencil next to a group opened a screen called "Edit Group", though it really holds the group's settings. It is now called Group Settings throughout and shows a gear icon, and every translation kept its wording.
- ✓ Saving a domain's stylesheet keeps you on the stylesheet editor. Saving while editing a domain's theme used to drop you back to the main Appearance form, so every further edit meant reopening the theme. The save now keeps you on the editor, the way the site's own theme save already did.