Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6261ec7
Add isometric drawing functionality for homepage
KristopherLeads Nov 11, 2026
27aa9e2
Add homepage wireframe scenes with IsoScene
KristopherLeads Nov 11, 2026
5a92579
Add IsoArt component for isometric wireframe scenes
KristopherLeads Nov 11, 2026
fd37478
Homepage refresh
KristopherLeads Nov 11, 2026
38a8e20
Refactor plant and site functions for clarity
KristopherLeads Nov 11, 2026
297e917
Revise content for improved clarity and focus
KristopherLeads Nov 11, 2026
787bded
Refactor social proof section in index.vue
KristopherLeads Nov 11, 2026
b45435d
Update index.vue
KristopherLeads Nov 11, 2026
742c1ef
Add foundation scene and update scene names
KristopherLeads Nov 11, 2026
0335b7e
Update title and references to Foundation Partnership
KristopherLeads Nov 11, 2026
f06fa1d
Rename robot cell comment to collection station
KristopherLeads Nov 11, 2026
b5bf851
Update styles and colors in index.vue
KristopherLeads Nov 11, 2026
a59f102
Enhance TestimonialCarousel with dark mode support
KristopherLeads Nov 11, 2026
332ffbd
Homepage SEO: brand-first title, brand schema, updated site subtitle
sumitshinde-84 Nov 12, 2026
cc27728
Homepage: replace role cards with an outcomes section
sumitshinde-84 Nov 12, 2026
94e68c2
Keep the brand schema's original profile links
sumitshinde-84 Nov 12, 2026
1989409
Homepage: bring back the FAQ, updated for the new story
sumitshinde-84 Nov 12, 2026
3962b5e
Homepage FAQ: three more questions, and links to the pages that go de…
sumitshinde-84 Nov 12, 2026
4181a11
Homepage FAQ: match 'What is FlowFuse?' to the hero's wording
sumitshinde-84 Nov 12, 2026
74b17bd
Homepage FAQ: answer the ownership question with what stays in the cu…
sumitshinde-84 Nov 12, 2026
d78a27a
Homepage FAQ: drop the system integrator question
sumitshinde-84 Nov 12, 2026
c3568d1
Homepage: one dark section, softer page rhythm
sumitshinde-84 Nov 12, 2026
1ffc6d6
Homepage: closing call to action card in the hero's colours
sumitshinde-84 Nov 12, 2026
f10a115
Merge pull request #6027 from FlowFuse/sumit/homepage-seo
KristopherLeads Nov 12, 2026
77611b0
Merge pull request #6028 from FlowFuse/sumit/homepage-outcomes-section
KristopherLeads Nov 12, 2026
14f7c7b
Create Hero.vue
KristopherLeads Nov 12, 2026
accf3a5
Enhance IsoArt component with animation and active states
KristopherLeads Nov 12, 2026
1a9f77d
Add group tagging and rendering for shapes
KristopherLeads Nov 12, 2026
442a973
Refactor scene types and clean up functions
KristopherLeads Nov 12, 2026
3954032
Update homepage component from HomeIsoArt to HomeHero
KristopherLeads Nov 12, 2026
670ce91
Update home-iso-scenes.ts
KristopherLeads Nov 12, 2026
7efdf13
Add new class for stroke color in IsoArt component
KristopherLeads Nov 12, 2026
2f87c01
Adjust animation timing and refactor hover behavior
KristopherLeads Nov 12, 2026
a056ad9
Update home-iso.ts
KristopherLeads Nov 12, 2026
035e503
Refactor delivery function for truck visualization
KristopherLeads Nov 12, 2026
4470197
Update index.vue
KristopherLeads Nov 12, 2026
39f570c
Update index.vue
KristopherLeads Nov 12, 2026
912dd46
Refactor IsoArt component for improved clarity
KristopherLeads Nov 12, 2026
f002465
Add Testimonials component for customer quotes
KristopherLeads Nov 12, 2026
756faa7
Update index.vue
KristopherLeads Nov 12, 2026
7fb36c4
Update IsoArt.vue
KristopherLeads Nov 12, 2026
c6475f1
Homepage hero: lead sub-head with existing equipment
sumitshinde-84 Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion nuxt/app.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
<script setup lang="ts">
import site from './data/site.json'

// alternateName ties the brand's other spellings and its former name to this entity:
// "flow fuse" and "flowforge" are both among the homepage's top search queries.
const { legalName, alternateName } = site.organization

useSchemaOrg([
defineOrganization({
name: 'FlowFuse',
legalName,
alternateName,
url: 'https://flowfuse.com',
logo: 'https://flowfuse.com/handbook/images/logos/ff-logo--square--dark.png',
sameAs: [
Expand All @@ -12,7 +20,7 @@ useSchemaOrg([
'https://www.youtube.com/channel/UCbBzP8NZbv3WDtlt4UouA-g',
],
}),
defineWebSite({ name: 'FlowFuse' }),
defineWebSite({ name: 'FlowFuse', alternateName }),
])
</script>

Expand Down
32 changes: 26 additions & 6 deletions nuxt/components/TestimonialCarousel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@
// unmounts - the .njk's setInterval had no teardown because 11ty pages never unmount.
import testimonials from '../data/testimonials.json'

withDefaults(defineProps<{
// `dark` sets the card, text and dots for a dark section background (the homepage).
// Off by default, so the product page and ABM pages keep the light card.
const props = withDefaults(defineProps<{
hideReadMore?: boolean
browseAllUrl?: string
browseAllText?: string
dark?: boolean
}>(), {
hideReadMore: false,
dark: false,
browseAllUrl: undefined,
browseAllText: 'Browse all customer stories',
})
Expand Down Expand Up @@ -44,7 +48,11 @@ onUnmounted(() => clearInterval(timer))

<template>
<div>
<div class="flex flex-col flex-wrap content-center justify-center p-6 sm:max-w-screen-lg max-w-5xl mx-auto border-2 border-indigo-200 rounded-xl bg-white hover:drop-shadow-lg hover:border-blue-600 hover:border-2 transition ease-in-out duration-300">
<div class="flex flex-col flex-wrap content-center justify-center p-6 sm:max-w-screen-lg max-w-5xl mx-auto border-2 rounded-xl hover:border-2 transition ease-in-out duration-300"
:class="props.dark
? 'border-white/10 bg-white/5 text-indigo-100 hover:border-indigo-300'
: 'border-indigo-200 bg-white hover:drop-shadow-lg hover:border-blue-600'"
>
<NuxtLink
v-for="(t, i) in testimonials"
:id="t.id"
Expand All @@ -62,8 +70,8 @@ onUnmounted(() => clearInterval(timer))
<span v-if="!hideReadMore" class="text-right pb-5 flex gap-1 hover:underline">
Read the full story <UIcon name="i-heroicons-arrow-long-right" class="w-5 h-5" />
</span>
<p class="font-normal italic text-2xl">"{{ t.quote }}"</p>
<p class="text-lg mt-4 flex flex-row gap-3 text-right items-center self-end mb-0 mr-0">
<p class="font-normal italic text-2xl" :class="{ 'text-white': props.dark }">"{{ t.quote }}"</p>
<p class="text-lg mt-4 flex flex-row gap-3 text-right items-center self-end mb-0 mr-0" :class="{ 'text-indigo-200': props.dark }">
<span>{{ t.author }}, <span class="font-medium">{{ t.company }}</span></span>
<span class="w-16 h-16 min-w-16 rounded-full mx-1 bg-black p-1">
<img :src="asset(t.companyLogo)" :alt="`${t.company} logo`" width="56" loading="lazy" class="w-full h-auto">
Expand All @@ -83,15 +91,27 @@ onUnmounted(() => clearInterval(timer))
v-for="(t, i) in testimonials"
:key="t.id"
class="testimonial-button align-baseline max-md:p-2"
:class="{ active: i === current }"
:class="{ active: i === current, 'testimonial-button-dark': props.dark }"
:aria-label="`Show testimonial from ${t.author}, ${t.company}`"
@click="show(i)"
><span /></button>
</div>
<NuxtLink v-if="browseAllUrl" :to="browseAllUrl" class="flex items-center gap-1.5 text-blue-600 hover:underline">
<NuxtLink v-if="browseAllUrl" :to="browseAllUrl" class="flex items-center gap-1.5 hover:underline" :class="props.dark ? 'text-indigo-200' : 'text-blue-600'">
{{ browseAllText }}
<UIcon name="i-heroicons-arrow-long-right" class="w-5 h-5" />
</NuxtLink>
</div>
</div>
</template>

<style scoped>
/* Dots on a dark background: dim when idle, bright when showing */
.testimonial-button-dark span {
background-color: rgb(255 255 255 / 0.15);
border-color: rgb(255 255 255 / 0.25);
}
.testimonial-button-dark.active span {
background-color: #ff7b6b;
border-color: #ff7b6b;
}
</style>
235 changes: 235 additions & 0 deletions nuxt/components/home/Hero.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<script setup lang="ts">
// The homepage hero picture: the wireframe plant draws itself in, then steps through
// the six FlowFuse solutions. Each step lights one part of the plant in orange (with
// its data path to the hub), and a callout above it shows the kind of readout that
// solution gives: OEE on a machining centre, throughput on a packaging line, a
// vision reject, a vibration alert, a plunger cycle, a remote battery command.
//
// The readouts are illustrative: they show what each solution surfaces, not a
// customer result, and carry no customer names.
//
// The plant renders fully drawn with the first step already lit (server-side too), so
// nothing waits on load.
//
// Timing has one clock: the active segment's fill animation. When it ends, the next
// step starts (after the last, the first), so the bar and the picture never drift.
// Hovering pauses that animation, which pauses everything. Picking a segment jumps to
// it and carries on from there. Under prefers-reduced-motion the fill doesn't
// animate, so nothing advances on its own; the first step shows still, and the step
// buttons still work.
import { renderScene, HERO_GROUPS, type HeroGroup } from '../../lib/home-iso-scenes'

interface Step {
group: HeroGroup
solution: string
value: string
detail: string
kind: 'ring' | 'bars' | 'alert' | 'spark-up' | 'spark-wave' | 'command'
}

const STEPS: Step[] = [
{ group: 'cnc', solution: 'CNC performance', value: 'OEE 78%', detail: 'Machining centre 1, this shift', kind: 'ring' },
{ group: 'packaging', solution: 'Packaging performance', value: '142 packs/min', detail: 'Micro-stop on Line 2, 3 min', kind: 'bars' },
{ group: 'inspection', solution: 'Visual inspection response', value: 'Reject flagged', detail: 'Lane 2 held, QA notified', kind: 'alert' },
{ group: 'vibration', solution: 'Vibration condition monitoring', value: '6.2 mm/s', detail: 'Pump bearing above alert level', kind: 'spark-up' },
{ group: 'well', solution: 'Well & plunger-lift optimization', value: 'Arrival 42 s', detail: 'Plunger cycle tuned for this pad', kind: 'spark-wave' },
{ group: 'energy', solution: 'Remote energy service', value: 'Balance charge sent', detail: 'Battery string 3, approved remotely', kind: 'command' },
]

const STEP_MS = 3600

const boxes = renderScene('hero').groups
const current = ref(0)
const step = computed(() => STEPS[current.value])

// Where the callout points: the top centre of the highlighted part, in % of the picture.
const anchor = computed(() => {
const b = boxes[step.value.group]
return { left: (b.x + b.w / 2) * 100, top: b.y * 100 }
})

const hovering = ref(false)

function advance () {
current.value = (current.value + 1) % STEPS.length
}

// Paths for the small line charts in the callouts (viewBox 0 0 120 36)
const SPARK_UP = 'M2,30 L14,29 L26,30 L38,28 L50,29 L62,27 L74,25 L86,20 L98,14 L110,8 L118,5'
const SPARK_WAVE = 'M2,24 C10,8 18,8 26,24 C34,30 38,30 46,24 C54,8 62,8 70,24 C78,30 82,30 90,24 C98,8 106,8 114,24'
const BARS = [22, 25, 24, 26, 23, 8, 6, 22, 25, 24]
</script>

<template>
<div class="relative" :class="{ paused: hovering }" @mouseenter="hovering = true" @mouseleave="hovering = false">
<div class="relative" aria-hidden="true">
<HomeIsoArt scene="hero" variant="dark" animated :active="step.group" class="w-full" />

<!-- Callout: a leader line rising from the highlighted part, and a card on top -->
<Transition name="callout" mode="out-in">
<div :key="step.group" class="callout-wrap" :style="{ left: `${anchor.left}%`, top: `${anchor.top}%` }">
<span class="leader" />
<div class="callout" :style="{ '--nudge': anchor.left < 22 ? '30%' : anchor.left > 78 ? '-30%' : '0%' }">
<p class="m-0 text-[11px] font-semibold uppercase tracking-wide text-[#ff9d90] whitespace-nowrap">{{ step.solution }}</p>
<div class="flex items-center gap-3 mt-2">
<!-- ring: OEE gauge -->
<svg v-if="step.kind === 'ring'" viewBox="0 0 36 36" class="w-10 h-10 shrink-0">
<circle cx="18" cy="18" r="15" pathLength="100" class="viz-track" />
<circle cx="18" cy="18" r="15" pathLength="100" class="viz-ring" transform="rotate(-90 18 18)" />
</svg>
<!-- alert: pulsing warning -->
<svg v-else-if="step.kind === 'alert'" viewBox="0 0 36 36" class="w-10 h-10 shrink-0">
<path d="M18 4 L33 31 H3 Z" pathLength="1" class="viz-draw viz-warn" />
<path d="M18 14 V22 M18 26 V26.5" class="viz-warn-mark" />
</svg>
<!-- command: approved and sent -->
<svg v-else-if="step.kind === 'command'" viewBox="0 0 36 36" class="w-10 h-10 shrink-0">
<circle cx="18" cy="18" r="15" pathLength="1" class="viz-draw viz-ok" />
<path d="M11 18.5 L16 23.5 L25.5 13" pathLength="1" class="viz-draw viz-ok viz-late" />
</svg>
<div class="min-w-0">
<p class="m-0 text-white text-lg font-medium leading-6 whitespace-nowrap">{{ step.value }}</p>
<p class="m-0 text-indigo-200 text-xs leading-4">{{ step.detail }}</p>
</div>
</div>
<!-- charts sit under the value -->
<svg v-if="step.kind === 'spark-up' || step.kind === 'spark-wave' || step.kind === 'bars'" viewBox="0 0 120 36" class="w-full h-9 mt-3">
<template v-if="step.kind === 'spark-up'">
<path d="M2,14 H118" class="viz-limit" />
<path :d="SPARK_UP" pathLength="1" class="viz-draw viz-line-hot" />
</template>
<path v-else-if="step.kind === 'spark-wave'" :d="SPARK_WAVE" pathLength="1" class="viz-draw viz-line" />
<template v-else>
<rect
v-for="(h, i) in BARS"
:key="i"
:x="4 + i * 11.6"
:y="34 - h"
width="7"
:height="h"
class="viz-bar"
:class="{ 'viz-bar-hot': h < 10 }"
:style="{ animationDelay: `${i * 45}ms` }"
/>
</template>
</svg>
</div>
</div>
</Transition>
</div>

<!-- Readout for small screens, where a floating card would cover the plant -->
<div class="sm:hidden mt-2 mx-auto max-w-xs callout callout-static" aria-hidden="true">
<p class="m-0 text-[11px] font-semibold uppercase tracking-wider text-[#ff9d90]">{{ step.solution }}</p>
<p class="m-0 mt-1 text-white text-base font-medium">{{ step.value }}</p>
<p class="m-0 text-indigo-200 text-xs">{{ step.detail }}</p>
</div>

<!-- Step control: one segment per solution; the active one fills over its turn -->
<div class="flex gap-1.5 mt-4 max-w-md mx-auto" role="group" aria-label="Solutions shown in the picture">
<button
v-for="(s, i) in STEPS"
:key="s.group"
type="button"
class="seg"
:class="{ 'seg-on': i === current, 'seg-done': i < current }"
:aria-label="`Show ${s.solution}`"
:aria-pressed="i === current"
:title="s.solution"
@click="current = i"
>
<span
:key="`${current}-${i}`"
class="seg-fill"
:style="{ animationDuration: `${STEP_MS}ms` }"
@animationend="i === current && advance()"
/>
</button>
</div>
</div>
</template>

<style scoped>
.callout-wrap {
position: absolute;
transform: translate(-50%, -100%);
display: flex;
flex-direction: column-reverse;
align-items: center;
pointer-events: none;
}
.leader {
width: 1.5px;
height: 34px;
background: linear-gradient(to top, #ff7b6b, rgba(255, 123, 107, 0.15));
transform-origin: bottom;
animation: grow 0.45s ease-out both;
}
.callout {
width: 264px;
padding: 0.75rem 0.875rem;
border-radius: 0.75rem;
background: rgba(20, 27, 64, 0.92);
border: 1px solid rgba(255, 123, 107, 0.55);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 4px rgba(255, 123, 107, 0.08);
backdrop-filter: blur(4px);
transform: translateX(var(--nudge, 0%));
}
.callout-static { width: auto; transform: none; }
/* Phones: the floating card would cover the plant, so the readout sits under it instead */
@media (max-width: 639px) { .callout-wrap { display: none; } }

.callout-enter-active { transition: opacity 0.35s ease 0.35s, transform 0.35s ease 0.35s; }
.callout-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.callout-enter-from { opacity: 0; transform: translate(-50%, calc(-100% + 8px)); }
.callout-leave-to { opacity: 0; transform: translate(-50%, calc(-100% - 6px)); }

/* Readout drawings: thin lines that draw themselves, like the plant */
.viz-draw { fill: none; stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw 0.9s ease-out 0.55s forwards; }
.viz-late { animation-delay: 1.1s; }
.viz-track { fill: none; stroke: #34406e; stroke-width: 3; }
.viz-ring {
fill: none; stroke: #ff7b6b; stroke-width: 3; stroke-linecap: round;
stroke-dasharray: 0 100;
animation: ring 1.1s ease-out 0.55s forwards;
}
.viz-warn { stroke: #ff7b6b; stroke-width: 2; stroke-linejoin: round; }
.viz-warn-mark { stroke: #ff7b6b; stroke-width: 2.4; stroke-linecap: round; animation: blink 1s ease-in-out 1.3s infinite; }
.viz-ok { stroke: #6ee7b7; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.viz-line { stroke: #a5b4fc; stroke-width: 1.6; }
.viz-line-hot { stroke: #ff7b6b; stroke-width: 1.8; }
.viz-limit { stroke: #ff7b6b; stroke-width: 1; stroke-dasharray: 3 3; opacity: 0.5; }
.viz-bar { fill: #3c4a8a; transform-origin: bottom; transform-box: fill-box; animation: rise 0.5s ease-out both; }
.viz-bar-hot { fill: #ff7b6b; }

.seg {
position: relative;
flex: 1;
height: 4px;
border-radius: 2px;
background: rgba(165, 180, 252, 0.18);
overflow: hidden;
padding: 0;
border: 0;
cursor: pointer;
}
.seg::before { content: ''; position: absolute; inset: -8px 0; } /* bigger hit area */
.seg-done { background: rgba(165, 180, 252, 0.45); }
.seg-fill { position: absolute; inset: 0; width: 0; background: #ff7b6b; }
.seg-on .seg-fill { animation: fill linear forwards; }
.paused .seg-on .seg-fill { animation-play-state: paused; }

@keyframes draw { to { stroke-dashoffset: 0; } }
@keyframes ring { to { stroke-dasharray: 78 100; } }
@keyframes grow { from { transform: scaleY(0); } }
@keyframes rise { from { transform: scaleY(0); } }
@keyframes blink { 50% { opacity: 0.25; } }
@keyframes fill { to { width: 100%; } }

@media (prefers-reduced-motion: reduce) {
.leader, .viz-draw, .viz-ring, .viz-bar, .viz-warn-mark { animation: none; stroke-dashoffset: 0; }
.viz-ring { stroke-dasharray: 78 100; }
.seg-on .seg-fill { animation: none; width: 100%; }
.callout-enter-active, .callout-leave-active { transition: none; }
}
</style>
Loading
Loading