openvibers/openvibe.publishing Commits
1.7.0: blocks.css, one stylesheet for every block the library renders (#203)
Changes
@@@ -4,6 +4,17 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://sbreaking change to any exported function, table layout, reason code or document shape is a newmajor (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.++## 1.7.0 — 2026-11-09++++- **`openvibe-publishing/blocks.css`: one stylesheet for every block the library renders** (additive). The look of++ an infobox, a timeline, a stat, a quote, a callout, a gallery, a compare, a hero, a section with its reactions,++ patch notes, a clip, a video, a link out and a missing picture, on the shared theme tokens only (each with a++ fallback) and with no site's classes. A product serves this file and links it wherever it renders a body; it places++ blocks (its text measure, an infobox beside a lead) and does not restyle them. Before this the rules lived in Blog++ alone, so a wiki article showed its blocks as bare browser markup.++- `test/blocks-css.test.js` fails when a renderer emits a class the sheet has no rule for, when a callout tone has no++ rule, when a token has no fallback, or when a site class appears in the sheet.++## 1.6.1 — 2026-11-09- `blocks`: a timeline and an infobox have one row per line (1.6.0 made a whole body one row) with list markers@@@ -3,7 +3,7 @@> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,> Deals, Coupons and Trade.--**Status:** alpha, **1.6.1**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data++**Status:** alpha, **1.7.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async datalayer (ADR-035: PostgreSQL 18 behind PgBouncer in production, PGlite in tests). Every store method isasync, takes the product's `openvibe-sdk/db` handle, and accepts the caller's transaction handle; eachstore gives its DDL as `schema(prefix)` for the product's migrations. v1.1.0 adds the shared ingest@@@ -16,7 +16,7 @@ several slashes (`//feed/`) on the site in `seo.canonicalUrl`, where it threw (a`openvibe-publishing/search-relay`, which delivers the Search documents a product queued in its own outbox tablethrough Search's owner API (Meme, Exchange and Fun queue them; additive). The exit proof (`examples/two-products`) runs in `npm test`.v1.6.0 adds `openvibe-publishing/blocks`, the rich block model Blog and Wiki share: a body is Markdown plus--blocks, parsed, validated, rendered server-side and flattened to text (additive). Releases v0.1.0 to v1.6.1 are tagged (see [CHANGELOG.md](CHANGELOG.md)). Fifteen products pin a release. The seven++blocks, parsed, validated, rendered server-side and flattened to text (additive). Releases v0.1.0 to v1.7.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). Fifteen products pin a release. The sevenpublication products run on PostgreSQL and are public: openvibe.wiki, openvibe.blog, openvibe.news, openvibe.reviews,openvibe.deals, openvibe.coupons and openvibe.trade (on 2026-10-22 Blog, News and Deals pinned v1.4.1, the other fourv1.3.0). Seven more use `search-feed` to put their pages in OpenVibe.Search: Work, Rent, Help, Inventory, Quest, Host and@@@ -5,7 +5,7 @@"wave": "W15","kind": "packages","package": "openvibe-publishing",-- "version": "1.6.1",++ "version": "1.7.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","runtime": null,"port": null,nnew file mode 100644@@@ -0,0 +1,112 @@++/* openvibe-publishing/blocks.css: the look of every block lib/blocks.js and lib/media.js render.++ *++ * One sheet for every site that shows a block (a blog post, a wiki article, a devblog), so a block reads the same++ * wherever it is. It uses only the shared theme tokens (openvibe-shared's theme-loader: --accent, --border,++ * --bg-card, --text-*), each with a fallback, and no site's own classes.++ *++ * A site places blocks (the measure of its text column, an infobox floated beside a lead); it does not restyle them.++ * test/blocks-css.test.js fails when the renderer emits a class this sheet has no rule for.++ */++++/* ── Pictures ── */++.ov-media { margin: 1.2rem 0; }++/* A figure in the text and a picture by its id are the same thing to look at. */++.ov-figure, .ov-pic { display: block; }++.ov-figure img, .ov-pic img { display: block; }++.ov-media img { max-width: 100%; height: auto; border-radius: 8px; }++.ov-media figcaption, .ov-gallery > figcaption, .ov-compare > figcaption { margin-top: 6px; font-size: .9rem; color: var(--text-muted, #7386a3); }++.ov-media-credit { display: block; margin-top: 6px; color: var(--text-muted, #7386a3); font-size: .82rem; }++/* A picture that is gone says so in its place; it is never a guessed address or an empty frame. */++.ov-media-broken { margin: 1.2rem 0; }++.ov-media-missing { padding: 28px 16px; border: 1px dashed var(--border-light, #2c3d5c); border-radius: 8px; text-align: center; color: var(--text-secondary, #96a7c2); background: var(--bg-tertiary, #172238); }++.ov-gallery, .ov-compare { margin: 26px 0; }++.ov-gallery-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 12px; }++.ov-gallery-grid .ov-gallery-item { margin: 0; }++.ov-compare-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 14px; }++.ov-compare-before, .ov-compare-after { margin: 0; }++++/* ── A clip, a video, a link out: one row to press, the whole width ── */++.ov-clip, .ov-video, .ov-embed { margin: 26px 0; }++.ov-clip > a, .ov-video > a, .ov-embed > a, .ov-embed > span { display: flex; align-items: center; min-height: 48px; padding: 10px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-weight: 650; text-decoration: none; overflow-wrap: anywhere; }++.ov-clip > a:hover, .ov-video > a:hover, .ov-embed > a:hover { border-color: var(--accent, #3b82f6); }++++/* ── A section: a big heading, who wrote it, and the readers' read at its end ── */++.ov-section { margin: 34px 0; scroll-margin-top: calc(var(--ovnav-real-h, 56px) + 16px); }++.ov-section > h2 { font-size: clamp(1.5rem, 1.2rem + 1.1vw, 2rem); line-height: 1.2; letter-spacing: -.015em; margin: 0 0 6px; }++.ov-section > h2:target { color: var(--accent, #3b82f6); }++.ov-section-by { margin: 0 0 14px; color: var(--text-muted, #7386a3); font-size: .9rem; }++.ov-section-by a { color: var(--text-secondary, #96a7c2); }++.ov-reactions { display: flex; align-items: center; gap: 8px; margin: 18px 0 0; }++.ov-reactions button, .ov-react { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-size: .95rem; cursor: pointer; }++.ov-reactions button:hover, .ov-react:hover { border-color: var(--accent, #3b82f6); }++.ov-react-up[aria-pressed="true"], .ov-react-down[aria-pressed="true"] { border-color: var(--accent, #3b82f6); }++.ov-react-count { color: var(--text-secondary, #96a7c2); font-variant-numeric: tabular-nums; }++.ov-react-signin { min-height: 44px; display: inline-flex; align-items: center; color: var(--text-secondary, #96a7c2); font-size: .92rem; }++++/* ── The hero: a banner. Its title art fills it under a dark wash, or an accent wash stands in; the kicker is a pill++ beside the date, and the title is the biggest thing on the page. ── */++.ov-hero { position: relative; isolation: isolate; overflow: hidden; display: grid; gap: 10px; margin: 0 0 28px; padding: clamp(22px, 4vw, 40px);++ border-radius: 22px; border: 1px solid var(--border, #1f2d47);++ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent, #3b82f6) 34%, transparent), transparent 62%),++ linear-gradient(160deg, color-mix(in srgb, var(--accent, #3b82f6) 14%, var(--bg-card, #131c2e)), var(--bg-card, #131c2e)); }++.ov-hero .ov-hero-art { position: absolute; inset: 0; z-index: -1; margin: 0; }++.ov-hero .ov-hero-art img { width: 100%; height: 100%; object-fit: cover; }++.ov-hero .ov-hero-art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(4, 8, 18, .18) 0%, rgba(4, 8, 18, .78) 100%); }++.ov-hero:has(.ov-hero-art) { min-height: clamp(260px, 42vw, 420px); align-content: end; color: #fff; border-color: transparent; }++.ov-hero:has(.ov-hero-art) .ov-hero-body p { color: rgba(255, 255, 255, .86); }++.ov-kicker { margin: 0; color: var(--accent, #3b82f6); font-size: .86rem; font-weight: 700; }++.ov-hero .ov-kicker { justify-self: start; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: .78rem;++ font-weight: 750; letter-spacing: .02em; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }++.ov-hero .ov-kicker::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }++.ov-hero:has(.ov-hero-art) .ov-kicker { color: #fff; background: rgba(255, 255, 255, .18); backdrop-filter: blur(6px); }++.ov-hero-date { margin: 2px 0 0; font-size: .86rem; color: var(--text-muted, #7386a3); }++.ov-hero:has(.ov-kicker) { grid-template-columns: auto 1fr; column-gap: 10px; }++.ov-hero:has(.ov-kicker) .ov-kicker { grid-row: 1; grid-column: 1; }++.ov-hero:has(.ov-kicker) .ov-hero-date { grid-row: 1; grid-column: 2; justify-self: start; margin: 0; padding: 4px 0; }++.ov-hero:has(.ov-kicker) .ov-hero-body { grid-column: 1 / -1; }++.ov-hero-body > :is(h1, h2) { margin: 4px 0 10px; font-size: clamp(2rem, 1.35rem + 3.2vw, 3.4rem); line-height: 1.06; letter-spacing: -.028em; font-weight: 800; }++.ov-hero-body > p { margin: 0; max-width: 62ch; font-size: clamp(1.02rem, .95rem + .4vw, 1.2rem); line-height: 1.55; color: var(--text-secondary, #96a7c2); }++++/* ── A callout: a card with a coloured edge; the tone is also said by its title, never by colour alone ── */++.ov-callout { margin: 26px 0; padding: 16px 18px; border-left: 4px solid var(--accent, #3b82f6); border-radius: 10px; background: var(--bg-card, #131c2e); }++.ov-callout-info { border-left-color: var(--accent, #3b82f6); }++.ov-callout-warn { border-left-color: var(--warning, #f59e0b); }++.ov-callout-tip { border-left-color: var(--success, #22c55e); }++.ov-callout-title { margin: 0 0 6px; font-weight: 700; }++.ov-callout > :last-child { margin-bottom: 0; }++++/* ── A stat reads as a figure, in the accent, with what it counts beside it ── */++.ov-stat { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin: 26px 0; }++.ov-stat-value { color: var(--accent, #3b82f6); font-size: clamp(2.2rem, 1.8rem + 2vw, 3rem); font-weight: 800; letter-spacing: -.02em; line-height: 1; font-variant-numeric: tabular-nums; }++.ov-stat-label { color: var(--text-secondary, #96a7c2); font-size: 1.05rem; }++++/* ── A quote ── */++.ov-quote { margin: 26px 0; padding: 4px 0 4px 18px; border-left: 3px solid color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #1f2d47)); }++.ov-quote > :first-child { margin-top: 0; }++.ov-quote-by { margin: 8px 0 0; color: var(--text-muted, #7386a3); font-size: .9rem; }++++/* ── Patch notes: a fold per release, a heading per site ── */++.ov-patchnotes { margin: 26px 0; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }++.ov-patchnotes > summary { display: flex; align-items: center; min-height: 46px; padding: 0 14px; font-weight: 700; cursor: pointer; list-style: none; }++.ov-patchnotes > summary::-webkit-details-marker { display: none; }++.ov-patchnotes-site { padding: 0 14px 10px; }++.ov-patchnotes-site h3 { margin: 12px 0 4px; font-size: 1rem; }++.ov-patchnotes-site ul { margin: 0; padding-left: 20px; }++++/* ── An infobox: the facts as rows, the name of each fact quieter than the fact ── */++.ov-infobox, .ov-timeline { margin: 26px 0; padding: 16px 18px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }++.ov-infobox-title, .ov-timeline-title { margin: 0 0 8px; font-size: 1.05rem; line-height: 1.3; border: 0; padding: 0; }++.ov-infobox dl { margin: 0; }++.ov-infobox-row { display: grid; grid-template-columns: minmax(84px, 32%) minmax(0, 1fr); gap: 12px; padding: 7px 0; border-top: 1px solid var(--border, #1f2d47); }++.ov-infobox dl > .ov-infobox-row:first-child { border-top: 0; }++.ov-infobox-title + dl > .ov-infobox-row:first-child { border-top: 1px solid var(--border, #1f2d47); }++.ov-infobox-key { margin: 0; color: var(--text-muted, #7386a3); }++.ov-infobox-value { margin: 0; overflow-wrap: anywhere; }++++/* ── A timeline: a line with a dot per moment, the time in tabular numbers, the words beside it ── */++.ov-timeline ol { position: relative; list-style: none; margin: 4px 0 0; padding: 0; }++.ov-timeline ol::before { content: ""; position: absolute; left: 5px; top: 10px; bottom: 10px; width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }++.ov-timeline-item { position: relative; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; padding: 7px 0 7px 24px; }++.ov-timeline-item::before { content: ""; position: absolute; left: 0; top: 13px; width: 12px; height: 12px; border-radius: 50%; background: var(--bg-card, #131c2e); box-shadow: inset 0 0 0 3px var(--accent, #3b82f6); }++.ov-timeline-when { min-width: 3.4em; font-weight: 700; color: var(--accent, #3b82f6); font-variant-numeric: tabular-nums; }++.ov-timeline-text { min-width: 0; }@@@ -1,12 +1,12 @@{"name": "openvibe-publishing",-- "version": "1.6.1",++ "version": "1.7.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-publishing",-- "version": "1.6.1",++ "version": "1.7.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-publishing",-- "version": "1.6.1",++ "version": "1.7.0","description": "Shared publishing packages for the OpenVibe publication products, on PostgreSQL through openvibe-sdk/db: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks, SSR helpers, the ingest chassis (Sources client, change cursor, signed event consumer, normalisers, PSL) and the publication glue. Packages, not an authority.","main": "index.js","exports": {@@@ -17,6 +17,7 @@"./index-hooks": "./lib/index-hooks.js","./ssr": "./lib/ssr.js","./blocks": "./lib/blocks.js",++ "./blocks.css": "./blocks.css","./ai": "./lib/ai.js","./ingest": "./lib/ingest.js","./publication": "./lib/publication.js",@@@ -27,6 +28,7 @@"files": ["index.js","lib/",++ "blocks.css","CHANGELOG.md"],"scripts": {nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * blocks.css is the one sheet for the blocks' look. It must keep up with the renderer: a class lib/blocks.js or++ * lib/media.js writes into a class attribute needs a rule here, or the block shows as bare browser markup on every++ * site at once (a wiki's infobox as an indented list, a stat with no space between the number and its label).++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const blocks = require('../lib/blocks');++const pkg = require('../package.json');++const { suite } = require('./helpers/db');++++const t = suite();++const ROOT = path.join(__dirname, '..');++const css = fs.readFileSync(path.join(ROOT, 'blocks.css'), 'utf8');++const hasRule = (cls) => new RegExp(`\\.${cls}(?![a-z0-9-])`).test(css);++++/** Every ov-… class written inside a class="…" attribute or passed as a class name in a renderer's source. */++function emitted(file) {++ const src = fs.readFileSync(path.join(ROOT, file), 'utf8');++ const out = new Set();++ const add = (c) => { if (/^ov-[a-z0-9-]*[a-z0-9]$/.test(c)) out.add(c); };++ Array.from(src.matchAll(/class="([^"]*)/g)).forEach((m) => m[1].split(/\s+/).forEach(add));++ Array.from(src.matchAll(/'(ov-[a-z0-9-]*[a-z0-9])'/g)).forEach((m) => add(m[1]));++ return [...out].sort();++}++++t.test('every class the renderers emit has a rule in blocks.css', () => {++ const classes = [...new Set([...emitted('lib/blocks.js'), ...emitted('lib/media.js')])];++ assert.ok(classes.length >= 30, `found ${classes.length} classes: the scan no longer reads the renderers`);++ const missing = classes.filter((c) => !hasRule(c));++ assert.deepStrictEqual(missing, [], `blocks.css has no rule for: ${missing.join(', ')}`);++});++++t.test('every callout tone has its own rule', () => {++ blocks.CALLOUT_TONES.forEach((tone) => assert.ok(hasRule(`ov-callout-${tone}`), tone));++});++++t.test('the sheet uses the shared theme tokens only, each with a fallback, and no site class', () => {++ const allowed = new Set(['--accent', '--border', '--border-light', '--bg-card', '--bg-tertiary', '--text-primary', '--text-secondary', '--text-muted', '--warning', '--success', '--ovnav-real-h']);++ Array.from(css.matchAll(/var\(\s*(--[a-z0-9-]+)\s*([,)])/g)).forEach(([, name, next]) => {++ assert.ok(allowed.has(name), `${name} is not a shared theme token`);++ assert.strictEqual(next, ',', `${name} needs a fallback`);++ });++ const selectors = css.replace(/\/\*[\s\S]*?\*\//g, '').match(/\.[a-z][a-z0-9-]*/g) || [];++ const foreign = [...new Set(selectors.filter((s) => !s.startsWith('.ov-')))];++ assert.deepStrictEqual(foreign, [], `only .ov-* classes belong here: ${foreign.join(', ')}`);++});++++t.test('the package ships and exports the sheet', () => {++ assert.strictEqual(pkg.exports['./blocks.css'], './blocks.css');++ assert.ok(pkg.files.includes('blocks.css'));++ assert.strictEqual(require.resolve('openvibe-publishing/blocks.css'), path.join(ROOT, 'blocks.css'));++});++++t.run();@@@ -8,7 +8,8 @@ const { suite } = require('./helpers/db');const { test, run } = suite();const ROOT = path.join(__dirname, '..');--const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './package.json');++// Modules only: a stylesheet export (./blocks.css) is a file to serve, not code to load (test/blocks-css.test.js).++const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './package.json' && !k.endsWith('.css'));test('every subpath export resolves to an existing module', () => {assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
@@ -4,6 +4,17 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
@@ -4,6 +4,17 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
breaking change to any exported function, table layout, reason code or document shape is a new
breaking change to any exported function, table layout, reason code or document shape is a new
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
+## 1.7.0 — 2026-11-09
+
+- **`openvibe-publishing/blocks.css`: one stylesheet for every block the library renders** (additive). The look of
+ an infobox, a timeline, a stat, a quote, a callout, a gallery, a compare, a hero, a section with its reactions,
+ patch notes, a clip, a video, a link out and a missing picture, on the shared theme tokens only (each with a
+ fallback) and with no site's classes. A product serves this file and links it wherever it renders a body; it places
+ blocks (its text measure, an infobox beside a lead) and does not restyle them. Before this the rules lived in Blog
+ alone, so a wiki article showed its blocks as bare browser markup.
+- `test/blocks-css.test.js` fails when a renderer emits a class the sheet has no rule for, when a callout tone has no
+ rule, when a token has no fallback, or when a site class appears in the sheet.
+
## 1.6.1 — 2026-11-09
## 1.6.1 — 2026-11-09
- `blocks`: a timeline and an infobox have one row per line (1.6.0 made a whole body one row) with list markers
- `blocks`: a timeline and an infobox have one row per line (1.6.0 made a whole body one row) with list markers
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Deals, Coupons and Trade.
> Deals, Coupons and Trade.
-**Status:** alpha, **1.6.1**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data
+**Status:** alpha, **1.7.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data
layer (ADR-035: PostgreSQL 18 behind PgBouncer in production, PGlite in tests). Every store method is
layer (ADR-035: PostgreSQL 18 behind PgBouncer in production, PGlite in tests). Every store method is
async, takes the product's `openvibe-sdk/db` handle, and accepts the caller's transaction handle; each
async, takes the product's `openvibe-sdk/db` handle, and accepts the caller's transaction handle; each
store gives its DDL as `schema(prefix)` for the product's migrations. v1.1.0 adds the shared ingest
store gives its DDL as `schema(prefix)` for the product's migrations. v1.1.0 adds the shared ingest
@@ -16,7 +16,7 @@ several slashes (`//feed/`) on the site in `seo.canonicalUrl`, where it threw (a
@@ -16,7 +16,7 @@ several slashes (`//feed/`) on the site in `seo.canonicalUrl`, where it threw (a
`openvibe-publishing/search-relay`, which delivers the Search documents a product queued in its own outbox table
`openvibe-publishing/search-relay`, which delivers the Search documents a product queued in its own outbox table
through Search's owner API (Meme, Exchange and Fun queue them; additive). The exit proof (`examples/two-products`) runs in `npm test`.
through Search's owner API (Meme, Exchange and Fun queue them; additive). The exit proof (`examples/two-products`) runs in `npm test`.
v1.6.0 adds `openvibe-publishing/blocks`, the rich block model Blog and Wiki share: a body is Markdown plus
v1.6.0 adds `openvibe-publishing/blocks`, the rich block model Blog and Wiki share: a body is Markdown plus
-blocks, parsed, validated, rendered server-side and flattened to text (additive). Releases v0.1.0 to v1.6.1 are tagged (see [CHANGELOG.md](CHANGELOG.md)). Fifteen products pin a release. The seven
+blocks, parsed, validated, rendered server-side and flattened to text (additive). Releases v0.1.0 to v1.7.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). Fifteen products pin a release. The seven
publication products run on PostgreSQL and are public: openvibe.wiki, openvibe.blog, openvibe.news, openvibe.reviews,
publication products run on PostgreSQL and are public: openvibe.wiki, openvibe.blog, openvibe.news, openvibe.reviews,
openvibe.deals, openvibe.coupons and openvibe.trade (on 2026-10-22 Blog, News and Deals pinned v1.4.1, the other four
openvibe.deals, openvibe.coupons and openvibe.trade (on 2026-10-22 Blog, News and Deals pinned v1.4.1, the other four
v1.3.0). Seven more use `search-feed` to put their pages in OpenVibe.Search: Work, Rent, Help, Inventory, Quest, Host and
v1.3.0). Seven more use `search-feed` to put their pages in OpenVibe.Search: Work, Rent, Help, Inventory, Quest, Host and
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"wave": "W15",
"wave": "W15",
"kind": "packages",
"kind": "packages",
"package": "openvibe-publishing",
"package": "openvibe-publishing",
- "version": "1.6.1",
+ "version": "1.7.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": null,
"runtime": null,
"port": null,
"port": null,
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+/* openvibe-publishing/blocks.css: the look of every block lib/blocks.js and lib/media.js render.
+ *
+ * One sheet for every site that shows a block (a blog post, a wiki article, a devblog), so a block reads the same
+ * wherever it is. It uses only the shared theme tokens (openvibe-shared's theme-loader: --accent, --border,
+ * --bg-card, --text-*), each with a fallback, and no site's own classes.
+ *
+ * A site places blocks (the measure of its text column, an infobox floated beside a lead); it does not restyle them.
+ * test/blocks-css.test.js fails when the renderer emits a class this sheet has no rule for.
+ */
+
+/* ── Pictures ── */
+.ov-media { margin: 1.2rem 0; }
+/* A figure in the text and a picture by its id are the same thing to look at. */
+.ov-figure, .ov-pic { display: block; }
+.ov-figure img, .ov-pic img { display: block; }
+.ov-media img { max-width: 100%; height: auto; border-radius: 8px; }
+.ov-media figcaption, .ov-gallery > figcaption, .ov-compare > figcaption { margin-top: 6px; font-size: .9rem; color: var(--text-muted, #7386a3); }
+.ov-media-credit { display: block; margin-top: 6px; color: var(--text-muted, #7386a3); font-size: .82rem; }
+/* A picture that is gone says so in its place; it is never a guessed address or an empty frame. */
+.ov-media-broken { margin: 1.2rem 0; }
+.ov-media-missing { padding: 28px 16px; border: 1px dashed var(--border-light, #2c3d5c); border-radius: 8px; text-align: center; color: var(--text-secondary, #96a7c2); background: var(--bg-tertiary, #172238); }
+.ov-gallery, .ov-compare { margin: 26px 0; }
+.ov-gallery-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 12px; }
+.ov-gallery-grid .ov-gallery-item { margin: 0; }
+.ov-compare-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 14px; }
+.ov-compare-before, .ov-compare-after { margin: 0; }
+
+/* ── A clip, a video, a link out: one row to press, the whole width ── */
+.ov-clip, .ov-video, .ov-embed { margin: 26px 0; }
+.ov-clip > a, .ov-video > a, .ov-embed > a, .ov-embed > span { display: flex; align-items: center; min-height: 48px; padding: 10px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-weight: 650; text-decoration: none; overflow-wrap: anywhere; }
+.ov-clip > a:hover, .ov-video > a:hover, .ov-embed > a:hover { border-color: var(--accent, #3b82f6); }
+
+/* ── A section: a big heading, who wrote it, and the readers' read at its end ── */
+.ov-section { margin: 34px 0; scroll-margin-top: calc(var(--ovnav-real-h, 56px) + 16px); }
+.ov-section > h2 { font-size: clamp(1.5rem, 1.2rem + 1.1vw, 2rem); line-height: 1.2; letter-spacing: -.015em; margin: 0 0 6px; }
+.ov-section > h2:target { color: var(--accent, #3b82f6); }
+.ov-section-by { margin: 0 0 14px; color: var(--text-muted, #7386a3); font-size: .9rem; }
+.ov-section-by a { color: var(--text-secondary, #96a7c2); }
+.ov-reactions { display: flex; align-items: center; gap: 8px; margin: 18px 0 0; }
+.ov-reactions button, .ov-react { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-size: .95rem; cursor: pointer; }
+.ov-reactions button:hover, .ov-react:hover { border-color: var(--accent, #3b82f6); }
+.ov-react-up[aria-pressed="true"], .ov-react-down[aria-pressed="true"] { border-color: var(--accent, #3b82f6); }
+.ov-react-count { color: var(--text-secondary, #96a7c2); font-variant-numeric: tabular-nums; }
+.ov-react-signin { min-height: 44px; display: inline-flex; align-items: center; color: var(--text-secondary, #96a7c2); font-size: .92rem; }
+
+/* ── The hero: a banner. Its title art fills it under a dark wash, or an accent wash stands in; the kicker is a pill
+ beside the date, and the title is the biggest thing on the page. ── */
+.ov-hero { position: relative; isolation: isolate; overflow: hidden; display: grid; gap: 10px; margin: 0 0 28px; padding: clamp(22px, 4vw, 40px);
+ border-radius: 22px; border: 1px solid var(--border, #1f2d47);
+ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent, #3b82f6) 34%, transparent), transparent 62%),
+ linear-gradient(160deg, color-mix(in srgb, var(--accent, #3b82f6) 14%, var(--bg-card, #131c2e)), var(--bg-card, #131c2e)); }
+.ov-hero .ov-hero-art { position: absolute; inset: 0; z-index: -1; margin: 0; }
+.ov-hero .ov-hero-art img { width: 100%; height: 100%; object-fit: cover; }
+.ov-hero .ov-hero-art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(4, 8, 18, .18) 0%, rgba(4, 8, 18, .78) 100%); }
+.ov-hero:has(.ov-hero-art) { min-height: clamp(260px, 42vw, 420px); align-content: end; color: #fff; border-color: transparent; }
+.ov-hero:has(.ov-hero-art) .ov-hero-body p { color: rgba(255, 255, 255, .86); }
+.ov-kicker { margin: 0; color: var(--accent, #3b82f6); font-size: .86rem; font-weight: 700; }
+.ov-hero .ov-kicker { justify-self: start; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: .78rem;
+ font-weight: 750; letter-spacing: .02em; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
+.ov-hero .ov-kicker::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
+.ov-hero:has(.ov-hero-art) .ov-kicker { color: #fff; background: rgba(255, 255, 255, .18); backdrop-filter: blur(6px); }
+.ov-hero-date { margin: 2px 0 0; font-size: .86rem; color: var(--text-muted, #7386a3); }
+.ov-hero:has(.ov-kicker) { grid-template-columns: auto 1fr; column-gap: 10px; }
+.ov-hero:has(.ov-kicker) .ov-kicker { grid-row: 1; grid-column: 1; }
+.ov-hero:has(.ov-kicker) .ov-hero-date { grid-row: 1; grid-column: 2; justify-self: start; margin: 0; padding: 4px 0; }
+.ov-hero:has(.ov-kicker) .ov-hero-body { grid-column: 1 / -1; }
+.ov-hero-body > :is(h1, h2) { margin: 4px 0 10px; font-size: clamp(2rem, 1.35rem + 3.2vw, 3.4rem); line-height: 1.06; letter-spacing: -.028em; font-weight: 800; }
+.ov-hero-body > p { margin: 0; max-width: 62ch; font-size: clamp(1.02rem, .95rem + .4vw, 1.2rem); line-height: 1.55; color: var(--text-secondary, #96a7c2); }
+
+/* ── A callout: a card with a coloured edge; the tone is also said by its title, never by colour alone ── */
+.ov-callout { margin: 26px 0; padding: 16px 18px; border-left: 4px solid var(--accent, #3b82f6); border-radius: 10px; background: var(--bg-card, #131c2e); }
+.ov-callout-info { border-left-color: var(--accent, #3b82f6); }
+.ov-callout-warn { border-left-color: var(--warning, #f59e0b); }
+.ov-callout-tip { border-left-color: var(--success, #22c55e); }
+.ov-callout-title { margin: 0 0 6px; font-weight: 700; }
+.ov-callout > :last-child { margin-bottom: 0; }
+
+/* ── A stat reads as a figure, in the accent, with what it counts beside it ── */
+.ov-stat { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin: 26px 0; }
+.ov-stat-value { color: var(--accent, #3b82f6); font-size: clamp(2.2rem, 1.8rem + 2vw, 3rem); font-weight: 800; letter-spacing: -.02em; line-height: 1; font-variant-numeric: tabular-nums; }
+.ov-stat-label { color: var(--text-secondary, #96a7c2); font-size: 1.05rem; }
+
+/* ── A quote ── */
+.ov-quote { margin: 26px 0; padding: 4px 0 4px 18px; border-left: 3px solid color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #1f2d47)); }
+.ov-quote > :first-child { margin-top: 0; }
+.ov-quote-by { margin: 8px 0 0; color: var(--text-muted, #7386a3); font-size: .9rem; }
+
+/* ── Patch notes: a fold per release, a heading per site ── */
+.ov-patchnotes { margin: 26px 0; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }
+.ov-patchnotes > summary { display: flex; align-items: center; min-height: 46px; padding: 0 14px; font-weight: 700; cursor: pointer; list-style: none; }
+.ov-patchnotes > summary::-webkit-details-marker { display: none; }
+.ov-patchnotes-site { padding: 0 14px 10px; }
+.ov-patchnotes-site h3 { margin: 12px 0 4px; font-size: 1rem; }
+.ov-patchnotes-site ul { margin: 0; padding-left: 20px; }
+
+/* ── An infobox: the facts as rows, the name of each fact quieter than the fact ── */
+.ov-infobox, .ov-timeline { margin: 26px 0; padding: 16px 18px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }
+.ov-infobox-title, .ov-timeline-title { margin: 0 0 8px; font-size: 1.05rem; line-height: 1.3; border: 0; padding: 0; }
+.ov-infobox dl { margin: 0; }
+.ov-infobox-row { display: grid; grid-template-columns: minmax(84px, 32%) minmax(0, 1fr); gap: 12px; padding: 7px 0; border-top: 1px solid var(--border, #1f2d47); }
+.ov-infobox dl > .ov-infobox-row:first-child { border-top: 0; }
+.ov-infobox-title + dl > .ov-infobox-row:first-child { border-top: 1px solid var(--border, #1f2d47); }
+.ov-infobox-key { margin: 0; color: var(--text-muted, #7386a3); }
+.ov-infobox-value { margin: 0; overflow-wrap: anywhere; }
+
+/* ── A timeline: a line with a dot per moment, the time in tabular numbers, the words beside it ── */
+.ov-timeline ol { position: relative; list-style: none; margin: 4px 0 0; padding: 0; }
+.ov-timeline ol::before { content: ""; position: absolute; left: 5px; top: 10px; bottom: 10px; width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
+.ov-timeline-item { position: relative; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; padding: 7px 0 7px 24px; }
+.ov-timeline-item::before { content: ""; position: absolute; left: 0; top: 13px; width: 12px; height: 12px; border-radius: 50%; background: var(--bg-card, #131c2e); box-shadow: inset 0 0 0 3px var(--accent, #3b82f6); }
+.ov-timeline-when { min-width: 3.4em; font-weight: 700; color: var(--accent, #3b82f6); font-variant-numeric: tabular-nums; }
+.ov-timeline-text { min-width: 0; }
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.6.1",
+ "version": "1.7.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.6.1",
+ "version": "1.7.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.6.1",
+ "version": "1.7.0",
"description": "Shared publishing packages for the OpenVibe publication products, on PostgreSQL through openvibe-sdk/db: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks, SSR helpers, the ingest chassis (Sources client, change cursor, signed event consumer, normalisers, PSL) and the publication glue. Packages, not an authority.",
"description": "Shared publishing packages for the OpenVibe publication products, on PostgreSQL through openvibe-sdk/db: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks, SSR helpers, the ingest chassis (Sources client, change cursor, signed event consumer, normalisers, PSL) and the publication glue. Packages, not an authority.",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
"./index-hooks": "./lib/index-hooks.js",
"./index-hooks": "./lib/index-hooks.js",
"./ssr": "./lib/ssr.js",
"./ssr": "./lib/ssr.js",
"./blocks": "./lib/blocks.js",
"./blocks": "./lib/blocks.js",
+ "./blocks.css": "./blocks.css",
"./ai": "./lib/ai.js",
"./ai": "./lib/ai.js",
"./ingest": "./lib/ingest.js",
"./ingest": "./lib/ingest.js",
"./publication": "./lib/publication.js",
"./publication": "./lib/publication.js",
@@ -27,6 +28,7 @@
@@ -27,6 +28,7 @@
"files": [
"files": [
"index.js",
"index.js",
"lib/",
"lib/",
+ "blocks.css",
"CHANGELOG.md"
"CHANGELOG.md"
],
],
"scripts": {
"scripts": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * blocks.css is the one sheet for the blocks' look. It must keep up with the renderer: a class lib/blocks.js or
+ * lib/media.js writes into a class attribute needs a rule here, or the block shows as bare browser markup on every
+ * site at once (a wiki's infobox as an indented list, a stat with no space between the number and its label).
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const blocks = require('../lib/blocks');
+const pkg = require('../package.json');
+const { suite } = require('./helpers/db');
+
+const t = suite();
+const ROOT = path.join(__dirname, '..');
+const css = fs.readFileSync(path.join(ROOT, 'blocks.css'), 'utf8');
+const hasRule = (cls) => new RegExp(`\\.${cls}(?![a-z0-9-])`).test(css);
+
+/** Every ov-… class written inside a class="…" attribute or passed as a class name in a renderer's source. */
+function emitted(file) {
+ const src = fs.readFileSync(path.join(ROOT, file), 'utf8');
+ const out = new Set();
+ const add = (c) => { if (/^ov-[a-z0-9-]*[a-z0-9]$/.test(c)) out.add(c); };
+ Array.from(src.matchAll(/class="([^"]*)/g)).forEach((m) => m[1].split(/\s+/).forEach(add));
+ Array.from(src.matchAll(/'(ov-[a-z0-9-]*[a-z0-9])'/g)).forEach((m) => add(m[1]));
+ return [...out].sort();
+}
+
+t.test('every class the renderers emit has a rule in blocks.css', () => {
+ const classes = [...new Set([...emitted('lib/blocks.js'), ...emitted('lib/media.js')])];
+ assert.ok(classes.length >= 30, `found ${classes.length} classes: the scan no longer reads the renderers`);
+ const missing = classes.filter((c) => !hasRule(c));
+ assert.deepStrictEqual(missing, [], `blocks.css has no rule for: ${missing.join(', ')}`);
+});
+
+t.test('every callout tone has its own rule', () => {
+ blocks.CALLOUT_TONES.forEach((tone) => assert.ok(hasRule(`ov-callout-${tone}`), tone));
+});
+
+t.test('the sheet uses the shared theme tokens only, each with a fallback, and no site class', () => {
+ const allowed = new Set(['--accent', '--border', '--border-light', '--bg-card', '--bg-tertiary', '--text-primary', '--text-secondary', '--text-muted', '--warning', '--success', '--ovnav-real-h']);
+ Array.from(css.matchAll(/var\(\s*(--[a-z0-9-]+)\s*([,)])/g)).forEach(([, name, next]) => {
+ assert.ok(allowed.has(name), `${name} is not a shared theme token`);
+ assert.strictEqual(next, ',', `${name} needs a fallback`);
+ });
+ const selectors = css.replace(/\/\*[\s\S]*?\*\//g, '').match(/\.[a-z][a-z0-9-]*/g) || [];
+ const foreign = [...new Set(selectors.filter((s) => !s.startsWith('.ov-')))];
+ assert.deepStrictEqual(foreign, [], `only .ov-* classes belong here: ${foreign.join(', ')}`);
+});
+
+t.test('the package ships and exports the sheet', () => {
+ assert.strictEqual(pkg.exports['./blocks.css'], './blocks.css');
+ assert.ok(pkg.files.includes('blocks.css'));
+ assert.strictEqual(require.resolve('openvibe-publishing/blocks.css'), path.join(ROOT, 'blocks.css'));
+});
+
+t.run();
@@ -8,7 +8,8 @@ const { suite } = require('./helpers/db');
@@ -8,7 +8,8 @@ const { suite } = require('./helpers/db');
const { test, run } = suite();
const { test, run } = suite();
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './package.json');
+// Modules only: a stylesheet export (./blocks.css) is a file to serve, not code to load (test/blocks-css.test.js).
+const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './package.json' && !k.endsWith('.css'));
test('every subpath export resolves to an existing module', () => {
test('every subpath export resolves to an existing module', () => {
assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),