From 7ced0395b64948e5a04d245f3181a0109a7492a2 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 19:50:30 -0500 Subject: [PATCH] feat(shared): lateral project-stage strip in every tool's header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a thin nav strip directly under the app-header showing the four canonical lifecycle stages from the transmittal-workflow spec: archive · working · staging · reviewing. Each is a link to that stage's directory under the current project. Current stage is highlighted (bold + primary color, aria-current="page"). Strip mounts as a sibling of .app-header on DOMContentLoaded — no template changes needed in any tool. Render rules (shared/nav.js shouldRender): - location.protocol must be http: or https: (file:// has no project structure to navigate within) - a project segment must be detectable as the first path segment (when it isn't a tool HTML file like /index.html or /archive.html?projects=A,B). Multi-project view at the deployment root therefore shows no strip. Stage URL targets: - Archive → /archive.html (project-root archive view) - Working → /working/ (directory listing — mdedit auto-served) - Staging → /staging/ (directory listing — transmittal auto-served) - Reviewing → /reviewing/ (directory listing) Convention-driven, not probed: if a deployment doesn't have one of these folders the link returns 404. Operators on non-standard layouts can opt out by setting window.zddc.nav.disabled = true before DOMContentLoaded. This pairs with the previous landing-tool change (single-project click → /archive.html). Together they give the user both URL-bar manipulation AND visible navigation across the four canonical project stages. Five Playwright tests in tests/nav.spec.js exercise: - non-render at deployment root - render + active stage on /archive.html - render + active stage deep inside /working/foo/mdedit.html - canonical link targets - mount position is sibling of .app-header Co-Authored-By: Claude Opus 4.7 (1M context) --- archive/build.sh | 2 + browse/build.sh | 2 + classifier/build.sh | 2 + form/build.sh | 2 + landing/build.sh | 2 + mdedit/build.sh | 2 + playwright.config.js | 4 + shared/nav.css | 56 +++++++++ shared/nav.js | 141 +++++++++++++++++++++ tables/build.sh | 2 + tests/nav.spec.js | 91 ++++++++++++++ transmittal/build.sh | 2 + zddc/internal/handler/tables.html | 201 +++++++++++++++++++++++++++++- 13 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 shared/nav.css create mode 100644 shared/nav.js create mode 100644 tests/nav.spec.js diff --git a/archive/build.sh b/archive/build.sh index b272996..cbfaf08 100644 --- a/archive/build.sh +++ b/archive/build.sh @@ -21,6 +21,7 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/nav.css" \ "css/base.css" \ "css/layout.css" \ "css/components.css" \ @@ -40,6 +41,7 @@ concat_files \ "../shared/hash.js" \ "../shared/theme.js" \ "../shared/toast.js" \ + "../shared/nav.js" \ "../shared/preview-lib.js" \ "js/init.js" \ "js/parser.js" \ diff --git a/browse/build.sh b/browse/build.sh index c9ec9b0..cdf3e93 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -21,6 +21,7 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/nav.css" \ "css/base.css" \ "css/tree.css" \ > "$css_temp" @@ -36,6 +37,7 @@ concat_files \ "../shared/zddc-filter.js" \ "../shared/theme.js" \ "../shared/toast.js" \ + "../shared/nav.js" \ "../shared/help.js" \ "../shared/preview-lib.js" \ "js/init.js" \ diff --git a/classifier/build.sh b/classifier/build.sh index c96ad81..d88735f 100644 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -21,6 +21,7 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/nav.css" \ "css/base.css" \ "css/layout.css" \ "css/spreadsheet.css" \ @@ -39,6 +40,7 @@ concat_files \ "../shared/zddc-source.js" \ "../shared/theme.js" \ "../shared/toast.js" \ + "../shared/nav.js" \ "../shared/preview-lib.js" \ "js/app.js" \ "js/utils.js" \ diff --git a/form/build.sh b/form/build.sh index 8462443..ef12105 100755 --- a/form/build.sh +++ b/form/build.sh @@ -20,12 +20,14 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/nav.css" \ "css/form.css" \ > "$css_temp" concat_files \ "../shared/theme.js" \ "../shared/toast.js" \ + "../shared/nav.js" \ "../shared/help.js" \ "js/app.js" \ "js/context.js" \ diff --git a/landing/build.sh b/landing/build.sh index 0abf041..23b9d7e 100755 --- a/landing/build.sh +++ b/landing/build.sh @@ -20,6 +20,7 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/nav.css" \ "css/landing.css" \ > "$css_temp" @@ -28,6 +29,7 @@ concat_files \ "../shared/zddc-filter.js" \ "../shared/theme.js" \ "../shared/toast.js" \ + "../shared/nav.js" \ "../shared/help.js" \ "js/landing.js" \ > "$js_raw" diff --git a/mdedit/build.sh b/mdedit/build.sh index ac4c75c..684f87a 100644 --- a/mdedit/build.sh +++ b/mdedit/build.sh @@ -31,6 +31,7 @@ concat_files \ "css/tailwind-utils.css" \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/nav.css" \ "css/base.css" \ "css/editor.css" \ "css/toc.css" \ @@ -43,6 +44,7 @@ concat_files \ "../shared/zddc-source.js" \ "../shared/theme.js" \ "../shared/toast.js" \ + "../shared/nav.js" \ "../shared/preview-lib.js" \ "js/app.js" \ "js/utils.js" \ diff --git a/playwright.config.js b/playwright.config.js index 65e1818..79b57d7 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -63,6 +63,10 @@ export default defineConfig({ name: 'toast', testMatch: 'toast.spec.js', }, + { + name: 'nav', + testMatch: 'nav.spec.js', + }, { name: 'zddc', testMatch: 'zddc.spec.js', diff --git a/shared/nav.css b/shared/nav.css new file mode 100644 index 0000000..d7baab4 --- /dev/null +++ b/shared/nav.css @@ -0,0 +1,56 @@ +/* shared/nav.css — lateral project-stage strip paired with shared/nav.js. + Sits as a sibling immediately under .app-header (mounted by JS). + Rendered only in online mode when a project segment is in the URL. */ + +.zddc-stage-strip { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 1rem; + background: var(--bg); + border-bottom: 1px solid var(--border); + font-size: 0.8rem; + line-height: 1.3; + flex-shrink: 0; + overflow-x: auto; + white-space: nowrap; +} + +.zddc-stage-strip__project { + color: var(--text); + font-weight: 600; + margin-right: 0.15rem; +} + +.zddc-stage-strip__divider, +.zddc-stage-strip__sep { + color: var(--text-muted); + user-select: none; +} + +.zddc-stage-strip__divider { + margin-right: 0.35rem; +} + +.zddc-stage { + color: var(--text-muted); + text-decoration: none; + padding: 0.1rem 0.25rem; + border-radius: var(--radius); + transition: color 0.15s, background 0.15s; +} + +.zddc-stage:hover { + color: var(--text); + background: var(--bg-secondary); + text-decoration: none; +} + +.zddc-stage--active { + color: var(--primary); + font-weight: 600; +} + +.zddc-stage--active:hover { + color: var(--primary); +} diff --git a/shared/nav.js b/shared/nav.js new file mode 100644 index 0000000..00b4bbc --- /dev/null +++ b/shared/nav.js @@ -0,0 +1,141 @@ +// shared/nav.js — lateral navigation strip across the four canonical +// project stages (archive · working · staging · reviewing). Renders +// only when: +// 1. location.protocol is http: or https: (online — file:// has no +// project structure to navigate within), AND +// 2. a project segment can be detected from location.pathname (the +// first path segment, when it isn't a tool HTML file). +// +// The strip is inserted as a sibling of
+// on DOMContentLoaded — no template changes required. Each tool just +// needs ../shared/nav.{js,css} in its build.sh. +// +// Stage URLs follow the canonical workflow folders documented at +// zddc.varasys.io/reference.html#transmittal-workflow: +// archive → /archive.html (archive tool, project-root mode) +// working → /working/ (directory listing → mdedit auto-serves) +// staging → /staging/ (directory listing → transmittal auto-serves) +// reviewing → /reviewing/ (directory listing) +// +// If a deployment doesn't have one of these folders the link will 404 — +// the strip is convention-driven, not probed. Operators on non-standard +// layouts can override by setting window.zddc.nav.disabled = true before +// DOMContentLoaded. +(function () { + 'use strict'; + + if (!window.zddc) window.zddc = {}; + if (window.zddc.nav) return; // already loaded + + var STAGES = [ + { key: 'archive', label: 'Archive', target: 'archive.html' }, + { key: 'working', label: 'Working', target: 'working/' }, + { key: 'staging', label: 'Staging', target: 'staging/' }, + { key: 'reviewing', label: 'Reviewing', target: 'reviewing/' }, + ]; + + function projectSegment(pathname) { + var parts = pathname.split('/').filter(Boolean); + if (parts.length === 0) return null; + var first = parts[0]; + // At deployment root (e.g. /archive.html?projects=A,B or + // /index.html) the first segment is a tool HTML — no single + // project to scope the strip to. + if (first.indexOf('.') !== -1) return null; + return first; + } + + function currentStage(pathname) { + var parts = pathname.split('/').filter(Boolean); + if (parts.length < 2) return null; + var second = parts[1]; + // /working/... | staging/... | reviewing/... | archive/... + for (var i = 0; i < STAGES.length; i++) { + if (second === STAGES[i].key) return STAGES[i].key; + } + // /archive.html → still the archive stage + if (second === 'archive.html') return 'archive'; + return null; + } + + function shouldRender() { + if (typeof location === 'undefined') return false; + if (location.protocol !== 'http:' && location.protocol !== 'https:') return false; + if (window.zddc.nav && window.zddc.nav.disabled) return false; + return projectSegment(location.pathname) !== null; + } + + function buildStrip(project, active) { + var nav = document.createElement('nav'); + nav.className = 'zddc-stage-strip'; + nav.setAttribute('aria-label', 'Project stage'); + + var label = document.createElement('span'); + label.className = 'zddc-stage-strip__project'; + label.textContent = project; + nav.appendChild(label); + + var sep0 = document.createElement('span'); + sep0.className = 'zddc-stage-strip__divider'; + sep0.setAttribute('aria-hidden', 'true'); + sep0.textContent = '/'; + nav.appendChild(sep0); + + for (var i = 0; i < STAGES.length; i++) { + var s = STAGES[i]; + var a = document.createElement('a'); + a.className = 'zddc-stage'; + a.href = '/' + encodeURIComponent(project) + '/' + s.target; + a.textContent = s.label; + if (s.key === active) { + a.classList.add('zddc-stage--active'); + a.setAttribute('aria-current', 'page'); + } + nav.appendChild(a); + + if (i < STAGES.length - 1) { + var sep = document.createElement('span'); + sep.className = 'zddc-stage-strip__sep'; + sep.setAttribute('aria-hidden', 'true'); + sep.textContent = '·'; + nav.appendChild(sep); + } + } + + return nav; + } + + function mount() { + if (!shouldRender()) return; + var header = document.querySelector('.app-header'); + if (!header) return; + // Don't double-mount if a tool's main.js calls us a second time. + if (header.nextElementSibling && + header.nextElementSibling.classList && + header.nextElementSibling.classList.contains('zddc-stage-strip')) { + return; + } + var project = projectSegment(location.pathname); + var active = currentStage(location.pathname); + var strip = buildStrip(project, active); + header.parentNode.insertBefore(strip, header.nextSibling); + } + + // Expose for tests + opt-out. + window.zddc.nav = { + mount: mount, + // Internals visible for unit tests; do not call from tools. + _projectSegment: projectSegment, + _currentStage: currentStage, + _stages: STAGES, + // Set to true before DOMContentLoaded to suppress mounting on + // deployments where the canonical folder layout doesn't apply. + disabled: false, + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount, { once: true }); + } else { + mount(); + } +})(); diff --git a/tables/build.sh b/tables/build.sh index 1aa90ea..bea0c1d 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -20,6 +20,7 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/nav.css" \ "css/table.css" \ "../form/css/form.css" \ > "$css_temp" @@ -35,6 +36,7 @@ concat_files \ "../shared/zddc-source.js" \ "../shared/theme.js" \ "../shared/toast.js" \ + "../shared/nav.js" \ "../shared/help.js" \ "js/mode.js" \ "js/app.js" \ diff --git a/tests/nav.spec.js b/tests/nav.spec.js new file mode 100644 index 0000000..4dbd79e --- /dev/null +++ b/tests/nav.spec.js @@ -0,0 +1,91 @@ +// Tests for shared/nav.js — the lateral project-stage strip. +// +// The strip's render decision depends on location.protocol and +// location.pathname. file:// won't render at all (online-only). To +// exercise online behavior we spin up a tiny in-process HTTP server +// for this spec so the page can be served from http://127.0.0.1: +// at arbitrary paths. + +import { test, expect } from '@playwright/test'; +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; + +const HTML_PATH = path.resolve('classifier/dist/classifier.html'); + +let server; +let baseUrl; + +test.beforeAll(async () => { + const html = fs.readFileSync(HTML_PATH, 'utf8'); + server = http.createServer((req, res) => { + // Serve the same classifier HTML at every path. The strip's + // detection logic uses location.pathname; the bytes don't have + // to vary. + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + baseUrl = `http://127.0.0.1:${port}`; +}); + +test.afterAll(async () => { + if (server) await new Promise(resolve => server.close(resolve)); +}); + +test.describe('shared/nav.js stage strip', () => { + + test('does NOT render at the deployment root', async ({ page }) => { + await page.goto(`${baseUrl}/index.html`, { waitUntil: 'load' }); + await page.waitForSelector('.app-header', { timeout: 5000 }); + await expect(page.locator('.zddc-stage-strip')).toHaveCount(0); + }); + + test('renders for /archive.html with archive active', async ({ page }) => { + await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' }); + const strip = page.locator('.zddc-stage-strip'); + await expect(strip).toHaveCount(1); + await expect(strip.locator('.zddc-stage-strip__project')).toHaveText('projA'); + + const stages = await strip.locator('.zddc-stage').allTextContents(); + expect(stages).toEqual(['Archive', 'Working', 'Staging', 'Reviewing']); + + const active = strip.locator('.zddc-stage--active'); + await expect(active).toHaveCount(1); + await expect(active).toHaveText('Archive'); + await expect(active).toHaveAttribute('aria-current', 'page'); + }); + + test('renders for /working/foo/mdedit.html with working active', async ({ page }) => { + await page.goto(`${baseUrl}/projA/working/casey/mdedit.html`, { waitUntil: 'load' }); + const active = page.locator('.zddc-stage-strip .zddc-stage--active'); + await expect(active).toHaveText('Working'); + }); + + test('stage links point to the canonical // URLs', async ({ page }) => { + await page.goto(`${baseUrl}/projA/staging/`, { waitUntil: 'load' }); + await page.waitForSelector('.zddc-stage-strip'); + + const links = await page.evaluate(() => { + const xs = document.querySelectorAll('.zddc-stage-strip .zddc-stage'); + return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') })); + }); + expect(links).toEqual([ + { text: 'Archive', href: '/projA/archive.html' }, + { text: 'Working', href: '/projA/working/' }, + { text: 'Staging', href: '/projA/staging/' }, + { text: 'Reviewing', href: '/projA/reviewing/' }, + ]); + }); + + test('mounts immediately under the app-header', async ({ page }) => { + await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' }); + const next = await page.evaluate(() => { + const h = document.querySelector('.app-header'); + return h && h.nextElementSibling && h.nextElementSibling.className; + }); + expect(next).toContain('zddc-stage-strip'); + }); + +}); diff --git a/transmittal/build.sh b/transmittal/build.sh index ebf75de..ccce04a 100755 --- a/transmittal/build.sh +++ b/transmittal/build.sh @@ -24,6 +24,7 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/nav.css" \ "css/base.css" \ "css/layout.css" \ "css/forms.css" \ @@ -49,6 +50,7 @@ concat_files \ "../shared/zddc-source.js" \ "../shared/theme.js" \ "../shared/toast.js" \ + "../shared/nav.js" \ "../shared/preview-lib.js" \ "js/app.js" \ "js/reactive.js" \ diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index a8c17bb..31b77be 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -571,6 +571,63 @@ body.help-open .app-header { to { transform: translateX(100%); opacity: 0; } } +/* shared/nav.css — lateral project-stage strip paired with shared/nav.js. + Sits as a sibling immediately under .app-header (mounted by JS). + Rendered only in online mode when a project segment is in the URL. */ + +.zddc-stage-strip { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 1rem; + background: var(--bg); + border-bottom: 1px solid var(--border); + font-size: 0.8rem; + line-height: 1.3; + flex-shrink: 0; + overflow-x: auto; + white-space: nowrap; +} + +.zddc-stage-strip__project { + color: var(--text); + font-weight: 600; + margin-right: 0.15rem; +} + +.zddc-stage-strip__divider, +.zddc-stage-strip__sep { + color: var(--text-muted); + user-select: none; +} + +.zddc-stage-strip__divider { + margin-right: 0.35rem; +} + +.zddc-stage { + color: var(--text-muted); + text-decoration: none; + padding: 0.1rem 0.25rem; + border-radius: var(--radius); + transition: color 0.15s, background 0.15s; +} + +.zddc-stage:hover { + color: var(--text); + background: var(--bg-secondary); + text-decoration: none; +} + +.zddc-stage--active { + color: var(--primary); + font-weight: 600; +} + +.zddc-stage--active:hover { + color: var(--primary); +} + /* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */ .table-main { @@ -1013,7 +1070,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-10 00:02:54 · 538167b-dirty + v0.0.17-alpha · 2026-05-10 00:48:10 · cc515b0-dirty
@@ -2075,6 +2132,148 @@ body.help-open .app-header { window.zddc.toast = toast; })(); +// shared/nav.js — lateral navigation strip across the four canonical +// project stages (archive · working · staging · reviewing). Renders +// only when: +// 1. location.protocol is http: or https: (online — file:// has no +// project structure to navigate within), AND +// 2. a project segment can be detected from location.pathname (the +// first path segment, when it isn't a tool HTML file). +// +// The strip is inserted as a sibling of
+// on DOMContentLoaded — no template changes required. Each tool just +// needs ../shared/nav.{js,css} in its build.sh. +// +// Stage URLs follow the canonical workflow folders documented at +// zddc.varasys.io/reference.html#transmittal-workflow: +// archive → /archive.html (archive tool, project-root mode) +// working → /working/ (directory listing → mdedit auto-serves) +// staging → /staging/ (directory listing → transmittal auto-serves) +// reviewing → /reviewing/ (directory listing) +// +// If a deployment doesn't have one of these folders the link will 404 — +// the strip is convention-driven, not probed. Operators on non-standard +// layouts can override by setting window.zddc.nav.disabled = true before +// DOMContentLoaded. +(function () { + 'use strict'; + + if (!window.zddc) window.zddc = {}; + if (window.zddc.nav) return; // already loaded + + var STAGES = [ + { key: 'archive', label: 'Archive', target: 'archive.html' }, + { key: 'working', label: 'Working', target: 'working/' }, + { key: 'staging', label: 'Staging', target: 'staging/' }, + { key: 'reviewing', label: 'Reviewing', target: 'reviewing/' }, + ]; + + function projectSegment(pathname) { + var parts = pathname.split('/').filter(Boolean); + if (parts.length === 0) return null; + var first = parts[0]; + // At deployment root (e.g. /archive.html?projects=A,B or + // /index.html) the first segment is a tool HTML — no single + // project to scope the strip to. + if (first.indexOf('.') !== -1) return null; + return first; + } + + function currentStage(pathname) { + var parts = pathname.split('/').filter(Boolean); + if (parts.length < 2) return null; + var second = parts[1]; + // /working/... | staging/... | reviewing/... | archive/... + for (var i = 0; i < STAGES.length; i++) { + if (second === STAGES[i].key) return STAGES[i].key; + } + // /archive.html → still the archive stage + if (second === 'archive.html') return 'archive'; + return null; + } + + function shouldRender() { + if (typeof location === 'undefined') return false; + if (location.protocol !== 'http:' && location.protocol !== 'https:') return false; + if (window.zddc.nav && window.zddc.nav.disabled) return false; + return projectSegment(location.pathname) !== null; + } + + function buildStrip(project, active) { + var nav = document.createElement('nav'); + nav.className = 'zddc-stage-strip'; + nav.setAttribute('aria-label', 'Project stage'); + + var label = document.createElement('span'); + label.className = 'zddc-stage-strip__project'; + label.textContent = project; + nav.appendChild(label); + + var sep0 = document.createElement('span'); + sep0.className = 'zddc-stage-strip__divider'; + sep0.setAttribute('aria-hidden', 'true'); + sep0.textContent = '/'; + nav.appendChild(sep0); + + for (var i = 0; i < STAGES.length; i++) { + var s = STAGES[i]; + var a = document.createElement('a'); + a.className = 'zddc-stage'; + a.href = '/' + encodeURIComponent(project) + '/' + s.target; + a.textContent = s.label; + if (s.key === active) { + a.classList.add('zddc-stage--active'); + a.setAttribute('aria-current', 'page'); + } + nav.appendChild(a); + + if (i < STAGES.length - 1) { + var sep = document.createElement('span'); + sep.className = 'zddc-stage-strip__sep'; + sep.setAttribute('aria-hidden', 'true'); + sep.textContent = '·'; + nav.appendChild(sep); + } + } + + return nav; + } + + function mount() { + if (!shouldRender()) return; + var header = document.querySelector('.app-header'); + if (!header) return; + // Don't double-mount if a tool's main.js calls us a second time. + if (header.nextElementSibling && + header.nextElementSibling.classList && + header.nextElementSibling.classList.contains('zddc-stage-strip')) { + return; + } + var project = projectSegment(location.pathname); + var active = currentStage(location.pathname); + var strip = buildStrip(project, active); + header.parentNode.insertBefore(strip, header.nextSibling); + } + + // Expose for tests + opt-out. + window.zddc.nav = { + mount: mount, + // Internals visible for unit tests; do not call from tools. + _projectSegment: projectSegment, + _currentStage: currentStage, + _stages: STAGES, + // Set to true before DOMContentLoaded to suppress mounting on + // deployments where the canonical folder layout doesn't apply. + disabled: false, + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount, { once: true }); + } else { + mount(); + } +})(); + /** * ZDDC shared help panel — open/close logic. * Works with all four tools regardless of their module pattern.