diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ccc603..8fe2c7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,11 +40,5 @@ jobs: - name: Lint run: pnpm lint - - name: Dead code check - run: pnpm knip - - - name: Test - run: pnpm test - - name: Build run: pnpm build diff --git a/CLAUDE.md b/CLAUDE.md index 846f085..4f3f9e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,173 +1,57 @@ Never run build or tests until I ask manually. -## The mental model (read this first) +## What this app is -**A preset is a hand-designed deck that has been embedded into this app.** Not a theme. Not a CSS skin over a generic template. A complete, intentional, designer-grade slide deck whose compositions live in this repo as JSX and scoped CSS. The only difference from a one-off, hand-coded deck is that the _content_ is variable: it comes in as markdown directives instead of being baked into the JSX. +Internal viewer for Octify case studies. **Bring your own HTML.** Each case study is a folder of hand-authored HTML files (one per slide) under `case-studies//`. The app renders each slide inside a sandboxed iframe at a fixed 1920×1080 canvas, scaled to fit the viewport. There is no editor, no markdown, no theming, no presets. -The bar is the same as the bar for a designer-built deck delivered to a client. Bloomberg Businessweek case studies. Stripe annual reports. Apple keynotes. That level. If a preset's output looks like a "templated theme", the preset is failing its job. +The job of this app is narrow: discover case studies, list them, render them as a deck (viewer + present mode), serve their assets. Nothing else. -The reason this app exists is reuse: design a deck once, render it as many times as needed for as many clients as needed by swapping the markdown content. The design quality is fixed at preset-design time. The content is fluid. +## The mental model -## The product +- **Author** designs slides as HTML somewhere (Astro, hand-coded, whatever). Each slide is a self-contained 1920×1080 document with inline CSS and system fonts. +- **Drop** the folder into `case-studies//` with a `meta.json`. +- **Ship.** The app picks it up, no code change needed. -The user experience is: pick a preset, drop your content in, it looks great, done. No customization panel, no grid editor, no layout options. The design quality is the product. The target user is content-rich and time-poor: consultants, agencies, founders. They should never have to make a visual decision. If you find yourself suggesting a customization option or a user-facing setting, stop. The preset should make that decision for them. +## Authoring contract (the only real rule) -Every preset is its own different design. A future "Bauhaus" preset is not Dossier with different colors. It is its own designed deck with its own grammar, its own type personality, its own decorative atoms, its own bespoke compositions. Two completely different decks that both happen to consume the same markdown directive vocabulary. +Every slide HTML file must: -## What a great preset must deliver +1. Be a complete `` document. +2. Render against a 1920×1080 canvas. `body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }`. +3. Inline its CSS. No external CSS, no Google Fonts, no external scripts, no fetches. System font stacks only. +4. Have a `` tag. -A real client case study has three tiers of slide content. A preset must hand-design Tier 1 and opinionate Tier 2 and 3. +Slide assets go under `case-studies/<slug>/assets/` and are referenced as `assets/<file>` (resolved at runtime by `/c/<slug>/assets/<path>`). -### Tier 1, signature moments (where presets win or lose) +## File map -Roughly 9 slide types. Each is a bespoke composition: hand-tuned JSX with absolute positioning, exact type sizes, decorative atoms, art direction. Generic CSS does not produce these. Bespoke components do. +- `case-studies/<slug>/meta.json`, deck metadata. +- `case-studies/<slug>/*.html`, one file per slide. +- `case-studies/<slug>/assets/*`, optional static assets. +- [src/lib/case-studies.ts](src/lib/case-studies.ts), manifest loader, slide reader, asset reader. Server-only. +- [src/components/SlideFrame.tsx](src/components/SlideFrame.tsx), fixed-canvas iframe with CSS scaling. +- [src/components/Viewer.tsx](src/components/Viewer.tsx), main viewer (chrome + stage + thumb strip). +- [src/components/Present.tsx](src/components/Present.tsx), fullscreen present mode. +- [src/app/page.tsx](src/app/page.tsx), index of case studies. +- [src/app/c/[slug]/page.tsx](src/app/c/[slug]/page.tsx), viewer route. +- [src/app/c/[slug]/present/page.tsx](src/app/c/[slug]/present/page.tsx), present mode route. +- [src/app/c/[slug]/slides/[file]/route.ts](src/app/c/[slug]/slides/[file]/route.ts), slide HTML serving. +- [src/app/c/[slug]/assets/[...path]/route.ts](src/app/c/[slug]/assets/[...path]/route.ts), slide asset serving. -1. Cover. Project title, subhead, client, date, author. First impression. -2. Tear sheet. Client, industry, engagement, duration, team, outcome headline. -3. Section divider. Chapter number, italic oversized chapter title. Used 3 to 5 times in the deck. -4. Hero stat. The one number that matters. Fills 50 to 70 percent of the canvas. -5. KPI grid. 3 to 6 stats with deltas and trend arrows. Optional source caption. -6. Pull quote. Quote, attribution, optional photo. Full bleed. Decorative open-quote glyph. -7. Before / after. Two columns, transformation in one frame. -8. Chart slide. Title, chart, context line, source caption. -9. Closer. Thank you. Contact. Brand mark. Mirrors the cover. +## What this app must NOT grow into -### Tier 2, workhorse layouts (60 to 70 percent of slide count) +- No editor. +- No theming, no presets, no palettes. +- No markdown, no IR, no block components. +- No customization UI of any kind. +- No "easier authoring" shortcuts that let slides skip the BYO HTML contract. -Body slides that carry the narrative between signature moments. CSS scoped to the active preset is enough. They should look professionally consistent, not generic. - -Body (heading plus paragraph plus list). Two-column compare. Three-column principles. Process steps. Timeline. Deliverables. Image with caption. Annotated image. Logo strip. Data table. Agenda. - -### Tier 3, inline blocks (atoms) - -Headings (H1 to H4), paragraph, lead, caption, bulleted list, numbered list, inline stat, inline quote, code, callout (info / warn / success / neutral), inline table, inline chart, image (plain / framed / inline), hairline rule. Generic React components, preset CSS for tone. - -## Vocabulary (lock these meanings) - -A **deck** is the final artifact: a stored slide deck the user edits, presents, and exports. A deck is created by combining a **preset** with an optional **template**. - -### Preset = the hand-designed deck - -A Preset is the design surface. A complete deck design embedded as code in this repo. To deliver one, you ship: - -- 9 signature compositions as bespoke JSX components, each typically 80 to 200 lines. -- Scoped CSS for the workhorse layouts and inline blocks, written under `[data-preset='<id>']` (or for a single-design app, in a single locked CSS file like `src/styles/dossier.css`). -- Decorative atoms the design uses (SVG monograms, hairlines, glyphs). -- A curated demo template chosen to flatter the preset's specific compositions. -- A palette and a default font. - -Adding a new design means designing a new deck end to end. There is no shortcut where generic CSS produces editorial results. The cost of a serious preset is roughly a designer-week. - -The current app ships one design (`src/styles/dossier.css`) and exposes it as multiple `Preset` records that vary palette and font over that locked design. That is a legitimate cheap-reuse path for _variations of the same designed deck_ (Dossier Noir vs Dossier Midnight). It is not a substitute for adding a _new design_. A new design = a new CSS file (e.g. `src/styles/bauhaus.css`), possibly new bespoke JSX components, a new curated demo template. - -Type and registry: [src/app/presets/presets.ts](src/app/presets/presets.ts). Color tokens: [src/themes/palettes/](src/themes/palettes/). Fonts: [src/themes/fonts.ts](src/themes/fonts.ts). Resolver: [src/render/theme-resolver.ts](src/render/theme-resolver.ts). - -### Palette = colors - -A Palette is one full set of dark-mode color tokens (`brand`, `accent`, `surface`, `surfaceMuted`, `text`, `textMuted`, `border`, `success`, `warn`, `danger`). The deck is dark-only by design, so palettes have a single token set, no light/dark split. - -### Template = content - -A Template is content data: the markdown directive body of a starter deck plus metadata (`category`, `slideCount`, `recommendedPresetId`). A template has no design. The same template can be spawned with any preset. - -Type and registry: [src/app/templates/templates.ts](src/app/templates/templates.ts). Markdown bodies live in [src/app/templates/seeds/](src/app/templates/seeds/). - -### Directive = author intent - -The markdown directive vocabulary is what authors learn once and use across all presets. It carries author intent (e.g., "this slide is the hero stat"), and the active preset interprets that intent into its own bespoke composition. - -Directives are tokens like `::cover`, `::section`, `::stat`, `::kpi-grid`, `::quote.big`, `::testimonial`, `::tear-sheet`, `::process-steps`, `::timeline`, `::chart`, `::table`. Authors do not pick layouts or compositions, they pick the _kind of moment_. The preset owns the rest. - -## Current schema state (subject to extension when adding a real second design) - -`ThemeRef`: - -```ts -{ presetId: string; paletteId?: string; fontId?: string; } -``` - -That is the entire per-deck design override. No mode (dark only). No density (one airy scale baked into the resolver). No font slot per role (one font drives display and body, mono is fixed). - -`Preset`: - -```ts -{ id; name; vibe; paletteId; fontId; previewTemplateId; } -``` - -A preset is a named starting combination of palette + font over the existing design. To add a new _design_, you will need to extend this: the renderer's `data-preset` scoping must come back, and the preset record must point at its own CSS file and (where used) its own bespoke React components. - -Block IR is unchanged: heading, text, list, quote, stat, code, box, columns, grid, cell, chart, table, image. The directives expand into block trees in [src/ir/parse.ts](src/ir/parse.ts). - -## Authoring contract - -The author writes markdown. The directive vocabulary is small and stable across presets. The same `::stat{value="38m"}` produces a Hero Stat composition in Dossier and a Hero Stat composition in Bauhaus, both bespoke to that preset's design. The author never picks a "layout style". The author picks intent (cover, section, hero stat, pull quote) and the preset renders. - -## When to write JSX vs CSS - -- **Signature moments (Tier 1)**: bespoke React components per preset. Hand-tuned positioning, exact type sizes, decorative SVG, art direction. Generic CSS will not deliver this tier. -- **Workhorse layouts (Tier 2)**: scoped CSS under `[data-preset='<id>']` is fine. The preset's voice (color, type, hairlines, spacing) applied to shared structural blocks. -- **Inline blocks (Tier 3)**: scoped CSS overrides on top of the default block components. Typography, color, small spacing tweaks. - -If you find yourself trying to make a generic block render a hero-stat by tweaking CSS, stop. Build a bespoke component for the hero stat instead. Generic abstraction at the block level cannot win Tier 1. - -### Layered JSX pattern for Tier 1 components - -Every Tier 1 component has three layers: - -1. **Background layer**: full bleed, decorative atoms, color fills, SVG shapes. This is where the preset's visual personality lives. -2. **Layout shell**: fixed insets from all four sides, baked into the component as design decisions, not exposed as props or configuration. -3. **Content layer**: the variable markdown content positioned within the shell. - -The insets are part of the design. A cover slide for a given preset has specific padding because the designer chose it. That number does not change per deck and is not user-configurable. - -## Anti-patterns to avoid - -- Treating a Preset as a CSS skin over one generic template. It isn't. A preset is a designed deck. -- Trying to deliver Tier 1 with generic CSS. It will look templated. Use bespoke JSX. This has been tried and failed: CSS over shared generic components always produces generic-looking output regardless of how much the CSS is tuned. Bespoke JSX per slide type per preset is not optional for Tier 1. -- Adding "more flexible directives" or "more configuration" to compensate for design that is missing. Configuration cannot capture composition. Hand-design the composition instead. -- Assembling Tier 1 slides from shared component libraries. This produces UI-kit output, not editorial output. A hero stat component shared across presets will look identical across presets with just color and font differences. That is a failure. -- Bundling content (`seed`) on `Preset` or design (`paletteId` / `fontId`) on `Template`. The split is intentional and load-bearing. -- Calling the directive vocabulary "templates" in code or copy. Templates use directives; they aren't directives. -- Combining `Template` and `Preset` into one type. They are different axes (content vs design). -- Reintroducing a `mode` (light/dark) field. The system is dark-only. -- Reintroducing a `density` field on `ThemeRef`. One scale (airy, multiplier 1.35) is the system constant. -- Producing a kitchen-sink demo as the preset preview. The preview should be a curated handful of slides chosen to flatter that preset's specific compositions. - -## File map (current) - -- [src/app/presets/presets.ts](src/app/presets/presets.ts), preset registry. -- [src/app/templates/templates.ts](src/app/templates/templates.ts), template registry. -- [src/app/templates/seeds/](src/app/templates/seeds/), markdown bodies. -- [src/themes/palettes/](src/themes/palettes/), color token sets. -- [src/themes/fonts.ts](src/themes/fonts.ts), font catalog (matches `next/font` imports in [src/app/layout.tsx](src/app/layout.tsx)). -- [src/styles/dossier.css](src/styles/dossier.css), the locked design rules for the current (and only) deck design. Edit here to change the design itself. -- [src/styles/blocks.css](src/styles/blocks.css), [src/styles/layouts.css](src/styles/layouts.css), [src/styles/deck.css](src/styles/deck.css), structural defaults shared across all presets. -- [src/render/theme-resolver.ts](src/render/theme-resolver.ts), turns Preset + Palette + Brand into CSS variables. Spacing, radius, shadow, mono font are fixed system constants here. -- [src/render/ThemeProvider.tsx](src/render/ThemeProvider.tsx), wraps the deck and emits the CSS variables. -- [src/blocks/](src/blocks/), default block React components. -- [src/ir/parse.ts](src/ir/parse.ts), markdown directives to block IR. -- [src/ir/schema.ts](src/ir/schema.ts), IR types and zod schemas. - -## Routes - -- `/presets` lists presets. -- `/templates` lists templates. -- `/new` is the two-step deck creation flow: pick a template (or "Blank deck"), then a preset. -- `/d/<id>/edit` opens the editor. -- `/d/<id>/present` opens presentation mode. - -## Quick mental check before changes - -- Are you about to make a generic abstraction that has to work across all presets? If yes, stop. Bespoke per preset is almost always the right answer. -- Are you adding a directive that is really a layout option in disguise? If yes, the preset should own that composition, not the directive. -- Are you about to write CSS that renders the cover, section divider, hero stat, big quote, or closer? If yes, that is Tier 1. Bespoke JSX is the right tool, not CSS. -- Are you adding a "feature" to compensate for a preset feeling weak? Strengthen the preset's design instead. +If something needs to be different per deck, it lives in the deck's HTML. Not in the app. ## Workflow rules -- Brainstorm before building. For Tier 1 work this is non-negotiable: you must have a slide-by-slide visual description signed off before writing any JSX. Not vague ("clean", "editorial") but specific: exact type sizes, what decorative atoms appear, how space is used, what the slide does NOT include. If you do not have this, stop and ask. Writing Tier 1 code without a concrete design brief always produces generic output. -- One genuinely great preset is worth more than five mediocre ones. Do not register a preset until all 9 Tier 1 slides are solid. A weak cover or a generic hero stat means the preset is not ready to ship. -- Never auto-commit or auto-push. A prior "go ahead" does not carry over. +- Brainstorm before building. Don't auto-implement non-trivial changes. +- Never auto-commit, never auto-push. A prior "go ahead" does not carry over to git. - Commit messages: one line, under 72 characters, imperative voice. No body. No `Co-Authored-By: Claude`. -- Never use an em dash in any output (sentences, lists, headers, code comments). Use a comma, colon, or rephrase instead. +- Never use an em dash anywhere (sentences, lists, headers, code comments). Use a comma, colon, or rephrase. - For tasks that can be parallelized, run agents in parallel. diff --git a/README.md b/README.md index ed64096..eab1f4e 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,76 @@ -<div align="center"> - # stackdeck -**Turn a markdown file into a beautiful slide deck.** -**Switch themes instantly. Export to PDF. No backend, no accounts, no lock-in.** - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![CI](https://github.com/Octify-Technologies/stackdeck/actions/workflows/ci.yml/badge.svg)](https://github.com/Octify-Technologies/stackdeck/actions/workflows/ci.yml) -[![Made with Next.js](https://img.shields.io/badge/Next.js-15-black?logo=next.js)](https://nextjs.org) -[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org) -[![Deployed on Vercel](https://img.shields.io/badge/Deployed-Vercel-black?logo=vercel)](https://stackdeck-seven.vercel.app) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing) - -[Live demo](https://stackdeck-seven.vercel.app) · [Grammar reference](docs/GRAMMAR.md) · [Architecture](docs/ARCHITECTURE.md) · [Changelog](CHANGELOG.md) +Internal viewer for Octify case study decks. Bring your own HTML, render as a deck. -</div> +## How it works ---- +Each case study lives in `case-studies/<slug>/` as a folder of self-contained HTML files (one per slide) plus a `meta.json` describing the deck. The viewer renders each slide inside a sandboxed iframe at a fixed **1920×1080** canvas, scaled to fit the viewport. -## Why stackdeck +There is no editor, no theming layer, no markdown directives. Slides are authored elsewhere (Astro, hand-coded HTML, whatever) and dropped in. -You write in markdown. The slides should follow. +## Authoring contract -Most slide tools force you into a visual editor, lock your content into proprietary formats, or hide your content behind a SaaS account. **stackdeck flips it.** Markdown is the source of truth. The same `.md` file renders under any theme, exports to PDF, and lives in your repo or on your disk forever. +Every slide HTML file must: -<!-- prettier-ignore --> -```md -::cover -# Q4 Review -A look at the year that was. -:: +1. Be a complete `<!doctype html>` document. +2. Render against a **1920×1080** canvas. Set `body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }`. +3. Inline its CSS (no external CSS, no Google Fonts, no external scripts). Use system font stacks. +4. Include a `<title>` tag, used as the slide name. -::slide +Static assets (images, fonts) live in `case-studies/<slug>/assets/` and are served at `/c/<slug>/assets/<path>`. -::stats -::stat{value="$3M" label="ARR" delta="+47%" trend="up"} -::stat{value="71" label="NPS" delta="+9" trend="up"} -::stat{value="12" label="Markets" delta="+5" trend="up"} -:: +## `meta.json` -::slide - -::quote.big -> The future is already here, it is just not evenly distributed. -> -- William Gibson -:: +```json +{ + "slug": "acme-churn", + "title": "How Acme cut churn by 38%", + "client": "Acme Corp", + "industry": "B2B SaaS", + "date": "2026-03-12", + "summary": "One-line summary shown on the index card.", + "tags": ["churn", "lifecycle"], + "cover": "01.html", + "slides": [ + { "file": "01.html", "title": "Cover" }, + { "file": "02.html", "title": "Tear sheet" } + ], + "visibility": "public" +} ``` -That markdown becomes a 3-slide deck with a cover, a 3-stat grid, and a takeover quote. Switch the theme and it reflows in milliseconds. +`slides` is optional. If omitted, all `*.html` files in the folder are picked up in lexicographic order, and each slide title is read from its `<title>` tag. + +## Routes -## Features +- `/` — case studies index +- `/c/<slug>` — viewer (thumbnail strip + main slide + chrome) +- `/c/<slug>/present` — fullscreen present mode +- `/c/<slug>/slides/<file>` — raw slide HTML (iframe source, also openable directly for debugging) +- `/c/<slug>/assets/<path>` — slide static assets -- **Markdown-first.** Plain markdown plus a small set of semantic directives. No new programming language to learn. -- **Theme = Style × Density × Palette × Mode.** Tens of thousands of theme combinations from a small curated atom set. -- **Instant theme switching.** No re-render, no flicker. CSS variable swap on a deck root, sub-frame fast. -- **PDF export via the browser.** `window.print()` plus a careful print stylesheet. Vector text, custom fonts, exact 16:9 page geometry. -- **No backend.** Pure static deploy on Vercel, Cloudflare Pages, GitHub Pages, or anywhere static. Your content lives in your browser (or in a `.md` file you control). -- **Open standards.** TypeScript-strict, Zod-validated IR, semantic versioned grammar, no lock-in. -- **Single source of truth.** One markdown file, every theme, every render path. -- **Production-grade primitives.** 9 atomic blocks, 10 pattern directives, 8 layouts. Tree-shaped IR, exhaustive renderer. +## Keyboard -## Quickstart +| Key | Viewer | Present | +| ------------------ | ------------- | ------------- | +| `→` `Space` `PgDn` | Next | Next | +| `←` `PgUp` | Prev | Prev | +| `Home` / `End` | First / Last | First / Last | +| `1`–`9` | — | Jump to slide | +| `F` | Enter present | — | +| `Esc` | — | Exit | -Requires Node 22 (see [.nvmrc](.nvmrc)) and pnpm 10+. +## Development ```bash -git clone https://github.com/Octify-Technologies/stackdeck.git -cd stackdeck pnpm install pnpm dev ``` -Open `http://localhost:3000`. Edit the markdown on the left, watch the deck update on the right, switch themes from the toolbar, click **Export PDF** when you're ready. - -## What it looks like - -``` -┌──────────────────────┬──────────────────────────────────┐ -│ Markdown source │ Live slide preview │ -│ │ │ -│ ::cover │ ┌──────────────────────────┐ │ -│ # Q4 Review │ │ │ │ -│ :: │ │ Q4 Review │ │ -│ │ │ A look at the year │ │ -│ ::slide │ │ that was. │ │ -│ │ │ │ │ -│ # Highlights │ └──────────────────────────┘ │ -│ │ │ -│ - Revenue up 47% │ ┌──────────────────────────┐ │ -│ - NPS at 71 │ │ Highlights │ │ -│ │ │ • Revenue up 47% │ │ -│ │ │ • NPS at 71 │ │ -│ │ └──────────────────────────┘ │ -└──────────────────────┴──────────────────────────────────┘ - Toolbar: Style ▾ Palette ▾ Density ▾ Mode ▾ Export PDF -``` - -## How it compares - -| | stackdeck | Slidev | Marp | Pitch | -| --------------------------- | --------- | ---------- | ---------- | ----- | -| Markdown-first | ✅ | ✅ | ✅ | ❌ | -| Instant theme switching | ✅ | ⚠️ rebuild | ⚠️ rebuild | ✅ | -| Multi-theme from one source | ✅ | ⚠️ partial | ⚠️ partial | ❌ | -| Self-hosted | ✅ | ✅ | ✅ | ❌ | -| No backend required | ✅ | ✅ | ✅ | ❌ | -| Browser PDF export | ✅ | ⚠️ CLI | ⚠️ CLI | ✅ | -| TypeScript-strict source | ✅ | ✅ | ⚠️ | n/a | - -## Architecture in one paragraph - -A markdown file is parsed into a versioned IR (`Slide = { layout, blocks[] }`). Pattern directives like `::callout` and `::compare` compile to trees of 9 atomic block primitives. Inference picks a layout when the user does not specify one; a deck-level planner enforces position rules (cover at slide 0, no repeated section breaks). The renderer reads CSS variables emitted by the active theme (Style + Palette + Density + Mode), so theme switching is a CSS variable swap, not a React re-render. PDF export uses native `window.print()` with a 1280×720 `@page` rule. - -Full deep-dive in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Grammar spec in [docs/GRAMMAR.md](docs/GRAMMAR.md). - -## Project layout - -``` -src/ - ir/ markdown parser, deck planner, Zod schemas (the IR contract) - blocks/ 9 atomic React primitives, one file each, all token-driven - layouts/ 8 layout definitions (CSS grid + metadata) - themes/ curated Styles + Palettes + registry - render/ ThemeProvider, SlideRenderer, DeckRenderer, PDF export - editor/ the live markdown editor with theme controls - styles/ global CSS that consumes the theme tokens - app/ Next.js routes -docs/ - GRAMMAR.md authoritative markdown grammar spec - ARCHITECTURE.md system overview -tests/ - ir/ deterministic tests for schema, parse, plan - render/ theme resolver tests -``` - -## Develop - -```bash -pnpm dev # start the editor on localhost:3000 -pnpm typecheck # tsc --noEmit -pnpm format # prettier --write -pnpm format:check # prettier --check (used by CI) -pnpm lint # eslint -pnpm knip # detect unused files, exports, dependencies -pnpm test # vitest, single run -pnpm test:watch # vitest, watch mode -pnpm test:coverage # vitest with v8 coverage -pnpm build # next build -``` - -CI runs typecheck, format-check, lint, knip, test, and build on every PR across Node 20 and 22. - -## Roadmap - -- [x] v0.1 — IR, parser, planner, theme system, 9 atomic blocks, 8 layouts, editor, PDF export -- [x] v0.2 — Brand kit, three-pane editor, templates gallery, Editorial + Brutalist Styles -- [x] v1.0 — Deck library at `/`, IndexedDB persistence with auto-save, `/d/[id]/edit` editor, Insert menu for directives, Soft Style, premium per-Style cover treatments -- [ ] v1.1 — CodeMirror editor with `/` directive palette and live syntax highlighting -- [ ] v1.2 — Image support (compressed-on-import, blob storage in IndexedDB) -- [ ] v1.3 — Charts and tables as native primitives -- [ ] v1.4 — Folders / collections; named brand profiles with logo + color presets -- [ ] v1.5 — Theme marketplace via static JSON registry, public stable grammar - -## Contributing - -PRs welcome. Please: - -1. Open an issue describing the change before sending a large PR. -2. Keep IR changes covered by tests in [tests/ir/](tests/ir/). Adding a directive or layout means adding a test. -3. Theme additions: one new file in [src/themes/styles/](src/themes/styles/) plus a registry entry. No code changes elsewhere. -4. Run `pnpm typecheck && pnpm test && pnpm lint` before pushing. - -The architectural boundary is firm: the renderer never sees pattern directives. They compile to atomic block trees in the parser. If your change wants to bend that rule, open an issue first. - -## License - -[MIT](LICENSE). Use it, fork it, ship it. - ---- - -<div align="center"> +## Publishing a case study -If stackdeck saved you time, [drop a star ⭐](https://github.com/Octify-Technologies/stackdeck) — it helps others find the project. +1. Create `case-studies/<slug>/` with a `meta.json` and your HTML files. +2. Open a PR. Merge. +3. Deploy. -</div> +That's it. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 673a89f..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,332 +0,0 @@ -# Architecture - -This document explains how stackdeck turns a markdown file into a rendered slide deck. It is the design contract; if you change something here, the code should follow, and vice versa. - -## Top-level data flow - -``` -┌──────────────┐ parse ┌──────────────┐ plan ┌──────────────┐ -│ │ ───────────▶ │ │ ──────────▶ │ │ -│ Markdown │ │ Deck IR │ │ Planned IR │ -│ (.md file) │ │ (validated) │ │ (validated) │ -│ │ │ │ │ │ -└──────────────┘ └──────────────┘ └──────────────┘ - │ - │ render - ▼ - ┌──────────────────────────────┐ - │ ThemeProvider │ - │ ┌────────────────────────┐ │ - │ │ DeckRenderer │ │ - │ │ ┌──────────────────┐ │ │ - │ │ │ SlideRenderer │ │ │ - │ │ │ ┌────────────┐ │ │ │ - │ │ │ │ BlockRender│ │ │ │ - │ │ │ └────────────┘ │ │ │ - │ │ └──────────────────┘ │ │ - │ └────────────────────────┘ │ - └──────────────────────────────┘ - │ - │ window.print() - ▼ - ┌──────────────┐ - │ PDF file │ - └──────────────┘ -``` - -Five stages, four data shapes. Each stage is pure: same input, same output. Only the editor and the browser print pipeline have side effects. - -## The IR - -The IR (intermediate representation) is the contract between every part of the system. Defined in [src/ir/schema.ts](../src/ir/schema.ts), validated by Zod, type-inferred for TypeScript. - -``` -Deck -├── version "2.0" -├── id ULID -├── title -├── aspectRatio "16:9" -├── theme ThemeRef -├── slides[] Slide -├── createdAt -└── updatedAt - -Slide -├── id ULID -├── layout LayoutId (one of 8) -├── blocks[] Block (one of 9 atomic types) -└── notes? string - -Block (discriminated union, 9 variants) -├── heading { level: 1..4, text } -├── text { emphasis: normal|lead|caption, text } -├── list { ordered, items[] } (recursive) -├── quote { emphasis: normal|big, text, attribution? } -├── stat { value, label?, delta?, trend? } -├── code { language?, content } -├── box { tone?, children: Block[] } (recursive) -├── columns { count: 2|3, columns: Block[][] } (recursive) -└── grid { cols, rows, children: Block[] } (recursive) -``` - -The recursive container blocks (Box, Columns, Grid) hold child blocks, making the IR a tree, not a flat list. This is what makes "put a box inside a column inside a slide" work cleanly. - -## The grammar layer (parser) - -[src/ir/parse.ts](../src/ir/parse.ts) is a single-pass line-tokenizer plus a recursive descent parser. - -``` -markdown source - │ - ▼ -┌─────────────────────────┐ -│ 1. Frontmatter split │ gray-matter splits YAML frontmatter from body -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 2. Slide section split │ split body on `::slide` lines into N sections -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 3. Per-slide tokenize │ classify each line: open / close / colsep / void / text -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 4. Recursive parse │ expand directives, accumulate text into markdown chunks -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 5. Pattern expansion │ ::callout → Box{tone}, ::compare → Columns{2}, etc. -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 6. Markdown → blocks │ marked.lexer for plain-md regions, mapped to Heading/Text/List/Quote/Code -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 7. Layout selection │ explicit > pattern > inferred -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 8. Schema validation │ Zod safeParse on the whole Deck -└─────────────────────────┘ - │ - ▼ - Deck IR -``` - -**The discipline:** the renderer never sees pattern names like `callout` or `compare`. They are compiled away in stage 5. The IR contains only the 9 atomic block types. Adding a new pattern is a parser-only change with zero impact on the renderer or themes. - -## The planner (deck-level pass) - -[src/ir/plan.ts](../src/ir/plan.ts) runs after the parser produces a per-slide IR. It enforces deck-level rules that local parsing cannot: - -| Rule | Effect | -| ------------------------------------------------------------------------ | ------------------------------------ | -| Slide 0 with single H1 and sparse content gets `cover` layout | Even if user did not write `::cover` | -| Slide 0 tagged `cover` but content is too dense gets demoted to `flow` | Honest fallback | -| Mid-deck `cover` layout becomes `section` if it is heading-only | Cover only belongs at slide 0 | -| Two adjacent slides with the same uncommon layout: second becomes `flow` | Avoid two `fullBleed` in a row | - -Inference is local; planning is global. Both produce the same `Deck` shape so the renderer never knows which path made each decision. - -## The theme system - -A theme is composed at runtime from four orthogonal axes: - -``` -Theme = Style × Density × Palette × Mode - -Style typography, radius, shadow, motion, base spacing, color sets (light + dark) -Density multiplier on the spacing scale: dense 0.75, comfortable 1.0, airy 1.35, spacious 1.7 -Palette brand + accent + optional surface/text overrides -Mode light or dark -``` - -The theme resolver ([src/render/theme-resolver.ts](../src/render/theme-resolver.ts)) is a pure function: - -``` -resolveTheme(themeRef, style, palette) -> - { - colors: { brand, accent, surface, surface-muted, text, text-muted, border, success, warn, danger } - cssVars: { --color-brand, --space-md, --radius-lg, --font-display, --shadow-md, ... } - } -``` - -The `cssVars` map is applied as inline `style` on a `.deck-root` element by `ThemeProvider`. Switching theme is a single CSS variable swap, ~40 properties. No React reconciliation. Sub-frame on a 100-slide deck. - -``` -┌──────────────────────────────────────────────────────────────┐ -│ <html style="--font-inter: ..."> │ -│ <body> │ -│ <div class="deck-root" │ -│ data-mode="light" data-density="comfortable" │ -│ style=" │ -│ --color-brand: #2563eb; │ -│ --color-surface: #ffffff; │ -│ --space-md: 8px; │ -│ --font-display: var(--font-inter), ...; │ -│ ... (~40 vars total) │ -│ "> │ -│ <div class="deck"> │ -│ <div class="slide-frame"> │ -│ <section class="slide layout-cover"> │ -│ <h1 class="block block-heading block-h1"> │ -│ Title │ -│ </h1> │ -│ <!-- All blocks read CSS variables only --> │ -│ </section> │ -│ </div> │ -│ ... │ -│ </div> │ -│ </div> │ -│ </body> │ -│ </html> │ -└──────────────────────────────────────────────────────────────┘ -``` - -**The discipline:** atomic block components consume CSS variables only. They never reference hardcoded hex colors, pixel spacing, or font families. This is what makes adding a new theme a token-file change with zero component edits. - -## The renderer - -``` -DeckRenderer - └── ThemeProvider (injects CSS vars) - └── div.deck - └── for each Slide: - └── div.slide-frame (16:9 box) - └── SlideRenderer - └── section.slide.layout-{id} - └── for each Block: - └── BlockRenderer (dispatches by type) - └── Heading | Text | List | Quote | Stat | Code | Box | Columns | Grid -``` - -The dispatcher in [src/blocks/BlockRenderer.tsx](../src/blocks/BlockRenderer.tsx) is exhaustive: TypeScript enforces a `case` for every block type. Adding a new atomic block is a compile-time error until you handle it in the dispatcher. - -## The storage layer - -v0.1 stores the source markdown and theme reference in React state only. It does not persist across reloads. v0.2 will add IndexedDB persistence with auto-save and version history. The persistence module will be `src/storage/`, isolated from the IR and rendering layers. - -## PDF export - -A single button calls `window.print()`. The print stylesheet (in [src/styles/deck.css](../src/styles/deck.css)) does the work: - -```css -@page { - size: 1280px 720px; /* Exact 16:9 */ - margin: 0; -} - -@media print { - .slide-frame { - width: 1280px; - height: 720px; - page-break-after: always; - } - * { - animation: none !important; - transition: none !important; - } -} -``` - -Modern Chrome's print pipeline produces vector text, embeds custom fonts, and respects CSS gradients. The output is a real PDF, not a screenshot. No serverless function needed. - -The honest tradeoffs: - -- The user sees the browser's native print dialog, not a stackdeck-branded one. -- Some browsers (older Safari) handle web fonts differently in print. -- Animations and `position: sticky` are disabled in print by the global rules above. - -## Module boundaries - -``` - ┌─────────────────┐ - │ src/ir/ │ pure logic, no React, no DOM - │ schema.ts │ - │ parse.ts │ - │ plan.ts │ - └────────┬────────┘ - │ (Block, Deck, Slide types) - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────────┐ ┌─────────────┐ - │ blocks/ │ │ layouts/ │ │ themes/ │ - │ 9 React │ │ 8 grid defs │ │ Style+Pal │ - │ comp. │ │ + CSS │ │ registry │ - └─────┬────┘ └──────┬───────┘ └──────┬──────┘ - │ │ │ - └─────────┬─────────┴───────────────────┘ - │ - ▼ - ┌──────────┐ - │ render/ │ ThemeProvider, DeckRenderer, SlideRenderer, ExportPdf - └─────┬────┘ - │ - ▼ - ┌──────────┐ - │ editor/ │ the UI shell - └─────┬────┘ - │ - ▼ - ┌──────────┐ - │ app/ │ Next.js routes - └──────────┘ -``` - -Dependencies only flow downward. `ir/` knows nothing about React. `blocks/` and `themes/` only depend on IR types. `render/` orchestrates. `editor/` is the only place state lives. `app/` is just the Next.js mounting points. - -## Testing strategy - -Deterministic logic gets unit tests. UI gets reviewed by humans. - -| Layer | Tested? | How | -| ---------------- | ------- | ----------------------------------------------- | -| `ir/schema.ts` | Yes | Validators accept good shapes, reject bad ones | -| `ir/parse.ts` | Yes | Each directive, each pattern, full 7-slide e2e | -| `ir/plan.ts` | Yes | Each rule in isolation | -| `theme-resolver` | Yes | Color overrides, density scaling, mode swap | -| Block components | No | Visual review | -| Editor UI | No | Visual review | -| Print stylesheet | No | Manual PDF export check before each Style ships | - -Coverage thresholds (in [vitest.config.ts](../vitest.config.ts)) are enforced on `src/ir/` and `src/render/theme-resolver.ts`: 75% lines, 75% functions, 70% branches, 75% statements. - -## What this architecture buys you - -- **One source, many themes.** Same markdown file, switch theme, get a different look without touching the source. -- **Cheap theme authoring.** A new Style is one TypeScript file in [src/themes/styles/](../src/themes/styles/). Zero changes elsewhere. -- **Cheap pattern authoring.** A new directive is one branch in the parser's pattern expander. Zero changes elsewhere. -- **Print fidelity.** PDF is a real PDF, not a screenshot. -- **No backend.** Pure static deploy. -- **No lock-in.** Markdown is forever readable, the IR schema is documented, the grammar is versioned. - -## What this architecture deliberately defers - -- Images (planned v0.5) -- Charts and tables (planned v0.6) -- Multiple aspect ratios (16:9 only in v0.x) -- Real-time multiplayer (out of scope; single-user product) -- A backend (out of scope; the no-backend constraint is a feature) - -## Where to look first - -| You want to | Start here | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Understand the IR | [src/ir/schema.ts](../src/ir/schema.ts) | -| Add a markdown directive | [src/ir/parse.ts](../src/ir/parse.ts) `expandBlockDirective` | -| Add a layout | [src/layouts/index.ts](../src/layouts/index.ts) + [src/styles/layouts.css](../src/styles/layouts.css) | -| Add a Style or Palette | [src/themes/styles/](../src/themes/styles/) + [src/themes/registry.ts](../src/themes/registry.ts) | -| Tune block visuals | [src/blocks/](../src/blocks/) + [src/styles/blocks.css](../src/styles/blocks.css) | -| Change theme switching mechanics | [src/render/theme-resolver.ts](../src/render/theme-resolver.ts) + [src/render/ThemeProvider.tsx](../src/render/ThemeProvider.tsx) | -| Improve PDF output | [src/styles/deck.css](../src/styles/deck.css) `@media print` | diff --git a/docs/GRAMMAR.md b/docs/GRAMMAR.md deleted file mode 100644 index 5b51ff3..0000000 --- a/docs/GRAMMAR.md +++ /dev/null @@ -1,558 +0,0 @@ -# stackdeck Markdown Grammar (v2.0) - -This document is the authoritative spec for the markdown syntax that produces a stackdeck deck. The parser, the inference engine, the editor's `/` palette, and the documentation site all derive from this file. - -The mental model is small: - -1. A deck is one markdown file plus a theme reference. -2. The file is split into slides by `::slide`. -3. Inside each slide, plain markdown is allowed and gets inferred into atomic blocks. -4. Pattern directives like `::callout` give explicit semantic intent. -5. Layout directives like `::columns` arrange blocks into shapes. -6. Directives describe **intent**, never form. Visual treatment is owned by the theme. - ---- - -## 1. Document structure - -``` ---- -title: Q4 Review ---- - -::cover -# Q4 Review -A look at the year that was. - -::slide - -# Highlights - -- Revenue up 47% -- 12 new markets -- NPS at 71 - -::slide - -::stats -::stat{value="$3M" label="ARR"} -::stat{value="47%" label="MoM Growth"} -::stat{value="71" label="NPS"} -:: - -::slide - -::callout -This was a transformational quarter for the team. -:: -``` - -### 1.1 Frontmatter - -Optional YAML at the start of the file, fenced by `---`. Recognized fields: - -| Field | Type | Purpose | -| ------------- | ------ | -------------------------------------------- | -| `title` | string | Deck title. Defaults to first H1 if missing. | -| `description` | string | Optional summary, used for sharing meta. | -| `theme` | object | Inline theme override (see section 6.2). | - -Frontmatter is parsed but never rendered as a slide. - -### 1.2 Slide separator - -`::slide` on its own line marks the start of a new slide. The first slide does not require a preceding `::slide`. - -``` -# This is slide 1 - -::slide - -# This is slide 2 -``` - -A `::slide` directive may carry slide-level options: - -``` -::slide{layout=hero} -::slide{notes="Speaker notes here"} -::slide{nosplit} # forbid auto-split on overflow -``` - -Recognized options: - -| Option | Value | Effect | -| --------- | --------------- | --------------------------------------------------------------------------- | -| `layout` | a LayoutId | Forces a specific layout for this slide, overriding inference. | -| `notes` | quoted string | Speaker notes, never rendered on the slide itself. | -| `nosplit` | flag (no value) | Forbids auto-split if content overflows. Slide may clip; user accepts that. | - ---- - -## 2. Atomic primitives (9) - -These are the only blocks the renderer understands. Pattern directives compile into trees of these. - -### 2.1 Heading - -``` -# Title -> { type: "heading", level: 1, text: "Title" } -## Subtitle -> { type: "heading", level: 2, text: "Subtitle" } -### Section -> level: 3 -#### Detail -> level: 4 -``` - -Levels 5 and 6 are downgraded to level 4. Inline markdown (`**bold**`, `*italic*`, `` `code` ``, `[link](url)`) is preserved in the `text` field and rendered by the inline renderer. - -### 2.2 Text - -A paragraph in markdown becomes a `text` block. - -``` -Lorem ipsum dolor sit amet. -> { type: "text", text: "...", emphasis: "normal" } -``` - -Emphasis variants: - -``` -::lead -The opening promise of this section. -:: - -::caption -A small caption. -:: -``` - -Compile to `{ type: "text", emphasis: "lead" | "caption", text: "..." }`. - -### 2.3 List - -Standard markdown lists. - -``` -- One -- Two - - Two-A - - Two-B -- Three - -1. First -2. Second -``` - -Compiles to a `list` block with `ordered` reflecting `1.` vs `-`. Nesting is preserved. - -### 2.4 Quote - -Markdown blockquote, optionally with attribution after a `--` separator on its own line. - -``` -> Make it work, make it right, make it fast. -> -- Kent Beck -``` - -Compiles to `{ type: "quote", text: "...", attribution: "Kent Beck", emphasis: "normal" }`. See `::quote.big` (section 3.7) for the takeover variant. - -### 2.5 Stat - -A single big-number block, used inside `::stats` / `::kpis` or as a standalone slide. - -``` -::stat{value="$3M" label="ARR"} -::stat{value="47%" label="MoM" delta="+12%" trend="up"} -``` - -Compiles to `{ type: "stat", value, label?, delta?, trend? }`. - -### 2.6 Box - -Generic container with optional semantic tone. Holds children blocks. - -``` -::box{tone=info} -Important secondary note. -:: - -::box{tone=warn} -Heads up about a risk. -:: -``` - -Tones: `info`, `warn`, `success`, `neutral`. Theme decides visual treatment. - -### 2.7 Columns - -Explicit horizontal arrangement. - -``` -::columns{count=2} -::: -First column content. -::: -Second column content. -::: -:: -``` - -Each `:::` starts a new column. Compiles to `{ type: "columns", count: 2|3, columns: [Block[], Block[]] }`. - -### 2.8 Grid - -``` -::grid{cols=2 rows=2} -Top-left content. - -Top-right content. - -Bottom-left content. - -Bottom-right content. -:: -``` - -Children are placed left-to-right, top-to-bottom into the grid. Compiles to `{ type: "grid", cols, rows, children: Block[] }`. - -### 2.9 Code - -Standard markdown fenced code block. - -```` -```ts -const x: number = 1; -``` -```` - -Compiles to `{ type: "code", language: "ts", content: "..." }`. - ---- - -## 3. Pattern directives (10) - -Patterns are sugar. The parser expands them into trees of atomic primitives. The renderer never sees the pattern name. Adding a pattern is a parser-only change. - -### 3.1 `::cover` - -Deck cover. Only meaningful on slide 1; ignored elsewhere by the deck planner. - -``` -::cover -# Big Title -A subtitle that frames the deck. -:: -``` - -Expands to a `cover` layout containing `Heading.h1` + `Text.lead`. - -### 3.2 `::section` - -Section break, used between major parts of the deck. - -``` -::section -# Part Two: Where We're Going -:: -``` - -Expands to a `section` layout containing `Heading.h1`. - -### 3.3 `::callout` - -Aside or important note. - -``` -::callout{tone=info} -This is the most important takeaway. -:: -``` - -Expands to `Box{tone}` containing the inner blocks. Tone defaults to `neutral`. - -### 3.4 `::compare` - -Two-sided comparison: before/after, this/that, problem/solution. - -``` -::compare -::: -**Before** -The old way was slow and error-prone. -::: -**After** -The new way is faster and safer. -::: -:: -``` - -Expands to a `split` layout containing two columns, each with the content given. - -### 3.5 `::stats` - -Row of 2 to 6 stats. Auto-arranges into the right grid. - -``` -::stats -::stat{value="42%" label="Lift"} -::stat{value="$3M" label="ARR"} -::stat{value="71" label="NPS"} -:: -``` - -Expands to a `grid` layout sized to fit (e.g. 3 stats -> grid 3x1). - -### 3.6 `::kpis` - -Larger grid (4 to 8 stats), arranged 2x2, 3x2, or 2x3. - -``` -::kpis -::stat{value="$3M" label="ARR"} -::stat{value="47%" label="MoM"} -::stat{value="71" label="NPS"} -::stat{value="12" label="Markets"} -:: -``` - -Expands to a `grid` layout. Parser picks rows/cols based on count. - -### 3.7 `::quote.big` - -Full-bleed takeover quote. - -``` -::quote.big -> The future is already here, it's just not evenly distributed. -> -- William Gibson -:: -``` - -Expands to a `fullBleed` layout containing `Quote{emphasis: "big"}`. - -### 3.8 `::steps` - -Ordered procedural list with step formatting. - -``` -::steps -1. Define the problem. -2. Sketch a solution. -3. Build the smallest version. -4. Ship and learn. -:: -``` - -Expands to `List{ordered: true}` inside a `flow` layout, with the renderer treating items as steps via theme. - -### 3.9 `::timeline` - -Time-anchored sequence. - -``` -::timeline -- **2021** -- Founded. -- **2022** -- First product launch. -- **2023** -- Series A. -- **2024** -- Profitability. -:: -``` - -Each item is parsed as `**when** -- body`. Expands to a `flow` layout containing a structured List the theme renders as a timeline. - -### 3.10 `::agenda` - -Deck table of contents. - -``` -::agenda -- Why we're here -- What we shipped -- What's next -- Q&A -:: -``` - -Expands to a `flow` layout with `Heading.h2 ("Agenda")` + `List` styled by the theme. - ---- - -## 4. Layouts (8) - -Layouts are JSON files at `src/layouts/<id>.layout.json`. Each defines a CSS grid with named slots that blocks fill. - -| LayoutId | Purpose | -| ----------- | ---------------------------------------------------------------- | -| `flow` | Top-to-bottom stack. Default fallback when inference is unsure. | -| `hero` | One dominant block, optional supporting content beneath. | -| `cover` | Deck cover treatment. Big title, subtitle, generous spacing. | -| `section` | Section break. Sparse, big, transition slide between deck parts. | -| `split` | Two-column 50/50. Used by `::compare`. | -| `columns` | Explicit N-column grid (2 or 3). Used by `::columns`. | -| `grid` | Explicit N x M grid. Used by `::stats`, `::kpis`, `::grid`. | -| `fullBleed` | Single dominant element edge-to-edge. Used by `::quote.big`. | - -A layout may declare `supportedRatios`. v1 only ships `16:9`, so this is informational. - ---- - -## 5. Inference - -When a slide has no `layout=` option and no pattern directive, the inference engine picks a layout and arranges blocks. Three passes run in order. - -### 5.1 Local pass - -Score each candidate layout against the slide's blocks independently. - -| Heuristic | Suggests | -| ------------------------------------------------ | ----------- | -| Single Heading.h1, sparse content, position 0 | `cover` | -| Single Heading.h1 or h2, no body, not position 0 | `section` | -| One Stat block alone | `hero` | -| 2 to 6 Stat blocks | `grid` | -| One Quote block alone | `fullBleed` | -| Heading + List or Heading + Text | `flow` | -| Anything else | `flow` | - -### 5.2 Deck pass (planner) - -After every slide has a candidate, the planner adjusts based on deck-level rules: - -- Slide 0 must use `cover` if any cover-shaped candidate exists. -- No two adjacent slides may share the same uncommon layout (avoid two `fullBleed` in a row). -- A slide tagged `::section` keeps `section` regardless of content. -- A slide carrying a pattern directive keeps its directive's layout. - -### 5.3 Theme pass - -The active Style may declare layouts it cannot render well. The planner substitutes a fallback. v1 themes all support all layouts; this pass is reserved for future theme-specific overrides. - ---- - -## 6. Theme reference - -A deck carries a `theme` object referring to a Style + Palette + Density + Mode. None of these affect the markdown source. Switching theme re-renders the same IR with different tokens. - -### 6.1 Theme on a deck - -``` -deck.theme = { - styleId: "editorial", - paletteId: "electric-blue", - density: "comfortable", // dense | comfortable | airy | spacious - mode: "light" // light | dark -} -``` - -### 6.2 Inline theme override (frontmatter) - -``` ---- -title: My Deck -theme: - styleId: brutalist - paletteId: monochrome - density: dense - mode: dark ---- -``` - -Inline overrides are convenience for authors. The persisted deck record is the source of truth. - ---- - -## 7. Overflow behavior - -A slide whose blocks exceed the available 16:9 area under the active theme + density gets auto-split into two slides at a sensible boundary (between top-level blocks). The editor surfaces a chip: - -> "Slide 4 overflowed under Airy density, split into 4a and 4b. Switch to Comfortable to keep as one." - -Authors can opt out per slide with `::slide{nosplit}`. The slide may clip; the author accepts that. - ---- - -## 8. Print - -Every slide must render correctly when the user invokes "Save as PDF" via the browser print dialog. The print stylesheet is part of the theme contract. Authors of new themes are responsible for testing print output across all 10 atomic primitives, both modes, and all 4 densities. - -Animations, transitions, and `position: sticky` are disabled in print. Custom fonts are preloaded with `font-display: block` before print fires. The `@page` rule sets a 1280x720 page size with zero margins, producing exact 16:9 PDF pages. - ---- - -## 9. Examples - -### 9.1 Minimal deck - -``` -::cover -# Hello - -::slide - -# What we'll cover -- The problem -- The solution -- The result - -::slide - -::stats -::stat{value="$3M" label="ARR"} -::stat{value="47%" label="Growth"} -::stat{value="71" label="NPS"} -:: - -::slide - -::quote.big -> Beautiful is better than ugly. -> -- Tim Peters -``` - -### 9.2 Compare slide - -``` -::slide - -::compare -::: -**Before** - -The pipeline took 12 minutes and failed 8% of runs. -::: -**After** - -The new pipeline runs in 2 minutes with a 0.4% failure rate. -::: -:: -``` - -### 9.3 Mixed columns - -``` -::slide - -# Why now - -::columns{count=2} -::: -::callout{tone=info} -The cost of waiting compounds. -:: -::: -::stat{value="$1M" label="Annual cost of inaction"} -::: -:: -``` - ---- - -## 10. What's NOT in v1 - -The following are deliberate omissions, listed so authors and theme designers know what to expect. - -- **Images.** No image upload, no image directive. v1 is text-only. -- **Charts.** Deferred. Will arrive as a separate atomic primitive in v1.5+. -- **Tables.** Use `::columns` or `::grid` for v1; native table primitive comes later. -- **Math.** No LaTeX rendering in v1. -- **Multiple aspect ratios.** Only 16:9. -- **User-uploaded fonts.** Top 10 Google Fonts, self-hosted, are the v1 set. -- **Live share links.** Sharing in v1 is via .deck file export or PDF. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 7a78b72..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,44 +0,0 @@ -# Roadmap - -Wave 1: Ship a real case study - -- Two premium templates only: `case-study-pro` (sales-call optimized) and `case-study-editorial` (sendable PDF optimized) -- Bespoke React composition per template, IR as the shared contract -- Real `::grid` and `::cell` primitives with span control and asymmetric splits -- Image support with bleed, focal-point crop, captions, aspect-ratio control -- Drag-drop and paste image directly into the preview pane -- Asset library per project: logos, photos, screenshots reusable across every deck in the project -- Brand kit per project: logo, colors, fonts, footer -- Projects and folders to organize decks -- Premium directives: `::cover`, `::section`, `::scope-strip`, `::problem`, `::approach`, `::kpi-grid`, `::big-number`, `::before-after`, `::testimonial-card`, `::pull-quote`, `::asset-frame`, `::annotated-image`, `::tear-sheet`, `::contact` -- Smart number formatting and auto trend arrows in `::kpi-grid` and `::big-number` (`$1.2M`, `+47%↑` with OpenType numerals) -- Source citations on stats rendered as designed footnotes -- Designed page furniture: page numbers, kickers, footers, section markers -- PDF export: 16:9 landscape true bleed, vector-only, font subsetting, hyperlinks, bookmarks, auto TOC -- Fullscreen present mode with arrow-key nav (no notes, no timer) - -Wave 2: Live in it day to day - -- In-preview editing: click any element in the preview to edit in place -- Drag-to-reorder slides in the thumbnail panel -- Undo and redo -- Duplicate slide -- Slide library: save any slide as a reusable block -- Outline view -- Keyboard shortcuts (cmd+/, cmd+d, cmd+1..9) -- Version history via auto-snapshots -- Full-text search across all stored decks -- Click-to-zoom on images during present -- Cursor highlight / spotlight mode during present -- PDF watermark modes at export: DRAFT, CONFIDENTIAL, FOR REVIEW, INTERNAL ONLY -- Workspace export and import as a single `.zip` (decks, assets, templates, brand kits) - -Wave 3: Premium finish - -- Background system per template: gradients, grain, decorative shapes -- Image treatments per template: duotone, B&W, polaroid, hard-frame, mask -- Auto-contrast on every palette swap -- Smart layout selection (3 stats horizontal, 4 stats 2x2, 6 stats 3x2) -- Color contrast linter -- Required alt text on images -- Custom font upload (woff2) diff --git a/eslint.config.mjs b/eslint.config.mjs index 05dca62..ed4698a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,18 +6,7 @@ import prettier from 'eslint-config-prettier'; export default tseslint.config( { - ignores: [ - '.next/**', - 'node_modules/**', - 'coverage/**', - 'next-env.d.ts', - 'src/lib/**', - 'src/templates/**', - 'src/components/**', - 'src/primitives/**', - 'src/hooks/**', - 'src/data/**', - ], + ignores: ['.next/**', 'node_modules/**', 'next-env.d.ts', 'case-studies/**'], }, js.configs.recommended, ...tseslint.configs.recommended, @@ -60,11 +49,5 @@ export default tseslint.config( 'react-hooks/exhaustive-deps': 'warn', }, }, - { - files: ['tests/**/*.ts'], - rules: { - '@typescript-eslint/no-explicit-any': 'off', - }, - }, prettier, ); diff --git a/knip.json b/knip.json deleted file mode 100644 index de16bce..0000000 --- a/knip.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/app/**/page.tsx", "src/app/**/layout.tsx"], - "project": ["src/**/*.{ts,tsx}", "tests/**/*.ts"], - "next": { - "entry": ["src/app/**/page.tsx", "src/app/**/layout.tsx", "next.config.{mjs,js,ts}"] - }, - "vitest": { - "config": ["vitest.config.ts"], - "entry": ["tests/**/*.test.ts"] - } -} diff --git a/next.config.mjs b/next.config.mjs index 549de6b..bbb2c14 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,7 +8,7 @@ const nextConfig = { source: '/:path*', headers: [ { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, ], diff --git a/package.json b/package.json index 20da4f2..1be2df9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stackdeck", - "version": "0.1.0", + "version": "0.2.0", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -9,11 +9,7 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", - "knip": "knip", "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", "prepare": "husky" }, "lint-staged": { @@ -26,20 +22,11 @@ ] }, "dependencies": { - "@codemirror/autocomplete": "^6.20.2", - "@codemirror/commands": "^6.10.3", - "@codemirror/lang-markdown": "^6.5.0", - "@codemirror/language": "^6.12.3", - "@codemirror/search": "^6.7.0", - "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.42.0", - "@lezer/highlight": "^1.2.3", - "gray-matter": "^4.0.3", - "marked": "^14.1.3", + "html-to-image": "^1.11.13", + "jspdf": "^4.2.1", "next": "^15.0.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "ulid": "^2.3.0", "zod": "^3.23.8" }, "devDependencies": { @@ -47,18 +34,14 @@ "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.5", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "husky": "^9.1.7", - "knip": "^6.11.0", "lint-staged": "^17.0.2", "prettier": "^3.8.3", "typescript": "^5.6.3", - "typescript-eslint": "^8.59.2", - "vitest": "^4.1.5" + "typescript-eslint": "^8.59.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbb7a29..7799e5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,36 +8,12 @@ importers: .: dependencies: - '@codemirror/autocomplete': - specifier: ^6.20.2 - version: 6.20.2 - '@codemirror/commands': - specifier: ^6.10.3 - version: 6.10.3 - '@codemirror/lang-markdown': - specifier: ^6.5.0 - version: 6.5.0 - '@codemirror/language': - specifier: ^6.12.3 - version: 6.12.3 - '@codemirror/search': - specifier: ^6.7.0 - version: 6.7.0 - '@codemirror/state': - specifier: ^6.6.0 - version: 6.6.0 - '@codemirror/view': - specifier: ^6.42.0 - version: 6.42.0 - '@lezer/highlight': - specifier: ^1.2.3 - version: 1.2.3 - gray-matter: - specifier: ^4.0.3 - version: 4.0.3 - marked: - specifier: ^14.1.3 - version: 14.1.4 + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 + jspdf: + specifier: ^4.2.1 + version: 4.2.1 next: specifier: ^15.0.4 version: 15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -47,9 +23,6 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.5(react@19.2.5) - ulid: - specifier: ^2.3.0 - version: 2.4.0 zod: specifier: ^3.23.8 version: 3.25.76 @@ -66,12 +39,6 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: ^6.0.1 - version: 6.0.1(vite@8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4)) - '@vitest/coverage-v8': - specifier: ^4.1.5 - version: 4.1.5(vitest@4.1.5) eslint: specifier: ^10.3.0 version: 10.3.0(jiti@2.7.0) @@ -87,9 +54,6 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 - knip: - specifier: ^6.11.0 - version: 6.11.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) lint-staged: specifier: ^17.0.2 version: 17.0.2 @@ -102,9 +66,6 @@ importers: typescript-eslint: specifier: ^8.59.2 version: 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) - vitest: - specifier: ^4.1.5 - version: 4.1.5(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4)) packages: @@ -163,6 +124,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -175,52 +140,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} - - '@codemirror/autocomplete@6.20.2': - resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==} - - '@codemirror/commands@6.10.3': - resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} - - '@codemirror/lang-css@6.3.1': - resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} - - '@codemirror/lang-html@6.4.11': - resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} - - '@codemirror/lang-javascript@6.2.5': - resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} - - '@codemirror/lang-markdown@6.5.0': - resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} - - '@codemirror/language@6.12.3': - resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} - - '@codemirror/lint@6.9.6': - resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==} - - '@codemirror/search@6.7.0': - resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==} - - '@codemirror/state@6.6.0': - resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} - - '@codemirror/view@6.42.0': - resolution: {integrity: sha512-+PJEyndSCrsS2oLH3DfWoLBcF3xfeyGXtLnpXqHY01kL3TogyCLD12hNvSu73ww2KFftrx3Rd0nGOigbSkU3Hw==} - - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -449,36 +371,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lezer/common@1.5.2': - resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} - - '@lezer/css@1.3.3': - resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==} - - '@lezer/highlight@1.2.3': - resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} - - '@lezer/html@1.3.13': - resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} - - '@lezer/javascript@1.5.4': - resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} - - '@lezer/lr@1.4.10': - resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} - - '@lezer/markdown@1.6.3': - resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} - - '@marijn/find-cluster-break@1.0.2': - resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} - - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@next/env@15.5.15': resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==} @@ -534,363 +426,9 @@ packages: cpu: [x64] os: [win32] - '@oxc-parser/binding-android-arm-eabi@0.128.0': - resolution: {integrity: sha512-aca6ZvzmCBUGOANQRiRQRZuRKYI3ENhcit6GisnknOOmcezfQc7xJ4dxlPU7MV7mOvrC7RNR1u3LAD7xyaiCxA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxc-parser/binding-android-arm64@0.128.0': - resolution: {integrity: sha512-BbeDmuohoJ7Rz/it5wnkj69i/OsCPS3Z51nLEzwO/Y6YshtC4JU+15oNwhY8v4LRKRYclRc7ggOikwrsJ/eOEQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxc-parser/binding-darwin-arm64@0.128.0': - resolution: {integrity: sha512-tRUHPt80417QmvNpoSslJT1VY8NUbWdrWR+L14Zn+RbOTcaqB8E6PYE/ZGN8jjWBzqporiA/H4MfO50ew/NCNA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxc-parser/binding-darwin-x64@0.128.0': - resolution: {integrity: sha512-rWI2Hb1Nt3U/vKsjyNvZzDC8i/l144U20DKjhzaTmwIhIiSRGeroPWWiImwypmKLqrw8GuIixbWJkpGWLbkzrQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxc-parser/binding-freebsd-x64@0.128.0': - resolution: {integrity: sha512-hhpdVMaNCLgQxjgNPeeFzSeJMmZPc5lKfv0NGSI3egZq9EdnEGqeC8JsYsQjK7PoQgbvZ17xlj0SO5ziH5Obkg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxc-parser/binding-linux-arm-gnueabihf@0.128.0': - resolution: {integrity: sha512-093zNw0zZ/e/obML+rhlSdmnzR0mVZluPcAkxunEc5E3F0yBVsFn24Y1ILfsEte11Ud041qn/gp2OJ1jxNqUng==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm-musleabihf@0.128.0': - resolution: {integrity: sha512-fq7DmKmfC+dvD97IXrgbph6Jzwe0EDu+PYMofmzZ6fv5X1k9vtaqLpDGMuICO9MmUnyKAQmVl+wIv2RNy4Dz8g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm64-gnu@0.128.0': - resolution: {integrity: sha512-Xvm48jJah8TlIrURIjNOP/gNiGe6aKvCB+r06VliflFo8Kq7VOLE8PxtgShJzZIqubrgdMdYfvuPPozn7F6MbQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-arm64-musl@0.128.0': - resolution: {integrity: sha512-M7iwBGmYJTx+pKOYFjI0buop4gJvlmcVzFGaXPt21DKpQkbQZG1f63Yg7LloIYT/t9yLxCw0Lhfx/RFlAlMSjA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxc-parser/binding-linux-ppc64-gnu@0.128.0': - resolution: {integrity: sha512-21LGNIZb1Pcfk5/EGsqabrxv4yqQOWis1407JJrClS7XpFCrbvr74YAB1V+m54cYbwvO6UWwQqS4WecxiyfCRg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-riscv64-gnu@0.128.0': - resolution: {integrity: sha512-gyHjOTFpg9bTTYjxPmQirvufb89+VdZwVfcMtAUyPr6F5H8ZswvCQshK4qOW+Q+2Xyb33hduRgY/eFHJQjU/vQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-riscv64-musl@0.128.0': - resolution: {integrity: sha512-X6Q2oKUrP5GyDd2xniuEBLk6aFQCZ97W2+aVXGgJXdjx5t4/oFuA9ri0wLOUrBIX+qdSuK581snMBio4z910eA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxc-parser/binding-linux-s390x-gnu@0.128.0': - resolution: {integrity: sha512-BdzTmqxfxoYkpgokoLaSnOX6T+R3/goL42klre2tnG+kHbG2TXS0VN+P5BPofH1axdKOHy5ei4ENZrjmCOt2lA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-x64-gnu@0.128.0': - resolution: {integrity: sha512-OO1nW2Q7sSYYvJZpDHdvyFSdRaVcQqRijZSSmWVMqFxPYy8cEF45zJ9fcdIYuzIT3jYq6YRhEFm/VMWNWhE22Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-x64-musl@0.128.0': - resolution: {integrity: sha512-4NehAe404MRdoZVS9DW8C5XbJwbXIc/KfVlYdpi5vE4081zc9Y0YzKVqyOYj/Puye7/Do+ohaONBFWlEHYl9hw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxc-parser/binding-openharmony-arm64@0.128.0': - resolution: {integrity: sha512-kVbqgW9xLL8bh8oc7aYOJilRKXE5G33+tE0jan+duo/9OriaFRpijcCwT2waWs2oqYROYq0GlE7/p3ywoshVeg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxc-parser/binding-wasm32-wasi@0.128.0': - resolution: {integrity: sha512-L38ojghJYHmgiz6fJd7jwLB/ESDBpB02NdFxh+smqVM6P2anCEvHn0jhaSrt5eVNR1Ak8+moOeftUlofeyvniA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@oxc-parser/binding-win32-arm64-msvc@0.128.0': - resolution: {integrity: sha512-xgvO35GyHBtjlQ5AEpaYr7Rll1rvY7zqIhT6ty8E3ezBW2J1SFLjIDEvI/tcgDg6oaseDAqVcM+jU1HuCekgZw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxc-parser/binding-win32-ia32-msvc@0.128.0': - resolution: {integrity: sha512-OY+3eM2SN72prHKRB22mPz8o5A/7dJ+f5DFLBVvggyZhEaNDAH9IB+ElMjmOkOIwf5MDCUAowCK7pAncNxzpBA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxc-parser/binding-win32-x64-msvc@0.128.0': - resolution: {integrity: sha512-NE9ny+cPUCCObXa0IKLfj0tCdPd7pe/dz9ZpkxpUOymB3miNeMPybdlYYTBSGJUalMWeBM85/4JcCErCNTqOXw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxc-project/types@0.127.0': - resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - - '@oxc-project/types@0.128.0': - resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} - - '@oxc-resolver/binding-android-arm-eabi@11.19.1': - resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} - cpu: [arm] - os: [android] - - '@oxc-resolver/binding-android-arm64@11.19.1': - resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==} - cpu: [arm64] - os: [android] - - '@oxc-resolver/binding-darwin-arm64@11.19.1': - resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==} - cpu: [arm64] - os: [darwin] - - '@oxc-resolver/binding-darwin-x64@11.19.1': - resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==} - cpu: [x64] - os: [darwin] - - '@oxc-resolver/binding-freebsd-x64@11.19.1': - resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==} - cpu: [x64] - os: [freebsd] - - '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': - resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==} - cpu: [arm] - os: [linux] - - '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': - resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==} - cpu: [arm] - os: [linux] - - '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': - resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxc-resolver/binding-linux-arm64-musl@11.19.1': - resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': - resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': - resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': - resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': - resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxc-resolver/binding-linux-x64-gnu@11.19.1': - resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxc-resolver/binding-linux-x64-musl@11.19.1': - resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxc-resolver/binding-openharmony-arm64@11.19.1': - resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==} - cpu: [arm64] - os: [openharmony] - - '@oxc-resolver/binding-wasm32-wasi@11.19.1': - resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': - resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==} - cpu: [arm64] - os: [win32] - - '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': - resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==} - cpu: [ia32] - os: [win32] - - '@oxc-resolver/binding-win32-x64-msvc@11.19.1': - resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==} - cpu: [x64] - os: [win32] - - '@rolldown/binding-android-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-rc.17': - resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': - resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': - resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': - resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-rc.17': - resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} - - '@rolldown/pluginutils@1.0.0-rc.7': - resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -903,6 +441,12 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -911,6 +455,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typescript-eslint/eslint-plugin@8.59.2': resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -970,57 +517,6 @@ packages: resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-react@6.0.1': - resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 - babel-plugin-react-compiler: ^1.0.0 - vite: ^8.0.0 - peerDependenciesMeta: - '@rolldown/plugin-babel': - optional: true - babel-plugin-react-compiler: - optional: true - - '@vitest/coverage-v8@4.1.5': - resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} - peerDependencies: - '@vitest/browser': 4.1.5 - vitest: 4.1.5 - peerDependenciesMeta: - '@vitest/browser': - optional: true - - '@vitest/expect@4.1.5': - resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} - - '@vitest/mocker@4.1.5': - resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.5': - resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} - - '@vitest/runner@4.1.5': - resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} - - '@vitest/snapshot@4.1.5': - resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} - - '@vitest/spy@4.1.5': - resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} - - '@vitest/utils@4.1.5': - resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1046,9 +542,6 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -1077,13 +570,6 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - ast-v8-to-istanbul@1.0.0: - resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} - async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1099,6 +585,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + baseline-browser-mapping@2.10.27: resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} engines: {node: '>=6.0.0'} @@ -1131,9 +621,9 @@ packages: caniuse-lite@1.0.30001791: resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} @@ -1152,13 +642,16 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - crelt@1.0.6: - resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1202,6 +695,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dompurify@3.4.2: + resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1232,9 +728,6 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1303,11 +796,6 @@ packages: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -1320,9 +808,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1330,14 +815,6 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - extend-shallow@2.0.1: - resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} - engines: {node: '>=0.10.0'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1347,8 +824,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fd-package-json@2.0.0: - resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1359,6 +836,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1378,16 +858,6 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - formatly@0.3.0: - resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} - engines: {node: '>=18.3.0'} - hasBin: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1422,9 +892,6 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1437,18 +904,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - gray-matter@4.0.3: - resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} - engines: {node: '>=6.0'} - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -1474,8 +933,12 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} @@ -1498,6 +961,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1530,10 +996,6 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} - is-extendable@0.1.1: - resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} - engines: {node: '>=0.10.0'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1608,18 +1070,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -1628,16 +1078,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1657,6 +1100,9 @@ packages: engines: {node: '>=6'} hasBin: true + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -1664,93 +1110,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} - - knip@6.11.0: - resolution: {integrity: sha512-84PTlN8Q5smLpTbzs8smTVh8PMbTDXtw0tFksXq/m6auGFC/KSzJykKFmnYh3As38kiWDkoDBvdTTyKk5M1TAQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - lint-staged@17.0.2: resolution: {integrity: sha512-Rbr6rdmbCn1fIDHBZpn0madg0hEkdlh+QwajnL3Qq0ZUq/icAJfLGj9BVBajAXi7657ZzKQ7kobGP9S5XOHYRw==} engines: {node: '>=22.22.1'} @@ -1775,21 +1138,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magicast@0.5.2: - resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - marked@14.1.4: - resolution: {integrity: sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==} - engines: {node: '>= 18'} - hasBin: true - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1805,9 +1153,6 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1875,9 +1220,6 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1890,13 +1232,6 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxc-parser@0.128.0: - resolution: {integrity: sha512-XkOw3eiIxAgQ19WRew/Bq9wc5Ga/guaWIzDBzq80z1PyuDNGvWBpPby9k6YGwV8A8uMw+Nlq3xqlzuDYmUFYUw==} - engines: {node: ^20.19.0 || >=22.12.0} - - oxc-resolver@11.19.1: - resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -1905,6 +1240,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1916,8 +1254,8 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1934,10 +1272,6 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} - engines: {node: ^10 || ^12 || >=14} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1954,6 +1288,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -1970,13 +1307,13 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@2.0.0-next.6: resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} engines: {node: '>= 0.4'} @@ -1989,10 +1326,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rolldown@1.0.0-rc.17: - resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} @@ -2009,10 +1345,6 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - section-matter@1.0.0: - resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} - engines: {node: '>=4'} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2062,9 +1394,6 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2077,22 +1406,13 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} - smol-toml@1.6.1: - resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} - engines: {node: '>= 18'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -2133,17 +1453,6 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} - strip-bom-string@1.0.0: - resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} - engines: {node: '>=0.10.0'} - - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} - - style-mod@4.1.3: - resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} - styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -2157,16 +1466,16 @@ packages: babel-plugin-macros: optional: true - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} tinyexec@1.1.2: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} @@ -2176,10 +1485,6 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2221,14 +1526,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - ulid@2.4.0: - resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} - hasBin: true - - unbash@3.0.0: - resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} - engines: {node: '>=14'} - unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -2245,96 +1542,8 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vite@8.0.10: - resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.5: - resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.5 - '@vitest/browser-preview': 4.1.5 - '@vitest/browser-webdriverio': 4.1.5 - '@vitest/coverage-istanbul': 4.1.5 - '@vitest/coverage-v8': 4.1.5 - '@vitest/ui': 4.1.5 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - w3c-keyname@2.2.8: - resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - - walk-up-path@4.0.0: - resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} - engines: {node: 20 || >=22} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} @@ -2357,11 +1566,6 @@ packages: engines: {node: '>= 8'} hasBin: true - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2395,9 +1599,6 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - snapshots: '@babel/code-frame@7.29.0': @@ -2477,6 +1678,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2500,110 +1703,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@bcoe/v8-coverage@1.0.2': {} - - '@codemirror/autocomplete@6.20.2': - dependencies: - '@codemirror/language': 6.12.3 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.42.0 - '@lezer/common': 1.5.2 - - '@codemirror/commands@6.10.3': - dependencies: - '@codemirror/language': 6.12.3 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.42.0 - '@lezer/common': 1.5.2 - - '@codemirror/lang-css@6.3.1': - dependencies: - '@codemirror/autocomplete': 6.20.2 - '@codemirror/language': 6.12.3 - '@codemirror/state': 6.6.0 - '@lezer/common': 1.5.2 - '@lezer/css': 1.3.3 - - '@codemirror/lang-html@6.4.11': - dependencies: - '@codemirror/autocomplete': 6.20.2 - '@codemirror/lang-css': 6.3.1 - '@codemirror/lang-javascript': 6.2.5 - '@codemirror/language': 6.12.3 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.42.0 - '@lezer/common': 1.5.2 - '@lezer/css': 1.3.3 - '@lezer/html': 1.3.13 - - '@codemirror/lang-javascript@6.2.5': - dependencies: - '@codemirror/autocomplete': 6.20.2 - '@codemirror/language': 6.12.3 - '@codemirror/lint': 6.9.6 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.42.0 - '@lezer/common': 1.5.2 - '@lezer/javascript': 1.5.4 - - '@codemirror/lang-markdown@6.5.0': - dependencies: - '@codemirror/autocomplete': 6.20.2 - '@codemirror/lang-html': 6.4.11 - '@codemirror/language': 6.12.3 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.42.0 - '@lezer/common': 1.5.2 - '@lezer/markdown': 1.6.3 - - '@codemirror/language@6.12.3': - dependencies: - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.42.0 - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 - style-mod: 4.1.3 - - '@codemirror/lint@6.9.6': - dependencies: - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.42.0 - crelt: 1.0.6 - - '@codemirror/search@6.7.0': - dependencies: - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.42.0 - crelt: 1.0.6 - - '@codemirror/state@6.6.0': - dependencies: - '@marijn/find-cluster-break': 1.0.2 - - '@codemirror/view@6.42.0': - dependencies: - '@codemirror/state': 6.6.0 - crelt: 1.0.6 - style-mod: 4.1.3 - w3c-keyname: 2.2.8 - - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0(jiti@2.7.0))': dependencies: eslint: 10.3.0(jiti@2.7.0) @@ -2703,345 +1807,103 @@ snapshots: optional: true '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.10.0 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@lezer/common@1.5.2': {} - - '@lezer/css@1.3.3': - dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 - - '@lezer/highlight@1.2.3': - dependencies: - '@lezer/common': 1.5.2 - - '@lezer/html@1.3.13': - dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 - - '@lezer/javascript@1.5.4': - dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 - - '@lezer/lr@1.4.10': - dependencies: - '@lezer/common': 1.5.2 - - '@lezer/markdown@1.6.3': - dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - - '@marijn/find-cluster-break@1.0.2': {} - - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@next/env@15.5.15': {} - - '@next/swc-darwin-arm64@15.5.15': - optional: true - - '@next/swc-darwin-x64@15.5.15': - optional: true - - '@next/swc-linux-arm64-gnu@15.5.15': - optional: true - - '@next/swc-linux-arm64-musl@15.5.15': - optional: true - - '@next/swc-linux-x64-gnu@15.5.15': - optional: true - - '@next/swc-linux-x64-musl@15.5.15': - optional: true - - '@next/swc-win32-arm64-msvc@15.5.15': - optional: true - - '@next/swc-win32-x64-msvc@15.5.15': - optional: true - - '@oxc-parser/binding-android-arm-eabi@0.128.0': - optional: true - - '@oxc-parser/binding-android-arm64@0.128.0': - optional: true - - '@oxc-parser/binding-darwin-arm64@0.128.0': - optional: true - - '@oxc-parser/binding-darwin-x64@0.128.0': - optional: true - - '@oxc-parser/binding-freebsd-x64@0.128.0': - optional: true - - '@oxc-parser/binding-linux-arm-gnueabihf@0.128.0': - optional: true - - '@oxc-parser/binding-linux-arm-musleabihf@0.128.0': - optional: true - - '@oxc-parser/binding-linux-arm64-gnu@0.128.0': - optional: true - - '@oxc-parser/binding-linux-arm64-musl@0.128.0': - optional: true - - '@oxc-parser/binding-linux-ppc64-gnu@0.128.0': - optional: true - - '@oxc-parser/binding-linux-riscv64-gnu@0.128.0': - optional: true - - '@oxc-parser/binding-linux-riscv64-musl@0.128.0': - optional: true - - '@oxc-parser/binding-linux-s390x-gnu@0.128.0': - optional: true - - '@oxc-parser/binding-linux-x64-gnu@0.128.0': - optional: true - - '@oxc-parser/binding-linux-x64-musl@0.128.0': - optional: true - - '@oxc-parser/binding-openharmony-arm64@0.128.0': - optional: true - - '@oxc-parser/binding-wasm32-wasi@0.128.0': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@oxc-parser/binding-win32-arm64-msvc@0.128.0': - optional: true - - '@oxc-parser/binding-win32-ia32-msvc@0.128.0': - optional: true - - '@oxc-parser/binding-win32-x64-msvc@0.128.0': - optional: true - - '@oxc-project/types@0.127.0': {} - - '@oxc-project/types@0.128.0': {} - - '@oxc-resolver/binding-android-arm-eabi@11.19.1': - optional: true - - '@oxc-resolver/binding-android-arm64@11.19.1': - optional: true - - '@oxc-resolver/binding-darwin-arm64@11.19.1': - optional: true - - '@oxc-resolver/binding-darwin-x64@11.19.1': - optional: true - - '@oxc-resolver/binding-freebsd-x64@11.19.1': - optional: true - - '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': - optional: true - - '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': - optional: true - - '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': - optional: true - - '@oxc-resolver/binding-linux-arm64-musl@11.19.1': - optional: true - - '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 optional: true - '@oxc-resolver/binding-linux-x64-musl@11.19.1': + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true - '@oxc-resolver/binding-openharmony-arm64@11.19.1': + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@img/sharp-wasm32@0.34.5': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - - '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + '@emnapi/runtime': 1.10.0 optional: true - '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': + '@img/sharp-win32-arm64@0.34.5': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + '@img/sharp-win32-ia32@0.34.5': optional: true - '@rolldown/binding-android-arm64@1.0.0-rc.17': + '@img/sharp-win32-x64@0.34.5': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': - optional: true + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - '@rolldown/binding-darwin-x64@1.0.0-rc.17': - optional: true + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': - optional: true + '@jridgewell/resolve-uri@3.1.2': {} - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': - optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': - optional: true + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': - optional: true + '@next/env@15.5.15': {} - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + '@next/swc-darwin-arm64@15.5.15': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + '@next/swc-darwin-x64@15.5.15': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + '@next/swc-linux-arm64-gnu@15.5.15': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + '@next/swc-linux-arm64-musl@15.5.15': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + '@next/swc-linux-x64-gnu@15.5.15': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@next/swc-linux-x64-musl@15.5.15': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + '@next/swc-win32-arm64-msvc@15.5.15': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + '@next/swc-win32-x64-msvc@15.5.15': optional: true - '@rolldown/pluginutils@1.0.0-rc.17': {} - - '@rolldown/pluginutils@1.0.0-rc.7': {} - - '@standard-schema/spec@1.1.0': {} - '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - '@types/esrecurse@4.3.1': {} '@types/estree@1.0.8': {} @@ -3052,6 +1914,11 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pako@2.0.4': {} + + '@types/raf@3.4.3': + optional: true + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -3060,6 +1927,9 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': + optional: true + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3151,66 +2021,6 @@ snapshots: '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4))': - dependencies: - '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4) - - '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.5 - ast-v8-to-istanbul: 1.0.0 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - std-env: 4.1.0 - tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4)) - - '@vitest/expect@4.1.5': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4))': - dependencies: - '@vitest/spy': 4.1.5 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4) - - '@vitest/pretty-format@4.1.5': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.5': - dependencies: - '@vitest/utils': 4.1.5 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.5': - dependencies: - '@vitest/pretty-format': 4.1.5 - '@vitest/utils': 4.1.5 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.5': {} - - '@vitest/utils@4.1.5': - dependencies: - '@vitest/pretty-format': 4.1.5 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -3232,10 +2042,6 @@ snapshots: ansi-styles@6.2.3: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -3293,14 +2099,6 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 - assertion-error@2.0.1: {} - - ast-v8-to-istanbul@1.0.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 - async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -3311,6 +2109,9 @@ snapshots: balanced-match@4.0.4: {} + base64-arraybuffer@1.0.2: + optional: true + baseline-browser-mapping@2.10.27: {} brace-expansion@1.1.14: @@ -3349,7 +2150,17 @@ snapshots: caniuse-lite@1.0.30001791: {} - chai@6.2.2: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.29.2 + '@types/raf': 3.4.3 + core-js: 3.49.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true cli-cursor@5.0.0: dependencies: @@ -3366,7 +2177,8 @@ snapshots: convert-source-map@2.0.0: {} - crelt@1.0.6: {} + core-js@3.49.0: + optional: true cross-spawn@7.0.6: dependencies: @@ -3374,6 +2186,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + csstype@3.2.3: {} data-view-buffer@1.0.2: @@ -3412,12 +2229,18 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - detect-libc@2.1.2: {} + detect-libc@2.1.2: + optional: true doctrine@2.1.0: dependencies: esutils: 2.0.3 + dompurify@3.4.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3510,8 +2333,6 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 - es-module-lexer@2.1.0: {} - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3628,8 +2449,6 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 5.0.1 - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -3640,34 +2459,28 @@ snapshots: estraverse@5.3.0: {} - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - esutils@2.0.3: {} eventemitter3@5.0.4: {} - expect-type@1.3.0: {} - - extend-shallow@2.0.1: - dependencies: - is-extendable: 0.1.1 - fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fd-package-json@2.0.0: + fast-png@6.4.0: dependencies: - walk-up-path: 4.0.0 + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3688,13 +2501,6 @@ snapshots: dependencies: is-callable: 1.2.7 - formatly@0.3.0: - dependencies: - fd-package-json: 2.0.0 - - fsevents@2.3.3: - optional: true - function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -3738,10 +2544,6 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -3753,17 +2555,8 @@ snapshots: gopd@1.2.0: {} - gray-matter@4.0.3: - dependencies: - js-yaml: 3.14.2 - kind-of: 6.0.3 - section-matter: 1.0.0 - strip-bom-string: 1.0.0 - has-bigints@1.1.0: {} - has-flag@4.0.0: {} - has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -3788,7 +2581,13 @@ snapshots: dependencies: hermes-estree: 0.25.1 - html-escaper@2.0.2: {} + html-to-image@1.11.13: {} + + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true husky@9.1.7: {} @@ -3804,6 +2603,8 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + iobuffer@5.4.0: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -3844,8 +2645,6 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-extendable@0.1.1: {} - is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -3920,19 +2719,6 @@ snapshots: isexe@2.0.0: {} - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -3942,17 +2728,11 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - jiti@2.7.0: {} - - js-tokens@10.0.0: {} + jiti@2.7.0: + optional: true js-tokens@4.0.0: {} - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -3963,6 +2743,17 @@ snapshots: json5@2.2.3: {} + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.2 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.4.2 + html2canvas: 1.4.1 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -3974,82 +2765,11 @@ snapshots: dependencies: json-buffer: 3.0.1 - kind-of@6.0.3: {} - - knip@6.11.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - formatly: 0.3.0 - get-tsconfig: 4.14.0 - jiti: 2.7.0 - minimist: 1.2.8 - oxc-parser: 0.128.0 - oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - picomatch: 4.0.4 - smol-toml: 1.6.1 - strip-json-comments: 5.0.3 - tinyglobby: 0.2.16 - unbash: 3.0.0 - yaml: 2.8.4 - zod: 4.4.3 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - lint-staged@17.0.2: dependencies: listr2: 10.2.1 @@ -4087,22 +2807,6 @@ snapshots: dependencies: yallist: 3.1.1 - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.5.2: - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.4 - - marked@14.1.4: {} - math-intrinsics@1.1.0: {} mimic-function@5.0.1: {} @@ -4115,8 +2819,6 @@ snapshots: dependencies: brace-expansion: 1.1.14 - minimist@1.2.8: {} - ms@2.1.3: {} nanoid@3.3.12: {} @@ -4191,8 +2893,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - obug@2.1.1: {} - onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -4212,57 +2912,6 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.128.0: - dependencies: - '@oxc-project/types': 0.128.0 - optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.128.0 - '@oxc-parser/binding-android-arm64': 0.128.0 - '@oxc-parser/binding-darwin-arm64': 0.128.0 - '@oxc-parser/binding-darwin-x64': 0.128.0 - '@oxc-parser/binding-freebsd-x64': 0.128.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.128.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.128.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.128.0 - '@oxc-parser/binding-linux-arm64-musl': 0.128.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.128.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.128.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.128.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.128.0 - '@oxc-parser/binding-linux-x64-gnu': 0.128.0 - '@oxc-parser/binding-linux-x64-musl': 0.128.0 - '@oxc-parser/binding-openharmony-arm64': 0.128.0 - '@oxc-parser/binding-wasm32-wasi': 0.128.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.128.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.128.0 - '@oxc-parser/binding-win32-x64-msvc': 0.128.0 - - oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): - optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.19.1 - '@oxc-resolver/binding-android-arm64': 11.19.1 - '@oxc-resolver/binding-darwin-arm64': 11.19.1 - '@oxc-resolver/binding-darwin-x64': 11.19.1 - '@oxc-resolver/binding-freebsd-x64': 11.19.1 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1 - '@oxc-resolver/binding-linux-arm64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-arm64-musl': 11.19.1 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-riscv64-musl': 11.19.1 - '@oxc-resolver/binding-linux-s390x-gnu': 11.19.1 - '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-x64-musl': 11.19.1 - '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 - '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 - '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -4271,13 +2920,16 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@2.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} path-parse@1.0.7: {} - pathe@2.0.3: {} + performance-now@2.1.0: + optional: true picocolors@1.1.1: {} @@ -4291,12 +2943,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.14: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - prelude-ls@1.2.1: {} prettier@3.8.3: {} @@ -4309,6 +2955,11 @@ snapshots: punycode@2.3.1: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 @@ -4329,6 +2980,9 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.13.11: + optional: true + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.9 @@ -4338,8 +2992,6 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 - resolve-pkg-maps@1.0.0: {} - resolve@2.0.0-next.6: dependencies: es-errors: 1.3.0 @@ -4356,26 +3008,8 @@ snapshots: rfdc@1.4.1: {} - rolldown@1.0.0-rc.17: - dependencies: - '@oxc-project/types': 0.127.0 - '@rolldown/pluginutils': 1.0.0-rc.17 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-x64': 1.0.0-rc.17 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + rgbcolor@1.0.1: + optional: true safe-array-concat@1.1.4: dependencies: @@ -4398,11 +3032,6 @@ snapshots: scheduler@0.27.0: {} - section-matter@1.0.0: - dependencies: - extend-shallow: 2.0.1 - kind-of: 6.0.3 - semver@6.3.1: {} semver@7.7.4: {} @@ -4495,8 +3124,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} - signal-exit@4.1.0: {} slice-ansi@7.1.2: @@ -4509,15 +3136,10 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - smol-toml@1.6.1: {} - source-map-js@1.2.1: {} - sprintf-js@1.0.3: {} - - stackback@0.0.2: {} - - std-env@4.1.0: {} + stackblur-canvas@2.7.0: + optional: true stop-iteration-iterator@1.1.0: dependencies: @@ -4585,12 +3207,6 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-bom-string@1.0.0: {} - - strip-json-comments@5.0.3: {} - - style-mod@4.1.3: {} - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5): dependencies: client-only: 0.0.1 @@ -4598,13 +3214,15 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} - tinybench@2.9.0: {} + svg-pathdata@6.0.3: + optional: true + + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true tinyexec@1.1.2: {} @@ -4613,8 +3231,6 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinyrainbow@3.1.0: {} - ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4671,10 +3287,6 @@ snapshots: typescript@5.9.3: {} - ulid@2.4.0: {} - - unbash@3.0.0: {} - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -4694,50 +3306,10 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4): + utrie@1.0.2: dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.14 - rolldown: 1.0.0-rc.17 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 22.19.17 - fsevents: 2.3.3 - jiti: 2.7.0 - yaml: 2.8.4 - - vitest@4.1.5(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4)): - dependencies: - '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4)) - '@vitest/pretty-format': 4.1.5 - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@22.19.17)(jiti@2.7.0)(yaml@2.8.4) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.19.17 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) - transitivePeerDependencies: - - msw - - w3c-keyname@2.2.8: {} - - walk-up-path@4.0.0: {} + base64-arraybuffer: 1.0.2 + optional: true which-boxed-primitive@1.1.1: dependencies: @@ -4784,11 +3356,6 @@ snapshots: dependencies: isexe: 2.0.0 - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - word-wrap@1.2.5: {} wrap-ansi@10.0.0: @@ -4805,7 +3372,8 @@ snapshots: yallist@3.1.1: {} - yaml@2.8.4: {} + yaml@2.8.4: + optional: true yocto-queue@0.1.0: {} @@ -4814,5 +3382,3 @@ snapshots: zod: 3.25.76 zod@3.25.76: {} - - zod@4.4.3: {} diff --git a/src/app/apple-icon.tsx b/src/app/apple-icon.tsx index 5c999f5..754482c 100644 --- a/src/app/apple-icon.tsx +++ b/src/app/apple-icon.tsx @@ -9,17 +9,17 @@ export default function AppleIcon() { style={{ width: '100%', height: '100%', - background: '#0b0b0f', + background: '#0a0a0a', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 36, }} > - <svg width="132" height="132" viewBox="0 0 22 22" fill="none"> - <rect x="1.8" y="6.9" width="12" height="12" rx="2" fill="#6366f1" /> - <rect x="5" y="5" width="12" height="12" rx="2" fill="#ec4899" /> - <rect x="8.2" y="3.1" width="12" height="12" rx="2" fill="#f59e0b" /> + <svg width="124" height="124" viewBox="0 0 22 22" fill="none"> + <rect x="1.8" y="6.9" width="12" height="12" rx="2.6" fill="#3f3f46" /> + <rect x="5" y="5" width="12" height="12" rx="2.6" fill="#a1a1aa" /> + <rect x="8.2" y="3.1" width="12" height="12" rx="2.6" fill="#fafafa" /> </svg> </div>, { ...size }, diff --git a/src/app/c/[slug]/assets/[...path]/route.ts b/src/app/c/[slug]/assets/[...path]/route.ts new file mode 100644 index 0000000..755f330 --- /dev/null +++ b/src/app/c/[slug]/assets/[...path]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { readAsset } from '@/lib/case-studies'; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ slug: string; path: string[] }> }, +) { + const { slug, path: parts } = await params; + const asset = `assets/${parts.join('/')}`; + const result = await readAsset(slug, asset); + if (!result) { + return new NextResponse('Not found', { status: 404 }); + } + return new NextResponse(new Uint8Array(result.buf), { + status: 200, + headers: { + 'Content-Type': result.type, + 'Cache-Control': 'public, max-age=3600, s-maxage=86400', + }, + }); +} diff --git a/src/app/c/[slug]/not-found.css b/src/app/c/[slug]/not-found.css new file mode 100644 index 0000000..534d533 --- /dev/null +++ b/src/app/c/[slug]/not-found.css @@ -0,0 +1,111 @@ +.nf { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg); + position: relative; + z-index: 2; +} + +.nf-bar { + display: flex; + align-items: center; + height: 68px; + padding: 0 32px; + border-bottom: 1px solid var(--line); +} + +.nf-brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--fg); + font-size: 16px; + font-weight: 600; + letter-spacing: -0.012em; +} + +.nf-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + padding: 0 64px; + max-width: 880px; + margin: 0 auto; + width: 100%; +} + +.nf-eyebrow { + display: inline-flex; + align-items: center; + padding: 6px 12px; + background: var(--accent-bg); + border: 1px solid var(--accent-border); + color: var(--accent-soft); + border-radius: 999px; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 24px; +} + +.nf-title { + margin: 0 0 24px; + font-size: clamp(40px, 6vw, 72px); + font-weight: 500; + letter-spacing: -0.035em; + line-height: 1; + color: var(--fg); +} + +.nf-title-accent { + color: var(--accent); +} + +.nf-sub { + margin: 0 0 36px; + font-size: 17px; + line-height: 1.55; + color: var(--fg-soft); + max-width: 540px; +} + +.nf-cta { + display: inline-flex; + align-items: center; + gap: 10px; + height: 44px; + padding: 0 22px; + background: var(--accent); + color: var(--bg); + border-radius: var(--rad); + font-size: 15px; + font-weight: 600; + transition: + background 0.15s var(--ease), + transform 0.06s var(--ease); +} + +.nf-cta:hover { + background: var(--accent-soft); + transform: translateY(-1px); +} + +.nf-cta svg { + transition: transform 0.2s var(--ease); +} + +.nf-cta:hover svg { + transform: translateX(3px); +} + +@media (max-width: 720px) { + .nf-bar, + .nf-main { + padding-left: 24px; + padding-right: 24px; + } +} diff --git a/src/app/c/[slug]/not-found.tsx b/src/app/c/[slug]/not-found.tsx new file mode 100644 index 0000000..590d9b6 --- /dev/null +++ b/src/app/c/[slug]/not-found.tsx @@ -0,0 +1,38 @@ +import Link from 'next/link'; +import { StackdeckMark } from '@/components/StackdeckMark'; +import './not-found.css'; + +export default function CaseStudyNotFound() { + return ( + <div className="nf"> + <header className="nf-bar"> + <Link href="/" className="nf-brand" aria-label="stackdeck"> + <StackdeckMark size={22} /> + <span>stackdeck</span> + </Link> + </header> + <main className="nf-main"> + <span className="nf-eyebrow">404 — case study not found</span> + <h1 className="nf-title"> + We could not find <span className="nf-title-accent">that deck.</span> + </h1> + <p className="nf-sub"> + The case study you were looking for has been moved or never existed. Head back to the + index and pick another one. + </p> + <Link href="/" className="nf-cta"> + <span>Back to all decks</span> + <svg width="14" height="10" viewBox="0 0 14 10" fill="none"> + <path + d="M1 5h12m0 0L9 1m4 4L9 9" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </Link> + </main> + </div> + ); +} diff --git a/src/app/c/[slug]/opengraph-image.tsx b/src/app/c/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..2ebdbb9 --- /dev/null +++ b/src/app/c/[slug]/opengraph-image.tsx @@ -0,0 +1,97 @@ +import { ImageResponse } from 'next/og'; +import { getCaseStudy } from '@/lib/case-studies'; + +export const alt = 'Octify case study'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function CaseStudyOG({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const study = await getCaseStudy(slug); + const title = study?.title ?? 'Octify case study'; + const client = study?.client; + const summary = study?.summary; + + return new ImageResponse( + <div + style={{ + width: '100%', + height: '100%', + background: '#0a0a0c', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + padding: 80, + fontFamily: 'sans-serif', + color: '#f4f4f6', + }} + > + <div style={{ display: 'flex', alignItems: 'center', gap: 18 }}> + <svg width="48" height="48" viewBox="0 0 22 22" fill="none"> + <rect x="1.8" y="6.9" width="12" height="12" rx="2.6" fill="#3f3f46" /> + <rect x="5" y="5" width="12" height="12" rx="2.6" fill="#a1a1aa" /> + <rect x="8.2" y="3.1" width="12" height="12" rx="2.6" fill="#fafafa" /> + </svg> + <div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.1 }}> + <div style={{ fontSize: 26, fontWeight: 600, letterSpacing: -0.4 }}> + Octify Case Study + </div> + <div + style={{ + fontSize: 14, + color: '#86868f', + marginTop: 4, + fontFamily: 'monospace', + letterSpacing: 0.5, + textTransform: 'uppercase', + }} + > + {client ? client : 'octifytechnologies.com'} + </div> + </div> + </div> + + <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}> + <div + style={{ + display: 'flex', + fontSize: title.length > 40 ? 88 : 112, + fontWeight: 500, + lineHeight: 0.98, + letterSpacing: -3, + maxWidth: 1040, + }} + > + <span>{title}</span> + <span style={{ color: '#818cf8' }}>.</span> + </div> + {summary ? ( + <div + style={{ + fontSize: 22, + color: '#c8c8cf', + lineHeight: 1.4, + maxWidth: 980, + display: 'flex', + }} + > + {summary.length > 160 ? `${summary.slice(0, 158)}…` : summary} + </div> + ) : ( + <div + style={{ + fontSize: 18, + color: '#86868f', + fontFamily: 'monospace', + letterSpacing: 0.5, + display: 'flex', + }} + > + Case study, presented as a deck · octifytechnologies.com + </div> + )} + </div> + </div>, + { ...size }, + ); +} diff --git a/src/app/c/[slug]/page.tsx b/src/app/c/[slug]/page.tsx new file mode 100644 index 0000000..50335a9 --- /dev/null +++ b/src/app/c/[slug]/page.tsx @@ -0,0 +1,46 @@ +import { notFound } from 'next/navigation'; +import { getCaseStudy, listCaseStudies } from '@/lib/case-studies'; +import { Viewer } from '@/components/Viewer'; +import type { Metadata } from 'next'; + +export async function generateStaticParams() { + const studies = await listCaseStudies(); + return studies.map((s) => ({ slug: s.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise<Metadata> { + const { slug } = await params; + const study = await getCaseStudy(slug); + if (!study) return {}; + const titleSuffix = study.client ? `${study.client} · Octify Case Study` : 'Octify Case Study'; + const description = + study.summary ?? `Octify case study${study.client ? ` for ${study.client}` : ''}.`; + return { + title: `${study.title} · ${titleSuffix}`, + description, + openGraph: { + title: `${study.title}${study.client ? ` — ${study.client}` : ''}`, + description, + type: 'article', + }, + twitter: { + card: 'summary_large_image', + title: study.title, + description, + }, + }; +} + +export default async function CaseStudyPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const study = await getCaseStudy(slug); + if (!study) notFound(); + + return ( + <Viewer slug={study.slug} title={study.title} client={study.client} slides={study.slides} /> + ); +} diff --git a/src/app/c/[slug]/slides/[file]/route.ts b/src/app/c/[slug]/slides/[file]/route.ts new file mode 100644 index 0000000..9ab9077 --- /dev/null +++ b/src/app/c/[slug]/slides/[file]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { readSlide } from '@/lib/case-studies'; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ slug: string; file: string }> }, +) { + const { slug, file } = await params; + const html = await readSlide(slug, file); + if (html === null) { + return new NextResponse('Not found', { status: 404 }); + } + return new NextResponse(html, { + status: 200, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'public, max-age=60, s-maxage=300', + 'X-Frame-Options': 'SAMEORIGIN', + }, + }); +} diff --git a/src/app/d/[id]/edit/page.tsx b/src/app/d/[id]/edit/page.tsx deleted file mode 100644 index 9ad8726..0000000 --- a/src/app/d/[id]/edit/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from 'next'; - -import { Editor } from '@/editor/Editor'; -import '@/editor/editor.css'; - -export const metadata: Metadata = { - title: 'Editor', -}; - -type Params = Promise<{ id: string }>; - -export default async function EditDeckPage({ params }: { params: Params }) { - const { id } = await params; - return <Editor deckId={id} />; -} diff --git a/src/app/d/[id]/page.tsx b/src/app/d/[id]/page.tsx deleted file mode 100644 index de2bc8d..0000000 --- a/src/app/d/[id]/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { redirect } from 'next/navigation'; - -type Params = Promise<{ id: string }>; - -export default async function DeckRedirect({ params }: { params: Params }) { - const { id } = await params; - redirect(`/d/${id}/edit`); -} diff --git a/src/app/d/[id]/present/page.tsx b/src/app/d/[id]/present/page.tsx deleted file mode 100644 index 3f9c65b..0000000 --- a/src/app/d/[id]/present/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from 'next'; - -import { PresentMode } from '@/present/PresentMode'; -import '@/present/present.css'; - -export const metadata: Metadata = { - title: 'Present', -}; - -type Params = Promise<{ id: string }>; - -export default async function PresentDeckPage({ params }: { params: Params }) { - const { id } = await params; - return <PresentMode deckId={id} />; -} diff --git a/src/app/globals.css b/src/app/globals.css index 9c552a9..ad3e4f2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,8 +1,122 @@ -@import '../styles/app-shell.css'; -@import '../styles/blocks.css'; -@import '../styles/layouts.css'; -@import '../styles/deck.css'; -@import '../styles/dossier.css'; +:root { + /* Surface elevations, light editorial scale. + bg = page canvas + soft = recessed panels (footer, secondary regions) + elev = raised cards on hover, chip fills, subtle insets + elev-2 = pressed / deeper inset + chip = neutral chip background */ + --bg: #ffffff; + --bg-soft: #fafafa; + --bg-elev: #f4f4f5; + --bg-elev-2: #e8e8eb; + --bg-chip: #f4f4f5; + + /* Stage = dark recess where the white slide canvas lives. */ + --stage-bg: #171717; + --stage-bg-soft: #1f1f1f; + --stage-line: #2a2a2a; + + /* Ink, near-black to soft gray. */ + --fg: #0a0a0a; + --fg-soft: #2a2a2a; + --fg-muted: #595959; + --fg-dim: #8a8a8a; + --fg-faint: #c4c4c4; + + /* Lines. */ + --line: #ececec; + --line-strong: #d4d4d4; + --line-vivid: #a3a3a3; + + /* Primary brand accent: deep indigo. Used for the active selection, + primary CTAs in the viewer, focus rings, and the editorial dot on + the index title. Never used as decoration on neutral surfaces. */ + --accent: #4f46e5; + --accent-soft: #6366f1; + --accent-strong: #4338ca; + --accent-bg: rgba(79, 70, 229, 0.06); + --accent-bg-strong: rgba(79, 70, 229, 0.1); + --accent-border: rgba(79, 70, 229, 0.32); + + /* Editorial dot color: warm amber. */ + --mark: #f59e0b; + + /* Soft tag palette, used on chips. Low saturation so the page stays + calm but gains a little warmth. */ + --tag-sky-fg: #1d4ed8; + --tag-sky-bg: #eff6ff; + --tag-sky-border: #bfdbfe; + --tag-sky-dot: #3b82f6; + + --tag-amber-fg: #b45309; + --tag-amber-bg: #fffbeb; + --tag-amber-border: #fde68a; + --tag-amber-dot: #f59e0b; + + --tag-rose-fg: #be123c; + --tag-rose-bg: #fff1f2; + --tag-rose-border: #fecdd3; + --tag-rose-dot: #f43f5e; + + --tag-emerald-fg: #047857; + --tag-emerald-bg: #ecfdf5; + --tag-emerald-border: #a7f3d0; + --tag-emerald-dot: #10b981; + + --tag-violet-fg: #6d28d9; + --tag-violet-bg: #f5f3ff; + --tag-violet-border: #ddd6fe; + --tag-violet-dot: #8b5cf6; + + /* Status colors, used on chips, dots, badges. */ + --status-ok: #15803d; + --status-ok-bg: #ecfdf5; + --status-ok-border: #bbf7d0; + + --status-warn: #b45309; + --status-warn-bg: #fffbeb; + --status-warn-border: #fde68a; + + --status-danger: #b91c1c; + --status-danger-bg: #fef2f2; + --status-danger-border: #fecaca; + + --status-info: #1d4ed8; + --status-info-bg: #eff6ff; + --status-info-border: #bfdbfe; + + --status-live: #16a34a; + + /* Type. */ + --sans: var(--font-sans), -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; + --mono: var(--font-mono), ui-monospace, 'SF Mono', Menlo, monospace; + + /* Scale. Bumped one notch across the board for a more confident read. */ + --t-micro: 12px; + --t-mono: 13px; + --t-small: 14px; + --t-body: 16px; + --t-large: 19px; + --t-h2: 32px; + + /* Radius. */ + --rad-sm: 4px; + --rad: 8px; + --rad-lg: 12px; + + /* Elevation. */ + --shadow-sm: 0 1px 2px rgba(15, 15, 20, 0.04); + --shadow: 0 1px 2px rgba(15, 15, 20, 0.04), 0 8px 24px -12px rgba(15, 15, 20, 0.08); + --shadow-lg: 0 1px 2px rgba(15, 15, 20, 0.04), 0 24px 60px -20px rgba(15, 15, 20, 0.18); + + /* Motion. */ + --ease: cubic-bezier(0.2, 0.7, 0.2, 1); + + /* Layout. */ + --nav-h: 68px; + + color-scheme: light; +} * { box-sizing: border-box; @@ -13,9 +127,94 @@ body { margin: 0; padding: 0; background: var(--bg); - color: var(--text); - font-family: var(--ui); + color: var(--fg); + font-family: var(--sans); + font-size: var(--t-body); + line-height: 1.55; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; } + +body { + min-height: 100vh; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font-family: inherit; + cursor: pointer; + color: inherit; +} + +::selection { + background: var(--accent); + color: #ffffff; +} + +:focus { + outline: none; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--rad-sm); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--line-strong); + border-radius: 10px; + border: 2px solid transparent; + background-clip: content-box; +} +::-webkit-scrollbar-thumb:hover { + background: var(--fg-dim); + background-clip: content-box; + border: 2px solid transparent; +} + +.eyebrow { + font-family: var(--mono); + font-size: var(--t-micro); + letter-spacing: 0.06em; + color: var(--fg-muted); + text-transform: uppercase; +} + +.rule { + height: 1px; + background: var(--line); + border: 0; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/src/app/home.css b/src/app/home.css new file mode 100644 index 0000000..326026c --- /dev/null +++ b/src/app/home.css @@ -0,0 +1,670 @@ +.home { + min-height: 100vh; + display: flex; + flex-direction: column; + padding-top: var(--nav-h); + position: relative; + background: var(--bg); + animation: fade 0.4s var(--ease) both; +} + +/* Reusable Chip ─────────────────────────────────────────────────── */ + +.chip { + display: inline-flex; + align-items: center; + gap: 7px; + height: 26px; + padding: 0 11px; + background: var(--chip-bg, var(--bg-chip)); + color: var(--chip-fg, var(--fg-soft)); + border: 1px solid var(--chip-border, var(--line)); + border-radius: 999px; + font-family: var(--sans); + font-size: var(--t-micro); + font-weight: 500; + letter-spacing: 0.005em; + white-space: nowrap; + transition: + background 0.15s var(--ease), + border-color 0.15s var(--ease), + color 0.15s var(--ease); +} + +.chip-tag { + text-transform: capitalize; +} + +.chip-tag .chip-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--chip-dot, var(--fg-dim)); + flex-shrink: 0; +} + +/* Hash-assigned soft tag colors. */ +.chip-tag-sky { + --chip-bg: var(--tag-sky-bg); + --chip-fg: var(--tag-sky-fg); + --chip-border: var(--tag-sky-border); + --chip-dot: var(--tag-sky-dot); +} +.chip-tag-amber { + --chip-bg: var(--tag-amber-bg); + --chip-fg: var(--tag-amber-fg); + --chip-border: var(--tag-amber-border); + --chip-dot: var(--tag-amber-dot); +} +.chip-tag-rose { + --chip-bg: var(--tag-rose-bg); + --chip-fg: var(--tag-rose-fg); + --chip-border: var(--tag-rose-border); + --chip-dot: var(--tag-rose-dot); +} +.chip-tag-emerald { + --chip-bg: var(--tag-emerald-bg); + --chip-fg: var(--tag-emerald-fg); + --chip-border: var(--tag-emerald-border); + --chip-dot: var(--tag-emerald-dot); +} +.chip-tag-violet { + --chip-bg: var(--tag-violet-bg); + --chip-fg: var(--tag-violet-fg); + --chip-border: var(--tag-violet-border); + --chip-dot: var(--tag-violet-dot); +} + +/* Status chips (used sparingly) */ +.chip-status-ok { + color: var(--status-ok); + background: var(--status-ok-bg); + border-color: var(--status-ok-border); +} +.chip-status-ok .chip-dot { + background: var(--status-ok); +} + +.chip-status-warn { + color: var(--status-warn); + background: var(--status-warn-bg); + border-color: var(--status-warn-border); +} +.chip-status-warn .chip-dot { + background: var(--status-warn); +} + +.chip-status-danger { + color: var(--status-danger); + background: var(--status-danger-bg); + border-color: var(--status-danger-border); +} +.chip-status-danger .chip-dot { + background: var(--status-danger); +} + +.chip-status-info { + color: var(--status-info); + background: var(--status-info-bg); + border-color: var(--status-info-border); +} +.chip-status-info .chip-dot { + background: var(--status-info); +} + +/* Fixed Masthead ──────────────────────────────────────────────── */ + +.masthead { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 50; + height: var(--nav-h); + background: rgba(255, 255, 255, 0.82); + backdrop-filter: saturate(180%) blur(16px); + -webkit-backdrop-filter: saturate(180%) blur(16px); + border-bottom: 1px solid var(--line); +} + +.masthead-inner { + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 40px; + max-width: 1480px; + margin: 0 auto; + gap: 16px; +} + +.wordmark { + display: inline-flex; + align-items: center; + gap: 12px; + color: var(--fg); + padding: 6px 8px; + margin-left: -8px; + border-radius: var(--rad); + transition: background 0.15s var(--ease); +} + +.wordmark:hover { + background: var(--bg-elev); +} + +.wordmark-stack { + display: flex; + flex-direction: column; + line-height: 1.1; +} + +.wordmark-text { + font-size: 17px; + font-weight: 600; + letter-spacing: -0.018em; + color: var(--fg); +} + +.wordmark-by { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--mono); + font-size: 10px; + color: var(--fg-muted); + letter-spacing: 0.04em; + margin-top: 2px; +} + +/* Live pulse uses the only chromatic exception, status green. */ +.wordmark-pulse { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--status-live); + box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.4); + animation: live-pulse 2.4s ease-in-out infinite; +} + +@keyframes live-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.4); + } + 50% { + box-shadow: 0 0 0 5px rgba(22, 163, 74, 0); + } +} + +.masthead-nav { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.masthead-link { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: var(--rad); + font-size: var(--t-small); + font-weight: 500; + color: var(--fg-muted); + letter-spacing: -0.005em; + transition: + background 0.15s var(--ease), + color 0.15s var(--ease); +} + +.masthead-link:hover { + background: var(--bg-elev); + color: var(--fg); +} + +.masthead-link svg { + opacity: 0.7; + transition: + transform 0.2s var(--ease), + opacity 0.2s var(--ease); +} + +.masthead-link:hover svg { + opacity: 1; + transform: translate(2px, -2px); +} + +.masthead-cta { + display: inline-flex; + align-items: center; + height: 34px; + padding: 0 16px; + background: var(--accent); + color: #ffffff; + border-radius: var(--rad); + font-size: var(--t-small); + font-weight: 600; + letter-spacing: -0.005em; + box-shadow: + 0 1px 2px rgba(79, 70, 229, 0.16), + 0 6px 14px -6px rgba(79, 70, 229, 0.32); + transition: + background 0.15s var(--ease), + box-shadow 0.15s var(--ease), + transform 0.06s var(--ease); +} + +.masthead-cta:hover { + background: var(--accent-soft); + box-shadow: + 0 1px 2px rgba(79, 70, 229, 0.2), + 0 10px 22px -8px rgba(79, 70, 229, 0.4); +} + +.masthead-cta:active { + transform: translateY(1px); +} + +/* Main ──────────────────────────────────────────────────────────── */ + +.home-main { + flex: 1; + width: 100%; + max-width: 1280px; + margin: 0 auto; + padding: 0 40px; +} + +/* Index ─────────────────────────────────────────────────────────── */ + +.index { + padding: 96px 0 120px; +} + +.index-title { + margin: 0 0 64px; + font-size: clamp(44px, 6vw, 72px); + font-weight: 500; + letter-spacing: -0.032em; + line-height: 1; + color: var(--fg); + animation: rise 0.55s var(--ease) 0.05s both; +} + +.index-title-dot { + color: var(--mark); +} + +.entries { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + border-top: 1px solid var(--line); +} + +.entry { + border-bottom: 1px solid var(--line); + animation: rise 0.55s var(--ease) both; + opacity: 0; +} + +.entry-link { + display: grid; + grid-template-columns: 80px 1fr 380px; + gap: 40px; + padding: 48px 24px; + margin: 0 -24px; + align-items: start; + position: relative; + transition: background 0.25s var(--ease); + border-radius: var(--rad-lg); +} + +.entry-link:hover { + background: var(--bg-soft); +} + +.entry-num { + font-family: var(--mono); + font-size: 15px; + color: var(--fg-dim); + font-feature-settings: 'tnum' on; + font-variant-numeric: tabular-nums; + padding-top: 10px; + transition: color 0.2s var(--ease); + letter-spacing: 0.06em; +} + +.entry-link:hover .entry-num { + color: var(--accent); +} + +.entry-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.entry-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + font-size: var(--t-small); + color: var(--fg-muted); +} + +.entry-client { + font-weight: 600; + color: var(--fg); + font-size: var(--t-small); +} + +.entry-meta-dot { + color: var(--fg-faint); +} + +.entry-industry { + color: var(--fg-soft); +} + +.entry-date { + margin-left: auto; + font-family: var(--mono); + font-size: var(--t-micro); + color: var(--fg-dim); + letter-spacing: 0.04em; +} + +.entry-title { + margin: 0; + font-size: clamp(26px, 2.4vw, 34px); + font-weight: 500; + letter-spacing: -0.026em; + line-height: 1.16; + color: var(--fg); + max-width: 26ch; + transition: color 0.2s var(--ease); +} + +.entry-summary { + margin: 0; + font-size: 17px; + line-height: 1.55; + color: var(--fg-muted); + max-width: 54ch; +} + +.entry-foot { + display: flex; + align-items: center; + gap: 12px; + margin-top: 6px; + flex-wrap: wrap; +} + +.entry-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.entry-count { + font-family: var(--mono); + font-size: var(--t-micro); + color: var(--fg-muted); + letter-spacing: 0.04em; + padding-left: 12px; + border-left: 1px solid var(--line-strong); + margin-left: auto; +} + +.entry-arrow { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--fg-muted); + transition: + color 0.2s var(--ease), + gap 0.2s var(--ease); +} + +.entry-arrow-text { + font-family: var(--mono); + font-size: var(--t-micro); + letter-spacing: 0.06em; + text-transform: uppercase; + color: inherit; + font-weight: 600; +} + +.entry-arrow svg { + transition: transform 0.25s var(--ease); +} + +.entry-link:hover .entry-arrow { + color: var(--accent); + gap: 10px; +} + +.entry-link:hover .entry-arrow svg { + transform: translateX(3px); +} + +.entry-cover { + position: relative; + width: 100%; + border: 1px solid var(--line-strong); + background: var(--stage-bg); + border-radius: var(--rad-lg); + overflow: hidden; + box-shadow: var(--shadow); + transition: + border-color 0.25s var(--ease), + box-shadow 0.25s var(--ease), + transform 0.25s var(--ease); + align-self: stretch; +} + +.entry-cover .slide-wrap { + border-radius: 0; + background: var(--stage-bg); +} + +.entry-link:hover .entry-cover { + border-color: var(--accent); + box-shadow: var(--shadow-lg); + transform: translateY(-3px); +} + +/* Empty ─────────────────────────────────────────────────────────── */ + +.empty { + padding: 96px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: var(--fg-muted); +} + +.empty-icon { + color: var(--fg-faint); + margin-bottom: 8px; +} + +.empty-line { + margin: 0; + font-size: var(--t-large); + font-weight: 500; + color: var(--fg-soft); +} + +.empty-hint { + margin: 0; + font-size: var(--t-small); +} + +/* Footer ────────────────────────────────────────────────────────── */ + +.home-footer { + margin-top: auto; + padding: 40px 40px 28px; + border-top: 1px solid var(--line); + background: var(--bg-soft); +} + +.home-footer-inner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + max-width: 1280px; + margin: 0 auto; + padding-bottom: 28px; + border-bottom: 1px solid var(--line); +} + +.home-footer-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.home-footer-brand-text { + display: flex; + flex-direction: column; + line-height: 1.25; +} + +.home-footer-name { + font-size: 14px; + font-weight: 600; + color: var(--fg); + letter-spacing: -0.012em; +} + +.home-footer-tagline { + margin-top: 3px; + font-size: 10px; + text-transform: none; + letter-spacing: 0.02em; + color: var(--fg-muted); +} + +.home-footer-links { + display: flex; + align-items: center; + gap: 24px; +} + +.home-footer-link { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: var(--t-small); + color: var(--fg-soft); + font-weight: 500; + transition: color 0.15s var(--ease); +} + +.home-footer-link:hover { + color: var(--fg); +} + +.home-footer-baseline { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1280px; + margin: 16px auto 0; +} + +.home-footer-baseline .eyebrow { + font-size: 10px; + color: var(--fg-dim); +} + +/* Responsive ────────────────────────────────────────────────────── */ + +@media (max-width: 1100px) { + .home-main, + .masthead-inner, + .home-footer { + padding-left: 32px; + padding-right: 32px; + } + .entry-link { + grid-template-columns: 60px 1fr 280px; + gap: 28px; + padding: 36px 0; + } +} + +@media (max-width: 820px) { + .home-main, + .masthead-inner, + .home-footer { + padding-left: 20px; + padding-right: 20px; + } + .index { + padding: 56px 0 96px; + } + .index-title { + margin-bottom: 40px; + } + .entry-link { + grid-template-columns: 1fr; + grid-template-areas: + 'cover' + 'body'; + gap: 20px; + padding: 28px 0; + } + .entry-link:hover { + background: transparent; + } + .entry-num { + display: none; + } + .entry-body { + grid-area: body; + } + .entry-cover { + grid-area: cover; + } + .entry-date { + display: none; + } + .wordmark-by { + display: none; + } + .masthead-link { + display: none; + } + .home-footer-inner { + flex-direction: column; + align-items: flex-start; + gap: 20px; + } + .home-footer-baseline { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } +} + +@media (prefers-reduced-motion: reduce) { + .entry, + .home, + .index-title { + animation: none !important; + opacity: 1 !important; + } +} diff --git a/src/app/icon.tsx b/src/app/icon.tsx index 85627aa..f422afc 100644 --- a/src/app/icon.tsx +++ b/src/app/icon.tsx @@ -16,9 +16,9 @@ export default function Icon() { }} > <svg width="32" height="32" viewBox="0 0 22 22" fill="none"> - <rect x="1.8" y="6.9" width="12" height="12" rx="2" fill="#6366f1" /> - <rect x="5" y="5" width="12" height="12" rx="2" fill="#ec4899" /> - <rect x="8.2" y="3.1" width="12" height="12" rx="2" fill="#f59e0b" /> + <rect x="1.8" y="6.9" width="12" height="12" rx="2.6" fill="#d4d4d8" /> + <rect x="5" y="5" width="12" height="12" rx="2.6" fill="#737373" /> + <rect x="8.2" y="3.1" width="12" height="12" rx="2.6" fill="#0a0a0a" /> </svg> </div>, { ...size }, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8c62ac4..3252b51 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,83 +1,33 @@ import type { Metadata, Viewport } from 'next'; -import { - DM_Sans, - Fraunces, - Geist, - Inter, - Inter_Tight, - JetBrains_Mono, - Manrope, - Space_Grotesk, -} from 'next/font/google'; +import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; -// User-pickable faces plus the project's mono. Every entry must have a -// matching definition in `src/themes/fonts.ts` for the font picker to -// surface it. Fraunces + Inter Tight are the Dossier preset's display+body -// pair; the others are the original sans catalog. -const geist = Geist({ subsets: ['latin'], variable: '--font-geist', display: 'swap' }); -const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }); -const spaceGrotesk = Space_Grotesk({ +const geist = Geist({ subsets: ['latin'], - variable: '--font-space-grotesk', + variable: '--font-sans', display: 'swap', }); -const dmSans = DM_Sans({ subsets: ['latin'], variable: '--font-dm-sans', display: 'swap' }); -const manrope = Manrope({ subsets: ['latin'], variable: '--font-manrope', display: 'swap' }); -const fraunces = Fraunces({ - subsets: ['latin'], - variable: '--font-fraunces', - style: ['normal', 'italic'], - display: 'swap', - axes: ['SOFT', 'WONK', 'opsz'], -}); -const interTight = Inter_Tight({ - subsets: ['latin'], - variable: '--font-inter-tight', - display: 'swap', -}); -const jetbrains = JetBrains_Mono({ + +const geistMono = Geist_Mono({ subsets: ['latin'], - variable: '--font-jetbrains', + variable: '--font-mono', display: 'swap', }); const SITE_URL = 'https://stackdeck.octifytechnologies.com'; const SITE_NAME = 'stackdeck'; -const DEFAULT_TITLE = 'stackdeck, open-source markdown slide deck builder'; -const DEFAULT_DESCRIPTION = - 'Turn a markdown file into a beautiful slide deck. Switch themes instantly. Export to PDF. No backend, no accounts, no lock-in. Open source.'; +const DEFAULT_TITLE = 'stackdeck'; +const DEFAULT_DESCRIPTION = 'Case studies, presented as decks.'; export const metadata: Metadata = { metadataBase: new URL(SITE_URL), - title: { - default: DEFAULT_TITLE, - template: '%s · stackdeck', - }, + title: { default: DEFAULT_TITLE, template: '%s · stackdeck' }, description: DEFAULT_DESCRIPTION, applicationName: SITE_NAME, - generator: 'Next.js', - keywords: [ - 'markdown slides', - 'markdown to pdf', - 'presentation tool', - 'slide deck', - 'open source slides', - 'markdown presentation', - 'slide generator', - 'themed slides', - 'static slide deck', - 'slide deck builder', - 'pitch deck maker', - 'markdown presentation tool', - ], - authors: [{ name: 'Octify Technologies', url: 'https://github.com/Octify-Technologies' }], + authors: [{ name: 'Octify Technologies' }], creator: 'Octify Technologies', publisher: 'Octify Technologies', - category: 'technology', - alternates: { - canonical: '/', - }, + alternates: { canonical: '/' }, openGraph: { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION, @@ -86,73 +36,20 @@ export const metadata: Metadata = { locale: 'en_US', type: 'website', }, - twitter: { - card: 'summary_large_image', - title: DEFAULT_TITLE, - description: DEFAULT_DESCRIPTION, - creator: '@stackdeck', - }, - robots: { - index: true, - follow: true, - googleBot: { - index: true, - follow: true, - 'max-image-preview': 'large', - 'max-snippet': -1, - 'max-video-preview': -1, - }, - }, - formatDetection: { - email: false, - address: false, - telephone: false, - }, + robots: { index: true, follow: true }, }; export const viewport: Viewport = { - themeColor: [ - { media: '(prefers-color-scheme: light)', color: '#ffffff' }, - { media: '(prefers-color-scheme: dark)', color: '#0b0b0f' }, - ], - colorScheme: 'light dark', + themeColor: '#ffffff', + colorScheme: 'light', width: 'device-width', initialScale: 1, }; -const jsonLd = { - '@context': 'https://schema.org', - '@type': 'SoftwareApplication', - name: SITE_NAME, - description: DEFAULT_DESCRIPTION, - url: SITE_URL, - applicationCategory: 'BusinessApplication', - operatingSystem: 'Web', - offers: { - '@type': 'Offer', - price: '0', - priceCurrency: 'USD', - }, - author: { - '@type': 'Organization', - name: 'Octify Technologies', - url: 'https://github.com/Octify-Technologies', - }, -}; - export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - <html - lang="en" - className={`${geist.variable} ${inter.variable} ${spaceGrotesk.variable} ${dmSans.variable} ${manrope.variable} ${fraunces.variable} ${interTight.variable} ${jetbrains.variable}`} - > - <body> - <script - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} - /> - {children} - </body> + <html lang="en" className={`${geist.variable} ${geistMono.variable}`}> + <body>{children}</body> </html> ); } diff --git a/src/app/new/NewDeckGallery.tsx b/src/app/new/NewDeckGallery.tsx deleted file mode 100644 index 3ca0d1b..0000000 --- a/src/app/new/NewDeckGallery.tsx +++ /dev/null @@ -1,301 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useMemo, useState } from 'react'; - -import { ParseError, parseDeck } from '@/ir/parse'; -import { planDeck } from '@/ir/plan'; -import { createDeck } from '@/storage/deck-store'; -import { DeckRenderer } from '@/render/DeckRenderer'; -import { - AppTopbar, - Caption, - GalleryGrid, - Heading, - Label, - PageMain, - PageShell, - PageWorkbar, -} from '@/components'; - -import { TEMPLATES, type Template } from '@/app/templates/templates'; -import { PRESETS, getPreset, type Preset } from '@/app/presets/presets'; - -const STARTER_MARKDOWN = `--- -title: New deck ---- - -::cover -# A new beginning. -Replace this with your own title. -:: - -::slide - -# Highlights - -- First key point -- Second key point -- Third key point - -::slide - -::stats -::stat{value="100%" label="Awesome"} -::stat{value="Now" label="Time to ship"} -:: - -::slide - -::quote.big -> The best way to predict the future is to invent it. -> -- Alan Kay -:: -`; - -type Picked = { template: Template | null; preset: Preset | null }; - -export function NewDeckGallery() { - const router = useRouter(); - const [picked, setPicked] = useState<Picked | null>(null); - - const create = async (template: Template | null, preset: Preset) => { - try { - const deck = await createDeck({ - source: template?.seed ?? STARTER_MARKDOWN, - theme: { - presetId: preset.id, - paletteId: preset.paletteId, - fontId: preset.fontId, - }, - templateName: template?.name, - }); - router.push(`/d/${deck.id}/edit`); - } catch (err) { - const message = err instanceof Error ? err.message : 'Could not create deck'; - window.alert(`Could not create the deck: ${message}`); - } - }; - - if (picked) { - return ( - <PresetPicker - picked={picked} - onCancel={() => setPicked(null)} - onConfirm={(preset) => create(picked.template, preset)} - /> - ); - } - - return ( - <PageShell className="presets-page"> - <AppTopbar /> - - <PageWorkbar - back={{ href: '/', label: 'Library', ariaLabel: 'Back to library' }} - title="Start a deck" - count={`${TEMPLATES.length + 1} ${TEMPLATES.length + 1 === 1 ? 'option' : 'options'}`} - subtitle="Pick a template (the content) or start blank. Next step is the preset (the design)." - /> - - <PageMain> - <GalleryGrid> - <BlankCard - onClick={() => { - const defaultPreset = PRESETS[0]; - if (!defaultPreset) return; - setPicked({ template: null, preset: null }); - }} - /> - {TEMPLATES.map((template) => ( - <TemplateCard - key={template.id} - template={template} - onClick={() => setPicked({ template, preset: null })} - /> - ))} - </GalleryGrid> - </PageMain> - </PageShell> - ); -} - -function BlankCard({ onClick }: { onClick: () => void }) { - return ( - <button - type="button" - className="surface-card surface-card--dashed preset-card preset-card--blank" - onClick={onClick} - > - <div className="preset-card__preview preset-card__preview--blank"> - <span aria-hidden>+</span> - </div> - <div className="preset-card__meta"> - <Heading level={3} size="md"> - Blank deck - </Heading> - <Caption>Start from scratch. Pick a preset on the next step.</Caption> - </div> - </button> - ); -} - -function TemplateCard({ template, onClick }: { template: Template; onClick: () => void }) { - const preset = getPreset(template.recommendedPresetId); - - const previewDeck = useMemo(() => { - if (!preset) return { ok: false as const, error: 'Preset missing' }; - try { - const parsed = parseDeck(template.seed, { - theme: { - presetId: preset.id, - paletteId: preset.paletteId, - fontId: preset.fontId, - }, - }); - const planned = planDeck(parsed); - return { ok: true as const, deck: { ...planned, slides: planned.slides.slice(0, 1) } }; - } catch (e) { - const message = e instanceof ParseError ? e.message : (e as Error).message; - return { ok: false as const, error: message }; - } - }, [template, preset]); - - return ( - <button - type="button" - className="surface-card preset-card" - data-preset={template.recommendedPresetId} - onClick={onClick} - > - <div className="preset-card__preview"> - <div className="preset-card__scaler"> - {previewDeck.ok ? <DeckRenderer deck={previewDeck.deck} /> : null} - </div> - <span className="preset-card__chip" data-preset={template.recommendedPresetId}> - {template.category} - </span> - </div> - <div className="preset-card__meta"> - <Heading level={3} size="md"> - {template.name} - </Heading> - <Caption>{template.vibe}</Caption> - <div className="preset-card__tags"> - <Label className="preset-card__tag">{template.category}</Label> - <Label className="preset-card__tag">{template.slideCount} slides</Label> - </div> - </div> - </button> - ); -} - -function PresetPicker({ - picked, - onCancel, - onConfirm, -}: { - picked: Picked; - onCancel: () => void; - onConfirm: (preset: Preset) => void; -}) { - return ( - <PageShell className="presets-page"> - <AppTopbar /> - - <PageWorkbar - back={{ href: '#', label: 'Back', ariaLabel: 'Back to template choice' }} - title={picked.template ? `${picked.template.name} → pick a design` : 'Pick a design'} - count={`${PRESETS.length} ${PRESETS.length === 1 ? 'preset' : 'presets'}`} - subtitle={ - picked.template ? 'Each preset re-skins the same content.' : 'Blank deck. Pick a design.' - } - /> - - <PageMain> - <GalleryGrid> - {PRESETS.map((preset) => ( - <PresetChoiceCard - key={preset.id} - preset={preset} - template={picked.template} - onConfirm={() => onConfirm(preset)} - /> - ))} - </GalleryGrid> - <button - type="button" - onClick={onCancel} - style={{ - margin: '24px auto', - display: 'block', - background: 'transparent', - border: 0, - color: 'var(--text-muted)', - cursor: 'pointer', - fontSize: 13, - }} - > - ← Pick a different template - </button> - </PageMain> - </PageShell> - ); -} - -function PresetChoiceCard({ - preset, - template, - onConfirm, -}: { - preset: Preset; - template: Template | null; - onConfirm: () => void; -}) { - const seed = template?.seed ?? STARTER_MARKDOWN; - - const previewDeck = useMemo(() => { - try { - const parsed = parseDeck(seed, { - theme: { - presetId: preset.id, - paletteId: preset.paletteId, - fontId: preset.fontId, - }, - }); - const planned = planDeck(parsed); - return { ok: true as const, deck: { ...planned, slides: planned.slides.slice(0, 3) } }; - } catch (e) { - const message = e instanceof ParseError ? e.message : (e as Error).message; - return { ok: false as const, error: message }; - } - }, [preset, seed]); - - return ( - <button - type="button" - className="surface-card preset-card preset-card--multi" - data-preset={preset.id} - onClick={onConfirm} - > - <div className="preset-card__preview preset-card__preview--multi"> - <div className="preset-card__scaler preset-card__scaler--multi"> - {previewDeck.ok ? <DeckRenderer deck={previewDeck.deck} /> : null} - </div> - <span className="preset-card__chip" data-preset={preset.id}> - design - </span> - </div> - <div className="preset-card__meta"> - <Heading level={3} size="md"> - {preset.name} - </Heading> - <Caption>{preset.vibe}</Caption> - <div className="preset-card__tags"> - <Label className="preset-card__tag">{preset.paletteId}</Label> - <Label className="preset-card__tag">{preset.fontId}</Label> - </div> - </div> - </button> - ); -} diff --git a/src/app/new/page.tsx b/src/app/new/page.tsx deleted file mode 100644 index 7004427..0000000 --- a/src/app/new/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from 'next'; - -import { NewDeckGallery } from './NewDeckGallery'; -import '@/app/presets/presets.css'; - -export const metadata: Metadata = { - title: 'New deck, pick a preset', - description: - 'Start a new slide deck from a curated preset. Pitch decks, editorials, brutalist manifestos, and more.', - alternates: { canonical: '/new' }, -}; - -export default function NewDeckPage() { - return <NewDeckGallery />; -} diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx index f343b49..3b14c16 100644 --- a/src/app/opengraph-image.tsx +++ b/src/app/opengraph-image.tsx @@ -1,6 +1,6 @@ import { ImageResponse } from 'next/og'; -export const alt = 'stackdeck — open-source markdown slide deck builder'; +export const alt = 'stackdeck — case studies by Octify Technologies'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; @@ -10,40 +10,52 @@ export default function OpenGraphImage() { style={{ width: '100%', height: '100%', - background: 'linear-gradient(135deg, #0b0b0f 0%, #1a1530 60%, #2a1240 100%)', + background: '#0a0a0c', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 80, fontFamily: 'sans-serif', + color: '#f4f4f6', }} > - <div style={{ display: 'flex', alignItems: 'center', gap: 24 }}> - <svg width="84" height="84" viewBox="0 0 22 22" fill="none"> - <rect x="1.8" y="6.9" width="12" height="12" rx="2" fill="#6366f1" /> - <rect x="5" y="5" width="12" height="12" rx="2" fill="#ec4899" /> - <rect x="8.2" y="3.1" width="12" height="12" rx="2" fill="#f59e0b" /> + <div style={{ display: 'flex', alignItems: 'center', gap: 18 }}> + <svg width="56" height="56" viewBox="0 0 22 22" fill="none"> + <rect x="1.8" y="6.9" width="12" height="12" rx="2.6" fill="#3f3f46" /> + <rect x="5" y="5" width="12" height="12" rx="2.6" fill="#a1a1aa" /> + <rect x="8.2" y="3.1" width="12" height="12" rx="2.6" fill="#fafafa" /> </svg> - <div style={{ color: '#fff', fontSize: 56, fontWeight: 700, letterSpacing: -1 }}> - stackdeck + <div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.1 }}> + <div style={{ fontSize: 34, fontWeight: 600, letterSpacing: -0.5 }}>stackdeck</div> + <div style={{ fontSize: 15, color: '#86868f', marginTop: 4, fontFamily: 'monospace' }}> + by Octify Technologies + </div> </div> </div> <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}> <div style={{ - color: '#fff', - fontSize: 76, - fontWeight: 700, - lineHeight: 1.05, - letterSpacing: -2, - maxWidth: 980, + display: 'flex', + fontSize: 124, + fontWeight: 500, + lineHeight: 0.95, + letterSpacing: -3.5, + maxWidth: 1000, }} > - Markdown in. Beautiful slides out. + <span>Selected work</span> + <span style={{ color: '#fafafa' }}>.</span> </div> - <div style={{ color: '#b8b3c7', fontSize: 32, lineHeight: 1.3, maxWidth: 900 }}> - Open-source slide deck builder. Switch themes instantly. Export to PDF. + <div + style={{ + fontSize: 22, + color: '#86868f', + fontFamily: 'monospace', + letterSpacing: 0.5, + }} + > + Case studies, presented as decks · octifytechnologies.com </div> </div> </div>, diff --git a/src/app/page.tsx b/src/app/page.tsx index 5adca45..9523332 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,15 +1,218 @@ -import type { Metadata } from 'next'; +import Link from 'next/link'; +import { listCaseStudies, type CaseStudy } from '@/lib/case-studies'; +import { SlideFrame } from '@/components/SlideFrame'; +import { StackdeckMark } from '@/components/StackdeckMark'; +import '@/components/SlideFrame.css'; +import './home.css'; -import { DeckLibrary } from '@/library/DeckLibrary'; -import '@/library/library.css'; +export const dynamic = 'force-static'; -export const metadata: Metadata = { - title: 'Your decks', - description: - 'Your library of markdown slide decks. Create, edit, and export beautiful presentations with stackdeck.', - alternates: { canonical: '/' }, -}; +const OCTIFY_URL = 'https://octifytechnologies.com'; +const CONTACT_EMAIL = 'ankur@octifytechnologies.com'; -export default function HomePage() { - return <DeckLibrary />; +export default async function Home() { + const studies = await listCaseStudies(); + + return ( + <div className="home"> + <header className="masthead"> + <div className="masthead-inner"> + <Link href="/" className="wordmark" aria-label="stackdeck"> + <StackdeckMark size={28} /> + <span className="wordmark-stack"> + <span className="wordmark-text">stackdeck</span> + <span className="wordmark-by"> + <span className="wordmark-pulse" aria-hidden /> + by Octify Technologies + </span> + </span> + </Link> + <nav className="masthead-nav"> + <a + href={OCTIFY_URL} + className="masthead-link" + target="_blank" + rel="noreferrer noopener" + > + <span>Visit Octify</span> + <ExternalIcon /> + </a> + <a href={`mailto:${CONTACT_EMAIL}`} className="masthead-cta"> + Get in touch + </a> + </nav> + </div> + </header> + + <main className="home-main"> + <section className="index"> + <h1 className="index-title"> + Case studies<span className="index-title-dot">.</span> + </h1> + + {studies.length === 0 ? ( + <Empty /> + ) : ( + <ol className="entries"> + {studies.map((s, i) => ( + <Entry key={s.slug} study={s} index={i} /> + ))} + </ol> + )} + </section> + </main> + + <footer className="home-footer"> + <div className="home-footer-inner"> + <div className="home-footer-brand"> + <StackdeckMark size={20} /> + <div className="home-footer-brand-text"> + <span className="home-footer-name">Octify Technologies</span> + <span className="eyebrow home-footer-tagline">Selected work, presented.</span> + </div> + </div> + <div className="home-footer-links"> + <a + href={OCTIFY_URL} + className="home-footer-link" + target="_blank" + rel="noreferrer noopener" + > + Website <ExternalIcon size={10} /> + </a> + <a href={`mailto:${CONTACT_EMAIL}`} className="home-footer-link"> + {CONTACT_EMAIL} + </a> + </div> + </div> + <div className="home-footer-baseline"> + <span className="eyebrow">© {new Date().getFullYear()} Octify Technologies</span> + <span className="eyebrow">All rights reserved</span> + </div> + </footer> + </div> + ); +} + +const TAG_TONES = ['sky', 'amber', 'rose', 'emerald', 'violet'] as const; + +function tagTone(tag: string): (typeof TAG_TONES)[number] { + let h = 0; + for (let i = 0; i < tag.length; i++) h = (h * 31 + tag.charCodeAt(i)) >>> 0; + return TAG_TONES[h % TAG_TONES.length]; +} + +function Entry({ study, index }: { study: CaseStudy; index: number }) { + const num = String(index + 1).padStart(2, '0'); + const date = study.date ? formatDate(study.date) : null; + const tags = (study.tags ?? []).slice(0, 3); + + return ( + <li className="entry" style={{ animationDelay: `${100 + index * 60}ms` }}> + <Link href={`/c/${study.slug}`} className="entry-link"> + <span className="entry-num" aria-hidden> + {num} + </span> + + <div className="entry-body"> + <div className="entry-meta"> + {study.client ? <span className="entry-client">{study.client}</span> : null} + {study.industry ? ( + <> + <span className="entry-meta-dot" aria-hidden> + · + </span> + <span className="entry-industry">{study.industry}</span> + </> + ) : null} + {date ? <span className="entry-date">{date}</span> : null} + </div> + + <h2 className="entry-title">{study.title}</h2> + + {study.summary ? <p className="entry-summary">{study.summary}</p> : null} + + <div className="entry-foot"> + {tags.length > 0 ? ( + <div className="entry-tags"> + {tags.map((t) => ( + <span key={t} className={`chip chip-tag chip-tag-${tagTone(t)}`}> + <span className="chip-dot" aria-hidden /> + {t} + </span> + ))} + </div> + ) : null} + <span className="entry-count"> + {study.slides.length} {study.slides.length === 1 ? 'slide' : 'slides'} + </span> + <span className="entry-arrow" aria-hidden> + <span className="entry-arrow-text">Read</span> + <svg width="16" height="12" viewBox="0 0 16 12" fill="none"> + <path + d="M1 6h14m0 0L10 1m5 5l-5 5" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </span> + </div> + </div> + + <div className="entry-cover"> + <SlideFrame + src={`/c/${study.slug}/slides/${study.cover}`} + title={study.title} + lazy + interactive={false} + /> + </div> + </Link> + </li> + ); +} + +function Empty() { + return ( + <div className="empty"> + <div className="empty-icon" aria-hidden> + <svg width="40" height="40" viewBox="0 0 40 40" fill="none"> + <rect + x="6" + y="10" + width="28" + height="20" + rx="3" + stroke="currentColor" + strokeWidth="1.5" + strokeDasharray="3 3" + /> + </svg> + </div> + <p className="empty-line">No case studies published yet.</p> + <p className="empty-hint">Check back soon, or get in touch with Octify directly.</p> + </div> + ); +} + +function ExternalIcon({ size = 11 }: { size?: number }) { + return ( + <svg width={size} height={size} viewBox="0 0 12 12" fill="none" aria-hidden> + <path + d="M4 2h6m0 0v6m0-6L4.5 7.5M3 4.5V10h5.5" + stroke="currentColor" + strokeWidth="1.4" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function formatDate(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); } diff --git a/src/app/presets/PresetsGallery.tsx b/src/app/presets/PresetsGallery.tsx deleted file mode 100644 index 108fcf6..0000000 --- a/src/app/presets/PresetsGallery.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useMemo } from 'react'; - -import { ParseError, parseDeck } from '@/ir/parse'; -import { planDeck } from '@/ir/plan'; -import { createDeck } from '@/storage/deck-store'; -import { DeckRenderer } from '@/render/DeckRenderer'; -import { - AppTopbar, - Caption, - GalleryGrid, - Heading, - Label, - PageMain, - PageWorkbar, - PageShell, -} from '@/components'; - -import { PRESETS, type Preset } from './presets'; -import { getTemplate } from '@/app/templates/templates'; - -export function PresetsGallery() { - const router = useRouter(); - - const applyPreset = async (preset: Preset) => { - const tpl = getTemplate(preset.previewTemplateId); - try { - const deck = await createDeck({ - source: tpl?.seed ?? '# Blank deck\n', - theme: { - presetId: preset.id, - paletteId: preset.paletteId, - fontId: preset.fontId, - }, - templateName: preset.name, - }); - router.push(`/d/${deck.id}/edit`); - } catch (err) { - const message = err instanceof Error ? err.message : 'Could not create deck'; - window.alert(`Could not create the deck: ${message}`); - } - }; - - return ( - <PageShell className="presets-page"> - <AppTopbar /> - - <PageWorkbar - back={{ href: '/', label: 'Library', ariaLabel: 'Back to library' }} - title="Presets" - count={`${PRESETS.length} ${PRESETS.length === 1 ? 'design' : 'designs'}`} - subtitle="Visual designs. Pick a look. Pair with a template at /templates, or spawn a starter deck right from here." - /> - - <PageMain> - <GalleryGrid> - {PRESETS.map((preset) => ( - <PresetCard key={preset.id} preset={preset} onApply={() => applyPreset(preset)} /> - ))} - </GalleryGrid> - </PageMain> - </PageShell> - ); -} - -function PresetCard({ preset, onApply }: { preset: Preset; onApply: () => void }) { - const previewTemplate = getTemplate(preset.previewTemplateId); - - const previewDeck = useMemo(() => { - if (!previewTemplate) return { ok: false as const, error: 'Preview template missing' }; - try { - const parsed = parseDeck(previewTemplate.seed, { - theme: { - presetId: preset.id, - paletteId: preset.paletteId, - fontId: preset.fontId, - }, - }); - const planned = planDeck(parsed); - return { ok: true as const, deck: { ...planned, slides: planned.slides.slice(0, 3) } }; - } catch (e) { - const message = e instanceof ParseError ? e.message : (e as Error).message; - return { ok: false as const, error: message }; - } - }, [preset, previewTemplate]); - - return ( - <button - type="button" - className="surface-card preset-card preset-card--multi" - data-preset={preset.id} - onClick={onApply} - > - <div className="preset-card__preview preset-card__preview--multi"> - <div className="preset-card__scaler preset-card__scaler--multi"> - {previewDeck.ok ? <DeckRenderer deck={previewDeck.deck} /> : null} - </div> - <span className="preset-card__chip" data-preset={preset.id}> - design - </span> - </div> - <div className="preset-card__meta"> - <Heading level={3} size="md"> - {preset.name} - </Heading> - <Caption>{preset.vibe}</Caption> - <div className="preset-card__tags"> - <Label className="preset-card__tag">{preset.paletteId}</Label> - <Label className="preset-card__tag">{preset.fontId}</Label> - </div> - </div> - </button> - ); -} diff --git a/src/app/presets/page.tsx b/src/app/presets/page.tsx deleted file mode 100644 index a324196..0000000 --- a/src/app/presets/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from 'next'; - -import { PresetsGallery } from './PresetsGallery'; -import './presets.css'; - -export const metadata: Metadata = { - title: 'Presets', - description: - 'Visual designs for your slide decks. Each preset is a typography and color system you can pair with any template.', - alternates: { canonical: '/presets' }, -}; - -export default function PresetsPage() { - return <PresetsGallery />; -} diff --git a/src/app/presets/presets.css b/src/app/presets/presets.css deleted file mode 100644 index df2c947..0000000 --- a/src/app/presets/presets.css +++ /dev/null @@ -1,132 +0,0 @@ -/* ========================================================================== - presets / new-deck gallery — page-specific styles - Page shell, workbar, grid, and surface card live in shared primitives - (components/layout/page.css and primitives/*). This file owns ONLY - preset-card unique parts: preview frame + scaler, blank-card plus - icon, and per-template accent + tag chip. - ========================================================================== */ - -/* Per-preset accent colors used by .surface-card[data-preset=*]. Add a - selector here for each new preset id to color its tag chips. */ - -/* Preview window - --------------------------------------------------------------------------*/ - -.preset-card__preview { - position: relative; - width: 100%; - aspect-ratio: 16 / 9; - background: #050504; - overflow: hidden; - border-bottom: 1px solid var(--line); -} - -/* Multi-slide preview shows 3 stacked 16:9 slides. */ -.preset-card__preview--multi { - aspect-ratio: 16 / 11; -} - -.preset-card--blank .preset-card__preview { - background: transparent; -} - -.preset-card__preview--blank { - display: grid; - place-items: center; - background: transparent; -} - -.preset-card__preview--blank span { - width: 40px; - height: 40px; - display: grid; - place-items: center; - border-radius: 999px; - background: var(--surface-strong); - border: 1px solid var(--line-strong); - color: var(--text); - font-size: 18px; - font-weight: 300; - line-height: 1; -} - -/* The 1280×720 deck preview is rendered at full size and scaled down - to fit the card width. */ -.preset-card__scaler { - position: absolute; - top: 0; - left: 0; - width: 1280px; - height: 720px; - transform: scale(calc(320 / 1280)); - transform-origin: top left; - pointer-events: none; -} - -.preset-card__scaler--multi { - height: auto; -} - -.preset-card__scaler .deck { - padding: 0; - gap: 0; - align-items: stretch; -} - -.preset-card__scaler--multi .deck { - display: flex; - flex-direction: column; - gap: 4px; -} - -.preset-card__scaler .slide-frame { - width: 1280px; - max-width: 1280px; - height: 720px; - border-radius: 0; - border: none; - box-shadow: none; -} - -/* Slide-count badge in the preview corner */ -.preset-card__count { - position: absolute; - top: 10px; - right: 10px; - padding: 4px 10px; - border-radius: 999px; - background: rgba(0, 0, 0, 0.55); - backdrop-filter: blur(8px); - color: #fff; - font-size: 11px; - font-weight: 500; - letter-spacing: 0.02em; - z-index: 2; -} - -/* Meta block under the preview - --------------------------------------------------------------------------*/ - -.preset-card__meta { - padding: 14px 16px 16px; - display: flex; - flex-direction: column; - gap: 6px; -} - -.preset-card__tags { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 10px; -} - -/* Tag chip — applied alongside .t-label, keeps mono uppercase typography - from the primitive and adds the accent-colored pill shape. */ -.preset-card__tag { - padding: 2px 8px; - background: rgba(var(--accent), 0.06); - color: rgba(var(--accent), 0.85); - border-radius: 999px; - border: 1px solid rgba(var(--accent), 0.18); -} diff --git a/src/app/presets/presets.ts b/src/app/presets/presets.ts deleted file mode 100644 index 8115923..0000000 --- a/src/app/presets/presets.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * A Preset is the design surface: a hand-designed deck embedded as code in - * this repo. To register a new preset, ship its bespoke JSX components, - * scoped CSS, palette, and curated demo template, then add the record here. - */ -export type Preset = { - id: string; - name: string; - vibe: string; - paletteId: string; - fontId: string; - previewTemplateId: string; -}; - -export const PRESETS: Preset[] = [ - { - id: 'dossier', - name: 'Dossier Noir', - vibe: 'Editorial case study, dark mode. Warm-paper near-black ground, italic Fraunces display, hairline rules, mono labels. Reads like a financial report crossed with a Monocle case feature.', - paletteId: 'dossier', - fontId: 'fraunces', - previewTemplateId: 'dossier-case-study', - }, -]; - -export const DEFAULT_PRESET_ID = 'dossier'; - -export function getPreset(id: string): Preset | undefined { - return PRESETS.find((p) => p.id === id); -} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 9707b18..f9d48bb 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,13 +1,17 @@ import type { MetadataRoute } from 'next'; +import { listCaseStudies } from '@/lib/case-studies'; -export default function sitemap(): MetadataRoute.Sitemap { +export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const base = 'https://stackdeck.octifytechnologies.com'; const lastModified = new Date(); - + const studies = await listCaseStudies(); return [ { url: `${base}/`, lastModified, changeFrequency: 'weekly', priority: 1.0 }, - { url: `${base}/new`, lastModified, changeFrequency: 'monthly', priority: 0.8 }, - { url: `${base}/presets`, lastModified, changeFrequency: 'weekly', priority: 0.9 }, - { url: `${base}/templates`, lastModified, changeFrequency: 'weekly', priority: 0.9 }, + ...studies.map((s) => ({ + url: `${base}/c/${s.slug}`, + lastModified: s.date ? new Date(s.date) : lastModified, + changeFrequency: 'monthly' as const, + priority: 0.8, + })), ]; } diff --git a/src/app/templates/TemplatesGallery.tsx b/src/app/templates/TemplatesGallery.tsx deleted file mode 100644 index 70ba9f6..0000000 --- a/src/app/templates/TemplatesGallery.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useMemo } from 'react'; - -import { ParseError, parseDeck } from '@/ir/parse'; -import { planDeck } from '@/ir/plan'; -import { createDeck } from '@/storage/deck-store'; -import { DeckRenderer } from '@/render/DeckRenderer'; -import { - AppTopbar, - Caption, - GalleryGrid, - Heading, - Label, - PageMain, - PageWorkbar, - PageShell, -} from '@/components'; - -import { TEMPLATES, type Template } from './templates'; -import { getPreset } from '@/app/presets/presets'; - -export function TemplatesGallery() { - const router = useRouter(); - - const applyTemplate = async (template: Template) => { - const preset = getPreset(template.recommendedPresetId); - if (!preset) { - window.alert(`Recommended preset "${template.recommendedPresetId}" not found.`); - return; - } - try { - const deck = await createDeck({ - source: template.seed, - theme: { - presetId: preset.id, - paletteId: preset.paletteId, - fontId: preset.fontId, - }, - templateName: template.name, - }); - router.push(`/d/${deck.id}/edit`); - } catch (err) { - const message = err instanceof Error ? err.message : 'Could not create deck'; - window.alert(`Could not create the deck: ${message}`); - } - }; - - return ( - <PageShell className="presets-page"> - <AppTopbar /> - - <PageWorkbar - back={{ href: '/', label: 'Library', ariaLabel: 'Back to library' }} - title="Templates" - count={`${TEMPLATES.length} ${TEMPLATES.length === 1 ? 'template' : 'templates'}`} - subtitle="Content scaffolds with example data. Pair with any preset. Edit the markdown after." - /> - - <PageMain> - <GalleryGrid> - {TEMPLATES.map((template) => ( - <TemplateCard - key={template.id} - template={template} - onApply={() => applyTemplate(template)} - /> - ))} - </GalleryGrid> - </PageMain> - </PageShell> - ); -} - -function TemplateCard({ template, onApply }: { template: Template; onApply: () => void }) { - const preset = getPreset(template.recommendedPresetId); - - const previewDeck = useMemo(() => { - if (!preset) return { ok: false as const, error: 'Preset missing' }; - try { - const parsed = parseDeck(template.seed, { - theme: { - presetId: preset.id, - paletteId: preset.paletteId, - fontId: preset.fontId, - }, - }); - const planned = planDeck(parsed); - return { ok: true as const, deck: { ...planned, slides: planned.slides.slice(0, 3) } }; - } catch (e) { - const message = e instanceof ParseError ? e.message : (e as Error).message; - return { ok: false as const, error: message }; - } - }, [template, preset]); - - return ( - <button - type="button" - className="surface-card preset-card preset-card--multi" - data-preset={template.recommendedPresetId} - onClick={onApply} - > - <div className="preset-card__preview preset-card__preview--multi"> - <div className="preset-card__scaler preset-card__scaler--multi"> - {previewDeck.ok ? <DeckRenderer deck={previewDeck.deck} /> : null} - </div> - <span className="preset-card__chip" data-preset={template.recommendedPresetId}> - {template.category} - </span> - <span className="preset-card__count" aria-label={`${template.slideCount} slides`}> - {template.slideCount} slides - </span> - </div> - <div className="preset-card__meta"> - <Heading level={3} size="md"> - {template.name} - </Heading> - <Caption>{template.vibe}</Caption> - <div className="preset-card__tags"> - <Label className="preset-card__tag">{template.category}</Label> - <Label className="preset-card__tag">in {preset?.name ?? 'preset'}</Label> - <Label className="preset-card__tag">{template.slideCount} slides</Label> - </div> - </div> - </button> - ); -} diff --git a/src/app/templates/page.tsx b/src/app/templates/page.tsx deleted file mode 100644 index ce426a0..0000000 --- a/src/app/templates/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from 'next'; - -import { TemplatesGallery } from './TemplatesGallery'; -import '../presets/presets.css'; - -export const metadata: Metadata = { - title: 'Templates', - description: - 'Content scaffolds for case studies, pitches, and sales decks. Pick a template and pair it with a preset design.', - alternates: { canonical: '/templates' }, -}; - -export default function TemplatesPage() { - return <TemplatesGallery />; -} diff --git a/src/app/templates/seeds/dossier-case-study.ts b/src/app/templates/seeds/dossier-case-study.ts deleted file mode 100644 index 4acc4da..0000000 --- a/src/app/templates/seeds/dossier-case-study.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * The Dossier preset's curated demo. A 14-slide case study, written as a - * real Octify deliverable — not a generic template. Every slide carries a - * specific finding, a specific number, a specific voice. The design above - * is locked; only the words below change per engagement. - */ -export const DOSSIER_CASE_STUDY_SEED = `--- -title: "Halden Industries, Pipeline Rebuild Nº 014" -footer: "May 2026" -brand: - name: "Halden Industries" -theme: - presetId: dossier - paletteId: dossier - fontId: fraunces ---- - -::cover -# We rebuilt Halden's outbound funnel in twelve weeks. -::lead -A field study in narrowing the ICP from forty thousand records to four hundred, rewriting the first three touches, and recovering forty percent of dormant pipeline without adding a single seat. -:: -:: - -::slide -::tear-sheet{client="Halden Industries" industry="Industrial logistics, North America" engagement="Outbound funnel rebuild" duration="12 weeks (Jan 13 — Apr 4, 2026)" team="3 strategists, 1 designer, 1 ops engineer" date="Filed May 6, 2026"} -::lead -Twelve weeks. Tripled qualified pipeline against a flat headcount. Eighty-one percent of outbound now lands inside the stated ICP, up from nineteen. -:: - -1. The funnel was running three competing plays in parallel and committing to none. -2. Eighty-one percent of outbound was leaving the ICP entirely. -3. The CRM held forty-two thousand stale records masking a real audience of four hundred. -4. The dashboard tracked activity, not outcomes. -:: - -::slide -::section -# The diagnosis -:: - -::slide -## What we found in the first two weeks -The team was running three plays in parallel and committing to none of them. Reps had thirty-two saved sequences and no documented owner. The dashboard tracked dials and emails, not meetings, and certainly not pipeline. Every leader we interviewed had a different answer to the question of who the ideal customer was. - -- Eighty-one percent of outbound was sent to companies outside the stated ICP. -- The first-touch reply rate had decayed sixty-one percent over four quarters. -- The CRM held forty-two thousand records flagged "no contact in 12+ months", obscuring a real addressable list of about four hundred accounts. -- No single owner held the playbook; every rep had quietly forked it. - -::slide -::stat{value="$3.4M" label="Net new pipeline / Q1 2026" delta="+212%"} -Sustained over eight consecutive weeks, with no headcount growth. - -::caption -DEFINITION: Pipeline value created by accounts not previously in our outbound list. -:: - -::caption -METHOD: HubSpot weighted forecast, weekly snapshot, audited by Octify ops. -:: - -::caption -SOURCE: HubSpot, May 6 2026 -:: - -::slide -::section -# The intervention -:: - -::slide -::kpi-grid{source="HubSpot, week of May 6 2026"} -::stat{value="38%" label="Reply rate, first touch" delta="+18 pts"} -::stat{value="14%" label="Meeting set rate" delta="+9 pts"} -::stat{value="2.1x" label="Pipeline velocity" delta="+1.3x"} -::stat{value="$87" label="Cost per qualified meeting" delta="-62%"} -::stat{value="6 wk" label="Average sales cycle" delta="-3 wk"} -::stat{value="91%" label="ICP match on opens" delta="+44 pts"} -:: - -::slide -::pull-quote -> The team finally feels like they're hunting in the right woods. Conversations are sharper, the meetings convert, and the pipeline reflects it. We didn't add a single seat. -> -- Anya Halden, Founder & CEO, Halden Industries -:: - -::slide -::section -# The transformation -:: - -::slide -::before-after -::box{tone=warn} -::stat{value="9%"} -### Reply rate, first touch -A generic value-prop email shipped to anyone with the title "VP". No segmentation. Same body to logistics directors and IT buyers alike. -:: -::: -::box{tone=success} -::stat{value="38%"} -### Reply rate, first touch -A point-of-view note tied to a quarterly initiative the prospect had publicly named. Three openers, one ICP, one ask. -:: -:: - -::slide -::chart{kind=bar title="Qualified meetings, weekly" prefix="" suffix=""} -W1: 4 -W2: 6 -W3: 9 -W4: 11 -W5: 14 -W6: 17 -W7: 19 -W8: 22 -:: -What changed in week three was not the volume; it was who we were sending to. We cut send volume by forty-eight percent and held the new send rate steady through week eight. - -::caption -SOURCE: Internal CRM, May 6 2026 -:: - -::slide -::section -# What we kept, what we cut -:: - -::slide -## The shape of the new playbook -We left Halden with a single, owned playbook: one ICP definition, three opener variants, one mid-funnel sequence, one cold-revival flow. The ops engineer documented every field, every status, every handoff. The thirty-two old sequences were archived. - -- One ICP, written in a single paragraph, signed off by the CEO. -- Three opener variants, A/B-tested weekly by the ops engineer. -- One mid-funnel sequence, owned by the senior AE. -- One cold-revival flow, run quarterly against the dormant list. -- A weekly outcome review, not an activity review. - -::slide -::cover -# Thank you. -::lead -Filed in confidence. The pages above are an account of one engagement at one moment in one company. Nothing here generalises without context, but the discipline behind it does. -:: - -::columns{count=3} -Anya Halden, Founder & CEO -::: -hello@halden.industries -::: -+1 415 555 0144 -:: -:: -`; diff --git a/src/app/templates/templates.ts b/src/app/templates/templates.ts deleted file mode 100644 index 72ebe00..0000000 --- a/src/app/templates/templates.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { DOSSIER_CASE_STUDY_SEED } from './seeds/dossier-case-study'; - -export type TemplateCategory = 'case-study' | 'pitch' | 'sales' | 'internal'; - -/** - * A Template is content data: the markdown directives for a starter deck. - * Each preset ships its own curated demo template here. - */ -export type Template = { - id: string; - name: string; - vibe: string; - category: TemplateCategory; - seed: string; - slideCount: number; - recommendedPresetId: string; -}; - -export const TEMPLATES: Template[] = [ - { - id: 'dossier-case-study', - name: 'Halden Industries · Dossier', - vibe: 'Fourteen-slide editorial dossier: cover, tear sheet, three chapter dividers, hero stat, KPI grid, pull quote, before/after, chart, closer.', - category: 'case-study', - seed: DOSSIER_CASE_STUDY_SEED, - slideCount: 14, - recommendedPresetId: 'dossier', - }, -]; - -export function getTemplate(id: string): Template | undefined { - return TEMPLATES.find((t) => t.id === id); -} diff --git a/src/blocks/BlockRenderer.tsx b/src/blocks/BlockRenderer.tsx deleted file mode 100644 index 2338f54..0000000 --- a/src/blocks/BlockRenderer.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client'; - -import type { Block } from '@/ir/schema'; - -import { resolveBlockComponent } from './registry'; - -/** - * Dispatches an IR Block to the right atomic component. The renderer never - * sees pattern directives or preset ids; it just looks up a component. - */ -export function BlockRenderer({ block }: { block: Block }) { - switch (block.type) { - case 'heading': { - const Comp = resolveBlockComponent('heading'); - return <Comp block={block} />; - } - case 'text': { - const Comp = resolveBlockComponent('text'); - return <Comp block={block} />; - } - case 'list': { - const Comp = resolveBlockComponent('list'); - return <Comp block={block} />; - } - case 'quote': { - const Comp = resolveBlockComponent('quote'); - return <Comp block={block} />; - } - case 'stat': { - const Comp = resolveBlockComponent('stat'); - return <Comp block={block} />; - } - case 'code': { - const Comp = resolveBlockComponent('code'); - return <Comp block={block} />; - } - case 'chart': { - const Comp = resolveBlockComponent('chart'); - return <Comp block={block} />; - } - case 'table': { - const Comp = resolveBlockComponent('table'); - return <Comp block={block} />; - } - case 'box': { - const Comp = resolveBlockComponent('box'); - return <Comp block={block} />; - } - case 'columns': { - const Comp = resolveBlockComponent('columns'); - return <Comp block={block} />; - } - case 'grid': { - const Comp = resolveBlockComponent('grid'); - return <Comp block={block} />; - } - case 'cell': { - const Comp = resolveBlockComponent('cell'); - return <Comp block={block} />; - } - case 'image': { - const Comp = resolveBlockComponent('image'); - return <Comp block={block} />; - } - default: { - const _exhaustive: never = block; - void _exhaustive; - return null; - } - } -} diff --git a/src/blocks/InlineText.tsx b/src/blocks/InlineText.tsx deleted file mode 100644 index 2a7a016..0000000 --- a/src/blocks/InlineText.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { marked } from 'marked'; - -/** - * Renders a string of inline markdown (bold, italic, code, links) as HTML. - * Inputs come from authored markdown, so we trust the parser's output. - */ -export function InlineText({ text }: { text: string }) { - const html = marked.parseInline(text, { async: false }) as string; - return <span dangerouslySetInnerHTML={{ __html: html }} />; -} diff --git a/src/blocks/default/Box.tsx b/src/blocks/default/Box.tsx deleted file mode 100644 index 6673389..0000000 --- a/src/blocks/default/Box.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { Box as BoxBlock } from '@/ir/schema'; - -import { BlockRenderer } from '../BlockRenderer'; - -export function Box({ block }: { block: BoxBlock }) { - return ( - <div className={`block block-box block-box-${block.tone ?? 'neutral'}`}> - {block.children.map((child, idx) => ( - <BlockRenderer key={idx} block={child} /> - ))} - </div> - ); -} diff --git a/src/blocks/default/Cell.tsx b/src/blocks/default/Cell.tsx deleted file mode 100644 index 3bd2312..0000000 --- a/src/blocks/default/Cell.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { Cell as CellBlock } from '@/ir/schema'; - -import { BlockRenderer } from '../BlockRenderer'; - -export function Cell({ block }: { block: CellBlock }) { - const style: React.CSSProperties = {}; - if (block.span) style.gridColumn = `span ${block.span}`; - if (block.rowSpan) style.gridRow = `span ${block.rowSpan}`; - return ( - <div - className="block block-cell" - data-span={block.span ?? undefined} - data-row-span={block.rowSpan ?? undefined} - style={style} - > - {block.children.map((child, idx) => ( - <BlockRenderer key={idx} block={child} /> - ))} - </div> - ); -} diff --git a/src/blocks/default/Chart.tsx b/src/blocks/default/Chart.tsx deleted file mode 100644 index 1d7fb36..0000000 --- a/src/blocks/default/Chart.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { Chart as ChartBlock, ChartDatum } from '@/ir/schema'; - -function formatValue( - value: number, - format?: ChartBlock['format'], - prefix?: string, - suffix?: string, -): string { - let text: string; - if (format === 'percent') { - text = `${value}%`; - } else if (format === 'currency') { - text = `$${value.toLocaleString()}`; - } else { - text = value.toLocaleString(); - } - return `${prefix ?? ''}${text}${suffix ?? ''}`; -} - -function BarChart({ block }: { block: ChartBlock }) { - const max = Math.max(...block.data.map((d) => d.value), 0) || 1; - return ( - <div className="chart chart--bar"> - {block.title ? <div className="chart__title">{block.title}</div> : null} - <div className="chart__bars" role="list"> - {block.data.map((d, i) => ( - <BarRow key={i} datum={d} max={max} block={block} /> - ))} - </div> - </div> - ); -} - -function BarRow({ datum, max, block }: { datum: ChartDatum; max: number; block: ChartBlock }) { - const pct = (datum.value / max) * 100; - return ( - <div className="chart__bar-row" role="listitem"> - <span className="chart__bar-label">{datum.label}</span> - <div className="chart__bar-track"> - <div className="chart__bar-fill" style={{ width: `${pct}%` }} /> - <span className="chart__bar-value"> - {formatValue(datum.value, block.format, block.prefix, block.suffix)} - </span> - </div> - </div> - ); -} - -function LineChart({ block }: { block: ChartBlock }) { - const w = 800; - const h = 280; - const pad = { top: 24, right: 32, bottom: 36, left: 48 }; - const innerW = w - pad.left - pad.right; - const innerH = h - pad.top - pad.bottom; - - const max = Math.max(...block.data.map((d) => d.value), 0); - const min = Math.min(...block.data.map((d) => d.value), 0); - const range = max - min || 1; - const stepX = block.data.length > 1 ? innerW / (block.data.length - 1) : 0; - - const points = block.data.map((d, i) => ({ - x: pad.left + i * stepX, - y: pad.top + innerH - ((d.value - min) / range) * innerH, - })); - - const path = points.reduce( - (acc, p, i) => acc + (i === 0 ? `M ${p.x} ${p.y}` : ` L ${p.x} ${p.y}`), - '', - ); - const area = `${path} L ${pad.left + innerW} ${pad.top + innerH} L ${pad.left} ${pad.top + innerH} Z`; - - return ( - <div className="chart chart--line"> - {block.title ? <div className="chart__title">{block.title}</div> : null} - <svg - viewBox={`0 0 ${w} ${h}`} - className="chart__svg" - preserveAspectRatio="xMidYMid meet" - role="img" - aria-label={block.title ?? 'Line chart'} - > - {[0, 1, 2, 3].map((i) => { - const y = pad.top + (i * innerH) / 3; - return ( - <line - key={i} - x1={pad.left} - x2={pad.left + innerW} - y1={y} - y2={y} - className="chart__gridline" - /> - ); - })} - <path d={area} className="chart__line-area" /> - <path d={path} className="chart__line-stroke" fill="none" /> - {points.map((p, i) => ( - <circle key={i} cx={p.x} cy={p.y} r={4} className="chart__line-dot" /> - ))} - {block.data.map((d, i) => ( - <text - key={i} - x={points[i].x} - y={h - pad.bottom + 22} - className="chart__label" - textAnchor="middle" - > - {d.label} - </text> - ))} - </svg> - </div> - ); -} - -function DonutChart({ block }: { block: ChartBlock }) { - const total = block.data.reduce((a, d) => a + d.value, 0) || 1; - const r = 64; - const C = 2 * Math.PI * r; - let offset = 0; - - const segments = block.data.map((d, i) => { - const fraction = d.value / total; - const length = C * fraction; - const seg = ( - <circle - key={i} - cx={100} - cy={100} - r={r} - fill="none" - strokeWidth="32" - strokeDasharray={`${length} ${C - length}`} - strokeDashoffset={-offset} - className={`chart__donut-seg chart__donut-seg--${i % 4}`} - /> - ); - offset += length; - return seg; - }); - - return ( - <div className="chart chart--donut"> - {block.title ? <div className="chart__title">{block.title}</div> : null} - <div className="chart__donut-wrap"> - <svg - viewBox="0 0 200 200" - className="chart__svg" - role="img" - aria-label={block.title ?? 'Donut chart'} - > - <circle - cx={100} - cy={100} - r={r} - fill="none" - strokeWidth="32" - className="chart__donut-track" - /> - {segments} - </svg> - <ul className="chart__donut-legend"> - {block.data.map((d, i) => ( - <li key={i}> - <span className={`chart__donut-marker chart__donut-marker--${i % 4}`} /> - <span className="chart__donut-label">{d.label}</span> - <span className="chart__donut-value"> - {formatValue(d.value, block.format, block.prefix, block.suffix)} - </span> - </li> - ))} - </ul> - </div> - </div> - ); -} - -export function Chart({ block }: { block: ChartBlock }) { - if (block.kind === 'line') return <LineChart block={block} />; - if (block.kind === 'donut') return <DonutChart block={block} />; - return <BarChart block={block} />; -} diff --git a/src/blocks/default/Code.tsx b/src/blocks/default/Code.tsx deleted file mode 100644 index 61079ad..0000000 --- a/src/blocks/default/Code.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { Code as CodeBlock } from '@/ir/schema'; - -export function Code({ block }: { block: CodeBlock }) { - return ( - <pre className="block block-code"> - <code data-language={block.language ?? ''}>{block.content}</code> - </pre> - ); -} diff --git a/src/blocks/default/Columns.tsx b/src/blocks/default/Columns.tsx deleted file mode 100644 index 9d5e482..0000000 --- a/src/blocks/default/Columns.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { Columns as ColumnsBlock } from '@/ir/schema'; - -import { BlockRenderer } from '../BlockRenderer'; - -export function Columns({ block }: { block: ColumnsBlock }) { - return ( - <div className="block block-columns" data-cols={block.count}> - {block.columns.map((col, ci) => ( - <div className="block-columns-col" key={ci}> - {col.map((child, bi) => ( - <BlockRenderer key={bi} block={child} /> - ))} - </div> - ))} - </div> - ); -} diff --git a/src/blocks/default/Grid.tsx b/src/blocks/default/Grid.tsx deleted file mode 100644 index cfc8d0e..0000000 --- a/src/blocks/default/Grid.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { Grid as GridBlock } from '@/ir/schema'; - -import { BlockRenderer } from '../BlockRenderer'; - -export function Grid({ block }: { block: GridBlock }) { - return ( - <div className="block block-grid" data-cols={block.cols} data-rows={block.rows}> - {block.children.map((child, idx) => ( - <BlockRenderer key={idx} block={child} /> - ))} - </div> - ); -} diff --git a/src/blocks/default/Heading.tsx b/src/blocks/default/Heading.tsx deleted file mode 100644 index 6bc8fbc..0000000 --- a/src/blocks/default/Heading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Heading as HeadingBlock } from '@/ir/schema'; - -import { InlineText } from '../InlineText'; - -export function Heading({ block }: { block: HeadingBlock }) { - const Tag = `h${block.level}` as 'h1' | 'h2' | 'h3' | 'h4'; - return ( - <Tag className={`block block-heading block-h${block.level}`}> - <InlineText text={block.text} /> - </Tag> - ); -} diff --git a/src/blocks/default/Image.tsx b/src/blocks/default/Image.tsx deleted file mode 100644 index 55eb332..0000000 --- a/src/blocks/default/Image.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; - -import type { Image as ImageBlock } from '@/ir/schema'; -import { assetIdFromSrc, getAssetUrl, isAssetSrc } from '@/storage/asset-store'; - -function parseAspect(ratio: string | undefined): string | undefined { - if (!ratio) return undefined; - const trimmed = ratio.trim(); - if (trimmed.includes('/')) return trimmed; - if (trimmed.includes(':')) return trimmed.replace(':', ' / '); - return undefined; -} - -function useResolvedSrc(src: string): string { - const [resolved, setResolved] = useState<string>(() => (isAssetSrc(src) ? '' : src)); - useEffect(() => { - if (!isAssetSrc(src)) { - setResolved(src); - return; - } - const id = assetIdFromSrc(src); - if (!id) return; - let alive = true; - void getAssetUrl(id).then((url) => { - if (alive && url) setResolved(url); - }); - return () => { - alive = false; - }; - }, [src]); - return resolved; -} - -export function Image({ block }: { block: ImageBlock }) { - const treatment = block.treatment ?? 'plain'; - const aspect = parseAspect(block.aspectRatio); - const focal = block.focal ?? '50% 50%'; - const resolvedSrc = useResolvedSrc(block.src); - - const figureStyle: React.CSSProperties = {}; - if (aspect) figureStyle.aspectRatio = aspect; - - const imgStyle: React.CSSProperties = { objectPosition: focal }; - - return ( - <figure - className="block block-image" - data-treatment={treatment} - data-has-caption={block.caption ? '' : undefined} - style={figureStyle} - > - <div className="block-image__frame"> - {resolvedSrc ? ( - <img src={resolvedSrc} alt={block.alt ?? ''} style={imgStyle} loading="lazy" /> - ) : ( - <div className="block-image__placeholder" /> - )} - {block.annotations?.length ? ( - <div className="block-image__annotations" aria-hidden="true"> - {block.annotations.map((a, i) => ( - <span - key={i} - className="block-image__annotation" - style={{ left: a.x, top: a.y }} - data-index={i + 1} - > - <span className="block-image__annotation-dot">{i + 1}</span> - <span className="block-image__annotation-label">{a.label}</span> - </span> - ))} - </div> - ) : null} - </div> - {block.caption ? <figcaption>{block.caption}</figcaption> : null} - </figure> - ); -} diff --git a/src/blocks/default/List.tsx b/src/blocks/default/List.tsx deleted file mode 100644 index bac842d..0000000 --- a/src/blocks/default/List.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { List as ListBlock, ListItem } from '@/ir/schema'; - -import { InlineText } from '../InlineText'; - -function ListItems({ items }: { items: ListItem[] }) { - return ( - <> - {items.map((item, idx) => ( - <li key={idx}> - <InlineText text={item.text} /> - {item.children && item.children.length > 0 ? ( - <ul> - <ListItems items={item.children} /> - </ul> - ) : null} - </li> - ))} - </> - ); -} - -export function List({ block }: { block: ListBlock }) { - const Tag = block.ordered ? 'ol' : 'ul'; - const className = `block block-list ${ - block.ordered ? 'block-list-ordered' : 'block-list-unordered' - }`; - return ( - <Tag className={className}> - <ListItems items={block.items} /> - </Tag> - ); -} diff --git a/src/blocks/default/Quote.tsx b/src/blocks/default/Quote.tsx deleted file mode 100644 index 3385d84..0000000 --- a/src/blocks/default/Quote.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Quote as QuoteBlock } from '@/ir/schema'; - -import { InlineText } from '../InlineText'; - -export function Quote({ block }: { block: QuoteBlock }) { - return ( - <figure className={`block block-quote block-quote-${block.emphasis}`}> - <blockquote> - <InlineText text={block.text} /> - </blockquote> - {block.attribution ? ( - <figcaption className="block-quote-attribution">— {block.attribution}</figcaption> - ) : null} - </figure> - ); -} diff --git a/src/blocks/default/Stat.tsx b/src/blocks/default/Stat.tsx deleted file mode 100644 index c330fed..0000000 --- a/src/blocks/default/Stat.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Stat as StatBlock } from '@/ir/schema'; - -export function Stat({ block }: { block: StatBlock }) { - return ( - <div className="block block-stat"> - <div className="block-stat-value">{block.value}</div> - {block.label ? <div className="block-stat-label">{block.label}</div> : null} - {block.delta ? ( - <div className={`block-stat-delta block-stat-delta-${block.trend ?? 'flat'}`}> - {block.delta} - </div> - ) : null} - </div> - ); -} diff --git a/src/blocks/default/Table.tsx b/src/blocks/default/Table.tsx deleted file mode 100644 index 9ff627d..0000000 --- a/src/blocks/default/Table.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Table as TableBlock } from '@/ir/schema'; - -export function Table({ block }: { block: TableBlock }) { - return ( - <div className="block-table-wrap"> - <table className="block-table"> - <thead> - <tr> - {block.headers.map((h, i) => ( - <th key={i} data-emphasize={i === block.emphasizeColumn ? 'true' : undefined}> - {h} - </th> - ))} - </tr> - </thead> - <tbody> - {block.rows.map((row, ri) => ( - <tr key={ri}> - {row.map((cell, ci) => ( - <td key={ci} data-emphasize={ci === block.emphasizeColumn ? 'true' : undefined}> - {cell} - </td> - ))} - </tr> - ))} - </tbody> - </table> - </div> - ); -} diff --git a/src/blocks/default/Text.tsx b/src/blocks/default/Text.tsx deleted file mode 100644 index 0338007..0000000 --- a/src/blocks/default/Text.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { Text as TextBlock } from '@/ir/schema'; - -import { InlineText } from '../InlineText'; - -export function Text({ block }: { block: TextBlock }) { - return ( - <p className={`block block-text block-text-${block.emphasis}`}> - <InlineText text={block.text} /> - </p> - ); -} diff --git a/src/blocks/index.ts b/src/blocks/index.ts deleted file mode 100644 index e5d093c..0000000 --- a/src/blocks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BlockRenderer } from './BlockRenderer'; diff --git a/src/blocks/registry.ts b/src/blocks/registry.ts deleted file mode 100644 index e213c35..0000000 --- a/src/blocks/registry.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ComponentType } from 'react'; - -import type { Block } from '@/ir/schema'; - -import { Box } from './default/Box'; -import { Cell } from './default/Cell'; -import { Chart } from './default/Chart'; -import { Code } from './default/Code'; -import { Columns } from './default/Columns'; -import { Grid } from './default/Grid'; -import { Heading } from './default/Heading'; -import { Image } from './default/Image'; -import { List } from './default/List'; -import { Quote } from './default/Quote'; -import { Stat } from './default/Stat'; -import { Table } from './default/Table'; -import { Text } from './default/Text'; - -type BlockType = Block['type']; -type BlockOf<T extends BlockType> = Extract<Block, { type: T }>; -type BlockComponent<T extends BlockType> = ComponentType<{ block: BlockOf<T> }>; - -type Registry = { - [K in BlockType]: BlockComponent<K>; -}; - -/** - * One default renderer per block type. The renderer never branches on - * preset; visual variation comes from per-preset scoped CSS and bespoke - * signature components dispatched at the SlideRenderer level. - */ -const blockRegistry: Registry = { - heading: Heading, - text: Text, - list: List, - quote: Quote, - stat: Stat, - code: Code, - chart: Chart, - table: Table, - box: Box, - columns: Columns, - grid: Grid, - cell: Cell, - image: Image, -}; - -export function resolveBlockComponent<T extends BlockType>(type: T): BlockComponent<T> { - return blockRegistry[type]; -} diff --git a/src/components/AppTopbar.css b/src/components/AppTopbar.css deleted file mode 100644 index 5e9cc0e..0000000 --- a/src/components/AppTopbar.css +++ /dev/null @@ -1,58 +0,0 @@ -/* ========================================================================== - app topbar - Single source of truth for the global top bar shared by /, /presets, - and /new. Inner rail and bar shape live in layout/page.css; nav buttons - come from primitives/button.css. Only chrome and brand mark live here. - ========================================================================== */ - -.app-topbar { - height: 56px; - border-bottom: 1px solid var(--line); - background: var(--bg-glass); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - position: sticky; - top: 0; - z-index: 20; -} - -.app-topbar__brand { - display: inline-flex; - align-items: center; - gap: 8px; - text-decoration: none; - color: var(--text); - font-family: var(--ui); - font-size: 22px; - font-weight: 600; - letter-spacing: -0.022em; - line-height: 1; -} - -.app-topbar__brand-mark { - display: inline-grid; - place-items: center; - width: 36px; - height: 36px; - color: var(--text); - transition: transform var(--t-fast) var(--ease); -} - -.app-topbar__brand:hover .app-topbar__brand-mark { - transform: translateY(-0.5px); -} - -.app-topbar__brand-name { - display: inline-block; -} - -.app-topbar__actions { - display: flex; - align-items: center; - gap: 4px; -} - -/* Tiny offset so the primary CTA doesn't sit flush with the ghost links. */ -.app-topbar__cta { - margin-left: 6px; -} diff --git a/src/components/AppTopbar.tsx b/src/components/AppTopbar.tsx deleted file mode 100644 index 0a2cb28..0000000 --- a/src/components/AppTopbar.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { Button } from './primitives/Button'; - -import './AppTopbar.css'; - -const NAV_ITEMS: ReadonlyArray<{ href: string; label: string; match: (p: string) => boolean }> = [ - { href: '/', label: 'Library', match: (p) => p === '/' }, - { href: '/presets', label: 'Presets', match: (p) => p.startsWith('/presets') }, - { href: '/templates', label: 'Templates', match: (p) => p.startsWith('/templates') }, -]; - -export function AppTopbar() { - const pathname = usePathname() ?? '/'; - - return ( - <header className="app-topbar"> - <div className="page-bar-inner app-topbar__inner"> - <Link href="/" className="app-topbar__brand" aria-label="stackdeck home"> - <span className="app-topbar__brand-mark" aria-hidden> - <svg width="36" height="36" viewBox="0 0 22 22" fill="none"> - <rect x="1.8" y="6.9" width="12" height="12" rx="2" fill="#6366f1" /> - <rect x="5" y="5" width="12" height="12" rx="2" fill="#ec4899" /> - <rect x="8.2" y="3.1" width="12" height="12" rx="2" fill="#f59e0b" /> - </svg> - </span> - <span className="app-topbar__brand-name">stackdeck</span> - </Link> - <nav className="app-topbar__actions" aria-label="Primary"> - {NAV_ITEMS.map((item) => { - const active = item.match(pathname); - return ( - <Button - key={item.href} - as="link" - href={item.href} - variant="ghost" - aria-current={active ? 'page' : undefined} - > - {item.label} - </Button> - ); - })} - <Button - as="a" - href="https://github.com/Octify-Technologies/stackdeck" - target="_blank" - rel="noopener noreferrer" - variant="ghost" - > - GitHub - </Button> - <Button as="link" href="/new" variant="primary" className="app-topbar__cta"> - New deck - </Button> - </nav> - </div> - </header> - ); -} diff --git a/src/components/Present.css b/src/components/Present.css new file mode 100644 index 0000000..f667185 --- /dev/null +++ b/src/components/Present.css @@ -0,0 +1,115 @@ +.present { + position: fixed; + inset: 0; + background: #000; + display: grid; + place-items: center; + cursor: default; + z-index: 1000; +} + +.present-stage { + width: 100vw; + height: 100vh; + display: grid; + place-items: center; + animation: fade 0.3s var(--ease) both; +} + +.present-stage .slide-wrap { + width: 100vw; + height: 100vh; + border-radius: 0; + background: transparent; +} + +/* Bottom pager wrap. Auto-hides after idle. */ +.present-pager-wrap { + position: fixed; + left: 0; + right: 0; + bottom: 28px; + display: flex; + justify-content: center; + pointer-events: none; + transition: + opacity 0.3s var(--ease), + transform 0.3s var(--ease); + opacity: 1; + transform: translateY(0); +} + +.present-pager-wrap-hidden { + opacity: 0; + transform: translateY(8px); +} + +/* Pager pill, mirrors the viewer's stage pager. */ +.present-pager { + display: inline-flex; + align-items: center; + height: 44px; + padding: 4px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 999px; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + pointer-events: auto; + box-shadow: 0 12px 40px -10px rgba(0, 0, 0, 0.6); +} + +.present-pager-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.9); + border-radius: 999px; + cursor: pointer; + transition: + background 0.12s var(--ease), + color 0.12s var(--ease); +} + +.present-pager-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + color: #ffffff; +} + +.present-pager-btn:active:not(:disabled) { + background: rgba(255, 255, 255, 0.24); +} + +.present-pager-btn:disabled { + opacity: 0.28; + cursor: not-allowed; +} + +.present-pager-counter { + font-family: var(--mono); + font-size: 14px; + font-feature-settings: 'tnum' on; + font-variant-numeric: tabular-nums; + font-weight: 600; + padding: 0 14px; + letter-spacing: 0.02em; + color: #ffffff; + white-space: nowrap; +} + +.present-pager-counter-current { + color: #ffffff; +} + +.present-pager-counter-sep { + margin: 0 5px; + color: rgba(255, 255, 255, 0.32); +} + +.present-pager-counter-total { + color: rgba(255, 255, 255, 0.55); +} diff --git a/src/components/Present.tsx b/src/components/Present.tsx new file mode 100644 index 0000000..ff72aab --- /dev/null +++ b/src/components/Present.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { SlideFrame } from './SlideFrame'; +import './SlideFrame.css'; +import './Present.css'; + +type SlideRef = { file: string; title?: string }; + +type Props = { + slug: string; + title: string; + slides: SlideRef[]; + initialIndex: number; + onIndexChange?: (i: number) => void; + onExit: () => void; +}; + +export function Present({ slug, slides, initialIndex, onIndexChange, onExit }: Props) { + const [index, setIndex] = useState(() => Math.max(0, Math.min(initialIndex, slides.length - 1))); + const [chromeVisible, setChromeVisible] = useState(true); + const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + const total = slides.length; + + const updateIndex = useCallback( + (next: number | ((i: number) => number)) => { + // Keep this reducer pure. Parent sync happens in the effect below so + // we never trigger a parent setState while React is mid-render. + setIndex((prev) => { + const n = typeof next === 'function' ? next(prev) : next; + return Math.max(0, Math.min(n, total - 1)); + }); + }, + [total], + ); + + // Sync back to the parent after every committed index change. + useEffect(() => { + onIndexChange?.(index); + }, [index, onIndexChange]); + + const next = useCallback(() => updateIndex((i) => i + 1), [updateIndex]); + const prev = useCallback(() => updateIndex((i) => i - 1), [updateIndex]); + + const exitedRef = useRef(false); + const exit = useCallback(() => { + if (exitedRef.current) return; + exitedRef.current = true; + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } + onExit(); + }, [onExit]); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { + e.preventDefault(); + next(); + } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { + e.preventDefault(); + prev(); + } else if (e.key === 'Home') { + updateIndex(0); + } else if (e.key === 'End') { + updateIndex(total - 1); + } else if (e.key === 'Escape') { + // Fallback path: browsers swallow Escape inside fullscreen and never + // deliver it to the page, so this handler primarily covers the case + // where the fullscreen request was denied/blocked. + e.preventDefault(); + exit(); + } else if (e.key >= '1' && e.key <= '9') { + const n = Number(e.key) - 1; + if (n < total) updateIndex(n); + } + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [next, prev, total, exit, updateIndex]); + + // Auto-hide the bottom pager after idle. + useEffect(() => { + function bump() { + setChromeVisible(true); + if (hideTimer.current) clearTimeout(hideTimer.current); + hideTimer.current = setTimeout(() => setChromeVisible(false), 2500); + } + bump(); + window.addEventListener('mousemove', bump); + window.addEventListener('keydown', bump); + return () => { + window.removeEventListener('mousemove', bump); + window.removeEventListener('keydown', bump); + if (hideTimer.current) clearTimeout(hideTimer.current); + }; + }, []); + + // Enter fullscreen on mount, lock body scroll, and bridge the + // browser-native Escape (which never reaches keydown) back to onExit by + // listening for fullscreenchange. + useEffect(() => { + const el = document.documentElement; + let enteredFullscreen = false; + + if (!document.fullscreenElement && el.requestFullscreen) { + el.requestFullscreen() + .then(() => { + enteredFullscreen = true; + }) + .catch(() => { + // Denied or blocked: keydown handler still covers Escape. + }); + } else if (document.fullscreenElement) { + enteredFullscreen = true; + } + + function onFullscreenChange() { + if (document.fullscreenElement) { + enteredFullscreen = true; + return; + } + // Fullscreen just left. If we ever entered it, the user pressed + // Escape (or otherwise exited) and expects to leave present mode. + if (enteredFullscreen) exit(); + } + document.addEventListener('fullscreenchange', onFullscreenChange); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('fullscreenchange', onFullscreenChange); + document.body.style.overflow = prevOverflow; + }; + }, [exit]); + + const current = slides[index]; + + return ( + <div className="present" role="dialog" aria-modal="true"> + <div className="present-stage"> + <SlideFrame + src={`/c/${slug}/slides/${current.file}`} + title={current.title ?? `Slide ${index + 1}`} + showLoader + /> + </div> + + <div + className={`present-pager-wrap ${chromeVisible ? '' : 'present-pager-wrap-hidden'}`} + aria-hidden={!chromeVisible} + > + <div className="present-pager" role="group" aria-label="Slide navigation"> + <button + type="button" + className="present-pager-btn" + onClick={prev} + disabled={index === 0} + aria-label="Previous slide" + title="Previous (←)" + > + <svg width="16" height="16" viewBox="0 0 12 12" fill="none"> + <path + d="M8 2L4 6l4 4" + stroke="currentColor" + strokeWidth="1.7" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </button> + <span className="present-pager-counter"> + <span className="present-pager-counter-current"> + {String(index + 1).padStart(2, '0')} + </span> + <span className="present-pager-counter-sep">/</span> + <span className="present-pager-counter-total">{String(total).padStart(2, '0')}</span> + </span> + <button + type="button" + className="present-pager-btn" + onClick={next} + disabled={index === total - 1} + aria-label="Next slide" + title="Next (→)" + > + <svg width="16" height="16" viewBox="0 0 12 12" fill="none"> + <path + d="M4 2l4 4-4 4" + stroke="currentColor" + strokeWidth="1.7" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </button> + </div> + </div> + </div> + ); +} diff --git a/src/components/SlideFrame.css b/src/components/SlideFrame.css new file mode 100644 index 0000000..de3fef5 --- /dev/null +++ b/src/components/SlideFrame.css @@ -0,0 +1,55 @@ +.slide-wrap { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: var(--bg, #fff); + overflow: hidden; + border-radius: 4px; +} + +.slide-canvas { + position: absolute; + top: 0; + left: 0; + transform-origin: top left; + pointer-events: auto; +} + +/* When a wrapper opts out of clicks, descendants must too — iframes default + to pointer-events: auto so the parent rule alone is not enough. */ +.slide-wrap-passive, +.slide-wrap-passive .slide-canvas, +.slide-wrap-passive iframe { + pointer-events: none; +} + +.slide-canvas iframe { + display: block; + border: 0; + width: 100%; + height: 100%; + background: transparent; +} + +/* Subtle indeterminate progress bar while a slide loads. */ +.slide-loader { + position: absolute; + top: 0; + left: 0; + width: 30%; + height: 2px; + background: rgba(10, 10, 10, 0.45); + animation: slide-loader 1.1s ease-in-out infinite; + z-index: 2; + pointer-events: none; + border-radius: 0 1px 1px 0; +} + +@keyframes slide-loader { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(450%); + } +} diff --git a/src/components/SlideFrame.tsx b/src/components/SlideFrame.tsx new file mode 100644 index 0000000..a375da3 --- /dev/null +++ b/src/components/SlideFrame.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +export const CANVAS_W = 1920; +export const CANVAS_H = 1080; + +type Props = { + src: string; + title?: string; + /** When false, defer src assignment until in view (for thumbnails). */ + lazy?: boolean; + /** When false, do not run scripts inside the slide. */ + interactive?: boolean; + /** Show a subtle loading bar while the iframe loads. */ + showLoader?: boolean; + className?: string; +}; + +export function SlideFrame({ + src, + title, + lazy = false, + interactive = true, + showLoader = false, + className, +}: Props) { + const wrapRef = useRef<HTMLDivElement>(null); + const iframeRef = useRef<HTMLIFrameElement>(null); + const [scale, setScale] = useState(1); + const [visible, setVisible] = useState(!lazy); + const [loading, setLoading] = useState(true); + + useLayoutEffect(() => { + const el = wrapRef.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + const s = Math.min(r.width / CANVAS_W, r.height / CANVAS_H); + setScale(s > 0 ? s : 1); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (!lazy) return; + const el = wrapRef.current; + if (!el) return; + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (e.isIntersecting) { + setVisible(true); + io.disconnect(); + break; + } + } + }, + { rootMargin: '200px' }, + ); + io.observe(el); + return () => io.disconnect(); + }, [lazy]); + + // Reset loading state when src changes + useEffect(() => { + setLoading(true); + }, [src]); + + const sandbox = interactive ? 'allow-scripts allow-same-origin' : ''; + const wrapClass = interactive ? 'slide-wrap' : 'slide-wrap slide-wrap-passive'; + + return ( + <div ref={wrapRef} className={`${wrapClass} ${className ?? ''}`}> + {showLoader && loading ? <div className="slide-loader" aria-hidden /> : null} + <div + className="slide-canvas" + style={{ + width: CANVAS_W, + height: CANVAS_H, + transform: `scale(${scale})`, + }} + > + {visible ? ( + <iframe + ref={iframeRef} + src={src} + title={title ?? 'slide'} + width={CANVAS_W} + height={CANVAS_H} + sandbox={sandbox} + loading={lazy ? 'lazy' : 'eager'} + tabIndex={-1} + onLoad={() => setLoading(false)} + /> + ) : null} + </div> + </div> + ); +} diff --git a/src/components/StackdeckMark.tsx b/src/components/StackdeckMark.tsx new file mode 100644 index 0000000..5f9d98a --- /dev/null +++ b/src/components/StackdeckMark.tsx @@ -0,0 +1,21 @@ +type Props = { + size?: number; + className?: string; +}; + +export function StackdeckMark({ size = 22, className }: Props) { + return ( + <svg + width={size} + height={size} + viewBox="0 0 22 22" + fill="none" + className={className} + aria-hidden + > + <rect x="1.8" y="6.9" width="12" height="12" rx="2.6" fill="#d4d4d8" /> + <rect x="5" y="5" width="12" height="12" rx="2.6" fill="#737373" /> + <rect x="8.2" y="3.1" width="12" height="12" rx="2.6" fill="#0a0a0a" /> + </svg> + ); +} diff --git a/src/components/Viewer.css b/src/components/Viewer.css new file mode 100644 index 0000000..e2d598a --- /dev/null +++ b/src/components/Viewer.css @@ -0,0 +1,888 @@ +.viewer { + --vc-bg: #ffffff; + --vc-bg-soft: #fafafa; + --vc-bg-elev: #f4f4f5; + --vc-bg-elev-2: #e8e8eb; + --vc-fg: #0a0a0a; + --vc-fg-soft: #2a2a2a; + --vc-fg-muted: #595959; + --vc-fg-dim: #8a8a8a; + --vc-fg-faint: #c4c4c4; + --vc-line: #ececec; + --vc-line-strong: #d4d4d4; + --vc-line-vivid: #a3a3a3; + + --vc-accent: var(--accent); + --vc-accent-soft: var(--accent-soft); + --vc-accent-bg: var(--accent-bg); + --vc-accent-bg-strong: var(--accent-bg-strong); + --vc-accent-border: var(--accent-border); + + --vc-stage-bg: #171717; + --vc-stage-grid: rgba(255, 255, 255, 0.03); + + --vc-radius: 8px; + --vc-radius-sm: 5px; + + /* Single source of truth for the navbar height — also used by the + sidebar's sticky CONTENTS header so they line up perfectly. */ + --vc-bar-h: 60px; + + display: grid; + grid-template-columns: 1fr 340px; + grid-template-rows: var(--vc-bar-h) 1fr; + grid-template-areas: + 'bar strip' + 'stage strip'; + height: 100vh; + background: var(--vc-bg); + color: var(--vc-fg); + position: relative; + animation: fade 0.25s var(--ease) both; +} + +/* Top bar ───────────────────────────────────────────────────────── */ + +.vbar { + grid-area: bar; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px 0 14px; + border-bottom: 1px solid var(--vc-line); + background: var(--vc-bg); + gap: 16px; +} + +.vbar-left, +.vbar-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.vbar-brand { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--vc-radius-sm); + color: var(--vc-fg); + transition: background 0.12s var(--ease); +} + +.vbar-brand:hover { + background: var(--vc-bg-elev); +} + +.vbar-sep { + width: 1px; + height: 20px; + background: var(--vc-line-strong); + flex-shrink: 0; +} + +.vbar-crumb { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: var(--vc-radius-sm); + font-size: 14px; + font-weight: 500; + color: var(--vc-fg-soft); + letter-spacing: -0.005em; + transition: + background 0.12s var(--ease), + color 0.12s var(--ease); + white-space: nowrap; +} + +.vbar-crumb-link { + color: var(--vc-fg-muted); +} + +.vbar-crumb-link:hover { + background: var(--vc-bg-elev); + color: var(--vc-fg); +} + +.vbar-crumb-link svg { + transition: transform 0.15s var(--ease); +} + +.vbar-crumb-link:hover svg { + transform: translateX(-2px); +} + +.vbar-crumb-trail { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + color: var(--vc-fg); + min-width: 0; +} + +.vbar-crumb-client { + color: var(--vc-fg-muted); + font-weight: 500; + white-space: nowrap; +} + +.vbar-crumb-current { + color: var(--vc-fg); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 380px; +} + +/* Personalization chip, shown when ?to=<company> is in the URL. Subtle but + present, so the prospect feels seen without the page screaming about it. */ +.vbar-recipient { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px 0 8px; + border: 1px solid var(--vc-accent-border); + background: var(--vc-accent-bg); + color: var(--vc-accent); + border-radius: 999px; + font-size: 12px; + font-weight: 600; + letter-spacing: -0.005em; + white-space: nowrap; + margin-left: 4px; +} + +.vbar-recipient-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--vc-accent); + box-shadow: 0 0 0 3px var(--vc-accent-bg-strong); +} + +.vbar-recipient-text { + font-feature-settings: 'ss01' on; +} + +/* Download button (was icon-only, now grows to fit progress label). */ +.vbar-download { + display: inline-flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 12px; + background: var(--vc-bg-soft); + border: 1px solid var(--vc-line); + border-radius: var(--vc-radius); + color: var(--vc-fg-soft); + font-family: var(--sans); + font-size: 13px; + font-weight: 500; + letter-spacing: -0.005em; + cursor: pointer; + transition: + background 0.12s var(--ease), + border-color 0.12s var(--ease), + color 0.12s var(--ease); +} + +.vbar-download:hover:not(:disabled) { + background: var(--vc-bg-elev); + border-color: var(--vc-line-strong); + color: var(--vc-fg); +} + +.vbar-download:disabled { + cursor: progress; + opacity: 0.85; +} + +.vbar-download-active { + border-color: var(--vc-accent-border); + background: var(--vc-accent-bg); + color: var(--vc-accent); +} + +.vbar-download-text { + font-feature-settings: 'tnum' on; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.vbar-spinner { + animation: vbar-spin 0.8s linear infinite; + color: var(--vc-accent); +} + +@keyframes vbar-spin { + to { + transform: rotate(360deg); + } +} + +/* Primary CTA: indigo. */ +.vbar-cta { + display: inline-flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 14px; + background: var(--vc-accent); + color: #ffffff; + border: 0; + border-radius: var(--vc-radius); + font-size: 14px; + font-weight: 600; + letter-spacing: -0.005em; + box-shadow: + 0 1px 2px rgba(79, 70, 229, 0.16), + 0 6px 14px -6px rgba(79, 70, 229, 0.32); + transition: + background 0.15s var(--ease), + box-shadow 0.15s var(--ease), + transform 0.06s var(--ease); + cursor: pointer; +} + +.vbar-cta:hover { + background: var(--vc-accent-soft); + box-shadow: + 0 1px 2px rgba(79, 70, 229, 0.2), + 0 10px 22px -8px rgba(79, 70, 229, 0.4); +} + +.vbar-cta:active { + transform: translateY(1px); +} + +.vbar-cta-key { + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--mono); + font-size: 11px; + padding: 1px 5px; + background: rgba(255, 255, 255, 0.18); + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 3px; + letter-spacing: 0.04em; + font-weight: 500; + color: #ffffff; +} + +/* Progress strip — spans the full width so it sits flush across both the + navbar and the sidebar header, giving a single continuous chrome edge. */ + +.vprogress { + position: absolute; + top: var(--vc-bar-h); + left: 0; + right: 0; + height: 2px; + background: var(--vc-bg-elev); + overflow: hidden; + z-index: 4; +} + +.vprogress-fill { + position: absolute; + inset: 0; + background: var(--vc-accent); + transform-origin: left; + transition: transform 0.3s var(--ease); +} + +/* Persistent corner CTA, present on every non-final slide so a prospect + always has one click between them and a reply. */ +.vstage-corner-cta { + position: absolute; + bottom: 32px; + right: 32px; + display: inline-flex; + align-items: center; + gap: 7px; + height: 32px; + padding: 0 14px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 999px; + font-size: 12px; + font-weight: 600; + letter-spacing: -0.005em; + white-space: nowrap; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + z-index: 3; + transition: + background 0.15s var(--ease), + border-color 0.15s var(--ease), + transform 0.15s var(--ease); +} + +.vstage-corner-cta:hover { + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.28); + transform: translateY(-1px); +} + +.vstage-corner-cta svg { + opacity: 0.85; +} + +/* Stage ─────────────────────────────────────────────────────────── */ + +.vstage { + grid-area: stage; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 32px 28px; + min-width: 0; + min-height: 0; + position: relative; + background: var(--vc-stage-bg); + background-image: radial-gradient(circle, var(--vc-stage-grid) 1px, transparent 1px); + background-size: 28px 28px; +} + +.vstage-frame { + position: relative; + width: 100%; + max-width: calc((100vh - var(--vc-bar-h) - 2px - 32px - 28px - 64px) * (16 / 9)); + max-height: calc(100vh - var(--vc-bar-h) - 2px - 32px - 28px - 64px); + aspect-ratio: 16 / 9; + border: 1px solid rgba(255, 255, 255, 0.06); + background: #ffffff; + border-radius: var(--vc-radius); + overflow: hidden; + z-index: 1; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02) inset, + 0 30px 80px -24px rgba(0, 0, 0, 0.7); + /* Reset because element is now a <button> for mobile tap-to-enlarge. */ + padding: 0; + font: inherit; + color: inherit; + cursor: default; +} + +/* "Tap to enlarge" hint, only meaningful on touch devices where slide text + is illegibly small. Hidden on desktop where the hover/F shortcut covers it. */ +.vstage-tap-hint { + position: absolute; + inset: 0; + display: none; + align-items: flex-end; + justify-content: center; + padding-bottom: 14px; + pointer-events: none; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(10, 10, 10, 0.7); +} + +.vstage-tap-hint svg { + margin-right: 6px; + vertical-align: middle; +} + +.vstage-frame .slide-wrap { + width: 100%; + height: 100%; + border-radius: 0; + background: #ffffff; +} + +/* Bottom caption with pager. */ +.vstage-caption { + display: flex; + align-items: center; + gap: 14px; + margin-top: 18px; + font-size: 13px; + color: rgba(255, 255, 255, 0.6); + z-index: 1; + width: 100%; + max-width: calc((100vh - var(--vc-bar-h) - 2px - 32px - 28px - 64px) * (16 / 9)); +} + +/* Pager pill */ +.vstage-pager { + display: inline-flex; + align-items: center; + height: 36px; + padding: 3px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.vstage-pager-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.85); + border-radius: 999px; + transition: + background 0.12s var(--ease), + color 0.12s var(--ease); +} + +.vstage-pager-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.14); + color: #ffffff; +} + +.vstage-pager-btn:active:not(:disabled) { + background: rgba(255, 255, 255, 0.22); +} + +.vstage-pager-btn:disabled { + opacity: 0.28; + cursor: not-allowed; +} + +.vstage-pager-counter { + font-family: var(--mono); + font-size: 13px; + font-feature-settings: 'tnum' on; + font-variant-numeric: tabular-nums; + font-weight: 600; + padding: 0 12px; + letter-spacing: 0.02em; + color: #ffffff; + white-space: nowrap; +} + +.vstage-pager-counter-current { + color: #ffffff; +} + +.vstage-pager-counter-sep { + margin: 0 4px; + color: rgba(255, 255, 255, 0.32); +} + +.vstage-pager-counter-total { + color: rgba(255, 255, 255, 0.55); +} + +.vstage-caption-title { + color: rgba(255, 255, 255, 0.95); + font-size: 14px; + font-weight: 500; + letter-spacing: -0.005em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +/* Last-slide CTA, white pill on dark stage. */ +.vstage-cta { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + height: 32px; + padding: 0 16px; + background: #ffffff; + color: var(--vc-fg); + border-radius: 999px; + font-size: 13px; + font-weight: 500; + letter-spacing: -0.005em; + box-shadow: 0 6px 18px -6px rgba(0, 0, 0, 0.5); + transition: + background 0.15s var(--ease), + transform 0.15s var(--ease); + white-space: nowrap; +} + +.vstage-cta:hover { + background: #f4f4f5; + transform: translateY(-1px); +} + +.vstage-cta-action { + font-weight: 700; + color: var(--vc-accent); +} + +.vstage-cta svg { + transition: transform 0.2s var(--ease); + color: var(--vc-accent); +} + +.vstage-cta:hover svg { + transform: translateX(3px); +} + +/* Sidebar (strip) ──────────────────────────────────────────────── */ + +.vstrip { + grid-area: strip; + border-left: 1px solid var(--vc-line); + background: var(--vc-bg); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.vstrip-head { + position: sticky; + top: 0; + height: var(--vc-bar-h); + min-height: var(--vc-bar-h); + flex: 0 0 var(--vc-bar-h); + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px; + background: var(--vc-bg); + border-bottom: 1px solid var(--vc-line); + z-index: 1; +} + +.vstrip-head-label { + font-family: var(--mono); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--vc-fg-soft); + font-weight: 700; +} + +.vstrip-head-count { + font-family: var(--mono); + font-size: 12px; + color: var(--vc-fg-muted); + font-feature-settings: 'tnum' on; + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; + font-weight: 600; +} + +.vstrip-list { + display: flex; + flex-direction: column; + padding: 16px 18px 36px; + gap: 18px; +} + +/* Thumb card. The "card behind" effect is rendered entirely through + layered box-shadows, not a pseudo-element. Box-shadows paint with the + element so they cannot be lost to a stacking-context bug, and there is + no z-index involved at all on this layer. */ +.thumb { + display: flex; + flex-direction: column; + gap: 12px; + background: transparent; + border: 0; + /* Reserve room for the ghost card to peek bottom-right without colliding + with siblings. */ + padding: 0 10px 10px 0; + border-radius: var(--vc-radius); + text-align: left; + position: relative; + color: inherit; + cursor: pointer; + isolation: isolate; + /* Leave room for the sticky CONTENTS header + progress strip when this + thumb is auto-scrolled into view. */ + scroll-margin-top: calc(var(--vc-bar-h) + 14px); + scroll-margin-bottom: 14px; +} + +.thumb-frame { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: var(--vc-radius-sm); + background: #ffffff; + /* Front-card hairline + ghost-card fill + ghost-card hairline. + First shadow paints on top, last paints furthest back. */ + box-shadow: + 0 0 0 1px var(--vc-line-strong), + 7px 7px 0 0 var(--vc-bg-elev), + 7px 7px 0 1px var(--vc-line-strong); + transition: + box-shadow 0.22s var(--ease), + transform 0.22s var(--ease); +} + +.thumb-frame .slide-wrap { + width: 100%; + height: 100%; + border-radius: var(--vc-radius-sm); + background: #ffffff; + overflow: hidden; +} + +/* Hover: front card nudges up-left and the ghost offset grows, so the + visual gap between front and back widens. Adds a soft drop shadow for + actual lift, not just hue. */ +.thumb:hover:not(.thumb-active) .thumb-frame { + transform: translate(-2px, -2px); + box-shadow: + 0 0 0 1px var(--vc-line-vivid), + 11px 11px 0 0 var(--vc-bg-elev), + 11px 11px 0 1px var(--vc-line-strong), + 0 16px 30px -14px rgba(15, 15, 20, 0.22); +} + +/* Active: indigo ring on the front, indigo-tinted ghost behind, and a + colored lift. Specificity beats :hover because the rule comes later + AND uses a more-specific selector below. */ +.thumb-active .thumb-frame { + box-shadow: + 0 0 0 2px var(--vc-accent), + 8px 8px 0 0 var(--vc-accent-bg), + 8px 8px 0 1px var(--vc-accent-border), + 0 14px 32px -12px rgba(79, 70, 229, 0.34); +} + +.thumb-active:hover .thumb-frame { + transform: translate(-2px, -2px); + box-shadow: + 0 0 0 2px var(--vc-accent), + 12px 12px 0 0 var(--vc-accent-bg), + 12px 12px 0 1px var(--vc-accent-border), + 0 18px 34px -12px rgba(79, 70, 229, 0.42); +} + +/* Number pill, top-left of the front card. */ +.thumb-num { + position: absolute; + top: 10px; + left: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + min-width: 32px; + padding: 0 9px; + background: rgba(255, 255, 255, 0.94); + color: var(--vc-fg); + border-radius: 999px; + font-family: var(--mono); + font-size: 11px; + font-feature-settings: 'tnum' on; + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; + font-weight: 700; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.06), + 0 2px 6px rgba(0, 0, 0, 0.18); + z-index: 2; + transition: + background 0.15s var(--ease), + color 0.15s var(--ease); +} + +.thumb-active .thumb-num { + background: var(--vc-accent); + color: #ffffff; + box-shadow: + 0 0 0 1px rgba(79, 70, 229, 0.32), + 0 4px 10px -2px rgba(79, 70, 229, 0.42); +} + +.thumb-title { + font-size: 13px; + line-height: 1.4; + color: var(--vc-fg-muted); + padding: 0 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + transition: color 0.15s var(--ease); +} + +.thumb-active .thumb-title { + color: var(--vc-fg); + font-weight: 600; +} + +.thumb:hover .thumb-title { + color: var(--vc-fg-soft); +} + +/* "Now playing" badge, top-right of the active front card. */ +.thumb-badge { + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: var(--vc-accent); + border-radius: 999px; + box-shadow: + 0 0 0 2px #ffffff, + 0 2px 6px rgba(79, 70, 229, 0.42); + z-index: 2; +} + +.playing-dot { + display: inline-flex; + align-items: flex-end; + gap: 1.5px; + height: 9px; +} + +.playing-dot span { + width: 1.5px; + background: #ffffff; + border-radius: 1px; + animation: playing-bar 1.1s ease-in-out infinite; +} + +.playing-dot span:nth-child(1) { + height: 4px; + animation-delay: 0s; +} + +.playing-dot span:nth-child(2) { + height: 8px; + animation-delay: 0.15s; +} + +.playing-dot span:nth-child(3) { + height: 5px; + animation-delay: 0.3s; +} + +@keyframes playing-bar { + 0%, + 100% { + transform: scaleY(0.4); + } + 50% { + transform: scaleY(1); + } +} + +/* Responsive ────────────────────────────────────────────────────── */ + +@media (max-width: 1100px) { + .viewer { + grid-template-columns: 1fr 300px; + } + .vbar-crumb-current { + max-width: 240px; + } +} + +@media (max-width: 900px) { + .viewer { + grid-template-columns: 1fr; + grid-template-rows: var(--vc-bar-h) 1fr 180px; + grid-template-areas: + 'bar' + 'stage' + 'strip'; + } + .vstrip { + border-left: 0; + border-top: 1px solid var(--vc-line); + } + .vstrip-list { + flex-direction: row; + overflow-x: auto; + padding: 14px 18px; + gap: 10px; + } + .thumb { + flex: 0 0 auto; + width: 200px; + } + .vbar-crumb-client, + .vbar-recipient { + display: none; + } + .vbar-crumb-current { + max-width: 180px; + } + + /* Mobile reading mode: stage becomes a tappable card with a clear hint + that fullscreen is one tap away, since 1920x1080 slide text is too + small to read at phone widths. */ + .vstage { + padding: 16px 16px 12px; + } + .vstage-frame { + cursor: pointer; + } + .vstage-frame:active { + transform: scale(0.99); + transition: transform 0.1s var(--ease); + } + .vstage-tap-hint { + display: flex; + } + /* Corner CTA collides with the tap-hint and the pager on small screens. */ + .vstage-corner-cta { + display: none; + } +} + +@media (max-width: 600px) { + .vbar-crumb-link span, + .vbar-cta span, + .vbar-download-text { + display: none; + } + .vbar-crumb-link { + width: 32px; + padding: 0; + justify-content: center; + } + .vbar-cta { + width: 38px; + padding: 0; + justify-content: center; + } + .vbar-download { + width: 38px; + padding: 0; + justify-content: center; + } + .vstage-caption-title { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .playing-dot span { + animation: none !important; + } +} diff --git a/src/components/Viewer.tsx b/src/components/Viewer.tsx new file mode 100644 index 0000000..0a1c29b --- /dev/null +++ b/src/components/Viewer.tsx @@ -0,0 +1,481 @@ +'use client'; + +import Link from 'next/link'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { SlideFrame } from './SlideFrame'; +import { StackdeckMark } from './StackdeckMark'; +import { Present } from './Present'; +import { generateDeckPdf, type ProgressEvent } from '@/lib/generate-pdf'; +import './SlideFrame.css'; +import './Viewer.css'; + +const CONTACT_EMAIL = 'ankur@octifytechnologies.com'; +const SENDER_NAME = 'Ankur'; + +type SlideRef = { file: string; title?: string }; + +type Props = { + slug: string; + title: string; + client?: string; + slides: SlideRef[]; +}; + +export function Viewer({ slug, title, client, slides }: Props) { + const [index, setIndex] = useState(0); + const [presenting, setPresenting] = useState(false); + const [pdfStatus, setPdfStatus] = useState<ProgressEvent | null>(null); + // Personalization, read from `?to=<company>` so links sent out can be + // tailored without changing app code or storing per-recipient state. + const [recipient, setRecipient] = useState<string | null>(null); + const total = slides.length; + const current = slides[index]; + + // Read `?to=` once on mount. + useEffect(() => { + const to = new URLSearchParams(window.location.search).get('to'); + if (to && to.length <= 80) setRecipient(to); + }, []); + + // URL hash <-> slide index. On mount we honor an incoming `#3`; on every + // subsequent navigation we write back via replaceState. The prevIndexRef + // skips the first commit so we never clobber the incoming hash. + const hashPrevIndex = useRef<number | null>(null); + useEffect(() => { + const m = window.location.hash.match(/^#(\d{1,3})$/); + if (m) { + const n = Math.max(1, Math.min(parseInt(m[1], 10), total)) - 1; + setIndex(n); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (hashPrevIndex.current === null) { + hashPrevIndex.current = index; + return; + } + if (hashPrevIndex.current === index) return; + hashPrevIndex.current = index; + const target = `#${index + 1}`; + if (window.location.hash !== target) { + window.history.replaceState( + null, + '', + `${window.location.pathname}${window.location.search}${target}`, + ); + } + }, [index]); + + const mailtoHref = useMemo(() => { + const subjectBits = ['Re:', title]; + if (client) subjectBits.push(`(${client})`); + const subject = subjectBits.join(' '); + const greeting = `Hi ${SENDER_NAME},`; + const body = recipient + ? `${greeting}\n\nThanks for sending the ${title} deck. A few thoughts from ${recipient}:\n\n` + : `${greeting}\n\nThanks for sending the ${title} deck. A few thoughts:\n\n`; + return `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + }, [title, client, recipient]); + + const pdfFilename = useMemo(() => { + const safe = title.replace(/[\\/:*?"<>|]+/g, '').trim(); + return `Octify – ${safe}.pdf`; + }, [title]); + + const downloadPdf = useCallback(async () => { + if (pdfStatus && pdfStatus.phase !== 'done') return; + try { + await generateDeckPdf({ + slug, + filename: pdfFilename, + slides, + contactCard: { + deckTitle: title, + client, + recipient, + senderName: SENDER_NAME, + contactEmail: CONTACT_EMAIL, + }, + onProgress: setPdfStatus, + }); + } catch (err) { + console.error('PDF generation failed', err); + setPdfStatus(null); + alert('Could not generate the PDF. Please try again.'); + return; + } + setTimeout(() => setPdfStatus(null), 1200); + }, [slug, slides, pdfStatus, pdfFilename, title, client, recipient]); + + const pdfBusy = pdfStatus !== null && pdfStatus.phase !== 'done'; + const pdfLabel = (() => { + if (!pdfStatus) return 'Download PDF'; + switch (pdfStatus.phase) { + case 'preparing': + return 'Preparing…'; + case 'rendering': + return `Rendering ${pdfStatus.current} / ${pdfStatus.total}`; + case 'composing': + return 'Composing PDF…'; + case 'done': + return 'Saved'; + } + })(); + + const slideUrl = useCallback((file: string) => `/c/${slug}/slides/${file}`, [slug]); + + const next = useCallback(() => setIndex((i) => Math.min(i + 1, total - 1)), [total]); + const prev = useCallback(() => setIndex((i) => Math.max(i - 1, 0)), []); + + const enterPresent = useCallback(() => setPresenting(true), []); + const exitPresent = useCallback(() => setPresenting(false), []); + + // Keep the active sidebar thumb in view as the user navigates. block: + // 'nearest' = no-op when already visible, minimal scroll otherwise. The + // scroll-margin set in CSS leaves room for the sticky CONTENTS header. + useEffect(() => { + const active = document.querySelector('.vstrip [aria-current="true"]') as HTMLElement | null; + active?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [index]); + + useEffect(() => { + if (presenting) return; // Present component owns its own keys + function onKey(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement) return; + if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { + e.preventDefault(); + next(); + } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { + e.preventDefault(); + prev(); + } else if (e.key === 'Home') { + setIndex(0); + } else if (e.key === 'End') { + setIndex(total - 1); + } else if (e.key === 'f' || e.key === 'F') { + enterPresent(); + } else if (e.key >= '1' && e.key <= '9') { + const n = Number(e.key) - 1; + if (n < total) setIndex(n); + } + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [next, prev, total, enterPresent, presenting]); + + const progress = (index + 1) / total; + + return ( + <div className="viewer"> + <header className="vbar"> + <div className="vbar-left"> + <Link href="/" className="vbar-brand" aria-label="stackdeck"> + <StackdeckMark size={20} /> + </Link> + + <span className="vbar-sep" aria-hidden /> + + <Link href="/" className="vbar-crumb vbar-crumb-link"> + <ChevronLeft /> + <span>All decks</span> + </Link> + + <span className="vbar-sep" aria-hidden /> + + <div className="vbar-crumb-trail" title={title}> + {client ? <span className="vbar-crumb-client">{client}</span> : null} + {client ? <ChevronRight muted /> : null} + <span className="vbar-crumb-current">{title}</span> + {recipient ? ( + <span className="vbar-recipient" title={`Personalized for ${recipient}`}> + <span className="vbar-recipient-dot" aria-hidden /> + <span className="vbar-recipient-text">for {recipient}</span> + </span> + ) : null} + </div> + </div> + + <div className="vbar-right"> + <button + type="button" + onClick={downloadPdf} + disabled={pdfBusy} + className={`vbar-download ${pdfStatus ? 'vbar-download-active' : ''}`} + title={pdfLabel} + aria-label={pdfLabel} + aria-busy={pdfBusy} + > + {pdfBusy ? <Spinner /> : <DownloadIcon />} + <span className="vbar-download-text">{pdfLabel}</span> + </button> + + <button type="button" onClick={enterPresent} className="vbar-cta" title="Present (F)"> + <PlayIcon /> + <span>Present</span> + <kbd className="vbar-cta-key">F</kbd> + </button> + </div> + </header> + + <div className="vprogress" aria-hidden> + <div className="vprogress-fill" style={{ transform: `scaleX(${progress})` }} /> + </div> + + <main className="vstage"> + <button + type="button" + className="vstage-frame" + onClick={() => { + if (window.matchMedia('(max-width: 900px)').matches) enterPresent(); + }} + aria-label="Open slide fullscreen" + > + <SlideFrame + src={slideUrl(current.file)} + title={current.title ?? `Slide ${index + 1}`} + showLoader + /> + <span className="vstage-tap-hint"> + <ExpandIcon /> + <span>Tap to enlarge</span> + </span> + </button> + <div className="vstage-caption"> + <div className="vstage-pager" role="group" aria-label="Slide navigation"> + <button + type="button" + className="vstage-pager-btn" + onClick={prev} + disabled={index === 0} + aria-label="Previous slide" + title="Previous (←)" + > + <svg width="14" height="14" viewBox="0 0 12 12" fill="none"> + <path + d="M8 2L4 6l4 4" + stroke="currentColor" + strokeWidth="1.7" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </button> + <span className="vstage-pager-counter"> + <span className="vstage-pager-counter-current"> + {String(index + 1).padStart(2, '0')} + </span> + <span className="vstage-pager-counter-sep">/</span> + <span className="vstage-pager-counter-total">{String(total).padStart(2, '0')}</span> + </span> + <button + type="button" + className="vstage-pager-btn" + onClick={next} + disabled={index === total - 1} + aria-label="Next slide" + title="Next (→)" + > + <svg width="14" height="14" viewBox="0 0 12 12" fill="none"> + <path + d="M4 2l4 4-4 4" + stroke="currentColor" + strokeWidth="1.7" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </button> + </div> + + {current.title ? <span className="vstage-caption-title">{current.title}</span> : null} + + {index === total - 1 ? ( + <a href={mailtoHref} className="vstage-cta"> + <span>Like what you see?</span> + <span className="vstage-cta-action">Get in touch</span> + <svg width="12" height="10" viewBox="0 0 14 10" fill="none"> + <path + d="M1 5h12m0 0L9 1m4 4L9 9" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </a> + ) : null} + </div> + + {/* Persistent corner CTA, visible on every slide so a prospect who + bails partway through always has a path back. Hidden on the last + slide where the inline caption CTA already covers it. */} + {index !== total - 1 ? ( + <a + href={mailtoHref} + className="vstage-corner-cta" + title={`Email ${SENDER_NAME} at ${CONTACT_EMAIL}`} + > + <svg width="12" height="12" viewBox="0 0 14 14" fill="none" aria-hidden> + <path + d="M2 4l5 4 5-4M2 4v6h10V4M2 4h10" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + <span>Talk to {SENDER_NAME}</span> + </a> + ) : null} + </main> + + <aside className="vstrip" aria-label="Slides"> + <div className="vstrip-head"> + <span className="vstrip-head-label">Contents</span> + <span className="vstrip-head-count"> + {String(index + 1).padStart(2, '0')}/{String(total).padStart(2, '0')} + </span> + </div> + <div className="vstrip-list"> + {slides.map((s, i) => ( + <button + key={s.file} + type="button" + className={`thumb ${i === index ? 'thumb-active' : ''}`} + onClick={() => setIndex(i)} + aria-label={`Go to slide ${i + 1}${s.title ? `: ${s.title}` : ''}`} + aria-current={i === index} + > + <div className="thumb-frame"> + <SlideFrame src={slideUrl(s.file)} title={s.title} lazy interactive={false} /> + <span className="thumb-num" aria-hidden> + {String(i + 1).padStart(2, '0')} + </span> + {i === index ? ( + <span className="thumb-badge" aria-hidden> + <PlayingDot /> + </span> + ) : null} + </div> + <span className="thumb-title">{s.title || `Slide ${i + 1}`}</span> + </button> + ))} + </div> + </aside> + + {presenting ? ( + <Present + slug={slug} + title={title} + slides={slides} + initialIndex={index} + onIndexChange={setIndex} + onExit={exitPresent} + /> + ) : null} + </div> + ); +} + +function ChevronLeft() { + return ( + <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden> + <path + d="M8 2L4 6l4 4" + stroke="currentColor" + strokeWidth="1.6" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function ChevronRight({ muted }: { muted?: boolean }) { + return ( + <svg + width="10" + height="10" + viewBox="0 0 12 12" + fill="none" + aria-hidden + style={{ opacity: muted ? 0.45 : 1 }} + > + <path + d="M4 2l4 4-4 4" + stroke="currentColor" + strokeWidth="1.6" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function PlayIcon() { + return ( + <svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden> + <path d="M3 2v8l7-4-7-4z" fill="currentColor" /> + </svg> + ); +} + +function DownloadIcon() { + return ( + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden> + <path + d="M7 1.5v7M4 6l3 3 3-3M2 11.5h10" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function PlayingDot() { + return ( + <span className="playing-dot"> + <span /> + <span /> + <span /> + </span> + ); +} + +function ExpandIcon() { + return ( + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden> + <path + d="M2 5.5V2h3.5M12 8.5V12H8.5M2 8.5V12h3.5M12 5.5V2H8.5" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function Spinner() { + return ( + <svg + width="14" + height="14" + viewBox="0 0 14 14" + fill="none" + aria-hidden + className="vbar-spinner" + > + <circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.4" opacity="0.22" /> + <path + d="M12.5 7a5.5 5.5 0 0 0-5.5-5.5" + stroke="currentColor" + strokeWidth="1.4" + strokeLinecap="round" + /> + </svg> + ); +} diff --git a/src/components/index.ts b/src/components/index.ts deleted file mode 100644 index b41b180..0000000 --- a/src/components/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { AppTopbar } from './AppTopbar'; - -export { Button } from './primitives/Button'; - -export { Heading, Subheading, Body, Caption, Mono, Label } from './primitives/Text'; - -export { PageShell, PageMain } from './layout/PageShell'; -export { PageWorkbar } from './layout/PageWorkbar'; -export { GalleryGrid } from './layout/GalleryGrid'; diff --git a/src/components/layout/BackLink.tsx b/src/components/layout/BackLink.tsx deleted file mode 100644 index 9662160..0000000 --- a/src/components/layout/BackLink.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Link from 'next/link'; -import type { ReactNode } from 'react'; - -import './page.css'; - -export function BackLink({ - href, - children, - ariaLabel, -}: { - href: string; - children: ReactNode; - ariaLabel?: string; -}) { - return ( - <Link href={href} className="back-link" aria-label={ariaLabel}> - <span className="back-link__arrow" aria-hidden> - <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> - <path - d="M9 11L5 7L9 3" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - </svg> - </span> - {children} - </Link> - ); -} diff --git a/src/components/layout/GalleryGrid.tsx b/src/components/layout/GalleryGrid.tsx deleted file mode 100644 index f5eef62..0000000 --- a/src/components/layout/GalleryGrid.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { ReactNode } from 'react'; - -import './page.css'; - -export function GalleryGrid({ children, className }: { children: ReactNode; className?: string }) { - return <div className={cx('gallery-grid', className)}>{children}</div>; -} - -function cx(...parts: Array<string | null | undefined | false>): string { - return parts.filter(Boolean).join(' '); -} diff --git a/src/components/layout/PageShell.tsx b/src/components/layout/PageShell.tsx deleted file mode 100644 index 92c82a3..0000000 --- a/src/components/layout/PageShell.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ReactNode } from 'react'; - -import './page.css'; - -export function PageShell({ children, className }: { children: ReactNode; className?: string }) { - return <div className={cx('page-shell', className)}>{children}</div>; -} - -export function PageMain({ children, className }: { children: ReactNode; className?: string }) { - return <main className={cx('page-main', className)}>{children}</main>; -} - -function cx(...parts: Array<string | null | undefined | false>): string { - return parts.filter(Boolean).join(' '); -} diff --git a/src/components/layout/PageWorkbar.tsx b/src/components/layout/PageWorkbar.tsx deleted file mode 100644 index 5d15598..0000000 --- a/src/components/layout/PageWorkbar.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { ReactNode } from 'react'; - -import { Heading, Mono, Body } from '@/components/primitives/Text'; - -import { BackLink } from './BackLink'; - -import './page.css'; - -export function PageWorkbar({ - back, - title, - count, - subtitle, - actions, -}: { - back?: { href: string; label: string; ariaLabel?: string }; - title: ReactNode; - count?: ReactNode; - subtitle?: ReactNode; - actions?: ReactNode; -}) { - const showRight = actions || subtitle; - return ( - <div className="page-workbar"> - <div className="page-bar-inner"> - <div className="page-workbar__left"> - {back ? ( - <BackLink href={back.href} ariaLabel={back.ariaLabel}> - {back.label} - </BackLink> - ) : null} - <Heading size="lg">{title}</Heading> - {count != null ? <Mono aria-live="polite">{count}</Mono> : null} - </div> - {showRight ? ( - <div className="page-workbar__right"> - {actions} - {subtitle ? ( - <Body as="p" muted className="page-workbar__subtitle"> - {subtitle} - </Body> - ) : null} - </div> - ) : null} - </div> - </div> - ); -} diff --git a/src/components/layout/page.css b/src/components/layout/page.css deleted file mode 100644 index f7515cb..0000000 --- a/src/components/layout/page.css +++ /dev/null @@ -1,195 +0,0 @@ -/* ========================================================================== - page layout primitives - Shared chrome for gallery-style pages: PageShell, PageBarInner, - PageWorkbar, BackLink, GalleryGrid, surface card base. - ========================================================================== */ - -/* PageShell: outermost wrapper used by every gallery-style page. */ -.page-shell { - min-height: 100vh; - background: transparent; - color: var(--text); - font-family: var(--ui); - display: flex; - flex-direction: column; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* PageBarInner: max-width content rail used inside topbar AND workbar. */ -.page-bar-inner { - width: 100%; - max-width: var(--page-max); - margin: 0 auto; - padding: 0 var(--page-pad); - height: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -/* PageWorkbar: 64px sub-header below the global topbar. */ -.page-workbar { - height: 64px; - border-bottom: 1px solid var(--line); - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.025) 0%, - rgba(255, 255, 255, 0.005) 100% - ); -} - -.page-workbar__left { - display: flex; - align-items: baseline; - gap: 14px; - min-width: 0; -} - -.page-workbar__right { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.page-workbar__subtitle { - max-width: 56ch; - text-align: right; -} - -/* BackLink: arrow + label, used to return to a parent route. */ -.back-link { - display: inline-flex; - align-items: center; - gap: 6px; - height: var(--h-control); - padding: 0 10px 0 8px; - border-radius: var(--r-sm); - font-family: var(--ui); - font-size: var(--fs-md); - font-weight: 500; - color: var(--text-2); - text-decoration: none; - transition: - color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease); -} - -.back-link:hover { - color: var(--text); - background: var(--surface); -} - -.back-link__arrow { - display: inline-grid; - place-items: center; - width: 16px; - height: 16px; - color: var(--text-3); - transition: - transform var(--t-fast) var(--ease), - color var(--t-fast) var(--ease); -} - -.back-link:hover .back-link__arrow { - color: var(--text); - transform: translateX(-2px); -} - -/* PageMain: card grid container with consistent gutters. */ -.page-main { - flex: 1; - width: 100%; - max-width: var(--page-max); - margin: 0 auto; - padding: 32px var(--page-pad) 96px; - display: flex; - flex-direction: column; - gap: 24px; -} - -/* GalleryGrid: responsive auto-fill card grid. */ -.gallery-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 16px; -} - -/* SurfaceCard: shared base for clickable card surfaces (deck, template). - Cards extend this with their own --accent and per-card content. */ -.surface-card { - --accent: 180, 180, 180; - position: relative; - display: flex; - flex-direction: column; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, transparent 50%), var(--bg-elev-1); - border: 1px solid var(--line); - border-radius: var(--r-lg); - padding: 0; - overflow: hidden; - text-decoration: none; - text-align: left; - font-family: inherit; - color: var(--text); - cursor: pointer; - box-shadow: var(--highlight), var(--shadow-2); - transition: - border-color var(--t-med) var(--ease), - transform var(--t-med) var(--ease), - box-shadow var(--t-med) var(--ease); -} - -.surface-card:hover { - border-color: rgba(var(--accent), 0.45); - transform: translateY(-1px); - box-shadow: - var(--highlight-strong), - var(--shadow-3), - 0 0 0 1px rgba(var(--accent), 0.12), - 0 12px 32px -16px rgba(var(--accent), 0.35); -} - -.surface-card:active { - transform: translateY(0); -} - -.surface-card--dashed { - background: transparent; - border-style: dashed; - border-color: var(--line-strong); - box-shadow: none; -} - -.surface-card--dashed:hover { - background: var(--surface); - border-color: var(--text-2); - box-shadow: none; - transform: translateY(-1px); -} - -/* Responsive - --------------------------------------------------------------------------*/ - -@media (max-width: 720px) { - .page-workbar { - height: auto; - } - - .page-workbar .page-bar-inner { - flex-direction: column; - align-items: stretch; - padding: 14px 18px; - gap: 10px; - } - - .page-workbar__right { - justify-content: space-between; - } - - .page-main { - padding: 22px 18px 96px; - } -} diff --git a/src/components/primitives/Button.tsx b/src/components/primitives/Button.tsx deleted file mode 100644 index 88bd8f1..0000000 --- a/src/components/primitives/Button.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import Link from 'next/link'; -import type { - AnchorHTMLAttributes, - ButtonHTMLAttributes, - ComponentPropsWithoutRef, - ReactNode, -} from 'react'; - -import './button.css'; - -type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'link' | 'danger'; -type ButtonSize = 'md' | 'sm' | 'xs'; - -type SharedProps = { - variant?: ButtonVariant; - size?: ButtonSize; - iconOnly?: boolean; - className?: string; - children: ReactNode; -}; - -type ButtonAsButton = SharedProps & - Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'className'> & { - as?: 'button'; - }; - -type ButtonAsAnchor = SharedProps & - Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'children' | 'className' | 'href'> & { - as: 'a'; - href: string; - }; - -type ButtonAsLink = SharedProps & - Omit<ComponentPropsWithoutRef<typeof Link>, 'children' | 'className' | 'href'> & { - as: 'link'; - href: ComponentPropsWithoutRef<typeof Link>['href']; - }; - -type ButtonProps = ButtonAsButton | ButtonAsAnchor | ButtonAsLink; - -export function Button(props: ButtonProps) { - const { variant = 'secondary', size = 'md', iconOnly = false, className, children } = props; - - const cls = [ - 'btn', - `btn--${variant}`, - size !== 'md' ? `btn--${size}` : null, - iconOnly ? 'btn--icon' : null, - className, - ] - .filter(Boolean) - .join(' '); - - if (props.as === 'a') { - const { - variant: _v, - size: _s, - iconOnly: _i, - className: _c, - children: _ch, - as: _a, - ...rest - } = props; - void _v; - void _s; - void _i; - void _c; - void _ch; - void _a; - return ( - <a className={cls} {...rest}> - {children} - </a> - ); - } - - if (props.as === 'link') { - const { - variant: _v, - size: _s, - iconOnly: _i, - className: _c, - children: _ch, - as: _a, - ...rest - } = props; - void _v; - void _s; - void _i; - void _c; - void _ch; - void _a; - return ( - <Link className={cls} {...rest}> - {children} - </Link> - ); - } - - const { - variant: _v, - size: _s, - iconOnly: _i, - className: _c, - children: _ch, - as: _a, - type, - ...rest - } = props; - void _v; - void _s; - void _i; - void _c; - void _ch; - void _a; - return ( - <button type={type ?? 'button'} className={cls} {...rest}> - {children} - </button> - ); -} diff --git a/src/components/primitives/Text.tsx b/src/components/primitives/Text.tsx deleted file mode 100644 index 39a2fe1..0000000 --- a/src/components/primitives/Text.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import type { ElementType, HTMLAttributes, ReactNode } from 'react'; - -import './typography.css'; - -type AsProp<T extends ElementType> = { as?: T }; -type Cls = { className?: string }; - -type HeadingSize = 'xl' | 'lg' | 'md'; -type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; - -export function Heading({ - level = 1, - size = 'lg', - className, - children, - ...rest -}: { - level?: HeadingLevel; - size?: HeadingSize; - children: ReactNode; -} & Cls & - HTMLAttributes<HTMLHeadingElement>) { - const Tag = `h${level}` as 'h1'; - return ( - <Tag className={cx('t-heading', `t-heading--${size}`, className)} {...rest}> - {children} - </Tag> - ); -} - -export function Subheading({ - as: Tag = 'p', - className, - children, - ...rest -}: AsProp<'p' | 'div' | 'span' | 'h2' | 'h3'> & - Cls & - HTMLAttributes<HTMLElement> & { children: ReactNode }) { - return ( - <Tag className={cx('t-subheading', className)} {...rest}> - {children} - </Tag> - ); -} - -export function Body({ - as: Tag = 'p', - muted, - strong, - className, - children, - ...rest -}: AsProp<'p' | 'div' | 'span'> & - Cls & - HTMLAttributes<HTMLElement> & { - muted?: boolean; - strong?: boolean; - children: ReactNode; - }) { - return ( - <Tag - className={cx( - 't-body', - muted ? 't-body--muted' : null, - strong ? 't-body--strong' : null, - className, - )} - {...rest} - > - {children} - </Tag> - ); -} - -export function Caption({ - as: Tag = 'p', - muted, - className, - children, - ...rest -}: AsProp<'p' | 'div' | 'span'> & - Cls & - HTMLAttributes<HTMLElement> & { muted?: boolean; children: ReactNode }) { - return ( - <Tag className={cx('t-caption', muted ? 't-caption--muted' : null, className)} {...rest}> - {children} - </Tag> - ); -} - -export function Mono({ - as: Tag = 'span', - className, - children, - ...rest -}: AsProp<'span' | 'p' | 'div'> & Cls & HTMLAttributes<HTMLElement> & { children: ReactNode }) { - return ( - <Tag className={cx('t-mono', className)} {...rest}> - {children} - </Tag> - ); -} - -export function Label({ - as: Tag = 'span', - className, - children, - ...rest -}: AsProp<'span' | 'p' | 'div'> & Cls & HTMLAttributes<HTMLElement> & { children: ReactNode }) { - return ( - <Tag className={cx('t-label', className)} {...rest}> - {children} - </Tag> - ); -} - -function cx(...parts: Array<string | null | undefined | false>): string { - return parts.filter(Boolean).join(' '); -} diff --git a/src/components/primitives/button.css b/src/components/primitives/button.css deleted file mode 100644 index d0d9a5c..0000000 --- a/src/components/primitives/button.css +++ /dev/null @@ -1,163 +0,0 @@ -/* ========================================================================== - button primitive - One control with variant + size modifiers. Replaces .library__cta, - .editor__cta, .library__sort-option, ad-hoc icon buttons. - ========================================================================== */ - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - height: var(--h-control); - padding: 0 14px; - border-radius: var(--r-sm); - border: 1px solid transparent; - background: transparent; - color: var(--text); - font-family: var(--ui); - font-size: var(--fs-md); - font-weight: 500; - letter-spacing: -0.003em; - text-decoration: none; - cursor: pointer; - white-space: nowrap; - transition: - opacity var(--t-fast) var(--ease), - transform var(--t-fast) var(--ease), - background var(--t-fast) var(--ease), - color var(--t-fast) var(--ease), - border-color var(--t-fast) var(--ease), - box-shadow var(--t-fast) var(--ease); -} - -.btn:focus-visible { - outline: 1px solid var(--text); - outline-offset: 2px; -} - -.btn:disabled, -.btn[aria-disabled='true'] { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; -} - -/* Sizes */ - -.btn--sm { - height: var(--h-control-sm); - padding: 0 10px; - font-size: var(--fs-sm); -} - -.btn--xs { - height: var(--h-control-xs); - padding: 0 8px; - font-size: var(--fs-xs); - gap: 4px; -} - -/* Primary: dark pill with shadow (the marquee CTA) */ - -.btn--primary { - background: var(--text); - color: var(--bg); - font-weight: 600; - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.2), - var(--shadow-1); -} - -.btn--primary:hover { - opacity: 0.92; -} - -.btn--primary:active { - transform: translateY(0.5px); -} - -/* Secondary: bordered surface */ - -.btn--secondary { - background: var(--surface); - color: var(--text); - border-color: var(--line); -} - -.btn--secondary:hover { - background: var(--surface-strong); - border-color: var(--line-strong); -} - -.btn--secondary:active { - transform: translateY(0.5px); -} - -/* Ghost: no chrome until hovered */ - -.btn--ghost { - color: var(--text-2); -} - -.btn--ghost:hover { - color: var(--text); - background: var(--surface); -} - -.btn--ghost:active { - transform: translateY(0.5px); -} - -/* Ghost active state (used for segmented controls and "current" links) */ - -.btn--ghost[aria-pressed='true'], -.btn--ghost[aria-current='page'] { - color: var(--text); - background: var(--surface); -} - -/* Link: looks like text */ - -.btn--link { - height: auto; - padding: 0; - color: var(--text-2); - font-weight: 500; - background: transparent; - border-radius: 0; -} - -.btn--link:hover { - color: var(--text); -} - -/* Danger: same shape as secondary, hot edge */ - -.btn--danger { - background: transparent; - color: #ff6b6b; - border-color: rgba(255, 107, 107, 0.3); -} - -.btn--danger:hover { - background: rgba(255, 107, 107, 0.08); - border-color: rgba(255, 107, 107, 0.5); - color: #ff8585; -} - -/* Icon-only: square, no padding */ - -.btn--icon { - width: var(--h-control); - padding: 0; - flex-shrink: 0; -} - -.btn--icon.btn--sm { - width: var(--h-control-sm); -} - -.btn--icon.btn--xs { - width: var(--h-control-xs); -} diff --git a/src/components/primitives/typography.css b/src/components/primitives/typography.css deleted file mode 100644 index 59d500c..0000000 --- a/src/components/primitives/typography.css +++ /dev/null @@ -1,87 +0,0 @@ -/* ========================================================================== - typography primitives - One source of truth for Heading, Subheading, Body, Caption, Mono, Label. - Replaces ad-hoc text styles scattered across library.css and templates.css. - ========================================================================== */ - -.t-heading { - margin: 0; - font-family: var(--ui); - font-size: var(--fs-lg); - font-weight: 500; - letter-spacing: -0.018em; - color: var(--text); - line-height: 1.2; -} - -.t-heading--xl { - font-size: var(--fs-xl); - font-weight: 600; - letter-spacing: -0.022em; -} - -.t-heading--md { - font-size: var(--fs-md); - letter-spacing: -0.012em; - line-height: 1.25; -} - -.t-subheading { - margin: 0; - font-family: var(--ui); - font-size: var(--fs-md); - font-weight: 500; - color: var(--text-2); - letter-spacing: -0.005em; - line-height: 1.4; -} - -.t-body { - margin: 0; - font-family: var(--ui); - font-size: var(--fs-base); - color: var(--text); - line-height: 1.5; - letter-spacing: -0.003em; -} - -.t-body--muted { - color: var(--text-2); -} - -.t-body--strong { - font-weight: 500; -} - -.t-caption { - margin: 0; - font-family: var(--ui); - font-size: var(--fs-sm); - color: var(--text-2); - line-height: 1.45; - letter-spacing: -0.003em; -} - -.t-caption--muted { - color: var(--text-3); -} - -.t-mono { - margin: 0; - font-family: var(--mono); - font-size: var(--fs-xs); - color: var(--text-3); - letter-spacing: 0.02em; - font-variant-numeric: tabular-nums; -} - -.t-label { - margin: 0; - display: inline-block; - font-family: var(--mono); - font-size: var(--fs-xs); - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-3); -} diff --git a/src/editor/AssetsDrawer.tsx b/src/editor/AssetsDrawer.tsx deleted file mode 100644 index 1021799..0000000 --- a/src/editor/AssetsDrawer.tsx +++ /dev/null @@ -1,144 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; - -import { - type AssetSummary, - assetSrc, - deleteAsset, - getAssetUrl, - listAssets, -} from '@/storage/asset-store'; - -type Props = { - onInsert: (snippet: string) => void; - onClose: () => void; - onUpload: (files: FileList | File[]) => Promise<void> | void; -}; - -export function AssetsDrawer({ onInsert, onClose, onUpload }: Props) { - const [assets, setAssets] = useState<AssetSummary[]>([]); - const fileInputRef = useRef<HTMLInputElement>(null); - - const refresh = async () => { - const list = await listAssets(); - setAssets(list); - }; - - useEffect(() => { - void refresh(); - }, []); - - const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { - if (e.target.files?.length) { - await onUpload(e.target.files); - e.target.value = ''; - await refresh(); - } - }; - - const onRemove = async (id: string) => { - await deleteAsset(id); - await refresh(); - }; - - const insertImage = (id: string, name: string) => { - const safeAlt = name.replace(/"/g, "'"); - onInsert(`\n::image{src="${assetSrc(id)}" alt="${safeAlt}"}\n`); - }; - - return ( - <aside className="drawer assets-drawer" role="dialog" aria-label="Asset library"> - <header className="drawer__header"> - <h2 className="drawer__title">Assets</h2> - <button type="button" className="drawer__close" onClick={onClose} aria-label="Close"> - × - </button> - </header> - - <div className="assets-drawer__upload"> - <button - type="button" - className="assets-drawer__upload-btn" - onClick={() => fileInputRef.current?.click()} - > - Upload images - </button> - <input - ref={fileInputRef} - type="file" - accept="image/*" - multiple - hidden - onChange={onFileChange} - /> - <p className="assets-drawer__hint"> - Or drop images onto the preview, or paste from your clipboard. - </p> - </div> - - {assets.length === 0 ? ( - <div className="assets-drawer__empty">No assets yet.</div> - ) : ( - <ul className="assets-drawer__grid"> - {assets.map((a) => ( - <AssetTile - key={a.id} - asset={a} - onInsert={() => insertImage(a.id, a.name)} - onRemove={() => onRemove(a.id)} - /> - ))} - </ul> - )} - </aside> - ); -} - -function AssetTile({ - asset, - onInsert, - onRemove, -}: { - asset: AssetSummary; - onInsert: () => void; - onRemove: () => void; -}) { - const [url, setUrl] = useState<string | undefined>(); - - useEffect(() => { - let alive = true; - void getAssetUrl(asset.id).then((u) => { - if (alive) setUrl(u); - }); - return () => { - alive = false; - }; - }, [asset.id]); - - return ( - <li className="assets-drawer__tile"> - <button - type="button" - className="assets-drawer__tile-btn" - onClick={onInsert} - title="Insert as ::image" - > - {url ? <img src={url} alt={asset.name} /> : <span className="assets-drawer__tile-ph" />} - </button> - <div className="assets-drawer__tile-meta"> - <span className="assets-drawer__tile-name" title={asset.name}> - {asset.name} - </span> - <button - type="button" - className="assets-drawer__remove" - onClick={onRemove} - aria-label={`Delete ${asset.name}`} - > - Remove - </button> - </div> - </li> - ); -} diff --git a/src/editor/ColorPicker.tsx b/src/editor/ColorPicker.tsx deleted file mode 100644 index e865f1a..0000000 --- a/src/editor/ColorPicker.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; - -import { contrastRatio, isValidHex } from '@/lib/color'; - -type Props = { - label: string; - value: string; - onChange: (hex: string) => void; - contrastAgainst?: string; -}; - -/** - * Compact color picker: native swatch on the left, hex input on the right, - * optional contrast badge against a background color. - */ -export function ColorPicker({ label, value, onChange, contrastAgainst }: Props) { - const [draft, setDraft] = useState(value); - - useEffect(() => { - setDraft(value); - }, [value]); - - const commit = (next: string) => { - if (isValidHex(next)) { - const normalized = next.startsWith('#') ? next : `#${next}`; - onChange(normalized.toLowerCase()); - } else { - setDraft(value); - } - }; - - const liveColor = isValidHex(draft) ? draft : isValidHex(value) ? value : null; - const ratio = contrastAgainst && liveColor ? contrastRatio(liveColor, contrastAgainst) : null; - const passesAA = ratio !== null && ratio >= 4.5; - const passesAALarge = ratio !== null && ratio >= 3; - - return ( - <div className="color-picker"> - <span className="color-picker__label">{label}</span> - <div className="color-picker__row"> - <input - type="color" - value={value} - onChange={(e) => onChange(e.target.value)} - aria-label={`${label} color`} - className="color-picker__swatch" - /> - <input - type="text" - value={draft} - onChange={(e) => setDraft(e.target.value)} - onBlur={() => commit(draft)} - onKeyDown={(e) => { - if (e.key === 'Enter') commit(draft); - }} - aria-label={`${label} hex value`} - spellCheck={false} - className="color-picker__hex" - placeholder="#000000" - /> - </div> - {ratio !== null ? ( - <span - className={`color-picker__contrast ${ - passesAA - ? 'color-picker__contrast--ok' - : passesAALarge - ? 'color-picker__contrast--warn' - : 'color-picker__contrast--fail' - }`} - title={`Contrast against ${contrastAgainst}`} - > - {ratio.toFixed(2)}:1 {passesAA ? 'AA' : passesAALarge ? 'AA Large' : 'Fail'} - </span> - ) : null} - </div> - ); -} diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx deleted file mode 100644 index de02f0c..0000000 --- a/src/editor/Editor.tsx +++ /dev/null @@ -1,733 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { ParseError, parseDeck } from '@/ir/parse'; -import { planDeck } from '@/ir/plan'; -import { reorderSlide } from '@/ir/source-edit'; -import { lintColors } from '@/render/lint'; -import { resolveTheme } from '@/render/theme-resolver'; -import type { Brand, Deck, ThemeRef } from '@/ir/schema'; -import { createAsset, assetSrc } from '@/storage/asset-store'; -import { getDeck, type StoredDeck, updateDeck } from '@/storage/deck-store'; -import { DeckRenderer } from '@/render/DeckRenderer'; -import { ExportPdf } from '@/render/ExportPdf'; -import { getPalette } from '@/themes/registry'; -import { DEFAULT_PRESET_ID, getPreset } from '@/app/presets/presets'; - -import { AssetsDrawer } from './AssetsDrawer'; -import { InsertMenu } from './InsertMenu'; -import { SAMPLE_MARKDOWN } from './sample-deck'; -import { SourceEditor } from './SourceEditor'; -import { ThemeDrawer } from './ThemeDrawer'; - -type EditorState = { - presetId: string; - paletteId: string; - fontId?: string; - brand: Brand; -}; - -const DEFAULT_STATE: EditorState = { - presetId: DEFAULT_PRESET_ID, - paletteId: '', - fontId: undefined, - brand: {}, -}; - -type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; - -type Props = { - deckId?: string; -}; - -const SOURCE_WIDTH_KEY = 'stackdeck:source-width'; -const SOURCE_WIDTH_DEFAULT = 480; -const SOURCE_WIDTH_MIN = 320; -const SOURCE_WIDTH_MAX = 900; - -export function Editor({ deckId }: Props) { - const router = useRouter(); - const [source, setSource] = useState(SAMPLE_MARKDOWN); - const [state, setState] = useState<EditorState>(DEFAULT_STATE); - const [drawerOpen, setDrawerOpen] = useState(false); - const [assetsOpen, setAssetsOpen] = useState(false); - const [selectedSlide, setSelectedSlide] = useState(0); - const [loaded, setLoaded] = useState(!deckId); - const [storedDeck, setStoredDeck] = useState<StoredDeck | null>(null); - const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle'); - - const insertRef = useRef<((s: string) => void) | null>(null); - const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); - const skipNextSaveRef = useRef(false); - - const [sourceWidth, setSourceWidth] = useState<number>(SOURCE_WIDTH_DEFAULT); - - useEffect(() => { - if (typeof window === 'undefined') return; - const raw = window.localStorage.getItem(SOURCE_WIDTH_KEY); - if (!raw) return; - const n = Number(raw); - if (!Number.isFinite(n)) return; - setSourceWidth(Math.min(SOURCE_WIDTH_MAX, Math.max(SOURCE_WIDTH_MIN, n))); - }, []); - - const commitSourceWidth = useCallback((next: number) => { - if (typeof window === 'undefined') return; - window.localStorage.setItem(SOURCE_WIDTH_KEY, String(next)); - }, []); - - useEffect(() => { - if (!deckId) { - setLoaded(true); - return; - } - setLoaded(false); - let cancelled = false; - getDeck(deckId).then((deck) => { - if (cancelled) return; - if (!deck) { - router.replace('/'); - return; - } - skipNextSaveRef.current = true; - setStoredDeck(deck); - setSource(deck.source); - const presetForDeck = getPreset(deck.theme.presetId); - setState({ - presetId: deck.theme.presetId, - paletteId: deck.theme.paletteId ?? presetForDeck?.paletteId ?? '', - fontId: deck.theme.fontId, - brand: deck.brand ?? {}, - }); - setSaveStatus('idle'); - setLoaded(true); - }); - return () => { - cancelled = true; - }; - }, [deckId, router]); - - useEffect(() => { - if (!deckId || !loaded) return; - if (skipNextSaveRef.current) { - skipNextSaveRef.current = false; - return; - } - setSaveStatus('saving'); - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(async () => { - try { - const next = await updateDeck(deckId, { - source, - theme: { - presetId: state.presetId, - paletteId: state.paletteId, - fontId: state.fontId, - }, - brand: state.brand, - }); - if (next) setStoredDeck(next); - setSaveStatus('saved'); - } catch { - setSaveStatus('error'); - } - }, 500); - return () => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - }; - }, [deckId, loaded, source, state]); - - const theme: ThemeRef = useMemo( - () => ({ - presetId: state.presetId, - paletteId: state.paletteId, - fontId: state.fontId, - }), - [state.presetId, state.paletteId, state.fontId], - ); - - const lintWarnings = useMemo(() => { - try { - const preset = getPreset(state.presetId); - const palette = getPalette(state.paletteId); - const resolved = resolveTheme(theme, preset, palette, state.brand); - return lintColors(resolved.colors); - } catch { - return []; - } - }, [theme, state.presetId, state.paletteId, state.brand]); - - const result = useMemo(() => { - try { - const parsed = parseDeck(source, { theme, brand: state.brand }); - const planned = planDeck(parsed); - return { ok: true as const, deck: planned }; - } catch (e) { - const message = e instanceof ParseError ? e.message : (e as Error).message; - return { ok: false as const, error: message }; - } - }, [source, theme, state.brand]); - - const handleSelectSlide = useCallback((index: number) => { - setSelectedSlide(index); - }, []); - - const handleReorderSlide = useCallback((from: number, to: number) => { - setSource((s) => reorderSlide(s, from, to)); - setSelectedSlide(to); - }, []); - - const handleInsert = useCallback((snippet: string) => { - insertRef.current?.(snippet); - }, []); - - const handleImageFiles = useCallback(async (files: FileList | File[]) => { - const list = Array.from(files).filter((f) => f.type.startsWith('image/')); - for (const file of list) { - const asset = await createAsset({ blob: file, name: file.name }); - const safeAlt = (file.name || 'image').replace(/"/g, "'"); - insertRef.current?.(`\n::image{src="${assetSrc(asset.id)}" alt="${safeAlt}"}\n`); - } - }, []); - - const onPreviewDrop = useCallback( - (e: React.DragEvent<HTMLDivElement>) => { - e.preventDefault(); - if (e.dataTransfer.files?.length) void handleImageFiles(e.dataTransfer.files); - }, - [handleImageFiles], - ); - - const onPreviewDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { - if (Array.from(e.dataTransfer.items).some((i) => i.kind === 'file')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - } - }, []); - - useEffect(() => { - const onPaste = (e: ClipboardEvent) => { - if (!e.clipboardData) return; - const files: File[] = []; - for (const item of Array.from(e.clipboardData.items)) { - if (item.kind === 'file' && item.type.startsWith('image/')) { - const f = item.getAsFile(); - if (f) files.push(f); - } - } - if (files.length === 0) return; - e.preventDefault(); - void handleImageFiles(files); - }; - window.addEventListener('paste', onPaste); - return () => window.removeEventListener('paste', onPaste); - }, [handleImageFiles]); - - const onEditorReady = useCallback((insert: (s: string) => void) => { - insertRef.current = insert; - }, []); - - const updateBrand = useCallback((brand: Brand) => setState((s) => ({ ...s, brand })), []); - - if (!loaded) { - return ( - <div className="editor editor--loading"> - <div className="editor__loading-mark" aria-hidden /> - <span>Loading deck</span> - </div> - ); - } - - return ( - <div className="editor"> - <header className="editor__topbar no-print"> - <div className="editor__topbar-left"> - <Link href="/" className="editor__back" aria-label="Back to library"> - <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> - <path - d="M9 11L5 7L9 3" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - </svg> - </Link> - <div className="editor__topbar-divider" aria-hidden /> - <div className="editor__deck-title-group"> - <DeckTitleField - value={storedDeck?.title ?? 'Untitled deck'} - onCommit={async (title) => { - if (!deckId) return; - const next = await updateDeck(deckId, { title }); - if (next) setStoredDeck(next); - }} - disabled={!deckId} - /> - <SaveIndicator status={saveStatus} /> - </div> - </div> - - <div className="editor__topbar-right"> - <Link href="/presets" className="editor__nav-link"> - Presets - </Link> - <button - type="button" - className={`editor__nav-link editor__nav-link--button ${ - assetsOpen ? 'editor__nav-link--active' : '' - }`} - onClick={() => { - setAssetsOpen((v) => !v); - setDrawerOpen(false); - }} - aria-pressed={assetsOpen} - > - Assets - </button> - <button - type="button" - className={`editor__nav-link editor__nav-link--button ${ - drawerOpen ? 'editor__nav-link--active' : '' - }`} - onClick={() => { - setDrawerOpen((v) => !v); - setAssetsOpen(false); - }} - aria-pressed={drawerOpen} - > - Design - </button> - {deckId ? ( - <Link - href={`/d/${deckId}/present`} - className="editor__nav-link editor__nav-link--button" - prefetch={false} - > - Present - </Link> - ) : null} - <ExportPdf className="editor__cta" /> - </div> - </header> - - <div - className="editor__shell no-print" - style={{ ['--source-width' as string]: `${sourceWidth}px` } as React.CSSProperties} - > - <div className="editor__source-pane"> - <SourceEditor value={source} onChange={setSource} onReady={onEditorReady} /> - <InsertMenu onInsert={handleInsert} /> - </div> - - <Resizer - value={sourceWidth} - min={SOURCE_WIDTH_MIN} - max={SOURCE_WIDTH_MAX} - onChange={setSourceWidth} - onCommit={commitSourceWidth} - /> - - <div className="editor__preview-pane" onDrop={onPreviewDrop} onDragOver={onPreviewDragOver}> - {lintWarnings.length > 0 ? ( - <div className="editor__lint" role="status"> - <span className="editor__lint-icon" aria-hidden> - ⚠ - </span> - <span className="editor__lint-text"> - {lintWarnings.length === 1 - ? `${lintWarnings[0].label} contrast is ${lintWarnings[0].ratio} (needs ${lintWarnings[0].needs})` - : `${lintWarnings.length} contrast issues — adjust palette or brand colors`} - </span> - </div> - ) : null} - {result.ok ? ( - <PreviewStage - deck={result.deck} - selectedSlide={selectedSlide} - onSelectSlide={handleSelectSlide} - onReorderSlide={handleReorderSlide} - /> - ) : ( - <div className="editor__error"> - <strong>Parse error</strong> - <pre>{result.error}</pre> - </div> - )} - </div> - - {assetsOpen ? ( - <AssetsDrawer - onInsert={(snippet) => insertRef.current?.(snippet)} - onClose={() => setAssetsOpen(false)} - onUpload={(files) => handleImageFiles(files)} - /> - ) : null} - - {drawerOpen ? ( - <ThemeDrawer - presetId={state.presetId} - paletteId={state.paletteId} - fontId={state.fontId} - brand={state.brand} - onPaletteChange={(paletteId) => setState((s) => ({ ...s, paletteId }))} - onFontChange={(fontId) => setState((s) => ({ ...s, fontId }))} - onBrandChange={updateBrand} - onClose={() => setDrawerOpen(false)} - /> - ) : null} - </div> - - <div className="print-only">{result.ok ? <DeckRenderer deck={result.deck} /> : null}</div> - </div> - ); -} - -function DeckTitleField({ - value, - onCommit, - disabled, -}: { - value: string; - onCommit: (next: string) => void | Promise<void>; - disabled?: boolean; -}) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(value); - const inputRef = useRef<HTMLInputElement>(null); - - useEffect(() => { - if (!editing) setDraft(value); - }, [value, editing]); - - useEffect(() => { - if (editing) { - requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); - } - }, [editing]); - - const commit = () => { - const trimmed = draft.trim(); - if (!trimmed) { - setDraft(value); - } else if (trimmed !== value) { - onCommit(trimmed); - } - setEditing(false); - }; - - if (editing) { - return ( - <input - ref={inputRef} - className="editor__deck-title editor__deck-title--editing" - value={draft} - onChange={(e) => setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === 'Enter') commit(); - if (e.key === 'Escape') { - setDraft(value); - setEditing(false); - } - }} - /> - ); - } - - return ( - <button - type="button" - className="editor__deck-title editor__deck-title--button" - onClick={() => !disabled && setEditing(true)} - title={disabled ? value : 'Click to rename'} - disabled={disabled} - > - {value} - </button> - ); -} - -function PreviewStage({ - deck, - selectedSlide, - onSelectSlide, - onReorderSlide, -}: { - deck: Deck; - selectedSlide: number; - onSelectSlide: (i: number) => void; - onReorderSlide: (from: number, to: number) => void; -}) { - const total = deck.slides.length; - const safeIndex = Math.min(Math.max(selectedSlide, 0), Math.max(total - 1, 0)); - const visibleDeck = useMemo<Deck>( - () => ({ ...deck, slides: total === 0 ? [] : [deck.slides[safeIndex]] }), - [deck, safeIndex, total], - ); - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - const target = e.target as HTMLElement | null; - if (target?.closest('input, textarea, [contenteditable], .cm-editor, .drawer')) return; - if (e.key === 'ArrowLeft' && safeIndex > 0) { - e.preventDefault(); - onSelectSlide(safeIndex - 1); - } else if (e.key === 'ArrowRight' && safeIndex < total - 1) { - e.preventDefault(); - onSelectSlide(safeIndex + 1); - } - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [safeIndex, total, onSelectSlide]); - - return ( - <div className="stage"> - <div className="stage__viewport"> - <div className="stage__slide"> - <DeckRenderer deck={visibleDeck} /> - </div> - </div> - <ThumbStrip - deck={deck} - selectedIndex={safeIndex} - onSelect={onSelectSlide} - onReorder={onReorderSlide} - /> - </div> - ); -} - -function ThumbStrip({ - deck, - selectedIndex, - onSelect, - onReorder, -}: { - deck: Deck; - selectedIndex: number; - onSelect: (i: number) => void; - onReorder: (from: number, to: number) => void; -}) { - const total = deck.slides.length; - const activeRef = useRef<HTMLButtonElement>(null); - const [dragIndex, setDragIndex] = useState<number | null>(null); - const [overIndex, setOverIndex] = useState<number | null>(null); - - const singles = useMemo<Deck[]>( - () => deck.slides.map((slide) => ({ ...deck, slides: [slide] })), - [deck], - ); - - useEffect(() => { - activeRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center', - }); - }, [selectedIndex]); - - return ( - <div className="thumb-strip" role="tablist" aria-label="Slide navigation"> - <span className="thumb-strip__counter" aria-live="polite"> - <span className="thumb-strip__counter-num"> - {String(Math.min(selectedIndex + 1, total)).padStart(2, '0')} - </span> - <span className="thumb-strip__counter-sep">/</span> - <span className="thumb-strip__counter-total">{String(total).padStart(2, '0')}</span> - </span> - <div className="thumb-strip__rail"> - {deck.slides.map((slide, i) => { - const active = i === selectedIndex; - return ( - <Thumb - key={slide.id} - ref={active ? activeRef : undefined} - index={i} - single={singles[i]} - active={active} - dragging={dragIndex === i} - over={overIndex === i && dragIndex !== null && dragIndex !== i} - onSelect={onSelect} - onDragStart={() => setDragIndex(i)} - onDragEnter={() => { - if (dragIndex !== null) setOverIndex(i); - }} - onDragOver={(e) => { - if (dragIndex !== null) e.preventDefault(); - }} - onDrop={(e) => { - e.preventDefault(); - if (dragIndex !== null && dragIndex !== i) onReorder(dragIndex, i); - setDragIndex(null); - setOverIndex(null); - }} - onDragEnd={() => { - setDragIndex(null); - setOverIndex(null); - }} - /> - ); - })} - </div> - </div> - ); -} - -type ThumbProps = { - index: number; - single: Deck; - active: boolean; - dragging?: boolean; - over?: boolean; - onSelect: (i: number) => void; - onDragStart?: () => void; - onDragEnter?: () => void; - onDragOver?: (e: React.DragEvent<HTMLButtonElement>) => void; - onDrop?: (e: React.DragEvent<HTMLButtonElement>) => void; - onDragEnd?: () => void; - ref?: React.Ref<HTMLButtonElement>; -}; - -const Thumb = memo(function Thumb({ - index, - single, - active, - dragging, - over, - onSelect, - onDragStart, - onDragEnter, - onDragOver, - onDrop, - onDragEnd, - ref, -}: ThumbProps) { - const cls = [ - 'thumb-strip__item', - active ? 'thumb-strip__item--active' : '', - dragging ? 'thumb-strip__item--dragging' : '', - over ? 'thumb-strip__item--over' : '', - ] - .filter(Boolean) - .join(' '); - return ( - <button - ref={ref} - type="button" - role="tab" - aria-selected={active} - aria-label={`Slide ${index + 1}`} - className={cls} - onClick={() => onSelect(index)} - draggable - onDragStart={(e) => { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', String(index)); - onDragStart?.(); - }} - onDragEnter={onDragEnter} - onDragOver={onDragOver} - onDrop={onDrop} - onDragEnd={onDragEnd} - > - <span className="thumb-strip__num">{String(index + 1).padStart(2, '0')}</span> - <div className="thumb-strip__frame"> - <div className="thumb-strip__scaler"> - <DeckRenderer deck={single} className="deck--thumbnail" /> - </div> - </div> - </button> - ); -}); - -function SaveIndicator({ status }: { status: SaveStatus }) { - const map: Record<SaveStatus, string> = { - idle: '', - saving: 'Saving', - saved: 'Saved', - error: 'Failed', - }; - const text = map[status]; - if (!text) return null; - return ( - <span className={`save-indicator save-indicator--${status}`} aria-live="polite"> - <span className="save-indicator__dot" aria-hidden /> - {text} - </span> - ); -} - -function Resizer({ - value, - min, - max, - onChange, - onCommit, -}: { - value: number; - min: number; - max: number; - onChange: (next: number) => void; - onCommit: (next: number) => void; -}) { - const [dragging, setDragging] = useState(false); - - const onMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - const startX = e.clientX; - const startWidth = value; - setDragging(true); - document.body.style.userSelect = 'none'; - document.body.style.cursor = 'col-resize'; - - let latest = startWidth; - const onMove = (ev: MouseEvent) => { - const next = Math.min(max, Math.max(min, startWidth + (ev.clientX - startX))); - latest = next; - onChange(next); - }; - const onUp = () => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - document.body.style.userSelect = ''; - document.body.style.cursor = ''; - setDragging(false); - onCommit(latest); - }; - - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - }; - - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; - e.preventDefault(); - const step = e.shiftKey ? 32 : 8; - const next = Math.min(max, Math.max(min, value + (e.key === 'ArrowLeft' ? -step : step))); - onChange(next); - onCommit(next); - }; - - return ( - <div - className={`editor__resizer${dragging ? ' editor__resizer--dragging' : ''}`} - role="separator" - aria-orientation="vertical" - aria-label="Resize source pane" - aria-valuenow={value} - aria-valuemin={min} - aria-valuemax={max} - tabIndex={0} - onMouseDown={onMouseDown} - onKeyDown={onKeyDown} - /> - ); -} diff --git a/src/editor/InsertMenu.tsx b/src/editor/InsertMenu.tsx deleted file mode 100644 index 23ee55f..0000000 --- a/src/editor/InsertMenu.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; - -import { INSERT_ITEMS } from './insert-items'; - -type Props = { - onInsert: (snippet: string) => void; -}; - -/** - * Floating "+" pill anchored to the bottom-right of the source pane. - * Opens an upward-anchored panel listing all insert snippets. The same set - * of snippets is also available via the slash command in the editor. - */ -export function InsertMenu({ onInsert }: Props) { - const [open, setOpen] = useState(false); - const ref = useRef<HTMLDivElement>(null); - - useEffect(() => { - if (!open) return; - const onClickOutside = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - const onEsc = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false); - }; - document.addEventListener('mousedown', onClickOutside); - document.addEventListener('keydown', onEsc); - return () => { - document.removeEventListener('mousedown', onClickOutside); - document.removeEventListener('keydown', onEsc); - }; - }, [open]); - - return ( - <div className="insert-menu" ref={ref}> - <button - type="button" - className={`insert-menu__pill ${open ? 'insert-menu__pill--open' : ''}`} - onClick={() => setOpen((v) => !v)} - aria-expanded={open} - aria-haspopup="menu" - aria-label="Insert block" - title="Insert block (or type / in the editor)" - > - <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden> - <path d="M7 3v8M3 7h8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> - </svg> - <span>Insert</span> - <kbd className="insert-menu__kbd">/</kbd> - </button> - {open ? ( - <div className="insert-menu__panel" role="menu"> - <div className="insert-menu__panel-head"> - <span>Insert block</span> - <span className="insert-menu__panel-hint">type / in editor</span> - </div> - <div className="insert-menu__panel-list"> - {INSERT_ITEMS.map((item) => ( - <button - key={item.label} - type="button" - role="menuitem" - className="insert-menu__item" - onClick={() => { - onInsert(item.snippet); - setOpen(false); - }} - > - <span className="insert-menu__item-label">{item.label}</span> - <span className="insert-menu__item-desc">{item.description}</span> - </button> - ))} - </div> - </div> - ) : null} - </div> - ); -} diff --git a/src/editor/SourceEditor.tsx b/src/editor/SourceEditor.tsx deleted file mode 100644 index eb5cec2..0000000 --- a/src/editor/SourceEditor.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client'; - -import { autocompletion, closeBrackets, completionKeymap } from '@codemirror/autocomplete'; -import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; -import { markdown } from '@codemirror/lang-markdown'; -import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'; -import { searchKeymap } from '@codemirror/search'; -import { EditorState } from '@codemirror/state'; -import { - EditorView, - drawSelection, - highlightActiveLine, - highlightActiveLineGutter, - keymap, - lineNumbers, -} from '@codemirror/view'; -import { useEffect, useRef } from 'react'; - -import { directiveHighlight } from './cm-directive-highlight'; -import { slashCommandSource } from './cm-slash-command'; -import { stackdeckSyntaxHighlighting, stackdeckTheme } from './cm-theme'; - -type Props = { - value: string; - onChange: (value: string) => void; - onReady?: (insert: (snippet: string) => void) => void; -}; - -/** - * CodeMirror 6 editor wired up with markdown language support, custom directive - * syntax highlighting, line numbers, history, fold gutter. The parent gets a - * callback to insert text at the cursor (used by the Insert menu). - */ -export function SourceEditor({ value, onChange, onReady }: Props) { - const hostRef = useRef<HTMLDivElement>(null); - const viewRef = useRef<EditorView | null>(null); - const onChangeRef = useRef(onChange); - const onReadyRef = useRef(onReady); - - useEffect(() => { - onChangeRef.current = onChange; - onReadyRef.current = onReady; - }); - - useEffect(() => { - if (!hostRef.current) return; - - const view = new EditorView({ - parent: hostRef.current, - state: EditorState.create({ - doc: value, - extensions: [ - stackdeckTheme, - stackdeckSyntaxHighlighting, - markdown(), - directiveHighlight, - lineNumbers(), - highlightActiveLine(), - highlightActiveLineGutter(), - foldGutter({ - markerDOM: (open) => { - const span = document.createElement('span'); - span.textContent = open ? '▾' : '▸'; - span.className = 'cm-fold-marker'; - return span; - }, - }), - drawSelection(), - history(), - bracketMatching(), - closeBrackets(), - indentOnInput(), - autocompletion({ - override: [slashCommandSource], - activateOnTyping: true, - icons: false, - defaultKeymap: true, - }), - EditorView.lineWrapping, - keymap.of([ - ...defaultKeymap, - ...historyKeymap, - ...searchKeymap, - ...completionKeymap, - indentWithTab, - ]), - EditorView.updateListener.of((update) => { - if (update.docChanged) { - onChangeRef.current(update.state.doc.toString()); - } - }), - ], - }), - }); - - viewRef.current = view; - - onReadyRef.current?.((snippet: string) => { - const v = viewRef.current; - if (!v) return; - const pos = v.state.selection.main.head; - v.dispatch({ - changes: { from: pos, insert: snippet }, - selection: { anchor: pos + snippet.length }, - }); - v.focus(); - }); - - return () => { - view.destroy(); - viewRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // External `value` updates (e.g., load from IndexedDB). Only swap when truly - // different to avoid clobbering the user's caret on every render. - useEffect(() => { - const view = viewRef.current; - if (!view) return; - const current = view.state.doc.toString(); - if (current === value) return; - view.dispatch({ - changes: { from: 0, to: current.length, insert: value }, - }); - }, [value]); - - return <div ref={hostRef} className="cm-host" />; -} diff --git a/src/editor/ThemeDrawer.tsx b/src/editor/ThemeDrawer.tsx deleted file mode 100644 index cde9ac7..0000000 --- a/src/editor/ThemeDrawer.tsx +++ /dev/null @@ -1,304 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import { type Brand, LOGO_POSITIONS, type Palette } from '@/ir/schema'; -import { deriveAccent, isValidHex } from '@/lib/color'; -import { allPalettes, getPalette } from '@/themes/registry'; -import { FONTS, getFont } from '@/themes/fonts'; -import { getPreset } from '@/app/presets/presets'; - -import { ColorPicker } from './ColorPicker'; - -type Section = 'theme' | 'brand'; - -type Props = { - presetId: string; - paletteId: string; - fontId?: string; - brand: Brand; - onPaletteChange: (id: string) => void; - onFontChange: (id: string | undefined) => void; - onBrandChange: (b: Brand) => void; - onClose: () => void; -}; - -export function ThemeDrawer(props: Props) { - const [section, setSection] = useState<Section>('theme'); - - const palette = getPalette(props.paletteId); - const surfaceForContrast = palette?.tokens.surface ?? '#0a0a0a'; - - return ( - <aside className="drawer" aria-label="Design system"> - <header className="drawer__header"> - <h2 className="drawer__title">Design</h2> - <button - type="button" - className="drawer__close" - onClick={props.onClose} - aria-label="Close drawer" - > - <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden> - <path - d="M3.5 3.5L10.5 10.5M10.5 3.5L3.5 10.5" - stroke="currentColor" - strokeWidth="1.4" - strokeLinecap="round" - /> - </svg> - </button> - </header> - - <nav className="drawer__tabs" role="tablist"> - <button - type="button" - role="tab" - aria-selected={section === 'theme'} - className={`drawer__tab ${section === 'theme' ? 'drawer__tab--active' : ''}`} - onClick={() => setSection('theme')} - > - Theme - </button> - <button - type="button" - role="tab" - aria-selected={section === 'brand'} - className={`drawer__tab ${section === 'brand' ? 'drawer__tab--active' : ''}`} - onClick={() => setSection('brand')} - > - Brand - </button> - </nav> - - <div className="drawer__body"> - {section === 'theme' ? ( - <ThemeSection - presetId={props.presetId} - paletteId={props.paletteId} - fontId={props.fontId} - onPaletteChange={props.onPaletteChange} - onFontChange={props.onFontChange} - /> - ) : ( - <BrandSection - brand={props.brand} - surfaceForContrast={surfaceForContrast} - onBrandChange={props.onBrandChange} - /> - )} - </div> - </aside> - ); -} - -function ThemeSection({ - presetId, - paletteId, - fontId, - onPaletteChange, - onFontChange, -}: { - presetId: string; - paletteId: string; - fontId?: string; - onPaletteChange: (id: string) => void; - onFontChange: (id: string | undefined) => void; -}) { - const preset = getPreset(presetId); - const presetFontId = preset?.fontId ?? 'geist'; - const presetFont = getFont(presetFontId); - const activeId = fontId ?? presetFontId; - const activeFamily = getFont(activeId)?.family ?? presetFont?.family ?? 'system-ui'; - - return ( - <div className="drawer__section"> - <Field label="Font"> - <div className="font-button-grid"> - {FONTS.map((f) => ( - <FontButton - key={f.id} - label={f.name} - family={f.family} - selected={f.id === activeId} - onClick={() => onFontChange(f.id === presetFontId ? undefined : f.id)} - /> - ))} - </div> - <p - className="font-preview" - style={{ fontFamily: activeFamily }} - aria-label="Active font preview" - > - The quick brown fox. - </p> - </Field> - - <Field label="Palette"> - <div className="swatch-grid"> - {allPalettes.map((p) => ( - <PaletteSwatch - key={p.id} - palette={p} - selected={p.id === paletteId} - onClick={() => onPaletteChange(p.id)} - /> - ))} - </div> - </Field> - </div> - ); -} - -function FontButton({ - label, - family, - selected, - onClick, -}: { - label: string; - family: string; - selected: boolean; - onClick: () => void; -}) { - return ( - <button - type="button" - onClick={onClick} - className={`font-button ${selected ? 'font-button--selected' : ''}`} - aria-pressed={selected} - title={label} - > - <span className="font-button__sample" style={{ fontFamily: family }} aria-hidden> - Aa - </span> - <span className="font-button__label" style={{ fontFamily: family }}> - {label} - </span> - </button> - ); -} - -function BrandSection({ - brand, - surfaceForContrast, - onBrandChange, -}: { - brand: Brand; - surfaceForContrast: string; - onBrandChange: (b: Brand) => void; -}) { - const update = (patch: Partial<Brand>) => onBrandChange({ ...brand, ...patch }); - - return ( - <div className="drawer__section"> - <Field label="Brand name"> - <input - type="text" - value={brand.name ?? ''} - onChange={(e) => update({ name: e.target.value || undefined })} - placeholder="Acme Inc." - className="text-input" - /> - </Field> - - <Field label="Logo URL"> - <input - type="url" - value={brand.logoUrl ?? ''} - onChange={(e) => update({ logoUrl: e.target.value || undefined })} - placeholder="https://your-cdn.com/logo.svg" - className="text-input" - /> - <p className="field__hint">SVG or PNG, hosted publicly. Loaded from URL at render time.</p> - </Field> - - <Field label="Logo position"> - <div className="segmented segmented--small"> - {LOGO_POSITIONS.map((pos) => ( - <button - key={pos} - type="button" - className={`segmented__option ${ - (brand.logoPosition ?? 'cover-only') === pos ? 'segmented__option--active' : '' - }`} - onClick={() => update({ logoPosition: pos })} - > - {pos.replace('-', ' ')} - </button> - ))} - </div> - </Field> - - <Field label="Brand color override"> - <ColorPicker - label="" - value={brand.brandColor ?? '#c9a878'} - onChange={(hex) => update({ brandColor: hex })} - contrastAgainst={surfaceForContrast} - /> - {brand.brandColor && isValidHex(brand.brandColor) ? ( - <button - type="button" - className="link-button" - onClick={() => - update({ accentColor: brand.brandColor ? deriveAccent(brand.brandColor) : undefined }) - } - > - Auto-derive accent - </button> - ) : null} - </Field> - - <Field label="Accent color override"> - <ColorPicker - label="" - value={brand.accentColor ?? '#c9a878'} - onChange={(hex) => update({ accentColor: hex })} - contrastAgainst={surfaceForContrast} - /> - </Field> - - <button - type="button" - className="link-button link-button--danger" - onClick={() => onBrandChange({})} - > - Reset brand - </button> - </div> - ); -} - -function PaletteSwatch({ - palette, - selected, - onClick, -}: { - palette: Palette; - selected: boolean; - onClick: () => void; -}) { - return ( - <button - type="button" - onClick={onClick} - className={`palette-swatch ${selected ? 'palette-swatch--selected' : ''}`} - aria-pressed={selected} - aria-label={palette.name} - title={palette.name} - > - <span style={{ background: palette.tokens.surface }} className="palette-swatch__half" /> - <span style={{ background: palette.tokens.accent }} className="palette-swatch__half" /> - </button> - ); -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( - <div className="field"> - {label ? <span className="field__label">{label}</span> : null} - {children} - </div> - ); -} diff --git a/src/editor/cm-directive-highlight.ts b/src/editor/cm-directive-highlight.ts deleted file mode 100644 index 276edcc..0000000 --- a/src/editor/cm-directive-highlight.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - Decoration, - type DecorationSet, - type EditorView, - type PluginValue, - ViewPlugin, - type ViewUpdate, -} from '@codemirror/view'; -import { RangeSetBuilder } from '@codemirror/state'; - -const directiveLineMark = Decoration.line({ class: 'cm-stackdeck-directive' }); -const directiveOpenMark = Decoration.mark({ class: 'cm-stackdeck-directive-name' }); -const directiveOptionsMark = Decoration.mark({ class: 'cm-stackdeck-directive-options' }); -const directiveCloseMark = Decoration.line({ class: 'cm-stackdeck-directive-close' }); -const columnSepMark = Decoration.line({ class: 'cm-stackdeck-colsep' }); -const slideSepMark = Decoration.line({ class: 'cm-stackdeck-slide-sep' }); - -const DIRECTIVE_OPEN_RE = /^(::)([a-zA-Z][a-zA-Z0-9.]*)(\{[^}]*\})?\s*$/; -const DIRECTIVE_CLOSE_RE = /^::\s*$/; -const COLUMN_SEP_RE = /^:::\s*$/; - -function buildDecorations(view: EditorView): DecorationSet { - const builder = new RangeSetBuilder<Decoration>(); - for (const { from, to } of view.visibleRanges) { - let pos = from; - while (pos <= to) { - const line = view.state.doc.lineAt(pos); - const text = line.text; - - if (DIRECTIVE_CLOSE_RE.test(text)) { - builder.add(line.from, line.from, directiveCloseMark); - } else if (COLUMN_SEP_RE.test(text)) { - builder.add(line.from, line.from, columnSepMark); - } else { - const m = DIRECTIVE_OPEN_RE.exec(text); - if (m) { - if (m[2] === 'slide') { - builder.add(line.from, line.from, slideSepMark); - } else { - builder.add(line.from, line.from, directiveLineMark); - } - const colonsStart = line.from; - const nameStart = colonsStart + m[1].length; - const nameEnd = nameStart + m[2].length; - builder.add(colonsStart, nameEnd, directiveOpenMark); - if (m[3]) { - const optsStart = nameEnd; - const optsEnd = optsStart + m[3].length; - builder.add(optsStart, optsEnd, directiveOptionsMark); - } - } - } - - pos = line.to + 1; - if (line.to === view.state.doc.length) break; - } - } - return builder.finish(); -} - -export const directiveHighlight = ViewPlugin.fromClass( - class implements PluginValue { - decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = buildDecorations(view); - } - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { - this.decorations = buildDecorations(update.view); - } - } - }, - { decorations: (v) => v.decorations }, -); diff --git a/src/editor/cm-slash-command.ts b/src/editor/cm-slash-command.ts deleted file mode 100644 index 750fcc8..0000000 --- a/src/editor/cm-slash-command.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; - -import { INSERT_ITEMS } from './insert-items'; - -/** - * Slash command source for CodeMirror autocomplete. Activates when the user - * types `/` followed by optional word characters at the start of a line. - * Replaces the slash + filter text with the chosen snippet. - */ -export function slashCommandSource(ctx: CompletionContext): CompletionResult | null { - const word = ctx.matchBefore(/\/[\w-]*/); - if (!word) return null; - - if (!ctx.explicit) { - const lineStart = ctx.state.doc.lineAt(word.from).from; - const before = ctx.state.sliceDoc(lineStart, word.from); - if (before.trim().length > 0) return null; - if (word.from === word.to) return null; - } - - return { - from: word.from, - to: word.to, - filter: true, - options: INSERT_ITEMS.map((item) => ({ - label: `/${item.label.toLowerCase().replace(/\s+/g, '-')}`, - displayLabel: item.label, - detail: item.description, - type: 'keyword', - apply: item.snippet.replace(/^\n+/, ''), - boost: -INSERT_ITEMS.indexOf(item), - })), - }; -} diff --git a/src/editor/cm-theme.ts b/src/editor/cm-theme.ts deleted file mode 100644 index f89d4f8..0000000 --- a/src/editor/cm-theme.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; -import { EditorView } from '@codemirror/view'; -import { tags as t } from '@lezer/highlight'; - -/** - * CodeMirror theme. All colors come from CSS variables declared on - * `.editor__source-pane` in editor.css, so the JS theme and the directive - * line classes share one palette. Change colors there. - */ -export const stackdeckTheme = EditorView.theme( - { - '&': { - color: 'var(--syn-fg)', - backgroundColor: 'transparent', - fontFamily: "var(--mono), var(--font-jetbrains), ui-monospace, 'JetBrains Mono', monospace", - fontSize: '14px', - lineHeight: '1.75', - height: '100%', - }, - '.cm-scroller': { - fontFamily: 'inherit', - padding: '24px 28px 96px', - overflow: 'auto', - }, - '.cm-content': { - caretColor: 'var(--syn-caret)', - paddingBottom: '60vh', - }, - '&.cm-focused .cm-cursor': { - borderLeftColor: 'var(--syn-caret)', - borderLeftWidth: '2px', - }, - '&.cm-focused .cm-selectionBackground, ::selection': { - backgroundColor: 'var(--syn-selection)', - }, - '.cm-selectionBackground': { - backgroundColor: 'var(--syn-selection)', - }, - '.cm-activeLine': { - backgroundColor: 'var(--syn-active-line)', - }, - '.cm-activeLineGutter': { - backgroundColor: 'transparent', - color: 'var(--syn-muted)', - }, - '.cm-gutters': { - backgroundColor: 'transparent', - color: 'var(--syn-faint)', - borderRight: '1px solid rgba(255, 255, 255, 0.04)', - paddingRight: '14px', - fontSize: '12px', - letterSpacing: '0.02em', - }, - '.cm-lineNumbers .cm-gutterElement': { - padding: '0 8px', - minWidth: '32px', - textAlign: 'right', - fontVariantNumeric: 'tabular-nums', - }, - '.cm-foldPlaceholder': { - backgroundColor: 'rgba(245, 185, 122, 0.1)', - border: '1px solid rgba(245, 185, 122, 0.3)', - borderRadius: '4px', - color: 'var(--syn-amber-soft)', - padding: '0 6px', - margin: '0 2px', - }, - /* Tooltip + autocomplete styling lives in editor.css (.cm-tooltip-autocomplete) - so all surface tokens stay in CSS. The JS theme just sets the chrome. */ - }, - { dark: true }, -); - -/** - * Markdown syntax colors. Editorial palette: warm amber for headings and - * structural markers, sky blue for directives/links, sage for quotes, - * cream for code and emphasis. Restraint over rainbow. - */ -const stackdeckHighlight = HighlightStyle.define([ - // Headings: warm amber hashes, bright fg text, weight increases by level - { tag: t.heading1, color: 'var(--syn-fg-bright)', fontWeight: '600' }, - { tag: t.heading2, color: 'var(--syn-fg-bright)', fontWeight: '600' }, - { tag: t.heading3, color: 'var(--syn-fg-bright)', fontWeight: '500' }, - { tag: t.heading4, color: 'var(--syn-fg-bright)', fontWeight: '500' }, - { tag: t.heading, color: 'var(--syn-fg-bright)', fontWeight: '500' }, - - // Inline emphasis - { tag: t.strong, color: 'var(--syn-fg-bright)', fontWeight: '700' }, - { tag: t.emphasis, color: 'var(--syn-fg)', fontStyle: 'italic' }, - { tag: t.strikethrough, color: 'var(--syn-faint)', textDecoration: 'line-through' }, - - // Links - { tag: t.link, color: 'var(--syn-teal)', textDecoration: 'underline' }, - { tag: t.url, color: 'var(--syn-teal-soft)' }, - - // Block content - { tag: t.quote, color: 'var(--syn-sage)', fontStyle: 'italic' }, - { tag: t.list, color: 'var(--syn-fg)' }, - - // Code - { tag: t.monospace, color: 'var(--syn-cream)' }, - { tag: t.string, color: 'var(--syn-cream)' }, - - // Markdown syntax punctuation (#, *, -, >, etc.) - { tag: t.processingInstruction, color: 'var(--syn-amber)' }, - { tag: t.contentSeparator, color: 'var(--syn-amber-soft)', fontWeight: '500' }, - { tag: t.escape, color: 'var(--syn-fg)' }, - - // Frontmatter / metadata - { tag: t.meta, color: 'var(--syn-faint)' }, - { tag: t.comment, color: 'var(--syn-faint)', fontStyle: 'italic' }, - { tag: t.keyword, color: 'var(--syn-violet)', fontWeight: '500' }, -]); - -export const stackdeckSyntaxHighlighting = syntaxHighlighting(stackdeckHighlight); diff --git a/src/editor/editor.css b/src/editor/editor.css deleted file mode 100644 index 1f020f2..0000000 --- a/src/editor/editor.css +++ /dev/null @@ -1,1699 +0,0 @@ -/* ========================================================================== - editor - Three-pane editor shell. Layered dark with real elevation across - thumbs / source / preview / drawer. Premium type scale, hairline rules, - mono used sparingly. Tokens live in styles/app-shell.css. - ========================================================================== */ - -.editor { - display: flex; - flex-direction: column; - height: 100vh; - overflow: hidden; - background: var(--bg); - color: var(--text); - font-family: var(--ui); - font-size: var(--fs-base); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.editor--loading { - display: grid; - place-items: center; - gap: 14px; - font-family: var(--mono); - font-size: var(--fs-xs); - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--text-3); - background: var(--bg); -} - -.editor__loading-mark { - width: 22px; - height: 22px; - border-radius: 50%; - border: 1px solid var(--line-strong); - border-top-color: var(--text); - animation: editor-loading 900ms linear infinite; -} - -@keyframes editor-loading { - to { - transform: rotate(360deg); - } -} - -/* Topbar - --------------------------------------------------------------------------*/ - -.editor__topbar { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 12px; - height: 56px; - padding: 0 var(--page-pad); - background: var(--bg-glass); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - border-bottom: 1px solid var(--line); - position: relative; - z-index: 20; - flex-shrink: 0; -} - -.editor__topbar-left { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; -} - -.editor__topbar-center { - display: none; -} - -.editor__topbar-right { - display: flex; - align-items: center; - gap: 4px; - justify-content: flex-end; - min-width: 0; -} - -.editor__back { - width: var(--h-control); - height: var(--h-control); - display: grid; - place-items: center; - border-radius: var(--r-sm); - color: var(--text-2); - text-decoration: none; - transition: - background var(--t-fast) var(--ease), - color var(--t-fast) var(--ease); - flex-shrink: 0; -} - -.editor__back:hover { - background: var(--surface); - color: var(--text); -} - -.editor__topbar-divider { - width: 1px; - height: 20px; - background: var(--line-strong); - flex-shrink: 0; -} - -.editor__deck-title-group { - display: flex; - align-items: center; - gap: 14px; - min-width: 0; -} - -.editor__deck-title { - font-family: var(--ui); - font-size: var(--fs-md); - font-weight: 500; - letter-spacing: -0.012em; - color: var(--text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 360px; - background: transparent; - border: 1px solid transparent; - padding: 6px 11px; - border-radius: var(--r-sm); - cursor: pointer; - text-align: left; - transition: - background var(--t-fast) var(--ease), - border-color var(--t-fast) var(--ease); -} - -.editor__deck-title--button:hover:not(:disabled) { - background: var(--surface); - border-color: var(--line); -} - -.editor__deck-title--editing { - background: var(--surface-strong); - border-color: var(--line-strong); - outline: none; - cursor: text; -} - -/* Crumbs (kept available, not shown in topbar by default) */ - -.editor__crumb { - display: inline-flex; - align-items: center; - gap: 6px; - font-family: var(--mono); - font-size: var(--fs-xs); - color: var(--text-3); - letter-spacing: 0.04em; -} - -.editor__crumb-label { - color: var(--text-3); - text-transform: uppercase; - font-size: var(--fs-micro); - letter-spacing: 0.16em; -} - -.editor__crumb-value { - color: var(--text-2); - font-variant-numeric: tabular-nums; -} - -.editor__crumb-sep { - color: var(--text-3); -} - -.editor__crumb--mode { - color: var(--text-2); - text-transform: capitalize; -} - -/* Save indicator: status pill chip */ - -.save-indicator { - display: inline-flex; - align-items: center; - gap: 7px; - height: 26px; - padding: 0 11px 0 9px; - font-family: var(--mono); - font-size: var(--fs-micro); - font-weight: 500; - letter-spacing: 0.1em; - text-transform: uppercase; - flex-shrink: 0; - color: var(--text-2); - background: var(--surface); - border: 1px solid var(--line); - border-radius: 999px; - transition: - color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease), - border-color var(--t-fast) var(--ease); -} - -.save-indicator__dot { - width: 6px; - height: 6px; - border-radius: 999px; - flex-shrink: 0; - background: var(--text-3); -} - -.save-indicator--saving { - color: var(--warn); - border-color: rgba(245, 196, 106, 0.28); - background: rgba(245, 196, 106, 0.08); -} - -.save-indicator--saving .save-indicator__dot { - background: var(--warn); - animation: pulse-dot 1.2s ease-in-out infinite; -} - -.save-indicator--saved { - color: var(--ok); - border-color: rgba(127, 219, 160, 0.28); - background: rgba(127, 219, 160, 0.08); -} - -.save-indicator--saved .save-indicator__dot { - background: var(--ok); -} - -.save-indicator--error { - color: var(--err); - border-color: rgba(240, 128, 128, 0.32); - background: rgba(240, 128, 128, 0.08); -} - -.save-indicator--error .save-indicator__dot { - background: var(--err); -} - -@keyframes pulse-dot { - 0%, - 100% { - opacity: 0.45; - } - 50% { - opacity: 1; - } -} - -/* Topbar buttons */ - -.editor__nav-link { - padding: 0 12px; - border-radius: var(--r-sm); - font-family: var(--ui); - font-size: var(--fs-sm); - font-weight: 500; - letter-spacing: -0.005em; - color: var(--text-2); - text-decoration: none; - background: transparent; - border: 1px solid transparent; - cursor: pointer; - transition: - background var(--t-fast) var(--ease), - color var(--t-fast) var(--ease), - border-color var(--t-fast) var(--ease), - transform var(--t-fast) var(--ease); - white-space: nowrap; - height: var(--h-control); - display: inline-flex; - align-items: center; -} - -.editor__nav-link:hover { - background: var(--surface); - color: var(--text); -} - -.editor__nav-link:active { - transform: translateY(0.5px); -} - -.editor__nav-link--active { - background: var(--surface-strong); - border-color: var(--line); - color: var(--text); - box-shadow: var(--highlight); -} - -.editor__cta { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 0 14px 0 12px; - height: var(--h-control); - background: var(--text); - color: var(--bg); - border: none; - border-radius: var(--r-sm); - font-size: var(--fs-sm); - font-weight: 600; - font-family: var(--ui); - letter-spacing: -0.005em; - cursor: pointer; - transition: - opacity var(--t-fast) var(--ease), - transform var(--t-fast) var(--ease); - margin-left: 6px; - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.2), - var(--shadow-1); -} - -.editor__cta svg { - flex-shrink: 0; - opacity: 0.85; -} - -.editor__cta:hover { - opacity: 0.92; -} - -.editor__cta:active { - transform: translateY(0.5px); -} - -/* Shell layout - --------------------------------------------------------------------------*/ - -/* Three (or four) columns: source | resizer | preview | drawer? - Source width is driven by --source-width set inline by Editor.tsx and - clamped here so a stale localStorage value can never explode the layout. */ -.editor__shell { - display: grid; - grid-template-columns: - clamp(320px, var(--source-width, 480px), min(900px, 70vw)) - 6px - 1fr; - flex: 1; - min-height: 0; - overflow: hidden; - background: var(--bg-canvas); -} - -.editor__shell:has(.drawer) { - grid-template-columns: - clamp(320px, var(--source-width, 480px), min(900px, 70vw)) - 6px - 1fr - 380px; -} - -/* Resizer handle: 6px-wide column with a centered hairline. - Hover/drag thicken the line and tint the hit zone. */ -.editor__resizer { - position: relative; - cursor: col-resize; - background: transparent; - user-select: none; - z-index: 5; - transition: background var(--t-fast) var(--ease); -} - -.editor__resizer::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 50%; - width: 1px; - margin-left: -0.5px; - background: var(--line); - transition: - background var(--t-fast) var(--ease), - width var(--t-fast) var(--ease), - margin-left var(--t-fast) var(--ease); -} - -.editor__resizer:hover::before, -.editor__resizer:focus-visible::before, -.editor__resizer--dragging::before { - background: var(--text-2); - width: 2px; - margin-left: -1px; -} - -.editor__resizer--dragging { - background: var(--surface); -} - -.editor__resizer:focus-visible { - outline: none; -} - -/* Source pane - Syntax palette lives here. Both the JS theme (cm-theme.ts) and the - directive line classes below read these tokens — one source of truth. - Editorial palette: amber for structural markers, sky for directives, - lavender for column splits, sage for quotes, cream for code. - --------------------------------------------------------------------------*/ - -.editor__source-pane { - display: flex; - flex-direction: column; - background: var(--bg-pane); - overflow: hidden; - min-height: 0; - position: relative; - - /* Foreground */ - --syn-fg: #e8e2d4; - --syn-fg-bright: #faf4e6; - --syn-muted: #a8a59c; - --syn-faint: #5e5b54; - - /* Amber: headings, slide separator, structural punctuation */ - --syn-amber: #f0b87a; - --syn-amber-soft: #d8a06a; - --syn-amber-bg: rgba(240, 184, 122, 0.07); - --syn-amber-edge: rgba(240, 184, 122, 0.55); - - /* Sky: directive names (::cover, ::stats, ::quote) */ - --syn-sky: #87b8d8; - --syn-sky-soft: #6b97b3; - --syn-sky-bg: rgba(135, 184, 216, 0.05); - --syn-sky-edge: rgba(135, 184, 216, 0.45); - - /* Violet: column separator (:::) and keywords */ - --syn-violet: #b9a4d8; - --syn-violet-bg: rgba(185, 164, 216, 0.045); - --syn-violet-edge: rgba(185, 164, 216, 0.4); - - /* Sage: quotes, blockquotes */ - --syn-sage: #9bc497; - - /* Cream: code spans, strings, option values */ - --syn-cream: #d8c39a; - - /* Teal: links and URLs */ - --syn-teal: #8fc1c6; - --syn-teal-soft: #6e948e; - - /* Quiet bracket grey: directive close (::) */ - --syn-bracket: #6a675f; - --syn-bracket-bg: rgba(255, 255, 255, 0.018); - --syn-bracket-edge: rgba(255, 255, 255, 0.18); - - /* Editor chrome */ - --syn-selection: rgba(245, 185, 122, 0.18); - --syn-active-line: rgba(245, 185, 122, 0.035); - --syn-caret: #f5b97a; -} - -.cm-host { - flex: 1; - min-height: 0; - overflow: hidden; -} - -.cm-editor { - height: 100%; - font-size: var(--fs-base); -} - -.cm-editor.cm-focused { - outline: none; -} - -/* Directive highlighting - Each structural marker gets its own color family + tinted bg + edge bar. - Slide separator is the loudest (2px solid amber). Directive open is - sky blue at 1px. Column separator is dashed lavender. Close is a quiet - bracket grey so it doesn't compete with the open it pairs with. - --------------------------------------------------------------------------*/ - -.cm-stackdeck-directive { - background: var(--syn-sky-bg); - border-left: 2px solid var(--syn-sky-edge); - padding-left: 10px !important; -} - -.cm-stackdeck-directive-close { - background: var(--syn-bracket-bg); - border-left: 2px solid var(--syn-bracket-edge); - padding-left: 10px !important; -} - -.cm-stackdeck-slide-sep { - background: var(--syn-amber-bg); - border-left: 2px solid var(--syn-amber); - box-shadow: - inset 0 1px 0 rgba(245, 185, 122, 0.12), - inset 0 -1px 0 rgba(245, 185, 122, 0.08); - padding-left: 10px !important; - margin-top: 2px; - margin-bottom: 2px; -} - -.cm-stackdeck-colsep { - background: var(--syn-violet-bg); - border-left: 1px dashed var(--syn-violet-edge); - padding-left: 10px !important; -} - -/* Inline marks: directive name colors with the family of its line */ -.cm-stackdeck-directive-name { - color: var(--syn-sky) !important; - font-weight: 500; - letter-spacing: 0.005em; -} - -.cm-stackdeck-slide-sep .cm-stackdeck-directive-name { - color: var(--syn-amber) !important; - font-weight: 600; - text-shadow: 0 0 12px rgba(245, 185, 122, 0.25); -} - -.cm-stackdeck-colsep .cm-stackdeck-directive-name { - color: var(--syn-violet) !important; -} - -.cm-stackdeck-directive-close .cm-stackdeck-directive-name { - color: var(--syn-bracket) !important; - font-weight: 500; -} - -.cm-stackdeck-directive-options { - color: var(--syn-cream) !important; - font-style: normal; -} - -.cm-fold-marker { - color: var(--syn-amber-soft); - cursor: pointer; - font-size: var(--fs-xs); - padding-right: 4px; - transition: color var(--t-fast) var(--ease); -} - -.cm-fold-marker:hover { - color: var(--syn-amber); -} - -/* Insert: floating pill in source pane - --------------------------------------------------------------------------*/ - -.insert-menu { - position: absolute; - right: 16px; - bottom: 16px; - z-index: 30; -} - -.insert-menu__pill { - display: inline-flex; - align-items: center; - gap: 8px; - height: 38px; - padding: 0 12px 0 12px; - border-radius: 999px; - border: 1px solid var(--line-strong); - background: rgba(28, 26, 21, 0.92); - backdrop-filter: blur(18px) saturate(160%); - -webkit-backdrop-filter: blur(18px) saturate(160%); - color: var(--text); - font-family: var(--ui); - font-size: var(--fs-sm); - font-weight: 500; - letter-spacing: -0.005em; - cursor: pointer; - transition: - border-color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease), - transform var(--t-fast) var(--ease); - box-shadow: var(--shadow-3), var(--highlight); -} - -.insert-menu__pill:hover { - border-color: var(--text-2); - background: rgba(36, 33, 26, 0.96); -} - -.insert-menu__pill--open { - border-color: var(--text); - background: rgba(36, 33, 26, 0.96); -} - -.insert-menu__pill svg { - color: var(--text); -} - -.insert-menu__kbd { - display: inline-grid; - place-items: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - margin-left: 2px; - border-radius: 4px; - border: 1px solid var(--line-strong); - background: rgba(255, 255, 255, 0.04); - font-family: var(--mono); - font-size: var(--fs-micro); - font-weight: 500; - color: var(--text-2); - line-height: 1; -} - -.insert-menu__panel { - position: absolute; - right: 0; - bottom: calc(100% + 8px); - min-width: 340px; - max-height: 440px; - display: flex; - flex-direction: column; - background: rgba(22, 20, 15, 0.98); - border: 1px solid var(--line-strong); - border-radius: var(--r-md); - box-shadow: var(--shadow-3), var(--highlight); - z-index: 100; - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - animation: panel-in 180ms var(--ease); - overflow: hidden; -} - -@keyframes panel-in { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.insert-menu__panel-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 14px; - border-bottom: 1px solid var(--line); - font-family: var(--mono); - font-size: var(--fs-micro); - font-weight: 500; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--text-3); - flex-shrink: 0; -} - -.insert-menu__panel-hint { - color: var(--text-3); - letter-spacing: 0.08em; -} - -.insert-menu__panel-list { - flex: 1; - overflow-y: auto; - padding: 4px; -} - -.insert-menu__item { - display: flex; - flex-direction: column; - gap: 3px; - padding: 10px 12px; - width: 100%; - background: transparent; - border: none; - border-radius: var(--r-sm); - cursor: pointer; - text-align: left; - font-family: inherit; - transition: background var(--t-fast) var(--ease); - color: var(--text); -} - -.insert-menu__item:hover { - background: var(--surface-strong); -} - -.insert-menu__item-label { - font-family: var(--ui); - font-size: var(--fs-base); - font-weight: 500; - letter-spacing: -0.005em; - color: var(--text); -} - -.insert-menu__item-desc { - font-size: var(--fs-sm); - color: var(--text-2); -} - -/* Preview pane: single-slide stage - --------------------------------------------------------------------------*/ - -.editor__preview-pane { - background: var(--bg-canvas); - position: relative; - overflow: hidden; - display: flex; - flex-direction: column; - min-height: 0; -} - -.editor__preview-pane::before { - content: ''; - position: absolute; - inset: 0; - background: radial-gradient( - ellipse 70% 50% at 50% 0%, - rgba(255, 232, 196, 0.025), - transparent 60% - ); - pointer-events: none; -} - -.stage { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; - position: relative; - z-index: 1; -} - -/* Flex (not grid) — grid auto-tracks size to max-content of children, so a - fluid `width: 100%` + `aspect-ratio` slide resolves to its 1280px max-width - and the viewport ends up horizontally scrolling instead of shrinking the - slide to fit. Flex constrains children to the parent's actual width. */ -.stage__viewport { - flex: 1; - min-height: 0; - min-width: 0; - display: flex; - align-items: center; - justify-content: center; - padding: 36px 36px 28px; - overflow: hidden; -} - -/* Slide is rendered at its native 1280x720 design size and proportionally - scaled with transform to fit the available width via container query - units. This guarantees pixel parity with the PDF export — what you see - is exactly what you get — and prevents content overflow when the pane - is narrower than 1280px. The frame's shadow and border live on the - outer wrapper so they don't get scaled with the transform. */ - -.stage__slide { - width: 100%; - max-width: 1280px; - max-height: 100%; - aspect-ratio: 16 / 9; - position: relative; - container-type: inline-size; - border: 1px solid var(--line); - box-shadow: var(--shadow-3); - overflow: hidden; - display: block; - flex-shrink: 1; - min-width: 0; -} - -.stage__slide .deck { - position: absolute; - inset: 0; - padding: 0; - gap: 0; - width: 1280px; - height: 720px; - /* length ÷ length yields a unitless number — required by scale(). - `100cqi / 1280` (number) is silently invalid and disables the - transform, leaving the deck at native 1280×720 and cropped. */ - transform: scale(calc(100cqi / 1280px)); - transform-origin: top left; - align-items: stretch; -} - -.stage__slide .slide-frame { - border-radius: 0; - box-shadow: none; - border: none; - width: 1280px; - max-width: 1280px; - height: 720px; -} - -/* Floating thumb strip: docked at the bottom of the preview pane. - Glassy bar containing the slide counter on the left and a horizontally - scrolling rail of mini slide previews. The active thumb auto-scrolls - into view on selection. - --------------------------------------------------------------------------*/ - -.thumb-strip { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 18px; - padding: 10px 18px; - border-top: 1px solid var(--line); - background: var(--bg-glass); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - position: relative; - z-index: 2; -} - -.thumb-strip__counter { - flex-shrink: 0; - display: inline-flex; - align-items: baseline; - gap: 4px; - font-family: var(--mono); - font-size: var(--fs-sm); - font-weight: 500; - letter-spacing: 0.06em; - color: var(--text-2); - font-variant-numeric: tabular-nums; - min-width: 60px; -} - -.thumb-strip__counter-num { - color: var(--text); -} - -.thumb-strip__counter-sep { - color: var(--text-3); - margin: 0 2px; -} - -.thumb-strip__counter-total { - color: var(--text-3); -} - -.thumb-strip__rail { - flex: 1; - display: flex; - align-items: stretch; - gap: 12px; - overflow-x: auto; - scroll-behavior: smooth; - padding: 2px 2px; - min-width: 0; -} - -.thumb-strip__rail::-webkit-scrollbar { - height: 6px; -} - -.thumb-strip__rail::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.08); - border-radius: 999px; -} - -.thumb-strip__rail::-webkit-scrollbar-track { - background: transparent; -} - -.thumb-strip__item--dragging { - opacity: 0.4; -} - -.thumb-strip__item--over .thumb-strip__frame { - outline: 2px dashed var(--accent, #6ee7b7); - outline-offset: 2px; -} - -.thumb-strip__item { - flex-shrink: 0; - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; - background: transparent; - border: none; - padding: 0; - cursor: pointer; - font-family: inherit; - border-radius: var(--r-sm); -} - -.thumb-strip__num { - font-family: var(--mono); - font-size: var(--fs-micro); - font-weight: 500; - letter-spacing: 0.14em; - color: var(--text-3); - font-feature-settings: 'tnum'; - transition: color var(--t-fast) var(--ease); -} - -.thumb-strip__item:hover .thumb-strip__num { - color: var(--text-2); -} - -.thumb-strip__item--active .thumb-strip__num { - color: var(--text); -} - -.thumb-strip__frame { - position: relative; - width: 124px; - height: 70px; - border-radius: 4px; - border: 1px solid var(--line); - background: var(--bg-elev-1); - overflow: hidden; - transition: - border-color var(--t-fast) var(--ease), - box-shadow var(--t-fast) var(--ease), - transform var(--t-fast) var(--ease); - box-shadow: var(--highlight); -} - -.thumb-strip__item:hover .thumb-strip__frame { - border-color: var(--line-strong); -} - -.thumb-strip__item--active .thumb-strip__frame { - border-color: var(--text); - box-shadow: - 0 0 0 1px var(--text), - var(--highlight-strong); -} - -.thumb-strip__scaler { - position: absolute; - top: 0; - left: 0; - width: 1280px; - height: 720px; - transform: scale(calc(124 / 1280)); - transform-origin: top left; - pointer-events: none; -} - -.thumb-strip__scaler .deck { - padding: 0; - gap: 0; - align-items: stretch; -} - -.thumb-strip__scaler .slide-frame { - width: 1280px; - max-width: 1280px; - border-radius: 0; - border: none; - box-shadow: none; -} - -/* Parse error - --------------------------------------------------------------------------*/ - -.editor__error { - margin: 24px; - padding: 18px 22px; - background: rgba(240, 128, 128, 0.05); - border: 1px solid rgba(240, 128, 128, 0.2); - border-radius: var(--r-md); - color: var(--err); - box-shadow: var(--shadow-1); -} - -.editor__error strong { - display: block; - margin-bottom: 6px; - font-family: var(--ui); - font-size: var(--fs-base); - font-weight: 500; -} - -.editor__error pre { - margin: 0; - font-family: var(--mono); - font-size: var(--fs-sm); - white-space: pre-wrap; - word-break: break-word; - color: var(--text-2); -} - -/* Drawer - --------------------------------------------------------------------------*/ - -.drawer { - border-left: 1px solid var(--line); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, transparent 35%), var(--bg-elev-1); - display: flex; - flex-direction: column; - overflow: hidden; - min-height: 0; - box-shadow: var(--highlight); -} - -.drawer__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 20px; - border-bottom: 1px solid var(--line); - flex-shrink: 0; -} - -.drawer__title { - margin: 0; - font-family: var(--ui); - font-size: var(--fs-md); - font-weight: 500; - letter-spacing: -0.012em; - color: var(--text); -} - -.drawer__close { - width: var(--h-control); - height: var(--h-control); - border-radius: var(--r-sm); - border: none; - background: transparent; - display: grid; - place-items: center; - color: var(--text-2); - cursor: pointer; - transition: - background var(--t-fast) var(--ease), - color var(--t-fast) var(--ease); -} - -.drawer__close:hover { - background: var(--surface); - color: var(--text); -} - -.drawer__tabs { - display: flex; - border-bottom: 1px solid var(--line); - flex-shrink: 0; - padding: 0 14px; - gap: 4px; -} - -.drawer__tab { - padding: 13px 11px; - border: none; - background: transparent; - font-family: var(--ui); - font-size: var(--fs-sm); - font-weight: 500; - color: var(--text-2); - cursor: pointer; - border-bottom: 1px solid transparent; - margin-bottom: -1px; - transition: - color var(--t-fast) var(--ease), - border-color var(--t-fast) var(--ease); -} - -.drawer__tab:hover { - color: var(--text); -} - -.drawer__tab--active { - color: var(--text); - border-bottom-color: var(--text); -} - -.drawer__body { - flex: 1; - overflow-y: auto; - padding: 22px 22px 44px; -} - -.drawer__section { - display: flex; - flex-direction: column; - gap: 24px; -} - -/* Field / inputs - --------------------------------------------------------------------------*/ - -.field { - display: flex; - flex-direction: column; - gap: 9px; -} - -.field__label { - font-family: var(--mono); - font-size: var(--fs-micro); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.16em; - color: var(--text-3); -} - -.field__hint { - margin: 0; - font-size: var(--fs-sm); - color: var(--text-3); - line-height: 1.5; -} - -.text-input { - width: 100%; - padding: 10px 12px; - border: 1px solid var(--line-strong); - border-radius: var(--r-sm); - font-family: inherit; - font-size: var(--fs-sm); - background: var(--surface); - color: var(--text); - outline: none; - transition: - border-color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease); -} - -.text-input:focus { - border-color: var(--text-2); - background: var(--surface-strong); -} - -.text-input::placeholder { - color: var(--text-3); -} - -.link-button { - align-self: flex-start; - background: transparent; - border: none; - padding: 0; - font-family: var(--ui); - font-size: var(--fs-sm); - color: var(--text-2); - cursor: pointer; - letter-spacing: 0; - transition: color var(--t-fast) var(--ease); - text-decoration: underline; - text-decoration-color: var(--line-strong); - text-underline-offset: 3px; -} - -.link-button:hover { - color: var(--text); - text-decoration-color: var(--text-2); -} - -.link-button--danger { - color: var(--err); - text-decoration-color: rgba(240, 128, 128, 0.4); -} - -.link-button--danger:hover { - color: #ff9b9b; -} - -/* Style cards - --------------------------------------------------------------------------*/ - -.card-grid { - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; - gap: 6px; -} - -.style-card { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 7px; - padding: 14px 6px; - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r-sm); - cursor: pointer; - font-family: inherit; - color: var(--text); - transition: - border-color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease); -} - -.style-card:hover { - border-color: var(--line-strong); - background: var(--surface-strong); -} - -.style-card--selected { - border-color: var(--text); - background: var(--surface-strong); - box-shadow: inset 0 0 0 1px var(--text); -} - -.style-card__name { - font-size: var(--fs-xl); - font-weight: 500; - line-height: 1; - color: var(--text); -} - -.style-card__label { - font-family: var(--mono); - font-size: var(--fs-micro); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.14em; - color: var(--text-2); -} - -.style-card--selected .style-card__label { - color: var(--text); -} - -/* Font buttons - --------------------------------------------------------------------------*/ - -.font-button-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 8px; -} - -.font-button { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 4px; - padding: 12px 12px 10px; - border: 1px solid var(--line); - border-radius: var(--r-sm); - background: var(--bg); - cursor: pointer; - text-align: left; - transition: - border-color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease), - transform var(--t-fast) var(--ease); -} - -.font-button:hover { - border-color: var(--text-2); - background: var(--bg-2); -} - -.font-button--selected { - border-color: var(--text); - background: var(--bg-2); - box-shadow: 0 0 0 1px var(--text); -} - -.font-button__sample { - font-size: 28px; - line-height: 1; - color: var(--text); - letter-spacing: -0.01em; -} - -.font-button__label { - font-size: 11px; - font-weight: 500; - color: var(--text-2); - letter-spacing: 0.01em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -.font-button--selected .font-button__label { - color: var(--text); -} - -.font-preview { - margin: 12px 0 0; - padding: 10px 12px; - font-size: 18px; - line-height: 1.3; - color: var(--text); - background: var(--bg-2); - border-radius: var(--r-sm); - border: 1px solid var(--line); -} - -/* Palette swatches - --------------------------------------------------------------------------*/ - -.swatch-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 6px; -} - -.palette-swatch { - position: relative; - display: flex; - height: 40px; - border: 1px solid var(--line-strong); - border-radius: var(--r-sm); - cursor: pointer; - overflow: hidden; - padding: 0; - background: transparent; - transition: border-color var(--t-fast) var(--ease); -} - -.palette-swatch:hover { - border-color: var(--text-2); -} - -.palette-swatch--selected { - border-color: var(--text); - box-shadow: 0 0 0 1px var(--text); -} - -.palette-swatch__half { - flex: 1; - display: block; -} - -/* Segmented controls - --------------------------------------------------------------------------*/ - -.segmented { - display: grid; - grid-template-columns: repeat(4, 1fr); - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r-sm); - padding: 3px; -} - -.segmented--small { - grid-template-columns: repeat(2, 1fr); -} - -.segmented__option { - padding: 8px 6px; - background: transparent; - border: none; - border-radius: 4px; - font-family: var(--ui); - font-size: var(--fs-sm); - font-weight: 500; - color: var(--text-2); - cursor: pointer; - text-transform: capitalize; - transition: - color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease); -} - -.segmented__option:hover { - color: var(--text); -} - -.segmented__option--active { - background: var(--bg-elev-1); - color: var(--text); - box-shadow: - var(--highlight-strong), - 0 0 0 1px var(--line-strong); -} - -/* Color picker - --------------------------------------------------------------------------*/ - -.color-picker { - display: flex; - flex-direction: column; - gap: 8px; -} - -.color-picker__label { - font-family: var(--mono); - font-size: var(--fs-micro); - font-weight: 500; - color: var(--text-3); - text-transform: uppercase; - letter-spacing: 0.16em; -} - -.color-picker__row { - display: flex; - gap: 6px; -} - -.color-picker__swatch { - width: 38px; - height: 38px; - padding: 0; - border: 1px solid var(--line-strong); - border-radius: var(--r-sm); - cursor: pointer; - background: transparent; - flex-shrink: 0; -} - -.color-picker__hex { - flex: 1; - padding: 10px 12px; - border: 1px solid var(--line-strong); - border-radius: var(--r-sm); - font-family: var(--mono); - font-size: var(--fs-sm); - text-transform: lowercase; - outline: none; - background: var(--surface); - color: var(--text); - letter-spacing: 0.02em; -} - -.color-picker__hex:focus { - border-color: var(--text-2); -} - -.color-picker__contrast { - font-family: var(--mono); - font-size: var(--fs-micro); - font-weight: 500; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.color-picker__contrast--ok { - color: var(--ok); -} -.color-picker__contrast--warn { - color: var(--warn); -} -.color-picker__contrast--fail { - color: var(--err); -} - -/* CodeMirror autocomplete tooltip (slash command) - --------------------------------------------------------------------------*/ - -.cm-tooltip.cm-tooltip-autocomplete { - background: rgba(22, 20, 15, 0.98) !important; - border: 1px solid var(--line-strong) !important; - border-radius: var(--r-md) !important; - padding: 4px !important; - box-shadow: var(--shadow-3), var(--highlight); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); -} - -.cm-tooltip.cm-tooltip-autocomplete > ul { - font-family: var(--ui); - max-height: 320px; -} - -.cm-tooltip.cm-tooltip-autocomplete > ul > li { - padding: 9px 12px !important; - font-size: var(--fs-sm) !important; - color: var(--text); - border-radius: var(--r-sm); - display: flex; - align-items: baseline; - gap: 12px; -} - -.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { - background: var(--surface-strong) !important; - color: var(--text) !important; -} - -.cm-completionLabel { - color: var(--text); -} - -.cm-completionDetail { - font-style: normal !important; - color: var(--text-3) !important; - font-size: var(--fs-xs); - margin-left: auto; - font-family: var(--mono); - letter-spacing: 0.02em; -} - -/* Print - --------------------------------------------------------------------------*/ - -/* ─── Export PDF menu ────────────────────────────────────────────────── */ - -.export-pdf { - position: relative; -} - -.export-pdf__menu { - position: absolute; - top: calc(100% + 6px); - right: 0; - background: var(--surface); - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: 0 16px 32px -8px rgba(0, 0, 0, 0.35); - list-style: none; - margin: 0; - padding: 6px; - min-width: 200px; - z-index: 50; -} - -.export-pdf__item { - appearance: none; - background: transparent; - border: none; - width: 100%; - text-align: left; - padding: 8px 12px; - border-radius: 6px; - font-size: 13px; - color: var(--text); - cursor: pointer; - letter-spacing: 0.04em; -} - -.export-pdf__item:hover { - background: var(--line); -} - -/* ─── Lint warnings strip ─────────────────────────────────────────────── */ - -.editor__lint { - position: absolute; - top: 12px; - left: 50%; - transform: translateX(-50%); - display: flex; - align-items: center; - gap: 8px; - padding: 6px 14px; - background: rgba(217, 119, 6, 0.15); - color: #f59e0b; - border: 1px solid rgba(217, 119, 6, 0.4); - border-radius: 999px; - font-size: 12px; - font-weight: 500; - letter-spacing: 0.01em; - z-index: 10; - pointer-events: none; - backdrop-filter: blur(8px); -} - -.editor__lint-icon { - font-size: 13px; - line-height: 1; -} - -/* ─── Assets drawer (rides on top of the shared .drawer styles) ────── */ - -.assets-drawer__upload { - padding: 16px 20px; - display: flex; - flex-direction: column; - gap: 8px; - border-bottom: 1px solid var(--line); -} - -.assets-drawer__upload-btn { - appearance: none; - border: 1px solid var(--line-strong); - background: var(--surface); - color: var(--text); - padding: 10px 14px; - border-radius: 8px; - font-size: 13px; - font-weight: 500; - cursor: pointer; -} - -.assets-drawer__upload-btn:hover { - border-color: var(--text-muted); -} - -.assets-drawer__hint { - margin: 0; - font-size: 12px; - color: var(--text-muted); - line-height: 1.4; -} - -.assets-drawer__empty { - padding: 32px 20px; - font-size: 13px; - color: var(--text-muted); - text-align: center; -} - -.assets-drawer__grid { - list-style: none; - margin: 0; - padding: 12px; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - overflow-y: auto; -} - -.assets-drawer__tile { - display: flex; - flex-direction: column; - gap: 6px; -} - -.assets-drawer__tile-btn { - appearance: none; - border: 1px solid var(--line); - background: var(--surface); - border-radius: 6px; - padding: 0; - aspect-ratio: 1 / 1; - overflow: hidden; - cursor: pointer; - transition: border-color 0.15s ease; -} - -.assets-drawer__tile-btn:hover { - border-color: var(--text-muted); -} - -.assets-drawer__tile-btn img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.assets-drawer__tile-ph { - display: block; - width: 100%; - height: 100%; - background: var(--line); -} - -.assets-drawer__tile-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - font-size: 11px; - color: var(--text-muted); -} - -.assets-drawer__tile-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; -} - -.assets-drawer__remove { - background: transparent; - border: none; - color: var(--text-muted); - font-size: 11px; - cursor: pointer; - padding: 0; - text-decoration: underline; -} - -.assets-drawer__remove:hover { - color: var(--text); -} - -.print-only { - display: none; -} - -@media print { - .no-print { - display: none !important; - } - - .print-only { - display: block; - } - - .editor { - background: var(--bg); - height: auto; - overflow: visible; - } -} diff --git a/src/editor/insert-items.ts b/src/editor/insert-items.ts deleted file mode 100644 index 504110b..0000000 --- a/src/editor/insert-items.ts +++ /dev/null @@ -1,105 +0,0 @@ -type InsertItem = { - label: string; - description: string; - snippet: string; -}; - -export const INSERT_ITEMS: InsertItem[] = [ - { - label: 'New slide', - description: 'Add a slide break', - snippet: '\n\n::slide\n\n', - }, - { - label: 'Cover', - description: 'Title slide for your deck', - snippet: '\n\n::cover\n# Your title.\nA short subtitle.\n::\n', - }, - { - label: 'Section break', - description: 'Transition between deck parts', - snippet: '\n\n::section\n# Section name.\n::\n', - }, - { - label: 'Stats row', - description: '2 to 6 numbers across', - snippet: - '\n\n::stats\n::stat{value="$3M" label="ARR" delta="+47%" trend="up"}\n::stat{value="71" label="NPS" delta="+9" trend="up"}\n::stat{value="12" label="Markets" delta="+5" trend="up"}\n::\n', - }, - { - label: 'KPI grid', - description: '4 to 8 numbers in a grid', - snippet: - '\n\n::kpis\n::stat{value="$3M" label="ARR"}\n::stat{value="47%" label="MoM"}\n::stat{value="71" label="NPS"}\n::stat{value="12" label="Markets"}\n::\n', - }, - { - label: 'Compare', - description: 'Two-sided before/after', - snippet: '\n\n::compare\n**Before**\n\nThe old way.\n\n:::\n\n**After**\n\nThe new way.\n::\n', - }, - { - label: '2 columns', - description: 'Two-column block', - snippet: '\n\n::columns{count=2}\nLeft column.\n\n:::\n\nRight column.\n::\n', - }, - { - label: '3 columns', - description: 'Three-column block', - snippet: '\n\n::columns{count=3}\nFirst.\n\n:::\n\nSecond.\n\n:::\n\nThird.\n::\n', - }, - { - label: '2x2 grid', - description: 'Four cells', - snippet: - '\n\n::grid{cols=2 rows=2}\nTop-left.\n\nTop-right.\n\nBottom-left.\n\nBottom-right.\n::\n', - }, - { - label: 'Callout', - description: 'Highlighted note', - snippet: '\n\n::callout{tone=info}\nThis is the key takeaway.\n::\n', - }, - { - label: 'Big quote', - description: 'Full-bleed takeover quote', - snippet: '\n\n::quote.big\n> The future is already here.\n> -- William Gibson\n::\n', - }, - { - label: 'Bullet list', - description: 'Markdown unordered list', - snippet: '\n\n- First point\n- Second point\n- Third point\n', - }, - { - label: 'Numbered steps', - description: 'Ordered procedure', - snippet: '\n\n::steps\n1. First step.\n2. Second step.\n3. Third step.\n::\n', - }, - { - label: 'Code block', - description: 'Fenced code with syntax', - snippet: '\n\n```ts\nconst x = 1;\n```\n', - }, - { - label: 'Bar chart', - description: 'Horizontal bars with labels and values', - snippet: - '\n\n::chart{kind=bar title="Revenue by quarter"}\nQ1: 120\nQ2: 165\nQ3: 210\nQ4: 280\n::\n', - }, - { - label: 'Line chart', - description: 'Trend over time', - snippet: - '\n\n::chart{kind=line title="Active users"}\nJan: 1200\nFeb: 1450\nMar: 1820\nApr: 2240\nMay: 2810\nJun: 3380\n::\n', - }, - { - label: 'Donut chart', - description: 'Share / breakdown', - snippet: - '\n\n::chart{kind=donut title="Revenue mix"}\nProduct: 45\nServices: 30\nLicensing: 15\nOther: 10\n::\n', - }, - { - label: 'Table', - description: 'Headered grid of rows and columns', - snippet: - '\n\n::table{emphasize=2}\n| Plan | Seats | Price |\n| Starter | 1 | $0 |\n| Pro | 5 | $20 |\n| Team | 25 | $80 |\n| Enterprise | unlimited | Contact us |\n::\n', - }, -]; diff --git a/src/editor/sample-deck.ts b/src/editor/sample-deck.ts deleted file mode 100644 index 3235c6e..0000000 --- a/src/editor/sample-deck.ts +++ /dev/null @@ -1,111 +0,0 @@ -export const SAMPLE_MARKDOWN = `--- -title: Q4 Review ---- - -::cover -# A year of compounding. -The numbers, the lessons, and what comes next. -:: - -::slide - -::section -# Where we landed. -:: - -::slide - -# Highlights - -- Revenue up **47%** year over year -- Twelve new markets opened -- NPS climbed to 71, the highest in company history -- Shipped 142 features and squashed 893 bugs - -::slide - -::stats -::stat{value="$3M" label="ARR" delta="+47%" trend="up"} -::stat{value="71" label="NPS" delta="+9" trend="up"} -::stat{value="12" label="Markets" delta="+5" trend="up"} -:: - -::slide - -::chart{kind=bar title="Revenue by quarter"} -Q1: 480 -Q2: 620 -Q3: 780 -Q4: 1120 -:: - -::slide - -::chart{kind=line title="Active customers"} -Jan: 1240 -Feb: 1480 -Mar: 1820 -Apr: 2210 -May: 2680 -Jun: 3140 -:: - -::slide - -::chart{kind=donut title="Revenue mix"} -Product: 58 -Services: 22 -Licensing: 14 -Other: 6 -:: - -::slide - -::table{emphasize=2} -| Plan | Seats | Price | Best for | -| Starter | 1 | $0 | Solo founders | -| Pro | 5 | $20 | Small teams | -| Team | 25 | $80 | Growing companies | -| Enterprise | unlimited | Contact us | Established orgs | -:: - -::slide - -::section -# What we learned. -:: - -::slide - -::compare -**Before** - -The pipeline took twelve minutes and failed eight percent of runs. - -::: - -**After** - -The new pipeline runs in two minutes with a 0.4% failure rate. -:: - -::slide - -::callout{tone=info} -The biggest win this year was getting deploy frequency from weekly to hourly. -:: - -::slide - -::quote.big -> The future is already here, it is just not evenly distributed. -> -- William Gibson -:: - -::slide - -::cover -# Onward. -2027 is the year we ship the next platform. -:: -`; diff --git a/src/ir/parse.ts b/src/ir/parse.ts deleted file mode 100644 index 63f8da9..0000000 --- a/src/ir/parse.ts +++ /dev/null @@ -1,924 +0,0 @@ -import matter from 'gray-matter'; -import { ulid } from 'ulid'; -import { marked } from 'marked'; - -import { - type Block, - type Box, - type Brand, - type Cell, - type Chart, - type ChartDatum, - type ChartKind, - type Code, - type Deck, - type Grid, - type Heading, - type Image, - type ImageAnnotation, - type LayoutId, - type List, - type ListItem, - type Quote, - type Slide, - type Stat, - type Table, - type Text, - type ThemeRef, - type Tone, - IR_VERSION, - LAYOUT_IDS, - validateDeck, -} from './schema'; - -const DEFAULT_THEME: ThemeRef = { - presetId: '', -}; - -const VOID_DIRECTIVES = new Set(['stat', 'slide', 'image']); - -const TONE_VALUES = new Set<Tone>(['info', 'warn', 'success', 'neutral']); - -type LineToken = - | { kind: 'text'; content: string } - | { kind: 'open'; name: string; options: Record<string, string> } - | { kind: 'close' } - | { kind: 'colsep' } - | { kind: 'void'; name: string; options: Record<string, string> }; - -const DIRECTIVE_OPEN_RE = /^::([a-zA-Z][a-zA-Z0-9.-]*)(\{[^}]*\})?\s*$/; -const DIRECTIVE_CLOSE_RE = /^::\s*$/; -const COLUMN_SEP_RE = /^:::\s*$/; - -function parseOptions(raw: string | undefined): Record<string, string> { - if (!raw) return {}; - const inner = raw.slice(1, -1).trim(); - if (!inner) return {}; - const out: Record<string, string> = {}; - const re = /([a-zA-Z][a-zA-Z0-9_]*)(?:=("([^"]*)"|'([^']*)'|([^\s]+)))?/g; - let m: RegExpExecArray | null; - while ((m = re.exec(inner))) { - const key = m[1]; - const value = m[3] ?? m[4] ?? m[5] ?? ''; - out[key] = value; - } - return out; -} - -function classifyLine(line: string): LineToken { - if (DIRECTIVE_CLOSE_RE.test(line)) return { kind: 'close' }; - if (COLUMN_SEP_RE.test(line)) return { kind: 'colsep' }; - const m = DIRECTIVE_OPEN_RE.exec(line); - if (m) { - const name = m[1]; - const options = parseOptions(m[2]); - if (VOID_DIRECTIVES.has(name)) return { kind: 'void', name, options }; - return { kind: 'open', name, options }; - } - return { kind: 'text', content: line }; -} - -function splitSlideSections(body: string): string[] { - const lines = body.split('\n'); - const sections: string[][] = [[]]; - for (const line of lines) { - const m = DIRECTIVE_OPEN_RE.exec(line); - if (m && m[1] === 'slide') { - sections.push([line]); - continue; - } - sections[sections.length - 1].push(line); - } - return sections.map((s) => s.join('\n').trim()).filter((s) => s.length > 0); -} - -function tokenize(body: string): LineToken[] { - return body.split('\n').map(classifyLine); -} - -type Cursor = { i: number }; - -function parseChildren(tokens: LineToken[], cursor: Cursor, untilCloseOrColsep: boolean): Block[] { - const blocks: Block[] = []; - let textBuffer: string[] = []; - - const flushText = () => { - if (textBuffer.length === 0) return; - const md = textBuffer.join('\n').trim(); - textBuffer = []; - if (md.length === 0) return; - blocks.push(...markdownToBlocks(md)); - }; - - while (cursor.i < tokens.length) { - const t = tokens[cursor.i]; - - if (t.kind === 'close') { - flushText(); - if (untilCloseOrColsep) { - cursor.i++; - return blocks; - } - cursor.i++; - continue; - } - - if (t.kind === 'colsep') { - flushText(); - if (untilCloseOrColsep) return blocks; - cursor.i++; - continue; - } - - if (t.kind === 'text') { - textBuffer.push(t.content); - cursor.i++; - continue; - } - - if (t.kind === 'void') { - flushText(); - const expanded = expandVoidDirective(t.name, t.options); - if (expanded) blocks.push(expanded); - cursor.i++; - continue; - } - - if (t.kind === 'open') { - flushText(); - cursor.i++; - const directiveName = t.name; - const expanded = expandBlockDirective(directiveName, t.options, tokens, cursor); - if (expanded) blocks.push(...expanded); - continue; - } - } - - flushText(); - return blocks; -} - -function collectRawLines(tokens: LineToken[], cursor: Cursor): string[] { - const out: string[] = []; - while (cursor.i < tokens.length) { - const t = tokens[cursor.i]; - if (t.kind === 'close') { - cursor.i++; - return out; - } - if (t.kind === 'colsep') { - cursor.i++; - continue; - } - if (t.kind === 'text') { - out.push(t.content); - } - cursor.i++; - } - return out; -} - -function parseChartContent( - _name: string, - options: Record<string, string>, - lines: string[], -): Chart | null { - const kind: ChartKind = - options.kind === 'line' || options.kind === 'donut' ? options.kind : 'bar'; - const format = - options.format === 'percent' || options.format === 'currency' ? options.format : 'number'; - - const data: ChartDatum[] = []; - for (const raw of lines) { - const line = raw.trim(); - if (!line) continue; - const match = /^([^:]+?):\s*(-?\d+(?:\.\d+)?)\s*$/.exec(line); - if (!match) continue; - data.push({ label: match[1].trim(), value: Number(match[2]) }); - } - - if (data.length === 0) return null; - - return { - type: 'chart', - kind, - title: options.title, - data, - format, - prefix: options.prefix, - suffix: options.suffix, - }; -} - -function parseTableContent(lines: string[], options: Record<string, string>): Table | null { - const rows = lines - .map((line) => line.trim()) - .filter((line) => line.startsWith('|')) - .map((line) => - line - .replace(/^\|/, '') - .replace(/\|$/, '') - .split('|') - .map((cell) => cell.trim()), - ) - .filter((row) => !row.every((cell) => /^-+$/.test(cell))); - - if (rows.length < 2) return null; - - const [headers, ...body] = rows; - const emphasizeRaw = options.emphasize ?? options.emphasizeColumn; - const emphasizeColumn = - emphasizeRaw !== undefined && /^\d+$/.test(emphasizeRaw) ? Number(emphasizeRaw) : undefined; - - return { - type: 'table', - headers, - rows: body, - emphasizeColumn, - }; -} - -function collectColumnGroups(tokens: LineToken[], cursor: Cursor, count: number): Block[][] { - const cols: Block[][] = []; - while (cursor.i < tokens.length) { - const colBlocks = parseChildren(tokens, cursor, true); - cols.push(colBlocks); - const next = tokens[cursor.i]; - if (next?.kind === 'colsep') { - cursor.i++; - continue; - } - break; - } - while (cols.length < count) cols.push([]); - return cols.slice(0, count); -} - -function inferTrend(options: Record<string, string>): Stat['trend'] { - if (options.trend === 'up' || options.trend === 'down' || options.trend === 'flat') { - return options.trend as Stat['trend']; - } - if (options.delta) { - const d = options.delta.trim(); - if (d.startsWith('+')) return 'up'; - if (d.startsWith('-') || d.startsWith('−')) return 'down'; - } - return undefined; -} - -const VALID_TREATMENTS = new Set([ - 'plain', - 'frame', - 'bleed', - 'duotone', - 'bw', - 'polaroid', - 'hard-frame', - 'mask', -]); - -function buildImage(options: Record<string, string>): Image | null { - if (!options.src) return null; - const treatment = VALID_TREATMENTS.has(options.treatment) - ? (options.treatment as Image['treatment']) - : 'plain'; - const img: Image = { type: 'image', src: options.src, treatment }; - if (options.alt) img.alt = options.alt; - if (options.caption) img.caption = options.caption; - if (options.aspect ?? options.aspectRatio) { - img.aspectRatio = options.aspect ?? options.aspectRatio; - } - if (options.focal) img.focal = options.focal; - return img; -} - -function expandVoidDirective(name: string, options: Record<string, string>): Block | null { - if (name === 'stat') { - if (!options.value) return null; - return { - type: 'stat', - value: options.value, - label: options.label, - delta: options.delta, - trend: inferTrend(options), - }; - } - if (name === 'image') { - return buildImage(options); - } - return null; -} - -function expandBlockDirective( - name: string, - options: Record<string, string>, - tokens: LineToken[], - cursor: Cursor, -): Block[] | null { - if (name === 'callout') { - const children = parseChildren(tokens, cursor, true); - const tone: Tone = TONE_VALUES.has(options.tone as Tone) ? (options.tone as Tone) : 'neutral'; - const box: Box = { type: 'box', tone, children }; - return [box]; - } - - if (name === 'box') { - const children = parseChildren(tokens, cursor, true); - const tone: Tone | undefined = TONE_VALUES.has(options.tone as Tone) - ? (options.tone as Tone) - : undefined; - return [{ type: 'box', tone, children }]; - } - - if (name === 'columns') { - const count = options.count === '3' ? 3 : 2; - const columns: Block[][] = collectColumnGroups(tokens, cursor, count); - return [{ type: 'columns', count: count as 2 | 3, columns }]; - } - - if (name === 'grid') { - const cols = ( - options.cols && [2, 3, 4, 6, 12].includes(Number(options.cols)) ? Number(options.cols) : 2 - ) as Grid['cols']; - const rows = ( - options.rows && [1, 2, 3, 4].includes(Number(options.rows)) ? Number(options.rows) : 2 - ) as Grid['rows']; - const children = parseChildren(tokens, cursor, true); - return [{ type: 'grid', cols, rows, children }]; - } - - if (name === 'asset-frame') { - parseChildren(tokens, cursor, true); - const img = buildImage({ ...options, treatment: 'frame' }); - return img ? [img] : []; - } - - if (name === 'annotated-image') { - const lines = collectRawLines(tokens, cursor); - const annotations: ImageAnnotation[] = []; - for (const raw of lines) { - const line = raw.trim(); - if (!line.startsWith('-')) continue; - const m = /^-\s*\(([^,]+),\s*([^)]+)\)\s+(.+)$/.exec(line); - if (!m) continue; - annotations.push({ x: m[1].trim(), y: m[2].trim(), label: m[3].trim() }); - } - const img = buildImage(options); - if (!img) return []; - if (annotations.length > 0) img.annotations = annotations; - return [img]; - } - - if (name === 'cell') { - const children = parseChildren(tokens, cursor, true); - const spanRaw = Number(options.span); - const rowRaw = Number(options.row ?? options.rowSpan); - const span = spanRaw && spanRaw >= 1 && spanRaw <= 12 ? (spanRaw as Cell['span']) : undefined; - const rowSpan = rowRaw && rowRaw >= 1 && rowRaw <= 4 ? (rowRaw as Cell['rowSpan']) : undefined; - const cell: Cell = { type: 'cell', children }; - if (span !== undefined) cell.span = span; - if (rowSpan !== undefined) cell.rowSpan = rowSpan; - return [cell]; - } - - if (name === 'lead' || name === 'caption') { - const children = parseChildren(tokens, cursor, true); - return children.map((b) => - b.type === 'text' ? { ...b, emphasis: name === 'lead' ? 'lead' : 'caption' } : b, - ); - } - - if (name === 'cover' || name === 'section') { - const children = parseChildren(tokens, cursor, true); - return children; - } - - if (name === 'compare') { - const columns = collectColumnGroups(tokens, cursor, 2); - return [{ type: 'columns', count: 2, columns }]; - } - - if (name === 'stats' || name === 'kpis') { - const children = parseChildren(tokens, cursor, true); - const stats = children.filter((b): b is Stat => b.type === 'stat'); - const cols = pickGridCols(name, stats.length); - const rows = pickGridRows(name, stats.length, cols); - return [{ type: 'grid', cols, rows, children: stats }]; - } - - if (name === 'quote.big') { - const children = parseChildren(tokens, cursor, true); - const quote = children.find((b): b is Quote => b.type === 'quote'); - if (quote) return [{ ...quote, emphasis: 'big' }]; - return []; - } - - if (name === 'steps') { - const children = parseChildren(tokens, cursor, true); - const list = children.find((b): b is List => b.type === 'list'); - if (list) return [{ ...list, ordered: true }]; - return children; - } - - if (name === 'chart') { - const lines = collectRawLines(tokens, cursor); - const chart = parseChartContent(name, options, lines); - return chart ? [chart] : []; - } - - if (name === 'table') { - const lines = collectRawLines(tokens, cursor); - const table = parseTableContent(lines, options); - return table ? [table] : []; - } - - if (name === 'agenda') { - const children = parseChildren(tokens, cursor, true); - const heading: Heading = { type: 'heading', level: 2, text: 'Agenda' }; - return [heading, ...children]; - } - - if (name === 'scope-strip') { - parseChildren(tokens, cursor, true); - const cells: Block[][] = []; - const cell = (label: string, value: string | undefined): Block[] => - value - ? [ - { type: 'text', text: label, emphasis: 'caption' } satisfies Text, - { type: 'text', text: value, emphasis: 'normal' } satisfies Text, - ] - : []; - if (options.industry) cells.push(cell('Industry', options.industry)); - if (options.region) cells.push(cell('Region', options.region)); - if (options.timeframe) cells.push(cell('Timeframe', options.timeframe)); - while (cells.length < 3) cells.push([]); - const cols = cells.slice(0, 3); - return [{ type: 'columns', count: 3, columns: cols }]; - } - - if (name === 'big-number') { - parseChildren(tokens, cursor, true); - if (!options.value) return []; - const stat: Stat = { - type: 'stat', - value: options.value, - label: options.label, - delta: options.delta, - trend: inferTrend(options), - }; - const children: Block[] = [stat]; - if (options.source) { - children.push({ - type: 'text', - text: `Source: ${options.source}`, - emphasis: 'caption', - } satisfies Text); - } - return [{ type: 'box', tone: 'neutral', children }]; - } - - if (name === 'kpi-grid') { - const children = parseChildren(tokens, cursor, true); - const stats = children.filter((b): b is Stat => b.type === 'stat'); - const cols = pickGridCols('kpis', stats.length); - const rows = pickGridRows('kpis', stats.length, cols); - const grid: Block = { type: 'grid', cols, rows, children: stats }; - if (options.source) { - const caption: Text = { - type: 'text', - text: `Source: ${options.source}`, - emphasis: 'caption', - }; - return [grid, caption]; - } - return [grid]; - } - - if (name === 'problem' || name === 'approach') { - const children = parseChildren(tokens, cursor, true); - const hasHeading = children.some((b) => b.type === 'heading'); - const tone: Tone = name === 'problem' ? 'warn' : 'info'; - const finalChildren: Block[] = hasHeading - ? children - : [ - { - type: 'heading', - level: 2, - text: name === 'problem' ? 'Problem' : 'Approach', - } satisfies Heading, - ...children, - ]; - return [{ type: 'box', tone, children: finalChildren }]; - } - - if (name === 'before-after') { - const groups = collectColumnGroups(tokens, cursor, 2); - const wrap = (blocks: Block[], tone: Tone): Block[] => [ - { type: 'box', tone, children: blocks }, - ]; - const cols: Block[][] = [wrap(groups[0], 'warn'), wrap(groups[1], 'success')]; - return [{ type: 'columns', count: 2, columns: cols }]; - } - - if (name === 'testimonial') { - const children = parseChildren(tokens, cursor, true); - const quote = children.find((b): b is Quote => b.type === 'quote'); - const attributionParts = [options.name, options.role, options.company].filter(Boolean); - const attribution = attributionParts.length > 0 ? attributionParts.join(', ') : undefined; - const finalQuote: Quote = quote - ? { ...quote, attribution: attribution ?? quote.attribution } - : { type: 'quote', text: '', emphasis: 'normal', attribution }; - const inner: Block[] = [finalQuote]; - return [{ type: 'box', tone: 'neutral', children: inner }]; - } - - if (name === 'pull-quote') { - const children = parseChildren(tokens, cursor, true); - const quote = children.find((b): b is Quote => b.type === 'quote'); - if (quote) return [{ ...quote, emphasis: 'big' }]; - return []; - } - - if (name === 'tear-sheet') { - const inner = parseChildren(tokens, cursor, true); - const cell = (label: string, value: string): Block[] => [ - { type: 'text', text: label, emphasis: 'caption' } satisfies Text, - { type: 'text', text: value, emphasis: 'normal' } satisfies Text, - ]; - const items: Block[] = []; - if (options.client) items.push(...cell('Client', options.client)); - if (options.industry) items.push(...cell('Industry', options.industry)); - if (options.engagement) items.push(...cell('Engagement', options.engagement)); - if (options.duration) items.push(...cell('Duration', options.duration)); - if (options.team) items.push(...cell('Team', options.team)); - if (options.date) items.push(...cell('Date', options.date)); - if (options.outcome) items.push(...cell('Outcome', options.outcome)); - const grid: Grid = { type: 'grid', cols: 2, rows: 2, children: items }; - const out: Block[] = [grid]; - // Inner content (e.g. a `::lead` outcome statement) survives and is - // rendered alongside the field grid. Presets can render it as the - // hero outcome on the slide. - const innerLead = inner.find((b): b is Text => b.type === 'text' && b.emphasis === 'lead'); - if (innerLead) out.push(innerLead); - return out; - } - - if (name === 'timeline') { - const children = parseChildren(tokens, cursor, true); - const list = children.find((b): b is List => b.type === 'list'); - const items = list?.items ?? []; - const cells: Block[] = items.map((it) => { - const raw = it.text; - const idx = raw.indexOf(':'); - const head = idx >= 0 ? raw.slice(0, idx).trim() : raw.trim(); - const body = idx >= 0 ? raw.slice(idx + 1).trim() : ''; - const inner: Block[] = [{ type: 'text', text: head, emphasis: 'caption' } satisfies Text]; - if (body) inner.push({ type: 'text', text: body, emphasis: 'normal' } satisfies Text); - return { type: 'box', tone: 'neutral', children: inner } satisfies Box; - }); - if (cells.length === 0) return []; - const cols = pickGridCols('timeline', cells.length); - const rows = pickGridRows('timeline', cells.length, cols); - return [{ type: 'grid', cols, rows, children: cells } satisfies Grid]; - } - - if (name === 'process-steps') { - const children = parseChildren(tokens, cursor, true); - const list = children.find((b): b is List => b.type === 'list'); - const items = list?.items ?? []; - const cells: Block[] = []; - items.forEach((it, i) => { - const raw = it.text; - const sepIdx = raw.indexOf('::'); - const head = sepIdx >= 0 ? raw.slice(0, sepIdx).trim() : raw.trim(); - const body = sepIdx >= 0 ? raw.slice(sepIdx + 2).trim() : ''; - const num = String(i + 1).padStart(2, '0'); - const inner: Block[] = [ - { type: 'text', text: num, emphasis: 'caption' } satisfies Text, - { type: 'heading', level: 3, text: head } satisfies Heading, - ]; - if (body) inner.push({ type: 'text', text: body, emphasis: 'normal' } satisfies Text); - cells.push({ type: 'box', tone: 'neutral', children: inner } satisfies Box); - }); - if (cells.length === 0) return []; - const cols = pickGridCols('steps', cells.length); - const rows = pickGridRows('steps', cells.length, cols); - return [{ type: 'grid', cols, rows, children: cells } satisfies Grid]; - } - - if (name === 'deliverables') { - const children = parseChildren(tokens, cursor, true); - const groups: { title?: string; items: ListItem[] }[] = []; - let current: { title?: string; items: ListItem[] } | null = null; - for (const block of children) { - if (block.type === 'heading') { - if (current) groups.push(current); - current = { title: block.text, items: [] }; - } else if (block.type === 'list') { - if (!current) current = { items: [] }; - current.items.push(...block.items); - } - } - if (current) groups.push(current); - if (groups.length === 0) return []; - const cols: Block[][] = groups.map((g) => { - const inner: Block[] = []; - if (g.title) { - inner.push({ type: 'heading', level: 4, text: g.title } satisfies Heading); - } - for (const it of g.items) { - inner.push({ type: 'text', text: `✓ ${it.text}`, emphasis: 'normal' } satisfies Text); - } - return [{ type: 'box', tone: 'neutral', children: inner } satisfies Box]; - }); - const count = Math.min(Math.max(cols.length, 2), 3); - while (cols.length < count) cols.push([]); - return [{ type: 'columns', count: count as 2 | 3, columns: cols.slice(0, count) }]; - } - - if (name === 'logo-strip') { - parseChildren(tokens, cursor, true); - const raw = options.logos ?? ''; - const logos = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - if (logos.length === 0) return []; - const cells: Block[] = logos.map( - (logo) => - ({ - type: 'box', - tone: 'neutral', - children: [{ type: 'text', text: logo, emphasis: 'caption' } satisfies Text], - }) satisfies Box, - ); - const cols = pickGridCols('logos', cells.length); - const rows = pickGridRows('logos', cells.length, cols); - return [{ type: 'grid', cols, rows, children: cells } satisfies Grid]; - } - - if (name === 'contact') { - parseChildren(tokens, cursor, true); - const left: Block[] = []; - const right: Block[] = []; - if (options.name) { - left.push({ type: 'heading', level: 3, text: options.name } satisfies Heading); - } - if (options.role) { - left.push({ type: 'text', text: options.role, emphasis: 'caption' } satisfies Text); - } - if (options.email) { - right.push({ type: 'text', text: options.email, emphasis: 'normal' } satisfies Text); - } - if (options.phone) { - right.push({ type: 'text', text: options.phone, emphasis: 'normal' } satisfies Text); - } - if (options.url) { - right.push({ type: 'text', text: options.url, emphasis: 'normal' } satisfies Text); - } - return [{ type: 'columns', count: 2, columns: [left, right] }]; - } - - const children = parseChildren(tokens, cursor, true); - return children; -} - -/** - * Smart grid layout for stats and KPIs. Targets the most pleasing arrangement - * for the most common counts: - * - * 1, 2 → row of 2 - * 3 → row of 3 (horizontal) - * 4 → 2x2 - * 5, 6 → 3x2 - * 7, 8 → 4x2 - * 9+ → 3x3 - */ -function pickGridCols(_pattern: string, count: number): 2 | 3 | 4 { - if (count <= 2) return 2; - if (count === 3) return 3; - if (count === 4) return 2; - if (count <= 6) return 3; - if (count <= 8) return 4; - return 3; -} - -function pickGridRows(_pattern: string, count: number, cols: 2 | 3 | 4): 1 | 2 | 3 { - const rows = Math.ceil(count / cols); - if (rows <= 1) return 1; - if (rows >= 3) return 3; - return 2; -} - -function markdownToBlocks(source: string): Block[] { - const tokens = marked.lexer(source); - const blocks: Block[] = []; - for (const tok of tokens) { - const block = tokenToBlock(tok); - if (block) blocks.push(block); - } - return blocks; -} - -type MarkedToken = ReturnType<typeof marked.lexer>[number]; - -function tokenToBlock(tok: MarkedToken): Block | null { - if (tok.type === 'heading') { - const level = Math.max(1, Math.min(4, tok.depth)) as 1 | 2 | 3 | 4; - return { type: 'heading', level, text: tok.text } satisfies Heading; - } - if (tok.type === 'paragraph') { - return { type: 'text', text: tok.text, emphasis: 'normal' } satisfies Text; - } - if (tok.type === 'list') { - const items = (tok.items as MarkedListItem[]).map((it) => listItemFromToken(it)); - return { type: 'list', ordered: !!tok.ordered, items } satisfies List; - } - if (tok.type === 'blockquote') { - return blockquoteToQuote({ text: (tok as { text?: string }).text ?? '' }); - } - if (tok.type === 'code') { - return { - type: 'code', - language: tok.lang || undefined, - content: tok.text, - } satisfies Code; - } - if (tok.type === 'space' || tok.type === 'hr') return null; - return null; -} - -type MarkedListItem = { text: string; tokens?: MarkedToken[] }; - -function listItemFromToken(item: MarkedListItem): ListItem { - const text = item.text.split('\n')[0].trim(); - const nested = item.tokens?.find((t) => t.type === 'list') as - | { items: MarkedListItem[] } - | undefined; - return { - text, - children: nested ? nested.items.map(listItemFromToken) : undefined, - }; -} - -function blockquoteToQuote(tok: { text: string }): Quote { - const lines = tok.text - .split('\n') - .map((l) => l.trim()) - .filter(Boolean); - let attribution: string | undefined; - const bodyLines: string[] = []; - for (const line of lines) { - if (line.startsWith('-- ') || line.startsWith('— ')) { - attribution = line.replace(/^(--|—)\s*/, '').trim(); - } else { - bodyLines.push(line); - } - } - return { - type: 'quote', - text: bodyLines.join(' '), - attribution, - emphasis: 'normal', - }; -} - -function detectSlideShape(rawSlide: string): { - patternLayout?: LayoutId; - patternName?: string; -} { - const trimmed = rawSlide.trim(); - const m = DIRECTIVE_OPEN_RE.exec(trimmed.split('\n')[0] || ''); - if (!m) return {}; - const name = m[1]; - if (name === 'cover') return { patternLayout: 'cover', patternName: name }; - if (name === 'section') return { patternLayout: 'section', patternName: name }; - if (name === 'compare') return { patternLayout: 'split', patternName: name }; - if (name === 'stats' || name === 'kpis') return { patternLayout: 'grid', patternName: name }; - if (name === 'quote.big') return { patternLayout: 'fullBleed', patternName: name }; - if (name === 'agenda' || name === 'steps' || name === 'timeline') { - return { patternLayout: 'flow', patternName: name }; - } - return {}; -} - -function parseSlideOptions(rawSlide: string): { - body: string; - layout?: LayoutId; - notes?: string; - nosplit?: boolean; -} { - const lines = rawSlide.split('\n'); - const first = (lines[0] ?? '').trim(); - const m = DIRECTIVE_OPEN_RE.exec(first); - if (!m || m[1] !== 'slide') return { body: rawSlide }; - const opts = parseOptions(m[2]); - const body = lines.slice(1).join('\n'); - return { - body, - layout: LAYOUT_IDS.includes(opts.layout as LayoutId) ? (opts.layout as LayoutId) : undefined, - notes: opts.notes, - nosplit: 'nosplit' in opts, - }; -} - -function inferLayout(blocks: Block[], index: number, totalSlides: number): LayoutId { - if (blocks.length === 0) return 'flow'; - - if (index === 0) { - const headings = blocks.filter((b): b is Heading => b.type === 'heading'); - if (headings.length === 1 && headings[0].level <= 2 && blocks.length <= 3) return 'cover'; - } - - if (blocks.length === 1) { - const only = blocks[0]; - if (only.type === 'quote' && only.emphasis === 'big') return 'fullBleed'; - if (only.type === 'stat') return 'hero'; - } - - const onlyHeading = blocks.length === 1 && blocks[0].type === 'heading'; - if (onlyHeading && index > 0 && index < totalSlides - 1) return 'section'; - - if (blocks.some((b) => b.type === 'columns')) return 'split'; - if (blocks.some((b) => b.type === 'grid')) return 'grid'; - - return 'flow'; -} - -type ParseOptions = { - theme?: Partial<ThemeRef>; - brand?: Brand; - deckId?: string; - title?: string; -}; - -export function parseDeck(source: string, options: ParseOptions = {}): Deck { - const fm = matter(source); - const sections = splitSlideSections(fm.content); - - const slides: Slide[] = sections.map((section, index) => { - const { body, layout: explicitLayout, notes } = parseSlideOptions(section); - const detected = detectSlideShape(body); - const tokens = tokenize(body); - const cursor: Cursor = { i: 0 }; - const blocks = parseChildren(tokens, cursor, false); - const inferred = inferLayout(blocks, index, sections.length); - const layout = explicitLayout ?? detected.patternLayout ?? inferred; - return { - id: ulid(), - layout, - blocks, - notes, - }; - }); - - const fmTheme = (fm.data?.theme ?? {}) as Partial<ThemeRef>; - const theme: ThemeRef = { - ...DEFAULT_THEME, - ...fmTheme, - ...options.theme, - }; - - const fmBrand = (fm.data?.brand ?? undefined) as Brand | undefined; - const brand: Brand | undefined = options.brand ?? fmBrand; - - const now = new Date().toISOString(); - const headingTitle = slides[0]?.blocks.find( - (b): b is Heading => b.type === 'heading' && b.level === 1, - )?.text; - const title = - options.title ?? - (typeof fm.data?.title === 'string' ? (fm.data.title as string) : undefined) ?? - headingTitle ?? - 'Untitled Deck'; - - const fmFooter = typeof fm.data?.footer === 'string' ? (fm.data.footer as string) : undefined; - - const deck: Deck = { - version: IR_VERSION, - id: options.deckId ?? ulid(), - title, - aspectRatio: '16:9', - theme, - ...(brand ? { brand } : {}), - ...(fmFooter ? { footer: fmFooter } : {}), - slides, - createdAt: now, - updatedAt: now, - }; - - const validation = validateDeck(deck); - if (!validation.ok) { - throw new ParseError('Parsed deck failed schema validation', validation.errors); - } - return validation.value; -} - -export class ParseError extends Error { - errors: string[]; - constructor(message: string, errors: string[]) { - super(`${message}: ${errors.join('; ')}`); - this.name = 'ParseError'; - this.errors = errors; - } -} diff --git a/src/ir/plan.ts b/src/ir/plan.ts deleted file mode 100644 index f81cdb1..0000000 --- a/src/ir/plan.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Deck, Heading, LayoutId, Slide } from './schema'; - -export function planDeck(deck: Deck): Deck { - const slides = deck.slides.map((s) => ({ ...s })); - - if (slides.length > 0 && hasCoverShape(slides[0])) { - slides[0] = { ...slides[0], layout: 'cover' }; - } else if (slides.length > 0 && slides[0].layout === 'cover' && !hasCoverShape(slides[0])) { - slides[0] = { ...slides[0], layout: 'flow' }; - } - - for (let i = 1; i < slides.length; i++) { - const prev = slides[i - 1]; - const cur = slides[i]; - - if (cur.layout === 'cover') { - slides[i] = { ...cur, layout: pickAlternativeForMidDeck(cur) }; - continue; - } - - if ( - isUncommonLayout(cur.layout) && - isUncommonLayout(prev.layout) && - cur.layout === prev.layout - ) { - slides[i] = { ...cur, layout: 'flow' }; - } - } - - return { ...deck, slides }; -} - -function hasCoverShape(slide: Slide): boolean { - if (slide.layout !== 'cover' && slide.layout !== 'flow') return false; - const headings = slide.blocks.filter((b): b is Heading => b.type === 'heading'); - if (headings.length !== 1) return false; - if (headings[0].level > 2) return false; - return slide.blocks.length <= 3; -} - -function pickAlternativeForMidDeck(slide: Slide): LayoutId { - if (slide.blocks.length === 1 && slide.blocks[0].type === 'heading') return 'section'; - return 'flow'; -} - -function isUncommonLayout(layout: LayoutId): boolean { - return layout === 'fullBleed' || layout === 'cover' || layout === 'section'; -} diff --git a/src/ir/schema.ts b/src/ir/schema.ts deleted file mode 100644 index 253f433..0000000 --- a/src/ir/schema.ts +++ /dev/null @@ -1,470 +0,0 @@ -import { z } from 'zod'; - -export const IR_VERSION = '2.0' as const; - -const HEX = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/; -const HexColorSchema = z.string().regex(HEX, 'expected hex color like #RRGGBB or #RRGGBBAA'); - -const TONES = ['info', 'warn', 'success', 'neutral'] as const; - -export const LAYOUT_IDS = [ - 'flow', - 'hero', - 'cover', - 'section', - 'split', - 'columns', - 'grid', - 'fullBleed', -] as const; - -const ASPECT_RATIOS = ['16:9'] as const; - -type AspectRatio = (typeof ASPECT_RATIOS)[number]; -export type LayoutId = (typeof LAYOUT_IDS)[number]; -export type Tone = (typeof TONES)[number]; - -export type Heading = { - type: 'heading'; - level: 1 | 2 | 3 | 4; - text: string; -}; - -export type Text = { - type: 'text'; - text: string; - emphasis: 'normal' | 'lead' | 'caption'; -}; - -export type ListItem = { - text: string; - children?: ListItem[]; -}; - -export type List = { - type: 'list'; - ordered: boolean; - items: ListItem[]; -}; - -export type Quote = { - type: 'quote'; - text: string; - attribution?: string; - emphasis: 'normal' | 'big'; -}; - -export type Stat = { - type: 'stat'; - value: string; - label?: string; - delta?: string; - trend?: 'up' | 'down' | 'flat'; -}; - -export type Code = { - type: 'code'; - language?: string; - content: string; -}; - -export type Box = { - type: 'box'; - tone?: Tone; - children: Block[]; -}; - -export type Columns = { - type: 'columns'; - count: 2 | 3; - columns: Block[][]; -}; - -export type Grid = { - type: 'grid'; - cols: 2 | 3 | 4 | 6 | 12; - rows: 1 | 2 | 3 | 4; - children: Block[]; -}; - -export type Cell = { - type: 'cell'; - span?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; - rowSpan?: 1 | 2 | 3 | 4; - children: Block[]; -}; - -export type ImageTreatment = - | 'plain' - | 'frame' - | 'bleed' - | 'duotone' - | 'bw' - | 'polaroid' - | 'hard-frame' - | 'mask'; - -export type Image = { - type: 'image'; - src: string; - alt?: string; - caption?: string; - /** Aspect ratio expressed as "W:H" or "W/H". Defaults to natural. */ - aspectRatio?: string; - /** Focal point for crops, "x% y%". Defaults to "50% 50%". */ - focal?: string; - treatment?: ImageTreatment; - /** Optional list of {x, y, label} annotations rendered over the image. */ - annotations?: ImageAnnotation[]; -}; - -export type ImageAnnotation = { - /** "x%" position from left. */ - x: string; - /** "y%" position from top. */ - y: string; - label: string; -}; - -export type ChartKind = 'bar' | 'line' | 'donut'; - -export type ChartDatum = { - label: string; - value: number; -}; - -export type Chart = { - type: 'chart'; - kind: ChartKind; - title?: string; - data: ChartDatum[]; - format?: 'number' | 'percent' | 'currency'; - prefix?: string; - suffix?: string; -}; - -export type Table = { - type: 'table'; - headers: string[]; - rows: string[][]; - emphasizeColumn?: number; -}; - -export type Block = - | Heading - | Text - | List - | Quote - | Stat - | Code - | Box - | Columns - | Grid - | Cell - | Chart - | Table - | Image; - -const HeadingSchema: z.ZodType<Heading> = z.object({ - type: z.literal('heading'), - level: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), - text: z.string().min(1), -}); - -const TextSchema: z.ZodType<Text> = z.object({ - type: z.literal('text'), - text: z.string().min(1), - emphasis: z.enum(['normal', 'lead', 'caption']), -}); - -const ListItemSchema: z.ZodType<ListItem> = z.lazy(() => - z.object({ - text: z.string().min(1), - children: z.array(ListItemSchema).optional(), - }), -); - -const ListSchema: z.ZodType<List> = z.object({ - type: z.literal('list'), - ordered: z.boolean(), - items: z.array(ListItemSchema).min(1), -}); - -const QuoteSchema: z.ZodType<Quote> = z.object({ - type: z.literal('quote'), - text: z.string().min(1), - attribution: z.string().optional(), - emphasis: z.enum(['normal', 'big']), -}); - -const StatSchema: z.ZodType<Stat> = z.object({ - type: z.literal('stat'), - value: z.string().min(1), - label: z.string().optional(), - delta: z.string().optional(), - trend: z.enum(['up', 'down', 'flat']).optional(), -}); - -const CodeSchema: z.ZodType<Code> = z.object({ - type: z.literal('code'), - language: z.string().optional(), - content: z.string(), -}); - -const ChartDatumSchema: z.ZodType<ChartDatum> = z.object({ - label: z.string().min(1), - value: z.number(), -}); - -const ChartSchema: z.ZodType<Chart> = z.object({ - type: z.literal('chart'), - kind: z.enum(['bar', 'line', 'donut']), - title: z.string().optional(), - data: z.array(ChartDatumSchema).min(1), - format: z.enum(['number', 'percent', 'currency']).optional(), - prefix: z.string().optional(), - suffix: z.string().optional(), -}); - -const TableSchema: z.ZodType<Table> = z.object({ - type: z.literal('table'), - headers: z.array(z.string()).min(1), - rows: z.array(z.array(z.string())).min(1), - emphasizeColumn: z.number().int().nonnegative().optional(), -}); - -const BlockSchema: z.ZodType<Block> = z.lazy(() => - z.union([ - HeadingSchema, - TextSchema, - ListSchema, - QuoteSchema, - StatSchema, - CodeSchema, - ChartSchema, - TableSchema, - BoxSchema, - ColumnsSchema, - GridSchema, - CellSchema, - ImageSchema, - ]), -); - -const BoxSchema: z.ZodType<Box> = z.lazy(() => - z.object({ - type: z.literal('box'), - tone: z.enum(TONES).optional(), - children: z.array(BlockSchema).min(1), - }), -); - -const ColumnsSchema: z.ZodType<Columns> = z.lazy(() => - z.object({ - type: z.literal('columns'), - count: z.union([z.literal(2), z.literal(3)]), - columns: z.array(z.array(BlockSchema)), - }), -); - -const GridSchema: z.ZodType<Grid> = z.lazy(() => - z.object({ - type: z.literal('grid'), - cols: z.union([z.literal(2), z.literal(3), z.literal(4), z.literal(6), z.literal(12)]), - rows: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), - children: z.array(BlockSchema).min(1), - }), -); - -const ImageAnnotationSchema: z.ZodType<ImageAnnotation> = z.object({ - x: z.string().min(1), - y: z.string().min(1), - label: z.string().min(1), -}); - -const ImageSchema: z.ZodType<Image> = z.object({ - type: z.literal('image'), - src: z.string().min(1), - alt: z.string().optional(), - caption: z.string().optional(), - aspectRatio: z.string().optional(), - focal: z.string().optional(), - treatment: z - .enum(['plain', 'frame', 'bleed', 'duotone', 'bw', 'polaroid', 'hard-frame', 'mask']) - .optional(), - annotations: z.array(ImageAnnotationSchema).optional(), -}); - -const CellSchema: z.ZodType<Cell> = z.lazy(() => - z.object({ - type: z.literal('cell'), - span: z - .union([ - z.literal(1), - z.literal(2), - z.literal(3), - z.literal(4), - z.literal(5), - z.literal(6), - z.literal(7), - z.literal(8), - z.literal(9), - z.literal(10), - z.literal(11), - z.literal(12), - ]) - .optional(), - rowSpan: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(), - children: z.array(BlockSchema), - }), -); - -const LayoutIdSchema = z.enum(LAYOUT_IDS); - -export type Slide = { - id: string; - layout: LayoutId; - blocks: Block[]; - notes?: string; -}; - -const SlideSchema: z.ZodType<Slide> = z.object({ - id: z.string().min(1), - layout: LayoutIdSchema, - blocks: z.array(BlockSchema), - notes: z.string().optional(), -}); - -export type ColorTokens = { - brand: string; - accent: string; - surface: string; - surfaceMuted: string; - text: string; - textMuted: string; - border: string; - success: string; - warn: string; - danger: string; -}; - -const ColorTokensSchema: z.ZodType<ColorTokens> = z.object({ - brand: HexColorSchema, - accent: HexColorSchema, - surface: HexColorSchema, - surfaceMuted: HexColorSchema, - text: HexColorSchema, - textMuted: HexColorSchema, - border: HexColorSchema, - success: HexColorSchema, - warn: HexColorSchema, - danger: HexColorSchema, -}); - -/** - * A Palette is the color theme. The product is dark-only by design, so each - * palette ships a single set of color tokens, no mode split. - */ -export type Palette = { - id: string; - name: string; - tokens: ColorTokens; -}; - -const PaletteSchema: z.ZodType<Palette> = z.object({ - id: z.string().min(1), - name: z.string().min(1), - tokens: ColorTokensSchema, -}); - -/** - * A deck's design choices. The visual design itself is locked: only the - * palette (colors) and font are user-controllable. `presetId` names the - * starting combination from the gallery; `paletteId` and `fontId` are user - * overrides on top of it. When omitted, they fall back to the preset's - * defaults at resolve time. - */ -export type ThemeRef = { - presetId: string; - paletteId?: string; - fontId?: string; -}; - -const ThemeRefSchema: z.ZodType<ThemeRef> = z.object({ - presetId: z.string().min(1), - paletteId: z.string().min(1).optional(), - fontId: z.string().min(1).optional(), -}); - -export const LOGO_POSITIONS = [ - 'cover-only', - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right', -] as const; - -type LogoPosition = (typeof LOGO_POSITIONS)[number]; - -export type Brand = { - name?: string; - logoUrl?: string; - logoDarkUrl?: string; - logoPosition?: LogoPosition; - brandColor?: string; - accentColor?: string; -}; - -const BrandSchema: z.ZodType<Brand> = z.object({ - name: z.string().min(1).optional(), - logoUrl: z.string().url().optional(), - logoDarkUrl: z.string().url().optional(), - logoPosition: z.enum(LOGO_POSITIONS).optional(), - brandColor: HexColorSchema.optional(), - accentColor: HexColorSchema.optional(), -}); - -export type Deck = { - version: typeof IR_VERSION; - id: string; - title: string; - aspectRatio: AspectRatio; - theme: ThemeRef; - brand?: Brand; - footer?: string; - slides: Slide[]; - createdAt: string; - updatedAt: string; -}; - -const DeckSchema: z.ZodType<Deck> = z.object({ - version: z.literal(IR_VERSION), - id: z.string().min(1), - title: z.string().min(1), - aspectRatio: z.enum(ASPECT_RATIOS), - theme: ThemeRefSchema, - brand: BrandSchema.optional(), - footer: z.string().optional(), - slides: z.array(SlideSchema), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}); - -type ValidationResult<T> = { ok: true; value: T } | { ok: false; errors: string[] }; - -function formatErrors(error: z.ZodError): string[] { - return error.issues.map((i) => { - const path = i.path.length ? i.path.join('.') : '<root>'; - return `${path}: ${i.message}`; - }); -} - -function validate<T>(schema: z.ZodType<T>, input: unknown): ValidationResult<T> { - const r = schema.safeParse(input); - return r.success ? { ok: true, value: r.data } : { ok: false, errors: formatErrors(r.error) }; -} - -export const validateDeck = (input: unknown) => validate(DeckSchema, input); -export const validateSlide = (input: unknown) => validate(SlideSchema, input); -export const validateBlock = (input: unknown) => validate(BlockSchema, input); -export const validatePalette = (input: unknown) => validate(PaletteSchema, input); diff --git a/src/ir/source-edit.ts b/src/ir/source-edit.ts deleted file mode 100644 index 65d6616..0000000 --- a/src/ir/source-edit.ts +++ /dev/null @@ -1,60 +0,0 @@ -import matter from 'gray-matter'; - -const SLIDE_MARKER_RE = /^::slide(\{[^}]*\})?\s*$/; - -/** - * Split markdown into a frontmatter prefix and a list of marker-less slide - * chunks. The first slide implicitly has no `::slide` marker; subsequent - * slides start with one. We strip the marker from every chunk so reordering - * can shuffle slides freely; `joinSlides` re-emits markers between them. - */ -function splitSourceIntoSlides(source: string): { prefix: string; slides: string[] } { - const fm = matter(source); - const body = fm.content; - const lines = body.split('\n'); - const sections: string[][] = [[]]; - for (const line of lines) { - if (SLIDE_MARKER_RE.test(line)) { - sections.push([]); - continue; - } - sections[sections.length - 1].push(line); - } - // If the body started with `::slide` (or only blank lines before one), - // the leading empty section is just whitespace — drop it so we don't - // turn it into a phantom slide. - if (sections.length > 1 && sections[0].every((l) => l.trim() === '')) { - sections.shift(); - } - const slides = sections.map((chunk) => chunk.join('\n').trim()); - const fmPrefix = source.slice(0, source.length - body.length); - return { prefix: fmPrefix, slides }; -} - -function joinSlides(prefix: string, slides: string[]): string { - if (slides.length === 0) return prefix; - const body = slides.filter((s) => s.length > 0).join('\n\n::slide\n\n'); - let out = prefix; - if (out.length > 0 && !out.endsWith('\n')) out += '\n'; - if (out.length > 0) out += '\n'; - return out + body + '\n'; -} - -/** - * Move slide at `from` to position `to`. Out-of-range indices and `from === to` - * are no-ops. Pure: same input, same output. - */ -export function reorderSlide(source: string, from: number, to: number): string { - if (from === to) return source; - const { prefix, slides } = splitSourceIntoSlides(source); - if (from < 0 || from >= slides.length) return source; - if (to < 0 || to >= slides.length) return source; - const next = slides.slice(); - const [moved] = next.splice(from, 1); - next.splice(to, 0, moved); - return joinSlides(prefix, next); -} - -export function countSlides(source: string): number { - return splitSourceIntoSlides(source).slides.length; -} diff --git a/src/layouts/index.ts b/src/layouts/index.ts deleted file mode 100644 index 8bc3f46..0000000 --- a/src/layouts/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { LayoutId } from '@/ir/schema'; - -type LayoutDef = { - id: LayoutId; - name: string; - description: string; - className: string; - printSafe: boolean; -}; - -const layouts: Record<LayoutId, LayoutDef> = { - flow: { - id: 'flow', - name: 'Flow', - description: 'Top-to-bottom stack. Default fallback.', - className: 'layout-flow', - printSafe: true, - }, - hero: { - id: 'hero', - name: 'Hero', - description: 'One dominant block with optional support beneath.', - className: 'layout-hero', - printSafe: true, - }, - cover: { - id: 'cover', - name: 'Cover', - description: 'Deck cover. Big title, generous space.', - className: 'layout-cover', - printSafe: true, - }, - section: { - id: 'section', - name: 'Section', - description: 'Section break between deck parts.', - className: 'layout-section', - printSafe: true, - }, - split: { - id: 'split', - name: 'Split', - description: 'Two-column 50/50 used by compare.', - className: 'layout-split', - printSafe: true, - }, - columns: { - id: 'columns', - name: 'Columns', - description: 'Explicit N-column arrangement.', - className: 'layout-columns', - printSafe: true, - }, - grid: { - id: 'grid', - name: 'Grid', - description: 'Explicit N x M arrangement, used by stats and kpis.', - className: 'layout-grid', - printSafe: true, - }, - fullBleed: { - id: 'fullBleed', - name: 'Full Bleed', - description: 'Single dominant element, edge to edge.', - className: 'layout-fullbleed', - printSafe: true, - }, -}; - -export function getLayout(id: LayoutId): LayoutDef { - return layouts[id]; -} diff --git a/src/lib/case-studies.ts b/src/lib/case-studies.ts new file mode 100644 index 0000000..13afc86 --- /dev/null +++ b/src/lib/case-studies.ts @@ -0,0 +1,150 @@ +import 'server-only'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { z } from 'zod'; + +const ROOT = path.join(process.cwd(), 'case-studies'); + +const SlideEntrySchema = z.object({ + file: z.string().regex(/^[a-zA-Z0-9._-]+\.html?$/), + title: z.string().optional(), +}); + +const MetaSchema = z.object({ + slug: z.string().regex(/^[a-z0-9-]+$/), + title: z.string(), + client: z.string().optional(), + industry: z.string().optional(), + date: z.string().optional(), + cover: z.string().optional(), + tags: z.array(z.string()).optional(), + slides: z.array(SlideEntrySchema).optional(), + visibility: z.enum(['public', 'private']).optional(), + summary: z.string().optional(), +}); + +export type SlideEntry = z.infer<typeof SlideEntrySchema>; +export type CaseStudyMeta = z.infer<typeof MetaSchema>; + +export type CaseStudy = CaseStudyMeta & { + slides: SlideEntry[]; + cover: string; +}; + +async function readMeta(slug: string): Promise<CaseStudy | null> { + const dir = path.join(ROOT, slug); + let stat; + try { + stat = await fs.stat(dir); + } catch { + return null; + } + if (!stat.isDirectory()) return null; + + const metaPath = path.join(dir, 'meta.json'); + let raw: string; + try { + raw = await fs.readFile(metaPath, 'utf8'); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + const result = MetaSchema.safeParse(parsed); + if (!result.success) return null; + const meta = result.data; + if (meta.slug !== slug) return null; + + let slides = meta.slides; + if (!slides || slides.length === 0) { + const entries = await fs.readdir(dir); + const htmls = entries.filter((f) => /\.html?$/.test(f) && !f.startsWith('_')).sort(); + slides = await Promise.all( + htmls.map(async (file) => { + const content = await fs.readFile(path.join(dir, file), 'utf8'); + const titleMatch = content.match(/<title>([^<]+)<\/title>/i); + return { file, title: titleMatch?.[1]?.trim() }; + }), + ); + } + + if (slides.length === 0) return null; + + return { + ...meta, + slides, + cover: meta.cover ?? slides[0].file, + }; +} + +export async function listCaseStudies(): Promise<CaseStudy[]> { + let entries: string[]; + try { + entries = await fs.readdir(ROOT); + } catch { + return []; + } + const studies = await Promise.all( + entries.filter((e) => !e.startsWith('.') && !e.startsWith('_')).map((slug) => readMeta(slug)), + ); + return studies + .filter((s): s is CaseStudy => s !== null && s.visibility !== 'private') + .sort((a, b) => (b.date ?? '').localeCompare(a.date ?? '')); +} + +export async function getCaseStudy(slug: string): Promise<CaseStudy | null> { + return readMeta(slug); +} + +export async function readSlide(slug: string, file: string): Promise<string | null> { + if (!/^[a-z0-9-]+$/.test(slug)) return null; + if (!/^[a-zA-Z0-9._-]+\.html?$/.test(file)) return null; + const study = await getCaseStudy(slug); + if (!study) return null; + if (!study.slides.find((s) => s.file === file)) return null; + try { + return await fs.readFile(path.join(ROOT, slug, file), 'utf8'); + } catch { + return null; + } +} + +export async function readAsset( + slug: string, + asset: string, +): Promise<{ buf: Buffer; type: string } | null> { + if (!/^[a-z0-9-]+$/.test(slug)) return null; + // Only allow paths under assets/ + const normalized = path.posix.normalize(asset); + if (normalized.startsWith('..') || normalized.startsWith('/')) return null; + if (!normalized.startsWith('assets/')) return null; + + const full = path.join(ROOT, slug, normalized); + try { + const buf = await fs.readFile(full); + const ext = path.extname(full).toLowerCase(); + const type = + { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.avif': 'image/avif', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + }[ext] ?? 'application/octet-stream'; + return { buf, type }; + } catch { + return null; + } +} diff --git a/src/lib/color.ts b/src/lib/color.ts deleted file mode 100644 index e490329..0000000 --- a/src/lib/color.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Color utilities. We work in OKLCH for perceptual operations (lightness shifts, - * harmonic accent picks) and convert back to hex for CSS. Contrast checks - * use sRGB luminance per WCAG 2.1. - * - * No external deps. Pure functions, fully tested. - */ - -const HEX_RE = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; - -type Rgb = { r: number; g: number; b: number }; -type Oklch = { l: number; c: number; h: number }; - -export function isValidHex(input: string): boolean { - return HEX_RE.test(input.trim()); -} - -export function hexToRgb(hex: string): Rgb { - const cleaned = hex.trim().replace(/^#/, ''); - const expanded = - cleaned.length === 3 - ? cleaned - .split('') - .map((c) => c + c) - .join('') - : cleaned; - const num = parseInt(expanded, 16); - return { - r: (num >> 16) & 0xff, - g: (num >> 8) & 0xff, - b: num & 0xff, - }; -} - -export function rgbToHex({ r, g, b }: Rgb): string { - const channel = (n: number) => - Math.max(0, Math.min(255, Math.round(n))) - .toString(16) - .padStart(2, '0'); - return `#${channel(r)}${channel(g)}${channel(b)}`; -} - -function srgbToLinear(c: number): number { - const v = c / 255; - return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); -} - -function linearToSrgb(c: number): number { - const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; - return Math.max(0, Math.min(255, Math.round(v * 255))); -} - -function rgbToOklch({ r, g, b }: Rgb): Oklch { - const lr = srgbToLinear(r); - const lg = srgbToLinear(g); - const lb = srgbToLinear(b); - - const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); - const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); - const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); - - const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_; - const A = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_; - const B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_; - - const c = Math.sqrt(A * A + B * B); - const h = ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360; - return { l: L, c, h }; -} - -export function oklchToHex({ l, c, h }: Oklch): string { - const hr = (h * Math.PI) / 180; - const A = c * Math.cos(hr); - const B = c * Math.sin(hr); - - const l_ = l + 0.3963377774 * A + 0.2158037573 * B; - const m_ = l - 0.1055613458 * A - 0.0638541728 * B; - const s_ = l - 0.0894841775 * A - 1.291485548 * B; - - const lr = l_ * l_ * l_; - const lg = m_ * m_ * m_; - const lb = s_ * s_ * s_; - - const r = 4.0767416621 * lr - 3.3077115913 * lg + 0.2309699292 * lb; - const g = -1.2684380046 * lr + 2.6097574011 * lg - 0.3413193965 * lb; - const b = -0.0041960863 * lr - 0.7034186147 * lg + 1.707614701 * lb; - - return rgbToHex({ r: linearToSrgb(r), g: linearToSrgb(g), b: linearToSrgb(b) }); -} - -function hexToOklch(hex: string): Oklch { - return rgbToOklch(hexToRgb(hex)); -} - -/** - * Pick a complementary accent for a brand color: rotate hue by 180° and - * keep similar chroma + lightness. Looks good in most palettes. - */ -export function deriveAccent(brandHex: string): string { - const oklch = hexToOklch(brandHex); - return oklchToHex({ - l: oklch.l, - c: oklch.c, - h: (oklch.h + 180) % 360, - }); -} - -function luminance(rgb: Rgb): number { - return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b); -} - -/** - * Contrast ratio between two colors per WCAG 2.1. Returns a number from 1 to 21. - * Anything >= 4.5 passes AA for normal text; >= 3 passes AA for large text. - */ -export function contrastRatio(a: string, b: string): number { - const la = luminance(hexToRgb(a)); - const lb = luminance(hexToRgb(b)); - const lighter = Math.max(la, lb); - const darker = Math.min(la, lb); - return (lighter + 0.05) / (darker + 0.05); -} - -type ContrastVerdict = { - ratio: number; - passesAA: boolean; - passesAALarge: boolean; - passesAAA: boolean; -}; - -export function checkContrast(foreground: string, background: string): ContrastVerdict { - const ratio = contrastRatio(foreground, background); - return { - ratio, - passesAA: ratio >= 4.5, - passesAALarge: ratio >= 3, - passesAAA: ratio >= 7, - }; -} - -/** - * Pick the better text color (black or white) for the given background. - */ -export function pickReadableText(backgroundHex: string): '#ffffff' | '#0a0a0a' { - return contrastRatio('#ffffff', backgroundHex) >= contrastRatio('#0a0a0a', backgroundHex) - ? '#ffffff' - : '#0a0a0a'; -} diff --git a/src/lib/generate-pdf.ts b/src/lib/generate-pdf.ts new file mode 100644 index 0000000..f144790 --- /dev/null +++ b/src/lib/generate-pdf.ts @@ -0,0 +1,390 @@ +'use client'; + +import { jsPDF } from 'jspdf'; +import { toJpeg } from 'html-to-image'; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +export type SlideRef = { file: string; title?: string }; + +export type ProgressEvent = + | { phase: 'preparing' } + | { phase: 'rendering'; current: number; total: number } + | { phase: 'composing' } + | { phase: 'done' }; + +export type ContactCard = { + deckTitle: string; + client?: string; + recipient?: string | null; + senderName: string; + contactEmail: string; +}; + +type GenerateOptions = { + slug: string; + filename: string; + slides: SlideRef[]; + /** Pixel ratio for rasterization. 2 = retina-grade. Higher = sharper but bigger files. */ + pixelRatio?: number; + /** JPEG quality, 0..1. */ + quality?: number; + /** When provided, an Octify-branded contact page is appended as the final PDF page. */ + contactCard?: ContactCard; + onProgress?: (event: ProgressEvent) => void; +}; + +/** + * Render every slide into a hidden 1920x1080 iframe, snapshot it to a JPEG, + * then assemble a one-page-per-slide PDF and trigger a Blob download. + * + * Why JPEG raster pages: the BYO HTML rule means slides may use any CSS the + * browser supports. The only way to guarantee identical output on the + * recipient's machine, regardless of their installed system fonts, is to + * rasterize on the author's machine with the author's fonts already painted. + * Vector PDF text is not viable here without a server-side browser engine. + */ +export async function generateDeckPdf(opts: GenerateOptions): Promise<void> { + const { slug, filename, slides, pixelRatio = 2, quality = 0.92, contactCard, onProgress } = opts; + if (!slides.length) throw new Error('No slides to render.'); + + onProgress?.({ phase: 'preparing' }); + + const host = document.createElement('div'); + host.setAttribute('aria-hidden', 'true'); + host.style.cssText = + 'position:fixed;left:-100000px;top:0;width:1920px;height:1080px;pointer-events:none;contain:strict;'; + document.body.appendChild(host); + + const pdf = new jsPDF({ + orientation: 'landscape', + unit: 'px', + format: [CANVAS_W, CANVAS_H], + compress: true, + hotfixes: ['px_scaling'], + }); + + const totalPages = slides.length + (contactCard ? 1 : 0); + + try { + for (let i = 0; i < slides.length; i++) { + onProgress?.({ phase: 'rendering', current: i + 1, total: totalPages }); + + const dataUrl = await renderSlideToJpeg(host, slug, slides[i].file, { + pixelRatio, + quality, + }); + + if (i > 0) pdf.addPage([CANVAS_W, CANVAS_H], 'landscape'); + pdf.addImage(dataUrl, 'JPEG', 0, 0, CANVAS_W, CANVAS_H, undefined, 'FAST'); + } + + if (contactCard) { + onProgress?.({ phase: 'rendering', current: totalPages, total: totalPages }); + const dataUrl = await renderContactCardToJpeg(host, contactCard, { + pixelRatio, + quality, + }); + pdf.addPage([CANVAS_W, CANVAS_H], 'landscape'); + pdf.addImage(dataUrl, 'JPEG', 0, 0, CANVAS_W, CANVAS_H, undefined, 'FAST'); + } + + onProgress?.({ phase: 'composing' }); + pdf.save(filename); + onProgress?.({ phase: 'done' }); + } finally { + host.remove(); + } +} + +/** + * Render an Octify-branded "thanks for reading" page. Same rendering pipeline + * as a real slide so the output is visually consistent with the deck. + */ +async function renderContactCardToJpeg( + host: HTMLElement, + card: ContactCard, + opts: { pixelRatio: number; quality: number }, +): Promise<string> { + const recipientLine = card.recipient + ? `<div class="recipient">Prepared for ${escapeHtml(card.recipient)}</div>` + : ''; + const clientLine = card.client + ? `<div class="client">${escapeHtml(card.client)} · ${escapeHtml(card.deckTitle)}</div>` + : `<div class="client">${escapeHtml(card.deckTitle)}</div>`; + const html = `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Get in touch + + + +
+
+ + + + + + + + Octify Technologies + ${recipientLine} +
+ +
+
Thanks for reading
+

Like what you
just saw.

+
If anything in this deck sparked an idea, let's get on a call. ${escapeHtml( + card.senderName, + )} replies fast and brings receipts.
+
+ +
+
+ Reply to ${escapeHtml(card.senderName)} + ${escapeHtml(card.contactEmail)} +
+
+ ${clientLine} +
octifytechnologies.com
+
+
+
+ +`; + + const iframe = document.createElement('iframe'); + iframe.width = String(CANVAS_W); + iframe.height = String(CANVAS_H); + iframe.style.cssText = `border:0;width:${CANVAS_W}px;height:${CANVAS_H}px;background:#0a0a0a;`; + iframe.setAttribute('sandbox', 'allow-same-origin'); + iframe.srcdoc = html; + host.appendChild(iframe); + + try { + await waitForIframeReady(iframe); + const doc = iframe.contentDocument; + if (!doc) throw new Error('Cannot read contact card document.'); + return await toJpeg(doc.documentElement, { + width: CANVAS_W, + height: CANVAS_H, + pixelRatio: opts.pixelRatio, + quality: opts.quality, + backgroundColor: '#0a0a0a', + cacheBust: false, + skipFonts: true, + }); + } finally { + iframe.remove(); + } +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => { + switch (c) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + default: + return '''; + } + }); +} + +async function renderSlideToJpeg( + host: HTMLElement, + slug: string, + file: string, + opts: { pixelRatio: number; quality: number }, +): Promise { + const iframe = document.createElement('iframe'); + iframe.width = String(CANVAS_W); + iframe.height = String(CANVAS_H); + iframe.style.cssText = `border:0;width:${CANVAS_W}px;height:${CANVAS_H}px;background:#fff;`; + // Same-origin + allow-same-origin lets us reach contentDocument for snapshotting. + iframe.setAttribute('sandbox', 'allow-same-origin'); + iframe.src = `/c/${slug}/slides/${file}`; + host.appendChild(iframe); + + try { + await waitForIframeReady(iframe); + + const doc = iframe.contentDocument; + if (!doc) throw new Error(`Cannot read slide document for ${file}.`); + + return await toJpeg(doc.documentElement, { + width: CANVAS_W, + height: CANVAS_H, + pixelRatio: opts.pixelRatio, + quality: opts.quality, + backgroundColor: '#ffffff', + cacheBust: false, + // Skip embedding external fonts. Slides per the project contract use + // system font stacks only, so we let the browser paint with what's + // already loaded; the result is rasterized regardless. + skipFonts: true, + }); + } finally { + iframe.remove(); + } +} + +function waitForIframeReady(iframe: HTMLIFrameElement): Promise { + return new Promise((resolve, reject) => { + const onLoad = async () => { + iframe.removeEventListener('load', onLoad); + iframe.removeEventListener('error', onError); + try { + const doc = iframe.contentDocument; + if (!doc) { + resolve(); + return; + } + // Wait for fonts the slide may still be loading (system fonts resolve + // synchronously, but custom @font-face rules would not). + if (doc.fonts && typeof doc.fonts.ready?.then === 'function') { + await doc.fonts.ready; + } + // Wait for any remaining images. + const images = Array.from(doc.images) as HTMLImageElement[]; + await Promise.all( + images.map((img) => + img.complete && img.naturalWidth > 0 + ? Promise.resolve() + : new Promise((r) => { + img.addEventListener('load', () => r(), { once: true }); + img.addEventListener('error', () => r(), { once: true }); + }), + ), + ); + // One paint frame to settle any final styles. + await new Promise((r) => requestAnimationFrame(() => r())); + resolve(); + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))); + } + }; + const onError = () => { + iframe.removeEventListener('load', onLoad); + iframe.removeEventListener('error', onError); + reject(new Error('Slide failed to load.')); + }; + iframe.addEventListener('load', onLoad); + iframe.addEventListener('error', onError); + }); +} diff --git a/src/library/DeckLibrary.tsx b/src/library/DeckLibrary.tsx deleted file mode 100644 index 6dcc264..0000000 --- a/src/library/DeckLibrary.tsx +++ /dev/null @@ -1,419 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useEffect, useMemo, useState } from 'react'; - -import { parseDeck } from '@/ir/parse'; -import { planDeck } from '@/ir/plan'; -import { - type DeckSummary, - deleteDeck, - duplicateDeck, - getDeck, - listDecks, -} from '@/storage/deck-store'; -import { DeckRenderer } from '@/render/DeckRenderer'; -import { - AppTopbar, - Body, - Button, - Caption, - GalleryGrid, - Heading, - Mono, - PageMain, - PageShell, - PageWorkbar, - Subheading, -} from '@/components'; - -type SortKey = 'recent' | 'oldest' | 'title'; - -export function DeckLibrary() { - const [decks, setDecks] = useState(null); - const [query, setQuery] = useState(''); - const [sort, setSort] = useState('recent'); - - const [loadError, setLoadError] = useState(null); - - useEffect(() => { - listDecks() - .then((d) => { - setDecks(d); - setLoadError(null); - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : 'Could not load decks'; - setLoadError(message); - setDecks([]); - }); - }, []); - - const refresh = async () => { - try { - setDecks(await listDecks()); - setLoadError(null); - } catch (err) { - const message = err instanceof Error ? err.message : 'Could not load decks'; - setLoadError(message); - } - }; - - const visibleDecks = useMemo(() => { - if (!decks) return null; - const q = query.trim().toLowerCase(); - const filtered = q - ? decks.filter( - (d) => - d.title.toLowerCase().includes(q) || (d.templateName ?? '').toLowerCase().includes(q), - ) - : decks.slice(); - switch (sort) { - case 'oldest': - return filtered.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt)); - case 'title': - return filtered.sort((a, b) => a.title.localeCompare(b.title)); - case 'recent': - default: - return filtered.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - } - }, [decks, query, sort]); - - const totalCount = decks?.length ?? 0; - const visibleCount = visibleDecks?.length ?? 0; - - const countLabel = query - ? `${visibleCount} of ${totalCount}` - : `${totalCount} ${totalCount === 1 ? 'deck' : 'decks'}`; - - return ( - - - - - - - - } - /> - - - {loadError ? ( - - {loadError} - - ) : decks === null ? ( - - Loading decks - - ) : decks.length === 0 ? ( - - ) : visibleCount === 0 ? ( - setQuery('')} /> - ) : ( - - {query ? null : ( - -
- - - -
-
- Start a new deck - Pick a template or write from scratch -
- - )} - {visibleDecks!.map((deck) => ( - - ))} -
- )} -
-
- ); -} - -function SearchField({ value, onChange }: { value: string; onChange: (next: string) => void }) { - return ( - - ); -} - -function SortControl({ value, onChange }: { value: SortKey; onChange: (next: SortKey) => void }) { - const options: { id: SortKey; label: string }[] = [ - { id: 'recent', label: 'Recent' }, - { id: 'oldest', label: 'Oldest' }, - { id: 'title', label: 'A–Z' }, - ]; - return ( -
- {options.map((opt) => ( - - ))} -
- ); -} - -function EmptyState() { - return ( -
-
- ::cover - - # Your first deck. -
A blank page is a beautiful start. -
- :: -
- - No decks yet - - - Pick a template to get started, or write a deck from scratch in markdown. - -
- -
-
- ); -} - -function NoMatches({ query, onClear }: { query: string; onClear: () => void }) { - return ( -
- - No decks match “{query}” - - Try a different search, or clear the filter to see everything. - -
- ); -} - -function DeckCard({ deck, onChange }: { deck: DeckSummary; onChange: () => Promise }) { - const [previewSource, setPreviewSource] = useState(null); - const [confirming, setConfirming] = useState(false); - - useEffect(() => { - let cancelled = false; - getDeck(deck.id).then((stored) => { - if (!cancelled && stored) setPreviewSource(stored.source); - }); - return () => { - cancelled = true; - }; - }, [deck.id]); - - const planned = useMemo(() => { - if (!previewSource) return null; - try { - const parsed = parseDeck(previewSource, { theme: deck.theme, brand: deck.brand }); - return planDeck(parsed); - } catch (e) { - void e; - return null; - } - }, [previewSource, deck.theme, deck.brand]); - - const previewDeck = planned ? { ...planned, slides: planned.slides.slice(0, 1) } : null; - const slideCount = planned?.slides.length ?? 0; - - const updated = formatRelativeDate(deck.updatedAt); - - const templateAttr = deck.templateName ? templateSlug(deck.templateName) : undefined; - - return ( -
- -
- {previewDeck ? ( -
- -
- ) : ( -
- )} -
-
-
- - {deck.title} - -
- - {updated} - - - · - - - {slideCount > 0 ? `${slideCount} ${slideCount === 1 ? 'slide' : 'slides'}` : '—'} - - {deck.templateName ? ( - - {deck.templateName} - - ) : null} -
-
- - - {confirming ? ( -
- - Delete? - - - -
- ) : ( -
- - -
- )} -
- ); -} - -function templateSlug(name: string): string { - const key = name.toLowerCase(); - if (key.includes('pitch')) return 'pitch'; - if (key.includes('editorial')) return 'editorial'; - return 'other'; -} - -function formatRelativeDate(iso: string): string { - const then = new Date(iso).getTime(); - const diffMs = Date.now() - then; - const minutes = Math.floor(diffMs / 60000); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}d ago`; - return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -} diff --git a/src/library/library.css b/src/library/library.css deleted file mode 100644 index 8ec2914..0000000 --- a/src/library/library.css +++ /dev/null @@ -1,486 +0,0 @@ -/* ========================================================================== - library — page-specific styles - Page shell, workbar, grid, surface card, and typography all live in - shared primitives (components/layout/page.css and primitives/*). - This file owns ONLY library-unique chrome: search, sort, deck-card, - empty/no-match decoration, and quick-create card. - ========================================================================== */ - -/* Search - --------------------------------------------------------------------------*/ - -.library__search { - display: inline-flex; - align-items: center; - gap: 8px; - height: var(--h-control); - width: 280px; - padding: 0 10px 0 11px; - border: 1px solid var(--line); - border-radius: var(--r-sm); - background: var(--surface); - color: var(--text-2); - transition: - border-color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease); -} - -.library__search:hover { - border-color: var(--line-strong); -} - -.library__search:focus-within { - border-color: var(--text-2); - background: var(--surface-strong); - color: var(--text); -} - -.library__search-icon { - flex-shrink: 0; - color: var(--text-3); -} - -.library__search:focus-within .library__search-icon { - color: var(--text-2); -} - -.library__search-input { - flex: 1; - min-width: 0; - background: transparent; - border: none; - outline: none; - font-family: var(--ui); - font-size: var(--fs-sm); - color: var(--text); - letter-spacing: -0.005em; -} - -.library__search-input::placeholder { - color: var(--text-3); -} - -.library__search-input::-webkit-search-cancel-button { - display: none; -} - -.library__search-clear { - display: grid; - place-items: center; - width: 18px; - height: 18px; - border: none; - background: var(--surface-strong); - color: var(--text-2); - border-radius: 999px; - cursor: pointer; - flex-shrink: 0; - transition: - color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease); -} - -.library__search-clear:hover { - background: var(--line-strong); - color: var(--text); -} - -/* Sort segmented - --------------------------------------------------------------------------*/ - -.library__sort { - display: inline-flex; - height: var(--h-control); - padding: 3px; - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r-sm); -} - -.library__sort-option { - padding: 0 12px; - background: transparent; - border: none; - border-radius: 4px; - font-family: var(--ui); - font-size: var(--fs-sm); - font-weight: 500; - color: var(--text-2); - cursor: pointer; - transition: - color var(--t-fast) var(--ease), - background var(--t-fast) var(--ease), - box-shadow var(--t-fast) var(--ease); -} - -.library__sort-option:hover { - color: var(--text); -} - -.library__sort-option:focus-visible { - outline: 1px solid var(--text); - outline-offset: -1px; - border-radius: 4px; -} - -.library__sort-option--active { - background: var(--bg-elev-1); - color: var(--text); - box-shadow: - var(--highlight-strong), - 0 0 0 1px var(--line-strong); -} - -/* Loading state (uses .t-mono base; this just adds spacing + tracking). - --------------------------------------------------------------------------*/ - -.library__loading { - padding: 72px 0; - text-align: center; - letter-spacing: 0.14em; - text-transform: uppercase; -} - -/* Quick-create card — extends .surface-card .surface-card--dashed. - This only owns internal layout, icon chip, and the warm radial flair. - --------------------------------------------------------------------------*/ - -.library__new-card { - align-items: flex-start; - justify-content: space-between; - height: 100%; - padding: 20px; -} - -.library__new-card::before { - content: ''; - position: absolute; - inset: 0; - background: radial-gradient(ellipse at 30% 0%, rgba(255, 232, 196, 0.04), transparent 60%); - opacity: 0; - transition: opacity var(--t-med) var(--ease); - pointer-events: none; -} - -.library__new-card:hover::before { - opacity: 1; -} - -.library__new-card-icon { - width: 32px; - height: 32px; - display: grid; - place-items: center; - border-radius: 999px; - background: var(--surface-strong); - border: 1px solid var(--line-strong); - color: var(--text); -} - -.library__new-card-text { - display: flex; - flex-direction: column; - gap: 4px; -} - -/* Deck card — extends .surface-card. Owns per-template accent and the - confirming state. - --------------------------------------------------------------------------*/ - -.deck-card[data-template='pitch'] { - --accent: 56, 189, 248; -} - -.deck-card[data-template='editorial'] { - --accent: 244, 114, 182; -} - -.deck-card--confirming { - border-color: rgba(240, 128, 128, 0.5); -} - -.deck-card__link { - display: flex; - flex-direction: column; - text-decoration: none; - color: inherit; -} - -.deck-card__preview { - position: relative; - width: 100%; - aspect-ratio: 16 / 9; - background: #050504; - overflow: hidden; - border-bottom: 1px solid var(--line); - container-type: inline-size; -} - -.deck-card__preview-placeholder { - width: 100%; - height: 100%; - background: linear-gradient(180deg, #0c0b0a 0%, #050504 100%); -} - -.deck-card__preview-shade { - position: absolute; - inset: 0; - background: linear-gradient(180deg, transparent 60%, rgba(0, 0, 0, 0.3) 100%); - pointer-events: none; -} - -.deck-card__scaler { - position: absolute; - top: 0; - left: 0; - width: 1280px; - height: 720px; - transform: scale(calc(100cqw / 1280)); - transform-origin: top left; - pointer-events: none; -} - -.deck-card__scaler .deck { - padding: 0; - gap: 0; - align-items: stretch; -} - -.deck-card__scaler .slide-frame { - width: 1280px; - max-width: 1280px; - height: 720px; - border-radius: 0; - border: none; - box-shadow: none; -} - -.deck-card__meta { - padding: 14px 16px 14px; - display: flex; - flex-direction: column; - gap: 6px; -} - -.deck-card__meta .t-heading { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.deck-card__sub { - display: flex; - align-items: center; - gap: 7px; - white-space: nowrap; - overflow: hidden; - min-height: 18px; -} - -.deck-card__sub-dot { - color: var(--text-3); -} - -.deck-card__chip { - margin-left: auto; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px 2px 7px; - font-family: var(--ui); - font-size: 11px; - font-weight: 500; - letter-spacing: 0.005em; - line-height: 1.6; - color: rgb(var(--accent)); - background: rgba(var(--accent), 0.14); - border: 1px solid rgba(var(--accent), 0.35); - border-radius: 999px; - white-space: nowrap; -} - -.deck-card__chip::before { - content: ''; - width: 5px; - height: 5px; - border-radius: 999px; - background: rgb(var(--accent)); - box-shadow: 0 0 8px rgba(var(--accent), 0.85); -} - -.deck-card__chip[data-template='pitch'] { - --accent: 56, 189, 248; -} - -.deck-card__chip[data-template='editorial'] { - --accent: 244, 114, 182; -} - -.deck-card__chip[data-template='other'] { - --accent: 180, 180, 180; -} - -/* Hover actions and inline confirm - --------------------------------------------------------------------------*/ - -.deck-card__actions { - position: absolute; - top: 10px; - right: 10px; - display: flex; - gap: 4px; - opacity: 0; - transform: translateY(-2px); - transition: - opacity var(--t-med) var(--ease), - transform var(--t-med) var(--ease); -} - -.deck-card:hover .deck-card__actions, -.deck-card:focus-within .deck-card__actions { - opacity: 1; - transform: translateY(0); -} - -/* Action buttons sit on top of the dark preview, so override the - transparent ghost/danger styles with a glassy chip background. */ -.deck-card__actions .btn { - background: rgba(15, 14, 13, 0.92); - border-color: var(--line-strong); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); -} - -.deck-card__actions .btn--ghost:hover { - background: var(--bg-elev-2); - border-color: var(--text-2); -} - -.deck-card__actions .btn--danger:hover { - background: rgba(240, 128, 128, 0.12); - border-color: rgba(240, 128, 128, 0.4); -} - -.deck-card__confirm { - position: absolute; - top: 10px; - right: 10px; - display: inline-flex; - align-items: center; - gap: 6px; - height: var(--h-control-sm); - padding: 0 4px 0 12px; - border: 1px solid rgba(240, 128, 128, 0.4); - background: rgba(40, 18, 18, 0.95); - border-radius: var(--r-sm); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - font-size: var(--fs-sm); - color: var(--text); - animation: confirm-in var(--t-med) var(--ease); - box-shadow: var(--shadow-2); -} - -@keyframes confirm-in { - from { - opacity: 0; - transform: translateY(-2px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.deck-card__confirm-text { - font-weight: 500; - letter-spacing: -0.005em; - color: var(--text); -} - -/* Confirm-overlay buttons share the .btn primitive but live on a colored - glass background, so we tighten their height. */ -.deck-card__confirm .btn { - height: 22px; - padding: 0 9px; - font-size: var(--fs-xs); - border-radius: 4px; - font-weight: 600; -} - -.deck-card__confirm .btn--danger { - background: var(--err); - color: #2a0a0a; - border-color: transparent; -} - -.deck-card__confirm .btn--danger:hover { - background: #f5a3a3; -} - -/* Empty / no-match states - --------------------------------------------------------------------------*/ - -.library__empty, -.library__nomatch { - display: flex; - flex-direction: column; - align-items: flex-start; - text-align: left; - padding: 24px 0 96px; - gap: 14px; - max-width: 520px; -} - -.library__empty-art { - display: flex; - flex-direction: column; - gap: 4px; - padding: 16px 18px; - background: var(--bg-elev-1); - border: 1px solid var(--line); - border-radius: var(--r-md); - font-family: var(--mono); - font-size: var(--fs-sm); - text-align: left; - color: var(--text); - width: 100%; - box-shadow: var(--highlight), var(--shadow-1); -} - -.library__empty-art span:first-child, -.library__empty-art span:last-child { - color: var(--text-3); -} - -.library__empty-art span:nth-child(2) { - color: var(--text-2); - padding-left: 8px; - border-left: 1px solid var(--line-strong); - line-height: 1.5; -} - -.library__empty .t-heading, -.library__nomatch .t-heading { - margin: 8px 0 0; -} - -.library__empty .t-subheading, -.library__nomatch .t-subheading { - max-width: 48ch; - line-height: 1.55; -} - -.library__empty-actions { - margin-top: 8px; -} - -/* Responsive tweaks - --------------------------------------------------------------------------*/ - -@media (max-width: 720px) { - .library__search { - flex: 1; - width: auto; - } -} diff --git a/src/present/PresentMode.tsx b/src/present/PresentMode.tsx deleted file mode 100644 index af55d3e..0000000 --- a/src/present/PresentMode.tsx +++ /dev/null @@ -1,138 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useEffect, useMemo, useRef, useState } from 'react'; - -import { ParseError, parseDeck } from '@/ir/parse'; -import { planDeck } from '@/ir/plan'; -import type { Deck } from '@/ir/schema'; -import { DeckRenderer } from '@/render/DeckRenderer'; -import { getDeck } from '@/storage/deck-store'; - -type Props = { deckId: string }; - -export function PresentMode({ deckId }: Props) { - const router = useRouter(); - const [deck, setDeck] = useState(null); - const [error, setError] = useState(null); - const [index, setIndex] = useState(0); - const stageRef = useRef(null); - - useEffect(() => { - let cancelled = false; - getDeck(deckId).then((stored) => { - if (cancelled) return; - if (!stored) { - router.replace('/'); - return; - } - try { - const parsed = parseDeck(stored.source, { theme: stored.theme, brand: stored.brand }); - const planned = planDeck(parsed); - setDeck(planned); - } catch (e) { - const message = e instanceof ParseError ? e.message : (e as Error).message; - setError(message); - } - }); - return () => { - cancelled = true; - }; - }, [deckId, router]); - - const total = deck?.slides.length ?? 0; - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { - e.preventDefault(); - setIndex((i) => Math.min(total - 1, i + 1)); - } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { - e.preventDefault(); - setIndex((i) => Math.max(0, i - 1)); - } else if (e.key === 'Home') { - setIndex(0); - } else if (e.key === 'End') { - setIndex(total - 1); - } else if (e.key === 'Escape') { - router.push(`/d/${deckId}/edit`); - } else if (/^[0-9]$/.test(e.key)) { - const n = Number(e.key); - if (n >= 1 && n <= total) setIndex(n - 1); - } - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [total, router, deckId]); - - useEffect(() => { - const el = stageRef.current; - if (!el) return; - if (!document.fullscreenElement) { - void el.requestFullscreen?.().catch(() => {}); - } - return () => { - if (document.fullscreenElement) { - void document.exitFullscreen?.().catch(() => {}); - } - }; - }, []); - - const visibleDeck = useMemo(() => { - if (!deck) return null; - const slide = deck.slides[index]; - if (!slide) return deck; - return { ...deck, slides: [slide] }; - }, [deck, index]); - - if (error) { - return ( -
- Could not present this deck -
{error}
- -
- ); - } - - if (!deck || !visibleDeck) { - return ( -
- Loading… -
- ); - } - - return ( -
-
- -
- - - -
- ); -} diff --git a/src/present/present.css b/src/present/present.css deleted file mode 100644 index f7f407d..0000000 --- a/src/present/present.css +++ /dev/null @@ -1,116 +0,0 @@ -.present-stage { - position: fixed; - inset: 0; - background: #050505; - display: grid; - place-items: center; - overflow: hidden; - z-index: 9999; -} - -.present-frame { - width: 100vw; - height: 100vh; - display: grid; - place-items: center; -} - -.present-frame .deck { - padding: 0; - gap: 0; - width: 100%; - height: 100%; -} - -.present-frame .slide-frame { - width: min(100vw, calc(100vh * 16 / 9)); - height: min(100vh, calc(100vw * 9 / 16)); - max-width: none; - border-radius: 0; - border: none; - box-shadow: none; -} - -.present-hud { - position: absolute; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 12px; - color: rgba(255, 255, 255, 0.6); - letter-spacing: 0.12em; - font-feature-settings: 'tnum' 1; - pointer-events: none; -} - -.present-nav { - position: absolute; - top: 50%; - transform: translateY(-50%); - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.4); - font-size: 48px; - line-height: 1; - width: 64px; - height: 96px; - cursor: pointer; - transition: color 0.15s ease; - display: grid; - place-items: center; -} - -.present-nav:hover:not(:disabled) { - color: rgba(255, 255, 255, 0.9); -} - -.present-nav:disabled { - opacity: 0; - pointer-events: none; -} - -.present-nav--prev { - left: 16px; -} - -.present-nav--next { - right: 16px; -} - -.present-loading, -.present-error { - position: fixed; - inset: 0; - display: grid; - place-items: center; - background: #050505; - color: #fff; - font-family: var(--font-body, system-ui); - gap: 12px; -} - -.present-error { - padding: 32px; - text-align: center; -} - -.present-error pre { - background: rgba(255, 255, 255, 0.05); - padding: 12px 16px; - border-radius: 6px; - max-width: 80ch; - overflow: auto; - font-size: 13px; -} - -.present-error button { - margin-top: 8px; - padding: 8px 16px; - background: #fff; - color: #050505; - border: none; - border-radius: 999px; - font-weight: 500; - cursor: pointer; -} diff --git a/src/presets/dossier/CONTRACT.md b/src/presets/dossier/CONTRACT.md deleted file mode 100644 index 8dbf419..0000000 --- a/src/presets/dossier/CONTRACT.md +++ /dev/null @@ -1,127 +0,0 @@ -# Dossier preset — design contract - -This contract is binding for every Tier 1 slide composition and every CSS rule that -ships under `[data-preset='dossier']`. The goal: nine slides that read as one -publication. - -If a layout doesn't fit within these tokens, redesign the layout inside the constraints. -Never invent new type sizes, spacing values, or colors. Use the CSS variables defined -in `src/styles/dossier.css` (the `--d-*` tokens). - -## Canvas - -- 1280 × 720 (16:9). All px values reference this scale. -- Outer margins: `--d-mx` (64px) left/right, `--d-my-top` (28px) top, `--d-my-bot` (24px) bottom. -- Spine rail (body slides only): 28px wide column at x=20–48px; content area starts at `var(--d-content-x)` (92px). -- Tier 1 slides without a spine (cover, closer, section divider) start content at `--d-mx` (64px). -- Content top baseline (body slides): y=102px. Bottom baseline: y=656px. - -## Grid - -- 12 columns. Column = 76.7px. Gutter 24px. -- Use 4/8 or 6/6 splits. Never 5/7. - -## Baseline - -- 8px. Pick spacing from `--d-s1`(8) `--d-s2`(16) `--d-s3`(24) `--d-s4`(40) `--d-s5`(64). Never invent intermediate values. -- 4px is allowed only for label→value internal gaps inside a single component. - -## Color (use only these for type) - -| Token | Value | Reserved for | -| ----------------------- | ------- | ------------------------------------------------------------------------------- | -| `--color-text` | #F2EDE2 | body, titles, primary numerals | -| `--color-text-muted` | #9A9384 | secondary prose, muted leaders | -| `--color-brand` | #C8A24A | mono kicker labels, brass marks, kpi positive deltas, accent rules | -| `--color-accent` | #C8492C | NEGATIVE deltas only, rubber stamps, the closer's "Fin." mark, "Verified" stamp | -| `--color-border` | #2A2723 | hairlines | -| `--color-surface-muted` | #15130F | code blocks, outlined section numeral fill | - -Oxblood is a precision instrument. Do not use it for accents, headings, or decoration. - -## Type scale - -Display (Fraunces ITALIC, weight 400, tracking `--d-tr-display` -0.022em unless noted): - -| Var | Size/lh | Used by | -| -------------------- | -------- | ---------------------------------------- | -| `--d-D1`/`--d-lh-D1` | 168/0.92 | Closer's "Thank you." | -| `--d-D2`/`--d-lh-D2` | 128/0.94 | Cover primary title | -| `--d-D3`/`--d-lh-D3` | 96/0.98 | Section divider title | -| `--d-D4`/`--d-lh-D4` | 72/1.02 | Tier 1 figure heading, h1 on body slides | -| `--d-D5`/`--d-lh-D5` | 56/1.06 | Tear-sheet outcome, secondary title | -| `--d-D6`/`--d-lh-D6` | 40/1.10 | Pull-quote attribution name, h2-bold | -| `--d-D7`/`--d-lh-D7` | 28/1.20 | Tear-sheet field value, h3 | - -Display ROMAN: - -| Var | Used by | -| -------------- | ----------------------------------- | -| `--d-DR1` 80px | Cover sub-line (roman counterpoint) | -| `--d-DR2` 32px | Roman H2 on body slides | - -Body (Fraunces ROMAN): - -| Var | Used by | -| ---------------- | ------------------------- | -| `--d-B1` 20/1.55 | Lead paragraph | -| `--d-B2` 17/1.60 | Default body | -| `--d-B3` 14/1.50 | Small body / sidebar copy | - -Mono (JetBrains, ALL CAPS, font-weight 600, tracked): - -| Var | Tracking | Used by | -| ------------- | ------------------ | ----------------------------------- | -| `--d-M1` 12px | `--d-tr-M1` 0.24em | Figure header label, primary kicker | -| `--d-M2` 11px | `--d-tr-M2` 0.26em | Running head, leader label | -| `--d-M3` 10px | `--d-tr-M3` 0.32em | Stamp text, masthead cells | -| `--d-M4` 9px | `--d-tr-M4` 0.32em | Folio, micro-meta | - -Numerals (display, italic): - -| Var | Used by | -| ------------------------------------------------------ | ------------------------------- | -| `--d-N1` 320px / 0.85 | Hero stat single value | -| `--d-N2` 240px | Cover watermark | -| `--d-N3` 384px stroked, transparent fill, 1.5px stroke | Section outline numeral | -| `--d-N4` 72px | KPI cell value | -| `--d-N5` 104px | Before/after value | -| `--d-N6` 24px brand color | Ordered-list / finding numerals | - -Units inside numerals (`$`, `%`, `M`, `pts`): 55% of base size, italic, brand color, vertical-align 0.22em. - -## Hairlines - -- 1px solid `var(--color-border)`. -- 2px reserved for: tear-sheet outcome left rule (brass), KPI grid header divider (brass), closer FIN border (oxblood). Nothing else. - -## Dotted leaders - -- `radial-gradient(circle, var(--color-text-muted) 0.6px, transparent 0.6px)` at 6px×1px repeat-x, opacity 0.5. Standard. - -## Chrome on body slides (every Tier 1 except cover/section/closer) - -- `.dossier-runhead` at top: 28px from top, hairline below at 12px gap. -- `.dossier-spine` at left margin: vertical mono rotated -90°, brass for chapter prefix only. -- `.dossier-folio` at bottom-right: hairline (18px wide) before mono M4. - -The `BodyFrame` component already implements all three. Use it. - -## Layering (z-index) - -| z | Element | -| --- | ----------------------------------------------------- | -| 1 | watermarks, decorative outline numerals, paper grain | -| 2 | main content (titles, body, charts) | -| 3 | secondary content (kickers, dotted leaders, sidebars) | -| 4 | chrome (running head, spine, folio) | -| 5 | stamps (always on top) | - -## Forbidden - -- Drop shadows, gradients, rounded corners (>0px) anywhere except the Quote avatar circle. -- Hover states on slides (they are presentation surfaces). -- Em dashes — anywhere, including comments. -- Mixing type sizes outside the scale above. -- Spacing values outside the rhythm set. -- Oxblood used for anything other than the three reserved cases. diff --git a/src/presets/dossier/atoms/DottedLeader.tsx b/src/presets/dossier/atoms/DottedLeader.tsx deleted file mode 100644 index fd016de..0000000 --- a/src/presets/dossier/atoms/DottedLeader.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { ReactNode } from 'react'; - -/** - * A label / dotted-leader / value row, the kind that anchors a tear sheet - * or a table of contents in a print magazine. The leader fills the gap - * with a row of dots, optically aligned with the baseline of the labels. - */ -type Props = { - label: ReactNode; - value: ReactNode; - /** Adjusts the leader's prominence. Default is a fine dotted gray. */ - emphasis?: 'normal' | 'strong'; -}; - -export function DottedLeader({ label, value, emphasis = 'normal' }: Props) { - return ( -
- {label} -
- ); -} diff --git a/src/presets/dossier/atoms/Furniture.tsx b/src/presets/dossier/atoms/Furniture.tsx deleted file mode 100644 index c176c10..0000000 --- a/src/presets/dossier/atoms/Furniture.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { ReactNode } from 'react'; - -import { RunningHead } from './RunningHead'; -import { Spine } from './Spine'; - -/** - * Body slide chrome: vertical spine on the far-left margin, running head - * across the top with hairline below, drop folio at the bottom-right. - * Wrap any body slide in this so the editorial frame is consistent. - */ -type Props = { - project: string; - dossier?: string; - folio: string; - section?: string; - chapter?: string; - children: ReactNode; -}; - -export function BodyFrame({ project, dossier, folio, section, chapter, children }: Props) { - return ( - <> - - - {children} - {folio} - - ); -} diff --git a/src/presets/dossier/atoms/Masthead.tsx b/src/presets/dossier/atoms/Masthead.tsx deleted file mode 100644 index ae22654..0000000 --- a/src/presets/dossier/atoms/Masthead.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/** - * The cover masthead: VOL · ISSUE · DATE strip across the top, hairline - * below. Mirrors the way a print magazine identifies its edition before - * any feature content. - */ -type Props = { - vol?: string; - issue?: string; - date?: string; -}; - -export function Masthead({ vol = 'VOL. III', issue, date }: Props) { - return ( -
- {vol} - {issue && ( - {issue} - )} - {date && {date}} -
- ); -} diff --git a/src/presets/dossier/atoms/Monogram.tsx b/src/presets/dossier/atoms/Monogram.tsx deleted file mode 100644 index c879466..0000000 --- a/src/presets/dossier/atoms/Monogram.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Brass monogram mark, 32 x 32. A circular outline with an italic 'O' - * letterform inside. Used bottom-right of cover and closer. - */ -export function Monogram({ size = 32 }: { size?: number }) { - return ( - - - - O - - - ); -} diff --git a/src/presets/dossier/atoms/RunningHead.tsx b/src/presets/dossier/atoms/RunningHead.tsx deleted file mode 100644 index c340d8e..0000000 --- a/src/presets/dossier/atoms/RunningHead.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** - * The thin masthead-style strip that sits at the top of every body slide. - * Project name on the left, dossier id + folio on the right, hairline below. - * This is the "running head" that gives the deck its serial-publication feel. - */ -type Props = { - project: string; - dossier?: string; - folio: string; - section?: string; -}; - -export function RunningHead({ project, dossier, folio, section }: Props) { - return ( -
- - {project} - {section && — {section}} - - - {dossier && {dossier}} - {folio} - -
- ); -} diff --git a/src/presets/dossier/atoms/ScaleBar.tsx b/src/presets/dossier/atoms/ScaleBar.tsx deleted file mode 100644 index 1278aa5..0000000 --- a/src/presets/dossier/atoms/ScaleBar.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/** - * A tiny horizontal bar used to render a stat's relative magnitude against - * a peer (used in the before/after composition). The fill width is a - * percentage; the track is a hairline border. - */ -type Props = { - /** 0 to 100. */ - pct: number; - tone?: 'brass' | 'muted'; -}; - -export function ScaleBar({ pct, tone = 'brass' }: Props) { - const color = tone === 'brass' ? 'var(--color-brand)' : 'var(--color-text-muted)'; - const clamped = Math.max(0, Math.min(100, pct)); - return ( -