Creating a topic failed intermittently — "a topic with that slug already
exists" — and worked when the author retried with a different name. The rule
was never intermittent, only invisible: the slug kept `[a-z0-9]` and dropped
everything else, so a Korean name contributed nothing. Whatever Latin word or
number happened to be in it became the entire slug.
Two ways that goes wrong, and the author hit both. `인증` reduced to an empty
string, which the form refused before a request was ever sent. `Redis 캐시` and
`Redis 클러스터` both reduced to `redis`, so the second one collided with the
first — a real conflict, reported honestly, about a slug the author never chose
and could not see.
Hangul is now romanized rather than discarded. Syllables decompose
arithmetically into initial, medial and final jamo, so this needs no table and
is deterministic: `백엔드 아키텍처` becomes `baekendeu-akitekcheo`. Only the
jamo mapping from Revised Romanization is applied — the sound-change rules are
deliberately left out, because a slug is read, not pronounced, and those rules
would make one name produce different slugs in different contexts.
The output keeps the shape document slugs already use
(`^[a-z0-9]+(?:-[a-z0-9]+)*$`), so the repository has one slug rule rather than
two, and the tests assert exactly that.
Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.
The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.
Two real defects surfaced while making the public port async, and both would
have shipped:
The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.
The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.
The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.
Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.