<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Phase 7 — Wiki UX — Arc Plan</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px;
margin: 1.5em auto; padding: 0 1em; line-height: 1.5; color: #1a1a1a; }
h1 { font-size: 1.4em; }
h2 { font-size: 1.1em; margin-top: 1.4em; }
ol { margin: 0.3em 0; } li { margin: 0.25em 0; }
ol.items { list-style: none; padding-left: 0; }
ol.items.numbered { list-style: decimal; padding-left: 2em; }
.check { color: #1a7f37; font-weight: bold; }
.next { color: #b35900; font-weight: bold; }
.review { color: #0969da; font-weight: bold; }
.nope { color: #82071e; font-weight: bold; }
.q { color: #8250df; font-weight: bold; }
code { background: #f2f2f2; padding: 0 0.25em; border-radius: 3px; }
em { color: #555; }
</style>
</head>
<body>
<h1>Phase 7 — Wiki UX</h1>
<p><em>Arc started 2026-07-22, after Phase 6. 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:
<span class="next">▶</span> being worked on,
<span class="review">⚑</span> implemented and waiting to be reviewed,
<span class="check">✓</span> done,
<span class="check">✓</span><span class="nope">✗</span>
closed by deciding not to do it, <span class="q">?</span> open decision,
unmarked = planned.</em></p>
<h2>What this arc is for</h2>
<p>The wiki is where a group keeps its shared pages, so its editing and
resource surfaces are what a group touches most. This arc finishes the
page-resource folder UI (I), gives the editor a live preview and then a
markup toolbar (II), makes the category mechanism a real content tool (III),
completes the email witness flow for secret ballots (IV), and documents the
folder UI once it is finished (V).</p>
<h2>I. Finish the page-resource folder UI</h2>
<p>To-do item 17. Most of it landed in Phase 2.5; two pieces stayed open.</p>
<ol class="items">
<li><span class="check">✓</span> A. <strong>Multi-select with keyboard
shortcuts.</strong> Pick rows with click, Shift-click and Control-click,
then act on the picked set with Delete, F2, Control C/X/V and Escape.
<p>Rows are picked in the browser and nothing is sent until an action is
asked for, when the whole set goes as one request. That needed the
server widened: <code>SocialComponent</code> now answers
<code>delete_resources</code>, <code>move_resources</code>,
<code>clip_copy_resources</code> and <code>clip_cut_resources</code>,
each taking a list. Dragging carries the picked set to either drop
target, dimming the rows it carries and showing a count.</p></li>
<li><span class="check">✓</span> B. <strong>CSS-Grid
auto-fill rewrite of the folder layout.</strong>
Replace the fixed-<code>in</code>-width table with
<code>repeat(auto-fill, minmax(min, 1fr))</code> and a container query so
the folder reflows to the width it is given.
<p><code>table-layout: fixed</code> and four hard column widths gave the
list a 502px floor that overflowed a phone's folder box by 156px; the
list is now a grid whose rows take the table's tracks through
<code>subgrid</code>, and the cards are <code>auto-fill</code> so they
need no column count at all. <code>.wiki-resources</code> is the query
container, so narrowing the folder with the window left wide is what
drops columns, which the old viewport media query could not express.
Measured: list content 502px to 344px at a 360px window, cards 167px to
282px in a 576px folder, desktop unchanged.</p></li>
</ol>
<h2>II. Editor preview and assist</h2>
<p>To-do items 19 and 20. The renderer in both is the existing
<code>WikiParser</code>, so neither adds a client-side parser.</p>
<ol class="items">
<li><span class="review">⚑</span> A. <strong>Live preview
pane.</strong> Split the editor into textarea and preview, refreshed as
the source is typed by rendering it through <code>WikiParser</code>.
<p>The rendering happens in the browser through the port of
<code>WikiParser</code> already in <code>help.js</code>, so a keystroke
costs no request; it waits for a pause in typing, and nothing is
rendered at all until the book beside the way into the page's history
opens the pane, which starts closed and leaves the source the whole
width until it does; the book is shut while the preview is out of
sight and open while it is being read. Source and preview share one window of a settled
height, split by the existing <code>split-adjuster</code> from
<code>wiki.js</code>, and the editor's row of controls moves above both
columns as the preview opens so the two start at the same place and end
level; either can be given more room by dragging, each is put at the other's place in the document as it is
scrolled. Under 52rem the pair becomes one column with the preview
above the source, and a markdown group gets no pane since only the
mediawiki engine is ported. Picking out words in either pane shows them
in the other: the two are matched on their letters and digits alone,
since markup is in one and not the other, and the answer is carried
back to where those letters sit in each. A resource written out as its
own data draws in the preview, since everything needed is in the
markup.</p></li>
<li>B. <strong>Markup-insertion toolbar.</strong> Buttons that insert wiki
markup at the cursor, with the textarea still the source of truth and the
preview from II.A showing the result. Comes after II.A. Proposed.</li>
</ol>
<h2>III. Category templates</h2>
<ol class="items">
<li>A. <strong>Flesh out the category mechanism.</strong> Grow
<code>{{category|...}}</code> and <code>{{category-list|...}}</code> into
a real content tool: named parameters, style presets, pagination,
auto-stub index pages, a "Filed under" affordance, cross-group lookup,
and semantic HTML in place of the custom tags. To-do item 18.
Proposed.</li>
</ol>
<h2>IV. Asynchronous secret-ballot witnesses</h2>
<ol class="items">
<li>A. <strong>Email-based witness flow.</strong> Complete the asynchronous
flow for <code>{{secret-ballot|w1|w2|...}}</code> so named witnesses take
part by email rather than having to be present at once. To-do item 21.
Proposed.</li>
</ol>
<h2>V. Document the resource-folder UI</h2>
<ol class="items">
<li>A. <strong>Write up the wiki resource-folder UI.</strong> Document the
folder interface and its actions as wiki help source delivered as a
<code>.txt</code>, not edited into the autogenerated help pages. Best
done after I. To-do item 37. Proposed.</li>
</ol>
<h2>Order and dependencies</h2>
<p>I, II, III and IV are independent. Within II the preview comes before the
toolbar; V waits on I so it describes finished behavior.</p>
<h2>Other work accomplished</h2>
<p>Oldest first.</p>
<ol class="items numbered">
<li><span class="check">✓</span> <strong>Git clone and pull over
HTTP/2 failed.</strong> The Phase 6 smart-HTTP serving built the whole
packfile synchronously on the event loop, so the server answered nothing
while it ran and the client's connection went unserviced.
<p>Git requests now run on the cooperative fiber path and the build
suspends periodically. The earlier attempt to pace the send with
<code>stream()</code> was aimed at the wrong layer and, by dropping
<code>webExit</code>, let the group controller render onto the reply; it
was undone here.</p></li>
<li><span class="check">✓</span> <strong>Pull failed with "bad line
length character: PACK".</strong> A client with a long history negotiates
in rounds, sending haves without saying "done" and expecting only an
acknowledgment; the server answered with the packfile.
<p>A round with no depth and no "done" now returns just the
acknowledgment, and skips the history walk since that round asks for no
objects. A five-commit client sends every have and "done" at once, which
is why the trouble looked size-dependent.</p></li>
<li><span class="check">✓</span> <strong>Negotiation never settled and
packfiles were held in memory.</strong> The server never named a commit
the two sides shared, so a client gave up after a couple of hundred
offers and asked for the whole history, which was then built as one
string.
<p>The server now offers <code>multi_ack_detailed</code>, naming
recognized commits as common and closing the round with
<code>NAK</code>; a flush there is read as a missing acknowledgment and
no closing line leaves the client waiting. The reply is written to a
stream with a running checksum. A seven-hundred-commit pull settles in
two rounds, and 17.7MB is built with 4.0MB of memory against 21.7MB as a
string.</p></li>
<li><span class="check">✓</span> <strong>A deferred reply cannot also
stream.</strong> <code>completeDeferredResponse</code> sends only the
buffered body, so a git request on the fiber path sent headers claiming
eighty megabytes and then nothing.
<p><code>GitUploadPack</code> now yields its reply piece by piece and
<code>serveUploadPack</code> hands that to the ordinary streaming path.
The fiber breathing and its interval constant went with it.</p></li>
<li><span class="check">✓</span> <strong>An HTTP/2 reply must state its
length.</strong> Sending with no stated length gave
<code>content-length: 0</code> and an empty body, since the length is
taken from the buffered body, which a streamed reply leaves empty.
<p>The yielded pieces are written to a temporary file, its size is the
stated length, and the file is streamed back and removed. Building before
sending means a very large clone occupies the server while its packfile
is made.</p></li>
<li><span class="check">✓</span> <strong>Every version of every file
was sent.</strong> The <code>have</code> lines stopped the commit walk but
nothing was set against what the client holds, so a one-commit update
packed a copy of the whole working tree.
<p>The walk now gathers everything the client's known commits record and
skips it. A client one commit behind was sent 63 objects and 17.6MB and
is now sent 3 objects and 1,755 bytes.</p></li>
<li><span class="check">✓</span> <strong>Objects were sent
whole.</strong> A new <code>GitDelta</code> writes and reads Git's delta
format, working on bytes so binary content is handled too.
<p><code>Utility.php</code>'s <code>diff</code> and
<code>computeLCS</code> were considered and not used: they compare lines
and build a table capped at a quarter of a million cells.
<code>GitPackReader</code>'s private applier moved into
<code>GitDelta</code>, leaving one implementation. A clone of a
forty-commit history of a large file fell from 898,559 to 105,915
bytes.</p></li>
<li><span class="check">✓</span> <strong>Differences could only be
built against objects in the pack.</strong> An update to a file whose
earlier version the client already held was still sent whole.
<p>The server now offers <code>thin-pack</code> and remembers the version
of each file the client holds, sending a new version as its difference
from that. A one-line edit fell from 22,508 bytes to 331.</p></li>
<li><span class="check">✓</span> <strong>A deep clone of the live
repository stopped partway.</strong> The reply stated 34,671,195 bytes
and the connection closed with a differing amount delivered each time,
over HTTP/1.1 as well as HTTP/2.
<p>Not the link: the same server sent a 142MB file from the repository to
the same machine complete, so what differs is how the two are sent, and
the packfile now goes out through <code>readfile</code> as that file
does. The earlier change to the idle sweep, which treats a connection
with a reply still to send as active, stays, though it was never tied to
this. <code>H3Listener</code> does not share that defect, its idle check
counting outbound packets.</p></li>
<li><span class="check">✓</span> <strong>Say where the server's memory
is going.</strong> The web server sat at 1924MB of a 2000MB limit for
minutes, so the search that finally failed asked for 1.25MB and had
nowhere to put it.
<p>The memory line now also reports about how many bytes the kept
sessions hold, from a sample of twenty, and how many sit in connection
buffers. Sessions are capped by count rather than weight, so their number
alone did not say.</p></li>
<li><span class="check">✓</span> <strong>The mail server ran out of
memory during a read.</strong> It sat at about 120MB for an hour, then
went to 445MB and to its 1GB limit within ten seconds, with the
connection buffers empty at the last reading before it died.
<p>A connection's unread input was allowed 256MB on a 1GB limit, half of
memory while the buffer grows, so it is now 32MB, one message plus its
command. A stored message was read whole however large the file had
become, so its size is checked first, on the storage every backend
shares. The loop now records which IMAP command it is running, so the
next crash line names it.</p></li>
<li><span class="check">✓</span> <strong>A missing wiki page left nulls
running through the media path.</strong> A warning, a deprecation and a
second warning on one request were one defect:
<code>getPageInfoByName</code> answers <code>false</code>, reading
<code>PAGE</code> off it warns and yields null, and that null reached
<code>preg_match</code>.
<p>All three now check that what came back is an array, in both places
the media path does this, and the sign-in check treats a missing page as
an empty one.</p></li>
<li><span class="check">✓</span> <strong>Every page was wider
than its own window between about 601 and 1060 pixels.</strong>
<code>min-width: 66rem</code> on <code>.body-container</code> meant the
content column could never be narrower than 1056px however narrow the
window was, and the phone layout does not take over until 600px.
<p>That width and the floor under it are now held to the window as well
as to their design, and seven more floors underneath doing the same
thing give way to their container the same way. Measured on two pages at
ten widths, the document's scroll width now equals the window at every
width from 601 up, where it had been stuck at 828 and 933. It fed back
into I.B, where the folder can now take widths that left one stretched
card per row.</p></li>
<li><span class="check">✓</span> <strong>Reporting an issue against a
repository needed an account.</strong> The tracker is public to read, so
a visitor who hits a bug either registers or says nothing, and letting
anyone report must not also let anyone spam.
<ol class="items">
<li><span class="check">✓</span> a. Let a signed-out visitor
submit a report, held rather than numbered, since an issue number is
permanent and a spam run would spend a block of them. The reporter
gives an unverified handle stored beside an opaque token derived
from their address, so an editor can recognize a repeat reporter
without seeing one.
<p>A submission passes a backoff counted apart from the sign-in
forms, a decoy field, a timing check, stateless proof of work and a
rolling daily allowance, and is told in six words or fewer what
became of it. Testing found the proof of work replaced the string an
answer was answering before reading the answer, and kept it in a
session a cookieless visitor does not have, so it now takes string
and time back from the form. A refusal names its reason except where
naming it would tell a script what to fix.</p></li>
<li><span class="check">✓</span> b. Add the editor queue as one
more issue filter listing waiting reports, minting the number
through the existing <code>createGitIssue</code> only on acceptance.
An accepted issue shows the handle where the reporter column would
otherwise show a username.
<p>Rows are selected the way rows are selected elsewhere and decided
together, and rejecting runs through one control whose catch also
shuts the reporter out for a week, a month or for good, with a
searchable paged list of who is shut out and a control to let them
back in. Both lists page through one query narrowing on page name
and record text, so only the rows shown are opened, and each list
is read by scrolling: reaching the end asks for the next page and
the rows arrive into the table, the way a feed reads. A waiting
report and a ban are each a page beside the repository, told apart
from a numbered issue by the mark after its name, which needed a
one-off rename of existing issue pages.</p></li>
<li><span class="check">✓</span> c. Give the repository a setting
— none, moderated or unmoderated — defaulting to
moderated, so it can be on for one repository and off elsewhere.
<p>It is a field in the repository page's own settings rather than a
group setting, which keeps the whole feature clear of any schema
change. <code>validateHashCode</code> moved from
<code>RegisterController</code> up to <code>Controller</code> so the
account forms and this one ask the same question through one copy of
it.</p></li>
</ol></li>
<li><span class="review">⚑</span> <strong>A second column heading
appeared part way down a scrolled issue list.</strong> The pager took
every row out of an answer, so when an answer was a whole page rather
than the rows alone, the page's own heading row came with it while the
markup around it was dropped by the table parser.
<p>It now takes only rows of the list itself, and an answer carrying
none of them ends the paging rather than being asked for again.
Measured against a whole-page answer, the old reading gave 11 rows with
a heading among them and the new one gives 10 rows with none. The rows
are also read back inside a table rather than a detached table body,
which is the one context where a browser is required to parse them as
rows.</p></li>
<li><span class="review">⚑</span> <strong>An activity that is mostly
working area was held to a reading width.</strong> The content column
stopped well short of the browser window and the activity inside it was
capped narrower still, which left the wiki editor, mail and messages
using a fraction of a wide screen.
<p>The column now stands 1.2rem narrower than the window, through a
named property, the margins either side of the centered container are
halved to 1.5rem, and an activity whose width is working room rather
than a line of prose carries a class that drops the cap: the wiki page
whether written or read, mail, and messages. Measured at 1920 pixels
the column went from 1322 to 1901 and the wiki page from 885 to 1853 in
both modes, with nothing overflowing at 360, 601, 800 or 1060 pixels.
Every length this arc added is said in rem and every direction as a
logical property, which is what Phase 5 settled the stylesheet on.</p>
</li>
<li><span class="review">⚑</span> <strong>The mail server ran out
of memory again, during an IMAP command.</strong> It climbed from 114MB
to its 1GB limit in about two minutes with the connection buffers empty
and no fibers parked, and the fatal named only "imap key=2259 UID",
which says neither whose mail was being read nor whether the command
was a fetch, a search or a store.
<p>The crash line now names the account, the folder and the word after
UID, and the periodic memory line says how many folder indexes are
cached and how many records they hold, so the next occurrence answers
both questions itself. Rebuilding a folder's index no longer grows its
snapshot as one string nor reads the whole index a second time as
records; it streams both files a line at a time and takes only flags
from the index. Measured on a folder of 77,000 messages that is 2.0MB
held beside the records against 6.2MB, but the same measurement shows
neither figure explains a climb of 319MB, so the cause is not yet
established.</p></li>
<li><span class="review">⚑</span> <strong>The server's default
settings were stated in two places and the constructor set only two of
them.</strong> The full set was built inside <code>listen</code>, so
anything reading a setting before listening began found nothing there,
and the root index carried its own copy of the request-size default.
<p>The settings now live in <code>defaultServerGlobals</code> alone.
The constructor starts a server with all of them rather than two,
<code>listen</code> starts from the same call, and the root index asks
for the one it wants. Checked against the source, all fifteen settings
read anywhere in the class are among them, bar one read with a fallback
of its own.</p>
<p>Separately, the file cache remembers the hash of what was last read
from or written to each path, and a write whose contents match that
hash is skipped, so saving something unchanged costs no disk
write.</p></li>
<li><span class="review">⚑</span> <strong>A session's contents were
held in memory with nothing bounding their size.</strong> Ten thousand
sessions were allowed whatever they each weighed, and the reported
figure was an average of twenty scaled up, which would miss one large
session among many small ones entirely.
<p>A session's contents now live in a file of their own, read back when
a request needs them and written through the cache above, so a request
that does not change a session costs no write at all. What the server
holds for each is its time, its use-count and nothing else, which is
all the sweep for stale sessions and the eviction of the least recently
used ever needed; evicting one now takes its file with it. A session
grown past what the server accepts as a request body is refused rather
than written.</p>
<p>The block in <code>listen</code> that restored sessions from a
configuration array is gone: nothing produced what it read.</p></li>
<li><span class="review">⚑</span> <strong>A test run printed a line
that was not a test result.</strong> Refusing to read an oversize
stored message went to the error log, which under the command-line PHP
is standard error, so the run said so in the middle of its results.
<p>A storage class now reports a problem through a handler that can be
handed in, falling back to the error log when none is. The test that
makes an oversize message catches it, so a run says only what the tests
themselves say, and the saying of it is checked rather than merely
heard. Measured on a filtered run, standard error is empty and every
line on standard output is a test result or a heading.</p></li>
<li><span class="review">⚑</span> <strong>The cached folder indexes
were bounded by how many folders were held, not by how much.</strong>
One mailbox of a hundred thousand messages outweighs sixty small ones,
and the rebuild that compaction calls for rewrote the whole index every
time even when it produced what the file already held.
<p>The cache now has a ceiling on the message records held across it
and drops the least recently used until they fit, and a folder too
large to fit on its own is not held at all rather than emptying the
cache for something that still would not. The snapshot a rebuild would
write is hashed a line at a time and the write is skipped when the file
already holds it. Measured on an index of 154,853 lines and 77,427
messages, the file is 3.2MB, one read costs 0.352 seconds and the
records it makes cost 48MB.</p>
<p>What the cache holds changed too: three lists of plain values beside
each other, keyed by uid, rather than a small array for every message.
A record holds four scalars and PHP spends more on the array around
them than on the values inside, so the same folder costs 6.01MB where
it cost 48MB, and building the records back out of it takes 0.035
seconds against 0.352 to parse the index file again. A first attempt at
this packed the values into one binary string, which measured slower to
decode than re-reading the file and was dropped; plain values keyed by
uid need no unpacking at all. Counting live messages needs no records
built, since the uids are the keys the compact form is already keyed
by.</p></li>
</ol>
</body>
</html>