diff --git a/apps/web/src/lib/word-count.ts b/apps/web/src/lib/word-count.ts
new file mode 100644
index 0000000..5eb2a30
--- /dev/null
+++ b/apps/web/src/lib/word-count.ts
@@ -0,0 +1,22 @@
+/**
+ * Approximate word count from plain text (issue #134). Splits on whitespace
+ * and keeps only tokens that contain a letter or digit, so markdown/HTML
+ * punctuation (`##`, `-`, `|`, `>`) never inflates the count.
+ */
+export function countWords(text: string): number {
+ if (!text) return 0;
+ return text
+ .trim()
+ .split(/\s+/)
+ .filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
+}
+
+/**
+ * Strip HTML to its visible text using the browser's parser (issue #134). Used
+ * by the public read view to derive a word count from the server-rendered HTML
+ * without loading the editor bundle.
+ */
+export function htmlToText(html: string): string {
+ if (!html) return '';
+ return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '';
+}
diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx
index 96d9d77..86918c3 100644
--- a/apps/web/src/pages/PageEditorPage.tsx
+++ b/apps/web/src/pages/PageEditorPage.tsx
@@ -32,9 +32,11 @@ import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
import { usePageActionsSlot } from '../layout/page-actions';
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
-import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api';
+import { ApiError, apiGet, apiGetText, apiPatch, apiPost } from '../lib/api';
import { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard';
+import { countWords } from '../lib/word-count';
import { PageActions } from './PageActions';
+import { PageStatusBar } from './PageStatusBar';
import { recallPage, rememberPage } from '../offline/page-cache';
import { PluginBlockContext } from '../editor/plugin-block-context';
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
@@ -360,6 +362,16 @@ export function PageEditorPage(): React.JSX.Element {
enabled: Boolean(pond.data),
});
+ // Word count for the read-mode status line (#134). Derived from the same
+ // Markdown export the history panel uses (shared query key), fetched only in
+ // reading mode so the editor session pays nothing.
+ const pageMarkdown = useQuery({
+ queryKey: ['page-markdown', page.data?.id],
+ queryFn: () => apiGetText(`/pages/${page.data!.id}/export/markdown`),
+ enabled: mode === 'view' && Boolean(page.data?.id),
+ });
+ const wordCount = useMemo(() => countWords(pageMarkdown.data ?? ''), [pageMarkdown.data]);
+
// Remember this page's metadata while online so it can be opened offline (#38).
useEffect(() => {
if (pond.data && page.data) {
@@ -521,6 +533,11 @@ export function PageEditorPage(): React.JSX.Element {
onBlur={() => void saveTitle()}
/>
+ {/* Status line between the header and the article (#134): last update,
+ word count, reading time — reading mode only. */}
+ {mode === 'view' && page.data && (
+
+ )}
{
+ const date = new Date(updatedAt);
+ if (Number.isNaN(date.getTime())) return null;
+ return new Intl.DateTimeFormat(i18n.language, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(date);
+ }, [updatedAt, i18n.language]);
+
+ const minutes = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE));
+
+ return (
+
+ {updated && (
+ {t('statusbar.updated', { date: updated })}
+ )}
+ {t('statusbar.words', { count: wordCount })}
+ {wordCount > 0 && (
+ {t('statusbar.readingTime', { minutes })}
+ )}
+
+ );
+}
diff --git a/apps/web/src/pages/PublicPageView.tsx b/apps/web/src/pages/PublicPageView.tsx
index 23d9981..1db8ecd 100644
--- a/apps/web/src/pages/PublicPageView.tsx
+++ b/apps/web/src/pages/PublicPageView.tsx
@@ -1,9 +1,12 @@
import { useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { ApiError, apiGet } from '../lib/api';
+import { countWords, htmlToText } from '../lib/word-count';
import { NotFoundPage } from './NotFoundPage';
+import { PageStatusBar } from './PageStatusBar';
interface PublicPageContent {
pondName: string;
@@ -31,6 +34,14 @@ export function PublicPageView(): React.JSX.Element {
retry: false,
});
+ // Word count for the status line (#134), derived from the server-rendered
+ // HTML — no editor bundle needed. Kept before the early returns so the hook
+ // order stays stable.
+ const wordCount = useMemo(
+ () => countWords(htmlToText(query.data?.html ?? '')),
+ [query.data?.html],
+ );
+
if (query.error instanceof ApiError && query.error.status === 404) return ;
if (query.isLoading || !query.data) return ;
@@ -40,6 +51,7 @@ export function PublicPageView(): React.JSX.Element {
{t('readOnlyBadge')}
{page.pondName}
{page.title}
+
{/* The HTML comes from the server's content cache (issue #24), derived
from the sanitized editor schema — safe to render. */}
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css
index c10aac2..b09634f 100644
--- a/apps/web/src/styles/base.css
+++ b/apps/web/src/styles/base.css
@@ -979,6 +979,29 @@ button {
outline-offset: 2px;
}
+/* Status line between page header and article (#134). Middot-separated,
+ muted; shared by the authenticated read view and the public view. */
+.page-statusbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-top: calc(-1 * var(--space-2));
+ margin-bottom: var(--space-4);
+ color: var(--color-text-muted);
+ font-size: 0.8125rem;
+}
+
+.page-statusbar__item {
+ display: inline-flex;
+ align-items: center;
+}
+
+.page-statusbar__item:not(:first-child)::before {
+ content: '·';
+ margin-right: var(--space-2);
+ color: var(--color-border);
+}
+
/* The export entries flow inline with the other overflow-menu items. */
.editor-page__export {
display: contents;
diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json
index 10c9916..0117fa0 100644
--- a/packages/shared/i18n/de/common.json
+++ b/packages/shared/i18n/de/common.json
@@ -1,4 +1,11 @@
{
+ "statusbar": {
+ "label": "Seiteninformationen",
+ "updated": "Aktualisiert {{date}}",
+ "words_one": "{{count}} Wort",
+ "words_other": "{{count}} Wörter",
+ "readingTime": "ca. {{minutes}} Min. Lesezeit"
+ },
"layout": {
"sidebar": {
"expand": "Seitenleiste einblenden",
diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json
index 919dcb8..b9e8576 100644
--- a/packages/shared/i18n/en/common.json
+++ b/packages/shared/i18n/en/common.json
@@ -1,4 +1,11 @@
{
+ "statusbar": {
+ "label": "Page information",
+ "updated": "Updated {{date}}",
+ "words_one": "{{count}} word",
+ "words_other": "{{count}} words",
+ "readingTime": "~{{minutes}} min read"
+ },
"layout": {
"sidebar": {
"expand": "Show sidebar",