The system gets a uniform
After a few weeks of building features, EmptyOS had a frontend that worked but didn't feel like one system. Every app had its own card styling, its own status pill, its own settings layout, its own way of confirming a delete. Some apps called native confirm(). Some used a hand-rolled modal. Status badges appeared in three different shades of green depending on which author had been in the file most recently. The whole thing was shaped like a federation of small utilities rather than a platform. Today was about finishing the unification: pulling every scattered pattern into shared primitives until no app needed to reinvent any of it.
The first move was entity cards. A new EOS_UI.entityCard({title, subtitle, badges, body, meta, actions, onClick}) builder landed in eos-components.js, backed by .eos-entity-card + .eec-{head,title,sub,body,meta,badges,actions} layout classes in the shared CSS. It's the list-item shape every app had been reinventing — a card with a title row, optional body, a meta row, an actions row. The projects kanban and list views migrated first (dropping a local .status-* + .project-card stylesheet), then publish's post list (dropping .post-item/.pi-title/.pi-meta/.pi-tags), then capture's triage cards, then music-studio's history cards, then note's list rows. Each migration deleted more lines than it added. The invariant we were chasing: a list of things in any EmptyOS app should be an entityCard call, not a hand-rolled <div class="my-app-card">.
Alongside it, a shared badge system. .eos-badge as the base, variants like .eos-badge-status-active, .eos-badge-priority-high, .eos-badge-age-stale, .eos-badge-neutral. Three apps had been carrying their own status-pill CSS with slightly different colours; they now resolve to the same palette. The .is-draft modifier on an entity card targets the shared class instead of each app's local override. Once this landed, a quick grep invariant went clean: no more hand-rolled .status-* classes anywhere in apps/*/pages/.
Native dialogs had to go. confirm(), alert(), and prompt() break theme, break mobile, and break the tab you're on. EOS_UI.confirm() got a Promise-based upgrade — it now supports both the old callback form (existing callers unchanged) and an async form (if (!await EOS_UI.confirm('Delete?')) return), plus an options object for custom button labels and a danger flag. Then we went through every app and migrated. Seventeen apps had their native-confirm delete flows replaced. Seven had no confirmation at all — added. The publish writer's five prompt() calls (for link, image, video, code, and callout insertion) and one confirm() (template replace warning) turned into EOS_UI.formModal / EOS_UI.confirm. Tests, projects, note, settings — all migrated the same way. The grep invariant we were enforcing: zero native confirm/alert/prompt anywhere in apps/**/pages/*.html. By end of day it was clean.
Settings panels were the third sweep. Twelve apps had declared [provides.settings] in their manifests but had no UI surface for it — the settings existed, but there was no way for a user to edit them without opening /settings and guessing the key. The in-app settings panel rule (CLAUDE.md §In-App Settings Panel) mandates a ⚙ button opening EOS_UI.settingsPanel({id, title, fields}). We went through and added the button + fields config to task, journal, assistant, music-studio, model-bench, billing, dictionary, focus, search, quotes, capture, and publish. The publish app had a per-site panel built from a hand-rolled .settings-panel stylesheet — migrated to the shared .eos-settings-panel/.sp-head/.sp-body chrome, dropping duplicate CSS. A bug along the way: the journal app wasn't loading eos-components.js at all — all EOS_UI.* calls had been silently undefined. Added the import.
One non-obvious fix underneath all of this: a stray }; at line 1240 of eos-components.js had been prematurely closing the EOS_UI object since commit d4e5f3f, and six methods after it (filterBar, cardGrid, slidePanel, tagCloud, scrollNav, reveal) were silently unreachable. Every UI test that depended on them had been failing quietly, and nobody noticed because the failures were structural (the functions didn't exist) rather than logical (they behaved wrong). Caught by running node --check on the file. The moral: when tests are failing and nothing visibly broke, the whole script might not be parsing.
A different thread in the same day: a reusable reference-test framework at tests/reference_validator.py. The pattern is generic — any computation-heavy app drops a JSON file in tests/references/ with expected values and tolerances (exact, abs, rel, range, gt/lt, not_null), and parametrised pytest cases get generated automatically. Dot-path field access means the JSON refers to any output key without the test file needing to enumerate them. The first consumer ran 11 reference cases of a computation through the framework, exact-match against a known external reference. A shared EOS_UI.testPanel() component followed — a slide-out for per-app test results, grouped by test class, with per-test pass/fail dots and raw-output toggle — backed by a new POST /tests/api/run-app endpoint in the tests app that discovers the test file for a given app, runs it, and returns parsed per-test results.
One new skill came out of the consolidation work: .claude/skills/eos-ui-consolidate/ codifies the audit → migrate → verify workflow. Inventory grep for hand-rolled dialogs, status pills, settings panels, entity cards. Report what's still nonstandard. Migrate reference apps first, then run the apps' system tests, then re-grep to verify. The second pass of the day used it end-to-end: note got entityCard, publish got its global-setting panel exposed. The skill makes the next audit a three-line invocation instead of a discovery exercise.
Deferred explicitly, not by oversight: focus's .session-item, task's .task-item, tests's .test-row, and a few music-studio rows. Each has a distinct shape (compact single-line, grid, inline sub-task) that would force a horizontal→vertical redesign if pushed into entityCard. Better to wait for /eos-simplify to flag them when those apps are next touched than to force a uniform that makes the UX worse.
The shape of the day: a frontend that had been a federation of twenty dialects became a single language. Shared cards, shared badges, shared dialogs, shared settings, shared test surface. Every future app builds on the uniform by default; the cost of a new app dropped by a visible amount because none of this has to be reinvented. Identity is consolidation — when the parts stop looking like they came from twenty different authors, the whole starts looking like a system.