feat(kb): add featured guides carousel and bookshelf variants

This commit is contained in:
anthony lio committed 2026-10-01 20:44:07 +03:00
1 parent d236171bc8
commit 3aaea70988
8 files changed
+907 -1

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -0,0 +1,200 @@
import { cn } from 'ui'
import { pickCoverTopic } from '../lib/ascii/cover-shapes'
import type { FeaturedGuide } from '../lib/guides'
import {
compositionBounds,
FALLBACK_COMPOSITION,
ISO_COMPOSITIONS,
paintOrder,
projectBox,
type IsoBox,
type IsoPoint,
} from '../lib/iso-shapes'
import { bindRevealOnce, REVEAL_GROUP_CLASS, REVEAL_ITEM_CLASS, revealStyle } from '../lib/reveal'
export interface FeaturedBookshelfProps {
guides: FeaturedGuide[]
className?: string
}
interface BookCardProps {
guide: FeaturedGuide
}
interface IsoDrawingProps {
boxes: IsoBox[]
className?: string
}
const COVER_TEXTURE_STYLE = {
backgroundImage: `url(${import.meta.env.BASE_URL}/textures/book-cover.jpg)`,
}
const GRAIN_FILTER_ID = 'kb-book-grain'
const GRAIN_THRESHOLD = 0.42
const GRAIN_CONTRAST = 9
const RED_TO_ALPHA = '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0'
const toPoints = (points: IsoPoint[]) => points.map(([px, py]) => `${px},${py}`).join(' ')
const segment = ([x1, y1]: IsoPoint, [x2, y2]: IsoPoint) => ({ x1, y1, x2, y2 })
export const FeaturedBookshelf = ({ guides, className }: FeaturedBookshelfProps) => (
<section aria-labelledby="featured-guides" className={cn('flex flex-col gap-8', className)}>
<header className="mx-auto flex w-full max-w-6xl px-6">
<h2
id="featured-guides"
className="m-0 font-sans! text-xl font-medium! tracking-[-0.015em] text-foreground"
>
Featured guides
</h2>
</header>
<ul
ref={bindRevealOnce}
className={cn(
'mx-auto grid w-full max-w-6xl list-none gap-x-16 gap-y-12 px-6 sm:grid-cols-2 lg:grid-cols-3 lg:gap-x-28',
REVEAL_GROUP_CLASS
)}
>
{guides.map((guide, index) => (
<li key={guide.slot} style={revealStyle(index)} className={REVEAL_ITEM_CLASS}>
<BookCard guide={guide} />
</li>
))}
</ul>
<GrainFilter />
</section>
)
const BookCard = ({ guide }: BookCardProps) => {
const topic = pickCoverTopic(guide.topics)
const { titleAt, boxes } = topic ? ISO_COMPOSITIONS[topic] : FALLBACK_COMPOSITION
const isTitleAtBottom = titleAt === 'bottom'
return (
<a
href={guide.href}
className="group flex flex-col gap-6 rounded-[10px] text-inherit no-underline focus-ring"
>
<article
style={COVER_TEXTURE_STYLE}
className={cn(
'relative flex h-92 overflow-hidden bg-alternative',
'bg-cover bg-center bg-blend-color-dodge',
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.07),0_20px_36px_rgb(0_0_0/0.5),0_2px_4px_rgb(0_0_0/0.25)]',
'transition-transform duration-200 ease-out motion-safe:group-hover:-translate-y-1',
isTitleAtBottom ? 'flex-col-reverse' : 'flex-col'
)}
>
<div
aria-hidden
className={cn('flex shrink-0 flex-col gap-2 px-6', isTitleAtBottom ? 'pb-6' : 'pt-7')}
>
{topic && topic !== guide.title ? (
<span className="font-mono text-[11px] uppercase tracking-[0.08em] text-foreground-lighter">
{topic}
</span>
) : null}
<span className="font-serif text-[29px] leading-8 tracking-[-0.015em] text-balance text-foreground">
{guide.title}
</span>
</div>
<IsoDrawing boxes={boxes} className="min-h-0 w-full flex-1" />
<span
aria-hidden
className="absolute inset-y-0 left-0 w-2.5 bg-linear-to-r from-white/10 via-white/3 via-60% to-black/25"
/>
</article>
<div className="flex flex-col gap-2">
<h3 className="m-0 font-sans! text-lg font-medium! leading-6.5 tracking-[-0.012em] text-foreground">
{guide.title}
</h3>
<p className="m-0 text-sm leading-5.5 text-foreground-light">{guide.description}</p>
</div>
</a>
)
}
const IsoDrawing = ({ boxes, className }: IsoDrawingProps) => {
const projected = paintOrder(boxes).map(projectBox)
const { x, y, width, height } = compositionBounds(projected)
return (
<svg
aria-hidden
viewBox={`${x} ${y} ${width} ${height}`}
preserveAspectRatio="xMidYMid meet"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
'[filter:url(#kb-book-grain)] [&_*]:[vector-effect:non-scaling-stroke]',
className
)}
>
<g className="stroke-foreground-muted/40" strokeWidth={0.6}>
{projected.flatMap((box) =>
box.guides.map(([from, to]) => (
<line key={`${box.key}-${from.join()}`} {...segment(from, to)} />
))
)}
</g>
{projected.map((box) => (
<g key={box.key}>
{box.faces.map((face) => (
<polygon
key={face.join()}
points={toPoints(face)}
className="fill-background-alternative"
/>
))}
<g className="stroke-foreground-muted/50" strokeWidth={0.7}>
{box.hiddenEdges.map(([from, to]) => (
<line key={to.join()} {...segment(from, to)} />
))}
</g>
<g className="stroke-foreground-light" strokeWidth={1.6}>
{box.faces.map((face) => (
<polygon key={face.join()} points={toPoints(face)} />
))}
{box.insets.map((inset) => (
<polygon key={inset.join()} points={toPoints(inset)} strokeWidth={1.1} />
))}
</g>
</g>
))}
</svg>
)
}
const GrainFilter = () => (
<svg aria-hidden className="absolute size-0">
<filter
id={GRAIN_FILTER_ID}
x="0"
y="0"
width="100%"
height="100%"
colorInterpolationFilters="sRGB"
>
<feTurbulence
type="fractalNoise"
baseFrequency="0.75"
numOctaves={2}
seed={20261001}
stitchTiles="stitch"
result="noise"
/>
<feColorMatrix in="noise" type="matrix" values={RED_TO_ALPHA} result="field" />
<feComponentTransfer in="field" result="wear">
<feFuncA
type="linear"
slope={-GRAIN_CONTRAST}
intercept={GRAIN_THRESHOLD * GRAIN_CONTRAST}
/>
</feComponentTransfer>
<feComposite operator="out" in="SourceGraphic" in2="wear" />
</filter>
</svg>
)
+215
View File
@@ -0,0 +1,215 @@
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { Button, cn } from 'ui'
import { coverField, pickCoverTopic } from '../lib/ascii/cover-shapes'
import {
CELL_HEIGHT,
CELL_WIDTH,
clamp,
fitEdges,
GLYPH_PITCH,
splitEdges,
uniformEdges,
type Lattice,
} from '../lib/ascii/field'
import type { FeaturedGuide } from '../lib/guides'
import { useQueryVariant } from '../lib/use-query-variant'
import { AsciiCanvas, type CanvasSize, type FieldParams } from './AsciiCanvas'
import { FeaturedBookshelf } from './FeaturedBookshelf'
export interface FeaturedGuidesProps {
guides: FeaturedGuide[]
className?: string
}
interface CoverSlideProps {
guide: FeaturedGuide
position: number
total: number
isActive: boolean
}
interface CenteredScrollLeftParams {
track: HTMLElement
slide: HTMLElement
}
const CENTER_LINE_MARGIN = '0px -50% 0px -50%'
const COVER_WAKE_RADIUS = 100
const FEATURED_VARIANTS = ['carousel', 'book'] as const
const getCoverGrid = ({ width, height }: CanvasSize): Lattice => ({
xs: fitEdges({ size: width, target: CELL_WIDTH }),
ys: uniformEdges({ size: height, step: CELL_HEIGHT }),
})
const getCoverLattice = (size: CanvasSize): Lattice => {
const grid = getCoverGrid(size)
return { xs: splitEdges({ edges: grid.xs, target: GLYPH_PITCH }), ys: grid.ys }
}
const centeredScrollLeft = ({ track, slide }: CenteredScrollLeftParams) =>
slide.offsetLeft - (track.clientWidth - slide.offsetWidth) / 2
export const FeaturedGuides = (props: FeaturedGuidesProps) => {
const variant = useQueryVariant({
key: 'featured',
options: FEATURED_VARIANTS,
fallback: 'carousel',
})
return variant === 'book' ? <FeaturedBookshelf {...props} /> : <FeaturedCarousel {...props} />
}
const FeaturedCarousel = ({ guides, className }: FeaturedGuidesProps) => {
const middleIndex = Math.floor(guides.length / 2)
const [activeIndex, setActiveIndex] = useState(middleIndex)
const trackRef = useRef<HTMLDivElement | null>(null)
const targetIndexRef = useRef(middleIndex)
const bindTrack = useCallback((track: HTMLDivElement | null) => {
trackRef.current = track
if (!track) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const index = Array.prototype.indexOf.call(track.children, entry.target)
targetIndexRef.current = index
setActiveIndex(index)
}
},
{ root: track, rootMargin: CENTER_LINE_MARGIN, threshold: 0 }
)
const middle = track.children[Math.floor(track.children.length / 2)]
if (middle instanceof HTMLElement)
track.scrollLeft = centeredScrollLeft({ track, slide: middle })
for (const slide of track.children) observer.observe(slide)
return () => observer.disconnect()
}, [])
const scrollToSlide = (index: number) => {
const track = trackRef.current
const target = clamp(index, 0, guides.length - 1)
const slide = track?.children[target]
if (!track || !(slide instanceof HTMLElement)) return
targetIndexRef.current = target
const isReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
track.scrollTo({
left: centeredScrollLeft({ track, slide }),
behavior: isReducedMotion ? 'auto' : 'smooth',
})
}
const handlePreviousClick = () => scrollToSlide(targetIndexRef.current - 1)
const handleNextClick = () => scrollToSlide(targetIndexRef.current + 1)
if (guides.length === 0) return null
return (
<section
aria-roledescription="carousel"
aria-labelledby="featured-guides"
className={cn('flex flex-col gap-8', className)}
>
<header className="mx-auto flex w-full max-w-6xl items-end justify-between px-6">
<h2
id="featured-guides"
className="m-0 font-sans! text-xl font-medium! tracking-[-0.015em] text-foreground"
>
Featured guides
</h2>
<div className="flex items-center gap-2">
<Button
variant="default"
size="tiny"
aria-label="Previous guide"
disabled={activeIndex === 0}
onClick={handlePreviousClick}
icon={<ArrowLeft aria-hidden strokeWidth={2.25} />}
/>
<Button
variant="default"
size="tiny"
aria-label="Next guide"
disabled={activeIndex === guides.length - 1}
onClick={handleNextClick}
icon={<ArrowRight aria-hidden strokeWidth={2.25} />}
/>
</div>
</header>
<div
ref={bindTrack}
className={cn(
'relative -my-2 flex snap-x snap-mandatory gap-6 overflow-x-auto overscroll-x-contain py-2',
'px-[max(1.5rem,calc((100%-69rem)/2))] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
)}
>
{guides.map((guide, index) => (
<CoverSlide
key={guide.slot}
guide={guide}
position={index + 1}
total={guides.length}
isActive={index === activeIndex}
/>
))}
</div>
</section>
)
}
const CoverSlide = ({ guide, position, total, isActive }: CoverSlideProps) => {
const titleRef = useRef<HTMLDivElement>(null)
const descriptionRef = useRef<HTMLParagraphElement>(null)
const ctaRef = useRef<HTMLDivElement>(null)
const getField = (params: FieldParams) =>
coverField({ ...params, topic: pickCoverTopic(guide.topics) })
return (
<article
aria-roledescription="slide"
aria-label={`${position} of ${total}: ${guide.title}`}
inert={!isActive}
className={cn(
'relative h-120 w-[min(69rem,calc(100vw-3rem))] shrink-0 snap-center overflow-hidden',
'rounded-lg bg-surface-75 shadow-codeblock',
"after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-[inherit] after:border after:border-default after:content-['']"
)}
>
<AsciiCanvas
getLattice={getCoverLattice}
getGridLattice={getCoverGrid}
getField={getField}
keepOutRefs={[titleRef, descriptionRef, ctaRef]}
wakeRadius={COVER_WAKE_RADIUS}
isActive={isActive}
/>
<div className="absolute inset-x-0 bottom-0 flex flex-col sm:flex-row sm:items-end">
<div ref={titleRef} className="min-w-0 bg-surface-75 px-6 pb-3 pt-6 sm:flex-1 sm:pb-6">
<h3 className="m-0 font-sans! text-3xl font-medium! leading-8 tracking-[-0.025em] text-foreground sm:max-w-95 sm:text-[40px] sm:leading-11">
{guide.title}
</h3>
</div>
<p
ref={descriptionRef}
className={cn(
'm-0 bg-surface-75 px-6 pb-4 text-sm leading-5.5 text-foreground-light',
'sm:w-100 sm:shrink-0 sm:p-6'
)}
>
{guide.description}
</p>
<div
ref={ctaRef}
className="bg-surface-75 px-6 pb-6 sm:flex sm:w-50 sm:shrink-0 sm:justify-end sm:pt-6"
>
<Button asChild variant="secondary" size="small">
<a href={guide.href}>{guide.actionLabel ?? 'Read guide'}</a>
</Button>
</div>
</div>
</article>
)
}
+1 -1
View File
@@ -65,7 +65,7 @@ const { title, description } = Astro.props
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=Newsreader:opsz,wght@6..72,400&display=swap"
/>
<title>{title} | Supabase Knowledge Base</title>
</head>
+280
View File
@@ -0,0 +1,280 @@
import type { Topic } from '../topics'
import { createRng, latticeSize, type Lattice } from './field'
interface ShapeGrid {
cols: number
rows: number
put: (c: number, r: number, density: number) => void
}
interface Stage extends ShapeGrid {
rand: () => number
cellWidth: number
cellHeight: number
at: (u: number, v: number) => [number, number]
line: (from: [number, number], to: [number, number], density: number) => void
}
type Shape = (stage: Stage) => void
interface CoverFieldParams {
lattice: Lattice
seed: number
mask: Uint8Array
topic: Topic | null
}
const STAGE = { left: 0.06, right: 0.8, top: 0.04, bottom: 0.62 }
const DROPOUT = 0.08
const SCATTER_CHANCE = 0.06
const SCATTER_DENSITY = 0.12
const FORMAT_TOPICS: Topic[] = ['Migration', 'Comparison', 'Troubleshooting', 'Tutorial']
const ramp = (from: number, to: number, t: number) => from + (to - from) * t
const firstBlockedRow = (mask: Uint8Array, cols: number, rows: number) => {
for (let r = 0; r < rows; r++) {
const blocked = mask.subarray(r * cols, (r + 1) * cols).reduce((sum, cell) => sum + cell, 0)
if (blocked > cols / 2) return r - 1
}
return rows
}
const policyWall: Shape = ({ at, line, put, rand }) => {
const [wallC, wallTop] = at(0.56, 0)
const [, wallBottom] = at(0, 1)
for (let r = wallTop; r <= wallBottom; r++) {
put(wallC, r, 1)
put(wallC + 1, r, 1)
}
const [startC] = at(0, 0)
const [endC] = at(1, 0)
for (let r = wallTop + 1, k = 0; r <= wallBottom; r += 2, k++) {
for (let c = startC; c < wallC; c++)
put(c, r, ramp(0.25, 0.75, (c - startC) / (wallC - startC)))
if (k % 3 === 1 || rand() < 0.15) line([wallC + 2, r], [endC - 1, r], 0.75)
if (k % 3 === 1) put(endC, r, 1)
}
}
const bTree: Shape = ({ at, line, put }) => {
const block = ([c, r]: [number, number]) => {
put(c, r, 1)
put(c + 1, r, 1)
put(c, r + 1, 1)
put(c + 1, r + 1, 1)
}
const root = at(0.5, 0)
const mids = [at(0.2, 0.32), at(0.5, 0.32), at(0.8, 0.32)]
block(root)
for (const mid of mids) {
block(mid)
line([root[0], root[1] + 2], [mid[0], mid[1] - 1], 0.6)
}
const leafRow = at(0, 0.72)[1]
mids.forEach((mid, m) => {
for (const offset of [-0.1, 0, 0.1]) {
const [leafC] = at(0.2 + m * 0.3 + offset, 0)
line([mid[0], mid[1] + 2], [leafC, leafRow - 1], 0.45)
put(leafC, leafRow, 1)
put(leafC, leafRow + 1, 0.25)
}
})
const [listStart, listRow] = at(0, 0.95)
const [listEnd] = at(1, 0)
line([listStart, listRow], [listEnd, listRow], 0.5)
put(listEnd + 1, listRow, 1)
}
const rings: Shape = ({ at, put, cols, rows, rand, cellWidth, cellHeight }) => {
const [cx, cy] = at(0.5, 0.5)
const spacing = Math.max(cellWidth, cellHeight) * 1.2
const thickness = Math.max(cellWidth, cellHeight) * 0.55
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const distance = Math.hypot((c - cx) * cellWidth, (r - cy) * cellHeight)
for (let k = 0; k < 7; k++) {
if (Math.abs(distance - (k + 1) * spacing) < thickness) put(c, r, 1 - k * 0.12)
}
}
}
put(cx, cy, 1)
for (let i = 0; i < 4; i++) {
const angle = rand() * Math.PI * 2
const radius = (2 + Math.floor(rand() * 5)) * spacing
put(
Math.round(cx + (radius * Math.cos(angle)) / cellWidth),
Math.round(cy + (radius * Math.sin(angle)) / cellHeight),
1
)
}
}
const tableToTable: Shape = ({ at, line, put }) => {
const grid = (u0: number, u1: number, density: number) => {
const [c0, r0] = at(u0, 0.1)
const [c1, r1] = at(u1, 0.9)
for (let r = r0; r <= r1; r += 2) for (let c = c0; c <= c1; c++) put(c, r, density)
}
grid(0, 0.24, 0.75)
grid(0.76, 1, 1)
for (const v of [0.1, 0.42, 0.74]) {
const [from, row] = at(0.27, v)
const [to] = at(0.72, v)
line([from, row], [to, row], 0.3)
put(to, row, 0.9)
}
}
const stackedBuckets: Shape = ({ at, line }) => {
for (let i = 0; i < 3; i++) {
const [c0, r0] = at(0.1 + i * 0.16, 0.05 + i * 0.12)
const [c1, r1] = at(0.55 + i * 0.16, 0.6 + i * 0.12)
const density = ramp(0.4, 1, i / 2)
line([c0, r0], [c1, r0], density)
line([c0, r0], [c0, r1], density)
line([c1, r0], [c1, r1], density)
line([c0, r1], [c1, r1], density)
}
}
const linkedNodes: Shape = ({ at, line, put, rand }) => {
const center = at(0.5, 0.5)
for (let i = 0; i < 9; i++) {
const angle = (i / 9) * Math.PI * 2 + rand() * 0.4
const node = at(0.5 + Math.cos(angle) * 0.45, 0.5 + Math.sin(angle) * 0.45)
line(center, node, 0.3)
put(node[0], node[1], 1)
put(node[0] + 1, node[1], 0.8)
}
put(center[0], center[1], 1)
put(center[0] + 1, center[1], 1)
}
const conveyor: Shape = ({ at, line, put }) => {
const [startC, row] = at(0, 0.5)
const [endC] = at(1, 0)
line([startC, row + 2], [endC, row + 2], 0.25)
for (let c = startC, k = 0; c < endC - 2; c += 4, k++) {
const density = ramp(1, 0.25, k / 10)
for (let dc = 0; dc < 2; dc++) for (let dr = 0; dr < 2; dr++) put(c + dc, row + dr - 1, density)
}
put(endC, row + 2, 1)
}
const staircase: Shape = ({ at, put }) => {
for (let step = 0; step < 6; step++) {
const [c0, r0] = at(step / 6, 1 - (step + 1) / 6)
const [c1, r1] = at((step + 1) / 6, 1)
for (let c = c0; c < c1; c++) put(c, r0, 1)
for (let r = r0; r <= r1; r += 2)
for (let c = c0; c < c1; c += 2) put(c, r, ramp(0.25, 0.6, step / 5))
}
}
const brokenPath: Shape = ({ at, line, put }) => {
const points: [number, number][] = [
[0, 0.8],
[0.2, 0.3],
[0.38, 0.7],
[0.5, 0.45],
]
for (let i = 0; i < points.length - 1; i++) line(at(...points[i]), at(...points[i + 1]), 0.75)
line(at(0.62, 0.35), at(0.8, 0.75), 0.45)
line(at(0.8, 0.75), at(1, 0.15), 0.45)
const [mc, mr] = at(0.56, 0.42)
for (let d = -1; d <= 1; d++) {
put(mc + d, mr + d, 1)
put(mc + d, mr - d, 1)
}
}
const pairedColumns: Shape = ({ at, put }) => {
const heights = [0.55, 0.8, 0.35, 0.95, 0.6, 0.75]
heights.forEach((height, i) => {
const [c] = at(0.04 + i * 0.17, 0)
const [, bottom] = at(0, 1)
const [, top] = at(0, 1 - height)
const [, pairTop] = at(0, 1 - height * 0.7)
for (let r = top; r <= bottom; r++) put(c, r, 1)
for (let r = pairTop; r <= bottom; r++) put(c + 2, r, 0.35)
})
}
const stackedLayers: Shape = ({ at, line }) => {
for (let i = 0; i < 4; i++) {
const v = 0.12 + i * 0.26
const skew = 0.08
const density = ramp(1, 0.35, i / 3)
line(at(skew, v - 0.12), at(1, v - 0.12), density)
line(at(0, v), at(1 - skew, v), density)
line(at(skew, v - 0.12), at(0, v), density)
line(at(1, v - 0.12), at(1 - skew, v), density)
}
}
const hatch: Shape = ({ cols, rows, put }) => {
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++) if ((c + r) % 6 === 0) put(c, r, 0.4)
}
export const pickCoverTopic = (topics: Topic[]): Topic | null =>
topics.find((topic) => !FORMAT_TOPICS.includes(topic)) ?? topics[0] ?? null
export const coverField = ({ lattice, seed, mask, topic }: CoverFieldParams): Float32Array => {
const { cols, rows } = latticeSize(lattice)
const field = new Float32Array(cols * rows)
if (cols === 0 || rows === 0) return field
const rand = createRng(seed)
const put = (c: number, r: number, density: number) => {
const col = Math.round(c)
const row = Math.round(r)
if (col < 0 || row < 0 || col >= cols || row >= rows) return
field[row * cols + col] = Math.max(field[row * cols + col], density)
}
const stageLeft = STAGE.left * cols
const stageTop = STAGE.top * rows
const stageWidth = (STAGE.right - STAGE.left) * cols
const stageHeight = Math.max(
1,
Math.min(STAGE.bottom * rows, firstBlockedRow(mask, cols, rows)) - stageTop
)
const at = (u: number, v: number): [number, number] => [
Math.round(stageLeft + u * stageWidth),
Math.round(stageTop + v * stageHeight),
]
const line = ([c0, r0]: [number, number], [c1, r1]: [number, number], density: number) => {
const steps = Math.max(Math.abs(c1 - c0), Math.abs(r1 - r0), 1)
for (let i = 0; i <= steps; i++) put(ramp(c0, c1, i / steps), ramp(r0, r1, i / steps), density)
}
const shape = topic ? COVER_SHAPES[topic] : FALLBACK_SHAPE
const cellWidth = (lattice.xs[cols] - lattice.xs[0]) / cols
const cellHeight = (lattice.ys[rows] - lattice.ys[0]) / rows
shape({ cols, rows, put, rand, at, line, cellWidth, cellHeight })
for (let i = 0; i < field.length; i++) {
if (mask[i]) field[i] = 0
else if (field[i] > 0 && rand() < DROPOUT) field[i] = 0
else if (field[i] === 0 && rand() < SCATTER_CHANCE) field[i] = SCATTER_DENSITY
}
return field
}
export const COVER_SHAPES: Record<Topic, Shape> = {
Migration: tableToTable,
Comparison: pairedColumns,
Troubleshooting: brokenPath,
Tutorial: staircase,
Storage: stackedBuckets,
Auth: policyWall,
Database: bTree,
'Edge Functions': linkedNodes,
Queues: conveyor,
Realtime: rings,
'Supabase Platform': stackedLayers,
}
const FALLBACK_SHAPE = hatch
+177
View File
@@ -0,0 +1,177 @@
import type { Topic } from './topics'
export type IsoPoint = [number, number]
export interface IsoBox {
x: number
y: number
z: number
w: number
d: number
h: number
inset?: 'front' | 'top'
}
export interface IsoComposition {
boxes: IsoBox[]
titleAt: 'top' | 'bottom'
}
export interface ProjectedBox {
key: string
faces: IsoPoint[][]
hiddenEdges: [IsoPoint, IsoPoint][]
insets: IsoPoint[][]
guides: [IsoPoint, IsoPoint][]
}
const COS = Math.cos(Math.PI / 6)
const SIN = 0.5
const GUIDE_REACH = 1.6
const INSET_MARGIN = 0.18
const INSET_DEPTH = 0.12
export const isoPoint = (x: number, y: number, z: number): IsoPoint => [
(x - y) * COS,
(x + y) * SIN - z,
]
const cube = (x: number, y: number, z: number, size: number): IsoBox => ({
x,
y,
z,
w: size,
d: size,
h: size,
})
export const paintOrder = (boxes: IsoBox[]): IsoBox[] =>
[...boxes].sort(
(a, b) => a.x + a.w / 2 + a.y + a.d / 2 + a.z - (b.x + b.w / 2 + b.y + b.d / 2 + b.z)
)
export const projectBox = (box: IsoBox): ProjectedBox => {
const { x, y, z, w, d, h, inset } = box
const p = (px: number, py: number, pz: number) => isoPoint(px, py, pz)
const right = [p(x + w, y, z), p(x + w, y + d, z), p(x + w, y + d, z + h), p(x + w, y, z + h)]
const left = [p(x, y + d, z), p(x + w, y + d, z), p(x + w, y + d, z + h), p(x, y + d, z + h)]
const top = [p(x, y, z + h), p(x + w, y, z + h), p(x + w, y + d, z + h), p(x, y + d, z + h)]
const back = p(x, y, z)
const mw = w * INSET_MARGIN
const md = d * INSET_MARGIN
const mh = h * INSET_MARGIN
const frontInset = (depth: number) => [
p(x + w - depth, y + md, z + mh),
p(x + w - depth, y + d - md, z + mh),
p(x + w - depth, y + d - md, z + h - mh),
p(x + w - depth, y + md, z + h - mh),
]
const topInset = (depth: number) => [
p(x + mw, y + md, z + h - depth),
p(x + w - mw, y + md, z + h - depth),
p(x + w - mw, y + d - md, z + h - depth),
p(x + mw, y + d - md, z + h - depth),
]
const insetFace = inset === 'front' ? frontInset : inset === 'top' ? topInset : null
return {
key: `${x}:${y}:${z}`,
faces: [right, left, top],
hiddenEdges: [
[back, p(x + w, y, z)],
[back, p(x, y + d, z)],
[back, p(x, y, z + h)],
],
insets: insetFace ? [insetFace(0), insetFace(w * INSET_DEPTH)] : [],
guides: [
[p(x - GUIDE_REACH, y + d, z), p(x + w + GUIDE_REACH, y + d, z)],
[p(x + w, y - GUIDE_REACH, z), p(x + w, y + d + GUIDE_REACH, z)],
],
}
}
export const compositionBounds = (boxes: ProjectedBox[], padding = 0.25) => {
const points = boxes.flatMap((box) => box.faces.flat())
const xs = points.map(([px]) => px)
const ys = points.map(([, py]) => py)
const minX = Math.min(...xs)
const minY = Math.min(...ys)
const width = Math.max(...xs) - minX
const height = Math.max(...ys) - minY
const pad = Math.max(width, height) * padding
return { x: minX - pad, y: minY - pad, width: width + pad * 2, height: height + pad * 2 }
}
export const ISO_COMPOSITIONS: Record<Topic, IsoComposition> = {
Auth: {
titleAt: 'bottom',
boxes: [cube(0, 0, 0, 1), { ...cube(0, 0, 1, 1), inset: 'front' }, cube(0, 0, 2, 1)],
},
Database: {
titleAt: 'top',
boxes: Array.from({ length: 5 }, (_, i) => ({
x: i * 0.1,
y: -i * 0.12,
z: i * 0.24,
w: 2,
d: 1.3,
h: 0.16,
})),
},
Realtime: {
titleAt: 'top',
boxes: [{ ...cube(0, 0, 0, 2), inset: 'front' }, cube(2.3, 1.2, 0, 0.7)],
},
Storage: {
titleAt: 'top',
boxes: [0, 1, 2].map((i) => ({ ...cube(0, i * 1.35, 0, 1.1), h: 0.8, inset: 'top' })),
},
'Edge Functions': {
titleAt: 'top',
boxes: [
cube(0, 0, 0, 1),
cube(1.9, 0.3, 0, 0.4),
cube(0.3, 1.9, 0, 0.4),
cube(-1.3, 0.3, 0, 0.4),
cube(0.3, -1.3, 0, 0.4),
],
},
Queues: {
titleAt: 'top',
boxes: [0, 1, 2, 3, 4].map((i) => cube(i * 0.95, 0, 0, 0.7)),
},
Migration: {
titleAt: 'top',
boxes: [
{ ...cube(0, 0, 0, 1), h: 1.2 },
cube(1.5, 0.3, 0.2, 0.4),
{ ...cube(2.6, 0, 0, 1), h: 1.2, inset: 'top' },
],
},
Comparison: {
titleAt: 'bottom',
boxes: [
{ ...cube(0, 0, 0, 0.9), h: 2.2 },
{ ...cube(1.4, 0, 0, 0.9), h: 1.4 },
],
},
Troubleshooting: {
titleAt: 'bottom',
boxes: [
{ ...cube(0, 0, 0, 1), h: 0.8 },
{ ...cube(0.25, -0.15, 0.8, 1), h: 0.8 },
{ ...cube(-0.1, 0.1, 1.6, 1), h: 0.8 },
],
},
Tutorial: {
titleAt: 'top',
boxes: [0, 1, 2, 3].map((i) => ({ x: i * 0.8, y: 0, z: 0, w: 0.8, d: 1.2, h: (i + 1) * 0.45 })),
},
'Supabase Platform': {
titleAt: 'top',
boxes: [0, 1, 2].map((i) => ({ x: 0, y: 0, z: i * 0.55, w: 2.2, d: 2.2, h: 0.18 })),
},
}
export const FALLBACK_COMPOSITION: IsoComposition = { titleAt: 'top', boxes: [cube(0, 0, 0, 1)] }
+26
View File
@@ -0,0 +1,26 @@
import type { CSSProperties } from 'react'
type RevealStyle = CSSProperties & { '--reveal-index': number }
export const REVEAL_GROUP_CLASS = 'group/reveal'
export const REVEAL_ITEM_CLASS = [
'motion-safe:opacity-0 motion-safe:group-data-[revealed=true]/reveal:opacity-100',
'motion-safe:group-data-[revealed=true]/reveal:animate-[reveal-up_500ms_cubic-bezier(0.23,1,0.32,1)_calc(min(var(--reveal-index),8)*60ms)_both]',
].join(' ')
export const bindRevealOnce = (element: HTMLElement | null) => {
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return
element.dataset.revealed = 'true'
observer.disconnect()
},
{ rootMargin: '0px 0px -10% 0px' }
)
observer.observe(element)
return () => observer.disconnect()
}
export const revealStyle = (index: number): RevealStyle => ({ '--reveal-index': index })
+8
View File
@@ -21,6 +21,7 @@
var(--font-inter), Inter, Helvetica Neue, Helvetica, ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-manrope, var(--font-sans));
--font-mono: 'Source Code Pro', ui-monospace, Menlo, monospace;
--font-serif: 'Newsreader', Georgia, serif;
}
/* Same type scale as apps/docs/styles/globals.css — without this, utilities
@@ -115,3 +116,10 @@ h6:not(.font-mono),
.shadow-codeblock {
box-shadow: var(--shadow-codeblock, none);
}
@keyframes reveal-up {
from {
opacity: 0;
translate: 0 12px;
}
}