fix: Fix containing various changes that stacked up in a period of time (#22)

This commit is contained in:
deytenit authored and GitHub committed 2026-08-10 09:04:39 +03:00
1 parent 1b3d7cecb2
commit 77afcca98e
163 files changed
+4268 -3852

No files matched your search

+76 -400
View File
@@ -1,404 +1,80 @@
---
name: write-docs
description: 'Use this to write uniform comprehensive documentation in ermnvldmr.com context, using nextra'
description: Use when creating, updating, or refactoring technical documentation, developer guides, API references, or Hugo Hextra pages
---
# HEXTRA DOCUMENTATION ARCHITECT
**IDENTITY**
You are the **Hextra Documentation Architect**, a senior technical writer and developer advocate expert in the **Hugo** static site generator and the **Hextra** theme. You possess a deep understanding of information architecture, technical pedagogy, and the specific syntax requirements of the Hextra framework.
**GOAL**
Your sole purpose is to transform raw user input (notes, code snippets, brief explanations) into **production-ready, comprehensive developer documentation**. Your output must be valid Markdown, ready to be saved directly into a Hugo `content/` directory without requiring further editing.
**DEFINITION OF SUCCESS**
A successful output is one that a developer can copy-paste, compile with Hugo, and immediately use to solve a specific problem. It is visually structured, strictly compliant with Hextra shortcodes, and devoid of fluff or hallucinated details.
---
## 1. LANGUAGE, TONE, AND TYPOGRAPHY
You must adhere to the following strict stylistic rules. There are no exceptions to "MUST" or "MUST NOT" constraints.
### 1.1 Language & Localization
- **MUST** write in grammatically perfect **English** (or **Russian**, if explicitly requested by the prompt).
- **MUST** verify that all technical terminology matches the standard glossary of the specific domain (e.g., Hugo terms, Cloud infrastructure terms).
- **MUST** translate the entire document structure (headers, admonitions, link descriptions) when writing in a specific language. Do not mix languages unless referring to specific code entities that cannot be translated.
### 1.2 Typography & Symbols
- **MUST** use standard Unicode typography to enhance readability.
- _Allowed:_ French quotes (`«...»`), em-dashes (`—`), en-dashes (`–`), ellipses (`…`), arrows (`→`, `←`).
- **MUST NOT** use Emojis (`🚀`, `⚠️`, `✅`), Kaomojis (`(o_O)`), or non-standard decorative symbols.
- _Reasoning:_ These symbols break search functionality, interfere with copy-pasting, and degrade the professional tone of the documentation.
- **MUST NOT** use "Title Case" for every word in headers. Capitalize the first word and proper nouns only, or follow standard sentence case rules (e.g., "Architecture of module A", not "Architecture Of Module A").
### 1.3 Document Titles & Headers
- **MUST** ensure the document `title` (in Front Matter) is descriptive and summarizes the document's purpose.
- _Bad:_ "Settings"
- _Good:_ "Module A Configuration Settings"
- **MUST NOT** write a top-level H1 header (e.g., `# Title`) in the Markdown body, as Hextra automatically generates this from the Front Matter `title`.
- **MUST** use lower-case conjunctions in headers (and, or, the, of) unless they start the sentence.
### 1.4 Tone & Audience
- **MUST** adopt an **authoritative and concrete** tone.
- _Bad:_ "You should probably try to click the button..."
- _Good:_ "Click the **Submit** button."
- **MUST** accommodate different skill levels within the same documentation ecosystem:
- Provide **Quick Start** sections for beginners (minimal theory, fast results).
- Provide **How-to Guides** and **Concepts** for experienced users (complex scenarios, architectural deep-dives).
- **SHOULD** maximize "Signal-to-Noise" ratio.
- Eliminate obvious statements.
- _Bad:_ "To save the file, click save. This will save your changes to the disk."
- _Good:_ "Save the file."
- **MUST NOT** be chatty or conversational. Avoid phrases like "Let's dive in," "In this tutorial, we will learn," or "Happy coding!"
### 1.5 Content Precision
- **MUST** define clear steps, expected results, and precise formulations.
- **SHOULD** only comment on code examples if the logic is non-obvious. Do not explain standard syntax (e.g., do not explain what a `for` loop does in Python, unless the loop logic is unique to the business case).
- **MUST** actively prompt the user for missing materials if the input is insufficient.
- _Example:_ "I need the specific configuration parameters for Service X to complete this section. Please provide a link to the wiki or a code snippet."
### 1.6 Source Fidelity (Tone Aspect)
- **MUST** write _only_ what is confirmed by the provided sources.
- **MUST NOT** speculate, guess, or fill in "gray areas" with "typical" or "default" values unless explicitly instructed.
- **MUST NOT** invent "Best Practices" or "Common Mistakes" sections unless the source material explicitly lists them. Your job is to document the _actual_ system, not a _hypothetical_ ideal system.
---
## 2. SOURCE FIDELITY & DATA INTEGRITY
This is the most critical directive. You are a documentation engine, not a creative writer. You must strictly adhere to the provided source material to ensure technical accuracy.
### 2.1 The "Zero Hallucination" Policy (CRITICAL / MUST NOT)
- **MUST NOT** add "logical" assumptions, behavioral descriptions, or implementation details if they are not explicitly present in the source text or code.
- **MUST NOT** invent "Best Practices" or "Common Pitfalls" unless the source explicitly lists them.
- **MUST NOT** fabricate command-line arguments, file paths, service names, configuration parameters, or variable names. If a parameter name is missing, use a placeholder like `<variable_name>` and flag it.
### 2.2 Evidence-Based Writing (MUST)
- **MUST** ensure every technical assertion is backed by one of the provided sources (documents, user-provided files, chat logs).
- **MUST** explicitly identify missing information. If the source material is insufficient to complete a section (e.g., a missing step in a procedure):
1. Stop writing that specific section.
2. Ask the user clarifying questions.
3. List exactly what data is missing (e.g., "I need the default port number for Service X to complete the Configuration section.").
### 2.3 Handling Conflicts
- **MUST** integrate materials sequentially if multiple sources are provided. Process Source A -> Normalize headers/terms -> Process Source B.
- **MUST NOT** resolve contradictions yourself. If Source A says "Port 80" and Source B says "Port 8080", you must document the discrepancy and ask the user for the "Source of Truth."
---
## 3. FILE MANAGEMENT & DIRECTORY STRUCTURE
You must structure the output to fit a standard localized Hugo project architecture.
### 3.1 File Locations (MUST)
- **MUST** assume documentation lives in the following localized directories:
- `content/en/` (English)
- `content/ru/` (Russian)
- **MUST** provide output that is ready to be placed in these directories.
### 3.2 Asset Management (MUST)
- **MUST** assume all static assets (images, videos, PDFs) are stored in the `static/` directory at the project root.
- **MUST** format links relative to the `static` root.
- _Input:_ An image named `diagram.png` stored in `static/images/`.
- _Markdown Output:_ `![Description](/images/diagram.png)` (Note the leading slash).
- **MUST NOT** include `static/` in the markdown link path itself (e.g., `![...](static/images/...)` is **WRONG**).
### 3.3 Translation Requirements (MUST)
- **MUST** be capable of generating the document in both English and Russian upon request.
- **MUST** ensure the glossary terms (see Part 3) are consistent across both languages.
---
## 4. GLOSSARY, LINKS, & CITATIONS
A consistent glossary and linking strategy is mandatory for maintainable documentation.
### 4.1 The Glossary Standards (MUST)
The glossary is the "Source of Truth" for terminology. It resides in `(en/ru)/{project}/glossary.md`.
- **MUST** maintain the glossary in **Alphabetical Order**.
- **MUST** assign a Markdown anchor to every term in the glossary definition.
- _Format:_ `## Term Name {#term-key}`
- _Definition Format:_
```markdown
## Term Name {#term-key}
**Term Name** - A brief definition in 1-3 sentences.
```
- **MUST** link to these anchors whenever the term is first used in other documents.
- _Usage Syntax:_ `... configure the [Term Name](/glossary#term-key) settings...`
- **MUST** maintain the glossary in both English and Russian if the documentation is bilingual.
### 4.2 Hyperlink Formatting (MUST / SHOULD)
- **MUST NOT** use "naked" URLs in the text.
- _Bad:_ `Check this link: https://example.com/api`
- _Bad:_ `[https://example.com/api](https://example.com/api)`
- **MUST** format links with the **Source Type** and **Content Description**.
- _Good:_ `[Wiki: API Deployment Guide](https://example.com/api)`
- _Good:_ `[RFC: HTTP/2 Protocol](https://tools.ietf.org/html/rfc7540)`
- **SHOULD** use relative paths for internal links using Hugo `ref` or standard relative markdown paths where possible.
### 4.3 The "See Also" Footer (MUST)
Every document MUST end with a "See Also" block containing external references and last update metadata.
- **Location:** Always at the very bottom of the file.
- **Format:** Use a horizontal rule (`---`) followed by bold text.
- **Content:**
- **MUST** contain only a bulleted list of Markdown links.
- **MUST NOT** contain paths to files as inline code.
- **MUST NOT** link to the current document (self-referencing) or the Glossary (redundant).
- **Template:**
```markdown
---
**See also:**
- [Doc: Related Component A](https://docs.example.com/comp-a)
- [Wiki: Legacy Architecture](https://wiki.example.com/arch)
```
### 4.4 Code References (MUST)
- **Product Code:**
- **SHOULD NOT** paste large blocks of product source code, as it becomes stale.
- **MUST** link to a specific GitHub/GitLab commit (permalink) that shows the implementation.
- _Format:_ `[GitHub: sha - Description of change](https://github.com/org/repo/blob/sha/file.go)`
- **Commands & Scripts:**
- **MUST** use Fenced Code Blocks (````bash`) for instructions, scripts, or conceptual examples that the user needs to copy.
- **Inline Code Entities:**
- **MUST** use inline code backticks for:
- Variable names (`retryCount`)
- Function names (`init()`)
- Component names (`UserProfile`)
- Literal values (`true`, `404`)
- File extensions (`.md`, `.json`)
- Filenames (`hugo.yaml`)
---
## 5. VISUALS, UI, & HEXTRA SHORTCODES
You must use Hextra's advanced features to create structured, interactive documentation. Plain Markdown is often insufficient for complex technical concepts.
### 5.1 Images & Media (MUST)
- **Alt Text & Titles:** Every image **MUST** have alt-text and a hover title.
- _Syntax:_ `![Alt Text](./path/image.png "Hover Title" =700x)`
- **Sizing:**
- **Small/Medium Images:** **SHOULD** be resized using the width attribute (e.g., `=700x`) to prevent layout shift.
- **Large Images/Diagrams:** **MUST NOT** be resized if detail loss is a risk. Instead, wrap them in a **Details** shortcode (see below).
- **YouTube:** Use the shortcode for videos.
- _Syntax:_ `{{< youtube id="VIDEO_ID" loading="lazy" >}}`
- **PDFs:** Use the PDF shortcode for embeddings.
- _Syntax:_ `{{< pdf "/path/to/doc.pdf" >}}`
### 5.2 UI & Text Formatting (MUST)
- **"Points of Attraction":** Use **Bold** for goals, key list items, important preambles, and action names.
- _Example:_ **Goal:** Set up the environment.
- **UI References:** Use _Italics_ for buttons, labels, window names, and screen titles.
- _Example:_ Click the _Submit_ button on the _Settings_ screen.
### 5.3 Interactive Shortcodes (MUST / SHOULD)
#### A. Details (Collapsible Content)
Use for large screenshots, detailed logs, or deep-dive explanations that disrupt the flow.
```markdown
{{% details title="Click to reveal configuration details" closed="true" %}}
1. Detailed step one...
![Large Screenshot](/images/screen.png 'Settings Config')
{{% /details %}}
```
#### B. Tabs (Mutually Exclusive Content)
Use when the user must choose _one_ path (e.g., OS specific, Language specific).
```markdown
{{< tabs >}}
{{< tab name="JSON" >}} **JSON** content here... {{< /tab >}}
{{< tab name="YAML" >}} **YAML** content here... {{< /tab >}}
{{< /tabs >}}
```
#### C. Steps (Procedures)
Use for sequential instructions. **MUST** use H3 (`###`) headers inside.
```markdown
{{% steps %}}
### Step 1: Install
Run `npm install`
### Step 2: Configure
Edit `config.js`
{{% /steps %}}
```
#### D. Badges (Status/Versioning)
Use to denote version availability or feature status.
- _Options:_ `color` (green, orange, red, blue), `icon` (sparkles, github), `style` (outline/filled).
```markdown
{{< badge content="v2.0+" color="green" >}}
{{< badge content="Beta" color="orange" icon="sparkles" >}}
```
#### E. File Trees (Directory Structure)
Use to visualize folder layouts.
```markdown
{{< filetree/container >}}
{{< filetree/folder name="src" state="open" >}}
{{< filetree/file name="index.js" >}}
{{< /filetree/folder >}}
{{< /filetree/container >}}
```
#### F. Alerts/Callouts
Use GitHub-flavored alerts for emphasis.
```markdown
> [!TIP]
> This is a helpful tip.
> [!WARNING]
> This action is irreversible.
```
---
## 6. DOCUMENT TYPES & TEMPLATES
Do not output full-page boilerplate unless requested. Instead, apply these structural rules ("Skeletons") dynamically based on the content type.
This is also serves as the fixed categories which should populate every project's docs: Getting Started, Guides, Concepts, Troubleshooting.
### 6.1 General Skeleton (All Docs)
1. **Front Matter:** `title`, `weight`, `date` (optional).
2. **Brief:** 1-2 sentences summarizing the "What" and "Why".
3. **Body Sections:** (See specific types below).
4. **Glossary Tooltips:** Use `[Term](/glossary#term)` for first mentions.
5. **Footer:** The `> [!NOTE]` "See Also" block.
### 6.2 Quick Start (Beginner)
- **Goal:** Minimal steps to "Hello World".
- **Structure:**
1. **Prerequisites:** (Brief list).
2. **Steps:** Use `{{% steps %}}`. Keep theory to zero.
3. **Validation:** How to check it worked.
### 6.3 Procedure / Guide (Strict)
- **Goal:** Reproducible, audit-ready process.
- **Structure:**
1. **Objective:** What is being done.
2. **Launch Conditions:** When to start (Triggers).
3. **Procedure:** Strict numbered steps (or `{{% steps %}}`).
4. **Result Control:** How to verify success.
### 6.4 How-to / Guide (Task-Oriented)
- **Goal:** Solve a specific problem.
- **Structure:**
1. **Context:** Why do this?
2. **Steps:** Detailed execution.
3. **Notes:** Edge cases or warnings.
### 6.5 Concept / Architecture (Theory)
- **Goal:** Explain how it works.
- **Structure:**
1. **Overview:** High-level summary.
2. **Architecture:** Mermaid diagrams or Images.
3. **Components:** Description of parts.
4. **Terminology:** Definitions.
### 6.6 Troubleshooting (Diagnostic)
- **Goal:** Fix a broken state.
- **Structure:**
1. **Symptoms:** Error logs, behaviors.
2. **Diagnosis:** How to confirm the issue.
3. **Solution:** Steps to resolve.
---
## 7. EXECUTION PROTOCOL
You are now active. Your operating loop is as follows:
1. **Wait for Input:** Receive raw text, code, or a topic request.
2. **Classify:** Determine the Document Type (Quick Start, How-to, etc.).
3. **Validate Sources:** Check if you have enough info.
- _If info is missing:_ STOP and ask the user specific questions.
- _If info is contradictory:_ STOP and ask for the "Source of Truth".
4. **Draft:** Generate the Hextra Markdown.
5. **Audit:**
- Did I use `filename=""` in code blocks?
- Did I use Hextra shortcodes (`steps`, `tabs`) effectively?
- Are links descriptive (No naked URLs)?
- Is the "See Also" footer present?
- Did I avoid hallucinations?
6. **Output:** Present the final Markdown code block.
**Awaiting user input...** (Please provide the topic, raw notes, API details, or specific file fragments you want converted into documentation).
# Write Technical Documentation (ASD-STE100 & Hextra Standard)
## Overview
Technical documentation must be unambiguous, direct, and easy to translate. This skill enforces **ASD-STE100** (Simplified Technical English) language constraints combined with **Hextra/Hugo** static site architecture.
Core principle: Write with controlled vocabulary, strict sentence limits, active voice, and zero fluff.
## ASD-STE100 Language Rules
### 1. Grammar & Sentence Constraints
| Rule | Constraint | Example |
| :--- | :--- | :--- |
| **Procedural Sentence Length** | Maximum **20 words** per sentence for steps, commands, or instructions. | ✅ "Run `hugo server`. Open the local preview link." (10 words)<br>❌ "To start the local preview server, run the hugo server command and then open the generated localhost URL in your browser." (22 words) |
| **Descriptive Sentence Length** | Maximum **25 words** per sentence for explanations or concepts. | ✅ "Hugo compiles markdown files into static HTML. The Hextra theme provides search and layout components." (15 words)<br>❌ "Hugo is an exceptionally fast static site generator that compiles markdown files into static HTML while the Hextra theme adds modern documentation layouts and search capabilities." (26 words) |
| **Voice** | **Active voice** only. Avoid passive voice. | ✅ "The worker node drops expired messages."<br>❌ "Expired messages are dropped by the worker node." |
| **Tense & Mood** | Use **present tense** for descriptions and **imperative mood** for steps. | ✅ "Click **Save**. The server restarts."<br>❌ "You will click Save, after which the server will be restarting." |
| **Noun Clusters** | Maximum **3 sequential nouns**. Unpack longer clusters using prepositions. | ✅ "Index file for site search" (4 words, 2 nouns)<br>❌ "Site search index configuration file" (5 sequential nouns) |
### 2. Controlled Vocabulary
- **One approved meaning per word**: Use words in only one part of speech (e.g. *close* as verb, never adjective; use *near* or *adjacent* instead).
- **Eliminate conversational filler**: Do NOT use "let's dive in", "basically", "simply", "easily", "in order to", "feel free to".
- **Standard verbs**: Use *use* (not *utilize*), *stop* (not *terminate*), *start* (not *initiate*), *show* (not *exhibit*).
- **No metaphors or jargon**: Write explicit, literal statements.
## Hextra & Hugo Formatting
### 1. Document Structure & Front Matter
- **Front Matter**: Always include `title` and `description`.
- **Top-level Header**: **MUST NOT** include `# Title` in the markdown body. Hextra automatically renders H1 from front matter `title`.
- **Admonitions**: Use GitHub alerts (`> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!CAUTION]`).
### 2. Hextra Shortcodes
- **Steps**: Use `{{% steps %}}` with `### Step` headers for sequential procedures.
- **Tabs**: Use `{{< tabs >}}` for alternative paths (e.g. OS, languages).
- **Details**: Use `{{% details title="..." %}}` for large code dumps, raw logs, or edge cases.
### 3. Hyperlinks & Code References
- **No naked URLs**: Use `[Source Type: Description](URL)` (e.g. `[RFC: HTTP/2 Specification](https://tools.ietf.org/html/rfc7540)`).
- **Inline code**: Use backticks for filenames (`hugo.yaml`), functions (`init()`), variables (`port`), and commands.
## Document Templates (Skeletons)
### Quick Start
1. **Prerequisites**: Bullet list.
2. **Steps**: Use `{{% steps %}}`. Keep steps under 20 words each.
3. **Verification**: Direct command or test to check success.
### Procedure / How-to
1. **Objective**: 1 sentence explanation (<25 words).
2. **Procedure**: Numbered steps or `{{% steps %}}`.
3. **Expected Result**: Direct verification.
### Troubleshooting
1. **Symptom**: Concrete error message or behavior.
2. **Cause**: Direct explanation of root failure.
3. **Solution**: Imperative repair steps.
## Red Flags & Rationalization Table
| Excuse / Rationalization | Reality |
| :--- | :--- |
| "A longer sentence makes it flow better." | Long sentences cause translation errors and slow reading. Split into two sentences under 20/25 words. |
| "Passive voice sounds more formal." | Passive voice creates ambiguity about who or what performs the action. Use active voice. |
| "The noun cluster is standard industry jargon." | 4+ sequential nouns confuse non-native speakers. Unpack with prepositions. |
| "I added H1 because markdown needs a main title." | Hextra generates H1 from Front Matter. Duplicate H1 breaks page layout. |
## Verification Checklist
- [ ] Procedural sentences are **20 words or fewer**.
- [ ] Descriptive sentences are **25 words or fewer**.
- [ ] Every step uses **imperative active voice**.
- [ ] Noun clusters contain **3 or fewer sequential nouns**.
- [ ] No conversational filler ("basically", "let's dive in").
- [ ] No top-level `# H1` header in markdown body.
- [ ] All links have descriptive text and source types.
@@ -0,0 +1,361 @@
name: packages/kits - next/required
on:
push:
branches: [next]
paths:
- 'packages/kits/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/packages-kits-next-required.yml'
- 'pnpm-lock.yaml'
concurrency:
group: ci-next-kits-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
runs-on: ubuntu-latest
outputs:
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Cache pnpm store
id: cache-pnpm-store
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
build-configs:
name: Build - Configs & UI
needs: setup-stage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Rsbuild config
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Build UI
run: pnpm --filter @ermnvldmr/ui build
- name: Upload Rsbuild Config
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
retention-days: 1
- name: Upload Storybook Config
uses: actions/upload-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
retention-days: 1
- name: Upload UI dist
uses: actions/upload-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
retention-days: 1
lint-kits:
name: Lint - Kits
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Lint Kits package
run: pnpm --filter @ermnvldmr/kits lint
typecheck-kits:
name: Typecheck - Kits
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Typecheck Kits package
run: pnpm --filter @ermnvldmr/kits typecheck
test-kits:
name: Test - Unit
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Test Kits package
run: pnpm --filter @ermnvldmr/kits test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./packages/kits/coverage/lcov.info
flags: kits
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-storybook:
name: Build - Storybook
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Build Kits Storybook
run: pnpm --filter @ermnvldmr/kits build-storybook
- name: Upload Storybook build
uses: actions/upload-artifact@v4
with:
name: kits-storybook-static-${{ github.run_id }}
path: packages/kits/storybook-static/
retention-days: 1
test-storybook:
name: Test - Storybook
needs: build-storybook
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Install Playwright browsers
run: pnpm --filter @ermnvldmr/kits exec playwright install --with-deps chromium
- name: Download Storybook build
uses: actions/download-artifact@v4
with:
name: kits-storybook-static-${{ github.run_id }}
path: packages/kits/storybook-static/
- name: Test Storybook
run: pnpm --filter @ermnvldmr/kits test:storybook:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./packages/kits/coverage/storybook/lcov.info
flags: kits-storybook
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-kits:
name: Build - Kits
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Build Kits package
run: pnpm --filter @ermnvldmr/kits build
@@ -0,0 +1,361 @@
name: packages/kits - pr/required
on:
pull_request:
branches: [next]
paths:
- 'packages/kits/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'packages/i18n/**'
- '.github/workflows/packages-kits-pr-required.yml'
- 'pnpm-lock.yaml'
concurrency:
group: ci-pr-kits-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
runs-on: ubuntu-latest
outputs:
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Cache pnpm store
id: cache-pnpm-store
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
build-configs:
name: Build - Configs & UI
needs: setup-stage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Rsbuild config
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Build Storybook config
run: pnpm --filter @ermnvldmr/storybook-config build
- name: Build i18n
run: pnpm --filter @ermnvldmr/i18n build
- name: Build UI
run: pnpm --filter @ermnvldmr/ui build
- name: Upload Rsbuild Config
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
retention-days: 1
- name: Upload Storybook Config
uses: actions/upload-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
retention-days: 1
- name: Upload i18n dist
uses: actions/upload-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
retention-days: 1
- name: Upload UI dist
uses: actions/upload-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
retention-days: 1
lint-kits:
name: Lint - Kits
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Lint Kits package
run: pnpm --filter @ermnvldmr/kits lint
typecheck-kits:
name: Typecheck - Kits
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Typecheck Kits package
run: pnpm --filter @ermnvldmr/kits typecheck
test-kits:
name: Test - Unit
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Test Kits package
run: pnpm --filter @ermnvldmr/kits test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./packages/kits/coverage/lcov.info
flags: kits
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-storybook:
name: Build - Storybook
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Build Kits Storybook
run: pnpm --filter @ermnvldmr/kits build-storybook
- name: Upload Storybook build
uses: actions/upload-artifact@v4
with:
name: kits-storybook-static-${{ github.run_id }}
path: packages/kits/storybook-static/
retention-days: 1
test-storybook:
name: Test - Storybook
needs: build-storybook
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Install Playwright browsers
run: pnpm --filter @ermnvldmr/kits exec playwright install --with-deps chromium
- name: Download Storybook build
uses: actions/download-artifact@v4
with:
name: kits-storybook-static-${{ github.run_id }}
path: packages/kits/storybook-static/
- name: Test Storybook
run: pnpm --filter @ermnvldmr/kits test:storybook:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./packages/kits/coverage/storybook/lcov.info
flags: kits-storybook
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-kits:
name: Build - Kits
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Rsbuild Config
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Download Storybook Config
uses: actions/download-artifact@v4
with:
name: storybook-config-dist-${{ github.run_id }}
path: packages/storybook-config/dist
- name: Download i18n dist
uses: actions/download-artifact@v4
with:
name: i18n-dist-${{ github.run_id }}
path: packages/i18n/dist
- name: Download UI dist
uses: actions/download-artifact@v4
with:
name: ui-dist-${{ github.run_id }}
path: packages/ui/dist
- name: Build Kits package
run: pnpm --filter @ermnvldmr/kits build
+138 -435
View File
@@ -1,444 +1,147 @@
# AGENTS.md - Development Guide
# AGENTS.md
## 📋 Project Overview
Guidance and operational constraints for AI coding agents working on `ermnvldmr.com`.
**ermnvldmr.com** is Vladimir Eremin's personal ecosystem of services delivered through a modern monorepo architecture. The project encompasses personal websites, documentation sites, and shared infrastructure for web-based content and services.
## 1. System Overview & Tech Stack
### Monorepo Architecture
`ermnvldmr.com` is a pnpm monorepo hosting personal web services, dividing applications into `services/` and shared packages into `packages/`. The primary website (`services/www`) and static pages (`services/static`) are built with React 19 and Rsbuild (Rspack). Build-time Static Site Generation is performed via `@ermnvldmr/ssg`, a custom Rsbuild plugin that pre-renders React entry points to HTML with inlined critical CSS (via Critters). The documentation service (`services/docs`) runs on Hugo with the Hextra theme.
- **Repository Structure**: pnpm workspace monorepo
- **Package Manager**: pnpm v9.12.3+
- **Core Technology**: TypeScript (Strict mode)
- **Services**: Independent web services with shared dependencies
- **Packages**: Reusable configurations and component libraries
- **Build System**: Distributed builds with dependency management
- **CI/CD**: GitHub Actions with path-based triggers
Shared workspace packages provide the UI design system (`@ermnvldmr/ui`), application component kits (`@ermnvldmr/kits`), standard types library (`@ermnvldmr/stl`), localization utilities (`@ermnvldmr/i18n`), monorepo CLI tooling (`@ermnvldmr/cli`), static site generator (`@ermnvldmr/ssg`), and reusable presets for ESLint, Rsbuild, and Storybook. Design components utilize Tailwind CSS v4, Radix UI primitives, React-Aria, and CVA, validated using Vitest and Storybook 8.
### Design Philosophy
| Technology | Version / Tool | Purpose |
| :--- | :--- | :--- |
| **Package Manager** | `pnpm` (>=9.12.3) | Workspace dependency and package management |
| **Runtime Engine** | Node.js (>=20) | Server and build runtime environment |
| **UI Library** | React 19 (`react`, `react-dom`) | Declarative UI rendering |
| **Bundler Engine** | Rsbuild / Rspack | Rust-powered bundling, hot module reloading, and build pipeline |
| **Static Site Generator** | `@ermnvldmr/ssg` / Hugo (>=0.110.0) | Build-time React HTML pre-rendering & Hugo docs engine |
| **UI Primitives & Icons** | React Aria, Radix UI, Lucide React | Accessible interactive primitives and icon system |
| **Styling & Utilities** | Tailwind CSS v4, CVA, `@ermnvldmr/stl` | Utility styling, component variant management, class utilities |
| **Internationalization** | `@ermnvldmr/i18n` | Multi-locale routing and localization strings (`en`, `ru`) |
| **Testing Framework** | Vitest, Testing Library, Playwright | Unit, integration, and Storybook interaction testing |
| **Component Workshop** | Storybook 8 | Isolated component development environment |
| **Static Analysis** | ESLint 9 (Flat Config), TypeScript 5.8 | Code linting, monorepo import boundary checks, strict typing |
| **Secrets Encryption** | `@dotenvx/dotenvx` | Public-key encrypted `.env.dev` secrets management |
- **Component-First**: All UI elements developed as isolated, reusable components
- **Configuration Sharing**: Centralized tooling configurations across services
- **TypeScript-First**: Strict typing for all public APIs and components
- **Documentation-Driven**: TSDoc required for all exported elements
- **Dependency Isolation**: Clear separation between packages and services
- **Build Optimization**: Efficient dependency building and caching
## 2. Project Structure & Key Directories
## 🏗️ Repository Structure
```
├── packages/ # Shared packages and configurations
│ ├── eslint-config/ # Shared ESLint configurations
│ ├── rsbuild-config/ # Shared Rsbuild configurations
│ ├── storybook-config/ # Shared Storybook configurations
│ ├── stl/ # Standard Type Library (utilities)
│ └── ui/ # Shared React component library
├── services/ # Independent web services
│ ├── www/ # Main website (Rsbuild + React)
│ └── docs/ # Documentation site (Hugo)
├── .github/workflows/ # CI/CD automation
├── pnpm-workspace.yaml # Workspace configuration
└── [root config files] # Global configurations
```text
ermnvldmr.com/
├── ci/ # CI/CD pipelines, Docker containers, deployment scripts
├── docs/ # Architectural documentation and design specifications
├── packages/ # Monorepo shared packages and toolchain configurations
│ ├── cli/ # Monorepo CLI utility (`ermnvldmr-cli`) for tasks like S3 asset sync
│ ├── eslint-config/ # Shared flat ESLint rule sets and monorepo import restriction rules
│ ├── i18n/ # Localization engine, locale pathing, and translation keysets
│ ├── kits/ # High-level domain & layout component kits (BentoGrid, InfoCard, PageHead, Page, etc.)
│ ├── rsbuild-config/ # Shared Rsbuild and Rspack bundler configuration presets
│ ├── ssg/ # Build-time Static Site Generation plugin for Rsbuild React apps
│ ├── stl/ # Standard Types Library for core utilities, type definitions, and react helpers
│ ├── storybook-config/ # Shared Storybook workshop configurations
│ └── ui/ # Design system React component library, styles, and Storybook stories
├── services/ # Deployable web applications and services
│ ├── docs/ # Technical documentation site (Hugo + Hextra theme)
│ ├── static/ # Static landing pages service (Rsbuild + React 19)
│ └── www/ # Primary website (Rsbuild + React 19 + MDX + SSG)
├── .env.dev # Encrypted environment variable storage (tracked in git)
├── .env.keys # Private decryption keys (UNTRACKED - do not commit)
├── eslint.config.mjs # Root ESLint flat configuration extending `@ermnvldmr/eslint-config`
├── pnpm-workspace.yaml # Monorepo workspace package definitions
└── tsconfig.json # Root TypeScript configuration with workspace path aliases
```
### Package Organization
#### Configuration Packages (`packages/`)
- **eslint-config**: Shared ESLint rules for TypeScript and React
- **rsbuild-config**: Shared Rsbuild and Rspack configurations for library and service builds
- **storybook-config**: Shared Storybook setup and configurations
#### Library Packages (`packages/`)
- **stl** (Standard Type Library): Common utilities, types, and helper functions
- **ui**: React component library with design system and Storybook stories
- **i18n**: Internationalization utilities
#### Service Packages (`services/`)
- **www**: Main website built with Rsbuild, React, and Tailwind CSS
- **static**: Static landing pages service built with Rsbuild and React
- **docs**: Documentation site built with Hugo and Hextra theme
### Key Directory Rules
#### `/packages/`
- **Purpose**: Shared configurations and reusable libraries
- **Dependencies**: Can depend on each other following build order (configs → stl → ui)
- **Exports**: Must provide proper TypeScript declarations
- **Building**: Required before dependent services can build
- **Testing**: Individual test suites for each package
#### `/services/`
- **Purpose**: Independent web applications and sites
- **Dependencies**: Can consume packages but not other services
- **Deployment**: Independent deployment pipelines
- **Development**: Can be developed and tested in isolation
- **Configuration**: Inherit from shared packages but can override
## 🛠️ Development Workflow
### Monorepo Development Principles
- **Root-Level Scripts**: Check `package.json` at repository root for monorepo-wide operations
- **Service-Specific Scripts**: Each service in `services/` has its own development commands
- **Package Filtering**: Use pnpm workspace filtering to target specific packages/services
- **Dependency-Aware Building**: UI components must be built before dependent services
### Package Development Workflow
The monorepo follows a strict dependency hierarchy that must be respected:
#### 1. Configuration Packages First
Configuration packages provide shared tooling setups and must be built before anything else. Look for packages ending in `-config` in the `packages/` directory.
#### 2. Library Packages
Library packages (`stl`, `ui`) provide reusable code and components. They depend on configuration packages and must be built in dependency order (check their `package.json` dependencies).
#### 3. Services
Services consume packages but are independent of each other. They can be developed and deployed separately. Check `services/` directory for available services and their individual `package.json` files for available scripts.
### Code Organization Principles
- **Dependency Order**: configs → stl → ui → services
- **Single Responsibility**: Each package serves one clear purpose
- **Explicit Dependencies**: All inter-package dependencies declared in package.json
- **Build Isolation**: Each package can be built independently
- **Configuration Inheritance**: Services inherit from shared configurations
- **Package Entry Points**:
- `.` (Main): Runtime code used in production (e.g., `import { ... } from '@ermnvldmr/stl'`).
- `/testing`: Test-related utilities and mocks (e.g., `import { ... } from '@ermnvldmr/stl/testing'`).
- `/dev`: Build-time dependencies (e.g., `import { ... } from '@ermnvldmr/stl/dev'`).
- All other entry points are forbidden. Strictly build-time packages must still expose via `/dev`.
- **Directory Structure (Packages & Services)**:
- `src/lib/`: Helpers, utilities, and types.
- `{category}/`: Grouped logic.
- `index.ts`: Barrel export for all of `lib/`.
- `testing.ts`: Testing utilities for the library.
- `dev.ts`: Build-time utilities for the library.
- `src/components/`: UI components.
- `{ComponentName}/`: Isolated component directory.
- `index.ts`: Barrel export for all components.
- `testing.ts`: Component-specific testing utilities.
- `src/static/`: Static assets (`.html`, `.css`, `.js`) directly in this directory (no subdirectories).
- `src/app/` (Services only): Page definitions.
- `{page}.tsx`: Single file per page, inlining the component in `createPage`.
- `{path}/{page}.tsx`: Nested routing structure.
- Top-level `src/index.ts`, `src/testing.ts`, and `src/dev.ts` should only re-export from `lib/` or `components/`.
## 📝 Coding Conventions
### Package Naming Convention
All packages use the `@ermnvldmr/` namespace:
- `@ermnvldmr/eslint-config` - Shared ESLint configurations
- `@ermnvldmr/rsbuild-config` - Shared Rsbuild configurations
- `@ermnvldmr/storybook-config` - Shared Storybook configurations
- `@ermnvldmr/stl` - Standard Type Library
- `@ermnvldmr/ui` - Component library
- `@ermnvldmr/i18n` - Internationalization utilities
- `@ermnvldmr/www` - Main website service
- `@ermnvldmr/static` - Static landing pages service
- `@ermnvldmr/docs` - Documentation service
### TypeScript Guidelines
- **Strict Mode**: All TypeScript strict checks must pass
- **No 'any'**: The use of `any` is strictly prohibited. Use `unknown` if the type is truly unknown, or define proper generic constraints.
- **No 'ts-ignore' or 'ts-expect-error'**: Never suppress compiler errors. If a third-party library has missing types, provide a local declaration file (.d.ts) instead.
- **No Unsafe Assertions**: Avoid `as any` or `as T` assertions. Prefer type guards, `instanceof` checks, or proper schema validation (e.g., Zod) to narrow types.
- **Explicit Types**: Avoid type inference for public APIs; prefer explicit type definitions for exports.
- **Interface First**: Use interfaces for component props and public APIs.
- **Cross-Package Types**: Export types from packages for service consumption.
### TSDoc Documentation Requirements
**MANDATORY for all exported elements**: types, functions, constants, components
```typescript
/**
* Combines class names using clsx and tailwind-merge for consistent styling.
*
* @param inputs - Class name inputs to combine
* @returns Combined and deduplicated class string
*
* @example
* \`\`\`typescript
* cn('px-4 py-2', 'bg-blue-500', 'px-6') // 'py-2 bg-blue-500 px-6'
* cn('text-lg', condition && 'font-bold') // 'text-lg font-bold'
* \`\`\`
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
```
### Internationalization (i18n) Guidelines
- **Package**: Use the `@ermnvldmr/i18n` package for all translations across services.
- **Keyset Pattern**: Create `.i18n.ts` files alongside components that require translation.
- **Russian Translation Style**: Embrace the Russian language fully. Prefer phonetic transliteration/transcription for global brand names and descriptive terms over keeping them in English.
- **Brands**: 'GitHub' → 'ГитХаб', 'Instagram' → 'Инстаграм', 'Telegram' → 'Телеграм'.
- **Descriptive**: 'Email' → 'Электропочта'.
- **Source of Truth**: The English locale (`en`) serves as the primary reference for keys and structure.
### Component Development Patterns
#### UI Package Components
```typescript
/**
* Props for the Button component.
*/
export interface ButtonProps {
/** The button content */
children: React.ReactNode;
/** Visual style variant */
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
/** Button size */
size?: 'default' | 'sm' | 'lg' | 'icon';
}
/**
* Versatile button component with multiple variants and sizes.
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
```
### File Naming Conventions
- **Components**: PascalCase (e.g., `Button.tsx`, `NavigationMenu.tsx`)
- **Utilities**: camelCase (e.g., `cn.ts`, `formatters.ts`)
- **Constants**: UPPER_SNAKE_CASE for exports (e.g., `API_ENDPOINTS`)
- **Types**: PascalCase with descriptive suffixes (e.g., `ButtonProps`, `ApiResponse`)
- **Config Files**: kebab-case (e.g., `rsbuild-config`, `eslint-config`)
### Module Structure Patterns
#### Package Structure
```
packages/ui/
├── .config/
│ ├── vitest/
│ │ └── setup.ts
│ └── storybook/
│ ├── main.ts
│ └── preview.tsx
├── src/
│ ├── components/ # Component categories
│ │ ├── index.ts # Production barrel
│ │ ├── testing.ts # Testing barrel
│ │ └── Button/ # Isolated component
│ ├── lib/ # Core logic
│ │ ├── index.ts
│ │ ├── testing.ts
│ │ └── dev.ts
│ ├── static/ # Flat static assets (CSS, etc.)
│ ├── index.ts # Main entry point (runtime)
│ ├── testing.ts # Testing entry point
│ └── dev.ts # Dev entry point
├── package.json
├── rsbuild.config.ts
└── tsconfig.json
```
#### Service Structure (www example)
```
services/www/
├── .config/
│ ├── vitest/
│ │ └── setup.ts
│ └── storybook/
├── src/
│ ├── app/ # Single file per page
│ │ ├── index.tsx # Inlined createPage
│ │ └── articles/
│ ├── components/ # layouts, widgets
│ ├── lib/ # core, shared
│ ├── static/ # global.css, index.html
│ ├── index.ts
│ └── ...
├── public/ # Direct static assets (images)
├── rsbuild.config.ts
└── package.json
```
## 🎨 Design System (UI Package)
### Component Organization
- **Generic Components**: Layout utilities (Stack, VStack, HStack)
- **Themed Components**: Paper-themed variants with consistent styling
- **Radix Integration**: Accessibility-first components using Radix UI
- **Storybook Stories**: Every component has comprehensive documentation
### Styling Approach
- **Tailwind CSS v4+**: Utility-first CSS framework
- **Class Variance Authority**: Type-safe variant generation
- **Custom Design Tokens**: Consistent spacing, colors, typography
- **Responsive Design**: Mobile-first responsive patterns
### Component Categories
```typescript
// Generic Layout Components
export { Stack } from './components/generic/Stack/Stack';
export { VStack } from './components/generic/VStack/VStack';
export { HStack } from './components/generic/HStack/HStack';
// Themed Components (Paper)
export { Button } from './components/paper/PaperButton/Button';
export { Separator } from './components/paper/PaperSeparator/Separator';
```
## ⚙️ Configuration Management
### Shared Configurations
Each configuration package provides standardized setups:
#### ESLint Configuration
```javascript
// Provides: base, react configurations
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
```
#### Vitest Configuration
```typescript
// Provides: base, react configurations from rsbuild-config
import { baseVitestConfig, reactVitestConfig } from '@ermnvldmr/rsbuild-config/dev';
import { mergeConfig } from 'vitest/config';
```
#### Rsbuild Configuration
```typescript
// Provides: definePackageConfig, defineServiceConfig
import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
```
### Service Configuration Inheritance
Services inherit from shared configs but can override:
```typescript
// In service package.json devDependencies
"@ermnvldmr/eslint-config": "workspace:*"
"@ermnvldmr/rsbuild-config": "workspace:*"
```
## 🚀 Build & Deployment
### Build Dependencies
The monorepo must respect a strict build order due to package dependencies:
1. **Configuration packages** (eslint-config, rsbuild-config, etc.) - Foundation tooling
2. **STL package** - Shared utilities and types
3. **UI package** - Component library (depends on STL)
4. **Services** - Applications that consume packages (www, docs)
### CI/CD Pipeline Structure
The repository uses path-based CI/CD triggers - each package and service has dedicated workflows in `.github/workflows/`. Examine the workflow files to understand:
- **Trigger Patterns**: Which file changes activate each pipeline
- **Build Dependencies**: How packages are built in sequence
- **Quality Gates**: Linting, type-checking, and testing requirements
- **Deployment Targets**: Where each service is deployed
## 🔧 Development Environment
### Prerequisites
- **Node.js**: v20+
- **pnpm**: v9.12.3+
- **TypeScript**: v5.8+
- **Hugo**: v0.110.0+ (for docs service)
### Initial Setup
To start development, follow the dependency hierarchy:
1. **Install dependencies** using the root `package.json` scripts
2. **Build configuration packages** first (check packages ending in `-config`)
3. **Build library packages** in dependency order (STL before UI)
4. **Start services** after dependencies are built
### Development Commands
The monorepo provides scripts at multiple levels:
- **Root Level**: Check root `package.json` for monorepo-wide operations
- **Package Level**: Each package has its own build, test, and development scripts
- **Service Level**: Services have development servers, build commands, and testing
Use pnpm workspace filtering (`--filter`) to target specific packages or services. Examine individual `package.json` files to discover available scripts for each component.
## 🧪 Testing Strategy
### Testing Framework Distribution
- **Shared Configuration**: Vitest configs in `@ermnvldmr/rsbuild-config`
- **Package Testing**: Each package has its own test suite
- **Service Testing**: Services test their specific functionality
- **Component Testing**: UI package uses React Testing Library with Vitest
- **Storybook Testing**: Visual regression and interaction testing
### Test Organization
```typescript
// Package-level testing (packages/stl/src/functions/cn/cn.test.ts)
describe('cn function', () => {
it('combines class names correctly', () => {
expect(cn('px-4', 'py-2')).toBe('px-4 py-2');
});
});
// Component testing (packages/ui/src/components/paper/Button/Button.test.tsx)
describe('components/paper/Button/Button', () => {
it('renders with correct variant', () => {
render(<Button variant="destructive">Test</Button>);
expect(screen.getByRole('button')).toHaveClass('destructive');
});
});
```
### Coverage Strategy
- **Package Coverage**: Individual coverage reports per package
- **Service Coverage**: Service-specific coverage including Storybook tests
- **Aggregated Coverage**: Combined reporting for the entire monorepo
---
_This guide serves as the architectural foundation for the monorepo. Focus on these stable patterns and structures rather than specific implementation details that may change over time._
## 🤖 AI Agent Best Practices
The following are explicit best practices and preferences intended for AI tools and agents operating within this repository:
- **Build System Preference**: Rsbuild and Rspack are the standard and strongly preferred build tools for all projects in this monorepo due to their Rust-based architecture and performance. Do not introduce Vite, Astro, or Webpack unless strictly necessary.
- **Framework Preference**: The monorepo has migrated away from Astro in favor of React + Rsbuild (with MDX support).
- **Content Sites**: Rspress is the preferred infrastructure for any new content-heavy or documentation sites.
## 3. Setup & Commands
### CLI Commands
- **Install dependencies**: `pnpm install`
- **Development servers**:
- Website (`services/www`): `pnpm www:dev` (or `pnpm dev:www`)
- Documentation (`services/docs`): `pnpm docs:dev` (or `pnpm dev:docs`)
- UI Component Storybook (`packages/ui`): `pnpm ui:storybook`
- Static landings (`services/static`): `pnpm static:dev` (or `pnpm dev:static`)
- UI package build watcher: `pnpm ui:dev`
- **Build**:
- Full workspace build: `pnpm build`
- Workspace package dependencies only: `pnpm build:deps`
- Individual package build: `pnpm --filter @ermnvldmr/ui build`
- **Linting & Formatting**:
- Lint workspace: `pnpm lint`
- Typecheck workspace: `pnpm typecheck`
- Format files: `pnpm format`
- **Testing**:
- Run full test suite: `pnpm test`
- Test single package: `pnpm --filter @ermnvldmr/ui test`
- Test single file: `pnpm --filter @ermnvldmr/ui exec vitest run src/components/Button/Button.test.tsx`
- Test coverage: `pnpm test:coverage`
### Environment Setup
- Environment variables are managed using `@dotenvx/dotenvx`.
- `.env.dev` contains encrypted values and is tracked in git.
- `.env.keys` contains local decryption keys (`DOTENV_PRIVATE_KEY`) and must never be committed.
- Environment variables:
- `LOCALE`: Target build language (`en` or `ru`, defaults to `en`).
- `RSDOCTOR`: Set `RSDOCTOR=true` during build to trigger Rsbuild bundle analysis.
- S3 static sync credentials: `COM_ERMNVLDMR_STATIC_S3_BUCKET`, `COM_ERMNVLDMR_STATIC_S3_REGION`, `COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL`, `COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID`, `COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY`.
## 4. Non-Negotiable Coding Rules & Patterns
### Typing & Documentation Rules
- **Strict TypeScript**: `strict: true` is enforced globally.
- **Forbidden Types**: `any` is strictly prohibited (`@typescript-eslint/no-explicit-any: error`).
- **Forbidden Assertions**: Type assertions (`as Type`) are strictly prohibited (`@typescript-eslint/consistent-type-assertions: ['error', { assertionStyle: 'never' }]`). Use explicit type guards or type casting helpers (e.g., `castRef` from `@ermnvldmr/stl`).
- **Forbidden Non-Null Assertion**: Non-null assertion operator (`!`) is prohibited (`@typescript-eslint/no-non-null-assertion: error`).
- **Explicit Type Imports**: Imports used strictly for types MUST use `import type` (`@typescript-eslint/consistent-type-imports: error`).
- **Interface Definitions**: Object shapes MUST use `interface`, not `type` aliases (`@typescript-eslint/consistent-type-definitions: ['error', 'interface']`).
- **TSDoc Requirements**: JSDoc comments are required on all exported function declarations, interfaces, and type declarations (`jsdoc/require-jsdoc: error`). A description string is mandatory (`jsdoc/require-description: error`).
### Component Architecture & Structure
- Component implementations in `packages/ui` MUST live in dedicated PascalCase subdirectories:
`packages/ui/src/components/<ComponentName>/`
- `<ComponentName>.tsx` — Component logic and JSX
- `<ComponentName>.stories.tsx` — Storybook stories
- `<ComponentName>.test.tsx` — Vitest unit tests
- **Styling**: Component variant logic must use `cva` (class-variance-authority). Utility class merging must use `cn(...)` from `@ermnvldmr/stl`.
- **Accessibility**: Interactive elements must leverage `react-aria` hooks (`useButton`, `useFocusRing`, `useHover`) to maintain accessibility standards.
- **Naming Conventions**:
- PascalCase for React component files and directories (`Button/Button.tsx`).
- camelCase for helper modules, hooks, and utilities (`path.ts`, `useTheme.ts`).
- kebab-case for configuration files (`eslint.config.mjs`, `postcss.config.mjs`).
### State Management & Import Rules
- **State Primitives**: Use standard React state (`useState`, `useReducer`, `useContext`) and `react-stately` / `react-aria` primitives. Do not introduce unapproved third-party state managers.
- **Monorepo Relative Imports**: Code inside a package or service MUST use relative paths when referencing files within that same package. Importing the package's own published name inside itself (e.g. `import ... from '@ermnvldmr/ui'` inside `packages/ui`) is strictly prohibited and fails ESLint checks.
### Anti-Patterns ("What NOT to do")
- **DO NOT** use `any`, `as Type` assertions, or `!` non-null assertions.
- **DO NOT** introduce Vite, Webpack, or Next.js configurations. Rsbuild (Rspack) is the monorepo's standardized React bundler.
- **DO NOT** use self-referential package imports within workspace packages; use relative paths.
- **DO NOT** suppress linter or compiler errors with `@ts-ignore`, `@ts-nocheck`, or inline eslint disables without explicit user approval.
- **DO NOT** commit unencrypted `.env` files or secret keys (`.env.keys`).
- **DO NOT** run `git commit` or execute automated git commits under any circumstances.
## 5. Critical Workflows & Safety Directives
### Verification Workflow
Before completing any task, run and pass every step in the following sequence:
1. `pnpm typecheck` — 0 TypeScript compiler errors.
2. `pnpm lint` — 0 ESLint warnings (`--max-warnings 0`).
3. `pnpm test` — All unit and component tests passing.
4. `pnpm build` — Clean workspace compilation.
### Protected Boundaries
The agent MUST NOT modify the following files or directories unless explicitly requested by the user:
- `.env.keys` (untracked decryption keys).
- `pnpm-lock.yaml` (dependencies must be managed exclusively through `pnpm` CLI commands).
- `LICENSE` file.
- `ci/` deployment configurations, Dockerfiles, and GitHub Actions workflows.
- `services/docs/content/` and `services/www/content/` creative and literary works.
+5 -2
View File
@@ -29,9 +29,12 @@ Composite (see [Copyright & License](#copyright--license))
#### Packages
- **[@ermnvldmr/ui](./packages/ui)**: Shared React component library and design system.
- **[@ermnvldmr/stl](./packages/stl)**: Standard Template Library – common utilities and reusable logic.
- **[@ermnvldmr/ui](./packages/ui)**: Shared React component library and design system primitives.
- **[@ermnvldmr/kits](./packages/kits)**: High-level application & layout component kits (`BentoGrid`, `InfoCard`, `PageHead`, `Page`, `Time`, `Markdown`, `ThemeSwitch`).
- **[@ermnvldmr/stl](./packages/stl)**: Standard Types Library – core utilities, type definitions, and React helpers.
- **[@ermnvldmr/i18n](./packages/i18n)**: Internationalization utilities.
- **[@ermnvldmr/ssg](./packages/ssg)**: Build-time Static Site Generation plugin for Rsbuild.
- **[@ermnvldmr/cli](./packages/cli)**: Monorepo CLI utility (`ermnvldmr-cli`).
- **[@ermnvldmr/eslint-config](./packages/eslint-config)**: Shared linting configurations.
- **[@ermnvldmr/rsbuild-config](./packages/rsbuild-config)**: Shared build configurations (Rsbuild & Rspack).
- **[@ermnvldmr/storybook-config](./packages/storybook-config)**: Shared Storybook configurations.
+10 -5
View File
@@ -23,6 +23,10 @@
"ui:build": "pnpm --filter @ermnvldmr/ui build",
"ui:test": "pnpm --filter @ermnvldmr/ui test",
"ui:storybook": "pnpm --filter @ermnvldmr/ui storybook",
"kits:dev": "pnpm --filter @ermnvldmr/kits dev",
"kits:build": "pnpm --filter @ermnvldmr/kits build",
"kits:test": "pnpm --filter @ermnvldmr/kits test",
"kits:storybook": "pnpm --filter @ermnvldmr/kits storybook",
"www:dev": "pnpm --filter @ermnvldmr/www dev",
"www:build": "pnpm --filter @ermnvldmr/www build",
"www:storybook": "pnpm --filter @ermnvldmr/www storybook",
@@ -33,18 +37,19 @@
"static:lint": "pnpm --filter @ermnvldmr/static lint",
"static:typecheck": "pnpm --filter @ermnvldmr/static typecheck",
"dev:ui": "pnpm ui:dev",
"dev:www": "pnpm ui:build && pnpm www:dev",
"dev:static": "pnpm ui:build && pnpm static:dev",
"dev:kits": "pnpm kits:dev",
"dev:www": "pnpm ui:build && pnpm kits:build && pnpm www:dev",
"dev:static": "pnpm ui:build && pnpm kits:build && pnpm static:dev",
"dev:docs": "pnpm docs:dev",
"build": "pnpm ui:build && pnpm -r --filter='./services/*' build",
"build:storybooks": "pnpm ui:build && pnpm --filter @ermnvldmr/ui build-storybook && pnpm --filter @ermnvldmr/www build-storybook",
"build": "pnpm ui:build && pnpm --filter @ermnvldmr/kits build && pnpm -r --filter='./services/*' build",
"build:storybooks": "pnpm ui:build && pnpm --filter @ermnvldmr/kits build && pnpm --filter @ermnvldmr/ui build-storybook && pnpm --filter @ermnvldmr/kits build-storybook && pnpm --filter @ermnvldmr/www build-storybook",
"lint": "pnpm -r lint",
"format": "prettier --write .",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"test:coverage": "pnpm -r test:coverage",
"clean": "pnpm -r clean && rm -rf node_modules",
"build:deps": "pnpm -r --filter='./packages/*-config' build && pnpm --filter='./packages/i18n' build"
"build:deps": "pnpm -r --filter='./packages/*-config' build && pnpm --filter='./packages/i18n' build && pnpm --filter='./packages/ui' build && pnpm --filter='./packages/kits' build"
},
"devDependencies": {
"globals": "^16.0.0",
+3 -2
View File
@@ -11,7 +11,7 @@ vi.mock('node:fs', () => ({
}));
describe('env', () => {
beforeEach(async () => {
beforeEach(() => {
// Reset module registry so the `loaded` singleton resets between tests
vi.resetModules();
});
@@ -24,7 +24,8 @@ describe('env', () => {
env('TEST_VAR');
expect(config).toHaveBeenCalledTimes(1);
expect(vi.mocked(config).mock.calls[0]![0]).toMatchObject({ path: expect.stringContaining('.env.dev') });
const firstCall = vi.mocked(config).mock.calls[0];
expect(firstCall[0]).toMatchObject({ path: expect.stringContaining('.env.dev') });
delete process.env.TEST_VAR;
});
+13 -2
View File
@@ -1,7 +1,8 @@
import { config } from '@dotenvx/dotenvx';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { config } from '@dotenvx/dotenvx';
let loaded = false;
/**
@@ -12,11 +13,16 @@ let loaded = false;
* @param start - Directory to begin the search from.
* @returns The path of the monorepo root directory, or `start` as a fallback.
*
* @example
* ```typescript
* const root = findRoot(process.cwd());
* ```
*
* @internal
*/
function findRoot(start: string): string {
let dir = start;
while (true) {
for (;;) {
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) {
return dir;
}
@@ -35,6 +41,11 @@ function findRoot(start: string): string {
* `.env.dev` file located there. Safe to call multiple times — runs only once per
* process.
*
* @example
* ```typescript
* ensureLoaded();
* ```
*
* @internal
*/
function ensureLoaded(): void {
+2 -1
View File
@@ -150,7 +150,8 @@ export async function runS3Sync(
const getS3Url = (finalName: string): string => {
const normalizedKey = normalizeKey(cmd.targetPrefix, finalName);
if (endpoint) {
return `https://${bucket}/${normalizedKey}`;
const cleanEndpoint = endpoint.replace(/\/+$/, '');
return `${cleanEndpoint}/${bucket}/${normalizedKey}`;
}
return `https://${bucket}.s3.${region}.amazonaws.com/${normalizedKey}`;
};
+55
View File
@@ -0,0 +1,55 @@
import { pluginBabel } from '@rsbuild/plugin-babel';
import { resolve } from 'path';
import type { StorybookConfig } from 'storybook-react-rsbuild';
const config: StorybookConfig = {
stories: ['../../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'@storybook/addon-themes',
],
framework: {
name: 'storybook-react-rsbuild',
options: {},
},
typescript: {
check: false,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
rsbuildFinal: (config) => {
config.resolve = config.resolve || {};
config.resolve.alias = {
...config.resolve.alias,
'@ermnvldmr/stl': resolve(__dirname, '../../stl/src'),
'@ermnvldmr/ui': resolve(__dirname, '../../ui/src'),
};
config.plugins = config.plugins || [];
config.plugins.push(
pluginBabel({
include: /\.(?:jsx|tsx)$/,
exclude: /[\\/]node_modules[\\/]/,
babelLoaderOptions: (opts) => {
opts.plugins = opts.plugins || [];
opts.plugins.push([
'babel-plugin-istanbul',
{
exclude: ['**/*.stories.tsx', '**/*.test.tsx', 'node_modules/**'],
extension: ['.js', '.jsx', '.ts', '.tsx'],
},
]);
},
})
);
return config;
},
};
export default config;
@@ -0,0 +1,15 @@
import type { Preview } from '@storybook/react';
import '../../../ui/src/static/styles.css';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
+48
View File
@@ -0,0 +1,48 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
class ResizeObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
global.ResizeObserver = ResizeObserverMock;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// jsdom sometimes does not expose localStorage.clear(); provide a functional polyfill
if (typeof window.localStorage.clear !== 'function') {
const store = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
getItem: (key: string): string | null => store.get(key) ?? null,
setItem: (key: string, value: string): void => {
store.set(key, value);
},
removeItem: (key: string): void => {
store.delete(key);
},
clear: (): void => {
store.clear();
},
key: (index: number): string | null => [...store.keys()][index] ?? null,
get length(): number {
return store.size;
},
},
});
}
+75
View File
@@ -0,0 +1,75 @@
{
"name": "@ermnvldmr/kits",
"version": "1.0.0",
"description": "Composed UI pattern assemblies and kit components",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/kits/src/index.d.ts",
"default": "./src/index.ts"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "rsbuild build --watch",
"build": "rsbuild build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"storybook": "storybook dev -p 6008 -c .config/storybook",
"build-storybook": "storybook build -c .config/storybook",
"test:storybook": "test-storybook --config-dir .config/storybook",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"http-server storybook-static --port 6008 --silent\" \"wait-on tcp:127.0.0.1:6008 && test-storybook --coverage --config-dir .config/storybook --url http://127.0.0.1:6008\"",
"lint": "eslint src --max-warnings 0",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist coverage storybook-static"
},
"dependencies": {
"@ermnvldmr/i18n": "workspace:*",
"@ermnvldmr/stl": "workspace:*",
"@ermnvldmr/ui": "workspace:*",
"class-variance-authority": "^0.7.1"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*",
"@ermnvldmr/rsbuild-config": "workspace:*",
"@ermnvldmr/storybook-config": "workspace:*",
"@rsbuild/core": "^1.2.11",
"@rsbuild/plugin-babel": "^1.1.0",
"@storybook/addon-a11y": "^8.6.15",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/addon-interactions": "^8.6.15",
"@storybook/addon-themes": "^8.6.15",
"@storybook/react": "^8.6.15",
"@storybook/test": "^8.6.15",
"@storybook/test-runner": "^0.19.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.20.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-istanbul": "^7.0.1",
"concurrently": "^9.1.2",
"http-server": "^14.1.1",
"jsdom": "^28.0.0",
"playwright": "^1.57.0",
"storybook": "^8.6.17",
"storybook-react-rsbuild": "^1.0.3",
"typescript": "^5.8.3",
"vitest": "^4.0.18",
"wait-on": "^8.0.3"
},
"peerDependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}
+43
View File
@@ -0,0 +1,43 @@
import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
export default definePackageConfig({
source: {
entry: {
index: './src/index.ts',
},
},
output: {
distPath: {
root: 'dist',
js: '',
},
filename: {
js: '[name].js',
},
cleanDistPath: true,
},
tools: {
rspack: {
output: {
library: {
type: 'module',
},
},
experiments: {
outputModule: true,
},
externals: [
'react',
'react-dom',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'tailwindcss',
'@ermnvldmr/i18n',
'@ermnvldmr/stl',
'@ermnvldmr/ui',
'class-variance-authority',
],
externalsType: 'module',
},
},
});
@@ -0,0 +1,30 @@
import React from 'react';
import { BentoGrid } from './BentoGrid';
import { InfoCard } from '../InfoCard/InfoCard';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof BentoGrid> = {
title: 'Kits/BentoGrid',
component: BentoGrid,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof BentoGrid>;
export const Default: Story = {
render: () => (
<BentoGrid>
<InfoCard colSpan={2}>
<InfoCard.Header>Featured Project</InfoCard.Header>
<InfoCard.Body>This is a 2-column wide featured bento grid item.</InfoCard.Body>
</InfoCard>
<InfoCard colSpan={2}>
<InfoCard.Header>Secondary Project</InfoCard.Header>
<InfoCard.Body>Another 2-column bento card.</InfoCard.Body>
</InfoCard>
</BentoGrid>
),
};
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { BentoGrid } from './BentoGrid';
describe('BentoGrid', () => {
it('renders children correctly inside grid container', () => {
render(
<BentoGrid data-testid="bento-grid">
<div>Item 1</div>
<div>Item 2</div>
</BentoGrid>
);
const grid = screen.getByTestId('bento-grid');
expect(grid).toBeInTheDocument();
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
});
it('applies custom className', () => {
render(
<BentoGrid className="custom-class" data-testid="bento-grid">
<div>Content</div>
</BentoGrid>
);
const grid = screen.getByTestId('bento-grid');
expect(grid).toHaveClass('custom-class');
});
});
@@ -0,0 +1,32 @@
import { cn, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
/**
* Props for the BentoGrid component.
*/
export interface BentoGridProps extends React.HTMLAttributes<HTMLDivElement> {
/** Grid items to be rendered */
children: React.ReactNode;
/** Additional CSS class names */
className?: string;
}
const BentoGridComponent = forwardRef<HTMLDivElement, BentoGridProps>(function BentoGrid(
{ children, className, ...props },
ref
) {
return (
<div
{...props}
ref={ref}
className={cn('grid w-full grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4', className)}
>
{children}
</div>
);
});
/**
* A responsive grid container layout component for bento-style card assemblies.
*/
export const BentoGrid = genericMemo(BentoGridComponent);
@@ -0,0 +1 @@
export * from './BentoGrid';
@@ -0,0 +1,42 @@
import React from 'react';
import { InfoCard } from './InfoCard';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof InfoCard> = {
title: 'Kits/InfoCard',
component: InfoCard,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof InfoCard>;
export const Default: Story = {
render: () => (
<InfoCard>
<InfoCard.Header>InfoCard Title</InfoCard.Header>
<InfoCard.Body>This is the body content of the InfoCard.</InfoCard.Body>
<InfoCard.Footer>Footer metadata</InfoCard.Footer>
</InfoCard>
),
};
export const PrimaryVariant: Story = {
render: () => (
<InfoCard variant="primary">
<InfoCard.Header>Primary Featured Card</InfoCard.Header>
<InfoCard.Body>Primary card content with brand accent background.</InfoCard.Body>
</InfoCard>
),
};
export const TransparentBackground: Story = {
render: () => (
<InfoCard bg="transparent">
<InfoCard.Header>Transparent Card</InfoCard.Header>
<InfoCard.Body>Transparent surface card content.</InfoCard.Body>
</InfoCard>
),
};
@@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { InfoCard } from './InfoCard';
describe('InfoCard', () => {
it('renders header, body, and footer content', () => {
render(
<InfoCard data-testid="info-card">
<InfoCard.Header>Card Header</InfoCard.Header>
<InfoCard.Body>Card Body Content</InfoCard.Body>
<InfoCard.Footer>Card Footer Content</InfoCard.Footer>
</InfoCard>
);
expect(screen.getByTestId('info-card')).toBeInTheDocument();
expect(screen.getByText('Card Header')).toBeInTheDocument();
expect(screen.getByText('Card Body Content')).toBeInTheDocument();
expect(screen.getByText('Card Footer Content')).toBeInTheDocument();
});
it('renders primary variant correctly', () => {
render(
<InfoCard data-testid="info-card-primary" variant="primary">
<InfoCard.Header>Primary Title</InfoCard.Header>
</InfoCard>
);
const card = screen.getByTestId('info-card-primary');
expect(card).toHaveClass('bg-primary');
});
it('renders accordion list sections', () => {
render(
<InfoCard>
<InfoCard.List
sections={[
{
value: 'sec1',
label: 'Section 1',
items: ['Item A', 'Item B'],
},
]}
/>
</InfoCard>
);
expect(screen.getByText('Section 1')).toBeInTheDocument();
});
it('renders InfoCard.Image with title and description overlay', () => {
render(
<InfoCard.Image
alt="Test Image"
className="custom-img"
overlayDescription="Image Overlay Description"
overlayTitle="Image Overlay Title"
src="/test-image.jpg"
/>
);
expect(screen.getByAltText('Test Image')).toBeInTheDocument();
expect(screen.getByText('Image Overlay Title')).toBeInTheDocument();
expect(screen.getByText('Image Overlay Description')).toBeInTheDocument();
});
it('renders accordion list sections with object items', () => {
render(
<InfoCard>
<InfoCard.List
sections={[
{
value: 'sec1',
label: 'Section 1',
items: [
{ key: 'obj-item-key', node: 'Object Item Content' },
{ node: 'Object Item Without Key' },
<span key="jsx-key">JSX Item Content</span>,
<span>JSX Element Without Key</span>,
],
},
]}
/>
</InfoCard>
);
expect(screen.getByText('Section 1')).toBeInTheDocument();
});
it('forwards ref to underlying DOM element', () => {
const ref = React.createRef<HTMLDivElement>();
render(<InfoCard ref={ref}>Card Content</InfoCard>);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
expect(ref.current?.textContent).toBe('Card Content');
});
});
@@ -0,0 +1,84 @@
import { castRef, cn, genericMemo } from '@ermnvldmr/stl';
import { Container, cva } from '@ermnvldmr/ui';
import React, { forwardRef } from 'react';
import { InfoCardBody } from './InfoCardBody';
import { InfoCardFooter } from './InfoCardFooter';
import { InfoCardHeader } from './InfoCardHeader';
import { InfoCardImage } from './InfoCardImage';
import { InfoCardList } from './InfoCardList';
import type { InfoCardProps } from './types';
const infoCardVariants = cva(
'relative overflow-hidden rounded-xl border border-outline p-6 shadow-sm transition-all hover:shadow-md flex flex-col justify-between gap-4',
{
variants: {
variant: {
default: '',
primary: 'bg-primary border-primary text-primary-text',
},
colSpan: {
1: 'md:col-span-1',
2: 'md:col-span-2',
3: 'md:col-span-3',
4: 'md:col-span-4',
},
rowSpan: {
1: 'md:row-span-1',
2: 'md:row-span-2',
3: 'md:row-span-3',
},
},
defaultVariants: {
variant: 'default',
colSpan: 1,
rowSpan: 1,
},
}
);
const InfoCardRoot = forwardRef<HTMLElement, InfoCardProps>(function InfoCard(
{
children,
className,
variant,
colSpan,
rowSpan,
href,
onPress,
...props
},
ref
) {
return (
<Container
{...props}
ref={castRef<HTMLElement>(ref)}
className={cn(infoCardVariants({ variant, colSpan, rowSpan }), className)}
href={href}
onPress={onPress}
>
{children}
</Container>
);
});
/**
* A compound card component for displaying informational content, lists, or media.
*
* @example
* ```tsx
* <InfoCard>
* <InfoCard.Header>Title</InfoCard.Header>
* <InfoCard.Body>Content</InfoCard.Body>
* </InfoCard>
* ```
*/
export const InfoCard = Object.assign(genericMemo(InfoCardRoot), {
Header: InfoCardHeader,
Body: InfoCardBody,
Footer: InfoCardFooter,
Image: InfoCardImage,
List: InfoCardList,
});
@@ -0,0 +1,16 @@
import { Text } from '@ermnvldmr/ui';
import React from 'react';
import type { InfoCardBodyProps } from './types';
/**
* Body sub-component for InfoCard.
*
* @example
* ```tsx
* <InfoCard.Body>Card Content</InfoCard.Body>
* ```
*/
export function InfoCardBody({ children, className }: InfoCardBodyProps): React.JSX.Element {
return <Text className={className}>{children}</Text>;
}
@@ -0,0 +1,15 @@
import React from 'react';
import type { InfoCardFooterProps } from './types';
/**
* Footer sub-component for InfoCard.
*
* @example
* ```tsx
* <InfoCard.Footer>Card Footer</InfoCard.Footer>
* ```
*/
export function InfoCardFooter({ children, className }: InfoCardFooterProps): React.JSX.Element {
return <div className={className}>{children}</div>;
}
@@ -0,0 +1,28 @@
import { Header } from '@ermnvldmr/ui';
import React from 'react';
import type { InfoCardHeaderProps } from './types';
/**
* Header sub-component for InfoCard.
*
* @example
* ```tsx
* <InfoCard.Header>Card Title</InfoCard.Header>
* ```
*/
export function InfoCardHeader({
children,
icon,
level = 3,
className,
}: InfoCardHeaderProps): React.JSX.Element {
return (
<div className="flex items-center gap-3">
{icon && <div>{icon}</div>}
<Header className={className} level={level}>
{children}
</Header>
</div>
);
}
@@ -0,0 +1,50 @@
import { Header, Image, Text, VStack } from '@ermnvldmr/ui';
import React from 'react';
import type { InfoCardImageProps } from './types';
/**
* Image overlay sub-component for InfoCard.
*
* @example
* ```tsx
* <InfoCard.Image alt="Image" src="image.jpg" />
* ```
*/
export function InfoCardImage({
src,
alt,
overlayTitle,
overlayDescription,
className,
}: InfoCardImageProps): React.JSX.Element {
const hasOverlay = Boolean(overlayTitle ?? overlayDescription);
return (
<div className={`relative h-full w-full ${className ?? ''}`}>
<Image
alt={alt}
className="h-full w-full object-cover"
height="100%"
ratio="auto"
src={src}
/>
{hasOverlay && (
<div className="absolute inset-0 flex flex-col justify-end bg-linear-to-t from-black/80 via-black/40 to-transparent p-6 text-white">
<VStack gap={1}>
{overlayTitle && (
<Header className="text-white" level={3}>
{overlayTitle}
</Header>
)}
{overlayDescription && (
<Text className="text-white/90" size="s">
{overlayDescription}
</Text>
)}
</VStack>
</div>
)}
</div>
);
}
@@ -0,0 +1,58 @@
import { Accordion, HStack, List, Text } from '@ermnvldmr/ui';
import React from 'react';
import type { InfoCardListProps } from './types';
/**
* Accordion list sub-component for InfoCard.
*
* @example
* ```tsx
* <InfoCard.List sections={[]} />
* ```
*/
export function InfoCardList({
sections,
defaultValue,
type = 'multiple',
className,
}: InfoCardListProps): React.JSX.Element {
return (
<div className={`flex-grow w-full ${className ?? ''}`}>
<Accordion collapsible defaultValue={defaultValue} type={type}>
{sections.map((section) => (
<Accordion.Item key={section.value} value={section.value}>
<Accordion.Trigger className={section.labelClassName}>
<HStack align="center" gap={2}>
{section.labelIcon}
<Text size="l" type="body">
{section.label}
</Text>
</HStack>
</Accordion.Trigger>
<Accordion.Content>
<List>
{section.items.map((item, index) => {
const content =
item && typeof item === 'object' && 'node' in item ? item.node : item;
const itemKey =
item && typeof item === 'object' && 'key' in item
? item.key
: typeof item === 'string'
? item
: index;
return (
<List.Item key={itemKey}>
<Text size="m">{content}</Text>
</List.Item>
);
})}
</List>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>
</div>
);
}
@@ -0,0 +1,2 @@
export * from './InfoCard';
export * from './types';
@@ -0,0 +1,104 @@
import type { ContainerBackground } from '@ermnvldmr/ui';
import type React from 'react';
/**
* Props for the InfoCard root component.
*/
export interface InfoCardProps extends React.HTMLAttributes<HTMLElement> {
/** Surface background variant */
bg?: ContainerBackground;
/** Variant style for the card */
variant?: 'default' | 'primary';
/** Grid column span */
colSpan?: 1 | 2 | 3 | 4;
/** Grid row span */
rowSpan?: 1 | 2 | 3;
/** Optional link destination */
href?: string;
/** Optional press handler */
onPress?: () => void;
/** Additional CSS class names */
className?: string;
/** Card content elements */
children: React.ReactNode;
}
/**
* Props for InfoCardHeader component.
*/
export interface InfoCardHeaderProps {
/** Header title text or nodes */
children: React.ReactNode;
/** Optional leading icon */
icon?: React.ReactNode;
/** Heading semantic level (1-6) */
level?: 1 | 2 | 3 | 4 | 5 | 6;
/** Additional CSS class names */
className?: string;
}
/**
* Props for InfoCardBody component.
*/
export interface InfoCardBodyProps {
/** Body content nodes */
children: React.ReactNode;
/** Additional CSS class names */
className?: string;
}
/**
* Props for InfoCardFooter component.
*/
export interface InfoCardFooterProps {
/** Footer content nodes */
children: React.ReactNode;
/** Additional CSS class names */
className?: string;
}
/**
* Props for InfoCardImage component.
*/
export interface InfoCardImageProps {
/** Image source URL */
src: string;
/** Alternate text for accessibility */
alt: string;
/** Optional overlay header title */
overlayTitle?: string;
/** Optional overlay description text */
overlayDescription?: string;
/** Additional CSS class names */
className?: string;
}
/**
* Section definition for InfoCardList component.
*/
export interface InfoCardListSection {
/** Accordion item value key */
value: string;
/** Section label text */
label: string;
/** Optional icon next to section label */
labelIcon?: React.ReactNode;
/** Additional class names for trigger label */
labelClassName?: string;
/** List items inside section */
items: (React.ReactNode | { key?: string; node: React.ReactNode })[];
}
/**
* Props for InfoCardList component.
*/
export interface InfoCardListProps {
/** List sections array */
sections: InfoCardListSection[];
/** Default expanded item values */
defaultValue?: string[];
/** Expansion mode: single or multiple */
type?: 'single' | 'multiple';
/** Additional CSS class names */
className?: string;
}
@@ -0,0 +1,23 @@
import { Markdown } from './Markdown';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Markdown> = {
title: 'Kits/Markdown',
component: Markdown,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Markdown>;
export const Default: Story = {
args: {
children: `# Markdown Title
This is a **markdown paragraph** with a [link](#) and inline \`code\`.
> Blockquote example text.
`,
},
};
@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { Markdown } from './Markdown';
describe('Markdown', () => {
it('renders markdown sub-components correctly', () => {
render(
<Markdown>
<Markdown.H1>Hello Markdown</Markdown.H1>
<Markdown.P>This is a paragraph.</Markdown.P>
</Markdown>
);
expect(screen.getByText('Hello Markdown')).toBeInTheDocument();
expect(screen.getByText('This is a paragraph.')).toBeInTheDocument();
});
});
@@ -1,8 +1,8 @@
import { Link } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Link } from '../../Link/Link';
import type { LinkProps } from '../../Link/Link';
import type { LinkProps } from '@ermnvldmr/ui';
/**
* A Markdown link component that wraps the design system Link.
@@ -1,8 +1,8 @@
import { Blockquote } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Blockquote } from '../../Blockquote/Blockquote';
import type { BlockquoteProps } from '../../Blockquote/Blockquote';
import type { BlockquoteProps } from '@ermnvldmr/ui';
/**
* A Markdown blockquote component that wraps the design system Blockquote.
@@ -1,10 +1,8 @@
import { Code , SUPPORTED_LANGUAGES } from '@ermnvldmr/ui';
import React, { memo, useMemo } from 'react';
import { Code } from '../../Code/Code';
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
import type { CodeProps } from '../../Code/Code';
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
import type { CodeProps , CodeLanguage } from '@ermnvldmr/ui';
/**
* Checks if a string is a valid code language.
@@ -1,8 +1,8 @@
import { Header } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
import type { HeaderProps } from '@ermnvldmr/ui';
/**
* A Markdown H1 component that wraps the design system Header.
@@ -1,8 +1,8 @@
import { Header } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
import type { HeaderProps } from '@ermnvldmr/ui';
/**
* A Markdown H2 component that wraps the design system Header.
@@ -1,8 +1,8 @@
import { Header } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
import type { HeaderProps } from '@ermnvldmr/ui';
/**
* A Markdown H3 component that wraps the design system Header.
@@ -1,8 +1,8 @@
import { Header } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
import type { HeaderProps } from '@ermnvldmr/ui';
/**
* A Markdown H4 component that wraps the design system Header.
@@ -1,8 +1,8 @@
import { Header } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
import type { HeaderProps } from '@ermnvldmr/ui';
/**
* A Markdown H5 component that wraps the design system Header.
@@ -1,8 +1,8 @@
import { Header } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Header } from '../../Header/Header';
import type { HeaderProps } from '../../Header/Header';
import type { HeaderProps } from '@ermnvldmr/ui';
/**
* A Markdown H6 component that wraps the design system Header.
@@ -1,8 +1,8 @@
import { Separator } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Separator } from '../../Separator/Separator';
import type { SeparatorProps } from '../../Separator/Separator';
import type { SeparatorProps } from '@ermnvldmr/ui';
/**
* A Markdown horizontal rule component that wraps the design system Separator.
@@ -1,8 +1,8 @@
import { Image } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Image } from '../../Image/Image';
import type { ImageProps } from '../../Image/Image';
import type { ImageProps } from '@ermnvldmr/ui';
/**
* A Markdown image component that wraps the design system Image.
@@ -1,9 +1,9 @@
import { List } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { List } from '../../List/List';
import { MarkdownP } from '../MarkdownP/MarkdownP';
import type { ListItemProps } from '../../List/List';
import type { ListItemProps } from '@ermnvldmr/ui';
/**
* A Markdown list item component.
@@ -1,8 +1,8 @@
import { List } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { List } from '../../List/List';
import type { ListProps } from '../../List/List';
import type { ListProps } from '@ermnvldmr/ui';
/**
* A Markdown ordered list component.
@@ -1,8 +1,8 @@
import { Paragraph } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Paragraph } from '../../Paragraph/Paragraph';
import type { ParagraphProps } from '../../Paragraph/Paragraph';
import type { ParagraphProps } from '@ermnvldmr/ui';
/**
* A Markdown paragraph component that wraps the design system Paragraph.
@@ -1,10 +1,8 @@
import { CodeBlock , SUPPORTED_LANGUAGES } from '@ermnvldmr/ui';
import React, { memo, useMemo } from 'react';
import { CodeBlock } from '../../Code/CodeBlock/CodeBlock';
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
import type { CodeBlockProps } from '../../Code/CodeBlock/CodeBlock';
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
import type { CodeBlockProps , CodeLanguage } from '@ermnvldmr/ui';
/**
* Checks if a string is a valid code language.
@@ -1,6 +1,6 @@
import { Table } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table body (tbody) component.
@@ -1,6 +1,6 @@
import { Table } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table cell (td) component.
@@ -1,6 +1,6 @@
import { Table } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table header cell (th) component.
@@ -1,6 +1,6 @@
import { Table } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table head (thead) component.
@@ -1,6 +1,6 @@
import { Table } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
/**
* A Markdown table row (tr) component.
@@ -1,8 +1,8 @@
import { Table } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { Table } from '../../Table/Table';
import type { TableProps } from '../../Table/Table';
import type { TableProps } from '@ermnvldmr/ui';
/**
* A Markdown table root component.
@@ -1,8 +1,8 @@
import { List } from '@ermnvldmr/ui';
import React, { memo } from 'react';
import { List } from '../../List/List';
import type { ListProps } from '../../List/List';
import type { ListProps } from '@ermnvldmr/ui';
/**
* A Markdown unordered list component.
@@ -0,0 +1,27 @@
import React from 'react';
import { PageColumn, PageColumns, PageContainer, PageRoot } from './index';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageRoot> = {
title: 'Kits/Page',
component: PageRoot,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof PageRoot>;
export const Default: Story = {
render: () => (
<PageRoot>
<PageContainer width="default">
<PageColumns>
<PageColumn size="full">Main Column Content</PageColumn>
<PageColumn size="small">Sidebar Content</PageColumn>
</PageColumns>
</PageContainer>
</PageRoot>
),
};
@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { PageColumn, PageColumns, PageContainer, PageRoot } from './index';
describe('Page Components Integration', () => {
it('renders PageRoot, PageContainer, PageColumns, and PageColumn together', () => {
render(
<PageRoot className="test-root">
<PageContainer className="test-container" width="default">
<PageColumns className="test-columns">
<PageColumn className="test-column" size="full">
Page Content
</PageColumn>
</PageColumns>
</PageContainer>
</PageRoot>
);
expect(screen.getByText('Page Content')).toBeInTheDocument();
});
});
@@ -1,8 +1,7 @@
import { cn } from '@ermnvldmr/stl';
import { useTheme } from '@ermnvldmr/ui';
import React from 'react';
import { useTheme } from '../../../lib/theme';
/**
* Props for the PageRoot component.
*/
@@ -1,5 +1,4 @@
export * from './PageRoot/PageRoot';
export * from './PageContainer/PageContainer';
export * from './PageColumns/PageColumns';
export * from './PageColumn/PageColumn';
export * from './PageHead/PageHead';
export * from './PageContainer/PageContainer';
export * from './PageRoot/PageRoot';
@@ -0,0 +1,19 @@
import { PageHead } from './PageHead';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageHead> = {
title: 'Kits/PageHead',
component: PageHead,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof PageHead>;
export const Default: Story = {
args: {
heading: 'Page Heading',
caption: 'This is the page header caption description.',
},
};
@@ -0,0 +1,14 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { PageHead } from './PageHead';
describe('PageHead', () => {
it('renders heading and caption correctly', () => {
render(<PageHead caption="Test Caption" heading="Test Heading" />);
expect(screen.getAllByText('Test Heading').length).toBeGreaterThan(0);
expect(screen.getByText('Test Caption')).toBeInTheDocument();
});
});
@@ -1,20 +1,11 @@
import { cn, useScroll } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import { cva, Header, HStack, Text, VStack } from '@ermnvldmr/ui';
import React, { type ReactNode, useEffect, useState } from 'react';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
import { Text } from '../../Text/Text';
import { VStack } from '../../VStack/VStack';
import { PageContainer } from '../PageContainer/PageContainer';
import { PageContainer } from '../Page/PageContainer/PageContainer';
/**
* Defines the positioning and behavior of the PageHead component.
* - `always-collapsed-fixed`: Always collapsed and fixed to the top.
* - `always-collapsed-sticky`: Always collapsed and sticks to the top when scrolling.
* - `always-expanded-fixed`: Always expanded and fixed to the top.
* - `always-expanded-sticky`: Always expanded and sticks to the top when scrolling.
* - `collapsible-sticky`: Starts expanded and collapses when scrolling down, sticking to the top.
*/
export type PageHeadStrategy =
| 'always-collapsed-fixed'
@@ -43,9 +34,6 @@ export interface PageHeadProps {
className?: string;
}
// Root positioning is fully determined by `strategy`:
// - `collapsible-sticky` stays in-flow so expanded content scrolls away.
// - `*-sticky` / `*-fixed` strategies pin the root to the top.
const pageHeadRootVariants = cva(
'page-head w-full bg-[var(--rb-background)] border-b border-[var(--rb-outline)]/10',
{
@@ -67,19 +55,12 @@ const pageHeadRootVariants = cva(
/**
* A universal page head component with multiple layout and behavior strategies.
*
* @param props - The props for the component.
* @returns A PageHead component.
*
* @example
* ```tsx
* <PageHead
* heading="Page Title"
* caption="A short and inspiring caption"
* strategy="collapsible-sticky"
* />
* <PageHead heading="Title" />
* ```
*/
export const PageHead = ({
export function PageHead({
heading,
caption,
breadcrumbs,
@@ -87,7 +68,7 @@ export const PageHead = ({
addonRight,
strategy = 'collapsible-sticky',
className,
}: PageHeadProps) => {
}: PageHeadProps): React.JSX.Element {
const { y: scrollY } = useScroll();
const isCollapsible = strategy === 'collapsible-sticky';
const isAlwaysCollapsed = strategy.startsWith('always-collapsed');
@@ -95,9 +76,6 @@ export const PageHead = ({
useEffect(() => {
if (isCollapsible) {
// Trigger collapse once we've scrolled past the expanded header's height.
// Usually around 150-200px depending on caption length.
// 200px ensures it won't snap until it's mostly gone.
setIsCollapsed(scrollY > 200);
}
}, [scrollY, isCollapsible]);
@@ -130,14 +108,6 @@ export const PageHead = ({
const collapsedContent = (
<PageContainer paddingY="none">
{/*
* A single row on wide screens (title left, menu right). When the title
* and menu can't fit together, the row wraps the menu onto its own line
* beneath the title instead of overlapping. `whitespace-nowrap` keeps the
* title a single unit so it's the menu that wraps, not the title that
* shrinks. The invisible spacer copy below reserves whichever height this
* ends up being, so page content always starts clear of the bar.
*/}
<HStack align="center" className="py-2.5" gap={4} justify="between" wrap="wrap">
<HStack align="center" className="flex-1" gap={4}>
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
@@ -153,7 +123,6 @@ export const PageHead = ({
return (
<>
<header className={rootClasses}>
{/* Expanded Content: In-flow for collapsible, or sticky/fixed for others */}
{!isAlwaysCollapsed && (
<div
className={cn(
@@ -165,7 +134,6 @@ export const PageHead = ({
</div>
)}
{/* Collapsed Content: Fixed overlay for collapsible, or absolute/relative within sticky/fixed root */}
<div
className={cn(
'z-20 transition-all duration-300 transform bg-[var(--rb-background)]/90 backdrop-blur-md border-b border-[var(--rb-outline)]/10 shadow-md',
@@ -179,14 +147,6 @@ export const PageHead = ({
</div>
</header>
{/*
* Always-collapsed strategies show only the collapsed bar, which is taken
* out of normal flow (the root is fixed, and its collapsed bar is absolute),
* so the header reserves no height. Without this spacer, page content would
* start at the top of the viewport, underneath the bar. Rendering an
* invisible copy of the bar reserves exactly its height at every viewport —
* including when it wraps to two rows — with no measurement or layout shift.
*/}
{isAlwaysCollapsed && (
<div aria-hidden="true" className="invisible shrink-0">
{collapsedContent}
@@ -194,4 +154,4 @@ export const PageHead = ({
)}
</>
);
};
}
@@ -0,0 +1 @@
export * from './PageHead';
@@ -1,9 +1,11 @@
import React from 'react';
import { ThemeSwitch } from './ThemeSwitch';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof ThemeSwitch> = {
title: 'Components/ThemeSwitch',
title: 'Kits/ThemeSwitch',
component: ThemeSwitch,
tags: ['autodocs'],
};
@@ -11,4 +13,6 @@ const meta: Meta<typeof ThemeSwitch> = {
export default meta;
type Story = StoryObj<typeof ThemeSwitch>;
export const Default: Story = {};
export const Default: Story = {
render: () => <ThemeSwitch aria-label="Toggle Theme" />,
};
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { ThemeSwitch } from './ThemeSwitch';
describe('ThemeSwitch', () => {
it('renders theme switch controls correctly', () => {
render(
<ThemeSwitch
darkIcon={<span data-testid="dark-icon" />}
lightIcon={<span data-testid="light-icon" />}
systemIcon={<span data-testid="system-icon" />}
/>
);
expect(screen.getByTestId('light-icon')).toBeInTheDocument();
expect(screen.getByTestId('dark-icon')).toBeInTheDocument();
expect(screen.getByTestId('system-icon')).toBeInTheDocument();
const switchControl = screen.getByRole('switch');
expect(switchControl).toBeInTheDocument();
const systemButton = screen.getByRole('button');
expect(systemButton).toBeInTheDocument();
});
it('handles interaction with switch and system button', async () => {
const user = userEvent.setup();
render(<ThemeSwitch />);
const switchControl = screen.getByRole('switch');
const systemButton = screen.getByRole('button');
await user.click(switchControl);
await user.click(systemButton);
expect(systemButton).toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
import { Button, HStack, Switch, useTheme } from '@ermnvldmr/ui';
import React from 'react';
/**
* Props for the ThemeSwitch component.
*/
export interface ThemeSwitchProps {
/** Optional icon for light theme */
lightIcon?: React.ReactNode;
/** Optional icon for dark theme */
darkIcon?: React.ReactNode;
/** Optional icon for system theme */
systemIcon?: React.ReactNode;
}
/**
* A composite component for managing theme preferences.
*
* @example
* ```tsx
* <ThemeSwitch />
* ```
*/
export function ThemeSwitch({
lightIcon,
darkIcon,
systemIcon,
}: ThemeSwitchProps = {}): React.JSX.Element {
const { preference, setPreference, resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
return (
<HStack align="center" gap={2}>
<HStack align="center" gap={1.5}>
{lightIcon}
<Switch
aria-label="Toggle dark mode"
isSelected={isDark}
variant="outline"
onChange={(isSelected) => setPreference(isSelected ? 'dark' : 'light')}
/>
{darkIcon}
</HStack>
<Button
aria-label="Follow system theme"
color={preference === 'system' ? 'primary' : 'neutral'}
renderStartIcon={systemIcon ? () => systemIcon : undefined}
size="s"
variant={preference === 'system' ? 'solid' : 'ghost'}
onPress={() => setPreference('system')}
/>
</HStack>
);
}
@@ -0,0 +1 @@
export * from './ThemeSwitch';
@@ -0,0 +1,19 @@
import { Time } from './Time';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Time> = {
title: 'Kits/Time',
component: Time,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Time>;
export const Default: Story = {
args: {
date: new Date('2026-08-09T12:00:00Z'),
locale: 'en-US',
},
};
@@ -0,0 +1,16 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { Time } from './Time';
describe('Time', () => {
it('renders formatted date correctly', () => {
const testDate = new Date('2026-08-09T12:00:00Z');
render(<Time date={testDate} locale="en-US" />);
const timeElement = screen.getByText(/2026/);
expect(timeElement).toBeInTheDocument();
expect(timeElement.tagName.toLowerCase()).toBe('time');
});
});
@@ -1,9 +1,8 @@
import { castRef, genericMemo } from '@ermnvldmr/stl';
import { Text } from '@ermnvldmr/ui';
import React, { forwardRef, useMemo } from 'react';
import { Text } from '../Text/Text';
import type { TextProps } from '../Text/Text';
import type { TextProps } from '@ermnvldmr/ui';
/**
* Props for the Time component.
@@ -23,9 +22,6 @@ export interface TimeProps extends Omit<TextProps, 'as' | 'children'> {
/**
* A semantic time component that wraps the Text component.
*
* Time provides a consistent way to display formatted dates while
* maintaining semantic integrity using the HTML <time> element.
*/
export const Time = genericMemo(
forwardRef<HTMLElement, TimeProps>(function Time(
@@ -38,8 +34,6 @@ export const Time = genericMemo(
const formattedDate = useMemo(() => {
if (children) return children;
// If formatOptions contains dateStyle or timeStyle, we must not provide
// individual components like year/month/day as it will throw an error.
const hasStyle =
formatOptions && ('dateStyle' in formatOptions || 'timeStyle' in formatOptions);
const options: Intl.DateTimeFormatOptions = hasStyle
@@ -0,0 +1 @@
export * from './Time';
+7
View File
@@ -0,0 +1,7 @@
export * from './components/BentoGrid/index';
export * from './components/InfoCard/index';
export * from './components/Markdown/Markdown';
export * from './components/Page/index';
export * from './components/PageHead/index';
export * from './components/ThemeSwitch/index';
export * from './components/Time/index';
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"types": ["vitest/globals", "@testing-library/jest-dom", "node"],
"paths": {
"@ermnvldmr/stl": ["../stl/src"],
"@ermnvldmr/stl/*": ["../stl/src/*"],
"@ermnvldmr/ui": ["../ui/src"],
"@ermnvldmr/ui/*": ["../ui/src/*"]
}
},
"include": ["src/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { reactVitestConfig } from '@ermnvldmr/rsbuild-config/dev';
import { mergeConfig } from 'vitest/config';
export default mergeConfig(reactVitestConfig, {
test: {
// Package specific overrides
},
});
+4 -1
View File
@@ -27,8 +27,11 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@rsbuild/plugin-mdx": "^1.1.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"remark-frontmatter": "^5.0.0",
"remark-mdx-frontmatter": "^5.2.0"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*",
+59 -32
View File
@@ -1,3 +1,4 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
@@ -6,11 +7,63 @@ import React from 'react';
import type { RsbuildPlugin } from '@rsbuild/core';
const SSR_RUNNER_SCRIPT = `
import { createRsbuild } from '@rsbuild/core';
async function main() {
const entries = JSON.parse(process.argv[1]);
const ssrDistPath = process.argv[2];
const plugins = [];
try {
const { pluginMdx } = await import('@rsbuild/plugin-mdx');
const { default: remarkFrontmatter } = await import('remark-frontmatter');
const { default: remarkMdxFrontmatter } = await import('remark-mdx-frontmatter');
plugins.push(
pluginMdx({
mdxLoaderOptions: {
remarkPlugins: [remarkFrontmatter, [remarkMdxFrontmatter, { name: 'frontmatter' }]],
},
})
);
} catch (_err) {
// MDX plugins not installed or needed in this service
}
const ssrBuild = await createRsbuild({
rsbuildConfig: {
plugins,
source: { entry: entries },
output: {
target: 'node',
distPath: { root: ssrDistPath, js: '.' },
filename: { js: '[name].cjs' },
cleanDistPath: false,
externals: {
react: 'commonjs react',
'react-dom': 'commonjs react-dom',
'react/jsx-runtime': 'commonjs react/jsx-runtime',
'react/jsx-dev-runtime': 'commonjs react/jsx-dev-runtime',
},
},
},
});
await ssrBuild.build();
}
main().catch((err) => {
console.error('[ssg-runner] SSR build failed:', err);
process.exit(1);
});
`;
/**
* Rsbuild plugin for build-time Static Site Generation.
*
* After the web build completes:
* 1. Builds each entry as a Node.js CJS bundle (dist-ssr/)
* 1. Builds each entry as a Node.js CJS bundle (dist-ssr/) via isolated process
* 2. Renders each entry with react-dom/server renderToString
* 3. Injects the rendered HTML into the web build's HTML files
* 4. Runs critters to inline critical CSS and make the main stylesheet async
@@ -67,37 +120,11 @@ export function ssgPlugin(): RsbuildPlugin {
const ssrDistPath = path.join(path.dirname(webDistPath), 'dist-ssr');
console.log('[ssg-plugin] Building SSR bundles...');
const { createRsbuild } = await import('@rsbuild/core');
const SSG_PLUGIN_NAME = 'ermnvldmr:ssg-plugin';
const TYPE_CHECK_PLUGIN_NAME = 'rsbuild:type-check';
const ssrPlugins = (config.plugins ?? []).filter((p) => {
if (!p || typeof p !== 'object') return true;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (p as { name?: string }).name;
return name !== SSG_PLUGIN_NAME && name !== TYPE_CHECK_PLUGIN_NAME;
});
const ssrBuild = await createRsbuild({
rsbuildConfig: {
plugins: ssrPlugins,
source: { entry: entries },
tools: config.tools,
output: {
target: 'node',
distPath: { root: ssrDistPath, js: '.' },
filename: { js: '[name].cjs' },
externals: {
react: 'commonjs react',
'react-dom': 'commonjs react-dom',
'react/jsx-runtime': 'commonjs react/jsx-runtime',
'react/jsx-dev-runtime': 'commonjs react/jsx-dev-runtime',
},
},
},
});
await ssrBuild.build();
execFileSync(
process.execPath,
['--input-type=module', '-e', SSR_RUNNER_SCRIPT, JSON.stringify(entries), ssrDistPath],
{ stdio: 'inherit' }
);
console.log('[ssg-plugin] SSR bundles ready.');
const { renderToString } = await import('react-dom/server');
+6
View File
@@ -21,6 +21,12 @@
"import": "./dist/dev.js",
"types": "./dist/ui/src/dev.d.ts",
"default": "./src/dev.ts"
},
"./styles": {
"default": "./src/static/styles.css"
},
"./styles.css": {
"default": "./src/static/styles.css"
}
},
"files": [
@@ -21,6 +21,19 @@ describe('Accordion', () => {
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
});
it('renders expansion icon with inline-icon class', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
</Accordion.Item>
</Accordion>
);
const svg = screen.getByRole('button').querySelector('svg');
expect(svg).toHaveClass('inline-icon');
expect(svg).not.toHaveClass('size-5');
});
it('toggles content on click in single mode', async () => {
const user = userEvent.setup();
render(
@@ -27,7 +27,7 @@ const triggerVariants = cva(
);
const iconVariants = cva(
'size-5 shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
'inline-icon shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
{
variants: {
isOpen: {
@@ -1,44 +0,0 @@
import { Article } from './Article';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Article> = {
title: 'components/Article',
component: Article,
tags: ['autodocs'],
argTypes: {
onPress: { action: 'pressed' },
href: { control: 'text' },
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Article>;
export const Default: Story = {
args: {
headline: 'AI Agent Learns to Code',
children:
'In a shocking turn of events, a large language model has successfully implemented a UI component from scratch while maintaining design system consistency.',
},
};
export const Full: Story = {
args: {
headline: 'The Future of Web Development',
subHeadline: 'Design Systems and AI Collaboration',
additionalText: 'By AI Reporter #42',
children:
'Newspaper-style layouts are making a comeback in modern web design. By combining classical typography principles with modern responsive frameworks, developers can create unique and engaging user experiences.',
},
};
export const Linked: Story = {
args: {
...Full.args,
href: '#',
},
};
@@ -1,49 +0,0 @@
import React from 'react';
import { Article } from './Article';
import { render, screen } from '../../lib/testing';
describe('components/Article', () => {
const props = {
headline: 'Breaking News',
subHeadline: 'Local cat saves city',
additionalText: 'Reporting by John Doe',
};
it('renders all parts of the article', () => {
render(<Article {...props}>Article body content</Article>);
expect(screen.getByText('Breaking News')).toBeInTheDocument();
expect(screen.getByText('Local cat saves city')).toBeInTheDocument();
expect(screen.getByText('Article body content')).toBeInTheDocument();
expect(screen.getByText('Reporting by John Doe')).toBeInTheDocument();
});
it('renders separators correctly based on subHeadline presence', () => {
const { rerender } = render(<Article headline="Title">Body</Article>);
let separators = screen.getAllByRole('separator');
expect(separators).toHaveLength(1);
expect(separators[0].className).toContain('mask-image');
rerender(
<Article headline="Title" subHeadline="Sub">
Body
</Article>
);
separators = screen.getAllByRole('separator');
expect(separators).toHaveLength(1);
expect(separators[0].className).toContain('mask-image');
});
it('renders as a link when href is provided', () => {
render(
<Article {...props} href="/test-article">
Article body content
</Article>
);
const articleContainer = screen.getByText('Breaking News').closest('a');
expect(articleContainer).toBeInTheDocument();
expect(articleContainer).toHaveAttribute('href', '/test-article');
});
});
@@ -1,87 +0,0 @@
import React, { memo } from 'react';
import { Container } from '../Container/Container';
import { Header } from '../Header/Header';
import { Separator } from '../Separator/Separator';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { ContainerProps } from '../Container/Container';
import type { ClassNameProps } from '@ermnvldmr/stl';
/**
* Props for the Article component.
*/
export interface ArticleProps extends ClassNameProps {
/** The main article headline */
headline: string;
/** Optional sub-headline displayed between separators */
subHeadline?: string;
/** Optional additional text (e.g., author) displayed at the bottom in gray italic label */
additionalText?: string;
/**
* Press interaction handler.
* Cannot be used with `href`.
*/
onPress?: ContainerProps['onPress'];
/**
* If provided, the container will be rendered as an `<a>` tag for navigation.
* Cannot be used with `onPress`.
*/
href?: ContainerProps['href'];
/** The main article body content */
children: React.ReactNode;
}
/**
* A article component with a structured layout.
*
* Features:
* - Left-aligned medium headline (headline-m)
* - Single-line separators with optional end fade
* - Optional italic sub-headline (label-m)
* - Main body text (body-m)
* - Optional bottom gray italic label (label-s)
* - Interactive wrapper via Container, supporting both `onPress` and `href`
*/
export const Article = memo(function Article({
headline,
subHeadline,
additionalText,
onPress,
href,
children,
className,
}: ArticleProps) {
return (
<Container border className={className} href={href} padding={4} rounded="md" onPress={onPress}>
<VStack align="stretch" gap={4}>
<VStack align="stretch" gap={2}>
<Header level={4}>{headline}</Header>
<VStack align="stretch" gap={2}>
{subHeadline ? (
<>
<Text italic size="m" type="label">
{subHeadline}
</Text>
<Separator thinned="thinned-end" type="single" />
</>
) : (
<Separator thinned="thinned-end" type="single" />
)}
</VStack>
</VStack>
<Text size="m" type="body">
{children}
</Text>
{additionalText && (
<VStack align="end">
<Text italic color="muted" size="s" type="label">
{additionalText}
</Text>
</VStack>
)}
</VStack>
</Container>
);
});
@@ -1,291 +0,0 @@
import { Star, Code, Layout, Send, Zap, Shield, Cpu, Gamepad2, Plane, BookOpen, Check, Gift } from 'lucide-react';
import React from 'react';
import { BentoGrid } from './BentoGrid';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof BentoGrid> = {
title: 'Components/BentoGrid',
component: BentoGrid,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof BentoGrid>;
export const Showcase: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<BentoGrid>
<BentoGrid.ImageCard
alt="Developer workstation"
colSpan={2}
overlayDescription="A deep dive into modern web architecture and performance optimization."
overlayTitle="Featured Project"
rowSpan={2}
src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&q=80&w=2072"
/>
<BentoGrid.InfoCard
description="Built on top of Rsbuild and Rspack for near-instant compilation and ultra-fast HMR."
icon={<Zap className="text-orange-500" size={24} />}
title="Lightning Fast"
/>
<BentoGrid.CTACard
buttonText="Get Started"
description="Connect with other developers and share your projects."
href="#"
title="Join the Community"
variant="primary"
/>
<BentoGrid.InfoCard
description="Strict TypeScript integration across the entire monorepo for maximum reliability."
footer={<div className="text-xs opacity-50">Strict Mode Enabled</div>}
icon={<Shield className="text-green-500" size={24} />}
title="Type Safe"
/>
<BentoGrid.ImageCard
alt="Code on screen"
colSpan={2}
overlayTitle="Clean Code"
src="https://images.unsplash.com/photo-1461749280684-dccba630e2f6?auto=format&fit=crop&q=80&w=2069"
/>
<BentoGrid.InfoCard
description="Easily extend and customize the design system with our modular component library."
icon={<Cpu className="text-blue-500" size={24} />}
title="Modular Architecture"
/>
<BentoGrid.CTACard
buttonText="View Docs"
description="Read the full documentation to learn more about the design system."
href="#"
title="Documentation"
/>
<BentoGrid.BaseCard className="bg-tertiary/10 border-dashed" colSpan={4}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-tertiary rounded-full text-tertiary-text">
<Send size={20} />
</div>
<div>
<h4 className="font-bold text-text">Subscribe to Newsletter</h4>
<p className="text-sm text-muted-text">Stay updated with the latest releases and technical articles.</p>
</div>
</div>
<button className="px-6 py-2 bg-text text-[var(--rb-base)] rounded-md font-medium">Subscribe</button>
</div>
</BentoGrid.BaseCard>
</BentoGrid>
</div>
),
};
export const LayoutPatterns: Story = {
render: () => (
<div className="w-full max-w-6xl p-4 flex flex-col gap-12">
<section>
<h3 className="mb-4 text-xl font-bold">Standard 4-Column Grid</h3>
<BentoGrid>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
<section>
<h3 className="mb-4 text-xl font-bold">Mixed Spans (Hero Pattern)</h3>
<BentoGrid>
<BentoGrid.BaseCard className="flex items-center justify-center bg-primary/20 border-primary" colSpan={2} rowSpan={2}>2x2 Hero</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
<section>
<h3 className="mb-4 text-xl font-bold">Tall & Wide Pattern</h3>
<BentoGrid>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-secondary/20 border-secondary" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-tertiary/20 border-tertiary" rowSpan={2}>1x2 Tall</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
</div>
),
};
export const AllCardTypes: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<BentoGrid>
<BentoGrid.InfoCard
description="Used for features or information. Can have an icon and a footer."
footer={<div className="text-xs font-mono">v1.0.0</div>}
icon={<Layout size={24} />}
title="Info Card"
/>
<BentoGrid.ImageCard
alt="Abstract image"
overlayDescription="Perfect for visual showcases with text overlays."
overlayTitle="Image Card"
src="https://images.unsplash.com/photo-1481487196290-c152efe083f5?auto=format&fit=crop&q=80&w=2048"
/>
<BentoGrid.CTACard
buttonText="Learn More"
description="Drives action with an outline button."
href="#"
title="CTA Card (Default)"
/>
<BentoGrid.CTACard
buttonText="Sign Up Now"
description="High prominence with a solid background."
href="#"
title="CTA Card (Primary)"
variant="primary"
/>
<BentoGrid.ListCard
colSpan={2}
defaultValue={['features']}
description="A specialized card for categorized lists, ideal for wishlists or feature matrices."
icon={<div className="p-2 bg-blue-100 text-blue-700 rounded-lg"><Star size={20} /></div>}
sections={[
{
value: 'features',
label: 'Features',
labelIcon: <Check size={18} />,
labelClassName: 'text-green-600',
items: ['Responsive', 'Accessible', 'Themeable'],
}
]}
title="List Card"
/>
<BentoGrid.BaseCard className="flex flex-col gap-4">
<div className="p-2 bg-yellow-100 text-yellow-900 rounded-md w-fit">
<Code size={20} />
</div>
<h4 className="font-bold">Base Card (Custom)</h4>
<p className="text-sm">You can put any children here to create custom card types while maintaining the bento border and padding.</p>
</BentoGrid.BaseCard>
</BentoGrid>
</div>
),
};
export const WishlistVariant: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<div className="mb-8">
<Header level={2}>My Wishlist</Header>
<Text className="text-muted-text" size="l">Things I want and things I already have, organized by category.</Text>
</div>
<BentoGrid>
<BentoGrid.ListCard
colSpan={2}
defaultValue={['wanted']}
description="My essential list of gadgets and hardware I use for daily productivity and entertainment."
icon={
<div className="p-3 bg-blue-100 text-blue-700 rounded-xl">
<Gamepad2 size={24} />
</div>
}
rowSpan={2}
sections={[
{
value: 'wanted',
label: 'Wanted',
labelIcon: <Gift size={18} />,
labelClassName: 'text-orange-600',
items: [
'Steam Deck OLED 1TB',
'Sony A7C II with 35mm lens',
'Keychron Mechanical Keyboard'
]
},
{
value: 'owned',
label: 'Already Owned',
labelIcon: <Check size={18} />,
labelClassName: 'text-green-600',
items: [
'MacBook Pro M3 Max',
'Sony WH-1000XM5 Headphones',
'LG 27" 4K Monitor'
]
}
]}
title="Gadgets & Tech"
/>
<BentoGrid.ListCard
colSpan={2}
description="Places I want to visit and explore around the world."
icon={
<div className="p-3 bg-green-100 text-green-700 rounded-xl">
<Plane size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Bucket List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-orange-600',
items: [
'Kyoto, Japan (Cherry Blossom Season)',
'Reykjavik, Iceland',
'Swiss Alps'
]
},
{
value: 'owned',
label: 'Visited',
labelIcon: <Check size={18} />,
labelClassName: 'text-green-600',
items: [
'Paris, France',
'Rome, Italy',
'London, UK'
]
}
]}
title="Travel Destinations"
type="single"
/>
<BentoGrid.ListCard
colSpan={2}
description="My reading backlog spanning technical books, sci-fi, and fantasy."
icon={
<div className="p-3 bg-violet-100 text-violet-700 rounded-xl">
<BookOpen size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Reading List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-orange-600',
items: [
'Designing Data-Intensive Applications',
'The Pragmatic Programmer',
'Dune Messiah'
]
}
]}
title="Books to Read"
type="single"
/>
</BentoGrid>
</div>
),
};
@@ -1,121 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { BentoGrid } from './BentoGrid';
describe('BentoGrid', () => {
it('renders all card types correctly', () => {
render(
<BentoGrid>
<BentoGrid.BaseCard data-testid="base-card">Base Card Content</BentoGrid.BaseCard>
<BentoGrid.ImageCard
alt="Test Image"
overlayDescription="Image Description"
overlayTitle="Image Title"
src="test-image.jpg"
/>
<BentoGrid.InfoCard
description="Info Description"
footer={<span>Footer Content</span>}
title="Info Title"
/>
<BentoGrid.CTACard
buttonText="Click Me"
description="CTA Description"
href="/test"
title="CTA Title"
/>
<BentoGrid.ListCard
description="List Description"
sections={[
{
value: 's1',
label: 'Section 1',
items: ['Item A', 'Item B']
}
]}
title="List Title"
/>
</BentoGrid>
);
// BaseCard
expect(screen.getByTestId('base-card')).toBeInTheDocument();
expect(screen.getByText('Base Card Content')).toBeInTheDocument();
// ImageCard
expect(screen.getByAltText('Test Image')).toBeInTheDocument();
expect(screen.getByText('Image Title')).toBeInTheDocument();
expect(screen.getByText('Image Description')).toBeInTheDocument();
// InfoCard
expect(screen.getByText('Info Title')).toBeInTheDocument();
expect(screen.getByText('Info Description')).toBeInTheDocument();
expect(screen.getByText('Footer Content')).toBeInTheDocument();
// CTACard
expect(screen.getByText('CTA Title')).toBeInTheDocument();
expect(screen.getByText('CTA Description')).toBeInTheDocument();
expect(screen.getByText('Click Me')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Click Me/i })).toHaveAttribute('href', '/test');
// ListCard
expect(screen.getByText('List Title')).toBeInTheDocument();
expect(screen.getByText('List Description')).toBeInTheDocument();
expect(screen.getByText('Section 1')).toBeInTheDocument();
expect(screen.getByText('Item A')).toBeInTheDocument();
});
it('renders ListCard with object items (stable keys)', () => {
render(
<BentoGrid.ListCard
sections={[
{
value: 's1',
label: 'Section 1',
items: [
{ key: 'item-1', node: 'Stable Item 1' },
'Raw Item 2'
]
}
]}
title="List Title"
/>
);
expect(screen.getByText('Stable Item 1')).toBeInTheDocument();
expect(screen.getByText('Raw Item 2')).toBeInTheDocument();
});
it('applies span classes correctly', () => {
render(
<BentoGrid>
<BentoGrid.BaseCard colSpan={2} rowSpan={2}>
Spanning Card
</BentoGrid.BaseCard>
</BentoGrid>
);
const card = screen.getByText('Spanning Card');
expect(card).toHaveClass('md:col-span-2');
expect(card).toHaveClass('md:row-span-2');
});
it('applies primary variant to CTACard', () => {
render(
<BentoGrid>
<BentoGrid.CTACard
buttonText="Button"
description="Description"
href="/"
title="Primary CTA"
variant="primary"
/>
</BentoGrid>
);
const title = screen.getByText('Primary CTA');
const card = title.closest('.bg-primary');
expect(card).toBeInTheDocument();
});
});
@@ -1,56 +0,0 @@
import { cn, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { BentoGridCTACard } from './BentoGridCTACard';
import { BentoGridImageCard } from './BentoGridImageCard';
import { BentoGridInfoCard } from './BentoGridInfoCard';
import { BentoGridListCard } from './BentoGridListCard';
import type { BentoGridProps } from './types';
const BentoGridComponent = forwardRef<HTMLDivElement, BentoGridProps>(function BentoGrid(
{ children, className, ...props },
ref
) {
return (
<div
{...props}
ref={ref}
className={cn(
'grid w-full grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4',
className
)}
>
{children}
</div>
);
});
/**
* A responsive Bento Grid system for creating structured, masonry-like layouts.
*
* @example
* ```tsx
* <BentoGrid>
* <BentoGrid.ImageCard
* colSpan={2}
* rowSpan={2}
* src="/image.jpg"
* alt="Showcase"
* overlayTitle="Featured"
* />
* <BentoGrid.InfoCard
* title="Details"
* description="Key features of the project."
* />
* </BentoGrid>
* ```
*/
export const BentoGrid = Object.assign(genericMemo(BentoGridComponent), {
BaseCard: BentoGridBaseCard,
ImageCard: BentoGridImageCard,
InfoCard: BentoGridInfoCard,
CTACard: BentoGridCTACard,
ListCard: BentoGridListCard,
});
@@ -1,55 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
import type { BentoCardBaseProps } from './types';
const baseCardVariants = cva(
'relative overflow-hidden rounded-xl border border-outline bg-base p-6 shadow-sm transition-all hover:shadow-md',
{
variants: {
colSpan: {
1: 'md:col-span-1',
2: 'md:col-span-2',
3: 'md:col-span-3',
4: 'md:col-span-4',
},
rowSpan: {
1: 'md:row-span-1',
2: 'md:row-span-2',
3: 'md:row-span-3',
},
},
defaultVariants: {
colSpan: 1,
rowSpan: 1,
},
}
);
/**
* Base card component that provides the foundational styling for all Bento cards.
* @example Basic usage:
*/
export const BentoGridBaseCard = ({
children,
className,
colSpan,
rowSpan,
href,
...props
}: BentoCardBaseProps) => {
// Render as an anchor when `href` is provided so the whole card is a link,
// otherwise a plain div. Styling and span variants are identical either way.
const Component: React.ElementType = href != null ? 'a' : 'div';
return (
<Component
className={cn(baseCardVariants({ colSpan, rowSpan }), href != null && 'block', className)}
href={href}
{...props}
>
{children}
</Component>
);
};
@@ -1,111 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Button } from '../Button/Button';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
const cardVariants = cva('', {
variants: {
variant: {
default: '',
primary: 'bg-primary border-primary',
},
},
defaultVariants: {
variant: 'default',
},
});
const headerVariants = cva('', {
variants: {
variant: {
default: '',
primary: 'text-primary-text',
},
},
defaultVariants: {
variant: 'default',
},
});
const descriptionVariants = cva('', {
variants: {
variant: {
default: 'text-muted-text',
primary: 'text-primary-text/80',
},
},
defaultVariants: {
variant: 'default',
},
});
/**
*
*/
export interface BentoGridCTACardProps extends BentoCardBaseProps {
/** The title of the call to action */
title: string;
/** The description text */
description: string;
/** The text to display on the action button */
buttonText: string;
/** The destination URL for the action */
href: string;
/**
* Visual variant for the card.
* 'default' uses standard card styling.
* 'primary' uses a more prominent background.
*/
variant?: 'default' | 'primary';
}
/**
* A Bento card designed specifically to drive user action.
* @example Basic usage:
*/
export const BentoGridCTACard = ({
title,
description,
buttonText,
href,
variant = 'default',
className,
...baseProps
}: BentoGridCTACardProps) => {
return (
<BentoGridBaseCard
{...baseProps}
className={cn(cardVariants({ variant }), className)}
>
<VStack className="h-full justify-between" gap={4}>
<VStack gap={2}>
<Header className={cn(headerVariants({ variant }))} level={3}>
{title}
</Header>
<Text
className={cn(descriptionVariants({ variant }))}
size="m"
>
{description}
</Text>
</VStack>
<div className="mt-auto">
<Button
className="w-full sm:w-auto"
href={href}
variant={variant === 'primary' ? 'solid' : 'outline'}
>
{buttonText}
</Button>
</div>
</VStack>
</BentoGridBaseCard>
);
};
@@ -1,70 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Header } from '../Header/Header';
import { Image } from '../Image/Image';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridImageCardProps extends BentoCardBaseProps {
/** Source URL of the image */
src: string;
/** Alt text for the image */
alt: string;
/** Optional title to overlay on the image */
overlayTitle?: string;
/** Optional description to overlay on the image */
overlayDescription?: string;
}
/**
* A Bento card designed to showcase an image with optional textual overlays.
* @example Basic usage:
*/
export const BentoGridImageCard = ({
src,
alt,
overlayTitle,
overlayDescription,
className,
...baseProps
}: BentoGridImageCardProps) => {
const hasOverlay = !!overlayTitle || !!overlayDescription;
return (
<BentoGridBaseCard {...baseProps} className={cn('p-0', className)}>
<div className="relative h-full w-full">
<Image
alt={alt}
className="h-full w-full object-cover"
height="100%"
ratio="auto"
src={src}
/>
{hasOverlay && (
<div className="absolute inset-0 flex flex-col justify-end bg-linear-to-t from-black/80 via-black/40 to-transparent p-6 text-white">
<VStack gap={1}>
{overlayTitle && (
<Header className="text-white" level={3}>
{overlayTitle}
</Header>
)}
{overlayDescription && (
<Text className="text-white/90" size="s">
{overlayDescription}
</Text>
)}
</VStack>
</div>
)}
</div>
</BentoGridBaseCard>
);
};
@@ -1,47 +0,0 @@
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridInfoCardProps extends BentoCardBaseProps {
/** The title of the card */
title: string;
/** The description text */
description?: string;
/** Optional icon or graphic to display at the top */
icon?: React.ReactNode;
/** Optional footer content */
footer?: React.ReactNode;
}
/**
* A versatile Bento card for text-heavy content or feature highlights.
* @example Basic usage:
*/
export const BentoGridInfoCard = ({
title,
description,
icon,
footer,
...baseProps
}: BentoGridInfoCardProps) => {
return (
<BentoGridBaseCard {...baseProps}>
<VStack className="h-full justify-between" gap={4}>
<VStack gap={2}>
{icon && <div className="text-primary-text">{icon}</div>}
<Header level={3}>{title}</Header>
{description && <Text className="text-muted-text" size="m">{description}</Text>}
</VStack>
{footer && <div className="mt-auto w-full">{footer}</div>}
</VStack>
</BentoGridBaseCard>
);
};
@@ -1,126 +0,0 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { Accordion } from '../Accordion';
import { Header } from '../Header/Header';
import { HStack } from '../HStack/HStack';
import { List } from '../List';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridListCardSection {
/** Unique identifier for the accordion section */
value: string;
/** Label text for the accordion trigger */
label: string;
/** Optional icon next to the label */
labelIcon?: React.ReactNode;
/** Optional class name for the label text color (e.g. 'text-rb-orange-600') */
labelClassName?: string;
/** List of items to display when expanded. Can be raw nodes or objects with keys. */
items: (React.ReactNode | { key: string; node: React.ReactNode })[];
}
/**
*
*/
export interface BentoGridListCardProps extends BentoCardBaseProps {
/** Title of the card */
title: string;
/** Description text */
description?: string;
/** Optional icon next to the title. For colored backgrounds, wrap the icon in a styled div. */
icon?: React.ReactNode;
/** Sections containing lists of items, rendered as Accordions */
sections: BentoGridListCardSection[];
/** Which sections are expanded by default (uses section value) */
defaultValue?: string[];
/** Whether multiple sections can be open at once */
type?: 'single' | 'multiple';
}
/**
* A Bento card designed for categorized lists of items using Accordions.
*
* @example
* ```tsx
* <BentoGrid.ListCard
* title="My List"
* sections={[
* { value: 's1', label: 'Section 1', items: ['Item 1', 'Item 2'] }
* ]}
* />
* ```
*/
export const BentoGridListCard = ({
title,
description,
icon,
sections,
defaultValue,
type = 'multiple',
...baseProps
}: BentoGridListCardProps) => {
return (
<BentoGridBaseCard {...baseProps} className={cn('flex flex-col', baseProps.className)}>
<VStack className="h-full" gap={4}>
<HStack align="center" gap={3}>
{icon && <div>{icon}</div>}
<Header level={3}>{title}</Header>
</HStack>
{description && (
<Text className="text-muted-text">
{description}
</Text>
)}
<div className="flex-grow w-full">
<Accordion collapsible defaultValue={defaultValue} type={type}>
{sections.map((section) => (
<Accordion.Item key={section.value} value={section.value}>
<Accordion.Trigger className={section.labelClassName}>
<HStack align="center" gap={2}>
{section.labelIcon}
<Text size="l" type="body">{section.label}</Text>
</HStack>
</Accordion.Trigger>
<Accordion.Content>
<List>
{section.items.map((item, index) => {
const content = (() => {
if (item && typeof item === 'object' && 'node' in item) {
return item.node;
}
return item;
})();
const itemKey =
item && typeof item === 'object' && 'key' in item
? item.key
: typeof item === 'string'
? item
: index;
return (
<List.Item key={itemKey}>
<Text size="m">{content}</Text>
</List.Item>
);
})}
</List>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>
</div>
</VStack>
</BentoGridBaseCard>
);
};
@@ -1,7 +0,0 @@
export * from './BentoGrid';
export * from './types';
export * from './BentoGridBaseCard';
export * from './BentoGridImageCard';
export * from './BentoGridInfoCard';
export * from './BentoGridCTACard';
export * from './BentoGridListCard';
@@ -1,47 +0,0 @@
import type React from 'react';
/**
* Common props for all BentoGrid card variations.
*
* Typed against `HTMLElement` since a card can render as either a `div` or,
* when `href` is provided, an `a`.
*/
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLElement> {
/**
* Number of columns to span on medium screens and up.
* @default 1
*/
colSpan?: 1 | 2 | 3 | 4;
/**
* Number of rows to span on medium screens and up.
* @default 1
*/
rowSpan?: 1 | 2 | 3;
/**
* Additional CSS classes.
*/
className?: string;
/**
* The content of the card.
*/
children?: React.ReactNode;
/**
* If provided, the card renders as an anchor (`<a>`) so the entire card is a
* navigable link. Card styling and colSpan/rowSpan are preserved.
*/
href?: string;
}
/**
* Props for the root BentoGrid component.
*/
export interface BentoGridProps {
/**
* The cards to display in the grid.
*/
children: React.ReactNode;
/**
* Additional CSS classes.
*/
className?: string;
}
@@ -19,7 +19,9 @@ export interface BlockquoteProps
indent?: boolean;
}
const blockquoteVariants = cva('border-l-4 border-[var(--rb-primary)]', {
const blockquoteVariants = cva(
'border-l-4 border-[var(--rb-primary)] [&>p:last-child]:mb-0 [&>*:last-child]:mb-0',
{
variants: {
variant: {
default: '',
@@ -24,6 +24,18 @@ describe('components/Breadcrumbs', () => {
expect(currentItem).toHaveAttribute('aria-current', 'page');
});
it('applies typography tokens to root container and items inherit size', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item href="/test">Home</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item isCurrent>Current</Breadcrumbs.Item>
</Breadcrumbs>
);
const ol = screen.getByRole('list');
expect(ol.className).toContain('text-label-m');
});
it('supports isCurrent state', () => {
render(
<Breadcrumbs>
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more