mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 04:45:08 +03:00
feat(kb): add featured guides carousel and bookshelf variants
This commit is contained in:
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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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)] }
|
||||
@@ -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 })
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user