Node-RED Editor show html tag as red in Ui Template

Curious question as the red tag does not seem to blow up the ui template. Here is the code:

<template v-if="state?.config?.alertThreshold">
            <div v-if="state.currentStorm.distance !== null && state.currentStorm.distance <= state.config.alertThreshold"
                class="danger-banner text-center py-1 font-weight-bold text-white" style="background-color: #d32f2f;">
                ⚠️ DANGER: {{ state.currentStorm.distance }}{{ state.config.unit }}
            </div>
                
            <div v-else-if="state.currentStorm.distance !== null && state.currentStorm.distance <= state.config.searchRadius"
                class="monitoring-banner text-center py-1 font-weight-bold text-white" style="background-color: #2e7d32;">
                🪟 MONITORING: {{ state.currentStorm.distance }}{{ state.config.unit }}
            </div>   
        </template>

As you can see in the screen shot below it looks like the editor does not like the code.

I am running 4.1.6 Node-RED and Node.js v24.14.4

Is that your entire template? Where is the state object defined?

Please see the template. Appreciate your input.

<template>
    <v-card v-if="state?.currentStorm" class="mx-auto lightning-card bg-grey-darken-4"
        style="max-width: 300px; min-width:300px">
        <v-card v-if="state?.currentStorm" class="mx-auto lightning-card bg-grey-darken-4" style="max-width: 300px; min-width:300px">
        <div class="d-flex justify-space-between align-center  header-bg" style="position: relative; z-index: 10;">
        
        <div class="d-flex align-center">
            <v-icon icon="mdi-flash" color="amber" size="large" class="mr-1"
                :class="{ 'pulsing-icon': (state?.currentStorm?.frequency > 0) }">
            </v-icon>
            <div class="d-flex flex-column align-start">
                <span class="text-subtitle-1 font-weight-bold text-brown-lighten-4" style="line-height: 1.2rem;">Lightning</span>
                <span class="text-grey-darken-1" style="font-size: 0.55rem;">Blitzortung.org</span>
            </div>
        </div>

        <v-chip size="x-small" color="orange-darken-4" variant="flat" style="font-weight: bold;">
            Freq: {{ state?.currentStorm?.frequency || 0 }}/min
        </v-chip>

        <div class="d-flex align-center">
            <v-btn icon variant="plain" size="small" @click="toggleMute" class="ma-0">
                <v-icon :icon="state?.config?.mute ? 'mdi-volume-off' : 'mdi-volume-high'"
                        :color="state?.config?.mute ? 'red' : 'green'" size="small"></v-icon>
            </v-btn>

            <v-btn icon variant="plain" size="small" @click.stop="openSettings" class="ma-0">
                <v-icon icon="mdi-cog" color="grey-lighten-1" size="small"></v-icon>
            </v-btn>
        </div>
        </div>
        <template v-if="state?.config?.alertThreshold">
            <div v-if="state.currentStorm.distance !== null && state.currentStorm.distance <= state.config.alertThreshold"
                class="danger-banner text-center py-1 font-weight-bold text-white" style="background-color: #d32f2f;">
                ⚠️ DANGER: {{ state.currentStorm.distance }}{{ state.config.unit }}
            </div>
                
            <div v-else-if="state.currentStorm.distance !== null && state.currentStorm.distance <= state.config.searchRadius"
                class="monitoring-banner text-center py-1 font-weight-bold text-white" style="background-color: #2e7d32;">
                🪟 MONITORING: {{ state.currentStorm.distance }}{{ state.config.unit }}
            </div>   
        </template>

        <v-card-text v-if="state?.history && state.history.length > 0" class="pa-4">
            <div class="d-flex justify-space-between align-end mb-4">
                <div class="d-flex align-baseline">
                    <div class="display-value text-brown-lighten-4">{{ state.currentStorm?.distance }}</div>
                    <div class="unit-text ml-1">{{ state.config?.unit }}</div>
                </div>
                <div class="text-right d-flex flex-column align-center" style="min-width: 100px;">
                    <v-icon icon="mdi-navigation"
                        :style="{ transform: `rotate(${state.currentStorm?.bearing || 0}deg)`, transition: 'transform 0.5s' }"
                        size="34" color="brown-lighten-4"></v-icon>
                    <div :class="['trend-text font-weight-black mt-1 text-capitalize', trendColor ]">
                        {{ state.currentStorm?.trend }}
                    </div>
                    <div class="unit-text ml-1">
                        {{ getDir(state.currentStorm?.bearing) }}
                    </div>
                </div>
            </div>

            <v-sparkline :model-value="sparklineValues" :gradient="['#4caf50', '#ffeb3b', '#f44336']" smooth="10"
                line-width="3" height="50" fill padding="4"></v-sparkline>

            <v-expansion-panels v-model="panel" variant="accordion" class="mt-4 custom-panels">
                <v-expansion-panel class="bg-transparent" value="strikes">
                    <v-expansion-panel-title class="pa-0 text-capitalize text-caption font-weight-bold white--text">
                        Recent Strikes
                    </v-expansion-panel-title>
                    <v-expansion-panel-text>
                        <div v-for="(strike, i) in recentStrikes" :key="i"
                            class="d-flex justify-space-between py-1 text-caption border-bottom-dim">
                            <span class="text-grey-lighten-1">{{ formatTime(strike.time) }}</span>
                            <span class="font-weight-bold text-orange-lighten-2">{{ strike.dist }}{{ state?.config?.unit }}</span>
                            <span class="white--text">{{ getDir(strike.bearing) }}</span>
                        </div>
                    </v-expansion-panel-text>
                </v-expansion-panel>
            </v-expansion-panels>
        </v-card-text>

        <v-card-text v-else class="pa-10 text-center text-grey-darken-1">
            <v-icon icon="mdi-shield-check-outline" size="48" class="mb-2"></v-icon>
            <div class="text-caption font-weight-bold text-uppercase">Quiet</div>
        </v-card-text>

        <div class="px-5 py-1 footer-bg border-top-dim rounded-b-lg">
            <div class="d-flex justify-space-between align-center text-caption font-weight-bold">
                <span class="text-orange" style="font-size: 0.7rem;">Area: {{ state?.config?.searchRadius }}{{ state?.config?.unit }}</span>
                <div class="text-center">
                    <div class="text-green-accent-2 d-flex align-center justify-center" style="font-size: 0.65rem;">
                        <v-icon icon="mdi-pulse" size="10" class="mr-1 pulse-icon"></v-icon>
                        {{ lastUpdated }}
                    </div>
                </div>
                <span class="text-orange" style="font-size: 0.7rem;">Alert: {{ state?.config?.alertThreshold }}{{ state?.config?.unit }}</span>
            </div>
        </div>

        <v-dialog v-model="showModal" max-width="325px" persistent>
            <v-card class="bg-grey-darken-4 text-white">
                <v-card-title class="text-h6 font-weight-bold">
                    <v-icon icon="mdi-cog" color="grey-lighten-1" size="small"></v-icon> Settings
                </v-card-title>
                <v-card-text>
                    <div class="text-caption text-grey-lighten-1 mb-1">📏 Unit</div>
                    <v-btn-toggle v-model="localConfig.unit" mandatory color="orange" density="compact" class="mb-4">
                        <v-btn value="mi" size="small">Mi</v-btn>
                        <v-btn value="km" size="small">Km</v-btn>
                    </v-btn-toggle>

                    <div class="pa-3 bg-grey-darken-4 rounded mb-4">
                        <div class="text-overline mb-2">🏠 Location</div>
                        <v-row dense>
                            <v-col cols="6">
                                <v-text-field v-model.number="localConfig.homeLocation.lat" label="Lat" density="compact" variant="outlined"
                                    hide-details type="number" step="0.0001"></v-text-field>
                            </v-col>
                            <v-col cols="6">
                                <v-text-field v-model.number="localConfig.homeLocation.lon" label="Lon" density="compact" variant="outlined"
                                    hide-details type="number" step="0.0001"></v-text-field>
                            </v-col>
                        </v-row>
                    </div>

                    <div class="pa-3 bg-grey-darken-4 rounded mb-3">
                        <div class="text-overline mb-1 text-capitalize">🕑 Reset Time ({{ localConfig.resetTime }})</div>
                        <div class="d-flex align-center ga-2">
                            <v-btn-toggle v-model="localConfig.resetTime" color="primary" mandatory density="compact"
                                class="flex-grow-1">
                                <v-btn :value="5" size="x-small">5</v-btn>
                                <v-btn :value="10" size="x-small">10</v-btn>
                                <v-btn :value="30" size="x-small">30</v-btn>
                                <v-btn :value="60" size="x-small">60</v-btn>
                            </v-btn-toggle>
                        </div>
                    </div>

                    <div class="pa-3 bg-grey-darken-4 rounded mb-3">
                        <div class="text-overline mb-1 text-capitalize">🌎 Search Radius ({{ localConfig.unit }})</div>
                        <v-text-field v-model.number="localConfig.searchRadius" type="number" density="compact" variant="solo-filled"
                            hide-details @input="localConfig.searchRadius = Math.min(localConfig.searchRadius, 12450)"
                            :rules="[v => v <= 12450 || 'Max Earth radius is 12450mi']"></v-text-field>
                    </div>
                    
                    <div class="pa-3 bg-grey-darken-4 rounded mb-3">
                        <div class="text-overline mb-1 text-capitalize">⚡ Alert Threshold ({{ localConfig.unit }})</div>
                        <v-text-field v-model.number="localConfig.alertThreshold" type="number" density="compact" variant="solo-filled"
                            hide-details @input="localConfig.alertThreshold = Math.min(localConfig.alertThreshold, 12450)"
                            :rules="[v => v <= 12450 || 'Max Earth radius is 12450mi']"></v-text-field>
                    </div>
                    
                </v-card-text>
                <v-card-actions>
                    <v-spacer></v-spacer>
                    <v-btn variant="text" @click="showModal = false">Cancel</v-btn>
                    <v-btn color="orange" variant="flat" @click="saveSettings">Save</v-btn>
                </v-card-actions>
            </v-card>
        </v-dialog>
    </v-card>
</template>

<script>    
 export default {
    data() {
        return {
            state: {
                history: [],
                config: { searchRadius: 50, alertThreshold: 25, resetTime: 30, unit: 'mi', mute: false },
                currentStorm: { distance: 0, bearing: 0, trend: 'Stationary', status: 'Clear' }
            },
            showModal: false,
            panel: [],
            localConfig: { searchRadius: 50, alertThreshold: 25, unit: 'mi' },
            expiryTimer: null
        };
    },
    watch: {
        'localConfig.unit': function(newUnit, oldUnit) {
            if (!oldUnit || newUnit === oldUnit) return;
            const factor = 1.60934;
            if (newUnit === 'km') {
                this.localConfig.alertThreshold = Math.round(this.localConfig.alertThreshold * factor);
                this.localConfig.searchRadius = Math.round(this.localConfig.searchRadius * factor);
            } else {
                this.localConfig.alertThreshold = Math.round(this.localConfig.alertThreshold / factor);
                this.localConfig.searchRadius = Math.round(this.localConfig.searchRadius / factor);
            }
        }
    },
    mounted() {
    this.$socket.on('msg-input:' + this.id, (msg) => {

        if (msg?.payload) {
            this.state = JSON.parse(JSON.stringify(msg.payload));
        }
    });
    this.startExpiryTimer();
    },
    unmounted() {
        if (this.expiryTimer) clearInterval(this.expiryTimer);
    },
    methods: {
        startExpiryTimer() {
            this.expiryTimer = setInterval(() => {
                if (this.state?.history?.length > 0) {
                    const thirtyMinsAgo = Date.now() - (30 * 60 * 1000);
                    this.state.history = this.state.history.filter(s => s && s.time > thirtyMinsAgo);
                }
            }, 60000);
        },
        toggleMute() {
            if (!this.state?.config) return;
            this.state.config.mute = !this.state.config.mute;
            this.send({ 
                topic: "update_config", 
                payload: { ...this.state.config } 
            });
        },
        openSettings() {
            const baseConfig = this.state?.config || { searchRadius: 50, alertThreshold: 25, unit: 'mi', mute: false };
            this.localConfig = JSON.parse(JSON.stringify(baseConfig));
            this.showModal = true;
        },
        saveSettings() {
            this.state.config = { ...this.state.config, ...this.localConfig };
            this.send({
                topic: "update_config",
                payload: { ...this.state.config }
            });
            this.showModal = false;
        },
        formatTime(ts) {
            return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
        },
        getDir(b) {
            const sectors = ["N", "NE", "E", "SE", "S", "SW", "W", "NW", "N"];
            return sectors[Math.round((b || 0) / 45) % 8];
        },
        getTimeAgo(timestamp) {
            if (!timestamp) return 'Never';
            const seconds = Math.floor((new Date() - new Date(timestamp)) / 1000);
            if (seconds < 60) return "Just now";
            const minutes = Math.floor(seconds / 60);
            return minutes < 60 ? minutes + "m ago" : Math.floor(minutes / 60) + "h ago";
        }
    },
    computed: {
        sparklineValues() { 
            if (!this.state?.history?.length) return [0, 0];
            return this.state.history.map(h => 100 - (h.dist || 0)); 
        },
        recentStrikes() { 
            if (!this.state?.history?.length) return [];
            const thirtyMinsAgo = Date.now() - (30 * 60 * 1000);
            return [...this.state.history]
                .filter(s => s && s.time > thirtyMinsAgo)
                .reverse()
                .slice(0, 5); 
        },
        lastUpdated() {
            if (!this.state?.history?.length) return 'No Data';
            const latest = this.state.history[this.state.history.length - 1];
            return latest?.time ? this.getTimeAgo(latest.time) : 'No Data';
        },
        trendColor() {
            const trend = this.state?.currentStorm?.trend;
            if (trend === 'Approaching') return 'text-red-lighten-2';
            if (trend === 'Receding') return 'text-green-accent-2';
            return 'text-blue-lighten-3';
        }
    }
}
</script>

<style scoped>
.display-container {display: flex;align-items: baseline;}
.lightning-card { border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); }
.header-bg { background: rgba(0,0,0,0.3); }
.footer-bg { background: rgba(0,0,0,0.5); }
.danger-banner { background: #d32f2f; letter-spacing: 1px; font-size: 0.75rem; }
.border-top-dim { border-top: 1px solid rgba(255,255,255,0.1); }
.border-bottom-dim { border-bottom: 1px solid rgba(255,255,255,0.05); }
.danger-banner { background: #d32f2f; letter-spacing: 0.5px; font-size: 0.75rem; text-transform: uppercase; }
.display-value { font-size: 4.5rem; font-weight: 900; line-height: 1; letter-spacing: -2px; }
.unit-text { font-size: 1.2rem; color: rgba(255,255,255,0.4); margin-left: 2px; }
.trend-text { font-size: 1.1rem; line-height: 1.2; text-transform: uppercase; margin-top: 2px; }
.custom-panels .v-expansion-panel-title { min-height: 32px !important; }
.v-expansion-panel-text__wrapper { padding: 4px 0 !important; }
.pulse-icon {animation: pulse-animation 2s infinite;}
@keyframes pulse-animation {0% { opacity: 1; }50% { opacity: 0.3; }100% { opacity: 1; }}
@keyframes pulse-lightning {
  0% { transform: scale(1); filter: drop-shadow(0 0 0px #FFD54F); }
  50% { transform: scale(1.2); filter: drop-shadow(0 0 8px #FFD54F); }
  100% { transform: scale(1); filter: drop-shadow(0 0 0px #FFD54F); }
}
.pulsing-icon {animation: pulse-lightning 1.5s infinite ease-in-out;}
</style>

I posted the entire ui but state is defined in the mount component

 mounted() {
    this.$socket.on('msg-input:' + this.id, (msg) => {

        if (msg?.payload) {
            this.state = JSON.parse(JSON.stringify(msg.payload));
        }
    });
    this.startExpiryTimer();
    },

and is declared in the data component

data() {
        return {
            state: {
                history: [],
                config: { searchRadius: 50, alertThreshold: 25, resetTime: 30, unit: 'mi', mute: false },
                currentStorm: { distance: 0, bearing: 0, trend: 'Stationary', status: 'Clear' }
            },

Errm. Not sure what the problem is. I have loaded your code and it all works fine (and no errors in the editor)

Interesting. What version editor?

Node-RED v4.18 & v5 Beta 4. I include a picture so you can confirm whether it is as expexted

Yes it is rendering. I had to fix is so it would not default to Danger banner. Here is what you get with no strikes

Here is real strike data. I had to open the radius way up..

and I list the 5 latest strikes.

I was curious as to what was driving the editor to flag the html tag.

Thanks

I don't see what it is in your screenshot that makes you think the editor is complaining.