openvibers/openvibe.work Commits

Work: Hacker News Who is hiring listings through OpenVibe.Sources (#137)

OP OpenVibers committed 14 days ago · 57fed10acc · 10 files, +156 −15

* Work: Hacker News Who is hiring listings through OpenVibe.Sources

Adds the hn-whoishiring source to the from-sources map and a rawOfHn mapping: the company and role come from the comment's first line, the thin-listing gate still applies, and the listing links the HN comment and is attributed to the thread. Work never calls the Hacker News API itself; WORK_HN_WHOISHIRING_FROM=sources switches it on. The home/source tests and the HTML budget account for the fourth board row.

* Merge origin/main into the Hacker News source branch

Changes

ddiff --git a/README.md b/README.md
iindex 643a5d6..92babbb 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Find work on open job boards.
--**Status:** the listings, the search and the agent pillar are live (listings gathered from three public job boards,
++**Status:** the listings, the search and the agent pillar are live (listings gathered from four public job boards (two of them read through OpenVibe.Sources),
search, saved searches, job applications with their status history, help preparing one from the person's OpenVibe.Actor
agent, an API and the pages around them).
**Domain:** `openvibe.work` · **Port:** 4960 · **Service id:** `work` · **Env prefix:** `WORK`
@@@ -31,7 +31,7 @@ republished: Work stores a short plain-text excerpt and sends the reader to the
| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |
| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |
| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `work_at` / `work_rt` cookies; `/auth/me` for the shared navbar |
--| Sources | [server/jobs/sources.js](server/jobs/sources.js) | The three boards: where each is fetched, its terms, how its payload becomes a listing, and what is refused |
++| Sources | [server/jobs/sources.js](server/jobs/sources.js) | The boards: where each is fetched, its terms, how its payload becomes a listing, and what is refused |
| Ingest | [server/ingest/](server/ingest/) | The background read (timer, User-Agent, 15 s timeout, upsert by source + the board's id, expiry) |
| Listings | [server/jobs/store.js](server/jobs/store.js), [migrations/0002_listings.sql](migrations/0002_listings.sql) | Search, facets, the cursor, saved searches, the fetch record |
| Text | [server/jobs/text.js](server/jobs/text.js) | HTML → plain text, and the ~300-character excerpt that is all we keep |
@@@ -71,7 +71,7 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
the canonical subject. Work holds only `user:usr_…` on its own rows.
- **Event delivery** — OpenVibe.Events: Work receives `network.account.export_requested` and `network.account.deleted`
and creates its two subscriptions at boot; it does not own the topics or the delivery.
--- **The listings themselves** — the boards (Arbeitnow, Remotive, RemoteOK): Work keeps a short excerpt and a link back,
++- **The listings themselves** — the boards (Arbeitnow, Remotive, RemoteOK and Hacker News "Who is hiring"): Work keeps a short excerpt and a link back,
never the full description, and reads each board only on the terms it publishes.
## Depends on
@@@ -85,8 +85,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
- **OpenVibe.Actor** — the person's agent: Work posts `POST /api/v1/tasks` with its own service token (capability
`actor.task.create`, audience `openvibe.actor`) when a person asks for help preparing an application
(`WORK_ACTOR_URL`, `WORK_ACTOR_PUBLIC_URL`, `WORK_ACTOR_TIMEOUT_MS`).
--- **The three job boards** — the only hosts the ingest fetches: Arbeitnow, Remotive and RemoteOK's public APIs
-- (`WORK_ARBEITNOW_URL`, `WORK_REMOTIVE_URL`, `WORK_REMOTEOK_URL`, `WORK_USER_AGENT`).
++- **The job boards** — Arbeitnow, Remotive and RemoteOK are the only hosts Work itself fetches (`WORK_ARBEITNOW_URL`,
++ `WORK_REMOTIVE_URL`, `WORK_REMOTEOK_URL`, `WORK_USER_AGENT`). We Work Remotely and Hacker News "Who is hiring" arrive
++ through OpenVibe.Sources (`WORK_WWR_FROM=sources`, `WORK_HN_WHOISHIRING_FROM=sources`).
- **PostgreSQL** (`DATABASE_URL`, `DATABASE_DIRECT_URL`; an embedded PGlite database in development via
`WORK_PGLITE_DIR`) and **Valkey** for shared limit counters (`VALKEY_URL`, `VALKEY_PREFIX`).
- **Packages**: `openvibe-contracts` v0.222.0, `openvibe-sdk` v0.74.0 (`db`, `auth`, `account-data`, `limits`,
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
iindex 6f1404f..7c0b19d 100644
---- a/server/agent-descriptor.js
++++ b/server/agent-descriptor.js
@@@ -7,7 +7,7 @@
module.exports = {
id: 'work',
name: 'OpenVibe.Work',
-- purpose: 'Find work on open job boards: listings from Arbeitnow, We Work Remotely, Remotive and RemoteOK under their terms, searchable with facets and each linking back to the original, plus saved searches and OpenVibe.Watch alerts for a signed-in person.',
++ purpose: 'Find work on open job boards: listings from Arbeitnow, We Work Remotely, Hacker News "Who is hiring", Remotive and RemoteOK under their terms, searchable with facets and each linking back to the original, plus saved searches and OpenVibe.Watch alerts for a signed-in person.',
home: 'https://openvibe.work',
site: 'openvibe.work',
api: '/api/v1',
ddiff --git a/server/config.js b/server/config.js
iindex 767fc6f..5b76e81 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -93,6 +93,9 @@ function load(env = process.env) {
// We Work Remotely reaches Work the same way (WORK_WWR_FROM=sources, the weworkremotely key in Sources);
// it is off until the board is read through Sources, and Work never fetches the feed itself.
weworkremotelyFrom: String(env.WORK_WWR_FROM || '').trim().toLowerCase() === 'sources' ? 'sources' : 'board',
++ // Hacker News "Who is hiring" reaches Work the same way (WORK_HN_WHOISHIRING_FROM=sources, the
++ // hn-whoishiring key in Sources); its resolver reads the thread, never Work itself.
++ hnWhoishiringFrom: String(env.WORK_HN_WHOISHIRING_FROM || '').trim().toLowerCase() === 'sources' ? 'sources' : 'board',
},
// The background ingest (server/ingest/): off unless WORK_INGEST=on, so a test and a development run
// never call a job board. Once on, each source is fetched at most every intervalMs (Remotive's terms
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 49435f7..21df58d 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -25,7 +25,7 @@ const SITEMAP_JOBS = 1000;
const PAGE_TEXT = {
'/': ['OpenVibe.Work home', 'OpenVibe.Work gathers job listings from public job boards, names the source of every one and links back to the original listing. Search is free and needs no account.'],
-- '/jobs': ['Jobs', 'Search job listings gathered from Arbeitnow, We Work Remotely, Remotive and RemoteOK, newest first, filterable by words, workplace, job type and location.'],
++ '/jobs': ['Jobs', 'Search job listings gathered from Arbeitnow, We Work Remotely, Hacker News "Who is hiring", Remotive and RemoteOK, newest first, filterable by words, workplace, job type and location.'],
'/companies': ['Companies hiring', 'Every company with open listings on the boards OpenVibe.Work reads, most openings first, each with a page of its jobs, where they are and which boards list them.'],
'/sources': ['Sources', 'The public job boards OpenVibe.Work gathers from, the terms each is used under as applied here, and when each was last read.'],
'/docs': ['API', 'The OpenVibe.Work API: search the listings, read one with its provenance, list the sources and their terms, keep saved searches.'],
ddiff --git a/server/ingest/from-sources.js b/server/ingest/from-sources.js
iindex ff88c54..bf08d05 100644
---- a/server/ingest/from-sources.js
++++ b/server/ingest/from-sources.js
@@@ -101,6 +101,44 @@ function rawOfWwr(item) {
};
}
++/**
++ * A Sources item → the row a Hacker News "Who is hiring" comment makes. The resolver's title is the comment's first
++ * line, which posters usually write as "Company | Role | Place" or "Company is hiring a role": the company comes from
++ * that, and a comment that does not name one is held by the same thin-listing gate as any other board. The excerpt is
++ * Sources' plain-text excerpt, already stripped of email addresses and phone numbers; the full comment is its link.
++ */
++function rawOfHn(item) {
++ const f = (item && item.fields) || {};
++ const first = String((item && item.title) || '').replace(/\s+/g, ' ').trim();
++ const parts = first.split(/\s*\|\s*/);
++ let company = '';
++ let title = first;
++ if (parts.length >= 2 && parts[0].trim()) {
++ company = parts[0].trim();
++ title = parts.slice(1).join(' | ').trim() || first;
++ } else {
++ const m = /^(.+?)\s+(?:is|are)\s+hiring\b/i.exec(first);
++ if (m && m[1].trim()) {
++ company = m[1].trim();
++ title = first.slice(m[0].length).replace(/^[\s:,-]+/, '').trim() || first;
++ }
++ }
++ const summary = String((item && item.summary) || '');
++ return {
++ source_id: item.identity,
++ url: item.canonical_url,
++ title,
++ company,
++ location: null, // the comment rarely states one; the title may, in its own words
++ remote: /\bremote\b/i.test(`${first} ${summary}`),
++ tags: list(f.tags),
++ job_type: null,
++ salary: null,
++ posted_at: item.published_at || null,
++ description_html: summary,
++ };
++}
++
/**
* The boards Work reads through OpenVibe.Sources: the Sources key an item arrives under, the id the listing is
* stored with, the kind of item that source keeps, and how the item becomes a listing.
@@@ -108,12 +146,14 @@ function rawOfWwr(item) {
const SOURCES = {
[SOURCE_KEY]: { source_id: SOURCE_ID, kind: 'job', rawOf },
'weworkremotely': { source_id: 'weworkremotely', kind: 'article', rawOf: rawOfWwr },
++ 'hn-whoishiring': { source_id: 'hn-whoishiring', kind: 'job', rawOf: rawOfHn },
};
/** The switch that says a board comes from Sources instead of from its own fetch. */
const SWITCH = {
arbeitnow: (config) => (config.sources && config.sources.arbeitnowFrom) === 'sources',
weworkremotely: (config) => (config.sources && config.sources.weworkremotelyFrom) === 'sources',
++ 'hn-whoishiring': (config) => (config.sources && config.sources.hnWhoishiringFrom) === 'sources',
};
function createFromSources({ config, s, fetchImpl = globalThis.fetch, log = console, source = null, afterRun = null, indexnow = null }) {
@@@ -199,4 +239,4 @@ function createFromSources({ config, s, fetchImpl = globalThis.fetch, log = cons
return { enabled: Boolean(client), pull, start, stop, apply, SOURCE_ID, sourceIds: active.map(([, e]) => e.source_id) };
}
--module.exports = { createFromSources, rawOf, rawOfWwr, SOURCES, SOURCE_ID, SOURCE_KEY };
++module.exports = { createFromSources, rawOf, rawOfWwr, rawOfHn, SOURCES, SOURCE_ID, SOURCE_KEY };
ddiff --git a/server/jobs/sources.js b/server/jobs/sources.js
iindex e00ce98..8e07992 100644
---- a/server/jobs/sources.js
++++ b/server/jobs/sources.js
@@@ -99,6 +99,17 @@ const SOURCES = [
requests() { return ['https://weworkremotely.com/remote-jobs.rss']; },
parse() { return []; }, // Work never reads this feed itself: the items arrive through Sources
},
++ {
++ id: 'hn-whoishiring',
++ name: 'Hacker News: Who is hiring',
++ homepage: 'https://news.ycombinator.com/',
++ terms: 'Hacker News makes its public data available through its API. OpenVibe.Sources reads the monthly "Ask HN: Who is hiring?" thread\'s top-level comments, one job per comment with emails and phones removed, and links the comment; Work follows that change feed. Attribute Hacker News and the poster.',
++ attribution: 'Listing from Hacker News "Who is hiring"',
++ remoteDefault: false,
++ viaSources: true, // the resolver in OpenVibe.Sources is the only reader
++ requests() { return []; },
++ parse() { return []; }, // Work never calls the Hacker News API itself
++ },
{
id: 'remotive',
name: 'Remotive',
ddiff --git a/test/hn-whoishiring-sources.test.js b/test/hn-whoishiring-sources.test.js
nnew file mode 100644
iindex 0000000..6c2a76e
---- /dev/null
++++ b/test/hn-whoishiring-sources.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++/**
++ * Hacker News "Who is hiring" through OpenVibe.Sources (server/ingest/from-sources.js), against a Sources stand-in:
++ * - a comment becomes a listing with its company and role read from the first line ("Company | Role | Place" or
++ * "Company is hiring …");
++ * - the thin-listing gate still applies: a comment with no company, or a one-letter title/company, is held;
++ * - the listing links the HN comment and is attributed to Hacker News "Who is hiring";
++ * - Work never calls the Hacker News API itself.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const store = require('../server/jobs/store');
++
++const feed = [];
++let seq = 0;
++const push = (item) => feed.push({ ...item, change_seq: ++seq });
++const sourcesClient = {
++ enabled: true,
++ async listItems({ after = 0, limit = 100 } = {}) {
++ const page = feed.filter((i) => i.change_seq > after).slice(0, limit);
++ return { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: false };
++ },
++};
++
++// A recorded item of the resolver's output (a fixture: no live call here).
++const hn = (identity, title, summary, over = {}) => ({
++ id: `itm_${identity}`, source_key: 'hn-whoishiring', category: 'jobs', kind: 'job', identity,
++ canonical_url: `https://news.ycombinator.com/item?id=${identity.replace('hn-', '')}`,
++ title, summary, authors: ['acme_founder'], published_at: '2026-10-01T15:00:00.000Z', source_updated_at: null,
++ fields: { story_id: '101', story_title: 'Ask HN: Who is hiring? (October 2026)', story_url: 'https://news.ycombinator.com/item?id=101' },
++ revision: 1, provenance: { retrieved_at: '2026-10-30T06:00:00.000Z' }, removed: null, ...over,
++});
++
++(async () => {
++ const clock = { t: Date.parse('2026-10-30T08:00:00Z') };
++ const t = await boot({ now: () => clock.t, env: { WORK_HN_WHOISHIRING_FROM: 'sources' }, sourcesClient });
++ const s = t.ctx.s;
++ const from = t.ctx.fromSources;
++
++ try {
++ await check('a comment becomes a listing with the company and role from its first line', async () => {
++ push(hn('hn-201', 'Acme Corp | Senior Backend Engineer | Remote (US)', 'Acme Corp is hiring. Email [email] or call [phone].'));
++ push(hn('hn-203', 'Beta & Co is hiring a product designer', 'Beta & Co is hiring a product designer. Contact [email].'));
++ push(hn('hn-205', 'No company here', 'A comment with no company in its first line.'));
++ push(hn('hn-206', 'Z | Q', 'A board test post with one-letter fields.'));
++ const out = await from.pull();
++ assert.deepStrictEqual([out.applied, out.hold, out.removed], [2, 2, 0]);
++
++ const acme = await s.db.maybe('SELECT * FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-201')]);
++ assert.deepStrictEqual([acme.source, acme.title, acme.company, acme.remote, acme.url],
++ ['hn-whoishiring', 'Senior Backend Engineer | Remote (US)', 'Acme Corp', true, 'https://news.ycombinator.com/item?id=201']);
++ assert.ok(acme.excerpt.includes('[email]') && acme.excerpt.includes('[phone]'), 'the excerpt is Sources\' privacy-stripped text');
++
++ const beta = await s.db.maybe('SELECT * FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-203')]);
++ assert.deepStrictEqual([beta.title, beta.company, beta.remote], ['a product designer', 'Beta & Co', false]);
++ assert.strictEqual(await s.db.maybe('SELECT id FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-205')]), null);
++ assert.strictEqual(await s.db.maybe('SELECT id FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-206')]), null);
++ });
++
++ await check('the listing links the HN comment and is attributed to the thread', async () => {
++ const id = store.idOf('hn-whoishiring', 'hn-201');
++ const list = await t.get('/jobs');
++ assert.strictEqual(list.status, 200);
++ assert.ok(list.text.includes('Hacker News: Who is hiring'));
++ assert.ok(list.text.includes('https://news.ycombinator.com/item?id=201'));
++ const page = await t.get(`/jobs/${id}`);
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('Hacker News: Who is hiring'));
++ assert.ok(page.text.includes('https://news.ycombinator.com/item?id=201'), 'the original comment is linked');
++ });
++
++ await check('a removed item expires the listing, and Work never reads HN itself', async () => {
++ push({ ...hn('hn-201', 'Acme Corp | Senior Backend Engineer | Remote (US)', 'x'), id: 'itm_hn-201', removed: { at: '2026-10-30T07:00:00.000Z', reason: 'gone_from_source' }, revision: 2 });
++ const out = await from.pull();
++ assert.strictEqual(out.removed, 1);
++ const row = await s.db.maybe('SELECT expired FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-201')]);
++ assert.ok(row.expired, 'the listing is expired, not deleted');
++ const runs = await t.ctx.ingest.runAll();
++ assert.deepStrictEqual(runs.find((o) => o.source === 'hn-whoishiring'), { source: 'hn-whoishiring', skipped: 'from_sources' });
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/ingest.test.js b/test/ingest.test.js
iindex 704de85..a5513e1 100644
---- a/test/ingest.test.js
++++ b/test/ingest.test.js
@@@ -23,9 +23,10 @@ const DAY = 86_400_000;
await check('reading the three boards keeps every listing with its source, its id and the original URL', async () => {
const out = await ingest.runAll();
// We Work Remotely is skipped here: it reaches Work through OpenVibe.Sources, never from its own feed.
-- assert.deepStrictEqual(out.map((o) => o.source), ['arbeitnow', 'weworkremotely', 'remotive', 'remoteok']);
-- assert.ok(out.filter((o) => o.source !== 'weworkremotely').every((o) => o.ok), JSON.stringify(out.map((o) => o.error)));
++ assert.deepStrictEqual(out.map((o) => o.source), ['arbeitnow', 'weworkremotely', 'hn-whoishiring', 'remotive', 'remoteok']);
++ assert.ok(out.filter((o) => !['weworkremotely', 'hn-whoishiring'].includes(o.source)).every((o) => o.ok), JSON.stringify(out.map((o) => o.error)));
assert.strictEqual(out.find((o) => o.source === 'weworkremotely').skipped, 'from_sources');
++ assert.strictEqual(out.find((o) => o.source === 'hn-whoishiring').skipped, 'from_sources');
// Arbeitnow's two pages (2 + 1), Remotive's two jobs, RemoteOK's two listings.
const counts = await store.countsBySource(s);
assert.strictEqual(counts.get('arbeitnow').count, 3);
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
iindex 85c7bf2..bad5d0a 100644
---- a/test/jobs.test.js
++++ b/test/jobs.test.js
@@@ -185,9 +185,9 @@ const first = async (t, path) => (await t.get(path)).json();
await check('every source is listed with its terms as applied and when it was last read', async () => {
const api = await first(t, '/api/v1/sources');
-- assert.deepStrictEqual(api.sources.map((s) => s.id).sort(), ['arbeitnow', 'remoteok', 'remotive', 'weworkremotely']);
++ assert.deepStrictEqual(api.sources.map((s) => s.id).sort(), ['arbeitnow', 'hn-whoishiring', 'remoteok', 'remotive', 'weworkremotely']);
// A board OpenVibe.Sources feeds has no read of its own here, so it is not asked for one.
-- for (const src of api.sources.filter((x) => x.id !== 'weworkremotely')) {
++ for (const src of api.sources.filter((x) => !['weworkremotely', 'hn-whoishiring'].includes(x.id))) {
assert.ok(src.terms.length > 40, `${src.id} has no terms`);
assert.match(src.terms, /link|attribute|mention|name/i, `${src.id}'s terms do not state the attribution rule`);
assert.strictEqual(src.last_fetch.ok, true);
@@@ -214,7 +214,7 @@ const first = async (t, path) => (await t.get(path)).json();
const llms = (await t.get('/llms.txt')).text;
assert.ok(llms.includes('(https://openvibe.work/jobs)'));
assert.ok(llms.includes('(https://openvibe.work/sources)'));
-- assert.ok(llms.includes('Arbeitnow, We Work Remotely, Remotive, RemoteOK'), 'the sources are not named for a crawler');
++ assert.ok(llms.includes('Arbeitnow, We Work Remotely, Hacker News: Who is hiring, Remotive, RemoteOK'), 'the sources are not named for a crawler');
assert.ok(!llms.includes('/saved'), 'llms.txt names a private page');
const robots = (await t.get('/robots.txt')).text;
assert.ok(robots.includes('Disallow: /saved'));
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex d420e0f..c537c91 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -17,8 +17,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// 18.2 KB raw / 4.3 KB brotli, still two stylesheets and no external file. The home page itself stayed inside its own
// budget — the listings replace the skeleton's placeholder sections rather than adding to them.
const BUDGETS = {
-- htmlRawKB: 39, // 38.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 36.5 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 31.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
-- htmlBrotliKB: 9.5, // 9.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 7.8 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 6.2
++ htmlRawKB: 39, // 38.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30), plus the Hacker News "Who is hiring" source row (2026-10-31); 36.5 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 31.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
++ htmlBrotliKB: 9.5, // 9.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30), plus the Hacker News source row; 7.8 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 6.2
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5