diff --git a/apps/kb/public/textures/book-cover.jpg b/apps/kb/public/textures/book-cover.jpg new file mode 100644 index 00000000000..51884de9824 Binary files /dev/null and b/apps/kb/public/textures/book-cover.jpg differ diff --git a/apps/kb/src/components/FeaturedBookshelf.tsx b/apps/kb/src/components/FeaturedBookshelf.tsx new file mode 100644 index 00000000000..517c75642eb --- /dev/null +++ b/apps/kb/src/components/FeaturedBookshelf.tsx @@ -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) => ( +
+
+ +
+ + +
+) + +const BookCard = ({ guide }: BookCardProps) => { + const topic = pickCoverTopic(guide.topics) + const { titleAt, boxes } = topic ? ISO_COMPOSITIONS[topic] : FALLBACK_COMPOSITION + const isTitleAtBottom = titleAt === 'bottom' + + return ( + +
+
+ {topic && topic !== guide.title ? ( + + {topic} + + ) : null} + + {guide.title} + +
+ + +
+
+

+ {guide.title} +

+

{guide.description}

+
+
+ ) +} + +const IsoDrawing = ({ boxes, className }: IsoDrawingProps) => { + const projected = paintOrder(boxes).map(projectBox) + const { x, y, width, height } = compositionBounds(projected) + + return ( + + + {projected.flatMap((box) => + box.guides.map(([from, to]) => ( + + )) + )} + + {projected.map((box) => ( + + {box.faces.map((face) => ( + + ))} + + {box.hiddenEdges.map(([from, to]) => ( + + ))} + + + {box.faces.map((face) => ( + + ))} + {box.insets.map((inset) => ( + + ))} + + + ))} + + ) +} + +const GrainFilter = () => ( + + + + + + + + + + +) diff --git a/apps/kb/src/components/FeaturedGuides.tsx b/apps/kb/src/components/FeaturedGuides.tsx new file mode 100644 index 00000000000..977dffe1a49 --- /dev/null +++ b/apps/kb/src/components/FeaturedGuides.tsx @@ -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' ? : +} + +const FeaturedCarousel = ({ guides, className }: FeaturedGuidesProps) => { + const middleIndex = Math.floor(guides.length / 2) + const [activeIndex, setActiveIndex] = useState(middleIndex) + const trackRef = useRef(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 ( +
+
+ +
+
+
+
+ {guides.map((guide, index) => ( + + ))} +
+
+ ) +} + +const CoverSlide = ({ guide, position, total, isActive }: CoverSlideProps) => { + const titleRef = useRef(null) + const descriptionRef = useRef(null) + const ctaRef = useRef(null) + const getField = (params: FieldParams) => + coverField({ ...params, topic: pickCoverTopic(guide.topics) }) + + return ( + + ) +} diff --git a/apps/kb/src/layouts/Layout.astro b/apps/kb/src/layouts/Layout.astro index 30b0d827ae6..50e5276beae 100644 --- a/apps/kb/src/layouts/Layout.astro +++ b/apps/kb/src/layouts/Layout.astro @@ -65,7 +65,7 @@ const { title, description } = Astro.props {title} | Supabase Knowledge Base diff --git a/apps/kb/src/lib/ascii/cover-shapes.ts b/apps/kb/src/lib/ascii/cover-shapes.ts new file mode 100644 index 00000000000..4687ba04579 --- /dev/null +++ b/apps/kb/src/lib/ascii/cover-shapes.ts @@ -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 = { + 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 diff --git a/apps/kb/src/lib/iso-shapes.ts b/apps/kb/src/lib/iso-shapes.ts new file mode 100644 index 00000000000..58331a4194d --- /dev/null +++ b/apps/kb/src/lib/iso-shapes.ts @@ -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 = { + 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)] } diff --git a/apps/kb/src/lib/reveal.ts b/apps/kb/src/lib/reveal.ts new file mode 100644 index 00000000000..aa98f19a93d --- /dev/null +++ b/apps/kb/src/lib/reveal.ts @@ -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 }) diff --git a/apps/kb/src/styles/globals.css b/apps/kb/src/styles/globals.css index df11978a63c..0a3b4210792 100644 --- a/apps/kb/src/styles/globals.css +++ b/apps/kb/src/styles/globals.css @@ -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; + } +}