Skip to content

Commit 716ab2d

Browse files
committed
Add live event preview to the editor form (issue #54)
Renders the event being edited with the existing <ote-events> widget (apps/embed), fed purely in-memory from the current form draft and debounced as the organizer types, so they can see how title length, description, tags, and venue will actually look without saving/exporting first. Works on a blank or mid-edit draft since the underlying JSON conversion never requires a schema-valid document. On narrow viewports it's a Form/Preview toggle above the form (same pattern as the description field's own Edit/Preview toggle). From a new 72rem breakpoint up, the form and preview render side by side as two columns instead, making better use of desktop screen space — the editor is no longer single-column at every width, reversing that prior convention documented in styles.css/CLAUDE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XXpiV7p6xPpVmiVWtYsugd
1 parent a1bb4eb commit 716ab2d

6 files changed

Lines changed: 229 additions & 3 deletions

File tree

apps/editor/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ tied to semver releases.
66

77
## 2026-08-13
88

9+
- **Live preview**: the form now renders the event being edited with the
10+
same `<ote-events>` widget (`apps/embed`) used elsewhere in the app, fed
11+
purely in-memory from the current draft (`layout="cards"`, debounced ~200ms
12+
as you type) — no need to save/export to see how title length, description
13+
formatting, tags, or venue will actually look. Works on a blank or
14+
mid-edit draft; missing fields just render as absent, nothing blocks the
15+
preview on validity. On narrow viewports it's a Form/Preview toggle above
16+
the form (same pattern as the description field's own Edit/Preview
17+
toggle); from the new wide-desktop breakpoint up, the form and preview
18+
render side by side as two columns instead — the editor's layout is no
19+
longer single-column at every width (see the updated note at the top of
20+
`styles.css`).
921
- **"Edit series" warns per field when occurrences already disagree**: any
1022
field in the shared bulk-edit template that isn't uniform across the
1123
series — e.g. one occurrence's `description` was hand-tweaked — now shows

apps/editor/CLAUDE.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,53 @@ is expected and the filename should never be treated as authoritative —
133133
only `startDate` inside the JSON is. See DESIGN.md's "Flujo de escritura"
134134
section for the cross-repo rationale.
135135

136+
## The layout is no longer single-column at every width
137+
138+
`styles.css`'s top-of-file comment used to say the layout "never splits."
139+
That was a deliberate choice at the time, but it's been overridden: from a
140+
new wide-desktop breakpoint (`min-width: 72rem`) up, `#form-view` becomes a
141+
two-column CSS grid — the form on the left, the live preview
142+
(`#preview-column`, see below) on the right — so a wide screen shows both
143+
instead of just getting more padding. Below that breakpoint (including the
144+
existing "wider desktop" ~52rem tier), the app is still genuinely
145+
single-column, unchanged. Don't assume single-column-always when reading
146+
older comments or issues that predate this — check the `72rem` tier's rules
147+
in `styles.css` first.
148+
149+
`body`'s `max-width` and `#action-bar`'s fixed width both grow to match at
150+
the same `72rem` tier (44rem form column + 2rem gap + 26rem preview column),
151+
reusing the exact centering mechanism `#action-bar` already used at the
152+
52rem tier (`left: 50%; transform: translateX(-50%); width: …`) rather than
153+
inventing new positioning math — that existing mechanism has its own
154+
hard-won comment nearby about a centering bug already hit once; don't
155+
recompute the action bar's position independently of `body`'s max-width, or
156+
you'll likely reintroduce it.
157+
158+
`#form-view` toggling `data-mobile-tab="form"/"preview"` (below the wide
159+
tier, to pick which of `#form-column`/`#preview-column` shows) uses
160+
attribute-selector rules like `#form-view[data-mobile-tab="preview"]
161+
#form-column`. The wide-tier override that forces both columns visible has
162+
to repeat those same compound selectors (see the comment in `styles.css`
163+
right above it) rather than a plain `#form-column, #preview-column { display:
164+
block }` — a lower-specificity override there would silently lose to the
165+
mobile-tab rule if `data-mobile-tab` still happens to be `"preview"` from a
166+
narrower viewport (main.ts doesn't reset it on resize).
167+
168+
## Live preview: `<ote-events>` fed purely in-memory
169+
170+
`#preview-column` embeds the same `<ote-events>` widget (`apps/embed`,
171+
already self-hosted via `dist/embed` — see `build.mjs`) as the events list
172+
view, but driven from the in-memory draft rather than a fetched feed:
173+
`main.ts`'s `refresh()` computes `toEventJson(state)` and calls
174+
`schedulePreviewUpdate()`, which debounces ~200ms then sets
175+
`previewWidget.events = [event]`. This works because
176+
`oteJsonToPreviewFeed` (`packages/preview-feed`) treats every event field as
177+
optional and only ever throws if its input isn't shaped like `{ events:
178+
[...] }` — never true here — so the preview renders fine even for a blank
179+
new-event draft or mid-edit bulk-edit template. There is deliberately no
180+
"wait until the draft is schema-valid" gate; if you're tempted to add one,
181+
it isn't needed and would just make the preview lag behind typing.
182+
136183
## OTE has no recurrence-rule concept
137184

138185
The spec is explicit: one document per occurrence, always ("un documento

apps/editor/index.html

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,35 @@ <h3 data-i18n="dialog.feedSettings.editorGroup">Editor setup</h3>
279279
untouched; this wrapper is a second, independent layer on top of
280280
that, not a replacement for it. -->
281281
<div id="form-view">
282+
<!-- Narrow viewports only (hidden once the two-column layout kicks in
283+
at the wide breakpoint, where the form and preview render side
284+
by side) — lets the organizer switch between editing and seeing
285+
the live preview without leaving the form view. Same
286+
.mode-toggle radiogroup component as #description-editor-mode's
287+
Edit/Preview toggle; driven by main.ts setting
288+
#form-view[data-mobile-tab] rather than the hidden attribute.
289+
Deliberately a sibling of #form-column, not nested inside it —
290+
nesting it there would hide the toggle itself along with the
291+
rest of the form as soon as the Preview tab is selected,
292+
trapping the organizer on the preview with no way back. -->
293+
<div
294+
class="mode-toggle"
295+
id="preview-toggle"
296+
role="radiogroup"
297+
aria-label="View"
298+
data-i18n-aria-label="nav.previewToggle.label"
299+
>
300+
<label>
301+
<input type="radio" name="preview-toggle" value="form" checked />
302+
<span data-i18n="nav.previewToggle.form">Form</span>
303+
</label>
304+
<label>
305+
<input type="radio" name="preview-toggle" value="preview" />
306+
<span data-i18n="nav.previewToggle.preview">Preview</span>
307+
</label>
308+
</div>
309+
310+
<div id="form-column">
282311
<button id="back-to-list" type="button" class="secondary" hidden data-i18n="nav.backToEvents">
283312
← Back to events
284313
</button>
@@ -367,6 +396,19 @@ <h3 data-i18n="dialog.feedSettings.editorGroup">Editor setup</h3>
367396
<form id="event-form" autocomplete="off"></form>
368397

369398
<ul id="document-errors" hidden></ul>
399+
</div>
400+
401+
<!-- Live preview: <ote-events> (apps/embed), fed purely in-memory from
402+
the current form state (main.ts's schedulePreviewUpdate, called
403+
from refresh()) — no feed=/network fetch. Below the wide
404+
breakpoint this is shown/hidden by #preview-toggle instead of
405+
sitting beside the form; see the #form-view grid rule in
406+
styles.css. Same widget attributes as the events-list-view's own
407+
#events-widget instance, for a consistent look. -->
408+
<aside id="preview-column">
409+
<h2 data-i18n="nav.previewToggle.heading">Preview</h2>
410+
<ote-events id="event-preview" layout="cards" sort="none" theme="light"></ote-events>
411+
</aside>
370412

371413
<div id="action-bar">
372414
<button id="valid-badge" type="button" hidden></button>

apps/editor/src/i18n/es.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export const es: Record<string, string> = {
3737
"nav.sectionsMenu": "☰ Secciones",
3838
"nav.eventsSearchLabel": "Buscar eventos",
3939
"nav.backToEvents": "← Volver a eventos",
40+
"nav.previewToggle.label": "Vista",
41+
"nav.previewToggle.form": "Formulario",
42+
"nav.previewToggle.preview": "Vista previa",
43+
"nav.previewToggle.heading": "Vista previa",
4044
"nav.groupEvents": "Vista",
4145
"nav.groupEvents.individual": "Individual",
4246
"nav.groupEvents.grouped": "Agrupado",

apps/editor/src/main.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,21 @@ async function startEditor(repo: string | null): Promise<void> {
350350
const feedSettingsOpen = el<HTMLButtonElement>("feed-settings-open");
351351
const actionBar = el<HTMLDivElement>("action-bar");
352352
const bulkEditBanner = el<HTMLDivElement>("bulk-edit-banner");
353+
const previewWidget = el<OteEventsElement>("event-preview");
354+
const previewToggle = el<HTMLDivElement>("preview-toggle");
355+
356+
// Narrow viewports: which of #form-column/#preview-column is shown (see
357+
// styles.css's "#form-view: two-column layout" — both always show at the
358+
// wide-desktop tier regardless of this attribute).
359+
formView.dataset.mobileTab = "form";
360+
previewToggle.addEventListener("input", () => {
361+
const mode =
362+
previewToggle.querySelector<HTMLInputElement>('input[name="preview-toggle"]:checked')
363+
?.value === "preview"
364+
? "preview"
365+
: "form";
366+
formView.dataset.mobileTab = mode;
367+
});
353368

354369
/** Toggles the three top-level views. Elements *inside* form-view keep their own content-driven `hidden` logic (section-nav, document-errors…) untouched — this only gates the wrapper. */
355370
function showView(): void {
@@ -873,7 +888,24 @@ async function startEditor(repo: string | null): Promise<void> {
873888
/** Result of the last refresh, consulted by the button handlers. */
874889
let draftValid = false;
875890

891+
// Debounced so a fast typist doesn't force a full shadow-DOM re-render on
892+
// every keystroke; the preview always reflects `state` as of the most
893+
// recent refresh() call, including mid-edit while bulk-editing a series
894+
// template — toEventJson() never throws on a partial/blank event (see
895+
// packages/preview-feed's tolerant field handling), so no separate
896+
// "wait until valid" gate is needed here.
897+
let previewTimer: ReturnType<typeof setTimeout> | undefined;
898+
function schedulePreviewUpdate(event: OteEvent): void {
899+
clearTimeout(previewTimer);
900+
previewTimer = setTimeout(() => {
901+
previewWidget.events = [event] as unknown as OriginalOteEvent[];
902+
}, 200);
903+
}
904+
876905
function refresh(): boolean {
906+
const event = toEventJson(state);
907+
schedulePreviewUpdate(event);
908+
877909
// Bulk-edit template: id/slug/startDate are deliberately blank (see
878910
// buildBulkEditTemplate) — the normal required-field validation would
879911
// be pure noise here, and slug/id auto-suggest/collision-checking
@@ -902,7 +934,6 @@ async function startEditor(repo: string | null): Promise<void> {
902934
setControlValue("id", state.id);
903935
}
904936
}
905-
const event = toEventJson(state);
906937
const result = validateDraft(config, event, new Date().toISOString());
907938

908939
// Collisions against the repo's existing events (best-effort: the

apps/editor/styles.css

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
/* Mobile-first. Single column; the layout never splits — wider screens just
2-
get more breathing room. */
1+
/* Mobile-first. Single column below the wide-desktop breakpoint — wider
2+
screens up to that point just get more breathing room, same as before.
3+
At the wide-desktop tier (see "#form-view: two-column layout" further
4+
down), the form view splits into a form column and a live preview
5+
column side by side; every other view stays single-column at every
6+
width. */
37

48
:root {
59
--bg: #f6f7f9;
@@ -36,6 +40,16 @@ body {
3640
-webkit-font-smoothing: antialiased;
3741
}
3842

43+
/* Matches the two-column form/preview layout's own total width (see
44+
"#form-view: two-column layout" further down) — every view (header,
45+
footer, the events list) gets the same wider column at this tier, not
46+
just the form. */
47+
@media (min-width: 72rem) {
48+
body {
49+
max-width: 72rem;
50+
}
51+
}
52+
3953
/* --- header ------------------------------------------------------------- */
4054

4155
header {
@@ -223,6 +237,12 @@ header h1 {
223237
outline-offset: 2px;
224238
}
225239

240+
/* Form/Preview switch — narrow viewports only, see "#form-view: two-column
241+
layout" for the wide-breakpoint override that hides this entirely. */
242+
#preview-toggle {
243+
margin: 0 0 0.75rem;
244+
}
245+
226246
.description-toolbar {
227247
display: flex;
228248
align-items: center;
@@ -1537,6 +1557,66 @@ select {
15371557
padding: 0.6rem 0.85rem 0.6rem 2rem;
15381558
}
15391559

1560+
/* --- form/preview layout ---------------------------------------------------- */
1561+
1562+
/* Mobile-first default: the live preview column is off-screen entirely,
1563+
and #preview-toggle (in #form-column) switches which one of
1564+
#form-column/#preview-column is shown. Below the wide-desktop tier this
1565+
stays a single column throughout, same as every other view in the app. */
1566+
#preview-column {
1567+
display: none;
1568+
}
1569+
1570+
#form-view[data-mobile-tab="preview"] #form-column {
1571+
display: none;
1572+
}
1573+
1574+
#form-view[data-mobile-tab="preview"] #preview-column {
1575+
display: block;
1576+
}
1577+
1578+
@media (min-width: 72rem) {
1579+
/* Two-column layout: form on the left, live preview on the right, both
1580+
always visible — #preview-toggle is hidden below (nothing to switch
1581+
between anymore). 44rem + 2rem gap + 26rem = 72rem, matching this
1582+
tier's own min-width and this file's widened `body` max-width, so the
1583+
grid never renders cramped right at the breakpoint edge. */
1584+
#form-view:not([hidden]) {
1585+
display: grid;
1586+
grid-template-columns: 44rem 26rem;
1587+
gap: 2rem;
1588+
align-items: start;
1589+
}
1590+
1591+
#preview-toggle {
1592+
display: none;
1593+
}
1594+
1595+
/* Matches the specificity of the mobile-tab rules above
1596+
(#form-view[data-mobile-tab="…"] #form-column/#preview-column) so this
1597+
override actually wins by source order — a plain `#form-column,
1598+
#preview-column { display: block }` here would lose to those rules
1599+
whenever data-mobile-tab happens to still be "preview" from a narrower
1600+
viewport (main.ts doesn't reset it on resize). */
1601+
#form-view[data-mobile-tab="form"] #form-column,
1602+
#form-view[data-mobile-tab="preview"] #form-column,
1603+
#form-view[data-mobile-tab="form"] #preview-column,
1604+
#form-view[data-mobile-tab="preview"] #preview-column {
1605+
display: block;
1606+
}
1607+
1608+
#preview-column {
1609+
position: sticky;
1610+
top: 1rem;
1611+
}
1612+
1613+
#preview-column h2 {
1614+
margin: 0 0 0.6rem;
1615+
font-size: 1rem;
1616+
color: var(--muted);
1617+
}
1618+
}
1619+
15401620
/* --- action bar ------------------------------------------------------------- */
15411621

15421622
/* :not([hidden]) — bulk-edit mode toggles this off in favor of
@@ -1608,6 +1688,16 @@ select {
16081688
}
16091689
}
16101690

1691+
@media (min-width: 72rem) {
1692+
/* Same centering mechanism as the 52rem tier above, just spanning the
1693+
wider two-column content width (form + gap + preview, see "#form-view:
1694+
two-column layout") instead of the single form column — the action bar
1695+
reads as applying to the whole page, not just the form side. */
1696+
#action-bar:not([hidden]) {
1697+
width: 72rem;
1698+
}
1699+
}
1700+
16111701
/* --- buttons -------------------------------------------------------------- */
16121702

16131703
button {

0 commit comments

Comments
 (0)