This commit is contained in:
Lars Nolden
2026-08-13 18:47:55 +02:00
parent e881799ff1
commit 3dd74e53a0
30 changed files with 1070 additions and 186 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,56 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
type BenefitGridItem = {
icon: React.ReactNode
title: React.ReactNode
text: React.ReactNode
}
/**
* Benefit cards ringing a product render: three across the top row, two
* flanking the render below. Built for five items — the render claims the
* bottom row's middle cell from `lg`, and grid auto-placement then pushes the
* fifth card past it into the last column, so no card needs explicit coords.
*
* The middle cell is handed to the caller as a positioned, never-clipped
* containing block, so the visual can fill it or lap out of it as its own
* matte requires — sizing it is the caller's job. Below `lg` the placement is
* dropped and the visual simply trails the cards.
*/
function BenefitGrid({
items,
visual,
className,
}: {
items: BenefitGridItem[]
visual: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
"grid gap-5 sm:grid-cols-2 lg:grid-cols-[1fr_1.2fr_1fr]",
className
)}
>
{items.map((item, i) => (
<Card key={i} className="gap-0 p-6 sm:p-7">
<span className="text-brand-700 [&_svg]:size-10">{item.icon}</span>
<h3 className="mt-6 text-base leading-snug font-bold text-balance text-navy">
{item.title}
</h3>
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground">
{item.text}
</p>
</Card>
))}
<div className="relative lg:col-start-2 lg:row-start-2">{visual}</div>
</div>
)
}
export { BenefitGrid }
export type { BenefitGridItem }
@@ -0,0 +1,207 @@
import * as React from "react"
import { CircleCheck } from "lucide-react"
import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type ComparisonColumn = {
title: React.ReactNode
/** lifts the column onto a tinted panel and fills in its checks */
highlight?: boolean
/** check beside every value in this column, cell overrides win (default true) */
check?: boolean
}
/** a cell that opts out of — or into — the check its column prescribes */
type ComparisonCell = {
value?: React.ReactNode
check?: boolean
}
type ComparisonRow = {
label: React.ReactNode
/** one entry per column, in column order */
cells: (React.ReactNode | ComparisonCell)[]
}
/** hairline between rows, painted per cell (see `ComparisonTable`) */
const HAIRLINE = "border-b border-b-border/70"
/** flanks of the highlighted column's panel */
const PANEL = "border-x border-x-brand-200 bg-brand-50/70"
/** `{ value?, check? }` is a cell spec, anything else is renderable content */
function isCellSpec(
cell: React.ReactNode | ComparisonCell
): cell is ComparisonCell {
return (
typeof cell === "object" &&
cell !== null &&
!Array.isArray(cell) &&
!React.isValidElement(cell)
)
}
/** solid teal badge for the highlighted column, thin mint ring for the rest */
function CheckMark({ solid }: { solid?: boolean }) {
return (
<CircleCheck
className={cn(
"size-5",
solid
? "[&>circle]:fill-primary [&>circle]:stroke-primary [&>path]:stroke-primary-foreground [&>path]:stroke-[2.5]"
: "text-brand-300"
)}
/>
)
}
/**
* Value over the check gutter. A checked column reserves the gutter in every
* cell — head included — so its text keeps one centre axis whether or not a
* given row carries a check.
*/
function ValueSlot({
children,
gutter,
check,
}: {
children: React.ReactNode
gutter: boolean
check?: React.ReactNode
}) {
return (
<div className="flex items-center gap-3">
<span className="flex-1 text-pretty">{children}</span>
{gutter ? (
<span aria-hidden className="size-5 shrink-0">
{check}
</span>
) : null}
</div>
)
}
/**
* Variant comparison ("Welche Variante passt zu mir?"): row labels down the
* left, one column per variant, every value trailed by a check. `highlight`
* lifts the recommended column onto a tinted panel running from the head to
* the last row and turns its checks solid.
*
* That panel is why the table is `border-separate`: only cells can carry the
* rounded corners a `<col>` cannot — so the row hairlines are painted per
* cell, since separated borders drop the ones on `<tr>`.
*/
function ComparisonTable({
columns,
rows,
labelHead,
labelWidth = "26%",
className,
}: {
columns: ComparisonColumn[]
rows: ComparisonRow[]
/** content for the otherwise empty top-left cell */
labelHead?: React.ReactNode
/** width of the label column, the rest split the remainder evenly */
labelWidth?: string
className?: string
}) {
const cells = rows.map((row) =>
columns.map((_, c) => {
const cell = row.cells[c]
return isCellSpec(cell) ? cell : { value: cell }
})
)
/* one gutter decision per column, so heads and values agree */
const gutters = columns.map((column, c) =>
cells.some((row) => row[c].check ?? column.check ?? true)
)
return (
<Card className={cn("gap-0 p-2 sm:p-4", className)}>
<Table className="min-w-xl table-fixed border-separate border-spacing-0">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead
className={cn("h-14 px-4 whitespace-normal", HAIRLINE)}
style={{ width: labelWidth }}
>
{labelHead}
</TableHead>
{columns.map((column, c) => (
<TableHead
key={c}
className={cn(
"h-14 px-4 text-center text-base whitespace-normal",
HAIRLINE,
column.highlight &&
"rounded-t-2xl border-t border-t-brand-200 text-primary",
column.highlight && PANEL
)}
>
<ValueSlot gutter={gutters[c]}>{column.title}</ValueSlot>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, r) => {
const last = r === rows.length - 1
return (
<TableRow key={r} className="hover:bg-transparent">
<TableCell
className={cn(
"px-4 py-5 font-bold text-navy",
!last && HAIRLINE
)}
>
{row.label}
</TableCell>
{columns.map((column, c) => {
const cell = cells[r][c]
return (
<TableCell
key={c}
className={cn(
"px-4 py-5 text-center",
!last && HAIRLINE,
column.highlight && PANEL,
column.highlight &&
last &&
"rounded-b-2xl border-b border-b-brand-200"
)}
>
<ValueSlot
gutter={gutters[c]}
check={
(cell.check ?? column.check ?? true) ? (
<CheckMark solid={column.highlight} />
) : null
}
>
{cell.value}
</ValueSlot>
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
)
}
export { ComparisonTable }
export type { ComparisonCell, ComparisonColumn, ComparisonRow }
@@ -0,0 +1,60 @@
import * as React from "react"
import { cn } from "@/lib/utils"
type FeatureBandItem = {
icon?: React.ReactNode
title: React.ReactNode
sub?: React.ReactNode
}
/**
* Feature row as one white card: hairline-divided columns (2 up on mobile,
* 4 from `sm`), each an oversized teal line icon over a navy title and a
* muted sub. Dividers are inset because the card pads around the columns.
*/
function FeatureBand({
items,
className,
}: {
items: FeatureBandItem[]
className?: string
}) {
return (
<div
className={cn(
"grid grid-cols-2 rounded-3xl bg-card p-4 shadow-card sm:grid-cols-4",
className
)}
>
{items.map((item, i) => (
<div
key={i}
className={cn(
"flex flex-col items-center px-4 py-5 text-center sm:px-6",
/* dividers between columns and rows of the current layout only */
i % 2 !== 0 && "border-l",
i >= 2 && "border-t",
i % 4 !== 0 ? "sm:border-l" : "sm:border-l-0",
i >= 4 ? "sm:border-t" : "sm:border-t-0"
)}
>
{item.icon ? (
<span className="text-brand-700 [&_svg]:size-12">{item.icon}</span>
) : null}
<div className="mt-5 text-[0.9375rem] leading-snug font-bold text-balance text-navy">
{item.title}
</div>
{item.sub ? (
<p className="mt-2 max-w-[15rem] text-[0.8125rem] leading-relaxed text-balance text-muted-foreground">
{item.sub}
</p>
) : null}
</div>
))}
</div>
)
}
export { FeatureBand }
export type { FeatureBandItem }
@@ -0,0 +1,125 @@
import * as React from "react"
/**
* GebOS line icons drawn on the lucide grid (24×24, currentColor stroke) for
* the motifs lucide has no equivalent of. Stroke is a hair lighter than
* lucide's 2 because these render large (≈48px) in feature bands.
*/
function LineIcon({
strokeWidth = 1.6,
children,
...props
}: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
)
}
/**
* Euro glyph inside a ring: costs stay transparent and under control.
* lucide has the bare glyph only, so it is shrunk about the icon centre and
* its stroke width divided by the same factor to keep the ring's weight.
*/
function EuroCircleIcon({
strokeWidth = 1.6,
...props
}: React.ComponentProps<"svg">) {
const glyphScale = 0.52
return (
<LineIcon strokeWidth={strokeWidth} {...props}>
<circle cx="12" cy="12" r="9.6" />
<g
transform={`translate(12 12) scale(${glyphScale}) translate(-12 -12)`}
strokeWidth={Number(strokeWidth) / glyphScale}
>
<path d="M4 10h12" />
<path d="M4 14h9" />
<path d="M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2" />
</g>
</LineIcon>
)
}
/** Cycle arrows around a radio dot: hardware that arrives ready to run. */
function PreconfiguredIcon(props: React.ComponentProps<"svg">) {
return (
<LineIcon {...props}>
<path d="M3.2 10.13A9 9 0 0 1 20.8 10.13" />
<path d="M22.11 7.66 20.8 10.13 18.59 8.41" />
<path d="M20.8 13.87A9 9 0 0 1 3.2 13.87" />
<path d="M1.89 16.34 3.2 13.87 5.41 15.59" />
<circle cx="10" cy="12" r="1.2" fill="currentColor" stroke="none" />
<path d="M11.93 9.7A3 3 0 0 1 11.93 14.3" />
<path d="M13.41 7.94A5.3 5.3 0 0 1 13.41 16.06" />
</LineIcon>
)
}
/**
* Wrench crossed with a screwdriver: install it yourself or hand it to a
* trade. Wrench contour is lucide's, mirrored onto the "\" diagonal so the
* jaw sits top-left; the screwdriver is drawn along +x and rotated onto "/",
* nudged clear of the jaw and filled with the card surface so it reads as
* lying on top of the wrench.
*/
function CrossedToolsIcon(props: React.ComponentProps<"svg">) {
return (
<LineIcon {...props}>
<path
transform="translate(24 0) scale(-1 1)"
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"
/>
<path
transform="rotate(-45 12 12) translate(-0.6 1.2)"
fill="var(--card)"
d="M5.7 9.9H10.4V10.5H11.8V11.25H17.6L18.3 10.95H20.4V13.05H18.3L17.6 12.75H11.8V13.5H10.4V14.1H5.7A2.1 2.1 0 0 1 5.7 9.9Z"
/>
</LineIcon>
)
}
/** Cloud with a bolt: metering data flows and is processed without anyone. */
function CloudAutomationIcon(props: React.ComponentProps<"svg">) {
return (
<LineIcon {...props}>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
<path d="M13.1 10.7 10.5 14.6h1.85l-.68 2.6 2.6-3.9h-1.85z" />
</LineIcon>
)
}
/** Dashboard gauge on a screen: every building and meter in one portal. */
function PortalGaugeIcon(props: React.ComponentProps<"svg">) {
return (
<LineIcon {...props}>
<rect x="4.6" y="4" width="14.8" height="13.8" rx="2.4" />
<path d="M3 20h18" />
<path d="M8.02 13.18A4 4 0 0 1 15.98 13.18" />
<path d="M12 13.6 13.89 10.9" />
<circle cx="12" cy="13.6" r="0.8" fill="currentColor" stroke="none" />
</LineIcon>
)
}
export {
CloudAutomationIcon,
CrossedToolsIcon,
EuroCircleIcon,
LineIcon,
PortalGaugeIcon,
PreconfiguredIcon,
}
@@ -0,0 +1,48 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
/**
* One of two mutually exclusive delivery routes (Selbst installieren /
* Komplettlösung): centred copy over a bold takeaway, the 3D scene below,
* CTA pinned to the card foot so side-by-side cards line their buttons up.
*/
function OptionCard({
title,
lead,
takeaway,
visual,
action,
className,
}: {
title: React.ReactNode
lead: React.ReactNode
/** bold navy claim that closes the copy block */
takeaway?: React.ReactNode
visual: React.ReactNode
action: React.ReactNode
className?: string
}) {
return (
<Card className={cn("items-center gap-0 p-6 text-center sm:p-8", className)}>
<h3 className="text-xl font-extrabold tracking-tight text-balance text-navy sm:text-2xl">
{title}
</h3>
<p className="mt-3 max-w-sm text-sm leading-relaxed text-pretty text-muted-foreground">
{lead}
</p>
{takeaway ? (
<p className="mt-4 max-w-sm text-sm leading-relaxed font-bold text-balance text-navy">
{takeaway}
</p>
) : null}
<div className="mt-7 flex w-full flex-1 items-end justify-center">
{visual}
</div>
<div className="mt-7 flex w-full justify-center">{action}</div>
</Card>
)
}
export { OptionCard }
@@ -94,4 +94,70 @@ function NumberedList({
)
}
export { ProcessSteps, NumberedList }
/**
* Step rail on a card (Selbstinstallation, steps 15): numbered rings threaded
* on a solid teal rail, hairline-divided columns beneath, each an asset render
* over title and copy. Each column draws its own half of the rail, so the run
* ends at the first and last ring whatever the step count. Rail and dividers
* appear only once the columns sit in one row.
*/
function StepRail({
steps,
className,
}: {
steps: ProcessStep[]
className?: string
}) {
return (
<ol
className={cn(
"grid grid-cols-1 gap-y-10 rounded-3xl bg-card p-5 shadow-card sm:grid-cols-2 sm:p-7 lg:gap-y-0",
steps.length >= 5 ? "lg:grid-cols-5" : "lg:grid-cols-4",
className
)}
>
{steps.map((step, i) => (
<li key={i} className="relative flex flex-col items-center">
<span
aria-hidden="true"
/* rail sits on the ring's centre line (size-9 ring → 18px) */
className={cn(
"absolute top-[17px] hidden h-0.5 bg-brand-300 lg:block",
i === 0 ? "left-1/2" : "left-0",
i === steps.length - 1 ? "right-1/2" : "right-0"
)}
/>
<span className="z-10 flex size-9 items-center justify-center rounded-full bg-card text-sm font-bold text-navy ring-2 ring-brand-300">
{i + 1}
</span>
<div
className={cn(
"mt-5 flex w-full flex-1 flex-col items-center px-2 text-center",
/* dividers start below the rail, between side-by-side columns */
i % 2 === 1 && "sm:border-l",
i > 0 && "lg:border-l"
)}
>
<div className="flex h-28 items-end justify-center gap-1">
{step.media ?? (
<IconBadge variant="outline" shape="squircle" size="xl">
{step.icon}
</IconBadge>
)}
</div>
<h3 className="mt-4 text-sm font-bold text-balance text-navy">
{step.title}
</h3>
{step.description ? (
<p className="mt-2 max-w-[15rem] text-xs leading-relaxed text-balance text-muted-foreground">
{step.description}
</p>
) : null}
</div>
</li>
))}
</ol>
)
}
export { ProcessSteps, NumberedList, StepRail }
@@ -22,6 +22,8 @@ import cubesRoundWebp from "@/assets/gebos/cubes-round.webp"
import drillToolAvif from "@/assets/gebos/drill-tool.avif"
import drillToolWebp from "@/assets/gebos/drill-tool.webp"
import euroOrb from "@/assets/gebos/euro-orb.svg"
import fullHeroSectionAvif from "@/assets/gebos/full-hero-section.avif"
import fullHeroSectionWebp from "@/assets/gebos/full-hero-section.webp"
import gatewayAvif from "@/assets/gebos/gateway.avif"
import gatewayWebp from "@/assets/gebos/gateway.webp"
import glassCubes from "@/assets/gebos/glass-cubes.svg"
@@ -29,6 +31,8 @@ import glassCubesStackAvif from "@/assets/gebos/glass-cubes-stack.avif"
import glassCubesStackWebp from "@/assets/gebos/glass-cubes-stack.webp"
import heatMeterAvif from "@/assets/gebos/heat-meter.avif"
import heatMeterWebp from "@/assets/gebos/heat-meter.webp"
import houseSmallIsoAvif from "@/assets/gebos/house-small-isometric.avif"
import houseSmallIsoWebp from "@/assets/gebos/house-small-isometric.webp"
import iconBilling from "@/assets/gebos/icon-billing.svg"
import iconPortal from "@/assets/gebos/icon-portal.svg"
import iconRadio from "@/assets/gebos/icon-radio.svg"
@@ -36,7 +40,13 @@ import iconSmokeAlarm from "@/assets/gebos/icon-smoke-alarm.svg"
import iconUvi from "@/assets/gebos/icon-uvi.svg"
import laptopAvif from "@/assets/gebos/laptop-dashboard.avif"
import laptopWebp from "@/assets/gebos/laptop-dashboard.webp"
import laptopPortfolioAvif from "@/assets/gebos/laptop-portfolio.avif"
import laptopPortfolioWebp from "@/assets/gebos/laptop-portfolio.webp"
import packageBox from "@/assets/gebos/package-box.svg"
import refreshLoopCloudAvif from "@/assets/gebos/refresh-loop-cloud.avif"
import refreshLoopCloudWebp from "@/assets/gebos/refresh-loop-cloud.webp"
import refreshLoopAvif from "@/assets/gebos/refresh-loop.avif"
import refreshLoopWebp from "@/assets/gebos/refresh-loop.webp"
import shippingBoxClosedAvif from "@/assets/gebos/shipping-box-closed.avif"
import shippingBoxClosedWebp from "@/assets/gebos/shipping-box-closed.webp"
import shippingBoxMetersAvif from "@/assets/gebos/shipping-box-meters.avif"
@@ -44,12 +54,18 @@ import shippingBoxMetersWebp from "@/assets/gebos/shipping-box-meters.webp"
import smokeAlarmAvif from "@/assets/gebos/smoke-alarm.avif"
import smokeAlarmWebp from "@/assets/gebos/smoke-alarm.webp"
import tealBars from "@/assets/gebos/teal-bars.svg"
import technicianPipeAvif from "@/assets/gebos/technician-pipe.avif"
import technicianPipeWebp from "@/assets/gebos/technician-pipe.webp"
import uviBadge from "@/assets/gebos/uvi-badge.svg"
import wirelessOrb from "@/assets/gebos/wireless-orb.svg"
import wordmarkDarkAvif from "@/assets/gebos/wordmark-cubes-dark.avif"
import wordmarkDarkWebp from "@/assets/gebos/wordmark-cubes-dark.webp"
import wordmarkGlowAvif from "@/assets/gebos/wordmark-cubes-glow.avif"
import wordmarkGlowWebp from "@/assets/gebos/wordmark-cubes-glow.webp"
import workerDrillingGateway2Avif from "@/assets/gebos/worker-drilling-gateway-2.avif"
import workerDrillingGateway2Webp from "@/assets/gebos/worker-drilling-gateway-2.webp"
import workerDrillingGatewayAvif from "@/assets/gebos/worker-drilling-gateway.avif"
import workerDrillingGatewayWebp from "@/assets/gebos/worker-drilling-gateway.webp"
import workerTabletAvif from "@/assets/gebos/worker-tablet.avif"
import workerTabletWebp from "@/assets/gebos/worker-tablet.webp"
@@ -112,6 +128,24 @@ const renders = {
alt: "Objektbezogen vorbereitete Hardware",
matte: "white",
},
technicianPipe: {
avif: technicianPipeAvif,
webp: technicianPipeWebp,
alt: "Techniker prüft Rohrleitung mit Messgerät",
matte: "white",
},
workerDrillingGateway: {
avif: workerDrillingGatewayAvif,
webp: workerDrillingGatewayWebp,
alt: "Monteur montiert GebOS Gateway an der Wand",
matte: "white",
},
workerDrillingGateway2: {
avif: workerDrillingGateway2Avif,
webp: workerDrillingGateway2Webp,
alt: "Monteur montiert GebOS Gateway an Trockenbauwand",
matte: "white",
},
/* --- transparent renders (free placement, any background) ---------- */
/** same subject as `building`, but with genuine alpha safe on any surface */
@@ -128,6 +162,27 @@ const renders = {
alt: "Modernes Mehrfamilienhaus",
matte: "alpha",
},
/** compact single-family house, isometric, genuine alpha */
houseSmallIso: {
avif: houseSmallIsoAvif,
webp: houseSmallIsoWebp,
alt: "Einfamilienhaus, isometrisch",
matte: "alpha",
},
/** full hero scene: building, gateway, consumption bars & ring on a glass platform */
fullHeroSection: {
avif: fullHeroSectionAvif,
webp: fullHeroSectionWebp,
alt: "GebOS Systemübersicht: Gebäude, Gateway und Verbrauchsdaten",
matte: "alpha",
},
/** same laptop mockup as `laptopDashboard`, portfolio/room screen, genuine alpha */
laptopPortfolio: {
avif: laptopPortfolioAvif,
webp: laptopPortfolioWebp,
alt: "GebOS Portfolio-Ansicht auf dem Laptop",
matte: "alpha",
},
brandMark3d: {
avif: brandMark3dAvif,
webp: brandMark3dWebp,
@@ -176,6 +231,18 @@ const renders = {
alt: "Vorkonfigurierte Lieferung",
matte: "alpha",
},
refreshLoop: {
avif: refreshLoopAvif,
webp: refreshLoopWebp,
alt: "",
matte: "alpha",
},
refreshLoopCloud: {
avif: refreshLoopCloudAvif,
webp: refreshLoopCloudWebp,
alt: "",
matte: "alpha",
},
/** baked light glow use on white/near-white surfaces */
wordmarkGlow: {
avif: wordmarkGlowAvif,
@@ -216,8 +283,10 @@ const icons = {
/**
* Renders a kit asset as AVIF→WebP <picture>. White-matte masters get
* mix-blend-multiply so their matte disappears on light surfaces;
* transparent assets composite normally.
* mix-blend-multiply so their matte disappears on light surfaces, plus a
* hairline brightness lift: the masters matte out at 252254, which multiply
* would otherwise leave as a visible 1 % box on a white card. Transparent
* assets composite normally.
*/
function AssetRender({
render,
@@ -242,7 +311,7 @@ function AssetRender({
loading={loading}
className={cn(
"object-contain",
render.matte === "white" && "mix-blend-multiply",
render.matte === "white" && "mix-blend-multiply brightness-[1.015]",
imgClassName
)}
/>
@@ -34,42 +34,44 @@ const COLUMNS: { title: string; links: { to: string; label: string }[] }[] = [
function SiteFooter() {
return (
<footer className="mt-20 bg-navy text-white">
<Container className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_1fr_1fr_1fr]">
<div>
<Logo inverse />
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/60">
Messdienstleistungen einfach gemacht von der vorkonfigurierten
Hardware bis zum laufenden Betrieb.
</p>
</div>
{COLUMNS.map((col) => (
<nav key={col.title} aria-label={col.title}>
<h3 className="text-xs font-bold tracking-[0.14em] text-brand-300 uppercase">
{col.title}
</h3>
<ul className="mt-4 flex flex-col gap-2.5">
{col.links.map((l) => (
<li key={l.to}>
<Link
to={l.to}
className="text-sm text-white/75 transition-colors hover:text-white"
>
{l.label}
</Link>
</li>
))}
</ul>
</nav>
))}
</Container>
<div className="border-t border-white/10">
<Container className="flex flex-wrap items-center justify-between gap-3 py-5 text-xs text-white/50">
<span>© 2026 GebOS. Alle Rechte vorbehalten.</span>
<span>Impressum · Datenschutz · AGB</span>
<div className="mt-20 px-3 pb-3 sm:px-5 sm:pb-5">
<footer className="glass-navy inset-shadow-glass-slab shadow-glass-slab rounded-3xl text-white">
<Container className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_1fr_1fr_1fr]">
<div>
<Logo inverse />
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/60">
Messdienstleistungen einfach gemacht von der vorkonfigurierten
Hardware bis zum laufenden Betrieb.
</p>
</div>
{COLUMNS.map((col) => (
<nav key={col.title} aria-label={col.title}>
<h3 className="text-xs font-bold tracking-[0.14em] text-brand-300 uppercase">
{col.title}
</h3>
<ul className="mt-4 flex flex-col gap-2.5">
{col.links.map((l) => (
<li key={l.to}>
<Link
to={l.to}
className="text-sm text-white/75 transition-colors hover:text-white"
>
{l.label}
</Link>
</li>
))}
</ul>
</nav>
))}
</Container>
</div>
</footer>
<div className="border-t border-white/10">
<Container className="flex flex-wrap items-center justify-between gap-3 py-5 text-xs text-white/60">
<span>© 2026 GebOS. Alle Rechte vorbehalten.</span>
<span>Impressum · Datenschutz · AGB</span>
</Container>
</div>
</footer>
</div>
)
}
@@ -4,22 +4,33 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* Glass physics shared by the filled variants: the backdrop is blurred and
* saturated the way the cube renders bend what is behind them, the lit rim
* and the caustic ride the inset-shadow slot, the cast bloom the outer one.
* Tint and edge colour come from the `glass-*` utilities in globals.css.
*/
const glass =
"backdrop-blur-md backdrop-saturate-150 inset-shadow-glass shadow-glass hover:shadow-glass-lg"
const glassOnHover =
"hover:backdrop-blur-md hover:backdrop-saturate-150 hover:inset-shadow-glass hover:shadow-glass"
const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg text-sm font-semibold whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-brand-700",
outline:
"border border-border bg-card text-foreground shadow-pill hover:border-brand-200 hover:bg-brand-50",
secondary: "bg-secondary text-secondary-foreground hover:bg-brand-100",
ghost: "text-foreground hover:bg-secondary",
default: `${glass} glass-teal text-primary-foreground hover:glass-lit`,
outline: `${glass} glass-clear text-foreground hover:glass-lit`,
secondary: `${glass} glass-mint text-secondary-foreground hover:glass-lit`,
ghost: `${glassOnHover} text-foreground hover:glass-clear`,
link: "text-primary underline-offset-4 hover:underline",
/* white button sitting on the teal gradient band */
inverse: "bg-white text-brand-800 shadow-sm hover:bg-brand-50",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
/* frosted white button sitting on the teal gradient band */
inverse: `${glass} glass-frost text-brand-800 hover:glass-lit`,
/* its quieter neighbour: the band still reads through the glass */
inverseOutline: `${glass} glass-veil text-white hover:glass-lit`,
destructive: `${glass} glass-danger text-destructive-foreground hover:glass-lit`,
},
size: {
default: "h-10 px-5 py-2",
@@ -3,6 +3,7 @@ import * as React from "react"
import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
import { PageHero } from "@/components/gebos/page-hero"
import { FeatureBand } from "@/components/gebos/feature-band"
import { FeatureTile } from "@/components/gebos/icon-tile"
type AudienceFeature = {
@@ -34,6 +35,7 @@ function AudiencePage({
actions,
visual,
features,
featureLayout = "tiles",
children,
closing,
}: {
@@ -45,6 +47,11 @@ function AudiencePage({
actions?: React.ReactNode
visual?: React.ReactNode
features: AudienceFeature[]
/**
* `tiles` — bare icon-over-label tiles on the page background.
* `band` — one divided white card (larger line icons, two-line subs).
*/
featureLayout?: "tiles" | "band"
/** additional full-width spec sections between feature row and closing */
children?: React.ReactNode
closing?: React.ReactNode
@@ -69,17 +76,21 @@ function AudiencePage({
/>
<Container>
<Section className="pt-2 sm:pt-4 lg:pt-6">
<div className="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-4">
{features.map((feature, i) => (
<FeatureTile
key={i}
icon={feature.icon}
media={feature.media}
title={feature.title}
sub={feature.sub}
/>
))}
</div>
{featureLayout === "band" ? (
<FeatureBand items={features} />
) : (
<div className="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-4">
{features.map((feature, i) => (
<FeatureTile
key={i}
icon={feature.icon}
media={feature.media}
title={feature.title}
sub={feature.sub}
/>
))}
</div>
)}
</Section>
{children}
{closing}
@@ -1,19 +1,27 @@
import { Link } from "react-router-dom"
import { Euro, Handshake, ReceiptText, ShieldCheck, Wrench } from "lucide-react"
import { Clock, Euro, HardHat, Network } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
BenefitGrid,
type BenefitGridItem,
} from "@/components/gebos/benefit-grid"
import {
ComparisonTable,
type ComparisonColumn,
type ComparisonRow,
} from "@/components/gebos/comparison-table"
import { Section, SectionHeading } from "@/components/gebos/section"
import { CtaBanner } from "@/components/gebos/cta-banner"
import { NumberedList } from "@/components/gebos/process-steps"
import { StepRail } from "@/components/gebos/process-steps"
import {
CloudAutomationIcon,
CrossedToolsIcon,
EuroCircleIcon,
PortalGaugeIcon,
PreconfiguredIcon,
} from "@/components/gebos/line-icons"
import { OptionCard } from "@/components/gebos/option-card"
import { AssetRender, renders } from "@/components/gebos/site-assets"
import { AudiencePage } from "@/pages/audiences/audience-page"
@@ -31,7 +39,7 @@ function HomeownerArt() {
imgClassName="w-full"
loading="eager"
/>
<AssetRender
{/* <AssetRender
render={renders.smokeAlarm}
className="absolute top-[8%] left-[2%] w-[26%]"
imgClassName="w-full"
@@ -50,69 +58,140 @@ function HomeownerArt() {
render={renders.cubesRound}
alt=""
className="absolute top-[50%] right-[30%] w-[16%] scale-200 z-30"
/> */}
</div>
)
}
/**
* Page-local scene for the full-service route: the fitter drilling the gateway
* onto the drywall panel. 4:3 frame to match the shipping-box render next door.
* The render is a self-contained scene whose subject occupies the middle 79%
* of its 4:5 canvas, so it is scaled past the frame and dropped below it: the
* subject then fills the frame's height while the surrounding matte — invisible
* on the card — is what overflows.
*/
function FullServiceArt() {
return (
<div className="relative w-full aspect-[4/3]">
<AssetRender
render={renders.workerDrillingGateway2}
className="absolute inset-x-0 -bottom-[15%] mx-auto w-[74%]"
/>
</div>
)
}
/**
* Portal laptop for the benefit grid's centre cell. Measured on the master,
* the laptop occupies 11.088.5 % of the canvas height and 7.783.9 % of its
* width, i.e. 77.4 % of each with clear air around it. Fitting the *canvas*
* to the cell would therefore leave the laptop floating small in the middle,
* so the canvas is inflated past the cell by exactly that air (11.04 % /
* 0.7744 above, 11.52 % / 0.7744 below): the laptop then fills the cell's
* height, flush with the flanking cards' floor and a grid gap clear of the
* row above — it never crosses into a card. `object-contain` keeps that true
* whatever height the row settles at; the wider right inset cancels the
* laptop's own leftward bias inside the canvas so it reads centred.
*/
function PortalLaptopArt() {
return (
<AssetRender
render={renders.laptopDashboard}
className="mx-auto block w-full max-w-md lg:absolute lg:-top-[14.3%] lg:-right-[12.3%] lg:-bottom-[14.9%] lg:-left-[3%] lg:mx-0 lg:w-auto lg:max-w-none"
imgClassName="h-full w-full object-contain"
/>
)
}
const SELF_INSTALL_STEPS = [
{
media: (
<AssetRender render={renders.houseSmallIso} imgClassName="w-28" />
),
title: "Gebäude angeben",
description:
"Sie teilen uns die grundlegenden Informationen zum Gebäude und den Wohnungen mit.",
"Daten zu Ihrem Gebäude und den Wohneinheiten in wenigen Schritten erfassen.",
},
{
media: (
<>
<AssetRender render={renders.heatMeter} imgClassName="w-24" />
<AssetRender render={renders.smokeAlarm} imgClassName="w-20" />
</>
),
title: "Passende Ausstattung zusammenstellen",
description:
"Die benötigten Zähler, Kommunikationskomponenten und gegebenenfalls Rauchwarnmelder werden zusammengestellt.",
description: "Wir empfehlen die benötigte Ausstattung Sie wählen aus.",
},
{
media: (
<AssetRender render={renders.shippingBoxClosed} imgClassName="w-36" />
),
title: "Vorkonfiguriert erhalten",
description:
"Die Geräte werden bereits für Ihr Gebäude vorbereitet geliefert.",
description: "Ihre Technik kommt vorkonfiguriert und einsatzbereit an.",
},
{
media: <AssetRender render={renders.drillTool} imgClassName="w-32" />,
title: "Montieren",
description:
"Sie selbst, Ihr Hausmeister oder Ihr Fachbetrieb installiert die Geräte.",
"Sie montieren mit Ihrem Handwerker oder selbst flexibel und unkompliziert.",
},
{
media: (
<AssetRender render={renders.refreshLoopCloud} imgClassName="w-28" />
),
title: "GebOS übernimmt",
description:
"Nach der Inbetriebnahme laufen Datenerfassung und die gebuchten Dienstleistungen über GebOS.",
"Wir übernehmen Ablesung, Übertragung und Auswertung automatisch.",
},
]
const WHY_GEBOS = [
const WHY_GEBOS: BenefitGridItem[] = [
{
icon: <EuroCircleIcon />,
title: "Kosten kontrollieren",
text: "Entscheiden Sie selbst, welche Arbeiten Sie übernehmen und welche Sie abgeben möchten.",
},
{
icon: <Network strokeWidth={1.6} />,
title: "Keine unnötige technische Komplexität",
text: "Die Hardware wird für das jeweilige Gebäude vorbereitet.",
},
{
icon: <HardHat strokeWidth={1.6} />,
title: "Bestehenden Handwerker nutzen",
text: "Sie benötigen keinen speziellen GebOS-Installateur.",
},
{
icon: <PortalGaugeIcon />,
title: "Alles an einem Ort",
text: "UVI, Abrechnung, Geräte und weitere Gebäudedienstleistungen werden zentral verwaltet.",
},
{
icon: <Clock strokeWidth={1.6} />,
title: "Weniger laufender Aufwand",
text: "Nach der Einrichtung werden Verbrauchsdaten automatisch erfasst und verarbeitet.",
},
]
const VARIANT_ROWS = [
["Planung", "gemeinsam mit GebOS", "GebOS"],
["Hardware", "GebOS", "GebOS"],
["Installation", "Kunde / eigener Fachbetrieb", "organisiert durch GebOS"],
["Softwareeinrichtung", "vorbereitet durch GebOS", "GebOS"],
["Laufender Betrieb", "GebOS", "GebOS"],
["Kosten", "möglichst niedrig", "höhere Planungssicherheit und Komfort"],
const VARIANT_COLUMNS: ComparisonColumn[] = [
{ title: "Selbst installieren", highlight: true },
{ title: "Komplettlösung" },
]
const VARIANT_ROWS: ComparisonRow[] = [
{ label: "Planung", cells: ["gemeinsam mit GebOS", "GebOS"] },
{ label: "Hardware", cells: ["GebOS", "GebOS"] },
{
label: "Installation",
cells: ["Kunde / eigener Fachbetrieb", "organisiert durch GebOS"],
},
{ label: "Softwareeinrichtung", cells: ["vorbereitet durch GebOS", "GebOS"] },
{ label: "Laufender Betrieb", cells: ["GebOS", "GebOS"] },
{
label: "Kosten",
cells: ["möglichst niedrig", "höhere Planungssicherheit und Komfort"],
},
]
export default function HauseigentuemerPage() {
@@ -131,26 +210,27 @@ export default function HauseigentuemerPage() {
</Button>
}
visual={<HomeownerArt />}
featureLayout="band"
features={[
{
icon: <Wrench />,
title: "Selbst installieren",
sub: "Kosten sparen, flexibel bleiben",
icon: <PreconfiguredIcon />,
title: "Vorkonfigurierte Technik",
sub: "Einsatzbereit, perfekt aufeinander abgestimmt.",
},
{
icon: <ShieldCheck />,
title: "Komplettlösung",
sub: "Wir kümmern uns um alles",
icon: <CrossedToolsIcon />,
title: "Flexibel installieren",
sub: "Selbst übernehmen oder vom Profi montieren lassen.",
},
{
icon: <ReceiptText />,
title: "Transparente Preise",
sub: "Keine versteckten Kosten",
icon: <CloudAutomationIcon />,
title: "Automatischer Betrieb",
sub: "GebOS übernimmt Ablesung, Übertragung und Auswertung.",
},
{
icon: <Handshake />,
title: "Bestehende Partner",
sub: "Ihre Handwerker bleiben",
icon: <PortalGaugeIcon />,
title: "Alles zentral verwaltet",
sub: "Alle Daten, Geräte und Gebäude im Blick.",
},
]}
closing={
@@ -181,51 +261,45 @@ export default function HauseigentuemerPage() {
title="Zwei Wege zu GebOS"
/>
<div className="mt-10 grid gap-6 md:grid-cols-2">
<Card className="relative gap-0 overflow-hidden p-7">
<AssetRender
render={renders.drillTool}
className="pointer-events-none absolute -top-2 -right-3 w-28 opacity-90"
/>
<h3 className="pr-24 text-lg font-extrabold tracking-tight text-navy">
Selbst installieren
</h3>
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground">
Sie möchten Kosten niedrig halten und können die Installation
selbst oder über einen vertrauten Heizungsbauer, Hausmeister
oder anderen Fachbetrieb organisieren? GebOS liefert die
benötigte Technik passend zu Ihrem Gebäude vorkonfiguriert.
</p>
<p className="mt-3 text-sm font-bold leading-relaxed text-navy">
Sie organisieren die Montage. GebOS übernimmt den digitalen
Betrieb.
</p>
<div className="mt-6">
<Button asChild size="sm">
<OptionCard
title="Selbst installieren"
lead="Sie erhalten vorkonfigurierte Technik und installieren mit Ihrem Handwerker oder in Eigenregie."
takeaway={
<>
Sie organisieren die Montage.
<br />
GebOS übernimmt den digitalen Betrieb.
</>
}
visual={
<AssetRender
render={renders.shippingBoxMeters}
className="block w-full"
/>
}
action={
<Button asChild size="lg">
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
</div>
</Card>
<Card className="relative gap-0 overflow-hidden p-7">
<AssetRender
render={renders.shippingBoxMeters}
className="pointer-events-none absolute -top-1 -right-4 w-32"
/>
<h3 className="pr-28 text-lg font-extrabold tracking-tight text-navy">
Komplett installieren lassen
</h3>
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground">
Sie möchten sich möglichst wenig mit Planung, Beschaffung und
Installation beschäftigen? GebOS organisiert die komplette
Umsetzung für Sie. Von der Ermittlung der benötigten Geräte
über die Installation bis zum laufenden Betrieb erhalten Sie
eine durchgängige Lösung.
</p>
<div className="mt-6">
<Button asChild size="sm" variant="outline">
}
/>
<OptionCard
title="Komplett installieren lassen"
lead="Lehnen Sie sich zurück. GebOS übernimmt Planung, Installation und Einrichtung inklusive Service."
takeaway={
<>
Ein Ansprechpartner. Alles aus einer Hand.
<br />
Komplettlösung mit voller Betreuung.
</>
}
visual={<FullServiceArt />}
action={
<Button asChild size="lg" variant="outline">
<Link to="/kontakt">Komplettlösung anfragen</Link>
</Button>
</div>
</Card>
}
/>
</div>
</Section>
@@ -234,7 +308,7 @@ export default function HauseigentuemerPage() {
eyebrow="Schritt für Schritt"
title="So funktioniert die Selbstinstallation"
/>
<NumberedList steps={SELF_INSTALL_STEPS} className="mt-10 max-w-2xl" />
<StepRail steps={SELF_INSTALL_STEPS} className="mt-10" />
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
@@ -242,16 +316,11 @@ export default function HauseigentuemerPage() {
eyebrow="Ihre Vorteile"
title="Warum GebOS für Hauseigentümer?"
/>
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{WHY_GEBOS.map((item) => (
<Card key={item.title} className="gap-0 p-6">
<h3 className="text-sm font-bold text-navy">{item.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{item.text}
</p>
</Card>
))}
</div>
<BenefitGrid
className="mt-10"
items={WHY_GEBOS}
visual={<PortalLaptopArt />}
/>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
@@ -259,32 +328,11 @@ export default function HauseigentuemerPage() {
eyebrow="Im Vergleich"
title="Welche Variante passt zu mir?"
/>
<Card className="mt-10 gap-0 overflow-hidden p-2 sm:p-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30%]" />
<TableHead>Selbst installieren</TableHead>
<TableHead>Komplettlösung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{VARIANT_ROWS.map(([label, self, full]) => (
<TableRow key={label}>
<TableCell className="font-bold text-navy">
{label}
</TableCell>
<TableCell className="text-muted-foreground">
{self}
</TableCell>
<TableCell className="text-muted-foreground">
{full}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<ComparisonTable
className="mt-10"
columns={VARIANT_COLUMNS}
rows={VARIANT_ROWS}
/>
</Section>
</AudiencePage>
)
@@ -37,8 +37,6 @@ const HEAT_OPTIONS: { value: Heat; label: string }[] = [
const INITIAL_BUILDINGS: Building[] = [
{ id: 1, adresse: "", heat: "heizkoerper", wohnungen: 12, zimmer: 3 },
{ id: 2, adresse: "", heat: "heizkoerper", wohnungen: 8, zimmer: 3 },
{ id: 3, adresse: "", heat: "heizkoerper", wohnungen: 6, zimmer: 3 },
]
const UVI_MONTHLY = 10.5
@@ -357,8 +355,7 @@ export default function ConfiguratorPage() {
</h3>
<div className="mt-4 flex flex-col gap-1">
<div className="text-2xl font-extrabold tracking-tight text-navy">
{totalBuildings}{" "}
{totalBuildings === 1 ? "Gebäude" : "Gebäude"}
{totalBuildings} Gebäude
</div>
<div className="text-2xl font-extrabold tracking-tight text-navy">
{totalUnits} {totalUnits === 1 ? "Wohnung" : "Wohnungen"}
+1 -6
View File
@@ -572,12 +572,7 @@ export default function HomePage() {
<Button asChild variant="inverse" size="lg">
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
<Button
asChild
variant="outline"
size="lg"
className="border-white/40 bg-transparent text-white hover:bg-white/10 hover:text-white"
>
<Button asChild variant="inverseOutline" size="lg">
<Link to="/so-funktionierts">So funktioniert GebOS</Link>
</Button>
</>
+189
View File
@@ -56,6 +56,18 @@
--brand-900: #06474e;
--navy: #071a3f;
/* glass — sampled off the cube renders (glass-cubes-stack, brand-cube) */
--glass-spec: #dffcfc; /* specular top face */
--glass-edge: #97f1f4; /* light piped along the lit edge */
--glass-deep: #176163; /* shaded face, and the bloom it casts */
/* per-surface slots, retuned by the glass-* utilities below */
--glass-rim: color-mix(in srgb, var(--glass-spec) 55%, transparent);
--glass-ring: rgb(255 255 255 / 0.2);
--glass-ring-lit: rgb(255 255 255 / 0.35);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 50%, transparent);
--glass-bloom: color-mix(in srgb, var(--glass-deep) 38%, transparent);
--radius: 0.75rem;
}
@@ -112,6 +124,21 @@
0 2px 4px rgb(19 41 60 / 0.04), 0 16px 48px rgb(19 41 60 / 0.1);
--shadow-band: 0 16px 40px rgb(10 100 89 / 0.28);
--shadow-pill: 0 1px 2px rgb(19 41 60 / 0.06);
/* glass: lit top edge, piped rim, caustic pooling at the base */
--inset-shadow-glass:
inset 0 1px 0 0 var(--glass-rim), inset 0 0 0 1px var(--glass-ring),
inset 0 -9px 12px -9px var(--glass-caustic);
--shadow-glass:
0 1px 2px rgb(19 41 60 / 0.05), 0 10px 22px -10px var(--glass-bloom);
--shadow-glass-lg:
0 2px 4px rgb(19 41 60 / 0.06), 0 18px 34px -12px var(--glass-bloom);
/* the same optics at slab scale, for surfaces the size of the footer */
--inset-shadow-glass-slab:
inset 0 2px 0 0 var(--glass-rim), inset 0 0 0 1px var(--glass-ring),
inset 0 -30px 44px -30px var(--glass-caustic);
--shadow-glass-slab:
0 2px 6px rgb(19 41 60 / 0.05), 0 30px 60px -24px var(--glass-bloom);
}
@layer base {
@@ -167,3 +194,165 @@
border-color: var(--brand-300);
border-style: dotted;
}
/* ------------------------------------------------------------------ */
/* Glass surfaces */
/* Read off the cube renders: light falls in from the top left, the */
/* body deepens towards the base, the edge pipes light around the */
/* silhouette and pools into a caustic underneath. */
/* Same colour space as the tokens above the bodies are brand */
/* tokens thinned `in srgb`, and hand-written gradients interpolate */
/* in srgb too (Tailwind's own gradient utilities would go oklab). */
/* ------------------------------------------------------------------ */
/* primary actions: teal glass, lit face → shaded base */
@utility glass-teal {
--glass-rim: color-mix(in srgb, var(--glass-spec) 45%, transparent);
--glass-ring: rgb(255 255 255 / 0.2);
--glass-ring-lit: rgb(255 255 255 / 0.38);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 55%, transparent);
--glass-bloom: color-mix(in srgb, var(--brand-800) 45%, transparent);
background-image:
linear-gradient(
135deg,
color-mix(in srgb, var(--glass-spec) 20%, transparent) 0%,
color-mix(in srgb, var(--glass-spec) 5%, transparent) 32%,
transparent 58%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--brand-600) 96%, transparent) 0%,
color-mix(in srgb, var(--brand-700) 98%, transparent) 52%,
var(--brand-800) 100%
);
}
/* secondary actions: thin mint glass over light surfaces */
@utility glass-mint {
--glass-rim: rgb(255 255 255 / 0.85);
--glass-ring: rgb(255 255 255 / 0.7);
--glass-ring-lit: rgb(255 255 255 / 0.9);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 40%, transparent);
--glass-bloom: color-mix(in srgb, var(--glass-deep) 22%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.55) 0%,
rgb(255 255 255 / 0.15) 40%,
transparent 66%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--brand-100) 82%, transparent) 0%,
color-mix(in srgb, var(--brand-200) 74%, transparent) 100%
);
}
/* quiet actions: clear glass, only the edge and a teal cast give it away */
@utility glass-clear {
--glass-rim: rgb(255 255 255 / 0.9);
--glass-ring: var(--input);
--glass-ring-lit: color-mix(in srgb, var(--brand-300) 80%, transparent);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 35%, transparent);
--glass-bloom: color-mix(in srgb, var(--glass-deep) 16%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.7) 0%,
rgb(255 255 255 / 0.3) 42%,
transparent 68%
),
linear-gradient(
180deg,
rgb(255 255 255 / 0.72) 0%,
color-mix(in srgb, var(--brand-50) 66%, transparent) 100%
);
}
/* frosted white glass for the teal gradient bands */
@utility glass-frost {
--glass-rim: rgb(255 255 255 / 0.95);
--glass-ring: rgb(255 255 255 / 0.8);
--glass-ring-lit: rgb(255 255 255 / 0.95);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 45%, transparent);
--glass-bloom: color-mix(in srgb, var(--brand-900) 45%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.4) 0%,
rgb(255 255 255 / 0.1) 42%,
transparent 66%
),
linear-gradient(
180deg,
rgb(255 255 255 / 0.94) 0%,
color-mix(in srgb, var(--brand-50) 88%, transparent) 100%
);
}
/* the same band, second action: glass with the band still showing through */
@utility glass-veil {
--glass-rim: rgb(255 255 255 / 0.55);
--glass-ring: rgb(255 255 255 / 0.45);
--glass-ring-lit: rgb(255 255 255 / 0.7);
--glass-caustic: rgb(255 255 255 / 0.3);
--glass-bloom: color-mix(in srgb, var(--brand-900) 35%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.22) 0%,
rgb(255 255 255 / 0.06) 40%,
transparent 64%
),
linear-gradient(180deg, rgb(255 255 255 / 0.16) 0%, rgb(255 255 255 / 0.07) 100%);
}
/* cast navy glass, thick enough to read as a slab (footer) */
@utility glass-navy {
--glass-rim: color-mix(in srgb, var(--glass-edge) 42%, transparent);
--glass-ring: rgb(255 255 255 / 0.12);
--glass-ring-lit: rgb(255 255 255 / 0.2);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 34%, transparent);
--glass-bloom: color-mix(in srgb, var(--navy) 32%, transparent);
background-image:
linear-gradient(
135deg,
color-mix(in srgb, var(--glass-spec) 13%, transparent) 0%,
color-mix(in srgb, var(--glass-spec) 3%, transparent) 26%,
transparent 52%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--navy) 88%, transparent) 0%,
color-mix(in srgb, var(--navy) 96%, transparent) 72%,
color-mix(in srgb, var(--navy) 98%, transparent) 100%
);
}
@utility glass-danger {
--glass-rim: rgb(255 255 255 / 0.4);
--glass-ring: rgb(255 255 255 / 0.25);
--glass-ring-lit: rgb(255 255 255 / 0.42);
--glass-caustic: rgb(255 215 216 / 0.45);
--glass-bloom: color-mix(in srgb, var(--destructive) 45%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.28) 0%,
rgb(255 255 255 / 0.06) 34%,
transparent 62%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--destructive) 94%, transparent) 0%,
color-mix(in srgb, var(--destructive) 99%, transparent) 100%
);
}
/* hover/active: the face turns further into the light */
@utility glass-lit {
--glass-rim: color-mix(in srgb, var(--glass-spec) 90%, transparent);
--glass-ring: var(--glass-ring-lit);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 92%, transparent);
background-color: color-mix(in srgb, var(--glass-spec) 16%, transparent);
}