Appearance
Text Block Templates -- Design Spec
Summary
Extend the text block with a template system. When a template is active, it replaces the manual text editor with a specialized renderer and config panel. When no template is selected, the text block works exactly as today. The first template is Countdown.
Motivation
Designers frequently need specialized text behaviors (countdown timers, live quiz scores) that currently require custom JavaScript via the Tag operator or library scripts. Text templates let designers configure these visually in the builder without writing code, while keeping everything inside the familiar text block.
Architecture
Type extensions (Frontend)
ts
type TextTemplate = 'none' | 'countdown'
// Future: | 'quiz' | 'typewriter' | 'dynamicDate'
type CountdownSegment = {
unit: 'weeks' | 'days' | 'hours' | 'minutes' | 'seconds'
label: string // user-editable: "dager", "d", "days", ""
}
type CountdownTemplateSettings = {
targetDate: string // ISO-8601, e.g. "2026-07-15T00:00:00"
segments: CountdownSegment[] // order = display order, DnD reorderable
expiredText: string // shown when countdown reaches 0
separator: string // e.g. " : " or " "
}
// Added to TextProperties:
template: TextTemplate
templateSettings: CountdownTemplateSettings | null
// templateSettings becomes a union as more templates are addedDefaults
ts
// Added to textDefaults:
template: 'none',
templateSettings: null,
// Countdown defaults (applied when user selects 'countdown'):
const countdownDefaults: CountdownTemplateSettings = {
targetDate: '', // user must pick a date
segments: [
{ unit: 'days', label: 'days' },
{ unit: 'hours', label: 'hrs' },
{ unit: 'minutes', label: 'min' },
{ unit: 'seconds', label: 'sec' },
],
expiredText: '',
separator: ' : ',
}Backward compatibility
templatedefaults to'none',templateSettingsdefaults tonull- Existing creatives have no
templatefield -- engine treats missing/undefined as'none' - All existing text block features (styling, position, background, shadow, animation, customCSS) work identically regardless of template
- No migration needed
Builder UI (Frontend)
Template selector
A dropdown at the top of TextConfiguration, above ContentSection. Options: "Text" (default), "Countdown" (future: "Quiz Score", etc.).
When template !== 'none':
- ContentSection (RichTextEditor) is hidden
- Template-specific config panel is shown in its place
Countdown config panel
Replaces ContentSection when template === 'countdown':
- Target date -- date-time picker for
targetDate - Segments -- DnD-reorderable list of active segments
- Each segment: a chip/row showing unit name + inline text input for custom label
- "Add segment" button to add units not yet in the list
- X button to remove a segment
- Separator -- text input for the separator string between segments
- Expired text -- text input for what to show when countdown reaches 0
All styling (font, color, size, alignment, background, shadow, etc.) is handled by the existing text block sections -- no duplication needed.
Engine Rendering (Creative-Engine)
Text.vue changes
Text.vue checks payload.template:
undefined/'none': renders as today (richText/text via innerHTML)'countdown': renders via a countdown renderer
Countdown renderer
A local JS ticker inside Text.vue (or extracted to a composable):
- On mount: parse
targetDate, startsetInterval(tick, 1000) - Each tick: calculate remaining time, split into configured units, format with labels and separator
- Update the element's text content
- When remaining <= 0: clear interval, show
expiredText - On unmount: clear interval
No flow variables involved -- pure presentation logic. The countdown runs entirely in the browser.
Unit calculation
Units are calculated in order from largest to smallest. Each unit consumes from the remaining time:
total seconds remaining = targetDate - now
weeks = floor(remaining / 604800), remaining -= weeks * 604800
days = floor(remaining / 86400), remaining -= days * 86400
hours = floor(remaining / 3600), remaining -= hours * 3600
minutes = floor(remaining / 60), remaining -= minutes * 60
seconds = remainingOnly segments present in the segments array are calculated. If weeks is not in segments, those seconds roll into the next unit (days).
Output format
Segments are rendered in array order with the separator between them:
"12 days : 05 hrs : 30 min : 15 sec"If a segment has an empty label, just the number is shown: "12 : 05 : 30 : 15".
Composer / Backend
Data flow
Template data flows through the same pipeline as all other block properties:
- Builder saves
templateandtemplateSettingsas part of TextProperties - Composer passes them through to the built creative JSON
- Engine reads them from
payload
No special Composer handling needed -- it's just additional properties on the text block.
Future templates (out of scope, noted for context)
These inform the architecture but are NOT part of this implementation:
- Quiz Score: auto-connects to
{score}/{score.total}flow variables, shows formatted score with animations for correct/wrong answers. Config: format, colors, animation style. - Typewriter: text "types" out character by character. Config: speed, cursor style.
- Dynamic Date: resolves date/time variables without manual
{variable}syntax. Config: format, locale.
The TextTemplate union type and templateSettings union pattern accommodate these without structural changes.
Component Reuse Plan
Existing components to use directly
| Component | Location | Usage |
|---|---|---|
| InputSelect | components/common/InputSelect.vue | Template type dropdown |
| InputField | components/common/InputField.vue | Separator input, segment label inputs |
| OptionRow | components/OptionRow/OptionRow.vue | Wrap each config row (date, separator, expired text) |
| ToggleSwitch | components/common/ToggleSwitch.vue | Future: toggle options within templates |
| sectionLogic mixin | Configuration/mixins/sectionLogic.ts | CountdownSection inherits override/lock system |
| configurationLogic mixin | Configuration/mixins/configurationLogic.ts | TextConfiguration already uses this |
Patterns to follow
| Pattern | Reference file | What to copy |
|---|---|---|
| Conditional config panel | FormInputConfiguration.vue | :is="component" switching based on type |
| Section registration | Blocks/utils.ts sectionSettings | Add entry for CountdownSection |
| Native HTML5 DnD | BlockItem.vue + SortArea.vue | Segment reordering (dragstart/dragover/drop) |
| Engine ticker cleanup | ticker.ts | setInterval in mounted, clearInterval in beforeDestroy |
New components needed
- CountdownSection.vue -- config panel for countdown settings (date, segments, separator, expired text). Uses sectionLogic mixin, renders inside TextConfiguration when
template === 'countdown'.
Date picker consideration
The existing DatePicker.vue uses Vuetify's v-date-picker for date ranges (reports use case). For countdown we need a datetime picker (date + time). Options:
- Option A: Use a native
<input type="datetime-local">wrapped in InputField. Simple, works everywhere, gives both date and time in ISO format. Minimal effort. - Option B: Extend DatePicker.vue with time support. More work, more polished.
- Recommendation: Option A for MVP. A styled
datetime-localinput is perfectly fine for picking a countdown target. Can always upgrade later.
Challenges and Edge Cases
Timezone handling
The countdown target date is stored as an ISO string without timezone info. The engine resolves it using the viewer's local Date constructor, which applies their timezone automatically. This means "July 15 at midnight" counts down to midnight in the viewer's timezone -- which is correct for most use cases (movie premieres, product launches are usually localized).
If a campaign needs a fixed UTC target (e.g., a global product drop), the user can account for this manually. Future: could add a timezone selector, but not MVP.
Builder preview
The builder preview iframe runs the engine. The countdown will tick live in the preview, which is actually a nice benefit -- designers see exactly what end users will see. No special handling needed.
Segment order vs unit size
Users can reorder segments freely (e.g., put seconds before days). The unit calculation must always go largest-to-smallest to correctly consume remaining time, but the display follows the segments array order. These are independent -- calculate all units first, then render in array order.
Zero-padding
Numbers should be zero-padded for visual consistency: "05" not "5" for hours/minutes/seconds. Days and weeks should NOT be padded (showing "003 days" looks wrong). Rule: pad units smaller than days to 2 digits.
Template switching
When switching from 'countdown' back to 'none', the templateSettings data should be preserved (not cleared). This way if a user accidentally switches away and back, their config is still there. The text field is also preserved independently.
When switching from 'none' to 'countdown' for the first time, populate templateSettings with countdownDefaults.
Copy/paste blocks
When a text block with a template is copied/pasted (or duplicated), the template and templateSettings fields copy over naturally since they're just properties on the block object. No special handling needed.
Master/child creative overrides
The sectionLogic mixin handles overrides automatically. If CountdownSection uses sectionLogic, child creatives can override countdown settings (different target date, different labels) just like they override any other section. The sectionSettings entry determines which fields are part of the override group.
Expired state persistence
If a countdown has already expired when the creative loads (targetDate is in the past), it should immediately show expiredText without starting a ticker. Check on mount: if targetDate <= now, render expired state and skip setInterval entirely.
What this does NOT change
- Text block identity -- it's still a text block, same blockType, same blockName pattern
- Existing styling/layout system -- all sections work the same
- Flow system -- countdown is pure engine-side, no flow integration
- Other blocks -- no changes to any other block type
- Composer -- transparent passthrough of new properties