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); + });
@@ -202,27 +246,67 @@

-
- 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; + }