From 30192bb85351b60f232f0701f42b7c39fd23dab3 Mon Sep 17 00:00:00 2001
From: dev
Date: Tue, 2 Jun 2026 18:45:06 +0200
Subject: [PATCH] feat(words): production-grade demo topbar (breadcrumb +
Edit/Preview + Saved)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
POLISH-3. Replaces the bare Inspector-mode row in the Words demo with a real
topbar: a document breadcrumb (FileText icon + the doc's first-heading title),
a "Saved / Editing…" indicator that flips on document change and settles after
a short idle, and the Inspector-mode + Edit/Preview toggles.
Preview mode renders the document as a clean, read-only reading view via the
export-only `serializeHtml` serializer (bare semantic HTML with its own
typography — headings, blockquote, code, lists, table, links). Showcases the
export path now that table cells serialize block content (… | ).
Also exposes the export-only serializers on the public soma barrel
(`serializeHtml`, `serializeMarkdown`, `renderWordsPlainText`) — apps need them
for preview / copy / download.
Browser-verified: topbar renders; Edit↔Preview swaps the editor for the
read-only article (table + headings render); Saved badge shows. npm run check:
0 new errors (1 pre-existing grafito baseline).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/uix/soma/components/words/exports.ts | 8 +
web/routes/uix/components/words/+page.svelte | 243 +++++++++++++++++--
2 files changed, 231 insertions(+), 20 deletions(-)
diff --git a/src/uix/soma/components/words/exports.ts b/src/uix/soma/components/words/exports.ts
index 6d80ccfea..810404e3a 100644
--- a/src/uix/soma/components/words/exports.ts
+++ b/src/uix/soma/components/words/exports.ts
@@ -86,3 +86,11 @@ export type { WordsAncestryNode, WordsBlockLocation } from './engine/block-tree'
// inspector's image-src field. Allows http(s) / mailto / tel / relative
// + anchor URLs; rejects javascript: and every other scheme.
export { sanitizeWordsUrl } from './engine/normalize';
+
+// Export-only serializers (lossy → no faithful inverse; the import path
+// accepts JSON + plain text only). `serializeHtml` emits clean semantic
+// HTML; `serializeMarkdown` emits GFM; `renderWordsPlainText` flattens to
+// text. Apps use these for a read-only preview / copy / download.
+export { serializeHtml } from './engine/serialize-html';
+export { serializeMarkdown } from './engine/serialize-markdown';
+export { renderWordsPlainText } from './engine/operations/serialize-text';
diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte
index 4126b124b..fa02b67aa 100644
--- a/web/routes/uix/components/words/+page.svelte
+++ b/web/routes/uix/components/words/+page.svelte
@@ -4,6 +4,8 @@
// settings drawer) lands in F3.
import Words from '$uix/eidos/components/words';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
+ import { Eye, Pencil, FileText, Check } from '$uix/eidos/components/icon';
+ import { serializeHtml } from '$soma/components/words';
import type { WordsInspectorMode } from '$uix/eidos/components/words/types';
import type {
WordsDocument,
@@ -191,6 +193,48 @@
}
]
});
+
+ // ── Topbar state ──────────────────────────────────────────────────────
+ // Edit / Preview. Preview renders the document as clean read-only HTML
+ // via the export-only `serializeHtml` serializer.
+ let mode = $state<'edit' | 'preview'>('edit');
+ const EDIT_MODES: readonly { v: 'edit' | 'preview'; label: string }[] = [
+ { v: 'edit', label: 'Edit' },
+ { v: 'preview', label: 'Preview' }
+ ];
+ const previewHtml = $derived(mode === 'preview' ? serializeHtml(value) : '');
+
+ // Breadcrumb title — the first heading's text, else a placeholder.
+ const docTitle = $derived.by(() => {
+ for (const b of value.children) {
+ if (b.type === 'heading') {
+ const text = b.children
+ .map((c) => (c.type === 'text' ? c.text : ''))
+ .join('')
+ .trim();
+ if (text) return text;
+ }
+ }
+ return 'Untitled document';
+ });
+
+ // "Saved" indicator — flips to "Editing…" on any document change, settles
+ // back to "Saved" after a short idle. Demo-only chrome (a real app would
+ // debounce a network save here).
+ let saveState = $state<'saved' | 'editing'>('saved');
+ let saveTimer: ReturnType | undefined;
+ let trackedInitial = false;
+ $effect(() => {
+ void value; // re-run whenever the bound document changes
+ if (!trackedInitial) {
+ trackedInitial = true;
+ return;
+ }
+ saveState = 'editing';
+ clearTimeout(saveTimer);
+ saveTimer = setTimeout(() => (saveState = 'saved'), 600);
+ return () => clearTimeout(saveTimer);
+ });
-
-
Inspector
-
{
- if (v[0]) inspector = v[0] as WordsInspectorMode;
- }}
- aria-label="Inspector mode"
- >
- {#each INSPECTOR_MODES as m (m.v)}
- {m.label}
- {/each}
-
+
+
+
+ {docTitle}
+
+ {#if saveState === 'saved'}Saved{:else}Editing…{/if}
+
+
+
+
Inspector
+
{
+ if (v[0]) inspector = v[0] as WordsInspectorMode;
+ }}
+ aria-label="Inspector mode"
+ >
+ {#each INSPECTOR_MODES as m (m.v)}
+ {m.label}
+ {/each}
+
+
{
+ if (v[0]) mode = v[0] as 'edit' | 'preview';
+ }}
+ aria-label="Edit or preview"
+ >
+ {#each EDIT_MODES as m (m.v)}
+
+ {#if m.v === 'edit'}{:else}{/if}
+ {m.label}
+
+ {/each}
+
+
-
-
+
+ {#if mode === 'edit'}
+
+ {:else}
+
+
+
{@html previewHtml}
+ {/if}
@@ -244,11 +328,52 @@
inline-size: 100%;
max-inline-size: 62rem;
}
- .demo-controls {
+ .demo-topbar {
display: flex;
align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
gap: var(--space-3);
margin-block-end: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-md);
+ background: var(--color-surface-raised);
+ }
+ .demo-doc {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-inline-size: 0;
+ color: var(--color-content-secondary);
+ }
+ .demo-doc-title {
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ color: var(--color-content-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .demo-save {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ margin-inline-start: var(--space-1);
+ font-size: var(--font-size-xs);
+ transition: color var(--duration-fast) var(--ease-default);
+ }
+ .demo-save[data-state='saved'] {
+ color: var(--color-affirm-text, var(--color-content-secondary));
+ }
+ .demo-save[data-state='editing'] {
+ color: var(--color-content-muted);
+ }
+ .demo-topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
}
.demo-controls-label {
font-size: var(--font-size-sm);
@@ -270,4 +395,82 @@
background: var(--color-surface-overlay);
border-radius: var(--radius-md);
}
+
+ /* Preview mode — the document as a clean reading view. The preview is
+ bare serialized HTML, so it carries its own typography (the editor's
+ `[data-words]` CSS does not apply here). */
+ .demo-canvas[data-mode='preview'] {
+ justify-content: flex-start;
+ }
+ .demo-preview {
+ inline-size: 100%;
+ max-inline-size: 48rem;
+ margin-inline: auto;
+ color: var(--color-content-primary);
+ font-size: var(--font-size-md);
+ line-height: 1.7;
+ }
+ .demo-preview :global(h1) {
+ font-size: var(--font-size-2xl);
+ font-weight: 700;
+ margin-block: var(--space-5) var(--space-3);
+ }
+ .demo-preview :global(h2) {
+ font-size: var(--font-size-xl);
+ font-weight: 700;
+ margin-block: var(--space-4) var(--space-2);
+ }
+ .demo-preview :global(h3) {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ margin-block: var(--space-4) var(--space-2);
+ }
+ .demo-preview :global(p) {
+ margin-block: var(--space-3);
+ }
+ .demo-preview :global(ul),
+ .demo-preview :global(ol) {
+ margin-block: var(--space-3);
+ padding-inline-start: var(--space-6);
+ }
+ .demo-preview :global(blockquote) {
+ margin-block: var(--space-3);
+ padding-inline-start: var(--space-4);
+ border-inline-start: 3px solid var(--color-border-default);
+ color: var(--color-content-secondary);
+ }
+ .demo-preview :global(pre) {
+ margin-block: var(--space-3);
+ padding: var(--space-3);
+ overflow: auto;
+ background: var(--color-surface-overlay);
+ border-radius: var(--radius-md);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-sm);
+ }
+ .demo-preview :global(table) {
+ inline-size: 100%;
+ margin-block: var(--space-4);
+ border-collapse: collapse;
+ }
+ .demo-preview :global(th),
+ .demo-preview :global(td) {
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border-subtle);
+ text-align: start;
+ vertical-align: top;
+ }
+ .demo-preview :global(th) {
+ background: var(--color-surface-raised);
+ font-weight: 600;
+ }
+ .demo-preview :global(img) {
+ max-inline-size: 100%;
+ height: auto;
+ border-radius: var(--radius-md);
+ }
+ .demo-preview :global(a) {
+ color: var(--color-primary-text, var(--color-primary-element));
+ text-decoration: underline;
+ }