feat: Add more base component to UI

This commit is contained in:
deytenit committed 2026-02-01 16:24:45 +03:00
1 parent ca20310460
commit df2abc06f2
116 files changed
+10379 -3473

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
build
coverage
storybook-static
.next
.astro
public
**/public/**
**/dist/**
**/node_modules/**
+136 -167
View File
@@ -1,6 +1,6 @@
---
name: write-docs
description: "Use this to write uniform comprehensive documentation in ermnvldmr.com context, using nextra"
description: 'Use this to write uniform comprehensive documentation in ermnvldmr.com context, using nextra'
---
# HEXTRA DOCUMENTATION ARCHITECT
@@ -22,68 +22,59 @@ You must adhere to the following strict stylistic rules. There are no exceptions
### 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.
- **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** 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 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").
- **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** 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 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.
- **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** 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).
* **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."
* **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!"
- **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."
- **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.
- **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.
---
@@ -93,24 +84,23 @@ This is the most critical directive. You are a documentation engine, not a creat
### 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.
- **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):
- **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."
- **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."
---
@@ -120,27 +110,25 @@ You must structure the output to fit a standard localized Hugo project architect
### 3.1 File Locations (MUST)
* **MUST** assume documentation lives in the following localized directories:
* `content/en/` (English)
* `content/ru/` (Russian)
- **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.
- **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** 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**).
- **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.
- **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.
---
@@ -152,84 +140,74 @@ A consistent glossary and linking strategy is mandatory for maintainable documen
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:*
- **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** 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.
- **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 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)`
* **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.
- **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).
- **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:**
* **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)`
- **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.
* **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`)
- **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`)
---
@@ -239,34 +217,26 @@ You must use Hextra's advanced features to create structured, interactive docume
### 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)`
- **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).
* **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" >}}`
- **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.
- **"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)
@@ -276,22 +246,21 @@ Use for large screenshots, detailed logs, or deep-dive explanations that disrupt
```markdown
{{% details title="Click to reveal configuration details" closed="true" %}}
1. Detailed step one...
![Large Screenshot](/images/screen.png "Settings Config")
{{% /details %}}
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).
Use when the user must choose _one_ path (e.g., OS specific, Language specific).
```markdown
{{< tabs items="JSON,YAML" >}}
{{< tab >}} **JSON** content here... {{< /tab >}}
{{< tab >}} **YAML** content here... {{< /tab >}}
{{< tab >}} **JSON** content here... {{< /tab >}}
{{< tab >}} **YAML** content here... {{< /tab >}}
{{< /tabs >}}
```
#### C. Steps (Procedures)
@@ -300,25 +269,26 @@ 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).
- _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)
@@ -327,11 +297,10 @@ Use to visualize folder layouts.
```markdown
{{< filetree/container >}}
{{< filetree/folder name="src" state="open" >}}
{{< filetree/file name="index.js" >}}
{{< /filetree/folder >}}
{{< filetree/folder name="src" state="open" >}}
{{< filetree/file name="index.js" >}}
{{< /filetree/folder >}}
{{< /filetree/container >}}
```
#### F. Alerts/Callouts
@@ -344,7 +313,6 @@ Use GitHub-flavored alerts for emphasis.
> [!WARNING]
> This action is irreversible.
```
---
@@ -364,46 +332,47 @@ This is also serves as the fixed categories which should populate every project'
### 6.2 Quick Start (Beginner)
* **Goal:** Minimal steps to "Hello World".
* **Structure:**
- **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:**
- **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 / Recipe (Task-Oriented)
* **Goal:** Solve a specific problem.
* **Structure:**
- **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:**
- **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:**
- **Goal:** Fix a broken state.
- **Structure:**
1. **Symptoms:** Error logs, behaviors.
2. **Diagnosis:** How to confirm the issue.
3. **Solution:** Steps to resolve.
@@ -417,18 +386,18 @@ 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".
- _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?
- 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.
@@ -3,7 +3,7 @@ name: packages/stl - next/required
on:
push:
branches: [next]
paths:
paths:
- 'packages/stl/**'
- 'pnpm-lock.yaml'
@@ -3,7 +3,7 @@ name: packages/ui - next/required
on:
push:
branches: [next]
paths:
paths:
- 'packages/ui/**'
- 'packages/stl/**'
- 'pnpm-lock.yaml'
@@ -3,7 +3,7 @@ name: services/docs - next/required
on:
push:
branches: [next]
paths:
paths:
- 'services/docs/**'
concurrency:
@@ -106,4 +106,3 @@ jobs:
# Use the short SHA to match the tag from release-docker
export DOCS_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
./ci/scripts/deploy-new-tags.sh
@@ -3,7 +3,7 @@ name: services/www - next/required
on:
push:
branches: [next]
paths:
paths:
- 'services/www/**'
- 'packages/ui/**'
- 'packages/stl/**'
@@ -299,4 +299,4 @@ jobs:
file: ci/docker/www/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
@@ -304,7 +304,7 @@ jobs:
name: Deploy - VPS
needs: release-tag-docker
runs-on: ubuntu-latest
environment:
environment:
name: services-www-releases-deploy
url: https://ermnvldmr.com
steps:
@@ -321,4 +321,4 @@ jobs:
chmod +x ci/scripts/deploy-new-tags.sh
# Use the short SHA to match the tag from release-tag-docker
export WWW_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
./ci/scripts/deploy-new-tags.sh
./ci/scripts/deploy-new-tags.sh
@@ -289,4 +289,4 @@ jobs:
file: ci/docker/www/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
build
coverage
storybook-static
.next
.astro
public
**/public/**
**/dist/**
**/node_modules/**
pnpm-lock.yaml
services/docs/themes/hextra
+1 -2
View File
@@ -4,6 +4,5 @@
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false,
"plugins": ["prettier-plugin-astro", "prettier-plugin-react", "prettier-plugin-tailwindcss"]
"useTabs": false
}
+45 -5
View File
@@ -5,6 +5,7 @@
**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.
### Monorepo Architecture
- **Repository Structure**: pnpm workspace monorepo
- **Package Manager**: pnpm v9.12.3+
- **Core Technology**: TypeScript (Strict mode)
@@ -14,6 +15,7 @@
- **CI/CD**: GitHub Actions with path-based triggers
### Design Philosophy
- **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
@@ -26,7 +28,7 @@
```
├── packages/ # Shared packages and configurations
│ ├── eslint-config/ # Shared ESLint configurations
│ ├── jest-config/ # Shared Jest configurations
│ ├── jest-config/ # Shared Jest configurations
│ ├── storybook-config/ # Shared Storybook configurations
│ ├── vite-config/ # Shared Vite configurations
│ ├── stl/ # Standard Type Library (utilities)
@@ -42,22 +44,26 @@
### Package Organization
#### Configuration Packages (`packages/`)
- **eslint-config**: Shared ESLint rules for TypeScript, React, and Astro
- **jest-config**: Shared Jest configurations for different project types
- **storybook-config**: Shared Storybook setup and configurations
- **vite-config**: Shared Vite configurations for library builds
#### Library Packages (`packages/`)
- **stl** (Standard Type Library): Common utilities, types, and helper functions
- **ui**: React component library with design system and Storybook stories
#### Service Packages (`services/`)
- **www**: Main website built with Astro.js, React, and Tailwind CSS
- **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
@@ -65,6 +71,7 @@
- **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
@@ -74,24 +81,30 @@
## 🛠️ 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
@@ -101,9 +114,11 @@ Services consume packages but are independent of each other. They can be develop
## 📝 Coding Conventions
### Package Naming Convention
All packages use the `@ermnvldmr/` namespace:
- `@ermnvldmr/eslint-config` - Shared ESLint configurations
- `@ermnvldmr/jest-config` - Shared Jest configurations
- `@ermnvldmr/jest-config` - Shared Jest configurations
- `@ermnvldmr/storybook-config` - Shared Storybook configurations
- `@ermnvldmr/vite-config` - Shared Vite configurations
- `@ermnvldmr/stl` - Standard Type Library
@@ -112,21 +127,23 @@ All packages use the `@ermnvldmr/` namespace:
- `@ermnvldmr/docs` - Documentation service
### TypeScript Guidelines
- **Strict Mode**: All TypeScript strict checks must pass
- **Explicit Types**: Avoid `any` and prefer explicit type definitions
- **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'
@@ -139,7 +156,9 @@ export function cn(...inputs: ClassValue[]): string {
```
### Component Development Patterns
#### UI Package Components
```typescript
/**
* Props for the Button component.
@@ -170,6 +189,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
```
### 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`)
@@ -179,6 +199,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
### Module Structure Patterns
#### Package Structure
```
packages/ui/
├── src/
@@ -195,6 +216,7 @@ packages/ui/
```
#### Service Structure (www example)
```
services/www/
├── src/
@@ -211,18 +233,21 @@ services/www/
## 🎨 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';
@@ -237,9 +262,11 @@ export { Separator } from './components/paper/PaperSeparator/Separator';
## ⚙️ Configuration Management
### Shared Configurations
Each configuration package provides standardized setups:
#### ESLint Configuration
```javascript
// Provides: base, react, astro configurations
import { base } from '@ermnvldmr/eslint-config';
@@ -248,6 +275,7 @@ import { astro } from '@ermnvldmr/eslint-config/astro';
```
#### Jest Configuration
```typescript
// Provides: base, react configurations
import { base } from '@ermnvldmr/jest-config/base';
@@ -255,13 +283,16 @@ import { react } from '@ermnvldmr/jest-config/react';
```
#### Vite Configuration
```typescript
// Provides: library build configurations
import { defineLibConfig } from '@ermnvldmr/vite-config/lib';
```
### Service Configuration Inheritance
Services inherit from shared configs but can override:
```typescript
// In service package.json devDependencies
"@ermnvldmr/eslint-config": "workspace:*"
@@ -271,6 +302,7 @@ Services inherit from shared configs but can override:
## 🚀 Build & Deployment
### Build Dependencies
The monorepo must respect a strict build order due to package dependencies:
1. **Configuration packages** (eslint-config, jest-config, etc.) - Foundation tooling
@@ -279,7 +311,9 @@ The monorepo must respect a strict build order due to package dependencies:
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
@@ -288,12 +322,14 @@ The repository uses path-based CI/CD triggers - each package and service has ded
## 🔧 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
@@ -302,6 +338,7 @@ To start development, follow the dependency hierarchy:
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
@@ -313,6 +350,7 @@ Use pnpm workspace filtering (`--filter`) to target specific packages or service
## 🧪 Testing Strategy
### Testing Framework Distribution
- **Shared Configuration**: Jest configs in `@ermnvldmr/jest-config`
- **Package Testing**: Each package has its own test suite
- **Service Testing**: Services test their specific functionality
@@ -320,6 +358,7 @@ Use pnpm workspace filtering (`--filter`) to target specific packages or service
- **Storybook Testing**: Visual regression and interaction testing
### Test Organization
```typescript
// Package-level testing (packages/stl/src/functions/cn/cn.test.ts)
describe('cn function', () => {
@@ -338,10 +377,11 @@ describe('components/paper/Button/Button', () => {
```
### 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.*
_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._
+6 -6
View File
@@ -49,9 +49,9 @@ Want to contribute or understand the codebase.
- **Setup**: `pnpm install`
- **Development**:
- Website: `pnpm www:dev`
- Documentation: `pnpm docs:dev`
- UI Library: `pnpm ui:dev`
- Website: `pnpm www:dev`
- Documentation: `pnpm docs:dev`
- UI Library: `pnpm ui:dev`
- **Development Guide**: [AGENTS.md](./AGENTS.md)
- **Component Development**: `pnpm ui:storybook`
- **Testing**: `pnpm test` or `pnpm test:coverage` (View results on [Codecov](https://app.codecov.io/github/deytenit/ermnvldmr.com))
@@ -63,7 +63,7 @@ Interested in the design system and components.
- **Components**: Run `pnpm ui:storybook` to explore components
- **Design System**: See [packages/ui/src/styles/index.css](./packages/ui/src/styles/index.css)
for the color palette and theme
for the color palette and theme
- **Theme Structure**: CSS variables with light/dark mode support
### Contributor
@@ -87,12 +87,12 @@ Want to submit improvements.
**Documentation & Informational Content**: Technical documentation, guides, and informational articles within `services/docs/content/` and `services/www/content/` are licensed under [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/).
**Creative Literature & Art**: Poems, personal essays, artworks, photography, and other purely creative or literary works are **individually copyrighted by their respective owners**. These works are *not* open-source and are *not* covered by the GPL or CC BY 4.0 licenses. They remain under full copyright protection unless explicitly stated otherwise within their specific file metadata or directory.
**Creative Literature & Art**: Poems, personal essays, artworks, photography, and other purely creative or literary works are **individually copyrighted by their respective owners**. These works are _not_ open-source and are _not_ covered by the GPL or CC BY 4.0 licenses. They remain under full copyright protection unless explicitly stated otherwise within their specific file metadata or directory.
---
**AI Assistance**:
Significant portions of this project's *source code* were developed with
Significant portions of this project's _source code_ were developed with
the assistance of AI code generation tools.
Copyright © 2026 Vladimir Eremin
+2 -2
View File
@@ -6,8 +6,8 @@ services:
container_name: com-ermnvldmr-nginx
restart: always
ports:
- "80:80"
- "443:443"
- '80:80'
- '443:443'
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
+7
View File
@@ -16,6 +16,13 @@ export default tseslint.config(
{
ignores: [
'services/docs/**/*', // Hugo content, no linting needed
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/coverage/**',
'**/storybook-static/**',
'**/.next/**',
'**/.astro/**',
],
},
+17 -15
View File
@@ -29,10 +29,7 @@ export const baseConfig = tseslint.config(
// TypeScript - Strict Configuration (only for .ts/.tsx files)
{
files: ['**/*.{ts,tsx,mts,cts}'],
extends: [
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
extends: [...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.stylisticTypeChecked],
languageOptions: {
parserOptions: {
projectService: true,
@@ -81,14 +78,7 @@ export const baseConfig = tseslint.config(
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
['parent', 'sibling'],
'index',
'type',
],
groups: ['builtin', 'external', 'internal', ['parent', 'sibling'], 'index', 'type'],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
},
@@ -129,16 +119,16 @@ export const baseConfig = tseslint.config(
},
},
// Test files
// Test files and Stories
{
files: ['**/*.{test,spec}.{js,jsx,ts,tsx}', '.config/jest/**/*.{js,jsx,ts,tsx}'],
files: ['**/*.{test,spec,stories}.{js,jsx,ts,tsx}', '.config/jest/**/*.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.jest,
},
},
rules: {
// Relax rules for test files and jest configuration
// Relax rules for test files, stories and jest configuration
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
@@ -146,7 +136,19 @@ export const baseConfig = tseslint.config(
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/consistent-type-assertions': 'off',
// Disable all JSDoc rules
'jsdoc/require-jsdoc': 'off',
'jsdoc/require-description': 'off',
'jsdoc/require-param': 'off',
'jsdoc/require-returns': 'off',
'jsdoc/require-example': 'off',
'jsdoc/check-alignment': 'off',
'jsdoc/check-indentation': 'off',
'jsdoc/check-syntax': 'off',
'jsdoc/check-tag-names': 'off',
'jsdoc/check-types': 'off',
'jsdoc/valid-types': 'off',
},
},
+3 -6
View File
@@ -2,11 +2,11 @@ import type { Config } from 'jest';
/**
* Base Jest configuration for TypeScript projects.
*
*
* @example
* ```typescript
* import { baseJestConfig } from '@ermnvldmr/jest-config/base';
*
*
* export default {
* ...baseJestConfig,
* // package-specific overrides
@@ -36,8 +36,5 @@ export const baseJestConfig: Config = {
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
testMatch: [
'**/__tests__/**/*.{ts,tsx}',
'**/*.{test,spec}.{ts,tsx}',
],
testMatch: ['**/__tests__/**/*.{ts,tsx}', '**/*.{test,spec}.{ts,tsx}'],
};
+2 -2
View File
@@ -3,11 +3,11 @@ import { baseJestConfig } from './base.js';
/**
* Jest configuration for React projects with jsdom environment.
*
*
* @example
* ```typescript
* import { reactJestConfig } from '@ermnvldmr/jest-config/react';
*
*
* export default reactJestConfig;
* ```
*/
+10 -12
View File
@@ -19,10 +19,10 @@ describe('shared/helpers/cn/cn', () => {
// Test with actual conditional values (simulating real usage)
const activeCondition = Math.random() > -1; // Always true but not constant
const inactiveCondition = Math.random() < -1; // Always false but not constant
expect(cn('p-4', activeCondition && 'bg-red-500')).toBe('p-4 bg-red-500');
expect(cn('p-4', inactiveCondition && 'bg-red-500')).toBe('p-4');
// Test null and undefined conditions
const nullCondition: string | null = null;
const undefinedCondition: string | undefined = undefined;
@@ -36,7 +36,9 @@ describe('shared/helpers/cn/cn', () => {
});
it('handles array inputs', () => {
expect(cn(['flex', 'items-center'], 'justify-between')).toBe('flex items-center justify-between');
expect(cn(['flex', 'items-center'], 'justify-between')).toBe(
'flex items-center justify-between'
);
expect(cn(['p-4', null, 'bg-red-500'])).toBe('p-4 bg-red-500');
});
@@ -50,11 +52,11 @@ describe('shared/helpers/cn/cn', () => {
const getIsActive = (): boolean => true;
const getPrimaryVariant = (): 'primary' | 'secondary' => 'primary';
const getSecondaryVariant = (): 'primary' | 'secondary' => 'secondary';
const isActive = getIsActive();
const primaryVariant = getPrimaryVariant();
const secondaryVariant = getSecondaryVariant();
// Test primary variant
expect(
cn(
@@ -84,12 +86,8 @@ describe('shared/helpers/cn/cn', () => {
});
it('properly handles spacing and responsive classes', () => {
expect(
cn('p-2 p-4', 'sm:p-6', 'md:p-8')
).toBe('p-4 sm:p-6 md:p-8');
expect(
cn('m-2', 'mx-4', 'ml-6')
).toBe('m-2 mx-4 ml-6');
expect(cn('p-2 p-4', 'sm:p-6', 'md:p-8')).toBe('p-4 sm:p-6 md:p-8');
expect(cn('m-2', 'mx-4', 'ml-6')).toBe('m-2 mx-4 ml-6');
});
});
+3 -3
View File
@@ -3,13 +3,13 @@ import { twMerge } from 'tailwind-merge';
/**
* Combines and merges CSS class names using clsx and tailwind-merge.
*
*
* This utility function combines the power of clsx for conditional class handling
* with tailwind-merge for proper Tailwind CSS class deduplication and conflict resolution.
*
*
* @param inputs - Class values to combine (strings, objects, arrays, etc.)
* @returns Merged class string with conflicts resolved
*
*
* @example
* ```typescript
* cn('px-4 py-2', 'bg-blue-500') // 'px-4 py-2 bg-blue-500'
+1 -1
View File
@@ -2,4 +2,4 @@
* Shared helper utilities for common functionality.
*/
export { cn } from './cn/cn';
export { cn } from './cn/cn';
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './types';
export * from './functions';
export * from './react';
export * from './react';
+5 -5
View File
@@ -2,9 +2,9 @@ import type React from 'react';
/**
* Safely casts a React ref to a specific type.
* This is a controlled escape hatch for situations where TypeScript's
* This is a controlled escape hatch for situations where TypeScript's
* built-in Ref types are too restrictive (e.g., in generic forwardRef components).
*
*
* @param ref - The ref to cast
* @returns The casted ref
* @example
@@ -13,7 +13,7 @@ import type React from 'react';
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function castRef<T>(ref: React.ForwardedRef<any>): React.ForwardedRef<T> {
export function castRef<T>(ref: React.ForwardedRef<any>): React.RefObject<T> {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return ref as React.ForwardedRef<T>;
}
return ref as unknown as React.RefObject<T>;
}
@@ -6,7 +6,7 @@ describe('react/genericMemo', () => {
it('should memoize a component', () => {
const Component = ({ name }: { name: string }) => <div>{name}</div>;
const Memoized = genericMemo(Component);
expect(Memoized).toBeDefined();
// In React, memo returns a special object with type: component
expect((Memoized as any).type).toBe(Component);
@@ -2,7 +2,7 @@ import React from 'react';
/**
* A type-safe version of React.memo that preserves generic types.
*
*
* @param component - The component to memoize
* @returns The memoized component
* @example
@@ -13,4 +13,4 @@ import React from 'react';
export function genericMemo<T>(component: T): T {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
return React.memo(component as any) as any as T;
}
}
+2 -1
View File
@@ -7,4 +7,5 @@ import type React from 'react';
* Common props for any React element.
* Useful for forwarding props to dynamic components.
*/
export type ReactElementProps = React.HTMLAttributes<HTMLElement> & React.AllHTMLAttributes<HTMLElement>;
export type ReactElementProps = React.HTMLAttributes<HTMLElement> &
React.AllHTMLAttributes<HTMLElement>;
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Props interface for components that accept a className prop.
*
*
* @example
* ```typescript
* interface ButtonProps extends ClassNameProps {
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Props interface for components that accept a test ID prop for React Testing Library.
*
*
* @example
* ```typescript
* interface ButtonProps extends TestIdProps {
+1 -1
View File
@@ -25,7 +25,7 @@ export const baseStorybookConfig: Partial<StorybookConfig> = {
try {
const tailwindcss = (await import('@tailwindcss/vite')).default;
const istanbul = (await import('vite-plugin-istanbul')).default;
return mergeConfig(config, {
plugins: [
tailwindcss(),
+2 -2
View File
@@ -3,11 +3,11 @@ import type { Preview } from '@storybook/react';
/**
* Base preview configuration for theme support.
*
*
* @example
* ```typescript
* import { basePreviewConfig } from '@ermnvldmr/storybook-config/preview';
*
*
* export default {
* ...basePreviewConfig,
* // additional overrides
@@ -1,6 +1,5 @@
import { Article } from './Article';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Article> = {
@@ -21,7 +20,8 @@ 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.',
children:
'In a shocking turn of events, a large language model has successfully implemented a UI component from scratch while maintaining design system consistency.',
},
};
@@ -30,7 +30,8 @@ export const Full: Story = {
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.',
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.',
},
};
@@ -3,7 +3,7 @@ import React from 'react';
import { Article } from './Article';
import { render, screen } from '../../test-utils';
describe('components/paper/Article', () => {
describe('components/Article', () => {
const props = {
headline: 'Breaking News',
subHeadline: 'Local cat saves city',
@@ -12,7 +12,7 @@ describe('components/paper/Article', () => {
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();
@@ -24,7 +24,11 @@ describe('components/paper/Article', () => {
// Should have 1 separator (between title and body)
expect(screen.getAllByRole('separator')).toHaveLength(1);
rerender(<Article headline="Title" subHeadline="Sub">Body</Article>);
rerender(
<Article headline="Title" subHeadline="Sub">
Body
</Article>
);
// Should have 2 separators (Title-Sub and Sub-Body)
expect(screen.getAllByRole('separator')).toHaveLength(2);
});
+16 -14
View File
@@ -26,7 +26,7 @@ export interface ArticleProps {
/**
* A article component with a structured layout.
*
*
* Features:
* - Centered medium headline (headline-m)
* - Single-line separators
@@ -48,25 +48,27 @@ export const Article = memo(function Article({
<Header className="text-center" level={4}>
{headline}
</Header>
<VStack align="center" gap={2}>
{subHeadline ? (
<>
<Separator className="w-3/4" type="single" />
<Text italic className="text-center" size="m" type="label">
{subHeadline}
</Text>
<Separator className="w-3/4" type="single" />
</>
) : <Separator className="w-3/4" type="single" />}
</VStack>
<VStack align="center" gap={2}>
{subHeadline ? (
<>
<Separator className="w-3/4" type="single" />
<Text italic className="text-center" size="m" type="label">
{subHeadline}
</Text>
<Separator className="w-3/4" type="single" />
</>
) : (
<Separator className="w-3/4" type="single" />
)}
</VStack>
<Text size="m" type="body">
{children}
</Text>
{additionalText && (
<VStack align='end'>
<VStack align="end">
<Text italic color="muted" size="s" type="label">
{additionalText}
</Text>
@@ -0,0 +1,38 @@
import React from 'react';
import { Blockquote } from './Blockquote';
import { Paragraph } from '../Paragraph/Paragraph';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Blockquote> = {
title: 'Components/Blockquote',
component: Blockquote,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Blockquote>;
export const Default: Story = {
render: (args) => (
<Blockquote {...args}>
<Paragraph>
Design is not just what it looks like and feels like. Design is how it works.
</Paragraph>
<Blockquote.Citation>Steve Jobs</Blockquote.Citation>
</Blockquote>
),
};
export const Inset: Story = {
args: {
variant: 'inset',
},
render: (args) => (
<Blockquote {...args}>
<Paragraph italic>The only way to do great work is to love what you do.</Paragraph>
<Blockquote.Citation>Steve Jobs</Blockquote.Citation>
</Blockquote>
),
};
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Blockquote } from './Blockquote';
describe('Blockquote', () => {
it('renders children correctly', () => {
render(<Blockquote>Quote content</Blockquote>);
expect(screen.getByText('Quote content')).toBeInTheDocument();
});
it('renders as a blockquote element', () => {
const { container } = render(<Blockquote>Quote</Blockquote>);
expect(container.querySelector('blockquote')).toBeInTheDocument();
});
it('applies gutterBottom class when prop is true', () => {
const { container } = render(<Blockquote gutterBottom>Quote</Blockquote>);
expect(container.querySelector('blockquote')).toHaveClass('mb-6');
});
it('applies inset styles when variant is inset', () => {
const { container } = render(<Blockquote variant="inset">Quote</Blockquote>);
expect(container.querySelector('blockquote')).toHaveClass('bg-[var(--rb-surface-variant)]');
});
it('renders citation with prefix', () => {
render(
<Blockquote>
Quote
<Blockquote.Citation>Author Name</Blockquote.Citation>
</Blockquote>
);
expect(screen.getByText('— Author Name')).toBeInTheDocument();
});
});
@@ -0,0 +1,83 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { Text } from '../Text/Text';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
*
*/
export interface BlockquoteProps
extends ClassNameProps,
TestIdProps,
React.BlockquoteHTMLAttributes<HTMLQuoteElement> {
children: React.ReactNode;
variant?: 'default' | 'inset';
gutterBottom?: boolean;
indent?: boolean;
}
const BlockquoteComponent = forwardRef<HTMLQuoteElement, BlockquoteProps>(function Blockquote(
{
children,
variant = 'default',
gutterBottom = false,
indent = true,
className,
'data-testid': testId,
...props
},
ref
) {
return (
<blockquote
{...props}
ref={ref}
className={cn(
'border-l-4 border-[var(--rb-primary)]',
indent && 'pl-6',
gutterBottom && 'mb-6',
variant === 'inset' && 'bg-[var(--rb-surface-variant)] p-6 rounded-r-lg',
className
)}
data-testid={testId}
>
{children}
</blockquote>
);
});
/**
*
*/
export interface BlockquoteCitationProps
extends ClassNameProps,
TestIdProps,
Omit<React.HTMLAttributes<HTMLElement>, 'prefix'> {
children: React.ReactNode;
prefix?: React.ReactNode;
}
const CitationComponent = forwardRef<HTMLElement, BlockquoteCitationProps>(function Citation(
{ children, prefix = '— ', className, 'data-testid': testId, ...props },
ref
) {
return (
<footer
{...props}
ref={castRef<HTMLElement>(ref)}
className={cn('mt-4 flex items-center', className)}
data-testid={testId}
>
<Text color="muted" size="m" type="label">
{prefix}
{children}
</Text>
</footer>
);
});
export const Blockquote = Object.assign(genericMemo(BlockquoteComponent), {
Citation: genericMemo(CitationComponent),
});
@@ -0,0 +1 @@
export * from './Blockquote';
@@ -1,10 +1,10 @@
import React from 'react';
import { Breadcrumbs } from './Breadcrumbs';
import { render, screen } from '../../../test-utils';
import { render, screen } from '../../test-utils';
import { Link } from '../Link/Link';
describe('components/generic/Breadcrumbs', () => {
describe('components/Breadcrumbs', () => {
it('renders correctly with multiple items and separators', () => {
render(
<Breadcrumbs>
@@ -15,7 +15,7 @@ describe('components/generic/Breadcrumbs', () => {
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
</Breadcrumbs>
);
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByText('/')).toBeInTheDocument();
@@ -32,11 +32,11 @@ const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: Breadc
/**
* Individual step in the breadcrumb trail.
*/
const BreadcrumbsItem = memo(function BreadcrumbsItem({
children,
const BreadcrumbsItem = memo(function BreadcrumbsItem({
children,
className,
'data-testid': testId,
}: {
}: {
children: React.ReactNode;
className?: string;
} & TestIdProps) {
@@ -57,7 +57,7 @@ export interface BreadcrumbsSeparatorProps extends ClassNameProps, TestIdProps {
variant?: 'slash' | 'bull' | 'arrow';
}
const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
variant = 'slash',
className,
'data-testid': testId,
@@ -69,8 +69,8 @@ const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
};
return (
<li
aria-hidden="true"
<li
aria-hidden="true"
className={cn('text-[var(--rb-muted-text)]', className)}
data-testid={testId}
>
@@ -83,10 +83,10 @@ const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
/**
* A hierarchical navigation component.
*
*
* Breadcrumbs provides a flexible way to display the current location within
* a site's hierarchy. It uses a compound component pattern for maximum flexibility.
*
*
* @example
* ```tsx
* <Breadcrumbs>
@@ -4,125 +4,188 @@ import { Button } from './Button';
import type { Meta, StoryObj } from '@storybook/react';
/**
* A newspaper-style button component with italic Lato font and underlined text.
*
* Features:
* - Rectangular design with subtle background
* - Italic Medium Lato font (500 weight)
* - Underlined text decoration
* - Accessible interaction patterns via react-aria
* - Responsive hover and focus states
*/
const meta: Meta<typeof Button> = {
title: 'components/Button',
component: Button,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A newspaper-style button following traditional print design patterns.',
},
},
},
tags: ['autodocs'],
argTypes: {
onPress: { action: 'pressed' },
variant: {
control: 'select',
options: ['default', 'primary', 'secondary'],
description: 'Visual style variant',
options: ['solid', 'outline', 'ghost', 'link'],
},
children: {
control: 'text',
description: 'The button text content (string only)',
color: {
control: 'select',
options: [
'primary',
'primary-negative',
'secondary',
'secondary-negative',
'tertiary',
'tertiary-negative',
'error',
'error-negative',
'neutral',
'neutral-negative',
],
},
isDisabled: {
control: 'boolean',
description: 'Whether the button is disabled',
size: {
control: 'radio',
options: ['s', 'm', 'l'],
},
rounded: {
control: 'radio',
options: ['none', 'md', 'full'],
},
},
args: {
children: 'Explore all the articles',
variant: 'default',
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof meta>;
/**
* The default newspaper-style button with underlined italic text.
* Story helper type.
*/
type Story = StoryObj<typeof Button>;
/**
* The default Button story demonstrating the standard configuration.
*/
export const Default: Story = {
args: {
children: 'Explore all the articles',
onPress: () => console.log('Default button pressed'),
children: 'Button',
variant: 'solid',
color: 'primary',
size: 'm',
},
};
/**
* Primary variant with enhanced visual prominence.
* A gallery of all available Button variants.
*/
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Read more stories',
onPress: () => console.log('Primary button pressed'),
},
export const Variants: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex gap-4 items-center">
<Button variant="solid">Solid</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link Variant</Button>
</div>
</div>
),
};
/**
* Secondary variant for supporting actions.
* A gallery of all available Button color themes, including negative variants.
*/
export const Secondary: Story = {
export const Colors: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex gap-4 flex-wrap">
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="tertiary">Tertiary</Button>
<Button color="error">Error</Button>
<Button color="neutral">Neutral</Button>
</div>
<div className="flex gap-4 flex-wrap p-4 bg-slate-900 rounded">
<Button color="primary-negative">Primary Neg</Button>
<Button color="secondary-negative">Secondary Neg</Button>
<Button color="tertiary-negative">Tertiary Neg</Button>
<Button color="error-negative">Error Neg</Button>
<Button color="neutral-negative">Neutral Neg</Button>
</div>
</div>
),
};
/**
* A comparison of the available Button sizes.
*/
export const Sizes: Story = {
render: () => (
<div className="flex gap-4 items-end">
<Button size="s">Small</Button>
<Button size="m">Medium</Button>
<Button size="l">Large</Button>
</div>
),
};
/**
* A comparison of the available border radius variants.
*/
export const Rounded: Story = {
render: () => (
<div className="flex gap-4 items-center">
<Button rounded="none">None</Button>
<Button rounded="md">Medium (Default)</Button>
<Button rounded="full">Full (Pill)</Button>
</div>
),
};
/**
* Helper component to render a simple SVG icon.
* @param className - CSS class names for styling.
* @returns An SVG icon element.
* @example
* <Icon className="w-4 h-4" />
*/
const Icon = (className: string): React.ReactNode => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
);
/**
* Story demonstrating the Button component with both start and end icons.
*/
export const WithIcons: Story = {
render: () => (
<div className="flex gap-4">
<Button renderStartIcon={Icon}>Start Icon</Button>
<Button renderEndIcon={Icon}>End Icon</Button>
<Button renderEndIcon={Icon} renderStartIcon={Icon}>
Both Icons
</Button>
<Button renderStartIcon={Icon} />
</div>
),
};
/**
* Story demonstrating the Button component acting as a link (polymorphic 'a' tag).
*/
export const AsLink: Story = {
args: {
variant: 'secondary',
children: 'Subscribe now',
onPress: () => console.log('Secondary button pressed'),
children: 'Go to Example',
href: 'https://example.com',
target: '_blank',
},
};
/**
* Disabled state of the button.
* Story demonstrating the disabled state of the Button component.
*/
export const Disabled: Story = {
args: {
children: 'Unavailable action',
children: 'Disabled Button',
isDisabled: true,
},
};
/**
* Long text content to test text wrapping and layout.
* Story demonstrating the full width capability of the Button component.
*/
export const LongText: Story = {
export const FullWidth: Story = {
args: {
children: 'Explore all the fascinating articles and stories from around the world',
onPress: () => console.log('Long text button pressed'),
children: 'Full Width Button',
fullWidth: true,
},
};
/**
* Interactive demonstration showing all variants side by side.
*/
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 items-start">
<Button variant="default" onPress={() => console.log('Default')}>
Default Style
</Button>
<Button variant="primary" onPress={() => console.log('Primary')}>
Primary Style
</Button>
<Button variant="secondary" onPress={() => console.log('Secondary')}>
Secondary Style
</Button>
<Button isDisabled>
Disabled State
</Button>
</div>
),
};
+56 -122
View File
@@ -1,148 +1,82 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Button } from './Button';
import { render, screen } from '../../test-utils';
import { render, screen, fireEvent } from '../../test-utils';
/**
* Test suite for the newspaper-style Button component
*/
describe('components/paper/Button/Button', () => {
const defaultProps = {
children: 'Test Button',
};
it('renders correctly with default props', () => {
render(<Button {...defaultProps} />);
const button = screen.getByRole('button', { name: 'Test Button' });
describe('components/Button', () => {
it('renders correctly as a button by default', () => {
render(<Button>Click me</Button>);
const button = screen.getByRole('button', { name: 'Click me' });
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent('Test Button');
expect(button.tagName).toBe('BUTTON');
});
it('applies correct styling classes', () => {
render(<Button {...defaultProps} />);
it('renders correctly as an anchor when href is provided', () => {
render(<Button href="https://example.com">Link Button</Button>);
const link = screen.getByRole('link', { name: 'Link Button' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://example.com');
});
it('calls onPress when clicked in button mode', () => {
const onPress = jest.fn();
render(<Button onPress={onPress}>Click me</Button>);
const button = screen.getByRole('button');
// Check for key newspaper styling classes
expect(button).toHaveClass('italic');
expect(button).toHaveClass('underline');
expect(button).toHaveClass('font-medium');
expect(button).toHaveClass('bg-background');
expect(button).toHaveClass('cursor-pointer');
fireEvent.click(button);
expect(onPress).toHaveBeenCalled();
});
it('handles click events through onPress prop', async () => {
const user = userEvent.setup();
const handlePress = jest.fn();
render(<Button onPress={handlePress}>{defaultProps.children}</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handlePress).toHaveBeenCalledTimes(1);
});
it('supports different variants', () => {
const { rerender } = render(
<Button variant="primary">{defaultProps.children}</Button>
it('does not call onPress when disabled', () => {
const onPress = jest.fn();
render(
<Button isDisabled onPress={onPress}>
Disabled
</Button>
);
let button = screen.getByRole('button');
expect(button).toHaveClass('bg-primary');
rerender(<Button variant="secondary">{defaultProps.children}</Button>);
button = screen.getByRole('button');
expect(button).toHaveClass('bg-secondary');
});
it('handles disabled state correctly', () => {
render(<Button isDisabled>{defaultProps.children}</Button>);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(onPress).not.toHaveBeenCalled();
expect(button).toBeDisabled();
expect(button).toHaveClass('disabled:opacity-50');
expect(button).toHaveClass('disabled:cursor-not-allowed');
});
it('does not trigger onPress when disabled', async () => {
const user = userEvent.setup();
const handlePress = jest.fn();
it('renders start and end icons correctly', () => {
const StartIcon = (className: string) => (
<span className={className} data-testid="start-icon" />
);
const EndIcon = (className: string) => <span className={className} data-testid="end-icon" />;
render(
<Button isDisabled onPress={handlePress}>
{defaultProps.children}
<Button renderEndIcon={EndIcon} renderStartIcon={StartIcon}>
Icon Button
</Button>
);
const button = screen.getByRole('button');
await user.click(button);
expect(handlePress).not.toHaveBeenCalled();
expect(screen.getByTestId('start-icon')).toBeInTheDocument();
expect(screen.getByTestId('end-icon')).toBeInTheDocument();
expect(screen.getByText('Icon Button')).toBeInTheDocument();
});
it('supports custom className prop', () => {
const customClass = 'custom-button-class';
render(<Button className={customClass}>{defaultProps.children}</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass(customClass);
it('applies correct size classes', () => {
const { rerender } = render(<Button size="s">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('h-8');
rerender(<Button size="l">Large</Button>);
expect(screen.getByRole('button')).toHaveClass('h-12');
});
it('supports data-testid prop', () => {
const testId = 'my-test-button';
render(<Button data-testid={testId}>{defaultProps.children}</Button>);
const button = screen.getByTestId(testId);
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent('Test Button');
it('applies correct rounded classes', () => {
const { rerender } = render(<Button rounded="full">Pill</Button>);
expect(screen.getByRole('button')).toHaveClass('rounded-full');
rerender(<Button rounded="none">Square</Button>);
expect(screen.getByRole('button')).toHaveClass('rounded-none');
rerender(<Button rounded="md">Default</Button>);
expect(screen.getByRole('button')).toHaveClass('rounded-md');
});
it('only accepts string children', () => {
// This test verifies TypeScript constraint - should compile without error
render(<Button>Simple string</Button>);
expect(screen.getByText('Simple string')).toBeInTheDocument();
});
it('maintains accessibility attributes', () => {
render(
<Button
aria-describedby="button-description"
aria-label="Custom accessibility label"
>
{defaultProps.children}
</Button>
);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'Custom accessibility label');
expect(button).toHaveAttribute('aria-describedby', 'button-description');
});
it('handles keyboard navigation', async () => {
const user = userEvent.setup();
const handlePress = jest.fn();
render(<Button onPress={handlePress}>{defaultProps.children}</Button>);
const button = screen.getByRole('button');
button.focus();
expect(button).toHaveFocus();
await user.keyboard('{Enter}');
expect(handlePress).toHaveBeenCalledTimes(1);
await user.keyboard(' ');
expect(handlePress).toHaveBeenCalledTimes(2);
});
it('renders string content correctly', () => {
render(<Button>Simple text content</Button>);
expect(screen.getByText('Simple text content')).toBeInTheDocument();
it('applies fullWidth class', () => {
render(<Button fullWidth>Full Width</Button>);
expect(screen.getByRole('button')).toHaveClass('w-full');
});
});
+202 -78
View File
@@ -1,101 +1,225 @@
import React, { memo } from 'react';
import { useButton } from 'react-aria';
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef, useRef } from 'react';
import { useButton, useLink, useFocusRing, useHover } from 'react-aria';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { AriaButtonProps } from 'react-aria';
import type { AriaButtonProps, AriaLinkOptions } from 'react-aria';
/**
* Props for the Button component.
* Button visual style variants.
*/
export interface ButtonProps extends AriaButtonProps, ClassNameProps, TestIdProps {
/** The button text content */
children: string;
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link';
/**
* Button color themes.
*/
export type ButtonColor =
| 'primary'
| 'primary-negative'
| 'secondary'
| 'secondary-negative'
| 'tertiary'
| 'tertiary-negative'
| 'error'
| 'error-negative'
| 'neutral'
| 'neutral-negative';
/**
* Button size variants.
*/
export type ButtonSize = 's' | 'm' | 'l';
/**
* Props shared by both action and link button variants.
*/
interface BaseButtonProps extends ClassNameProps, TestIdProps {
/** Content to be rendered inside the button */
children?: React.ReactNode;
/** Visual style variant */
variant?: 'default' | 'primary' | 'secondary';
variant?: ButtonVariant;
/** Color theme */
color?: ButtonColor;
/** Button size */
size?: ButtonSize;
/** Render prop for icon at the start */
renderStartIcon?: (className: string) => React.ReactNode;
/** Render prop for icon at the end */
renderEndIcon?: (className: string) => React.ReactNode;
/** Whether the button takes full width of container */
fullWidth?: boolean;
/** Corner radius variant */
rounded?: 'none' | 'md' | 'full';
/** Whether the button is disabled */
isDisabled?: boolean;
}
/**
* A button component with italic Lato font and underlined text.
*
* Features:
* - Rectangular design with subtle background
* - Italic Medium Lato font (500 weight)
* - Underlined text decoration
* - Accessible interaction patterns via react-aria
* - Responsive hover and focus states
*
* @example
* ```tsx
* <Button onPress={() => console.log('Clicked!')}>
* Explore all the articles
* </Button>
* ```
* Props for a button acting as a standard button element.
*/
export const Button = memo(function Button({
children,
variant = 'default',
className = '',
'data-testid': testId,
...buttonProps
}: ButtonProps) {
const ref = React.useRef<HTMLButtonElement>(null);
const { buttonProps: ariaButtonProps } = useButton(buttonProps, ref);
interface ActionButtonProps
extends BaseButtonProps,
Omit<AriaButtonProps, 'children' | 'isDisabled' | 'elementType'> {
href?: never;
}
const baseClasses = [
// Layout and spacing
'inline-flex items-center justify-center',
'px-3 py-2',
'min-h-[40px]',
// Typography
'font-sans font-medium italic',
'text-base leading-6 tracking-[0.015em]',
'text-center underline',
// Appearance
'border border-border',
'rounded-none',
'transition-all duration-200',
// Interactive states
'cursor-pointer',
'hover:bg-muted/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'active:bg-muted',
'disabled:opacity-50 disabled:cursor-not-allowed',
];
/**
* Props for a button acting as a navigation link.
*/
interface LinkButtonProps
extends BaseButtonProps,
Omit<AriaLinkOptions, 'children' | 'isDisabled' | 'elementType'> {
href: string;
}
const variantClasses = {
default: [
'bg-background text-foreground',
'hover:bg-muted/30',
],
primary: [
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
],
secondary: [
'bg-secondary text-secondary-foreground',
'hover:bg-secondary/90',
],
/**
* Unified Button props supporting both action and link modes.
*/
export type ButtonProps = ActionButtonProps | LinkButtonProps;
/**
* A highly extensible, polymorphic button component.
*
* Button handles both action-based and navigation-based interactions while
* maintaining a unified visual style adhering to the Rainby design system.
*/
const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(props, ref) {
const {
children,
variant = 'solid',
color = 'primary',
size = 'm',
renderStartIcon,
renderEndIcon,
fullWidth = false,
rounded = 'md',
className,
'data-testid': testId,
isDisabled,
href,
} = props;
const internalRef = useRef<HTMLElement>(null);
const targetRef = ref ? castRef<HTMLElement>(ref) : internalRef;
// Hooks for interactions
// We explicitly construct the props object to avoid unsafe casting of the Union type.
const buttonHookProps = {
...props,
isDisabled,
elementType: href ? ('a' as const) : ('button' as const),
};
const allClasses = [
...baseClasses,
...variantClasses[variant],
className,
].join(' ');
const { buttonProps } = useButton(buttonHookProps, castRef<HTMLButtonElement>(targetRef));
const linkHookProps: AriaLinkOptions = {
...props,
isDisabled,
elementType: 'a',
href: typeof href === 'string' ? href : undefined,
};
const { linkProps } = useLink(linkHookProps, castRef<HTMLAnchorElement>(targetRef));
const { focusProps, isFocusVisible } = useFocusRing();
const { hoverProps, isHovered } = useHover({ isDisabled });
// Styles mapping
const sizeClasses: Record<ButtonSize, string> = {
s: 'h-8 px-3 text-sm gap-1.5',
m: 'h-10 px-4 text-base gap-2',
l: 'h-12 px-6 text-lg gap-3',
};
const iconClasses: Record<ButtonSize, string> = {
s: 'w-4 h-4',
m: 'w-5 h-5',
l: 'w-6 h-6',
};
const roundedClasses: Record<string, string> = {
none: 'rounded-none',
md: 'rounded-md',
full: 'rounded-full',
};
const getVariantClasses = (): string => {
const isNegative = color.endsWith('-negative');
const baseColor = color.replace('-negative', '');
const colors: Record<string, { base: string; text: string }> = {
primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' },
secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' },
tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' },
error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' },
neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' },
};
const colorValues = colors[baseColor];
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
const foregroundColor = isNegative ? colorValues.base : colorValues.text;
switch (variant) {
case 'solid':
return `bg-[${backgroundColor}] text-[${foregroundColor}] border-transparent`;
case 'outline':
return `bg-transparent text-[${backgroundColor}] border-[${backgroundColor}] border-2`;
case 'ghost':
return `bg-transparent text-[${backgroundColor}] border-transparent hover:bg-[${backgroundColor}]/10`;
case 'link':
return `bg-transparent text-[${backgroundColor}] border-transparent underline-offset-4 hover:underline px-0 h-auto`;
default:
return '';
}
};
const sharedClasses = cn(
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden',
'active:scale-[0.98]',
sizeClasses[size],
roundedClasses[rounded],
getVariantClasses(),
fullWidth && 'w-full',
isDisabled && 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
isFocusVisible && 'ring-2 ring-[var(--rb-ring)] ring-offset-2',
isHovered && !isDisabled && 'brightness-95 dark:brightness-105',
className
);
const content = (
<>
{renderStartIcon?.(iconClasses[size])}
{children}
{renderEndIcon?.(iconClasses[size])}
</>
);
if (href) {
return (
<a
{...linkProps}
{...focusProps}
{...hoverProps}
ref={castRef<HTMLAnchorElement>(targetRef)}
className={sharedClasses}
data-testid={testId}
>
{content}
</a>
);
}
return (
<button
{...ariaButtonProps}
ref={ref}
className={allClasses}
{...buttonProps}
{...focusProps}
{...hoverProps}
ref={castRef<HTMLButtonElement>(targetRef)}
className={sharedClasses}
data-testid={testId}
>
{children}
{content}
</button>
);
});
export const Button = genericMemo(ButtonComponent);
@@ -0,0 +1,103 @@
import { Code } from './Code';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Code> = {
title: 'Components/Code',
component: Code,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'radio',
options: ['block', 'inline'],
},
showLineNumbers: {
control: 'boolean',
},
label: {
control: 'text',
},
},
};
export default meta;
type Story = StoryObj<typeof Code>;
// Helper to simulate highlighted lines
const MockHighlightedCode = () => (
<>
<span className="block text-[var(--rb-primary-text)]">import React from &apos;react&apos;;</span>
<span className="block text-[var(--rb-text)]"> </span>
<span className="block text-[var(--rb-secondary-text)]">
export const MyComponent = () =&gt; &#123;
</span>
<span className="block text-[var(--rb-text)] pl-4">
return &lt;div&gt;Hello World&lt;/div&gt;;
</span>
<span className="block text-[var(--rb-secondary-text)]">&#125;;</span>
</>
);
const LongHighlightedCode = () => (
<>
{Array.from({ length: 50 }).map((_, i) => (
<span key={`line-${String(i)}`} className="block">
<span className="text-[var(--rb-tertiary-text)]">line_{i + 1}</span>:{' '}
<span className="text-[var(--rb-text)]">const x = {Math.random()};</span>
</span>
))}
</>
);
export const DefaultBlock: Story = {
args: {
children: <MockHighlightedCode />,
variant: 'block',
label: 'MyComponent.tsx',
},
};
export const WithoutLabel: Story = {
args: {
children: <MockHighlightedCode />,
variant: 'block',
showLineNumbers: true,
},
};
export const WithoutLineNumbers: Story = {
args: {
children: <MockHighlightedCode />,
variant: 'block',
showLineNumbers: false,
label: 'no-lines.js',
},
};
export const Inline: Story = {
args: {
children: 'npm install @ermnvldmr/ui',
variant: 'inline',
},
};
export const LongWithScroll: Story = {
args: {
children: <LongHighlightedCode />,
variant: 'block',
label: 'long-file.ts',
maxHeight: '300px',
},
};
export const PlainText: Story = {
args: {
children: `const simple = "string content";
console.log(simple);`,
variant: 'block',
label: 'plain-text.js',
// Note: Line numbers won't work automatically on plain text unless we split it into elements
// This story demonstrates behavior with raw text (counters will count the single text node as 1 line or 0)
showLineNumbers: false,
},
};
@@ -0,0 +1,80 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import React from 'react';
import { Code } from './Code';
// Mock clipboard API
const mockWriteText = jest.fn();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
describe('Code Component', () => {
beforeEach(() => {
mockWriteText.mockClear();
});
it('renders inline code correctly', () => {
render(<Code variant="inline">const x = 1;</Code>);
const codeElement = screen.getByText('const x = 1;');
expect(codeElement.tagName).toBe('CODE');
expect(codeElement).toHaveClass('rounded-sm');
});
it('renders block code correctly', () => {
render(
<Code label="test.ts" variant="block">
<span>console.log(&quot;hello&quot;);</span>
</Code>
);
// Should verify it's inside a pre tag
const preElement = screen.getByText('console.log("hello");').closest('pre');
expect(preElement).toBeInTheDocument();
// Should see label
expect(screen.getByText('test.ts')).toBeInTheDocument();
});
it('copies content on click in inline mode', () => {
render(
<Code copyValue="copied!" variant="inline">
Display Text
</Code>
);
const codeElement = screen.getByText('Display Text');
act(() => {
fireEvent.click(codeElement);
});
expect(mockWriteText).toHaveBeenCalledWith('copied!');
});
it('copies content via button in block mode', () => {
render(
<Code copyValue="block copy" variant="block">
<span>some code</span>
</Code>
);
const copyButton = screen.getByLabelText('Copy code');
act(() => {
fireEvent.click(copyButton);
});
expect(mockWriteText).toHaveBeenCalledWith('block copy');
});
it('extracts text from children if copyValue is not provided', () => {
render(<Code variant="inline">extracted</Code>);
const codeElement = screen.getByText('extracted');
act(() => {
fireEvent.click(codeElement);
});
expect(mockWriteText).toHaveBeenCalledWith('extracted');
});
});
+201
View File
@@ -0,0 +1,201 @@
import { cn } from '@ermnvldmr/stl';
import { Check, Copy } from 'lucide-react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { usePress } from 'react-aria';
import { Button } from '../Button/Button';
import { Container } from '../Container/Container';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
*
*/
export interface CodeProps extends ClassNameProps, TestIdProps {
/** The content to display. Can be a string or pre-highlighted HTML elements. */
children: React.ReactNode;
/**
* Display style of the code.
* - 'block': Multi-line container with optional scroll, line numbers, and header controls.
* - 'inline': Compact, clickable snippet for embedding in text.
* @default 'block'
*/
variant?: 'block' | 'inline';
/**
* Optional label text (e.g., filename "index.ts", language "bash")
* displayed in the top-left corner of block variants.
*/
label?: string;
/**
* Whether to render line number gutters in block mode.
* Requires children to be rendered as block-level elements (e.g., via a highlighter).
* @default true
*/
showLineNumbers?: boolean;
/**
* Raw string value to copy to clipboard.
* If omitted, the component will attempt to extract text from `children`.
*/
copyValue?: string;
/**
* Maximum height of the code block before vertical scrolling is enabled.
*/
maxHeight?: string | number;
}
/**
* A general-purpose wrapper for displaying source code.
*
* It supports both block-level (multi-line) and inline (snippet) display modes,
* with features like line numbering, syntax highlighting containers, and copy-to-clipboard functionality.
*/
export const Code = memo(function Code({
children,
variant = 'block',
label,
showLineNumbers = true,
copyValue,
maxHeight,
className,
'data-testid': testId,
}: CodeProps) {
const [isCopied, setIsCopied] = useState(false);
const codeRef = useRef<HTMLElement>(null);
const handleCopy = useCallback(async () => {
try {
let textToCopy = copyValue;
if (!textToCopy && codeRef.current) {
textToCopy = codeRef.current.innerText;
if (!textToCopy) {
textToCopy = codeRef.current.textContent ?? undefined;
}
}
if (textToCopy) {
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true);
}
} catch (err) {
console.error('Failed to copy code:', err);
}
}, [copyValue]);
const onCopyPress = useCallback(() => {
void handleCopy();
}, [handleCopy]);
useEffect(() => {
if (isCopied) {
const timer = setTimeout(() => setIsCopied(false), 2000);
return () => clearTimeout(timer);
}
}, [isCopied]);
// Inline Variant
const { pressProps } = usePress({
onPress: onCopyPress,
});
if (variant === 'inline') {
return (
<code
{...pressProps}
ref={codeRef}
aria-label="Copy code snippet"
className={cn(
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono',
'bg-[var(--rb-muted-base)]/50 text-[var(--rb-text)] border border-[var(--rb-outline)]/20',
'cursor-pointer transition-all duration-200',
'hover:bg-[var(--rb-muted-base)]/80',
'active:scale-95',
isCopied && 'ring-1 ring-[var(--rb-secondary-base)]',
className
)}
data-testid={testId}
role="button"
tabIndex={0}
>
{children}
</code>
);
}
// Block Variant
return (
<Container
border
bg="base"
className={cn('relative group overflow-hidden', className)}
data-testid={testId}
rounded="md"
>
{/* Header Controls */}
<div className="absolute top-0 left-0 right-0 h-0 z-10 pointer-events-none">
{label && (
<span className="absolute top-2 left-4 text-xs font-mono text-[var(--rb-muted-text)] select-none bg-[var(--rb-container-base)]/80 backdrop-blur-sm px-1.5 rounded pointer-events-auto">
{label}
</span>
)}
<div className="absolute top-2 right-2 pointer-events-auto">
<Button
aria-label="Copy code"
className={cn(
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
!isCopied && 'opacity-0 group-hover:opacity-100 focus:opacity-100'
)}
color="neutral"
size="s"
variant="ghost"
onPress={onCopyPress}
>
{isCopied ? (
<Check className="w-3.5 h-3.5 text-[var(--rb-secondary-text)]" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</Button>
</div>
</div>
{/* Code Area */}
<pre
className={cn(
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text',
label && 'pt-8' // Add padding if label is present to avoid overlap
)}
style={{ maxHeight: maxHeight }}
>
<code
ref={codeRef}
className={cn(
'block min-w-full',
showLineNumbers && [
'[counter-reset:line] pl-10',
'[&>*]:relative [&>*]:block',
'[&>*]:[counter-increment:line]',
'[&>*]:before:content-[counter(line)]',
'[&>*]:before:absolute [&>*]:before:left-[-2.5rem]',
'[&>*]:before:inline-block',
'[&>*]:before:w-8',
'[&>*]:before:text-right',
'[&>*]:before:text-[var(--rb-muted-text)]/50',
'[&>*]:before:select-none',
'[&>*]:before:border-r',
'[&>*]:before:border-[var(--rb-outline)]/10',
'[&>*]:before:pr-3',
'[&>*]:before:font-mono [&>*]:before:text-[10px]', // Use mono for numbers too
]
)}
>
{children}
</code>
</pre>
</Container>
);
});
@@ -6,7 +6,6 @@ import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Container> = {
title: 'components/Container',
component: Container,
@@ -49,13 +48,27 @@ export const Default: Story = {
export const SemanticColors: Story = {
render: () => (
<VStack gap={4}>
<Container border bg="base" padding={4} rounded="md">Base Surface</Container>
<Container bg="primary" padding={4} rounded="md">Primary Surface</Container>
<Container bg="secondary" padding={4} rounded="md">Secondary Surface</Container>
<Container bg="tertiary" padding={4} rounded="md">Tertiary Surface</Container>
<Container bg="error" padding={4} rounded="md">Error Surface</Container>
<Container bg="muted" padding={4} rounded="md">Muted Surface</Container>
<Container border bg="transparent" padding={4} rounded="md">Transparent Surface</Container>
<Container border bg="base" padding={4} rounded="md">
Base Surface
</Container>
<Container bg="primary" padding={4} rounded="md">
Primary Surface
</Container>
<Container bg="secondary" padding={4} rounded="md">
Secondary Surface
</Container>
<Container bg="tertiary" padding={4} rounded="md">
Tertiary Surface
</Container>
<Container bg="error" padding={4} rounded="md">
Error Surface
</Container>
<Container bg="muted" padding={4} rounded="md">
Muted Surface
</Container>
<Container border bg="transparent" padding={4} rounded="md">
Transparent Surface
</Container>
</VStack>
),
};
@@ -63,11 +76,21 @@ export const SemanticColors: Story = {
export const PaddingScale: Story = {
render: () => (
<VStack gap={4}>
<Container border bg="muted" padding={1}>Padding 1</Container>
<Container border bg="muted" padding={2}>Padding 2</Container>
<Container border bg="muted" padding={4}>Padding 4</Container>
<Container border bg="muted" padding={8}>Padding 8</Container>
<Container border bg="muted" padding={12}>Padding 12</Container>
<Container border bg="muted" padding={1}>
Padding 1
</Container>
<Container border bg="muted" padding={2}>
Padding 2
</Container>
<Container border bg="muted" padding={4}>
Padding 4
</Container>
<Container border bg="muted" padding={8}>
Padding 8
</Container>
<Container border bg="muted" padding={12}>
Padding 12
</Container>
</VStack>
),
};
@@ -75,10 +98,18 @@ export const PaddingScale: Story = {
export const MaxWidths: Story = {
render: () => (
<VStack className="w-full" gap={4}>
<Container bg="tertiary" maxWidth="sm" padding={4} rounded="md">Max Width SM (640px)</Container>
<Container bg="tertiary" maxWidth="md" padding={4} rounded="md">Max Width MD (768px)</Container>
<Container bg="tertiary" maxWidth="lg" padding={4} rounded="md">Max Width LG (1024px)</Container>
<Container bg="tertiary" maxWidth="xl" padding={4} rounded="md">Max Width XL (1280px)</Container>
<Container bg="tertiary" maxWidth="sm" padding={4} rounded="md">
Max Width SM (640px)
</Container>
<Container bg="tertiary" maxWidth="md" padding={4} rounded="md">
Max Width MD (768px)
</Container>
<Container bg="tertiary" maxWidth="lg" padding={4} rounded="md">
Max Width LG (1024px)
</Container>
<Container bg="tertiary" maxWidth="xl" padding={4} rounded="md">
Max Width XL (1280px)
</Container>
</VStack>
),
};
@@ -86,11 +117,21 @@ export const MaxWidths: Story = {
export const Rounding: Story = {
render: () => (
<div className="flex flex-wrap gap-4">
<Container bg="secondary" padding={8} rounded="none">None</Container>
<Container bg="secondary" padding={8} rounded="sm">Small</Container>
<Container bg="secondary" padding={8} rounded="md">Medium</Container>
<Container bg="secondary" padding={8} rounded="lg">Large</Container>
<Container bg="secondary" padding={8} rounded="full">Full</Container>
<Container bg="secondary" padding={8} rounded="none">
None
</Container>
<Container bg="secondary" padding={8} rounded="sm">
Small
</Container>
<Container bg="secondary" padding={8} rounded="md">
Medium
</Container>
<Container bg="secondary" padding={8} rounded="lg">
Large
</Container>
<Container bg="secondary" padding={8} rounded="full">
Full
</Container>
</div>
),
};
@@ -115,16 +156,20 @@ export const Composition: Story = {
render: () => (
<Container border shadow bg="base" maxWidth="md" padding={0} rounded="lg">
<Container bg="primary" padding={4} rounded="none">
<Text bold color="inherit" size="l" type="title">Card Header</Text>
<Text bold color="inherit" size="l" type="title">
Card Header
</Text>
</Container>
<Container padding={6}>
<VStack gap={4}>
<Text size="m" type="body">
This example demonstrates how containers can be nested and composed.
The outer container has a shadow and border, while the header uses a primary background.
This example demonstrates how containers can be nested and composed. The outer container
has a shadow and border, while the header uses a primary background.
</Text>
<Container border bg="muted" padding={4} rounded="md">
<Text size="m" type="label">Inside a nested muted container</Text>
<Text size="m" type="label">
Inside a nested muted container
</Text>
</Container>
</VStack>
</Container>
@@ -2,14 +2,14 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { Container } from './Container';
import { render, screen } from '../../../test-utils';
import { render, screen } from '../../test-utils';
describe('components/generic/Container', () => {
describe('components/Container', () => {
it('handles onPress event', async () => {
const user = userEvent.setup();
const handlePress = jest.fn();
render(<Container onPress={handlePress}>Content</Container>);
const container = screen.getByText('Content');
await user.click(container);
expect(handlePress).toHaveBeenCalledTimes(1);
@@ -25,4 +25,4 @@ describe('components/generic/Container', () => {
expect(container).toHaveClass('cursor-pointer');
expect(container).toHaveClass('active:opacity-80');
});
});
});
@@ -8,12 +8,53 @@ import type { PressEvents } from 'react-aria';
/**
* Tailwind gap spacing scale values.
*/
type GapScale = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
type GapScale =
| 0
| 0.5
| 1
| 1.5
| 2
| 2.5
| 3
| 3.5
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12
| 14
| 16
| 20
| 24
| 28
| 32
| 36
| 40
| 44
| 48
| 52
| 56
| 60
| 64
| 72
| 80
| 96;
/**
* Background variants for the Container.
*/
export type ContainerBackground = 'base' | 'primary' | 'secondary' | 'tertiary' | 'error' | 'muted' | 'transparent';
export type ContainerBackground =
| 'base'
| 'primary'
| 'secondary'
| 'tertiary'
| 'error'
| 'muted'
| 'transparent';
/**
* Max width constraints for the Container.
@@ -49,8 +90,8 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
/**
* A generic surface component for creating structural layouts.
*
* Container provides a consistent way to handle backgrounds, padding,
*
* Container provides a consistent way to handle backgrounds, padding,
* borders, and width constraints while maintaining theme consistency.
*/
export const Container = memo(function Container({
+8 -6
View File
@@ -15,11 +15,11 @@ export interface HStackProps extends Omit<StackProps, 'direction'> {
/**
* A horizontal stack layout component that arranges children in a row using CSS Flexbox.
*
*
* This is a specialized version of the Stack component with a fixed horizontal direction.
* It provides a convenient, semantic way to create horizontal layouts without needing
* to specify direction="row" every time.
*
*
* @example
* ```tsx
* // Simple horizontal stack
@@ -28,7 +28,7 @@ export interface HStackProps extends Omit<StackProps, 'direction'> {
* <button>Second</button>
* <button>Third</button>
* </HStack>
*
*
* // Centered horizontal stack with space between
* <HStack align="center" justify="between">
* <div>Logo</div>
@@ -37,6 +37,8 @@ export interface HStackProps extends Omit<StackProps, 'direction'> {
* </HStack>
* ```
*/
export const HStack = memo(forwardRef<HTMLElement, HStackProps>(function HStack(props, ref) {
return <Stack direction="row" {...props} ref={ref} />;
}));
export const HStack = memo(
forwardRef<HTMLElement, HStackProps>(function HStack(props, ref) {
return <Stack direction="row" {...props} ref={ref} />;
})
);
@@ -5,7 +5,6 @@ import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Header> = {
title: 'components/Header',
component: Header,
@@ -51,9 +50,15 @@ export const AllLevels: Story = {
export const Overrides: Story = {
render: () => (
<VStack gap={4}>
<Header level={1} size="s" type="headline">H1 Tag with Headline Small Style</Header>
<Header color="primary" level={3} size="l" type="body">H3 Tag with Body Large Style and Primary Color</Header>
<Header italic underline level={6}>H6 with Modifiers</Header>
<Header level={1} size="s" type="headline">
H1 Tag with Headline Small Style
</Header>
<Header color="primary" level={3} size="l" type="body">
H3 Tag with Body Large Style and Primary Color
</Header>
<Header italic underline level={6}>
H6 with Modifiers
</Header>
</VStack>
),
};
+5 -16
View File
@@ -14,18 +14,12 @@ export interface HeaderProps extends Omit<TextProps, 'as'> {
/**
* A semantic header component that wraps the Text component.
*
* Header automatically maps semantic levels (h1-h6) to the appropriate
* typography variants in the design system, ensuring accessibility
*
* Header automatically maps semantic levels (h1-h6) to the appropriate
* typography variants in the design system, ensuring accessibility
* and visual consistency.
*/
export const Header = memo(function Header({
level,
type,
size,
children,
...props
}: HeaderProps) {
export const Header = memo(function Header({ level, type, size, children, ...props }: HeaderProps) {
// Default mappings from level to type and size
const levelMapping: Record<number, { type: TextType; size: TextSize }> = {
1: { type: 'display', size: 'l' },
@@ -48,12 +42,7 @@ export const Header = memo(function Header({
const Tag = tags[level];
return (
<Text
as={Tag}
size={size ?? defaultStyles.size}
type={type ?? defaultStyles.type}
{...props}
>
<Text as={Tag} size={size ?? defaultStyles.size} type={type ?? defaultStyles.type} {...props}>
{children}
</Text>
);
@@ -0,0 +1,124 @@
import React from 'react';
import { Image } from './Image';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Image> = {
title: 'components/Image',
component: Image,
tags: ['autodocs'],
argTypes: {
rounded: {
control: 'radio',
options: ['none', 'sm', 'md', 'lg', 'full'],
},
shadow: {
control: 'radio',
options: ['none', 'sm', 'md', 'lg'],
},
objectFit: {
control: 'select',
options: ['cover', 'contain', 'fill'],
},
},
};
export default meta;
type Story = StoryObj<typeof Image>;
export const Default: Story = {
args: {
src: 'https://images.unsplash.com/photo-1472214103451-9374bd1c798e?q=80&w=2070&auto=format&fit=crop',
alt: 'Beautiful Landscape',
width: '100%',
ratio: '16/9',
rounded: 'lg',
shadow: 'md',
},
};
export const AspectRatios: Story = {
render: () => (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">16:9 (Landscape)</span>
<Image
alt="16:9"
ratio="16/9"
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=2070&auto=format&fit=crop"
width="100%"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">1:1 (Square)</span>
<Image
alt="1:1"
ratio="1/1"
rounded="full"
src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?q=80&w=2000&auto=format&fit=crop"
width="100%"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">4:5 (Portrait)</span>
<Image
alt="4:5"
ratio="4/5"
src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=1964&auto=format&fit=crop"
width="100%"
/>
</div>
</div>
),
};
export const ObjectFit: Story = {
render: () => (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Cover (Default)</span>
<Image
alt="Cover"
height="200px"
objectFit="cover"
src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=2074&auto=format&fit=crop"
width="100%"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Contain</span>
<Image
alt="Contain"
className="bg-neutral-100 dark:bg-neutral-800"
height="200px"
objectFit="contain"
src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=2074&auto=format&fit=crop"
width="100%"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Fill</span>
<Image
alt="Fill"
height="200px"
objectFit="fill"
src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=2074&auto=format&fit=crop"
width="100%"
/>
</div>
</div>
),
};
export const LoadingState: Story = {
render: () => (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Simulated Loading (Using Stub)</span>
<Image alt="Loading" ratio="16/9" src="https://invalid-url.jpg" width="300px" />
<p className="text-xs text-neutral-500">Note: This will stay in &quot;loading&quot; then &quot;error&quot; state because URL is invalid.</p>
</div>
),
};
@@ -0,0 +1,70 @@
import React from 'react';
import { Image } from './Image';
import { render, screen, fireEvent } from '../../test-utils';
describe('components/Image', () => {
const defaultProps = {
src: 'https://example.com/image.jpg',
alt: 'Example image',
width: '300px',
height: '200px',
};
it('renders with correct dimensions for CLS safety', () => {
render(<Image {...defaultProps} />);
const wrapper = screen.getByTestId('image-wrapper');
expect(wrapper).toHaveStyle({ width: '300px', height: '200px' });
});
it('renders with aspect ratio when provided', () => {
render(<Image alt="test" ratio="16/9" src="test.jpg" width="100%" />);
const wrapper = screen.getByTestId('image-wrapper');
expect(wrapper).toHaveStyle({ width: '100%', aspectRatio: '16/9' });
});
it('shows stub while loading and hides it after load', () => {
render(<Image {...defaultProps} />);
// Stub should be visible
expect(screen.getByTestId('stub')).toBeInTheDocument();
const img = screen.getByTestId('image-element');
expect(img).toHaveClass('opacity-0');
// Simulate load
fireEvent.load(img);
// Image should be visible (opacity-100)
expect(img).toHaveClass('opacity-100');
// Stub is removed from DOM when isLoaded is true in implementation
expect(screen.queryByTestId('stub')).not.toBeInTheDocument();
});
it('stops stub animation on error', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('image-element');
// Simulate error
fireEvent.error(img);
// Stub should be present but not animating
const stub = screen.getByTestId('stub');
expect(stub).not.toHaveClass('animate-shimmer');
});
it('applies rounding and shadow classes', () => {
render(<Image {...defaultProps} rounded="full" shadow="lg" />);
const wrapper = screen.getByTestId('image-wrapper');
expect(wrapper).toHaveClass('rounded-full');
expect(wrapper).toHaveClass('shadow-lg');
});
it('passes correct attributes to native img tag', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('image-element');
expect(img).toHaveAttribute('src', defaultProps.src);
expect(img).toHaveAttribute('alt', defaultProps.alt);
expect(img).toHaveAttribute('loading', 'lazy');
expect(img).toHaveAttribute('decoding', 'async');
});
});
+108
View File
@@ -0,0 +1,108 @@
import { cn } from '@ermnvldmr/stl';
import React, { useState } from 'react';
import { Stub } from '../Stub/Stub';
/**
* Image dimension constraints to ensure CLS safety.
* Requires either:
* - Both width and height
* - Width and ratio
* - Height and ratio
*/
type ImageDimensions =
| { width: number | string; height: number | string; ratio?: never }
| { width: number | string; ratio: number | string; height?: never }
| { height: number | string; ratio: number | string; width?: never };
/**
* Base props for the Image component.
*/
interface ImageBaseProps {
/** Image source URL */
src: string;
/** Accessible alt text */
alt: string;
/** Corner radius variant */
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full';
/** Shadow variant */
shadow?: 'none' | 'sm' | 'md' | 'lg';
/** CSS object-fit property */
objectFit?: 'cover' | 'contain' | 'fill';
/** Additional CSS classes for the wrapper */
className?: string;
}
/**
* Unified Image props.
*/
export type ImageProps = ImageBaseProps & ImageDimensions;
/**
* A performance-optimized Image component with built-in CLS prevention,
* loading states, and Rainby design system styles.
*
* @param props - Component properties
* @returns A styled image component with loading skeleton
* @example
* <Image src="path/to/img.jpg" alt="Description" width={300} ratio="16/9" />
*/
export const Image: React.FC<ImageProps> = (props) => {
const { src, alt, rounded = 'none', shadow = 'none', objectFit = 'cover', className } = props;
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
const shadowClasses = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
};
const wrapperStyle: React.CSSProperties = {
width: 'width' in props ? props.width : undefined,
height: 'height' in props ? props.height : undefined,
aspectRatio: 'ratio' in props ? String(props.ratio) : undefined,
};
return (
<div
className={cn(
'relative overflow-hidden',
roundedClasses[rounded],
shadowClasses[shadow],
className
)}
data-testid="image-wrapper"
style={wrapperStyle}
>
{!isLoaded && (
<Stub animate={!hasError} className="absolute inset-0 z-10" rounded={rounded} />
)}
<img
alt={alt}
className={cn(
'w-full h-full transition-opacity duration-300',
objectFit === 'cover' && 'object-cover',
objectFit === 'contain' && 'object-contain',
objectFit === 'fill' && 'object-fill',
isLoaded ? 'opacity-100' : 'opacity-0'
)}
data-testid="image-element"
decoding="async"
loading="lazy"
src={src}
onError={() => setHasError(true)}
onLoad={() => setIsLoaded(true)}
/>
</div>
);
};
@@ -1,6 +1,5 @@
import { Link } from './Link';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Link> = {
@@ -1,9 +1,9 @@
import React from 'react';
import { Link } from './Link';
import { render, screen } from '../../../test-utils';
import { render, screen } from '../../test-utils';
describe('components/generic/Link', () => {
describe('components/Link', () => {
it('renders correctly as a link when href is provided', () => {
render(<Link href="https://example.com">Click me</Link>);
const link = screen.getByRole('link', { name: 'Click me' });
+5 -16
View File
@@ -19,14 +19,14 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
/**
* A generic navigation primitive for interactive text.
*
*
* Link ensures consistent styling and accessibility across all navigation elements.
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
*/
export const Link = memo(function Link(props: LinkProps) {
const { children, className, href, ...otherProps } = props;
// We use a specific ref type that useLink expects.
// We use a specific ref type that useLink expects.
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
const ref = useRef<HTMLAnchorElement>(null);
const { linkProps } = useLink(otherProps, ref);
@@ -39,25 +39,14 @@ export const Link = memo(function Link(props: LinkProps) {
if (href) {
return (
<Text
{...linkProps}
ref={ref}
as="a"
className={sharedClasses}
href={href}
>
<Text {...linkProps} ref={ref} as="a" className={sharedClasses} href={href}>
{children}
</Text>
);
}
return (
<Text
{...linkProps}
ref={castRef<HTMLSpanElement>(ref)}
as="span"
className={sharedClasses}
>
<Text {...linkProps} ref={castRef<HTMLSpanElement>(ref)} as="span" className={sharedClasses}>
{children}
</Text>
);
@@ -0,0 +1,72 @@
import React from 'react';
import { List } from './List';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof List> = {
title: 'Components/List',
component: List,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof List>;
export const Unordered: Story = {
args: {
variant: 'unordered',
spacing: 's',
children: (
<>
<List.Item>First item</List.Item>
<List.Item>Second item</List.Item>
<List.Item>Third item</List.Item>
</>
),
},
};
export const Ordered: Story = {
args: {
variant: 'ordered',
spacing: 's',
children: (
<>
<List.Item>First item</List.Item>
<List.Item>Second item</List.Item>
<List.Item>Third item</List.Item>
</>
),
},
};
export const ExplicitUsage: Story = {
render: () => (
<div className="space-y-4">
<List spacing="s" variant="unordered">
<List.Item>Unordered Item 1</List.Item>
<List.Item>Unordered Item 2</List.Item>
</List>
<List spacing="s" variant="ordered">
<List.Item>Ordered Item 1</List.Item>
<List.Item>Ordered Item 2</List.Item>
</List>
</div>
),
};
export const Nested: Story = {
render: () => (
<List spacing="m" variant="unordered">
<List.Item>
Parent Item 1
<List className="mt-2" spacing="s" variant="unordered">
<List.Item>Child Item 1</List.Item>
<List.Item>Child Item 2</List.Item>
</List>
</List.Item>
<List.Item>Parent Item 2</List.Item>
</List>
),
};
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { List } from './List';
describe('List', () => {
it('renders children correctly', () => {
render(
<List>
<List.Item>Item 1</List.Item>
</List>
);
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
it('renders as ul by default', () => {
const { container } = render(
<List>
<List.Item>Item 1</List.Item>
</List>
);
expect(container.querySelector('ul')).toBeInTheDocument();
});
it('applies ordered styles', () => {
const { container } = render(
<List variant="ordered">
<List.Item>1</List.Item>
</List>
);
expect(container.querySelector('ol')).toHaveClass('list-decimal');
});
it('applies spacing', () => {
const { container } = render(
<List spacing="m">
<List.Item>1</List.Item>
</List>
);
expect(container.firstChild).toHaveClass('gap-2');
});
it('renders custom marker', () => {
render(
<List marker={<span data-testid="custom-marker">*</span>}>
<List.Item>Item 1</List.Item>
</List>
);
expect(screen.getByTestId('custom-marker')).toBeInTheDocument();
});
it('item marker overrides list marker', () => {
render(
<List marker={<span>Global</span>}>
<List.Item marker={<span data-testid="item-marker">Item</span>}>Item 1</List.Item>
</List>
);
expect(screen.getByTestId('item-marker')).toBeInTheDocument();
expect(screen.queryByText('Global')).not.toBeInTheDocument();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Supported list visual variants.
*/
export type ListVariant = 'unordered' | 'ordered' | 'plain';
/**
* Supported spacing values between list items.
*/
export type ListSpacing = 'none' | 's' | 'm' | 'l';
/**
* Props for the List component.
*/
export interface ListProps extends ClassNameProps, TestIdProps, React.HTMLAttributes<HTMLElement> {
/** List content, typically List.Item or Li components */
children: React.ReactNode;
/** Visual style variant */
variant?: ListVariant;
/** Spacing between items */
spacing?: ListSpacing;
/** Global marker override for all items in this list */
marker?: React.ReactNode;
/** Custom HTML element to render (defaults to ul/ol based on variant) */
as?: React.ElementType;
}
const spacingStyles: Record<ListSpacing, string> = {
none: 'gap-0',
s: 'gap-1',
m: 'gap-2',
l: 'gap-4',
};
const variantStyles: Record<ListVariant, string> = {
unordered: 'list-disc pl-5',
ordered: 'list-decimal pl-5',
plain: 'list-none pl-0',
};
const ListContext = React.createContext<{ marker?: React.ReactNode }>({});
const ListComponent = forwardRef<HTMLElement, ListProps>(function List(
{
children,
variant = 'unordered',
spacing = 'none',
marker,
as,
className,
'data-testid': testId,
...props
},
ref
) {
const Component = as ?? (variant === 'ordered' ? 'ol' : 'ul');
const hasCustomMarker = !!marker;
return (
<ListContext.Provider value={{ marker }}>
<Component
{...props}
ref={castRef<HTMLElement>(ref)}
className={cn(
'm-0 flex flex-col',
// Hide default markers if a global custom marker is provided
hasCustomMarker ? 'list-none pl-0' : variantStyles[variant],
spacingStyles[spacing],
className
)}
data-testid={testId}
>
{children}
</Component>
</ListContext.Provider>
);
});
/**
* Props for a List item.
*/
export interface ListItemProps
extends ClassNameProps,
TestIdProps,
React.HTMLAttributes<HTMLElement> {
/** Item content */
children: React.ReactNode;
/** Specific marker override for this individual item */
marker?: React.ReactNode;
}
const ListItemComponent = forwardRef<HTMLElement, ListItemProps>(function ListItem(
{ children, marker: itemMarker, className, 'data-testid': testId, ...props },
ref
) {
const { marker: listMarker } = React.useContext(ListContext);
const effectiveMarker = itemMarker ?? listMarker;
if (effectiveMarker) {
return (
<li
{...props}
ref={castRef<HTMLLIElement>(ref)}
className={cn('flex gap-3 items-start', className)}
data-testid={testId}
>
<span className="flex-shrink-0 flex items-center justify-center min-w-[1.25rem] min-h-[1.5rem]">
{effectiveMarker}
</span>
<div className="flex-1">{children}</div>
</li>
);
}
return (
<li
{...props}
ref={castRef<HTMLLIElement>(ref)}
className={cn('relative', className)}
data-testid={testId}
>
{children}
</li>
);
});
export const List = Object.assign(genericMemo(ListComponent), {
Item: genericMemo(ListItemComponent),
});
+1
View File
@@ -0,0 +1 @@
export * from './List';
@@ -26,12 +26,12 @@ export const Default: Story = {
children: (
<>
<p>
This is a <strong>Markdown</strong> component that uses Tailwind CSS Typography (prose)
to render content with consistent styling.
This is a <strong>Markdown</strong> component that uses Tailwind CSS Typography (prose) to
render content with consistent styling.
</p>
<p>
It is designed to be used with rendered HTML from markdown processors,
but works with any HTML content that needs standard document styling.
It is designed to be used with rendered HTML from markdown processors, but works with any
HTML content that needs standard document styling.
</p>
</>
),
@@ -72,7 +72,8 @@ export const Lists: Story = {
<h3>Unordered List</h3>
<ul>
<li>First item</li>
<li>Second item
<li>
Second item
<ul>
<li>Nested item A</li>
<li>Nested item B</li>
@@ -84,7 +85,8 @@ export const Lists: Story = {
<h3>Ordered List</h3>
<ol>
<li>Step one</li>
<li>Step two
<li>
Step two
<ol>
<li>Sub-step 2.1</li>
<li>Sub-step 2.2</li>
@@ -183,16 +185,23 @@ export const KitchenSink: Story = {
A lead paragraph that stands out and sets the tone for the rest of the document.
</p>
<p>
Markdown is a lightweight markup language with plain-text-formatting syntax.
Its design allows it to be converted to many output formats, but the name
originally referred to the tool for converting it to HTML.
Markdown is a lightweight markup language with plain-text-formatting syntax. Its design
allows it to be converted to many output formats, but the name originally referred to the
tool for converting it to HTML.
</p>
<h2>Key Features</h2>
<ul>
<li><strong>Easy to read</strong> - The source text looks like the final output.</li>
<li><strong>Easy to write</strong> - Minimal syntax overhead.</li>
<li><strong>Versatile</strong> - Supports many elements like <em>italics</em>, <del>strikethrough</del>, and <code>code</code>.</li>
<li>
<strong>Easy to read</strong> - The source text looks like the final output.
</li>
<li>
<strong>Easy to write</strong> - Minimal syntax overhead.
</li>
<li>
<strong>Versatile</strong> - Supports many elements like <em>italics</em>,{' '}
<del>strikethrough</del>, and <code>code</code>.
</li>
</ul>
<h3>Example Code Block</h3>
@@ -209,8 +218,7 @@ export const MyPage = () => (
<blockquote>
&quot;Simplicity is the ultimate sophistication.&quot;
<br />
— Leonardo da Vinci
<br />— Leonardo da Vinci
</blockquote>
<hr />
@@ -245,7 +253,10 @@ export const MyPage = () => (
<h3>Media Elements</h3>
<p>Images should be rounded and have a nice shadow.</p>
<img alt="Coding" src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=1200&q=80" />
<img
alt="Coding"
src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=1200&q=80"
/>
<p>Conclusion: We&apos;ve built a robust system for rendering content.</p>
</>
@@ -31,13 +31,5 @@ export interface MarkdownProps {
* ```
*/
export const Markdown: React.FC<MarkdownProps> = ({ children, className }) => {
return (
<div
className={cn(
className
)}
>
{children}
</div>
);
return <div className={cn(className)}>{children}</div>;
};
@@ -0,0 +1,77 @@
import React from 'react';
import { Paragraph } from './Paragraph';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Paragraph> = {
title: 'components/Paragraph',
component: Paragraph,
tags: ['autodocs'],
argTypes: {
align: { control: 'select', options: ['left', 'center', 'right', 'justify'] },
wrap: { control: 'select', options: ['nowrap', 'balance', 'pretty'] },
gutterBottom: { control: 'boolean' },
indent: { control: 'boolean' },
dropCap: { control: 'boolean' },
type: {
control: 'select',
options: ['display', 'headline', 'title', 'body', 'label'],
},
size: {
control: 'select',
options: ['s', 'm', 'l'],
},
color: {
control: 'select',
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
},
},
};
export default meta;
/**
* Story helper type for Paragraph.
*/
type Story = StoryObj<typeof Paragraph>;
/**
* The default Paragraph story showing standard body text.
*/
export const Default: Story = {
args: {
children:
'This is a standard paragraph used for body text. It wraps naturally and provides good readability defaults using the body type and medium size from the design system.',
},
};
export const Editorial: Story = {
args: {
dropCap: true,
indent: true,
align: 'justify',
className: 'max-w-md',
children:
'Once upon a time, there was a design system that needed a robust paragraph component. It was designed to handle long-form content with grace, supporting traditional typesetting features like drop caps and first-line indentation. This helps create a more engaging reading experience for long-form articles or documentation.',
},
};
export const VerticalRhythm: Story = {
render: () => (
<div className="max-w-md border p-4">
<Paragraph gutterBottom>
This is the first paragraph with <code>gutterBottom</code> enabled. It adds a margin at the
bottom to separate it from the next block of text, establishing a clear vertical rhythm.
</Paragraph>
<Paragraph gutterBottom>
The second paragraph also has <code>gutterBottom</code>. Notice the consistent spacing
between these blocks.
</Paragraph>
<Paragraph>
This final paragraph does not have <code>gutterBottom</code>, so any subsequent elements
would be placed immediately after it without the extra spacing.
</Paragraph>
</div>
),
};
@@ -0,0 +1,26 @@
import React from 'react';
import { Paragraph } from './Paragraph';
import { render, screen } from '../../test-utils';
describe('components/Paragraph', () => {
it('renders as a p tag by default', () => {
render(<Paragraph>Content</Paragraph>);
expect(screen.getByText('Content').tagName).toBe('P');
});
it('applies gutterBottom', () => {
render(<Paragraph gutterBottom>Content</Paragraph>);
expect(screen.getByText('Content')).toHaveClass('mb-4');
});
it('applies indent', () => {
render(<Paragraph indent>Content</Paragraph>);
expect(screen.getByText('Content')).toHaveClass('indent-8');
});
it('passes through Text props like align', () => {
render(<Paragraph align="center">Content</Paragraph>);
expect(screen.getByText('Content')).toHaveClass('text-center');
});
});
@@ -0,0 +1,49 @@
import { cn, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { Text } from '../Text/Text';
import type { TextProps } from '../Text/Text';
/**
* Props for the Paragraph component.
*/
export interface ParagraphProps extends Omit<TextProps, 'as'> {
/** Whether to add a bottom margin (standard vertical rhythm) */
gutterBottom?: boolean;
/** Whether to indent the first line */
indent?: boolean;
/** Whether to style the first letter as a drop cap */
dropCap?: boolean;
}
/**
* A semantic paragraph component that wraps the Text component.
*
* Paragraph provides specialized block-level features like vertical spacing,
* indentation, and drop caps while inheriting all typographic capabilities
* of the Text component.
*/
const ParagraphComponent = forwardRef<HTMLElement, ParagraphProps>(function Paragraph(
{ children, gutterBottom = false, indent = false, dropCap = false, className, ...props },
ref
) {
return (
<Text
ref={ref}
as="p"
className={cn(
gutterBottom && 'mb-4',
indent && 'indent-8',
dropCap &&
'first-letter:text-7xl first-letter:font-bold first-letter:float-left first-letter:mr-3 first-letter:leading-none',
className
)}
{...props}
>
{children}
</Text>
);
});
export const Paragraph = genericMemo(ParagraphComponent);
@@ -6,7 +6,7 @@ import type { Meta, StoryObj } from '@storybook/react';
/**
* A newspaper-style separator component for dividing content sections.
*
*
* Features:
* - Single or double line variants inspired by newspaper layout traditions
* - Multiple color options (black, outline, outline-light) with theme support
@@ -22,7 +22,8 @@ const meta: Meta<typeof Separator> = {
layout: 'centered',
docs: {
description: {
component: 'A newspaper-style separator following traditional print design patterns for content division.',
component:
'A newspaper-style separator following traditional print design patterns for content division.',
},
},
},
@@ -69,13 +70,9 @@ export const Default: Story = {
decorators: [
(Story) => (
<div className="w-96 p-8">
<div className="text-center text-sm text-muted-foreground mb-4">
Content Above
</div>
<div className="text-center text-sm text-muted-foreground mb-4">Content Above</div>
<Story />
<div className="text-center text-sm text-muted-foreground mt-4">
Content Below
</div>
<div className="text-center text-sm text-muted-foreground mt-4">Content Below</div>
</div>
),
],
@@ -92,13 +89,9 @@ export const Double: Story = {
decorators: [
(Story) => (
<div className="w-96 p-8">
<div className="text-center text-sm text-muted-foreground mb-4">
Content Above
</div>
<div className="text-center text-sm text-muted-foreground mb-4">Content Above</div>
<Story />
<div className="text-center text-sm text-muted-foreground mt-4">
Content Below
</div>
<div className="text-center text-sm text-muted-foreground mt-4">Content Below</div>
</div>
),
],
@@ -115,13 +108,9 @@ export const Black: Story = {
decorators: [
(Story) => (
<div className="w-96 p-8">
<div className="text-center text-sm text-muted-foreground mb-4">
Content Above
</div>
<div className="text-center text-sm text-muted-foreground mb-4">Content Above</div>
<Story />
<div className="text-center text-sm text-muted-foreground mt-4">
Content Below
</div>
<div className="text-center text-sm text-muted-foreground mt-4">Content Below</div>
</div>
),
],
@@ -138,13 +127,9 @@ export const OutlineLight: Story = {
decorators: [
(Story) => (
<div className="w-96 p-8">
<div className="text-center text-sm text-muted-foreground mb-4">
Content Above
</div>
<div className="text-center text-sm text-muted-foreground mb-4">Content Above</div>
<Story />
<div className="text-center text-sm text-muted-foreground mt-4">
Content Below
</div>
<div className="text-center text-sm text-muted-foreground mt-4">Content Below</div>
</div>
),
],
@@ -302,7 +287,7 @@ export const VerticalShowcase: Story = {
Content
</div>
</div>
<div className="flex h-48">
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Medium
@@ -314,7 +299,7 @@ export const VerticalShowcase: Story = {
Content
</div>
</div>
<div className="flex h-64">
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
Tall
@@ -5,7 +5,6 @@ import { VStack } from '../VStack/VStack';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Props for the Separator component.
*/
@@ -22,7 +21,7 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
/**
* A separator component for dividing content sections.
*
*
* Features:
* - Single or double line variants inspired by newspaper layout traditions
* - Multiple color options (black, outline, outline-light) with theme support
@@ -30,26 +29,26 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
* - Optional thinned gradient fade effect at line ends (10% fade length)
* - 2px stroke width following print design standards
* - Semantic HTML with proper ARIA attributes for accessibility
*
*
* The component uses CSS mask-image for the thinned effect and CSS custom properties
* for theme-aware colors. Double separators have 4px spacing between lines.
*
*
* @example
* ```tsx
* // Basic horizontal separator
* <Separator />
*
*
* // Double-line separator with black color
* <Separator type="double" color="black" />
*
*
* // Vertical separator without thinned ends
* <Separator direction="vertical" thinned={false} />
*
*
* // Light separator for subtle content division
* <Separator color="outline-light" />
* ```
*/
export const Separator = memo(function Separator({
export const Separator = memo(function Separator({
type = 'single',
color,
direction = 'horizontal',
@@ -64,7 +63,7 @@ export const Separator = memo(function Separator({
// Color mapping to CSS custom properties
const colorClasses = {
black: 'border-foreground',
outline: 'border-border',
outline: 'border-border',
'outline-light': 'border-muted',
};
@@ -78,25 +77,23 @@ export const Separator = memo(function Separator({
];
// Thinned effect using CSS mask
const thinnedClasses = thinned ? [
direction === 'horizontal'
? '[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]'
: '[mask-image:linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]',
] : [];
const thinnedClasses = thinned
? [
direction === 'horizontal'
? '[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]'
: '[mask-image:linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]',
]
: [];
// Size classes - fill container by default
const sizeClasses = direction === 'horizontal'
? ['w-full', 'h-0'] // Full width, no height (border provides the visual)
: ['h-full', 'w-0']; // Full height, no width (border provides the visual)
const sizeClasses =
direction === 'horizontal'
? ['w-full', 'h-0'] // Full width, no height (border provides the visual)
: ['h-full', 'w-0']; // Full height, no width (border provides the visual)
// Single line implementation
if (type === 'single') {
const lineClasses = cn(
...lineBaseClasses,
...thinnedClasses,
...sizeClasses,
className
);
const lineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses, className);
return (
<div
@@ -109,16 +106,9 @@ export const Separator = memo(function Separator({
}
// Double line implementation
const singleLineClasses = cn(
...lineBaseClasses,
...thinnedClasses,
...sizeClasses
);
const singleLineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses);
const containerClasses = cn(
direction === 'horizontal' ? 'w-full' : 'h-full',
className
);
const containerClasses = cn(direction === 'horizontal' ? 'w-full' : 'h-full', className);
if (direction === 'horizontal') {
return (
@@ -129,20 +119,14 @@ export const Separator = memo(function Separator({
role="separator"
>
<VStack gap={1}>
<div
aria-hidden="true"
className={singleLineClasses}
/>
<div
aria-hidden="true"
className={singleLineClasses}
/>
<div aria-hidden="true" className={singleLineClasses} />
<div aria-hidden="true" className={singleLineClasses} />
</VStack>
</div>
);
} else {
return (
<div
<div
aria-orientation="vertical"
className={cn('flex flex-row gap-1', containerClasses)}
data-testid={testId}
@@ -6,7 +6,7 @@ import type { Meta, StoryObj } from '@storybook/react';
/**
* A flexible layout component that arranges children using CSS Flexbox.
*
*
* The Stack component provides a consistent, type-safe way to create flexible layouts
* with proper spacing and alignment. It supports all major flexbox properties
* through semantic prop names that map to Tailwind CSS utility classes.
@@ -18,7 +18,8 @@ const meta: Meta<typeof Stack> = {
layout: 'centered',
docs: {
description: {
component: 'A flexible layout component using CSS Flexbox for consistent spacing and alignment.',
component:
'A flexible layout component using CSS Flexbox for consistent spacing and alignment.',
},
},
},
@@ -34,7 +35,7 @@ const meta: Meta<typeof Stack> = {
description: 'Main axis alignment',
},
align: {
control: 'select',
control: 'select',
options: ['start', 'end', 'center', 'baseline', 'stretch'],
description: 'Cross axis alignment',
},
@@ -48,6 +49,10 @@ const meta: Meta<typeof Stack> = {
options: [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 12, 16, 20, 24],
description: 'Spacing between items',
},
scrollable: {
control: 'boolean',
description: 'Enable scrolling on the main axis',
},
},
args: {
direction: 'row',
@@ -55,6 +60,7 @@ const meta: Meta<typeof Stack> = {
align: 'start',
wrap: 'nowrap',
gap: 4,
scrollable: false,
},
};
@@ -65,8 +71,16 @@ export default meta;
type Story = StoryObj<typeof meta>;
// Sample content components for demonstrations
const SampleBox = ({ children, color = 'bg-primary' }: { children: React.ReactNode; color?: string }): React.JSX.Element => (
<div className={`${color} text-primary-foreground px-4 py-2 rounded text-sm font-medium min-w-16 text-center`}>
const SampleBox = ({
children,
color = 'bg-primary',
}: {
children: React.ReactNode;
color?: string;
}): React.JSX.Element => (
<div
className={`${color} text-primary-foreground px-4 py-2 rounded text-sm font-medium min-w-16 text-center`}
>
{children}
</div>
);
@@ -179,7 +193,7 @@ export const Reversed: Story = {
<SampleBox color="bg-accent">3</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Column Reverse</h3>
<Stack direction="col-reverse" gap={3}>
@@ -206,7 +220,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 1.5 (Decimal)</h3>
<Stack gap={1.5}>
@@ -215,7 +229,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 2.5 (Decimal)</h3>
<Stack gap={2.5}>
@@ -224,7 +238,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 3.5 (Decimal)</h3>
<Stack gap={3.5}>
@@ -233,7 +247,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 4 (Integer)</h3>
<Stack gap={4}>
@@ -242,7 +256,7 @@ export const GapSizes: Story = {
<SampleBox color="bg-accent">C</SampleBox>
</Stack>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Gap: 8 (Large)</h3>
<Stack gap={8}>
@@ -266,23 +280,27 @@ export const AlignmentVariations: Story = {
<div className="h-20 border-2 border-dashed border-border">
<Stack align="start" className="h-full" gap={2}>
<SampleBox>Short</SampleBox>
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">Tall</div>
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">
Tall
</div>
<SampleBox>Short</SampleBox>
</Stack>
</div>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Align: Center</h3>
<div className="h-20 border-2 border-dashed border-border">
<Stack align="center" className="h-full" gap={2}>
<SampleBox>Short</SampleBox>
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">Tall</div>
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">
Tall
</div>
<SampleBox>Short</SampleBox>
</Stack>
</div>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Align: Stretch</h3>
<div className="h-20 border-2 border-dashed border-border">
@@ -297,6 +315,37 @@ export const AlignmentVariations: Story = {
),
};
/**
* Scrollable stack when content overflows.
*/
export const Scrollable: Story = {
render: () => (
<div className="space-y-8">
<div>
<h3 className="text-sm font-semibold mb-2">Horizontal Scroll</h3>
<div className="w-80 border-2 border-dashed border-border p-2">
<Stack scrollable direction="row" gap={4}>
{Array.from({ length: 8 }).map((_, i) => (
<SampleBox key={`SampleBox_${String(i)}`}>Item {i + 1}</SampleBox>
))}
</Stack>
</div>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Vertical Scroll</h3>
<div className="h-40 w-40 border-2 border-dashed border-border p-2">
<Stack scrollable className="h-full" direction="col" gap={4}>
{Array.from({ length: 8 }).map((_, i) => (
<SampleBox key={`SampleBox_${String(i)}`}>Item {i + 1}</SampleBox>
))}
</Stack>
</div>
</div>
</div>
),
};
/**
* Interactive playground for experimenting with all Stack properties.
*/
@@ -304,9 +353,10 @@ export const Playground: Story = {
args: {
direction: 'row',
justify: 'start',
align: 'center',
align: 'center',
wrap: 'nowrap',
gap: 4,
scrollable: false,
},
render: (args) => (
<div className="w-96 h-64 border-2 border-dashed border-border p-4">
@@ -0,0 +1,64 @@
import React from 'react';
import { Stack } from './Stack';
import { render, screen } from '../../test-utils';
describe('components/Stack', () => {
it('renders children correctly', () => {
render(
<Stack>
<div>Child 1</div>
<div>Child 2</div>
</Stack>
);
expect(screen.getByText('Child 1')).toBeInTheDocument();
expect(screen.getByText('Child 2')).toBeInTheDocument();
});
it('applies basic flex classes', () => {
const { container } = render(<Stack>Content</Stack>);
expect(container.firstChild).toHaveClass('flex');
expect(container.firstChild).toHaveClass('flex-row'); // Default
});
it('applies direction classes', () => {
const { container, rerender } = render(<Stack direction="col">Content</Stack>);
expect(container.firstChild).toHaveClass('flex-col');
rerender(<Stack direction="row-reverse">Content</Stack>);
expect(container.firstChild).toHaveClass('flex-row-reverse');
});
it('applies gap classes', () => {
const { container } = render(<Stack gap={4}>Content</Stack>);
expect(container.firstChild).toHaveClass('gap-4');
});
it('applies scrollable classes correctly', () => {
const { container, rerender } = render(
<Stack scrollable direction="row">
Content
</Stack>
);
// scrollable=true, direction=row -> overflow-x-auto
expect(container.firstChild).toHaveClass('overflow-x-auto');
expect(container.firstChild).not.toHaveClass('overflow-y-auto');
rerender(
<Stack scrollable direction="col">
Content
</Stack>
);
// scrollable=true, direction=col -> overflow-y-auto
expect(container.firstChild).toHaveClass('overflow-y-auto');
expect(container.firstChild).not.toHaveClass('overflow-x-auto');
rerender(
<Stack direction="row" scrollable={false}>
Content
</Stack>
);
expect(container.firstChild).not.toHaveClass('overflow-x-auto');
expect(container.firstChild).not.toHaveClass('overflow-y-auto');
});
});
+69 -21
View File
@@ -6,7 +6,41 @@ import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Tailwind gap spacing scale values.
*/
type GapScale = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
type GapScale =
| 0
| 0.5
| 1
| 1.5
| 2
| 2.5
| 3
| 3.5
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12
| 14
| 16
| 20
| 24
| 28
| 32
| 36
| 40
| 44
| 48
| 52
| 56
| 60
| 64
| 72
| 80
| 96;
/**
* Flexbox direction values.
@@ -44,17 +78,19 @@ export interface StackProps extends ClassNameProps, TestIdProps {
wrap?: FlexWrap;
/** Spacing between items using Tailwind's spacing scale */
gap?: GapScale;
/** Whether to enable scrolling when content overflows */
scrollable?: boolean;
/** The HTML element to use for rendering */
as?: React.ElementType;
}
/**
* A flexible layout component that arranges children using CSS Flexbox.
*
*
* This component provides a consistent, type-safe way to create flexible layouts
* with proper spacing and alignment. It supports all major flexbox properties
* through semantic prop names that map to Tailwind CSS utility classes.
*
*
* @example
* ```tsx
* // Horizontal stack with center alignment
@@ -62,26 +98,35 @@ export interface StackProps extends ClassNameProps, TestIdProps {
* <button>First</button>
* <button>Second</button>
* </Stack>
*
*
* // Vertical stack with space between items
* <Stack direction="col" justify="between" gap={2}>
* <div>Header</div>
* <div>Content</div>
* <div>Footer</div>
* </Stack>
*
* // Scrollable row
* <Stack direction="row" scrollable gap={4}>
* {items.map(item => <Item key={item.id} {...item} />)}
* </Stack>
* ```
*/
const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack({
children,
direction = 'row',
justify = 'start',
align = 'start',
wrap = 'nowrap',
gap = 0,
as: Component = 'div',
className,
'data-testid': testId,
}, ref) {
const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack(
{
children,
direction = 'row',
justify = 'start',
align = 'start',
wrap = 'nowrap',
gap = 0,
scrollable = false,
as: Component = 'div',
className,
'data-testid': testId,
},
ref
) {
const directionClasses = {
row: 'flex-row',
col: 'flex-col',
@@ -150,9 +195,15 @@ const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack({
80: 'gap-80',
96: 'gap-96',
} as const;
const gapClass = gapClasses[gap];
const scrollClasses = scrollable
? direction.includes('row')
? 'overflow-x-auto'
: 'overflow-y-auto'
: '';
const stackClasses = cn(
'flex',
directionClasses[direction],
@@ -160,15 +211,12 @@ const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack({
alignClasses[align],
wrapClasses[wrap],
gapClass,
scrollClasses,
className
);
return (
<Component
ref={castRef<HTMLElement>(ref)}
className={stackClasses}
data-testid={testId}
>
<Component ref={castRef<HTMLElement>(ref)} className={stackClasses} data-testid={testId}>
{children}
</Component>
);
@@ -0,0 +1,64 @@
import React from 'react';
import { Stub } from './Stub';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Stub> = {
title: 'components/Stub',
component: Stub,
tags: ['autodocs'],
argTypes: {
rounded: {
control: 'radio',
options: ['none', 'sm', 'md', 'lg', 'full'],
},
animate: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof Stub>;
export const Default: Story = {
args: {
width: '200px',
height: '100px',
rounded: 'md',
animate: true,
},
};
export const Variants: Story = {
render: () => (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Text Skeletons</span>
<Stub height="1rem" width="60%" />
<Stub height="1rem" width="80%" />
<Stub height="1rem" width="40%" />
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Avatar Skeleton</span>
<Stub height="3rem" rounded="full" width="3rem" />
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Card Skeleton</span>
<Stub height="200px" rounded="lg" width="100%" />
</div>
</div>
),
};
export const Static: Story = {
args: {
width: '200px',
height: '100px',
animate: false,
},
};
@@ -0,0 +1,43 @@
import React from 'react';
import { Stub } from './Stub';
import { render, screen } from '../../test-utils';
describe('components/Stub', () => {
it('renders correctly with default props', () => {
render(<Stub />);
const stub = screen.getByTestId('stub');
expect(stub).toBeInTheDocument();
expect(stub).toHaveClass('animate-shimmer');
expect(stub).toHaveClass('rounded-md');
});
it('applies custom dimensions via style', () => {
render(<Stub height="100px" width="200px" />);
const stub = screen.getByTestId('stub');
expect(stub).toHaveStyle({ width: '200px', height: '100px' });
});
it('applies correct rounded classes', () => {
const { rerender } = render(<Stub rounded="full" />);
expect(screen.getByTestId('stub')).toHaveClass('rounded-full');
rerender(<Stub rounded="none" />);
expect(screen.getByTestId('stub')).toHaveClass('rounded-none');
rerender(<Stub rounded="lg" />);
expect(screen.getByTestId('stub')).toHaveClass('rounded-lg');
});
it('disables animation when animate prop is false', () => {
render(<Stub animate={false} />);
const stub = screen.getByTestId('stub');
expect(stub).not.toHaveClass('animate-shimmer');
});
it('applies custom className', () => {
render(<Stub className="custom-class" />);
const stub = screen.getByTestId('stub');
expect(stub).toHaveClass('custom-class');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
/**
* Props for the Stub (Skeleton) component.
*/
export interface StubProps {
/** Width of the stub */
width?: string | number;
/** Height of the stub */
height?: string | number;
/** Corner radius variant */
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full';
/** Additional CSS classes */
className?: string;
/** Whether to animate the shimmering effect */
animate?: boolean;
}
/**
* A skeleton placeholder component with a shimmering animation.
* Used to indicate loading states while maintaining layout stability.
*
* @param props - Component properties
* @param props.width - Width of the stub
* @param props.height - Height of the stub
* @param props.rounded - Corner radius variant
* @param props.className - Additional CSS classes
* @param props.animate - Whether to animate the shimmering effect
* @returns A shimmering skeleton element
* @example
* <Stub width="100%" height="20px" rounded="sm" />
*/
export const Stub: React.FC<StubProps> = ({
width,
height,
rounded = 'md',
className,
animate = true,
}) => {
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
return (
<div
className={cn(
'bg-[var(--rb-color-neutral-100)] dark:bg-[var(--rb-color-neutral-800)]',
animate && 'animate-shimmer',
roundedClasses[rounded],
className
)}
data-testid="stub"
style={{ width, height }}
/>
);
};
+127 -22
View File
@@ -5,7 +5,6 @@ import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Text> = {
title: 'components/Text',
component: Text,
@@ -23,6 +22,21 @@ const meta: Meta<typeof Text> = {
control: 'select',
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
},
align: {
control: 'select',
options: ['left', 'center', 'right', 'justify'],
},
wrap: {
control: 'select',
options: ['nowrap', 'balance', 'pretty'],
},
overflow: {
control: 'select',
options: ['ellipsis', 'clip'],
},
maxLines: {
control: 'number',
},
},
};
@@ -40,47 +54,136 @@ export const Default: Story = {
},
};
export const Layout: Story = {
render: () => (
<VStack gap={4}>
<div className="w-full max-w-md border p-4">
<Text align="center" as="div">
Centered Text
</Text>
<Text align="right" as="div">
Right Aligned Text
</Text>
<Text align="justify" as="div">
Justified text: The quick brown fox jumps over the lazy dog. This sentence is repeated to
demonstrate justification behavior in a multi-line context. The quick brown fox jumps over
the lazy dog.
</Text>
</div>
<div className="w-full max-w-xs border p-4">
<Text as="div" wrap="nowrap">
Nowrap: The quick brown fox jumps over the lazy dog.
</Text>
<Text as="div" className="mt-4" wrap="balance">
Balanced: This headline should be balanced across lines for better visual weight.
</Text>
<Text as="div" className="mt-4" wrap="pretty">
Pretty: This paragraph uses text-wrap: pretty to avoid orphans at the end of the text
block.
</Text>
</div>
</VStack>
),
};
export const Truncation: Story = {
render: () => (
<VStack gap={4}>
<div className="w-full max-w-[200px] border p-2">
<Text as="div" overflow="ellipsis">
Single line truncation with ellipsis.
</Text>
</div>
<div className="w-full max-w-[200px] border p-2">
<Text as="div" maxLines={2}>
Multi-line clamping: this text will be truncated after two lines of content.
</Text>
</div>
</VStack>
),
};
export const TypeScale: Story = {
render: () => (
<VStack gap={8}>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Display</Text>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">
Display
</Text>
<VStack gap={2}>
<Text size="l" type="display">Display Large</Text>
<Text size="m" type="display">Display Medium</Text>
<Text size="s" type="display">Display Small</Text>
<Text size="l" type="display">
Display Large
</Text>
<Text size="m" type="display">
Display Medium
</Text>
<Text size="s" type="display">
Display Small
</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Headline</Text>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">
Headline
</Text>
<VStack gap={2}>
<Text size="l" type="headline">Headline Large</Text>
<Text size="m" type="headline">Headline Medium</Text>
<Text size="s" type="headline">Headline Small</Text>
<Text size="l" type="headline">
Headline Large
</Text>
<Text size="m" type="headline">
Headline Medium
</Text>
<Text size="s" type="headline">
Headline Small
</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Title</Text>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">
Title
</Text>
<VStack gap={2}>
<Text size="l" type="title">Title Large</Text>
<Text size="m" type="title">Title Medium</Text>
<Text size="s" type="title">Title Small</Text>
<Text size="l" type="title">
Title Large
</Text>
<Text size="m" type="title">
Title Medium
</Text>
<Text size="s" type="title">
Title Small
</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Body</Text>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">
Body
</Text>
<VStack gap={2}>
<Text size="l" type="body">Body Large</Text>
<Text size="m" type="body">Body Medium</Text>
<Text size="s" type="body">Body Small</Text>
<Text size="l" type="body">
Body Large
</Text>
<Text size="m" type="body">
Body Medium
</Text>
<Text size="s" type="body">
Body Small
</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Label</Text>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">
Label
</Text>
<VStack gap={2}>
<Text size="l" type="label">Label Large</Text>
<Text size="m" type="label">Label Medium</Text>
<Text size="s" type="label">Label Small</Text>
<Text size="l" type="label">
Label Large
</Text>
<Text size="m" type="label">
Label Medium
</Text>
<Text size="s" type="label">
Label Small
</Text>
</VStack>
</div>
</VStack>
@@ -107,7 +210,9 @@ export const Modifiers: Story = {
<Text italic>Italic Text</Text>
<Text underline>Underlined Text</Text>
<Text strike>Strikethrough Text</Text>
<Text bold italic underline>Combined Modifiers</Text>
<Text bold italic underline>
Combined Modifiers
</Text>
</VStack>
),
};
@@ -0,0 +1,42 @@
import React from 'react';
import { Text } from './Text';
import { render, screen } from '../../test-utils';
describe('components/Text', () => {
it('renders children correctly', () => {
render(<Text>Test content</Text>);
expect(screen.getByText('Test content')).toBeInTheDocument();
});
it('applies alignment classes', () => {
render(<Text align="center">Centered</Text>);
expect(screen.getByText('Centered')).toHaveClass('text-center');
});
it('applies wrap classes', () => {
render(<Text wrap="balance">Balanced</Text>);
expect(screen.getByText('Balanced')).toHaveClass('text-balance');
});
it('applies maxLines (line-clamp)', () => {
render(<Text maxLines={3}>Clamped</Text>);
expect(screen.getByText('Clamped')).toHaveClass('line-clamp-3');
});
it('applies overflow ellipsis (truncate)', () => {
render(<Text overflow="ellipsis">Truncated</Text>);
expect(screen.getByText('Truncated')).toHaveClass('truncate');
});
it('maxLines takes precedence over overflow', () => {
render(
<Text maxLines={2} overflow="ellipsis">
Priority
</Text>
);
const el = screen.getByText('Priority');
expect(el).toHaveClass('line-clamp-2');
expect(el).not.toHaveClass('truncate');
});
});
+68 -20
View File
@@ -16,12 +16,22 @@ export type TextSize = 's' | 'm' | 'l';
/**
* Text color variants.
*/
export type TextColor = 'default' | 'primary' | 'secondary' | 'tertiary' | 'error' | 'muted' | 'inherit';
export type TextColor =
| 'default'
| 'primary'
| 'secondary'
| 'tertiary'
| 'error'
| 'muted'
| 'inherit';
/**
* Props for the Text component.
*/
export interface TextProps extends ClassNameProps, TestIdProps, Omit<React.HTMLAttributes<HTMLElement>, 'as'> {
export interface TextProps
extends ClassNameProps,
TestIdProps,
Omit<React.HTMLAttributes<HTMLElement>, 'as'> {
/** Content to be rendered */
children: React.ReactNode;
/** Typography style type */
@@ -42,29 +52,43 @@ export interface TextProps extends ClassNameProps, TestIdProps, Omit<React.HTMLA
as?: React.ElementType;
/** Optional href for links */
href?: string;
/** Text alignment */
align?: 'left' | 'center' | 'right' | 'justify';
/** Text wrapping behavior */
wrap?: 'nowrap' | 'balance' | 'pretty';
/** Overflow behavior for single lines */
overflow?: 'ellipsis' | 'clip';
/** Maximum number of lines to show (truncates with ellipsis) */
maxLines?: number;
}
/**
* A flexible typography component that implements the design system's type scale.
*
* Text centralizes all typography rules, including font families, sizes,
*
* Text centralizes all typography rules, including font families, sizes,
* line heights, and letter spacing, reducing the need for custom CSS.
*/
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text({
children,
type = 'body',
size = 'm',
color = 'default',
bold = false,
italic = false,
underline = false,
strike = false,
as: Component = 'span',
className,
'data-testid': testId,
...props
}, ref) {
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
{
children,
type = 'body',
size = 'm',
color = 'default',
bold = false,
italic = false,
underline = false,
strike = false,
align,
wrap,
overflow,
maxLines,
as: Component = 'span',
className,
'data-testid': testId,
...props
},
ref
) {
// Base typography styles mapping
const typeStyles: Record<TextType, Record<TextSize, string>> = {
display: {
@@ -104,6 +128,27 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text({
inherit: 'text-inherit',
};
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
const wrapClasses = {
nowrap: 'whitespace-nowrap',
balance: 'text-balance',
pretty: 'text-pretty',
};
let truncationClass = '';
if (maxLines) {
truncationClass = `line-clamp-${maxLines}`;
} else {
if (overflow === 'ellipsis') truncationClass = 'truncate';
if (overflow === 'clip') truncationClass = 'overflow-clip';
}
return (
<Component
{...props}
@@ -115,6 +160,9 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text({
italic && 'italic',
underline && 'underline',
strike && 'line-through',
align && alignClasses[align],
wrap && wrapClasses[wrap],
truncationClass,
className
)}
data-testid={testId}
@@ -124,4 +172,4 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text({
);
});
export const Text = genericMemo(TextComponent);
export const Text = genericMemo(TextComponent);
+8 -6
View File
@@ -15,11 +15,11 @@ export interface VStackProps extends Omit<StackProps, 'direction'> {
/**
* A vertical stack layout component that arranges children in a column using CSS Flexbox.
*
*
* This is a specialized version of the Stack component with a fixed vertical direction.
* It provides a convenient, semantic way to create vertical layouts without needing
* to specify direction="col" every time.
*
*
* @example
* ```tsx
* // Simple vertical stack
@@ -28,7 +28,7 @@ export interface VStackProps extends Omit<StackProps, 'direction'> {
* <div>Content</div>
* <div>Footer</div>
* </VStack>
*
*
* // Centered vertical stack with spacing
* <VStack align="center" justify="center" gap={6}>
* <h1>Title</h1>
@@ -37,6 +37,8 @@ export interface VStackProps extends Omit<StackProps, 'direction'> {
* </VStack>
* ```
*/
export const VStack = memo(forwardRef<HTMLElement, VStackProps>(function VStack(props, ref) {
return <Stack direction="col" {...props} ref={ref} />;
}));
export const VStack = memo(
forwardRef<HTMLElement, VStackProps>(function VStack(props, ref) {
return <Stack direction="col" {...props} ref={ref} />;
})
);
+21 -2
View File
@@ -6,9 +6,28 @@ export * from './Text/Text';
export * from './VStack/VStack';
export * from './Link/Link';
export * from './Breadcrumbs/Breadcrumbs';
export * from './List/List';
export * from './Markdown/Markdown';
export * from './Paragraph/Paragraph';
export * from './Button/Button';
export * from './Code/Code';
export * from './Blockquote';
export * from './Stub/Stub';
export * from './Image/Image';
export type { StackProps } from './Stack/Stack';
export type { ContainerProps, ContainerBackground, ContainerMaxWidth, ContainerRounded } from './Container/Container';
export type { ListProps, ListVariant, ListSpacing, ListItemProps } from './List/List';
export type {
ContainerProps,
ContainerBackground,
ContainerMaxWidth,
ContainerRounded,
} from './Container/Container';
export type { TextProps, TextType, TextSize, TextColor } from './Text/Text';
export type { HeaderProps } from './Header/Header';
export type { HeaderProps } from './Header/Header';
export type { ParagraphProps } from './Paragraph/Paragraph';
export type { ButtonProps, ButtonVariant, ButtonColor, ButtonSize } from './Button/Button';
export type { CodeProps } from './Code/Code';
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
export type { StubProps } from './Stub/Stub';
export type { ImageProps } from './Image/Image';
+1 -2
View File
@@ -1,6 +1,5 @@
// Export all components
export * from './components/paper';
export * from './components/generic';
export * from './components';
// Import styles to ensure they're bundled
import './styles/index.css';
+36 -2
View File
@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400..800;1,400..800&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400..800;1,400..800&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Fira+Code:wght@300..700&display=swap');
@import 'tailwindcss';
@import 'tw-animate-css';
@@ -8,6 +8,7 @@
@theme {
--font-sans: 'Lato', sans-serif;
--font-serif: 'EB Garamond', serif;
--font-mono: 'Fira Code', monospace;
}
@theme {
@@ -174,6 +175,39 @@
@layer base {
body {
@apply bg-background text-foreground;
background-color: var(--rb-base);
color: var(--rb-text);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@layer utilities {
.animate-shimmer {
background: linear-gradient(
90deg,
var(--rb-color-neutral-100) 25%,
var(--rb-color-neutral-200) 50%,
var(--rb-color-neutral-100) 75%
);
background-size: 200% 100%;
animation: shimmer 2s infinite linear;
}
.dark .animate-shimmer {
background: linear-gradient(
90deg,
var(--rb-color-neutral-800) 25%,
var(--rb-color-neutral-700) 50%,
var(--rb-color-neutral-800) 75%
);
background-size: 200% 100%;
}
}
+1 -4
View File
@@ -13,10 +13,7 @@ import type { ReactElement } from 'react';
* render(<Button>Click me</Button>);
* ```
*/
function customRender(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
): RenderResult {
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>): RenderResult {
return render(ui, {
// Add any providers here if needed in the future
...options,
+7 -1
View File
@@ -4,7 +4,13 @@ import { mergeConfig } from 'vite';
const baseConfig = createLibConfig('ErmnvldmrUI', 'src/index.ts', ['@ermnvldmr/stl'], {
dts: {
exclude: ['**/*.test.ts', '**/*.test.tsx', '**/*.stories.tsx', 'src/setupTests.ts', 'src/test-utils.tsx'],
exclude: [
'**/*.test.ts',
'**/*.test.tsx',
'**/*.stories.tsx',
'src/setupTests.ts',
'src/test-utils.tsx',
],
},
});
+6 -6
View File
@@ -10,17 +10,17 @@ export interface LibConfigOptions {
/**
* Base Vite configuration for library packages.
*
*
* @param packageName - The name for the library build
* @param entry - Entry point file path (relative to package root)
* @param external - Array of external dependencies to exclude from bundle
* @param options - Additional configuration options
* @returns Vite configuration object
*
*
* @example
* ```typescript
* import { createLibConfig } from '@ermnvldmr/vite-config/lib';
*
*
* export default createLibConfig('MyLib', 'src/index.ts', ['react', 'react-dom']);
* ```
*/
@@ -47,11 +47,11 @@ export function createLibConfig(
rollupOptions: {
external: [
'react',
'react-dom',
'react/jsx-runtime',
'react-dom',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'tailwindcss',
...external
...external,
],
output: {
globals: {
+7242 -2421
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -7,6 +7,6 @@ title: "Ermnvldmr's Documentation"
Welcome to the unified documentation for my own projects
{{< cards >}}
{{< card link="root" title="root.ermnvldmr.com" icon="server" subtitle="Documentation for root.ermnvldmr.com infrastructure" >}}
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Modular Discord bot framework documentation" >}}
{{< card link="root" title="root.ermnvldmr.com" icon="server" subtitle="Documentation for root.ermnvldmr.com infrastructure" >}}
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Modular Discord bot framework documentation" >}}
{{< /cards >}}
+9 -9
View File
@@ -15,16 +15,16 @@ The framework addresses the fundamental challenge of extensible bot design: prov
## Documentation Sections
{{% cards %}}
{{< card link="getting-started" title="Getting Started" icon="lightning-bolt" subtitle="Understand the core philosophy and architecture of Quetza." >}}
{{< card link="guides" title="Guides" icon="book-open" subtitle="Step-by-step instructions for development and deployment." >}}
{{< card link="recipes" title="Recipes" icon="beaker" subtitle="Strategic patterns for common bot features." >}}
{{< card link="concepts" title="Concepts" icon="light-bulb" subtitle="Deep dive into the philosophy and design principles." >}}
{{< card link="troubleshooting" title="Troubleshooting" icon="shield-exclamation" subtitle="Diagnose and fix common issues." >}}
{{< card link="getting-started" title="Getting Started" icon="lightning-bolt" subtitle="Understand the core philosophy and architecture of Quetza." >}}
{{< card link="guides" title="Guides" icon="book-open" subtitle="Step-by-step instructions for development and deployment." >}}
{{< card link="recipes" title="Recipes" icon="beaker" subtitle="Strategic patterns for common bot features." >}}
{{< card link="concepts" title="Concepts" icon="light-bulb" subtitle="Deep dive into the philosophy and design principles." >}}
{{< card link="troubleshooting" title="Troubleshooting" icon="shield-exclamation" subtitle="Diagnose and fix common issues." >}}
{{% /cards %}}
## Key Features
* **Modular Architecture:** Features are plugins, not core modifications.
* **TypeScript First:** Built for type safety and developer experience.
* **Zero-Config Discovery:** Filesystem-based module registration.
* **State Management:** Built-in patterns for guild-scoped services.
- **Modular Architecture:** Features are plugins, not core modifications.
- **TypeScript First:** Built for type safety and developer experience.
- **Zero-Config Discovery:** Filesystem-based module registration.
- **State Management:** Built-in patterns for guild-scoped services.
@@ -3,12 +3,11 @@ title: Concepts
weight: 4
---
**Quetza** concepts explain the *Why* and *How* behind the framework's design. These documents cover the underlying philosophy and architectural decisions.
**Quetza** concepts explain the _Why_ and _How_ behind the framework's design. These documents cover the underlying philosophy and architectural decisions.
## Key Topics
{{% cards %}}
{{< card link="architecture" title="Architecture & Philosophy" icon="scale" subtitle="Design principles: Convention over Configuration, Composition, and Isolation." >}}
{{< card link="module-system" title="The Module System" icon="template" subtitle="Understanding Clients, Modules, Controllers, and Events." >}}
{{< card link="architecture" title="Architecture & Philosophy" icon="scale" subtitle="Design principles: Convention over Configuration, Composition, and Isolation." >}}
{{< card link="module-system" title="The Module System" icon="template" subtitle="Understanding Clients, Modules, Controllers, and Events." >}}
{{% /cards %}}
@@ -3,36 +3,43 @@ title: Architecture & Philosophy
weight: 1
---
**Goal:** Understand the core design principles that drive Quetza's modularity.
## Design Philosophies
### Convention Over Configuration
Quetza minimizes boilerplate by relying on strict folder structures.
* **No central registry:** The filesystem *is* the registry.
* **Result:** You don't update a config file to add a module; you just create the folder.
- **No central registry:** The filesystem _is_ the registry.
- **Result:** You don't update a config file to add a module; you just create the folder.
### Composition Over Inheritance
Modules are composed of capabilities, not inherited from a base class.
* **Flexibility:** Modules can use any internal architecture (Functional, OOP) as long as they export the required interfaces.
- **Flexibility:** Modules can use any internal architecture (Functional, OOP) as long as they export the required interfaces.
### Isolation Over Integration
Modules cannot directly invoke each other.
* **Why:** Prevents tight coupling.
* **Benefit:** You can delete any module without breaking the rest of the bot.
- **Why:** Prevents tight coupling.
- **Benefit:** You can delete any module without breaking the rest of the bot.
## Core Entities
| Entity | Role | Lifecycle |
| :--- | :--- | :--- |
| **Client** | Service Locator & Orchestrator | Singleton. Runs the show. |
| **Module** | Namespace for features | Loaded at startup. |
| **Command** | User Interaction Endpoint | Stateless. Request/Response. |
| **Event** | Reactive Observer | Subscribed at startup. |
| **Controller** | State Manager | Persists across interactions. |
| Entity | Role | Lifecycle |
| :------------- | :----------------------------- | :---------------------------- |
| **Client** | Service Locator & Orchestrator | Singleton. Runs the show. |
| **Module** | Namespace for features | Loaded at startup. |
| **Command** | User Interaction Endpoint | Stateless. Request/Response. |
| **Event** | Reactive Observer | Subscribed at startup. |
| **Controller** | State Manager | Persists across interactions. |
---
**See also:**
- [Service Locator Pattern](https://en.wikipedia.org/wiki/Service_locator_pattern)
- [Observer Pattern](https://en.wikipedia.org/wiki/Observer_pattern)
@@ -3,52 +3,65 @@ title: The Module System
weight: 2
---
**Goal:** Define the vocabulary and mechanics of Quetza's primary abstraction unit.
## System Vocabulary
### Module Declaration
The `module.ts` file acts as the manifest. It is the entry point for discovery.
```typescript
export const name = 'my-module'; // ID
export const description = '...'; // Doc
```
### Command Registration
Occurs during the `ready` event.
1. Quetza scans all `commands/` folders.
2. Compiles `ApplicationCommandData`.
3. PUTs the payload to Discord's API.
### Controller Injection
State is injected, not imported.
```typescript
// The framework calls this:
execute(client, interaction, controller)
execute(client, interaction, controller);
```
* **Why?** Allows the framework to manage the controller's lifecycle (singleton per module).
- **Why?** Allows the framework to manage the controller's lifecycle (singleton per module).
## State Management Philosophy
### Ephemeral by Default
Quetza provides **no built-in database**.
* **Memory:** Controllers store state in JS `Map` or `Set` objects.
* **Restart:** All state is lost when the process exits.
* **Persistence:** Must be implemented manually (e.g., SQLite, Redis) inside the Controller.
- **Memory:** Controllers store state in JS `Map` or `Set` objects.
- **Restart:** All state is lost when the process exits.
- **Persistence:** Must be implemented manually (e.g., SQLite, Redis) inside the Controller.
### Scope as Key
State must be keyed by its boundary.
* `Map<GuildID, ...>` - Server specific (Most common).
* `Map<UserID, ...>` - User specific (DM bots).
- `Map<GuildID, ...>` - Server specific (Most common).
- `Map<UserID, ...>` - User specific (DM bots).
## Error Handling Model
**Fail Fast & Friendly.**
* **Validation:** Check permissions/inputs immediately.
* **User-Facing:** "You don't have permission" (Not "Error: 403").
* **Isolation:** A crash in `/ping` catches itself and doesn't kill the bot.
- **Validation:** Check permissions/inputs immediately.
- **User-Facing:** "You don't have permission" (Not "Error: 403").
- **Isolation:** A crash in `/ping` catches itself and doesn't kill the bot.
---
**See also:**
- [Discord Interaction Lifecycle](https://discord.com/developers/docs/interactions/receiving-and-responding)
@@ -12,10 +12,13 @@ Quetza provides an architectural foundation that transforms feature development
## The Core Philosophy
### The Problem
Traditional monolithic bots suffer from brittleness. Adding features requires modifying core logic, intertwining dependencies, and managing growing codebases that are difficult to test or change.
### The Solution
Quetza treats modules as plugins.
- **Zero-Touch Extension:** Add features by creating a folder. No core code modification required.
- **Isolation:** Modules operate independently.
- **Composition:** Enable/disable features by simply adding or removing module folders.
@@ -31,28 +34,33 @@ Quetza consists of three primary layers:
## Key Concepts
### Mental Model
To work effectively with Quetza, adopt this framework:
* **Modules are Plugins:** Independent folders discovered at runtime.
* **Commands are Endpoints:** Stateless handlers acting like REST API endpoints.
* **Events are Observers:** Reactive logic subscribing to Discord lifecycle events.
* **Controllers are Services:** Stateful managers bridging the gap between stateless commands and persistent behavior.
- **Modules are Plugins:** Independent folders discovered at runtime.
- **Commands are Endpoints:** Stateless handlers acting like REST API endpoints.
- **Events are Observers:** Reactive logic subscribing to Discord lifecycle events.
- **Controllers are Services:** Stateful managers bridging the gap between stateless commands and persistent behavior.
### System Boundaries
Understanding constraints is critical for design:
* **No Persistence:** In-memory state is lost on restart. External storage is required for persistence.
* **No Inter-Module Communication:** Modules cannot directly invoke each other, ensuring loose coupling.
* **No Dynamic Loading:** Modules are loaded only at startup.
- **No Persistence:** In-memory state is lost on restart. External storage is required for persistence.
- **No Inter-Module Communication:** Modules cannot directly invoke each other, ensuring loose coupling.
- **No Dynamic Loading:** Modules are loaded only at startup.
## Architectural Invariants
Quetza trades flexibility for predictability through strict conventions:
* **Convention-Based Discovery:** Modules *must* follow the directory structure (`module.ts`, `commands/`, `events/`) to be loaded.
* **Single-Phase Initialization:** All loading happens before the Discord connection is established.
* **Interface Compliance:** All commands and events must export specific interfaces to be registered.
- **Convention-Based Discovery:** Modules _must_ follow the directory structure (`module.ts`, `commands/`, `events/`) to be loaded.
- **Single-Phase Initialization:** All loading happens before the Discord connection is established.
- **Interface Compliance:** All commands and events must export specific interfaces to be registered.
---
**See also:**
- [Discord.js Documentation](https://discord.js.org/)
- [TypeScript Documentation](https://www.typescriptlang.org/)
@@ -3,7 +3,6 @@ title: Guides
weight: 2
---
**Quetza** guides provide step-by-step instructions for common tasks and workflows. These documents focus on practical application of the framework's concepts.
## Core Workflows
@@ -17,6 +16,6 @@ The development and operation of Quetza bots revolve around three primary workfl
## Available Guides
{{% cards %}}
{{< card link="module-development" title="Module Development" icon="code" subtitle="Step-by-step guide to creating your first module." >}}
{{< card link="deployment" title="Deployment & Operations" icon="server" subtitle="Deploying Quetza with Docker or from source." >}}
{{< card link="module-development" title="Module Development" icon="code" subtitle="Step-by-step guide to creating your first module." >}}
{{< card link="deployment" title="Deployment & Operations" icon="server" subtitle="Deploying Quetza with Docker or from source." >}}
{{% /cards %}}
@@ -3,70 +3,74 @@ title: Deployment & Operations
weight: 2
---
**Goal:** Configure, deploy, and monitor a Quetza bot instance.
## 1. Environment Preparation
Quetza relies on environment variables for configuration. **Never** commit these to version control.
| Variable | Description | Required |
| :--- | :--- | :--- |
| `DISCORD_TOKEN` | Bot token from Discord Developer Portal | **Yes** |
| `CLIENT_ID` | Application ID for slash command registration | **Yes** |
| `LLAMA_API_KEY` | Required if using the AI module | No |
| Variable | Description | Required |
| :-------------- | :-------------------------------------------- | :------- |
| `DISCORD_TOKEN` | Bot token from Discord Developer Portal | **Yes** |
| `CLIENT_ID` | Application ID for slash command registration | **Yes** |
| `LLAMA_API_KEY` | Required if using the AI module | No |
## 2. Deployment Strategies
{{< tabs items="Docker (Recommended),Source" >}}
{{< tab >}}
{{< tab >}}
**Docker** ensures a consistent runtime environment.
1. **Build** the image:
```bash
docker build -t quetza-bot .
```
2. **Run** the container:
```bash
docker run -d \
--env-file .env \
--name quetza \
quetza-bot
```
{{< /tab >}}
{{< tab >}}
**Source** deployment is useful for development.
`bash
docker run -d \
--env-file .env \
--name quetza \
quetza-bot
`
{{< /tab >}}
{{< tab >}}
**Source** deployment is useful for development.
3. **Install** dependencies:
1. **Install** dependencies:
```bash
pnpm install
```
2. **Build** the project:
4. **Build** the project:
```bash
pnpm build
```
3. **Start** the bot:
```bash
pnpm start
```
{{< /tab >}}
{{< /tabs >}}
5. **Start** the bot:
`bash
pnpm start
`
{{< /tab >}}
{{< /tabs >}}
## 3. Operational Monitoring
Once running, use these mechanisms to check health:
* **Logs:** Check standard output (Docker logs) for initialization errors.
* **`/ping` Command:** Verifies network latency and API responsiveness.
* **`/modules` Command:** Lists all loaded modules to confirm feature availability.
- **Logs:** Check standard output (Docker logs) for initialization errors.
- **`/ping` Command:** Verifies network latency and API responsiveness.
- **`/modules` Command:** Lists all loaded modules to confirm feature availability.
> [!WARNING]
> **Scaling Constraint:**
> [!WARNING] > **Scaling Constraint:**
> Quetza follows a single-instance-per-token model. Do not run multiple instances with the same token, as this will cause WebSocket conflicts.
---
**See also:**
- [Docker Documentation](https://docs.docker.com/)
- [Discord Developer Portal](https://discord.com/developers/applications)
@@ -3,29 +3,31 @@ title: Module Development Workflow
weight: 1
---
**Goal:** Create a new feature for the Quetza bot by implementing a self-contained module.
## 1. Conceptualization Phase
Before writing code, determine the module's architecture:
* **Simple Command:** Needs only a `commands/` folder (e.g., utility commands).
* **Reactive Logic:** Needs `events/` to listen to Discord lifecycles (e.g., welcome messages).
* **Stateful Feature:** Needs a **Controller** to manage state across guilds (e.g., music player).
- **Simple Command:** Needs only a `commands/` folder (e.g., utility commands).
- **Reactive Logic:** Needs `events/` to listen to Discord lifecycles (e.g., welcome messages).
- **Stateful Feature:** Needs a **Controller** to manage state across guilds (e.g., music player).
## 2. Structure Establishment
Create the module directory structure. The presence of these files triggers automatic discovery.
{{< filetree/container >}}
{{< filetree/folder name="src" state="open" >}}
{{< filetree/folder name="modules" state="open" >}}
{{< filetree/folder name="my-feature" state="open" >}}
{{< filetree/file name="module.ts" >}}
{{< filetree/folder name="commands" >}} {{< /filetree/folder >}}
{{< filetree/folder name="events" >}} {{< /filetree/folder >}}
{{< filetree/folder name="lib" >}} {{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< filetree/folder name="src" state="open" >}}
{{< filetree/folder name="modules" state="open" >}}
{{< filetree/folder name="my-feature" state="open" >}}
{{< filetree/file name="module.ts" >}}
{{< filetree/folder name="commands" >}} {{< /filetree/folder >}}
{{< filetree/folder name="events" >}} {{< /filetree/folder >}}
{{< filetree/folder name="lib" >}} {{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/folder >}}
{{< /filetree/container >}}
## 3. Implementation Steps
@@ -33,6 +35,7 @@ Create the module directory structure. The presence of these files triggers auto
{{% steps %}}
### Step 1: Create the Manifest
Create `module.ts` to declare the module.
```typescript
@@ -43,15 +46,14 @@ export const description = 'Adds cool feature capabilities';
```
### Step 2: Implement Commands
Create command files in `commands/`. Each file exports data and an executor.
```typescript
// src/modules/my-feature/commands/ping.ts
import { SlashCommandBuilder } from 'discord.js';
export const data = new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong!');
export const data = new SlashCommandBuilder().setName('ping').setDescription('Replies with Pong!');
export async function execute(interaction) {
await interaction.reply('Pong!');
@@ -59,6 +61,7 @@ export async function execute(interaction) {
```
### Step 3: Implement Events (Optional)
Create event handlers in `events/` to observe Discord state.
```typescript
@@ -74,10 +77,13 @@ export function execute(client) {
```
### Step 4: Define Controller (Optional)
For stateful logic, implement a controller in `lib/` and export it in `module.ts`.
### Step 5: Validate
Restart the bot.
1. Check logs for "Loaded module: my-feature".
2. Verify the command `/ping` appears in Discord.
@@ -85,13 +91,15 @@ Restart the bot.
## Integration Patterns
| Pattern | Use Case | Components |
| :--- | :--- | :--- |
| **Simple Command** | Stateless utilities (Ping, Dice Roll) | `commands/` only |
| **Stateful Service** | Music, RPGs, Economy | `commands/` + `lib/Controller` |
| **Event-Driven** | Auto-moderation, Logging | `events/` only |
| **Hybrid** | Complex features (AI Chat) | All components |
| Pattern | Use Case | Components |
| :------------------- | :------------------------------------ | :----------------------------- |
| **Simple Command** | Stateless utilities (Ping, Dice Roll) | `commands/` only |
| **Stateful Service** | Music, RPGs, Economy | `commands/` + `lib/Controller` |
| **Event-Driven** | Auto-moderation, Logging | `events/` only |
| **Hybrid** | Complex features (AI Chat) | All components |
---
**See also:**
- [Discord.js Guide: Slash Commands](https://discordjs.guide/creating-your-bot/slash-commands.html)
@@ -3,12 +3,11 @@ title: Recipes
weight: 3
---
**Quetza** recipes are strategic architectural patterns for common functional requirements. They focus on *how* to compose framework features to solve business problems.
**Quetza** recipes are strategic architectural patterns for common functional requirements. They focus on _how_ to compose framework features to solve business problems.
## Recipe Categories
{{% cards %}}
{{< card link="voice-features" title="Interactive Voice Features" icon="users" subtitle="Managing audio players and guild-scoped state." >}}
{{< card link="conversational-ai" title="Conversational AI" icon="chip" subtitle="Handling LLM context and long-running responses." >}}
{{< card link="voice-features" title="Interactive Voice Features" icon="users" subtitle="Managing audio players and guild-scoped state." >}}
{{< card link="conversational-ai" title="Conversational AI" icon="chip" subtitle="Handling LLM context and long-running responses." >}}
{{% /cards %}}
@@ -3,27 +3,33 @@ title: Conversational AI Integration
weight: 2
---
**Goal:** Integrate Large Language Models (LLMs) while managing context and Discord's timeout constraints.
## 1. Context Management Strategy
Conversations require history to be coherent.
* **Scoping:** `Map<GuildID, Map<UserID, Message[]>>`
* **Guild Key:** Prevents cross-server leaks.
* **User Key:** Prevents cross-user confusion in public channels.
* **Lifecycle:** Implement a rolling window (e.g., last 50 messages) to prevent context bloat.
- **Scoping:** `Map<GuildID, Map<UserID, Message[]>>`
- **Guild Key:** Prevents cross-server leaks.
- **User Key:** Prevents cross-user confusion in public channels.
- **Lifecycle:** Implement a rolling window (e.g., last 50 messages) to prevent context bloat.
## 2. Response Streaming
LLMs are slow; Discord is fast (3s timeout).
{{% steps %}}
### Step 1: Defer Interaction
Immediately call `interaction.deferReply()`. This buys you 15 minutes.
### Step 2: Invoke LLM
Call your AI service asynchronously.
### Step 3: Edit Response
When data arrives, use `interaction.editReply(content)`.
{{% /steps %}}
@@ -45,6 +51,5 @@ export async function execute(interaction, controller) {
}
```
> [!WARNING]
> **API Costs:**
> [!WARNING] > **API Costs:**
> AI APIs are often billed by token. Implement strict user-based rate limits in your Controller to prevent abuse.
@@ -3,33 +3,36 @@ title: Interactive Voice Features
weight: 1
---
**Goal:** Implement sophisticated voice interactions that respect guild boundaries and manage ephemeral resources.
## 1. Resource Scoping Strategy
Voice features **MUST** be scoped to the Guild.
* **Pattern:** The Controller maintains a `Map<GuildID, Player>`.
* **Behavior:**
* **Join:** Create a Player for `Guild A` if none exists.
* **Leave:** Destroy the Player and release connections.
* **Isolation:** Commands in `Guild B` never affect `Guild A`.
- **Pattern:** The Controller maintains a `Map<GuildID, Player>`.
- **Behavior:**
- **Join:** Create a Player for `Guild A` if none exists.
- **Leave:** Destroy the Player and release connections.
- **Isolation:** Commands in `Guild B` never affect `Guild A`.
## 2. Command Orchestration
Voice features require a suite of commands coordinated via the Controller.
| Command | Action | Controller Logic |
| :--- | :--- | :--- |
| `/join` | Connect to channel | `createPlayer(guildId, channelId)` |
| Command | Action | Controller Logic |
| :------ | :----------------- | :---------------------------------- |
| `/join` | Connect to channel | `createPlayer(guildId, channelId)` |
| `/play` | Add track to queue | `getPlayer(guildId).enqueue(track)` |
| `/stop` | Disconnect | `destroyPlayer(guildId)` |
| `/stop` | Disconnect | `destroyPlayer(guildId)` |
## 3. State Persistence
Audio sessions are ephemeral.
* **Transient State:** Active audio stream (Lost on disconnect).
* **Durable State:** Queue/Playlist (Persisted in Controller until restart).
> [!TIP]
> **Resilience:** Separate the *Queue* logic from the *Voice Connection* logic. This allows the bot to reconnect without losing the user's playlist.
Audio sessions are ephemeral.
- **Transient State:** Active audio stream (Lost on disconnect).
- **Durable State:** Queue/Playlist (Persisted in Controller until restart).
> [!TIP] > **Resilience:** Separate the _Queue_ logic from the _Voice Connection_ logic. This allows the bot to reconnect without losing the user's playlist.
## 4. Implementation Sketch
@@ -55,4 +58,5 @@ class MusicController {
---
**See also:**
- [Discord.js Voice Guide](https://discordjs.guide/voice/)
@@ -3,11 +3,10 @@ title: Troubleshooting
weight: 5
---
**Quetza** troubleshooting focuses on diagnosing structural and conceptual issues. Because of its convention-based nature, most errors are silent (missing files) rather than noisy (stack traces).
## Common Issue Categories
{{% cards %}}
{{< card link="diagnostics" title="Diagnostics Guide" icon="exclamation" subtitle="Solutions for module discovery, command registration, and state leaks." >}}
{{< card link="diagnostics" title="Diagnostics Guide" icon="exclamation" subtitle="Solutions for module discovery, command registration, and state leaks." >}}
{{% /cards %}}
@@ -3,38 +3,45 @@ title: Diagnostics Guide
weight: 1
---
**Goal:** Identify and fix common pitfalls in Quetza development.
## 1. Module Discovery Failures
**Symptom:** The bot starts, but my module is ignored.
**Cause:** Violation of filesystem conventions.
{{% details title="Checklist: Directory Structure" closed="false" %}}
* [ ] Does `src/modules/<name>/` exist?
* [ ] Does `module.ts` export `name`?
* [ ] Are commands in `commands/` (not `cmds/`)?
* [ ] Did you run `pnpm build`? (Quetza runs from `dist/`).
{{% /details %}}
- [ ] Does `src/modules/<name>/` exist?
- [ ] Does `module.ts` export `name`?
- [ ] Are commands in `commands/` (not `cmds/`)?
- [ ] Did you run `pnpm build`? (Quetza runs from `dist/`).
{{% /details %}}
## 2. Command Registration Issues
**Symptom:** `/command` does not appear in Discord.
### Global vs. Guild Registration
* **Global:** Takes up to 1 hour to propagate.
* **Guild:** Instant update.
* **Fix:** Set `DEV_GUILD_ID` in your `.env` for development.
- **Global:** Takes up to 1 hour to propagate.
- **Guild:** Instant update.
- **Fix:** Set `DEV_GUILD_ID` in your `.env` for development.
### Data Malformation
Discord rejects invalid command structures silently or with generic errors.
* **Name:** Must be lowercase, alphanumeric, `-` only.
* **Description:** Required, max 100 chars.
- **Name:** Must be lowercase, alphanumeric, `-` only.
- **Description:** Required, max 100 chars.
## 3. Interaction Failures
**Symptom:** "The application did not respond".
**Cause:** Command took > 3 seconds without deferral.
**Solution:**
```typescript
// BAD
await longRunningTask();
@@ -47,19 +54,22 @@ await interaction.editReply('Done');
```
## 4. State Management Leaks
**Symptom:** Bot memory usage grows; cross-server weirdness.
**Cause:** Storing state globally instead of per-guild.
**Diagnostic:**
Check your Controller.
* ❌ `private player: AudioPlayer;` (Shared by everyone!)
* ✅ `private players = new Map<string, AudioPlayer>();` (Scoped by ID).
- ❌ `private player: AudioPlayer;` (Shared by everyone!)
- ✅ `private players = new Map<string, AudioPlayer>();` (Scoped by ID).
## 5. Event Handling
**Symptom:** Events fire multiple times or not at all.
* **Multiple:** Events are additive. Did you register the same handler in two modules?
* **None:** Did you export `name` and `execute` correctly?
> [!TIP]
> **Debug Mode:**
**Symptom:** Events fire multiple times or not at all.
- **Multiple:** Events are additive. Did you register the same handler in two modules?
- **None:** Did you export `name` and `execute` correctly?
> [!TIP] > **Debug Mode:**
> Enable verbose logging to see exactly which modules and commands are loaded at startup.
+4 -3
View File
@@ -1,6 +1,6 @@
---
title: "Root Infrastructure"
description: "Documentation for the root.ermnvldmr.com infrastructure."
title: 'Root Infrastructure'
description: 'Documentation for the root.ermnvldmr.com infrastructure.'
weight: 1
type: docs
cascade:
@@ -12,10 +12,11 @@ Comprehensive documentation for the personal infrastructure deployment and manag
## Sections
{{% cards %}}
{{< card link="recipes" title="Recipes" icon="beaker" subtitle="Collection of step-by-step procedures for infrastructure maintenance and deployment." >}}
{{< card link="recipes" title="Recipes" icon="beaker" subtitle="Collection of step-by-step procedures for infrastructure maintenance and deployment." >}}
{{% /cards %}}
---
**See also:**
- [GitHub: root.ermnvldmr.com](https://github.com/deytenit/root.ermnvldmr.com)
@@ -1,6 +1,6 @@
---
title: "Recipes"
description: "Collection of step-by-step procedures for infrastructure maintenance and deployment."
title: 'Recipes'
description: 'Collection of step-by-step procedures for infrastructure maintenance and deployment.'
weight: 50
---
@@ -9,10 +9,11 @@ Step-by-step instructions for reproducible tasks and infrastructure maintenance.
## Available Recipes
{{% cards %}}
{{< card link="initial-setup" title="Initial Host Setup" icon="server" subtitle="Hardened setup for a fresh Debian host from zero to deployment." >}}
{{< card link="initial-setup" title="Initial Host Setup" icon="server" subtitle="Hardened setup for a fresh Debian host from zero to deployment." >}}
{{% /cards %}}
---
**See also:**
- [GitHub: root.ermnvldmr.com](https://github.com/deytenit/root.ermnvldmr.com)
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more