openvibers/openvibe.work Commits

Work: listings as cards with a company tile, chips for remote, type, pay and tags, and a two-line excerpt

OP OpenVibers committed 8 Oct 2026 · 94dc78b213 · 5 files, +47 −27


Changes

ddiff --git a/README.md b/README.md
iindex 9b1dc33..1a0a122 100644
---- a/README.md
++++ b/README.md
@@@ -133,7 +133,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.115.0
++- openvibe-contracts: v0.116.0
- openvibe-sdk: v0.35.2
--- openvibe-shared: v2.14.0
++- openvibe-shared: v2.15.0
<!-- versions:end -->
ddiff --git a/package-lock.json b/package-lock.json
iindex 7c2fb07..a1f5049 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-work",
-- "version": "0.1.0",
++ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-work",
-- "version": "0.1.0",
++ "version": "0.2.0",
"license": "AGPL-3.0-only",
"dependencies": {
"cookie-parser": "^1.4.7",
@@@ -15,9 +15,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.14.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -871,9 +871,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.115.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
-- "integrity": "sha512-2si6od4+RLgZEIAuWMmTrLZL65TzvCwB/sKoe24vSzC9+sNPt2RXWGXDCwE2fB90vmaRvX6pI+DQyu18QN2f0Q==",
++ "version": "0.116.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
++ "integrity": "sha512-mDqQJ6m8YSJf3UV+kVpqeMrgNDMMzBQdYmj/3o5JqGegj52ymiDH1NerVW6c+0ebsV3zX7RbRlknIy3vr634yw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -916,9 +916,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.14.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.14.0",
-- "integrity": "sha512-XraQxTERs14Jc65sJ23Hv0B5BJLC3Fbwsg3uEp8rMaEShFiuhMofr7pKGYX11sgreMMF64tymO6l6kdjYTBJfw==",
++ "version": "2.15.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
++ "integrity": "sha512-xApTyiUBVDQLTdnKEjjr/9uUU4+T5gofz9DqXv0MrLVINmW88uwbfoEht3kAlG9mp1792U6kIFEblJDHn2wyMQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex fa7c255..0e61e57 100644
---- a/package.json
++++ b/package.json
@@@ -23,9 +23,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.14.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/css/app.css b/public/css/app.css
iindex deb6623..eafdad0 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -55,17 +55,26 @@ button.sc-btn.sc-primary { border: 0; }
.facets li.on a { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); }
.facet-n { color: var(--text-muted, #7386a3); }
--.job-list { list-style: none; margin: 8px 0 0; padding: 0; }
--.job-item { border-top: 1px solid var(--border, #1f2d47); padding: 14px 0; }
--.job-item:first-child { border-top: 0; }
--.job-title { margin: 0 0 4px; }
--.job-meta { margin: 0 0 6px; color: var(--text-secondary, #96a7c2); font-size: .92rem; }
--.job-company { font-weight: 600; color: var(--text-primary, #e6edf7); }
--.job-excerpt { margin: 0 0 8px; max-width: 78ch; }
--.job-tags { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; padding: 0; }
--.job-tags li { font-size: .78rem; border: 1px solid var(--border-light, #2c3d5c); border-radius: 999px; padding: 0 8px; color: var(--text-muted, #7386a3); }
--.job-provenance { margin: 0; font-size: .88rem; }
++.job-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 10px; }
++.job-item { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: 14px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }
++.job-item:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--border, #1f2d47)); transform: translateY(-1px); }
++.job-logo { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-weight: 800; font-size: 1.1rem; color: hsl(var(--h) 80% 78%); background: hsl(var(--h) 45% 22%); border: 1px solid hsl(var(--h) 40% 32%); }
++.job-main { min-width: 0; }
++.job-title { margin: 0 0 3px; font-size: 1.05rem; line-height: 1.3; }
++.job-title a { color: var(--text-primary, #e6edf7); text-decoration: none; }
++.job-title a:hover { color: var(--accent-light, #60a5fa); text-decoration: underline; text-underline-offset: 3px; }
++.job-meta { margin: 0 0 8px; display: flex; flex-wrap: wrap; gap: 2px 10px; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
++.job-company { font-weight: 650; color: var(--text-primary, #e6edf7); }
++.job-chips { margin: 0 0 8px; display: flex; flex-wrap: wrap; gap: 6px; }
++.chip { font-size: .76rem; font-weight: 600; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
++.chip-ok { color: var(--success, #22c55e); border-color: color-mix(in srgb, var(--success, #22c55e) 45%, transparent); }
++.chip-pay { color: var(--warning, #f59e0b); border-color: color-mix(in srgb, var(--warning, #f59e0b) 45%, transparent); }
++.chip-tag { color: var(--text-muted, #7386a3); font-weight: 500; }
++.job-excerpt { margin: 0 0 8px; max-width: 80ch; font-size: .92rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++.job-provenance { margin: 0; font-size: .82rem; }
.job-source { font-weight: 600; }
++@media (max-width: 560px) { .job-item { grid-template-columns: 1fr; } .job-logo { display: none; } }
++@media (prefers-reduced-motion: reduce) { .job-item { transition: none; } }
.job-pager { display: flex; gap: 12px; align-items: center; margin: 18px 0 8px; }
.job-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 16px 0; font-size: .92rem; }
.job-facts dt { color: var(--text-secondary, #96a7c2); }
ddiff --git a/server/render/listing.js b/server/render/listing.js
iindex c2d0ded..3c32526 100644
---- a/server/render/listing.js
++++ b/server/render/listing.js
@@@ -47,15 +47,26 @@ function provenance(row, source) {
return html`<p class="job-provenance"><span class="muted">Listing from</span> ${sourceLink(row, source)}${row.posted_at ? html` <span class="muted">· posted ${dateWords(row.posted_at)}</span>` : ''}</p>`;
}
--/** One listing in a list: title, who and where, the excerpt, the tags, and the link back to the board. */
++/** A company's initial on a tint picked from its name: the same company always gets the same tile. */
++function initialTile(company) {
++ const name = String(company || '?').trim();
++ let h = 0;
++ for (const ch of name) h = (h * 31 + ch.codePointAt(0)) % 360;
++ return html`<span class="job-logo" style="--h:${h}" aria-hidden="true">${(name[0] || '?').toUpperCase()}</span>`;
++}
++
++/** One listing in a list, as a card: who, what and where at a glance, two lines of the excerpt, and the link back. */
function listingItem(row, { source }) {
const l = meta(row);
return html`<li class="job-item" id="${l.id}">
++${initialTile(l.company)}
++<div class="job-main">
<h3 class="job-title"><a href="/jobs/${l.id}">${l.title}</a></h3>
--<p class="job-meta"><span class="job-company">${l.company}</span> · <span class="job-location">${l.location || 'Location not given'}</span>${l.remote ? html` · ${badge('Remote', 'ok')}` : ''}${l.job_type ? html` · <span class="job-type">${l.job_type}</span>` : ''}${l.salary ? html` · <span class="job-salary">${l.salary}</span>` : ''}</p>
++<p class="job-meta"><span class="job-company">${l.company}</span><span class="job-location">${l.location || 'Location not given'}</span></p>
++<p class="job-chips">${l.remote ? html`<span class="chip chip-ok">Remote</span>` : ''}${l.job_type ? html`<span class="chip">${l.job_type}</span>` : ''}${l.salary ? html`<span class="chip chip-pay">${l.salary}</span>` : ''}${l.tags.slice(0, 3).map((t) => html`<span class="chip chip-tag">${t}</span>`)}</p>
<p class="job-excerpt">${l.excerpt}</p>
--${l.tags.length ? html`<ul class="job-tags">${l.tags.slice(0, 8).map((t) => html`<li>${t}</li>`)}</ul>` : ''}
${provenance(row, source)}
++</div>
</li>`;
}