Skip to content

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 added

Defaults

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

  • template defaults to 'none', templateSettings defaults to null
  • Existing creatives have no template field -- 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':

  1. Target date -- date-time picker for targetDate
  2. 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
  3. Separator -- text input for the separator string between segments
  4. 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):

  1. On mount: parse targetDate, start setInterval(tick, 1000)
  2. Each tick: calculate remaining time, split into configured units, format with labels and separator
  3. Update the element's text content
  4. When remaining <= 0: clear interval, show expiredText
  5. 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 = remaining

Only 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 template and templateSettings as 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

ComponentLocationUsage
InputSelectcomponents/common/InputSelect.vueTemplate type dropdown
InputFieldcomponents/common/InputField.vueSeparator input, segment label inputs
OptionRowcomponents/OptionRow/OptionRow.vueWrap each config row (date, separator, expired text)
ToggleSwitchcomponents/common/ToggleSwitch.vueFuture: toggle options within templates
sectionLogic mixinConfiguration/mixins/sectionLogic.tsCountdownSection inherits override/lock system
configurationLogic mixinConfiguration/mixins/configurationLogic.tsTextConfiguration already uses this

Patterns to follow

PatternReference fileWhat to copy
Conditional config panelFormInputConfiguration.vue:is="component" switching based on type
Section registrationBlocks/utils.ts sectionSettingsAdd entry for CountdownSection
Native HTML5 DnDBlockItem.vue + SortArea.vueSegment reordering (dragstart/dragover/drop)
Engine ticker cleanupticker.tssetInterval in mounted, clearInterval in beforeDestroy

New components needed

  1. 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-local input 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

Internal documentation